update
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import json
|
||||
import shutil
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
@@ -14,13 +15,15 @@ from database import (
|
||||
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, set_baustein_absolviert, delete_baustein_daten, delete_baustein_progress, count_relevant_subs, subs_count_roh,
|
||||
set_baustein_score, 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,
|
||||
get_sub_artefakte,
|
||||
)
|
||||
from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, lade_quelle, lade_uebersicht, subbausteine_titel, lade_frage_muster
|
||||
from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, lade_quelle, lade_uebersicht, subbausteine_titel, subbausteine_frei, lade_frage_muster, lade_frage_muster_frei
|
||||
from elements import generate_element, chat_with_guide, chat_with_element, check_element, style_element, refine_suggestion
|
||||
from lernen import NIVEAUS, cap_fuer, 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, score_berechnen, floor_aus_score, progressiver_malus, stufe_aus_score
|
||||
from guide import generate_guide, guide_slot_dateien, guide_fertig_step, block_pruefen, block_uebernehmen
|
||||
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 pipeline import cancel_guide
|
||||
from regeln import FORMATE, formate_stats, guide_lock, ist_absolviert, lade_lernstand, thema_abgeschlossen
|
||||
from models import (
|
||||
@@ -221,30 +224,59 @@ async def get_bausteine_uebersicht(topic: str):
|
||||
|
||||
@router.get("/bausteine/frage-muster")
|
||||
async def get_frage_muster(topic: str, baustein: str):
|
||||
"""Vordefinierte Frage-Muster eines Bausteins (leer = Fallback auf Live-Generierung)."""
|
||||
return {"muster": await lade_frage_muster(topic, baustein)}
|
||||
"""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("/bausteine/artefakte")
|
||||
async def get_artefakte(topic: str, typ: str | None = None):
|
||||
"""Lern-Artefakte (Karteikarten/Beispiele/Diagramme) je Thema, gruppiert nach Baustein-Norm.
|
||||
Diagramm liegt auf Baustein-Ebene (sub_norm=''), Karteikarte/Beispiel je Subbaustein."""
|
||||
rows = await get_sub_artefakte(topic, typ)
|
||||
out: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
b = out.setdefault(r["baustein_norm"], {"baustein": r["baustein"], "karteikarte": [], "beispiel": [], "diagramm": None})
|
||||
if r["baustein"] and not b["baustein"]:
|
||||
b["baustein"] = r["baustein"]
|
||||
try:
|
||||
daten = json.loads(r["daten"])
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if r["typ"] == "diagramm":
|
||||
b["diagramm"] = daten
|
||||
elif r["typ"] in ("karteikarte", "beispiel"):
|
||||
b[r["typ"]].append({"subbaustein": r["sub_titel"], **daten})
|
||||
return {"artefakte": out}
|
||||
|
||||
|
||||
# --- Baustein-Lernen: Chat, Prüfung ---
|
||||
|
||||
@router.get("/bausteine/lernstand", response_model=BausteinLernstandResponse)
|
||||
async def baustein_lernstand(topic: str):
|
||||
"""Prüfungs-Stand pro Baustein (roher Titel als Key). cap = 4×relevante Subs [10,50]
|
||||
— für ALLE Bausteine (auch noch nicht geprüfte), damit der cap sofort stimmt."""
|
||||
"""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)}
|
||||
caps = await subs_count_roh(topic)
|
||||
bausteine = {
|
||||
b: {
|
||||
"gute_antworten": progress[b]["gute_antworten"] if b in progress else 0,
|
||||
"streak": progress[b]["streak"] if b in progress else 0,
|
||||
"cap": cap_fuer(n),
|
||||
ebenen = await subs_je_ebene_roh(topic)
|
||||
|
||||
def _stand(score: int, streak: int, n_je_ebene: 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),
|
||||
}
|
||||
for b, n in caps.items()
|
||||
|
||||
bausteine = {
|
||||
b: _stand(progress[b]["gute_antworten"] if b in progress else 0,
|
||||
progress[b]["streak"] if b in progress else 0, n)
|
||||
for b, n in ebenen.items()
|
||||
}
|
||||
# Altbestand-Bausteine mit Prüfung, aber ohne (relevante) Subs → Default-cap.
|
||||
# Altbestand-Bausteine mit Prüfung, aber ohne Subs → leere Ebenen (cap 0).
|
||||
for b, p in progress.items():
|
||||
if b not in bausteine:
|
||||
bausteine[b] = {"gute_antworten": p["gute_antworten"], "streak": p["streak"], "cap": 10}
|
||||
bausteine[b] = _stand(p["gute_antworten"], p["streak"], {})
|
||||
return {"bausteine": bausteine}
|
||||
|
||||
|
||||
@@ -282,54 +314,52 @@ def _farbe(punkte: int) -> str:
|
||||
return "gut" if punkte > 0 else ("neutral" if punkte == 0 else "schlecht")
|
||||
|
||||
|
||||
def _erklaeren_delta(niveau: str, nachgefragt: bool) -> int:
|
||||
"""Erklären-Form: Punkt-Delta aus dem Niveau (−1…+3). Mit Nachfrage höchstens +1."""
|
||||
delta = NIVEAUS.get(niveau, 0)
|
||||
return min(delta, 1) if nachgefragt else delta
|
||||
|
||||
|
||||
async def _cap_fuer(topic: str, baustein: str) -> int:
|
||||
"""Maximal-Score des Bausteins = clamp(4×relevante Subbausteine, 10, 50)."""
|
||||
return cap_fuer(await count_relevant_subs(topic, baustein))
|
||||
|
||||
|
||||
async def _buche(req, frage: str, delta: int, cap: int) -> dict:
|
||||
"""Score driftfrei buchen (Lock + offene_frage-Anker). Fehler (delta<0) → progressiver
|
||||
Malus nach Fortschritt. Element einmalig anlegen, sobald Anfänger (20 %) erreicht."""
|
||||
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, 0)
|
||||
floor = floor_aus_score(basis, cap)
|
||||
d = progressiver_malus(basis, cap) if delta < 0 else delta
|
||||
score = score_berechnen(basis, d, floor, cap)
|
||||
await set_offene_frage(req.topic, req.baustein, frage, basis, stand["streak"])
|
||||
s = schwellen(n_je_ebene)
|
||||
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 = await set_baustein_score(req.topic, req.baustein, score)
|
||||
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, cap) is not None:
|
||||
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, "cap": cap}
|
||||
return {"punkte": punkte, "bewertung": _farbe(punkte), "gute_antworten": gute, "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"]
|
||||
cap = await _cap_fuer(req.topic, req.baustein)
|
||||
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
|
||||
msgs = [m.model_dump() for m in req.messages]
|
||||
|
||||
if req.aktion == "frage":
|
||||
if req.muster.strip():
|
||||
# Aus gezogenem Muster eine konkrete, leicht andere Frage formulieren (kein Dedup nötig).
|
||||
frage = await pruefung_frage_variante(req.topic, req.baustein, req.section, kompakt, req.muster, provider=req.provider)
|
||||
# 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)
|
||||
else:
|
||||
# Fallback (kein Muster-Sidecar): Live-Generierung mit zufälligem Typ/Subbaustein.
|
||||
subs = await subbausteine_titel(req.topic, req.baustein) # Fokus-Kandidaten (zufällig gewählt)
|
||||
frage = await pruefung_frage(req.topic, req.baustein, req.section, kompakt, msgs, subbausteine=subs, vermeide=req.vermeide, provider=req.provider)
|
||||
# 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}
|
||||
@@ -349,7 +379,8 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest):
|
||||
if req.aktion == "quiz_frage":
|
||||
if not req.muster.strip():
|
||||
raise HTTPException(400, "Quiz braucht ein Muster")
|
||||
quiz = await quiz_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, schwer=req.schwer, provider=req.provider)
|
||||
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)
|
||||
if quiz is None:
|
||||
raise HTTPException(502, "Quiz-Frage fehlgeschlagen — bitte erneut versuchen")
|
||||
return {"frage": quiz["frage"], "optionen": quiz["optionen"],
|
||||
@@ -359,8 +390,7 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest):
|
||||
if not req.frage.strip():
|
||||
raise HTTPException(400, "Quiz-Antwort braucht eine Frage")
|
||||
getroffen = set(req.auswahl) == set(req.korrekt) # exakt die richtige Menge
|
||||
delta = (3 if req.schwer else 1) if getroffen else -1
|
||||
res = await _buche(req, req.frage, delta, cap)
|
||||
res = await _buche(req, req.frage, "stark" if getroffen else "kaum", n_je_ebene)
|
||||
res["feedback"] = "Richtig — alle korrekten getroffen." if getroffen else "Nicht ganz — die markierten waren richtig."
|
||||
return res
|
||||
|
||||
@@ -369,12 +399,13 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest):
|
||||
if not req.muster.strip():
|
||||
raise HTTPException(400, "Lückentext braucht ein Muster")
|
||||
if req.schwer:
|
||||
lt = await lueckentext_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, provider=req.provider)
|
||||
lt = await lueckentext_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, niveau=niveau, 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}
|
||||
lw = await lueckwahl_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, provider=req.provider)
|
||||
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)
|
||||
if lw is None:
|
||||
raise HTTPException(502, "Lückentext fehlgeschlagen — bitte erneut versuchen")
|
||||
return {"satz": lw["satz"], "optionen": lw["optionen"],
|
||||
@@ -389,7 +420,7 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest):
|
||||
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, (3 if req.schwer else 1) if ok else -1, cap)
|
||||
res = await _buche(req, req.frage, "stark" if ok else "kaum", n_je_ebene)
|
||||
res["feedback"] = feedback
|
||||
return res
|
||||
|
||||
@@ -406,11 +437,14 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest):
|
||||
)
|
||||
if data is None:
|
||||
raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen")
|
||||
basis, _ = _basis(stand, req.frage)
|
||||
floor = floor_aus_score(basis, cap)
|
||||
roh = _erklaeren_delta(data["niveau"], req.nachgefragt)
|
||||
d = progressiver_malus(basis, cap) if roh < 0 else roh
|
||||
score = score_berechnen(basis, d, floor, cap)
|
||||
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}
|
||||
@@ -424,8 +458,8 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest):
|
||||
)
|
||||
if data is None:
|
||||
raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen")
|
||||
roh = _erklaeren_delta(data["niveau"], req.nachgefragt)
|
||||
res = await _buche(req, req.frage, roh, cap) # kurzer Lock: Basis driftfrei über offene_frage-Anker
|
||||
niveau = deckel_nachfrage(data["niveau"], req.nachgefragt)
|
||||
res = await _buche(req, req.frage, niveau, n_je_ebene) # kurzer Lock: Basis driftfrei über Anker
|
||||
res["feedback"] = data["feedback"]
|
||||
return res
|
||||
|
||||
@@ -483,19 +517,26 @@ async def get_one(guide_id: str):
|
||||
|
||||
|
||||
@router.get("/guides/{guide_id}/content")
|
||||
async def guide_content(guide_id: str):
|
||||
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)."""
|
||||
guide = await get_guide(guide_id)
|
||||
if guide is None:
|
||||
raise HTTPException(404, "Guide nicht gefunden")
|
||||
if guide["status"] != "done":
|
||||
raise HTTPException(404, "Inhalt nicht verfügbar")
|
||||
stored = await get_guide_content(guide["topic"], guide["format"]) # DB-first
|
||||
if stored is not None:
|
||||
if stored is None:
|
||||
path = guide_content_path(guide["topic"], guide["format"]) # Fallback: Datei (Alt-Themen)
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Datei nicht gefunden")
|
||||
stored = path.read_text(encoding="utf-8")
|
||||
if ebene >= 4:
|
||||
return Response(content=stored, media_type="application/json") # Vollfassung roh
|
||||
try:
|
||||
return content_fuer_ebene(json.loads(stored), ebene)
|
||||
except ValueError:
|
||||
return Response(content=stored, media_type="application/json")
|
||||
path = guide_content_path(guide["topic"], guide["format"]) # Fallback: Datei (Alt-Themen)
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Datei nicht gefunden")
|
||||
return FileResponse(path, media_type="application/json")
|
||||
|
||||
|
||||
@router.post("/guides/{guide_id}/chat", response_model=GuideChatResponse)
|
||||
|
||||
Reference in New Issue
Block a user