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

View File

@@ -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 processesindependent 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(">")):

File diff suppressed because it is too large Load Diff

3326
backend/blocks.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -10,122 +10,123 @@ UNI_DIR = PROJECT_ROOT / "uni"
MAX_CONCURRENT_GENERATIONS = 10
# Lesbarkeits-Gate: deterministischer Prüfer (kleines deutsches Komplexitäts-Modell,
# Skala 17). Zu schwere Sections gehen in die Lese-Prüfungs-Überarbeitung.
# Fehlen transformers/torch oder das ModellGate stumm aus.
LESBARKEIT_AKTIV = True
LESBARKEIT_MODELL = "MiriUll/distilbert-german-text-complexity"
# Anker auf der 17-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 17). Sections that are too hard go into the read-exam revision.
# If transformers/torch or the model are missinggate silently off.
READABILITY_ACTIVE = True
READABILITY_MODEL = "MiriUll/distilbert-german-text-complexity"
# Anchors on the 17 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
# nichtEmbedding 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 loadembedding 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-Agentenkalt (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 agentscold (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?
},
}

View File

@@ -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

View File

@@ -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: RechercheVerifizieren. 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: Recherchebreit 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

View File

@@ -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)

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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
View File

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

View File

@@ -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 14. 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, # 2549 % → neutral
"solide": 16, # 5074 %
"stark": 24, # 7599 % (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)

View File

@@ -1,115 +0,0 @@
"""Deterministisches Lesbarkeits-Gate für Guide-Sections.
Ein kleines deutsches Komplexitäts-Modell (DistilBERT, GermEval 2022, Skala 17)
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 (17). 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

View File

@@ -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

View File

@@ -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)

View File

@@ -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); leerLive-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); emptylive 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

View File

@@ -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 × typeexample 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:

View File

@@ -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 herecopied 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-Rundeein 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ültigSlot-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/invalidslot 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 erreichtnur bis zum Deadline-Rest warten
# Grace set and minimum reachedonly 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 ErgebnisGrace {grace}s läuft")
_log(topic, f"{label}: first resultgrace {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
View File

@@ -0,0 +1,115 @@
"""Deterministic readability gate for guide sections.
A small German complexity model (DistilBERT, GermEval 2022, scale 17) 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 (17). 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

View File

@@ -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 0cap). 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

View File

@@ -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 FrageRe-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 questionre-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 ganzdie 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 quitethe 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 getipptSynonym-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 typedsynonym-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+Formatserst 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
View 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 0cap). 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

View File

@@ -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:
"""Titlenumber; 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. Titleshort 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) — 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] = []