Files
creator/backend/routes.py
2026-07-05 10:38:24 +02:00

824 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import json
import logging
import shutil
import uuid
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, HTTPException
from fastapi.responses import Response
from agents import active_agents, provider_available
from config import DEFAULT_PROVIDER, PROJECTS_DIR, UNI_DIR, PROVIDERS
from database import (
create_guide, delete_guide, get_guide, list_guides, update_guide,
create_topic, list_topics as db_list_topics, delete_topic,
list_block_progress, get_block_progress, set_open_question,
set_block_score_and_streak,
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, kanban_reset, delete_guide_board,
get_practice_progress, upsert_practice_progress, sub_levels_norm, subs_per_level_norm,
)
from textkit import _norm_title
from blocks import generate_blocks, cancel_blocks, blocks_status, active_blocks, reset_blocks, load_source, load_overview, subblocks_title, subblocks_frei, load_question_pattern, load_question_pattern_free, _blocks_files
from board_inventory import add_research_agent, board_snapshot, requeue_dead, reset_board_from_stage, restart_artefact_card
from learning import block_chat, block_discussion, 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, leitner_step, PRACTICE_NEW_PER_SESSION
from guide import chat_with_guide, generate_guide, guide_slot_files, 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, BlocksResetStageRequest, BlocksCardRestartRequest, BlocksStatusResponse, QaRunRequest, RepairRequest,
GuideCardResetRequest, GuideFormatRequest,
GuideBoardResetRequest, GuideChatRequest, GuideChatResponse,
ProviderInfo,
FolderResponse, BlocksSourceUpdate, BlocksSourceResponse, BlockOverview,
BlockChatRequest, BlockChatResponse,
BlockExamRequest, BlockExamResponse, BlockLearnStateResponse,
BlockPruefenRequest, BlockPruefenResponse, BlockUebernehmenRequest, BlockUebernehmenResponse,
PracticeAnswerRequest,
)
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, 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, levels)}
@router.get("/topics/progress")
async def topic_progress(topic: str):
"""Completion status per format + topic completion — for unlocking the next expansion stage."""
guides, levels = await load_learnstate()
status = {fmt: ist_completed(topic, fmt, guides, levels) for fmt in FORMATE}
status["completed"] = topic_completed(topic, guides, 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)
import qa
shutil.rmtree(qa.QA_DIR / topic, ignore_errors=True) # QA reports belong to the topic
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,
research=req.research, qa_force=req.qa_force))
return {"ok": True}
@router.get("/blocks/board")
async def get_blocks_board(topic: str):
"""Live kanban board: columns with counts + newest cards, dead-letter, agents."""
snap = await board_snapshot(topic)
status = await blocks_status(topic)
snap["generating"] = status["generating"]
snap["progress"] = status["progress"]
snap["error"] = status["error"]
snap["agents"] = [{"key": a["key"], "label": a["label"] or a["key"].removeprefix(f"blocks-{topic}-"), "runtime": a["runtime"]}
for a in active_agents(f"blocks-{topic}-")]
return snap
_qa_laeuft: set[str] = set()
@router.post("/blocks/qa")
async def run_qa_route(req: QaRunRequest):
"""Manual QA run (like the gate: incl. LLM samples); the badge reads the written report."""
if req.topic in _qa_laeuft:
return {"status": "läuft bereits"}
_qa_laeuft.add(req.topic)
try:
import qa
report = await qa.qa_report(req.topic, llm=req.llm)
if report is None:
raise HTTPException(status_code=404, detail="keine fertigen Bausteine")
await qa.write_report(report)
note_guide = None
try: # fertiger Guide vorhanden → mitmessen (ein Klick, drei Noten); best-effort
import guide_qa
from database import list_guide_cards
if any(c["stage"] == "done" and (c.get("md") or "").strip()
for c in await list_guide_cards(req.topic)):
grep = await guide_qa.guide_qa_report(req.topic, llm=req.llm)
if grep:
await asyncio.to_thread(guide_qa._write_report, grep)
note_guide = grep["note_guide"]
except Exception:
logging.getLogger("creator.routes").exception("[%s] Guide-QA im QA-Button fehlgeschlagen", req.topic)
return {"note": report["note"], "note_artefakte": report["note_artefakte"],
"note_guide": note_guide}
finally:
_qa_laeuft.discard(req.topic)
_repair_laeuft: set[str] = set()
@router.post("/blocks/repair")
async def run_repair_route(req: RepairRequest):
"""Fix the latest QA findings in place: hygiene, confirmed duplicates, foreign/unreal blocks."""
topic = req.topic.strip()
if (await blocks_status(topic))["generating"]:
return {"status": "generating"}
if topic in _repair_laeuft:
return {"status": "läuft bereits"}
_repair_laeuft.add(topic)
try:
import repair
res = await repair.repair_befunde(topic)
if "fehler" in res:
raise HTTPException(status_code=404, detail=res["fehler"])
return res
finally:
_repair_laeuft.discard(topic)
@router.post("/blocks/research")
async def add_blocks_research(topic: str, provider: str = DEFAULT_PROVIDER):
"""Attach one more research agent — to the live flow, or attach-or-start."""
if add_research_agent(topic):
return {"ok": True, "attached": True}
if (await blocks_status(topic))["generating"]:
return {"ok": False, "status": "starting"} # flow is booting, try again shortly
asyncio.create_task(generate_blocks(topic, "", provider, research=True))
return {"ok": True, "attached": False}
@router.post("/blocks/reset-stage")
async def reset_blocks_stage(req: BlocksResetStageRequest):
"""Reset cards from a column onward back to that column (no regeneration)."""
topic = req.topic.strip()
if (await blocks_status(topic))["generating"]:
return {"ok": True, "status": "generating"} # don't interfere with a running generation
moved = await reset_board_from_stage(topic, req.board, req.stage, _blocks_files(topic))
return {"ok": True, "moved": moved}
@router.post("/blocks/requeue-dead")
async def requeue_blocks_dead(topic: str):
return {"ok": True, "requeued": await requeue_dead(topic)}
@router.post("/blocks/card-restart")
async def blocks_card_restart(req: BlocksCardRestartRequest):
if (await blocks_status(req.topic))["generating"]:
return {"ok": True, "status": "generating"}
if not await restart_artefact_card(req.topic, req.card_id):
raise HTTPException(404, "Card not found")
return {"ok": True}
@router.post("/guides/board/card-reset")
async def guide_card_reset(req: GuideCardResetRequest):
running = [g for g in await list_guides()
if g["topic"] == req.topic and g["format"] == req.format
and g["status"] in ("queued", "generating")]
if running:
return {"ok": True, "status": "generating"}
from guide_board import reset_card
if not await reset_card(req.topic, req.format, req.block_norm, req.ab_stage):
raise HTTPException(404, "Card not found")
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
await kanban_reset(topic) # kanban cards + cluster membership gone
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/completeness")
async def blocks_completeness(topic: str):
"""Beleg der Themen-Zerlegung: Bestand, Filter-Bilanz, Lernziele, Artefakte, Laufzeit."""
import glob as _glob
from pathlib import Path as _Path
from paths import arbeit_dir
from database import (kanban_stage_counts, list_blocks, list_subblocks, list_lernziele,
count_question_pattern_blocks, count_sub_artefakte, event_span)
counts = await kanban_stage_counts(topic)
inv = counts.get("inventory", {})
blocks = await list_blocks(topic, status="consensus")
subs = 0
for b in blocks:
subs += sum(1 for s in await list_subblocks(topic, b["title_norm"]) if s["status"] == "consensus")
ziele = await list_lernziele(topic)
dead = sum(v for board in counts.values() for s, v in board.items() if s == "dead")
degradiert = ueberstimmt = 0
for p in _glob.glob(str(arbeit_dir(topic) / "inventar-filter*.json")):
try:
d = json.loads(_Path(p).read_text(encoding="utf-8"))
degradiert += d.get("degradiert", 0)
ueberstimmt += len(d.get("ueberstimmt", []))
except Exception:
continue
status = await blocks_status(topic)
return {
"bloecke": len(blocks), "subs": subs,
"verworfen": inv.get("rejected", 0), "zusammengelegt": inv.get("grouped", 0),
"degradiert_geprueft": degradiert, "panel_gerettet": ueberstimmt,
"ziele_total": len(ziele), "ziele_covered": sum(1 for z in ziele if z["covered"]),
"frage_bloecke": await count_question_pattern_blocks(topic),
"lernkarten": await count_sub_artefakte(topic),
"dead": dead, "lauf_minuten": await event_span(topic),
"vollstaendig": bool(status.get("ready")) and dead == 0,
}
@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)}
# --- Practice deck: Leitner flashcard pool per topic ---
async def build_practice_deck(topic: str) -> dict:
"""ONE stack per topic (spacing beats per-block mini-stacks): due cards first
(oldest due_at), then up to PRACTICE_NEW_PER_SESSION new ones. Level gate via the
block's exam score (freie_level) — locked cards are counted for transparency."""
cards = await get_sub_artefakte(topic, "flashcard")
levels = await sub_levels_norm(topic)
n_je = await subs_per_level_norm(topic)
progress = {_norm_title(p["block"]): p["good_answers"] for p in await list_block_progress(topic)}
pp = {(p["block_norm"], p["sub_norm"]): p for p in await get_practice_progress(topic)}
now = datetime.now(timezone.utc).isoformat()
due, new, future, gesperrt = [], [], [], 0
for r in cards:
bn, sn = r["block_norm"], r["sub_norm"]
n_block = n_je.get(bn)
if n_block is not None: # legacy blocks without level data pass unfiltered
if levels.get((bn, sn), 1) > freie_level(progress.get(bn, 0), n_block):
gesperrt += 1
continue
try:
data = json.loads(r["data"])
except (ValueError, TypeError):
continue
card = {"block": r["block"], "block_norm": bn, "sub_norm": sn,
"subblock": r["sub_title"], "question": data.get("question", ""),
"answer": data.get("answer", "")}
p = pp.get((bn, sn))
if p is None:
card.update(box=None, status="new")
new.append(card)
elif p["due_at"] <= now:
card.update(box=p["box"], status="due", due_at=p["due_at"])
due.append(card)
else:
future.append(p["due_at"])
due.sort(key=lambda c: c["due_at"])
new_total = len(new)
new = new[:PRACTICE_NEW_PER_SESSION]
return {"cards": due + new,
"counts": {"due": len(due), "new": len(new), "new_total": new_total,
"gesperrt": gesperrt},
"next_due_at": min(future) if future else None}
@router.get("/practice/deck")
async def practice_deck(topic: str):
return await build_practice_deck(topic)
@router.post("/practice/answer")
async def practice_answer(req: PracticeAnswerRequest):
"""Book a Leitner step. Deliberately NO existence check against sub_artefakte:
an answer during regeneration books instead of failing (worst case an orphan row)."""
pp = {(p["block_norm"], p["sub_norm"]): p for p in await get_practice_progress(req.topic)}
prev = pp.get((req.block_norm, req.sub_norm))
box, days = leitner_step(prev["box"] if prev else None, req.correct)
due_at = (datetime.now(timezone.utc) + timedelta(days=days)).isoformat()
await upsert_practice_progress(req.topic, req.block_norm, req.sub_norm, box, due_at)
return {"box": box, "due_at": due_at}
# --- 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).
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)
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)
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, levels = await load_learnstate()
reason = guide_lock(req.topic.strip(), req.format, guides, 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/board")
async def get_guide_board(topic: str, format: str = "Guide"):
"""Live guide board: columns with counts + cards (rounds, covered objectives), agents."""
import guide_board
snap = await guide_board.board_snapshot(topic, format)
guide = next((g for g in await list_guides()
if g["topic"] == topic and g["format"] == format), None)
snap["generating"] = bool(guide and guide["status"] in ("queued", "generating"))
snap["guide_id"] = guide["id"] if guide else None
snap["progress"] = guide.get("progress") if guide else None
snap["error"] = guide.get("error_msg") if guide else None
prefix = f"{guide['id']}-" if guide else "-"
snap["agents"] = [{"key": a["key"], "label": a["label"] or a["key"].removeprefix(prefix), "runtime": a["runtime"]}
for a in active_agents(prefix)]
return snap
@router.post("/guides/board/repair")
async def repair_guide_board(req: GuideBoardResetRequest):
"""Befunde beheben fürs Guide-Board: Karten mit QA-Befunden zurück auf `pruefer`,
dann resumt generate_guide die offenen Karten (Prüfer+Fix) und misst neu."""
import guide_board
topic = req.topic.strip()
guide = next((g for g in await list_guides()
if g["topic"] == topic and g["format"] == req.format), None)
if guide is None:
raise HTTPException(404, "Kein Guide für dieses Topic/Format")
if guide["status"] in ("queued", "generating"):
return {"ok": True, "status": "generating", "betroffen": []}
betroffen = await guide_board.repair_karten(topic, req.format)
if betroffen:
now = datetime.now(timezone.utc).isoformat()
await update_guide(guide["id"], status="queued", progress="Repair…", updated_at=now)
asyncio.create_task(generate_guide(guide["id"], topic, req.format,
guide.get("instructions") or "", DEFAULT_PROVIDER))
return {"ok": True, "betroffen": betroffen}
@router.post("/guides/board/reset")
async def reset_guide_board(req: GuideBoardResetRequest):
"""Reset cards from a stage onward — without generation (pendant to blocks reset-stage)."""
import guide_board
topic = req.topic.strip()
guide = next((g for g in await list_guides()
if g["topic"] == topic and g["format"] == req.format), None)
if guide and guide["status"] in ("queued", "generating"):
return {"ok": True, "status": "generating"}
moved = await guide_board.reset_from_stage(topic, req.format, req.ab_stage)
return {"ok": True, "moved": moved}
@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
@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.post("/guides/board/remove")
async def remove_guide_format(req: GuideFormatRequest):
"""Board-Remove: discard ALL runs of topic+format — old error rows pile up, and the
per-guide delete keeps the board cards until the LAST row is gone (measured: 8 rows)."""
doomed = [g for g in await list_guides() if g["topic"] == req.topic and g["format"] == req.format]
if any(g["status"] in ("queued", "generating") for g in doomed):
return {"ok": True, "status": "generating"}
for g in doomed:
await delete_guide(g["id"])
await delete_guide_content(req.topic, req.format)
await delete_guide_board(req.topic, req.format)
content = guide_content_path(req.topic, req.format)
for p in guide_slot_files(content):
p.unlink(missing_ok=True)
content.unlink(missing_ok=True)
return {"ok": True, "removed": len(doomed)}
@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_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"])
await delete_guide_board(guide["topic"], guide["format"]) # board cards + lernziele
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}