File size: 13,364 Bytes
f46f3ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b5463b9
f46f3ea
98b2fce
 
 
 
f46f3ea
 
 
ca459cc
f46f3ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33b6fdf
f46f3ea
 
 
 
 
 
 
 
 
33b6fdf
f46f3ea
33b6fdf
f46f3ea
 
 
33b6fdf
 
f46f3ea
33b6fdf
f46f3ea
33b6fdf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f46f3ea
33b6fdf
 
f46f3ea
 
33b6fdf
f46f3ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
# worker.py β€” FastAPI generation worker for HuggingFace Docker Space
# Receives job requests, streams from OpenRouter, calls back PythonAnywhere when done.

import os
import re
import json
import time
import logging
import threading
import requests

from datetime import datetime
from fastapi import FastAPI, BackgroundTasks, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel

# ============================================================
#  LOGGING
# ============================================================
logging.basicConfig(
    level=logging.DEBUG,
    format='%(asctime)s [%(levelname)s] %(name)s: %(message)s'
)
logger = logging.getLogger(__name__)

app = FastAPI()

# ============================================================
#  CONFIG β€” set these as HF Space secrets
# ============================================================
OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY", "")
OPENROUTER_BASE    = "https://openrouter.ai/api/v1"
OPENROUTER_MODEL = os.environ.get(
    "OPENROUTER_MODEL",
    "meta-llama/llama-3-8b-instruct:free"  # confirmed free tier
)

# PythonAnywhere callback β€” HF Space POSTs results here when done
# Set this as a secret: e.g. https://yourname.pythonanywhere.com/api/generation-callback
CALLBACK_BASE = os.environ.get("CALLBACK_BASE", "https://smilystudy.pythonanywhere.com")

# Shared secret so PA knows the callback is legit
CALLBACK_SECRET = os.environ.get("CALLBACK_SECRET", "change-me-please")

MAX_TOKENS   = 16000
TEMPERATURE  = 0.55
TOP_P        = 0.9

COURSE_SYSTEM_PROMPT = """You are SupaNova, an expert AI curriculum designer built by SmilyAI Labs.

A user will tell you a topic they want to learn. Your job:

1. Inside <think> tags, reason step by step about: the right scope for a 
   beginner-to-intermediate learner, a logical order of lessons, and what 
   each lesson should cover. Be thorough but efficient.

2. After closing </think>, output ONLY a single valid JSON object β€” nothing 
   else. No markdown code fences, no commentary, no text before or after it.
   The JSON must exactly follow this structure:

{
  "title": "Course title",
  "description": "A one or two sentence description of the course",
  "lessons": [
    {
      "title": "Lesson title",
      "content": "Full lesson content in markdown, at least 300 words, 
                  using ## headers, bullet points, and concrete examples."
    }
  ]
}

Include between 5 and 10 lessons. Make every lesson genuinely educational,
well organised, and self-contained. Output valid JSON only after </think>."""

# ============================================================
#  IN-MEMORY JOB STATE  (HF Spaces don't restart randomly like PA)
# ============================================================
# job_id -> {"status": "running"|"done"|"error", "result": ..., "error": ...}
_jobs: dict[str, dict] = {}
_jobs_lock = threading.Lock()


def set_job(job_id: str, data: dict):
    with _jobs_lock:
        _jobs[job_id] = data


def get_job(job_id: str) -> dict | None:
    with _jobs_lock:
        return _jobs.get(job_id)


def update_job(job_id: str, **kwargs):
    with _jobs_lock:
        if job_id in _jobs:
            _jobs[job_id].update(kwargs)


# ============================================================
#  HELPERS
# ============================================================

def openrouter_headers() -> dict:
    return {
        "Authorization": f"Bearer {OPENROUTER_API_KEY}",
        "Content-Type":  "application/json",
        "HTTP-Referer":  "https://smilystudy.app",
        "X-Title":       "SmilyStudy",
    }


