mjpsm commited on
Commit
e1fc9bc
·
verified ·
1 Parent(s): d66225e

Initial commit: Activity Composition API (FastAPI + llama-cpp-python)

Browse files
Files changed (9) hide show
  1. .dockerignore +7 -0
  2. .gitignore +7 -0
  3. DEPLOYMENT.md +67 -0
  4. Dockerfile +18 -0
  5. README.md +91 -6
  6. example_request.json +18 -0
  7. main.py +389 -0
  8. requirements.txt +6 -0
  9. test_api.py +46 -0
.dockerignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ .git
2
+ __pycache__
3
+ *.pyc
4
+ .venv
5
+ venv
6
+ .env
7
+ *.gguf
.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ .env
6
+ .DS_Store
7
+ *.gguf
DEPLOYMENT.md ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Deployment
2
+
3
+ ## 1. Create the Space
4
+
5
+ Create a Hugging Face Space named:
6
+
7
+ `activity-composition-api`
8
+
9
+ Choose:
10
+
11
+ - SDK: Docker
12
+
13
+ The expected URL is:
14
+
15
+ `https://mjpsm-activity-composition-api.hf.space`
16
+
17
+ ## 2. Upload these files
18
+
19
+ ```text
20
+ activity-composition-api-llama-python/
21
+ ├── main.py
22
+ ├── requirements.txt
23
+ ├── Dockerfile
24
+ ├── README.md
25
+ ├── test_api.py
26
+ ├── example_request.json
27
+ ├── .dockerignore
28
+ └── .gitignore
29
+ ```
30
+
31
+ ## 3. Private model repository only
32
+
33
+ If the GGUF repository is private:
34
+
35
+ **Space → Settings → Variables and secrets → New secret**
36
+
37
+ Add:
38
+
39
+ `HF_TOKEN`
40
+
41
+ with read access to:
42
+
43
+ `mjpsm/activity-composition-model-400-qwen3.5-0.8b-gguf`
44
+
45
+ ## 4. What starts
46
+
47
+ Only this starts:
48
+
49
+ ```bash
50
+ uvicorn main:app --host 0.0.0.0 --port 7860 --workers 1
51
+ ```
52
+
53
+ There is no separate llama-server.
54
+
55
+ FastAPI imports `llama_cpp.Llama`, downloads the GGUF once, and loads it directly into the Python process.
56
+
57
+ ## 5. Test
58
+
59
+ Open:
60
+
61
+ `https://mjpsm-activity-composition-api.hf.space/health`
62
+
63
+ Then:
64
+
65
+ `https://mjpsm-activity-composition-api.hf.space/docs`
66
+
67
+ Use `POST /generate`.
Dockerfile ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1
4
+ ENV PYTHONUNBUFFERED=1
5
+ ENV PIP_NO_CACHE_DIR=1
6
+
7
+ WORKDIR /app
8
+
9
+ COPY requirements.txt /app/requirements.txt
10
+
11
+ RUN pip install --upgrade pip \
12
+ && pip install -r /app/requirements.txt
13
+
14
+ COPY main.py /app/main.py
15
+
16
+ EXPOSE 7860
17
+
18
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
README.md CHANGED
@@ -1,10 +1,95 @@
1
  ---
2
- title: Activity Composition Api
3
- emoji: 🐨
4
- colorFrom: purple
5
- colorTo: pink
6
  sdk: docker
7
- pinned: false
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: MyVillage Activity Composition API
3
+ emoji: 🧩
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: docker
7
+ app_port: 7860
8
+ models:
9
+ - mjpsm/activity-composition-model-400-qwen3.5-0.8b-gguf
10
  ---
11
 
