"""MyVillage Activity Instruction API — one FastAPI process, GGUF via llama-cpp-python.""" import json import logging import os import re import secrets import threading import time import uuid from contextlib import asynccontextmanager from typing import Literal from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from huggingface_hub import hf_hub_download from llama_cpp import Llama from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator logging.basicConfig( level=os.getenv("LOG_LEVEL", "INFO").upper(), format="%(asctime)s %(levelname)s %(name)s %(message)s", ) logger = logging.getLogger("activity-instruction-api") # Exact evaluated merged checkpoint and its GGUF repository. SOURCE_MODEL = "mjpsm/activity-instruction-model-500-qwen3.5-2b" SOURCE_REVISION = "9ee80b07eb127e22c2008ea95fa75c43230b5c0e" MODEL_REPO = os.getenv("MODEL_REPO", SOURCE_MODEL + "-gguf") MODEL_FILENAME = os.getenv( "MODEL_FILENAME", "activity-instruction-model-500-qwen3.5-2b-Q4_K_M.gguf" ) MODEL_REVISION = os.getenv("MODEL_REVISION") or None N_CTX = int(os.getenv("N_CTX", "4096")) N_THREADS = int(os.getenv("N_THREADS", "2")) MAX_NEW_TOKENS = int(os.getenv("MAX_NEW_TOKENS", "192")) REPEAT_PENALTY = float(os.getenv("REPEAT_PENALTY", "1.0")) SEED = int(os.getenv("SEED", "42")) QUEUE_TIMEOUT = float(os.getenv("QUEUE_TIMEOUT", "90")) llm: Llama | None = None model_lock = threading.Lock() # Exact baseline system prompt from the final 40-case evaluation and GGUF regression. SYSTEM_INSTRUCTION = '''You are the MyVillage Activity Instruction Model. You receive the Activity Progression Model output and the Activity Composition Model output. Generate clear, actionable instructions for the planned activity. Rules: - Follow the progression activity_plan.action and target. - Address the next_gap. - Respect demonstrated_state and avoid_repeating. - Do not assign already-completed work again. - Do not invent unsupported requirements or details. - Preserve uncertainty when results are not yet known. - Return between 1 and 5 concise instructions. - Return valid JSON only, without explanations. Required output: {"output":{"instructions":["instruction 1","instruction 2"]}}''' class StrictModel(BaseModel): model_config = ConfigDict(extra="forbid") class ActivityPlan(StrictModel): action: str = Field(min_length=1) target: str = Field(min_length=1) avoid_repeating: list[str] class Progression(StrictModel): knowledge_state: Literal["COMPLETED", "PARTIAL", "UNCLEAR", "BLOCKED", "ADVANCED"] demonstrated_state: list[str] next_gap: str = Field(min_length=1) activity_plan: ActivityPlan class Composition(StrictModel): activity_title: str = Field(min_length=1) activity_description: str = Field(min_length=1) class InstructionRequest(StrictModel): progression: Progression composition: Composition class InstructionOutput(StrictModel): instructions: list[str] = Field(min_length=1, max_length=5) @field_validator("instructions") @classmethod def ensure_nonempty(cls, items: list[str]) -> list[str]: if any(not isinstance(v, str) or not v.strip() for v in items): raise ValueError("Every instruction must be a nonempty string") return items class InstructionResponse(StrictModel): output: InstructionOutput def build_prompt(payload: dict) -> str: """Qwen3.5 two-message chat framing, with its non-thinking assistant prefix. Composition uses a raw INPUT/OUTPUT prompt. The Instruction model was evaluated as system + user chat, with enable_thinking=False. The original Qwen3.5 chat template in that mode opens the assistant with an empty block. We reproduce that explicitly for create_completion. Re-check equivalence against the saved GGUF regressions when deployed. """ user_json = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) return ( "<|im_start|>system\n" + SYSTEM_INSTRUCTION + "<|im_end|>\n" "<|im_start|>user\n" + user_json + "<|im_end|>\n" "<|im_start|>assistant\n\n\n\n\n" ) def extract_json(text: str) -> dict: cleaned = re.sub(r"^\s*.*?\s*", "", text, flags=re.DOTALL).strip() fenced = re.fullmatch(r"```(?:json)?\s*(.*?)\s*```", cleaned, re.DOTALL | re.IGNORECASE) if fenced: cleaned = fenced.group(1).strip() value = json.loads(cleaned) # Reject arbitrary preamble/trailing non-JSON text. if not isinstance(value, dict): raise ValueError("Expected a JSON object") return value def load_model() -> Llama: token = os.getenv("HF_TOKEN") or None logger.info("Downloading GGUF: repo=%s filename=%s revision=%s", MODEL_REPO, MODEL_FILENAME, MODEL_REVISION) path = hf_hub_download( repo_id=MODEL_REPO, filename=MODEL_FILENAME, revision=MODEL_REVISION, token=token, ) logger.info("Loading model in-process with llama-cpp-python (n_ctx=%s n_threads=%s)", N_CTX, N_THREADS) instance = Llama( model_path=path, n_ctx=N_CTX, n_threads=N_THREADS, n_gpu_layers=0, # CPU wheel; same architecture as Composition API. seed=SEED, verbose=False, ) logger.info("GGUF model loaded successfully") return instance @asynccontextmanager async def lifespan(app: FastAPI): global llm llm = load_model() try: yield finally: llm = None app = FastAPI( title="MyVillage Activity Instruction API", version="1.1.0", description="Generate instructions from Progression + Composition using a GGUF via llama-cpp-python.", lifespan=lifespan, ) origins = os.getenv("ALLOWED_ORIGINS", "*").strip() app.add_middleware( CORSMiddleware, allow_origins=["*"] if origins == "*" else [x.strip() for x in origins.split(",") if x.strip()], allow_credentials=False, allow_methods=["*"], allow_headers=["*"], ) @app.middleware("http") async def request_logging(request: Request, call_next): request_id = request.headers.get("x-request-id") or str(uuid.uuid4()) start = time.perf_counter() if request.url.path in ("/generate", "/predict"): required_key = os.getenv("API_KEY") if required_key and not secrets.compare_digest(request.headers.get("x-api-key", ""), required_key): from fastapi.responses import JSONResponse response = JSONResponse(status_code=401, content={"detail": "Invalid or missing API key."}) response.headers["x-request-id"] = request_id return response response = await call_next(request) response.headers["x-request-id"] = request_id logger.info("%s %s status=%s request_id=%s elapsed=%.3fs", request.method, request.url.path, response.status_code, request_id, time.perf_counter()-start) return response def generate_instructions(payload: dict) -> InstructionResponse: if llm is None: raise HTTPException(status_code=503, detail="Model has not loaded yet.") prompt = build_prompt(payload) acquired = model_lock.acquire(timeout=QUEUE_TIMEOUT) if not acquired: raise HTTPException(status_code=503, detail="Model is busy. Retry shortly.") try: result = llm.create_completion( prompt=prompt, max_tokens=MAX_NEW_TOKENS, temperature=0.0, repeat_penalty=REPEAT_PENALTY, seed=SEED, echo=False, stop=["<|im_end|>", "<|im_start|>"], ) except Exception as exc: logger.exception("llama-cpp-python inference failed") raise HTTPException(status_code=500, detail="Model inference failed.") from exc finally: model_lock.release() try: choice = result["choices"][0] if choice.get("finish_reason") == "length": raise ValueError("Generation truncated at max_tokens") obj = extract_json(choice["text"]) return InstructionResponse.model_validate(obj) except (KeyError, IndexError, TypeError, ValueError, ValidationError) as exc: logger.warning("Model returned invalid instruction JSON/schema: %s", type(exc).__name__) raise HTTPException(status_code=502, detail="Model returned invalid or nonconforming instruction JSON.") from exc @app.get("/") def root(): return { "name": "MyVillage Activity Instruction API", "status": "online" if llm is not None else "starting", "model_repo": MODEL_REPO, "model_file": MODEL_FILENAME, "quantization": "Q4_K_M" if "Q4_K_M" in MODEL_FILENAME.upper() else ("Q8_0" if "Q8_0" in MODEL_FILENAME.upper() else "other"), "runtime": "llama-cpp-python", "docs": "/docs", "health": "/health", "generate": "/generate", } @app.get("/health") def health(): if llm is None: raise HTTPException(status_code=503, detail="Model is not loaded yet.") return {"status": "ok", "model_loaded": True, "model_repo": MODEL_REPO, "model_file": MODEL_FILENAME} @app.get("/model") def model_info(): return { "source_model": SOURCE_MODEL, "source_revision": SOURCE_REVISION, "gguf_repo": MODEL_REPO, "gguf_file": MODEL_FILENAME, "gguf_revision": MODEL_REVISION, "runtime": "llama-cpp-python", "n_ctx": N_CTX, "n_threads": N_THREADS, "max_new_tokens": MAX_NEW_TOKENS, "temperature": 0.0, "repeat_penalty": REPEAT_PENALTY, "seed": SEED, "thinking": False, } @app.post("/generate", response_model=InstructionResponse) def generate(request: InstructionRequest): return generate_instructions(request.model_dump(mode="json")) @app.post("/predict", response_model=InstructionResponse, include_in_schema=False) def predict(request: InstructionRequest): return generate(request)