Compare commits
35 Commits
v1.0
...
b5398f73d2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5398f73d2 | ||
|
|
fa718b7d6c | ||
|
|
a2a5da25df | ||
|
|
3669fd8d0a | ||
|
|
c794fcaccf | ||
|
|
3e3559aa8f | ||
|
|
3382a426cf | ||
|
|
12f97031e8 | ||
|
|
87fc9b209a | ||
|
|
753b95b689 | ||
|
|
bf7ebe045b | ||
|
|
07b6736ced | ||
|
|
5b71fcbcd7 | ||
|
|
8c69c44b37 | ||
|
|
2266606ca2 | ||
|
|
3f15df7e9b | ||
|
|
8a43839f18 | ||
|
|
f018ca5048 | ||
|
|
2a2026c9ae | ||
|
|
e0a5f4956f | ||
|
|
ccbe7765d4 | ||
|
|
e0f1b59707 | ||
|
|
c802c7fd02 | ||
|
|
c1310a31cd | ||
|
|
2ba76e40dd | ||
|
|
d223cc6a07 | ||
|
|
55c5063da7 | ||
|
|
46ae62a335 | ||
|
|
fed25795cc | ||
|
|
324d2e73b4 | ||
|
|
caa9314de5 | ||
|
|
e985f07696 | ||
|
|
d389584b58 | ||
|
|
3b1e84e17d | ||
|
|
12125d0f2d |
@@ -5,3 +5,9 @@ CLAUDE_CODE_OAUTH_TOKEN=
|
||||
|
||||
# MiniMax-Provider: API-Key aus der MiniMax-Console (Coding-Plan).
|
||||
MINIMAX_API_KEY=
|
||||
|
||||
# Agent-Parallelität (optional). Zwei verschachtelte Limits, Default je 10 = bisheriges Verhalten.
|
||||
# Global gilt über ALLE Themen, der Thema-Wert je Thema. Lokal das globale Limit hochsetzen,
|
||||
# um mehrere Themen parallel mit je 10 Agenten zu fahren (z.B. global 50, Thema 10).
|
||||
# MAX_CONCURRENT_AGENTS=10
|
||||
# MAX_CONCURRENT_AGENTS_PER_TOPIC=10
|
||||
|
||||
19
Makefile
19
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: install dev prod stop logs remove auth sync sync-projects sync-all projects searxng ollama
|
||||
.PHONY: install dev prod stop logs remove auth sync sync-projects sync-all sync-all-reverse projects searxng ollama
|
||||
|
||||
COMPOSE = docker compose
|
||||
|
||||
@@ -12,7 +12,7 @@ auth:
|
||||
@echo "Verzeichnisse angelegt und auf uid 1000 chowned."
|
||||
|
||||
install:
|
||||
pip install --break-system-packages fastapi uvicorn[standard] aiosqlite uv playwright transformers
|
||||
pip install --break-system-packages fastapi uvicorn[standard] aiosqlite uv playwright transformers trafilatura
|
||||
pip install --break-system-packages torch --index-url https://download.pytorch.org/whl/cpu
|
||||
python3 -m playwright install chromium
|
||||
@echo "Falls Chromium OS-Libs fehlen: 'sudo python3 -m playwright install-deps chromium' einmalig ausführen."
|
||||
@@ -79,4 +79,19 @@ sync-projects:
|
||||
sync-all: sync sync-projects
|
||||
@echo "Voller Sync (Themen + Projekte) abgeschlossen."
|
||||
|
||||
# Gegenstück zu sync-all: lokalen Stand auf den Server schieben (Local → Remote).
|
||||
# Stoppt den Remote-Server (DB-Sicherheit), pusht DB + Themen + Projekte, startet ihn neu.
|
||||
# --delete = exakter Spiegel: Remote-Dateien ohne lokales Pendant werden gelöscht.
|
||||
sync-all-reverse: stop
|
||||
@echo "ACHTUNG: überschreibt den Remote-Stand (DB, themen/, projects/) mit dem lokalen."
|
||||
@[ -f storage/creator.db ] || { echo "Keine lokale DB — abgebrochen."; exit 1; }
|
||||
ssh root@178.104.67.87 'cd /var/www/creator && docker compose down'
|
||||
ssh root@178.104.67.87 'rm -f /var/www/creator/storage/creator.db-shm /var/www/creator/storage/creator.db-wal'
|
||||
rsync -avz --progress storage/creator.db root@178.104.67.87:/var/www/creator/storage/
|
||||
@[ -f storage/creator.db-wal ] && rsync -avz --progress storage/creator.db-wal root@178.104.67.87:/var/www/creator/storage/ || true
|
||||
rsync -avz --progress --delete storage/themen/ root@178.104.67.87:/var/www/creator/storage/themen/
|
||||
rsync -avz --progress --delete projects/ root@178.104.67.87:/var/www/creator/projects/
|
||||
ssh root@178.104.67.87 'cd /var/www/creator && docker compose up -d --build'
|
||||
@echo "Reverse-Sync abgeschlossen — Remote läuft wieder."
|
||||
|
||||
projects: sync-projects
|
||||
|
||||
60
README.md
60
README.md
@@ -1,18 +1,48 @@
|
||||
Bausteine finden
|
||||
- 1+ Agenten suchen Bausteine zum Thema
|
||||
- Baustein bekommt die Einstufung kern, wichtig und rest
|
||||
# Creator — Features
|
||||
|
||||
MiniGuide generieren
|
||||
- 1 Agent erstellt den Guide
|
||||
- Nur Themen (kern) verwenden
|
||||
- MiniGuide-Format bestimmt den Stil
|
||||
## Quellen
|
||||
- Freies Thema: die KI recherchiert den Stoff selbst im Web.
|
||||
- Uni-Skript: lädt ein Skript und liest es abschnittsweise.
|
||||
- Projektordner: nutzt eigene Dateien als Quelle.
|
||||
- Web-Link: crawlt eine Seite und filtert relevante Inhalte.
|
||||
|
||||
Guide generieren
|
||||
- 1 Agent erstellt den Guide
|
||||
- Nur Themen (kern+wichtig) verwenden
|
||||
- Guide-Format bestimmt den Stil
|
||||
## Inhalt erstellen
|
||||
- Zerlegt den Stoff in einzelne Lern-Konzepte (Bausteine).
|
||||
- Führt doppelt genannte Konzepte automatisch zusammen.
|
||||
- Entfernt Bruchstücke, die keine eigenen Themen sind.
|
||||
- Gliedert jeden Baustein in Teilpunkte.
|
||||
- Belegt jeden Teilpunkt mit Fakten aus der Quelle.
|
||||
- Stuft Teilpunkte nach Schwierigkeit ein (Anfänger/Fortgeschritten/Experte).
|
||||
- Trennt Randthemen vom Kern.
|
||||
- Ordnet Bausteine in Kapitel mit Voraussetzungs-Reihenfolge.
|
||||
- Schreibt daraus einen lesbaren Guide.
|
||||
- Drei Guide-Varianten: fokussiert, komplett, nur Randthemen.
|
||||
- Erzeugt Karteikarten und durchgerechnete Beispiele.
|
||||
|
||||
Flow
|
||||
- Bausteine finden
|
||||
- HTML erstellen
|
||||
- Code prüfen
|
||||
## Guide-Ansicht
|
||||
- Zeigt Mathe-Formeln korrekt gerendert.
|
||||
- Umschalten zwischen kurzer und ausführlicher Darstellung.
|
||||
- Filtert den Guide nach Lernstufe.
|
||||
- Prüft schwer lesbare Sätze und vereinfacht sie.
|
||||
- Einzelne Abschnitte auf Knopfdruck neu prüfen.
|
||||
|
||||
## Lernen & Prüfen
|
||||
- Übungsfragen je Baustein generieren.
|
||||
- Vier Frageformen: Quiz, Lückentext, Freitext, Erklären.
|
||||
- KI bewertet Freitext-Antworten mit Begründung.
|
||||
- Punkte-System mit Stufen und Streak je Baustein.
|
||||
- Themenweite Gesamt-Prüfung über alle Bausteine.
|
||||
- Chat-Nachfrage zu jedem Baustein.
|
||||
- Fokus-Modus: Guide und Prüfung nebeneinander.
|
||||
|
||||
## Eigene Elemente
|
||||
- Eigene Lern-Notizen per Stichwort erstellen lassen.
|
||||
- Notizen im Chat anpassen; KI prüft Lücken und Stil.
|
||||
|
||||
## Steuerung
|
||||
- KI-Anbieter wählen: Claude, MiniMax, lokal.
|
||||
- Generierung ab jedem Schritt neu starten.
|
||||
- Ab jedem Schritt löschen, ohne neu zu generieren.
|
||||
- Laufende Generierung abbrechen oder fortsetzen.
|
||||
- Live-Fortschritt sehen.
|
||||
- Dark Mode.
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Provider-Schicht: führt Agent-Aufrufe über die Claude-CLI oder OpenCode (MiniMax) aus.
|
||||
"""Provider layer: runs agent calls via the Claude CLI or OpenCode (MiniMax).
|
||||
|
||||
Beide Runner sind unabhängig. Fehlt ein Binary/Key, schlägt nur der
|
||||
jeweilige Provider fehl — der andere läuft unverändert weiter.
|
||||
Both runners are independent. If a binary/key is missing, only the
|
||||
respective provider fails — the other keeps running unchanged.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import heapq
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -13,17 +14,30 @@ import signal
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from config import PROVIDERS, DEFAULT_PROVIDER, MAX_CONCURRENT_AGENTS, MAX_CONCURRENT_INTERACTIVE
|
||||
from config import (PROVIDERS, DEFAULT_PROVIDER, MAX_CONCURRENT_AGENTS,
|
||||
MAX_CONCURRENT_AGENTS_PER_TOPIC, MAX_CONCURRENT_INTERACTIVE)
|
||||
|
||||
log = logging.getLogger("creator.agents")
|
||||
|
||||
_active_processes: dict[str, asyncio.subprocess.Process] = {}
|
||||
_active_started: dict[str, float] = {} # agent_key → wall-clock start (for the live runtime display)
|
||||
|
||||
# Abgebrochene Scopes (Schlüssel-Präfixe, symmetrisch zu kill_process). Ein Agent, dessen
|
||||
# Key mit einem dieser Präfixe beginnt, bricht VOR dem Spawn ab — so werden auch in der
|
||||
# Semaphore-Schlange WARTENDE Agenten beim Abbruch sofort gestoppt, statt noch zu starten.
|
||||
|
||||
def active_agents(scope_prefix: str | None = None) -> list[dict]:
|
||||
"""Currently running agents and how long they've been running. Filter by key prefix
|
||||
(e.g. f"blocks-{topic}-") for one topic. → [{key, runtime}] sorted longest-first."""
|
||||
now = time.time()
|
||||
out = [{"key": k, "runtime": round(now - t, 1)}
|
||||
for k, t in list(_active_started.items())
|
||||
if k in _active_processes and (not scope_prefix or k.startswith(scope_prefix))]
|
||||
return sorted(out, key=lambda a: -a["runtime"])
|
||||
|
||||
# Cancelled scopes (key prefixes, symmetric to kill_process). An agent whose
|
||||
# key starts with one of these prefixes aborts BEFORE the spawn — so agents WAITING
|
||||
# in the semaphore queue are also stopped immediately on abort instead of still starting.
|
||||
_cancelled_prefixes: set[str] = set()
|
||||
|
||||
|
||||
@@ -38,15 +52,78 @@ def clear_scope(prefix: str) -> None:
|
||||
def _scope_cancelled(agent_key: str) -> bool:
|
||||
return any(agent_key.startswith(p) for p in _cancelled_prefixes)
|
||||
|
||||
# Deckelt die realen CLI-Prozesse — unabhängig von der Pipeline-Semaphore in
|
||||
# generator.py. Acquire passiert VOR dem Spawn, damit Wartezeit in der Queue
|
||||
# nicht gegen den Agent-Timeout zählt.
|
||||
_batch_sem = asyncio.Semaphore(MAX_CONCURRENT_AGENTS)
|
||||
_interactive_sem = asyncio.Semaphore(MAX_CONCURRENT_INTERACTIVE)
|
||||
# Caps the real CLI processes — independent of the pipeline semaphore in
|
||||
# generator.py. The acquire happens BEFORE the spawn so that queue wait time
|
||||
# does not count against the agent timeout.
|
||||
class _PrioritySemaphore:
|
||||
"""asyncio.Semaphore variant: when slots are scarce, the LOWEST priority number is served first
|
||||
(FIFO within the same priority). Lets earlier pipeline columns grab agents before later ones."""
|
||||
def __init__(self, value: int):
|
||||
self._value = value
|
||||
self._waiters: list = [] # heap of [priority, seq, future]
|
||||
self._seq = 0
|
||||
|
||||
# OpenCode-Starts serialisieren: gleichzeitig startende Prozesse kollidieren an
|
||||
# der internen Session-DB ("database is locked", Exit nach <1s). Der kurze
|
||||
# Versatz entzerrt die Starts; danach laufen die Prozesse normal parallel.
|
||||
async def acquire(self, priority: int = 100):
|
||||
if self._value > 0:
|
||||
self._value -= 1
|
||||
return
|
||||
fut = asyncio.get_event_loop().create_future()
|
||||
entry = [priority, self._seq, fut]
|
||||
self._seq += 1
|
||||
heapq.heappush(self._waiters, entry)
|
||||
try:
|
||||
await fut # release() hands us the slot directly (no value change)
|
||||
except BaseException:
|
||||
entry[2] = None # tombstone so release() skips this dead waiter
|
||||
if fut.done() and not fut.cancelled():
|
||||
self.release() # granted just before we were cancelled → pass it on
|
||||
raise
|
||||
|
||||
def release(self):
|
||||
while self._waiters:
|
||||
entry = heapq.heappop(self._waiters)
|
||||
if entry[2] is not None and not entry[2].done():
|
||||
entry[2].set_result(None) # hand the slot straight to the highest-priority waiter
|
||||
return
|
||||
self._value += 1
|
||||
|
||||
|
||||
_batch_sem = _PrioritySemaphore(MAX_CONCURRENT_AGENTS)
|
||||
_interactive_sem = asyncio.Semaphore(MAX_CONCURRENT_INTERACTIVE)
|
||||
# Per-topic caps (lazily created): each topic gets its own priority semaphore of size
|
||||
# MAX_CONCURRENT_AGENTS_PER_TOPIC, nested INSIDE the global _batch_sem. Priority-based too, so the
|
||||
# per-topic queue can't undo the global priority when one topic is the only load.
|
||||
_topic_sems: dict[str, _PrioritySemaphore] = {}
|
||||
|
||||
# Earlier kanban columns get the scarce global slot first (smaller = higher priority).
|
||||
_STAGE_PRIORITY = ("research", "verify", "naming", "small", "dep")
|
||||
|
||||
|
||||
def _agent_priority(key: str) -> int:
|
||||
for i, tag in enumerate(_STAGE_PRIORITY):
|
||||
if f"-{tag}-" in key or key.endswith(f"-{tag}"):
|
||||
return i
|
||||
return len(_STAGE_PRIORITY) # downstream agents (subblocks/facts/…) after the inventory columns
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _batch_gate(scope: str | None, priority: int):
|
||||
"""Per-topic slot FIRST (fair), then the GLOBAL slot by priority (earlier columns win when
|
||||
agents are scarce). Order matters — a waiter holds only its per-topic slot while queueing globally."""
|
||||
topic_sem = _topic_sems.setdefault(scope, _PrioritySemaphore(MAX_CONCURRENT_AGENTS_PER_TOPIC)) if scope else None
|
||||
if topic_sem is not None:
|
||||
await topic_sem.acquire(priority)
|
||||
await _batch_sem.acquire(priority)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_batch_sem.release()
|
||||
if topic_sem is not None:
|
||||
topic_sem.release()
|
||||
|
||||
# Serialize OpenCode starts: processes starting simultaneously collide on the
|
||||
# internal session DB ("database is locked", exit after <1s). The short
|
||||
# stagger spreads out the starts; afterwards the processes run in parallel normally.
|
||||
_opencode_start_lock = asyncio.Lock()
|
||||
_OPENCODE_START_DELAY = 1.0
|
||||
|
||||
@@ -58,7 +135,7 @@ _CLAUDE_TOOLS = {
|
||||
"none": None,
|
||||
}
|
||||
|
||||
# Capability → OpenCode-Agent (Tool-Rechte in dev-ops/opencode.json definiert)
|
||||
# Capability → OpenCode agent (tool permissions defined in dev-ops/opencode.json)
|
||||
_OPENCODE_AGENTS = {
|
||||
"full": "full",
|
||||
"files": "files",
|
||||
@@ -86,8 +163,8 @@ def provider_available(provider: str) -> bool:
|
||||
|
||||
|
||||
def _kill(process) -> None:
|
||||
"""Killt den Agenten samt Kindprozessen über die Prozess-Gruppe (sonst überleben die
|
||||
von der CLI gestarteten Kinder, halten die Pipes offen und blockieren communicate())."""
|
||||
"""Kill the agent and its child processes via the process group (otherwise the
|
||||
children spawned by the CLI survive, keep the pipes open and block communicate())."""
|
||||
try:
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
@@ -98,10 +175,11 @@ def _kill(process) -> None:
|
||||
|
||||
|
||||
def kill_process(agent_key_prefix: str) -> None:
|
||||
"""Killt alle aktiven Prozesse, deren Key mit dem Prefix beginnt (deckt -plan/-w1… ab)."""
|
||||
"""Kill all active processes whose key starts with the prefix (covers -plan/-w1…)."""
|
||||
for key, process in list(_active_processes.items()):
|
||||
if process.returncode is not None: # tote Einträge beim Iterieren aufräumen
|
||||
if process.returncode is not None: # clean up dead entries while iterating
|
||||
_active_processes.pop(key, None)
|
||||
_active_started.pop(key, None)
|
||||
continue
|
||||
if key.startswith(agent_key_prefix):
|
||||
log.debug("kill agent %s", key)
|
||||
@@ -116,23 +194,25 @@ async def run_agent(
|
||||
role: str = "fast",
|
||||
capabilities: str = "none",
|
||||
lane: str = "batch",
|
||||
scope: str | None = None,
|
||||
on_line=None,
|
||||
) -> tuple[int, str, str]:
|
||||
if _scope_cancelled(agent_key): # vor dem Anstehen: gar nicht erst in die Schlange
|
||||
return 1, "", "abgebrochen"
|
||||
if _scope_cancelled(agent_key): # before queueing: don't even enter the queue
|
||||
return 1, "", "cancelled"
|
||||
if provider not in PROVIDERS:
|
||||
return 1, "", f"Unbekannter Provider: {provider}"
|
||||
return 1, "", f"Unknown provider: {provider}"
|
||||
if shutil.which(PROVIDERS[provider]["cli"]) is None:
|
||||
return 1, "", f"CLI '{PROVIDERS[provider]['cli']}' nicht installiert (Provider: {provider})"
|
||||
sem = _interactive_sem if lane == "interactive" else _batch_sem
|
||||
async with sem:
|
||||
if _scope_cancelled(agent_key): # nach dem Acquire: in der Schlange abgebrochen → kein Spawn
|
||||
return 1, "", "abgebrochen"
|
||||
return 1, "", f"CLI '{PROVIDERS[provider]['cli']}' not installed (provider: {provider})"
|
||||
gate = _interactive_sem if lane == "interactive" else _batch_gate(scope, _agent_priority(agent_key))
|
||||
async with gate:
|
||||
if _scope_cancelled(agent_key): # after the acquire: cancelled in the queue → no spawn
|
||||
return 1, "", "cancelled"
|
||||
if PROVIDERS[provider]["cli"] == "opencode":
|
||||
return await _run_opencode(agent_key, prompt, timeout, provider, role, capabilities)
|
||||
return await _run_opencode(agent_key, prompt, timeout, provider, role, capabilities, on_line=on_line)
|
||||
return await _run_claude_cli(agent_key, prompt, timeout, role, capabilities)
|
||||
|
||||
|
||||
async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None, timeout: int, stagger: bool = False) -> tuple[int, str, str]:
|
||||
async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None, timeout: int, stagger: bool = False, on_line=None) -> tuple[int, str, str]:
|
||||
start = time.monotonic()
|
||||
|
||||
async def spawn():
|
||||
@@ -141,7 +221,7 @@ async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None,
|
||||
stdin=asyncio.subprocess.PIPE if stdin_data is not None else asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
start_new_session=True, # eigene Prozess-Gruppe → killpg killt auch Kindprozesse
|
||||
start_new_session=True, # own process group → killpg also kills child processes
|
||||
)
|
||||
|
||||
if stagger:
|
||||
@@ -151,30 +231,48 @@ async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None,
|
||||
else:
|
||||
process = await spawn()
|
||||
_active_processes[agent_key] = process
|
||||
_active_started[agent_key] = time.time()
|
||||
try:
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(input=stdin_data),
|
||||
timeout=timeout,
|
||||
)
|
||||
if on_line is not None:
|
||||
# Streaming path: read stdout line by line, hand each raw line to on_line LIVE.
|
||||
out_chunks: list[str] = []
|
||||
async def _pump():
|
||||
async for raw in process.stdout:
|
||||
s = raw.decode("utf-8", errors="replace")
|
||||
out_chunks.append(s)
|
||||
try:
|
||||
on_line(s)
|
||||
except Exception:
|
||||
log.debug("on_line callback failed", exc_info=True)
|
||||
await asyncio.wait_for(_pump(), timeout=timeout)
|
||||
await process.wait()
|
||||
stderr_b = await process.stderr.read()
|
||||
stdout, stderr = "".join(out_chunks).encode("utf-8"), stderr_b
|
||||
else:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(input=stdin_data),
|
||||
timeout=timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
_kill(process)
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=5)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
log.info("agent %s: Timeout nach %ds", agent_key, timeout)
|
||||
log.info("agent %s: timeout after %ds", agent_key, timeout)
|
||||
raise
|
||||
log.info(
|
||||
"agent %s: exit %s nach %.1fs (%d Bytes stdout)",
|
||||
"agent %s: exit %s after %.1fs (%d bytes stdout)",
|
||||
agent_key, process.returncode, time.monotonic() - start, len(stdout),
|
||||
)
|
||||
return process.returncode, stdout.decode("utf-8", errors="replace"), stderr.decode("utf-8", errors="replace")
|
||||
finally:
|
||||
# Pop nur bei Identität: ein Slot-Restart unter demselben Key darf den
|
||||
# NEUEN Prozess nicht aus dem Tracking werfen.
|
||||
# Pop only on identity: a slot restart under the same key must not evict
|
||||
# the NEW process from tracking.
|
||||
if _active_processes.get(agent_key) is process:
|
||||
del _active_processes[agent_key]
|
||||
_active_started.pop(agent_key, None)
|
||||
|
||||
|
||||
async def _run_claude_cli(agent_key: str, prompt: str, timeout: int, role: str, capabilities: str) -> tuple[int, str, str]:
|
||||
@@ -187,14 +285,14 @@ async def _run_claude_cli(agent_key: str, prompt: str, timeout: int, role: str,
|
||||
return await _communicate(agent_key, cmd, prompt.encode("utf-8"), timeout)
|
||||
|
||||
|
||||
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, on_line=None) -> tuple[int, str, str]:
|
||||
cfg = PROVIDERS[provider]
|
||||
# Prompt über Tempdatei statt argv (ARG_MAX-Schutz bei großen Projekt-Prompts)
|
||||
# Prompt via temp file instead of argv (ARG_MAX protection for large project prompts)
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8", dir=tempfile.gettempdir()) as f:
|
||||
f.write(prompt)
|
||||
prompt_path = Path(f.name)
|
||||
# Positional-Message MUSS vor -f stehen: -f ist ein Array-Flag und
|
||||
# frisst sonst den Text als zweiten Dateinamen ("File not found").
|
||||
# The positional message MUST come before -f: -f is an array flag and
|
||||
# would otherwise eat the text as a second file name ("File not found").
|
||||
cmd = [
|
||||
cfg["cli"], "run",
|
||||
"Folge exakt den Anweisungen in der angehängten Datei. Sie sind der vollständige Auftrag.",
|
||||
@@ -203,9 +301,11 @@ async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str
|
||||
"--dangerously-skip-permissions",
|
||||
"-f", str(prompt_path),
|
||||
]
|
||||
if on_line is not None:
|
||||
cmd += ["--format", "json"] # raw JSON events → parsed live by on_line
|
||||
try:
|
||||
rc, stdout, stderr = await _communicate(agent_key, cmd, None, timeout, stagger=True)
|
||||
return rc, _clean_opencode_output(stdout), stderr
|
||||
rc, stdout, stderr = await _communicate(agent_key, cmd, None, timeout, stagger=True, on_line=on_line)
|
||||
return rc, (stdout if on_line is not None else _clean_opencode_output(stdout)), stderr
|
||||
finally:
|
||||
prompt_path.unlink(missing_ok=True)
|
||||
|
||||
@@ -214,7 +314,7 @@ _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
||||
|
||||
|
||||
def _clean_opencode_output(text: str) -> str:
|
||||
"""Entfernt ANSI-Codes und den führenden Banner ("> agent · modell")."""
|
||||
"""Strip ANSI codes and the leading banner ("> agent · model")."""
|
||||
text = _ANSI_RE.sub("", text)
|
||||
lines = text.splitlines()
|
||||
while lines and (not lines[0].strip() or lines[0].lstrip().startswith(">")):
|
||||
|
||||
1738
backend/bausteine.py
1738
backend/bausteine.py
File diff suppressed because it is too large
Load Diff
3123
backend/blocks.py
Normal file
3123
backend/blocks.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
@@ -10,96 +11,132 @@ UNI_DIR = PROJECT_ROOT / "uni"
|
||||
|
||||
MAX_CONCURRENT_GENERATIONS = 10
|
||||
|
||||
# Lesbarkeits-Gate: deterministischer Prüfer (kleines deutsches Komplexitäts-Modell,
|
||||
# Skala 1–7). Zu schwere Sections gehen in die Lese-Prüfungs-Überarbeitung.
|
||||
# Fehlen transformers/torch oder das Modell → Gate stumm aus.
|
||||
LESBARKEIT_AKTIV = True
|
||||
LESBARKEIT_MODELL = "MiriUll/distilbert-german-text-complexity"
|
||||
# Anker auf der 1–7-Skala (TextComplexityDE): Leichte Sprache ~1,2; Wikipedia-Schnitt
|
||||
# ~3,22; ab MOS > 4 gilt ein Satz als „echt komplex" (Vereinfachungs-Grenze des Papers).
|
||||
LESBARKEIT_MAX = 3.5 # Section zu schwer, wenn der Satz-Schnitt darüber liegt
|
||||
LESBARKEIT_HART = 4.0 # Einzelsatz ab hier „hart"
|
||||
LESBARKEIT_HART_ANTEIL = 0.30 # … ODER wenn dieser Anteil der Sätze hart ist
|
||||
# Readability gate: deterministic checker (small German complexity model,
|
||||
# scale 1–7). Sections that are too hard go into the read-exam revision.
|
||||
# If transformers/torch or the model are missing → gate silently off.
|
||||
READABILITY_ACTIVE = True
|
||||
READABILITY_MODEL = "MiriUll/distilbert-german-text-complexity"
|
||||
# Anchors on the 1–7 scale (TextComplexityDE): plain language ~1.2; Wikipedia average
|
||||
# ~3.22; from MOS > 4 a sentence counts as "truly complex" (the paper's simplification cutoff).
|
||||
READABILITY_MAX = 3.5 # section too hard when the sentence average is above this
|
||||
READABILITY_HARD = 4.0 # an individual sentence is "hard" from here on
|
||||
READABILITY_HARD_SHARE = 0.30 # … OR when this share of sentences is hard
|
||||
|
||||
# Deckel für gleichzeitige CLI-Agenten-Prozesse (über alle Generierungen hinweg).
|
||||
# Eigene Spur für interaktive Aufrufe (Chat, Elemente), damit sie nicht hinter
|
||||
# laufenden Writern in der Warteschlange hängen.
|
||||
MAX_CONCURRENT_AGENTS = 10
|
||||
MAX_CONCURRENT_INTERACTIVE = 4
|
||||
# Block consolidation: semantic embedding clustering instead of an LLM list merge.
|
||||
# A small multilingual sentence embedding (mean-pool) builds the candidate clusters
|
||||
# GLOBALLY (no chunk loss) via cosine + union-find. Title variants of the same concept
|
||||
# ("Vertex Cover" / "Vertex Cover Definition") merge; the consensus then counts the
|
||||
# real readers per cluster (≥2 = consensus). If transformers/torch are missing or the model
|
||||
# won't load → embedding silently off, `_consolidate` falls back to the old panel-judge path.
|
||||
EMBEDDING_AKTIV = True
|
||||
EMBEDDING_MODELL = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2" # CPU, multilingual, ~470 MB
|
||||
# Stronger (larger) CPU alternative if needed: "BAAI/bge-m3".
|
||||
# Consolidation = two-stage: (1) the embedding builds COARSE similarity blocks (high recall),
|
||||
# (2) an LLM judge groups EACH block into the real blocks (merge paraphrases, split
|
||||
# over-merges). Pure threshold blocking creates a giant component (everything chained) →
|
||||
# hence "capped blocking": greedily merge by cosine, but cap the block size. This keeps the
|
||||
# LLM lists short and stable (evidenced: embedding block + LLM judge ≈ 95% precision).
|
||||
EMBEDDING_BLOCK_FLOOR = 0.5 # minimum cosine for two candidates to share ONE block
|
||||
EMBEDDING_BLOCK_CAP = 25 # max. titles per block (keep the LLM list short/stable)
|
||||
# Subblock dedup: purely deterministic (no LLM). Subblocks are short statements IN THE SAME
|
||||
# block context — from this cosine on two are the same statement (checked on aak: ≥0.88 are
|
||||
# without exception true duplicates). Conservative 0.90 so different aspects (∈NP ≠ NP-hard) stay separate.
|
||||
EMBEDDING_SUB_DUP = 0.90
|
||||
# Cosine: two judge-subblocks state the SAME point → one cluster in the clarify majority vote.
|
||||
# Lower than _DUP because it must merge paraphrases (not just typo-variants). Empirically 0.80 keeps
|
||||
# distinct aspects (∈NP vs NP-hard) apart while clustering re-wordings of the same fact.
|
||||
EMBEDDING_SUB_SAME = 0.80
|
||||
|
||||
# Grace-Fenster der Konsens-Races (Bausteine, Guide, OnePager): Nach dem ersten
|
||||
# gültigen Ergebnis dürfen die übrigen Agenten noch so viele Sekunden fertig
|
||||
# werden (Kill nur, wenn das Minimum schon steht).
|
||||
KONSENS_GRACE = 300
|
||||
# Caps for concurrent CLI agent processes (env-overridable). Two nested limits, both always active:
|
||||
# a per-topic cap and a global cap across all topics. Defaults 10/10 = previous behavior (global
|
||||
# dominates). Locally raise the global cap to actually parallelize across topics (per-topic stays 10).
|
||||
# Own lane for interactive calls (chat, elements) so they don't hang behind running writers.
|
||||
MAX_CONCURRENT_AGENTS = int(os.getenv("MAX_CONCURRENT_AGENTS", "10")) # global, all topics
|
||||
MAX_CONCURRENT_AGENTS_PER_TOPIC = int(os.getenv("MAX_CONCURRENT_AGENTS_PER_TOPIC", "10")) # per topic
|
||||
MAX_CONCURRENT_INTERACTIVE = 8
|
||||
|
||||
# Recherche-Race: längeres Grace-Fenster. Recherche treibt die ganze Bausteine-Anzahl;
|
||||
# bei langsamen Providern (z.B. MiniMax) sollen ALLE 5 Agenten fertig werden, nicht nur
|
||||
# das Quorum von 3. Pro-Agent-Timeout (TIMEOUTS["recherche"]=1800s) deckelt echte Hänger.
|
||||
RECHERCHE_GRACE = 900
|
||||
# Inventory engine: streaming kanban dataflow (kanban.py) is the default; set "0" for the legacy ER pipeline.
|
||||
KANBAN_INVENTORY = os.getenv("KANBAN_INVENTORY", "1") != "0"
|
||||
|
||||
# Cap der Klärungs- und Prüf-Loops: maximale Runden, bis alles entschieden sein
|
||||
# muss. In der letzten Runde MUSS der Mapping-Agent jeden Eintrag entscheiden;
|
||||
# Prüf-Loops lassen Rest-Beanstandungen danach stehen.
|
||||
KONSENS_MAX_RUNDEN = 3
|
||||
# Grace window of the consensus races (blocks, guide, OnePager): after the first
|
||||
# valid result the remaining agents may still become done for this many seconds
|
||||
# (kill only once the minimum is already in).
|
||||
CONSENSUS_GRACE = 300
|
||||
|
||||
# Crawler-Sichtung (Content/Noise) — deterministischer Regel-Filter statt LLM.
|
||||
# Match: Substring (klein) gegen URL UND Dateiname. Reihenfolge: keep > noise > min_chars > behalten.
|
||||
# Sonderregeln einfach hier ergänzen.
|
||||
CRAWL_KEEP_PATTERNS = ["learn-unit", "learn-course"] # immer Content
|
||||
CRAWL_NOISE_PATTERNS = [ # eindeutig themenfremd → raus
|
||||
# Research race: longer grace window. Research drives the whole block count;
|
||||
# with slow providers (e.g. MiniMax) ALL 5 agents should become done, not just
|
||||
# the quorum of 3. The per-agent timeout (TIMEOUTS["research"]=1800s) caps real hangs.
|
||||
RESEARCH_GRACE = 900
|
||||
|
||||
# Cap of the clarification and check loops: maximum rounds until everything must be
|
||||
# decided. In the last round the mapping agent MUST decide every entry;
|
||||
# check loops leave any remaining objections standing after that.
|
||||
CONSENSUS_MAX_ROUNDS = 3
|
||||
|
||||
# Crawler triage (content/noise) — deterministic rule filter instead of an LLM.
|
||||
# Match: substring (lowercase) against URL AND file name. Order: keep > noise > min_chars > keep.
|
||||
# Just add special rules here.
|
||||
CRAWL_KEEP_PATTERNS = ["learn-unit", "learn-course"] # always content
|
||||
CRAWL_NOISE_PATTERNS = [ # clearly off-topic → out
|
||||
"clubs", "events", "podcasts", "resources", "-u-",
|
||||
"academy", "pricing", "/plans", "career", "newsletter", "impressum", "login",
|
||||
]
|
||||
CRAWL_MIN_CHARS = 400 # zu wenig Text → raus
|
||||
CRAWL_MIN_CHARS = 400 # too little text → out
|
||||
|
||||
# Timeouts pro Agenten-Schritt: (Basis-Sekunden, Sekunden pro Baustein/Section).
|
||||
# Gilt für alle Provider gleich — wer zu langsam ist, wird neu gestartet bzw. überholt.
|
||||
# LLM topic relevance gate (after the rule filter): per content page yes/no against the spec.
|
||||
# Separates the subject area (e.g. backend vs frontend), which the global CRAWL_* rules can't.
|
||||
QUELLE_RELEVANZ_CHUNK = 12 # pages per rater package (small, since a snippet ships per page)
|
||||
QUELLE_RELEVANZ_SNIPPET = 800 # body characters per page in the prompt (URL is the primary signal)
|
||||
|
||||
# Timeouts per agent step: (base seconds, seconds per block/section).
|
||||
# Applies equally to all providers — whoever is too slow gets restarted or overtaken.
|
||||
TIMEOUTS = {
|
||||
"recherche": (1800, 0), # fix 30 min
|
||||
"recherche_mapping": (600, 3), # n = vorgemergte Einträge
|
||||
"auswahl_mapping": (600, 2), # n = Rest-Einträge (Bausteine-Inventar)
|
||||
"ergaenzung": (900, 0), # Themenfeld-Ergänzung bei Projekten (Web-Recherche)
|
||||
"research": (1800, 0), # fixed 30 min
|
||||
"research_mapping": (600, 3), # n = pre-merged entries
|
||||
"selection_mapping": (600, 2), # n = remaining entries (block inventory)
|
||||
"ergaenzung": (900, 0), # subject-field extension for projects (web research)
|
||||
"plan": (300, 5),
|
||||
"plan_judge": (600, 5), # Judge liest bis zu 5 Gliederungen, n = Sections
|
||||
"inhalt": (600, 90), # Inhalte je Baustein im Chunk identifizieren (Websuche)
|
||||
"inhalt_check": (300, 10), # Inhalts-Prüfung je Baustein im Paket
|
||||
"subbaustein": (900, 45), # Subbausteine je Baustein im Chunk finden (Websuche)
|
||||
"subbaustein_check": (300, 15), # Judge entscheidet strittige Subbausteine im Chunk
|
||||
"stufe": (300, 10), # Subbausteine einstufen je Chunk
|
||||
"stufe_check": (300, 10), # Judge entscheidet strittige Stufen im Chunk
|
||||
"relevanz": (300, 10), # Subbausteine relevant/rand je Chunk
|
||||
"relevanz_check": (300, 10), # Judge entscheidet strittige Relevanz im Chunk
|
||||
"frage_muster": (300, 15), # Frage-Muster je Baustein (Subbausteine × Typen)
|
||||
"frage_muster_check": (300, 10), # Kritiker bereinigt die Muster-Tabelle je Baustein
|
||||
"writer": (600, 120), # pro Section im Chunk
|
||||
"lese_check": (300, 10), # pro Section im Paket
|
||||
"plan_judge": (600, 5), # judge reads up to 5 outlines, n = sections
|
||||
"content": (600, 90), # identify content per block in the chunk (web search)
|
||||
"content_check": (300, 10), # content exam per block in the package
|
||||
"subblock": (900, 45), # find subblocks per block in the chunk (web search)
|
||||
"subblock_check": (300, 15), # judge decides contested subblocks in the chunk
|
||||
"level": (300, 10), # classify subblocks per chunk
|
||||
"level_check": (300, 10), # judge decides contested levels in the chunk
|
||||
"relevance": (300, 10), # subblocks relevant/peripheral per chunk
|
||||
"relevance_check": (300, 10), # judge decides contested relevance in the chunk
|
||||
"question_pattern": (300, 15), # question patterns per block (subblocks × types)
|
||||
"question_pattern_check": (300, 10), # critic cleans up the pattern table per block
|
||||
"writer": (600, 120), # per section in the chunk
|
||||
"lese_check": (300, 10), # per section in the package
|
||||
}
|
||||
|
||||
# Zweck je Format — fließt in den Gliederungs-Judge (was der Guide leisten soll).
|
||||
FORMAT_ZWECK = {
|
||||
# Purpose per format — flows into the outline judge (what the guide should achieve).
|
||||
# German strings: these are inserted verbatim into the judge prompt → kept German on purpose.
|
||||
FORMAT_PURPOSE = {
|
||||
"Guide": "einen fokussierten Guide — alles Relevante ohne Randthemen",
|
||||
"FullGuide": "einen Komplett-Guide — das ganze Thema inkl. Randthemen",
|
||||
"Rest": "einen Ergänzungs-Guide — nur die Randthemen",
|
||||
}
|
||||
|
||||
# Provider-Stacks: komplett unabhängig, einer kann jederzeit entfernt werden.
|
||||
# Rollen: "quick" = Massenarbeit (Recherche, Einordnung),
|
||||
# "fast" = Interaktion + Voten (Chat, Prüfung, Klärung, Elemente),
|
||||
# "judge" = Mapping-/Judge-/Prüf-Agenten — kalt (niedrige Temperature,
|
||||
# ohne Thinking) für stabile Urteile; Claude/Lokal mappen auf "fast",
|
||||
# "guide" = große Generierung (Vorschläge, Writer).
|
||||
# Provider stacks: completely independent, any one can be removed at any time.
|
||||
# Roles: "quick" = bulk work (research, classification),
|
||||
# "fast" = interaction + voting (chat, exam, clarification, elements),
|
||||
# "judge" = mapping/judge/check agents — cold (low temperature,
|
||||
# no thinking) for stable verdicts; Claude/local map to "fast",
|
||||
# "guide" = large generation (proposals, writer).
|
||||
DEFAULT_PROVIDER = "claude"
|
||||
PROVIDERS = {
|
||||
"claude": {
|
||||
"cli": "claude",
|
||||
"guide": "claude-opus-4-8[1m]",
|
||||
"fast": "claude-sonnet-4-6",
|
||||
"judge": "claude-sonnet-4-6", # CLI kennt keine Temperature
|
||||
"judge": "claude-sonnet-4-6", # the CLI has no temperature setting
|
||||
"quick": "claude-sonnet-4-6",
|
||||
"env_key": None, # Auth via CLAUDE_CODE_OAUTH_TOKEN oder ~/.claude
|
||||
"env_key": None, # auth via CLAUDE_CODE_OAUTH_TOKEN or ~/.claude
|
||||
},
|
||||
# "minimax-kalt/…" ist KEIN eigener Stack, nur ein opencode-Provider-Eintrag
|
||||
# (dev-ops/opencode.json) mit niedriger Temperature; M3 dort ohne Thinking.
|
||||
# "minimax-kalt/…" is NOT its own stack, just an opencode provider entry
|
||||
# (dev-ops/opencode.json) with low temperature; M3 there without thinking.
|
||||
"minimax": {
|
||||
"cli": "opencode",
|
||||
"guide": "minimax/MiniMax-M3",
|
||||
@@ -115,6 +152,6 @@ PROVIDERS = {
|
||||
"judge": "ollama/qwen3.5:9b",
|
||||
"quick": "ollama/qwen3.5:9b",
|
||||
"env_key": None,
|
||||
"check_url": "http://localhost:11434/api/tags", # Ollama erreichbar?
|
||||
"check_url": "http://localhost:11434/api/tags", # Ollama reachable?
|
||||
},
|
||||
}
|
||||
|
||||
117
backend/crawl.py
117
backend/crawl.py
@@ -1,9 +1,9 @@
|
||||
"""Geboundeter Domain-Crawler für Link-Quellen — rendert JS via Playwright (Chromium).
|
||||
"""Bounded domain crawler for link sources — renders JS via Playwright (Chromium).
|
||||
|
||||
Lädt ab einer Start-URL Seiten + PDFs — NUR dieselbe Domain, begrenzte Tiefe und
|
||||
Seitenzahl. HTML-Seiten werden im Headless-Browser gerendert (für SPAs nötig), dann
|
||||
Links + Text aus dem fertigen DOM gezogen. PDFs werden direkt als Bytes geladen.
|
||||
Deterministisch, gebounded; läuft via asyncio.to_thread (Sync-API, kein Event-Loop).
|
||||
Loads pages + PDFs starting from a start URL — ONLY the same domain, limited depth
|
||||
and page count. HTML pages are rendered in a headless browser (needed for SPAs), then
|
||||
links + text are pulled from the finished DOM. PDFs are loaded directly as bytes.
|
||||
Deterministic, bounded; runs via asyncio.to_thread (sync API, no event loop).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
@@ -17,22 +17,23 @@ from fsutil import atomic_write_text
|
||||
|
||||
log = logging.getLogger("creator.crawl")
|
||||
|
||||
MAX_TIEFE = 3
|
||||
MAX_SEITEN = 500
|
||||
SEITE_TIMEOUT = 30 # Sekunden pro Seite (Render bzw. PDF-Download)
|
||||
MAX_BYTES = 10_000_000 # 10 MB Deckel pro PDF
|
||||
MAX_DEPTH = 3
|
||||
MAX_PAGES = 500
|
||||
PAGE_TIMEOUT = 30 # seconds per page (render or PDF download)
|
||||
CRAWL_SETTLE_MS = 3000 # capped settle after domcontentloaded (SPA render); no 30s networkidle hang
|
||||
MAX_BYTES = 10_000_000 # 10 MB cap per PDF
|
||||
_UA = "Mozilla/5.0 (creator-lernbot)"
|
||||
|
||||
|
||||
def _fetch_bytes(url: str) -> bytes | None:
|
||||
"""PDF-Bytes per urllib laden (kein Rendering nötig). None bei Fehler/zu groß."""
|
||||
"""Load PDF bytes via urllib (no rendering needed). None on error/too large."""
|
||||
try:
|
||||
req = Request(url, headers={"User-Agent": _UA})
|
||||
with urlopen(req, timeout=SEITE_TIMEOUT) as resp:
|
||||
with urlopen(req, timeout=PAGE_TIMEOUT) as resp:
|
||||
data = resp.read(MAX_BYTES + 1)
|
||||
return None if len(data) > MAX_BYTES else data
|
||||
except Exception as e:
|
||||
log.debug("crawl: PDF-Fetch fehlgeschlagen %s: %s", url, e)
|
||||
log.debug("crawl: PDF fetch failed %s: %s", url, e)
|
||||
return None
|
||||
|
||||
|
||||
@@ -46,56 +47,89 @@ def _is_pdf(url: str) -> bool:
|
||||
return url.lower().split("?")[0].rstrip("/").endswith(".pdf")
|
||||
|
||||
|
||||
def crawl(start_url: str, ziel: Path, *, max_tiefe: int = MAX_TIEFE, max_seiten: int = MAX_SEITEN, cancelled=None) -> int:
|
||||
"""Crawlt ab start_url (nur gleiche Domain), rendert JS und legt Seiten/PDFs in `ziel` ab.
|
||||
def _scope_prefix(start_url: str) -> str:
|
||||
"""First non-empty path segment of the start URL as the crawl scope, e.g.
|
||||
`/learn/path/x` → `/learn`. No path segment → `""` (whole domain, no narrowing)."""
|
||||
seg = [s for s in urlparse(start_url).path.split("/") if s]
|
||||
return f"/{seg[0]}" if seg else ""
|
||||
|
||||
BFS bis `max_tiefe` / `max_seiten`. Fehler einzelner Seiten werden übersprungen.
|
||||
Schreibt am ENDE einen `.done`-Marker; ein Abbruch (`cancelled()` → True) lässt ihn weg,
|
||||
sodass ein Neustart neu crawlt. Gibt die Zahl gespeicherter Quellen zurück.
|
||||
|
||||
def _in_scope(url: str, prefix: str) -> bool:
|
||||
"""Segment-exact prefix match (no `/learn` ⊃ `/learning-x`). Empty prefix → everything allowed."""
|
||||
if not prefix:
|
||||
return True
|
||||
p = urlparse(url).path
|
||||
return p == prefix or p.startswith(prefix + "/")
|
||||
|
||||
|
||||
def _page_text(page) -> str:
|
||||
"""Main text of the rendered page — nav/footer/boilerplate removed via trafilatura.
|
||||
Falls back to the raw body text when extraction is empty/too short (non-article pages)."""
|
||||
try:
|
||||
from trafilatura import extract # lazy: the backend starts even without the package
|
||||
text = extract(page.content(), include_comments=False, include_tables=True) or ""
|
||||
except Exception:
|
||||
text = ""
|
||||
if len(text.strip()) >= 200:
|
||||
return text.strip()
|
||||
try:
|
||||
return page.inner_text("body").strip()
|
||||
except Exception:
|
||||
return text.strip()
|
||||
|
||||
|
||||
def crawl(start_url: str, target: Path, *, max_depth: int = MAX_DEPTH, max_pages: int = MAX_PAGES, cancelled=None) -> int:
|
||||
"""Crawl from start_url (same domain only), render JS and store pages/PDFs in `target`.
|
||||
|
||||
BFS up to `max_depth` / `max_pages`. Errors on individual pages are skipped.
|
||||
Writes a `.done` marker at the END; an abort (`cancelled()` → True) omits it,
|
||||
so a restart crawls again. Returns the number of saved sources.
|
||||
"""
|
||||
# Lazy: so startet das Backend auch ohne installiertes Playwright; nur das Crawlen schlägt dann fehl.
|
||||
# Lazy: this way the backend starts even without Playwright installed; only crawling then fails.
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ziel.mkdir(parents=True, exist_ok=True)
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
domain = urlparse(start_url).netloc
|
||||
gesehen: set[str] = set()
|
||||
prefix = _scope_prefix(start_url) # only follow links under this path segment
|
||||
seen: set[str] = set()
|
||||
queue: list[tuple[str, int]] = [(urldefrag(start_url)[0], 0)]
|
||||
gespeichert = 0
|
||||
saved = 0
|
||||
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(args=["--no-sandbox"]) # non-root (Docker user app)
|
||||
page = browser.new_page(user_agent=_UA)
|
||||
try:
|
||||
while queue and gespeichert < max_seiten:
|
||||
while queue and saved < max_pages:
|
||||
if cancelled and cancelled():
|
||||
return gespeichert # Abbruch → KEIN .done-Marker → Neustart crawlt neu
|
||||
url, tiefe = queue.pop(0)
|
||||
if url in gesehen:
|
||||
return saved # abort → NO .done marker → restart crawls again
|
||||
url, depth = queue.pop(0)
|
||||
if url in seen:
|
||||
continue
|
||||
gesehen.add(url)
|
||||
seen.add(url)
|
||||
|
||||
# PDFs brauchen kein Rendering — direkt laden.
|
||||
# PDFs need no rendering — load directly.
|
||||
if _is_pdf(url):
|
||||
data = _fetch_bytes(url)
|
||||
if data:
|
||||
p = ziel / _name(url, ".pdf")
|
||||
p = target / _name(url, ".pdf")
|
||||
if not p.exists():
|
||||
p.write_bytes(data)
|
||||
gespeichert += 1
|
||||
saved += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
page.goto(url, wait_until="networkidle", timeout=SEITE_TIMEOUT * 1000)
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=PAGE_TIMEOUT * 1000)
|
||||
except Exception as e:
|
||||
log.debug("crawl: goto unvollständig %s: %s", url, e) # trotzdem versuchen, Inhalt zu lesen
|
||||
log.debug("crawl: goto incomplete %s: %s", url, e) # still try to read the content
|
||||
try:
|
||||
text = page.inner_text("body").strip()
|
||||
page.wait_for_load_state("networkidle", timeout=CRAWL_SETTLE_MS)
|
||||
except Exception:
|
||||
text = ""
|
||||
pass # an SPA with constant traffic never reaches idle → continue after settle, no 30s hang
|
||||
text = _page_text(page) # main text, nav/footer removed (fallback: raw body)
|
||||
if text:
|
||||
atomic_write_text(ziel / _name(url, ".txt"), f"QUELLE: {url}\n\n{text}")
|
||||
gespeichert += 1
|
||||
if tiefe < max_tiefe:
|
||||
atomic_write_text(target / _name(url, ".txt"), f"QUELLE: {url}\n\n{text}")
|
||||
saved += 1
|
||||
if depth < max_depth:
|
||||
try:
|
||||
hrefs = page.eval_on_selector_all("a[href]", "els => els.map(e => e.href)")
|
||||
except Exception:
|
||||
@@ -103,11 +137,12 @@ def crawl(start_url: str, ziel: Path, *, max_tiefe: int = MAX_TIEFE, max_seiten:
|
||||
for href in hrefs:
|
||||
nxt = urldefrag(href)[0]
|
||||
if (nxt.startswith(("http://", "https://"))
|
||||
and urlparse(nxt).netloc == domain and nxt not in gesehen):
|
||||
queue.append((nxt, tiefe + 1))
|
||||
and urlparse(nxt).netloc == domain and _in_scope(nxt, prefix)
|
||||
and nxt not in seen):
|
||||
queue.append((nxt, depth + 1))
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
(ziel / ".done").write_text("ok", encoding="utf-8") # sauber durchgelaufen
|
||||
log.info("crawl %s → %d Quellen in %s", start_url, gespeichert, ziel)
|
||||
return gespeichert
|
||||
(target / ".done").write_text("ok", encoding="utf-8") # ran through cleanly
|
||||
log.info("crawl %s → %d sources in %s", start_url, saved, target)
|
||||
return saved
|
||||
|
||||
1011
backend/database.py
1011
backend/database.py
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
"""Elemente (persönliche Zusammenfassung) und Tutor-Chat zum Guide."""
|
||||
"""Elements (personal summary) and tutor chat for the guide."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
@@ -6,25 +6,25 @@ import uuid
|
||||
|
||||
from agents import run_agent
|
||||
from config import DEFAULT_PROVIDER
|
||||
from jsonio import parse_json_text as _parse_json_text, read_json_file as _json_datei
|
||||
from paths import bausteine_path, guide_content_path
|
||||
from jsonio import parse_json_text as _parse_json_text, read_json_file as _read_json_file
|
||||
from paths import blocks_path, guide_content_path
|
||||
from pipeline import _prompt
|
||||
|
||||
log = logging.getLogger("creator.elements")
|
||||
|
||||
|
||||
# --- Tutor-Chat ---
|
||||
# --- Tutor chat ---
|
||||
|
||||
def _build_guide_chat_prompt(topic: str, format_name: str, section: str, outline: str, messages: list[dict]) -> str:
|
||||
transcript = "\n".join(
|
||||
f"{'Nutzer' if m.get('role') == 'user' else 'Assistent'}: {m.get('content', '')}"
|
||||
f"{'User' if m.get('role') == 'user' else 'Assistant'}: {m.get('content', '')}"
|
||||
for m in messages
|
||||
)
|
||||
return _prompt(
|
||||
"Chat",
|
||||
topic=topic, format_name=format_name,
|
||||
outline_block=outline.strip() or "(keine)",
|
||||
section_block=section.strip() or "(kein Abschnitt erkannt)",
|
||||
outline_block=outline.strip() or "(none)",
|
||||
section_block=section.strip() or "(no section detected)",
|
||||
transcript=transcript,
|
||||
)
|
||||
|
||||
@@ -36,62 +36,62 @@ async def chat_with_guide(topic: str, format_name: str, section: str, outline: s
|
||||
"chat-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
|
||||
)
|
||||
if returncode != 0:
|
||||
return "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut."
|
||||
return "Sorry, that didn't work. Please try again."
|
||||
reply = stdout.strip()
|
||||
return reply or "Entschuldigung, ich habe keine Antwort erhalten."
|
||||
return reply or "Sorry, I didn't get a response."
|
||||
except Exception:
|
||||
log.warning("[%s] Guide-Chat fehlgeschlagen", topic, exc_info=True)
|
||||
return "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut."
|
||||
log.warning("[%s] Guide chat failed", topic, exc_info=True)
|
||||
return "Sorry, that didn't work. Please try again."
|
||||
|
||||
|
||||
# --- Elemente ---
|
||||
# --- Elements ---
|
||||
|
||||
def _element_fields(data: dict) -> dict | None:
|
||||
"""Validiert KI-Element-JSON und normalisiert auf die DB-Felder."""
|
||||
"""Validate AI element JSON and normalize it onto the DB fields."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
title = str(data.get("title", "")).strip()
|
||||
if not title:
|
||||
return None
|
||||
listen = {}
|
||||
lists = {}
|
||||
for key in ("examples", "hints"):
|
||||
raw = data.get(key, [])
|
||||
listen[key] = [str(e).strip() for e in raw if str(e).strip()] if isinstance(raw, list) else []
|
||||
lists[key] = [str(e).strip() for e in raw if str(e).strip()] if isinstance(raw, list) else []
|
||||
return {
|
||||
"title": title[:200],
|
||||
"description": str(data.get("description", "")).strip(),
|
||||
"examples": listen["examples"],
|
||||
"hints": listen["hints"],
|
||||
"examples": lists["examples"],
|
||||
"hints": lists["hints"],
|
||||
}
|
||||
|
||||
|
||||
def _topic_context(topic: str, limit: int = 12000) -> str:
|
||||
"""Bausteine + Guide-Inhalte des Themas als Kontext-Text (gekürzt)."""
|
||||
"""Blocks + guide content of the topic as context text (truncated)."""
|
||||
parts: list[str] = []
|
||||
bp = bausteine_path(topic)
|
||||
bp = blocks_path(topic)
|
||||
if bp.exists():
|
||||
parts.append(bp.read_text(encoding="utf-8"))
|
||||
for fmt in ("Guide", "FullGuide"): # bester verfügbarer Prosa-Guide als Chat-Kontext
|
||||
content = _json_datei(guide_content_path(topic, fmt))
|
||||
for fmt in ("Guide", "FullGuide"): # best available prose guide as chat context
|
||||
content = _read_json_file(guide_content_path(topic, fmt))
|
||||
if content:
|
||||
for ch in content.get("chapters", []):
|
||||
for sec in ch.get("sections", []):
|
||||
parts.append(sec if isinstance(sec, str) else json.dumps(sec, ensure_ascii=False))
|
||||
break # bester verfügbarer Guide reicht
|
||||
break # the best available guide is enough
|
||||
text = "\n\n".join(parts).strip()
|
||||
return text[:limit] if text else "(kein Material vorhanden)"
|
||||
return text[:limit] if text else "(no material available)"
|
||||
|
||||
|
||||
async def generate_element(topic: str, hint: str, provider: str = DEFAULT_PROVIDER, extra_context: str = "") -> dict:
|
||||
"""Erstellt Element-Felder per KI. Fallback: nur Titel aus dem Stichwort."""
|
||||
fallback = {"title": hint.strip() or "Neues Element", "description": "", "examples": [], "hints": []}
|
||||
"""Create element fields via AI. Fallback: only the title from the keyword."""
|
||||
fallback = {"title": hint.strip() or "New element", "description": "", "examples": [], "hints": []}
|
||||
try:
|
||||
context = _topic_context(topic)
|
||||
if extra_context.strip():
|
||||
context = (extra_context.strip() + "\n\n" + context)[:12000]
|
||||
prompt = _prompt(
|
||||
"Element-Create",
|
||||
topic=topic, hint=hint.strip() or "(keins — wähle selbst ein Kernkonzept)",
|
||||
topic=topic, hint=hint.strip() or "(none — pick a core concept yourself)",
|
||||
context=context,
|
||||
)
|
||||
returncode, stdout, _ = await run_agent(
|
||||
@@ -101,12 +101,12 @@ async def generate_element(topic: str, hint: str, provider: str = DEFAULT_PROVID
|
||||
return fallback
|
||||
return _element_fields(_parse_json_text(stdout)) or fallback
|
||||
except Exception:
|
||||
log.warning("[%s] Element-Erstellung fehlgeschlagen", topic, exc_info=True)
|
||||
log.warning("[%s] Element creation failed", topic, exc_info=True)
|
||||
return fallback
|
||||
|
||||
|
||||
def _parse_suggestions(stdout: str) -> list[dict] | None:
|
||||
"""Validiert Vorschlags-JSON aus KI-Output. None bei ungültigem JSON."""
|
||||
"""Validate suggestion JSON from AI output. None on invalid JSON."""
|
||||
data = _parse_json_text(stdout)
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
@@ -123,7 +123,7 @@ def _parse_suggestions(stdout: str) -> list[dict] | None:
|
||||
|
||||
|
||||
async def check_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list[dict] | None:
|
||||
"""Zweischrittige Prüfung auf fehlende Infos: Recherche → Verifizieren. None bei Fehler."""
|
||||
"""Two-step check for missing info: research → verify. None on error."""
|
||||
try:
|
||||
element_json = json.dumps(
|
||||
{k: element[k] for k in ("title", "description", "examples", "hints")},
|
||||
@@ -131,7 +131,7 @@ async def check_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list
|
||||
)
|
||||
context = _topic_context(element["topic"])
|
||||
|
||||
# Schritt 1: Recherche — breit Kandidaten sammeln
|
||||
# Step 1: research — collect candidates broadly
|
||||
prompt = _prompt("Element-Check", topic=element["topic"], element_json=element_json, context=context)
|
||||
returncode, stdout, _ = await run_agent(
|
||||
"element-check-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
|
||||
@@ -144,7 +144,7 @@ async def check_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
# Schritt 2: Verifizieren — nur Wichtiges, nicht Redundantes durchlassen
|
||||
# Step 2: verify — only let important, non-redundant items through
|
||||
prompt = _prompt(
|
||||
"Element-Verify",
|
||||
topic=element["topic"], element_json=element_json,
|
||||
@@ -158,7 +158,7 @@ async def check_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list
|
||||
return None
|
||||
return _parse_suggestions(stdout)
|
||||
except Exception:
|
||||
log.warning("[%s] Element-Prüfung fehlgeschlagen", element.get("topic", "?"), exc_info=True)
|
||||
log.warning("[%s] Element check failed", element.get("topic", "?"), exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ def _element_json(element: dict) -> str:
|
||||
|
||||
|
||||
def _validate_change(c, element: dict) -> dict | None:
|
||||
"""Validiert einen Änderungs-Vorschlag aus KI-Output gegen das Element."""
|
||||
"""Validate a change suggestion from AI output against the element."""
|
||||
if not isinstance(c, dict):
|
||||
return None
|
||||
text = str(c.get("text", "")).strip()
|
||||
@@ -178,16 +178,16 @@ def _validate_change(c, element: dict) -> dict | None:
|
||||
target = c.get("target")
|
||||
index = c.get("index")
|
||||
content = str(c.get("content", "")).strip()
|
||||
if not text or action not in ("entfernen", "anpassen", "hinzufuegen"):
|
||||
if not text or action not in ("remove", "adjust", "add"):
|
||||
return None
|
||||
if target not in ("title", "description", "examples", "hints"):
|
||||
return None
|
||||
if action in ("anpassen", "hinzufuegen") and not content:
|
||||
if action in ("adjust", "add") and not content:
|
||||
return None
|
||||
if action == "entfernen" and target not in ("examples", "hints"):
|
||||
if action == "remove" and target not in ("examples", "hints"):
|
||||
return None
|
||||
# Index nur für anpassen/entfernen in Listen-Feldern; muss existieren
|
||||
if target in ("examples", "hints") and action in ("anpassen", "entfernen"):
|
||||
# Index only for adjust/remove on list fields; must exist
|
||||
if target in ("examples", "hints") and action in ("adjust", "remove"):
|
||||
if not isinstance(index, int) or not (0 <= index < len(element[target])):
|
||||
return None
|
||||
else:
|
||||
@@ -196,11 +196,11 @@ def _validate_change(c, element: dict) -> dict | None:
|
||||
|
||||
|
||||
async def chat_with_element(element: dict, messages: list[dict], provider: str = DEFAULT_PROVIDER) -> tuple[str, list[dict]]:
|
||||
"""Chat zum Element. Gibt (Antwort, Änderungs-Vorschläge) zurück — ändert nichts direkt."""
|
||||
fehler = "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut."
|
||||
"""Chat about the element. Returns (reply, change suggestions) — changes nothing directly."""
|
||||
error = "Sorry, that didn't work. Please try again."
|
||||
try:
|
||||
transcript = "\n".join(
|
||||
f"{'Nutzer' if m.get('role') == 'user' else 'Assistent'}: {m.get('content', '')}"
|
||||
f"{'User' if m.get('role') == 'user' else 'Assistant'}: {m.get('content', '')}"
|
||||
for m in messages
|
||||
)
|
||||
prompt = _prompt("Element-Chat", topic=element["topic"], element_json=_element_json(element), transcript=transcript)
|
||||
@@ -208,22 +208,22 @@ async def chat_with_element(element: dict, messages: list[dict], provider: str =
|
||||
"element-chat-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
|
||||
)
|
||||
if returncode != 0:
|
||||
return fehler, []
|
||||
return error, []
|
||||
data = _parse_json_text(stdout)
|
||||
if not isinstance(data, dict):
|
||||
return fehler, []
|
||||
return error, []
|
||||
changes = [v for c in data.get("changes", []) if (v := _validate_change(c, element))]
|
||||
reply = str(data.get("reply", "")).strip() or ("Vorschläge erstellt." if changes else fehler)
|
||||
reply = str(data.get("reply", "")).strip() or ("Suggestions created." if changes else error)
|
||||
return reply, changes
|
||||
except Exception:
|
||||
log.warning("[%s] Element-Chat fehlgeschlagen", element.get("topic", "?"), exc_info=True)
|
||||
return fehler, []
|
||||
log.warning("[%s] Element chat failed", element.get("topic", "?"), exc_info=True)
|
||||
return error, []
|
||||
|
||||
|
||||
async def style_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list[dict] | None:
|
||||
"""Prüft ein Element auf die Stil-Regeln und schlägt Änderungen vor. None bei Fehler."""
|
||||
"""Check an element against the style rules and suggest changes. None on error."""
|
||||
try:
|
||||
prompt = _prompt("Element-Stil", topic=element["topic"], element_json=_element_json(element))
|
||||
prompt = _prompt("Element-Style", topic=element["topic"], element_json=_element_json(element))
|
||||
returncode, stdout, _ = await run_agent(
|
||||
"element-stil-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
|
||||
)
|
||||
@@ -234,12 +234,12 @@ async def style_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list
|
||||
return None
|
||||
return [v for c in data.get("changes", []) if (v := _validate_change(c, element))]
|
||||
except Exception:
|
||||
log.warning("[%s] Stil-Prüfung fehlgeschlagen", element.get("topic", "?"), exc_info=True)
|
||||
log.warning("[%s] Style check failed", element.get("topic", "?"), exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def refine_suggestion(element: dict, suggestion: dict, instruction: str, provider: str = DEFAULT_PROVIDER) -> dict | None:
|
||||
"""Überarbeitet einen einzelnen Vorschlag nach Nutzer-Anweisung. None bei Fehler."""
|
||||
"""Revise a single suggestion per user instruction. None on error."""
|
||||
try:
|
||||
prompt = _prompt(
|
||||
"Element-Refine",
|
||||
@@ -257,5 +257,5 @@ async def refine_suggestion(element: dict, suggestion: dict, instruction: str, p
|
||||
return None
|
||||
return _validate_change(data.get("change"), element)
|
||||
except Exception:
|
||||
log.warning("[%s] Vorschlags-Überarbeitung fehlgeschlagen", element.get("topic", "?"), exc_info=True)
|
||||
log.warning("[%s] Suggestion revision failed", element.get("topic", "?"), exc_info=True)
|
||||
return None
|
||||
|
||||
123
backend/embedding.py
Normal file
123
backend/embedding.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""Semantic embedding clustering for block consolidation.
|
||||
|
||||
Mean-pool embeddings of a multilingual sentence model build GLOBAL candidate
|
||||
clusters via cosine blocking + union-find (no chunk loss). Safe pairs (similarity
|
||||
≥ HARD) are merged without an LLM; borderline pairs in the band [BAND_LOW, HARD) are
|
||||
handed by the caller to an LLM judge for a yes/no decision. If `transformers`/`torch`
|
||||
are missing or the model won't load → `embed_sims()` returns `None`, and the caller
|
||||
falls back to the old panel-judge path (silent deactivation, like the readability gate).
|
||||
|
||||
CPU is enough; the caller wraps the blocking inference in `asyncio.to_thread`.
|
||||
`numpy` comes in transitively via torch (deliberately not in requirements.txt, like torch).
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import numpy as np
|
||||
|
||||
from config import EMBEDDING_AKTIV, EMBEDDING_MODELL, EMBEDDING_BLOCK_FLOOR, EMBEDDING_BLOCK_CAP
|
||||
|
||||
log = logging.getLogger("creator.embedding")
|
||||
|
||||
_model_cache = None # (tokenizer, model, torch) — singleton
|
||||
_load_attempt = False # already tried to load?
|
||||
|
||||
EMBEDDING_BATCH = 32 # inference batch size (CPU)
|
||||
EMBEDDING_MAX_LEN = 128 # title + short description are short → a small truncation cap suffices
|
||||
|
||||
|
||||
def _model():
|
||||
"""Load the model once. None = clustering off (disabled or load error)."""
|
||||
global _model_cache, _load_attempt
|
||||
if _load_attempt:
|
||||
return _model_cache
|
||||
_load_attempt = True
|
||||
if not EMBEDDING_AKTIV:
|
||||
return None
|
||||
try:
|
||||
import torch
|
||||
from transformers import AutoModel, AutoTokenizer
|
||||
tok = AutoTokenizer.from_pretrained(EMBEDDING_MODELL)
|
||||
model = AutoModel.from_pretrained(EMBEDDING_MODELL)
|
||||
model.eval()
|
||||
_model_cache = (tok, model, torch)
|
||||
log.info("embedding model loaded: %s", EMBEDDING_MODELL)
|
||||
except Exception as e:
|
||||
log.warning("embedding clustering disabled (model not loadable): %s", e)
|
||||
_model_cache = None
|
||||
return _model_cache
|
||||
|
||||
|
||||
def available() -> bool:
|
||||
"""True if the model could be loaded. Loads on the first call (blocking)."""
|
||||
return _model() is not None
|
||||
|
||||
|
||||
def embed(texts: list[str]) -> "np.ndarray | None":
|
||||
"""Texts → (n, d) L2-normalized, mean-pooled embeddings. None = model off."""
|
||||
if _model() is None:
|
||||
return None
|
||||
tok, model, torch = _model_cache
|
||||
out = []
|
||||
for i in range(0, len(texts), EMBEDDING_BATCH):
|
||||
batch = texts[i:i + EMBEDDING_BATCH]
|
||||
enc = tok(batch, return_tensors="pt", truncation=True, max_length=EMBEDDING_MAX_LEN, padding=True)
|
||||
with torch.no_grad():
|
||||
hidden = model(**enc).last_hidden_state # (b, t, d)
|
||||
mask = enc["attention_mask"].unsqueeze(-1).type_as(hidden)
|
||||
vec = (hidden * mask).sum(1) / mask.sum(1).clamp(min=1e-9) # mean-pool without padding
|
||||
vec = torch.nn.functional.normalize(vec, p=2, dim=1) # L2 → cosine = dot product
|
||||
out.append(vec.cpu().numpy())
|
||||
return np.vstack(out).astype(np.float32)
|
||||
|
||||
|
||||
def _find(parent: list[int], x: int) -> int:
|
||||
while parent[x] != x:
|
||||
parent[x] = parent[parent[x]] # Pfad-Kompression
|
||||
x = parent[x]
|
||||
return x
|
||||
|
||||
|
||||
def _union(parent: list[int], a: int, b: int) -> None:
|
||||
ra, rb = _find(parent, a), _find(parent, b)
|
||||
if ra != rb:
|
||||
parent[max(ra, rb)] = min(ra, rb) # smallest index = root (deterministic)
|
||||
|
||||
|
||||
def embed_sims(texts: list[str]):
|
||||
"""Texts → (n, n) cosine matrix · None = model not available (fallback)."""
|
||||
embs = embed(texts)
|
||||
if embs is None:
|
||||
return None
|
||||
return embs @ embs.T # (n, n) cosine, float32 (~2 MB at n=700)
|
||||
|
||||
|
||||
def capped_blocks(sims, floor: float | None = None, cap: int | None = None) -> list[list[int]]:
|
||||
"""Coarse similarity blocks for the LLM — high recall, but size-capped.
|
||||
|
||||
Greedy: all pairs with cosine ≥ `floor` in descending cosine order; two blocks are merged
|
||||
only if the resulting block stays ≤ `cap`. Prevents the giant component (pure threshold
|
||||
blocking would otherwise chain almost everything together) and keeps the LLM lists short.
|
||||
→ list of blocks (index lists), each node in exactly one block.
|
||||
"""
|
||||
fl = EMBEDDING_BLOCK_FLOOR if floor is None else floor
|
||||
cp = EMBEDDING_BLOCK_CAP if cap is None else cap
|
||||
n = len(sims)
|
||||
parent = list(range(n))
|
||||
size = [1] * n
|
||||
if n >= 2:
|
||||
iu = np.triu_indices(n, k=1)
|
||||
s = sims[iu]
|
||||
kept = np.where(s >= fl)[0]
|
||||
# highest cosine first → the tightest pairs form blocks first
|
||||
for k in kept[np.argsort(-s[kept])]:
|
||||
i, j = int(iu[0][k]), int(iu[1][k])
|
||||
ri, rj = _find(parent, i), _find(parent, j)
|
||||
if ri != rj and size[ri] + size[rj] <= cp:
|
||||
_union(parent, i, j)
|
||||
r = _find(parent, i)
|
||||
size[r] = size[ri] + size[rj]
|
||||
blocks: dict[int, list[int]] = {}
|
||||
for i in range(n):
|
||||
blocks.setdefault(_find(parent, i), []).append(i)
|
||||
return list(blocks.values())
|
||||
177
backend/fragen-vorrat-aak.md
Normal file
177
backend/fragen-vorrat-aak.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# Frage-Muster für Lern-Prüfung: aak
|
||||
|
||||
---
|
||||
|
||||
## BAUSTEIN: CliqueAndIndependentSet-Problem
|
||||
|
||||
### Subbaustein: Independent Set: Knotenmenge ohne Kanten zwischen je zwei Knoten
|
||||
**Muster:** Was ist die formale Definition eines Independent Set in einem Graphen G = (V, E)?
|
||||
|
||||
### Subbaustein: Komplementgraph (V, E) bildet IS und Clique aufeinander ab
|
||||
**Muster:** Wie hängt die Existenz einer Clique in G mit der Existenz eines Independent Set in G' (dem Komplementgraphen) zusammen?
|
||||
|
||||
### Subbaustein: Existenz von Independent Set der Größe k ist NP-vollständig
|
||||
**Muster:** Durch welche Polynomialzeitreduktion lässt sich zeigen, dass Independent Set NP-schwer ist?
|
||||
|
||||
### Subbaustein: Existenz von Clique der Größe k ist NP-vollständig
|
||||
**Muster:** Wie wird in Satz 6.26 die NP-Schwere von k-Clique bewiesen?
|
||||
|
||||
### Subbaustein: Reduktion k-CLIQUE ⪯ k-INDEPENDENT-SET mittels Komplementgraph
|
||||
**Muster:** Wie transformiert man eine Instanz (G, k) von CLIQUE in eine Instanz von INDEPENDENT-SET?
|
||||
|
||||
### Subbaustein: Clique: Knotenmenge, in der je zwei Knoten durch eine Kante verbunden sind
|
||||
**Muster:** Wann ist eine Knotenmenge C ⊆ V eine Clique in einem Graphen G = (V, E)?
|
||||
|
||||
### Subbaustein: Complementärgraph: Independent Set in G ist Clique in G̅
|
||||
**Muster:** Welche Beziehung besteht zwischen einem Independent Set in G und einer Clique in G̅?
|
||||
|
||||
### Subbaustein: Beide Probleme sind NP-vollständig
|
||||
**Muster:** Welche fundamentale Konsequenz ergibt sich aus Satz 6.16, wenn ein NP-vollständiges Problem in P liegt?
|
||||
|
||||
### Subbaustein: Frage: Existiert Independent Set bzw. Clique der Größe k in G?
|
||||
**Muster:** Was ist die Eingabe und was die Ausgabe bei den Entscheidungsproblemen k-Clique und k-Independent-Set?
|
||||
|
||||
### Subbaustein: Komplementarität: Independent Set in G = Clique im Komplementgraphen
|
||||
**Muster:** Warum sind Clique und Independent Set gegenseitig in Polynomialzeit aufeinander reduzierbar?
|
||||
|
||||
### Subbaustein: CLIQUE: Knotenmenge in der je zwei Knoten durch Kante verbunden sind
|
||||
**Muster:** Welche Bedingung müssen alle Knotenpaare einer Clique erfüllen?
|
||||
|
||||
### Subbaustein: Komplementär: IS in G = CLIQUE in G̅
|
||||
**Muster:** Was bleibt bei der Bildung des Komplementgraphen G̅ gleich und was ändert sich?
|
||||
|
||||
### Subbaustein: Existenz von IS bzw. CLIQUE der Größe k ist NP-vollständig
|
||||
**Muster:** Welche untere Schranke für die Laufzeit von Algorithmen für Independent Set folgt aus der ETH?
|
||||
|
||||
### Subbaustein: Formale Sprachen: CLIQUE = {(G,k) | G enthält Clique ≥ k}
|
||||
**Muster:** Wie ist die formale Sprache CLIQUE über dem Alphabet Σ = {0, 1} kodiert?
|
||||
|
||||
### Subbaustein: Formale Sprachen: INDEPENDENT-SET = {(G,k) | G enthält IS ≥ k}
|
||||
**Muster:** Welche Struktur hat die formale Sprache INDEPENDENT-SET?
|
||||
|
||||
### Subbaustein: Komplementgraph G̅: Kantenmenge E̅ = {(u,v) | u≠v und {u,v} ∉ E}
|
||||
**Muster:** Wie unterscheiden sich die Kantenmengen von G und seinem Komplementgraphen G̅?
|
||||
|
||||
### Subbaustein: K-Clique in G entspricht K-Unabhängige-Menge in G̅
|
||||
**Muster:** Bleibt die Größe k bei der Reduktion von k-Clique auf k-Independent-Set erhalten?
|
||||
|
||||
### Subbaustein: Beide Probleme sind in NP (Verifizierer existiert)
|
||||
**Muster:** Welche Eigenschaft müssen Zertifikat und Verifizierer für ein Problem in NP erfüllen?
|
||||
|
||||
### Subbaustein: NP-vollständig via gegenseitige Reduktion über Komplementgraph
|
||||
**Muster:** Wie folgt aus Korollar 6.18 die NP-Vollständigkeit von Independent Set?
|
||||
|
||||
---
|
||||
|
||||
## BAUSTEIN: Reduktion CLIQUE → CLIQUE-NOMEMBER
|
||||
|
||||
### Subbaustein: Füge isolierten Knoten v zu G hinzu: G' = G ∪ {v}
|
||||
**Muster:** Wie wird bei der Reduktion von CLIQUE auf CLIQUE-NOMEMBER der neue Graph G' konstruiert?
|
||||
|
||||
### Subbaustein: G hat k-Clique ⟺ G' hat (k+1)-Clique mit v
|
||||
**Muster:** Warum kann der hinzugefügte Knoten v in keiner gültigen k-Clique von G' enthalten sein?
|
||||
|
||||
### Subbaustein: Reduktion beweist CLIQUE-NOMEMBER ∈ NP-vollständig
|
||||
**Muster:** Welche drei Bedingungen müssen erfüllt sein, damit CLIQUE-NOMEMBER als NP-vollständig gilt?
|
||||
|
||||
### Subbaustein: CLIQUE: Eingabe Graph G, Frage: existiert K-clique?
|
||||
**Muster:** Was ist die Eingabe und was ist die Frage beim Entscheidungsproblem CLIQUE?
|
||||
|
||||
### Subbaustein: CLIQUE-NOMEMBER: existiert Knoten der nicht in jeder maximalen Clique liegt?
|
||||
**Muster:** Wie ist das Problem CLIQUE-NOMEMBER gemäß Skript 6.50 formal definiert?
|
||||
|
||||
### Subbaustein: Reduktion: isolierten Knoten v zu G hinzufügen → G'
|
||||
**Muster:** Welche Elemente werden bei der Reduktion von CLIQUE auf CLIQUE-NOMEMBER gegenüber der ursprünglichen Instanz verändert?
|
||||
|
||||
### Subbaustein: G' hat Clique der Größe k genau dann wenn G eine hat
|
||||
**Muster:** Warum bleibt die Cliquengröße k bei der Reduktion unverändert?
|
||||
|
||||
### Subbaustein: v ist in G' in keiner k-Clique (isoliert)
|
||||
**Muster:** Welche Eigenschaft hat der Knoten v in der konstruierten Instanz (G', v, k)?
|
||||
|
||||
### Subbaustein: G hat Clique der Größe k ⟺ G' hat Clique der Größe k ohne v
|
||||
**Muster:** Wie hängt eine k-Clique in G mit einer k-Clique in G' zusammen?
|
||||
|
||||
### Subbaustein: Polynomielle Transformation
|
||||
**Muster:** Warum ist die beschriebene Reduktion in polynomieller Zeit berechenbar?
|
||||
|
||||
---
|
||||
|
||||
## BAUSTEIN: Independent Set
|
||||
|
||||
### Subbaustein: Für alle u, v ∈ S gilt: {u, v} ∉ E
|
||||
**Muster:** Welche Bedingung muss für je zwei Knoten eines Independent Set gelten?
|
||||
|
||||
### Subbaustein: Komplementär zur Clique
|
||||
**Muster:** Wie hängt ein Independent Set in G mit einer Clique im Komplementgraphen G' zusammen?
|
||||
|
||||
### Subbaustein: NP-vollständiges Problem
|
||||
**Muster:** Welche Komplexitätsklasse enthält Independent Set und wie wurde dies bewiesen?
|
||||
|
||||
### Subbaustein: Knotenmenge ohne Kanten zwischen je zwei Knoten der Menge
|
||||
**Muster:** Was bedeutet es, dass die Knoten eines Independent Set paarweise nicht adjazent sind?
|
||||
|
||||
### Subbaustein: Komplementär zum Clique-Problem
|
||||
**Muster:** In welchem Graphen entspricht ein Independent Set einer Clique?
|
||||
|
||||
### Subbaustein: Independent Set S⊆V: keine Kante zwischen je zwei Knoten in S
|
||||
**Muster:** Wie unterscheidet sich ein Independent Set von einer beliebigen Teilmenge von V?
|
||||
|
||||
### Subbaustein: INDEPENDENT-SET = {(G,k) | G enthält unabhängige Menge der Größe ≥k}
|
||||
**Muster:** Welche Sprache formalisiert das Entscheidungsproblem Independent Set?
|
||||
|
||||
---
|
||||
|
||||
## BAUSTEIN: Tiefensuche (DFS) für Zykluserkennung
|
||||
|
||||
### Subbaustein: Weiß: unbesuchter Knoten, Grau: aktuell in Bearbeitung, Schwarz: fertig
|
||||
**Muster:** Welche Farbe hat ein Knoten während er von der DFS bearbeitet wird und welche nach Abschluss?
|
||||
|
||||
### Subbaustein: DFS-Zykluserkennung in O(V+E) bei adjacency List
|
||||
**Muster:** Warum beträgt die Laufzeit der DFS-Zykluserkennung bei Adjazenzliste Θ(|V|+|E|)?
|
||||
|
||||
### Subbaustein: Schwarz: vollständig bearbeiteter Knoten
|
||||
**Muster:** Wann wird ein Knoten in der DFS schwarz gefärbt?
|
||||
|
||||
### Subbaustein: Rückkante (grau → weiß): signalisiert Zyklus
|
||||
**Muster:** Zu einem Knoten welcher Farbe muss eine Kante führen, um einen Zyklus anzuzeigen?
|
||||
|
||||
### Subbaustein: Tree Edge (weiß): Kante zu unbesuchtem Knoten
|
||||
**Muster:** Welche Kante wird als Tree Edge bezeichnet?
|
||||
|
||||
### Subbaustein: Grau: aktuell in Bearbeitung (in DFS-Weite)
|
||||
**Muster:** Was bedeutet es, wenn ein Knoten während der DFS grau gefärbt ist?
|
||||
|
||||
---
|
||||
|
||||
## BAUSTEIN: Turingmaschine für 0^n (Zweierpotenz)
|
||||
|
||||
### Subbaustein: Eingabe: n Nullen in unärer Codierung
|
||||
**Muster:** In welcher Codierung wird die Eingabezahl n der TM für 0^n dargestellt?
|
||||
|
||||
### Subbaustein: Akzeptiert nur wenn n = 2^k für ein k ≥ 0
|
||||
**Muster:** Nach welchem Kriterium entscheidet die TM, ob eine Eingabe akzeptiert wird?
|
||||
|
||||
### Subbaustein: Phase 1: Markiere jede zweite 0 mit x (alternierend)
|
||||
**Muster:** Wie markiert die TM die Nullen im ersten Schritt?
|
||||
|
||||
---
|
||||
|
||||
## BAUSTEIN: 3-SAT zu 3-Färbung Reduktion
|
||||
|
||||
### Subbaustein: Knotenzahl linear in Variablen und Klauseln
|
||||
**Muster:** Aus welchen Komponenten setzt sich die Knotenmenge V der konstruierten Instanz zusammen?
|
||||
|
||||
### Subbaustein: Dreieck erzwingt drei verschiedene Farben für die drei Knoten
|
||||
**Muster:** Warum benötigen die drei Knoten xi, x̄i und vi eines jeden Dreiecks drei verschiedene Farben?
|
||||
|
||||
---
|
||||
|
||||
## BAUSTEIN: MC-Knapsack
|
||||
|
||||
### Subbaustein: Ziel: Maximierung des Gesamtwerts
|
||||
**Muster:** Was ist die Zielfunktion beim Maximum-Cut Knapsack Problem?
|
||||
|
||||
---
|
||||
|
||||
**Gesamt: 48 Frage-Muster**
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Atomare Datei-Writes: erst .tmp im selben Verzeichnis, dann os.replace.
|
||||
"""Atomic file writes: first a .tmp in the same directory, then os.replace.
|
||||
|
||||
Ein Crash hinterlässt höchstens eine .tmp-Datei — nie eine halb geschriebene
|
||||
Zieldatei. Die .tmp wird beim nächsten erfolgreichen Write überschrieben.
|
||||
A crash leaves at most a .tmp file behind — never a half-written target
|
||||
file. The .tmp is overwritten on the next successful write.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
1030
backend/guide.py
1030
backend/guide.py
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
"""Toleranter JSON-Parser für KI-Output — als Text oder aus Dateien.
|
||||
"""Tolerant JSON parser for AI output — from text or from files.
|
||||
|
||||
Verkraftet Code-Fences, Drumherum-Text und unescapte Anführungszeichen in
|
||||
Strings (z. B. MiniMax: "Titel „p" geändert"): das letzte `"` vor der
|
||||
Fehlerstelle wird escapet und erneut geparst.
|
||||
Copes with code fences, surrounding prose and unescaped quotes inside
|
||||
strings (e.g. MiniMax: "Title „p" changed"): the last `"` before the
|
||||
error position is escaped and parsing is retried.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -14,7 +14,7 @@ log = logging.getLogger("creator.jsonio")
|
||||
|
||||
|
||||
def parse_json_text(text: str):
|
||||
"""Parst JSON aus KI-Output; None bei nicht reparierbarem Input."""
|
||||
"""Parse JSON from AI output; None for input that can't be repaired."""
|
||||
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", (text or "").strip())
|
||||
start, end = text.find("{"), text.rfind("}")
|
||||
if start == -1 or end <= start:
|
||||
@@ -36,14 +36,14 @@ def parse_json_text(text: str):
|
||||
|
||||
|
||||
def read_json_file(path: Path):
|
||||
"""Liest eine JSON-Datei mit derselben Toleranz; None bei fehlend/ungültig."""
|
||||
"""Read a JSON file with the same tolerance; None if missing/invalid."""
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
data = parse_json_text(path.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
log.debug("JSON-Datei nicht lesbar: %s (%s)", path, e)
|
||||
log.debug("JSON file not readable: %s (%s)", path, e)
|
||||
return None
|
||||
if data is None:
|
||||
log.debug("JSON-Datei ungültig: %s", path)
|
||||
log.debug("JSON file invalid: %s", path)
|
||||
return data
|
||||
|
||||
748
backend/kanban.py
Normal file
748
backend/kanban.py
Normal file
@@ -0,0 +1,748 @@
|
||||
"""Streaming kanban dataflow for the inventory phase.
|
||||
|
||||
Each column is a worker that pulls cards from its input `stage` (the queue), processes up to
|
||||
KANBAN_BATCH at a time, and advances them to the next stage. Cards: titles → chains → blocks.
|
||||
|
||||
Streaming columns run continuously; barrier columns start only at QUIESCENCE of everything before
|
||||
them (no active worker + empty queues). Verify columns push failures back (rework). The Chain column
|
||||
re-clusters live: a chain that gains a member is marked dirty and flows back to chain_verify.
|
||||
|
||||
Reused from blocks.py (imported lazily-safe — kanban is only imported after blocks is loaded):
|
||||
embedding clustering, `_pairs_schema`/`_cliques`, `_canonical`, research prompt + file payload.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
|
||||
import database as db
|
||||
import embedding
|
||||
import blocks
|
||||
from config import RESEARCH_GRACE, MAX_CONCURRENT_AGENTS_PER_TOPIC
|
||||
from pipeline import GenContext, run_single_slot, _prompt, _timeout, _log, OK
|
||||
from textkit import _norm_title, _title, _parse_selection
|
||||
from jsonio import read_json_file as _json_file
|
||||
|
||||
KANBAN_BATCH = 5 # cards a worker pulls per package (micro-batching)
|
||||
# How many packages ONE worker keeps in flight at once. A worker no longer blocks on a single
|
||||
# package — it keeps pulling and dispatching until this many run concurrently, so a busy column
|
||||
# fills the agent slots (the per-topic semaphore is the real cap; over-dispatch just queues cheaply).
|
||||
WORKER_INFLIGHT = MAX_CONCURRENT_AGENTS_PER_TOPIC
|
||||
# Stages whose processor mutates shared cross-card state and MUST run one package at a time.
|
||||
# Online chain-clustering reads the whole universe + membership; parallel packages would race.
|
||||
_SERIAL_STAGES = {"chain"}
|
||||
_ID_COL = {"kanban_titles": "title_norm", "kanban_chains": "chain_id", "kanban_blocks": "block_id"}
|
||||
CHAIN_CAP = 12 # max members per chain — caps the O(n²) pair-verification blow-up
|
||||
_POLL = 0.3 # seconds between empty-queue polls
|
||||
|
||||
# Stage order. A card's `stage` = the column it waits in (its worker's input).
|
||||
TITLE_STAGES = ["merge", "chain", "chained"] # 'chained' = consumed into a chain
|
||||
CHAIN_STAGES = ["chain_verify", "naming", "naming_verify", "chain_filter", "filter_verify", "block_assemble"]
|
||||
BLOCK_STAGES = ["small_blocks", "small_verify", "dependency", "dependency_verify", "main"]
|
||||
DONE_CHAIN = "done_chain"
|
||||
DONE_BLOCK = "done_block"
|
||||
REJECTED = "rejected" # block dropped by filter_verify (off-topic / noise) — terminal, never mirrored
|
||||
|
||||
# Predecessor stages for each barrier (must ALL be quiescent before the barrier worker runs).
|
||||
_BEFORE_CHAIN_FILTER = ["merge", "chain", "chain_verify", "naming", "naming_verify"]
|
||||
_BEFORE_BLOCK = _BEFORE_CHAIN_FILTER + ["chain_filter", "filter_verify"]
|
||||
_BEFORE_MAIN = ["small_blocks", "small_verify", "dependency", "dependency_verify"]
|
||||
|
||||
|
||||
class _Flow:
|
||||
"""Shared runtime state: active-task counters per stage + a wakeup event. `producers` counts the
|
||||
running research agents (initial + any added live via the generate button); research counts as
|
||||
done only when ALL producers have finished, so the flow stays awake while extras still search."""
|
||||
def __init__(self, topic: str, work_dir):
|
||||
self.topic = topic
|
||||
self.work_dir = work_dir
|
||||
self.active: dict[str, int] = {}
|
||||
self.producers = 1 # the initial research agent
|
||||
self.research_tag = 0
|
||||
self.stop = False
|
||||
self.wake = asyncio.Event()
|
||||
self.spawn_research = None # set by run_kanban: () → coroutine that adds one more research agent
|
||||
|
||||
@property
|
||||
def research_done(self) -> bool:
|
||||
return self.producers <= 0
|
||||
|
||||
def add_producer(self):
|
||||
self.producers += 1
|
||||
self.wake.set()
|
||||
|
||||
def done_producer(self):
|
||||
self.producers -= 1
|
||||
self.wake.set()
|
||||
|
||||
def next_tag(self) -> int:
|
||||
self.research_tag += 1
|
||||
return self.research_tag
|
||||
|
||||
def enter(self, stage: str):
|
||||
self.active[stage] = self.active.get(stage, 0) + 1
|
||||
|
||||
def leave(self, stage: str):
|
||||
self.active[stage] = max(0, self.active.get(stage, 0) - 1)
|
||||
self.wake.set()
|
||||
|
||||
def active_in(self, stages) -> bool:
|
||||
return any(self.active.get(s, 0) > 0 for s in stages)
|
||||
|
||||
async def queued_in(self, table: str, stages) -> bool:
|
||||
return await db.kanban_count(self.topic, table, list(stages)) > 0
|
||||
|
||||
|
||||
# ── Research producer ────────────────────────────────────────────────────────────
|
||||
async def _ingest_titles(topic: str, text: str) -> int:
|
||||
"""Parse a reader file into kanban_titles (stage 'merge'). Exact dupes drop on the PK. → new count."""
|
||||
n, seen = 0, set()
|
||||
for record in _parse_selection(text).values():
|
||||
title = _title(record)
|
||||
norm = _norm_title(title)
|
||||
if not norm or norm in seen:
|
||||
continue
|
||||
seen.add(norm)
|
||||
parts = [t.strip() for t in record.split(" — ")]
|
||||
source = parts[2] if len(parts) >= 3 else ""
|
||||
desc = parts[1] if len(parts) >= 2 else ""
|
||||
if await db.kanban_add_title(topic, norm, title, source, desc):
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
RESEARCH_RUNTIME = 900 # one research agent, one round, ~15 min hard cap — the tail ingests live while it writes
|
||||
_POLL_RESEARCH = 3 # seconds between live reads of a running research file
|
||||
|
||||
# Live registry of running flows, so the "+ research" button can attach another agent to a live run.
|
||||
_active_flows: dict[str, "_Flow"] = {}
|
||||
|
||||
|
||||
def _extract_text(raw_line: str) -> str:
|
||||
"""Best-effort: pull assistant/tool text out of ONE opencode `--format json` event line.
|
||||
Recursively collects every `text`/`content` string — robust to the exact event schema."""
|
||||
try:
|
||||
obj = json.loads(raw_line)
|
||||
except Exception:
|
||||
return ""
|
||||
parts: list[str] = []
|
||||
def _walk(o):
|
||||
if isinstance(o, dict):
|
||||
for k, v in o.items():
|
||||
if k in ("text", "content") and isinstance(v, str):
|
||||
parts.append(v)
|
||||
else:
|
||||
_walk(v)
|
||||
elif isinstance(o, list):
|
||||
for v in o:
|
||||
_walk(v)
|
||||
_walk(obj)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
async def _research_once(ctx: GenContext, files: dict, q: dict, folder, instructions: str, tag: str, flow: "_Flow"):
|
||||
"""ONE agent searches the topic; its titles go into the merge queue LIVE. Two sources feed the
|
||||
ingest: the JSON event stream (on_line → text buffer) AND the file the agent writes — whichever
|
||||
the agent uses, cards stream in immediately (not only after it finishes)."""
|
||||
work_dir = files["arbeit"]
|
||||
caps = "files" if folder else "full"
|
||||
p = work_dir / f"research-{tag}.md"
|
||||
p.unlink(missing_ok=True)
|
||||
stop = asyncio.Event()
|
||||
buf: list[str] = [] # assistant text streamed live from the JSON events
|
||||
|
||||
def _on_line(raw: str): # sync, called per stdout line by the agent runner
|
||||
if (t := _extract_text(raw)):
|
||||
buf.append(t)
|
||||
|
||||
async def _drain() -> bool: # ingest from BOTH event buffer and file (idempotent, dupes drop on PK)
|
||||
text = "".join(buf)
|
||||
if (ft := blocks._file_payload(p)):
|
||||
text += "\n" + ft
|
||||
return bool(text) and await _ingest_titles(ctx.topic, text)
|
||||
|
||||
async def _tail(): # live-ingest loop while the agent runs
|
||||
while not stop.is_set():
|
||||
try:
|
||||
await asyncio.wait_for(stop.wait(), timeout=_POLL_RESEARCH)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
if await _drain():
|
||||
flow.wake.set() # new cards → wake the workers
|
||||
|
||||
tail = asyncio.create_task(_tail())
|
||||
try:
|
||||
await run_single_slot(
|
||||
ctx, f"research-{tag}", key=f"blocks-{ctx.topic}-research-{tag}",
|
||||
prompt=blocks._build_research_prompt(ctx.topic, p, instructions, q["type"], folder),
|
||||
role="quick", capabilities=caps,
|
||||
payload=(lambda result, p=p: blocks._file_payload(p)),
|
||||
timeout=RESEARCH_RUNTIME, on_line=_on_line,
|
||||
)
|
||||
finally:
|
||||
stop.set()
|
||||
await tail
|
||||
if await _drain(): # final catch-up
|
||||
flow.wake.set()
|
||||
_log(ctx.topic, f"Research {tag}: titles → merge queue")
|
||||
|
||||
|
||||
async def _research(ctx: GenContext, files: dict, q: dict, folder, instructions: str, flow: _Flow):
|
||||
"""The initial research producer (already counted in flow.producers=1)."""
|
||||
try:
|
||||
await _research_once(ctx, files, q, folder, instructions, "1", flow)
|
||||
finally:
|
||||
flow.done_producer()
|
||||
|
||||
|
||||
async def _extra_research(ctx: GenContext, files: dict, q: dict, folder, instructions: str, flow: _Flow):
|
||||
"""One more research agent, added live via the generate button. Keeps the flow awake until done."""
|
||||
flow.add_producer()
|
||||
try:
|
||||
await _research_once(ctx, files, q, folder, instructions, f"x{flow.next_tag()}", flow)
|
||||
finally:
|
||||
flow.done_producer()
|
||||
|
||||
|
||||
def add_research_agent(topic: str) -> bool:
|
||||
"""Attach one more research agent to a running flow. → True if a run was live to attach to."""
|
||||
flow = _active_flows.get(topic)
|
||||
if flow is None or flow.stop or flow.spawn_research is None:
|
||||
return False
|
||||
asyncio.create_task(flow.spawn_research())
|
||||
return True
|
||||
|
||||
|
||||
# ── Generic worker loop ────────────────────────────────────────────────────────────
|
||||
async def _quiescent(flow: _Flow, stages) -> bool:
|
||||
"""True iff no worker is active in `stages` AND no card is queued in any of them (all tables).
|
||||
The barrier/exit condition — must include QUEUED cards, not just active workers, or a worker
|
||||
could exit in a momentary lull while an upstream worker still has work to push down."""
|
||||
if not stages:
|
||||
return True
|
||||
if flow.active_in(stages):
|
||||
return False
|
||||
for tb in ("kanban_titles", "kanban_chains", "kanban_blocks"):
|
||||
if await db.kanban_count(flow.topic, tb, list(stages)):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def _worker(flow: _Flow, table: str, in_stage: str, process, upstream, *, barrier=False, inflight=WORKER_INFLIGHT):
|
||||
"""Pull cards from `in_stage`, run `process` — keeping up to `inflight` packages running CONCURRENTLY
|
||||
so a busy column fills the agent slots instead of doing one package at a time. `upstream` = all stages
|
||||
before this one. A barrier worker only pulls when `upstream` is fully quiescent. ANY worker exits only
|
||||
when research is done, its own queue is empty, AND `upstream` is quiescent (nothing can still arrive).
|
||||
|
||||
Double-pull safety: each stage has exactly ONE worker, so an in-memory `claimed` set of card-ids (held
|
||||
while a package runs) is enough to keep concurrent pulls from grabbing the same cards."""
|
||||
topic = flow.topic
|
||||
idc = _ID_COL[table]
|
||||
claimed: set[str] = set()
|
||||
tasks: set[asyncio.Task] = set()
|
||||
|
||||
async def _run(cards):
|
||||
ids = [c[idc] for c in cards]
|
||||
flow.enter(in_stage)
|
||||
try:
|
||||
await process(cards)
|
||||
except Exception as e: # one bad package must not kill the worker
|
||||
_log(topic, f"worker {in_stage}: {type(e).__name__}: {e}")
|
||||
finally:
|
||||
flow.leave(in_stage)
|
||||
for i in ids:
|
||||
claimed.discard(i)
|
||||
flow.wake.set()
|
||||
|
||||
try:
|
||||
while not flow.stop:
|
||||
tasks = {t for t in tasks if not t.done()}
|
||||
# Fill the pipeline: pull fresh cards and dispatch until `inflight` packages run.
|
||||
if not barrier or await _quiescent(flow, upstream):
|
||||
while len(tasks) < inflight:
|
||||
rows = await db.kanban_pull(topic, table, in_stage, KANBAN_BATCH + len(claimed))
|
||||
fresh = [r for r in rows if r[idc] not in claimed][:KANBAN_BATCH]
|
||||
if not fresh:
|
||||
break
|
||||
for r in fresh:
|
||||
claimed.add(r[idc])
|
||||
tasks.add(asyncio.create_task(_run(list(fresh))))
|
||||
if tasks: # busy → wait for a package to finish, then refill
|
||||
await asyncio.wait(tasks, timeout=_POLL, return_when=asyncio.FIRST_COMPLETED)
|
||||
continue
|
||||
# idle: nothing in flight and nothing pulled
|
||||
up_quiet = await _quiescent(flow, upstream)
|
||||
if (flow.research_done and up_quiet and not flow.active_in([in_stage])
|
||||
and await db.kanban_count(topic, table, in_stage) == 0):
|
||||
return # nothing left and nothing upstream can produce
|
||||
await _sleep_wake(flow)
|
||||
finally:
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
|
||||
async def _sleep_wake(flow: _Flow):
|
||||
try:
|
||||
await asyncio.wait_for(flow.wake.wait(), timeout=_POLL)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
flow.wake.clear()
|
||||
|
||||
|
||||
# ── Column processors ──────────────────────────────────────────────────────────────
|
||||
async def _proc_merge(flow: _Flow, cards):
|
||||
"""Exact dedup happened at ingest (PK). Merge just advances titles to the chain column."""
|
||||
for c in cards:
|
||||
await db.kanban_advance(flow.topic, "kanban_titles", c["title_norm"], "chain")
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
async def _proc_chain(flow: _Flow, cards):
|
||||
"""Embedding blocking: for each new title, find the most similar existing title (cosine ≥ floor).
|
||||
Join its chain (or open a new one), mark the chain dirty → chain_verify. Live-growing clusters."""
|
||||
topic = flow.topic
|
||||
by_norm = await db.kanban_titles_by_norm(topic)
|
||||
# Universe = titles already chained + the new batch (for nearest-neighbour search).
|
||||
universe = [nm for nm, r in by_norm.items() if r["stage"] in ("chain", "chained")]
|
||||
if len(universe) < 1:
|
||||
return
|
||||
texts = [f"{by_norm[nm]['title']} — {by_norm[nm]['content']}" if by_norm[nm]["content"] else by_norm[nm]["title"]
|
||||
for nm in universe]
|
||||
sims = await asyncio.to_thread(embedding.embed_sims, texts) if (
|
||||
embedding and await asyncio.to_thread(embedding.available)) else None
|
||||
idx = {nm: i for i, nm in enumerate(universe)}
|
||||
# existing membership
|
||||
member_chain = {}
|
||||
for nm in universe:
|
||||
cid = await _chain_of(topic, nm)
|
||||
if cid:
|
||||
member_chain[nm] = cid
|
||||
touched = set()
|
||||
for c in cards:
|
||||
nm = c["title_norm"]
|
||||
target = None
|
||||
if sims is not None and nm in idx:
|
||||
best, bestcos = None, blocks.DEDUP_PAIR_FLOOR
|
||||
for other in universe:
|
||||
if other == nm or other not in member_chain and other not in idx:
|
||||
continue
|
||||
cos = float(sims[idx[nm]][idx[other]]) if other in idx else -1
|
||||
if cos >= bestcos and other != nm:
|
||||
best, bestcos = other, cos
|
||||
if best is not None:
|
||||
target = member_chain.get(best)
|
||||
cid = target or f"c-{uuid.uuid4().hex[:12]}"
|
||||
members = set(await db.kanban_chain_members(topic, cid))
|
||||
if target and len(members) >= CHAIN_CAP: # neighbour's chain is full → start a fresh chain
|
||||
cid = f"c-{uuid.uuid4().hex[:12]}"
|
||||
members = set()
|
||||
members.add(nm)
|
||||
await db.kanban_set_chain_members(topic, cid, sorted(members))
|
||||
await db.kanban_upsert_chain(topic, cid, "chain_verify", dirty=1)
|
||||
member_chain[nm] = cid
|
||||
await db.kanban_advance(topic, "kanban_titles", nm, "chained")
|
||||
touched.add(cid)
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
async def _chain_of(topic: str, title_norm: str) -> str | None:
|
||||
return await db.kanban_member_chain(topic, title_norm)
|
||||
|
||||
|
||||
async def _members_dicts(topic: str, members: list[str]) -> list[dict]:
|
||||
by = await db.kanban_titles_by_norm(topic)
|
||||
return [by[m] for m in members if m in by]
|
||||
|
||||
|
||||
async def _proc_chain_verify(ctx: GenContext, flow: _Flow, cards):
|
||||
"""Pairwise-verify the batch's chains IN PARALLEL (one agent per chain). Failures split off."""
|
||||
await asyncio.gather(*[_verify_one(ctx, flow, c) for c in cards], return_exceptions=True)
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
async def _verify_one(ctx: GenContext, flow: _Flow, c):
|
||||
topic = flow.topic
|
||||
cid = c["chain_id"]
|
||||
members = await db.kanban_chain_members(topic, cid)
|
||||
dicts = await _members_dicts(topic, members)
|
||||
if len(dicts) <= 1:
|
||||
await db.kanban_upsert_chain(topic, cid, "naming", dirty=0)
|
||||
return
|
||||
# Only the embedding-NEAR candidate pairs (cosine ≥ floor) — NOT all O(n²) pairs. A 12-member
|
||||
# chain shrinks from 66 pairs to a handful. Transitivity (connected components) does the rest.
|
||||
nm = [d["title_norm"] for d in dicts]
|
||||
texts = [f"{d['title']} — {d['content']}" if d["content"] else d["title"] for d in dicts]
|
||||
sims = await asyncio.to_thread(embedding.embed_sims, texts) if (
|
||||
embedding and await asyncio.to_thread(embedding.available)) else None
|
||||
if sims is not None:
|
||||
pairs = [(nm[i], nm[j]) for i in range(len(nm)) for j in range(i + 1, len(nm))
|
||||
if float(sims[i][j]) >= blocks.DEDUP_PAIR_FLOOR]
|
||||
else:
|
||||
pairs = [(a, b) for x, a in enumerate(members) for b in members[x + 1:]]
|
||||
keep_edges = await _verify_pairs(ctx, flow.work_dir, topic, cid, dicts, pairs)
|
||||
groups = _components(members, keep_edges) # transitive groups over confirmed near-pairs
|
||||
groups.sort(key=len, reverse=True)
|
||||
main = groups[0] if groups else members
|
||||
await db.kanban_set_chain_members(topic, cid, sorted(main))
|
||||
await db.kanban_upsert_chain(topic, cid, "naming", dirty=0)
|
||||
for g in groups[1:]: # the rest split into fresh chains
|
||||
ncid = f"c-{uuid.uuid4().hex[:12]}"
|
||||
await db.kanban_set_chain_members(topic, ncid, sorted(g))
|
||||
await db.kanban_upsert_chain(topic, ncid, "naming", dirty=0)
|
||||
|
||||
|
||||
def _components(members: list[str], edges) -> list[list[str]]:
|
||||
"""Connected components (union-find) over confirmed pairs. Members without an edge stay alone."""
|
||||
parent = {m: m for m in members}
|
||||
|
||||
def find(x):
|
||||
while parent[x] != x:
|
||||
parent[x] = parent[parent[x]]
|
||||
x = parent[x]
|
||||
return x
|
||||
|
||||
for a, b in edges:
|
||||
if a in parent and b in parent:
|
||||
parent[find(a)] = find(b)
|
||||
comp: dict[str, list[str]] = {}
|
||||
for m in members:
|
||||
comp.setdefault(find(m), []).append(m)
|
||||
return list(comp.values())
|
||||
|
||||
|
||||
async def _verify_pairs(ctx, work_dir, topic, cid, dicts, pairs):
|
||||
"""Judge the candidate pairs in DEDUP_PAIRS_CHUNK packages, all packages IN PARALLEL → confirmed edges."""
|
||||
by = {d["title_norm"]: d for d in dicts}
|
||||
chunks = [pairs[k:k + blocks.DEDUP_PAIRS_CHUNK] for k in range(0, len(pairs), blocks.DEDUP_PAIRS_CHUNK)]
|
||||
|
||||
async def _chunk(ci, chunk):
|
||||
path = work_dir / f"verify-{cid}-{ci}.json"
|
||||
lines = "\n\n".join(
|
||||
f"{j + 1}.\nA: {by[a]['title']} — {by[a]['content']}\nB: {by[b]['title']} — {by[b]['content']}"
|
||||
for j, (a, b) in enumerate(chunk))
|
||||
await run_single_slot(
|
||||
ctx, f"Chain verify {cid}", key=f"blocks-{topic}-verify-{cid}-{ci}",
|
||||
prompt=_prompt("Blocks-Paar-Filter", topic=topic, pairs=lines, out_path=path),
|
||||
role="judge", capabilities="files",
|
||||
payload=lambda result, p=path: blocks._pairs_schema(_json_file(p)),
|
||||
timeout=_timeout("selection_mapping", len(chunk)))
|
||||
verdict = blocks._pairs_schema(_json_file(path)) or {}
|
||||
return [(a, b) for j, (a, b) in enumerate(chunk) if verdict.get(j + 1)]
|
||||
|
||||
results = await asyncio.gather(*[_chunk(ci, ch) for ci, ch in enumerate(chunks)], return_exceptions=True)
|
||||
return [e for r in results if isinstance(r, list) for e in r]
|
||||
|
||||
|
||||
async def _proc_naming(ctx: GenContext, flow: _Flow, cards):
|
||||
"""Pick the best member title per chain — batch runs IN PARALLEL (one agent per chain)."""
|
||||
await asyncio.gather(*[_name_one(ctx, flow, c) for c in cards], return_exceptions=True)
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
async def _name_one(ctx: GenContext, flow: _Flow, c):
|
||||
topic = flow.topic
|
||||
cid = c["chain_id"]
|
||||
members = await db.kanban_chain_members(topic, cid)
|
||||
dicts = await _members_dicts(topic, members)
|
||||
if len(dicts) <= 1:
|
||||
await db.kanban_upsert_chain(topic, cid, "naming_verify",
|
||||
main_title_norm=(members[0] if members else None), dirty=0)
|
||||
return
|
||||
winner = await _choose_title(ctx, flow.work_dir, topic, cid, members, dicts, "Blocks-Naming")
|
||||
await db.kanban_upsert_chain(topic, cid, "naming_verify", main_title_norm=winner, dirty=0)
|
||||
|
||||
|
||||
async def _proc_naming_verify(ctx: GenContext, flow: _Flow, cards):
|
||||
"""Second judge checks each title — batch runs IN PARALLEL."""
|
||||
await asyncio.gather(*[_namecheck_one(ctx, flow, c) for c in cards], return_exceptions=True)
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
async def _namecheck_one(ctx: GenContext, flow: _Flow, c):
|
||||
topic = flow.topic
|
||||
cid = c["chain_id"]
|
||||
members = await db.kanban_chain_members(topic, cid)
|
||||
dicts = await _members_dicts(topic, members)
|
||||
winner = c.get("main_title_norm") or (members[0] if members else None)
|
||||
if len(dicts) > 1:
|
||||
winner = await _choose_title(ctx, flow.work_dir, topic, cid, members, dicts, "Blocks-Naming-Check",
|
||||
current=(members.index(winner) + 1 if winner in members else 1))
|
||||
await db.kanban_upsert_chain(topic, cid, "chain_filter", main_title_norm=winner, dirty=0)
|
||||
|
||||
|
||||
async def _choose_title(ctx, work_dir, topic, cid, members, dicts, template, current=None):
|
||||
by = {d["title_norm"]: d for d in dicts}
|
||||
path = work_dir / f"naming-{cid}.json"
|
||||
lines = "\n".join(f"{k + 1}. {by[m]['title']} — {by[m]['content']}" for k, m in enumerate(members) if m in by)
|
||||
kw = dict(topic=topic, members=lines, out_path=path)
|
||||
if current is not None:
|
||||
kw["current"] = current
|
||||
await run_single_slot(
|
||||
ctx, f"Naming {cid}", key=f"blocks-{topic}-naming-{cid}",
|
||||
prompt=_prompt(template, **kw), role="judge", capabilities="files",
|
||||
payload=lambda result, p=path: blocks._naming_schema(_json_file(p), len(members)),
|
||||
timeout=_timeout("selection_mapping", len(members)))
|
||||
best = blocks._naming_schema(_json_file(path), len(members))
|
||||
if best is None:
|
||||
rep = blocks._canonical(dicts, list(range(len(dicts))), set())
|
||||
w = _norm_title(rep["title"])
|
||||
return w if w in members else members[0]
|
||||
return members[best - 1]
|
||||
|
||||
|
||||
async def _proc_chain_filter(flow: _Flow, cards):
|
||||
"""BARRIER. Reduce each chain to its winner → upsert a block (chain_id stable). → filter_verify."""
|
||||
topic = flow.topic
|
||||
by = await db.kanban_titles_by_norm(topic)
|
||||
for c in cards:
|
||||
cid = c["chain_id"]
|
||||
members = await db.kanban_chain_members(topic, cid)
|
||||
winner = c.get("main_title_norm") if c.get("main_title_norm") in members else (members[0] if members else None)
|
||||
if not winner or winner not in by:
|
||||
await db.kanban_upsert_chain(topic, cid, DONE_CHAIN, dirty=0)
|
||||
continue
|
||||
w = by[winner]
|
||||
await db.kanban_upsert_block(topic, f"b-{cid}", cid, w["title"], w["source"], w["content"], stage="filter_verify_b")
|
||||
await db.kanban_upsert_chain(topic, cid, "filter_verify", dirty=0)
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
async def _proc_filter_verify(ctx: GenContext, flow: _Flow, cards):
|
||||
"""An agent confirms each reduced block is a valid, on-topic, self-contained concept. Off-topic /
|
||||
noise / empty blocks are dropped (→ REJECTED, chain done). IN PARALLEL (one agent per block)."""
|
||||
await asyncio.gather(*[_filtercheck_one(ctx, flow, c) for c in cards], return_exceptions=True)
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
async def _filtercheck_one(ctx: GenContext, flow: _Flow, c):
|
||||
topic = flow.topic
|
||||
cid = c["chain_id"]
|
||||
bid = f"b-{cid}"
|
||||
by = await db.kanban_titles_by_norm(topic)
|
||||
members = await db.kanban_chain_members(topic, cid)
|
||||
winner = c.get("main_title_norm") if c.get("main_title_norm") in by else (members[0] if members else None)
|
||||
|
||||
async def _drop():
|
||||
await db.kanban_advance(topic, "kanban_blocks", bid, REJECTED)
|
||||
await db.kanban_upsert_chain(topic, cid, DONE_CHAIN, dirty=0)
|
||||
|
||||
async def _pass():
|
||||
await db.kanban_advance(topic, "kanban_blocks", bid, "block_assemble_b")
|
||||
await db.kanban_upsert_chain(topic, cid, "block_assemble", dirty=0)
|
||||
|
||||
if not winner or winner not in by: # nothing to verify → drop the empty chain
|
||||
await _drop()
|
||||
return
|
||||
w = by[winner]
|
||||
path = flow.work_dir / f"filtercheck-{cid}.json"
|
||||
await run_single_slot(
|
||||
ctx, f"Filter verify {cid}", key=f"blocks-{topic}-verify-filter-{cid}",
|
||||
prompt=_prompt("Blocks-Filter-Check", topic=topic, title=w["title"], content=w["content"], out_path=path),
|
||||
role="judge", capabilities="files",
|
||||
payload=lambda result, p=path: _keep_schema(_json_file(p)),
|
||||
timeout=_timeout("selection_mapping", 1))
|
||||
keep = _keep_schema(_json_file(path))
|
||||
await (_drop() if keep is False else _pass()) # None (parse fail) → keep, conservative
|
||||
|
||||
|
||||
async def _proc_block(flow: _Flow, cards):
|
||||
"""BARRIER. Assemble the final block row → small_blocks. (chain card consumed → done.)"""
|
||||
topic = flow.topic
|
||||
for c in cards:
|
||||
cid = c["chain_id"]
|
||||
await db.kanban_advance(topic, "kanban_blocks", f"b-{cid}", "small_blocks")
|
||||
await db.kanban_upsert_chain(topic, cid, DONE_CHAIN, dirty=0)
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
def _small_schema(data, count):
|
||||
"""{"small": {"1": true, ...}} → {block_index: bool} · else None."""
|
||||
if not isinstance(data, dict) or not isinstance(data.get("small"), dict):
|
||||
return None
|
||||
out = {}
|
||||
for k, v in data["small"].items():
|
||||
try:
|
||||
n = int(k)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if 1 <= n <= count:
|
||||
out[n] = str(v).strip().casefold() in ("true", "ja", "yes", "1")
|
||||
return out or None
|
||||
|
||||
|
||||
def _keep_schema(data):
|
||||
"""{"keep": true/false} → bool · None when absent/unparseable (caller keeps on None, conservative)."""
|
||||
if not isinstance(data, dict) or "keep" not in data:
|
||||
return None
|
||||
return str(data["keep"]).strip().casefold() in ("true", "ja", "yes", "1")
|
||||
|
||||
|
||||
def _dep_schema(data, count):
|
||||
"""{"parent": N} → 0..count (0 = standalone) · else None."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
try:
|
||||
n = int(data.get("parent"))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return n if 0 <= n <= count else None
|
||||
|
||||
|
||||
async def _proc_small(ctx: GenContext, flow: _Flow, cards):
|
||||
"""Judge marks fragment-like blocks (batch). → small_verify."""
|
||||
topic = flow.topic
|
||||
path = flow.work_dir / f"small-{cards[0]['block_id']}.json"
|
||||
lines = "\n".join(f"{i + 1}. {c['title']} — {c['content']}" for i, c in enumerate(cards))
|
||||
await run_single_slot(
|
||||
ctx, "Small blocks", key=f"blocks-{topic}-small-{cards[0]['block_id']}",
|
||||
prompt=_prompt("Blocks-Small", topic=topic, blocks=lines, out_path=path),
|
||||
role="judge", capabilities="files",
|
||||
payload=lambda result, p=path: _small_schema(_json_file(p), len(cards)),
|
||||
timeout=_timeout("selection_mapping", len(cards)))
|
||||
verdict = _small_schema(_json_file(path), len(cards)) or {}
|
||||
for i, c in enumerate(cards):
|
||||
is_small = 1 if verdict.get(i + 1) else 0
|
||||
await db.kanban_upsert_block(topic, c["block_id"], c["chain_id"], c["title"], c["source"], c["content"],
|
||||
stage="small_verify", is_small=is_small, parent_block_id=c.get("parent_block_id"))
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
async def _proc_small_verify(ctx: GenContext, flow: _Flow, cards):
|
||||
"""Second judge re-checks the small flag (consensus): a block stays `small` only if it was marked
|
||||
small AND this judge also calls it a fragment. Disagreement → keep as a main block (conservative).
|
||||
→ dependency."""
|
||||
topic = flow.topic
|
||||
path = flow.work_dir / f"smallcheck-{cards[0]['block_id']}.json"
|
||||
lines = "\n".join(f"{i + 1}. {c['title']} — {c['content']}" for i, c in enumerate(cards))
|
||||
await run_single_slot(
|
||||
ctx, "Small verify", key=f"blocks-{topic}-verify-small-{cards[0]['block_id']}",
|
||||
prompt=_prompt("Blocks-Small", topic=topic, blocks=lines, out_path=path),
|
||||
role="judge", capabilities="files",
|
||||
payload=lambda result, p=path: _small_schema(_json_file(p), len(cards)),
|
||||
timeout=_timeout("selection_mapping", len(cards)))
|
||||
verdict = _small_schema(_json_file(path), len(cards)) or {}
|
||||
for i, c in enumerate(cards):
|
||||
is_small = 1 if (c["is_small"] and verdict.get(i + 1)) else 0
|
||||
await db.kanban_upsert_block(topic, c["block_id"], c["chain_id"], c["title"], c["source"], c["content"],
|
||||
stage="dependency", is_small=is_small, parent_block_id=c.get("parent_block_id"))
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
async def _proc_dependency(ctx: GenContext, flow: _Flow, cards):
|
||||
"""For each SMALL block, a judge picks its parent from the full list — batch runs IN PARALLEL."""
|
||||
topic = flow.topic
|
||||
parents = [b for b in await db.kanban_all_blocks(topic) if not b["is_small"] and b["stage"] != REJECTED]
|
||||
plist = "\n".join(f"{i + 1}. {b['title']}" for i, b in enumerate(parents))
|
||||
await asyncio.gather(*[_dep_one(ctx, flow, c, parents, plist) for c in cards], return_exceptions=True)
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
async def _dep_one(ctx: GenContext, flow: _Flow, c, parents, plist):
|
||||
topic = flow.topic
|
||||
if not c["is_small"] or not parents:
|
||||
await db.kanban_advance(topic, "kanban_blocks", c["block_id"], "dependency_verify")
|
||||
return
|
||||
path = flow.work_dir / f"dep-{c['block_id']}.json"
|
||||
await run_single_slot(
|
||||
ctx, "Dependency", key=f"blocks-{topic}-dep-{c['block_id']}",
|
||||
prompt=_prompt("Blocks-Dependency", topic=topic,
|
||||
small=f"{c['title']} — {c['content']}", parents=plist, out_path=path),
|
||||
role="judge", capabilities="files",
|
||||
payload=lambda result, p=path: _dep_schema(_json_file(p), len(parents)),
|
||||
timeout=_timeout("selection_mapping", len(parents)))
|
||||
pick = _dep_schema(_json_file(path), len(parents))
|
||||
parent_id = parents[pick - 1]["block_id"] if pick else None
|
||||
await db.kanban_upsert_block(topic, c["block_id"], c["chain_id"], c["title"], c["source"], c["content"],
|
||||
stage="dependency_verify", is_small=c["is_small"], parent_block_id=parent_id)
|
||||
|
||||
|
||||
async def _proc_dependency_verify(flow: _Flow, cards):
|
||||
"""A small block without a parent is demarked → becomes a main block. → main."""
|
||||
topic = flow.topic
|
||||
for c in cards:
|
||||
is_small = c["is_small"]
|
||||
if is_small and not c.get("parent_block_id"):
|
||||
is_small = 0
|
||||
await db.kanban_upsert_block(topic, c["block_id"], c["chain_id"], c["title"], c["source"], c["content"],
|
||||
stage="main", is_small=is_small, parent_block_id=c.get("parent_block_id"))
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
async def _proc_main(flow: _Flow, cards):
|
||||
"""BARRIER. Finalize non-small blocks → mirror into the legacy `blocks` table as consensus."""
|
||||
topic = flow.topic
|
||||
for c in cards:
|
||||
if not c["is_small"]:
|
||||
norm = _norm_title(c["title"])
|
||||
await db.upsert_block(topic, norm, c["title"], c["content"], [c["source"]] if c["source"] else [])
|
||||
await db.set_block_status(topic, norm, "consensus")
|
||||
await db.kanban_advance(topic, "kanban_blocks", c["block_id"], DONE_BLOCK)
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
# ── Orchestration ──────────────────────────────────────────────────────────────────
|
||||
async def run_kanban(ctx: GenContext, set_p, files: dict, q: dict, folder, instructions: str,
|
||||
research: bool = True) -> bool:
|
||||
"""Run the streaming inventory. Returns True when the whole flow reaches quiescence at 'main'.
|
||||
|
||||
research=False ("Continue"): process the EXISTING queue without searching new titles. No initial
|
||||
research producer, producers=0 → research_done is true at once; workers drain the queue and exit.
|
||||
The +Research button can still attach an agent later via flow.spawn_research."""
|
||||
topic = ctx.topic
|
||||
flow = _Flow(topic, files["arbeit"])
|
||||
flow.spawn_research = lambda: _extra_research(ctx, files, q, folder, instructions, flow)
|
||||
if not research:
|
||||
flow.producers = 0 # continue the existing queue, search no new titles
|
||||
_active_flows[topic] = flow
|
||||
set_p("Kanban inventory…")
|
||||
|
||||
ORDER = ["merge", "chain", "chain_verify", "naming", "naming_verify", "chain_filter",
|
||||
"filter_verify", "block_assemble", "small_blocks", "small_verify",
|
||||
"dependency", "dependency_verify", "main"]
|
||||
up = {s: ORDER[:i] for i, s in enumerate(ORDER)} # upstream = all stages before this one
|
||||
barriers = {"chain_filter", "block_assemble", "main"}
|
||||
specs = [
|
||||
("kanban_titles", "merge", lambda cs: _proc_merge(flow, cs)),
|
||||
("kanban_titles", "chain", lambda cs: _proc_chain(flow, cs)),
|
||||
("kanban_chains", "chain_verify", lambda cs: _proc_chain_verify(ctx, flow, cs)),
|
||||
("kanban_chains", "naming", lambda cs: _proc_naming(ctx, flow, cs)),
|
||||
("kanban_chains", "naming_verify", lambda cs: _proc_naming_verify(ctx, flow, cs)),
|
||||
("kanban_chains", "chain_filter", lambda cs: _proc_chain_filter(flow, cs)),
|
||||
("kanban_chains", "filter_verify", lambda cs: _proc_filter_verify(ctx, flow, cs)),
|
||||
("kanban_chains", "block_assemble", lambda cs: _proc_block(flow, cs)),
|
||||
("kanban_blocks", "small_blocks", lambda cs: _proc_small(ctx, flow, cs)),
|
||||
("kanban_blocks", "small_verify", lambda cs: _proc_small_verify(ctx, flow, cs)),
|
||||
("kanban_blocks", "dependency", lambda cs: _proc_dependency(ctx, flow, cs)),
|
||||
("kanban_blocks", "dependency_verify", lambda cs: _proc_dependency_verify(flow, cs)),
|
||||
("kanban_blocks", "main", lambda cs: _proc_main(flow, cs)),
|
||||
]
|
||||
workers = [_research(ctx, files, q, folder, instructions, flow)] if research else []
|
||||
for table, stage, proc in specs:
|
||||
workers.append(_worker(flow, table, stage, proc, up[stage], barrier=(stage in barriers),
|
||||
inflight=(1 if stage in _SERIAL_STAGES else WORKER_INFLIGHT)))
|
||||
|
||||
progress = asyncio.create_task(_progress(flow, set_p))
|
||||
try:
|
||||
await asyncio.gather(*workers, return_exceptions=True)
|
||||
finally:
|
||||
flow.stop = True
|
||||
progress.cancel()
|
||||
_active_flows.pop(topic, None)
|
||||
if ctx.is_cancelled():
|
||||
return False
|
||||
n = await db.kanban_count(topic, "kanban_blocks", DONE_BLOCK)
|
||||
_log(topic, f"Kanban: done — {n} blocks finalized")
|
||||
return True
|
||||
|
||||
|
||||
async def _progress(flow: _Flow, set_p):
|
||||
while not flow.stop:
|
||||
try:
|
||||
counts = await db.kanban_stage_counts(flow.topic)
|
||||
total = sum(counts.values())
|
||||
set_p(f"Kanban: {total} cards in flow")
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(1.0)
|
||||
657
backend/learning.py
Normal file
657
backend/learning.py
Normal file
@@ -0,0 +1,657 @@
|
||||
"""Block learning: deep-dive, block chat and exam for individual guide sections.
|
||||
|
||||
All calls are interactive (stdout response, lane "interactive") and stateless —
|
||||
the chat/exam history comes from the frontend; only the exam counter (DB) and
|
||||
the deep-dive (DB) are persisted.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from agents import run_agent
|
||||
from config import DEFAULT_PROVIDER
|
||||
from database import create_element, list_elements, get_block_hurdles
|
||||
from elements import generate_element
|
||||
from jsonio import parse_json_text as _parse_json_text
|
||||
from pipeline import _prompt, _problems_schema
|
||||
from textkit import _norm_title
|
||||
|
||||
log = logging.getLogger("creator.learning")
|
||||
|
||||
# Learning levels per block — relative to the cap (floor as % of the max score):
|
||||
# green=beginner 20% · blue=advanced 40% · purple=expert 60% · gold=master 100%.
|
||||
# Exam form is always random (5 forms); the cap scales with the amount of material.
|
||||
LEVELS = (("beginner", 0.2), ("advanced", 0.4), ("expert", 0.6), ("master", 1.0))
|
||||
|
||||
|
||||
POINTS_BASE = 25 # Points per subblock. Master cap = (all subs) × 25.
|
||||
|
||||
|
||||
def _levels(n_je_level: dict[int, int]) -> list[int]:
|
||||
return [n_je_level.get(k, 0) for k in (1, 2, 3, 4)]
|
||||
|
||||
|
||||
def thresholds(n_je_level: dict[int, int]) -> list[int]:
|
||||
"""Cumulative sub-level thresholds [S_1, S_2, S_3, S_4] = (n_1+…+n_k) × 25.
|
||||
S_k is the score at which sub-level k+1 unlocks; S_4 = cap_final."""
|
||||
out, acc = [], 0
|
||||
for n in _levels(n_je_level):
|
||||
acc += n
|
||||
out.append(acc * POINTS_BASE)
|
||||
return out
|
||||
|
||||
|
||||
def cap_final(n_je_level: dict[int, int]) -> int:
|
||||
"""Max score (master) = all subblocks × 25."""
|
||||
return thresholds(n_je_level)[-1]
|
||||
|
||||
|
||||
def freie_level(score: int, n_je_level: dict[int, int]) -> int:
|
||||
"""Highest unlocked sub-level 1–4. Level k+1 unlocks once score ≥ S_k.
|
||||
Empty levels (n_k=0) are skipped automatically (S_k == S_{k-1})."""
|
||||
s = thresholds(n_je_level)
|
||||
e = 1
|
||||
for k in range(3): # S_1..S_3 unlock levels 2..4
|
||||
if score >= s[k]:
|
||||
e = k + 2
|
||||
return e
|
||||
|
||||
|
||||
def cap_aktuell(score: int, n_je_level: dict[int, int]) -> int:
|
||||
"""Reachable cap of the currently unlocked level = unlocked subs × 25."""
|
||||
return thresholds(n_je_level)[freie_level(score, n_je_level) - 1]
|
||||
|
||||
|
||||
def _threshold(p: float, cap: int) -> int:
|
||||
return round(p * cap)
|
||||
|
||||
|
||||
def level_from_score(score: int, cap_final_value: int) -> str | None:
|
||||
"""Highest reached learning level (None below 20%), relative to cap_final."""
|
||||
reached = None
|
||||
for key, p in LEVELS:
|
||||
if score >= _threshold(p, cap_final_value):
|
||||
reached = key
|
||||
return reached
|
||||
|
||||
|
||||
def progressive_malus(basis: int, cap_akt: int) -> int:
|
||||
"""Error penalty by progress within the current level (against cap_aktuell):
|
||||
≤25%→−5 · ≤50%→−10 · ≤75%→−15 · >75%→−20."""
|
||||
pct = (basis / cap_akt) if cap_akt else 0.0
|
||||
if pct <= 0.25:
|
||||
return -5
|
||||
if pct <= 0.5:
|
||||
return -10
|
||||
if pct <= 0.75:
|
||||
return -15
|
||||
return -20
|
||||
CHAT_TIMEOUT = 240
|
||||
EXAM_TIMEOUT = 120 # short JSON turns; caps the serial latency per exam step
|
||||
THOROUGH_TIMEOUT = 600 # "thorough check": strong model (role guide) takes longer
|
||||
CRITIC_MAX_ROUNDS = 2 # Generator → Critic → maybe Regenerate, at most this many times
|
||||
|
||||
# Question types for active recall — one per question, chosen at random. Creates variety.
|
||||
QUESTION_TYPES = {
|
||||
"abruf": "Free Recall: have the learner explain the core idea freely from memory (open comprehension question).",
|
||||
"punkt": "Cued Recall: ask for ONE specific detail or distinction.",
|
||||
"warum": "Why-question: ask for the reason/mechanism — why does this work or hold?",
|
||||
"anwendung": "Application: have the concept applied to ONE short, new example/scenario.",
|
||||
"pruefen": "For code/tool topics: show a small snippet — predict the output OR find the bug. No code topic → an application question instead.",
|
||||
}
|
||||
|
||||
|
||||
# Answer tier → base points (new 25-scale). "barely" = −1 is only the signal for the
|
||||
# progressive malus (the real value comes from progressive_malus). Positive values are
|
||||
# modulated up on a streak and clamped to [10, 40].
|
||||
TIERS = {
|
||||
"unanswerable": 0, # question itself broken → no change
|
||||
"barely": -1, # < 25% correct → malus
|
||||
"partial": 0, # 25–49% → neutral
|
||||
"solid": 16, # 50–74%
|
||||
"strong": 24, # 75–99% (quiz/gap hit)
|
||||
"complete": 30, # 100% (only reachable by free explanation)
|
||||
}
|
||||
|
||||
# Order weak→strong (for the follow-up cap).
|
||||
_TIER_RANK = ("barely", "partial", "solid", "strong", "complete")
|
||||
|
||||
|
||||
def cap_followup(tier: str, asked_again: bool) -> str:
|
||||
"""With a follow-up (hint received) at most "solid" — no full score by cheating."""
|
||||
if asked_again and tier in ("strong", "complete"):
|
||||
return "solid"
|
||||
return tier
|
||||
|
||||
|
||||
def streak_points(basis_delta: int, streak_basis: int) -> int:
|
||||
"""Modulate a positive base delta up by streak, clamped to [10, 40]."""
|
||||
factor = min(1.33, 1 + 0.066 * min(streak_basis, 5))
|
||||
return max(10, min(40, round(basis_delta * factor)))
|
||||
|
||||
|
||||
def points_delta(tier: str, streak_basis: int, basis: int, cap_akt: int) -> tuple[int, int]:
|
||||
"""Answer tier → (points delta, new streak). Positive: streak-modulated, streak +1.
|
||||
Neutral (0): no change, streak stays. Negative: progressive malus, streak reset to 0."""
|
||||
basis_delta = TIERS.get(tier, 0)
|
||||
if basis_delta > 0:
|
||||
return streak_points(basis_delta, streak_basis), streak_basis + 1
|
||||
if basis_delta == 0:
|
||||
return 0, streak_basis
|
||||
return progressive_malus(basis, cap_akt), 0
|
||||
|
||||
|
||||
def compute_score(basis: int, delta: int, floor: int, cap_akt: int, cap_fin: int) -> int:
|
||||
"""New score · drift-free from the base. Clamps up against `cap_akt` (cap of the
|
||||
currently unlocked level) and down against `floor`. Frozen ONLY at the absolute
|
||||
maximum (`basis ≥ cap_fin`) — otherwise it would block at every level threshold."""
|
||||
if basis >= cap_fin:
|
||||
return basis
|
||||
return max(floor, min(cap_akt, basis + delta))
|
||||
|
||||
|
||||
def floor_from_score(basis: int, cap_fin: int, s_thresholds: list[int]) -> int:
|
||||
"""Lower bound (no fallback): highest reached learning-level threshold (over cap_final)
|
||||
AND highest reached level-unlock threshold S_k. max of both axes."""
|
||||
floor = 0
|
||||
for _, p in LEVELS:
|
||||
s = _threshold(p, cap_fin)
|
||||
if basis >= s:
|
||||
floor = max(floor, s)
|
||||
for s in s_thresholds:
|
||||
if basis >= s:
|
||||
floor = max(floor, s)
|
||||
return floor
|
||||
|
||||
|
||||
def _transcript(messages: list[dict]) -> str:
|
||||
return "\n".join(
|
||||
f"{'User' if m.get('role') == 'user' else 'Assistant'}: {m.get('content', '')}"
|
||||
for m in messages
|
||||
) or "(empty)"
|
||||
|
||||
|
||||
async def block_chat(topic: str, block: str, section: str, compact: str | None, messages: list[dict], provider: str = DEFAULT_PROVIDER) -> str:
|
||||
try:
|
||||
prompt = _prompt(
|
||||
"Block-Chat",
|
||||
topic=topic, block=block,
|
||||
section_block=section.strip() or "(no guide version provided)",
|
||||
compact_block=(compact or "").strip() or "(none)",
|
||||
transcript=_transcript(messages),
|
||||
)
|
||||
returncode, stdout, _ = await run_agent(
|
||||
"blockchat-" + str(uuid.uuid4()), prompt, CHAT_TIMEOUT,
|
||||
provider=provider, role="fast", capabilities="none", lane="interactive",
|
||||
)
|
||||
if returncode != 0:
|
||||
return "Sorry, that didn't work. Please try again."
|
||||
reply = stdout.strip()
|
||||
return reply or "Sorry, I didn't get a response."
|
||||
except Exception:
|
||||
log.warning("[%s] Block chat failed (%s)", topic, block, exc_info=True)
|
||||
return "Sorry, that didn't work. Please try again."
|
||||
|
||||
|
||||
def _question_schema(data) -> dict | None:
|
||||
"""{"question": str} · else None."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
question = str(data.get("question", "")).strip()
|
||||
return {"question": question} if question else None
|
||||
|
||||
|
||||
def _rating_schema(data) -> dict | None:
|
||||
"""{"feedback": str, "tier": ∈ TIERS} · else None."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
feedback = str(data.get("feedback", "")).strip()
|
||||
tier = data.get("tier")
|
||||
if not feedback or tier not in TIERS:
|
||||
return None
|
||||
return {"feedback": feedback, "tier": tier}
|
||||
|
||||
|
||||
async def _gen_call(name: str, role: str, schema, provider: str, timeout: int = EXAM_TIMEOUT, lane: str = "interactive", **kwargs) -> dict | None:
|
||||
"""Generator agent: fill the template, run it, parse via schema · None on error.
|
||||
lane="batch" for background (preloading, thorough rating) → its own slot queue."""
|
||||
returncode, stdout, _ = await run_agent(
|
||||
name.lower() + "-" + str(uuid.uuid4()), _prompt(name, **kwargs), timeout,
|
||||
provider=provider, role=role, capabilities="none", lane=lane,
|
||||
)
|
||||
return schema(_parse_json_text(stdout)) if returncode == 0 else None
|
||||
|
||||
|
||||
async def _critique_call(name: str, provider: str, role: str = "judge", timeout: int = EXAM_TIMEOUT, lane: str = "interactive", **kwargs) -> list[str]:
|
||||
"""Critic agent (default role judge): empty list = fine. Fail-open: a critic failure
|
||||
must not block the turn, so it returns an empty list then as well."""
|
||||
returncode, stdout, _ = await run_agent(
|
||||
name.lower() + "-" + str(uuid.uuid4()), _prompt(name, **kwargs), timeout,
|
||||
provider=provider, role=role, capabilities="none", lane=lane,
|
||||
)
|
||||
if returncode != 0:
|
||||
return []
|
||||
return _problems_schema(_parse_json_text(stdout)) or []
|
||||
|
||||
|
||||
def _critique_block(prev_version: str, problems: list[str]) -> str:
|
||||
points = "\n".join(f"- {p}" for p in problems)
|
||||
return (
|
||||
f"Your previous version was:\n«{prev_version}»\n\n"
|
||||
f"The examiner objects:\n{points}\n\nFix these points."
|
||||
)
|
||||
|
||||
|
||||
def _rating_text(rating: dict) -> str:
|
||||
return f"Tier: {rating['tier']}\nFeedback: {rating['feedback']}"
|
||||
|
||||
|
||||
# Deterministic guard against double questions — the AI critic misses "…, and which…".
|
||||
_QUESTION_WORD = r"(was|welche[rsnm]?|wie|wieso|warum|wofür|wozu|wann|wo|wer|wem|wen|nenne)"
|
||||
_DOUBLE_RE = re.compile(r"[,;]?\s+(und|sowie|außerdem|bzw\.?)\s+" + _QUESTION_WORD + r"\b", re.IGNORECASE)
|
||||
|
||||
|
||||
def _double_question_flaw(question: str) -> str | None:
|
||||
"""Detects two chained questions. None = ok. Flags ONLY 'und/sowie' + question word."""
|
||||
if question.count("?") > 1:
|
||||
return "More than one question mark — ask EXACTLY ONE question."
|
||||
if _DOUBLE_RE.search(question):
|
||||
return "Two questions chained with 'und'/'sowie' — ask EXACTLY ONE question, one thing."
|
||||
return None
|
||||
|
||||
|
||||
async def _question_with_critique(
|
||||
topic: str, block: str, section_block: str, compact_block: str,
|
||||
transcript: str, avoid_block: str, type_block: str, fokus_block: str,
|
||||
tier_block: str, provider: str,
|
||||
) -> str | None:
|
||||
"""Generate a question, have the critic check it, regenerate on flaws (max CRITIC_MAX_ROUNDS)."""
|
||||
kritik_block = "(none)"
|
||||
question = None
|
||||
for _ in range(CRITIC_MAX_ROUNDS):
|
||||
data = await _gen_call(
|
||||
"Block-Question", "guide", _question_schema, provider, lane="batch",
|
||||
topic=topic, block=block, section_block=section_block,
|
||||
compact_block=compact_block, transcript=transcript, avoid_block=avoid_block,
|
||||
type_block=type_block, fokus_block=fokus_block, tier_block=tier_block, kritik_block=kritik_block,
|
||||
)
|
||||
if data is None:
|
||||
return None
|
||||
question = data["question"]
|
||||
problems = await _critique_call(
|
||||
"Block-Question-Critique", provider, role="guide", lane="batch", # strong AI checks the rules
|
||||
topic=topic, block=block, section_block=section_block,
|
||||
compact_block=compact_block, transcript=transcript, avoid_block=avoid_block,
|
||||
type_block=type_block, fokus_block=fokus_block, question=question,
|
||||
)
|
||||
hard = _double_question_flaw(question) # forces regeneration even if the AI critic missed it
|
||||
if hard:
|
||||
problems = [hard, *(problems or [])]
|
||||
if not problems:
|
||||
return question
|
||||
kritik_block = _critique_block(question, problems)
|
||||
return question # best-effort after the last round
|
||||
|
||||
|
||||
async def _rating_with_critique(
|
||||
topic: str, block: str, section_block: str, compact_block: str,
|
||||
question: str, transcript: str, reason_block: str, provider: str, role: str = "judge",
|
||||
) -> dict | None:
|
||||
"""Rate an answer (tier), have the critic check it, redo on misjudgment.
|
||||
|
||||
`question` anchors the checked question; the dialog (transcript) provides answer + discussion.
|
||||
`reason_block` = optional learner dissatisfaction (only for "thorough check").
|
||||
`role` = "judge" (fast) or "guide" (thorough, strong model with thinking).
|
||||
"""
|
||||
timeout = THOROUGH_TIMEOUT if role == "guide" else EXAM_TIMEOUT
|
||||
# Thorough (role guide) = user is waiting → interactive. Background-thorough (judge) → batch.
|
||||
lane = "interactive" if role == "guide" else "batch"
|
||||
kritik_block = "(none)"
|
||||
rating = None
|
||||
for _ in range(CRITIC_MAX_ROUNDS):
|
||||
rating = await _gen_call(
|
||||
"Block-Rating", role, _rating_schema, provider, timeout, lane=lane,
|
||||
topic=topic, block=block, section_block=section_block,
|
||||
compact_block=compact_block, question=question, transcript=transcript,
|
||||
reason_block=reason_block, kritik_block=kritik_block,
|
||||
)
|
||||
if rating is None:
|
||||
return None
|
||||
problems = await _critique_call(
|
||||
"Block-Rating-Critique", provider, role=role, timeout=timeout, lane=lane,
|
||||
topic=topic, block=block, section_block=section_block,
|
||||
compact_block=compact_block, question=question, transcript=transcript,
|
||||
rating_block=_rating_text(rating),
|
||||
)
|
||||
if not problems:
|
||||
return rating
|
||||
kritik_block = _critique_block(_rating_text(rating), problems)
|
||||
return rating # best-effort after the last round
|
||||
|
||||
|
||||
def _section_blocks(section: str, compact: str | None) -> tuple[str, str]:
|
||||
return (
|
||||
section.strip() or "(no guide version provided)",
|
||||
(compact or "").strip() or "(none)",
|
||||
)
|
||||
|
||||
|
||||
def _avoid_block(avoid: list[str] | None) -> str:
|
||||
entries = [f.strip() for f in (avoid or []) if f and f.strip()]
|
||||
return "\n".join(f"- {f}" for f in entries) or "(none)"
|
||||
|
||||
|
||||
# Learner tier (derived from the score) → addressee role for the question. This is how the
|
||||
# difficulty arises: not "make it extra hard", but "ask questions for a beginner/expert".
|
||||
# Per level: addressee role + cognitive demand (Bloom) + "ask like this" cue. Without explicit levels
|
||||
# the model takes the easy path (mere recall) — the cues lift higher tiers to apply/analyze/transfer.
|
||||
TIER_ROLE = {
|
||||
"beginner": "The learner is a BEGINNER. Cognitive: REMEMBER/UNDERSTAND. Ask about the basic understanding — the core concept, simple and direct.",
|
||||
"advanced": "The learner is ADVANCED. Cognitive: APPLY. Pose a small concrete situation and have the concept applied to it — don't just ask for the definition.",
|
||||
"expert": "The learner is an EXPERT. Cognitive: ANALYZE. Have them distinguish/compare, classify a special case or uncover a typical pitfall (hurdle) — don't quiz textbook knowledge.",
|
||||
"master": "The learner is at MASTER level. Cognitive: EVALUATE/TRANSFER. Have the concept transferred to a NEW problem, justify a decision or weigh a trade-off.",
|
||||
}
|
||||
|
||||
|
||||
def _tier_block(tier: str | None) -> str:
|
||||
return TIER_ROLE.get(tier or "", TIER_ROLE["beginner"])
|
||||
|
||||
|
||||
async def exam_question(
|
||||
topic: str, block: str, section: str, compact: str | None,
|
||||
messages: list[dict], subblocks: list[str] | None = None,
|
||||
avoid: list[str] | None = None, tier: str = "beginner", provider: str = DEFAULT_PROVIDER,
|
||||
) -> str | None:
|
||||
"""Action 'question': generate a question — random type for a random subblock,
|
||||
in the addressee role of the tier, then critic (sequential) · None on error."""
|
||||
try:
|
||||
section_block, compact_block = _section_blocks(section, compact)
|
||||
transcript = _transcript(messages) if messages else "(empty)"
|
||||
type_block = QUESTION_TYPES[random.choice(list(QUESTION_TYPES))]
|
||||
subs = [s for s in (subblocks or []) if s and s.strip()]
|
||||
focus = random.choice(subs) if subs else ""
|
||||
fokus_block = (
|
||||
f"Focus the question on this subblock: „{focus}\"" if focus
|
||||
else "(whole block — no specific subblock)"
|
||||
)
|
||||
return await _question_with_critique(
|
||||
topic, block, section_block, compact_block, transcript,
|
||||
_avoid_block(avoid), type_block, fokus_block, _tier_block(tier), provider,
|
||||
)
|
||||
except Exception:
|
||||
log.warning("[%s] Question failed (%s)", topic, block, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def exam_question_variant(
|
||||
topic: str, block: str, section: str, compact: str | None,
|
||||
pattern: str, tier: str = "beginner", provider: str = DEFAULT_PROVIDER,
|
||||
) -> str | None:
|
||||
"""Action 'question' with a pattern: from a predefined pattern, phrase a concrete question in
|
||||
the addressee role of the tier. No critic (the pattern is build-checked).
|
||||
The style guard stays as a cheap protection against double questions · None on error."""
|
||||
try:
|
||||
section_block, compact_block = _section_blocks(section, compact)
|
||||
data = await _gen_call(
|
||||
"Block-Question-Variante", "guide", _question_schema, provider, lane="batch",
|
||||
topic=topic, block=block, section_block=section_block,
|
||||
compact_block=compact_block, pattern=pattern, tier_block=_tier_block(tier),
|
||||
)
|
||||
if data is None:
|
||||
return None
|
||||
return data["question"]
|
||||
except Exception:
|
||||
log.warning("[%s] Question variant failed (%s)", topic, block, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _options_schema(opts) -> list[dict] | None:
|
||||
"""[{text, correct}]×4 → validated list · else None."""
|
||||
if not isinstance(opts, list) or len(opts) != 4:
|
||||
return None
|
||||
out = []
|
||||
for o in opts:
|
||||
if not isinstance(o, dict):
|
||||
return None
|
||||
text = str(o.get("text", "")).strip()
|
||||
correct = o.get("correct")
|
||||
if not text or not isinstance(correct, bool):
|
||||
return None
|
||||
out.append({"text": text, "correct": correct})
|
||||
return out
|
||||
|
||||
|
||||
def _quiz_schema(data) -> dict | None:
|
||||
"""{"question": str, "options": [{text, correct}]×4} → validated · else None.
|
||||
Single choice: exactly 1 correct. The difficulty is in the tier, not in the count."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
question = str(data.get("question", "")).strip()
|
||||
out = _options_schema(data.get("options"))
|
||||
if not question or out is None:
|
||||
return None
|
||||
if sum(o["correct"] for o in out) != 1:
|
||||
return None
|
||||
return {"question": question, "options": out}
|
||||
|
||||
|
||||
def _gapchoice_schema(data) -> dict | None:
|
||||
"""{"sentence": str (with ___), "options": [{text, correct}]×4} → exactly 1 correct · else None."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
sentence = str(data.get("sentence", "")).strip()
|
||||
out = _options_schema(data.get("options"))
|
||||
if not sentence or "___" not in sentence or out is None or sum(o["correct"] for o in out) != 1:
|
||||
return None
|
||||
return {"sentence": sentence, "options": out}
|
||||
|
||||
|
||||
async def hurdles_distractor_block(topic: str, block: str) -> str:
|
||||
"""Typical misconceptions (facts hurdles) of the block as a distractor source for quiz/gap choice.
|
||||
Empty if none exist (legacy) → the prompt placeholder disappears without a trace."""
|
||||
try:
|
||||
hurdles = await get_block_hurdles(topic, _norm_title(block))
|
||||
except Exception:
|
||||
return ""
|
||||
if not hurdles:
|
||||
return ""
|
||||
lines = "\n".join(f"- {h}" for h in hurdles[:8])
|
||||
return ("TYPICAL MISCONCEPTIONS for this block (use them as distractors when they fit the question):\n"
|
||||
+ lines + "\n")
|
||||
|
||||
|
||||
async def generate_quiz(
|
||||
topic: str, block: str, section: str, compact: str | None,
|
||||
pattern: str, tier: str = "beginner", provider: str = DEFAULT_PROVIDER,
|
||||
distractor_block: str = "",
|
||||
) -> dict | None:
|
||||
"""From a pattern, a single-choice question (exactly 1 correct), at the tier's level.
|
||||
Strong model (role guide) for correct flags. → {question, options} · None on error.
|
||||
distractor_block: optional typical misconceptions (from the facts hurdles) as a distractor source."""
|
||||
try:
|
||||
section_block, compact_block = _section_blocks(section, compact)
|
||||
return await _gen_call(
|
||||
"Block-Quiz", "guide", _quiz_schema, provider, lane="batch",
|
||||
topic=topic, block=block, section_block=section_block,
|
||||
compact_block=compact_block, pattern=pattern, tier_block=_tier_block(tier),
|
||||
distractor_block=distractor_block,
|
||||
)
|
||||
except Exception:
|
||||
log.warning("[%s] Quiz question failed (%s)", topic, block, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def generate_gapchoice(
|
||||
topic: str, block: str, section: str, compact: str | None,
|
||||
pattern: str, tier: str = "beginner", provider: str = DEFAULT_PROVIDER,
|
||||
distractor_block: str = "",
|
||||
) -> dict | None:
|
||||
"""Gap text with choices: sentence with ___ + 4 terms, exactly 1 correct — at the tier's level.
|
||||
→ {sentence, options:[{text,correct}]} · None on error.
|
||||
distractor_block: optional typical misconceptions (from the facts hurdles) as a distractor source."""
|
||||
try:
|
||||
section_block, compact_block = _section_blocks(section, compact)
|
||||
return await _gen_call(
|
||||
"Block-Gapchoice", "guide", _gapchoice_schema, provider, lane="batch",
|
||||
topic=topic, block=block, section_block=section_block,
|
||||
compact_block=compact_block, pattern=pattern, tier_block=_tier_block(tier),
|
||||
distractor_block=distractor_block,
|
||||
)
|
||||
except Exception:
|
||||
log.warning("[%s] Gap-text choice failed (%s)", topic, block, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _gap_schema(data) -> dict | None:
|
||||
"""{"sentence": str (with ___), "solution": str, "alternatives": [str]} → validated · else None."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
sentence = str(data.get("sentence", "")).strip()
|
||||
solution = str(data.get("solution", "")).strip()
|
||||
alt = data.get("alternatives", [])
|
||||
if not sentence or "___" not in sentence or not solution:
|
||||
return None
|
||||
alternatives = [str(a).strip() for a in alt if isinstance(a, str) and str(a).strip()] if isinstance(alt, list) else []
|
||||
return {"sentence": sentence, "solution": solution, "alternatives": alternatives}
|
||||
|
||||
|
||||
async def generate_gaptext(
|
||||
topic: str, block: str, section: str, compact: str | None,
|
||||
pattern: str, tier: str = "beginner", provider: str = DEFAULT_PROVIDER,
|
||||
) -> dict | None:
|
||||
"""From a pattern, a gap-text task (sentence with ___, solution, synonyms), at the
|
||||
tier's level. → {sentence, solution, alternatives} · None on error."""
|
||||
try:
|
||||
section_block, compact_block = _section_blocks(section, compact)
|
||||
return await _gen_call(
|
||||
"Block-Gaptext", "guide", _gap_schema, provider, lane="batch",
|
||||
topic=topic, block=block, section_block=section_block,
|
||||
compact_block=compact_block, pattern=pattern, tier_block=_tier_block(tier),
|
||||
)
|
||||
except Exception:
|
||||
log.warning("[%s] Gap-text question failed (%s)", topic, block, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _norm_term(t: str) -> str:
|
||||
return re.sub(r"[^\wäöüß]", "", str(t or "").lower())
|
||||
|
||||
|
||||
def _correct_schema(data) -> dict | None:
|
||||
if not isinstance(data, dict) or not isinstance(data.get("correct"), bool):
|
||||
return None
|
||||
return {"correct": data["correct"]}
|
||||
|
||||
|
||||
async def check_gaptext(
|
||||
topic: str, block: str, sentence: str, solution: str, alternatives: list[str],
|
||||
input: str, provider: str = DEFAULT_PROVIDER,
|
||||
) -> bool:
|
||||
"""Check a gap-text answer: first a normalized comparison (solution + synonyms),
|
||||
otherwise 1 AI call for synonym tolerance. Fail-open to CORRECT only on an exact match."""
|
||||
if not input.strip():
|
||||
return False
|
||||
norm = _norm_term(input)
|
||||
if norm and norm in {_norm_term(solution), *(_norm_term(a) for a in alternatives)}:
|
||||
return True
|
||||
data = await _gen_call(
|
||||
"Block-Gaptext-Exam", "fast", _correct_schema, provider,
|
||||
topic=topic, block=block, sentence=sentence, solution=solution,
|
||||
alternatives=", ".join(alternatives) or "(none)", input=input,
|
||||
)
|
||||
return bool(data and data["correct"])
|
||||
|
||||
|
||||
async def exam_rating_fast(
|
||||
topic: str, block: str, section: str, compact: str | None,
|
||||
question: str, messages: list[dict], provider: str = DEFAULT_PROVIDER,
|
||||
) -> dict | None:
|
||||
"""Action 'answer' (Agent 1, fast): evaluator only, no critic. → {feedback, tier}."""
|
||||
try:
|
||||
section_block, compact_block = _section_blocks(section, compact)
|
||||
transcript = _transcript(messages) if messages else "(empty)"
|
||||
return await _gen_call(
|
||||
"Block-Rating", "judge", _rating_schema, provider,
|
||||
topic=topic, block=block, section_block=section_block, compact_block=compact_block,
|
||||
question=question.strip() or "(no question provided)", transcript=transcript,
|
||||
reason_block="(none)", kritik_block="(none)",
|
||||
)
|
||||
except Exception:
|
||||
log.warning("[%s] Fast rating failed (%s)", topic, block, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def exam_rating(
|
||||
topic: str, block: str, section: str, compact: str | None,
|
||||
question: str, messages: list[dict], provider: str = DEFAULT_PROVIDER,
|
||||
role: str = "judge", reason: str = "",
|
||||
) -> dict | None:
|
||||
"""Action 'answer_check' (Agent 2, thorough): evaluator + critic. → {feedback, tier}.
|
||||
|
||||
`role` = "guide" for "thorough check" (strong model). `reason` = optional
|
||||
learner dissatisfaction with an earlier rating.
|
||||
"""
|
||||
try:
|
||||
section_block, compact_block = _section_blocks(section, compact)
|
||||
transcript = _transcript(messages) if messages else "(empty)"
|
||||
reason_block = reason.strip() or "(none)"
|
||||
return await _rating_with_critique(
|
||||
topic, block, section_block, compact_block,
|
||||
question.strip() or "(no question provided)", transcript, reason_block, provider, role,
|
||||
)
|
||||
except Exception:
|
||||
log.warning("[%s] Rating failed (%s)", topic, block, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def block_discussion(
|
||||
topic: str, block: str, section: str, compact: str | None,
|
||||
question: str, last_rating: str | None, messages: list[dict], provider: str = DEFAULT_PROVIDER,
|
||||
) -> str | None:
|
||||
"""Action 'discussion': tutor explains/discusses the question or a rating.
|
||||
|
||||
No rating, no critic — here the human is the examiner. None on error.
|
||||
"""
|
||||
try:
|
||||
section_block, compact_block = _section_blocks(section, compact)
|
||||
prompt = _prompt(
|
||||
"Block-Exam-Discussion",
|
||||
topic=topic, block=block,
|
||||
section_block=section_block, compact_block=compact_block,
|
||||
question=question.strip() or "(no question provided)",
|
||||
last_rating_block=(last_rating or "").strip() or "(none yet)",
|
||||
transcript=_transcript(messages) if messages else "(empty)",
|
||||
)
|
||||
returncode, stdout, _ = await run_agent(
|
||||
"examdiscussion-" + str(uuid.uuid4()), prompt, CHAT_TIMEOUT,
|
||||
provider=provider, role="fast", capabilities="none", lane="interactive",
|
||||
)
|
||||
if returncode != 0:
|
||||
return None
|
||||
return stdout.strip() or None
|
||||
except Exception:
|
||||
log.warning("[%s] Exam discussion failed (%s)", topic, block, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def create_block_element(topic: str, block: str, section: str, provider: str = DEFAULT_PROVIDER) -> None:
|
||||
"""Background task after completion: register the block as an element.
|
||||
|
||||
Dedup via normalized title — if an element for the block already exists,
|
||||
nothing happens. Must never raise an exception to the outside.
|
||||
"""
|
||||
try:
|
||||
existing = {_norm_title(e["title"]) for e in await list_elements(topic)}
|
||||
if _norm_title(block) in existing:
|
||||
return
|
||||
fields = await generate_element(topic, hint=block, provider=provider, extra_context=section)
|
||||
if _norm_title(fields["title"]) in existing:
|
||||
return
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
await create_element({"id": str(uuid.uuid4()), "topic": topic, **fields, "created_at": now, "updated_at": now})
|
||||
log.info("[%s] Block registered as element: %s", topic, fields["title"])
|
||||
except Exception:
|
||||
log.warning("[%s] Element registration after exam failed (%s)", topic, block, exc_info=True)
|
||||
@@ -1,537 +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
|
||||
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")
|
||||
|
||||
# Meilensteine je Baustein (Score 0–30). Form ergibt sich aus dem Score-Band:
|
||||
# 0–6 Quiz, 7–12 Lückentext, 13–18 Erklären, 19–30 frei.
|
||||
NOETIG = 6 # MiniGuide-Meilenstein → „absolviert" (Quiz-Band durch)
|
||||
MASTERY = 12 # Guide-Meilenstein → „verstanden" (Lückentext-Band durch)
|
||||
MEISTERN = 18 # ProGuide-Meilenstein → „gemeistert" (Erklären-Band durch, Thema fertig)
|
||||
MAX_VOLL = 30 # FullGuide-Maximum (freie Formwahl ab 18)
|
||||
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 (wie sehr der Kern getroffen ist) → Punkt-Delta.
|
||||
NIVEAUS = {
|
||||
"kaum": -1, # < 25 % richtig
|
||||
"teilweise": 0, # 25–49 %
|
||||
"solide": 1, # 50–74 %
|
||||
"stark": 2, # 75–99 %
|
||||
"komplett": 3, # 100 %
|
||||
}
|
||||
|
||||
|
||||
def score_berechnen(basis: int, delta: int, floor: int, cap: int) -> int:
|
||||
"""Neuer Score · driftfrei aus der Basis. `delta` liefert die Route je Prüfungsform
|
||||
(Quiz +1/+3, Lückentext +1/+3, Erklären −1…+3; falsch −1). Auf [floor, cap] geklemmt.
|
||||
floor = höchster erreichter Meilenstein (6/12/18); cap = Max des höchsten Formats.
|
||||
Am Format-Deckel (basis ≥ cap) eingefroren: „Max" — weder + noch −, bis das nächste
|
||||
Format freischaltet. Bei cap 30 (FullGuide) bleibt 30 endgültig."""
|
||||
if basis >= cap:
|
||||
return basis
|
||||
return max(floor, min(cap, basis + delta))
|
||||
|
||||
|
||||
def _zufall_malus(basis: int, delta: int) -> int:
|
||||
"""Im Zufallband (Score ≥ MEISTERN=18, FullGuide) kostet jeder Fehler −3 statt −1 —
|
||||
die 30 erreicht man nur mit Sicherheit. Richtige Antworten bleiben unverändert."""
|
||||
return -3 if (basis >= MEISTERN and delta < 0) else delta
|
||||
|
||||
|
||||
def floor_aus_meilensteinen(absolviert: bool, verstanden: bool, gemeistert: bool) -> int:
|
||||
"""Untergrenze des Scores: ein erreichter Meilenstein hält (kein Rückfall ins Band davor)."""
|
||||
if gemeistert:
|
||||
return MEISTERN
|
||||
if verstanden:
|
||||
return MASTERY
|
||||
if absolviert:
|
||||
return NOETIG
|
||||
return 0
|
||||
|
||||
|
||||
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, **kwargs) -> dict | None:
|
||||
"""Generator-Agent: Template füllen, laufen lassen, per schema parsen · None bei Fehler."""
|
||||
returncode, stdout, _ = await run_agent(
|
||||
name.lower() + "-" + str(uuid.uuid4()), _prompt(name, **kwargs), timeout,
|
||||
provider=provider, role=role, capabilities="none", lane="interactive",
|
||||
)
|
||||
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, **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="interactive",
|
||||
)
|
||||
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, 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", "fast", _frage_schema, provider,
|
||||
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, kritik_block=kritik_block,
|
||||
)
|
||||
if data is None:
|
||||
return None
|
||||
frage = data["frage"]
|
||||
probleme = await _kritik_call(
|
||||
"Baustein-Frage-Kritik", provider, role="guide", # 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
|
||||
kritik_block = "(keine)"
|
||||
bew = None
|
||||
for _ in range(KRITIK_MAX_RUNDEN):
|
||||
bew = await _gen_call(
|
||||
"Baustein-Bewertung", role, _bewertung_schema, provider, timeout,
|
||||
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,
|
||||
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)"
|
||||
|
||||
|
||||
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, provider: str = DEFAULT_PROVIDER,
|
||||
) -> str | None:
|
||||
"""Aktion 'frage': eine Frage generieren — zufälliger Typ zu einem zufälligen Subbaustein,
|
||||
dann Kritiker (Generator → 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, 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, provider: str = DEFAULT_PROVIDER,
|
||||
) -> str | None:
|
||||
"""Aktion 'frage' mit Muster: aus einem vordefinierten Muster eine konkrete, im
|
||||
Wortlaut leicht andere Frage 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", "fast", _frage_schema, provider,
|
||||
topic=topic, baustein=baustein, section_block=section_block,
|
||||
kompakt_block=kompakt_block, muster=muster,
|
||||
)
|
||||
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, schwer: bool = False) -> dict | None:
|
||||
"""{"frage": str, "optionen": [{text, korrekt}]×4} → validiert · sonst None.
|
||||
leicht: genau 1 richtig (Single) · schwer: mindestens 2 richtig (mehrdeutig)."""
|
||||
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
|
||||
n_korrekt = sum(o["korrekt"] for o in out)
|
||||
if (n_korrekt < 2 if schwer else n_korrekt != 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 quiz_generieren(
|
||||
topic: str, baustein: str, section: str, kompakt: str | None,
|
||||
muster: str, schwer: bool = False, provider: str = DEFAULT_PROVIDER,
|
||||
) -> dict | None:
|
||||
"""Aus einem Muster eine Multiple-Choice-Frage. schwer: 0–4 richtig (Multi).
|
||||
leicht: genau 1 richtig (Single). Starkes Modell (role guide) für korrekte Flags.
|
||||
→ {frage, optionen:[{text,korrekt}]} · None bei Fehler."""
|
||||
try:
|
||||
section_block, kompakt_block = _bloecke(section, kompakt)
|
||||
anzahl_block = (
|
||||
"MINDESTENS ZWEI Optionen sind richtig (gern auch drei) — NIEMALS nur eine. Mische bewusst, welche."
|
||||
if schwer else
|
||||
"GENAU EINE Option ist richtig, die anderen drei sind klar falsch."
|
||||
)
|
||||
return await _gen_call(
|
||||
"Baustein-Quiz", "guide", (lambda d: _quiz_schema(d, schwer=schwer)), provider,
|
||||
topic=topic, baustein=baustein, section_block=section_block,
|
||||
kompakt_block=kompakt_block, muster=muster, anzahl_block=anzahl_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, provider: str = DEFAULT_PROVIDER,
|
||||
) -> dict | None:
|
||||
"""Lückentext mit Auswahl (leichte Variante): Satz mit ___ + 4 Begriffe, genau 1 richtig.
|
||||
→ {satz, optionen:[{text,korrekt}]} · None bei Fehler."""
|
||||
try:
|
||||
section_block, kompakt_block = _bloecke(section, kompakt)
|
||||
return await _gen_call(
|
||||
"Baustein-Lueckwahl", "guide", _lueckwahl_schema, provider,
|
||||
topic=topic, baustein=baustein, section_block=section_block,
|
||||
kompakt_block=kompakt_block, muster=muster,
|
||||
)
|
||||
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, provider: str = DEFAULT_PROVIDER,
|
||||
) -> dict | None:
|
||||
"""Aus einem Muster eine Lückentext-Aufgabe (Satz mit ___, Lösung, Synonyme).
|
||||
→ {satz, loesung, alternativen} · None bei Fehler."""
|
||||
try:
|
||||
section_block, kompakt_block = _bloecke(section, kompakt)
|
||||
return await _gen_call(
|
||||
"Baustein-Lueckentext", "fast", _lueck_schema, provider,
|
||||
topic=topic, baustein=baustein, section_block=section_block,
|
||||
kompakt_block=kompakt_block, muster=muster,
|
||||
)
|
||||
except Exception:
|
||||
log.warning("[%s] Lückentext-Frage fehlgeschlagen (%s)", topic, baustein, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _norm_begriff(t: str) -> str:
|
||||
return re.sub(r"[^\wäöüß]", "", str(t or "").lower())
|
||||
|
||||
|
||||
def _richtig_schema(data) -> dict | None:
|
||||
if not isinstance(data, dict) or not isinstance(data.get("richtig"), bool):
|
||||
return None
|
||||
return {"richtig": data["richtig"]}
|
||||
|
||||
|
||||
async def lueckentext_pruefen(
|
||||
topic: str, baustein: str, satz: str, loesung: str, alternativen: list[str],
|
||||
eingabe: str, provider: str = DEFAULT_PROVIDER,
|
||||
) -> bool:
|
||||
"""Lückentext-Antwort prüfen: erst normalisierter Vergleich (Lösung + Synonyme),
|
||||
sonst 1 KI-Call für Synonym-Toleranz. Fail-open zu RICHTIG nur bei exaktem Match."""
|
||||
if not eingabe.strip():
|
||||
return False
|
||||
norm = _norm_begriff(eingabe)
|
||||
if norm and norm in {_norm_begriff(loesung), *(_norm_begriff(a) for a in alternativen)}:
|
||||
return True
|
||||
data = await _gen_call(
|
||||
"Baustein-Lueckentext-Pruefung", "fast", _richtig_schema, provider,
|
||||
topic=topic, baustein=baustein, satz=satz, loesung=loesung,
|
||||
alternativen=", ".join(alternativen) or "(keine)", eingabe=eingabe,
|
||||
)
|
||||
return bool(data and data["richtig"])
|
||||
|
||||
|
||||
async def pruefung_bewertung_schnell(
|
||||
topic: str, baustein: str, section: str, kompakt: str | None,
|
||||
frage: str, messages: list[dict], provider: str = DEFAULT_PROVIDER,
|
||||
) -> dict | None:
|
||||
"""Aktion 'antwort' (Agent 1, schnell): nur Evaluator, kein Kritiker. → {feedback, niveau}."""
|
||||
try:
|
||||
section_block, kompakt_block = _bloecke(section, kompakt)
|
||||
transcript = _transcript(messages) if messages else "(leer)"
|
||||
return await _gen_call(
|
||||
"Baustein-Bewertung", "judge", _bewertung_schema, provider,
|
||||
topic=topic, baustein=baustein, section_block=section_block, kompakt_block=kompakt_block,
|
||||
frage=frage.strip() or "(keine Frage übergeben)", transcript=transcript,
|
||||
begruendung_block="(keine)", kritik_block="(keine)",
|
||||
)
|
||||
except Exception:
|
||||
log.warning("[%s] Schnell-Bewertung fehlgeschlagen (%s)", topic, baustein, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def pruefung_bewertung(
|
||||
topic: str, baustein: str, section: str, kompakt: str | None,
|
||||
frage: str, messages: list[dict], provider: str = DEFAULT_PROVIDER,
|
||||
role: str = "judge", begruendung: str = "",
|
||||
) -> dict | None:
|
||||
"""Aktion 'antwort_pruefen' (Agent 2, genau): Evaluator + Kritiker. → {feedback, niveau}.
|
||||
|
||||
`role` = "guide" für „Gründlich prüfen" (starkes Modell). `begruendung` = optionale
|
||||
Unzufriedenheit des Lerners mit einer früheren Bewertung.
|
||||
"""
|
||||
try:
|
||||
section_block, kompakt_block = _bloecke(section, kompakt)
|
||||
transcript = _transcript(messages) if messages else "(leer)"
|
||||
begruendung_block = begruendung.strip() or "(keine)"
|
||||
return await _bewertung_mit_kritik(
|
||||
topic, baustein, section_block, kompakt_block,
|
||||
frage.strip() or "(keine Frage übergeben)", transcript, begruendung_block, provider, role,
|
||||
)
|
||||
except Exception:
|
||||
log.warning("[%s] Bewertung fehlgeschlagen (%s)", topic, baustein, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def baustein_diskussion(
|
||||
topic: str, baustein: str, section: str, kompakt: str | None,
|
||||
frage: str, letzte_bewertung: str | None, messages: list[dict], provider: str = DEFAULT_PROVIDER,
|
||||
) -> str | None:
|
||||
"""Aktion 'diskussion': Tutor erklärt/diskutiert die Frage oder eine Bewertung.
|
||||
|
||||
Kein Bewerten, kein Kritiker — hier ist der Mensch der Prüfer. None bei Fehler.
|
||||
"""
|
||||
try:
|
||||
section_block, kompakt_block = _bloecke(section, kompakt)
|
||||
prompt = _prompt(
|
||||
"Baustein-Pruefung-Diskussion",
|
||||
topic=topic, baustein=baustein,
|
||||
section_block=section_block, kompakt_block=kompakt_block,
|
||||
frage=frage.strip() or "(keine Frage übergeben)",
|
||||
letzte_bewertung_block=(letzte_bewertung or "").strip() or "(noch keine)",
|
||||
transcript=_transcript(messages) if messages else "(leer)",
|
||||
)
|
||||
returncode, stdout, _ = await run_agent(
|
||||
"pruefungdiskussion-" + str(uuid.uuid4()), prompt, CHAT_TIMEOUT,
|
||||
provider=provider, role="fast", capabilities="none", lane="interactive",
|
||||
)
|
||||
if returncode != 0:
|
||||
return None
|
||||
return stdout.strip() or None
|
||||
except Exception:
|
||||
log.warning("[%s] Prüfungs-Diskussion fehlgeschlagen (%s)", topic, baustein, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def baustein_element_anlegen(topic: str, baustein: str, section: str, provider: str = DEFAULT_PROVIDER) -> None:
|
||||
"""Hintergrund-Task nach dem Absolvieren: Baustein als Element anlegen.
|
||||
|
||||
Dedup über normalisierte Titel — existiert schon ein Element zum Baustein,
|
||||
passiert nichts. Darf nie eine Exception nach außen werfen.
|
||||
"""
|
||||
try:
|
||||
vorhanden = {_norm_titel(e["title"]) for e in await list_elements(topic)}
|
||||
if _norm_titel(baustein) in vorhanden:
|
||||
return
|
||||
fields = await generate_element(topic, hint=baustein, provider=provider, extra_context=section)
|
||||
if _norm_titel(fields["title"]) in vorhanden:
|
||||
return
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
await create_element({"id": str(uuid.uuid4()), "topic": topic, **fields, "created_at": now, "updated_at": now})
|
||||
log.info("[%s] Baustein als Element angelegt: %s", topic, fields["title"])
|
||||
except Exception:
|
||||
log.warning("[%s] Element-Anlage nach Prüfung fehlgeschlagen (%s)", topic, baustein, exc_info=True)
|
||||
@@ -1,115 +0,0 @@
|
||||
"""Deterministisches Lesbarkeits-Gate für Guide-Sections.
|
||||
|
||||
Ein kleines deutsches Komplexitäts-Modell (DistilBERT, GermEval 2022, Skala 1–7)
|
||||
bewertet die Verständlichkeit der Fließtext-Prosa. guide.py meldet zu schwere
|
||||
Sections in die bestehende Lese-Prüfungs-/Überarbeitungs-Schleife — kein Prompt,
|
||||
kein Raten.
|
||||
|
||||
Optional: fehlen `transformers`/`torch` oder lädt das Modell nicht, ist das Gate
|
||||
stumm deaktiviert (das Backend läuft unverändert weiter). CPU genügt; der Aufrufer
|
||||
wrappt die Bewertung in `asyncio.to_thread` (blockierende Modell-Inferenz).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from config import (
|
||||
LESBARKEIT_AKTIV, LESBARKEIT_HART, LESBARKEIT_HART_ANTEIL, LESBARKEIT_MAX, LESBARKEIT_MODELL,
|
||||
)
|
||||
|
||||
log = logging.getLogger("creator.lesbarkeit")
|
||||
|
||||
_modell_cache = None # (tokenizer, model, torch) — Singleton
|
||||
_ladeversuch = False # schon versucht zu laden?
|
||||
|
||||
# Markup raus → reiner Fließtext (Code zählt nicht zur Lesbarkeit).
|
||||
_CODE_FENCE = re.compile(r"```.*?```", re.DOTALL)
|
||||
_COMMENT = re.compile(r"<!--.*?-->", re.DOTALL)
|
||||
_INLINE_CODE = re.compile(r"`[^`]*`")
|
||||
_LINK = re.compile(r"\[([^\]]*)\]\([^)]*\)")
|
||||
_MD_MARK = re.compile(r"^[ \t]*([#>]+|[-*+]\s)|[*_~|]", re.MULTILINE)
|
||||
_WS = re.compile(r"\s+")
|
||||
_SATZ = re.compile(r"(?<=[.!?])\s+")
|
||||
|
||||
|
||||
def _modell():
|
||||
"""Lädt das Modell einmalig. None = Gate aus (deaktiviert oder Lade-Fehler)."""
|
||||
global _modell_cache, _ladeversuch
|
||||
if _ladeversuch:
|
||||
return _modell_cache
|
||||
_ladeversuch = True
|
||||
if not LESBARKEIT_AKTIV:
|
||||
return None
|
||||
try:
|
||||
import torch
|
||||
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
||||
tok = AutoTokenizer.from_pretrained(LESBARKEIT_MODELL)
|
||||
model = AutoModelForSequenceClassification.from_pretrained(LESBARKEIT_MODELL)
|
||||
model.eval()
|
||||
_modell_cache = (tok, model, torch)
|
||||
log.info("Lesbarkeits-Modell geladen: %s (num_labels=%d)", LESBARKEIT_MODELL, model.config.num_labels)
|
||||
except Exception as e:
|
||||
log.warning("Lesbarkeits-Gate deaktiviert (Modell nicht ladbar): %s", e)
|
||||
_modell_cache = None
|
||||
return _modell_cache
|
||||
|
||||
|
||||
def _prosa(md: str) -> str:
|
||||
"""Markdown/Code strippen → reiner Fließtext für die Bewertung."""
|
||||
t = _CODE_FENCE.sub(" ", md)
|
||||
t = _COMMENT.sub(" ", t)
|
||||
t = _INLINE_CODE.sub(" ", t)
|
||||
t = _LINK.sub(r"\1", t)
|
||||
t = _MD_MARK.sub(" ", t)
|
||||
return _WS.sub(" ", t).strip()
|
||||
|
||||
|
||||
def _saetze(text: str) -> list[str]:
|
||||
"""Fließtext in Sätze splitten; sehr kurze Fragmente verwerfen."""
|
||||
return [s.strip() for s in _SATZ.split(text) if len(s.strip()) >= 15]
|
||||
|
||||
|
||||
def _scores(saetze: list[str]) -> list[float]:
|
||||
"""Komplexität je Satz (1–7). Regression (num_labels=1) oder Erwartungswert über Klassen."""
|
||||
tok, model, torch = _modell_cache
|
||||
werte: list[float] = []
|
||||
n = model.config.num_labels
|
||||
for i in range(0, len(saetze), 16):
|
||||
batch = saetze[i:i + 16]
|
||||
enc = tok(batch, return_tensors="pt", truncation=True, max_length=256, padding=True)
|
||||
with torch.no_grad():
|
||||
logits = model(**enc).logits
|
||||
if n == 1:
|
||||
vals = logits.reshape(-1).tolist()
|
||||
else:
|
||||
probs = torch.softmax(logits, dim=-1)
|
||||
stufen = torch.arange(1, n + 1, dtype=probs.dtype)
|
||||
vals = (probs * stufen).sum(-1).reshape(-1).tolist()
|
||||
werte.extend(vals)
|
||||
return werte
|
||||
|
||||
|
||||
def bewerte_sections(md_by_num: dict[int, str]) -> dict[int, str]:
|
||||
"""{num: section_md} → {num: Hinweis} nur für zu schwere Sections.
|
||||
|
||||
Leeres dict, wenn das Gate aus ist. Blockierend (CPU) — in to_thread aufrufen.
|
||||
"""
|
||||
if _modell() is None:
|
||||
return {}
|
||||
out: dict[int, str] = {}
|
||||
for num, md in md_by_num.items():
|
||||
saetze = _saetze(_prosa(md or ""))
|
||||
if len(saetze) < 2: # fast nur Code / zu kurz → überspringen
|
||||
continue
|
||||
werte = _scores(saetze)
|
||||
if not werte:
|
||||
continue
|
||||
schnitt = sum(werte) / len(werte)
|
||||
hart = sum(1 for w in werte if w > LESBARKEIT_HART) / len(werte)
|
||||
# Zu schwer = hoher Schnitt ODER zu viele harte Einzelsätze (Ausreißer-Nester).
|
||||
if schnitt > LESBARKEIT_MAX or hart >= LESBARKEIT_HART_ANTEIL:
|
||||
out[num] = (
|
||||
f"Zu schwer lesbar (Ø {schnitt:.1f}/7, {hart * 100:.0f}% harte Sätze): "
|
||||
"kürzere Sätze, einfachere Wörter, weniger Schachtelsätze, mehr Beispiele."
|
||||
)
|
||||
return out
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Zentrales Logging-Setup — einmal in main.py aufrufen, bevor die App entsteht."""
|
||||
"""Central logging setup — call once in main.py before the app is created."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
@@ -2,6 +2,7 @@ from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.gzip import GZipMiddleware
|
||||
|
||||
from logsetup import setup_logging
|
||||
|
||||
@@ -15,16 +16,31 @@ from routes import router
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
(STORAGE_DIR / "themen").mkdir(parents=True, exist_ok=True)
|
||||
(STORAGE_DIR / "topics").mkdir(parents=True, exist_ok=True)
|
||||
await init_db()
|
||||
await reconcile_guides()
|
||||
yield
|
||||
await close_db()
|
||||
|
||||
|
||||
class CachedStatic(StaticFiles):
|
||||
"""StaticFiles with Cache-Control: hashed assets forever (immutable),
|
||||
index.html never cached (it always points at the current asset hashes)."""
|
||||
async def get_response(self, path, scope):
|
||||
resp = await super().get_response(path, scope)
|
||||
if path.startswith("assets/"):
|
||||
resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||
else:
|
||||
resp.headers["Cache-Control"] = "no-cache"
|
||||
return resp
|
||||
|
||||
|
||||
app = FastAPI(title="Creator", lifespan=lifespan)
|
||||
|
||||
# gzip for the JS/CSS bundle + large JSON responses (~1.39 MB JS → ~400 KB).
|
||||
app.add_middleware(GZipMiddleware, minimum_size=500)
|
||||
|
||||
app.include_router(router)
|
||||
|
||||
if FRONTEND_DIST.exists():
|
||||
app.mount("/", StaticFiles(directory=FRONTEND_DIST, html=True), name="frontend")
|
||||
app.mount("/", CachedStatic(directory=FRONTEND_DIST, html=True), name="frontend")
|
||||
|
||||
@@ -17,34 +17,49 @@ class GuideCreateRequest(BaseModel):
|
||||
format: FormatType
|
||||
instructions: str = Field(default="", max_length=2000)
|
||||
provider: ProviderType = "claude"
|
||||
ab_step: int | None = Field(default=None, ge=0, le=4) # Re-Run ab Guide-Schritt (0 Gliederung … 4 Lese-Prüfung); None = voll/Resume
|
||||
ab_step: int | None = Field(default=None, ge=0, le=4) # re-run from guide step (0 outline … 4 read-exam); None = full/resume
|
||||
|
||||
|
||||
class TopicCreateRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
|
||||
|
||||
class BausteineCreateRequest(BaseModel):
|
||||
class BlocksCreateRequest(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
instructions: str = Field(default="", max_length=2000)
|
||||
provider: ProviderType = "claude"
|
||||
source_type: SourceType = "thema"
|
||||
source_ort: str = Field(default="", max_length=2000)
|
||||
ab_phase: int | None = Field(default=None, ge=1, le=6) # Re-Run ab grober Phase (Position in _phasen(topic), 1-based); None = Resume/Fortsetzen ohne Löschen
|
||||
source_location: str = Field(default="", max_length=2000)
|
||||
ab_phase: int | None = Field(default=None, ge=1, le=9) # re-run from a coarse phase (position in _phasen(topic), 1-based; up to 9: …outline/questions/artifacts); None = resume/continue without deleting
|
||||
ab_step: int | None = Field(default=None, ge=0) # re-run from a fine sub-step (0-based index into _blocks_steps); takes precedence over ab_phase
|
||||
to_step: int | None = Field(default=None, ge=0) # stop AFTER this fine sub-step (0-based index into _blocks_steps); None = run to the end
|
||||
research: bool = True # False = "Continue": process the existing queue, search no new titles
|
||||
|
||||
|
||||
class BausteineStep(BaseModel):
|
||||
class BlocksResetStepRequest(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
ab_step: int = Field(ge=0) # ONLY reset from this sub-step (no regeneration)
|
||||
|
||||
|
||||
class BlocksStep(BaseModel):
|
||||
label: str
|
||||
state: Literal["done", "active", "pending"]
|
||||
|
||||
|
||||
class BausteineStatusResponse(BaseModel):
|
||||
class BlocksFineStep(BaseModel):
|
||||
label: str
|
||||
phase: str = ""
|
||||
state: Literal["done", "active", "pending"]
|
||||
|
||||
|
||||
class BlocksStatusResponse(BaseModel):
|
||||
ready: bool
|
||||
generating: bool
|
||||
progress: str | None = None
|
||||
error: str | None = None
|
||||
partial: bool = False
|
||||
steps: list[BausteineStep] = []
|
||||
steps: list[BlocksStep] = []
|
||||
feine_steps: list[BlocksFineStep] = []
|
||||
|
||||
|
||||
class ProjectResponse(BaseModel):
|
||||
@@ -53,33 +68,33 @@ class ProjectResponse(BaseModel):
|
||||
|
||||
class FolderResponse(BaseModel):
|
||||
name: str
|
||||
ort: str # relativer Pfad ab Repo-Root (z.B. "projects/foo")
|
||||
location: str # path relative to the repo root (e.g. "projects/foo")
|
||||
|
||||
|
||||
class BausteineQuelleUpdate(BaseModel):
|
||||
class BlocksSourceUpdate(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
type: SourceType = "thema"
|
||||
ort: str = Field(default="", max_length=2000)
|
||||
location: str = Field(default="", max_length=2000)
|
||||
spec: str = Field(default="", max_length=2000)
|
||||
|
||||
|
||||
class BausteineQuelleResponse(BaseModel):
|
||||
class BlocksSourceResponse(BaseModel):
|
||||
type: SourceType
|
||||
ort: str
|
||||
location: str
|
||||
spec: str
|
||||
|
||||
|
||||
class SubbausteinInfo(BaseModel):
|
||||
titel: str
|
||||
stufe: Literal["einfach", "mittel", "schwer"]
|
||||
relevanz: Literal["relevant", "rand"] | None = None
|
||||
class SubblockInfo(BaseModel):
|
||||
title: str
|
||||
level: Literal["beginner", "advanced", "expert", "easy", "medium", "hard"]
|
||||
relevance: Literal["relevant", "peripheral"] | None = None
|
||||
|
||||
|
||||
class BausteinUebersicht(BaseModel):
|
||||
class BlockOverview(BaseModel):
|
||||
num: int
|
||||
titel: str
|
||||
beschreibung: str = ""
|
||||
subbausteine: list[SubbausteinInfo] = []
|
||||
title: str
|
||||
description: str = ""
|
||||
subblocks: list[SubblockInfo] = []
|
||||
|
||||
|
||||
class ProviderInfo(BaseModel):
|
||||
@@ -155,7 +170,7 @@ class ElementCheckResponse(BaseModel):
|
||||
|
||||
class ElementStyleChange(BaseModel):
|
||||
text: str
|
||||
action: Literal["entfernen", "anpassen", "hinzufuegen"]
|
||||
action: Literal["remove", "adjust", "add"]
|
||||
target: Literal["title", "description", "examples", "hints"]
|
||||
index: int | None = None
|
||||
content: str = ""
|
||||
@@ -194,77 +209,104 @@ class ProgressResponse(BaseModel):
|
||||
chapters: list[str]
|
||||
|
||||
|
||||
# --- Baustein-Lernen ---
|
||||
# --- Block learning ---
|
||||
|
||||
class BausteinChatRequest(BaseModel):
|
||||
class BlockChatRequest(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
baustein: str = Field(min_length=1, max_length=200)
|
||||
section: str = Field(default="", max_length=20000) # ausführliche Fassung
|
||||
section_kompakt: str = Field(default="", max_length=20000) # kompakte Fassung (Merksätze)
|
||||
block: str = Field(min_length=1, max_length=200)
|
||||
section: str = Field(default="", max_length=20000) # detailed version
|
||||
section_compact: str = Field(default="", max_length=20000) # compact version (mnemonics)
|
||||
messages: list[ChatMessage] = Field(min_length=1)
|
||||
provider: ProviderType = "claude"
|
||||
|
||||
|
||||
class BausteinChatResponse(BaseModel):
|
||||
class BlockChatResponse(BaseModel):
|
||||
reply: str
|
||||
|
||||
|
||||
class BausteinPruefungRequest(BaseModel):
|
||||
class BlockExamRequest(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
baustein: str = Field(min_length=1, max_length=200)
|
||||
section: str = Field(default="", max_length=20000) # ausführliche Fassung
|
||||
section_kompakt: str = Field(default="", max_length=20000) # kompakte Fassung (Merksätze)
|
||||
aktion: Literal[
|
||||
"frage", "diskussion", "antwort", "antwort_pruefen",
|
||||
"quiz_frage", "quiz_antwort", "lueck_frage", "lueck_antwort",
|
||||
] = "frage"
|
||||
frage: str = Field(default="", max_length=2000) # aktuell geprüfte Frage (für diskussion/antwort); Anker der Basis
|
||||
auswahl: list[int] = [] # Quiz/Lückentext-Auswahl: vom Lerner gewählte Options-Indizes
|
||||
korrekt: list[int] = [] # Quiz/Lückentext-Auswahl: korrekte Indizes (Client hält sie aus der Generierung)
|
||||
loesung: str = Field(default="", max_length=500) # Lückentext frei: erwarteter Begriff
|
||||
alternativen: list[str] = [] # Lückentext frei: akzeptierte Synonyme
|
||||
eingabe: str = Field(default="", max_length=500) # Lückentext frei: getippter Begriff
|
||||
schwer: bool = False # Variante: leicht (+1/−1) vs schwer (+3/−1)
|
||||
letzte_bewertung: str = Field(default="", max_length=2000) # Feedback der letzten Bewertung (Kontext für diskussion)
|
||||
vermeide: list[str] = [] # schon gestellte + vorgemerkte Fragen — sinngemäß nicht wiederholen
|
||||
nachgefragt: bool = False # für diese Frage wurde nachgefragt → Gewinn auf +1 gedeckelt
|
||||
begruendung: str = Field(default="", max_length=2000) # „Gründlich prüfen": warum mit der Bewertung unzufrieden
|
||||
muster: str = Field(default="", max_length=2000) # gezogenes Frage-Muster (Saat); leer → Live-Generierung (Fallback)
|
||||
# Basis wird serverseitig je Frage geführt (offene_frage-Anker) — nicht mehr vom Client.
|
||||
cap: int = Field(default=6, ge=6, le=30) # Score-Deckel = Max des höchsten erstellten Formats (6/12/18/30)
|
||||
messages: list[ChatMessage] = [] # Dialog bisher; leer = erste Frage
|
||||
block: str = Field(min_length=1, max_length=200)
|
||||
section: str = Field(default="", max_length=20000) # detailed version
|
||||
section_compact: str = Field(default="", max_length=20000) # compact version (mnemonics)
|
||||
action: Literal[
|
||||
"question", "discussion", "answer", "answer_check",
|
||||
"quiz_question", "quiz_answer", "gap_question", "gap_answer",
|
||||
] = "question"
|
||||
question: str = Field(default="", max_length=2000) # currently checked question (for discussion/answer); base anchor
|
||||
selection: list[int] = [] # quiz/gap-text choice: option indices picked by the learner
|
||||
correct: list[int] = [] # quiz/gap-text choice: correct indices (the client keeps them from generation)
|
||||
solution: str = Field(default="", max_length=500) # gap text free: expected term
|
||||
alternatives: list[str] = [] # gap text free: accepted synonyms
|
||||
input: str = Field(default="", max_length=500) # gap text free: typed term
|
||||
schwer: bool = False # variant: easy (+1/−1) vs hard (+3/−1)
|
||||
last_rating: str = Field(default="", max_length=2000) # feedback of the last rating (context for discussion)
|
||||
avoid: list[str] = [] # already-asked + earmarked questions — don't repeat them in substance
|
||||
asked_again: bool = False # asked_again was used for this question → gain capped at +1
|
||||
reason: str = Field(default="", max_length=2000) # "thorough check": why dissatisfied with the rating
|
||||
pattern: str = Field(default="", max_length=2000) # drawn question pattern (seed); empty → live generation (fallback)
|
||||
# Base + cap are kept server-side (anchor / subs×25) — the client cap is only a hint.
|
||||
cap: int = Field(default=10, ge=1, le=10000) # score cap = unlocked subblocks × 25
|
||||
messages: list[ChatMessage] = [] # dialog so far; empty = first question
|
||||
provider: ProviderType = "claude"
|
||||
gruendlich: bool = False # „Gründlich prüfen": Bewertung mit starkem Modell (role guide)
|
||||
thorough: bool = False # "thorough check": rating with a strong model (role guide)
|
||||
|
||||
|
||||
class QuizOption(BaseModel):
|
||||
text: str
|
||||
korrekt: bool
|
||||
correct: bool
|
||||
|
||||
|
||||
class BausteinPruefungResponse(BaseModel):
|
||||
frage: str | None = None
|
||||
class BlockExamResponse(BaseModel):
|
||||
question: str | None = None
|
||||
reply: str | None = None
|
||||
feedback: str | None = None
|
||||
punkte: int | None = None # Punkt-Delta dieser Antwort (−2 … +3); schnell = voraussichtlich
|
||||
bewertung: Literal["gut", "neutral", "schlecht"] | None = None # aus Vorzeichen, fürs Einfärben
|
||||
optionen: list[QuizOption] | None = None # Quiz: 4 Optionen + Korrekt-Flags
|
||||
satz: str | None = None # Lückentext: Satz mit Lücke (___)
|
||||
loesung: str | None = None # Lückentext: erwarteter Begriff
|
||||
alternativen: list[str] | None = None # Lückentext: akzeptierte Synonyme
|
||||
gute_antworten: int
|
||||
absolviert: bool
|
||||
verstanden: bool = False
|
||||
gemeistert: bool = False
|
||||
points: int | None = None # points delta of this answer (−2 … +3); fast = expected
|
||||
rating: Literal["gut", "neutral", "schlecht"] | None = None # from the sign, for coloring
|
||||
options: list[QuizOption] | None = None # quiz: 4 options + correct flags
|
||||
sentence: str | None = None # gap text: sentence with a gap (___)
|
||||
solution: str | None = None # gap text: expected term
|
||||
alternatives: list[str] | None = None # gap text: accepted synonyms
|
||||
good_answers: int
|
||||
streak: int = 0 # current run of correct answers (per block)
|
||||
cap: int = 10 # cap_final = all subs × 25 — the frontend derives the learning level
|
||||
|
||||
|
||||
class BausteinLernstand(BaseModel):
|
||||
gute_antworten: int
|
||||
class BlockLearnState(BaseModel):
|
||||
good_answers: int
|
||||
streak: int = 0
|
||||
absolviert: bool
|
||||
verstanden: bool
|
||||
gemeistert: bool
|
||||
cap: int = 0 # cap_final = all subblocks × 25
|
||||
cap_aktuell: int = 0 # reachable cap of the currently unlocked level
|
||||
freie_level: int = 1 # 1=A · 2=F · 3=E · 4=V
|
||||
|
||||
|
||||
class BausteinLernstandResponse(BaseModel):
|
||||
bausteine: dict[str, BausteinLernstand]
|
||||
class BlockLearnStateResponse(BaseModel):
|
||||
blocks: dict[str, BlockLearnState]
|
||||
|
||||
|
||||
# --- Block content: check + apply one section on demand (focus, right-click) ---
|
||||
|
||||
class BlockPruefenRequest(BaseModel):
|
||||
block: str = Field(min_length=1, max_length=200)
|
||||
spot: str = "ausführlich" # "compact" | "ausführlich" (displayed field)
|
||||
snippet: str = Field(min_length=1, max_length=20000) # raw markdown block
|
||||
hint: str = Field(default="", max_length=2000) # optional addition (✏️)
|
||||
provider: ProviderType = "claude"
|
||||
|
||||
|
||||
class BlockPruefenResponse(BaseModel):
|
||||
revised: str # corrected block as markdown
|
||||
|
||||
|
||||
class BlockUebernehmenRequest(BaseModel):
|
||||
block: str = Field(min_length=1, max_length=200)
|
||||
spot: str = "ausführlich"
|
||||
alt: str = Field(min_length=1, max_length=20000)
|
||||
revised: str = Field(default="", max_length=20000)
|
||||
provider: ProviderType = "claude"
|
||||
|
||||
|
||||
class BlockUebernehmenResponse(BaseModel):
|
||||
compact: str
|
||||
md: str
|
||||
found: bool
|
||||
|
||||
@@ -2,7 +2,7 @@ from pathlib import Path
|
||||
|
||||
from config import STORAGE_DIR, PROJECTS_DIR, PROJECT_ROOT
|
||||
|
||||
THEMEN_DIR = STORAGE_DIR / "themen"
|
||||
TOPICS_DIR = STORAGE_DIR / "topics"
|
||||
|
||||
|
||||
def _safe(name: str) -> str:
|
||||
@@ -10,42 +10,42 @@ def _safe(name: str) -> str:
|
||||
|
||||
|
||||
def topic_dir(topic: str) -> Path:
|
||||
return THEMEN_DIR / _safe(topic)
|
||||
return TOPICS_DIR / _safe(topic)
|
||||
|
||||
|
||||
def arbeit_dir(topic: str) -> Path:
|
||||
return topic_dir(topic) / "arbeit"
|
||||
|
||||
|
||||
def bausteine_path(topic: str) -> Path:
|
||||
return topic_dir(topic) / "bausteine.md"
|
||||
def blocks_path(topic: str) -> Path:
|
||||
return topic_dir(topic) / "blocks.md"
|
||||
|
||||
|
||||
def subbausteine_path(topic: str) -> Path:
|
||||
"""Sidecar: pro Baustein die Subbausteine mit Stufe (von allen Guides geteilt)."""
|
||||
return topic_dir(topic) / "subbausteine.json"
|
||||
def subblocks_path(topic: str) -> Path:
|
||||
"""Sidecar: the subblocks with level per block (shared by all guides)."""
|
||||
return topic_dir(topic) / "subblocks.json"
|
||||
|
||||
|
||||
def frage_muster_path(topic: str) -> Path:
|
||||
"""Sidecar: pro Baustein vordefinierte Frage-Muster (Subbaustein × Typ → Beispielfrage)."""
|
||||
return topic_dir(topic) / "frage_muster.json"
|
||||
def question_pattern_path(topic: str) -> Path:
|
||||
"""Sidecar: predefined question patterns per block (subblock × type → example question)."""
|
||||
return topic_dir(topic) / "question_pattern.json"
|
||||
|
||||
|
||||
def quelle_path(topic: str) -> Path:
|
||||
"""Persistierte Quellen-Wahl pro Thema: {type, ort, spec}."""
|
||||
return topic_dir(topic) / "quelle.json"
|
||||
def source_path(topic: str) -> Path:
|
||||
"""Persisted source choice per topic: {type, location, spec}."""
|
||||
return topic_dir(topic) / "source.json"
|
||||
|
||||
|
||||
def quelle_crawl_dir(topic: str) -> Path:
|
||||
"""Zielordner für gecrawlte Link-Quellen (Seiten + PDF-.txt)."""
|
||||
return topic_dir(topic) / "quelle"
|
||||
def source_crawl_dir(topic: str) -> Path:
|
||||
"""Target folder for crawled link sources (pages + PDF .txt)."""
|
||||
return topic_dir(topic) / "source"
|
||||
|
||||
|
||||
def safe_ordner(ort: str) -> Path | None:
|
||||
"""Ordnerpfad relativ zum Repo-Root, gesandboxt. None bei leer/Ausbruch (../, absolut außerhalb)."""
|
||||
if not ort or not ort.strip():
|
||||
def safe_folder(location: str) -> Path | None:
|
||||
"""Folder path relative to the repo root, sandboxed. None if empty/escaping (../, absolute outside)."""
|
||||
if not location or not location.strip():
|
||||
return None
|
||||
p = (PROJECT_ROOT / ort.strip()).resolve()
|
||||
p = (PROJECT_ROOT / location.strip()).resolve()
|
||||
try:
|
||||
p.relative_to(PROJECT_ROOT)
|
||||
except ValueError:
|
||||
@@ -57,11 +57,11 @@ def guide_content_path(topic: str, format_name: str) -> Path:
|
||||
return topic_dir(topic) / "guides" / f"{format_name}.json"
|
||||
|
||||
|
||||
def bausteine_topics() -> list[str]:
|
||||
"""Themen, für die ein Themen-Ordner existiert."""
|
||||
if not THEMEN_DIR.is_dir():
|
||||
def blocks_topics() -> list[str]:
|
||||
"""Topics for which a topic folder exists."""
|
||||
if not TOPICS_DIR.is_dir():
|
||||
return []
|
||||
return [d.name for d in THEMEN_DIR.iterdir() if d.is_dir()]
|
||||
return [d.name for d in TOPICS_DIR.iterdir() if d.is_dir()]
|
||||
|
||||
|
||||
def project_dir(name: str) -> Path:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Pipeline-Grundbausteine: Agent-Races (mit Grace), Single-Slot, Schemata, Prompts, Guide-Status.
|
||||
"""Pipeline building blocks: agent races (with grace), single-slot, schemas, prompts, guide status.
|
||||
|
||||
Hält den mutablen Pipeline-Zustand (Generierungs-Semaphore, Cancel-Set).
|
||||
Zugriff auf das Cancel-Set NUR über die Funktionen hier — kopierte Referenzen
|
||||
in anderen Modulen würden bei einem Re-Assign auseinanderlaufen.
|
||||
Holds the mutable pipeline state (generation semaphore, cancel set).
|
||||
Access the cancel set ONLY through the functions here — copied references
|
||||
in other modules would diverge on a re-assign.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -15,7 +15,7 @@ from typing import Callable
|
||||
from agents import run_agent, kill_process, cancel_scope, clear_scope
|
||||
from config import MAX_CONCURRENT_GENERATIONS, TEMPLATES_DIR, TIMEOUTS
|
||||
from database import update_guide
|
||||
from jsonio import read_json_file as _json_datei
|
||||
from jsonio import read_json_file as _json_file
|
||||
from textkit import _STUFEN
|
||||
|
||||
log = logging.getLogger("creator.pipeline")
|
||||
@@ -26,10 +26,10 @@ _cancelled: set[str] = set()
|
||||
|
||||
async def cancel_guide(guide_id: str) -> bool:
|
||||
_cancelled.add(guide_id)
|
||||
cancel_scope(f"{guide_id}-") # wartende Agenten bailen vorm Spawn
|
||||
kill_process(guide_id) # laufende Subprozesse killen
|
||||
cancel_scope(f"{guide_id}-") # waiting agents bail before spawn
|
||||
kill_process(guide_id) # kill running subprocesses
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
await update_guide(guide_id, status="error", progress=None, error_msg="Abgebrochen — Fortschritt bleibt erhalten", updated_at=now)
|
||||
await update_guide(guide_id, status="error", progress=None, error_msg="Cancelled — progress is preserved", updated_at=now)
|
||||
return True
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ def is_guide_cancelled(guide_id: str) -> bool:
|
||||
|
||||
def clear_guide_cancelled(guide_id: str) -> None:
|
||||
_cancelled.discard(guide_id)
|
||||
clear_scope(f"{guide_id}-") # Scope leeren → Neustart blockiert nicht
|
||||
clear_scope(f"{guide_id}-") # clear scope → restart not blocked
|
||||
|
||||
|
||||
async def _set_progress(guide_id: str, progress: str) -> None:
|
||||
@@ -63,7 +63,7 @@ def _prompt(name: str, **kwargs) -> str:
|
||||
|
||||
|
||||
def _extra(instructions: str) -> str:
|
||||
return f"\n\nZUSÄTZLICHE ANWEISUNGEN VOM NUTZER:\n{instructions}\n" if instructions else ""
|
||||
return f"\n\nADDITIONAL INSTRUCTIONS FROM THE USER:\n{instructions}\n" if instructions else ""
|
||||
|
||||
|
||||
def _log(topic: str, msg: str) -> None:
|
||||
@@ -76,8 +76,8 @@ def _claude_error(label: str, returncode: int, stdout: str, stderr: str) -> str:
|
||||
return f"{label}: {stderr[:1000]}"
|
||||
tail = (stdout or "").strip()[-500:]
|
||||
if tail:
|
||||
return f"{label} (exit {returncode}, stderr leer): …{tail}"
|
||||
return f"{label} (exit {returncode}, ohne Ausgabe)"
|
||||
return f"{label} (exit {returncode}, stderr empty): …{tail}"
|
||||
return f"{label} (exit {returncode}, no output)"
|
||||
|
||||
|
||||
def _gather_error(label: str, results: list) -> str:
|
||||
@@ -87,7 +87,7 @@ def _gather_error(label: str, results: list) -> str:
|
||||
returncode, stdout, stderr = r
|
||||
if returncode != 0:
|
||||
return _claude_error(label, returncode, stdout, stderr)
|
||||
return f"{label}: kein verwertbares Ergebnis"
|
||||
return f"{label}: no usable result"
|
||||
|
||||
|
||||
def _timeout(step: str, n: int = 0) -> int:
|
||||
@@ -95,117 +95,103 @@ def _timeout(step: str, n: int = 0) -> int:
|
||||
return base + per * n
|
||||
|
||||
|
||||
def _probleme_schema(data):
|
||||
"""{"ok": true} → [] · {"probleme": [str]} → Liste · sonst None."""
|
||||
def _problems_schema(data):
|
||||
"""{"ok": true} → [] · {"problems": [str]} → list · else None."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
if data.get("ok") is True:
|
||||
return []
|
||||
p = data.get("probleme")
|
||||
p = data.get("problems")
|
||||
if not isinstance(p, list) or not p:
|
||||
return None
|
||||
out = [str(x).strip() for x in p if str(x).strip()]
|
||||
return out or None
|
||||
|
||||
|
||||
def _str_liste(val) -> list[str] | None:
|
||||
"""Liste nicht-leerer Strings → gestrippte Liste (leer erlaubt) · sonst None."""
|
||||
def _str_list(val) -> list[str] | None:
|
||||
"""List of non-empty strings → stripped list (empty allowed) · else None."""
|
||||
if not isinstance(val, list) or not all(isinstance(x, str) for x in val):
|
||||
return None
|
||||
out = [x.strip() for x in val]
|
||||
return None if any(not x for x in out) else out
|
||||
|
||||
|
||||
def _rest_schema(data):
|
||||
"""{"uebernehmen": [str]} → Liste (leer erlaubt) · sonst None."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return _str_liste(data.get("uebernehmen"))
|
||||
|
||||
|
||||
def _runde_schema(data, final: bool = False):
|
||||
"""{"aufnehmen": [str], "rest": [str]} → (aufnehmen, rest) · sonst None.
|
||||
"""{"keep": [str], "rest": [str]} → (include, rest) · else None.
|
||||
|
||||
final=True: letzte Klärungs-Runde — ein nicht-leerer Rest ist ungültig.
|
||||
final=True: last clarification round — a non-empty rest is invalid.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
aufnehmen = _str_liste(data.get("aufnehmen"))
|
||||
rest = _str_liste(data.get("rest"))
|
||||
if aufnehmen is None or rest is None or (final and rest):
|
||||
include = _str_list(data.get("keep"))
|
||||
rest = _str_list(data.get("rest"))
|
||||
if include is None or rest is None or (final and rest):
|
||||
return None
|
||||
return aufnehmen, rest
|
||||
return include, rest
|
||||
|
||||
|
||||
def _stufen_schema(data, ids: set[int] | None = None):
|
||||
"""{"stufen": {"1": "einfach", …}} → {id: stufe} · sonst None.
|
||||
_RELEVANCE = ("relevant", "peripheral")
|
||||
_YESNO = ("ja", "nein")
|
||||
|
||||
Stufe ∈ {einfach, mittel, schwer}. Sind `ids` gegeben, müssen mindestens
|
||||
diese abgedeckt sein (Extras erlaubt); der Aufrufer filtert dann auf `ids`.
|
||||
"""
|
||||
if not isinstance(data, dict) or not isinstance(data.get("stufen"), dict) or not data["stufen"]:
|
||||
return None
|
||||
out: dict[int, str] = {}
|
||||
for k, v in data["stufen"].items():
|
||||
try:
|
||||
num = int(k)
|
||||
except (ValueError, TypeError):
|
||||
|
||||
def _enum_map_schema(key: str, allowed):
|
||||
"""Factory for `{"<key>": {"1": value, …}}` → `{id: value}` parsers; value ∈ `allowed`
|
||||
(casefolded). If `ids` are given, at least these must be covered (extras allowed). None
|
||||
on any invalid id/value or wrong shape. The caller filters the result to `ids`."""
|
||||
def parse(data, ids: set[int] | None = None):
|
||||
if not isinstance(data, dict) or not isinstance(data.get(key), dict) or not data[key]:
|
||||
return None
|
||||
stufe = str(v).strip().casefold()
|
||||
if stufe not in _STUFEN:
|
||||
out: dict[int, str] = {}
|
||||
for k, v in data[key].items():
|
||||
try:
|
||||
num = int(k)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
value = str(v).strip().casefold()
|
||||
if value not in allowed:
|
||||
return None
|
||||
out[num] = value
|
||||
if ids is not None and not ids <= set(out):
|
||||
return None
|
||||
out[num] = stufe
|
||||
if ids is not None and not ids <= set(out):
|
||||
return None
|
||||
return out
|
||||
return out
|
||||
return parse
|
||||
|
||||
|
||||
_RELEVANZ = ("relevant", "rand")
|
||||
|
||||
|
||||
def _relevanz_schema(data, ids: set[int] | None = None):
|
||||
"""{"relevanz": {"1": "relevant", …}} → {id: relevanz} · sonst None.
|
||||
|
||||
Relevanz ∈ {relevant, rand} (binär). Wie `_stufen_schema`: sind `ids` gegeben,
|
||||
müssen mindestens diese abgedeckt sein (Extras erlaubt).
|
||||
"""
|
||||
if not isinstance(data, dict) or not isinstance(data.get("relevanz"), dict) or not data["relevanz"]:
|
||||
return None
|
||||
out: dict[int, str] = {}
|
||||
for k, v in data["relevanz"].items():
|
||||
try:
|
||||
num = int(k)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
wert = str(v).strip().casefold()
|
||||
if wert not in _RELEVANZ:
|
||||
return None
|
||||
out[num] = wert
|
||||
if ids is not None and not ids <= set(out):
|
||||
return None
|
||||
return out
|
||||
_levels_schema = _enum_map_schema("levels", _STUFEN) # level ∈ beginner/advanced/expert
|
||||
_relevance_schema = _enum_map_schema("relevance", _RELEVANCE) # relevance ∈ relevant/peripheral
|
||||
_yesno_schema = _enum_map_schema("relevant", _YESNO) # triage gate ∈ ja/nein
|
||||
|
||||
|
||||
_MAX_RESTARTS = 2
|
||||
|
||||
|
||||
async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: int, provider: str, on_update=None, cancelled=None, *, grace: int | None = None) -> list | None:
|
||||
"""Startet alle Slots parallel und sammelt `quorum` gültige Ergebnisse.
|
||||
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, min_runtime: int | None = None, max_runtime: int | None = None) -> list | None:
|
||||
"""Starts all slots in parallel and collects `quorum` valid results.
|
||||
|
||||
Slot-Spec: {key, prompt, role, capabilities, payload}. `payload(result)`
|
||||
prüft die Gültigkeit und liefert das Slot-Ergebnis oder None.
|
||||
Fehler/Timeout/ungültig → Slot-Neustart (max. _MAX_RESTARTS). Sobald das
|
||||
Quorum steht, werden die übrigen Agenten gekillt. None = Quorum verfehlt.
|
||||
`cancelled()` → True bricht ab (keine Restarts, Rückgabe None).
|
||||
Slot spec: {key, prompt, role, capabilities, payload}. `payload(result)`
|
||||
checks validity and returns the slot result or None.
|
||||
Error/timeout/invalid → slot restart (max. _MAX_RESTARTS). As soon as the
|
||||
quorum stands, the remaining agents are killed. None = quorum missed.
|
||||
`cancelled()` → True aborts (no restarts, returns None).
|
||||
|
||||
Mit `grace` wird `quorum` zum Minimum: Das erste gültige Ergebnis startet
|
||||
einen Timer von `grace` Sekunden. Nach dessen Ablauf werden laufende
|
||||
Agenten nur gekillt, wenn das Minimum steht — sonst läuft das Race samt
|
||||
Restarts weiter, bis es steht. Rückgabe: `quorum` bis `len(slots)` Ergebnisse.
|
||||
With `grace`, `quorum` becomes the minimum: the first valid result starts
|
||||
a timer of `grace` seconds. After it expires, running agents are only
|
||||
killed if the minimum stands — otherwise the race, including restarts,
|
||||
keeps running until it stands. Returns: `quorum` to `len(slots)` results.
|
||||
|
||||
`min_runtime` (wall-clock from start): the race does not return before it
|
||||
elapses while agents are still running — gives them time to search thoroughly.
|
||||
`max_runtime` (wall-clock from start): hard cap — returns whatever is collected
|
||||
(or None if nothing), killing the rest. Both default off; only Research sets them.
|
||||
"""
|
||||
attempts = {i: 0 for i in range(len(slots))}
|
||||
tasks: dict[asyncio.Task, int] = {}
|
||||
loop = asyncio.get_running_loop()
|
||||
start = loop.time()
|
||||
min_deadline = start + min_runtime if min_runtime else None
|
||||
max_deadline = start + max_runtime if max_runtime else None
|
||||
deadline: float | None = None
|
||||
|
||||
def spawn(i: int) -> None:
|
||||
@@ -213,6 +199,7 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
||||
task = asyncio.create_task(run_agent(
|
||||
slot["key"], slot["prompt"], timeout,
|
||||
provider=provider, role=slot["role"], capabilities=slot["capabilities"],
|
||||
scope=topic, on_line=slot.get("on_line"),
|
||||
))
|
||||
tasks[task] = i
|
||||
|
||||
@@ -224,12 +211,22 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
||||
while tasks:
|
||||
if cancelled and cancelled():
|
||||
return None
|
||||
if deadline is not None and len(results) >= quorum and loop.time() >= deadline:
|
||||
# Hard wall-clock cap: return whatever we have (None if empty), kill the rest.
|
||||
if max_deadline is not None and loop.time() >= max_deadline:
|
||||
_log(topic, f"{label}: max runtime {max_runtime}s reached ({len(results)} valid)")
|
||||
return results or None
|
||||
min_ok = min_deadline is None or loop.time() >= min_deadline
|
||||
if deadline is not None and len(results) >= quorum and loop.time() >= deadline and min_ok:
|
||||
return results
|
||||
# Grace gesetzt und Minimum erreicht → nur bis zum Deadline-Rest warten
|
||||
wait_timeout = None
|
||||
# Wake up for the earliest relevant deadline (grace, min, or max).
|
||||
waits = []
|
||||
if deadline is not None and len(results) >= quorum:
|
||||
wait_timeout = max(0.0, deadline - loop.time())
|
||||
waits.append(deadline - loop.time())
|
||||
if min_deadline is not None:
|
||||
waits.append(min_deadline - loop.time())
|
||||
if max_deadline is not None:
|
||||
waits.append(max_deadline - loop.time())
|
||||
wait_timeout = max(0.0, min(waits)) if waits else None
|
||||
done, _ = await asyncio.wait(tasks.keys(), return_when=asyncio.FIRST_COMPLETED, timeout=wait_timeout)
|
||||
if not done:
|
||||
continue
|
||||
@@ -239,13 +236,13 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
||||
try:
|
||||
result = task.result()
|
||||
if result[0] != 0:
|
||||
err = _claude_error("Fehler", *result)
|
||||
err = _claude_error("Error", *result)
|
||||
else:
|
||||
payload = slots[i]["payload"](result)
|
||||
if payload is None:
|
||||
err = "Ergebnis ungültig/nicht parsebar"
|
||||
err = "result invalid/not parseable"
|
||||
except asyncio.TimeoutError:
|
||||
err = f"Timeout nach {timeout}s"
|
||||
err = f"Timeout after {timeout}s"
|
||||
except Exception as e:
|
||||
err = f"{type(e).__name__}: {e}"
|
||||
|
||||
@@ -253,23 +250,24 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
||||
results.append(payload)
|
||||
if grace is not None and deadline is None:
|
||||
deadline = loop.time() + grace
|
||||
_log(topic, f"{label}: erstes Ergebnis — Grace {grace}s läuft")
|
||||
_log(topic, f"{label}: first result — grace {grace}s running")
|
||||
if on_update:
|
||||
on_update(len(results))
|
||||
if len(results) >= quorum and (grace is None or loop.time() >= deadline):
|
||||
if (len(results) >= quorum and (grace is None or loop.time() >= deadline)
|
||||
and (min_deadline is None or loop.time() >= min_deadline)):
|
||||
return results
|
||||
continue
|
||||
|
||||
_log(topic, f"{label} {i + 1} (Versuch {attempts[i] + 1}): {err}")
|
||||
_log(topic, f"{label} {i + 1} (attempt {attempts[i] + 1}): {err}")
|
||||
attempts[i] += 1
|
||||
# Steht das Minimum schon, sind Restarts sinnlos — der Neustart
|
||||
# würde am Grace-Ende ohnehin gekillt.
|
||||
satt = grace is not None and len(results) >= quorum
|
||||
if attempts[i] <= _MAX_RESTARTS and not satt and not (cancelled and cancelled()):
|
||||
# If the minimum already stands, restarts are pointless — the restart
|
||||
# would be killed at the grace end anyway.
|
||||
enough = grace is not None and len(results) >= quorum
|
||||
if attempts[i] <= _MAX_RESTARTS and not enough and not (cancelled and cancelled()):
|
||||
spawn(i)
|
||||
if len(results) >= quorum: # alle Slots durch, Minimum steht (nur mit grace erreichbar)
|
||||
if len(results) >= quorum: # all slots done, minimum stands (only reachable with grace)
|
||||
return results
|
||||
_log(topic, f"{label}: Quorum {quorum} nicht erreicht ({len(results)} gültig)")
|
||||
_log(topic, f"{label}: quorum {quorum} not reached ({len(results)} valid)")
|
||||
return None
|
||||
finally:
|
||||
for task, i in tasks.items():
|
||||
@@ -281,26 +279,26 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
||||
|
||||
@dataclass
|
||||
class GenContext:
|
||||
"""Durchgereichte Pipeline-Parameter — erspart lange Argument-Signaturen."""
|
||||
"""Pipeline parameters passed through — saves long argument signatures."""
|
||||
topic: str
|
||||
provider: str
|
||||
is_cancelled: Callable[[], bool]
|
||||
guide_id: str | None = None
|
||||
|
||||
|
||||
# Ergebnis-Status von run_single_slot
|
||||
# Result status of run_single_slot
|
||||
OK, CANCELLED, FAILED = "ok", "cancelled", "failed"
|
||||
|
||||
|
||||
async def run_single_slot(
|
||||
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, on_line=None,
|
||||
) -> 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, "on_line": on_line}]
|
||||
res = await _race(ctx.topic, label, slots, 1, timeout, ctx.provider, cancelled=ctx.is_cancelled)
|
||||
if ctx.is_cancelled():
|
||||
return CANCELLED, None
|
||||
@@ -309,9 +307,9 @@ async def run_single_slot(
|
||||
return OK, res[0]
|
||||
|
||||
|
||||
async def _gather_fortschritt(coros, total, melde, start=0):
|
||||
"""Läuft `coros` nebenläufig und meldet Live-Fortschritt: `await melde(fertig, total)`
|
||||
nach jedem Abschluss (und einmal initial). Ergebnisse in Reihenfolge, return_exceptions=True."""
|
||||
async def _gather_progress(coros, total, report, start=0):
|
||||
"""Runs `coros` concurrently and reports live progress: `await report(done, total)`
|
||||
after each completion (and once initially). Results in order, return_exceptions=True."""
|
||||
done = start
|
||||
|
||||
async def wrap(c):
|
||||
@@ -320,9 +318,7 @@ async def _gather_fortschritt(coros, total, melde, start=0):
|
||||
return await c
|
||||
finally:
|
||||
done += 1
|
||||
await melde(done, total)
|
||||
await report(done, total)
|
||||
|
||||
await melde(done, total)
|
||||
await report(done, total)
|
||||
return await asyncio.gather(*[wrap(c) for c in coros], return_exceptions=True)
|
||||
|
||||
|
||||
|
||||
115
backend/readability.py
Normal file
115
backend/readability.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Deterministic readability gate for guide sections.
|
||||
|
||||
A small German complexity model (DistilBERT, GermEval 2022, scale 1–7) rates the
|
||||
readability of the prose. guide.py feeds sections that are too hard into the existing
|
||||
read-exam/revision loop — no prompt, no guessing.
|
||||
|
||||
Optional: if `transformers`/`torch` are missing or the model won't load, the gate is
|
||||
silently disabled (the backend keeps running unchanged). CPU is enough; the caller
|
||||
wraps the scoring in `asyncio.to_thread` (blocking model inference).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from config import (
|
||||
READABILITY_ACTIVE, READABILITY_HARD, READABILITY_HARD_SHARE, READABILITY_MAX, READABILITY_MODEL,
|
||||
)
|
||||
|
||||
log = logging.getLogger("creator.readability")
|
||||
|
||||
_model_cache = None # (tokenizer, model, torch) — singleton
|
||||
_load_attempt = False # already tried to load?
|
||||
|
||||
# Strip markup → plain prose (code does not count toward readability).
|
||||
_CODE_FENCE = re.compile(r"```.*?```", re.DOTALL)
|
||||
_COMMENT = re.compile(r"<!--.*?-->", re.DOTALL)
|
||||
_INLINE_CODE = re.compile(r"`[^`]*`")
|
||||
_LINK = re.compile(r"\[([^\]]*)\]\([^)]*\)")
|
||||
_MD_MARK = re.compile(r"^[ \t]*([#>]+|[-*+]\s)|[*_~|]", re.MULTILINE)
|
||||
_WS = re.compile(r"\s+")
|
||||
_SENTENCE = re.compile(r"(?<=[.!?])\s+")
|
||||
|
||||
|
||||
def _model():
|
||||
"""Load the model once. None = gate off (disabled or load error)."""
|
||||
global _model_cache, _load_attempt
|
||||
if _load_attempt:
|
||||
return _model_cache
|
||||
_load_attempt = True
|
||||
if not READABILITY_ACTIVE:
|
||||
return None
|
||||
try:
|
||||
import torch
|
||||
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
||||
tok = AutoTokenizer.from_pretrained(READABILITY_MODEL)
|
||||
model = AutoModelForSequenceClassification.from_pretrained(READABILITY_MODEL)
|
||||
model.eval()
|
||||
_model_cache = (tok, model, torch)
|
||||
log.info("readability model loaded: %s (num_labels=%d)", READABILITY_MODEL, model.config.num_labels)
|
||||
except Exception as e:
|
||||
log.warning("readability gate disabled (model not loadable): %s", e)
|
||||
_model_cache = None
|
||||
return _model_cache
|
||||
|
||||
|
||||
def _prose(md: str) -> str:
|
||||
"""Strip markdown/code → plain prose for scoring."""
|
||||
t = _CODE_FENCE.sub(" ", md)
|
||||
t = _COMMENT.sub(" ", t)
|
||||
t = _INLINE_CODE.sub(" ", t)
|
||||
t = _LINK.sub(r"\1", t)
|
||||
t = _MD_MARK.sub(" ", t)
|
||||
return _WS.sub(" ", t).strip()
|
||||
|
||||
|
||||
def _sentences(text: str) -> list[str]:
|
||||
"""Split prose into sentences; discard very short fragments."""
|
||||
return [s.strip() for s in _SENTENCE.split(text) if len(s.strip()) >= 15]
|
||||
|
||||
|
||||
def _scores(sentences: list[str]) -> list[float]:
|
||||
"""Complexity per sentence (1–7). Regression (num_labels=1) or expectation over classes."""
|
||||
tok, model, torch = _model_cache
|
||||
values: list[float] = []
|
||||
n = model.config.num_labels
|
||||
for i in range(0, len(sentences), 16):
|
||||
batch = sentences[i:i + 16]
|
||||
enc = tok(batch, return_tensors="pt", truncation=True, max_length=256, padding=True)
|
||||
with torch.no_grad():
|
||||
logits = model(**enc).logits
|
||||
if n == 1:
|
||||
vals = logits.reshape(-1).tolist()
|
||||
else:
|
||||
probs = torch.softmax(logits, dim=-1)
|
||||
levels = torch.arange(1, n + 1, dtype=probs.dtype)
|
||||
vals = (probs * levels).sum(-1).reshape(-1).tolist()
|
||||
values.extend(vals)
|
||||
return values
|
||||
|
||||
|
||||
def rate_sections(md_by_num: dict[int, str]) -> dict[int, str]:
|
||||
"""{num: section_md} → {num: hint} only for sections that are too hard.
|
||||
|
||||
Empty dict if the gate is off. Blocking (CPU) — call inside to_thread.
|
||||
"""
|
||||
if _model() is None:
|
||||
return {}
|
||||
out: dict[int, str] = {}
|
||||
for num, md in md_by_num.items():
|
||||
sentences = _sentences(_prose(md or ""))
|
||||
if len(sentences) < 2: # almost only code / too short → skip
|
||||
continue
|
||||
values = _scores(sentences)
|
||||
if not values:
|
||||
continue
|
||||
mean = sum(values) / len(values)
|
||||
hard = sum(1 for w in values if w > READABILITY_HARD) / len(values)
|
||||
# Too hard = high mean OR too many hard individual sentences (outlier nests).
|
||||
if mean > READABILITY_MAX or hard >= READABILITY_HARD_SHARE:
|
||||
# German revision hint fed to the (German-writing) writer agent — kept German on purpose.
|
||||
out[num] = (
|
||||
f"Zu schwer lesbar (Ø {mean:.1f}/7, {hard * 100:.0f}% harte Sätze): "
|
||||
"kürzere Sätze, einfachere Wörter, weniger Schachtelsätze, mehr Examples."
|
||||
)
|
||||
return out
|
||||
@@ -1,140 +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_levels_all, list_guides, list_progress_all
|
||||
from guide import guide_slot_dateien
|
||||
from paths import bausteine_path, guide_content_path
|
||||
from textkit import _norm_titel
|
||||
|
||||
MAX_OFFENE_GUIDES = 3
|
||||
# Nur noch EIN Format „Guide" (alle relevanten Bausteine, Prüfung 0–30). Keine Progression,
|
||||
# keine Vorstufe → der Guide ist immer freischaltbar. „Rest"/FullGuide separat.
|
||||
VORSTUFE: dict[str, str] = {}
|
||||
FREISCHALT_LEVEL: dict[str, str] = {}
|
||||
FORMATE = ("Guide",)
|
||||
|
||||
# Geforderter Score je Freischalt-Niveau — nur für die Sperr-Meldung.
|
||||
_LEVEL_WORT = {
|
||||
"absolviert": "absolvieren (alle Bausteine auf 6)",
|
||||
"verstanden": "verstehen (alle Bausteine auf 12)",
|
||||
"gemeistert": "meistern (alle Bausteine auf 18)",
|
||||
}
|
||||
|
||||
|
||||
async def lade_lernstand() -> tuple[list[dict], dict[str, set[str]], dict[str, dict[str, set[str]]]]:
|
||||
"""Guides + Kapitel-Fortschritt + Bausteine je Meilenstein.
|
||||
|
||||
levels: {"absolviert"/"verstanden"/"gemeistert": {topic → normalisierte Titel}}.
|
||||
"""
|
||||
roh = await list_baustein_levels_all()
|
||||
levels = {
|
||||
stufe: {topic: {_norm_titel(b) for b in titel} for topic, titel in pro_topic.items()}
|
||||
for stufe, pro_topic in roh.items()
|
||||
}
|
||||
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 _kapitel_titel(topic: str, fmt: str) -> set[str] | None:
|
||||
content = _content_json(topic, fmt)
|
||||
if content is None:
|
||||
return None
|
||||
return {c.get("title") for c in content.get("chapters", [])}
|
||||
|
||||
|
||||
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 absolviert (≥3)?"""
|
||||
return ist_level(topic, fmt, guides, progress, levels["absolviert"])
|
||||
|
||||
|
||||
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 gemeistert (18)?"""
|
||||
return ist_level(topic, "Guide", guides, progress, levels["gemeistert"])
|
||||
|
||||
|
||||
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["absolviert"]))
|
||||
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
|
||||
@@ -2,6 +2,7 @@ fastapi
|
||||
uvicorn[standard]
|
||||
aiosqlite
|
||||
playwright
|
||||
trafilatura
|
||||
transformers
|
||||
# torch NICHT hier listen — sonst zieht pip die CUDA-Variante (~2,5 GB).
|
||||
# Es wird separat als CPU-Build installiert (Dockerfile + Makefile-Target `install`).
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import asyncio
|
||||
import json
|
||||
import shutil
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse, Response
|
||||
from fastapi.responses import Response
|
||||
|
||||
from agents import provider_available
|
||||
from config import PROJECTS_DIR, UNI_DIR, PROVIDERS
|
||||
@@ -13,30 +14,33 @@ from database import (
|
||||
create_topic, list_topics as db_list_topics, delete_topic,
|
||||
list_progress, set_progress, delete_progress,
|
||||
create_element, list_elements, get_element, update_element, delete_element,
|
||||
list_baustein_progress, get_baustein_progress, set_offene_frage,
|
||||
set_baustein_score, set_baustein_absolviert, set_baustein_verstanden, set_baustein_gemeistert, delete_baustein_daten,
|
||||
delete_topic_pipeline, delete_quelle, get_guide_content, delete_guide_content,
|
||||
list_block_progress, get_block_progress, set_open_question,
|
||||
set_block_score_and_streak, set_block_completed,
|
||||
delete_block_data, delete_block_progress, subs_per_level, subs_per_level_raw,
|
||||
delete_topic_pipeline, delete_source, get_guide_content, delete_guide_content,
|
||||
get_sub_artefakte,
|
||||
)
|
||||
from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, lade_quelle, lade_uebersicht, subbausteine_titel, lade_frage_muster
|
||||
from blocks import generate_blocks, cancel_blocks, blocks_status, active_blocks, reset_blocks, reset_blocks_ab_step, load_source, load_overview, subblocks_title, subblocks_frei, load_question_pattern, load_question_pattern_free
|
||||
from elements import generate_element, chat_with_guide, chat_with_element, check_element, style_element, refine_suggestion
|
||||
from lernen import NOETIG, MASTERY, MEISTERN, NIVEAUS, 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, score_berechnen, floor_aus_meilensteinen, _zufall_malus
|
||||
from guide import generate_guide, guide_slot_dateien, guide_fertig_step
|
||||
from learning import block_chat, block_discussion, create_block_element, exam_rating, exam_rating_fast, exam_question, exam_question_variant, generate_quiz, generate_gapchoice, generate_gaptext, check_gaptext, hurdles_distractor_block, compute_score, floor_from_score, level_from_score, cap_final, cap_aktuell, freie_level, thresholds, points_delta, cap_followup
|
||||
from guide import generate_guide, guide_slot_files, guide_done_step, block_pruefen, block_adopt, content_fuer_level
|
||||
from pipeline import cancel_guide
|
||||
from regeln import FORMATE, formate_stats, guide_lock, ist_absolviert, lade_lernstand, thema_abgeschlossen
|
||||
from rules import FORMATE, formats_stats, guide_lock, ist_completed, load_learnstate, topic_completed
|
||||
from models import (
|
||||
GuideCreateRequest, GuideResponse,
|
||||
TopicCreateRequest,
|
||||
BausteineCreateRequest, BausteineStatusResponse,
|
||||
BlocksCreateRequest, BlocksResetStepRequest, BlocksStatusResponse,
|
||||
GuideChatRequest, GuideChatResponse,
|
||||
ElementCreateRequest, ElementChatRequest, ElementChatResponse, ElementResponse,
|
||||
ElementUpdateRequest, ElementCheckRequest, ElementCheckResponse, ElementStyleResponse,
|
||||
ElementRefineRequest, ElementRefineResponse,
|
||||
ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo,
|
||||
FolderResponse, BausteineQuelleUpdate, BausteineQuelleResponse, BausteinUebersicht,
|
||||
BausteinChatRequest, BausteinChatResponse,
|
||||
BausteinPruefungRequest, BausteinPruefungResponse, BausteinLernstandResponse,
|
||||
FolderResponse, BlocksSourceUpdate, BlocksSourceResponse, BlockOverview,
|
||||
BlockChatRequest, BlockChatResponse,
|
||||
BlockExamRequest, BlockExamResponse, BlockLearnStateResponse,
|
||||
BlockPruefenRequest, BlockPruefenResponse, BlockUebernehmenRequest, BlockUebernehmenResponse,
|
||||
)
|
||||
from paths import bausteine_topics, guide_content_path, project_dir, topic_dir, quelle_path, safe_ordner
|
||||
from paths import blocks_topics, guide_content_path, project_dir, topic_dir, source_path, safe_folder
|
||||
from fsutil import atomic_write_json
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
@@ -52,28 +56,28 @@ async def get_topics():
|
||||
db_topics = await db_list_topics()
|
||||
guides = await list_guides()
|
||||
derived = {g["topic"] for g in guides}
|
||||
derived.update(bausteine_topics())
|
||||
derived.update(job["topic"] for job in active_bausteine())
|
||||
# DB ist führend (Reihenfolge: neueste zuerst); Abgeleitetes ohne DB-Eintrag hinten anhängen
|
||||
derived.update(blocks_topics())
|
||||
derived.update(job["topic"] for job in active_blocks())
|
||||
# DB is authoritative (order: newest first); append derived entries without a DB row at the end
|
||||
return db_topics + sorted(derived - set(db_topics))
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_stats():
|
||||
"""Tracker: Themen-Anzahl + pro Format erstellt/absolviert."""
|
||||
guides, progress, levels = await lade_lernstand()
|
||||
themen = set(await db_list_topics()) | {g["topic"] for g in guides} | set(bausteine_topics())
|
||||
"""Tracker: number of topics + per format created/completed."""
|
||||
guides, progress, levels = await load_learnstate()
|
||||
topics = set(await db_list_topics()) | {g["topic"] for g in guides} | set(blocks_topics())
|
||||
if PROJECTS_DIR.is_dir():
|
||||
themen |= {e.name for e in PROJECTS_DIR.iterdir() if e.is_dir()}
|
||||
return {"themen": len(themen), "formate": formate_stats(guides, progress, levels)}
|
||||
topics |= {e.name for e in PROJECTS_DIR.iterdir() if e.is_dir()}
|
||||
return {"topics": len(topics), "formats": formats_stats(guides, progress, levels)}
|
||||
|
||||
|
||||
@router.get("/topics/fortschritt")
|
||||
async def topic_fortschritt(topic: str):
|
||||
"""Absolviert-Status pro Format + Themen-Abschluss — fürs Freischalten der nächsten Ausbaustufe."""
|
||||
guides, progress, levels = await lade_lernstand()
|
||||
status = {fmt: ist_absolviert(topic, fmt, guides, progress, levels) for fmt in FORMATE}
|
||||
status["abgeschlossen"] = thema_abgeschlossen(topic, guides, progress, levels)
|
||||
@router.get("/topics/progress")
|
||||
async def topic_progress(topic: str):
|
||||
"""Completion status per format + topic completion — for unlocking the next expansion stage."""
|
||||
guides, progress, levels = await load_learnstate()
|
||||
status = {fmt: ist_completed(topic, fmt, guides, progress, levels) for fmt in FORMATE}
|
||||
status["completed"] = topic_completed(topic, guides, progress, levels)
|
||||
return status
|
||||
|
||||
|
||||
@@ -86,9 +90,9 @@ async def add_topic(req: TopicCreateRequest):
|
||||
@router.delete("/topics")
|
||||
async def remove_topic(topic: str):
|
||||
await delete_topic(topic)
|
||||
await delete_baustein_daten(topic)
|
||||
await delete_block_data(topic)
|
||||
await delete_topic_pipeline(topic)
|
||||
await delete_quelle(topic) # Themen-Config (DB) — beim Thema-Löschen mit weg
|
||||
await delete_source(topic) # topic config (DB) — removed together with the topic
|
||||
await delete_guide_content(topic)
|
||||
shutil.rmtree(topic_dir(topic), ignore_errors=True)
|
||||
return {"ok": True}
|
||||
@@ -96,7 +100,7 @@ async def remove_topic(topic: str):
|
||||
|
||||
def _safe_project_name(name: str) -> str:
|
||||
if not name or "/" in name or "\\" in name or ".." in name or "\x00" in name:
|
||||
raise HTTPException(400, "Ungültiger Projektname")
|
||||
raise HTTPException(400, "Invalid project name")
|
||||
return name
|
||||
|
||||
|
||||
@@ -112,344 +116,390 @@ async def remove_project(name: str):
|
||||
_safe_project_name(name)
|
||||
pdir = project_dir(name)
|
||||
if not pdir.is_dir():
|
||||
raise HTTPException(404, "Projekt nicht gefunden")
|
||||
raise HTTPException(404, "Project not found")
|
||||
shutil.rmtree(pdir)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/folders", response_model=list[FolderResponse])
|
||||
async def list_folders(kind: str):
|
||||
"""Ordner für die Quellen-Auswahl: kind=projekt → projects/, kind=uni → uni/."""
|
||||
"""Folders for the source selection: kind=projekt → projects/, kind=uni → uni/."""
|
||||
base = {"projekt": (PROJECTS_DIR, "projects"), "uni": (UNI_DIR, "uni")}.get(kind)
|
||||
if base is None:
|
||||
raise HTTPException(400, "kind muss 'projekt' oder 'uni' sein")
|
||||
raise HTTPException(400, "kind must be 'projekt' or 'uni'")
|
||||
root, prefix = base
|
||||
if not root.is_dir():
|
||||
return []
|
||||
return [{"name": e.name, "ort": f"{prefix}/{e.name}"} for e in sorted(root.iterdir()) if e.is_dir()]
|
||||
return [{"name": e.name, "location": f"{prefix}/{e.name}"} for e in sorted(root.iterdir()) if e.is_dir()]
|
||||
|
||||
|
||||
# --- Bausteine ---
|
||||
# --- Blocks ---
|
||||
|
||||
@router.get("/bausteine/status", response_model=BausteineStatusResponse)
|
||||
async def get_bausteine_status(topic: str):
|
||||
return bausteine_status(topic)
|
||||
@router.get("/blocks/status", response_model=BlocksStatusResponse)
|
||||
async def get_blocks_status(topic: str):
|
||||
return await blocks_status(topic)
|
||||
|
||||
|
||||
@router.get("/bausteine/active")
|
||||
async def get_active_bausteine():
|
||||
return active_bausteine()
|
||||
@router.get("/blocks/active")
|
||||
async def get_active_blocks():
|
||||
return active_blocks()
|
||||
|
||||
|
||||
@router.post("/bausteine")
|
||||
async def create_bausteine(req: BausteineCreateRequest):
|
||||
@router.post("/blocks")
|
||||
async def create_blocks(req: BlocksCreateRequest):
|
||||
topic = req.topic.strip()
|
||||
if bausteine_status(topic)["generating"]:
|
||||
if (await blocks_status(topic))["generating"]:
|
||||
return {"ok": True, "status": "already_generating"}
|
||||
await create_topic(topic)
|
||||
qp = quelle_path(topic)
|
||||
# Quelle nur beim ERSTEN Mal festschreiben; ▶/Resume erhält die bestehende Wahl.
|
||||
qp = source_path(topic)
|
||||
# Persist the source only the FIRST time; ▶/Resume keeps the existing choice.
|
||||
if not qp.exists():
|
||||
typ, ort = req.source_type, req.source_ort.strip()
|
||||
if typ in ("projekt", "uni"):
|
||||
ordner = safe_ordner(ort)
|
||||
if ordner is None or not ordner.is_dir():
|
||||
raise HTTPException(400, "Ordner ungültig oder nicht gefunden (Pfad relativ zum Projekt-Root, kein ../).")
|
||||
elif typ == "link":
|
||||
if not ort.lower().startswith(("http://", "https://")):
|
||||
raise HTTPException(400, "Link muss mit http:// oder https:// beginnen.")
|
||||
type, location = req.source_type, req.source_location.strip()
|
||||
if type in ("projekt", "uni"):
|
||||
folder = safe_folder(location)
|
||||
if folder is None or not folder.is_dir():
|
||||
raise HTTPException(400, "Folder invalid or not found (path relative to the project root, no ../).")
|
||||
elif type == "link":
|
||||
if not location.lower().startswith(("http://", "https://")):
|
||||
raise HTTPException(400, "Link must start with http:// or https://.")
|
||||
qp.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(qp, {"type": typ, "ort": ort, "spec": req.instructions.strip()})
|
||||
asyncio.create_task(generate_bausteine(topic, req.instructions.strip(), req.provider, ab_phase=req.ab_phase))
|
||||
atomic_write_json(qp, {"type": type, "location": location, "spec": req.instructions.strip()})
|
||||
asyncio.create_task(generate_blocks(topic, req.instructions.strip(), req.provider, ab_phase=req.ab_phase, ab_step=req.ab_step, to_step=req.to_step, research=req.research))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/bausteine/cancel")
|
||||
async def cancel_bausteine_route(topic: str):
|
||||
if not cancel_bausteine(topic):
|
||||
raise HTTPException(404, "Keine laufende Generierung")
|
||||
@router.post("/blocks/cancel")
|
||||
async def cancel_blocks_route(topic: str):
|
||||
if not cancel_blocks(topic):
|
||||
raise HTTPException(404, "No running generation")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/bausteine")
|
||||
async def remove_bausteine(topic: str):
|
||||
reset_bausteine(topic) # Dateien: Crawl + Sichtung + Inventar…Fragen weg; quelle.json bleibt
|
||||
await delete_topic_pipeline(topic) # DB: Bausteine-Bereich weg; Themen-Config (quelle) bleibt
|
||||
@router.delete("/blocks")
|
||||
async def remove_blocks(topic: str):
|
||||
reset_blocks(topic) # Files: crawl + triage + inventory…questions gone; source.json stays
|
||||
await delete_topic_pipeline(topic) # DB: blocks area gone; topic config (source) stays
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _validate_quelle(typ: str, ort: str) -> None:
|
||||
"""Quellen-Eingabe prüfen (gleiche Regeln wie beim Erstellen)."""
|
||||
if typ in ("projekt", "uni"):
|
||||
ordner = safe_ordner(ort)
|
||||
if ordner is None or not ordner.is_dir():
|
||||
raise HTTPException(400, "Ordner ungültig oder nicht gefunden (Pfad relativ zum Projekt-Root, kein ../).")
|
||||
elif typ == "link":
|
||||
if not ort.lower().startswith(("http://", "https://")):
|
||||
raise HTTPException(400, "Link muss mit http:// oder https:// beginnen.")
|
||||
@router.post("/blocks/reset-step")
|
||||
async def reset_blocks_step(req: BlocksResetStepRequest):
|
||||
topic = req.topic.strip()
|
||||
if (await blocks_status(topic))["generating"]:
|
||||
return {"ok": True, "status": "generating"} # don't interfere with a running generation
|
||||
await reset_blocks_ab_step(topic, req.ab_step)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/bausteine/quelle", response_model=BausteineQuelleResponse)
|
||||
async def get_bausteine_quelle(topic: str):
|
||||
return lade_quelle(topic)
|
||||
@router.delete("/blocks/progress")
|
||||
async def reset_block_progress(topic: str, block: str):
|
||||
"""Reset learning progress of ONE block to zero (score/streak/flags/open question)."""
|
||||
await delete_block_progress(topic, block)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.put("/bausteine/quelle", response_model=BausteineQuelleResponse)
|
||||
async def update_bausteine_quelle(req: BausteineQuelleUpdate):
|
||||
"""Nur speichern — KEINE Neugenerierung. Quellen-/Spec-Wahl überschreiben."""
|
||||
topic, typ, ort = req.topic.strip(), req.type, req.ort.strip()
|
||||
_validate_quelle(typ, ort)
|
||||
qp = quelle_path(topic)
|
||||
def _validate_source(type: str, location: str) -> None:
|
||||
"""Check source input (same rules as on creation)."""
|
||||
if type in ("projekt", "uni"):
|
||||
folder = safe_folder(location)
|
||||
if folder is None or not folder.is_dir():
|
||||
raise HTTPException(400, "Folder invalid or not found (path relative to the project root, no ../).")
|
||||
elif type == "link":
|
||||
if not location.lower().startswith(("http://", "https://")):
|
||||
raise HTTPException(400, "Link must start with http:// or https://.")
|
||||
|
||||
|
||||
@router.get("/blocks/source", response_model=BlocksSourceResponse)
|
||||
async def get_blocks_source(topic: str):
|
||||
return load_source(topic)
|
||||
|
||||
|
||||
@router.put("/blocks/source", response_model=BlocksSourceResponse)
|
||||
async def update_blocks_source(req: BlocksSourceUpdate):
|
||||
"""Only save — NO regeneration. Overwrite the source/spec choice."""
|
||||
topic, type, location = req.topic.strip(), req.type, req.location.strip()
|
||||
_validate_source(type, location)
|
||||
qp = source_path(topic)
|
||||
qp.parent.mkdir(parents=True, exist_ok=True)
|
||||
daten = {"type": typ, "ort": ort, "spec": req.spec.strip()}
|
||||
atomic_write_json(qp, daten)
|
||||
return daten
|
||||
data = {"type": type, "location": location, "spec": req.spec.strip()}
|
||||
atomic_write_json(qp, data)
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/bausteine/uebersicht", response_model=list[BausteinUebersicht])
|
||||
async def get_bausteine_uebersicht(topic: str):
|
||||
return await lade_uebersicht(topic)
|
||||
@router.get("/blocks/overview", response_model=list[BlockOverview])
|
||||
async def get_blocks_uebersicht(topic: str):
|
||||
return await load_overview(topic)
|
||||
|
||||
|
||||
@router.get("/bausteine/frage-muster")
|
||||
async def get_frage_muster(topic: str, baustein: str):
|
||||
"""Vordefinierte Frage-Muster eines Bausteins (leer = Fallback auf Live-Generierung)."""
|
||||
return {"muster": await lade_frage_muster(topic, baustein)}
|
||||
@router.get("/blocks/kanban")
|
||||
async def get_kanban_board(topic: str):
|
||||
"""Live card counts per kanban column (empty dict when the streaming inventory is not in use)."""
|
||||
import database as db
|
||||
return await db.kanban_stage_counts(topic)
|
||||
|
||||
|
||||
# --- Baustein-Lernen: Chat, Prüfung ---
|
||||
@router.get("/blocks/agents")
|
||||
async def get_active_agents(topic: str):
|
||||
"""Currently running agents for this topic + their runtime (seconds). Label = key minus prefix."""
|
||||
from agents import active_agents
|
||||
prefix = f"blocks-{topic}-"
|
||||
return [{"label": a["key"][len(prefix):], "runtime": a["runtime"]} for a in active_agents(prefix)]
|
||||
|
||||
@router.get("/bausteine/lernstand", response_model=BausteinLernstandResponse)
|
||||
async def baustein_lernstand(topic: str):
|
||||
"""Prüfungs-Stand pro Baustein (roher Titel als Key)."""
|
||||
progress = await list_baustein_progress(topic)
|
||||
bausteine = {
|
||||
p["baustein"]: {
|
||||
"gute_antworten": p["gute_antworten"],
|
||||
"streak": p["streak"],
|
||||
"absolviert": p["absolviert"] is not None,
|
||||
"verstanden": p["verstanden"] is not None,
|
||||
"gemeistert": p["gemeistert"] is not None,
|
||||
|
||||
@router.post("/blocks/research")
|
||||
async def add_research(topic: str):
|
||||
"""Attach one more research agent to the running kanban flow (live breadth boost)."""
|
||||
from kanban import add_research_agent
|
||||
return {"started": add_research_agent(topic)}
|
||||
|
||||
|
||||
@router.get("/blocks/question-pattern")
|
||||
async def get_question_pattern(topic: str, block: str):
|
||||
"""Unlocked question patterns of a block (up to the current level; empty = live)."""
|
||||
state = await get_block_progress(topic, block)
|
||||
fe = freie_level(state["good_answers"], await subs_per_level(topic, block))
|
||||
return {"pattern": await load_question_pattern_free(topic, block, fe)}
|
||||
|
||||
|
||||
@router.get("/blocks/artefakte")
|
||||
async def get_artefakte(topic: str, type: str | None = None):
|
||||
"""Learning artifacts (flashcards/examples) per topic, grouped by block norm — per subblock."""
|
||||
rows = await get_sub_artefakte(topic, type)
|
||||
out: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
b = out.setdefault(r["block_norm"], {"block": r["block"], "flashcard": [], "example": []})
|
||||
if r["block"] and not b["block"]:
|
||||
b["block"] = r["block"]
|
||||
try:
|
||||
data = json.loads(r["data"])
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if r["type"] in ("flashcard", "example"):
|
||||
b[r["type"]].append({"subblock": r["sub_title"], **data})
|
||||
return {"artefakte": out}
|
||||
|
||||
|
||||
# --- Block learning: chat, exam ---
|
||||
|
||||
@router.get("/blocks/learnstate", response_model=BlockLearnStateResponse)
|
||||
async def block_learnstate(topic: str):
|
||||
"""Exam state per block (raw title as key). cap_final = all subs × 25;
|
||||
cap_aktuell + freie_level from the score — for ALL blocks (even unexamined)."""
|
||||
progress = {p["block"]: p for p in await list_block_progress(topic)}
|
||||
levels = await subs_per_level_raw(topic)
|
||||
|
||||
def _state(score: int, streak: int, n_je_level: dict[int, int]) -> dict:
|
||||
return {
|
||||
"good_answers": score, "streak": streak,
|
||||
"cap": cap_final(n_je_level),
|
||||
"cap_aktuell": cap_aktuell(score, n_je_level),
|
||||
"freie_level": freie_level(score, n_je_level),
|
||||
}
|
||||
for p in progress
|
||||
|
||||
blocks = {
|
||||
b: _state(progress[b]["good_answers"] if b in progress else 0,
|
||||
progress[b]["streak"] if b in progress else 0, n)
|
||||
for b, n in levels.items()
|
||||
}
|
||||
return {"bausteine": bausteine}
|
||||
# Legacy blocks with an exam but without subs → empty levels (cap 0).
|
||||
for b, p in progress.items():
|
||||
if b not in blocks:
|
||||
blocks[b] = _state(p["good_answers"], p["streak"], {})
|
||||
return {"blocks": blocks}
|
||||
|
||||
|
||||
@router.post("/bausteine/chat", response_model=BausteinChatResponse)
|
||||
async def baustein_chat_route(req: BausteinChatRequest):
|
||||
reply = await baustein_chat(
|
||||
req.topic, req.baustein, req.section, req.section_kompakt,
|
||||
@router.post("/blocks/chat", response_model=BlockChatResponse)
|
||||
async def block_chat_route(req: BlockChatRequest):
|
||||
reply = await block_chat(
|
||||
req.topic, req.block, req.section, req.section_compact,
|
||||
[m.model_dump() for m in req.messages], provider=req.provider,
|
||||
)
|
||||
return {"reply": reply}
|
||||
|
||||
|
||||
# Bewertungen je (topic, baustein) serialisieren — sonst überschreiben zwei
|
||||
# gleichzeitige Bewertungen den absoluten Score mit veralteter Basis (Race).
|
||||
_pruef_locks: dict[tuple[str, str], asyncio.Lock] = {}
|
||||
# Serialize ratings per (topic, block) — otherwise two simultaneous ratings would
|
||||
# overwrite the absolute score with a stale base (race).
|
||||
_check_locks: dict[tuple[str, str], asyncio.Lock] = {}
|
||||
|
||||
|
||||
def _pruef_lock(topic: str, baustein: str) -> asyncio.Lock:
|
||||
key = (topic, baustein)
|
||||
lock = _pruef_locks.get(key)
|
||||
def _check_lock(topic: str, block: str) -> asyncio.Lock:
|
||||
key = (topic, block)
|
||||
lock = _check_locks.get(key)
|
||||
if lock is None:
|
||||
lock = _pruef_locks[key] = asyncio.Lock()
|
||||
lock = _check_locks[key] = asyncio.Lock()
|
||||
return lock
|
||||
|
||||
|
||||
def _basis(stand: dict, frage: str) -> tuple[int, bool]:
|
||||
"""Score-Basis VOR der Frage. Gleiche offene Frage → Re-Bewertung auf derselben Basis
|
||||
(idempotent); sonst neue Frage auf dem aktuellen Stand. → (basis, re_bewertung)."""
|
||||
re_bewertung = stand["offene_frage"] == frage and stand["offene_basis"] is not None
|
||||
return (stand["offene_basis"] if re_bewertung else stand["gute_antworten"]), re_bewertung
|
||||
def _basis(state: dict, question: str) -> tuple[int, bool]:
|
||||
"""Score base BEFORE the question. Same open question → re-rating on the same base
|
||||
(idempotent); otherwise a new question on the current state. → (basis, re_rating)."""
|
||||
re_rating = state["offene_question"] == question and state["offene_basis"] is not None
|
||||
return (state["offene_basis"] if re_rating else state["good_answers"]), re_rating
|
||||
|
||||
|
||||
def _farbe(punkte: int) -> str:
|
||||
"""Punkte-Delta → grobe Einfärbung der Bubble."""
|
||||
return "gut" if punkte > 0 else ("neutral" if punkte == 0 else "schlecht")
|
||||
def _color(points: int) -> str:
|
||||
"""Points delta → rough bubble coloring."""
|
||||
return "gut" if points > 0 else ("neutral" if points == 0 else "schlecht")
|
||||
|
||||
|
||||
def _erklaeren_delta(niveau: str, nachgefragt: bool) -> int:
|
||||
"""Erklären-Form: Punkt-Delta aus dem Niveau (−1…+3). Mit Nachfrage höchstens +1."""
|
||||
delta = NIVEAUS.get(niveau, 0)
|
||||
return min(delta, 1) if nachgefragt else delta
|
||||
async def _book_score(req, question: str, tier: str, n_je_level: dict[int, int]) -> dict:
|
||||
"""Book score+streak drift-free (lock + open-question/open-streak anchor). Tier →
|
||||
points delta (streak-modulated) or progressive malus on error. cap_aktuell is derived
|
||||
from the base (delayed unlock at the level threshold); element once from beginner level.
|
||||
Re-rating of the same question uses the open streak anchor → idempotent."""
|
||||
async with _check_lock(req.topic, req.block):
|
||||
state = await get_block_progress(req.topic, req.block)
|
||||
was_level = state["completed"] is not None # element guard: ever created already?
|
||||
basis, re_rating = _basis(state, question)
|
||||
streak_basis = state["offene_streak"] if re_rating else state["streak"]
|
||||
if not re_rating:
|
||||
await set_open_question(req.topic, req.block, question, basis, state["streak"])
|
||||
s = thresholds(n_je_level)
|
||||
cf = s[-1]
|
||||
ca = cap_aktuell(basis, n_je_level)
|
||||
floor = floor_from_score(basis, cf, s)
|
||||
d, new_streak = points_delta(tier, streak_basis, basis, ca)
|
||||
score = compute_score(basis, d, floor, ca, cf)
|
||||
points = score - basis
|
||||
good, streak = await set_block_score_and_streak(req.topic, req.block, score, new_streak)
|
||||
# Create the learning element once, as soon as the first level (beginner) is reached.
|
||||
if not was_level and level_from_score(score, cf) is not None:
|
||||
if await set_block_completed(req.topic, req.block):
|
||||
asyncio.create_task(create_block_element(req.topic, req.block, req.section, req.provider))
|
||||
return {"points": points, "rating": _color(points), "good_answers": good, "streak": streak, "cap": cf}
|
||||
|
||||
|
||||
async def _punkte_buchen(req, frage: str, delta: int) -> dict:
|
||||
"""Score driftfrei buchen (Lock + offene_frage-Anker + Meilensteine). Gemeinsam für
|
||||
Quiz/Lückentext (deterministisch/schnell). → Response-Felder ohne feedback."""
|
||||
async with _pruef_lock(req.topic, req.baustein):
|
||||
stand = await get_baustein_progress(req.topic, req.baustein)
|
||||
absolviert = stand["absolviert"] is not None
|
||||
verstanden = stand["verstanden"] is not None
|
||||
gemeistert = stand["gemeistert"] is not None
|
||||
basis, re_bewertung = _basis(stand, frage)
|
||||
if not re_bewertung:
|
||||
await set_offene_frage(req.topic, req.baustein, frage, basis, 0)
|
||||
floor = floor_aus_meilensteinen(absolviert, verstanden, gemeistert)
|
||||
score = score_berechnen(basis, _zufall_malus(basis, delta), floor, req.cap)
|
||||
punkte = score - basis
|
||||
gute = await set_baustein_score(req.topic, req.baustein, score)
|
||||
if score >= NOETIG and not absolviert:
|
||||
absolviert = True
|
||||
if await set_baustein_absolviert(req.topic, req.baustein):
|
||||
asyncio.create_task(baustein_element_anlegen(req.topic, req.baustein, req.section, req.provider))
|
||||
if score >= MASTERY and not verstanden:
|
||||
await set_baustein_verstanden(req.topic, req.baustein)
|
||||
verstanden = True
|
||||
if score >= MEISTERN and not gemeistert:
|
||||
await set_baustein_gemeistert(req.topic, req.baustein)
|
||||
gemeistert = True
|
||||
return {"punkte": punkte, "bewertung": _farbe(punkte), "gute_antworten": gute,
|
||||
"absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
|
||||
|
||||
|
||||
@router.post("/bausteine/pruefung", response_model=BausteinPruefungResponse)
|
||||
async def baustein_pruefung_route(req: BausteinPruefungRequest):
|
||||
stand = await get_baustein_progress(req.topic, req.baustein)
|
||||
gute = stand["gute_antworten"]
|
||||
absolviert = stand["absolviert"] is not None
|
||||
verstanden = stand["verstanden"] is not None
|
||||
gemeistert = stand["gemeistert"] is not None
|
||||
kompakt = req.section_kompakt
|
||||
@router.post("/blocks/exam", response_model=BlockExamResponse)
|
||||
async def block_exam_route(req: BlockExamRequest):
|
||||
state = await get_block_progress(req.topic, req.block)
|
||||
good = state["good_answers"]
|
||||
n_je_level = await subs_per_level(req.topic, req.block)
|
||||
cap = cap_final(n_je_level)
|
||||
tier = level_from_score(good, cap) or "beginner" # addressee role of the question
|
||||
fe = freie_level(good, n_je_level) # only check unlocked subs
|
||||
compact = req.section_compact
|
||||
msgs = [m.model_dump() for m in req.messages]
|
||||
|
||||
if req.aktion == "frage":
|
||||
if req.muster.strip():
|
||||
# Aus gezogenem Muster eine konkrete, leicht andere Frage formulieren (kein Dedup nötig).
|
||||
frage = await pruefung_frage_variante(req.topic, req.baustein, req.section, kompakt, req.muster, provider=req.provider)
|
||||
if req.action == "question":
|
||||
if req.pattern.strip():
|
||||
# From a drawn pattern, phrase a concrete question at the tier (no dedup needed).
|
||||
question = await exam_question_variant(req.topic, req.block, req.section, compact, req.pattern, tier=tier, provider=req.provider)
|
||||
else:
|
||||
# Fallback (kein Muster-Sidecar): Live-Generierung mit zufälligem Typ/Subbaustein.
|
||||
subs = await subbausteine_titel(req.topic, req.baustein) # Fokus-Kandidaten (zufällig gewählt)
|
||||
frage = await pruefung_frage(req.topic, req.baustein, req.section, kompakt, msgs, subbausteine=subs, vermeide=req.vermeide, provider=req.provider)
|
||||
if frage is None:
|
||||
raise HTTPException(502, "Frage fehlgeschlagen — bitte erneut versuchen")
|
||||
return {"frage": frage, "gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
|
||||
# Fallback (no pattern sidecar): live generation, focus only on unlocked subs.
|
||||
subs = await subblocks_frei(req.topic, req.block, fe)
|
||||
question = await exam_question(req.topic, req.block, req.section, compact, msgs, subblocks=subs, avoid=req.avoid, tier=tier, provider=req.provider)
|
||||
if question is None:
|
||||
raise HTTPException(502, "Question failed — please try again")
|
||||
return {"question": question, "good_answers": good, "cap": cap}
|
||||
|
||||
if req.aktion == "diskussion":
|
||||
if not req.frage.strip():
|
||||
raise HTTPException(400, "Diskussion braucht eine laufende Frage")
|
||||
reply = await baustein_diskussion(
|
||||
req.topic, req.baustein, req.section, kompakt,
|
||||
req.frage, req.letzte_bewertung or None, msgs, provider=req.provider,
|
||||
if req.action == "discussion":
|
||||
if not req.question.strip():
|
||||
raise HTTPException(400, "Discussion needs an active question")
|
||||
reply = await block_discussion(
|
||||
req.topic, req.block, req.section, compact,
|
||||
req.question, req.last_rating or None, msgs, provider=req.provider,
|
||||
)
|
||||
if reply is None:
|
||||
raise HTTPException(502, "Diskussion fehlgeschlagen — bitte erneut versuchen")
|
||||
return {"reply": reply, "gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
|
||||
raise HTTPException(502, "Discussion failed — please try again")
|
||||
return {"reply": reply, "good_answers": good, "cap": cap}
|
||||
|
||||
# --- Quiz: leicht (1 von 4) +1/−1 · schwer (x von 4) +3/−1 — deterministisch ---
|
||||
if req.aktion == "quiz_frage":
|
||||
if not req.muster.strip():
|
||||
raise HTTPException(400, "Quiz braucht ein Muster")
|
||||
quiz = await quiz_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, schwer=req.schwer, provider=req.provider)
|
||||
# --- Quiz: easy (1 of 4) +1/−1 · hard (x of 4) +3/−1 — deterministic ---
|
||||
if req.action == "quiz_question":
|
||||
if not req.pattern.strip():
|
||||
raise HTTPException(400, "Quiz needs a pattern")
|
||||
distractors = await hurdles_distractor_block(req.topic, req.block)
|
||||
quiz = await generate_quiz(req.topic, req.block, req.section, compact, req.pattern, tier=tier, provider=req.provider, distractor_block=distractors)
|
||||
if quiz is None:
|
||||
raise HTTPException(502, "Quiz-Frage fehlgeschlagen — bitte erneut versuchen")
|
||||
return {"frage": quiz["frage"], "optionen": quiz["optionen"],
|
||||
"gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
|
||||
raise HTTPException(502, "Quiz question failed — please try again")
|
||||
return {"question": quiz["question"], "options": quiz["options"],
|
||||
"good_answers": good, "cap": cap}
|
||||
|
||||
if req.aktion == "quiz_antwort":
|
||||
if not req.frage.strip():
|
||||
raise HTTPException(400, "Quiz-Antwort braucht eine Frage")
|
||||
getroffen = set(req.auswahl) == set(req.korrekt) # exakt die richtige Menge
|
||||
delta = (3 if req.schwer else 1) if getroffen else -1
|
||||
res = await _punkte_buchen(req, req.frage, delta)
|
||||
res["feedback"] = "Richtig — alle korrekten getroffen." if getroffen else "Nicht ganz — die markierten waren richtig."
|
||||
if req.action == "quiz_answer":
|
||||
if not req.question.strip():
|
||||
raise HTTPException(400, "Quiz answer needs a question")
|
||||
hit = set(req.selection) == set(req.correct) # exactly the correct set
|
||||
res = await _book_score(req, req.question, "strong" if hit else "barely", n_je_level)
|
||||
res["feedback"] = "Correct — all correct ones hit." if hit else "Not quite — the marked ones were correct."
|
||||
return res
|
||||
|
||||
# --- Lückentext: leicht (Begriff aus 4) +1/−1 · schwer (frei tippen) +3/−1 ---
|
||||
if req.aktion == "lueck_frage":
|
||||
if not req.muster.strip():
|
||||
raise HTTPException(400, "Lückentext braucht ein Muster")
|
||||
# --- Gap text: easy (term from 4) +1/−1 · hard (free typing) +3/−1 ---
|
||||
if req.action == "gap_question":
|
||||
if not req.pattern.strip():
|
||||
raise HTTPException(400, "Gap text needs a pattern")
|
||||
if req.schwer:
|
||||
lt = await lueckentext_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, provider=req.provider)
|
||||
lt = await generate_gaptext(req.topic, req.block, req.section, compact, req.pattern, tier=tier, provider=req.provider)
|
||||
if lt is None:
|
||||
raise HTTPException(502, "Lückentext fehlgeschlagen — bitte erneut versuchen")
|
||||
return {"satz": lt["satz"], "loesung": lt["loesung"], "alternativen": lt["alternativen"],
|
||||
"gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
|
||||
lw = await lueckwahl_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, provider=req.provider)
|
||||
raise HTTPException(502, "Gap text failed — please try again")
|
||||
return {"sentence": lt["sentence"], "solution": lt["solution"], "alternatives": lt["alternatives"],
|
||||
"good_answers": good, "cap": cap}
|
||||
distractors = await hurdles_distractor_block(req.topic, req.block)
|
||||
lw = await generate_gapchoice(req.topic, req.block, req.section, compact, req.pattern, tier=tier, provider=req.provider, distractor_block=distractors)
|
||||
if lw is None:
|
||||
raise HTTPException(502, "Lückentext fehlgeschlagen — bitte erneut versuchen")
|
||||
return {"satz": lw["satz"], "optionen": lw["optionen"],
|
||||
"gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
|
||||
raise HTTPException(502, "Gap text failed — please try again")
|
||||
return {"sentence": lw["sentence"], "options": lw["options"],
|
||||
"good_answers": good, "cap": cap}
|
||||
|
||||
if req.aktion == "lueck_antwort":
|
||||
if not req.frage.strip():
|
||||
raise HTTPException(400, "Lückentext-Antwort braucht einen Satz")
|
||||
if req.schwer: # frei getippt → Synonym-tolerante KI-Prüfung
|
||||
ok = await lueckentext_pruefen(req.topic, req.baustein, req.frage, req.loesung, req.alternativen, req.eingabe, provider=req.provider)
|
||||
feedback = "Richtig!" if ok else f"Nicht ganz — erwartet war „{req.loesung}“."
|
||||
else: # Begriff aus 4 gewählt → deterministisch
|
||||
ok = set(req.auswahl) == set(req.korrekt)
|
||||
feedback = "Richtig!" if ok else "Nicht ganz — der markierte Begriff war richtig."
|
||||
res = await _punkte_buchen(req, req.frage, (3 if req.schwer else 1) if ok else -1)
|
||||
if req.action == "gap_answer":
|
||||
if not req.question.strip():
|
||||
raise HTTPException(400, "Gap-text answer needs a sentence")
|
||||
if req.schwer: # free typed → synonym-tolerant AI check
|
||||
ok = await check_gaptext(req.topic, req.block, req.question, req.solution, req.alternatives, req.input, provider=req.provider)
|
||||
feedback = "Correct!" if ok else f"Not quite — expected „{req.solution}“."
|
||||
else: # term chosen from 4 → deterministic
|
||||
ok = set(req.selection) == set(req.correct)
|
||||
feedback = "Correct!" if ok else "Not quite — the marked term was correct."
|
||||
res = await _book_score(req, req.question, "strong" if ok else "barely", n_je_level)
|
||||
res["feedback"] = feedback
|
||||
return res
|
||||
|
||||
# aktion "antwort" (Agent 1 schnell) / "antwort_pruefen" (Agent 2 genau).
|
||||
# action "answer" (Agent 1 fast) / "answer_check" (Agent 2 thorough).
|
||||
if not any(m.get("role") == "user" for m in msgs):
|
||||
raise HTTPException(400, "Antwort braucht eine Nutzer-Antwort")
|
||||
if not req.frage.strip():
|
||||
raise HTTPException(400, "Antwort braucht eine laufende Frage")
|
||||
raise HTTPException(400, "Answer needs a user answer")
|
||||
if not req.question.strip():
|
||||
raise HTTPException(400, "Answer needs an active question")
|
||||
|
||||
if req.aktion == "antwort":
|
||||
# Agent 1: nur Vorschau — Niveau + voraussichtliche Punkte, NICHTS persistieren, kein Anker.
|
||||
data = await pruefung_bewertung_schnell(
|
||||
req.topic, req.baustein, req.section, kompakt, req.frage, msgs, provider=req.provider,
|
||||
if req.action == "answer":
|
||||
# Agent 1: preview only — tier + expected points, persist NOTHING, no anchor.
|
||||
data = await exam_rating_fast(
|
||||
req.topic, req.block, req.section, compact, req.question, msgs, provider=req.provider,
|
||||
)
|
||||
if data is None:
|
||||
raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen")
|
||||
basis, _ = _basis(stand, req.frage)
|
||||
floor = floor_aus_meilensteinen(absolviert, verstanden, gemeistert)
|
||||
score = score_berechnen(basis, _zufall_malus(basis, _erklaeren_delta(data["niveau"], req.nachgefragt)), floor, req.cap)
|
||||
punkte = score - basis
|
||||
return {"feedback": data["feedback"], "punkte": punkte, "bewertung": _farbe(punkte),
|
||||
"gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
|
||||
raise HTTPException(502, "Rating failed — please try again")
|
||||
basis, re_rating = _basis(state, req.question)
|
||||
streak_basis = state["offene_streak"] if re_rating else state["streak"]
|
||||
s = thresholds(n_je_level)
|
||||
ca = cap_aktuell(basis, n_je_level)
|
||||
floor = floor_from_score(basis, s[-1], s)
|
||||
tier = cap_followup(data["tier"], req.asked_again)
|
||||
d, _ = points_delta(tier, streak_basis, basis, ca)
|
||||
score = compute_score(basis, d, floor, ca, s[-1])
|
||||
points = score - basis
|
||||
return {"feedback": data["feedback"], "points": points, "rating": _color(points),
|
||||
"good_answers": good, "cap": cap}
|
||||
|
||||
# aktion "antwort_pruefen" (Agent 2 genau): verbindlich, persistiert. Serialisiert je Baustein,
|
||||
# Basis driftfrei über den offene_frage-Anker. NUR hier ändert sich der Score.
|
||||
async with _pruef_lock(req.topic, req.baustein):
|
||||
stand = await get_baustein_progress(req.topic, req.baustein)
|
||||
absolviert = stand["absolviert"] is not None
|
||||
verstanden = stand["verstanden"] is not None
|
||||
gemeistert = stand["gemeistert"] is not None
|
||||
basis, re_bewertung = _basis(stand, req.frage)
|
||||
if not re_bewertung:
|
||||
await set_offene_frage(req.topic, req.baustein, req.frage, basis, 0)
|
||||
|
||||
data = await pruefung_bewertung(
|
||||
req.topic, req.baustein, req.section, kompakt, req.frage, msgs, provider=req.provider,
|
||||
role="guide" if req.gruendlich else "judge", begruendung=req.begruendung,
|
||||
)
|
||||
if data is None:
|
||||
raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen")
|
||||
|
||||
floor = floor_aus_meilensteinen(absolviert, verstanden, gemeistert)
|
||||
score = score_berechnen(basis, _zufall_malus(basis, _erklaeren_delta(data["niveau"], req.nachgefragt)), floor, req.cap)
|
||||
punkte = score - basis
|
||||
gute = await set_baustein_score(req.topic, req.baustein, score)
|
||||
if score >= NOETIG and not absolviert:
|
||||
absolviert = True
|
||||
if await set_baustein_absolviert(req.topic, req.baustein):
|
||||
asyncio.create_task(baustein_element_anlegen(req.topic, req.baustein, req.section, req.provider))
|
||||
if score >= MASTERY and not verstanden:
|
||||
await set_baustein_verstanden(req.topic, req.baustein)
|
||||
verstanden = True
|
||||
if score >= MEISTERN and not gemeistert:
|
||||
await set_baustein_gemeistert(req.topic, req.baustein)
|
||||
gemeistert = True
|
||||
return {"feedback": data["feedback"], "punkte": punkte, "bewertung": _farbe(punkte),
|
||||
"gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
|
||||
# action "answer_check" (Agent 2 thorough): binding, persisted. ONLY here does the score change.
|
||||
# The LLM runs WITHOUT a lock; booking is done briefly via _book_score (anchor + score), as with quiz/gap.
|
||||
# This way the long AI rating doesn't block a following (deterministic) answer of the same block.
|
||||
data = await exam_rating(
|
||||
req.topic, req.block, req.section, compact, req.question, msgs, provider=req.provider,
|
||||
role="guide" if req.thorough else "judge", reason=req.reason,
|
||||
)
|
||||
if data is None:
|
||||
raise HTTPException(502, "Rating failed — please try again")
|
||||
tier = cap_followup(data["tier"], req.asked_again)
|
||||
res = await _book_score(req, req.question, tier, n_je_level) # short lock: drift-free base via anchor
|
||||
res["feedback"] = data["feedback"]
|
||||
return res
|
||||
|
||||
|
||||
# --- Guides ---
|
||||
|
||||
@router.post("/guides", response_model=GuideResponse)
|
||||
async def create(req: GuideCreateRequest):
|
||||
guides, progress, levels = await lade_lernstand()
|
||||
grund = guide_lock(req.topic.strip(), req.format, guides, progress, levels)
|
||||
if grund:
|
||||
raise HTTPException(400 if grund == "Erst Bausteine erstellen" else 409, grund)
|
||||
guides, progress, levels = await load_learnstate()
|
||||
reason = guide_lock(req.topic.strip(), req.format, guides, progress, levels)
|
||||
if reason:
|
||||
raise HTTPException(400 if reason == "Erst Blocks erstellen" else 409, reason) # string matches rules.py contract
|
||||
await create_topic(req.topic.strip())
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
guide = {
|
||||
@@ -474,47 +524,54 @@ async def list_all():
|
||||
|
||||
@router.get("/guides/locks")
|
||||
async def guide_locks(topic: str):
|
||||
"""Sperr-Gründe pro Format für den ▶-Button — None = erstellbar."""
|
||||
guides, progress, levels = await lade_lernstand()
|
||||
"""Lock reasons per format for the ▶ button — None = creatable."""
|
||||
guides, progress, levels = await load_learnstate()
|
||||
return {fmt: guide_lock(topic, fmt, guides, progress, levels) for fmt in ("FullGuide", "Rest", *FORMATE)}
|
||||
|
||||
|
||||
@router.get("/guides/steps")
|
||||
async def guide_steps(topic: str):
|
||||
"""Höchster voll abgeschlossener Schritt-Index je Format (artefakt-basiert, -1 = keiner).
|
||||
Treibt die klickbaren Schritt-Kugeln (wie die Bausteine-Phasen)."""
|
||||
return {fmt: guide_fertig_step(guide_content_path(topic, fmt)) for fmt in ("Guide", "FullGuide", "Rest")}
|
||||
"""Highest fully completed step index per format (artifact-based, -1 = none).
|
||||
Drives the clickable step bubbles (like the blocks phases)."""
|
||||
return {fmt: guide_done_step(guide_content_path(topic, fmt)) for fmt in ("Guide", "FullGuide", "Rest")}
|
||||
|
||||
|
||||
@router.get("/guides/{guide_id}", response_model=GuideResponse)
|
||||
async def get_one(guide_id: str):
|
||||
guide = await get_guide(guide_id)
|
||||
if guide is None:
|
||||
raise HTTPException(404, "Guide nicht gefunden")
|
||||
raise HTTPException(404, "Guide not found")
|
||||
return guide
|
||||
|
||||
|
||||
@router.get("/guides/{guide_id}/content")
|
||||
async def guide_content(guide_id: str):
|
||||
async def guide_content(guide_id: str, level: int = 4):
|
||||
"""Guide content. `level` (1=A · 2=F · 3=E · 4=V) filters to subblocks up to this
|
||||
level; 4 = full version (raw, unchanged)."""
|
||||
guide = await get_guide(guide_id)
|
||||
if guide is None:
|
||||
raise HTTPException(404, "Guide nicht gefunden")
|
||||
raise HTTPException(404, "Guide not found")
|
||||
if guide["status"] != "done":
|
||||
raise HTTPException(404, "Inhalt nicht verfügbar")
|
||||
raise HTTPException(404, "Content not available")
|
||||
stored = await get_guide_content(guide["topic"], guide["format"]) # DB-first
|
||||
if stored is not None:
|
||||
if stored is None:
|
||||
path = guide_content_path(guide["topic"], guide["format"]) # fallback: file (legacy topics)
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "File not found")
|
||||
stored = path.read_text(encoding="utf-8")
|
||||
if level >= 4:
|
||||
return Response(content=stored, media_type="application/json") # full version, raw
|
||||
try:
|
||||
return content_fuer_level(json.loads(stored), level)
|
||||
except ValueError:
|
||||
return Response(content=stored, media_type="application/json")
|
||||
path = guide_content_path(guide["topic"], guide["format"]) # Fallback: Datei (Alt-Themen)
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Datei nicht gefunden")
|
||||
return FileResponse(path, media_type="application/json")
|
||||
|
||||
|
||||
@router.post("/guides/{guide_id}/chat", response_model=GuideChatResponse)
|
||||
async def guide_chat(guide_id: str, req: GuideChatRequest):
|
||||
guide = await get_guide(guide_id)
|
||||
if guide is None:
|
||||
raise HTTPException(404, "Guide nicht gefunden")
|
||||
raise HTTPException(404, "Guide not found")
|
||||
reply = await chat_with_guide(
|
||||
guide["topic"], guide["format"], req.section, req.outline,
|
||||
[m.model_dump() for m in req.messages],
|
||||
@@ -523,7 +580,32 @@ async def guide_chat(guide_id: str, req: GuideChatRequest):
|
||||
return {"reply": reply}
|
||||
|
||||
|
||||
# --- Elemente (persönliche Zusammenfassung) ---
|
||||
async def _guide_tf(guide_id: str) -> tuple[str, str]:
|
||||
guide = await get_guide(guide_id)
|
||||
if guide is None:
|
||||
raise HTTPException(404, "Guide not found")
|
||||
return guide["topic"], guide["format"]
|
||||
|
||||
|
||||
@router.post("/guides/{guide_id}/block/pruefen", response_model=BlockPruefenResponse)
|
||||
async def block_pruefen_route(guide_id: str, req: BlockPruefenRequest):
|
||||
topic, fmt = await _guide_tf(guide_id)
|
||||
new = await block_pruefen(topic, fmt, req.block, req.spot, req.snippet, req.hint, provider=req.provider)
|
||||
if new is None:
|
||||
raise HTTPException(502, "Check failed — please try again")
|
||||
return {"revised": new}
|
||||
|
||||
|
||||
@router.post("/guides/{guide_id}/block/uebernehmen", response_model=BlockUebernehmenResponse)
|
||||
async def block_adopt_route(guide_id: str, req: BlockUebernehmenRequest):
|
||||
topic, fmt = await _guide_tf(guide_id)
|
||||
res = await block_adopt(topic, fmt, req.block, req.spot, req.alt, req.revised)
|
||||
if res is None:
|
||||
raise HTTPException(404, "Section not found")
|
||||
return res
|
||||
|
||||
|
||||
# --- Elements (personal summary) ---
|
||||
|
||||
@router.get("/elements", response_model=list[ElementResponse])
|
||||
async def get_elements(topic: str):
|
||||
@@ -543,7 +625,7 @@ async def post_element(req: ElementCreateRequest):
|
||||
async def element_chat(element_id: str, req: ElementChatRequest):
|
||||
element = await get_element(element_id)
|
||||
if element is None:
|
||||
raise HTTPException(404, "Element nicht gefunden")
|
||||
raise HTTPException(404, "Element not found")
|
||||
reply, changes = await chat_with_element(element, [m.model_dump() for m in req.messages], provider=req.provider)
|
||||
return {"reply": reply, "changes": changes}
|
||||
|
||||
@@ -552,17 +634,17 @@ async def element_chat(element_id: str, req: ElementChatRequest):
|
||||
async def element_refine(element_id: str, req: ElementRefineRequest):
|
||||
element = await get_element(element_id)
|
||||
if element is None:
|
||||
raise HTTPException(404, "Element nicht gefunden")
|
||||
raise HTTPException(404, "Element not found")
|
||||
change = await refine_suggestion(element, req.suggestion.model_dump(), req.instruction, provider=req.provider)
|
||||
if change is None:
|
||||
raise HTTPException(502, "Überarbeitung fehlgeschlagen — bitte erneut versuchen")
|
||||
raise HTTPException(502, "Revision failed — please try again")
|
||||
return {"change": change}
|
||||
|
||||
|
||||
@router.put("/elements/{element_id}", response_model=ElementResponse)
|
||||
async def put_element(element_id: str, req: ElementUpdateRequest):
|
||||
if await get_element(element_id) is None:
|
||||
raise HTTPException(404, "Element nicht gefunden")
|
||||
raise HTTPException(404, "Element not found")
|
||||
fields = req.model_dump(exclude_unset=True, exclude_none=True)
|
||||
if fields:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
@@ -574,10 +656,10 @@ async def put_element(element_id: str, req: ElementUpdateRequest):
|
||||
async def element_style(element_id: str, req: ElementCheckRequest):
|
||||
element = await get_element(element_id)
|
||||
if element is None:
|
||||
raise HTTPException(404, "Element nicht gefunden")
|
||||
raise HTTPException(404, "Element not found")
|
||||
changes = await style_element(element, provider=req.provider)
|
||||
if changes is None:
|
||||
raise HTTPException(502, "Stil-Prüfung fehlgeschlagen — bitte erneut versuchen")
|
||||
raise HTTPException(502, "Style check failed — please try again")
|
||||
return {"changes": changes}
|
||||
|
||||
|
||||
@@ -585,17 +667,17 @@ async def element_style(element_id: str, req: ElementCheckRequest):
|
||||
async def element_check(element_id: str, req: ElementCheckRequest):
|
||||
element = await get_element(element_id)
|
||||
if element is None:
|
||||
raise HTTPException(404, "Element nicht gefunden")
|
||||
raise HTTPException(404, "Element not found")
|
||||
suggestions = await check_element(element, provider=req.provider)
|
||||
if suggestions is None:
|
||||
raise HTTPException(502, "Prüfung fehlgeschlagen — bitte erneut versuchen")
|
||||
raise HTTPException(502, "Check failed — please try again")
|
||||
return {"suggestions": suggestions}
|
||||
|
||||
|
||||
@router.delete("/elements/{element_id}")
|
||||
async def remove_element(element_id: str):
|
||||
if not await delete_element(element_id):
|
||||
raise HTTPException(404, "Element nicht gefunden")
|
||||
raise HTTPException(404, "Element not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -603,7 +685,7 @@ async def remove_element(element_id: str):
|
||||
async def cancel(guide_id: str):
|
||||
cancelled = await cancel_guide(guide_id)
|
||||
if not cancelled:
|
||||
raise HTTPException(404, "Kein aktiver Prozess gefunden")
|
||||
raise HTTPException(404, "No active process found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -611,18 +693,18 @@ async def cancel(guide_id: str):
|
||||
async def remove(guide_id: str, slots: bool = False):
|
||||
guide = await get_guide(guide_id)
|
||||
if guide is None:
|
||||
raise HTTPException(404, "Guide nicht gefunden")
|
||||
raise HTTPException(404, "Guide not found")
|
||||
await delete_progress(guide_id)
|
||||
await delete_guide(guide_id)
|
||||
# Content-/Schritt-Dateien teilen sich alle Läufe eines Thema+Formats — erst löschen,
|
||||
# wenn kein Eintrag sie mehr braucht. Teilfortschritt (Schritt-Dateien ohne fertigen
|
||||
# Content) bleibt fürs Resume erhalten, außer es wird explizit verlangt (slots=1).
|
||||
# Content/step files are shared by all runs of a topic+format — only delete them
|
||||
# once no entry needs them anymore. Partial progress (step files without finished
|
||||
# content) is kept for resume, unless explicitly requested (slots=1).
|
||||
rest = [g for g in await list_guides() if g["topic"] == guide["topic"] and g["format"] == guide["format"]]
|
||||
if not rest:
|
||||
await delete_guide_content(guide["topic"], guide["format"])
|
||||
content = guide_content_path(guide["topic"], guide["format"])
|
||||
if slots or content.exists():
|
||||
for p in guide_slot_dateien(content):
|
||||
for p in guide_slot_files(content):
|
||||
p.unlink(missing_ok=True)
|
||||
content.unlink(missing_ok=True)
|
||||
return {"ok": True}
|
||||
@@ -632,7 +714,7 @@ async def remove(guide_id: str, slots: bool = False):
|
||||
async def get_progress(guide_id: str):
|
||||
guide = await get_guide(guide_id)
|
||||
if guide is None:
|
||||
raise HTTPException(404, "Guide nicht gefunden")
|
||||
raise HTTPException(404, "Guide not found")
|
||||
return {"chapters": await list_progress(guide_id)}
|
||||
|
||||
|
||||
@@ -640,6 +722,6 @@ async def get_progress(guide_id: str):
|
||||
async def update_progress(guide_id: str, req: ProgressUpdate):
|
||||
guide = await get_guide(guide_id)
|
||||
if guide is None:
|
||||
raise HTTPException(404, "Guide nicht gefunden")
|
||||
raise HTTPException(404, "Guide not found")
|
||||
await set_progress(guide_id, req.chapter, req.done)
|
||||
return {"chapters": await list_progress(guide_id)}
|
||||
|
||||
141
backend/rules.py
Normal file
141
backend/rules.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""Learning-debt rules: progression and cap for open guides — the ONLY source.
|
||||
|
||||
Rules (new creations only; topics + blocks unlimited):
|
||||
- Format "Guide": at most 3 created, not-yet-completed guides
|
||||
- No more progression/prerequisite (only a single guide format).
|
||||
- Completed: ALL blocks (section titles) of the latest finished guide have
|
||||
a passed exam. The rest is read-only (no progress, no exam).
|
||||
All functions work on data loaded once (load_learnstate) — no more
|
||||
query loops per guide.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from database import list_block_scores_all, subs_per_level_all, list_guides, list_progress_all
|
||||
from guide import guide_slot_files
|
||||
from learning import cap_final, LEVELS, _threshold
|
||||
from paths import blocks_path, guide_content_path
|
||||
from textkit import _norm_title
|
||||
|
||||
MAX_OFFENE_GUIDES = 3
|
||||
# Only ONE format "Guide" left (all relevant blocks, exam 0–cap). No progression,
|
||||
# no prerequisite → the guide is always unlockable. "Rest"/FullGuide are separate.
|
||||
PRESTAGE: dict[str, str] = {}
|
||||
FREISCHALT_LEVEL: dict[str, str] = {}
|
||||
FORMATE = ("Guide",)
|
||||
|
||||
# 4 learning levels (floor in % of the cap) — keys from learning.LEVELS.
|
||||
_LEVEL_WORT = {
|
||||
"beginner": "to beginner (20%)",
|
||||
"advanced": "to advanced (40%)",
|
||||
"expert": "to expert (60%)",
|
||||
"master": "to mastery (100%)",
|
||||
}
|
||||
|
||||
|
||||
async def load_learnstate() -> tuple[list[dict], dict[str, set[str]], dict[str, dict[str, set[str]]]]:
|
||||
"""Guides + chapter progress + blocks per level.
|
||||
|
||||
levels: {"beginner"/"advanced"/"expert"/"master": {topic → normalized title}}.
|
||||
The level per block is derived from score + cap (4×relevant subs).
|
||||
"""
|
||||
scores = await list_block_scores_all()
|
||||
subs_by_level = await subs_per_level_all()
|
||||
levels: dict[str, dict[str, set[str]]] = {key: {} for key, _ in LEVELS}
|
||||
for topic, block, score in scores:
|
||||
cf = cap_final(subs_by_level.get((topic, _norm_title(block)), {}))
|
||||
for key, p in LEVELS:
|
||||
if cf and score >= _threshold(p, cf):
|
||||
levels[key].setdefault(topic, set()).add(_norm_title(block))
|
||||
return await list_guides(), await list_progress_all(), levels
|
||||
|
||||
|
||||
def _content_json(topic: str, fmt: str) -> dict | None:
|
||||
path = guide_content_path(topic, fmt)
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
|
||||
|
||||
def _section_title(topic: str, fmt: str) -> set[str] | None:
|
||||
"""Normalized block titles (sections) from the guide content."""
|
||||
content = _content_json(topic, fmt)
|
||||
if content is None:
|
||||
return None
|
||||
return {
|
||||
_norm_title(s.get("title", ""))
|
||||
for ch in content.get("chapters", [])
|
||||
for s in ch.get("sections", [])
|
||||
}
|
||||
|
||||
|
||||
def _latest_done(guides: list[dict], fmt: str) -> dict[str, dict]:
|
||||
"""Per topic, the latest finished guide of this format."""
|
||||
latest: dict[str, dict] = {}
|
||||
for g in guides:
|
||||
if g["format"] == fmt and g["status"] == "done":
|
||||
if g["topic"] not in latest or g["created_at"] > latest[g["topic"]]["created_at"]:
|
||||
latest[g["topic"]] = g
|
||||
return latest
|
||||
|
||||
|
||||
def _guide_all(g: dict, progress: dict[str, set[str]], levelset: dict[str, set[str]]) -> bool:
|
||||
"""Are ALL blocks of the guide at the required level?"""
|
||||
sections = _section_title(g["topic"], g["format"])
|
||||
return bool(sections) and sections <= levelset.get(g["topic"], set())
|
||||
|
||||
|
||||
def is_level(topic: str, fmt: str, guides: list[dict], progress: dict[str, set[str]], levelset: dict[str, set[str]]) -> bool:
|
||||
"""Latest finished guide (topic+format): all blocks at the level of levelset?"""
|
||||
g = _latest_done(guides, fmt).get(topic)
|
||||
return g is not None and _guide_all(g, progress, levelset)
|
||||
|
||||
|
||||
def ist_completed(topic: str, fmt: str, guides: list[dict], progress: dict[str, set[str]], levels: dict[str, dict[str, set[str]]]) -> bool:
|
||||
"""All blocks of the latest finished guide at least beginner (≥20%)?"""
|
||||
return is_level(topic, fmt, guides, progress, levels["beginner"])
|
||||
|
||||
|
||||
def topic_completed(topic: str, guides: list[dict], progress: dict[str, set[str]], levels: dict[str, dict[str, set[str]]]) -> bool:
|
||||
"""Topic done: latest finished guide, all blocks at master (100%)?"""
|
||||
return is_level(topic, "Guide", guides, progress, levels["master"])
|
||||
|
||||
|
||||
def formats_stats(guides: list[dict], progress: dict[str, set[str]], levels: dict[str, dict[str, set[str]]]) -> dict:
|
||||
"""Per format created/completed — per topic only the latest finished guide counts."""
|
||||
formats = {}
|
||||
for fmt in FORMATE:
|
||||
latest = _latest_done(guides, fmt)
|
||||
completed = sum(1 for g in latest.values() if _guide_all(g, progress, levels["beginner"]))
|
||||
formats[fmt] = {"created": len(latest), "completed": completed}
|
||||
return formats
|
||||
|
||||
|
||||
def guide_lock(topic: str, fmt: str, guides: list[dict], progress: dict[str, set[str]], levels: dict[str, dict[str, set[str]]]) -> str | None:
|
||||
"""Reason why a fresh start for topic+format is locked — None = allowed.
|
||||
|
||||
Exactly the rules from POST /guides: blocks required, no duplicate start,
|
||||
learning debt only for genuine new creations (resume/regenerate are free).
|
||||
"""
|
||||
if not blocks_path(topic).exists():
|
||||
return "Create blocks first"
|
||||
for g in guides:
|
||||
if g["topic"] == topic and g["format"] == fmt and g["status"] in ("queued", "generating"):
|
||||
return "Generation already running"
|
||||
content = guide_content_path(topic, fmt)
|
||||
if not content.exists() and not guide_slot_files(content):
|
||||
prereq = PRESTAGE.get(fmt)
|
||||
if prereq:
|
||||
level = FREISCHALT_LEVEL[fmt] # completed=10 · understood=20 · mastered=30
|
||||
if not is_level(topic, prereq, guides, progress, levels[level]):
|
||||
return f"First take the {prereq} of this topic {_LEVEL_WORT[level]}"
|
||||
stat = formats_stats(guides, progress, levels).get(fmt, {"created": 0, "completed": 0})
|
||||
open_count = stat["created"] - stat["completed"]
|
||||
if open_count >= MAX_OFFENE_GUIDES:
|
||||
return f"Complete {fmt}s first — at most {MAX_OFFENE_GUIDES} open allowed ({open_count} open)"
|
||||
return None
|
||||
@@ -1,19 +1,19 @@
|
||||
"""Reine Text-Helfer: Titel-Normalisierung, Listen-Parser, Chunk-Aufteilung.
|
||||
"""Pure text helpers: title normalization, list parsers, chunk splitting.
|
||||
|
||||
Kein Zustand, keine IO — überall gefahrlos importierbar.
|
||||
No state, no IO — safe to import anywhere.
|
||||
"""
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
_CATEGORIES = ("KERN", "WICHTIG", "REST") # nur noch für den Altformat-Reader
|
||||
_CATEGORIES = ("KERN", "WICHTIG", "REST") # only for the legacy-format reader now
|
||||
|
||||
|
||||
def _norm_titel(s: str) -> str:
|
||||
"""Normalisiert einen Titel für den Schlüssel-Vergleich.
|
||||
def _norm_title(s: str) -> str:
|
||||
"""Normalize a title for key comparison.
|
||||
|
||||
NFKC + casefold fangen Unicode-Varianten; Anführungszeichen, Markdown-
|
||||
Emphasis und Dash-Varianten kommen aus KI-Output in allen Spielarten.
|
||||
NFKC + casefold catch Unicode variants; quotes, markdown emphasis
|
||||
and dash variants come out of AI output in every shape.
|
||||
"""
|
||||
s = unicodedata.normalize("NFKC", s)
|
||||
s = re.sub(r"[`'\"<>„“”‚’«»*_]", "", s)
|
||||
@@ -22,77 +22,63 @@ def _norm_titel(s: str) -> str:
|
||||
return s.casefold()
|
||||
|
||||
|
||||
def _titel(entry: str) -> str:
|
||||
def _title(entry: str) -> str:
|
||||
return entry.split(" — ")[0].strip() or entry
|
||||
|
||||
|
||||
def _eindeutige_titel(entries: dict[int, str]) -> dict[int, str]:
|
||||
"""Macht Titel eindeutig (Suffix " (2)", " (3)" …), damit sie als Schlüssel taugen."""
|
||||
def _unique_title(entries: dict[int, str]) -> dict[int, str]:
|
||||
"""Make titles unique (suffix " (2)", " (3)" …) so they work as keys."""
|
||||
seen: dict[str, int] = {}
|
||||
out: dict[int, str] = {}
|
||||
for num, text in entries.items():
|
||||
titel = _titel(text)
|
||||
key = _norm_titel(titel)
|
||||
title = _title(text)
|
||||
key = _norm_title(title)
|
||||
seen[key] = seen.get(key, 0) + 1
|
||||
if seen[key] > 1:
|
||||
rest = text.split(" — ", 1)
|
||||
text = f"{titel} ({seen[key]})" + (f" — {rest[1]}" if len(rest) == 2 else "")
|
||||
# zweiter Durchlauf nicht nötig: Suffixe kollidieren praktisch nicht
|
||||
text = f"{title} ({seen[key]})" + (f" — {rest[1]}" if len(rest) == 2 else "")
|
||||
# a second pass isn't needed: suffixes practically never collide
|
||||
out[num] = text
|
||||
return out
|
||||
|
||||
|
||||
def _vormerge(listen: list[dict[int, str]]) -> list[tuple[str, int]]:
|
||||
"""Vereinigt Baustein-Listen: exakte Titel-Dubletten (per _norm_titel) zusammenführen.
|
||||
|
||||
→ [("Titel — Beschreibung", nennungen)] in Erstnennungs-Reihenfolge.
|
||||
nennungen = Anzahl der Listen, die den Titel nennen (Dublette innerhalb
|
||||
einer Liste zählt nicht doppelt). Repräsentant ist die Erstnennung; ein
|
||||
drittes " — "-Segment (Quelle) wird verworfen.
|
||||
"""
|
||||
merged: dict[str, tuple[str, int]] = {}
|
||||
for liste in listen:
|
||||
gesehen: set[str] = set()
|
||||
for text in liste.values():
|
||||
key = _norm_titel(_titel(text))
|
||||
if key in gesehen:
|
||||
continue
|
||||
gesehen.add(key)
|
||||
if key in merged:
|
||||
repr_text, n = merged[key]
|
||||
merged[key] = (repr_text, n + 1)
|
||||
else:
|
||||
merged[key] = (" — ".join(text.split(" — ")[:2]), 1)
|
||||
return list(merged.values())
|
||||
|
||||
|
||||
def _titel_index(entries: dict[int, str]) -> dict[str, int]:
|
||||
return {_norm_titel(_titel(text)): num for num, text in entries.items()}
|
||||
def _title_index(entries: dict[int, str]) -> dict[str, int]:
|
||||
return {_norm_title(_title(text)): num for num, text in entries.items()}
|
||||
|
||||
|
||||
def _titel_aufloesen(idx: dict[str, int], t: str) -> int | None:
|
||||
"""Titel → Nummer; toleriert mitgeschleppte Beschreibungen ("Titel — …")."""
|
||||
def _resolve_title(idx: dict[str, int], t: str) -> int | None:
|
||||
"""Title → number; tolerates trailing descriptions ("Title — …")."""
|
||||
if not isinstance(t, str):
|
||||
return None
|
||||
return idx.get(_norm_titel(t)) or idx.get(_norm_titel(_titel(t)))
|
||||
return idx.get(_norm_title(t)) or idx.get(_norm_title(_title(t)))
|
||||
|
||||
|
||||
def _parse_auswahl(text: str) -> dict[int, str]:
|
||||
"""Parst eine Baustein-Liste: `N. Titel — Kurzbeschreibung` pro Zeile."""
|
||||
def _norm_dash(s: str) -> str:
|
||||
"""Space-surrounded dash variants (en/em/figure/bar/hyphen) → uniform separator ' — '.
|
||||
Some models (especially non-western ones) use an en-dash "–" instead of the em-dash; without
|
||||
normalization the ` — ` split fails entirely and the whole entry becomes the title.
|
||||
The ASCII hyphen "-" is left untouched (otherwise it would split formulas like "n - 1")."""
|
||||
return re.sub(r"\s+[‒–—―‐]\s+", " — ", s)
|
||||
|
||||
|
||||
def _parse_selection(text: str) -> dict[int, str]:
|
||||
"""Parse a block list: `N. Title — short description` per line."""
|
||||
entries: dict[int, str] = {}
|
||||
last = None
|
||||
for line in text.splitlines():
|
||||
m = re.match(r"\s*(\d+)[.)]\s+(.*\S)", line)
|
||||
if m:
|
||||
last = int(m.group(1))
|
||||
entries[last] = m.group(2)
|
||||
entries[last] = _norm_dash(m.group(2))
|
||||
elif last is not None and line.strip():
|
||||
entries[last] += " " + line.strip()
|
||||
entries[last] += " " + _norm_dash(line.strip())
|
||||
return entries
|
||||
|
||||
|
||||
def _parse_kategorien(text: str) -> dict[str, list[str]]:
|
||||
"""Altformat-Reader: finale Baustein-Datei mit ## KERN/WICHTIG/REST-Abschnitten."""
|
||||
def _parse_categories(text: str) -> dict[str, list[str]]:
|
||||
"""Legacy-format reader: final block file with ## KERN/WICHTIG/REST sections."""
|
||||
cats: dict[str, list[str]] = {}
|
||||
current = None
|
||||
for line in text.splitlines():
|
||||
@@ -108,38 +94,40 @@ def _parse_kategorien(text: str) -> dict[str, list[str]]:
|
||||
return cats
|
||||
|
||||
|
||||
def _lade_bausteine(text: str) -> dict[int, str]:
|
||||
"""Lädt die finale Baustein-Datei — sortierte Liste (neu) oder Kategorien (Altformat)."""
|
||||
def _load_blocks(text: str) -> dict[int, str]:
|
||||
"""Load the final block file — sorted list (new) or categories (legacy format)."""
|
||||
if re.search(r"^#+\s*KERN\b", text, re.IGNORECASE | re.MULTILINE):
|
||||
cats = _parse_kategorien(text)
|
||||
cats = _parse_categories(text)
|
||||
texts = [t for cat in _CATEGORIES for t in cats.get(cat, [])]
|
||||
return {i: t for i, t in enumerate(texts, 1)}
|
||||
return _parse_auswahl(text)
|
||||
return _parse_selection(text)
|
||||
|
||||
|
||||
_FRAGMENT_KAPITEL_RE = re.compile(r"<!--\s*kapitel\s*:\s*(.*?)\s*-->", re.IGNORECASE)
|
||||
_FRAGMENT_SECTION_RE = re.compile(r"<!--\s*section\s*:\s*(.*?)\s*-->", re.IGNORECASE)
|
||||
_FRAGMENT_SUB_RE = re.compile(r"<!--\s*sub\s*:\s*(.*?)\s*-->", re.IGNORECASE)
|
||||
_FRAGMENT_BAUSTEIN_RE = re.compile(r"<!--\s*baustein\s*:\s*(.*?)\s*-->", re.IGNORECASE)
|
||||
# Zwei Lese-Schichten je Section: kompakt (Merksätze) + ausführlich (Erklärung).
|
||||
_FRAGMENT_KOMPAKT_RE = re.compile(r"<!--\s*kompakt\s*-->", re.IGNORECASE)
|
||||
_FRAGMENT_BAUSTEIN_RE = re.compile(r"<!--\s*block\s*:\s*(.*?)\s*-->", re.IGNORECASE)
|
||||
# Two reading layers per section: compact (key sentences) + detailed (explanation).
|
||||
_FRAGMENT_KOMPAKT_RE = re.compile(r"<!--\s*compact\s*-->", re.IGNORECASE)
|
||||
_FRAGMENT_AUSF_RE = re.compile(r"<!--\s*ausf(?:ü|ue)hrlich\s*-->", re.IGNORECASE)
|
||||
_STUFEN = ("einfach", "mittel", "schwer")
|
||||
# Learning-path levels + peripheral; old difficulty values accepted for backward compatibility.
|
||||
_STUFEN = ("beginner", "advanced", "expert", "peripheral", "easy", "medium", "hard")
|
||||
|
||||
|
||||
def _parse_fragment(text: str) -> list[dict]:
|
||||
"""Parst eine Writer-Datei → [{"kapitel", "titel", "md", "kompakt", "subs"}] in Reihenfolge.
|
||||
"""Parse a writer file → [{kapitel, title, md, compact, anker, anker_compact, subs}].
|
||||
|
||||
Zwei optionale Lese-Schichten je Section über `<!-- kompakt -->` / `<!-- ausführlich -->`:
|
||||
Zeilen nach `kompakt` landen in `sec["kompakt"]`, sonst in `sec["md"]` (= ausführlich,
|
||||
auch der Default ohne Marker → rückwärtskompatibel). Optionale `<!-- sub: -->`-Marker
|
||||
sammeln sich weiter in `sec["subs"]`.
|
||||
Two reading layers per section via `<!-- compact -->` / `<!-- ausführlich -->`. Within
|
||||
both, `<!-- sub: level | title -->` markers mark a block per subblock; the same
|
||||
sub title in both layers is merged → `sec["subs"] = [{level, title, md, compact}]`.
|
||||
Text BEFORE the first sub marker is the anchor (framing) → `anker`/`anker_compact`.
|
||||
`md`/`compact` stay the FULL version (anchor + all subs) — backward compatible.
|
||||
"""
|
||||
sections: list[dict] = []
|
||||
kapitel = None
|
||||
current = None
|
||||
cur_sub = None
|
||||
cur_layer = "md" # Default: alles ohne Schicht-Marker ist die ausführliche Fassung
|
||||
cur_layer = "md" # default: anything without a layer marker is the detailed version
|
||||
for line in text.splitlines():
|
||||
s = line.strip()
|
||||
m = _FRAGMENT_KAPITEL_RE.match(s)
|
||||
@@ -150,13 +138,14 @@ def _parse_fragment(text: str) -> list[dict]:
|
||||
continue
|
||||
m = _FRAGMENT_SECTION_RE.match(s)
|
||||
if m:
|
||||
current = {"kapitel": kapitel, "titel": m.group(1), "md": [], "kompakt": [], "subs": []}
|
||||
current = {"chapters": kapitel, "title": m.group(1), "md": [], "compact": [],
|
||||
"anker_md": [], "anker_compact": [], "_submap": {}, "_suborder": []}
|
||||
cur_sub = None
|
||||
cur_layer = "md"
|
||||
sections.append(current)
|
||||
continue
|
||||
if current is not None and _FRAGMENT_KOMPAKT_RE.match(s):
|
||||
cur_layer = "kompakt"
|
||||
cur_layer = "compact"
|
||||
cur_sub = None
|
||||
continue
|
||||
if current is not None and _FRAGMENT_AUSF_RE.match(s):
|
||||
@@ -165,32 +154,48 @@ def _parse_fragment(text: str) -> list[dict]:
|
||||
continue
|
||||
m = _FRAGMENT_SUB_RE.match(s)
|
||||
if m and current is not None:
|
||||
teil = m.group(1).split("|", 1)
|
||||
stufe = teil[0].strip().casefold()
|
||||
cur_sub = {
|
||||
"stufe": stufe if stufe in _STUFEN else "einfach",
|
||||
"titel": teil[1].strip() if len(teil) == 2 else "",
|
||||
"md": [],
|
||||
}
|
||||
current["subs"].append(cur_sub)
|
||||
parts = m.group(1).split("|", 1)
|
||||
level = parts[0].strip().casefold()
|
||||
title = parts[1].strip() if len(parts) == 2 else ""
|
||||
key = title.casefold() or f"_pos{len(current['_suborder'])}"
|
||||
cur_sub = current["_submap"].get(key)
|
||||
if cur_sub is None:
|
||||
cur_sub = {"level": level if level in _STUFEN else "beginner", "title": title, "md": [], "compact": []}
|
||||
current["_submap"][key] = cur_sub
|
||||
current["_suborder"].append(key)
|
||||
elif level in _STUFEN:
|
||||
cur_sub["level"] = level
|
||||
continue
|
||||
if current is not None:
|
||||
current[cur_layer].append(line)
|
||||
if cur_sub is not None and cur_layer == "md":
|
||||
cur_sub["md"].append(line)
|
||||
if cur_sub is not None:
|
||||
cur_sub[cur_layer].append(line)
|
||||
else:
|
||||
current["anker_" + cur_layer].append(line)
|
||||
out: list[dict] = []
|
||||
for sec in sections:
|
||||
sec["md"] = "\n".join(sec["md"]).strip()
|
||||
sec["kompakt"] = "\n".join(sec["kompakt"]).strip()
|
||||
for sub in sec["subs"]:
|
||||
subs = []
|
||||
for key in sec["_suborder"]:
|
||||
sub = sec["_submap"][key]
|
||||
sub["md"] = "\n".join(sub["md"]).strip()
|
||||
sec["subs"] = [sub for sub in sec["subs"] if sub["md"]]
|
||||
return sections
|
||||
sub["compact"] = "\n".join(sub["compact"]).strip()
|
||||
if sub["md"] or sub["compact"]:
|
||||
subs.append(sub)
|
||||
out.append({
|
||||
"chapters": sec["chapters"], "title": sec["title"],
|
||||
"md": "\n".join(sec["md"]).strip(),
|
||||
"compact": "\n".join(sec["compact"]).strip(),
|
||||
"anchor": "\n".join(sec["anker_md"]).strip(),
|
||||
"anker_compact": "\n".join(sec["anker_compact"]).strip(),
|
||||
"subs": subs,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _parse_subbausteine(text: str) -> dict[str, list[str]]:
|
||||
"""Parst eine Subbaustein-Datei → {Baustein-Titel: [Subbaustein, …]} in Reihenfolge.
|
||||
def _parse_subblocks(text: str) -> dict[str, list[str]]:
|
||||
"""Parse a subblock file → {block title: [subblock, …]} in order.
|
||||
|
||||
Format: `<!-- baustein: Titel -->` gefolgt von Listenzeilen `- Subbaustein`.
|
||||
Format: `<!-- block: Title -->` followed by list lines `- Subblock`.
|
||||
"""
|
||||
out: dict[str, list[str]] = {}
|
||||
current = None
|
||||
@@ -210,7 +215,7 @@ def _parse_subbausteine(text: str) -> dict[str, list[str]]:
|
||||
|
||||
|
||||
def _split_chunks(chapters: list[dict], n: int) -> list[list[dict]]:
|
||||
"""Teilt Kapitel in bis zu n zusammenhängende Chunks, balanciert nach Section-Anzahl."""
|
||||
"""Split chapters into up to n contiguous chunks, balanced by section count."""
|
||||
n = max(1, min(n, len(chapters)))
|
||||
chunks: list[list[dict]] = []
|
||||
current: list[dict] = []
|
||||
@@ -231,9 +236,3 @@ def _split_chunks(chapters: list[dict], n: int) -> list[list[dict]]:
|
||||
return chunks
|
||||
|
||||
|
||||
def _zuteilung_text(chunk: list[dict], entries: dict[int, str]) -> str:
|
||||
lines = []
|
||||
for ch in chunk:
|
||||
lines.append(f"KAPITEL: {ch['title']}")
|
||||
lines.extend(f"- {entries[num]}" for num in ch["nums"])
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
||||
import { fetchGuides, fetchTopics, createTopic as apiCreateTopic, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBausteineStatus, fetchActiveBausteine, createBausteine as apiCreateBausteine, cancelBausteine as apiCancelBausteine, deleteBausteine as apiDeleteBausteine, fetchProviders, fetchStats, fetchTopicFortschritt, fetchGuideLocks, fetchGuideSteps, fetchFolders, updateQuelle as apiUpdateQuelle } from './api.js'
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
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 TopicSidebar from './components/TopicSidebar.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 ElementsOverview from './components/ElementsOverview.vue'
|
||||
import GeneralExamPanel from './components/GeneralExamPanel.vue'
|
||||
|
||||
const guides = ref([])
|
||||
const backendTopics = ref([])
|
||||
@@ -14,36 +15,38 @@ const selectedTopic = ref(null)
|
||||
const previewGuide = ref(null)
|
||||
const sidebarPinned = ref(localStorage.getItem('sidebarPinned') !== 'false')
|
||||
const sidebarSticky = ref(false)
|
||||
const focusOpen = ref(false) // block fullscreen active → lift sidebar as overlay above the focus overlay
|
||||
const darkMode = ref(
|
||||
localStorage.getItem('darkMode') === null
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
: localStorage.getItem('darkMode') === 'true',
|
||||
)
|
||||
const EMPTY_BAUSTEINE = { ready: false, generating: false, progress: null, error: null, partial: false, steps: [] }
|
||||
const bausteine = ref({ ...EMPTY_BAUSTEINE })
|
||||
const activeBausteine = ref([])
|
||||
const EMPTY_BLOCKS = { ready: false, generating: false, progress: null, error: null, partial: false, steps: [], feine_steps: [] }
|
||||
const blocks = ref({ ...EMPTY_BLOCKS })
|
||||
const activeBlocks = ref([])
|
||||
const provider = ref(localStorage.getItem('provider') || 'claude')
|
||||
const providers = ref([])
|
||||
const folders = ref({ projekt: [], uni: [] }) // Ordner für die Quellen-Auswahl
|
||||
const bausteineView = ref(false) // Bausteine-Übersicht im Hauptbereich
|
||||
const ansichtModus = ref('kompakt') // kompakt | erklärend — je Thema, Default kompakt
|
||||
const folders = ref({ projekt: [], uni: [] }) // folders for the sources picker
|
||||
const mainView = ref('blocks') // blocks | elements | general | detail — exclusive main-area view
|
||||
const viewMode = ref('compact') // compact | erklärend — per topic, default compact
|
||||
const levelView = ref(Number(localStorage.getItem('level')) || 4) // 1=A · 2=F · 3=E · 4=V (levels view)
|
||||
const stats = ref(null)
|
||||
const fortschritt = ref({})
|
||||
const locks = ref({}) // Sperr-Gründe pro Format (Backend = einzige Regel-Quelle)
|
||||
const guideStepsDone = ref({}) // höchster fertiger Schritt-Index je Format (artefakt-basiert)
|
||||
const uiError = ref(null) // abgewiesene Aktionen (409/400) sichtbar machen
|
||||
const elementsOpen = ref(false) // rechte Sidebar
|
||||
const elementsView = ref(false) // Übersicht im Hauptbereich
|
||||
const elementsVersion = ref(0) // Erhöhung = Übersicht neu laden
|
||||
const elementOpenId = ref(null) // Element aus Übersicht in Sidebar öffnen
|
||||
const progress = ref({})
|
||||
const locks = ref({}) // lock reasons per format (backend = single rule source)
|
||||
const guideStepsDone = ref({}) // highest finished step index per format (artifact-based)
|
||||
const uiError = ref(null) // surface rejected actions (409/400)
|
||||
const elementsOpen = ref(false) // right sidebar
|
||||
const elementsVersion = ref(0) // increment = reload overview
|
||||
const elementOpenId = ref(null) // open element from overview in sidebar
|
||||
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() {
|
||||
try {
|
||||
stats.value = await fetchStats()
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden der Statistik:', e)
|
||||
}
|
||||
await guard('Failed to load stats:', async () => { stats.value = await fetchStats() })
|
||||
}
|
||||
|
||||
function setProvider(id) {
|
||||
@@ -52,25 +55,21 @@ function setProvider(id) {
|
||||
}
|
||||
|
||||
async function loadFolders() {
|
||||
try {
|
||||
await guard('Failed to load folders:', async () => {
|
||||
const [projekt, uni] = await Promise.all([fetchFolders('projekt'), fetchFolders('uni')])
|
||||
folders.value = { projekt, uni }
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden der Ordner:', e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function loadProviders() {
|
||||
try {
|
||||
await guard('Failed to load providers:', async () => {
|
||||
providers.value = await fetchProviders()
|
||||
const current = providers.value.find((p) => p.id === provider.value)
|
||||
if (current && !current.available) {
|
||||
const fallback = providers.value.find((p) => p.available)
|
||||
if (fallback) setProvider(fallback.id)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden der Provider:', e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function applyTheme() {
|
||||
@@ -132,12 +131,12 @@ async function loadTopics() {
|
||||
try {
|
||||
backendTopics.value = await fetchTopics()
|
||||
} 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).
|
||||
// Nicht weggeklickte Fehler bleiben sichtbar, bis der Nutzer sie schließt.
|
||||
// Dismissed errors stay dismissed — even across reloads (localStorage).
|
||||
// Errors that aren't dismissed stay visible until the user closes them.
|
||||
const dismissedErrors = ref(new Set(JSON.parse(localStorage.getItem('dismissedErrors') || '[]')))
|
||||
|
||||
function persistDismissed() {
|
||||
@@ -152,7 +151,7 @@ function handleDismissError(guideId) {
|
||||
async function loadGuides() {
|
||||
try {
|
||||
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))
|
||||
if ([...dismissedErrors.value].some((id) => !errorIds.has(id))) {
|
||||
dismissedErrors.value = new Set([...dismissedErrors.value].filter((id) => errorIds.has(id)))
|
||||
@@ -160,97 +159,86 @@ async function loadGuides() {
|
||||
}
|
||||
loadStats()
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden:', e)
|
||||
console.error('Failed to load:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBausteine() {
|
||||
async function loadBlocks() {
|
||||
try {
|
||||
activeBausteine.value = await fetchActiveBausteine()
|
||||
activeBlocks.value = await fetchActiveBlocks()
|
||||
if (selectedTopic.value) {
|
||||
bausteine.value = await fetchBausteineStatus(selectedTopic.value)
|
||||
fortschritt.value = await fetchTopicFortschritt(selectedTopic.value)
|
||||
blocks.value = await fetchBlocksStatus(selectedTopic.value)
|
||||
progress.value = await fetchTopicProgress(selectedTopic.value)
|
||||
locks.value = await fetchGuideLocks(selectedTopic.value)
|
||||
guideStepsDone.value = await fetchGuideSteps(selectedTopic.value)
|
||||
} else {
|
||||
bausteine.value = { ...EMPTY_BAUSTEINE }
|
||||
fortschritt.value = {}
|
||||
blocks.value = { ...EMPTY_BLOCKS }
|
||||
progress.value = {}
|
||||
locks.value = {}
|
||||
guideStepsDone.value = {}
|
||||
}
|
||||
if (activeBausteine.value.length && !polling.running()) startPolling()
|
||||
if (activeBlocks.value.length && !polling.running()) startPolling()
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden der Bausteine:', e)
|
||||
console.error('Failed to load blocks:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const FORMAT_ORDER = ['Guide', 'FullGuide', 'Rest']
|
||||
|
||||
function autoPreview() {
|
||||
const map = doneByFormat.value
|
||||
for (const f of FORMAT_ORDER) {
|
||||
if (map[f]) {
|
||||
previewGuide.value = map[f]
|
||||
return
|
||||
}
|
||||
}
|
||||
previewGuide.value = null
|
||||
}
|
||||
|
||||
function selectTopic(topic) {
|
||||
selectedTopic.value = topic
|
||||
previewGuide.value = null
|
||||
sidebarSticky.value = false
|
||||
elementsOpen.value = false
|
||||
elementsView.value = false
|
||||
bausteineView.value = false
|
||||
mainView.value = 'blocks' // topic click → blocks overview (guide only on pill click)
|
||||
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)
|
||||
loadBausteine()
|
||||
nextTick(autoPreview)
|
||||
loadBlocks()
|
||||
}
|
||||
|
||||
// Beim Reload dort landen, wo man vorher war (Thema + Format)
|
||||
// On reload, land where you were before (topic + format)
|
||||
watch(previewGuide, (g) => {
|
||||
if (g) localStorage.setItem('lastFormat', g.format)
|
||||
})
|
||||
|
||||
async function createTopic(topic) {
|
||||
await apiCreateTopic(topic)
|
||||
await loadTopics()
|
||||
selectedTopic.value = topic
|
||||
previewGuide.value = null
|
||||
loadBausteine()
|
||||
}
|
||||
|
||||
async function handleCancelBausteine() {
|
||||
async function handleCancelBlocks() {
|
||||
if (!selectedTopic.value) return
|
||||
await apiCancelBausteine(selectedTopic.value)
|
||||
await loadBausteine()
|
||||
await loadBlocks()
|
||||
}
|
||||
|
||||
async function handleResetBausteine() {
|
||||
async function handleResetBlocks() {
|
||||
if (!selectedTopic.value) return
|
||||
await apiDeleteBausteine(selectedTopic.value)
|
||||
await loadBausteine()
|
||||
await loadBlocks()
|
||||
}
|
||||
|
||||
async function handleBausteineClick({ instructions, abPhase = null }) {
|
||||
async function handleResetFromStep(step) {
|
||||
if (!selectedTopic.value) return
|
||||
uiError.value = null
|
||||
try {
|
||||
// Quelle ist hier schon festgeschrieben; nur abPhase steuert den Re-Run-Umfang.
|
||||
await apiCreateBausteine(selectedTopic.value, instructions, provider.value, undefined, undefined, abPhase)
|
||||
await apiResetBausteineAbStep(selectedTopic.value, step) // only reset, no regeneration
|
||||
} catch (e) {
|
||||
uiError.value = e.message
|
||||
return
|
||||
}
|
||||
await loadBausteine()
|
||||
await loadBlocks()
|
||||
}
|
||||
|
||||
async function handleBlocksClick({ instructions, abPhase = null, abStep = null, toStep = null, research = true }) {
|
||||
if (!selectedTopic.value) return
|
||||
uiError.value = null
|
||||
try {
|
||||
// Source is already fixed here; abPhase/abStep set the start, toStep an optional end limit.
|
||||
await apiCreateBausteine(selectedTopic.value, instructions, provider.value, undefined, undefined, abPhase, abStep, toStep, research)
|
||||
} catch (e) {
|
||||
uiError.value = e.message
|
||||
return
|
||||
}
|
||||
await loadBlocks()
|
||||
startPolling()
|
||||
}
|
||||
|
||||
async function handleCreateThema({ topic, instructions, sourceType, sourceOrt }) {
|
||||
async function handleCreateTopic({ topic, instructions, sourceType, sourceOrt }) {
|
||||
uiError.value = null
|
||||
try {
|
||||
await apiCreateBausteine(topic, instructions, provider.value, sourceType, sourceOrt)
|
||||
@@ -263,7 +251,7 @@ async function handleCreateThema({ topic, instructions, sourceType, sourceOrt })
|
||||
startPolling()
|
||||
}
|
||||
|
||||
async function handleUpdateQuelle({ topic, type, ort, spec }) {
|
||||
async function handleUpdateSource({ topic, type, ort, spec }) {
|
||||
uiError.value = null
|
||||
try {
|
||||
await apiUpdateQuelle(topic, { type, ort, spec })
|
||||
@@ -271,24 +259,28 @@ async function handleUpdateQuelle({ topic, type, ort, spec }) {
|
||||
uiError.value = e.message
|
||||
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) {
|
||||
ansichtModus.value = modus
|
||||
function setView(modus) {
|
||||
viewMode.value = modus
|
||||
if (selectedTopic.value) localStorage.setItem('ansicht_' + selectedTopic.value, modus)
|
||||
}
|
||||
|
||||
function handleOpenBausteineView() {
|
||||
function setLevel(k) {
|
||||
levelView.value = k
|
||||
localStorage.setItem('level', String(k))
|
||||
}
|
||||
|
||||
function handleOpenBlocksView() {
|
||||
if (!selectedTopic.value) return
|
||||
bausteineView.value = true
|
||||
elementsView.value = false
|
||||
mainView.value = 'blocks'
|
||||
previewGuide.value = null
|
||||
}
|
||||
|
||||
async function handleFormatClick({ format, instructions, abStep = null }) {
|
||||
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(
|
||||
(g) => g.topic === selectedTopic.value && g.format === format
|
||||
&& (g.status === 'generating' || g.status === 'queued'),
|
||||
@@ -307,15 +299,19 @@ async function handleFormatClick({ format, instructions, abStep = null }) {
|
||||
|
||||
function handlePreview(guide) {
|
||||
previewGuide.value = guide
|
||||
elementsView.value = false
|
||||
bausteineView.value = false
|
||||
mainView.value = 'detail'
|
||||
}
|
||||
|
||||
function handleGeneralExam() {
|
||||
if (!selectedTopic.value) return
|
||||
mainView.value = 'general'
|
||||
previewGuide.value = null
|
||||
}
|
||||
|
||||
function handleOpenElements() {
|
||||
if (!selectedTopic.value) return
|
||||
elementsView.value = true
|
||||
bausteineView.value = false
|
||||
// Rechte Sidebar bleibt zu — sie öffnet erst beim Klick auf ein Element.
|
||||
mainView.value = 'elements'
|
||||
// Right sidebar stays closed — it opens only when an element is clicked.
|
||||
}
|
||||
|
||||
function handleOpenElementDetail(el) {
|
||||
@@ -333,8 +329,8 @@ async function handleDeleteGuide(guideId, slots = false) {
|
||||
}
|
||||
|
||||
const polling = usePolling(
|
||||
() => Promise.all([loadGuides(), loadBausteine(), loadTopics()]),
|
||||
() => hasActiveGuides.value || activeBausteine.value.length > 0,
|
||||
() => Promise.all([loadGuides(), loadBlocks(), loadTopics()]),
|
||||
() => hasActiveGuides.value || activeBlocks.value.length > 0,
|
||||
)
|
||||
const startPolling = polling.start
|
||||
|
||||
@@ -361,12 +357,8 @@ async function handleDeleteTopic(topic) {
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadGuides(), loadTopics(), loadProviders(), loadFolders()])
|
||||
const savedTopic = localStorage.getItem('lastTopic')
|
||||
const savedFormat = localStorage.getItem('lastFormat')
|
||||
if (savedTopic && topics.value.includes(savedTopic)) {
|
||||
selectTopic(savedTopic)
|
||||
await nextTick()
|
||||
const g = doneByFormat.value[savedFormat]
|
||||
if (g) previewGuide.value = g
|
||||
selectTopic(savedTopic) // lands on the blocks overview
|
||||
} else if (!selectedTopic.value && topics.value.length) {
|
||||
selectTopic(topics.value[0])
|
||||
}
|
||||
@@ -374,14 +366,14 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout" :class="{ 'sidebar-floating': !sidebarPinned, 'sidebar-open': 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 && sidebarSticky" class="sidebar-backdrop" @click="sidebarSticky = false"></div>
|
||||
<div v-if="(!sidebarPinned && sidebarSticky) || (focusOpen && sidebarSticky)" class="sidebar-backdrop" @click="sidebarSticky = false"></div>
|
||||
<TopicSidebar
|
||||
:topics="topics"
|
||||
:selectedTopic="selectedTopic"
|
||||
:stats="stats"
|
||||
:fortschritt="fortschritt"
|
||||
:fortschritt="progress"
|
||||
:locks="locks"
|
||||
:guideStepsDone="guideStepsDone"
|
||||
:uiError="uiError"
|
||||
@@ -389,26 +381,28 @@ onMounted(async () => {
|
||||
:latestByFormat="latestByFormat"
|
||||
:allGuides="guides"
|
||||
:dismissedErrors="dismissedErrors"
|
||||
:bausteine="bausteine"
|
||||
:activeBausteine="activeBausteine"
|
||||
:blocks="blocks"
|
||||
:activeBausteine="activeBlocks"
|
||||
:pinned="sidebarPinned"
|
||||
:dark="darkMode"
|
||||
:provider="provider"
|
||||
:providers="providers"
|
||||
:folders="folders"
|
||||
:ansichtModus="ansichtModus"
|
||||
:ansichtModus="viewMode"
|
||||
:stufeAnsicht="levelView"
|
||||
@setProvider="setProvider"
|
||||
@toggleDark="toggleDark"
|
||||
@setAnsicht="setAnsicht"
|
||||
@setAnsicht="setView"
|
||||
@setStufe="setLevel"
|
||||
@generalExam="handleGeneralExam"
|
||||
@select="selectTopic"
|
||||
@create="createTopic"
|
||||
@createThema="handleCreateThema"
|
||||
@updateQuelle="handleUpdateQuelle"
|
||||
@openBausteineView="handleOpenBausteineView"
|
||||
@createThema="handleCreateTopic"
|
||||
@updateSource="handleUpdateSource"
|
||||
@openBausteineView="handleOpenBlocksView"
|
||||
@formatClick="handleFormatClick"
|
||||
@bausteineClick="handleBausteineClick"
|
||||
@cancelBausteine="handleCancelBausteine"
|
||||
@resetBausteine="handleResetBausteine"
|
||||
@bausteineClick="handleBlocksClick"
|
||||
@cancelBlocks="handleCancelBlocks"
|
||||
@resetBausteine="handleResetBlocks"
|
||||
@deleteTopic="handleDeleteTopic"
|
||||
@cancelGuide="handleCancel"
|
||||
@deleteGuide="handleDeleteGuide"
|
||||
@@ -419,17 +413,34 @@ onMounted(async () => {
|
||||
@togglePin="toggleSidebarPin"
|
||||
@sidebarLeave="onSidebarLeave"
|
||||
/>
|
||||
<BausteineUebersicht
|
||||
v-if="selectedTopic && bausteineView"
|
||||
<BlocksOverview
|
||||
v-if="selectedTopic && mainView === 'blocks'"
|
||||
:topic="selectedTopic"
|
||||
@close="bausteineView = false"
|
||||
:steps="blocks.feine_steps || []"
|
||||
:generating="blocks.generating"
|
||||
:progress="blocks.progress"
|
||||
:ready="blocks.ready"
|
||||
:partial="blocks.partial"
|
||||
@close="mainView = 'detail'"
|
||||
@restartFrom="(r) => handleBlocksClick({ instructions: '', abStep: r.from, toStep: r.to })"
|
||||
@resetFrom="handleResetFromStep"
|
||||
@restartAll="(o) => handleBlocksClick({ research: o?.research ?? false })"
|
||||
@removeAll="handleResetBlocks"
|
||||
@cancel="handleCancelBlocks"
|
||||
/>
|
||||
<ElementsOverview
|
||||
v-else-if="selectedTopic && elementsView"
|
||||
v-else-if="selectedTopic && mainView === 'elements'"
|
||||
:topic="selectedTopic"
|
||||
:version="elementsVersion"
|
||||
@open="handleOpenElementDetail"
|
||||
/>
|
||||
<GeneralExamPanel
|
||||
v-else-if="selectedTopic && mainView === 'general'"
|
||||
:topic="selectedTopic"
|
||||
:provider="provider"
|
||||
@progressChanged="loadStats(); loadBlocks()"
|
||||
@fokus-active="focusOpen = $event"
|
||||
/>
|
||||
<TopicDetail
|
||||
v-else-if="selectedTopic"
|
||||
:previewGuide="previewGuide"
|
||||
@@ -437,13 +448,16 @@ onMounted(async () => {
|
||||
:provider="provider"
|
||||
:elementsOpen="elementsOpen"
|
||||
:doneByFormat="doneByFormat"
|
||||
:themaAbgeschlossen="!!fortschritt.abgeschlossen"
|
||||
:ansichtModus="ansichtModus"
|
||||
@progressChanged="loadStats(); loadBausteine()"
|
||||
@setAnsicht="setAnsicht"
|
||||
:themaAbgeschlossen="!!progress.completed"
|
||||
:ansichtModus="viewMode"
|
||||
:stufeAnsicht="levelView"
|
||||
@progressChanged="loadStats(); loadBlocks()"
|
||||
@setAnsicht="setView"
|
||||
@open-sidebar="sidebarSticky = true"
|
||||
@fokus-active="focusOpen = $event"
|
||||
/>
|
||||
<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
|
||||
v-if="elementsOpen && selectedTopic"
|
||||
@@ -482,6 +496,11 @@ onMounted(async () => {
|
||||
--success-soft: #d1fae5;
|
||||
--success-soft-hover: #a7f3d0;
|
||||
--success-border: #34d399;
|
||||
/* 4 learning levels: green/blue/purple/gold = Beginner/Advanced/Expert/Master */
|
||||
--level-beginner: #22c55e;
|
||||
--level-advanced: #3b82f6;
|
||||
--level-expert: #8b5cf6;
|
||||
--level-master: #d4af37;
|
||||
--warning: #92400e;
|
||||
--warning-soft: #fef3c7;
|
||||
--warning-border: #fbbf24;
|
||||
@@ -557,8 +576,8 @@ textarea::placeholder {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Unsichtbare Fläche hinter der offenen Floating-Sidebar.
|
||||
Tipp/Klick daneben schließt sie — ohne sie gibt es auf Touch keinen Ausweg. */
|
||||
/* Invisible surface behind the open floating sidebar.
|
||||
A tap/click next to it closes it — without it there's no way out on touch. */
|
||||
.sidebar-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -583,6 +602,21 @@ textarea::placeholder {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* Block fullscreen: lift the sidebar as an overlay ABOVE the focus overlay (z-index 40).
|
||||
Applies for pinned AND floating. */
|
||||
.layout.sidebar-over-fokus > .sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100dvh;
|
||||
transform: translateX(0);
|
||||
z-index: 50;
|
||||
box-shadow: 0 0 16px var(--shadow);
|
||||
}
|
||||
.layout.sidebar-over-fokus .sidebar-backdrop {
|
||||
z-index: 49;
|
||||
}
|
||||
|
||||
.empty-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -592,8 +626,8 @@ textarea::placeholder {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Nur sichtbar, wenn die Elemente-Sidebar mobil als Overlay liegt.
|
||||
Tipp daneben schließt sie. */
|
||||
/* Only visible when the elements sidebar sits as an overlay on mobile.
|
||||
A tap next to it closes it. */
|
||||
.elements-backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -37,70 +37,79 @@ export async function createGuide(topic, format, instructions = '', provider = '
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function fetchActiveBausteine() {
|
||||
const res = await fetch(`${BASE}/bausteine/active`)
|
||||
export async function fetchActiveBlocks() {
|
||||
const res = await fetch(`${BASE}/blocks/active`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function fetchBausteineStatus(topic) {
|
||||
const res = await fetch(`${BASE}/bausteine/status?topic=${encodeURIComponent(topic)}`)
|
||||
export async function fetchBlocksStatus(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/status?topic=${encodeURIComponent(topic)}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function createBausteine(topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '', abPhase = null) {
|
||||
const res = await fetch(`${BASE}/bausteine`, {
|
||||
export async function createBlocks(topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '', abPhase = null, abStep = null, toStep = null, research = true) {
|
||||
const res = await fetch(`${BASE}/blocks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, instructions, provider, source_type: sourceType, source_ort: sourceOrt, ab_phase: abPhase }),
|
||||
body: JSON.stringify({ topic, instructions, provider, source_type: sourceType, source_location: sourceOrt, ab_phase: abPhase, ab_step: abStep, to_step: toStep, research }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function cancelBausteine(topic) {
|
||||
await fetch(`${BASE}/bausteine/cancel?topic=${encodeURIComponent(topic)}`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function deleteBausteine(topic) {
|
||||
await fetch(`${BASE}/bausteine?topic=${encodeURIComponent(topic)}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// --- Baustein-Lernen: Chat, Prüfung ---
|
||||
|
||||
export async function fetchBausteinLernstand(topic) {
|
||||
const res = await fetch(`${BASE}/bausteine/lernstand?topic=${encodeURIComponent(topic)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function chatBaustein({ topic, baustein, section, section_kompakt = '', messages, provider }) {
|
||||
const res = await fetch(`${BASE}/bausteine/chat`, {
|
||||
export async function resetBlocksFromStep(topic, abStep) {
|
||||
const res = await fetch(`${BASE}/blocks/reset-step`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, baustein, section, section_kompakt, messages, provider }),
|
||||
body: JSON.stringify({ topic, ab_step: abStep }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function pruefeBaustein({
|
||||
topic, baustein, section, section_kompakt = '', provider,
|
||||
aktion = 'frage', frage = '', letzte_bewertung = '', vermeide = [],
|
||||
nachgefragt = false, begruendung = '', muster = '', cap = 6, messages = [], gruendlich = false,
|
||||
auswahl = [], korrekt = [], loesung = '', alternativen = [], eingabe = '', schwer = false,
|
||||
export async function cancelBlocks(topic) {
|
||||
await fetch(`${BASE}/blocks/cancel?topic=${encodeURIComponent(topic)}`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function deleteBlocks(topic) {
|
||||
await fetch(`${BASE}/blocks?topic=${encodeURIComponent(topic)}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// --- Block-Learning: Chat, Exam ---
|
||||
|
||||
export async function fetchBlockLearnState(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/learnstate?topic=${encodeURIComponent(topic)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function chatBlock({ topic, block, section, section_compact = '', messages, provider }) {
|
||||
const res = await fetch(`${BASE}/blocks/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, block, section, section_compact, messages, provider }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function examBlock({
|
||||
topic, block, section, section_compact = '', provider,
|
||||
action = 'question', question = '', last_rating = '', avoid = [],
|
||||
asked_again = false, reason = '', pattern = '', cap = 6, messages = [], thorough = false,
|
||||
selection = [], correct = [], solution = '', alternatives = [], input = '', schwer = false,
|
||||
}) {
|
||||
const res = await fetch(`${BASE}/bausteine/pruefung`, {
|
||||
const res = await fetch(`${BASE}/blocks/exam`, {
|
||||
method: 'POST',
|
||||
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)
|
||||
}
|
||||
|
||||
export async function fetchFrageMuster(topic, baustein) {
|
||||
const res = await fetch(`${BASE}/bausteine/frage-muster?topic=${encodeURIComponent(topic)}&baustein=${encodeURIComponent(baustein)}`)
|
||||
export async function fetchQuestionPattern(topic, block) {
|
||||
const res = await fetch(`${BASE}/blocks/question-pattern?topic=${encodeURIComponent(topic)}&block=${encodeURIComponent(block)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function fetchTopicFortschritt(topic) {
|
||||
const res = await fetch(`${BASE}/topics/fortschritt?topic=${encodeURIComponent(topic)}`)
|
||||
export async function fetchTopicProgress(topic) {
|
||||
const res = await fetch(`${BASE}/topics/progress?topic=${encodeURIComponent(topic)}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
@@ -114,37 +123,46 @@ export async function fetchProviders() {
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function fetchProjects() {
|
||||
const res = await fetch(`${BASE}/projects`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function fetchFolders(kind) {
|
||||
const res = await fetch(`${BASE}/folders?kind=${encodeURIComponent(kind)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function fetchQuelle(topic) {
|
||||
const res = await fetch(`${BASE}/bausteine/quelle?topic=${encodeURIComponent(topic)}`)
|
||||
export async function fetchSource(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/source?topic=${encodeURIComponent(topic)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function updateQuelle(topic, { type, ort = '', spec = '' }) {
|
||||
const res = await fetch(`${BASE}/bausteine/quelle`, {
|
||||
export async function updateSource(topic, { type, ort = '', spec = '' }) {
|
||||
const res = await fetch(`${BASE}/blocks/source`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, type, ort, spec }),
|
||||
body: JSON.stringify({ topic, type, location: ort, spec }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function fetchBausteineUebersicht(topic) {
|
||||
const res = await fetch(`${BASE}/bausteine/uebersicht?topic=${encodeURIComponent(topic)}`)
|
||||
export async function fetchBlocksOverview(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/overview?topic=${encodeURIComponent(topic)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function deleteProject(name) {
|
||||
await fetch(`${BASE}/projects/${encodeURIComponent(name)}`, { method: 'DELETE' })
|
||||
// Live card counts per kanban column ({} when the streaming inventory is not in use).
|
||||
export async function fetchKanban(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/kanban?topic=${encodeURIComponent(topic)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// Currently running agents for a topic + their runtime in seconds.
|
||||
export async function fetchAgents(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/agents?topic=${encodeURIComponent(topic)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// Attach one more research agent to the running kanban flow.
|
||||
export async function addResearch(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/research?topic=${encodeURIComponent(topic)}`, { method: 'POST' })
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function cancelGuide(id) {
|
||||
@@ -155,12 +173,45 @@ export async function deleteGuide(id, slots = false) {
|
||||
await fetch(`${BASE}/guides/${id}${slots ? '?slots=1' : ''}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function fetchGuideContent(id) {
|
||||
const res = await fetch(`${BASE}/guides/${id}/content`)
|
||||
if (!res.ok) throw new Error(`Inhalt nicht verfügbar (${res.status})`)
|
||||
export async function fetchGuideContent(id, level = 4) {
|
||||
const res = await fetch(`${BASE}/guides/${id}/content?level=${level}`)
|
||||
if (!res.ok) throw new Error(`Content not available (${res.status})`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// Lern-Artefakte (Flashcards/Examples/Diagramme) je Thema, gruppiert nach Block-Norm.
|
||||
export async function fetchArtefakte(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/artefakte?topic=${encodeURIComponent(topic)}`)
|
||||
if (!res.ok) return { artefakte: {} }
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// Einen Markdown-Block on-demand gegen die Guide-Rules prüfen (Fokus, Rechtsklick).
|
||||
export async function pruefeBlock(id, { block, spot, snippet, hint = '', provider }) {
|
||||
const res = await fetch(`${BASE}/guides/${id}/block/pruefen`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ block, spot, snippet, hint, provider }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// Geprüften Block persistent übernehmen (alt → new im jeweiligen Feld).
|
||||
export async function uebernehmeBlock(id, { block, spot, alt, revised, provider }) {
|
||||
const res = await fetch(`${BASE}/guides/${id}/block/uebernehmen`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ block, spot, alt, revised, provider }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// Reset a block's learning progress to zero (score/streak/flags/open question).
|
||||
export async function resetBlockProgress(topic, block) {
|
||||
const res = await fetch(`${BASE}/blocks/progress?topic=${encodeURIComponent(topic)}&block=${encodeURIComponent(block)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function fetchTopics() {
|
||||
const res = await fetch(`${BASE}/topics`)
|
||||
return res.json()
|
||||
@@ -229,7 +280,7 @@ export async function styleElement(id, provider = 'claude') {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -249,6 +300,6 @@ export async function checkElement(id, provider = 'claude') {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -7,11 +7,22 @@
|
||||
margin: 0 0 0.5em;
|
||||
}
|
||||
|
||||
/* KaTeX: lange Block-Formeln scrollen statt das Layout zu sprengen */
|
||||
/* KaTeX: lange Block-Formeln scrollen statt das Layout zu sprengen.
|
||||
Sichtbarer dünner Scrollbalken (statt unsichtbarem Overlay) → man erkennt,
|
||||
dass die Formel weitergeht und nicht abgeschnitten ist. */
|
||||
.markdown .katex-display {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding: 0.2em 0;
|
||||
padding: 0.2em 0 0.45em;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
.markdown .katex-display::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
.markdown .katex-display::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.markdown p:last-child {
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
<script setup>
|
||||
import BausteinPanel from './BausteinPanel.vue'
|
||||
import { renderMarkdown } from '../markdown.js'
|
||||
|
||||
const props = defineProps({
|
||||
baustein: { type: Object, required: true }, // { title, md, num }
|
||||
topic: { type: String, required: true },
|
||||
provider: { type: String, default: 'claude' },
|
||||
status: { type: Object, default: null },
|
||||
cap: { type: Number, default: 6 },
|
||||
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, absolviert: 0, verstanden: 0, gemeistert: 0 }) },
|
||||
})
|
||||
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const emit = defineEmits(['close', 'prev', 'next', 'statusChanged', 'setAnsicht'])
|
||||
|
||||
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 Stand des aktuellen Bausteins: gemeistert=gold, verstanden=lila, absolviert=grün.
|
||||
const standFarbe = computed(() => {
|
||||
const s = props.status || {}
|
||||
if (s.gemeistert) return '#d4af37'
|
||||
if (s.verstanden) return '#8b5cf6'
|
||||
if (s.absolviert) return 'var(--success-border)'
|
||||
return 'var(--border)'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fokus-overlay" :style="{ '--stand': standFarbe }">
|
||||
<div class="fokus-bar">
|
||||
<div class="fokus-bar-inner">
|
||||
<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="status?.gemeistert" class="stand-badge gold">✓✓✓ Gemeistert</span>
|
||||
<span v-else-if="status?.verstanden" class="stand-badge lila">✓✓ Verstanden</span>
|
||||
<span v-else-if="status?.absolviert" class="stand-badge gruen">✓ Absolviert</span>
|
||||
<span class="fokus-spacer"></span>
|
||||
<button class="fokus-btn" title="Vollansicht beenden" @click="$emit('close')">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fokus-xp" :title="`${fortschritt.absolviert}/${fortschritt.total} absolviert · ${fortschritt.verstanden} verstanden · ${fortschritt.gemeistert} gemeistert`">
|
||||
<div class="xp-seg gold" :style="{ width: pct(fortschritt.gemeistert) }"></div>
|
||||
<div class="xp-seg lila" :style="{ width: pct(fortschritt.verstanden - fortschritt.gemeistert) }"></div>
|
||||
<div class="xp-seg gruen" :style="{ width: pct(fortschritt.absolviert - fortschritt.verstanden) }"></div>
|
||||
</div>
|
||||
<div class="fokus-body">
|
||||
<div ref="guideEl" class="fokus-col left">
|
||||
<div class="markdown" v-html="renderMarkdown(ansicht === 'kompakt' ? (baustein.kompakt || baustein.md) : baustein.md)"></div>
|
||||
</div>
|
||||
<div ref="rightEl" class="fokus-col right">
|
||||
<BausteinPanel
|
||||
mode="full"
|
||||
:key="baustein.title + '|' + tab"
|
||||
: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 { display: flex; height: 6px; background: var(--panel-soft); }
|
||||
.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; }
|
||||
|
||||
/* 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; }
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,225 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { fetchBausteineUebersicht } from '../api.js'
|
||||
|
||||
const props = defineProps({
|
||||
topic: { type: String, required: true },
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const items = ref([])
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
|
||||
// Stufen-Reihenfolge + Anzeige-Label (Farbe via CSS-Klasse st-<key>)
|
||||
const STUFEN = [
|
||||
{ key: 'einfach', label: 'Einfach' },
|
||||
{ key: 'mittel', label: 'Mittel' },
|
||||
{ key: 'schwer', label: 'Schwer' },
|
||||
]
|
||||
|
||||
watch(() => props.topic, load, { immediate: true })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
items.value = []
|
||||
try {
|
||||
items.value = await fetchBausteineUebersicht(props.topic)
|
||||
} catch (e) {
|
||||
error.value = 'Übersicht nicht verfügbar — bitte erst Bausteine erstellen.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Baustein relevant = hat ≥1 relevanten Subbaustein (gleiche Regel wie der Guide).
|
||||
// Ohne Relevanz-Daten (Alt-Themen) nicht dimmen.
|
||||
function relevant(b) {
|
||||
const mitRelevanz = (b.subbausteine || []).filter((s) => s.relevanz)
|
||||
return !mitRelevanz.length || mitRelevanz.some((s) => s.relevanz === 'relevant')
|
||||
}
|
||||
|
||||
// Nur nicht-leere Stufen-Gruppen je Baustein (v-if + v-for nicht auf einem Element)
|
||||
function gruppen(b) {
|
||||
return STUFEN
|
||||
.map((st) => ({ ...st, subs: (b.subbausteine || []).filter((s) => s.stufe === st.key) }))
|
||||
.filter((g) => g.subs.length)
|
||||
}
|
||||
|
||||
const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subbausteine?.length || 0), 0))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bk-view">
|
||||
<header class="bk-head">
|
||||
<h1>{{ topic }}</h1>
|
||||
<span class="bk-sub">Bausteine-Übersicht</span>
|
||||
<span v-if="items.length" class="bk-count">{{ items.length }} Bausteine · {{ subTotal }} Subbausteine</span>
|
||||
<span class="bk-spacer"></span>
|
||||
<button class="bk-close" title="Schließen" @click="emit('close')">✕</button>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="bk-empty-state">Lade…</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 class="bk-grid">
|
||||
<article
|
||||
v-for="b in items"
|
||||
:key="b.num"
|
||||
class="bk-card"
|
||||
:class="{ 'bk-irrelevant': !relevant(b) }"
|
||||
:title="relevant(b) ? null : 'Nicht relevant — kommt später in den „Rest“'"
|
||||
>
|
||||
<h3 class="bk-title"><span class="bk-num">{{ b.num }}</span>{{ b.titel }}</h3>
|
||||
<p v-if="b.beschreibung" class="bk-desc">{{ b.beschreibung }}</p>
|
||||
<div v-if="b.subbausteine && b.subbausteine.length" class="bk-stufen">
|
||||
<div v-for="g in gruppen(b)" :key="g.key" class="bk-stufe" :class="'st-' + g.key">
|
||||
<span class="bk-stufe-label">{{ g.label }}</span>
|
||||
<ul>
|
||||
<li v-for="s in g.subs" :key="s.titel" :class="{ rand: s.relevanz === 'rand' }">
|
||||
{{ s.titel }}<span v-if="s.relevanz === 'rand'" class="rand-tag" title="Randthema — kommt später in den „Rest“">Rand</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="bk-no-subs">Keine Subbausteine.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bk-view {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-preview);
|
||||
}
|
||||
|
||||
.bk-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
padding: 1.25rem 2rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
}
|
||||
.bk-head h1 { font-size: 1.5rem; }
|
||||
.bk-sub { color: var(--text-faint); font-size: 0.9rem; font-weight: 600; }
|
||||
.bk-count { color: var(--text-muted); font-size: 0.82rem; }
|
||||
.bk-spacer { flex: 1; }
|
||||
.bk-close {
|
||||
align-self: center;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.bk-close:hover { border-color: var(--accent); }
|
||||
|
||||
.bk-empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.bk-grid {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem 2rem 4rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.bk-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-top: 3px solid var(--accent-border);
|
||||
border-radius: 10px;
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
/* Nicht relevante Bausteine (kein relevanter Subbaustein) gedimmt */
|
||||
.bk-card.bk-irrelevant { opacity: 0.5; }
|
||||
|
||||
.bk-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.bk-num {
|
||||
flex: 0 0 auto;
|
||||
min-width: 26px;
|
||||
height: 26px;
|
||||
padding: 0 6px;
|
||||
border-radius: 7px;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.bk-desc {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
|
||||
.bk-stufen { display: flex; flex-direction: column; gap: 0.55rem; }
|
||||
.bk-stufe {
|
||||
border-left: 3px solid var(--st);
|
||||
padding-left: 0.6rem;
|
||||
}
|
||||
.bk-stufe.st-einfach { --st: var(--success-border); }
|
||||
.bk-stufe.st-mittel { --st: var(--warning-border); }
|
||||
.bk-stufe.st-schwer { --st: var(--danger); }
|
||||
|
||||
.bk-stufe-label {
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--st);
|
||||
}
|
||||
.bk-stufe ul {
|
||||
list-style: disc;
|
||||
margin: 0.25rem 0 0;
|
||||
padding-left: 1.15rem;
|
||||
}
|
||||
.bk-stufe li {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
line-height: 1.35;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.bk-stufe li::marker { color: var(--text-faint); }
|
||||
.bk-stufe li.rand { color: var(--text-faint); }
|
||||
.rand-tag {
|
||||
margin-left: 0.4rem;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text-faint);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 4px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.bk-no-subs { color: var(--text-faint); font-size: 0.8rem; font-style: italic; }
|
||||
</style>
|
||||
418
frontend/src/components/BlockFocus.vue
Normal file
418
frontend/src/components/BlockFocus.vue
Normal 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>
|
||||
966
frontend/src/components/BlockPanel.vue
Normal file
966
frontend/src/components/BlockPanel.vue
Normal 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 19–30) 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 1–4; 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()
|
||||
}
|
||||
|
||||
// 1–4 (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 1–4: 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>
|
||||
474
frontend/src/components/BlocksOverview.vue
Normal file
474
frontend/src/components/BlocksOverview.vue
Normal file
@@ -0,0 +1,474 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { fetchBlocksOverview, fetchKanban, fetchAgents, addResearch } from '../api.js'
|
||||
|
||||
const props = defineProps({
|
||||
topic: { type: String, required: true },
|
||||
steps: { type: Array, default: () => [] }, // fine sub-steps {label, phase, state}
|
||||
generating: { type: Boolean, default: false },
|
||||
progress: { type: String, default: null },
|
||||
ready: { type: Boolean, default: false },
|
||||
partial: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['close', 'restartFrom', 'resetFrom', 'restartAll', 'removeAll', 'cancel'])
|
||||
|
||||
// Live kanban board (streaming inventory). Ordered columns + their card counts.
|
||||
const KANBAN_COLS = [
|
||||
['merge', 'Merge'], ['chain', 'Chain'], ['chain_verify', 'Verify'], ['naming', 'Naming'],
|
||||
['naming_verify', 'Name✓'], ['chain_filter', 'Filter'], ['filter_verify', 'Filter✓'],
|
||||
['block_assemble', 'Block'], ['small_blocks', 'Small'], ['small_verify', 'Small✓'],
|
||||
['dependency', 'Dep'], ['dependency_verify', 'Dep✓'], ['main', 'Main'], ['done_block', 'Done'],
|
||||
]
|
||||
const kanban = ref({})
|
||||
const agents = ref([])
|
||||
const kanbanCols = computed(() => KANBAN_COLS.map(([k, label]) => ({ key: k, label, n: kanban.value[k] || 0 })))
|
||||
const kanbanActive = computed(() => Object.values(kanban.value).some((n) => n > 0))
|
||||
function fmtRuntime(s) {
|
||||
const m = Math.floor(s / 60), sec = Math.floor(s % 60)
|
||||
return `${m}:${String(sec).padStart(2, '0')}`
|
||||
}
|
||||
const researchBusy = ref(false)
|
||||
async function moreResearch() {
|
||||
researchBusy.value = true
|
||||
try { await addResearch(props.topic) } catch { /* ignore */ }
|
||||
setTimeout(() => { researchBusy.value = false }, 800) // brief debounce against double-clicks
|
||||
}
|
||||
let kanbanTimer = null
|
||||
async function pollKanban() {
|
||||
try { kanban.value = await fetchKanban(props.topic) } catch { /* ignore */ }
|
||||
try { agents.value = await fetchAgents(props.topic) } catch { /* ignore */ }
|
||||
}
|
||||
watch(() => [props.topic, props.generating], () => {
|
||||
clearInterval(kanbanTimer)
|
||||
pollKanban()
|
||||
if (props.generating) kanbanTimer = setInterval(pollKanban, 1000)
|
||||
}, { immediate: true })
|
||||
onUnmounted(() => clearInterval(kanbanTimer))
|
||||
|
||||
// Group sub-steps by phase, carrying the global index for the re-run.
|
||||
const phaseGroups = computed(() => {
|
||||
const out = []
|
||||
props.steps.forEach((s, i) => {
|
||||
const last = out[out.length - 1]
|
||||
if (last && last.phase === s.phase) last.steps.push({ ...s, idx: i })
|
||||
else out.push({ phase: s.phase, steps: [{ ...s, idx: i }] })
|
||||
})
|
||||
return out
|
||||
})
|
||||
|
||||
const startSel = ref(null) // marked start point (step index) — only ≤ current stand
|
||||
const endSel = ref(null) // optional end point (step index, > start) — generation stops there
|
||||
const confirm = ref(null) // which destructive action currently shows "Sure?"
|
||||
const startLabel = computed(() => props.steps[startSel.value]?.label || '')
|
||||
const endLabel = computed(() => props.steps[endSel.value]?.label || '')
|
||||
|
||||
// Start is only valid up to the current stand: done/active steps, never a pending one (never ran).
|
||||
function startDisabled(idx) { return startSel.value === null && props.steps[idx]?.state === 'pending' }
|
||||
function inRange(idx) { return startSel.value !== null && endSel.value !== null && idx > startSel.value && idx < endSel.value }
|
||||
|
||||
function stepClick(idx) {
|
||||
if (props.generating || startDisabled(idx)) return
|
||||
confirm.value = null
|
||||
if (startSel.value === null) { startSel.value = idx; endSel.value = null } // 1st click → start
|
||||
else if (idx === startSel.value) { startSel.value = null; endSel.value = null } // re-click start → clear
|
||||
else if (idx > startSel.value) { endSel.value = endSel.value === idx ? null : idx } // later step → toggle end
|
||||
else if (props.steps[idx]?.state !== 'pending') { startSel.value = idx; endSel.value = null } // earlier → new start
|
||||
}
|
||||
function clearSel() { startSel.value = null; endSel.value = null; confirm.value = null }
|
||||
// 2-click confirmation for destructive actions: first click "arms", second runs it.
|
||||
function arm(action, fn) {
|
||||
if (confirm.value === action) { confirm.value = null; fn() }
|
||||
else confirm.value = action
|
||||
}
|
||||
// regenerate: re-run ONLY the picked step(s). No end → bound to the start itself (single step);
|
||||
// with an end → the whole [start, end] range. Later steps stay intact (bounded reset).
|
||||
function regenerateFromHere() { const from = startSel.value, to = endSel.value ?? startSel.value; clearSel(); emit('restartFrom', { from, to }) }
|
||||
// continue: run from the start point straight through to the end of the pipeline (full cascade).
|
||||
function continueFromHere() { const from = startSel.value; clearSel(); emit('restartFrom', { from, to: null }) }
|
||||
function deleteFromHere() { const from = startSel.value; clearSel(); emit('resetFrom', from) }
|
||||
|
||||
const items = ref([])
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
|
||||
// Learning-path levels: order + label (color via CSS class st-<key>).
|
||||
const LEVELS = [
|
||||
{ key: 'beginner', label: 'Beginner' },
|
||||
{ key: 'advanced', label: 'Advanced' },
|
||||
{ key: 'expert', label: 'Expert' },
|
||||
]
|
||||
// Legacy topics still carry einfach/mittel/schwer → map them to the new keys.
|
||||
const LEGACY_LEVEL = { einfach: 'beginner', mittel: 'advanced', schwer: 'expert' }
|
||||
|
||||
watch(() => props.topic, load, { immediate: true })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
items.value = []
|
||||
try {
|
||||
items.value = await fetchBlocksOverview(props.topic)
|
||||
} catch (e) {
|
||||
error.value = 'Overview not available — create blocks first.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Block relevant = has ≥1 relevant subblock (same rule as the guide).
|
||||
// Without relevance data (legacy topics) don't dim.
|
||||
function relevant(b) {
|
||||
const withRelevance = (b.subblocks || []).filter((s) => s.relevance)
|
||||
return !withRelevance.length || withRelevance.some((s) => s.relevance === 'relevant')
|
||||
}
|
||||
|
||||
// Only non-empty level groups per block (v-if + v-for not on one element)
|
||||
function groups(b) {
|
||||
return LEVELS
|
||||
.map((st) => ({ ...st, subs: (b.subblocks || []).filter((s) => (LEGACY_LEVEL[s.level] || s.level) === st.key) }))
|
||||
.filter((g) => g.subs.length)
|
||||
}
|
||||
|
||||
const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.length || 0), 0))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bk-view">
|
||||
<header class="bk-head">
|
||||
<h1>{{ topic }}</h1>
|
||||
<span class="bk-sub">Blocks overview</span>
|
||||
<span v-if="items.length" class="bk-count">{{ items.length }} Blocks · {{ subTotal }} Subblocks</span>
|
||||
<span class="bk-spacer"></span>
|
||||
<button class="bk-close" title="Close" @click="emit('close')">✕</button>
|
||||
</header>
|
||||
|
||||
<section v-if="steps.length" class="bk-steps">
|
||||
<div class="bk-steps-top">
|
||||
<div v-if="progress" class="bk-progress"><span class="bk-progress-dot"></span>{{ progress }}</div>
|
||||
<div v-if="!generating" class="bk-global-actions">
|
||||
<button class="bk-act play" @click="emit('restartAll', { research: false })" title="Process the existing queue — search no new topics">Continue</button>
|
||||
<button class="bk-act play" @click="emit('restartAll', { research: true })" title="Start one research agent and process the queue">+ Research</button>
|
||||
<button
|
||||
v-if="ready || partial"
|
||||
class="bk-act danger"
|
||||
:class="{ armed: confirm === 'remove' }"
|
||||
@click="arm('remove', () => emit('removeAll'))"
|
||||
>{{ confirm === 'remove' ? 'Sure?' : 'Remove' }}</button>
|
||||
</div>
|
||||
<div v-else class="bk-global-actions">
|
||||
<button class="bk-act play" :disabled="researchBusy" @click="moreResearch" title="Start one more research agent">+ Research</button>
|
||||
<button class="bk-act danger" @click="emit('cancel')">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="generating || kanbanActive" class="bk-kanban">
|
||||
<div v-for="c in kanbanCols" :key="c.key" class="bk-kcol" :class="{ 'bk-kactive': c.n > 0 }">
|
||||
<span class="bk-kcount">{{ c.n }}</span>
|
||||
<span class="bk-klabel">{{ c.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="agents.length" class="bk-agents">
|
||||
<span class="bk-agents-label">{{ agents.length }} Agenten aktiv:</span>
|
||||
<span v-for="a in agents" :key="a.label" class="bk-agent">
|
||||
{{ a.label }} <span class="bk-agent-time">{{ fmtRuntime(a.runtime) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="bk-phasen">
|
||||
<div v-for="g in phaseGroups" :key="g.phase" class="bk-phase">
|
||||
<span class="bk-phase-label">{{ g.phase }}</span>
|
||||
<div class="bk-steps">
|
||||
<button
|
||||
v-for="s in g.steps"
|
||||
:key="s.idx"
|
||||
class="bk-step"
|
||||
:class="[s.state, { sel: startSel === s.idx, end: endSel === s.idx, 'in-range': inRange(s.idx) }]"
|
||||
:disabled="generating || startDisabled(s.idx)"
|
||||
:title="startDisabled(s.idx) ? `«${s.label}» — not reached yet` : (startSel !== null && s.idx > startSel ? `End at «${s.label}»` : `Start at «${s.label}»`)"
|
||||
@click="stepClick(s.idx)"
|
||||
>{{ s.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="startSel !== null && !generating" class="bk-step-actions">
|
||||
<span class="bk-step-actions-label">From «{{ startLabel }}»<span v-if="endSel !== null"> to «{{ endLabel }}»</span>:</span>
|
||||
<button class="bk-act play" @click="regenerateFromHere" :title="endSel === null ? 'Re-run only this step; later steps stay' : 'Re-run every step in the range; later steps stay'">↻ regenerate {{ endSel === null ? 'step' : 'range' }}</button>
|
||||
<button v-if="endSel === null" class="bk-act" @click="continueFromHere" title="Run from here through to the end">▶ continue</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="clearSel">Cancel</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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="!items.length" class="bk-empty-state">No blocks yet.</div>
|
||||
|
||||
<div v-else class="bk-grid">
|
||||
<article
|
||||
v-for="b in items"
|
||||
:key="b.num"
|
||||
class="bk-card"
|
||||
:class="{ 'bk-irrelevant': !relevant(b) }"
|
||||
:title="relevant(b) ? null : 'Not relevant — comes later in the "Rest"'"
|
||||
>
|
||||
<h3 class="bk-title"><span class="bk-num">{{ b.num }}</span>{{ b.title }}</h3>
|
||||
<p v-if="b.description" class="bk-desc">{{ b.description }}</p>
|
||||
<div v-if="b.subblocks && b.subblocks.length" class="bk-levels">
|
||||
<div v-for="g in groups(b)" :key="g.key" class="bk-level" :class="'st-' + g.key">
|
||||
<span class="bk-level-label">{{ g.label }}</span>
|
||||
<ul>
|
||||
<li v-for="s in g.subs" :key="s.title" :class="{ rand: s.relevance === 'peripheral' }">
|
||||
{{ s.title }}<span v-if="s.relevance === 'peripheral'" class="rand-tag" title="Peripheral topic — comes later in the 'Rest'">Edge</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="bk-no-subs">No subblocks.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bk-view {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-preview);
|
||||
}
|
||||
|
||||
.bk-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
padding: 1.25rem 2rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
}
|
||||
.bk-head h1 { font-size: 1.5rem; }
|
||||
.bk-sub { color: var(--text-faint); font-size: 0.9rem; font-weight: 600; }
|
||||
.bk-count { color: var(--text-muted); font-size: 0.82rem; }
|
||||
.bk-spacer { flex: 1; }
|
||||
.bk-close {
|
||||
align-self: center;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.bk-close:hover { border-color: var(--accent); }
|
||||
|
||||
/* Step overview above the blocks */
|
||||
.bk-steps {
|
||||
padding: 0.85rem 2rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
.bk-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.84rem;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
.bk-progress-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: bk-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes bk-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||
|
||||
.bk-phasen { display: flex; flex-wrap: wrap; gap: 0.5rem 1.1rem; }
|
||||
.bk-phase { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.bk-phase-label {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.bk-steps { display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
||||
.bk-step {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.74rem;
|
||||
padding: 0.22rem 0.5rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bk-step:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
|
||||
.bk-step:disabled { cursor: default; opacity: 0.7; }
|
||||
.bk-step.done { border-color: var(--success-border); color: var(--success); }
|
||||
.bk-step.active { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); font-weight: 600; }
|
||||
.bk-step.pending { color: var(--text-faint); }
|
||||
.bk-step.sel,
|
||||
.bk-step.end { 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.in-range { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
|
||||
.bk-step:disabled:not(.done):not(.active) { opacity: 0.45; }
|
||||
|
||||
/* 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 .bk-progress { margin-bottom: 0; }
|
||||
|
||||
/* Live kanban board (streaming inventory) */
|
||||
.bk-kanban { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-bottom: 0.7rem; }
|
||||
.bk-kcol {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 1px;
|
||||
min-width: 3.1rem; padding: 0.3rem 0.4rem;
|
||||
border: 1px solid var(--border-strong); border-radius: 6px; background: var(--panel);
|
||||
}
|
||||
.bk-kcol.bk-kactive { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.bk-kcount { font-size: 0.95rem; font-weight: 700; color: var(--text); }
|
||||
.bk-kactive .bk-kcount { color: var(--accent); }
|
||||
.bk-klabel { font-size: 0.6rem; text-transform: uppercase; letter-spacing: 0.03em; color: var(--text-faint); }
|
||||
|
||||
/* Running agents + live runtime */
|
||||
.bk-agents { display: flex; flex-wrap: wrap; align-items: center; gap: 0.3rem 0.5rem; margin-bottom: 0.7rem; font-size: 0.78rem; }
|
||||
.bk-agents-label { color: var(--text-muted); font-weight: 600; }
|
||||
.bk-agent {
|
||||
display: inline-flex; align-items: center; gap: 0.35rem;
|
||||
padding: 0.12rem 0.5rem; border: 1px solid var(--accent); border-radius: 10px;
|
||||
background: var(--accent-soft); color: var(--text);
|
||||
}
|
||||
.bk-agent-time { font-variant-numeric: tabular-nums; font-weight: 700; color: var(--accent); }
|
||||
.bk-global-actions { margin-left: auto; display: flex; gap: 0.4rem; }
|
||||
|
||||
/* Action bar for the selected start point */
|
||||
.bk-step-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.7rem;
|
||||
border-top: 1px dashed var(--border-strong);
|
||||
}
|
||||
.bk-step-actions-label { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); }
|
||||
|
||||
.bk-act {
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.bk-act:hover { border-color: var(--accent); }
|
||||
.bk-act.play { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
|
||||
.bk-act.play:hover { background: var(--accent-hover); }
|
||||
.bk-act.danger { color: var(--danger); border-color: var(--danger); background: transparent; }
|
||||
.bk-act.danger.armed { background: var(--danger); color: #fff; }
|
||||
.bk-act.ghost { color: var(--text-muted); }
|
||||
|
||||
.bk-empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.bk-grid {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem 2rem 4rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.bk-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-top: 3px solid var(--accent-border);
|
||||
border-radius: 10px;
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
/* Non-relevant blocks (no relevant subblock) dimmed */
|
||||
.bk-card.bk-irrelevant { opacity: 0.5; }
|
||||
|
||||
.bk-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.bk-num {
|
||||
flex: 0 0 auto;
|
||||
min-width: 26px;
|
||||
height: 26px;
|
||||
padding: 0 6px;
|
||||
border-radius: 7px;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.bk-desc {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
|
||||
.bk-levels { display: flex; flex-direction: column; gap: 0.55rem; }
|
||||
.bk-level {
|
||||
border-left: 3px solid var(--st);
|
||||
padding-left: 0.6rem;
|
||||
}
|
||||
.bk-level.st-beginner { --st: var(--level-beginner); }
|
||||
.bk-level.st-advanced { --st: var(--level-advanced); }
|
||||
.bk-level.st-expert { --st: var(--level-expert); }
|
||||
|
||||
.bk-level-label {
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--st);
|
||||
}
|
||||
.bk-level ul {
|
||||
list-style: disc;
|
||||
margin: 0.25rem 0 0;
|
||||
padding-left: 1.15rem;
|
||||
}
|
||||
.bk-level li {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
line-height: 1.35;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.bk-level li::marker { color: var(--text-faint); }
|
||||
.bk-level li.rand { color: var(--text-faint); }
|
||||
.rand-tag {
|
||||
margin-left: 0.4rem;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text-faint);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 4px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.bk-no-subs { color: var(--text-faint); font-size: 0.8rem; font-style: italic; }
|
||||
</style>
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { fetchElements } from '../api.js'
|
||||
import { renderMarkdown } from '../markdown.js'
|
||||
import { renderMarkdown, plainText } from '../markdown.js'
|
||||
|
||||
const props = defineProps({
|
||||
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'])
|
||||
@@ -18,13 +18,10 @@ async function load() {
|
||||
try {
|
||||
elements.value = await fetchElements(props.topic)
|
||||
} 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>
|
||||
|
||||
<template>
|
||||
@@ -33,10 +30,10 @@ function plain(text) {
|
||||
<div class="overview-content">
|
||||
<header class="overview-head">
|
||||
<h1>{{ topic }}</h1>
|
||||
<span class="overview-format">Elemente</span>
|
||||
<span class="overview-format">Elements</span>
|
||||
</header>
|
||||
<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>
|
||||
<div class="element-grid">
|
||||
<article
|
||||
@@ -45,11 +42,11 @@ function plain(text) {
|
||||
class="element-card"
|
||||
@click="emit('open', el)"
|
||||
>
|
||||
<h3>{{ plain(el.title) }}</h3>
|
||||
<h3>{{ plainText(el.title) }}</h3>
|
||||
<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-if="el.hints.length" class="el-hints-block">
|
||||
<h4>Hinweise</h4>
|
||||
<h4>Hints</h4>
|
||||
<ul>
|
||||
<li v-for="(h, i) in el.hints" :key="i" class="markdown" v-html="renderMarkdown(h)"></li>
|
||||
</ul>
|
||||
@@ -161,7 +158,7 @@ function plain(text) {
|
||||
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 {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.55;
|
||||
|
||||
106
frontend/src/components/FlashcardWidget.vue
Normal file
106
frontend/src/components/FlashcardWidget.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { renderMarkdownInline } from '../markdown.js'
|
||||
|
||||
const props = defineProps({ cards: { type: Array, default: () => [] } })
|
||||
|
||||
const open = ref(false)
|
||||
const order = ref([])
|
||||
const pos = ref(0)
|
||||
const flipped = ref(false)
|
||||
|
||||
function reset() {
|
||||
order.value = props.cards.map((_, i) => i)
|
||||
pos.value = 0
|
||||
flipped.value = false
|
||||
}
|
||||
watch(() => props.cards, reset, { immediate: true })
|
||||
|
||||
const current = computed(() => props.cards[order.value[pos.value]] || null)
|
||||
const counter = computed(() => `${Math.min(pos.value + 1, order.value.length)} / ${order.value.length}`)
|
||||
|
||||
function next(known) {
|
||||
if (known) {
|
||||
pos.value++
|
||||
} else {
|
||||
// "Again" → push the card to the end of the round (light spacing).
|
||||
const [k] = order.value.splice(pos.value, 1)
|
||||
order.value.push(k)
|
||||
}
|
||||
if (pos.value >= order.value.length) pos.value = 0
|
||||
flipped.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="cards.length" class="flashcards">
|
||||
<button class="art-head" @click="open = !open">
|
||||
<span class="art-icon">🃏</span> Flashcards
|
||||
<span class="art-count">{{ cards.length }}</span>
|
||||
<span class="art-toggle">{{ open ? '▾' : '▸' }}</span>
|
||||
</button>
|
||||
<div v-if="open && current" class="fc-body">
|
||||
<div class="fc-card" :class="{ flipped }" @click="flipped = !flipped">
|
||||
<div class="fc-zaehler">{{ counter }}</div>
|
||||
<div v-if="!flipped" class="fc-seite">
|
||||
<span class="fc-label">Question</span>
|
||||
<div class="fc-text" v-html="renderMarkdownInline(current.question)"></div>
|
||||
<span class="fc-hint">Click to flip</span>
|
||||
</div>
|
||||
<div v-else class="fc-seite">
|
||||
<span class="fc-label">Answer</span>
|
||||
<div class="fc-text" v-html="renderMarkdownInline(current.answer)"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="flipped" class="fc-aktionen">
|
||||
<button class="fc-btn nochmal" @click="next(false)">Again</button>
|
||||
<button class="fc-btn gewusst" @click="next(true)">Knew it</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.flashcards { margin-top: 0.75rem; }
|
||||
.art-head {
|
||||
display: flex; align-items: center; gap: 8px; width: 100%;
|
||||
background: none; border: none; cursor: pointer; padding: 0.35rem 0;
|
||||
font-size: 0.82rem; font-weight: 600; color: var(--text-muted);
|
||||
}
|
||||
.art-icon { font-size: 0.95rem; }
|
||||
.art-count {
|
||||
background: var(--panel-soft); border: 1px solid var(--border);
|
||||
border-radius: 999px; padding: 0 0.45rem; font-size: 0.72rem;
|
||||
}
|
||||
.art-toggle { margin-left: auto; color: var(--text-faint); }
|
||||
|
||||
.fc-body { margin-top: 0.5rem; }
|
||||
.fc-card {
|
||||
position: relative; min-height: 120px; cursor: pointer;
|
||||
border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 1rem 1.1rem; background: var(--panel-soft);
|
||||
display: flex; align-items: center; justify-content: center; text-align: center;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.fc-card.flipped { border-color: var(--accent); }
|
||||
.fc-zaehler {
|
||||
position: absolute; top: 6px; right: 10px;
|
||||
font-size: 0.68rem; color: var(--text-faint);
|
||||
}
|
||||
.fc-seite { display: flex; flex-direction: column; gap: 0.4rem; align-items: center; }
|
||||
.fc-label {
|
||||
font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
color: var(--text-faint); font-weight: 700;
|
||||
}
|
||||
.fc-text { font-size: 0.98rem; line-height: 1.45; }
|
||||
.fc-hint { font-size: 0.7rem; color: var(--text-faint); margin-top: 0.2rem; }
|
||||
|
||||
.fc-aktionen { display: flex; gap: 8px; margin-top: 0.5rem; }
|
||||
.fc-btn {
|
||||
flex: 1; padding: 0.5rem; border-radius: 8px; cursor: pointer;
|
||||
font-size: 0.82rem; font-weight: 600; border: 1px solid var(--border-strong);
|
||||
background: var(--panel);
|
||||
}
|
||||
.fc-btn.gewusst { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
|
||||
.fc-btn.nochmal { color: var(--text-muted); }
|
||||
</style>
|
||||
120
frontend/src/components/GeneralExamPanel.vue
Normal file
120
frontend/src/components/GeneralExamPanel.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup>
|
||||
// Topic-wide mixed exam: picks a block (prioritized: not yet at max), then random
|
||||
// within it, and reuses the normal BlockPanel. Each block has its own exam slot
|
||||
// (pruefungCache), so the question pools stay separate. Booking runs per block via the
|
||||
// normal exam route. Total cap = Σ cap_final of all checkable blocks.
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { fetchGuides, fetchGuideContent, fetchBlockLearnState } from '../api.js'
|
||||
import BlockPanel from './BlockPanel.vue'
|
||||
|
||||
const props = defineProps({
|
||||
topic: { type: String, required: true },
|
||||
provider: { type: String, default: 'claude' },
|
||||
})
|
||||
const emit = defineEmits(['progressChanged', 'fokus-active'])
|
||||
|
||||
const sections = ref([]) // [{title, md, compact, num}] — checkable sections of the full version
|
||||
const learnstate = ref({}) // title -> {good_answers, streak, cap, cap_aktuell, freie_level}
|
||||
const active = ref(null) // current block title
|
||||
const loadError = ref(null)
|
||||
|
||||
const checkable = computed(() => sections.value.filter((s) => (learnstate.value[s.title]?.cap || 0) > 0))
|
||||
|
||||
// Total progress: Σ min(score, cap_final) / Σ cap_final over all checkable blocks.
|
||||
const total = computed(() => {
|
||||
let score = 0, cap = 0
|
||||
for (const s of checkable.value) {
|
||||
const l = learnstate.value[s.title] || {}
|
||||
score += Math.min(l.good_answers || 0, l.cap || 0)
|
||||
cap += l.cap || 0
|
||||
}
|
||||
return { score, cap }
|
||||
})
|
||||
|
||||
const activeSection = computed(() => sections.value.find((s) => s.title === active.value) || null)
|
||||
const activeStatus = computed(() => learnstate.value[active.value] || null)
|
||||
|
||||
// Priority: blocks not yet at cap_final; among those random.
|
||||
function pickBlock() {
|
||||
const unfinished = checkable.value.filter((s) => {
|
||||
const l = learnstate.value[s.title] || {}
|
||||
return (l.good_answers || 0) < (l.cap || 0)
|
||||
})
|
||||
const pool = unfinished.length ? unfinished : checkable.value
|
||||
active.value = pool.length ? pool[Math.floor(Math.random() * pool.length)].title : null
|
||||
}
|
||||
|
||||
async function loadLearnState() {
|
||||
try {
|
||||
learnstate.value = (await fetchBlockLearnState(props.topic)).blocks || {}
|
||||
} catch { /* offline → keep old state */ }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
emit('fokus-active', true)
|
||||
try {
|
||||
const guides = await fetchGuides()
|
||||
const g = guides
|
||||
.filter((x) => x.topic === props.topic && x.format === 'Guide' && x.status === 'done')
|
||||
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1))[0]
|
||||
if (!g) { loadError.value = 'No finished guide — create a guide first.'; return }
|
||||
const content = await fetchGuideContent(g.id, 4) // full version (all subs)
|
||||
sections.value = (content.chapters || [])
|
||||
.flatMap((ch) => ch.sections || [])
|
||||
.filter((s) => s.checkable !== false)
|
||||
.map((s) => ({ title: s.title, md: s.md, compact: s.compact, num: s.num }))
|
||||
await loadLearnState()
|
||||
pickBlock()
|
||||
} catch {
|
||||
loadError.value = 'Could not load the exam.'
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => emit('fokus-active', false))
|
||||
|
||||
async function onStatus() {
|
||||
await loadLearnState() // cap_aktuell/freie_level may have grown after an answer
|
||||
emit('progressChanged')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ge-panel">
|
||||
<div class="ge-head">
|
||||
<h2>General Exam</h2>
|
||||
<span v-if="!loadError" class="ge-score">{{ total.score }} / {{ total.cap }}</span>
|
||||
<button v-if="active" class="ge-next" title="Check another block" @click="pickBlock">⟳ Another block</button>
|
||||
</div>
|
||||
|
||||
<p v-if="loadError" class="ge-msg">{{ loadError }}</p>
|
||||
<p v-else-if="!checkable.length" class="ge-msg">No checkable blocks yet.</p>
|
||||
<p v-else-if="!active" class="ge-msg">Everything maxed out — nothing left to check. 🎉</p>
|
||||
|
||||
<div v-else class="ge-body">
|
||||
<div class="ge-block">Block: <strong>{{ active }}</strong></div>
|
||||
<BlockPanel
|
||||
:key="active"
|
||||
mode="full"
|
||||
:topic="topic"
|
||||
:block="active"
|
||||
:section="activeSection?.md || ''"
|
||||
:section-compact="activeSection?.compact || ''"
|
||||
:provider="provider"
|
||||
:status="activeStatus"
|
||||
:cap="activeStatus?.cap || 0"
|
||||
@statusChanged="onStatus"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ge-panel { padding: 16px 20px; max-width: 900px; margin: 0 auto; }
|
||||
.ge-head { display: flex; align-items: center; gap: 14px; margin-bottom: 12px; }
|
||||
.ge-head h2 { margin: 0; font-size: 1.1rem; }
|
||||
.ge-score { font-variant-numeric: tabular-nums; color: var(--text-muted); font-weight: 600; }
|
||||
.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-msg { color: var(--text-muted); }
|
||||
.ge-block { margin-bottom: 8px; color: var(--text-muted); font-size: 0.9rem; }
|
||||
</style>
|
||||
@@ -1,104 +1,193 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { fetchGuideContent, chatGuide, fetchBausteinLernstand } from '../api.js'
|
||||
import { computed, reactive, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { fetchGuideContent, chatGuide, fetchBlockLearnState, fetchArtefakte } from '../api.js'
|
||||
import { renderMarkdown } from '../markdown.js'
|
||||
import { stufeFuer, schwelle } from '../levels.js'
|
||||
import { useChat } from '../composables/useChat.js'
|
||||
import BausteinPanel from './BausteinPanel.vue'
|
||||
import BausteinFokus from './BausteinFokus.vue'
|
||||
import BlockPanel from './BlockPanel.vue'
|
||||
import BlockFocus from './BlockFocus.vue'
|
||||
import FlashcardWidget from './FlashcardWidget.vue'
|
||||
import WorkedExampleBlock from './WorkedExampleBlock.vue'
|
||||
|
||||
// Title normalization like backend _norm_title (casefold ≈ toLowerCase + ß→ss) — for
|
||||
// attaching the artifacts (keyed by block_norm) to the section titles.
|
||||
function normTitle(s) {
|
||||
return (s || '').normalize('NFKC')
|
||||
.replace(/[`'"<>„“”‚’«»*_]/g, '').replace(/[–—‐]/g, '-')
|
||||
.replace(/\s+/g, ' ').trim().replace(/^[.:;]+|[.:;]+$/g, '').trim()
|
||||
.toLowerCase().replace(/ß/g, 'ss')
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
previewGuide: { type: Object, default: null },
|
||||
dark: { type: Boolean, default: false },
|
||||
provider: { type: String, default: 'claude' },
|
||||
elementsOpen: { type: Boolean, default: false }, // Element-Sidebar offen → Chat nach links
|
||||
doneByFormat: { type: Object, default: () => ({}) }, // Format → fertiger Guide (Themen-bezogen)
|
||||
elementsOpen: { type: Boolean, default: false }, // element sidebar open → chat to the left
|
||||
doneByFormat: { type: Object, default: () => ({}) }, // format → finished guide (topic-related)
|
||||
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
|
||||
})
|
||||
|
||||
const emit = defineEmits(['progressChanged', 'setAnsicht'])
|
||||
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']
|
||||
|
||||
// --- Inhalt laden ---
|
||||
// --- Load content ---
|
||||
const content = ref(null)
|
||||
const loadError = 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 artifacts = ref({}) // block_norm → {flashcard[], example[], diagramm}
|
||||
|
||||
function artifactsFor(title) {
|
||||
return artifacts.value[normTitle(title)] || null
|
||||
}
|
||||
|
||||
// --- Lazy render + markdown cache: parse only visible sections, each only once.
|
||||
// Fixes the freeze on open (160× marked/highlight.js) and re-parse on every update. ---
|
||||
const mdCache = new Map() // `${mode}:${num}` → html
|
||||
const visible = reactive({}) // num → true (stays true once ever visible)
|
||||
let mdObserver = null
|
||||
|
||||
function htmlFor(s) {
|
||||
const key = `${props.ansichtModus}:${s.num}`
|
||||
let h = mdCache.get(key)
|
||||
if (h === undefined) {
|
||||
h = renderMarkdown(props.ansichtModus === 'compact' ? (s.compact || s.md) : s.md)
|
||||
mdCache.set(key, h)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// Sections render only when (almost) in the viewport — observer on the scroll container.
|
||||
function setupLazy() {
|
||||
mdObserver?.disconnect()
|
||||
if (!scrollEl.value) return
|
||||
mdObserver = new IntersectionObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
if (!e.isIntersecting) continue
|
||||
visible[Number(e.target.dataset.num)] = true
|
||||
mdObserver.unobserve(e.target)
|
||||
}
|
||||
}, { root: scrollEl.value, rootMargin: '800px 0px' })
|
||||
for (const el of scrollEl.value.querySelectorAll('.section-card')) mdObserver.observe(el)
|
||||
}
|
||||
|
||||
watch(content, () => nextTick(setupLazy))
|
||||
onUnmounted(() => mdObserver?.disconnect())
|
||||
|
||||
watch(() => props.previewGuide?.id, loadContent, { immediate: true })
|
||||
// Level view (E/M/S/F) changed → reload guide content with the matching depth filter.
|
||||
watch(() => props.stufeAnsicht, loadContent)
|
||||
|
||||
async function loadContent() {
|
||||
content.value = null
|
||||
loadError.value = null
|
||||
lernstand.value = {}
|
||||
learnstate.value = {}
|
||||
mdCache.clear()
|
||||
for (const k in visible) delete visible[k]
|
||||
const g = props.previewGuide
|
||||
if (!g || g.status !== 'done') return
|
||||
try {
|
||||
content.value = await fetchGuideContent(g.id)
|
||||
content.value = await fetchGuideContent(g.id, props.stufeAnsicht)
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden des Guides:', e)
|
||||
loadError.value = 'Inhalt nicht verfügbar — die Datei fehlt. Guide neu generieren (▶).'
|
||||
console.error('Error loading guide:', e)
|
||||
loadError.value = 'Content unavailable — the file is missing. Regenerate the guide (▶).'
|
||||
return
|
||||
}
|
||||
try {
|
||||
lernstand.value = (await fetchBausteinLernstand(g.topic)).bausteine || {}
|
||||
} catch { /* offline → leer */ }
|
||||
learnstate.value = (await fetchBlockLearnState(g.topic)).blocks || {}
|
||||
} catch { /* offline → empty */ }
|
||||
try {
|
||||
artifacts.value = (await fetchArtefakte(g.topic)).artefakte || {}
|
||||
} catch { artifacts.value = {} }
|
||||
// On open, scroll to the first not-yet-mastered checkable block.
|
||||
await nextTick()
|
||||
const target = blocks.value.find((s) => isCheckable(s) && levelOf(s.title)?.key !== 'master')
|
||||
if (target) scrollEl.value?.querySelector(`[data-num="${target.num}"]`)?.scrollIntoView({ block: 'start' })
|
||||
}
|
||||
|
||||
// --- Baustein-Lernen: Prüfungs-Stand pro Baustein-Titel (lernstand oben deklariert) ---
|
||||
function onBausteinStatus(baustein, status) {
|
||||
const warAbsolviert = lernstand.value[baustein]?.absolviert
|
||||
lernstand.value = { ...lernstand.value, [baustein]: status }
|
||||
if (status.absolviert && !warAbsolviert) emit('progressChanged') // Locks/Stats neu laden
|
||||
// --- Block learning: exam state per block title (learnstate declared above) ---
|
||||
function levelOf(title) {
|
||||
const l = learnstate.value[title]
|
||||
return l ? stufeFuer(l.good_answers || 0, l.cap || 10) : null
|
||||
}
|
||||
function onBlockStatus(block, status) {
|
||||
const prevKey = levelOf(block)?.key || null
|
||||
learnstate.value = { ...learnstate.value, [block]: status }
|
||||
const newKey = stufeFuer(status.good_answers || 0, status.cap || 10)?.key || null
|
||||
if (newKey !== prevKey) emit('progressChanged') // level change → reload locks/stats
|
||||
}
|
||||
|
||||
// --- Vollbild-Fokus: ein Baustein groß, Guide links + Prüfung rechts ---
|
||||
const fokusIndex = ref(null) // Index in der flachen Baustein-Liste; null = zu
|
||||
const fokusTab = ref('pruefung')
|
||||
const bausteine = computed(() =>
|
||||
// Replace the section in content after check/fix/rewrite + clear the markdown cache.
|
||||
function onSectionUpdated({ title, compact, md }) {
|
||||
if (!content.value) return
|
||||
for (const ch of content.value.chapters) {
|
||||
for (const s of ch.sections) {
|
||||
if (s.title !== title) continue
|
||||
s.md = md
|
||||
s.compact = compact
|
||||
for (const k of [...mdCache.keys()]) if (k.endsWith(`:${s.num}`)) mdCache.delete(k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Fullscreen focus: one block large, guide left + exam right ---
|
||||
const focusIndex = ref(null) // index in the flat block list; null = closed
|
||||
const focusTab = ref('exam')
|
||||
const blocks = computed(() =>
|
||||
!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]))
|
||||
|
||||
function openFokus(baustein, tab) {
|
||||
if (!istPruefbar(baustein)) return // reine Lese-Sections (Rest/Rand) öffnen keinen Prüfungs-Fokus
|
||||
const i = bausteine.value.findIndex((s) => s.title === baustein.title)
|
||||
// Report focus state to App (for the sidebar above the overlay); a topic change closes the focus.
|
||||
watch(focusIndex, (v) => emit('fokusActive', v !== null))
|
||||
watch(() => props.previewGuide?.id, () => { focusIndex.value = null })
|
||||
|
||||
function openFocus(block, tab) {
|
||||
if (!isCheckable(block)) return // read-only sections (Rest/edge) don't open an exam focus
|
||||
const i = blocks.value.findIndex((s) => s.title === block.title)
|
||||
if (i === -1) return
|
||||
fokusIndex.value = i
|
||||
fokusTab.value = tab || 'pruefung'
|
||||
focusIndex.value = i
|
||||
focusTab.value = tab || 'exam'
|
||||
}
|
||||
// Nur prüfbare Sections im Fokus anspringen (FullGuide hat dazwischen reine Lese-Sections).
|
||||
function fokusPrev() {
|
||||
for (let i = fokusIndex.value - 1; i >= 0; i--) if (istPruefbar(bausteine.value[i])) { fokusIndex.value = i; return }
|
||||
// Only jump to checkable sections in focus (FullGuide has read-only sections in between).
|
||||
function focusPrev() {
|
||||
for (let i = focusIndex.value - 1; i >= 0; i--) if (isCheckable(blocks.value[i])) { focusIndex.value = i; return }
|
||||
}
|
||||
function fokusNext() {
|
||||
for (let i = fokusIndex.value + 1; i < bausteine.value.length; i++) if (istPruefbar(bausteine.value[i])) { fokusIndex.value = i; return }
|
||||
function focusNext() {
|
||||
for (let i = focusIndex.value + 1; i < blocks.value.length; i++) if (isCheckable(blocks.value[i])) { focusIndex.value = i; return }
|
||||
}
|
||||
|
||||
// Erfahrungsleiste: Lernstand über alle Bausteine des Guides (sticky, absolviert ⊇ verstanden ⊇ gemeistert).
|
||||
const fortschritt = computed(() => {
|
||||
let a = 0, v = 0, g = 0
|
||||
for (const s of bausteine.value) {
|
||||
const l = lernstand.value[s.title] || {}
|
||||
if (l.absolviert) a++
|
||||
if (l.verstanden) v++
|
||||
if (l.gemeistert) g++
|
||||
// Experience bar: cumulative level state over all blocks (gold ⊆ purple ⊆ blue ⊆ green).
|
||||
const progress = computed(() => {
|
||||
const z = { total: blocks.value.length, beginner: 0, advanced: 0, expert: 0, master: 0 }
|
||||
for (const s of blocks.value) {
|
||||
const l = learnstate.value[s.title]
|
||||
if (!l) continue
|
||||
const sc = l.good_answers || 0, cp = l.cap || 10
|
||||
if (sc >= schwelle(0.2, cp)) z.beginner++
|
||||
if (sc >= schwelle(0.4, cp)) z.advanced++
|
||||
if (sc >= schwelle(0.6, cp)) z.expert++
|
||||
if (sc >= schwelle(1.0, cp)) z.master++
|
||||
}
|
||||
return { total: bausteine.value.length, absolviert: a, verstanden: v, gemeistert: g }
|
||||
return z
|
||||
})
|
||||
|
||||
// Prüfung läuft voll 0–30, sobald Guide ODER FullGuide fertig ist (gleicher Baustein-Score).
|
||||
const cap = computed(() => ((props.doneByFormat?.Guide || props.doneByFormat?.FullGuide) ? 30 : 6))
|
||||
|
||||
// Section prüfbar? Rest nie. Sonst prüfbar, außer das Feld ist explizit false (FullGuide-Rand).
|
||||
// Fehlt das Feld (alte Guides ohne `pruefbar`) → prüfbar, damit Bestands-Guides weiter funktionieren.
|
||||
function istPruefbar(s) {
|
||||
return props.previewGuide?.format !== 'Rest' && s.pruefbar !== false
|
||||
// cap per block = 4×relevant subblocks (provided by the backend in learnstate[title].cap).
|
||||
function capOf(title) {
|
||||
return learnstate.value[title]?.cap || 10
|
||||
}
|
||||
|
||||
// --- Chat (Mechanik in useChat; Kontext-Extraktion bleibt hier) ---
|
||||
// Section checkable? Rest never. Otherwise checkable, unless the field is explicitly false (FullGuide edge).
|
||||
// Missing field (old guides without `checkable`) → checkable, so existing guides keep working.
|
||||
function isCheckable(s) {
|
||||
return props.previewGuide?.format !== 'Rest' && s.checkable !== false
|
||||
}
|
||||
|
||||
// --- Chat (mechanics in useChat; context extraction stays here) ---
|
||||
const chat = useChat((msgs) => {
|
||||
const { section, outline } = extractContext()
|
||||
return chatGuide(props.previewGuide.id, {
|
||||
@@ -120,8 +209,8 @@ function closeChat() {
|
||||
chat.reset()
|
||||
}
|
||||
|
||||
// Mobil schließen sich Chat und Elemente-Sidebar gegenseitig aus —
|
||||
// nebeneinander ist kein Platz, die Sidebar würde den Chat überdecken.
|
||||
// On mobile, chat and element sidebar are mutually exclusive —
|
||||
// there is no room side by side, the sidebar would cover the chat.
|
||||
watch(() => props.elementsOpen, (open) => {
|
||||
if (open && chatOpen.value && window.matchMedia('(max-width: 768px)').matches) closeChat()
|
||||
})
|
||||
@@ -132,7 +221,7 @@ function onDocMouseDown(e) {
|
||||
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) {
|
||||
if (e.key === 'Escape' && chatOpen.value) {
|
||||
e.preventDefault()
|
||||
@@ -162,7 +251,7 @@ function extractContext() {
|
||||
.join('\n')
|
||||
.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 = ''
|
||||
const cards = Array.from(scrollEl.value?.querySelectorAll('.section-card') || [])
|
||||
let current = null
|
||||
@@ -184,7 +273,12 @@ function extractContext() {
|
||||
<header class="guide-head">
|
||||
<h1>{{ previewGuide.topic }}</h1>
|
||||
<span class="guide-format">{{ previewGuide.format }}</span>
|
||||
<span v-if="themaAbgeschlossen" class="thema-done" title="Alle Bausteine gemeistert (18/18)">✓ Thema abgeschlossen</span>
|
||||
<span v-if="themaAbgeschlossen" class="thema-done" title="All blocks mastered">✓ Topic completed</span>
|
||||
<span class="gh-spacer"></span>
|
||||
<div class="ansicht-toggle" title="Toggle verbosity">
|
||||
<button :class="{ active: ansichtModus === 'compact' }" @click="$emit('setAnsicht', 'compact')">Compact</button>
|
||||
<button :class="{ active: ansichtModus === 'erklärend' }" @click="$emit('setAnsicht', 'erklärend')">Explanatory</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section
|
||||
@@ -198,25 +292,30 @@ function extractContext() {
|
||||
<article
|
||||
v-for="s in ch.sections"
|
||||
:key="s.num"
|
||||
:class="['section-card', lernstand[s.title]?.gemeistert ? 'gemeistert' : (lernstand[s.title]?.verstanden ? 'verstanden' : (lernstand[s.title]?.absolviert ? 'absolviert' : ''))]"
|
||||
:data-num="s.num"
|
||||
class="section-card"
|
||||
: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 }}
|
||||
<template v-if="istPruefbar(s)">
|
||||
<span v-if="lernstand[s.title]?.gemeistert" class="baustein-done gemeistert" title="Gemeistert (25/25)">✓✓✓ Gemeistert</span>
|
||||
<span v-else-if="lernstand[s.title]?.verstanden" class="baustein-done verstanden" title="Vollständig verstanden (10/10)">✓✓ Verstanden</span>
|
||||
<span v-else-if="lernstand[s.title]?.absolviert" class="baustein-done" title="Prüfung bestanden">✓ Absolviert</span>
|
||||
<template v-if="isCheckable(s) && levelOf(s.title)">
|
||||
<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>
|
||||
</h3>
|
||||
<div class="section-body markdown" v-html="renderMarkdown(ansichtModus === 'kompakt' ? (s.kompakt || s.md) : s.md)"></div>
|
||||
<BausteinPanel
|
||||
v-if="istPruefbar(s)"
|
||||
<div v-if="visible[s.num]" class="section-body markdown" v-html="htmlFor(s)"></div>
|
||||
<div v-else class="section-body skeleton"></div>
|
||||
<template v-if="visible[s.num] && artifactsFor(s.title)">
|
||||
<WorkedExampleBlock :examples="artifactsFor(s.title).example || []" />
|
||||
<FlashcardWidget :cards="artifactsFor(s.title).flashcard || []" />
|
||||
</template>
|
||||
<BlockPanel
|
||||
v-if="isCheckable(s)"
|
||||
mode="trigger"
|
||||
:baustein="s.title"
|
||||
:status="lernstand[s.title]"
|
||||
:cap="cap"
|
||||
:block="s.title"
|
||||
:status="learnstate[s.title]"
|
||||
:cap="capOf(s.title)"
|
||||
:topic="previewGuide.topic"
|
||||
@open-fokus="(tab) => openFokus(s, tab)"
|
||||
@open-fokus="(tab) => openFocus(s, tab)"
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
@@ -225,60 +324,64 @@ function extractContext() {
|
||||
</div>
|
||||
|
||||
<div v-else-if="previewGuide" class="empty-preview">
|
||||
<p>{{ loadError || 'Lade Inhalt…' }}</p>
|
||||
<p>{{ loadError || 'Loading content…' }}</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<BausteinFokus
|
||||
v-if="fokusBaustein"
|
||||
:baustein="fokusBaustein"
|
||||
<BlockFocus
|
||||
v-if="focusBlock"
|
||||
:block="focusBlock"
|
||||
:artefakte="artifactsFor(focusBlock.title)"
|
||||
:topic="previewGuide.topic"
|
||||
:guide-id="previewGuide.id"
|
||||
:provider="provider"
|
||||
:fortschritt="fortschritt"
|
||||
:status="lernstand[fokusBaustein.title]"
|
||||
:cap="cap"
|
||||
:tab="fokusTab"
|
||||
:fortschritt="progress"
|
||||
:status="learnstate[focusBlock.title]"
|
||||
:cap="capOf(focusBlock.title)"
|
||||
:tab="focusTab"
|
||||
:ansicht="ansichtModus"
|
||||
:has-prev="fokusIndex > 0"
|
||||
:has-next="fokusIndex < bausteine.length - 1"
|
||||
@prev="fokusPrev"
|
||||
@next="fokusNext"
|
||||
@close="fokusIndex = null"
|
||||
:has-prev="focusIndex > 0"
|
||||
:has-next="focusIndex < blocks.length - 1"
|
||||
@prev="focusPrev"
|
||||
@next="focusNext"
|
||||
@close="focusIndex = null"
|
||||
@set-ansicht="$emit('setAnsicht', $event)"
|
||||
@status-changed="(st) => onBausteinStatus(st.baustein, st)"
|
||||
@status-changed="(st) => onBlockStatus(st.block, st)"
|
||||
@open-sidebar="$emit('openSidebar')"
|
||||
@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 }">
|
||||
<header class="chat-header">
|
||||
<span>Fragen zum Guide</span>
|
||||
<button class="chat-close" title="Chat beenden" @click="closeChat">×</button>
|
||||
<span>Questions about the guide</span>
|
||||
<button class="chat-close" title="Close chat" @click="closeChat">×</button>
|
||||
</header>
|
||||
<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">
|
||||
<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>
|
||||
</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 class="chat-input">
|
||||
<textarea
|
||||
ref="inputEl"
|
||||
v-model="input"
|
||||
rows="3"
|
||||
placeholder="Frage stellen…"
|
||||
placeholder="Ask a question…"
|
||||
@input="autoGrow"
|
||||
@keydown.enter.exact.prevent="send"
|
||||
></textarea>
|
||||
<button
|
||||
:disabled="!input.trim() && !loading"
|
||||
:class="{ cancel: loading }"
|
||||
:title="loading ? 'Abbrechen' : 'Senden'"
|
||||
:title="loading ? 'Cancel' : 'Send'"
|
||||
@click="send"
|
||||
>{{ loading ? '✕' : '➤' }}</button>
|
||||
</div>
|
||||
@@ -289,8 +392,8 @@ function extractContext() {
|
||||
<style scoped>
|
||||
.detail {
|
||||
flex: 1;
|
||||
/* Flex-Item darf schmaler werden als seine Code-Blöcke — sonst sprengt
|
||||
deren Mindestbreite auf Mobile das Layout */
|
||||
/* Flex item may shrink below its code blocks — otherwise their
|
||||
min width breaks the layout on mobile */
|
||||
min-width: 0;
|
||||
height: 100dvh;
|
||||
position: relative;
|
||||
@@ -299,7 +402,7 @@ function extractContext() {
|
||||
.guide-scroll {
|
||||
height: 100%;
|
||||
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;
|
||||
background: var(--bg-preview);
|
||||
}
|
||||
@@ -308,7 +411,7 @@ function extractContext() {
|
||||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -323,12 +426,41 @@ function extractContext() {
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
/* Sticky: the verbosity toggle stays reachable while scrolling. */
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--bg-preview);
|
||||
padding: 0.6rem 0 0.5rem;
|
||||
|
||||
h1 {
|
||||
font-size: 1.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
.gh-spacer { flex: 1; }
|
||||
|
||||
.ansicht-toggle {
|
||||
align-self: center;
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ansicht-toggle button {
|
||||
border: none;
|
||||
background: var(--panel);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding: 0.3rem 0.7rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ansicht-toggle button.active {
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.guide-format {
|
||||
color: var(--text-faint);
|
||||
font-size: 0.9rem;
|
||||
@@ -381,7 +513,7 @@ function extractContext() {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.baustein-done {
|
||||
.block-done {
|
||||
float: right;
|
||||
margin-left: 0.5rem;
|
||||
padding: 0.12rem 0.6rem;
|
||||
@@ -395,38 +527,38 @@ function extractContext() {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Absolvierte Bausteine: Karte kippt sichtbar auf Grün */
|
||||
.guide-content .section-card.absolviert {
|
||||
/* Completed blocks: card visibly flips to green */
|
||||
.guide-content .section-card.completed {
|
||||
border-color: var(--success-border);
|
||||
border-top: 3px solid var(--success);
|
||||
background: color-mix(in srgb, var(--success) 5%, var(--panel));
|
||||
}
|
||||
|
||||
/* Verstandene Bausteine (10/10): Lila */
|
||||
.baustein-done.verstanden {
|
||||
/* Understood blocks (10/10): purple */
|
||||
.block-done.understood {
|
||||
background: color-mix(in srgb, #8b5cf6 16%, var(--panel));
|
||||
border-color: #8b5cf6;
|
||||
color: #6d28d9;
|
||||
}
|
||||
.guide-content .section-card.verstanden {
|
||||
.guide-content .section-card.understood {
|
||||
border-color: #8b5cf6;
|
||||
border-top: 3px solid #8b5cf6;
|
||||
background: color-mix(in srgb, #8b5cf6 7%, var(--panel));
|
||||
}
|
||||
|
||||
/* Gemeisterte Bausteine (Meisterpfad 25/25): Gold */
|
||||
.baustein-done.gemeistert {
|
||||
/* Mastered blocks (master path 25/25): gold */
|
||||
.block-done.mastered {
|
||||
background: color-mix(in srgb, #d4af37 20%, var(--panel));
|
||||
border-color: #d4af37;
|
||||
color: #8a6d12;
|
||||
}
|
||||
.guide-content .section-card.gemeistert {
|
||||
.guide-content .section-card.mastered {
|
||||
border-color: #d4af37;
|
||||
border-top: 3px solid #d4af37;
|
||||
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 {
|
||||
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));
|
||||
@@ -440,9 +572,13 @@ function extractContext() {
|
||||
}
|
||||
}
|
||||
|
||||
/* Titel-Klick öffnet die Vollansicht. */
|
||||
.section-card h3.baustein-klick { cursor: pointer; width: fit-content; }
|
||||
.section-card h3.baustein-klick:hover { color: var(--accent); }
|
||||
/* Title click opens the full view. */
|
||||
.section-card h3.block-klick { cursor: pointer; width: fit-content; }
|
||||
.section-card h3.block-klick:hover { color: var(--accent); }
|
||||
|
||||
/* Placeholder for not-yet-rendered sections (lazy render). Stable height,
|
||||
so the IntersectionObserver detects the following cards in a staggered way. */
|
||||
.section-body.skeleton { min-height: 160px; }
|
||||
|
||||
.empty-preview {
|
||||
display: flex;
|
||||
@@ -452,16 +588,16 @@ function extractContext() {
|
||||
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) {
|
||||
white-space: pre;
|
||||
overflow-wrap: normal;
|
||||
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) {
|
||||
font-size: 0.74em;
|
||||
text-transform: uppercase;
|
||||
@@ -470,8 +606,8 @@ function extractContext() {
|
||||
margin: 0.9em 0 0.35em;
|
||||
}
|
||||
|
||||
/* Lesbarkeit: ~17px Fließtext, Zeilenhöhe 1.6, Textspalte max. ~70 Zeichen —
|
||||
Code-Blöcke dürfen die volle Kartenbreite nutzen */
|
||||
/* Readability: ~17px body text, line height 1.6, text column max ~70 chars —
|
||||
code blocks may use the full card width */
|
||||
.section-body {
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.6;
|
||||
@@ -504,7 +640,7 @@ function extractContext() {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* Element-Sidebar (320px) offen → Chat links daneben anzeigen */
|
||||
/* Element sidebar (320px) open → show chat to its left */
|
||||
.chat-fab.shifted {
|
||||
right: calc(1.5rem + 320px);
|
||||
}
|
||||
@@ -513,7 +649,7 @@ function extractContext() {
|
||||
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) {
|
||||
.chat-fab.shifted,
|
||||
.chat-panel.shifted {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { useConfirm } from '../composables/useConfirm.js'
|
||||
import { fetchQuelle } from '../api.js'
|
||||
import { fetchSource } from '../api.js'
|
||||
|
||||
const props = defineProps({
|
||||
topics: { type: Array, required: true },
|
||||
@@ -9,25 +9,26 @@ const props = defineProps({
|
||||
stats: { type: Object, default: null },
|
||||
fortschritt: { 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 },
|
||||
doneByFormat: { type: Object, default: () => ({}) },
|
||||
latestByFormat: { type: Object, default: () => ({}) },
|
||||
allGuides: { type: Array, default: () => [] },
|
||||
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: () => [] },
|
||||
pinned: { type: Boolean, default: true },
|
||||
dark: { type: Boolean, default: false },
|
||||
provider: { type: String, default: 'claude' },
|
||||
providers: { type: Array, default: () => [] },
|
||||
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
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select', 'create', 'createThema', 'updateQuelle', 'formatClick', 'bausteineClick', 'cancelBausteine', 'resetBausteine', 'deleteTopic', 'cancelGuide', 'deleteGuide', 'dismissError', 'dismissUiError', 'preview', 'openBausteineView', 'openElements', 'togglePin', 'sidebarLeave', 'toggleDark', 'setAnsicht', '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 isOpen = (id) => openPanel.value === id
|
||||
function togglePanel(id) {
|
||||
@@ -39,79 +40,67 @@ function providerAvailable(id) {
|
||||
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(() => {
|
||||
if (!props.stats) return []
|
||||
const f = props.stats.formate || {}
|
||||
const fmt = (k) => `${f[k]?.absolviert ?? 0}/${f[k]?.erstellt ?? 0}`
|
||||
const f = props.stats.formats || {}
|
||||
const fmt = (k) => `${f[k]?.completed ?? 0}/${f[k]?.erstellt ?? 0}`
|
||||
return [
|
||||
{ label: 'Themen', value: String(props.stats.themen ?? 0), title: 'Themen inkl. Projekte' },
|
||||
{ label: 'Guides', value: fmt('Guide'), title: 'absolviert/erstellt' },
|
||||
{ label: 'Topics', value: String(props.stats.topics ?? 0), title: 'Topics incl. projects' },
|
||||
{ label: 'Guides', value: fmt('Guide'), title: 'completed/created' },
|
||||
]
|
||||
})
|
||||
|
||||
const formats = [
|
||||
{ key: 'Guide', label: 'Guide' },
|
||||
{ key: 'FullGuide', label: 'FullGuide' },
|
||||
{ key: 'Rest', label: 'Rest' },
|
||||
]
|
||||
|
||||
const bausteineState = computed(() => {
|
||||
if (props.bausteine.generating) return 'generating'
|
||||
return props.bausteine.ready ? 'done' : 'none'
|
||||
// Level views of the SINGLE guide (filtered by subblock depth).
|
||||
const LEVEL_VIEWS = [
|
||||
{ k: 1, label: 'A', title: 'Beginner' },
|
||||
{ k: 2, label: 'F', title: 'Beginner + Advanced' },
|
||||
{ k: 3, label: 'E', title: 'up to Expert' },
|
||||
{ k: 4, label: 'V', title: 'Complete (incl. edge)' },
|
||||
]
|
||||
|
||||
const blocksState = computed(() => {
|
||||
if (props.blocks.generating) return 'generating'
|
||||
return props.blocks.ready ? 'done' : 'none'
|
||||
})
|
||||
|
||||
// Re-Run ab gewählter Phase (1-basiert). null = ab Anfang / Fortsetzen.
|
||||
const gewaehltePhase = ref(null)
|
||||
// Phasen anklickbar, sobald etwas (teil-)gebaut ist und gerade nicht generiert wird.
|
||||
const phasenWaehlbar = computed(() => (props.bausteine.ready || props.bausteine.partial) && !props.bausteine.generating)
|
||||
const gewaehltesLabel = computed(() => (props.bausteine.steps || [])[(gewaehltePhase.value || 0) - 1]?.label || '')
|
||||
// Re-run from a substep now happens in the blocks overview; the sidebar no longer has phase pills.
|
||||
|
||||
function phaseKlick(n) {
|
||||
if (!phasenWaehlbar.value) return
|
||||
gewaehltePhase.value = gewaehltePhase.value === n ? null : n
|
||||
}
|
||||
|
||||
// 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 bausteinLines = props.activeBausteine
|
||||
const blockLines = props.activeBausteine
|
||||
.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
|
||||
.filter((g) => (g.status === 'generating' || g.status === 'queued') && g.topic !== props.selectedTopic)
|
||||
.map((g) => `${g.topic} – ${g.format}: ${g.progress || 'Wartend…'}`)
|
||||
return [...bausteinLines, ...guideLines]
|
||||
.map((g) => `${g.topic} – ${g.format}: ${g.progress || 'Waiting…'}`)
|
||||
return [...blockLines, ...guideLines]
|
||||
})
|
||||
|
||||
const { pending: pendingConfirm, armOrRun } = useConfirm()
|
||||
|
||||
function confirmCancelBausteine() {
|
||||
armOrRun('bausteine', () => emit('cancelBausteine'))
|
||||
function confirmCancelBlocks() {
|
||||
armOrRun('blocks', () => emit('cancelBlocks'))
|
||||
}
|
||||
|
||||
function confirmResetBausteine() {
|
||||
armOrRun('bausteine', () => emit('resetBausteine'))
|
||||
function confirmResetBlocks() {
|
||||
armOrRun('blocks', () => emit('resetBausteine'))
|
||||
}
|
||||
|
||||
function handleBausteinePlay() {
|
||||
if (bausteineState.value === 'generating') return
|
||||
// Nur „Neu generieren" (ready) nutzt eine Phase; Fortsetzen/Erstbau resumen ohne Löschen.
|
||||
const abPhase = props.bausteine.ready ? (gewaehltePhase.value ?? 1) : null
|
||||
emit('bausteineClick', { instructions: '', abPhase })
|
||||
gewaehltePhase.value = null
|
||||
}
|
||||
|
||||
// Name-Klick = Primäraktion: fertige Bausteine → Übersicht, sonst Panel auf/zu.
|
||||
function onBausteineName() {
|
||||
if (props.bausteine.ready && !props.bausteine.generating) emit('openBausteineView')
|
||||
else togglePanel('bausteine')
|
||||
// Name click = open the block overview (generate/resume/remove all live there now).
|
||||
function onBlocksName() {
|
||||
emit('openBausteineView')
|
||||
}
|
||||
|
||||
function guideStatus(format) {
|
||||
// Laufende Generierung hat Vorrang — sonst maskiert ein älterer fertiger
|
||||
// Guide den Lauf und ▶ würde Duplikate starten.
|
||||
// Running generation takes precedence — otherwise an older finished
|
||||
// guide masks the run and ▶ would start duplicates.
|
||||
const latest = props.latestByFormat[format]
|
||||
if (latest && (latest.status === 'generating' || latest.status === 'queued')) return latest.status
|
||||
if (props.doneByFormat[format]) return 'done'
|
||||
@@ -119,74 +108,74 @@ function guideStatus(format) {
|
||||
return latest.status
|
||||
}
|
||||
|
||||
// Schritt-Kugeln der Guide-Pipeline
|
||||
const GUIDE_STEPS = ['Gliederung', 'Inhalte', 'Inhalts-Check', 'Schreiben', 'Lese-Prüfung']
|
||||
// Step dots of the guide pipeline
|
||||
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):
|
||||
// ≤ fertig = done. Läuft gerade → der nächste Schritt (fertig+1) ist aktiv.
|
||||
// Dots from the artifact-based "done" marker (like blocks, not the DB counter):
|
||||
// ≤ done = done. Running → the next step (done+1) is active.
|
||||
function guideSteps(format) {
|
||||
const labels = GUIDE_STEPS
|
||||
const fertig = props.guideStepsDone[format] ?? -1
|
||||
const done = props.guideStepsDone[format] ?? -1
|
||||
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) => ({
|
||||
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.
|
||||
const gewaehlterStep = reactive({})
|
||||
// Kugeln klickbar, sobald Artefakte existieren (Marker ≥ 0 oder fertig) und nicht generiert wird.
|
||||
function guideWaehlbar(format) {
|
||||
// Re-run from a guide step (1-based dot per format). null = full/resume.
|
||||
const selectedStep = reactive({})
|
||||
// Dots clickable once artifacts exist (marker ≥ 0 or done) and not generating.
|
||||
function guideSelectable(format) {
|
||||
const st = guideStatus(format)
|
||||
if (st === 'generating' || st === 'queued') return false
|
||||
return (props.guideStepsDone[format] ?? -1) >= 0 || st === 'done'
|
||||
}
|
||||
function guideStepKlick(format, n) {
|
||||
if (!guideWaehlbar(format)) return
|
||||
gewaehlterStep[format] = gewaehlterStep[format] === n ? null : n
|
||||
function guideStepClick(format, n) {
|
||||
if (!guideSelectable(format)) return
|
||||
selectedStep[format] = selectedStep[format] === n ? null : n
|
||||
}
|
||||
function gewaehltesStepLabel(format) {
|
||||
return GUIDE_STEPS[(gewaehlterStep[format] || 0) - 1] || ''
|
||||
function selectedStepLabel(format) {
|
||||
return GUIDE_STEPS[(selectedStep[format] || 0) - 1] || ''
|
||||
}
|
||||
|
||||
function errorMsg(format) {
|
||||
const latest = props.latestByFormat[format]
|
||||
if (latest?.status !== 'error' || props.dismissedErrors.has(latest.id)) return ''
|
||||
if (abgebrochen(format)) return '' // kein roter Fehler — das Pausiert-Badge zeigt den Zustand
|
||||
return latest.error_msg || 'Fehler bei der Generierung'
|
||||
if (aborted(format)) return '' // no red error — the Paused badge shows the state
|
||||
return latest.error_msg || 'Generation failed'
|
||||
}
|
||||
|
||||
// Abgebrochener Lauf = Teilfortschritt vorhanden: ▶ setzt fort, ✕ löscht den Fortschritt
|
||||
function abgebrochen(format) {
|
||||
// Aborted run = partial progress present: ▶ resumes, ✕ deletes the progress
|
||||
function aborted(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) {
|
||||
const guide = props.doneByFormat[format]
|
||||
if (guide) emit('preview', guide)
|
||||
else togglePanel('fmt-' + format)
|
||||
}
|
||||
|
||||
// Sperr-Gründe kommen vom Backend (GET /guides/locks) — die Regeln existieren
|
||||
// nur noch dort. Solange locks noch nicht geladen sind: Button frei, das
|
||||
// Backend weist ungültige Starts ohnehin ab (sichtbar über uiError).
|
||||
// Lock reasons come from the backend (GET /guides/locks) — the rules only
|
||||
// exist there now. While locks are not yet loaded: button enabled, the
|
||||
// backend rejects invalid starts anyway (visible via uiError).
|
||||
function playLock(format) {
|
||||
return props.locks?.[format] ?? null
|
||||
}
|
||||
|
||||
function handlePlay(format) {
|
||||
if (playLock(format)) return
|
||||
// Gewählte Kugel (1-basiert) → ab_step (0-basiert). Nur bei (teil-)gebautem Guide.
|
||||
const abStep = guideWaehlbar(format) && gewaehlterStep[format] ? gewaehlterStep[format] - 1 : null
|
||||
// Selected dot (1-based) → ab_step (0-based). Only for a (partially) built guide.
|
||||
const abStep = guideSelectable(format) && selectedStep[format] ? selectedStep[format] - 1 : null
|
||||
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) {
|
||||
const latest = props.latestByFormat[format]
|
||||
if (latest?.status === 'error') emit('dismissError', latest.id)
|
||||
@@ -195,15 +184,15 @@ function dismissError(format) {
|
||||
function handleDelete(format) {
|
||||
if (!props.latestByFormat[format]) return
|
||||
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(
|
||||
(g) => g.topic === props.selectedTopic && g.format === format
|
||||
&& (g.status === 'generating' || g.status === 'queued'),
|
||||
)
|
||||
if (running.length) {
|
||||
for (const g of running) emit('cancelGuide', g.id)
|
||||
} else if (abgebrochen(format)) {
|
||||
// Pausierter Lauf: Teilfortschritt samt Schritt-Dateien löschen (Reset)
|
||||
} else if (aborted(format)) {
|
||||
// Paused run: delete partial progress incl. step files (reset)
|
||||
emit('deleteGuide', props.latestByFormat[format].id, true)
|
||||
} else {
|
||||
emit('deleteGuide', props.latestByFormat[format].id)
|
||||
@@ -211,7 +200,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 form = ref({ name: '', instructions: '', sourceType: 'thema', sourceOrt: '' })
|
||||
const canCreate = computed(() => {
|
||||
@@ -220,12 +209,12 @@ const canCreate = computed(() => {
|
||||
return true
|
||||
})
|
||||
|
||||
function toggleErstellen() {
|
||||
function toggleCreate() {
|
||||
if (!dlg.value) form.value = { name: '', instructions: '', sourceType: 'thema', sourceOrt: '' }
|
||||
dlg.value = !dlg.value
|
||||
}
|
||||
|
||||
function createThema() {
|
||||
function createTopic() {
|
||||
if (!canCreate.value) return
|
||||
emit('createThema', {
|
||||
topic: form.value.name.trim(),
|
||||
@@ -240,7 +229,7 @@ function confirmDeleteTopic(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 editForm = ref({ type: 'thema', ort: '', spec: '' })
|
||||
const editLoading = ref(false)
|
||||
@@ -256,8 +245,8 @@ async function toggleTopicPanel(t) {
|
||||
editTopic.value = t
|
||||
editLoading.value = true
|
||||
try {
|
||||
const q = await fetchQuelle(t)
|
||||
editForm.value = { type: q.type || 'thema', ort: q.ort || '', spec: q.spec || '' }
|
||||
const q = await fetchSource(t)
|
||||
editForm.value = { type: q.type || 'thema', ort: q.location || '', spec: q.spec || '' }
|
||||
} catch {
|
||||
editForm.value = { type: 'thema', ort: '', spec: '' }
|
||||
} finally {
|
||||
@@ -270,9 +259,9 @@ function setEditType(t) {
|
||||
editForm.value.ort = ''
|
||||
}
|
||||
|
||||
function saveQuelle() {
|
||||
function saveSource() {
|
||||
if (!canSave.value || !editTopic.value) return
|
||||
emit('updateQuelle', {
|
||||
emit('updateSource', {
|
||||
topic: editTopic.value,
|
||||
type: editForm.value.type,
|
||||
ort: editForm.value.ort.trim(),
|
||||
@@ -293,22 +282,22 @@ function saveQuelle() {
|
||||
<div class="new-topic">
|
||||
<button
|
||||
class="pin-btn"
|
||||
:title="pinned ? 'Sidebar ausblenden' : 'Sidebar fixieren'"
|
||||
:title="pinned ? 'Hide sidebar' : 'Pin sidebar'"
|
||||
@click="emit('togglePin')"
|
||||
>{{ pinned ? '⇤' : '⇥' }}</button>
|
||||
<button
|
||||
class="theme-btn"
|
||||
:title="dark ? 'Hellmodus' : 'Dunkelmodus'"
|
||||
:title="dark ? 'Light mode' : 'Dark mode'"
|
||||
@click="emit('toggleDark')"
|
||||
>{{ dark ? '☀' : '🌙' }}</button>
|
||||
<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">
|
||||
<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" />
|
||||
</svg>
|
||||
</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">
|
||||
<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" />
|
||||
@@ -317,34 +306,39 @@ function saveQuelle() {
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button class="new-topic-toggle" :class="{ active: dlg }" title="Thema erstellen" @click="toggleErstellen">+</button>
|
||||
<div v-if="selectedTopic" class="level-toggle" title="Depth of guide view">
|
||||
<button v-for="s in LEVEL_VIEWS" :key="s.k"
|
||||
:class="{ active: stufeAnsicht === s.k }" :title="s.title"
|
||||
@click="emit('setStufe', s.k)">{{ s.label }}</button>
|
||||
</div>
|
||||
<button class="new-topic-toggle" :class="{ active: dlg }" title="Create topic" @click="toggleCreate">+</button>
|
||||
</div>
|
||||
|
||||
<!-- Erstellen: inline aufklappbar (kein Modal) -->
|
||||
<!-- Create: inline expandable (no modal) -->
|
||||
<div v-if="dlg" class="thema-panel">
|
||||
<input class="dlg-input" v-model="form.name" placeholder="Thema-Name…" @keyup.enter="createThema" autofocus />
|
||||
<textarea class="dlg-textarea" v-model="form.instructions" rows="2" placeholder="Weitere Infos (optional)…"></textarea>
|
||||
<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="More info (optional)…"></textarea>
|
||||
<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 === '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>
|
||||
</div>
|
||||
<input
|
||||
v-if="form.sourceType === 'link'"
|
||||
class="dlg-input" v-model="form.sourceOrt"
|
||||
placeholder="https://…" @keyup.enter="createThema"
|
||||
placeholder="https://…" @keyup.enter="createTopic"
|
||||
/>
|
||||
<select
|
||||
v-else-if="form.sourceType === 'projekt' || form.sourceType === 'uni'"
|
||||
class="dlg-input" v-model="form.sourceOrt"
|
||||
>
|
||||
<option value="" disabled>Ordner wählen…</option>
|
||||
<option v-for="fo in (folders[form.sourceType] || [])" :key="fo.ort" :value="fo.ort">{{ fo.name }}</option>
|
||||
<option value="" disabled>Choose folder…</option>
|
||||
<option v-for="fo in (folders[form.sourceType] || [])" :key="fo.location" :value="fo.location">{{ fo.name }}</option>
|
||||
</select>
|
||||
<div class="dlg-actions">
|
||||
<button class="dlg-cancel" @click="dlg = false">Abbrechen</button>
|
||||
<button class="dlg-create" :disabled="!canCreate" @click="createThema">Erstellen</button>
|
||||
<button class="dlg-cancel" @click="dlg = false">Cancel</button>
|
||||
<button class="dlg-create" :disabled="!canCreate" @click="createTopic">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider-toggle" v-if="providers.length">
|
||||
@@ -353,118 +347,112 @@ function saveQuelle() {
|
||||
:key="p.id"
|
||||
:class="{ active: p.id === provider }"
|
||||
:disabled="!p.available"
|
||||
:title="p.available ? '' : 'Nicht konfiguriert (CLI/Key fehlt)'"
|
||||
:title="p.available ? '' : 'Not configured (CLI/key missing)'"
|
||||
@click="emit('setProvider', p.id)"
|
||||
>{{ PROVIDER_LABELS[p.id] || p.id }}</button>
|
||||
</div>
|
||||
<div class="format-section" v-if="selectedTopic">
|
||||
<div class="format-error ui-error" v-if="uiError">
|
||||
<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 class="progress-info" v-if="activeGenerations.length">
|
||||
<div v-for="(line, i) in activeGenerations" :key="i">{{ line }}</div>
|
||||
</div>
|
||||
<div class="ord-bausteine">
|
||||
<div class="ord-blocks">
|
||||
<div
|
||||
class="format-row bausteine-row"
|
||||
:class="{ 'is-active': bausteineState === 'generating' || bausteine.partial, 'row-open': isOpen('bausteine') }"
|
||||
class="format-row blocks-row"
|
||||
:class="{ 'is-active': blocksState === 'generating' || blocks.partial, 'row-open': isOpen('blocks') }"
|
||||
>
|
||||
<button class="format-name bausteine-name" @click="onBausteineName">
|
||||
<span class="format-label">Bausteine</span>
|
||||
<button class="format-name blocks-name" @click="onBlocksName">
|
||||
<span class="format-label">Blocks</span>
|
||||
<span
|
||||
v-if="bausteine.partial && bausteineState !== 'generating'"
|
||||
v-if="blocks.partial && blocksState !== 'generating'"
|
||||
class="resume-badge"
|
||||
title="Abgebrochen — Fortsetzen möglich"
|
||||
>Pausiert</span>
|
||||
<span class="step-dots">
|
||||
<span
|
||||
v-for="(s, i) in (bausteine.steps || [])"
|
||||
:key="s.label"
|
||||
class="step-pill"
|
||||
:class="[s.state, { sel: gewaehltePhase === i + 1, klickbar: phasenWaehlbar }]"
|
||||
:title="(s.state === 'active' ? (bausteine.progress || s.label) : s.label) + (phasenWaehlbar ? ' — Klick: ab hier neu' : '')"
|
||||
@click.stop="phaseKlick(i + 1)"
|
||||
>{{ i + 1 }}</span>
|
||||
</span>
|
||||
title="Aborted — can be resumed"
|
||||
>Paused</span>
|
||||
</button>
|
||||
<button class="panel-toggle" :class="{ open: isOpen('bausteine') }" title="Aktionen" @click.stop="togglePanel('bausteine')">▾</button>
|
||||
<button v-if="blocksState === 'generating' || blocks.ready || blocks.partial" class="panel-toggle" :class="{ open: isOpen('blocks') }" title="Actions" @click.stop="togglePanel('blocks')">▾</button>
|
||||
</div>
|
||||
<div v-if="isOpen('bausteine')" class="action-panel">
|
||||
<template v-if="bausteineState === 'generating'">
|
||||
<button class="panel-btn danger" :class="{ armed: pendingConfirm === 'bausteine' }" @click="confirmCancelBausteine">{{ pendingConfirm === 'bausteine' ? 'Sicher?' : 'Abbrechen' }}</button>
|
||||
<div v-if="isOpen('blocks')" class="action-panel">
|
||||
<template v-if="blocksState === 'generating'">
|
||||
<button class="panel-btn danger" :class="{ armed: pendingConfirm === 'blocks' }" @click="confirmCancelBlocks">{{ pendingConfirm === 'blocks' ? 'Sure?' : 'Cancel' }}</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="panel-btn play" @click="handleBausteinePlay">{{ bausteine.partial ? 'Fortsetzen' : bausteine.ready ? (gewaehltePhase ? `Ab «${gewaehltesLabel}» neu` : 'Neu generieren') : 'Generieren' }}</button>
|
||||
<button
|
||||
v-if="bausteine.ready || bausteine.partial"
|
||||
v-if="blocks.ready || blocks.partial"
|
||||
class="panel-btn danger"
|
||||
:class="{ armed: pendingConfirm === 'bausteine' }"
|
||||
@click="confirmResetBausteine"
|
||||
>{{ pendingConfirm === 'bausteine' ? 'Sicher?' : 'Entfernen' }}</button>
|
||||
:class="{ armed: pendingConfirm === 'blocks' }"
|
||||
@click="confirmResetBlocks"
|
||||
>{{ pendingConfirm === 'blocks' ? 'Sure?' : 'Remove' }}</button>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="bausteineState === 'generating'" class="format-progress">
|
||||
{{ bausteine.progress || 'Wartend…' }}
|
||||
<div v-if="blocksState === 'generating'" class="format-progress">
|
||||
{{ blocks.progress || 'Waiting…' }}
|
||||
</div>
|
||||
<div v-if="bausteine.error && !bausteine.error.startsWith('Abgebrochen')" class="format-error">
|
||||
<span class="format-error-text">{{ bausteine.error }}</span>
|
||||
<div v-if="blocks.error && !blocks.error.startsWith('Cancelled')" class="format-error">
|
||||
<span class="format-error-text">{{ blocks.error }}</span>
|
||||
</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 :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)">
|
||||
<span class="format-label">{{ f.label }}</span>
|
||||
<span
|
||||
v-if="abgebrochen(f.key)"
|
||||
v-if="aborted(f.key)"
|
||||
class="resume-badge"
|
||||
title="Abgebrochen — Fortsetzen möglich"
|
||||
>Pausiert</span>
|
||||
title="Aborted — can be resumed"
|
||||
>Paused</span>
|
||||
<span class="step-dots" v-if="guideSteps(f.key).length">
|
||||
<span
|
||||
v-for="(s, i) in guideSteps(f.key)"
|
||||
:key="s.label"
|
||||
class="step-pill"
|
||||
:class="[s.state, { sel: gewaehlterStep[f.key] === i + 1, klickbar: guideWaehlbar(f.key) }]"
|
||||
:title="(s.state === 'active' ? (latestByFormat[f.key]?.progress || s.label) : s.label) + (guideWaehlbar(f.key) ? ' — Klick: ab hier neu' : '')"
|
||||
@click.stop="guideStepKlick(f.key, i + 1)"
|
||||
: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) + (guideSelectable(f.key) ? ' — Click: regenerate from here' : '')"
|
||||
@click.stop="guideStepClick(f.key, i + 1)"
|
||||
>{{ i + 1 }}</span>
|
||||
</span>
|
||||
</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 v-if="isOpen('fmt-' + f.key)" class="action-panel">
|
||||
<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 v-else>
|
||||
<button
|
||||
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)"
|
||||
@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
|
||||
v-if="guideStatus(f.key) !== 'none' || abgebrochen(f.key)"
|
||||
v-if="guideStatus(f.key) !== 'none' || aborted(f.key)"
|
||||
class="panel-btn danger"
|
||||
:class="{ armed: pendingConfirm === 'fmt-' + 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>
|
||||
</div>
|
||||
<div
|
||||
v-if="guideStatus(f.key) === 'generating' || guideStatus(f.key) === 'queued'"
|
||||
class="format-progress"
|
||||
>{{ latestByFormat[f.key]?.progress || 'Wartend…' }}</div>
|
||||
>{{ latestByFormat[f.key]?.progress || 'Waiting…' }}</div>
|
||||
<div v-if="errorMsg(f.key)" class="format-error">
|
||||
<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 class="format-row ord-exam">
|
||||
<button class="format-name elements-btn" @click="emit('generalExam')">
|
||||
<span class="format-label">General Exam</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="format-row ord-elemente">
|
||||
<button class="format-name elements-btn" @click="emit('openElements')">
|
||||
<span class="format-label">Elemente</span>
|
||||
<span class="format-label">Elements</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -477,16 +465,16 @@ function saveQuelle() {
|
||||
>
|
||||
<div class="topic-row">
|
||||
<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 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>
|
||||
<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">
|
||||
<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 === '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>
|
||||
</div>
|
||||
<input
|
||||
@@ -498,16 +486,16 @@ function saveQuelle() {
|
||||
v-else-if="editForm.type === 'projekt' || editForm.type === 'uni'"
|
||||
class="dlg-input" v-model="editForm.ort"
|
||||
>
|
||||
<option value="" disabled>Ordner wählen…</option>
|
||||
<option v-for="fo in (folders[editForm.type] || [])" :key="fo.ort" :value="fo.ort">{{ fo.name }}</option>
|
||||
<option value="" disabled>Choose folder…</option>
|
||||
<option v-for="fo in (folders[editForm.type] || [])" :key="fo.location" :value="fo.location">{{ fo.name }}</option>
|
||||
</select>
|
||||
<div class="dlg-actions">
|
||||
<button
|
||||
class="dlg-delete"
|
||||
:class="{ armed: pendingConfirm === 'topic-' + t }"
|
||||
@click="confirmDeleteTopic(t)"
|
||||
>{{ pendingConfirm === 'topic-' + t ? 'Sicher?' : 'Löschen' }}</button>
|
||||
<button class="dlg-create" :disabled="!canSave" @click="saveQuelle">Aktualisieren</button>
|
||||
>{{ pendingConfirm === 'topic-' + t ? 'Sure?' : 'Delete' }}</button>
|
||||
<button class="dlg-create" :disabled="!canSave" @click="saveSource">Update</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -592,7 +580,7 @@ function saveQuelle() {
|
||||
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; }
|
||||
.new-topic .ansicht-toggle button {
|
||||
padding: 4px 7px;
|
||||
@@ -607,6 +595,21 @@ function saveQuelle() {
|
||||
.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); }
|
||||
.ansicht-toggle svg { fill: currentColor; display: block; }
|
||||
.level-toggle { display: inline-flex; margin-left: 4px; }
|
||||
.new-topic .level-toggle button {
|
||||
padding: 4px 6px;
|
||||
background: var(--bg);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-strong);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
min-width: 20px;
|
||||
}
|
||||
.new-topic .level-toggle button:first-child { border-radius: 6px 0 0 6px; }
|
||||
.new-topic .level-toggle button:last-child { border-radius: 0 6px 6px 0; }
|
||||
.new-topic .level-toggle button:not(:first-child) { border-left: none; }
|
||||
.new-topic .level-toggle button:hover { color: var(--accent-hover); }
|
||||
.new-topic .level-toggle button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
||||
|
||||
.stats-bar {
|
||||
display: flex;
|
||||
@@ -634,7 +637,7 @@ function saveQuelle() {
|
||||
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 {
|
||||
position: absolute;
|
||||
top: -0.42rem;
|
||||
@@ -728,7 +731,7 @@ function saveQuelle() {
|
||||
}
|
||||
|
||||
/* Format section */
|
||||
.bausteine-name {
|
||||
.blocks-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -741,7 +744,7 @@ function saveQuelle() {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Grobe Phasen als nummerierte Pillen (1–5) — Anzeige + anklickbar für Re-Run ab hier. */
|
||||
/* Coarse phases as numbered pills (1–5) — display + clickable for re-run from here. */
|
||||
.step-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -791,19 +794,23 @@ function saveQuelle() {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
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;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ord-bausteine {
|
||||
.ord-blocks {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.ord-elemente {
|
||||
.ord-exam {
|
||||
order: 4;
|
||||
}
|
||||
|
||||
.ord-elemente {
|
||||
order: 5;
|
||||
}
|
||||
|
||||
.elements-btn {
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
@@ -822,14 +829,14 @@ function saveQuelle() {
|
||||
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 {
|
||||
order: 0;
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: var(--warning-soft);
|
||||
}
|
||||
|
||||
/* Fortschritts-Text direkt unter der laufenden Format-Zeile */
|
||||
/* Progress text directly below the running format row */
|
||||
.format-progress {
|
||||
padding: 0 0.75rem 5px calc(0.75rem + 8px);
|
||||
font-size: 0.72rem;
|
||||
@@ -879,7 +886,7 @@ function saveQuelle() {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Accordion: Pfeil-Toggle + aufklappendes Aktions-Panel */
|
||||
/* Accordion: arrow toggle + expanding action panel */
|
||||
.panel-toggle {
|
||||
flex: 0 0 auto;
|
||||
background: none;
|
||||
@@ -941,11 +948,11 @@ function saveQuelle() {
|
||||
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-queued .format-x,
|
||||
.fmt-paused .format-x,
|
||||
.bausteine-row.is-active .format-x {
|
||||
.blocks-row.is-active .format-x {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
@@ -1056,9 +1063,9 @@ function saveQuelle() {
|
||||
50% { opacity: 0.65; }
|
||||
}
|
||||
|
||||
/* Erstellen — inline aufklappbar (kein Modal) */
|
||||
/* Create — inline expandable (no modal) */
|
||||
.new-topic-toggle {
|
||||
margin-left: auto; /* rechtsbündig statt volle Breite */
|
||||
margin-left: auto; /* right-aligned instead of full width */
|
||||
padding: 6px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
@@ -1120,7 +1127,7 @@ function saveQuelle() {
|
||||
.dlg-create { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
||||
.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 {
|
||||
margin: 0;
|
||||
padding: 0.5rem 1rem 0.7rem 1.25rem;
|
||||
|
||||
67
frontend/src/components/WorkedExampleBlock.vue
Normal file
67
frontend/src/components/WorkedExampleBlock.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { renderMarkdownInline } from '../markdown.js'
|
||||
|
||||
defineProps({ examples: { type: Array, default: () => [] } })
|
||||
const open = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="examples.length" class="worked">
|
||||
<button class="art-head" @click="open = !open">
|
||||
<span class="art-icon">📝</span> Examples
|
||||
<span class="art-count">{{ examples.length }}</span>
|
||||
<span class="art-toggle">{{ open ? '▾' : '▸' }}</span>
|
||||
</button>
|
||||
<div v-if="open" class="we-body">
|
||||
<div v-for="(b, i) in examples" :key="i" class="we-card">
|
||||
<div v-if="b.subblock" class="we-sub">{{ b.subblock }}</div>
|
||||
<div class="we-problem" v-html="renderMarkdownInline(b.problem)"></div>
|
||||
<ol class="we-steps">
|
||||
<li v-for="(s, j) in b.steps" :key="j" v-html="renderMarkdownInline(s)"></li>
|
||||
</ol>
|
||||
<div v-if="b.result" class="we-result">
|
||||
<span class="we-label">Result</span>
|
||||
<span v-html="renderMarkdownInline(b.result)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.worked { margin-top: 0.5rem; }
|
||||
.art-head {
|
||||
display: flex; align-items: center; gap: 8px; width: 100%;
|
||||
background: none; border: none; cursor: pointer; padding: 0.35rem 0;
|
||||
font-size: 0.82rem; font-weight: 600; color: var(--text-muted);
|
||||
}
|
||||
.art-icon { font-size: 0.95rem; }
|
||||
.art-count {
|
||||
background: var(--panel-soft); border: 1px solid var(--border);
|
||||
border-radius: 999px; padding: 0 0.45rem; font-size: 0.72rem;
|
||||
}
|
||||
.art-toggle { margin-left: auto; color: var(--text-faint); }
|
||||
|
||||
.we-body { margin-top: 0.4rem; display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.we-card {
|
||||
border: 1px solid var(--border); border-left: 3px solid var(--accent);
|
||||
border-radius: 8px; padding: 0.7rem 0.9rem; background: var(--panel-soft);
|
||||
font-size: 0.92rem; line-height: 1.5;
|
||||
}
|
||||
.we-sub {
|
||||
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
color: var(--text-faint); font-weight: 700; margin-bottom: 0.3rem;
|
||||
}
|
||||
.we-problem { font-weight: 600; margin-bottom: 0.4rem; }
|
||||
.we-steps { margin: 0 0 0.4rem 1.1rem; padding: 0; }
|
||||
.we-steps li { margin: 0.2rem 0; }
|
||||
.we-result {
|
||||
display: flex; gap: 6px; align-items: baseline;
|
||||
padding-top: 0.35rem; border-top: 1px dashed var(--border);
|
||||
}
|
||||
.we-label {
|
||||
font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
color: var(--success); font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
@@ -13,7 +13,7 @@ const emit = defineEmits(['changes'])
|
||||
const chat = useChat((msgs) => chatElement(props.element.id, msgs, props.provider))
|
||||
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())
|
||||
|
||||
async function send() {
|
||||
@@ -25,23 +25,23 @@ async function send() {
|
||||
<template>
|
||||
<div class="el-chat">
|
||||
<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">
|
||||
<div :class="['chat-msg', m.role]">{{ m.content }}</div>
|
||||
</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 class="chat-input">
|
||||
<textarea
|
||||
ref="inputEl"
|
||||
v-model="input"
|
||||
placeholder="Element anpassen…"
|
||||
placeholder="Adjust element…"
|
||||
@keydown.enter.exact.prevent="send"
|
||||
></textarea>
|
||||
<button
|
||||
:disabled="!input.trim() && !loading"
|
||||
:class="{ cancel: loading }"
|
||||
:title="loading ? 'Abbrechen' : 'Senden'"
|
||||
:title="loading ? 'Cancel' : 'Send'"
|
||||
@click="send"
|
||||
>{{ loading ? '✕' : '➤' }}</button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
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 ElementChatTab from './ElementChatTab.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 savingEdit = ref(false)
|
||||
|
||||
// Markdown-Zeichen aus dem Header-Titel entfernen
|
||||
function plain(text) {
|
||||
return (text || '').replace(/```[a-z]*\n?/g, '').replace(/[`*_#]/g, '')
|
||||
}
|
||||
// Strip Markdown characters from the header title
|
||||
|
||||
// Anderes Element gewählt → Prüf-Zustand und Tab zurücksetzen
|
||||
// Different element selected → reset exam state and tab
|
||||
watch(() => props.element.id, () => {
|
||||
tab.value = 'overview'
|
||||
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 statusMsg = ref(null)
|
||||
|
||||
@@ -37,10 +34,10 @@ function resetCheck() {
|
||||
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() {
|
||||
if (checking.value) { // zweiter Klick = abbrechen
|
||||
if (checking.value) { // second click = cancel
|
||||
checkRun++
|
||||
checking.value = false
|
||||
return
|
||||
@@ -50,23 +47,23 @@ async function runCheck() {
|
||||
statusMsg.value = null
|
||||
try {
|
||||
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) => ({
|
||||
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]
|
||||
else statusMsg.value = 'Keine wichtigen Lücken gefunden.'
|
||||
else statusMsg.value = 'No important gaps found.'
|
||||
} catch (e) {
|
||||
if (run !== checkRun) return
|
||||
console.error('Prüfung fehlgeschlagen:', e)
|
||||
statusMsg.value = 'Prüfung fehlgeschlagen — bitte erneut versuchen.'
|
||||
console.error('Exam failed:', e)
|
||||
statusMsg.value = 'Exam failed — please try again.'
|
||||
} finally {
|
||||
if (run === checkRun) checking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Stil-Prüfung: KI schlägt Änderungen vor, Nutzer bestätigt ---
|
||||
const styleChanges = ref(null) // null = noch nicht geprüft
|
||||
// --- Style exam: AI proposes changes, user confirms ---
|
||||
const styleChanges = ref(null) // null = not yet examined
|
||||
const styling = ref(false)
|
||||
const applyingStyle = ref(false)
|
||||
const refiningIdx = ref(null)
|
||||
@@ -83,7 +80,7 @@ function suggBusy(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) {
|
||||
if (refiningIdx.value !== null || applyingStyle.value) return
|
||||
refiningIdx.value = i
|
||||
@@ -93,15 +90,15 @@ async function refineChange(i, instruction) {
|
||||
next[i] = res.change
|
||||
styleChanges.value = next
|
||||
} catch (e) {
|
||||
console.error('Überarbeitung fehlgeschlagen:', e)
|
||||
statusMsg.value = 'Überarbeitung fehlgeschlagen — bitte erneut versuchen.'
|
||||
console.error('Refinement failed:', e)
|
||||
statusMsg.value = 'Refinement failed — please try again.'
|
||||
} finally {
|
||||
refiningIdx.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function runStyle() {
|
||||
if (styling.value) { // zweiter Klick = abbrechen
|
||||
if (styling.value) { // second click = cancel
|
||||
styleRun++
|
||||
styling.value = false
|
||||
return
|
||||
@@ -113,35 +110,35 @@ async function runStyle() {
|
||||
const res = await styleElement(props.element.id, props.provider)
|
||||
if (run !== styleRun) return
|
||||
if (res.changes.length) styleChanges.value = [...(styleChanges.value || []), ...res.changes]
|
||||
else statusMsg.value = 'Stil passt bereits.'
|
||||
else statusMsg.value = 'Style already fits.'
|
||||
} catch (e) {
|
||||
if (run !== styleRun) return
|
||||
console.error('Stil-Prüfung fehlgeschlagen:', e)
|
||||
statusMsg.value = 'Stil-Prüfung fehlgeschlagen — bitte erneut versuchen.'
|
||||
console.error('Style exam failed:', e)
|
||||
statusMsg.value = 'Style exam failed — please try again.'
|
||||
} finally {
|
||||
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) {
|
||||
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) {
|
||||
if (!styleChanges.value) return []
|
||||
return styleChanges.value
|
||||
.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) {
|
||||
if (!styleChanges.value) return []
|
||||
return styleChanges.value
|
||||
.map((c, i) => [i, c])
|
||||
.filter(([, c]) => c.target === target && c.action === 'hinzufuegen')
|
||||
.filter(([, c]) => c.target === target && c.action === 'add')
|
||||
}
|
||||
|
||||
function dismissStyleChange(i) {
|
||||
@@ -160,8 +157,8 @@ async function applyStyleChange(i) {
|
||||
examples: [...props.element.examples],
|
||||
hints: [...props.element.hints],
|
||||
}
|
||||
if (c.action === 'entfernen') fields[c.target].splice(c.index, 1)
|
||||
else if (c.action === 'hinzufuegen') {
|
||||
if (c.action === 'remove') fields[c.target].splice(c.index, 1)
|
||||
else if (c.action === 'add') {
|
||||
if (c.target === 'title') fields.title = c.content
|
||||
else if (c.target === 'description')
|
||||
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)
|
||||
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)
|
||||
if (c.action === 'entfernen') {
|
||||
if (c.action === 'remove') {
|
||||
for (const r of rest) {
|
||||
if (r.target === c.target && r.index !== null && r.index > c.index) r.index--
|
||||
}
|
||||
}
|
||||
styleChanges.value = rest
|
||||
} catch (e) {
|
||||
console.error('Übernehmen fehlgeschlagen:', e)
|
||||
console.error('Apply failed:', e)
|
||||
} finally {
|
||||
applyingStyle.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bearbeiten-Tab: Felder direkt speichern ---
|
||||
// --- Edit tab: save fields directly ---
|
||||
async function saveEdit(fields) {
|
||||
if (savingEdit.value) return
|
||||
savingEdit.value = true
|
||||
@@ -196,7 +193,7 @@ async function saveEdit(fields) {
|
||||
emit('updated', updated)
|
||||
tab.value = 'overview'
|
||||
} catch (e) {
|
||||
console.error('Speichern fehlgeschlagen:', e)
|
||||
console.error('Save failed:', e)
|
||||
} finally {
|
||||
savingEdit.value = false
|
||||
}
|
||||
@@ -205,26 +202,26 @@ async function saveEdit(fields) {
|
||||
|
||||
<template>
|
||||
<header class="el-header">
|
||||
<button class="el-back" title="Zur Liste" @click="emit('back')">←</button>
|
||||
<span class="el-title">{{ plain(element.title) }}</span>
|
||||
<button class="el-back" title="Back to list" @click="emit('back')">←</button>
|
||||
<span class="el-title">{{ plainText(element.title) }}</span>
|
||||
<button
|
||||
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
|
||||
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 class="el-close" title="Schließen" @click="emit('close')">×</button>
|
||||
<button class="el-close" title="Close" @click="emit('close')">×</button>
|
||||
</header>
|
||||
|
||||
<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 === 'edit' }" @click="tab = 'edit'">Bearbeiten</button>
|
||||
<button :class="{ active: tab === 'edit' }" @click="tab = 'edit'">Edit</button>
|
||||
</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-if="element.description" class="el-desc markdown" v-html="renderMarkdown(element.description)"></div>
|
||||
<ElementSuggestion
|
||||
@@ -246,7 +243,7 @@ async function saveEdit(fields) {
|
||||
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
|
||||
/>
|
||||
<div v-if="element.hints.length || styleAdds('hints').length" class="el-hints-block">
|
||||
<h4>Hinweise</h4>
|
||||
<h4>Hints</h4>
|
||||
<ul class="el-hints">
|
||||
<li v-for="(h, i) in element.hints" :key="i">
|
||||
<span class="markdown" v-html="renderMarkdown(h)"></span>
|
||||
@@ -265,13 +262,13 @@ async function saveEdit(fields) {
|
||||
</div>
|
||||
|
||||
<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="styling" class="check-empty busy-text">Prüft den Stil…</p>
|
||||
<p v-if="checking" class="check-empty busy-text">Checking for missing info…</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- v-show erhält den Chat-Verlauf beim Tab-Wechsel -->
|
||||
<!-- v-show preserves the chat history when switching tabs -->
|
||||
<ElementChatTab
|
||||
v-show="tab === 'chat'"
|
||||
:element="element"
|
||||
@@ -279,7 +276,7 @@ async function saveEdit(fields) {
|
||||
@changes="onChatChanges"
|
||||
/>
|
||||
|
||||
<!-- v-if lädt die Edit-Felder bei jedem Öffnen frisch -->
|
||||
<!-- v-if loads the edit fields fresh on every open -->
|
||||
<ElementEditTab
|
||||
v-if="tab === 'edit'"
|
||||
:element="element"
|
||||
@@ -427,7 +424,7 @@ async function saveEdit(fields) {
|
||||
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 {
|
||||
display: inline;
|
||||
}
|
||||
@@ -437,12 +434,12 @@ async function saveEdit(fields) {
|
||||
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) {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
/* --- KI-Prüfung --- */
|
||||
/* --- AI exam --- */
|
||||
.el-check {
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.8rem;
|
||||
|
||||
@@ -35,28 +35,28 @@ function save() {
|
||||
<template>
|
||||
<div class="el-edit">
|
||||
<button class="edit-save" :disabled="saving" @click="save">
|
||||
{{ saving ? 'Speichert…' : 'Speichern' }}
|
||||
{{ saving ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
|
||||
<label>Titel</label>
|
||||
<input v-model="edit.title" placeholder="Titel" />
|
||||
<label>Title</label>
|
||||
<input v-model="edit.title" placeholder="Title" />
|
||||
|
||||
<label>Beschreibung</label>
|
||||
<textarea v-model="edit.description" placeholder="Beschreibung"></textarea>
|
||||
<label>Description</label>
|
||||
<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">
|
||||
<textarea v-model="edit.examples[i]" placeholder="Beispiel"></textarea>
|
||||
<button class="edit-del" title="Entfernen" @click="edit.examples.splice(i, 1)">×</button>
|
||||
<textarea v-model="edit.examples[i]" placeholder="Example"></textarea>
|
||||
<button class="edit-del" title="Remove" @click="edit.examples.splice(i, 1)">×</button>
|
||||
</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">
|
||||
<textarea v-model="edit.hints[i]" placeholder="Hinweis"></textarea>
|
||||
<button class="edit-del" title="Entfernen" @click="edit.hints.splice(i, 1)">×</button>
|
||||
<textarea v-model="edit.hints[i]" placeholder="Hint"></textarea>
|
||||
<button class="edit-del" title="Remove" @click="edit.hints.splice(i, 1)">×</button>
|
||||
</div>
|
||||
<button class="edit-add" @click="edit.hints.push('')">+ Hinweis</button>
|
||||
<button class="edit-add" @click="edit.hints.push('')">+ Hint</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useConfirm } from '../../composables/useConfirm.js'
|
||||
import { plainText } from '../../markdown.js'
|
||||
|
||||
const props = defineProps({
|
||||
elements: { type: Array, required: true },
|
||||
@@ -12,10 +13,7 @@ const emit = defineEmits(['select', 'create', 'remove'])
|
||||
const query = ref('')
|
||||
const { isArmed, armOrRun } = useConfirm()
|
||||
|
||||
// Markdown-Zeichen für Titel und Listen-Vorschau entfernen
|
||||
function plain(text) {
|
||||
return (text || '').replace(/```[a-z]*\n?/g, '').replace(/[`*_#]/g, '')
|
||||
}
|
||||
// Strip Markdown characters for title and list preview
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = query.value.trim().toLowerCase()
|
||||
@@ -31,7 +29,7 @@ function add() {
|
||||
query.value = ''
|
||||
}
|
||||
|
||||
// Inline-Bestätigung: erster Klick „Sicher?", zweiter löscht
|
||||
// Inline confirmation: first click "Sure?", second deletes
|
||||
function confirmDelete(el) {
|
||||
armOrRun('el-' + el.id, () => emit('remove', el))
|
||||
}
|
||||
@@ -41,28 +39,28 @@ function confirmDelete(el) {
|
||||
<div class="el-new">
|
||||
<input
|
||||
v-model="query"
|
||||
placeholder="Suchen oder Stichwort…"
|
||||
placeholder="Search or keyword…"
|
||||
:disabled="creating"
|
||||
@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 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">
|
||||
<li v-for="el in filtered" :key="el.id" @click="emit('select', el)">
|
||||
<div class="el-item-main">
|
||||
<span class="el-item-title">{{ plain(el.title) }}</span>
|
||||
<span class="el-item-desc">{{ plain(el.description) }}</span>
|
||||
<span class="el-item-title">{{ plainText(el.title) }}</span>
|
||||
<span class="el-item-desc">{{ plainText(el.description) }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="el-delete"
|
||||
:class="{ armed: isArmed('el-' + el.id) }"
|
||||
title="Element löschen"
|
||||
title="Delete element"
|
||||
@click.stop="confirmDelete(el)"
|
||||
>{{ isArmed('el-' + el.id) ? 'Sicher?' : '×' }}</button>
|
||||
>{{ isArmed('el-' + el.id) ? 'Sure?' : '×' }}</button>
|
||||
</li>
|
||||
<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>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
@@ -9,7 +9,7 @@ const props = defineProps({
|
||||
|
||||
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 instruction = ref('')
|
||||
@@ -34,15 +34,15 @@ function submit() {
|
||||
<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 class="style-sugg-actions">
|
||||
<button class="sugg-ok" :disabled="busy" @click="emit('apply')">Bestätigen</button>
|
||||
<button class="sugg-no" :disabled="busy" @click="emit('dismiss')">Ablehnen</button>
|
||||
<button class="sugg-edit" :disabled="busy" title="Vorschlag per Anweisung anpassen" @click="toggleEdit">✏️</button>
|
||||
<button class="sugg-ok" :disabled="busy" @click="emit('apply')">Confirm</button>
|
||||
<button class="sugg-no" :disabled="busy" @click="emit('dismiss')">Reject</button>
|
||||
<button class="sugg-edit" :disabled="busy" title="Adjust suggestion via instruction" @click="toggleEdit">✏️</button>
|
||||
</div>
|
||||
<div v-if="editing" class="sugg-edit-row">
|
||||
<input
|
||||
ref="inputEl"
|
||||
v-model="instruction"
|
||||
placeholder="Anweisung zum Vorschlag…"
|
||||
placeholder="Instruction for the suggestion…"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
<button :disabled="!instruction.trim() || busy" @click="submit">➤</button>
|
||||
@@ -175,7 +175,7 @@ function submit() {
|
||||
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) {
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
|
||||
@@ -7,8 +7,8 @@ import ElementDetail from './ElementDetail.vue'
|
||||
const props = defineProps({
|
||||
topic: { type: String, required: true },
|
||||
provider: { type: String, default: 'claude' },
|
||||
openId: { type: String, default: null }, // Element-ID, die geöffnet werden soll
|
||||
openTick: { type: Number, default: 0 }, // Erhöhung = openId (erneut) öffnen
|
||||
openId: { type: String, default: null }, // Element ID that should be opened
|
||||
openTick: { type: Number, default: 0 }, // increment = (re)open openId
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'changed'])
|
||||
@@ -24,12 +24,12 @@ async function load() {
|
||||
try {
|
||||
elements.value = await fetchElements(props.topic)
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden der Elemente:', e)
|
||||
console.error('Failed to load elements:', e)
|
||||
}
|
||||
openFromProp()
|
||||
}
|
||||
|
||||
// Aus der Übersicht im Hauptbereich angeklicktes Element öffnen
|
||||
// Open the element clicked in the overview of the main area
|
||||
watch(() => props.openTick, openFromProp)
|
||||
|
||||
function openFromProp() {
|
||||
@@ -46,7 +46,7 @@ async function create(hint) {
|
||||
elements.value.unshift(el)
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Erstellen des Elements:', e)
|
||||
console.error('Failed to create element:', e)
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
@@ -59,7 +59,7 @@ async function remove(el) {
|
||||
emit('changed')
|
||||
}
|
||||
|
||||
// Bearbeitetes Element in Liste und Auswahl synchron halten
|
||||
// Keep the edited element in sync within the list and selection
|
||||
function onUpdated(el) {
|
||||
selected.value = el
|
||||
const idx = elements.value.findIndex((e) => e.id === el.id)
|
||||
@@ -81,8 +81,8 @@ function onUpdated(el) {
|
||||
/>
|
||||
<template v-else>
|
||||
<header class="el-header">
|
||||
<span class="el-title">Elemente</span>
|
||||
<button class="el-close" title="Schließen" @click="emit('close')">×</button>
|
||||
<span class="el-title">Elements</span>
|
||||
<button class="el-close" title="Close" @click="emit('close')">×</button>
|
||||
</header>
|
||||
<ElementList
|
||||
:elements="elements"
|
||||
@@ -104,13 +104,13 @@ function onUpdated(el) {
|
||||
flex-direction: column;
|
||||
background: var(--panel);
|
||||
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;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
/* Mobil/schmal: als Overlay über den Hauptinhalt legen, statt ihn
|
||||
im Flex-Fluss einzuquetschen. */
|
||||
/* Mobile/narrow: lay it as an overlay over the main content instead of
|
||||
squeezing it into the flex flow. */
|
||||
@media (max-width: 768px) {
|
||||
.elements-sidebar {
|
||||
position: fixed;
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { ref, nextTick } from 'vue'
|
||||
|
||||
// (Fast) am unteren Rand? Schwelle fängt Sub-Pixel und kleine Abstände ab.
|
||||
export function istUnten(el, schwelle = 60) {
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight < schwelle
|
||||
// (Almost) at the bottom edge? Threshold absorbs sub-pixels and small gaps.
|
||||
export function istUnten(el, threshold = 60) {
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight < threshold
|
||||
}
|
||||
|
||||
// Gemeinsame Chat-Mechanik: senden, abbrechen (Run-Counter), scrollen, Fokus.
|
||||
// performRequest(messages) → Promise<{ reply, … }>; send() gibt die Antwort
|
||||
// zurück, damit der Aufrufer Extras (z. B. changes) auswerten kann.
|
||||
// Shared chat mechanics: send, cancel (run counter), scroll, focus.
|
||||
// performRequest(messages) → Promise<{ reply, … }>; send() returns the response
|
||||
// so the caller can evaluate extras (e.g. changes).
|
||||
export function useChat(performRequest) {
|
||||
const messages = ref([])
|
||||
const input = ref('')
|
||||
const loading = ref(false)
|
||||
const messagesEl = ref(null) // Template-Ref: Nachrichten-Container
|
||||
const inputEl = ref(null) // Template-Ref: Textarea
|
||||
const stick = ref(true) // an den Boden „gepinnt" — nur dann auto-scrollen
|
||||
let run = 0 // laufende Anfrage identifizieren; Abbruch ignoriert ihr Ergebnis
|
||||
const messagesEl = ref(null) // template ref: messages container
|
||||
const inputEl = ref(null) // template ref: textarea
|
||||
const stick = ref(true) // "pinned" to the bottom — only then auto-scroll
|
||||
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() {
|
||||
if (messagesEl.value) stick.value = istUnten(messagesEl.value)
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export function useChat(performRequest) {
|
||||
function cancel() {
|
||||
run++
|
||||
loading.value = false
|
||||
messages.value.push({ role: 'assistant', content: 'Abgebrochen.' })
|
||||
messages.value.push({ role: 'assistant', content: 'Cancelled.' })
|
||||
}
|
||||
|
||||
function reset() {
|
||||
@@ -48,13 +48,13 @@ export function useChat(performRequest) {
|
||||
}
|
||||
|
||||
async function send() {
|
||||
if (loading.value) { // zweiter Klick = abbrechen
|
||||
if (loading.value) { // second click = cancel
|
||||
cancel()
|
||||
return null
|
||||
}
|
||||
const text = input.value.trim()
|
||||
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
|
||||
messages.value.push({ role: 'user', content: text })
|
||||
input.value = ''
|
||||
@@ -64,17 +64,17 @@ export function useChat(performRequest) {
|
||||
try {
|
||||
const res = await performRequest(messages.value)
|
||||
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({
|
||||
role: 'assistant',
|
||||
content: res.frage ?? res.reply ?? '…',
|
||||
content: res.question ?? res.reply ?? '…',
|
||||
feedback: res.feedback ?? null,
|
||||
bewertung: res.bewertung ?? null,
|
||||
rating: res.rating ?? null,
|
||||
})
|
||||
return res
|
||||
} catch {
|
||||
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
|
||||
} finally {
|
||||
if (current === run) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
|
||||
// Inline-Bestätigung statt confirm(): erster Klick scharfschalten („Sicher?"),
|
||||
// zweiter Klick führt aus. Browser-Dialoge können unterdrückt sein (Firefox).
|
||||
// Inline confirmation instead of confirm(): first click arms ("Sure?"),
|
||||
// second click runs it. Browser dialogs can be suppressed (Firefox).
|
||||
export function useConfirm(timeoutMs = 3000) {
|
||||
const pending = ref(null) // aktuell scharfgeschalteter Key
|
||||
const pending = ref(null) // currently armed key
|
||||
let timer = null
|
||||
|
||||
function armOrRun(key, action) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { onUnmounted } from 'vue'
|
||||
|
||||
// Polling mit Visibility-Pause: Tab unsichtbar → stoppen; wieder sichtbar →
|
||||
// sofortiger Tick, dann weiter, falls isActive(). Stoppt selbst, sobald
|
||||
// isActive() nach einem Tick false liefert.
|
||||
// Polling with a visibility pause: tab hidden → stop; visible again →
|
||||
// immediate tick, then continue if isActive(). Stops itself as soon as
|
||||
// isActive() returns false after a tick.
|
||||
export function usePolling(tick, isActive, interval = 3000) {
|
||||
let timer = null
|
||||
|
||||
|
||||
35
frontend/src/levels.js
Normal file
35
frontend/src/levels.js
Normal 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'
|
||||
}
|
||||
@@ -17,9 +17,9 @@ marked.use(markedHighlight({
|
||||
}))
|
||||
marked.setOptions({ breaks: true, gfm: true })
|
||||
|
||||
// LaTeX-Mathe via KaTeX. Eigene marked-Extensions (statt marked-katex-extension,
|
||||
// die marked v18 hinterherhinkt). marked tokenisiert Code zuerst → $…$ in Code-
|
||||
// Blöcken wird NICHT als Mathe erkannt. throwOnError:false zeigt defektes TeX rot.
|
||||
// LaTeX math via KaTeX. Own marked extensions (instead of marked-katex-extension,
|
||||
// which lags behind marked v18). marked tokenizes code first → $…$ inside code
|
||||
// blocks is NOT treated as math. throwOnError:false renders broken TeX in red.
|
||||
function renderTex(tex, displayMode) {
|
||||
return katex.renderToString(tex, { displayMode, throwOnError: false, output: 'html' })
|
||||
}
|
||||
@@ -40,8 +40,8 @@ const inlineMath = {
|
||||
level: 'inline',
|
||||
start(src) { const i = src.indexOf('$'); return i < 0 ? undefined : i },
|
||||
tokenizer(src) {
|
||||
// $…$: kein $$, kein Leerzeichen direkt hinter dem öffnenden $ und vor dem
|
||||
// schließenden $ (pandoc-Stil) → mindert Kollisionen mit Fließtext-Dollarzeichen.
|
||||
// $…$: no $$, no space right after the opening $ or before the closing $
|
||||
// (pandoc style) → reduces collisions with dollar signs in prose.
|
||||
const m = /^\$(?![\s$])((?:\\\$|[^$])+?)\$/.exec(src)
|
||||
if (!m || /\s$/.test(m[1])) return
|
||||
return { type: 'inlineMath', raw: m[0], text: m[1].trim() }
|
||||
@@ -51,8 +51,8 @@ const inlineMath = {
|
||||
|
||||
marked.use({ extensions: [blockMath, inlineMath] })
|
||||
|
||||
// Rohes HTML im Markdown (z. B. <p>, <img> ohne Backticks aus Agenten-Output)
|
||||
// als Text anzeigen statt rendern — sonst verschluckt der Browser den Inhalt.
|
||||
// Raw HTML in markdown (e.g. <p>, <img> without backticks from agent output)
|
||||
// shown as text instead of rendered — otherwise the browser swallows the content.
|
||||
marked.use({
|
||||
renderer: {
|
||||
html(token) {
|
||||
@@ -66,8 +66,22 @@ export function renderMarkdown(text) {
|
||||
return DOMPurify.sanitize(marked.parse(text || ''))
|
||||
}
|
||||
|
||||
// Inline-Variante (kein <p>-Wrapping) für kurze Texte wie Quiz-Optionen oder
|
||||
// Lückentext-Satzteile — rendert $…$-Mathe und Markdown ohne Block-Umbruch.
|
||||
// Inline variant (no <p> wrapping) for short texts like quiz options or
|
||||
// gap-text sentence fragments — renders $…$ math and markdown without a block break.
|
||||
export function renderMarkdownInline(text) {
|
||||
return DOMPurify.sanitize(marked.parseInline(text || ''))
|
||||
}
|
||||
|
||||
// Strip markdown to plain text (code fences + inline marks) — for previews/search.
|
||||
export function plainText(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 || '')
|
||||
return tokens
|
||||
.filter((t) => t.type !== 'space' && (t.raw || '').trim())
|
||||
.map((t) => ({ raw: t.raw, html: DOMPurify.sanitize(marked.parser([t])) }))
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// Geteilter Prüfungs-State pro Baustein.
|
||||
// Die durablen Refs leben hier als Modul-Map: alle BausteinPanel-Instanzen desselben
|
||||
// Bausteins nutzen DIESELBEN Refs. So überlebt der Verlauf das Remounten (Baustein-
|
||||
// Wechsel, Fokus zu/auf) UND eine spät eintreffende Frage ist sofort reaktiv sichtbar.
|
||||
// Geht bei Page-Reload verloren (Modul re-init) — bewusst kein DB-Aufwand.
|
||||
// Shared exam state per block.
|
||||
// The durable refs live here as a module map: all BlockPanel instances of the same
|
||||
// block use the SAME refs. So the history survives remounting (block switch, focus
|
||||
// open/close) AND a late-arriving question is immediately reactive and visible.
|
||||
// Lost on page reload (module re-init) — deliberately no DB overhead.
|
||||
|
||||
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) {
|
||||
if (!store.has(key)) {
|
||||
@@ -15,14 +15,14 @@ export function usePruefSlot(key) {
|
||||
phase: ref('idle'),
|
||||
aktuelleFrage: ref(''),
|
||||
letztesFeedback: ref(''),
|
||||
pool: ref([]), // vorformulierte Frage-Objekte der AKTUELLEN Form ({form, ...})
|
||||
inflight: ref(0), // laufende Pool-Generierungen (instanzübergreifend koordiniert)
|
||||
poolForm: ref(''), // Form, für die der Pool gefüllt ist — bei Wechsel leeren
|
||||
musterQuelle: ref([]), // unveränderliche Voll-Liste der Frage-Muster (leer = Fallback)
|
||||
musterPool: ref([]), // Arbeitskopie: gezogene Saat ohne Zurücklegen; leer → Reset
|
||||
musterGeladen: ref(false), // Muster-Sidecar schon abgerufen?
|
||||
quizAktuell: ref(null), // laufende Quiz-Frage {frage, optionen, gewaehlt, fertig, ...}
|
||||
lueckAktuell: ref(null), // laufende Lückentext-Aufgabe {satz, loesung, ..., fertig}
|
||||
pool: ref([]), // pre-built question objects of the CURRENT form ({form, ...})
|
||||
inflight: ref(0), // running pool generations (coordinated across instances)
|
||||
poolForm: ref(''), // form the pool is filled for — clear on switch
|
||||
musterQuelle: ref([]), // immutable full list of question patterns (empty = fallback)
|
||||
musterPool: ref([]), // working copy: drawn seed without replacement; empty → reset
|
||||
musterGeladen: ref(false), // pattern sidecar already fetched?
|
||||
quizAktuell: ref(null), // running quiz question {question, options, gewaehlt, done, ...}
|
||||
lueckAktuell: ref(null), // running gap-text task {sentence, solution, ..., done}
|
||||
})
|
||||
}
|
||||
return store.get(key)
|
||||
|
||||
@@ -35,6 +35,12 @@ Markdown im Section-Body: erklärende Absätze in normalem Text, Aufzählungen a
|
||||
|
||||
Mathematik IMMER als LaTeX schreiben: inline zwischen `$…$` (z. B. `$\Sigma^*$`, `$L \subseteq U$`, `$k = 3$`), abgesetzte Formeln zwischen `$$…$$`. KEINE Unicode-Sonderzeichen als Mathe-Ersatz (nicht `x₁`, `¬`, `∨`, `≤` — stattdessen `$x_1$`, `$\neg$`, `$\lor$`, `$\le$`) und keine nackten Formeln ohne `$`. Außerhalb von Mathe normaler Text.
|
||||
|
||||
Formeln brechen NICHT automatisch um — lange Mathe läuft sonst über den Rand. Darum:
|
||||
- **Inline `$…$` nur für KURZE Symbole/Terme** — einzelne Variablen, Mengen, kurze Relationen (`$\Sigma^*$`, `$k = 3$`, `$x \notin L$`). NIE einen ganzen Ausdruck mit mehreren Teilen oder Sätzen inline (auch nicht in einem Listenpunkt).
|
||||
- **Lange oder mehrteilige Formeln IMMER abgesetzt zwischen `$$…$$` auf eigener Zeile** — besonders Mengen-/Set-Builder-Definitionen (`\{ … \mid … \}`). Nicht inline, nicht in eine Aufzählung quetschen.
|
||||
- **KEINE Prosa in `\text{…}`.** Bedingungen und Erklärungen als normalen deutschen Text NEBEN oder UNTER die Formel, nicht in sie hinein. Also nicht `$$L = \text{CLIQUE} = \{ u\#v \mid u \text{ kodiert die Adjazenzmatrix …}\}$$`, sondern die Formel knapp (`$$L = \{\, u\#v \mid \dots \,\}$$`) und die Bedeutung von `u`, `v` im Fließtext erklären.
|
||||
- Lange Definitionen oder Gleichungsketten mit `$$\begin{aligned} … \\ … \end{aligned}$$` über mehrere Zeilen umbrechen.
|
||||
|
||||
Beispiel einer fertigen Section (Code-Thema, nur der Body):
|
||||
|
||||
Arrays lösen ein simples Problem: Du willst viele Werte unter einem Namen halten, statt für jeden eine eigene Variable. In PHP gibt es zwei Sorten. Indizierte Arrays nummerieren die Werte durch (`[0 => 'a']`). Assoziative Arrays geben jedem Wert einen eigenen Schlüssel (`['key' => 'wert']`) — praktisch, wenn die Position egal ist, der Name aber zählt. Intern sind beide dasselbe: geordnete Hashmaps.
|
||||
|
||||
25
templates/Prompt/Artifact-Example-Check.md
Normal file
25
templates/Prompt/Artifact-Example-Check.md
Normal 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}
|
||||
24
templates/Prompt/Artifact-Example.md
Normal file
24
templates/Prompt/Artifact-Example.md
Normal 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**: 2–5 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}
|
||||
23
templates/Prompt/Artifact-Flashcard.md
Normal file
23
templates/Prompt/Artifact-Flashcard.md
Normal 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}
|
||||
@@ -1,34 +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):
|
||||
- kaum < 25 % · teilweise 25–49 % · solide 50–74 % · stark 75–99 % · komplett 100 %.
|
||||
|
||||
PRÜFE GEGEN DIESE KRITERIEN:
|
||||
- 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"]}}
|
||||
@@ -1,49 +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.
|
||||
|
||||
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": 25–49 % — ein Bruchstück stimmt, der Kern fehlt.
|
||||
- "solide": 50–74 % — der Kern ist richtig getroffen, Details fehlen.
|
||||
- "stark": 75–99 % — 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": "kaum" | "teilweise" | "solide" | "stark" | "komplett"}}
|
||||
@@ -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: 1–3 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:".
|
||||
@@ -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 1–2 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"]}}
|
||||
@@ -1,21 +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. Stelle dieselbe Sache, aber im Wortlaut **leicht anders** (andere Formulierung, gleicher Kern und gleiche Schwierigkeit).
|
||||
|
||||
MUSTER (Vorlage — gleiche Sache, neuer Wortlaut):
|
||||
{muster}
|
||||
|
||||
BAUSTEIN AUS DEM GUIDE (fachliche Referenz — die Frage darf ihr nie widersprechen):
|
||||
{section_block}
|
||||
|
||||
KOMPAKTE FASSUNG (Merksätze, falls vorhanden):
|
||||
{kompakt_block}
|
||||
|
||||
HARTE REGELN:
|
||||
- GENAU EINE Frage. Ein einziges Fragezeichen. Eine einzige Sache.
|
||||
- Maximal 1–2 Sätze, keine Vorrede, kein Szenario-Aufbau.
|
||||
- Verboten: zwei Fragen mit "und"/"sowie" verketten, "nenne drei …", Aufzähl-Forderungen.
|
||||
- Gleiche Kernfrage wie das Muster, nur anders formuliert — KEINE neue Sache, KEINE wörtliche Kopie.
|
||||
- Frag nur, was aus dem Material beantwortbar ist. Keine erfundenen Zusatzannahmen.
|
||||
- Sprich den Lerner direkt an, klares Deutsch, keine Floskeln.
|
||||
|
||||
Gib NUR dieses JSON aus (kein weiterer Text):
|
||||
{{"frage": "genau eine kurze Frage"}}
|
||||
@@ -1,41 +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}
|
||||
|
||||
HARTE REGELN FÜR DIE FRAGE — wichtiger als alles andere:
|
||||
- GENAU EINE Frage. Ein einziges Fragezeichen. Eine einzige Sache.
|
||||
- Maximal 1–2 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.
|
||||
|
||||
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"}}
|
||||
@@ -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}}
|
||||
@@ -1,26 +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}
|
||||
|
||||
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).
|
||||
- Fachbegriffe/Formeln dürfen als $…$ gesetzt werden (z. B. $V(w,c)$).
|
||||
- `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"]}}
|
||||
@@ -1,27 +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}
|
||||
|
||||
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.
|
||||
- Fachbegriffe/Formeln dürfen als $…$ gesetzt werden.
|
||||
|
||||
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}}
|
||||
]}}
|
||||
@@ -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.
|
||||
@@ -1,29 +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}
|
||||
|
||||
AUFGABE:
|
||||
- Formuliere EINE klare Frage und GENAU VIER Antwortoptionen.
|
||||
- {anzahl_block}
|
||||
- Jede 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.
|
||||
- Fachbegriffe/Formeln dürfen als $…$ gesetzt werden (z. B. $V(w,c)=A(w)$).
|
||||
|
||||
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": true}}
|
||||
]}}
|
||||
@@ -1,19 +0,0 @@
|
||||
Zum Thema "{topic}" haben {n} Agenten unabhängig über strittige Baustein-Einträge entschieden. Die Zahl in Klammern sagt, wie viele Agenten den Eintrag übernehmen wollen. Sortiere die Einträge.
|
||||
|
||||
BESCHLOSSENER KONSENS (nur Kontext — nicht ändern):
|
||||
{konsens}
|
||||
|
||||
STRITTIGE EINTRÄGE MIT VOTUM:
|
||||
{rest}
|
||||
|
||||
Regeln:
|
||||
- Von der MEHRHEIT der {n} Agenten übernommen und keine Dublette zum Konsens → `aufnehmen`.
|
||||
- Von NIEMANDEM übernommen oder Dublette zum Konsens → verwerfen (in keine Liste).
|
||||
- Uneindeutig (Minderheits-Votum, fachlich unklar) → `rest`. Diese Einträge gehen in eine weitere Runde.{final}
|
||||
- Übernimm Einträge wörtlich ("Titel — Kurzbeschreibung"), nicht umformulieren.
|
||||
- Sind alle Einträge entschieden, ist `rest` LEER — das ist ein gültiges Ergebnis.
|
||||
|
||||
Schreibe NUR die JSON-Datei nach: {out_path}
|
||||
|
||||
Format (kein weiterer Text in der Datei):
|
||||
{{"aufnehmen": ["Titel — Kurzbeschreibung"], "rest": ["Titel — Kurzbeschreibung"]}}
|
||||
@@ -1,19 +0,0 @@
|
||||
Bei der Auswahl der Bausteine zum Thema "{topic}" sind einige Einträge strittig. Entscheide für jeden strittigen Eintrag: aufnehmen oder verwerfen.
|
||||
|
||||
BESCHLOSSENER KONSENS (nur Kontext — nicht ändern):
|
||||
{konsens}
|
||||
|
||||
STRITTIGE EINTRÄGE:
|
||||
{rest}
|
||||
|
||||
Regeln:
|
||||
- AUFNEHMEN, wenn der Eintrag eine eigenständige Lerneinheit des Themas ist, die der Konsens nicht abdeckt — auch nicht unter anderem Titel oder innerhalb eines Sammeleintrags.
|
||||
- VERWERFEN, wenn er eine Dublette zum Konsens ist, nur eine Variante/Vertiefung eines vorhandenen Bausteins, Mikro-Granularität oder fachlich zweifelhaft.
|
||||
- Übernimm aufgenommene Einträge wörtlich ("Titel — Kurzbeschreibung"), nicht umformulieren.
|
||||
- Nichts Aufnehmenswertes dabei → leere Liste. Das ist ein gültiges Ergebnis.
|
||||
|
||||
Schreibe NUR die JSON-Datei nach: {out_path}
|
||||
|
||||
Format (kein weiterer Text in der Datei):
|
||||
{{"uebernehmen": ["Titel — Kurzbeschreibung"]}}
|
||||
Nichts aufnehmen: {{"uebernehmen": []}}
|
||||
@@ -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}
|
||||
@@ -1,17 +0,0 @@
|
||||
Zum Thema "{topic}" sind unten Bausteine, die in der Recherche nur EINMAL genannt wurden. Die Quelle wurde vollständig durchsucht. Entscheide jeden Eintrag einzeln.
|
||||
|
||||
BEREITS BESTÄTIGTES INVENTAR (nur Kontext — nicht ändern, nicht duplizieren):
|
||||
{konsens}
|
||||
|
||||
EINZELN GENANNTE EINTRÄGE (jeweils entscheiden):
|
||||
{rest}
|
||||
|
||||
Regeln:
|
||||
- Fachlich valider, eigenständiger Baustein und KEINE Dublette/Variante des Inventars → `aufnehmen`.
|
||||
- Erfunden, zu vage, oder Dublette/Variante eines Inventar-Bausteins → verwerfen (in keine Liste).
|
||||
- Ü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):
|
||||
{{"aufnehmen": ["Titel — Kurzbeschreibung"], "rest": []}}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"]}}
|
||||
@@ -1,20 +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.
|
||||
- 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}
|
||||
19
templates/Prompt/Block-Chat.md
Normal file
19
templates/Prompt/Block-Chat.md
Normal 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: 1–3 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:".
|
||||
45
templates/Prompt/Block-Exam-Discussion.md
Normal file
45
templates/Prompt/Block-Exam-Discussion.md
Normal 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.
|
||||
31
templates/Prompt/Block-Gapchoice.md
Normal file
31
templates/Prompt/Block-Gapchoice.md
Normal 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}}
|
||||
]}}
|
||||
18
templates/Prompt/Block-Gaptext-Exam.md
Normal file
18
templates/Prompt/Block-Gaptext-Exam.md
Normal 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}}
|
||||
29
templates/Prompt/Block-Gaptext.md
Normal file
29
templates/Prompt/Block-Gaptext.md
Normal 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"]}}
|
||||
25
templates/Prompt/Block-Pruefen.md
Normal file
25
templates/Prompt/Block-Pruefen.md
Normal file
@@ -0,0 +1,25 @@
|
||||
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}
|
||||
|
||||
SECTION SPECIFICATION (target state):
|
||||
{spec}
|
||||
|
||||
SUBBLOCKS of the section (context):
|
||||
{subblocks}
|
||||
|
||||
WHOLE SECTION (context only — do NOT output it):
|
||||
{context}
|
||||
|
||||
PASSAGE TO CHECK:
|
||||
{snippet}
|
||||
|
||||
{hint}
|
||||
|
||||
Check the passage for genuine flaws and fix them — anything that is fine stays unchanged:
|
||||
- Factual errors, wrong or invented facts/values.
|
||||
- Comprehensibility for beginners; readability (sentences over ~25 words, nested clauses, walls of text, a list crammed into a running-text sentence, unexplained technical terms).
|
||||
- The example is fitting and correct; format per the specification.
|
||||
- Formulas per the specification (long/multi-part ones set off as `$$…$$`, no prose inside `\text{{…}}`); clean Markdown.
|
||||
|
||||
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.
|
||||
32
templates/Prompt/Block-Question-Critique.md
Normal file
32
templates/Prompt/Block-Question-Critique.md
Normal 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 1–2 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"]}}
|
||||
31
templates/Prompt/Block-Question-Variante.md
Normal file
31
templates/Prompt/Block-Question-Variante.md
Normal 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 1–2 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"}}
|
||||
45
templates/Prompt/Block-Question.md
Normal file
45
templates/Prompt/Block-Question.md
Normal 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 1–2 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"}}
|
||||
32
templates/Prompt/Block-Quiz.md
Normal file
32
templates/Prompt/Block-Quiz.md
Normal 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}}
|
||||
]}}
|
||||
35
templates/Prompt/Block-Rating-Critique.md
Normal file
35
templates/Prompt/Block-Rating-Critique.md
Normal 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 25–49 % · solide 50–74 % · stark 75–99 % · 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"]}}
|
||||
55
templates/Prompt/Block-Rating.md
Normal file
55
templates/Prompt/Block-Rating.md
Normal 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": 25–49 % — a fragment is right, the core is missing.
|
||||
- "solid": 50–74 % — the core is right, details are missing.
|
||||
- "strong": 75–99 % — 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"}}
|
||||
20
templates/Prompt/Blocks-Block-Grouping.md
Normal file
20
templates/Prompt/Blocks-Block-Grouping.md
Normal 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]]}}
|
||||
17
templates/Prompt/Blocks-Dependency.md
Normal file
17
templates/Prompt/Blocks-Dependency.md
Normal file
@@ -0,0 +1,17 @@
|
||||
A small block for the topic "{topic}" may be a sub-topic of a bigger block. Pick the ONE block it belongs under — or 0 if it stands on its own after all.
|
||||
|
||||
SMALL BLOCK:
|
||||
{small}
|
||||
|
||||
CANDIDATE PARENT BLOCKS:
|
||||
{parents}
|
||||
|
||||
Rules:
|
||||
- Pick the number of the block the small block is a detail/sub-step/property of.
|
||||
- 0 = it is NOT a sub-topic of any of them (it is actually standalone).
|
||||
- Only pick a parent if the small block clearly belongs INSIDE it.
|
||||
|
||||
Write ONLY the JSON file to: {out_path}
|
||||
|
||||
Format (the chosen parent number, or 0):
|
||||
{{"parent": 0}}
|
||||
23
templates/Prompt/Blocks-Filter-Check.md
Normal file
23
templates/Prompt/Blocks-Filter-Check.md
Normal file
@@ -0,0 +1,23 @@
|
||||
You are filtering the block inventory of a learning guide for the topic "{topic}". Judge the ONE block below.
|
||||
|
||||
BLOCK:
|
||||
{title} — {content}
|
||||
|
||||
## Question
|
||||
Is this a **valid, self-contained learning block** that genuinely belongs to "{topic}"?
|
||||
|
||||
- **keep = true** → a real, teachable concept of this topic: a method, definition, syntax element, problem, rule, or feature. The default.
|
||||
- **keep = false** → drop it, ONLY if it clearly is one of:
|
||||
- **Off-topic**: not actually about "{topic}" (a stray crawl artifact, a different subject).
|
||||
- **Noise / meta**: navigation, "Table of Contents", "Dos and Don'ts", a tool/website name, a page section — not a concept you would learn.
|
||||
- **Empty / degenerate**: title says nothing teachable, or the content is a non-statement.
|
||||
|
||||
## Rules
|
||||
- When in doubt → **keep** (true). Only drop a CLEAR off-topic / noise / empty case.
|
||||
- Judge by the CONTENT (after the "—"), not only the title.
|
||||
- Do NOT drop something just because it is narrow or overlaps another block — that is handled elsewhere. Drop only off-topic / noise / empty.
|
||||
|
||||
Write ONLY the JSON file to: {out_path}
|
||||
|
||||
Format:
|
||||
{{"keep": true}}
|
||||
39
templates/Prompt/Blocks-Filter.md
Normal file
39
templates/Prompt/Blocks-Filter.md
Normal 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}}}}
|
||||
32
templates/Prompt/Blocks-Klaerung.md
Normal file
32
templates/Prompt/Blocks-Klaerung.md
Normal 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": []}}
|
||||
15
templates/Prompt/Blocks-Naming-Check.md
Normal file
15
templates/Prompt/Blocks-Naming-Check.md
Normal file
@@ -0,0 +1,15 @@
|
||||
The numbered entries below all describe the SAME block for the topic "{topic}". Entry number {current} was chosen as the canonical title. Check whether that is the best choice — if another entry is a clearly better canonical name, pick it instead.
|
||||
|
||||
MEMBERS:
|
||||
{members}
|
||||
|
||||
Rules:
|
||||
- Pick an EXISTING entry number — do NOT invent a title.
|
||||
- Best = most concrete, precise, self-explanatory, established term for the shared concept.
|
||||
- If the current choice ({current}) is already the best, return it unchanged.
|
||||
- When in doubt, keep the current choice.
|
||||
|
||||
Write ONLY the JSON file to: {out_path}
|
||||
|
||||
Format (the best member number, nothing else):
|
||||
{{"best": {current}}}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user