"""Block learning: deep-dive, block chat and exam for individual guide sections. All calls are interactive (stdout response, lane "interactive") and stateless — the chat/exam history comes from the frontend; only the exam counter (DB) and the deep-dive (DB) are persisted. """ import logging import random import re import uuid from datetime import datetime, timezone from agents import run_agent from config import DEFAULT_PROVIDER from database import get_block_hurdles from jsonio import parse_json_text as _parse_json_text from pipeline import _prompt, _problems_schema from textkit import _norm_title log = logging.getLogger("creator.learning") # Learning levels per block — relative to the cap (floor as % of the max score): # green=beginner 20% · blue=advanced 40% · purple=expert 60% · gold=master 100%. # Exam form is always random (5 forms); the cap scales with the amount of material. LEVELS = (("beginner", 0.2), ("advanced", 0.4), ("expert", 0.6), ("master", 1.0)) POINTS_BASE = 25 # Points per subblock. Master cap = (all subs) × 25. def _levels(n_je_level: dict[int, int]) -> list[int]: return [n_je_level.get(k, 0) for k in (1, 2, 3, 4)] def thresholds(n_je_level: dict[int, int]) -> list[int]: """Cumulative sub-level thresholds [S_1, S_2, S_3, S_4] = (n_1+…+n_k) × 25. S_k is the score at which sub-level k+1 unlocks; S_4 = cap_final.""" out, acc = [], 0 for n in _levels(n_je_level): acc += n out.append(acc * POINTS_BASE) return out def cap_final(n_je_level: dict[int, int]) -> int: """Max score (master) = all subblocks × 25.""" return thresholds(n_je_level)[-1] def freie_level(score: int, n_je_level: dict[int, int]) -> int: """Highest unlocked sub-level 1–4. Level k+1 unlocks once score ≥ S_k. Empty levels (n_k=0) are skipped automatically (S_k == S_{k-1}).""" s = thresholds(n_je_level) e = 1 for k in range(3): # S_1..S_3 unlock levels 2..4 if score >= s[k]: e = k + 2 return e def cap_aktuell(score: int, n_je_level: dict[int, int]) -> int: """Reachable cap of the currently unlocked level = unlocked subs × 25.""" return thresholds(n_je_level)[freie_level(score, n_je_level) - 1] def _threshold(p: float, cap: int) -> int: return round(p * cap) def level_from_score(score: int, cap_final_value: int) -> str | None: """Highest reached learning level (None below 20%), relative to cap_final.""" reached = None for key, p in LEVELS: if score >= _threshold(p, cap_final_value): reached = key return reached def progressive_malus(basis: int, cap_akt: int) -> int: """Error penalty by progress within the current level (against cap_aktuell): ≤25%→−5 · ≤50%→−10 · ≤75%→−15 · >75%→−20.""" pct = (basis / cap_akt) if cap_akt else 0.0 if pct <= 0.25: return -5 if pct <= 0.5: return -10 if pct <= 0.75: return -15 return -20 CHAT_TIMEOUT = 240 EXAM_TIMEOUT = 120 # short JSON turns; caps the serial latency per exam step THOROUGH_TIMEOUT = 600 # "thorough check": strong model (role guide) takes longer CRITIC_MAX_ROUNDS = 2 # Generator → Critic → maybe Regenerate, at most this many times # Question types for active recall — one per question, chosen at random. Creates variety. QUESTION_TYPES = { "abruf": "Free Recall: have the learner explain the core idea freely from memory (open comprehension question).", "punkt": "Cued Recall: ask for ONE specific detail or distinction.", "warum": "Why-question: ask for the reason/mechanism — why does this work or hold?", "anwendung": "Application: have the concept applied to ONE short, new example/scenario.", "pruefen": "For code/tool topics: show a small snippet — predict the output OR find the bug. No code topic → an application question instead.", } # Answer tier → base points (new 25-scale). "barely" = −1 is only the signal for the # progressive malus (the real value comes from progressive_malus). Positive values are # modulated up on a streak and clamped to [10, 40]. TIERS = { "unanswerable": 0, # question itself broken → no change "barely": -1, # < 25% correct → malus "partial": 0, # 25–49% → neutral "solid": 16, # 50–74% "strong": 24, # 75–99% (quiz/gap hit) "complete": 30, # 100% (only reachable by free explanation) } # Order weak→strong (for the follow-up cap). _TIER_RANK = ("barely", "partial", "solid", "strong", "complete") def cap_followup(tier: str, asked_again: bool) -> str: """With a follow-up (hint received) at most "solid" — no full score by cheating.""" if asked_again and tier in ("strong", "complete"): return "solid" return tier def streak_points(basis_delta: int, streak_basis: int) -> int: """Modulate a positive base delta up by streak, clamped to [10, 40].""" factor = min(1.33, 1 + 0.066 * min(streak_basis, 5)) return max(10, min(40, round(basis_delta * factor))) def points_delta(tier: str, streak_basis: int, basis: int, cap_akt: int) -> tuple[int, int]: """Answer tier → (points delta, new streak). Positive: streak-modulated, streak +1. Neutral (0): no change, streak stays. Negative: progressive malus, streak reset to 0.""" basis_delta = TIERS.get(tier, 0) if basis_delta > 0: return streak_points(basis_delta, streak_basis), streak_basis + 1 if basis_delta == 0: return 0, streak_basis return progressive_malus(basis, cap_akt), 0 def compute_score(basis: int, delta: int, floor: int, cap_akt: int, cap_fin: int) -> int: """New score · drift-free from the base. Clamps up against `cap_akt` (cap of the currently unlocked level) and down against `floor`. Frozen ONLY at the absolute maximum (`basis ≥ cap_fin`) — otherwise it would block at every level threshold.""" if basis >= cap_fin: return basis return max(floor, min(cap_akt, basis + delta)) def floor_from_score(basis: int, cap_fin: int, s_thresholds: list[int]) -> int: """Lower bound (no fallback): highest reached learning-level threshold (over cap_final) AND highest reached level-unlock threshold S_k. max of both axes.""" floor = 0 for _, p in LEVELS: s = _threshold(p, cap_fin) if basis >= s: floor = max(floor, s) for s in s_thresholds: if basis >= s: floor = max(floor, s) return floor def _transcript(messages: list[dict]) -> str: return "\n".join( f"{'User' if m.get('role') == 'user' else 'Assistant'}: {m.get('content', '')}" for m in messages ) or "(empty)" async def block_chat(topic: str, block: str, section: str, compact: str | None, messages: list[dict], provider: str = DEFAULT_PROVIDER) -> str: try: prompt = _prompt( "Block-Chat", topic=topic, block=block, section_block=section.strip() or "(no guide version provided)", compact_block=(compact or "").strip() or "(none)", transcript=_transcript(messages), ) returncode, stdout, _ = await run_agent( "blockchat-" + str(uuid.uuid4()), prompt, CHAT_TIMEOUT, provider=provider, role="fast", capabilities="none", lane="interactive", ) if returncode != 0: return "Sorry, that didn't work. Please try again." reply = stdout.strip() return reply or "Sorry, I didn't get a response." except Exception: log.warning("[%s] Block chat failed (%s)", topic, block, exc_info=True) return "Sorry, that didn't work. Please try again." def _question_schema(data) -> dict | None: """{"question": str} · else None.""" if not isinstance(data, dict): return None question = str(data.get("question", "")).strip() return {"question": question} if question else None def _rating_schema(data) -> dict | None: """{"feedback": str, "tier": ∈ TIERS} · else None.""" if not isinstance(data, dict): return None feedback = str(data.get("feedback", "")).strip() tier = data.get("tier") if not feedback or tier not in TIERS: return None return {"feedback": feedback, "tier": tier} async def _gen_call(name: str, role: str, schema, provider: str, timeout: int = EXAM_TIMEOUT, lane: str = "interactive", **kwargs) -> dict | None: """Generator agent: fill the template, run it, parse via schema · None on error. lane="batch" for background (preloading, thorough rating) → its own slot queue.""" returncode, stdout, _ = await run_agent( name.lower() + "-" + str(uuid.uuid4()), _prompt(name, **kwargs), timeout, provider=provider, role=role, capabilities="none", lane=lane, ) return schema(_parse_json_text(stdout)) if returncode == 0 else None async def _critique_call(name: str, provider: str, role: str = "judge", timeout: int = EXAM_TIMEOUT, lane: str = "interactive", **kwargs) -> list[str]: """Critic agent (default role judge): empty list = fine. Fail-open: a critic failure must not block the turn, so it returns an empty list then as well.""" returncode, stdout, _ = await run_agent( name.lower() + "-" + str(uuid.uuid4()), _prompt(name, **kwargs), timeout, provider=provider, role=role, capabilities="none", lane=lane, ) if returncode != 0: return [] return _problems_schema(_parse_json_text(stdout)) or [] def _critique_block(prev_version: str, problems: list[str]) -> str: points = "\n".join(f"- {p}" for p in problems) return ( f"Your previous version was:\n«{prev_version}»\n\n" f"The examiner objects:\n{points}\n\nFix these points." ) def _rating_text(rating: dict) -> str: return f"Tier: {rating['tier']}\nFeedback: {rating['feedback']}" # Deterministic guard against double questions — the AI critic misses "…, and which…". _QUESTION_WORD = r"(was|welche[rsnm]?|wie|wieso|warum|wofür|wozu|wann|wo|wer|wem|wen|nenne)" _DOUBLE_RE = re.compile(r"[,;]?\s+(und|sowie|außerdem|bzw\.?)\s+" + _QUESTION_WORD + r"\b", re.IGNORECASE) def _double_question_flaw(question: str) -> str | None: """Detects two chained questions. None = ok. Flags ONLY 'und/sowie' + question word.""" if question.count("?") > 1: return "More than one question mark — ask EXACTLY ONE question." if _DOUBLE_RE.search(question): return "Two questions chained with 'und'/'sowie' — ask EXACTLY ONE question, one thing." return None async def _question_with_critique( topic: str, block: str, section_block: str, compact_block: str, transcript: str, avoid_block: str, type_block: str, fokus_block: str, tier_block: str, provider: str, ) -> str | None: """Generate a question, have the critic check it, regenerate on flaws (max CRITIC_MAX_ROUNDS).""" kritik_block = "(none)" question = None for _ in range(CRITIC_MAX_ROUNDS): data = await _gen_call( "Block-Question", "guide", _question_schema, provider, lane="batch", topic=topic, block=block, section_block=section_block, compact_block=compact_block, transcript=transcript, avoid_block=avoid_block, type_block=type_block, fokus_block=fokus_block, tier_block=tier_block, kritik_block=kritik_block, ) if data is None: return None question = data["question"] problems = await _critique_call( "Block-Question-Critique", provider, role="guide", lane="batch", # strong AI checks the rules topic=topic, block=block, section_block=section_block, compact_block=compact_block, transcript=transcript, avoid_block=avoid_block, type_block=type_block, fokus_block=fokus_block, question=question, ) hard = _double_question_flaw(question) # forces regeneration even if the AI critic missed it if hard: problems = [hard, *(problems or [])] if not problems: return question kritik_block = _critique_block(question, problems) return question # best-effort after the last round async def _rating_with_critique( topic: str, block: str, section_block: str, compact_block: str, question: str, transcript: str, reason_block: str, provider: str, role: str = "judge", ) -> dict | None: """Rate an answer (tier), have the critic check it, redo on misjudgment. `question` anchors the checked question; the dialog (transcript) provides answer + discussion. `reason_block` = optional learner dissatisfaction (only for "thorough check"). `role` = "judge" (fast) or "guide" (thorough, strong model with thinking). """ timeout = THOROUGH_TIMEOUT if role == "guide" else EXAM_TIMEOUT # Thorough (role guide) = user is waiting → interactive. Background-thorough (judge) → batch. lane = "interactive" if role == "guide" else "batch" kritik_block = "(none)" rating = None for _ in range(CRITIC_MAX_ROUNDS): rating = await _gen_call( "Block-Rating", role, _rating_schema, provider, timeout, lane=lane, topic=topic, block=block, section_block=section_block, compact_block=compact_block, question=question, transcript=transcript, reason_block=reason_block, kritik_block=kritik_block, ) if rating is None: return None problems = await _critique_call( "Block-Rating-Critique", provider, role=role, timeout=timeout, lane=lane, topic=topic, block=block, section_block=section_block, compact_block=compact_block, question=question, transcript=transcript, rating_block=_rating_text(rating), ) if not problems: return rating kritik_block = _critique_block(_rating_text(rating), problems) return rating # best-effort after the last round def _section_blocks(section: str, compact: str | None) -> tuple[str, str]: return ( section.strip() or "(no guide version provided)", (compact or "").strip() or "(none)", ) def _avoid_block(avoid: list[str] | None) -> str: entries = [f.strip() for f in (avoid or []) if f and f.strip()] return "\n".join(f"- {f}" for f in entries) or "(none)" # Learner tier (derived from the score) → addressee role for the question. This is how the # difficulty arises: not "make it extra hard", but "ask questions for a beginner/expert". # Per level: addressee role + cognitive demand (Bloom) + "ask like this" cue. Without explicit levels # the model takes the easy path (mere recall) — the cues lift higher tiers to apply/analyze/transfer. TIER_ROLE = { "beginner": "The learner is a BEGINNER. Cognitive: REMEMBER/UNDERSTAND. Ask about the basic understanding — the core concept, simple and direct.", "advanced": "The learner is ADVANCED. Cognitive: APPLY. Pose a small concrete situation and have the concept applied to it — don't just ask for the definition.", "expert": "The learner is an EXPERT. Cognitive: ANALYZE. Have them distinguish/compare, classify a special case or uncover a typical pitfall (hurdle) — don't quiz textbook knowledge.", "master": "The learner is at MASTER level. Cognitive: EVALUATE/TRANSFER. Have the concept transferred to a NEW problem, justify a decision or weigh a trade-off.", } def _tier_block(tier: str | None) -> str: return TIER_ROLE.get(tier or "", TIER_ROLE["beginner"]) async def exam_question( topic: str, block: str, section: str, compact: str | None, messages: list[dict], subblocks: list[str] | None = None, avoid: list[str] | None = None, tier: str = "beginner", provider: str = DEFAULT_PROVIDER, ) -> str | None: """Action 'question': generate a question — random type for a random subblock, in the addressee role of the tier, then critic (sequential) · None on error.""" try: section_block, compact_block = _section_blocks(section, compact) transcript = _transcript(messages) if messages else "(empty)" type_block = QUESTION_TYPES[random.choice(list(QUESTION_TYPES))] subs = [s for s in (subblocks or []) if s and s.strip()] focus = random.choice(subs) if subs else "" fokus_block = ( f"Focus the question on this subblock: „{focus}\"" if focus else "(whole block — no specific subblock)" ) return await _question_with_critique( topic, block, section_block, compact_block, transcript, _avoid_block(avoid), type_block, fokus_block, _tier_block(tier), provider, ) except Exception: log.warning("[%s] Question failed (%s)", topic, block, exc_info=True) return None async def exam_question_variant( topic: str, block: str, section: str, compact: str | None, pattern: str, tier: str = "beginner", provider: str = DEFAULT_PROVIDER, ) -> str | None: """Action 'question' with a pattern: from a predefined pattern, phrase a concrete question in the addressee role of the tier. No critic (the pattern is build-checked). The style guard stays as a cheap protection against double questions · None on error.""" try: section_block, compact_block = _section_blocks(section, compact) data = await _gen_call( "Block-Question-Variante", "guide", _question_schema, provider, lane="batch", topic=topic, block=block, section_block=section_block, compact_block=compact_block, pattern=pattern, tier_block=_tier_block(tier), ) if data is None: return None return data["question"] except Exception: log.warning("[%s] Question variant failed (%s)", topic, block, exc_info=True) return None def _options_schema(opts) -> list[dict] | None: """[{text, correct}]×4 → validated list · else 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() correct = o.get("correct") if not text or not isinstance(correct, bool): return None out.append({"text": text, "correct": correct}) return out def _quiz_schema(data) -> dict | None: """{"question": str, "options": [{text, correct}]×4} → validated · else None. Single choice: exactly 1 correct. The difficulty is in the tier, not in the count.""" if not isinstance(data, dict): return None question = str(data.get("question", "")).strip() out = _options_schema(data.get("options")) if not question or out is None: return None if sum(o["correct"] for o in out) != 1: return None return {"question": question, "options": out} def _gapchoice_schema(data) -> dict | None: """{"sentence": str (with ___), "options": [{text, correct}]×4} → exactly 1 correct · else None.""" if not isinstance(data, dict): return None sentence = str(data.get("sentence", "")).strip() out = _options_schema(data.get("options")) if not sentence or "___" not in sentence or out is None or sum(o["correct"] for o in out) != 1: return None return {"sentence": sentence, "options": out} async def hurdles_distractor_block(topic: str, block: str) -> str: """Typical misconceptions (facts hurdles) of the block as a distractor source for quiz/gap choice. Empty if none exist (legacy) → the prompt placeholder disappears without a trace.""" try: hurdles = await get_block_hurdles(topic, _norm_title(block)) except Exception: return "" if not hurdles: return "" lines = "\n".join(f"- {h}" for h in hurdles[:8]) return ("TYPICAL MISCONCEPTIONS for this block (use them as distractors when they fit the question):\n" + lines + "\n") async def generate_quiz( topic: str, block: str, section: str, compact: str | None, pattern: str, tier: str = "beginner", provider: str = DEFAULT_PROVIDER, distractor_block: str = "", ) -> dict | None: """From a pattern, a single-choice question (exactly 1 correct), at the tier's level. Strong model (role guide) for correct flags. → {question, options} · None on error. distractor_block: optional typical misconceptions (from the facts hurdles) as a distractor source.""" try: section_block, compact_block = _section_blocks(section, compact) return await _gen_call( "Block-Quiz", "guide", _quiz_schema, provider, lane="batch", topic=topic, block=block, section_block=section_block, compact_block=compact_block, pattern=pattern, tier_block=_tier_block(tier), distractor_block=distractor_block, ) except Exception: log.warning("[%s] Quiz question failed (%s)", topic, block, exc_info=True) return None async def generate_gapchoice( topic: str, block: str, section: str, compact: str | None, pattern: str, tier: str = "beginner", provider: str = DEFAULT_PROVIDER, distractor_block: str = "", ) -> dict | None: """Gap text with choices: sentence with ___ + 4 terms, exactly 1 correct — at the tier's level. → {sentence, options:[{text,correct}]} · None on error. distractor_block: optional typical misconceptions (from the facts hurdles) as a distractor source.""" try: section_block, compact_block = _section_blocks(section, compact) return await _gen_call( "Block-Gapchoice", "guide", _gapchoice_schema, provider, lane="batch", topic=topic, block=block, section_block=section_block, compact_block=compact_block, pattern=pattern, tier_block=_tier_block(tier), distractor_block=distractor_block, ) except Exception: log.warning("[%s] Gap-text choice failed (%s)", topic, block, exc_info=True) return None def _gap_schema(data) -> dict | None: """{"sentence": str (with ___), "solution": str, "alternatives": [str]} → validated · else None.""" if not isinstance(data, dict): return None sentence = str(data.get("sentence", "")).strip() solution = str(data.get("solution", "")).strip() alt = data.get("alternatives", []) if not sentence or "___" not in sentence or not solution: return None alternatives = [str(a).strip() for a in alt if isinstance(a, str) and str(a).strip()] if isinstance(alt, list) else [] return {"sentence": sentence, "solution": solution, "alternatives": alternatives} async def generate_gaptext( topic: str, block: str, section: str, compact: str | None, pattern: str, tier: str = "beginner", provider: str = DEFAULT_PROVIDER, ) -> dict | None: """From a pattern, a gap-text task (sentence with ___, solution, synonyms), at the tier's level. → {sentence, solution, alternatives} · None on error.""" try: section_block, compact_block = _section_blocks(section, compact) return await _gen_call( "Block-Gaptext", "guide", _gap_schema, provider, lane="batch", topic=topic, block=block, section_block=section_block, compact_block=compact_block, pattern=pattern, tier_block=_tier_block(tier), ) except Exception: log.warning("[%s] Gap-text question failed (%s)", topic, block, exc_info=True) return None def _norm_term(t: str) -> str: return re.sub(r"[^\wäöüß]", "", str(t or "").lower()) def _correct_schema(data) -> dict | None: if not isinstance(data, dict) or not isinstance(data.get("correct"), bool): return None return {"correct": data["correct"]} async def check_gaptext( topic: str, block: str, sentence: str, solution: str, alternatives: list[str], input: str, provider: str = DEFAULT_PROVIDER, ) -> bool: """Check a gap-text answer: first a normalized comparison (solution + synonyms), otherwise 1 AI call for synonym tolerance. Fail-open to CORRECT only on an exact match.""" if not input.strip(): return False norm = _norm_term(input) if norm and norm in {_norm_term(solution), *(_norm_term(a) for a in alternatives)}: return True data = await _gen_call( "Block-Gaptext-Exam", "fast", _correct_schema, provider, topic=topic, block=block, sentence=sentence, solution=solution, alternatives=", ".join(alternatives) or "(none)", input=input, ) return bool(data and data["correct"]) async def exam_rating_fast( topic: str, block: str, section: str, compact: str | None, question: str, messages: list[dict], provider: str = DEFAULT_PROVIDER, ) -> dict | None: """Action 'answer' (Agent 1, fast): evaluator only, no critic. → {feedback, tier}.""" try: section_block, compact_block = _section_blocks(section, compact) transcript = _transcript(messages) if messages else "(empty)" return await _gen_call( "Block-Rating", "judge", _rating_schema, provider, topic=topic, block=block, section_block=section_block, compact_block=compact_block, question=question.strip() or "(no question provided)", transcript=transcript, reason_block="(none)", kritik_block="(none)", ) except Exception: log.warning("[%s] Fast rating failed (%s)", topic, block, exc_info=True) return None async def exam_rating( topic: str, block: str, section: str, compact: str | None, question: str, messages: list[dict], provider: str = DEFAULT_PROVIDER, role: str = "judge", reason: str = "", ) -> dict | None: """Action 'answer_check' (Agent 2, thorough): evaluator + critic. → {feedback, tier}. `role` = "guide" for "thorough check" (strong model). `reason` = optional learner dissatisfaction with an earlier rating. """ try: section_block, compact_block = _section_blocks(section, compact) transcript = _transcript(messages) if messages else "(empty)" reason_block = reason.strip() or "(none)" return await _rating_with_critique( topic, block, section_block, compact_block, question.strip() or "(no question provided)", transcript, reason_block, provider, role, ) except Exception: log.warning("[%s] Rating failed (%s)", topic, block, exc_info=True) return None async def block_discussion( topic: str, block: str, section: str, compact: str | None, question: str, last_rating: str | None, messages: list[dict], provider: str = DEFAULT_PROVIDER, ) -> str | None: """Action 'discussion': tutor explains/discusses the question or a rating. No rating, no critic — here the human is the examiner. None on error. """ try: section_block, compact_block = _section_blocks(section, compact) prompt = _prompt( "Block-Exam-Discussion", topic=topic, block=block, section_block=section_block, compact_block=compact_block, question=question.strip() or "(no question provided)", last_rating_block=(last_rating or "").strip() or "(none yet)", transcript=_transcript(messages) if messages else "(empty)", ) returncode, stdout, _ = await run_agent( "examdiscussion-" + str(uuid.uuid4()), prompt, CHAT_TIMEOUT, provider=provider, role="fast", capabilities="none", lane="interactive", ) if returncode != 0: return None return stdout.strip() or None except Exception: log.warning("[%s] Exam discussion failed (%s)", topic, block, exc_info=True) return None