Files
creator/backend/learning.py
2026-07-07 02:25:29 +02:00

673 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 14. 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, # 2549% → neutral
"solid": 16, # 5074%
"strong": 24, # 7599% (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)"
# Optional preposition between the conjunction and the question word — the second question
# hides behind it: "und IN welcher Datei", "und UNTER welcher Bedingung".
_PREP = r"(?:in|unter|auf|für|mit|bei|zu[rm]?|von|vom|über|durch|aus|nach|an|am|um|gegen|ohne|wobei)\s+"
_DOUBLE_RE = re.compile(
r"[,;]?\s+(?:und|sowie|außerdem|bzw\.?)\s+(?:" + _PREP + r")?" + _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. Have the concept applied to ONE minimal case — name the case in a half-sentence (no story, no roles, no backstory), then ask ONE single thing.",
"expert": "The learner is an EXPERT. Cognitive: ANALYZE. Ask for ONE distinction, comparison, or typical pitfall — a single question, no scenario prose.",
"master": "The learner is at MASTER level. Cognitive: EVALUATE/TRANSFER. Have them justify ONE decision or weigh ONE trade-off — one question, no build-up, no second sub-question.",
}
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 AI critic (the pattern is build-checked), but the
deterministic double-question guard runs and forces a regenerate on a flaw · None on error."""
try:
section_block, compact_block = _section_blocks(section, compact)
kritik_block = "(none)"
question = None
for _ in range(CRITIC_MAX_ROUNDS):
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),
kritik_block=kritik_block,
)
if data is None:
return None
question = data["question"]
flaw = _double_question_flaw(question)
if not flaw:
return question
kritik_block = _critique_block(question, [flaw])
return question # best-effort after the last round
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