E5K7 commited on
Commit
4041d2a
Β·
1 Parent(s): a85308c

Deploy LonelyTrack backend to HF Spaces

Browse files
Files changed (4) hide show
  1. Dockerfile +12 -0
  2. README.md +26 -5
  3. main.py +364 -0
  4. requirements.txt +6 -0
Dockerfile ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY main.py .
9
+
10
+ EXPOSE 7860
11
+
12
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,10 +1,31 @@
1
  ---
2
- title: CyberSync
3
- emoji: 🏒
4
- colorFrom: pink
5
- colorTo: purple
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: CyberSync - LonelyTrack Backend
3
+ emoji: 🧠
4
+ colorFrom: purple
5
+ colorTo: blue
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
+ # LonelyTrack – AI-Powered Learning Consistency Agent
12
+
13
+ Backend API for the LonelyTrack adaptive study planner.
14
+
15
+ ## Endpoints
16
+
17
+ - `POST /generate-plan` β€” Generate an AI-powered study schedule
18
+ - `POST /update-status` β€” Mark a day as completed/missed
19
+ - `POST /generate-tutorial` β€” Get an AI tutorial for a topic
20
+ - `GET /plan/{plan_id}` β€” Fetch a specific plan
21
+ - `GET /history/{user_id}` β€” Get all plans for a user
22
+ - `DELETE /plan/{plan_id}` β€” Delete a plan
23
+
24
+ ## Setup
25
+
26
+ This Space requires the following **Secrets** (Settings β†’ Secrets):
27
+
28
+ | Secret | Description |
29
+ |---|---|
30
+ | `OPENROUTER_API_KEY` | Your OpenRouter API key |
31
+ | `FIREBASE_SERVICE_ACCOUNT_KEY_JSON` | Full JSON content of your Firebase service account key |
main.py ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ from datetime import datetime
4
+ from typing import Optional
5
+
6
+ import firebase_admin
7
+ from firebase_admin import credentials, firestore
8
+ from fastapi import FastAPI, HTTPException, BackgroundTasks
9
+ from pydantic import BaseModel, Field
10
+ from openai import OpenAI
11
+ from dotenv import load_dotenv
12
+
13
+ load_dotenv()
14
+
15
+ # ── Firebase Admin Init ──────────────────────────────────────────────────────
16
+ if not firebase_admin._apps:
17
+ # Support JSON string via env var (for Docker/HF Spaces) or file path (local dev)
18
+ firebase_json = os.getenv("FIREBASE_SERVICE_ACCOUNT_KEY_JSON")
19
+ if firebase_json:
20
+ cred = credentials.Certificate(json.loads(firebase_json))
21
+ else:
22
+ cred = credentials.Certificate(os.getenv("FIREBASE_SERVICE_ACCOUNT_KEY_PATH", "serviceAccountKey.json"))
23
+ firebase_admin.initialize_app(cred)
24
+ db = firestore.client()
25
+
26
+ # ── OpenRouter LLM Init ──────────────────────────────────────────────────────
27
+ client = OpenAI(
28
+ base_url="https://openrouter.ai/api/v1",
29
+ api_key=os.getenv("OPENROUTER_API_KEY"),
30
+ )
31
+ LLM_MODEL = os.getenv("OPENROUTER_MODEL", "google/gemini-2.0-flash-001")
32
+
33
+ app = FastAPI(title="lonelytrack", version="0.1.0")
34
+
35
+
36
+ # ── Pydantic Models ─────────────────────────────────────────────────────────
37
+ class UserRequest(BaseModel):
38
+ user_id: str
39
+ topic: str
40
+ daily_minutes: int = Field(gt=0, le=480)
41
+ total_days: int = Field(gt=0, le=365, default=14)
42
+ skill_level: str = Field(pattern="^(beginner|intermediate|advanced|pro|Beginner|Intermediate|Advanced|Pro)$")
43
+
44
+
45
+ class DailyTask(BaseModel):
46
+ day: int
47
+ topic: str
48
+ duration_mins: int
49
+ status: str = "pending" # pending | completed | missed
50
+
51
+
52
+ class LearningPlan(BaseModel):
53
+ goal: str
54
+ total_days: int
55
+ schedule: list[DailyTask]
56
+
57
+
58
+ class StatusUpdate(BaseModel):
59
+ user_id: str
60
+ plan_id: str
61
+ day: int
62
+ status: str = Field(pattern="^(completed|missed)$")
63
+
64
+
65
+ class TutorialRequest(BaseModel):
66
+ topic: str
67
+ skill_level: str = "beginner"
68
+
69
+
70
+ # ── Helpers ──────────────────────────────────────────────────────────────────
71
+
72
+ PLAN_PROMPT_TEMPLATE = """You are a study-plan generator. Create a structured learning plan.
73
+
74
+ Topic: {topic}
75
+ Daily available time: {daily_minutes} minutes
76
+ Total duration: {total_days} days
77
+ Current skill level: {skill_level}
78
+
79
+ Return ONLY valid JSON matching this exact schema (no markdown, no explanation):
80
+ {{
81
+ "goal": "<one-sentence learning goal>",
82
+ "total_days": {total_days},
83
+ "schedule": [
84
+ {{"day": 1, "topic": "<sub-topic>", "duration_mins": <int>, "status": "pending"}},
85
+ ...
86
+ ]
87
+ }}
88
+
89
+ Rules:
90
+ - Each day's duration_mins must be <= {daily_minutes}.
91
+ - Provide exactly {total_days} days of content.
92
+ - Tailor complexity to the {skill_level} level.
93
+ """
94
+
95
+ SIMPLIFY_PROMPT_TEMPLATE = """The learner has missed 3 consecutive days. Simplify the remaining schedule.
96
+
97
+ Original goal: {goal}
98
+ Remaining schedule (days not yet completed):
99
+ {remaining_json}
100
+
101
+ Return ONLY valid JSON as a list of daily tasks matching this schema (no markdown):
102
+ [
103
+ {{"day": <int starting from 1>, "topic": "<simplified sub-topic>", "duration_mins": <int>, "status": "pending"}}
104
+ ]
105
+
106
+ Rules:
107
+ - Reduce complexity and session length by ~25%.
108
+ - Merge or drop low-priority topics.
109
+ - Keep the list between 3 and 20 entries.
110
+ """
111
+
112
+ TUTORIAL_PROMPT_TEMPLATE = """You are an expert tutor. Write a comprehensive tutorial on the following topic.
113
+
114
+ Topic: {topic}
115
+ Skill level: {skill_level}
116
+
117
+ Write a well-structured tutorial with:
118
+ - A brief introduction explaining what this topic is and why it matters
119
+ - Clear step-by-step explanations with examples
120
+ - Code examples if applicable (use proper formatting)
121
+ - Key takeaways or summary at the end
122
+ - Practice exercises or questions to reinforce learning
123
+
124
+ Write in plain text with clear section headers (use ALL CAPS for headers).
125
+ Keep the tutorial focused and around 800-1200 words.
126
+ Make it engaging and easy to follow for a {skill_level} learner.
127
+ """
128
+
129
+
130
+ def parse_llm_json(text: str):
131
+ """Strip markdown fences and parse JSON from LLM output."""
132
+ cleaned = text.strip()
133
+ if cleaned.startswith("```"):
134
+ cleaned = cleaned.split("\n", 1)[1] if "\n" in cleaned else cleaned[3:]
135
+ if cleaned.endswith("```"):
136
+ cleaned = cleaned[:-3]
137
+ return json.loads(cleaned.strip())
138
+
139
+
140
+ # ── Background task: detect 3 consecutive misses β†’ simplify ─────────────────
141
+
142
+ async def check_and_simplify(user_id: str, plan_id: str):
143
+ doc_ref = db.collection("learning_plans").document(plan_id)
144
+ doc = doc_ref.get()
145
+ if not doc.exists:
146
+ return
147
+
148
+ data = doc.to_dict()
149
+ schedule = data.get("schedule", [])
150
+ sorted_schedule = sorted(schedule, key=lambda t: t["day"])
151
+
152
+ # Detect 3 consecutive misses
153
+ consecutive_misses = 0
154
+ needs_simplification = False
155
+ for task in sorted_schedule:
156
+ if task["status"] == "missed":
157
+ consecutive_misses += 1
158
+ if consecutive_misses >= 3:
159
+ needs_simplification = True
160
+ break
161
+ else:
162
+ consecutive_misses = 0
163
+
164
+ if not needs_simplification:
165
+ return
166
+
167
+ # Gather remaining (non-completed) tasks
168
+ remaining = [t for t in sorted_schedule if t["status"] != "completed"]
169
+ if not remaining:
170
+ return
171
+
172
+ prompt = SIMPLIFY_PROMPT_TEMPLATE.format(
173
+ goal=data.get("goal", ""),
174
+ remaining_json=json.dumps(remaining, indent=2),
175
+ )
176
+
177
+ response = client.chat.completions.create(
178
+ model=LLM_MODEL,
179
+ messages=[{"role": "user", "content": prompt}],
180
+ )
181
+ new_schedule_raw = parse_llm_json(response.choices[0].message.content)
182
+
183
+ # Validate each entry
184
+ new_remaining = []
185
+ for entry in new_schedule_raw:
186
+ task = DailyTask(**entry)
187
+ new_remaining.append(task.model_dump())
188
+
189
+ # Merge: keep completed tasks, replace the rest
190
+ completed = [t for t in sorted_schedule if t["status"] == "completed"]
191
+ updated_schedule = completed + new_remaining
192
+
193
+ doc_ref.update({
194
+ "schedule": updated_schedule,
195
+ "simplified_at": datetime.utcnow().isoformat(),
196
+ })
197
+
198
+
199
+ # ── Endpoints ────────────────────────────────────────────────────────────────
200
+
201
+ @app.post("/generate-plan")
202
+ async def generate_plan(req: UserRequest):
203
+ # ── Check cache: reuse existing plan for same user + topic ───────────
204
+ existing = (
205
+ db.collection("learning_plans")
206
+ .where("user_id", "==", req.user_id)
207
+ .where("topic", "==", req.topic)
208
+ .stream()
209
+ )
210
+ for doc in existing:
211
+ d = doc.to_dict()
212
+ plan_id = doc.id
213
+ print(f"[generate-plan] Cache hit: returning existing plan {plan_id} for topic '{req.topic}'")
214
+ return {
215
+ "plan_id": plan_id,
216
+ "goal": d.get("goal", ""),
217
+ "total_days": d.get("total_days", 0),
218
+ "schedule": d.get("schedule", []),
219
+ "cached": True,
220
+ }
221
+
222
+ # ── No cache β€” generate new plan via LLM ─────────────────────────────
223
+ prompt = PLAN_PROMPT_TEMPLATE.format(
224
+ topic=req.topic,
225
+ daily_minutes=req.daily_minutes,
226
+ total_days=req.total_days,
227
+ skill_level=req.skill_level,
228
+ )
229
+
230
+ response = client.chat.completions.create(
231
+ model=LLM_MODEL,
232
+ messages=[{"role": "user", "content": prompt}],
233
+ )
234
+ plan_data = parse_llm_json(response.choices[0].message.content)
235
+
236
+ # Validate against Pydantic model
237
+ plan = LearningPlan(**plan_data)
238
+
239
+ # Persist to Firestore
240
+ doc_data = {
241
+ "user_id": req.user_id,
242
+ "topic": req.topic,
243
+ "created_at": datetime.utcnow().isoformat(),
244
+ **plan.model_dump(),
245
+ }
246
+ doc_ref = db.collection("learning_plans").add(doc_data)
247
+ plan_id = doc_ref[1].id
248
+
249
+ return {"plan_id": plan_id, **plan.model_dump(), "cached": False}
250
+
251
+
252
+ @app.post("/update-status")
253
+ async def update_status(req: StatusUpdate, background_tasks: BackgroundTasks):
254
+ doc_ref = db.collection("learning_plans").document(req.plan_id)
255
+ doc = doc_ref.get()
256
+
257
+ if not doc.exists:
258
+ raise HTTPException(status_code=404, detail="Plan not found")
259
+
260
+ data = doc.to_dict()
261
+ if data.get("user_id") != req.user_id:
262
+ raise HTTPException(status_code=403, detail="Not your plan")
263
+
264
+ schedule = data.get("schedule", [])
265
+ updated = False
266
+ for task in schedule:
267
+ if int(task["day"]) == req.day and task["status"] == "pending":
268
+ task["status"] = req.status
269
+ updated = True
270
+ break
271
+
272
+ if not updated:
273
+ # Log debug info to help diagnose
274
+ day_statuses = {int(t.get('day', 0)): t.get('status', '?') for t in schedule}
275
+ print(f"[update-status] day={req.day}, schedule_days={day_statuses}")
276
+ raise HTTPException(
277
+ status_code=400,
278
+ detail=f"Day {req.day} not found or already updated. Current statuses: {day_statuses}"
279
+ )
280
+
281
+ doc_ref.update({"schedule": schedule})
282
+
283
+ # Trigger background check for consecutive misses
284
+ background_tasks.add_task(check_and_simplify, req.user_id, req.plan_id)
285
+
286
+ return {"message": f"Day {req.day} marked as {req.status}"}
287
+
288
+
289
+ @app.get("/plan/{plan_id}")
290
+ async def get_plan(plan_id: str):
291
+ doc = db.collection("learning_plans").document(plan_id).get()
292
+ if not doc.exists:
293
+ raise HTTPException(status_code=404, detail="Plan not found")
294
+ return {"plan_id": plan_id, **doc.to_dict()}
295
+
296
+
297
+ @app.post("/generate-tutorial")
298
+ async def generate_tutorial(req: TutorialRequest):
299
+ prompt = TUTORIAL_PROMPT_TEMPLATE.format(
300
+ topic=req.topic,
301
+ skill_level=req.skill_level,
302
+ )
303
+
304
+ response = client.chat.completions.create(
305
+ model=LLM_MODEL,
306
+ messages=[{"role": "user", "content": prompt}],
307
+ )
308
+ content = response.choices[0].message.content.strip()
309
+ return {"topic": req.topic, "tutorial": content}
310
+
311
+
312
+ # ── History endpoint ──────────────────────────────────────────────────────────
313
+ @app.get("/history/{user_id}")
314
+ async def get_history(user_id: str):
315
+ """Return all learning plans for a user, newest first."""
316
+ try:
317
+ docs = (
318
+ db.collection("learning_plans")
319
+ .where("user_id", "==", user_id)
320
+ .stream()
321
+ )
322
+ plans = []
323
+ for doc in docs:
324
+ d = doc.to_dict()
325
+ schedule = d.get("schedule", [])
326
+ completed = sum(1 for t in schedule if t.get("status") == "completed")
327
+ topic = d.get("topic", "") or ""
328
+ # Fallback: extract topic from goal if topic wasn't saved
329
+ if not topic and d.get("goal"):
330
+ topic = d["goal"].split(".")[0][:50]
331
+ plans.append({
332
+ "plan_id": doc.id,
333
+ "goal": d.get("goal", ""),
334
+ "topic": topic,
335
+ "total_days": d.get("total_days", len(schedule)),
336
+ "completed_days": completed,
337
+ "created_at": d.get("created_at", ""),
338
+ })
339
+ # Sort newest first in Python (avoids needing a Firestore composite index)
340
+ plans.sort(key=lambda p: p["created_at"], reverse=True)
341
+ return {"plans": plans}
342
+ except Exception as e:
343
+ print(f"[history] Error: {e}")
344
+ raise HTTPException(status_code=500, detail=str(e))
345
+
346
+
347
+ # ── Delete plan endpoint ─────────────────────────────────────────────────────
348
+ @app.delete("/plan/{plan_id}")
349
+ async def delete_plan(plan_id: str, user_id: str):
350
+ """Delete a learning plan. Requires user_id as query param for ownership check."""
351
+ doc_ref = db.collection("learning_plans").document(plan_id)
352
+ doc = doc_ref.get()
353
+ if not doc.exists:
354
+ raise HTTPException(status_code=404, detail="Plan not found")
355
+ data = doc.to_dict()
356
+ if data.get("user_id") != user_id:
357
+ raise HTTPException(status_code=403, detail="Not your plan")
358
+ doc_ref.delete()
359
+ return {"message": "Plan deleted"}
360
+
361
+
362
+ if __name__ == "__main__":
363
+ import uvicorn
364
+ uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi==0.115.0
2
+ uvicorn[standard]==0.30.6
3
+ pydantic==2.9.2
4
+ firebase-admin==6.5.0
5
+ openai>=1.51.0
6
+ python-dotenv==1.0.1