658 lines
28 KiB
Python
658 lines
28 KiB
Python
"""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.
|
||
|
||
# Leitner boxes for the flashcard practice deck: roughly doubling intervals cover
|
||
# session → day → week → month. Box 1 with interval 0 = a failed card stays due in
|
||
# the running session. Absolute UTC times, no day-boundary semantics (timezone-free).
|
||
LEITNER_INTERVALS = {1: 0, 2: 1, 3: 3, 4: 7, 5: 21} # days per box
|
||
LEITNER_MAX_BOX = 5
|
||
PRACTICE_NEW_PER_SESSION = 10 # new cards offered per deck fetch
|
||
|
||
|
||
def leitner_step(box: int | None, correct: bool) -> tuple[int, int]:
|
||
"""(new box, interval in days). New card + correct → box 2; wrong → box 1 (due now);
|
||
correct → one box up, capped at LEITNER_MAX_BOX."""
|
||
if not correct:
|
||
new = 1
|
||
elif box is None:
|
||
new = 2
|
||
else:
|
||
new = min(box + 1, LEITNER_MAX_BOX)
|
||
return new, LEITNER_INTERVALS[new]
|
||
|
||
|
||
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
|
||
|
||
|