Spaces:
Sleeping
Sleeping
Ali Abozahra
Roadmap v2 (topics + 10Q quiz + project) + CV Analyzer (pdfplumber) + model registry
25cb670 | """ | |
| roadmap_agent.py β Phase 3 (v2) | |
| Multi-step, plain-Groq roadmap generator (no CrewAI). Pipeline: | |
| 1. Profile Analyzer β clean the raw {track, level, goal} into a structured profile | |
| 2. Skill-Gap Mapper β what to prioritise / skip for this learner | |
| 3. Roadmap Architect β >=5 phases, each with >=4 *topics* (field-appropriate) | |
| 4. Course Curator β one real platform + course/playlist per topic | |
| -> resource links built from PLATFORM_SEARCH_URLS | |
| 5. Quiz Generator β a 10-question MCQ quiz at the END of each phase, | |
| covering that phase's topics | |
| 6. Task Generator β one hands-on practical project at the END of each phase | |
| 7. Timeline Planner β realistic phase/total durations | |
| Output is a validated `RoadmapOutput` (never raises β falls back gracefully). | |
| Schema mapping for the existing UI / Mongo: | |
| * each phase TOPIC is stored as a `RoadmapTask` (title = topic name) so the | |
| current task rendering, completion toggle, progress %, and Mongo save all | |
| keep working unchanged. | |
| * each phase also gets a single `project` (the practical task) and a `quiz`. | |
| Models are read from llms.py (GROQ default) β change the model in ONE place. | |
| """ | |
| import json | |
| import uuid | |
| import urllib.parse | |
| from typing import Optional, List, Dict | |
| from pydantic import BaseModel, Field | |
| from dotenv import load_dotenv | |
| load_dotenv() | |
| from llms import groq_client, ROADMAP_MODEL | |
| # --------------------------------------------------------------------------- | |
| # Real platform search URLs (used to build resource links per topic). | |
| # --------------------------------------------------------------------------- | |
| PLATFORM_SEARCH_URLS = { | |
| "coursera": "https://www.coursera.org/search?query={q}", | |
| "udemy": "https://www.udemy.com/courses/search/?q={q}", | |
| "freecodecamp": "https://www.freecodecamp.org/news/search/?query={q}", | |
| "kaggle": "https://www.kaggle.com/search?q={q}", | |
| "edx": "https://www.edx.org/search?q={q}", | |
| "fast.ai": "https://www.fast.ai/", | |
| "leetcode": "https://leetcode.com/problemset/?search={q}", | |
| "datacamp": "https://www.datacamp.com/search?q={q}", | |
| "youtube": "https://www.youtube.com/results?search_query={q}", | |
| } | |
| # How each platform should be labelled / typed in the UI. | |
| _PLATFORM_TYPE = { | |
| "youtube": "video", "coursera": "course", "udemy": "course", | |
| "edx": "course", "fast.ai": "course", "datacamp": "course", | |
| "freecodecamp": "article", "kaggle": "practice", "leetcode": "practice", | |
| } | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Pydantic schema | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class ResourceItem(BaseModel): | |
| title: str | |
| url: Optional[str] = None | |
| type: str = "article" # "video" | "article" | "course" | "book" | "practice" | |
| class QuizQuestion(BaseModel): | |
| question: str | |
| options: Dict[str, str] = Field(default_factory=dict) # {"A": "...", ...} | |
| answer: str = "A" | |
| explanation: str = "" | |
| class RoadmapTask(BaseModel): | |
| """A learning TOPIC inside a phase (kept named 'task' for UI/Mongo compat).""" | |
| id: str = Field(default_factory=lambda: str(uuid.uuid4())[:8]) | |
| title: str | |
| description: str = "" | |
| duration: str = "" | |
| resources: List[ResourceItem] = Field(default_factory=list) | |
| completed: bool = False | |
| class PhaseProject(BaseModel): | |
| """The single hands-on practical task at the end of a phase.""" | |
| title: str = "" | |
| description: str = "" | |
| deliverable: str = "" | |
| estimated_hours: int = 0 | |
| completed: bool = False | |
| class RoadmapPhase(BaseModel): | |
| phase_number: int | |
| title: str | |
| description: str = "" | |
| duration: str = "" | |
| tasks: List[RoadmapTask] = Field(default_factory=list) # the topics | |
| project: Optional[PhaseProject] = None # practical task | |
| quiz: List[QuizQuestion] = Field(default_factory=list) # 10 MCQs | |
| class RoadmapOutput(BaseModel): | |
| track: str | |
| level: str | |
| goal: str | |
| total_duration: str = "Self-paced" | |
| summary: str = "" | |
| display_hint: str = "timeline" # "timeline" | "kanban" | "checklist" | |
| phases: List[RoadmapPhase] = Field(default_factory=list) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Constraints | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| MIN_PHASES = 5 | |
| MIN_TOPICS_PER_PHASE = 4 | |
| QUIZ_QUESTIONS = 10 | |
| _LEVEL_DESC = { | |
| "beginner": "no prior experience in this area", | |
| "intermediate": "knows the basics, has some hands-on experience", | |
| "advanced": "experienced practitioner wanting to deepen expertise", | |
| } | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Groq JSON helper | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _call_llm(prompt: str, temperature: float = 0.3, system: str = "") -> dict: | |
| """Call Groq with JSON mode and parse the response. Returns {} on failure.""" | |
| client = groq_client() | |
| if client is None: | |
| return {} | |
| messages = [] | |
| if system: | |
| messages.append({"role": "system", "content": system}) | |
| messages.append({"role": "user", "content": prompt}) | |
| try: | |
| resp = client.chat.completions.create( | |
| model=ROADMAP_MODEL, | |
| messages=messages, | |
| response_format={"type": "json_object"}, | |
| temperature=temperature, | |
| max_tokens=4000, | |
| ) | |
| return json.loads(resp.choices[0].message.content) | |
| except Exception as exc: | |
| print(f"[RoadmapAgent] LLM error: {exc}") | |
| return {} | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 1. Profile Analyzer | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _run_profile_analyzer(track: str, level: str, goal: str) -> dict: | |
| level_desc = _LEVEL_DESC.get(level.lower(), level) | |
| data = _call_llm(f"""Extract a structured student profile. Return ONLY valid JSON. | |
| field/track : {track} | |
| level : {level} β {level_desc} | |
| goal : {goal} | |
| Return: | |
| {{ | |
| "specialization": "cleaned field name", | |
| "academic_level": "standardized level ({level})", | |
| "career_goal": "concise goal statement", | |
| "implied_skills": ["skill already implied by their level"], | |
| "timeline_hint": "urgency from goal e.g. 6 months, or 'flexible'" | |
| }}""") | |
| # Sensible fallback so the rest of the pipeline always has something. | |
| return { | |
| "specialization": data.get("specialization") or track, | |
| "academic_level": data.get("academic_level") or level, | |
| "career_goal": data.get("career_goal") or goal, | |
| "implied_skills": data.get("implied_skills") or [], | |
| "timeline_hint": data.get("timeline_hint") or "flexible", | |
| } | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 2. Skill-Gap Mapper | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _run_skill_gap_mapper(profile: dict) -> dict: | |
| data = _call_llm(f"""Identify skill gaps for this student. Return ONLY valid JSON. | |
| Profile: | |
| {json.dumps(profile, indent=2)} | |
| Return: | |
| {{ | |
| "skills_to_skip": ["already implied by their level"], | |
| "priority_skills": ["most important to learn first"], | |
| "nice_to_have": ["optional for their goal"], | |
| "weakness_areas": ["likely struggles at their level"] | |
| }}""") | |
| return { | |
| "skills_to_skip": data.get("skills_to_skip") or [], | |
| "priority_skills": data.get("priority_skills") or [], | |
| "nice_to_have": data.get("nice_to_have") or [], | |
| "weakness_areas": data.get("weakness_areas") or [], | |
| } | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 3. Roadmap Architect | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _run_architect( | |
| profile: dict, gaps: dict, track: str, level: str, goal: str, | |
| note: str = "", completed_tasks: Optional[List[str]] = None, | |
| ) -> dict: | |
| completed_note = "" | |
| if completed_tasks: | |
| completed_note = ( | |
| "\nThe user already completed these topics β build on them, don't " | |
| f"repeat them as primary topics: {'; '.join(completed_tasks[:12])}" | |
| ) | |
| extra_note = f"\nExtra instruction from the user: {note}" if note.strip() else "" | |
| data = _call_llm(f"""Design a learning roadmap for this student. Return ONLY valid JSON. | |
| Profile: {json.dumps(profile, indent=2)} | |
| Gaps: {json.dumps(gaps, indent=2)}{completed_note}{extra_note} | |
| CRITICAL RULES before you pick any topic: | |
| 1. Ground every topic in what a REAL-WORLD curriculum, degree program, or | |
| certification for "{profile.get('career_goal', goal)}" in the field of | |
| "{profile.get('specialization', track)}" actually contains. If an accredited | |
| program or licensing body for this exact career would NOT include a topic, | |
| do not include it. | |
| 2. Do NOT add programming/coding/data-science tooling (Python, R, SQL, pandas...) | |
| UNLESS the specialization itself is a technical/programming/data field. Use | |
| the field's own standard tools and terminology. | |
| 3. Cover the field's actual core pillars first (foundational theory, core | |
| practice/clinical/professional skills, ethics where relevant, applied practice). | |
| 4. Topics must be SPECIFIC and SEARCHABLE on YouTube/Google | |
| (e.g. "Cognitive Behavioral Therapy techniques", not just "therapy"). | |
| 5. Align topics to priority_skills; skip skills_to_skip. | |
| STRUCTURE REQUIREMENTS (strict): | |
| - Produce AT LEAST {MIN_PHASES} phases ordered from foundation to launch. | |
| - Each phase MUST have AT LEAST {MIN_TOPICS_PER_PHASE} topics. | |
| - Each topic has its own short description and an individual duration. | |
| - Try to make totalDuration match the timeline_hint ({profile.get('timeline_hint', 'flexible')}). | |
| Return: | |
| {{ | |
| "title": "short roadmap title", | |
| "summary": "2 personalized sentences about what the learner will achieve", | |
| "total_duration": "e.g. 10 months", | |
| "display_hint": "timeline | kanban | checklist", | |
| "phases": [ | |
| {{ | |
| "title": "phase title", | |
| "description": "1 sentence on this phase", | |
| "duration": "e.g. 2 months", | |
| "topics": [ | |
| {{"name": "specific field-appropriate topic", | |
| "description": "1 sentence on what to learn", | |
| "duration": "e.g. 1 week"}} | |
| ] | |
| }} | |
| ] | |
| }}""", temperature=0.4) | |
| return data | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 4. Course Curator β real platform links per topic (one LLM call) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _run_course_curator(arch: dict, profile: dict) -> dict: | |
| specialization = profile.get("specialization", "") | |
| goal = profile.get("career_goal", "") | |
| overview = [ | |
| {"phase": pi, "topics": [t.get("name", "") for t in p.get("topics", [])]} | |
| for pi, p in enumerate(arch.get("phases", [])) | |
| ] | |
| platforms = ", ".join(PLATFORM_SEARCH_URLS.keys()) | |
| suggestion = _call_llm(f"""Suggest ONE real course platform + course NAME for EACH topic below. | |
| Return ONLY valid JSON. Do NOT invent URLs β only platform + course_name. | |
| Specialization: {specialization} | |
| Goal: {goal} | |
| Topics by phase (phase is a 0-based index): | |
| {json.dumps(overview, indent=2)} | |
| Pick the platform that best fits each topic from: {platforms} | |
| Return: | |
| {{ | |
| "course_picks": [ | |
| {{"phase": 0, "topic": "exact topic name", | |
| "platform": "one of the allowed platforms", | |
| "course_name": "specific real course or playlist name"}} | |
| ] | |
| }}""") | |
| picks = { | |
| (p.get("phase"), p.get("topic")): p | |
| for p in suggestion.get("course_picks", []) | |
| } | |
| for pi, phase in enumerate(arch.get("phases", [])): | |
| for topic in phase.get("topics", []): | |
| name = topic.get("name", "") | |
| q = urllib.parse.quote_plus(f"{name} {specialization}".strip()) | |
| resources = [{ | |
| "title": f"{name} β video tutorials", | |
| "url": PLATFORM_SEARCH_URLS["youtube"].format(q=q), | |
| "type": "video", | |
| }] | |
| pick = picks.get((pi, name)) | |
| if pick: | |
| platform = (pick.get("platform") or "coursera").lower().strip() | |
| course_name = pick.get("course_name") or name | |
| tmpl = PLATFORM_SEARCH_URLS.get(platform, PLATFORM_SEARCH_URLS["coursera"]) | |
| cq = urllib.parse.quote_plus(course_name) | |
| resources.append({ | |
| "title": f"{course_name} ({platform})", | |
| "url": tmpl.format(q=cq), | |
| "type": _PLATFORM_TYPE.get(platform, "course"), | |
| }) | |
| else: | |
| # Fallback: a generic course search so every topic has 2 links. | |
| resources.append({ | |
| "title": f"{name} β courses", | |
| "url": PLATFORM_SEARCH_URLS["coursera"].format(q=q), | |
| "type": "course", | |
| }) | |
| topic["resources"] = resources | |
| return arch | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 5. Quiz Generator β 10 MCQs at the end of each phase | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _run_quiz_generator(arch: dict, profile: dict) -> dict: | |
| specialization = profile.get("specialization", "") | |
| for phase in arch.get("phases", []): | |
| topics = ", ".join(t.get("name", "") for t in phase.get("topics", []))[:600] | |
| title = phase.get("title", "") | |
| data = _call_llm(f"""Create a quiz to test understanding AFTER completing the phase | |
| "{title}" in the field "{specialization}". Cover these topics: {topics}. | |
| Return ONLY valid JSON. | |
| Generate EXACTLY {QUIZ_QUESTIONS} multiple-choice questions, increasing in difficulty. | |
| Each question has 4 options (keys A, B, C, D) and exactly one correct answer. | |
| Return: | |
| {{ | |
| "questions": [ | |
| {{"question": "...", | |
| "options": {{"A": "...", "B": "...", "C": "...", "D": "..."}}, | |
| "answer": "A", | |
| "explanation": "one sentence why"}} | |
| ] | |
| }}""", temperature=0.5) | |
| phase["quiz"] = (data.get("questions") or [])[:QUIZ_QUESTIONS] | |
| return arch | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 6. Task Generator β one practical project per phase (one LLM call) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _run_task_generator(arch: dict, profile: dict) -> dict: | |
| specialization = profile.get("specialization", "") | |
| goal = profile.get("career_goal", "") | |
| overview = [ | |
| {"phase": pi, "title": p.get("title", ""), | |
| "topics": [t.get("name", "") for t in p.get("topics", [])]} | |
| for pi, p in enumerate(arch.get("phases", [])) | |
| ] | |
| data = _call_llm(f"""For EACH phase below, create ONE hands-on practical task/project that | |
| applies the phase's topics. Return ONLY valid JSON. | |
| Field: {specialization} | |
| Career goal: {goal} | |
| Phases (phase is a 0-based index): | |
| {json.dumps(overview, indent=2)} | |
| Return: | |
| {{ | |
| "tasks": [ | |
| {{"phase": 0, | |
| "title": "short task title", | |
| "description": "2-3 sentences on what to build/do", | |
| "deliverable": "what the student should produce", | |
| "estimated_hours": 6}} | |
| ] | |
| }}""", temperature=0.4) | |
| by_phase = {t.get("phase"): t for t in data.get("tasks", [])} | |
| for pi, phase in enumerate(arch.get("phases", [])): | |
| t = by_phase.get(pi) | |
| if t: | |
| try: | |
| hrs = int(t.get("estimated_hours") or 0) | |
| except (ValueError, TypeError): | |
| hrs = 0 | |
| phase["project"] = { | |
| "title": t.get("title", ""), | |
| "description": t.get("description", ""), | |
| "deliverable": t.get("deliverable", ""), | |
| "estimated_hours": hrs, | |
| "completed": False, | |
| } | |
| return arch | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 7. Timeline Planner β realistic durations | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _run_timeline_planner(arch: dict, profile: dict) -> dict: | |
| light = { | |
| "total_duration": arch.get("total_duration"), | |
| "phases": [ | |
| {"phase": pi, "title": p.get("title"), | |
| "duration": p.get("duration"), "topic_count": len(p.get("topics", []))} | |
| for pi, p in enumerate(arch.get("phases", [])) | |
| ], | |
| } | |
| data = _call_llm(f"""Validate and adjust roadmap phase durations. Return ONLY valid JSON. | |
| Level: {profile.get('academic_level')} | |
| Timeline hint: {profile.get('timeline_hint', 'flexible')} | |
| Roadmap overview: {json.dumps(light, indent=2)} | |
| Assume 10-15 study hours/week. Make each phase's duration realistic for its topic_count. | |
| Return: | |
| {{ | |
| "total_duration": "e.g. 10 months", | |
| "phase_durations": [{{"phase": 0, "duration": "e.g. 2 months"}}] | |
| }}""", temperature=0.2) | |
| if data.get("total_duration"): | |
| arch["total_duration"] = data["total_duration"] | |
| by_phase = {d.get("phase"): d.get("duration") for d in data.get("phase_durations", [])} | |
| for pi, phase in enumerate(arch.get("phases", [])): | |
| if by_phase.get(pi): | |
| phase["duration"] = by_phase[pi] | |
| return arch | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Assemble + validate | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _to_output(arch: dict, track: str, level: str, goal: str) -> RoadmapOutput: | |
| phases: List[RoadmapPhase] = [] | |
| for pi, p in enumerate(arch.get("phases", []), 1): | |
| tasks = [] | |
| for ti, topic in enumerate(p.get("topics", []), 1): | |
| tasks.append(RoadmapTask( | |
| id=f"p{pi}t{ti}", | |
| title=topic.get("name", "") or f"Topic {ti}", | |
| description=topic.get("description", "") or "", | |
| duration=topic.get("duration", "") or "", | |
| resources=[ResourceItem(**r) for r in topic.get("resources", []) if r.get("url")], | |
| )) | |
| project = None | |
| if p.get("project"): | |
| project = PhaseProject(**p["project"]) | |
| quiz = [] | |
| for q in p.get("quiz", []): | |
| opts = q.get("options") or {} | |
| if isinstance(opts, list): # tolerate ["A) ...", ...] -> dict | |
| opts = {chr(65 + i): str(o) for i, o in enumerate(opts)} | |
| quiz.append(QuizQuestion( | |
| question=q.get("question", ""), | |
| options={str(k): str(v) for k, v in opts.items()}, | |
| answer=str(q.get("answer") or q.get("correct_answer") or "A").strip()[:1].upper(), | |
| explanation=q.get("explanation", "") or "", | |
| )) | |
| phases.append(RoadmapPhase( | |
| phase_number=pi, | |
| title=p.get("title", "") or f"Phase {pi}", | |
| description=p.get("description", "") or "", | |
| duration=p.get("duration", "") or "", | |
| tasks=tasks, | |
| project=project, | |
| quiz=quiz, | |
| )) | |
| return RoadmapOutput( | |
| track=track, | |
| level=level, | |
| goal=goal, | |
| total_duration=arch.get("total_duration") or "Self-paced", | |
| summary=arch.get("summary") or f"A personalized learning path for {track}.", | |
| display_hint=(arch.get("display_hint") or "timeline").lower(), | |
| phases=phases, | |
| ) | |
| def _fallback_roadmap(track: str, level: str, goal: str) -> RoadmapOutput: | |
| """Minimal valid roadmap when the LLM is unavailable.""" | |
| phases = [] | |
| titles = ["Foundation", "Core Skills", "Applied Practice", "Advanced", "Launch"] | |
| for pi, ptitle in enumerate(titles, 1): | |
| tasks = [ | |
| RoadmapTask( | |
| id=f"p{pi}t{ti}", | |
| title=f"{ptitle} topic {ti} for {track}", | |
| description="Study the fundamentals and practice.", | |
| duration="1 week", | |
| resources=[ | |
| ResourceItem( | |
| title=f"{track} β video tutorials", type="video", | |
| url=PLATFORM_SEARCH_URLS["youtube"].format( | |
| q=urllib.parse.quote_plus(track)), | |
| ), | |
| ResourceItem( | |
| title=f"{track} β courses", type="course", | |
| url=PLATFORM_SEARCH_URLS["coursera"].format( | |
| q=urllib.parse.quote_plus(track)), | |
| ), | |
| ], | |
| ) | |
| for ti in range(1, MIN_TOPICS_PER_PHASE + 1) | |
| ] | |
| phases.append(RoadmapPhase( | |
| phase_number=pi, title=ptitle, | |
| description=f"{ptitle} stage of your {track} journey.", | |
| duration="3 weeks", tasks=tasks, | |
| project=PhaseProject( | |
| title=f"{ptitle} project", | |
| description=f"Apply what you learned in the {ptitle.lower()} phase.", | |
| deliverable="A small portfolio-worthy artifact.", | |
| estimated_hours=6, | |
| ), | |
| quiz=[], | |
| )) | |
| return RoadmapOutput( | |
| track=track, level=level, goal=goal, total_duration="Self-paced", | |
| summary=f"A personalized learning path for {track}.", | |
| display_hint="timeline", phases=phases, | |
| ) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Public API | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def generate_roadmap( | |
| track: str, | |
| level: str, | |
| goal: str, | |
| note: str = "", | |
| completed_tasks: Optional[List[str]] = None, | |
| ) -> RoadmapOutput: | |
| """Run the full pipeline. Returns a validated RoadmapOutput (never raises).""" | |
| try: | |
| profile = _run_profile_analyzer(track, level, goal) | |
| gaps = _run_skill_gap_mapper(profile) | |
| arch = _run_architect(profile, gaps, track, level, goal, note, completed_tasks) | |
| if not arch.get("phases"): | |
| print("[RoadmapAgent] Architect returned no phases β using fallback.") | |
| return _fallback_roadmap(track, level, goal) | |
| arch = _run_course_curator(arch, profile) | |
| arch = _run_quiz_generator(arch, profile) | |
| arch = _run_task_generator(arch, profile) | |
| arch = _run_timeline_planner(arch, profile) | |
| return _to_output(arch, track, level, goal) | |
| except Exception as exc: | |
| print(f"[RoadmapAgent] Generation error: {exc}") | |
| return _fallback_roadmap(track, level, goal) | |
| def refresh_roadmap(stored_roadmap: dict, track: str, level: str, goal: str) -> RoadmapOutput: | |
| """Regenerate taking the user's completed topics into account.""" | |
| completed_tasks = [] | |
| for phase in stored_roadmap.get("phases", []): | |
| for task in phase.get("tasks", []): | |
| if task.get("completed"): | |
| completed_tasks.append(task.get("title", "")) | |
| return generate_roadmap( | |
| track=track, level=level, goal=goal, | |
| note="Build on the user's progress. Adjust remaining topics and suggest advanced next steps.", | |
| completed_tasks=completed_tasks or None, | |
| ) | |