Compare commits
115 Commits
54eaa1c89b
...
v1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e888c216a | ||
|
|
6a5a1cccd9 | ||
|
|
cec8d1ee5f | ||
|
|
cd1c926599 | ||
|
|
9a02e8dc76 | ||
|
|
0eaa72c536 | ||
|
|
c07e1e9e30 | ||
|
|
8fe0e8581f | ||
|
|
28d0b494cd | ||
|
|
b3b5dbf37d | ||
|
|
c5543c0529 | ||
|
|
36b7aabdb0 | ||
|
|
0951337a29 | ||
|
|
bd1de0770a | ||
|
|
613a4a2b1f | ||
|
|
0adc223c0a | ||
|
|
04900eef55 | ||
|
|
2d1a3d3e37 | ||
|
|
4f1f8d18d0 | ||
|
|
b54f5e23f3 | ||
|
|
bf9eea967b | ||
|
|
2e33fa5c47 | ||
|
|
b4c686f74f | ||
|
|
50b1c81bca | ||
|
|
8ba4b94498 | ||
|
|
885c4811d0 | ||
|
|
befd509c08 | ||
|
|
3965bc5bf9 | ||
|
|
920046b774 | ||
|
|
89a853981d | ||
|
|
76ae2161e1 | ||
|
|
1910f730d2 | ||
|
|
20831e1399 | ||
|
|
ba529e61a8 | ||
|
|
0868a768a2 | ||
|
|
26e097dc5a | ||
|
|
3168ec9094 | ||
|
|
1fba9ba898 | ||
|
|
bf6ab18ffd | ||
|
|
d80b22d53e | ||
|
|
f24054ba56 | ||
|
|
30b28290ca | ||
|
|
a17ea155b8 | ||
|
|
ed006976d9 | ||
|
|
d067cb8b54 | ||
|
|
9c0f622e0c | ||
|
|
d8932c90d6 | ||
|
|
3df5976d3d | ||
|
|
b8f08ed71b | ||
|
|
af6fb7a9c2 | ||
|
|
59d108a13a | ||
|
|
993552efe5 | ||
|
|
9f7553edbc | ||
|
|
4b0cf1ef29 | ||
|
|
1aba312242 | ||
|
|
229880b7e6 | ||
|
|
3c839861e7 | ||
|
|
dce9156ac8 | ||
|
|
0ff33271a0 | ||
|
|
c487e9cfcd | ||
|
|
2f702477c7 | ||
|
|
18b0a32f1e | ||
|
|
d81bba9644 | ||
|
|
cc68bdd242 | ||
|
|
229ed93c5d | ||
|
|
61a22a4d9f | ||
|
|
76a405bab9 | ||
|
|
7a524ddb99 | ||
|
|
b3097a6143 | ||
|
|
a503285b6f | ||
|
|
5268c77fde | ||
|
|
f30c7a6456 | ||
|
|
0a96705453 | ||
|
|
5fea458d66 | ||
|
|
390ec12bbf | ||
|
|
e8214598ea | ||
|
|
d6b748cb94 | ||
|
|
c00580c261 | ||
|
|
b592944497 | ||
|
|
466818c47c | ||
|
|
19b520a3b1 | ||
|
|
44e04315cc | ||
|
|
47be019f02 | ||
|
|
a6d249acf6 | ||
|
|
003f6d3b8e | ||
|
|
33a4440404 | ||
|
|
25a07ede4d | ||
|
|
08dd0ccd69 | ||
|
|
6e5d673ca7 | ||
|
|
54adcdc50c | ||
|
|
77fd6156f6 | ||
|
|
143e6d6f7c | ||
|
|
2b89e21cd3 | ||
|
|
822f6ee3e9 | ||
|
|
8382d6f27a | ||
|
|
a7fd345bb6 | ||
|
|
0ba708dc54 | ||
|
|
78d5833fe4 | ||
|
|
cfc666055c | ||
|
|
bc7c2c8b40 | ||
|
|
700ba1e0e8 | ||
|
|
2c426e6ac4 | ||
|
|
5c35939eab | ||
|
|
601237bbbf | ||
|
|
f4c16eed84 | ||
|
|
5702108d28 | ||
|
|
0b4a086e89 | ||
|
|
c0b7d236bb | ||
|
|
38db80296c | ||
|
|
63280d88d6 | ||
|
|
32f6fab16b | ||
|
|
d97ec48bf1 | ||
|
|
fb5fc7bff9 | ||
|
|
e3cf9a83f4 | ||
|
|
693475128c |
10
Dockerfile
10
Dockerfile
@@ -23,7 +23,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
RUN useradd -m -u 1000 app
|
||||
|
||||
COPY backend/requirements.txt /app/backend/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /app/backend/requirements.txt
|
||||
# torch als CPU-Build (~190 MB) — eigener Index, sonst zieht pip die CUDA-Variante (~2,5 GB).
|
||||
# Fürs Lesbarkeits-Gate (transformers nutzt das vorhandene torch).
|
||||
RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu \
|
||||
&& pip install --no-cache-dir -r /app/backend/requirements.txt
|
||||
|
||||
# Chromium + OS-Libs für Playwright (als root) in ein gemeinsames, welt-lesbares Verzeichnis.
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||
RUN python3 -m playwright install --with-deps chromium \
|
||||
&& chmod -R a+rX /ms-playwright
|
||||
|
||||
COPY --chown=app:app backend/ /app/backend/
|
||||
COPY --chown=app:app templates/ /app/templates/
|
||||
|
||||
27
Makefile
27
Makefile
@@ -1,10 +1,10 @@
|
||||
.PHONY: install dev prod stop logs remove auth sync searxng ollama
|
||||
.PHONY: install dev prod stop logs remove auth sync sync-projects sync-all projects searxng ollama
|
||||
|
||||
COMPOSE = docker compose
|
||||
|
||||
auth:
|
||||
@mkdir -p .claude-data storage projects
|
||||
@chown -R 1000:1000 .claude-data storage projects
|
||||
@mkdir -p .claude-data storage projects uni
|
||||
@chown -R 1000:1000 .claude-data storage projects uni
|
||||
@grep -q "^CLAUDE_CODE_OAUTH_TOKEN=.\+" .env 2>/dev/null \
|
||||
|| echo "WARNUNG: CLAUDE_CODE_OAUTH_TOKEN fehlt in .env (Claude-Provider inaktiv)."
|
||||
@grep -q "^MINIMAX_API_KEY=.\+" .env 2>/dev/null \
|
||||
@@ -12,7 +12,10 @@ auth:
|
||||
@echo "Verzeichnisse angelegt und auf uid 1000 chowned."
|
||||
|
||||
install:
|
||||
pip install --break-system-packages fastapi uvicorn[standard] aiosqlite uv
|
||||
pip install --break-system-packages fastapi uvicorn[standard] aiosqlite uv playwright transformers
|
||||
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."
|
||||
@which pdftotext >/dev/null 2>&1 || sudo apt-get install -y poppler-utils
|
||||
cd frontend && npm install
|
||||
npm install -g opencode-ai
|
||||
@@ -58,8 +61,22 @@ ollama:
|
||||
ollama pull qwen3.5:9b
|
||||
@echo "Ollama bereit — Provider 'Lokal' ist aktiv (Modelle anpassen: backend/config.py + dev-ops/opencode.json)."
|
||||
|
||||
sync:
|
||||
sync: stop
|
||||
@mkdir -p storage/themen
|
||||
@rm -f storage/creator.db-shm storage/creator.db-wal
|
||||
rsync -avz --progress root@178.104.67.87:/var/www/creator/storage/creator.db storage/
|
||||
rsync -avz --progress root@178.104.67.87:/var/www/creator/storage/creator.db-wal storage/
|
||||
rsync -avz --progress --delete root@178.104.67.87:/var/www/creator/storage/themen/ storage/themen/
|
||||
@echo "Sync abgeschlossen."
|
||||
|
||||
# Projekte vom Server holen: `make sync-projects` (auch: `make sync projects`).
|
||||
# Mit --delete — exakter Spiegel: lokale Projekte ohne Server-Pendant werden gelöscht.
|
||||
sync-projects:
|
||||
@mkdir -p projects
|
||||
rsync -avz --progress --delete root@178.104.67.87:/var/www/creator/projects/ projects/
|
||||
@echo "Projekt-Sync abgeschlossen."
|
||||
|
||||
sync-all: sync sync-projects
|
||||
@echo "Voller Sync (Themen + Projekte) abgeschlossen."
|
||||
|
||||
projects: sync-projects
|
||||
|
||||
@@ -5,17 +5,51 @@ jeweilige Provider fehl — der andere läuft unverändert weiter.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from config import PROVIDERS, DEFAULT_PROVIDER
|
||||
from config import PROVIDERS, DEFAULT_PROVIDER, MAX_CONCURRENT_AGENTS, MAX_CONCURRENT_INTERACTIVE
|
||||
|
||||
log = logging.getLogger("creator.agents")
|
||||
|
||||
_active_processes: dict[str, asyncio.subprocess.Process] = {}
|
||||
|
||||
# Abgebrochene Scopes (Schlüssel-Präfixe, symmetrisch zu kill_process). Ein Agent, dessen
|
||||
# Key mit einem dieser Präfixe beginnt, bricht VOR dem Spawn ab — so werden auch in der
|
||||
# Semaphore-Schlange WARTENDE Agenten beim Abbruch sofort gestoppt, statt noch zu starten.
|
||||
_cancelled_prefixes: set[str] = set()
|
||||
|
||||
|
||||
def cancel_scope(prefix: str) -> None:
|
||||
_cancelled_prefixes.add(prefix)
|
||||
|
||||
|
||||
def clear_scope(prefix: str) -> None:
|
||||
_cancelled_prefixes.discard(prefix)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# 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.
|
||||
_opencode_start_lock = asyncio.Lock()
|
||||
_OPENCODE_START_DELAY = 1.0
|
||||
|
||||
# Capability → Claude --allowedTools
|
||||
_CLAUDE_TOOLS = {
|
||||
"full": "Write,Bash,Read,WebSearch,WebFetch",
|
||||
@@ -51,11 +85,27 @@ def provider_available(provider: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
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())."""
|
||||
try:
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
try:
|
||||
process.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
|
||||
def kill_process(agent_key_prefix: str) -> None:
|
||||
"""Killt alle aktiven Prozesse, deren Key mit dem Prefix beginnt (deckt -plan/-w1… ab)."""
|
||||
for key, process in list(_active_processes.items()):
|
||||
if key.startswith(agent_key_prefix) and process.returncode is None:
|
||||
process.kill()
|
||||
if process.returncode is not None: # tote Einträge beim Iterieren aufräumen
|
||||
_active_processes.pop(key, None)
|
||||
continue
|
||||
if key.startswith(agent_key_prefix):
|
||||
log.debug("kill agent %s", key)
|
||||
_kill(process)
|
||||
|
||||
|
||||
async def run_agent(
|
||||
@@ -65,23 +115,41 @@ async def run_agent(
|
||||
provider: str = DEFAULT_PROVIDER,
|
||||
role: str = "fast",
|
||||
capabilities: str = "none",
|
||||
lane: str = "batch",
|
||||
) -> tuple[int, str, str]:
|
||||
if _scope_cancelled(agent_key): # vor dem Anstehen: gar nicht erst in die Schlange
|
||||
return 1, "", "abgebrochen"
|
||||
if provider not in PROVIDERS:
|
||||
return 1, "", f"Unbekannter Provider: {provider}"
|
||||
if shutil.which(PROVIDERS[provider]["cli"]) is None:
|
||||
return 1, "", f"CLI '{PROVIDERS[provider]['cli']}' nicht installiert (Provider: {provider})"
|
||||
if PROVIDERS[provider]["cli"] == "opencode":
|
||||
return await _run_opencode(agent_key, prompt, timeout, provider, role, capabilities)
|
||||
return await _run_claude_cli(agent_key, prompt, timeout, role, capabilities)
|
||||
sem = _interactive_sem if lane == "interactive" else _batch_sem
|
||||
async with sem:
|
||||
if _scope_cancelled(agent_key): # nach dem Acquire: in der Schlange abgebrochen → kein Spawn
|
||||
return 1, "", "abgebrochen"
|
||||
if PROVIDERS[provider]["cli"] == "opencode":
|
||||
return await _run_opencode(agent_key, prompt, timeout, provider, role, capabilities)
|
||||
return await _run_claude_cli(agent_key, prompt, timeout, role, capabilities)
|
||||
|
||||
|
||||
async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None, timeout: int) -> tuple[int, str, str]:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdin=asyncio.subprocess.PIPE if stdin_data is not None else asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None, timeout: int, stagger: bool = False) -> tuple[int, str, str]:
|
||||
start = time.monotonic()
|
||||
|
||||
async def spawn():
|
||||
return await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
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
|
||||
)
|
||||
|
||||
if stagger:
|
||||
async with _opencode_start_lock:
|
||||
process = await spawn()
|
||||
await asyncio.sleep(_OPENCODE_START_DELAY)
|
||||
else:
|
||||
process = await spawn()
|
||||
_active_processes[agent_key] = process
|
||||
try:
|
||||
try:
|
||||
@@ -90,15 +158,23 @@ async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None,
|
||||
timeout=timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
_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)
|
||||
raise
|
||||
log.info(
|
||||
"agent %s: exit %s nach %.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:
|
||||
_active_processes.pop(agent_key, None)
|
||||
# Pop nur bei Identität: ein Slot-Restart unter demselben Key darf den
|
||||
# NEUEN Prozess nicht aus dem Tracking werfen.
|
||||
if _active_processes.get(agent_key) is process:
|
||||
del _active_processes[agent_key]
|
||||
|
||||
|
||||
async def _run_claude_cli(agent_key: str, prompt: str, timeout: int, role: str, capabilities: str) -> tuple[int, str, str]:
|
||||
@@ -128,7 +204,7 @@ async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str
|
||||
"-f", str(prompt_path),
|
||||
]
|
||||
try:
|
||||
rc, stdout, stderr = await _communicate(agent_key, cmd, None, timeout)
|
||||
rc, stdout, stderr = await _communicate(agent_key, cmd, None, timeout, stagger=True)
|
||||
return rc, _clean_opencode_output(stdout), stderr
|
||||
finally:
|
||||
prompt_path.unlink(missing_ok=True)
|
||||
|
||||
1738
backend/bausteine.py
Normal file
1738
backend/bausteine.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,60 +6,105 @@ STORAGE_DIR = PROJECT_ROOT / "storage"
|
||||
FRONTEND_DIST = PROJECT_ROOT / "frontend" / "dist"
|
||||
DB_PATH = STORAGE_DIR / "creator.db"
|
||||
PROJECTS_DIR = PROJECT_ROOT / "projects"
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
"clubs", "events", "podcasts", "resources", "-u-",
|
||||
"academy", "pricing", "/plans", "career", "newsletter", "impressum", "login",
|
||||
]
|
||||
CRAWL_MIN_CHARS = 400 # zu wenig Text → raus
|
||||
|
||||
# Timeouts pro Agenten-Schritt: (Basis-Sekunden, Sekunden pro Baustein/Section).
|
||||
# Gilt für alle Provider gleich — wer zu langsam ist, wird neu gestartet bzw. überholt.
|
||||
TIMEOUTS = {
|
||||
"recherche": (1800, 0), # fix 30 min
|
||||
"auswahl": (600, 10),
|
||||
"auswahl_check": (300, 2),
|
||||
"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)
|
||||
"guide_auswahl": (300, 5), # pro Baustein im Inventar
|
||||
"guide_check": (300, 2), # Auswahl-/Gliederungs-Prüfung (nur Titellisten)
|
||||
"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
|
||||
"onepager_recherche": (900, 0),
|
||||
"onepager_bauen": (300, 0),
|
||||
"onepager_verify": (300, 0),
|
||||
}
|
||||
|
||||
# Auswahl-Auftrag je Format: (Mindest-Anteil, Maximal-Anteil, Mindestanzahl, Zweck).
|
||||
FORMAT_ANTEIL = {
|
||||
"MiniGuide": (0.05, 0.10, 8, "einen kompakten Anfänger-Guide — der schnelle Einstieg ins Thema"),
|
||||
"Guide": (0.25, 0.35, 20, "einen ausführlichen Anfänger-Guide — ein solides Fundament im Thema"),
|
||||
"FullGuide": (0.90, 1.00, 0, "einen Komplett-Guide — das ganze Thema"),
|
||||
# Zweck je Format — fließt in den Gliederungs-Judge (was der Guide leisten soll).
|
||||
FORMAT_ZWECK = {
|
||||
"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" = Urteilsaufgaben mit kleinem Output (Auswahl, Final, OnePager, Chat),
|
||||
# "guide" = große Generierung (Plan, Writer).
|
||||
# "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).
|
||||
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
|
||||
"quick": "claude-sonnet-4-6",
|
||||
"env_key": None, # Auth via CLAUDE_CODE_OAUTH_TOKEN oder ~/.claude
|
||||
},
|
||||
# "minimax-kalt/…" ist KEIN eigener Stack, nur ein opencode-Provider-Eintrag
|
||||
# (dev-ops/opencode.json) mit niedriger Temperature; M3 dort ohne Thinking.
|
||||
"minimax": {
|
||||
"cli": "opencode",
|
||||
"guide": "minimax/MiniMax-M3",
|
||||
"fast": "minimax/MiniMax-M2.7-highspeed",
|
||||
"quick": "minimax/MiniMax-M2.7-highspeed",
|
||||
"env_key": "MINIMAX_API_KEY",
|
||||
},
|
||||
# Wie "minimax", aber Chat/Elemente (Rolle "fast") laufen auf M3 OHNE Thinking.
|
||||
# M2.x kann Thinking nicht abschalten — nur M3 respektiert thinking:disabled.
|
||||
# guide/quick bleiben identisch zur Thinking-Variante.
|
||||
"minimax-direkt": {
|
||||
"cli": "opencode",
|
||||
"guide": "minimax/MiniMax-M3",
|
||||
"fast": "minimax-direkt/MiniMax-M3",
|
||||
"fast": "minimax-kalt/MiniMax-M2.7-highspeed",
|
||||
"judge": "minimax-kalt/MiniMax-M3",
|
||||
"quick": "minimax/MiniMax-M2.7-highspeed",
|
||||
"env_key": "MINIMAX_API_KEY",
|
||||
},
|
||||
@@ -67,6 +112,7 @@ PROVIDERS = {
|
||||
"cli": "opencode",
|
||||
"guide": "ollama/qwen3.6:27b",
|
||||
"fast": "ollama/qwen3.5:9b",
|
||||
"judge": "ollama/qwen3.5:9b",
|
||||
"quick": "ollama/qwen3.5:9b",
|
||||
"env_key": None,
|
||||
"check_url": "http://localhost:11434/api/tags", # Ollama erreichbar?
|
||||
|
||||
113
backend/crawl.py
Normal file
113
backend/crawl.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Geboundeter Domain-Crawler für Link-Quellen — rendert 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).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import urldefrag, urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
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
|
||||
_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ß."""
|
||||
try:
|
||||
req = Request(url, headers={"User-Agent": _UA})
|
||||
with urlopen(req, timeout=SEITE_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)
|
||||
return None
|
||||
|
||||
|
||||
def _name(url: str, ext: str) -> str:
|
||||
h = hashlib.md5(url.encode("utf-8")).hexdigest()[:8]
|
||||
slug = re.sub(r"[^a-zA-Z0-9]+", "-", urlparse(url).path).strip("-")[:60] or "index"
|
||||
return f"{h}-{slug}{ext}"
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
"""
|
||||
# Lazy: so startet das Backend auch ohne installiertes Playwright; nur das Crawlen schlägt dann fehl.
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ziel.mkdir(parents=True, exist_ok=True)
|
||||
domain = urlparse(start_url).netloc
|
||||
gesehen: set[str] = set()
|
||||
queue: list[tuple[str, int]] = [(urldefrag(start_url)[0], 0)]
|
||||
gespeichert = 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:
|
||||
if cancelled and cancelled():
|
||||
return gespeichert # Abbruch → KEIN .done-Marker → Neustart crawlt neu
|
||||
url, tiefe = queue.pop(0)
|
||||
if url in gesehen:
|
||||
continue
|
||||
gesehen.add(url)
|
||||
|
||||
# PDFs brauchen kein Rendering — direkt laden.
|
||||
if _is_pdf(url):
|
||||
data = _fetch_bytes(url)
|
||||
if data:
|
||||
p = ziel / _name(url, ".pdf")
|
||||
if not p.exists():
|
||||
p.write_bytes(data)
|
||||
gespeichert += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
page.goto(url, wait_until="networkidle", timeout=SEITE_TIMEOUT * 1000)
|
||||
except Exception as e:
|
||||
log.debug("crawl: goto unvollständig %s: %s", url, e) # trotzdem versuchen, Inhalt zu lesen
|
||||
try:
|
||||
text = page.inner_text("body").strip()
|
||||
except Exception:
|
||||
text = ""
|
||||
if text:
|
||||
atomic_write_text(ziel / _name(url, ".txt"), f"QUELLE: {url}\n\n{text}")
|
||||
gespeichert += 1
|
||||
if tiefe < max_tiefe:
|
||||
try:
|
||||
hrefs = page.eval_on_selector_all("a[href]", "els => els.map(e => e.href)")
|
||||
except Exception:
|
||||
hrefs = []
|
||||
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))
|
||||
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
|
||||
@@ -42,13 +42,134 @@ CREATE TABLE IF NOT EXISTS elements (
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
examples TEXT NOT NULL DEFAULT '[]',
|
||||
hints TEXT NOT NULL DEFAULT '[]',
|
||||
aufgabe TEXT NOT NULL DEFAULT '',
|
||||
loesung TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
|
||||
CREATE_BAUSTEIN_TEXTE = """
|
||||
CREATE TABLE IF NOT EXISTS baustein_texte (
|
||||
topic TEXT NOT NULL,
|
||||
baustein TEXT NOT NULL,
|
||||
art TEXT NOT NULL,
|
||||
md TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (topic, baustein, art)
|
||||
)
|
||||
"""
|
||||
|
||||
CREATE_BAUSTEIN_PROGRESS = """
|
||||
CREATE TABLE IF NOT EXISTS baustein_progress (
|
||||
topic TEXT NOT NULL,
|
||||
baustein TEXT NOT NULL,
|
||||
gute_antworten INTEGER NOT NULL DEFAULT 0,
|
||||
streak INTEGER NOT NULL DEFAULT 0,
|
||||
absolviert TEXT,
|
||||
verstanden TEXT,
|
||||
gemeistert TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (topic, baustein)
|
||||
)
|
||||
"""
|
||||
|
||||
# --- Bausteine-Pipeline-Inhalt (ersetzt Datei-Sidecars) ---
|
||||
|
||||
# Inventar: ein Baustein je (topic, titel_norm). nennungen = Anzahl Agenten/Runden,
|
||||
# die ihn nannten (≥2 = Konsens). status: kandidat/konsens/rest/verworfen.
|
||||
CREATE_BAUSTEINE = """
|
||||
CREATE TABLE IF NOT EXISTS bausteine (
|
||||
topic TEXT NOT NULL,
|
||||
titel_norm TEXT NOT NULL,
|
||||
titel TEXT NOT NULL,
|
||||
beschreibung TEXT NOT NULL DEFAULT '',
|
||||
nennungen INTEGER NOT NULL DEFAULT 1,
|
||||
status TEXT NOT NULL DEFAULT 'kandidat',
|
||||
quellen TEXT NOT NULL DEFAULT '[]',
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (topic, titel_norm)
|
||||
)
|
||||
"""
|
||||
|
||||
# Subbausteine je Baustein. stufe (einfach/mittel/schwer) + relevanz (relevant/rand)
|
||||
# werden später gesetzt. nennungen analog zum Inventar.
|
||||
CREATE_SUBBAUSTEINE = """
|
||||
CREATE TABLE IF NOT EXISTS subbausteine (
|
||||
topic TEXT NOT NULL,
|
||||
baustein_norm TEXT NOT NULL,
|
||||
sub_norm TEXT NOT NULL,
|
||||
baustein TEXT NOT NULL,
|
||||
sub_titel TEXT NOT NULL,
|
||||
nennungen INTEGER NOT NULL DEFAULT 1,
|
||||
stufe TEXT,
|
||||
relevanz TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'kandidat',
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (topic, baustein_norm, sub_norm)
|
||||
)
|
||||
"""
|
||||
|
||||
# Ein Frage-Muster je (Baustein, Subbaustein, Typ).
|
||||
CREATE_FRAGE_MUSTER = """
|
||||
CREATE TABLE IF NOT EXISTS frage_muster (
|
||||
topic TEXT NOT NULL,
|
||||
baustein_norm TEXT NOT NULL,
|
||||
sub_norm TEXT NOT NULL,
|
||||
baustein TEXT NOT NULL,
|
||||
sub_titel TEXT NOT NULL,
|
||||
typ TEXT NOT NULL,
|
||||
frage TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (topic, baustein_norm, sub_norm, typ)
|
||||
)
|
||||
"""
|
||||
|
||||
# Crawl-Seiten je Thema: inhalt (1=Content/0=Noise, von der Sichtung) + gelesen (von der Recherche).
|
||||
CREATE_RECHERCHE_COVERAGE = """
|
||||
CREATE TABLE IF NOT EXISTS recherche_coverage (
|
||||
topic TEXT NOT NULL,
|
||||
quelle TEXT NOT NULL,
|
||||
gelesen INTEGER NOT NULL DEFAULT 0,
|
||||
inhalt INTEGER,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (topic, quelle)
|
||||
)
|
||||
"""
|
||||
|
||||
# Schritt-Status der Pipeline (ersetzt Datei-Existenz-Resume + Reset-Globs).
|
||||
CREATE_PIPELINE_STATE = """
|
||||
CREATE TABLE IF NOT EXISTS pipeline_state (
|
||||
topic TEXT NOT NULL,
|
||||
schritt TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'offen',
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (topic, schritt)
|
||||
)
|
||||
"""
|
||||
|
||||
# Fertiger Guide-Inhalt je (Thema, Format) als JSON-Blob (ersetzt die Guide-JSON-Datei).
|
||||
# Geteilt über alle Guide-Läufe desselben Thema+Formats (wie zuvor die Content-Datei).
|
||||
CREATE_GUIDE_CONTENT = """
|
||||
CREATE TABLE IF NOT EXISTS guide_content (
|
||||
topic TEXT NOT NULL,
|
||||
format TEXT NOT NULL,
|
||||
json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (topic, format)
|
||||
)
|
||||
"""
|
||||
|
||||
# Quellen-Wahl je Thema (ersetzt quelle.json).
|
||||
CREATE_QUELLE = """
|
||||
CREATE TABLE IF NOT EXISTS quelle (
|
||||
topic TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
ort TEXT NOT NULL DEFAULT '',
|
||||
spec TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
|
||||
_db: aiosqlite.Connection | None = None
|
||||
|
||||
|
||||
@@ -62,23 +183,71 @@ async def get_db() -> aiosqlite.Connection:
|
||||
|
||||
async def init_db():
|
||||
db = await get_db()
|
||||
# WAL übersteht Crashes deutlich besser; busy_timeout fängt kurze Locks ab.
|
||||
await db.execute("PRAGMA journal_mode=WAL")
|
||||
await db.execute("PRAGMA busy_timeout=5000")
|
||||
await db.execute(CREATE_GUIDES)
|
||||
await db.execute(CREATE_PROGRESS)
|
||||
await db.execute(CREATE_TOPICS)
|
||||
await db.execute(CREATE_ELEMENTS)
|
||||
await db.execute(CREATE_BAUSTEIN_TEXTE)
|
||||
await db.execute(CREATE_BAUSTEIN_PROGRESS)
|
||||
await db.execute(CREATE_BAUSTEINE)
|
||||
await db.execute(CREATE_SUBBAUSTEINE)
|
||||
await db.execute(CREATE_FRAGE_MUSTER)
|
||||
await db.execute(CREATE_RECHERCHE_COVERAGE)
|
||||
await db.execute(CREATE_PIPELINE_STATE)
|
||||
await db.execute(CREATE_GUIDE_CONTENT)
|
||||
await db.execute(CREATE_QUELLE)
|
||||
try: # Migration für Bestands-DBs ohne step-Spalte
|
||||
await db.execute("ALTER TABLE guides ADD COLUMN step INTEGER")
|
||||
except aiosqlite.OperationalError:
|
||||
pass
|
||||
for col in ("aufgabe", "loesung"): # Migration für Elemente ohne Aufgabe/Lösung
|
||||
try: # Migration: recherche_coverage.inhalt (Content/Noise aus der Sichtung)
|
||||
await db.execute("ALTER TABLE recherche_coverage ADD COLUMN inhalt INTEGER")
|
||||
except aiosqlite.OperationalError:
|
||||
pass
|
||||
try: # Migration für Bestands-DBs ohne verstanden-Spalte (Mastery-Stufe)
|
||||
await db.execute("ALTER TABLE baustein_progress ADD COLUMN verstanden TEXT")
|
||||
except aiosqlite.OperationalError:
|
||||
pass
|
||||
try: # Migration für Bestands-DBs ohne gemeistert-Spalte (Meisterpfad 25)
|
||||
await db.execute("ALTER TABLE baustein_progress ADD COLUMN gemeistert TEXT")
|
||||
except aiosqlite.OperationalError:
|
||||
pass
|
||||
try: # Migration für Bestands-DBs ohne streak-Spalte (persistente Streak-Bonus-Folge)
|
||||
await db.execute("ALTER TABLE baustein_progress ADD COLUMN streak INTEGER NOT NULL DEFAULT 0")
|
||||
except aiosqlite.OperationalError:
|
||||
pass
|
||||
# Offene-Frage-Anker: Basis/Streak VOR der aktuell offenen Frage — macht die Bewertung
|
||||
# serverseitig idempotent (Re-Bewertung) und driftfrei über Fragen hinweg.
|
||||
for _spalte, _typ in (("offene_frage", "TEXT"), ("offene_basis", "INTEGER"), ("offene_streak", "INTEGER")):
|
||||
try:
|
||||
await db.execute(f"ALTER TABLE elements ADD COLUMN {col} TEXT NOT NULL DEFAULT ''")
|
||||
await db.execute(f"ALTER TABLE baustein_progress ADD COLUMN {_spalte} {_typ}")
|
||||
except aiosqlite.OperationalError:
|
||||
pass
|
||||
# Migration: alte vertiefungen-Tabelle → baustein_texte (Bestand = lange Form, art 'deepdive')
|
||||
cursor = await db.execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'vertiefungen'")
|
||||
if await cursor.fetchone():
|
||||
await db.execute(
|
||||
"INSERT OR IGNORE INTO baustein_texte (topic, baustein, art, md, created_at, updated_at) "
|
||||
"SELECT topic, baustein, 'deepdive', md, created_at, updated_at FROM vertiefungen"
|
||||
)
|
||||
await db.execute("DROP TABLE vertiefungen")
|
||||
await db.execute(
|
||||
"UPDATE guides SET status = 'error', progress = NULL, error_msg = 'Server-Neustart' "
|
||||
"WHERE status IN ('queued', 'generating')"
|
||||
)
|
||||
# Milestone-Migration auf Score-Skala 0–30: absolviert≥6, verstanden≥12, gemeistert≥18.
|
||||
# Leitet die Meilensteine aus dem aktuellen Score ab (bestehende Timestamps bleiben),
|
||||
# idempotent — nach einem Lauf stabil, im Normalbetrieb deckungsgleich mit der Prüf-Route.
|
||||
await db.execute("UPDATE baustein_progress SET gute_antworten = 30 WHERE gute_antworten > 30") # Alt-Scores deckeln
|
||||
for _spalte, _schwelle in (("absolviert", 6), ("verstanden", 12), ("gemeistert", 18)):
|
||||
await db.execute(
|
||||
f"UPDATE baustein_progress SET {_spalte} = COALESCE({_spalte}, datetime('now')) "
|
||||
f"WHERE gute_antworten >= {_schwelle}"
|
||||
)
|
||||
await db.execute(f"UPDATE baustein_progress SET {_spalte} = NULL WHERE gute_antworten < {_schwelle}")
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -173,8 +342,8 @@ def _element_row(row, cursor) -> dict:
|
||||
async def create_element(element: dict) -> dict:
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"""INSERT INTO elements (id, topic, title, description, examples, hints, aufgabe, loesung, created_at, updated_at)
|
||||
VALUES (:id, :topic, :title, :description, :examples, :hints, :aufgabe, :loesung, :created_at, :updated_at)""",
|
||||
"""INSERT INTO elements (id, topic, title, description, examples, hints, created_at, updated_at)
|
||||
VALUES (:id, :topic, :title, :description, :examples, :hints, :created_at, :updated_at)""",
|
||||
{**element, "examples": json.dumps(element["examples"], ensure_ascii=False),
|
||||
"hints": json.dumps(element["hints"], ensure_ascii=False)},
|
||||
)
|
||||
@@ -220,6 +389,17 @@ async def delete_element(element_id: str) -> bool:
|
||||
|
||||
# --- Kapitel-Fortschritt ---
|
||||
|
||||
async def list_progress_all() -> dict[str, set[str]]:
|
||||
"""Kompletter Kapitel-Fortschritt in einem Query: guide_id → Kapitel-Titel."""
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT guide_id, chapter FROM guide_progress")
|
||||
rows = await cursor.fetchall()
|
||||
out: dict[str, set[str]] = {}
|
||||
for guide_id, chapter in rows:
|
||||
out.setdefault(guide_id, set()).add(chapter)
|
||||
return out
|
||||
|
||||
|
||||
async def list_progress(guide_id: str) -> list[str]:
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
@@ -248,3 +428,445 @@ async def delete_progress(guide_id: str) -> None:
|
||||
db = await get_db()
|
||||
await db.execute("DELETE FROM guide_progress WHERE guide_id = ?", (guide_id,))
|
||||
await db.commit()
|
||||
|
||||
|
||||
# --- Baustein-Lernen: Vertiefungen + Prüfungs-Fortschritt ---
|
||||
|
||||
def _now() -> str:
|
||||
from datetime import datetime, timezone
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
async def list_baustein_progress(topic: str) -> list[dict]:
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT baustein, gute_antworten, streak, absolviert, verstanden, gemeistert FROM baustein_progress WHERE topic = ?", (topic,)
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [{"baustein": b, "gute_antworten": n, "streak": s, "absolviert": a, "verstanden": v, "gemeistert": m} for b, n, s, a, v, m in rows]
|
||||
|
||||
|
||||
async def get_baustein_progress(topic: str, baustein: str) -> dict:
|
||||
"""Eine Baustein-Zeile inkl. Offene-Frage-Anker. Defaults, falls noch keine existiert."""
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT gute_antworten, streak, absolviert, verstanden, gemeistert, "
|
||||
"offene_frage, offene_basis, offene_streak FROM baustein_progress "
|
||||
"WHERE topic = ? AND baustein = ?",
|
||||
(topic, baustein),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
return {"gute_antworten": 0, "streak": 0, "absolviert": None, "verstanden": None,
|
||||
"gemeistert": None, "offene_frage": None, "offene_basis": None, "offene_streak": None}
|
||||
return {"gute_antworten": row[0], "streak": row[1], "absolviert": row[2], "verstanden": row[3],
|
||||
"gemeistert": row[4], "offene_frage": row[5], "offene_basis": row[6], "offene_streak": row[7]}
|
||||
|
||||
|
||||
async def set_offene_frage(topic: str, baustein: str, frage: str, basis: int, streak: int) -> None:
|
||||
"""Friert Basis + Streak VOR der jetzt offenen Frage ein (Anker für idempotente Re-Bewertung)."""
|
||||
db = await get_db()
|
||||
now = _now()
|
||||
await db.execute(
|
||||
"""INSERT INTO baustein_progress (topic, baustein, offene_frage, offene_basis, offene_streak, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(topic, baustein) DO UPDATE SET
|
||||
offene_frage = excluded.offene_frage, offene_basis = excluded.offene_basis,
|
||||
offene_streak = excluded.offene_streak, updated_at = excluded.updated_at""",
|
||||
(topic, baustein, frage, basis, streak, now),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def set_baustein_score(topic: str, baustein: str, score: int) -> int:
|
||||
"""Setzt den Score absolut (vom Aufrufer geclampt) und liefert ihn zurück."""
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"""INSERT INTO baustein_progress (topic, baustein, gute_antworten, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(topic, baustein) DO UPDATE SET
|
||||
gute_antworten = excluded.gute_antworten, updated_at = excluded.updated_at""",
|
||||
(topic, baustein, score, _now()),
|
||||
)
|
||||
await db.commit()
|
||||
return score
|
||||
|
||||
|
||||
async def set_baustein_streak(topic: str, baustein: str, streak: int) -> None:
|
||||
"""Persistiert die Streak-Folge (Bonus) absolut — überlebt Reload/Session-Wechsel."""
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"""INSERT INTO baustein_progress (topic, baustein, streak, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(topic, baustein) DO UPDATE SET
|
||||
streak = excluded.streak, updated_at = excluded.updated_at""",
|
||||
(topic, baustein, streak, _now()),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def set_baustein_verstanden(topic: str, baustein: str) -> bool:
|
||||
"""Markiert verstanden (Mastery); True nur beim ersten Mal. Sticky wie absolviert."""
|
||||
db = await get_db()
|
||||
now = _now()
|
||||
await db.execute(
|
||||
"INSERT OR IGNORE INTO baustein_progress (topic, baustein, gute_antworten, updated_at) VALUES (?, ?, 0, ?)",
|
||||
(topic, baustein, now),
|
||||
)
|
||||
cursor = await db.execute(
|
||||
"UPDATE baustein_progress SET verstanden = ?, updated_at = ? "
|
||||
"WHERE topic = ? AND baustein = ? AND verstanden IS NULL",
|
||||
(now, now, topic, baustein),
|
||||
)
|
||||
await db.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
async def set_baustein_gemeistert(topic: str, baustein: str) -> bool:
|
||||
"""Markiert gemeistert (Meisterpfad, Score 25); True nur beim ersten Mal. Sticky."""
|
||||
db = await get_db()
|
||||
now = _now()
|
||||
await db.execute(
|
||||
"INSERT OR IGNORE INTO baustein_progress (topic, baustein, gute_antworten, updated_at) VALUES (?, ?, 0, ?)",
|
||||
(topic, baustein, now),
|
||||
)
|
||||
cursor = await db.execute(
|
||||
"UPDATE baustein_progress SET gemeistert = ?, updated_at = ? "
|
||||
"WHERE topic = ? AND baustein = ? AND gemeistert IS NULL",
|
||||
(now, now, topic, baustein),
|
||||
)
|
||||
await db.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
async def set_baustein_absolviert(topic: str, baustein: str) -> bool:
|
||||
"""Markiert absolviert; True nur beim ersten Mal (steuert den Element-Task)."""
|
||||
db = await get_db()
|
||||
now = _now()
|
||||
await db.execute(
|
||||
"INSERT OR IGNORE INTO baustein_progress (topic, baustein, gute_antworten, updated_at) VALUES (?, ?, 0, ?)",
|
||||
(topic, baustein, now),
|
||||
)
|
||||
cursor = await db.execute(
|
||||
"UPDATE baustein_progress SET absolviert = ?, updated_at = ? "
|
||||
"WHERE topic = ? AND baustein = ? AND absolviert IS NULL",
|
||||
(now, now, topic, baustein),
|
||||
)
|
||||
await db.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
async def list_baustein_levels_all() -> dict[str, dict[str, set[str]]]:
|
||||
"""Bausteine je Meilenstein in EINER Query: {"absolviert"/"verstanden"/"gemeistert": {topic → {Titel}}}."""
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT topic, baustein, absolviert, verstanden, gemeistert FROM baustein_progress"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
levels: dict[str, dict[str, set[str]]] = {"absolviert": {}, "verstanden": {}, "gemeistert": {}}
|
||||
for topic, baustein, absolviert, verstanden, gemeistert in rows:
|
||||
if absolviert is not None:
|
||||
levels["absolviert"].setdefault(topic, set()).add(baustein)
|
||||
if verstanden is not None:
|
||||
levels["verstanden"].setdefault(topic, set()).add(baustein)
|
||||
if gemeistert is not None:
|
||||
levels["gemeistert"].setdefault(topic, set()).add(baustein)
|
||||
return levels
|
||||
|
||||
|
||||
async def delete_baustein_daten(topic: str) -> None:
|
||||
db = await get_db()
|
||||
await db.execute("DELETE FROM baustein_texte WHERE topic = ?", (topic,))
|
||||
await db.execute("DELETE FROM baustein_progress WHERE topic = ?", (topic,))
|
||||
await db.commit()
|
||||
|
||||
|
||||
# --- Bausteine-Pipeline-Inhalt: Inventar / Subbausteine / Frage-Muster / Coverage / State / Quelle ---
|
||||
|
||||
async def upsert_baustein(topic: str, titel_norm: str, titel: str, beschreibung: str = "", quellen: list | None = None) -> None:
|
||||
"""Kandidat einfügen oder Nennungszähler erhöhen. Erst-Beschreibung bleibt erhalten."""
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"""INSERT INTO bausteine (topic, titel_norm, titel, beschreibung, nennungen, status, quellen, updated_at)
|
||||
VALUES (?, ?, ?, ?, 1, 'kandidat', ?, ?)
|
||||
ON CONFLICT(topic, titel_norm) DO UPDATE SET
|
||||
nennungen = nennungen + 1, quellen = excluded.quellen, updated_at = excluded.updated_at""",
|
||||
(topic, titel_norm, titel, beschreibung, json.dumps(quellen or [], ensure_ascii=False), _now()),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def list_bausteine(topic: str, status: str | None = None) -> list[dict]:
|
||||
db = await get_db()
|
||||
if status is None:
|
||||
cursor = await db.execute("SELECT * FROM bausteine WHERE topic = ? ORDER BY rowid", (topic,))
|
||||
else:
|
||||
cursor = await db.execute("SELECT * FROM bausteine WHERE topic = ? AND status = ? ORDER BY rowid", (topic, status))
|
||||
rows = await cursor.fetchall()
|
||||
out = []
|
||||
for row in rows:
|
||||
d = _row_to_dict(row, cursor)
|
||||
d["quellen"] = json.loads(d.get("quellen") or "[]")
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
async def set_baustein_status(topic: str, titel_norm: str, status: str, titel: str | None = None, beschreibung: str | None = None) -> None:
|
||||
"""Status setzen; optional Titel/Beschreibung aktualisieren (z.B. nach semantischem Merge)."""
|
||||
db = await get_db()
|
||||
fields = {"status": status, "updated_at": _now()}
|
||||
if titel is not None:
|
||||
fields["titel"] = titel
|
||||
if beschreibung is not None:
|
||||
fields["beschreibung"] = beschreibung
|
||||
sets = ", ".join(f"{k} = :{k}" for k in fields)
|
||||
await db.execute(
|
||||
f"UPDATE bausteine SET {sets} WHERE topic = :topic AND titel_norm = :titel_norm",
|
||||
{**fields, "topic": topic, "titel_norm": titel_norm},
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def delete_bausteine(topic: str) -> None:
|
||||
db = await get_db()
|
||||
await db.execute("DELETE FROM bausteine WHERE topic = ?", (topic,))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def upsert_subbaustein(topic: str, baustein_norm: str, sub_norm: str, baustein: str, sub_titel: str) -> None:
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"""INSERT INTO subbausteine (topic, baustein_norm, sub_norm, baustein, sub_titel, nennungen, status, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 1, 'kandidat', ?)
|
||||
ON CONFLICT(topic, baustein_norm, sub_norm) DO UPDATE SET
|
||||
nennungen = nennungen + 1, updated_at = excluded.updated_at""",
|
||||
(topic, baustein_norm, sub_norm, baustein, sub_titel, _now()),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def put_subbaustein(topic: str, baustein_norm: str, sub_norm: str, baustein: str, sub_titel: str,
|
||||
stufe: str | None = None, relevanz: str | None = None, status: str = "konsens") -> None:
|
||||
"""Insert/Update OHNE Nennungszähler (Spiegel aus dem Sidecar). stufe/relevanz nur überschreiben,
|
||||
wenn ein neuer Wert übergeben wird (COALESCE schützt Bestehendes)."""
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"""INSERT INTO subbausteine (topic, baustein_norm, sub_norm, baustein, sub_titel, nennungen, stufe, relevanz, status, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?)
|
||||
ON CONFLICT(topic, baustein_norm, sub_norm) DO UPDATE SET
|
||||
baustein = excluded.baustein, sub_titel = excluded.sub_titel,
|
||||
stufe = COALESCE(excluded.stufe, subbausteine.stufe),
|
||||
relevanz = COALESCE(excluded.relevanz, subbausteine.relevanz),
|
||||
status = excluded.status, updated_at = excluded.updated_at""",
|
||||
(topic, baustein_norm, sub_norm, baustein, sub_titel, stufe, relevanz, status, _now()),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def list_subbausteine(topic: str, baustein_norm: str | None = None) -> list[dict]:
|
||||
db = await get_db()
|
||||
if baustein_norm is None:
|
||||
cursor = await db.execute("SELECT * FROM subbausteine WHERE topic = ? ORDER BY rowid", (topic,))
|
||||
else:
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM subbausteine WHERE topic = ? AND baustein_norm = ? ORDER BY rowid", (topic, baustein_norm)
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [_row_to_dict(row, cursor) for row in rows]
|
||||
|
||||
|
||||
async def set_subbaustein_felder(topic: str, baustein_norm: str, sub_norm: str, **fields) -> None:
|
||||
"""Setzt Felder (stufe/relevanz/status/sub_titel) einer Subbaustein-Zeile."""
|
||||
fields["updated_at"] = _now()
|
||||
sets = ", ".join(f"{k} = :{k}" for k in fields)
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
f"UPDATE subbausteine SET {sets} WHERE topic = :topic AND baustein_norm = :baustein_norm AND sub_norm = :sub_norm",
|
||||
{**fields, "topic": topic, "baustein_norm": baustein_norm, "sub_norm": sub_norm},
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def delete_subbausteine(topic: str) -> None:
|
||||
db = await get_db()
|
||||
await db.execute("DELETE FROM subbausteine WHERE topic = ?", (topic,))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def upsert_frage_muster(topic: str, baustein_norm: str, sub_norm: str, baustein: str, sub_titel: str, typ: str, frage: str) -> None:
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"""INSERT INTO frage_muster (topic, baustein_norm, sub_norm, baustein, sub_titel, typ, frage, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(topic, baustein_norm, sub_norm, typ) DO UPDATE SET
|
||||
frage = excluded.frage, updated_at = excluded.updated_at""",
|
||||
(topic, baustein_norm, sub_norm, baustein, sub_titel, typ, frage, _now()),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def list_frage_muster(topic: str, baustein_norm: str | None = None) -> list[dict]:
|
||||
db = await get_db()
|
||||
if baustein_norm is None:
|
||||
cursor = await db.execute("SELECT * FROM frage_muster WHERE topic = ? ORDER BY rowid", (topic,))
|
||||
else:
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM frage_muster WHERE topic = ? AND baustein_norm = ? ORDER BY rowid", (topic, baustein_norm)
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [_row_to_dict(row, cursor) for row in rows]
|
||||
|
||||
|
||||
async def delete_frage_muster(topic: str) -> None:
|
||||
db = await get_db()
|
||||
await db.execute("DELETE FROM frage_muster WHERE topic = ?", (topic,))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def mark_quellen_gelesen(topic: str, quellen: list[str]) -> None:
|
||||
"""Markiert die zitierten Crawl-Seiten als gelesen (Recherche-Loop-Abdeckung)."""
|
||||
if not quellen:
|
||||
return
|
||||
db = await get_db()
|
||||
now = _now()
|
||||
await db.executemany(
|
||||
"""INSERT INTO recherche_coverage (topic, quelle, gelesen, updated_at) VALUES (?, ?, 1, ?)
|
||||
ON CONFLICT(topic, quelle) DO UPDATE SET gelesen = 1, updated_at = excluded.updated_at""",
|
||||
[(topic, q, now) for q in quellen],
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def list_coverage(topic: str) -> dict[str, int]:
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT quelle, gelesen FROM recherche_coverage WHERE topic = ?", (topic,))
|
||||
rows = await cursor.fetchall()
|
||||
return {q: g for q, g in rows}
|
||||
|
||||
|
||||
async def mark_inhalt(topic: str, content: list[str], noise: list[str]) -> None:
|
||||
"""Sichtungs-Ergebnis je Crawl-Seite ablegen: inhalt=1 (Content) bzw. 0 (Noise)."""
|
||||
db = await get_db()
|
||||
now = _now()
|
||||
rows = [(topic, q, 1, now) for q in content] + [(topic, q, 0, now) for q in noise]
|
||||
if not rows:
|
||||
return
|
||||
await db.executemany(
|
||||
"""INSERT INTO recherche_coverage (topic, quelle, inhalt, updated_at) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(topic, quelle) DO UPDATE SET inhalt = excluded.inhalt, updated_at = excluded.updated_at""",
|
||||
rows,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def list_content(topic: str) -> list[str]:
|
||||
"""Crawl-Seiten, die die Sichtung als Content markiert hat (inhalt=1)."""
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT quelle FROM recherche_coverage WHERE topic = ? AND inhalt = 1 ORDER BY quelle", (topic,)
|
||||
)
|
||||
return [r[0] for r in await cursor.fetchall()]
|
||||
|
||||
|
||||
async def delete_coverage(topic: str) -> None:
|
||||
db = await get_db()
|
||||
await db.execute("DELETE FROM recherche_coverage WHERE topic = ?", (topic,))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def set_step_status(topic: str, schritt: str, status: str) -> None:
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"""INSERT INTO pipeline_state (topic, schritt, status, updated_at) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(topic, schritt) DO UPDATE SET status = excluded.status, updated_at = excluded.updated_at""",
|
||||
(topic, schritt, status, _now()),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_step_status(topic: str, schritt: str) -> str:
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT status FROM pipeline_state WHERE topic = ? AND schritt = ?", (topic, schritt)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row[0] if row else "offen"
|
||||
|
||||
|
||||
async def list_pipeline_state(topic: str) -> dict[str, str]:
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT schritt, status FROM pipeline_state WHERE topic = ?", (topic,))
|
||||
rows = await cursor.fetchall()
|
||||
return {s: st for s, st in rows}
|
||||
|
||||
|
||||
async def delete_pipeline_state(topic: str, schritte: list[str] | None = None) -> None:
|
||||
db = await get_db()
|
||||
if schritte is None:
|
||||
await db.execute("DELETE FROM pipeline_state WHERE topic = ?", (topic,))
|
||||
elif schritte:
|
||||
marks = ",".join("?" for _ in schritte)
|
||||
await db.execute(f"DELETE FROM pipeline_state WHERE topic = ? AND schritt IN ({marks})", (topic, *schritte))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def set_quelle(topic: str, type: str, ort: str = "", spec: str = "") -> None:
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"""INSERT INTO quelle (topic, type, ort, spec, updated_at) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(topic) DO UPDATE SET
|
||||
type = excluded.type, ort = excluded.ort, spec = excluded.spec, updated_at = excluded.updated_at""",
|
||||
(topic, type, ort, spec, _now()),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_quelle(topic: str) -> dict | None:
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT type, ort, spec FROM quelle WHERE topic = ?", (topic,))
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return {"type": row[0], "ort": row[1], "spec": row[2]}
|
||||
|
||||
|
||||
async def delete_quelle(topic: str) -> None:
|
||||
db = await get_db()
|
||||
await db.execute("DELETE FROM quelle WHERE topic = ?", (topic,))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def set_guide_content(topic: str, format: str, content_json: str) -> None:
|
||||
"""Fertigen Guide-Inhalt (JSON-Blob) je Thema+Format speichern."""
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"""INSERT INTO guide_content (topic, format, json, updated_at) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(topic, format) DO UPDATE SET json = excluded.json, updated_at = excluded.updated_at""",
|
||||
(topic, format, content_json, _now()),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_guide_content(topic: str, format: str) -> str | None:
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT json FROM guide_content WHERE topic = ? AND format = ?", (topic, format))
|
||||
row = await cursor.fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
async def delete_guide_content(topic: str, format: str | None = None) -> None:
|
||||
db = await get_db()
|
||||
if format is None:
|
||||
await db.execute("DELETE FROM guide_content WHERE topic = ?", (topic,))
|
||||
else:
|
||||
await db.execute("DELETE FROM guide_content WHERE topic = ? AND format = ?", (topic, format))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def delete_topic_pipeline(topic: str) -> None:
|
||||
"""Bausteine-Bereich eines Themas verwerfen (Inventar/Subs/Muster/Coverage/State).
|
||||
NICHT die Themen-Config `quelle` — die wird separat verwaltet (delete_quelle)."""
|
||||
db = await get_db()
|
||||
for tab in ("bausteine", "subbausteine", "frage_muster", "recherche_coverage", "pipeline_state"):
|
||||
await db.execute(f"DELETE FROM {tab} WHERE topic = ?", (topic,))
|
||||
await db.commit()
|
||||
|
||||
261
backend/elements.py
Normal file
261
backend/elements.py
Normal file
@@ -0,0 +1,261 @@
|
||||
"""Elemente (persönliche Zusammenfassung) und Tutor-Chat zum Guide."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
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 pipeline import _prompt
|
||||
|
||||
log = logging.getLogger("creator.elements")
|
||||
|
||||
|
||||
# --- 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', '')}"
|
||||
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)",
|
||||
transcript=transcript,
|
||||
)
|
||||
|
||||
|
||||
async def chat_with_guide(topic: str, format_name: str, section: str, outline: str, messages: list[dict], provider: str = DEFAULT_PROVIDER) -> str:
|
||||
try:
|
||||
prompt = _build_guide_chat_prompt(topic, format_name, section, outline, messages)
|
||||
returncode, stdout, stderr = await run_agent(
|
||||
"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."
|
||||
reply = stdout.strip()
|
||||
return reply or "Entschuldigung, ich habe keine Antwort erhalten."
|
||||
except Exception:
|
||||
log.warning("[%s] Guide-Chat fehlgeschlagen", topic, exc_info=True)
|
||||
return "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut."
|
||||
|
||||
|
||||
# --- Elemente ---
|
||||
|
||||
def _element_fields(data: dict) -> dict | None:
|
||||
"""Validiert KI-Element-JSON und normalisiert auf die DB-Felder."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
title = str(data.get("title", "")).strip()
|
||||
if not title:
|
||||
return None
|
||||
listen = {}
|
||||
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 []
|
||||
return {
|
||||
"title": title[:200],
|
||||
"description": str(data.get("description", "")).strip(),
|
||||
"examples": listen["examples"],
|
||||
"hints": listen["hints"],
|
||||
}
|
||||
|
||||
|
||||
def _topic_context(topic: str, limit: int = 12000) -> str:
|
||||
"""Bausteine + Guide-Inhalte des Themas als Kontext-Text (gekürzt)."""
|
||||
parts: list[str] = []
|
||||
bp = bausteine_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))
|
||||
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
|
||||
text = "\n\n".join(parts).strip()
|
||||
return text[:limit] if text else "(kein Material vorhanden)"
|
||||
|
||||
|
||||
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": []}
|
||||
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)",
|
||||
context=context,
|
||||
)
|
||||
returncode, stdout, _ = await run_agent(
|
||||
"element-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
|
||||
)
|
||||
if returncode != 0:
|
||||
return fallback
|
||||
return _element_fields(_parse_json_text(stdout)) or fallback
|
||||
except Exception:
|
||||
log.warning("[%s] Element-Erstellung fehlgeschlagen", 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."""
|
||||
data = _parse_json_text(stdout)
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
suggestions = []
|
||||
for s in data.get("suggestions", []):
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
text = str(s.get("text", "")).strip()
|
||||
target = s.get("target")
|
||||
content = str(s.get("content", "")).strip()
|
||||
if text and content and target in ("description", "examples", "hints"):
|
||||
suggestions.append({"text": text, "target": target, "content": content})
|
||||
return suggestions
|
||||
|
||||
|
||||
async def check_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list[dict] | None:
|
||||
"""Zweischrittige Prüfung auf fehlende Infos: Recherche → Verifizieren. None bei Fehler."""
|
||||
try:
|
||||
element_json = json.dumps(
|
||||
{k: element[k] for k in ("title", "description", "examples", "hints")},
|
||||
ensure_ascii=False, indent=1,
|
||||
)
|
||||
context = _topic_context(element["topic"])
|
||||
|
||||
# Schritt 1: Recherche — breit Kandidaten sammeln
|
||||
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"
|
||||
)
|
||||
if returncode != 0:
|
||||
return None
|
||||
candidates = _parse_suggestions(stdout)
|
||||
if candidates is None:
|
||||
return None
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
# Schritt 2: Verifizieren — nur Wichtiges, nicht Redundantes durchlassen
|
||||
prompt = _prompt(
|
||||
"Element-Verify",
|
||||
topic=element["topic"], element_json=element_json,
|
||||
candidates_json=json.dumps({"suggestions": candidates}, ensure_ascii=False, indent=1),
|
||||
context=context,
|
||||
)
|
||||
returncode, stdout, _ = await run_agent(
|
||||
"element-verify-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
|
||||
)
|
||||
if returncode != 0:
|
||||
return None
|
||||
return _parse_suggestions(stdout)
|
||||
except Exception:
|
||||
log.warning("[%s] Element-Prüfung fehlgeschlagen", element.get("topic", "?"), exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _element_json(element: dict) -> str:
|
||||
return json.dumps(
|
||||
{k: element[k] for k in ("title", "description", "examples", "hints")},
|
||||
ensure_ascii=False, indent=1,
|
||||
)
|
||||
|
||||
|
||||
def _validate_change(c, element: dict) -> dict | None:
|
||||
"""Validiert einen Änderungs-Vorschlag aus KI-Output gegen das Element."""
|
||||
if not isinstance(c, dict):
|
||||
return None
|
||||
text = str(c.get("text", "")).strip()
|
||||
action = c.get("action")
|
||||
target = c.get("target")
|
||||
index = c.get("index")
|
||||
content = str(c.get("content", "")).strip()
|
||||
if not text or action not in ("entfernen", "anpassen", "hinzufuegen"):
|
||||
return None
|
||||
if target not in ("title", "description", "examples", "hints"):
|
||||
return None
|
||||
if action in ("anpassen", "hinzufuegen") and not content:
|
||||
return None
|
||||
if action == "entfernen" 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"):
|
||||
if not isinstance(index, int) or not (0 <= index < len(element[target])):
|
||||
return None
|
||||
else:
|
||||
index = None
|
||||
return {"text": text, "action": action, "target": target, "index": index, "content": content}
|
||||
|
||||
|
||||
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."
|
||||
try:
|
||||
transcript = "\n".join(
|
||||
f"{'Nutzer' if m.get('role') == 'user' else 'Assistent'}: {m.get('content', '')}"
|
||||
for m in messages
|
||||
)
|
||||
prompt = _prompt("Element-Chat", topic=element["topic"], element_json=_element_json(element), transcript=transcript)
|
||||
returncode, stdout, _ = await run_agent(
|
||||
"element-chat-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
|
||||
)
|
||||
if returncode != 0:
|
||||
return fehler, []
|
||||
data = _parse_json_text(stdout)
|
||||
if not isinstance(data, dict):
|
||||
return fehler, []
|
||||
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)
|
||||
return reply, changes
|
||||
except Exception:
|
||||
log.warning("[%s] Element-Chat fehlgeschlagen", element.get("topic", "?"), exc_info=True)
|
||||
return fehler, []
|
||||
|
||||
|
||||
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."""
|
||||
try:
|
||||
prompt = _prompt("Element-Stil", 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"
|
||||
)
|
||||
if returncode != 0:
|
||||
return None
|
||||
data = _parse_json_text(stdout)
|
||||
if not isinstance(data, dict):
|
||||
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)
|
||||
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."""
|
||||
try:
|
||||
prompt = _prompt(
|
||||
"Element-Refine",
|
||||
topic=element["topic"], element_json=_element_json(element),
|
||||
suggestion_json=json.dumps(suggestion, ensure_ascii=False, indent=1),
|
||||
instruction=instruction,
|
||||
)
|
||||
returncode, stdout, _ = await run_agent(
|
||||
"element-refine-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
|
||||
)
|
||||
if returncode != 0:
|
||||
return None
|
||||
data = _parse_json_text(stdout)
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return _validate_change(data.get("change"), element)
|
||||
except Exception:
|
||||
log.warning("[%s] Vorschlags-Überarbeitung fehlgeschlagen", element.get("topic", "?"), exc_info=True)
|
||||
return None
|
||||
22
backend/fsutil.py
Normal file
22
backend/fsutil.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""Atomare Datei-Writes: erst .tmp im selben Verzeichnis, dann 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.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def atomic_write_text(path: Path, text: str) -> None:
|
||||
tmp = path.with_name(path.name + ".tmp")
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def atomic_write_json(path: Path, obj, **dumps_kwargs) -> None:
|
||||
atomic_write_text(path, json.dumps(obj, ensure_ascii=False, **dumps_kwargs))
|
||||
1600
backend/generator.py
1600
backend/generator.py
File diff suppressed because it is too large
Load Diff
818
backend/guide.py
Normal file
818
backend/guide.py
Normal file
@@ -0,0 +1,818 @@
|
||||
"""Guide-Generierung als Konsens-Pipeline.
|
||||
|
||||
Gliederung: Auswahl der Bausteine (deterministisch je Format) → 3 Vorschläge
|
||||
(Grace), die Bausteine NUMMERN-basiert in Kapitel ordnen → ein Judge merged die
|
||||
Vorschläge zu einer kohärenten Reihenfolge.
|
||||
Schreiben: Writer je Baustein. Lese-Prüfung: Check→Fix (eine Runde),
|
||||
Folgerunden prüfen nur ersetzte Sections; danach bleiben Beanstandungen stehen.
|
||||
Schritt-Dateien bleiben liegen → Abbruch erhält Fortschritt, ▶ setzt am offenen Schritt fort.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from agents import run_agent
|
||||
from bausteine import _pdfs_konvertieren, quelle_ordner
|
||||
from config import (
|
||||
DEFAULT_PROVIDER, FORMAT_ZWECK, KONSENS_GRACE,
|
||||
LESBARKEIT_AKTIV, TEMPLATES_DIR,
|
||||
)
|
||||
import lesbarkeit
|
||||
from database import list_guides, update_guide, list_bausteine, list_subbausteine, set_guide_content
|
||||
from fsutil import atomic_write_json, atomic_write_text
|
||||
from jsonio import read_json_file as _json_datei
|
||||
from paths import bausteine_path, guide_content_path, project_dir, subbausteine_path
|
||||
from pipeline import (
|
||||
CANCELLED, FAILED, GenContext, _claude_error, _extra,
|
||||
_fail, _gather_error, _gather_fortschritt, _log, _prompt, _race,
|
||||
_semaphore, _set_progress, _set_step, _timeout, clear_guide_cancelled,
|
||||
is_guide_cancelled, run_single_slot,
|
||||
)
|
||||
from textkit import (
|
||||
_eindeutige_titel, _lade_bausteine, _parse_fragment, _split_chunks,
|
||||
_titel, _titel_aufloesen, _titel_index,
|
||||
)
|
||||
|
||||
log = logging.getLogger("creator.guide")
|
||||
|
||||
GUIDE_STEPS = ("Gliederung", "Inhalte", "Inhalts-Check", "Schreiben", "Lese-Prüfung")
|
||||
|
||||
# Inhalte/Inhalts-Check/Lese-Prüfung laufen in Paketen von ~GUIDE_CHUNK Bausteinen je Agent.
|
||||
# Nur der Writer (Schreiben) bleibt 1 Agent je Baustein (variable Längen, kein Kürzen, keine
|
||||
# Längen-Angleichung zwischen Bausteinen).
|
||||
GUIDE_CHUNK = 10
|
||||
|
||||
# Prüf-Schritte als Panel: CHECK_PANEL Judges je Chunk, Section beanstandet bei Mehrheit.
|
||||
# Ein einzelner Judge ist bias-/sampling-anfällig; ein kleines Panel ist stabiler.
|
||||
CHECK_PANEL = 3
|
||||
|
||||
# Lese-Prüfung: nur EINE Runde (Check + Fix). Folgerunden brachten kaum Mehrwert
|
||||
# (1 Agent je Baustein prüft ohnehin fein), kosten aber extra Agenten.
|
||||
LESE_RUNDEN = 1
|
||||
|
||||
|
||||
async def _load_subbausteine(topic: str) -> dict[str, list[dict]]:
|
||||
"""Subbausteine je Baustein — DB-first ({titel, stufe, relevanz}), Fallback Sidecar-Datei.
|
||||
Fehlt beides → {} (Guide nimmt alles)."""
|
||||
out: dict[str, list[dict]] = {}
|
||||
for r in await list_subbausteine(topic):
|
||||
if r["status"] == "konsens" and r["sub_titel"] and r["stufe"] in ("einfach", "mittel", "schwer"):
|
||||
out.setdefault(r["baustein"], []).append(
|
||||
{"titel": r["sub_titel"], "stufe": r["stufe"], "relevanz": r["relevanz"]})
|
||||
if out:
|
||||
return out
|
||||
data = _json_datei(subbausteine_path(topic))
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
for titel, subs in data.items():
|
||||
if not isinstance(subs, list):
|
||||
continue
|
||||
gut = [s for s in subs if isinstance(s, dict) and str(s.get("titel", "")).strip()
|
||||
and s.get("stufe") in ("einfach", "mittel", "schwer")]
|
||||
if gut:
|
||||
out[titel] = gut
|
||||
return out
|
||||
|
||||
|
||||
def _zuteilung_subs(chunk: list[dict], entries: dict[int, str], subs_by_titel: dict[str, list[dict]]) -> str:
|
||||
"""Wie _zuteilung_text, aber listet pro Baustein seine Subbausteine mit Stufe."""
|
||||
lines: list[str] = []
|
||||
for ch in chunk:
|
||||
lines.append(f"KAPITEL: {ch['title']}")
|
||||
for num in ch["nums"]:
|
||||
lines.append(f"- {entries[num]}")
|
||||
for s in subs_by_titel.get(_titel(entries[num]), []):
|
||||
lines.append(f" [{s['stufe']}] {s['titel']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _guide_files(content_path: Path) -> dict:
|
||||
d, stem = content_path.parent, content_path.stem
|
||||
return {
|
||||
"gliederung_slots": [d / f"{stem}.gliederung-{i}.json" for i in (1, 2, 3)],
|
||||
"gliederung": d / f"{stem}.gliederung.json", # Judge-Ausgabe
|
||||
# chunk-/lese-check-/fix-Dateien sind dynamisch:
|
||||
# {stem}.chunk-i.md, {stem}.lese-check-r{n}-{i}.json, {stem}.fix-r{n}-{i}.md
|
||||
}
|
||||
|
||||
|
||||
def guide_slot_dateien(content_path: Path) -> list[Path]:
|
||||
"""Alle Schritt-Dateien eines Guides (für den Frischstart)."""
|
||||
return [p for p in content_path.parent.glob(f"{content_path.stem}.*") if p != content_path]
|
||||
|
||||
|
||||
def _fertig_path(content_path: Path) -> Path:
|
||||
return content_path.parent / f"{content_path.stem}.fertig"
|
||||
|
||||
|
||||
def guide_fertig_step(content_path: Path) -> int:
|
||||
"""Höchster VOLL abgeschlossener Schritt-Index (Marker je Thema+Format). -1 = keiner.
|
||||
Existiert die Content-Datei, sind alle Schritte fertig."""
|
||||
if content_path.exists():
|
||||
return len(GUIDE_STEPS) - 1
|
||||
try:
|
||||
return int(_fertig_path(content_path).read_text(encoding="utf-8").strip())
|
||||
except (OSError, ValueError):
|
||||
return -1
|
||||
|
||||
|
||||
def _set_fertig(content_path: Path, step: int) -> None:
|
||||
"""Marker auf `step` setzen — monoton (nur erhöhen), außer beim Re-Run-Reset (force)."""
|
||||
if step > guide_fertig_step(content_path):
|
||||
atomic_write_text(_fertig_path(content_path), str(step))
|
||||
|
||||
|
||||
def _reset_fertig(content_path: Path, step: int) -> None:
|
||||
"""Marker hart auf `step` setzen (für Re-Run ab Schritt; step kann sinken)."""
|
||||
if step < 0:
|
||||
_fertig_path(content_path).unlink(missing_ok=True)
|
||||
else:
|
||||
atomic_write_text(_fertig_path(content_path), str(step))
|
||||
|
||||
|
||||
# Slot-Datei-Globs je Schritt (Index = GUIDE_STEPS). Stem-verankert, kollisionsfrei.
|
||||
_STEP_GLOBS = (
|
||||
("gliederung*",), # 0 Gliederung (inkl. Auswahl-Filter)
|
||||
("inhalt-chunk-*", "inhalt-nach-*"), # 1 Inhalte (inkl. Nachrunde)
|
||||
("inhalt-check-*", "inhalt-fix-*"), # 2 Inhalts-Check
|
||||
("chunk-*",), # 3 Schreiben (chunk-* matcht auch chunk-nach-*)
|
||||
("lese-check-*", "fix-r*"), # 4 Lese-Prüfung
|
||||
)
|
||||
|
||||
|
||||
def _reset_guide_ab_step(content_path: Path, step: int) -> None:
|
||||
"""Re-Run ab Schritt: Content + alle Slot-Dateien der Schritte ≥ step löschen.
|
||||
Frühere Schritte bleiben → der Resume baut ab `step` neu (alles darunter wiederverwendet)."""
|
||||
content_path.unlink(missing_ok=True) # nicht mehr „done" → kein Frischstart-Wipe
|
||||
d, stem = content_path.parent, content_path.stem
|
||||
for globs in _STEP_GLOBS[step:]:
|
||||
for pat in globs:
|
||||
for p in d.glob(f"{stem}.{pat}"):
|
||||
p.unlink(missing_ok=True)
|
||||
_reset_fertig(content_path, step - 1) # Schritte < step gelten als fertig
|
||||
|
||||
|
||||
def _lese_probleme_schema(data):
|
||||
"""{"ok": true} → [] · {"probleme": [{"section", "problem"}]} → Liste · sonst None."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
if data.get("ok") is True:
|
||||
return []
|
||||
p = data.get("probleme")
|
||||
if not isinstance(p, list) or not p:
|
||||
return None
|
||||
out = []
|
||||
for x in p:
|
||||
if not isinstance(x, dict) or not isinstance(x.get("section"), str) or not isinstance(x.get("problem"), str):
|
||||
return None
|
||||
out.append({"section": x["section"].strip(), "problem": x["problem"].strip()})
|
||||
return out or None
|
||||
|
||||
|
||||
def _panel_probleme(judge_paths: list[Path], geltung: set[int], idx: dict[str, int]) -> dict[int, str]:
|
||||
"""Panel-Aggregation: mehrere Judge-Outputs eines Chunks → beanstandete {num: problem}.
|
||||
|
||||
Eine Stimme je Judge, der eine Section nennt. Beanstandet, wenn > Hälfte der
|
||||
GELIEFERTEN (valid geparsten) Judges sie nennt (3→≥2, 2→≥2, 1→≥1). Robust gegen
|
||||
Einzel-Ausfall: fehlende Dateien zählen nicht mit. Problem-Text vom erstnennenden Judge.
|
||||
"""
|
||||
outputs = [p for p in (_lese_probleme_schema(_json_datei(j)) for j in judge_paths) if p is not None]
|
||||
if not outputs:
|
||||
return {}
|
||||
votes: dict[int, int] = {}
|
||||
problem: dict[int, str] = {}
|
||||
for out in outputs:
|
||||
gesehen: set[int] = set()
|
||||
for item in out:
|
||||
num = _titel_aufloesen(idx, item["section"])
|
||||
if num is None or num not in geltung or num in gesehen:
|
||||
continue
|
||||
gesehen.add(num)
|
||||
votes[num] = votes.get(num, 0) + 1
|
||||
problem.setdefault(num, item["problem"])
|
||||
schwelle = len(outputs) / 2
|
||||
return {num: problem[num] for num, v in votes.items() if v > schwelle}
|
||||
|
||||
|
||||
def _resolve_gliederung(data, entries: dict[int, str], soll_min: int, soll_max: int) -> list[dict] | None:
|
||||
"""{"kapitel": [{"titel", "nummern": [1, 3, 7]}]} → [{"title", "nums"}].
|
||||
|
||||
Nummern sind die IDs aus `entries` (1-basiert, wie dem Agenten präsentiert).
|
||||
`soll_min`/`soll_max` = erlaubte Spanne gewählter Bausteine (mit kleiner Toleranz).
|
||||
"""
|
||||
if not isinstance(data, dict) or not isinstance(data.get("kapitel"), list):
|
||||
return None
|
||||
gueltig = set(entries)
|
||||
chapters: list[dict] = []
|
||||
seen: set[int] = set()
|
||||
total = unknown = 0
|
||||
for ch in data["kapitel"]:
|
||||
if not isinstance(ch, dict) or not isinstance(ch.get("nummern"), list):
|
||||
return None
|
||||
nums = []
|
||||
for t in ch["nummern"]:
|
||||
total += 1
|
||||
num = t if isinstance(t, int) and not isinstance(t, bool) else None
|
||||
if num is None or num not in gueltig:
|
||||
unknown += 1
|
||||
elif num not in seen:
|
||||
nums.append(num)
|
||||
seen.add(num)
|
||||
if nums:
|
||||
chapters.append({"title": str(ch.get("titel", "")).strip() or "Kapitel", "nums": nums})
|
||||
if not chapters or total == 0:
|
||||
return None
|
||||
if (total - unknown) / total < 0.85:
|
||||
return None
|
||||
if len(seen) < 0.9 * soll_min or len(seen) > 1.1 * soll_max:
|
||||
return None
|
||||
return chapters
|
||||
|
||||
|
||||
def _fallback_gliederung(entries: dict[int, str]) -> list[dict]:
|
||||
"""Deterministische Gliederung, wenn die Agenten keine liefern: ein Kapitel mit
|
||||
allen gewählten Bausteinen in Reihenfolge. Garantiert vollständige Abdeckung."""
|
||||
return [{"title": "Inhalte", "nums": list(entries)}]
|
||||
|
||||
|
||||
def _mit_resten(plan: list[dict], entries: dict[int, str]) -> list[dict]:
|
||||
"""Stellt sicher, dass JEDER gewählte Baustein im Plan steht — fehlende landen in
|
||||
einem Kapitel „Weitere" (gegen Agenten/Judge, die Bausteine weglassen)."""
|
||||
drin = {num for ch in plan for num in ch.get("nums", [])}
|
||||
fehlen = [num for num in entries if num not in drin]
|
||||
return [*plan, {"title": "Weitere", "nums": fehlen}] if fehlen else plan
|
||||
|
||||
|
||||
async def _generate_sections(
|
||||
guide_id: str, topic: str, format_name: str, entries: dict[int, str],
|
||||
facts: str, instructions: str, provider: str,
|
||||
content_path: Path,
|
||||
) -> list[dict] | None:
|
||||
def is_cancelled() -> bool:
|
||||
return is_guide_cancelled(guide_id)
|
||||
|
||||
ctx = GenContext(topic=topic, provider=provider, is_cancelled=is_cancelled, guide_id=guide_id)
|
||||
spec = (TEMPLATES_DIR / "Format" / "Section.md").read_text(encoding="utf-8")
|
||||
files = _guide_files(content_path)
|
||||
zweck = FORMAT_ZWECK[format_name]
|
||||
|
||||
# Subbausteine je Baustein (DB-first) — früh geladen: steuert Auswahl + Sub-Filter je Format.
|
||||
# Fehlt sie → {} (Fallback: Guide nimmt alles).
|
||||
subs_raw = await _load_subbausteine(topic)
|
||||
|
||||
def _hat_relevanz(num, art):
|
||||
return any(isinstance(s, dict) and s.get("relevanz") == art for s in subs_raw.get(_titel(entries[num]), []))
|
||||
|
||||
# Auswahl (Teil von Gliederung). Deterministisch je Format:
|
||||
# Guide=relevante Bausteine · FullGuide=alle · Rest=reine Rand-Bausteine (keine im Guide).
|
||||
if format_name == "FullGuide":
|
||||
auswahl = list(entries)
|
||||
elif format_name == "Rest":
|
||||
auswahl = [num for num in entries if not _hat_relevanz(num, "relevant")]
|
||||
else: # Guide
|
||||
auswahl = [num for num in entries if _hat_relevanz(num, "relevant")]
|
||||
if not auswahl:
|
||||
_log(topic, "Guide: keine Relevanz-Daten — alle Bausteine genommen")
|
||||
auswahl = list(entries)
|
||||
if not auswahl:
|
||||
await _fail(guide_id, "Keine passenden Bausteine für dieses Format")
|
||||
return None
|
||||
|
||||
sel_entries = {num: entries[num] for num in auswahl}
|
||||
soll = len(sel_entries)
|
||||
# Nummerierte Liste (ID = Baustein-Nummer aus entries) — Agenten/Judge ordnen per Nummer.
|
||||
sel_liste = "\n".join(f"{num}. {t}" for num, t in sel_entries.items())
|
||||
|
||||
# Schritt 0: Gliederung — bis zu 3 Vorschläge (Grace), ein Judge merged sie. Bricht NIE ab:
|
||||
# 0 gültige → Code-Fallback, 1 → direkt, ≥2 → Judge (mit Vorschlag als Rückfall).
|
||||
# Gültiges gliederung.json überspringt den Schritt.
|
||||
plan = _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll)
|
||||
if plan is None:
|
||||
await _set_step(guide_id, 0, "Gliederungs-Vorschläge (3 Agenten)…")
|
||||
files["gliederung"].unlink(missing_ok=True)
|
||||
vorschlaege: list[list[dict]] = []
|
||||
offen = []
|
||||
for i, path in enumerate(files["gliederung_slots"], 1):
|
||||
res = _resolve_gliederung(_json_datei(path), sel_entries, soll, soll)
|
||||
if res is not None:
|
||||
vorschlaege.append(res)
|
||||
else:
|
||||
offen.append((i, path))
|
||||
if len(vorschlaege) < 3 and offen:
|
||||
slots = [
|
||||
{
|
||||
"key": f"{guide_id}-gliederung-{i}",
|
||||
"prompt": _prompt(
|
||||
"Guide-Gliederung",
|
||||
topic=topic, format_name=format_name, bausteine=sel_liste,
|
||||
out_path=path, extra=_extra(instructions),
|
||||
),
|
||||
"role": "guide", "capabilities": "files",
|
||||
"payload": (lambda result, p=path: _resolve_gliederung(_json_datei(p), sel_entries, soll, soll)),
|
||||
}
|
||||
for i, path in offen
|
||||
]
|
||||
# Quorum 1: nimm, was kommt — kein Mindestzwang, kein Abbruch.
|
||||
neue = await _race(
|
||||
topic, "Gliederung", slots, 1, _timeout("plan", soll),
|
||||
provider, cancelled=is_cancelled, grace=KONSENS_GRACE,
|
||||
)
|
||||
if is_cancelled():
|
||||
return None
|
||||
vorschlaege += neue or []
|
||||
|
||||
if not vorschlaege:
|
||||
_log(topic, "Gliederung: kein gültiger Vorschlag — deterministischer Fallback")
|
||||
plan = _fallback_gliederung(sel_entries)
|
||||
elif len(vorschlaege) == 1:
|
||||
plan = vorschlaege[0] # ein Vorschlag → kein Judge nötig
|
||||
else:
|
||||
await _set_step(guide_id, 0, "Gliederungen zusammenführen…")
|
||||
bloecke = "\n\n".join(
|
||||
f"### Vorschlag {i}\n"
|
||||
+ "\n".join(f"KAPITEL: {ch['title']}\n Nummern: {', '.join(str(num) for num in ch['nums'])}" for ch in v)
|
||||
for i, v in enumerate(vorschlaege, 1)
|
||||
)
|
||||
status, plan = await run_single_slot(
|
||||
ctx, "Gliederungs-Judge",
|
||||
key=f"{guide_id}-gliederung-judge",
|
||||
prompt=_prompt(
|
||||
"Guide-Gliederung-Judge",
|
||||
topic=topic, format_name=format_name, zweck=zweck, n=len(vorschlaege),
|
||||
bausteine=sel_liste, gliederungen=bloecke,
|
||||
out_path=files["gliederung"], extra=_extra(instructions),
|
||||
),
|
||||
role="judge", capabilities="files",
|
||||
payload=lambda result: _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll),
|
||||
timeout=_timeout("plan_judge", soll),
|
||||
)
|
||||
if status == CANCELLED:
|
||||
return None
|
||||
if status == FAILED or plan is None:
|
||||
_log(topic, "Gliederung-Judge ohne Ergebnis — bester Vorschlag bleibt")
|
||||
plan = vorschlaege[0]
|
||||
|
||||
# Garantie: jeder gewählte Baustein steht im Plan (gegen weglassende Agenten/Judges).
|
||||
plan = _mit_resten(plan, sel_entries)
|
||||
_set_fertig(content_path, 0) # Gliederung steht
|
||||
|
||||
# Grobe Chunks (~GUIDE_CHUNK Bausteine je Agent) für Inhalte, Inhalts-Check und Lese-Prüfung.
|
||||
# Der Writer baut darunter pro Baustein (eigene feine Chunks, s.u.) → variable Längen.
|
||||
total_sections = sum(len(c["nums"]) for c in plan)
|
||||
chunks = _split_chunks(plan, max(1, math.ceil(total_sections / GUIDE_CHUNK)))
|
||||
# Subbausteine je Baustein, je Format gefiltert: Guide=relevant · FullGuide=alle · Rest=Rand.
|
||||
# Subs ohne Relevanz-Feld (Alt-Themen) zählen als „nicht Rand" (→ Guide/FullGuide).
|
||||
if format_name == "FullGuide":
|
||||
subs_by_titel = {t: list(subs) for t, subs in subs_raw.items()}
|
||||
elif format_name == "Rest":
|
||||
subs_by_titel = {t: [s for s in subs if s.get("relevanz") == "rand"] for t, subs in subs_raw.items()}
|
||||
else: # Guide
|
||||
subs_by_titel = {t: [s for s in subs if s.get("relevanz") != "rand"] for t, subs in subs_raw.items()}
|
||||
subs_by_titel = {t: subs for t, subs in subs_by_titel.items() if subs}
|
||||
zuteilungen = [_zuteilung_subs(chunk, entries, subs_by_titel) for chunk in chunks]
|
||||
chunk_sizes = [sum(len(c["nums"]) for c in chunk) for chunk in chunks]
|
||||
writer_count = len(chunks)
|
||||
idx = _titel_index(entries)
|
||||
|
||||
# Schritt 2: Inhalte je Baustein identifizieren — pro Chunk ein Agent (Marker-Output, Resume).
|
||||
inhalt_paths = [content_path.parent / f"{content_path.stem}.inhalt-chunk-{i}.md" for i in range(1, writer_count + 1)]
|
||||
offen = [i for i, p in enumerate(inhalt_paths) if not p.exists()]
|
||||
if offen:
|
||||
async def melde(d, t): await _set_step(guide_id, 1, f"Sammle Inhalte {d}/{t}…")
|
||||
results = await _gather_fortschritt([
|
||||
run_agent(
|
||||
f"{guide_id}-inhalt-{i + 1}",
|
||||
_prompt(
|
||||
"Guide-Inhalt",
|
||||
topic=topic, zuteilung=zuteilungen[i], facts=facts,
|
||||
out_path=inhalt_paths[i], extra=_extra(instructions),
|
||||
),
|
||||
_timeout("inhalt", chunk_sizes[i]), provider=provider, role="guide", capabilities="full",
|
||||
)
|
||||
for i in offen
|
||||
], writer_count, melde, start=writer_count - len(offen))
|
||||
if is_cancelled():
|
||||
return None
|
||||
if not any(p.exists() for p in inhalt_paths):
|
||||
await _fail(guide_id, _gather_error("Inhalts-Fehler", list(results)))
|
||||
return None
|
||||
|
||||
inhalt_by_num: dict[int, str] = {}
|
||||
for p in inhalt_paths:
|
||||
if not p.exists():
|
||||
continue
|
||||
for sec in _parse_fragment(p.read_text(encoding="utf-8")):
|
||||
num = _titel_aufloesen(idx, sec["titel"])
|
||||
if num is not None and num not in inhalt_by_num and sec["md"].strip():
|
||||
inhalt_by_num[num] = sec["md"]
|
||||
if not inhalt_by_num:
|
||||
await _fail(guide_id, "Keine Inhalte identifiziert")
|
||||
return None
|
||||
|
||||
# Nachrunde: fehlende Bausteine (Chunk-Ausfall oder Lazy-Output) gezielt nachziehen — eine Runde.
|
||||
geplant_nums = [num for ch in plan for num in ch["nums"]]
|
||||
fehlend = [num for num in geplant_nums if num not in inhalt_by_num]
|
||||
if fehlend:
|
||||
_log(topic, f"Inhalte: {len(fehlend)} Baustein(e) fehlen — Nachrunde…")
|
||||
nach_chunks = [[{"title": "Weitere", "nums": fehlend[k:k + GUIDE_CHUNK]}] for k in range(0, len(fehlend), GUIDE_CHUNK)]
|
||||
nach_paths = [content_path.parent / f"{content_path.stem}.inhalt-nach-{k}.md" for k in range(1, len(nach_chunks) + 1)]
|
||||
nach_offen = [k for k, p in enumerate(nach_paths) if not p.exists()]
|
||||
if nach_offen:
|
||||
async def melde_n(d, t): await _set_step(guide_id, 1, f"Sammle fehlende Inhalte {d}/{t}…")
|
||||
await _gather_fortschritt([
|
||||
run_agent(
|
||||
f"{guide_id}-inhalt-nach-{k + 1}",
|
||||
_prompt(
|
||||
"Guide-Inhalt",
|
||||
topic=topic, zuteilung=_zuteilung_subs(nach_chunks[k], entries, subs_by_titel),
|
||||
facts=facts, out_path=nach_paths[k], extra=_extra(instructions),
|
||||
),
|
||||
_timeout("inhalt", len(nach_chunks[k][0]["nums"])), provider=provider, role="guide", capabilities="full",
|
||||
)
|
||||
for k in nach_offen
|
||||
], len(nach_chunks), melde_n, start=len(nach_chunks) - len(nach_offen))
|
||||
if is_cancelled():
|
||||
return None
|
||||
for p in nach_paths:
|
||||
if not p.exists():
|
||||
continue
|
||||
for sec in _parse_fragment(p.read_text(encoding="utf-8")):
|
||||
num = _titel_aufloesen(idx, sec["titel"])
|
||||
if num is not None and num not in inhalt_by_num and sec["md"].strip():
|
||||
inhalt_by_num[num] = sec["md"]
|
||||
|
||||
if all(p.exists() for p in inhalt_paths):
|
||||
_set_fertig(content_path, 1) # Inhalte vollständig
|
||||
|
||||
inhalt_chunk_nums = [[num for ch in chunk for num in ch["nums"] if num in inhalt_by_num] for chunk in chunks]
|
||||
|
||||
# Schritt 3: Inhalte prüfen — CHECK_PANEL Judges je Chunk, Mehrheit beanstandet.
|
||||
# + beanstandete einmal überarbeiten. Resume: nur fehlende Judge-Dateien neu starten.
|
||||
check_judge_paths = [
|
||||
[content_path.parent / f"{content_path.stem}.inhalt-check-{i}-j{j}.json" for j in range(1, CHECK_PANEL + 1)]
|
||||
for i in range(1, writer_count + 1)
|
||||
]
|
||||
offen_slots = [
|
||||
(i, j) for i in range(writer_count) if inhalt_chunk_nums[i]
|
||||
for j in range(CHECK_PANEL) if _lese_probleme_schema(_json_datei(check_judge_paths[i][j])) is None
|
||||
]
|
||||
if offen_slots:
|
||||
await _set_step(guide_id, 2, "Prüfe Inhalte…")
|
||||
sections_je_chunk = {
|
||||
i: "\n\n".join(f"SECTION: {_titel(entries[num])}\n{inhalt_by_num[num]}" for num in inhalt_chunk_nums[i])
|
||||
for i, _ in offen_slots
|
||||
}
|
||||
slots = [{
|
||||
"key": f"{guide_id}-inhalt-check-{i + 1}-j{j + 1}",
|
||||
"prompt": _prompt(
|
||||
"Guide-Inhalt-Check",
|
||||
topic=topic, format_name=format_name, sections=sections_je_chunk[i],
|
||||
out_path=check_judge_paths[i][j], extra=_extra(instructions),
|
||||
),
|
||||
"role": "judge", "capabilities": "files",
|
||||
"payload": (lambda result, p=check_judge_paths[i][j]: _lese_probleme_schema(_json_datei(p))),
|
||||
} for i, j in offen_slots]
|
||||
n_checks = len(slots)
|
||||
upd = lambda n: asyncio.create_task(_set_step(guide_id, 2, f"Prüfe Inhalte {n}/{n_checks}…"))
|
||||
await _race(topic, "Inhalts-Prüfung", slots, len(slots), _timeout("inhalt_check", max(chunk_sizes)), provider, on_update=upd, cancelled=is_cancelled, grace=KONSENS_GRACE)
|
||||
if is_cancelled():
|
||||
return None
|
||||
|
||||
probleme_by_num: dict[int, str] = {}
|
||||
for i in range(writer_count):
|
||||
if inhalt_chunk_nums[i]:
|
||||
probleme_by_num.update(_panel_probleme(check_judge_paths[i], set(inhalt_chunk_nums[i]), idx))
|
||||
|
||||
if probleme_by_num:
|
||||
_log(topic, f"Inhalts-Prüfung: {len(probleme_by_num)} Baustein(e) beanstandet")
|
||||
await _set_step(guide_id, 2, f"Überarbeite {len(probleme_by_num)} Inhalt(e)…")
|
||||
fix_chunks = [[num for num in nums if num in probleme_by_num] for nums in inhalt_chunk_nums]
|
||||
fix_paths = [content_path.parent / f"{content_path.stem}.inhalt-fix-{i + 1}.md" for i in range(writer_count)]
|
||||
fix_offen = [i for i, nums in enumerate(fix_chunks) if nums and not fix_paths[i].exists()]
|
||||
results = await asyncio.gather(*[
|
||||
run_agent(
|
||||
f"{guide_id}-inhalt-fix-{i + 1}",
|
||||
_prompt(
|
||||
"Guide-Inhalt-Fix",
|
||||
topic=topic, facts=facts,
|
||||
auftraege="\n\n".join(
|
||||
f"SECTION: {_titel(entries[num])}\nPROBLEM: {probleme_by_num[num]}\nAKTUELL:\n{inhalt_by_num[num]}"
|
||||
for num in fix_chunks[i]
|
||||
),
|
||||
out_path=fix_paths[i], extra=_extra(instructions),
|
||||
),
|
||||
_timeout("inhalt", len(fix_chunks[i])), provider=provider, role="guide", capabilities="full",
|
||||
)
|
||||
for i in fix_offen
|
||||
], return_exceptions=True)
|
||||
if is_cancelled():
|
||||
return None
|
||||
for p in fix_paths:
|
||||
if not p.exists():
|
||||
continue
|
||||
for sec in _parse_fragment(p.read_text(encoding="utf-8")):
|
||||
num = _titel_aufloesen(idx, sec["titel"])
|
||||
if num in probleme_by_num and sec["md"].strip():
|
||||
inhalt_by_num[num] = sec["md"]
|
||||
|
||||
_set_fertig(content_path, 2) # Inhalts-Check durch
|
||||
|
||||
# Schritt 4: Schreiben — Writer formuliert die geprüften Inhalte aus (Resume).
|
||||
# FEINE Chunks: genau 1 Baustein je Writer → variable Längen, kein Budget-Rationieren.
|
||||
def inhalte_text(chunk) -> str:
|
||||
nums = [num for ch in chunk for num in ch["nums"] if num in inhalt_by_num]
|
||||
return "\n\n".join(f"<!-- section: {_titel(entries[num])} -->\n{inhalt_by_num[num]}" for num in nums)
|
||||
|
||||
w_chunks = [[{"title": ch["title"], "nums": [num]}] for ch in plan for num in ch["nums"]]
|
||||
w_zuteil = [_zuteilung_subs(c, entries, subs_by_titel) for c in w_chunks]
|
||||
paths = [content_path.parent / f"{content_path.stem}.chunk-{i}.md" for i in range(1, len(w_chunks) + 1)]
|
||||
offen = [i for i, p in enumerate(paths) if not p.exists()]
|
||||
if offen:
|
||||
async def melde(d, t): await _set_step(guide_id, 3, f"Schreibe Sections {d}/{t}…")
|
||||
results = await _gather_fortschritt([
|
||||
run_agent(
|
||||
f"{guide_id}-w{i + 1}",
|
||||
_prompt(
|
||||
"Guide-Writer",
|
||||
topic=topic, format_name=format_name, zuteilung=w_zuteil[i],
|
||||
inhalte=inhalte_text(w_chunks[i]),
|
||||
spec=spec, out_path=paths[i], extra=_extra(instructions),
|
||||
),
|
||||
_timeout("writer", 1), provider=provider, role="guide", capabilities="files",
|
||||
)
|
||||
for i in offen
|
||||
], len(w_chunks), melde, start=len(w_chunks) - len(offen))
|
||||
if is_cancelled():
|
||||
return None
|
||||
for i, r in zip(offen, results):
|
||||
if isinstance(r, BaseException):
|
||||
_log(topic, f"Writer {i + 1}: {type(r).__name__}: {r}")
|
||||
elif r[0] != 0:
|
||||
_log(topic, f"Writer {i + 1}: {_claude_error('Fehler', *r)}")
|
||||
elif not paths[i].exists():
|
||||
_log(topic, f"Writer {i + 1}: keine Ausgabedatei erstellt")
|
||||
if not any(p.exists() for p in paths):
|
||||
await _fail(guide_id, _gather_error("Writer-Fehler", list(results)))
|
||||
return None
|
||||
|
||||
by_num: dict[int, dict] = {}
|
||||
for p in paths:
|
||||
if not p.exists():
|
||||
continue
|
||||
for sec in _parse_fragment(p.read_text(encoding="utf-8")):
|
||||
num = _titel_aufloesen(idx, sec["titel"])
|
||||
if num is None:
|
||||
_log(topic, f"Writer lieferte unbekannte Section '{sec['titel'][:40]}' (ignoriert)")
|
||||
elif num not in by_num:
|
||||
by_num[num] = sec
|
||||
if not by_num:
|
||||
await _fail(guide_id, "Keine Sections in der Writer-Ausgabe gefunden")
|
||||
return None
|
||||
|
||||
# Nachrunde: fehlende Sections (Writer-Ausfall) gezielt nachschreiben — eine Runde.
|
||||
nach_fehlend = [num for num in geplant_nums if num not in by_num]
|
||||
if nach_fehlend:
|
||||
_log(topic, f"Schreiben: {len(nach_fehlend)} Section(s) fehlen — Nachrunde…")
|
||||
nw_chunks = [[{"title": "Weitere", "nums": [num]}] for num in nach_fehlend]
|
||||
nw_paths = [content_path.parent / f"{content_path.stem}.chunk-nach-{k}.md" for k in range(1, len(nw_chunks) + 1)]
|
||||
nw_offen = [k for k, p in enumerate(nw_paths) if not p.exists()]
|
||||
if nw_offen:
|
||||
async def melde_nw(d, t): await _set_step(guide_id, 3, f"Schreibe fehlende Sections {d}/{t}…")
|
||||
await _gather_fortschritt([
|
||||
run_agent(
|
||||
f"{guide_id}-w-nach-{k + 1}",
|
||||
_prompt(
|
||||
"Guide-Writer",
|
||||
topic=topic, format_name=format_name, zuteilung=_zuteilung_subs(nw_chunks[k], entries, subs_by_titel),
|
||||
inhalte=inhalte_text(nw_chunks[k]), spec=spec, out_path=nw_paths[k], extra=_extra(instructions),
|
||||
),
|
||||
_timeout("writer", 1), provider=provider, role="guide", capabilities="files",
|
||||
)
|
||||
for k in nw_offen
|
||||
], len(nw_chunks), melde_nw, start=len(nw_chunks) - len(nw_offen))
|
||||
if is_cancelled():
|
||||
return None
|
||||
for p in nw_paths:
|
||||
if not p.exists():
|
||||
continue
|
||||
for sec in _parse_fragment(p.read_text(encoding="utf-8")):
|
||||
num = _titel_aufloesen(idx, sec["titel"])
|
||||
if num is not None and num not in by_num and sec["md"].strip():
|
||||
by_num[num] = sec
|
||||
|
||||
if all(p.exists() for p in paths):
|
||||
_set_fertig(content_path, 3) # Schreiben vollständig
|
||||
|
||||
# Schritt 3: Lese-Prüfungs-Loop — Check pro Writer-Paket, Fix nur für
|
||||
# beanstandete Sections; Folgerunden prüfen NUR die ersetzten Sections.
|
||||
# Nach dem Runden-Cap bleiben offene Beanstandungen stehen.
|
||||
chunk_nums = [[num for ch in chunk for num in ch["nums"] if num in by_num] for chunk in chunks]
|
||||
|
||||
def sections_text(nums: list[int]) -> str:
|
||||
return "\n\n".join(f"SECTION: {_titel(entries[num])}\n{by_num[num]['md']}" for num in nums)
|
||||
|
||||
def auftraege_text(nums: list[int], probleme: dict[int, str]) -> str:
|
||||
return "\n\n".join(
|
||||
f"SECTION: {_titel(entries[num])}\nPROBLEM: {probleme[num]}\nAKTUELLER INHALT:\n{by_num[num]['md']}"
|
||||
for num in nums
|
||||
)
|
||||
|
||||
scope = chunk_nums
|
||||
for runde in range(1, LESE_RUNDEN + 1):
|
||||
# CHECK_PANEL Judges je Paket; Mehrheit beanstandet. Aggregation robust gegen Einzel-Ausfall.
|
||||
check_judge_paths = [
|
||||
[content_path.parent / f"{content_path.stem}.lese-check-r{runde}-{i}-j{j}.json" for j in range(1, CHECK_PANEL + 1)]
|
||||
for i in range(1, writer_count + 1)
|
||||
]
|
||||
offen_slots = [
|
||||
(i, j) for i in range(writer_count) if scope[i]
|
||||
for j in range(CHECK_PANEL) if _lese_probleme_schema(_json_datei(check_judge_paths[i][j])) is None
|
||||
]
|
||||
if offen_slots:
|
||||
await _set_step(guide_id, 4, "Prüfe Lesbarkeit…")
|
||||
sections_je_chunk = {i: sections_text(scope[i]) for i, _ in offen_slots}
|
||||
slots = [{
|
||||
"key": f"{guide_id}-lese-check-r{runde}-{i + 1}-j{j + 1}",
|
||||
"prompt": _prompt(
|
||||
"Guide-Lese-Check",
|
||||
topic=topic, format_name=format_name, spec=spec,
|
||||
sections=sections_je_chunk[i],
|
||||
out_path=check_judge_paths[i][j], extra=_extra(instructions),
|
||||
),
|
||||
"role": "judge", "capabilities": "files",
|
||||
"payload": (lambda result, p=check_judge_paths[i][j]: _lese_probleme_schema(_json_datei(p))),
|
||||
} for i, j in offen_slots]
|
||||
n_checks = len(slots)
|
||||
upd = lambda n: asyncio.create_task(_set_step(guide_id, 4, f"Prüfe Lesbarkeit {n}/{n_checks}…"))
|
||||
res = await _race(topic, f"Lese-Prüfung r{runde}", slots, len(slots), _timeout("lese_check", max(chunk_sizes)), provider, on_update=upd, cancelled=is_cancelled, grace=KONSENS_GRACE)
|
||||
if is_cancelled():
|
||||
return None
|
||||
if res is None:
|
||||
_log(topic, f"Lese-Prüfung Runde {runde}: kein volles Quorum — vorhandene Judges aggregiert")
|
||||
|
||||
probleme_by_num: dict[int, str] = {}
|
||||
for i in range(writer_count):
|
||||
if scope[i]:
|
||||
probleme_by_num.update(_panel_probleme(check_judge_paths[i], set(scope[i]), idx))
|
||||
|
||||
# Deterministisches Lesbarkeits-Gate: zu schwere Sections in dieselbe
|
||||
# Überarbeitung einreihen (LLM-Beanstandung hat Vorrang). Gate aus → no-op.
|
||||
if LESBARKEIT_AKTIV:
|
||||
md_by_num = {num: by_num[num]["md"] for nums in scope for num in nums if num in by_num}
|
||||
hinweise = await asyncio.to_thread(lesbarkeit.bewerte_sections, md_by_num)
|
||||
if hinweise:
|
||||
_log(topic, f"Lesbarkeit: {len(hinweise)} Section(s) zu schwer")
|
||||
for num, hinweis in hinweise.items():
|
||||
probleme_by_num.setdefault(num, hinweis)
|
||||
|
||||
if not probleme_by_num:
|
||||
break
|
||||
|
||||
_log(topic, f"Lese-Prüfung Runde {runde}: {len(probleme_by_num)} Section(s) beanstandet")
|
||||
await _set_step(guide_id, 4, f"Überarbeite {len(probleme_by_num)} Section(s) (Runde {runde})…")
|
||||
fix_chunks = [[num for num in nums if num in probleme_by_num] for nums in chunk_nums]
|
||||
fix_paths = [content_path.parent / f"{content_path.stem}.fix-r{runde}-{i + 1}.md" for i in range(writer_count)]
|
||||
fix_offen = [i for i, nums in enumerate(fix_chunks) if nums and not fix_paths[i].exists()]
|
||||
results = await asyncio.gather(*[
|
||||
run_agent(
|
||||
f"{guide_id}-fix-r{runde}-w{i + 1}",
|
||||
_prompt(
|
||||
"Guide-Sections-Fix",
|
||||
topic=topic, format_name=format_name, facts=facts, spec=spec,
|
||||
auftraege=auftraege_text(fix_chunks[i], probleme_by_num),
|
||||
out_path=fix_paths[i], extra=_extra(instructions),
|
||||
),
|
||||
_timeout("writer", len(fix_chunks[i])), provider=provider, role="guide", capabilities="full",
|
||||
)
|
||||
for i in fix_offen
|
||||
], return_exceptions=True)
|
||||
if is_cancelled():
|
||||
return None
|
||||
for i, r in zip(fix_offen, results):
|
||||
if isinstance(r, BaseException) or (not isinstance(r, BaseException) and r[0] != 0):
|
||||
_log(topic, f"Sections-Fix {i + 1} (Runde {runde}) fehlgeschlagen — Original bleibt")
|
||||
ersetzt: set[int] = set()
|
||||
for p in fix_paths:
|
||||
if not p.exists():
|
||||
continue
|
||||
for sec in _parse_fragment(p.read_text(encoding="utf-8")):
|
||||
num = _titel_aufloesen(idx, sec["titel"])
|
||||
if num in probleme_by_num and sec["md"].strip():
|
||||
by_num[num] = sec
|
||||
ersetzt.add(num)
|
||||
_log(topic, f"Lese-Prüfung Runde {runde}: {len(ersetzt)} Section(s) überarbeitet")
|
||||
if not ersetzt:
|
||||
break
|
||||
if runde == LESE_RUNDEN:
|
||||
_log(topic, f"Lese-Prüfung: 1 Runde — Überarbeitung bleibt ungeprüft")
|
||||
break
|
||||
scope = [[num for num in nums if num in ersetzt] for nums in chunk_nums]
|
||||
_set_fertig(content_path, 4) # Lese-Prüfung durch
|
||||
|
||||
# Prüfbar = Format hat Prüfung UND Baustein hat ≥1 relevanten Subbaustein.
|
||||
# Guide ist immer prüfbar (auch ohne Relevanz-Daten, Fallback = alles).
|
||||
def _pruefbar(num):
|
||||
if format_name == "Guide":
|
||||
return True
|
||||
if format_name == "FullGuide":
|
||||
return any(isinstance(s, dict) and s.get("relevanz") == "relevant"
|
||||
for s in subs_raw.get(_titel(entries[num]), []))
|
||||
return False # Rest u.a. → reine Lese-Sections
|
||||
|
||||
await _set_progress(guide_id, "Setze zusammen…")
|
||||
chapters: list[dict] = []
|
||||
for ch in plan:
|
||||
sections = [
|
||||
{"num": num, "title": _titel(entries[num]), "md": by_num[num]["md"],
|
||||
"kompakt": by_num[num].get("kompakt", ""), "pruefbar": _pruefbar(num)}
|
||||
for num in ch["nums"] if num in by_num
|
||||
]
|
||||
if sections:
|
||||
chapters.append({"title": ch["title"], "sections": sections})
|
||||
geplant = {num for ch in plan for num in ch["nums"]}
|
||||
missing = sorted(geplant - set(by_num))
|
||||
if missing:
|
||||
_log(topic, f"Sections fehlen in der Writer-Ausgabe: {[_titel(entries[n]) for n in missing]}")
|
||||
if not chapters:
|
||||
await _fail(guide_id, "Keine Sections in der Writer-Ausgabe gefunden")
|
||||
return None
|
||||
return chapters
|
||||
|
||||
|
||||
async def reconcile_guides() -> None:
|
||||
"""DB↔Dateisystem abgleichen: status=done ohne Content-Datei → error.
|
||||
|
||||
Läuft beim Server-Start (nach init_db) — fängt Crashes zwischen
|
||||
Datei-Write und Status-Update ab.
|
||||
"""
|
||||
for g in await list_guides():
|
||||
if g["status"] == "done" and not guide_content_path(g["topic"], g["format"]).exists():
|
||||
log.warning("[%s] Guide %s: done ohne Content-Datei — auf error gesetzt", g["topic"], g["id"])
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
await update_guide(g["id"], status="error", error_msg="Inhalt fehlt — neu generieren", updated_at=now)
|
||||
|
||||
|
||||
async def generate_guide(guide_id: str, topic: str, format_name: str, instructions: str = "", provider: str = DEFAULT_PROVIDER, ab_step: int | None = None) -> None:
|
||||
async with _semaphore:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
await update_guide(guide_id, status="generating", progress="Starte…", updated_at=now)
|
||||
|
||||
content_path = guide_content_path(topic, format_name)
|
||||
content_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
project = quelle_ordner(topic) # Ordner-Quelle (projekt/uni/link) → Pfad, sonst None
|
||||
|
||||
try:
|
||||
if is_guide_cancelled(guide_id):
|
||||
return
|
||||
|
||||
if project:
|
||||
await asyncio.to_thread(_pdfs_konvertieren, project)
|
||||
|
||||
# Re-Run ab Schritt: Content + Slots ab `ab_step` löschen, Rest bleibt → Resume baut ab dort.
|
||||
# Sonst „Neu erstellen": fertiger Guide → kompletter Frischstart.
|
||||
# Sonst sind Schritt-Dateien Reste eines Abbruchs/Fehlers → Resume.
|
||||
if ab_step is not None:
|
||||
_reset_guide_ab_step(content_path, ab_step)
|
||||
elif content_path.exists():
|
||||
for p_alt in guide_slot_dateien(content_path):
|
||||
p_alt.unlink(missing_ok=True)
|
||||
|
||||
bs = await list_bausteine(topic, status="konsens")
|
||||
if bs:
|
||||
alle = {i: (f"{b['titel']} — {b['beschreibung']}" if b["beschreibung"] else b["titel"])
|
||||
for i, b in enumerate(bs, 1)}
|
||||
else: # Fallback: bausteine.md (Alt-Themen)
|
||||
bp = bausteine_path(topic)
|
||||
alle = _lade_bausteine(bp.read_text(encoding="utf-8")) if bp.exists() else {}
|
||||
if not alle:
|
||||
await _fail(guide_id, "Keine Bausteine gefunden")
|
||||
return
|
||||
entries = _eindeutige_titel(alle)
|
||||
facts = _prompt("Guide-Fakten-Projekt", project=project) if project else _prompt("Guide-Fakten-Thema")
|
||||
chapters = await _generate_sections(
|
||||
guide_id, topic, format_name, entries,
|
||||
facts, instructions, provider, content_path,
|
||||
)
|
||||
if chapters is None or is_guide_cancelled(guide_id):
|
||||
return
|
||||
content = {"topic": topic, "format": format_name, "chapters": chapters}
|
||||
|
||||
atomic_write_json(content_path, content, indent=1) # Brücke (Resume/Fallback)
|
||||
await set_guide_content(topic, format_name, json.dumps(content, ensure_ascii=False))
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
await update_guide(guide_id, status="done", progress=None, step=None, updated_at=now)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
await _fail(guide_id, "Timeout bei der Generierung")
|
||||
except FileNotFoundError:
|
||||
await _fail(guide_id, "Bausteine fehlen")
|
||||
except Exception as e:
|
||||
log.exception("[%s] Guide-Generierung fehlgeschlagen (%s)", topic, guide_id)
|
||||
await _fail(guide_id, str(e)[:2000])
|
||||
finally:
|
||||
clear_guide_cancelled(guide_id)
|
||||
49
backend/jsonio.py
Normal file
49
backend/jsonio.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Toleranter JSON-Parser für KI-Output — als Text oder aus Dateien.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger("creator.jsonio")
|
||||
|
||||
|
||||
def parse_json_text(text: str):
|
||||
"""Parst JSON aus KI-Output; None bei nicht reparierbarem Input."""
|
||||
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", (text or "").strip())
|
||||
start, end = text.find("{"), text.rfind("}")
|
||||
if start == -1 or end <= start:
|
||||
return None
|
||||
candidate = text[start:end + 1]
|
||||
for _ in range(20):
|
||||
try:
|
||||
return json.loads(candidate)
|
||||
except json.JSONDecodeError as e:
|
||||
if not e.msg.startswith(("Expecting ',' delimiter", "Expecting ':' delimiter")):
|
||||
return None
|
||||
q = candidate.rfind('"', 0, e.pos)
|
||||
if q <= 0:
|
||||
return None
|
||||
candidate = candidate[:q] + '\\"' + candidate[q + 1:]
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def read_json_file(path: Path):
|
||||
"""Liest eine JSON-Datei mit derselben Toleranz; None bei fehlend/ungültig."""
|
||||
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)
|
||||
return None
|
||||
if data is None:
|
||||
log.debug("JSON-Datei ungültig: %s", path)
|
||||
return data
|
||||
537
backend/lernen.py
Normal file
537
backend/lernen.py
Normal file
@@ -0,0 +1,537 @@
|
||||
"""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)
|
||||
115
backend/lesbarkeit.py
Normal file
115
backend/lesbarkeit.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""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
|
||||
11
backend/logsetup.py
Normal file
11
backend/logsetup.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""Zentrales Logging-Setup — einmal in main.py aufrufen, bevor die App entsteht."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
logging.basicConfig(
|
||||
level=os.environ.get("LOG_LEVEL", "INFO").upper(),
|
||||
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||
)
|
||||
@@ -3,8 +3,13 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from logsetup import setup_logging
|
||||
|
||||
setup_logging()
|
||||
|
||||
from config import FRONTEND_DIST, STORAGE_DIR
|
||||
from database import init_db, close_db
|
||||
from guide import reconcile_guides
|
||||
from routes import router
|
||||
|
||||
|
||||
@@ -12,6 +17,7 @@ from routes import router
|
||||
async def lifespan(app: FastAPI):
|
||||
(STORAGE_DIR / "themen").mkdir(parents=True, exist_ok=True)
|
||||
await init_db()
|
||||
await reconcile_guides()
|
||||
yield
|
||||
await close_db()
|
||||
|
||||
|
||||
@@ -2,13 +2,14 @@ from pydantic import BaseModel, Field
|
||||
from typing import Literal
|
||||
|
||||
FormatType = Literal[
|
||||
"OnePager",
|
||||
"MiniGuide",
|
||||
"Guide",
|
||||
"FullGuide",
|
||||
"Rest",
|
||||
]
|
||||
|
||||
ProviderType = Literal["claude", "minimax", "minimax-direkt", "lokal"]
|
||||
ProviderType = Literal["claude", "minimax", "lokal"]
|
||||
|
||||
SourceType = Literal["thema", "projekt", "uni", "link"]
|
||||
|
||||
|
||||
class GuideCreateRequest(BaseModel):
|
||||
@@ -16,6 +17,7 @@ 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
|
||||
|
||||
|
||||
class TopicCreateRequest(BaseModel):
|
||||
@@ -26,6 +28,9 @@ class BausteineCreateRequest(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
|
||||
|
||||
|
||||
class BausteineStep(BaseModel):
|
||||
@@ -46,6 +51,37 @@ class ProjectResponse(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class FolderResponse(BaseModel):
|
||||
name: str
|
||||
ort: str # relativer Pfad ab Repo-Root (z.B. "projects/foo")
|
||||
|
||||
|
||||
class BausteineQuelleUpdate(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
type: SourceType = "thema"
|
||||
ort: str = Field(default="", max_length=2000)
|
||||
spec: str = Field(default="", max_length=2000)
|
||||
|
||||
|
||||
class BausteineQuelleResponse(BaseModel):
|
||||
type: SourceType
|
||||
ort: str
|
||||
spec: str
|
||||
|
||||
|
||||
class SubbausteinInfo(BaseModel):
|
||||
titel: str
|
||||
stufe: Literal["einfach", "mittel", "schwer"]
|
||||
relevanz: Literal["relevant", "rand"] | None = None
|
||||
|
||||
|
||||
class BausteinUebersicht(BaseModel):
|
||||
num: int
|
||||
titel: str
|
||||
beschreibung: str = ""
|
||||
subbausteine: list[SubbausteinInfo] = []
|
||||
|
||||
|
||||
class ProviderInfo(BaseModel):
|
||||
id: str
|
||||
available: bool
|
||||
@@ -86,8 +122,6 @@ class ElementResponse(BaseModel):
|
||||
description: str = ""
|
||||
examples: list[str] = []
|
||||
hints: list[str] = []
|
||||
aufgabe: str = ""
|
||||
loesung: str = ""
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
@@ -103,8 +137,6 @@ class ElementUpdateRequest(BaseModel):
|
||||
description: str | None = None
|
||||
examples: list[str] | None = None
|
||||
hints: list[str] | None = None
|
||||
aufgabe: str | None = None
|
||||
loesung: str | None = None
|
||||
|
||||
|
||||
class ElementCheckRequest(BaseModel):
|
||||
@@ -113,7 +145,7 @@ class ElementCheckRequest(BaseModel):
|
||||
|
||||
class ElementSuggestion(BaseModel):
|
||||
text: str
|
||||
target: Literal["description", "examples", "hints", "aufgabe", "loesung"]
|
||||
target: Literal["description", "examples", "hints"]
|
||||
content: str
|
||||
|
||||
|
||||
@@ -124,7 +156,7 @@ class ElementCheckResponse(BaseModel):
|
||||
class ElementStyleChange(BaseModel):
|
||||
text: str
|
||||
action: Literal["entfernen", "anpassen", "hinzufuegen"]
|
||||
target: Literal["title", "description", "examples", "hints", "aufgabe", "loesung"]
|
||||
target: Literal["title", "description", "examples", "hints"]
|
||||
index: int | None = None
|
||||
content: str = ""
|
||||
|
||||
@@ -160,3 +192,79 @@ class ProgressUpdate(BaseModel):
|
||||
|
||||
class ProgressResponse(BaseModel):
|
||||
chapters: list[str]
|
||||
|
||||
|
||||
# --- Baustein-Lernen ---
|
||||
|
||||
class BausteinChatRequest(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)
|
||||
messages: list[ChatMessage] = Field(min_length=1)
|
||||
provider: ProviderType = "claude"
|
||||
|
||||
|
||||
class BausteinChatResponse(BaseModel):
|
||||
reply: str
|
||||
|
||||
|
||||
class BausteinPruefungRequest(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
|
||||
provider: ProviderType = "claude"
|
||||
gruendlich: bool = False # „Gründlich prüfen": Bewertung mit starkem Modell (role guide)
|
||||
|
||||
|
||||
class QuizOption(BaseModel):
|
||||
text: str
|
||||
korrekt: bool
|
||||
|
||||
|
||||
class BausteinPruefungResponse(BaseModel):
|
||||
frage: 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
|
||||
|
||||
|
||||
class BausteinLernstand(BaseModel):
|
||||
gute_antworten: int
|
||||
streak: int = 0
|
||||
absolviert: bool
|
||||
verstanden: bool
|
||||
gemeistert: bool
|
||||
|
||||
|
||||
class BausteinLernstandResponse(BaseModel):
|
||||
bausteine: dict[str, BausteinLernstand]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from pathlib import Path
|
||||
|
||||
from config import STORAGE_DIR, PROJECTS_DIR
|
||||
from config import STORAGE_DIR, PROJECTS_DIR, PROJECT_ROOT
|
||||
|
||||
THEMEN_DIR = STORAGE_DIR / "themen"
|
||||
|
||||
@@ -21,6 +21,38 @@ def bausteine_path(topic: str) -> Path:
|
||||
return topic_dir(topic) / "bausteine.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 frage_muster_path(topic: str) -> Path:
|
||||
"""Sidecar: pro Baustein vordefinierte Frage-Muster (Subbaustein × Typ → Beispielfrage)."""
|
||||
return topic_dir(topic) / "frage_muster.json"
|
||||
|
||||
|
||||
def quelle_path(topic: str) -> Path:
|
||||
"""Persistierte Quellen-Wahl pro Thema: {type, ort, spec}."""
|
||||
return topic_dir(topic) / "quelle.json"
|
||||
|
||||
|
||||
def quelle_crawl_dir(topic: str) -> Path:
|
||||
"""Zielordner für gecrawlte Link-Quellen (Seiten + PDF-.txt)."""
|
||||
return topic_dir(topic) / "quelle"
|
||||
|
||||
|
||||
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():
|
||||
return None
|
||||
p = (PROJECT_ROOT / ort.strip()).resolve()
|
||||
try:
|
||||
p.relative_to(PROJECT_ROOT)
|
||||
except ValueError:
|
||||
return None
|
||||
return p
|
||||
|
||||
|
||||
def guide_content_path(topic: str, format_name: str) -> Path:
|
||||
return topic_dir(topic) / "guides" / f"{format_name}.json"
|
||||
|
||||
|
||||
328
backend/pipeline.py
Normal file
328
backend/pipeline.py
Normal file
@@ -0,0 +1,328 @@
|
||||
"""Pipeline-Grundbausteine: Agent-Races (mit Grace), Single-Slot, Schemata, 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.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
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 textkit import _STUFEN
|
||||
|
||||
log = logging.getLogger("creator.pipeline")
|
||||
|
||||
_semaphore = asyncio.Semaphore(MAX_CONCURRENT_GENERATIONS)
|
||||
_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
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
await update_guide(guide_id, status="error", progress=None, error_msg="Abgebrochen — Fortschritt bleibt erhalten", updated_at=now)
|
||||
return True
|
||||
|
||||
|
||||
def is_guide_cancelled(guide_id: str) -> bool:
|
||||
return guide_id in _cancelled
|
||||
|
||||
|
||||
def clear_guide_cancelled(guide_id: str) -> None:
|
||||
_cancelled.discard(guide_id)
|
||||
clear_scope(f"{guide_id}-") # Scope leeren → Neustart blockiert nicht
|
||||
|
||||
|
||||
async def _set_progress(guide_id: str, progress: str) -> None:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
await update_guide(guide_id, progress=progress, updated_at=now)
|
||||
|
||||
|
||||
async def _set_step(guide_id: str, step: int, progress: str) -> None:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
await update_guide(guide_id, step=step, progress=progress, updated_at=now)
|
||||
|
||||
|
||||
async def _fail(guide_id: str, msg: str) -> None:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
await update_guide(guide_id, status="error", progress=None, error_msg=msg, updated_at=now)
|
||||
|
||||
|
||||
def _prompt(name: str, **kwargs) -> str:
|
||||
template = (TEMPLATES_DIR / "Prompt" / f"{name}.md").read_text(encoding="utf-8")
|
||||
return template.format(**kwargs)
|
||||
|
||||
|
||||
def _extra(instructions: str) -> str:
|
||||
return f"\n\nZUSÄTZLICHE ANWEISUNGEN VOM NUTZER:\n{instructions}\n" if instructions else ""
|
||||
|
||||
|
||||
def _log(topic: str, msg: str) -> None:
|
||||
log.info("[%s] %s", topic, msg)
|
||||
|
||||
|
||||
def _claude_error(label: str, returncode: int, stdout: str, stderr: str) -> str:
|
||||
stderr = (stderr or "").strip()
|
||||
if stderr:
|
||||
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)"
|
||||
|
||||
|
||||
def _gather_error(label: str, results: list) -> str:
|
||||
for r in results:
|
||||
if isinstance(r, BaseException):
|
||||
return f"{label}: {type(r).__name__}: {r}"
|
||||
returncode, stdout, stderr = r
|
||||
if returncode != 0:
|
||||
return _claude_error(label, returncode, stdout, stderr)
|
||||
return f"{label}: kein verwertbares Ergebnis"
|
||||
|
||||
|
||||
def _timeout(step: str, n: int = 0) -> int:
|
||||
base, per = TIMEOUTS[step]
|
||||
return base + per * n
|
||||
|
||||
|
||||
def _probleme_schema(data):
|
||||
"""{"ok": true} → [] · {"probleme": [str]} → Liste · sonst None."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
if data.get("ok") is True:
|
||||
return []
|
||||
p = data.get("probleme")
|
||||
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."""
|
||||
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.
|
||||
|
||||
final=True: letzte Klärungs-Runde — ein nicht-leerer Rest ist ungültig.
|
||||
"""
|
||||
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):
|
||||
return None
|
||||
return aufnehmen, rest
|
||||
|
||||
|
||||
def _stufen_schema(data, ids: set[int] | None = None):
|
||||
"""{"stufen": {"1": "einfach", …}} → {id: stufe} · sonst None.
|
||||
|
||||
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):
|
||||
return None
|
||||
stufe = str(v).strip().casefold()
|
||||
if stufe not in _STUFEN:
|
||||
return None
|
||||
out[num] = stufe
|
||||
if ids is not None and not ids <= set(out):
|
||||
return None
|
||||
return out
|
||||
|
||||
|
||||
_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
|
||||
|
||||
|
||||
_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.
|
||||
|
||||
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).
|
||||
|
||||
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.
|
||||
"""
|
||||
attempts = {i: 0 for i in range(len(slots))}
|
||||
tasks: dict[asyncio.Task, int] = {}
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline: float | None = None
|
||||
|
||||
def spawn(i: int) -> None:
|
||||
slot = slots[i]
|
||||
task = asyncio.create_task(run_agent(
|
||||
slot["key"], slot["prompt"], timeout,
|
||||
provider=provider, role=slot["role"], capabilities=slot["capabilities"],
|
||||
))
|
||||
tasks[task] = i
|
||||
|
||||
for i in range(len(slots)):
|
||||
spawn(i)
|
||||
|
||||
results: list = []
|
||||
try:
|
||||
while tasks:
|
||||
if cancelled and cancelled():
|
||||
return None
|
||||
if deadline is not None and len(results) >= quorum and loop.time() >= deadline:
|
||||
return results
|
||||
# Grace gesetzt und Minimum erreicht → nur bis zum Deadline-Rest warten
|
||||
wait_timeout = None
|
||||
if deadline is not None and len(results) >= quorum:
|
||||
wait_timeout = max(0.0, deadline - loop.time())
|
||||
done, _ = await asyncio.wait(tasks.keys(), return_when=asyncio.FIRST_COMPLETED, timeout=wait_timeout)
|
||||
if not done:
|
||||
continue
|
||||
for task in done:
|
||||
i = tasks.pop(task)
|
||||
payload, err = None, None
|
||||
try:
|
||||
result = task.result()
|
||||
if result[0] != 0:
|
||||
err = _claude_error("Fehler", *result)
|
||||
else:
|
||||
payload = slots[i]["payload"](result)
|
||||
if payload is None:
|
||||
err = "Ergebnis ungültig/nicht parsebar"
|
||||
except asyncio.TimeoutError:
|
||||
err = f"Timeout nach {timeout}s"
|
||||
except Exception as e:
|
||||
err = f"{type(e).__name__}: {e}"
|
||||
|
||||
if payload is not None:
|
||||
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")
|
||||
if on_update:
|
||||
on_update(len(results))
|
||||
if len(results) >= quorum and (grace is None or loop.time() >= deadline):
|
||||
return results
|
||||
continue
|
||||
|
||||
_log(topic, f"{label} {i + 1} (Versuch {attempts[i] + 1}): {err}")
|
||||
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()):
|
||||
spawn(i)
|
||||
if len(results) >= quorum: # alle Slots durch, Minimum steht (nur mit grace erreichbar)
|
||||
return results
|
||||
_log(topic, f"{label}: Quorum {quorum} nicht erreicht ({len(results)} gültig)")
|
||||
return None
|
||||
finally:
|
||||
for task, i in tasks.items():
|
||||
kill_process(slots[i]["key"])
|
||||
task.cancel()
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks.keys(), return_exceptions=True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GenContext:
|
||||
"""Durchgereichte Pipeline-Parameter — erspart lange Argument-Signaturen."""
|
||||
topic: str
|
||||
provider: str
|
||||
is_cancelled: Callable[[], bool]
|
||||
guide_id: str | None = None
|
||||
|
||||
|
||||
# Ergebnis-Status von 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,
|
||||
) -> tuple[str, object]:
|
||||
"""Ein Agent, ein gültiges Ergebnis (Race mit Quorum 1).
|
||||
|
||||
→ (OK, wert) | (CANCELLED, None) | (FAILED, None)
|
||||
"""
|
||||
slots = [{"key": key, "prompt": prompt, "role": role, "capabilities": capabilities, "payload": payload}]
|
||||
res = await _race(ctx.topic, label, slots, 1, timeout, ctx.provider, cancelled=ctx.is_cancelled)
|
||||
if ctx.is_cancelled():
|
||||
return CANCELLED, None
|
||||
if res is None:
|
||||
return FAILED, None
|
||||
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."""
|
||||
done = start
|
||||
|
||||
async def wrap(c):
|
||||
nonlocal done
|
||||
try:
|
||||
return await c
|
||||
finally:
|
||||
done += 1
|
||||
await melde(done, total)
|
||||
|
||||
await melde(done, total)
|
||||
return await asyncio.gather(*[wrap(c) for c in coros], return_exceptions=True)
|
||||
|
||||
|
||||
140
backend/regeln.py
Normal file
140
backend/regeln.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""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
|
||||
@@ -1,3 +1,7 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
aiosqlite
|
||||
playwright
|
||||
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,25 +1,28 @@
|
||||
import asyncio
|
||||
import json
|
||||
import shutil
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.responses import FileResponse, Response
|
||||
|
||||
from agents import provider_available
|
||||
from config import PROJECTS_DIR, PROVIDERS
|
||||
from config import PROJECTS_DIR, UNI_DIR, PROVIDERS
|
||||
from database import (
|
||||
create_guide, delete_guide, get_guide, list_guides,
|
||||
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,
|
||||
)
|
||||
from generator import (
|
||||
generate_guide, cancel_guide, chat_with_guide, guide_slot_dateien,
|
||||
generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine,
|
||||
generate_element, chat_with_element, check_element, style_element, refine_suggestion,
|
||||
)
|
||||
from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, lade_quelle, lade_uebersicht, subbausteine_titel, lade_frage_muster
|
||||
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 pipeline import cancel_guide
|
||||
from regeln import FORMATE, formate_stats, guide_lock, ist_absolviert, lade_lernstand, thema_abgeschlossen
|
||||
from models import (
|
||||
GuideCreateRequest, GuideResponse,
|
||||
TopicCreateRequest,
|
||||
@@ -29,8 +32,12 @@ from models import (
|
||||
ElementUpdateRequest, ElementCheckRequest, ElementCheckResponse, ElementStyleResponse,
|
||||
ElementRefineRequest, ElementRefineResponse,
|
||||
ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo,
|
||||
FolderResponse, BausteineQuelleUpdate, BausteineQuelleResponse, BausteinUebersicht,
|
||||
BausteinChatRequest, BausteinChatResponse,
|
||||
BausteinPruefungRequest, BausteinPruefungResponse, BausteinLernstandResponse,
|
||||
)
|
||||
from paths import bausteine_path, bausteine_topics, guide_content_path, project_dir, topic_dir
|
||||
from paths import bausteine_topics, guide_content_path, project_dir, topic_dir, quelle_path, safe_ordner
|
||||
from fsutil import atomic_write_json
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
@@ -51,73 +58,23 @@ async def get_topics():
|
||||
return db_topics + sorted(derived - set(db_topics))
|
||||
|
||||
|
||||
# Lernschulden-Regeln (nur Neu-Erstellungen; Themen, Bausteine, OnePager unbegrenzt):
|
||||
# - JE Format (MiniGuide/Guide/FullGuide) höchstens 3 erstellte, nicht absolvierte Guides
|
||||
# - Progression pro Thema: Guide erst nach absolviertem MiniGuide, FullGuide erst nach absolviertem Guide
|
||||
MAX_OFFENE_GUIDES = 3
|
||||
VORSTUFE = {"Guide": "MiniGuide", "FullGuide": "Guide"}
|
||||
|
||||
|
||||
async def _ist_absolviert(topic: str, fmt: str) -> bool:
|
||||
"""Alle Kapitel des neuesten fertigen Guides (Thema+Format) abgehakt?"""
|
||||
neueste = None
|
||||
for g in await list_guides():
|
||||
if g["topic"] == topic and g["format"] == fmt and g["status"] == "done":
|
||||
if neueste is None or g["created_at"] > neueste["created_at"]:
|
||||
neueste = g
|
||||
if neueste is None:
|
||||
return False
|
||||
path = guide_content_path(topic, fmt)
|
||||
if not path.exists():
|
||||
return False
|
||||
try:
|
||||
chapters = json.loads(path.read_text(encoding="utf-8")).get("chapters", [])
|
||||
except ValueError:
|
||||
return False
|
||||
titles = {c.get("title") for c in chapters}
|
||||
return bool(titles) and titles <= set(await list_progress(neueste["id"]))
|
||||
|
||||
|
||||
async def _formate_stats() -> dict:
|
||||
"""Pro Format erstellt/absolviert (alle Kapitel abgehakt) — pro Thema zählt nur der neueste fertige Guide."""
|
||||
guides = await list_guides()
|
||||
formate = {}
|
||||
for fmt in ("MiniGuide", "Guide", "FullGuide"):
|
||||
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
|
||||
absolviert = 0
|
||||
for g in neueste.values():
|
||||
path = guide_content_path(g["topic"], fmt)
|
||||
if not path.exists():
|
||||
continue
|
||||
try:
|
||||
chapters = json.loads(path.read_text(encoding="utf-8")).get("chapters", [])
|
||||
except ValueError:
|
||||
continue
|
||||
titles = {c.get("title") for c in chapters}
|
||||
if titles and titles <= set(await list_progress(g["id"])):
|
||||
absolviert += 1
|
||||
formate[fmt] = {"erstellt": len(neueste), "absolviert": absolviert}
|
||||
return formate
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_stats():
|
||||
"""Tracker: Themen-Anzahl + pro Format erstellt/absolviert."""
|
||||
guides = await list_guides()
|
||||
guides, progress, levels = await lade_lernstand()
|
||||
themen = set(await db_list_topics()) | {g["topic"] for g in guides} | set(bausteine_topics())
|
||||
if PROJECTS_DIR.is_dir():
|
||||
themen |= {e.name for e in PROJECTS_DIR.iterdir() if e.is_dir()}
|
||||
return {"themen": len(themen), "formate": await _formate_stats()}
|
||||
return {"themen": len(themen), "formate": formate_stats(guides, progress, levels)}
|
||||
|
||||
|
||||
@router.get("/topics/fortschritt")
|
||||
async def topic_fortschritt(topic: str):
|
||||
"""Absolviert-Status pro Format — fürs Freischalten der nächsten Ausbaustufe."""
|
||||
return {fmt: await _ist_absolviert(topic, fmt) for fmt in ("MiniGuide", "Guide", "FullGuide")}
|
||||
"""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)
|
||||
return status
|
||||
|
||||
|
||||
@router.post("/topics")
|
||||
@@ -129,6 +86,10 @@ 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_topic_pipeline(topic)
|
||||
await delete_quelle(topic) # Themen-Config (DB) — beim Thema-Löschen mit weg
|
||||
await delete_guide_content(topic)
|
||||
shutil.rmtree(topic_dir(topic), ignore_errors=True)
|
||||
return {"ok": True}
|
||||
|
||||
@@ -156,6 +117,18 @@ async def remove_project(name: str):
|
||||
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/."""
|
||||
base = {"projekt": (PROJECTS_DIR, "projects"), "uni": (UNI_DIR, "uni")}.get(kind)
|
||||
if base is None:
|
||||
raise HTTPException(400, "kind muss 'projekt' oder 'uni' sein")
|
||||
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()]
|
||||
|
||||
|
||||
# --- Bausteine ---
|
||||
|
||||
@router.get("/bausteine/status", response_model=BausteineStatusResponse)
|
||||
@@ -174,7 +147,20 @@ async def create_bausteine(req: BausteineCreateRequest):
|
||||
if bausteine_status(topic)["generating"]:
|
||||
return {"ok": True, "status": "already_generating"}
|
||||
await create_topic(topic)
|
||||
asyncio.create_task(generate_bausteine(topic, req.instructions.strip(), req.provider))
|
||||
qp = quelle_path(topic)
|
||||
# Quelle nur beim ERSTEN Mal festschreiben; ▶/Resume erhält die bestehende Wahl.
|
||||
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.")
|
||||
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))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -187,31 +173,283 @@ async def cancel_bausteine_route(topic: str):
|
||||
|
||||
@router.delete("/bausteine")
|
||||
async def remove_bausteine(topic: str):
|
||||
reset_bausteine(topic)
|
||||
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
|
||||
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.get("/bausteine/quelle", response_model=BausteineQuelleResponse)
|
||||
async def get_bausteine_quelle(topic: str):
|
||||
return lade_quelle(topic)
|
||||
|
||||
|
||||
@router.put("/bausteine/quelle", response_model=BausteineQuelleResponse)
|
||||
async def update_bausteine_quelle(req: BausteineQuelleUpdate):
|
||||
"""Nur speichern — KEINE Neugenerierung. Quellen-/Spec-Wahl überschreiben."""
|
||||
topic, typ, ort = req.topic.strip(), req.type, req.ort.strip()
|
||||
_validate_quelle(typ, ort)
|
||||
qp = quelle_path(topic)
|
||||
qp.parent.mkdir(parents=True, exist_ok=True)
|
||||
daten = {"type": typ, "ort": ort, "spec": req.spec.strip()}
|
||||
atomic_write_json(qp, daten)
|
||||
return daten
|
||||
|
||||
|
||||
@router.get("/bausteine/uebersicht", response_model=list[BausteinUebersicht])
|
||||
async def get_bausteine_uebersicht(topic: str):
|
||||
return await lade_uebersicht(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)}
|
||||
|
||||
|
||||
# --- Baustein-Lernen: Chat, Prüfung ---
|
||||
|
||||
@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,
|
||||
}
|
||||
for p in progress
|
||||
}
|
||||
return {"bausteine": bausteine}
|
||||
|
||||
|
||||
@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,
|
||||
[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] = {}
|
||||
|
||||
|
||||
def _pruef_lock(topic: str, baustein: str) -> asyncio.Lock:
|
||||
key = (topic, baustein)
|
||||
lock = _pruef_locks.get(key)
|
||||
if lock is None:
|
||||
lock = _pruef_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 _farbe(punkte: int) -> str:
|
||||
"""Punkte-Delta → grobe Einfärbung der Bubble."""
|
||||
return "gut" if punkte > 0 else ("neutral" if punkte == 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 _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
|
||||
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)
|
||||
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}
|
||||
|
||||
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 reply is None:
|
||||
raise HTTPException(502, "Diskussion fehlgeschlagen — bitte erneut versuchen")
|
||||
return {"reply": reply, "gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
|
||||
|
||||
# --- 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)
|
||||
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}
|
||||
|
||||
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."
|
||||
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")
|
||||
if req.schwer:
|
||||
lt = await lueckentext_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, 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)
|
||||
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}
|
||||
|
||||
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)
|
||||
res["feedback"] = feedback
|
||||
return res
|
||||
|
||||
# aktion "antwort" (Agent 1 schnell) / "antwort_pruefen" (Agent 2 genau).
|
||||
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")
|
||||
|
||||
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 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}
|
||||
|
||||
# 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}
|
||||
|
||||
|
||||
# --- Guides ---
|
||||
|
||||
@router.post("/guides", response_model=GuideResponse)
|
||||
async def create(req: GuideCreateRequest):
|
||||
if req.format != "OnePager" and not bausteine_path(req.topic.strip()).exists():
|
||||
raise HTTPException(400, "Erst Bausteine erstellen")
|
||||
# Kein Duplikat-Start: pro Thema+Format höchstens eine laufende Generierung
|
||||
for g in await list_guides():
|
||||
if g["topic"] == req.topic.strip() and g["format"] == req.format and g["status"] in ("queued", "generating"):
|
||||
raise HTTPException(409, "Generierung läuft bereits")
|
||||
# Lernschulden-Regeln — nur für Neu-Erstellungen; Resume (Schritt-Dateien
|
||||
# vorhanden) und Neu-Generieren bestehender Guides sind ausgenommen.
|
||||
content = guide_content_path(req.topic.strip(), req.format)
|
||||
if req.format != "OnePager" and not content.exists() and not guide_slot_dateien(content):
|
||||
vorstufe = VORSTUFE.get(req.format)
|
||||
if vorstufe and not await _ist_absolviert(req.topic.strip(), vorstufe):
|
||||
raise HTTPException(409, f"Erst den {vorstufe} dieses Themas absolvieren")
|
||||
stat = (await _formate_stats()).get(req.format, {"erstellt": 0, "absolviert": 0})
|
||||
offen = stat["erstellt"] - stat["absolviert"]
|
||||
if offen >= MAX_OFFENE_GUIDES:
|
||||
raise HTTPException(409, f"Erst {req.format}s absolvieren — maximal {MAX_OFFENE_GUIDES} offene erlaubt ({offen} offen)")
|
||||
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)
|
||||
await create_topic(req.topic.strip())
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
guide = {
|
||||
@@ -225,7 +463,7 @@ async def create(req: GuideCreateRequest):
|
||||
"updated_at": now,
|
||||
}
|
||||
await create_guide(guide)
|
||||
asyncio.create_task(generate_guide(guide["id"], guide["topic"], guide["format"], guide["instructions"], req.provider))
|
||||
asyncio.create_task(generate_guide(guide["id"], guide["topic"], guide["format"], guide["instructions"], req.provider, ab_step=req.ab_step))
|
||||
return guide
|
||||
|
||||
|
||||
@@ -234,6 +472,20 @@ async def list_all():
|
||||
return await list_guides()
|
||||
|
||||
|
||||
@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()
|
||||
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")}
|
||||
|
||||
|
||||
@router.get("/guides/{guide_id}", response_model=GuideResponse)
|
||||
async def get_one(guide_id: str):
|
||||
guide = await get_guide(guide_id)
|
||||
@@ -249,7 +501,10 @@ async def guide_content(guide_id: str):
|
||||
raise HTTPException(404, "Guide nicht gefunden")
|
||||
if guide["status"] != "done":
|
||||
raise HTTPException(404, "Inhalt nicht verfügbar")
|
||||
path = guide_content_path(guide["topic"], guide["format"])
|
||||
stored = await get_guide_content(guide["topic"], guide["format"]) # DB-first
|
||||
if stored is not None:
|
||||
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")
|
||||
@@ -364,6 +619,7 @@ async def remove(guide_id: str, slots: bool = False):
|
||||
# Content) bleibt fürs Resume erhalten, außer es wird explizit verlangt (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):
|
||||
|
||||
239
backend/textkit.py
Normal file
239
backend/textkit.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""Reine Text-Helfer: Titel-Normalisierung, Listen-Parser, Chunk-Aufteilung.
|
||||
|
||||
Kein Zustand, keine IO — überall gefahrlos importierbar.
|
||||
"""
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
_CATEGORIES = ("KERN", "WICHTIG", "REST") # nur noch für den Altformat-Reader
|
||||
|
||||
|
||||
def _norm_titel(s: str) -> str:
|
||||
"""Normalisiert einen Titel für den Schlüssel-Vergleich.
|
||||
|
||||
NFKC + casefold fangen Unicode-Varianten; Anführungszeichen, Markdown-
|
||||
Emphasis und Dash-Varianten kommen aus KI-Output in allen Spielarten.
|
||||
"""
|
||||
s = unicodedata.normalize("NFKC", s)
|
||||
s = re.sub(r"[`'\"<>„“”‚’«»*_]", "", s)
|
||||
s = re.sub(r"[–—‐]", "-", s)
|
||||
s = re.sub(r"\s+", " ", s).strip().strip(".:;").strip()
|
||||
return s.casefold()
|
||||
|
||||
|
||||
def _titel(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."""
|
||||
seen: dict[str, int] = {}
|
||||
out: dict[int, str] = {}
|
||||
for num, text in entries.items():
|
||||
titel = _titel(text)
|
||||
key = _norm_titel(titel)
|
||||
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
|
||||
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 _titel_aufloesen(idx: dict[str, int], t: str) -> int | None:
|
||||
"""Titel → Nummer; toleriert mitgeschleppte Beschreibungen ("Titel — …")."""
|
||||
if not isinstance(t, str):
|
||||
return None
|
||||
return idx.get(_norm_titel(t)) or idx.get(_norm_titel(_titel(t)))
|
||||
|
||||
|
||||
def _parse_auswahl(text: str) -> dict[int, str]:
|
||||
"""Parst eine Baustein-Liste: `N. Titel — Kurzbeschreibung` pro Zeile."""
|
||||
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)
|
||||
elif last is not None and line.strip():
|
||||
entries[last] += " " + line.strip()
|
||||
return entries
|
||||
|
||||
|
||||
def _parse_kategorien(text: str) -> dict[str, list[str]]:
|
||||
"""Altformat-Reader: finale Baustein-Datei mit ## KERN/WICHTIG/REST-Abschnitten."""
|
||||
cats: dict[str, list[str]] = {}
|
||||
current = None
|
||||
for line in text.splitlines():
|
||||
s = line.strip()
|
||||
m = re.match(r"#+\s*(KERN|WICHTIG|REST)\b", s, re.IGNORECASE)
|
||||
if m:
|
||||
current = m.group(1).upper()
|
||||
cats.setdefault(current, [])
|
||||
continue
|
||||
m = re.match(r"(\d+)[.)]\s+(.*\S)", s)
|
||||
if m and current:
|
||||
cats[current].append(m.group(2))
|
||||
return cats
|
||||
|
||||
|
||||
def _lade_bausteine(text: str) -> dict[int, str]:
|
||||
"""Lädt die finale Baustein-Datei — sortierte Liste (neu) oder Kategorien (Altformat)."""
|
||||
if re.search(r"^#+\s*KERN\b", text, re.IGNORECASE | re.MULTILINE):
|
||||
cats = _parse_kategorien(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)
|
||||
|
||||
|
||||
_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_AUSF_RE = re.compile(r"<!--\s*ausf(?:ü|ue)hrlich\s*-->", re.IGNORECASE)
|
||||
_STUFEN = ("einfach", "mittel", "schwer")
|
||||
|
||||
|
||||
def _parse_fragment(text: str) -> list[dict]:
|
||||
"""Parst eine Writer-Datei → [{"kapitel", "titel", "md", "kompakt", "subs"}] in Reihenfolge.
|
||||
|
||||
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"]`.
|
||||
"""
|
||||
sections: list[dict] = []
|
||||
kapitel = None
|
||||
current = None
|
||||
cur_sub = None
|
||||
cur_layer = "md" # Default: alles ohne Schicht-Marker ist die ausführliche Fassung
|
||||
for line in text.splitlines():
|
||||
s = line.strip()
|
||||
m = _FRAGMENT_KAPITEL_RE.match(s)
|
||||
if m:
|
||||
kapitel = m.group(1)
|
||||
current = None
|
||||
cur_sub = None
|
||||
continue
|
||||
m = _FRAGMENT_SECTION_RE.match(s)
|
||||
if m:
|
||||
current = {"kapitel": kapitel, "titel": m.group(1), "md": [], "kompakt": [], "subs": []}
|
||||
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_sub = None
|
||||
continue
|
||||
if current is not None and _FRAGMENT_AUSF_RE.match(s):
|
||||
cur_layer = "md"
|
||||
cur_sub = None
|
||||
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)
|
||||
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)
|
||||
for sec in sections:
|
||||
sec["md"] = "\n".join(sec["md"]).strip()
|
||||
sec["kompakt"] = "\n".join(sec["kompakt"]).strip()
|
||||
for sub in sec["subs"]:
|
||||
sub["md"] = "\n".join(sub["md"]).strip()
|
||||
sec["subs"] = [sub for sub in sec["subs"] if sub["md"]]
|
||||
return sections
|
||||
|
||||
|
||||
def _parse_subbausteine(text: str) -> dict[str, list[str]]:
|
||||
"""Parst eine Subbaustein-Datei → {Baustein-Titel: [Subbaustein, …]} in Reihenfolge.
|
||||
|
||||
Format: `<!-- baustein: Titel -->` gefolgt von Listenzeilen `- Subbaustein`.
|
||||
"""
|
||||
out: dict[str, list[str]] = {}
|
||||
current = None
|
||||
for line in text.splitlines():
|
||||
s = line.strip()
|
||||
m = _FRAGMENT_BAUSTEIN_RE.match(s)
|
||||
if m:
|
||||
current = m.group(1).strip()
|
||||
out.setdefault(current, [])
|
||||
continue
|
||||
if current is None:
|
||||
continue
|
||||
m = re.match(r"[-*]\s+(.*\S)", s)
|
||||
if m:
|
||||
out[current].append(m.group(1).strip())
|
||||
return {k: v for k, v in out.items() if v}
|
||||
|
||||
|
||||
def _split_chunks(chapters: list[dict], n: int) -> list[list[dict]]:
|
||||
"""Teilt Kapitel in bis zu n zusammenhängende Chunks, balanciert nach Section-Anzahl."""
|
||||
n = max(1, min(n, len(chapters)))
|
||||
chunks: list[list[dict]] = []
|
||||
current: list[dict] = []
|
||||
count = 0
|
||||
remaining_total = sum(len(c["nums"]) for c in chapters)
|
||||
remaining_chunks = n
|
||||
for ch in chapters:
|
||||
current.append(ch)
|
||||
count += len(ch["nums"])
|
||||
if remaining_chunks > 1 and count >= remaining_total / remaining_chunks:
|
||||
chunks.append(current)
|
||||
remaining_total -= count
|
||||
remaining_chunks -= 1
|
||||
current = []
|
||||
count = 0
|
||||
if current:
|
||||
chunks.append(current)
|
||||
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)
|
||||
@@ -11,19 +11,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"minimax-direkt": {
|
||||
"minimax-kalt": {
|
||||
"npm": "@ai-sdk/anthropic",
|
||||
"name": "MiniMax (ohne Thinking)",
|
||||
"name": "MiniMax (kalt — niedrige Temperature, ohne Thinking)",
|
||||
"options": {
|
||||
"baseURL": "https://api.minimax.io/anthropic/v1",
|
||||
"apiKey": "{env:MINIMAX_API_KEY}"
|
||||
},
|
||||
"models": {
|
||||
"MiniMax-M3": {
|
||||
"name": "MiniMax M3 (ohne Thinking)",
|
||||
"name": "MiniMax M3 (kalt)",
|
||||
"options": {
|
||||
"temperature": 0.2,
|
||||
"thinking": { "type": "disabled" }
|
||||
}
|
||||
},
|
||||
"MiniMax-M2.7-highspeed": {
|
||||
"name": "MiniMax M2.7 highspeed (kalt)",
|
||||
"options": {
|
||||
"temperature": 0.3
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ services:
|
||||
volumes:
|
||||
- ./storage:/app/storage
|
||||
- ./projects:/app/projects
|
||||
- ./uni:/app/uni
|
||||
- ./.claude-data:/home/app/.claude
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
|
||||
26
frontend/package-lock.json
generated
26
frontend/package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"dompurify": "^3.4.7",
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.17.0",
|
||||
"marked": "^18.0.4",
|
||||
"marked-highlight": "^2.2.4",
|
||||
"vue": "^3.5.32"
|
||||
@@ -1187,6 +1188,15 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
|
||||
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -1468,6 +1478,22 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.17.0.tgz",
|
||||
"integrity": "sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==",
|
||||
"funding": [
|
||||
"https://opencollective.com/katex",
|
||||
"https://github.com/sponsors/katex"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^8.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"katex": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/kolorist": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"dompurify": "^3.4.7",
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.17.0",
|
||||
"marked": "^18.0.4",
|
||||
"marked-highlight": "^2.2.4",
|
||||
"vue": "^3.5.32"
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted, 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, fetchProjects, deleteProject as apiDeleteProject, fetchProviders, fetchStats, fetchTopicFortschritt } from './api.js'
|
||||
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 { usePolling } from './composables/usePolling.js'
|
||||
import TopicSidebar from './components/TopicSidebar.vue'
|
||||
import TopicDetail from './components/TopicDetail.vue'
|
||||
import ElementsSidebar from './components/ElementsSidebar.vue'
|
||||
import BausteineUebersicht from './components/BausteineUebersicht.vue'
|
||||
import ElementsSidebar from './components/elements/ElementsSidebar.vue'
|
||||
import ElementsOverview from './components/ElementsOverview.vue'
|
||||
|
||||
const guides = ref([])
|
||||
const projects = ref([])
|
||||
const backendTopics = ref([])
|
||||
const selectedTopic = ref(null)
|
||||
const previewGuide = ref(null)
|
||||
@@ -23,8 +24,14 @@ const bausteine = ref({ ...EMPTY_BAUSTEINE })
|
||||
const activeBausteine = 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 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
|
||||
@@ -44,6 +51,15 @@ function setProvider(id) {
|
||||
localStorage.setItem('provider', id)
|
||||
}
|
||||
|
||||
async function loadFolders() {
|
||||
try {
|
||||
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 {
|
||||
providers.value = await fetchProviders()
|
||||
@@ -56,7 +72,6 @@ async function loadProviders() {
|
||||
console.error('Fehler beim Laden der Provider:', e)
|
||||
}
|
||||
}
|
||||
let pollTimer = null
|
||||
|
||||
function applyTheme() {
|
||||
document.documentElement.classList.toggle('dark', darkMode.value)
|
||||
@@ -84,12 +99,7 @@ function onSidebarLeave() {
|
||||
if (!sidebarPinned.value) sidebarSticky.value = false
|
||||
}
|
||||
|
||||
const projectNames = computed(() => projects.value.map((p) => p.name))
|
||||
|
||||
const topics = computed(() => {
|
||||
const isProject = new Set(projectNames.value)
|
||||
return backendTopics.value.filter((t) => !isProject.has(t))
|
||||
})
|
||||
const topics = computed(() => backendTopics.value)
|
||||
|
||||
const doneByFormat = computed(() => {
|
||||
const map = {}
|
||||
@@ -126,21 +136,27 @@ async function loadTopics() {
|
||||
}
|
||||
}
|
||||
|
||||
// Fehlermeldungen verhalten sich wie Flash-Messages: × blendet aus,
|
||||
// beim Reload sind Alt-Fehler von vornherein ausgeblendet.
|
||||
const dismissedErrors = ref(new Set())
|
||||
let errorsInitialized = false
|
||||
// Weggeklickte Fehler bleiben weggeklickt — auch über Reloads (localStorage).
|
||||
// Nicht weggeklickte Fehler bleiben sichtbar, bis der Nutzer sie schließt.
|
||||
const dismissedErrors = ref(new Set(JSON.parse(localStorage.getItem('dismissedErrors') || '[]')))
|
||||
|
||||
function persistDismissed() {
|
||||
localStorage.setItem('dismissedErrors', JSON.stringify([...dismissedErrors.value]))
|
||||
}
|
||||
|
||||
function handleDismissError(guideId) {
|
||||
dismissedErrors.value = new Set([...dismissedErrors.value, guideId])
|
||||
persistDismissed()
|
||||
}
|
||||
|
||||
async function loadGuides() {
|
||||
try {
|
||||
guides.value = await fetchGuides()
|
||||
if (!errorsInitialized) {
|
||||
errorsInitialized = true
|
||||
dismissedErrors.value = new Set(guides.value.filter((g) => g.status === 'error').map((g) => g.id))
|
||||
// IDs prunen, deren Guide nicht mehr als Fehler existiert
|
||||
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)))
|
||||
persistDismissed()
|
||||
}
|
||||
loadStats()
|
||||
} catch (e) {
|
||||
@@ -154,25 +170,21 @@ async function loadBausteine() {
|
||||
if (selectedTopic.value) {
|
||||
bausteine.value = await fetchBausteineStatus(selectedTopic.value)
|
||||
fortschritt.value = await fetchTopicFortschritt(selectedTopic.value)
|
||||
locks.value = await fetchGuideLocks(selectedTopic.value)
|
||||
guideStepsDone.value = await fetchGuideSteps(selectedTopic.value)
|
||||
} else {
|
||||
bausteine.value = { ...EMPTY_BAUSTEINE }
|
||||
fortschritt.value = {}
|
||||
locks.value = {}
|
||||
guideStepsDone.value = {}
|
||||
}
|
||||
if (activeBausteine.value.length && !pollTimer) startPolling()
|
||||
if (activeBausteine.value.length && !polling.running()) startPolling()
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden der Bausteine:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
projects.value = await fetchProjects()
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden der Projekte:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const FORMAT_ORDER = ['OnePager', 'MiniGuide', 'Guide', 'FullGuide']
|
||||
const FORMAT_ORDER = ['Guide', 'FullGuide', 'Rest']
|
||||
|
||||
function autoPreview() {
|
||||
const map = doneByFormat.value
|
||||
@@ -191,7 +203,9 @@ function selectTopic(topic) {
|
||||
sidebarSticky.value = false
|
||||
elementsOpen.value = false
|
||||
elementsView.value = false
|
||||
bausteineView.value = false
|
||||
elementOpenId.value = null
|
||||
ansichtModus.value = localStorage.getItem('ansicht_' + topic) === 'erklärend' ? 'erklärend' : 'kompakt'
|
||||
localStorage.setItem('lastTopic', topic)
|
||||
loadBausteine()
|
||||
nextTick(autoPreview)
|
||||
@@ -222,14 +236,57 @@ async function handleResetBausteine() {
|
||||
await loadBausteine()
|
||||
}
|
||||
|
||||
async function handleBausteineClick({ instructions }) {
|
||||
async function handleBausteineClick({ instructions, abPhase = null }) {
|
||||
if (!selectedTopic.value) return
|
||||
await apiCreateBausteine(selectedTopic.value, instructions, provider.value)
|
||||
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)
|
||||
} catch (e) {
|
||||
uiError.value = e.message
|
||||
return
|
||||
}
|
||||
await loadBausteine()
|
||||
startPolling()
|
||||
}
|
||||
|
||||
async function handleFormatClick({ format, instructions }) {
|
||||
async function handleCreateThema({ topic, instructions, sourceType, sourceOrt }) {
|
||||
uiError.value = null
|
||||
try {
|
||||
await apiCreateBausteine(topic, instructions, provider.value, sourceType, sourceOrt)
|
||||
} catch (e) {
|
||||
uiError.value = e.message
|
||||
return
|
||||
}
|
||||
await loadTopics()
|
||||
selectTopic(topic)
|
||||
startPolling()
|
||||
}
|
||||
|
||||
async function handleUpdateQuelle({ topic, type, ort, spec }) {
|
||||
uiError.value = null
|
||||
try {
|
||||
await apiUpdateQuelle(topic, { type, ort, spec })
|
||||
} catch (e) {
|
||||
uiError.value = e.message
|
||||
return
|
||||
}
|
||||
await loadBausteine() // Schritt-Anzeige folgt ggf. der neuen Quelle (z.B. Link → „Quelle laden")
|
||||
}
|
||||
|
||||
function setAnsicht(modus) {
|
||||
ansichtModus.value = modus
|
||||
if (selectedTopic.value) localStorage.setItem('ansicht_' + selectedTopic.value, modus)
|
||||
}
|
||||
|
||||
function handleOpenBausteineView() {
|
||||
if (!selectedTopic.value) return
|
||||
bausteineView.value = true
|
||||
elementsView.value = false
|
||||
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
|
||||
const running = guides.value.some(
|
||||
@@ -237,28 +294,27 @@ async function handleFormatClick({ format, instructions }) {
|
||||
&& (g.status === 'generating' || g.status === 'queued'),
|
||||
)
|
||||
if (running) return
|
||||
await apiCreate(selectedTopic.value, format, instructions, provider.value)
|
||||
uiError.value = null
|
||||
try {
|
||||
await apiCreate(selectedTopic.value, format, instructions, provider.value, abStep)
|
||||
} catch (e) {
|
||||
uiError.value = e.message
|
||||
return
|
||||
}
|
||||
await loadGuides()
|
||||
startPolling()
|
||||
}
|
||||
|
||||
async function handleDeleteProject(name) {
|
||||
await apiDeleteProject(name)
|
||||
if (selectedTopic.value === name) {
|
||||
selectedTopic.value = null
|
||||
previewGuide.value = null
|
||||
}
|
||||
await loadProjects()
|
||||
}
|
||||
|
||||
function handlePreview(guide) {
|
||||
previewGuide.value = guide
|
||||
elementsView.value = false
|
||||
bausteineView.value = false
|
||||
}
|
||||
|
||||
function handleOpenElements() {
|
||||
if (!selectedTopic.value) return
|
||||
elementsView.value = true
|
||||
bausteineView.value = false
|
||||
// Rechte Sidebar bleibt zu — sie öffnet erst beim Klick auf ein Element.
|
||||
}
|
||||
|
||||
@@ -276,20 +332,11 @@ async function handleDeleteGuide(guideId, slots = false) {
|
||||
await loadGuides()
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling()
|
||||
pollTimer = setInterval(async () => {
|
||||
await Promise.all([loadGuides(), loadBausteine(), loadTopics()])
|
||||
if (!hasActiveGuides.value && !activeBausteine.value.length) stopPolling()
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
const polling = usePolling(
|
||||
() => Promise.all([loadGuides(), loadBausteine(), loadTopics()]),
|
||||
() => hasActiveGuides.value || activeBausteine.value.length > 0,
|
||||
)
|
||||
const startPolling = polling.start
|
||||
|
||||
async function handleCancel(guideId) {
|
||||
await apiCancel(guideId)
|
||||
@@ -311,21 +358,11 @@ async function handleDeleteTopic(topic) {
|
||||
await loadGuides()
|
||||
}
|
||||
|
||||
function onVisibility() {
|
||||
if (document.hidden) {
|
||||
stopPolling()
|
||||
} else {
|
||||
loadGuides()
|
||||
loadBausteine()
|
||||
if (hasActiveGuides.value || activeBausteine.value.length) startPolling()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadGuides(), loadTopics(), loadProjects(), loadProviders()])
|
||||
await Promise.all([loadGuides(), loadTopics(), loadProviders(), loadFolders()])
|
||||
const savedTopic = localStorage.getItem('lastTopic')
|
||||
const savedFormat = localStorage.getItem('lastFormat')
|
||||
if (savedTopic && [...topics.value, ...projectNames.value].includes(savedTopic)) {
|
||||
if (savedTopic && topics.value.includes(savedTopic)) {
|
||||
selectTopic(savedTopic)
|
||||
await nextTick()
|
||||
const g = doneByFormat.value[savedFormat]
|
||||
@@ -333,12 +370,6 @@ onMounted(async () => {
|
||||
} else if (!selectedTopic.value && topics.value.length) {
|
||||
selectTopic(topics.value[0])
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -348,10 +379,12 @@ onUnmounted(() => {
|
||||
<div v-if="!sidebarPinned && sidebarSticky" class="sidebar-backdrop" @click="sidebarSticky = false"></div>
|
||||
<TopicSidebar
|
||||
:topics="topics"
|
||||
:projects="projectNames"
|
||||
:selectedTopic="selectedTopic"
|
||||
:stats="stats"
|
||||
:fortschritt="fortschritt"
|
||||
:locks="locks"
|
||||
:guideStepsDone="guideStepsDone"
|
||||
:uiError="uiError"
|
||||
:doneByFormat="doneByFormat"
|
||||
:latestByFormat="latestByFormat"
|
||||
:allGuides="guides"
|
||||
@@ -362,26 +395,37 @@ onUnmounted(() => {
|
||||
:dark="darkMode"
|
||||
:provider="provider"
|
||||
:providers="providers"
|
||||
:folders="folders"
|
||||
:ansichtModus="ansichtModus"
|
||||
@setProvider="setProvider"
|
||||
@toggleDark="toggleDark"
|
||||
@setAnsicht="setAnsicht"
|
||||
@select="selectTopic"
|
||||
@create="createTopic"
|
||||
@createThema="handleCreateThema"
|
||||
@updateQuelle="handleUpdateQuelle"
|
||||
@openBausteineView="handleOpenBausteineView"
|
||||
@formatClick="handleFormatClick"
|
||||
@bausteineClick="handleBausteineClick"
|
||||
@cancelBausteine="handleCancelBausteine"
|
||||
@resetBausteine="handleResetBausteine"
|
||||
@deleteTopic="handleDeleteTopic"
|
||||
@deleteProject="handleDeleteProject"
|
||||
@cancelGuide="handleCancel"
|
||||
@deleteGuide="handleDeleteGuide"
|
||||
@dismissError="handleDismissError"
|
||||
@dismissUiError="uiError = null"
|
||||
@preview="handlePreview"
|
||||
@openElements="handleOpenElements"
|
||||
@togglePin="toggleSidebarPin"
|
||||
@sidebarLeave="onSidebarLeave"
|
||||
/>
|
||||
<BausteineUebersicht
|
||||
v-if="selectedTopic && bausteineView"
|
||||
:topic="selectedTopic"
|
||||
@close="bausteineView = false"
|
||||
/>
|
||||
<ElementsOverview
|
||||
v-if="selectedTopic && elementsView"
|
||||
v-else-if="selectedTopic && elementsView"
|
||||
:topic="selectedTopic"
|
||||
:version="elementsVersion"
|
||||
@open="handleOpenElementDetail"
|
||||
@@ -392,8 +436,11 @@ onUnmounted(() => {
|
||||
:dark="darkMode"
|
||||
:provider="provider"
|
||||
:elementsOpen="elementsOpen"
|
||||
:doneByFormat="doneByFormat"
|
||||
:themaAbgeschlossen="!!fortschritt.abgeschlossen"
|
||||
:ansichtModus="ansichtModus"
|
||||
@progressChanged="loadStats(); loadBausteine()"
|
||||
@openElements="elementsOpen = true"
|
||||
@setAnsicht="setAnsicht"
|
||||
/>
|
||||
<div v-else class="empty-main">
|
||||
<p>Thema in der Sidebar anlegen oder auswählen.</p>
|
||||
|
||||
@@ -1,17 +1,40 @@
|
||||
const BASE = '/api'
|
||||
|
||||
// Backend-Fehler (400/409 mit detail) als Error werfen statt sie zu verschlucken
|
||||
async function jsonOrThrow(res) {
|
||||
if (!res.ok) {
|
||||
let detail = `Fehler (HTTP ${res.status})`
|
||||
try {
|
||||
const data = await res.json()
|
||||
if (data.detail) detail = typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail)
|
||||
} catch { /* kein JSON-Body */ }
|
||||
throw new Error(detail)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function fetchGuides() {
|
||||
const res = await fetch(`${BASE}/guides`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function createGuide(topic, format, instructions = '', provider = 'claude') {
|
||||
export async function fetchGuideSteps(topic) {
|
||||
const res = await fetch(`${BASE}/guides/steps?topic=${encodeURIComponent(topic)}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function fetchGuideLocks(topic) {
|
||||
const res = await fetch(`${BASE}/guides/locks?topic=${encodeURIComponent(topic)}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function createGuide(topic, format, instructions = '', provider = 'claude', abStep = null) {
|
||||
const res = await fetch(`${BASE}/guides`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, format, instructions, provider }),
|
||||
body: JSON.stringify({ topic, format, instructions, provider, ab_step: abStep }),
|
||||
})
|
||||
return res.json()
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function fetchActiveBausteine() {
|
||||
@@ -24,13 +47,13 @@ export async function fetchBausteineStatus(topic) {
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function createBausteine(topic, instructions = '', provider = 'claude') {
|
||||
export async function createBausteine(topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '', abPhase = null) {
|
||||
const res = await fetch(`${BASE}/bausteine`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, instructions, provider }),
|
||||
body: JSON.stringify({ topic, instructions, provider, source_type: sourceType, source_ort: sourceOrt, ab_phase: abPhase }),
|
||||
})
|
||||
return res.json()
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function cancelBausteine(topic) {
|
||||
@@ -41,6 +64,41 @@ 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`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, baustein, section, section_kompakt, messages, provider }),
|
||||
})
|
||||
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,
|
||||
}) {
|
||||
const res = await fetch(`${BASE}/bausteine/pruefung`, {
|
||||
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 }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function fetchFrageMuster(topic, baustein) {
|
||||
const res = await fetch(`${BASE}/bausteine/frage-muster?topic=${encodeURIComponent(topic)}&baustein=${encodeURIComponent(baustein)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function fetchTopicFortschritt(topic) {
|
||||
const res = await fetch(`${BASE}/topics/fortschritt?topic=${encodeURIComponent(topic)}`)
|
||||
return res.json()
|
||||
@@ -61,6 +119,30 @@ export async function fetchProjects() {
|
||||
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)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function updateQuelle(topic, { type, ort = '', spec = '' }) {
|
||||
const res = await fetch(`${BASE}/bausteine/quelle`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, type, ort, spec }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function fetchBausteineUebersicht(topic) {
|
||||
const res = await fetch(`${BASE}/bausteine/uebersicht?topic=${encodeURIComponent(topic)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function deleteProject(name) {
|
||||
await fetch(`${BASE}/projects/${encodeURIComponent(name)}`, { method: 'DELETE' })
|
||||
}
|
||||
@@ -96,20 +178,6 @@ export async function deleteTopic(name) {
|
||||
await fetch(`${BASE}/topics?topic=${encodeURIComponent(name)}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function fetchProgress(id) {
|
||||
const res = await fetch(`${BASE}/guides/${id}/progress`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function setProgress(id, chapter, done) {
|
||||
const res = await fetch(`${BASE}/guides/${id}/progress`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chapter, done }),
|
||||
})
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function chatGuide(id, { section, outline, messages, provider = 'claude' }) {
|
||||
const res = await fetch(`${BASE}/guides/${id}/chat`, {
|
||||
method: 'POST',
|
||||
|
||||
80
frontend/src/assets/markdown.css
Normal file
80
frontend/src/assets/markdown.css
Normal file
@@ -0,0 +1,80 @@
|
||||
/* Gemeinsames Markdown-Styling für v-html-Inhalte (renderMarkdown).
|
||||
v-html-Kinder tragen keine data-v-Attribute — daher globale Regeln statt
|
||||
:deep()-Kopien in jeder Komponente. Komponenten-Overrides bleiben scoped
|
||||
und gewinnen per Spezifität (z. B. pre-Scroll in TopicDetail). */
|
||||
|
||||
.markdown p {
|
||||
margin: 0 0 0.5em;
|
||||
}
|
||||
|
||||
/* KaTeX: lange Block-Formeln scrollen statt das Layout zu sprengen */
|
||||
.markdown .katex-display {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding: 0.2em 0;
|
||||
}
|
||||
|
||||
.markdown p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.markdown ul,
|
||||
.markdown ol {
|
||||
margin: 0.3em 0;
|
||||
padding-left: 1.2em;
|
||||
}
|
||||
|
||||
.markdown li {
|
||||
margin: 0.15em 0;
|
||||
}
|
||||
|
||||
.markdown code {
|
||||
background: var(--border);
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
font-family: "SF Mono", Consolas, monospace;
|
||||
font-size: 0.85em;
|
||||
/* Lange Bezeichner (Namespaces, Pfade) dürfen umbrechen statt zu überlaufen */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.markdown pre {
|
||||
background: var(--code-bg, #1e2330);
|
||||
color: var(--code-fg, #e6e8ee);
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
/* Default: umbrechen (schmale Sidebars/Karten) — die breite Lese-Ansicht
|
||||
in TopicDetail überschreibt auf horizontales Scrollen */
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.markdown pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.markdown h1,
|
||||
.markdown h2,
|
||||
.markdown h3 {
|
||||
font-size: 0.95em;
|
||||
margin: 0.6em 0 0.3em;
|
||||
}
|
||||
|
||||
.markdown a {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.markdown table {
|
||||
border-collapse: collapse;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.markdown th,
|
||||
.markdown td {
|
||||
border: 1px solid var(--border-strong);
|
||||
padding: 2px 6px;
|
||||
}
|
||||
218
frontend/src/components/BausteinFokus.vue
Normal file
218
frontend/src/components/BausteinFokus.vue
Normal file
@@ -0,0 +1,218 @@
|
||||
<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>
|
||||
1017
frontend/src/components/BausteinPanel.vue
Normal file
1017
frontend/src/components/BausteinPanel.vue
Normal file
File diff suppressed because it is too large
Load Diff
225
frontend/src/components/BausteineUebersicht.vue
Normal file
225
frontend/src/components/BausteineUebersicht.vue
Normal file
@@ -0,0 +1,225 @@
|
||||
<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>
|
||||
@@ -161,45 +161,10 @@ function plain(text) {
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
/* Markdown im Guide-Stil */
|
||||
/* Markdown: Basis global (assets/markdown.css), hier nur Grundschrift der Karten */
|
||||
.markdown {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.55;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.markdown :deep(p) {
|
||||
margin: 0 0 0.5em;
|
||||
}
|
||||
|
||||
.markdown :deep(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.markdown :deep(code) {
|
||||
background: var(--border);
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
font-family: "SF Mono", Consolas, monospace;
|
||||
font-size: 0.85em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.markdown :deep(pre) {
|
||||
background: var(--code-bg, #1e2330);
|
||||
color: var(--code-fg, #e6e8ee);
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
/* Umbrechen statt horizontal scrollen — Scrollbar verdeckt sonst die Code-Zeile */
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
margin: 0.4em 0;
|
||||
}
|
||||
|
||||
.markdown :deep(pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,34 +1,38 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { fetchGuideContent, chatGuide, fetchProgress, setProgress } from '../api.js'
|
||||
import { fetchGuideContent, chatGuide, fetchBausteinLernstand } from '../api.js'
|
||||
import { renderMarkdown } from '../markdown.js'
|
||||
import { useChat } from '../composables/useChat.js'
|
||||
import BausteinPanel from './BausteinPanel.vue'
|
||||
import BausteinFokus from './BausteinFokus.vue'
|
||||
|
||||
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)
|
||||
themaAbgeschlossen: { type: Boolean, default: false },
|
||||
ansichtModus: { type: String, default: 'kompakt' }, // kompakt | erklärend
|
||||
})
|
||||
|
||||
const emit = defineEmits(['progressChanged', 'openElements'])
|
||||
const emit = defineEmits(['progressChanged', 'setAnsicht'])
|
||||
|
||||
const isOnePager = computed(() => props.previewGuide?.format === 'OnePager')
|
||||
|
||||
// Rotierende Kapitel-Akzentfarben (passend zum OnePager-Cheat-Sheet, ohne Rot)
|
||||
// Rotierende Kapitel-Akzentfarben (ohne Rot)
|
||||
const CH_COLORS = ['#3b82f6', '#8b5cf6', '#14b8a6', '#f59e0b', '#22c55e', '#6366f1']
|
||||
|
||||
// --- Inhalt laden ---
|
||||
const content = ref(null)
|
||||
const loadError = ref(null)
|
||||
const doneChapters = ref(new Set())
|
||||
const scrollEl = ref(null)
|
||||
const lernstand = ref({}) // Prüfungs-Stand pro Baustein-Titel — VOR dem immediate-Watch (loadContent nutzt es)
|
||||
|
||||
watch(() => props.previewGuide?.id, loadContent, { immediate: true })
|
||||
|
||||
async function loadContent() {
|
||||
content.value = null
|
||||
loadError.value = null
|
||||
doneChapters.value = new Set()
|
||||
lernstand.value = {}
|
||||
const g = props.previewGuide
|
||||
if (!g || g.status !== 'done') return
|
||||
try {
|
||||
@@ -39,46 +43,71 @@ async function loadContent() {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await fetchProgress(g.id)
|
||||
doneChapters.value = new Set(res.chapters || [])
|
||||
lernstand.value = (await fetchBausteinLernstand(g.topic)).bausteine || {}
|
||||
} catch { /* offline → leer */ }
|
||||
nextTick(scrollToFirstOpen)
|
||||
}
|
||||
|
||||
// Zum ersten noch offenen Kapitel springen — aber nur, wenn schon etwas erledigt ist.
|
||||
function scrollToFirstOpen() {
|
||||
if (!doneChapters.value.size || !content.value) return
|
||||
const chapters = Array.from(scrollEl.value?.querySelectorAll('section.chapter') || [])
|
||||
const firstOpen = chapters.find((el) => !el.classList.contains('ch-complete'))
|
||||
if (firstOpen && firstOpen !== chapters[0]) firstOpen.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
|
||||
}
|
||||
|
||||
// --- Kapitel-Fortschritt ---
|
||||
async function toggleChapter(title) {
|
||||
const newState = !doneChapters.value.has(title)
|
||||
const optimistic = new Set(doneChapters.value)
|
||||
if (newState) optimistic.add(title)
|
||||
else optimistic.delete(title)
|
||||
doneChapters.value = optimistic
|
||||
try {
|
||||
const res = await setProgress(props.previewGuide.id, title, newState)
|
||||
doneChapters.value = new Set(res.chapters || [])
|
||||
emit('progressChanged')
|
||||
} catch {
|
||||
const rollback = new Set(doneChapters.value)
|
||||
if (newState) rollback.delete(title)
|
||||
else rollback.add(title)
|
||||
doneChapters.value = rollback
|
||||
// --- 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(() =>
|
||||
!content.value ? [] : content.value.chapters.flatMap((ch) => ch.sections),
|
||||
)
|
||||
const fokusBaustein = computed(() => (fokusIndex.value === null ? null : bausteine.value[fokusIndex.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)
|
||||
if (i === -1) return
|
||||
fokusIndex.value = i
|
||||
fokusTab.value = tab || 'pruefung'
|
||||
}
|
||||
// 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 }
|
||||
}
|
||||
function fokusNext() {
|
||||
for (let i = fokusIndex.value + 1; i < bausteine.value.length; i++) if (istPruefbar(bausteine.value[i])) { fokusIndex.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++
|
||||
}
|
||||
return { total: bausteine.value.length, absolviert: a, verstanden: v, gemeistert: g }
|
||||
})
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// --- Chat ---
|
||||
// --- Chat (Mechanik in useChat; Kontext-Extraktion bleibt hier) ---
|
||||
const chat = useChat((msgs) => {
|
||||
const { section, outline } = extractContext()
|
||||
return chatGuide(props.previewGuide.id, {
|
||||
section, outline, messages: msgs, provider: props.provider,
|
||||
})
|
||||
})
|
||||
const { messages, input, loading, messagesEl, inputEl, onScroll, send } = chat
|
||||
const autoGrow = () => chat.autoGrow()
|
||||
const chatOpen = ref(false)
|
||||
const messages = ref([])
|
||||
const input = ref('')
|
||||
const loading = ref(false)
|
||||
const messagesEl = ref(null)
|
||||
const inputEl = ref(null)
|
||||
const panelEl = ref(null)
|
||||
|
||||
function openChat() {
|
||||
@@ -88,10 +117,15 @@ function openChat() {
|
||||
|
||||
function closeChat() {
|
||||
chatOpen.value = false
|
||||
messages.value = []
|
||||
input.value = ''
|
||||
chat.reset()
|
||||
}
|
||||
|
||||
// Mobil schließen sich Chat und Elemente-Sidebar gegenseitig aus —
|
||||
// nebeneinander ist kein Platz, die Sidebar würde den Chat überdecken.
|
||||
watch(() => props.elementsOpen, (open) => {
|
||||
if (open && chatOpen.value && window.matchMedia('(max-width: 768px)').matches) closeChat()
|
||||
})
|
||||
|
||||
function onDocMouseDown(e) {
|
||||
if (!chatOpen.value) return
|
||||
if (panelEl.value && panelEl.value.contains(e.target)) return
|
||||
@@ -141,58 +175,22 @@ function extractContext() {
|
||||
return { section, outline }
|
||||
}
|
||||
|
||||
async function scrollToBottom() {
|
||||
await nextTick()
|
||||
if (messagesEl.value) messagesEl.value.scrollTop = messagesEl.value.scrollHeight
|
||||
}
|
||||
|
||||
function autoGrow() {
|
||||
const el = inputEl.value
|
||||
if (!el) return
|
||||
el.style.height = 'auto'
|
||||
el.style.height = Math.min(el.scrollHeight, 140) + 'px'
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const text = input.value.trim()
|
||||
if (!text || loading.value || !props.previewGuide) return
|
||||
messages.value.push({ role: 'user', content: text })
|
||||
input.value = ''
|
||||
nextTick(autoGrow)
|
||||
loading.value = true
|
||||
scrollToBottom()
|
||||
try {
|
||||
const { section, outline } = extractContext()
|
||||
const res = await chatGuide(props.previewGuide.id, {
|
||||
section,
|
||||
outline,
|
||||
messages: messages.value,
|
||||
provider: props.provider,
|
||||
})
|
||||
messages.value.push({ role: 'assistant', content: res.reply || '…' })
|
||||
} catch {
|
||||
messages.value.push({ role: 'assistant', content: 'Fehler bei der Anfrage.' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
scrollToBottom()
|
||||
nextTick(() => inputEl.value?.focus())
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="detail">
|
||||
<div v-if="previewGuide && content" ref="scrollEl" class="guide-scroll">
|
||||
<div class="guide-content" :class="{ onepager: isOnePager }">
|
||||
<div class="guide-content">
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<section
|
||||
v-for="(ch, ci) in content.chapters"
|
||||
:key="ch.title"
|
||||
class="chapter"
|
||||
:class="{ 'ch-complete': doneChapters.has(ch.title) }"
|
||||
:style="{ '--ch-accent': CH_COLORS[ci % CH_COLORS.length] }"
|
||||
>
|
||||
<h2 class="chapter-title"><span class="ch-num">{{ ci + 1 }}</span>{{ ch.title }}</h2>
|
||||
@@ -200,19 +198,28 @@ async function send() {
|
||||
<article
|
||||
v-for="s in ch.sections"
|
||||
:key="s.num"
|
||||
:class="['section-card', isOnePager && s.key ? 'op-card op-' + s.key : '']"
|
||||
:style="isOnePager && s.key ? { gridArea: s.key } : null"
|
||||
:class="['section-card', lernstand[s.title]?.gemeistert ? 'gemeistert' : (lernstand[s.title]?.verstanden ? 'verstanden' : (lernstand[s.title]?.absolviert ? 'absolviert' : ''))]"
|
||||
>
|
||||
<h3>{{ s.title }}</h3>
|
||||
<div class="section-body markdown" v-html="renderMarkdown(s.md)"></div>
|
||||
<h3 :class="{ 'baustein-klick': istPruefbar(s) }" @click="istPruefbar(s) && openFokus(s, 'pruefung')">
|
||||
{{ 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>
|
||||
</h3>
|
||||
<div class="section-body markdown" v-html="renderMarkdown(ansichtModus === 'kompakt' ? (s.kompakt || s.md) : s.md)"></div>
|
||||
<BausteinPanel
|
||||
v-if="istPruefbar(s)"
|
||||
mode="trigger"
|
||||
:baustein="s.title"
|
||||
:status="lernstand[s.title]"
|
||||
:cap="cap"
|
||||
:topic="previewGuide.topic"
|
||||
@open-fokus="(tab) => openFokus(s, tab)"
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
<button
|
||||
v-if="!isOnePager"
|
||||
class="ch-toggle"
|
||||
:class="{ 'is-done': doneChapters.has(ch.title) }"
|
||||
@click="toggleChapter(ch.title)"
|
||||
>{{ doneChapters.has(ch.title) ? '✓ Erledigt – rückgängig' : 'Kapitel als erledigt markieren' }}</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -225,15 +232,33 @@ async function send() {
|
||||
<p>Guide-Format anklicken um zu generieren oder Vorschau zu öffnen.</p>
|
||||
</div>
|
||||
|
||||
<button v-if="previewGuide && !chatOpen" class="chat-fab" :class="{ shifted: elementsOpen }" title="Fragen zum Guide" @click="openChat">💬</button>
|
||||
<button v-if="previewGuide && !chatOpen && !elementsOpen" class="chat-fab elements-fab" title="Elemente öffnen" @click="emit('openElements')">🗂</button>
|
||||
<BausteinFokus
|
||||
v-if="fokusBaustein"
|
||||
:baustein="fokusBaustein"
|
||||
:topic="previewGuide.topic"
|
||||
:provider="provider"
|
||||
:fortschritt="fortschritt"
|
||||
:status="lernstand[fokusBaustein.title]"
|
||||
:cap="cap"
|
||||
:tab="fokusTab"
|
||||
:ansicht="ansichtModus"
|
||||
:has-prev="fokusIndex > 0"
|
||||
:has-next="fokusIndex < bausteine.length - 1"
|
||||
@prev="fokusPrev"
|
||||
@next="fokusNext"
|
||||
@close="fokusIndex = null"
|
||||
@set-ansicht="$emit('setAnsicht', $event)"
|
||||
@status-changed="(st) => onBausteinStatus(st.baustein, st)"
|
||||
/>
|
||||
|
||||
<button v-if="previewGuide && !chatOpen && fokusIndex === null" class="chat-fab" :class="{ shifted: elementsOpen }" title="Fragen zum 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>
|
||||
</header>
|
||||
<div ref="messagesEl" class="chat-messages">
|
||||
<div ref="messagesEl" class="chat-messages" @scroll="onScroll">
|
||||
<p v-if="!messages.length" class="chat-hint">Stell eine Frage zum aktuellen Abschnitt.</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>
|
||||
@@ -250,7 +275,12 @@ async function send() {
|
||||
@input="autoGrow"
|
||||
@keydown.enter.exact.prevent="send"
|
||||
></textarea>
|
||||
<button :disabled="!input.trim() || loading" @click="send">➤</button>
|
||||
<button
|
||||
:disabled="!input.trim() && !loading"
|
||||
:class="{ cancel: loading }"
|
||||
:title="loading ? 'Abbrechen' : 'Senden'"
|
||||
@click="send"
|
||||
>{{ loading ? '✕' : '➤' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -305,6 +335,16 @@ async function send() {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.thema-done {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding: 0.15rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, #d4af37 20%, var(--panel));
|
||||
border: 1px solid #d4af37;
|
||||
color: #8a6d12;
|
||||
}
|
||||
|
||||
.chapter {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
@@ -333,10 +373,6 @@ async function send() {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chapter.ch-complete .sections {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
@@ -345,8 +381,53 @@ async function send() {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* Guides: Karten tragen die Kapitel-Akzentfarbe (OnePager hat eigene op-card-Farben) */
|
||||
.guide-content:not(.onepager) .section-card {
|
||||
.baustein-done {
|
||||
float: right;
|
||||
margin-left: 0.5rem;
|
||||
padding: 0.12rem 0.6rem;
|
||||
font-size: 0.68em;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
border-radius: 999px;
|
||||
background: var(--success-soft);
|
||||
border: 1px solid var(--success-border);
|
||||
color: var(--success);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Absolvierte Bausteine: Karte kippt sichtbar auf Grün */
|
||||
.guide-content .section-card.absolviert {
|
||||
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 {
|
||||
background: color-mix(in srgb, #8b5cf6 16%, var(--panel));
|
||||
border-color: #8b5cf6;
|
||||
color: #6d28d9;
|
||||
}
|
||||
.guide-content .section-card.verstanden {
|
||||
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 {
|
||||
background: color-mix(in srgb, #d4af37 20%, var(--panel));
|
||||
border-color: #d4af37;
|
||||
color: #8a6d12;
|
||||
}
|
||||
.guide-content .section-card.gemeistert {
|
||||
border-color: #d4af37;
|
||||
border-top: 3px solid #d4af37;
|
||||
background: color-mix(in srgb, #d4af37 8%, var(--panel));
|
||||
}
|
||||
|
||||
/* Guides: Karten tragen die Kapitel-Akzentfarbe */
|
||||
.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));
|
||||
}
|
||||
@@ -359,136 +440,9 @@ async function send() {
|
||||
}
|
||||
}
|
||||
|
||||
/* OnePager: festes 3×3-Raster über volle Breite und Höhe.
|
||||
Kein Lese-Zoom (bricht 100%-Höhen) — stattdessen sind die Schriften unten 1.2× skaliert. */
|
||||
.guide-content.onepager {
|
||||
max-width: none;
|
||||
height: 100%;
|
||||
zoom: 1;
|
||||
padding: 0.9rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.guide-content.onepager .guide-head,
|
||||
.guide-content.onepager .chapter-title {
|
||||
display: none; /* Thema steht in der Info-Karte — Platz fürs Raster */
|
||||
}
|
||||
|
||||
.guide-content.onepager .chapter {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.guide-content.onepager .sections {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: repeat(3, 1fr);
|
||||
grid-template-areas:
|
||||
"info beispiel voraussetzungen"
|
||||
"eigenschaften beispiel modern"
|
||||
"eigenschaften zusammenhaenge veraltet";
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.guide-content.onepager .section-card {
|
||||
margin-bottom: 0;
|
||||
padding: 0.7rem 0.9rem;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
|
||||
h3 {
|
||||
font-size: 1.06rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.section-body {
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Farbkodiertes Cheat-Sheet: feste Akzentfarbe + Icon pro Karte */
|
||||
.op-info { --op-accent: #3b82f6; }
|
||||
.op-eigenschaften { --op-accent: #8b5cf6; --op-icon: "☰"; }
|
||||
.op-beispiel { --op-accent: #64748b; --op-icon: "❯"; }
|
||||
.op-zusammenhaenge { --op-accent: #14b8a6; --op-icon: "⇄"; }
|
||||
.op-voraussetzungen { --op-accent: #f59e0b; --op-icon: "✓"; }
|
||||
.op-modern { --op-accent: #22c55e; --op-icon: "✦"; }
|
||||
.op-veraltet { --op-accent: #ef4444; --op-icon: "⚠"; }
|
||||
|
||||
.guide-content.onepager .section-card.op-card {
|
||||
border-top: 3px solid var(--op-accent);
|
||||
background: color-mix(in srgb, var(--op-accent) 5%, var(--panel));
|
||||
|
||||
h3 {
|
||||
color: var(--op-accent);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
h3::before {
|
||||
content: var(--op-icon, "");
|
||||
margin-right: 7px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Info-Karte: Thema als große Headline statt Uppercase-Label */
|
||||
.guide-content.onepager .op-card.op-info h3 {
|
||||
font-size: 1.5rem;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Mobil: eine Spalte in Quellreihenfolge (info → … → veraltet) */
|
||||
@media (max-width: 900px) {
|
||||
.guide-content.onepager {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.guide-content.onepager .sections {
|
||||
height: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.guide-content.onepager .section-card {
|
||||
overflow-y: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.ch-toggle {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.8rem 1rem;
|
||||
border: 1.5px dashed var(--border-strong);
|
||||
border-radius: 10px;
|
||||
background: var(--panel-soft);
|
||||
color: var(--text-muted);
|
||||
font: 600 0.9rem/1.2 inherit;
|
||||
font-family: inherit;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&.is-done {
|
||||
border-style: solid;
|
||||
border-color: var(--success-border);
|
||||
background: var(--success-soft);
|
||||
color: var(--success);
|
||||
}
|
||||
}
|
||||
/* Titel-Klick öffnet die Vollansicht. */
|
||||
.section-card h3.baustein-klick { cursor: pointer; width: fit-content; }
|
||||
.section-card h3.baustein-klick:hover { color: var(--accent); }
|
||||
|
||||
.empty-preview {
|
||||
display: flex;
|
||||
@@ -498,56 +452,13 @@ async function send() {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* --- Markdown (Sections + Chat) --- */
|
||||
.markdown :deep(p) {
|
||||
margin: 0 0 0.5em;
|
||||
}
|
||||
|
||||
.markdown :deep(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.markdown :deep(ul),
|
||||
.markdown :deep(ol) {
|
||||
margin: 0.3em 0;
|
||||
padding-left: 1.2em;
|
||||
}
|
||||
|
||||
.markdown :deep(li) {
|
||||
margin: 0.15em 0;
|
||||
}
|
||||
|
||||
.markdown :deep(code) {
|
||||
background: var(--border);
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
font-family: "SF Mono", Consolas, monospace;
|
||||
font-size: 0.85em;
|
||||
/* Lange Bezeichner (Namespaces, Pfade) dürfen umbrechen statt zu überlaufen */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* --- Markdown: Basis global (assets/markdown.css), hier nur Lese-Ansicht-Overrides --- */
|
||||
|
||||
/* Breite Lese-Ansicht: Code scrollt horizontal statt umzubrechen */
|
||||
.markdown :deep(pre) {
|
||||
background: var(--code-bg, #1e2330);
|
||||
color: var(--code-fg, #e6e8ee);
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
white-space: pre;
|
||||
overflow-wrap: normal;
|
||||
overflow-x: auto;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.markdown :deep(pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.markdown :deep(h1),
|
||||
.markdown :deep(h2),
|
||||
.markdown :deep(h3) {
|
||||
font-size: 0.95em;
|
||||
margin: 0.6em 0 0.3em;
|
||||
}
|
||||
|
||||
/* „Beispiel"-Überschriften in Karten als dezentes Uppercase-Label */
|
||||
@@ -559,21 +470,6 @@ async function send() {
|
||||
margin: 0.9em 0 0.35em;
|
||||
}
|
||||
|
||||
.markdown :deep(a) {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.markdown :deep(table) {
|
||||
border-collapse: collapse;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.markdown :deep(th),
|
||||
.markdown :deep(td) {
|
||||
border: 1px solid var(--border-strong);
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
/* Lesbarkeit: ~17px Fließtext, Zeilenhöhe 1.6, Textspalte max. ~70 Zeichen —
|
||||
Code-Blöcke dürfen die volle Kartenbreite nutzen */
|
||||
.section-body {
|
||||
@@ -587,12 +483,6 @@ async function send() {
|
||||
max-width: 70ch;
|
||||
}
|
||||
|
||||
.onepager .section-card .markdown :deep(p),
|
||||
.onepager .section-card .markdown :deep(ul),
|
||||
.onepager .section-card .markdown :deep(ol) {
|
||||
max-width: none; /* OnePager-Zellen sind selbst schmal genug */
|
||||
}
|
||||
|
||||
/* --- Chat --- */
|
||||
.chat-fab {
|
||||
position: fixed;
|
||||
@@ -614,10 +504,6 @@ async function send() {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.elements-fab {
|
||||
right: 5.25rem;
|
||||
}
|
||||
|
||||
/* Element-Sidebar (320px) offen → Chat links daneben anzeigen */
|
||||
.chat-fab.shifted {
|
||||
right: calc(1.5rem + 320px);
|
||||
@@ -627,6 +513,14 @@ async function send() {
|
||||
right: calc(1.5rem + 320px);
|
||||
}
|
||||
|
||||
/* Mobil liegt die Elemente-Sidebar als Overlay über dem Chat — FAB/Panel ausblenden */
|
||||
@media (max-width: 768px) {
|
||||
.chat-fab.shifted,
|
||||
.chat-panel.shifted {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-panel {
|
||||
position: fixed;
|
||||
right: 1.5rem;
|
||||
@@ -756,4 +650,8 @@ async function send() {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chat-input button.cancel {
|
||||
background: var(--danger);
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
147
frontend/src/components/elements/ElementChatTab.vue
Normal file
147
frontend/src/components/elements/ElementChatTab.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<script setup>
|
||||
import { watch } from 'vue'
|
||||
import { chatElement } from '../../api.js'
|
||||
import { useChat } from '../../composables/useChat.js'
|
||||
|
||||
const props = defineProps({
|
||||
element: { type: Object, required: true },
|
||||
provider: { type: String, default: 'claude' },
|
||||
})
|
||||
|
||||
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
|
||||
watch(() => props.element.id, () => chat.reset())
|
||||
|
||||
async function send() {
|
||||
const res = await chat.send()
|
||||
if (res?.changes?.length) emit('changes', res.changes)
|
||||
}
|
||||
</script>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<div class="chat-input">
|
||||
<textarea
|
||||
ref="inputEl"
|
||||
v-model="input"
|
||||
placeholder="Element anpassen…"
|
||||
@keydown.enter.exact.prevent="send"
|
||||
></textarea>
|
||||
<button
|
||||
:disabled="!input.trim() && !loading"
|
||||
:class="{ cancel: loading }"
|
||||
:title="loading ? 'Abbrechen' : 'Senden'"
|
||||
@click="send"
|
||||
>{{ loading ? '✕' : '➤' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.el-chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.6rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-hint {
|
||||
color: var(--text-faint);
|
||||
font-size: 0.78rem;
|
||||
text-align: center;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.chat-msg {
|
||||
max-width: 85%;
|
||||
padding: 6px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-msg.user {
|
||||
align-self: flex-end;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
border-bottom-right-radius: 3px;
|
||||
}
|
||||
|
||||
.chat-msg.assistant {
|
||||
align-self: flex-start;
|
||||
background: var(--panel-soft);
|
||||
color: var(--text);
|
||||
border-bottom-left-radius: 3px;
|
||||
}
|
||||
|
||||
.chat-typing {
|
||||
color: var(--text-faint);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 0.6rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.chat-input textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
height: 72px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.chat-input textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.chat-input button {
|
||||
width: 38px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-input button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chat-input button.cancel {
|
||||
background: var(--danger);
|
||||
}
|
||||
</style>
|
||||
458
frontend/src/components/elements/ElementDetail.vue
Normal file
458
frontend/src/components/elements/ElementDetail.vue
Normal file
@@ -0,0 +1,458 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { updateElement, checkElement, styleElement, refineSuggestion } from '../../api.js'
|
||||
import { renderMarkdown } from '../../markdown.js'
|
||||
import ElementSuggestion from './ElementSuggestion.vue'
|
||||
import ElementChatTab from './ElementChatTab.vue'
|
||||
import ElementEditTab from './ElementEditTab.vue'
|
||||
|
||||
const props = defineProps({
|
||||
element: { type: Object, required: true },
|
||||
provider: { type: String, default: 'claude' },
|
||||
})
|
||||
|
||||
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, '')
|
||||
}
|
||||
|
||||
// Anderes Element gewählt → Prüf-Zustand und Tab zurücksetzen
|
||||
watch(() => props.element.id, () => {
|
||||
tab.value = 'overview'
|
||||
resetCheck()
|
||||
})
|
||||
|
||||
// --- KI-Prüfung auf fehlende Infos (Ergebnisse landen als Inline-Vorschläge) ---
|
||||
const checking = ref(false)
|
||||
const statusMsg = ref(null)
|
||||
|
||||
function resetCheck() {
|
||||
checking.value = false
|
||||
statusMsg.value = null
|
||||
resetStyle()
|
||||
}
|
||||
|
||||
let checkRun = 0 // laufende Prüfung identifizieren; Abbruch ignoriert ihr Ergebnis
|
||||
|
||||
async function runCheck() {
|
||||
if (checking.value) { // zweiter Klick = abbrechen
|
||||
checkRun++
|
||||
checking.value = false
|
||||
return
|
||||
}
|
||||
const run = ++checkRun
|
||||
checking.value = true
|
||||
statusMsg.value = null
|
||||
try {
|
||||
const res = await checkElement(props.element.id, props.provider)
|
||||
if (run !== checkRun) return // abgebrochen oder neue Prüfung gestartet
|
||||
const mapped = res.suggestions.map((s) => ({
|
||||
text: s.text, action: 'hinzufuegen', target: s.target, index: null, content: s.content,
|
||||
}))
|
||||
if (mapped.length) styleChanges.value = [...(styleChanges.value || []), ...mapped]
|
||||
else statusMsg.value = 'Keine wichtigen Lücken gefunden.'
|
||||
} catch (e) {
|
||||
if (run !== checkRun) return
|
||||
console.error('Prüfung fehlgeschlagen:', e)
|
||||
statusMsg.value = 'Prüfung fehlgeschlagen — bitte erneut versuchen.'
|
||||
} 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
|
||||
const styling = ref(false)
|
||||
const applyingStyle = ref(false)
|
||||
const refiningIdx = ref(null)
|
||||
let styleRun = 0
|
||||
|
||||
function resetStyle() {
|
||||
styleChanges.value = null
|
||||
styling.value = false
|
||||
applyingStyle.value = false
|
||||
refiningIdx.value = null
|
||||
}
|
||||
|
||||
function suggBusy(i) {
|
||||
return applyingStyle.value || refiningIdx.value === i
|
||||
}
|
||||
|
||||
// Einzelnen Vorschlag per Anweisung überarbeiten (Stift-Icon)
|
||||
async function refineChange(i, instruction) {
|
||||
if (refiningIdx.value !== null || applyingStyle.value) return
|
||||
refiningIdx.value = i
|
||||
try {
|
||||
const res = await refineSuggestion(props.element.id, styleChanges.value[i], instruction, props.provider)
|
||||
const next = [...styleChanges.value]
|
||||
next[i] = res.change
|
||||
styleChanges.value = next
|
||||
} catch (e) {
|
||||
console.error('Überarbeitung fehlgeschlagen:', e)
|
||||
statusMsg.value = 'Überarbeitung fehlgeschlagen — bitte erneut versuchen.'
|
||||
} finally {
|
||||
refiningIdx.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function runStyle() {
|
||||
if (styling.value) { // zweiter Klick = abbrechen
|
||||
styleRun++
|
||||
styling.value = false
|
||||
return
|
||||
}
|
||||
const run = ++styleRun
|
||||
styling.value = true
|
||||
statusMsg.value = null
|
||||
try {
|
||||
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.'
|
||||
} catch (e) {
|
||||
if (run !== styleRun) return
|
||||
console.error('Stil-Prüfung fehlgeschlagen:', e)
|
||||
statusMsg.value = 'Stil-Prüfung fehlgeschlagen — bitte erneut versuchen.'
|
||||
} finally {
|
||||
if (run === styleRun) styling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Chat-Vorschläge landen ebenfalls als Inline-Vorschläge in der Übersicht
|
||||
function onChatChanges(changes) {
|
||||
styleChanges.value = [...(styleChanges.value || []), ...changes]
|
||||
}
|
||||
|
||||
// Vorschläge am Ziel-Ort anzeigen: anpassen/entfernen beim betroffenen Eintrag …
|
||||
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')
|
||||
}
|
||||
|
||||
// … Ergänzungen am Ende der jeweiligen Sektion
|
||||
function styleAdds(target) {
|
||||
if (!styleChanges.value) return []
|
||||
return styleChanges.value
|
||||
.map((c, i) => [i, c])
|
||||
.filter(([, c]) => c.target === target && c.action === 'hinzufuegen')
|
||||
}
|
||||
|
||||
function dismissStyleChange(i) {
|
||||
styleChanges.value = styleChanges.value.filter((_, j) => j !== i)
|
||||
}
|
||||
|
||||
async function applyStyleChange(i) {
|
||||
if (applyingStyle.value) return
|
||||
const c = styleChanges.value[i]
|
||||
applyingStyle.value = true
|
||||
try {
|
||||
const STRING_TARGETS = ['title', 'description']
|
||||
const fields = {
|
||||
title: props.element.title,
|
||||
description: props.element.description,
|
||||
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.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
|
||||
else fields[c.target].push(c.content)
|
||||
} else if (STRING_TARGETS.includes(c.target)) fields[c.target] = c.content
|
||||
else fields[c.target][c.index] = c.content
|
||||
|
||||
const updated = await updateElement(props.element.id, fields)
|
||||
emit('updated', updated)
|
||||
|
||||
// Rest-Vorschläge behalten; Indizes hinter einer Entfernung rücken auf
|
||||
const rest = styleChanges.value.filter((_, j) => j !== i)
|
||||
if (c.action === 'entfernen') {
|
||||
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)
|
||||
} finally {
|
||||
applyingStyle.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bearbeiten-Tab: Felder direkt speichern ---
|
||||
async function saveEdit(fields) {
|
||||
if (savingEdit.value) return
|
||||
savingEdit.value = true
|
||||
try {
|
||||
const updated = await updateElement(props.element.id, fields)
|
||||
emit('updated', updated)
|
||||
tab.value = 'overview'
|
||||
} catch (e) {
|
||||
console.error('Speichern fehlgeschlagen:', e)
|
||||
} finally {
|
||||
savingEdit.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<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-tool" :class="{ busy: checking }"
|
||||
:title="checking ? 'Prüfung abbrechen' : 'Auf fehlende Infos prüfen'" @click="runCheck"
|
||||
>🔍</button>
|
||||
<button
|
||||
class="el-tool" :class="{ busy: styling }"
|
||||
:title="styling ? 'Prüfung abbrechen' : 'Stil prüfen & anpassen'" @click="runStyle"
|
||||
>✨</button>
|
||||
<button class="el-close" title="Schließen" @click="emit('close')">×</button>
|
||||
</header>
|
||||
|
||||
<nav class="el-tabs">
|
||||
<button :class="{ active: tab === 'overview' }" @click="tab = 'overview'">Übersicht</button>
|
||||
<button :class="{ active: tab === 'chat' }" @click="tab = 'chat'">Chat</button>
|
||||
<button :class="{ active: tab === 'edit' }" @click="tab = 'edit'">Bearbeiten</button>
|
||||
</nav>
|
||||
|
||||
<!-- Übersicht: untrennbar mit styleChanges/Apply verzahnt → bleibt hier -->
|
||||
<div v-show="tab === 'overview'" class="el-detail">
|
||||
<div v-if="element.description" class="el-desc markdown" v-html="renderMarkdown(element.description)"></div>
|
||||
<ElementSuggestion
|
||||
v-for="[ci, c] in [...styleAt('title'), ...styleAt('description'), ...styleAdds('description')]"
|
||||
:key="'sgd' + ci" :change="c" :busy="suggBusy(ci)"
|
||||
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
|
||||
/>
|
||||
<template v-for="(ex, i) in element.examples" :key="i">
|
||||
<div class="el-entry markdown" v-html="renderMarkdown(ex)"></div>
|
||||
<ElementSuggestion
|
||||
v-for="[ci, c] in styleAt('examples', i)"
|
||||
:key="'sge' + ci" :change="c" :busy="suggBusy(ci)"
|
||||
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
|
||||
/>
|
||||
</template>
|
||||
<ElementSuggestion
|
||||
v-for="[ci, c] in styleAdds('examples')"
|
||||
:key="'sgea' + ci" :change="c" :busy="suggBusy(ci)"
|
||||
@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>
|
||||
<ul class="el-hints">
|
||||
<li v-for="(h, i) in element.hints" :key="i">
|
||||
<span class="markdown" v-html="renderMarkdown(h)"></span>
|
||||
<ElementSuggestion
|
||||
v-for="[ci, c] in styleAt('hints', i)"
|
||||
:key="'sgh' + ci" :change="c" :busy="suggBusy(ci)"
|
||||
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
<ElementSuggestion
|
||||
v-for="[ci, c] in styleAdds('hints')"
|
||||
:key="'sgha' + ci" :change="c" :busy="suggBusy(ci)"
|
||||
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
|
||||
/>
|
||||
</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="statusMsg && !checking && !styling" class="check-empty">{{ statusMsg }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- v-show erhält den Chat-Verlauf beim Tab-Wechsel -->
|
||||
<ElementChatTab
|
||||
v-show="tab === 'chat'"
|
||||
:element="element"
|
||||
:provider="provider"
|
||||
@changes="onChatChanges"
|
||||
/>
|
||||
|
||||
<!-- v-if lädt die Edit-Felder bei jedem Öffnen frisch -->
|
||||
<ElementEditTab
|
||||
v-if="tab === 'edit'"
|
||||
:element="element"
|
||||
:saving="savingEdit"
|
||||
@save="saveEdit"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.el-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.el-title {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.el-back,
|
||||
.el-close {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-faint);
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.el-back:hover,
|
||||
.el-close:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.el-tool {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 2px 3px;
|
||||
border-radius: 6px;
|
||||
filter: grayscale(0.4);
|
||||
}
|
||||
|
||||
.el-tool:hover {
|
||||
background: var(--panel-soft);
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.el-tool.busy {
|
||||
filter: none;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.busy-text {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
.el-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.el-tabs button {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.25rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.el-tabs button.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.el-tabs button:hover:not(.active) {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.el-detail {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.el-desc {
|
||||
margin: 0 0 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.el-hints-block {
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
.el-hints-block h4 {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.el-entry {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.el-entry:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.el-hints {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
}
|
||||
|
||||
.el-hints li {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* Hinweis-Text inline neben dem Bullet halten (p ist sonst block) */
|
||||
.el-hints li > .markdown {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.el-hints li > .markdown :deep(p) {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Markdown: Basis global (assets/markdown.css); schmale Sidebar → kompaktere Code-Blöcke */
|
||||
.markdown :deep(pre) {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
/* --- KI-Prüfung --- */
|
||||
.el-check {
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.8rem;
|
||||
border-top: 1px dashed var(--border-strong);
|
||||
}
|
||||
|
||||
.check-empty {
|
||||
margin: 0.6rem 0 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-faint);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
164
frontend/src/components/elements/ElementEditTab.vue
Normal file
164
frontend/src/components/elements/ElementEditTab.vue
Normal file
@@ -0,0 +1,164 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
element: { type: Object, required: true },
|
||||
saving: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['save'])
|
||||
|
||||
const edit = ref({ title: '', description: '', examples: [], hints: [] })
|
||||
|
||||
watch(() => props.element, load, { immediate: true })
|
||||
|
||||
function load() {
|
||||
edit.value = {
|
||||
title: props.element.title,
|
||||
description: props.element.description,
|
||||
examples: [...props.element.examples],
|
||||
hints: [...props.element.hints],
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (props.saving) return
|
||||
emit('save', {
|
||||
title: edit.value.title,
|
||||
description: edit.value.description,
|
||||
examples: edit.value.examples.filter((s) => s.trim()),
|
||||
hints: edit.value.hints.filter((s) => s.trim()),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="el-edit">
|
||||
<button class="edit-save" :disabled="saving" @click="save">
|
||||
{{ saving ? 'Speichert…' : 'Speichern' }}
|
||||
</button>
|
||||
|
||||
<label>Titel</label>
|
||||
<input v-model="edit.title" placeholder="Titel" />
|
||||
|
||||
<label>Beschreibung</label>
|
||||
<textarea v-model="edit.description" placeholder="Beschreibung"></textarea>
|
||||
|
||||
<label>Beispiele</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>
|
||||
</div>
|
||||
<button class="edit-add" @click="edit.examples.push('')">+ Beispiel</button>
|
||||
|
||||
<label>Hinweise</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>
|
||||
</div>
|
||||
<button class="edit-add" @click="edit.hints.push('')">+ Hinweis</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.el-edit {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.9rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.el-edit label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-faint);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.el-edit input,
|
||||
.el-edit textarea {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.el-edit textarea {
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
overflow: auto;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.el-edit input:focus,
|
||||
.el-edit textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.edit-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.edit-row textarea {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.edit-del {
|
||||
flex-shrink: 0;
|
||||
width: 30px;
|
||||
align-self: stretch;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
color: var(--danger);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edit-add {
|
||||
align-self: flex-start;
|
||||
padding: 5px 10px;
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edit-add:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.edit-save {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
margin-bottom: 0.3rem;
|
||||
padding: 9px 10px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edit-save:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: wait;
|
||||
}
|
||||
</style>
|
||||
198
frontend/src/components/elements/ElementList.vue
Normal file
198
frontend/src/components/elements/ElementList.vue
Normal file
@@ -0,0 +1,198 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useConfirm } from '../../composables/useConfirm.js'
|
||||
|
||||
const props = defineProps({
|
||||
elements: { type: Array, required: true },
|
||||
creating: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
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, '')
|
||||
}
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = query.value.trim().toLowerCase()
|
||||
if (!q) return props.elements
|
||||
return props.elements.filter(
|
||||
(el) => el.title.toLowerCase().includes(q) || el.description.toLowerCase().includes(q),
|
||||
)
|
||||
})
|
||||
|
||||
function add() {
|
||||
if (props.creating) return
|
||||
emit('create', query.value.trim())
|
||||
query.value = ''
|
||||
}
|
||||
|
||||
// Inline-Bestätigung: erster Klick „Sicher?", zweiter löscht
|
||||
function confirmDelete(el) {
|
||||
armOrRun('el-' + el.id, () => emit('remove', el))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="el-new">
|
||||
<input
|
||||
v-model="query"
|
||||
placeholder="Suchen oder Stichwort…"
|
||||
:disabled="creating"
|
||||
@keyup.enter="add"
|
||||
/>
|
||||
<button :disabled="creating" title="Element per KI erstellen" @click="add">+</button>
|
||||
</div>
|
||||
<div v-if="creating" class="el-creating">KI erstellt 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>
|
||||
</div>
|
||||
<button
|
||||
class="el-delete"
|
||||
:class="{ armed: isArmed('el-' + el.id) }"
|
||||
title="Element löschen"
|
||||
@click.stop="confirmDelete(el)"
|
||||
>{{ isArmed('el-' + el.id) ? 'Sicher?' : '×' }}</button>
|
||||
</li>
|
||||
<li v-if="!filtered.length && !creating" class="el-empty">
|
||||
{{ elements.length ? 'Keine Treffer.' : 'Noch keine Elemente. Stichwort eingeben und + klicken.' }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.el-new {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
}
|
||||
|
||||
.el-new input {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.el-new input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.el-new button {
|
||||
width: 38px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.el-new button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.el-creating {
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--warning);
|
||||
background: var(--warning-soft);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
.el-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.el-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.el-list li:hover {
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.el-item-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.el-item-title {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.el-item-desc {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-faint);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.el-delete {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--danger);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.el-list li:hover .el-delete {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.el-delete.armed {
|
||||
visibility: visible;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.el-empty {
|
||||
cursor: default !important;
|
||||
color: var(--text-faint);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.el-empty:hover {
|
||||
background: none !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { renderMarkdown } from '../markdown.js'
|
||||
import { renderMarkdown } from '../../markdown.js'
|
||||
|
||||
const props = defineProps({
|
||||
change: { type: Object, required: true },
|
||||
@@ -175,38 +175,10 @@ function submit() {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Markdown in der Vorschau */
|
||||
.markdown :deep(p) {
|
||||
margin: 0 0 0.4em;
|
||||
}
|
||||
|
||||
.markdown :deep(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.markdown :deep(code) {
|
||||
background: var(--border);
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
font-family: "SF Mono", Consolas, monospace;
|
||||
font-size: 0.85em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Markdown: Basis global (assets/markdown.css); kompakte Vorschau-Code-Blöcke */
|
||||
.markdown :deep(pre) {
|
||||
background: var(--code-bg, #1e2330);
|
||||
color: var(--code-fg, #e6e8ee);
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
margin: 0.3em 0;
|
||||
}
|
||||
|
||||
.markdown :deep(pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
</style>
|
||||
156
frontend/src/components/elements/ElementsSidebar.vue
Normal file
156
frontend/src/components/elements/ElementsSidebar.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { fetchElements, createElement, deleteElement } from '../../api.js'
|
||||
import ElementList from './ElementList.vue'
|
||||
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
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'changed'])
|
||||
|
||||
const elements = ref([])
|
||||
const creating = ref(false)
|
||||
const selected = ref(null)
|
||||
|
||||
watch(() => props.topic, load, { immediate: true })
|
||||
|
||||
async function load() {
|
||||
selected.value = null
|
||||
try {
|
||||
elements.value = await fetchElements(props.topic)
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden der Elemente:', e)
|
||||
}
|
||||
openFromProp()
|
||||
}
|
||||
|
||||
// Aus der Übersicht im Hauptbereich angeklicktes Element öffnen
|
||||
watch(() => props.openTick, openFromProp)
|
||||
|
||||
function openFromProp() {
|
||||
if (!props.openId) return
|
||||
const el = elements.value.find((e) => e.id === props.openId)
|
||||
if (el) selected.value = el
|
||||
}
|
||||
|
||||
async function create(hint) {
|
||||
if (creating.value) return
|
||||
creating.value = true
|
||||
try {
|
||||
const el = await createElement(props.topic, hint, props.provider)
|
||||
elements.value.unshift(el)
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Erstellen des Elements:', e)
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(el) {
|
||||
await deleteElement(el.id)
|
||||
elements.value = elements.value.filter((e) => e.id !== el.id)
|
||||
if (selected.value?.id === el.id) selected.value = null
|
||||
emit('changed')
|
||||
}
|
||||
|
||||
// Bearbeitetes Element in Liste und Auswahl synchron halten
|
||||
function onUpdated(el) {
|
||||
selected.value = el
|
||||
const idx = elements.value.findIndex((e) => e.id === el.id)
|
||||
if (idx !== -1) elements.value.splice(idx, 1, el)
|
||||
emit('changed')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="elements-sidebar">
|
||||
<ElementDetail
|
||||
v-if="selected"
|
||||
:element="selected"
|
||||
:provider="provider"
|
||||
@back="selected = null"
|
||||
@close="emit('close')"
|
||||
@updated="onUpdated"
|
||||
@changed="emit('changed')"
|
||||
/>
|
||||
<template v-else>
|
||||
<header class="el-header">
|
||||
<span class="el-title">Elemente</span>
|
||||
<button class="el-close" title="Schließen" @click="emit('close')">×</button>
|
||||
</header>
|
||||
<ElementList
|
||||
:elements="elements"
|
||||
:creating="creating"
|
||||
@select="(el) => (selected = el)"
|
||||
@create="create"
|
||||
@remove="remove"
|
||||
/>
|
||||
</template>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.elements-sidebar {
|
||||
width: 320px;
|
||||
min-width: 320px;
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--panel);
|
||||
border-left: 1px solid var(--border);
|
||||
/* Über dem 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. */
|
||||
@media (max-width: 768px) {
|
||||
.elements-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: min(100vw, 380px);
|
||||
min-width: 0;
|
||||
box-shadow: -4px 0 16px var(--shadow);
|
||||
}
|
||||
}
|
||||
|
||||
.el-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.el-title {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.el-close {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-faint);
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.el-close:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
</style>
|
||||
89
frontend/src/composables/useChat.js
Normal file
89
frontend/src/composables/useChat.js
Normal file
@@ -0,0 +1,89 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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.
|
||||
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
|
||||
|
||||
// @scroll-Handler: pinnt nur, wenn der Nutzer (fast) unten ist.
|
||||
function onScroll() {
|
||||
if (messagesEl.value) stick.value = istUnten(messagesEl.value)
|
||||
}
|
||||
|
||||
async function scrollToBottom() {
|
||||
await nextTick()
|
||||
if (messagesEl.value && stick.value) messagesEl.value.scrollTop = messagesEl.value.scrollHeight
|
||||
}
|
||||
|
||||
function autoGrow(max = 140) {
|
||||
const el = inputEl.value
|
||||
if (!el) return
|
||||
el.style.height = 'auto'
|
||||
el.style.height = Math.min(el.scrollHeight, max) + 'px'
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
run++
|
||||
loading.value = false
|
||||
messages.value.push({ role: 'assistant', content: 'Abgebrochen.' })
|
||||
}
|
||||
|
||||
function reset() {
|
||||
run++
|
||||
loading.value = false
|
||||
messages.value = []
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
async function send() {
|
||||
if (loading.value) { // zweiter Klick = abbrechen
|
||||
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
|
||||
const current = ++run
|
||||
messages.value.push({ role: 'user', content: text })
|
||||
input.value = ''
|
||||
nextTick(() => autoGrow())
|
||||
loading.value = true
|
||||
scrollToBottom()
|
||||
try {
|
||||
const res = await performRequest(messages.value)
|
||||
if (current !== run) return null
|
||||
// Prüfung liefert `frage` (+ getrenntes `feedback`); andere Chats `reply`.
|
||||
messages.value.push({
|
||||
role: 'assistant',
|
||||
content: res.frage ?? res.reply ?? '…',
|
||||
feedback: res.feedback ?? null,
|
||||
bewertung: res.bewertung ?? null,
|
||||
})
|
||||
return res
|
||||
} catch {
|
||||
if (current !== run) return null
|
||||
messages.value.push({ role: 'assistant', content: 'Fehler bei der Anfrage.' })
|
||||
return null
|
||||
} finally {
|
||||
if (current === run) {
|
||||
loading.value = false
|
||||
scrollToBottom()
|
||||
nextTick(() => inputEl.value?.focus())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { messages, input, loading, messagesEl, inputEl, stick, onScroll, send, cancel, reset, scrollToBottom, autoGrow }
|
||||
}
|
||||
32
frontend/src/composables/useConfirm.js
Normal file
32
frontend/src/composables/useConfirm.js
Normal file
@@ -0,0 +1,32 @@
|
||||
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).
|
||||
export function useConfirm(timeoutMs = 3000) {
|
||||
const pending = ref(null) // aktuell scharfgeschalteter Key
|
||||
let timer = null
|
||||
|
||||
function armOrRun(key, action) {
|
||||
clearTimeout(timer)
|
||||
if (pending.value === key) {
|
||||
pending.value = null
|
||||
action()
|
||||
} else {
|
||||
pending.value = key
|
||||
timer = setTimeout(() => { pending.value = null }, timeoutMs)
|
||||
}
|
||||
}
|
||||
|
||||
function isArmed(key) {
|
||||
return pending.value === key
|
||||
}
|
||||
|
||||
function reset() {
|
||||
clearTimeout(timer)
|
||||
pending.value = null
|
||||
}
|
||||
|
||||
onUnmounted(() => clearTimeout(timer))
|
||||
|
||||
return { pending, isArmed, armOrRun, reset }
|
||||
}
|
||||
44
frontend/src/composables/usePolling.js
Normal file
44
frontend/src/composables/usePolling.js
Normal file
@@ -0,0 +1,44 @@
|
||||
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.
|
||||
export function usePolling(tick, isActive, interval = 3000) {
|
||||
let timer = null
|
||||
|
||||
function stop() {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
stop()
|
||||
timer = setInterval(async () => {
|
||||
await tick()
|
||||
if (!isActive()) stop()
|
||||
}, interval)
|
||||
}
|
||||
|
||||
function running() {
|
||||
return !!timer
|
||||
}
|
||||
|
||||
async function onVisibility() {
|
||||
if (document.hidden) {
|
||||
stop()
|
||||
} else {
|
||||
await tick()
|
||||
if (isActive()) start()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
onUnmounted(() => {
|
||||
stop()
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
})
|
||||
|
||||
return { start, stop, running }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './assets/markdown.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
|
||||
@@ -2,6 +2,8 @@ import { marked } from 'marked'
|
||||
import { markedHighlight } from 'marked-highlight'
|
||||
import hljs from 'highlight.js'
|
||||
import 'highlight.js/styles/github-dark.css'
|
||||
import katex from 'katex'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
marked.use(markedHighlight({
|
||||
@@ -15,6 +17,40 @@ 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.
|
||||
function renderTex(tex, displayMode) {
|
||||
return katex.renderToString(tex, { displayMode, throwOnError: false, output: 'html' })
|
||||
}
|
||||
|
||||
const blockMath = {
|
||||
name: 'blockMath',
|
||||
level: 'block',
|
||||
start(src) { const i = src.indexOf('$$'); return i < 0 ? undefined : i },
|
||||
tokenizer(src) {
|
||||
const m = /^\$\$([\s\S]+?)\$\$/.exec(src)
|
||||
if (m) return { type: 'blockMath', raw: m[0], text: m[1].trim() }
|
||||
},
|
||||
renderer(token) { return renderTex(token.text, true) },
|
||||
}
|
||||
|
||||
const inlineMath = {
|
||||
name: '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.
|
||||
const m = /^\$(?![\s$])((?:\\\$|[^$])+?)\$/.exec(src)
|
||||
if (!m || /\s$/.test(m[1])) return
|
||||
return { type: 'inlineMath', raw: m[0], text: m[1].trim() }
|
||||
},
|
||||
renderer(token) { return renderTex(token.text, false) },
|
||||
}
|
||||
|
||||
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.
|
||||
marked.use({
|
||||
@@ -29,3 +65,9 @@ marked.use({
|
||||
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.
|
||||
export function renderMarkdownInline(text) {
|
||||
return DOMPurify.sanitize(marked.parseInline(text || ''))
|
||||
}
|
||||
|
||||
31
frontend/src/pruefungCache.js
Normal file
31
frontend/src/pruefungCache.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// 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.
|
||||
|
||||
import { ref } from 'vue'
|
||||
|
||||
const store = new Map() // key "topic::baustein" -> Refs (Verlauf + Fragen-Pool)
|
||||
|
||||
export function usePruefSlot(key) {
|
||||
if (!store.has(key)) {
|
||||
store.set(key, {
|
||||
messages: ref([]),
|
||||
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}
|
||||
})
|
||||
}
|
||||
return store.get(key)
|
||||
}
|
||||
|
||||
export const clearPruef = (key) => store.delete(key)
|
||||
@@ -10,6 +10,12 @@ export default defineConfig({
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
],
|
||||
// Default-cssTarget enthält alte Browser (firefox78) — esbuild optimiert dann die
|
||||
// unprefixed backdrop-filter weg und lässt nur -webkit (in Firefox unsichtbar).
|
||||
// Moderne Ziele + Safari erzwingen, dass BEIDE Varianten erhalten bleiben.
|
||||
build: {
|
||||
cssTarget: ['chrome90', 'firefox103', 'safari15', 'edge90'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
|
||||
167
recherche.txt
Normal file
167
recherche.txt
Normal file
@@ -0,0 +1,167 @@
|
||||
================================================================================
|
||||
RECHERCHE: Guide-Generierungskette erweitern
|
||||
Ziel: Lernen ohne Angst vor (a) Falschem, (b) fehlenden Infos,
|
||||
(c) nervigem Aufbau, (d) Überforderung durch Struktur/Auswahl/Lesbarkeit.
|
||||
Bestehende Kette: Auswahl → Gliederung → Inhalte → Inhalts-Check → Writer → Lese-Check.
|
||||
================================================================================
|
||||
|
||||
## META-ERKENNTNIS (gilt übergreifend)
|
||||
- "Mehr gleiche Agenten" sättigt früh und erzeugt Echo-Kammer: identische Modelle
|
||||
teilen dieselben systematischen Fehler. Voting/Debatte schlagen Single-Agent nur
|
||||
moderat und nicht zuverlässig besser als Self-Consistency.
|
||||
- Echte Hebel: HETEROGENITÄT (andere Modelle), EXTERNE ERDUNG (Quellen/Tools/Websuche),
|
||||
ROLLEN-TRENNUNG, GATES zwischen Stufen.
|
||||
- Selbst-Korrektur OHNE externes Feedback verbessert Faktualität kaum, kann verschlechtern
|
||||
(CommonSenseQA 75,8 → 38,1 nach 1 Runde). Nur MIT Quelle/Tool/Oracle einsetzen.
|
||||
- ~41,8 % der Multi-Agent-Fehler sind System-/Spezifikations-Design, nicht Modell-Fähigkeit
|
||||
(MAST). → Architektur (Rollen, Gates, Stopp-Logik) ist der größte Hebel.
|
||||
|
||||
|
||||
================================================================================
|
||||
A) GEGEN FALSCHES (faktische Korrektheit)
|
||||
================================================================================
|
||||
1. RAG / Grounding gegen verlässliche Quellen — stärkster Hebel gegen erfundene Fakten.
|
||||
Generierung auf abgerufene Quell-Passagen beschränken. (Inhalte/Prüfen)
|
||||
2. Zitat-Pflicht + Attribution: jeder Fakt braucht Inline-Quelle; gilt nur als belegt,
|
||||
wenn aus dem Zitat ableitbar. Macht Fehler sichtbar/prüfbar. (Schreiben/Prüfen)
|
||||
3. Claim-Extraktion + Verifikation (FActScore-Stil): Text in atomare Fakten zerlegen,
|
||||
jeden einzeln gegen Quelle prüfen. Fängt einzelne falsche Sätze. (Prüfen)
|
||||
4. SAFE (Search-Augmented Factuality Evaluator): Claims zerlegen → pro Claim Websuche →
|
||||
per Reasoning prüfen. Übertrifft menschliche Annotatoren, ~20x billiger.
|
||||
Ideal, da Websuche schon vorhanden. (Prüfen)
|
||||
5. Abstention bei Unsicherheit: unbelegte/inkonsistente Fakten weglassen oder markieren
|
||||
statt raten. Tauscht Coverage gegen Korrektheit — für Lerninhalte oft richtig.
|
||||
6. Chain-of-Verification (CoVe): Entwurf → Verifikationsfragen → einzeln beantworten →
|
||||
revidieren. Günstig (reines Prompting). (Prüfen, pro Baustein)
|
||||
7. Self-Consistency: mehrere Pfade samplen, Mehrheit wählen. Nur für Varianz-Reduktion,
|
||||
NICHT für Wahrheit (systematische Fehler bleiben). Sweet Spot 10–20 Samples.
|
||||
8. Post-hoc Revision (RARR): fertigen Text nehmen, pro Claim Evidenz suchen, widersprechende
|
||||
Claims editieren, Originaltext maximal erhalten. (zwischen Schreiben und Lesbarkeit)
|
||||
9. Stärkerer Verifier statt mehr gleicher Agenten: generieren günstig, verifizieren mit
|
||||
stärkerem/anderem Modell. Lohnt bei schwierigen/seltenen Fakten. (Prüfen)
|
||||
10. Self-Refine/Reflexion NUR mit externem Feedback (Tool/Quelle). Intrinsisch verschlechtert.
|
||||
|
||||
Quellen A:
|
||||
- SAFE / Long-form Factuality: https://openreview.net/pdf?id=4M9f8VMt2C
|
||||
- FActScore (Primer): https://aman.ai/primers/ai/factuality-in-LLMs/
|
||||
- Chain-of-Verification: https://arxiv.org/pdf/2309.11495
|
||||
- Grounded Attribution + Refuse: https://arxiv.org/pdf/2409.11242
|
||||
- Self-Correction braucht externes Feedback: https://arxiv.org/abs/2310.01798
|
||||
- When Can LLMs Self-Correct (TACL): https://direct.mit.edu/tacl/article/doi/10.1162/tacl_a_00713/125177/
|
||||
- RARR: https://aclanthology.org/2023.acl-long.910/
|
||||
- Weaver / Generation-Verification-Gap: https://arxiv.org/pdf/2506.18203
|
||||
- Abstention-Survey: https://direct.mit.edu/tacl/article/doi/10.1162/tacl_a_00754/131566/
|
||||
|
||||
|
||||
================================================================================
|
||||
B) GEGEN LÜCKEN (Vollständigkeit)
|
||||
================================================================================
|
||||
1. Backward Design (Stufe 0): zuerst Lernziele definieren, DANN Inhalt generieren.
|
||||
3 Schichten: "familiar with" / "important to know" / "enduring understanding".
|
||||
2. Lernziele explizit als Anker-Liste (Bloom-Verben). Jeder Abschnitt wird genau einem
|
||||
Lernziel zugeordnet. Ziel ohne Inhalt = Lücke; Inhalt ohne Ziel = Ballast.
|
||||
3. Curriculum-Mapping / Alignment-Matrix (Lernziel × Abschnitt × Assessment) als eigene
|
||||
Stufe: prüft Abdeckung, Doppelungen, hängende Ziele gleichzeitig.
|
||||
4. "What's missing"-Critic-Agent: bekommt Thema + Guide + Referenzfakten, sucht NUR
|
||||
fehlende korrekte Punkte, bessert minimal nach. Discrimination von Revision trennen.
|
||||
5. "Das Wichtige" verlässlich via Mehrquellen-Konsens (Triangulation): Standard-Curricula
|
||||
+ mehrere unabhängige Quellen (Schnittmenge = Kern) + LLM-Delphi (Konsens-Topics).
|
||||
|
||||
Quellen B:
|
||||
- Backward Design: https://fctl.ucf.edu/teaching-resources/course-design/backward-design/
|
||||
- Understanding by Design: https://en.wikipedia.org/wiki/Understanding_by_Design
|
||||
- GPT-4 Learning Objectives: https://arxiv.org/pdf/2306.17459
|
||||
- Curriculum Mapping: https://pce.sandiego.edu/curriculum-mapping/
|
||||
- Critique-Guided Improvement: https://arxiv.org/pdf/2503.16024
|
||||
- Delphi-Konsens (PMC): https://pmc.ncbi.nlm.nih.gov/articles/PMC8178515/
|
||||
|
||||
|
||||
================================================================================
|
||||
C) GEGEN ÜBERFORDERUNG (Struktur, Reihenfolge, Lesbarkeit)
|
||||
================================================================================
|
||||
1. Prerequisite-/Concept-Dependency-Graph: Konzepte = Knoten, "X braucht Y" = Kante.
|
||||
Topologische Sortierung → gültige Reihenfolge (Basics zuerst). LLM extrahiert Kanten,
|
||||
Zyklus-Check. Knoten mit vielen ausgehenden Kanten = Kernkonzepte, früh.
|
||||
2. Knowledge-Space-Theorie (Doignon/Falmagne, ALEKS): erlaubte Wissenszustände statt fixer
|
||||
Linie. Validiert Reihenfolge, schließt unmögliche Pfade aus.
|
||||
3. Bloom-Progression: Remember → Understand → Apply → Analyze → Evaluate → Create.
|
||||
Jeden Abschnitt taggen, monotone Progression prüfen.
|
||||
4. Cognitive Load GLOBAL budgetieren (nicht nur pro Abschnitt): Working Memory ~4–7
|
||||
Einheiten. Neue Konzepte/Abschnitt begrenzen, Spitzen glätten, schwer/leicht abwechseln.
|
||||
5. Chunking + einfach→komplex: Teilfertigkeiten zuerst, dann zusammensetzen. Lange
|
||||
Abschnitte automatisch splitten (Max-Chunk-Regel).
|
||||
6. Scaffolding mit Fading: anfangs viele Worked Examples, Stütze schrittweise zurücknehmen.
|
||||
Achtung Expertise-Reversal: was Novizen hilft, bremst Fortgeschrittene.
|
||||
7. Diátaxis-Struktur: Tutorial / How-to / Reference / Explanation NICHT mischen
|
||||
(häufigste Ursache verwirrender Doku). Für Lern-Guide: Tutorial + Explanation trennen.
|
||||
8. Spiral-Curriculum + Spaced Retrieval: Kernideen wiederkehren lassen, aktives Abrufen.
|
||||
+ Lesbarkeit (frühere Recherche): Ø-Satz ≤ 20 W, keiner > 40; ≤ ~4 neue Begriffe/Abschnitt;
|
||||
eine Idee pro Absatz; Listen statt Aufzählungssätze.
|
||||
|
||||
Quellen C:
|
||||
- Concept Graph (CMU): https://www.cs.cmu.edu/~hanxiaol/publications/yang-wsdm15.pdf
|
||||
- Prerequisite Relations (AAAI): https://ojs.aaai.org/index.php/AAAI/article/view/10550
|
||||
- Knowledge Spaces / ALEKS: https://www.aleks.com/about_aleks/Science_Behind_ALEKS.pdf
|
||||
- Cognitive Load Strategien: https://www.structural-learning.com/post/cognitive-load-theory-a-teachers-guide
|
||||
- Expertise Reversal: https://en.wikipedia.org/wiki/Expertise_reversal_effect
|
||||
- Diátaxis: https://diataxis.fr/start-here/
|
||||
- Spiral Curriculum: https://www.structural-learning.com/post/the-spiral-curriculum-a-teachers-guide
|
||||
|
||||
|
||||
================================================================================
|
||||
D) ARCHITEKTUR-METHODEN (Pipeline-Qualität)
|
||||
================================================================================
|
||||
1. Generator–Critic-Loop NUR mit externer Erdung (Self-Refine ~+20 % bei offenen Aufgaben;
|
||||
intrinsisch ohne Signal verschlechtert). Für Faktenprüfung Tool-Erdung (CRITIC).
|
||||
2. Stopp-Kriterien: fast alle Gewinne in Runde 1–2. Stoppen bei Stabilität/Score-Delta<1/
|
||||
Draft-Ähnlichkeit>0,90. Bestes Draft per Validierung wählen, nicht das letzte.
|
||||
3. Rollen-Spezialisierung statt Klone: Faktenprüfer ≠ Didaktiker ≠ Lektor ≠ Zielgruppen-
|
||||
Anwalt. Erlaubt kleinere/billigere Modelle pro Teilschritt.
|
||||
4. Heterogenität ist der Wirkstoff von Debatte: Gewinn kommt aus VERSCHIEDENEN Modellen,
|
||||
nicht aus dem Mechanismus. Identische Modelle ≈ Self-Consistency, nur teurer.
|
||||
5. LLM-as-Judge mit Rubrik, BINÄR (pass/fail) statt 1–5; Multi-Kriterien getrennt; vage
|
||||
Begriffe definieren; niedrige Temperatur; gegen 1 Experten kalibrieren.
|
||||
6. Eval-Harness + Regression-Evals (CI-Gate): Golden-Set (25–50 Fälle reichen anfangs),
|
||||
wächst mit jedem Produktionsfehler. PR/Release blockt unter Schwelle.
|
||||
7. Diminishing Returns: Voting Sweet Spot 10–20 Samples; Debatte-Plateau ~3 Runden;
|
||||
bei sequentiellen Aufgaben degradiert jede Multi-Agent-Variante um 39–70 %.
|
||||
8. Fehler-Akkumulation: 95 %/Schritt × 10 = 59 %; 90 % → 35 %. Verifier-Gate ZWISCHEN
|
||||
die Stufen; zentrale Koordination begrenzt Verstärkung (4,4x statt 17,2x).
|
||||
9. Pitfall Sycophancy/Konsens-Bias: Modelle stimmen zu (~42–58 %); Peer-Druck-Flip ~48 %.
|
||||
Fix STRUKTURELL: Mehrheit klein halten (6→3 Agenten senkt Konformität 70 %→33 %),
|
||||
isolierte Selbstkorrektur vor Konsens. Prompt-Nudges ("sei standhaft") wirken NICHT.
|
||||
10. Pitfall Self-Preference: Judge bevorzugt eigene Outputs (GPT-4 ~+10 %, Claude ~+25 %).
|
||||
Fix: Judge ≠ Generator-Modell (Cross-Model-Judging), Positionen tauschen+mitteln.
|
||||
11. Human-in-the-Loop kalibrierend, NICHT als blindes Reward (OpenAI GPT-4o-Sycophancy-
|
||||
Vorfall April 2025 durch Thumbs-up/down). Experten-Review-Gate behalten.
|
||||
12. MAST-Failure-Taxonomie als Architektur-Checkliste (System-Design / Inter-Agent /
|
||||
Verification-Termination).
|
||||
|
||||
Quellen D:
|
||||
- Self-Refine: https://arxiv.org/pdf/2303.17651
|
||||
- CRITIC: https://arxiv.org/abs/2305.11738
|
||||
- Multiagent Debate: https://arxiv.org/abs/2305.14325 ; Stop Overvaluing: https://arxiv.org/pdf/2502.08788
|
||||
- LLM-as-Judge (MT-Bench): https://arxiv.org/abs/2306.05685 ; G-Eval: https://aclanthology.org/2023.emnlp-main.153/
|
||||
- Eval-Prozess (Hamel): https://hamel.dev/blog/posts/llm-judge/
|
||||
- Google Scaling Agent Systems: https://research.google/blog/towards-a-science-of-scaling-agent-systems-when-and-why-agent-systems-work/
|
||||
- Sycophancy: https://arxiv.org/abs/2310.13548 ; Conformity: https://arxiv.org/abs/2505.21588
|
||||
- MAST: https://arxiv.org/abs/2503.13657
|
||||
- OpenAI Sycophancy-Postmortem: https://venturebeat.com/ai/openai-rolls-back-chatgpts-sycophancy-and-explains-what-went-wrong
|
||||
|
||||
|
||||
================================================================================
|
||||
EMPFOHLENE NÄCHSTE STUFEN FÜR UNSERE KETTE (priorisiert, größter Nutzen/Aufwand)
|
||||
================================================================================
|
||||
0. (Stufe ganz vorn) Lernziele definieren (Backward Design) + "das Wichtige" via
|
||||
Mehrquellen-Konsens → ankert Auswahl UND Vollständigkeit.
|
||||
1. Reihenfolge: Prerequisite-Graph + topologische Sortierung in/nach der Gliederung
|
||||
(Basics zuerst, keine Vorgriffe).
|
||||
2. Faktencheck-Gate (SAFE/CoVe) in der Inhalts-Prüfung — nutzt vorhandene Websuche;
|
||||
Verifier = anderes/stärkeres Modell, BINÄRE Urteile.
|
||||
3. Abstention: unbelegte Fakten raus/markieren.
|
||||
4. Coverage-/"Was-fehlt"-Critic + Lernziel↔Inhalt-Mapping als Schluss-Gate (zurück bei Lücken).
|
||||
5. Cognitive-Load-Pass über den ganzen Guide (neue Begriffe/Abschnitt begrenzen, schwer/leicht mischen).
|
||||
6. Architektur-Hygiene: Gate zwischen jeder Stufe (Judge ≠ Generator), Loops nach 1–2 Runden
|
||||
stoppen, Voting 10–20 Samples, kleine Mehrheiten (Konsens-Bias), Golden-Set-Regression-Evals.
|
||||
|
||||
WARNUNG: NICHT auf "mehr gleiche Agenten" setzen. Heterogenität + externe Erdung + Gates.
|
||||
@@ -1,26 +1,74 @@
|
||||
SECTION-AUFBAU
|
||||
|
||||
Jeder Baustein wird GENAU eine Section mit:
|
||||
1. Titel — der Baustein-Titel (kommt aus dem Marker, nicht in den Body schreiben)
|
||||
2. Beschreibung — was es ist und wozu: MAXIMAL 1–2 Sätze
|
||||
3. Beispiele — KURZ und SIMPEL: wenige Zeilen Code, das Minimalbeispiel, keine Realwelt-Komplexität. Höchstens 1 knapper Satz Einordnung dazu. Ein Beispiel pro relevanter Variante: simple Bausteine eines, variantenreiche mehrere. Geordnet vom Üblichen zum Speziellen. Weglassen, wenn ohne Mehrwert.
|
||||
Jeder Baustein ist ein kleiner, eigenständiger Lern-Guide: er stellt EIN Konzept vor, erklärt es von Grund auf und macht es nutzbar. Zielgruppe: ein Junior-Entwickler, der das Thema NEU lernt und KEIN Vorwissen mitbringt. Du holst ihn ab und bringst ihm die Sache bei. Eine Section ist kein Stichwort-Zettel zum Nachschlagen.
|
||||
|
||||
Aufbau je Baustein — drei Beats, fließend ineinander, OHNE Zwischenüberschriften:
|
||||
1. Einordnung (PFLICHT, der Ankerpunkt) — welches Problem löst der Baustein, wozu braucht man ihn? Knüpfe an etwas Bekanntes/Alltägliches an, bevor das Neue kommt. Ohne diesen Anker steht ein Neuling im Leeren. Nie weglassen.
|
||||
2. Erklärung — was es ist UND wie/warum es funktioniert. Alltagssprache, von der Intuition zum Detail. JEDEN Fachbegriff beim ersten Auftreten in einem Halbsatz auflösen — auch Begriffe aus dem Section-Titel oder anderen Bausteinen NIE als bekannt voraussetzen. Eine Analogie oder ein Bild ist erlaubt und oft besser als eine Definition. „Wie"-Abläufe Schritt für Schritt zeigen (nicht nur das Ergebnis nennen). Kleine Beispiele/Mini-Snippets dürfen schon hier mitten im Text stehen, wo sie einen Punkt sofort greifbar machen.
|
||||
3. Beispiel(e) — das Konzept konkret gemacht (siehe BEISPIELFORMAT).
|
||||
|
||||
LESBARKEIT — wichtiger als Kürze:
|
||||
- Kurze Sätze: eine Aussage pro Satz. Richtwert höchstens ~20 Wörter, nie über 25. Keine Schachtelsätze mit mehreren Einschüben (Gedankenstrich-Einschübe vermeiden).
|
||||
- Kurze Absätze: eine Idee pro Absatz. Lieber zwei kurze Absätze als ein dichter Block. Keine Textwand.
|
||||
- Aufzählungen (Schritte, Optionen, Anforderungen, mehrere gleichrangige Punkte) als Markdown-Liste mit `-`, NIE in einen langen Aufzählungssatz pressen.
|
||||
- Wenige neue Fachbegriffe pro Section. Jeden beim ersten Auftreten in Alltagssprache auflösen. Lieber eine Stufe einfacher erklären als mehr Fakten stapeln.
|
||||
- Im Zweifel ein Satz mehr und klar — statt verdichtet. Verständlichkeit schlägt Knappheit.
|
||||
|
||||
LÄNGE — so lang wie nötig:
|
||||
- KEIN festes Wortlimit. Die Länge richtet sich nach der Schwierigkeit des Konzepts.
|
||||
- Verständnis-Test: Versteht ein NEULING das Konzept allein aus dieser Section, ohne anderswo nachzulesen? Wenn nein → einen Schritt mehr erklären (Warum + Wie), NICHT verdichten. Diese Section trägt die volle Tiefe selbst — es gibt keine zweite Ausbaustufe mehr, die nachliefert.
|
||||
- Weglassen: Füllsätze, Einleitungsfloskeln („In diesem Abschnitt…"), Wiederholungen, Fazit. Nicht jeden Randfall nennen — das Übliche erklären, seltene Varianten in die Beispiele.
|
||||
|
||||
BEISPIELFORMAT — am Thema ausrichten, nicht pauschal an Code:
|
||||
- Code-/Tool-Thema (Sprache, Framework, CLI, Konfiguration): Codeblock mit Sprachangabe, wenige Zeilen, Minimalbeispiel.
|
||||
- Sprach-Thema (Vokabeln, Grammatik, Formulierungen): 1–3 Beispielsätze oder ein Mini-Dialog, fremdsprachiger Teil *kursiv*, deutsche Übersetzung in Klammern wo nötig.
|
||||
- Konzept-Thema (Psychologie, Kommunikation, Methoden, Theorie, Mathe): ein Mini-Szenario in 2–4 Sätzen (Situation → Anwendung → Wirkung), ein Schema oder eine durchgerechnete Formel mit kleinen Zahlen.
|
||||
Mischthemen: pro Beispiel das Format wählen, das den Punkt am direktesten zeigt.
|
||||
Ein Beispiel ist immer KONKRET (echter Code, echte Sätze, echte Situation) — nie die Beschreibung, was ein Beispiel zeigen würde.
|
||||
Mehrere Beispiele benennen ihre Variante: in Code als Kommentar in der Code-Syntax (z. B. `<!-- Einzelner Absatz -->`, `// Mit Default-Wert`), in Prosa als vorangestelltes fettes Label (z. B. **Höfliche Bitte:**). Bei nur einem Beispiel ist kein Label nötig.
|
||||
|
||||
Jede Section ist ATOMAR: allein verständlich, ohne dass der Leser eine andere Section gelesen hat. Test: Ergibt der Text Sinn, wenn man NUR diese Section liest? Verweise auf andere Bausteine sind erlaubt, ihr Inhalt darf aber nie vorausgesetzt werden — benutzte Begriffe in einem Halbsatz auflösen.
|
||||
|
||||
Umfang: kurz. Die Länge einer Section kommt aus der ZAHL der Beispiele (Varianten), nie aus langen Texten.
|
||||
Tonalität: klares, direktes Deutsch. Du erklärst, du referierst nicht. Praxisorientiert, ohne Füllsätze.
|
||||
|
||||
Tonalität: klares Deutsch, direkt, praxisorientiert. Fachbegriffe beim ersten Auftreten kurz erklären. Keine Füllsätze, keine Einleitungsfloskeln.
|
||||
Markdown im Section-Body: erklärende Absätze in normalem Text, Aufzählungen als Markdown-Liste (`-`), `inline-code` für Bezeichner, Codeblöcke mit Sprachangabe NUR für Code-Beispiele — Beispielsätze, Dialoge und Szenarien als normaler Text, NIE in einen Codeblock zwingen. **fett** sparsam für Kernaussagen und Beispiel-Labels. Keine eigenen Überschriften außer `### Beispiel` bzw. `### Beispiele` vor den Beispielen.
|
||||
|
||||
Markdown im Section-Body: normale Absätze, `inline-code` für Bezeichner, Codeblöcke mit Sprachangabe, **fett** sparsam für Kernaussagen. Keine eigenen Überschriften außer `### Beispiel` bzw. `### Beispiele` vor den Beispielen.
|
||||
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.
|
||||
|
||||
Beispiel einer fertigen Section (nur der Body):
|
||||
Beispiel einer fertigen Section (Code-Thema, nur der Body):
|
||||
|
||||
Arrays speichern mehrere Werte unter einem Namen. PHP unterscheidet indizierte Arrays (`[0 => 'a']`) und assoziative Arrays (`['key' => 'wert']`) — intern sind beide geordnete Hashmaps.
|
||||
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.
|
||||
|
||||
### Beispiel
|
||||
```php
|
||||
$preise = ['apfel' => 1.20, 'birne' => 1.50];
|
||||
$preise['kirsche'] = 3.90; // ergänzen
|
||||
echo $preise['apfel']; // 1.2
|
||||
$preise['kirsche'] = 3.90; // neuen Schlüssel ergänzen
|
||||
echo $preise['apfel']; // 1.2 — Zugriff über den Namen
|
||||
```
|
||||
Assoziative Arrays sind der Arbeitsalltag: Datenbankzeilen, Konfiguration, JSON.
|
||||
So sieht der Alltag aus: Datenbankzeilen, Konfiguration, JSON landen fast immer in assoziativen Arrays.
|
||||
|
||||
Beispiel einer fertigen Section (Konzept-Thema, nur der Body):
|
||||
|
||||
Im Streit reden zwei oft aneinander vorbei, weil keiner sicher ist, ob er den anderen richtig verstanden hat. Paraphrasieren setzt genau hier an: Du wiederholst die Aussage des Gegenübers in eigenen Worten und fragst nach, ob das so stimmt. Das prüft dein Verständnis und nimmt Tempo aus dem Konflikt — der andere fühlt sich gehört, statt sich verteidigen zu müssen. Wichtig: Du bestätigst nicht den Vorwurf, du spiegelst nur die Botschaft dahinter.
|
||||
|
||||
### Beispiel
|
||||
A: „Nie hältst du dich an Absprachen!"
|
||||
B: „Du bist sauer, weil ich den Termin gestern verschoben habe — richtig?"
|
||||
B übernimmt nicht das Wort „nie", sondern benennt das konkrete Anliegen. Das öffnet das Gespräch, statt es zu eskalieren.
|
||||
|
||||
Beispiel einer fertigen Section mit Aufzählung (Liste statt Aufzählungssatz):
|
||||
|
||||
Bevor du Shopware installierst, muss dein Server die Software tragen können. Sonst bricht die Installation ab. Shopware 6 braucht ein paar feste Bausteine:
|
||||
|
||||
- **PHP 8.2, 8.3 oder 8.4** — die Sprache, in der Shopware läuft.
|
||||
- **MySQL ab 8.0.17** oder **MariaDB ab 10.11** — die Datenbank für deine Artikel und Bestellungen.
|
||||
- **Composer ab 2.2** — lädt die PHP-Bibliotheken, die Shopware mitbringt.
|
||||
- **Node.js 20+** — baut die JavaScript- und CSS-Dateien zusammen.
|
||||
|
||||
### Beispiel
|
||||
```bash
|
||||
php -v # PHP-Version prüfen
|
||||
composer -V # Composer-Version
|
||||
node -v # Node-Version
|
||||
```
|
||||
Stimmt eine Version nicht, aktualisierst du sie zuerst. Eine zu alte Version ist die häufigste Ursache für eine fehlgeschlagene Installation.
|
||||
|
||||
34
templates/Prompt/Baustein-Bewertung-Kritik.md
Normal file
34
templates/Prompt/Baustein-Bewertung-Kritik.md
Normal file
@@ -0,0 +1,34 @@
|
||||
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"]}}
|
||||
49
templates/Prompt/Baustein-Bewertung.md
Normal file
49
templates/Prompt/Baustein-Bewertung.md
Normal file
@@ -0,0 +1,49 @@
|
||||
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"}}
|
||||
19
templates/Prompt/Baustein-Chat.md
Normal file
19
templates/Prompt/Baustein-Chat.md
Normal file
@@ -0,0 +1,19 @@
|
||||
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:".
|
||||
32
templates/Prompt/Baustein-Frage-Kritik.md
Normal file
32
templates/Prompt/Baustein-Frage-Kritik.md
Normal file
@@ -0,0 +1,32 @@
|
||||
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"]}}
|
||||
21
templates/Prompt/Baustein-Frage-Variante.md
Normal file
21
templates/Prompt/Baustein-Frage-Variante.md
Normal file
@@ -0,0 +1,21 @@
|
||||
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"}}
|
||||
41
templates/Prompt/Baustein-Frage.md
Normal file
41
templates/Prompt/Baustein-Frage.md
Normal file
@@ -0,0 +1,41 @@
|
||||
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"}}
|
||||
18
templates/Prompt/Baustein-Lueckentext-Pruefung.md
Normal file
18
templates/Prompt/Baustein-Lueckentext-Pruefung.md
Normal file
@@ -0,0 +1,18 @@
|
||||
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}}
|
||||
26
templates/Prompt/Baustein-Lueckentext.md
Normal file
26
templates/Prompt/Baustein-Lueckentext.md
Normal file
@@ -0,0 +1,26 @@
|
||||
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"]}}
|
||||
27
templates/Prompt/Baustein-Lueckwahl.md
Normal file
27
templates/Prompt/Baustein-Lueckwahl.md
Normal file
@@ -0,0 +1,27 @@
|
||||
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}}
|
||||
]}}
|
||||
45
templates/Prompt/Baustein-Pruefung-Diskussion.md
Normal file
45
templates/Prompt/Baustein-Pruefung-Diskussion.md
Normal file
@@ -0,0 +1,45 @@
|
||||
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.
|
||||
29
templates/Prompt/Baustein-Quiz.md
Normal file
29
templates/Prompt/Baustein-Quiz.md
Normal file
@@ -0,0 +1,29 @@
|
||||
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,16 +0,0 @@
|
||||
Eine konsolidierte Baustein-Liste zum Thema "{topic}" wurde aus drei Recherchen erstellt. Prüfe sie auf Verluste und Duplikate.
|
||||
|
||||
TITEL DER RECHERCHEN:
|
||||
{results}
|
||||
|
||||
KONSOLIDIERTE LISTE:
|
||||
{auswahl}
|
||||
|
||||
Prüfe genau zwei Dinge:
|
||||
1. FEHLT ein Konzept, das in den Recherchen vorkommt, aber in der konsolidierten Liste nicht enthalten ist — auch nicht unter anderem Titel oder in einem Sammeleintrag?
|
||||
2. Beschreiben mehrere Einträge der Liste DASSELBE Konzept? Der beste bleibt, die übrigen werden gestrichen.
|
||||
|
||||
Schreibe NUR die JSON-Datei nach: {out_path}
|
||||
|
||||
Format (Titel EXAKT wie in der konsolidierten Liste; nichts zu tun = leere Listen):
|
||||
{{"nachtraege": ["Titel — Kurzbeschreibung"], "streichen": ["Exakter Titel aus der Liste"]}}
|
||||
19
templates/Prompt/Bausteine-Auswahl-Mapping.md
Normal file
19
templates/Prompt/Bausteine-Auswahl-Mapping.md
Normal file
@@ -0,0 +1,19 @@
|
||||
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,15 +1,19 @@
|
||||
Drei Recherche-Agenten haben unabhängig voneinander die Bausteine des Themas "{topic}" ermittelt. Konsolidiere ihre Ergebnisse zur endgültigen Baustein-Liste.
|
||||
Bei der Auswahl der Bausteine zum Thema "{topic}" sind einige Einträge strittig. Entscheide für jeden strittigen Eintrag: aufnehmen oder verwerfen.
|
||||
|
||||
{results}
|
||||
BESCHLOSSENER KONSENS (nur Kontext — nicht ändern):
|
||||
{konsens}
|
||||
|
||||
STRITTIGE EINTRÄGE:
|
||||
{rest}
|
||||
|
||||
Regeln:
|
||||
- Vereinige die Listen: erkenne gleiche Konzepte unter verschiedenen Titeln und führe sie zu einem Baustein zusammen.
|
||||
- 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; falsch: je ein Eintrag pro input-Typ, 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.
|
||||
- Verwirf Bausteine ohne Quelle oder die erfunden wirken. Behalte im Zweifel, was mindestens eine Recherche belegt.
|
||||
- KEINE Kategorien, KEINE Bewertung — eine flache, durchnummerierte Liste.
|
||||
- Lass die Quellen weg. Titel und Kurzbeschreibung (max. ~12 Wörter) auf DEUTSCH (Code-Bezeichner bleiben original). Jeder Titel muss EINDEUTIG sein.
|
||||
- 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 Markdown-Datei nach: {out_path}
|
||||
Schreibe NUR die JSON-Datei nach: {out_path}
|
||||
|
||||
Format: GENAU eine Zeile pro Baustein: `N. Titel — Kurzbeschreibung`. Kein weiterer Text in der Datei.
|
||||
Format (kein weiterer Text in der Datei):
|
||||
{{"uebernehmen": ["Titel — Kurzbeschreibung"]}}
|
||||
Nichts aufnehmen: {{"uebernehmen": []}}
|
||||
|
||||
17
templates/Prompt/Bausteine-Klaerung.md
Normal file
17
templates/Prompt/Bausteine-Klaerung.md
Normal file
@@ -0,0 +1,17 @@
|
||||
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
templates/Prompt/Bausteine-Quelle-Link.md
Normal file
1
templates/Prompt/Bausteine-Quelle-Link.md
Normal file
@@ -0,0 +1 @@
|
||||
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 +1 @@
|
||||
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. Die Bausteine müssen das echte Projekt widerspiegeln, nichts Erfundenes.
|
||||
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 +1 @@
|
||||
Recherchiere per Websuche und gehe systematisch vor: arbeite die Struktur der offiziellen Dokumentation ab (Handbuch-Kapitel, Feature-Übersichten, Release Notes der letzten Versionen) und erfasse jeden Baustein, dem ein Anwender begegnen kann — von Grundlagen bis Spezialfälle. Achte darauf, dass Versionsangaben und Fakten aktuell sind.
|
||||
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
templates/Prompt/Bausteine-Quelle-Uni.md
Normal file
1
templates/Prompt/Bausteine-Quelle-Uni.md
Normal file
@@ -0,0 +1 @@
|
||||
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.
|
||||
16
templates/Prompt/Bausteine-Recherche-Mapping.md
Normal file
16
templates/Prompt/Bausteine-Recherche-Mapping.md
Normal file
@@ -0,0 +1,16 @@
|
||||
{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,17 +1,20 @@
|
||||
Ermittle ALLE Bausteine (Konzepte/Funktionen/Features) des Themas "{topic}" für einen Lern-Guide.
|
||||
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; falsch: 21 Einträge für jeden input-Typ, aber auch Sammeleinträge, die mehrere Probleme mischen).
|
||||
- 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).
|
||||
- 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}
|
||||
@@ -9,7 +9,7 @@ BISHERIGER CHAT-VERLAUF:
|
||||
Setze die letzte Nutzer-Anweisung in Änderungs-Vorschläge um. Halte die Element-Regeln ein:
|
||||
1. title — prägnanter Titel (max. 8 Wörter, reiner Text ohne Markdown/Backticks)
|
||||
2. description — was es ist und wozu: MAXIMAL 1–2 Sätze
|
||||
3. examples — KURZ und SIMPEL: wenige Zeilen Code, das Minimalbeispiel. Jedes beginnt mit einem kurzen Kommentar in der Code-Syntax (z. B. `<!-- Einzelner Absatz -->`), der die Variante benennt. Als Codeblock mit Sprachangabe (```sprache).
|
||||
3. examples — KURZ und SIMPEL: das Minimalbeispiel im themengerechten Format (Codeblock mit Sprachangabe bei Code-Themen, sonst Beispielsätze/Mini-Dialog/Mini-Szenario als normaler Text). Jedes mit Varianten-Label: Code-Kommentar (z. B. `<!-- Einzelner Absatz -->`) bzw. **fettes** Label (z. B. **Höfliche Bitte:**).
|
||||
4. hints — jeder Hinweis muss WICHTIG oder NÜTZLICH sein. Telegrammstil: nur die Kernaussage. Beispiel: "Keine Blockelemente in `<p>`."
|
||||
|
||||
Umfang: SO LANG WIE NÖTIG und SO KURZ WIE MÖGLICH. Markdown: `inline-code` für Bezeichner, Tags und Befehle — IMMER in Backticks. Tonalität: klares Deutsch, direkt, keine Füllsätze.
|
||||
@@ -17,8 +17,8 @@ Umfang: SO LANG WIE NÖTIG und SO KURZ WIE MÖGLICH. Markdown: `inline-code` fü
|
||||
Jeder Vorschlag:
|
||||
- text: kurz, was geändert wird (max. 12 Wörter, reiner Text)
|
||||
- action: "entfernen" | "anpassen" | "hinzufuegen"
|
||||
- target: "title" | "description" | "examples" | "hints" | "aufgabe" | "loesung"
|
||||
- index: 0-basierte Position im AKTUELLEN examples- bzw. hints-Array (bei title/description/aufgabe/loesung und hinzufuegen: null)
|
||||
- target: "title" | "description" | "examples" | "hints"
|
||||
- index: 0-basierte Position im AKTUELLEN examples- bzw. hints-Array (bei title/description und hinzufuegen: null)
|
||||
- content: der neue vollständige Inhalt (bei entfernen: leer)
|
||||
|
||||
"entfernen" nur für examples/hints. Nur Vorschläge machen, die die Nutzer-Anweisung verlangt.
|
||||
|
||||
@@ -6,12 +6,12 @@ AKTUELLES ELEMENT (JSON):
|
||||
KONTEXT (Auszüge aus dem Themen-Material):
|
||||
{context}
|
||||
|
||||
RECHERCHE — sammle breit alle Kandidaten: fehlende Kernaussagen, wichtige Varianten, typische Stolperfallen, Best Practices. Fehlt eine Übungsaufgabe (aufgabe) oder deren Lösung (loesung), schlage sie vor. Lieber einen Kandidaten zu viel als einen zu wenig — die Bewertung passiert in einem zweiten Schritt. Nichts vorschlagen, was das Element schon enthält.
|
||||
RECHERCHE — sammle breit alle Kandidaten: fehlende Kernaussagen, wichtige Varianten, typische Stolperfallen, Best Practices. Lieber einen Kandidaten zu viel als einen zu wenig — die Bewertung passiert in einem zweiten Schritt. Nichts vorschlagen, was das Element schon enthält.
|
||||
|
||||
Jeder Kandidat:
|
||||
- text: kurze Beschreibung der Lücke (max. 12 Wörter, reiner Text)
|
||||
- target: "description" | "examples" | "hints" | "aufgabe" | "loesung"
|
||||
- content: fertiger Inhalt zum Einfügen. SO KURZ WIE MÖGLICH, so lang wie nötig. Markdown: `inline-code` für Bezeichner, examples als Codeblock mit Sprachangabe (```sprache), beginnend mit kurzem Kommentar zur Variante (z. B. `<!-- Einzelner Absatz -->`), hints nur wenn WICHTIG oder NÜTZLICH, im Telegrammstil (nur Kernaussage, z. B. "Keine Blockelemente in `<p>`."). aufgabe: EINE konkrete, in Minuten lösbare Übungsaufgabe; loesung: die knappe Musterlösung dazu. Tags/Bezeichner im Fließtext IMMER in Backticks.
|
||||
- target: "description" | "examples" | "hints"
|
||||
- content: fertiger Inhalt zum Einfügen. SO KURZ WIE MÖGLICH, so lang wie nötig. Markdown: `inline-code` für Bezeichner, examples im themengerechten Format (Codeblock mit Sprachangabe bei Code, sonst Beispielsätze/Mini-Szenario als normaler Text), jeweils mit Varianten-Label (Code-Kommentar bzw. **fettes** Label), hints nur wenn WICHTIG oder NÜTZLICH, im Telegrammstil (nur Kernaussage, z. B. "Keine Blockelemente in `<p>`."). Tags/Bezeichner im Fließtext IMMER in Backticks.
|
||||
|
||||
Gib NUR gültiges JSON aus, ohne Code-Fence, ohne weiteren Text:
|
||||
{{"suggestions": [{{"text": "...", "target": "hints", "content": "..."}}]}}
|
||||
|
||||
@@ -9,10 +9,16 @@ KONTEXT (Auszüge aus dem Themen-Material):
|
||||
Erstelle GENAU EIN Element zum Stichwort:
|
||||
1. title — prägnanter Titel (max. 8 Wörter, reiner Text ohne Markdown/Backticks)
|
||||
2. description — was es ist und wozu: MAXIMAL 1–2 Sätze
|
||||
3. examples — GENAU EIN Beispiel: KURZ und SIMPEL, wenige Zeilen Code, das Minimalbeispiel, keine Realwelt-Komplexität. Beginnt mit einem kurzen Kommentar in der Code-Syntax (z. B. `<!-- Einzelner Absatz -->`, `// Mit Default-Wert`), der die Variante benennt.
|
||||
3. examples — GENAU EIN Beispiel: KURZ und SIMPEL, das Minimalbeispiel im themengerechten Format (siehe BEISPIELFORMAT), keine Realwelt-Komplexität.
|
||||
4. hints — IMMER leere Liste. Hinweise ergänzt der Nutzer später selbst. (Falls je gefordert: TELEGRAMMSTIL, max. 10 Wörter.)
|
||||
5. aufgabe — GENAU EINE kleine Übungsaufgabe zum Konzept: konkret, in wenigen Minuten lösbar, prüft das Verständnis. Markdown, Code als Codeblock mit Sprachangabe.
|
||||
6. loesung — die Musterlösung zur Aufgabe: knapp, nachvollziehbar, Schritt für Schritt nur wo nötig. Code als Codeblock mit Sprachangabe.
|
||||
|
||||
BEISPIELFORMAT — am Thema ausrichten, nicht pauschal an Code:
|
||||
- Code-/Tool-Thema (Sprache, Framework, CLI, Konfiguration): Codeblock mit Sprachangabe, wenige Zeilen, Minimalbeispiel.
|
||||
- Sprach-Thema (Vokabeln, Grammatik, Formulierungen): 1–3 Beispielsätze oder ein Mini-Dialog, fremdsprachiger Teil *kursiv*, deutsche Übersetzung in Klammern wo nötig.
|
||||
- Konzept-Thema (Psychologie, Kommunikation, Methoden, Theorie): ein Mini-Szenario in 2–4 Sätzen (Situation → Anwendung → Wirkung), ein Schema oder eine Formel.
|
||||
Mischthemen: pro Beispiel das Format wählen, das den Punkt am direktesten zeigt.
|
||||
Ein Beispiel ist immer KONKRET (echter Code, echte Sätze, echte Situation) — nie die Beschreibung, was ein Beispiel zeigen würde.
|
||||
Jedes Beispiel benennt seine Variante: in Code als Kommentar in der Code-Syntax (z. B. `<!-- Einzelner Absatz -->`, `// Mit Default-Wert`), in Prosa als vorangestelltes fettes Label (z. B. **Höfliche Bitte:**).
|
||||
|
||||
Das Element ist ATOMAR: allein verständlich, ohne dass der Leser etwas anderes gelesen hat. Benutzte Begriffe in einem Halbsatz auflösen.
|
||||
|
||||
@@ -20,7 +26,7 @@ Umfang: SO KURZ WIE MÖGLICH, so lang wie nötig — gilt für description, exam
|
||||
|
||||
Tonalität: klares Deutsch, direkt, praxisorientiert. Fachbegriffe beim ersten Auftreten kurz erklären. Keine Füllsätze, keine Einleitungsfloskeln.
|
||||
|
||||
Markdown in description und examples: normale Absätze, `inline-code` für Bezeichner, Codeblöcke mit Sprachangabe (```sprache), **fett** sparsam für Kernaussagen. Keine Überschriften. Code-Beispiele IMMER als Codeblock, nie als Inline-Code. Bezeichner, Tags und Befehle (z. B. `<p>`, `git add`) im Fließtext IMMER in Backticks — nie nackt.
|
||||
Markdown in description und examples: normale Absätze, `inline-code` für Bezeichner, **fett** sparsam für Kernaussagen. Keine Überschriften. Code-Beispiele IMMER als Codeblock mit Sprachangabe (```sprache), nie als Inline-Code; Prosa-Beispiele (Sätze, Dialoge, Szenarien) als normaler Text, NIE in einen Codeblock zwingen. Bezeichner, Tags und Befehle (z. B. `<p>`, `git add`) im Fließtext IMMER in Backticks — nie nackt.
|
||||
|
||||
Gib NUR gültiges JSON aus, ohne Code-Fence, ohne weiteren Text:
|
||||
{{"title": "...", "description": "...", "examples": ["```sprache\n...\n```"], "hints": [], "aufgabe": "...", "loesung": "..."}}
|
||||
{{"title": "...", "description": "...", "examples": ["```sprache\n...\n``` ODER **Variante:** Prosa-Beispiel"], "hints": []}}
|
||||
|
||||
@@ -11,7 +11,7 @@ ANWEISUNG DES NUTZERS:
|
||||
|
||||
Passe den Vorschlag gemäß der Anweisung an. Behalte action/target/index bei, außer die Anweisung verlangt anderes.
|
||||
|
||||
Stil-Regeln für content: SO LANG WIE NÖTIG und SO KURZ WIE MÖGLICH. `inline-code` für Bezeichner, Tags und Befehle — IMMER in Backticks. examples als Codeblock mit Sprachangabe und kurzem Varianten-Kommentar (z. B. `<!-- Einzelner Absatz -->`). hints im Telegrammstil: nur die Kernaussage.
|
||||
Stil-Regeln für content: SO LANG WIE NÖTIG und SO KURZ WIE MÖGLICH. `inline-code` für Bezeichner, Tags und Befehle — IMMER in Backticks. examples im themengerechten Format (Codeblock mit Sprachangabe NUR bei Code, sonst Beispielsätze/Mini-Szenario) mit Varianten-Label (Code-Kommentar bzw. **fettes** Label). hints im Telegrammstil: nur die Kernaussage.
|
||||
|
||||
Felder:
|
||||
- text: kurz, was geändert wird (max. 12 Wörter, reiner Text)
|
||||
|
||||
@@ -6,20 +6,27 @@ AKTUELLES ELEMENT (JSON):
|
||||
STIL-REGELN:
|
||||
1. title — prägnant, max. 8 Wörter, reiner Text ohne Markdown/Backticks
|
||||
2. description — was es ist und wozu: MAXIMAL 1–2 Sätze
|
||||
3. examples — KURZ und SIMPEL: wenige Zeilen Code, Minimalbeispiel, keine Realwelt-Komplexität. Ein Beispiel pro relevanter Variante, geordnet vom Üblichen zum Speziellen. Als Codeblock mit Sprachangabe (```sprache), nie als Inline-Code. Jedes Beispiel beginnt mit einem kurzen Kommentar in der Code-Syntax (z. B. `<!-- Einzelner Absatz -->`), der die Variante benennt.
|
||||
3. examples — KURZ und SIMPEL: das Minimalbeispiel im themengerechten Format (siehe BEISPIELFORMAT), keine Realwelt-Komplexität. Ein Beispiel pro relevanter Variante, geordnet vom Üblichen zum Speziellen. Ein Codeblock um ein Prosa-Beispiel ist ein Stil-Verstoß — ebenso ein Code-Beispiel ohne Codeblock.
|
||||
|
||||
BEISPIELFORMAT — am Thema ausrichten, nicht pauschal an Code:
|
||||
- Code-/Tool-Thema (Sprache, Framework, CLI, Konfiguration): Codeblock mit Sprachangabe, wenige Zeilen, Minimalbeispiel.
|
||||
- Sprach-Thema (Vokabeln, Grammatik, Formulierungen): 1–3 Beispielsätze oder ein Mini-Dialog, fremdsprachiger Teil *kursiv*, deutsche Übersetzung in Klammern wo nötig.
|
||||
- Konzept-Thema (Psychologie, Kommunikation, Methoden, Theorie): ein Mini-Szenario in 2–4 Sätzen (Situation → Anwendung → Wirkung), ein Schema oder eine Formel.
|
||||
Mischthemen: pro Beispiel das Format wählen, das den Punkt am direktesten zeigt.
|
||||
Ein Beispiel ist immer KONKRET (echter Code, echte Sätze, echte Situation) — nie die Beschreibung, was ein Beispiel zeigen würde.
|
||||
Jedes Beispiel benennt seine Variante: in Code als Kommentar in der Code-Syntax (z. B. `<!-- Einzelner Absatz -->`, `// Mit Default-Wert`), in Prosa als vorangestelltes fettes Label (z. B. **Höfliche Bitte:**).
|
||||
4. hints — jeder Hinweis muss WICHTIG oder NÜTZLICH sein: Stolperfalle, Merksatz oder Best Practice mit echtem Praxiswert. Selbstverständliches, Nischenwissen und Redundantes zum Element entfernen. Telegrammstil: nur die Kernaussage, Füllverben und Herleitungen streichen.
|
||||
aufgabe — EINE konkrete, in Minuten lösbare Übungsaufgabe, die das Verständnis prüft. loesung — knappe, nachvollziehbare Musterlösung dazu. Beide: Code als Codeblock mit Sprachangabe.
|
||||
Vorher: "Browser fügen standardmäßig vertikalen Abstand vor und nach `<p>` ein — anpassbar mit `margin`."
|
||||
Nachher: "Browser-Abstand um `<p>` per `margin` anpassbar."
|
||||
5. Umfang: SO LANG WIE NÖTIG und SO KURZ WIE MÖGLICH. Jedes Wort muss seinen Platz verdienen — Füllwörter, Nebensätze ohne Informationswert und Selbstverständliches streichen. Aber: Kürze nie auf Kosten der Verständlichkeit oder Korrektheit.
|
||||
6. Markdown: `inline-code` für Bezeichner, Tags und Befehle im Fließtext (z. B. `<p>`, `git add`) — IMMER in Backticks, nie nackt. **fett** sparsam. Keine Überschriften.
|
||||
6. Markdown: `inline-code` für Bezeichner, Tags und Befehle im Fließtext (z. B. `<p>`, `git add`) — IMMER in Backticks, nie nackt. Fremdsprachige Beispielsätze *kursiv*. **fett** sparsam. Keine Überschriften.
|
||||
7. Tonalität: klares Deutsch, direkt, praxisorientiert. Keine Füllsätze.
|
||||
|
||||
Schlage für jeden Stil-Verstoß GENAU EINE Änderung vor:
|
||||
- text: kurz, was und warum (max. 12 Wörter, reiner Text)
|
||||
- action: "entfernen" | "anpassen" | "hinzufuegen"
|
||||
- target: "title" | "description" | "examples" | "hints" | "aufgabe" | "loesung"
|
||||
- index: 0-basierte Position im AKTUELLEN examples- bzw. hints-Array (bei title/description/aufgabe/loesung: null; bei hinzufuegen: null)
|
||||
- target: "title" | "description" | "examples" | "hints"
|
||||
- index: 0-basierte Position im AKTUELLEN examples- bzw. hints-Array (bei title/description: null; bei hinzufuegen: null)
|
||||
- content: der neue/vollständige Inhalt (bei entfernen: leer)
|
||||
|
||||
"entfernen" nur für examples/hints. "hinzufuegen" sparsam — nur wenn eine Stil-Regel es verlangt (z. B. fehlender Varianten-Kommentar gehört zu "anpassen", nicht "hinzufuegen"). Erfüllt etwas die Regeln schon: NICHT anfassen.
|
||||
|
||||
@@ -13,9 +13,9 @@ Prüfe JEDEN Kandidaten kritisch:
|
||||
1. WICHTIG? Muss ein Lerner das wissen? Nice-to-haves und Nischenwissen ablehnen.
|
||||
2. REDUNDANT? Steckt die Info schon im Element oder in einem anderen Kandidaten? Ablehnen bzw. Duplikate zusammenführen.
|
||||
3. KORREKT? Fachlich falsch oder irreführend → ablehnen.
|
||||
4. PASST das target ("description" | "examples" | "hints" | "aufgabe" | "loesung")? Sonst korrigieren. Höchstens EIN Vorschlag je für "aufgabe" und "loesung".
|
||||
4. PASST das target ("description" | "examples" | "hints")? Sonst korrigieren.
|
||||
|
||||
Behalte nur Kandidaten, die alle Prüfungen bestehen. Verbessere dabei content auf die Stil-Regeln: SO LANG WIE NÖTIG und SO KURZ WIE MÖGLICH; `inline-code` für Bezeichner; examples als Codeblock mit Sprachangabe und kurzem Varianten-Kommentar; hints im Telegrammstil (nur Kernaussage, Kürze nie auf Kosten der Verständlichkeit).
|
||||
Behalte nur Kandidaten, die alle Prüfungen bestehen. Verbessere dabei content auf die Stil-Regeln: SO LANG WIE NÖTIG und SO KURZ WIE MÖGLICH; `inline-code` für Bezeichner; examples im themengerechten Format (Codeblock mit Sprachangabe bei Code, sonst Beispielsätze/Mini-Szenario) mit Varianten-Label; hints im Telegrammstil (nur Kernaussage, Kürze nie auf Kosten der Verständlichkeit).
|
||||
|
||||
Gib NUR gültiges JSON aus, ohne Code-Fence, ohne weiteren Text:
|
||||
{{"suggestions": [{{"text": "...", "target": "hints", "content": "..."}}]}}
|
||||
|
||||
21
templates/Prompt/Frage-Muster-Kritik.md
Normal file
21
templates/Prompt/Frage-Muster-Kritik.md
Normal file
@@ -0,0 +1,21 @@
|
||||
Du bist Qualitäts-Prüfer für den Fragen-Vorrat einer Lern-Prüfung zum Thema "{topic}". Ein anderer Agent hat Frage-Muster für **mehrere Bausteine** erstellt. Prüfe sie und gib eine **bereinigte** Endfassung zurück.
|
||||
|
||||
ZU PRÜFENDE MUSTER (gruppiert je Baustein; Zeilen: [typ] (subbaustein) Frage):
|
||||
{tabelle}
|
||||
|
||||
PRÜFE UND BEREINIGE GEGEN DIESE KRITERIEN (je Baustein getrennt):
|
||||
- **Stil:** jede Frage GENAU EINE Frage, ein Fragezeichen, eine Sache. Kein "und"/"sowie", keine Aufzählung. Maximal 1–2 Sätze. Verstöße → umformulieren.
|
||||
- **Eindeutig beantwortbar:** aus dem Baustein-Wissen klar beantwortbar, keine erfundenen Annahmen. Unbeantwortbares → korrigieren oder entfernen.
|
||||
- **Distinkt:** keine zwei Muster desselben Bausteins, die sinngemäß dieselbe Frage stellen (auch nicht über Typen hinweg). Dubletten → eines behalten, das andere entfernen.
|
||||
- **Typ-Treue:** die Frage passt zum angegebenen Typ; sonst Typ anpassen oder Muster entfernen.
|
||||
- `baustein`, `subbaustein` und `typ` der Einträge unverändert übernehmen (nur `frage` darf sich ändern); nur entfernen, nicht neu erfinden.
|
||||
|
||||
Behalte gute Muster unverändert. Ändere nur, was wirklich gegen die Kriterien verstößt.
|
||||
|
||||
Schreibe die bereinigte Endfassung ALLER Bausteine als EIN JSON in die Datei {out_path} (nutze dein Schreib-Werkzeug), GENAU in diesem Format:
|
||||
{{"muster": [
|
||||
{{"baustein": "<exakter Baustein-Titel>", "subbaustein": "<exakter Titel>", "typ": "<Schlüssel>", "frage": "<eine konkrete Frage>"}}
|
||||
]}}
|
||||
|
||||
Gib sonst keinen Text aus.
|
||||
{extra}
|
||||
28
templates/Prompt/Frage-Muster-Recherche.md
Normal file
28
templates/Prompt/Frage-Muster-Recherche.md
Normal file
@@ -0,0 +1,28 @@
|
||||
Du baust den Fragen-Vorrat für eine Lern-Prüfung zum Thema "{topic}". Erzeuge **Frage-Muster** — je eine konkrete Beispielfrage pro Subbaustein und Fragetyp. Aus diesen Mustern werden später bei der Prüfung leicht abgewandelte Fragen formuliert.
|
||||
|
||||
Du bearbeitest **mehrere Bausteine**. Jeder Baustein hat seine eigenen relevanten Subbausteine:
|
||||
{bausteine}
|
||||
|
||||
FRAGETYPEN (Schlüssel : Bedeutung):
|
||||
{typen}
|
||||
|
||||
AUFGABE (für **jeden** der Bausteine oben):
|
||||
- Erzeuge für **jeden** seiner Subbausteine zu **jedem** passenden Fragetyp **ein** Muster (volles Kreuzprodukt).
|
||||
- Lasse eine Kombination **weg**, wenn der Typ klar nicht passt (z.B. Code-Vorhersage bei reiner Theorie ohne Code). Nichts erzwingen.
|
||||
- Jedes Muster ist EINE konkrete, knappe Frage (1–2 Sätze, genau ein Fragezeichen, eine Sache) — kein Mehrteiler, keine Aufzählung.
|
||||
- Die Frage muss aus dem Baustein-Wissen beantwortbar sein. Erfinde keine Zusatzannahmen.
|
||||
- Innerhalb eines Subbausteins sollen die Muster verschiedener Typen **wirklich verschiedene** Aspekte treffen — keine Umformulierungen derselben Frage.
|
||||
- Der `typ` ist exakt einer der Schlüssel oben. `baustein` ist exakt einer der Baustein-Titel oben. `subbaustein` ist exakt einer der Subbaustein-Titel des jeweiligen Bausteins.
|
||||
|
||||
HARTE STIL-REGELN JE FRAGE:
|
||||
- Genau EINE Frage, ein Fragezeichen, eine Sache. Kein "und"/"sowie", kein "nenne drei …".
|
||||
- Maximal 1–2 Sätze, keine Vorrede, kein Szenario-Aufbau.
|
||||
- Direkte Ansprache, klares Deutsch.
|
||||
{extra}
|
||||
|
||||
Schreibe ALLE Muster aller Bausteine als EIN JSON in die Datei {out_path} (nutze dein Schreib-Werkzeug), GENAU in diesem Format:
|
||||
{{"muster": [
|
||||
{{"baustein": "<exakter Baustein-Titel>", "subbaustein": "<exakter Subbaustein-Titel>", "typ": "<Schlüssel>", "frage": "<eine konkrete Frage>"}}
|
||||
]}}
|
||||
|
||||
Gib sonst keinen Text aus.
|
||||
@@ -1,24 +0,0 @@
|
||||
Prüfe die Baustein-Auswahl für einen Lern-Guide zum Thema "{topic}" (Format: {format_name}).
|
||||
|
||||
Der Auftrag an die Auswahl war: {auswahl_auftrag}
|
||||
|
||||
INVENTAR (alle verfügbaren Bausteine):
|
||||
{bausteine}
|
||||
|
||||
GETROFFENE AUSWAHL:
|
||||
{auswahl}
|
||||
|
||||
Prüfe:
|
||||
1. Fehlt etwas, das der Leser für diesen Zweck zwingend braucht?
|
||||
2. Ist etwas drin, das dem Zweck nicht dient — Interna, Nischenfälle, Doppelungen (mehrere Lösungen fürs selbe Problem)?
|
||||
3. Passt der Umfang zum Auftrag?
|
||||
|
||||
Du PRÜFST nur und notierst Probleme — du änderst die Auswahl nicht.
|
||||
|
||||
Schreibe NUR die JSON-Datei nach: {out_path}
|
||||
|
||||
Format — Auswahl in Ordnung:
|
||||
{{"ok": true}}
|
||||
Sonst (kurz und konkret, maximal 10 Punkte, Baustein-Titel exakt nennen):
|
||||
{{"probleme": ["…", "…"]}}
|
||||
{extra}
|
||||
@@ -1,21 +0,0 @@
|
||||
Korrigiere die Baustein-Auswahl für einen Lern-Guide zum Thema "{topic}" (Format: {format_name}).
|
||||
|
||||
Der Auftrag an die Auswahl war: {auswahl_auftrag}
|
||||
|
||||
INVENTAR (alle verfügbaren Bausteine):
|
||||
{bausteine}
|
||||
|
||||
BISHERIGE AUSWAHL:
|
||||
{auswahl}
|
||||
|
||||
NOTIERTE PROBLEME (von der Prüfung):
|
||||
{probleme}
|
||||
|
||||
Behebe NUR die notierten Probleme — alles andere bleibt unverändert.
|
||||
Verwende die Titel EXAKT so, wie sie im Inventar stehen. Keine neuen erfinden.
|
||||
|
||||
Schreibe NUR die vollständige, korrigierte JSON-Datei nach: {out_path}
|
||||
|
||||
Format:
|
||||
{{"bausteine": ["Exakter Titel", "Exakter Titel"]}}
|
||||
{extra}
|
||||
@@ -1,18 +0,0 @@
|
||||
Wähle die Bausteine für einen Lern-Guide zum Thema "{topic}" (Format: {format_name}).
|
||||
|
||||
BAUSTEINE (unsortiertes Inventar):
|
||||
{bausteine}
|
||||
|
||||
{auswahl_auftrag}
|
||||
|
||||
Denke vom Ziel her: Was soll der Leser am Ende KÖNNEN?
|
||||
- Wähle, was der Leser dafür praktisch braucht und wirklich benutzt.
|
||||
- Lass weg: Interna (was das Werkzeug intern tut, ohne dass man es anfasst), Spezialfälle und Alternativen zum selben Problem — ein Weg reicht.
|
||||
- "Klingt fundamental" ist kein Kriterium. Frage stattdessen: Fasst der Leser das selbst an?
|
||||
- Verwende die Titel EXAKT so, wie sie in der Liste stehen. Keine neuen erfinden.
|
||||
|
||||
Schreibe NUR die JSON-Datei nach: {out_path}
|
||||
|
||||
Format:
|
||||
{{"bausteine": ["Exakter Titel", "Exakter Titel"]}}
|
||||
{extra}
|
||||
@@ -1 +1 @@
|
||||
Prüfe unsichere oder veraltbare Fakten (z. B. Versionsnummern) per Websuche.
|
||||
Prüfe unsichere oder veraltbare Fakten (Versionsnummern, aktuelle Empfehlungen, Forschungsstand) per Websuche, BEVOR du sie in eine Section schreibst. Nichts Unbelegtes behaupten.
|
||||
@@ -1,24 +0,0 @@
|
||||
Prüfe die Gliederung eines Lern-Guides zum Thema "{topic}" (Format: {format_name}).
|
||||
Zielgruppe: Anfänger. Zweck: {zweck}.
|
||||
|
||||
GEWÄHLTE BAUSTEINE (müssen alle vorkommen):
|
||||
{auswahl}
|
||||
|
||||
GLIEDERUNG:
|
||||
{gliederung}
|
||||
|
||||
Prüfe:
|
||||
1. Kommt jeder gewählte Baustein in GENAU einem Kapitel vor (nichts fehlt, nichts doppelt, nichts erfunden)?
|
||||
2. Führt Kapitel 1 zum schnellsten sichtbaren Ergebnis — oder beginnt es mit Theorie/Interna?
|
||||
3. Stehen Voraussetzungen vor dem, was auf ihnen aufbaut? Konkretes vor Abstraktem?
|
||||
4. Kapitelgrößen 3–7, Kapiteltitel kurz und konkret?
|
||||
|
||||
Du PRÜFST nur und notierst Probleme — du änderst die Gliederung nicht.
|
||||
|
||||
Schreibe NUR die JSON-Datei nach: {out_path}
|
||||
|
||||
Format — Gliederung in Ordnung:
|
||||
{{"ok": true}}
|
||||
Sonst (kurz und konkret, maximal 10 Punkte):
|
||||
{{"probleme": ["…", "…"]}}
|
||||
{extra}
|
||||
@@ -1,20 +0,0 @@
|
||||
Korrigiere die Gliederung eines Lern-Guides zum Thema "{topic}" (Format: {format_name}).
|
||||
|
||||
GEWÄHLTE BAUSTEINE (müssen alle vorkommen):
|
||||
{auswahl}
|
||||
|
||||
BISHERIGE GLIEDERUNG:
|
||||
{gliederung}
|
||||
|
||||
NOTIERTE PROBLEME (von der Prüfung):
|
||||
{probleme}
|
||||
|
||||
Behebe NUR die notierten Probleme — alles andere bleibt unverändert.
|
||||
- JEDER gewählte Baustein landet in GENAU einem Kapitel.
|
||||
- Verwende die Titel EXAKT so, wie sie in der Liste stehen.
|
||||
|
||||
Schreibe NUR die vollständige, korrigierte JSON-Datei nach: {out_path}
|
||||
|
||||
Format:
|
||||
{{"kapitel": [{{"titel": "Grundlagen", "bausteine": ["Exakter Titel", "Exakter Titel"]}}]}}
|
||||
{extra}
|
||||
24
templates/Prompt/Guide-Gliederung-Judge.md
Normal file
24
templates/Prompt/Guide-Gliederung-Judge.md
Normal file
@@ -0,0 +1,24 @@
|
||||
{n} Agenten haben die feste Baustein-Auswahl für {format_name} zum Thema "{topic}" unabhängig gegliedert ({zweck}). Führe die Vorschläge zu EINER kohärenten Gliederung zusammen.
|
||||
|
||||
GEWÄHLTE BAUSTEINE (jeder mit seiner NUMMER — die Auswahl steht fest):
|
||||
{bausteine}
|
||||
|
||||
DIE VORSCHLÄGE (je Kapitel die Baustein-Nummern):
|
||||
{gliederungen}
|
||||
|
||||
Vorgehen — MERGEN, nicht nur einen wählen:
|
||||
- Wo die Vorschläge übereinstimmen (Bausteine im selben Kapitel, ähnliche Reihenfolge) → übernehmen.
|
||||
- Wo sie sich unterscheiden → selbst entscheiden: bester roter Faden, vom Bekannten zum Unbekannten, erster Erfolg vor Theorie.
|
||||
- Nimm den besten Kapitelschnitt und die klarsten Kapiteltitel aus allen Vorschlägen.
|
||||
- Ergebnis ist EINE Gliederung, die besser ist als jeder einzelne Vorschlag.
|
||||
|
||||
Regeln:
|
||||
- JEDER Baustein in GENAU einem Kapitel. Keinen weglassen, keinen doppelt.
|
||||
- Ordne per NUMMER. Verwende nur die Nummern aus der Liste oben, jede genau einmal.
|
||||
- 3–7 Bausteine pro Kapitel; Kapiteltitel kurz und konkret.
|
||||
|
||||
Schreibe NUR die JSON-Datei nach: {out_path}
|
||||
|
||||
Format (nummern = die Baustein-Nummern in der gewünschten Reihenfolge):
|
||||
{{"kapitel": [{{"titel": "Grundlagen", "nummern": [3, 1, 7]}}]}}
|
||||
{extra}
|
||||
@@ -1,6 +1,6 @@
|
||||
Plane die Gliederung eines Lern-Guides zum Thema "{topic}" (Format: {format_name}).
|
||||
|
||||
GEWÄHLTE BAUSTEINE (unsortiert — die Auswahl steht fest, du ordnest nur):
|
||||
GEWÄHLTE BAUSTEINE (jeder mit seiner NUMMER — die Auswahl steht fest, du ordnest nur):
|
||||
{bausteine}
|
||||
|
||||
REIHENFOLGE — vom Bekannten zum Unbekannten:
|
||||
@@ -10,12 +10,12 @@ REIHENFOLGE — vom Bekannten zum Unbekannten:
|
||||
|
||||
Regeln:
|
||||
- JEDER gewählte Baustein landet in GENAU einem Kapitel. Keinen weglassen, keine neuen erfinden.
|
||||
- Verwende die Titel EXAKT so, wie sie in der Liste stehen.
|
||||
- Ordne per NUMMER. Verwende nur die Nummern aus der Liste oben, jede genau einmal.
|
||||
- 3–7 Bausteine pro Kapitel; die Kapitelzahl folgt aus dem Thema.
|
||||
- Kapiteltitel kurz und konkret.
|
||||
|
||||
Schreibe NUR die JSON-Datei nach: {out_path}
|
||||
|
||||
Format:
|
||||
{{"kapitel": [{{"titel": "Grundlagen", "bausteine": ["Exakter Titel", "Exakter Titel"]}}]}}
|
||||
Format (nummern = die Baustein-Nummern in der gewünschten Reihenfolge):
|
||||
{{"kapitel": [{{"titel": "Grundlagen", "nummern": [3, 1, 7]}}]}}
|
||||
{extra}
|
||||
|
||||
19
templates/Prompt/Guide-Inhalt-Check.md
Normal file
19
templates/Prompt/Guide-Inhalt-Check.md
Normal file
@@ -0,0 +1,19 @@
|
||||
Prüfe die gesammelten Inhalte für Bausteine eines Lern-Guides zum Thema "{topic}" (Format: {format_name}). Zielgruppe: Anfänger. Es ist noch kein Guide-Text — nur die Inhalts-Stichpunkte, die später gelehrt werden.
|
||||
|
||||
INHALTE:
|
||||
{sections}
|
||||
|
||||
Prüfe jeden Baustein:
|
||||
1. Korrektheit: Sind die Punkte und Fakten sachlich richtig und belegbar? Nichts Halluziniertes, keine erfundenen Werte/Versionen.
|
||||
2. Vollständigkeit: Fehlt etwas Wesentliches, das ein Anfänger zum Verständnis des Bausteins braucht?
|
||||
3. Scope: Nicht mehr, als der Baustein hergibt — nichts am Rand Erwähntes aufgeblasen; aber auch keine zentrale Lücke.
|
||||
|
||||
Du PRÜFST nur und notierst Probleme — du änderst nichts. Nur echte Mängel notieren, keine Geschmacksfragen.
|
||||
|
||||
Schreibe NUR die JSON-Datei nach: {out_path}
|
||||
|
||||
Format — alles in Ordnung:
|
||||
{{"ok": true}}
|
||||
Sonst (Baustein-Titel EXAKT wie oben):
|
||||
{{"probleme": [{{"section": "Exakter Baustein-Titel", "problem": "…"}}]}}
|
||||
{extra}
|
||||
16
templates/Prompt/Guide-Inhalt-Fix.md
Normal file
16
templates/Prompt/Guide-Inhalt-Fix.md
Normal file
@@ -0,0 +1,16 @@
|
||||
Überarbeite einzelne Baustein-Inhalte eines Lern-Guides zum Thema "{topic}". Pro Baustein ist ein PROBLEM notiert (Korrektheit, Vollständigkeit oder Scope). Behebe NUR das notierte Problem; was in Ordnung ist, bleibt erhalten.
|
||||
|
||||
{facts}
|
||||
|
||||
AUFTRÄGE — je Baustein das Problem und der aktuelle Inhalt:
|
||||
{auftraege}
|
||||
|
||||
Schreibe NUR die Datei {out_path} — pro überarbeitetem Baustein ein section-Marker (Titel EXAKT wie im Auftrag), darunter die korrigierten Punkte:
|
||||
|
||||
<!-- section: Exakter Baustein-Titel -->
|
||||
- Kernpunkt …
|
||||
- Kernpunkt …
|
||||
Beispiel: kurze Idee, was das Beispiel zeigt
|
||||
|
||||
Die Marker-Zeile exakt so schreiben. Kein Text außerhalb der Sections.
|
||||
{extra}
|
||||
26
templates/Prompt/Guide-Inhalt.md
Normal file
26
templates/Prompt/Guide-Inhalt.md
Normal file
@@ -0,0 +1,26 @@
|
||||
Identifiziere für jeden zugeteilten Baustein eines Lern-Guides zum Thema "{topic}", WAS ein Anfänger ohne Vorwissen verstehen muss. Du schreibst noch keinen Guide-Text — du sammelst nur die Inhalte, die später gelehrt werden.
|
||||
|
||||
Dir zugeteilt sind folgende Kapitel und Bausteine — verbindlich: jeder zugeteilte Baustein muss vorkommen, keine zusätzlichen erfinden. Unter jedem Baustein stehen seine SUBBAUSTEINE mit Stufe (`[einfach]`/`[mittel]`/`[schwer]`) — die Teilpunkte, die gelehrt werden:
|
||||
{zuteilung}
|
||||
|
||||
{facts}
|
||||
|
||||
Sammle pro Baustein:
|
||||
- Kernpunkte / Lernziele: was muss der Leser begreifen? Decke JEDEN gelisteten Subbaustein mit mindestens einem Kernpunkt ab. Hat ein Baustein keine Subbausteine, sammle 3–7 knappe Punkte.
|
||||
- Voraussetzungen: welche Begriffe/Ideen muss man vorher kennen, um das hier zu verstehen? Jede in einem Halbsatz erklärbar — das sind die Ankerpunkte für Neulinge.
|
||||
- Typische Hürden: wo verstehen Anfänger es erfahrungsgemäß falsch oder stolpern?
|
||||
- Belegte Fakten (Versionen, Namen, Werte) — nichts Unbelegtes; Unsicheres per Websuche prüfen.
|
||||
- Eine konkrete Beispiel-Idee: was soll das Beispiel zeigen?
|
||||
- Scope: nur was DIESER Baustein hergibt. Nicht mehr, nicht weniger. Am Rand Erwähntes nicht zum Thema aufblasen.
|
||||
|
||||
Schreibe NUR die Datei {out_path} — pro Baustein ein section-Marker (Titel EXAKT aus der Zuteilung), darunter die Punkte:
|
||||
|
||||
<!-- section: Exakter Baustein-Titel -->
|
||||
- Kernpunkt …
|
||||
- Kernpunkt …
|
||||
Voraussetzung: was vorher kurz erklärt werden muss (Anker)
|
||||
Hürde: typischer Anfänger-Irrtum
|
||||
Beispiel: kurze Idee, was das Beispiel zeigt
|
||||
|
||||
Die Marker-Zeile exakt so schreiben. Kein Text außerhalb der Sections, kein Fließtext-Guide.
|
||||
{extra}
|
||||
@@ -8,9 +8,14 @@ SECTIONS:
|
||||
{sections}
|
||||
|
||||
Prüfe jede Section:
|
||||
1. Ist die Beschreibung für Anfänger verständlich und maximal 1–2 Sätze?
|
||||
2. Sind die Beispiele kurz, simpel und plausibel korrekt?
|
||||
3. Ist das Markdown sauber (keine abgebrochenen Code-Blöcke, keine Platzhalter, kein Fremdtext)?
|
||||
1. Lehrt die Section das Konzept für einen Anfänger ohne Vorwissen verständlich — ordnet sie es ein, erklärt sie das Wie/Warum, macht ein Beispiel es konkret? Nicht so verdichtet, dass nur jemand sie versteht, der das Thema schon kennt.
|
||||
2. Lesbarkeit (echte Mängel notieren):
|
||||
- Sätze über ~25 Wörter oder Schachtelsätze mit mehreren Einschüben.
|
||||
- Eine Aufzählung (Schritte/Optionen/Anforderungen) als langer Fließtext-Satz, die eine Markdown-Liste sein sollte.
|
||||
- Textwand: ein dichter Block ohne Absätze, der sich in mehrere teilen ließe.
|
||||
- Mehr als ~4 neue Fachbegriffe ohne Erklärung beim ersten Auftreten.
|
||||
3. Sind die Beispiele kurz, simpel, plausibel korrekt — und im themengerechten Format laut Spezifikation (kein Codeblock um Prosa-Beispiele, kein Prosa-Pseudo-Beispiel, wo Code gefragt ist)?
|
||||
4. Ist das Markdown sauber (keine abgebrochenen Code-Blöcke, keine Platzhalter, kein Fremdtext)?
|
||||
|
||||
Du PRÜFST nur und notierst Probleme — du änderst nichts. Nur echte Mängel notieren, keine Geschmacksfragen.
|
||||
|
||||
|
||||
@@ -10,15 +10,18 @@ ZU ÜBERARBEITEN — je Section der aktuelle Inhalt und das notierte Problem:
|
||||
|
||||
Behebe pro Section NUR das notierte Problem; was in Ordnung ist, bleibt inhaltlich erhalten.
|
||||
|
||||
Schreibe NUR die Datei {out_path} in GENAU diesem Format — für JEDE beanstandete Section ein section-Marker (Titel EXAKT wie oben), darunter der vollständige neue Markdown-Body:
|
||||
Jede Section hat ZWEI Fassungen mit denselben Inhalten: **kompakt** (je Subbaustein ein Merksatz als Stichpunkt) und **ausführlich** (zusammenhängender Anfänger-Text). Gib beide neu aus und halte sie deckungsgleich.
|
||||
|
||||
Schreibe NUR die Datei {out_path} in GENAU diesem Format — für JEDE beanstandete Section ein section-Marker (Titel EXAKT wie oben) mit `kompakt`- und `ausführlich`-Block:
|
||||
|
||||
<!-- section: Exakter Section-Titel -->
|
||||
Beschreibung…
|
||||
<!-- kompakt -->
|
||||
- Merksatz je Subbaustein (knapp, ohne Erklärung)
|
||||
<!-- ausführlich -->
|
||||
Erklärung (Einordnung → Wie/Warum) laut SECTION-SPEZIFIKATION…
|
||||
|
||||
### Beispiel
|
||||
```sprache
|
||||
…
|
||||
```
|
||||
(Beispiel im themengerechten Format laut SECTION-SPEZIFIKATION: Codeblock NUR bei Code-Themen, sonst Beispielsätze oder Mini-Szenario)
|
||||
|
||||
Die Marker-Zeilen exakt so schreiben. Kein Text außerhalb der Sections.
|
||||
{extra}
|
||||
|
||||
@@ -1,25 +1,43 @@
|
||||
Schreibe Sections für einen Lern-Guide zum Thema "{topic}" (Format: {format_name}).
|
||||
|
||||
Dir zugeteilt sind folgende Kapitel und Bausteine — verbindlich: jede zugeteilte Section muss vorkommen, keine zusätzlichen erfinden:
|
||||
Dir zugeteilt sind folgende Kapitel und Bausteine — verbindlich: jede zugeteilte Section muss vorkommen, keine zusätzlichen erfinden. Unter jedem Baustein stehen seine SUBBAUSTEINE mit Stufe (`[einfach]`/`[mittel]`/`[schwer]`):
|
||||
{zuteilung}
|
||||
|
||||
{facts}
|
||||
Das Format ({format_name}) beeinflusst NICHT, wie ein Baustein geschrieben wird — nur, wie viele Bausteine das Thema abdecken. Derselbe Baustein bekommt in MiniGuide, Guide und FullGuide denselben Text.
|
||||
|
||||
Beispiel-Tiefe für dieses Format ({format_name}): MiniGuide = nur die üblichen Varianten eines Bausteins, Guide = die gängigen Varianten, FullGuide = alle relevanten Varianten inkl. Nischenfällen.
|
||||
Jeder Baustein bekommt ZWEI Fassungen mit DENSELBEN Inhalten (dieselben Subbausteine):
|
||||
- **kompakt**: je Subbaustein EIN Merksatz als Stichpunkt (`- …`). Zum Erinnern. Nur benennen, nicht erklären. Gleiche Reihenfolge wie ausführlich.
|
||||
- **ausführlich**: der zusammenhängende Anfänger-Lerntext (siehe unten).
|
||||
Beide MÜSSEN dieselben Subbausteine abdecken. Nichts, das nur in einer Fassung steht.
|
||||
|
||||
SECTION-SPEZIFIKATION:
|
||||
SO SCHREIBST DU die ausführliche Fassung — EIN zusammenhängender Text für einen Junior, der das Thema NEU lernt:
|
||||
- Beginne mit dem Anker (Einordnung): welches Problem/wozu, angeknüpft an etwas Bekanntes — erst dann das Neue. Pflicht, nie weglassen.
|
||||
- Löse JEDEN Fachbegriff bei Erstnennung in einem Halbsatz auf. Setze nichts voraus — auch keine Begriffe aus dem Titel oder anderen Bausteinen. Nutze die „Voraussetzung"-Hinweise aus den Inhalten als Anker, die „Hürde"-Hinweise, um Missverständnisse vorweg auszuräumen.
|
||||
- Die Subbausteine sind deine INHALTS-CHECKLISTE, keine Überschriften: webe sie in den Fließtext ein. Behandle jeden, aber als Teil eines Ganzen — nicht als isolierten Mini-Absatz.
|
||||
- Reihenfolge: erst die `[einfach]`-Punkte (das Fundament), dann `[mittel]` (Details/Varianten), zuletzt `[schwer]` (Sonderfälle/Tücken — knapp, als fortgeschrittener Hinweis).
|
||||
- Diese Fassung trägt die volle Tiefe selbst — es gibt keine zweite Ausbaustufe. „Wie"-Abläufe Schritt für Schritt zeigen, nicht nur das Ergebnis.
|
||||
- KEIN festes Längen-Limit. So lang wie nötig, damit ein Neuling es OHNE Vorwissen versteht. Im Zweifel ein Satz mehr und klar.
|
||||
- Beispiele großzügig — auch kleine Mini-Beispiele mitten im Text, wo sie einen Punkt sofort greifbar machen, zusätzlich zum `### Beispiel` am Ende (Format laut Spezifikation).
|
||||
|
||||
GEPRÜFTE INHALTE je Baustein — das ist verbindlich, was gelehrt werden muss:
|
||||
{inhalte}
|
||||
|
||||
Recherchiere NICHT und suche NICHT im Web. Alle nötigen Fakten stehen in diesen geprüften Inhalten — nutze ausschließlich sie. Erfinde nichts dazu, lass nichts Wesentliches weg. Lehre simpel: kurze Sätze, Listen für Aufzählungen, ein Anfänger versteht es sofort.
|
||||
|
||||
SECTION-SPEZIFIKATION (gilt je Baustein):
|
||||
{spec}
|
||||
|
||||
Schreibe NUR die Datei {out_path} in GENAU diesem Format — pro Kapitel ein kapitel-Marker, pro Baustein ein section-Marker (Titel EXAKT aus der Zuteilung), darunter der Markdown-Body:
|
||||
Schreibe NUR die Datei {out_path} in GENAU diesem Format — pro Kapitel ein kapitel-Marker, pro Baustein ein section-Marker (Titel EXAKT aus der Zuteilung), darin ein `kompakt`- und ein `ausführlich`-Block:
|
||||
|
||||
<!-- kapitel: Kapiteltitel -->
|
||||
<!-- section: Exakter Baustein-Titel -->
|
||||
Beschreibung…
|
||||
<!-- kompakt -->
|
||||
- Merksatz je Subbaustein (knapp, ohne Erklärung)
|
||||
<!-- ausführlich -->
|
||||
Erklärung (Einordnung → Wie/Warum) laut SECTION-SPEZIFIKATION…
|
||||
|
||||
### Beispiel
|
||||
```sprache
|
||||
…
|
||||
```
|
||||
(Beispiel im themengerechten Format laut SECTION-SPEZIFIKATION: Codeblock NUR bei Code-Themen, sonst Beispielsätze oder Mini-Szenario)
|
||||
|
||||
Die Marker-Zeilen exakt so schreiben. Kein Text außerhalb der Sections, kein Dokument-Titel, kein Inhaltsverzeichnis.
|
||||
{extra}
|
||||
Die Marker-Zeilen exakt so schreiben (jede Section hat genau einen `<!-- kompakt -->`- und einen `<!-- ausführlich -->`-Block). Kein Text außerhalb der Sections, kein Dokument-Titel, kein Inhaltsverzeichnis.
|
||||
{extra}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
Baue aus der Faktenbasis einen OnePager zum Thema "{topic}" — ein Übersichtsblatt im 3×3-Raster auf einer Seite.
|
||||
|
||||
FAKTENBASIS (alleinige Quelle, nichts hinzuerfinden):
|
||||
{recherche}
|
||||
|
||||
Erstelle GENAU diese 7 Karten (JSON-Schlüssel exakt so):
|
||||
- "info" — Titel: "{topic}". Kurzbeschreibung in 1–2 Sätzen, darunter technische Daten als Stichpunkte (Art/Typ, Version/Stand, Lizenz/Kosten).
|
||||
- "eigenschaften" — Titel: "Kerneigenschaften". Was einen IM Thema erwartet: kleine Übersicht der Inhalte/Teilgebiete.
|
||||
- "beispiel" — Titel: "Beispiel". EIN anschauliches, typisches Codebeispiel (Markdown-Codeblock) mit einem Satz Erklärung.
|
||||
- "zusammenhaenge" — Titel: "Zusammenhänge". Mit welchen ANDEREN Themen es zusammenhängt — Nachbarthemen außerhalb dieses Themas, keine Inhalte des Themas selbst.
|
||||
- "voraussetzungen" — Titel: "Voraussetzungen". Welche Themen man vorher bearbeitet haben sollte, um hier klarzukommen.
|
||||
- "modern" — Titel: "Moderne Features". NUR was in den letzten Jahren neu dazugekommen ist. Gibt es nichts Neues: ehrlich "Keine." mit einem Satz Begründung.
|
||||
- "veraltet" — Titel: "Veraltete Features". Was nicht mehr verwendet wird. Gibt es nichts Veraltetes: ehrlich "Keine." mit einem Satz Begründung — nichts erfinden.
|
||||
|
||||
KOMPAKTHEIT — der OnePager muss OHNE Scrollen auf eine Bildschirmseite passen:
|
||||
- Maximal 5 Stichpunkte pro Karte, je maximal ~8 Wörter (Schlagwort + halber Satz).
|
||||
- Nur das Wichtigste — nicht alle Varianten aufzählen. Weglassen schlägt Vollständigkeit.
|
||||
- Keine Tabellen, keine verschachtelten Listen, keine Einleitungssätze.
|
||||
- Codebeispiel maximal ~12 kurze Zeilen.
|
||||
|
||||
Inhalt auf DEUTSCH, alles aus der Faktenbasis belegbar.
|
||||
Stichpunkte als Markdown-Liste mit fettem Schlagwort: `- **Schlagwort**: Rest` (kein rohes •).
|
||||
Code-Bezeichner und HTML-Tags im Text IMMER in Backticks (`<p>`, `src`) — nie roh.
|
||||
|
||||
Schreibe NUR die JSON-Datei nach: {out_path}
|
||||
|
||||
Format:
|
||||
{{"karten": {{"info": {{"titel": "{topic}", "md": "…"}}, "eigenschaften": {{"titel": "Kerneigenschaften", "md": "…"}}, "beispiel": {{"titel": "Beispiel", "md": "…"}}, "zusammenhaenge": {{"titel": "Zusammenhänge", "md": "…"}}, "voraussetzungen": {{"titel": "Voraussetzungen", "md": "…"}}, "modern": {{"titel": "Moderne Features", "md": "…"}}, "veraltet": {{"titel": "Veraltete Features", "md": "…"}}}}}}
|
||||
{extra}
|
||||
@@ -1,18 +0,0 @@
|
||||
Korrigiere die Karten eines OnePagers zum Thema "{topic}".
|
||||
|
||||
FAKTENBASIS (alleinige Quelle, nichts hinzuerfinden):
|
||||
{recherche}
|
||||
|
||||
BISHERIGE KARTEN:
|
||||
{karten}
|
||||
|
||||
NOTIERTE PROBLEME (von der Prüfung):
|
||||
{probleme}
|
||||
|
||||
Behebe NUR die notierten Probleme — alle anderen Karten bleiben unverändert.
|
||||
|
||||
Schreibe NUR die vollständige, korrigierte JSON-Datei (alle 7 Karten) nach: {out_path}
|
||||
|
||||
Format:
|
||||
{{"karten": {{"info": {{"titel": "…", "md": "…"}}, "eigenschaften": {{"titel": "…", "md": "…"}}, "beispiel": {{"titel": "…", "md": "…"}}, "zusammenhaenge": {{"titel": "…", "md": "…"}}, "voraussetzungen": {{"titel": "…", "md": "…"}}, "modern": {{"titel": "…", "md": "…"}}, "veraltet": {{"titel": "…", "md": "…"}}}}}}
|
||||
{extra}
|
||||
@@ -1 +0,0 @@
|
||||
Das Thema ist das Projekt unter {project}. Verschaffe dir mit Bash (ls/find) einen Überblick und lies README, Doku und den relevanten Quellcode mit dem Read-Tool. PDFs liegen als gleichnamige .txt-Dateien vor — lies IMMER die .txt, nie das PDF. Erfasse Zweck, Architektur und die wichtigsten Konzepte — nichts Erfundenes.
|
||||
@@ -1 +0,0 @@
|
||||
Recherchiere per Websuche: aktuelle Version, die Kernkonzepte und die wichtigsten Fakten zu "{topic}". Nimm nur auf, was du in der Recherche belegt hast.
|
||||
@@ -1,27 +0,0 @@
|
||||
Prüfe die Faktenbasis für einen OnePager zum Projekt "{topic}".
|
||||
|
||||
FAKTENBASIS:
|
||||
{recherche}
|
||||
|
||||
Sie muss diese Dimensionen abdecken:
|
||||
1. Kurzbeschreibung (Art des Projekts, Gegenstand)
|
||||
2. Technische Daten (Technologie/Format, Umfang, Stand/Aktualität)
|
||||
3. Inhaltsübersicht (was einen im Projekt erwartet)
|
||||
4. Ein typisches Beispiel aus dem Projekt
|
||||
5. Zusammenhänge mit ANDEREN Themen (Nachbarthemen außerhalb des Projektinhalts)
|
||||
6. Voraussetzungen (vorher zu bearbeitende Themen)
|
||||
7. Neuerungen der letzten Jahre vs. nicht mehr Verwendetes (oder die ausdrückliche Feststellung, dass es jeweils nichts gibt)
|
||||
|
||||
Prüfe:
|
||||
1. Ist jede Dimension mit konkreten Fakten aus den Projektdateien belegt (Namen, Zahlen — nicht vage)?
|
||||
2. Hat jeder Punkt einen Dateipfad als Quelle?
|
||||
3. Wirkt etwas erfunden — also nicht aus dem Projekt belegbar?
|
||||
|
||||
Du PRÜFST nur und notierst Probleme — du änderst nichts.
|
||||
|
||||
Schreibe NUR die JSON-Datei nach: {out_path}
|
||||
|
||||
Format — alles in Ordnung:
|
||||
{{"ok": true}}
|
||||
Sonst (kurz und konkret, maximal 10 Punkte):
|
||||
{{"probleme": ["…", "…"]}}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user