class ThinkFilterStream:
    """Strips <think>…</think> blocks and returns only the final JSON text."""

    def __init__(self):
        self.buffer   = ""
        self.in_think = False

    def feed(self, new_chars: str):
        self.buffer += new_chars
        yield from self._process()

    def _process(self):
        while self.buffer:
            if self.in_think:
                end = self.buffer.find("</think>")
                if end == -1:
                    # Keep a safe tail in case the tag is split across chunks
                    safe = self.buffer.rfind("<")
                    if safe > 0:
                        self.buffer = self.buffer[safe:]
                    return
                self.in_think = False
                self.buffer   = self.buffer[end + len("</think>"):]
            else:
                start = self.buffer.find("<think>")
                if start == -1:
                    safe = self.buffer.rfind("<")
                    if safe == -1:
                        yield self.buffer
                        self.buffer = ""
                    else:
                        yield self.buffer[:safe]
                        self.buffer = self.buffer[safe:]
                    return
                yield self.buffer[:start]
                self.in_think = True
                self.buffer   = self.buffer[start + len("<think>"):]

    def flush(self):
        if self.buffer and not self.in_think:
            yield self.buffer
        self.buffer = ""


def extract_json(text: str) -> dict | None:
    text = text.strip()
    # Strip markdown fences if any
    text = re.sub(r"^```(?:json)?", "", text, flags=re.IGNORECASE).strip()
    text = re.sub(r"```$", "", text).strip()

    # Direct parse
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        pass

    # Find first { … last }
    start = text.find("{")
    end   = text.rfind("}")
    if start != -1 and end > start:
        try:
            return json.loads(text[start:end + 1])
        except json.JSONDecodeError as e:
            logger.error(f"extract_json fallback failed: {e}")

    return None


def _send_callback(job_id: str, payload: dict):
    """POST result back to PythonAnywhere. Fire-and-forget with retries."""
    if not CALLBACK_BASE:
        logger.warning("CALLBACK_BASE not set β€” skipping callback")
        return

    url = f"{CALLBACK_BASE}/api/generation-callback"
    headers = {
        "Content-Type":    "application/json",
        "X-Callback-Secret": CALLBACK_SECRET,
    }

    for attempt in range(5):
        try:
            resp = requests.post(url, json=payload, headers=headers, timeout=30)
            if resp.status_code == 200:
                logger.info(f"[{job_id}] Callback delivered on attempt {attempt+1}")
                return
            logger.warning(
                f"[{job_id}] Callback attempt {attempt+1} got {resp.status_code}"
            )
        except Exception as e:
            logger.warning(f"[{job_id}] Callback attempt {attempt+1} error: {e}")

        time.sleep(2 ** attempt)   # exponential back-off: 1s, 2s, 4s, 8s, 16s

    logger.error(f"[{job_id}] All callback attempts failed")


# ============================================================
#  CORE GENERATION  (runs in a daemon thread inside HF Space)
# ============================================================

def _run_generation(job_id: str, user_id: int, topic: str):
    logger.info(f"[{job_id}] === GENERATION START === topic={topic!r}")

    def finish_error(message: str):
        update_job(job_id, status="error", error=message)
        _send_callback(job_id, {
            "job_id":  job_id,
            "user_id": user_id,
            "status":  "error",
            "error":   message,
        })

    def finish_ok(course_data: dict):
        update_job(job_id, status="done", result=course_data)
        _send_callback(job_id, {
            "job_id":      job_id,
            "user_id":     user_id,
            "status":      "done",
            "course_data": course_data,
        })

    try:
        if not OPENROUTER_API_KEY:
            finish_error("OpenRouter API key not configured on worker.")
            return

        payload = {
            "model":    OPENROUTER_MODEL,
            "stream":   False,  # ← NO STREAMING
            "messages": [
                {"role": "system", "content": COURSE_SYSTEM_PROMPT},
                {"role": "user",   "content": f"I want to learn: {topic}"},
            ],
            "max_tokens":  MAX_TOKENS,
            "temperature": TEMPERATURE,
            "top_p":       TOP_P,
        }

        logger.info(f"[{job_id}] Calling OpenRouter (no stream)...")

        resp = requests.post(
            f"{OPENROUTER_BASE}/chat/completions",
            headers=openrouter_headers(),
            json=payload,
            timeout=600,  # 10 min timeout, one shot
        )

        logger.info(f"[{job_id}] HTTP {resp.status_code}")

        if resp.status_code != 200:
            finish_error(f"API error {resp.status_code}: {resp.text[:300]}")
            return

        data = resp.json()

        # Check for API-level error
        if "error" in data:
            err = data["error"]
            msg = err.get("message", str(err)) if isinstance(err, dict) else str(err)
            logger.error(f"[{job_id}] API error: {msg}")
            finish_error(f"API error: {msg}")
            return

        # Extract content from response
        choices = data.get("choices") or []
        if not choices:
            finish_error("No response from model.")
            return

        content = choices[0].get("message", {}).get("content", "")
        if not content:
            finish_error("Model returned empty content.")
            return

        logger.info(f"[{job_id}] Got {len(content)} chars from model")
        logger.debug(f"[{job_id}] First 500 chars: {content[:500]}")

        # Parse JSON
        course_data = extract_json(content)
        if not course_data or "lessons" not in course_data:
            logger.error(f"[{job_id}] Failed to parse JSON")
            logger.debug(f"[{job_id}] Full content:\n{content}")
            finish_error("Model returned invalid JSON. Please try again.")
            return

        if not course_data.get("lessons"):
            finish_error("No lessons were generated.")
            return

        logger.info(f"[{job_id}] βœ“ {len(course_data['lessons'])} lessons parsed")
        finish_ok(course_data)

    except requests.exceptions.Timeout:
        logger.error(f"[{job_id}] Request timeout (10 min)")
        finish_error("Generation timed out. Please try again.")
    except Exception as e:
        logger.exception(f"[{job_id}] Unhandled exception")
        finish_error(f"Worker error: {e}")
    finally:
        logger.info(f"[{job_id}] === THREAD EXIT ===")


