This commit is contained in:
team3
2026-06-30 00:14:18 +02:00
parent 3e3559aa8f
commit c794fcaccf
152 changed files with 9485 additions and 9583 deletions

View File

@@ -14,33 +14,33 @@ from database import (
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_baustein_progress, get_baustein_progress, set_offene_frage,
set_baustein_score_and_streak, set_baustein_absolviert,
delete_baustein_daten, delete_baustein_progress, subs_je_ebene, subs_je_ebene_roh,
delete_topic_pipeline, delete_quelle, get_guide_content, delete_guide_content,
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 bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, reset_bausteine_ab_step, lade_quelle, lade_uebersicht, subbausteine_titel, subbausteine_frei, lade_frage_muster, lade_frage_muster_frei
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 lernen import baustein_chat, baustein_diskussion, baustein_element_anlegen, pruefung_bewertung, pruefung_bewertung_schnell, pruefung_frage, pruefung_frage_variante, quiz_generieren, lueckwahl_generieren, lueckentext_generieren, lueckentext_pruefen, huerden_distraktor_block, score_berechnen, floor_aus_score, stufe_aus_score, cap_final, cap_aktuell, freie_ebene, schwellen, punkte_delta, deckel_nachfrage
from guide import generate_guide, guide_slot_dateien, guide_fertig_step, block_pruefen, block_uebernehmen, content_fuer_ebene
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 regeln import FORMATE, formate_stats, guide_lock, ist_absolviert, lade_lernstand, thema_abgeschlossen
from rules import FORMATE, formats_stats, guide_lock, ist_completed, load_learnstate, topic_completed
from models import (
GuideCreateRequest, GuideResponse,
TopicCreateRequest,
BausteineCreateRequest, BausteineResetStepRequest, BausteineStatusResponse,
BlocksCreateRequest, BlocksResetStepRequest, BlocksStatusResponse,
GuideChatRequest, GuideChatResponse,
ElementCreateRequest, ElementChatRequest, ElementChatResponse, ElementResponse,
ElementUpdateRequest, ElementCheckRequest, ElementCheckResponse, ElementStyleResponse,
ElementRefineRequest, ElementRefineResponse,
ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo,
FolderResponse, BausteineQuelleUpdate, BausteineQuelleResponse, BausteinUebersicht,
BausteinChatRequest, BausteinChatResponse,
BausteinPruefungRequest, BausteinPruefungResponse, BausteinLernstandResponse,
FolderResponse, BlocksSourceUpdate, BlocksSourceResponse, BlockOverview,
BlockChatRequest, BlockChatResponse,
BlockExamRequest, BlockExamResponse, BlockLearnStateResponse,
BlockPruefenRequest, BlockPruefenResponse, BlockUebernehmenRequest, BlockUebernehmenResponse,
)
from paths import bausteine_topics, guide_content_path, project_dir, topic_dir, quelle_path, safe_ordner
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")
@@ -56,28 +56,28 @@ async def get_topics():
db_topics = await db_list_topics()
guides = await list_guides()
derived = {g["topic"] for g in guides}
derived.update(bausteine_topics())
derived.update(job["topic"] for job in active_bausteine())
# DB ist führend (Reihenfolge: neueste zuerst); Abgeleitetes ohne DB-Eintrag hinten anhängen
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: Themen-Anzahl + pro Format erstellt/absolviert."""
guides, progress, levels = await lade_lernstand()
themen = set(await db_list_topics()) | {g["topic"] for g in guides} | set(bausteine_topics())
"""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():
themen |= {e.name for e in PROJECTS_DIR.iterdir() if e.is_dir()}
return {"themen": len(themen), "formate": formate_stats(guides, progress, levels)}
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/fortschritt")
async def topic_fortschritt(topic: str):
"""Absolviert-Status pro Format + Themen-Abschluss — fürs Freischalten der nächsten Ausbaustufe."""
guides, progress, levels = await lade_lernstand()
status = {fmt: ist_absolviert(topic, fmt, guides, progress, levels) for fmt in FORMATE}
status["abgeschlossen"] = thema_abgeschlossen(topic, 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
@@ -90,9 +90,9 @@ async def add_topic(req: TopicCreateRequest):
@router.delete("/topics")
async def remove_topic(topic: str):
await delete_topic(topic)
await delete_baustein_daten(topic)
await delete_block_data(topic)
await delete_topic_pipeline(topic)
await delete_quelle(topic) # Themen-Config (DB) — beim Thema-Löschen mit weg
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}
@@ -100,7 +100,7 @@ async def remove_topic(topic: str):
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, "Ungültiger Projektname")
raise HTTPException(400, "Invalid project name")
return name
@@ -116,356 +116,356 @@ async def remove_project(name: str):
_safe_project_name(name)
pdir = project_dir(name)
if not pdir.is_dir():
raise HTTPException(404, "Projekt nicht gefunden")
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):
"""Ordner für die Quellen-Auswahl: kind=projekt → projects/, kind=uni → uni/."""
"""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 muss 'projekt' oder 'uni' sein")
raise HTTPException(400, "kind must be 'projekt' or 'uni'")
root, prefix = base
if not root.is_dir():
return []
return [{"name": e.name, "ort": f"{prefix}/{e.name}"} for e in sorted(root.iterdir()) if e.is_dir()]
return [{"name": e.name, "location": f"{prefix}/{e.name}"} for e in sorted(root.iterdir()) if e.is_dir()]
# --- Bausteine ---
# --- Blocks ---
@router.get("/bausteine/status", response_model=BausteineStatusResponse)
async def get_bausteine_status(topic: str):
return bausteine_status(topic)
@router.get("/blocks/status", response_model=BlocksStatusResponse)
async def get_blocks_status(topic: str):
return await blocks_status(topic)
@router.get("/bausteine/active")
async def get_active_bausteine():
return active_bausteine()
@router.get("/blocks/active")
async def get_active_blocks():
return active_blocks()
@router.post("/bausteine")
async def create_bausteine(req: BausteineCreateRequest):
@router.post("/blocks")
async def create_blocks(req: BlocksCreateRequest):
topic = req.topic.strip()
if bausteine_status(topic)["generating"]:
if (await blocks_status(topic))["generating"]:
return {"ok": True, "status": "already_generating"}
await create_topic(topic)
qp = quelle_path(topic)
# Quelle nur beim ERSTEN Mal festschreiben; ▶/Resume erhält die bestehende Wahl.
qp = source_path(topic)
# Persist the source only the FIRST time; ▶/Resume keeps the existing choice.
if not qp.exists():
typ, ort = req.source_type, req.source_ort.strip()
if typ in ("projekt", "uni"):
ordner = safe_ordner(ort)
if ordner is None or not ordner.is_dir():
raise HTTPException(400, "Ordner ungültig oder nicht gefunden (Pfad relativ zum Projekt-Root, kein ../).")
elif typ == "link":
if not ort.lower().startswith(("http://", "https://")):
raise HTTPException(400, "Link muss mit http:// oder https:// beginnen.")
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": typ, "ort": ort, "spec": req.instructions.strip()})
asyncio.create_task(generate_bausteine(topic, req.instructions.strip(), req.provider, ab_phase=req.ab_phase, ab_step=req.ab_step))
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))
return {"ok": True}
@router.post("/bausteine/cancel")
async def cancel_bausteine_route(topic: str):
if not cancel_bausteine(topic):
raise HTTPException(404, "Keine laufende Generierung")
@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("/bausteine")
async def remove_bausteine(topic: str):
reset_bausteine(topic) # Dateien: Crawl + Sichtung + Inventar…Fragen weg; quelle.json bleibt
await delete_topic_pipeline(topic) # DB: Bausteine-Bereich weg; Themen-Config (quelle) bleibt
@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("/bausteine/reset-step")
async def reset_bausteine_step(req: BausteineResetStepRequest):
@router.post("/blocks/reset-step")
async def reset_blocks_step(req: BlocksResetStepRequest):
topic = req.topic.strip()
if bausteine_status(topic)["generating"]:
return {"ok": True, "status": "generating"} # nicht in laufende Generierung eingreifen
await reset_bausteine_ab_step(topic, req.ab_step)
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("/bausteine/fortschritt")
async def reset_baustein_fortschritt(topic: str, baustein: str):
"""Lern-Fortschritt EINES Bausteins auf null (Score/Streak/Flags/offene Frage)."""
await delete_baustein_progress(topic, baustein)
@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_quelle(typ: str, ort: str) -> None:
"""Quellen-Eingabe prüfen (gleiche Regeln wie beim Erstellen)."""
if typ in ("projekt", "uni"):
ordner = safe_ordner(ort)
if ordner is None or not ordner.is_dir():
raise HTTPException(400, "Ordner ungültig oder nicht gefunden (Pfad relativ zum Projekt-Root, kein ../).")
elif typ == "link":
if not ort.lower().startswith(("http://", "https://")):
raise HTTPException(400, "Link muss mit http:// oder https:// beginnen.")
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("/bausteine/quelle", response_model=BausteineQuelleResponse)
async def get_bausteine_quelle(topic: str):
return lade_quelle(topic)
@router.get("/blocks/source", response_model=BlocksSourceResponse)
async def get_blocks_source(topic: str):
return load_source(topic)
@router.put("/bausteine/quelle", response_model=BausteineQuelleResponse)
async def update_bausteine_quelle(req: BausteineQuelleUpdate):
"""Nur speichern — KEINE Neugenerierung. Quellen-/Spec-Wahl überschreiben."""
topic, typ, ort = req.topic.strip(), req.type, req.ort.strip()
_validate_quelle(typ, ort)
qp = quelle_path(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)
daten = {"type": typ, "ort": ort, "spec": req.spec.strip()}
atomic_write_json(qp, daten)
return daten
data = {"type": type, "location": location, "spec": req.spec.strip()}
atomic_write_json(qp, data)
return data
@router.get("/bausteine/uebersicht", response_model=list[BausteinUebersicht])
async def get_bausteine_uebersicht(topic: str):
return await lade_uebersicht(topic)
@router.get("/blocks/overview", response_model=list[BlockOverview])
async def get_blocks_uebersicht(topic: str):
return await load_overview(topic)
@router.get("/bausteine/frage-muster")
async def get_frage_muster(topic: str, baustein: str):
"""Freigeschaltete Frage-Muster eines Bausteins (bis zur aktuellen Ebene; leer = Live)."""
stand = await get_baustein_progress(topic, baustein)
fe = freie_ebene(stand["gute_antworten"], await subs_je_ebene(topic, baustein))
return {"muster": await lade_frage_muster_frei(topic, baustein, fe)}
@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("/bausteine/artefakte")
async def get_artefakte(topic: str, typ: str | None = None):
"""Lern-Artefakte (Karteikarten/Beispiele) je Thema, gruppiert nach Baustein-Norm — je Subbaustein."""
rows = await get_sub_artefakte(topic, typ)
@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["baustein_norm"], {"baustein": r["baustein"], "karteikarte": [], "beispiel": []})
if r["baustein"] and not b["baustein"]:
b["baustein"] = r["baustein"]
b = out.setdefault(r["block_norm"], {"block": r["block"], "flashcard": [], "example": []})
if r["block"] and not b["block"]:
b["block"] = r["block"]
try:
daten = json.loads(r["daten"])
data = json.loads(r["data"])
except (ValueError, TypeError):
continue
if r["typ"] in ("karteikarte", "beispiel"):
b[r["typ"]].append({"subbaustein": r["sub_titel"], **daten})
if r["type"] in ("flashcard", "example"):
b[r["type"]].append({"subblock": r["sub_title"], **data})
return {"artefakte": out}
# --- Baustein-Lernen: Chat, Prüfung ---
# --- Block learning: chat, exam ---
@router.get("/bausteine/lernstand", response_model=BausteinLernstandResponse)
async def baustein_lernstand(topic: str):
"""Prüfungs-Stand pro Baustein (roher Titel als Key). cap_final = alle Subs × 25;
cap_aktuell + freie_ebene aus dem Score — für ALLE Bausteine (auch ungeprüfte)."""
progress = {p["baustein"]: p for p in await list_baustein_progress(topic)}
ebenen = await subs_je_ebene_roh(topic)
@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 _stand(score: int, streak: int, n_je_ebene: dict[int, int]) -> dict:
def _state(score: int, streak: int, n_je_level: dict[int, int]) -> dict:
return {
"gute_antworten": score, "streak": streak,
"cap": cap_final(n_je_ebene),
"cap_aktuell": cap_aktuell(score, n_je_ebene),
"freie_ebene": freie_ebene(score, n_je_ebene),
"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),
}
bausteine = {
b: _stand(progress[b]["gute_antworten"] if b in progress else 0,
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 ebenen.items()
for b, n in levels.items()
}
# Altbestand-Bausteine mit Prüfung, aber ohne Subs → leere Ebenen (cap 0).
# Legacy blocks with an exam but without subs → empty levels (cap 0).
for b, p in progress.items():
if b not in bausteine:
bausteine[b] = _stand(p["gute_antworten"], p["streak"], {})
return {"bausteine": bausteine}
if b not in blocks:
blocks[b] = _state(p["good_answers"], p["streak"], {})
return {"blocks": blocks}
@router.post("/bausteine/chat", response_model=BausteinChatResponse)
async def baustein_chat_route(req: BausteinChatRequest):
reply = await baustein_chat(
req.topic, req.baustein, req.section, req.section_kompakt,
@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}
# Bewertungen je (topic, baustein) serialisieren — sonst überschreiben zwei
# gleichzeitige Bewertungen den absoluten Score mit veralteter Basis (Race).
_pruef_locks: dict[tuple[str, str], asyncio.Lock] = {}
# 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 _pruef_lock(topic: str, baustein: str) -> asyncio.Lock:
key = (topic, baustein)
lock = _pruef_locks.get(key)
def _check_lock(topic: str, block: str) -> asyncio.Lock:
key = (topic, block)
lock = _check_locks.get(key)
if lock is None:
lock = _pruef_locks[key] = asyncio.Lock()
lock = _check_locks[key] = asyncio.Lock()
return lock
def _basis(stand: dict, frage: str) -> tuple[int, bool]:
"""Score-Basis VOR der Frage. Gleiche offene FrageRe-Bewertung auf derselben Basis
(idempotent); sonst neue Frage auf dem aktuellen Stand. → (basis, re_bewertung)."""
re_bewertung = stand["offene_frage"] == frage and stand["offene_basis"] is not None
return (stand["offene_basis"] if re_bewertung else stand["gute_antworten"]), re_bewertung
def _basis(state: dict, question: str) -> tuple[int, bool]:
"""Score base BEFORE the question. Same open questionre-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 _farbe(punkte: int) -> str:
"""Punkte-Delta → grobe Einfärbung der Bubble."""
return "gut" if punkte > 0 else ("neutral" if punkte == 0 else "schlecht")
def _color(points: int) -> str:
"""Points delta → rough bubble coloring."""
return "gut" if points > 0 else ("neutral" if points == 0 else "schlecht")
async def _buche(req, frage: str, niveau: str, n_je_ebene: dict[int, int]) -> dict:
"""Score+Streak driftfrei buchen (Lock + offene_frage/offene_streak-Anker). Niveau
Punkt-Delta (streak-moduliert) bzw. progressiver Malus bei Fehler. cap_aktuell wird aus
der Basis abgeleitet (verzögerte Freischaltung an der Ebenen-Schwelle); Element einmalig
ab Anfänger-Stufe. Re-Bewertung derselben Frage nutzt den offenen Streak-Anker → idempotent."""
async with _pruef_lock(req.topic, req.baustein):
stand = await get_baustein_progress(req.topic, req.baustein)
war_stufe = stand["absolviert"] is not None # Element-Guard: schon je angelegt?
basis, re_bewertung = _basis(stand, frage)
streak_basis = stand["offene_streak"] if re_bewertung else stand["streak"]
if not re_bewertung:
await set_offene_frage(req.topic, req.baustein, frage, basis, stand["streak"])
s = schwellen(n_je_ebene)
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_ebene)
floor = floor_aus_score(basis, cf, s)
d, neue_streak = punkte_delta(niveau, streak_basis, basis, ca)
score = score_berechnen(basis, d, floor, ca, cf)
punkte = score - basis
gute, streak = await set_baustein_score_and_streak(req.topic, req.baustein, score, neue_streak)
# Lern-Element einmalig anlegen, sobald die erste Stufe (Anfänger) erreicht ist.
if not war_stufe and stufe_aus_score(score, cf) is not None:
if await set_baustein_absolviert(req.topic, req.baustein):
asyncio.create_task(baustein_element_anlegen(req.topic, req.baustein, req.section, req.provider))
return {"punkte": punkte, "bewertung": _farbe(punkte), "gute_antworten": gute, "streak": streak, "cap": cf}
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("/bausteine/pruefung", response_model=BausteinPruefungResponse)
async def baustein_pruefung_route(req: BausteinPruefungRequest):
stand = await get_baustein_progress(req.topic, req.baustein)
gute = stand["gute_antworten"]
n_je_ebene = await subs_je_ebene(req.topic, req.baustein)
cap = cap_final(n_je_ebene)
niveau = stufe_aus_score(gute, cap) or "anfaenger" # Adressaten-Rolle der Frage
fe = freie_ebene(gute, n_je_ebene) # nur freigeschaltete Subs prüfen
kompakt = req.section_kompakt
@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.aktion == "frage":
if req.muster.strip():
# Aus gezogenem Muster eine konkrete Frage im Niveau formulieren (kein Dedup nötig).
frage = await pruefung_frage_variante(req.topic, req.baustein, req.section, kompakt, req.muster, niveau=niveau, provider=req.provider)
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 (kein Muster-Sidecar): Live-Generierung, Fokus nur auf freigeschaltete Subs.
subs = await subbausteine_frei(req.topic, req.baustein, fe)
frage = await pruefung_frage(req.topic, req.baustein, req.section, kompakt, msgs, subbausteine=subs, vermeide=req.vermeide, niveau=niveau, provider=req.provider)
if frage is None:
raise HTTPException(502, "Frage fehlgeschlagen — bitte erneut versuchen")
return {"frage": frage, "gute_antworten": gute, "cap": cap}
# 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.aktion == "diskussion":
if not req.frage.strip():
raise HTTPException(400, "Diskussion braucht eine laufende Frage")
reply = await baustein_diskussion(
req.topic, req.baustein, req.section, kompakt,
req.frage, req.letzte_bewertung or None, msgs, provider=req.provider,
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, "Diskussion fehlgeschlagen — bitte erneut versuchen")
return {"reply": reply, "gute_antworten": gute, "cap": cap}
raise HTTPException(502, "Discussion failed — please try again")
return {"reply": reply, "good_answers": good, "cap": cap}
# --- Quiz: leicht (1 von 4) +1/1 · schwer (x von 4) +3/1 — deterministisch ---
if req.aktion == "quiz_frage":
if not req.muster.strip():
raise HTTPException(400, "Quiz braucht ein Muster")
distraktoren = await huerden_distraktor_block(req.topic, req.baustein)
quiz = await quiz_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, niveau=niveau, provider=req.provider, distraktor_block=distraktoren)
# --- 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-Frage fehlgeschlagen — bitte erneut versuchen")
return {"frage": quiz["frage"], "optionen": quiz["optionen"],
"gute_antworten": gute, "cap": cap}
raise HTTPException(502, "Quiz question failed — please try again")
return {"question": quiz["question"], "options": quiz["options"],
"good_answers": good, "cap": cap}
if req.aktion == "quiz_antwort":
if not req.frage.strip():
raise HTTPException(400, "Quiz-Antwort braucht eine Frage")
getroffen = set(req.auswahl) == set(req.korrekt) # exakt die richtige Menge
res = await _buche(req, req.frage, "stark" if getroffen else "kaum", n_je_ebene)
res["feedback"] = "Richtig — alle korrekten getroffen." if getroffen else "Nicht ganzdie markierten waren richtig."
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 quitethe marked ones were correct."
return res
# --- Lückentext: leicht (Begriff aus 4) +1/1 · schwer (frei tippen) +3/1 ---
if req.aktion == "lueck_frage":
if not req.muster.strip():
raise HTTPException(400, "Lückentext braucht ein Muster")
# --- 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 lueckentext_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, niveau=niveau, provider=req.provider)
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, "Lückentext fehlgeschlagen — bitte erneut versuchen")
return {"satz": lt["satz"], "loesung": lt["loesung"], "alternativen": lt["alternativen"],
"gute_antworten": gute, "cap": cap}
distraktoren = await huerden_distraktor_block(req.topic, req.baustein)
lw = await lueckwahl_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, niveau=niveau, provider=req.provider, distraktor_block=distraktoren)
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, "Lückentext fehlgeschlagen — bitte erneut versuchen")
return {"satz": lw["satz"], "optionen": lw["optionen"],
"gute_antworten": gute, "cap": cap}
raise HTTPException(502, "Gap text failed — please try again")
return {"sentence": lw["sentence"], "options": lw["options"],
"good_answers": good, "cap": cap}
if req.aktion == "lueck_antwort":
if not req.frage.strip():
raise HTTPException(400, "Lückentext-Antwort braucht einen Satz")
if req.schwer: # frei getipptSynonym-tolerante KI-Prüfung
ok = await lueckentext_pruefen(req.topic, req.baustein, req.frage, req.loesung, req.alternativen, req.eingabe, provider=req.provider)
feedback = "Richtig!" if ok else f"Nicht ganz — erwartet war{req.loesung}“."
else: # Begriff aus 4 gewählt → deterministisch
ok = set(req.auswahl) == set(req.korrekt)
feedback = "Richtig!" if ok else "Nicht ganz — der markierte Begriff war richtig."
res = await _buche(req, req.frage, "stark" if ok else "kaum", n_je_ebene)
if req.action == "gap_answer":
if not req.question.strip():
raise HTTPException(400, "Gap-text answer needs a sentence")
if req.schwer: # free typedsynonym-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
# aktion "antwort" (Agent 1 schnell) / "antwort_pruefen" (Agent 2 genau).
# action "answer" (Agent 1 fast) / "answer_check" (Agent 2 thorough).
if not any(m.get("role") == "user" for m in msgs):
raise HTTPException(400, "Antwort braucht eine Nutzer-Antwort")
if not req.frage.strip():
raise HTTPException(400, "Antwort braucht eine laufende Frage")
raise HTTPException(400, "Answer needs a user answer")
if not req.question.strip():
raise HTTPException(400, "Answer needs an active question")
if req.aktion == "antwort":
# Agent 1: nur Vorschau — Niveau + voraussichtliche Punkte, NICHTS persistieren, kein Anker.
data = await pruefung_bewertung_schnell(
req.topic, req.baustein, req.section, kompakt, req.frage, msgs, provider=req.provider,
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, "Bewertung fehlgeschlagen — bitte erneut versuchen")
basis, re_bew = _basis(stand, req.frage)
streak_basis = stand["offene_streak"] if re_bew else stand["streak"]
s = schwellen(n_je_ebene)
ca = cap_aktuell(basis, n_je_ebene)
floor = floor_aus_score(basis, s[-1], s)
niveau = deckel_nachfrage(data["niveau"], req.nachgefragt)
d, _ = punkte_delta(niveau, streak_basis, basis, ca)
score = score_berechnen(basis, d, floor, ca, s[-1])
punkte = score - basis
return {"feedback": data["feedback"], "punkte": punkte, "bewertung": _farbe(punkte),
"gute_antworten": gute, "cap": cap}
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}
# aktion "antwort_pruefen" (Agent 2 genau): verbindlich, persistiert. NUR hier ändert sich der Score.
# LLM läuft OHNE Lock; gebucht wird kurz über _buche (Anker + Score), wie bei Quiz/Lück.
# So blockiert die lange KI-Bewertung keine folgende (deterministische) Antwort desselben Bausteins.
data = await pruefung_bewertung(
req.topic, req.baustein, req.section, kompakt, req.frage, msgs, provider=req.provider,
role="guide" if req.gruendlich else "judge", begruendung=req.begruendung,
# 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, "Bewertung fehlgeschlagen — bitte erneut versuchen")
niveau = deckel_nachfrage(data["niveau"], req.nachgefragt)
res = await _buche(req, req.frage, niveau, n_je_ebene) # kurzer Lock: Basis driftfrei über Anker
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
@@ -474,10 +474,10 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest):
@router.post("/guides", response_model=GuideResponse)
async def create(req: GuideCreateRequest):
guides, progress, levels = await lade_lernstand()
grund = guide_lock(req.topic.strip(), req.format, guides, progress, levels)
if grund:
raise HTTPException(400 if grund == "Erst Bausteine erstellen" else 409, grund)
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 = {
@@ -502,45 +502,45 @@ async def list_all():
@router.get("/guides/locks")
async def guide_locks(topic: str):
"""Sperr-Gründe pro Format für den ▶-Button — None = erstellbar."""
guides, progress, levels = await lade_lernstand()
"""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):
"""Höchster voll abgeschlossener Schritt-Index je Format (artefakt-basiert, -1 = keiner).
Treibt die klickbaren Schritt-Kugeln (wie die Bausteine-Phasen)."""
return {fmt: guide_fertig_step(guide_content_path(topic, fmt)) for fmt in ("Guide", "FullGuide", "Rest")}
"""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 nicht gefunden")
raise HTTPException(404, "Guide not found")
return guide
@router.get("/guides/{guide_id}/content")
async def guide_content(guide_id: str, ebene: int = 4):
"""Guide-Inhalt. `ebene` (1=A · 2=F · 3=E · 4=V) filtert auf Subbausteine bis zu dieser
Ebene; 4 = Vollfassung (roh, unverändert)."""
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 nicht gefunden")
raise HTTPException(404, "Guide not found")
if guide["status"] != "done":
raise HTTPException(404, "Inhalt nicht verfügbar")
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: Datei (Alt-Themen)
path = guide_content_path(guide["topic"], guide["format"]) # fallback: file (legacy topics)
if not path.exists():
raise HTTPException(404, "Datei nicht gefunden")
raise HTTPException(404, "File not found")
stored = path.read_text(encoding="utf-8")
if ebene >= 4:
return Response(content=stored, media_type="application/json") # Vollfassung roh
if level >= 4:
return Response(content=stored, media_type="application/json") # full version, raw
try:
return content_fuer_ebene(json.loads(stored), ebene)
return content_fuer_level(json.loads(stored), level)
except ValueError:
return Response(content=stored, media_type="application/json")
@@ -549,7 +549,7 @@ async def guide_content(guide_id: str, ebene: int = 4):
async def guide_chat(guide_id: str, req: GuideChatRequest):
guide = await get_guide(guide_id)
if guide is None:
raise HTTPException(404, "Guide nicht gefunden")
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],
@@ -561,29 +561,29 @@ async def guide_chat(guide_id: str, req: GuideChatRequest):
async def _guide_tf(guide_id: str) -> tuple[str, str]:
guide = await get_guide(guide_id)
if guide is None:
raise HTTPException(404, "Guide nicht gefunden")
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)
neu = await block_pruefen(topic, fmt, req.baustein, req.stelle, req.block, req.hinweis, provider=req.provider)
if neu is None:
raise HTTPException(502, "Prüfung fehlgeschlagen — bitte erneut versuchen")
return {"neu": neu}
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_uebernehmen_route(guide_id: str, req: BlockUebernehmenRequest):
async def block_adopt_route(guide_id: str, req: BlockUebernehmenRequest):
topic, fmt = await _guide_tf(guide_id)
res = await block_uebernehmen(topic, fmt, req.baustein, req.stelle, req.alt, req.neu)
res = await block_adopt(topic, fmt, req.block, req.spot, req.alt, req.revised)
if res is None:
raise HTTPException(404, "Section nicht gefunden")
raise HTTPException(404, "Section not found")
return res
# --- Elemente (persönliche Zusammenfassung) ---
# --- Elements (personal summary) ---
@router.get("/elements", response_model=list[ElementResponse])
async def get_elements(topic: str):
@@ -603,7 +603,7 @@ async def post_element(req: ElementCreateRequest):
async def element_chat(element_id: str, req: ElementChatRequest):
element = await get_element(element_id)
if element is None:
raise HTTPException(404, "Element nicht gefunden")
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}
@@ -612,17 +612,17 @@ async def element_chat(element_id: str, req: ElementChatRequest):
async def element_refine(element_id: str, req: ElementRefineRequest):
element = await get_element(element_id)
if element is None:
raise HTTPException(404, "Element nicht gefunden")
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, "Überarbeitung fehlgeschlagen — bitte erneut versuchen")
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 nicht gefunden")
raise HTTPException(404, "Element not found")
fields = req.model_dump(exclude_unset=True, exclude_none=True)
if fields:
now = datetime.now(timezone.utc).isoformat()
@@ -634,10 +634,10 @@ async def put_element(element_id: str, req: ElementUpdateRequest):
async def element_style(element_id: str, req: ElementCheckRequest):
element = await get_element(element_id)
if element is None:
raise HTTPException(404, "Element nicht gefunden")
raise HTTPException(404, "Element not found")
changes = await style_element(element, provider=req.provider)
if changes is None:
raise HTTPException(502, "Stil-Prüfung fehlgeschlagen — bitte erneut versuchen")
raise HTTPException(502, "Style check failed — please try again")
return {"changes": changes}
@@ -645,17 +645,17 @@ async def element_style(element_id: str, req: ElementCheckRequest):
async def element_check(element_id: str, req: ElementCheckRequest):
element = await get_element(element_id)
if element is None:
raise HTTPException(404, "Element nicht gefunden")
raise HTTPException(404, "Element not found")
suggestions = await check_element(element, provider=req.provider)
if suggestions is None:
raise HTTPException(502, "Prüfung fehlgeschlagen — bitte erneut versuchen")
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 nicht gefunden")
raise HTTPException(404, "Element not found")
return {"ok": True}
@@ -663,7 +663,7 @@ async def remove_element(element_id: str):
async def cancel(guide_id: str):
cancelled = await cancel_guide(guide_id)
if not cancelled:
raise HTTPException(404, "Kein aktiver Prozess gefunden")
raise HTTPException(404, "No active process found")
return {"ok": True}
@@ -671,18 +671,18 @@ async def cancel(guide_id: str):
async def remove(guide_id: str, slots: bool = False):
guide = await get_guide(guide_id)
if guide is None:
raise HTTPException(404, "Guide nicht gefunden")
raise HTTPException(404, "Guide not found")
await delete_progress(guide_id)
await delete_guide(guide_id)
# Content-/Schritt-Dateien teilen sich alle Läufe eines Thema+Formatserst löschen,
# wenn kein Eintrag sie mehr braucht. Teilfortschritt (Schritt-Dateien ohne fertigen
# Content) bleibt fürs Resume erhalten, außer es wird explizit verlangt (slots=1).
# 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_dateien(content):
for p in guide_slot_files(content):
p.unlink(missing_ok=True)
content.unlink(missing_ok=True)
return {"ok": True}
@@ -692,7 +692,7 @@ async def remove(guide_id: str, slots: bool = False):
async def get_progress(guide_id: str):
guide = await get_guide(guide_id)
if guide is None:
raise HTTPException(404, "Guide nicht gefunden")
raise HTTPException(404, "Guide not found")
return {"chapters": await list_progress(guide_id)}
@@ -700,6 +700,6 @@ async def get_progress(guide_id: str):
async def update_progress(guide_id: str, req: ProgressUpdate):
guide = await get_guide(guide_id)
if guide is None:
raise HTTPException(404, "Guide nicht gefunden")
raise HTTPException(404, "Guide not found")
await set_progress(guide_id, req.chapter, req.done)
return {"chapters": await list_progress(guide_id)}