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 Both runners are independent. If a binary/key is missing, only the
jeweilige Provider fehl — der andere läuft unverändert weiter. respective provider fails — the other keeps running unchanged.
""" """
import asyncio import asyncio
@@ -21,9 +21,9 @@ log = logging.getLogger("creator.agents")
_active_processes: dict[str, asyncio.subprocess.Process] = {} _active_processes: dict[str, asyncio.subprocess.Process] = {}
# Abgebrochene Scopes (Schlüssel-Präfixe, symmetrisch zu kill_process). Ein Agent, dessen # Cancelled scopes (key prefixes, symmetric to kill_process). An agent whose
# Key mit einem dieser Präfixe beginnt, bricht VOR dem Spawn ab — so werden auch in der # key starts with one of these prefixes aborts BEFORE the spawn — so agents WAITING
# Semaphore-Schlange WARTENDE Agenten beim Abbruch sofort gestoppt, statt noch zu starten. # in the semaphore queue are also stopped immediately on abort instead of still starting.
_cancelled_prefixes: set[str] = set() _cancelled_prefixes: set[str] = set()
@@ -38,15 +38,15 @@ def clear_scope(prefix: str) -> None:
def _scope_cancelled(agent_key: str) -> bool: def _scope_cancelled(agent_key: str) -> bool:
return any(agent_key.startswith(p) for p in _cancelled_prefixes) return any(agent_key.startswith(p) for p in _cancelled_prefixes)
# Deckelt die realen CLI-Prozesse — unabhängig von der Pipeline-Semaphore in # Caps the real CLI processesindependent of the pipeline semaphore in
# generator.py. Acquire passiert VOR dem Spawn, damit Wartezeit in der Queue # generator.py. The acquire happens BEFORE the spawn so that queue wait time
# nicht gegen den Agent-Timeout zählt. # does not count against the agent timeout.
_batch_sem = asyncio.Semaphore(MAX_CONCURRENT_AGENTS) _batch_sem = asyncio.Semaphore(MAX_CONCURRENT_AGENTS)
_interactive_sem = asyncio.Semaphore(MAX_CONCURRENT_INTERACTIVE) _interactive_sem = asyncio.Semaphore(MAX_CONCURRENT_INTERACTIVE)
# OpenCode-Starts serialisieren: gleichzeitig startende Prozesse kollidieren an # Serialize OpenCode starts: processes starting simultaneously collide on the
# der internen Session-DB ("database is locked", Exit nach <1s). Der kurze # internal session DB ("database is locked", exit after <1s). The short
# Versatz entzerrt die Starts; danach laufen die Prozesse normal parallel. # stagger spreads out the starts; afterwards the processes run in parallel normally.
_opencode_start_lock = asyncio.Lock() _opencode_start_lock = asyncio.Lock()
_OPENCODE_START_DELAY = 1.0 _OPENCODE_START_DELAY = 1.0
@@ -58,7 +58,7 @@ _CLAUDE_TOOLS = {
"none": None, "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 = { _OPENCODE_AGENTS = {
"full": "full", "full": "full",
"files": "files", "files": "files",
@@ -86,8 +86,8 @@ def provider_available(provider: str) -> bool:
def _kill(process) -> None: def _kill(process) -> None:
"""Killt den Agenten samt Kindprozessen über die Prozess-Gruppe (sonst überleben die """Kill the agent and its child processes via the process group (otherwise the
von der CLI gestarteten Kinder, halten die Pipes offen und blockieren communicate()).""" children spawned by the CLI survive, keep the pipes open and block communicate())."""
try: try:
os.killpg(os.getpgid(process.pid), signal.SIGKILL) os.killpg(os.getpgid(process.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError): except (ProcessLookupError, PermissionError):
@@ -98,9 +98,9 @@ def _kill(process) -> None:
def kill_process(agent_key_prefix: str) -> 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()): 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) _active_processes.pop(key, None)
continue continue
if key.startswith(agent_key_prefix): if key.startswith(agent_key_prefix):
@@ -117,16 +117,16 @@ async def run_agent(
capabilities: str = "none", capabilities: str = "none",
lane: str = "batch", lane: str = "batch",
) -> tuple[int, str, str]: ) -> tuple[int, str, str]:
if _scope_cancelled(agent_key): # vor dem Anstehen: gar nicht erst in die Schlange if _scope_cancelled(agent_key): # before queueing: don't even enter the queue
return 1, "", "abgebrochen" return 1, "", "cancelled"
if provider not in PROVIDERS: 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: 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 sem = _interactive_sem if lane == "interactive" else _batch_sem
async with sem: async with sem:
if _scope_cancelled(agent_key): # nach dem Acquire: in der Schlange abgebrochen → kein Spawn if _scope_cancelled(agent_key): # after the acquire: cancelled in the queue → no spawn
return 1, "", "abgebrochen" return 1, "", "cancelled"
if PROVIDERS[provider]["cli"] == "opencode": if PROVIDERS[provider]["cli"] == "opencode":
return await _run_opencode(agent_key, prompt, timeout, provider, role, capabilities) return await _run_opencode(agent_key, prompt, timeout, provider, role, capabilities)
return await _run_claude_cli(agent_key, prompt, timeout, 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, stdin=asyncio.subprocess.PIPE if stdin_data is not None else asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=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: 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) await asyncio.wait_for(process.wait(), timeout=5)
except asyncio.TimeoutError: except asyncio.TimeoutError:
pass pass
log.info("agent %s: Timeout nach %ds", agent_key, timeout) log.info("agent %s: timeout after %ds", agent_key, timeout)
raise raise
log.info( 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), agent_key, process.returncode, time.monotonic() - start, len(stdout),
) )
return process.returncode, stdout.decode("utf-8", errors="replace"), stderr.decode("utf-8", errors="replace") return process.returncode, stdout.decode("utf-8", errors="replace"), stderr.decode("utf-8", errors="replace")
finally: finally:
# Pop nur bei Identität: ein Slot-Restart unter demselben Key darf den # Pop only on identity: a slot restart under the same key must not evict
# NEUEN Prozess nicht aus dem Tracking werfen. # the NEW process from tracking.
if _active_processes.get(agent_key) is process: if _active_processes.get(agent_key) is process:
del _active_processes[agent_key] 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]: async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str, role: str, capabilities: str) -> tuple[int, str, str]:
cfg = PROVIDERS[provider] 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: with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8", dir=tempfile.gettempdir()) as f:
f.write(prompt) f.write(prompt)
prompt_path = Path(f.name) prompt_path = Path(f.name)
# Positional-Message MUSS vor -f stehen: -f ist ein Array-Flag und # The positional message MUST come before -f: -f is an array flag and
# frisst sonst den Text als zweiten Dateinamen ("File not found"). # would otherwise eat the text as a second file name ("File not found").
cmd = [ cmd = [
cfg["cli"], "run", cfg["cli"], "run",
"Folge exakt den Anweisungen in der angehängten Datei. Sie sind der vollständige Auftrag.", "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: 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) text = _ANSI_RE.sub("", text)
lines = text.splitlines() lines = text.splitlines()
while lines and (not lines[0].strip() or lines[0].lstrip().startswith(">")): 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 MAX_CONCURRENT_GENERATIONS = 10
# Lesbarkeits-Gate: deterministischer Prüfer (kleines deutsches Komplexitäts-Modell, # Readability gate: deterministic checker (small German complexity model,
# Skala 17). Zu schwere Sections gehen in die Lese-Prüfungs-Überarbeitung. # scale 17). Sections that are too hard go into the read-exam revision.
# Fehlen transformers/torch oder das ModellGate stumm aus. # If transformers/torch or the model are missinggate silently off.
LESBARKEIT_AKTIV = True READABILITY_ACTIVE = True
LESBARKEIT_MODELL = "MiriUll/distilbert-german-text-complexity" READABILITY_MODEL = "MiriUll/distilbert-german-text-complexity"
# Anker auf der 17-Skala (TextComplexityDE): Leichte Sprache ~1,2; Wikipedia-Schnitt # Anchors on the 17 scale (TextComplexityDE): plain language ~1.2; Wikipedia average
# ~3,22; ab MOS > 4 gilt ein Satz als „echt komplex" (Vereinfachungs-Grenze des Papers). # ~3.22; from MOS > 4 a sentence counts as "truly complex" (the paper's simplification cutoff).
LESBARKEIT_MAX = 3.5 # Section zu schwer, wenn der Satz-Schnitt darüber liegt READABILITY_MAX = 3.5 # section too hard when the sentence average is above this
LESBARKEIT_HART = 4.0 # Einzelsatz ab hier „hart" READABILITY_HARD = 4.0 # an individual sentence is "hard" from here on
LESBARKEIT_HART_ANTEIL = 0.30 # … ODER wenn dieser Anteil der Sätze hart ist READABILITY_HARD_SHARE = 0.30 # … OR when this share of sentences is hard
# Bausteine-Konsolidierung: semantisches Embedding-Clustering statt LLM-Listen-Merge. # Block consolidation: semantic embedding clustering instead of an LLM list merge.
# Ein kleines mehrsprachiges Satz-Embedding (mean-pool) bildet die Kandidaten-Cluster # A small multilingual sentence embedding (mean-pool) builds the candidate clusters
# GLOBAL (kein Chunk-Verlust) per Cosine + Union-Find. Titel-Varianten desselben Konzepts # GLOBALLY (no chunk loss) via cosine + union-find. Title variants of the same concept
# ("Vertex Cover" / "Vertex Cover Definition") verschmelzen; der Konsens zählt danach die # ("Vertex Cover" / "Vertex Cover Definition") merge; the consensus then counts the
# echten Reader pro Cluster (≥2 = Konsens). Fehlen transformers/torch oder lädt das Modell # real readers per cluster (≥2 = consensus). If transformers/torch are missing or the model
# nichtEmbedding stumm aus, `_konsolidiere` fällt auf den alten Panel-Judge-Pfad zurück. # won't loadembedding silently off, `_consolidate` falls back to the old panel-judge path.
EMBEDDING_AKTIV = True EMBEDDING_AKTIV = True
EMBEDDING_MODELL = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2" # CPU, mehrsprachig, ~470 MB EMBEDDING_MODELL = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2" # CPU, multilingual, ~470 MB
# Stärkere (größere) CPU-Alternative bei Bedarf: "BAAI/bge-m3". # Stronger (larger) CPU alternative if needed: "BAAI/bge-m3".
# Konsolidierung = zweistufig: (1) Embedding bildet GROBE Ähnlichkeits-Blocks (High-Recall), # Consolidation = two-stage: (1) the embedding builds COARSE similarity blocks (high recall),
# (2) ein LLM-Judge gruppiert JEDEN Block in die echten Bausteine (merge Paraphrasen, split # (2) an LLM judge groups EACH block into the real blocks (merge paraphrases, split
# Über-Merges). Reines Threshold-Blocking erzeugt einen Giant-Component (alles verkettet) → # over-merges). Pure threshold blocking creates a giant component (everything chained) →
# darum „Capped-Blocking": greedy nach Cosine mergen, aber Blockgröße deckeln. So bleiben die # hence "capped blocking": greedily merge by cosine, but cap the block size. This keeps the
# LLM-Listen kurz und stabil (belegt: Embedding-Block + LLM-Judge ≈ 95 % Precision). # LLM lists short and stable (evidenced: embedding block + LLM judge ≈ 95% precision).
EMBEDDING_BLOCK_FLOOR = 0.5 # Mindest-Cosine, damit zwei Kandidaten in EINEN Block dürfen EMBEDDING_BLOCK_FLOOR = 0.5 # minimum cosine for two candidates to share ONE block
EMBEDDING_BLOCK_CAP = 25 # max. Titel je Block (LLM-Liste kurz/stabil halten) EMBEDDING_BLOCK_CAP = 25 # max. titles per block (keep the LLM list short/stable)
# Subbaustein-Dedup: rein deterministisch (kein LLM). Subbausteine sind kurze Aussagen IM SELBEN # Subblock dedup: purely deterministic (no LLM). Subblocks are short statements IN THE SAME
# Baustein-Kontext — ab dieser Cosine sind zwei dieselbe Aussage (an aak geprüft: ≥0,88 ausnahmslos # block context — from this cosine on two are the same statement (checked on aak: ≥0.88 are
# echte Dubletten). Konservativ 0,90, damit verschiedene Aspekte (∈NP ≠ NP-schwer) getrennt bleiben. # without exception true duplicates). Conservative 0.90 so different aspects (∈NP ≠ NP-hard) stay separate.
EMBEDDING_SUB_DUP = 0.90 EMBEDDING_SUB_DUP = 0.90
# Deckel für gleichzeitige CLI-Agenten-Prozesse (über alle Generierungen hinweg). # Cap for concurrent CLI agent processes (across all generations).
# Eigene Spur für interaktive Aufrufe (Chat, Elemente), damit sie nicht hinter # Own lane for interactive calls (chat, elements) so they don't hang behind
# laufenden Writern in der Warteschlange hängen. # running writers in the queue.
MAX_CONCURRENT_AGENTS = 10 MAX_CONCURRENT_AGENTS = 10
MAX_CONCURRENT_INTERACTIVE = 8 MAX_CONCURRENT_INTERACTIVE = 8
# Grace-Fenster der Konsens-Races (Bausteine, Guide, OnePager): Nach dem ersten # Grace window of the consensus races (blocks, guide, OnePager): after the first
# gültigen Ergebnis dürfen die übrigen Agenten noch so viele Sekunden fertig # valid result the remaining agents may still become done for this many seconds
# werden (Kill nur, wenn das Minimum schon steht). # (kill only once the minimum is already in).
KONSENS_GRACE = 300 CONSENSUS_GRACE = 300
# Recherche-Race: längeres Grace-Fenster. Recherche treibt die ganze Bausteine-Anzahl; # Research race: longer grace window. Research drives the whole block count;
# bei langsamen Providern (z.B. MiniMax) sollen ALLE 5 Agenten fertig werden, nicht nur # with slow providers (e.g. MiniMax) ALL 5 agents should become done, not just
# das Quorum von 3. Pro-Agent-Timeout (TIMEOUTS["recherche"]=1800s) deckelt echte Hänger. # the quorum of 3. The per-agent timeout (TIMEOUTS["research"]=1800s) caps real hangs.
RECHERCHE_GRACE = 900 RESEARCH_GRACE = 900
# Cap der Klärungs- und Prüf-Loops: maximale Runden, bis alles entschieden sein # Cap of the clarification and check loops: maximum rounds until everything must be
# muss. In der letzten Runde MUSS der Mapping-Agent jeden Eintrag entscheiden; # decided. In the last round the mapping agent MUST decide every entry;
# Prüf-Loops lassen Rest-Beanstandungen danach stehen. # check loops leave any remaining objections standing after that.
KONSENS_MAX_RUNDEN = 3 CONSENSUS_MAX_ROUNDS = 3
# Crawler-Sichtung (Content/Noise) — deterministischer Regel-Filter statt LLM. # Crawler triage (content/noise) — deterministic rule filter instead of an LLM.
# Match: Substring (klein) gegen URL UND Dateiname. Reihenfolge: keep > noise > min_chars > behalten. # Match: substring (lowercase) against URL AND file name. Order: keep > noise > min_chars > keep.
# Sonderregeln einfach hier ergänzen. # Just add special rules here.
CRAWL_KEEP_PATTERNS = ["learn-unit", "learn-course"] # immer Content CRAWL_KEEP_PATTERNS = ["learn-unit", "learn-course"] # always content
CRAWL_NOISE_PATTERNS = [ # eindeutig themenfremd → raus CRAWL_NOISE_PATTERNS = [ # clearly off-topic → out
"clubs", "events", "podcasts", "resources", "-u-", "clubs", "events", "podcasts", "resources", "-u-",
"academy", "pricing", "/plans", "career", "newsletter", "impressum", "login", "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. # LLM topic relevance gate (after the rule filter): per content page yes/no against the spec.
# Trennt das Fachgebiet (z.B. Backend vs Frontend), was die globalen CRAWL_*-Regeln nicht können. # Separates the subject area (e.g. backend vs frontend), which the global CRAWL_* rules can't.
QUELLE_RELEVANZ_CHUNK = 12 # Seiten je Rater-Paket (klein, da je Seite ein Snippet mitgeht) QUELLE_RELEVANZ_CHUNK = 12 # pages per rater package (small, since a snippet ships per page)
QUELLE_RELEVANZ_SNIPPET = 800 # Body-Zeichen je Seite im Prompt (URL ist Primärsignal) 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). # Timeouts per agent step: (base seconds, seconds per block/section).
# Gilt für alle Provider gleich — wer zu langsam ist, wird neu gestartet bzw. überholt. # Applies equally to all providers — whoever is too slow gets restarted or overtaken.
TIMEOUTS = { TIMEOUTS = {
"recherche": (1800, 0), # fix 30 min "research": (1800, 0), # fixed 30 min
"recherche_mapping": (600, 3), # n = vorgemergte Einträge "research_mapping": (600, 3), # n = pre-merged entries
"auswahl_mapping": (600, 2), # n = Rest-Einträge (Bausteine-Inventar) "selection_mapping": (600, 2), # n = remaining entries (block inventory)
"ergaenzung": (900, 0), # Themenfeld-Ergänzung bei Projekten (Web-Recherche) "ergaenzung": (900, 0), # subject-field extension for projects (web research)
"plan": (300, 5), "plan": (300, 5),
"plan_judge": (600, 5), # Judge liest bis zu 5 Gliederungen, n = Sections "plan_judge": (600, 5), # judge reads up to 5 outlines, n = sections
"inhalt": (600, 90), # Inhalte je Baustein im Chunk identifizieren (Websuche) "content": (600, 90), # identify content per block in the chunk (web search)
"inhalt_check": (300, 10), # Inhalts-Prüfung je Baustein im Paket "content_check": (300, 10), # content exam per block in the package
"subbaustein": (900, 45), # Subbausteine je Baustein im Chunk finden (Websuche) "subblock": (900, 45), # find subblocks per block in the chunk (web search)
"subbaustein_check": (300, 15), # Judge entscheidet strittige Subbausteine im Chunk "subblock_check": (300, 15), # judge decides contested subblocks in the chunk
"stufe": (300, 10), # Subbausteine einstufen je Chunk "level": (300, 10), # classify subblocks per chunk
"stufe_check": (300, 10), # Judge entscheidet strittige Stufen im Chunk "level_check": (300, 10), # judge decides contested levels in the chunk
"relevanz": (300, 10), # Subbausteine relevant/rand je Chunk "relevance": (300, 10), # subblocks relevant/peripheral per chunk
"relevanz_check": (300, 10), # Judge entscheidet strittige Relevanz im Chunk "relevance_check": (300, 10), # judge decides contested relevance in the chunk
"frage_muster": (300, 15), # Frage-Muster je Baustein (Subbausteine × Typen) "question_pattern": (300, 15), # question patterns per block (subblocks × types)
"frage_muster_check": (300, 10), # Kritiker bereinigt die Muster-Tabelle je Baustein "question_pattern_check": (300, 10), # critic cleans up the pattern table per block
"writer": (600, 120), # pro Section im Chunk "writer": (600, 120), # per section in the chunk
"lese_check": (300, 10), # pro Section im Paket "lese_check": (300, 10), # per section in the package
} }
# Zweck je Format — fließt in den Gliederungs-Judge (was der Guide leisten soll). # Purpose per format — flows into the outline judge (what the guide should achieve).
FORMAT_ZWECK = { # German strings: these are inserted verbatim into the judge prompt → kept German on purpose.
FORMAT_PURPOSE = {
"Guide": "einen fokussierten Guide — alles Relevante ohne Randthemen", "Guide": "einen fokussierten Guide — alles Relevante ohne Randthemen",
"FullGuide": "einen Komplett-Guide — das ganze Thema inkl. Randthemen", "FullGuide": "einen Komplett-Guide — das ganze Thema inkl. Randthemen",
"Rest": "einen Ergänzungs-Guide — nur die Randthemen", "Rest": "einen Ergänzungs-Guide — nur die Randthemen",
} }
# Provider-Stacks: komplett unabhängig, einer kann jederzeit entfernt werden. # Provider stacks: completely independent, any one can be removed at any time.
# Rollen: "quick" = Massenarbeit (Recherche, Einordnung), # Roles: "quick" = bulk work (research, classification),
# "fast" = Interaktion + Voten (Chat, Prüfung, Klärung, Elemente), # "fast" = interaction + voting (chat, exam, clarification, elements),
# "judge" = Mapping-/Judge-/Prüf-Agentenkalt (niedrige Temperature, # "judge" = mapping/judge/check agentscold (low temperature,
# ohne Thinking) für stabile Urteile; Claude/Lokal mappen auf "fast", # no thinking) for stable verdicts; Claude/local map to "fast",
# "guide" = große Generierung (Vorschläge, Writer). # "guide" = large generation (proposals, writer).
DEFAULT_PROVIDER = "claude" DEFAULT_PROVIDER = "claude"
PROVIDERS = { PROVIDERS = {
"claude": { "claude": {
"cli": "claude", "cli": "claude",
"guide": "claude-opus-4-8[1m]", "guide": "claude-opus-4-8[1m]",
"fast": "claude-sonnet-4-6", "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", "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 # "minimax-kalt/…" is NOT its own stack, just an opencode provider entry
# (dev-ops/opencode.json) mit niedriger Temperature; M3 dort ohne Thinking. # (dev-ops/opencode.json) with low temperature; M3 there without thinking.
"minimax": { "minimax": {
"cli": "opencode", "cli": "opencode",
"guide": "minimax/MiniMax-M3", "guide": "minimax/MiniMax-M3",
@@ -141,6 +142,6 @@ PROVIDERS = {
"judge": "ollama/qwen3.5:9b", "judge": "ollama/qwen3.5:9b",
"quick": "ollama/qwen3.5:9b", "quick": "ollama/qwen3.5:9b",
"env_key": None, "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 Loads pages + PDFs starting from a start URL — ONLY the same domain, limited depth
Seitenzahl. HTML-Seiten werden im Headless-Browser gerendert (für SPAs nötig), dann and page count. HTML pages are rendered in a headless browser (needed for SPAs), then
Links + Text aus dem fertigen DOM gezogen. PDFs werden direkt als Bytes geladen. links + text are pulled from the finished DOM. PDFs are loaded directly as bytes.
Deterministisch, gebounded; läuft via asyncio.to_thread (Sync-API, kein Event-Loop). Deterministic, bounded; runs via asyncio.to_thread (sync API, no event loop).
""" """
import hashlib import hashlib
@@ -17,23 +17,23 @@ from fsutil import atomic_write_text
log = logging.getLogger("creator.crawl") log = logging.getLogger("creator.crawl")
MAX_TIEFE = 3 MAX_DEPTH = 3
MAX_SEITEN = 500 MAX_PAGES = 500
SEITE_TIMEOUT = 30 # Sekunden pro Seite (Render bzw. PDF-Download) PAGE_TIMEOUT = 30 # seconds per page (render or PDF download)
CRAWL_SETTLE_MS = 3000 # gedeckelter Settle nach domcontentloaded (SPA-Render); kein 30s-networkidle-Hang CRAWL_SETTLE_MS = 3000 # capped settle after domcontentloaded (SPA render); no 30s networkidle hang
MAX_BYTES = 10_000_000 # 10 MB Deckel pro PDF MAX_BYTES = 10_000_000 # 10 MB cap per PDF
_UA = "Mozilla/5.0 (creator-lernbot)" _UA = "Mozilla/5.0 (creator-lernbot)"
def _fetch_bytes(url: str) -> bytes | None: 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: try:
req = Request(url, headers={"User-Agent": _UA}) 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) data = resp.read(MAX_BYTES + 1)
return None if len(data) > MAX_BYTES else data return None if len(data) > MAX_BYTES else data
except Exception as e: 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 return None
@@ -48,25 +48,25 @@ def _is_pdf(url: str) -> bool:
def _scope_prefix(start_url: str) -> str: def _scope_prefix(start_url: str) -> str:
"""Erstes nicht-leeres Pfad-Segment der Start-URL als Crawl-Scope, z.B. """First non-empty path segment of the start URL as the crawl scope, e.g.
`/learn/path/x` → `/learn`. Ohne Pfad-Segment → `""` (ganze Domain, kein Regress).""" `/learn/path/x` → `/learn`. No path segment → `""` (whole domain, no narrowing)."""
seg = [s for s in urlparse(start_url).path.split("/") if s] seg = [s for s in urlparse(start_url).path.split("/") if s]
return f"/{seg[0]}" if seg else "" return f"/{seg[0]}" if seg else ""
def _in_scope(url: str, prefix: str) -> bool: 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: if not prefix:
return True return True
p = urlparse(url).path p = urlparse(url).path
return p == prefix or p.startswith(prefix + "/") return p == prefix or p.startswith(prefix + "/")
def _seiten_text(page) -> str: def _page_text(page) -> str:
"""Haupttext der gerenderten Seite — Nav/Footer/Boilerplate per trafilatura entfernt. """Main text of the rendered page — nav/footer/boilerplate removed via trafilatura.
Fallback auf den rohen Body-Text, wenn die Extraktion leer/zu kurz ausfällt (Nicht-Artikel-Seiten).""" Falls back to the raw body text when extraction is empty/too short (non-article pages)."""
try: 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 "" text = extract(page.content(), include_comments=False, include_tables=True) or ""
except Exception: except Exception:
text = "" text = ""
@@ -78,58 +78,58 @@ def _seiten_text(page) -> str:
return text.strip() return text.strip()
def crawl(start_url: str, ziel: Path, *, max_tiefe: int = MAX_TIEFE, max_seiten: int = MAX_SEITEN, cancelled=None) -> int: def crawl(start_url: str, target: Path, *, max_depth: int = MAX_DEPTH, max_pages: int = MAX_PAGES, cancelled=None) -> int:
"""Crawlt ab start_url (nur gleiche Domain), rendert JS und legt Seiten/PDFs in `ziel` ab. """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. BFS up to `max_depth` / `max_pages`. Errors on individual pages are skipped.
Schreibt am ENDE einen `.done`-Marker; ein Abbruch (`cancelled()` → True) lässt ihn weg, Writes a `.done` marker at the END; an abort (`cancelled()` → True) omits it,
sodass ein Neustart neu crawlt. Gibt die Zahl gespeicherter Quellen zurück. 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 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 domain = urlparse(start_url).netloc
prefix = _scope_prefix(start_url) # nur Links unter diesem Pfad-Segment folgen prefix = _scope_prefix(start_url) # only follow links under this path segment
gesehen: set[str] = set() seen: set[str] = set()
queue: list[tuple[str, int]] = [(urldefrag(start_url)[0], 0)] queue: list[tuple[str, int]] = [(urldefrag(start_url)[0], 0)]
gespeichert = 0 saved = 0
with sync_playwright() as pw: with sync_playwright() as pw:
browser = pw.chromium.launch(args=["--no-sandbox"]) # non-root (Docker user app) browser = pw.chromium.launch(args=["--no-sandbox"]) # non-root (Docker user app)
page = browser.new_page(user_agent=_UA) page = browser.new_page(user_agent=_UA)
try: try:
while queue and gespeichert < max_seiten: while queue and saved < max_pages:
if cancelled and cancelled(): if cancelled and cancelled():
return gespeichert # Abbruch → KEIN .done-Marker → Neustart crawlt neu return saved # abort → NO .done marker → restart crawls again
url, tiefe = queue.pop(0) url, depth = queue.pop(0)
if url in gesehen: if url in seen:
continue continue
gesehen.add(url) seen.add(url)
# PDFs brauchen kein Rendering — direkt laden. # PDFs need no rendering — load directly.
if _is_pdf(url): if _is_pdf(url):
data = _fetch_bytes(url) data = _fetch_bytes(url)
if data: if data:
p = ziel / _name(url, ".pdf") p = target / _name(url, ".pdf")
if not p.exists(): if not p.exists():
p.write_bytes(data) p.write_bytes(data)
gespeichert += 1 saved += 1
continue continue
try: 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: 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: try:
page.wait_for_load_state("networkidle", timeout=CRAWL_SETTLE_MS) page.wait_for_load_state("networkidle", timeout=CRAWL_SETTLE_MS)
except Exception: except Exception:
pass # SPA mit Dauer-Traffic erreicht nie idle → nach Settle weiter, kein 30s-Hang pass # an SPA with constant traffic never reaches idle → continue after settle, no 30s hang
text = _seiten_text(page) # Haupttext, Nav/Footer entfernt (Fallback: roher Body) text = _page_text(page) # main text, nav/footer removed (fallback: raw body)
if text: if text:
atomic_write_text(ziel / _name(url, ".txt"), f"QUELLE: {url}\n\n{text}") atomic_write_text(target / _name(url, ".txt"), f"QUELLE: {url}\n\n{text}")
gespeichert += 1 saved += 1
if tiefe < max_tiefe: if depth < max_depth:
try: try:
hrefs = page.eval_on_selector_all("a[href]", "els => els.map(e => e.href)") hrefs = page.eval_on_selector_all("a[href]", "els => els.map(e => e.href)")
except Exception: except Exception:
@@ -138,11 +138,11 @@ def crawl(start_url: str, ziel: Path, *, max_tiefe: int = MAX_TIEFE, max_seiten:
nxt = urldefrag(href)[0] nxt = urldefrag(href)[0]
if (nxt.startswith(("http://", "https://")) if (nxt.startswith(("http://", "https://"))
and urlparse(nxt).netloc == domain and _in_scope(nxt, prefix) and urlparse(nxt).netloc == domain and _in_scope(nxt, prefix)
and nxt not in gesehen): and nxt not in seen):
queue.append((nxt, tiefe + 1)) queue.append((nxt, depth + 1))
finally: finally:
browser.close() browser.close()
(ziel / ".done").write_text("ok", encoding="utf-8") # sauber durchgelaufen (target / ".done").write_text("ok", encoding="utf-8") # ran through cleanly
log.info("crawl %s%d Quellen in %s", start_url, gespeichert, ziel) log.info("crawl %s%d sources in %s", start_url, saved, target)
return gespeichert 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 json
import logging import logging
@@ -6,25 +6,25 @@ import uuid
from agents import run_agent from agents import run_agent
from config import DEFAULT_PROVIDER from config import DEFAULT_PROVIDER
from jsonio import parse_json_text as _parse_json_text, read_json_file as _json_datei from jsonio import parse_json_text as _parse_json_text, read_json_file as _read_json_file
from paths import bausteine_path, guide_content_path from paths import blocks_path, guide_content_path
from pipeline import _prompt from pipeline import _prompt
log = logging.getLogger("creator.elements") 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: def _build_guide_chat_prompt(topic: str, format_name: str, section: str, outline: str, messages: list[dict]) -> str:
transcript = "\n".join( 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 for m in messages
) )
return _prompt( return _prompt(
"Chat", "Chat",
topic=topic, format_name=format_name, topic=topic, format_name=format_name,
outline_block=outline.strip() or "(keine)", outline_block=outline.strip() or "(none)",
section_block=section.strip() or "(kein Abschnitt erkannt)", section_block=section.strip() or "(no section detected)",
transcript=transcript, 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" "chat-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
) )
if returncode != 0: 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() reply = stdout.strip()
return reply or "Entschuldigung, ich habe keine Antwort erhalten." return reply or "Sorry, I didn't get a response."
except Exception: except Exception:
log.warning("[%s] Guide-Chat fehlgeschlagen", topic, exc_info=True) log.warning("[%s] Guide chat failed", topic, exc_info=True)
return "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut." return "Sorry, that didn't work. Please try again."
# --- Elemente --- # --- Elements ---
def _element_fields(data: dict) -> dict | None: 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): if not isinstance(data, dict):
return None return None
title = str(data.get("title", "")).strip() title = str(data.get("title", "")).strip()
if not title: if not title:
return None return None
listen = {} lists = {}
for key in ("examples", "hints"): for key in ("examples", "hints"):
raw = data.get(key, []) 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 { return {
"title": title[:200], "title": title[:200],
"description": str(data.get("description", "")).strip(), "description": str(data.get("description", "")).strip(),
"examples": listen["examples"], "examples": lists["examples"],
"hints": listen["hints"], "hints": lists["hints"],
} }
def _topic_context(topic: str, limit: int = 12000) -> str: 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] = [] parts: list[str] = []
bp = bausteine_path(topic) bp = blocks_path(topic)
if bp.exists(): if bp.exists():
parts.append(bp.read_text(encoding="utf-8")) parts.append(bp.read_text(encoding="utf-8"))
for fmt in ("Guide", "FullGuide"): # bester verfügbarer Prosa-Guide als Chat-Kontext for fmt in ("Guide", "FullGuide"): # best available prose guide as chat context
content = _json_datei(guide_content_path(topic, fmt)) content = _read_json_file(guide_content_path(topic, fmt))
if content: if content:
for ch in content.get("chapters", []): for ch in content.get("chapters", []):
for sec in ch.get("sections", []): for sec in ch.get("sections", []):
parts.append(sec if isinstance(sec, str) else json.dumps(sec, ensure_ascii=False)) 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() 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: 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.""" """Create element fields via AI. Fallback: only the title from the keyword."""
fallback = {"title": hint.strip() or "Neues Element", "description": "", "examples": [], "hints": []} fallback = {"title": hint.strip() or "New element", "description": "", "examples": [], "hints": []}
try: try:
context = _topic_context(topic) context = _topic_context(topic)
if extra_context.strip(): if extra_context.strip():
context = (extra_context.strip() + "\n\n" + context)[:12000] context = (extra_context.strip() + "\n\n" + context)[:12000]
prompt = _prompt( prompt = _prompt(
"Element-Create", "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, context=context,
) )
returncode, stdout, _ = await run_agent( returncode, stdout, _ = await run_agent(
@@ -101,12 +101,12 @@ async def generate_element(topic: str, hint: str, provider: str = DEFAULT_PROVID
return fallback return fallback
return _element_fields(_parse_json_text(stdout)) or fallback return _element_fields(_parse_json_text(stdout)) or fallback
except Exception: except Exception:
log.warning("[%s] Element-Erstellung fehlgeschlagen", topic, exc_info=True) log.warning("[%s] Element creation failed", topic, exc_info=True)
return fallback return fallback
def _parse_suggestions(stdout: str) -> list[dict] | None: 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) data = _parse_json_text(stdout)
if not isinstance(data, dict): if not isinstance(data, dict):
return None 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: 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: try:
element_json = json.dumps( element_json = json.dumps(
{k: element[k] for k in ("title", "description", "examples", "hints")}, {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"]) 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) prompt = _prompt("Element-Check", topic=element["topic"], element_json=element_json, context=context)
returncode, stdout, _ = await run_agent( returncode, stdout, _ = await run_agent(
"element-check-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive" "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: if not candidates:
return [] return []
# Schritt 2: Verifizieren — nur Wichtiges, nicht Redundantes durchlassen # Step 2: verify — only let important, non-redundant items through
prompt = _prompt( prompt = _prompt(
"Element-Verify", "Element-Verify",
topic=element["topic"], element_json=element_json, 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 None
return _parse_suggestions(stdout) return _parse_suggestions(stdout)
except Exception: 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 return None
@@ -170,7 +170,7 @@ def _element_json(element: dict) -> str:
def _validate_change(c, element: dict) -> dict | None: 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): if not isinstance(c, dict):
return None return None
text = str(c.get("text", "")).strip() text = str(c.get("text", "")).strip()
@@ -178,16 +178,16 @@ def _validate_change(c, element: dict) -> dict | None:
target = c.get("target") target = c.get("target")
index = c.get("index") index = c.get("index")
content = str(c.get("content", "")).strip() 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 return None
if target not in ("title", "description", "examples", "hints"): if target not in ("title", "description", "examples", "hints"):
return None return None
if action in ("anpassen", "hinzufuegen") and not content: if action in ("adjust", "add") and not content:
return None return None
if action == "entfernen" and target not in ("examples", "hints"): if action == "remove" and target not in ("examples", "hints"):
return None return None
# Index nur für anpassen/entfernen in Listen-Feldern; muss existieren # Index only for adjust/remove on list fields; must exist
if target in ("examples", "hints") and action in ("anpassen", "entfernen"): if target in ("examples", "hints") and action in ("adjust", "remove"):
if not isinstance(index, int) or not (0 <= index < len(element[target])): if not isinstance(index, int) or not (0 <= index < len(element[target])):
return None return None
else: 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]]: 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.""" """Chat about the element. Returns (reply, change suggestions) — changes nothing directly."""
fehler = "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut." error = "Sorry, that didn't work. Please try again."
try: try:
transcript = "\n".join( 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 for m in messages
) )
prompt = _prompt("Element-Chat", topic=element["topic"], element_json=_element_json(element), transcript=transcript) 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" "element-chat-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
) )
if returncode != 0: if returncode != 0:
return fehler, [] return error, []
data = _parse_json_text(stdout) data = _parse_json_text(stdout)
if not isinstance(data, dict): if not isinstance(data, dict):
return fehler, [] return error, []
changes = [v for c in data.get("changes", []) if (v := _validate_change(c, element))] 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 return reply, changes
except Exception: except Exception:
log.warning("[%s] Element-Chat fehlgeschlagen", element.get("topic", "?"), exc_info=True) log.warning("[%s] Element chat failed", element.get("topic", "?"), exc_info=True)
return fehler, [] return error, []
async def style_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list[dict] | None: 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: 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( returncode, stdout, _ = await run_agent(
"element-stil-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive" "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 None
return [v for c in data.get("changes", []) if (v := _validate_change(c, element))] return [v for c in data.get("changes", []) if (v := _validate_change(c, element))]
except Exception: 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 return None
async def refine_suggestion(element: dict, suggestion: dict, instruction: str, provider: str = DEFAULT_PROVIDER) -> dict | 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: try:
prompt = _prompt( prompt = _prompt(
"Element-Refine", "Element-Refine",
@@ -257,5 +257,5 @@ async def refine_suggestion(element: dict, suggestion: dict, instruction: str, p
return None return None
return _validate_change(data.get("change"), element) return _validate_change(data.get("change"), element)
except Exception: 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 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 + Mean-pool embeddings of a multilingual sentence model build GLOBAL candidate
Union-Find GLOBALE Kandidaten-Cluster (kein Chunk-Verlust). Sichere Paare (Ähnlichkeit clusters via cosine blocking + union-find (no chunk loss). Safe pairs (similarity
≥ HART) werden ohne LLM gemergt; Grenz-Paare im Band [BAND_LOW, HART) gibt der Aufrufer ≥ HARD) are merged without an LLM; borderline pairs in the band [BAND_LOW, HARD) are
einem LLM-Judge zur ja/nein-Entscheidung. Fehlen `transformers`/`torch` oder lädt das handed by the caller to an LLM judge for a yes/no decision. If `transformers`/`torch`
Modell nicht → `embed_sims()` liefert `None`, der Aufrufer fällt auf den alten are missing or the model won't load → `embed_sims()` returns `None`, and the caller
Panel-Judge-Pfad zurück (silente Deaktivierung, wie das Lesbarkeits-Gate). 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`. CPU is enough; the caller wraps the blocking inference in `asyncio.to_thread`.
`numpy` ist transitiv über torch vorhanden (bewusst nicht in requirements.txt, analog torch). `numpy` comes in transitively via torch (deliberately not in requirements.txt, like torch).
""" """
import logging import logging
@@ -19,19 +19,19 @@ from config import EMBEDDING_AKTIV, EMBEDDING_MODELL, EMBEDDING_BLOCK_FLOOR, EMB
log = logging.getLogger("creator.embedding") log = logging.getLogger("creator.embedding")
_modell_cache = None # (tokenizer, model, torch) — Singleton _model_cache = None # (tokenizer, model, torch) — singleton
_ladeversuch = False # schon versucht zu laden? _load_attempt = False # already tried to load?
EMBEDDING_BATCH = 32 # Inferenz-Batchgröße (CPU) EMBEDDING_BATCH = 32 # inference batch size (CPU)
EMBEDDING_MAX_LEN = 128 # Titel + Kurzbeschreibung sind kurz → kleiner Truncation-Cap genügt EMBEDDING_MAX_LEN = 128 # title + short description are short → a small truncation cap suffices
def _modell(): def _model():
"""Lädt das Modell einmalig. None = Clustering aus (deaktiviert oder Lade-Fehler).""" """Load the model once. None = clustering off (disabled or load error)."""
global _modell_cache, _ladeversuch global _model_cache, _load_attempt
if _ladeversuch: if _load_attempt:
return _modell_cache return _model_cache
_ladeversuch = True _load_attempt = True
if not EMBEDDING_AKTIV: if not EMBEDDING_AKTIV:
return None return None
try: try:
@@ -40,24 +40,24 @@ def _modell():
tok = AutoTokenizer.from_pretrained(EMBEDDING_MODELL) tok = AutoTokenizer.from_pretrained(EMBEDDING_MODELL)
model = AutoModel.from_pretrained(EMBEDDING_MODELL) model = AutoModel.from_pretrained(EMBEDDING_MODELL)
model.eval() model.eval()
_modell_cache = (tok, model, torch) _model_cache = (tok, model, torch)
log.info("Embedding-Modell geladen: %s", EMBEDDING_MODELL) log.info("embedding model loaded: %s", EMBEDDING_MODELL)
except Exception as e: except Exception as e:
log.warning("Embedding-Clustering deaktiviert (Modell nicht ladbar): %s", e) log.warning("embedding clustering disabled (model not loadable): %s", e)
_modell_cache = None _model_cache = None
return _modell_cache return _model_cache
def verfuegbar() -> bool: def available() -> bool:
"""True, wenn das Modell geladen werden konnte. Lädt beim ersten Aufruf (blockierend).""" """True if the model could be loaded. Loads on the first call (blocking)."""
return _modell() is not None return _model() is not None
def embed(texts: list[str]) -> "np.ndarray | None": def embed(texts: list[str]) -> "np.ndarray | None":
"""Texte → (n, d) L2-normalisierte, mean-gepoolte Embeddings. None = Modell aus.""" """Texts → (n, d) L2-normalized, mean-pooled embeddings. None = model off."""
if _modell() is None: if _model() is None:
return None return None
tok, model, torch = _modell_cache tok, model, torch = _model_cache
out = [] out = []
for i in range(0, len(texts), EMBEDDING_BATCH): for i in range(0, len(texts), EMBEDDING_BATCH):
batch = texts[i:i + EMBEDDING_BATCH] batch = texts[i:i + EMBEDDING_BATCH]
@@ -65,8 +65,8 @@ def embed(texts: list[str]) -> "np.ndarray | None":
with torch.no_grad(): with torch.no_grad():
hidden = model(**enc).last_hidden_state # (b, t, d) hidden = model(**enc).last_hidden_state # (b, t, d)
mask = enc["attention_mask"].unsqueeze(-1).type_as(hidden) 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 = (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 = Skalarprodukt vec = torch.nn.functional.normalize(vec, p=2, dim=1) # L2 → cosine = dot product
out.append(vec.cpu().numpy()) out.append(vec.cpu().numpy())
return np.vstack(out).astype(np.float32) 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: def _union(parent: list[int], a: int, b: int) -> None:
ra, rb = _find(parent, a), _find(parent, b) ra, rb = _find(parent, a), _find(parent, b)
if ra != rb: 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]): 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) embs = embed(texts)
if embs is None: if embs is None:
return 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]]: 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 Greedy: all pairs with cosine ≥ `floor` in descending cosine order; two blocks are merged
verschmolzen, wenn der resultierende Block ≤ `cap` bleibt. Verhindert den Giant-Component only if the resulting block stays ≤ `cap`. Prevents the giant component (pure threshold
(reines Threshold-Blocking verkettet sonst fast alles) und hält die LLM-Listen kurz. blocking would otherwise chain almost everything together) and keeps the LLM lists short.
Liste von Blocks (Index-Listen), jeder Knoten in genau einem Block. list of blocks (index lists), each node in exactly one block.
""" """
fl = EMBEDDING_BLOCK_FLOOR if floor is None else floor fl = EMBEDDING_BLOCK_FLOOR if floor is None else floor
cp = EMBEDDING_BLOCK_CAP if cap is None else cap 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) iu = np.triu_indices(n, k=1)
s = sims[iu] s = sims[iu]
kept = np.where(s >= fl)[0] 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])]: for k in kept[np.argsort(-s[kept])]:
i, j = int(iu[0][k]), int(iu[1][k]) i, j = int(iu[0][k]), int(iu[1][k])
ri, rj = _find(parent, i), _find(parent, j) 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 A crash leaves at most a .tmp file behind — never a half-written target
Zieldatei. Die .tmp wird beim nächsten erfolgreichen Write überschrieben. file. The .tmp is overwritten on the next successful write.
""" """
import json 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 Copes with code fences, surrounding prose and unescaped quotes inside
Strings (z. B. MiniMax: "Titel „p" geändert"): das letzte `"` vor der strings (e.g. MiniMax: "Title „p" changed"): the last `"` before the
Fehlerstelle wird escapet und erneut geparst. error position is escaped and parsing is retried.
""" """
import json import json
@@ -14,7 +14,7 @@ log = logging.getLogger("creator.jsonio")
def parse_json_text(text: str): 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()) text = re.sub(r"^```(?:json)?\s*|\s*```$", "", (text or "").strip())
start, end = text.find("{"), text.rfind("}") start, end = text.find("{"), text.rfind("}")
if start == -1 or end <= start: if start == -1 or end <= start:
@@ -36,14 +36,14 @@ def parse_json_text(text: str):
def read_json_file(path: Path): 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(): if not path.exists():
return None return None
try: try:
data = parse_json_text(path.read_text(encoding="utf-8")) data = parse_json_text(path.read_text(encoding="utf-8"))
except Exception as e: 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 return None
if data is None: if data is None:
log.debug("JSON-Datei ungültig: %s", path) log.debug("JSON file invalid: %s", path)
return data 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 logging
import os import os

View File

@@ -16,7 +16,7 @@ from routes import router
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): 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 init_db()
await reconcile_guides() await reconcile_guides()
yield yield
@@ -24,8 +24,8 @@ async def lifespan(app: FastAPI):
class CachedStatic(StaticFiles): class CachedStatic(StaticFiles):
"""StaticFiles mit Cache-Control: gehashte Assets dauerhaft (immutable), """StaticFiles with Cache-Control: hashed assets forever (immutable),
index.html nie cachen (verweist immer auf die aktuellen Asset-Hashes).""" index.html never cached (it always points at the current asset hashes)."""
async def get_response(self, path, scope): async def get_response(self, path, scope):
resp = await super().get_response(path, scope) resp = await super().get_response(path, scope)
if path.startswith("assets/"): if path.startswith("assets/"):
@@ -37,7 +37,7 @@ class CachedStatic(StaticFiles):
app = FastAPI(title="Creator", lifespan=lifespan) 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.add_middleware(GZipMiddleware, minimum_size=500)
app.include_router(router) app.include_router(router)

View File

@@ -17,47 +17,47 @@ class GuideCreateRequest(BaseModel):
format: FormatType format: FormatType
instructions: str = Field(default="", max_length=2000) instructions: str = Field(default="", max_length=2000)
provider: ProviderType = "claude" 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): class TopicCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=100) name: str = Field(min_length=1, max_length=100)
class BausteineCreateRequest(BaseModel): class BlocksCreateRequest(BaseModel):
topic: str = Field(min_length=1, max_length=100) topic: str = Field(min_length=1, max_length=100)
instructions: str = Field(default="", max_length=2000) instructions: str = Field(default="", max_length=2000)
provider: ProviderType = "claude" provider: ProviderType = "claude"
source_type: SourceType = "thema" source_type: SourceType = "thema"
source_ort: str = Field(default="", max_length=2000) source_location: 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_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 ab feinem Teilschritt (0-basierter Index in _bausteine_steps); hat Vorrang vor ab_phase 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) 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 label: str
state: Literal["done", "active", "pending"] state: Literal["done", "active", "pending"]
class BausteineFeinStep(BaseModel): class BlocksFineStep(BaseModel):
label: str label: str
phase: str = "" phase: str = ""
state: Literal["done", "active", "pending"] state: Literal["done", "active", "pending"]
class BausteineStatusResponse(BaseModel): class BlocksStatusResponse(BaseModel):
ready: bool ready: bool
generating: bool generating: bool
progress: str | None = None progress: str | None = None
error: str | None = None error: str | None = None
partial: bool = False partial: bool = False
steps: list[BausteineStep] = [] steps: list[BlocksStep] = []
feine_steps: list[BausteineFeinStep] = [] feine_steps: list[BlocksFineStep] = []
class ProjectResponse(BaseModel): class ProjectResponse(BaseModel):
@@ -66,33 +66,33 @@ class ProjectResponse(BaseModel):
class FolderResponse(BaseModel): class FolderResponse(BaseModel):
name: str 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) topic: str = Field(min_length=1, max_length=100)
type: SourceType = "thema" type: SourceType = "thema"
ort: str = Field(default="", max_length=2000) location: str = Field(default="", max_length=2000)
spec: str = Field(default="", max_length=2000) spec: str = Field(default="", max_length=2000)
class BausteineQuelleResponse(BaseModel): class BlocksSourceResponse(BaseModel):
type: SourceType type: SourceType
ort: str location: str
spec: str spec: str
class SubbausteinInfo(BaseModel): class SubblockInfo(BaseModel):
titel: str title: str
stufe: Literal["anfaenger", "fortgeschritten", "experte", "einfach", "mittel", "schwer"] level: Literal["beginner", "advanced", "expert", "easy", "medium", "hard"]
relevanz: Literal["relevant", "rand"] | None = None relevance: Literal["relevant", "peripheral"] | None = None
class BausteinUebersicht(BaseModel): class BlockOverview(BaseModel):
num: int num: int
titel: str title: str
beschreibung: str = "" description: str = ""
subbausteine: list[SubbausteinInfo] = [] subblocks: list[SubblockInfo] = []
class ProviderInfo(BaseModel): class ProviderInfo(BaseModel):
@@ -168,7 +168,7 @@ class ElementCheckResponse(BaseModel):
class ElementStyleChange(BaseModel): class ElementStyleChange(BaseModel):
text: str text: str
action: Literal["entfernen", "anpassen", "hinzufuegen"] action: Literal["remove", "adjust", "add"]
target: Literal["title", "description", "examples", "hints"] target: Literal["title", "description", "examples", "hints"]
index: int | None = None index: int | None = None
content: str = "" content: str = ""
@@ -207,104 +207,104 @@ class ProgressResponse(BaseModel):
chapters: list[str] chapters: list[str]
# --- Baustein-Lernen --- # --- Block learning ---
class BausteinChatRequest(BaseModel): class BlockChatRequest(BaseModel):
topic: str = Field(min_length=1, max_length=100) topic: str = Field(min_length=1, max_length=100)
baustein: str = Field(min_length=1, max_length=200) block: str = Field(min_length=1, max_length=200)
section: str = Field(default="", max_length=20000) # ausführliche Fassung section: str = Field(default="", max_length=20000) # detailed version
section_kompakt: str = Field(default="", max_length=20000) # kompakte Fassung (Merksätze) section_compact: str = Field(default="", max_length=20000) # compact version (mnemonics)
messages: list[ChatMessage] = Field(min_length=1) messages: list[ChatMessage] = Field(min_length=1)
provider: ProviderType = "claude" provider: ProviderType = "claude"
class BausteinChatResponse(BaseModel): class BlockChatResponse(BaseModel):
reply: str reply: str
class BausteinPruefungRequest(BaseModel): class BlockExamRequest(BaseModel):
topic: str = Field(min_length=1, max_length=100) topic: str = Field(min_length=1, max_length=100)
baustein: str = Field(min_length=1, max_length=200) block: str = Field(min_length=1, max_length=200)
section: str = Field(default="", max_length=20000) # ausführliche Fassung section: str = Field(default="", max_length=20000) # detailed version
section_kompakt: str = Field(default="", max_length=20000) # kompakte Fassung (Merksätze) section_compact: str = Field(default="", max_length=20000) # compact version (mnemonics)
aktion: Literal[ action: Literal[
"frage", "diskussion", "antwort", "antwort_pruefen", "question", "discussion", "answer", "answer_check",
"quiz_frage", "quiz_antwort", "lueck_frage", "lueck_antwort", "quiz_question", "quiz_answer", "gap_question", "gap_answer",
] = "frage" ] = "question"
frage: str = Field(default="", max_length=2000) # aktuell geprüfte Frage (für diskussion/antwort); Anker der Basis question: str = Field(default="", max_length=2000) # currently checked question (for discussion/answer); base anchor
auswahl: list[int] = [] # Quiz/Lückentext-Auswahl: vom Lerner gewählte Options-Indizes selection: list[int] = [] # quiz/gap-text choice: option indices picked by the learner
korrekt: list[int] = [] # Quiz/Lückentext-Auswahl: korrekte Indizes (Client hält sie aus der Generierung) correct: list[int] = [] # quiz/gap-text choice: correct indices (the client keeps them from generation)
loesung: str = Field(default="", max_length=500) # Lückentext frei: erwarteter Begriff solution: str = Field(default="", max_length=500) # gap text free: expected term
alternativen: list[str] = [] # Lückentext frei: akzeptierte Synonyme alternatives: list[str] = [] # gap text free: accepted synonyms
eingabe: str = Field(default="", max_length=500) # Lückentext frei: getippter Begriff input: str = Field(default="", max_length=500) # gap text free: typed term
schwer: bool = False # Variante: leicht (+1/1) vs schwer (+3/1) schwer: bool = False # variant: easy (+1/1) vs hard (+3/1)
letzte_bewertung: str = Field(default="", max_length=2000) # Feedback der letzten Bewertung (Kontext für diskussion) last_rating: str = Field(default="", max_length=2000) # feedback of the last rating (context for discussion)
vermeide: list[str] = [] # schon gestellte + vorgemerkte Fragen — sinngemäß nicht wiederholen avoid: list[str] = [] # already-asked + earmarked questions — don't repeat them in substance
nachgefragt: bool = False # für diese Frage wurde nachgefragt → Gewinn auf +1 gedeckelt asked_again: bool = False # asked_again was used for this question → gain capped at +1
begruendung: str = Field(default="", max_length=2000) # „Gründlich prüfen": warum mit der Bewertung unzufrieden reason: str = Field(default="", max_length=2000) # "thorough check": why dissatisfied with the rating
muster: str = Field(default="", max_length=2000) # gezogenes Frage-Muster (Saat); leerLive-Generierung (Fallback) pattern: str = Field(default="", max_length=2000) # drawn question pattern (seed); emptylive generation (fallback)
# Basis + cap werden serverseitig geführt (Anker bzw. Subs×25) — Client-cap nur Hinweis. # 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-Deckel = freigeschaltete Subbausteine × 25 cap: int = Field(default=10, ge=1, le=10000) # score cap = unlocked subblocks × 25
messages: list[ChatMessage] = [] # Dialog bisher; leer = erste Frage messages: list[ChatMessage] = [] # dialog so far; empty = first question
provider: ProviderType = "claude" 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): class QuizOption(BaseModel):
text: str text: str
korrekt: bool correct: bool
class BausteinPruefungResponse(BaseModel): class BlockExamResponse(BaseModel):
frage: str | None = None question: str | None = None
reply: str | None = None reply: str | None = None
feedback: str | None = None feedback: str | None = None
punkte: int | None = None # Punkt-Delta dieser Antwort (2 … +3); schnell = voraussichtlich points: int | None = None # points delta of this answer (2 … +3); fast = expected
bewertung: Literal["gut", "neutral", "schlecht"] | None = None # aus Vorzeichen, fürs Einfärben rating: Literal["gut", "neutral", "schlecht"] | None = None # from the sign, for coloring
optionen: list[QuizOption] | None = None # Quiz: 4 Optionen + Korrekt-Flags options: list[QuizOption] | None = None # quiz: 4 options + correct flags
satz: str | None = None # Lückentext: Satz mit Lücke (___) sentence: str | None = None # gap text: sentence with a gap (___)
loesung: str | None = None # Lückentext: erwarteter Begriff solution: str | None = None # gap text: expected term
alternativen: list[str] | None = None # Lückentext: akzeptierte Synonyme alternatives: list[str] | None = None # gap text: accepted synonyms
gute_antworten: int good_answers: int
streak: int = 0 # aktuelle Serie korrekter Antworten (je Baustein) streak: int = 0 # current run of correct answers (per block)
cap: int = 10 # cap_final = alle Subs × 25 — Frontend leitet die Lernstufe ab cap: int = 10 # cap_final = all subs × 25 — the frontend derives the learning level
class BausteinLernstand(BaseModel): class BlockLearnState(BaseModel):
gute_antworten: int good_answers: int
streak: int = 0 streak: int = 0
cap: int = 0 # cap_final = alle Subbausteine × 25 cap: int = 0 # cap_final = all subblocks × 25
cap_aktuell: int = 0 # erreichbarer cap der aktuell freigeschalteten Ebene cap_aktuell: int = 0 # reachable cap of the currently unlocked level
freie_ebene: int = 1 # 1=A · 2=F · 3=E · 4=V freie_level: int = 1 # 1=A · 2=F · 3=E · 4=V
class BausteinLernstandResponse(BaseModel): class BlockLearnStateResponse(BaseModel):
bausteine: dict[str, BausteinLernstand] 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): class BlockPruefenRequest(BaseModel):
baustein: str = Field(min_length=1, max_length=200) block: str = Field(min_length=1, max_length=200)
stelle: str = "ausführlich" # "kompakt" | "ausführlich" (angezeigtes Feld) spot: str = "ausführlich" # "compact" | "ausführlich" (displayed field)
block: str = Field(min_length=1, max_length=20000) # roher Markdown-Block snippet: str = Field(min_length=1, max_length=20000) # raw markdown block
hinweis: str = Field(default="", max_length=2000) # optionaler Zusatz (✏️) hint: str = Field(default="", max_length=2000) # optional addition (✏️)
provider: ProviderType = "claude" provider: ProviderType = "claude"
class BlockPruefenResponse(BaseModel): class BlockPruefenResponse(BaseModel):
neu: str # korrigierter Block als Markdown revised: str # corrected block as markdown
class BlockUebernehmenRequest(BaseModel): class BlockUebernehmenRequest(BaseModel):
baustein: str = Field(min_length=1, max_length=200) block: str = Field(min_length=1, max_length=200)
stelle: str = "ausführlich" spot: str = "ausführlich"
alt: str = Field(min_length=1, max_length=20000) 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" provider: ProviderType = "claude"
class BlockUebernehmenResponse(BaseModel): class BlockUebernehmenResponse(BaseModel):
kompakt: str compact: str
md: 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 from config import STORAGE_DIR, PROJECTS_DIR, PROJECT_ROOT
THEMEN_DIR = STORAGE_DIR / "themen" TOPICS_DIR = STORAGE_DIR / "topics"
def _safe(name: str) -> str: def _safe(name: str) -> str:
@@ -10,42 +10,42 @@ def _safe(name: str) -> str:
def topic_dir(topic: str) -> Path: def topic_dir(topic: str) -> Path:
return THEMEN_DIR / _safe(topic) return TOPICS_DIR / _safe(topic)
def arbeit_dir(topic: str) -> Path: def arbeit_dir(topic: str) -> Path:
return topic_dir(topic) / "arbeit" return topic_dir(topic) / "arbeit"
def bausteine_path(topic: str) -> Path: def blocks_path(topic: str) -> Path:
return topic_dir(topic) / "bausteine.md" return topic_dir(topic) / "blocks.md"
def subbausteine_path(topic: str) -> Path: def subblocks_path(topic: str) -> Path:
"""Sidecar: pro Baustein die Subbausteine mit Stufe (von allen Guides geteilt).""" """Sidecar: the subblocks with level per block (shared by all guides)."""
return topic_dir(topic) / "subbausteine.json" return topic_dir(topic) / "subblocks.json"
def frage_muster_path(topic: str) -> Path: def question_pattern_path(topic: str) -> Path:
"""Sidecar: pro Baustein vordefinierte Frage-Muster (Subbaustein × Typ → Beispielfrage).""" """Sidecar: predefined question patterns per block (subblock × typeexample question)."""
return topic_dir(topic) / "frage_muster.json" return topic_dir(topic) / "question_pattern.json"
def quelle_path(topic: str) -> Path: def source_path(topic: str) -> Path:
"""Persistierte Quellen-Wahl pro Thema: {type, ort, spec}.""" """Persisted source choice per topic: {type, location, spec}."""
return topic_dir(topic) / "quelle.json" return topic_dir(topic) / "source.json"
def quelle_crawl_dir(topic: str) -> Path: def source_crawl_dir(topic: str) -> Path:
"""Zielordner für gecrawlte Link-Quellen (Seiten + PDF-.txt).""" """Target folder for crawled link sources (pages + PDF .txt)."""
return topic_dir(topic) / "quelle" return topic_dir(topic) / "source"
def safe_ordner(ort: str) -> Path | None: def safe_folder(location: str) -> Path | None:
"""Ordnerpfad relativ zum Repo-Root, gesandboxt. None bei leer/Ausbruch (../, absolut außerhalb).""" """Folder path relative to the repo root, sandboxed. None if empty/escaping (../, absolute outside)."""
if not ort or not ort.strip(): if not location or not location.strip():
return None return None
p = (PROJECT_ROOT / ort.strip()).resolve() p = (PROJECT_ROOT / location.strip()).resolve()
try: try:
p.relative_to(PROJECT_ROOT) p.relative_to(PROJECT_ROOT)
except ValueError: 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" return topic_dir(topic) / "guides" / f"{format_name}.json"
def bausteine_topics() -> list[str]: def blocks_topics() -> list[str]:
"""Themen, für die ein Themen-Ordner existiert.""" """Topics for which a topic folder exists."""
if not THEMEN_DIR.is_dir(): if not TOPICS_DIR.is_dir():
return [] 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: 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). Holds the mutable pipeline state (generation semaphore, cancel set).
Zugriff auf das Cancel-Set NUR über die Funktionen hier — kopierte Referenzen Access the cancel set ONLY through the functions herecopied references
in anderen Modulen würden bei einem Re-Assign auseinanderlaufen. in other modules would diverge on a re-assign.
""" """
import asyncio import asyncio
@@ -15,7 +15,7 @@ from typing import Callable
from agents import run_agent, kill_process, cancel_scope, clear_scope from agents import run_agent, kill_process, cancel_scope, clear_scope
from config import MAX_CONCURRENT_GENERATIONS, TEMPLATES_DIR, TIMEOUTS from config import MAX_CONCURRENT_GENERATIONS, TEMPLATES_DIR, TIMEOUTS
from database import update_guide 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 from textkit import _STUFEN
log = logging.getLogger("creator.pipeline") log = logging.getLogger("creator.pipeline")
@@ -26,10 +26,10 @@ _cancelled: set[str] = set()
async def cancel_guide(guide_id: str) -> bool: async def cancel_guide(guide_id: str) -> bool:
_cancelled.add(guide_id) _cancelled.add(guide_id)
cancel_scope(f"{guide_id}-") # wartende Agenten bailen vorm Spawn cancel_scope(f"{guide_id}-") # waiting agents bail before spawn
kill_process(guide_id) # laufende Subprozesse killen kill_process(guide_id) # kill running subprocesses
now = datetime.now(timezone.utc).isoformat() 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 return True
@@ -39,7 +39,7 @@ def is_guide_cancelled(guide_id: str) -> bool:
def clear_guide_cancelled(guide_id: str) -> None: def clear_guide_cancelled(guide_id: str) -> None:
_cancelled.discard(guide_id) _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: 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: 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: 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]}" return f"{label}: {stderr[:1000]}"
tail = (stdout or "").strip()[-500:] tail = (stdout or "").strip()[-500:]
if tail: if tail:
return f"{label} (exit {returncode}, stderr leer): …{tail}" return f"{label} (exit {returncode}, stderr empty): …{tail}"
return f"{label} (exit {returncode}, ohne Ausgabe)" return f"{label} (exit {returncode}, no output)"
def _gather_error(label: str, results: list) -> str: def _gather_error(label: str, results: list) -> str:
@@ -87,7 +87,7 @@ def _gather_error(label: str, results: list) -> str:
returncode, stdout, stderr = r returncode, stdout, stderr = r
if returncode != 0: if returncode != 0:
return _claude_error(label, returncode, stdout, stderr) 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: def _timeout(step: str, n: int = 0) -> int:
@@ -95,21 +95,21 @@ def _timeout(step: str, n: int = 0) -> int:
return base + per * n return base + per * n
def _probleme_schema(data): def _problems_schema(data):
"""{"ok": true} → [] · {"probleme": [str]} → Liste · sonst None.""" """{"ok": true} → [] · {"problems": [str]} → list · else None."""
if not isinstance(data, dict): if not isinstance(data, dict):
return None return None
if data.get("ok") is True: if data.get("ok") is True:
return [] return []
p = data.get("probleme") p = data.get("problems")
if not isinstance(p, list) or not p: if not isinstance(p, list) or not p:
return None return None
out = [str(x).strip() for x in p if str(x).strip()] out = [str(x).strip() for x in p if str(x).strip()]
return out or None return out or None
def _str_liste(val) -> list[str] | None: def _str_list(val) -> list[str] | None:
"""Liste nicht-leerer Strings → gestrippte Liste (leer erlaubt) · sonst 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): if not isinstance(val, list) or not all(isinstance(x, str) for x in val):
return None return None
out = [x.strip() for x in val] 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): 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): if not isinstance(data, dict):
return None return None
aufnehmen = _str_liste(data.get("aufnehmen")) include = _str_list(data.get("keep"))
rest = _str_liste(data.get("rest")) rest = _str_list(data.get("rest"))
if aufnehmen is None or rest is None or (final and rest): if include is None or rest is None or (final and rest):
return None return None
return aufnehmen, rest return include, rest
def _stufen_schema(data, ids: set[int] | None = None): _RELEVANCE = ("relevant", "peripheral")
"""{"stufen": {"1": "anfaenger", …}} → {id: stufe} · sonst None. _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`. def _enum_map_schema(key: str, allowed):
""" """Factory for `{"<key>": {"1": value, …}}` → `{id: value}` parsers; value ∈ `allowed`
if not isinstance(data, dict) or not isinstance(data.get("stufen"), dict) or not data["stufen"]: (casefolded). If `ids` are given, at least these must be covered (extras allowed). None
return None on any invalid id/value or wrong shape. The caller filters the result to `ids`."""
out: dict[int, str] = {} def parse(data, ids: set[int] | None = None):
for k, v in data["stufen"].items(): if not isinstance(data, dict) or not isinstance(data.get(key), dict) or not data[key]:
try:
num = int(k)
except (ValueError, TypeError):
return None return None
stufe = str(v).strip().casefold() out: dict[int, str] = {}
if stufe not in _STUFEN: 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 return None
out[num] = stufe return out
if ids is not None and not ids <= set(out): return parse
return None
return out
_RELEVANZ = ("relevant", "rand") _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
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
_MAX_RESTARTS = 2 _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: 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)` Slot spec: {key, prompt, role, capabilities, payload}. `payload(result)`
prüft die Gültigkeit und liefert das Slot-Ergebnis oder None. checks validity and returns the slot result or None.
Fehler/Timeout/ungültigSlot-Neustart (max. _MAX_RESTARTS). Sobald das Error/timeout/invalidslot restart (max. _MAX_RESTARTS). As soon as the
Quorum steht, werden die übrigen Agenten gekillt. None = Quorum verfehlt. quorum stands, the remaining agents are killed. None = quorum missed.
`cancelled()` → True bricht ab (keine Restarts, Rückgabe None). `cancelled()` → True aborts (no restarts, returns None).
Mit `grace` wird `quorum` zum Minimum: Das erste gültige Ergebnis startet With `grace`, `quorum` becomes the minimum: the first valid result starts
einen Timer von `grace` Sekunden. Nach dessen Ablauf werden laufende a timer of `grace` seconds. After it expires, running agents are only
Agenten nur gekillt, wenn das Minimum steht — sonst läuft das Race samt killed if the minimum stands — otherwise the race, including restarts,
Restarts weiter, bis es steht. Rückgabe: `quorum` bis `len(slots)` Ergebnisse. keeps running until it stands. Returns: `quorum` to `len(slots)` results.
""" """
attempts = {i: 0 for i in range(len(slots))} attempts = {i: 0 for i in range(len(slots))}
tasks: dict[asyncio.Task, int] = {} tasks: dict[asyncio.Task, int] = {}
@@ -247,7 +204,7 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
return None return None
if deadline is not None and len(results) >= quorum and loop.time() >= deadline: if deadline is not None and len(results) >= quorum and loop.time() >= deadline:
return results 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 wait_timeout = None
if deadline is not None and len(results) >= quorum: if deadline is not None and len(results) >= quorum:
wait_timeout = max(0.0, deadline - loop.time()) 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: try:
result = task.result() result = task.result()
if result[0] != 0: if result[0] != 0:
err = _claude_error("Fehler", *result) err = _claude_error("Error", *result)
else: else:
payload = slots[i]["payload"](result) payload = slots[i]["payload"](result)
if payload is None: if payload is None:
err = "Ergebnis ungültig/nicht parsebar" err = "result invalid/not parseable"
except asyncio.TimeoutError: except asyncio.TimeoutError:
err = f"Timeout nach {timeout}s" err = f"Timeout after {timeout}s"
except Exception as e: except Exception as e:
err = f"{type(e).__name__}: {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) results.append(payload)
if grace is not None and deadline is None: if grace is not None and deadline is None:
deadline = loop.time() + grace 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: if on_update:
on_update(len(results)) on_update(len(results))
if len(results) >= quorum and (grace is None or loop.time() >= deadline): if len(results) >= quorum and (grace is None or loop.time() >= deadline):
return results return results
continue 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 attempts[i] += 1
# Steht das Minimum schon, sind Restarts sinnlos — der Neustart # If the minimum already stands, restarts are pointless — the restart
# würde am Grace-Ende ohnehin gekillt. # would be killed at the grace end anyway.
satt = grace is not None and len(results) >= quorum enough = grace is not None and len(results) >= quorum
if attempts[i] <= _MAX_RESTARTS and not satt and not (cancelled and cancelled()): if attempts[i] <= _MAX_RESTARTS and not enough and not (cancelled and cancelled()):
spawn(i) 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 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 return None
finally: finally:
for task, i in tasks.items(): for task, i in tasks.items():
@@ -302,14 +259,14 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
@dataclass @dataclass
class GenContext: class GenContext:
"""Durchgereichte Pipeline-Parameter — erspart lange Argument-Signaturen.""" """Pipeline parameters passed through — saves long argument signatures."""
topic: str topic: str
provider: str provider: str
is_cancelled: Callable[[], bool] is_cancelled: Callable[[], bool]
guide_id: str | None = None guide_id: str | None = None
# Ergebnis-Status von run_single_slot # Result status of run_single_slot
OK, CANCELLED, FAILED = "ok", "cancelled", "failed" OK, CANCELLED, FAILED = "ok", "cancelled", "failed"
@@ -317,9 +274,9 @@ async def run_single_slot(
ctx: GenContext, label: str, *, ctx: GenContext, label: str, *,
key: str, prompt: str, role: str, capabilities: str, payload, timeout: int, key: str, prompt: str, role: str, capabilities: str, payload, timeout: int,
) -> tuple[str, object]: ) -> 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}] 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) 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] return OK, res[0]
async def _gather_fortschritt(coros, total, melde, start=0): async def _gather_progress(coros, total, report, start=0):
"""Läuft `coros` nebenläufig und meldet Live-Fortschritt: `await melde(fertig, total)` """Runs `coros` concurrently and reports live progress: `await report(done, total)`
nach jedem Abschluss (und einmal initial). Ergebnisse in Reihenfolge, return_exceptions=True.""" after each completion (and once initially). Results in order, return_exceptions=True."""
done = start done = start
async def wrap(c): async def wrap(c):
@@ -341,9 +298,7 @@ async def _gather_fortschritt(coros, total, melde, start=0):
return await c return await c
finally: finally:
done += 1 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) 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, create_topic, list_topics as db_list_topics, delete_topic,
list_progress, set_progress, delete_progress, list_progress, set_progress, delete_progress,
create_element, list_elements, get_element, update_element, delete_element, create_element, list_elements, get_element, update_element, delete_element,
list_baustein_progress, get_baustein_progress, set_offene_frage, list_block_progress, get_block_progress, set_open_question,
set_baustein_score_and_streak, set_baustein_absolviert, set_block_score_and_streak, set_block_completed,
delete_baustein_daten, delete_baustein_progress, subs_je_ebene, subs_je_ebene_roh, delete_block_data, delete_block_progress, subs_per_level, subs_per_level_raw,
delete_topic_pipeline, delete_quelle, get_guide_content, delete_guide_content, delete_topic_pipeline, delete_source, get_guide_content, delete_guide_content,
get_sub_artefakte, 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 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 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_dateien, guide_fertig_step, block_pruefen, block_uebernehmen, content_fuer_ebene from guide import generate_guide, guide_slot_files, guide_done_step, block_pruefen, block_adopt, content_fuer_level
from pipeline import cancel_guide 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 ( from models import (
GuideCreateRequest, GuideResponse, GuideCreateRequest, GuideResponse,
TopicCreateRequest, TopicCreateRequest,
BausteineCreateRequest, BausteineResetStepRequest, BausteineStatusResponse, BlocksCreateRequest, BlocksResetStepRequest, BlocksStatusResponse,
GuideChatRequest, GuideChatResponse, GuideChatRequest, GuideChatResponse,
ElementCreateRequest, ElementChatRequest, ElementChatResponse, ElementResponse, ElementCreateRequest, ElementChatRequest, ElementChatResponse, ElementResponse,
ElementUpdateRequest, ElementCheckRequest, ElementCheckResponse, ElementStyleResponse, ElementUpdateRequest, ElementCheckRequest, ElementCheckResponse, ElementStyleResponse,
ElementRefineRequest, ElementRefineResponse, ElementRefineRequest, ElementRefineResponse,
ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo, ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo,
FolderResponse, BausteineQuelleUpdate, BausteineQuelleResponse, BausteinUebersicht, FolderResponse, BlocksSourceUpdate, BlocksSourceResponse, BlockOverview,
BausteinChatRequest, BausteinChatResponse, BlockChatRequest, BlockChatResponse,
BausteinPruefungRequest, BausteinPruefungResponse, BausteinLernstandResponse, BlockExamRequest, BlockExamResponse, BlockLearnStateResponse,
BlockPruefenRequest, BlockPruefenResponse, BlockUebernehmenRequest, BlockUebernehmenResponse, 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 from fsutil import atomic_write_json
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
@@ -56,28 +56,28 @@ async def get_topics():
db_topics = await db_list_topics() db_topics = await db_list_topics()
guides = await list_guides() guides = await list_guides()
derived = {g["topic"] for g in guides} derived = {g["topic"] for g in guides}
derived.update(bausteine_topics()) derived.update(blocks_topics())
derived.update(job["topic"] for job in active_bausteine()) derived.update(job["topic"] for job in active_blocks())
# DB ist führend (Reihenfolge: neueste zuerst); Abgeleitetes ohne DB-Eintrag hinten anhängen # DB is authoritative (order: newest first); append derived entries without a DB row at the end
return db_topics + sorted(derived - set(db_topics)) return db_topics + sorted(derived - set(db_topics))
@router.get("/stats") @router.get("/stats")
async def get_stats(): async def get_stats():
"""Tracker: Themen-Anzahl + pro Format erstellt/absolviert.""" """Tracker: number of topics + per format created/completed."""
guides, progress, levels = await lade_lernstand() guides, progress, levels = await load_learnstate()
themen = set(await db_list_topics()) | {g["topic"] for g in guides} | set(bausteine_topics()) topics = set(await db_list_topics()) | {g["topic"] for g in guides} | set(blocks_topics())
if PROJECTS_DIR.is_dir(): if PROJECTS_DIR.is_dir():
themen |= {e.name for e in PROJECTS_DIR.iterdir() if e.is_dir()} topics |= {e.name for e in PROJECTS_DIR.iterdir() if e.is_dir()}
return {"themen": len(themen), "formate": formate_stats(guides, progress, levels)} return {"topics": len(topics), "formats": formats_stats(guides, progress, levels)}
@router.get("/topics/fortschritt") @router.get("/topics/progress")
async def topic_fortschritt(topic: str): async def topic_progress(topic: str):
"""Absolviert-Status pro Format + Themen-Abschluss — fürs Freischalten der nächsten Ausbaustufe.""" """Completion status per format + topic completion — for unlocking the next expansion stage."""
guides, progress, levels = await lade_lernstand() guides, progress, levels = await load_learnstate()
status = {fmt: ist_absolviert(topic, fmt, guides, progress, levels) for fmt in FORMATE} status = {fmt: ist_completed(topic, fmt, guides, progress, levels) for fmt in FORMATE}
status["abgeschlossen"] = thema_abgeschlossen(topic, guides, progress, levels) status["completed"] = topic_completed(topic, guides, progress, levels)
return status return status
@@ -90,9 +90,9 @@ async def add_topic(req: TopicCreateRequest):
@router.delete("/topics") @router.delete("/topics")
async def remove_topic(topic: str): async def remove_topic(topic: str):
await delete_topic(topic) await delete_topic(topic)
await delete_baustein_daten(topic) await delete_block_data(topic)
await delete_topic_pipeline(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) await delete_guide_content(topic)
shutil.rmtree(topic_dir(topic), ignore_errors=True) shutil.rmtree(topic_dir(topic), ignore_errors=True)
return {"ok": True} return {"ok": True}
@@ -100,7 +100,7 @@ async def remove_topic(topic: str):
def _safe_project_name(name: str) -> str: def _safe_project_name(name: str) -> str:
if not name or "/" in name or "\\" in name or ".." in name or "\x00" in name: 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 return name
@@ -116,356 +116,356 @@ async def remove_project(name: str):
_safe_project_name(name) _safe_project_name(name)
pdir = project_dir(name) pdir = project_dir(name)
if not pdir.is_dir(): if not pdir.is_dir():
raise HTTPException(404, "Projekt nicht gefunden") raise HTTPException(404, "Project not found")
shutil.rmtree(pdir) shutil.rmtree(pdir)
return {"ok": True} return {"ok": True}
@router.get("/folders", response_model=list[FolderResponse]) @router.get("/folders", response_model=list[FolderResponse])
async def list_folders(kind: str): 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) base = {"projekt": (PROJECTS_DIR, "projects"), "uni": (UNI_DIR, "uni")}.get(kind)
if base is None: 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 root, prefix = base
if not root.is_dir(): if not root.is_dir():
return [] 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) @router.get("/blocks/status", response_model=BlocksStatusResponse)
async def get_bausteine_status(topic: str): async def get_blocks_status(topic: str):
return bausteine_status(topic) return await blocks_status(topic)
@router.get("/bausteine/active") @router.get("/blocks/active")
async def get_active_bausteine(): async def get_active_blocks():
return active_bausteine() return active_blocks()
@router.post("/bausteine") @router.post("/blocks")
async def create_bausteine(req: BausteineCreateRequest): async def create_blocks(req: BlocksCreateRequest):
topic = req.topic.strip() topic = req.topic.strip()
if bausteine_status(topic)["generating"]: if (await blocks_status(topic))["generating"]:
return {"ok": True, "status": "already_generating"} return {"ok": True, "status": "already_generating"}
await create_topic(topic) await create_topic(topic)
qp = quelle_path(topic) qp = source_path(topic)
# Quelle nur beim ERSTEN Mal festschreiben; ▶/Resume erhält die bestehende Wahl. # Persist the source only the FIRST time; ▶/Resume keeps the existing choice.
if not qp.exists(): if not qp.exists():
typ, ort = req.source_type, req.source_ort.strip() type, location = req.source_type, req.source_location.strip()
if typ in ("projekt", "uni"): if type in ("projekt", "uni"):
ordner = safe_ordner(ort) folder = safe_folder(location)
if ordner is None or not ordner.is_dir(): if folder is None or not folder.is_dir():
raise HTTPException(400, "Ordner ungültig oder nicht gefunden (Pfad relativ zum Projekt-Root, kein ../).") raise HTTPException(400, "Folder invalid or not found (path relative to the project root, no ../).")
elif typ == "link": elif type == "link":
if not ort.lower().startswith(("http://", "https://")): if not location.lower().startswith(("http://", "https://")):
raise HTTPException(400, "Link muss mit http:// oder https:// beginnen.") raise HTTPException(400, "Link must start with http:// or https://.")
qp.parent.mkdir(parents=True, exist_ok=True) qp.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(qp, {"type": typ, "ort": ort, "spec": req.instructions.strip()}) atomic_write_json(qp, {"type": type, "location": location, "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)) asyncio.create_task(generate_blocks(topic, req.instructions.strip(), req.provider, ab_phase=req.ab_phase, ab_step=req.ab_step))
return {"ok": True} return {"ok": True}
@router.post("/bausteine/cancel") @router.post("/blocks/cancel")
async def cancel_bausteine_route(topic: str): async def cancel_blocks_route(topic: str):
if not cancel_bausteine(topic): if not cancel_blocks(topic):
raise HTTPException(404, "Keine laufende Generierung") raise HTTPException(404, "No running generation")
return {"ok": True} return {"ok": True}
@router.delete("/bausteine") @router.delete("/blocks")
async def remove_bausteine(topic: str): async def remove_blocks(topic: str):
reset_bausteine(topic) # Dateien: Crawl + Sichtung + Inventar…Fragen weg; quelle.json bleibt reset_blocks(topic) # Files: crawl + triage + inventory…questions gone; source.json stays
await delete_topic_pipeline(topic) # DB: Bausteine-Bereich weg; Themen-Config (quelle) bleibt await delete_topic_pipeline(topic) # DB: blocks area gone; topic config (source) stays
return {"ok": True} return {"ok": True}
@router.post("/bausteine/reset-step") @router.post("/blocks/reset-step")
async def reset_bausteine_step(req: BausteineResetStepRequest): async def reset_blocks_step(req: BlocksResetStepRequest):
topic = req.topic.strip() topic = req.topic.strip()
if bausteine_status(topic)["generating"]: if (await blocks_status(topic))["generating"]:
return {"ok": True, "status": "generating"} # nicht in laufende Generierung eingreifen return {"ok": True, "status": "generating"} # don't interfere with a running generation
await reset_bausteine_ab_step(topic, req.ab_step) await reset_blocks_ab_step(topic, req.ab_step)
return {"ok": True} return {"ok": True}
@router.delete("/bausteine/fortschritt") @router.delete("/blocks/progress")
async def reset_baustein_fortschritt(topic: str, baustein: str): async def reset_block_progress(topic: str, block: str):
"""Lern-Fortschritt EINES Bausteins auf null (Score/Streak/Flags/offene Frage).""" """Reset learning progress of ONE block to zero (score/streak/flags/open question)."""
await delete_baustein_progress(topic, baustein) await delete_block_progress(topic, block)
return {"ok": True} return {"ok": True}
def _validate_quelle(typ: str, ort: str) -> None: def _validate_source(type: str, location: str) -> None:
"""Quellen-Eingabe prüfen (gleiche Regeln wie beim Erstellen).""" """Check source input (same rules as on creation)."""
if typ in ("projekt", "uni"): if type in ("projekt", "uni"):
ordner = safe_ordner(ort) folder = safe_folder(location)
if ordner is None or not ordner.is_dir(): if folder is None or not folder.is_dir():
raise HTTPException(400, "Ordner ungültig oder nicht gefunden (Pfad relativ zum Projekt-Root, kein ../).") raise HTTPException(400, "Folder invalid or not found (path relative to the project root, no ../).")
elif typ == "link": elif type == "link":
if not ort.lower().startswith(("http://", "https://")): if not location.lower().startswith(("http://", "https://")):
raise HTTPException(400, "Link muss mit http:// oder https:// beginnen.") raise HTTPException(400, "Link must start with http:// or https://.")
@router.get("/bausteine/quelle", response_model=BausteineQuelleResponse) @router.get("/blocks/source", response_model=BlocksSourceResponse)
async def get_bausteine_quelle(topic: str): async def get_blocks_source(topic: str):
return lade_quelle(topic) return load_source(topic)
@router.put("/bausteine/quelle", response_model=BausteineQuelleResponse) @router.put("/blocks/source", response_model=BlocksSourceResponse)
async def update_bausteine_quelle(req: BausteineQuelleUpdate): async def update_blocks_source(req: BlocksSourceUpdate):
"""Nur speichern — KEINE Neugenerierung. Quellen-/Spec-Wahl überschreiben.""" """Only save — NO regeneration. Overwrite the source/spec choice."""
topic, typ, ort = req.topic.strip(), req.type, req.ort.strip() topic, type, location = req.topic.strip(), req.type, req.location.strip()
_validate_quelle(typ, ort) _validate_source(type, location)
qp = quelle_path(topic) qp = source_path(topic)
qp.parent.mkdir(parents=True, exist_ok=True) qp.parent.mkdir(parents=True, exist_ok=True)
daten = {"type": typ, "ort": ort, "spec": req.spec.strip()} data = {"type": type, "location": location, "spec": req.spec.strip()}
atomic_write_json(qp, daten) atomic_write_json(qp, data)
return daten return data
@router.get("/bausteine/uebersicht", response_model=list[BausteinUebersicht]) @router.get("/blocks/overview", response_model=list[BlockOverview])
async def get_bausteine_uebersicht(topic: str): async def get_blocks_uebersicht(topic: str):
return await lade_uebersicht(topic) return await load_overview(topic)
@router.get("/bausteine/frage-muster") @router.get("/blocks/question-pattern")
async def get_frage_muster(topic: str, baustein: str): async def get_question_pattern(topic: str, block: str):
"""Freigeschaltete Frage-Muster eines Bausteins (bis zur aktuellen Ebene; leer = Live).""" """Unlocked question patterns of a block (up to the current level; empty = live)."""
stand = await get_baustein_progress(topic, baustein) state = await get_block_progress(topic, block)
fe = freie_ebene(stand["gute_antworten"], await subs_je_ebene(topic, baustein)) fe = freie_level(state["good_answers"], await subs_per_level(topic, block))
return {"muster": await lade_frage_muster_frei(topic, baustein, fe)} return {"pattern": await load_question_pattern_free(topic, block, fe)}
@router.get("/bausteine/artefakte") @router.get("/blocks/artefakte")
async def get_artefakte(topic: str, typ: str | None = None): async def get_artefakte(topic: str, type: str | None = None):
"""Lern-Artefakte (Karteikarten/Beispiele) je Thema, gruppiert nach Baustein-Norm — je Subbaustein.""" """Learning artifacts (flashcards/examples) per topic, grouped by block norm — per subblock."""
rows = await get_sub_artefakte(topic, typ) rows = await get_sub_artefakte(topic, type)
out: dict[str, dict] = {} out: dict[str, dict] = {}
for r in rows: for r in rows:
b = out.setdefault(r["baustein_norm"], {"baustein": r["baustein"], "karteikarte": [], "beispiel": []}) b = out.setdefault(r["block_norm"], {"block": r["block"], "flashcard": [], "example": []})
if r["baustein"] and not b["baustein"]: if r["block"] and not b["block"]:
b["baustein"] = r["baustein"] b["block"] = r["block"]
try: try:
daten = json.loads(r["daten"]) data = json.loads(r["data"])
except (ValueError, TypeError): except (ValueError, TypeError):
continue continue
if r["typ"] in ("karteikarte", "beispiel"): if r["type"] in ("flashcard", "example"):
b[r["typ"]].append({"subbaustein": r["sub_titel"], **daten}) b[r["type"]].append({"subblock": r["sub_title"], **data})
return {"artefakte": out} return {"artefakte": out}
# --- Baustein-Lernen: Chat, Prüfung --- # --- Block learning: chat, exam ---
@router.get("/bausteine/lernstand", response_model=BausteinLernstandResponse) @router.get("/blocks/learnstate", response_model=BlockLearnStateResponse)
async def baustein_lernstand(topic: str): async def block_learnstate(topic: str):
"""Prüfungs-Stand pro Baustein (roher Titel als Key). cap_final = alle Subs × 25; """Exam state per block (raw title as key). cap_final = all subs × 25;
cap_aktuell + freie_ebene aus dem Score — für ALLE Bausteine (auch ungeprüfte).""" cap_aktuell + freie_level from the score — for ALL blocks (even unexamined)."""
progress = {p["baustein"]: p for p in await list_baustein_progress(topic)} progress = {p["block"]: p for p in await list_block_progress(topic)}
ebenen = await subs_je_ebene_roh(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 { return {
"gute_antworten": score, "streak": streak, "good_answers": score, "streak": streak,
"cap": cap_final(n_je_ebene), "cap": cap_final(n_je_level),
"cap_aktuell": cap_aktuell(score, n_je_ebene), "cap_aktuell": cap_aktuell(score, n_je_level),
"freie_ebene": freie_ebene(score, n_je_ebene), "freie_level": freie_level(score, n_je_level),
} }
bausteine = { blocks = {
b: _stand(progress[b]["gute_antworten"] if b in progress else 0, b: _state(progress[b]["good_answers"] if b in progress else 0,
progress[b]["streak"] if b in progress else 0, n) 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(): for b, p in progress.items():
if b not in bausteine: if b not in blocks:
bausteine[b] = _stand(p["gute_antworten"], p["streak"], {}) blocks[b] = _state(p["good_answers"], p["streak"], {})
return {"bausteine": bausteine} return {"blocks": blocks}
@router.post("/bausteine/chat", response_model=BausteinChatResponse) @router.post("/blocks/chat", response_model=BlockChatResponse)
async def baustein_chat_route(req: BausteinChatRequest): async def block_chat_route(req: BlockChatRequest):
reply = await baustein_chat( reply = await block_chat(
req.topic, req.baustein, req.section, req.section_kompakt, req.topic, req.block, req.section, req.section_compact,
[m.model_dump() for m in req.messages], provider=req.provider, [m.model_dump() for m in req.messages], provider=req.provider,
) )
return {"reply": reply} return {"reply": reply}
# Bewertungen je (topic, baustein) serialisieren — sonst überschreiben zwei # Serialize ratings per (topic, block) — otherwise two simultaneous ratings would
# gleichzeitige Bewertungen den absoluten Score mit veralteter Basis (Race). # overwrite the absolute score with a stale base (race).
_pruef_locks: dict[tuple[str, str], asyncio.Lock] = {} _check_locks: dict[tuple[str, str], asyncio.Lock] = {}
def _pruef_lock(topic: str, baustein: str) -> asyncio.Lock: def _check_lock(topic: str, block: str) -> asyncio.Lock:
key = (topic, baustein) key = (topic, block)
lock = _pruef_locks.get(key) lock = _check_locks.get(key)
if lock is None: if lock is None:
lock = _pruef_locks[key] = asyncio.Lock() lock = _check_locks[key] = asyncio.Lock()
return lock return lock
def _basis(stand: dict, frage: str) -> tuple[int, bool]: def _basis(state: dict, question: str) -> tuple[int, bool]:
"""Score-Basis VOR der Frage. Gleiche offene FrageRe-Bewertung auf derselben Basis """Score base BEFORE the question. Same open questionre-rating on the same base
(idempotent); sonst neue Frage auf dem aktuellen Stand. → (basis, re_bewertung).""" (idempotent); otherwise a new question on the current state. → (basis, re_rating)."""
re_bewertung = stand["offene_frage"] == frage and stand["offene_basis"] is not None re_rating = state["offene_question"] == question and state["offene_basis"] is not None
return (stand["offene_basis"] if re_bewertung else stand["gute_antworten"]), re_bewertung return (state["offene_basis"] if re_rating else state["good_answers"]), re_rating
def _farbe(punkte: int) -> str: def _color(points: int) -> str:
"""Punkte-Delta → grobe Einfärbung der Bubble.""" """Points delta → rough bubble coloring."""
return "gut" if punkte > 0 else ("neutral" if punkte == 0 else "schlecht") 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: async def _book_score(req, question: str, tier: str, n_je_level: dict[int, int]) -> dict:
"""Score+Streak driftfrei buchen (Lock + offene_frage/offene_streak-Anker). Niveau """Book score+streak drift-free (lock + open-question/open-streak anchor). Tier
Punkt-Delta (streak-moduliert) bzw. progressiver Malus bei Fehler. cap_aktuell wird aus points delta (streak-modulated) or progressive malus on error. cap_aktuell is derived
der Basis abgeleitet (verzögerte Freischaltung an der Ebenen-Schwelle); Element einmalig from the base (delayed unlock at the level threshold); element once from beginner level.
ab Anfänger-Stufe. Re-Bewertung derselben Frage nutzt den offenen Streak-Anker → idempotent.""" Re-rating of the same question uses the open streak anchor → idempotent."""
async with _pruef_lock(req.topic, req.baustein): async with _check_lock(req.topic, req.block):
stand = await get_baustein_progress(req.topic, req.baustein) state = await get_block_progress(req.topic, req.block)
war_stufe = stand["absolviert"] is not None # Element-Guard: schon je angelegt? was_level = state["completed"] is not None # element guard: ever created already?
basis, re_bewertung = _basis(stand, frage) basis, re_rating = _basis(state, question)
streak_basis = stand["offene_streak"] if re_bewertung else stand["streak"] streak_basis = state["offene_streak"] if re_rating else state["streak"]
if not re_bewertung: if not re_rating:
await set_offene_frage(req.topic, req.baustein, frage, basis, stand["streak"]) await set_open_question(req.topic, req.block, question, basis, state["streak"])
s = schwellen(n_je_ebene) s = thresholds(n_je_level)
cf = s[-1] cf = s[-1]
ca = cap_aktuell(basis, n_je_ebene) ca = cap_aktuell(basis, n_je_level)
floor = floor_aus_score(basis, cf, s) floor = floor_from_score(basis, cf, s)
d, neue_streak = punkte_delta(niveau, streak_basis, basis, ca) d, new_streak = points_delta(tier, streak_basis, basis, ca)
score = score_berechnen(basis, d, floor, ca, cf) score = compute_score(basis, d, floor, ca, cf)
punkte = score - basis points = score - basis
gute, streak = await set_baustein_score_and_streak(req.topic, req.baustein, score, neue_streak) good, streak = await set_block_score_and_streak(req.topic, req.block, score, new_streak)
# Lern-Element einmalig anlegen, sobald die erste Stufe (Anfänger) erreicht ist. # Create the learning element once, as soon as the first level (beginner) is reached.
if not war_stufe and stufe_aus_score(score, cf) is not None: if not was_level and level_from_score(score, cf) is not None:
if await set_baustein_absolviert(req.topic, req.baustein): if await set_block_completed(req.topic, req.block):
asyncio.create_task(baustein_element_anlegen(req.topic, req.baustein, req.section, req.provider)) asyncio.create_task(create_block_element(req.topic, req.block, req.section, req.provider))
return {"punkte": punkte, "bewertung": _farbe(punkte), "gute_antworten": gute, "streak": streak, "cap": cf} return {"points": points, "rating": _color(points), "good_answers": good, "streak": streak, "cap": cf}
@router.post("/bausteine/pruefung", response_model=BausteinPruefungResponse) @router.post("/blocks/exam", response_model=BlockExamResponse)
async def baustein_pruefung_route(req: BausteinPruefungRequest): async def block_exam_route(req: BlockExamRequest):
stand = await get_baustein_progress(req.topic, req.baustein) state = await get_block_progress(req.topic, req.block)
gute = stand["gute_antworten"] good = state["good_answers"]
n_je_ebene = await subs_je_ebene(req.topic, req.baustein) n_je_level = await subs_per_level(req.topic, req.block)
cap = cap_final(n_je_ebene) cap = cap_final(n_je_level)
niveau = stufe_aus_score(gute, cap) or "anfaenger" # Adressaten-Rolle der Frage tier = level_from_score(good, cap) or "beginner" # addressee role of the question
fe = freie_ebene(gute, n_je_ebene) # nur freigeschaltete Subs prüfen fe = freie_level(good, n_je_level) # only check unlocked subs
kompakt = req.section_kompakt compact = req.section_compact
msgs = [m.model_dump() for m in req.messages] msgs = [m.model_dump() for m in req.messages]
if req.aktion == "frage": if req.action == "question":
if req.muster.strip(): if req.pattern.strip():
# Aus gezogenem Muster eine konkrete Frage im Niveau formulieren (kein Dedup nötig). # From a drawn pattern, phrase a concrete question at the tier (no dedup needed).
frage = await pruefung_frage_variante(req.topic, req.baustein, req.section, kompakt, req.muster, niveau=niveau, provider=req.provider) question = await exam_question_variant(req.topic, req.block, req.section, compact, req.pattern, tier=tier, provider=req.provider)
else: else:
# Fallback (kein Muster-Sidecar): Live-Generierung, Fokus nur auf freigeschaltete Subs. # Fallback (no pattern sidecar): live generation, focus only on unlocked subs.
subs = await subbausteine_frei(req.topic, req.baustein, fe) subs = await subblocks_frei(req.topic, req.block, fe)
frage = await pruefung_frage(req.topic, req.baustein, req.section, kompakt, msgs, subbausteine=subs, vermeide=req.vermeide, niveau=niveau, provider=req.provider) question = await exam_question(req.topic, req.block, req.section, compact, msgs, subblocks=subs, avoid=req.avoid, tier=tier, provider=req.provider)
if frage is None: if question is None:
raise HTTPException(502, "Frage fehlgeschlagen — bitte erneut versuchen") raise HTTPException(502, "Question failed — please try again")
return {"frage": frage, "gute_antworten": gute, "cap": cap} return {"question": question, "good_answers": good, "cap": cap}
if req.aktion == "diskussion": if req.action == "discussion":
if not req.frage.strip(): if not req.question.strip():
raise HTTPException(400, "Diskussion braucht eine laufende Frage") raise HTTPException(400, "Discussion needs an active question")
reply = await baustein_diskussion( reply = await block_discussion(
req.topic, req.baustein, req.section, kompakt, req.topic, req.block, req.section, compact,
req.frage, req.letzte_bewertung or None, msgs, provider=req.provider, req.question, req.last_rating or None, msgs, provider=req.provider,
) )
if reply is None: if reply is None:
raise HTTPException(502, "Diskussion fehlgeschlagen — bitte erneut versuchen") raise HTTPException(502, "Discussion failed — please try again")
return {"reply": reply, "gute_antworten": gute, "cap": cap} return {"reply": reply, "good_answers": good, "cap": cap}
# --- Quiz: leicht (1 von 4) +1/1 · schwer (x von 4) +3/1 — deterministisch --- # --- Quiz: easy (1 of 4) +1/1 · hard (x of 4) +3/1 — deterministic ---
if req.aktion == "quiz_frage": if req.action == "quiz_question":
if not req.muster.strip(): if not req.pattern.strip():
raise HTTPException(400, "Quiz braucht ein Muster") raise HTTPException(400, "Quiz needs a pattern")
distraktoren = await huerden_distraktor_block(req.topic, req.baustein) distractors = await hurdles_distractor_block(req.topic, req.block)
quiz = await quiz_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, niveau=niveau, provider=req.provider, distraktor_block=distraktoren) 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: if quiz is None:
raise HTTPException(502, "Quiz-Frage fehlgeschlagen — bitte erneut versuchen") raise HTTPException(502, "Quiz question failed — please try again")
return {"frage": quiz["frage"], "optionen": quiz["optionen"], return {"question": quiz["question"], "options": quiz["options"],
"gute_antworten": gute, "cap": cap} "good_answers": good, "cap": cap}
if req.aktion == "quiz_antwort": if req.action == "quiz_answer":
if not req.frage.strip(): if not req.question.strip():
raise HTTPException(400, "Quiz-Antwort braucht eine Frage") raise HTTPException(400, "Quiz answer needs a question")
getroffen = set(req.auswahl) == set(req.korrekt) # exakt die richtige Menge hit = set(req.selection) == set(req.correct) # exactly the correct set
res = await _buche(req, req.frage, "stark" if getroffen else "kaum", n_je_ebene) res = await _book_score(req, req.question, "strong" if hit else "barely", n_je_level)
res["feedback"] = "Richtig — alle korrekten getroffen." if getroffen else "Nicht ganzdie markierten waren richtig." res["feedback"] = "Correct — all correct ones hit." if hit else "Not quitethe marked ones were correct."
return res return res
# --- Lückentext: leicht (Begriff aus 4) +1/1 · schwer (frei tippen) +3/1 --- # --- Gap text: easy (term from 4) +1/1 · hard (free typing) +3/1 ---
if req.aktion == "lueck_frage": if req.action == "gap_question":
if not req.muster.strip(): if not req.pattern.strip():
raise HTTPException(400, "Lückentext braucht ein Muster") raise HTTPException(400, "Gap text needs a pattern")
if req.schwer: 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: if lt is None:
raise HTTPException(502, "Lückentext fehlgeschlagen — bitte erneut versuchen") raise HTTPException(502, "Gap text failed — please try again")
return {"satz": lt["satz"], "loesung": lt["loesung"], "alternativen": lt["alternativen"], return {"sentence": lt["sentence"], "solution": lt["solution"], "alternatives": lt["alternatives"],
"gute_antworten": gute, "cap": cap} "good_answers": good, "cap": cap}
distraktoren = await huerden_distraktor_block(req.topic, req.baustein) distractors = await hurdles_distractor_block(req.topic, req.block)
lw = await lueckwahl_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, niveau=niveau, provider=req.provider, distraktor_block=distraktoren) 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: if lw is None:
raise HTTPException(502, "Lückentext fehlgeschlagen — bitte erneut versuchen") raise HTTPException(502, "Gap text failed — please try again")
return {"satz": lw["satz"], "optionen": lw["optionen"], return {"sentence": lw["sentence"], "options": lw["options"],
"gute_antworten": gute, "cap": cap} "good_answers": good, "cap": cap}
if req.aktion == "lueck_antwort": if req.action == "gap_answer":
if not req.frage.strip(): if not req.question.strip():
raise HTTPException(400, "Lückentext-Antwort braucht einen Satz") raise HTTPException(400, "Gap-text answer needs a sentence")
if req.schwer: # frei getipptSynonym-tolerante KI-Prüfung if req.schwer: # free typedsynonym-tolerant AI check
ok = await lueckentext_pruefen(req.topic, req.baustein, req.frage, req.loesung, req.alternativen, req.eingabe, provider=req.provider) ok = await check_gaptext(req.topic, req.block, req.question, req.solution, req.alternatives, req.input, provider=req.provider)
feedback = "Richtig!" if ok else f"Nicht ganz — erwartet war{req.loesung}“." feedback = "Correct!" if ok else f"Not quite — expected{req.solution}“."
else: # Begriff aus 4 gewählt → deterministisch else: # term chosen from 4 → deterministic
ok = set(req.auswahl) == set(req.korrekt) ok = set(req.selection) == set(req.correct)
feedback = "Richtig!" if ok else "Nicht ganz — der markierte Begriff war richtig." feedback = "Correct!" if ok else "Not quite — the marked term was correct."
res = await _buche(req, req.frage, "stark" if ok else "kaum", n_je_ebene) res = await _book_score(req, req.question, "strong" if ok else "barely", n_je_level)
res["feedback"] = feedback res["feedback"] = feedback
return res 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): if not any(m.get("role") == "user" for m in msgs):
raise HTTPException(400, "Antwort braucht eine Nutzer-Antwort") raise HTTPException(400, "Answer needs a user answer")
if not req.frage.strip(): if not req.question.strip():
raise HTTPException(400, "Antwort braucht eine laufende Frage") raise HTTPException(400, "Answer needs an active question")
if req.aktion == "antwort": if req.action == "answer":
# Agent 1: nur Vorschau — Niveau + voraussichtliche Punkte, NICHTS persistieren, kein Anker. # Agent 1: preview only — tier + expected points, persist NOTHING, no anchor.
data = await pruefung_bewertung_schnell( data = await exam_rating_fast(
req.topic, req.baustein, req.section, kompakt, req.frage, msgs, provider=req.provider, req.topic, req.block, req.section, compact, req.question, msgs, provider=req.provider,
) )
if data is None: if data is None:
raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen") raise HTTPException(502, "Rating failed — please try again")
basis, re_bew = _basis(stand, req.frage) basis, re_rating = _basis(state, req.question)
streak_basis = stand["offene_streak"] if re_bew else stand["streak"] streak_basis = state["offene_streak"] if re_rating else state["streak"]
s = schwellen(n_je_ebene) s = thresholds(n_je_level)
ca = cap_aktuell(basis, n_je_ebene) ca = cap_aktuell(basis, n_je_level)
floor = floor_aus_score(basis, s[-1], s) floor = floor_from_score(basis, s[-1], s)
niveau = deckel_nachfrage(data["niveau"], req.nachgefragt) tier = cap_followup(data["tier"], req.asked_again)
d, _ = punkte_delta(niveau, streak_basis, basis, ca) d, _ = points_delta(tier, streak_basis, basis, ca)
score = score_berechnen(basis, d, floor, ca, s[-1]) score = compute_score(basis, d, floor, ca, s[-1])
punkte = score - basis points = score - basis
return {"feedback": data["feedback"], "punkte": punkte, "bewertung": _farbe(punkte), return {"feedback": data["feedback"], "points": points, "rating": _color(points),
"gute_antworten": gute, "cap": cap} "good_answers": good, "cap": cap}
# aktion "antwort_pruefen" (Agent 2 genau): verbindlich, persistiert. NUR hier ändert sich der Score. # action "answer_check" (Agent 2 thorough): binding, persisted. ONLY here does the score change.
# LLM läuft OHNE Lock; gebucht wird kurz über _buche (Anker + Score), wie bei Quiz/Lück. # The LLM runs WITHOUT a lock; booking is done briefly via _book_score (anchor + score), as with quiz/gap.
# So blockiert die lange KI-Bewertung keine folgende (deterministische) Antwort desselben Bausteins. # This way the long AI rating doesn't block a following (deterministic) answer of the same block.
data = await pruefung_bewertung( data = await exam_rating(
req.topic, req.baustein, req.section, kompakt, req.frage, msgs, provider=req.provider, req.topic, req.block, req.section, compact, req.question, msgs, provider=req.provider,
role="guide" if req.gruendlich else "judge", begruendung=req.begruendung, role="guide" if req.thorough else "judge", reason=req.reason,
) )
if data is None: if data is None:
raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen") raise HTTPException(502, "Rating failed — please try again")
niveau = deckel_nachfrage(data["niveau"], req.nachgefragt) tier = cap_followup(data["tier"], req.asked_again)
res = await _buche(req, req.frage, niveau, n_je_ebene) # kurzer Lock: Basis driftfrei über Anker res = await _book_score(req, req.question, tier, n_je_level) # short lock: drift-free base via anchor
res["feedback"] = data["feedback"] res["feedback"] = data["feedback"]
return res return res
@@ -474,10 +474,10 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest):
@router.post("/guides", response_model=GuideResponse) @router.post("/guides", response_model=GuideResponse)
async def create(req: GuideCreateRequest): async def create(req: GuideCreateRequest):
guides, progress, levels = await lade_lernstand() guides, progress, levels = await load_learnstate()
grund = guide_lock(req.topic.strip(), req.format, guides, progress, levels) reason = guide_lock(req.topic.strip(), req.format, guides, progress, levels)
if grund: if reason:
raise HTTPException(400 if grund == "Erst Bausteine erstellen" else 409, grund) raise HTTPException(400 if reason == "Erst Blocks erstellen" else 409, reason) # string matches rules.py contract
await create_topic(req.topic.strip()) await create_topic(req.topic.strip())
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
guide = { guide = {
@@ -502,45 +502,45 @@ async def list_all():
@router.get("/guides/locks") @router.get("/guides/locks")
async def guide_locks(topic: str): async def guide_locks(topic: str):
"""Sperr-Gründe pro Format für den ▶-Button — None = erstellbar.""" """Lock reasons per format for the ▶ button — None = creatable."""
guides, progress, levels = await lade_lernstand() guides, progress, levels = await load_learnstate()
return {fmt: guide_lock(topic, fmt, guides, progress, levels) for fmt in ("FullGuide", "Rest", *FORMATE)} return {fmt: guide_lock(topic, fmt, guides, progress, levels) for fmt in ("FullGuide", "Rest", *FORMATE)}
@router.get("/guides/steps") @router.get("/guides/steps")
async def guide_steps(topic: str): async def guide_steps(topic: str):
"""Höchster voll abgeschlossener Schritt-Index je Format (artefakt-basiert, -1 = keiner). """Highest fully completed step index per format (artifact-based, -1 = none).
Treibt die klickbaren Schritt-Kugeln (wie die Bausteine-Phasen).""" Drives the clickable step bubbles (like the blocks phases)."""
return {fmt: guide_fertig_step(guide_content_path(topic, fmt)) for fmt in ("Guide", "FullGuide", "Rest")} return {fmt: guide_done_step(guide_content_path(topic, fmt)) for fmt in ("Guide", "FullGuide", "Rest")}
@router.get("/guides/{guide_id}", response_model=GuideResponse) @router.get("/guides/{guide_id}", response_model=GuideResponse)
async def get_one(guide_id: str): async def get_one(guide_id: str):
guide = await get_guide(guide_id) guide = await get_guide(guide_id)
if guide is None: if guide is None:
raise HTTPException(404, "Guide nicht gefunden") raise HTTPException(404, "Guide not found")
return guide return guide
@router.get("/guides/{guide_id}/content") @router.get("/guides/{guide_id}/content")
async def guide_content(guide_id: str, ebene: int = 4): async def guide_content(guide_id: str, level: int = 4):
"""Guide-Inhalt. `ebene` (1=A · 2=F · 3=E · 4=V) filtert auf Subbausteine bis zu dieser """Guide content. `level` (1=A · 2=F · 3=E · 4=V) filters to subblocks up to this
Ebene; 4 = Vollfassung (roh, unverändert).""" level; 4 = full version (raw, unchanged)."""
guide = await get_guide(guide_id) guide = await get_guide(guide_id)
if guide is None: if guide is None:
raise HTTPException(404, "Guide nicht gefunden") raise HTTPException(404, "Guide not found")
if guide["status"] != "done": 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 stored = await get_guide_content(guide["topic"], guide["format"]) # DB-first
if stored is None: 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(): if not path.exists():
raise HTTPException(404, "Datei nicht gefunden") raise HTTPException(404, "File not found")
stored = path.read_text(encoding="utf-8") stored = path.read_text(encoding="utf-8")
if ebene >= 4: if level >= 4:
return Response(content=stored, media_type="application/json") # Vollfassung roh return Response(content=stored, media_type="application/json") # full version, raw
try: try:
return content_fuer_ebene(json.loads(stored), ebene) return content_fuer_level(json.loads(stored), level)
except ValueError: except ValueError:
return Response(content=stored, media_type="application/json") 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): async def guide_chat(guide_id: str, req: GuideChatRequest):
guide = await get_guide(guide_id) guide = await get_guide(guide_id)
if guide is None: if guide is None:
raise HTTPException(404, "Guide nicht gefunden") raise HTTPException(404, "Guide not found")
reply = await chat_with_guide( reply = await chat_with_guide(
guide["topic"], guide["format"], req.section, req.outline, guide["topic"], guide["format"], req.section, req.outline,
[m.model_dump() for m in req.messages], [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]: async def _guide_tf(guide_id: str) -> tuple[str, str]:
guide = await get_guide(guide_id) guide = await get_guide(guide_id)
if guide is None: if guide is None:
raise HTTPException(404, "Guide nicht gefunden") raise HTTPException(404, "Guide not found")
return guide["topic"], guide["format"] return guide["topic"], guide["format"]
@router.post("/guides/{guide_id}/block/pruefen", response_model=BlockPruefenResponse) @router.post("/guides/{guide_id}/block/pruefen", response_model=BlockPruefenResponse)
async def block_pruefen_route(guide_id: str, req: BlockPruefenRequest): async def block_pruefen_route(guide_id: str, req: BlockPruefenRequest):
topic, fmt = await _guide_tf(guide_id) topic, fmt = await _guide_tf(guide_id)
neu = await block_pruefen(topic, fmt, req.baustein, req.stelle, req.block, req.hinweis, provider=req.provider) new = await block_pruefen(topic, fmt, req.block, req.spot, req.snippet, req.hint, provider=req.provider)
if neu is None: if new is None:
raise HTTPException(502, "Prüfung fehlgeschlagen — bitte erneut versuchen") raise HTTPException(502, "Check failed — please try again")
return {"neu": neu} return {"revised": new}
@router.post("/guides/{guide_id}/block/uebernehmen", response_model=BlockUebernehmenResponse) @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) 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: if res is None:
raise HTTPException(404, "Section nicht gefunden") raise HTTPException(404, "Section not found")
return res return res
# --- Elemente (persönliche Zusammenfassung) --- # --- Elements (personal summary) ---
@router.get("/elements", response_model=list[ElementResponse]) @router.get("/elements", response_model=list[ElementResponse])
async def get_elements(topic: str): 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): async def element_chat(element_id: str, req: ElementChatRequest):
element = await get_element(element_id) element = await get_element(element_id)
if element is None: 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) reply, changes = await chat_with_element(element, [m.model_dump() for m in req.messages], provider=req.provider)
return {"reply": reply, "changes": changes} 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): async def element_refine(element_id: str, req: ElementRefineRequest):
element = await get_element(element_id) element = await get_element(element_id)
if element is None: 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) change = await refine_suggestion(element, req.suggestion.model_dump(), req.instruction, provider=req.provider)
if change is None: if change is None:
raise HTTPException(502, "Überarbeitung fehlgeschlagen — bitte erneut versuchen") raise HTTPException(502, "Revision failed — please try again")
return {"change": change} return {"change": change}
@router.put("/elements/{element_id}", response_model=ElementResponse) @router.put("/elements/{element_id}", response_model=ElementResponse)
async def put_element(element_id: str, req: ElementUpdateRequest): async def put_element(element_id: str, req: ElementUpdateRequest):
if await get_element(element_id) is None: 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) fields = req.model_dump(exclude_unset=True, exclude_none=True)
if fields: if fields:
now = datetime.now(timezone.utc).isoformat() 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): async def element_style(element_id: str, req: ElementCheckRequest):
element = await get_element(element_id) element = await get_element(element_id)
if element is None: if element is None:
raise HTTPException(404, "Element nicht gefunden") raise HTTPException(404, "Element not found")
changes = await style_element(element, provider=req.provider) changes = await style_element(element, provider=req.provider)
if changes is None: 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} 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): async def element_check(element_id: str, req: ElementCheckRequest):
element = await get_element(element_id) element = await get_element(element_id)
if element is None: if element is None:
raise HTTPException(404, "Element nicht gefunden") raise HTTPException(404, "Element not found")
suggestions = await check_element(element, provider=req.provider) suggestions = await check_element(element, provider=req.provider)
if suggestions is None: if suggestions is None:
raise HTTPException(502, "Prüfung fehlgeschlagen — bitte erneut versuchen") raise HTTPException(502, "Check failed — please try again")
return {"suggestions": suggestions} return {"suggestions": suggestions}
@router.delete("/elements/{element_id}") @router.delete("/elements/{element_id}")
async def remove_element(element_id: str): async def remove_element(element_id: str):
if not await delete_element(element_id): if not await delete_element(element_id):
raise HTTPException(404, "Element nicht gefunden") raise HTTPException(404, "Element not found")
return {"ok": True} return {"ok": True}
@@ -663,7 +663,7 @@ async def remove_element(element_id: str):
async def cancel(guide_id: str): async def cancel(guide_id: str):
cancelled = await cancel_guide(guide_id) cancelled = await cancel_guide(guide_id)
if not cancelled: if not cancelled:
raise HTTPException(404, "Kein aktiver Prozess gefunden") raise HTTPException(404, "No active process found")
return {"ok": True} return {"ok": True}
@@ -671,18 +671,18 @@ async def cancel(guide_id: str):
async def remove(guide_id: str, slots: bool = False): async def remove(guide_id: str, slots: bool = False):
guide = await get_guide(guide_id) guide = await get_guide(guide_id)
if guide is None: if guide is None:
raise HTTPException(404, "Guide nicht gefunden") raise HTTPException(404, "Guide not found")
await delete_progress(guide_id) await delete_progress(guide_id)
await delete_guide(guide_id) await delete_guide(guide_id)
# Content-/Schritt-Dateien teilen sich alle Läufe eines Thema+Formatserst löschen, # Content/step files are shared by all runs of a topic+format — only delete them
# wenn kein Eintrag sie mehr braucht. Teilfortschritt (Schritt-Dateien ohne fertigen # once no entry needs them anymore. Partial progress (step files without finished
# Content) bleibt fürs Resume erhalten, außer es wird explizit verlangt (slots=1). # 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"]] rest = [g for g in await list_guides() if g["topic"] == guide["topic"] and g["format"] == guide["format"]]
if not rest: if not rest:
await delete_guide_content(guide["topic"], guide["format"]) await delete_guide_content(guide["topic"], guide["format"])
content = guide_content_path(guide["topic"], guide["format"]) content = guide_content_path(guide["topic"], guide["format"])
if slots or content.exists(): if slots or content.exists():
for p in guide_slot_dateien(content): for p in guide_slot_files(content):
p.unlink(missing_ok=True) p.unlink(missing_ok=True)
content.unlink(missing_ok=True) content.unlink(missing_ok=True)
return {"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): async def get_progress(guide_id: str):
guide = await get_guide(guide_id) guide = await get_guide(guide_id)
if guide is None: if guide is None:
raise HTTPException(404, "Guide nicht gefunden") raise HTTPException(404, "Guide not found")
return {"chapters": await list_progress(guide_id)} 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): async def update_progress(guide_id: str, req: ProgressUpdate):
guide = await get_guide(guide_id) guide = await get_guide(guide_id)
if guide is None: 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) await set_progress(guide_id, req.chapter, req.done)
return {"chapters": await list_progress(guide_id)} 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 re
import unicodedata 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: def _norm_title(s: str) -> str:
"""Normalisiert einen Titel für den Schlüssel-Vergleich. """Normalize a title for key comparison.
NFKC + casefold fangen Unicode-Varianten; Anführungszeichen, Markdown- NFKC + casefold catch Unicode variants; quotes, markdown emphasis
Emphasis und Dash-Varianten kommen aus KI-Output in allen Spielarten. and dash variants come out of AI output in every shape.
""" """
s = unicodedata.normalize("NFKC", s) s = unicodedata.normalize("NFKC", s)
s = re.sub(r"[`'\"<>„“”‚’«»*_]", "", s) s = re.sub(r"[`'\"<>„“”‚’«»*_]", "", s)
@@ -22,49 +22,49 @@ def _norm_titel(s: str) -> str:
return s.casefold() return s.casefold()
def _titel(entry: str) -> str: def _title(entry: str) -> str:
return entry.split("")[0].strip() or entry return entry.split("")[0].strip() or entry
def _eindeutige_titel(entries: dict[int, str]) -> dict[int, str]: def _unique_title(entries: dict[int, str]) -> dict[int, str]:
"""Macht Titel eindeutig (Suffix " (2)", " (3)" …), damit sie als Schlüssel taugen.""" """Make titles unique (suffix " (2)", " (3)" …) so they work as keys."""
seen: dict[str, int] = {} seen: dict[str, int] = {}
out: dict[int, str] = {} out: dict[int, str] = {}
for num, text in entries.items(): for num, text in entries.items():
titel = _titel(text) title = _title(text)
key = _norm_titel(titel) key = _norm_title(title)
seen[key] = seen.get(key, 0) + 1 seen[key] = seen.get(key, 0) + 1
if seen[key] > 1: if seen[key] > 1:
rest = text.split("", 1) rest = text.split("", 1)
text = f"{titel} ({seen[key]})" + (f"{rest[1]}" if len(rest) == 2 else "") text = f"{title} ({seen[key]})" + (f"{rest[1]}" if len(rest) == 2 else "")
# zweiter Durchlauf nicht nötig: Suffixe kollidieren praktisch nicht # a second pass isn't needed: suffixes practically never collide
out[num] = text out[num] = text
return out return out
def _titel_index(entries: dict[int, str]) -> dict[str, int]: def _title_index(entries: dict[int, str]) -> dict[str, int]:
return {_norm_titel(_titel(text)): num for num, text in entries.items()} return {_norm_title(_title(text)): num for num, text in entries.items()}
def _titel_aufloesen(idx: dict[str, int], t: str) -> int | None: def _resolve_title(idx: dict[str, int], t: str) -> int | None:
"""Titel → Nummer; toleriert mitgeschleppte Beschreibungen ("Titel — …").""" """Titlenumber; tolerates trailing descriptions ("Title — …")."""
if not isinstance(t, str): if not isinstance(t, str):
return None 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: def _norm_dash(s: str) -> str:
"""Space-umgebene Dash-Varianten (en/em/figure/bar/hyphen) → einheitlicher Trenner ''. """Space-surrounded dash variants (en/em/figure/bar/hyphen) → uniform separator ''.
Manche Modelle (v.a. nicht-westliche) setzen statt des Em-Dashs einen En-Dash "; ohne Some models (especially non-western ones) use an en-dash "" instead of the em-dash; without
Normalisierung scheitert der ` — `-Split komplett und der ganze Eintrag wird zum Titel. normalization the ` — ` split fails entirely and the whole entry becomes the title.
ASCII-Bindestrich „-" bleibt unangetastet (sonst zerlegt es Formeln wie „n - 1").""" The ASCII hyphen "-" is left untouched (otherwise it would split formulas like "n - 1")."""
return re.sub(r"\s+[‒–—―‐]\s+", "", s) return re.sub(r"\s+[‒–—―‐]\s+", "", s)
def _parse_auswahl(text: str) -> dict[int, str]: def _parse_selection(text: str) -> dict[int, str]:
"""Parst eine Baustein-Liste: `N. Titel — Kurzbeschreibung` pro Zeile.""" """Parse a block list: `N. Titleshort description` per line."""
entries: dict[int, str] = {} entries: dict[int, str] = {}
last = None last = None
for line in text.splitlines(): for line in text.splitlines():
@@ -77,8 +77,8 @@ def _parse_auswahl(text: str) -> dict[int, str]:
return entries return entries
def _parse_kategorien(text: str) -> dict[str, list[str]]: def _parse_categories(text: str) -> dict[str, list[str]]:
"""Altformat-Reader: finale Baustein-Datei mit ## KERN/WICHTIG/REST-Abschnitten.""" """Legacy-format reader: final block file with ## KERN/WICHTIG/REST sections."""
cats: dict[str, list[str]] = {} cats: dict[str, list[str]] = {}
current = None current = None
for line in text.splitlines(): for line in text.splitlines():
@@ -94,40 +94,40 @@ def _parse_kategorien(text: str) -> dict[str, list[str]]:
return cats return cats
def _lade_bausteine(text: str) -> dict[int, str]: def _load_blocks(text: str) -> dict[int, str]:
"""Lädt die finale Baustein-Datei — sortierte Liste (neu) oder Kategorien (Altformat).""" """Load the final block file — sorted list (new) or categories (legacy format)."""
if re.search(r"^#+\s*KERN\b", text, re.IGNORECASE | re.MULTILINE): 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, [])] texts = [t for cat in _CATEGORIES for t in cats.get(cat, [])]
return {i: t for i, t in enumerate(texts, 1)} 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_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_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_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) _FRAGMENT_BAUSTEIN_RE = re.compile(r"<!--\s*block\s*:\s*(.*?)\s*-->", re.IGNORECASE)
# Zwei Lese-Schichten je Section: kompakt (Merksätze) + ausführlich (Erklärung). # Two reading layers per section: compact (key sentences) + detailed (explanation).
_FRAGMENT_KOMPAKT_RE = re.compile(r"<!--\s*kompakt\s*-->", re.IGNORECASE) _FRAGMENT_KOMPAKT_RE = re.compile(r"<!--\s*compact\s*-->", re.IGNORECASE)
_FRAGMENT_AUSF_RE = re.compile(r"<!--\s*ausf(?:ü|ue)hrlich\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. # Learning-path levels + peripheral; old difficulty values accepted for backward compatibility.
_STUFEN = ("anfaenger", "fortgeschritten", "experte", "rand", "einfach", "mittel", "schwer") _STUFEN = ("beginner", "advanced", "expert", "peripheral", "easy", "medium", "hard")
def _parse_fragment(text: str) -> list[dict]: 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 Two reading layers per section via `<!-- compact -->` / `<!-- ausführlich -->`. Within
beider markieren `<!-- sub: stufe | titel -->`-Marker je Subbaustein einen Block; gleicher both, `<!-- sub: level | title -->` markers mark a block per subblock; the same
Sub-Titel in beiden Schichten wird gemergt → `sec["subs"] = [{stufe, titel, md, kompakt}]`. sub title in both layers is merged → `sec["subs"] = [{level, title, md, compact}]`.
Text VOR dem ersten Sub-Marker ist der Anker (Einordnung) → `anker`/`anker_kompakt`. Text BEFORE the first sub marker is the anchor (framing) → `anker`/`anker_compact`.
`md`/`kompakt` bleiben die VOLLE Fassung (Anker + alle Subs) — ckwärtskompatibel. `md`/`compact` stay the FULL version (anchor + all subs) — backward compatible.
""" """
sections: list[dict] = [] sections: list[dict] = []
kapitel = None kapitel = None
current = None current = None
cur_sub = 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(): for line in text.splitlines():
s = line.strip() s = line.strip()
m = _FRAGMENT_KAPITEL_RE.match(s) m = _FRAGMENT_KAPITEL_RE.match(s)
@@ -138,14 +138,14 @@ def _parse_fragment(text: str) -> list[dict]:
continue continue
m = _FRAGMENT_SECTION_RE.match(s) m = _FRAGMENT_SECTION_RE.match(s)
if m: if m:
current = {"kapitel": kapitel, "titel": m.group(1), "md": [], "kompakt": [], current = {"chapters": kapitel, "title": m.group(1), "md": [], "compact": [],
"anker_md": [], "anker_kompakt": [], "_submap": {}, "_suborder": []} "anker_md": [], "anker_compact": [], "_submap": {}, "_suborder": []}
cur_sub = None cur_sub = None
cur_layer = "md" cur_layer = "md"
sections.append(current) sections.append(current)
continue continue
if current is not None and _FRAGMENT_KOMPAKT_RE.match(s): if current is not None and _FRAGMENT_KOMPAKT_RE.match(s):
cur_layer = "kompakt" cur_layer = "compact"
cur_sub = None cur_sub = None
continue continue
if current is not None and _FRAGMENT_AUSF_RE.match(s): if current is not None and _FRAGMENT_AUSF_RE.match(s):
@@ -154,17 +154,17 @@ def _parse_fragment(text: str) -> list[dict]:
continue continue
m = _FRAGMENT_SUB_RE.match(s) m = _FRAGMENT_SUB_RE.match(s)
if m and current is not None: if m and current is not None:
teil = m.group(1).split("|", 1) parts = m.group(1).split("|", 1)
stufe = teil[0].strip().casefold() level = parts[0].strip().casefold()
titel = teil[1].strip() if len(teil) == 2 else "" title = parts[1].strip() if len(parts) == 2 else ""
key = titel.casefold() or f"_pos{len(current['_suborder'])}" key = title.casefold() or f"_pos{len(current['_suborder'])}"
cur_sub = current["_submap"].get(key) cur_sub = current["_submap"].get(key)
if cur_sub is None: 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["_submap"][key] = cur_sub
current["_suborder"].append(key) current["_suborder"].append(key)
elif stufe in _STUFEN: elif level in _STUFEN:
cur_sub["stufe"] = stufe cur_sub["level"] = level
continue continue
if current is not None: if current is not None:
current[cur_layer].append(line) current[cur_layer].append(line)
@@ -178,24 +178,24 @@ def _parse_fragment(text: str) -> list[dict]:
for key in sec["_suborder"]: for key in sec["_suborder"]:
sub = sec["_submap"][key] sub = sec["_submap"][key]
sub["md"] = "\n".join(sub["md"]).strip() sub["md"] = "\n".join(sub["md"]).strip()
sub["kompakt"] = "\n".join(sub["kompakt"]).strip() sub["compact"] = "\n".join(sub["compact"]).strip()
if sub["md"] or sub["kompakt"]: if sub["md"] or sub["compact"]:
subs.append(sub) subs.append(sub)
out.append({ out.append({
"kapitel": sec["kapitel"], "titel": sec["titel"], "chapters": sec["chapters"], "title": sec["title"],
"md": "\n".join(sec["md"]).strip(), "md": "\n".join(sec["md"]).strip(),
"kompakt": "\n".join(sec["kompakt"]).strip(), "compact": "\n".join(sec["compact"]).strip(),
"anker": "\n".join(sec["anker_md"]).strip(), "anchor": "\n".join(sec["anker_md"]).strip(),
"anker_kompakt": "\n".join(sec["anker_kompakt"]).strip(), "anker_compact": "\n".join(sec["anker_compact"]).strip(),
"subs": subs, "subs": subs,
}) })
return out return out
def _parse_subbausteine(text: str) -> dict[str, list[str]]: def _parse_subblocks(text: str) -> dict[str, list[str]]:
"""Parst eine Subbaustein-Datei → {Baustein-Titel: [Subbaustein, …]} in Reihenfolge. """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]] = {} out: dict[str, list[str]] = {}
current = None 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]]: 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))) n = max(1, min(n, len(chapters)))
chunks: list[list[dict]] = [] chunks: list[list[dict]] = []
current: list[dict] = [] current: list[dict] = []

View File

@@ -1,10 +1,10 @@
<script setup> <script setup>
import { ref, computed, watch, onMounted } from 'vue' import { ref, computed, watch, onMounted } from 'vue'
import { fetchGuides, fetchTopics, createTopic as apiCreateTopic, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBausteineStatus, fetchActiveBausteine, createBausteine as apiCreateBausteine, resetBausteineAbStep as apiResetBausteineAbStep, cancelBausteine as apiCancelBausteine, deleteBausteine as apiDeleteBausteine, fetchProviders, fetchStats, fetchTopicFortschritt, fetchGuideLocks, fetchGuideSteps, fetchFolders, updateQuelle as apiUpdateQuelle } from './api.js' import { fetchGuides, fetchTopics, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBlocksStatus, fetchActiveBlocks, createBlocks as apiCreateBausteine, resetBlocksFromStep as apiResetBausteineAbStep, cancelBlocks as apiCancelBausteine, deleteBlocks as apiDeleteBausteine, fetchProviders, fetchStats, fetchTopicProgress, fetchGuideLocks, fetchGuideSteps, fetchFolders, updateSource as apiUpdateQuelle } from './api.js'
import { usePolling } from './composables/usePolling.js' import { usePolling } from './composables/usePolling.js'
import TopicSidebar from './components/TopicSidebar.vue' import TopicSidebar from './components/TopicSidebar.vue'
import TopicDetail from './components/TopicDetail.vue' import TopicDetail from './components/TopicDetail.vue'
import BausteineUebersicht from './components/BausteineUebersicht.vue' import BlocksOverview from './components/BlocksOverview.vue'
import ElementsSidebar from './components/elements/ElementsSidebar.vue' import ElementsSidebar from './components/elements/ElementsSidebar.vue'
import ElementsOverview from './components/ElementsOverview.vue' import ElementsOverview from './components/ElementsOverview.vue'
import GeneralExamPanel from './components/GeneralExamPanel.vue' import GeneralExamPanel from './components/GeneralExamPanel.vue'
@@ -15,39 +15,38 @@ const selectedTopic = ref(null)
const previewGuide = ref(null) const previewGuide = ref(null)
const sidebarPinned = ref(localStorage.getItem('sidebarPinned') !== 'false') const sidebarPinned = ref(localStorage.getItem('sidebarPinned') !== 'false')
const sidebarSticky = ref(false) const sidebarSticky = ref(false)
const fokusOffen = ref(false) // Baustein-Vollbild aktiv → Sidebar als Overlay über das Fokus-Overlay heben const focusOpen = ref(false) // block fullscreen activelift sidebar as overlay above the focus overlay
const darkMode = ref( const darkMode = ref(
localStorage.getItem('darkMode') === null localStorage.getItem('darkMode') === null
? window.matchMedia('(prefers-color-scheme: dark)').matches ? window.matchMedia('(prefers-color-scheme: dark)').matches
: localStorage.getItem('darkMode') === 'true', : localStorage.getItem('darkMode') === 'true',
) )
const EMPTY_BAUSTEINE = { ready: false, generating: false, progress: null, error: null, partial: false, steps: [], feine_steps: [] } const EMPTY_BLOCKS = { ready: false, generating: false, progress: null, error: null, partial: false, steps: [], feine_steps: [] }
const bausteine = ref({ ...EMPTY_BAUSTEINE }) const blocks = ref({ ...EMPTY_BLOCKS })
const activeBausteine = ref([]) const activeBlocks = ref([])
const provider = ref(localStorage.getItem('provider') || 'claude') const provider = ref(localStorage.getItem('provider') || 'claude')
const providers = ref([]) const providers = ref([])
const folders = ref({ projekt: [], uni: [] }) // Ordner für die Quellen-Auswahl const folders = ref({ projekt: [], uni: [] }) // folders for the sources picker
const bausteineView = ref(false) // Bausteine-Übersicht im Hauptbereich const mainView = ref('blocks') // blocks | elements | general | detail — exclusive main-area view
const allgemeinView = ref(false) // Allgemeine Prüfung (themenweit) im Hauptbereich const viewMode = ref('compact') // compact | erklärend — per topic, default compact
const ansichtModus = ref('kompakt') // kompakt | erklärend — je Thema, Default kompakt const levelView = ref(Number(localStorage.getItem('level')) || 4) // 1=A · 2=F · 3=E · 4=V (levels view)
const stufeAnsicht = ref(Number(localStorage.getItem('stufe')) || 4) // 1=A · 2=F · 3=E · 4=V (Stufen-Ansicht)
const stats = ref(null) const stats = ref(null)
const fortschritt = ref({}) const progress = ref({})
const locks = ref({}) // Sperr-Gründe pro Format (Backend = einzige Regel-Quelle) const locks = ref({}) // lock reasons per format (backend = single rule source)
const guideStepsDone = ref({}) // höchster fertiger Schritt-Index je Format (artefakt-basiert) const guideStepsDone = ref({}) // highest finished step index per format (artifact-based)
const uiError = ref(null) // abgewiesene Aktionen (409/400) sichtbar machen const uiError = ref(null) // surface rejected actions (409/400)
const elementsOpen = ref(false) // rechte Sidebar const elementsOpen = ref(false) // right sidebar
const elementsView = ref(false) // Übersicht im Hauptbereich const elementsVersion = ref(0) // increment = reload overview
const elementsVersion = ref(0) // Erhöhung = Übersicht neu laden const elementOpenId = ref(null) // open element from overview in sidebar
const elementOpenId = ref(null) // Element aus Übersicht in Sidebar öffnen
const elementOpenTick = ref(0) const elementOpenTick = ref(0)
// Run a loader, log + swallow its error (a failed background load must not break the UI).
async function guard(label, fn) {
try { return await fn() } catch (e) { console.error(label, e) }
}
async function loadStats() { async function loadStats() {
try { await guard('Failed to load stats:', async () => { stats.value = await fetchStats() })
stats.value = await fetchStats()
} catch (e) {
console.error('Fehler beim Laden der Statistik:', e)
}
} }
function setProvider(id) { function setProvider(id) {
@@ -56,25 +55,21 @@ function setProvider(id) {
} }
async function loadFolders() { async function loadFolders() {
try { await guard('Failed to load folders:', async () => {
const [projekt, uni] = await Promise.all([fetchFolders('projekt'), fetchFolders('uni')]) const [projekt, uni] = await Promise.all([fetchFolders('projekt'), fetchFolders('uni')])
folders.value = { projekt, uni } folders.value = { projekt, uni }
} catch (e) { })
console.error('Fehler beim Laden der Ordner:', e)
}
} }
async function loadProviders() { async function loadProviders() {
try { await guard('Failed to load providers:', async () => {
providers.value = await fetchProviders() providers.value = await fetchProviders()
const current = providers.value.find((p) => p.id === provider.value) const current = providers.value.find((p) => p.id === provider.value)
if (current && !current.available) { if (current && !current.available) {
const fallback = providers.value.find((p) => p.available) const fallback = providers.value.find((p) => p.available)
if (fallback) setProvider(fallback.id) if (fallback) setProvider(fallback.id)
} }
} catch (e) { })
console.error('Fehler beim Laden der Provider:', e)
}
} }
function applyTheme() { function applyTheme() {
@@ -136,12 +131,12 @@ async function loadTopics() {
try { try {
backendTopics.value = await fetchTopics() backendTopics.value = await fetchTopics()
} catch (e) { } catch (e) {
console.error('Fehler beim Laden der Themen:', e) console.error('Failed to load topics:', e)
} }
} }
// Weggeklickte Fehler bleiben weggeklickt — auch über Reloads (localStorage). // Dismissed errors stay dismissed — even across reloads (localStorage).
// Nicht weggeklickte Fehler bleiben sichtbar, bis der Nutzer sie schließt. // Errors that aren't dismissed stay visible until the user closes them.
const dismissedErrors = ref(new Set(JSON.parse(localStorage.getItem('dismissedErrors') || '[]'))) const dismissedErrors = ref(new Set(JSON.parse(localStorage.getItem('dismissedErrors') || '[]')))
function persistDismissed() { function persistDismissed() {
@@ -156,7 +151,7 @@ function handleDismissError(guideId) {
async function loadGuides() { async function loadGuides() {
try { try {
guides.value = await fetchGuides() guides.value = await fetchGuides()
// IDs prunen, deren Guide nicht mehr als Fehler existiert // Prune IDs whose guide no longer exists as an error
const errorIds = new Set(guides.value.filter((g) => g.status === 'error').map((g) => g.id)) const errorIds = new Set(guides.value.filter((g) => g.status === 'error').map((g) => g.id))
if ([...dismissedErrors.value].some((id) => !errorIds.has(id))) { if ([...dismissedErrors.value].some((id) => !errorIds.has(id))) {
dismissedErrors.value = new Set([...dismissedErrors.value].filter((id) => errorIds.has(id))) dismissedErrors.value = new Set([...dismissedErrors.value].filter((id) => errorIds.has(id)))
@@ -164,27 +159,27 @@ async function loadGuides() {
} }
loadStats() loadStats()
} catch (e) { } catch (e) {
console.error('Fehler beim Laden:', e) console.error('Failed to load:', e)
} }
} }
async function loadBausteine() { async function loadBlocks() {
try { try {
activeBausteine.value = await fetchActiveBausteine() activeBlocks.value = await fetchActiveBlocks()
if (selectedTopic.value) { if (selectedTopic.value) {
bausteine.value = await fetchBausteineStatus(selectedTopic.value) blocks.value = await fetchBlocksStatus(selectedTopic.value)
fortschritt.value = await fetchTopicFortschritt(selectedTopic.value) progress.value = await fetchTopicProgress(selectedTopic.value)
locks.value = await fetchGuideLocks(selectedTopic.value) locks.value = await fetchGuideLocks(selectedTopic.value)
guideStepsDone.value = await fetchGuideSteps(selectedTopic.value) guideStepsDone.value = await fetchGuideSteps(selectedTopic.value)
} else { } else {
bausteine.value = { ...EMPTY_BAUSTEINE } blocks.value = { ...EMPTY_BLOCKS }
fortschritt.value = {} progress.value = {}
locks.value = {} locks.value = {}
guideStepsDone.value = {} guideStepsDone.value = {}
} }
if (activeBausteine.value.length && !polling.running()) startPolling() if (activeBlocks.value.length && !polling.running()) startPolling()
} catch (e) { } catch (e) {
console.error('Fehler beim Laden der Bausteine:', e) console.error('Failed to load blocks:', e)
} }
} }
@@ -193,67 +188,57 @@ function selectTopic(topic) {
previewGuide.value = null previewGuide.value = null
sidebarSticky.value = false sidebarSticky.value = false
elementsOpen.value = false elementsOpen.value = false
elementsView.value = false mainView.value = 'blocks' // topic click → blocks overview (guide only on pill click)
bausteineView.value = true // Thema-Klick → Baustein-Übersicht (Guide erst auf Pill-Klick)
allgemeinView.value = false
elementOpenId.value = null elementOpenId.value = null
ansichtModus.value = localStorage.getItem('ansicht_' + topic) === 'erklärend' ? 'erklärend' : 'kompakt' viewMode.value = localStorage.getItem('ansicht_' + topic) === 'erklärend' ? 'erklärend' : 'compact'
localStorage.setItem('lastTopic', topic) localStorage.setItem('lastTopic', topic)
loadBausteine() loadBlocks()
} }
// Beim Reload dort landen, wo man vorher war (Thema + Format) // On reload, land where you were before (topic + format)
watch(previewGuide, (g) => { watch(previewGuide, (g) => {
if (g) localStorage.setItem('lastFormat', g.format) if (g) localStorage.setItem('lastFormat', g.format)
}) })
async function createTopic(topic) { async function handleCancelBlocks() {
await apiCreateTopic(topic)
await loadTopics()
selectedTopic.value = topic
previewGuide.value = null
loadBausteine()
}
async function handleCancelBausteine() {
if (!selectedTopic.value) return if (!selectedTopic.value) return
await apiCancelBausteine(selectedTopic.value) await apiCancelBausteine(selectedTopic.value)
await loadBausteine() await loadBlocks()
} }
async function handleResetBausteine() { async function handleResetBlocks() {
if (!selectedTopic.value) return if (!selectedTopic.value) return
await apiDeleteBausteine(selectedTopic.value) await apiDeleteBausteine(selectedTopic.value)
await loadBausteine() await loadBlocks()
} }
async function handleResetAbStep(step) { async function handleResetFromStep(step) {
if (!selectedTopic.value) return if (!selectedTopic.value) return
uiError.value = null uiError.value = null
try { try {
await apiResetBausteineAbStep(selectedTopic.value, step) // nur zurücksetzen, kein Neu-Generieren await apiResetBausteineAbStep(selectedTopic.value, step) // only reset, no regeneration
} catch (e) { } catch (e) {
uiError.value = e.message uiError.value = e.message
return return
} }
await loadBausteine() await loadBlocks()
} }
async function handleBausteineClick({ instructions, abPhase = null, abStep = null }) { async function handleBlocksClick({ instructions, abPhase = null, abStep = null }) {
if (!selectedTopic.value) return if (!selectedTopic.value) return
uiError.value = null uiError.value = null
try { try {
// Quelle ist hier schon festgeschrieben; abPhase/abStep steuern den Re-Run-Umfang. // Source is already fixed here; abPhase/abStep control the re-run scope.
await apiCreateBausteine(selectedTopic.value, instructions, provider.value, undefined, undefined, abPhase, abStep) await apiCreateBausteine(selectedTopic.value, instructions, provider.value, undefined, undefined, abPhase, abStep)
} catch (e) { } catch (e) {
uiError.value = e.message uiError.value = e.message
return return
} }
await loadBausteine() await loadBlocks()
startPolling() startPolling()
} }
async function handleCreateThema({ topic, instructions, sourceType, sourceOrt }) { async function handleCreateTopic({ topic, instructions, sourceType, sourceOrt }) {
uiError.value = null uiError.value = null
try { try {
await apiCreateBausteine(topic, instructions, provider.value, sourceType, sourceOrt) await apiCreateBausteine(topic, instructions, provider.value, sourceType, sourceOrt)
@@ -266,7 +251,7 @@ async function handleCreateThema({ topic, instructions, sourceType, sourceOrt })
startPolling() startPolling()
} }
async function handleUpdateQuelle({ topic, type, ort, spec }) { async function handleUpdateSource({ topic, type, ort, spec }) {
uiError.value = null uiError.value = null
try { try {
await apiUpdateQuelle(topic, { type, ort, spec }) await apiUpdateQuelle(topic, { type, ort, spec })
@@ -274,30 +259,28 @@ async function handleUpdateQuelle({ topic, type, ort, spec }) {
uiError.value = e.message uiError.value = e.message
return return
} }
await loadBausteine() // Schritt-Anzeige folgt ggf. der neuen Quelle (z.B. Link → „Quelle laden") await loadBlocks() // the step display may follow the new source (e.g. link → "load source")
} }
function setAnsicht(modus) { function setView(modus) {
ansichtModus.value = modus viewMode.value = modus
if (selectedTopic.value) localStorage.setItem('ansicht_' + selectedTopic.value, modus) if (selectedTopic.value) localStorage.setItem('ansicht_' + selectedTopic.value, modus)
} }
function setStufe(k) { function setLevel(k) {
stufeAnsicht.value = k levelView.value = k
localStorage.setItem('stufe', String(k)) localStorage.setItem('level', String(k))
} }
function handleOpenBausteineView() { function handleOpenBlocksView() {
if (!selectedTopic.value) return if (!selectedTopic.value) return
bausteineView.value = true mainView.value = 'blocks'
elementsView.value = false
allgemeinView.value = false
previewGuide.value = null previewGuide.value = null
} }
async function handleFormatClick({ format, instructions, abStep = null }) { async function handleFormatClick({ format, instructions, abStep = null }) {
if (!selectedTopic.value) return if (!selectedTopic.value) return
// Kein Duplikat-Start: läuft für Thema+Format schon eine Generierung, ignorieren // No duplicate start: if a generation is already running for topic+format, ignore
const running = guides.value.some( const running = guides.value.some(
(g) => g.topic === selectedTopic.value && g.format === format (g) => g.topic === selectedTopic.value && g.format === format
&& (g.status === 'generating' || g.status === 'queued'), && (g.status === 'generating' || g.status === 'queued'),
@@ -316,25 +299,19 @@ async function handleFormatClick({ format, instructions, abStep = null }) {
function handlePreview(guide) { function handlePreview(guide) {
previewGuide.value = guide previewGuide.value = guide
elementsView.value = false mainView.value = 'detail'
bausteineView.value = false
allgemeinView.value = false
} }
function handleGeneralExam() { function handleGeneralExam() {
if (!selectedTopic.value) return if (!selectedTopic.value) return
allgemeinView.value = true mainView.value = 'general'
bausteineView.value = false
elementsView.value = false
previewGuide.value = null previewGuide.value = null
} }
function handleOpenElements() { function handleOpenElements() {
if (!selectedTopic.value) return if (!selectedTopic.value) return
elementsView.value = true mainView.value = 'elements'
bausteineView.value = false // Right sidebar stays closed — it opens only when an element is clicked.
allgemeinView.value = false
// Rechte Sidebar bleibt zu — sie öffnet erst beim Klick auf ein Element.
} }
function handleOpenElementDetail(el) { function handleOpenElementDetail(el) {
@@ -352,8 +329,8 @@ async function handleDeleteGuide(guideId, slots = false) {
} }
const polling = usePolling( const polling = usePolling(
() => Promise.all([loadGuides(), loadBausteine(), loadTopics()]), () => Promise.all([loadGuides(), loadBlocks(), loadTopics()]),
() => hasActiveGuides.value || activeBausteine.value.length > 0, () => hasActiveGuides.value || activeBlocks.value.length > 0,
) )
const startPolling = polling.start const startPolling = polling.start
@@ -381,7 +358,7 @@ onMounted(async () => {
await Promise.all([loadGuides(), loadTopics(), loadProviders(), loadFolders()]) await Promise.all([loadGuides(), loadTopics(), loadProviders(), loadFolders()])
const savedTopic = localStorage.getItem('lastTopic') const savedTopic = localStorage.getItem('lastTopic')
if (savedTopic && topics.value.includes(savedTopic)) { if (savedTopic && topics.value.includes(savedTopic)) {
selectTopic(savedTopic) // landet auf der Baustein-Übersicht selectTopic(savedTopic) // lands on the blocks overview
} else if (!selectedTopic.value && topics.value.length) { } else if (!selectedTopic.value && topics.value.length) {
selectTopic(topics.value[0]) selectTopic(topics.value[0])
} }
@@ -389,14 +366,14 @@ onMounted(async () => {
</script> </script>
<template> <template>
<div class="layout" :class="{ 'sidebar-floating': !sidebarPinned, 'sidebar-open': sidebarSticky, 'sidebar-over-fokus': fokusOffen && sidebarSticky }"> <div class="layout" :class="{ 'sidebar-floating': !sidebarPinned, 'sidebar-open': sidebarSticky, 'sidebar-over-fokus': focusOpen && sidebarSticky }">
<div v-if="!sidebarPinned" class="hover-zone" @click="clickHoverZone"></div> <div v-if="!sidebarPinned" class="hover-zone" @click="clickHoverZone"></div>
<div v-if="(!sidebarPinned && sidebarSticky) || (fokusOffen && sidebarSticky)" class="sidebar-backdrop" @click="sidebarSticky = false"></div> <div v-if="(!sidebarPinned && sidebarSticky) || (focusOpen && sidebarSticky)" class="sidebar-backdrop" @click="sidebarSticky = false"></div>
<TopicSidebar <TopicSidebar
:topics="topics" :topics="topics"
:selectedTopic="selectedTopic" :selectedTopic="selectedTopic"
:stats="stats" :stats="stats"
:fortschritt="fortschritt" :fortschritt="progress"
:locks="locks" :locks="locks"
:guideStepsDone="guideStepsDone" :guideStepsDone="guideStepsDone"
:uiError="uiError" :uiError="uiError"
@@ -404,29 +381,28 @@ onMounted(async () => {
:latestByFormat="latestByFormat" :latestByFormat="latestByFormat"
:allGuides="guides" :allGuides="guides"
:dismissedErrors="dismissedErrors" :dismissedErrors="dismissedErrors"
:bausteine="bausteine" :blocks="blocks"
:activeBausteine="activeBausteine" :activeBausteine="activeBlocks"
:pinned="sidebarPinned" :pinned="sidebarPinned"
:dark="darkMode" :dark="darkMode"
:provider="provider" :provider="provider"
:providers="providers" :providers="providers"
:folders="folders" :folders="folders"
:ansichtModus="ansichtModus" :ansichtModus="viewMode"
:stufeAnsicht="stufeAnsicht" :stufeAnsicht="levelView"
@setProvider="setProvider" @setProvider="setProvider"
@toggleDark="toggleDark" @toggleDark="toggleDark"
@setAnsicht="setAnsicht" @setAnsicht="setView"
@setStufe="setStufe" @setStufe="setLevel"
@generalExam="handleGeneralExam" @generalExam="handleGeneralExam"
@select="selectTopic" @select="selectTopic"
@create="createTopic" @createThema="handleCreateTopic"
@createThema="handleCreateThema" @updateSource="handleUpdateSource"
@updateQuelle="handleUpdateQuelle" @openBausteineView="handleOpenBlocksView"
@openBausteineView="handleOpenBausteineView"
@formatClick="handleFormatClick" @formatClick="handleFormatClick"
@bausteineClick="handleBausteineClick" @bausteineClick="handleBlocksClick"
@cancelBausteine="handleCancelBausteine" @cancelBlocks="handleCancelBlocks"
@resetBausteine="handleResetBausteine" @resetBausteine="handleResetBlocks"
@deleteTopic="handleDeleteTopic" @deleteTopic="handleDeleteTopic"
@cancelGuide="handleCancel" @cancelGuide="handleCancel"
@deleteGuide="handleDeleteGuide" @deleteGuide="handleDeleteGuide"
@@ -437,33 +413,33 @@ onMounted(async () => {
@togglePin="toggleSidebarPin" @togglePin="toggleSidebarPin"
@sidebarLeave="onSidebarLeave" @sidebarLeave="onSidebarLeave"
/> />
<BausteineUebersicht <BlocksOverview
v-if="selectedTopic && bausteineView" v-if="selectedTopic && mainView === 'blocks'"
:topic="selectedTopic" :topic="selectedTopic"
:steps="bausteine.feine_steps || []" :steps="blocks.feine_steps || []"
:generating="bausteine.generating" :generating="blocks.generating"
:progress="bausteine.progress" :progress="blocks.progress"
:ready="bausteine.ready" :ready="blocks.ready"
:partial="bausteine.partial" :partial="blocks.partial"
@close="bausteineView = false" @close="mainView = 'detail'"
@restartFrom="(i) => handleBausteineClick({ instructions: '', abStep: i })" @restartFrom="(i) => handleBlocksClick({ instructions: '', abStep: i })"
@resetFrom="handleResetAbStep" @resetFrom="handleResetFromStep"
@restartAll="() => handleBausteineClick({ abPhase: bausteine.ready ? 1 : null })" @restartAll="() => handleBlocksClick({ abPhase: blocks.ready ? 1 : null })"
@removeAll="handleResetBausteine" @removeAll="handleResetBlocks"
@cancel="handleCancelBausteine" @cancel="handleCancelBlocks"
/> />
<ElementsOverview <ElementsOverview
v-else-if="selectedTopic && elementsView" v-else-if="selectedTopic && mainView === 'elements'"
:topic="selectedTopic" :topic="selectedTopic"
:version="elementsVersion" :version="elementsVersion"
@open="handleOpenElementDetail" @open="handleOpenElementDetail"
/> />
<GeneralExamPanel <GeneralExamPanel
v-else-if="selectedTopic && allgemeinView" v-else-if="selectedTopic && mainView === 'general'"
:topic="selectedTopic" :topic="selectedTopic"
:provider="provider" :provider="provider"
@progressChanged="loadStats(); loadBausteine()" @progressChanged="loadStats(); loadBlocks()"
@fokus-active="fokusOffen = $event" @fokus-active="focusOpen = $event"
/> />
<TopicDetail <TopicDetail
v-else-if="selectedTopic" v-else-if="selectedTopic"
@@ -472,16 +448,16 @@ onMounted(async () => {
:provider="provider" :provider="provider"
:elementsOpen="elementsOpen" :elementsOpen="elementsOpen"
:doneByFormat="doneByFormat" :doneByFormat="doneByFormat"
:themaAbgeschlossen="!!fortschritt.abgeschlossen" :themaAbgeschlossen="!!progress.completed"
:ansichtModus="ansichtModus" :ansichtModus="viewMode"
:stufeAnsicht="stufeAnsicht" :stufeAnsicht="levelView"
@progressChanged="loadStats(); loadBausteine()" @progressChanged="loadStats(); loadBlocks()"
@setAnsicht="setAnsicht" @setAnsicht="setView"
@open-sidebar="sidebarSticky = true" @open-sidebar="sidebarSticky = true"
@fokus-active="fokusOffen = $event" @fokus-active="focusOpen = $event"
/> />
<div v-else class="empty-main"> <div v-else class="empty-main">
<p>Thema in der Sidebar anlegen oder auswählen.</p> <p>Create or select a topic in the sidebar.</p>
</div> </div>
<div <div
v-if="elementsOpen && selectedTopic" v-if="elementsOpen && selectedTopic"
@@ -520,11 +496,11 @@ onMounted(async () => {
--success-soft: #d1fae5; --success-soft: #d1fae5;
--success-soft-hover: #a7f3d0; --success-soft-hover: #a7f3d0;
--success-border: #34d399; --success-border: #34d399;
/* 4 Lernstufen: grün/blau/lila/gold = Anfänger/Fortgeschritten/Experte/Meister */ /* 4 learning levels: green/blue/purple/gold = Beginner/Advanced/Expert/Master */
--stufe-anfaenger: #22c55e; --level-beginner: #22c55e;
--stufe-fortgeschritten: #3b82f6; --level-advanced: #3b82f6;
--stufe-experte: #8b5cf6; --level-expert: #8b5cf6;
--stufe-meister: #d4af37; --level-master: #d4af37;
--warning: #92400e; --warning: #92400e;
--warning-soft: #fef3c7; --warning-soft: #fef3c7;
--warning-border: #fbbf24; --warning-border: #fbbf24;
@@ -600,8 +576,8 @@ textarea::placeholder {
cursor: pointer; cursor: pointer;
} }
/* Unsichtbare Fläche hinter der offenen Floating-Sidebar. /* Invisible surface behind the open floating sidebar.
Tipp/Klick daneben schließt sie — ohne sie gibt es auf Touch keinen Ausweg. */ A tap/click next to it closes it — without it there's no way out on touch. */
.sidebar-backdrop { .sidebar-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -626,8 +602,8 @@ textarea::placeholder {
transform: translateX(0); transform: translateX(0);
} }
/* Baustein-Vollbild: Sidebar als Overlay ÜBER das Fokus-Overlay (z-index 40) heben. /* Block fullscreen: lift the sidebar as an overlay ABOVE the focus overlay (z-index 40).
Greift für gepinnt UND floating. */ Applies for pinned AND floating. */
.layout.sidebar-over-fokus > .sidebar { .layout.sidebar-over-fokus > .sidebar {
position: fixed; position: fixed;
left: 0; left: 0;
@@ -650,8 +626,8 @@ textarea::placeholder {
font-size: 1rem; font-size: 1rem;
} }
/* Nur sichtbar, wenn die Elemente-Sidebar mobil als Overlay liegt. /* Only visible when the elements sidebar sits as an overlay on mobile.
Tipp daneben schließt sie. */ A tap next to it closes it. */
.elements-backdrop { .elements-backdrop {
display: none; display: none;
} }

View File

@@ -37,27 +37,27 @@ export async function createGuide(topic, format, instructions = '', provider = '
return jsonOrThrow(res) return jsonOrThrow(res)
} }
export async function fetchActiveBausteine() { export async function fetchActiveBlocks() {
const res = await fetch(`${BASE}/bausteine/active`) const res = await fetch(`${BASE}/blocks/active`)
return res.json() return res.json()
} }
export async function fetchBausteineStatus(topic) { export async function fetchBlocksStatus(topic) {
const res = await fetch(`${BASE}/bausteine/status?topic=${encodeURIComponent(topic)}`) const res = await fetch(`${BASE}/blocks/status?topic=${encodeURIComponent(topic)}`)
return res.json() return res.json()
} }
export async function createBausteine(topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '', abPhase = null, abStep = null) { export async function createBlocks(topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '', abPhase = null, abStep = null) {
const res = await fetch(`${BASE}/bausteine`, { const res = await fetch(`${BASE}/blocks`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, instructions, provider, source_type: sourceType, source_ort: sourceOrt, ab_phase: abPhase, ab_step: abStep }), body: JSON.stringify({ topic, instructions, provider, source_type: sourceType, source_location: sourceOrt, ab_phase: abPhase, ab_step: abStep }),
}) })
return jsonOrThrow(res) return jsonOrThrow(res)
} }
export async function resetBausteineAbStep(topic, abStep) { export async function resetBlocksFromStep(topic, abStep) {
const res = await fetch(`${BASE}/bausteine/reset-step`, { const res = await fetch(`${BASE}/blocks/reset-step`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, ab_step: abStep }), body: JSON.stringify({ topic, ab_step: abStep }),
@@ -65,51 +65,51 @@ export async function resetBausteineAbStep(topic, abStep) {
return jsonOrThrow(res) return jsonOrThrow(res)
} }
export async function cancelBausteine(topic) { export async function cancelBlocks(topic) {
await fetch(`${BASE}/bausteine/cancel?topic=${encodeURIComponent(topic)}`, { method: 'POST' }) await fetch(`${BASE}/blocks/cancel?topic=${encodeURIComponent(topic)}`, { method: 'POST' })
} }
export async function deleteBausteine(topic) { export async function deleteBlocks(topic) {
await fetch(`${BASE}/bausteine?topic=${encodeURIComponent(topic)}`, { method: 'DELETE' }) await fetch(`${BASE}/blocks?topic=${encodeURIComponent(topic)}`, { method: 'DELETE' })
} }
// --- Baustein-Lernen: Chat, Prüfung --- // --- Block-Learning: Chat, Exam ---
export async function fetchBausteinLernstand(topic) { export async function fetchBlockLearnState(topic) {
const res = await fetch(`${BASE}/bausteine/lernstand?topic=${encodeURIComponent(topic)}`) const res = await fetch(`${BASE}/blocks/learnstate?topic=${encodeURIComponent(topic)}`)
return jsonOrThrow(res) return jsonOrThrow(res)
} }
export async function chatBaustein({ topic, baustein, section, section_kompakt = '', messages, provider }) { export async function chatBlock({ topic, block, section, section_compact = '', messages, provider }) {
const res = await fetch(`${BASE}/bausteine/chat`, { const res = await fetch(`${BASE}/blocks/chat`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, baustein, section, section_kompakt, messages, provider }), body: JSON.stringify({ topic, block, section, section_compact, messages, provider }),
}) })
return jsonOrThrow(res) return jsonOrThrow(res)
} }
export async function pruefeBaustein({ export async function examBlock({
topic, baustein, section, section_kompakt = '', provider, topic, block, section, section_compact = '', provider,
aktion = 'frage', frage = '', letzte_bewertung = '', vermeide = [], action = 'question', question = '', last_rating = '', avoid = [],
nachgefragt = false, begruendung = '', muster = '', cap = 6, messages = [], gruendlich = false, asked_again = false, reason = '', pattern = '', cap = 6, messages = [], thorough = false,
auswahl = [], korrekt = [], loesung = '', alternativen = [], eingabe = '', schwer = false, selection = [], correct = [], solution = '', alternatives = [], input = '', schwer = false,
}) { }) {
const res = await fetch(`${BASE}/bausteine/pruefung`, { const res = await fetch(`${BASE}/blocks/exam`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, baustein, section, section_kompakt, aktion, frage, letzte_bewertung, vermeide, nachgefragt, begruendung, muster, cap, messages, provider, gruendlich, auswahl, korrekt, loesung, alternativen, eingabe, schwer }), body: JSON.stringify({ topic, block, section, section_compact, action, question, last_rating, avoid, asked_again, reason, pattern, cap, messages, provider, thorough, selection, correct, solution, alternatives, input, schwer }),
}) })
return jsonOrThrow(res) return jsonOrThrow(res)
} }
export async function fetchFrageMuster(topic, baustein) { export async function fetchQuestionPattern(topic, block) {
const res = await fetch(`${BASE}/bausteine/frage-muster?topic=${encodeURIComponent(topic)}&baustein=${encodeURIComponent(baustein)}`) const res = await fetch(`${BASE}/blocks/question-pattern?topic=${encodeURIComponent(topic)}&block=${encodeURIComponent(block)}`)
return jsonOrThrow(res) return jsonOrThrow(res)
} }
export async function fetchTopicFortschritt(topic) { export async function fetchTopicProgress(topic) {
const res = await fetch(`${BASE}/topics/fortschritt?topic=${encodeURIComponent(topic)}`) const res = await fetch(`${BASE}/topics/progress?topic=${encodeURIComponent(topic)}`)
return res.json() return res.json()
} }
@@ -128,22 +128,22 @@ export async function fetchFolders(kind) {
return jsonOrThrow(res) return jsonOrThrow(res)
} }
export async function fetchQuelle(topic) { export async function fetchSource(topic) {
const res = await fetch(`${BASE}/bausteine/quelle?topic=${encodeURIComponent(topic)}`) const res = await fetch(`${BASE}/blocks/source?topic=${encodeURIComponent(topic)}`)
return jsonOrThrow(res) return jsonOrThrow(res)
} }
export async function updateQuelle(topic, { type, ort = '', spec = '' }) { export async function updateSource(topic, { type, ort = '', spec = '' }) {
const res = await fetch(`${BASE}/bausteine/quelle`, { const res = await fetch(`${BASE}/blocks/source`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, type, ort, spec }), body: JSON.stringify({ topic, type, location: ort, spec }),
}) })
return jsonOrThrow(res) return jsonOrThrow(res)
} }
export async function fetchBausteineUebersicht(topic) { export async function fetchBlocksOverview(topic) {
const res = await fetch(`${BASE}/bausteine/uebersicht?topic=${encodeURIComponent(topic)}`) const res = await fetch(`${BASE}/blocks/overview?topic=${encodeURIComponent(topic)}`)
return jsonOrThrow(res) return jsonOrThrow(res)
} }
@@ -155,40 +155,40 @@ export async function deleteGuide(id, slots = false) {
await fetch(`${BASE}/guides/${id}${slots ? '?slots=1' : ''}`, { method: 'DELETE' }) await fetch(`${BASE}/guides/${id}${slots ? '?slots=1' : ''}`, { method: 'DELETE' })
} }
export async function fetchGuideContent(id, ebene = 4) { export async function fetchGuideContent(id, level = 4) {
const res = await fetch(`${BASE}/guides/${id}/content?ebene=${ebene}`) const res = await fetch(`${BASE}/guides/${id}/content?level=${level}`)
if (!res.ok) throw new Error(`Inhalt nicht verfügbar (${res.status})`) if (!res.ok) throw new Error(`Content not available (${res.status})`)
return res.json() return res.json()
} }
// Lern-Artefakte (Karteikarten/Beispiele/Diagramme) je Thema, gruppiert nach Baustein-Norm. // Lern-Artefakte (Flashcards/Examples/Diagramme) je Thema, gruppiert nach Block-Norm.
export async function fetchArtefakte(topic) { export async function fetchArtefakte(topic) {
const res = await fetch(`${BASE}/bausteine/artefakte?topic=${encodeURIComponent(topic)}`) const res = await fetch(`${BASE}/blocks/artefakte?topic=${encodeURIComponent(topic)}`)
if (!res.ok) return { artefakte: {} } if (!res.ok) return { artefakte: {} }
return res.json() return res.json()
} }
// Einen Markdown-Block on-demand gegen die Guide-Regeln prüfen (Fokus, Rechtsklick). // Einen Markdown-Block on-demand gegen die Guide-Rules prüfen (Fokus, Rechtsklick).
export async function pruefeBlock(id, { baustein, stelle, block, hinweis = '', provider }) { export async function pruefeBlock(id, { block, spot, snippet, hint = '', provider }) {
const res = await fetch(`${BASE}/guides/${id}/block/pruefen`, { const res = await fetch(`${BASE}/guides/${id}/block/pruefen`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ baustein, stelle, block, hinweis, provider }), body: JSON.stringify({ block, spot, snippet, hint, provider }),
}) })
return jsonOrThrow(res) return jsonOrThrow(res)
} }
// Geprüften Block persistent übernehmen (alt → neu im jeweiligen Feld). // Geprüften Block persistent übernehmen (alt → new im jeweiligen Feld).
export async function uebernehmeBlock(id, { baustein, stelle, alt, neu, provider }) { export async function uebernehmeBlock(id, { block, spot, alt, revised, provider }) {
const res = await fetch(`${BASE}/guides/${id}/block/uebernehmen`, { const res = await fetch(`${BASE}/guides/${id}/block/uebernehmen`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ baustein, stelle, alt, neu, provider }), body: JSON.stringify({ block, spot, alt, revised, provider }),
}) })
return jsonOrThrow(res) return jsonOrThrow(res)
} }
// Lern-Fortschritt eines Bausteins auf null setzen (Score/Streak/Flags/offene Frage). // Reset a block's learning progress to zero (score/streak/flags/open question).
export async function resetBausteinFortschritt(topic, baustein) { export async function resetBlockProgress(topic, block) {
const res = await fetch(`${BASE}/bausteine/fortschritt?topic=${encodeURIComponent(topic)}&baustein=${encodeURIComponent(baustein)}`, { const res = await fetch(`${BASE}/blocks/progress?topic=${encodeURIComponent(topic)}&block=${encodeURIComponent(block)}`, {
method: 'DELETE', method: 'DELETE',
}) })
return jsonOrThrow(res) return jsonOrThrow(res)
@@ -262,7 +262,7 @@ export async function styleElement(id, provider = 'claude') {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider }), body: JSON.stringify({ provider }),
}) })
if (!res.ok) throw new Error(`Stil-Prüfung fehlgeschlagen (${res.status})`) if (!res.ok) throw new Error(`Stil-Exam fehlgeschlagen (${res.status})`)
return res.json() return res.json()
} }
@@ -282,6 +282,6 @@ export async function checkElement(id, provider = 'claude') {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider }), body: JSON.stringify({ provider }),
}) })
if (!res.ok) throw new Error(`Prüfung fehlgeschlagen (${res.status})`) if (!res.ok) throw new Error(`Exam fehlgeschlagen (${res.status})`)
return res.json() return res.json()
} }

View File

@@ -1,418 +0,0 @@
<script setup>
import BausteinPanel from './BausteinPanel.vue'
import FlashcardWidget from './FlashcardWidget.vue'
import WorkedExampleBlock from './WorkedExampleBlock.vue'
import { renderMarkdown, renderBloecke } from '../markdown.js'
import { stufeFuer, STUFEN } from '../stufen.js'
import { pruefeBlock, uebernehmeBlock, resetBausteinFortschritt } from '../api.js'
import { clearPruef } from '../pruefungCache.js'
import { useConfirm } from '../composables/useConfirm.js'
const props = defineProps({
baustein: { type: Object, required: true }, // { title, md, num }
artefakte: { type: Object, default: null }, // { karteikarte[], beispiel[], diagramm } für diesen Baustein
topic: { type: String, required: true },
provider: { type: String, default: 'claude' },
status: { type: Object, default: null },
cap: { type: Number, default: 6 },
guideId: { type: String, default: '' },
tab: { type: String, default: 'pruefung' },
ansicht: { type: String, default: 'kompakt' }, // kompakt | erklärend — linke Guide-Spalte
hasPrev: { type: Boolean, default: false },
hasNext: { type: Boolean, default: false },
fortschritt: { type: Object, default: () => ({ total: 0, anfaenger: 0, fortgeschritten: 0, experte: 0, meister: 0 }) },
})
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
const emit = defineEmits(['close', 'prev', 'next', 'statusChanged', 'setAnsicht', 'openSidebar', 'sectionUpdated'])
// --- Reset pro Baustein: Fragen-Session (Slot) bzw. Fortschritt (Score) ---
const { isArmed, armOrRun } = useConfirm()
const resetN = ref(0) // Hochzählen → Panel-Remount (frischer Slot, Muster neu)
const pruefKey = computed(() => `${props.topic}::${props.baustein.title}`)
// Frage-Session neu: Slot verwerfen → Remount lädt Muster + Pool frisch (behebt Erklären-only).
function fragenResetten() {
clearPruef(pruefKey.value)
resetN.value++
}
// Fortschritt auf 0: DB-Zeile löschen, Badge/Lernstand nullen, Session frisch.
async function fortschrittResetten() {
try {
await resetBausteinFortschritt(props.topic, props.baustein.title)
emit('statusChanged', { baustein: props.baustein.title, gute_antworten: 0, streak: 0, cap: props.cap })
clearPruef(pruefKey.value)
resetN.value++
} catch (e) { /* still bleiben — Reset fehlgeschlagen */ }
}
const guideEl = ref(null) // linke Guide-Spalte (Scroll-Ziel für ALT+↑/↓)
const rightEl = ref(null) // rechte Spalte (Prüfungs-Panel mit Eingabefeld)
// ALT ist der Modifier der Fokus-Ansicht. Plain Pfeile bleiben Browser-Standard.
// ALT+←/→ blättert Baustein, ALT+↑/↓ scrollt den Guide. Ein reiner ALT-Tipp
// (drücken+loslassen ohne andere Taste) toggelt den Fokus aufs Eingabefeld.
let altAlone = false
function onKeyDown(e) {
if (e.key === 'Alt') { altAlone = true; e.preventDefault(); return } // unterdrückt Firefox-Menüleiste
if (e.ctrlKey || e.metaKey || !e.altKey) return // plain/andere → normal
altAlone = false // ALT+irgendwas = kein Lone-Tap
if (e.key === 'ArrowLeft') { e.preventDefault(); if (!e.repeat && props.hasPrev) emit('prev') }
else if (e.key === 'ArrowRight') { e.preventDefault(); if (!e.repeat && props.hasNext) emit('next') }
else if (e.key === 'ArrowUp') { e.preventDefault(); guideEl.value?.scrollBy(0, -120) }
else if (e.key === 'ArrowDown') { e.preventDefault(); guideEl.value?.scrollBy(0, 120) }
}
function onKeyUp(e) {
if (e.key === 'Alt') { e.preventDefault(); if (altAlone) { altAlone = false; toggleInput() } }
}
function resetAlt() { altAlone = false } // Fenster-Blur (ALT+Tab) → kein falscher Tipp
function toggleInput() {
// Antwortfeld der aktuellen Form: Erklären = textarea, Lückentext-frei = input.
const el = rightEl.value?.querySelector('textarea, input')
if (!el) return
document.activeElement === el ? el.blur() : el.focus()
}
onMounted(() => {
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
window.addEventListener('blur', resetAlt)
})
onUnmounted(() => {
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', resetAlt)
})
const pct = (n) => (100 * n / (props.fortschritt.total || 1)) + '%'
// Akzentfarbe nach Stufe des aktuellen Bausteins (grün/blau/lila/gold) oder neutral.
const standFarbe = computed(() => {
const s = props.status || {}
return stufeFuer(s.gute_antworten || 0, s.cap || props.cap)?.farbe || 'var(--border)'
})
const stufe = computed(() => {
const s = props.status || {}
return stufeFuer(s.gute_antworten || 0, s.cap || props.cap)
})
// --- Block-Prüfen (Rechtsklick auf einen Abschnitt) ---
const angezeigterText = computed(() =>
props.ansicht === 'kompakt' ? (props.baustein.kompakt || props.baustein.md) : props.baustein.md,
)
const bloecke = computed(() => renderBloecke(angezeigterText.value))
// Feld, in das übernommen wird (muss zum angezeigten Text passen).
const stelle = computed(() => (props.ansicht === 'kompakt' && props.baustein.kompakt) ? 'kompakt' : 'ausführlich')
const menu = reactive({ show: false, x: 0, y: 0, index: null })
function blockMenue(i, e) { menu.show = true; menu.x = e.clientX; menu.y = e.clientY; menu.index = i }
function menueZu() { menu.show = false }
// Vorschlag je Block-Index: { neu, laeuft, fehler, editOffen, hinweis }
const vorschlaege = reactive({})
async function blockPruefen(i, zusatz = '') {
const raw = bloecke.value[i]?.raw
if (!raw || !props.guideId) return
menueZu()
vorschlaege[i] = { neu: '', laeuft: true, fehler: '', editOffen: false, hinweis: '' }
try {
const res = await pruefeBlock(props.guideId, { baustein: props.baustein.title, stelle: stelle.value, block: raw, hinweis: zusatz, provider: props.provider })
vorschlaege[i] = { neu: res.neu, laeuft: false, fehler: '', editOffen: false, hinweis: '' }
} catch (e) {
vorschlaege[i] = { neu: '', laeuft: false, fehler: e.message || 'Prüfung fehlgeschlagen', editOffen: false, hinweis: '' }
}
}
async function blockUebernehmen(i) {
const v = vorschlaege[i]; const raw = bloecke.value[i]?.raw
if (!v || v.laeuft || !raw) return
v.laeuft = true; v.fehler = ''
try {
const res = await uebernehmeBlock(props.guideId, { baustein: props.baustein.title, stelle: stelle.value, alt: raw, neu: v.neu, provider: props.provider })
if (res.gefunden) emit('sectionUpdated', { title: props.baustein.title, kompakt: res.kompakt, md: res.md })
else { v.laeuft = false; v.fehler = 'Stelle nicht gefunden — evtl. schon geändert.' }
} catch (e) { v.laeuft = false; v.fehler = e.message || 'Übernehmen fehlgeschlagen' }
}
function blockVerwerfen(i) { delete vorschlaege[i] }
function blockEdit(i) { const v = vorschlaege[i]; if (v) v.editOffen = !v.editOffen }
function blockEditSenden(i) {
const z = (vorschlaege[i]?.hinweis || '').trim()
if (z) blockPruefen(i, z)
}
// Baustein/Inhalt-Wechsel → Vorschläge + Menü zurücksetzen (Block-Indizes neu).
watch(() => `${props.baustein.title}|${props.baustein.md}|${props.baustein.kompakt || ''}`, () => {
for (const k of Object.keys(vorschlaege)) delete vorschlaege[k]
menueZu()
})
</script>
<template>
<div class="fokus-overlay" :style="{ '--stand': standFarbe }">
<div class="fokus-bar">
<div class="fokus-bar-inner">
<button class="fokus-btn" title="Navigation öffnen" @click="$emit('openSidebar')"></button>
<button class="fokus-btn" :disabled="!hasPrev" title="Voriger Baustein" @click="$emit('prev')"></button>
<button class="fokus-btn" :disabled="!hasNext" title="Nächster Baustein" @click="$emit('next')"></button>
<span class="fokus-titel">{{ baustein.title }}</span>
<span v-if="stufe" class="stand-badge" :style="{ color: stufe.farbe, borderColor: stufe.farbe }">{{ stufe.kurz }} {{ stufe.label }}</span>
<span class="fokus-spacer"></span>
<button class="fokus-btn" title="Fragen resetten (neue Frage-Session)" @click="fragenResetten"></button>
<button
class="fokus-btn"
:class="{ armed: isArmed('reset-fortschritt') }"
:title="isArmed('reset-fortschritt') ? 'Nochmal klicken: Fortschritt auf 0 setzen' : 'Fortschritt resetten (Score auf 0)'"
@click="armOrRun('reset-fortschritt', fortschrittResetten)"
></button>
<button class="fokus-btn" title="Vollansicht beenden" @click="$emit('close')"></button>
</div>
</div>
<div class="fokus-xp" :title="`${fortschritt.anfaenger}/${fortschritt.total} ab Anfänger · ${fortschritt.fortgeschritten} Fortgeschritten · ${fortschritt.experte} Experte · ${fortschritt.meister} Meister`">
<div class="xp-seg" :style="{ width: pct(fortschritt.meister), background: 'var(--stufe-meister)' }"></div>
<div class="xp-seg" :style="{ width: pct(fortschritt.experte - fortschritt.meister), background: 'var(--stufe-experte)' }"></div>
<div class="xp-seg" :style="{ width: pct(fortschritt.fortgeschritten - fortschritt.experte), background: 'var(--stufe-fortgeschritten)' }"></div>
<div class="xp-seg" :style="{ width: pct(fortschritt.anfaenger - fortschritt.fortgeschritten), background: 'var(--stufe-anfaenger)' }"></div>
</div>
<div class="fokus-body">
<div ref="guideEl" class="fokus-col left">
<div class="markdown">
<template v-for="(b, i) in bloecke" :key="i">
<div class="md-block" v-html="b.html" @contextmenu.prevent="blockMenue(i, $event)"></div>
<div v-if="vorschlaege[i]" class="block-vorschlag">
<div v-if="vorschlaege[i].laeuft" class="bv-status">Prüfe Abschnitt</div>
<template v-else>
<p v-if="vorschlaege[i].fehler" class="bv-fehler">{{ vorschlaege[i].fehler }}</p>
<div class="markdown bv-neu" v-html="renderMarkdown(vorschlaege[i].neu)"></div>
<div class="bv-aktionen">
<button class="bv-btn ja" title="Übernehmen" @click="blockUebernehmen(i)"></button>
<button class="bv-btn" title="Verwerfen" @click="blockVerwerfen(i)"></button>
<button class="bv-btn" :class="{ aktiv: vorschlaege[i].editOffen }" title="Hinweis ergänzen" @click="blockEdit(i)"></button>
</div>
<div v-if="vorschlaege[i].editOffen" class="bv-edit">
<input v-model="vorschlaege[i].hinweis" class="bv-input" placeholder="Zusatz-Info → erneut prüfen" @keyup.enter="blockEditSenden(i)" />
<button class="bv-btn ja" title="Erneut prüfen" @click="blockEditSenden(i)"></button>
</div>
</template>
</div>
</template>
</div>
<template v-if="artefakte">
<WorkedExampleBlock :beispiele="artefakte.beispiel || []" />
<FlashcardWidget :karten="artefakte.karteikarte || []" />
</template>
</div>
<div v-if="menu.show" class="menu-overlay" @click="menueZu" @contextmenu.prevent="menueZu">
<div class="block-menu" :style="{ top: menu.y + 'px', left: menu.x + 'px' }" @click.stop>
<button class="bm-item" @click="blockPruefen(menu.index)">Prüfen</button>
</div>
</div>
<div ref="rightEl" class="fokus-col right">
<BausteinPanel
mode="full"
:key="baustein.title + '|' + tab + '|' + resetN"
:initial-tab="tab"
:topic="topic"
:baustein="baustein.title"
:section="baustein.md"
:section-kompakt="baustein.kompakt || ''"
:provider="provider"
:status="status"
:cap="cap"
:ansicht="ansicht"
@set-ansicht="$emit('setAnsicht', $event)"
@status-changed="$emit('statusChanged', $event)"
/>
</div>
</div>
</div>
</template>
<style scoped>
.fokus-overlay {
position: fixed;
inset: 0;
z-index: 40;
/* Halbtransparent + Blur: die Guide-Liste dahinter scheint verschwommen durch die grauen Flächen. */
background: color-mix(in srgb, var(--bg-preview) 60%, transparent);
backdrop-filter: blur(10px);
display: grid;
grid-template-rows: auto auto 1fr; /* Header / XP-Leiste / Body */
}
.fokus-bar {
padding: 0.5rem 0;
border-bottom: 1px solid var(--border);
background: var(--panel);
}
/* Header-Inhalt fluchtet mit dem Body (gleiche max-width + Innenabstand). */
.fokus-bar-inner {
display: flex;
align-items: center;
gap: 0.5rem;
max-width: 1600px;
width: 100%;
margin-inline: auto;
padding-inline: 1.25rem;
}
.fokus-spacer { flex: 1; }
.stand-badge {
margin-left: 0.5rem;
padding: 0.12rem 0.6rem;
font-size: 0.72rem; font-weight: 600;
border-radius: 999px; border: 1px solid; white-space: nowrap;
}
.stand-badge.gruen { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
.stand-badge.lila { background: color-mix(in srgb, #8b5cf6 16%, var(--panel)); border-color: #8b5cf6; color: #6d28d9; }
.stand-badge.gold { background: color-mix(in srgb, #d4af37 20%, var(--panel)); border-color: #d4af37; color: #8a6d12; }
/* Erfahrungsleiste oben: füllt sich von links — gold (gemeistert) → lila (verstanden) → grün (absolviert). */
.fokus-xp { position: relative; display: flex; height: 8px; background: var(--panel-soft); }
/* 9 Trennlinien alle 10 % → 10 sichtbare Abschnitte (Füllung bleibt durchgehend). */
.fokus-xp::after {
content: '';
position: absolute; inset: 0;
pointer-events: none;
background: repeating-linear-gradient(
to right,
transparent 0,
transparent calc(10% - 2px),
var(--panel) calc(10% - 2px),
var(--panel) 10%
);
}
.xp-seg { height: 100%; transition: width 0.3s ease; }
.xp-seg.gold { background: #d4af37; }
.xp-seg.lila { background: #8b5cf6; }
.xp-seg.gruen { background: var(--success-border); }
.fokus-titel { font-weight: 600; font-size: 0.95rem; margin-left: 0.5rem; }
.fokus-btn {
display: inline-flex; align-items: center; justify-content: center;
min-width: 2rem; height: 2rem; padding: 0 0.5rem;
font-size: 1rem;
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--panel);
color: var(--text);
cursor: pointer;
}
.fokus-btn:hover { border-color: var(--accent); }
.fokus-btn:disabled { opacity: 0.4; cursor: default; }
.fokus-btn.armed { border-color: var(--danger, #dc2626); color: var(--danger, #dc2626); background: color-mix(in srgb, var(--danger, #dc2626) 12%, var(--panel)); }
/* Zwei abgehobene Karten auf grauem „Schreibtisch" (--bg-preview vom Overlay). */
.fokus-body {
min-height: 0;
display: flex;
gap: 1.25rem;
padding: 1.25rem;
max-width: 1600px;
width: 100%;
margin-inline: auto;
}
.fokus-col {
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
/* Karte kippt nach Baustein-Stand: farbiger Rahmen + getönter Hintergrund (grün/lila/gold). */
background: color-mix(in srgb, var(--stand) 7%, var(--panel));
border: 1px solid var(--stand);
border-top: 3px solid var(--stand);
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
/* Links: breite Lese-Karte, Text in Lesebreite zentriert. */
.fokus-col.left { flex: 1; padding: 2rem 2.5rem; }
.fokus-col.left > * { max-width: 74ch; margin-inline: auto; }
/* Rechts: Arbeits-Karte; Panel darin randlos (Karte ist der Rahmen). */
.fokus-col.right { flex: none; width: clamp(440px, 34%, 600px); padding: 1.5rem; }
.fokus-col.right :deep(.bp) { margin-top: 0; }
.fokus-col.right :deep(.bp-panel) { border: none; background: transparent; padding: 0; }
.fokus-h2 { font-size: 1.1rem; margin: 0 0 0.75rem; }
/* Desktop: die rechte Karte als Flex-Spalte — Prüfungs-/Chat-Verlauf füllt die
volle Höhe und scrollt intern, Eingabe + Buttons bleiben unten. */
@media (min-width: 901px) {
.fokus-col.right { display: flex; flex-direction: column; overflow: hidden; }
.fokus-col.right :deep(.bp) { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.fokus-col.right :deep(.bp-panel) { flex: 1; display: flex; flex-direction: column; min-height: 0; overflow-y: auto; }
.fokus-col.right :deep(.bp-panel > div) { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.fokus-col.right :deep(.bp-messages) { flex: 1; min-height: 0; max-height: none; }
}
@media (max-width: 900px) {
.fokus-body { flex-direction: column; overflow-y: auto; }
.fokus-col { overflow-y: visible; }
.fokus-col.right { width: auto; }
}
/* Block-Prüfen: Rechtsklick-Menü + Vorschlag unter dem Abschnitt */
/* Abstand am Wrapper führen — inneres p ist jetzt :last-child (Margin 0). */
.md-block { border-radius: 6px; transition: background 0.15s; margin-bottom: 0.8em; }
.md-block:last-child { margin-bottom: 0; }
.md-block > :first-child { margin-top: 0; }
.md-block > :last-child { margin-bottom: 0; }
.md-block:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); }
.menu-overlay { position: fixed; inset: 0; z-index: 60; }
.block-menu {
position: fixed;
min-width: 8rem;
padding: 0.25rem;
background: var(--panel);
border: 1px solid var(--border-strong);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
}
.bm-item {
display: block;
width: 100%;
padding: 0.4rem 0.7rem;
text-align: left;
font-size: 0.9rem;
border: none;
border-radius: 5px;
background: transparent;
color: var(--text);
cursor: pointer;
}
.bm-item:hover { background: color-mix(in srgb, var(--accent) 14%, transparent); }
.block-vorschlag {
margin: 0.5rem 0 1rem;
padding: 0.75rem 1rem;
border: 1px solid var(--accent);
border-left: 3px solid var(--accent);
border-radius: 8px;
background: color-mix(in srgb, var(--accent) 6%, var(--panel));
}
.bv-status { font-size: 0.9rem; color: var(--text-soft, #888); }
.bv-fehler { color: var(--danger, #dc2626); font-size: 0.88rem; margin: 0 0 0.5rem; }
.bv-neu > :first-child { margin-top: 0; }
.bv-neu > :last-child { margin-bottom: 0; }
.bv-aktionen { display: flex; gap: 0.4rem; margin-top: 0.6rem; }
.bv-btn {
min-width: 2rem; height: 2rem; padding: 0 0.5rem;
font-size: 0.95rem;
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--panel);
color: var(--text);
cursor: pointer;
}
.bv-btn:hover { border-color: var(--accent); }
.bv-btn.aktiv { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, var(--panel)); }
.bv-btn.ja { border-color: var(--success-border); color: var(--success); }
.bv-edit { display: flex; gap: 0.4rem; margin-top: 0.5rem; }
.bv-input {
flex: 1;
padding: 0.4rem 0.6rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel);
color: var(--text);
font-size: 0.9rem;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,418 @@
<script setup>
import BlockPanel from './BlockPanel.vue'
import FlashcardWidget from './FlashcardWidget.vue'
import WorkedExampleBlock from './WorkedExampleBlock.vue'
import { renderMarkdown, renderBlocks } from '../markdown.js'
import { stufeFuer, LEVELS } from '../levels.js'
import { pruefeBlock, uebernehmeBlock, resetBlockProgress } from '../api.js'
import { clearPruef } from '../pruefungCache.js'
import { useConfirm } from '../composables/useConfirm.js'
const props = defineProps({
block: { type: Object, required: true }, // { title, md, num }
artefakte: { type: Object, default: null }, // { flashcard[], example[], diagramm } for this block
topic: { type: String, required: true },
provider: { type: String, default: 'claude' },
status: { type: Object, default: null },
cap: { type: Number, default: 6 },
guideId: { type: String, default: '' },
tab: { type: String, default: 'exam' },
ansicht: { type: String, default: 'compact' }, // compact | erklärend — left guide column
hasPrev: { type: Boolean, default: false },
hasNext: { type: Boolean, default: false },
fortschritt: { type: Object, default: () => ({ total: 0, beginner: 0, advanced: 0, expert: 0, master: 0 }) },
})
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
const emit = defineEmits(['close', 'prev', 'next', 'statusChanged', 'setAnsicht', 'openSidebar', 'sectionUpdated'])
// --- Reset per block: questions session (slot) resp. progress (score) ---
const { isArmed, armOrRun } = useConfirm()
const resetN = ref(0) // increment → panel remount (fresh slot, patterns reset)
const examKey = computed(() => `${props.topic}::${props.block.title}`)
// New question session: discard slot → remount loads patterns + pool fresh (fixes explain-only).
function resetQuestions() {
clearPruef(examKey.value)
resetN.value++
}
// Progress to 0: delete DB row, zero badge/learn state, fresh session.
async function resetProgress() {
try {
await resetBlockProgress(props.topic, props.block.title)
emit('statusChanged', { block: props.block.title, good_answers: 0, streak: 0, cap: props.cap })
clearPruef(examKey.value)
resetN.value++
} catch (e) { /* stay quiet — reset failed */ }
}
const guideEl = ref(null) // left guide column (scroll target for ALT+↑/↓)
const rightEl = ref(null) // right column (exam panel with input field)
// ALT is the modifier of the focus view. Plain arrows stay browser-default.
// ALT+←/→ pages blocks, ALT+↑/↓ scrolls the guide. A bare ALT tap
// (press+release without another key) toggles focus on the input field.
let altAlone = false
function onKeyDown(e) {
if (e.key === 'Alt') { altAlone = true; e.preventDefault(); return } // suppresses the Firefox menu bar
if (e.ctrlKey || e.metaKey || !e.altKey) return // plain/other → normal
altAlone = false // ALT+anything = no lone tap
if (e.key === 'ArrowLeft') { e.preventDefault(); if (!e.repeat && props.hasPrev) emit('prev') }
else if (e.key === 'ArrowRight') { e.preventDefault(); if (!e.repeat && props.hasNext) emit('next') }
else if (e.key === 'ArrowUp') { e.preventDefault(); guideEl.value?.scrollBy(0, -120) }
else if (e.key === 'ArrowDown') { e.preventDefault(); guideEl.value?.scrollBy(0, 120) }
}
function onKeyUp(e) {
if (e.key === 'Alt') { e.preventDefault(); if (altAlone) { altAlone = false; toggleInput() } }
}
function resetAlt() { altAlone = false } // window blur (ALT+Tab) → no false tap
function toggleInput() {
// Answer field of the current form: explain = textarea, free gap-text = input.
const el = rightEl.value?.querySelector('textarea, input')
if (!el) return
document.activeElement === el ? el.blur() : el.focus()
}
onMounted(() => {
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
window.addEventListener('blur', resetAlt)
})
onUnmounted(() => {
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', resetAlt)
})
const pct = (n) => (100 * n / (props.fortschritt.total || 1)) + '%'
// Accent color by the current block's level (green/blue/purple/gold) or neutral.
const levelColor = computed(() => {
const s = props.status || {}
return stufeFuer(s.good_answers || 0, s.cap || props.cap)?.farbe || 'var(--border)'
})
const level = computed(() => {
const s = props.status || {}
return stufeFuer(s.good_answers || 0, s.cap || props.cap)
})
// --- Check block (right-click on a section) ---
const displayedText = computed(() =>
props.ansicht === 'compact' ? (props.block.compact || props.block.md) : props.block.md,
)
const blocks = computed(() => renderBlocks(displayedText.value))
// Field that edits are applied to (must match the displayed text).
const spot = computed(() => (props.ansicht === 'compact' && props.block.compact) ? 'compact' : 'ausführlich')
const menu = reactive({ show: false, x: 0, y: 0, index: null })
function blockMenu(i, e) { menu.show = true; menu.x = e.clientX; menu.y = e.clientY; menu.index = i }
function closeMenu() { menu.show = false }
// Suggestion per block index: { new, running, error, editOpen, hint }
const suggestions = reactive({})
async function checkBlock(i, extra = '') {
const raw = blocks.value[i]?.raw
if (!raw || !props.guideId) return
closeMenu()
suggestions[i] = { revised: '', running: true, error: '', editOpen: false, hint: '' }
try {
const res = await pruefeBlock(props.guideId, { block: props.block.title, spot: spot.value, snippet: raw, hint: extra, provider: props.provider })
suggestions[i] = { revised: res.revised, running: false, error: '', editOpen: false, hint: '' }
} catch (e) {
suggestions[i] = { revised: '', running: false, error: e.message || 'Exam failed', editOpen: false, hint: '' }
}
}
async function applyBlock(i) {
const v = suggestions[i]; const raw = blocks.value[i]?.raw
if (!v || v.running || !raw) return
v.running = true; v.error = ''
try {
const res = await uebernehmeBlock(props.guideId, { block: props.block.title, spot: spot.value, alt: raw, revised: v.revised, provider: props.provider })
if (res.found) emit('sectionUpdated', { title: props.block.title, compact: res.compact, md: res.md })
else { v.running = false; v.error = 'Spot not found — may have already changed.' }
} catch (e) { v.running = false; v.error = e.message || 'Apply failed' }
}
function discardBlock(i) { delete suggestions[i] }
function editBlock(i) { const v = suggestions[i]; if (v) v.editOpen = !v.editOpen }
function sendBlockEdit(i) {
const z = (suggestions[i]?.hint || '').trim()
if (z) checkBlock(i, z)
}
// Block/content switch → reset suggestions + menu (block indices change).
watch(() => `${props.block.title}|${props.block.md}|${props.block.compact || ''}`, () => {
for (const k of Object.keys(suggestions)) delete suggestions[k]
closeMenu()
})
</script>
<template>
<div class="fokus-overlay" :style="{ '--stand': levelColor }">
<div class="fokus-bar">
<div class="fokus-bar-inner">
<button class="fokus-btn" title="Open navigation" @click="$emit('openSidebar')"></button>
<button class="fokus-btn" :disabled="!hasPrev" title="Previous block" @click="$emit('prev')"></button>
<button class="fokus-btn" :disabled="!hasNext" title="Next block" @click="$emit('next')"></button>
<span class="fokus-title">{{ block.title }}</span>
<span v-if="level" class="stand-badge" :style="{ color: level.farbe, borderColor: level.farbe }">{{ level.kurz }} {{ level.label }}</span>
<span class="fokus-spacer"></span>
<button class="fokus-btn" title="Reset questions (new question session)" @click="resetQuestions"></button>
<button
class="fokus-btn"
:class="{ armed: isArmed('reset-fortschritt') }"
:title="isArmed('reset-fortschritt') ? 'Click again: set progress to 0' : 'Reset progress (score to 0)'"
@click="armOrRun('reset-fortschritt', resetProgress)"
></button>
<button class="fokus-btn" title="Exit full view" @click="$emit('close')"></button>
</div>
</div>
<div class="fokus-xp" :title="`${fortschritt.beginner}/${fortschritt.total} from Beginner · ${fortschritt.advanced} Advanced · ${fortschritt.expert} Expert · ${fortschritt.master} Master`">
<div class="xp-seg" :style="{ width: pct(fortschritt.master), background: 'var(--level-master)' }"></div>
<div class="xp-seg" :style="{ width: pct(fortschritt.expert - fortschritt.master), background: 'var(--level-expert)' }"></div>
<div class="xp-seg" :style="{ width: pct(fortschritt.advanced - fortschritt.expert), background: 'var(--level-advanced)' }"></div>
<div class="xp-seg" :style="{ width: pct(fortschritt.beginner - fortschritt.advanced), background: 'var(--level-beginner)' }"></div>
</div>
<div class="fokus-body">
<div ref="guideEl" class="fokus-col left">
<div class="markdown">
<template v-for="(b, i) in blocks" :key="i">
<div class="md-block" v-html="b.html" @contextmenu.prevent="blockMenu(i, $event)"></div>
<div v-if="suggestions[i]" class="block-vorschlag">
<div v-if="suggestions[i].running" class="bv-status">Check Section</div>
<template v-else>
<p v-if="suggestions[i].error" class="bv-fehler">{{ suggestions[i].error }}</p>
<div class="markdown bv-new" v-html="renderMarkdown(suggestions[i].revised)"></div>
<div class="bv-aktionen">
<button class="bv-btn ja" title="Apply" @click="applyBlock(i)"></button>
<button class="bv-btn" title="Discard" @click="discardBlock(i)"></button>
<button class="bv-btn" :class="{ aktiv: suggestions[i].editOpen }" title="Add hint" @click="editBlock(i)"></button>
</div>
<div v-if="suggestions[i].editOpen" class="bv-edit">
<input v-model="suggestions[i].hint" class="bv-input" placeholder="Extra info → check again" @keyup.enter="sendBlockEdit(i)" />
<button class="bv-btn ja" title="Check again" @click="sendBlockEdit(i)"></button>
</div>
</template>
</div>
</template>
</div>
<template v-if="artefakte">
<WorkedExampleBlock :examples="artefakte.example || []" />
<FlashcardWidget :cards="artefakte.flashcard || []" />
</template>
</div>
<div v-if="menu.show" class="menu-overlay" @click="closeMenu" @contextmenu.prevent="closeMenu">
<div class="block-menu" :style="{ top: menu.y + 'px', left: menu.x + 'px' }" @click.stop>
<button class="bm-item" @click="checkBlock(menu.index)">Check</button>
</div>
</div>
<div ref="rightEl" class="fokus-col right">
<BlockPanel
mode="full"
:key="block.title + '|' + tab + '|' + resetN"
:initial-tab="tab"
:topic="topic"
:block="block.title"
:section="block.md"
:section-compact="block.compact || ''"
:provider="provider"
:status="status"
:cap="cap"
:ansicht="ansicht"
@set-ansicht="$emit('setAnsicht', $event)"
@status-changed="$emit('statusChanged', $event)"
/>
</div>
</div>
</div>
</template>
<style scoped>
.fokus-overlay {
position: fixed;
inset: 0;
z-index: 40;
/* Semi-transparent + blur: the guide list behind shows blurred through the gray surfaces. */
background: color-mix(in srgb, var(--bg-preview) 60%, transparent);
backdrop-filter: blur(10px);
display: grid;
grid-template-rows: auto auto 1fr; /* header / XP bar / body */
}
.fokus-bar {
padding: 0.5rem 0;
border-bottom: 1px solid var(--border);
background: var(--panel);
}
/* Header content aligns with the body (same max-width + inner padding). */
.fokus-bar-inner {
display: flex;
align-items: center;
gap: 0.5rem;
max-width: 1600px;
width: 100%;
margin-inline: auto;
padding-inline: 1.25rem;
}
.fokus-spacer { flex: 1; }
.stand-badge {
margin-left: 0.5rem;
padding: 0.12rem 0.6rem;
font-size: 0.72rem; font-weight: 600;
border-radius: 999px; border: 1px solid; white-space: nowrap;
}
.stand-badge.gruen { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
.stand-badge.lila { background: color-mix(in srgb, #8b5cf6 16%, var(--panel)); border-color: #8b5cf6; color: #6d28d9; }
.stand-badge.gold { background: color-mix(in srgb, #d4af37 20%, var(--panel)); border-color: #d4af37; color: #8a6d12; }
/* Experience bar on top: fills from the left — gold (mastered) → purple (understood) → green (completed). */
.fokus-xp { position: relative; display: flex; height: 8px; background: var(--panel-soft); }
/* 9 divider lines every 10% → 10 visible segments (fill stays continuous). */
.fokus-xp::after {
content: '';
position: absolute; inset: 0;
pointer-events: none;
background: repeating-linear-gradient(
to right,
transparent 0,
transparent calc(10% - 2px),
var(--panel) calc(10% - 2px),
var(--panel) 10%
);
}
.xp-seg { height: 100%; transition: width 0.3s ease; }
.xp-seg.gold { background: #d4af37; }
.xp-seg.lila { background: #8b5cf6; }
.xp-seg.gruen { background: var(--success-border); }
.fokus-title { font-weight: 600; font-size: 0.95rem; margin-left: 0.5rem; }
.fokus-btn {
display: inline-flex; align-items: center; justify-content: center;
min-width: 2rem; height: 2rem; padding: 0 0.5rem;
font-size: 1rem;
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--panel);
color: var(--text);
cursor: pointer;
}
.fokus-btn:hover { border-color: var(--accent); }
.fokus-btn:disabled { opacity: 0.4; cursor: default; }
.fokus-btn.armed { border-color: var(--danger, #dc2626); color: var(--danger, #dc2626); background: color-mix(in srgb, var(--danger, #dc2626) 12%, var(--panel)); }
/* Two raised cards on a gray "desk" (--bg-preview from the overlay). */
.fokus-body {
min-height: 0;
display: flex;
gap: 1.25rem;
padding: 1.25rem;
max-width: 1600px;
width: 100%;
margin-inline: auto;
}
.fokus-col {
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
/* Card tilts by block level: colored border + tinted background (green/purple/gold). */
background: color-mix(in srgb, var(--stand) 7%, var(--panel));
border: 1px solid var(--stand);
border-top: 3px solid var(--stand);
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
/* Left: wide reading card, text centered at reading width. */
.fokus-col.left { flex: 1; padding: 2rem 2.5rem; }
.fokus-col.left > * { max-width: 74ch; margin-inline: auto; }
/* Right: work card; the panel inside is borderless (the card is the frame). */
.fokus-col.right { flex: none; width: clamp(440px, 34%, 600px); padding: 1.5rem; }
.fokus-col.right :deep(.bp) { margin-top: 0; }
.fokus-col.right :deep(.bp-panel) { border: none; background: transparent; padding: 0; }
.fokus-h2 { font-size: 1.1rem; margin: 0 0 0.75rem; }
/* Desktop: the right card as a flex column — exam/chat history fills the
full height and scrolls internally, input + buttons stay at the bottom. */
@media (min-width: 901px) {
.fokus-col.right { display: flex; flex-direction: column; overflow: hidden; }
.fokus-col.right :deep(.bp) { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.fokus-col.right :deep(.bp-panel) { flex: 1; display: flex; flex-direction: column; min-height: 0; overflow-y: auto; }
.fokus-col.right :deep(.bp-panel > div) { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.fokus-col.right :deep(.bp-messages) { flex: 1; min-height: 0; max-height: none; }
}
@media (max-width: 900px) {
.fokus-body { flex-direction: column; overflow-y: auto; }
.fokus-col { overflow-y: visible; }
.fokus-col.right { width: auto; }
}
/* Check block: right-click menu + suggestion below the section */
/* Keep the spacing on the wrapper — the inner p is now :last-child (margin 0). */
.md-block { border-radius: 6px; transition: background 0.15s; margin-bottom: 0.8em; }
.md-block:last-child { margin-bottom: 0; }
.md-block > :first-child { margin-top: 0; }
.md-block > :last-child { margin-bottom: 0; }
.md-block:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); }
.menu-overlay { position: fixed; inset: 0; z-index: 60; }
.block-menu {
position: fixed;
min-width: 8rem;
padding: 0.25rem;
background: var(--panel);
border: 1px solid var(--border-strong);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
}
.bm-item {
display: block;
width: 100%;
padding: 0.4rem 0.7rem;
text-align: left;
font-size: 0.9rem;
border: none;
border-radius: 5px;
background: transparent;
color: var(--text);
cursor: pointer;
}
.bm-item:hover { background: color-mix(in srgb, var(--accent) 14%, transparent); }
.block-vorschlag {
margin: 0.5rem 0 1rem;
padding: 0.75rem 1rem;
border: 1px solid var(--accent);
border-left: 3px solid var(--accent);
border-radius: 8px;
background: color-mix(in srgb, var(--accent) 6%, var(--panel));
}
.bv-status { font-size: 0.9rem; color: var(--text-soft, #888); }
.bv-fehler { color: var(--danger, #dc2626); font-size: 0.88rem; margin: 0 0 0.5rem; }
.bv-new > :first-child { margin-top: 0; }
.bv-new > :last-child { margin-bottom: 0; }
.bv-aktionen { display: flex; gap: 0.4rem; margin-top: 0.6rem; }
.bv-btn {
min-width: 2rem; height: 2rem; padding: 0 0.5rem;
font-size: 0.95rem;
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--panel);
color: var(--text);
cursor: pointer;
}
.bv-btn:hover { border-color: var(--accent); }
.bv-btn.aktiv { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, var(--panel)); }
.bv-btn.ja { border-color: var(--success-border); color: var(--success); }
.bv-edit { display: flex; gap: 0.4rem; margin-top: 0.5rem; }
.bv-input {
flex: 1;
padding: 0.4rem 0.6rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel);
color: var(--text);
font-size: 0.9rem;
}
</style>

View File

@@ -0,0 +1,966 @@
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { chatBlock, examBlock, fetchQuestionPattern } from '../api.js'
import { usePruefSlot } from '../pruefungCache.js'
import { renderMarkdown, renderMarkdownInline } from '../markdown.js'
import { stufeFuer, malusRegel } from '../levels.js'
import { useChat, istUnten } from '../composables/useChat.js'
const props = defineProps({
topic: { type: String, required: true },
block: { type: String, required: true },
section: { type: String, default: '' }, // detailed version
sectionKompakt: { type: String, default: '' }, // compact version (key points) — exam/chat context
provider: { type: String, default: 'claude' },
status: { type: Object, default: null }, // {good_answers, streak, completed, understood, mastered}
cap: { type: Number, default: 6 }, // score cap = max of the highest format (6/12/18/30)
// 'trigger' (list): only tab bar, click opens the fullscreen focus.
// 'full' (focus): tabs + panel content as before.
mode: { type: String, default: 'trigger' },
initialTab: { type: String, default: null },
ansicht: { type: String, default: 'compact' }, // compact | erklärend — controls the left guide column
})
const emit = defineEmits(['statusChanged', 'openFokus', 'setAnsicht'])
// Learning levels relative to the cap (levels.js): green/blue/purple/gold @ 20/40/60/100 %.
// Exam is ALWAYS random from 5 forms (with pattern); cap = 4×relevant subblocks.
// Four question forms, random. No more easy/hard — the difficulty is set by the backend
// via the learner level (addressee role). gapchoice = term choice · gaptext = free.
const MODES = ['quiz', 'gapchoice', 'gaptext', 'erklaeren']
const st = computed(() => props.status || { good_answers: 0, streak: 0, cap: props.cap })
const score = computed(() => st.value.good_answers || 0)
const cap = computed(() => st.value.cap || props.cap)
const scoreDisplay = computed(() => Math.min(score.value, cap.value)) // legacy data may exceed cap
const level = computed(() => stufeFuer(score.value, cap.value)) // object {key,label,kurz,farbe} | null
const atCap = computed(() => score.value >= cap.value)
// With question pattern random from 5 forms; without pattern (old block) only Explain.
const currentBand = computed(() => patternMode.value ? 'zufall' : 'erklaeren')
const inRandom = computed(() => currentBand.value === 'zufall')
const activeMode = computed(() => currentBand.value) // 'zufall' | 'erklaeren'
const activeForm = computed(() => inRandom.value ? 'quiz' : 'erklaeren') // placeholder; drawn object overrides
// Displayed form: follows the active form, freezes while a drawn question is visible.
const displayForm = ref(null)
const shownForm = computed(() => displayForm.value || activeForm.value)
const displayBand = ref(null)
const sectionChange = computed(() => displayBand.value && displayBand.value !== currentBand.value)
// Difficulty of the currently shown widget (from the drawn object).
const currentHard = computed(() => {
if (shownForm.value === 'quiz' && quizCurrent.value) return quizCurrent.value.schwer
if (shownForm.value === 'gaptext' && clozeCurrent.value) return clozeCurrent.value.schwer
return false
})
const modeRule = computed(() => {
const m = malusRegel(score.value, cap.value) // '0' | '1' | '2' | '3' by progress
if (shownForm.value === 'quiz') return currentHard.value ? `x of 4 · +3/${m}` : `1 of 4 · +1/${m}`
if (shownForm.value === 'gaptext') return currentHard.value ? `free text · +3/${m}` : `term from 4 · +1/${m}`
return `+1/+2/+3 · error ${m}`
})
const bandTarget = computed(() => cap.value)
const FORM_NAME = { quiz: 'Quiz', gaptext: 'Cloze', erklaeren: 'Explain' }
// --- Toggle area ---
const activeTab = ref(props.mode === 'full' ? props.initialTab : null) // null | 'chat' | 'exam'
// Click on a tab: in the list open the focus, in the focus toggle the tab.
function tabClick(tab) {
if (props.mode === 'trigger') { emit('openFokus', tab); return }
activeTab.value = activeTab.value === tab ? null : tab
}
// --- Block chat (ephemeral) ---
const chat = useChat((msgs) => chatBlock({
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
messages: msgs, provider: props.provider,
}))
// --- Exam: guided dialog ---
// Phases: 'idle' (request question) | 'question_offen' (answer/ask) | 'bewertet' (discuss/re-evaluate/continue)
// Durable state lives in a SHARED slot per block (module store): survives
// remounting and a late-arriving question is immediately reactive and visible.
const examKey = `${props.topic}::${props.block}`
const slot = usePruefSlot(examKey)
const examMessages = slot.messages // {role, kind: 'question'|'nachfrage'|'answer'|'feedback'|'discussion'|'fehler', content, rating?}
const examPhase = slot.phase
const currentQuestion = slot.aktuelleFrage // anchors evaluation/discussion
const lastFeedback = slot.letztesFeedback // context for the discussion about an evaluation
const patternSource = slot.musterQuelle // immutable full list of question patterns (empty = fallback)
const patternPool = slot.musterPool // working copy, without replacement; empty → reset
const patternLoaded = slot.musterGeladen
const poolForm = slot.poolForm // form the pool is filled for
const quizCurrent = slot.quizAktuell // running quiz question (widget state)
const clozeCurrent = slot.lueckAktuell // running cloze task (widget state)
// Transient (per instance): input draft, loading spinner.
const examInput = ref('')
const examLoading = ref(false)
const asked_again = ref(false) // the running question used asked_again → gain max +1
const thoroughMsg = ref(null) // feedback bubble whose thorough field is open
const thoroughText = ref('') // optional reason for "check thoroughly"
const examMessagesEl = ref(null)
const examInputEl = ref(null)
const examStick = ref(true) // only auto-scroll when the user is (almost) at the bottom
let examRun = 0
function onExamScroll() {
if (examMessagesEl.value) examStick.value = istUnten(examMessagesEl.value)
}
function applyExam(res) {
// res.streak only set on answer_check (binding, persisted); otherwise leave streak unchanged.
emit('statusChanged', { block: props.block, ...st.value, good_answers: res.good_answers, streak: res.streak ?? st.value.streak, cap: res.cap ?? cap.value })
}
async function examScroll() {
await nextTick()
if (examMessagesEl.value && examStick.value) examMessagesEl.value.scrollTop = examMessagesEl.value.scrollHeight
}
// Only real conversation turns go to the backend; feedback stays a pure UI artifact.
function examDialog() {
return examMessages.value
.filter((m) => m.kind !== 'feedback' && m.kind !== 'fehler')
.map((m) => ({ role: m.role, content: m.content }))
}
async function examSend(payload, onOk) {
const run = ++examRun
examStick.value = true // own action = to the end; scrolling up while waiting sets it back to false
examLoading.value = true
examScroll()
try {
const res = await examBlock({
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
provider: props.provider, messages: examDialog(), ...payload,
})
if (run !== examRun) return
onOk(res)
applyExam(res)
examScroll()
nextTick(() => examInputEl.value?.focus())
} catch {
if (run === examRun) examMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Didn\'t work — please try again.' })
} finally {
if (run === examRun) examLoading.value = false
}
}
// Show question (fresh or queued). Base/streak is tracked by the server per question.
function showQuestion(text) {
currentQuestion.value = text
lastFeedback.value = ''
asked_again.value = false
thoroughMsg.value = null
examMessages.value.push({ role: 'assistant', kind: 'question', content: text })
examPhase.value = 'question_offen'
}
// Dedup: detect already-asked questions (the generator repeats itself on small blocks).
function normQuestion(t) { return (t || '').toLowerCase().replace(/\s+/g, ' ').trim() }
function askedQuestions() {
return new Set(examMessages.value.filter((m) => m.kind === 'question').map((m) => normQuestion(m.content)))
}
// --- Questions pool: TARGET pre-formulated question OBJECTS of the active form (in the slot) ---
// Each object: {form, question, options?|sentence?|solution?|alternatives?}. When the
// active form changes, the pool is cleared and refilled for the new form.
const POOL_TARGET = 5
const pool = slot.pool
const inflight = slot.inflight
const examError = ref('') // error hint for quiz/cloze (no bubble history)
let poolMiss = 0 // fallback only: consecutive duplicates/errors — cap against an infinite loop
// Pattern mode active once loaded and patterns present; otherwise fallback (only Explain).
const patternMode = computed(() => patternLoaded.value && patternSource.value.length > 0)
function objText(o) { return (o && (o.question || o.sentence)) || '' }
// Render cloze sentence: split at "___", parts inline (with $…$ math), gap as a span.
// This keeps the gap and markdown does not touch the underscores.
function clozeSentenceHtml(sentence) {
sentence = String(sentence || '')
const m = sentence.match(/_{3,}/) // first gap (three or more underscores)
if (!m) return renderMarkdownInline(sentence)
// Is the gap INSIDE a $…$ formula (odd number of $ before it)? Then do not split
// (that tears the formula) — instead replace it with a KaTeX line, render the sentence whole.
const inFormula = ((sentence.slice(0, m.index).match(/\$/g) || []).length % 2) === 1
if (inFormula) return renderMarkdownInline(sentence.replace(/_{3,}/, '\\rule{2.5em}{0.4pt}'))
// Gap in text: split + gap span.
return sentence.split(/_{3,}/).map((t) => renderMarkdownInline(t)).join('<span class="bp-luecke">______</span>')
}
function isKnown(o) {
const n = normQuestion(objText(o))
return !n || askedQuestions().has(n) || pool.value.some((f) => normQuestion(objText(f)) === n)
}
// Already-asked + queued questions — fallback mode only (Explain without pattern).
function avoidList() {
const asked = examMessages.value.filter((m) => m.kind === 'question').map((m) => m.content)
return [...asked, ...pool.value.map(objText)]
}
// Load pattern sidecar once per block (slot-cached). Empty → fallback.
async function loadPatterns() {
if (patternLoaded.value) return
try {
const res = await fetchQuestionPattern(props.topic, props.block)
patternSource.value = (res.pattern || []).map((m) => (m.question || '').trim()).filter(Boolean)
patternLoaded.value = true // ONLY on success — otherwise a failed attempt freezes the session to Explain-only
} catch {
patternSource.value = [] // patternLoaded stays false → next turn retries
}
}
function mischen(arr) { // Fisher-Yates
const a = [...arr]
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[a[i], a[j]] = [a[j], a[i]]
}
return a
}
// Draw a pattern without replacement; pool empty → reset (reshuffle). null = fallback.
function takePattern() {
if (!patternSource.value.length) return null
if (!patternPool.value.length) patternPool.value = mischen(patternSource.value)
return patternPool.value.shift()
}
// Pool key = form + difficulty; in the random band a mixed pool ('zufall').
const poolKey = computed(() => inRandom.value ? 'zufall' : 'erklaeren')
// Mode of the next question: in the random band (score 1930) random from all 5 modes.
function nextMode() {
return inRandom.value ? MODES[Math.floor(Math.random() * MODES.length)] : activeMode.value
}
// Generate a question object of a given mode (from a drawn seed; Explain has a fallback).
function buildSingleQuestion(mode = nextMode()) {
const form = mode === 'quiz' ? 'quiz' : mode.startsWith('gap') ? 'gaptext' : 'erklaeren'
const istLueckFrei = mode === 'gaptext' // free text vs. gapchoice = term from 4
const pattern = takePattern()
const base = {
topic: props.topic, block: props.block, section: props.section,
section_compact: props.sectionKompakt, provider: props.provider,
}
if (form === 'quiz' && pattern) {
return examBlock({ ...base, action: 'quiz_question', pattern }) // single choice, level controls
.then((r) => ({ form, schwer: false, question: r.question, options: r.options }))
}
if (form === 'gaptext' && pattern) {
return examBlock({ ...base, action: 'gap_question', pattern, schwer: istLueckFrei })
.then((r) => istLueckFrei
? ({ form, schwer: true, question: r.sentence, sentence: r.sentence, solution: r.solution, alternatives: r.alternatives })
: ({ form, schwer: false, question: r.sentence, sentence: r.sentence, options: r.options }))
}
if (pattern) { // erklaeren from pattern
return examBlock({ ...base, action: 'question', pattern }).then((r) => ({ form: 'erklaeren', question: r.question }))
}
// Fallback (no pattern sidecar): Explain live with an avoid list.
return examBlock({ ...base, action: 'question', messages: examDialog(), avoid: avoidList() })
.then((r) => ({ form: 'erklaeren', question: r.question }))
}
// Clear the pool when form or difficulty changed (band boundary or ALT+E).
function checkPoolForm() {
if (poolForm.value !== poolKey.value) {
pool.value = []
poolForm.value = poolKey.value
poolMiss = 0
}
}
// Fill the pool in PARALLEL: each draws a DISTINCT pattern → no collision.
function fillPool() {
checkPoolForm()
const keyStart = poolKey.value
const missCap = patternMode.value ? Infinity : 3
while (pool.value.length + inflight.value < POOL_TARGET && poolMiss < missCap) {
inflight.value++
buildSingleQuestion()
.then((obj) => {
if (obj && objText(obj) && poolKey.value === keyStart && (patternMode.value || !isKnown(obj))) { pool.value.push(obj); poolMiss = 0 }
else poolMiss++
})
.catch(() => { poolMiss++ })
.finally(() => { inflight.value--; fillPool() })
}
}
// Draw a question from the pool — random, never the fastest.
// First load (pool empty + nothing in progress): generate 5, await ALL, then draw.
async function drawQuestion() {
const firstLoad = !pool.value.length && !inflight.value
fillPool() // start/keep up to 5
examLoading.value = true
try {
const missCap = patternMode.value ? Infinity : 3
if (firstLoad) {
// only at the start: wait until all 5 are done (speed must not decide the form)
while ((inflight.value > 0 || pool.value.length < POOL_TARGET) && poolMiss < missCap) {
if (!inflight.value) fillPool()
await new Promise((r) => setTimeout(r, 80))
}
} else if (!pool.value.length) {
// pool drained (fast user): wait for the first finished one
while (!pool.value.length && (inflight.value > 0 || poolMiss < missCap)) {
if (!inflight.value) fillPool()
await new Promise((r) => setTimeout(r, 80))
}
}
if (!pool.value.length) return null
const i = Math.floor(Math.random() * pool.value.length)
return pool.value.splice(i, 1)[0]
} finally { examLoading.value = false }
}
// Show a drawn object in the matching form (Explain bubble or quiz/cloze widget).
function showQuestionObj(obj) {
examError.value = ''
displayForm.value = obj.form // bind display to the drawn question (freeze the form)
displayBand.value = currentBand.value // band at draw time (before the answer)
if (obj.form === 'quiz') {
quizCurrent.value = { question: obj.question, options: obj.options, schwer: obj.schwer, gewaehlt: [], done: false, points: null, rating: null, feedback: '' }
} else if (obj.form === 'gaptext') {
clozeCurrent.value = { sentence: obj.sentence, schwer: obj.schwer, options: obj.options || null, solution: obj.solution || '', alternatives: obj.alternatives || [], input: '', gewaehlt: [], done: false, points: null, rating: null, feedback: '' }
if (obj.schwer) nextTick(() => document.getElementById('bp-gap-input')?.focus())
} else {
showQuestion(obj.question)
nextTick(() => examInputEl.value?.focus())
}
}
// First / next question: load patterns, roll the form, fetch exactly that form.
async function showNextQuestion() {
if (examLoading.value) return
poolMiss = 0
examError.value = ''
await loadPatterns()
const obj = await drawQuestion() // first load: await all 5; then draw randomly
if (obj) {
showQuestionObj(obj)
examScroll()
} else if (activeForm.value === 'erklaeren') {
examMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'No question — please try again.' })
} else {
examError.value = 'No question — please try again.'
}
fillPool()
}
const requestQuestion = showNextQuestion
// Milestone exceeded: release the freeze (result was visible) without drawing a new
// question → idle state of the new band ("request question").
function toNextSection() {
displayForm.value = null
displayBand.value = null
quizCurrent.value = null
clozeCurrent.value = null
examMessages.value = [] // discard the old Explain history (new section)
examPhase.value = 'idle'
examError.value = ''
}
// --- Quiz: easy = single select (exactly 1) · hard = multi select. Deterministic. ---
function quizToggle(i) {
const q = quizCurrent.value
if (!q || q.done) return
if (!q.schwer) { q.gewaehlt = q.gewaehlt.includes(i) ? [] : [i]; return }
const idx = q.gewaehlt.indexOf(i)
if (idx >= 0) q.gewaehlt.splice(idx, 1)
else q.gewaehlt.push(i)
}
async function quizAnswer() {
const q = quizCurrent.value
if (!q || q.done || examLoading.value) return
examLoading.value = true
examError.value = ''
try {
const correct = q.options.map((o, i) => (o.correct ? i : -1)).filter((i) => i >= 0)
const res = await examBlock({
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
provider: props.provider, action: 'quiz_answer', question: q.question, cap: props.cap,
selection: q.gewaehlt, correct, schwer: q.schwer,
})
q.done = true; q.points = res.points; q.rating = res.rating; q.feedback = res.feedback
applyExam(res)
} catch {
examError.value = 'Didn\'t work — please try again.'
} finally {
examLoading.value = false
}
}
// --- Cloze: easy = term from 4 (single select) · hard = free text ---
function clozeToggle(i) {
const l = clozeCurrent.value
if (!l || l.done || l.schwer) return
l.gewaehlt = l.gewaehlt.includes(i) ? [] : [i]
}
async function clozeAnswer() {
const l = clozeCurrent.value
if (!l || l.done || examLoading.value) return
if (l.schwer ? !l.input.trim() : !l.gewaehlt.length) return
examLoading.value = true
examError.value = ''
try {
const specific = l.schwer
? { schwer: true, solution: l.solution, alternatives: l.alternatives, input: l.input }
: { schwer: false, selection: l.gewaehlt, correct: l.options.map((o, i) => (o.correct ? i : -1)).filter((i) => i >= 0) }
const res = await examBlock({
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
provider: props.provider, action: 'gap_answer', question: l.sentence, cap: props.cap, ...specific,
})
l.done = true; l.points = res.points; l.rating = res.rating; l.feedback = res.feedback
applyExam(res)
} catch {
examError.value = 'Didn\'t work — please try again.'
} finally {
examLoading.value = false
}
}
function askFollowUp() {
const text = examInput.value.trim()
if (!text || examLoading.value) return
asked_again.value = true // help used → gain for this question max +1
examMessages.value.push({ role: 'user', kind: 'nachfrage', content: text })
examInput.value = ''
examSend(
{ action: 'discussion', question: currentQuestion.value, last_rating: lastFeedback.value },
(res) => examMessages.value.push({ role: 'assistant', kind: 'discussion', content: res.reply }),
)
}
let lastFeedbackMsg = null // last shown evaluation bubble
let evalRun = 0 // only the most recent quick evaluation may display
function ratingPayload() {
return { question: currentQuestion.value, cap: props.cap, asked_again: asked_again.value }
}
// Agent 1 (fast): show immediate level + points, then Agent 2 (precise) in the background.
async function quickEvaluate() {
const mine = ++evalRun
examStick.value = true
examLoading.value = true
examScroll()
try {
const res = await examBlock({
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
provider: props.provider, messages: examDialog(), action: 'answer', ...ratingPayload(),
})
if (mine !== evalRun) return
// Agent 1 does NOT change the score — only show feedback + expected points.
lastFeedback.value = res.feedback || ''
examMessages.value.push({ role: 'assistant', kind: 'feedback', content: res.feedback || '', rating: res.rating, points: res.points, checked: false })
lastFeedbackMsg = examMessages.value[examMessages.value.length - 1]
examPhase.value = 'bewertet'
examScroll()
nextTick(() => examInputEl.value?.focus())
preciseEvaluate() // Agent 2 evaluates bindingly (sets the score)
} catch {
if (mine === evalRun) examMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Didn\'t work — please try again.' })
} finally {
if (mine === evalRun) examLoading.value = false
}
}
// Agent 2 (precise): evaluator + critic. Corrects bubble + score (server truth, always apply).
// thorough=true: strong model, optionally with the learner's reason.
async function preciseEvaluate(thorough = false, reason = '') {
const target = lastFeedbackMsg
if (thorough) examLoading.value = true
try {
const res = await examBlock({
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
provider: props.provider, messages: examDialog(), action: 'answer_check', ...ratingPayload(), thorough, reason,
})
applyExam(res)
if (target) {
target.content = res.feedback || target.content
target.rating = res.rating
target.points = res.points
target.checked = true
}
} catch {
if (target) target.checked = true
} finally {
if (thorough) examLoading.value = false
}
}
function submitAnswer() {
const text = examInput.value.trim()
if (!text || examLoading.value) return
examMessages.value.push({ role: 'user', kind: 'answer', content: text })
examInput.value = ''
quickEvaluate()
}
// Was there already a discussion? Then the history may be evaluated directly.
const hasDiscussion = computed(() => examMessages.value.some((m) => m.kind === 'nachfrage' || m.kind === 'discussion'))
// "Evaluate history": evaluate the dialog so far as the answer (field is ignored).
function evaluateHistory() {
if (examLoading.value) return
quickEvaluate()
}
// Right-click an evaluation → open the thorough field; submitting re-evaluates (with reason).
function openThorough(msg) {
thoroughText.value = ''
thoroughMsg.value = msg
nextTick(() => document.getElementById('bp-thorough-input')?.focus())
}
function submitThorough() {
if (examLoading.value) return
lastFeedbackMsg = thoroughMsg.value || lastFeedbackMsg
const text = thoroughText.value.trim()
thoroughMsg.value = null
preciseEvaluate(true, text)
}
function pointsLabel(p) {
if (p == null) return ''
return p > 0 ? `+${p}` : p < 0 ? String(p) : '±0'
}
// Cancel like in chat: increment the run counter → the running result (examSend
// and quickEvaluate) is discarded, buttons free immediately. Agent finishes server-side.
function examCancel() {
if (!examLoading.value) return
examRun++
evalRun++
examLoading.value = false
examMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Cancelled.' })
}
// ESC cancels — window listener, because after a button click the focus leaves the panel.
function onWindowKey(e) {
if (e.key === 'Escape' && examLoading.value) { e.preventDefault(); examCancel() }
}
onMounted(() => {
if (props.mode !== 'full') return // trigger mode (list): only tab bar, no exam/text logic
window.addEventListener('keydown', onWindowKey)
window.addEventListener('keydown', onExamKey)
loadPatterns() // GET (no agent) — sets the active form early correctly (quiz/cloze/explain)
})
onUnmounted(() => {
window.removeEventListener('keydown', onWindowKey)
window.removeEventListener('keydown', onExamKey)
})
// Quiz / cloze choice is a choice widget with keys 14; otherwise text field/bubble.
const choiceWidget = computed(() => {
if (shownForm.value === 'quiz') return quizCurrent.value
if (shownForm.value === 'gaptext' && clozeCurrent.value && !clozeCurrent.value.schwer) return clozeCurrent.value
return null
})
// After an answer: on milestone crossing go to the new section, otherwise next question.
function continueAction() {
if (sectionChange.value) toNextSection()
else showNextQuestion()
}
// Primary action per form: no question → request · open → answer · done → continue.
function primaryAction() {
if (examLoading.value) return
const f = shownForm.value
if (f === 'quiz') { if (!quizCurrent.value) requestQuestion(); else if (!quizCurrent.value.done) quizAnswer(); else continueAction(); return }
if (f === 'gaptext') { if (!clozeCurrent.value) requestQuestion(); else if (!clozeCurrent.value.done) clozeAnswer(); else continueAction(); return }
if (examPhase.value === 'idle') requestQuestion()
else if (examPhase.value === 'question_offen') { if (examInput.value.trim()) submitAnswer() }
else continueAction()
}
// 14 (bare): toggle option in the choice widget. Alt alone: jump to the input field.
// Alt+2 primary (answer/next) · Alt+Q view · Alt+1/3 Explain.
function onExamKey(e) {
if (e.repeat) return
if (props.mode !== 'full' || activeTab.value !== 'exam') return
// Alt alone toggles the focus (BlockFocus lone-tap) → do NOT focus here,
// otherwise keydown focus (here) and keyup toggle (focus) fight → "only while held".
if (e.key === 'Alt') return
if (!e.altKey) {
// Bare 14: toggle the choice widget — but not while a text field has focus.
if (['1', '2', '3', '4'].includes(e.key) && choiceWidget.value && !choiceWidget.value.done) {
const ae = document.activeElement
if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA')) return
e.preventDefault()
const i = Number(e.key) - 1
if (i < choiceWidget.value.options.length) (shownForm.value === 'quiz' ? quizToggle : clozeToggle)(i)
}
return
}
if (e.code === 'KeyQ') {
e.preventDefault()
emit('setAnsicht', props.ansicht === 'compact' ? 'erklärend' : 'compact')
return
}
if (e.key === '2') { e.preventDefault(); primaryAction(); return }
// Explain special keys
if (e.key === '1') { e.preventDefault(); if (shownForm.value === 'erklaeren' && examPhase.value === 'question_offen' && examInput.value.trim()) askFollowUp(); return }
if (e.key === '3') { e.preventDefault(); if (shownForm.value === 'erklaeren' && examPhase.value === 'question_offen' && hasDiscussion.value) evaluateHistory() }
}
</script>
<template>
<div class="bp">
<div class="bp-toggles">
<!-- View switcher (fullscreen only): controls the left guide column, left of the tabs -->
<div v-if="mode === 'full'" class="bp-ansicht">
<button :class="{ active: ansicht === 'compact' }" title="Key points" @click="emit('setAnsicht', 'compact')">Compact</button>
<button :class="{ active: ansicht === 'erklärend' }" title="Detailed explanation" @click="emit('setAnsicht', 'erklärend')">Explanatory</button>
</div>
<button :class="{ active: activeTab === 'chat' }" @click="tabClick('chat')">
Chat
</button>
<button :class="{ active: activeTab === 'exam' }" @click="tabClick('exam')">
Exam
<span v-if="level" class="bp-chip" :style="{ borderColor: level.farbe, color: level.farbe }" :title="`${level.label} (${cap})`">{{ level.kurz }} {{ scoreDisplay }}/{{ cap }}</span>
<span v-else-if="score" class="bp-chip">{{ scoreDisplay }}/{{ cap }}</span>
</button>
</div>
<div v-if="mode === 'full' && activeTab" class="bp-panel">
<!-- Block chat -->
<div v-if="activeTab === 'chat'">
<div :ref="chat.messagesEl" class="bp-messages" @scroll="chat.onScroll">
<p v-if="!chat.messages.value.length" class="bp-hint">Ask something about this block. The history is not saved.</p>
<template v-for="(m, i) in chat.messages.value" :key="i">
<div v-if="m.role === 'assistant'" class="bp-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
<div v-else class="bp-msg user">{{ m.content }}</div>
</template>
<div v-if="chat.loading.value" class="bp-msg assistant bp-typing">Thinking</div>
</div>
<div class="bp-input">
<textarea
:ref="chat.inputEl"
v-model="chat.input.value"
rows="2"
placeholder="Question about the block…"
@keydown.enter.exact.prevent="chat.send"
></textarea>
<button :disabled="!chat.input.value.trim() && !chat.loading.value" :class="{ cancel: chat.loading.value }" @click="chat.send">
{{ chat.loading.value ? '' : '' }}
</button>
</div>
</div>
<!-- Exam: guided dialog -->
<div v-else>
<p class="bp-hint">
<template v-if="atCap">{{ FORM_NAME[shownForm] }} · {{ scoreDisplay }}/{{ cap }} <strong>Max</strong>.</template>
<template v-else-if="inRandom && !displayForm">Random · {{ scoreDisplay }}/{{ cap }}</template>
<template v-else-if="inRandom">{{ FORM_NAME[shownForm] }} · {{ scoreDisplay }}/{{ cap }} · {{ modeRule }}</template>
<template v-else>{{ FORM_NAME[shownForm] }} · {{ score }}/{{ bandTarget }} · {{ modeRule }}</template>
</p>
<!-- Quiz: question + multiple choice (widget stays even at the cap practice without points) -->
<template v-if="shownForm === 'quiz'">
<div v-if="!quizCurrent" class="bp-actions">
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Cancel</button>
<button v-else class="bp-action primary" @click="requestQuestion">Request question</button>
</div>
<div v-else class="bp-quiz">
<p class="bp-quiz-question markdown" v-html="renderMarkdownInline(quizCurrent.question)"></p>
<div class="bp-quiz-grid">
<button
v-for="(o, i) in quizCurrent.options" :key="i"
type="button"
class="bp-quiz-opt"
:class="{ gewaehlt: quizCurrent.gewaehlt.includes(i), correct: quizCurrent.done && o.correct, falsch: quizCurrent.done && !o.correct && quizCurrent.gewaehlt.includes(i) }"
:disabled="quizCurrent.done"
:title="`${i + 1}`"
@click="quizToggle(i)"
>
<span class="markdown" v-html="renderMarkdownInline(o.text)"></span>
</button>
</div>
<p v-if="examError" class="bp-error">{{ examError }}</p>
<div class="bp-actions">
<button v-if="!quizCurrent.done" class="bp-action primary" title="Alt+2" :disabled="examLoading" @click="quizAnswer">Answer</button>
<template v-else>
<span class="bp-tier" :class="quizCurrent.rating">{{ pointsLabel(quizCurrent.points) }}</span>
<span class="bp-form-feedback">{{ quizCurrent.feedback }}</span>
<button class="bp-action primary" @click="continueAction">{{ sectionChange ? 'To next section' : 'Next' }}</button>
</template>
</div>
</div>
</template>
<!-- Cloze: sentence with gap + input -->
<template v-else-if="shownForm === 'gaptext'">
<div v-if="!clozeCurrent" class="bp-actions">
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Cancel</button>
<button v-else class="bp-action primary" @click="requestQuestion">Request question</button>
</div>
<div v-else class="bp-gap">
<p class="bp-gap-sentence markdown" v-html="clozeSentenceHtml(clozeCurrent.sentence)"></p>
<!-- hard: free text -->
<input
v-if="clozeCurrent.schwer"
id="bp-gap-input"
v-model="clozeCurrent.input"
:disabled="clozeCurrent.done"
placeholder="Term for the gap…"
@keyup.enter="clozeAnswer"
/>
<p v-if="clozeCurrent.schwer && clozeCurrent.done && !clozeCurrent.feedback.startsWith('Correct')" class="bp-gap-solution">Solution: <span class="markdown" v-html="renderMarkdownInline(clozeCurrent.solution)"></span></p>
<!-- easy: choose term from 4 -->
<div v-if="!clozeCurrent.schwer" class="bp-quiz-grid">
<button
v-for="(o, i) in clozeCurrent.options" :key="i"
type="button"
class="bp-quiz-opt"
:class="{ gewaehlt: clozeCurrent.gewaehlt.includes(i), correct: clozeCurrent.done && o.correct, falsch: clozeCurrent.done && !o.correct && clozeCurrent.gewaehlt.includes(i) }"
:disabled="clozeCurrent.done"
:title="`${i + 1}`"
@click="clozeToggle(i)"
>
<span class="markdown" v-html="renderMarkdownInline(o.text)"></span>
</button>
</div>
<p v-if="examError" class="bp-error">{{ examError }}</p>
<div class="bp-actions">
<button v-if="!clozeCurrent.done" class="bp-action primary" title="Alt+2" :disabled="examLoading || (clozeCurrent.schwer ? !clozeCurrent.input.trim() : !clozeCurrent.gewaehlt.length)" @click="clozeAnswer">Answer</button>
<template v-else>
<span class="bp-tier" :class="clozeCurrent.rating">{{ pointsLabel(clozeCurrent.points) }}</span>
<span class="bp-form-feedback">{{ clozeCurrent.feedback }}</span>
<button class="bp-action primary" @click="continueAction">{{ sectionChange ? 'To next section' : 'Next' }}</button>
</template>
</div>
</div>
</template>
<!-- Explain: guided dialog (existing) -->
<template v-else>
<div v-if="examMessages.length" ref="examMessagesEl" class="bp-messages" @scroll="onExamScroll">
<template v-for="(m, i) in examMessages" :key="i">
<div v-if="m.kind === 'feedback'" class="bp-feedback" :class="m.rating" title="Click: check thoroughly" @click="openThorough(m)">
<span v-if="m.points != null" class="bp-tier">{{ pointsLabel(m.points) }}</span>{{ m.content }}<span v-if="!m.checked" class="bp-pruefend"> · being checked</span>
<div v-if="thoroughMsg === m" class="bp-thorough" @click.stop>
<input id="bp-thorough-input" v-model="thoroughText" placeholder="Why unsatisfied? (optional)" @keyup.enter="submitThorough" />
<button class="bp-action primary" @click="submitThorough">Check thoroughly</button>
<button class="bp-action" @click="thoroughMsg = null">×</button>
</div>
</div>
<div v-else-if="m.kind === 'fehler'" class="bp-error">{{ m.content }}</div>
<div v-else-if="m.role === 'assistant'" class="bp-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
<div v-else class="bp-msg user">{{ m.content }}</div>
</template>
<div v-if="examLoading" class="bp-msg assistant bp-typing"></div>
</div>
<div v-if="examPhase === 'idle'" class="bp-actions">
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Cancel</button>
<button v-else class="bp-action primary" @click="requestQuestion">Request question</button>
</div>
<template v-else>
<div v-if="examPhase === 'question_offen'" class="bp-input">
<textarea
ref="examInputEl"
v-model="examInput"
rows="2"
placeholder="Answer — or ask if unclear…"
></textarea>
</div>
<div class="bp-actions">
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Cancel</button>
<template v-else-if="examPhase === 'question_offen'">
<button class="bp-action" title="Alt+1" :disabled="!examInput.trim()" @click="askFollowUp"><span class="bp-kbd">1</span>Ask</button>
<button class="bp-action primary" title="Alt+2" :disabled="!examInput.trim()" @click="submitAnswer"><span class="bp-kbd">2</span>Submit answer</button>
<button v-if="hasDiscussion" class="bp-action" title="Alt+3" @click="evaluateHistory"><span class="bp-kbd">3</span>Evaluate history</button>
</template>
<template v-else>
<button class="bp-action primary" title="Alt+2" @click="continueAction"><span class="bp-kbd">2</span>{{ sectionChange ? 'To next section' : 'Next question' }}</button>
</template>
</div>
</template>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.bp { margin-top: 0.75rem; }
.bp-toggles { display: flex; gap: 0.4rem; }
.bp-toggles button {
display: inline-flex; align-items: center; gap: 0.35rem;
padding: 0.25rem 0.7rem;
font-size: 0.8rem;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--panel-soft);
color: var(--text-muted);
cursor: pointer;
}
.bp-toggles button:hover { border-color: var(--border-strong); color: var(--text); }
.bp-toggles button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
/* View switcher (Compact/Explanatory) — segmented, left of the tabs */
.bp-ansicht { display: inline-flex; margin-right: 0.5rem; }
.bp-ansicht button {
padding: 0.25rem 0.6rem;
font-size: 0.75rem;
border: 1px solid var(--border);
background: var(--panel-soft);
color: var(--text-muted);
cursor: pointer;
}
.bp-ansicht button:first-child { border-radius: 999px 0 0 999px; }
.bp-ansicht button:last-child { border-radius: 0 999px 999px 0; }
.bp-ansicht button + button { border-left: none; }
.bp-ansicht button:hover { color: var(--text); }
.bp-ansicht button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.bp-chip {
font-size: 0.7rem;
padding: 0 0.35rem;
border-radius: 999px;
background: var(--panel);
border: 1px solid var(--border);
color: var(--text-muted);
}
.bp-chip.done { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
.bp-chip.lila { background: color-mix(in srgb, #8b5cf6 16%, var(--panel)); border-color: #8b5cf6; color: #6d28d9; }
.bp-chip.gold { background: color-mix(in srgb, #d4af37 20%, var(--panel)); border-color: #d4af37; color: #8a6d12; }
.bp-panel {
margin-top: 0.6rem;
padding: 0.75rem 0.9rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel-soft);
}
.bp-hint { font-size: 0.85rem; color: var(--text-muted); margin: 0 0 0.5rem; }
.bp-hint-key { font-size: 0.72rem; opacity: 0.7; white-space: nowrap; }
.bp-error { font-size: 0.85rem; color: var(--danger); margin: 0.5rem 0 0; }
.bp-action {
margin-top: 0.5rem;
padding: 0.3rem 0.8rem;
font-size: 0.8rem;
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--panel);
color: var(--text);
cursor: pointer;
}
.bp-action:hover { border-color: var(--accent); }
.bp-action:disabled { opacity: 0.5; cursor: default; }
.bp-action.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.bp-action.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.bp-action.cancel { background: var(--danger); border-color: var(--danger); color: var(--on-accent); }
.bp-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.5rem; }
.bp-actions .bp-action { margin-top: 0; }
.bp-kbd {
display: inline-flex; align-items: center; justify-content: center;
min-width: 1rem; height: 1rem; padding: 0 0.2rem; margin-right: 0.35rem;
font-size: 0.65rem; font-weight: 600; border-radius: 3px;
background: var(--panel); border: 1px solid var(--border); color: var(--text-muted);
}
.bp-action.primary .bp-kbd { background: color-mix(in srgb, var(--on-accent) 20%, transparent); border-color: transparent; color: var(--on-accent); }
.bp-messages { display: flex; flex-direction: column; gap: 0.4rem; max-height: 320px; overflow-y: auto; }
.bp-msg {
max-width: 88%;
padding: 0.4rem 0.65rem;
border-radius: 10px;
font-size: 0.88rem;
line-height: 1.45;
overflow-wrap: anywhere;
}
.bp-msg.user { align-self: flex-end; background: var(--accent); color: var(--on-accent); white-space: pre-wrap; }
.bp-msg.assistant { align-self: flex-start; background: var(--panel); border: 1px solid var(--border); }
.bp-typing { color: var(--text-faint); font-style: italic; }
/* Evaluation of the last answer — separated above the next question */
.bp-feedback {
align-self: flex-start;
max-width: 88%;
padding: 0.3rem 0.6rem;
border-radius: 8px;
font-size: 0.82rem;
line-height: 1.4;
border: 1px solid var(--border);
}
.bp-pruefend { font-style: italic; opacity: 0.7; font-size: 0.92em; }
.bp-feedback { cursor: pointer; }
.bp-feedback.gut { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
.bp-feedback.neutral { background: var(--warning-soft); border-color: var(--warning-border); color: var(--warning); }
.bp-feedback.schlecht { background: var(--danger-soft, #fee2e2); border-color: var(--danger-border, #f87171); color: var(--danger); }
.bp-tier { font-weight: 700; text-transform: uppercase; font-size: 0.62rem; letter-spacing: 0.03em; margin-right: 0.4rem; opacity: 0.85; }
.bp-tier.gut { color: var(--success); }
.bp-tier.neutral { color: var(--warning); }
.bp-tier.schlecht { color: var(--danger); }
/* Quiz: question + multiple choice */
.bp-quiz, .bp-gap { margin-top: 0.6rem; }
.bp-quiz-question, .bp-gap-sentence { font-size: 0.9rem; font-weight: 600; margin: 0 0 0.5rem; line-height: 1.5; }
.bp-luecke { font-weight: 700; letter-spacing: 1px; color: var(--accent); padding: 0 0.15rem; }
.bp-quiz-opt .markdown, .bp-quiz-question.markdown, .bp-gap-sentence.markdown { display: inline; }
.bp-quiz-opt .markdown { min-width: 0; overflow-wrap: anywhere; }
.bp-quiz-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-bottom: 0.5rem; }
.bp-quiz-opt {
display: flex; gap: 0.45rem; align-items: flex-start; text-align: left;
min-width: 0; overflow-wrap: anywhere;
padding: 0.5rem 0.6rem;
border: 1px solid var(--border); border-radius: 8px;
background: var(--panel); color: var(--text); cursor: pointer; font-size: 0.85rem;
}
.bp-quiz-opt:disabled { cursor: default; }
.bp-quiz-opt.gewaehlt { border-color: var(--accent); background: var(--accent-soft, rgba(99, 102, 241, 0.12)); }
.bp-quiz-opt.correct { background: var(--success-soft); border-color: var(--success-border); }
.bp-quiz-opt.falsch { background: var(--danger-soft, #fee2e2); border-color: var(--danger-border, #f87171); }
.bp-form-feedback { font-size: 0.82rem; color: var(--text-muted); flex: 1; }
/* Cloze: sentence + input */
.bp-gap input {
width: 100%; box-sizing: border-box; padding: 0.45rem 0.6rem; font: inherit; font-size: 0.88rem;
border: 1px solid var(--border); border-radius: 8px; background: var(--panel); color: var(--text);
}
.bp-gap input:disabled { opacity: 0.7; }
.bp-gap-solution { font-size: 0.82rem; color: var(--success); margin: 0.35rem 0 0; }
/* Thorough-check field (via right-click) inside the evaluation bubble */
.bp-thorough { display: flex; gap: 0.3rem; margin-top: 0.4rem; align-items: center; }
.bp-thorough input {
flex: 1; min-width: 0; padding: 0.3rem 0.5rem; font: inherit; font-size: 0.8rem;
border: 1px solid var(--border-strong); border-radius: 6px; background: var(--panel); color: var(--text);
}
.bp-thorough .bp-action { margin-top: 0; }
.bp-input { display: flex; gap: 0.4rem; margin-top: 0.55rem; align-items: flex-end; }
.bp-input textarea {
flex: 1;
resize: none;
padding: 0.45rem 0.6rem;
font: inherit;
font-size: 0.88rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
color: var(--text);
}
.bp-input button {
padding: 0.45rem 0.7rem;
border: none;
border-radius: 8px;
background: var(--accent);
color: var(--on-accent);
cursor: pointer;
}
.bp-input button:disabled { opacity: 0.5; cursor: default; }
.bp-input button.cancel { background: var(--danger); }
</style>

View File

@@ -1,10 +1,10 @@
<script setup> <script setup>
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { fetchBausteineUebersicht } from '../api.js' import { fetchBlocksOverview } from '../api.js'
const props = defineProps({ const props = defineProps({
topic: { type: String, required: true }, topic: { type: String, required: true },
steps: { type: Array, default: () => [] }, // feine Teilschritte {label, phase, state} steps: { type: Array, default: () => [] }, // fine sub-steps {label, phase, state}
generating: { type: Boolean, default: false }, generating: { type: Boolean, default: false },
progress: { type: String, default: null }, progress: { type: String, default: null },
ready: { type: Boolean, default: false }, ready: { type: Boolean, default: false },
@@ -12,46 +12,46 @@ const props = defineProps({
}) })
const emit = defineEmits(['close', 'restartFrom', 'resetFrom', 'restartAll', 'removeAll', 'cancel']) const emit = defineEmits(['close', 'restartFrom', 'resetFrom', 'restartAll', 'removeAll', 'cancel'])
// Teilschritte nach Phase gruppieren, globalen Index für den Re-Run mitführen. // Group sub-steps by phase, carrying the global index for the re-run.
const phasenGruppen = computed(() => { const phaseGroups = computed(() => {
const out = [] const out = []
props.steps.forEach((s, i) => { props.steps.forEach((s, i) => {
const last = out[out.length - 1] const last = out[out.length - 1]
if (last && last.phase === s.phase) last.schritte.push({ ...s, idx: i }) if (last && last.phase === s.phase) last.steps.push({ ...s, idx: i })
else out.push({ phase: s.phase, schritte: [{ ...s, idx: i }] }) else out.push({ phase: s.phase, steps: [{ ...s, idx: i }] })
}) })
return out return out
}) })
const gewaehlt = ref(null) // markierter Startpunkt (Schritt-Index) const selected = ref(null) // marked start point (step index)
const confirm = ref(null) // welche destruktive Aktion gerade Sicher?" zeigt const confirm = ref(null) // which destructive action currently shows "Sure?"
const gewaehltLabel = computed(() => props.steps[gewaehlt.value]?.label || '') const selectedLabel = computed(() => props.steps[selected.value]?.label || '')
function stepKlick(idx) { function stepClick(idx) {
if (props.generating) return if (props.generating) return
gewaehlt.value = gewaehlt.value === idx ? null : idx selected.value = selected.value === idx ? null : idx
confirm.value = null confirm.value = null
} }
// 2-Klick-Bestätigung für destruktive Aktionen: erster Klick armt", zweiter führt aus. // 2-click confirmation for destructive actions: first click "arms", second runs it.
function arm(aktion, fn) { function arm(action, fn) {
if (confirm.value === aktion) { confirm.value = null; fn() } if (confirm.value === action) { confirm.value = null; fn() }
else confirm.value = aktion else confirm.value = action
} }
function neuAbHier() { const i = gewaehlt.value; gewaehlt.value = null; confirm.value = null; emit('restartFrom', i) } function regenerateFromHere() { const i = selected.value; selected.value = null; confirm.value = null; emit('restartFrom', i) }
function loeschAbHier() { const i = gewaehlt.value; gewaehlt.value = null; confirm.value = null; emit('resetFrom', i) } function deleteFromHere() { const i = selected.value; selected.value = null; confirm.value = null; emit('resetFrom', i) }
const items = ref([]) const items = ref([])
const loading = ref(true) const loading = ref(true)
const error = ref(null) const error = ref(null)
// Lernpfad-Stufen: Reihenfolge + Label (Farbe via CSS-Klasse st-<key>). // Learning-path levels: order + label (color via CSS class st-<key>).
const STUFEN = [ const LEVELS = [
{ key: 'anfaenger', label: 'Anfänger' }, { key: 'beginner', label: 'Beginner' },
{ key: 'fortgeschritten', label: 'Fortgeschritten' }, { key: 'advanced', label: 'Advanced' },
{ key: 'experte', label: 'Experte' }, { key: 'expert', label: 'Expert' },
] ]
// Alt-Themen tragen noch einfach/mittel/schwer auf die neuen Keys mappen. // Legacy topics still carry einfach/mittel/schwer map them to the new keys.
const ALT_STUFE = { einfach: 'anfaenger', mittel: 'fortgeschritten', schwer: 'experte' } const LEGACY_LEVEL = { einfach: 'beginner', mittel: 'advanced', schwer: 'expert' }
watch(() => props.topic, load, { immediate: true }) watch(() => props.topic, load, { immediate: true })
@@ -60,84 +60,84 @@ async function load() {
error.value = null error.value = null
items.value = [] items.value = []
try { try {
items.value = await fetchBausteineUebersicht(props.topic) items.value = await fetchBlocksOverview(props.topic)
} catch (e) { } catch (e) {
error.value = 'Übersicht nicht verfügbar — bitte erst Bausteine erstellen.' error.value = 'Overview not available — create blocks first.'
} finally { } finally {
loading.value = false loading.value = false
} }
} }
// Baustein relevant = hat 1 relevanten Subbaustein (gleiche Regel wie der Guide). // Block relevant = has 1 relevant subblock (same rule as the guide).
// Ohne Relevanz-Daten (Alt-Themen) nicht dimmen. // Without relevance data (legacy topics) don't dim.
function relevant(b) { function relevant(b) {
const mitRelevanz = (b.subbausteine || []).filter((s) => s.relevanz) const withRelevance = (b.subblocks || []).filter((s) => s.relevance)
return !mitRelevanz.length || mitRelevanz.some((s) => s.relevanz === 'relevant') return !withRelevance.length || withRelevance.some((s) => s.relevance === 'relevant')
} }
// Nur nicht-leere Stufen-Gruppen je Baustein (v-if + v-for nicht auf einem Element) // Only non-empty level groups per block (v-if + v-for not on one element)
function gruppen(b) { function groups(b) {
return STUFEN return LEVELS
.map((st) => ({ ...st, subs: (b.subbausteine || []).filter((s) => (ALT_STUFE[s.stufe] || s.stufe) === st.key) })) .map((st) => ({ ...st, subs: (b.subblocks || []).filter((s) => (LEGACY_LEVEL[s.level] || s.level) === st.key) }))
.filter((g) => g.subs.length) .filter((g) => g.subs.length)
} }
const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subbausteine?.length || 0), 0)) const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.length || 0), 0))
</script> </script>
<template> <template>
<div class="bk-view"> <div class="bk-view">
<header class="bk-head"> <header class="bk-head">
<h1>{{ topic }}</h1> <h1>{{ topic }}</h1>
<span class="bk-sub">Bausteine-Übersicht</span> <span class="bk-sub">Blocks overview</span>
<span v-if="items.length" class="bk-count">{{ items.length }} Bausteine · {{ subTotal }} Subbausteine</span> <span v-if="items.length" class="bk-count">{{ items.length }} Blocks · {{ subTotal }} Subblocks</span>
<span class="bk-spacer"></span> <span class="bk-spacer"></span>
<button class="bk-close" title="Schließen" @click="emit('close')"></button> <button class="bk-close" title="Close" @click="emit('close')"></button>
</header> </header>
<section v-if="steps.length" class="bk-steps"> <section v-if="steps.length" class="bk-steps">
<div class="bk-steps-top"> <div class="bk-steps-top">
<div v-if="progress" class="bk-progress"><span class="bk-progress-dot"></span>{{ progress }}</div> <div v-if="progress" class="bk-progress"><span class="bk-progress-dot"></span>{{ progress }}</div>
<div v-if="!generating" class="bk-global-actions"> <div v-if="!generating" class="bk-global-actions">
<button class="bk-act play" @click="emit('restartAll')">{{ partial ? 'Fortsetzen' : ready ? 'Neu generieren' : 'Generieren' }}</button> <button class="bk-act play" @click="emit('restartAll')">{{ partial ? 'Continue' : ready ? 'Regenerate' : 'Generate' }}</button>
<button <button
v-if="ready || partial" v-if="ready || partial"
class="bk-act danger" class="bk-act danger"
:class="{ armed: confirm === 'remove' }" :class="{ armed: confirm === 'remove' }"
@click="arm('remove', () => emit('removeAll'))" @click="arm('remove', () => emit('removeAll'))"
>{{ confirm === 'remove' ? 'Sicher?' : 'Entfernen' }}</button> >{{ confirm === 'remove' ? 'Sure?' : 'Remove' }}</button>
</div> </div>
<div v-else class="bk-global-actions"> <div v-else class="bk-global-actions">
<button class="bk-act danger" @click="emit('cancel')">Abbrechen</button> <button class="bk-act danger" @click="emit('cancel')">Cancel</button>
</div> </div>
</div> </div>
<div class="bk-phasen"> <div class="bk-phasen">
<div v-for="g in phasenGruppen" :key="g.phase" class="bk-phase"> <div v-for="g in phaseGroups" :key="g.phase" class="bk-phase">
<span class="bk-phase-label">{{ g.phase }}</span> <span class="bk-phase-label">{{ g.phase }}</span>
<div class="bk-schritte"> <div class="bk-steps">
<button <button
v-for="s in g.schritte" v-for="s in g.steps"
:key="s.idx" :key="s.idx"
class="bk-step" class="bk-step"
:class="[s.state, { sel: gewaehlt === s.idx }]" :class="[s.state, { sel: selected === s.idx }]"
:disabled="generating" :disabled="generating"
:title="`Startpunkt «${s.label}» wählen`" :title="`Choose start point «${s.label}»`"
@click="stepKlick(s.idx)" @click="stepClick(s.idx)"
>{{ s.label }}</button> >{{ s.label }}</button>
</div> </div>
</div> </div>
</div> </div>
<div v-if="gewaehlt !== null && !generating" class="bk-step-actions"> <div v-if="selected !== null && !generating" class="bk-step-actions">
<span class="bk-step-actions-label">Ab «{{ gewaehltLabel }}»:</span> <span class="bk-step-actions-label">From «{{ selectedLabel }}»:</span>
<button class="bk-act play" @click="neuAbHier"> neu generieren</button> <button class="bk-act play" @click="regenerateFromHere"> regenerate</button>
<button class="bk-act danger" :class="{ armed: confirm === 'reset' }" @click="arm('reset', loeschAbHier)">{{ confirm === 'reset' ? 'Sicher?' : ' alles löschen' }}</button> <button class="bk-act danger" :class="{ armed: confirm === 'reset' }" @click="arm('reset', deleteFromHere)">{{ confirm === 'reset' ? 'Sure?' : ' delete all' }}</button>
<button class="bk-act ghost" @click="gewaehlt = null; confirm = null">Abbrechen</button> <button class="bk-act ghost" @click="selected = null; confirm = null">Cancel</button>
</div> </div>
</section> </section>
<div v-if="loading" class="bk-empty-state">Lade</div> <div v-if="loading" class="bk-empty-state">Loading</div>
<div v-else-if="error" class="bk-empty-state">{{ error }}</div> <div v-else-if="error" class="bk-empty-state">{{ error }}</div>
<div v-else-if="!items.length" class="bk-empty-state">Noch keine Bausteine.</div> <div v-else-if="!items.length" class="bk-empty-state">No blocks yet.</div>
<div v-else class="bk-grid"> <div v-else class="bk-grid">
<article <article
@@ -145,21 +145,21 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subbausteine
:key="b.num" :key="b.num"
class="bk-card" class="bk-card"
:class="{ 'bk-irrelevant': !relevant(b) }" :class="{ 'bk-irrelevant': !relevant(b) }"
:title="relevant(b) ? null : 'Nicht relevant — kommt später in den „Rest“'" :title="relevant(b) ? null : 'Not relevant — comes later in the &quot;Rest&quot;'"
> >
<h3 class="bk-title"><span class="bk-num">{{ b.num }}</span>{{ b.titel }}</h3> <h3 class="bk-title"><span class="bk-num">{{ b.num }}</span>{{ b.title }}</h3>
<p v-if="b.beschreibung" class="bk-desc">{{ b.beschreibung }}</p> <p v-if="b.description" class="bk-desc">{{ b.description }}</p>
<div v-if="b.subbausteine && b.subbausteine.length" class="bk-stufen"> <div v-if="b.subblocks && b.subblocks.length" class="bk-levels">
<div v-for="g in gruppen(b)" :key="g.key" class="bk-stufe" :class="'st-' + g.key"> <div v-for="g in groups(b)" :key="g.key" class="bk-level" :class="'st-' + g.key">
<span class="bk-stufe-label">{{ g.label }}</span> <span class="bk-level-label">{{ g.label }}</span>
<ul> <ul>
<li v-for="s in g.subs" :key="s.titel" :class="{ rand: s.relevanz === 'rand' }"> <li v-for="s in g.subs" :key="s.title" :class="{ rand: s.relevance === 'peripheral' }">
{{ s.titel }}<span v-if="s.relevanz === 'rand'" class="rand-tag" title="Randthemakommt später in den „Rest">Rand</span> {{ s.title }}<span v-if="s.relevance === 'peripheral'" class="rand-tag" title="Peripheral topiccomes later in the 'Rest'">Edge</span>
</li> </li>
</ul> </ul>
</div> </div>
</div> </div>
<p v-else class="bk-no-subs">Keine Subbausteine.</p> <p v-else class="bk-no-subs">No subblocks.</p>
</article> </article>
</div> </div>
</div> </div>
@@ -199,7 +199,7 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subbausteine
} }
.bk-close:hover { border-color: var(--accent); } .bk-close:hover { border-color: var(--accent); }
/* Schritt-Übersicht über den Bausteinen */ /* Step overview above the blocks */
.bk-steps { .bk-steps {
padding: 0.85rem 2rem; padding: 0.85rem 2rem;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
@@ -232,7 +232,7 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subbausteine
letter-spacing: 0.05em; letter-spacing: 0.05em;
color: var(--text-faint); color: var(--text-faint);
} }
.bk-schritte { display: flex; flex-wrap: wrap; gap: 0.3rem; } .bk-steps { display: flex; flex-wrap: wrap; gap: 0.3rem; }
.bk-step { .bk-step {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -253,12 +253,12 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subbausteine
.bk-step.pending { color: var(--text-faint); } .bk-step.pending { color: var(--text-faint); }
.bk-step.sel { border-color: var(--accent); color: var(--on-accent); background: var(--accent); font-weight: 700; box-shadow: 0 0 0 2px var(--accent-soft); } .bk-step.sel { border-color: var(--accent); color: var(--on-accent); background: var(--accent); font-weight: 700; box-shadow: 0 0 0 2px var(--accent-soft); }
/* Kopf: Fortschritt links, globale Buttons rechts */ /* Header: progress left, global buttons right */
.bk-steps-top { display: flex; align-items: center; gap: 1rem; min-height: 1.9rem; margin-bottom: 0.7rem; } .bk-steps-top { display: flex; align-items: center; gap: 1rem; min-height: 1.9rem; margin-bottom: 0.7rem; }
.bk-steps-top .bk-progress { margin-bottom: 0; } .bk-steps-top .bk-progress { margin-bottom: 0; }
.bk-global-actions { margin-left: auto; display: flex; gap: 0.4rem; } .bk-global-actions { margin-left: auto; display: flex; gap: 0.4rem; }
/* Aktions-Leiste bei gewähltem Startpunkt */ /* Action bar for the selected start point */
.bk-step-actions { .bk-step-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -311,7 +311,7 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subbausteine
border-radius: 10px; border-radius: 10px;
padding: 1rem 1.1rem; padding: 1rem 1.1rem;
} }
/* Nicht relevante Bausteine (kein relevanter Subbaustein) gedimmt */ /* Non-relevant blocks (no relevant subblock) dimmed */
.bk-card.bk-irrelevant { opacity: 0.5; } .bk-card.bk-irrelevant { opacity: 0.5; }
.bk-title { .bk-title {
@@ -342,35 +342,35 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subbausteine
margin-bottom: 0.7rem; margin-bottom: 0.7rem;
} }
.bk-stufen { display: flex; flex-direction: column; gap: 0.55rem; } .bk-levels { display: flex; flex-direction: column; gap: 0.55rem; }
.bk-stufe { .bk-level {
border-left: 3px solid var(--st); border-left: 3px solid var(--st);
padding-left: 0.6rem; padding-left: 0.6rem;
} }
.bk-stufe.st-anfaenger { --st: var(--stufe-anfaenger); } .bk-level.st-beginner { --st: var(--level-beginner); }
.bk-stufe.st-fortgeschritten { --st: var(--stufe-fortgeschritten); } .bk-level.st-advanced { --st: var(--level-advanced); }
.bk-stufe.st-experte { --st: var(--stufe-experte); } .bk-level.st-expert { --st: var(--level-expert); }
.bk-stufe-label { .bk-level-label {
font-size: 0.66rem; font-size: 0.66rem;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.04em;
color: var(--st); color: var(--st);
} }
.bk-stufe ul { .bk-level ul {
list-style: disc; list-style: disc;
margin: 0.25rem 0 0; margin: 0.25rem 0 0;
padding-left: 1.15rem; padding-left: 1.15rem;
} }
.bk-stufe li { .bk-level li {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text); color: var(--text);
line-height: 1.35; line-height: 1.35;
margin-bottom: 2px; margin-bottom: 2px;
} }
.bk-stufe li::marker { color: var(--text-faint); } .bk-level li::marker { color: var(--text-faint); }
.bk-stufe li.rand { color: var(--text-faint); } .bk-level li.rand { color: var(--text-faint); }
.rand-tag { .rand-tag {
margin-left: 0.4rem; margin-left: 0.4rem;
font-size: 0.6rem; font-size: 0.6rem;

View File

@@ -1,11 +1,11 @@
<script setup> <script setup>
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { fetchElements } from '../api.js' import { fetchElements } from '../api.js'
import { renderMarkdown } from '../markdown.js' import { renderMarkdown, plainText } from '../markdown.js'
const props = defineProps({ const props = defineProps({
topic: { type: String, required: true }, topic: { type: String, required: true },
version: { type: Number, default: 0 }, // Erhöhung = Elemente neu laden version: { type: Number, default: 0 }, // increment = reload elements
}) })
const emit = defineEmits(['open']) const emit = defineEmits(['open'])
@@ -18,13 +18,10 @@ async function load() {
try { try {
elements.value = await fetchElements(props.topic) elements.value = await fetchElements(props.topic)
} catch (e) { } catch (e) {
console.error('Fehler beim Laden der Elemente:', e) console.error('Failed to load elements:', e)
} }
} }
function plain(text) {
return (text || '').replace(/```[a-z]*\n?/g, '').replace(/[`*_#]/g, '')
}
</script> </script>
<template> <template>
@@ -33,10 +30,10 @@ function plain(text) {
<div class="overview-content"> <div class="overview-content">
<header class="overview-head"> <header class="overview-head">
<h1>{{ topic }}</h1> <h1>{{ topic }}</h1>
<span class="overview-format">Elemente</span> <span class="overview-format">Elements</span>
</header> </header>
<p v-if="!elements.length" class="overview-empty"> <p v-if="!elements.length" class="overview-empty">
Noch keine Elemente. Rechts in der Sidebar Stichwort eingeben und + klicken. No elements yet. Enter a keyword in the sidebar on the right and click +.
</p> </p>
<div class="element-grid"> <div class="element-grid">
<article <article
@@ -45,11 +42,11 @@ function plain(text) {
class="element-card" class="element-card"
@click="emit('open', el)" @click="emit('open', el)"
> >
<h3>{{ plain(el.title) }}</h3> <h3>{{ plainText(el.title) }}</h3>
<div class="markdown" v-html="renderMarkdown(el.description)"></div> <div class="markdown" v-html="renderMarkdown(el.description)"></div>
<div v-for="(ex, i) in el.examples" :key="i" class="markdown el-example" v-html="renderMarkdown(ex)"></div> <div v-for="(ex, i) in el.examples" :key="i" class="markdown el-example" v-html="renderMarkdown(ex)"></div>
<div v-if="el.hints.length" class="el-hints-block"> <div v-if="el.hints.length" class="el-hints-block">
<h4>Hinweise</h4> <h4>Hints</h4>
<ul> <ul>
<li v-for="(h, i) in el.hints" :key="i" class="markdown" v-html="renderMarkdown(h)"></li> <li v-for="(h, i) in el.hints" :key="i" class="markdown" v-html="renderMarkdown(h)"></li>
</ul> </ul>
@@ -161,7 +158,7 @@ function plain(text) {
margin-bottom: 0.2rem; margin-bottom: 0.2rem;
} }
/* Markdown: Basis global (assets/markdown.css), hier nur Grundschrift der Karten */ /* Markdown: base styles global (assets/markdown.css), here only the card base font */
.markdown { .markdown {
font-size: 0.9rem; font-size: 0.9rem;
line-height: 1.55; line-height: 1.55;

View File

@@ -2,59 +2,59 @@
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { renderMarkdownInline } from '../markdown.js' import { renderMarkdownInline } from '../markdown.js'
const props = defineProps({ karten: { type: Array, default: () => [] } }) const props = defineProps({ cards: { type: Array, default: () => [] } })
const offen = ref(false) const open = ref(false)
const reihenfolge = ref([]) const order = ref([])
const pos = ref(0) const pos = ref(0)
const flipped = ref(false) const flipped = ref(false)
function reset() { function reset() {
reihenfolge.value = props.karten.map((_, i) => i) order.value = props.cards.map((_, i) => i)
pos.value = 0 pos.value = 0
flipped.value = false flipped.value = false
} }
watch(() => props.karten, reset, { immediate: true }) watch(() => props.cards, reset, { immediate: true })
const aktuelle = computed(() => props.karten[reihenfolge.value[pos.value]] || null) const current = computed(() => props.cards[order.value[pos.value]] || null)
const zaehler = computed(() => `${Math.min(pos.value + 1, reihenfolge.value.length)} / ${reihenfolge.value.length}`) const counter = computed(() => `${Math.min(pos.value + 1, order.value.length)} / ${order.value.length}`)
function weiter(gewusst) { function next(known) {
if (gewusst) { if (known) {
pos.value++ pos.value++
} else { } else {
// „Nochmal" → Karte ans Ende der Runde schieben (leichtes Spacing). // "Again" → push the card to the end of the round (light spacing).
const [k] = reihenfolge.value.splice(pos.value, 1) const [k] = order.value.splice(pos.value, 1)
reihenfolge.value.push(k) order.value.push(k)
} }
if (pos.value >= reihenfolge.value.length) pos.value = 0 if (pos.value >= order.value.length) pos.value = 0
flipped.value = false flipped.value = false
} }
</script> </script>
<template> <template>
<div v-if="karten.length" class="flashcards"> <div v-if="cards.length" class="flashcards">
<button class="art-head" @click="offen = !offen"> <button class="art-head" @click="open = !open">
<span class="art-icon">🃏</span> Karteikarten <span class="art-icon">🃏</span> Flashcards
<span class="art-count">{{ karten.length }}</span> <span class="art-count">{{ cards.length }}</span>
<span class="art-toggle">{{ offen ? '▾' : '▸' }}</span> <span class="art-toggle">{{ open ? '▾' : '▸' }}</span>
</button> </button>
<div v-if="offen && aktuelle" class="fc-body"> <div v-if="open && current" class="fc-body">
<div class="fc-card" :class="{ flipped }" @click="flipped = !flipped"> <div class="fc-card" :class="{ flipped }" @click="flipped = !flipped">
<div class="fc-zaehler">{{ zaehler }}</div> <div class="fc-zaehler">{{ counter }}</div>
<div v-if="!flipped" class="fc-seite"> <div v-if="!flipped" class="fc-seite">
<span class="fc-label">Frage</span> <span class="fc-label">Question</span>
<div class="fc-text" v-html="renderMarkdownInline(aktuelle.frage)"></div> <div class="fc-text" v-html="renderMarkdownInline(current.question)"></div>
<span class="fc-hint">Klick zum Umdrehen</span> <span class="fc-hint">Click to flip</span>
</div> </div>
<div v-else class="fc-seite"> <div v-else class="fc-seite">
<span class="fc-label">Antwort</span> <span class="fc-label">Answer</span>
<div class="fc-text" v-html="renderMarkdownInline(aktuelle.antwort)"></div> <div class="fc-text" v-html="renderMarkdownInline(current.answer)"></div>
</div> </div>
</div> </div>
<div v-if="flipped" class="fc-aktionen"> <div v-if="flipped" class="fc-aktionen">
<button class="fc-btn nochmal" @click="weiter(false)">Nochmal</button> <button class="fc-btn nochmal" @click="next(false)">Again</button>
<button class="fc-btn gewusst" @click="weiter(true)">Gewusst</button> <button class="fc-btn gewusst" @click="next(true)">Knew it</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,11 +1,11 @@
<script setup> <script setup>
// Themenweite Misch-Prüfung: wählt einen Baustein (priorisiert: noch nicht auf Max), darunter // Topic-wide mixed exam: picks a block (prioritized: not yet at max), then random
// zufällig, und nutzt dafür das normale BausteinPanel. Jeder Baustein hat seinen eigenen // within it, and reuses the normal BlockPanel. Each block has its own exam slot
// Prüfungs-Slot (pruefungCache), also bleiben die Frage-Pools getrennt. Buchung läuft je // (pruefungCache), so the question pools stay separate. Booking runs per block via the
// Baustein über die normale Prüfungs-Route. Gesamt-cap = Σ cap_final aller prüfbaren Bausteine. // normal exam route. Total cap = Σ cap_final of all checkable blocks.
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { fetchGuides, fetchGuideContent, fetchBausteinLernstand } from '../api.js' import { fetchGuides, fetchGuideContent, fetchBlockLearnState } from '../api.js'
import BausteinPanel from './BausteinPanel.vue' import BlockPanel from './BlockPanel.vue'
const props = defineProps({ const props = defineProps({
topic: { type: String, required: true }, topic: { type: String, required: true },
@@ -13,41 +13,41 @@ const props = defineProps({
}) })
const emit = defineEmits(['progressChanged', 'fokus-active']) const emit = defineEmits(['progressChanged', 'fokus-active'])
const sections = ref([]) // [{titel, md, kompakt, num}] — prüfbare Sections der Vollfassung const sections = ref([]) // [{title, md, compact, num}] — checkable sections of the full version
const lernstand = ref({}) // titel -> {gute_antworten, streak, cap, cap_aktuell, freie_ebene} const learnstate = ref({}) // title -> {good_answers, streak, cap, cap_aktuell, freie_level}
const aktiv = ref(null) // aktueller Baustein-Titel const active = ref(null) // current block title
const ladefehler = ref(null) const loadError = ref(null)
const pruefbare = computed(() => sections.value.filter((s) => (lernstand.value[s.titel]?.cap || 0) > 0)) const checkable = computed(() => sections.value.filter((s) => (learnstate.value[s.title]?.cap || 0) > 0))
// Gesamt-Fortschritt: Σ min(score, cap_final) / Σ cap_final über alle prüfbaren Bausteine. // Total progress: Σ min(score, cap_final) / Σ cap_final over all checkable blocks.
const gesamt = computed(() => { const total = computed(() => {
let score = 0, cap = 0 let score = 0, cap = 0
for (const s of pruefbare.value) { for (const s of checkable.value) {
const l = lernstand.value[s.titel] || {} const l = learnstate.value[s.title] || {}
score += Math.min(l.gute_antworten || 0, l.cap || 0) score += Math.min(l.good_answers || 0, l.cap || 0)
cap += l.cap || 0 cap += l.cap || 0
} }
return { score, cap } return { score, cap }
}) })
const aktivSection = computed(() => sections.value.find((s) => s.titel === aktiv.value) || null) const activeSection = computed(() => sections.value.find((s) => s.title === active.value) || null)
const aktivStatus = computed(() => lernstand.value[aktiv.value] || null) const activeStatus = computed(() => learnstate.value[active.value] || null)
// Priorität: Bausteine, die noch nicht auf cap_final sind; darunter zufällig. // Priority: blocks not yet at cap_final; among those random.
function waehleBaustein() { function pickBlock() {
const offen = pruefbare.value.filter((s) => { const unfinished = checkable.value.filter((s) => {
const l = lernstand.value[s.titel] || {} const l = learnstate.value[s.title] || {}
return (l.gute_antworten || 0) < (l.cap || 0) return (l.good_answers || 0) < (l.cap || 0)
}) })
const pool = offen.length ? offen : pruefbare.value const pool = unfinished.length ? unfinished : checkable.value
aktiv.value = pool.length ? pool[Math.floor(Math.random() * pool.length)].titel : null active.value = pool.length ? pool[Math.floor(Math.random() * pool.length)].title : null
} }
async function ladeLernstand() { async function loadLearnState() {
try { try {
lernstand.value = (await fetchBausteinLernstand(props.topic)).bausteine || {} learnstate.value = (await fetchBlockLearnState(props.topic)).blocks || {}
} catch { /* offline → alter Stand */ } } catch { /* offline → keep old state */ }
} }
onMounted(async () => { onMounted(async () => {
@@ -57,23 +57,23 @@ onMounted(async () => {
const g = guides const g = guides
.filter((x) => x.topic === props.topic && x.format === 'Guide' && x.status === 'done') .filter((x) => x.topic === props.topic && x.format === 'Guide' && x.status === 'done')
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1))[0] .sort((a, b) => (a.created_at < b.created_at ? 1 : -1))[0]
if (!g) { ladefehler.value = 'Kein fertiger Guide — erst einen Guide erstellen.'; return } if (!g) { loadError.value = 'No finished guide — create a guide first.'; return }
const content = await fetchGuideContent(g.id, 4) // Vollfassung (alle Subs) const content = await fetchGuideContent(g.id, 4) // full version (all subs)
sections.value = (content.chapters || []) sections.value = (content.chapters || [])
.flatMap((ch) => ch.sections || []) .flatMap((ch) => ch.sections || [])
.filter((s) => s.pruefbar !== false) .filter((s) => s.checkable !== false)
.map((s) => ({ titel: s.title, md: s.md, kompakt: s.kompakt, num: s.num })) .map((s) => ({ title: s.title, md: s.md, compact: s.compact, num: s.num }))
await ladeLernstand() await loadLearnState()
waehleBaustein() pickBlock()
} catch { } catch {
ladefehler.value = 'Konnte die Prüfung nicht laden.' loadError.value = 'Could not load the exam.'
} }
}) })
onUnmounted(() => emit('fokus-active', false)) onUnmounted(() => emit('fokus-active', false))
async function onStatus() { async function onStatus() {
await ladeLernstand() // cap_aktuell/freie_ebene können nach einer Antwort gewachsen sein await loadLearnState() // cap_aktuell/freie_level may have grown after an answer
emit('progressChanged') emit('progressChanged')
} }
</script> </script>
@@ -81,27 +81,27 @@ async function onStatus() {
<template> <template>
<div class="ge-panel"> <div class="ge-panel">
<div class="ge-head"> <div class="ge-head">
<h2>Allgemeine Prüfung</h2> <h2>General Exam</h2>
<span v-if="!ladefehler" class="ge-score">{{ gesamt.score }} / {{ gesamt.cap }}</span> <span v-if="!loadError" class="ge-score">{{ total.score }} / {{ total.cap }}</span>
<button v-if="aktiv" class="ge-next" title="Anderen Baustein prüfen" @click="waehleBaustein"> Anderer Baustein</button> <button v-if="active" class="ge-next" title="Check another block" @click="pickBlock"> Another block</button>
</div> </div>
<p v-if="ladefehler" class="ge-msg">{{ ladefehler }}</p> <p v-if="loadError" class="ge-msg">{{ loadError }}</p>
<p v-else-if="!pruefbare.length" class="ge-msg">Noch keine prüfbaren Bausteine.</p> <p v-else-if="!checkable.length" class="ge-msg">No checkable blocks yet.</p>
<p v-else-if="!aktiv" class="ge-msg">Alles auf Max nichts mehr zu prüfen. 🎉</p> <p v-else-if="!active" class="ge-msg">Everything maxed out nothing left to check. 🎉</p>
<div v-else class="ge-body"> <div v-else class="ge-body">
<div class="ge-baustein">Baustein: <strong>{{ aktiv }}</strong></div> <div class="ge-block">Block: <strong>{{ active }}</strong></div>
<BausteinPanel <BlockPanel
:key="aktiv" :key="active"
mode="full" mode="full"
:topic="topic" :topic="topic"
:baustein="aktiv" :block="active"
:section="aktivSection?.md || ''" :section="activeSection?.md || ''"
:section-kompakt="aktivSection?.kompakt || ''" :section-compact="activeSection?.compact || ''"
:provider="provider" :provider="provider"
:status="aktivStatus" :status="activeStatus"
:cap="aktivStatus?.cap || 0" :cap="activeStatus?.cap || 0"
@statusChanged="onStatus" @statusChanged="onStatus"
/> />
</div> </div>
@@ -116,5 +116,5 @@ async function onStatus() {
.ge-next { margin-left: auto; padding: 5px 10px; border: 1px solid var(--border-strong); border-radius: 6px; background: var(--bg); cursor: pointer; } .ge-next { margin-left: auto; padding: 5px 10px; border: 1px solid var(--border-strong); border-radius: 6px; background: var(--bg); cursor: pointer; }
.ge-next:hover { color: var(--accent-hover); } .ge-next:hover { color: var(--accent-hover); }
.ge-msg { color: var(--text-muted); } .ge-msg { color: var(--text-muted); }
.ge-baustein { margin-bottom: 8px; color: var(--text-muted); font-size: 0.9rem; } .ge-block { margin-bottom: 8px; color: var(--text-muted); font-size: 0.9rem; }
</style> </style>

View File

@@ -1,17 +1,17 @@
<script setup> <script setup>
import { computed, reactive, ref, watch, nextTick, onMounted, onUnmounted } from 'vue' import { computed, reactive, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { fetchGuideContent, chatGuide, fetchBausteinLernstand, fetchArtefakte } from '../api.js' import { fetchGuideContent, chatGuide, fetchBlockLearnState, fetchArtefakte } from '../api.js'
import { renderMarkdown } from '../markdown.js' import { renderMarkdown } from '../markdown.js'
import { stufeFuer, schwelle } from '../stufen.js' import { stufeFuer, schwelle } from '../levels.js'
import { useChat } from '../composables/useChat.js' import { useChat } from '../composables/useChat.js'
import BausteinPanel from './BausteinPanel.vue' import BlockPanel from './BlockPanel.vue'
import BausteinFokus from './BausteinFokus.vue' import BlockFocus from './BlockFocus.vue'
import FlashcardWidget from './FlashcardWidget.vue' import FlashcardWidget from './FlashcardWidget.vue'
import WorkedExampleBlock from './WorkedExampleBlock.vue' import WorkedExampleBlock from './WorkedExampleBlock.vue'
// Titel-Normalisierung wie backend _norm_titel (casefold ≈ toLowerCase + ß→ss) — für das // Title normalization like backend _norm_title (casefold ≈ toLowerCase + ß→ss) — for
// Andocken der Artefakte (gekeyt nach baustein_norm) an die Section-Titel. // attaching the artifacts (keyed by block_norm) to the section titles.
function normTitel(s) { function normTitle(s) {
return (s || '').normalize('NFKC') return (s || '').normalize('NFKC')
.replace(/[`'"<>„“”‚’«»*_]/g, '').replace(/[–—‐]/g, '-') .replace(/[`'"<>„“”‚’«»*_]/g, '').replace(/[–—‐]/g, '-')
.replace(/\s+/g, ' ').trim().replace(/^[.:;]+|[.:;]+$/g, '').trim() .replace(/\s+/g, ' ').trim().replace(/^[.:;]+|[.:;]+$/g, '').trim()
@@ -22,53 +22,53 @@ const props = defineProps({
previewGuide: { type: Object, default: null }, previewGuide: { type: Object, default: null },
dark: { type: Boolean, default: false }, dark: { type: Boolean, default: false },
provider: { type: String, default: 'claude' }, provider: { type: String, default: 'claude' },
elementsOpen: { type: Boolean, default: false }, // Element-Sidebar offen → Chat nach links elementsOpen: { type: Boolean, default: false }, // element sidebar open → chat to the left
doneByFormat: { type: Object, default: () => ({}) }, // Format → fertiger Guide (Themen-bezogen) doneByFormat: { type: Object, default: () => ({}) }, // format → finished guide (topic-related)
themaAbgeschlossen: { type: Boolean, default: false }, themaAbgeschlossen: { type: Boolean, default: false },
ansichtModus: { type: String, default: 'kompakt' }, // kompakt | erklärend ansichtModus: { type: String, default: 'compact' }, // compact | erklärend
stufeAnsicht: { type: Number, default: 4 }, // 1=A · 2=F · 3=E · 4=V stufeAnsicht: { type: Number, default: 4 }, // 1=A · 2=F · 3=E · 4=V
}) })
const emit = defineEmits(['progressChanged', 'setAnsicht', 'openSidebar', 'fokusActive']) const emit = defineEmits(['progressChanged', 'setAnsicht', 'openSidebar', 'fokusActive'])
// Rotierende Kapitel-Akzentfarben (ohne Rot) // Rotating chapter accent colors (no red)
const CH_COLORS = ['#3b82f6', '#8b5cf6', '#14b8a6', '#f59e0b', '#22c55e', '#6366f1'] const CH_COLORS = ['#3b82f6', '#8b5cf6', '#14b8a6', '#f59e0b', '#22c55e', '#6366f1']
// --- Inhalt laden --- // --- Load content ---
const content = ref(null) const content = ref(null)
const loadError = ref(null) const loadError = ref(null)
const scrollEl = ref(null) const scrollEl = ref(null)
const lernstand = ref({}) // Prüfungs-Stand pro Baustein-Titel — VOR dem immediate-Watch (loadContent nutzt es) const learnstate = ref({}) // exam state per block title — BEFORE the immediate watch (loadContent uses it)
const artefakte = ref({}) // baustein_norm → {karteikarte[], beispiel[], diagramm} const artifacts = ref({}) // block_norm → {flashcard[], example[], diagramm}
function artefakteVon(title) { function artifactsFor(title) {
return artefakte.value[normTitel(title)] || null return artifacts.value[normTitle(title)] || null
} }
// --- Lazy-Render + Markdown-Cache: nur sichtbare Sections parsen, jede nur einmal. // --- Lazy render + markdown cache: parse only visible sections, each only once.
// Behebt das Blockieren beim Öffnen (160× marked/highlight.js) und Re-Parse bei jedem Update. --- // Fixes the freeze on open (160× marked/highlight.js) and re-parse on every update. ---
const mdCache = new Map() // `${modus}:${num}` → html const mdCache = new Map() // `${mode}:${num}` → html
const sichtbar = reactive({}) // num → true (bleibt true, sobald je sichtbar) const visible = reactive({}) // num → true (stays true once ever visible)
let mdObserver = null let mdObserver = null
function htmlFor(s) { function htmlFor(s) {
const key = `${props.ansichtModus}:${s.num}` const key = `${props.ansichtModus}:${s.num}`
let h = mdCache.get(key) let h = mdCache.get(key)
if (h === undefined) { if (h === undefined) {
h = renderMarkdown(props.ansichtModus === 'kompakt' ? (s.kompakt || s.md) : s.md) h = renderMarkdown(props.ansichtModus === 'compact' ? (s.compact || s.md) : s.md)
mdCache.set(key, h) mdCache.set(key, h)
} }
return h return h
} }
// Sections rendern erst, wenn sie (fast) im Viewport sind Observer auf den Scroll-Container. // Sections render only when (almost) in the viewport — observer on the scroll container.
function setupLazy() { function setupLazy() {
mdObserver?.disconnect() mdObserver?.disconnect()
if (!scrollEl.value) return if (!scrollEl.value) return
mdObserver = new IntersectionObserver((entries) => { mdObserver = new IntersectionObserver((entries) => {
for (const e of entries) { for (const e of entries) {
if (!e.isIntersecting) continue if (!e.isIntersecting) continue
sichtbar[Number(e.target.dataset.num)] = true visible[Number(e.target.dataset.num)] = true
mdObserver.unobserve(e.target) mdObserver.unobserve(e.target)
} }
}, { root: scrollEl.value, rootMargin: '800px 0px' }) }, { root: scrollEl.value, rootMargin: '800px 0px' })
@@ -79,115 +79,115 @@ watch(content, () => nextTick(setupLazy))
onUnmounted(() => mdObserver?.disconnect()) onUnmounted(() => mdObserver?.disconnect())
watch(() => props.previewGuide?.id, loadContent, { immediate: true }) watch(() => props.previewGuide?.id, loadContent, { immediate: true })
// Stufen-Ansicht (E/M/S/F) gewechselt → Guide-Inhalt neu mit passendem Ebenen-Filter laden. // Level view (E/M/S/F) changed → reload guide content with the matching depth filter.
watch(() => props.stufeAnsicht, loadContent) watch(() => props.stufeAnsicht, loadContent)
async function loadContent() { async function loadContent() {
content.value = null content.value = null
loadError.value = null loadError.value = null
lernstand.value = {} learnstate.value = {}
mdCache.clear() mdCache.clear()
for (const k in sichtbar) delete sichtbar[k] for (const k in visible) delete visible[k]
const g = props.previewGuide const g = props.previewGuide
if (!g || g.status !== 'done') return if (!g || g.status !== 'done') return
try { try {
content.value = await fetchGuideContent(g.id, props.stufeAnsicht) content.value = await fetchGuideContent(g.id, props.stufeAnsicht)
} catch (e) { } catch (e) {
console.error('Fehler beim Laden des Guides:', e) console.error('Error loading guide:', e)
loadError.value = 'Inhalt nicht verfügbar — die Datei fehlt. Guide neu generieren (▶).' loadError.value = 'Content unavailable — the file is missing. Regenerate the guide (▶).'
return return
} }
try { try {
lernstand.value = (await fetchBausteinLernstand(g.topic)).bausteine || {} learnstate.value = (await fetchBlockLearnState(g.topic)).blocks || {}
} catch { /* offline → leer */ } } catch { /* offline → empty */ }
try { try {
artefakte.value = (await fetchArtefakte(g.topic)).artefakte || {} artifacts.value = (await fetchArtefakte(g.topic)).artefakte || {}
} catch { artefakte.value = {} } } catch { artifacts.value = {} }
// Beim Öffnen zum ersten noch nicht gemeisterten prüfbaren Baustein scrollen. // On open, scroll to the first not-yet-mastered checkable block.
await nextTick() await nextTick()
const ziel = bausteine.value.find((s) => istPruefbar(s) && stufeVon(s.title)?.key !== 'meister') const target = blocks.value.find((s) => isCheckable(s) && levelOf(s.title)?.key !== 'master')
if (ziel) scrollEl.value?.querySelector(`[data-num="${ziel.num}"]`)?.scrollIntoView({ block: 'start' }) if (target) scrollEl.value?.querySelector(`[data-num="${target.num}"]`)?.scrollIntoView({ block: 'start' })
} }
// --- Baustein-Lernen: Prüfungs-Stand pro Baustein-Titel (lernstand oben deklariert) --- // --- Block learning: exam state per block title (learnstate declared above) ---
function stufeVon(title) { function levelOf(title) {
const l = lernstand.value[title] const l = learnstate.value[title]
return l ? stufeFuer(l.gute_antworten || 0, l.cap || 10) : null return l ? stufeFuer(l.good_answers || 0, l.cap || 10) : null
} }
function onBausteinStatus(baustein, status) { function onBlockStatus(block, status) {
const alt = stufeVon(baustein)?.key || null const prevKey = levelOf(block)?.key || null
lernstand.value = { ...lernstand.value, [baustein]: status } learnstate.value = { ...learnstate.value, [block]: status }
const neu = stufeFuer(status.gute_antworten || 0, status.cap || 10)?.key || null const newKey = stufeFuer(status.good_answers || 0, status.cap || 10)?.key || null
if (neu !== alt) emit('progressChanged') // Stufenwechsel → Locks/Stats neu laden if (newKey !== prevKey) emit('progressChanged') // level change → reload locks/stats
} }
// Section nach Prüfen/Beheben/Neu-Schreiben im Content ersetzen + Markdown-Cache leeren. // Replace the section in content after check/fix/rewrite + clear the markdown cache.
function onSectionUpdated({ title, kompakt, md }) { function onSectionUpdated({ title, compact, md }) {
if (!content.value) return if (!content.value) return
for (const ch of content.value.chapters) { for (const ch of content.value.chapters) {
for (const s of ch.sections) { for (const s of ch.sections) {
if (s.title !== title) continue if (s.title !== title) continue
s.md = md s.md = md
s.kompakt = kompakt s.compact = compact
for (const k of [...mdCache.keys()]) if (k.endsWith(`:${s.num}`)) mdCache.delete(k) for (const k of [...mdCache.keys()]) if (k.endsWith(`:${s.num}`)) mdCache.delete(k)
} }
} }
} }
// --- Vollbild-Fokus: ein Baustein groß, Guide links + Prüfung rechts --- // --- Fullscreen focus: one block large, guide left + exam right ---
const fokusIndex = ref(null) // Index in der flachen Baustein-Liste; null = zu const focusIndex = ref(null) // index in the flat block list; null = closed
const fokusTab = ref('pruefung') const focusTab = ref('exam')
const bausteine = computed(() => const blocks = computed(() =>
!content.value ? [] : content.value.chapters.flatMap((ch) => ch.sections), !content.value ? [] : content.value.chapters.flatMap((ch) => ch.sections),
) )
const fokusBaustein = computed(() => (fokusIndex.value === null ? null : bausteine.value[fokusIndex.value])) const focusBlock = computed(() => (focusIndex.value === null ? null : blocks.value[focusIndex.value]))
// Fokus-Zustand an App melden (für die Sidebar über dem Overlay); Themenwechsel schließt den Fokus. // Report focus state to App (for the sidebar above the overlay); a topic change closes the focus.
watch(fokusIndex, (v) => emit('fokusActive', v !== null)) watch(focusIndex, (v) => emit('fokusActive', v !== null))
watch(() => props.previewGuide?.id, () => { fokusIndex.value = null }) watch(() => props.previewGuide?.id, () => { focusIndex.value = null })
function openFokus(baustein, tab) { function openFocus(block, tab) {
if (!istPruefbar(baustein)) return // reine Lese-Sections (Rest/Rand) öffnen keinen Prüfungs-Fokus if (!isCheckable(block)) return // read-only sections (Rest/edge) don't open an exam focus
const i = bausteine.value.findIndex((s) => s.title === baustein.title) const i = blocks.value.findIndex((s) => s.title === block.title)
if (i === -1) return if (i === -1) return
fokusIndex.value = i focusIndex.value = i
fokusTab.value = tab || 'pruefung' focusTab.value = tab || 'exam'
} }
// Nur prüfbare Sections im Fokus anspringen (FullGuide hat dazwischen reine Lese-Sections). // Only jump to checkable sections in focus (FullGuide has read-only sections in between).
function fokusPrev() { function focusPrev() {
for (let i = fokusIndex.value - 1; i >= 0; i--) if (istPruefbar(bausteine.value[i])) { fokusIndex.value = i; return } for (let i = focusIndex.value - 1; i >= 0; i--) if (isCheckable(blocks.value[i])) { focusIndex.value = i; return }
} }
function fokusNext() { function focusNext() {
for (let i = fokusIndex.value + 1; i < bausteine.value.length; i++) if (istPruefbar(bausteine.value[i])) { fokusIndex.value = i; return } for (let i = focusIndex.value + 1; i < blocks.value.length; i++) if (isCheckable(blocks.value[i])) { focusIndex.value = i; return }
} }
// Erfahrungsleiste: kumulativer Stufen-Stand über alle Bausteine (gold ⊆ lila ⊆ blau ⊆ grün). // Experience bar: cumulative level state over all blocks (gold ⊆ purple ⊆ blue ⊆ green).
const fortschritt = computed(() => { const progress = computed(() => {
const z = { total: bausteine.value.length, anfaenger: 0, fortgeschritten: 0, experte: 0, meister: 0 } const z = { total: blocks.value.length, beginner: 0, advanced: 0, expert: 0, master: 0 }
for (const s of bausteine.value) { for (const s of blocks.value) {
const l = lernstand.value[s.title] const l = learnstate.value[s.title]
if (!l) continue if (!l) continue
const sc = l.gute_antworten || 0, cp = l.cap || 10 const sc = l.good_answers || 0, cp = l.cap || 10
if (sc >= schwelle(0.2, cp)) z.anfaenger++ if (sc >= schwelle(0.2, cp)) z.beginner++
if (sc >= schwelle(0.4, cp)) z.fortgeschritten++ if (sc >= schwelle(0.4, cp)) z.advanced++
if (sc >= schwelle(0.6, cp)) z.experte++ if (sc >= schwelle(0.6, cp)) z.expert++
if (sc >= schwelle(1.0, cp)) z.meister++ if (sc >= schwelle(1.0, cp)) z.master++
} }
return z return z
}) })
// cap je Baustein = 4×relevante Subbausteine (vom Backend in lernstand[title].cap geliefert). // cap per block = 4×relevant subblocks (provided by the backend in learnstate[title].cap).
function capVon(title) { function capOf(title) {
return lernstand.value[title]?.cap || 10 return learnstate.value[title]?.cap || 10
} }
// Section prüfbar? Rest nie. Sonst prüfbar, außer das Feld ist explizit false (FullGuide-Rand). // Section checkable? Rest never. Otherwise checkable, unless the field is explicitly false (FullGuide edge).
// Fehlt das Feld (alte Guides ohne `pruefbar`) → prüfbar, damit Bestands-Guides weiter funktionieren. // Missing field (old guides without `checkable`) → checkable, so existing guides keep working.
function istPruefbar(s) { function isCheckable(s) {
return props.previewGuide?.format !== 'Rest' && s.pruefbar !== false return props.previewGuide?.format !== 'Rest' && s.checkable !== false
} }
// --- Chat (Mechanik in useChat; Kontext-Extraktion bleibt hier) --- // --- Chat (mechanics in useChat; context extraction stays here) ---
const chat = useChat((msgs) => { const chat = useChat((msgs) => {
const { section, outline } = extractContext() const { section, outline } = extractContext()
return chatGuide(props.previewGuide.id, { return chatGuide(props.previewGuide.id, {
@@ -209,8 +209,8 @@ function closeChat() {
chat.reset() chat.reset()
} }
// Mobil schließen sich Chat und Elemente-Sidebar gegenseitig aus // On mobile, chat and element sidebar are mutually exclusive
// nebeneinander ist kein Platz, die Sidebar würde den Chat überdecken. // there is no room side by side, the sidebar would cover the chat.
watch(() => props.elementsOpen, (open) => { watch(() => props.elementsOpen, (open) => {
if (open && chatOpen.value && window.matchMedia('(max-width: 768px)').matches) closeChat() if (open && chatOpen.value && window.matchMedia('(max-width: 768px)').matches) closeChat()
}) })
@@ -221,7 +221,7 @@ function onDocMouseDown(e) {
closeChat() closeChat()
} }
// Enter öffnet den Chat (wenn zu, nicht in Eingabefeld); ESC schließt ihn // Enter opens the chat (when closed, not in an input field); ESC closes it
function onDocKeyDown(e) { function onDocKeyDown(e) {
if (e.key === 'Escape' && chatOpen.value) { if (e.key === 'Escape' && chatOpen.value) {
e.preventDefault() e.preventDefault()
@@ -251,7 +251,7 @@ function extractContext() {
.join('\n') .join('\n')
.slice(0, 7000) .slice(0, 7000)
// Aktuelle Section = letzte Karte, deren Oberkante oben im Viewport oder darüber liegt // Current section = last card whose top edge is at or above the top of the viewport
let section = '' let section = ''
const cards = Array.from(scrollEl.value?.querySelectorAll('.section-card') || []) const cards = Array.from(scrollEl.value?.querySelectorAll('.section-card') || [])
let current = null let current = null
@@ -273,11 +273,11 @@ function extractContext() {
<header class="guide-head"> <header class="guide-head">
<h1>{{ previewGuide.topic }}</h1> <h1>{{ previewGuide.topic }}</h1>
<span class="guide-format">{{ previewGuide.format }}</span> <span class="guide-format">{{ previewGuide.format }}</span>
<span v-if="themaAbgeschlossen" class="thema-done" title="Alle Bausteine auf Meister"> Thema abgeschlossen</span> <span v-if="themaAbgeschlossen" class="thema-done" title="All blocks mastered"> Topic completed</span>
<span class="gh-spacer"></span> <span class="gh-spacer"></span>
<div class="ansicht-toggle" title="Ausführlichkeit umschalten"> <div class="ansicht-toggle" title="Toggle verbosity">
<button :class="{ active: ansichtModus === 'kompakt' }" @click="$emit('setAnsicht', 'kompakt')">Kompakt</button> <button :class="{ active: ansichtModus === 'compact' }" @click="$emit('setAnsicht', 'compact')">Compact</button>
<button :class="{ active: ansichtModus === 'erklärend' }" @click="$emit('setAnsicht', 'erklärend')">Erklärend</button> <button :class="{ active: ansichtModus === 'erklärend' }" @click="$emit('setAnsicht', 'erklärend')">Explanatory</button>
</div> </div>
</header> </header>
@@ -294,28 +294,28 @@ function extractContext() {
:key="s.num" :key="s.num"
:data-num="s.num" :data-num="s.num"
class="section-card" class="section-card"
:style="stufeVon(s.title) ? { borderLeftColor: stufeVon(s.title).farbe } : {}" :style="levelOf(s.title) ? { borderLeftColor: levelOf(s.title).farbe } : {}"
> >
<h3 :class="{ 'baustein-klick': istPruefbar(s) }" @click="istPruefbar(s) && openFokus(s, 'pruefung')"> <h3 :class="{ 'block-klick': isCheckable(s) }" @click="isCheckable(s) && openFocus(s, 'exam')">
{{ s.title }} {{ s.title }}
<template v-if="istPruefbar(s) && stufeVon(s.title)"> <template v-if="isCheckable(s) && levelOf(s.title)">
<span class="baustein-done" :style="{ color: stufeVon(s.title).farbe, borderColor: stufeVon(s.title).farbe }" :title="`${stufeVon(s.title).label} (${capVon(s.title)})`">{{ stufeVon(s.title).kurz }} {{ stufeVon(s.title).label }}</span> <span class="block-done" :style="{ color: levelOf(s.title).farbe, borderColor: levelOf(s.title).farbe }" :title="`${levelOf(s.title).label} (${capOf(s.title)})`">{{ levelOf(s.title).kurz }} {{ levelOf(s.title).label }}</span>
</template> </template>
</h3> </h3>
<div v-if="sichtbar[s.num]" class="section-body markdown" v-html="htmlFor(s)"></div> <div v-if="visible[s.num]" class="section-body markdown" v-html="htmlFor(s)"></div>
<div v-else class="section-body skeleton"></div> <div v-else class="section-body skeleton"></div>
<template v-if="sichtbar[s.num] && artefakteVon(s.title)"> <template v-if="visible[s.num] && artifactsFor(s.title)">
<WorkedExampleBlock :beispiele="artefakteVon(s.title).beispiel || []" /> <WorkedExampleBlock :examples="artifactsFor(s.title).example || []" />
<FlashcardWidget :karten="artefakteVon(s.title).karteikarte || []" /> <FlashcardWidget :cards="artifactsFor(s.title).flashcard || []" />
</template> </template>
<BausteinPanel <BlockPanel
v-if="istPruefbar(s)" v-if="isCheckable(s)"
mode="trigger" mode="trigger"
:baustein="s.title" :block="s.title"
:status="lernstand[s.title]" :status="learnstate[s.title]"
:cap="capVon(s.title)" :cap="capOf(s.title)"
:topic="previewGuide.topic" :topic="previewGuide.topic"
@open-fokus="(tab) => openFokus(s, tab)" @open-fokus="(tab) => openFocus(s, tab)"
/> />
</article> </article>
</div> </div>
@@ -324,64 +324,64 @@ function extractContext() {
</div> </div>
<div v-else-if="previewGuide" class="empty-preview"> <div v-else-if="previewGuide" class="empty-preview">
<p>{{ loadError || 'Lade Inhalt…' }}</p> <p>{{ loadError || 'Loading content…' }}</p>
</div> </div>
<div class="empty-preview" v-else> <div class="empty-preview" v-else>
<p>Guide-Format anklicken um zu generieren oder Vorschau zu öffnen.</p> <p>Click a guide format to generate or open a preview.</p>
</div> </div>
<BausteinFokus <BlockFocus
v-if="fokusBaustein" v-if="focusBlock"
:baustein="fokusBaustein" :block="focusBlock"
:artefakte="artefakteVon(fokusBaustein.title)" :artefakte="artifactsFor(focusBlock.title)"
:topic="previewGuide.topic" :topic="previewGuide.topic"
:guide-id="previewGuide.id" :guide-id="previewGuide.id"
:provider="provider" :provider="provider"
:fortschritt="fortschritt" :fortschritt="progress"
:status="lernstand[fokusBaustein.title]" :status="learnstate[focusBlock.title]"
:cap="capVon(fokusBaustein.title)" :cap="capOf(focusBlock.title)"
:tab="fokusTab" :tab="focusTab"
:ansicht="ansichtModus" :ansicht="ansichtModus"
:has-prev="fokusIndex > 0" :has-prev="focusIndex > 0"
:has-next="fokusIndex < bausteine.length - 1" :has-next="focusIndex < blocks.length - 1"
@prev="fokusPrev" @prev="focusPrev"
@next="fokusNext" @next="focusNext"
@close="fokusIndex = null" @close="focusIndex = null"
@set-ansicht="$emit('setAnsicht', $event)" @set-ansicht="$emit('setAnsicht', $event)"
@status-changed="(st) => onBausteinStatus(st.baustein, st)" @status-changed="(st) => onBlockStatus(st.block, st)"
@open-sidebar="$emit('openSidebar')" @open-sidebar="$emit('openSidebar')"
@section-updated="onSectionUpdated" @section-updated="onSectionUpdated"
/> />
<button v-if="previewGuide && !chatOpen && fokusIndex === null" class="chat-fab" :class="{ shifted: elementsOpen }" title="Fragen zum Guide" @click="openChat">💬</button> <button v-if="previewGuide && !chatOpen && focusIndex === null" class="chat-fab" :class="{ shifted: elementsOpen }" title="Questions about the guide" @click="openChat">💬</button>
<div v-if="previewGuide && chatOpen" ref="panelEl" class="chat-panel" :class="{ shifted: elementsOpen }"> <div v-if="previewGuide && chatOpen" ref="panelEl" class="chat-panel" :class="{ shifted: elementsOpen }">
<header class="chat-header"> <header class="chat-header">
<span>Fragen zum Guide</span> <span>Questions about the guide</span>
<button class="chat-close" title="Chat beenden" @click="closeChat">×</button> <button class="chat-close" title="Close chat" @click="closeChat">×</button>
</header> </header>
<div ref="messagesEl" class="chat-messages" @scroll="onScroll"> <div ref="messagesEl" class="chat-messages" @scroll="onScroll">
<p v-if="!messages.length" class="chat-hint">Stell eine Frage zum aktuellen Abschnitt.</p> <p v-if="!messages.length" class="chat-hint">Ask a question about the current section.</p>
<template v-for="(m, i) in messages" :key="i"> <template v-for="(m, i) in messages" :key="i">
<div v-if="m.role === 'assistant'" class="chat-msg assistant markdown" v-html="renderMarkdown(m.content)"></div> <div v-if="m.role === 'assistant'" class="chat-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
<div v-else class="chat-msg user">{{ m.content }}</div> <div v-else class="chat-msg user">{{ m.content }}</div>
</template> </template>
<div v-if="loading" class="chat-msg assistant chat-typing">Denkt…</div> <div v-if="loading" class="chat-msg assistant chat-typing">Thinking…</div>
</div> </div>
<div class="chat-input"> <div class="chat-input">
<textarea <textarea
ref="inputEl" ref="inputEl"
v-model="input" v-model="input"
rows="3" rows="3"
placeholder="Frage stellen" placeholder="Ask a question"
@input="autoGrow" @input="autoGrow"
@keydown.enter.exact.prevent="send" @keydown.enter.exact.prevent="send"
></textarea> ></textarea>
<button <button
:disabled="!input.trim() && !loading" :disabled="!input.trim() && !loading"
:class="{ cancel: loading }" :class="{ cancel: loading }"
:title="loading ? 'Abbrechen' : 'Senden'" :title="loading ? 'Cancel' : 'Send'"
@click="send" @click="send"
>{{ loading ? '✕' : '➤' }}</button> >{{ loading ? '✕' : '➤' }}</button>
</div> </div>
@@ -392,8 +392,8 @@ function extractContext() {
<style scoped> <style scoped>
.detail { .detail {
flex: 1; flex: 1;
/* Flex-Item darf schmaler werden als seine Code-Blöckesonst sprengt /* Flex item may shrink below its code blocksotherwise their
deren Mindestbreite auf Mobile das Layout */ min width breaks the layout on mobile */
min-width: 0; min-width: 0;
height: 100dvh; height: 100dvh;
position: relative; position: relative;
@@ -402,7 +402,7 @@ function extractContext() {
.guide-scroll { .guide-scroll {
height: 100%; height: 100%;
overflow-y: auto; overflow-y: auto;
/* Kein horizontales Pannen der ganzen Seite — Code-Blöcke scrollen intern */ /* No horizontal panning of the whole page — code blocks scroll internally */
overflow-x: hidden; overflow-x: hidden;
background: var(--bg-preview); background: var(--bg-preview);
} }
@@ -411,7 +411,7 @@ function extractContext() {
max-width: 880px; max-width: 880px;
margin: 0 auto; margin: 0 auto;
padding: 2rem 2.5rem 5rem; padding: 2rem 2.5rem 5rem;
/* Lese-Zoom nur für den Inhalt — Sidebar/Chat bleiben unverändert */ /* Reading zoom only for the content — sidebar/chat stay unchanged */
zoom: 1; zoom: 1;
} }
@@ -426,7 +426,7 @@ function extractContext() {
align-items: baseline; align-items: baseline;
gap: 0.75rem; gap: 0.75rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
/* Sticky: der Ausführlichkeits-Toggle bleibt beim Scrollen erreichbar. */ /* Sticky: the verbosity toggle stays reachable while scrolling. */
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 10; z-index: 10;
@@ -513,7 +513,7 @@ function extractContext() {
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
.baustein-done { .block-done {
float: right; float: right;
margin-left: 0.5rem; margin-left: 0.5rem;
padding: 0.12rem 0.6rem; padding: 0.12rem 0.6rem;
@@ -527,38 +527,38 @@ function extractContext() {
white-space: nowrap; white-space: nowrap;
} }
/* Absolvierte Bausteine: Karte kippt sichtbar auf Grün */ /* Completed blocks: card visibly flips to green */
.guide-content .section-card.absolviert { .guide-content .section-card.completed {
border-color: var(--success-border); border-color: var(--success-border);
border-top: 3px solid var(--success); border-top: 3px solid var(--success);
background: color-mix(in srgb, var(--success) 5%, var(--panel)); background: color-mix(in srgb, var(--success) 5%, var(--panel));
} }
/* Verstandene Bausteine (10/10): Lila */ /* Understood blocks (10/10): purple */
.baustein-done.verstanden { .block-done.understood {
background: color-mix(in srgb, #8b5cf6 16%, var(--panel)); background: color-mix(in srgb, #8b5cf6 16%, var(--panel));
border-color: #8b5cf6; border-color: #8b5cf6;
color: #6d28d9; color: #6d28d9;
} }
.guide-content .section-card.verstanden { .guide-content .section-card.understood {
border-color: #8b5cf6; border-color: #8b5cf6;
border-top: 3px solid #8b5cf6; border-top: 3px solid #8b5cf6;
background: color-mix(in srgb, #8b5cf6 7%, var(--panel)); background: color-mix(in srgb, #8b5cf6 7%, var(--panel));
} }
/* Gemeisterte Bausteine (Meisterpfad 25/25): Gold */ /* Mastered blocks (master path 25/25): gold */
.baustein-done.gemeistert { .block-done.mastered {
background: color-mix(in srgb, #d4af37 20%, var(--panel)); background: color-mix(in srgb, #d4af37 20%, var(--panel));
border-color: #d4af37; border-color: #d4af37;
color: #8a6d12; color: #8a6d12;
} }
.guide-content .section-card.gemeistert { .guide-content .section-card.mastered {
border-color: #d4af37; border-color: #d4af37;
border-top: 3px solid #d4af37; border-top: 3px solid #d4af37;
background: color-mix(in srgb, #d4af37 8%, var(--panel)); background: color-mix(in srgb, #d4af37 8%, var(--panel));
} }
/* Guides: Karten tragen die Kapitel-Akzentfarbe */ /* Guides: cards carry the chapter accent color */
.guide-content .section-card { .guide-content .section-card {
border-top: 3px solid color-mix(in srgb, var(--ch-accent, var(--accent)) 65%, transparent); border-top: 3px solid color-mix(in srgb, var(--ch-accent, var(--accent)) 65%, transparent);
background: color-mix(in srgb, var(--ch-accent, var(--accent)) 3%, var(--panel)); background: color-mix(in srgb, var(--ch-accent, var(--accent)) 3%, var(--panel));
@@ -572,12 +572,12 @@ function extractContext() {
} }
} }
/* Titel-Klick öffnet die Vollansicht. */ /* Title click opens the full view. */
.section-card h3.baustein-klick { cursor: pointer; width: fit-content; } .section-card h3.block-klick { cursor: pointer; width: fit-content; }
.section-card h3.baustein-klick:hover { color: var(--accent); } .section-card h3.block-klick:hover { color: var(--accent); }
/* Platzhalter für noch nicht gerenderte Sections (Lazy-Render). Stabile he, /* Placeholder for not-yet-rendered sections (lazy render). Stable height,
damit der IntersectionObserver die folgenden Karten gestaffelt erkennt. */ so the IntersectionObserver detects the following cards in a staggered way. */
.section-body.skeleton { min-height: 160px; } .section-body.skeleton { min-height: 160px; }
.empty-preview { .empty-preview {
@@ -588,16 +588,16 @@ function extractContext() {
color: var(--text-muted); color: var(--text-muted);
} }
/* --- Markdown: Basis global (assets/markdown.css), hier nur Lese-Ansicht-Overrides --- */ /* --- Markdown: base global (assets/markdown.css), here only reading-view overrides --- */
/* Breite Lese-Ansicht: Code scrollt horizontal statt umzubrechen */ /* Wide reading view: code scrolls horizontally instead of wrapping */
.markdown :deep(pre) { .markdown :deep(pre) {
white-space: pre; white-space: pre;
overflow-wrap: normal; overflow-wrap: normal;
overflow-x: auto; overflow-x: auto;
} }
/* „Beispiel"-Überschriften in Karten als dezentes Uppercase-Label */ /* "Example" headings in cards as a subtle uppercase label */
.section-card .markdown :deep(h3) { .section-card .markdown :deep(h3) {
font-size: 0.74em; font-size: 0.74em;
text-transform: uppercase; text-transform: uppercase;
@@ -606,8 +606,8 @@ function extractContext() {
margin: 0.9em 0 0.35em; margin: 0.9em 0 0.35em;
} }
/* Lesbarkeit: ~17px Fließtext, Zeilenhöhe 1.6, Textspalte max. ~70 Zeichen /* Readability: ~17px body text, line height 1.6, text column max ~70 chars
Code-Blöcke dürfen die volle Kartenbreite nutzen */ code blocks may use the full card width */
.section-body { .section-body {
font-size: 1.0625rem; font-size: 1.0625rem;
line-height: 1.6; line-height: 1.6;
@@ -640,7 +640,7 @@ function extractContext() {
background: var(--accent-hover); background: var(--accent-hover);
} }
/* Element-Sidebar (320px) offen → Chat links daneben anzeigen */ /* Element sidebar (320px) open → show chat to its left */
.chat-fab.shifted { .chat-fab.shifted {
right: calc(1.5rem + 320px); right: calc(1.5rem + 320px);
} }
@@ -649,7 +649,7 @@ function extractContext() {
right: calc(1.5rem + 320px); right: calc(1.5rem + 320px);
} }
/* Mobil liegt die Elemente-Sidebar als Overlay über dem Chat — FAB/Panel ausblenden */ /* On mobile the element sidebar overlays the chat — hide FAB/panel */
@media (max-width: 768px) { @media (max-width: 768px) {
.chat-fab.shifted, .chat-fab.shifted,
.chat-panel.shifted { .chat-panel.shifted {

View File

@@ -1,7 +1,7 @@
<script setup> <script setup>
import { ref, reactive, computed } from 'vue' import { ref, reactive, computed } from 'vue'
import { useConfirm } from '../composables/useConfirm.js' import { useConfirm } from '../composables/useConfirm.js'
import { fetchQuelle } from '../api.js' import { fetchSource } from '../api.js'
const props = defineProps({ const props = defineProps({
topics: { type: Array, required: true }, topics: { type: Array, required: true },
@@ -9,26 +9,26 @@ const props = defineProps({
stats: { type: Object, default: null }, stats: { type: Object, default: null },
fortschritt: { type: Object, default: () => ({}) }, fortschritt: { type: Object, default: () => ({}) },
locks: { type: Object, default: () => ({}) }, locks: { type: Object, default: () => ({}) },
guideStepsDone: { type: Object, default: () => ({}) }, // höchster fertiger Schritt je Format (-1 = keiner) guideStepsDone: { type: Object, default: () => ({}) }, // highest finished step per format (-1 = none)
uiError: { type: String, default: null }, uiError: { type: String, default: null },
doneByFormat: { type: Object, default: () => ({}) }, doneByFormat: { type: Object, default: () => ({}) },
latestByFormat: { type: Object, default: () => ({}) }, latestByFormat: { type: Object, default: () => ({}) },
allGuides: { type: Array, default: () => [] }, allGuides: { type: Array, default: () => [] },
dismissedErrors: { type: Object, default: () => new Set() }, dismissedErrors: { type: Object, default: () => new Set() },
bausteine: { type: Object, default: () => ({ ready: false, generating: false, progress: null, error: null }) }, blocks: { type: Object, default: () => ({ ready: false, generating: false, progress: null, error: null }) },
activeBausteine: { type: Array, default: () => [] }, activeBausteine: { type: Array, default: () => [] },
pinned: { type: Boolean, default: true }, pinned: { type: Boolean, default: true },
dark: { type: Boolean, default: false }, dark: { type: Boolean, default: false },
provider: { type: String, default: 'claude' }, provider: { type: String, default: 'claude' },
providers: { type: Array, default: () => [] }, providers: { type: Array, default: () => [] },
folders: { type: Object, default: () => ({ projekt: [], uni: [] }) }, folders: { type: Object, default: () => ({ projekt: [], uni: [] }) },
ansichtModus: { type: String, default: 'kompakt' }, // kompakt | erklärend ansichtModus: { type: String, default: 'compact' }, // compact | erklärend
stufeAnsicht: { type: Number, default: 4 }, // 1=A · 2=F · 3=E · 4=V stufeAnsicht: { type: Number, default: 4 }, // 1=A · 2=F · 3=E · 4=V
}) })
const emit = defineEmits(['select', 'create', 'createThema', 'updateQuelle', 'formatClick', 'bausteineClick', 'cancelBausteine', 'resetBausteine', 'deleteTopic', 'cancelGuide', 'deleteGuide', 'dismissError', 'dismissUiError', 'preview', 'openBausteineView', 'openElements', 'togglePin', 'sidebarLeave', 'toggleDark', 'setAnsicht', 'setStufe', 'generalExam', 'setProvider']) const emit = defineEmits(['select', 'createThema', 'updateSource', 'formatClick', 'bausteineClick', 'cancelBlocks', 'resetBausteine', 'deleteTopic', 'cancelGuide', 'deleteGuide', 'dismissError', 'dismissUiError', 'preview', 'openBausteineView', 'openElements', 'togglePin', 'sidebarLeave', 'toggleDark', 'setAnsicht', 'setStufe', 'generalExam', 'setProvider'])
// Accordion: höchstens ein Panel offen. IDs: 'bausteine', 'fmt-<Format>', 'topic-<Name>'. // Accordion: at most one panel open. IDs: 'blocks', 'fmt-<Format>', 'topic-<Name>'.
const openPanel = ref(null) const openPanel = ref(null)
const isOpen = (id) => openPanel.value === id const isOpen = (id) => openPanel.value === id
function togglePanel(id) { function togglePanel(id) {
@@ -40,16 +40,16 @@ function providerAvailable(id) {
return p ? p.available : true return p ? p.available : true
} }
const PROVIDER_LABELS = { claude: 'Claude', minimax: 'MiniMax', lokal: 'Lokal' } const PROVIDER_LABELS = { claude: 'Claude', minimax: 'MiniMax', lokal: 'Local' }
// Tracker oben in der Navigation: Themen gesamt, pro Format erstellt/absolviert // Tracker at the top of the navigation: total topics, created/completed per format
const trackerItems = computed(() => { const trackerItems = computed(() => {
if (!props.stats) return [] if (!props.stats) return []
const f = props.stats.formate || {} const f = props.stats.formats || {}
const fmt = (k) => `${f[k]?.absolviert ?? 0}/${f[k]?.erstellt ?? 0}` const fmt = (k) => `${f[k]?.completed ?? 0}/${f[k]?.erstellt ?? 0}`
return [ return [
{ label: 'Themen', value: String(props.stats.themen ?? 0), title: 'Themen inkl. Projekte' }, { label: 'Topics', value: String(props.stats.topics ?? 0), title: 'Topics incl. projects' },
{ label: 'Guides', value: fmt('Guide'), title: 'absolviert/erstellt' }, { label: 'Guides', value: fmt('Guide'), title: 'completed/created' },
] ]
}) })
@@ -57,58 +57,58 @@ const formats = [
{ key: 'Guide', label: 'Guide' }, { key: 'Guide', label: 'Guide' },
] ]
// Stufen-Ansichten des EINEN Guides (gefiltert nach Subbaustein-Ebene). // Level views of the SINGLE guide (filtered by subblock depth).
const STUFEN_ANSICHT = [ const LEVEL_VIEWS = [
{ k: 1, label: 'A', titel: 'Anfänger' }, { k: 1, label: 'A', title: 'Beginner' },
{ k: 2, label: 'F', titel: 'Anfänger + Fortgeschritten' }, { k: 2, label: 'F', title: 'Beginner + Advanced' },
{ k: 3, label: 'E', titel: 'bis Experte' }, { k: 3, label: 'E', title: 'up to Expert' },
{ k: 4, label: 'V', titel: 'Vollständig (inkl. Rand)' }, { k: 4, label: 'V', title: 'Complete (incl. edge)' },
] ]
const bausteineState = computed(() => { const blocksState = computed(() => {
if (props.bausteine.generating) return 'generating' if (props.blocks.generating) return 'generating'
return props.bausteine.ready ? 'done' : 'none' return props.blocks.ready ? 'done' : 'none'
}) })
// Re-Run ab Teilschritt passiert jetzt in der Bausteine-Übersicht; die Sidebar hat keine Phasen-Pillen mehr. // Re-run from a substep now happens in the blocks overview; the sidebar no longer has phase pills.
// Nur FREMDE Themen — das gewählte Thema zeigt seinen Fortschritt inline an der Zeile // Only OTHER topics — the selected topic shows its progress inline on the row
const activeGenerations = computed(() => { const activeGenerations = computed(() => {
const bausteinLines = props.activeBausteine const blockLines = props.activeBausteine
.filter((b) => b.topic !== props.selectedTopic) .filter((b) => b.topic !== props.selectedTopic)
.map((b) => `${b.topic} Bausteine: ${b.progress || 'Wartend…'}`) .map((b) => `${b.topic} Blocks: ${b.progress || 'Waiting…'}`)
const guideLines = props.allGuides const guideLines = props.allGuides
.filter((g) => (g.status === 'generating' || g.status === 'queued') && g.topic !== props.selectedTopic) .filter((g) => (g.status === 'generating' || g.status === 'queued') && g.topic !== props.selectedTopic)
.map((g) => `${g.topic} ${g.format}: ${g.progress || 'Wartend…'}`) .map((g) => `${g.topic} ${g.format}: ${g.progress || 'Waiting…'}`)
return [...bausteinLines, ...guideLines] return [...blockLines, ...guideLines]
}) })
const { pending: pendingConfirm, armOrRun } = useConfirm() const { pending: pendingConfirm, armOrRun } = useConfirm()
function confirmCancelBausteine() { function confirmCancelBlocks() {
armOrRun('bausteine', () => emit('cancelBausteine')) armOrRun('blocks', () => emit('cancelBlocks'))
} }
function confirmResetBausteine() { function confirmResetBlocks() {
armOrRun('bausteine', () => emit('resetBausteine')) armOrRun('blocks', () => emit('resetBausteine'))
} }
function handleBausteinePlay() { function handleBlocksPlay() {
if (bausteineState.value === 'generating') return if (blocksState.value === 'generating') return
// „Neu generieren" (ready) = ab Anfang; Erstbau/Fortsetzen ohne Löschen. Feiner Re-Run via Übersicht. // "Regenerate" (ready) = from start; first build/resume without deleting. Fine re-run via overview.
const abPhase = props.bausteine.ready ? 1 : null const abPhase = props.blocks.ready ? 1 : null
emit('bausteineClick', { instructions: '', abPhase }) emit('bausteineClick', { instructions: '', abPhase })
} }
// Name-Klick = Primäraktion: fertige Bausteine → Übersicht, sonst Panel auf/zu. // Name click = primary action: finished blocks → overview, otherwise toggle panel.
function onBausteineName() { function onBlocksName() {
if (props.bausteine.ready && !props.bausteine.generating) emit('openBausteineView') if (props.blocks.ready && !props.blocks.generating) emit('openBausteineView')
else togglePanel('bausteine') else togglePanel('blocks')
} }
function guideStatus(format) { function guideStatus(format) {
// Laufende Generierung hat Vorrang — sonst maskiert ein älterer fertiger // Running generation takes precedence — otherwise an older finished
// Guide den Lauf und ▶ würde Duplikate starten. // guide masks the run and ▶ would start duplicates.
const latest = props.latestByFormat[format] const latest = props.latestByFormat[format]
if (latest && (latest.status === 'generating' || latest.status === 'queued')) return latest.status if (latest && (latest.status === 'generating' || latest.status === 'queued')) return latest.status
if (props.doneByFormat[format]) return 'done' if (props.doneByFormat[format]) return 'done'
@@ -116,74 +116,74 @@ function guideStatus(format) {
return latest.status return latest.status
} }
// Schritt-Kugeln der Guide-Pipeline // Step dots of the guide pipeline
const GUIDE_STEPS = ['Gliederung', 'Inhalte', 'Inhalts-Check', 'Schreiben', 'Lese-Prüfung'] const GUIDE_STEPS = ['Outline', 'Content', 'Content check', 'Writing', 'Reading exam']
// Kugeln aus dem artefakt-basierten „fertig"-Marker (wie Bausteine, nicht aus dem DB-Zähler): // Dots from the artifact-based "done" marker (like blocks, not the DB counter):
// ≤ fertig = done. Läuft gerade → der nächste Schritt (fertig+1) ist aktiv. // ≤ done = done. Running → the next step (done+1) is active.
function guideSteps(format) { function guideSteps(format) {
const labels = GUIDE_STEPS const labels = GUIDE_STEPS
const fertig = props.guideStepsDone[format] ?? -1 const done = props.guideStepsDone[format] ?? -1
const st = guideStatus(format) const st = guideStatus(format)
const aktiv = st === 'generating' || st === 'queued' ? fertig + 1 : -1 const active = st === 'generating' || st === 'queued' ? done + 1 : -1
return labels.map((label, i) => ({ return labels.map((label, i) => ({
label, label,
state: i <= fertig ? 'done' : i === aktiv ? 'active' : 'pending', state: i <= done ? 'done' : i === active ? 'active' : 'pending',
})) }))
} }
// Re-Run ab Guide-Schritt (1-basierte Kugel je Format). null = voll/Resume. // Re-run from a guide step (1-based dot per format). null = full/resume.
const gewaehlterStep = reactive({}) const selectedStep = reactive({})
// Kugeln klickbar, sobald Artefakte existieren (Marker ≥ 0 oder fertig) und nicht generiert wird. // Dots clickable once artifacts exist (marker ≥ 0 or done) and not generating.
function guideWaehlbar(format) { function guideSelectable(format) {
const st = guideStatus(format) const st = guideStatus(format)
if (st === 'generating' || st === 'queued') return false if (st === 'generating' || st === 'queued') return false
return (props.guideStepsDone[format] ?? -1) >= 0 || st === 'done' return (props.guideStepsDone[format] ?? -1) >= 0 || st === 'done'
} }
function guideStepKlick(format, n) { function guideStepClick(format, n) {
if (!guideWaehlbar(format)) return if (!guideSelectable(format)) return
gewaehlterStep[format] = gewaehlterStep[format] === n ? null : n selectedStep[format] = selectedStep[format] === n ? null : n
} }
function gewaehltesStepLabel(format) { function selectedStepLabel(format) {
return GUIDE_STEPS[(gewaehlterStep[format] || 0) - 1] || '' return GUIDE_STEPS[(selectedStep[format] || 0) - 1] || ''
} }
function errorMsg(format) { function errorMsg(format) {
const latest = props.latestByFormat[format] const latest = props.latestByFormat[format]
if (latest?.status !== 'error' || props.dismissedErrors.has(latest.id)) return '' if (latest?.status !== 'error' || props.dismissedErrors.has(latest.id)) return ''
if (abgebrochen(format)) return '' // kein roter Fehler — das Pausiert-Badge zeigt den Zustand if (aborted(format)) return '' // no red error — the Paused badge shows the state
return latest.error_msg || 'Fehler bei der Generierung' return latest.error_msg || 'Generation failed'
} }
// Abgebrochener Lauf = Teilfortschritt vorhanden: ▶ setzt fort, ✕ löscht den Fortschritt // Aborted run = partial progress present: ▶ resumes, ✕ deletes the progress
function abgebrochen(format) { function aborted(format) {
const latest = props.latestByFormat[format] const latest = props.latestByFormat[format]
return latest?.status === 'error' && (latest.error_msg || '').startsWith('Abgebrochen') return latest?.status === 'error' && (latest.error_msg || '').startsWith('Cancelled')
} }
// Name-Klick: fertiger Guide → Vorschau, sonst Aktions-Panel auf/zu. // Name click: finished guide → preview, otherwise toggle action panel.
function handleFormatClick(format) { function handleFormatClick(format) {
const guide = props.doneByFormat[format] const guide = props.doneByFormat[format]
if (guide) emit('preview', guide) if (guide) emit('preview', guide)
else togglePanel('fmt-' + format) else togglePanel('fmt-' + format)
} }
// Sperr-Gründe kommen vom Backend (GET /guides/locks) — die Regeln existieren // Lock reasons come from the backend (GET /guides/locks) — the rules only
// nur noch dort. Solange locks noch nicht geladen sind: Button frei, das // exist there now. While locks are not yet loaded: button enabled, the
// Backend weist ungültige Starts ohnehin ab (sichtbar über uiError). // backend rejects invalid starts anyway (visible via uiError).
function playLock(format) { function playLock(format) {
return props.locks?.[format] ?? null return props.locks?.[format] ?? null
} }
function handlePlay(format) { function handlePlay(format) {
if (playLock(format)) return if (playLock(format)) return
// Gewählte Kugel (1-basiert) → ab_step (0-basiert). Nur bei (teil-)gebautem Guide. // Selected dot (1-based) → ab_step (0-based). Only for a (partially) built guide.
const abStep = guideWaehlbar(format) && gewaehlterStep[format] ? gewaehlterStep[format] - 1 : null const abStep = guideSelectable(format) && selectedStep[format] ? selectedStep[format] - 1 : null
emit('formatClick', { format, instructions: '', abStep }) emit('formatClick', { format, instructions: '', abStep })
gewaehlterStep[format] = null selectedStep[format] = null
} }
// Flash-Message-Verhalten: × blendet nur aus, nichts wird gelöscht // Flash-message behavior: × only hides, nothing is deleted
function dismissError(format) { function dismissError(format) {
const latest = props.latestByFormat[format] const latest = props.latestByFormat[format]
if (latest?.status === 'error') emit('dismissError', latest.id) if (latest?.status === 'error') emit('dismissError', latest.id)
@@ -192,15 +192,15 @@ function dismissError(format) {
function handleDelete(format) { function handleDelete(format) {
if (!props.latestByFormat[format]) return if (!props.latestByFormat[format]) return
armOrRun('fmt-' + format, () => { armOrRun('fmt-' + format, () => {
// Alle laufenden Generierungen des Formats abbrechen (deckt auch Duplikate ab) // Cancel all running generations of the format (also covers duplicates)
const running = props.allGuides.filter( const running = props.allGuides.filter(
(g) => g.topic === props.selectedTopic && g.format === format (g) => g.topic === props.selectedTopic && g.format === format
&& (g.status === 'generating' || g.status === 'queued'), && (g.status === 'generating' || g.status === 'queued'),
) )
if (running.length) { if (running.length) {
for (const g of running) emit('cancelGuide', g.id) for (const g of running) emit('cancelGuide', g.id)
} else if (abgebrochen(format)) { } else if (aborted(format)) {
// Pausierter Lauf: Teilfortschritt samt Schritt-Dateien löschen (Reset) // Paused run: delete partial progress incl. step files (reset)
emit('deleteGuide', props.latestByFormat[format].id, true) emit('deleteGuide', props.latestByFormat[format].id, true)
} else { } else {
emit('deleteGuide', props.latestByFormat[format].id) emit('deleteGuide', props.latestByFormat[format].id)
@@ -208,7 +208,7 @@ function handleDelete(format) {
}) })
} }
// Erstellen-Bereich: inline aufklappbar (Name + weitere Infos + Quellen-Typ). // Create area: inline expandable (name + more info + source type).
const dlg = ref(false) const dlg = ref(false)
const form = ref({ name: '', instructions: '', sourceType: 'thema', sourceOrt: '' }) const form = ref({ name: '', instructions: '', sourceType: 'thema', sourceOrt: '' })
const canCreate = computed(() => { const canCreate = computed(() => {
@@ -217,12 +217,12 @@ const canCreate = computed(() => {
return true return true
}) })
function toggleErstellen() { function toggleCreate() {
if (!dlg.value) form.value = { name: '', instructions: '', sourceType: 'thema', sourceOrt: '' } if (!dlg.value) form.value = { name: '', instructions: '', sourceType: 'thema', sourceOrt: '' }
dlg.value = !dlg.value dlg.value = !dlg.value
} }
function createThema() { function createTopic() {
if (!canCreate.value) return if (!canCreate.value) return
emit('createThema', { emit('createThema', {
topic: form.value.name.trim(), topic: form.value.name.trim(),
@@ -237,7 +237,7 @@ function confirmDeleteTopic(topic) {
armOrRun('topic-' + topic, () => emit('deleteTopic', topic)) armOrRun('topic-' + topic, () => emit('deleteTopic', topic))
} }
// --- Thema-Edit: Name-Klick wählt nur; das Chevron klappt die Quellen-Form auf/zu (Default zu) --- // --- Topic edit: name click only selects; the chevron toggles the sources form (default closed) ---
const editTopic = ref(null) const editTopic = ref(null)
const editForm = ref({ type: 'thema', ort: '', spec: '' }) const editForm = ref({ type: 'thema', ort: '', spec: '' })
const editLoading = ref(false) const editLoading = ref(false)
@@ -253,8 +253,8 @@ async function toggleTopicPanel(t) {
editTopic.value = t editTopic.value = t
editLoading.value = true editLoading.value = true
try { try {
const q = await fetchQuelle(t) const q = await fetchSource(t)
editForm.value = { type: q.type || 'thema', ort: q.ort || '', spec: q.spec || '' } editForm.value = { type: q.type || 'thema', ort: q.location || '', spec: q.spec || '' }
} catch { } catch {
editForm.value = { type: 'thema', ort: '', spec: '' } editForm.value = { type: 'thema', ort: '', spec: '' }
} finally { } finally {
@@ -267,9 +267,9 @@ function setEditType(t) {
editForm.value.ort = '' editForm.value.ort = ''
} }
function saveQuelle() { function saveSource() {
if (!canSave.value || !editTopic.value) return if (!canSave.value || !editTopic.value) return
emit('updateQuelle', { emit('updateSource', {
topic: editTopic.value, topic: editTopic.value,
type: editForm.value.type, type: editForm.value.type,
ort: editForm.value.ort.trim(), ort: editForm.value.ort.trim(),
@@ -290,22 +290,22 @@ function saveQuelle() {
<div class="new-topic"> <div class="new-topic">
<button <button
class="pin-btn" class="pin-btn"
:title="pinned ? 'Sidebar ausblenden' : 'Sidebar fixieren'" :title="pinned ? 'Hide sidebar' : 'Pin sidebar'"
@click="emit('togglePin')" @click="emit('togglePin')"
>{{ pinned ? '⇤' : '⇥' }}</button> >{{ pinned ? '⇤' : '⇥' }}</button>
<button <button
class="theme-btn" class="theme-btn"
:title="dark ? 'Hellmodus' : 'Dunkelmodus'" :title="dark ? 'Light mode' : 'Dark mode'"
@click="emit('toggleDark')" @click="emit('toggleDark')"
>{{ dark ? '☀' : '🌙' }}</button> >{{ dark ? '☀' : '🌙' }}</button>
<div v-if="selectedTopic" class="ansicht-toggle"> <div v-if="selectedTopic" class="ansicht-toggle">
<button :class="{ active: ansichtModus === 'kompakt' }" title="Kurzer Text" @click="emit('setAnsicht', 'kompakt')"> <button :class="{ active: ansichtModus === 'compact' }" title="Short text" @click="emit('setAnsicht', 'compact')">
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"> <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
<rect x="2" y="5" width="12" height="1.6" rx="0.8" /> <rect x="2" y="5" width="12" height="1.6" rx="0.8" />
<rect x="2" y="9" width="7" height="1.6" rx="0.8" /> <rect x="2" y="9" width="7" height="1.6" rx="0.8" />
</svg> </svg>
</button> </button>
<button :class="{ active: ansichtModus === 'erklärend' }" title="Langer Text" @click="emit('setAnsicht', 'erklärend')"> <button :class="{ active: ansichtModus === 'erklärend' }" title="Long text" @click="emit('setAnsicht', 'erklärend')">
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"> <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
<rect x="2" y="3" width="12" height="1.4" rx="0.7" /> <rect x="2" y="3" width="12" height="1.4" rx="0.7" />
<rect x="2" y="6.3" width="12" height="1.4" rx="0.7" /> <rect x="2" y="6.3" width="12" height="1.4" rx="0.7" />
@@ -314,39 +314,39 @@ function saveQuelle() {
</svg> </svg>
</button> </button>
</div> </div>
<div v-if="selectedTopic" class="stufe-toggle" title="Tiefe der Guide-Ansicht"> <div v-if="selectedTopic" class="level-toggle" title="Depth of guide view">
<button v-for="s in STUFEN_ANSICHT" :key="s.k" <button v-for="s in LEVEL_VIEWS" :key="s.k"
:class="{ active: stufeAnsicht === s.k }" :title="s.titel" :class="{ active: stufeAnsicht === s.k }" :title="s.title"
@click="emit('setStufe', s.k)">{{ s.label }}</button> @click="emit('setStufe', s.k)">{{ s.label }}</button>
</div> </div>
<button class="new-topic-toggle" :class="{ active: dlg }" title="Thema erstellen" @click="toggleErstellen">+</button> <button class="new-topic-toggle" :class="{ active: dlg }" title="Create topic" @click="toggleCreate">+</button>
</div> </div>
<!-- Erstellen: inline aufklappbar (kein Modal) --> <!-- Create: inline expandable (no modal) -->
<div v-if="dlg" class="thema-panel"> <div v-if="dlg" class="thema-panel">
<input class="dlg-input" v-model="form.name" placeholder="Thema-Name…" @keyup.enter="createThema" autofocus /> <input class="dlg-input" v-model="form.name" placeholder="Topic name…" @keyup.enter="createTopic" autofocus />
<textarea class="dlg-textarea" v-model="form.instructions" rows="2" placeholder="Weitere Infos (optional)…"></textarea> <textarea class="dlg-textarea" v-model="form.instructions" rows="2" placeholder="More info (optional)…"></textarea>
<div class="dlg-sources"> <div class="dlg-sources">
<button :class="{ active: form.sourceType === 'thema' }" @click="form.sourceType = 'thema'; form.sourceOrt = ''">Thema</button> <button :class="{ active: form.sourceType === 'thema' }" @click="form.sourceType = 'thema'; form.sourceOrt = ''">Topic</button>
<button :class="{ active: form.sourceType === 'link' }" @click="form.sourceType = 'link'; form.sourceOrt = ''">Link</button> <button :class="{ active: form.sourceType === 'link' }" @click="form.sourceType = 'link'; form.sourceOrt = ''">Link</button>
<button :class="{ active: form.sourceType === 'projekt' }" @click="form.sourceType = 'projekt'; form.sourceOrt = ''">Projekt</button> <button :class="{ active: form.sourceType === 'projekt' }" @click="form.sourceType = 'projekt'; form.sourceOrt = ''">Project</button>
<button :class="{ active: form.sourceType === 'uni' }" @click="form.sourceType = 'uni'; form.sourceOrt = ''">Uni</button> <button :class="{ active: form.sourceType === 'uni' }" @click="form.sourceType = 'uni'; form.sourceOrt = ''">Uni</button>
</div> </div>
<input <input
v-if="form.sourceType === 'link'" v-if="form.sourceType === 'link'"
class="dlg-input" v-model="form.sourceOrt" class="dlg-input" v-model="form.sourceOrt"
placeholder="https://…" @keyup.enter="createThema" placeholder="https://…" @keyup.enter="createTopic"
/> />
<select <select
v-else-if="form.sourceType === 'projekt' || form.sourceType === 'uni'" v-else-if="form.sourceType === 'projekt' || form.sourceType === 'uni'"
class="dlg-input" v-model="form.sourceOrt" class="dlg-input" v-model="form.sourceOrt"
> >
<option value="" disabled>Ordner wählen</option> <option value="" disabled>Choose folder</option>
<option v-for="fo in (folders[form.sourceType] || [])" :key="fo.ort" :value="fo.ort">{{ fo.name }}</option> <option v-for="fo in (folders[form.sourceType] || [])" :key="fo.location" :value="fo.location">{{ fo.name }}</option>
</select> </select>
<div class="dlg-actions"> <div class="dlg-actions">
<button class="dlg-cancel" @click="dlg = false">Abbrechen</button> <button class="dlg-cancel" @click="dlg = false">Cancel</button>
<button class="dlg-create" :disabled="!canCreate" @click="createThema">Erstellen</button> <button class="dlg-create" :disabled="!canCreate" @click="createTopic">Create</button>
</div> </div>
</div> </div>
<div class="provider-toggle" v-if="providers.length"> <div class="provider-toggle" v-if="providers.length">
@@ -355,113 +355,113 @@ function saveQuelle() {
:key="p.id" :key="p.id"
:class="{ active: p.id === provider }" :class="{ active: p.id === provider }"
:disabled="!p.available" :disabled="!p.available"
:title="p.available ? '' : 'Nicht konfiguriert (CLI/Key fehlt)'" :title="p.available ? '' : 'Not configured (CLI/key missing)'"
@click="emit('setProvider', p.id)" @click="emit('setProvider', p.id)"
>{{ PROVIDER_LABELS[p.id] || p.id }}</button> >{{ PROVIDER_LABELS[p.id] || p.id }}</button>
</div> </div>
<div class="format-section" v-if="selectedTopic"> <div class="format-section" v-if="selectedTopic">
<div class="format-error ui-error" v-if="uiError"> <div class="format-error ui-error" v-if="uiError">
<span class="format-error-text">{{ uiError }}</span> <span class="format-error-text">{{ uiError }}</span>
<button class="format-error-x" title="Ausblenden" @click="emit('dismissUiError')">×</button> <button class="format-error-x" title="Hide" @click="emit('dismissUiError')">×</button>
</div> </div>
<div class="progress-info" v-if="activeGenerations.length"> <div class="progress-info" v-if="activeGenerations.length">
<div v-for="(line, i) in activeGenerations" :key="i">{{ line }}</div> <div v-for="(line, i) in activeGenerations" :key="i">{{ line }}</div>
</div> </div>
<div class="ord-bausteine"> <div class="ord-blocks">
<div <div
class="format-row bausteine-row" class="format-row blocks-row"
:class="{ 'is-active': bausteineState === 'generating' || bausteine.partial, 'row-open': isOpen('bausteine') }" :class="{ 'is-active': blocksState === 'generating' || blocks.partial, 'row-open': isOpen('blocks') }"
> >
<button class="format-name bausteine-name" @click="onBausteineName"> <button class="format-name blocks-name" @click="onBlocksName">
<span class="format-label">Bausteine</span> <span class="format-label">Blocks</span>
<span <span
v-if="bausteine.partial && bausteineState !== 'generating'" v-if="blocks.partial && blocksState !== 'generating'"
class="resume-badge" class="resume-badge"
title="Abgebrochen — Fortsetzen möglich" title="Aborted — can be resumed"
>Pausiert</span> >Paused</span>
</button> </button>
<button class="panel-toggle" :class="{ open: isOpen('bausteine') }" title="Aktionen" @click.stop="togglePanel('bausteine')"></button> <button class="panel-toggle" :class="{ open: isOpen('blocks') }" title="Actions" @click.stop="togglePanel('blocks')"></button>
</div> </div>
<div v-if="isOpen('bausteine')" class="action-panel"> <div v-if="isOpen('blocks')" class="action-panel">
<template v-if="bausteineState === 'generating'"> <template v-if="blocksState === 'generating'">
<button class="panel-btn danger" :class="{ armed: pendingConfirm === 'bausteine' }" @click="confirmCancelBausteine">{{ pendingConfirm === 'bausteine' ? 'Sicher?' : 'Abbrechen' }}</button> <button class="panel-btn danger" :class="{ armed: pendingConfirm === 'blocks' }" @click="confirmCancelBlocks">{{ pendingConfirm === 'blocks' ? 'Sure?' : 'Cancel' }}</button>
</template> </template>
<template v-else> <template v-else>
<button class="panel-btn play" @click="handleBausteinePlay">{{ bausteine.partial ? 'Fortsetzen' : bausteine.ready ? 'Neu generieren' : 'Generieren' }}</button> <button class="panel-btn play" @click="handleBlocksPlay">{{ blocks.partial ? 'Resume' : blocks.ready ? 'Regenerate' : 'Generate' }}</button>
<button <button
v-if="bausteine.ready || bausteine.partial" v-if="blocks.ready || blocks.partial"
class="panel-btn danger" class="panel-btn danger"
:class="{ armed: pendingConfirm === 'bausteine' }" :class="{ armed: pendingConfirm === 'blocks' }"
@click="confirmResetBausteine" @click="confirmResetBlocks"
>{{ pendingConfirm === 'bausteine' ? 'Sicher?' : 'Entfernen' }}</button> >{{ pendingConfirm === 'blocks' ? 'Sure?' : 'Remove' }}</button>
</template> </template>
</div> </div>
<div v-if="bausteineState === 'generating'" class="format-progress"> <div v-if="blocksState === 'generating'" class="format-progress">
{{ bausteine.progress || 'Wartend' }} {{ blocks.progress || 'Waiting' }}
</div> </div>
<div v-if="bausteine.error && !bausteine.error.startsWith('Abgebrochen')" class="format-error"> <div v-if="blocks.error && !blocks.error.startsWith('Cancelled')" class="format-error">
<span class="format-error-text">{{ bausteine.error }}</span> <span class="format-error-text">{{ blocks.error }}</span>
</div> </div>
</div> </div>
<!-- Formate stehen per CSS-order nach der Bausteine-Zeile (order 2) --> <!-- Formats come after the blocks row via CSS order (order 2) -->
<div v-for="f in formats" :key="f.key" :style="{ order: 3 }"> <div v-for="f in formats" :key="f.key" :style="{ order: 3 }">
<div :class="['format-row', 'fmt-' + guideStatus(f.key), { 'fmt-paused': abgebrochen(f.key), 'row-open': isOpen('fmt-' + f.key) }]"> <div :class="['format-row', 'fmt-' + guideStatus(f.key), { 'fmt-paused': aborted(f.key), 'row-open': isOpen('fmt-' + f.key) }]">
<button class="format-name" @click="handleFormatClick(f.key)"> <button class="format-name" @click="handleFormatClick(f.key)">
<span class="format-label">{{ f.label }}</span> <span class="format-label">{{ f.label }}</span>
<span <span
v-if="abgebrochen(f.key)" v-if="aborted(f.key)"
class="resume-badge" class="resume-badge"
title="Abgebrochen — Fortsetzen möglich" title="Aborted — can be resumed"
>Pausiert</span> >Paused</span>
<span class="step-dots" v-if="guideSteps(f.key).length"> <span class="step-dots" v-if="guideSteps(f.key).length">
<span <span
v-for="(s, i) in guideSteps(f.key)" v-for="(s, i) in guideSteps(f.key)"
:key="s.label" :key="s.label"
class="step-pill" class="step-pill"
:class="[s.state, { sel: gewaehlterStep[f.key] === i + 1, klickbar: guideWaehlbar(f.key) }]" :class="[s.state, { sel: selectedStep[f.key] === i + 1, klickbar: guideSelectable(f.key) }]"
:title="(s.state === 'active' ? (latestByFormat[f.key]?.progress || s.label) : s.label) + (guideWaehlbar(f.key) ? ' — Klick: ab hier neu' : '')" :title="(s.state === 'active' ? (latestByFormat[f.key]?.progress || s.label) : s.label) + (guideSelectable(f.key) ? ' — Click: regenerate from here' : '')"
@click.stop="guideStepKlick(f.key, i + 1)" @click.stop="guideStepClick(f.key, i + 1)"
>{{ i + 1 }}</span> >{{ i + 1 }}</span>
</span> </span>
</button> </button>
<button class="panel-toggle" :class="{ open: isOpen('fmt-' + f.key) }" title="Aktionen" @click.stop="togglePanel('fmt-' + f.key)"></button> <button class="panel-toggle" :class="{ open: isOpen('fmt-' + f.key) }" title="Actions" @click.stop="togglePanel('fmt-' + f.key)"></button>
</div> </div>
<div v-if="isOpen('fmt-' + f.key)" class="action-panel"> <div v-if="isOpen('fmt-' + f.key)" class="action-panel">
<template v-if="guideStatus(f.key) === 'generating' || guideStatus(f.key) === 'queued'"> <template v-if="guideStatus(f.key) === 'generating' || guideStatus(f.key) === 'queued'">
<button class="panel-btn danger" :class="{ armed: pendingConfirm === 'fmt-' + f.key }" @click="handleDelete(f.key)">{{ pendingConfirm === 'fmt-' + f.key ? 'Sicher?' : 'Abbrechen' }}</button> <button class="panel-btn danger" :class="{ armed: pendingConfirm === 'fmt-' + f.key }" @click="handleDelete(f.key)">{{ pendingConfirm === 'fmt-' + f.key ? 'Sure?' : 'Cancel' }}</button>
</template> </template>
<template v-else> <template v-else>
<button <button
class="panel-btn play" class="panel-btn play"
:title="playLock(f.key) || (abgebrochen(f.key) ? 'Fortsetzen' : 'Generieren')" :title="playLock(f.key) || (aborted(f.key) ? 'Resume' : 'Generate')"
:disabled="!!playLock(f.key)" :disabled="!!playLock(f.key)"
@click="handlePlay(f.key)" @click="handlePlay(f.key)"
>{{ gewaehlterStep[f.key] ? `Ab «${gewaehltesStepLabel(f.key)}» neu` : abgebrochen(f.key) ? 'Fortsetzen' : guideStatus(f.key) === 'done' ? 'Neu generieren' : 'Generieren' }}</button> >{{ selectedStep[f.key] ? `Restart from «${selectedStepLabel(f.key)}»` : aborted(f.key) ? 'Resume' : guideStatus(f.key) === 'done' ? 'Regenerate' : 'Generate' }}</button>
<button <button
v-if="guideStatus(f.key) !== 'none' || abgebrochen(f.key)" v-if="guideStatus(f.key) !== 'none' || aborted(f.key)"
class="panel-btn danger" class="panel-btn danger"
:class="{ armed: pendingConfirm === 'fmt-' + f.key }" :class="{ armed: pendingConfirm === 'fmt-' + f.key }"
@click="handleDelete(f.key)" @click="handleDelete(f.key)"
>{{ pendingConfirm === 'fmt-' + f.key ? 'Sicher?' : abgebrochen(f.key) ? 'Fortschritt löschen' : 'Entfernen' }}</button> >{{ pendingConfirm === 'fmt-' + f.key ? 'Sure?' : aborted(f.key) ? 'Delete progress' : 'Remove' }}</button>
</template> </template>
</div> </div>
<div <div
v-if="guideStatus(f.key) === 'generating' || guideStatus(f.key) === 'queued'" v-if="guideStatus(f.key) === 'generating' || guideStatus(f.key) === 'queued'"
class="format-progress" class="format-progress"
>{{ latestByFormat[f.key]?.progress || 'Wartend…' }}</div> >{{ latestByFormat[f.key]?.progress || 'Waiting…' }}</div>
<div v-if="errorMsg(f.key)" class="format-error"> <div v-if="errorMsg(f.key)" class="format-error">
<span class="format-error-text">{{ errorMsg(f.key) }}</span> <span class="format-error-text">{{ errorMsg(f.key) }}</span>
<button class="format-error-x" title="Ausblenden" @click="dismissError(f.key)">×</button> <button class="format-error-x" title="Hide" @click="dismissError(f.key)">×</button>
</div> </div>
</div> </div>
<div class="format-row ord-pruefung"> <div class="format-row ord-exam">
<button class="format-name elements-btn" @click="emit('generalExam')"> <button class="format-name elements-btn" @click="emit('generalExam')">
<span class="format-label">Allgemeine Prüfung</span> <span class="format-label">General Exam</span>
</button> </button>
</div> </div>
<div class="format-row ord-elemente"> <div class="format-row ord-elemente">
<button class="format-name elements-btn" @click="emit('openElements')"> <button class="format-name elements-btn" @click="emit('openElements')">
<span class="format-label">Elemente</span> <span class="format-label">Elements</span>
</button> </button>
</div> </div>
</div> </div>
@@ -474,16 +474,16 @@ function saveQuelle() {
> >
<div class="topic-row"> <div class="topic-row">
<span class="topic-name" @click="emit('select', t)">{{ t }}</span> <span class="topic-name" @click="emit('select', t)">{{ t }}</span>
<button class="panel-toggle" :class="{ open: isOpen('topic-' + t) }" title="Optionen" @click.stop="toggleTopicPanel(t)"></button> <button class="panel-toggle" :class="{ open: isOpen('topic-' + t) }" title="Options" @click.stop="toggleTopicPanel(t)"></button>
</div> </div>
<div v-if="isOpen('topic-' + t)" class="thema-panel edit-panel" @click.stop> <div v-if="isOpen('topic-' + t)" class="thema-panel edit-panel" @click.stop>
<p v-if="editLoading" class="dlg-hint">Lade</p> <p v-if="editLoading" class="dlg-hint">Loading</p>
<template v-else> <template v-else>
<textarea class="dlg-textarea" v-model="editForm.spec" rows="2" placeholder="Weitere Infos (optional)…"></textarea> <textarea class="dlg-textarea" v-model="editForm.spec" rows="2" placeholder="More info (optional)…"></textarea>
<div class="dlg-sources"> <div class="dlg-sources">
<button :class="{ active: editForm.type === 'thema' }" @click="setEditType('thema')">Thema</button> <button :class="{ active: editForm.type === 'thema' }" @click="setEditType('thema')">Topic</button>
<button :class="{ active: editForm.type === 'link' }" @click="setEditType('link')">Link</button> <button :class="{ active: editForm.type === 'link' }" @click="setEditType('link')">Link</button>
<button :class="{ active: editForm.type === 'projekt' }" @click="setEditType('projekt')">Projekt</button> <button :class="{ active: editForm.type === 'projekt' }" @click="setEditType('projekt')">Project</button>
<button :class="{ active: editForm.type === 'uni' }" @click="setEditType('uni')">Uni</button> <button :class="{ active: editForm.type === 'uni' }" @click="setEditType('uni')">Uni</button>
</div> </div>
<input <input
@@ -495,16 +495,16 @@ function saveQuelle() {
v-else-if="editForm.type === 'projekt' || editForm.type === 'uni'" v-else-if="editForm.type === 'projekt' || editForm.type === 'uni'"
class="dlg-input" v-model="editForm.ort" class="dlg-input" v-model="editForm.ort"
> >
<option value="" disabled>Ordner wählen</option> <option value="" disabled>Choose folder</option>
<option v-for="fo in (folders[editForm.type] || [])" :key="fo.ort" :value="fo.ort">{{ fo.name }}</option> <option v-for="fo in (folders[editForm.type] || [])" :key="fo.location" :value="fo.location">{{ fo.name }}</option>
</select> </select>
<div class="dlg-actions"> <div class="dlg-actions">
<button <button
class="dlg-delete" class="dlg-delete"
:class="{ armed: pendingConfirm === 'topic-' + t }" :class="{ armed: pendingConfirm === 'topic-' + t }"
@click="confirmDeleteTopic(t)" @click="confirmDeleteTopic(t)"
>{{ pendingConfirm === 'topic-' + t ? 'Sicher?' : 'Löschen' }}</button> >{{ pendingConfirm === 'topic-' + t ? 'Sure?' : 'Delete' }}</button>
<button class="dlg-create" :disabled="!canSave" @click="saveQuelle">Aktualisieren</button> <button class="dlg-create" :disabled="!canSave" @click="saveSource">Update</button>
</div> </div>
</template> </template>
</div> </div>
@@ -589,7 +589,7 @@ function saveQuelle() {
border-color: var(--accent-border); border-color: var(--accent-border);
} }
/* Ansicht-Umschalter: zwei Icon-Buttons (kurzer / langer Text) */ /* View switcher: two icon buttons (short / long text) */
.ansicht-toggle { display: inline-flex; } .ansicht-toggle { display: inline-flex; }
.new-topic .ansicht-toggle button { .new-topic .ansicht-toggle button {
padding: 4px 7px; padding: 4px 7px;
@@ -604,8 +604,8 @@ function saveQuelle() {
.new-topic .ansicht-toggle button:hover { color: var(--accent-hover); } .new-topic .ansicht-toggle button:hover { color: var(--accent-hover); }
.new-topic .ansicht-toggle button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); } .new-topic .ansicht-toggle button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.ansicht-toggle svg { fill: currentColor; display: block; } .ansicht-toggle svg { fill: currentColor; display: block; }
.stufe-toggle { display: inline-flex; margin-left: 4px; } .level-toggle { display: inline-flex; margin-left: 4px; }
.new-topic .stufe-toggle button { .new-topic .level-toggle button {
padding: 4px 6px; padding: 4px 6px;
background: var(--bg); background: var(--bg);
color: var(--text-muted); color: var(--text-muted);
@@ -614,11 +614,11 @@ function saveQuelle() {
font-weight: 600; font-weight: 600;
min-width: 20px; min-width: 20px;
} }
.new-topic .stufe-toggle button:first-child { border-radius: 6px 0 0 6px; } .new-topic .level-toggle button:first-child { border-radius: 6px 0 0 6px; }
.new-topic .stufe-toggle button:last-child { border-radius: 0 6px 6px 0; } .new-topic .level-toggle button:last-child { border-radius: 0 6px 6px 0; }
.new-topic .stufe-toggle button:not(:first-child) { border-left: none; } .new-topic .level-toggle button:not(:first-child) { border-left: none; }
.new-topic .stufe-toggle button:hover { color: var(--accent-hover); } .new-topic .level-toggle button:hover { color: var(--accent-hover); }
.new-topic .stufe-toggle button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); } .new-topic .level-toggle button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.stats-bar { .stats-bar {
display: flex; display: flex;
@@ -646,7 +646,7 @@ function saveQuelle() {
color: var(--text); color: var(--text);
} }
/* Mini-Titel sitzt auf der oberen Kante des Badges (Legenden-Look) */ /* Mini title sits on the top edge of the badge (legend look) */
.stat-label { .stat-label {
position: absolute; position: absolute;
top: -0.42rem; top: -0.42rem;
@@ -740,7 +740,7 @@ function saveQuelle() {
} }
/* Format section */ /* Format section */
.bausteine-name { .blocks-name {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
@@ -753,7 +753,7 @@ function saveQuelle() {
flex: 1; flex: 1;
} }
/* Grobe Phasen als nummerierte Pillen (15) — Anzeige + anklickbar für Re-Run ab hier. */ /* Coarse phases as numbered pills (15) — display + clickable for re-run from here. */
.step-pill { .step-pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -803,16 +803,16 @@ function saveQuelle() {
max-height: 60vh; max-height: 60vh;
overflow-y: auto; overflow-y: auto;
padding: 0.5rem 0; padding: 0.5rem 0;
/* flex + order: Bausteine (order 2) vor den Formaten (order 3) */ /* flex + order: blocks (order 2) before the formats (order 3) */
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.ord-bausteine { .ord-blocks {
order: 2; order: 2;
} }
.ord-pruefung { .ord-exam {
order: 4; order: 4;
} }
@@ -838,14 +838,14 @@ function saveQuelle() {
animation: pulse 1.5s ease-in-out infinite; animation: pulse 1.5s ease-in-out infinite;
} }
/* Abgewiesene Aktion (409/400) — oberhalb aller Format-Zeilen */ /* Rejected action (409/400) — above all format rows */
.ui-error { .ui-error {
order: 0; order: 0;
padding: 0.4rem 0.75rem; padding: 0.4rem 0.75rem;
background: var(--warning-soft); background: var(--warning-soft);
} }
/* Fortschritts-Text direkt unter der laufenden Format-Zeile */ /* Progress text directly below the running format row */
.format-progress { .format-progress {
padding: 0 0.75rem 5px calc(0.75rem + 8px); padding: 0 0.75rem 5px calc(0.75rem + 8px);
font-size: 0.72rem; font-size: 0.72rem;
@@ -895,7 +895,7 @@ function saveQuelle() {
gap: 8px; gap: 8px;
} }
/* Accordion: Pfeil-Toggle + aufklappendes Aktions-Panel */ /* Accordion: arrow toggle + expanding action panel */
.panel-toggle { .panel-toggle {
flex: 0 0 auto; flex: 0 0 auto;
background: none; background: none;
@@ -957,11 +957,11 @@ function saveQuelle() {
display: inline; display: inline;
} }
/* Laufend/pausiert: × immer zeigen — Hover gibt es auf Touch nicht */ /* Running/paused: always show × — there is no hover on touch */
.fmt-generating .format-x, .fmt-generating .format-x,
.fmt-queued .format-x, .fmt-queued .format-x,
.fmt-paused .format-x, .fmt-paused .format-x,
.bausteine-row.is-active .format-x { .blocks-row.is-active .format-x {
display: inline; display: inline;
} }
@@ -1072,9 +1072,9 @@ function saveQuelle() {
50% { opacity: 0.65; } 50% { opacity: 0.65; }
} }
/* Erstellen — inline aufklappbar (kein Modal) */ /* Create — inline expandable (no modal) */
.new-topic-toggle { .new-topic-toggle {
margin-left: auto; /* rechtsbündig statt volle Breite */ margin-left: auto; /* right-aligned instead of full width */
padding: 6px 12px; padding: 6px 12px;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 6px; border-radius: 6px;
@@ -1136,7 +1136,7 @@ function saveQuelle() {
.dlg-create { background: var(--accent); border-color: var(--accent); color: var(--on-accent); } .dlg-create { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.dlg-create:disabled { opacity: 0.45; cursor: default; } .dlg-create:disabled { opacity: 0.45; cursor: default; }
/* Thema-Edit-Panel: dezent in die Liste eingefügt — keine Box, nur ein linker Akzent */ /* Topic edit panel: subtly inserted into the list — no box, just a left accent */
.edit-panel { .edit-panel {
margin: 0; margin: 0;
padding: 0.5rem 1rem 0.7rem 1.25rem; padding: 0.5rem 1rem 0.7rem 1.25rem;

View File

@@ -2,27 +2,27 @@
import { ref } from 'vue' import { ref } from 'vue'
import { renderMarkdownInline } from '../markdown.js' import { renderMarkdownInline } from '../markdown.js'
defineProps({ beispiele: { type: Array, default: () => [] } }) defineProps({ examples: { type: Array, default: () => [] } })
const offen = ref(false) const open = ref(false)
</script> </script>
<template> <template>
<div v-if="beispiele.length" class="worked"> <div v-if="examples.length" class="worked">
<button class="art-head" @click="offen = !offen"> <button class="art-head" @click="open = !open">
<span class="art-icon">📝</span> Beispiele <span class="art-icon">📝</span> Examples
<span class="art-count">{{ beispiele.length }}</span> <span class="art-count">{{ examples.length }}</span>
<span class="art-toggle">{{ offen ? '▾' : '▸' }}</span> <span class="art-toggle">{{ open ? '▾' : '▸' }}</span>
</button> </button>
<div v-if="offen" class="we-body"> <div v-if="open" class="we-body">
<div v-for="(b, i) in beispiele" :key="i" class="we-card"> <div v-for="(b, i) in examples" :key="i" class="we-card">
<div v-if="b.subbaustein" class="we-sub">{{ b.subbaustein }}</div> <div v-if="b.subblock" class="we-sub">{{ b.subblock }}</div>
<div class="we-problem" v-html="renderMarkdownInline(b.problem)"></div> <div class="we-problem" v-html="renderMarkdownInline(b.problem)"></div>
<ol class="we-schritte"> <ol class="we-steps">
<li v-for="(s, j) in b.schritte" :key="j" v-html="renderMarkdownInline(s)"></li> <li v-for="(s, j) in b.steps" :key="j" v-html="renderMarkdownInline(s)"></li>
</ol> </ol>
<div v-if="b.ergebnis" class="we-ergebnis"> <div v-if="b.result" class="we-result">
<span class="we-label">Ergebnis</span> <span class="we-label">Result</span>
<span v-html="renderMarkdownInline(b.ergebnis)"></span> <span v-html="renderMarkdownInline(b.result)"></span>
</div> </div>
</div> </div>
</div> </div>
@@ -54,9 +54,9 @@ const offen = ref(false)
color: var(--text-faint); font-weight: 700; margin-bottom: 0.3rem; color: var(--text-faint); font-weight: 700; margin-bottom: 0.3rem;
} }
.we-problem { font-weight: 600; margin-bottom: 0.4rem; } .we-problem { font-weight: 600; margin-bottom: 0.4rem; }
.we-schritte { margin: 0 0 0.4rem 1.1rem; padding: 0; } .we-steps { margin: 0 0 0.4rem 1.1rem; padding: 0; }
.we-schritte li { margin: 0.2rem 0; } .we-steps li { margin: 0.2rem 0; }
.we-ergebnis { .we-result {
display: flex; gap: 6px; align-items: baseline; display: flex; gap: 6px; align-items: baseline;
padding-top: 0.35rem; border-top: 1px dashed var(--border); padding-top: 0.35rem; border-top: 1px dashed var(--border);
} }

View File

@@ -13,7 +13,7 @@ const emit = defineEmits(['changes'])
const chat = useChat((msgs) => chatElement(props.element.id, msgs, props.provider)) const chat = useChat((msgs) => chatElement(props.element.id, msgs, props.provider))
const { messages, input, loading, messagesEl, inputEl, onScroll } = chat const { messages, input, loading, messagesEl, inputEl, onScroll } = chat
// Anderes Element gewählt → Verlauf verwerfen // Different element selected → discard history
watch(() => props.element.id, () => chat.reset()) watch(() => props.element.id, () => chat.reset())
async function send() { async function send() {
@@ -25,23 +25,23 @@ async function send() {
<template> <template>
<div class="el-chat"> <div class="el-chat">
<div ref="messagesEl" class="chat-messages" @scroll="onScroll"> <div ref="messagesEl" class="chat-messages" @scroll="onScroll">
<p v-if="!messages.length" class="chat-hint">Schreib, was am Element geändert werden soll.</p> <p v-if="!messages.length" class="chat-hint">Write what should be changed on the element.</p>
<template v-for="(m, i) in messages" :key="i"> <template v-for="(m, i) in messages" :key="i">
<div :class="['chat-msg', m.role]">{{ m.content }}</div> <div :class="['chat-msg', m.role]">{{ m.content }}</div>
</template> </template>
<div v-if="loading" class="chat-msg assistant chat-typing">Passt an</div> <div v-if="loading" class="chat-msg assistant chat-typing">Adjusting</div>
</div> </div>
<div class="chat-input"> <div class="chat-input">
<textarea <textarea
ref="inputEl" ref="inputEl"
v-model="input" v-model="input"
placeholder="Element anpassen…" placeholder="Adjust element…"
@keydown.enter.exact.prevent="send" @keydown.enter.exact.prevent="send"
></textarea> ></textarea>
<button <button
:disabled="!input.trim() && !loading" :disabled="!input.trim() && !loading"
:class="{ cancel: loading }" :class="{ cancel: loading }"
:title="loading ? 'Abbrechen' : 'Senden'" :title="loading ? 'Cancel' : 'Send'"
@click="send" @click="send"
>{{ loading ? '✕' : '➤' }}</button> >{{ loading ? '✕' : '➤' }}</button>
</div> </div>

View File

@@ -1,7 +1,7 @@
<script setup> <script setup>
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { updateElement, checkElement, styleElement, refineSuggestion } from '../../api.js' import { updateElement, checkElement, styleElement, refineSuggestion } from '../../api.js'
import { renderMarkdown } from '../../markdown.js' import { renderMarkdown, plainText } from '../../markdown.js'
import ElementSuggestion from './ElementSuggestion.vue' import ElementSuggestion from './ElementSuggestion.vue'
import ElementChatTab from './ElementChatTab.vue' import ElementChatTab from './ElementChatTab.vue'
import ElementEditTab from './ElementEditTab.vue' import ElementEditTab from './ElementEditTab.vue'
@@ -16,18 +16,15 @@ const emit = defineEmits(['back', 'close', 'updated', 'changed'])
const tab = ref('overview') // 'overview' | 'chat' | 'edit' const tab = ref('overview') // 'overview' | 'chat' | 'edit'
const savingEdit = ref(false) const savingEdit = ref(false)
// Markdown-Zeichen aus dem Header-Titel entfernen // Strip Markdown characters from the header title
function plain(text) {
return (text || '').replace(/```[a-z]*\n?/g, '').replace(/[`*_#]/g, '')
}
// Anderes Element gewählt → Prüf-Zustand und Tab zurücksetzen // Different element selected → reset exam state and tab
watch(() => props.element.id, () => { watch(() => props.element.id, () => {
tab.value = 'overview' tab.value = 'overview'
resetCheck() resetCheck()
}) })
// --- KI-Prüfung auf fehlende Infos (Ergebnisse landen als Inline-Vorschläge) --- // --- AI exam for missing info (results land as inline suggestions) ---
const checking = ref(false) const checking = ref(false)
const statusMsg = ref(null) const statusMsg = ref(null)
@@ -37,10 +34,10 @@ function resetCheck() {
resetStyle() resetStyle()
} }
let checkRun = 0 // laufende Prüfung identifizieren; Abbruch ignoriert ihr Ergebnis let checkRun = 0 // identify the running exam; cancellation ignores its result
async function runCheck() { async function runCheck() {
if (checking.value) { // zweiter Klick = abbrechen if (checking.value) { // second click = cancel
checkRun++ checkRun++
checking.value = false checking.value = false
return return
@@ -50,23 +47,23 @@ async function runCheck() {
statusMsg.value = null statusMsg.value = null
try { try {
const res = await checkElement(props.element.id, props.provider) const res = await checkElement(props.element.id, props.provider)
if (run !== checkRun) return // abgebrochen oder neue Prüfung gestartet if (run !== checkRun) return // cancelled or a new exam started
const mapped = res.suggestions.map((s) => ({ const mapped = res.suggestions.map((s) => ({
text: s.text, action: 'hinzufuegen', target: s.target, index: null, content: s.content, text: s.text, action: 'add', target: s.target, index: null, content: s.content,
})) }))
if (mapped.length) styleChanges.value = [...(styleChanges.value || []), ...mapped] if (mapped.length) styleChanges.value = [...(styleChanges.value || []), ...mapped]
else statusMsg.value = 'Keine wichtigen Lücken gefunden.' else statusMsg.value = 'No important gaps found.'
} catch (e) { } catch (e) {
if (run !== checkRun) return if (run !== checkRun) return
console.error('Prüfung fehlgeschlagen:', e) console.error('Exam failed:', e)
statusMsg.value = 'Prüfung fehlgeschlagen — bitte erneut versuchen.' statusMsg.value = 'Exam failed — please try again.'
} finally { } finally {
if (run === checkRun) checking.value = false if (run === checkRun) checking.value = false
} }
} }
// --- Stil-Prüfung: KI schlägt Änderungen vor, Nutzer bestätigt --- // --- Style exam: AI proposes changes, user confirms ---
const styleChanges = ref(null) // null = noch nicht geprüft const styleChanges = ref(null) // null = not yet examined
const styling = ref(false) const styling = ref(false)
const applyingStyle = ref(false) const applyingStyle = ref(false)
const refiningIdx = ref(null) const refiningIdx = ref(null)
@@ -83,7 +80,7 @@ function suggBusy(i) {
return applyingStyle.value || refiningIdx.value === i return applyingStyle.value || refiningIdx.value === i
} }
// Einzelnen Vorschlag per Anweisung überarbeiten (Stift-Icon) // Refine a single suggestion via instruction (pencil icon)
async function refineChange(i, instruction) { async function refineChange(i, instruction) {
if (refiningIdx.value !== null || applyingStyle.value) return if (refiningIdx.value !== null || applyingStyle.value) return
refiningIdx.value = i refiningIdx.value = i
@@ -93,15 +90,15 @@ async function refineChange(i, instruction) {
next[i] = res.change next[i] = res.change
styleChanges.value = next styleChanges.value = next
} catch (e) { } catch (e) {
console.error('Überarbeitung fehlgeschlagen:', e) console.error('Refinement failed:', e)
statusMsg.value = 'Überarbeitung fehlgeschlagen — bitte erneut versuchen.' statusMsg.value = 'Refinement failed — please try again.'
} finally { } finally {
refiningIdx.value = null refiningIdx.value = null
} }
} }
async function runStyle() { async function runStyle() {
if (styling.value) { // zweiter Klick = abbrechen if (styling.value) { // second click = cancel
styleRun++ styleRun++
styling.value = false styling.value = false
return return
@@ -113,35 +110,35 @@ async function runStyle() {
const res = await styleElement(props.element.id, props.provider) const res = await styleElement(props.element.id, props.provider)
if (run !== styleRun) return if (run !== styleRun) return
if (res.changes.length) styleChanges.value = [...(styleChanges.value || []), ...res.changes] if (res.changes.length) styleChanges.value = [...(styleChanges.value || []), ...res.changes]
else statusMsg.value = 'Stil passt bereits.' else statusMsg.value = 'Style already fits.'
} catch (e) { } catch (e) {
if (run !== styleRun) return if (run !== styleRun) return
console.error('Stil-Prüfung fehlgeschlagen:', e) console.error('Style exam failed:', e)
statusMsg.value = 'Stil-Prüfung fehlgeschlagen — bitte erneut versuchen.' statusMsg.value = 'Style exam failed — please try again.'
} finally { } finally {
if (run === styleRun) styling.value = false if (run === styleRun) styling.value = false
} }
} }
// Chat-Vorschläge landen ebenfalls als Inline-Vorschläge in der Übersicht // Chat suggestions also land as inline suggestions in the overview
function onChatChanges(changes) { function onChatChanges(changes) {
styleChanges.value = [...(styleChanges.value || []), ...changes] styleChanges.value = [...(styleChanges.value || []), ...changes]
} }
// Vorschläge am Ziel-Ort anzeigen: anpassen/entfernen beim betroffenen Eintrag // Show suggestions at the target location: adjust/remove at the affected entry
function styleAt(target, index = null) { function styleAt(target, index = null) {
if (!styleChanges.value) return [] if (!styleChanges.value) return []
return styleChanges.value return styleChanges.value
.map((c, i) => [i, c]) .map((c, i) => [i, c])
.filter(([, c]) => c.target === target && c.index === index && c.action !== 'hinzufuegen') .filter(([, c]) => c.target === target && c.index === index && c.action !== 'add')
} }
// … Ergänzungen am Ende der jeweiligen Sektion // … additions at the end of the respective section
function styleAdds(target) { function styleAdds(target) {
if (!styleChanges.value) return [] if (!styleChanges.value) return []
return styleChanges.value return styleChanges.value
.map((c, i) => [i, c]) .map((c, i) => [i, c])
.filter(([, c]) => c.target === target && c.action === 'hinzufuegen') .filter(([, c]) => c.target === target && c.action === 'add')
} }
function dismissStyleChange(i) { function dismissStyleChange(i) {
@@ -160,8 +157,8 @@ async function applyStyleChange(i) {
examples: [...props.element.examples], examples: [...props.element.examples],
hints: [...props.element.hints], hints: [...props.element.hints],
} }
if (c.action === 'entfernen') fields[c.target].splice(c.index, 1) if (c.action === 'remove') fields[c.target].splice(c.index, 1)
else if (c.action === 'hinzufuegen') { else if (c.action === 'add') {
if (c.target === 'title') fields.title = c.content if (c.target === 'title') fields.title = c.content
else if (c.target === 'description') else if (c.target === 'description')
fields[c.target] = fields[c.target] ? fields[c.target] + '\n\n' + c.content : c.content fields[c.target] = fields[c.target] ? fields[c.target] + '\n\n' + c.content : c.content
@@ -172,22 +169,22 @@ async function applyStyleChange(i) {
const updated = await updateElement(props.element.id, fields) const updated = await updateElement(props.element.id, fields)
emit('updated', updated) emit('updated', updated)
// Rest-Vorschläge behalten; Indizes hinter einer Entfernung rücken auf // Keep remaining suggestions; indices after a removal shift up
const rest = styleChanges.value.filter((_, j) => j !== i) const rest = styleChanges.value.filter((_, j) => j !== i)
if (c.action === 'entfernen') { if (c.action === 'remove') {
for (const r of rest) { for (const r of rest) {
if (r.target === c.target && r.index !== null && r.index > c.index) r.index-- if (r.target === c.target && r.index !== null && r.index > c.index) r.index--
} }
} }
styleChanges.value = rest styleChanges.value = rest
} catch (e) { } catch (e) {
console.error('Übernehmen fehlgeschlagen:', e) console.error('Apply failed:', e)
} finally { } finally {
applyingStyle.value = false applyingStyle.value = false
} }
} }
// --- Bearbeiten-Tab: Felder direkt speichern --- // --- Edit tab: save fields directly ---
async function saveEdit(fields) { async function saveEdit(fields) {
if (savingEdit.value) return if (savingEdit.value) return
savingEdit.value = true savingEdit.value = true
@@ -196,7 +193,7 @@ async function saveEdit(fields) {
emit('updated', updated) emit('updated', updated)
tab.value = 'overview' tab.value = 'overview'
} catch (e) { } catch (e) {
console.error('Speichern fehlgeschlagen:', e) console.error('Save failed:', e)
} finally { } finally {
savingEdit.value = false savingEdit.value = false
} }
@@ -205,26 +202,26 @@ async function saveEdit(fields) {
<template> <template>
<header class="el-header"> <header class="el-header">
<button class="el-back" title="Zur Liste" @click="emit('back')"></button> <button class="el-back" title="Back to list" @click="emit('back')"></button>
<span class="el-title">{{ plain(element.title) }}</span> <span class="el-title">{{ plainText(element.title) }}</span>
<button <button
class="el-tool" :class="{ busy: checking }" class="el-tool" :class="{ busy: checking }"
:title="checking ? 'Prüfung abbrechen' : 'Auf fehlende Infos prüfen'" @click="runCheck" :title="checking ? 'Cancel exam' : 'Check for missing info'" @click="runCheck"
>🔍</button> >🔍</button>
<button <button
class="el-tool" :class="{ busy: styling }" class="el-tool" :class="{ busy: styling }"
:title="styling ? 'Prüfung abbrechen' : 'Stil prüfen & anpassen'" @click="runStyle" :title="styling ? 'Cancel exam' : 'Check & adjust style'" @click="runStyle"
></button> ></button>
<button class="el-close" title="Schließen" @click="emit('close')">×</button> <button class="el-close" title="Close" @click="emit('close')">×</button>
</header> </header>
<nav class="el-tabs"> <nav class="el-tabs">
<button :class="{ active: tab === 'overview' }" @click="tab = 'overview'">Übersicht</button> <button :class="{ active: tab === 'overview' }" @click="tab = 'overview'">Overview</button>
<button :class="{ active: tab === 'chat' }" @click="tab = 'chat'">Chat</button> <button :class="{ active: tab === 'chat' }" @click="tab = 'chat'">Chat</button>
<button :class="{ active: tab === 'edit' }" @click="tab = 'edit'">Bearbeiten</button> <button :class="{ active: tab === 'edit' }" @click="tab = 'edit'">Edit</button>
</nav> </nav>
<!-- Übersicht: untrennbar mit styleChanges/Apply verzahnt bleibt hier --> <!-- Overview: inseparably intertwined with styleChanges/apply stays here -->
<div v-show="tab === 'overview'" class="el-detail"> <div v-show="tab === 'overview'" class="el-detail">
<div v-if="element.description" class="el-desc markdown" v-html="renderMarkdown(element.description)"></div> <div v-if="element.description" class="el-desc markdown" v-html="renderMarkdown(element.description)"></div>
<ElementSuggestion <ElementSuggestion
@@ -246,7 +243,7 @@ async function saveEdit(fields) {
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)" @apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
/> />
<div v-if="element.hints.length || styleAdds('hints').length" class="el-hints-block"> <div v-if="element.hints.length || styleAdds('hints').length" class="el-hints-block">
<h4>Hinweise</h4> <h4>Hints</h4>
<ul class="el-hints"> <ul class="el-hints">
<li v-for="(h, i) in element.hints" :key="i"> <li v-for="(h, i) in element.hints" :key="i">
<span class="markdown" v-html="renderMarkdown(h)"></span> <span class="markdown" v-html="renderMarkdown(h)"></span>
@@ -265,13 +262,13 @@ async function saveEdit(fields) {
</div> </div>
<div v-if="checking || styling || statusMsg" class="el-check"> <div v-if="checking || styling || statusMsg" class="el-check">
<p v-if="checking" class="check-empty busy-text">Prüft auf fehlende Infos</p> <p v-if="checking" class="check-empty busy-text">Checking for missing info</p>
<p v-if="styling" class="check-empty busy-text">Prüft den Stil</p> <p v-if="styling" class="check-empty busy-text">Checking the style</p>
<p v-if="statusMsg && !checking && !styling" class="check-empty">{{ statusMsg }}</p> <p v-if="statusMsg && !checking && !styling" class="check-empty">{{ statusMsg }}</p>
</div> </div>
</div> </div>
<!-- v-show erhält den Chat-Verlauf beim Tab-Wechsel --> <!-- v-show preserves the chat history when switching tabs -->
<ElementChatTab <ElementChatTab
v-show="tab === 'chat'" v-show="tab === 'chat'"
:element="element" :element="element"
@@ -279,7 +276,7 @@ async function saveEdit(fields) {
@changes="onChatChanges" @changes="onChatChanges"
/> />
<!-- v-if lädt die Edit-Felder bei jedem Öffnen frisch --> <!-- v-if loads the edit fields fresh on every open -->
<ElementEditTab <ElementEditTab
v-if="tab === 'edit'" v-if="tab === 'edit'"
:element="element" :element="element"
@@ -427,7 +424,7 @@ async function saveEdit(fields) {
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
} }
/* Hinweis-Text inline neben dem Bullet halten (p ist sonst block) */ /* Keep hint text inline next to the bullet (p is block otherwise) */
.el-hints li > .markdown { .el-hints li > .markdown {
display: inline; display: inline;
} }
@@ -437,12 +434,12 @@ async function saveEdit(fields) {
margin: 0; margin: 0;
} }
/* Markdown: Basis global (assets/markdown.css); schmale Sidebar → kompaktere Code-Blöcke */ /* Markdown: base is global (assets/markdown.css); narrow sidebar → more compact code blocks */
.markdown :deep(pre) { .markdown :deep(pre) {
padding: 8px 10px; padding: 8px 10px;
} }
/* --- KI-Prüfung --- */ /* --- AI exam --- */
.el-check { .el-check {
margin-top: 1rem; margin-top: 1rem;
padding-top: 0.8rem; padding-top: 0.8rem;

View File

@@ -35,28 +35,28 @@ function save() {
<template> <template>
<div class="el-edit"> <div class="el-edit">
<button class="edit-save" :disabled="saving" @click="save"> <button class="edit-save" :disabled="saving" @click="save">
{{ saving ? 'Speichert' : 'Speichern' }} {{ saving ? 'Saving' : 'Save' }}
</button> </button>
<label>Titel</label> <label>Title</label>
<input v-model="edit.title" placeholder="Titel" /> <input v-model="edit.title" placeholder="Title" />
<label>Beschreibung</label> <label>Description</label>
<textarea v-model="edit.description" placeholder="Beschreibung"></textarea> <textarea v-model="edit.description" placeholder="Description"></textarea>
<label>Beispiele</label> <label>Examples</label>
<div v-for="(ex, i) in edit.examples" :key="'ex' + i" class="edit-row"> <div v-for="(ex, i) in edit.examples" :key="'ex' + i" class="edit-row">
<textarea v-model="edit.examples[i]" placeholder="Beispiel"></textarea> <textarea v-model="edit.examples[i]" placeholder="Example"></textarea>
<button class="edit-del" title="Entfernen" @click="edit.examples.splice(i, 1)">×</button> <button class="edit-del" title="Remove" @click="edit.examples.splice(i, 1)">×</button>
</div> </div>
<button class="edit-add" @click="edit.examples.push('')">+ Beispiel</button> <button class="edit-add" @click="edit.examples.push('')">+ Example</button>
<label>Hinweise</label> <label>Hints</label>
<div v-for="(h, i) in edit.hints" :key="'hi' + i" class="edit-row"> <div v-for="(h, i) in edit.hints" :key="'hi' + i" class="edit-row">
<textarea v-model="edit.hints[i]" placeholder="Hinweis"></textarea> <textarea v-model="edit.hints[i]" placeholder="Hint"></textarea>
<button class="edit-del" title="Entfernen" @click="edit.hints.splice(i, 1)">×</button> <button class="edit-del" title="Remove" @click="edit.hints.splice(i, 1)">×</button>
</div> </div>
<button class="edit-add" @click="edit.hints.push('')">+ Hinweis</button> <button class="edit-add" @click="edit.hints.push('')">+ Hint</button>
</div> </div>
</template> </template>

View File

@@ -1,6 +1,7 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useConfirm } from '../../composables/useConfirm.js' import { useConfirm } from '../../composables/useConfirm.js'
import { plainText } from '../../markdown.js'
const props = defineProps({ const props = defineProps({
elements: { type: Array, required: true }, elements: { type: Array, required: true },
@@ -12,10 +13,7 @@ const emit = defineEmits(['select', 'create', 'remove'])
const query = ref('') const query = ref('')
const { isArmed, armOrRun } = useConfirm() const { isArmed, armOrRun } = useConfirm()
// Markdown-Zeichen für Titel und Listen-Vorschau entfernen // Strip Markdown characters for title and list preview
function plain(text) {
return (text || '').replace(/```[a-z]*\n?/g, '').replace(/[`*_#]/g, '')
}
const filtered = computed(() => { const filtered = computed(() => {
const q = query.value.trim().toLowerCase() const q = query.value.trim().toLowerCase()
@@ -31,7 +29,7 @@ function add() {
query.value = '' query.value = ''
} }
// Inline-Bestätigung: erster Klick „Sicher?", zweiter löscht // Inline confirmation: first click "Sure?", second deletes
function confirmDelete(el) { function confirmDelete(el) {
armOrRun('el-' + el.id, () => emit('remove', el)) armOrRun('el-' + el.id, () => emit('remove', el))
} }
@@ -41,28 +39,28 @@ function confirmDelete(el) {
<div class="el-new"> <div class="el-new">
<input <input
v-model="query" v-model="query"
placeholder="Suchen oder Stichwort…" placeholder="Search or keyword…"
:disabled="creating" :disabled="creating"
@keyup.enter="add" @keyup.enter="add"
/> />
<button :disabled="creating" title="Element per KI erstellen" @click="add">+</button> <button :disabled="creating" title="Create element via AI" @click="add">+</button>
</div> </div>
<div v-if="creating" class="el-creating">KI erstellt Element</div> <div v-if="creating" class="el-creating">AI is creating element</div>
<ul class="el-list"> <ul class="el-list">
<li v-for="el in filtered" :key="el.id" @click="emit('select', el)"> <li v-for="el in filtered" :key="el.id" @click="emit('select', el)">
<div class="el-item-main"> <div class="el-item-main">
<span class="el-item-title">{{ plain(el.title) }}</span> <span class="el-item-title">{{ plainText(el.title) }}</span>
<span class="el-item-desc">{{ plain(el.description) }}</span> <span class="el-item-desc">{{ plainText(el.description) }}</span>
</div> </div>
<button <button
class="el-delete" class="el-delete"
:class="{ armed: isArmed('el-' + el.id) }" :class="{ armed: isArmed('el-' + el.id) }"
title="Element löschen" title="Delete element"
@click.stop="confirmDelete(el)" @click.stop="confirmDelete(el)"
>{{ isArmed('el-' + el.id) ? 'Sicher?' : '×' }}</button> >{{ isArmed('el-' + el.id) ? 'Sure?' : '×' }}</button>
</li> </li>
<li v-if="!filtered.length && !creating" class="el-empty"> <li v-if="!filtered.length && !creating" class="el-empty">
{{ elements.length ? 'Keine Treffer.' : 'Noch keine Elemente. Stichwort eingeben und + klicken.' }} {{ elements.length ? 'No matches.' : 'No elements yet. Enter a keyword and click +.' }}
</li> </li>
</ul> </ul>
</template> </template>

View File

@@ -9,7 +9,7 @@ const props = defineProps({
const emit = defineEmits(['apply', 'dismiss', 'refine']) const emit = defineEmits(['apply', 'dismiss', 'refine'])
const ACTION_LABELS = { entfernen: 'Entfernen:', anpassen: 'Anpassen:', hinzufuegen: 'Hinzufügen:' } const ACTION_LABELS = { remove: 'Remove:', adjust: 'Adjust:', add: 'Add:' }
const editing = ref(false) const editing = ref(false)
const instruction = ref('') const instruction = ref('')
@@ -34,15 +34,15 @@ function submit() {
<div class="style-sugg-text"><strong>{{ ACTION_LABELS[change.action] }}</strong> {{ change.text }}</div> <div class="style-sugg-text"><strong>{{ ACTION_LABELS[change.action] }}</strong> {{ change.text }}</div>
<div v-if="change.content" class="style-sugg-preview markdown" v-html="renderMarkdown(change.content)"></div> <div v-if="change.content" class="style-sugg-preview markdown" v-html="renderMarkdown(change.content)"></div>
<div class="style-sugg-actions"> <div class="style-sugg-actions">
<button class="sugg-ok" :disabled="busy" @click="emit('apply')">Bestätigen</button> <button class="sugg-ok" :disabled="busy" @click="emit('apply')">Confirm</button>
<button class="sugg-no" :disabled="busy" @click="emit('dismiss')">Ablehnen</button> <button class="sugg-no" :disabled="busy" @click="emit('dismiss')">Reject</button>
<button class="sugg-edit" :disabled="busy" title="Vorschlag per Anweisung anpassen" @click="toggleEdit"></button> <button class="sugg-edit" :disabled="busy" title="Adjust suggestion via instruction" @click="toggleEdit"></button>
</div> </div>
<div v-if="editing" class="sugg-edit-row"> <div v-if="editing" class="sugg-edit-row">
<input <input
ref="inputEl" ref="inputEl"
v-model="instruction" v-model="instruction"
placeholder="Anweisung zum Vorschlag…" placeholder="Instruction for the suggestion…"
@keyup.enter="submit" @keyup.enter="submit"
/> />
<button :disabled="!instruction.trim() || busy" @click="submit"></button> <button :disabled="!instruction.trim() || busy" @click="submit"></button>
@@ -175,7 +175,7 @@ function submit() {
cursor: not-allowed; cursor: not-allowed;
} }
/* Markdown: Basis global (assets/markdown.css); kompakte Vorschau-Code-Blöcke */ /* Markdown: base is global (assets/markdown.css); compact preview code blocks */
.markdown :deep(pre) { .markdown :deep(pre) {
padding: 6px 8px; padding: 6px 8px;
border-radius: 6px; border-radius: 6px;

View File

@@ -7,8 +7,8 @@ import ElementDetail from './ElementDetail.vue'
const props = defineProps({ const props = defineProps({
topic: { type: String, required: true }, topic: { type: String, required: true },
provider: { type: String, default: 'claude' }, provider: { type: String, default: 'claude' },
openId: { type: String, default: null }, // Element-ID, die geöffnet werden soll openId: { type: String, default: null }, // Element ID that should be opened
openTick: { type: Number, default: 0 }, // Erhöhung = openId (erneut) öffnen openTick: { type: Number, default: 0 }, // increment = (re)open openId
}) })
const emit = defineEmits(['close', 'changed']) const emit = defineEmits(['close', 'changed'])
@@ -24,12 +24,12 @@ async function load() {
try { try {
elements.value = await fetchElements(props.topic) elements.value = await fetchElements(props.topic)
} catch (e) { } catch (e) {
console.error('Fehler beim Laden der Elemente:', e) console.error('Failed to load elements:', e)
} }
openFromProp() openFromProp()
} }
// Aus der Übersicht im Hauptbereich angeklicktes Element öffnen // Open the element clicked in the overview of the main area
watch(() => props.openTick, openFromProp) watch(() => props.openTick, openFromProp)
function openFromProp() { function openFromProp() {
@@ -46,7 +46,7 @@ async function create(hint) {
elements.value.unshift(el) elements.value.unshift(el)
emit('changed') emit('changed')
} catch (e) { } catch (e) {
console.error('Fehler beim Erstellen des Elements:', e) console.error('Failed to create element:', e)
} finally { } finally {
creating.value = false creating.value = false
} }
@@ -59,7 +59,7 @@ async function remove(el) {
emit('changed') emit('changed')
} }
// Bearbeitetes Element in Liste und Auswahl synchron halten // Keep the edited element in sync within the list and selection
function onUpdated(el) { function onUpdated(el) {
selected.value = el selected.value = el
const idx = elements.value.findIndex((e) => e.id === el.id) const idx = elements.value.findIndex((e) => e.id === el.id)
@@ -81,8 +81,8 @@ function onUpdated(el) {
/> />
<template v-else> <template v-else>
<header class="el-header"> <header class="el-header">
<span class="el-title">Elemente</span> <span class="el-title">Elements</span>
<button class="el-close" title="Schließen" @click="emit('close')">×</button> <button class="el-close" title="Close" @click="emit('close')">×</button>
</header> </header>
<ElementList <ElementList
:elements="elements" :elements="elements"
@@ -104,13 +104,13 @@ function onUpdated(el) {
flex-direction: column; flex-direction: column;
background: var(--panel); background: var(--panel);
border-left: 1px solid var(--border); border-left: 1px solid var(--border);
/* Über dem Guide-Chat (FAB/Panel: z-index 20) */ /* Above the guide chat (FAB/panel: z-index 20) */
position: relative; position: relative;
z-index: 30; z-index: 30;
} }
/* Mobil/schmal: als Overlay über den Hauptinhalt legen, statt ihn /* Mobile/narrow: lay it as an overlay over the main content instead of
im Flex-Fluss einzuquetschen. */ squeezing it into the flex flow. */
@media (max-width: 768px) { @media (max-width: 768px) {
.elements-sidebar { .elements-sidebar {
position: fixed; position: fixed;

View File

@@ -1,23 +1,23 @@
import { ref, nextTick } from 'vue' import { ref, nextTick } from 'vue'
// (Fast) am unteren Rand? Schwelle fängt Sub-Pixel und kleine Abstände ab. // (Almost) at the bottom edge? Threshold absorbs sub-pixels and small gaps.
export function istUnten(el, schwelle = 60) { export function istUnten(el, threshold = 60) {
return el.scrollHeight - el.scrollTop - el.clientHeight < schwelle return el.scrollHeight - el.scrollTop - el.clientHeight < threshold
} }
// Gemeinsame Chat-Mechanik: senden, abbrechen (Run-Counter), scrollen, Fokus. // Shared chat mechanics: send, cancel (run counter), scroll, focus.
// performRequest(messages) → Promise<{ reply, … }>; send() gibt die Antwort // performRequest(messages) → Promise<{ reply, … }>; send() returns the response
// zurück, damit der Aufrufer Extras (z. B. changes) auswerten kann. // so the caller can evaluate extras (e.g. changes).
export function useChat(performRequest) { export function useChat(performRequest) {
const messages = ref([]) const messages = ref([])
const input = ref('') const input = ref('')
const loading = ref(false) const loading = ref(false)
const messagesEl = ref(null) // Template-Ref: Nachrichten-Container const messagesEl = ref(null) // template ref: messages container
const inputEl = ref(null) // Template-Ref: Textarea const inputEl = ref(null) // template ref: textarea
const stick = ref(true) // an den Boden „gepinnt" — nur dann auto-scrollen const stick = ref(true) // "pinned" to the bottom — only then auto-scroll
let run = 0 // laufende Anfrage identifizieren; Abbruch ignoriert ihr Ergebnis let run = 0 // identify the running request; cancel ignores its result
// @scroll-Handler: pinnt nur, wenn der Nutzer (fast) unten ist. // @scroll handler: pins only when the user is (almost) at the bottom.
function onScroll() { function onScroll() {
if (messagesEl.value) stick.value = istUnten(messagesEl.value) if (messagesEl.value) stick.value = istUnten(messagesEl.value)
} }
@@ -37,7 +37,7 @@ export function useChat(performRequest) {
function cancel() { function cancel() {
run++ run++
loading.value = false loading.value = false
messages.value.push({ role: 'assistant', content: 'Abgebrochen.' }) messages.value.push({ role: 'assistant', content: 'Cancelled.' })
} }
function reset() { function reset() {
@@ -48,13 +48,13 @@ export function useChat(performRequest) {
} }
async function send() { async function send() {
if (loading.value) { // zweiter Klick = abbrechen if (loading.value) { // second click = cancel
cancel() cancel()
return null return null
} }
const text = input.value.trim() const text = input.value.trim()
if (!text) return null if (!text) return null
stick.value = true // eigenes Senden = ans Ende; Hochscrollen während des Wartens setzt es wieder false stick.value = true // own send = jump to end; scrolling up while waiting resets it to false
const current = ++run const current = ++run
messages.value.push({ role: 'user', content: text }) messages.value.push({ role: 'user', content: text })
input.value = '' input.value = ''
@@ -64,17 +64,17 @@ export function useChat(performRequest) {
try { try {
const res = await performRequest(messages.value) const res = await performRequest(messages.value)
if (current !== run) return null if (current !== run) return null
// Prüfung liefert `frage` (+ getrenntes `feedback`); andere Chats `reply`. // Exam returns `question` (+ separate `feedback`); other chats `reply`.
messages.value.push({ messages.value.push({
role: 'assistant', role: 'assistant',
content: res.frage ?? res.reply ?? '…', content: res.question ?? res.reply ?? '…',
feedback: res.feedback ?? null, feedback: res.feedback ?? null,
bewertung: res.bewertung ?? null, rating: res.rating ?? null,
}) })
return res return res
} catch { } catch {
if (current !== run) return null if (current !== run) return null
messages.value.push({ role: 'assistant', content: 'Fehler bei der Anfrage.' }) messages.value.push({ role: 'assistant', content: 'Request failed.' })
return null return null
} finally { } finally {
if (current === run) { if (current === run) {

View File

@@ -1,9 +1,9 @@
import { ref, onUnmounted } from 'vue' import { ref, onUnmounted } from 'vue'
// Inline-Bestätigung statt confirm(): erster Klick scharfschalten („Sicher?"), // Inline confirmation instead of confirm(): first click arms ("Sure?"),
// zweiter Klick führt aus. Browser-Dialoge können unterdrückt sein (Firefox). // second click runs it. Browser dialogs can be suppressed (Firefox).
export function useConfirm(timeoutMs = 3000) { export function useConfirm(timeoutMs = 3000) {
const pending = ref(null) // aktuell scharfgeschalteter Key const pending = ref(null) // currently armed key
let timer = null let timer = null
function armOrRun(key, action) { function armOrRun(key, action) {

View File

@@ -1,8 +1,8 @@
import { onUnmounted } from 'vue' import { onUnmounted } from 'vue'
// Polling mit Visibility-Pause: Tab unsichtbar → stoppen; wieder sichtbar // Polling with a visibility pause: tab hidden → stop; visible again
// sofortiger Tick, dann weiter, falls isActive(). Stoppt selbst, sobald // immediate tick, then continue if isActive(). Stops itself as soon as
// isActive() nach einem Tick false liefert. // isActive() returns false after a tick.
export function usePolling(tick, isActive, interval = 3000) { export function usePolling(tick, isActive, interval = 3000) {
let timer = null let timer = null

35
frontend/src/levels.js Normal file
View File

@@ -0,0 +1,35 @@
// Learning levels per block — identical to the backend (learning.py LEVELS).
// Floor in % of cap_final (cap_final = all subblocks × 25).
// green=Beginner 20% · blue=Advanced 40% · purple=Expert 60% · gold=Master 100%.
export const LEVELS = [
{ key: 'beginner', label: 'Beginner', kurz: '✓', floor: 0.2, farbe: 'var(--level-beginner)' },
{ key: 'advanced', label: 'Advanced', kurz: '✓✓', floor: 0.4, farbe: 'var(--level-advanced)' },
{ key: 'expert', label: 'Expert', kurz: '✓✓✓', floor: 0.6, farbe: 'var(--level-expert)' },
{ key: 'master', label: 'Master', kurz: '★', floor: 1.0, farbe: 'var(--level-master)' },
]
export function schwelle(floor, cap) {
return Math.round(floor * cap)
}
// Highest reached level (object) or null (below Beginner, <20%).
export function stufeFuer(score, cap) {
let s = null
for (const st of LEVELS) if (score >= schwelle(st.floor, cap)) s = st
return s
}
// Next not-yet-reached level (the goal) or null (= Master).
export function naechste(score, cap) {
for (const st of LEVELS) if (score < schwelle(st.floor, cap)) return st
return null
}
// Error-penalty display by progress (against cap_aktuell): ≤25%→5 · ≤50%→10 · ≤75%→15 · >75%→20.
export function malusRegel(score, cap) {
const pct = cap ? score / cap : 0
if (pct <= 0.25) return '5'
if (pct <= 0.5) return '10'
if (pct <= 0.75) return '15'
return '20'
}

View File

@@ -17,9 +17,9 @@ marked.use(markedHighlight({
})) }))
marked.setOptions({ breaks: true, gfm: true }) marked.setOptions({ breaks: true, gfm: true })
// LaTeX-Mathe via KaTeX. Eigene marked-Extensions (statt marked-katex-extension, // LaTeX math via KaTeX. Own marked extensions (instead of marked-katex-extension,
// die marked v18 hinterherhinkt). marked tokenisiert Code zuerst → $…$ in Code- // which lags behind marked v18). marked tokenizes code first → $…$ inside code
// Blöcken wird NICHT als Mathe erkannt. throwOnError:false zeigt defektes TeX rot. // blocks is NOT treated as math. throwOnError:false renders broken TeX in red.
function renderTex(tex, displayMode) { function renderTex(tex, displayMode) {
return katex.renderToString(tex, { displayMode, throwOnError: false, output: 'html' }) return katex.renderToString(tex, { displayMode, throwOnError: false, output: 'html' })
} }
@@ -40,8 +40,8 @@ const inlineMath = {
level: 'inline', level: 'inline',
start(src) { const i = src.indexOf('$'); return i < 0 ? undefined : i }, start(src) { const i = src.indexOf('$'); return i < 0 ? undefined : i },
tokenizer(src) { tokenizer(src) {
// $…$: kein $$, kein Leerzeichen direkt hinter dem öffnenden $ und vor dem // $…$: no $$, no space right after the opening $ or before the closing $
// schließenden $ (pandoc-Stil) → mindert Kollisionen mit Fließtext-Dollarzeichen. // (pandoc style) → reduces collisions with dollar signs in prose.
const m = /^\$(?![\s$])((?:\\\$|[^$])+?)\$/.exec(src) const m = /^\$(?![\s$])((?:\\\$|[^$])+?)\$/.exec(src)
if (!m || /\s$/.test(m[1])) return if (!m || /\s$/.test(m[1])) return
return { type: 'inlineMath', raw: m[0], text: m[1].trim() } return { type: 'inlineMath', raw: m[0], text: m[1].trim() }
@@ -51,8 +51,8 @@ const inlineMath = {
marked.use({ extensions: [blockMath, inlineMath] }) marked.use({ extensions: [blockMath, inlineMath] })
// Rohes HTML im Markdown (z. B. <p>, <img> ohne Backticks aus Agenten-Output) // Raw HTML in markdown (e.g. <p>, <img> without backticks from agent output)
// als Text anzeigen statt rendernsonst verschluckt der Browser den Inhalt. // shown as text instead of renderedotherwise the browser swallows the content.
marked.use({ marked.use({
renderer: { renderer: {
html(token) { html(token) {
@@ -66,15 +66,20 @@ export function renderMarkdown(text) {
return DOMPurify.sanitize(marked.parse(text || '')) return DOMPurify.sanitize(marked.parse(text || ''))
} }
// Inline-Variante (kein <p>-Wrapping) für kurze Texte wie Quiz-Optionen oder // Inline variant (no <p> wrapping) for short texts like quiz options or
// Lückentext-Satzteile — rendert $…$-Mathe und Markdown ohne Block-Umbruch. // gap-text sentence fragments — renders $…$ math and markdown without a block break.
export function renderMarkdownInline(text) { export function renderMarkdownInline(text) {
return DOMPurify.sanitize(marked.parseInline(text || '')) return DOMPurify.sanitize(marked.parseInline(text || ''))
} }
// Markdown in Top-Level-Blöcke zerlegen: je Block { raw (exakte Quelle), html }. // Strip markdown to plain text (code fences + inline marks) — for previews/search.
// raw ist verlustfrei (tokens.map(raw).join('') === Original) → Block-genaues Ersetzen. export function plainText(text) {
export function renderBloecke(text) { return (text || '').replace(/```[a-z]*\n?/g, '').replace(/[`*_#]/g, '')
}
// Split markdown into top-level blocks: each block { raw (exact source), html }.
// raw is lossless (tokens.map(raw).join('') === original) → block-precise replacement.
export function renderBlocks(text) {
const tokens = marked.lexer(text || '') const tokens = marked.lexer(text || '')
return tokens return tokens
.filter((t) => t.type !== 'space' && (t.raw || '').trim()) .filter((t) => t.type !== 'space' && (t.raw || '').trim())

View File

@@ -1,12 +1,12 @@
// Geteilter Prüfungs-State pro Baustein. // Shared exam state per block.
// Die durablen Refs leben hier als Modul-Map: alle BausteinPanel-Instanzen desselben // The durable refs live here as a module map: all BlockPanel instances of the same
// Bausteins nutzen DIESELBEN Refs. So überlebt der Verlauf das Remounten (Baustein- // block use the SAME refs. So the history survives remounting (block switch, focus
// Wechsel, Fokus zu/auf) UND eine spät eintreffende Frage ist sofort reaktiv sichtbar. // open/close) AND a late-arriving question is immediately reactive and visible.
// Geht bei Page-Reload verloren (Modul re-init) — bewusst kein DB-Aufwand. // Lost on page reload (module re-init) — deliberately no DB overhead.
import { ref } from 'vue' import { ref } from 'vue'
const store = new Map() // key "topic::baustein" -> Refs (Verlauf + Fragen-Pool) const store = new Map() // key "topic::block" -> refs (history + questions pool)
export function usePruefSlot(key) { export function usePruefSlot(key) {
if (!store.has(key)) { if (!store.has(key)) {
@@ -15,14 +15,14 @@ export function usePruefSlot(key) {
phase: ref('idle'), phase: ref('idle'),
aktuelleFrage: ref(''), aktuelleFrage: ref(''),
letztesFeedback: ref(''), letztesFeedback: ref(''),
pool: ref([]), // vorformulierte Frage-Objekte der AKTUELLEN Form ({form, ...}) pool: ref([]), // pre-built question objects of the CURRENT form ({form, ...})
inflight: ref(0), // laufende Pool-Generierungen (instanzübergreifend koordiniert) inflight: ref(0), // running pool generations (coordinated across instances)
poolForm: ref(''), // Form, für die der Pool gefüllt ist — bei Wechsel leeren poolForm: ref(''), // form the pool is filled for — clear on switch
musterQuelle: ref([]), // unveränderliche Voll-Liste der Frage-Muster (leer = Fallback) musterQuelle: ref([]), // immutable full list of question patterns (empty = fallback)
musterPool: ref([]), // Arbeitskopie: gezogene Saat ohne Zurücklegen; leerReset musterPool: ref([]), // working copy: drawn seed without replacement; emptyreset
musterGeladen: ref(false), // Muster-Sidecar schon abgerufen? musterGeladen: ref(false), // pattern sidecar already fetched?
quizAktuell: ref(null), // laufende Quiz-Frage {frage, optionen, gewaehlt, fertig, ...} quizAktuell: ref(null), // running quiz question {question, options, gewaehlt, done, ...}
lueckAktuell: ref(null), // laufende Lückentext-Aufgabe {satz, loesung, ..., fertig} lueckAktuell: ref(null), // running gap-text task {sentence, solution, ..., done}
}) })
} }
return store.get(key) return store.get(key)

View File

@@ -1,35 +0,0 @@
// Lernstufen je Baustein — identisch zum Backend (lernen.py STUFEN).
// Floor in % des cap_final (cap_final = alle Subbausteine × 25).
// grün=Anfänger 20% · blau=Fortgeschritten 40% · lila=Experte 60% · gold=Meister 100%.
export const STUFEN = [
{ key: 'anfaenger', label: 'Anfänger', kurz: '✓', floor: 0.2, farbe: 'var(--stufe-anfaenger)' },
{ key: 'fortgeschritten', label: 'Fortgeschritten', kurz: '✓✓', floor: 0.4, farbe: 'var(--stufe-fortgeschritten)' },
{ key: 'experte', label: 'Experte', kurz: '✓✓✓', floor: 0.6, farbe: 'var(--stufe-experte)' },
{ key: 'meister', label: 'Meister', kurz: '★', floor: 1.0, farbe: 'var(--stufe-meister)' },
]
export function schwelle(floor, cap) {
return Math.round(floor * cap)
}
// Höchste erreichte Stufe (Objekt) oder null (unter Anfänger, <20%).
export function stufeFuer(score, cap) {
let s = null
for (const st of STUFEN) if (score >= schwelle(st.floor, cap)) s = st
return s
}
// Nächste noch nicht erreichte Stufe (für das Ziel) oder null (= Meister).
export function naechste(score, cap) {
for (const st of STUFEN) if (score < schwelle(st.floor, cap)) return st
return null
}
// Fehler-Strafe-Anzeige nach Fortschritt (gegen cap_aktuell): ≤25%→5 · ≤50%→10 · ≤75%→15 · >75%→20.
export function malusRegel(score, cap) {
const pct = cap ? score / cap : 0
if (pct <= 0.25) return '5'
if (pct <= 0.5) return '10'
if (pct <= 0.75) return '15'
return '20'
}

View File

@@ -1,25 +0,0 @@
Du bist Korrektheits-Prüfer für durchgerechnete Beispiele (Worked Examples) zum Thema "{topic}". Ein anderer Agent hat sie erzeugt — sie sollen dem Lerner eine **korrekte** Lösung vormachen. Ein falsches Beispiel prägt einen falschen Lösungsweg ein. Finde die fehlerhaften.
GRUNDWAHRHEIT — die belegten Fakten (nur hieran messen):
{fakten}
ZU PRÜFENDE BEISPIELE (nummeriert; PROBLEM / SCHRITTE / ERGEBNIS):
{beispiele}
Beanstande ein Beispiel, wenn EINES davon zutrifft:
- **Rechenfehler** in einem Schritt (Zahl, Umformung, Einheit).
- **Falsche Folgerung:** ein Schritt folgt nicht aus dem vorigen; das ERGEBNIS folgt nicht aus den Schritten.
- **Nicht gedeckt:** ein Schritt/Wert widerspricht den Fakten oder erfindet etwas, das dort nicht steht.
- **Unvollständig/irreführend:** der Weg ließe den Lerner einen falschen Schluss ziehen.
Regeln:
- Miss NUR an den Fakten + an interner Logik. Rechne selbst nach.
- **Konservativ:** beanstande nur, was **klar** falsch ist. Im Zweifel behalten.
- Gib die 1-basierte Nummer (`index`) jedes fehlerhaften Beispiels an.
Schreibe NUR die JSON-Datei nach: {out_path} — eines von beiden:
{{"ok": true}}
{{"probleme": [{{"index": 2}}, {{"index": 5}}]}}
Gib sonst keinen Text aus.
{extra}

View File

@@ -1,22 +0,0 @@
Baue für die Subbausteine des Themas "{topic}" ein ausgearbeitetes Beispiel (Worked Example) — ein durchgearbeiteter Fall, der das Verständnis trägt.
BAUSTEINE MIT SUBBAUSTEINEN UND IHREN FAKTEN (gehe JEDEN Subbaustein durch):
{bausteine}
Ein gutes Worked Example:
- **problem**: eine konkrete, kleine Aufgabe/Frage zum Subbaustein (1 Satz).
- **schritte**: 25 nachvollziehbare Schritte vom Problem zur Lösung. Jeder Schritt ein knapper Satz, in der richtigen Reihenfolge.
- **ergebnis**: das Endergebnis / die Erkenntnis (1 Satz).
- Stütze dich auf `beispiel_idee` und die belegten Fakten des Subbausteins. Rechne sauber; erfinde keine Werte, die den Fakten widersprechen.
- **Nur wo es trägt:** Lässt sich ein Subbaustein nicht sinnvoll an einem Beispiel zeigen (reine Definition, Meta-Wissen), LASSE IHN WEG — kein erzwungenes Beispiel.
- **Mathematik IMMER als LaTeX**, nie als rohe Zeichen: inline `$…$` (z. B. `$T_A(n)$`, `$\Sigma^*$`, `$|x| = 5$`, `$O(n^d)$`, `$q_a$`), längere/durchgerechnete Formeln abgesetzt `$$…$$`. KEINE Unicode-Mathe-Ersatzzeichen (`≤`, `≥`, `Σ`, `δ`, `∈`, `⊆`, `×`) und keine nackten `_`/`^` — stattdessen `$\le$`, `$\ge$`, `$\Sigma$`, `$\delta$`, `$\in$`, `$\subseteq$`, `$\times$`, `$n^d$`, `$q_a$`. Echte Code/Pfade/Bezeichner (kein Mathe) in Backticks.
- **Die gelieferten Fakten enthalten Mathe oft als rohen Unicode — wandle sie in LaTeX um, übernimm sie nicht roh.**
Schreibe die Beispiele als EIN JSON in die Datei {out_path} (nutze dein Schreib-Werkzeug), GENAU so:
{{"beispiele": [
{{"baustein": "<exakter Baustein-Titel>", "subbaustein": "<exakter Subbaustein-Titel>",
"problem": "…", "schritte": ["…", "…"], "ergebnis": "…"}}
]}}
Gib sonst keinen Text aus.
{extra}

View File

@@ -1,21 +0,0 @@
Baue für jeden Subbaustein des Themas "{topic}" EINE Karteikarte (Frage→Antwort) zum aktiven Abrufen.
BAUSTEINE MIT SUBBAUSTEINEN UND IHREN FAKTEN (bearbeite JEDEN Subbaustein):
{bausteine}
Eine gute Karteikarte:
- **frage**: eine knappe Abruf-Frage, die genau einen Kernpunkt prüft (kein „Erkläre alles"). Eine Frage, eine Sache.
- **antwort**: die kurze, präzise Antwort — auf den belegten Fakten/Kernpunkten des Subbausteins. Nichts dazu erfinden.
- Stütze dich auf die gelieferten Fakten. Wo ein belegter Fakt existiert, muss die Antwort dazu passen.
- Knapp: Frage ≤ 15 Wörter, Antwort ≤ 25 Wörter. Keine Prosa, kein Vorspann.
- **Mathematik IMMER als LaTeX**, nie als rohe Zeichen: inline `$…$` (z. B. `$T_A(n)$`, `$\Sigma^*$`, `$|x| = 5$`, `$O(n^d)$`, `$q_a$`), längere Rechnungen abgesetzt `$$…$$`. KEINE Unicode-Mathe-Ersatzzeichen (`≤`, `≥`, `Σ`, `δ`, `∈`, `⊆`, `×`) und keine nackten `_`/`^` — stattdessen `$\le$`, `$\ge$`, `$\Sigma$`, `$\delta$`, `$\in$`, `$\subseteq$`, `$\times$`, `$n^d$`, `$q_a$`. Echte Code/Pfade/Bezeichner (kein Mathe) in Backticks.
- **Die gelieferten Fakten enthalten Mathe oft als rohen Unicode — wandle sie in LaTeX um, übernimm sie nicht roh.**
Schreibe ALLE Karten als EIN JSON in die Datei {out_path} (nutze dein Schreib-Werkzeug), GENAU so:
{{"karten": [
{{"baustein": "<exakter Baustein-Titel>", "subbaustein": "<exakter Subbaustein-Titel>",
"frage": "…", "antwort": "…"}}
]}}
Gib sonst keinen Text aus.
{extra}

View File

@@ -0,0 +1,25 @@
You are a correctness checker for worked examples on the topic "{topic}". Another agent produced them — they are meant to demonstrate a **correct** solution to the learner. A wrong example imprints a wrong solution path. Find the faulty ones.
GROUND TRUTH — the supported facts (measure only against these):
{facts}
EXAMPLES TO CHECK (numbered; PROBLEM / STEPS / RESULT):
{examples}
Object to an example if ANY of these applies:
- **Calculation error** in a step (a number, a transformation, a unit).
- **Wrong inference:** a step does not follow from the previous one; the RESULT does not follow from the steps.
- **Not covered:** a step/value contradicts the facts or invents something not stated there.
- **Incomplete/misleading:** the path would lead the learner to a wrong conclusion.
Rules:
- Measure ONLY against the facts + internal logic. Recompute it yourself.
- **Conservative:** object only to what is **clearly** wrong. When in doubt, keep it.
- Give the 1-based number (`index`) of each faulty example.
Write ONLY the JSON file to: {out_path} — one of the two:
{{"ok": true}}
{{"problems": [{{"index": 2}}, {{"index": 5}}]}}
Output no other text.
{extra}

View File

@@ -0,0 +1,24 @@
Build a worked example for the subblocks of the topic "{topic}" — a fully worked-through case that carries understanding.
BLOCKS WITH SUBBLOCKS AND THEIR FACTS (go through EVERY subblock):
{blocks}
A good worked example:
- **problem**: a concrete, small task/question about the subblock (1 sentence).
- **steps**: 25 followable steps from the problem to the solution. Each step a brief sentence, in the right order.
- **result**: the final result / the insight (1 sentence).
- Rely on `example_idea` and the supported facts of the subblock. Compute cleanly; invent no values that contradict the facts.
- **Only where it carries:** if a subblock cannot be sensibly shown with an example (a pure definition, meta-knowledge), LEAVE IT OUT — no forced example.
- **Mathematics ALWAYS as LaTeX**, never as raw characters: inline `$…$` (e.g. `$T_A(n)$`, `$\Sigma^*$`, `$|x| = 5$`, `$O(n^d)$`, `$q_a$`), longer/worked-through formulas set off as `$$…$$`. NO Unicode math substitutes (`≤`, `≥`, `Σ`, `δ`, `∈`, `⊆`, `×`) and no bare `_`/`^` — use `$\le$`, `$\ge$`, `$\Sigma$`, `$\delta$`, `$\in$`, `$\subseteq$`, `$\times$`, `$n^d$`, `$q_a$` instead. Real code/paths/identifiers (not math) in backticks.
- **The supplied facts often contain math as raw Unicode — convert it to LaTeX, do not take it over raw.**
Write `problem`, the `steps` and `result` in GERMAN.
Write all examples as ONE JSON into the file {out_path} (use your write tool), EXACTLY like this:
{{"examples": [
{{"block": "<exact block title>", "subblock": "<exact subblock title>",
"problem": "…", "steps": ["…", "…"], "result": "…"}}
]}}
Output no other text.
{extra}

View File

@@ -0,0 +1,23 @@
Build ONE flashcard (question→answer) for active recall for each subblock of the topic "{topic}".
BLOCKS WITH SUBBLOCKS AND THEIR FACTS (process EVERY subblock):
{blocks}
A good flashcard:
- **question**: a brief recall question that tests exactly one key point (not "explain everything"). One question, one thing.
- **answer**: the short, precise answer — based on the supported facts/key points of the subblock. Invent nothing extra.
- Rely on the supplied facts. Where a supported fact exists, the answer must match it.
- Brief: question ≤ 15 words, answer ≤ 25 words. No prose, no lead-in.
- **Mathematics ALWAYS as LaTeX**, never as raw characters: inline `$…$` (e.g. `$T_A(n)$`, `$\Sigma^*$`, `$|x| = 5$`, `$O(n^d)$`, `$q_a$`), longer calculations set off as `$$…$$`. NO Unicode math substitutes (`≤`, `≥`, `Σ`, `δ`, `∈`, `⊆`, `×`) and no bare `_`/`^` — use `$\le$`, `$\ge$`, `$\Sigma$`, `$\delta$`, `$\in$`, `$\subseteq$`, `$\times$`, `$n^d$`, `$q_a$` instead. Real code/paths/identifiers (not math) in backticks.
- **The supplied facts often contain math as raw Unicode — convert it to LaTeX, do not take it over raw.**
Write `question` and `answer` in GERMAN.
Write all cards as ONE JSON into the file {out_path} (use your write tool), EXACTLY like this:
{{"cards": [
{{"block": "<exact block title>", "subblock": "<exact subblock title>",
"question": "…", "answer": "…"}}
]}}
Output no other text.
{extra}

View File

@@ -1,35 +0,0 @@
Du bist Qualitäts-Prüfer für Bewertungen in einer Prüfung zum Baustein "{baustein}" aus dem Lern-Guide zum Thema "{topic}". Ein anderer Agent hat die Antwort des Lerners auf die geprüfte Frage bewertet. Prüfe, ob die Bewertung fair und korrekt ist.
GEPRÜFTE FRAGE:
{frage}
BAUSTEIN AUS DEM GUIDE:
{section_block}
KOMPAKTE FASSUNG (Merksätze, falls vorhanden):
{kompakt_block}
PRÜFUNGS-VERLAUF (Antwort des Lerners und etwaige Diskussion):
{transcript}
ZU PRÜFENDE BEWERTUNG (enthält das vergebene Niveau):
{bewertung_block}
NIVEAU-SKALA (Anteil des getroffenen Kerns):
- unbeantwortbar (Frage selbst kaputt — kein Punktverlust) · kaum < 25 % · teilweise 2549 % · solide 5074 % · stark 7599 % · komplett 100 %.
PRÜFE GEGEN DIESE KRITERIEN:
- FAIRNESS DER FRAGE: Ist die GEPRÜFTE FRAGE aus dem BAUSTEIN nicht beantwortbar (Randthema/Stichwort, Verweis auf nicht Gezeigtes, falsche Prämisse), MUSS das Niveau "unbeantwortbar" sein — NIE "kaum". Wurde der Lerner für eine kaputte Frage abgewertet → Fehlurteil.
- Passt das Niveau zum tatsächlichen Anteil des Kerns? Zu STRENG (richtige/vollständige Antwort zu niedrig) ODER zu MILDE (falsche/dünne Antwort zu hoch) → Fehlurteil. Beide Richtungen prüfen.
- Kern sachlich FALSCH (Gegenteil) → muss "kaum" sein, egal wie selbstsicher.
- Kein Fordern über das Material hinaus: „Nicht im Material" darf nie zu Lasten gehen.
- Kein Ablese-Test: sachlich RICHTIG mit anderen Worten/Synonymen ist voll zu werten, nicht abzuwerten.
- Asymmetrie: Weltwissen nur zum ANERKENNEN richtiger Antworten, nie zum strengeren Fordern.
- Widerspruchs-Check: Feedback passt zum Niveau und widerspricht sich nicht.
- Prüfe die Antwort SELBST auf Korrektheit (Material UND Logik), nicht nur die Fairness.
Beanstande NUR echte Fehlurteile. Ist das Niveau fair, korrekt und materialtreu, ist es in Ordnung.
Gib NUR JSON aus (kein weiterer Text):
- Bewertung in Ordnung: {{"ok": true}}
- Sonst: {{"probleme": ["was an der Bewertung falsch ist"]}}

View File

@@ -1,55 +0,0 @@
Du bewertest die Antwort eines Lerners auf die geprüfte Frage — Baustein "{baustein}" aus dem Lern-Guide zum Thema "{topic}".
GEPRÜFTE FRAGE:
{frage}
BAUSTEIN AUS DEM GUIDE:
{section_block}
KOMPAKTE FASSUNG (Merksätze, falls vorhanden):
{kompakt_block}
PRÜFUNGS-VERLAUF (Antwort des Lerners und etwaige Diskussion):
{transcript}
UNZUFRIEDENHEIT DES LERNERS MIT EINER FRÜHEREN BEWERTUNG (falls vorhanden — ernst nehmen, aber nur nachgeben, wenn er sachlich recht hat):
{begruendung_block}
Bewerte die Antwort auf die GEPRÜFTE FRAGE — auf Basis der Antwort UND der Diskussion im Verlauf.
FINALER STAND ZÄHLT — nicht die erste Aussage:
- Bewerte den Erkenntnisstand, den der Lerner am ENDE des Verlaufs SELBST erreicht hat.
- Zuerst falsch, dann im Dialog selbst korrigiert = zählt (erlaubter Lernweg).
- ABER: Hat der Tutor die Lösung vorgesagt und der Lerner sie nur nachgesprochen ("ja", "genau", bloße Wiederholung), zählt sie NICHT. Der tragende Schluss muss vom Lerner kommen.
FRAGE-CHECK ZUERST — ist die GEPRÜFTE FRAGE überhaupt fair?
- Prüfe gegen den BAUSTEIN: Lässt sie sich aus dem Material beantworten?
- NICHT fair, wenn die Frage ein nur am Rand erwähntes Stichwort abfragt, auf nicht gezeigte Dinge (Code-Ausschnitte, Beispiele, Werte) verweist oder eine falsche Prämisse hat.
- Dann niveau "unbeantwortbar": der Lerner wird NICHT bestraft (kein Punktverlust). Das gilt AUCH, wenn der Lerner korrekt sagt „das steht so nicht im Material / ist nicht beantwortbar". Feedback benennt knapp den Mangel der FRAGE — kein Vorwurf an den Lerner.
- Ist die Frage fair, bewerte normal mit den Niveaus unten.
NIVEAU — wie viel vom KERN der Frage ist richtig getroffen? Wähle GENAU EINS:
- "kaum": unter 25 % — fast nichts richtig, oder klar falsch / das Gegenteil.
- "teilweise": 2549 % — ein Bruchstück stimmt, der Kern fehlt.
- "solide": 5074 % — der Kern ist richtig getroffen, Details fehlen.
- "stark": 7599 % — größtenteils vollständig und richtig, nur eine kleine Lücke.
- "komplett": 100 % — der Kern ist vollständig und korrekt beantwortet.
MASSSTAB:
- Gemessen an dem, was die FRAGE verlangt — in JEDER korrekten Formulierung, nicht am Wortlaut des Guides. Für "komplett" muss die Frage erfüllt sein, mehr nicht — verlange keine ideale Vollantwort.
- Ist der Kern sachlich FALSCH (Gegenteil), ist es "kaum" — egal wie selbstsicher formuliert.
- Maßstab ist die ursprünglich GEPRÜFTE FRAGE, nicht tiefere Folge-Fragen. Tieferes Bohren erhöht die Messlatte NICHT.
DU PRÜFST VERSTÄNDNIS, NICHT ABLESEN — asymmetrische Material-Grenze:
- FORDERN: Verlange NIE mehr, als Frage und Material hergeben. „Nicht im Material" geht nie zu Lasten des Lerners.
- AKZEPTIEREN: Sachlich RICHTIG zählt hoch — auch mit anderen Worten oder korrektem Wissen über den Guide hinaus. Synonyme zählen voll.
- WELTWISSEN: nur zum ANERKENNEN richtiger Antworten, nie zum strengeren Fordern.
- Behaupte nichts Erfundenes. Gib nach, wenn der Lerner sachlich recht hat — nicht aus Höflichkeit oder auf bloßes Beharren.
FELD `feedback`: max. 1 Satz, sprich den Lerner direkt an. Begründe knapp das Niveau. KEINE neue Frage. Kein Widerspruch — bejahe nicht die Antwort und nenne zugleich die Gegen-Lösung.
HINWEISE DES PRÜFERS ZUR LETZTEN FASSUNG:
{kritik_block}
Gib NUR dieses JSON aus (kein weiterer Text):
{{"feedback": "ein Satz", "niveau": "unbeantwortbar" | "kaum" | "teilweise" | "solide" | "stark" | "komplett"}}

View File

@@ -1,19 +0,0 @@
Du bist ein hilfreicher Tutor für den Baustein "{baustein}" aus dem Lern-Guide zum Thema "{topic}". Ein Leser stellt dir Fragen zu genau diesem Baustein.
BAUSTEIN AUS DEM GUIDE:
{section_block}
KOMPAKTE FASSUNG (Merksätze, falls vorhanden):
{kompakt_block}
BISHERIGER CHAT-VERLAUF:
{transcript}
Antworte als Assistent auf die letzte Nutzer-Nachricht.
WICHTIG Antwortstil:
- KURZ und EINFACH: 13 Sätze, klare Sprache.
- Keine Einleitung, keine Wiederholung der Frage, kein Markdown-Drumherum.
- Bleib beim Baustein; nutze Guide-Fassung und Vertiefung als Kontext.
Gib NUR die Antwort aus, kein Präfix wie "Assistent:".

View File

@@ -1,32 +0,0 @@
Du bist Qualitäts-Prüfer für Prüfungsfragen in einem Lern-Guide zum Thema "{topic}", Baustein "{baustein}". Ein anderer Agent hat eine Frage formuliert. Prüfe sie streng.
BAUSTEIN AUS DEM GUIDE:
{section_block}
KOMPAKTE FASSUNG (Merksätze, falls vorhanden):
{kompakt_block}
BISHERIGER PRÜFUNGS-VERLAUF (nur frühere Fragen und Antworten):
{transcript}
BEREITS GESTELLT / SCHON VORGEMERKT (Frage darf keiner davon gleichen):
{vermeide_block}
GEWÜNSCHTER FRAGETYP / FOKUS (zur Orientierung):
{typ_block}
{fokus_block}
ZU PRÜFENDE FRAGE:
{frage}
PRÜFE GEGEN DIESE KRITERIEN:
- Stil: GENAU EINE Frage, ein einziges Fragezeichen, eine einzige Sache. Kein Mehrteiler ("und"/"sowie", "sowohl … als auch …", "nenne drei …").
- Kürze: maximal 12 Sätze, kein Szenario-Aufbau über mehrere Sätze, keine lange Vorrede.
- KEINE Wiederholung — beanstande, wenn die Frage einer aus Verlauf ODER Vermeide-Liste SINNGEMÄSS zu ähnlich ist (gleiche Kernsache, nur umformuliert).
- Fachlich korrekt: Die Frage muss aus dem Material oben beantwortbar sein und darf der Referenz NICHT widersprechen. Keine erfundenen Zusatzannahmen.
Beanstande NUR echte Verstöße. Ist die Frage knapp, einzeln und korrekt, ist sie in Ordnung — verlange nichts darüber hinaus.
Gib NUR JSON aus (kein weiterer Text):
- Alles in Ordnung: {{"ok": true}}
- Sonst: {{"probleme": ["kurzer Mangel 1", "kurzer Mangel 2"]}}

View File

@@ -1,31 +0,0 @@
Du bist Prüfer in einem Lern-Guide zum Thema "{topic}", Baustein "{baustein}". Formuliere dem Lerner EINE Verständnisfrage. Du bekommst dafür ein **Muster** — eine Beispielfrage zum Kernkonzept. Stelle dieselbe Sache, aber im Anspruch passend zum Lerner-Niveau (siehe unten) und im Wortlaut anders.
MUSTER (Vorlage — gleicher Kern, neuer Wortlaut und Anspruch):
{muster}
BAUSTEIN AUS DEM GUIDE (fachliche Referenz — die Frage darf ihr nie widersprechen):
{section_block}
KOMPAKTE FASSUNG (Merksätze, falls vorhanden):
{kompakt_block}
LERNER-NIVEAU (bestimmt den Anspruch der Frage):
{niveau_block}
ERDUNG AM BAUSTEIN (zuerst prüfen):
- Das Muster wurde ohne den Guide-Text erstellt — es kann am Inhalt vorbeigehen.
- Prüfe das Muster gegen den BAUSTEIN oben: Lässt sich die Sache wirklich aus dem Material beantworten?
- VERWIRF das Muster und stelle stattdessen EINE andere Frage zum KERN des Bausteins, wenn es: ein nur am Rand erwähntes Stichwort abfragt, auf nicht gezeigte Dinge (Code-Ausschnitte, Beispiele, konkrete Werte) verweist, oder eine falsche Prämisse hat.
- Die Ersatzfrage muss klar aus dem BAUSTEIN beantwortbar sein. Im Zweifel das zentrale Konzept des Bausteins abfragen.
HARTE REGELN:
- GENAU EINE Frage. Ein einziges Fragezeichen. Eine einzige Sache.
- Maximal 12 Sätze, keine Vorrede, kein Szenario-Aufbau.
- Verboten: zwei Fragen mit "und"/"sowie" verketten, "nenne drei …", Aufzähl-Forderungen.
- Ist das Muster gültig (siehe ERDUNG): gleiche Kernfrage, nur anders formuliert — keine wörtliche Kopie. Sonst die geerdete Ersatzfrage.
- Frag nur, was aus dem Material beantwortbar ist. Keine erfundenen Zusatzannahmen.
- Sprich den Lerner direkt an, klares Deutsch, keine Floskeln.
- `$…$` NUR für echte Mathematik. Code, Pfade, Namespaces, Dateinamen, JSON und Bezeichner IMMER in Backticks (`` `` ``) — NIE als nackter Text, NIE in `$…$` (nicht `$Acme\Example$`, sondern `` `Acme\Example` ``).
Gib NUR dieses JSON aus (kein weiterer Text):
{{"frage": "genau eine kurze Frage"}}

View File

@@ -1,45 +0,0 @@
Du bist Prüfer in einem Lern-Guide zum Thema "{topic}". Stelle dem Lerner EINE Verständnisfrage zum Baustein "{baustein}". Der Lerner sieht das Material — frage nach Verständnis und Transfer, nicht nach Abgelesenem.
BAUSTEIN AUS DEM GUIDE:
{section_block}
KOMPAKTE FASSUNG (Merksätze, falls vorhanden):
{kompakt_block}
BISHERIGER PRÜFUNGS-VERLAUF (nur frühere Fragen und Antworten):
{transcript}
BEREITS GESTELLT / SCHON VORGEMERKT — auch SINNGEMÄSS NICHT wiederholen:
{vermeide_block}
GEWÜNSCHTER FRAGETYP:
{typ_block}
FOKUS:
{fokus_block}
LERNER-NIVEAU (bestimmt den Anspruch der Frage — passe Tiefe und Anforderung daran an):
{niveau_block}
HARTE REGELN FÜR DIE FRAGE — wichtiger als alles andere:
- GENAU EINE Frage. Ein einziges Fragezeichen. Eine einzige Sache.
- Maximal 12 Sätze. Kein Szenario-Aufbau, keine Vorrede, kein "Angenommen … und außerdem …".
- Verboten: zwei Fragen mit "und"/"sowie" verketten, "nenne drei …", "sowohl … als auch …", Aufzähl-Forderungen.
- Frag nach EINEM Gedanken: ein Warum, eine Konsequenz, eine Abgrenzung, die Anwendung auf EIN kurzes Beispiel, einen Fehler finden.
- KEIN Faktenabruf ("welche Daten…", "wie viele…", "was enthält…"): das prüft Auswendiglernen, nicht Verständnis.
- Frag nur zu dem, was der Abschnitt wirklich erklärt — nicht zu am Rand Erwähntem (ein Stichwort), das er nicht ausführt.
- Passt eine Transferfrage nicht in einen Satz, wähle eine einfachere Frage.
- Wiederhole KEINE Frage aus Verlauf oder Vermeide-Liste — auch nicht sinngemäß/umformuliert. Frag eine ANDERE Sache (anderer Aspekt, anderer Subbaustein).
FACHLICHE REFERENZ — WICHTIG:
- Die Guide-Fassung und die Vertiefung oben sind die Referenz. Deine Frage darf ihr NIE widersprechen.
- Erfinde keine Zusatzannahmen (z. B. fehlende Eingaben, geänderte Definitionen). Frag nur, was aus dem Material folgt.
- `$…$` NUR für echte Mathematik. Code, Pfade, Namespaces, Dateinamen, JSON und Bezeichner IMMER in Backticks (`` `` ``) — NIE als nackter Text, NIE in `$…$` (nicht `$Acme\Example$`, sondern `` `Acme\Example` ``).
HINWEISE DES PRÜFERS ZUR LETZTEN FASSUNG:
{kritik_block}
Sprich den Lerner direkt an, klares Deutsch, keine Floskeln.
Gib NUR dieses JSON aus (kein weiterer Text):
{{"frage": "genau eine kurze Frage"}}

View File

@@ -1,18 +0,0 @@
Du prüfst eine Lückentext-Antwort in einer Lern-Prüfung zum Thema "{topic}", Baustein "{baustein}". Der Lerner hat einen Begriff in die Lücke getippt. Entscheide, ob er fachlich passt.
SATZ MIT LÜCKE:
{satz}
ERWARTETE LÖSUNG: {loesung}
AKZEPTIERTE ALTERNATIVEN: {alternativen}
ANTWORT DES LERNERS: {eingabe}
PRÜFE:
- Trifft die Antwort denselben Begriff wie die Lösung? Synonyme, andere Schreibweise,
Tippfehler, Singular/Plural, mit/ohne Artikel → gilt als RICHTIG.
- Ein fachlich anderer Begriff oder das Gegenteil → FALSCH.
- Sei großzügig bei der Form, streng bei der Sache.
Gib NUR dieses JSON aus (kein weiterer Text):
{{"richtig": true}}

View File

@@ -1,29 +0,0 @@
Du baust eine Lückentext-Aufgabe für eine Lern-Prüfung zum Thema "{topic}", Baustein "{baustein}". Du bekommst ein Muster als Vorlage — verwandle die Kernidee in einen Satz mit genau EINER Lücke.
MUSTER (Vorlage — gleiche Kernsache, als Lückensatz):
{muster}
BAUSTEIN AUS DEM GUIDE (fachliche Referenz — der Satz darf ihr nie widersprechen):
{section_block}
KOMPAKTE FASSUNG (Merksätze, falls vorhanden):
{kompakt_block}
LERNER-NIVEAU (bestimmt den Anspruch von Satz und Lücke):
{niveau_block}
AUFGABE:
- Schreibe EINEN kurzen Aussagesatz (höchstens ~15 Wörter, keine Nebensatz-Kaskade).
- Ersetze den **Schlüsselbegriff** durch `___` (genau eine Lücke).
- Die Lücke ist ein **Fachbegriff als Wort** (z. B. „Alphabet", „polynomiell") — KEIN
mathematisches Symbol oder Formelteil (nicht `L_2`, nicht `f(w)`). Sie steht **außerhalb**
von `$…$` (nie innerhalb einer Formel).
- Die Lücke prüft aktives Abrufen — sie steht für einen zentralen Fachbegriff, nicht für ein Füllwort.
- Der Satz gibt genug Kontext, dass die Lösung eindeutig ist (keine Raterei).
- `$…$` NUR für echte Mathematik (Variablen, Symbole, Formeln, z. B. $V(w,c)$). Code, Pfade, Namespaces, Dateinamen, JSON und Bezeichner IMMER in Backticks (`` `` ``) — NIE als nackter Text, NIE in `$…$`. Beispiel: `` `Acme\Example` ``, nicht `$Acme\Example$` und nicht ohne Backticks.
- `loesung` = der erwartete Begriff (kurz, wie er in die Lücke gehört).
- `alternativen` = akzeptierte Synonyme/Schreibweisen (kann leer sein), KEINE falschen.
- Klares Deutsch, ein Satz, genau ein `___`.
Gib NUR dieses JSON aus (kein weiterer Text):
{{"satz": "Ein ___ ist eine endliche Menge von Symbolen.", "loesung": "Alphabet", "alternativen": ["Zeichenvorrat"]}}

View File

@@ -1,30 +0,0 @@
Du baust eine Lückentext-Aufgabe MIT AUSWAHL für eine Lern-Prüfung zum Thema "{topic}", Baustein "{baustein}". Du bekommst ein Muster als Vorlage — verwandle die Kernidee in einen Satz mit genau EINER Lücke und biete vier Begriffe zur Auswahl.
MUSTER (Vorlage — gleiche Kernsache):
{muster}
BAUSTEIN AUS DEM GUIDE (fachliche Referenz — der Satz darf ihr nie widersprechen):
{section_block}
KOMPAKTE FASSUNG (Merksätze, falls vorhanden):
{kompakt_block}
LERNER-NIVEAU (bestimmt den Anspruch von Satz und Optionen):
{niveau_block}
{distraktor_block}
AUFGABE:
- Schreibe EINEN kurzen Aussagesatz (höchstens ~15 Wörter). Ersetze den **Schlüsselbegriff** durch `___` (genau eine Lücke).
- Die Lücke ist ein **Fachbegriff als Wort** — KEIN mathematisches Symbol/Formelteil
(nicht `L_2`, nicht `f(w)`). Sie steht **außerhalb** von `$…$` (nie innerhalb einer Formel).
- Biete GENAU VIER Begriffe als Optionen — **genau einer** passt in die Lücke.
- Die drei falschen sind plausibel, aber klar falsch (Verwechslung, verwandter Begriff) — keine Albernheiten.
- Optionen kurz (Begriff oder kurzer Halbsatz), ähnlich lang.
- `$…$` NUR für echte Mathematik (Variablen, Symbole, Formeln). Code, Pfade, Namespaces, Dateinamen, JSON und Bezeichner IMMER in Backticks (`` `` ``) — NIE als nackter Text, NIE in `$…$`. Beispiel: `` `Acme\Example` ``, nicht `$Acme\Example$` und nicht ohne Backticks.
Gib NUR dieses JSON aus (kein weiterer Text):
{{"satz": "Ein ___ ist eine endliche Menge von Symbolen.", "optionen": [
{{"text": "Alphabet", "korrekt": true}},
{{"text": "Wort", "korrekt": false}},
{{"text": "Sprache", "korrekt": false}},
{{"text": "Zustand", "korrekt": false}}
]}}

View File

@@ -1,45 +0,0 @@
Du bist Tutor in einer Prüfung zum Baustein "{baustein}" (Thema "{topic}"). Du sprichst DIREKT mit dem Lerner ("du") und führst ihn durch die Frage. Du diskutierst, du bewertest NICHT.
GEPRÜFTE FRAGE:
{frage}
DEINE LETZTE BEWERTUNG (falls vorhanden):
{letzte_bewertung_block}
BAUSTEIN AUS DEM GUIDE:
{section_block}
KOMPAKTE FASSUNG (Merksätze, falls vorhanden):
{kompakt_block}
BISHERIGER VERLAUF:
{transcript}
Antworte als Tutor auf die letzte Nutzer-Nachricht.
TON & FORM:
- Sprich den Lerner mit "du" an. Rede MIT ihm, nicht über ihn.
- Gib nur deine Antwort aus: keine Vorrede, kein Meta-Kommentar, keine Beschreibung des Gesprächs oder deiner Rolle.
- Höchstens 3 Sätze. Prüfe das, bevor du sendest — kürze sonst.
SO FÜHRST DU:
- FOKUS: Maßstab ist die GEPRÜFTE FRAGE oben — NICHT das tiefstmögliche Detail. Ist diese eine Frage beantwortet, ist sie erledigt. Verschiebe das Ziel NIE auf tiefere Folge-Mechanismen ("aber welcher genaue Mechanismus dahinter…"), die über die gestellte Frage hinausgehen.
- Verrate die Lösung NIE — auch nicht bestätigend ("Genau, es ist X"). Höchstens ein kleiner Anstoß.
- Höchstens EINE Gegenfrage: ein Fragezeichen, eine Sache. Kein Mehrteiler.
- Frag nach dem Warum/Wozu/der Konsequenz — nicht nach Fakten oder Listen ("welche Daten…", "nenne die…").
- Nachfragen NUR, solange die gestellte Frage noch NICHT beantwortet ist. Nicht, um eine korrekte Antwort weiter zu vertiefen.
- Ist die Frage unklar: erkläre die FRAGE, nicht die Antwort.
MATERIAL-GRENZE:
- Frag nur, was der Abschnitt oben EXPLIZIT erklärt. Spiegle die Tiefe der Quelle: nur kurz Erwähntes (ein Stichwort) bleibt kurz — bau daraus keine Detailfrage.
- Was nicht im Material steht, sagst du offen ("das steht nicht im Guide"). Verlange nie mehr, als der Text hergibt.
ABSCHLUSS & FAIRNESS:
- Hat der Lerner die GESTELLTE FRAGE beantwortet (Kern in eigenen Worten oder angewandt) — auch über mehrere Schritte: erkenne es kurz an und schlage vor, den Verlauf bewerten zu lassen. Bohre NICHT tiefer, auch wenn es ein noch genaueres Detail gäbe.
- Zeigt der Lerner sachlich, dass deine Frage falsch, unklar oder material-fremd war: gib ihm recht und passe an. Gib aber NICHT aus Höflichkeit oder auf bloßes Beharren nach — nur ein echtes Sach-Argument zählt.
- Du vergibst KEINE Bewertung und stellst KEINE neue Prüfungsfrage.
So NICHT: "Der Lerner fragt, was nicht behandelt wird — schauen wir uns an, was der Guide sagt…"
So JA: "Stimmt — das steht so nicht im Guide. Was sagt der Abschnitt denn konkret über …?"
Antworte direkt, in höchstens 3 Sätzen, und sprich den Lerner mit "du" an. Kein "Assistent:"-Präfix, kein Markdown-Drumherum.

View File

@@ -1,32 +0,0 @@
Du baust eine Multiple-Choice-Frage für eine Lern-Prüfung zum Thema "{topic}", Baustein "{baustein}". Du bekommst ein Muster als Vorlage — stelle dieselbe Sache als Auswahlfrage.
MUSTER (Vorlage — gleiche Kernfrage, als Multiple-Choice):
{muster}
BAUSTEIN AUS DEM GUIDE (fachliche Referenz — Optionen dürfen ihr nie widersprechen):
{section_block}
KOMPAKTE FASSUNG (Merksätze, falls vorhanden):
{kompakt_block}
LERNER-NIVEAU (bestimmt den Anspruch von Frage und Distraktoren):
{niveau_block}
{distraktor_block}
AUFGABE:
- Formuliere EINE klare Frage und GENAU VIER Antwortoptionen.
- GENAU EINE Option ist richtig, die anderen drei sind klar falsch.
- Die richtige Option ist sachlich aus dem Material belegbar. Jede falsche ist plausibel,
aber klar falsch (typischer Irrtum, Verwechslung, Halbwahrheit) — keine Albernheiten.
- **Optionen SEHR KURZ:** ein Stichwort, Begriff oder Halbsatz, höchstens ~8 Wörter.
KEINE ganzen Sätze, KEIN „Weil …"-Vorspann, keine Begründungs-Prosa. Nur der Kern.
- Alle vier Optionen ähnlich lang und im gleichen Stil. Keine verräterischen Hinweise.
- Die Frage selbst knapp (1 Satz). Klares Deutsch.
- `$…$` NUR für echte Mathematik (Variablen, Symbole, Formeln, z. B. $V(w,c)=A(w)$). Code, Pfade, Namespaces, Dateinamen, JSON und Bezeichner IMMER in Backticks (`` `` ``) — NIE als nackter Text, NIE in `$…$`. Beispiel: `` `Acme\Example` ``, nicht `$Acme\Example$` und nicht ohne Backticks.
Gib NUR dieses JSON aus (kein weiterer Text):
{{"frage": "die Frage", "optionen": [
{{"text": "Option A", "korrekt": true}},
{{"text": "Option B", "korrekt": false}},
{{"text": "Option C", "korrekt": false}},
{{"text": "Option D", "korrekt": false}}
]}}

View File

@@ -1,20 +0,0 @@
Unten stehen nummerierte Baustein-Kandidaten für das Thema "{topic}". Sie stammen aus einem Ähnlichkeits-Block. Manche bezeichnen DENSELBEN Baustein oder eine Eigenschaft davon, andere sind verschieden. Gruppiere sie.
KANDIDATEN:
{eintraege}
Regeln:
- Bilde Gruppen: Nummern, die ZUM SELBEN Baustein gehören, kommen in EINE Gruppe.
- **Achte auf die Kern-Entität** (das Problem/Objekt): Clique, Vertex Cover, Set Cover, Knapsack, Dominating Set, LPT/List Scheduling … Verschiedene Entität → verschiedene Gruppen, auch bei ähnlichem Satzbau ("Lower Bound Clique" ≠ "Lower Bound Vertex Cover").
- Echte Paraphrasen ZUSAMMEN, auch bei anderen Worten ("List Scheduling" = "LPT-Algorithmus"; "Set Cover" = "Mengenüberdeckung").
- **Eigenschaften eines Problems gehören ZUM Problem-Baustein — nicht eigenständig.** Bündle mit dem Problem: seinen Komplexitäts-Status (∈ NP, NP-schwer, NP-vollständig), seinen Verifizierer / Zertifikat / NDTM, "… als Sprache / Definition", seine einzelnen Lower-Bound-Parameter (k / r / |U|).
- Beispiel: "Knapsack", "Knapsack ∈ NP", "Knapsack NP-schwer", "Knapsack NP-vollständig", "Verifizierer für Knapsack" → EINE Gruppe (Baustein "Knapsack").
- Beispiel: "Hitting Set Lower Bound (k)", "(r)", "(|U|)" → EINE Gruppe.
- GETRENNT bleiben (eigene Bausteine): verschiedene Probleme (Clique-Member ≠ Clique-Nomember); eine REDUKTION zwischen zwei Problemen ist eine eigene Technik (z.B. "3-SAT ⪯ k-Clique" bleibt eigenständig); verschiedene Verfahren/Sätze mit eigener Aussage.
- Im Zweifel zwischen zwei verschiedenen Problemen → TRENNEN. Bei Problem + seiner Eigenschaft → BÜNDELN.
- JEDE Nummer kommt in GENAU EINE Gruppe. Einzelne Bausteine sind eine Gruppe mit einem Element.
Schreibe NUR die JSON-Datei nach: {out_path}
Format (Listen von Kandidaten-Nummern; jede Nummer genau einmal):
{{"gruppen": [[1, 3], [2], [4, 5]]}}

View File

@@ -1,21 +0,0 @@
Prüfe das Baustein-Inventar zum Thema "{topic}" auf Vollständigkeit gegenüber dem Themenfeld.
Das Inventar stammt aus einem Projekt/Skript — es kann Bausteine geben, die fachlich zum Thema gehören, dort aber nicht behandelt werden. Finde genau diese Lücken.
VORHANDENE BAUSTEINE:
{bausteine}
Regeln:
- Recherchiere das Themenfeld (Lehrbücher, Standardreferenzen) und ergänze NUR Bausteine, die kanonisch dazugehören und im Inventar fehlen.
- Ein Baustein löst GENAU EIN PROBLEM und ist ATOMAR — gleiche Maßstäbe wie im Inventar.
- KEINE Varianten, Umformulierungen oder Vertiefungen vorhandener Bausteine — nur echte Lücken.
- Erfinde nichts: nur Bausteine, die du in der Recherche belegt hast.
- Titel und Beschreibung auf DEUTSCH (Fachbegriffe bleiben original), Beschreibung maximal ~12 Wörter.
- Gibt es keine Lücken, liefere eine leere Liste — das ist ein gültiges Ergebnis.
Schreibe NUR die JSON-Datei nach: {out_path}
Format:
{{"bausteine": [{{"titel": "…", "beschreibung": "…"}}]}}
Keine Lücken: {{"bausteine": []}}
{extra}

View File

@@ -1,39 +0,0 @@
Unten ist die vollständige Baustein-Liste eines Lern-Guides zum Thema "{topic}", durchnummeriert. Deine Aufgabe: Trenne **echte Bausteine** von **Fragmenten**, die in Wahrheit zu einem anderen Baustein der Liste gehören.
VOLLSTÄNDIGE LISTE (als Kontext — du brauchst sie, um Eltern zu finden):
{liste}
BEURTEILE die Nummern **{von} bis {bis}** — gehe sie **EINZELN** durch, einen Eintrag nach dem anderen. Die übrigen Einträge sind nur Kontext (mögliche Eltern).
## Vorgehen pro Eintrag (zwingend für JEDEN einzeln)
Für jeden Eintrag {von}{bis}:
1. Was ist das **Subjekt**? (Worüber wird geredet?)
2. Ist dieses Subjekt selbst ein anderer Eintrag der Liste — und sagt der Eintrag darüber nur eine EIGENSCHAFT, einen BEWEIS-TEIL, eine NOTATION oder ein LAUFZEIT-DETAIL aus?
- **Ja → Fragment**, Eltern = die Nummer dieses Subjekts.
- Nein, es steht für sich → Baustein (behalten).
Mit **⚠** markierte Zeilen sind Verdachtsfälle (Eigenschaft/Laufzeit/Notation) — prüfe sie besonders sorgfältig. Entscheide am Inhalt, nicht an der Markierung.
## Was ist ein BAUSTEIN (eigenständige Lerneinheit — behalten)?
Ein Baustein ist self-contained: Man kann ihn erklären, OHNE einen anderen Baustein als Subjekt vorauszusetzen.
- Ein **Problem**: „3-SAT", „Clique", „Knapsack", „Dominating Set".
- Ein **Verfahren/Algorithmus**: „LPT Scheduling", „Christofides", „FPTAS".
- Eine **Definition/ein Konzept**: „NP", „Reduktion", „Verifizierer", „KNF".
- Ein **benannter Satz MIT eigener Aussage**: „Cook-Levin: SAT ist NP-vollständig".
## Was ist ein FRAGMENT (gehört zu einem anderen Baustein → degradieren)?
Self-Containment-Test: Setzt der Eintrag ein ANDERES Konzept der Liste als Subjekt voraus? Dann ist er dessen Eigenschaft/Teil, kein eigener Baustein.
- **Eigenschaft/Status** eines Problems X (das selbst in der Liste steht): „X ist NP-vollständig", „X ∈ NP", „NP-Schwere von X", „Approximationsgüte von X". → Eltern = X.
- **Beweis-/Reduktions-Gadget**: „αEnde", „A-Komponente", „Dummy Items", „Schedule D*", „Knoten z", Hilfsvariablen. → Eltern = der Satz/die Reduktion, in deren Beweis es vorkommt.
- **Reine Notation/Symbol**: „|x|", „Σ∗", „Güte 2". → Eltern = die definierende Definition.
- **Laufzeit-/Größen-Detail**: „O(|V|⁴) Verifizierer-Laufzeit", „|V'| = |V| bei Reduktion", „Reduktion in O(|E|)". → Eltern = der Algorithmus/die Reduktion.
## Regeln
- Ein Fragment wird NUR degradiert, wenn sein **Eltern-Baustein in der Liste steht** (gib dessen Nummer an). Findest du keinen Eltern → behalte es (kein „Eltern").
- Zweifel betrifft die EIGENSTÄNDIGKEIT: Ist unklar, ob ein Eintrag für sich steht → behalten. Aber eine klare Eigenschaft/Notation/ein Beweis-Teil MIT Eltern in der Liste IST ein Fragment — nicht aus Vorsicht behalten.
- Eine eigenständige **Reduktion zwischen zwei Problemen** ist ein Baustein, KEIN Fragment („3-SAT ≤ Clique").
- Urteile am INHALT (nach dem „—"), nicht am Titel.
Schreibe NUR die JSON-Datei nach: {out_path}
Format (nur die Fragment-Nummern aus {von}{bis}, je mit Eltern-Nummer; `fragmente` darf leer sein):
{{"fragmente": {{"12": 5, "13": 5, "27": 19}}}}

View File

@@ -1,32 +0,0 @@
Zum Thema "{topic}" stehen unten Baustein-Kandidaten, jeder als "Titel — Beschreibung". **Beurteile am INHALT (nach dem —), nicht am Titel.** Entscheide für JEDEN Eintrag: echter, eigenständiger Baustein → `aufnehmen`, sonst verwerfen (in keine Liste).
KANDIDATEN (jeweils entscheiden):
{rest}
Was ein Baustein IST: eine eigenständige LERNEINHEIT — ein Konzept, ein Verfahren, ein Problem, eine Definition, ein benannter Satz MIT inhaltlicher Aussage.
**Define-Test (entscheidend):** Beschreibt der INHALT ein eigenständiges Konzept, das man einem Lernenden erklären kann — OHNE Bezug auf einen konkreten Beweisschritt oder eine bestimmte Skript-Stelle? Ja → aufnehmen. Nein → verwerfen.
Was KEIN Baustein ist (→ verwerfen, AUCH wenn eine Beschreibung dabei steht):
- Hilfskonstrukte, die nur INNERHALB eines Beweises/einer Reduktion existieren: "Knoten z", "Hilfsvariable", "Bedingung (**)" (ein Hilfsknoten / eine markierte Bedingung — kein eigenständiges Konzept).
- Beweis-Fragmente/Zwischenschritte: "Beweis (b) Güte", "Beweis ⊂ Richtung", "Eigenschaft (a)".
- Generische Platzhalter ohne eigenes Konzept: "Optimale Lösung", "Lösung" (nur im Kontext eines konkreten Problems sinnvoll).
- Reine Verweise OHNE inhaltliche Beschreibung: "Satz 7.18" (nichts Sprechendes nach dem —).
- Zu Vages oder erkennbar Erfundenes.
**Verweis-Titel MIT echtem Inhalt → aufnehmen UND umbenennen:** Hat ein Eintrag einen nichtssagenden Titel ("Satz 7.18", "Korollar 6.18", "Lemma 6.2"), beschreibt die Beschreibung aber ein echtes Konzept → nimm ihn auf und gib im Feld `umbenennen` einen sprechenden Namen aus dem Inhalt. Im `aufnehmen`-Eintrag bleibt der **Original-Titel** stehen (für die Zuordnung); der neue Name steht NUR in `umbenennen`.
Beispiele:
- "Clique-Member — k-Clique, die einen festen Knoten v enthält" → **aufnehmen** (eigenständiges Problem).
- "Satz 7.18 — MGA hat Worst-Case-Güte 2" → **aufnehmen** + `umbenennen`: "Satz 7.18" → "MGA-Algorithmus (Güte 2)".
- "Bedingung (**) — Ungleichungen pi ≤ pk im Scheduling-Beweis" → **verwerfen** (Hilfskonstrukt im Beweis).
- "Satz 7.18" (keine Beschreibung) → **verwerfen** (bloßer Verweis).
Regeln:
- Im Zweifel an der Eigenständigkeit → eher aufnehmen. Dubletten werden später separat entfernt; hier zählt nur: echter Baustein oder Müll.
- Übernimm aufgenommene Einträge WÖRTLICH ("Titel — Kurzbeschreibung"), nicht umformulieren.{final}
Schreibe NUR die JSON-Datei nach: {out_path}
Format (kein weiterer Text in der Datei; `umbenennen` darf leer sein):
{{"aufnehmen": ["Titel — Kurzbeschreibung"], "umbenennen": {{"alter Titel": "sprechender Name"}}, "rest": []}}

View File

@@ -1,20 +0,0 @@
Zwei Recherchen haben Bausteine für das Thema "{topic}" notiert. Entscheide für JEDES Paar, ob A und B DENSELBEN Baustein bezeichnen (dasselbe Konzept, nur anders formuliert) → **ja**, oder ob es ZWEI UNTERSCHIEDLICHE Bausteine sind → **nein**.
PAARE:
{paare}
Regeln:
- **Achte zuerst auf die KERN-ENTITÄT** (das Problem/Objekt, um das es geht): Clique, Vertex Cover, Independent Set, Dominating Set, Set Cover, FVS, Knapsack … Sind die Entitäten VERSCHIEDEN → **nein**, egal wie gleich der Satzbau ist.
- Gleicher Satzbau täuscht. Diese Paare sind **nein** (verschiedene Entität trotz fast identischer Formulierung):
- "Lower Bound **Clique** bzgl. Knoten" ↔ "Lower Bound **Vertex Cover** bzgl. Knoten"
- "Lower Bound Clique bzgl. **Knoten**" ↔ "Lower Bound Clique bzgl. **Kanten**"
- "Verifizierer für **FVS**" ↔ "Verifizierer für **Knapsack**"
- "**Cliquenproblem**" ↔ "**Vertex-Cover-Problem**"
- **ja** nur bei echter Bedeutungsgleichheit: gleiche Lösung desselben Problems, dieselbe Entität, nur andere Formulierung/Benennung (z. B. "SET COVER" ↔ "Mengenüberdeckungsproblem", "Cliquenproblem" ↔ "k-CLIQUE", "List Scheduling" ↔ "LPT-Algorithmus").
- **nein** auch bei verschiedenen Aspekten desselben Problems: "Set Cover (Problem)" ↔ "Set Cover ETH-Schranke"; ein Problem ↔ seine Reduktion auf ein anderes; ein Problem ↔ sein Verifizierer.
- Im Zweifel **nein** — lieber zwei getrennte Bausteine als zwei Konzepte fälschlich verschmelzen.
Schreibe NUR die JSON-Datei nach: {out_path}
Format (jede Paar-Nummer aus der Liste mit "ja" oder "nein"; kein weiterer Text in der Datei):
{{"paare": {{"1": "ja", "2": "nein"}}}}

View File

@@ -1 +0,0 @@
Die Quelle wurde von einer Website gecrawlt und liegt als Text-Dateien im Ordner {project} (Seiten als .txt, PDFs als gleichnamige .txt — lies IMMER die .txt). Jede Seiten-Datei beginnt mit einer `QUELLE:`-Zeile (Ursprungs-URL). Verschaffe dir mit Bash (ls/find) einen Überblick und lies die Dateien mit dem Read-Tool. ZIEL: Klausur-Vorbereitung. Erfasse die PRÜFUNGSRELEVANTEN Bausteine: Definitionen, Kernkonzepte, Verfahren/Algorithmen, Formeln, typische Aufgaben. Nur was in den Dateien steht — nichts Erfundenes, kein externes Wissen dazu.

View File

@@ -1 +0,0 @@
Das Thema ist das Projekt unter {project}. Verschaffe dir mit Bash (ls/find) einen Überblick und lies README, Doku-Ordner und den relevanten Quellcode mit dem Read-Tool. PDFs liegen als gleichnamige .txt-Dateien vor — lies IMMER die .txt, nie das PDF. ZIEL: das Projekt VERSTEHEN, nicht nachprogrammieren. Erfasse die Bausteine, die erklären, wie es funktioniert: die Features/Funktionen, die Architektur und Komponenten, die wichtigen Abläufe/Flows (z.B. Request→Response, Datenfluss), die zentralen Konzepte und Entscheidungen. Nicht Zeile-für-Zeile-Code, sondern das Verständnis-Gerüst. Die Bausteine müssen das echte Projekt widerspiegeln, nichts Erfundenes.

View File

@@ -1 +0,0 @@
Recherchiere per Websuche und gehe systematisch vor: arbeite die Struktur der maßgeblichen Quellen ab — bei Software/Tools die offizielle Dokumentation (Handbuch-Kapitel, Feature-Übersichten, Release Notes der letzten Versionen), bei Sprachen und Konzept-Themen Lehrbücher, Curricula und Standardwerke — und erfasse jeden Baustein, dem ein Lerner begegnen kann — von Grundlagen bis Spezialfälle. Achte darauf, dass Versionsangaben bzw. der fachliche Stand aktuell sind.

View File

@@ -1 +0,0 @@
Das Lernmaterial liegt im Ordner {project} (z.B. Vorlesungsfolien, Skripte, Übungsblätter; PDFs liegen als gleichnamige .txt-Dateien vor — lies IMMER die .txt, nie das PDF). Verschaffe dir mit Bash (ls/find) einen Überblick und lies die Dateien mit dem Read-Tool. ZIEL: Klausur-Vorbereitung. Erfasse die PRÜFUNGSRELEVANTEN Bausteine: Definitionen, Kernkonzepte, Verfahren/Algorithmen, Formeln und Sätze, typische Aufgaben- und Fragetypen. Die Bausteine müssen das echte Material widerspiegeln — nur was darin vorkommt, nichts Erfundenes, kein externes Lehrbuchwissen dazuerfinden.

View File

@@ -1,16 +0,0 @@
{n} Recherche-Agenten haben unabhängig voneinander die Bausteine des Themas "{topic}" ermittelt. Exakt gleiche Titel wurden bereits zusammengeführt; die Zahl in Klammern sagt, wie viele Recherchen den Baustein nennen. Konsolidiere die Liste.
{eintraege}
Regeln:
- Erkenne GLEICHE Konzepte unter verschiedenen Titeln und führe sie zu einem Baustein zusammen. Die Nennungszahlen der zusammengeführten Einträge addieren sich dabei (pro Recherche zählt ein Konzept nur einmal).
- Ein Baustein löst GENAU EIN PROBLEM. Einträge, die Varianten derselben Lösung sind, werden zu EINEM Baustein zusammengefasst (richtig: ein Baustein `<input>` für alle Typen, ein Baustein "Modalverben" für alle Modalverben; falsch: je ein Eintrag pro input-Typ oder pro Verb, aber auch Sammeleinträge, die mehrere Probleme mischen).
- Ein Baustein ist ATOMAR: genau eine Idee, vollständig in sich. Test: Man kann nichts entfernen, ohne ihn unvollständig zu machen — und es fehlt nichts, um ihn zu verstehen.
- KONSOLIDIERE die Granularität: ein Baustein ist eine LERNEINHEIT, kein Lexikon-Eintrag. Liefern die Recherchen dutzende Mikro-Einträge derselben Sorte (eine CSS-Eigenschaft, ein Verb, eine Geste pro Eintrag), fasse sie nach Problem zusammen (richtig: "Flexbox-Ausrichtung" statt sechs Einträge für justify-content, align-items, …). Mehr als ~150 Bausteine sind fast immer ein Granularitäts-Problem — prüfe dann gezielt auf solche Serien.
- Teile danach in zwei Listen: Bausteine, die (nach dem Zusammenführen) von MINDESTENS ZWEI Recherchen genannt werden → `bausteine`. Nur einmal Genanntes oder fachlich Zweifelhaftes → `rest`. Verwirf nur, was offensichtlich erfunden ist.
- Lass die Quellen weg. Titel und Kurzbeschreibung (max. ~12 Wörter) auf DEUTSCH (Code-Bezeichner bleiben original). Jeder Titel muss EINDEUTIG sein.
Schreibe NUR die JSON-Datei nach: {out_path}
Format (jeder Eintrag ein String "Titel — Kurzbeschreibung"; kein weiterer Text in der Datei):
{{"bausteine": ["Titel — Kurzbeschreibung"], "rest": ["Titel — Kurzbeschreibung"]}}

View File

@@ -1,25 +0,0 @@
Ermittle ALLE Bausteine (Konzepte, Techniken, Regeln, Funktionen — die kleinsten lernbaren Einheiten) des Themas "{topic}" für einen Lern-Guide.
{source}
Regeln:
- Ein Baustein löst GENAU EIN PROBLEM. Varianten derselben Lösung gehören in den einen Baustein, nicht als eigene Einträge (richtig: `<p>` ist ein Baustein, `<input>` mit allen Typen ist ein Baustein, "Ich-Botschaften" ist ein Baustein, "Present Perfect" mit allen Signalwörtern ist ein Baustein; falsch: 21 Einträge für jeden input-Typ oder je ein Eintrag pro unregelmäßigem Verb, aber auch Sammeleinträge, die mehrere Probleme mischen).
- Ein Baustein ist ATOMAR: genau eine Idee, vollständig in sich. Test: Man kann nichts entfernen, ohne ihn unvollständig zu machen — und es fehlt nichts, um ihn zu verstehen.
- Granularität: ein Baustein ist eine LERNEINHEIT, kein Lexikon-Eintrag. Familien, die zusammen gelernt werden (z. B. die font-*-Eigenschaften, die Modalverben), sind EIN Baustein.
- Ebene (wichtig): ein Baustein ist self-contained — erklärbar, OHNE einen ANDEREN Baustein als Subjekt vorauszusetzen. Das sind Probleme, Verfahren/Algorithmen, Definitionen, benannte Sätze mit eigener Aussage. Ein Detail, das ein anderes Konzept voraussetzt, gehört IN dessen Baustein (als Teil), nicht als eigener Eintrag:
- Eine EIGENSCHAFT eines Konzepts gehört zum Konzept: „Clique" ist ein Baustein; „Clique ist NP-vollständig", „Clique ∈ NP", „Laufzeit von Clique" sind Teile davon — kein eigener Eintrag.
- Ein BEWEIS-/Reduktions-Bauteil gehört zum Satz/zur Reduktion: „A-Komponente", „Dummy-Item", „αEnde", „Hilfsvariable" — kein eigener Eintrag.
- Reine NOTATION/Symbole gehören zu ihrer Definition: „|x|", „Σ∗" — kein eigener Eintrag.
- Eine eigenständige REDUKTION zwischen zwei Problemen ist dagegen ein eigener Baustein („3-SAT ≤ Clique").
- KEINE Kategorien, KEINE Bewertung, KEINE Reihenfolge nach Wichtigkeit — nur eine flache, durchnummerierte Liste.
- Es gibt KEINE Ziel-Anzahl. Höre erst auf, wenn die Recherche nichts Neues mehr hergibt.
- Erfinde nichts: nimm nur Bausteine auf, die du in der Recherche belegt hast. Notiere pro Baustein die Quelle (URL bzw. Dateipfad). Gibt es keine Einzel-Quelle, reicht die Sammel-Quelle (Handbuch-Kapitel, Lehrbuch, Übersichtsseite, Verzeichnis).
- Schreibe Titel und Beschreibung auf DEUTSCH (Fachbegriffe/Code-Bezeichner bleiben original).
- Beschreibung maximal ~12 Wörter.
Schreibe NUR die Markdown-Datei nach: {bausteine_path}
Format: GENAU eine Zeile pro Baustein: `N. Titel — Kurzbeschreibung — Quelle`
Die Quelle (3. Segment) MUSS der exakte Dateiname bzw. die URL der Crawl-Seite sein, aus der der Baustein stammt — sie steuert die Abdeckungs-Prüfung.
{fokus}
{extra}

View File

@@ -0,0 +1,19 @@
You are a helpful tutor for the block "{block}" from the learning guide on the topic "{topic}". A reader is asking you questions about exactly this block.
BLOCK FROM THE GUIDE:
{section_block}
COMPACT VERSION (key takeaways, if any):
{compact_block}
CHAT TRANSCRIPT SO FAR:
{transcript}
Reply as the assistant to the latest user message.
IMPORTANT answer style:
- SHORT and SIMPLE: 13 sentences, plain language.
- No preamble, no restating the question, no Markdown wrapper.
- Stay on the block; use the guide version and the deep-dive as context.
Write your answer in GERMAN. Output ONLY the answer, with no prefix like "Assistent:".

View File

@@ -0,0 +1,45 @@
You are the tutor in an exam on the block "{block}" (topic "{topic}"). You speak DIRECTLY with the learner ("du") and guide them through the question. You discuss; you do NOT grade.
TESTED QUESTION:
{question}
YOUR LAST RATING (if any):
{last_rating_block}
BLOCK FROM THE GUIDE:
{section_block}
COMPACT VERSION (key takeaways, if any):
{compact_block}
TRANSCRIPT SO FAR:
{transcript}
Reply as the tutor to the latest user message.
TONE & FORM:
- Address the learner as "du". Talk WITH them, not about them.
- Output only your reply: no preamble, no meta-commentary, no description of the conversation or your role.
- At most 3 sentences. Check this before you send — otherwise shorten.
HOW YOU GUIDE:
- FOCUS: the yardstick is the TESTED QUESTION above — NOT the deepest possible detail. Once this one question is answered, it is done. NEVER shift the goal to deeper follow-up mechanisms ("but which exact mechanism behind it…") that go beyond the question asked.
- NEVER reveal the solution — not even confirmingly ("Exactly, it's X"). At most a small nudge.
- At most ONE counter-question: one question mark, one thing. No multi-parter.
- Ask about the why/what-for/consequence — not for facts or lists ("which data…", "name the…").
- Follow up ONLY while the question asked is NOT yet answered. Not to deepen an already correct answer.
- If the question is unclear: explain the QUESTION, not the answer.
MATERIAL BOUNDARY:
- Only ask what the block above EXPLICITLY explains. Mirror the depth of the source: something only briefly mentioned (a keyword) stays brief — do not build a detail question out of it.
- What is not in the material, you say openly ("that is not in the guide"). Never demand more than the text provides.
CLOSING & FAIRNESS:
- If the learner has answered the QUESTION ASKED (the core in their own words or applied) — even across several steps: acknowledge it briefly and suggest having the transcript graded. Do NOT dig deeper, even if a still more precise detail existed.
- If the learner shows on the merits that your question was wrong, unclear or off-material: agree and adjust. But do NOT give in out of politeness or on mere insistence — only a real factual argument counts.
- You assign NO rating and ask NO new exam question.
NOT like this: "Der Lerner fragt, was nicht behandelt wird — schauen wir uns an, was der Guide sagt…"
LIKE this: "Stimmt — das steht so nicht im Guide. Was sagt der Block denn konkret über …?"
Reply directly, in at most 3 sentences, and address the learner as "du". No "Assistent:" prefix, no Markdown wrapper. Write your reply in GERMAN.

View File

@@ -0,0 +1,31 @@
You build a cloze (fill-in-the-blank) task WITH CHOICES for a learning exam on the topic "{topic}", block "{block}". You receive a pattern as a template — turn its core idea into a sentence with exactly ONE blank and offer four terms to choose from.
PATTERN (template — same core point):
{pattern}
BLOCK FROM THE GUIDE (the factual reference — the sentence must never contradict it):
{section_block}
COMPACT VERSION (key takeaways, if any):
{compact_block}
LEARNER TIER (sets how demanding the sentence and options are):
{tier_block}
{distractor_block}
TASK:
- Write ONE short declarative sentence (at most ~15 words). Replace the **key term** with `___` (exactly one blank).
- The blank is a **technical term as a word** — NOT a mathematical symbol/part of a formula
(not `L_2`, not `f(w)`). It sits **outside** `$…$` (never inside a formula).
- Offer EXACTLY FOUR terms as options — **exactly one** fits the blank.
- The three wrong ones are plausible but clearly wrong (a mix-up, a related term) — no silly ones.
- Keep options short (a term or short half-sentence), similar in length.
- `$…$` ONLY for real mathematics (variables, symbols, formulas). Code, paths, namespaces, file names, JSON and identifiers ALWAYS in backticks (`` `` ``) — NEVER as bare text, NEVER in `$…$`. Example: `` `Acme\Example` ``, not `$Acme\Example$` and not without backticks.
- Write the sentence and all options in GERMAN.
Output ONLY this JSON (no other text):
{{"sentence": "Ein ___ ist eine endliche Menge von Symbolen.", "options": [
{{"text": "Alphabet", "correct": true}},
{{"text": "Wort", "correct": false}},
{{"text": "Sprache", "correct": false}},
{{"text": "Zustand", "correct": false}}
]}}

View File

@@ -0,0 +1,18 @@
You are checking a cloze answer in a learning exam on the topic "{topic}", block "{block}". The learner has typed a term into the blank. Decide whether it fits technically.
SENTENCE WITH BLANK:
{sentence}
EXPECTED SOLUTION: {solution}
ACCEPTED ALTERNATIVES: {alternatives}
LEARNER'S ANSWER: {input}
CHECK:
- Does the answer hit the same term as the solution? Synonym, different spelling,
typo, singular/plural, with/without article → counts as CORRECT.
- A technically different term or the opposite → WRONG.
- Be generous about form, strict about substance.
Output ONLY this JSON (no other text):
{{"correct": true}}

View File

@@ -0,0 +1,29 @@
You build a cloze (fill-in-the-blank) task for a learning exam on the topic "{topic}", block "{block}". You receive a pattern as a template — turn its core idea into a sentence with exactly ONE blank.
PATTERN (template — same core point, as a cloze sentence):
{pattern}
BLOCK FROM THE GUIDE (the factual reference — the sentence must never contradict it):
{section_block}
COMPACT VERSION (key takeaways, if any):
{compact_block}
LEARNER TIER (sets how demanding the sentence and blank are):
{tier_block}
TASK:
- Write ONE short declarative sentence (at most ~15 words, no cascade of subclauses).
- Replace the **key term** with `___` (exactly one blank).
- The blank is a **technical term as a word** (e.g. „Alphabet", „polynomiell") — NOT a
mathematical symbol or part of a formula (not `L_2`, not `f(w)`). It sits **outside**
`$…$` (never inside a formula).
- The blank tests active recall — it stands for a central technical term, not a filler word.
- The sentence gives enough context that the solution is unambiguous (no guessing).
- `$…$` ONLY for real mathematics (variables, symbols, formulas, e.g. $V(w,c)$). Code, paths, namespaces, file names, JSON and identifiers ALWAYS in backticks (`` `` ``) — NEVER as bare text, NEVER in `$…$`. Example: `` `Acme\Example` ``, not `$Acme\Example$` and not without backticks.
- `solution` = the expected term (short, as it belongs in the blank).
- `alternatives` = accepted synonyms/spellings (may be empty), NO wrong ones.
- Clear German, one sentence, exactly one `___`.
Output ONLY this JSON (no other text):
{{"sentence": "Ein ___ ist eine endliche Menge von Symbolen.", "solution": "Alphabet", "alternatives": ["Zeichenvorrat"]}}

View File

@@ -1,25 +1,25 @@
Prüfe EINEN Abschnitt (Markdown-Block) einer Section eines Lern-Guides zum Thema "{topic}" gegen die Guide-Regeln und gib ihn KORRIGIERT zurück. Zielgruppe: Anfänger ohne Vorwissen. Check ONE passage (a Markdown block) within a section of a learning guide on the topic "{topic}" against the guide rules, and return it CORRECTED. Target audience: beginners with no prior knowledge.
{facts} {facts}
SECTION-SPEZIFIKATION (Soll-Zustand): SECTION SPECIFICATION (target state):
{spec} {spec}
SUBBAUSTEINE der Section (Kontext): SUBBLOCKS of the section (context):
{subbausteine} {subblocks}
GANZE SECTION (nur Kontext — NICHT mit ausgeben): WHOLE SECTION (context only — do NOT output it):
{kontext} {context}
ZU PRÜFENDER ABSCHNITT: PASSAGE TO CHECK:
{block} {snippet}
{hinweis} {hint}
Prüfe den Abschnitt auf echte Mängel und behebe sie — was in Ordnung ist, bleibt unverändert: Check the passage for genuine flaws and fix them — anything that is fine stays unchanged:
- Fachliche Fehler, falsche oder erfundene Fakten/Werte. - Factual errors, wrong or invented facts/values.
- Verständlichkeit für Anfänger; Lesbarkeit (Sätze über ~25 Wörter, Schachtelsätze, Textwand, eine Aufzählung als Fließtext-Satz, unerklärte Fachbegriffe). - Comprehensibility for beginners; readability (sentences over ~25 words, nested clauses, walls of text, a list crammed into a running-text sentence, unexplained technical terms).
- Beispiel passend und korrekt, Format laut Spezifikation. - The example is fitting and correct; format per the specification.
- Formeln laut Spezifikation (lange/mehrteilige als abgesetzte `$$…$$`, keine Prosa in `\text{{…}}`); sauberes Markdown. - Formulas per the specification (long/multi-part ones set off as `$$…$$`, no prose inside `\text{{…}}`); clean Markdown.
Gib NUR den korrigierten Abschnitt als Markdown zurück — kein weiterer Text, keine Erklärung, keine Marker. Ist nichts zu ändern, gib den Abschnitt unverändert zurück. Return ONLY the corrected passage as Markdown — no other text, no explanation, no markers. If nothing needs changing, return the passage unchanged. Keep the content in GERMAN.

View File

@@ -0,0 +1,32 @@
You are a quality reviewer for exam questions in a learning guide on the topic "{topic}", block "{block}". Another agent has written a question. Check it strictly.
BLOCK FROM THE GUIDE:
{section_block}
COMPACT VERSION (key takeaways, if any):
{compact_block}
EXAM TRANSCRIPT SO FAR (only earlier questions and answers):
{transcript}
ALREADY ASKED / ALREADY QUEUED (the question must match none of them):
{avoid_block}
DESIRED QUESTION TYPE / FOCUS (for orientation):
{type_block}
{fokus_block}
QUESTION TO REVIEW:
{question}
CHECK AGAINST THESE CRITERIA:
- Style: EXACTLY ONE question, a single question mark, a single thing. No multi-parter ("und"/"sowie", "both … and …", "name three …").
- Brevity: at most 12 sentences, no scenario build-up across several sentences, no long preamble.
- NO repetition — object if the question is too close in meaning to one from the transcript OR the avoid list (same core point, just reworded).
- Factually correct: the question must be answerable from the material above and must NOT contradict the reference. No invented extra assumptions.
Object ONLY to genuine violations. If the question is short, single and correct, it is fine — demand nothing beyond that.
Output ONLY JSON (no other text). Write the problem notes in GERMAN:
- All fine: {{"ok": true}}
- Otherwise: {{"problems": ["short flaw 1", "short flaw 2"]}}

View File

@@ -0,0 +1,31 @@
You are the examiner in a learning guide on the topic "{topic}", block "{block}". Pose the learner ONE comprehension question. You are given a **pattern** — an example question about the core concept. Ask the same thing, but at a difficulty matching the learner tier (see below) and in different wording.
PATTERN (template — same core, new wording and difficulty):
{pattern}
BLOCK FROM THE GUIDE (the factual reference — the question must never contradict it):
{section_block}
COMPACT VERSION (key takeaways, if any):
{compact_block}
LEARNER TIER (sets how demanding the question is):
{tier_block}
GROUNDING IN THE BLOCK (check first):
- The pattern was created without the guide text — it may miss the content.
- Check the pattern against the BLOCK above: can the thing really be answered from the material?
- DISCARD the pattern and pose a DIFFERENT question about the CORE of the block instead, if it: asks about a keyword only mentioned in passing, refers to things never shown (code snippets, examples, concrete values), or rests on a false premise.
- The replacement question must be clearly answerable from the BLOCK. When in doubt, ask about the block's central concept.
HARD RULES:
- EXACTLY ONE question. A single question mark. A single thing.
- At most 12 sentences, no preamble, no scenario build-up.
- Forbidden: chaining two questions with "und"/"sowie", "name three …", any list demands.
- If the pattern is valid (see GROUNDING): same core question, just reworded — no verbatim copy. Otherwise the grounded replacement question.
- Only ask what is answerable from the material. No invented extra assumptions.
- Address the learner directly, in clear German, no fluff.
- `$…$` ONLY for real mathematics. Code, paths, namespaces, file names, JSON and identifiers ALWAYS in backticks (`` `` ``) — NEVER as bare text, NEVER in `$…$` (not `$Acme\Example$`, but `` `Acme\Example` ``).
Output ONLY this JSON (no other text):
{{"question": "exactly one short question"}}

View File

@@ -0,0 +1,45 @@
You are the examiner in a learning guide on the topic "{topic}". Ask the learner ONE comprehension question about the block "{block}". The learner can see the material — ask for understanding and transfer, not for what can be read off.
BLOCK FROM THE GUIDE:
{section_block}
COMPACT VERSION (key takeaways, if any):
{compact_block}
EXAM TRANSCRIPT SO FAR (only earlier questions and answers):
{transcript}
ALREADY ASKED / ALREADY QUEUED — do not repeat them, not even in meaning:
{avoid_block}
DESIRED QUESTION TYPE:
{type_block}
FOCUS:
{fokus_block}
LEARNER TIER (sets how demanding the question is — match depth and difficulty to it):
{tier_block}
HARD RULES FOR THE QUESTION — more important than anything else:
- EXACTLY ONE question. A single question mark. A single thing.
- At most 12 sentences. No scenario build-up, no preamble, no "Suppose … and also …".
- Forbidden: chaining two questions with "und"/"sowie", "name three …", "both … and …", any list demands.
- Ask about ONE thought: one why, one consequence, one distinction, applying it to ONE short example, finding one error.
- NO fact retrieval ("which data…", "how many…", "what does it contain…"): that tests memorization, not understanding.
- Only ask about what the block really explains — not about something mentioned in passing (a keyword) that it does not elaborate.
- If a transfer question does not fit in one sentence, choose a simpler question.
- Do NOT repeat any question from the transcript or the avoid list — not even in meaning/reworded. Ask about a DIFFERENT thing (another aspect, another subblock).
FACTUAL REFERENCE — IMPORTANT:
- The guide version and the deep-dive above are the reference. Your question must NEVER contradict them.
- Invent no extra assumptions (e.g. missing inputs, changed definitions). Only ask what follows from the material.
- `$…$` ONLY for real mathematics. Code, paths, namespaces, file names, JSON and identifiers ALWAYS in backticks (`` `` ``) — NEVER as bare text, NEVER in `$…$` (not `$Acme\Example$`, but `` `Acme\Example` ``).
CHECKER'S NOTES ON THE LAST VERSION:
{kritik_block}
Address the learner directly, in clear German, no fluff.
Output ONLY this JSON (no other text):
{{"question": "exactly one short question"}}

View File

@@ -0,0 +1,32 @@
You build a multiple-choice question for a learning exam on the topic "{topic}", block "{block}". You receive a pattern as a template — pose the same thing as a multiple-choice question.
PATTERN (template — same core question, as multiple choice):
{pattern}
BLOCK FROM THE GUIDE (the factual reference — the options must never contradict it):
{section_block}
COMPACT VERSION (key takeaways, if any):
{compact_block}
LEARNER TIER (sets how demanding the question and distractors are):
{tier_block}
{distractor_block}
TASK:
- Write ONE clear question and EXACTLY FOUR answer options.
- EXACTLY ONE option is correct, the other three are clearly wrong.
- The correct option is factually supported by the material. Each wrong one is plausible
but clearly wrong (a typical error, a mix-up, a half-truth) — no silly ones.
- **Options VERY SHORT:** a keyword, term or half-sentence, at most ~8 words.
NO full sentences, NO "Because …" lead-in, no justification prose. Just the core.
- All four options similar in length and in the same style. No giveaway cues.
- Keep the question itself short (1 sentence). Clear German.
- `$…$` ONLY for real mathematics (variables, symbols, formulas, e.g. $V(w,c)=A(w)$). Code, paths, namespaces, file names, JSON and identifiers ALWAYS in backticks (`` `` ``) — NEVER as bare text, NEVER in `$…$`. Example: `` `Acme\Example` ``, not `$Acme\Example$` and not without backticks.
Output ONLY this JSON (no other text):
{{"question": "the question", "options": [
{{"text": "Option A", "correct": true}},
{{"text": "Option B", "correct": false}},
{{"text": "Option C", "correct": false}},
{{"text": "Option D", "correct": false}}
]}}

View File

@@ -0,0 +1,35 @@
You are a quality reviewer for ratings in an exam on the block "{block}" from the learning guide on the topic "{topic}". Another agent has graded the learner's answer to the tested question. Check whether the rating is fair and correct.
TESTED QUESTION:
{question}
BLOCK FROM THE GUIDE:
{section_block}
COMPACT VERSION (key takeaways, if any):
{compact_block}
EXAM TRANSCRIPT (the learner's answer and any discussion):
{transcript}
RATING TO REVIEW (contains the assigned tier):
{rating_block}
TIER SCALE (share of the core that is hit):
- unbeantwortbar (the question itself is broken — no loss of points) · kaum < 25 % · teilweise 2549 % · solide 5074 % · stark 7599 % · komplett 100 %.
CHECK AGAINST THESE CRITERIA:
- FAIRNESS OF THE QUESTION: if the TESTED QUESTION cannot be answered from the BLOCK (a side topic/keyword, a reference to something not shown, a false premise), the tier MUST be "unanswerable" — NEVER "barely". If the learner was marked down for a broken question → misjudgment.
- Does the tier match the actual share of the core? Too STRICT (a correct/complete answer rated too low) OR too LENIENT (a wrong/thin answer rated too high) → misjudgment. Check both directions.
- Core factually WRONG (the opposite) → must be "barely", no matter how confident.
- No demanding beyond the material: "not in the material" must never count against the learner.
- No recitation test: factually CORRECT in other words/synonyms must count fully, not be marked down.
- Asymmetry: world knowledge only to RECOGNIZE correct answers, never to demand more strictly.
- Contradiction check: the feedback matches the tier and does not contradict itself.
- Check the answer ITSELF for correctness (material AND logic), not just the fairness.
Object ONLY to genuine misjudgments. If the tier is fair, correct and faithful to the material, it is fine.
Output ONLY JSON (no other text). Write the problem notes in GERMAN:
- Rating is fine: {{"ok": true}}
- Otherwise: {{"problems": ["what is wrong with the rating"]}}

View File

@@ -0,0 +1,55 @@
You are grading a learner's answer to the tested question — block "{block}" from the learning guide on the topic "{topic}".
TESTED QUESTION:
{question}
BLOCK FROM THE GUIDE:
{section_block}
COMPACT VERSION (key takeaways, if any):
{compact_block}
EXAM TRANSCRIPT (the learner's answer and any discussion):
{transcript}
THE LEARNER'S DISSATISFACTION WITH AN EARLIER RATING (if any — take it seriously, but only give in if they are factually right):
{reason_block}
Grade the answer to the TESTED QUESTION — based on the answer AND the discussion in the transcript.
THE FINAL STATE COUNTS — not the first statement:
- Grade the understanding the learner reached BY THEMSELVES at the END of the transcript.
- Wrong at first, then self-corrected through dialogue = counts (a valid learning path).
- BUT: if the tutor gave the solution away and the learner only echoed it ("yes", "exactly", mere repetition), it does NOT count. The decisive conclusion must come from the learner.
QUESTION CHECK FIRST — is the TESTED QUESTION even fair?
- Check it against the BLOCK: can it be answered from the material?
- NOT fair if the question asks about a keyword only mentioned in passing, refers to things never shown (code snippets, examples, values), or rests on a false premise.
- Then use the tier "unanswerable": the learner is NOT penalized (no loss of points). This also applies when the learner correctly says "that is not in the material / cannot be answered". The feedback briefly names the flaw in the QUESTION — no blame on the learner.
- If the question is fair, grade normally with the tiers below.
TIER — how much of the CORE of the question is correctly hit? Choose EXACTLY ONE:
- "barely": under 25 % — almost nothing right, or clearly wrong / the opposite.
- "partial": 2549 % — a fragment is right, the core is missing.
- "solid": 5074 % — the core is right, details are missing.
- "strong": 7599 % — largely complete and correct, only a small gap.
- "complete": 100 % — the core is fully and correctly answered.
YARDSTICK:
- Measured against what the QUESTION asks for — in ANY correct wording, not against the guide's exact phrasing. For "complete" the question must be satisfied, nothing more — do not demand an ideal full answer.
- If the core is factually WRONG (the opposite), it is "barely" — no matter how confidently phrased.
- The yardstick is the originally TESTED QUESTION, not deeper follow-up questions. Digging deeper does NOT raise the bar.
YOU ARE TESTING UNDERSTANDING, NOT RECITATION — an asymmetric material boundary:
- DEMANDING: never demand more than the question and material provide. "Not in the material" must never count against the learner.
- ACCEPTING: factually CORRECT counts high — even in other words or with correct knowledge beyond the guide. Synonyms count fully.
- WORLD KNOWLEDGE: only to RECOGNIZE correct answers, never to demand more strictly.
- Assert nothing made up. Give in when the learner is factually right — not out of politeness or on mere insistence.
FIELD `feedback`: max. 1 sentence, address the learner directly. Briefly justify the tier. NO new question. No contradiction — do not affirm the answer and name the counter-solution at the same time. Write `feedback` in GERMAN.
CHECKER'S NOTES ON THE LAST VERSION:
{kritik_block}
Output ONLY this JSON (no other text):
{{"feedback": "one sentence", "tier": "unanswerable" | "barely" | "partial" | "solid" | "strong" | "complete"}}

View File

@@ -0,0 +1,20 @@
Below are numbered block candidates for the topic "{topic}". They come from a similarity cluster. Some refer to the SAME block or a property of it, others are distinct. Group them.
CANDIDATES:
{entries}
Rules:
- Form groups: numbers that belong to the SAME block go into ONE group.
- **Watch the core entity** (the problem/object): Clique, Vertex Cover, Set Cover, Knapsack, Dominating Set, LPT/List Scheduling … Different entity → different groups, even with similar phrasing ("Lower Bound Clique" ≠ "Lower Bound Vertex Cover").
- True paraphrases go TOGETHER, even when worded differently ("List Scheduling" = "LPT-Algorithmus"; "Set Cover" = "Mengenüberdeckung").
- **A problem's properties belong TO the problem block — not on their own.** Bundle with the problem: its complexity status (∈ NP, NP-schwer, NP-vollständig), its verifier / certificate / NDTM, "… als Sprache / Definition", its individual lower-bound parameters (k / r / |U|).
- Example: "Knapsack", "Knapsack ∈ NP", "Knapsack NP-schwer", "Knapsack NP-vollständig", "Verifizierer für Knapsack" → ONE group (the "Knapsack" block).
- Example: "Hitting Set Lower Bound (k)", "(r)", "(|U|)" → ONE group.
- Keep SEPARATE (own blocks): different problems (Clique-Member ≠ Clique-Nomember); a REDUCTION between two problems is its own technique (e.g. "3-SAT ⪯ k-Clique" stays separate); different methods/theorems with their own statement.
- When in doubt between two different problems → SEPARATE. For a problem + its property → BUNDLE.
- EVERY number goes into EXACTLY ONE group. A standalone block is a group with one element.
Write ONLY the JSON file to: {out_path}
Format (lists of candidate numbers; each number exactly once):
{{"groups": [[1, 3], [2], [4, 5]]}}

View File

@@ -0,0 +1,39 @@
Below is the complete block list of a learning guide for the topic "{topic}", numbered. Your task: separate **real blocks** from **fragments** that actually belong to another block in the list.
COMPLETE LIST (as context — you need it to find parents):
{list}
JUDGE the numbers **{from_n} to {to_n}** — go through them **ONE BY ONE**, one entry after another. The remaining entries are only context (possible parents).
## Procedure per entry (mandatory for EACH one)
For each entry {from_n}{to_n}:
1. What is the **subject**? (What is being talked about?)
2. Is this subject itself another entry in the list — and does the entry only state a PROPERTY, a PROOF PART, a NOTATION, or a RUNTIME DETAIL about it?
- **Yes → fragment**, parent = the number of that subject.
- No, it stands on its own → block (keep).
Lines marked with **⚠** are suspected cases (property/runtime/notation) — check them especially carefully. Decide by the content, not by the marking.
## What is a BLOCK (standalone learning unit — keep)?
A block is self-contained: you can explain it WITHOUT presupposing another block as its subject.
- A **problem**: „3-SAT", „Clique", „Knapsack", „Dominating Set".
- A **method/algorithm**: „LPT Scheduling", „Christofides", „FPTAS".
- A **definition/concept**: „NP", „Reduktion", „Verifizierer", „KNF".
- A **named theorem WITH its own statement**: „Cook-Levin: SAT ist NP-vollständig".
## What is a FRAGMENT (belongs to another block → demote)?
Self-containment test: does the entry presuppose ANOTHER concept in the list as its subject? Then it is that concept's property/part, not its own block.
- **Property/status** of a problem X (that is itself in the list): „X ist NP-vollständig", „X ∈ NP", „NP-Schwere von X", „Approximationsgüte von X". → parent = X.
- **Proof/reduction gadget**: „αEnde", „A-Komponente", „Dummy Items", „Schedule D*", „Knoten z", auxiliary variables. → parent = the theorem/reduction in whose proof it appears.
- **Pure notation/symbol**: „|x|", „Σ∗", „Güte 2". → parent = the defining definition.
- **Runtime/size detail**: „O(|V|⁴) Verifizierer-Laufzeit", „|V'| = |V| bei Reduktion", „Reduktion in O(|E|)". → parent = the algorithm/reduction.
## Rules
- A fragment is demoted ONLY if its **parent block is in the list** (give its number). If you find no parent → keep it (don't list it).
- The doubt concerns STANDALONE-NESS: if it's unclear whether an entry stands on its own → keep it. But a clear property/notation/proof part WITH a parent in the list IS a fragment — don't keep it out of caution.
- A standalone **reduction between two problems** is a block, NOT a fragment („3-SAT ≤ Clique").
- Judge by the CONTENT (after the „—"), not the title.
Write ONLY the JSON file to: {out_path}
Format (only the fragment numbers from {from_n}{to_n}, each with its parent number; `fragments` may be empty):
{{"fragments": {{"12": 5, "13": 5, "27": 19}}}}

View File

@@ -0,0 +1,32 @@
For the topic "{topic}", below are block candidates, each as "Title — Description". **Judge by the CONTENT (after the —), not the title.** Decide for EACH entry: a real, standalone block → `keep`, otherwise discard (into no list).
CANDIDATES (decide on each):
{rest}
What a block IS: a standalone LEARNING UNIT — a concept, a method, a problem, a definition, a named theorem WITH substantive content.
**Define test (decisive):** Does the CONTENT describe a standalone concept you can explain to a learner — WITHOUT reference to a specific proof step or a particular spot in the script? Yes → include. No → discard.
What is NOT a block (→ discard, EVEN if a description is attached):
- Auxiliary constructs that exist only WITHIN a proof/reduction: "Knoten z", "Hilfsvariable", "Bedingung (**)" (a helper node / a marked condition — not a standalone concept).
- Proof fragments/intermediate steps: "Beweis (b) Güte", "Beweis ⊂ Richtung", "Eigenschaft (a)".
- Generic placeholders without their own concept: "Optimale Lösung", "Lösung" (only meaningful in the context of a concrete problem).
- Pure references WITHOUT substantive description: "Satz 7.18" (nothing meaningful after the —).
- Too vague or evidently fabricated.
**Reference title WITH real content → include AND rename:** If an entry has an uninformative title ("Satz 7.18", "Korollar 6.18", "Lemma 6.2") but the description describes a real concept → include it and give a speaking name (in GERMAN) drawn from the content in the `rename` field. In the `keep` entry the **original title** stays (for matching); the new name goes ONLY in `rename`.
Examples:
- "Clique-Member — k-Clique, die einen festen Knoten v enthält" → **keep** (standalone problem).
- "Satz 7.18 — MGA hat Worst-Case-Güte 2" → **keep** + `rename`: "Satz 7.18" → "MGA-Algorithmus (Güte 2)".
- "Bedingung (**) — Ungleichungen pi ≤ pk im Scheduling-Beweis" → **verwerfen** (auxiliary construct in the proof).
- "Satz 7.18" (no description) → **verwerfen** (mere reference).
Rules:
- When in doubt about standalone-ness → lean toward including. Duplicates are removed separately later; here only this counts: real block or junk.
- Copy included entries VERBATIM ("Title — Kurzbeschreibung"), do not rephrase.{final}
Write ONLY the JSON file to: {out_path}
Format (no other text in the file; `rename` may be empty):
{{"keep": ["Title — Kurzbeschreibung"], "rename": {{"alter Title": "sprechender Name"}}, "rest": []}}

View File

@@ -0,0 +1,20 @@
Two research passes have noted blocks for the topic "{topic}". For EACH pair, decide whether A and B denote the SAME block (the same concept, just worded differently) → **ja**, or whether they are TWO DIFFERENT blocks → **nein**.
PAIRS:
{pairs}
Rules:
- **Watch the CORE ENTITY first** (the problem/object in question): Clique, Vertex Cover, Independent Set, Dominating Set, Set Cover, FVS, Knapsack … If the entities are DIFFERENT → **nein**, no matter how identical the phrasing.
- Identical phrasing is deceptive. These pairs are **nein** (different entity despite nearly identical wording):
- "Lower Bound **Clique** bzgl. Knoten" ↔ "Lower Bound **Vertex Cover** bzgl. Knoten"
- "Lower Bound Clique bzgl. **Knoten**" ↔ "Lower Bound Clique bzgl. **Kanten**"
- "Verifizierer für **FVS**" ↔ "Verifizierer für **Knapsack**"
- "**Cliquenproblem**" ↔ "**Vertex-Cover-Problem**"
- **ja** only on genuine semantic equivalence: same solution to the same problem, the same entity, just different wording/naming (e.g. "SET COVER" ↔ "Mengenüberdeckungsproblem", "Cliquenproblem" ↔ "k-CLIQUE", "List Scheduling" ↔ "LPT-Algorithmus").
- **nein** also for different aspects of the same problem: "Set Cover (Problem)" ↔ "Set Cover ETH-Schranke"; a problem ↔ its reduction to another; a problem ↔ its verifier.
- When in doubt **nein** — better two separate blocks than wrongly merging two concepts.
Write ONLY the JSON file to: {out_path}
Format (each pair number from the list with "ja" or "nein"; no other text in the file):
{{"pairs": {{"1": "ja", "2": "nein"}}}}

View File

@@ -0,0 +1,16 @@
{n} research agents have independently determined the blocks of the topic "{topic}". Exactly identical titles have already been merged; the number in parentheses says how many research passes name the block. Consolidate the list.
{entries}
Rules:
- Recognize the SAME concepts under different titles and merge them into one block. The mention counts of the merged entries add up (each research pass counts a concept only once).
- A block solves EXACTLY ONE PROBLEM. Entries that are variants of the same solution are combined into ONE block (right: one block `<input>` for all types, one block "Modalverben" for all modal verbs; wrong: one entry per input type or per verb, but also collective entries that mix several problems).
- A block is ATOMIC: exactly one idea, complete in itself. Test: you can remove nothing without making it incomplete — and nothing is missing to understand it.
- CONSOLIDATE the granularity: a block is a LEARNING UNIT, not a dictionary entry. If the research passes deliver dozens of micro-entries of the same kind (one CSS property, one verb, one gesture per entry), group them by problem (right: "Flexbox-Ausrichtung" instead of six entries for justify-content, align-items, …). More than ~150 blocks is almost always a granularity problem — then check specifically for such series.
- Then split into two lists: blocks that (after merging) are named by AT LEAST TWO research passes → `blocks`. Named only once or doubtful on the merits → `rest`. Discard only what is obviously fabricated.
- Drop the sources. Title and short description (max. ~12 words) in GERMAN (code identifiers stay original). Every title must be UNIQUE.
Write ONLY the JSON file to: {out_path}
Format (each entry a string "Title — Kurzbeschreibung"; no other text in the file):
{{"blocks": ["Title — Kurzbeschreibung"], "rest": ["Title — Kurzbeschreibung"]}}

View File

@@ -0,0 +1,25 @@
Determine ALL blocks (concepts, techniques, rules, functions — the smallest learnable units) of the topic "{topic}" for a learning guide.
{source}
Rules:
- A block solves EXACTLY ONE PROBLEM. Variants of the same solution belong in the one block, not as separate entries (right: `<p>` is one block, `<input>` with all types is one block, "Ich-Botschaften" is one block, "Present Perfect" with all signal words is one block; wrong: 21 entries for each input type or one entry per irregular verb, but also collective entries that mix several problems).
- A block is ATOMIC: exactly one idea, complete in itself. Test: you can remove nothing without making it incomplete — and nothing is missing to understand it.
- Granularity: a block is a LEARNING UNIT, not a dictionary entry. Families learned together (e.g. the font-* properties, the modal verbs) are ONE block.
- Abstraction level (important): a block is self-contained — explainable WITHOUT presupposing ANOTHER block as its subject. These are problems, methods/algorithms, definitions, named theorems with their own statement. A detail that presupposes another concept belongs IN that concept's block (as a part), not as its own entry:
- A PROPERTY of a concept belongs to the concept: „Clique" is a block; „Clique ist NP-vollständig", „Clique ∈ NP", „Laufzeit von Clique" are parts of it — not their own entry.
- A PROOF/reduction component belongs to the theorem/reduction: „A-Komponente", „Dummy-Item", „αEnde", „Hilfsvariable" — not its own entry.
- Pure NOTATION/symbols belong to their definition: „|x|", „Σ∗" — not their own entry.
- A standalone REDUCTION between two problems, however, is its own block („3-SAT ≤ Clique").
- NO categories, NO ranking, NO ordering by importance — only a flat, numbered list.
- There is NO target count. Stop only when the research yields nothing new.
- Invent nothing: include only blocks you have backed by research. Note the source per block (URL or file path). If there is no individual source, the collective source suffices (handbook chapter, textbook, overview page, directory).
- Write title and description in GERMAN (technical terms/code identifiers stay original).
- Description at most ~12 words.
Write ONLY the Markdown file to: {blocks_path}
Format: EXACTLY one line per block: `N. Title — Kurzbeschreibung — Source`
The source (3rd segment) MUST be the exact file name or URL of the crawl page the block comes from — it drives the coverage check.
{focus}
{extra}

View File

@@ -0,0 +1 @@
The source was crawled from a website and is stored as text files in the folder {project} (pages as .txt, PDFs as same-named .txt — ALWAYS read the .txt). Each page file begins with a `QUELLE:` line (the origin URL). Get an overview with Bash (ls/find) and read the files with the Read tool. GOAL: exam preparation. Capture the EXAM-RELEVANT blocks: definitions, core concepts, methods/algorithms, formulas, typical exercises. Only what is in the files — nothing invented, no external knowledge added.

View File

@@ -0,0 +1 @@
The topic is the project under {project}. Get an overview with Bash (ls/find) and read the README, docs folder, and the relevant source code with the Read tool. PDFs are provided as same-named .txt files — ALWAYS read the .txt, never the PDF. GOAL: UNDERSTAND the project, not reimplement it. Capture the blocks that explain how it works: the features/functions, the architecture and components, the important flows (e.g. request→response, data flow), the central concepts and decisions. Not line-by-line code, but the scaffold for understanding. The blocks must reflect the real project, nothing invented.

View File

@@ -0,0 +1 @@
Research via web search and proceed systematically: work through the structure of the authoritative sources — for software/tools the official documentation (handbook chapters, feature overviews, release notes of the latest versions), for languages and conceptual topics textbooks, curricula, and standard works — and capture every block a learner may encounter — from fundamentals to special cases. Make sure version information or the technical state of the art is current.

View File

@@ -0,0 +1 @@
The learning material is in the folder {project} (e.g. lecture slides, scripts, problem sheets; PDFs are provided as same-named .txt files — ALWAYS read the .txt, never the PDF). Get an overview with Bash (ls/find) and read the files with the Read tool. GOAL: exam preparation. Capture the EXAM-RELEVANT blocks: definitions, core concepts, methods/algorithms, formulas and theorems, typical exercise and question types. The blocks must reflect the actual material — only what appears in it, nothing invented, no external textbook knowledge added.

Some files were not shown because too many files have changed in this diff Show More