update
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"""Provider-Schicht: führt Agent-Aufrufe über die Claude-CLI oder OpenCode (MiniMax) aus.
|
||||
"""Provider layer: runs agent calls via the Claude CLI or OpenCode (MiniMax).
|
||||
|
||||
Beide Runner sind unabhängig. Fehlt ein Binary/Key, schlägt nur der
|
||||
jeweilige Provider fehl — der andere läuft unverändert weiter.
|
||||
Both runners are independent. If a binary/key is missing, only the
|
||||
respective provider fails — the other keeps running unchanged.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -21,9 +21,9 @@ log = logging.getLogger("creator.agents")
|
||||
|
||||
_active_processes: dict[str, asyncio.subprocess.Process] = {}
|
||||
|
||||
# Abgebrochene Scopes (Schlüssel-Präfixe, symmetrisch zu kill_process). Ein Agent, dessen
|
||||
# Key mit einem dieser Präfixe beginnt, bricht VOR dem Spawn ab — so werden auch in der
|
||||
# Semaphore-Schlange WARTENDE Agenten beim Abbruch sofort gestoppt, statt noch zu starten.
|
||||
# Cancelled scopes (key prefixes, symmetric to kill_process). An agent whose
|
||||
# key starts with one of these prefixes aborts BEFORE the spawn — so agents WAITING
|
||||
# in the semaphore queue are also stopped immediately on abort instead of still starting.
|
||||
_cancelled_prefixes: set[str] = set()
|
||||
|
||||
|
||||
@@ -38,15 +38,15 @@ def clear_scope(prefix: str) -> None:
|
||||
def _scope_cancelled(agent_key: str) -> bool:
|
||||
return any(agent_key.startswith(p) for p in _cancelled_prefixes)
|
||||
|
||||
# Deckelt die realen CLI-Prozesse — unabhängig von der Pipeline-Semaphore in
|
||||
# generator.py. Acquire passiert VOR dem Spawn, damit Wartezeit in der Queue
|
||||
# nicht gegen den Agent-Timeout zählt.
|
||||
# Caps the real CLI processes — independent of the pipeline semaphore in
|
||||
# generator.py. The acquire happens BEFORE the spawn so that queue wait time
|
||||
# does not count against the agent timeout.
|
||||
_batch_sem = asyncio.Semaphore(MAX_CONCURRENT_AGENTS)
|
||||
_interactive_sem = asyncio.Semaphore(MAX_CONCURRENT_INTERACTIVE)
|
||||
|
||||
# OpenCode-Starts serialisieren: gleichzeitig startende Prozesse kollidieren an
|
||||
# der internen Session-DB ("database is locked", Exit nach <1s). Der kurze
|
||||
# Versatz entzerrt die Starts; danach laufen die Prozesse normal parallel.
|
||||
# Serialize OpenCode starts: processes starting simultaneously collide on the
|
||||
# internal session DB ("database is locked", exit after <1s). The short
|
||||
# stagger spreads out the starts; afterwards the processes run in parallel normally.
|
||||
_opencode_start_lock = asyncio.Lock()
|
||||
_OPENCODE_START_DELAY = 1.0
|
||||
|
||||
@@ -58,7 +58,7 @@ _CLAUDE_TOOLS = {
|
||||
"none": None,
|
||||
}
|
||||
|
||||
# Capability → OpenCode-Agent (Tool-Rechte in dev-ops/opencode.json definiert)
|
||||
# Capability → OpenCode agent (tool permissions defined in dev-ops/opencode.json)
|
||||
_OPENCODE_AGENTS = {
|
||||
"full": "full",
|
||||
"files": "files",
|
||||
@@ -86,8 +86,8 @@ def provider_available(provider: str) -> bool:
|
||||
|
||||
|
||||
def _kill(process) -> None:
|
||||
"""Killt den Agenten samt Kindprozessen über die Prozess-Gruppe (sonst überleben die
|
||||
von der CLI gestarteten Kinder, halten die Pipes offen und blockieren communicate())."""
|
||||
"""Kill the agent and its child processes via the process group (otherwise the
|
||||
children spawned by the CLI survive, keep the pipes open and block communicate())."""
|
||||
try:
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
@@ -98,9 +98,9 @@ def _kill(process) -> None:
|
||||
|
||||
|
||||
def kill_process(agent_key_prefix: str) -> None:
|
||||
"""Killt alle aktiven Prozesse, deren Key mit dem Prefix beginnt (deckt -plan/-w1… ab)."""
|
||||
"""Kill all active processes whose key starts with the prefix (covers -plan/-w1…)."""
|
||||
for key, process in list(_active_processes.items()):
|
||||
if process.returncode is not None: # tote Einträge beim Iterieren aufräumen
|
||||
if process.returncode is not None: # clean up dead entries while iterating
|
||||
_active_processes.pop(key, None)
|
||||
continue
|
||||
if key.startswith(agent_key_prefix):
|
||||
@@ -117,16 +117,16 @@ async def run_agent(
|
||||
capabilities: str = "none",
|
||||
lane: str = "batch",
|
||||
) -> tuple[int, str, str]:
|
||||
if _scope_cancelled(agent_key): # vor dem Anstehen: gar nicht erst in die Schlange
|
||||
return 1, "", "abgebrochen"
|
||||
if _scope_cancelled(agent_key): # before queueing: don't even enter the queue
|
||||
return 1, "", "cancelled"
|
||||
if provider not in PROVIDERS:
|
||||
return 1, "", f"Unbekannter Provider: {provider}"
|
||||
return 1, "", f"Unknown provider: {provider}"
|
||||
if shutil.which(PROVIDERS[provider]["cli"]) is None:
|
||||
return 1, "", f"CLI '{PROVIDERS[provider]['cli']}' nicht installiert (Provider: {provider})"
|
||||
return 1, "", f"CLI '{PROVIDERS[provider]['cli']}' not installed (provider: {provider})"
|
||||
sem = _interactive_sem if lane == "interactive" else _batch_sem
|
||||
async with sem:
|
||||
if _scope_cancelled(agent_key): # nach dem Acquire: in der Schlange abgebrochen → kein Spawn
|
||||
return 1, "", "abgebrochen"
|
||||
if _scope_cancelled(agent_key): # after the acquire: cancelled in the queue → no spawn
|
||||
return 1, "", "cancelled"
|
||||
if PROVIDERS[provider]["cli"] == "opencode":
|
||||
return await _run_opencode(agent_key, prompt, timeout, provider, role, capabilities)
|
||||
return await _run_claude_cli(agent_key, prompt, timeout, role, capabilities)
|
||||
@@ -141,7 +141,7 @@ async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None,
|
||||
stdin=asyncio.subprocess.PIPE if stdin_data is not None else asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
start_new_session=True, # eigene Prozess-Gruppe → killpg killt auch Kindprozesse
|
||||
start_new_session=True, # own process group → killpg also kills child processes
|
||||
)
|
||||
|
||||
if stagger:
|
||||
@@ -163,16 +163,16 @@ async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None,
|
||||
await asyncio.wait_for(process.wait(), timeout=5)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
log.info("agent %s: Timeout nach %ds", agent_key, timeout)
|
||||
log.info("agent %s: timeout after %ds", agent_key, timeout)
|
||||
raise
|
||||
log.info(
|
||||
"agent %s: exit %s nach %.1fs (%d Bytes stdout)",
|
||||
"agent %s: exit %s after %.1fs (%d bytes stdout)",
|
||||
agent_key, process.returncode, time.monotonic() - start, len(stdout),
|
||||
)
|
||||
return process.returncode, stdout.decode("utf-8", errors="replace"), stderr.decode("utf-8", errors="replace")
|
||||
finally:
|
||||
# Pop nur bei Identität: ein Slot-Restart unter demselben Key darf den
|
||||
# NEUEN Prozess nicht aus dem Tracking werfen.
|
||||
# Pop only on identity: a slot restart under the same key must not evict
|
||||
# the NEW process from tracking.
|
||||
if _active_processes.get(agent_key) is process:
|
||||
del _active_processes[agent_key]
|
||||
|
||||
@@ -189,12 +189,12 @@ async def _run_claude_cli(agent_key: str, prompt: str, timeout: int, role: str,
|
||||
|
||||
async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str, role: str, capabilities: str) -> tuple[int, str, str]:
|
||||
cfg = PROVIDERS[provider]
|
||||
# Prompt über Tempdatei statt argv (ARG_MAX-Schutz bei großen Projekt-Prompts)
|
||||
# Prompt via temp file instead of argv (ARG_MAX protection for large project prompts)
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8", dir=tempfile.gettempdir()) as f:
|
||||
f.write(prompt)
|
||||
prompt_path = Path(f.name)
|
||||
# Positional-Message MUSS vor -f stehen: -f ist ein Array-Flag und
|
||||
# frisst sonst den Text als zweiten Dateinamen ("File not found").
|
||||
# The positional message MUST come before -f: -f is an array flag and
|
||||
# would otherwise eat the text as a second file name ("File not found").
|
||||
cmd = [
|
||||
cfg["cli"], "run",
|
||||
"Folge exakt den Anweisungen in der angehängten Datei. Sie sind der vollständige Auftrag.",
|
||||
@@ -214,7 +214,7 @@ _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
||||
|
||||
|
||||
def _clean_opencode_output(text: str) -> str:
|
||||
"""Entfernt ANSI-Codes und den führenden Banner ("> agent · modell")."""
|
||||
"""Strip ANSI codes and the leading banner ("> agent · model")."""
|
||||
text = _ANSI_RE.sub("", text)
|
||||
lines = text.splitlines()
|
||||
while lines and (not lines[0].strip() or lines[0].lstrip().startswith(">")):
|
||||
|
||||
3330
backend/bausteine.py
3330
backend/bausteine.py
File diff suppressed because it is too large
Load Diff
3326
backend/blocks.py
Normal file
3326
backend/blocks.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,122 +10,123 @@ UNI_DIR = PROJECT_ROOT / "uni"
|
||||
|
||||
MAX_CONCURRENT_GENERATIONS = 10
|
||||
|
||||
# Lesbarkeits-Gate: deterministischer Prüfer (kleines deutsches Komplexitäts-Modell,
|
||||
# Skala 1–7). Zu schwere Sections gehen in die Lese-Prüfungs-Überarbeitung.
|
||||
# Fehlen transformers/torch oder das Modell → Gate stumm aus.
|
||||
LESBARKEIT_AKTIV = True
|
||||
LESBARKEIT_MODELL = "MiriUll/distilbert-german-text-complexity"
|
||||
# Anker auf der 1–7-Skala (TextComplexityDE): Leichte Sprache ~1,2; Wikipedia-Schnitt
|
||||
# ~3,22; ab MOS > 4 gilt ein Satz als „echt komplex" (Vereinfachungs-Grenze des Papers).
|
||||
LESBARKEIT_MAX = 3.5 # Section zu schwer, wenn der Satz-Schnitt darüber liegt
|
||||
LESBARKEIT_HART = 4.0 # Einzelsatz ab hier „hart"
|
||||
LESBARKEIT_HART_ANTEIL = 0.30 # … ODER wenn dieser Anteil der Sätze hart ist
|
||||
# Readability gate: deterministic checker (small German complexity model,
|
||||
# scale 1–7). Sections that are too hard go into the read-exam revision.
|
||||
# If transformers/torch or the model are missing → gate silently off.
|
||||
READABILITY_ACTIVE = True
|
||||
READABILITY_MODEL = "MiriUll/distilbert-german-text-complexity"
|
||||
# Anchors on the 1–7 scale (TextComplexityDE): plain language ~1.2; Wikipedia average
|
||||
# ~3.22; from MOS > 4 a sentence counts as "truly complex" (the paper's simplification cutoff).
|
||||
READABILITY_MAX = 3.5 # section too hard when the sentence average is above this
|
||||
READABILITY_HARD = 4.0 # an individual sentence is "hard" from here on
|
||||
READABILITY_HARD_SHARE = 0.30 # … OR when this share of sentences is hard
|
||||
|
||||
# Bausteine-Konsolidierung: semantisches Embedding-Clustering statt LLM-Listen-Merge.
|
||||
# Ein kleines mehrsprachiges Satz-Embedding (mean-pool) bildet die Kandidaten-Cluster
|
||||
# GLOBAL (kein Chunk-Verlust) per Cosine + Union-Find. Titel-Varianten desselben Konzepts
|
||||
# ("Vertex Cover" / "Vertex Cover Definition") verschmelzen; der Konsens zählt danach die
|
||||
# echten Reader pro Cluster (≥2 = Konsens). Fehlen transformers/torch oder lädt das Modell
|
||||
# nicht → Embedding stumm aus, `_konsolidiere` fällt auf den alten Panel-Judge-Pfad zurück.
|
||||
# Block consolidation: semantic embedding clustering instead of an LLM list merge.
|
||||
# A small multilingual sentence embedding (mean-pool) builds the candidate clusters
|
||||
# GLOBALLY (no chunk loss) via cosine + union-find. Title variants of the same concept
|
||||
# ("Vertex Cover" / "Vertex Cover Definition") merge; the consensus then counts the
|
||||
# real readers per cluster (≥2 = consensus). If transformers/torch are missing or the model
|
||||
# won't load → embedding silently off, `_consolidate` falls back to the old panel-judge path.
|
||||
EMBEDDING_AKTIV = True
|
||||
EMBEDDING_MODELL = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2" # CPU, mehrsprachig, ~470 MB
|
||||
# Stärkere (größere) CPU-Alternative bei Bedarf: "BAAI/bge-m3".
|
||||
# Konsolidierung = zweistufig: (1) Embedding bildet GROBE Ähnlichkeits-Blocks (High-Recall),
|
||||
# (2) ein LLM-Judge gruppiert JEDEN Block in die echten Bausteine (merge Paraphrasen, split
|
||||
# Über-Merges). Reines Threshold-Blocking erzeugt einen Giant-Component (alles verkettet) →
|
||||
# darum „Capped-Blocking": greedy nach Cosine mergen, aber Blockgröße deckeln. So bleiben die
|
||||
# LLM-Listen kurz und stabil (belegt: Embedding-Block + LLM-Judge ≈ 95 % Precision).
|
||||
EMBEDDING_BLOCK_FLOOR = 0.5 # Mindest-Cosine, damit zwei Kandidaten in EINEN Block dürfen
|
||||
EMBEDDING_BLOCK_CAP = 25 # max. Titel je Block (LLM-Liste kurz/stabil halten)
|
||||
# Subbaustein-Dedup: rein deterministisch (kein LLM). Subbausteine sind kurze Aussagen IM SELBEN
|
||||
# Baustein-Kontext — ab dieser Cosine sind zwei dieselbe Aussage (an aak geprüft: ≥0,88 ausnahmslos
|
||||
# echte Dubletten). Konservativ 0,90, damit verschiedene Aspekte (∈NP ≠ NP-schwer) getrennt bleiben.
|
||||
EMBEDDING_MODELL = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2" # CPU, multilingual, ~470 MB
|
||||
# Stronger (larger) CPU alternative if needed: "BAAI/bge-m3".
|
||||
# Consolidation = two-stage: (1) the embedding builds COARSE similarity blocks (high recall),
|
||||
# (2) an LLM judge groups EACH block into the real blocks (merge paraphrases, split
|
||||
# over-merges). Pure threshold blocking creates a giant component (everything chained) →
|
||||
# hence "capped blocking": greedily merge by cosine, but cap the block size. This keeps the
|
||||
# LLM lists short and stable (evidenced: embedding block + LLM judge ≈ 95% precision).
|
||||
EMBEDDING_BLOCK_FLOOR = 0.5 # minimum cosine for two candidates to share ONE block
|
||||
EMBEDDING_BLOCK_CAP = 25 # max. titles per block (keep the LLM list short/stable)
|
||||
# Subblock dedup: purely deterministic (no LLM). Subblocks are short statements IN THE SAME
|
||||
# block context — from this cosine on two are the same statement (checked on aak: ≥0.88 are
|
||||
# without exception true duplicates). Conservative 0.90 so different aspects (∈NP ≠ NP-hard) stay separate.
|
||||
EMBEDDING_SUB_DUP = 0.90
|
||||
|
||||
# Deckel für gleichzeitige CLI-Agenten-Prozesse (über alle Generierungen hinweg).
|
||||
# Eigene Spur für interaktive Aufrufe (Chat, Elemente), damit sie nicht hinter
|
||||
# laufenden Writern in der Warteschlange hängen.
|
||||
# Cap for concurrent CLI agent processes (across all generations).
|
||||
# Own lane for interactive calls (chat, elements) so they don't hang behind
|
||||
# running writers in the queue.
|
||||
MAX_CONCURRENT_AGENTS = 10
|
||||
MAX_CONCURRENT_INTERACTIVE = 8
|
||||
|
||||
# Grace-Fenster der Konsens-Races (Bausteine, Guide, OnePager): Nach dem ersten
|
||||
# gültigen Ergebnis dürfen die übrigen Agenten noch so viele Sekunden fertig
|
||||
# werden (Kill nur, wenn das Minimum schon steht).
|
||||
KONSENS_GRACE = 300
|
||||
# Grace window of the consensus races (blocks, guide, OnePager): after the first
|
||||
# valid result the remaining agents may still become done for this many seconds
|
||||
# (kill only once the minimum is already in).
|
||||
CONSENSUS_GRACE = 300
|
||||
|
||||
# Recherche-Race: längeres Grace-Fenster. Recherche treibt die ganze Bausteine-Anzahl;
|
||||
# bei langsamen Providern (z.B. MiniMax) sollen ALLE 5 Agenten fertig werden, nicht nur
|
||||
# das Quorum von 3. Pro-Agent-Timeout (TIMEOUTS["recherche"]=1800s) deckelt echte Hänger.
|
||||
RECHERCHE_GRACE = 900
|
||||
# Research race: longer grace window. Research drives the whole block count;
|
||||
# with slow providers (e.g. MiniMax) ALL 5 agents should become done, not just
|
||||
# the quorum of 3. The per-agent timeout (TIMEOUTS["research"]=1800s) caps real hangs.
|
||||
RESEARCH_GRACE = 900
|
||||
|
||||
# Cap der Klärungs- und Prüf-Loops: maximale Runden, bis alles entschieden sein
|
||||
# muss. In der letzten Runde MUSS der Mapping-Agent jeden Eintrag entscheiden;
|
||||
# Prüf-Loops lassen Rest-Beanstandungen danach stehen.
|
||||
KONSENS_MAX_RUNDEN = 3
|
||||
# Cap of the clarification and check loops: maximum rounds until everything must be
|
||||
# decided. In the last round the mapping agent MUST decide every entry;
|
||||
# check loops leave any remaining objections standing after that.
|
||||
CONSENSUS_MAX_ROUNDS = 3
|
||||
|
||||
# Crawler-Sichtung (Content/Noise) — deterministischer Regel-Filter statt LLM.
|
||||
# Match: Substring (klein) gegen URL UND Dateiname. Reihenfolge: keep > noise > min_chars > behalten.
|
||||
# Sonderregeln einfach hier ergänzen.
|
||||
CRAWL_KEEP_PATTERNS = ["learn-unit", "learn-course"] # immer Content
|
||||
CRAWL_NOISE_PATTERNS = [ # eindeutig themenfremd → raus
|
||||
# Crawler triage (content/noise) — deterministic rule filter instead of an LLM.
|
||||
# Match: substring (lowercase) against URL AND file name. Order: keep > noise > min_chars > keep.
|
||||
# Just add special rules here.
|
||||
CRAWL_KEEP_PATTERNS = ["learn-unit", "learn-course"] # always content
|
||||
CRAWL_NOISE_PATTERNS = [ # clearly off-topic → out
|
||||
"clubs", "events", "podcasts", "resources", "-u-",
|
||||
"academy", "pricing", "/plans", "career", "newsletter", "impressum", "login",
|
||||
]
|
||||
CRAWL_MIN_CHARS = 400 # zu wenig Text → raus
|
||||
CRAWL_MIN_CHARS = 400 # too little text → out
|
||||
|
||||
# LLM-Themen-Relevanz-Gate (nach dem Regel-Filter): je Content-Seite ja/nein gegen die Spec.
|
||||
# Trennt das Fachgebiet (z.B. Backend vs Frontend), was die globalen CRAWL_*-Regeln nicht können.
|
||||
QUELLE_RELEVANZ_CHUNK = 12 # Seiten je Rater-Paket (klein, da je Seite ein Snippet mitgeht)
|
||||
QUELLE_RELEVANZ_SNIPPET = 800 # Body-Zeichen je Seite im Prompt (URL ist Primärsignal)
|
||||
# LLM topic relevance gate (after the rule filter): per content page yes/no against the spec.
|
||||
# Separates the subject area (e.g. backend vs frontend), which the global CRAWL_* rules can't.
|
||||
QUELLE_RELEVANZ_CHUNK = 12 # pages per rater package (small, since a snippet ships per page)
|
||||
QUELLE_RELEVANZ_SNIPPET = 800 # body characters per page in the prompt (URL is the primary signal)
|
||||
|
||||
# Timeouts pro Agenten-Schritt: (Basis-Sekunden, Sekunden pro Baustein/Section).
|
||||
# Gilt für alle Provider gleich — wer zu langsam ist, wird neu gestartet bzw. überholt.
|
||||
# Timeouts per agent step: (base seconds, seconds per block/section).
|
||||
# Applies equally to all providers — whoever is too slow gets restarted or overtaken.
|
||||
TIMEOUTS = {
|
||||
"recherche": (1800, 0), # fix 30 min
|
||||
"recherche_mapping": (600, 3), # n = vorgemergte Einträge
|
||||
"auswahl_mapping": (600, 2), # n = Rest-Einträge (Bausteine-Inventar)
|
||||
"ergaenzung": (900, 0), # Themenfeld-Ergänzung bei Projekten (Web-Recherche)
|
||||
"research": (1800, 0), # fixed 30 min
|
||||
"research_mapping": (600, 3), # n = pre-merged entries
|
||||
"selection_mapping": (600, 2), # n = remaining entries (block inventory)
|
||||
"ergaenzung": (900, 0), # subject-field extension for projects (web research)
|
||||
"plan": (300, 5),
|
||||
"plan_judge": (600, 5), # Judge liest bis zu 5 Gliederungen, n = Sections
|
||||
"inhalt": (600, 90), # Inhalte je Baustein im Chunk identifizieren (Websuche)
|
||||
"inhalt_check": (300, 10), # Inhalts-Prüfung je Baustein im Paket
|
||||
"subbaustein": (900, 45), # Subbausteine je Baustein im Chunk finden (Websuche)
|
||||
"subbaustein_check": (300, 15), # Judge entscheidet strittige Subbausteine im Chunk
|
||||
"stufe": (300, 10), # Subbausteine einstufen je Chunk
|
||||
"stufe_check": (300, 10), # Judge entscheidet strittige Stufen im Chunk
|
||||
"relevanz": (300, 10), # Subbausteine relevant/rand je Chunk
|
||||
"relevanz_check": (300, 10), # Judge entscheidet strittige Relevanz im Chunk
|
||||
"frage_muster": (300, 15), # Frage-Muster je Baustein (Subbausteine × Typen)
|
||||
"frage_muster_check": (300, 10), # Kritiker bereinigt die Muster-Tabelle je Baustein
|
||||
"writer": (600, 120), # pro Section im Chunk
|
||||
"lese_check": (300, 10), # pro Section im Paket
|
||||
"plan_judge": (600, 5), # judge reads up to 5 outlines, n = sections
|
||||
"content": (600, 90), # identify content per block in the chunk (web search)
|
||||
"content_check": (300, 10), # content exam per block in the package
|
||||
"subblock": (900, 45), # find subblocks per block in the chunk (web search)
|
||||
"subblock_check": (300, 15), # judge decides contested subblocks in the chunk
|
||||
"level": (300, 10), # classify subblocks per chunk
|
||||
"level_check": (300, 10), # judge decides contested levels in the chunk
|
||||
"relevance": (300, 10), # subblocks relevant/peripheral per chunk
|
||||
"relevance_check": (300, 10), # judge decides contested relevance in the chunk
|
||||
"question_pattern": (300, 15), # question patterns per block (subblocks × types)
|
||||
"question_pattern_check": (300, 10), # critic cleans up the pattern table per block
|
||||
"writer": (600, 120), # per section in the chunk
|
||||
"lese_check": (300, 10), # per section in the package
|
||||
}
|
||||
|
||||
# Zweck je Format — fließt in den Gliederungs-Judge (was der Guide leisten soll).
|
||||
FORMAT_ZWECK = {
|
||||
# Purpose per format — flows into the outline judge (what the guide should achieve).
|
||||
# German strings: these are inserted verbatim into the judge prompt → kept German on purpose.
|
||||
FORMAT_PURPOSE = {
|
||||
"Guide": "einen fokussierten Guide — alles Relevante ohne Randthemen",
|
||||
"FullGuide": "einen Komplett-Guide — das ganze Thema inkl. Randthemen",
|
||||
"Rest": "einen Ergänzungs-Guide — nur die Randthemen",
|
||||
}
|
||||
|
||||
# Provider-Stacks: komplett unabhängig, einer kann jederzeit entfernt werden.
|
||||
# Rollen: "quick" = Massenarbeit (Recherche, Einordnung),
|
||||
# "fast" = Interaktion + Voten (Chat, Prüfung, Klärung, Elemente),
|
||||
# "judge" = Mapping-/Judge-/Prüf-Agenten — kalt (niedrige Temperature,
|
||||
# ohne Thinking) für stabile Urteile; Claude/Lokal mappen auf "fast",
|
||||
# "guide" = große Generierung (Vorschläge, Writer).
|
||||
# Provider stacks: completely independent, any one can be removed at any time.
|
||||
# Roles: "quick" = bulk work (research, classification),
|
||||
# "fast" = interaction + voting (chat, exam, clarification, elements),
|
||||
# "judge" = mapping/judge/check agents — cold (low temperature,
|
||||
# no thinking) for stable verdicts; Claude/local map to "fast",
|
||||
# "guide" = large generation (proposals, writer).
|
||||
DEFAULT_PROVIDER = "claude"
|
||||
PROVIDERS = {
|
||||
"claude": {
|
||||
"cli": "claude",
|
||||
"guide": "claude-opus-4-8[1m]",
|
||||
"fast": "claude-sonnet-4-6",
|
||||
"judge": "claude-sonnet-4-6", # CLI kennt keine Temperature
|
||||
"judge": "claude-sonnet-4-6", # the CLI has no temperature setting
|
||||
"quick": "claude-sonnet-4-6",
|
||||
"env_key": None, # Auth via CLAUDE_CODE_OAUTH_TOKEN oder ~/.claude
|
||||
"env_key": None, # auth via CLAUDE_CODE_OAUTH_TOKEN or ~/.claude
|
||||
},
|
||||
# "minimax-kalt/…" ist KEIN eigener Stack, nur ein opencode-Provider-Eintrag
|
||||
# (dev-ops/opencode.json) mit niedriger Temperature; M3 dort ohne Thinking.
|
||||
# "minimax-kalt/…" is NOT its own stack, just an opencode provider entry
|
||||
# (dev-ops/opencode.json) with low temperature; M3 there without thinking.
|
||||
"minimax": {
|
||||
"cli": "opencode",
|
||||
"guide": "minimax/MiniMax-M3",
|
||||
@@ -141,6 +142,6 @@ PROVIDERS = {
|
||||
"judge": "ollama/qwen3.5:9b",
|
||||
"quick": "ollama/qwen3.5:9b",
|
||||
"env_key": None,
|
||||
"check_url": "http://localhost:11434/api/tags", # Ollama erreichbar?
|
||||
"check_url": "http://localhost:11434/api/tags", # Ollama reachable?
|
||||
},
|
||||
}
|
||||
|
||||
100
backend/crawl.py
100
backend/crawl.py
@@ -1,9 +1,9 @@
|
||||
"""Geboundeter Domain-Crawler für Link-Quellen — rendert JS via Playwright (Chromium).
|
||||
"""Bounded domain crawler for link sources — renders JS via Playwright (Chromium).
|
||||
|
||||
Lädt ab einer Start-URL Seiten + PDFs — NUR dieselbe Domain, begrenzte Tiefe und
|
||||
Seitenzahl. HTML-Seiten werden im Headless-Browser gerendert (für SPAs nötig), dann
|
||||
Links + Text aus dem fertigen DOM gezogen. PDFs werden direkt als Bytes geladen.
|
||||
Deterministisch, gebounded; läuft via asyncio.to_thread (Sync-API, kein Event-Loop).
|
||||
Loads pages + PDFs starting from a start URL — ONLY the same domain, limited depth
|
||||
and page count. HTML pages are rendered in a headless browser (needed for SPAs), then
|
||||
links + text are pulled from the finished DOM. PDFs are loaded directly as bytes.
|
||||
Deterministic, bounded; runs via asyncio.to_thread (sync API, no event loop).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
@@ -17,23 +17,23 @@ from fsutil import atomic_write_text
|
||||
|
||||
log = logging.getLogger("creator.crawl")
|
||||
|
||||
MAX_TIEFE = 3
|
||||
MAX_SEITEN = 500
|
||||
SEITE_TIMEOUT = 30 # Sekunden pro Seite (Render bzw. PDF-Download)
|
||||
CRAWL_SETTLE_MS = 3000 # gedeckelter Settle nach domcontentloaded (SPA-Render); kein 30s-networkidle-Hang
|
||||
MAX_BYTES = 10_000_000 # 10 MB Deckel pro PDF
|
||||
MAX_DEPTH = 3
|
||||
MAX_PAGES = 500
|
||||
PAGE_TIMEOUT = 30 # seconds per page (render or PDF download)
|
||||
CRAWL_SETTLE_MS = 3000 # capped settle after domcontentloaded (SPA render); no 30s networkidle hang
|
||||
MAX_BYTES = 10_000_000 # 10 MB cap per PDF
|
||||
_UA = "Mozilla/5.0 (creator-lernbot)"
|
||||
|
||||
|
||||
def _fetch_bytes(url: str) -> bytes | None:
|
||||
"""PDF-Bytes per urllib laden (kein Rendering nötig). None bei Fehler/zu groß."""
|
||||
"""Load PDF bytes via urllib (no rendering needed). None on error/too large."""
|
||||
try:
|
||||
req = Request(url, headers={"User-Agent": _UA})
|
||||
with urlopen(req, timeout=SEITE_TIMEOUT) as resp:
|
||||
with urlopen(req, timeout=PAGE_TIMEOUT) as resp:
|
||||
data = resp.read(MAX_BYTES + 1)
|
||||
return None if len(data) > MAX_BYTES else data
|
||||
except Exception as e:
|
||||
log.debug("crawl: PDF-Fetch fehlgeschlagen %s: %s", url, e)
|
||||
log.debug("crawl: PDF fetch failed %s: %s", url, e)
|
||||
return None
|
||||
|
||||
|
||||
@@ -48,25 +48,25 @@ def _is_pdf(url: str) -> bool:
|
||||
|
||||
|
||||
def _scope_prefix(start_url: str) -> str:
|
||||
"""Erstes nicht-leeres Pfad-Segment der Start-URL als Crawl-Scope, z.B.
|
||||
`/learn/path/x` → `/learn`. Ohne Pfad-Segment → `""` (ganze Domain, kein Regress)."""
|
||||
"""First non-empty path segment of the start URL as the crawl scope, e.g.
|
||||
`/learn/path/x` → `/learn`. No path segment → `""` (whole domain, no narrowing)."""
|
||||
seg = [s for s in urlparse(start_url).path.split("/") if s]
|
||||
return f"/{seg[0]}" if seg else ""
|
||||
|
||||
|
||||
def _in_scope(url: str, prefix: str) -> bool:
|
||||
"""Segment-genauer Prefix-Match (kein `/learn` ⊃ `/learning-x`). Leerer Prefix → alles erlaubt."""
|
||||
"""Segment-exact prefix match (no `/learn` ⊃ `/learning-x`). Empty prefix → everything allowed."""
|
||||
if not prefix:
|
||||
return True
|
||||
p = urlparse(url).path
|
||||
return p == prefix or p.startswith(prefix + "/")
|
||||
|
||||
|
||||
def _seiten_text(page) -> str:
|
||||
"""Haupttext der gerenderten Seite — Nav/Footer/Boilerplate per trafilatura entfernt.
|
||||
Fallback auf den rohen Body-Text, wenn die Extraktion leer/zu kurz ausfällt (Nicht-Artikel-Seiten)."""
|
||||
def _page_text(page) -> str:
|
||||
"""Main text of the rendered page — nav/footer/boilerplate removed via trafilatura.
|
||||
Falls back to the raw body text when extraction is empty/too short (non-article pages)."""
|
||||
try:
|
||||
from trafilatura import extract # lazy: Backend startet auch ohne das Paket
|
||||
from trafilatura import extract # lazy: the backend starts even without the package
|
||||
text = extract(page.content(), include_comments=False, include_tables=True) or ""
|
||||
except Exception:
|
||||
text = ""
|
||||
@@ -78,58 +78,58 @@ def _seiten_text(page) -> str:
|
||||
return text.strip()
|
||||
|
||||
|
||||
def crawl(start_url: str, ziel: Path, *, max_tiefe: int = MAX_TIEFE, max_seiten: int = MAX_SEITEN, cancelled=None) -> int:
|
||||
"""Crawlt ab start_url (nur gleiche Domain), rendert JS und legt Seiten/PDFs in `ziel` ab.
|
||||
def crawl(start_url: str, target: Path, *, max_depth: int = MAX_DEPTH, max_pages: int = MAX_PAGES, cancelled=None) -> int:
|
||||
"""Crawl from start_url (same domain only), render JS and store pages/PDFs in `target`.
|
||||
|
||||
BFS bis `max_tiefe` / `max_seiten`. Fehler einzelner Seiten werden übersprungen.
|
||||
Schreibt am ENDE einen `.done`-Marker; ein Abbruch (`cancelled()` → True) lässt ihn weg,
|
||||
sodass ein Neustart neu crawlt. Gibt die Zahl gespeicherter Quellen zurück.
|
||||
BFS up to `max_depth` / `max_pages`. Errors on individual pages are skipped.
|
||||
Writes a `.done` marker at the END; an abort (`cancelled()` → True) omits it,
|
||||
so a restart crawls again. Returns the number of saved sources.
|
||||
"""
|
||||
# Lazy: so startet das Backend auch ohne installiertes Playwright; nur das Crawlen schlägt dann fehl.
|
||||
# Lazy: this way the backend starts even without Playwright installed; only crawling then fails.
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ziel.mkdir(parents=True, exist_ok=True)
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
domain = urlparse(start_url).netloc
|
||||
prefix = _scope_prefix(start_url) # nur Links unter diesem Pfad-Segment folgen
|
||||
gesehen: set[str] = set()
|
||||
prefix = _scope_prefix(start_url) # only follow links under this path segment
|
||||
seen: set[str] = set()
|
||||
queue: list[tuple[str, int]] = [(urldefrag(start_url)[0], 0)]
|
||||
gespeichert = 0
|
||||
saved = 0
|
||||
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(args=["--no-sandbox"]) # non-root (Docker user app)
|
||||
page = browser.new_page(user_agent=_UA)
|
||||
try:
|
||||
while queue and gespeichert < max_seiten:
|
||||
while queue and saved < max_pages:
|
||||
if cancelled and cancelled():
|
||||
return gespeichert # Abbruch → KEIN .done-Marker → Neustart crawlt neu
|
||||
url, tiefe = queue.pop(0)
|
||||
if url in gesehen:
|
||||
return saved # abort → NO .done marker → restart crawls again
|
||||
url, depth = queue.pop(0)
|
||||
if url in seen:
|
||||
continue
|
||||
gesehen.add(url)
|
||||
seen.add(url)
|
||||
|
||||
# PDFs brauchen kein Rendering — direkt laden.
|
||||
# PDFs need no rendering — load directly.
|
||||
if _is_pdf(url):
|
||||
data = _fetch_bytes(url)
|
||||
if data:
|
||||
p = ziel / _name(url, ".pdf")
|
||||
p = target / _name(url, ".pdf")
|
||||
if not p.exists():
|
||||
p.write_bytes(data)
|
||||
gespeichert += 1
|
||||
saved += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=SEITE_TIMEOUT * 1000)
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=PAGE_TIMEOUT * 1000)
|
||||
except Exception as e:
|
||||
log.debug("crawl: goto unvollständig %s: %s", url, e) # trotzdem versuchen, Inhalt zu lesen
|
||||
log.debug("crawl: goto incomplete %s: %s", url, e) # still try to read the content
|
||||
try:
|
||||
page.wait_for_load_state("networkidle", timeout=CRAWL_SETTLE_MS)
|
||||
except Exception:
|
||||
pass # SPA mit Dauer-Traffic erreicht nie idle → nach Settle weiter, kein 30s-Hang
|
||||
text = _seiten_text(page) # Haupttext, Nav/Footer entfernt (Fallback: roher Body)
|
||||
pass # an SPA with constant traffic never reaches idle → continue after settle, no 30s hang
|
||||
text = _page_text(page) # main text, nav/footer removed (fallback: raw body)
|
||||
if text:
|
||||
atomic_write_text(ziel / _name(url, ".txt"), f"QUELLE: {url}\n\n{text}")
|
||||
gespeichert += 1
|
||||
if tiefe < max_tiefe:
|
||||
atomic_write_text(target / _name(url, ".txt"), f"QUELLE: {url}\n\n{text}")
|
||||
saved += 1
|
||||
if depth < max_depth:
|
||||
try:
|
||||
hrefs = page.eval_on_selector_all("a[href]", "els => els.map(e => e.href)")
|
||||
except Exception:
|
||||
@@ -138,11 +138,11 @@ def crawl(start_url: str, ziel: Path, *, max_tiefe: int = MAX_TIEFE, max_seiten:
|
||||
nxt = urldefrag(href)[0]
|
||||
if (nxt.startswith(("http://", "https://"))
|
||||
and urlparse(nxt).netloc == domain and _in_scope(nxt, prefix)
|
||||
and nxt not in gesehen):
|
||||
queue.append((nxt, tiefe + 1))
|
||||
and nxt not in seen):
|
||||
queue.append((nxt, depth + 1))
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
(ziel / ".done").write_text("ok", encoding="utf-8") # sauber durchgelaufen
|
||||
log.info("crawl %s → %d Quellen in %s", start_url, gespeichert, ziel)
|
||||
return gespeichert
|
||||
(target / ".done").write_text("ok", encoding="utf-8") # ran through cleanly
|
||||
log.info("crawl %s → %d sources in %s", start_url, saved, target)
|
||||
return saved
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
"""Elemente (persönliche Zusammenfassung) und Tutor-Chat zum Guide."""
|
||||
"""Elements (personal summary) and tutor chat for the guide."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
@@ -6,25 +6,25 @@ import uuid
|
||||
|
||||
from agents import run_agent
|
||||
from config import DEFAULT_PROVIDER
|
||||
from jsonio import parse_json_text as _parse_json_text, read_json_file as _json_datei
|
||||
from paths import bausteine_path, guide_content_path
|
||||
from jsonio import parse_json_text as _parse_json_text, read_json_file as _read_json_file
|
||||
from paths import blocks_path, guide_content_path
|
||||
from pipeline import _prompt
|
||||
|
||||
log = logging.getLogger("creator.elements")
|
||||
|
||||
|
||||
# --- Tutor-Chat ---
|
||||
# --- Tutor chat ---
|
||||
|
||||
def _build_guide_chat_prompt(topic: str, format_name: str, section: str, outline: str, messages: list[dict]) -> str:
|
||||
transcript = "\n".join(
|
||||
f"{'Nutzer' if m.get('role') == 'user' else 'Assistent'}: {m.get('content', '')}"
|
||||
f"{'User' if m.get('role') == 'user' else 'Assistant'}: {m.get('content', '')}"
|
||||
for m in messages
|
||||
)
|
||||
return _prompt(
|
||||
"Chat",
|
||||
topic=topic, format_name=format_name,
|
||||
outline_block=outline.strip() or "(keine)",
|
||||
section_block=section.strip() or "(kein Abschnitt erkannt)",
|
||||
outline_block=outline.strip() or "(none)",
|
||||
section_block=section.strip() or "(no section detected)",
|
||||
transcript=transcript,
|
||||
)
|
||||
|
||||
@@ -36,62 +36,62 @@ async def chat_with_guide(topic: str, format_name: str, section: str, outline: s
|
||||
"chat-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
|
||||
)
|
||||
if returncode != 0:
|
||||
return "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut."
|
||||
return "Sorry, that didn't work. Please try again."
|
||||
reply = stdout.strip()
|
||||
return reply or "Entschuldigung, ich habe keine Antwort erhalten."
|
||||
return reply or "Sorry, I didn't get a response."
|
||||
except Exception:
|
||||
log.warning("[%s] Guide-Chat fehlgeschlagen", topic, exc_info=True)
|
||||
return "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut."
|
||||
log.warning("[%s] Guide chat failed", topic, exc_info=True)
|
||||
return "Sorry, that didn't work. Please try again."
|
||||
|
||||
|
||||
# --- Elemente ---
|
||||
# --- Elements ---
|
||||
|
||||
def _element_fields(data: dict) -> dict | None:
|
||||
"""Validiert KI-Element-JSON und normalisiert auf die DB-Felder."""
|
||||
"""Validate AI element JSON and normalize it onto the DB fields."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
title = str(data.get("title", "")).strip()
|
||||
if not title:
|
||||
return None
|
||||
listen = {}
|
||||
lists = {}
|
||||
for key in ("examples", "hints"):
|
||||
raw = data.get(key, [])
|
||||
listen[key] = [str(e).strip() for e in raw if str(e).strip()] if isinstance(raw, list) else []
|
||||
lists[key] = [str(e).strip() for e in raw if str(e).strip()] if isinstance(raw, list) else []
|
||||
return {
|
||||
"title": title[:200],
|
||||
"description": str(data.get("description", "")).strip(),
|
||||
"examples": listen["examples"],
|
||||
"hints": listen["hints"],
|
||||
"examples": lists["examples"],
|
||||
"hints": lists["hints"],
|
||||
}
|
||||
|
||||
|
||||
def _topic_context(topic: str, limit: int = 12000) -> str:
|
||||
"""Bausteine + Guide-Inhalte des Themas als Kontext-Text (gekürzt)."""
|
||||
"""Blocks + guide content of the topic as context text (truncated)."""
|
||||
parts: list[str] = []
|
||||
bp = bausteine_path(topic)
|
||||
bp = blocks_path(topic)
|
||||
if bp.exists():
|
||||
parts.append(bp.read_text(encoding="utf-8"))
|
||||
for fmt in ("Guide", "FullGuide"): # bester verfügbarer Prosa-Guide als Chat-Kontext
|
||||
content = _json_datei(guide_content_path(topic, fmt))
|
||||
for fmt in ("Guide", "FullGuide"): # best available prose guide as chat context
|
||||
content = _read_json_file(guide_content_path(topic, fmt))
|
||||
if content:
|
||||
for ch in content.get("chapters", []):
|
||||
for sec in ch.get("sections", []):
|
||||
parts.append(sec if isinstance(sec, str) else json.dumps(sec, ensure_ascii=False))
|
||||
break # bester verfügbarer Guide reicht
|
||||
break # the best available guide is enough
|
||||
text = "\n\n".join(parts).strip()
|
||||
return text[:limit] if text else "(kein Material vorhanden)"
|
||||
return text[:limit] if text else "(no material available)"
|
||||
|
||||
|
||||
async def generate_element(topic: str, hint: str, provider: str = DEFAULT_PROVIDER, extra_context: str = "") -> dict:
|
||||
"""Erstellt Element-Felder per KI. Fallback: nur Titel aus dem Stichwort."""
|
||||
fallback = {"title": hint.strip() or "Neues Element", "description": "", "examples": [], "hints": []}
|
||||
"""Create element fields via AI. Fallback: only the title from the keyword."""
|
||||
fallback = {"title": hint.strip() or "New element", "description": "", "examples": [], "hints": []}
|
||||
try:
|
||||
context = _topic_context(topic)
|
||||
if extra_context.strip():
|
||||
context = (extra_context.strip() + "\n\n" + context)[:12000]
|
||||
prompt = _prompt(
|
||||
"Element-Create",
|
||||
topic=topic, hint=hint.strip() or "(keins — wähle selbst ein Kernkonzept)",
|
||||
topic=topic, hint=hint.strip() or "(none — pick a core concept yourself)",
|
||||
context=context,
|
||||
)
|
||||
returncode, stdout, _ = await run_agent(
|
||||
@@ -101,12 +101,12 @@ async def generate_element(topic: str, hint: str, provider: str = DEFAULT_PROVID
|
||||
return fallback
|
||||
return _element_fields(_parse_json_text(stdout)) or fallback
|
||||
except Exception:
|
||||
log.warning("[%s] Element-Erstellung fehlgeschlagen", topic, exc_info=True)
|
||||
log.warning("[%s] Element creation failed", topic, exc_info=True)
|
||||
return fallback
|
||||
|
||||
|
||||
def _parse_suggestions(stdout: str) -> list[dict] | None:
|
||||
"""Validiert Vorschlags-JSON aus KI-Output. None bei ungültigem JSON."""
|
||||
"""Validate suggestion JSON from AI output. None on invalid JSON."""
|
||||
data = _parse_json_text(stdout)
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
@@ -123,7 +123,7 @@ def _parse_suggestions(stdout: str) -> list[dict] | None:
|
||||
|
||||
|
||||
async def check_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list[dict] | None:
|
||||
"""Zweischrittige Prüfung auf fehlende Infos: Recherche → Verifizieren. None bei Fehler."""
|
||||
"""Two-step check for missing info: research → verify. None on error."""
|
||||
try:
|
||||
element_json = json.dumps(
|
||||
{k: element[k] for k in ("title", "description", "examples", "hints")},
|
||||
@@ -131,7 +131,7 @@ async def check_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list
|
||||
)
|
||||
context = _topic_context(element["topic"])
|
||||
|
||||
# Schritt 1: Recherche — breit Kandidaten sammeln
|
||||
# Step 1: research — collect candidates broadly
|
||||
prompt = _prompt("Element-Check", topic=element["topic"], element_json=element_json, context=context)
|
||||
returncode, stdout, _ = await run_agent(
|
||||
"element-check-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
|
||||
@@ -144,7 +144,7 @@ async def check_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
# Schritt 2: Verifizieren — nur Wichtiges, nicht Redundantes durchlassen
|
||||
# Step 2: verify — only let important, non-redundant items through
|
||||
prompt = _prompt(
|
||||
"Element-Verify",
|
||||
topic=element["topic"], element_json=element_json,
|
||||
@@ -158,7 +158,7 @@ async def check_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list
|
||||
return None
|
||||
return _parse_suggestions(stdout)
|
||||
except Exception:
|
||||
log.warning("[%s] Element-Prüfung fehlgeschlagen", element.get("topic", "?"), exc_info=True)
|
||||
log.warning("[%s] Element check failed", element.get("topic", "?"), exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ def _element_json(element: dict) -> str:
|
||||
|
||||
|
||||
def _validate_change(c, element: dict) -> dict | None:
|
||||
"""Validiert einen Änderungs-Vorschlag aus KI-Output gegen das Element."""
|
||||
"""Validate a change suggestion from AI output against the element."""
|
||||
if not isinstance(c, dict):
|
||||
return None
|
||||
text = str(c.get("text", "")).strip()
|
||||
@@ -178,16 +178,16 @@ def _validate_change(c, element: dict) -> dict | None:
|
||||
target = c.get("target")
|
||||
index = c.get("index")
|
||||
content = str(c.get("content", "")).strip()
|
||||
if not text or action not in ("entfernen", "anpassen", "hinzufuegen"):
|
||||
if not text or action not in ("remove", "adjust", "add"):
|
||||
return None
|
||||
if target not in ("title", "description", "examples", "hints"):
|
||||
return None
|
||||
if action in ("anpassen", "hinzufuegen") and not content:
|
||||
if action in ("adjust", "add") and not content:
|
||||
return None
|
||||
if action == "entfernen" and target not in ("examples", "hints"):
|
||||
if action == "remove" and target not in ("examples", "hints"):
|
||||
return None
|
||||
# Index nur für anpassen/entfernen in Listen-Feldern; muss existieren
|
||||
if target in ("examples", "hints") and action in ("anpassen", "entfernen"):
|
||||
# Index only for adjust/remove on list fields; must exist
|
||||
if target in ("examples", "hints") and action in ("adjust", "remove"):
|
||||
if not isinstance(index, int) or not (0 <= index < len(element[target])):
|
||||
return None
|
||||
else:
|
||||
@@ -196,11 +196,11 @@ def _validate_change(c, element: dict) -> dict | None:
|
||||
|
||||
|
||||
async def chat_with_element(element: dict, messages: list[dict], provider: str = DEFAULT_PROVIDER) -> tuple[str, list[dict]]:
|
||||
"""Chat zum Element. Gibt (Antwort, Änderungs-Vorschläge) zurück — ändert nichts direkt."""
|
||||
fehler = "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut."
|
||||
"""Chat about the element. Returns (reply, change suggestions) — changes nothing directly."""
|
||||
error = "Sorry, that didn't work. Please try again."
|
||||
try:
|
||||
transcript = "\n".join(
|
||||
f"{'Nutzer' if m.get('role') == 'user' else 'Assistent'}: {m.get('content', '')}"
|
||||
f"{'User' if m.get('role') == 'user' else 'Assistant'}: {m.get('content', '')}"
|
||||
for m in messages
|
||||
)
|
||||
prompt = _prompt("Element-Chat", topic=element["topic"], element_json=_element_json(element), transcript=transcript)
|
||||
@@ -208,22 +208,22 @@ async def chat_with_element(element: dict, messages: list[dict], provider: str =
|
||||
"element-chat-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
|
||||
)
|
||||
if returncode != 0:
|
||||
return fehler, []
|
||||
return error, []
|
||||
data = _parse_json_text(stdout)
|
||||
if not isinstance(data, dict):
|
||||
return fehler, []
|
||||
return error, []
|
||||
changes = [v for c in data.get("changes", []) if (v := _validate_change(c, element))]
|
||||
reply = str(data.get("reply", "")).strip() or ("Vorschläge erstellt." if changes else fehler)
|
||||
reply = str(data.get("reply", "")).strip() or ("Suggestions created." if changes else error)
|
||||
return reply, changes
|
||||
except Exception:
|
||||
log.warning("[%s] Element-Chat fehlgeschlagen", element.get("topic", "?"), exc_info=True)
|
||||
return fehler, []
|
||||
log.warning("[%s] Element chat failed", element.get("topic", "?"), exc_info=True)
|
||||
return error, []
|
||||
|
||||
|
||||
async def style_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list[dict] | None:
|
||||
"""Prüft ein Element auf die Stil-Regeln und schlägt Änderungen vor. None bei Fehler."""
|
||||
"""Check an element against the style rules and suggest changes. None on error."""
|
||||
try:
|
||||
prompt = _prompt("Element-Stil", topic=element["topic"], element_json=_element_json(element))
|
||||
prompt = _prompt("Element-Style", topic=element["topic"], element_json=_element_json(element))
|
||||
returncode, stdout, _ = await run_agent(
|
||||
"element-stil-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
|
||||
)
|
||||
@@ -234,12 +234,12 @@ async def style_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list
|
||||
return None
|
||||
return [v for c in data.get("changes", []) if (v := _validate_change(c, element))]
|
||||
except Exception:
|
||||
log.warning("[%s] Stil-Prüfung fehlgeschlagen", element.get("topic", "?"), exc_info=True)
|
||||
log.warning("[%s] Style check failed", element.get("topic", "?"), exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def refine_suggestion(element: dict, suggestion: dict, instruction: str, provider: str = DEFAULT_PROVIDER) -> dict | None:
|
||||
"""Überarbeitet einen einzelnen Vorschlag nach Nutzer-Anweisung. None bei Fehler."""
|
||||
"""Revise a single suggestion per user instruction. None on error."""
|
||||
try:
|
||||
prompt = _prompt(
|
||||
"Element-Refine",
|
||||
@@ -257,5 +257,5 @@ async def refine_suggestion(element: dict, suggestion: dict, instruction: str, p
|
||||
return None
|
||||
return _validate_change(data.get("change"), element)
|
||||
except Exception:
|
||||
log.warning("[%s] Vorschlags-Überarbeitung fehlgeschlagen", element.get("topic", "?"), exc_info=True)
|
||||
log.warning("[%s] Suggestion revision failed", element.get("topic", "?"), exc_info=True)
|
||||
return None
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"""Semantisches Embedding-Clustering für die Baustein-Konsolidierung.
|
||||
"""Semantic embedding clustering for block consolidation.
|
||||
|
||||
Mean-Pool-Embeddings eines mehrsprachigen Satz-Modells bilden über Cosine-Blocking +
|
||||
Union-Find GLOBALE Kandidaten-Cluster (kein Chunk-Verlust). Sichere Paare (Ähnlichkeit
|
||||
≥ HART) werden ohne LLM gemergt; Grenz-Paare im Band [BAND_LOW, HART) gibt der Aufrufer
|
||||
einem LLM-Judge zur ja/nein-Entscheidung. Fehlen `transformers`/`torch` oder lädt das
|
||||
Modell nicht → `embed_sims()` liefert `None`, der Aufrufer fällt auf den alten
|
||||
Panel-Judge-Pfad zurück (silente Deaktivierung, wie das Lesbarkeits-Gate).
|
||||
Mean-pool embeddings of a multilingual sentence model build GLOBAL candidate
|
||||
clusters via cosine blocking + union-find (no chunk loss). Safe pairs (similarity
|
||||
≥ HARD) are merged without an LLM; borderline pairs in the band [BAND_LOW, HARD) are
|
||||
handed by the caller to an LLM judge for a yes/no decision. If `transformers`/`torch`
|
||||
are missing or the model won't load → `embed_sims()` returns `None`, and the caller
|
||||
falls back to the old panel-judge path (silent deactivation, like the readability gate).
|
||||
|
||||
CPU genügt; der Aufrufer wrappt die blockierende Inferenz in `asyncio.to_thread`.
|
||||
`numpy` ist transitiv über torch vorhanden (bewusst nicht in requirements.txt, analog torch).
|
||||
CPU is enough; the caller wraps the blocking inference in `asyncio.to_thread`.
|
||||
`numpy` comes in transitively via torch (deliberately not in requirements.txt, like torch).
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -19,19 +19,19 @@ from config import EMBEDDING_AKTIV, EMBEDDING_MODELL, EMBEDDING_BLOCK_FLOOR, EMB
|
||||
|
||||
log = logging.getLogger("creator.embedding")
|
||||
|
||||
_modell_cache = None # (tokenizer, model, torch) — Singleton
|
||||
_ladeversuch = False # schon versucht zu laden?
|
||||
_model_cache = None # (tokenizer, model, torch) — singleton
|
||||
_load_attempt = False # already tried to load?
|
||||
|
||||
EMBEDDING_BATCH = 32 # Inferenz-Batchgröße (CPU)
|
||||
EMBEDDING_MAX_LEN = 128 # Titel + Kurzbeschreibung sind kurz → kleiner Truncation-Cap genügt
|
||||
EMBEDDING_BATCH = 32 # inference batch size (CPU)
|
||||
EMBEDDING_MAX_LEN = 128 # title + short description are short → a small truncation cap suffices
|
||||
|
||||
|
||||
def _modell():
|
||||
"""Lädt das Modell einmalig. None = Clustering aus (deaktiviert oder Lade-Fehler)."""
|
||||
global _modell_cache, _ladeversuch
|
||||
if _ladeversuch:
|
||||
return _modell_cache
|
||||
_ladeversuch = True
|
||||
def _model():
|
||||
"""Load the model once. None = clustering off (disabled or load error)."""
|
||||
global _model_cache, _load_attempt
|
||||
if _load_attempt:
|
||||
return _model_cache
|
||||
_load_attempt = True
|
||||
if not EMBEDDING_AKTIV:
|
||||
return None
|
||||
try:
|
||||
@@ -40,24 +40,24 @@ def _modell():
|
||||
tok = AutoTokenizer.from_pretrained(EMBEDDING_MODELL)
|
||||
model = AutoModel.from_pretrained(EMBEDDING_MODELL)
|
||||
model.eval()
|
||||
_modell_cache = (tok, model, torch)
|
||||
log.info("Embedding-Modell geladen: %s", EMBEDDING_MODELL)
|
||||
_model_cache = (tok, model, torch)
|
||||
log.info("embedding model loaded: %s", EMBEDDING_MODELL)
|
||||
except Exception as e:
|
||||
log.warning("Embedding-Clustering deaktiviert (Modell nicht ladbar): %s", e)
|
||||
_modell_cache = None
|
||||
return _modell_cache
|
||||
log.warning("embedding clustering disabled (model not loadable): %s", e)
|
||||
_model_cache = None
|
||||
return _model_cache
|
||||
|
||||
|
||||
def verfuegbar() -> bool:
|
||||
"""True, wenn das Modell geladen werden konnte. Lädt beim ersten Aufruf (blockierend)."""
|
||||
return _modell() is not None
|
||||
def available() -> bool:
|
||||
"""True if the model could be loaded. Loads on the first call (blocking)."""
|
||||
return _model() is not None
|
||||
|
||||
|
||||
def embed(texts: list[str]) -> "np.ndarray | None":
|
||||
"""Texte → (n, d) L2-normalisierte, mean-gepoolte Embeddings. None = Modell aus."""
|
||||
if _modell() is None:
|
||||
"""Texts → (n, d) L2-normalized, mean-pooled embeddings. None = model off."""
|
||||
if _model() is None:
|
||||
return None
|
||||
tok, model, torch = _modell_cache
|
||||
tok, model, torch = _model_cache
|
||||
out = []
|
||||
for i in range(0, len(texts), EMBEDDING_BATCH):
|
||||
batch = texts[i:i + EMBEDDING_BATCH]
|
||||
@@ -65,8 +65,8 @@ def embed(texts: list[str]) -> "np.ndarray | None":
|
||||
with torch.no_grad():
|
||||
hidden = model(**enc).last_hidden_state # (b, t, d)
|
||||
mask = enc["attention_mask"].unsqueeze(-1).type_as(hidden)
|
||||
vec = (hidden * mask).sum(1) / mask.sum(1).clamp(min=1e-9) # mean-pool ohne Padding
|
||||
vec = torch.nn.functional.normalize(vec, p=2, dim=1) # L2 → Cosine = Skalarprodukt
|
||||
vec = (hidden * mask).sum(1) / mask.sum(1).clamp(min=1e-9) # mean-pool without padding
|
||||
vec = torch.nn.functional.normalize(vec, p=2, dim=1) # L2 → cosine = dot product
|
||||
out.append(vec.cpu().numpy())
|
||||
return np.vstack(out).astype(np.float32)
|
||||
|
||||
@@ -81,24 +81,24 @@ def _find(parent: list[int], x: int) -> int:
|
||||
def _union(parent: list[int], a: int, b: int) -> None:
|
||||
ra, rb = _find(parent, a), _find(parent, b)
|
||||
if ra != rb:
|
||||
parent[max(ra, rb)] = min(ra, rb) # kleinster Index = Wurzel (deterministisch)
|
||||
parent[max(ra, rb)] = min(ra, rb) # smallest index = root (deterministic)
|
||||
|
||||
|
||||
def embed_sims(texts: list[str]):
|
||||
"""Texte → (n, n) Cosine-Matrix · None = Modell nicht verfügbar (Fallback)."""
|
||||
"""Texts → (n, n) cosine matrix · None = model not available (fallback)."""
|
||||
embs = embed(texts)
|
||||
if embs is None:
|
||||
return None
|
||||
return embs @ embs.T # (n, n) Cosine, float32 (~2 MB bei n=700)
|
||||
return embs @ embs.T # (n, n) cosine, float32 (~2 MB at n=700)
|
||||
|
||||
|
||||
def capped_blocks(sims, floor: float | None = None, cap: int | None = None) -> list[list[int]]:
|
||||
"""Grobe Ähnlichkeits-Blocks für den LLM — High-Recall, aber Größe gedeckelt.
|
||||
"""Coarse similarity blocks for the LLM — high recall, but size-capped.
|
||||
|
||||
Greedy: alle Paare mit Cosine ≥ `floor` nach Cosine absteigend; zwei Blocks werden nur
|
||||
verschmolzen, wenn der resultierende Block ≤ `cap` bleibt. Verhindert den Giant-Component
|
||||
(reines Threshold-Blocking verkettet sonst fast alles) und hält die LLM-Listen kurz.
|
||||
→ Liste von Blocks (Index-Listen), jeder Knoten in genau einem Block.
|
||||
Greedy: all pairs with cosine ≥ `floor` in descending cosine order; two blocks are merged
|
||||
only if the resulting block stays ≤ `cap`. Prevents the giant component (pure threshold
|
||||
blocking would otherwise chain almost everything together) and keeps the LLM lists short.
|
||||
→ list of blocks (index lists), each node in exactly one block.
|
||||
"""
|
||||
fl = EMBEDDING_BLOCK_FLOOR if floor is None else floor
|
||||
cp = EMBEDDING_BLOCK_CAP if cap is None else cap
|
||||
@@ -109,7 +109,7 @@ def capped_blocks(sims, floor: float | None = None, cap: int | None = None) -> l
|
||||
iu = np.triu_indices(n, k=1)
|
||||
s = sims[iu]
|
||||
kept = np.where(s >= fl)[0]
|
||||
# höchste Cosine zuerst → engste Paare bilden zuerst Blocks
|
||||
# highest cosine first → the tightest pairs form blocks first
|
||||
for k in kept[np.argsort(-s[kept])]:
|
||||
i, j = int(iu[0][k]), int(iu[1][k])
|
||||
ri, rj = _find(parent, i), _find(parent, j)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Atomare Datei-Writes: erst .tmp im selben Verzeichnis, dann os.replace.
|
||||
"""Atomic file writes: first a .tmp in the same directory, then os.replace.
|
||||
|
||||
Ein Crash hinterlässt höchstens eine .tmp-Datei — nie eine halb geschriebene
|
||||
Zieldatei. Die .tmp wird beim nächsten erfolgreichen Write überschrieben.
|
||||
A crash leaves at most a .tmp file behind — never a half-written target
|
||||
file. The .tmp is overwritten on the next successful write.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
1032
backend/guide.py
1032
backend/guide.py
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
"""Toleranter JSON-Parser für KI-Output — als Text oder aus Dateien.
|
||||
"""Tolerant JSON parser for AI output — from text or from files.
|
||||
|
||||
Verkraftet Code-Fences, Drumherum-Text und unescapte Anführungszeichen in
|
||||
Strings (z. B. MiniMax: "Titel „p" geändert"): das letzte `"` vor der
|
||||
Fehlerstelle wird escapet und erneut geparst.
|
||||
Copes with code fences, surrounding prose and unescaped quotes inside
|
||||
strings (e.g. MiniMax: "Title „p" changed"): the last `"` before the
|
||||
error position is escaped and parsing is retried.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -14,7 +14,7 @@ log = logging.getLogger("creator.jsonio")
|
||||
|
||||
|
||||
def parse_json_text(text: str):
|
||||
"""Parst JSON aus KI-Output; None bei nicht reparierbarem Input."""
|
||||
"""Parse JSON from AI output; None for input that can't be repaired."""
|
||||
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", (text or "").strip())
|
||||
start, end = text.find("{"), text.rfind("}")
|
||||
if start == -1 or end <= start:
|
||||
@@ -36,14 +36,14 @@ def parse_json_text(text: str):
|
||||
|
||||
|
||||
def read_json_file(path: Path):
|
||||
"""Liest eine JSON-Datei mit derselben Toleranz; None bei fehlend/ungültig."""
|
||||
"""Read a JSON file with the same tolerance; None if missing/invalid."""
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
data = parse_json_text(path.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
log.debug("JSON-Datei nicht lesbar: %s (%s)", path, e)
|
||||
log.debug("JSON file not readable: %s (%s)", path, e)
|
||||
return None
|
||||
if data is None:
|
||||
log.debug("JSON-Datei ungültig: %s", path)
|
||||
log.debug("JSON file invalid: %s", path)
|
||||
return data
|
||||
|
||||
657
backend/learning.py
Normal file
657
backend/learning.py
Normal 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 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
|
||||
|
||||
|
||||
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)
|
||||
@@ -1,657 +0,0 @@
|
||||
"""Baustein-Lernen: Vertiefung, Bausteinchat und Prüfung zu einzelnen Guide-Sections.
|
||||
|
||||
Alle Aufrufe sind interaktiv (stdout-Antwort, lane "interactive") und stateless —
|
||||
der Chat-/Prüfungs-Verlauf kommt vom Frontend, persistiert wird nur der
|
||||
Prüfungs-Zähler (DB) und die Vertiefung (DB).
|
||||
"""
|
||||
|
||||
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_baustein_huerden
|
||||
from elements import generate_element
|
||||
from jsonio import parse_json_text as _parse_json_text
|
||||
from pipeline import _prompt, _probleme_schema
|
||||
from textkit import _norm_titel
|
||||
|
||||
log = logging.getLogger("creator.lernen")
|
||||
|
||||
# Lernstufen je Baustein — relativ zum cap (Floor in % des Maximal-Scores):
|
||||
# grün=Anfänger 20% · blau=Fortgeschritten 40% · lila=Experte 60% · gold=Meister 100%.
|
||||
# Prüfungsform ist immer zufällig (5 Formen); der cap skaliert mit der Stoffmenge.
|
||||
STUFEN = (("anfaenger", 0.2), ("fortgeschritten", 0.4), ("experte", 0.6), ("meister", 1.0))
|
||||
|
||||
|
||||
PUNKT_BASIS = 25 # Punkte je Subbaustein. Meister-cap = (alle Subs) × 25.
|
||||
|
||||
|
||||
def _ebenen(n_je_ebene: dict[int, int]) -> list[int]:
|
||||
return [n_je_ebene.get(k, 0) for k in (1, 2, 3, 4)]
|
||||
|
||||
|
||||
def schwellen(n_je_ebene: dict[int, int]) -> list[int]:
|
||||
"""Kumulative Ebenen-Schwellen [S_1, S_2, S_3, S_4] = (n_1+…+n_k) × 25.
|
||||
S_k ist der Score, ab dem Ebene k+1 (E→M→S→F) freigeschaltet ist; S_4 = cap_final."""
|
||||
out, akk = [], 0
|
||||
for n in _ebenen(n_je_ebene):
|
||||
akk += n
|
||||
out.append(akk * PUNKT_BASIS)
|
||||
return out
|
||||
|
||||
|
||||
def cap_final(n_je_ebene: dict[int, int]) -> int:
|
||||
"""Maximal-Score (Meister) = alle Subbausteine × 25."""
|
||||
return schwellen(n_je_ebene)[-1]
|
||||
|
||||
|
||||
def freie_ebene(score: int, n_je_ebene: dict[int, int]) -> int:
|
||||
"""Höchste freigeschaltete Sub-Ebene 1–4. Ebene k+1 frei, sobald score ≥ S_k.
|
||||
Leere Ebenen (n_k=0) werden automatisch übersprungen (S_k == S_{k-1})."""
|
||||
s = schwellen(n_je_ebene)
|
||||
e = 1
|
||||
for k in range(3): # S_1..S_3 schalten Ebene 2..4 frei
|
||||
if score >= s[k]:
|
||||
e = k + 2
|
||||
return e
|
||||
|
||||
|
||||
def cap_aktuell(score: int, n_je_ebene: dict[int, int]) -> int:
|
||||
"""Erreichbarer cap der aktuell freigeschalteten Ebene = freigeschaltete Subs × 25."""
|
||||
return schwellen(n_je_ebene)[freie_ebene(score, n_je_ebene) - 1]
|
||||
|
||||
|
||||
def _schwelle(p: float, cap: int) -> int:
|
||||
return round(p * cap)
|
||||
|
||||
|
||||
def stufe_aus_score(score: int, cap_final_wert: int) -> str | None:
|
||||
"""Höchste erreichte Lernstufe (None unter 20 %), relativ zum cap_final."""
|
||||
erreicht = None
|
||||
for key, p in STUFEN:
|
||||
if score >= _schwelle(p, cap_final_wert):
|
||||
erreicht = key
|
||||
return erreicht
|
||||
|
||||
|
||||
def progressiver_malus(basis: int, cap_akt: int) -> int:
|
||||
"""Fehler-Strafe nach Fortschritt in der aktuellen Ebene (gegen 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
|
||||
PRUEFUNG_TIMEOUT = 120 # kurze JSON-Turns; deckelt die Serien-Latenz pro Prüfungs-Schritt
|
||||
GRUENDLICH_TIMEOUT = 600 # „Gründlich prüfen": starkes Modell (role guide) braucht länger
|
||||
KRITIK_MAX_RUNDEN = 2 # Generator → Kritiker → ggf. Neu, höchstens so oft
|
||||
|
||||
# Fragetypen für Active Recall — pro Frage einer, zufällig gewählt. Schafft Vielfalt.
|
||||
FRAGETYPEN = {
|
||||
"abruf": "Free Recall: Lass den Lerner die Kernidee frei aus dem Kopf erklären (offene Verständnisfrage).",
|
||||
"punkt": "Cued Recall: Frag gezielt EIN konkretes Detail oder eine Abgrenzung ab.",
|
||||
"warum": "Warum-Frage: Frag nach dem Grund/Mechanismus — warum funktioniert oder gilt das so?",
|
||||
"anwendung": "Anwendung: Lass das Konzept auf EIN kurzes, neues Beispiel/Szenario anwenden.",
|
||||
"pruefen": "Bei Code-/Tool-Themen: kleinen Schnipsel zeigen — Output vorhersagen ODER den Fehler finden. Kein Code-Thema → stattdessen eine Anwendungsfrage.",
|
||||
}
|
||||
|
||||
|
||||
# Antwort-Niveau → Basis-Punkte (neue 25er-Skala). „kaum" = −1 ist nur das Signal für den
|
||||
# progressiven Malus (echter Wert kommt aus progressiver_malus). Positive Werte werden bei
|
||||
# Streak hochmoduliert und auf [10, 40] geklemmt.
|
||||
NIVEAUS = {
|
||||
"unbeantwortbar": 0, # Frage selbst kaputt → keine Änderung
|
||||
"kaum": -1, # < 25 % richtig → Malus
|
||||
"teilweise": 0, # 25–49 % → neutral
|
||||
"solide": 16, # 50–74 %
|
||||
"stark": 24, # 75–99 % (Quiz/Lücke-Treffer)
|
||||
"komplett": 30, # 100 % (nur freies Erklären erreichbar)
|
||||
}
|
||||
|
||||
# Reihenfolge schwach→stark (für den Nachfrage-Deckel).
|
||||
_NIVEAU_RANG = ("kaum", "teilweise", "solide", "stark", "komplett")
|
||||
|
||||
|
||||
def deckel_nachfrage(niveau: str, nachgefragt: bool) -> str:
|
||||
"""Mit Nachfrage (Hinweis erhalten) höchstens „solide" — kein Voll-Score erschummeln."""
|
||||
if nachgefragt and niveau in ("stark", "komplett"):
|
||||
return "solide"
|
||||
return niveau
|
||||
|
||||
|
||||
def streak_punkte(basis_delta: int, streak_basis: int) -> int:
|
||||
"""Positives Basis-Delta mit Streak hochmodulieren, auf [10, 40] geklemmt."""
|
||||
faktor = min(1.33, 1 + 0.066 * min(streak_basis, 5))
|
||||
return max(10, min(40, round(basis_delta * faktor)))
|
||||
|
||||
|
||||
def punkte_delta(niveau: str, streak_basis: int, basis: int, cap_akt: int) -> tuple[int, int]:
|
||||
"""Antwort-Niveau → (Punkt-Delta, neue Streak). Positiv: streak-moduliert, Streak +1.
|
||||
Neutral (0): keine Änderung, Streak bleibt. Negativ: progressiver Malus, Streak-Reset 0."""
|
||||
basis_delta = NIVEAUS.get(niveau, 0)
|
||||
if basis_delta > 0:
|
||||
return streak_punkte(basis_delta, streak_basis), streak_basis + 1
|
||||
if basis_delta == 0:
|
||||
return 0, streak_basis
|
||||
return progressiver_malus(basis, cap_akt), 0
|
||||
|
||||
|
||||
def score_berechnen(basis: int, delta: int, floor: int, cap_akt: int, cap_fin: int) -> int:
|
||||
"""Neuer Score · driftfrei aus der Basis. Klemmt nach oben gegen `cap_akt` (Deckel der
|
||||
aktuell freigeschalteten Ebene) und nach unten gegen `floor`. Eingefroren NUR am
|
||||
absoluten Maximum (`basis ≥ cap_fin`) — sonst würde an jeder Ebenen-Schwelle blockiert."""
|
||||
if basis >= cap_fin:
|
||||
return basis
|
||||
return max(floor, min(cap_akt, basis + delta))
|
||||
|
||||
|
||||
def floor_aus_score(basis: int, cap_fin: int, s_schwellen: list[int]) -> int:
|
||||
"""Untergrenze (kein Rückfall): höchste erreichte Lernstufen-Schwelle (über cap_final)
|
||||
UND höchste erreichte Ebenen-Freischalt-Schwelle S_k. max beider Achsen."""
|
||||
floor = 0
|
||||
for _, p in STUFEN:
|
||||
s = _schwelle(p, cap_fin)
|
||||
if basis >= s:
|
||||
floor = max(floor, s)
|
||||
for s in s_schwellen:
|
||||
if basis >= s:
|
||||
floor = max(floor, s)
|
||||
return floor
|
||||
|
||||
|
||||
def _transcript(messages: list[dict]) -> str:
|
||||
return "\n".join(
|
||||
f"{'Nutzer' if m.get('role') == 'user' else 'Assistent'}: {m.get('content', '')}"
|
||||
for m in messages
|
||||
) or "(leer)"
|
||||
|
||||
|
||||
async def baustein_chat(topic: str, baustein: str, section: str, kompakt: str | None, messages: list[dict], provider: str = DEFAULT_PROVIDER) -> str:
|
||||
try:
|
||||
prompt = _prompt(
|
||||
"Baustein-Chat",
|
||||
topic=topic, baustein=baustein,
|
||||
section_block=section.strip() or "(keine Guide-Fassung übergeben)",
|
||||
kompakt_block=(kompakt or "").strip() or "(keine)",
|
||||
transcript=_transcript(messages),
|
||||
)
|
||||
returncode, stdout, _ = await run_agent(
|
||||
"bausteinchat-" + str(uuid.uuid4()), prompt, CHAT_TIMEOUT,
|
||||
provider=provider, role="fast", capabilities="none", lane="interactive",
|
||||
)
|
||||
if returncode != 0:
|
||||
return "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut."
|
||||
reply = stdout.strip()
|
||||
return reply or "Entschuldigung, ich habe keine Antwort erhalten."
|
||||
except Exception:
|
||||
log.warning("[%s] Baustein-Chat fehlgeschlagen (%s)", topic, baustein, exc_info=True)
|
||||
return "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut."
|
||||
|
||||
|
||||
def _frage_schema(data) -> dict | None:
|
||||
"""{"frage": str} · sonst None."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
frage = str(data.get("frage", "")).strip()
|
||||
return {"frage": frage} if frage else None
|
||||
|
||||
|
||||
def _bewertung_schema(data) -> dict | None:
|
||||
"""{"feedback": str, "niveau": ∈ NIVEAUS} · sonst None."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
feedback = str(data.get("feedback", "")).strip()
|
||||
niveau = data.get("niveau")
|
||||
if not feedback or niveau not in NIVEAUS:
|
||||
return None
|
||||
return {"feedback": feedback, "niveau": niveau}
|
||||
|
||||
|
||||
async def _gen_call(name: str, role: str, schema, provider: str, timeout: int = PRUEFUNG_TIMEOUT, lane: str = "interactive", **kwargs) -> dict | None:
|
||||
"""Generator-Agent: Template füllen, laufen lassen, per schema parsen · None bei Fehler.
|
||||
lane="batch" für Hintergrund (Vorladen, Genau-Bewertung) → eigene Slot-Schlange."""
|
||||
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 _kritik_call(name: str, provider: str, role: str = "judge", timeout: int = PRUEFUNG_TIMEOUT, lane: str = "interactive", **kwargs) -> list[str]:
|
||||
"""Kritiker-Agent (Default role judge): leere Liste = in Ordnung. Fail-open: Ausfall des
|
||||
Kritikers darf den Turn nicht blockieren, also dann ebenfalls leere Liste."""
|
||||
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 _probleme_schema(_parse_json_text(stdout)) or []
|
||||
|
||||
|
||||
def _kritik_block(vorversion: str, probleme: list[str]) -> str:
|
||||
punkte = "\n".join(f"- {p}" for p in probleme)
|
||||
return (
|
||||
f"Deine vorige Fassung war:\n«{vorversion}»\n\n"
|
||||
f"Der Prüfer bemängelt:\n{punkte}\n\nBehebe diese Punkte."
|
||||
)
|
||||
|
||||
|
||||
def _bewertung_text(bew: dict) -> str:
|
||||
return f"Niveau: {bew['niveau']}\nFeedback: {bew['feedback']}"
|
||||
|
||||
|
||||
# Deterministischer Guard gegen Doppelfragen — der KI-Kritiker übersieht „…, und welchen…".
|
||||
_FRAGEWORT = r"(was|welche[rsnm]?|wie|wieso|warum|wofür|wozu|wann|wo|wer|wem|wen|nenne)"
|
||||
_DOPPEL_RE = re.compile(r"[,;]?\s+(und|sowie|außerdem|bzw\.?)\s+" + _FRAGEWORT + r"\b", re.IGNORECASE)
|
||||
|
||||
|
||||
def _doppelfrage_mangel(frage: str) -> str | None:
|
||||
"""Erkennt zwei verkettete Fragen. None = ok. Flaggt NUR 'und/sowie' + Fragewort."""
|
||||
if frage.count("?") > 1:
|
||||
return "Mehr als ein Fragezeichen — stelle GENAU EINE Frage."
|
||||
if _DOPPEL_RE.search(frage):
|
||||
return "Zwei Fragen mit 'und'/'sowie' verkettet — stelle GENAU EINE Frage, eine Sache."
|
||||
return None
|
||||
|
||||
|
||||
async def _frage_mit_kritik(
|
||||
topic: str, baustein: str, section_block: str, kompakt_block: str,
|
||||
transcript: str, vermeide_block: str, typ_block: str, fokus_block: str,
|
||||
niveau_block: str, provider: str,
|
||||
) -> str | None:
|
||||
"""Frage generieren, vom Kritiker prüfen lassen, bei Mängeln neu (max KRITIK_MAX_RUNDEN)."""
|
||||
kritik_block = "(keine)"
|
||||
frage = None
|
||||
for _ in range(KRITIK_MAX_RUNDEN):
|
||||
data = await _gen_call(
|
||||
"Baustein-Frage", "guide", _frage_schema, provider, lane="batch",
|
||||
topic=topic, baustein=baustein, section_block=section_block,
|
||||
kompakt_block=kompakt_block, transcript=transcript, vermeide_block=vermeide_block,
|
||||
typ_block=typ_block, fokus_block=fokus_block, niveau_block=niveau_block, kritik_block=kritik_block,
|
||||
)
|
||||
if data is None:
|
||||
return None
|
||||
frage = data["frage"]
|
||||
probleme = await _kritik_call(
|
||||
"Baustein-Frage-Kritik", provider, role="guide", lane="batch", # starke KI prüft die Regeln
|
||||
topic=topic, baustein=baustein, section_block=section_block,
|
||||
kompakt_block=kompakt_block, transcript=transcript, vermeide_block=vermeide_block,
|
||||
typ_block=typ_block, fokus_block=fokus_block, frage=frage,
|
||||
)
|
||||
hart = _doppelfrage_mangel(frage) # erzwingt Neugenerierung, auch wenn der KI-Kritiker es übersah
|
||||
if hart:
|
||||
probleme = [hart, *(probleme or [])]
|
||||
if not probleme:
|
||||
return frage
|
||||
kritik_block = _kritik_block(frage, probleme)
|
||||
return frage # best-effort nach der letzten Runde
|
||||
|
||||
|
||||
async def _bewertung_mit_kritik(
|
||||
topic: str, baustein: str, section_block: str, kompakt_block: str,
|
||||
frage: str, transcript: str, begruendung_block: str, provider: str, role: str = "judge",
|
||||
) -> dict | None:
|
||||
"""Antwort bewerten (Niveau), vom Kritiker prüfen lassen, bei Fehlurteil neu.
|
||||
|
||||
`frage` ankert die geprüfte Frage; der Dialog (transcript) liefert Antwort + Diskussion.
|
||||
`begruendung_block` = optionale Unzufriedenheit des Lerners (nur bei „Gründlich prüfen").
|
||||
`role` = "judge" (schnell) oder "guide" (gründlich, starkes Modell mit Thinking).
|
||||
"""
|
||||
timeout = GRUENDLICH_TIMEOUT if role == "guide" else PRUEFUNG_TIMEOUT
|
||||
# Gründlich (role guide) = Nutzer wartet → interaktiv. Hintergrund-Genau (judge) → batch.
|
||||
lane = "interactive" if role == "guide" else "batch"
|
||||
kritik_block = "(keine)"
|
||||
bew = None
|
||||
for _ in range(KRITIK_MAX_RUNDEN):
|
||||
bew = await _gen_call(
|
||||
"Baustein-Bewertung", role, _bewertung_schema, provider, timeout, lane=lane,
|
||||
topic=topic, baustein=baustein, section_block=section_block,
|
||||
kompakt_block=kompakt_block, frage=frage, transcript=transcript,
|
||||
begruendung_block=begruendung_block, kritik_block=kritik_block,
|
||||
)
|
||||
if bew is None:
|
||||
return None
|
||||
probleme = await _kritik_call(
|
||||
"Baustein-Bewertung-Kritik", provider, role=role, timeout=timeout, lane=lane,
|
||||
topic=topic, baustein=baustein, section_block=section_block,
|
||||
kompakt_block=kompakt_block, frage=frage, transcript=transcript,
|
||||
bewertung_block=_bewertung_text(bew),
|
||||
)
|
||||
if not probleme:
|
||||
return bew
|
||||
kritik_block = _kritik_block(_bewertung_text(bew), probleme)
|
||||
return bew # best-effort nach der letzten Runde
|
||||
|
||||
|
||||
def _bloecke(section: str, kompakt: str | None) -> tuple[str, str]:
|
||||
return (
|
||||
section.strip() or "(keine Guide-Fassung übergeben)",
|
||||
(kompakt or "").strip() or "(keine)",
|
||||
)
|
||||
|
||||
|
||||
def _vermeide_block(vermeide: list[str] | None) -> str:
|
||||
eintraege = [f.strip() for f in (vermeide or []) if f and f.strip()]
|
||||
return "\n".join(f"- {f}" for f in eintraege) or "(keine)"
|
||||
|
||||
|
||||
# Lerner-Niveau (aus dem Score abgeleitet) → Adressaten-Rolle für die Frage. So entsteht die
|
||||
# Schwierigkeit: nicht „extra schwer machen", sondern „für einen Anfänger/Experten fragen".
|
||||
# Je Stufe: Adressaten-Rolle + kognitive Anforderung (Bloom) + „frag so"-Cue. Ohne explizite Stufe nimmt
|
||||
# das Modell den leichten Pfad (bloßer Abruf) — die Cues heben höhere Niveaus auf Anwenden/Analysieren/Transfer.
|
||||
NIVEAU_ROLLE = {
|
||||
"anfaenger": "Der Lerner ist ANFÄNGER. Kognitiv: ERINNERN/VERSTEHEN. Frage nach dem Grundverständnis — das Kernkonzept, einfach und direkt.",
|
||||
"fortgeschritten": "Der Lerner ist FORTGESCHRITTEN. Kognitiv: ANWENDEN. Stelle eine kleine konkrete Situation und lass das Konzept darauf anwenden — frage nicht bloß die Definition ab.",
|
||||
"experte": "Der Lerner ist EXPERTE. Kognitiv: ANALYSIEREN. Lass abgrenzen/vergleichen, einen Sonderfall einordnen oder eine typische Tücke (Hürde) aufdecken — nicht das Lehrbuch-Wissen abfragen.",
|
||||
"meister": "Der Lerner ist auf MEISTER-Niveau. Kognitiv: BEWERTEN/TRANSFER. Lass das Konzept auf ein NEUES Problem übertragen, eine Entscheidung begründen oder einen Trade-off abwägen.",
|
||||
}
|
||||
|
||||
|
||||
def _niveau_block(niveau: str | None) -> str:
|
||||
return NIVEAU_ROLLE.get(niveau or "", NIVEAU_ROLLE["anfaenger"])
|
||||
|
||||
|
||||
async def pruefung_frage(
|
||||
topic: str, baustein: str, section: str, kompakt: str | None,
|
||||
messages: list[dict], subbausteine: list[str] | None = None,
|
||||
vermeide: list[str] | None = None, niveau: str = "anfaenger", provider: str = DEFAULT_PROVIDER,
|
||||
) -> str | None:
|
||||
"""Aktion 'frage': eine Frage generieren — zufälliger Typ zu einem zufälligen Subbaustein,
|
||||
in der Adressaten-Rolle des Niveaus, dann Kritiker (sequenziell) · None bei Fehler."""
|
||||
try:
|
||||
section_block, kompakt_block = _bloecke(section, kompakt)
|
||||
transcript = _transcript(messages) if messages else "(leer)"
|
||||
typ_block = FRAGETYPEN[random.choice(list(FRAGETYPEN))]
|
||||
subs = [s for s in (subbausteine or []) if s and s.strip()]
|
||||
fokus = random.choice(subs) if subs else ""
|
||||
fokus_block = (
|
||||
f"Konzentriere die Frage auf diesen Subbaustein: „{fokus}\"" if fokus
|
||||
else "(ganzer Baustein — kein bestimmter Subbaustein)"
|
||||
)
|
||||
return await _frage_mit_kritik(
|
||||
topic, baustein, section_block, kompakt_block, transcript,
|
||||
_vermeide_block(vermeide), typ_block, fokus_block, _niveau_block(niveau), provider,
|
||||
)
|
||||
except Exception:
|
||||
log.warning("[%s] Frage fehlgeschlagen (%s)", topic, baustein, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def pruefung_frage_variante(
|
||||
topic: str, baustein: str, section: str, kompakt: str | None,
|
||||
muster: str, niveau: str = "anfaenger", provider: str = DEFAULT_PROVIDER,
|
||||
) -> str | None:
|
||||
"""Aktion 'frage' mit Muster: aus einem vordefinierten Muster eine konkrete Frage in der
|
||||
Adressaten-Rolle des Niveaus formulieren. Kein Kritiker (Muster ist build-geprüft).
|
||||
Stil-Guard bleibt als billiger Schutz gegen Doppelfragen · None bei Fehler."""
|
||||
try:
|
||||
section_block, kompakt_block = _bloecke(section, kompakt)
|
||||
data = await _gen_call(
|
||||
"Baustein-Frage-Variante", "guide", _frage_schema, provider, lane="batch",
|
||||
topic=topic, baustein=baustein, section_block=section_block,
|
||||
kompakt_block=kompakt_block, muster=muster, niveau_block=_niveau_block(niveau),
|
||||
)
|
||||
if data is None:
|
||||
return None
|
||||
return data["frage"]
|
||||
except Exception:
|
||||
log.warning("[%s] Frage-Variante fehlgeschlagen (%s)", topic, baustein, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _optionen_schema(opts) -> list[dict] | None:
|
||||
"""[{text, korrekt}]×4 → validierte Liste · sonst None."""
|
||||
if not isinstance(opts, list) or len(opts) != 4:
|
||||
return None
|
||||
out = []
|
||||
for o in opts:
|
||||
if not isinstance(o, dict):
|
||||
return None
|
||||
text = str(o.get("text", "")).strip()
|
||||
korrekt = o.get("korrekt")
|
||||
if not text or not isinstance(korrekt, bool):
|
||||
return None
|
||||
out.append({"text": text, "korrekt": korrekt})
|
||||
return out
|
||||
|
||||
|
||||
def _quiz_schema(data) -> dict | None:
|
||||
"""{"frage": str, "optionen": [{text, korrekt}]×4} → validiert · sonst None.
|
||||
Single-Choice: genau 1 richtig. Die Schwierigkeit steckt im Niveau, nicht in der Anzahl."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
frage = str(data.get("frage", "")).strip()
|
||||
out = _optionen_schema(data.get("optionen"))
|
||||
if not frage or out is None:
|
||||
return None
|
||||
if sum(o["korrekt"] for o in out) != 1:
|
||||
return None
|
||||
return {"frage": frage, "optionen": out}
|
||||
|
||||
|
||||
def _lueckwahl_schema(data) -> dict | None:
|
||||
"""{"satz": str (mit ___), "optionen": [{text, korrekt}]×4} → genau 1 korrekt · sonst None."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
satz = str(data.get("satz", "")).strip()
|
||||
out = _optionen_schema(data.get("optionen"))
|
||||
if not satz or "___" not in satz or out is None or sum(o["korrekt"] for o in out) != 1:
|
||||
return None
|
||||
return {"satz": satz, "optionen": out}
|
||||
|
||||
|
||||
async def huerden_distraktor_block(topic: str, baustein: str) -> str:
|
||||
"""Typische Irrtümer (Fakten-Hürden) des Bausteins als Distraktor-Quelle für Quiz/Lückenwahl.
|
||||
Leer, wenn keine vorhanden (Altbestand) → der Prompt-Platzhalter verschwindet rückstandslos."""
|
||||
try:
|
||||
huerden = await get_baustein_huerden(topic, _norm_titel(baustein))
|
||||
except Exception:
|
||||
return ""
|
||||
if not huerden:
|
||||
return ""
|
||||
zeilen = "\n".join(f"- {h}" for h in huerden[:8])
|
||||
return ("TYPISCHE IRRTÜMER zu diesem Baustein (nutze sie als Distraktoren, wenn sie zur Frage passen):\n"
|
||||
+ zeilen + "\n")
|
||||
|
||||
|
||||
async def quiz_generieren(
|
||||
topic: str, baustein: str, section: str, kompakt: str | None,
|
||||
muster: str, niveau: str = "anfaenger", provider: str = DEFAULT_PROVIDER,
|
||||
distraktor_block: str = "",
|
||||
) -> dict | None:
|
||||
"""Aus einem Muster eine Single-Choice-Frage (genau 1 richtig), im Anspruch des Niveaus.
|
||||
Starkes Modell (role guide) für korrekte Flags. → {frage, optionen} · None bei Fehler.
|
||||
distraktor_block: optionale typische Irrtümer (aus den Fakten-Hürden) als Distraktor-Quelle."""
|
||||
try:
|
||||
section_block, kompakt_block = _bloecke(section, kompakt)
|
||||
return await _gen_call(
|
||||
"Baustein-Quiz", "guide", _quiz_schema, provider, lane="batch",
|
||||
topic=topic, baustein=baustein, section_block=section_block,
|
||||
kompakt_block=kompakt_block, muster=muster, niveau_block=_niveau_block(niveau),
|
||||
distraktor_block=distraktor_block,
|
||||
)
|
||||
except Exception:
|
||||
log.warning("[%s] Quiz-Frage fehlgeschlagen (%s)", topic, baustein, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def lueckwahl_generieren(
|
||||
topic: str, baustein: str, section: str, kompakt: str | None,
|
||||
muster: str, niveau: str = "anfaenger", provider: str = DEFAULT_PROVIDER,
|
||||
distraktor_block: str = "",
|
||||
) -> dict | None:
|
||||
"""Lückentext mit Auswahl: Satz mit ___ + 4 Begriffe, genau 1 richtig — im Anspruch des Niveaus.
|
||||
→ {satz, optionen:[{text,korrekt}]} · None bei Fehler.
|
||||
distraktor_block: optionale typische Irrtümer (aus den Fakten-Hürden) als Distraktor-Quelle."""
|
||||
try:
|
||||
section_block, kompakt_block = _bloecke(section, kompakt)
|
||||
return await _gen_call(
|
||||
"Baustein-Lueckwahl", "guide", _lueckwahl_schema, provider, lane="batch",
|
||||
topic=topic, baustein=baustein, section_block=section_block,
|
||||
kompakt_block=kompakt_block, muster=muster, niveau_block=_niveau_block(niveau),
|
||||
distraktor_block=distraktor_block,
|
||||
)
|
||||
except Exception:
|
||||
log.warning("[%s] Lückentext-Auswahl fehlgeschlagen (%s)", topic, baustein, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _lueck_schema(data) -> dict | None:
|
||||
"""{"satz": str (mit ___), "loesung": str, "alternativen": [str]} → validiert · sonst None."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
satz = str(data.get("satz", "")).strip()
|
||||
loesung = str(data.get("loesung", "")).strip()
|
||||
alt = data.get("alternativen", [])
|
||||
if not satz or "___" not in satz or not loesung:
|
||||
return None
|
||||
alternativen = [str(a).strip() for a in alt if isinstance(a, str) and str(a).strip()] if isinstance(alt, list) else []
|
||||
return {"satz": satz, "loesung": loesung, "alternativen": alternativen}
|
||||
|
||||
|
||||
async def lueckentext_generieren(
|
||||
topic: str, baustein: str, section: str, kompakt: str | None,
|
||||
muster: str, niveau: str = "anfaenger", provider: str = DEFAULT_PROVIDER,
|
||||
) -> dict | None:
|
||||
"""Aus einem Muster eine Lückentext-Aufgabe (Satz mit ___, Lösung, Synonyme), im Anspruch
|
||||
des Niveaus. → {satz, loesung, alternativen} · None bei Fehler."""
|
||||
try:
|
||||
section_block, kompakt_block = _bloecke(section, kompakt)
|
||||
return await _gen_call(
|
||||
"Baustein-Lueckentext", "guide", _lueck_schema, provider, lane="batch",
|
||||
topic=topic, baustein=baustein, section_block=section_block,
|
||||
kompakt_block=kompakt_block, muster=muster, niveau_block=_niveau_block(niveau),
|
||||
)
|
||||
except Exception:
|
||||
log.warning("[%s] Lückentext-Frage fehlgeschlagen (%s)", topic, baustein, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _norm_begriff(t: str) -> str:
|
||||
return re.sub(r"[^\wäöüß]", "", str(t or "").lower())
|
||||
|
||||
|
||||
def _richtig_schema(data) -> dict | None:
|
||||
if not isinstance(data, dict) or not isinstance(data.get("richtig"), bool):
|
||||
return None
|
||||
return {"richtig": data["richtig"]}
|
||||
|
||||
|
||||
async def lueckentext_pruefen(
|
||||
topic: str, baustein: str, satz: str, loesung: str, alternativen: list[str],
|
||||
eingabe: str, provider: str = DEFAULT_PROVIDER,
|
||||
) -> bool:
|
||||
"""Lückentext-Antwort prüfen: erst normalisierter Vergleich (Lösung + Synonyme),
|
||||
sonst 1 KI-Call für Synonym-Toleranz. Fail-open zu RICHTIG nur bei exaktem Match."""
|
||||
if not eingabe.strip():
|
||||
return False
|
||||
norm = _norm_begriff(eingabe)
|
||||
if norm and norm in {_norm_begriff(loesung), *(_norm_begriff(a) for a in alternativen)}:
|
||||
return True
|
||||
data = await _gen_call(
|
||||
"Baustein-Lueckentext-Pruefung", "fast", _richtig_schema, provider,
|
||||
topic=topic, baustein=baustein, satz=satz, loesung=loesung,
|
||||
alternativen=", ".join(alternativen) or "(keine)", eingabe=eingabe,
|
||||
)
|
||||
return bool(data and data["richtig"])
|
||||
|
||||
|
||||
async def pruefung_bewertung_schnell(
|
||||
topic: str, baustein: str, section: str, kompakt: str | None,
|
||||
frage: str, messages: list[dict], provider: str = DEFAULT_PROVIDER,
|
||||
) -> dict | None:
|
||||
"""Aktion 'antwort' (Agent 1, schnell): nur Evaluator, kein Kritiker. → {feedback, niveau}."""
|
||||
try:
|
||||
section_block, kompakt_block = _bloecke(section, kompakt)
|
||||
transcript = _transcript(messages) if messages else "(leer)"
|
||||
return await _gen_call(
|
||||
"Baustein-Bewertung", "judge", _bewertung_schema, provider,
|
||||
topic=topic, baustein=baustein, section_block=section_block, kompakt_block=kompakt_block,
|
||||
frage=frage.strip() or "(keine Frage übergeben)", transcript=transcript,
|
||||
begruendung_block="(keine)", kritik_block="(keine)",
|
||||
)
|
||||
except Exception:
|
||||
log.warning("[%s] Schnell-Bewertung fehlgeschlagen (%s)", topic, baustein, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def pruefung_bewertung(
|
||||
topic: str, baustein: str, section: str, kompakt: str | None,
|
||||
frage: str, messages: list[dict], provider: str = DEFAULT_PROVIDER,
|
||||
role: str = "judge", begruendung: str = "",
|
||||
) -> dict | None:
|
||||
"""Aktion 'antwort_pruefen' (Agent 2, genau): Evaluator + Kritiker. → {feedback, niveau}.
|
||||
|
||||
`role` = "guide" für „Gründlich prüfen" (starkes Modell). `begruendung` = optionale
|
||||
Unzufriedenheit des Lerners mit einer früheren Bewertung.
|
||||
"""
|
||||
try:
|
||||
section_block, kompakt_block = _bloecke(section, kompakt)
|
||||
transcript = _transcript(messages) if messages else "(leer)"
|
||||
begruendung_block = begruendung.strip() or "(keine)"
|
||||
return await _bewertung_mit_kritik(
|
||||
topic, baustein, section_block, kompakt_block,
|
||||
frage.strip() or "(keine Frage übergeben)", transcript, begruendung_block, provider, role,
|
||||
)
|
||||
except Exception:
|
||||
log.warning("[%s] Bewertung fehlgeschlagen (%s)", topic, baustein, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def baustein_diskussion(
|
||||
topic: str, baustein: str, section: str, kompakt: str | None,
|
||||
frage: str, letzte_bewertung: str | None, messages: list[dict], provider: str = DEFAULT_PROVIDER,
|
||||
) -> str | None:
|
||||
"""Aktion 'diskussion': Tutor erklärt/diskutiert die Frage oder eine Bewertung.
|
||||
|
||||
Kein Bewerten, kein Kritiker — hier ist der Mensch der Prüfer. None bei Fehler.
|
||||
"""
|
||||
try:
|
||||
section_block, kompakt_block = _bloecke(section, kompakt)
|
||||
prompt = _prompt(
|
||||
"Baustein-Pruefung-Diskussion",
|
||||
topic=topic, baustein=baustein,
|
||||
section_block=section_block, kompakt_block=kompakt_block,
|
||||
frage=frage.strip() or "(keine Frage übergeben)",
|
||||
letzte_bewertung_block=(letzte_bewertung or "").strip() or "(noch keine)",
|
||||
transcript=_transcript(messages) if messages else "(leer)",
|
||||
)
|
||||
returncode, stdout, _ = await run_agent(
|
||||
"pruefungdiskussion-" + 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] Prüfungs-Diskussion fehlgeschlagen (%s)", topic, baustein, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def baustein_element_anlegen(topic: str, baustein: str, section: str, provider: str = DEFAULT_PROVIDER) -> None:
|
||||
"""Hintergrund-Task nach dem Absolvieren: Baustein als Element anlegen.
|
||||
|
||||
Dedup über normalisierte Titel — existiert schon ein Element zum Baustein,
|
||||
passiert nichts. Darf nie eine Exception nach außen werfen.
|
||||
"""
|
||||
try:
|
||||
vorhanden = {_norm_titel(e["title"]) for e in await list_elements(topic)}
|
||||
if _norm_titel(baustein) in vorhanden:
|
||||
return
|
||||
fields = await generate_element(topic, hint=baustein, provider=provider, extra_context=section)
|
||||
if _norm_titel(fields["title"]) in vorhanden:
|
||||
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] Baustein als Element angelegt: %s", topic, fields["title"])
|
||||
except Exception:
|
||||
log.warning("[%s] Element-Anlage nach Prüfung fehlgeschlagen (%s)", topic, baustein, exc_info=True)
|
||||
@@ -1,115 +0,0 @@
|
||||
"""Deterministisches Lesbarkeits-Gate für Guide-Sections.
|
||||
|
||||
Ein kleines deutsches Komplexitäts-Modell (DistilBERT, GermEval 2022, Skala 1–7)
|
||||
bewertet die Verständlichkeit der Fließtext-Prosa. guide.py meldet zu schwere
|
||||
Sections in die bestehende Lese-Prüfungs-/Überarbeitungs-Schleife — kein Prompt,
|
||||
kein Raten.
|
||||
|
||||
Optional: fehlen `transformers`/`torch` oder lädt das Modell nicht, ist das Gate
|
||||
stumm deaktiviert (das Backend läuft unverändert weiter). CPU genügt; der Aufrufer
|
||||
wrappt die Bewertung in `asyncio.to_thread` (blockierende Modell-Inferenz).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from config import (
|
||||
LESBARKEIT_AKTIV, LESBARKEIT_HART, LESBARKEIT_HART_ANTEIL, LESBARKEIT_MAX, LESBARKEIT_MODELL,
|
||||
)
|
||||
|
||||
log = logging.getLogger("creator.lesbarkeit")
|
||||
|
||||
_modell_cache = None # (tokenizer, model, torch) — Singleton
|
||||
_ladeversuch = False # schon versucht zu laden?
|
||||
|
||||
# Markup raus → reiner Fließtext (Code zählt nicht zur Lesbarkeit).
|
||||
_CODE_FENCE = re.compile(r"```.*?```", re.DOTALL)
|
||||
_COMMENT = re.compile(r"<!--.*?-->", re.DOTALL)
|
||||
_INLINE_CODE = re.compile(r"`[^`]*`")
|
||||
_LINK = re.compile(r"\[([^\]]*)\]\([^)]*\)")
|
||||
_MD_MARK = re.compile(r"^[ \t]*([#>]+|[-*+]\s)|[*_~|]", re.MULTILINE)
|
||||
_WS = re.compile(r"\s+")
|
||||
_SATZ = re.compile(r"(?<=[.!?])\s+")
|
||||
|
||||
|
||||
def _modell():
|
||||
"""Lädt das Modell einmalig. None = Gate aus (deaktiviert oder Lade-Fehler)."""
|
||||
global _modell_cache, _ladeversuch
|
||||
if _ladeversuch:
|
||||
return _modell_cache
|
||||
_ladeversuch = True
|
||||
if not LESBARKEIT_AKTIV:
|
||||
return None
|
||||
try:
|
||||
import torch
|
||||
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
||||
tok = AutoTokenizer.from_pretrained(LESBARKEIT_MODELL)
|
||||
model = AutoModelForSequenceClassification.from_pretrained(LESBARKEIT_MODELL)
|
||||
model.eval()
|
||||
_modell_cache = (tok, model, torch)
|
||||
log.info("Lesbarkeits-Modell geladen: %s (num_labels=%d)", LESBARKEIT_MODELL, model.config.num_labels)
|
||||
except Exception as e:
|
||||
log.warning("Lesbarkeits-Gate deaktiviert (Modell nicht ladbar): %s", e)
|
||||
_modell_cache = None
|
||||
return _modell_cache
|
||||
|
||||
|
||||
def _prosa(md: str) -> str:
|
||||
"""Markdown/Code strippen → reiner Fließtext für die Bewertung."""
|
||||
t = _CODE_FENCE.sub(" ", md)
|
||||
t = _COMMENT.sub(" ", t)
|
||||
t = _INLINE_CODE.sub(" ", t)
|
||||
t = _LINK.sub(r"\1", t)
|
||||
t = _MD_MARK.sub(" ", t)
|
||||
return _WS.sub(" ", t).strip()
|
||||
|
||||
|
||||
def _saetze(text: str) -> list[str]:
|
||||
"""Fließtext in Sätze splitten; sehr kurze Fragmente verwerfen."""
|
||||
return [s.strip() for s in _SATZ.split(text) if len(s.strip()) >= 15]
|
||||
|
||||
|
||||
def _scores(saetze: list[str]) -> list[float]:
|
||||
"""Komplexität je Satz (1–7). Regression (num_labels=1) oder Erwartungswert über Klassen."""
|
||||
tok, model, torch = _modell_cache
|
||||
werte: list[float] = []
|
||||
n = model.config.num_labels
|
||||
for i in range(0, len(saetze), 16):
|
||||
batch = saetze[i:i + 16]
|
||||
enc = tok(batch, return_tensors="pt", truncation=True, max_length=256, padding=True)
|
||||
with torch.no_grad():
|
||||
logits = model(**enc).logits
|
||||
if n == 1:
|
||||
vals = logits.reshape(-1).tolist()
|
||||
else:
|
||||
probs = torch.softmax(logits, dim=-1)
|
||||
stufen = torch.arange(1, n + 1, dtype=probs.dtype)
|
||||
vals = (probs * stufen).sum(-1).reshape(-1).tolist()
|
||||
werte.extend(vals)
|
||||
return werte
|
||||
|
||||
|
||||
def bewerte_sections(md_by_num: dict[int, str]) -> dict[int, str]:
|
||||
"""{num: section_md} → {num: Hinweis} nur für zu schwere Sections.
|
||||
|
||||
Leeres dict, wenn das Gate aus ist. Blockierend (CPU) — in to_thread aufrufen.
|
||||
"""
|
||||
if _modell() is None:
|
||||
return {}
|
||||
out: dict[int, str] = {}
|
||||
for num, md in md_by_num.items():
|
||||
saetze = _saetze(_prosa(md or ""))
|
||||
if len(saetze) < 2: # fast nur Code / zu kurz → überspringen
|
||||
continue
|
||||
werte = _scores(saetze)
|
||||
if not werte:
|
||||
continue
|
||||
schnitt = sum(werte) / len(werte)
|
||||
hart = sum(1 for w in werte if w > LESBARKEIT_HART) / len(werte)
|
||||
# Zu schwer = hoher Schnitt ODER zu viele harte Einzelsätze (Ausreißer-Nester).
|
||||
if schnitt > LESBARKEIT_MAX or hart >= LESBARKEIT_HART_ANTEIL:
|
||||
out[num] = (
|
||||
f"Zu schwer lesbar (Ø {schnitt:.1f}/7, {hart * 100:.0f}% harte Sätze): "
|
||||
"kürzere Sätze, einfachere Wörter, weniger Schachtelsätze, mehr Beispiele."
|
||||
)
|
||||
return out
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Zentrales Logging-Setup — einmal in main.py aufrufen, bevor die App entsteht."""
|
||||
"""Central logging setup — call once in main.py before the app is created."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
@@ -16,7 +16,7 @@ from routes import router
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
(STORAGE_DIR / "themen").mkdir(parents=True, exist_ok=True)
|
||||
(STORAGE_DIR / "topics").mkdir(parents=True, exist_ok=True)
|
||||
await init_db()
|
||||
await reconcile_guides()
|
||||
yield
|
||||
@@ -24,8 +24,8 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
|
||||
class CachedStatic(StaticFiles):
|
||||
"""StaticFiles mit Cache-Control: gehashte Assets dauerhaft (immutable),
|
||||
index.html nie cachen (verweist immer auf die aktuellen Asset-Hashes)."""
|
||||
"""StaticFiles with Cache-Control: hashed assets forever (immutable),
|
||||
index.html never cached (it always points at the current asset hashes)."""
|
||||
async def get_response(self, path, scope):
|
||||
resp = await super().get_response(path, scope)
|
||||
if path.startswith("assets/"):
|
||||
@@ -37,7 +37,7 @@ class CachedStatic(StaticFiles):
|
||||
|
||||
app = FastAPI(title="Creator", lifespan=lifespan)
|
||||
|
||||
# gzip für JS/CSS-Bundle + große JSON-Antworten (~1,39 MB JS → ~400 KB).
|
||||
# gzip for the JS/CSS bundle + large JSON responses (~1.39 MB JS → ~400 KB).
|
||||
app.add_middleware(GZipMiddleware, minimum_size=500)
|
||||
|
||||
app.include_router(router)
|
||||
|
||||
@@ -17,47 +17,47 @@ class GuideCreateRequest(BaseModel):
|
||||
format: FormatType
|
||||
instructions: str = Field(default="", max_length=2000)
|
||||
provider: ProviderType = "claude"
|
||||
ab_step: int | None = Field(default=None, ge=0, le=4) # Re-Run ab Guide-Schritt (0 Gliederung … 4 Lese-Prüfung); None = voll/Resume
|
||||
ab_step: int | None = Field(default=None, ge=0, le=4) # re-run from guide step (0 outline … 4 read-exam); None = full/resume
|
||||
|
||||
|
||||
class TopicCreateRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
|
||||
|
||||
class BausteineCreateRequest(BaseModel):
|
||||
class BlocksCreateRequest(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
instructions: str = Field(default="", max_length=2000)
|
||||
provider: ProviderType = "claude"
|
||||
source_type: SourceType = "thema"
|
||||
source_ort: str = Field(default="", max_length=2000)
|
||||
ab_phase: int | None = Field(default=None, ge=1, le=9) # Re-Run ab grober Phase (Position in _phasen(topic), 1-based; bis 9: …Gliederung/Fragen/Artefakte); None = Resume/Fortsetzen ohne Löschen
|
||||
ab_step: int | None = Field(default=None, ge=0) # Re-Run ab feinem Teilschritt (0-basierter Index in _bausteine_steps); hat Vorrang vor ab_phase
|
||||
source_location: str = Field(default="", max_length=2000)
|
||||
ab_phase: int | None = Field(default=None, ge=1, le=9) # re-run from a coarse phase (position in _phasen(topic), 1-based; up to 9: …outline/questions/artifacts); None = resume/continue without deleting
|
||||
ab_step: int | None = Field(default=None, ge=0) # re-run from a fine sub-step (0-based index into _blocks_steps); takes precedence over ab_phase
|
||||
|
||||
|
||||
class BausteineResetStepRequest(BaseModel):
|
||||
class BlocksResetStepRequest(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
ab_step: int = Field(ge=0) # NUR zurücksetzen ab diesem Teilschritt (kein Neu-Generieren)
|
||||
ab_step: int = Field(ge=0) # ONLY reset from this sub-step (no regeneration)
|
||||
|
||||
|
||||
class BausteineStep(BaseModel):
|
||||
class BlocksStep(BaseModel):
|
||||
label: str
|
||||
state: Literal["done", "active", "pending"]
|
||||
|
||||
|
||||
class BausteineFeinStep(BaseModel):
|
||||
class BlocksFineStep(BaseModel):
|
||||
label: str
|
||||
phase: str = ""
|
||||
state: Literal["done", "active", "pending"]
|
||||
|
||||
|
||||
class BausteineStatusResponse(BaseModel):
|
||||
class BlocksStatusResponse(BaseModel):
|
||||
ready: bool
|
||||
generating: bool
|
||||
progress: str | None = None
|
||||
error: str | None = None
|
||||
partial: bool = False
|
||||
steps: list[BausteineStep] = []
|
||||
feine_steps: list[BausteineFeinStep] = []
|
||||
steps: list[BlocksStep] = []
|
||||
feine_steps: list[BlocksFineStep] = []
|
||||
|
||||
|
||||
class ProjectResponse(BaseModel):
|
||||
@@ -66,33 +66,33 @@ class ProjectResponse(BaseModel):
|
||||
|
||||
class FolderResponse(BaseModel):
|
||||
name: str
|
||||
ort: str # relativer Pfad ab Repo-Root (z.B. "projects/foo")
|
||||
location: str # path relative to the repo root (e.g. "projects/foo")
|
||||
|
||||
|
||||
class BausteineQuelleUpdate(BaseModel):
|
||||
class BlocksSourceUpdate(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
type: SourceType = "thema"
|
||||
ort: str = Field(default="", max_length=2000)
|
||||
location: str = Field(default="", max_length=2000)
|
||||
spec: str = Field(default="", max_length=2000)
|
||||
|
||||
|
||||
class BausteineQuelleResponse(BaseModel):
|
||||
class BlocksSourceResponse(BaseModel):
|
||||
type: SourceType
|
||||
ort: str
|
||||
location: str
|
||||
spec: str
|
||||
|
||||
|
||||
class SubbausteinInfo(BaseModel):
|
||||
titel: str
|
||||
stufe: Literal["anfaenger", "fortgeschritten", "experte", "einfach", "mittel", "schwer"]
|
||||
relevanz: Literal["relevant", "rand"] | None = None
|
||||
class SubblockInfo(BaseModel):
|
||||
title: str
|
||||
level: Literal["beginner", "advanced", "expert", "easy", "medium", "hard"]
|
||||
relevance: Literal["relevant", "peripheral"] | None = None
|
||||
|
||||
|
||||
class BausteinUebersicht(BaseModel):
|
||||
class BlockOverview(BaseModel):
|
||||
num: int
|
||||
titel: str
|
||||
beschreibung: str = ""
|
||||
subbausteine: list[SubbausteinInfo] = []
|
||||
title: str
|
||||
description: str = ""
|
||||
subblocks: list[SubblockInfo] = []
|
||||
|
||||
|
||||
class ProviderInfo(BaseModel):
|
||||
@@ -168,7 +168,7 @@ class ElementCheckResponse(BaseModel):
|
||||
|
||||
class ElementStyleChange(BaseModel):
|
||||
text: str
|
||||
action: Literal["entfernen", "anpassen", "hinzufuegen"]
|
||||
action: Literal["remove", "adjust", "add"]
|
||||
target: Literal["title", "description", "examples", "hints"]
|
||||
index: int | None = None
|
||||
content: str = ""
|
||||
@@ -207,104 +207,104 @@ class ProgressResponse(BaseModel):
|
||||
chapters: list[str]
|
||||
|
||||
|
||||
# --- Baustein-Lernen ---
|
||||
# --- Block learning ---
|
||||
|
||||
class BausteinChatRequest(BaseModel):
|
||||
class BlockChatRequest(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
baustein: str = Field(min_length=1, max_length=200)
|
||||
section: str = Field(default="", max_length=20000) # ausführliche Fassung
|
||||
section_kompakt: str = Field(default="", max_length=20000) # kompakte Fassung (Merksätze)
|
||||
block: str = Field(min_length=1, max_length=200)
|
||||
section: str = Field(default="", max_length=20000) # detailed version
|
||||
section_compact: str = Field(default="", max_length=20000) # compact version (mnemonics)
|
||||
messages: list[ChatMessage] = Field(min_length=1)
|
||||
provider: ProviderType = "claude"
|
||||
|
||||
|
||||
class BausteinChatResponse(BaseModel):
|
||||
class BlockChatResponse(BaseModel):
|
||||
reply: str
|
||||
|
||||
|
||||
class BausteinPruefungRequest(BaseModel):
|
||||
class BlockExamRequest(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
baustein: str = Field(min_length=1, max_length=200)
|
||||
section: str = Field(default="", max_length=20000) # ausführliche Fassung
|
||||
section_kompakt: str = Field(default="", max_length=20000) # kompakte Fassung (Merksätze)
|
||||
aktion: Literal[
|
||||
"frage", "diskussion", "antwort", "antwort_pruefen",
|
||||
"quiz_frage", "quiz_antwort", "lueck_frage", "lueck_antwort",
|
||||
] = "frage"
|
||||
frage: str = Field(default="", max_length=2000) # aktuell geprüfte Frage (für diskussion/antwort); Anker der Basis
|
||||
auswahl: list[int] = [] # Quiz/Lückentext-Auswahl: vom Lerner gewählte Options-Indizes
|
||||
korrekt: list[int] = [] # Quiz/Lückentext-Auswahl: korrekte Indizes (Client hält sie aus der Generierung)
|
||||
loesung: str = Field(default="", max_length=500) # Lückentext frei: erwarteter Begriff
|
||||
alternativen: list[str] = [] # Lückentext frei: akzeptierte Synonyme
|
||||
eingabe: str = Field(default="", max_length=500) # Lückentext frei: getippter Begriff
|
||||
schwer: bool = False # Variante: leicht (+1/−1) vs schwer (+3/−1)
|
||||
letzte_bewertung: str = Field(default="", max_length=2000) # Feedback der letzten Bewertung (Kontext für diskussion)
|
||||
vermeide: list[str] = [] # schon gestellte + vorgemerkte Fragen — sinngemäß nicht wiederholen
|
||||
nachgefragt: bool = False # für diese Frage wurde nachgefragt → Gewinn auf +1 gedeckelt
|
||||
begruendung: str = Field(default="", max_length=2000) # „Gründlich prüfen": warum mit der Bewertung unzufrieden
|
||||
muster: str = Field(default="", max_length=2000) # gezogenes Frage-Muster (Saat); leer → Live-Generierung (Fallback)
|
||||
# Basis + cap werden serverseitig geführt (Anker bzw. Subs×25) — Client-cap nur Hinweis.
|
||||
cap: int = Field(default=10, ge=1, le=10000) # Score-Deckel = freigeschaltete Subbausteine × 25
|
||||
messages: list[ChatMessage] = [] # Dialog bisher; leer = erste Frage
|
||||
block: str = Field(min_length=1, max_length=200)
|
||||
section: str = Field(default="", max_length=20000) # detailed version
|
||||
section_compact: str = Field(default="", max_length=20000) # compact version (mnemonics)
|
||||
action: Literal[
|
||||
"question", "discussion", "answer", "answer_check",
|
||||
"quiz_question", "quiz_answer", "gap_question", "gap_answer",
|
||||
] = "question"
|
||||
question: str = Field(default="", max_length=2000) # currently checked question (for discussion/answer); base anchor
|
||||
selection: list[int] = [] # quiz/gap-text choice: option indices picked by the learner
|
||||
correct: list[int] = [] # quiz/gap-text choice: correct indices (the client keeps them from generation)
|
||||
solution: str = Field(default="", max_length=500) # gap text free: expected term
|
||||
alternatives: list[str] = [] # gap text free: accepted synonyms
|
||||
input: str = Field(default="", max_length=500) # gap text free: typed term
|
||||
schwer: bool = False # variant: easy (+1/−1) vs hard (+3/−1)
|
||||
last_rating: str = Field(default="", max_length=2000) # feedback of the last rating (context for discussion)
|
||||
avoid: list[str] = [] # already-asked + earmarked questions — don't repeat them in substance
|
||||
asked_again: bool = False # asked_again was used for this question → gain capped at +1
|
||||
reason: str = Field(default="", max_length=2000) # "thorough check": why dissatisfied with the rating
|
||||
pattern: str = Field(default="", max_length=2000) # drawn question pattern (seed); empty → live generation (fallback)
|
||||
# Base + cap are kept server-side (anchor / subs×25) — the client cap is only a hint.
|
||||
cap: int = Field(default=10, ge=1, le=10000) # score cap = unlocked subblocks × 25
|
||||
messages: list[ChatMessage] = [] # dialog so far; empty = first question
|
||||
provider: ProviderType = "claude"
|
||||
gruendlich: bool = False # „Gründlich prüfen": Bewertung mit starkem Modell (role guide)
|
||||
thorough: bool = False # "thorough check": rating with a strong model (role guide)
|
||||
|
||||
|
||||
class QuizOption(BaseModel):
|
||||
text: str
|
||||
korrekt: bool
|
||||
correct: bool
|
||||
|
||||
|
||||
class BausteinPruefungResponse(BaseModel):
|
||||
frage: str | None = None
|
||||
class BlockExamResponse(BaseModel):
|
||||
question: str | None = None
|
||||
reply: str | None = None
|
||||
feedback: str | None = None
|
||||
punkte: int | None = None # Punkt-Delta dieser Antwort (−2 … +3); schnell = voraussichtlich
|
||||
bewertung: Literal["gut", "neutral", "schlecht"] | None = None # aus Vorzeichen, fürs Einfärben
|
||||
optionen: list[QuizOption] | None = None # Quiz: 4 Optionen + Korrekt-Flags
|
||||
satz: str | None = None # Lückentext: Satz mit Lücke (___)
|
||||
loesung: str | None = None # Lückentext: erwarteter Begriff
|
||||
alternativen: list[str] | None = None # Lückentext: akzeptierte Synonyme
|
||||
gute_antworten: int
|
||||
streak: int = 0 # aktuelle Serie korrekter Antworten (je Baustein)
|
||||
cap: int = 10 # cap_final = alle Subs × 25 — Frontend leitet die Lernstufe ab
|
||||
points: int | None = None # points delta of this answer (−2 … +3); fast = expected
|
||||
rating: Literal["gut", "neutral", "schlecht"] | None = None # from the sign, for coloring
|
||||
options: list[QuizOption] | None = None # quiz: 4 options + correct flags
|
||||
sentence: str | None = None # gap text: sentence with a gap (___)
|
||||
solution: str | None = None # gap text: expected term
|
||||
alternatives: list[str] | None = None # gap text: accepted synonyms
|
||||
good_answers: int
|
||||
streak: int = 0 # current run of correct answers (per block)
|
||||
cap: int = 10 # cap_final = all subs × 25 — the frontend derives the learning level
|
||||
|
||||
|
||||
class BausteinLernstand(BaseModel):
|
||||
gute_antworten: int
|
||||
class BlockLearnState(BaseModel):
|
||||
good_answers: int
|
||||
streak: int = 0
|
||||
cap: int = 0 # cap_final = alle Subbausteine × 25
|
||||
cap_aktuell: int = 0 # erreichbarer cap der aktuell freigeschalteten Ebene
|
||||
freie_ebene: int = 1 # 1=A · 2=F · 3=E · 4=V
|
||||
cap: int = 0 # cap_final = all subblocks × 25
|
||||
cap_aktuell: int = 0 # reachable cap of the currently unlocked level
|
||||
freie_level: int = 1 # 1=A · 2=F · 3=E · 4=V
|
||||
|
||||
|
||||
class BausteinLernstandResponse(BaseModel):
|
||||
bausteine: dict[str, BausteinLernstand]
|
||||
class BlockLearnStateResponse(BaseModel):
|
||||
blocks: dict[str, BlockLearnState]
|
||||
|
||||
|
||||
# --- Block-Inhalt: einen Abschnitt on-demand prüfen + übernehmen (Fokus, Rechtsklick) ---
|
||||
# --- Block content: check + apply one section on demand (focus, right-click) ---
|
||||
|
||||
class BlockPruefenRequest(BaseModel):
|
||||
baustein: str = Field(min_length=1, max_length=200)
|
||||
stelle: str = "ausführlich" # "kompakt" | "ausführlich" (angezeigtes Feld)
|
||||
block: str = Field(min_length=1, max_length=20000) # roher Markdown-Block
|
||||
hinweis: str = Field(default="", max_length=2000) # optionaler Zusatz (✏️)
|
||||
block: str = Field(min_length=1, max_length=200)
|
||||
spot: str = "ausführlich" # "compact" | "ausführlich" (displayed field)
|
||||
snippet: str = Field(min_length=1, max_length=20000) # raw markdown block
|
||||
hint: str = Field(default="", max_length=2000) # optional addition (✏️)
|
||||
provider: ProviderType = "claude"
|
||||
|
||||
|
||||
class BlockPruefenResponse(BaseModel):
|
||||
neu: str # korrigierter Block als Markdown
|
||||
revised: str # corrected block as markdown
|
||||
|
||||
|
||||
class BlockUebernehmenRequest(BaseModel):
|
||||
baustein: str = Field(min_length=1, max_length=200)
|
||||
stelle: str = "ausführlich"
|
||||
block: str = Field(min_length=1, max_length=200)
|
||||
spot: str = "ausführlich"
|
||||
alt: str = Field(min_length=1, max_length=20000)
|
||||
neu: str = Field(default="", max_length=20000)
|
||||
revised: str = Field(default="", max_length=20000)
|
||||
provider: ProviderType = "claude"
|
||||
|
||||
|
||||
class BlockUebernehmenResponse(BaseModel):
|
||||
kompakt: str
|
||||
compact: str
|
||||
md: str
|
||||
gefunden: bool
|
||||
found: bool
|
||||
|
||||
@@ -2,7 +2,7 @@ from pathlib import Path
|
||||
|
||||
from config import STORAGE_DIR, PROJECTS_DIR, PROJECT_ROOT
|
||||
|
||||
THEMEN_DIR = STORAGE_DIR / "themen"
|
||||
TOPICS_DIR = STORAGE_DIR / "topics"
|
||||
|
||||
|
||||
def _safe(name: str) -> str:
|
||||
@@ -10,42 +10,42 @@ def _safe(name: str) -> str:
|
||||
|
||||
|
||||
def topic_dir(topic: str) -> Path:
|
||||
return THEMEN_DIR / _safe(topic)
|
||||
return TOPICS_DIR / _safe(topic)
|
||||
|
||||
|
||||
def arbeit_dir(topic: str) -> Path:
|
||||
return topic_dir(topic) / "arbeit"
|
||||
|
||||
|
||||
def bausteine_path(topic: str) -> Path:
|
||||
return topic_dir(topic) / "bausteine.md"
|
||||
def blocks_path(topic: str) -> Path:
|
||||
return topic_dir(topic) / "blocks.md"
|
||||
|
||||
|
||||
def subbausteine_path(topic: str) -> Path:
|
||||
"""Sidecar: pro Baustein die Subbausteine mit Stufe (von allen Guides geteilt)."""
|
||||
return topic_dir(topic) / "subbausteine.json"
|
||||
def subblocks_path(topic: str) -> Path:
|
||||
"""Sidecar: the subblocks with level per block (shared by all guides)."""
|
||||
return topic_dir(topic) / "subblocks.json"
|
||||
|
||||
|
||||
def frage_muster_path(topic: str) -> Path:
|
||||
"""Sidecar: pro Baustein vordefinierte Frage-Muster (Subbaustein × Typ → Beispielfrage)."""
|
||||
return topic_dir(topic) / "frage_muster.json"
|
||||
def question_pattern_path(topic: str) -> Path:
|
||||
"""Sidecar: predefined question patterns per block (subblock × type → example question)."""
|
||||
return topic_dir(topic) / "question_pattern.json"
|
||||
|
||||
|
||||
def quelle_path(topic: str) -> Path:
|
||||
"""Persistierte Quellen-Wahl pro Thema: {type, ort, spec}."""
|
||||
return topic_dir(topic) / "quelle.json"
|
||||
def source_path(topic: str) -> Path:
|
||||
"""Persisted source choice per topic: {type, location, spec}."""
|
||||
return topic_dir(topic) / "source.json"
|
||||
|
||||
|
||||
def quelle_crawl_dir(topic: str) -> Path:
|
||||
"""Zielordner für gecrawlte Link-Quellen (Seiten + PDF-.txt)."""
|
||||
return topic_dir(topic) / "quelle"
|
||||
def source_crawl_dir(topic: str) -> Path:
|
||||
"""Target folder for crawled link sources (pages + PDF .txt)."""
|
||||
return topic_dir(topic) / "source"
|
||||
|
||||
|
||||
def safe_ordner(ort: str) -> Path | None:
|
||||
"""Ordnerpfad relativ zum Repo-Root, gesandboxt. None bei leer/Ausbruch (../, absolut außerhalb)."""
|
||||
if not ort or not ort.strip():
|
||||
def safe_folder(location: str) -> Path | None:
|
||||
"""Folder path relative to the repo root, sandboxed. None if empty/escaping (../, absolute outside)."""
|
||||
if not location or not location.strip():
|
||||
return None
|
||||
p = (PROJECT_ROOT / ort.strip()).resolve()
|
||||
p = (PROJECT_ROOT / location.strip()).resolve()
|
||||
try:
|
||||
p.relative_to(PROJECT_ROOT)
|
||||
except ValueError:
|
||||
@@ -57,11 +57,11 @@ def guide_content_path(topic: str, format_name: str) -> Path:
|
||||
return topic_dir(topic) / "guides" / f"{format_name}.json"
|
||||
|
||||
|
||||
def bausteine_topics() -> list[str]:
|
||||
"""Themen, für die ein Themen-Ordner existiert."""
|
||||
if not THEMEN_DIR.is_dir():
|
||||
def blocks_topics() -> list[str]:
|
||||
"""Topics for which a topic folder exists."""
|
||||
if not TOPICS_DIR.is_dir():
|
||||
return []
|
||||
return [d.name for d in THEMEN_DIR.iterdir() if d.is_dir()]
|
||||
return [d.name for d in TOPICS_DIR.iterdir() if d.is_dir()]
|
||||
|
||||
|
||||
def project_dir(name: str) -> Path:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Pipeline-Grundbausteine: Agent-Races (mit Grace), Single-Slot, Schemata, Prompts, Guide-Status.
|
||||
"""Pipeline building blocks: agent races (with grace), single-slot, schemas, prompts, guide status.
|
||||
|
||||
Hält den mutablen Pipeline-Zustand (Generierungs-Semaphore, Cancel-Set).
|
||||
Zugriff auf das Cancel-Set NUR über die Funktionen hier — kopierte Referenzen
|
||||
in anderen Modulen würden bei einem Re-Assign auseinanderlaufen.
|
||||
Holds the mutable pipeline state (generation semaphore, cancel set).
|
||||
Access the cancel set ONLY through the functions here — copied references
|
||||
in other modules would diverge on a re-assign.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -15,7 +15,7 @@ from typing import Callable
|
||||
from agents import run_agent, kill_process, cancel_scope, clear_scope
|
||||
from config import MAX_CONCURRENT_GENERATIONS, TEMPLATES_DIR, TIMEOUTS
|
||||
from database import update_guide
|
||||
from jsonio import read_json_file as _json_datei
|
||||
from jsonio import read_json_file as _json_file
|
||||
from textkit import _STUFEN
|
||||
|
||||
log = logging.getLogger("creator.pipeline")
|
||||
@@ -26,10 +26,10 @@ _cancelled: set[str] = set()
|
||||
|
||||
async def cancel_guide(guide_id: str) -> bool:
|
||||
_cancelled.add(guide_id)
|
||||
cancel_scope(f"{guide_id}-") # wartende Agenten bailen vorm Spawn
|
||||
kill_process(guide_id) # laufende Subprozesse killen
|
||||
cancel_scope(f"{guide_id}-") # waiting agents bail before spawn
|
||||
kill_process(guide_id) # kill running subprocesses
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
await update_guide(guide_id, status="error", progress=None, error_msg="Abgebrochen — Fortschritt bleibt erhalten", updated_at=now)
|
||||
await update_guide(guide_id, status="error", progress=None, error_msg="Cancelled — progress is preserved", updated_at=now)
|
||||
return True
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ def is_guide_cancelled(guide_id: str) -> bool:
|
||||
|
||||
def clear_guide_cancelled(guide_id: str) -> None:
|
||||
_cancelled.discard(guide_id)
|
||||
clear_scope(f"{guide_id}-") # Scope leeren → Neustart blockiert nicht
|
||||
clear_scope(f"{guide_id}-") # clear scope → restart not blocked
|
||||
|
||||
|
||||
async def _set_progress(guide_id: str, progress: str) -> None:
|
||||
@@ -63,7 +63,7 @@ def _prompt(name: str, **kwargs) -> str:
|
||||
|
||||
|
||||
def _extra(instructions: str) -> str:
|
||||
return f"\n\nZUSÄTZLICHE ANWEISUNGEN VOM NUTZER:\n{instructions}\n" if instructions else ""
|
||||
return f"\n\nADDITIONAL INSTRUCTIONS FROM THE USER:\n{instructions}\n" if instructions else ""
|
||||
|
||||
|
||||
def _log(topic: str, msg: str) -> None:
|
||||
@@ -76,8 +76,8 @@ def _claude_error(label: str, returncode: int, stdout: str, stderr: str) -> str:
|
||||
return f"{label}: {stderr[:1000]}"
|
||||
tail = (stdout or "").strip()[-500:]
|
||||
if tail:
|
||||
return f"{label} (exit {returncode}, stderr leer): …{tail}"
|
||||
return f"{label} (exit {returncode}, ohne Ausgabe)"
|
||||
return f"{label} (exit {returncode}, stderr empty): …{tail}"
|
||||
return f"{label} (exit {returncode}, no output)"
|
||||
|
||||
|
||||
def _gather_error(label: str, results: list) -> str:
|
||||
@@ -87,7 +87,7 @@ def _gather_error(label: str, results: list) -> str:
|
||||
returncode, stdout, stderr = r
|
||||
if returncode != 0:
|
||||
return _claude_error(label, returncode, stdout, stderr)
|
||||
return f"{label}: kein verwertbares Ergebnis"
|
||||
return f"{label}: no usable result"
|
||||
|
||||
|
||||
def _timeout(step: str, n: int = 0) -> int:
|
||||
@@ -95,21 +95,21 @@ def _timeout(step: str, n: int = 0) -> int:
|
||||
return base + per * n
|
||||
|
||||
|
||||
def _probleme_schema(data):
|
||||
"""{"ok": true} → [] · {"probleme": [str]} → Liste · sonst None."""
|
||||
def _problems_schema(data):
|
||||
"""{"ok": true} → [] · {"problems": [str]} → list · else None."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
if data.get("ok") is True:
|
||||
return []
|
||||
p = data.get("probleme")
|
||||
p = data.get("problems")
|
||||
if not isinstance(p, list) or not p:
|
||||
return None
|
||||
out = [str(x).strip() for x in p if str(x).strip()]
|
||||
return out or None
|
||||
|
||||
|
||||
def _str_liste(val) -> list[str] | None:
|
||||
"""Liste nicht-leerer Strings → gestrippte Liste (leer erlaubt) · sonst None."""
|
||||
def _str_list(val) -> list[str] | None:
|
||||
"""List of non-empty strings → stripped list (empty allowed) · else None."""
|
||||
if not isinstance(val, list) or not all(isinstance(x, str) for x in val):
|
||||
return None
|
||||
out = [x.strip() for x in val]
|
||||
@@ -119,110 +119,67 @@ def _str_liste(val) -> list[str] | None:
|
||||
|
||||
|
||||
def _runde_schema(data, final: bool = False):
|
||||
"""{"aufnehmen": [str], "rest": [str]} → (aufnehmen, rest) · sonst None.
|
||||
"""{"keep": [str], "rest": [str]} → (include, rest) · else None.
|
||||
|
||||
final=True: letzte Klärungs-Runde — ein nicht-leerer Rest ist ungültig.
|
||||
final=True: last clarification round — a non-empty rest is invalid.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
aufnehmen = _str_liste(data.get("aufnehmen"))
|
||||
rest = _str_liste(data.get("rest"))
|
||||
if aufnehmen is None or rest is None or (final and rest):
|
||||
include = _str_list(data.get("keep"))
|
||||
rest = _str_list(data.get("rest"))
|
||||
if include is None or rest is None or (final and rest):
|
||||
return None
|
||||
return aufnehmen, rest
|
||||
return include, rest
|
||||
|
||||
|
||||
def _stufen_schema(data, ids: set[int] | None = None):
|
||||
"""{"stufen": {"1": "anfaenger", …}} → {id: stufe} · sonst None.
|
||||
_RELEVANCE = ("relevant", "peripheral")
|
||||
_YESNO = ("ja", "nein")
|
||||
|
||||
Stufe ∈ {anfaenger, fortgeschritten, experte} (alte Werte abwärtskompatibel). Sind `ids`
|
||||
gegeben, müssen mindestens diese abgedeckt sein (Extras erlaubt); der Aufrufer filtert auf `ids`.
|
||||
"""
|
||||
if not isinstance(data, dict) or not isinstance(data.get("stufen"), dict) or not data["stufen"]:
|
||||
return None
|
||||
out: dict[int, str] = {}
|
||||
for k, v in data["stufen"].items():
|
||||
try:
|
||||
num = int(k)
|
||||
except (ValueError, TypeError):
|
||||
|
||||
def _enum_map_schema(key: str, allowed):
|
||||
"""Factory for `{"<key>": {"1": value, …}}` → `{id: value}` parsers; value ∈ `allowed`
|
||||
(casefolded). If `ids` are given, at least these must be covered (extras allowed). None
|
||||
on any invalid id/value or wrong shape. The caller filters the result to `ids`."""
|
||||
def parse(data, ids: set[int] | None = None):
|
||||
if not isinstance(data, dict) or not isinstance(data.get(key), dict) or not data[key]:
|
||||
return None
|
||||
stufe = str(v).strip().casefold()
|
||||
if stufe not in _STUFEN:
|
||||
out: dict[int, str] = {}
|
||||
for k, v in data[key].items():
|
||||
try:
|
||||
num = int(k)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
value = str(v).strip().casefold()
|
||||
if value not in allowed:
|
||||
return None
|
||||
out[num] = value
|
||||
if ids is not None and not ids <= set(out):
|
||||
return None
|
||||
out[num] = stufe
|
||||
if ids is not None and not ids <= set(out):
|
||||
return None
|
||||
return out
|
||||
return out
|
||||
return parse
|
||||
|
||||
|
||||
_RELEVANZ = ("relevant", "rand")
|
||||
|
||||
|
||||
def _relevanz_schema(data, ids: set[int] | None = None):
|
||||
"""{"relevanz": {"1": "relevant", …}} → {id: relevanz} · sonst None.
|
||||
|
||||
Relevanz ∈ {relevant, rand} (binär). Wie `_stufen_schema`: sind `ids` gegeben,
|
||||
müssen mindestens diese abgedeckt sein (Extras erlaubt).
|
||||
"""
|
||||
if not isinstance(data, dict) or not isinstance(data.get("relevanz"), dict) or not data["relevanz"]:
|
||||
return None
|
||||
out: dict[int, str] = {}
|
||||
for k, v in data["relevanz"].items():
|
||||
try:
|
||||
num = int(k)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
wert = str(v).strip().casefold()
|
||||
if wert not in _RELEVANZ:
|
||||
return None
|
||||
out[num] = wert
|
||||
if ids is not None and not ids <= set(out):
|
||||
return None
|
||||
return out
|
||||
|
||||
|
||||
_JANEIN = ("ja", "nein")
|
||||
|
||||
|
||||
def _janein_schema(data, ids: set[int] | None = None):
|
||||
"""{"relevant": {"1": "ja", …}} → {id: ja/nein} · sonst None.
|
||||
|
||||
Binäres ja/nein — das Themen-Relevanz-Gate der Sichtung. Wie `_relevanz_schema`:
|
||||
sind `ids` gegeben, müssen mindestens diese abgedeckt sein (Extras erlaubt).
|
||||
"""
|
||||
if not isinstance(data, dict) or not isinstance(data.get("relevant"), dict) or not data["relevant"]:
|
||||
return None
|
||||
out: dict[int, str] = {}
|
||||
for k, v in data["relevant"].items():
|
||||
try:
|
||||
num = int(k)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
wert = str(v).strip().casefold()
|
||||
if wert not in _JANEIN:
|
||||
return None
|
||||
out[num] = wert
|
||||
if ids is not None and not ids <= set(out):
|
||||
return None
|
||||
return out
|
||||
_levels_schema = _enum_map_schema("levels", _STUFEN) # level ∈ beginner/advanced/expert
|
||||
_relevance_schema = _enum_map_schema("relevance", _RELEVANCE) # relevance ∈ relevant/peripheral
|
||||
_yesno_schema = _enum_map_schema("relevant", _YESNO) # triage gate ∈ ja/nein
|
||||
|
||||
|
||||
_MAX_RESTARTS = 2
|
||||
|
||||
|
||||
async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: int, provider: str, on_update=None, cancelled=None, *, grace: int | None = None) -> list | None:
|
||||
"""Startet alle Slots parallel und sammelt `quorum` gültige Ergebnisse.
|
||||
"""Starts all slots in parallel and collects `quorum` valid results.
|
||||
|
||||
Slot-Spec: {key, prompt, role, capabilities, payload}. `payload(result)`
|
||||
prüft die Gültigkeit und liefert das Slot-Ergebnis oder None.
|
||||
Fehler/Timeout/ungültig → Slot-Neustart (max. _MAX_RESTARTS). Sobald das
|
||||
Quorum steht, werden die übrigen Agenten gekillt. None = Quorum verfehlt.
|
||||
`cancelled()` → True bricht ab (keine Restarts, Rückgabe None).
|
||||
Slot spec: {key, prompt, role, capabilities, payload}. `payload(result)`
|
||||
checks validity and returns the slot result or None.
|
||||
Error/timeout/invalid → slot restart (max. _MAX_RESTARTS). As soon as the
|
||||
quorum stands, the remaining agents are killed. None = quorum missed.
|
||||
`cancelled()` → True aborts (no restarts, returns None).
|
||||
|
||||
Mit `grace` wird `quorum` zum Minimum: Das erste gültige Ergebnis startet
|
||||
einen Timer von `grace` Sekunden. Nach dessen Ablauf werden laufende
|
||||
Agenten nur gekillt, wenn das Minimum steht — sonst läuft das Race samt
|
||||
Restarts weiter, bis es steht. Rückgabe: `quorum` bis `len(slots)` Ergebnisse.
|
||||
With `grace`, `quorum` becomes the minimum: the first valid result starts
|
||||
a timer of `grace` seconds. After it expires, running agents are only
|
||||
killed if the minimum stands — otherwise the race, including restarts,
|
||||
keeps running until it stands. Returns: `quorum` to `len(slots)` results.
|
||||
"""
|
||||
attempts = {i: 0 for i in range(len(slots))}
|
||||
tasks: dict[asyncio.Task, int] = {}
|
||||
@@ -247,7 +204,7 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
||||
return None
|
||||
if deadline is not None and len(results) >= quorum and loop.time() >= deadline:
|
||||
return results
|
||||
# Grace gesetzt und Minimum erreicht → nur bis zum Deadline-Rest warten
|
||||
# Grace set and minimum reached → only wait for the remaining deadline
|
||||
wait_timeout = None
|
||||
if deadline is not None and len(results) >= quorum:
|
||||
wait_timeout = max(0.0, deadline - loop.time())
|
||||
@@ -260,13 +217,13 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
||||
try:
|
||||
result = task.result()
|
||||
if result[0] != 0:
|
||||
err = _claude_error("Fehler", *result)
|
||||
err = _claude_error("Error", *result)
|
||||
else:
|
||||
payload = slots[i]["payload"](result)
|
||||
if payload is None:
|
||||
err = "Ergebnis ungültig/nicht parsebar"
|
||||
err = "result invalid/not parseable"
|
||||
except asyncio.TimeoutError:
|
||||
err = f"Timeout nach {timeout}s"
|
||||
err = f"Timeout after {timeout}s"
|
||||
except Exception as e:
|
||||
err = f"{type(e).__name__}: {e}"
|
||||
|
||||
@@ -274,23 +231,23 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
||||
results.append(payload)
|
||||
if grace is not None and deadline is None:
|
||||
deadline = loop.time() + grace
|
||||
_log(topic, f"{label}: erstes Ergebnis — Grace {grace}s läuft")
|
||||
_log(topic, f"{label}: first result — grace {grace}s running")
|
||||
if on_update:
|
||||
on_update(len(results))
|
||||
if len(results) >= quorum and (grace is None or loop.time() >= deadline):
|
||||
return results
|
||||
continue
|
||||
|
||||
_log(topic, f"{label} {i + 1} (Versuch {attempts[i] + 1}): {err}")
|
||||
_log(topic, f"{label} {i + 1} (attempt {attempts[i] + 1}): {err}")
|
||||
attempts[i] += 1
|
||||
# Steht das Minimum schon, sind Restarts sinnlos — der Neustart
|
||||
# würde am Grace-Ende ohnehin gekillt.
|
||||
satt = grace is not None and len(results) >= quorum
|
||||
if attempts[i] <= _MAX_RESTARTS and not satt and not (cancelled and cancelled()):
|
||||
# If the minimum already stands, restarts are pointless — the restart
|
||||
# would be killed at the grace end anyway.
|
||||
enough = grace is not None and len(results) >= quorum
|
||||
if attempts[i] <= _MAX_RESTARTS and not enough and not (cancelled and cancelled()):
|
||||
spawn(i)
|
||||
if len(results) >= quorum: # alle Slots durch, Minimum steht (nur mit grace erreichbar)
|
||||
if len(results) >= quorum: # all slots done, minimum stands (only reachable with grace)
|
||||
return results
|
||||
_log(topic, f"{label}: Quorum {quorum} nicht erreicht ({len(results)} gültig)")
|
||||
_log(topic, f"{label}: quorum {quorum} not reached ({len(results)} valid)")
|
||||
return None
|
||||
finally:
|
||||
for task, i in tasks.items():
|
||||
@@ -302,14 +259,14 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
||||
|
||||
@dataclass
|
||||
class GenContext:
|
||||
"""Durchgereichte Pipeline-Parameter — erspart lange Argument-Signaturen."""
|
||||
"""Pipeline parameters passed through — saves long argument signatures."""
|
||||
topic: str
|
||||
provider: str
|
||||
is_cancelled: Callable[[], bool]
|
||||
guide_id: str | None = None
|
||||
|
||||
|
||||
# Ergebnis-Status von run_single_slot
|
||||
# Result status of run_single_slot
|
||||
OK, CANCELLED, FAILED = "ok", "cancelled", "failed"
|
||||
|
||||
|
||||
@@ -317,9 +274,9 @@ async def run_single_slot(
|
||||
ctx: GenContext, label: str, *,
|
||||
key: str, prompt: str, role: str, capabilities: str, payload, timeout: int,
|
||||
) -> tuple[str, object]:
|
||||
"""Ein Agent, ein gültiges Ergebnis (Race mit Quorum 1).
|
||||
"""One agent, one valid result (race with quorum 1).
|
||||
|
||||
→ (OK, wert) | (CANCELLED, None) | (FAILED, None)
|
||||
→ (OK, value) | (CANCELLED, None) | (FAILED, None)
|
||||
"""
|
||||
slots = [{"key": key, "prompt": prompt, "role": role, "capabilities": capabilities, "payload": payload}]
|
||||
res = await _race(ctx.topic, label, slots, 1, timeout, ctx.provider, cancelled=ctx.is_cancelled)
|
||||
@@ -330,9 +287,9 @@ async def run_single_slot(
|
||||
return OK, res[0]
|
||||
|
||||
|
||||
async def _gather_fortschritt(coros, total, melde, start=0):
|
||||
"""Läuft `coros` nebenläufig und meldet Live-Fortschritt: `await melde(fertig, total)`
|
||||
nach jedem Abschluss (und einmal initial). Ergebnisse in Reihenfolge, return_exceptions=True."""
|
||||
async def _gather_progress(coros, total, report, start=0):
|
||||
"""Runs `coros` concurrently and reports live progress: `await report(done, total)`
|
||||
after each completion (and once initially). Results in order, return_exceptions=True."""
|
||||
done = start
|
||||
|
||||
async def wrap(c):
|
||||
@@ -341,9 +298,7 @@ async def _gather_fortschritt(coros, total, melde, start=0):
|
||||
return await c
|
||||
finally:
|
||||
done += 1
|
||||
await melde(done, total)
|
||||
await report(done, total)
|
||||
|
||||
await melde(done, total)
|
||||
await report(done, total)
|
||||
return await asyncio.gather(*[wrap(c) for c in coros], return_exceptions=True)
|
||||
|
||||
|
||||
|
||||
115
backend/readability.py
Normal file
115
backend/readability.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Deterministic readability gate for guide sections.
|
||||
|
||||
A small German complexity model (DistilBERT, GermEval 2022, scale 1–7) rates the
|
||||
readability of the prose. guide.py feeds sections that are too hard into the existing
|
||||
read-exam/revision loop — no prompt, no guessing.
|
||||
|
||||
Optional: if `transformers`/`torch` are missing or the model won't load, the gate is
|
||||
silently disabled (the backend keeps running unchanged). CPU is enough; the caller
|
||||
wraps the scoring in `asyncio.to_thread` (blocking model inference).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from config import (
|
||||
READABILITY_ACTIVE, READABILITY_HARD, READABILITY_HARD_SHARE, READABILITY_MAX, READABILITY_MODEL,
|
||||
)
|
||||
|
||||
log = logging.getLogger("creator.readability")
|
||||
|
||||
_model_cache = None # (tokenizer, model, torch) — singleton
|
||||
_load_attempt = False # already tried to load?
|
||||
|
||||
# Strip markup → plain prose (code does not count toward readability).
|
||||
_CODE_FENCE = re.compile(r"```.*?```", re.DOTALL)
|
||||
_COMMENT = re.compile(r"<!--.*?-->", re.DOTALL)
|
||||
_INLINE_CODE = re.compile(r"`[^`]*`")
|
||||
_LINK = re.compile(r"\[([^\]]*)\]\([^)]*\)")
|
||||
_MD_MARK = re.compile(r"^[ \t]*([#>]+|[-*+]\s)|[*_~|]", re.MULTILINE)
|
||||
_WS = re.compile(r"\s+")
|
||||
_SENTENCE = re.compile(r"(?<=[.!?])\s+")
|
||||
|
||||
|
||||
def _model():
|
||||
"""Load the model once. None = gate off (disabled or load error)."""
|
||||
global _model_cache, _load_attempt
|
||||
if _load_attempt:
|
||||
return _model_cache
|
||||
_load_attempt = True
|
||||
if not READABILITY_ACTIVE:
|
||||
return None
|
||||
try:
|
||||
import torch
|
||||
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
||||
tok = AutoTokenizer.from_pretrained(READABILITY_MODEL)
|
||||
model = AutoModelForSequenceClassification.from_pretrained(READABILITY_MODEL)
|
||||
model.eval()
|
||||
_model_cache = (tok, model, torch)
|
||||
log.info("readability model loaded: %s (num_labels=%d)", READABILITY_MODEL, model.config.num_labels)
|
||||
except Exception as e:
|
||||
log.warning("readability gate disabled (model not loadable): %s", e)
|
||||
_model_cache = None
|
||||
return _model_cache
|
||||
|
||||
|
||||
def _prose(md: str) -> str:
|
||||
"""Strip markdown/code → plain prose for scoring."""
|
||||
t = _CODE_FENCE.sub(" ", md)
|
||||
t = _COMMENT.sub(" ", t)
|
||||
t = _INLINE_CODE.sub(" ", t)
|
||||
t = _LINK.sub(r"\1", t)
|
||||
t = _MD_MARK.sub(" ", t)
|
||||
return _WS.sub(" ", t).strip()
|
||||
|
||||
|
||||
def _sentences(text: str) -> list[str]:
|
||||
"""Split prose into sentences; discard very short fragments."""
|
||||
return [s.strip() for s in _SENTENCE.split(text) if len(s.strip()) >= 15]
|
||||
|
||||
|
||||
def _scores(sentences: list[str]) -> list[float]:
|
||||
"""Complexity per sentence (1–7). Regression (num_labels=1) or expectation over classes."""
|
||||
tok, model, torch = _model_cache
|
||||
values: list[float] = []
|
||||
n = model.config.num_labels
|
||||
for i in range(0, len(sentences), 16):
|
||||
batch = sentences[i:i + 16]
|
||||
enc = tok(batch, return_tensors="pt", truncation=True, max_length=256, padding=True)
|
||||
with torch.no_grad():
|
||||
logits = model(**enc).logits
|
||||
if n == 1:
|
||||
vals = logits.reshape(-1).tolist()
|
||||
else:
|
||||
probs = torch.softmax(logits, dim=-1)
|
||||
levels = torch.arange(1, n + 1, dtype=probs.dtype)
|
||||
vals = (probs * levels).sum(-1).reshape(-1).tolist()
|
||||
values.extend(vals)
|
||||
return values
|
||||
|
||||
|
||||
def rate_sections(md_by_num: dict[int, str]) -> dict[int, str]:
|
||||
"""{num: section_md} → {num: hint} only for sections that are too hard.
|
||||
|
||||
Empty dict if the gate is off. Blocking (CPU) — call inside to_thread.
|
||||
"""
|
||||
if _model() is None:
|
||||
return {}
|
||||
out: dict[int, str] = {}
|
||||
for num, md in md_by_num.items():
|
||||
sentences = _sentences(_prose(md or ""))
|
||||
if len(sentences) < 2: # almost only code / too short → skip
|
||||
continue
|
||||
values = _scores(sentences)
|
||||
if not values:
|
||||
continue
|
||||
mean = sum(values) / len(values)
|
||||
hard = sum(1 for w in values if w > READABILITY_HARD) / len(values)
|
||||
# Too hard = high mean OR too many hard individual sentences (outlier nests).
|
||||
if mean > READABILITY_MAX or hard >= READABILITY_HARD_SHARE:
|
||||
# German revision hint fed to the (German-writing) writer agent — kept German on purpose.
|
||||
out[num] = (
|
||||
f"Zu schwer lesbar (Ø {mean:.1f}/7, {hard * 100:.0f}% harte Sätze): "
|
||||
"kürzere Sätze, einfachere Wörter, weniger Schachtelsätze, mehr Examples."
|
||||
)
|
||||
return out
|
||||
@@ -1,141 +0,0 @@
|
||||
"""Lernschulden-Regeln: Progression und Deckel für offene Guides — die EINZIGE Quelle.
|
||||
|
||||
Regeln (nur Neu-Erstellungen; Themen + Bausteine unbegrenzt):
|
||||
- Format „Guide": höchstens 3 erstellte, nicht absolvierte Guides
|
||||
- Keine Progression/Vorstufe mehr (nur ein Guide-Format).
|
||||
- Absolviert: ALLE Bausteine (Section-Titel) des neuesten fertigen Guides haben
|
||||
eine bestandene Prüfung. Rest ist read-only (kein Fortschritt, keine Prüfung).
|
||||
Alle Funktionen arbeiten auf einmal geladenen Daten (lade_lernstand) — keine
|
||||
Query-Schleifen mehr pro Guide.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from database import list_baustein_scores_all, subs_je_ebene_alle, list_guides, list_progress_all
|
||||
from guide import guide_slot_dateien
|
||||
from lernen import cap_final, STUFEN, _schwelle
|
||||
from paths import bausteine_path, guide_content_path
|
||||
from textkit import _norm_titel
|
||||
|
||||
MAX_OFFENE_GUIDES = 3
|
||||
# Nur noch EIN Format „Guide" (alle relevanten Bausteine, Prüfung 0–cap). Keine Progression,
|
||||
# keine Vorstufe → der Guide ist immer freischaltbar. „Rest"/FullGuide separat.
|
||||
VORSTUFE: dict[str, str] = {}
|
||||
FREISCHALT_LEVEL: dict[str, str] = {}
|
||||
FORMATE = ("Guide",)
|
||||
|
||||
# 4 Lernstufen (Floor in % des cap) — Schlüssel aus lernen.STUFEN.
|
||||
_LEVEL_WORT = {
|
||||
"anfaenger": "auf Anfänger (20 %)",
|
||||
"fortgeschritten": "auf Fortgeschritten (40 %)",
|
||||
"experte": "auf Experte (60 %)",
|
||||
"meister": "meistern (100 %)",
|
||||
}
|
||||
|
||||
|
||||
async def lade_lernstand() -> tuple[list[dict], dict[str, set[str]], dict[str, dict[str, set[str]]]]:
|
||||
"""Guides + Kapitel-Fortschritt + Bausteine je Stufe.
|
||||
|
||||
levels: {"anfaenger"/"fortgeschritten"/"experte"/"meister": {topic → normalisierte Titel}}.
|
||||
Stufe je Baustein wird aus Score + cap (4×relevante Subs) abgeleitet.
|
||||
"""
|
||||
scores = await list_baustein_scores_all()
|
||||
ebenen = await subs_je_ebene_alle()
|
||||
levels: dict[str, dict[str, set[str]]] = {key: {} for key, _ in STUFEN}
|
||||
for topic, baustein, score in scores:
|
||||
cf = cap_final(ebenen.get((topic, _norm_titel(baustein)), {}))
|
||||
for key, p in STUFEN:
|
||||
if cf and score >= _schwelle(p, cf):
|
||||
levels[key].setdefault(topic, set()).add(_norm_titel(baustein))
|
||||
return await list_guides(), await list_progress_all(), levels
|
||||
|
||||
|
||||
def _content_json(topic: str, fmt: str) -> dict | None:
|
||||
path = guide_content_path(topic, fmt)
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
|
||||
|
||||
def _section_titel(topic: str, fmt: str) -> set[str] | None:
|
||||
"""Normalisierte Baustein-Titel (Sections) aus dem Guide-Content."""
|
||||
content = _content_json(topic, fmt)
|
||||
if content is None:
|
||||
return None
|
||||
return {
|
||||
_norm_titel(s.get("title", ""))
|
||||
for ch in content.get("chapters", [])
|
||||
for s in ch.get("sections", [])
|
||||
}
|
||||
|
||||
|
||||
def _neueste_done(guides: list[dict], fmt: str) -> dict[str, dict]:
|
||||
"""Pro Thema der neueste fertige Guide dieses Formats."""
|
||||
neueste: dict[str, dict] = {}
|
||||
for g in guides:
|
||||
if g["format"] == fmt and g["status"] == "done":
|
||||
if g["topic"] not in neueste or g["created_at"] > neueste[g["topic"]]["created_at"]:
|
||||
neueste[g["topic"]] = g
|
||||
return neueste
|
||||
|
||||
|
||||
def _guide_alle(g: dict, progress: dict[str, set[str]], levelset: dict[str, set[str]]) -> bool:
|
||||
"""Sind ALLE Bausteine des Guides auf dem geforderten Niveau?"""
|
||||
sections = _section_titel(g["topic"], g["format"])
|
||||
return bool(sections) and sections <= levelset.get(g["topic"], set())
|
||||
|
||||
|
||||
def ist_level(topic: str, fmt: str, guides: list[dict], progress: dict[str, set[str]], levelset: dict[str, set[str]]) -> bool:
|
||||
"""Neuester fertiger Guide (Thema+Format): alle Bausteine auf dem Niveau von levelset?"""
|
||||
g = _neueste_done(guides, fmt).get(topic)
|
||||
return g is not None and _guide_alle(g, progress, levelset)
|
||||
|
||||
|
||||
def ist_absolviert(topic: str, fmt: str, guides: list[dict], progress: dict[str, set[str]], levels: dict[str, dict[str, set[str]]]) -> bool:
|
||||
"""Alle Bausteine des neuesten fertigen Guides mindestens Anfänger (≥20 %)?"""
|
||||
return ist_level(topic, fmt, guides, progress, levels["anfaenger"])
|
||||
|
||||
|
||||
def thema_abgeschlossen(topic: str, guides: list[dict], progress: dict[str, set[str]], levels: dict[str, dict[str, set[str]]]) -> bool:
|
||||
"""Thema fertig: neuester fertiger Guide, alle Bausteine auf Meister (100 %)?"""
|
||||
return ist_level(topic, "Guide", guides, progress, levels["meister"])
|
||||
|
||||
|
||||
def formate_stats(guides: list[dict], progress: dict[str, set[str]], levels: dict[str, dict[str, set[str]]]) -> dict:
|
||||
"""Pro Format erstellt/absolviert — pro Thema zählt nur der neueste fertige Guide."""
|
||||
formate = {}
|
||||
for fmt in FORMATE:
|
||||
neueste = _neueste_done(guides, fmt)
|
||||
absolviert = sum(1 for g in neueste.values() if _guide_alle(g, progress, levels["anfaenger"]))
|
||||
formate[fmt] = {"erstellt": len(neueste), "absolviert": absolviert}
|
||||
return formate
|
||||
|
||||
|
||||
def guide_lock(topic: str, fmt: str, guides: list[dict], progress: dict[str, set[str]], levels: dict[str, dict[str, set[str]]]) -> str | None:
|
||||
"""Grund, warum ein Neu-Start für Thema+Format gesperrt ist — None = erlaubt.
|
||||
|
||||
Exakt die Regeln aus POST /guides: Bausteine nötig, kein Duplikat-Start,
|
||||
Lernschulden nur für echte Neu-Erstellungen (Resume/Regenerieren frei).
|
||||
"""
|
||||
if not bausteine_path(topic).exists():
|
||||
return "Erst Bausteine erstellen"
|
||||
for g in guides:
|
||||
if g["topic"] == topic and g["format"] == fmt and g["status"] in ("queued", "generating"):
|
||||
return "Generierung läuft bereits"
|
||||
content = guide_content_path(topic, fmt)
|
||||
if not content.exists() and not guide_slot_dateien(content):
|
||||
vorstufe = VORSTUFE.get(fmt)
|
||||
if vorstufe:
|
||||
stufe = FREISCHALT_LEVEL[fmt] # absolviert=10 · verstanden=20 · gemeistert=30
|
||||
if not ist_level(topic, vorstufe, guides, progress, levels[stufe]):
|
||||
return f"Erst den {vorstufe} dieses Themas {_LEVEL_WORT[stufe]}"
|
||||
stat = formate_stats(guides, progress, levels).get(fmt, {"erstellt": 0, "absolviert": 0})
|
||||
offen = stat["erstellt"] - stat["absolviert"]
|
||||
if offen >= MAX_OFFENE_GUIDES:
|
||||
return f"Erst {fmt}s absolvieren — maximal {MAX_OFFENE_GUIDES} offene erlaubt ({offen} offen)"
|
||||
return None
|
||||
@@ -14,33 +14,33 @@ from database import (
|
||||
create_topic, list_topics as db_list_topics, delete_topic,
|
||||
list_progress, set_progress, delete_progress,
|
||||
create_element, list_elements, get_element, update_element, delete_element,
|
||||
list_baustein_progress, get_baustein_progress, set_offene_frage,
|
||||
set_baustein_score_and_streak, set_baustein_absolviert,
|
||||
delete_baustein_daten, delete_baustein_progress, subs_je_ebene, subs_je_ebene_roh,
|
||||
delete_topic_pipeline, delete_quelle, get_guide_content, delete_guide_content,
|
||||
list_block_progress, get_block_progress, set_open_question,
|
||||
set_block_score_and_streak, set_block_completed,
|
||||
delete_block_data, delete_block_progress, subs_per_level, subs_per_level_raw,
|
||||
delete_topic_pipeline, delete_source, get_guide_content, delete_guide_content,
|
||||
get_sub_artefakte,
|
||||
)
|
||||
from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, reset_bausteine_ab_step, lade_quelle, lade_uebersicht, subbausteine_titel, subbausteine_frei, lade_frage_muster, lade_frage_muster_frei
|
||||
from blocks import generate_blocks, cancel_blocks, blocks_status, active_blocks, reset_blocks, reset_blocks_ab_step, load_source, load_overview, subblocks_title, subblocks_frei, load_question_pattern, load_question_pattern_free
|
||||
from elements import generate_element, chat_with_guide, chat_with_element, check_element, style_element, refine_suggestion
|
||||
from lernen import baustein_chat, baustein_diskussion, baustein_element_anlegen, pruefung_bewertung, pruefung_bewertung_schnell, pruefung_frage, pruefung_frage_variante, quiz_generieren, lueckwahl_generieren, lueckentext_generieren, lueckentext_pruefen, huerden_distraktor_block, score_berechnen, floor_aus_score, stufe_aus_score, cap_final, cap_aktuell, freie_ebene, schwellen, punkte_delta, deckel_nachfrage
|
||||
from guide import generate_guide, guide_slot_dateien, guide_fertig_step, block_pruefen, block_uebernehmen, content_fuer_ebene
|
||||
from learning import block_chat, block_discussion, create_block_element, exam_rating, exam_rating_fast, exam_question, exam_question_variant, generate_quiz, generate_gapchoice, generate_gaptext, check_gaptext, hurdles_distractor_block, compute_score, floor_from_score, level_from_score, cap_final, cap_aktuell, freie_level, thresholds, points_delta, cap_followup
|
||||
from guide import generate_guide, guide_slot_files, guide_done_step, block_pruefen, block_adopt, content_fuer_level
|
||||
from pipeline import cancel_guide
|
||||
from regeln import FORMATE, formate_stats, guide_lock, ist_absolviert, lade_lernstand, thema_abgeschlossen
|
||||
from rules import FORMATE, formats_stats, guide_lock, ist_completed, load_learnstate, topic_completed
|
||||
from models import (
|
||||
GuideCreateRequest, GuideResponse,
|
||||
TopicCreateRequest,
|
||||
BausteineCreateRequest, BausteineResetStepRequest, BausteineStatusResponse,
|
||||
BlocksCreateRequest, BlocksResetStepRequest, BlocksStatusResponse,
|
||||
GuideChatRequest, GuideChatResponse,
|
||||
ElementCreateRequest, ElementChatRequest, ElementChatResponse, ElementResponse,
|
||||
ElementUpdateRequest, ElementCheckRequest, ElementCheckResponse, ElementStyleResponse,
|
||||
ElementRefineRequest, ElementRefineResponse,
|
||||
ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo,
|
||||
FolderResponse, BausteineQuelleUpdate, BausteineQuelleResponse, BausteinUebersicht,
|
||||
BausteinChatRequest, BausteinChatResponse,
|
||||
BausteinPruefungRequest, BausteinPruefungResponse, BausteinLernstandResponse,
|
||||
FolderResponse, BlocksSourceUpdate, BlocksSourceResponse, BlockOverview,
|
||||
BlockChatRequest, BlockChatResponse,
|
||||
BlockExamRequest, BlockExamResponse, BlockLearnStateResponse,
|
||||
BlockPruefenRequest, BlockPruefenResponse, BlockUebernehmenRequest, BlockUebernehmenResponse,
|
||||
)
|
||||
from paths import bausteine_topics, guide_content_path, project_dir, topic_dir, quelle_path, safe_ordner
|
||||
from paths import blocks_topics, guide_content_path, project_dir, topic_dir, source_path, safe_folder
|
||||
from fsutil import atomic_write_json
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
@@ -56,28 +56,28 @@ async def get_topics():
|
||||
db_topics = await db_list_topics()
|
||||
guides = await list_guides()
|
||||
derived = {g["topic"] for g in guides}
|
||||
derived.update(bausteine_topics())
|
||||
derived.update(job["topic"] for job in active_bausteine())
|
||||
# DB ist führend (Reihenfolge: neueste zuerst); Abgeleitetes ohne DB-Eintrag hinten anhängen
|
||||
derived.update(blocks_topics())
|
||||
derived.update(job["topic"] for job in active_blocks())
|
||||
# DB is authoritative (order: newest first); append derived entries without a DB row at the end
|
||||
return db_topics + sorted(derived - set(db_topics))
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_stats():
|
||||
"""Tracker: Themen-Anzahl + pro Format erstellt/absolviert."""
|
||||
guides, progress, levels = await lade_lernstand()
|
||||
themen = set(await db_list_topics()) | {g["topic"] for g in guides} | set(bausteine_topics())
|
||||
"""Tracker: number of topics + per format created/completed."""
|
||||
guides, progress, levels = await load_learnstate()
|
||||
topics = set(await db_list_topics()) | {g["topic"] for g in guides} | set(blocks_topics())
|
||||
if PROJECTS_DIR.is_dir():
|
||||
themen |= {e.name for e in PROJECTS_DIR.iterdir() if e.is_dir()}
|
||||
return {"themen": len(themen), "formate": formate_stats(guides, progress, levels)}
|
||||
topics |= {e.name for e in PROJECTS_DIR.iterdir() if e.is_dir()}
|
||||
return {"topics": len(topics), "formats": formats_stats(guides, progress, levels)}
|
||||
|
||||
|
||||
@router.get("/topics/fortschritt")
|
||||
async def topic_fortschritt(topic: str):
|
||||
"""Absolviert-Status pro Format + Themen-Abschluss — fürs Freischalten der nächsten Ausbaustufe."""
|
||||
guides, progress, levels = await lade_lernstand()
|
||||
status = {fmt: ist_absolviert(topic, fmt, guides, progress, levels) for fmt in FORMATE}
|
||||
status["abgeschlossen"] = thema_abgeschlossen(topic, guides, progress, levels)
|
||||
@router.get("/topics/progress")
|
||||
async def topic_progress(topic: str):
|
||||
"""Completion status per format + topic completion — for unlocking the next expansion stage."""
|
||||
guides, progress, levels = await load_learnstate()
|
||||
status = {fmt: ist_completed(topic, fmt, guides, progress, levels) for fmt in FORMATE}
|
||||
status["completed"] = topic_completed(topic, guides, progress, levels)
|
||||
return status
|
||||
|
||||
|
||||
@@ -90,9 +90,9 @@ async def add_topic(req: TopicCreateRequest):
|
||||
@router.delete("/topics")
|
||||
async def remove_topic(topic: str):
|
||||
await delete_topic(topic)
|
||||
await delete_baustein_daten(topic)
|
||||
await delete_block_data(topic)
|
||||
await delete_topic_pipeline(topic)
|
||||
await delete_quelle(topic) # Themen-Config (DB) — beim Thema-Löschen mit weg
|
||||
await delete_source(topic) # topic config (DB) — removed together with the topic
|
||||
await delete_guide_content(topic)
|
||||
shutil.rmtree(topic_dir(topic), ignore_errors=True)
|
||||
return {"ok": True}
|
||||
@@ -100,7 +100,7 @@ async def remove_topic(topic: str):
|
||||
|
||||
def _safe_project_name(name: str) -> str:
|
||||
if not name or "/" in name or "\\" in name or ".." in name or "\x00" in name:
|
||||
raise HTTPException(400, "Ungültiger Projektname")
|
||||
raise HTTPException(400, "Invalid project name")
|
||||
return name
|
||||
|
||||
|
||||
@@ -116,356 +116,356 @@ async def remove_project(name: str):
|
||||
_safe_project_name(name)
|
||||
pdir = project_dir(name)
|
||||
if not pdir.is_dir():
|
||||
raise HTTPException(404, "Projekt nicht gefunden")
|
||||
raise HTTPException(404, "Project not found")
|
||||
shutil.rmtree(pdir)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/folders", response_model=list[FolderResponse])
|
||||
async def list_folders(kind: str):
|
||||
"""Ordner für die Quellen-Auswahl: kind=projekt → projects/, kind=uni → uni/."""
|
||||
"""Folders for the source selection: kind=projekt → projects/, kind=uni → uni/."""
|
||||
base = {"projekt": (PROJECTS_DIR, "projects"), "uni": (UNI_DIR, "uni")}.get(kind)
|
||||
if base is None:
|
||||
raise HTTPException(400, "kind muss 'projekt' oder 'uni' sein")
|
||||
raise HTTPException(400, "kind must be 'projekt' or 'uni'")
|
||||
root, prefix = base
|
||||
if not root.is_dir():
|
||||
return []
|
||||
return [{"name": e.name, "ort": f"{prefix}/{e.name}"} for e in sorted(root.iterdir()) if e.is_dir()]
|
||||
return [{"name": e.name, "location": f"{prefix}/{e.name}"} for e in sorted(root.iterdir()) if e.is_dir()]
|
||||
|
||||
|
||||
# --- Bausteine ---
|
||||
# --- Blocks ---
|
||||
|
||||
@router.get("/bausteine/status", response_model=BausteineStatusResponse)
|
||||
async def get_bausteine_status(topic: str):
|
||||
return bausteine_status(topic)
|
||||
@router.get("/blocks/status", response_model=BlocksStatusResponse)
|
||||
async def get_blocks_status(topic: str):
|
||||
return await blocks_status(topic)
|
||||
|
||||
|
||||
@router.get("/bausteine/active")
|
||||
async def get_active_bausteine():
|
||||
return active_bausteine()
|
||||
@router.get("/blocks/active")
|
||||
async def get_active_blocks():
|
||||
return active_blocks()
|
||||
|
||||
|
||||
@router.post("/bausteine")
|
||||
async def create_bausteine(req: BausteineCreateRequest):
|
||||
@router.post("/blocks")
|
||||
async def create_blocks(req: BlocksCreateRequest):
|
||||
topic = req.topic.strip()
|
||||
if bausteine_status(topic)["generating"]:
|
||||
if (await blocks_status(topic))["generating"]:
|
||||
return {"ok": True, "status": "already_generating"}
|
||||
await create_topic(topic)
|
||||
qp = quelle_path(topic)
|
||||
# Quelle nur beim ERSTEN Mal festschreiben; ▶/Resume erhält die bestehende Wahl.
|
||||
qp = source_path(topic)
|
||||
# Persist the source only the FIRST time; ▶/Resume keeps the existing choice.
|
||||
if not qp.exists():
|
||||
typ, ort = req.source_type, req.source_ort.strip()
|
||||
if typ in ("projekt", "uni"):
|
||||
ordner = safe_ordner(ort)
|
||||
if ordner is None or not ordner.is_dir():
|
||||
raise HTTPException(400, "Ordner ungültig oder nicht gefunden (Pfad relativ zum Projekt-Root, kein ../).")
|
||||
elif typ == "link":
|
||||
if not ort.lower().startswith(("http://", "https://")):
|
||||
raise HTTPException(400, "Link muss mit http:// oder https:// beginnen.")
|
||||
type, location = req.source_type, req.source_location.strip()
|
||||
if type in ("projekt", "uni"):
|
||||
folder = safe_folder(location)
|
||||
if folder is None or not folder.is_dir():
|
||||
raise HTTPException(400, "Folder invalid or not found (path relative to the project root, no ../).")
|
||||
elif type == "link":
|
||||
if not location.lower().startswith(("http://", "https://")):
|
||||
raise HTTPException(400, "Link must start with http:// or https://.")
|
||||
qp.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(qp, {"type": typ, "ort": ort, "spec": req.instructions.strip()})
|
||||
asyncio.create_task(generate_bausteine(topic, req.instructions.strip(), req.provider, ab_phase=req.ab_phase, ab_step=req.ab_step))
|
||||
atomic_write_json(qp, {"type": type, "location": location, "spec": req.instructions.strip()})
|
||||
asyncio.create_task(generate_blocks(topic, req.instructions.strip(), req.provider, ab_phase=req.ab_phase, ab_step=req.ab_step))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/bausteine/cancel")
|
||||
async def cancel_bausteine_route(topic: str):
|
||||
if not cancel_bausteine(topic):
|
||||
raise HTTPException(404, "Keine laufende Generierung")
|
||||
@router.post("/blocks/cancel")
|
||||
async def cancel_blocks_route(topic: str):
|
||||
if not cancel_blocks(topic):
|
||||
raise HTTPException(404, "No running generation")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/bausteine")
|
||||
async def remove_bausteine(topic: str):
|
||||
reset_bausteine(topic) # Dateien: Crawl + Sichtung + Inventar…Fragen weg; quelle.json bleibt
|
||||
await delete_topic_pipeline(topic) # DB: Bausteine-Bereich weg; Themen-Config (quelle) bleibt
|
||||
@router.delete("/blocks")
|
||||
async def remove_blocks(topic: str):
|
||||
reset_blocks(topic) # Files: crawl + triage + inventory…questions gone; source.json stays
|
||||
await delete_topic_pipeline(topic) # DB: blocks area gone; topic config (source) stays
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/bausteine/reset-step")
|
||||
async def reset_bausteine_step(req: BausteineResetStepRequest):
|
||||
@router.post("/blocks/reset-step")
|
||||
async def reset_blocks_step(req: BlocksResetStepRequest):
|
||||
topic = req.topic.strip()
|
||||
if bausteine_status(topic)["generating"]:
|
||||
return {"ok": True, "status": "generating"} # nicht in laufende Generierung eingreifen
|
||||
await reset_bausteine_ab_step(topic, req.ab_step)
|
||||
if (await blocks_status(topic))["generating"]:
|
||||
return {"ok": True, "status": "generating"} # don't interfere with a running generation
|
||||
await reset_blocks_ab_step(topic, req.ab_step)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/bausteine/fortschritt")
|
||||
async def reset_baustein_fortschritt(topic: str, baustein: str):
|
||||
"""Lern-Fortschritt EINES Bausteins auf null (Score/Streak/Flags/offene Frage)."""
|
||||
await delete_baustein_progress(topic, baustein)
|
||||
@router.delete("/blocks/progress")
|
||||
async def reset_block_progress(topic: str, block: str):
|
||||
"""Reset learning progress of ONE block to zero (score/streak/flags/open question)."""
|
||||
await delete_block_progress(topic, block)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _validate_quelle(typ: str, ort: str) -> None:
|
||||
"""Quellen-Eingabe prüfen (gleiche Regeln wie beim Erstellen)."""
|
||||
if typ in ("projekt", "uni"):
|
||||
ordner = safe_ordner(ort)
|
||||
if ordner is None or not ordner.is_dir():
|
||||
raise HTTPException(400, "Ordner ungültig oder nicht gefunden (Pfad relativ zum Projekt-Root, kein ../).")
|
||||
elif typ == "link":
|
||||
if not ort.lower().startswith(("http://", "https://")):
|
||||
raise HTTPException(400, "Link muss mit http:// oder https:// beginnen.")
|
||||
def _validate_source(type: str, location: str) -> None:
|
||||
"""Check source input (same rules as on creation)."""
|
||||
if type in ("projekt", "uni"):
|
||||
folder = safe_folder(location)
|
||||
if folder is None or not folder.is_dir():
|
||||
raise HTTPException(400, "Folder invalid or not found (path relative to the project root, no ../).")
|
||||
elif type == "link":
|
||||
if not location.lower().startswith(("http://", "https://")):
|
||||
raise HTTPException(400, "Link must start with http:// or https://.")
|
||||
|
||||
|
||||
@router.get("/bausteine/quelle", response_model=BausteineQuelleResponse)
|
||||
async def get_bausteine_quelle(topic: str):
|
||||
return lade_quelle(topic)
|
||||
@router.get("/blocks/source", response_model=BlocksSourceResponse)
|
||||
async def get_blocks_source(topic: str):
|
||||
return load_source(topic)
|
||||
|
||||
|
||||
@router.put("/bausteine/quelle", response_model=BausteineQuelleResponse)
|
||||
async def update_bausteine_quelle(req: BausteineQuelleUpdate):
|
||||
"""Nur speichern — KEINE Neugenerierung. Quellen-/Spec-Wahl überschreiben."""
|
||||
topic, typ, ort = req.topic.strip(), req.type, req.ort.strip()
|
||||
_validate_quelle(typ, ort)
|
||||
qp = quelle_path(topic)
|
||||
@router.put("/blocks/source", response_model=BlocksSourceResponse)
|
||||
async def update_blocks_source(req: BlocksSourceUpdate):
|
||||
"""Only save — NO regeneration. Overwrite the source/spec choice."""
|
||||
topic, type, location = req.topic.strip(), req.type, req.location.strip()
|
||||
_validate_source(type, location)
|
||||
qp = source_path(topic)
|
||||
qp.parent.mkdir(parents=True, exist_ok=True)
|
||||
daten = {"type": typ, "ort": ort, "spec": req.spec.strip()}
|
||||
atomic_write_json(qp, daten)
|
||||
return daten
|
||||
data = {"type": type, "location": location, "spec": req.spec.strip()}
|
||||
atomic_write_json(qp, data)
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/bausteine/uebersicht", response_model=list[BausteinUebersicht])
|
||||
async def get_bausteine_uebersicht(topic: str):
|
||||
return await lade_uebersicht(topic)
|
||||
@router.get("/blocks/overview", response_model=list[BlockOverview])
|
||||
async def get_blocks_uebersicht(topic: str):
|
||||
return await load_overview(topic)
|
||||
|
||||
|
||||
@router.get("/bausteine/frage-muster")
|
||||
async def get_frage_muster(topic: str, baustein: str):
|
||||
"""Freigeschaltete Frage-Muster eines Bausteins (bis zur aktuellen Ebene; leer = Live)."""
|
||||
stand = await get_baustein_progress(topic, baustein)
|
||||
fe = freie_ebene(stand["gute_antworten"], await subs_je_ebene(topic, baustein))
|
||||
return {"muster": await lade_frage_muster_frei(topic, baustein, fe)}
|
||||
@router.get("/blocks/question-pattern")
|
||||
async def get_question_pattern(topic: str, block: str):
|
||||
"""Unlocked question patterns of a block (up to the current level; empty = live)."""
|
||||
state = await get_block_progress(topic, block)
|
||||
fe = freie_level(state["good_answers"], await subs_per_level(topic, block))
|
||||
return {"pattern": await load_question_pattern_free(topic, block, fe)}
|
||||
|
||||
|
||||
@router.get("/bausteine/artefakte")
|
||||
async def get_artefakte(topic: str, typ: str | None = None):
|
||||
"""Lern-Artefakte (Karteikarten/Beispiele) je Thema, gruppiert nach Baustein-Norm — je Subbaustein."""
|
||||
rows = await get_sub_artefakte(topic, typ)
|
||||
@router.get("/blocks/artefakte")
|
||||
async def get_artefakte(topic: str, type: str | None = None):
|
||||
"""Learning artifacts (flashcards/examples) per topic, grouped by block norm — per subblock."""
|
||||
rows = await get_sub_artefakte(topic, type)
|
||||
out: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
b = out.setdefault(r["baustein_norm"], {"baustein": r["baustein"], "karteikarte": [], "beispiel": []})
|
||||
if r["baustein"] and not b["baustein"]:
|
||||
b["baustein"] = r["baustein"]
|
||||
b = out.setdefault(r["block_norm"], {"block": r["block"], "flashcard": [], "example": []})
|
||||
if r["block"] and not b["block"]:
|
||||
b["block"] = r["block"]
|
||||
try:
|
||||
daten = json.loads(r["daten"])
|
||||
data = json.loads(r["data"])
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if r["typ"] in ("karteikarte", "beispiel"):
|
||||
b[r["typ"]].append({"subbaustein": r["sub_titel"], **daten})
|
||||
if r["type"] in ("flashcard", "example"):
|
||||
b[r["type"]].append({"subblock": r["sub_title"], **data})
|
||||
return {"artefakte": out}
|
||||
|
||||
|
||||
# --- Baustein-Lernen: Chat, Prüfung ---
|
||||
# --- Block learning: chat, exam ---
|
||||
|
||||
@router.get("/bausteine/lernstand", response_model=BausteinLernstandResponse)
|
||||
async def baustein_lernstand(topic: str):
|
||||
"""Prüfungs-Stand pro Baustein (roher Titel als Key). cap_final = alle Subs × 25;
|
||||
cap_aktuell + freie_ebene aus dem Score — für ALLE Bausteine (auch ungeprüfte)."""
|
||||
progress = {p["baustein"]: p for p in await list_baustein_progress(topic)}
|
||||
ebenen = await subs_je_ebene_roh(topic)
|
||||
@router.get("/blocks/learnstate", response_model=BlockLearnStateResponse)
|
||||
async def block_learnstate(topic: str):
|
||||
"""Exam state per block (raw title as key). cap_final = all subs × 25;
|
||||
cap_aktuell + freie_level from the score — for ALL blocks (even unexamined)."""
|
||||
progress = {p["block"]: p for p in await list_block_progress(topic)}
|
||||
levels = await subs_per_level_raw(topic)
|
||||
|
||||
def _stand(score: int, streak: int, n_je_ebene: dict[int, int]) -> dict:
|
||||
def _state(score: int, streak: int, n_je_level: dict[int, int]) -> dict:
|
||||
return {
|
||||
"gute_antworten": score, "streak": streak,
|
||||
"cap": cap_final(n_je_ebene),
|
||||
"cap_aktuell": cap_aktuell(score, n_je_ebene),
|
||||
"freie_ebene": freie_ebene(score, n_je_ebene),
|
||||
"good_answers": score, "streak": streak,
|
||||
"cap": cap_final(n_je_level),
|
||||
"cap_aktuell": cap_aktuell(score, n_je_level),
|
||||
"freie_level": freie_level(score, n_je_level),
|
||||
}
|
||||
|
||||
bausteine = {
|
||||
b: _stand(progress[b]["gute_antworten"] if b in progress else 0,
|
||||
blocks = {
|
||||
b: _state(progress[b]["good_answers"] if b in progress else 0,
|
||||
progress[b]["streak"] if b in progress else 0, n)
|
||||
for b, n in ebenen.items()
|
||||
for b, n in levels.items()
|
||||
}
|
||||
# Altbestand-Bausteine mit Prüfung, aber ohne Subs → leere Ebenen (cap 0).
|
||||
# Legacy blocks with an exam but without subs → empty levels (cap 0).
|
||||
for b, p in progress.items():
|
||||
if b not in bausteine:
|
||||
bausteine[b] = _stand(p["gute_antworten"], p["streak"], {})
|
||||
return {"bausteine": bausteine}
|
||||
if b not in blocks:
|
||||
blocks[b] = _state(p["good_answers"], p["streak"], {})
|
||||
return {"blocks": blocks}
|
||||
|
||||
|
||||
@router.post("/bausteine/chat", response_model=BausteinChatResponse)
|
||||
async def baustein_chat_route(req: BausteinChatRequest):
|
||||
reply = await baustein_chat(
|
||||
req.topic, req.baustein, req.section, req.section_kompakt,
|
||||
@router.post("/blocks/chat", response_model=BlockChatResponse)
|
||||
async def block_chat_route(req: BlockChatRequest):
|
||||
reply = await block_chat(
|
||||
req.topic, req.block, req.section, req.section_compact,
|
||||
[m.model_dump() for m in req.messages], provider=req.provider,
|
||||
)
|
||||
return {"reply": reply}
|
||||
|
||||
|
||||
# Bewertungen je (topic, baustein) serialisieren — sonst überschreiben zwei
|
||||
# gleichzeitige Bewertungen den absoluten Score mit veralteter Basis (Race).
|
||||
_pruef_locks: dict[tuple[str, str], asyncio.Lock] = {}
|
||||
# Serialize ratings per (topic, block) — otherwise two simultaneous ratings would
|
||||
# overwrite the absolute score with a stale base (race).
|
||||
_check_locks: dict[tuple[str, str], asyncio.Lock] = {}
|
||||
|
||||
|
||||
def _pruef_lock(topic: str, baustein: str) -> asyncio.Lock:
|
||||
key = (topic, baustein)
|
||||
lock = _pruef_locks.get(key)
|
||||
def _check_lock(topic: str, block: str) -> asyncio.Lock:
|
||||
key = (topic, block)
|
||||
lock = _check_locks.get(key)
|
||||
if lock is None:
|
||||
lock = _pruef_locks[key] = asyncio.Lock()
|
||||
lock = _check_locks[key] = asyncio.Lock()
|
||||
return lock
|
||||
|
||||
|
||||
def _basis(stand: dict, frage: str) -> tuple[int, bool]:
|
||||
"""Score-Basis VOR der Frage. Gleiche offene Frage → Re-Bewertung auf derselben Basis
|
||||
(idempotent); sonst neue Frage auf dem aktuellen Stand. → (basis, re_bewertung)."""
|
||||
re_bewertung = stand["offene_frage"] == frage and stand["offene_basis"] is not None
|
||||
return (stand["offene_basis"] if re_bewertung else stand["gute_antworten"]), re_bewertung
|
||||
def _basis(state: dict, question: str) -> tuple[int, bool]:
|
||||
"""Score base BEFORE the question. Same open question → re-rating on the same base
|
||||
(idempotent); otherwise a new question on the current state. → (basis, re_rating)."""
|
||||
re_rating = state["offene_question"] == question and state["offene_basis"] is not None
|
||||
return (state["offene_basis"] if re_rating else state["good_answers"]), re_rating
|
||||
|
||||
|
||||
def _farbe(punkte: int) -> str:
|
||||
"""Punkte-Delta → grobe Einfärbung der Bubble."""
|
||||
return "gut" if punkte > 0 else ("neutral" if punkte == 0 else "schlecht")
|
||||
def _color(points: int) -> str:
|
||||
"""Points delta → rough bubble coloring."""
|
||||
return "gut" if points > 0 else ("neutral" if points == 0 else "schlecht")
|
||||
|
||||
|
||||
async def _buche(req, frage: str, niveau: str, n_je_ebene: dict[int, int]) -> dict:
|
||||
"""Score+Streak driftfrei buchen (Lock + offene_frage/offene_streak-Anker). Niveau →
|
||||
Punkt-Delta (streak-moduliert) bzw. progressiver Malus bei Fehler. cap_aktuell wird aus
|
||||
der Basis abgeleitet (verzögerte Freischaltung an der Ebenen-Schwelle); Element einmalig
|
||||
ab Anfänger-Stufe. Re-Bewertung derselben Frage nutzt den offenen Streak-Anker → idempotent."""
|
||||
async with _pruef_lock(req.topic, req.baustein):
|
||||
stand = await get_baustein_progress(req.topic, req.baustein)
|
||||
war_stufe = stand["absolviert"] is not None # Element-Guard: schon je angelegt?
|
||||
basis, re_bewertung = _basis(stand, frage)
|
||||
streak_basis = stand["offene_streak"] if re_bewertung else stand["streak"]
|
||||
if not re_bewertung:
|
||||
await set_offene_frage(req.topic, req.baustein, frage, basis, stand["streak"])
|
||||
s = schwellen(n_je_ebene)
|
||||
async def _book_score(req, question: str, tier: str, n_je_level: dict[int, int]) -> dict:
|
||||
"""Book score+streak drift-free (lock + open-question/open-streak anchor). Tier →
|
||||
points delta (streak-modulated) or progressive malus on error. cap_aktuell is derived
|
||||
from the base (delayed unlock at the level threshold); element once from beginner level.
|
||||
Re-rating of the same question uses the open streak anchor → idempotent."""
|
||||
async with _check_lock(req.topic, req.block):
|
||||
state = await get_block_progress(req.topic, req.block)
|
||||
was_level = state["completed"] is not None # element guard: ever created already?
|
||||
basis, re_rating = _basis(state, question)
|
||||
streak_basis = state["offene_streak"] if re_rating else state["streak"]
|
||||
if not re_rating:
|
||||
await set_open_question(req.topic, req.block, question, basis, state["streak"])
|
||||
s = thresholds(n_je_level)
|
||||
cf = s[-1]
|
||||
ca = cap_aktuell(basis, n_je_ebene)
|
||||
floor = floor_aus_score(basis, cf, s)
|
||||
d, neue_streak = punkte_delta(niveau, streak_basis, basis, ca)
|
||||
score = score_berechnen(basis, d, floor, ca, cf)
|
||||
punkte = score - basis
|
||||
gute, streak = await set_baustein_score_and_streak(req.topic, req.baustein, score, neue_streak)
|
||||
# Lern-Element einmalig anlegen, sobald die erste Stufe (Anfänger) erreicht ist.
|
||||
if not war_stufe and stufe_aus_score(score, cf) is not None:
|
||||
if await set_baustein_absolviert(req.topic, req.baustein):
|
||||
asyncio.create_task(baustein_element_anlegen(req.topic, req.baustein, req.section, req.provider))
|
||||
return {"punkte": punkte, "bewertung": _farbe(punkte), "gute_antworten": gute, "streak": streak, "cap": cf}
|
||||
ca = cap_aktuell(basis, n_je_level)
|
||||
floor = floor_from_score(basis, cf, s)
|
||||
d, new_streak = points_delta(tier, streak_basis, basis, ca)
|
||||
score = compute_score(basis, d, floor, ca, cf)
|
||||
points = score - basis
|
||||
good, streak = await set_block_score_and_streak(req.topic, req.block, score, new_streak)
|
||||
# Create the learning element once, as soon as the first level (beginner) is reached.
|
||||
if not was_level and level_from_score(score, cf) is not None:
|
||||
if await set_block_completed(req.topic, req.block):
|
||||
asyncio.create_task(create_block_element(req.topic, req.block, req.section, req.provider))
|
||||
return {"points": points, "rating": _color(points), "good_answers": good, "streak": streak, "cap": cf}
|
||||
|
||||
|
||||
@router.post("/bausteine/pruefung", response_model=BausteinPruefungResponse)
|
||||
async def baustein_pruefung_route(req: BausteinPruefungRequest):
|
||||
stand = await get_baustein_progress(req.topic, req.baustein)
|
||||
gute = stand["gute_antworten"]
|
||||
n_je_ebene = await subs_je_ebene(req.topic, req.baustein)
|
||||
cap = cap_final(n_je_ebene)
|
||||
niveau = stufe_aus_score(gute, cap) or "anfaenger" # Adressaten-Rolle der Frage
|
||||
fe = freie_ebene(gute, n_je_ebene) # nur freigeschaltete Subs prüfen
|
||||
kompakt = req.section_kompakt
|
||||
@router.post("/blocks/exam", response_model=BlockExamResponse)
|
||||
async def block_exam_route(req: BlockExamRequest):
|
||||
state = await get_block_progress(req.topic, req.block)
|
||||
good = state["good_answers"]
|
||||
n_je_level = await subs_per_level(req.topic, req.block)
|
||||
cap = cap_final(n_je_level)
|
||||
tier = level_from_score(good, cap) or "beginner" # addressee role of the question
|
||||
fe = freie_level(good, n_je_level) # only check unlocked subs
|
||||
compact = req.section_compact
|
||||
msgs = [m.model_dump() for m in req.messages]
|
||||
|
||||
if req.aktion == "frage":
|
||||
if req.muster.strip():
|
||||
# Aus gezogenem Muster eine konkrete Frage im Niveau formulieren (kein Dedup nötig).
|
||||
frage = await pruefung_frage_variante(req.topic, req.baustein, req.section, kompakt, req.muster, niveau=niveau, provider=req.provider)
|
||||
if req.action == "question":
|
||||
if req.pattern.strip():
|
||||
# From a drawn pattern, phrase a concrete question at the tier (no dedup needed).
|
||||
question = await exam_question_variant(req.topic, req.block, req.section, compact, req.pattern, tier=tier, provider=req.provider)
|
||||
else:
|
||||
# Fallback (kein Muster-Sidecar): Live-Generierung, Fokus nur auf freigeschaltete Subs.
|
||||
subs = await subbausteine_frei(req.topic, req.baustein, fe)
|
||||
frage = await pruefung_frage(req.topic, req.baustein, req.section, kompakt, msgs, subbausteine=subs, vermeide=req.vermeide, niveau=niveau, provider=req.provider)
|
||||
if frage is None:
|
||||
raise HTTPException(502, "Frage fehlgeschlagen — bitte erneut versuchen")
|
||||
return {"frage": frage, "gute_antworten": gute, "cap": cap}
|
||||
# Fallback (no pattern sidecar): live generation, focus only on unlocked subs.
|
||||
subs = await subblocks_frei(req.topic, req.block, fe)
|
||||
question = await exam_question(req.topic, req.block, req.section, compact, msgs, subblocks=subs, avoid=req.avoid, tier=tier, provider=req.provider)
|
||||
if question is None:
|
||||
raise HTTPException(502, "Question failed — please try again")
|
||||
return {"question": question, "good_answers": good, "cap": cap}
|
||||
|
||||
if req.aktion == "diskussion":
|
||||
if not req.frage.strip():
|
||||
raise HTTPException(400, "Diskussion braucht eine laufende Frage")
|
||||
reply = await baustein_diskussion(
|
||||
req.topic, req.baustein, req.section, kompakt,
|
||||
req.frage, req.letzte_bewertung or None, msgs, provider=req.provider,
|
||||
if req.action == "discussion":
|
||||
if not req.question.strip():
|
||||
raise HTTPException(400, "Discussion needs an active question")
|
||||
reply = await block_discussion(
|
||||
req.topic, req.block, req.section, compact,
|
||||
req.question, req.last_rating or None, msgs, provider=req.provider,
|
||||
)
|
||||
if reply is None:
|
||||
raise HTTPException(502, "Diskussion fehlgeschlagen — bitte erneut versuchen")
|
||||
return {"reply": reply, "gute_antworten": gute, "cap": cap}
|
||||
raise HTTPException(502, "Discussion failed — please try again")
|
||||
return {"reply": reply, "good_answers": good, "cap": cap}
|
||||
|
||||
# --- Quiz: leicht (1 von 4) +1/−1 · schwer (x von 4) +3/−1 — deterministisch ---
|
||||
if req.aktion == "quiz_frage":
|
||||
if not req.muster.strip():
|
||||
raise HTTPException(400, "Quiz braucht ein Muster")
|
||||
distraktoren = await huerden_distraktor_block(req.topic, req.baustein)
|
||||
quiz = await quiz_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, niveau=niveau, provider=req.provider, distraktor_block=distraktoren)
|
||||
# --- Quiz: easy (1 of 4) +1/−1 · hard (x of 4) +3/−1 — deterministic ---
|
||||
if req.action == "quiz_question":
|
||||
if not req.pattern.strip():
|
||||
raise HTTPException(400, "Quiz needs a pattern")
|
||||
distractors = await hurdles_distractor_block(req.topic, req.block)
|
||||
quiz = await generate_quiz(req.topic, req.block, req.section, compact, req.pattern, tier=tier, provider=req.provider, distractor_block=distractors)
|
||||
if quiz is None:
|
||||
raise HTTPException(502, "Quiz-Frage fehlgeschlagen — bitte erneut versuchen")
|
||||
return {"frage": quiz["frage"], "optionen": quiz["optionen"],
|
||||
"gute_antworten": gute, "cap": cap}
|
||||
raise HTTPException(502, "Quiz question failed — please try again")
|
||||
return {"question": quiz["question"], "options": quiz["options"],
|
||||
"good_answers": good, "cap": cap}
|
||||
|
||||
if req.aktion == "quiz_antwort":
|
||||
if not req.frage.strip():
|
||||
raise HTTPException(400, "Quiz-Antwort braucht eine Frage")
|
||||
getroffen = set(req.auswahl) == set(req.korrekt) # exakt die richtige Menge
|
||||
res = await _buche(req, req.frage, "stark" if getroffen else "kaum", n_je_ebene)
|
||||
res["feedback"] = "Richtig — alle korrekten getroffen." if getroffen else "Nicht ganz — die markierten waren richtig."
|
||||
if req.action == "quiz_answer":
|
||||
if not req.question.strip():
|
||||
raise HTTPException(400, "Quiz answer needs a question")
|
||||
hit = set(req.selection) == set(req.correct) # exactly the correct set
|
||||
res = await _book_score(req, req.question, "strong" if hit else "barely", n_je_level)
|
||||
res["feedback"] = "Correct — all correct ones hit." if hit else "Not quite — the marked ones were correct."
|
||||
return res
|
||||
|
||||
# --- Lückentext: leicht (Begriff aus 4) +1/−1 · schwer (frei tippen) +3/−1 ---
|
||||
if req.aktion == "lueck_frage":
|
||||
if not req.muster.strip():
|
||||
raise HTTPException(400, "Lückentext braucht ein Muster")
|
||||
# --- Gap text: easy (term from 4) +1/−1 · hard (free typing) +3/−1 ---
|
||||
if req.action == "gap_question":
|
||||
if not req.pattern.strip():
|
||||
raise HTTPException(400, "Gap text needs a pattern")
|
||||
if req.schwer:
|
||||
lt = await lueckentext_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, niveau=niveau, provider=req.provider)
|
||||
lt = await generate_gaptext(req.topic, req.block, req.section, compact, req.pattern, tier=tier, provider=req.provider)
|
||||
if lt is None:
|
||||
raise HTTPException(502, "Lückentext fehlgeschlagen — bitte erneut versuchen")
|
||||
return {"satz": lt["satz"], "loesung": lt["loesung"], "alternativen": lt["alternativen"],
|
||||
"gute_antworten": gute, "cap": cap}
|
||||
distraktoren = await huerden_distraktor_block(req.topic, req.baustein)
|
||||
lw = await lueckwahl_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, niveau=niveau, provider=req.provider, distraktor_block=distraktoren)
|
||||
raise HTTPException(502, "Gap text failed — please try again")
|
||||
return {"sentence": lt["sentence"], "solution": lt["solution"], "alternatives": lt["alternatives"],
|
||||
"good_answers": good, "cap": cap}
|
||||
distractors = await hurdles_distractor_block(req.topic, req.block)
|
||||
lw = await generate_gapchoice(req.topic, req.block, req.section, compact, req.pattern, tier=tier, provider=req.provider, distractor_block=distractors)
|
||||
if lw is None:
|
||||
raise HTTPException(502, "Lückentext fehlgeschlagen — bitte erneut versuchen")
|
||||
return {"satz": lw["satz"], "optionen": lw["optionen"],
|
||||
"gute_antworten": gute, "cap": cap}
|
||||
raise HTTPException(502, "Gap text failed — please try again")
|
||||
return {"sentence": lw["sentence"], "options": lw["options"],
|
||||
"good_answers": good, "cap": cap}
|
||||
|
||||
if req.aktion == "lueck_antwort":
|
||||
if not req.frage.strip():
|
||||
raise HTTPException(400, "Lückentext-Antwort braucht einen Satz")
|
||||
if req.schwer: # frei getippt → Synonym-tolerante KI-Prüfung
|
||||
ok = await lueckentext_pruefen(req.topic, req.baustein, req.frage, req.loesung, req.alternativen, req.eingabe, provider=req.provider)
|
||||
feedback = "Richtig!" if ok else f"Nicht ganz — erwartet war „{req.loesung}“."
|
||||
else: # Begriff aus 4 gewählt → deterministisch
|
||||
ok = set(req.auswahl) == set(req.korrekt)
|
||||
feedback = "Richtig!" if ok else "Nicht ganz — der markierte Begriff war richtig."
|
||||
res = await _buche(req, req.frage, "stark" if ok else "kaum", n_je_ebene)
|
||||
if req.action == "gap_answer":
|
||||
if not req.question.strip():
|
||||
raise HTTPException(400, "Gap-text answer needs a sentence")
|
||||
if req.schwer: # free typed → synonym-tolerant AI check
|
||||
ok = await check_gaptext(req.topic, req.block, req.question, req.solution, req.alternatives, req.input, provider=req.provider)
|
||||
feedback = "Correct!" if ok else f"Not quite — expected „{req.solution}“."
|
||||
else: # term chosen from 4 → deterministic
|
||||
ok = set(req.selection) == set(req.correct)
|
||||
feedback = "Correct!" if ok else "Not quite — the marked term was correct."
|
||||
res = await _book_score(req, req.question, "strong" if ok else "barely", n_je_level)
|
||||
res["feedback"] = feedback
|
||||
return res
|
||||
|
||||
# aktion "antwort" (Agent 1 schnell) / "antwort_pruefen" (Agent 2 genau).
|
||||
# action "answer" (Agent 1 fast) / "answer_check" (Agent 2 thorough).
|
||||
if not any(m.get("role") == "user" for m in msgs):
|
||||
raise HTTPException(400, "Antwort braucht eine Nutzer-Antwort")
|
||||
if not req.frage.strip():
|
||||
raise HTTPException(400, "Antwort braucht eine laufende Frage")
|
||||
raise HTTPException(400, "Answer needs a user answer")
|
||||
if not req.question.strip():
|
||||
raise HTTPException(400, "Answer needs an active question")
|
||||
|
||||
if req.aktion == "antwort":
|
||||
# Agent 1: nur Vorschau — Niveau + voraussichtliche Punkte, NICHTS persistieren, kein Anker.
|
||||
data = await pruefung_bewertung_schnell(
|
||||
req.topic, req.baustein, req.section, kompakt, req.frage, msgs, provider=req.provider,
|
||||
if req.action == "answer":
|
||||
# Agent 1: preview only — tier + expected points, persist NOTHING, no anchor.
|
||||
data = await exam_rating_fast(
|
||||
req.topic, req.block, req.section, compact, req.question, msgs, provider=req.provider,
|
||||
)
|
||||
if data is None:
|
||||
raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen")
|
||||
basis, re_bew = _basis(stand, req.frage)
|
||||
streak_basis = stand["offene_streak"] if re_bew else stand["streak"]
|
||||
s = schwellen(n_je_ebene)
|
||||
ca = cap_aktuell(basis, n_je_ebene)
|
||||
floor = floor_aus_score(basis, s[-1], s)
|
||||
niveau = deckel_nachfrage(data["niveau"], req.nachgefragt)
|
||||
d, _ = punkte_delta(niveau, streak_basis, basis, ca)
|
||||
score = score_berechnen(basis, d, floor, ca, s[-1])
|
||||
punkte = score - basis
|
||||
return {"feedback": data["feedback"], "punkte": punkte, "bewertung": _farbe(punkte),
|
||||
"gute_antworten": gute, "cap": cap}
|
||||
raise HTTPException(502, "Rating failed — please try again")
|
||||
basis, re_rating = _basis(state, req.question)
|
||||
streak_basis = state["offene_streak"] if re_rating else state["streak"]
|
||||
s = thresholds(n_je_level)
|
||||
ca = cap_aktuell(basis, n_je_level)
|
||||
floor = floor_from_score(basis, s[-1], s)
|
||||
tier = cap_followup(data["tier"], req.asked_again)
|
||||
d, _ = points_delta(tier, streak_basis, basis, ca)
|
||||
score = compute_score(basis, d, floor, ca, s[-1])
|
||||
points = score - basis
|
||||
return {"feedback": data["feedback"], "points": points, "rating": _color(points),
|
||||
"good_answers": good, "cap": cap}
|
||||
|
||||
# aktion "antwort_pruefen" (Agent 2 genau): verbindlich, persistiert. NUR hier ändert sich der Score.
|
||||
# LLM läuft OHNE Lock; gebucht wird kurz über _buche (Anker + Score), wie bei Quiz/Lück.
|
||||
# So blockiert die lange KI-Bewertung keine folgende (deterministische) Antwort desselben Bausteins.
|
||||
data = await pruefung_bewertung(
|
||||
req.topic, req.baustein, req.section, kompakt, req.frage, msgs, provider=req.provider,
|
||||
role="guide" if req.gruendlich else "judge", begruendung=req.begruendung,
|
||||
# action "answer_check" (Agent 2 thorough): binding, persisted. ONLY here does the score change.
|
||||
# The LLM runs WITHOUT a lock; booking is done briefly via _book_score (anchor + score), as with quiz/gap.
|
||||
# This way the long AI rating doesn't block a following (deterministic) answer of the same block.
|
||||
data = await exam_rating(
|
||||
req.topic, req.block, req.section, compact, req.question, msgs, provider=req.provider,
|
||||
role="guide" if req.thorough else "judge", reason=req.reason,
|
||||
)
|
||||
if data is None:
|
||||
raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen")
|
||||
niveau = deckel_nachfrage(data["niveau"], req.nachgefragt)
|
||||
res = await _buche(req, req.frage, niveau, n_je_ebene) # kurzer Lock: Basis driftfrei über Anker
|
||||
raise HTTPException(502, "Rating failed — please try again")
|
||||
tier = cap_followup(data["tier"], req.asked_again)
|
||||
res = await _book_score(req, req.question, tier, n_je_level) # short lock: drift-free base via anchor
|
||||
res["feedback"] = data["feedback"]
|
||||
return res
|
||||
|
||||
@@ -474,10 +474,10 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest):
|
||||
|
||||
@router.post("/guides", response_model=GuideResponse)
|
||||
async def create(req: GuideCreateRequest):
|
||||
guides, progress, levels = await lade_lernstand()
|
||||
grund = guide_lock(req.topic.strip(), req.format, guides, progress, levels)
|
||||
if grund:
|
||||
raise HTTPException(400 if grund == "Erst Bausteine erstellen" else 409, grund)
|
||||
guides, progress, levels = await load_learnstate()
|
||||
reason = guide_lock(req.topic.strip(), req.format, guides, progress, levels)
|
||||
if reason:
|
||||
raise HTTPException(400 if reason == "Erst Blocks erstellen" else 409, reason) # string matches rules.py contract
|
||||
await create_topic(req.topic.strip())
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
guide = {
|
||||
@@ -502,45 +502,45 @@ async def list_all():
|
||||
|
||||
@router.get("/guides/locks")
|
||||
async def guide_locks(topic: str):
|
||||
"""Sperr-Gründe pro Format für den ▶-Button — None = erstellbar."""
|
||||
guides, progress, levels = await lade_lernstand()
|
||||
"""Lock reasons per format for the ▶ button — None = creatable."""
|
||||
guides, progress, levels = await load_learnstate()
|
||||
return {fmt: guide_lock(topic, fmt, guides, progress, levels) for fmt in ("FullGuide", "Rest", *FORMATE)}
|
||||
|
||||
|
||||
@router.get("/guides/steps")
|
||||
async def guide_steps(topic: str):
|
||||
"""Höchster voll abgeschlossener Schritt-Index je Format (artefakt-basiert, -1 = keiner).
|
||||
Treibt die klickbaren Schritt-Kugeln (wie die Bausteine-Phasen)."""
|
||||
return {fmt: guide_fertig_step(guide_content_path(topic, fmt)) for fmt in ("Guide", "FullGuide", "Rest")}
|
||||
"""Highest fully completed step index per format (artifact-based, -1 = none).
|
||||
Drives the clickable step bubbles (like the blocks phases)."""
|
||||
return {fmt: guide_done_step(guide_content_path(topic, fmt)) for fmt in ("Guide", "FullGuide", "Rest")}
|
||||
|
||||
|
||||
@router.get("/guides/{guide_id}", response_model=GuideResponse)
|
||||
async def get_one(guide_id: str):
|
||||
guide = await get_guide(guide_id)
|
||||
if guide is None:
|
||||
raise HTTPException(404, "Guide nicht gefunden")
|
||||
raise HTTPException(404, "Guide not found")
|
||||
return guide
|
||||
|
||||
|
||||
@router.get("/guides/{guide_id}/content")
|
||||
async def guide_content(guide_id: str, ebene: int = 4):
|
||||
"""Guide-Inhalt. `ebene` (1=A · 2=F · 3=E · 4=V) filtert auf Subbausteine bis zu dieser
|
||||
Ebene; 4 = Vollfassung (roh, unverändert)."""
|
||||
async def guide_content(guide_id: str, level: int = 4):
|
||||
"""Guide content. `level` (1=A · 2=F · 3=E · 4=V) filters to subblocks up to this
|
||||
level; 4 = full version (raw, unchanged)."""
|
||||
guide = await get_guide(guide_id)
|
||||
if guide is None:
|
||||
raise HTTPException(404, "Guide nicht gefunden")
|
||||
raise HTTPException(404, "Guide not found")
|
||||
if guide["status"] != "done":
|
||||
raise HTTPException(404, "Inhalt nicht verfügbar")
|
||||
raise HTTPException(404, "Content not available")
|
||||
stored = await get_guide_content(guide["topic"], guide["format"]) # DB-first
|
||||
if stored is None:
|
||||
path = guide_content_path(guide["topic"], guide["format"]) # Fallback: Datei (Alt-Themen)
|
||||
path = guide_content_path(guide["topic"], guide["format"]) # fallback: file (legacy topics)
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Datei nicht gefunden")
|
||||
raise HTTPException(404, "File not found")
|
||||
stored = path.read_text(encoding="utf-8")
|
||||
if ebene >= 4:
|
||||
return Response(content=stored, media_type="application/json") # Vollfassung roh
|
||||
if level >= 4:
|
||||
return Response(content=stored, media_type="application/json") # full version, raw
|
||||
try:
|
||||
return content_fuer_ebene(json.loads(stored), ebene)
|
||||
return content_fuer_level(json.loads(stored), level)
|
||||
except ValueError:
|
||||
return Response(content=stored, media_type="application/json")
|
||||
|
||||
@@ -549,7 +549,7 @@ async def guide_content(guide_id: str, ebene: int = 4):
|
||||
async def guide_chat(guide_id: str, req: GuideChatRequest):
|
||||
guide = await get_guide(guide_id)
|
||||
if guide is None:
|
||||
raise HTTPException(404, "Guide nicht gefunden")
|
||||
raise HTTPException(404, "Guide not found")
|
||||
reply = await chat_with_guide(
|
||||
guide["topic"], guide["format"], req.section, req.outline,
|
||||
[m.model_dump() for m in req.messages],
|
||||
@@ -561,29 +561,29 @@ async def guide_chat(guide_id: str, req: GuideChatRequest):
|
||||
async def _guide_tf(guide_id: str) -> tuple[str, str]:
|
||||
guide = await get_guide(guide_id)
|
||||
if guide is None:
|
||||
raise HTTPException(404, "Guide nicht gefunden")
|
||||
raise HTTPException(404, "Guide not found")
|
||||
return guide["topic"], guide["format"]
|
||||
|
||||
|
||||
@router.post("/guides/{guide_id}/block/pruefen", response_model=BlockPruefenResponse)
|
||||
async def block_pruefen_route(guide_id: str, req: BlockPruefenRequest):
|
||||
topic, fmt = await _guide_tf(guide_id)
|
||||
neu = await block_pruefen(topic, fmt, req.baustein, req.stelle, req.block, req.hinweis, provider=req.provider)
|
||||
if neu is None:
|
||||
raise HTTPException(502, "Prüfung fehlgeschlagen — bitte erneut versuchen")
|
||||
return {"neu": neu}
|
||||
new = await block_pruefen(topic, fmt, req.block, req.spot, req.snippet, req.hint, provider=req.provider)
|
||||
if new is None:
|
||||
raise HTTPException(502, "Check failed — please try again")
|
||||
return {"revised": new}
|
||||
|
||||
|
||||
@router.post("/guides/{guide_id}/block/uebernehmen", response_model=BlockUebernehmenResponse)
|
||||
async def block_uebernehmen_route(guide_id: str, req: BlockUebernehmenRequest):
|
||||
async def block_adopt_route(guide_id: str, req: BlockUebernehmenRequest):
|
||||
topic, fmt = await _guide_tf(guide_id)
|
||||
res = await block_uebernehmen(topic, fmt, req.baustein, req.stelle, req.alt, req.neu)
|
||||
res = await block_adopt(topic, fmt, req.block, req.spot, req.alt, req.revised)
|
||||
if res is None:
|
||||
raise HTTPException(404, "Section nicht gefunden")
|
||||
raise HTTPException(404, "Section not found")
|
||||
return res
|
||||
|
||||
|
||||
# --- Elemente (persönliche Zusammenfassung) ---
|
||||
# --- Elements (personal summary) ---
|
||||
|
||||
@router.get("/elements", response_model=list[ElementResponse])
|
||||
async def get_elements(topic: str):
|
||||
@@ -603,7 +603,7 @@ async def post_element(req: ElementCreateRequest):
|
||||
async def element_chat(element_id: str, req: ElementChatRequest):
|
||||
element = await get_element(element_id)
|
||||
if element is None:
|
||||
raise HTTPException(404, "Element nicht gefunden")
|
||||
raise HTTPException(404, "Element not found")
|
||||
reply, changes = await chat_with_element(element, [m.model_dump() for m in req.messages], provider=req.provider)
|
||||
return {"reply": reply, "changes": changes}
|
||||
|
||||
@@ -612,17 +612,17 @@ async def element_chat(element_id: str, req: ElementChatRequest):
|
||||
async def element_refine(element_id: str, req: ElementRefineRequest):
|
||||
element = await get_element(element_id)
|
||||
if element is None:
|
||||
raise HTTPException(404, "Element nicht gefunden")
|
||||
raise HTTPException(404, "Element not found")
|
||||
change = await refine_suggestion(element, req.suggestion.model_dump(), req.instruction, provider=req.provider)
|
||||
if change is None:
|
||||
raise HTTPException(502, "Überarbeitung fehlgeschlagen — bitte erneut versuchen")
|
||||
raise HTTPException(502, "Revision failed — please try again")
|
||||
return {"change": change}
|
||||
|
||||
|
||||
@router.put("/elements/{element_id}", response_model=ElementResponse)
|
||||
async def put_element(element_id: str, req: ElementUpdateRequest):
|
||||
if await get_element(element_id) is None:
|
||||
raise HTTPException(404, "Element nicht gefunden")
|
||||
raise HTTPException(404, "Element not found")
|
||||
fields = req.model_dump(exclude_unset=True, exclude_none=True)
|
||||
if fields:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
@@ -634,10 +634,10 @@ async def put_element(element_id: str, req: ElementUpdateRequest):
|
||||
async def element_style(element_id: str, req: ElementCheckRequest):
|
||||
element = await get_element(element_id)
|
||||
if element is None:
|
||||
raise HTTPException(404, "Element nicht gefunden")
|
||||
raise HTTPException(404, "Element not found")
|
||||
changes = await style_element(element, provider=req.provider)
|
||||
if changes is None:
|
||||
raise HTTPException(502, "Stil-Prüfung fehlgeschlagen — bitte erneut versuchen")
|
||||
raise HTTPException(502, "Style check failed — please try again")
|
||||
return {"changes": changes}
|
||||
|
||||
|
||||
@@ -645,17 +645,17 @@ async def element_style(element_id: str, req: ElementCheckRequest):
|
||||
async def element_check(element_id: str, req: ElementCheckRequest):
|
||||
element = await get_element(element_id)
|
||||
if element is None:
|
||||
raise HTTPException(404, "Element nicht gefunden")
|
||||
raise HTTPException(404, "Element not found")
|
||||
suggestions = await check_element(element, provider=req.provider)
|
||||
if suggestions is None:
|
||||
raise HTTPException(502, "Prüfung fehlgeschlagen — bitte erneut versuchen")
|
||||
raise HTTPException(502, "Check failed — please try again")
|
||||
return {"suggestions": suggestions}
|
||||
|
||||
|
||||
@router.delete("/elements/{element_id}")
|
||||
async def remove_element(element_id: str):
|
||||
if not await delete_element(element_id):
|
||||
raise HTTPException(404, "Element nicht gefunden")
|
||||
raise HTTPException(404, "Element not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -663,7 +663,7 @@ async def remove_element(element_id: str):
|
||||
async def cancel(guide_id: str):
|
||||
cancelled = await cancel_guide(guide_id)
|
||||
if not cancelled:
|
||||
raise HTTPException(404, "Kein aktiver Prozess gefunden")
|
||||
raise HTTPException(404, "No active process found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -671,18 +671,18 @@ async def cancel(guide_id: str):
|
||||
async def remove(guide_id: str, slots: bool = False):
|
||||
guide = await get_guide(guide_id)
|
||||
if guide is None:
|
||||
raise HTTPException(404, "Guide nicht gefunden")
|
||||
raise HTTPException(404, "Guide not found")
|
||||
await delete_progress(guide_id)
|
||||
await delete_guide(guide_id)
|
||||
# Content-/Schritt-Dateien teilen sich alle Läufe eines Thema+Formats — erst löschen,
|
||||
# wenn kein Eintrag sie mehr braucht. Teilfortschritt (Schritt-Dateien ohne fertigen
|
||||
# Content) bleibt fürs Resume erhalten, außer es wird explizit verlangt (slots=1).
|
||||
# Content/step files are shared by all runs of a topic+format — only delete them
|
||||
# once no entry needs them anymore. Partial progress (step files without finished
|
||||
# content) is kept for resume, unless explicitly requested (slots=1).
|
||||
rest = [g for g in await list_guides() if g["topic"] == guide["topic"] and g["format"] == guide["format"]]
|
||||
if not rest:
|
||||
await delete_guide_content(guide["topic"], guide["format"])
|
||||
content = guide_content_path(guide["topic"], guide["format"])
|
||||
if slots or content.exists():
|
||||
for p in guide_slot_dateien(content):
|
||||
for p in guide_slot_files(content):
|
||||
p.unlink(missing_ok=True)
|
||||
content.unlink(missing_ok=True)
|
||||
return {"ok": True}
|
||||
@@ -692,7 +692,7 @@ async def remove(guide_id: str, slots: bool = False):
|
||||
async def get_progress(guide_id: str):
|
||||
guide = await get_guide(guide_id)
|
||||
if guide is None:
|
||||
raise HTTPException(404, "Guide nicht gefunden")
|
||||
raise HTTPException(404, "Guide not found")
|
||||
return {"chapters": await list_progress(guide_id)}
|
||||
|
||||
|
||||
@@ -700,6 +700,6 @@ async def get_progress(guide_id: str):
|
||||
async def update_progress(guide_id: str, req: ProgressUpdate):
|
||||
guide = await get_guide(guide_id)
|
||||
if guide is None:
|
||||
raise HTTPException(404, "Guide nicht gefunden")
|
||||
raise HTTPException(404, "Guide not found")
|
||||
await set_progress(guide_id, req.chapter, req.done)
|
||||
return {"chapters": await list_progress(guide_id)}
|
||||
|
||||
141
backend/rules.py
Normal file
141
backend/rules.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""Learning-debt rules: progression and cap for open guides — the ONLY source.
|
||||
|
||||
Rules (new creations only; topics + blocks unlimited):
|
||||
- Format "Guide": at most 3 created, not-yet-completed guides
|
||||
- No more progression/prerequisite (only a single guide format).
|
||||
- Completed: ALL blocks (section titles) of the latest finished guide have
|
||||
a passed exam. The rest is read-only (no progress, no exam).
|
||||
All functions work on data loaded once (load_learnstate) — no more
|
||||
query loops per guide.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from database import list_block_scores_all, subs_per_level_all, list_guides, list_progress_all
|
||||
from guide import guide_slot_files
|
||||
from learning import cap_final, LEVELS, _threshold
|
||||
from paths import blocks_path, guide_content_path
|
||||
from textkit import _norm_title
|
||||
|
||||
MAX_OFFENE_GUIDES = 3
|
||||
# Only ONE format "Guide" left (all relevant blocks, exam 0–cap). No progression,
|
||||
# no prerequisite → the guide is always unlockable. "Rest"/FullGuide are separate.
|
||||
PRESTAGE: dict[str, str] = {}
|
||||
FREISCHALT_LEVEL: dict[str, str] = {}
|
||||
FORMATE = ("Guide",)
|
||||
|
||||
# 4 learning levels (floor in % of the cap) — keys from learning.LEVELS.
|
||||
_LEVEL_WORT = {
|
||||
"beginner": "to beginner (20%)",
|
||||
"advanced": "to advanced (40%)",
|
||||
"expert": "to expert (60%)",
|
||||
"master": "to mastery (100%)",
|
||||
}
|
||||
|
||||
|
||||
async def load_learnstate() -> tuple[list[dict], dict[str, set[str]], dict[str, dict[str, set[str]]]]:
|
||||
"""Guides + chapter progress + blocks per level.
|
||||
|
||||
levels: {"beginner"/"advanced"/"expert"/"master": {topic → normalized title}}.
|
||||
The level per block is derived from score + cap (4×relevant subs).
|
||||
"""
|
||||
scores = await list_block_scores_all()
|
||||
subs_by_level = await subs_per_level_all()
|
||||
levels: dict[str, dict[str, set[str]]] = {key: {} for key, _ in LEVELS}
|
||||
for topic, block, score in scores:
|
||||
cf = cap_final(subs_by_level.get((topic, _norm_title(block)), {}))
|
||||
for key, p in LEVELS:
|
||||
if cf and score >= _threshold(p, cf):
|
||||
levels[key].setdefault(topic, set()).add(_norm_title(block))
|
||||
return await list_guides(), await list_progress_all(), levels
|
||||
|
||||
|
||||
def _content_json(topic: str, fmt: str) -> dict | None:
|
||||
path = guide_content_path(topic, fmt)
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
|
||||
|
||||
def _section_title(topic: str, fmt: str) -> set[str] | None:
|
||||
"""Normalized block titles (sections) from the guide content."""
|
||||
content = _content_json(topic, fmt)
|
||||
if content is None:
|
||||
return None
|
||||
return {
|
||||
_norm_title(s.get("title", ""))
|
||||
for ch in content.get("chapters", [])
|
||||
for s in ch.get("sections", [])
|
||||
}
|
||||
|
||||
|
||||
def _latest_done(guides: list[dict], fmt: str) -> dict[str, dict]:
|
||||
"""Per topic, the latest finished guide of this format."""
|
||||
latest: dict[str, dict] = {}
|
||||
for g in guides:
|
||||
if g["format"] == fmt and g["status"] == "done":
|
||||
if g["topic"] not in latest or g["created_at"] > latest[g["topic"]]["created_at"]:
|
||||
latest[g["topic"]] = g
|
||||
return latest
|
||||
|
||||
|
||||
def _guide_all(g: dict, progress: dict[str, set[str]], levelset: dict[str, set[str]]) -> bool:
|
||||
"""Are ALL blocks of the guide at the required level?"""
|
||||
sections = _section_title(g["topic"], g["format"])
|
||||
return bool(sections) and sections <= levelset.get(g["topic"], set())
|
||||
|
||||
|
||||
def is_level(topic: str, fmt: str, guides: list[dict], progress: dict[str, set[str]], levelset: dict[str, set[str]]) -> bool:
|
||||
"""Latest finished guide (topic+format): all blocks at the level of levelset?"""
|
||||
g = _latest_done(guides, fmt).get(topic)
|
||||
return g is not None and _guide_all(g, progress, levelset)
|
||||
|
||||
|
||||
def ist_completed(topic: str, fmt: str, guides: list[dict], progress: dict[str, set[str]], levels: dict[str, dict[str, set[str]]]) -> bool:
|
||||
"""All blocks of the latest finished guide at least beginner (≥20%)?"""
|
||||
return is_level(topic, fmt, guides, progress, levels["beginner"])
|
||||
|
||||
|
||||
def topic_completed(topic: str, guides: list[dict], progress: dict[str, set[str]], levels: dict[str, dict[str, set[str]]]) -> bool:
|
||||
"""Topic done: latest finished guide, all blocks at master (100%)?"""
|
||||
return is_level(topic, "Guide", guides, progress, levels["master"])
|
||||
|
||||
|
||||
def formats_stats(guides: list[dict], progress: dict[str, set[str]], levels: dict[str, dict[str, set[str]]]) -> dict:
|
||||
"""Per format created/completed — per topic only the latest finished guide counts."""
|
||||
formats = {}
|
||||
for fmt in FORMATE:
|
||||
latest = _latest_done(guides, fmt)
|
||||
completed = sum(1 for g in latest.values() if _guide_all(g, progress, levels["beginner"]))
|
||||
formats[fmt] = {"created": len(latest), "completed": completed}
|
||||
return formats
|
||||
|
||||
|
||||
def guide_lock(topic: str, fmt: str, guides: list[dict], progress: dict[str, set[str]], levels: dict[str, dict[str, set[str]]]) -> str | None:
|
||||
"""Reason why a fresh start for topic+format is locked — None = allowed.
|
||||
|
||||
Exactly the rules from POST /guides: blocks required, no duplicate start,
|
||||
learning debt only for genuine new creations (resume/regenerate are free).
|
||||
"""
|
||||
if not blocks_path(topic).exists():
|
||||
return "Create blocks first"
|
||||
for g in guides:
|
||||
if g["topic"] == topic and g["format"] == fmt and g["status"] in ("queued", "generating"):
|
||||
return "Generation already running"
|
||||
content = guide_content_path(topic, fmt)
|
||||
if not content.exists() and not guide_slot_files(content):
|
||||
prereq = PRESTAGE.get(fmt)
|
||||
if prereq:
|
||||
level = FREISCHALT_LEVEL[fmt] # completed=10 · understood=20 · mastered=30
|
||||
if not is_level(topic, prereq, guides, progress, levels[level]):
|
||||
return f"First take the {prereq} of this topic {_LEVEL_WORT[level]}"
|
||||
stat = formats_stats(guides, progress, levels).get(fmt, {"created": 0, "completed": 0})
|
||||
open_count = stat["created"] - stat["completed"]
|
||||
if open_count >= MAX_OFFENE_GUIDES:
|
||||
return f"Complete {fmt}s first — at most {MAX_OFFENE_GUIDES} open allowed ({open_count} open)"
|
||||
return None
|
||||
@@ -1,19 +1,19 @@
|
||||
"""Reine Text-Helfer: Titel-Normalisierung, Listen-Parser, Chunk-Aufteilung.
|
||||
"""Pure text helpers: title normalization, list parsers, chunk splitting.
|
||||
|
||||
Kein Zustand, keine IO — überall gefahrlos importierbar.
|
||||
No state, no IO — safe to import anywhere.
|
||||
"""
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
_CATEGORIES = ("KERN", "WICHTIG", "REST") # nur noch für den Altformat-Reader
|
||||
_CATEGORIES = ("KERN", "WICHTIG", "REST") # only for the legacy-format reader now
|
||||
|
||||
|
||||
def _norm_titel(s: str) -> str:
|
||||
"""Normalisiert einen Titel für den Schlüssel-Vergleich.
|
||||
def _norm_title(s: str) -> str:
|
||||
"""Normalize a title for key comparison.
|
||||
|
||||
NFKC + casefold fangen Unicode-Varianten; Anführungszeichen, Markdown-
|
||||
Emphasis und Dash-Varianten kommen aus KI-Output in allen Spielarten.
|
||||
NFKC + casefold catch Unicode variants; quotes, markdown emphasis
|
||||
and dash variants come out of AI output in every shape.
|
||||
"""
|
||||
s = unicodedata.normalize("NFKC", s)
|
||||
s = re.sub(r"[`'\"<>„“”‚’«»*_]", "", s)
|
||||
@@ -22,49 +22,49 @@ def _norm_titel(s: str) -> str:
|
||||
return s.casefold()
|
||||
|
||||
|
||||
def _titel(entry: str) -> str:
|
||||
def _title(entry: str) -> str:
|
||||
return entry.split(" — ")[0].strip() or entry
|
||||
|
||||
|
||||
def _eindeutige_titel(entries: dict[int, str]) -> dict[int, str]:
|
||||
"""Macht Titel eindeutig (Suffix " (2)", " (3)" …), damit sie als Schlüssel taugen."""
|
||||
def _unique_title(entries: dict[int, str]) -> dict[int, str]:
|
||||
"""Make titles unique (suffix " (2)", " (3)" …) so they work as keys."""
|
||||
seen: dict[str, int] = {}
|
||||
out: dict[int, str] = {}
|
||||
for num, text in entries.items():
|
||||
titel = _titel(text)
|
||||
key = _norm_titel(titel)
|
||||
title = _title(text)
|
||||
key = _norm_title(title)
|
||||
seen[key] = seen.get(key, 0) + 1
|
||||
if seen[key] > 1:
|
||||
rest = text.split(" — ", 1)
|
||||
text = f"{titel} ({seen[key]})" + (f" — {rest[1]}" if len(rest) == 2 else "")
|
||||
# zweiter Durchlauf nicht nötig: Suffixe kollidieren praktisch nicht
|
||||
text = f"{title} ({seen[key]})" + (f" — {rest[1]}" if len(rest) == 2 else "")
|
||||
# a second pass isn't needed: suffixes practically never collide
|
||||
out[num] = text
|
||||
return out
|
||||
|
||||
|
||||
|
||||
|
||||
def _titel_index(entries: dict[int, str]) -> dict[str, int]:
|
||||
return {_norm_titel(_titel(text)): num for num, text in entries.items()}
|
||||
def _title_index(entries: dict[int, str]) -> dict[str, int]:
|
||||
return {_norm_title(_title(text)): num for num, text in entries.items()}
|
||||
|
||||
|
||||
def _titel_aufloesen(idx: dict[str, int], t: str) -> int | None:
|
||||
"""Titel → Nummer; toleriert mitgeschleppte Beschreibungen ("Titel — …")."""
|
||||
def _resolve_title(idx: dict[str, int], t: str) -> int | None:
|
||||
"""Title → number; tolerates trailing descriptions ("Title — …")."""
|
||||
if not isinstance(t, str):
|
||||
return None
|
||||
return idx.get(_norm_titel(t)) or idx.get(_norm_titel(_titel(t)))
|
||||
return idx.get(_norm_title(t)) or idx.get(_norm_title(_title(t)))
|
||||
|
||||
|
||||
def _norm_dash(s: str) -> str:
|
||||
"""Space-umgebene Dash-Varianten (en/em/figure/bar/hyphen) → einheitlicher Trenner ' — '.
|
||||
Manche Modelle (v.a. nicht-westliche) setzen statt des Em-Dashs einen En-Dash „–"; ohne
|
||||
Normalisierung scheitert der ` — `-Split komplett und der ganze Eintrag wird zum Titel.
|
||||
ASCII-Bindestrich „-" bleibt unangetastet (sonst zerlegt es Formeln wie „n - 1")."""
|
||||
"""Space-surrounded dash variants (en/em/figure/bar/hyphen) → uniform separator ' — '.
|
||||
Some models (especially non-western ones) use an en-dash "–" instead of the em-dash; without
|
||||
normalization the ` — ` split fails entirely and the whole entry becomes the title.
|
||||
The ASCII hyphen "-" is left untouched (otherwise it would split formulas like "n - 1")."""
|
||||
return re.sub(r"\s+[‒–—―‐]\s+", " — ", s)
|
||||
|
||||
|
||||
def _parse_auswahl(text: str) -> dict[int, str]:
|
||||
"""Parst eine Baustein-Liste: `N. Titel — Kurzbeschreibung` pro Zeile."""
|
||||
def _parse_selection(text: str) -> dict[int, str]:
|
||||
"""Parse a block list: `N. Title — short description` per line."""
|
||||
entries: dict[int, str] = {}
|
||||
last = None
|
||||
for line in text.splitlines():
|
||||
@@ -77,8 +77,8 @@ def _parse_auswahl(text: str) -> dict[int, str]:
|
||||
return entries
|
||||
|
||||
|
||||
def _parse_kategorien(text: str) -> dict[str, list[str]]:
|
||||
"""Altformat-Reader: finale Baustein-Datei mit ## KERN/WICHTIG/REST-Abschnitten."""
|
||||
def _parse_categories(text: str) -> dict[str, list[str]]:
|
||||
"""Legacy-format reader: final block file with ## KERN/WICHTIG/REST sections."""
|
||||
cats: dict[str, list[str]] = {}
|
||||
current = None
|
||||
for line in text.splitlines():
|
||||
@@ -94,40 +94,40 @@ def _parse_kategorien(text: str) -> dict[str, list[str]]:
|
||||
return cats
|
||||
|
||||
|
||||
def _lade_bausteine(text: str) -> dict[int, str]:
|
||||
"""Lädt die finale Baustein-Datei — sortierte Liste (neu) oder Kategorien (Altformat)."""
|
||||
def _load_blocks(text: str) -> dict[int, str]:
|
||||
"""Load the final block file — sorted list (new) or categories (legacy format)."""
|
||||
if re.search(r"^#+\s*KERN\b", text, re.IGNORECASE | re.MULTILINE):
|
||||
cats = _parse_kategorien(text)
|
||||
cats = _parse_categories(text)
|
||||
texts = [t for cat in _CATEGORIES for t in cats.get(cat, [])]
|
||||
return {i: t for i, t in enumerate(texts, 1)}
|
||||
return _parse_auswahl(text)
|
||||
return _parse_selection(text)
|
||||
|
||||
|
||||
_FRAGMENT_KAPITEL_RE = re.compile(r"<!--\s*kapitel\s*:\s*(.*?)\s*-->", re.IGNORECASE)
|
||||
_FRAGMENT_SECTION_RE = re.compile(r"<!--\s*section\s*:\s*(.*?)\s*-->", re.IGNORECASE)
|
||||
_FRAGMENT_SUB_RE = re.compile(r"<!--\s*sub\s*:\s*(.*?)\s*-->", re.IGNORECASE)
|
||||
_FRAGMENT_BAUSTEIN_RE = re.compile(r"<!--\s*baustein\s*:\s*(.*?)\s*-->", re.IGNORECASE)
|
||||
# Zwei Lese-Schichten je Section: kompakt (Merksätze) + ausführlich (Erklärung).
|
||||
_FRAGMENT_KOMPAKT_RE = re.compile(r"<!--\s*kompakt\s*-->", re.IGNORECASE)
|
||||
_FRAGMENT_BAUSTEIN_RE = re.compile(r"<!--\s*block\s*:\s*(.*?)\s*-->", re.IGNORECASE)
|
||||
# Two reading layers per section: compact (key sentences) + detailed (explanation).
|
||||
_FRAGMENT_KOMPAKT_RE = re.compile(r"<!--\s*compact\s*-->", re.IGNORECASE)
|
||||
_FRAGMENT_AUSF_RE = re.compile(r"<!--\s*ausf(?:ü|ue)hrlich\s*-->", re.IGNORECASE)
|
||||
# Lernpfad-Stufen + Rand; alte Schwierigkeits-Werte abwärtskompatibel akzeptiert.
|
||||
_STUFEN = ("anfaenger", "fortgeschritten", "experte", "rand", "einfach", "mittel", "schwer")
|
||||
# Learning-path levels + peripheral; old difficulty values accepted for backward compatibility.
|
||||
_STUFEN = ("beginner", "advanced", "expert", "peripheral", "easy", "medium", "hard")
|
||||
|
||||
|
||||
def _parse_fragment(text: str) -> list[dict]:
|
||||
"""Parst eine Writer-Datei → [{kapitel, titel, md, kompakt, anker, anker_kompakt, subs}].
|
||||
"""Parse a writer file → [{kapitel, title, md, compact, anker, anker_compact, subs}].
|
||||
|
||||
Zwei Lese-Schichten je Section über `<!-- kompakt -->` / `<!-- ausführlich -->`. Innerhalb
|
||||
beider markieren `<!-- sub: stufe | titel -->`-Marker je Subbaustein einen Block; gleicher
|
||||
Sub-Titel in beiden Schichten wird gemergt → `sec["subs"] = [{stufe, titel, md, kompakt}]`.
|
||||
Text VOR dem ersten Sub-Marker ist der Anker (Einordnung) → `anker`/`anker_kompakt`.
|
||||
`md`/`kompakt` bleiben die VOLLE Fassung (Anker + alle Subs) — rückwärtskompatibel.
|
||||
Two reading layers per section via `<!-- compact -->` / `<!-- ausführlich -->`. Within
|
||||
both, `<!-- sub: level | title -->` markers mark a block per subblock; the same
|
||||
sub title in both layers is merged → `sec["subs"] = [{level, title, md, compact}]`.
|
||||
Text BEFORE the first sub marker is the anchor (framing) → `anker`/`anker_compact`.
|
||||
`md`/`compact` stay the FULL version (anchor + all subs) — backward compatible.
|
||||
"""
|
||||
sections: list[dict] = []
|
||||
kapitel = None
|
||||
current = None
|
||||
cur_sub = None
|
||||
cur_layer = "md" # Default: alles ohne Schicht-Marker ist die ausführliche Fassung
|
||||
cur_layer = "md" # default: anything without a layer marker is the detailed version
|
||||
for line in text.splitlines():
|
||||
s = line.strip()
|
||||
m = _FRAGMENT_KAPITEL_RE.match(s)
|
||||
@@ -138,14 +138,14 @@ def _parse_fragment(text: str) -> list[dict]:
|
||||
continue
|
||||
m = _FRAGMENT_SECTION_RE.match(s)
|
||||
if m:
|
||||
current = {"kapitel": kapitel, "titel": m.group(1), "md": [], "kompakt": [],
|
||||
"anker_md": [], "anker_kompakt": [], "_submap": {}, "_suborder": []}
|
||||
current = {"chapters": kapitel, "title": m.group(1), "md": [], "compact": [],
|
||||
"anker_md": [], "anker_compact": [], "_submap": {}, "_suborder": []}
|
||||
cur_sub = None
|
||||
cur_layer = "md"
|
||||
sections.append(current)
|
||||
continue
|
||||
if current is not None and _FRAGMENT_KOMPAKT_RE.match(s):
|
||||
cur_layer = "kompakt"
|
||||
cur_layer = "compact"
|
||||
cur_sub = None
|
||||
continue
|
||||
if current is not None and _FRAGMENT_AUSF_RE.match(s):
|
||||
@@ -154,17 +154,17 @@ def _parse_fragment(text: str) -> list[dict]:
|
||||
continue
|
||||
m = _FRAGMENT_SUB_RE.match(s)
|
||||
if m and current is not None:
|
||||
teil = m.group(1).split("|", 1)
|
||||
stufe = teil[0].strip().casefold()
|
||||
titel = teil[1].strip() if len(teil) == 2 else ""
|
||||
key = titel.casefold() or f"_pos{len(current['_suborder'])}"
|
||||
parts = m.group(1).split("|", 1)
|
||||
level = parts[0].strip().casefold()
|
||||
title = parts[1].strip() if len(parts) == 2 else ""
|
||||
key = title.casefold() or f"_pos{len(current['_suborder'])}"
|
||||
cur_sub = current["_submap"].get(key)
|
||||
if cur_sub is None:
|
||||
cur_sub = {"stufe": stufe if stufe in _STUFEN else "anfaenger", "titel": titel, "md": [], "kompakt": []}
|
||||
cur_sub = {"level": level if level in _STUFEN else "beginner", "title": title, "md": [], "compact": []}
|
||||
current["_submap"][key] = cur_sub
|
||||
current["_suborder"].append(key)
|
||||
elif stufe in _STUFEN:
|
||||
cur_sub["stufe"] = stufe
|
||||
elif level in _STUFEN:
|
||||
cur_sub["level"] = level
|
||||
continue
|
||||
if current is not None:
|
||||
current[cur_layer].append(line)
|
||||
@@ -178,24 +178,24 @@ def _parse_fragment(text: str) -> list[dict]:
|
||||
for key in sec["_suborder"]:
|
||||
sub = sec["_submap"][key]
|
||||
sub["md"] = "\n".join(sub["md"]).strip()
|
||||
sub["kompakt"] = "\n".join(sub["kompakt"]).strip()
|
||||
if sub["md"] or sub["kompakt"]:
|
||||
sub["compact"] = "\n".join(sub["compact"]).strip()
|
||||
if sub["md"] or sub["compact"]:
|
||||
subs.append(sub)
|
||||
out.append({
|
||||
"kapitel": sec["kapitel"], "titel": sec["titel"],
|
||||
"chapters": sec["chapters"], "title": sec["title"],
|
||||
"md": "\n".join(sec["md"]).strip(),
|
||||
"kompakt": "\n".join(sec["kompakt"]).strip(),
|
||||
"anker": "\n".join(sec["anker_md"]).strip(),
|
||||
"anker_kompakt": "\n".join(sec["anker_kompakt"]).strip(),
|
||||
"compact": "\n".join(sec["compact"]).strip(),
|
||||
"anchor": "\n".join(sec["anker_md"]).strip(),
|
||||
"anker_compact": "\n".join(sec["anker_compact"]).strip(),
|
||||
"subs": subs,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _parse_subbausteine(text: str) -> dict[str, list[str]]:
|
||||
"""Parst eine Subbaustein-Datei → {Baustein-Titel: [Subbaustein, …]} in Reihenfolge.
|
||||
def _parse_subblocks(text: str) -> dict[str, list[str]]:
|
||||
"""Parse a subblock file → {block title: [subblock, …]} in order.
|
||||
|
||||
Format: `<!-- baustein: Titel -->` gefolgt von Listenzeilen `- Subbaustein`.
|
||||
Format: `<!-- block: Title -->` followed by list lines `- Subblock`.
|
||||
"""
|
||||
out: dict[str, list[str]] = {}
|
||||
current = None
|
||||
@@ -215,7 +215,7 @@ def _parse_subbausteine(text: str) -> dict[str, list[str]]:
|
||||
|
||||
|
||||
def _split_chunks(chapters: list[dict], n: int) -> list[list[dict]]:
|
||||
"""Teilt Kapitel in bis zu n zusammenhängende Chunks, balanciert nach Section-Anzahl."""
|
||||
"""Split chapters into up to n contiguous chunks, balanced by section count."""
|
||||
n = max(1, min(n, len(chapters)))
|
||||
chunks: list[list[dict]] = []
|
||||
current: list[dict] = []
|
||||
|
||||
Reference in New Issue
Block a user