This commit is contained in:
team3
2026-06-19 09:06:45 +02:00
parent bf6ab18ffd
commit 1fba9ba898
6 changed files with 113 additions and 19 deletions

View File

@@ -5,6 +5,7 @@ der Chat-/Prüfungs-Verlauf kommt vom Frontend, persistiert wird nur der
Prüfungs-Zähler (DB) und die Vertiefung (DB). Prüfungs-Zähler (DB) und die Vertiefung (DB).
""" """
import asyncio
import logging import logging
import re import re
import uuid import uuid
@@ -113,6 +114,14 @@ def _frage_schema(data) -> dict | None:
return {"frage": frage} if frage else None return {"frage": frage} if frage else None
def _fragen_schema(data) -> list[str] | None:
"""{"fragen": [str, …]} → Liste nicht-leerer Fragen · sonst None."""
if not isinstance(data, dict) or not isinstance(data.get("fragen"), list):
return None
fragen = [str(f).strip() for f in data["fragen"] if str(f).strip()]
return fragen or None
def _bewertung_schema(data) -> dict | None: def _bewertung_schema(data) -> dict | None:
"""{"feedback": str, "bewertung": "gut"|"schlecht", "bestanden": bool} · sonst None.""" """{"feedback": str, "bewertung": "gut"|"schlecht", "bestanden": bool} · sonst None."""
if not isinstance(data, dict): if not isinstance(data, dict):
@@ -264,6 +273,56 @@ async def pruefung_frage(
return None return None
async def _frage_ok(topic, baustein, section_block, kompakt_block, transcript, vermeide_block, frage, provider) -> bool:
"""Eine geplante Frage prüfen: Doppelfrage-Guard + Kritiker. True = in Ordnung."""
if _doppelfrage_mangel(frage):
return False
probleme = await _kritik_call(
"Baustein-Frage-Kritik", provider, role="guide",
topic=topic, baustein=baustein, section_block=section_block,
kompakt_block=kompakt_block, transcript=transcript, vermeide_block=vermeide_block, frage=frage,
)
return not probleme
async def pruefung_fragen(
topic: str, baustein: str, section: str, kompakt: str | None,
messages: list[dict], vermeide: list[str] | None = None, anzahl: int = 5,
provider: str = DEFAULT_PROVIDER,
) -> list[str]:
"""Aktion 'fragen': EIN Planer entwirft `anzahl` verschiedene Fragen, parallel geprüft.
Diversität kommt vom Planer; der Kritiker prüft Qualität + Ähnlichkeit zu Gestelltem.
→ Liste gültiger Fragen (kann < anzahl sein)."""
try:
section_block, kompakt_block = _bloecke(section, kompakt)
transcript = _transcript(messages) if messages else "(leer)"
vermeide = [f for f in (vermeide or []) if f and f.strip()]
gesammelt: list[str] = []
for _ in range(2): # Plan + eine Nachrunde für Abgelehnte
brauche = anzahl - len(gesammelt)
if brauche <= 0:
break
vblock = _vermeide_block(vermeide + gesammelt)
kandidaten = await _gen_call(
"Baustein-Fragen-Plan", "fast", _fragen_schema, provider, CHAT_TIMEOUT,
topic=topic, baustein=baustein, section_block=section_block, kompakt_block=kompakt_block,
transcript=transcript, vermeide_block=vblock, anzahl=brauche,
)
if not kandidaten:
break
oks = await asyncio.gather(
*[_frage_ok(topic, baustein, section_block, kompakt_block, transcript, vblock, f, provider) for f in kandidaten],
return_exceptions=True,
)
for f, ok in zip(kandidaten, oks):
if ok is True and f not in gesammelt:
gesammelt.append(f)
return gesammelt[:anzahl]
except Exception:
log.warning("[%s] Fragen-Plan fehlgeschlagen (%s)", topic, baustein, exc_info=True)
return []
async def pruefung_bewertung( async def pruefung_bewertung(
topic: str, baustein: str, section: str, kompakt: str | None, topic: str, baustein: str, section: str, kompakt: str | None,
frage: str, messages: list[dict], gute_antworten: int, provider: str = DEFAULT_PROVIDER, frage: str, messages: list[dict], gute_antworten: int, provider: str = DEFAULT_PROVIDER,

View File

@@ -218,6 +218,7 @@ class BausteinPruefungRequest(BaseModel):
frage: str = Field(default="", max_length=2000) # aktuell geprüfte Frage (für diskussion/antwort); Anker der Basis 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) letzte_bewertung: str = Field(default="", max_length=2000) # Feedback der letzten Bewertung (Kontext für diskussion)
vermeide: list[str] = [] # schon gestellte + vorgemerkte Fragen — sinngemäß nicht wiederholen vermeide: list[str] = [] # schon gestellte + vorgemerkte Fragen — sinngemäß nicht wiederholen
anzahl: int = 5 # aktion 'fragen': wie viele neue Fragen der Planer entwerfen soll
# Basis/Streak werden serverseitig je Frage geführt (offene_frage-Anker) — nicht mehr vom Client. # 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 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 tier3: bool = False # ganzer Guide verstanden (alle ≥10) → Meisterpfad, 2 bei falsch, Deckel 25
@@ -228,6 +229,7 @@ class BausteinPruefungRequest(BaseModel):
class BausteinPruefungResponse(BaseModel): class BausteinPruefungResponse(BaseModel):
frage: str | None = None frage: str | None = None
fragen: list[str] | None = None # aktion 'fragen': geplante, geprüfte Fragen
reply: str | None = None reply: str | None = None
feedback: str | None = None feedback: str | None = None
bewertung: Literal["gut", "schlecht"] | None = None bewertung: Literal["gut", "schlecht"] | None = None

View File

@@ -18,7 +18,7 @@ from database import (
) )
from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, lade_quelle, lade_uebersicht 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 from elements import generate_element, chat_with_guide, chat_with_element, check_element, style_element, refine_suggestion
from lernen import NOETIG, MASTERY, MEISTERN, baustein_chat, baustein_diskussion, baustein_element_anlegen, pruefung_bewertung, pruefung_bewertung_schnell, pruefung_frage, score_berechnen, streak_berechnen from lernen import NOETIG, MASTERY, MEISTERN, baustein_chat, baustein_diskussion, baustein_element_anlegen, pruefung_bewertung, pruefung_bewertung_schnell, pruefung_frage, pruefung_fragen, score_berechnen, streak_berechnen
from guide import generate_guide, guide_slot_dateien from guide import generate_guide, guide_slot_dateien
from pipeline import cancel_guide from pipeline import cancel_guide
from regeln import FORMATE, formate_stats, guide_lock, ist_absolviert, lade_lernstand, thema_abgeschlossen from regeln import FORMATE, formate_stats, guide_lock, ist_absolviert, lade_lernstand, thema_abgeschlossen
@@ -273,6 +273,10 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest):
raise HTTPException(502, "Frage fehlgeschlagen — bitte erneut versuchen") raise HTTPException(502, "Frage fehlgeschlagen — bitte erneut versuchen")
return {"frage": frage, "gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert} return {"frage": frage, "gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
if req.aktion == "fragen":
fragen = await pruefung_fragen(req.topic, req.baustein, req.section, kompakt, msgs, vermeide=req.vermeide, anzahl=req.anzahl, provider=req.provider)
return {"fragen": fragen, "gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
if req.aktion == "diskussion": if req.aktion == "diskussion":
if not req.frage.strip(): if not req.frage.strip():
raise HTTPException(400, "Diskussion braucht eine laufende Frage") raise HTTPException(400, "Diskussion braucht eine laufende Frage")

View File

@@ -77,13 +77,13 @@ export async function chatBaustein({ topic, baustein, section, section_kompakt =
export async function pruefeBaustein({ export async function pruefeBaustein({
topic, baustein, section, section_kompakt = '', provider, topic, baustein, section, section_kompakt = '', provider,
aktion = 'frage', frage = '', letzte_bewertung = '', vermeide = [], aktion = 'frage', frage = '', letzte_bewertung = '', vermeide = [], anzahl = 5,
tier2 = false, tier3 = false, messages = [], gruendlich = false, tier2 = false, tier3 = false, messages = [], gruendlich = false,
}) { }) {
const res = await fetch(`${BASE}/bausteine/pruefung`, { const res = await fetch(`${BASE}/bausteine/pruefung`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, baustein, section, section_kompakt, aktion, frage, letzte_bewertung, vermeide, tier2, tier3, messages, provider, gruendlich }), body: JSON.stringify({ topic, baustein, section, section_kompakt, aktion, frage, letzte_bewertung, vermeide, anzahl, tier2, tier3, messages, provider, gruendlich }),
}) })
return jsonOrThrow(res) return jsonOrThrow(res)
} }

View File

@@ -137,27 +137,24 @@ function vermeideListe() {
return [...gestellt, ...pool.value] return [...gestellt, ...pool.value]
} }
async function frageEinzeln() { // Pool als BATCH auffüllen: EIN Planer entwirft die fehlenden Fragen auf einmal
const res = await pruefeBaustein({ // (verschieden by design), parallel geprüft → schnell und ohne ähnliche Dubletten.
topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt,
provider: props.provider, aktion: 'frage', messages: pruefDialog(), vermeide: vermeideListe(),
})
return res.frage
}
// Pool SEQUENZIELL auffüllen — je eine Generierung; jede neue sieht die vorigen
// (Verlauf + Pool) in der Vermeide-Liste und divergiert. Verhindert ähnliche Dubletten.
function poolFuellen() { function poolFuellen() {
if (inflight.value > 0) return if (inflight.value > 0) return
if (pool.value.length >= POOL_ZIEL || poolMiss >= 6) return const brauche = POOL_ZIEL - pool.value.length
if (brauche <= 0 || poolMiss >= 3) return
inflight.value++ inflight.value++
frageEinzeln() pruefeBaustein({
.then((text) => { topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt,
if (text && !istBekannt(text)) { pool.value.push(text); poolMiss = 0 } provider: props.provider, aktion: 'fragen', anzahl: brauche, messages: pruefDialog(), vermeide: vermeideListe(),
})
.then((res) => {
const neu = (res.fragen || []).filter((f) => f && !istBekannt(f))
if (neu.length) { pool.value.push(...neu); poolMiss = 0 }
else poolMiss++ else poolMiss++
}) })
.catch(() => { poolMiss++ }) .catch(() => { poolMiss++ })
.finally(() => { inflight.value--; poolFuellen() }) .finally(() => { inflight.value--; if (pool.value.length < POOL_ZIEL) poolFuellen() })
} }
// Eine Frage aus dem Pool nehmen; ist er leer, auf die erste eintreffende warten. // Eine Frage aus dem Pool nehmen; ist er leer, auf die erste eintreffende warten.
@@ -165,7 +162,7 @@ async function poolNehmen() {
if (!pool.value.length) { if (!pool.value.length) {
pruefLoading.value = true pruefLoading.value = true
try { try {
while (!pool.value.length && (inflight.value > 0 || poolMiss < 6)) { while (!pool.value.length && (inflight.value > 0 || poolMiss < 3)) {
if (!inflight.value) poolFuellen() if (!inflight.value) poolFuellen()
await new Promise((r) => setTimeout(r, 120)) await new Promise((r) => setTimeout(r, 120))
} }
@@ -299,6 +296,7 @@ onMounted(() => {
if (props.mode !== 'full') return // Trigger-Modus (Liste): nur Tab-Leiste, keine Prüfungs-/Text-Logik if (props.mode !== 'full') return // Trigger-Modus (Liste): nur Tab-Leiste, keine Prüfungs-/Text-Logik
window.addEventListener('keydown', onWindowKey) window.addEventListener('keydown', onWindowKey)
window.addEventListener('keydown', onPruefKey) window.addEventListener('keydown', onPruefKey)
poolFuellen() // Pool schon beim Öffnen vorfüllen — keine Wartezeit bei der ersten Frage
}) })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('keydown', onWindowKey) window.removeEventListener('keydown', onWindowKey)

View File

@@ -0,0 +1,31 @@
Du bist Prüfer in einem Lern-Guide zum Thema "{topic}". Plane {anzahl} Verständnisfragen zum Baustein "{baustein}" — auf einmal, damit sie sich klar UNTERSCHEIDEN. Der Lerner sieht das Material; frage nach Verständnis und Transfer, nicht nach Abgelesenem.
BAUSTEIN AUS DEM GUIDE:
{section_block}
KOMPAKTE FASSUNG (Merksätze, falls vorhanden):
{kompakt_block}
BISHERIGER PRÜFUNGS-VERLAUF (nur frühere Fragen und Antworten):
{transcript}
BEREITS GESTELLT / SCHON VORGEMERKT — auch SINNGEMÄSS NICHT wiederholen:
{vermeide_block}
WICHTIG — VIELFALT:
- Die {anzahl} Fragen müssen verschiedene Aspekte/Subbausteine treffen. KEINE zwei Fragen zur gleichen Kernsache, auch nicht umformuliert.
- Decke möglichst unterschiedliche Subbausteine ab (einfach → mittel → schwer gemischt).
HARTE REGELN JE FRAGE — wichtiger als alles andere:
- GENAU EINE Frage. Ein einziges Fragezeichen. Eine einzige Sache.
- Maximal 12 Sätze. Kein Szenario-Aufbau, keine Vorrede, kein "Angenommen … und außerdem …".
- Verboten: zwei Fragen mit "und"/"sowie" verketten, "nenne drei …", "sowohl … als auch …", Aufzähl-Forderungen.
- Frag nach EINEM Gedanken: ein Warum, eine Konsequenz, eine Abgrenzung, die Anwendung auf EIN kurzes Beispiel, einen Fehler finden.
- KEIN Faktenabruf ("welche Daten…", "wie viele…", "was enthält…").
- Frag nur zu dem, was der Abschnitt wirklich erklärt — nicht zu am Rand Erwähntem.
- Fachlich korrekt: aus dem Material beantwortbar, der Referenz NIE widersprechen, keine erfundenen Zusatzannahmen.
Sprich den Lerner direkt an, klares Deutsch, keine Floskeln.
Gib NUR dieses JSON aus (kein weiterer Text), genau {anzahl} verschiedene Fragen:
{{"fragen": ["genau eine kurze Frage", "…"]}}