# ============================================================
#  FASTAPI ENDPOINTS
# ============================================================

class StartRequest(BaseModel):
    job_id:  str          # PA generates this (e.g. uuid4 hex)
    user_id: int
    topic:   str
    secret:  str          # Must match CALLBACK_SECRET


@app.post("/start")
async def start_job(req: StartRequest, background_tasks: BackgroundTasks):
    """PA calls this to kick off a generation job."""

    if req.secret != CALLBACK_SECRET:
        raise HTTPException(status_code=403, detail="Bad secret")

    if not req.topic.strip():
        raise HTTPException(status_code=400, detail="Topic is empty")

    if len(req.topic) > 300:
        raise HTTPException(status_code=400, detail="Topic too long")

    existing = get_job(req.job_id)
    if existing:
        return JSONResponse({"ok": True, "note": "job already exists"})

    set_job(req.job_id, {
        "status":   "running",
        "user_id":  req.user_id,
        "topic":    req.topic,
        "started":  datetime.utcnow().isoformat(),
    })

    # FastAPI BackgroundTasks = proper async threading, no PA issues
    background_tasks.add_task(_run_generation, req.job_id, req.user_id, req.topic)

    logger.info(f"Job {req.job_id} queued for user {req.user_id}, topic={req.topic!r}")
    return JSONResponse({"ok": True, "job_id": req.job_id})


@app.get("/status/{job_id}")
async def job_status(job_id: str, secret: str = ""):
    """PA can poll this as a fallback if the callback is missed."""

    if secret != CALLBACK_SECRET:
        raise HTTPException(status_code=403, detail="Bad secret")

    job = get_job(job_id)
    if job is None:
        raise HTTPException(status_code=404, detail="Job not found")

    # Don't send the full course_data in status β€” only signal done/error
    response = {
        "job_id": job_id,
        "status": job["status"],
    }
    if job["status"] == "error":
        response["error"] = job.get("error", "Unknown error")
    if job["status"] == "done":
        response["has_result"] = True

    return JSONResponse(response)


@app.get("/result/{job_id}")
async def job_result(job_id: str, secret: str = ""):
    """PA fetches full course data after receiving a 'done' callback."""

    if secret != CALLBACK_SECRET:
        raise HTTPException(status_code=403, detail="Bad secret")

    job = get_job(job_id)
    if job is None:
        raise HTTPException(status_code=404, detail="Job not found")
    if job["status"] != "done":
        raise HTTPException(status_code=409, detail=f"Job is {job['status']}, not done")

    return JSONResponse({
        "job_id":      job_id,
        "user_id":     job["user_id"],
        "course_data": job["result"],
    })


@app.get("/health")
async def health():
    return {"ok": True, "jobs": len(_jobs)}