This commit is contained in:
team3
2026-06-18 20:24:24 +02:00
parent d80b22d53e
commit bf6ab18ffd
12 changed files with 176 additions and 86 deletions

View File

@@ -111,6 +111,13 @@ async def init_db():
await db.execute("ALTER TABLE baustein_progress ADD COLUMN streak INTEGER NOT NULL DEFAULT 0")
except aiosqlite.OperationalError:
pass
# Offene-Frage-Anker: Basis/Streak VOR der aktuell offenen Frage — macht die Bewertung
# serverseitig idempotent (Re-Bewertung) und driftfrei über Fragen hinweg.
for _spalte, _typ in (("offene_frage", "TEXT"), ("offene_basis", "INTEGER"), ("offene_streak", "INTEGER")):
try:
await db.execute(f"ALTER TABLE baustein_progress ADD COLUMN {_spalte} {_typ}")
except aiosqlite.OperationalError:
pass
# Migration: alte vertiefungen-Tabelle → baustein_texte (Bestand = lange Form, art 'deepdive')
cursor = await db.execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'vertiefungen'")
if await cursor.fetchone():
@@ -321,6 +328,38 @@ async def list_baustein_progress(topic: str) -> list[dict]:
return [{"baustein": b, "gute_antworten": n, "streak": s, "absolviert": a, "verstanden": v, "gemeistert": m} for b, n, s, a, v, m in rows]
async def get_baustein_progress(topic: str, baustein: str) -> dict:
"""Eine Baustein-Zeile inkl. Offene-Frage-Anker. Defaults, falls noch keine existiert."""
db = await get_db()
cursor = await db.execute(
"SELECT gute_antworten, streak, absolviert, verstanden, gemeistert, "
"offene_frage, offene_basis, offene_streak FROM baustein_progress "
"WHERE topic = ? AND baustein = ?",
(topic, baustein),
)
row = await cursor.fetchone()
if row is None:
return {"gute_antworten": 0, "streak": 0, "absolviert": None, "verstanden": None,
"gemeistert": None, "offene_frage": None, "offene_basis": None, "offene_streak": None}
return {"gute_antworten": row[0], "streak": row[1], "absolviert": row[2], "verstanden": row[3],
"gemeistert": row[4], "offene_frage": row[5], "offene_basis": row[6], "offene_streak": row[7]}
async def set_offene_frage(topic: str, baustein: str, frage: str, basis: int, streak: int) -> None:
"""Friert Basis + Streak VOR der jetzt offenen Frage ein (Anker für idempotente Re-Bewertung)."""
db = await get_db()
now = _now()
await db.execute(
"""INSERT INTO baustein_progress (topic, baustein, offene_frage, offene_basis, offene_streak, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(topic, baustein) DO UPDATE SET
offene_frage = excluded.offene_frage, offene_basis = excluded.offene_basis,
offene_streak = excluded.offene_streak, updated_at = excluded.updated_at""",
(topic, baustein, frage, basis, streak, now),
)
await db.commit()
async def set_baustein_score(topic: str, baustein: str, score: int) -> int:
"""Setzt den Score absolut (vom Aufrufer geclampt) und liefert ihn zurück."""
db = await get_db()

View File