12
+ # MyVillage Activity Composition API
13
+
14
+ A simple FastAPI API that loads the Q8_0 GGUF directly with `llama-cpp-python`.
15
+
16
+ There is **no separate llama-server process**.
17
+
18
+ The only web server is Uvicorn:
19
+
20
+ ```text
21
+ uvicorn main:app --host 0.0.0.0 --port 7860
22
+ ```
23
+
24
+ ## Model
25
+
26
+ - Source model: `mjpsm/activity-composition-model-400-qwen3.5-0.8b`
27
+ - GGUF repo: `mjpsm/activity-composition-model-400-qwen3.5-0.8b-gguf`
28
+ - GGUF file: `activity-composition-model-400-qwen3.5-0.8b-Q8_0.gguf`
29
+ - Runtime: `llama-cpp-python`
30
+
31
+ ## Endpoints
32
+
33
+ - `GET /`
34
+ - `GET /health`
35
+ - `GET /model`
36
+ - `POST /generate`
37
+ - `GET /docs`
38
+
39
+ ## Request
40
+
41
+ ```json
42
+ {
43
+ "progression": {
44
+ "knowledge_state": "PARTIAL",
45
+ "demonstrated_state": [
46
+ "Created the initial animation and added the first set of assets"
47
+ ],
48
+ "next_gap": "Finish and polish the remaining animation work",
49
+ "activity_plan": {
50
+ "action": "complete the remaining animation work",
51
+ "target": "the animation",
52
+ "success_evidence": "a completed and polished animation",
53
+ "avoid_repeating": [
54
+ "recreating the initial animation",
55
+ "re-adding assets already included"
56
+ ]
57
+ }
58
+ }
59
+ }
60
+ ```
61
+
62
+ ## Response
63
+
64
+ ```json
65
+ {
66
+ "output": {
67
+ "activity_title": "string",
68
+ "activity_description": "string"
69
+ }
70
+ }
71
+ ```
72
+
73
+ ## Private GGUF repo
74
+
75
+ If the GGUF repo is private, add a Space secret named:
76
+
77
+ `HF_TOKEN`
78
+
79
+ with read access to the model repository.
80
+
81
+ ## Architecture
82
+
83
+ ```text
84
+ Client
85
+ ↓
86
+ Uvicorn
87
+ ↓
88
+ FastAPI
89
+ ↓
90
+ llama-cpp-python
91
+ ↓
92
+ Q8_0 GGUF
93
+ ↓
94
+ validated Composition JSON
95
+ ```
example_request.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "progression": {
3
+ "knowledge_state": "PARTIAL",
4
+ "demonstrated_state": [
5
+ "Created the initial animation and added the first set of assets"
6
+ ],
7
+ "next_gap": "Finish and polish the remaining animation work",
8
+ "activity_plan": {
9
+ "action": "complete the remaining animation work",
10
+ "target": "the animation",
11
+ "success_evidence": "a completed and polished animation",
12
+ "avoid_repeating": [
13
+ "recreating the initial animation",
14
+ "re-adding assets already included"
15
+ ]
16
+ }
17
+ }
18
+ }
main.py ADDED
@@ -0,0 +1,389 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import logging
3
+ import os
4
+ import re
5
+ import threading
6
+ import time
7
+ import uuid
8
+ from contextlib import asynccontextmanager
9
+ from enum import Enum
10
+ from typing import List
11
+
12
+ from fastapi import FastAPI, HTTPException, Request
13
+ from fastapi.middleware.cors import CORSMiddleware
14
+ from huggingface_hub import hf_hub_download
15
+ from llama_cpp import Llama
16
+ from pydantic import BaseModel, ConfigDict, Field
17
+
18
+
19
+ # ============================================================
20
+ # Logging
21
+ # ============================================================
22
+
23
+ logging.basicConfig(
24
+ level=os.getenv("LOG_LEVEL", "INFO").upper(),
25
+ format="%(asctime)s %(levelname)s %(name)s %(message)s",
26
+ )
27
+
28
+ logger = logging.getLogger("activity-composition-api")
29
+
30
+
31
+ # ============================================================
32
+ # Model configuration
33
+ # ============================================================
34
+
35
+ MODEL_REPO = os.getenv(
36
+ "MODEL_REPO",
37
+ "mjpsm/activity-composition-model-400-qwen3.5-0.8b-gguf",
38
+ )
39
+
40
+ MODEL_FILENAME = os.getenv(
41
+ "MODEL_FILENAME",
42
+ "activity-composition-model-400-qwen3.5-0.8b-Q8_0.gguf",
43
+ )
44
+
45
+ N_CTX = int(os.getenv("N_CTX", "2048"))
46
+ N_THREADS = int(os.getenv("N_THREADS", "2"))
47
+ MAX_NEW_TOKENS = int(os.getenv("MAX_NEW_TOKENS", "220"))
48
+ REPEAT_PENALTY = float(os.getenv("REPEAT_PENALTY", "1.05"))
49
+ SEED = int(os.getenv("SEED", "42"))
50
+
51
+ llm: Llama | None = None
52
+ model_lock = threading.Lock()
53
+
54
+
55
+ # ============================================================
56
+ # Exact Composition prompt used during training/evaluation
57
+ # ============================================================
58
+
59
+ SYSTEM_INSTRUCTION = """You are the MyVillage Activity Composition Model.
60
+ The Activity Progression Model has already decided what the villager should do next.
61
+ Your only job is to compose that progression into a short activity title and a one-sentence activity description.
62
+ Return ONLY valid JSON.
63
+
64
+ Required output schema:
65
+ {
66
+ "output": {
67
+ "activity_title": "string",
68
+ "activity_description": "string"
69
+ }
70
+ }
71
+
72
+ Rules:
73
+ - Trust the progression input. Do not choose a different next activity.
74
+ - Base the title and description only on the progression provided.
75
+ - Keep the title short, clear, professional, and villager-friendly.
76
+ - The title should represent the progression itself and should avoid unnecessary project or program names when the activity remains clear without them.
77
+ - The description must be exactly one explanatory sentence about what the activity is.
78
+ - Write the description descriptively, not as a direct command or step-by-step instruction.
79
+ - Descriptive noun phrases and gerund constructions are preferred when natural.
80
+ - Do not summarize what the villager previously accomplished.
81
+ - Do not use phrases such as 'You previously', 'You learned', 'You successfully', 'You discovered', or 'You already'.
82
+ - Do not assign work listed in avoid_repeating.
83
+ - Do not turn demonstrated_state into new work.
84
+ - Do not invent unsupported goals, requirements, tools, deliverables, or outcomes.
85
+ - Do not generate instructions, numbered steps, bullets, or commentary.
86
+ """
87
+
88
+
89
+ # ============================================================
90
+ # API schemas
91
+ # ============================================================
92
+
93
+ class KnowledgeState(str, Enum):
94
+ COMPLETED = "COMPLETED"
95
+ PARTIAL = "PARTIAL"
96
+ UNCLEAR = "UNCLEAR"
97
+ BLOCKED = "BLOCKED"
98
+ ADVANCED = "ADVANCED"
99
+
100
+
101
+ class ActivityPlan(BaseModel):
102
+ model_config = ConfigDict(extra="forbid")
103
+
104
+ action: str = Field(min_length=1)
105
+ target: str = Field(min_length=1)
106
+ success_evidence: str = Field(min_length=1)
107
+ avoid_repeating: List[str] = Field(default_factory=list)
108
+
109
+
110
+ class Progression(BaseModel):
111
+ model_config = ConfigDict(extra="forbid")
112
+
113
+ knowledge_state: KnowledgeState
114
+ demonstrated_state: List[str] = Field(default_factory=list)
115
+ next_gap: str = Field(min_length=1)
116
+ activity_plan: ActivityPlan
117
+
118
+
119
+ class CompositionRequest(BaseModel):
120
+ model_config = ConfigDict(extra="forbid")
121
+
122
+ progression: Progression
123
+
124
+
125
+ class CompositionOutput(BaseModel):
126
+ model_config = ConfigDict(extra="forbid")
127
+
128
+ activity_title: str = Field(min_length=1)
129
+ activity_description: str = Field(min_length=1)
130
+
131
+
132
+ class CompositionResponse(BaseModel):
133
+ model_config = ConfigDict(extra="forbid")
134
+
135
+ output: CompositionOutput
136
+
137
+
138
+ # ============================================================
139
+ # Prompt helpers
140
+ # ============================================================
141
+
142
+ def compact_json(value: dict) -> str:
143
+ return json.dumps(
144
+ value,
145
+ ensure_ascii=False,
146
+ separators=(",", ":"),
147
+ )
148
+
149
+
150
+ def build_prompt(input_obj: dict) -> str:
151
+ return (
152
+ SYSTEM_INSTRUCTION.strip()
153
+ + "\n\nINPUT:\n"
154
+ + compact_json(input_obj)
155
+ + "\n\nOUTPUT:\n"
156
+ )
157
+
158
+
159
+ def extract_json(text: str):
160
+ decoder = json.JSONDecoder()
161
+
162
+ for match in re.finditer(r"\{", text):
163
+ try:
164
+ obj, _ = decoder.raw_decode(text[match.start():])
165
+ if isinstance(obj, dict):
166
+ return obj
167
+ except json.JSONDecodeError:
168
+ pass
169
+
170
+ return None
171
+
172
+
173
+ # ============================================================
174
+ # Load GGUF directly with llama-cpp-python
175
+ # ============================================================
176
+
177
+ def load_model() -> Llama:
178
+ logger.info(
179
+ "Downloading GGUF repo=%s filename=%s",
180
+ MODEL_REPO,
181
+ MODEL_FILENAME,
182
+ )
183
+
184
+ model_path = hf_hub_download(
185
+ repo_id=MODEL_REPO,
186
+ filename=MODEL_FILENAME,
187
+ token=os.getenv("HF_TOKEN") or None,
188
+ )
189
+
190
+ logger.info("Loading GGUF with llama-cpp-python")
191
+
192
+ model = Llama(
193
+ model_path=model_path,
194
+ n_ctx=N_CTX,
195
+ n_threads=N_THREADS,
196
+ n_gpu_layers=0,
197
+ seed=SEED,
198
+ verbose=False,
199
+ )
200
+
201
+ logger.info("Model loaded successfully")
202
+ return model
203
+
204
+
205
+ @asynccontextmanager
206
+ async def lifespan(app: FastAPI):
207
+ global llm
208
+
209
+ llm = load_model()
210
+
211
+ yield
212
+
213
+ llm = None
214
+
215
+
216
+ # ============================================================
217
+ # FastAPI
218
+ # ============================================================
219
+
220
+ app = FastAPI(
221
+ title="MyVillage Activity Composition API",
222
+ version="1.0.0",
223
+ description=(
224
+ "Converts Activity Progression Model output into an activity title "
225
+ "and one-sentence activity description."
226
+ ),
227
+ lifespan=lifespan,
228
+ )
229
+
230
+
231
+ # ============================================================
232
+ # CORS
233
+ # ============================================================
234
+
235
+ origins = os.getenv("ALLOWED_ORIGINS", "*").strip()
236
+
237
+ app.add_middleware(
238
+ CORSMiddleware,
239
+ allow_origins=["*"] if origins == "*" else [
240
+ origin.strip()
241
+ for origin in origins.split(",")
242
+ if origin.strip()
243
+ ],
244
+ allow_credentials=False,
245
+ allow_methods=["*"],
246
+ allow_headers=["*"],
247
+ )
248
+
249
+
250
+ # ============================================================
251
+ # Request logging
252
+ # ============================================================
253
+
254
+ @app.middleware("http")
255
+ async def request_logging(request: Request, call_next):
256
+ request_id = request.headers.get("x-request-id") or str(uuid.uuid4())
257
+ started = time.perf_counter()
258
+
259
+ response = await call_next(request)
260
+
261
+ elapsed = time.perf_counter() - started
262
+ response.headers["x-request-id"] = request_id
263
+
264
+ logger.info(
265
+ "%s %s status=%s request_id=%s elapsed=%.4fs",
266
+ request.method,
267
+ request.url.path,
268
+ response.status_code,
269
+ request_id,
270
+ elapsed,
271
+ )
272
+
273
+ return response
274
+
275
+
276
+ # ============================================================
277
+ # Inference
278
+ # ============================================================
279
+
280
+ def generate_composition(input_obj: dict) -> CompositionResponse:
281
+ if llm is None:
282
+ raise HTTPException(
283
+ status_code=503,
284
+ detail="Model is not loaded yet.",
285
+ )
286
+
287
+ prompt = build_prompt(input_obj)
288
+
289
+ try:
290
+ with model_lock:
291
+ result = llm.create_completion(
292
+ prompt=prompt,
293
+ max_tokens=MAX_NEW_TOKENS,
294
+ temperature=0.0,
295
+ repeat_penalty=REPEAT_PENALTY,
296
+ seed=SEED,
297
+ echo=False,
298
+ )
299
+ except Exception as exc:
300
+ logger.exception("llama-cpp-python inference failed")
301
+ raise HTTPException(
302
+ status_code=500,
303
+ detail="Model inference failed.",
304
+ ) from exc
305
+
306
+ raw_text = result["choices"][0]["text"].strip()
307
+ parsed = extract_json(raw_text)
308
+
309
+ if parsed is None:
310
+ logger.error("Model returned non-JSON output: %r", raw_text[:2000])
311
+ raise HTTPException(
312
+ status_code=502,
313
+ detail="Model returned invalid JSON.",
314
+ )
315
+
316
+ try:
317
+ return CompositionResponse.model_validate(parsed)
318
+ except Exception as exc:
319
+ logger.error(
320
+ "Model output failed schema validation: %s raw=%r",
321
+ exc,
322
+ raw_text[:2000],
323
+ )
324
+ raise HTTPException(
325
+ status_code=502,
326
+ detail="Model returned JSON with an invalid schema.",
327
+ ) from exc
328
+
329
+
330
+ # ============================================================
331
+ # Routes
332
+ # ============================================================
333
+
334
+ @app.get("/")
335
+ def root():
336
+ return {
337
+ "name": "MyVillage Activity Composition API",
338
+ "status": "online",
339
+ "model_repo": MODEL_REPO,
340
+ "model_file": MODEL_FILENAME,
341
+ "quantization": "Q8_0",
342
+ "docs": "/docs",
343
+ "health": "/health",
344
+ "generate": "/generate",
345
+ }
346
+
347
+
348
+ @app.get("/health")
349
+ def health():
350
+ if llm is None:
351
+ raise HTTPException(
352
+ status_code=503,
353
+ detail="Model is not loaded yet.",
354
+ )
355
+
356
+ return {
357
+ "status": "ok",
358
+ "model_loaded": True,
359
+ "model_repo": MODEL_REPO,
360
+ "model_file": MODEL_FILENAME,
361
+ "quantization": "Q8_0",
362
+ }
363
+
364
+
365
+ @app.get("/model")
366
+ def model_info():
367
+ return {
368
+ "source_model": "mjpsm/activity-composition-model-400-qwen3.5-0.8b",
369
+ "gguf_repo": MODEL_REPO,
370
+ "gguf_file": MODEL_FILENAME,
371
+ "quantization": "Q8_0",
372
+ "runtime": "llama-cpp-python",
373
+ "n_ctx": N_CTX,
374
+ "n_threads": N_THREADS,
375
+ "max_new_tokens": MAX_NEW_TOKENS,
376
+ "temperature": 0.0,
377
+ "repeat_penalty": REPEAT_PENALTY,
378
+ "seed": SEED,
379
+ }
380
+
381
+
382
+ @app.post(
383
+ "/generate",
384
+ response_model=CompositionResponse,
385
+ )
386
+ def generate(request: CompositionRequest):
387
+ return generate_composition(
388
+ request.model_dump(mode="json")
389
+ )
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+
2
+ fastapi
3
+ uvicorn
4
+ pydantic
5
+ huggingface_hub
6
+ llama-cpp-python
test_api.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+
4
+ import requests
5
+
6
+ BASE_URL = os.getenv(
7
+ "COMPOSITION_API_URL",
8
+ "http://localhost:7860",
9
+ ).rstrip("/")
10
+
11
+ payload = {
12
+ "progression": {
13
+ "knowledge_state": "PARTIAL",
14
+ "demonstrated_state": [
15
+ "Created the initial animation and added the first set of assets"
16
+ ],
17
+ "next_gap": "Finish and polish the remaining animation work",
18
+ "activity_plan": {
19
+ "action": "complete the remaining animation work",
20
+ "target": "the animation",
21
+ "success_evidence": "a completed and polished animation",
22
+ "avoid_repeating": [
23
+ "recreating the initial animation",
24
+ "re-adding assets already included",
25
+ ],
26
+ },
27
+ }
28
+ }
29
+
30
+ print("HEALTH")
31
+ health = requests.get(
32
+ f"{BASE_URL}/health",
33
+ timeout=30,
34
+ )
35
+ print(health.status_code)
36
+ print(json.dumps(health.json(), indent=2))
37
+
38
+ print("\nGENERATE")
39
+ response = requests.post(
40
+ f"{BASE_URL}/generate",
41
+ json=payload,
42
+ timeout=180,
43
+ )
44
+
45
+ print(response.status_code)
46
+ print(json.dumps(response.json(), indent=2))