803 lines
36 KiB
Python
803 lines
36 KiB
Python
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 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_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 asyncio.to_thread(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 = "claude"):
|
||
"""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/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}
|