import asyncio import json import shutil import uuid from datetime import datetime, timezone from fastapi import APIRouter, HTTPException from fastapi.responses import Response from agents import provider_available from config import PROJECTS_DIR, UNI_DIR, PROVIDERS from database import ( create_guide, delete_guide, get_guide, list_guides, create_topic, list_topics as db_list_topics, delete_topic, list_progress, set_progress, delete_progress, create_element, list_elements, get_element, update_element, delete_element, list_block_progress, get_block_progress, set_open_question, set_block_score_and_streak, set_block_completed, delete_block_data, delete_block_progress, subs_per_level, subs_per_level_raw, delete_topic_pipeline, delete_source, get_guide_content, delete_guide_content, get_sub_artefakte, ) from blocks import generate_blocks, cancel_blocks, blocks_status, active_blocks, reset_blocks, reset_blocks_ab_step, load_source, load_overview, subblocks_title, subblocks_frei, load_question_pattern, load_question_pattern_free from elements import generate_element, chat_with_guide, chat_with_element, check_element, style_element, refine_suggestion from learning import block_chat, block_discussion, create_block_element, exam_rating, exam_rating_fast, exam_question, exam_question_variant, generate_quiz, generate_gapchoice, generate_gaptext, check_gaptext, hurdles_distractor_block, compute_score, floor_from_score, level_from_score, cap_final, cap_aktuell, freie_level, thresholds, points_delta, cap_followup from guide import generate_guide, guide_slot_files, guide_done_step, block_pruefen, block_adopt, content_fuer_level from pipeline import cancel_guide from rules import FORMATE, formats_stats, guide_lock, ist_completed, load_learnstate, topic_completed from models import ( GuideCreateRequest, GuideResponse, TopicCreateRequest, BlocksCreateRequest, BlocksResetStepRequest, BlocksStatusResponse, GuideChatRequest, GuideChatResponse, ElementCreateRequest, ElementChatRequest, ElementChatResponse, ElementResponse, ElementUpdateRequest, ElementCheckRequest, ElementCheckResponse, ElementStyleResponse, ElementRefineRequest, ElementRefineResponse, ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo, FolderResponse, BlocksSourceUpdate, BlocksSourceResponse, BlockOverview, BlockChatRequest, BlockChatResponse, BlockExamRequest, BlockExamResponse, BlockLearnStateResponse, BlockPruefenRequest, BlockPruefenResponse, BlockUebernehmenRequest, BlockUebernehmenResponse, ) from paths import blocks_topics, guide_content_path, project_dir, topic_dir, source_path, safe_folder from fsutil import atomic_write_json router = APIRouter(prefix="/api") @router.get("/providers", response_model=list[ProviderInfo]) async def get_providers(): return [{"id": pid, "available": provider_available(pid)} for pid in PROVIDERS] @router.get("/topics") async def get_topics(): db_topics = await db_list_topics() guides = await list_guides() derived = {g["topic"] for g in guides} derived.update(blocks_topics()) derived.update(job["topic"] for job in active_blocks()) # DB is authoritative (order: newest first); append derived entries without a DB row at the end return db_topics + sorted(derived - set(db_topics)) @router.get("/stats") async def get_stats(): """Tracker: number of topics + per format created/completed.""" guides, progress, levels = await load_learnstate() topics = set(await db_list_topics()) | {g["topic"] for g in guides} | set(blocks_topics()) if PROJECTS_DIR.is_dir(): topics |= {e.name for e in PROJECTS_DIR.iterdir() if e.is_dir()} return {"topics": len(topics), "formats": formats_stats(guides, progress, levels)} @router.get("/topics/progress") async def topic_progress(topic: str): """Completion status per format + topic completion — for unlocking the next expansion stage.""" guides, progress, levels = await load_learnstate() status = {fmt: ist_completed(topic, fmt, guides, progress, levels) for fmt in FORMATE} status["completed"] = topic_completed(topic, guides, progress, levels) return status @router.post("/topics") async def add_topic(req: TopicCreateRequest): await create_topic(req.name.strip()) return {"ok": True} @router.delete("/topics") async def remove_topic(topic: str): await delete_topic(topic) await delete_block_data(topic) await delete_topic_pipeline(topic) await delete_source(topic) # topic config (DB) — removed together with the topic await delete_guide_content(topic) shutil.rmtree(topic_dir(topic), ignore_errors=True) return {"ok": True} def _safe_project_name(name: str) -> str: if not name or "/" in name or "\\" in name or ".." in name or "\x00" in name: raise HTTPException(400, "Invalid project name") return name @router.get("/projects", response_model=list[ProjectResponse]) async def list_projects(): if not PROJECTS_DIR.is_dir(): return [] return [{"name": entry.name} for entry in sorted(PROJECTS_DIR.iterdir()) if entry.is_dir()] @router.delete("/projects/{name}") async def remove_project(name: str): _safe_project_name(name) pdir = project_dir(name) if not pdir.is_dir(): raise HTTPException(404, "Project not found") shutil.rmtree(pdir) return {"ok": True} @router.get("/folders", response_model=list[FolderResponse]) async def list_folders(kind: str): """Folders for the source selection: kind=projekt → projects/, kind=uni → uni/.""" base = {"projekt": (PROJECTS_DIR, "projects"), "uni": (UNI_DIR, "uni")}.get(kind) if base is None: raise HTTPException(400, "kind must be 'projekt' or 'uni'") root, prefix = base if not root.is_dir(): return [] return [{"name": e.name, "location": f"{prefix}/{e.name}"} for e in sorted(root.iterdir()) if e.is_dir()] # --- Blocks --- @router.get("/blocks/status", response_model=BlocksStatusResponse) async def get_blocks_status(topic: str): return await blocks_status(topic) @router.get("/blocks/active") async def get_active_blocks(): return active_blocks() @router.post("/blocks") async def create_blocks(req: BlocksCreateRequest): topic = req.topic.strip() if (await blocks_status(topic))["generating"]: return {"ok": True, "status": "already_generating"} await create_topic(topic) qp = source_path(topic) # Persist the source only the FIRST time; ▶/Resume keeps the existing choice. if not qp.exists(): type, location = req.source_type, req.source_location.strip() if type in ("projekt", "uni"): folder = safe_folder(location) if folder is None or not folder.is_dir(): raise HTTPException(400, "Folder invalid or not found (path relative to the project root, no ../).") elif type == "link": if not location.lower().startswith(("http://", "https://")): raise HTTPException(400, "Link must start with http:// or https://.") qp.parent.mkdir(parents=True, exist_ok=True) atomic_write_json(qp, {"type": type, "location": location, "spec": req.instructions.strip()}) asyncio.create_task(generate_blocks(topic, req.instructions.strip(), req.provider, ab_phase=req.ab_phase, ab_step=req.ab_step, to_step=req.to_step)) return {"ok": True} @router.post("/blocks/cancel") async def cancel_blocks_route(topic: str): if not cancel_blocks(topic): raise HTTPException(404, "No running generation") return {"ok": True} @router.delete("/blocks") async def remove_blocks(topic: str): reset_blocks(topic) # Files: crawl + triage + inventory…questions gone; source.json stays await delete_topic_pipeline(topic) # DB: blocks area gone; topic config (source) stays return {"ok": True} @router.post("/blocks/reset-step") async def reset_blocks_step(req: BlocksResetStepRequest): topic = req.topic.strip() if (await blocks_status(topic))["generating"]: return {"ok": True, "status": "generating"} # don't interfere with a running generation await reset_blocks_ab_step(topic, req.ab_step) return {"ok": True} @router.delete("/blocks/progress") async def reset_block_progress(topic: str, block: str): """Reset learning progress of ONE block to zero (score/streak/flags/open question).""" await delete_block_progress(topic, block) return {"ok": True} def _validate_source(type: str, location: str) -> None: """Check source input (same rules as on creation).""" if type in ("projekt", "uni"): folder = safe_folder(location) if folder is None or not folder.is_dir(): raise HTTPException(400, "Folder invalid or not found (path relative to the project root, no ../).") elif type == "link": if not location.lower().startswith(("http://", "https://")): raise HTTPException(400, "Link must start with http:// or https://.") @router.get("/blocks/source", response_model=BlocksSourceResponse) async def get_blocks_source(topic: str): return load_source(topic) @router.put("/blocks/source", response_model=BlocksSourceResponse) async def update_blocks_source(req: BlocksSourceUpdate): """Only save — NO regeneration. Overwrite the source/spec choice.""" topic, type, location = req.topic.strip(), req.type, req.location.strip() _validate_source(type, location) qp = source_path(topic) qp.parent.mkdir(parents=True, exist_ok=True) data = {"type": type, "location": location, "spec": req.spec.strip()} atomic_write_json(qp, data) return data @router.get("/blocks/overview", response_model=list[BlockOverview]) async def get_blocks_uebersicht(topic: str): return await load_overview(topic) @router.get("/blocks/question-pattern") async def get_question_pattern(topic: str, block: str): """Unlocked question patterns of a block (up to the current level; empty = live).""" state = await get_block_progress(topic, block) fe = freie_level(state["good_answers"], await subs_per_level(topic, block)) return {"pattern": await load_question_pattern_free(topic, block, fe)} @router.get("/blocks/artefakte") async def get_artefakte(topic: str, type: str | None = None): """Learning artifacts (flashcards/examples) per topic, grouped by block norm — per subblock.""" rows = await get_sub_artefakte(topic, type) out: dict[str, dict] = {} for r in rows: b = out.setdefault(r["block_norm"], {"block": r["block"], "flashcard": [], "example": []}) if r["block"] and not b["block"]: b["block"] = r["block"] try: data = json.loads(r["data"]) except (ValueError, TypeError): continue if r["type"] in ("flashcard", "example"): b[r["type"]].append({"subblock": r["sub_title"], **data}) return {"artefakte": out} # --- Block learning: chat, exam --- @router.get("/blocks/learnstate", response_model=BlockLearnStateResponse) async def block_learnstate(topic: str): """Exam state per block (raw title as key). cap_final = all subs × 25; cap_aktuell + freie_level from the score — for ALL blocks (even unexamined).""" progress = {p["block"]: p for p in await list_block_progress(topic)} levels = await subs_per_level_raw(topic) def _state(score: int, streak: int, n_je_level: dict[int, int]) -> dict: return { "good_answers": score, "streak": streak, "cap": cap_final(n_je_level), "cap_aktuell": cap_aktuell(score, n_je_level), "freie_level": freie_level(score, n_je_level), } blocks = { b: _state(progress[b]["good_answers"] if b in progress else 0, progress[b]["streak"] if b in progress else 0, n) for b, n in levels.items() } # Legacy blocks with an exam but without subs → empty levels (cap 0). for b, p in progress.items(): if b not in blocks: blocks[b] = _state(p["good_answers"], p["streak"], {}) return {"blocks": blocks} @router.post("/blocks/chat", response_model=BlockChatResponse) async def block_chat_route(req: BlockChatRequest): reply = await block_chat( req.topic, req.block, req.section, req.section_compact, [m.model_dump() for m in req.messages], provider=req.provider, ) return {"reply": reply} # Serialize ratings per (topic, block) — otherwise two simultaneous ratings would # overwrite the absolute score with a stale base (race). _check_locks: dict[tuple[str, str], asyncio.Lock] = {} def _check_lock(topic: str, block: str) -> asyncio.Lock: key = (topic, block) lock = _check_locks.get(key) if lock is None: lock = _check_locks[key] = asyncio.Lock() return lock def _basis(state: dict, question: str) -> tuple[int, bool]: """Score base BEFORE the question. Same open question → re-rating on the same base (idempotent); otherwise a new question on the current state. → (basis, re_rating).""" re_rating = state["offene_question"] == question and state["offene_basis"] is not None return (state["offene_basis"] if re_rating else state["good_answers"]), re_rating def _color(points: int) -> str: """Points delta → rough bubble coloring.""" return "gut" if points > 0 else ("neutral" if points == 0 else "schlecht") async def _book_score(req, question: str, tier: str, n_je_level: dict[int, int]) -> dict: """Book score+streak drift-free (lock + open-question/open-streak anchor). Tier → points delta (streak-modulated) or progressive malus on error. cap_aktuell is derived from the base (delayed unlock at the level threshold); element once from beginner level. Re-rating of the same question uses the open streak anchor → idempotent.""" async with _check_lock(req.topic, req.block): state = await get_block_progress(req.topic, req.block) was_level = state["completed"] is not None # element guard: ever created already? basis, re_rating = _basis(state, question) streak_basis = state["offene_streak"] if re_rating else state["streak"] if not re_rating: await set_open_question(req.topic, req.block, question, basis, state["streak"]) s = thresholds(n_je_level) cf = s[-1] ca = cap_aktuell(basis, n_je_level) floor = floor_from_score(basis, cf, s) d, new_streak = points_delta(tier, streak_basis, basis, ca) score = compute_score(basis, d, floor, ca, cf) points = score - basis good, streak = await set_block_score_and_streak(req.topic, req.block, score, new_streak) # Create the learning element once, as soon as the first level (beginner) is reached. if not was_level and level_from_score(score, cf) is not None: if await set_block_completed(req.topic, req.block): asyncio.create_task(create_block_element(req.topic, req.block, req.section, req.provider)) return {"points": points, "rating": _color(points), "good_answers": good, "streak": streak, "cap": cf} @router.post("/blocks/exam", response_model=BlockExamResponse) async def block_exam_route(req: BlockExamRequest): state = await get_block_progress(req.topic, req.block) good = state["good_answers"] n_je_level = await subs_per_level(req.topic, req.block) cap = cap_final(n_je_level) tier = level_from_score(good, cap) or "beginner" # addressee role of the question fe = freie_level(good, n_je_level) # only check unlocked subs compact = req.section_compact msgs = [m.model_dump() for m in req.messages] if req.action == "question": if req.pattern.strip(): # From a drawn pattern, phrase a concrete question at the tier (no dedup needed). question = await exam_question_variant(req.topic, req.block, req.section, compact, req.pattern, tier=tier, provider=req.provider) else: # Fallback (no pattern sidecar): live generation, focus only on unlocked subs. subs = await subblocks_frei(req.topic, req.block, fe) question = await exam_question(req.topic, req.block, req.section, compact, msgs, subblocks=subs, avoid=req.avoid, tier=tier, provider=req.provider) if question is None: raise HTTPException(502, "Question failed — please try again") return {"question": question, "good_answers": good, "cap": cap} if req.action == "discussion": if not req.question.strip(): raise HTTPException(400, "Discussion needs an active question") reply = await block_discussion( req.topic, req.block, req.section, compact, req.question, req.last_rating or None, msgs, provider=req.provider, ) if reply is None: raise HTTPException(502, "Discussion failed — please try again") return {"reply": reply, "good_answers": good, "cap": cap} # --- Quiz: easy (1 of 4) +1/−1 · hard (x of 4) +3/−1 — deterministic --- if req.action == "quiz_question": if not req.pattern.strip(): raise HTTPException(400, "Quiz needs a pattern") distractors = await hurdles_distractor_block(req.topic, req.block) quiz = await generate_quiz(req.topic, req.block, req.section, compact, req.pattern, tier=tier, provider=req.provider, distractor_block=distractors) if quiz is None: raise HTTPException(502, "Quiz question failed — please try again") return {"question": quiz["question"], "options": quiz["options"], "good_answers": good, "cap": cap} if req.action == "quiz_answer": if not req.question.strip(): raise HTTPException(400, "Quiz answer needs a question") hit = set(req.selection) == set(req.correct) # exactly the correct set res = await _book_score(req, req.question, "strong" if hit else "barely", n_je_level) res["feedback"] = "Correct — all correct ones hit." if hit else "Not quite — the marked ones were correct." return res # --- Gap text: easy (term from 4) +1/−1 · hard (free typing) +3/−1 --- if req.action == "gap_question": if not req.pattern.strip(): raise HTTPException(400, "Gap text needs a pattern") if req.schwer: lt = await generate_gaptext(req.topic, req.block, req.section, compact, req.pattern, tier=tier, provider=req.provider) if lt is None: raise HTTPException(502, "Gap text failed — please try again") return {"sentence": lt["sentence"], "solution": lt["solution"], "alternatives": lt["alternatives"], "good_answers": good, "cap": cap} distractors = await hurdles_distractor_block(req.topic, req.block) lw = await generate_gapchoice(req.topic, req.block, req.section, compact, req.pattern, tier=tier, provider=req.provider, distractor_block=distractors) if lw is None: raise HTTPException(502, "Gap text failed — please try again") return {"sentence": lw["sentence"], "options": lw["options"], "good_answers": good, "cap": cap} if req.action == "gap_answer": if not req.question.strip(): raise HTTPException(400, "Gap-text answer needs a sentence") if req.schwer: # free typed → synonym-tolerant AI check ok = await check_gaptext(req.topic, req.block, req.question, req.solution, req.alternatives, req.input, provider=req.provider) feedback = "Correct!" if ok else f"Not quite — expected „{req.solution}“." else: # term chosen from 4 → deterministic ok = set(req.selection) == set(req.correct) feedback = "Correct!" if ok else "Not quite — the marked term was correct." res = await _book_score(req, req.question, "strong" if ok else "barely", n_je_level) res["feedback"] = feedback return res # action "answer" (Agent 1 fast) / "answer_check" (Agent 2 thorough). if not any(m.get("role") == "user" for m in msgs): raise HTTPException(400, "Answer needs a user answer") if not req.question.strip(): raise HTTPException(400, "Answer needs an active question") if req.action == "answer": # Agent 1: preview only — tier + expected points, persist NOTHING, no anchor. data = await exam_rating_fast( req.topic, req.block, req.section, compact, req.question, msgs, provider=req.provider, ) if data is None: raise HTTPException(502, "Rating failed — please try again") basis, re_rating = _basis(state, req.question) streak_basis = state["offene_streak"] if re_rating else state["streak"] s = thresholds(n_je_level) ca = cap_aktuell(basis, n_je_level) floor = floor_from_score(basis, s[-1], s) tier = cap_followup(data["tier"], req.asked_again) d, _ = points_delta(tier, streak_basis, basis, ca) score = compute_score(basis, d, floor, ca, s[-1]) points = score - basis return {"feedback": data["feedback"], "points": points, "rating": _color(points), "good_answers": good, "cap": cap} # action "answer_check" (Agent 2 thorough): binding, persisted. ONLY here does the score change. # The LLM runs WITHOUT a lock; booking is done briefly via _book_score (anchor + score), as with quiz/gap. # This way the long AI rating doesn't block a following (deterministic) answer of the same block. data = await exam_rating( req.topic, req.block, req.section, compact, req.question, msgs, provider=req.provider, role="guide" if req.thorough else "judge", reason=req.reason, ) if data is None: raise HTTPException(502, "Rating failed — please try again") tier = cap_followup(data["tier"], req.asked_again) res = await _book_score(req, req.question, tier, n_je_level) # short lock: drift-free base via anchor res["feedback"] = data["feedback"] return res # --- Guides --- @router.post("/guides", response_model=GuideResponse) async def create(req: GuideCreateRequest): guides, progress, levels = await load_learnstate() reason = guide_lock(req.topic.strip(), req.format, guides, progress, levels) if reason: raise HTTPException(400 if reason == "Erst Blocks erstellen" else 409, reason) # string matches rules.py contract await create_topic(req.topic.strip()) now = datetime.now(timezone.utc).isoformat() guide = { "id": str(uuid.uuid4()), "topic": req.topic.strip(), "format": req.format, "instructions": req.instructions.strip(), "status": "queued", "progress": None, "created_at": now, "updated_at": now, } await create_guide(guide) asyncio.create_task(generate_guide(guide["id"], guide["topic"], guide["format"], guide["instructions"], req.provider, ab_step=req.ab_step)) return guide @router.get("/guides", response_model=list[GuideResponse]) async def list_all(): return await list_guides() @router.get("/guides/locks") async def guide_locks(topic: str): """Lock reasons per format for the ▶ button — None = creatable.""" guides, progress, levels = await load_learnstate() return {fmt: guide_lock(topic, fmt, guides, progress, levels) for fmt in ("FullGuide", "Rest", *FORMATE)} @router.get("/guides/steps") async def guide_steps(topic: str): """Highest fully completed step index per format (artifact-based, -1 = none). Drives the clickable step bubbles (like the blocks phases).""" return {fmt: guide_done_step(guide_content_path(topic, fmt)) for fmt in ("Guide", "FullGuide", "Rest")} @router.get("/guides/{guide_id}", response_model=GuideResponse) async def get_one(guide_id: str): guide = await get_guide(guide_id) if guide is None: raise HTTPException(404, "Guide not found") return guide @router.get("/guides/{guide_id}/content") async def guide_content(guide_id: str, level: int = 4): """Guide content. `level` (1=A · 2=F · 3=E · 4=V) filters to subblocks up to this level; 4 = full version (raw, unchanged).""" guide = await get_guide(guide_id) if guide is None: raise HTTPException(404, "Guide not found") if guide["status"] != "done": raise HTTPException(404, "Content not available") stored = await get_guide_content(guide["topic"], guide["format"]) # DB-first if stored is None: path = guide_content_path(guide["topic"], guide["format"]) # fallback: file (legacy topics) if not path.exists(): raise HTTPException(404, "File not found") stored = path.read_text(encoding="utf-8") if level >= 4: return Response(content=stored, media_type="application/json") # full version, raw try: return content_fuer_level(json.loads(stored), level) except ValueError: return Response(content=stored, media_type="application/json") @router.post("/guides/{guide_id}/chat", response_model=GuideChatResponse) async def guide_chat(guide_id: str, req: GuideChatRequest): guide = await get_guide(guide_id) if guide is None: raise HTTPException(404, "Guide not found") reply = await chat_with_guide( guide["topic"], guide["format"], req.section, req.outline, [m.model_dump() for m in req.messages], provider=req.provider, ) return {"reply": reply} async def _guide_tf(guide_id: str) -> tuple[str, str]: guide = await get_guide(guide_id) if guide is None: raise HTTPException(404, "Guide not found") return guide["topic"], guide["format"] @router.post("/guides/{guide_id}/block/pruefen", response_model=BlockPruefenResponse) async def block_pruefen_route(guide_id: str, req: BlockPruefenRequest): topic, fmt = await _guide_tf(guide_id) new = await block_pruefen(topic, fmt, req.block, req.spot, req.snippet, req.hint, provider=req.provider) if new is None: raise HTTPException(502, "Check failed — please try again") return {"revised": new} @router.post("/guides/{guide_id}/block/uebernehmen", response_model=BlockUebernehmenResponse) async def block_adopt_route(guide_id: str, req: BlockUebernehmenRequest): topic, fmt = await _guide_tf(guide_id) res = await block_adopt(topic, fmt, req.block, req.spot, req.alt, req.revised) if res is None: raise HTTPException(404, "Section not found") return res # --- Elements (personal summary) --- @router.get("/elements", response_model=list[ElementResponse]) async def get_elements(topic: str): return await list_elements(topic) @router.post("/elements", response_model=ElementResponse) async def post_element(req: ElementCreateRequest): fields = await generate_element(req.topic, req.hint, provider=req.provider) now = datetime.now(timezone.utc).isoformat() element = {"id": str(uuid.uuid4()), "topic": req.topic, **fields, "created_at": now, "updated_at": now} await create_element(element) return element @router.post("/elements/{element_id}/chat", response_model=ElementChatResponse) async def element_chat(element_id: str, req: ElementChatRequest): element = await get_element(element_id) if element is None: raise HTTPException(404, "Element not found") reply, changes = await chat_with_element(element, [m.model_dump() for m in req.messages], provider=req.provider) return {"reply": reply, "changes": changes} @router.post("/elements/{element_id}/refine", response_model=ElementRefineResponse) async def element_refine(element_id: str, req: ElementRefineRequest): element = await get_element(element_id) if element is None: raise HTTPException(404, "Element not found") change = await refine_suggestion(element, req.suggestion.model_dump(), req.instruction, provider=req.provider) if change is None: raise HTTPException(502, "Revision failed — please try again") return {"change": change} @router.put("/elements/{element_id}", response_model=ElementResponse) async def put_element(element_id: str, req: ElementUpdateRequest): if await get_element(element_id) is None: raise HTTPException(404, "Element not found") fields = req.model_dump(exclude_unset=True, exclude_none=True) if fields: now = datetime.now(timezone.utc).isoformat() await update_element(element_id, **fields, updated_at=now) return await get_element(element_id) @router.post("/elements/{element_id}/style", response_model=ElementStyleResponse) async def element_style(element_id: str, req: ElementCheckRequest): element = await get_element(element_id) if element is None: raise HTTPException(404, "Element not found") changes = await style_element(element, provider=req.provider) if changes is None: raise HTTPException(502, "Style check failed — please try again") return {"changes": changes} @router.post("/elements/{element_id}/check", response_model=ElementCheckResponse) async def element_check(element_id: str, req: ElementCheckRequest): element = await get_element(element_id) if element is None: raise HTTPException(404, "Element not found") suggestions = await check_element(element, provider=req.provider) if suggestions is None: raise HTTPException(502, "Check failed — please try again") return {"suggestions": suggestions} @router.delete("/elements/{element_id}") async def remove_element(element_id: str): if not await delete_element(element_id): raise HTTPException(404, "Element not found") return {"ok": True} @router.post("/guides/{guide_id}/cancel") async def cancel(guide_id: str): cancelled = await cancel_guide(guide_id) if not cancelled: raise HTTPException(404, "No active process found") return {"ok": True} @router.delete("/guides/{guide_id}") async def remove(guide_id: str, slots: bool = False): guide = await get_guide(guide_id) if guide is None: raise HTTPException(404, "Guide not found") await delete_progress(guide_id) await delete_guide(guide_id) # Content/step files are shared by all runs of a topic+format — only delete them # once no entry needs them anymore. Partial progress (step files without finished # content) is kept for resume, unless explicitly requested (slots=1). rest = [g for g in await list_guides() if g["topic"] == guide["topic"] and g["format"] == guide["format"]] if not rest: await delete_guide_content(guide["topic"], guide["format"]) content = guide_content_path(guide["topic"], guide["format"]) if slots or content.exists(): for p in guide_slot_files(content): p.unlink(missing_ok=True) content.unlink(missing_ok=True) return {"ok": True} @router.get("/guides/{guide_id}/progress", response_model=ProgressResponse) async def get_progress(guide_id: str): guide = await get_guide(guide_id) if guide is None: raise HTTPException(404, "Guide not found") return {"chapters": await list_progress(guide_id)} @router.post("/guides/{guide_id}/progress", response_model=ProgressResponse) async def update_progress(guide_id: str, req: ProgressUpdate): guide = await get_guide(guide_id) if guide is None: raise HTTPException(404, "Guide not found") await set_progress(guide_id, req.chapter, req.done) return {"chapters": await list_progress(guide_id)}