@@ -173,7 +173,7 @@ def _doppelfrage_mangel(frage: str) -> str | None:
async def _frage_mit_kritik(
topic: str, baustein: str, section_block: str, kompakt_block: str,
transcript: str, provider: str,
transcript: str, vermeide_block: str, provider: str,
) -> str | None:
"""Frage generieren, vom Kritiker prüfen lassen, bei Mängeln neu (max KRITIK_MAX_RUNDEN)."""
kritik_block = "(keine)"
@@ -182,7 +182,8 @@ async def _frage_mit_kritik(
data = await _gen_call(
"Baustein-Frage", "fast", _frage_schema, provider,
topic=topic, baustein=baustein, section_block=section_block,
kompakt_block=kompakt_block, transcript=transcript, kritik_block=kritik_block,
kompakt_block=kompakt_block, transcript=transcript, vermeide_block=vermeide_block,
kritik_block=kritik_block,
)
if data is None:
return None
@@ -190,7 +191,7 @@ async def _frage_mit_kritik(
probleme = await _kritik_call(
"Baustein-Frage-Kritik", provider, role="guide", # starke KI prüft die Regeln
topic=topic, baustein=baustein, section_block=section_block,
kompakt_block=kompakt_block, transcript=transcript, frage=frage,
kompakt_block=kompakt_block, transcript=transcript, vermeide_block=vermeide_block, frage=frage,
)
hart = _doppelfrage_mangel(frage) # erzwingt Neugenerierung, auch wenn der KI-Kritiker es übersah
if hart:
@@ -242,15 +243,22 @@ def _bloecke(section: str, kompakt: str | None) -> tuple[str, str]:
)
def _vermeide_block(vermeide: list[str] | None) -> str:
eintraege = [f.strip() for f in (vermeide or []) if f and f.strip()]
return "\n".join(f"- {f}" for f in eintraege) or "(keine)"
async def pruefung_frage(
topic: str, baustein: str, section: str, kompakt: str | None,
messages: list[dict], provider: str = DEFAULT_PROVIDER,
messages: list[dict], vermeide: list[str] | None = None, provider: str = DEFAULT_PROVIDER,
) -> str | None:
"""Aktion 'frage': nächste Frage generieren (Generator + Kritiker) · None bei Fehler."""
try:
section_block, kompakt_block = _bloecke(section, kompakt)
transcript = _transcript(messages) if messages else "(leer)"
return await _frage_mit_kritik(topic, baustein, section_block, kompakt_block, transcript, provider)
return await _frage_mit_kritik(
topic, baustein, section_block, kompakt_block, transcript, _vermeide_block(vermeide), provider,
)
except Exception:
log.warning("[%s] Frage fehlgeschlagen (%s)", topic, baustein, exc_info=True)
return None

View File

@@ -215,10 +215,10 @@ class BausteinPruefungRequest(BaseModel):
section: str = Field(default="", max_length=20000) # ausführliche Fassung
section_kompakt: str = Field(default="", max_length=20000) # kompakte Fassung (Merksätze)
aktion: Literal["frage", "diskussion", "antwort", "antwort_pruefen"] = "frage"
frage: str = Field(default="", max_length=2000) # aktuell geprüfte Frage (für diskussion/antwort)
frage: str = Field(default="", max_length=2000) # aktuell geprüfte Frage (für diskussion/antwort); Anker der Basis
letzte_bewertung: str = Field(default="", max_length=2000) # Feedback der letzten Bewertung (Kontext für diskussion)
score_vor_frage: int = 0 # Score, als die Frage gestellt wurde → driftfreies (Re-)Bewerten
streak: int = 0 # Folge korrekter Antworten VOR dieser Frage (Streak-Bonus)
vermeide: list[str] = [] # schon gestellte + vorgemerkte Fragen — sinngemäß nicht wiederholen
# Basis/Streak werden serverseitig je Frage geführt (offene_frage-Anker) — nicht mehr vom Client.
tier2: bool = False # ganzer Guide absolviert (alle ≥3) → 1 bei falsch, Deckel 10
tier3: bool = False # ganzer Guide verstanden (alle ≥10) → Meisterpfad, 2 bei falsch, Deckel 25
messages: list[ChatMessage] = [] # Dialog bisher; leer = erste Frage

View File

@@ -13,7 +13,8 @@ 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, set_baustein_score, set_baustein_streak, set_baustein_absolviert, set_baustein_verstanden, set_baustein_gemeistert, delete_baustein_daten,
list_baustein_progress, get_baustein_progress, set_offene_frage,
set_baustein_score, set_baustein_streak, set_baustein_absolviert, set_baustein_verstanden, set_baustein_gemeistert, delete_baustein_daten,
)
from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, lade_quelle, lade_uebersicht
from elements import generate_element, chat_with_guide, chat_with_element, check_element, style_element, refine_suggestion
@@ -233,12 +234,32 @@ async def baustein_chat_route(req: BausteinChatRequest):
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] = {}
def _pruef_lock(topic: str, baustein: str) -> asyncio.Lock:
key = (topic, baustein)
lock = _pruef_locks.get(key)
if lock is None:
lock = _pruef_locks[key] = asyncio.Lock()
return lock
def _basis(stand: dict, frage: str) -> tuple[int, int, bool]:
"""Score-/Streak-Basis VOR der Frage. Gleiche offene Frage → Re-Bewertung auf derselben
Basis (idempotent); sonst neue Frage auf dem aktuellen Stand. → (basis, streak_basis, re_bewertung)."""
re_bewertung = stand["offene_frage"] == frage and stand["offene_basis"] is not None
if re_bewertung:
streak_basis = stand["offene_streak"] if stand["offene_streak"] is not None else stand["streak"]
return stand["offene_basis"], streak_basis, True
return stand["gute_antworten"], stand["streak"], False
@router.post("/bausteine/pruefung", response_model=BausteinPruefungResponse)
async def baustein_pruefung_route(req: BausteinPruefungRequest):
stand = next(
(p for p in await list_baustein_progress(req.topic) if p["baustein"] == req.baustein),
{"gute_antworten": 0, "absolviert": None, "verstanden": None, "gemeistert": None},
)
stand = await get_baustein_progress(req.topic, req.baustein)
gute = stand["gute_antworten"]
absolviert = stand["absolviert"] is not None
verstanden = stand["verstanden"] is not None
@@ -247,7 +268,7 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest):
msgs = [m.model_dump() for m in req.messages]
if req.aktion == "frage":
frage = await pruefung_frage(req.topic, req.baustein, req.section, kompakt, msgs, provider=req.provider)
frage = await pruefung_frage(req.topic, req.baustein, req.section, kompakt, msgs, vermeide=req.vermeide, provider=req.provider)
if frage is None:
raise HTTPException(502, "Frage fehlgeschlagen — bitte erneut versuchen")
return {"frage": frage, "gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
@@ -271,47 +292,58 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest):
raise HTTPException(400, "Antwort braucht eine laufende Frage")
if req.aktion == "antwort":
# Vorschau: nur Bewerter, kein Kritiker, KEIN Persist, KEINE Meilensteine.
# Vorschau: nur Bewerter, kein Kritiker, KEIN Persist. Basis serverseitig (best effort).
data = await pruefung_bewertung_schnell(
req.topic, req.baustein, req.section, kompakt, req.frage, msgs, gute, provider=req.provider,
)
if data is None:
raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen")
score = score_berechnen(req.score_vor_frage, data["bewertung"] == "gut", req.streak, req.tier2, req.tier3, absolviert, verstanden, gemeistert)
basis, streak_basis, _ = _basis(stand, req.frage)
score = score_berechnen(basis, data["bewertung"] == "gut", streak_basis, req.tier2, req.tier3, absolviert, verstanden, gemeistert)
return {"feedback": data["feedback"], "bewertung": data["bewertung"], "gute_antworten": score,
"absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
# aktion == "antwort_pruefen": verbindlich (Bewerter + Kritiker), persistiert Score + Meilensteine.
data = await pruefung_bewertung(
req.topic, req.baustein, req.section, kompakt, req.frage, msgs, gute, provider=req.provider,
role="guide" if req.gruendlich else "judge",
)
if data is None:
raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen")
# aktion == "antwort_pruefen": verbindlich, persistiert. Serialisiert je Baustein, damit
# die Basis immer den geprüften Vorgänger-Stand sieht (kein Stale-Overwrite).
async with _pruef_lock(req.topic, req.baustein):
stand = await get_baustein_progress(req.topic, req.baustein) # nach evtl. Vorgänger-Persist neu lesen
absolviert = stand["absolviert"] is not None
verstanden = stand["verstanden"] is not None
gemeistert = stand["gemeistert"] is not None
basis, streak_basis, re_bewertung = _basis(stand, req.frage)
if not re_bewertung:
# Neue Frage: Basis/Streak einfrieren (Anker für spätere Re-Bewertung dieser Frage).
await set_offene_frage(req.topic, req.baustein, req.frage, basis, streak_basis)
# Score driftfrei aus dem Basis-Score rechnen (Re-Bewertung ersetzt das vorige Ergebnis).
score = score_berechnen(
req.score_vor_frage, data["bewertung"] == "gut", req.streak, req.tier2, req.tier3, absolviert, verstanden, gemeistert,
)
war_absolviert, war_verstanden = absolviert, verstanden
gute = await set_baustein_score(req.topic, req.baustein, score)
if score >= NOETIG and not absolviert:
absolviert = True
if await set_baustein_absolviert(req.topic, req.baustein):
asyncio.create_task(baustein_element_anlegen(req.topic, req.baustein, req.section, req.provider))
if score >= MASTERY and not verstanden:
await set_baustein_verstanden(req.topic, req.baustein)
verstanden = True
if score >= MEISTERN and not gemeistert:
await set_baustein_gemeistert(req.topic, req.baustein)
gemeistert = True
# Streak persistieren — Reset nur am Deckel der gerade aktiven Stufe.
streak = streak_berechnen(
req.streak, data["bewertung"] == "gut", req.tier2, req.tier3,
absolviert and not war_absolviert, verstanden and not war_verstanden,
)
await set_baustein_streak(req.topic, req.baustein, streak)
return {"feedback": data["feedback"], "bewertung": data["bewertung"], "gute_antworten": gute, "streak": streak, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
data = await pruefung_bewertung(
req.topic, req.baustein, req.section, kompakt, req.frage, msgs, basis, provider=req.provider,
role="guide" if req.gruendlich else "judge",
)
if data is None:
raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen")
score = score_berechnen(
basis, data["bewertung"] == "gut", streak_basis, req.tier2, req.tier3, absolviert, verstanden, gemeistert,
)
war_absolviert, war_verstanden = absolviert, verstanden
gute = await set_baustein_score(req.topic, req.baustein, score)
if score >= NOETIG and not absolviert:
absolviert = True
if await set_baustein_absolviert(req.topic, req.baustein):
asyncio.create_task(baustein_element_anlegen(req.topic, req.baustein, req.section, req.provider))
if score >= MASTERY and not verstanden:
await set_baustein_verstanden(req.topic, req.baustein)
verstanden = True
if score >= MEISTERN and not gemeistert:
await set_baustein_gemeistert(req.topic, req.baustein)
gemeistert = True
# Streak persistieren — Reset nur am Deckel der gerade aktiven Stufe.
streak = streak_berechnen(
streak_basis, data["bewertung"] == "gut", req.tier2, req.tier3,
absolviert and not war_absolviert, verstanden and not war_verstanden,
)
await set_baustein_streak(req.topic, req.baustein, streak)
return {"feedback": data["feedback"], "bewertung": data["bewertung"], "gute_antworten": gute, "streak": streak, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
# --- Guides ---