This commit is contained in:
team3
2026-06-30 00:14:18 +02:00
parent 3e3559aa8f
commit c794fcaccf
152 changed files with 9485 additions and 9583 deletions

657
backend/learning.py Normal file
View File

@@ -0,0 +1,657 @@
"""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 create_element, list_elements, get_block_hurdles
from elements import generate_element
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 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)"
_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
async def create_block_element(topic: str, block: str, section: str, provider: str = DEFAULT_PROVIDER) -> None:
"""Background task after completion: register the block as an element.
Dedup via normalized title — if an element for the block already exists,
nothing happens. Must never raise an exception to the outside.
"""
try:
existing = {_norm_title(e["title"]) for e in await list_elements(topic)}
if _norm_title(block) in existing:
return
fields = await generate_element(topic, hint=block, provider=provider, extra_context=section)
if _norm_title(fields["title"]) in existing:
return
now = datetime.now(timezone.utc).isoformat()
await create_element({"id": str(uuid.uuid4()), "topic": topic, **fields, "created_at": now, "updated_at": now})
log.info("[%s] Block registered as element: %s", topic, fields["title"])
except Exception:
log.warning("[%s] Element registration after exam failed (%s)", topic, block, exc_info=True)