update
This commit is contained in:
@@ -130,6 +130,16 @@ async def init_db():
|
|||||||
"UPDATE guides SET status = 'error', progress = NULL, error_msg = 'Server-Neustart' "
|
"UPDATE guides SET status = 'error', progress = NULL, error_msg = 'Server-Neustart' "
|
||||||
"WHERE status IN ('queued', 'generating')"
|
"WHERE status IN ('queued', 'generating')"
|
||||||
)
|
)
|
||||||
|
# Milestone-Migration auf Score-Skala 0–30: absolviert≥6, verstanden≥12, gemeistert≥18.
|
||||||
|
# Leitet die Meilensteine aus dem aktuellen Score ab (bestehende Timestamps bleiben),
|
||||||
|
# idempotent — nach einem Lauf stabil, im Normalbetrieb deckungsgleich mit der Prüf-Route.
|
||||||
|
await db.execute("UPDATE baustein_progress SET gute_antworten = 30 WHERE gute_antworten > 30") # Alt-Scores deckeln
|
||||||
|
for _spalte, _schwelle in (("absolviert", 6), ("verstanden", 12), ("gemeistert", 18)):
|
||||||
|
await db.execute(
|
||||||
|
f"UPDATE baustein_progress SET {_spalte} = COALESCE({_spalte}, datetime('now')) "
|
||||||
|
f"WHERE gute_antworten >= {_schwelle}"
|
||||||
|
)
|
||||||
|
await db.execute(f"UPDATE baustein_progress SET {_spalte} = NULL WHERE gute_antworten < {_schwelle}")
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,12 @@ from textkit import _norm_titel
|
|||||||
|
|
||||||
log = logging.getLogger("creator.lernen")
|
log = logging.getLogger("creator.lernen")
|
||||||
|
|
||||||
NOETIG = 3 # gute Antworten bis "absolviert" (Tier 1)
|
# Meilensteine je Baustein (Score 0–30). Form ergibt sich aus dem Score-Band:
|
||||||
MASTERY = 10 # Score bis "verstanden" (Tier 2)
|
# 0–6 Quiz, 7–12 Lückentext, 13–18 Erklären, 19–30 frei.
|
||||||
MEISTERN = 25 # Score bis "gemeistert" (Tier 3, Maximum)
|
NOETIG = 6 # MiniGuide-Meilenstein → „absolviert" (Quiz-Band durch)
|
||||||
|
MASTERY = 12 # Guide-Meilenstein → „verstanden" (Lückentext-Band durch)
|
||||||
|
MEISTERN = 18 # ProGuide-Meilenstein → „gemeistert" (Erklären-Band durch, Thema fertig)
|
||||||
|
MAX_VOLL = 30 # FullGuide-Maximum (freie Formwahl ab 18)
|
||||||
CHAT_TIMEOUT = 240
|
CHAT_TIMEOUT = 240
|
||||||
PRUEFUNG_TIMEOUT = 120 # kurze JSON-Turns; deckelt die Serien-Latenz pro Prüfungs-Schritt
|
PRUEFUNG_TIMEOUT = 120 # kurze JSON-Turns; deckelt die Serien-Latenz pro Prüfungs-Schritt
|
||||||
GRUENDLICH_TIMEOUT = 600 # „Gründlich prüfen": starkes Modell (role guide) braucht länger
|
GRUENDLICH_TIMEOUT = 600 # „Gründlich prüfen": starkes Modell (role guide) braucht länger
|
||||||
@@ -49,29 +52,22 @@ NIVEAUS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def score_berechnen(
|
def score_berechnen(basis: int, delta: int, floor: int, cap: int) -> int:
|
||||||
score_vor_frage: int, niveau: str, nachgefragt: bool,
|
"""Neuer Score · driftfrei aus der Basis. `delta` liefert die Route je Prüfungsform
|
||||||
tier2: bool, tier3: bool, absolviert: bool, verstanden: bool, gemeistert: bool,
|
(Quiz +1/−2, Lückentext +1/−1, Erklären −1…+3). Auf [floor, cap] geklemmt.
|
||||||
) -> int:
|
floor = höchster erreichter Meilenstein (10/20/30); cap = Max des höchsten Formats."""
|
||||||
"""Neuer Score · driftfrei (immer aus dem Basis-Score). Delta aus dem Antwort-Niveau:
|
return max(floor, min(cap, basis + delta))
|
||||||
je besser, desto mehr (−1/0/+1/+2/+3). Bei Nachfragen ist der Gewinn auf +1 gedeckelt.
|
|
||||||
Danach auf Boden/Deckel der aktiven Stufe geklemmt (3 / 10 / 25). Kein Streak mehr.
|
|
||||||
"""
|
def floor_aus_meilensteinen(absolviert: bool, verstanden: bool, gemeistert: bool) -> int:
|
||||||
|
"""Untergrenze des Scores: ein erreichter Meilenstein hält (kein Rückfall ins Band davor)."""
|
||||||
if gemeistert:
|
if gemeistert:
|
||||||
return MEISTERN
|
return MEISTERN
|
||||||
# Stufe nach Baustein-Fortschritt: frisch erst Tier 1 (0→3), dann ab absolviert/verstanden.
|
if verstanden:
|
||||||
tier2 = tier2 and absolviert
|
return MASTERY
|
||||||
tier3 = tier3 and verstanden
|
if absolviert:
|
||||||
if not tier2:
|
return NOETIG
|
||||||
floor, cap = (NOETIG if absolviert else 0), NOETIG
|
return 0
|
||||||
elif not tier3:
|
|
||||||
floor, cap = NOETIG, MASTERY
|
|
||||||
else:
|
|
||||||
floor, cap = MASTERY, MEISTERN
|
|
||||||
delta = NIVEAUS.get(niveau, 0)
|
|
||||||
if nachgefragt:
|
|
||||||
delta = min(delta, 1) # mit Hilfe (Nachfrage) höchstens +1
|
|
||||||
return max(floor, min(cap, score_vor_frage + delta))
|
|
||||||
|
|
||||||
|
|
||||||
def _transcript(messages: list[dict]) -> str:
|
def _transcript(messages: list[dict]) -> str:
|
||||||
@@ -295,6 +291,149 @@ async def pruefung_frage_variante(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _optionen_schema(opts) -> list[dict] | None:
|
||||||
|
"""[{text, korrekt}]×4 → validierte Liste · sonst None."""
|
||||||
|
if not isinstance(opts, list) or len(opts) != 4:
|
||||||
|
return None
|
||||||
|
out = []
|
||||||
|
for o in opts:
|
||||||
|
if not isinstance(o, dict):
|
||||||
|
return None
|
||||||
|
text = str(o.get("text", "")).strip()
|
||||||
|
korrekt = o.get("korrekt")
|
||||||
|
if not text or not isinstance(korrekt, bool):
|
||||||
|
return None
|
||||||
|
out.append({"text": text, "korrekt": korrekt})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _quiz_schema(data, genau_einer: bool = False) -> dict | None:
|
||||||
|
"""{"frage": str, "optionen": [{text, korrekt}]×4} → validiert · sonst None.
|
||||||
|
genau_einer (leichte Variante): exakt eine Option muss korrekt sein."""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
frage = str(data.get("frage", "")).strip()
|
||||||
|
out = _optionen_schema(data.get("optionen"))
|
||||||
|
if not frage or out is None:
|
||||||
|
return None
|
||||||
|
if genau_einer and sum(o["korrekt"] for o in out) != 1:
|
||||||
|
return None
|
||||||
|
return {"frage": frage, "optionen": out}
|
||||||
|
|
||||||
|
|
||||||
|
def _lueckwahl_schema(data) -> dict | None:
|
||||||
|
"""{"satz": str (mit ___), "optionen": [{text, korrekt}]×4} → genau 1 korrekt · sonst None."""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
satz = str(data.get("satz", "")).strip()
|
||||||
|
out = _optionen_schema(data.get("optionen"))
|
||||||
|
if not satz or "___" not in satz or out is None or sum(o["korrekt"] for o in out) != 1:
|
||||||
|
return None
|
||||||
|
return {"satz": satz, "optionen": out}
|
||||||
|
|
||||||
|
|
||||||
|
async def quiz_generieren(
|
||||||
|
topic: str, baustein: str, section: str, kompakt: str | None,
|
||||||
|
muster: str, schwer: bool = False, provider: str = DEFAULT_PROVIDER,
|
||||||
|
) -> dict | None:
|
||||||
|
"""Aus einem Muster eine Multiple-Choice-Frage. schwer: 0–4 richtig (Multi).
|
||||||
|
leicht: genau 1 richtig (Single). Starkes Modell (role guide) für korrekte Flags.
|
||||||
|
→ {frage, optionen:[{text,korrekt}]} · None bei Fehler."""
|
||||||
|
try:
|
||||||
|
section_block, kompakt_block = _bloecke(section, kompakt)
|
||||||
|
anzahl_block = (
|
||||||
|
"0 bis 4 Optionen dürfen richtig sein (nicht zwingend genau eine). Mische bewusst."
|
||||||
|
if schwer else
|
||||||
|
"GENAU EINE Option ist richtig, die anderen drei sind klar falsch."
|
||||||
|
)
|
||||||
|
return await _gen_call(
|
||||||
|
"Baustein-Quiz", "guide", (lambda d: _quiz_schema(d, genau_einer=not schwer)), provider,
|
||||||
|
topic=topic, baustein=baustein, section_block=section_block,
|
||||||
|
kompakt_block=kompakt_block, muster=muster, anzahl_block=anzahl_block,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
log.warning("[%s] Quiz-Frage fehlgeschlagen (%s)", topic, baustein, exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def lueckwahl_generieren(
|
||||||
|
topic: str, baustein: str, section: str, kompakt: str | None,
|
||||||
|
muster: str, provider: str = DEFAULT_PROVIDER,
|
||||||
|
) -> dict | None:
|
||||||
|
"""Lückentext mit Auswahl (leichte Variante): Satz mit ___ + 4 Begriffe, genau 1 richtig.
|
||||||
|
→ {satz, optionen:[{text,korrekt}]} · None bei Fehler."""
|
||||||
|
try:
|
||||||
|
section_block, kompakt_block = _bloecke(section, kompakt)
|
||||||
|
return await _gen_call(
|
||||||
|
"Baustein-Lueckwahl", "guide", _lueckwahl_schema, provider,
|
||||||
|
topic=topic, baustein=baustein, section_block=section_block,
|
||||||
|
kompakt_block=kompakt_block, muster=muster,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
log.warning("[%s] Lückentext-Auswahl fehlgeschlagen (%s)", topic, baustein, exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _lueck_schema(data) -> dict | None:
|
||||||
|
"""{"satz": str (mit ___), "loesung": str, "alternativen": [str]} → validiert · sonst None."""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
satz = str(data.get("satz", "")).strip()
|
||||||
|
loesung = str(data.get("loesung", "")).strip()
|
||||||
|
alt = data.get("alternativen", [])
|
||||||
|
if not satz or "___" not in satz or not loesung:
|
||||||
|
return None
|
||||||
|
alternativen = [str(a).strip() for a in alt if isinstance(a, str) and str(a).strip()] if isinstance(alt, list) else []
|
||||||
|
return {"satz": satz, "loesung": loesung, "alternativen": alternativen}
|
||||||
|
|
||||||
|
|
||||||
|
async def lueckentext_generieren(
|
||||||
|
topic: str, baustein: str, section: str, kompakt: str | None,
|
||||||
|
muster: str, provider: str = DEFAULT_PROVIDER,
|
||||||
|
) -> dict | None:
|
||||||
|
"""Aus einem Muster eine Lückentext-Aufgabe (Satz mit ___, Lösung, Synonyme).
|
||||||
|
→ {satz, loesung, alternativen} · None bei Fehler."""
|
||||||
|
try:
|
||||||
|
section_block, kompakt_block = _bloecke(section, kompakt)
|
||||||
|
return await _gen_call(
|
||||||
|
"Baustein-Lueckentext", "fast", _lueck_schema, provider,
|
||||||
|
topic=topic, baustein=baustein, section_block=section_block,
|
||||||
|
kompakt_block=kompakt_block, muster=muster,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
log.warning("[%s] Lückentext-Frage fehlgeschlagen (%s)", topic, baustein, exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_begriff(t: str) -> str:
|
||||||
|
return re.sub(r"[^\wäöüß]", "", str(t or "").lower())
|
||||||
|
|
||||||
|
|
||||||
|
def _richtig_schema(data) -> dict | None:
|
||||||
|
if not isinstance(data, dict) or not isinstance(data.get("richtig"), bool):
|
||||||
|
return None
|
||||||
|
return {"richtig": data["richtig"]}
|
||||||
|
|
||||||
|
|
||||||
|
async def lueckentext_pruefen(
|
||||||
|
topic: str, baustein: str, satz: str, loesung: str, alternativen: list[str],
|
||||||
|
eingabe: str, provider: str = DEFAULT_PROVIDER,
|
||||||
|
) -> bool:
|
||||||
|
"""Lückentext-Antwort prüfen: erst normalisierter Vergleich (Lösung + Synonyme),
|
||||||
|
sonst 1 KI-Call für Synonym-Toleranz. Fail-open zu RICHTIG nur bei exaktem Match."""
|
||||||
|
if not eingabe.strip():
|
||||||
|
return False
|
||||||
|
norm = _norm_begriff(eingabe)
|
||||||
|
if norm and norm in {_norm_begriff(loesung), *(_norm_begriff(a) for a in alternativen)}:
|
||||||
|
return True
|
||||||
|
data = await _gen_call(
|
||||||
|
"Baustein-Lueckentext-Pruefung", "fast", _richtig_schema, provider,
|
||||||
|
topic=topic, baustein=baustein, satz=satz, loesung=loesung,
|
||||||
|
alternativen=", ".join(alternativen) or "(keine)", eingabe=eingabe,
|
||||||
|
)
|
||||||
|
return bool(data and data["richtig"])
|
||||||
|
|
||||||
|
|
||||||
async def pruefung_bewertung_schnell(
|
async def pruefung_bewertung_schnell(
|
||||||
topic: str, baustein: str, section: str, kompakt: str | None,
|
topic: str, baustein: str, section: str, kompakt: str | None,
|
||||||
frage: str, messages: list[dict], provider: str = DEFAULT_PROVIDER,
|
frage: str, messages: list[dict], provider: str = DEFAULT_PROVIDER,
|
||||||
|
|||||||
@@ -215,27 +215,44 @@ class BausteinPruefungRequest(BaseModel):
|
|||||||
baustein: str = Field(min_length=1, max_length=200)
|
baustein: str = Field(min_length=1, max_length=200)
|
||||||
section: str = Field(default="", max_length=20000) # ausführliche Fassung
|
section: str = Field(default="", max_length=20000) # ausführliche Fassung
|
||||||
section_kompakt: str = Field(default="", max_length=20000) # kompakte Fassung (Merksätze)
|
section_kompakt: str = Field(default="", max_length=20000) # kompakte Fassung (Merksätze)
|
||||||
aktion: Literal["frage", "diskussion", "antwort", "antwort_pruefen"] = "frage"
|
aktion: Literal[
|
||||||
|
"frage", "diskussion", "antwort", "antwort_pruefen",
|
||||||
|
"quiz_frage", "quiz_antwort", "lueck_frage", "lueck_antwort",
|
||||||
|
] = "frage"
|
||||||
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
|
||||||
|
auswahl: list[int] = [] # Quiz/Lückentext-Auswahl: vom Lerner gewählte Options-Indizes
|
||||||
|
korrekt: list[int] = [] # Quiz/Lückentext-Auswahl: korrekte Indizes (Client hält sie aus der Generierung)
|
||||||
|
loesung: str = Field(default="", max_length=500) # Lückentext frei: erwarteter Begriff
|
||||||
|
alternativen: list[str] = [] # Lückentext frei: akzeptierte Synonyme
|
||||||
|
eingabe: str = Field(default="", max_length=500) # Lückentext frei: getippter Begriff
|
||||||
|
schwer: bool = False # Variante: leicht (+1/−1) vs schwer (+3/−1)
|
||||||
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
|
||||||
nachgefragt: bool = False # für diese Frage wurde nachgefragt → Gewinn auf +1 gedeckelt
|
nachgefragt: bool = False # für diese Frage wurde nachgefragt → Gewinn auf +1 gedeckelt
|
||||||
begruendung: str = Field(default="", max_length=2000) # „Gründlich prüfen": warum mit der Bewertung unzufrieden
|
begruendung: str = Field(default="", max_length=2000) # „Gründlich prüfen": warum mit der Bewertung unzufrieden
|
||||||
muster: str = Field(default="", max_length=2000) # gezogenes Frage-Muster (Saat); leer → Live-Generierung (Fallback)
|
muster: str = Field(default="", max_length=2000) # gezogenes Frage-Muster (Saat); leer → Live-Generierung (Fallback)
|
||||||
# Basis wird serverseitig je Frage geführt (offene_frage-Anker) — nicht mehr vom Client.
|
# Basis wird 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
|
cap: int = Field(default=6, ge=6, le=30) # Score-Deckel = Max des höchsten erstellten Formats (6/12/18/30)
|
||||||
tier3: bool = False # ganzer Guide verstanden (alle ≥10) → Meisterpfad, −2 bei falsch, Deckel 25
|
|
||||||
messages: list[ChatMessage] = [] # Dialog bisher; leer = erste Frage
|
messages: list[ChatMessage] = [] # Dialog bisher; leer = erste Frage
|
||||||
provider: ProviderType = "claude"
|
provider: ProviderType = "claude"
|
||||||
gruendlich: bool = False # „Gründlich prüfen": Bewertung mit starkem Modell (role guide)
|
gruendlich: bool = False # „Gründlich prüfen": Bewertung mit starkem Modell (role guide)
|
||||||
|
|
||||||
|
|
||||||
|
class QuizOption(BaseModel):
|
||||||
|
text: str
|
||||||
|
korrekt: bool
|
||||||
|
|
||||||
|
|
||||||
class BausteinPruefungResponse(BaseModel):
|
class BausteinPruefungResponse(BaseModel):
|
||||||
frage: str | None = None
|
frage: str | None = None
|
||||||
reply: str | None = None
|
reply: str | None = None
|
||||||
feedback: str | None = None
|
feedback: str | None = None
|
||||||
punkte: int | None = None # Punkt-Delta dieser Antwort (−1 … +3); schnell = voraussichtlich
|
punkte: int | None = None # Punkt-Delta dieser Antwort (−2 … +3); schnell = voraussichtlich
|
||||||
bewertung: Literal["gut", "neutral", "schlecht"] | None = None # aus Vorzeichen, fürs Einfärben
|
bewertung: Literal["gut", "neutral", "schlecht"] | None = None # aus Vorzeichen, fürs Einfärben
|
||||||
|
optionen: list[QuizOption] | None = None # Quiz: 4 Optionen + Korrekt-Flags
|
||||||
|
satz: str | None = None # Lückentext: Satz mit Lücke (___)
|
||||||
|
loesung: str | None = None # Lückentext: erwarteter Begriff
|
||||||
|
alternativen: list[str] | None = None # Lückentext: akzeptierte Synonyme
|
||||||
gute_antworten: int
|
gute_antworten: int
|
||||||
absolviert: bool
|
absolviert: bool
|
||||||
verstanden: bool = False
|
verstanden: bool = False
|
||||||
|
|||||||
@@ -18,12 +18,20 @@ from paths import bausteine_path, guide_content_path
|
|||||||
from textkit import _norm_titel
|
from textkit import _norm_titel
|
||||||
|
|
||||||
MAX_OFFENE_GUIDES = 3
|
MAX_OFFENE_GUIDES = 3
|
||||||
VORSTUFE = {"Guide": "MiniGuide", "ProGuide": "Guide", "FullGuide": "Guide"}
|
# Lineare Progression: jedes Format schaltet das nächste frei, sobald ALLE Bausteine
|
||||||
# Welches Niveau die Vorstufe erreichen muss, um das Format freizuschalten.
|
# der Vorstufe das geforderte Niveau (Score-Meilenstein 10/20/30) erreicht haben.
|
||||||
# ProGuide hängt parallel zu FullGuide an „Guide absolviert" — kein Pflicht-Gate vor FullGuide.
|
VORSTUFE = {"Guide": "MiniGuide", "ProGuide": "Guide", "FullGuide": "ProGuide"}
|
||||||
FREISCHALT_LEVEL = {"Guide": "absolviert", "ProGuide": "absolviert", "FullGuide": "verstanden"}
|
# absolviert=6 · verstanden=12 · gemeistert=18 (siehe lernen.NOETIG/MASTERY/MEISTERN).
|
||||||
|
FREISCHALT_LEVEL = {"Guide": "absolviert", "ProGuide": "verstanden", "FullGuide": "gemeistert"}
|
||||||
FORMATE = ("MiniGuide", "Guide", "ProGuide", "FullGuide")
|
FORMATE = ("MiniGuide", "Guide", "ProGuide", "FullGuide")
|
||||||
|
|
||||||
|
# Geforderter Score je Freischalt-Niveau — nur für die Sperr-Meldung.
|
||||||
|
_LEVEL_WORT = {
|
||||||
|
"absolviert": "absolvieren (alle Bausteine auf 6)",
|
||||||
|
"verstanden": "verstehen (alle Bausteine auf 12)",
|
||||||
|
"gemeistert": "meistern (alle Bausteine auf 18)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def lade_lernstand() -> tuple[list[dict], dict[str, set[str]], dict[str, dict[str, set[str]]]]:
|
async def lade_lernstand() -> tuple[list[dict], dict[str, set[str]], dict[str, dict[str, set[str]]]]:
|
||||||
"""Guides + Kapitel-Fortschritt + Bausteine je Meilenstein.
|
"""Guides + Kapitel-Fortschritt + Bausteine je Meilenstein.
|
||||||
@@ -127,10 +135,9 @@ def guide_lock(topic: str, fmt: str, guides: list[dict], progress: dict[str, set
|
|||||||
if fmt != "OnePager" and not content.exists() and not guide_slot_dateien(content):
|
if fmt != "OnePager" and not content.exists() and not guide_slot_dateien(content):
|
||||||
vorstufe = VORSTUFE.get(fmt)
|
vorstufe = VORSTUFE.get(fmt)
|
||||||
if vorstufe:
|
if vorstufe:
|
||||||
stufe = FREISCHALT_LEVEL[fmt] # "absolviert" (alle 3) oder "verstanden" (alle 10)
|
stufe = FREISCHALT_LEVEL[fmt] # absolviert=10 · verstanden=20 · gemeistert=30
|
||||||
if not ist_level(topic, vorstufe, guides, progress, levels[stufe]):
|
if not ist_level(topic, vorstufe, guides, progress, levels[stufe]):
|
||||||
wort = "verstehen (alle Bausteine auf 10)" if stufe == "verstanden" else "absolvieren (alle Bausteine prüfen)"
|
return f"Erst den {vorstufe} dieses Themas {_LEVEL_WORT[stufe]}"
|
||||||
return f"Erst den {vorstufe} dieses Themas {wort}"
|
|
||||||
stat = formate_stats(guides, progress, levels).get(fmt, {"erstellt": 0, "absolviert": 0})
|
stat = formate_stats(guides, progress, levels).get(fmt, {"erstellt": 0, "absolviert": 0})
|
||||||
offen = stat["erstellt"] - stat["absolviert"]
|
offen = stat["erstellt"] - stat["absolviert"]
|
||||||
if offen >= MAX_OFFENE_GUIDES:
|
if offen >= MAX_OFFENE_GUIDES:
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from database import (
|
|||||||
)
|
)
|
||||||
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, lade_frage_muster
|
||||||
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, pruefung_frage_variante, score_berechnen
|
from lernen import NOETIG, MASTERY, MEISTERN, NIVEAUS, 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_meilensteinen
|
||||||
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
|
||||||
@@ -265,6 +265,41 @@ def _farbe(punkte: int) -> str:
|
|||||||
return "gut" if punkte > 0 else ("neutral" if punkte == 0 else "schlecht")
|
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 _punkte_buchen(req, frage: str, delta: int) -> dict:
|
||||||
|
"""Score driftfrei buchen (Lock + offene_frage-Anker + Meilensteine). Gemeinsam für
|
||||||
|
Quiz/Lückentext (deterministisch/schnell). → Response-Felder ohne feedback."""
|
||||||
|
async with _pruef_lock(req.topic, req.baustein):
|
||||||
|
stand = await get_baustein_progress(req.topic, req.baustein)
|
||||||
|
absolviert = stand["absolviert"] is not None
|
||||||
|
verstanden = stand["verstanden"] is not None
|
||||||
|
gemeistert = stand["gemeistert"] is not None
|
||||||
|
basis, re_bewertung = _basis(stand, frage)
|
||||||
|
if not re_bewertung:
|
||||||
|
await set_offene_frage(req.topic, req.baustein, frage, basis, 0)
|
||||||
|
floor = floor_aus_meilensteinen(absolviert, verstanden, gemeistert)
|
||||||
|
score = score_berechnen(basis, delta, floor, req.cap)
|
||||||
|
punkte = score - basis
|
||||||
|
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
|
||||||
|
return {"punkte": punkte, "bewertung": _farbe(punkte), "gute_antworten": gute,
|
||||||
|
"absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/bausteine/pruefung", response_model=BausteinPruefungResponse)
|
@router.post("/bausteine/pruefung", response_model=BausteinPruefungResponse)
|
||||||
async def baustein_pruefung_route(req: BausteinPruefungRequest):
|
async def baustein_pruefung_route(req: BausteinPruefungRequest):
|
||||||
stand = await get_baustein_progress(req.topic, req.baustein)
|
stand = await get_baustein_progress(req.topic, req.baustein)
|
||||||
@@ -298,6 +333,54 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest):
|
|||||||
raise HTTPException(502, "Diskussion fehlgeschlagen — bitte erneut versuchen")
|
raise HTTPException(502, "Diskussion fehlgeschlagen — bitte erneut versuchen")
|
||||||
return {"reply": reply, "gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
|
return {"reply": reply, "gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
|
||||||
|
|
||||||
|
# --- 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")
|
||||||
|
quiz = await quiz_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, schwer=req.schwer, provider=req.provider)
|
||||||
|
if quiz is None:
|
||||||
|
raise HTTPException(502, "Quiz-Frage fehlgeschlagen — bitte erneut versuchen")
|
||||||
|
return {"frage": quiz["frage"], "optionen": quiz["optionen"],
|
||||||
|
"gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
|
||||||
|
|
||||||
|
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
|
||||||
|
delta = (3 if req.schwer else 1) if getroffen else -1
|
||||||
|
res = await _punkte_buchen(req, req.frage, delta)
|
||||||
|
res["feedback"] = "Richtig — alle korrekten getroffen." if getroffen else "Nicht ganz — die markierten waren richtig."
|
||||||
|
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")
|
||||||
|
if req.schwer:
|
||||||
|
lt = await lueckentext_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, 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, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
|
||||||
|
lw = await lueckwahl_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, provider=req.provider)
|
||||||
|
if lw is None:
|
||||||
|
raise HTTPException(502, "Lückentext fehlgeschlagen — bitte erneut versuchen")
|
||||||
|
return {"satz": lw["satz"], "optionen": lw["optionen"],
|
||||||
|
"gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
|
||||||
|
|
||||||
|
if req.aktion == "lueck_antwort":
|
||||||
|
if not req.frage.strip():
|
||||||
|
raise HTTPException(400, "Lückentext-Antwort braucht einen Satz")
|
||||||
|
if req.schwer: # frei getippt → Synonym-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 _punkte_buchen(req, req.frage, (3 if req.schwer else 1) if ok else -1)
|
||||||
|
res["feedback"] = feedback
|
||||||
|
return res
|
||||||
|
|
||||||
# aktion "antwort" (Agent 1 schnell) / "antwort_pruefen" (Agent 2 genau).
|
# aktion "antwort" (Agent 1 schnell) / "antwort_pruefen" (Agent 2 genau).
|
||||||
if not any(m.get("role") == "user" for m in msgs):
|
if not any(m.get("role") == "user" for m in msgs):
|
||||||
raise HTTPException(400, "Antwort braucht eine Nutzer-Antwort")
|
raise HTTPException(400, "Antwort braucht eine Nutzer-Antwort")
|
||||||
@@ -312,7 +395,8 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest):
|
|||||||
if data is None:
|
if data is None:
|
||||||
raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen")
|
raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen")
|
||||||
basis, _ = _basis(stand, req.frage)
|
basis, _ = _basis(stand, req.frage)
|
||||||
score = score_berechnen(basis, data["niveau"], req.nachgefragt, req.tier2, req.tier3, absolviert, verstanden, gemeistert)
|
floor = floor_aus_meilensteinen(absolviert, verstanden, gemeistert)
|
||||||
|
score = score_berechnen(basis, _erklaeren_delta(data["niveau"], req.nachgefragt), floor, req.cap)
|
||||||
punkte = score - basis
|
punkte = score - basis
|
||||||
return {"feedback": data["feedback"], "punkte": punkte, "bewertung": _farbe(punkte),
|
return {"feedback": data["feedback"], "punkte": punkte, "bewertung": _farbe(punkte),
|
||||||
"gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
|
"gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
|
||||||
@@ -335,7 +419,8 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest):
|
|||||||
if data is None:
|
if data is None:
|
||||||
raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen")
|
raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen")
|
||||||
|
|
||||||
score = score_berechnen(basis, data["niveau"], req.nachgefragt, req.tier2, req.tier3, absolviert, verstanden, gemeistert)
|
floor = floor_aus_meilensteinen(absolviert, verstanden, gemeistert)
|
||||||
|
score = score_berechnen(basis, _erklaeren_delta(data["niveau"], req.nachgefragt), floor, req.cap)
|
||||||
punkte = score - basis
|
punkte = score - basis
|
||||||
gute = await set_baustein_score(req.topic, req.baustein, score)
|
gute = await set_baustein_score(req.topic, req.baustein, score)
|
||||||
if score >= NOETIG and not absolviert:
|
if score >= NOETIG and not absolviert:
|
||||||
|
|||||||
@@ -78,12 +78,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 = [],
|
||||||
nachgefragt = false, begruendung = '', muster = '', tier2 = false, tier3 = false, messages = [], gruendlich = false,
|
nachgefragt = false, begruendung = '', muster = '', cap = 10, messages = [], gruendlich = false,
|
||||||
|
auswahl = [], korrekt = [], loesung = '', alternativen = [], eingabe = '',
|
||||||
}) {
|
}) {
|
||||||
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, nachgefragt, begruendung, muster, tier2, tier3, messages, provider, gruendlich }),
|
body: JSON.stringify({ topic, baustein, section, section_kompakt, aktion, frage, letzte_bewertung, vermeide, nachgefragt, begruendung, muster, cap, messages, provider, gruendlich, auswahl, korrekt, loesung, alternativen, eingabe }),
|
||||||
})
|
})
|
||||||
return jsonOrThrow(res)
|
return jsonOrThrow(res)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ const props = defineProps({
|
|||||||
topic: { type: String, required: true },
|
topic: { type: String, required: true },
|
||||||
provider: { type: String, default: 'claude' },
|
provider: { type: String, default: 'claude' },
|
||||||
status: { type: Object, default: null },
|
status: { type: Object, default: null },
|
||||||
tier2: { type: Boolean, default: false },
|
cap: { type: Number, default: 6 },
|
||||||
tier3: { type: Boolean, default: false },
|
|
||||||
tab: { type: String, default: 'pruefung' },
|
tab: { type: String, default: 'pruefung' },
|
||||||
ansicht: { type: String, default: 'kompakt' }, // kompakt | erklärend — linke Guide-Spalte
|
ansicht: { type: String, default: 'kompakt' }, // kompakt | erklärend — linke Guide-Spalte
|
||||||
hasPrev: { type: Boolean, default: false },
|
hasPrev: { type: Boolean, default: false },
|
||||||
@@ -102,8 +101,7 @@ const standFarbe = computed(() => {
|
|||||||
:section-kompakt="baustein.kompakt || ''"
|
:section-kompakt="baustein.kompakt || ''"
|
||||||
:provider="provider"
|
:provider="provider"
|
||||||
:status="status"
|
:status="status"
|
||||||
:tier2="tier2"
|
:cap="cap"
|
||||||
:tier3="tier3"
|
|
||||||
:ansicht="ansicht"
|
:ansicht="ansicht"
|
||||||
@set-ansicht="$emit('setAnsicht', $event)"
|
@set-ansicht="$emit('setAnsicht', $event)"
|
||||||
@status-changed="$emit('statusChanged', $event)"
|
@status-changed="$emit('statusChanged', $event)"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { chatBaustein, pruefeBaustein, fetchFrageMuster } from '../api.js'
|
import { chatBaustein, pruefeBaustein, fetchFrageMuster } from '../api.js'
|
||||||
import { usePruefSlot } from '../pruefungCache.js'
|
import { usePruefSlot } from '../pruefungCache.js'
|
||||||
import { renderMarkdown } from '../markdown.js'
|
import { renderMarkdown, renderMarkdownInline } from '../markdown.js'
|
||||||
import { useChat, istUnten } from '../composables/useChat.js'
|
import { useChat, istUnten } from '../composables/useChat.js'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -12,8 +12,7 @@ const props = defineProps({
|
|||||||
sectionKompakt: { type: String, default: '' }, // kompakte Fassung (Merksätze) — Prüfungs-/Chat-Kontext
|
sectionKompakt: { type: String, default: '' }, // kompakte Fassung (Merksätze) — Prüfungs-/Chat-Kontext
|
||||||
provider: { type: String, default: 'claude' },
|
provider: { type: String, default: 'claude' },
|
||||||
status: { type: Object, default: null }, // {gute_antworten, streak, absolviert, verstanden, gemeistert}
|
status: { type: Object, default: null }, // {gute_antworten, streak, absolviert, verstanden, gemeistert}
|
||||||
tier2: { type: Boolean, default: false }, // Tier 2 frei (ganzer Guide absolviert)
|
cap: { type: Number, default: 6 }, // Score-Deckel = Max des höchsten Formats (6/12/18/30)
|
||||||
tier3: { type: Boolean, default: false }, // Meisterpfad frei (ganzer Guide verstanden)
|
|
||||||
// 'trigger' (Liste): nur Tab-Leiste, Klick öffnet den Vollbild-Fokus.
|
// 'trigger' (Liste): nur Tab-Leiste, Klick öffnet den Vollbild-Fokus.
|
||||||
// 'full' (Fokus): Tabs + Panel-Inhalt wie gehabt.
|
// 'full' (Fokus): Tabs + Panel-Inhalt wie gehabt.
|
||||||
mode: { type: String, default: 'trigger' },
|
mode: { type: String, default: 'trigger' },
|
||||||
@@ -23,10 +22,52 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['statusChanged', 'openFokus', 'setAnsicht'])
|
const emit = defineEmits(['statusChanged', 'openFokus', 'setAnsicht'])
|
||||||
|
|
||||||
const NOETIG = 3 // absolviert
|
// Meilensteine (Score-Bänder): Quiz→6, Lückentext→12, Erklären→18, FullGuide-Max 30.
|
||||||
const MAX = 10 // verstanden
|
const ABSOLVIERT = 6
|
||||||
const MEISTERN = 25 // gemeistert (Maximum)
|
const VERSTANDEN = 12
|
||||||
|
const GEMEISTERT = 18
|
||||||
|
const VOLL = 30
|
||||||
const st = computed(() => props.status || { gute_antworten: 0, streak: 0, absolviert: false, verstanden: false, gemeistert: false })
|
const st = computed(() => props.status || { gute_antworten: 0, streak: 0, absolviert: false, verstanden: false, gemeistert: false })
|
||||||
|
// Aktiver Modus aus Score-Band (unter 30 fix) + Schwierigkeit (ALT+E). Ab 30 frei.
|
||||||
|
// Ohne Frage-Muster (alter Baustein) gibt es nur Erklären — Quiz/Lückentext brauchen die Saat.
|
||||||
|
const MODI = ['quiz_leicht', 'quiz_schwer', 'lueck_leicht', 'lueck_schwer', 'erklaeren']
|
||||||
|
const score = computed(() => st.value.gute_antworten || 0)
|
||||||
|
const schwerWahl = ref(false) // leicht/schwer unter Score 30 (ALT+E)
|
||||||
|
const freiModus = ref('quiz_leicht') // gewählter Modus ab Score 30 (ALT+E zyklisch)
|
||||||
|
const aktiverModus = computed(() => {
|
||||||
|
if (!musterModus.value) return 'erklaeren'
|
||||||
|
if (score.value >= GEMEISTERT) return freiModus.value
|
||||||
|
if (score.value >= VERSTANDEN) return 'erklaeren'
|
||||||
|
if (score.value >= ABSOLVIERT) return schwerWahl.value ? 'lueck_schwer' : 'lueck_leicht'
|
||||||
|
return schwerWahl.value ? 'quiz_schwer' : 'quiz_leicht'
|
||||||
|
})
|
||||||
|
const aktiveForm = computed(() => aktiverModus.value.startsWith('quiz') ? 'quiz' : aktiverModus.value.startsWith('lueck') ? 'lueckentext' : 'erklaeren')
|
||||||
|
const schwer = computed(() => aktiverModus.value.endsWith('_schwer'))
|
||||||
|
// Angezeigte Form: folgt der aktiven Form, friert aber ein, solange eine gezogene Frage
|
||||||
|
// sichtbar ist (sonst verschwindet das Ergebnis, wenn die Antwort eine Bandgrenze überschreitet).
|
||||||
|
const anzeigeForm = ref(null)
|
||||||
|
const zeigeForm = computed(() => anzeigeForm.value || aktiveForm.value)
|
||||||
|
// Schwierigkeit des gerade gezeigten Widgets (eingefroren) bzw. der aktiven Wahl.
|
||||||
|
const aktSchwer = computed(() => {
|
||||||
|
if (zeigeForm.value === 'quiz' && quizAktuell.value) return quizAktuell.value.schwer
|
||||||
|
if (zeigeForm.value === 'lueckentext' && lueckAktuell.value) return lueckAktuell.value.schwer
|
||||||
|
return schwer.value
|
||||||
|
})
|
||||||
|
const modusRegel = computed(() => {
|
||||||
|
if (zeigeForm.value === 'quiz') return aktSchwer.value ? 'x von 4 · +3/−1' : '1 von 4 · +1/−1'
|
||||||
|
if (zeigeForm.value === 'lueckentext') return aktSchwer.value ? 'frei tippen · +3/−1' : 'Begriff aus 4 · +1/−1'
|
||||||
|
return '+1/+2/+3 · Nachfragen max +1 · schwach −1'
|
||||||
|
})
|
||||||
|
// Ende des aktuellen Bands (nächster Meilenstein) und ob der Score am Format-Deckel klebt.
|
||||||
|
const bandZiel = computed(() => {
|
||||||
|
if (score.value >= GEMEISTERT) return VOLL
|
||||||
|
if (score.value >= VERSTANDEN) return GEMEISTERT
|
||||||
|
if (score.value >= ABSOLVIERT) return VERSTANDEN
|
||||||
|
return ABSOLVIERT
|
||||||
|
})
|
||||||
|
const amDeckel = computed(() => score.value >= props.cap && props.cap < VOLL)
|
||||||
|
const FORM_NAME = { quiz: 'Quiz', lueckentext: 'Lückentext', erklaeren: 'Erklären' }
|
||||||
|
const NAECHSTES_FORMAT = { 6: 'Guide', 12: 'ProGuide', 18: 'FullGuide' }
|
||||||
|
|
||||||
// --- Toggle-Bereich ---
|
// --- Toggle-Bereich ---
|
||||||
const activeTab = ref(props.mode === 'full' ? props.initialTab : null) // null | 'chat' | 'pruefung'
|
const activeTab = ref(props.mode === 'full' ? props.initialTab : null) // null | 'chat' | 'pruefung'
|
||||||
@@ -56,6 +97,9 @@ const letztesFeedback = slot.letztesFeedback // Kontext für die Diskussion üb
|
|||||||
const musterQuelle = slot.musterQuelle // unveränderliche Voll-Liste der Frage-Muster (leer = Fallback)
|
const musterQuelle = slot.musterQuelle // unveränderliche Voll-Liste der Frage-Muster (leer = Fallback)
|
||||||
const musterPool = slot.musterPool // Arbeitskopie, ohne Zurücklegen; leer → Reset
|
const musterPool = slot.musterPool // Arbeitskopie, ohne Zurücklegen; leer → Reset
|
||||||
const musterGeladen = slot.musterGeladen
|
const musterGeladen = slot.musterGeladen
|
||||||
|
const poolForm = slot.poolForm // Form, für die der Pool gefüllt ist
|
||||||
|
const quizAktuell = slot.quizAktuell // laufende Quiz-Frage (Widget-State)
|
||||||
|
const lueckAktuell = slot.lueckAktuell // laufende Lückentext-Aufgabe (Widget-State)
|
||||||
// Transient (pro Instanz): Eingabe-Entwurf, Lade-Spinner.
|
// Transient (pro Instanz): Eingabe-Entwurf, Lade-Spinner.
|
||||||
const pruefInput = ref('')
|
const pruefInput = ref('')
|
||||||
const pruefLoading = ref(false)
|
const pruefLoading = ref(false)
|
||||||
@@ -127,25 +171,35 @@ function gestellteFragen() {
|
|||||||
return new Set(pruefMessages.value.filter((m) => m.kind === 'frage').map((m) => normFrage(m.content)))
|
return new Set(pruefMessages.value.filter((m) => m.kind === 'frage').map((m) => normFrage(m.content)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Fragen-Pool: ZIEL vorformulierte, unbeantwortete Fragen je Baustein (im Slot) ---
|
// --- Fragen-Pool: ZIEL vorformulierte Frage-OBJEKTE der aktiven Form (im Slot) ---
|
||||||
|
// Jedes Objekt: {form, frage, optionen?|satz?|loesung?|alternativen?}. Wechselt die
|
||||||
|
// aktive Form, wird der Pool geleert und für die neue Form neu gefüllt.
|
||||||
const POOL_ZIEL = 5
|
const POOL_ZIEL = 5
|
||||||
const pool = slot.pool
|
const pool = slot.pool
|
||||||
const inflight = slot.inflight
|
const inflight = slot.inflight
|
||||||
|
const pruefFehler = ref('') // Fehlerhinweis für Quiz/Lückentext (kein Bubble-Verlauf)
|
||||||
let poolMiss = 0 // nur Fallback: aufeinanderfolgende Dubletten/Fehler — Cap gegen Endlosschleife
|
let poolMiss = 0 // nur Fallback: aufeinanderfolgende Dubletten/Fehler — Cap gegen Endlosschleife
|
||||||
|
|
||||||
// Muster-Modus aktiv, sobald geladen und Muster vorhanden; sonst Fallback auf Live-Generierung.
|
// Muster-Modus aktiv, sobald geladen und Muster vorhanden; sonst Fallback (nur Erklären).
|
||||||
const musterModus = computed(() => musterGeladen.value && musterQuelle.value.length > 0)
|
const musterModus = computed(() => musterGeladen.value && musterQuelle.value.length > 0)
|
||||||
|
|
||||||
function istBekannt(text) {
|
function objText(o) { return (o && (o.frage || o.satz)) || '' }
|
||||||
const n = normFrage(text)
|
|
||||||
return !n || gestellteFragen().has(n) || pool.value.some((f) => normFrage(f) === n)
|
// Lückensatz rendern: an „___" splitten, Teile inline (mit $…$-Mathe), Lücke als Span.
|
||||||
|
// So bleibt die Lücke erhalten und Markdown fasst die Unterstriche nicht an.
|
||||||
|
function lueckSatzHtml(satz) {
|
||||||
|
return String(satz || '').split('___').map((t) => renderMarkdownInline(t)).join('<span class="bp-luecke">______</span>')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schon gestellte + bereits vorgemerkte Fragen — der Generator soll sie (auch
|
function istBekannt(o) {
|
||||||
// sinngemäß) meiden. Nur Fallback-Modus. Original-Texte, nicht normalisiert.
|
const n = normFrage(objText(o))
|
||||||
|
return !n || gestellteFragen().has(n) || pool.value.some((f) => normFrage(objText(f)) === n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schon gestellte + vorgemerkte Fragen — nur Fallback-Modus (Erklären ohne Muster).
|
||||||
function vermeideListe() {
|
function vermeideListe() {
|
||||||
const gestellt = pruefMessages.value.filter((m) => m.kind === 'frage').map((m) => m.content)
|
const gestellt = pruefMessages.value.filter((m) => m.kind === 'frage').map((m) => m.content)
|
||||||
return [...gestellt, ...pool.value]
|
return [...gestellt, ...pool.value.map(objText)]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Muster-Sidecar einmal je Baustein laden (Slot-cached). Leer → Fallback.
|
// Muster-Sidecar einmal je Baustein laden (Slot-cached). Leer → Fallback.
|
||||||
@@ -176,31 +230,55 @@ function musterNehmen() {
|
|||||||
return musterPool.value.shift()
|
return musterPool.value.shift()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pool-Schlüssel = Form + Schwierigkeit (Quiz leicht/schwer ziehen verschiedene Fragen).
|
||||||
|
const poolKey = computed(() => `${aktiveForm.value}/${schwer.value}`)
|
||||||
|
|
||||||
|
// Ein Frage-Objekt des aktiven Modus generieren (aus gezogener Saat; Erklären hat Fallback).
|
||||||
function frageEinzeln() {
|
function frageEinzeln() {
|
||||||
|
const form = aktiveForm.value
|
||||||
|
const istSchwer = schwer.value
|
||||||
const muster = musterNehmen()
|
const muster = musterNehmen()
|
||||||
if (muster) {
|
const basis = {
|
||||||
// Muster-Modus: aus gezogener Saat eine konkrete, leicht andere Frage formulieren.
|
topic: props.topic, baustein: props.baustein, section: props.section,
|
||||||
return pruefeBaustein({
|
section_kompakt: props.sectionKompakt, provider: props.provider,
|
||||||
topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt,
|
|
||||||
provider: props.provider, aktion: 'frage', muster,
|
|
||||||
}).then((res) => res.frage)
|
|
||||||
}
|
}
|
||||||
// Fallback (kein Muster-Sidecar): Live-Generierung mit Vermeide-Liste.
|
if (form === 'quiz' && muster) {
|
||||||
return pruefeBaustein({
|
return pruefeBaustein({ ...basis, aktion: 'quiz_frage', muster, schwer: istSchwer })
|
||||||
topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt,
|
.then((r) => ({ form, schwer: istSchwer, frage: r.frage, optionen: r.optionen }))
|
||||||
provider: props.provider, aktion: 'frage', messages: pruefDialog(), vermeide: vermeideListe(),
|
}
|
||||||
}).then((res) => res.frage)
|
if (form === 'lueckentext' && muster) {
|
||||||
|
return pruefeBaustein({ ...basis, aktion: 'lueck_frage', muster, schwer: istSchwer })
|
||||||
|
.then((r) => istSchwer
|
||||||
|
? ({ form, schwer: true, frage: r.satz, satz: r.satz, loesung: r.loesung, alternativen: r.alternativen })
|
||||||
|
: ({ form, schwer: false, frage: r.satz, satz: r.satz, optionen: r.optionen }))
|
||||||
|
}
|
||||||
|
if (muster) { // erklaeren aus Muster
|
||||||
|
return pruefeBaustein({ ...basis, aktion: 'frage', muster }).then((r) => ({ form: 'erklaeren', frage: r.frage }))
|
||||||
|
}
|
||||||
|
// Fallback (kein Muster-Sidecar): Erklären live mit Vermeide-Liste.
|
||||||
|
return pruefeBaustein({ ...basis, aktion: 'frage', messages: pruefDialog(), vermeide: vermeideListe() })
|
||||||
|
.then((r) => ({ form: 'erklaeren', frage: r.frage }))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pool PARALLEL auffüllen: pro fehlender Frage ein eigener Agent. Im Muster-Modus
|
// Pool leeren, wenn Form oder Schwierigkeit gewechselt hat (Bandgrenze oder ALT+E).
|
||||||
// zieht jeder ein DISTINKTES Muster (ohne Zurücklegen) → keine Kollision, kein Miss-Cap.
|
function poolFormPruefen() {
|
||||||
|
if (poolForm.value !== poolKey.value) {
|
||||||
|
pool.value = []
|
||||||
|
poolForm.value = poolKey.value
|
||||||
|
poolMiss = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pool PARALLEL auffüllen: jeder zieht ein DISTINKTES Muster → keine Kollision.
|
||||||
function poolFuellen() {
|
function poolFuellen() {
|
||||||
|
poolFormPruefen()
|
||||||
|
const keyBeginn = poolKey.value
|
||||||
const cap = musterModus.value ? Infinity : 3
|
const cap = musterModus.value ? Infinity : 3
|
||||||
while (pool.value.length + inflight.value < POOL_ZIEL && poolMiss < cap) {
|
while (pool.value.length + inflight.value < POOL_ZIEL && poolMiss < cap) {
|
||||||
inflight.value++
|
inflight.value++
|
||||||
frageEinzeln()
|
frageEinzeln()
|
||||||
.then((text) => {
|
.then((obj) => {
|
||||||
if (text && !istBekannt(text)) { pool.value.push(text); poolMiss = 0 }
|
if (obj && objText(obj) && poolKey.value === keyBeginn && (musterModus.value || !istBekannt(obj))) { pool.value.push(obj); poolMiss = 0 }
|
||||||
else poolMiss++
|
else poolMiss++
|
||||||
})
|
})
|
||||||
.catch(() => { poolMiss++ })
|
.catch(() => { poolMiss++ })
|
||||||
@@ -208,7 +286,7 @@ function poolFuellen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Eine Frage aus dem Pool nehmen; ist er leer, auf die erste eintreffende warten.
|
// Ein Frage-Objekt aus dem Pool nehmen; ist er leer, auf das erste eintreffende warten.
|
||||||
async function poolNehmen() {
|
async function poolNehmen() {
|
||||||
if (!pool.value.length) {
|
if (!pool.value.length) {
|
||||||
const cap = musterModus.value ? Infinity : 3
|
const cap = musterModus.value ? Infinity : 3
|
||||||
@@ -223,36 +301,101 @@ async function poolNehmen() {
|
|||||||
return pool.value.length ? pool.value.shift() : null
|
return pool.value.length ? pool.value.shift() : null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Erste Frage (Phase idle): Muster laden, ZIEL parallel anstoßen, schnellste zuerst zeigen.
|
// Ein gezogenes Objekt der passenden Form anzeigen (Erklären-Bubble bzw. Quiz/Lück-Widget).
|
||||||
async function frageAnfordern() {
|
function frageObjZeigen(obj) {
|
||||||
if (pruefLoading.value) return
|
pruefFehler.value = ''
|
||||||
poolMiss = 0
|
anzeigeForm.value = obj.form // Anzeige an die gezogene Frage binden (Form einfrieren)
|
||||||
await musterLaden()
|
if (obj.form === 'quiz') {
|
||||||
poolFuellen()
|
quizAktuell.value = { frage: obj.frage, optionen: obj.optionen, schwer: obj.schwer, gewaehlt: [], fertig: false, punkte: null, bewertung: null, feedback: '' }
|
||||||
const text = await poolNehmen()
|
} else if (obj.form === 'lueckentext') {
|
||||||
if (text) {
|
lueckAktuell.value = { satz: obj.satz, schwer: obj.schwer, optionen: obj.optionen || null, loesung: obj.loesung || '', alternativen: obj.alternativen || [], eingabe: '', gewaehlt: [], fertig: false, punkte: null, bewertung: null, feedback: '' }
|
||||||
frageZeigen(text)
|
if (obj.schwer) nextTick(() => document.getElementById('bp-lueck-input')?.focus())
|
||||||
nextTick(() => pruefInputEl.value?.focus())
|
|
||||||
} else {
|
} else {
|
||||||
pruefMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Keine Frage — bitte erneut.' })
|
frageZeigen(obj.frage)
|
||||||
|
nextTick(() => pruefInputEl.value?.focus())
|
||||||
}
|
}
|
||||||
poolFuellen()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// „Nächste Frage": sofort aus dem Pool, dann nachfüllen.
|
// Erste / nächste Frage: Muster laden, Pool füllen, schnellste zuerst zeigen.
|
||||||
async function naechsteFrageZeigen() {
|
async function naechsteFrageZeigen() {
|
||||||
if (pruefLoading.value) return
|
if (pruefLoading.value) return
|
||||||
|
poolMiss = 0
|
||||||
|
pruefFehler.value = ''
|
||||||
await musterLaden()
|
await musterLaden()
|
||||||
const text = await poolNehmen()
|
poolFuellen()
|
||||||
if (text) {
|
const obj = await poolNehmen()
|
||||||
frageZeigen(text)
|
if (obj) {
|
||||||
|
frageObjZeigen(obj)
|
||||||
pruefScroll()
|
pruefScroll()
|
||||||
nextTick(() => pruefInputEl.value?.focus())
|
} else if (aktiveForm.value === 'erklaeren') {
|
||||||
|
pruefMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Keine Frage — bitte erneut.' })
|
||||||
} else {
|
} else {
|
||||||
pruefMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Keine neue Frage — bitte erneut.' })
|
pruefFehler.value = 'Keine Frage — bitte erneut.'
|
||||||
}
|
}
|
||||||
poolFuellen()
|
poolFuellen()
|
||||||
}
|
}
|
||||||
|
const frageAnfordern = naechsteFrageZeigen
|
||||||
|
|
||||||
|
// --- Quiz: leicht = Single-Select (genau 1) · schwer = Multi-Select. Deterministisch. ---
|
||||||
|
function quizToggle(i) {
|
||||||
|
const q = quizAktuell.value
|
||||||
|
if (!q || q.fertig) return
|
||||||
|
if (!q.schwer) { q.gewaehlt = q.gewaehlt.includes(i) ? [] : [i]; return }
|
||||||
|
const idx = q.gewaehlt.indexOf(i)
|
||||||
|
if (idx >= 0) q.gewaehlt.splice(idx, 1)
|
||||||
|
else q.gewaehlt.push(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function quizAntworten() {
|
||||||
|
const q = quizAktuell.value
|
||||||
|
if (!q || q.fertig || pruefLoading.value) return
|
||||||
|
pruefLoading.value = true
|
||||||
|
pruefFehler.value = ''
|
||||||
|
try {
|
||||||
|
const korrekt = q.optionen.map((o, i) => (o.korrekt ? i : -1)).filter((i) => i >= 0)
|
||||||
|
const res = await pruefeBaustein({
|
||||||
|
topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt,
|
||||||
|
provider: props.provider, aktion: 'quiz_antwort', frage: q.frage, cap: props.cap,
|
||||||
|
auswahl: q.gewaehlt, korrekt, schwer: q.schwer,
|
||||||
|
})
|
||||||
|
q.fertig = true; q.punkte = res.punkte; q.bewertung = res.bewertung; q.feedback = res.feedback
|
||||||
|
applyPruefung(res)
|
||||||
|
} catch {
|
||||||
|
pruefFehler.value = 'Hat nicht geklappt — bitte erneut.'
|
||||||
|
} finally {
|
||||||
|
pruefLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Lückentext: leicht = Begriff aus 4 (Single-Select) · schwer = frei tippen ---
|
||||||
|
function lueckToggle(i) {
|
||||||
|
const l = lueckAktuell.value
|
||||||
|
if (!l || l.fertig || l.schwer) return
|
||||||
|
l.gewaehlt = l.gewaehlt.includes(i) ? [] : [i]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lueckAntworten() {
|
||||||
|
const l = lueckAktuell.value
|
||||||
|
if (!l || l.fertig || pruefLoading.value) return
|
||||||
|
if (l.schwer ? !l.eingabe.trim() : !l.gewaehlt.length) return
|
||||||
|
pruefLoading.value = true
|
||||||
|
pruefFehler.value = ''
|
||||||
|
try {
|
||||||
|
const spez = l.schwer
|
||||||
|
? { schwer: true, loesung: l.loesung, alternativen: l.alternativen, eingabe: l.eingabe }
|
||||||
|
: { schwer: false, auswahl: l.gewaehlt, korrekt: l.optionen.map((o, i) => (o.korrekt ? i : -1)).filter((i) => i >= 0) }
|
||||||
|
const res = await pruefeBaustein({
|
||||||
|
topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt,
|
||||||
|
provider: props.provider, aktion: 'lueck_antwort', frage: l.satz, cap: props.cap, ...spez,
|
||||||
|
})
|
||||||
|
l.fertig = true; l.punkte = res.punkte; l.bewertung = res.bewertung; l.feedback = res.feedback
|
||||||
|
applyPruefung(res)
|
||||||
|
} catch {
|
||||||
|
pruefFehler.value = 'Hat nicht geklappt — bitte erneut.'
|
||||||
|
} finally {
|
||||||
|
pruefLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function nachfragen() {
|
function nachfragen() {
|
||||||
const text = pruefInput.value.trim()
|
const text = pruefInput.value.trim()
|
||||||
@@ -270,7 +413,7 @@ let letzteFeedbackMsg = null // zuletzt gezeigte Bewertungs-Bubble
|
|||||||
let pruefenRun = 0 // nur die jüngste Schnell-Bewertung darf anzeigen
|
let pruefenRun = 0 // nur die jüngste Schnell-Bewertung darf anzeigen
|
||||||
|
|
||||||
function bewertungPayload() {
|
function bewertungPayload() {
|
||||||
return { frage: aktuelleFrage.value, tier2: props.tier2, tier3: props.tier3, nachgefragt: nachgefragt.value }
|
return { frage: aktuelleFrage.value, cap: props.cap, nachgefragt: nachgefragt.value }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agent 1 (schnell): sofortiges Niveau + Punkte zeigen, dann Agent 2 (genau) im Hintergrund.
|
// Agent 1 (schnell): sofortiges Niveau + Punkte zeigen, dann Agent 2 (genau) im Hintergrund.
|
||||||
@@ -378,30 +521,81 @@ 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)
|
||||||
|
musterLaden() // GET (kein Agent) — setzt die aktive Form früh korrekt (Quiz/Lückentext/Erklären)
|
||||||
})
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('keydown', onWindowKey)
|
window.removeEventListener('keydown', onWindowKey)
|
||||||
window.removeEventListener('keydown', onPruefKey)
|
window.removeEventListener('keydown', onPruefKey)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ALT+1 Nachfragen (nur frage_offen) · ALT+2 primärer Button je Phase (idle: Frage anfordern,
|
// ALT+E: unter 30 leicht↔schwer der Form (Quiz/Lückentext); ab 30 zyklisch durch alle 5 Modi.
|
||||||
// frage_offen: Antwort abgeben, bewertet: Nächste Frage) · ALT+3 Verlauf bewerten (frage_offen).
|
function variantWechseln() {
|
||||||
// Gründlich prüfen läuft per Rechtsklick auf die Bewertung, nicht über ALT.
|
const vorher = aktiverModus.value
|
||||||
function onPruefKey(e) {
|
if (score.value >= GEMEISTERT) {
|
||||||
if (!e.altKey || e.repeat) return
|
freiModus.value = MODI[(MODI.indexOf(freiModus.value) + 1) % MODI.length]
|
||||||
if (props.mode !== 'full' || activeTab.value !== 'pruefung') return
|
} else if (aktiveForm.value === 'quiz' || aktiveForm.value === 'lueckentext') {
|
||||||
if (!['1', '2', '3'].includes(e.key)) return
|
schwerWahl.value = !schwerWahl.value
|
||||||
e.preventDefault()
|
}
|
||||||
|
if (aktiverModus.value === vorher) return // nichts geändert (z.B. Erklären-Band)
|
||||||
|
// Einfrieren + altes Widget verwerfen → frische Frage der neuen Variante anfordern.
|
||||||
|
anzeigeForm.value = null
|
||||||
|
quizAktuell.value = null
|
||||||
|
lueckAktuell.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quiz / Lückentext-Auswahl ist ein Wahl-Widget mit 1–4-Tasten; sonst Textfeld/Bubble.
|
||||||
|
const wahlWidget = computed(() => {
|
||||||
|
if (zeigeForm.value === 'quiz') return quizAktuell.value
|
||||||
|
if (zeigeForm.value === 'lueckentext' && lueckAktuell.value && !lueckAktuell.value.schwer) return lueckAktuell.value
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
// Eingabefeld der aktuellen Form (Lückentext frei / Erklären) — für „Alt fokussiert".
|
||||||
|
function eingabeFeld() {
|
||||||
|
if (zeigeForm.value === 'lueckentext' && lueckAktuell.value && lueckAktuell.value.schwer && !lueckAktuell.value.fertig) {
|
||||||
|
return document.getElementById('bp-lueck-input')
|
||||||
|
}
|
||||||
|
if (zeigeForm.value === 'erklaeren' && pruefPhase.value === 'frage_offen') return pruefInputEl.value
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// Primäre Aktion je Form: keine Frage → anfordern · offen → antworten · fertig → nächste.
|
||||||
|
function primaerAktion() {
|
||||||
if (pruefLoading.value) return
|
if (pruefLoading.value) return
|
||||||
if (e.key === '1') {
|
const f = zeigeForm.value
|
||||||
if (pruefPhase.value === 'frage_offen' && pruefInput.value.trim()) nachfragen()
|
if (f === 'quiz') { if (!quizAktuell.value) frageAnfordern(); else if (!quizAktuell.value.fertig) quizAntworten(); else naechsteFrageZeigen(); return }
|
||||||
} else if (e.key === '2') {
|
if (f === 'lueckentext') { if (!lueckAktuell.value) frageAnfordern(); else if (!lueckAktuell.value.fertig) lueckAntworten(); else naechsteFrageZeigen(); return }
|
||||||
if (pruefPhase.value === 'idle') frageAnfordern()
|
if (pruefPhase.value === 'idle') frageAnfordern()
|
||||||
else if (pruefPhase.value === 'frage_offen') { if (pruefInput.value.trim()) antwortAbgeben() }
|
else if (pruefPhase.value === 'frage_offen') { if (pruefInput.value.trim()) antwortAbgeben() }
|
||||||
else naechsteFrageZeigen()
|
else naechsteFrageZeigen()
|
||||||
} else if (e.key === '3') {
|
|
||||||
if (pruefPhase.value === 'frage_offen' && hatDiskussion.value) verlaufBewerten()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1–4 (nackt): Option im Wahl-Widget toggeln. Alt allein: ins Eingabefeld springen.
|
||||||
|
// Alt+2 primär (antworten/nächste) · Alt+Q Ansicht · Alt+E Variante · Alt+1/3 Erklären.
|
||||||
|
function onPruefKey(e) {
|
||||||
|
if (e.repeat) return
|
||||||
|
if (props.mode !== 'full' || activeTab.value !== 'pruefung') return
|
||||||
|
// Alt allein → ins Eingabefeld (Lückentext frei / Erklären), wie bei Frage-Antwort.
|
||||||
|
if (e.key === 'Alt') {
|
||||||
|
const el = eingabeFeld()
|
||||||
|
if (el) { e.preventDefault(); el.focus() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!e.altKey) {
|
||||||
|
// Nackte 1–4: Wahl-Widget toggeln — aber nicht, während ein Textfeld den Fokus hat.
|
||||||
|
if (['1', '2', '3', '4'].includes(e.key) && wahlWidget.value && !wahlWidget.value.fertig) {
|
||||||
|
const ae = document.activeElement
|
||||||
|
if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA')) return
|
||||||
|
e.preventDefault()
|
||||||
|
const i = Number(e.key) - 1
|
||||||
|
if (i < wahlWidget.value.optionen.length) (zeigeForm.value === 'quiz' ? quizToggle : lueckToggle)(i)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.code === 'KeyQ') { e.preventDefault(); emit('setAnsicht', props.ansicht === 'kompakt' ? 'erklärend' : 'kompakt'); return }
|
||||||
|
if (e.code === 'KeyE') { e.preventDefault(); variantWechseln(); return }
|
||||||
|
if (e.key === '2') { e.preventDefault(); primaerAktion(); return }
|
||||||
|
// Erklären-Sondertasten
|
||||||
|
if (e.key === '1') { e.preventDefault(); if (zeigeForm.value === 'erklaeren' && pruefPhase.value === 'frage_offen' && pruefInput.value.trim()) nachfragen(); return }
|
||||||
|
if (e.key === '3') { e.preventDefault(); if (zeigeForm.value === 'erklaeren' && pruefPhase.value === 'frage_offen' && hatDiskussion.value) verlaufBewerten() }
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -418,12 +612,10 @@ function onPruefKey(e) {
|
|||||||
</button>
|
</button>
|
||||||
<button :class="{ active: activeTab === 'pruefung' }" @click="tabKlick('pruefung')">
|
<button :class="{ active: activeTab === 'pruefung' }" @click="tabKlick('pruefung')">
|
||||||
Prüfung
|
Prüfung
|
||||||
<span v-if="st.gemeistert" class="bp-chip gold" title="Gemeistert (25/25)">✓✓✓ Max</span>
|
<span v-if="st.gemeistert" class="bp-chip gold" :title="`Gemeistert (${GEMEISTERT})`">✓✓✓ {{ score }}/{{ cap }}</span>
|
||||||
<span v-else-if="st.verstanden && tier3" class="bp-chip lila" title="Meisterpfad">✓✓ {{ st.gute_antworten }}/{{ MEISTERN }}</span>
|
<span v-else-if="st.verstanden" class="bp-chip lila" :title="`Verstanden (${VERSTANDEN})`">✓✓ {{ score }}/{{ cap }}</span>
|
||||||
<span v-else-if="st.verstanden" class="bp-chip lila" title="Verstanden (10/10)">✓✓</span>
|
<span v-else-if="st.absolviert" class="bp-chip done" :title="`Absolviert (${ABSOLVIERT})`">✓ {{ score }}/{{ cap }}</span>
|
||||||
<span v-else-if="st.absolviert && tier2" class="bp-chip done">✓ {{ st.gute_antworten }}/{{ MAX }}</span>
|
<span v-else-if="score" class="bp-chip">{{ score }}/{{ cap }}</span>
|
||||||
<span v-else-if="st.absolviert" class="bp-chip done">✓</span>
|
|
||||||
<span v-else-if="st.gute_antworten" class="bp-chip">{{ Math.min(st.gute_antworten, NOETIG) }}/{{ NOETIG }}</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -455,14 +647,92 @@ function onPruefKey(e) {
|
|||||||
<!-- Prüfung: gesteuerter Dialog -->
|
<!-- Prüfung: gesteuerter Dialog -->
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<p class="bp-hint">
|
<p class="bp-hint">
|
||||||
<template v-if="st.gemeistert">✓✓✓ Gemeistert ({{ MEISTERN }}/{{ MEISTERN }}) — Max. Du kannst dich weiter prüfen, ohne Punkte.</template>
|
<template v-if="amDeckel">{{ FORM_NAME[zeigeForm] }} · {{ score }}/{{ cap }} — Max für dieses Format. Erstelle den {{ NAECHSTES_FORMAT[cap] }}, um weiterzukommen.</template>
|
||||||
<template v-else-if="st.verstanden && tier3">Meisterpfad: {{ st.gute_antworten }}/{{ MEISTERN }}. Je besser, desto mehr (+1/+2/+3), schwach = −1 (nicht unter {{ MAX }}). Bei {{ MEISTERN }} gemeistert.</template>
|
<template v-else>{{ FORM_NAME[zeigeForm] }} · {{ score }}/{{ score >= GEMEISTERT ? VOLL : bandZiel }} · {{ modusRegel }} · <span class="bp-hint-key">Alt+E: {{ score >= GEMEISTERT ? 'Modus' : 'Schwierigkeit' }}</span></template>
|
||||||
<template v-else-if="st.verstanden">✓✓ Verstanden. Der Meisterpfad ({{ MAX }}→{{ MEISTERN }}) öffnet, sobald der ganze Guide verstanden ist.</template>
|
|
||||||
<template v-else-if="st.absolviert && tier2">Mastery: {{ st.gute_antworten }}/{{ MAX }}. Je besser, desto mehr (+1/+2/+3), schwach = −1 (nicht unter {{ NOETIG }}). Bei {{ MAX }} verstanden.</template>
|
|
||||||
<template v-else-if="st.absolviert">✓ Absolviert. Mehr ({{ NOETIG }}→{{ MAX }}) gibt's, sobald der ganze Guide absolviert ist.</template>
|
|
||||||
<template v-else>{{ Math.min(st.gute_antworten, NOETIG) }}/{{ NOETIG }} guten Antworten. Je besser deine Antwort, desto mehr Punkte (bis +3). Nachfragen ist erlaubt — dann max +1.</template>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- Am Format-Deckel: kein Widget, nur der Hinweis oben. -->
|
||||||
|
<template v-if="amDeckel"></template>
|
||||||
|
|
||||||
|
<!-- Quiz: Frage + Mehrfachauswahl -->
|
||||||
|
<template v-else-if="zeigeForm === 'quiz'">
|
||||||
|
<div v-if="!quizAktuell" class="bp-actions">
|
||||||
|
<button v-if="pruefLoading" class="bp-action cancel" title="ESC" @click="pruefAbbrechen">Abbrechen</button>
|
||||||
|
<button v-else class="bp-action primary" @click="frageAnfordern">Frage anfordern</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="bp-quiz">
|
||||||
|
<p class="bp-quiz-frage markdown" v-html="renderMarkdownInline(quizAktuell.frage)"></p>
|
||||||
|
<div class="bp-quiz-grid">
|
||||||
|
<button
|
||||||
|
v-for="(o, i) in quizAktuell.optionen" :key="i"
|
||||||
|
type="button"
|
||||||
|
class="bp-quiz-opt"
|
||||||
|
:class="{ gewaehlt: quizAktuell.gewaehlt.includes(i), richtig: quizAktuell.fertig && o.korrekt, falsch: quizAktuell.fertig && !o.korrekt && quizAktuell.gewaehlt.includes(i) }"
|
||||||
|
:disabled="quizAktuell.fertig"
|
||||||
|
:title="`${i + 1}`"
|
||||||
|
@click="quizToggle(i)"
|
||||||
|
>
|
||||||
|
<span class="markdown" v-html="renderMarkdownInline(o.text)"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="pruefFehler" class="bp-error">{{ pruefFehler }}</p>
|
||||||
|
<div class="bp-actions">
|
||||||
|
<button v-if="!quizAktuell.fertig" class="bp-action primary" title="Alt+2" :disabled="pruefLoading" @click="quizAntworten">Antworten</button>
|
||||||
|
<template v-else>
|
||||||
|
<span class="bp-niveau" :class="quizAktuell.bewertung">{{ punkteLabel(quizAktuell.punkte) }}</span>
|
||||||
|
<span class="bp-form-feedback">{{ quizAktuell.feedback }}</span>
|
||||||
|
<button class="bp-action primary" @click="naechsteFrageZeigen">Nächste</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Lückentext: Satz mit Lücke + Eingabe -->
|
||||||
|
<template v-else-if="zeigeForm === 'lueckentext'">
|
||||||
|
<div v-if="!lueckAktuell" class="bp-actions">
|
||||||
|
<button v-if="pruefLoading" class="bp-action cancel" title="ESC" @click="pruefAbbrechen">Abbrechen</button>
|
||||||
|
<button v-else class="bp-action primary" @click="frageAnfordern">Frage anfordern</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="bp-lueck">
|
||||||
|
<p class="bp-lueck-satz markdown" v-html="lueckSatzHtml(lueckAktuell.satz)"></p>
|
||||||
|
<!-- schwer: frei tippen -->
|
||||||
|
<input
|
||||||
|
v-if="lueckAktuell.schwer"
|
||||||
|
id="bp-lueck-input"
|
||||||
|
v-model="lueckAktuell.eingabe"
|
||||||
|
:disabled="lueckAktuell.fertig"
|
||||||
|
placeholder="Begriff für die Lücke…"
|
||||||
|
@keyup.enter="lueckAntworten"
|
||||||
|
/>
|
||||||
|
<p v-if="lueckAktuell.schwer && lueckAktuell.fertig && !lueckAktuell.feedback.startsWith('Richtig')" class="bp-lueck-loesung">Lösung: <span class="markdown" v-html="renderMarkdownInline(lueckAktuell.loesung)"></span></p>
|
||||||
|
<!-- leicht: Begriff aus 4 wählen -->
|
||||||
|
<div v-if="!lueckAktuell.schwer" class="bp-quiz-grid">
|
||||||
|
<button
|
||||||
|
v-for="(o, i) in lueckAktuell.optionen" :key="i"
|
||||||
|
type="button"
|
||||||
|
class="bp-quiz-opt"
|
||||||
|
:class="{ gewaehlt: lueckAktuell.gewaehlt.includes(i), richtig: lueckAktuell.fertig && o.korrekt, falsch: lueckAktuell.fertig && !o.korrekt && lueckAktuell.gewaehlt.includes(i) }"
|
||||||
|
:disabled="lueckAktuell.fertig"
|
||||||
|
:title="`${i + 1}`"
|
||||||
|
@click="lueckToggle(i)"
|
||||||
|
>
|
||||||
|
<span class="markdown" v-html="renderMarkdownInline(o.text)"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="pruefFehler" class="bp-error">{{ pruefFehler }}</p>
|
||||||
|
<div class="bp-actions">
|
||||||
|
<button v-if="!lueckAktuell.fertig" class="bp-action primary" title="Alt+2" :disabled="pruefLoading || (lueckAktuell.schwer ? !lueckAktuell.eingabe.trim() : !lueckAktuell.gewaehlt.length)" @click="lueckAntworten">Antworten</button>
|
||||||
|
<template v-else>
|
||||||
|
<span class="bp-niveau" :class="lueckAktuell.bewertung">{{ punkteLabel(lueckAktuell.punkte) }}</span>
|
||||||
|
<span class="bp-form-feedback">{{ lueckAktuell.feedback }}</span>
|
||||||
|
<button class="bp-action primary" @click="naechsteFrageZeigen">Nächste</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Erklären: gesteuerter Dialog (bestehend) -->
|
||||||
|
<template v-else>
|
||||||
<div v-if="pruefMessages.length" ref="pruefMessagesEl" class="bp-messages" @scroll="onPruefScroll">
|
<div v-if="pruefMessages.length" ref="pruefMessagesEl" class="bp-messages" @scroll="onPruefScroll">
|
||||||
<template v-for="(m, i) in pruefMessages" :key="i">
|
<template v-for="(m, i) in pruefMessages" :key="i">
|
||||||
<div v-if="m.kind === 'feedback'" class="bp-feedback" :class="m.bewertung" title="Klick: gründlich prüfen" @click="gruendlichOeffnen(m)">
|
<div v-if="m.kind === 'feedback'" class="bp-feedback" :class="m.bewertung" title="Klick: gründlich prüfen" @click="gruendlichOeffnen(m)">
|
||||||
@@ -480,13 +750,11 @@ function onPruefKey(e) {
|
|||||||
<div v-if="pruefLoading" class="bp-msg assistant bp-typing">…</div>
|
<div v-if="pruefLoading" class="bp-msg assistant bp-typing">…</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Phase idle: Frage anfordern -->
|
|
||||||
<div v-if="pruefPhase === 'idle'" class="bp-actions">
|
<div v-if="pruefPhase === 'idle'" class="bp-actions">
|
||||||
<button v-if="pruefLoading" class="bp-action cancel" title="ESC" @click="pruefAbbrechen">Abbrechen</button>
|
<button v-if="pruefLoading" class="bp-action cancel" title="ESC" @click="pruefAbbrechen">Abbrechen</button>
|
||||||
<button v-else class="bp-action primary" @click="frageAnfordern">Frage anfordern</button>
|
<button v-else class="bp-action primary" @click="frageAnfordern">Frage anfordern</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Phase frage_offen: Textfeld + Aktionen · bewertet: nur „Nächste Frage" -->
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div v-if="pruefPhase === 'frage_offen'" class="bp-input">
|
<div v-if="pruefPhase === 'frage_offen'" class="bp-input">
|
||||||
<textarea
|
<textarea
|
||||||
@@ -508,6 +776,7 @@ function onPruefKey(e) {
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -566,6 +835,7 @@ function onPruefKey(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bp-hint { font-size: 0.85rem; color: var(--text-muted); margin: 0 0 0.5rem; }
|
.bp-hint { font-size: 0.85rem; color: var(--text-muted); margin: 0 0 0.5rem; }
|
||||||
|
.bp-hint-key { font-size: 0.72rem; opacity: 0.7; white-space: nowrap; }
|
||||||
.bp-error { font-size: 0.85rem; color: var(--danger); margin: 0.5rem 0 0; }
|
.bp-error { font-size: 0.85rem; color: var(--danger); margin: 0.5rem 0 0; }
|
||||||
|
|
||||||
.bp-action {
|
.bp-action {
|
||||||
@@ -624,6 +894,35 @@ function onPruefKey(e) {
|
|||||||
.bp-feedback.neutral { background: var(--warning-soft); border-color: var(--warning-border); color: var(--warning); }
|
.bp-feedback.neutral { background: var(--warning-soft); border-color: var(--warning-border); color: var(--warning); }
|
||||||
.bp-feedback.schlecht { background: var(--danger-soft, #fee2e2); border-color: var(--danger-border, #f87171); color: var(--danger); }
|
.bp-feedback.schlecht { background: var(--danger-soft, #fee2e2); border-color: var(--danger-border, #f87171); color: var(--danger); }
|
||||||
.bp-niveau { font-weight: 700; text-transform: uppercase; font-size: 0.62rem; letter-spacing: 0.03em; margin-right: 0.4rem; opacity: 0.85; }
|
.bp-niveau { font-weight: 700; text-transform: uppercase; font-size: 0.62rem; letter-spacing: 0.03em; margin-right: 0.4rem; opacity: 0.85; }
|
||||||
|
.bp-niveau.gut { color: var(--success); }
|
||||||
|
.bp-niveau.neutral { color: var(--warning); }
|
||||||
|
.bp-niveau.schlecht { color: var(--danger); }
|
||||||
|
|
||||||
|
/* Quiz: Frage + Mehrfachauswahl */
|
||||||
|
.bp-quiz, .bp-lueck { margin-top: 0.6rem; }
|
||||||
|
.bp-quiz-frage, .bp-lueck-satz { font-size: 0.9rem; font-weight: 600; margin: 0 0 0.5rem; line-height: 1.5; }
|
||||||
|
.bp-luecke { font-weight: 700; letter-spacing: 1px; color: var(--accent); padding: 0 0.15rem; }
|
||||||
|
.bp-quiz-opt .markdown, .bp-quiz-frage.markdown, .bp-lueck-satz.markdown { display: inline; }
|
||||||
|
.bp-quiz-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-bottom: 0.5rem; }
|
||||||
|
.bp-quiz-opt {
|
||||||
|
display: flex; gap: 0.45rem; align-items: flex-start; text-align: left;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
background: var(--panel); color: var(--text); cursor: pointer; font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.bp-quiz-opt:disabled { cursor: default; }
|
||||||
|
.bp-quiz-opt.gewaehlt { border-color: var(--accent); background: var(--accent-soft, rgba(99, 102, 241, 0.12)); }
|
||||||
|
.bp-quiz-opt.richtig { background: var(--success-soft); border-color: var(--success-border); }
|
||||||
|
.bp-quiz-opt.falsch { background: var(--danger-soft, #fee2e2); border-color: var(--danger-border, #f87171); }
|
||||||
|
.bp-form-feedback { font-size: 0.82rem; color: var(--text-muted); flex: 1; }
|
||||||
|
|
||||||
|
/* Lückentext: Satz + Eingabe */
|
||||||
|
.bp-lueck input {
|
||||||
|
width: 100%; box-sizing: border-box; padding: 0.45rem 0.6rem; font: inherit; font-size: 0.88rem;
|
||||||
|
border: 1px solid var(--border); border-radius: 8px; background: var(--panel); color: var(--text);
|
||||||
|
}
|
||||||
|
.bp-lueck input:disabled { opacity: 0.7; }
|
||||||
|
.bp-lueck-loesung { font-size: 0.82rem; color: var(--success); margin: 0.35rem 0 0; }
|
||||||
/* Gründlich-prüfen-Feld (per Rechtsklick) innerhalb der Bewertungs-Bubble */
|
/* Gründlich-prüfen-Feld (per Rechtsklick) innerhalb der Bewertungs-Bubble */
|
||||||
.bp-gruendlich { display: flex; gap: 0.3rem; margin-top: 0.4rem; align-items: center; }
|
.bp-gruendlich { display: flex; gap: 0.3rem; margin-top: 0.4rem; align-items: center; }
|
||||||
.bp-gruendlich input {
|
.bp-gruendlich input {
|
||||||
|
|||||||
@@ -87,9 +87,14 @@ const fortschritt = computed(() => {
|
|||||||
return { total: bausteine.value.length, absolviert: a, verstanden: v, gemeistert: g }
|
return { total: bausteine.value.length, absolviert: a, verstanden: v, gemeistert: g }
|
||||||
})
|
})
|
||||||
|
|
||||||
// Cap pro Baustein folgt den erstellten Formaten: Guide vorhanden → bis 10, FullGuide → bis 25.
|
// Score-Deckel folgt dem höchsten erstellten Format: Mini→6, Guide→12, ProGuide→18, FullGuide→30.
|
||||||
const tier2 = computed(() => !!props.doneByFormat?.Guide)
|
const cap = computed(() => {
|
||||||
const tier3 = computed(() => !!props.doneByFormat?.FullGuide)
|
const d = props.doneByFormat || {}
|
||||||
|
if (d.FullGuide) return 30
|
||||||
|
if (d.ProGuide) return 18
|
||||||
|
if (d.Guide) return 12
|
||||||
|
return 6
|
||||||
|
})
|
||||||
|
|
||||||
// --- Chat (Mechanik in useChat; Kontext-Extraktion bleibt hier) ---
|
// --- Chat (Mechanik in useChat; Kontext-Extraktion bleibt hier) ---
|
||||||
const chat = useChat((msgs) => {
|
const chat = useChat((msgs) => {
|
||||||
@@ -205,8 +210,7 @@ function extractContext() {
|
|||||||
mode="trigger"
|
mode="trigger"
|
||||||
:baustein="s.title"
|
:baustein="s.title"
|
||||||
:status="lernstand[s.title]"
|
:status="lernstand[s.title]"
|
||||||
:tier2="tier2"
|
:cap="cap"
|
||||||
:tier3="tier3"
|
|
||||||
:topic="previewGuide.topic"
|
:topic="previewGuide.topic"
|
||||||
@open-fokus="(tab) => openFokus(s, tab)"
|
@open-fokus="(tab) => openFokus(s, tab)"
|
||||||
/>
|
/>
|
||||||
@@ -231,8 +235,7 @@ function extractContext() {
|
|||||||
:provider="provider"
|
:provider="provider"
|
||||||
:fortschritt="fortschritt"
|
:fortschritt="fortschritt"
|
||||||
:status="lernstand[fokusBaustein.title]"
|
:status="lernstand[fokusBaustein.title]"
|
||||||
:tier2="tier2"
|
:cap="cap"
|
||||||
:tier3="tier3"
|
|
||||||
:tab="fokusTab"
|
:tab="fokusTab"
|
||||||
:ansicht="ansichtModus"
|
:ansicht="ansichtModus"
|
||||||
:has-prev="fokusIndex > 0"
|
:has-prev="fokusIndex > 0"
|
||||||
|
|||||||
@@ -65,3 +65,9 @@ marked.use({
|
|||||||
export function renderMarkdown(text) {
|
export function renderMarkdown(text) {
|
||||||
return DOMPurify.sanitize(marked.parse(text || ''))
|
return DOMPurify.sanitize(marked.parse(text || ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inline-Variante (kein <p>-Wrapping) für kurze Texte wie Quiz-Optionen oder
|
||||||
|
// Lückentext-Satzteile — rendert $…$-Mathe und Markdown ohne Block-Umbruch.
|
||||||
|
export function renderMarkdownInline(text) {
|
||||||
|
return DOMPurify.sanitize(marked.parseInline(text || ''))
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,11 +15,14 @@ export function usePruefSlot(key) {
|
|||||||
phase: ref('idle'),
|
phase: ref('idle'),
|
||||||
aktuelleFrage: ref(''),
|
aktuelleFrage: ref(''),
|
||||||
letztesFeedback: ref(''),
|
letztesFeedback: ref(''),
|
||||||
pool: ref([]), // vorformulierte, unbeantwortete Fragen
|
pool: ref([]), // vorformulierte Frage-Objekte der AKTUELLEN Form ({form, ...})
|
||||||
inflight: ref(0), // laufende Pool-Generierungen (instanzübergreifend koordiniert)
|
inflight: ref(0), // laufende Pool-Generierungen (instanzübergreifend koordiniert)
|
||||||
|
poolForm: ref(''), // Form, für die der Pool gefüllt ist — bei Wechsel leeren
|
||||||
musterQuelle: ref([]), // unveränderliche Voll-Liste der Frage-Muster (leer = Fallback)
|
musterQuelle: ref([]), // unveränderliche Voll-Liste der Frage-Muster (leer = Fallback)
|
||||||
musterPool: ref([]), // Arbeitskopie: gezogene Saat ohne Zurücklegen; leer → Reset
|
musterPool: ref([]), // Arbeitskopie: gezogene Saat ohne Zurücklegen; leer → Reset
|
||||||
musterGeladen: ref(false), // Muster-Sidecar schon abgerufen?
|
musterGeladen: ref(false), // Muster-Sidecar schon abgerufen?
|
||||||
|
quizAktuell: ref(null), // laufende Quiz-Frage {frage, optionen, gewaehlt, fertig, ...}
|
||||||
|
lueckAktuell: ref(null), // laufende Lückentext-Aufgabe {satz, loesung, ..., fertig}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return store.get(key)
|
return store.get(key)
|
||||||
|
|||||||
18
templates/Prompt/Baustein-Lueckentext-Pruefung.md
Normal file
18
templates/Prompt/Baustein-Lueckentext-Pruefung.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
Du prüfst eine Lückentext-Antwort in einer Lern-Prüfung zum Thema "{topic}", Baustein "{baustein}". Der Lerner hat einen Begriff in die Lücke getippt. Entscheide, ob er fachlich passt.
|
||||||
|
|
||||||
|
SATZ MIT LÜCKE:
|
||||||
|
{satz}
|
||||||
|
|
||||||
|
ERWARTETE LÖSUNG: {loesung}
|
||||||
|
AKZEPTIERTE ALTERNATIVEN: {alternativen}
|
||||||
|
|
||||||
|
ANTWORT DES LERNERS: {eingabe}
|
||||||
|
|
||||||
|
PRÜFE:
|
||||||
|
- Trifft die Antwort denselben Begriff wie die Lösung? Synonyme, andere Schreibweise,
|
||||||
|
Tippfehler, Singular/Plural, mit/ohne Artikel → gilt als RICHTIG.
|
||||||
|
- Ein fachlich anderer Begriff oder das Gegenteil → FALSCH.
|
||||||
|
- Sei großzügig bei der Form, streng bei der Sache.
|
||||||
|
|
||||||
|
Gib NUR dieses JSON aus (kein weiterer Text):
|
||||||
|
{{"richtig": true}}
|
||||||
23
templates/Prompt/Baustein-Lueckentext.md
Normal file
23
templates/Prompt/Baustein-Lueckentext.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
Du baust eine Lückentext-Aufgabe für eine Lern-Prüfung zum Thema "{topic}", Baustein "{baustein}". Du bekommst ein Muster als Vorlage — verwandle die Kernidee in einen Satz mit genau EINER Lücke.
|
||||||
|
|
||||||
|
MUSTER (Vorlage — gleiche Kernsache, als Lückensatz):
|
||||||
|
{muster}
|
||||||
|
|
||||||
|
BAUSTEIN AUS DEM GUIDE (fachliche Referenz — der Satz darf ihr nie widersprechen):
|
||||||
|
{section_block}
|
||||||
|
|
||||||
|
KOMPAKTE FASSUNG (Merksätze, falls vorhanden):
|
||||||
|
{kompakt_block}
|
||||||
|
|
||||||
|
AUFGABE:
|
||||||
|
- Schreibe EINEN kurzen Aussagesatz (höchstens ~15 Wörter, keine Nebensatz-Kaskade).
|
||||||
|
- Ersetze den **Schlüsselbegriff** durch `___` (genau eine Lücke).
|
||||||
|
- Die Lücke prüft aktives Abrufen — sie steht für einen zentralen Fachbegriff, nicht für ein Füllwort.
|
||||||
|
- Der Satz gibt genug Kontext, dass die Lösung eindeutig ist (keine Raterei).
|
||||||
|
- Fachbegriffe/Formeln dürfen als $…$ gesetzt werden (z. B. $V(w,c)$).
|
||||||
|
- `loesung` = der erwartete Begriff (kurz, wie er in die Lücke gehört).
|
||||||
|
- `alternativen` = akzeptierte Synonyme/Schreibweisen (kann leer sein), KEINE falschen.
|
||||||
|
- Klares Deutsch, ein Satz, genau ein `___`.
|
||||||
|
|
||||||
|
Gib NUR dieses JSON aus (kein weiterer Text):
|
||||||
|
{{"satz": "Ein ___ ist eine endliche Menge von Symbolen.", "loesung": "Alphabet", "alternativen": ["Zeichenvorrat"]}}
|
||||||
25
templates/Prompt/Baustein-Lueckwahl.md
Normal file
25
templates/Prompt/Baustein-Lueckwahl.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
Du baust eine Lückentext-Aufgabe MIT AUSWAHL für eine Lern-Prüfung zum Thema "{topic}", Baustein "{baustein}". Du bekommst ein Muster als Vorlage — verwandle die Kernidee in einen Satz mit genau EINER Lücke und biete vier Begriffe zur Auswahl.
|
||||||
|
|
||||||
|
MUSTER (Vorlage — gleiche Kernsache):
|
||||||
|
{muster}
|
||||||
|
|
||||||
|
BAUSTEIN AUS DEM GUIDE (fachliche Referenz — der Satz darf ihr nie widersprechen):
|
||||||
|
{section_block}
|
||||||
|
|
||||||
|
KOMPAKTE FASSUNG (Merksätze, falls vorhanden):
|
||||||
|
{kompakt_block}
|
||||||
|
|
||||||
|
AUFGABE:
|
||||||
|
- Schreibe EINEN kurzen Aussagesatz (höchstens ~15 Wörter). Ersetze den **Schlüsselbegriff** durch `___` (genau eine Lücke).
|
||||||
|
- Biete GENAU VIER Begriffe als Optionen — **genau einer** passt in die Lücke.
|
||||||
|
- Die drei falschen sind plausibel, aber klar falsch (Verwechslung, verwandter Begriff) — keine Albernheiten.
|
||||||
|
- Optionen kurz (Begriff oder kurzer Halbsatz), ähnlich lang.
|
||||||
|
- Fachbegriffe/Formeln dürfen als $…$ gesetzt werden.
|
||||||
|
|
||||||
|
Gib NUR dieses JSON aus (kein weiterer Text):
|
||||||
|
{{"satz": "Ein ___ ist eine endliche Menge von Symbolen.", "optionen": [
|
||||||
|
{{"text": "Alphabet", "korrekt": true}},
|
||||||
|
{{"text": "Wort", "korrekt": false}},
|
||||||
|
{{"text": "Sprache", "korrekt": false}},
|
||||||
|
{{"text": "Zustand", "korrekt": false}}
|
||||||
|
]}}
|
||||||
29
templates/Prompt/Baustein-Quiz.md
Normal file
29
templates/Prompt/Baustein-Quiz.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
Du baust eine Multiple-Choice-Frage für eine Lern-Prüfung zum Thema "{topic}", Baustein "{baustein}". Du bekommst ein Muster als Vorlage — stelle dieselbe Sache als Auswahlfrage.
|
||||||
|
|
||||||
|
MUSTER (Vorlage — gleiche Kernfrage, als Multiple-Choice):
|
||||||
|
{muster}
|
||||||
|
|
||||||
|
BAUSTEIN AUS DEM GUIDE (fachliche Referenz — Optionen dürfen ihr nie widersprechen):
|
||||||
|
{section_block}
|
||||||
|
|
||||||
|
KOMPAKTE FASSUNG (Merksätze, falls vorhanden):
|
||||||
|
{kompakt_block}
|
||||||
|
|
||||||
|
AUFGABE:
|
||||||
|
- Formuliere EINE klare Frage und GENAU VIER Antwortoptionen.
|
||||||
|
- {anzahl_block}
|
||||||
|
- Jede richtige Option ist sachlich aus dem Material belegbar. Jede falsche ist plausibel,
|
||||||
|
aber klar falsch (typischer Irrtum, Verwechslung, Halbwahrheit) — keine Albernheiten.
|
||||||
|
- **Optionen SEHR KURZ:** ein Stichwort, Begriff oder Halbsatz, höchstens ~8 Wörter.
|
||||||
|
KEINE ganzen Sätze, KEIN „Weil …"-Vorspann, keine Begründungs-Prosa. Nur der Kern.
|
||||||
|
- Alle vier Optionen ähnlich lang und im gleichen Stil. Keine verräterischen Hinweise.
|
||||||
|
- Die Frage selbst knapp (1 Satz). Klares Deutsch.
|
||||||
|
- Fachbegriffe/Formeln dürfen als $…$ gesetzt werden (z. B. $V(w,c)=A(w)$).
|
||||||
|
|
||||||
|
Gib NUR dieses JSON aus (kein weiterer Text):
|
||||||
|
{{"frage": "die Frage", "optionen": [
|
||||||
|
{{"text": "Option A", "korrekt": true}},
|
||||||
|
{{"text": "Option B", "korrekt": false}},
|
||||||
|
{{"text": "Option C", "korrekt": false}},
|
||||||
|
{{"text": "Option D", "korrekt": true}}
|
||||||
|
]}}
|
||||||
Reference in New Issue
Block a user