Compare commits
35 Commits
54eaa1c89b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
11
Makefile
11
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: install dev prod stop logs remove auth sync searxng ollama
|
.PHONY: install dev prod stop logs remove auth sync sync-projects projects searxng ollama
|
||||||
|
|
||||||
COMPOSE = docker compose
|
COMPOSE = docker compose
|
||||||
|
|
||||||
@@ -63,3 +63,12 @@ sync:
|
|||||||
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 storage/
|
||||||
rsync -avz --progress --delete root@178.104.67.87:/var/www/creator/storage/themen/ storage/themen/
|
rsync -avz --progress --delete root@178.104.67.87:/var/www/creator/storage/themen/ storage/themen/
|
||||||
@echo "Sync abgeschlossen."
|
@echo "Sync abgeschlossen."
|
||||||
|
|
||||||
|
# Projekte vom Server holen: `make sync-projects` (auch: `make sync projects`).
|
||||||
|
# Ohne --delete — lokale Projekte, die es auf dem Server nicht gibt, bleiben.
|
||||||
|
sync-projects:
|
||||||
|
@mkdir -p projects
|
||||||
|
rsync -avz --progress root@178.104.67.87:/var/www/creator/projects/ projects/
|
||||||
|
@echo "Projekt-Sync abgeschlossen."
|
||||||
|
|
||||||
|
projects: sync-projects
|
||||||
|
|||||||
@@ -5,17 +5,33 @@ jeweilige Provider fehl — der andere läuft unverändert weiter.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
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] = {}
|
_active_processes: dict[str, asyncio.subprocess.Process] = {}
|
||||||
|
|
||||||
|
# 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
|
# Capability → Claude --allowedTools
|
||||||
_CLAUDE_TOOLS = {
|
_CLAUDE_TOOLS = {
|
||||||
"full": "Write,Bash,Read,WebSearch,WebFetch",
|
"full": "Write,Bash,Read,WebSearch,WebFetch",
|
||||||
@@ -54,7 +70,11 @@ def provider_available(provider: str) -> bool:
|
|||||||
def kill_process(agent_key_prefix: str) -> None:
|
def kill_process(agent_key_prefix: str) -> None:
|
||||||
"""Killt alle aktiven Prozesse, deren Key mit dem Prefix beginnt (deckt -plan/-w1… ab)."""
|
"""Killt alle aktiven Prozesse, deren Key mit dem Prefix beginnt (deckt -plan/-w1… ab)."""
|
||||||
for key, process in list(_active_processes.items()):
|
for key, process in list(_active_processes.items()):
|
||||||
if key.startswith(agent_key_prefix) and process.returncode is None:
|
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)
|
||||||
process.kill()
|
process.kill()
|
||||||
|
|
||||||
|
|
||||||
@@ -65,23 +85,36 @@ async def run_agent(
|
|||||||
provider: str = DEFAULT_PROVIDER,
|
provider: str = DEFAULT_PROVIDER,
|
||||||
role: str = "fast",
|
role: str = "fast",
|
||||||
capabilities: str = "none",
|
capabilities: str = "none",
|
||||||
|
lane: str = "batch",
|
||||||
) -> tuple[int, str, str]:
|
) -> tuple[int, str, str]:
|
||||||
if provider not in PROVIDERS:
|
if provider not in PROVIDERS:
|
||||||
return 1, "", f"Unbekannter Provider: {provider}"
|
return 1, "", f"Unbekannter Provider: {provider}"
|
||||||
if shutil.which(PROVIDERS[provider]["cli"]) is None:
|
if shutil.which(PROVIDERS[provider]["cli"]) is None:
|
||||||
return 1, "", f"CLI '{PROVIDERS[provider]['cli']}' nicht installiert (Provider: {provider})"
|
return 1, "", f"CLI '{PROVIDERS[provider]['cli']}' nicht installiert (Provider: {provider})"
|
||||||
if PROVIDERS[provider]["cli"] == "opencode":
|
sem = _interactive_sem if lane == "interactive" else _batch_sem
|
||||||
return await _run_opencode(agent_key, prompt, timeout, provider, role, capabilities)
|
async with sem:
|
||||||
return await _run_claude_cli(agent_key, prompt, timeout, role, capabilities)
|
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]:
|
async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None, timeout: int, stagger: bool = False) -> tuple[int, str, str]:
|
||||||
process = await asyncio.create_subprocess_exec(
|
start = time.monotonic()
|
||||||
*cmd,
|
|
||||||
stdin=asyncio.subprocess.PIPE if stdin_data is not None else asyncio.subprocess.DEVNULL,
|
async def spawn():
|
||||||
stdout=asyncio.subprocess.PIPE,
|
return await asyncio.create_subprocess_exec(
|
||||||
stderr=asyncio.subprocess.PIPE,
|
*cmd,
|
||||||
)
|
stdin=asyncio.subprocess.PIPE if stdin_data is not None else asyncio.subprocess.DEVNULL,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
_active_processes[agent_key] = process
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
@@ -95,10 +128,18 @@ async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None,
|
|||||||
await asyncio.wait_for(process.wait(), timeout=5)
|
await asyncio.wait_for(process.wait(), timeout=5)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
pass
|
pass
|
||||||
|
log.info("agent %s: Timeout nach %ds", agent_key, timeout)
|
||||||
raise
|
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")
|
return process.returncode, stdout.decode("utf-8", errors="replace"), stderr.decode("utf-8", errors="replace")
|
||||||
finally:
|
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]:
|
async def _run_claude_cli(agent_key: str, prompt: str, timeout: int, role: str, capabilities: str) -> tuple[int, str, str]:
|
||||||
@@ -128,7 +169,7 @@ async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str
|
|||||||
"-f", str(prompt_path),
|
"-f", str(prompt_path),
|
||||||
]
|
]
|
||||||
try:
|
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
|
return rc, _clean_opencode_output(stdout), stderr
|
||||||
finally:
|
finally:
|
||||||
prompt_path.unlink(missing_ok=True)
|
prompt_path.unlink(missing_ok=True)
|
||||||
|
|||||||
435
backend/bausteine.py
Normal file
435
backend/bausteine.py
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
"""Bausteine-Pipeline: Recherche-Konsens + Klärungs-Loop — reines Inventar, unsortiert.
|
||||||
|
|
||||||
|
5x Recherche (min. 3, Grace) → Mapping (Konsens/Rest) → Klärungs-Loop (max.
|
||||||
|
KONSENS_MAX_RUNDEN Runden): 3 Auswahl-Agenten (min. 2, Grace) entscheiden
|
||||||
|
über den strittigen Rest, ein Mapping-Agent sortiert in aufnehmen/verwerfen/
|
||||||
|
weiter strittig. Leerer Rest beendet den Loop; die letzte Runde muss alles
|
||||||
|
entscheiden. Races nutzen ein Grace-Fenster statt „erste N gewinnen": Nach dem
|
||||||
|
ersten gültigen Ergebnis dürfen die übrigen Agenten KONSENS_GRACE Sekunden
|
||||||
|
fertig werden. Der Konsens wird im Code akkumuliert — kein Agent re-emittiert
|
||||||
|
die Gesamtliste.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from agents import kill_process
|
||||||
|
from config import KONSENS_GRACE, KONSENS_MAX_RUNDEN, DEFAULT_PROVIDER
|
||||||
|
from fsutil import atomic_write_text
|
||||||
|
from jsonio import read_json_file as _json_datei
|
||||||
|
from paths import arbeit_dir, bausteine_path, project_dir
|
||||||
|
from pipeline import (
|
||||||
|
CANCELLED, FAILED, GenContext, _extra, _log, _prompt, _race, _rest_schema,
|
||||||
|
_runde_schema, _semaphore, _str_liste, _timeout, run_single_slot,
|
||||||
|
)
|
||||||
|
from textkit import _eindeutige_titel, _parse_auswahl, _titel_aufloesen, _titel_index, _vormerge
|
||||||
|
|
||||||
|
log = logging.getLogger("creator.bausteine")
|
||||||
|
|
||||||
|
_bausteine_progress: dict[str, str] = {}
|
||||||
|
_bausteine_errors: dict[str, str] = {}
|
||||||
|
_bausteine_cancelled: set[str] = set()
|
||||||
|
_bausteine_step: dict[str, int] = {}
|
||||||
|
|
||||||
|
BAUSTEINE_STEPS = ("Recherche", "Konsolidierung", "Klärung")
|
||||||
|
|
||||||
|
|
||||||
|
def _bausteine_steps(topic: str) -> tuple:
|
||||||
|
"""Projekte haben einen zusätzlichen Schritt: Themenfeld-Ergänzung per Web-Recherche."""
|
||||||
|
if project_dir(topic).is_dir():
|
||||||
|
return BAUSTEINE_STEPS + ("Ergänzung",)
|
||||||
|
return BAUSTEINE_STEPS
|
||||||
|
|
||||||
|
|
||||||
|
def _bausteine_files(topic: str) -> dict:
|
||||||
|
arbeit = arbeit_dir(topic)
|
||||||
|
runden = range(1, KONSENS_MAX_RUNDEN + 1)
|
||||||
|
return {
|
||||||
|
"final": bausteine_path(topic),
|
||||||
|
"arbeit": arbeit,
|
||||||
|
"recherche": [arbeit / f"recherche-{i}.md" for i in (1, 2, 3, 4, 5)],
|
||||||
|
"recherche_mapping": arbeit / "recherche-mapping.json",
|
||||||
|
"auswahl": {n: [arbeit / f"auswahl-r{n}-{i}.json" for i in (1, 2, 3)] for n in runden},
|
||||||
|
"mapping": {n: arbeit / f"auswahl-mapping-r{n}.json" for n in runden},
|
||||||
|
"ergaenzung": arbeit / "ergaenzung.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _alle_slot_dateien(files: dict) -> list[Path]:
|
||||||
|
return [
|
||||||
|
*files["recherche"], files["recherche_mapping"],
|
||||||
|
*(p for slots in files["auswahl"].values() for p in slots),
|
||||||
|
*files["mapping"].values(), files["ergaenzung"],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_bausteine(topic: str) -> bool:
|
||||||
|
if topic not in _bausteine_progress:
|
||||||
|
return False
|
||||||
|
_bausteine_cancelled.add(topic)
|
||||||
|
kill_process(f"bausteine-{topic}-")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _resume_step(topic: str) -> int:
|
||||||
|
"""Erster noch offener Schritt anhand der persistierten Zwischendateien."""
|
||||||
|
files = _bausteine_files(topic)
|
||||||
|
if sum(p.exists() for p in files["recherche"]) < 3:
|
||||||
|
return 0
|
||||||
|
if not files["recherche_mapping"].exists():
|
||||||
|
return 1
|
||||||
|
mapping = _mapping_schema(_json_datei(files["recherche_mapping"]))
|
||||||
|
geklaert = mapping is not None and (
|
||||||
|
not mapping[1] # kein strittiger Rest
|
||||||
|
or any((r := _runde_schema(_json_datei(p))) is not None and not r[1] for p in files["mapping"].values())
|
||||||
|
)
|
||||||
|
if not geklaert:
|
||||||
|
return 2
|
||||||
|
if project_dir(topic).is_dir() and not files["ergaenzung"].exists():
|
||||||
|
return 3
|
||||||
|
return len(_bausteine_steps(topic))
|
||||||
|
|
||||||
|
|
||||||
|
def bausteine_status(topic: str) -> dict:
|
||||||
|
steps = _bausteine_steps(topic)
|
||||||
|
ready = bausteine_path(topic).exists()
|
||||||
|
generating = topic in _bausteine_progress
|
||||||
|
partial = False
|
||||||
|
if generating:
|
||||||
|
current = _bausteine_step.get(topic)
|
||||||
|
states = [
|
||||||
|
"pending" if current is None else "done" if i < current else "active" if i == current else "pending"
|
||||||
|
for i in range(len(steps))
|
||||||
|
]
|
||||||
|
elif ready:
|
||||||
|
states = ["done"] * len(steps)
|
||||||
|
else:
|
||||||
|
nxt = _resume_step(topic)
|
||||||
|
partial = nxt > 0
|
||||||
|
states = ["done" if i < nxt else "pending" for i in range(len(steps))]
|
||||||
|
return {
|
||||||
|
"ready": ready,
|
||||||
|
"generating": generating,
|
||||||
|
"progress": _bausteine_progress.get(topic),
|
||||||
|
"error": _bausteine_errors.get(topic),
|
||||||
|
"partial": partial,
|
||||||
|
"steps": [{"label": label, "state": s} for label, s in zip(steps, states)],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def active_bausteine() -> list[dict]:
|
||||||
|
return [{"topic": t, "progress": p} for t, p in _bausteine_progress.items()]
|
||||||
|
|
||||||
|
|
||||||
|
def reset_bausteine(topic: str) -> None:
|
||||||
|
files = _bausteine_files(topic)
|
||||||
|
files["final"].unlink(missing_ok=True)
|
||||||
|
shutil.rmtree(files["arbeit"], ignore_errors=True)
|
||||||
|
_bausteine_errors.pop(topic, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _ergaenzung_schema(data):
|
||||||
|
"""{"bausteine": [{"titel", "beschreibung"}]} → Liste (leer erlaubt) · sonst None."""
|
||||||
|
if not isinstance(data, dict) or not isinstance(data.get("bausteine"), list):
|
||||||
|
return None
|
||||||
|
out = []
|
||||||
|
for b in data["bausteine"]:
|
||||||
|
if not isinstance(b, dict) or not isinstance(b.get("titel"), str) or not isinstance(b.get("beschreibung"), str):
|
||||||
|
return None
|
||||||
|
titel, beschreibung = b["titel"].strip(), b["beschreibung"].strip()
|
||||||
|
if not titel:
|
||||||
|
return None
|
||||||
|
out.append((titel, beschreibung))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _pdfs_konvertieren(project: Path) -> None:
|
||||||
|
"""PDFs im Projekt in .txt wandeln (pdftotext) — Agenten lesen Text statt Seiten-Bildern.
|
||||||
|
|
||||||
|
Wird vor jeder Projekt-Generierung aufgerufen; konvertiert nur, wenn die
|
||||||
|
.txt fehlt oder älter als das PDF ist. Das Original bleibt unangetastet.
|
||||||
|
Fehlt pdftotext und das Projekt enthält PDFs → harter Fehler statt
|
||||||
|
unzuverlässigem Direkt-Lese-Modus (MiniMax-Bilderlimit, Vision-Kosten).
|
||||||
|
"""
|
||||||
|
pdfs = list(project.rglob("*.pdf"))
|
||||||
|
if not pdfs:
|
||||||
|
return
|
||||||
|
if shutil.which("pdftotext") is None:
|
||||||
|
raise RuntimeError("pdftotext fehlt (poppler-utils installieren) — PDFs im Projekt können nicht gelesen werden")
|
||||||
|
for pdf in pdfs:
|
||||||
|
txt = pdf.with_suffix(".txt")
|
||||||
|
if txt.exists() and txt.stat().st_mtime >= pdf.stat().st_mtime:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
subprocess.run(["pdftotext", "-layout", str(pdf), str(txt)], check=True, timeout=120)
|
||||||
|
_log(project.name, f"PDF konvertiert: {pdf.name} → {txt.name}")
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"PDF-Konvertierung fehlgeschlagen ({pdf.name}): {e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def _build_recherche_prompt(topic: str, out_path: Path, instructions: str = "", project: Path | None = None) -> str:
|
||||||
|
if project:
|
||||||
|
source = _prompt("Bausteine-Quelle-Projekt", project=project)
|
||||||
|
else:
|
||||||
|
source = _prompt("Bausteine-Quelle-Thema", topic=topic)
|
||||||
|
return _prompt(
|
||||||
|
"Bausteine-Recherche",
|
||||||
|
topic=topic, source=source, bausteine_path=out_path, extra=_extra(instructions),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _file_payload(path: Path):
|
||||||
|
"""Gültig, wenn die Slot-Datei existiert und nummerierte Einträge enthält."""
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
return text if _parse_auswahl(text) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping_schema(data):
|
||||||
|
"""{"bausteine": [str, ≥1], "rest": [str]} → (bausteine, rest) · sonst None."""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
bausteine = _str_liste(data.get("bausteine"))
|
||||||
|
rest = _str_liste(data.get("rest"))
|
||||||
|
if not bausteine or rest is None:
|
||||||
|
return None
|
||||||
|
return bausteine, rest
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_bausteine(topic: str, instructions: str = "", provider: str = DEFAULT_PROVIDER) -> None:
|
||||||
|
if topic in _bausteine_progress:
|
||||||
|
return
|
||||||
|
_bausteine_progress[topic] = "Wartend…"
|
||||||
|
_bausteine_errors.pop(topic, None)
|
||||||
|
|
||||||
|
files = _bausteine_files(topic)
|
||||||
|
final_path = files["final"]
|
||||||
|
project = project_dir(topic) if project_dir(topic).is_dir() else None
|
||||||
|
|
||||||
|
def set_p(msg: str, step: int | None = None) -> None:
|
||||||
|
_bausteine_progress[topic] = msg
|
||||||
|
if step is not None:
|
||||||
|
_bausteine_step[topic] = step
|
||||||
|
|
||||||
|
def is_cancelled() -> bool:
|
||||||
|
return topic in _bausteine_cancelled
|
||||||
|
|
||||||
|
def abgebrochen() -> None:
|
||||||
|
_bausteine_errors[topic] = "Abgebrochen — Fortschritt bleibt erhalten"
|
||||||
|
|
||||||
|
ctx = GenContext(topic=topic, provider=provider, is_cancelled=is_cancelled)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with _semaphore:
|
||||||
|
files["arbeit"].mkdir(parents=True, exist_ok=True)
|
||||||
|
if project:
|
||||||
|
await asyncio.to_thread(_pdfs_konvertieren, project)
|
||||||
|
# „Neu erstellen": fertige Bausteine → kompletter Frischstart.
|
||||||
|
# Sonst sind Slot-Dateien Reste eines Abbruchs/Fehlers → Resume.
|
||||||
|
if final_path.exists():
|
||||||
|
for p_alt in _alle_slot_dateien(files):
|
||||||
|
p_alt.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
# Schritt 1: 5 Recherche-Agenten, min. 3 mit Grace-Fenster — alle gültigen
|
||||||
|
# Slot-Dateien fließen ins Mapping (kein Kappen mehr bei 3)
|
||||||
|
recherchen: list[str] = []
|
||||||
|
offen = []
|
||||||
|
for i, path in enumerate(files["recherche"], 1):
|
||||||
|
text = _file_payload(path)
|
||||||
|
if text is not None:
|
||||||
|
recherchen.append(text)
|
||||||
|
else:
|
||||||
|
offen.append((i, path))
|
||||||
|
vorhanden = len(recherchen)
|
||||||
|
set_p(f"Recherche läuft ({vorhanden} gültig, min. 3)…", step=0)
|
||||||
|
if vorhanden < 3:
|
||||||
|
caps = "files" if project else "full"
|
||||||
|
slots = [
|
||||||
|
{
|
||||||
|
"key": f"bausteine-{topic}-recherche-{i}",
|
||||||
|
"prompt": _build_recherche_prompt(topic, path, instructions, project),
|
||||||
|
"role": "quick", "capabilities": caps,
|
||||||
|
"payload": (lambda result, p=path: _file_payload(p)),
|
||||||
|
}
|
||||||
|
for i, path in offen
|
||||||
|
]
|
||||||
|
neue = await _race(
|
||||||
|
topic, "Recherche", slots, 3 - vorhanden, _timeout("recherche"), provider,
|
||||||
|
on_update=lambda c: set_p(f"Recherche läuft ({vorhanden + c} gültig, min. 3)…"),
|
||||||
|
cancelled=is_cancelled, grace=KONSENS_GRACE,
|
||||||
|
)
|
||||||
|
if is_cancelled():
|
||||||
|
abgebrochen()
|
||||||
|
return
|
||||||
|
if neue is None:
|
||||||
|
_bausteine_errors[topic] = "Recherche fehlgeschlagen (Minimum nicht erreicht)"
|
||||||
|
return
|
||||||
|
recherchen += neue
|
||||||
|
|
||||||
|
# Schritt 2: Recherche-Mapping — Code-Vormerge (exakte Titel) + 1 Agent
|
||||||
|
# für semantische Dubletten und Konsens/Rest-Teilung (fatal)
|
||||||
|
mapping = _mapping_schema(_json_datei(files["recherche_mapping"]))
|
||||||
|
if mapping is None:
|
||||||
|
set_p("Konsolidiere Recherche…", step=1)
|
||||||
|
files["recherche_mapping"].unlink(missing_ok=True)
|
||||||
|
gemergt = _vormerge([_parse_auswahl(t) for t in recherchen])
|
||||||
|
eintraege = "\n".join(f"{i}. {text} ({n}× genannt)" for i, (text, n) in enumerate(gemergt, 1))
|
||||||
|
status, mapping = await run_single_slot(
|
||||||
|
ctx, "Recherche-Mapping",
|
||||||
|
key=f"bausteine-{topic}-recherche-mapping",
|
||||||
|
prompt=_prompt(
|
||||||
|
"Bausteine-Recherche-Mapping",
|
||||||
|
topic=topic, n=len(recherchen), eintraege=eintraege,
|
||||||
|
out_path=files["recherche_mapping"],
|
||||||
|
),
|
||||||
|
role="judge", capabilities="files",
|
||||||
|
payload=lambda result: _mapping_schema(_json_datei(files["recherche_mapping"])),
|
||||||
|
timeout=_timeout("recherche_mapping", len(gemergt)),
|
||||||
|
)
|
||||||
|
if status == CANCELLED:
|
||||||
|
abgebrochen()
|
||||||
|
return
|
||||||
|
if status == FAILED:
|
||||||
|
_bausteine_errors[topic] = "Recherche-Mapping fehlgeschlagen"
|
||||||
|
return
|
||||||
|
konsens, rest = mapping
|
||||||
|
|
||||||
|
# Klärungs-Loop: 3 Auswahl-Agenten entscheiden über den Rest, ein
|
||||||
|
# Mapping-Agent sortiert in aufnehmen/verwerfen/weiter strittig.
|
||||||
|
# Leerer Rest beendet den Loop; Runde KONSENS_MAX_RUNDEN muss
|
||||||
|
# alles entscheiden. Der Konsens wächst nur hier im Code.
|
||||||
|
runde = 0
|
||||||
|
while rest and runde < KONSENS_MAX_RUNDEN:
|
||||||
|
runde += 1
|
||||||
|
final_runde = runde == KONSENS_MAX_RUNDEN
|
||||||
|
set_p(f"Klärung läuft (Runde {runde}/{KONSENS_MAX_RUNDEN})…", step=2)
|
||||||
|
mapping_path = files["mapping"][runde]
|
||||||
|
|
||||||
|
# Resume: fertiges Runden-Mapping wird direkt übernommen
|
||||||
|
ergebnis = _runde_schema(_json_datei(mapping_path), final=final_runde)
|
||||||
|
if ergebnis is None:
|
||||||
|
mapping_path.unlink(missing_ok=True)
|
||||||
|
konsens_block = "\n".join(f"- {t}" for t in konsens)
|
||||||
|
rest_block = "\n".join(f"- {t}" for t in rest)
|
||||||
|
|
||||||
|
# 3 Auswahl-Agenten, min. 2 mit Grace-Fenster
|
||||||
|
entscheidungen = []
|
||||||
|
offen = []
|
||||||
|
for i, path in enumerate(files["auswahl"][runde], 1):
|
||||||
|
res = _rest_schema(_json_datei(path))
|
||||||
|
if res is not None:
|
||||||
|
entscheidungen.append(res)
|
||||||
|
else:
|
||||||
|
offen.append((i, path))
|
||||||
|
if len(entscheidungen) < 2:
|
||||||
|
slots = [
|
||||||
|
{
|
||||||
|
"key": f"bausteine-{topic}-auswahl-r{runde}-{i}",
|
||||||
|
"prompt": _prompt(
|
||||||
|
"Bausteine-Auswahl",
|
||||||
|
topic=topic, konsens=konsens_block, rest=rest_block, out_path=path,
|
||||||
|
),
|
||||||
|
"role": "fast", "capabilities": "files",
|
||||||
|
"payload": (lambda result, p=path: _rest_schema(_json_datei(p))),
|
||||||
|
}
|
||||||
|
for i, path in offen
|
||||||
|
]
|
||||||
|
neue = await _race(
|
||||||
|
topic, f"Auswahl r{runde}", slots, 2 - len(entscheidungen),
|
||||||
|
_timeout("auswahl", len(rest)), provider,
|
||||||
|
cancelled=is_cancelled, grace=KONSENS_GRACE,
|
||||||
|
)
|
||||||
|
if is_cancelled():
|
||||||
|
abgebrochen()
|
||||||
|
return
|
||||||
|
if neue is None:
|
||||||
|
_bausteine_errors[topic] = f"Auswahl fehlgeschlagen (Runde {runde}, Minimum nicht erreicht)"
|
||||||
|
return
|
||||||
|
entscheidungen += neue
|
||||||
|
|
||||||
|
# Votum pro Rest-Eintrag deterministisch zählen
|
||||||
|
indizes = [_titel_index(dict(enumerate(e, 1))) for e in entscheidungen]
|
||||||
|
voten = "\n".join(
|
||||||
|
f"{i}. {text} (von {sum(1 for idx in indizes if _titel_aufloesen(idx, text) is not None)}"
|
||||||
|
f"/{len(entscheidungen)} Agenten übernommen)"
|
||||||
|
for i, text in enumerate(rest, 1)
|
||||||
|
)
|
||||||
|
final_zusatz = (
|
||||||
|
"\n- LETZTE RUNDE: Es gibt keine weitere Runde. `rest` MUSS leer sein"
|
||||||
|
" — entscheide JEDEN Eintrag selbst: aufnehmen oder verwerfen."
|
||||||
|
if final_runde else ""
|
||||||
|
)
|
||||||
|
status, ergebnis = await run_single_slot(
|
||||||
|
ctx, f"Auswahl-Mapping r{runde}",
|
||||||
|
key=f"bausteine-{topic}-auswahl-mapping-r{runde}",
|
||||||
|
prompt=_prompt(
|
||||||
|
"Bausteine-Auswahl-Mapping",
|
||||||
|
topic=topic, n=len(entscheidungen), konsens=konsens_block,
|
||||||
|
rest=voten, final=final_zusatz, out_path=mapping_path,
|
||||||
|
),
|
||||||
|
role="judge", capabilities="files",
|
||||||
|
payload=lambda result, p=mapping_path, f=final_runde: _runde_schema(_json_datei(p), final=f),
|
||||||
|
timeout=_timeout("auswahl_mapping", len(rest)),
|
||||||
|
)
|
||||||
|
if status == CANCELLED:
|
||||||
|
abgebrochen()
|
||||||
|
return
|
||||||
|
if status == FAILED:
|
||||||
|
_bausteine_errors[topic] = f"Auswahl-Mapping fehlgeschlagen (Runde {runde})"
|
||||||
|
return
|
||||||
|
|
||||||
|
aufnehmen, rest = ergebnis
|
||||||
|
_log(topic, f"Klärung Runde {runde}: {len(aufnehmen)} aufgenommen, {len(rest)} weiter strittig")
|
||||||
|
konsens = konsens + aufnehmen
|
||||||
|
|
||||||
|
entries = {i: t for i, t in enumerate(konsens, 1)}
|
||||||
|
|
||||||
|
# Nur Projekte: Themenfeld-Ergänzung — Skript/Projekt ist ein Ausschnitt,
|
||||||
|
# ein Web-Agent ergänzt kanonisch fehlende Bausteine, markiert mit [Ergänzung].
|
||||||
|
if project:
|
||||||
|
set_p("Ergänze Themenfeld…", step=3)
|
||||||
|
erg_path = files["ergaenzung"]
|
||||||
|
ergaenzungen = _ergaenzung_schema(_json_datei(erg_path))
|
||||||
|
if ergaenzungen is None:
|
||||||
|
erg_path.unlink(missing_ok=True)
|
||||||
|
status, ergaenzungen = await run_single_slot(
|
||||||
|
ctx, "Ergänzung",
|
||||||
|
key=f"bausteine-{topic}-ergaenzung-1",
|
||||||
|
prompt=_prompt(
|
||||||
|
"Bausteine-Ergaenzung",
|
||||||
|
topic=topic, bausteine="\n".join(f"- {t}" for t in entries.values()),
|
||||||
|
out_path=erg_path, extra=_extra(instructions),
|
||||||
|
),
|
||||||
|
role="quick", capabilities="full",
|
||||||
|
payload=lambda result: _ergaenzung_schema(_json_datei(erg_path)),
|
||||||
|
timeout=_timeout("ergaenzung"),
|
||||||
|
)
|
||||||
|
if status == CANCELLED:
|
||||||
|
abgebrochen()
|
||||||
|
return
|
||||||
|
if status == FAILED:
|
||||||
|
_bausteine_errors[topic] = "Ergänzung fehlgeschlagen (kein gültiges Ergebnis)"
|
||||||
|
return
|
||||||
|
idx = _titel_index(entries)
|
||||||
|
neu = [(t, b) for t, b in ergaenzungen if _titel_aufloesen(idx, t) is None]
|
||||||
|
if neu:
|
||||||
|
_log(topic, f"Ergänzung: {len(neu)} Baustein(e) aus dem Themenfeld ergänzt")
|
||||||
|
start = max(entries, default=0) + 1
|
||||||
|
for off, (t, b) in enumerate(neu):
|
||||||
|
entries[start + off] = f"{t} — {b} [Ergänzung]"
|
||||||
|
|
||||||
|
# Titel eindeutig machen und unsortiertes Inventar schreiben
|
||||||
|
entries = _eindeutige_titel(entries)
|
||||||
|
atomic_write_text(final_path, "\n".join(f"{i}. {t}" for i, t in entries.items()) + "\n")
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("[%s] Bausteine-Generierung fehlgeschlagen", topic)
|
||||||
|
_bausteine_errors[topic] = str(e)[:2000]
|
||||||
|
finally:
|
||||||
|
# Kein Datei-Cleanup: Zwischendateien bleiben für Resume bzw. Nachvollziehbarkeit.
|
||||||
|
_bausteine_progress.pop(topic, None)
|
||||||
|
_bausteine_step.pop(topic, None)
|
||||||
|
_bausteine_cancelled.discard(topic)
|
||||||
@@ -9,20 +9,39 @@ PROJECTS_DIR = PROJECT_ROOT / "projects"
|
|||||||
|
|
||||||
MAX_CONCURRENT_GENERATIONS = 10
|
MAX_CONCURRENT_GENERATIONS = 10
|
||||||
|
|
||||||
|
# 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 = 12
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
# Timeouts pro Agenten-Schritt: (Basis-Sekunden, Sekunden pro Baustein/Section).
|
# 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.
|
# Gilt für alle Provider gleich — wer zu langsam ist, wird neu gestartet bzw. überholt.
|
||||||
TIMEOUTS = {
|
TIMEOUTS = {
|
||||||
"recherche": (1800, 0), # fix 30 min
|
"recherche": (1800, 0), # fix 30 min
|
||||||
"auswahl": (600, 10),
|
"recherche_mapping": (600, 3), # n = vorgemergte Einträge
|
||||||
"auswahl_check": (300, 2),
|
"auswahl": (300, 2), # Rest-Prüfung im Klärungs-Loop, n = Rest-Einträge
|
||||||
|
"auswahl_mapping": (600, 2), # n = Rest-Einträge
|
||||||
"ergaenzung": (900, 0), # Themenfeld-Ergänzung bei Projekten (Web-Recherche)
|
"ergaenzung": (900, 0), # Themenfeld-Ergänzung bei Projekten (Web-Recherche)
|
||||||
"guide_auswahl": (300, 5), # pro Baustein im Inventar
|
"guide_auswahl": (300, 5), # pro Baustein im Inventar
|
||||||
"guide_check": (300, 2), # Auswahl-/Gliederungs-Prüfung (nur Titellisten)
|
|
||||||
"plan": (300, 5),
|
"plan": (300, 5),
|
||||||
|
"plan_judge": (600, 5), # Judge liest bis zu 5 Gliederungen, n = Sections
|
||||||
"writer": (600, 120), # pro Section im Chunk
|
"writer": (600, 120), # pro Section im Chunk
|
||||||
"lese_check": (300, 10), # pro Section im Paket
|
"lese_check": (300, 10), # pro Section im Paket
|
||||||
"onepager_recherche": (900, 0),
|
"onepager_recherche": (900, 0),
|
||||||
|
"onepager_mapping": (600, 0), # Konsolidierung der Recherchen
|
||||||
"onepager_bauen": (300, 0),
|
"onepager_bauen": (300, 0),
|
||||||
|
"onepager_judge": (600, 0), # Judge über die Karten-Sätze
|
||||||
"onepager_verify": (300, 0),
|
"onepager_verify": (300, 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,31 +54,27 @@ FORMAT_ANTEIL = {
|
|||||||
|
|
||||||
# Provider-Stacks: komplett unabhängig, einer kann jederzeit entfernt werden.
|
# Provider-Stacks: komplett unabhängig, einer kann jederzeit entfernt werden.
|
||||||
# Rollen: "quick" = Massenarbeit (Recherche, Einordnung),
|
# Rollen: "quick" = Massenarbeit (Recherche, Einordnung),
|
||||||
# "fast" = Urteilsaufgaben mit kleinem Output (Auswahl, Final, OnePager, Chat),
|
# "fast" = Interaktion + Voten (Chat, Prüfung, Klärung, Elemente),
|
||||||
# "guide" = große Generierung (Plan, Writer).
|
# "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"
|
DEFAULT_PROVIDER = "claude"
|
||||||
PROVIDERS = {
|
PROVIDERS = {
|
||||||
"claude": {
|
"claude": {
|
||||||
"cli": "claude",
|
"cli": "claude",
|
||||||
"guide": "claude-opus-4-8[1m]",
|
"guide": "claude-opus-4-8[1m]",
|
||||||
"fast": "claude-sonnet-4-6",
|
"fast": "claude-sonnet-4-6",
|
||||||
|
"judge": "claude-sonnet-4-6", # CLI kennt keine Temperature
|
||||||
"quick": "claude-sonnet-4-6",
|
"quick": "claude-sonnet-4-6",
|
||||||
"env_key": None, # Auth via CLAUDE_CODE_OAUTH_TOKEN oder ~/.claude
|
"env_key": None, # Auth via CLAUDE_CODE_OAUTH_TOKEN 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": {
|
"minimax": {
|
||||||
"cli": "opencode",
|
"cli": "opencode",
|
||||||
"guide": "minimax/MiniMax-M3",
|
"guide": "minimax/MiniMax-M3",
|
||||||
"fast": "minimax/MiniMax-M2.7-highspeed",
|
"fast": "minimax-kalt/MiniMax-M2.7-highspeed",
|
||||||
"quick": "minimax/MiniMax-M2.7-highspeed",
|
"judge": "minimax-kalt/MiniMax-M3",
|
||||||
"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",
|
|
||||||
"quick": "minimax/MiniMax-M2.7-highspeed",
|
"quick": "minimax/MiniMax-M2.7-highspeed",
|
||||||
"env_key": "MINIMAX_API_KEY",
|
"env_key": "MINIMAX_API_KEY",
|
||||||
},
|
},
|
||||||
@@ -67,6 +82,7 @@ PROVIDERS = {
|
|||||||
"cli": "opencode",
|
"cli": "opencode",
|
||||||
"guide": "ollama/qwen3.6:27b",
|
"guide": "ollama/qwen3.6:27b",
|
||||||
"fast": "ollama/qwen3.5:9b",
|
"fast": "ollama/qwen3.5:9b",
|
||||||
|
"judge": "ollama/qwen3.5:9b",
|
||||||
"quick": "ollama/qwen3.5:9b",
|
"quick": "ollama/qwen3.5:9b",
|
||||||
"env_key": None,
|
"env_key": None,
|
||||||
"check_url": "http://localhost:11434/api/tags", # Ollama erreichbar?
|
"check_url": "http://localhost:11434/api/tags", # Ollama erreichbar?
|
||||||
|
|||||||
@@ -42,13 +42,36 @@ CREATE TABLE IF NOT EXISTS elements (
|
|||||||
description TEXT NOT NULL DEFAULT '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
examples TEXT NOT NULL DEFAULT '[]',
|
examples TEXT NOT NULL DEFAULT '[]',
|
||||||
hints TEXT NOT NULL DEFAULT '[]',
|
hints TEXT NOT NULL DEFAULT '[]',
|
||||||
aufgabe TEXT NOT NULL DEFAULT '',
|
|
||||||
loesung TEXT NOT NULL DEFAULT '',
|
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_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,
|
||||||
|
absolviert TEXT,
|
||||||
|
verstanden TEXT,
|
||||||
|
gemeistert TEXT,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (topic, baustein)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
_db: aiosqlite.Connection | None = None
|
_db: aiosqlite.Connection | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -62,19 +85,35 @@ async def get_db() -> aiosqlite.Connection:
|
|||||||
|
|
||||||
async def init_db():
|
async def init_db():
|
||||||
db = await get_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_GUIDES)
|
||||||
await db.execute(CREATE_PROGRESS)
|
await db.execute(CREATE_PROGRESS)
|
||||||
await db.execute(CREATE_TOPICS)
|
await db.execute(CREATE_TOPICS)
|
||||||
await db.execute(CREATE_ELEMENTS)
|
await db.execute(CREATE_ELEMENTS)
|
||||||
|
await db.execute(CREATE_BAUSTEIN_TEXTE)
|
||||||
|
await db.execute(CREATE_BAUSTEIN_PROGRESS)
|
||||||
try: # Migration für Bestands-DBs ohne step-Spalte
|
try: # Migration für Bestands-DBs ohne step-Spalte
|
||||||
await db.execute("ALTER TABLE guides ADD COLUMN step INTEGER")
|
await db.execute("ALTER TABLE guides ADD COLUMN step INTEGER")
|
||||||
except aiosqlite.OperationalError:
|
except aiosqlite.OperationalError:
|
||||||
pass
|
pass
|
||||||
for col in ("aufgabe", "loesung"): # Migration für Elemente ohne Aufgabe/Lösung
|
try: # Migration für Bestands-DBs ohne verstanden-Spalte (Mastery-Stufe)
|
||||||
try:
|
await db.execute("ALTER TABLE baustein_progress ADD COLUMN verstanden TEXT")
|
||||||
await db.execute(f"ALTER TABLE elements ADD COLUMN {col} TEXT NOT NULL DEFAULT ''")
|
except aiosqlite.OperationalError:
|
||||||
except aiosqlite.OperationalError:
|
pass
|
||||||
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
|
||||||
|
# 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(
|
await db.execute(
|
||||||
"UPDATE guides SET status = 'error', progress = NULL, error_msg = 'Server-Neustart' "
|
"UPDATE guides SET status = 'error', progress = NULL, error_msg = 'Server-Neustart' "
|
||||||
"WHERE status IN ('queued', 'generating')"
|
"WHERE status IN ('queued', 'generating')"
|
||||||
@@ -173,8 +212,8 @@ def _element_row(row, cursor) -> dict:
|
|||||||
async def create_element(element: dict) -> dict:
|
async def create_element(element: dict) -> dict:
|
||||||
db = await get_db()
|
db = await get_db()
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""INSERT INTO elements (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, :aufgabe, :loesung, :created_at, :updated_at)""",
|
VALUES (:id, :topic, :title, :description, :examples, :hints, :created_at, :updated_at)""",
|
||||||
{**element, "examples": json.dumps(element["examples"], ensure_ascii=False),
|
{**element, "examples": json.dumps(element["examples"], ensure_ascii=False),
|
||||||
"hints": json.dumps(element["hints"], ensure_ascii=False)},
|
"hints": json.dumps(element["hints"], ensure_ascii=False)},
|
||||||
)
|
)
|
||||||
@@ -220,6 +259,17 @@ async def delete_element(element_id: str) -> bool:
|
|||||||
|
|
||||||
# --- Kapitel-Fortschritt ---
|
# --- 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]:
|
async def list_progress(guide_id: str) -> list[str]:
|
||||||
db = await get_db()
|
db = await get_db()
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
@@ -248,3 +298,142 @@ async def delete_progress(guide_id: str) -> None:
|
|||||||
db = await get_db()
|
db = await get_db()
|
||||||
await db.execute("DELETE FROM guide_progress WHERE guide_id = ?", (guide_id,))
|
await db.execute("DELETE FROM guide_progress WHERE guide_id = ?", (guide_id,))
|
||||||
await db.commit()
|
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 get_vertiefung(topic: str, baustein: str, art: str) -> str | None:
|
||||||
|
db = await get_db()
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT md FROM baustein_texte WHERE topic = ? AND baustein = ? AND art = ?",
|
||||||
|
(topic, baustein, art),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def set_vertiefung(topic: str, baustein: str, art: str, md: str) -> None:
|
||||||
|
db = await get_db()
|
||||||
|
now = _now()
|
||||||
|
await db.execute(
|
||||||
|
"""INSERT INTO baustein_texte (topic, baustein, art, md, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(topic, baustein, art) DO UPDATE SET md = excluded.md, updated_at = excluded.updated_at""",
|
||||||
|
(topic, baustein, art, md, now, now),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_vertiefungen(topic: str) -> dict[str, set[str]]:
|
||||||
|
"""Baustein-Titel → vorhandene Text-Arten ('vertiefung'/'deepdive')."""
|
||||||
|
db = await get_db()
|
||||||
|
cursor = await db.execute("SELECT baustein, art FROM baustein_texte WHERE topic = ?", (topic,))
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
out: dict[str, set[str]] = {}
|
||||||
|
for baustein, art in rows:
|
||||||
|
out.setdefault(baustein, set()).add(art)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def list_baustein_progress(topic: str) -> list[dict]:
|
||||||
|
db = await get_db()
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT baustein, gute_antworten, absolviert, verstanden, gemeistert FROM baustein_progress WHERE topic = ?", (topic,)
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [{"baustein": b, "gute_antworten": n, "absolviert": a, "verstanden": v, "gemeistert": m} for b, n, a, v, m in rows]
|
||||||
|
|
||||||
|
|
||||||
|
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_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()
|
||||||
|
|||||||
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 ("FullGuide", "Guide", "MiniGuide", "OnePager"):
|
||||||
|
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
682
backend/guide.py
Normal file
682
backend/guide.py
Normal file
@@ -0,0 +1,682 @@
|
|||||||
|
"""Guide-Generierung als Konsens-Pipeline (OnePager hat einen eigenen Weg).
|
||||||
|
|
||||||
|
Auswahl: 5 Agenten (min. 3, Grace) → Code-Voting (Mehrheit = Konsens) →
|
||||||
|
Mapping-Agent sortiert Strittiges → Klärungs-Loop (max. KONSENS_MAX_RUNDEN).
|
||||||
|
Gliederung: 5 Vorschläge (min. 3, Grace) → ein Judge wählt und kombiniert.
|
||||||
|
Schreiben: Writer pro Chunk. Lese-Prüfung: Check→Fix-Loop (max. Runden-Cap),
|
||||||
|
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 logging
|
||||||
|
import math
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from agents import run_agent
|
||||||
|
from bausteine import _pdfs_konvertieren
|
||||||
|
from config import (
|
||||||
|
DEFAULT_PROVIDER, FORMAT_ANTEIL, KONSENS_GRACE, KONSENS_MAX_RUNDEN,
|
||||||
|
TEMPLATES_DIR,
|
||||||
|
)
|
||||||
|
from database import list_guides, update_guide
|
||||||
|
from fsutil import atomic_write_json
|
||||||
|
from jsonio import read_json_file as _json_datei
|
||||||
|
from onepager import _generate_onepager
|
||||||
|
from paths import bausteine_path, guide_content_path, project_dir
|
||||||
|
from pipeline import (
|
||||||
|
CANCELLED, FAILED, GenContext, _claude_error, _extra,
|
||||||
|
_fail, _gather_error, _log, _prompt, _race, _rest_schema, _runde_schema,
|
||||||
|
_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, _zuteilung_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
log = logging.getLogger("creator.guide")
|
||||||
|
|
||||||
|
GUIDE_STEPS = ("Auswahl", "Gliederung", "Schreiben", "Lese-Prüfung")
|
||||||
|
|
||||||
|
# Writer skalieren mit der Section-Zahl: 1 Writer je ~30 Sections (gedeckelt).
|
||||||
|
# Kleine Pakete vermeiden Lazy-Output bei langen Listen und begrenzen den Schaden
|
||||||
|
# eines fehlgeschlagenen Writers.
|
||||||
|
WRITER_SECTIONS = 30
|
||||||
|
WRITER_MAX = 20
|
||||||
|
|
||||||
|
|
||||||
|
def _guide_files(content_path: Path) -> dict:
|
||||||
|
d, stem = content_path.parent, content_path.stem
|
||||||
|
runden = range(1, KONSENS_MAX_RUNDEN + 1)
|
||||||
|
return {
|
||||||
|
# Runde 1: 5 volle Auswahl-Vorschläge; Runden 2+: 3 Klärungs-Voten
|
||||||
|
"auswahl_slots": {
|
||||||
|
n: [d / f"{stem}.auswahl-r{n}-{i}.json" for i in range(1, (5 if n == 1 else 3) + 1)]
|
||||||
|
for n in runden
|
||||||
|
},
|
||||||
|
"auswahl_mapping": {n: d / f"{stem}.auswahl-mapping-r{n}.json" for n in runden},
|
||||||
|
"gliederung_slots": [d / f"{stem}.gliederung-{i}.json" for i in (1, 2, 3, 4, 5)],
|
||||||
|
"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 _resolve_auswahl(data, entries: dict[int, str], k_min: int, k_max: int) -> list[int] | None:
|
||||||
|
"""{"bausteine": [Titel]} → Nummern; None bei Schema-Verstoß/Drift/falschem Umfang."""
|
||||||
|
if not isinstance(data, dict) or not isinstance(data.get("bausteine"), list):
|
||||||
|
return None
|
||||||
|
idx = _titel_index(entries)
|
||||||
|
nums: list[int] = []
|
||||||
|
seen: set[int] = set()
|
||||||
|
total = unknown = 0
|
||||||
|
for t in data["bausteine"]:
|
||||||
|
total += 1
|
||||||
|
num = _titel_aufloesen(idx, t) if isinstance(t, str) else None
|
||||||
|
if num is None:
|
||||||
|
unknown += 1
|
||||||
|
elif num not in seen:
|
||||||
|
seen.add(num)
|
||||||
|
nums.append(num)
|
||||||
|
if total == 0 or (total - unknown) / total < 0.85:
|
||||||
|
return None
|
||||||
|
if len(nums) < 0.9 * k_min or len(nums) > 1.1 * k_max:
|
||||||
|
return None
|
||||||
|
return nums
|
||||||
|
|
||||||
|
|
||||||
|
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 _resolve_gliederung(data, entries: dict[int, str], soll_min: int, soll_max: int) -> list[dict] | None:
|
||||||
|
"""{"kapitel": [{"titel", "bausteine": [Titel]}]} → [{"title", "nums"}].
|
||||||
|
|
||||||
|
`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
|
||||||
|
idx = _titel_index(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("bausteine"), list):
|
||||||
|
return None
|
||||||
|
nums = []
|
||||||
|
for t in ch["bausteine"]:
|
||||||
|
total += 1
|
||||||
|
num = _titel_aufloesen(idx, t) if isinstance(t, str) else None
|
||||||
|
if num is None:
|
||||||
|
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 _voting(stimmen: list[list[int]]) -> tuple[list[int], dict[int, int]]:
|
||||||
|
"""Mehrheit (> Hälfte der Stimmen) → Konsens; ≥1 Stimme → Rest mit Votenzahl."""
|
||||||
|
zaehler: dict[int, int] = {}
|
||||||
|
for stimme in stimmen:
|
||||||
|
for num in stimme:
|
||||||
|
zaehler[num] = zaehler.get(num, 0) + 1
|
||||||
|
konsens = sorted(num for num, v in zaehler.items() if v > len(stimmen) / 2)
|
||||||
|
rest = {num: v for num, v in sorted(zaehler.items()) if v <= len(stimmen) / 2}
|
||||||
|
return konsens, rest
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_uebernehmen(data, entries: dict[int, str]) -> list[int] | None:
|
||||||
|
"""{"uebernehmen": [Titel]} → Nummern; leer gültig; >15 % unauflösbar → None."""
|
||||||
|
titel = _rest_schema(data)
|
||||||
|
if titel is None:
|
||||||
|
return None
|
||||||
|
if not titel:
|
||||||
|
return []
|
||||||
|
idx = _titel_index(entries)
|
||||||
|
nums: list[int] = []
|
||||||
|
seen: set[int] = set()
|
||||||
|
unknown = 0
|
||||||
|
for t in titel:
|
||||||
|
num = _titel_aufloesen(idx, t)
|
||||||
|
if num is None:
|
||||||
|
unknown += 1
|
||||||
|
elif num not in seen:
|
||||||
|
seen.add(num)
|
||||||
|
nums.append(num)
|
||||||
|
if unknown / len(titel) > 0.15:
|
||||||
|
return None
|
||||||
|
return nums
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_runde(data, entries: dict[int, str], konsens: list[int], k_min: int, k_max: int, final: bool) -> tuple[list[int], list[int]] | None:
|
||||||
|
"""Auswahl-Mapping-Runde auflösen — erzwingt die Zielgrößen-Grenzen schema-seitig.
|
||||||
|
|
||||||
|
Immer: Konsens + Aufnehmen + Rest muss 0.9*k_min erreichen können (sonst
|
||||||
|
wäre die Mindestgröße in späteren Runden unerreichbar). Aufnehmen über
|
||||||
|
1.1*k_max hinaus ist ungültig; final erzwingt zusätzlich leeren Rest und
|
||||||
|
die Mindestgröße. Ein bereits zu großer Konsens allein ist kein Fehler —
|
||||||
|
der Agent kann dann nichts mehr aufnehmen.
|
||||||
|
"""
|
||||||
|
res = _runde_schema(data, final=final)
|
||||||
|
if res is None:
|
||||||
|
return None
|
||||||
|
idx = _titel_index(entries)
|
||||||
|
bekannt = set(konsens)
|
||||||
|
listen: list[list[int]] = []
|
||||||
|
for titel_liste in res:
|
||||||
|
nums: list[int] = []
|
||||||
|
unknown = 0
|
||||||
|
for t in titel_liste:
|
||||||
|
num = _titel_aufloesen(idx, t)
|
||||||
|
if num is None:
|
||||||
|
unknown += 1
|
||||||
|
elif num not in bekannt:
|
||||||
|
bekannt.add(num)
|
||||||
|
nums.append(num)
|
||||||
|
if titel_liste and unknown / len(titel_liste) > 0.15:
|
||||||
|
return None
|
||||||
|
listen.append(nums)
|
||||||
|
aufnehmen, rest = listen
|
||||||
|
gesamt = len(konsens) + len(aufnehmen)
|
||||||
|
if aufnehmen and gesamt > 1.1 * k_max:
|
||||||
|
return None
|
||||||
|
if gesamt + len(rest) < 0.9 * k_min:
|
||||||
|
return None
|
||||||
|
if (final or not rest) and gesamt < 0.9 * k_min:
|
||||||
|
return None
|
||||||
|
return aufnehmen, rest
|
||||||
|
|
||||||
|
|
||||||
|
async def _konsens_auswahl(
|
||||||
|
ctx: GenContext, files: dict, entries: dict[int, str],
|
||||||
|
k_min: int, k_max: int, auswahl_auftrag: str, format_name: str,
|
||||||
|
bausteine_liste: str, instructions: str,
|
||||||
|
) -> list[int] | None:
|
||||||
|
"""Schritt 0: 5 Auswahl-Agenten → Code-Voting → Mapping → Klärungs-Loop.
|
||||||
|
|
||||||
|
Rückgabe: finale Baustein-Nummern; None = Fehler/Abbruch (bereits gemeldet).
|
||||||
|
"""
|
||||||
|
guide_id, topic, provider = ctx.guide_id, ctx.topic, ctx.provider
|
||||||
|
is_cancelled = ctx.is_cancelled
|
||||||
|
n = len(entries)
|
||||||
|
|
||||||
|
def titel_liste(nums) -> str:
|
||||||
|
return "\n".join(f"- {_titel(entries[num])}" for num in nums)
|
||||||
|
|
||||||
|
konsens: list[int] = []
|
||||||
|
rest: list[int] = []
|
||||||
|
runde = 0
|
||||||
|
while True:
|
||||||
|
runde += 1
|
||||||
|
final_runde = runde == KONSENS_MAX_RUNDEN
|
||||||
|
|
||||||
|
# Voten der Runde einsammeln — Slot-Dateien zuerst (Resume), Rest per Race
|
||||||
|
if runde == 1:
|
||||||
|
await _set_step(guide_id, 0, "Wähle Bausteine (5 Vorschläge)…")
|
||||||
|
stimmen: list[list[int]] = []
|
||||||
|
offen = []
|
||||||
|
for i, path in enumerate(files["auswahl_slots"][1], 1):
|
||||||
|
res = _resolve_auswahl(_json_datei(path), entries, k_min, k_max)
|
||||||
|
if res is not None:
|
||||||
|
stimmen.append(res)
|
||||||
|
else:
|
||||||
|
offen.append((i, path))
|
||||||
|
if len(stimmen) < 3:
|
||||||
|
slots = [
|
||||||
|
{
|
||||||
|
"key": f"{guide_id}-auswahl-r1-{i}",
|
||||||
|
"prompt": _prompt(
|
||||||
|
"Guide-Auswahl",
|
||||||
|
topic=topic, format_name=format_name, bausteine=bausteine_liste,
|
||||||
|
auswahl_auftrag=auswahl_auftrag, out_path=path, extra=_extra(instructions),
|
||||||
|
),
|
||||||
|
"role": "guide", "capabilities": "files",
|
||||||
|
"payload": (lambda result, p=path: _resolve_auswahl(_json_datei(p), entries, k_min, k_max)),
|
||||||
|
}
|
||||||
|
for i, path in offen
|
||||||
|
]
|
||||||
|
neue = await _race(
|
||||||
|
topic, "Guide-Auswahl", slots, 3 - len(stimmen), _timeout("guide_auswahl", n),
|
||||||
|
provider, cancelled=is_cancelled, grace=KONSENS_GRACE,
|
||||||
|
)
|
||||||
|
if is_cancelled():
|
||||||
|
return None
|
||||||
|
if neue is None:
|
||||||
|
await _fail(guide_id, "Auswahl fehlgeschlagen (Minimum nicht erreicht)")
|
||||||
|
return None
|
||||||
|
stimmen += neue
|
||||||
|
konsens, voten = _voting(stimmen)
|
||||||
|
rest = list(voten)
|
||||||
|
stimmen_n = len(stimmen)
|
||||||
|
else:
|
||||||
|
await _set_step(guide_id, 0, f"Kläre strittige Bausteine (Runde {runde}/{KONSENS_MAX_RUNDEN})…")
|
||||||
|
entscheidungen: list[list[int]] = []
|
||||||
|
offen = []
|
||||||
|
for i, path in enumerate(files["auswahl_slots"][runde], 1):
|
||||||
|
res = _resolve_uebernehmen(_json_datei(path), entries)
|
||||||
|
if res is not None:
|
||||||
|
entscheidungen.append(res)
|
||||||
|
else:
|
||||||
|
offen.append((i, path))
|
||||||
|
if len(entscheidungen) < 2:
|
||||||
|
slots = [
|
||||||
|
{
|
||||||
|
"key": f"{guide_id}-auswahl-r{runde}-{i}",
|
||||||
|
"prompt": _prompt(
|
||||||
|
"Guide-Klaerung",
|
||||||
|
topic=topic, format_name=format_name, auswahl_auftrag=auswahl_auftrag,
|
||||||
|
konsens=titel_liste(konsens) or "- (leer)", rest=titel_liste(rest),
|
||||||
|
out_path=path, extra=_extra(instructions),
|
||||||
|
),
|
||||||
|
"role": "fast", "capabilities": "files",
|
||||||
|
"payload": (lambda result, p=path: _resolve_uebernehmen(_json_datei(p), entries)),
|
||||||
|
}
|
||||||
|
for i, path in offen
|
||||||
|
]
|
||||||
|
neue = await _race(
|
||||||
|
topic, f"Guide-Klärung r{runde}", slots, 2 - len(entscheidungen),
|
||||||
|
_timeout("auswahl", len(rest)), provider, cancelled=is_cancelled, grace=KONSENS_GRACE,
|
||||||
|
)
|
||||||
|
if is_cancelled():
|
||||||
|
return None
|
||||||
|
if neue is None:
|
||||||
|
await _fail(guide_id, f"Auswahl fehlgeschlagen (Runde {runde}, Minimum nicht erreicht)")
|
||||||
|
return None
|
||||||
|
entscheidungen += neue
|
||||||
|
voten = {num: sum(1 for e in entscheidungen if num in e) for num in rest}
|
||||||
|
stimmen_n = len(entscheidungen)
|
||||||
|
|
||||||
|
# Mapping-Agent sortiert die strittigen Voten — gültige Datei = Resume
|
||||||
|
mapping_path = files["auswahl_mapping"][runde]
|
||||||
|
ergebnis = _resolve_runde(_json_datei(mapping_path), entries, konsens, k_min, k_max, final_runde)
|
||||||
|
if ergebnis is None:
|
||||||
|
mapping_path.unlink(missing_ok=True)
|
||||||
|
voten_block = "\n".join(
|
||||||
|
f"{i}. {_titel(entries[num])} (von {voten[num]}/{stimmen_n} Agenten gewählt)"
|
||||||
|
for i, num in enumerate(rest, 1)
|
||||||
|
) or "- (keine)"
|
||||||
|
final_zusatz = (
|
||||||
|
"\n- LETZTE RUNDE: Es gibt keine weitere Runde. `rest` MUSS leer sein"
|
||||||
|
" — entscheide JEDEN Eintrag selbst: aufnehmen oder verwerfen."
|
||||||
|
if final_runde else ""
|
||||||
|
)
|
||||||
|
status, ergebnis = await run_single_slot(
|
||||||
|
ctx, f"Auswahl-Mapping r{runde}",
|
||||||
|
key=f"{guide_id}-auswahl-mapping-r{runde}",
|
||||||
|
prompt=_prompt(
|
||||||
|
"Guide-Auswahl-Mapping",
|
||||||
|
topic=topic, format_name=format_name, n=stimmen_n,
|
||||||
|
auswahl_auftrag=auswahl_auftrag, konsens_n=len(konsens),
|
||||||
|
k_min=k_min, k_max=k_max,
|
||||||
|
konsens=titel_liste(konsens) or "- (leer)", rest=voten_block,
|
||||||
|
final=final_zusatz, out_path=mapping_path,
|
||||||
|
),
|
||||||
|
role="judge", capabilities="files",
|
||||||
|
payload=lambda result, p=mapping_path, k=tuple(konsens), f=final_runde:
|
||||||
|
_resolve_runde(_json_datei(p), entries, list(k), k_min, k_max, f),
|
||||||
|
timeout=_timeout("auswahl_mapping", len(konsens) + len(rest)),
|
||||||
|
)
|
||||||
|
if status == CANCELLED:
|
||||||
|
return None
|
||||||
|
if status == FAILED:
|
||||||
|
await _fail(guide_id, f"Auswahl-Mapping fehlgeschlagen (Runde {runde})")
|
||||||
|
return None
|
||||||
|
|
||||||
|
aufnehmen, rest = ergebnis
|
||||||
|
konsens = konsens + aufnehmen
|
||||||
|
_log(topic, f"Auswahl Runde {runde}: {len(aufnehmen)} aufgenommen, {len(rest)} strittig, Konsens {len(konsens)}")
|
||||||
|
if not rest or final_runde:
|
||||||
|
return konsens
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
bausteine_liste = "\n".join(f"- {t}" for t in entries.values())
|
||||||
|
n = len(entries)
|
||||||
|
anteil_min, anteil_max, minimum, zweck = FORMAT_ANTEIL[format_name]
|
||||||
|
k_min = min(n, max(minimum, math.ceil(anteil_min * n)))
|
||||||
|
k_max = min(n, max(k_min, math.floor(anteil_max * n)))
|
||||||
|
auswahl_auftrag = (
|
||||||
|
f"Wähle MINDESTENS {k_min} und HÖCHSTENS {k_max} der Bausteine und baue daraus {zweck}. "
|
||||||
|
"Wähle, was diesem Zweck dient — lass weg, was dafür nicht nötig ist."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Schritt 0: Auswahl-Konsens (5 Agenten → Voting → Mapping → Klärungs-Loop)
|
||||||
|
auswahl = await _konsens_auswahl(
|
||||||
|
ctx, files, entries, k_min, k_max, auswahl_auftrag, format_name,
|
||||||
|
bausteine_liste, instructions,
|
||||||
|
)
|
||||||
|
if auswahl is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
sel_entries = {num: entries[num] for num in auswahl}
|
||||||
|
soll = len(sel_entries)
|
||||||
|
sel_liste = "\n".join(f"- {t}" for t in sel_entries.values())
|
||||||
|
|
||||||
|
# Schritt 1: Gliederung — 5 Vorschläge (min. 3, Grace), ein Judge wählt.
|
||||||
|
# Gültiges gliederung.json (auch aus Altläufen) überspringt den Schritt.
|
||||||
|
plan = _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll)
|
||||||
|
if plan is None:
|
||||||
|
await _set_step(guide_id, 1, "Gliederungs-Vorschläge (5 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:
|
||||||
|
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
|
||||||
|
]
|
||||||
|
neue = await _race(
|
||||||
|
topic, "Gliederung", slots, 3 - len(vorschlaege), _timeout("plan", soll),
|
||||||
|
provider, cancelled=is_cancelled, grace=KONSENS_GRACE,
|
||||||
|
)
|
||||||
|
if is_cancelled():
|
||||||
|
return None
|
||||||
|
if neue is None:
|
||||||
|
await _fail(guide_id, "Gliederung fehlgeschlagen (Minimum nicht erreicht)")
|
||||||
|
return None
|
||||||
|
vorschlaege += neue
|
||||||
|
|
||||||
|
await _set_step(guide_id, 1, "Wähle beste Gliederung…")
|
||||||
|
bloecke = "\n\n".join(
|
||||||
|
f"### Vorschlag {i}\n"
|
||||||
|
+ "\n".join(_zuteilung_text([ch], {num: _titel(entries[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:
|
||||||
|
await _fail(guide_id, "Gliederung fehlgeschlagen (Judge ohne gültiges Ergebnis)")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Schritt 2: Schreiben — vorhandene Chunk-Dateien werden übernommen (Resume)
|
||||||
|
total_sections = sum(len(c["nums"]) for c in plan)
|
||||||
|
chunks = _split_chunks(plan, min(WRITER_MAX, max(1, math.ceil(total_sections / WRITER_SECTIONS))))
|
||||||
|
zuteilungen = [_zuteilung_text(chunk, entries) for chunk in chunks]
|
||||||
|
chunk_sizes = [sum(len(c["nums"]) for c in chunk) for chunk in chunks]
|
||||||
|
writer_count = len(zuteilungen)
|
||||||
|
paths = [content_path.parent / f"{content_path.stem}.chunk-{i}.md" for i in range(1, writer_count + 1)]
|
||||||
|
offen = [i for i, p in enumerate(paths) if not p.exists()]
|
||||||
|
if offen:
|
||||||
|
await _set_step(guide_id, 2, f"Schreibe Sections ({writer_count} Writer)…" if writer_count > 1 else "Schreibe Sections…")
|
||||||
|
results = await asyncio.gather(*[
|
||||||
|
run_agent(
|
||||||
|
f"{guide_id}-w{i + 1}",
|
||||||
|
_prompt(
|
||||||
|
"Guide-Writer",
|
||||||
|
topic=topic, format_name=format_name, zuteilung=zuteilungen[i],
|
||||||
|
facts=facts, spec=spec, out_path=paths[i], extra=_extra(instructions),
|
||||||
|
),
|
||||||
|
_timeout("writer", chunk_sizes[i]), provider=provider, role="guide", capabilities="full",
|
||||||
|
)
|
||||||
|
for i in offen
|
||||||
|
], return_exceptions=True)
|
||||||
|
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
|
||||||
|
|
||||||
|
idx = _titel_index(entries)
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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, KONSENS_MAX_RUNDEN + 1):
|
||||||
|
check_paths = [content_path.parent / f"{content_path.stem}.lese-check-r{runde}-{i}.json" for i in range(1, writer_count + 1)]
|
||||||
|
offen_checks = [i for i, p in enumerate(check_paths) if scope[i] and _lese_probleme_schema(_json_datei(p)) is None]
|
||||||
|
if offen_checks:
|
||||||
|
await _set_step(guide_id, 3, f"Prüfe Lesbarkeit (Runde {runde}/{KONSENS_MAX_RUNDEN})…")
|
||||||
|
slots = [{
|
||||||
|
"key": f"{guide_id}-lese-check-r{runde}-{i + 1}",
|
||||||
|
"prompt": _prompt(
|
||||||
|
"Guide-Lese-Check",
|
||||||
|
topic=topic, format_name=format_name, spec=spec,
|
||||||
|
sections=sections_text(scope[i]),
|
||||||
|
out_path=check_paths[i], extra=_extra(instructions),
|
||||||
|
),
|
||||||
|
"role": "judge", "capabilities": "files",
|
||||||
|
"payload": (lambda result, p=check_paths[i]: _lese_probleme_schema(_json_datei(p))),
|
||||||
|
} for i in offen_checks]
|
||||||
|
res = await _race(topic, f"Lese-Prüfung r{runde}", slots, len(slots), _timeout("lese_check", max(chunk_sizes)), provider, cancelled=is_cancelled)
|
||||||
|
if is_cancelled():
|
||||||
|
return None
|
||||||
|
if res is None:
|
||||||
|
if runde == 1:
|
||||||
|
await _fail(guide_id, "Lese-Prüfung fehlgeschlagen")
|
||||||
|
return None
|
||||||
|
_log(topic, f"Lese-Prüfung Runde {runde} fehlgeschlagen — Stand bleibt")
|
||||||
|
break
|
||||||
|
|
||||||
|
probleme_by_num: dict[int, str] = {}
|
||||||
|
for i, p in enumerate(check_paths):
|
||||||
|
geltung = set(scope[i])
|
||||||
|
for item in (_lese_probleme_schema(_json_datei(p)) or []):
|
||||||
|
num = _titel_aufloesen(idx, item["section"])
|
||||||
|
if num in geltung and num in by_num and num not in probleme_by_num:
|
||||||
|
probleme_by_num[num] = item["problem"]
|
||||||
|
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, 3, 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 == KONSENS_MAX_RUNDEN:
|
||||||
|
_log(topic, f"Lese-Prüfung: Cap erreicht — letzte Überarbeitung bleibt ungeprüft")
|
||||||
|
break
|
||||||
|
scope = [[num for num in nums if num in ersetzt] for nums in chunk_nums]
|
||||||
|
|
||||||
|
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"]}
|
||||||
|
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) -> 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 = project_dir(topic) if project_dir(topic).is_dir() else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if is_guide_cancelled(guide_id):
|
||||||
|
return
|
||||||
|
|
||||||
|
if project:
|
||||||
|
await asyncio.to_thread(_pdfs_konvertieren, project)
|
||||||
|
|
||||||
|
# „Neu erstellen": fertiger Guide → kompletter Frischstart.
|
||||||
|
# Sonst sind Schritt-Dateien Reste eines Abbruchs/Fehlers → Resume.
|
||||||
|
if content_path.exists():
|
||||||
|
for p_alt in guide_slot_dateien(content_path):
|
||||||
|
p_alt.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
if format_name == "OnePager":
|
||||||
|
chapters = await _generate_onepager(guide_id, topic, instructions, provider, project, content_path)
|
||||||
|
else:
|
||||||
|
alle = _lade_bausteine(bausteine_path(topic).read_text(encoding="utf-8"))
|
||||||
|
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
|
||||||
|
|
||||||
|
atomic_write_json(content_path, {"topic": topic, "format": format_name, "chapters": chapters}, indent=1)
|
||||||
|
|
||||||
|
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
|
||||||
307
backend/lernen.py
Normal file
307
backend/lernen.py
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
"""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 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")
|
||||||
|
|
||||||
|
NOETIG = 3 # gute Antworten bis "absolviert" (Tier 1)
|
||||||
|
MASTERY = 10 # Score bis "verstanden" (Tier 2)
|
||||||
|
MEISTERN = 25 # Score bis "gemeistert" (Tier 3, Maximum)
|
||||||
|
VERTIEFUNG_TIMEOUT = 600
|
||||||
|
CHAT_TIMEOUT = 240
|
||||||
|
PRUEFUNG_TIMEOUT = 120 # kurze JSON-Turns; deckelt die Serien-Latenz pro Prüfungs-Schritt
|
||||||
|
KRITIK_MAX_RUNDEN = 2 # Generator → Kritiker → ggf. Neu, höchstens so oft
|
||||||
|
|
||||||
|
|
||||||
|
def score_berechnen(
|
||||||
|
score_vor_frage: int, gut: bool, tier2: bool, tier3: bool, absolviert: bool, gemeistert: bool,
|
||||||
|
) -> int:
|
||||||
|
"""Neuer Score nach einer Antwort · driftfrei (immer aus dem Basis-Score gerechnet).
|
||||||
|
|
||||||
|
Drei Stufen, freigeschaltet über Guide-Flags:
|
||||||
|
- Tier 1 (tier2=False): +1 bei richtig, KEINE Strafe, Deckel NOETIG (3).
|
||||||
|
- Tier 2 (tier2, nicht tier3): +1 / −1, Boden 3, Deckel MASTERY (10).
|
||||||
|
- Tier 3 (tier3, Meisterpfad): +1 / −2, Boden 10, Deckel MEISTERN (25).
|
||||||
|
Boden vor dem Absolvieren ist 0 (sonst NOETIG — absolviert bleibt erhalten).
|
||||||
|
Ist der Baustein gemeistert, friert der Score bei MEISTERN ein (keine Punkte mehr).
|
||||||
|
Re-Bewertung nutzt denselben Basis-Score und ersetzt das vorige Ergebnis.
|
||||||
|
"""
|
||||||
|
if gemeistert:
|
||||||
|
return MEISTERN
|
||||||
|
if not tier2:
|
||||||
|
delta, floor, cap = (1 if gut else 0), (NOETIG if absolviert else 0), NOETIG
|
||||||
|
elif not tier3:
|
||||||
|
delta, floor, cap = (1 if gut else -1), NOETIG, MASTERY
|
||||||
|
else:
|
||||||
|
delta, floor, cap = (1 if gut else -2), MASTERY, MEISTERN
|
||||||
|
return max(floor, min(cap, score_vor_frage + delta))
|
||||||
|
|
||||||
|
|
||||||
|
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 vertiefung_generieren(topic: str, baustein: str, section: str, art: str = "vertiefung", provider: str = DEFAULT_PROVIDER) -> str | None:
|
||||||
|
"""Ausführlichere Fassung des Bausteins als Markdown · None bei Fehler.
|
||||||
|
|
||||||
|
art "vertiefung" = gleicher Stoff, nur umfangreicher;
|
||||||
|
"deepdive" = Label „Amateur": gleicher Stoff, für Einsteiger aufbereitet.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
prompt = _prompt(
|
||||||
|
"Baustein-Deepdive" if art == "deepdive" else "Baustein-Vertiefung",
|
||||||
|
topic=topic, baustein=baustein,
|
||||||
|
section_block=section.strip() or "(keine Guide-Fassung übergeben)",
|
||||||
|
)
|
||||||
|
returncode, stdout, _ = await run_agent(
|
||||||
|
"vertiefung-" + str(uuid.uuid4()), prompt, VERTIEFUNG_TIMEOUT,
|
||||||
|
provider=provider, role="fast", capabilities="none", lane="interactive",
|
||||||
|
)
|
||||||
|
if returncode != 0:
|
||||||
|
return None
|
||||||
|
return stdout.strip() or None
|
||||||
|
except Exception:
|
||||||
|
log.warning("[%s] Vertiefung fehlgeschlagen (%s)", topic, baustein, exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def baustein_chat(topic: str, baustein: str, section: str, vertiefung: 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)",
|
||||||
|
vertiefung_block=(vertiefung 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, "bewertung": "gut"|"schlecht", "bestanden": bool} · sonst None."""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
feedback = str(data.get("feedback", "")).strip()
|
||||||
|
bewertung = data.get("bewertung")
|
||||||
|
if not feedback or bewertung not in ("gut", "schlecht"):
|
||||||
|
return None
|
||||||
|
return {"feedback": feedback, "bewertung": bewertung, "bestanden": data.get("bestanden") is True}
|
||||||
|
|
||||||
|
|
||||||
|
async def _gen_call(name: str, role: str, schema, provider: str, **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), PRUEFUNG_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, **kwargs) -> list[str]:
|
||||||
|
"""Kritiker-Agent (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), PRUEFUNG_TIMEOUT,
|
||||||
|
provider=provider, role="judge", 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"Bewertung: {bew['bewertung']}\nFeedback: {bew['feedback']}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _frage_mit_kritik(
|
||||||
|
topic: str, baustein: str, section_block: str, vertiefung_block: str,
|
||||||
|
transcript: 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,
|
||||||
|
vertiefung_block=vertiefung_block, transcript=transcript, kritik_block=kritik_block,
|
||||||
|
)
|
||||||
|
if data is None:
|
||||||
|
return None
|
||||||
|
frage = data["frage"]
|
||||||
|
probleme = await _kritik_call(
|
||||||
|
"Baustein-Frage-Kritik", provider,
|
||||||
|
topic=topic, baustein=baustein, section_block=section_block,
|
||||||
|
vertiefung_block=vertiefung_block, transcript=transcript, frage=frage,
|
||||||
|
)
|
||||||
|
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, vertiefung_block: str,
|
||||||
|
frage: str, transcript: str, gute_antworten: int, provider: str,
|
||||||
|
) -> dict | None:
|
||||||
|
"""Antwort bewerten (gut/schlecht), vom Kritiker prüfen lassen, bei Fehlurteil neu.
|
||||||
|
|
||||||
|
`frage` ankert die geprüfte Frage; der Dialog (transcript) liefert die Antwort und
|
||||||
|
eine etwaige Diskussion — so kann eine Re-Bewertung das Argument sehen.
|
||||||
|
"""
|
||||||
|
kritik_block = "(keine)"
|
||||||
|
bew = None
|
||||||
|
for _ in range(KRITIK_MAX_RUNDEN):
|
||||||
|
bew = await _gen_call(
|
||||||
|
"Baustein-Bewertung", "judge", _bewertung_schema, provider,
|
||||||
|
topic=topic, baustein=baustein, section_block=section_block,
|
||||||
|
vertiefung_block=vertiefung_block, frage=frage, transcript=transcript,
|
||||||
|
gute_antworten=gute_antworten, noetig=NOETIG, kritik_block=kritik_block,
|
||||||
|
)
|
||||||
|
if bew is None:
|
||||||
|
return None
|
||||||
|
probleme = await _kritik_call(
|
||||||
|
"Baustein-Bewertung-Kritik", provider,
|
||||||
|
topic=topic, baustein=baustein, section_block=section_block,
|
||||||
|
vertiefung_block=vertiefung_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, vertiefung: str | None) -> tuple[str, str]:
|
||||||
|
return (
|
||||||
|
section.strip() or "(keine Guide-Fassung übergeben)",
|
||||||
|
(vertiefung or "").strip() or "(keine)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def pruefung_frage(
|
||||||
|
topic: str, baustein: str, section: str, vertiefung: str | None,
|
||||||
|
messages: list[dict], provider: str = DEFAULT_PROVIDER,
|
||||||
|
) -> str | None:
|
||||||
|
"""Aktion 'frage': nächste Frage generieren (Generator + Kritiker) · None bei Fehler."""
|
||||||
|
try:
|
||||||
|
section_block, vertiefung_block = _bloecke(section, vertiefung)
|
||||||
|
transcript = _transcript(messages) if messages else "(leer)"
|
||||||
|
return await _frage_mit_kritik(topic, baustein, section_block, vertiefung_block, transcript, provider)
|
||||||
|
except Exception:
|
||||||
|
log.warning("[%s] Frage fehlgeschlagen (%s)", topic, baustein, exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def pruefung_bewertung(
|
||||||
|
topic: str, baustein: str, section: str, vertiefung: str | None,
|
||||||
|
frage: str, messages: list[dict], gute_antworten: int, provider: str = DEFAULT_PROVIDER,
|
||||||
|
) -> dict | None:
|
||||||
|
"""Aktion 'antwort': Antwort bewerten (Evaluator + Kritiker).
|
||||||
|
|
||||||
|
Gibt {"feedback", "bewertung": gut|schlecht, "bestanden"} · None bei Fehler.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
section_block, vertiefung_block = _bloecke(section, vertiefung)
|
||||||
|
transcript = _transcript(messages) if messages else "(leer)"
|
||||||
|
return await _bewertung_mit_kritik(
|
||||||
|
topic, baustein, section_block, vertiefung_block,
|
||||||
|
frage.strip() or "(keine Frage übergeben)", transcript, gute_antworten, provider,
|
||||||
|
)
|
||||||
|
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, vertiefung: 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, vertiefung_block = _bloecke(section, vertiefung)
|
||||||
|
prompt = _prompt(
|
||||||
|
"Baustein-Pruefung-Diskussion",
|
||||||
|
topic=topic, baustein=baustein,
|
||||||
|
section_block=section_block, vertiefung_block=vertiefung_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)
|
||||||
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 import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from logsetup import setup_logging
|
||||||
|
|
||||||
|
setup_logging()
|
||||||
|
|
||||||
from config import FRONTEND_DIST, STORAGE_DIR
|
from config import FRONTEND_DIST, STORAGE_DIR
|
||||||
from database import init_db, close_db
|
from database import init_db, close_db
|
||||||
|
from guide import reconcile_guides
|
||||||
from routes import router
|
from routes import router
|
||||||
|
|
||||||
|
|
||||||
@@ -12,6 +17,7 @@ from routes import router
|
|||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
(STORAGE_DIR / "themen").mkdir(parents=True, exist_ok=True)
|
(STORAGE_DIR / "themen").mkdir(parents=True, exist_ok=True)
|
||||||
await init_db()
|
await init_db()
|
||||||
|
await reconcile_guides()
|
||||||
yield
|
yield
|
||||||
await close_db()
|
await close_db()
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ FormatType = Literal[
|
|||||||
"FullGuide",
|
"FullGuide",
|
||||||
]
|
]
|
||||||
|
|
||||||
ProviderType = Literal["claude", "minimax", "minimax-direkt", "lokal"]
|
ProviderType = Literal["claude", "minimax", "lokal"]
|
||||||
|
|
||||||
|
|
||||||
class GuideCreateRequest(BaseModel):
|
class GuideCreateRequest(BaseModel):
|
||||||
@@ -86,8 +86,6 @@ class ElementResponse(BaseModel):
|
|||||||
description: str = ""
|
description: str = ""
|
||||||
examples: list[str] = []
|
examples: list[str] = []
|
||||||
hints: list[str] = []
|
hints: list[str] = []
|
||||||
aufgabe: str = ""
|
|
||||||
loesung: str = ""
|
|
||||||
created_at: str
|
created_at: str
|
||||||
updated_at: str
|
updated_at: str
|
||||||
|
|
||||||
@@ -103,8 +101,6 @@ class ElementUpdateRequest(BaseModel):
|
|||||||
description: str | None = None
|
description: str | None = None
|
||||||
examples: list[str] | None = None
|
examples: list[str] | None = None
|
||||||
hints: list[str] | None = None
|
hints: list[str] | None = None
|
||||||
aufgabe: str | None = None
|
|
||||||
loesung: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ElementCheckRequest(BaseModel):
|
class ElementCheckRequest(BaseModel):
|
||||||
@@ -113,7 +109,7 @@ class ElementCheckRequest(BaseModel):
|
|||||||
|
|
||||||
class ElementSuggestion(BaseModel):
|
class ElementSuggestion(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
target: Literal["description", "examples", "hints", "aufgabe", "loesung"]
|
target: Literal["description", "examples", "hints"]
|
||||||
content: str
|
content: str
|
||||||
|
|
||||||
|
|
||||||
@@ -124,7 +120,7 @@ class ElementCheckResponse(BaseModel):
|
|||||||
class ElementStyleChange(BaseModel):
|
class ElementStyleChange(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
action: Literal["entfernen", "anpassen", "hinzufuegen"]
|
action: Literal["entfernen", "anpassen", "hinzufuegen"]
|
||||||
target: Literal["title", "description", "examples", "hints", "aufgabe", "loesung"]
|
target: Literal["title", "description", "examples", "hints"]
|
||||||
index: int | None = None
|
index: int | None = None
|
||||||
content: str = ""
|
content: str = ""
|
||||||
|
|
||||||
@@ -160,3 +156,70 @@ class ProgressUpdate(BaseModel):
|
|||||||
|
|
||||||
class ProgressResponse(BaseModel):
|
class ProgressResponse(BaseModel):
|
||||||
chapters: list[str]
|
chapters: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Baustein-Lernen ---
|
||||||
|
|
||||||
|
VertiefungArt = Literal["vertiefung", "deepdive"]
|
||||||
|
|
||||||
|
|
||||||
|
class VertiefungRequest(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)
|
||||||
|
art: VertiefungArt = "vertiefung"
|
||||||
|
provider: ProviderType = "claude"
|
||||||
|
|
||||||
|
|
||||||
|
class VertiefungResponse(BaseModel):
|
||||||
|
md: str
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
aktion: Literal["frage", "diskussion", "antwort"] = "frage"
|
||||||
|
frage: str = Field(default="", max_length=2000) # aktuell geprüfte Frage (für diskussion/antwort)
|
||||||
|
letzte_bewertung: str = Field(default="", max_length=2000) # Feedback der letzten Bewertung (Kontext für diskussion)
|
||||||
|
score_vor_frage: int = 0 # Score, als die Frage gestellt wurde → driftfreies (Re-)Bewerten
|
||||||
|
tier2: bool = False # ganzer Guide absolviert (alle ≥3) → −1 bei falsch, Deckel 10
|
||||||
|
tier3: bool = False # ganzer Guide verstanden (alle ≥10) → Meisterpfad, −2 bei falsch, Deckel 25
|
||||||
|
messages: list[ChatMessage] = [] # Dialog bisher; leer = erste Frage
|
||||||
|
provider: ProviderType = "claude"
|
||||||
|
|
||||||
|
|
||||||
|
class BausteinPruefungResponse(BaseModel):
|
||||||
|
frage: str | None = None
|
||||||
|
reply: str | None = None
|
||||||
|
feedback: str | None = None
|
||||||
|
bewertung: Literal["gut", "schlecht"] | None = None
|
||||||
|
gute_antworten: int
|
||||||
|
absolviert: bool
|
||||||
|
verstanden: bool = False
|
||||||
|
gemeistert: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class BausteinLernstand(BaseModel):
|
||||||
|
gute_antworten: int
|
||||||
|
absolviert: bool
|
||||||
|
verstanden: bool
|
||||||
|
gemeistert: bool
|
||||||
|
vertiefung: bool
|
||||||
|
deepdive: bool
|
||||||
|
|
||||||
|
|
||||||
|
class BausteinLernstandResponse(BaseModel):
|
||||||
|
bausteine: dict[str, BausteinLernstand]
|
||||||
|
|||||||
244
backend/onepager.py
Normal file
244
backend/onepager.py
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
"""OnePager-Pipeline als Konsens-Kette (7 Karten im 3×3-Raster).
|
||||||
|
|
||||||
|
Recherche: 3 Agenten (min. 2, Grace) → Mapping konsolidiert zu EINER Faktenbasis.
|
||||||
|
Bauen: 3 Agenten bauen je einen Karten-Satz → ein Judge wählt und kombiniert.
|
||||||
|
Prüfung: Verify→Fix-Loop (max. KONSENS_MAX_RUNDEN); Runde 1 ist fatal, danach
|
||||||
|
bleibt bei Fehlern die letzte gültige Version. Schritt-Dateien bleiben liegen →
|
||||||
|
Abbruch erhält Fortschritt, ▶ setzt am offenen Schritt fort.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from config import KONSENS_GRACE, KONSENS_MAX_RUNDEN
|
||||||
|
from jsonio import read_json_file as _json_datei
|
||||||
|
from pipeline import (
|
||||||
|
CANCELLED, FAILED, GenContext, _extra, _fail, _log, _probleme_schema,
|
||||||
|
_prompt, _race, _set_step, _timeout, is_guide_cancelled, run_single_slot,
|
||||||
|
)
|
||||||
|
|
||||||
|
ONEPAGER_STEPS = ("Recherche", "Bauen", "Prüfung")
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_onepager(
|
||||||
|
guide_id: str, topic: str, instructions: str, provider: str,
|
||||||
|
project: Path | None, content_path: Path,
|
||||||
|
) -> list[dict] | None:
|
||||||
|
is_cancelled = lambda: is_guide_cancelled(guide_id)
|
||||||
|
ctx = GenContext(topic=topic, provider=provider, is_cancelled=is_cancelled, guide_id=guide_id)
|
||||||
|
|
||||||
|
# 3×3-Raster: 7 Karten mit festen Schlüsseln (Reihenfolge = Lesereihenfolge mobil)
|
||||||
|
KARTEN_KEYS = ("info", "eigenschaften", "beispiel", "zusammenhaenge", "voraussetzungen", "modern", "veraltet")
|
||||||
|
|
||||||
|
def karten_schema(data):
|
||||||
|
"""{"karten": {key: {titel, md}}} → Liste · sonst None."""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
karten = data.get("karten")
|
||||||
|
if not isinstance(karten, dict):
|
||||||
|
return None
|
||||||
|
out = []
|
||||||
|
for key in KARTEN_KEYS:
|
||||||
|
k = karten.get(key)
|
||||||
|
if not isinstance(k, dict) or not isinstance(k.get("titel"), str) or not isinstance(k.get("md"), str):
|
||||||
|
return None
|
||||||
|
titel, md = k["titel"].strip(), k["md"].strip()
|
||||||
|
if not titel or len(md) < 5: # abgebrochene/leere Karten sind ungültig
|
||||||
|
return None
|
||||||
|
out.append({"key": key, "titel": titel, "md": md})
|
||||||
|
return out
|
||||||
|
|
||||||
|
d, stem = content_path.parent, content_path.stem
|
||||||
|
recherche_slots = [d / f"{stem}.recherche-{i}.md" for i in (1, 2, 3)]
|
||||||
|
recherche_path = d / f"{stem}.recherche.md" # konsolidierte Faktenbasis
|
||||||
|
karten_slots = [d / f"{stem}.karten-{i}.json" for i in (1, 2, 3)]
|
||||||
|
karten_path = d / f"{stem}.karten.json" # Judge-Ausgabe
|
||||||
|
verify_paths = {n: d / f"{stem}.verify-r{n}.json" for n in range(1, KONSENS_MAX_RUNDEN + 1)}
|
||||||
|
fix_paths = {n: d / f"{stem}.karten-fix-r{n}.json" for n in range(1, KONSENS_MAX_RUNDEN + 1)}
|
||||||
|
|
||||||
|
# Projekte bekommen eigene Recherche-Dimensionen — Produkt-Fragen
|
||||||
|
# (Version, Lizenz, Alternativen) laufen dort ins Leere.
|
||||||
|
if project:
|
||||||
|
source = _prompt("OnePager-Quelle-Projekt", project=project)
|
||||||
|
recherche_template = "OnePager-Recherche-Projekt"
|
||||||
|
else:
|
||||||
|
source = _prompt("OnePager-Quelle-Thema", topic=topic)
|
||||||
|
recherche_template = "OnePager-Recherche"
|
||||||
|
|
||||||
|
def text_payload(path: Path):
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
text = path.read_text(encoding="utf-8").strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
# Schritt 0: Recherche — 3 Agenten (min. 2, Grace), Mapping konsolidiert.
|
||||||
|
# Eine gültige recherche.md (auch aus Altläufen) überspringt den Schritt.
|
||||||
|
recherche = text_payload(recherche_path)
|
||||||
|
if recherche is None:
|
||||||
|
await _set_step(guide_id, 0, "Recherchiere (3 Agenten)…")
|
||||||
|
recherchen = []
|
||||||
|
offen = []
|
||||||
|
for i, path in enumerate(recherche_slots, 1):
|
||||||
|
text = text_payload(path)
|
||||||
|
if text is not None:
|
||||||
|
recherchen.append(text)
|
||||||
|
else:
|
||||||
|
offen.append((i, path))
|
||||||
|
if len(recherchen) < 2:
|
||||||
|
slots = [
|
||||||
|
{
|
||||||
|
"key": f"{guide_id}-recherche-{i}",
|
||||||
|
"prompt": _prompt(recherche_template, topic=topic, source=source, out_path=path, extra=_extra(instructions)),
|
||||||
|
"role": "quick", "capabilities": "files" if project else "full",
|
||||||
|
"payload": (lambda result, p=path: text_payload(p)),
|
||||||
|
}
|
||||||
|
for i, path in offen
|
||||||
|
]
|
||||||
|
neue = await _race(
|
||||||
|
topic, "OnePager-Recherche", slots, 2 - len(recherchen),
|
||||||
|
_timeout("onepager_recherche"), provider, cancelled=is_cancelled, grace=KONSENS_GRACE,
|
||||||
|
)
|
||||||
|
if is_cancelled():
|
||||||
|
return None
|
||||||
|
if neue is None:
|
||||||
|
await _fail(guide_id, "OnePager-Recherche fehlgeschlagen (Minimum nicht erreicht)")
|
||||||
|
return None
|
||||||
|
recherchen += neue
|
||||||
|
|
||||||
|
await _set_step(guide_id, 0, "Konsolidiere Recherche…")
|
||||||
|
recherchen_block = "\n\n".join(f"### Recherche {i}\n\n{text}" for i, text in enumerate(recherchen, 1))
|
||||||
|
status, recherche = await run_single_slot(
|
||||||
|
ctx, "Recherche-Mapping",
|
||||||
|
key=f"{guide_id}-recherche-mapping",
|
||||||
|
prompt=_prompt(
|
||||||
|
"OnePager-Recherche-Mapping",
|
||||||
|
topic=topic, n=len(recherchen), recherchen=recherchen_block, out_path=recherche_path,
|
||||||
|
),
|
||||||
|
role="judge", capabilities="files",
|
||||||
|
payload=lambda result: text_payload(recherche_path),
|
||||||
|
timeout=_timeout("onepager_mapping"),
|
||||||
|
)
|
||||||
|
if status == CANCELLED:
|
||||||
|
return None
|
||||||
|
if status == FAILED:
|
||||||
|
await _fail(guide_id, "Recherche-Konsolidierung fehlgeschlagen")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Schritt 1: Bauen — 3 Entwürfe (min. 2, Grace), ein Judge kombiniert.
|
||||||
|
# Gültiges karten.json (auch aus Altläufen) überspringt den Schritt.
|
||||||
|
karten = karten_schema(_json_datei(karten_path))
|
||||||
|
if karten is None:
|
||||||
|
await _set_step(guide_id, 1, "Baue OnePager (3 Entwürfe)…")
|
||||||
|
entwuerfe = []
|
||||||
|
offen = []
|
||||||
|
for i, path in enumerate(karten_slots, 1):
|
||||||
|
res = karten_schema(_json_datei(path))
|
||||||
|
if res is not None:
|
||||||
|
entwuerfe.append(res)
|
||||||
|
else:
|
||||||
|
offen.append((i, path))
|
||||||
|
if len(entwuerfe) < 2:
|
||||||
|
slots = [
|
||||||
|
{
|
||||||
|
"key": f"{guide_id}-bauen-{i}",
|
||||||
|
"prompt": _prompt("OnePager-Bauen", topic=topic, recherche=recherche, out_path=path, extra=_extra(instructions)),
|
||||||
|
"role": "fast", "capabilities": "files",
|
||||||
|
"payload": (lambda result, p=path: karten_schema(_json_datei(p))),
|
||||||
|
}
|
||||||
|
for i, path in offen
|
||||||
|
]
|
||||||
|
neue = await _race(
|
||||||
|
topic, "OnePager-Bauen", slots, 2 - len(entwuerfe),
|
||||||
|
_timeout("onepager_bauen"), provider, cancelled=is_cancelled, grace=KONSENS_GRACE,
|
||||||
|
)
|
||||||
|
if is_cancelled():
|
||||||
|
return None
|
||||||
|
if neue is None:
|
||||||
|
await _fail(guide_id, "OnePager-Bau fehlgeschlagen (Minimum nicht erreicht)")
|
||||||
|
return None
|
||||||
|
entwuerfe += neue
|
||||||
|
|
||||||
|
await _set_step(guide_id, 1, "Wähle besten Entwurf…")
|
||||||
|
saetze_block = "\n\n".join(
|
||||||
|
f"## Entwurf {i}\n\n" + "\n\n".join(f"### {k['titel']} [{k['key']}]\n{k['md']}" for k in satz)
|
||||||
|
for i, satz in enumerate(entwuerfe, 1)
|
||||||
|
)
|
||||||
|
status, karten = await run_single_slot(
|
||||||
|
ctx, "Bauen-Judge",
|
||||||
|
key=f"{guide_id}-bauen-judge",
|
||||||
|
prompt=_prompt(
|
||||||
|
"OnePager-Bauen-Judge",
|
||||||
|
topic=topic, n=len(entwuerfe), recherche=recherche, kartensaetze=saetze_block,
|
||||||
|
out_path=karten_path, extra=_extra(instructions),
|
||||||
|
),
|
||||||
|
role="judge", capabilities="files",
|
||||||
|
payload=lambda result: karten_schema(_json_datei(karten_path)),
|
||||||
|
timeout=_timeout("onepager_judge"),
|
||||||
|
)
|
||||||
|
if status == CANCELLED:
|
||||||
|
return None
|
||||||
|
if status == FAILED:
|
||||||
|
await _fail(guide_id, "OnePager-Bau fehlgeschlagen (Judge ohne gültiges Ergebnis)")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def karten_block() -> str:
|
||||||
|
return "\n\n".join(f"### {k['titel']} [{k['key']}]\n{k['md']}" for k in karten)
|
||||||
|
|
||||||
|
# Schritt 2: Prüf-Loop — Verify notiert Probleme, Fix behebt; max. Runden-Cap.
|
||||||
|
# Runde 1 ist fatal (wie früher der Einzel-Check), danach bleibt bei Fehlern
|
||||||
|
# die letzte gültige Version stehen.
|
||||||
|
for runde in range(1, KONSENS_MAX_RUNDEN + 1):
|
||||||
|
probleme = _probleme_schema(_json_datei(verify_paths[runde]))
|
||||||
|
if probleme is None:
|
||||||
|
await _set_step(guide_id, 2, f"Prüfe OnePager (Runde {runde}/{KONSENS_MAX_RUNDEN})…")
|
||||||
|
verify_paths[runde].unlink(missing_ok=True)
|
||||||
|
status, probleme = await run_single_slot(
|
||||||
|
ctx, f"OnePager-Prüfung r{runde}",
|
||||||
|
key=f"{guide_id}-verify-r{runde}",
|
||||||
|
prompt=_prompt("OnePager-Verifikation", topic=topic, recherche=recherche, karten=karten_block(), out_path=verify_paths[runde]),
|
||||||
|
role="judge", capabilities="files",
|
||||||
|
payload=lambda result, p=verify_paths[runde]: _probleme_schema(_json_datei(p)),
|
||||||
|
timeout=_timeout("onepager_verify"),
|
||||||
|
)
|
||||||
|
if status == CANCELLED:
|
||||||
|
return None
|
||||||
|
if status == FAILED:
|
||||||
|
if runde == 1:
|
||||||
|
await _fail(guide_id, "OnePager-Prüfung fehlgeschlagen")
|
||||||
|
return None
|
||||||
|
_log(topic, f"OnePager-Prüfung Runde {runde} fehlgeschlagen — letzte gültige Version bleibt")
|
||||||
|
break
|
||||||
|
if not probleme:
|
||||||
|
break
|
||||||
|
if runde == KONSENS_MAX_RUNDEN:
|
||||||
|
_log(topic, f"OnePager-Prüfung: {len(probleme)} Problem(e) bleiben nach Runde {runde} stehen")
|
||||||
|
break
|
||||||
|
|
||||||
|
_log(topic, f"OnePager-Prüfung Runde {runde}: {len(probleme)} Problem(e) notiert")
|
||||||
|
fixed = karten_schema(_json_datei(fix_paths[runde])) # Resume
|
||||||
|
if fixed is None:
|
||||||
|
await _set_step(guide_id, 2, f"Überarbeite OnePager (Runde {runde})…")
|
||||||
|
status, fixed = await run_single_slot(
|
||||||
|
ctx, f"OnePager-Fix r{runde}",
|
||||||
|
key=f"{guide_id}-karten-fix-r{runde}",
|
||||||
|
prompt=_prompt(
|
||||||
|
"OnePager-Fix",
|
||||||
|
topic=topic, recherche=recherche, karten=karten_block(),
|
||||||
|
probleme="\n".join(f"- {p}" for p in probleme),
|
||||||
|
out_path=fix_paths[runde], extra=_extra(instructions),
|
||||||
|
),
|
||||||
|
role="fast", capabilities="files",
|
||||||
|
payload=lambda result, p=fix_paths[runde]: karten_schema(_json_datei(p)),
|
||||||
|
timeout=_timeout("onepager_bauen"),
|
||||||
|
)
|
||||||
|
if status == CANCELLED:
|
||||||
|
return None
|
||||||
|
if status == FAILED:
|
||||||
|
_log(topic, f"OnePager-Fix Runde {runde} ungültig — letzte gültige Version bleibt")
|
||||||
|
break
|
||||||
|
karten = fixed
|
||||||
|
|
||||||
|
sections = [
|
||||||
|
{"num": i, "title": k["titel"], "md": k["md"], "key": k["key"]}
|
||||||
|
for i, k in enumerate(karten, 1)
|
||||||
|
]
|
||||||
|
return [{"title": topic, "sections": sections}]
|
||||||
259
backend/pipeline.py
Normal file
259
backend/pipeline.py
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
"""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
|
||||||
|
from config import MAX_CONCURRENT_GENERATIONS, TEMPLATES_DIR, TIMEOUTS
|
||||||
|
from database import update_guide
|
||||||
|
from jsonio import read_json_file as _json_datei
|
||||||
|
|
||||||
|
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)
|
||||||
|
kill_process(guide_id)
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
_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]
|
||||||
|
|
||||||
|
|
||||||
137
backend/regeln.py
Normal file
137
backend/regeln.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"""Lernschulden-Regeln: Progression und Deckel für offene Guides — die EINZIGE Quelle.
|
||||||
|
|
||||||
|
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
|
||||||
|
- Absolviert (Mini/Guide/FullGuide): ALLE Bausteine (Section-Titel) des neuesten
|
||||||
|
fertigen Guides haben eine bestandene Prüfung (baustein_progress). Das
|
||||||
|
Kapitel-Häkchen ist nur noch eine Lese-Markierung. OnePager: Kapitel-Häkchen.
|
||||||
|
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
|
||||||
|
VORSTUFE = {"Guide": "MiniGuide", "FullGuide": "Guide"}
|
||||||
|
# Welches Niveau die Vorstufe erreichen muss, um das Format freizuschalten.
|
||||||
|
FREISCHALT_LEVEL = {"Guide": "absolviert", "FullGuide": "verstanden"}
|
||||||
|
FORMATE = ("MiniGuide", "Guide", "FullGuide")
|
||||||
|
|
||||||
|
|
||||||
|
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 (bzw. OnePager-Kapitel) des Guides auf dem geforderten Niveau?"""
|
||||||
|
if g["format"] == "OnePager":
|
||||||
|
titles = _kapitel_titel(g["topic"], g["format"])
|
||||||
|
return bool(titles) and titles <= progress.get(g["id"], set())
|
||||||
|
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 FullGuide, alle Bausteine gemeistert (25)?"""
|
||||||
|
return ist_level(topic, "FullGuide", 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 fmt != "OnePager" and 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 fmt != "OnePager" and not content.exists() and not guide_slot_dateien(content):
|
||||||
|
vorstufe = VORSTUFE.get(fmt)
|
||||||
|
if vorstufe:
|
||||||
|
stufe = FREISCHALT_LEVEL[fmt] # "absolviert" (alle 3) oder "verstanden" (alle 10)
|
||||||
|
if not ist_level(topic, vorstufe, guides, progress, levels[stufe]):
|
||||||
|
wort = "verstehen (alle Bausteine auf 10)" if stufe == "verstanden" else "absolvieren (alle Bausteine prüfen)"
|
||||||
|
return f"Erst den {vorstufe} dieses Themas {wort}"
|
||||||
|
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,5 +1,4 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import shutil
|
import shutil
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -14,12 +13,15 @@ from database import (
|
|||||||
create_topic, list_topics as db_list_topics, delete_topic,
|
create_topic, list_topics as db_list_topics, delete_topic,
|
||||||
list_progress, set_progress, delete_progress,
|
list_progress, set_progress, delete_progress,
|
||||||
create_element, list_elements, get_element, update_element, delete_element,
|
create_element, list_elements, get_element, update_element, delete_element,
|
||||||
|
get_vertiefung, set_vertiefung, list_vertiefungen,
|
||||||
|
list_baustein_progress, set_baustein_score, set_baustein_absolviert, set_baustein_verstanden, set_baustein_gemeistert, delete_baustein_daten,
|
||||||
)
|
)
|
||||||
from generator import (
|
from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine
|
||||||
generate_guide, cancel_guide, chat_with_guide, guide_slot_dateien,
|
from elements import generate_element, chat_with_guide, chat_with_element, check_element, style_element, refine_suggestion
|
||||||
generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine,
|
from lernen import NOETIG, MASTERY, MEISTERN, baustein_chat, baustein_diskussion, baustein_element_anlegen, pruefung_bewertung, pruefung_frage, score_berechnen, vertiefung_generieren
|
||||||
generate_element, chat_with_element, check_element, style_element, refine_suggestion,
|
from guide import generate_guide, guide_slot_dateien
|
||||||
)
|
from pipeline import cancel_guide
|
||||||
|
from regeln import FORMATE, formate_stats, guide_lock, ist_absolviert, lade_lernstand, thema_abgeschlossen
|
||||||
from models import (
|
from models import (
|
||||||
GuideCreateRequest, GuideResponse,
|
GuideCreateRequest, GuideResponse,
|
||||||
TopicCreateRequest,
|
TopicCreateRequest,
|
||||||
@@ -29,8 +31,11 @@ from models import (
|
|||||||
ElementUpdateRequest, ElementCheckRequest, ElementCheckResponse, ElementStyleResponse,
|
ElementUpdateRequest, ElementCheckRequest, ElementCheckResponse, ElementStyleResponse,
|
||||||
ElementRefineRequest, ElementRefineResponse,
|
ElementRefineRequest, ElementRefineResponse,
|
||||||
ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo,
|
ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo,
|
||||||
|
VertiefungRequest, VertiefungResponse,
|
||||||
|
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
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
@@ -51,73 +56,23 @@ async def get_topics():
|
|||||||
return db_topics + sorted(derived - set(db_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")
|
@router.get("/stats")
|
||||||
async def get_stats():
|
async def get_stats():
|
||||||
"""Tracker: Themen-Anzahl + pro Format erstellt/absolviert."""
|
"""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())
|
themen = set(await db_list_topics()) | {g["topic"] for g in guides} | set(bausteine_topics())
|
||||||
if PROJECTS_DIR.is_dir():
|
if PROJECTS_DIR.is_dir():
|
||||||
themen |= {e.name for e in PROJECTS_DIR.iterdir() if e.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")
|
@router.get("/topics/fortschritt")
|
||||||
async def topic_fortschritt(topic: str):
|
async def topic_fortschritt(topic: str):
|
||||||
"""Absolviert-Status pro Format — fürs Freischalten der nächsten Ausbaustufe."""
|
"""Absolviert-Status pro Format + Themen-Abschluss — fürs Freischalten der nächsten Ausbaustufe."""
|
||||||
return {fmt: await _ist_absolviert(topic, fmt) for fmt in ("MiniGuide", "Guide", "FullGuide")}
|
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")
|
@router.post("/topics")
|
||||||
@@ -129,6 +84,7 @@ async def add_topic(req: TopicCreateRequest):
|
|||||||
@router.delete("/topics")
|
@router.delete("/topics")
|
||||||
async def remove_topic(topic: str):
|
async def remove_topic(topic: str):
|
||||||
await delete_topic(topic)
|
await delete_topic(topic)
|
||||||
|
await delete_baustein_daten(topic)
|
||||||
shutil.rmtree(topic_dir(topic), ignore_errors=True)
|
shutil.rmtree(topic_dir(topic), ignore_errors=True)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@@ -191,27 +147,135 @@ async def remove_bausteine(topic: str):
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Baustein-Lernen: Vertiefung, Chat, Prüfung ---
|
||||||
|
|
||||||
|
async def _bester_text(topic: str, baustein: str) -> str | None:
|
||||||
|
"""Kontext für Chat/Prüfung: Amateur-Fassung (art 'deepdive') bevorzugt, sonst Vertiefung."""
|
||||||
|
return await get_vertiefung(topic, baustein, "deepdive") or await get_vertiefung(topic, baustein, "vertiefung")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/bausteine/lernstand", response_model=BausteinLernstandResponse)
|
||||||
|
async def baustein_lernstand(topic: str):
|
||||||
|
"""Prüfungs-Stand + Vertiefungs-/Deepdive-Existenz pro Baustein (roher Titel als Key)."""
|
||||||
|
progress = await list_baustein_progress(topic)
|
||||||
|
texte = await list_vertiefungen(topic)
|
||||||
|
bausteine = {
|
||||||
|
p["baustein"]: {
|
||||||
|
"gute_antworten": p["gute_antworten"],
|
||||||
|
"absolviert": p["absolviert"] is not None,
|
||||||
|
"verstanden": p["verstanden"] is not None,
|
||||||
|
"gemeistert": p["gemeistert"] is not None,
|
||||||
|
"vertiefung": "vertiefung" in texte.get(p["baustein"], set()),
|
||||||
|
"deepdive": "deepdive" in texte.get(p["baustein"], set()),
|
||||||
|
}
|
||||||
|
for p in progress
|
||||||
|
}
|
||||||
|
for b, arten in texte.items():
|
||||||
|
if b not in bausteine:
|
||||||
|
bausteine[b] = {
|
||||||
|
"gute_antworten": 0, "absolviert": False, "verstanden": False, "gemeistert": False,
|
||||||
|
"vertiefung": "vertiefung" in arten, "deepdive": "deepdive" in arten,
|
||||||
|
}
|
||||||
|
return {"bausteine": bausteine}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/bausteine/vertiefung", response_model=VertiefungResponse)
|
||||||
|
async def get_baustein_vertiefung(topic: str, baustein: str, art: str = "vertiefung"):
|
||||||
|
if art not in ("vertiefung", "deepdive"):
|
||||||
|
raise HTTPException(400, "Unbekannte Art")
|
||||||
|
md = await get_vertiefung(topic, baustein, art)
|
||||||
|
if md is None:
|
||||||
|
raise HTTPException(404, "Kein Text vorhanden")
|
||||||
|
return {"md": md}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bausteine/vertiefung", response_model=VertiefungResponse)
|
||||||
|
async def create_baustein_vertiefung(req: VertiefungRequest):
|
||||||
|
md = await vertiefung_generieren(req.topic, req.baustein, req.section, art=req.art, provider=req.provider)
|
||||||
|
if md is None:
|
||||||
|
raise HTTPException(502, "Generierung fehlgeschlagen — bitte erneut versuchen")
|
||||||
|
await set_vertiefung(req.topic, req.baustein, req.art, md)
|
||||||
|
return {"md": md}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bausteine/chat", response_model=BausteinChatResponse)
|
||||||
|
async def baustein_chat_route(req: BausteinChatRequest):
|
||||||
|
vertiefung = await _bester_text(req.topic, req.baustein)
|
||||||
|
reply = await baustein_chat(
|
||||||
|
req.topic, req.baustein, req.section, vertiefung,
|
||||||
|
[m.model_dump() for m in req.messages], provider=req.provider,
|
||||||
|
)
|
||||||
|
return {"reply": reply}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bausteine/pruefung", response_model=BausteinPruefungResponse)
|
||||||
|
async def baustein_pruefung_route(req: BausteinPruefungRequest):
|
||||||
|
stand = next(
|
||||||
|
(p for p in await list_baustein_progress(req.topic) if p["baustein"] == req.baustein),
|
||||||
|
{"gute_antworten": 0, "absolviert": None, "verstanden": None, "gemeistert": None},
|
||||||
|
)
|
||||||
|
gute = stand["gute_antworten"]
|
||||||
|
absolviert = stand["absolviert"] is not None
|
||||||
|
verstanden = stand["verstanden"] is not None
|
||||||
|
gemeistert = stand["gemeistert"] is not None
|
||||||
|
vertiefung = await _bester_text(req.topic, req.baustein)
|
||||||
|
msgs = [m.model_dump() for m in req.messages]
|
||||||
|
|
||||||
|
if req.aktion == "frage":
|
||||||
|
frage = await pruefung_frage(req.topic, req.baustein, req.section, vertiefung, msgs, 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, vertiefung,
|
||||||
|
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}
|
||||||
|
|
||||||
|
# aktion == "antwort" — mindestens eine Nutzer-Antwort muss im Dialog stehen
|
||||||
|
# (nach einer Diskussion endet der Dialog mit dem Tutor; Re-Bewertung bleibt erlaubt).
|
||||||
|
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")
|
||||||
|
data = await pruefung_bewertung(
|
||||||
|
req.topic, req.baustein, req.section, vertiefung, req.frage, msgs, gute, provider=req.provider,
|
||||||
|
)
|
||||||
|
if data is None:
|
||||||
|
raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen")
|
||||||
|
|
||||||
|
# Score driftfrei aus dem Basis-Score rechnen (Re-Bewertung ersetzt das vorige Ergebnis).
|
||||||
|
score = score_berechnen(
|
||||||
|
req.score_vor_frage, data["bewertung"] == "gut", req.tier2, req.tier3, absolviert, gemeistert,
|
||||||
|
)
|
||||||
|
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"], "bewertung": data["bewertung"], "gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}
|
||||||
|
|
||||||
|
|
||||||
# --- Guides ---
|
# --- Guides ---
|
||||||
|
|
||||||
@router.post("/guides", response_model=GuideResponse)
|
@router.post("/guides", response_model=GuideResponse)
|
||||||
async def create(req: GuideCreateRequest):
|
async def create(req: GuideCreateRequest):
|
||||||
if req.format != "OnePager" and not bausteine_path(req.topic.strip()).exists():
|
guides, progress, levels = await lade_lernstand()
|
||||||
raise HTTPException(400, "Erst Bausteine erstellen")
|
grund = guide_lock(req.topic.strip(), req.format, guides, progress, levels)
|
||||||
# Kein Duplikat-Start: pro Thema+Format höchstens eine laufende Generierung
|
if grund:
|
||||||
for g in await list_guides():
|
raise HTTPException(400 if grund == "Erst Bausteine erstellen" else 409, grund)
|
||||||
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)")
|
|
||||||
await create_topic(req.topic.strip())
|
await create_topic(req.topic.strip())
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
guide = {
|
guide = {
|
||||||
@@ -234,6 +298,13 @@ async def list_all():
|
|||||||
return await list_guides()
|
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 ("OnePager", *FORMATE)}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/guides/{guide_id}", response_model=GuideResponse)
|
@router.get("/guides/{guide_id}", response_model=GuideResponse)
|
||||||
async def get_one(guide_id: str):
|
async def get_one(guide_id: str):
|
||||||
guide = await get_guide(guide_id)
|
guide = await get_guide(guide_id)
|
||||||
|
|||||||
175
backend/textkit.py
Normal file
175
backend/textkit.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
"""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)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_fragment(text: str) -> list[dict]:
|
||||||
|
"""Parst eine Writer-Datei → [{"kapitel", "titel", "md"}] in Datei-Reihenfolge."""
|
||||||
|
sections: list[dict] = []
|
||||||
|
kapitel = None
|
||||||
|
current = None
|
||||||
|
for line in text.splitlines():
|
||||||
|
s = line.strip()
|
||||||
|
m = _FRAGMENT_KAPITEL_RE.match(s)
|
||||||
|
if m:
|
||||||
|
kapitel = m.group(1)
|
||||||
|
current = None
|
||||||
|
continue
|
||||||
|
m = _FRAGMENT_SECTION_RE.match(s)
|
||||||
|
if m:
|
||||||
|
current = {"kapitel": kapitel, "titel": m.group(1), "md": []}
|
||||||
|
sections.append(current)
|
||||||
|
continue
|
||||||
|
if current is not None:
|
||||||
|
current["md"].append(line)
|
||||||
|
for sec in sections:
|
||||||
|
sec["md"] = "\n".join(sec["md"]).strip()
|
||||||
|
return sections
|
||||||
|
|
||||||
|
|
||||||
|
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",
|
"npm": "@ai-sdk/anthropic",
|
||||||
"name": "MiniMax (ohne Thinking)",
|
"name": "MiniMax (kalt — niedrige Temperature, ohne Thinking)",
|
||||||
"options": {
|
"options": {
|
||||||
"baseURL": "https://api.minimax.io/anthropic/v1",
|
"baseURL": "https://api.minimax.io/anthropic/v1",
|
||||||
"apiKey": "{env:MINIMAX_API_KEY}"
|
"apiKey": "{env:MINIMAX_API_KEY}"
|
||||||
},
|
},
|
||||||
"models": {
|
"models": {
|
||||||
"MiniMax-M3": {
|
"MiniMax-M3": {
|
||||||
"name": "MiniMax M3 (ohne Thinking)",
|
"name": "MiniMax M3 (kalt)",
|
||||||
"options": {
|
"options": {
|
||||||
|
"temperature": 0.2,
|
||||||
"thinking": { "type": "disabled" }
|
"thinking": { "type": "disabled" }
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"MiniMax-M2.7-highspeed": {
|
||||||
|
"name": "MiniMax M2.7 highspeed (kalt)",
|
||||||
|
"options": {
|
||||||
|
"temperature": 0.3
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
26
frontend/package-lock.json
generated
26
frontend/package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dompurify": "^3.4.7",
|
"dompurify": "^3.4.7",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
|
"katex": "^0.17.0",
|
||||||
"marked": "^18.0.4",
|
"marked": "^18.0.4",
|
||||||
"marked-highlight": "^2.2.4",
|
"marked-highlight": "^2.2.4",
|
||||||
"vue": "^3.5.32"
|
"vue": "^3.5.32"
|
||||||
@@ -1187,6 +1188,15 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/convert-source-map": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
@@ -1468,6 +1478,22 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/kolorist": {
|
||||||
"version": "1.8.0",
|
"version": "1.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dompurify": "^3.4.7",
|
"dompurify": "^3.4.7",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
|
"katex": "^0.17.0",
|
||||||
"marked": "^18.0.4",
|
"marked": "^18.0.4",
|
||||||
"marked-highlight": "^2.2.4",
|
"marked-highlight": "^2.2.4",
|
||||||
"vue": "^3.5.32"
|
"vue": "^3.5.32"
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
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, fetchProjects, deleteProject as apiDeleteProject, fetchProviders, fetchStats, fetchTopicFortschritt } from './api.js'
|
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, fetchGuideLocks } from './api.js'
|
||||||
|
import { usePolling } from './composables/usePolling.js'
|
||||||
import TopicSidebar from './components/TopicSidebar.vue'
|
import TopicSidebar from './components/TopicSidebar.vue'
|
||||||
import TopicDetail from './components/TopicDetail.vue'
|
import TopicDetail from './components/TopicDetail.vue'
|
||||||
import ElementsSidebar from './components/ElementsSidebar.vue'
|
import ElementsSidebar from './components/elements/ElementsSidebar.vue'
|
||||||
import ElementsOverview from './components/ElementsOverview.vue'
|
import ElementsOverview from './components/ElementsOverview.vue'
|
||||||
|
|
||||||
const guides = ref([])
|
const guides = ref([])
|
||||||
@@ -25,6 +26,8 @@ const provider = ref(localStorage.getItem('provider') || 'claude')
|
|||||||
const providers = ref([])
|
const providers = ref([])
|
||||||
const stats = ref(null)
|
const stats = ref(null)
|
||||||
const fortschritt = ref({})
|
const fortschritt = ref({})
|
||||||
|
const locks = ref({}) // Sperr-Gründe pro Format (Backend = einzige Regel-Quelle)
|
||||||
|
const uiError = ref(null) // abgewiesene Aktionen (409/400) sichtbar machen
|
||||||
const elementsOpen = ref(false) // rechte Sidebar
|
const elementsOpen = ref(false) // rechte Sidebar
|
||||||
const elementsView = ref(false) // Übersicht im Hauptbereich
|
const elementsView = ref(false) // Übersicht im Hauptbereich
|
||||||
const elementsVersion = ref(0) // Erhöhung = Übersicht neu laden
|
const elementsVersion = ref(0) // Erhöhung = Übersicht neu laden
|
||||||
@@ -56,7 +59,6 @@ async function loadProviders() {
|
|||||||
console.error('Fehler beim Laden der Provider:', e)
|
console.error('Fehler beim Laden der Provider:', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let pollTimer = null
|
|
||||||
|
|
||||||
function applyTheme() {
|
function applyTheme() {
|
||||||
document.documentElement.classList.toggle('dark', darkMode.value)
|
document.documentElement.classList.toggle('dark', darkMode.value)
|
||||||
@@ -126,21 +128,27 @@ async function loadTopics() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fehlermeldungen verhalten sich wie Flash-Messages: × blendet aus,
|
// Weggeklickte Fehler bleiben weggeklickt — auch über Reloads (localStorage).
|
||||||
// beim Reload sind Alt-Fehler von vornherein ausgeblendet.
|
// Nicht weggeklickte Fehler bleiben sichtbar, bis der Nutzer sie schließt.
|
||||||
const dismissedErrors = ref(new Set())
|
const dismissedErrors = ref(new Set(JSON.parse(localStorage.getItem('dismissedErrors') || '[]')))
|
||||||
let errorsInitialized = false
|
|
||||||
|
function persistDismissed() {
|
||||||
|
localStorage.setItem('dismissedErrors', JSON.stringify([...dismissedErrors.value]))
|
||||||
|
}
|
||||||
|
|
||||||
function handleDismissError(guideId) {
|
function handleDismissError(guideId) {
|
||||||
dismissedErrors.value = new Set([...dismissedErrors.value, guideId])
|
dismissedErrors.value = new Set([...dismissedErrors.value, guideId])
|
||||||
|
persistDismissed()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadGuides() {
|
async function loadGuides() {
|
||||||
try {
|
try {
|
||||||
guides.value = await fetchGuides()
|
guides.value = await fetchGuides()
|
||||||
if (!errorsInitialized) {
|
// IDs prunen, deren Guide nicht mehr als Fehler existiert
|
||||||
errorsInitialized = true
|
const errorIds = new Set(guides.value.filter((g) => g.status === 'error').map((g) => g.id))
|
||||||
dismissedErrors.value = 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()
|
loadStats()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -154,11 +162,13 @@ async function loadBausteine() {
|
|||||||
if (selectedTopic.value) {
|
if (selectedTopic.value) {
|
||||||
bausteine.value = await fetchBausteineStatus(selectedTopic.value)
|
bausteine.value = await fetchBausteineStatus(selectedTopic.value)
|
||||||
fortschritt.value = await fetchTopicFortschritt(selectedTopic.value)
|
fortschritt.value = await fetchTopicFortschritt(selectedTopic.value)
|
||||||
|
locks.value = await fetchGuideLocks(selectedTopic.value)
|
||||||
} else {
|
} else {
|
||||||
bausteine.value = { ...EMPTY_BAUSTEINE }
|
bausteine.value = { ...EMPTY_BAUSTEINE }
|
||||||
fortschritt.value = {}
|
fortschritt.value = {}
|
||||||
|
locks.value = {}
|
||||||
}
|
}
|
||||||
if (activeBausteine.value.length && !pollTimer) startPolling()
|
if (activeBausteine.value.length && !polling.running()) startPolling()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Fehler beim Laden der Bausteine:', e)
|
console.error('Fehler beim Laden der Bausteine:', e)
|
||||||
}
|
}
|
||||||
@@ -224,7 +234,13 @@ async function handleResetBausteine() {
|
|||||||
|
|
||||||
async function handleBausteineClick({ instructions }) {
|
async function handleBausteineClick({ instructions }) {
|
||||||
if (!selectedTopic.value) return
|
if (!selectedTopic.value) return
|
||||||
await apiCreateBausteine(selectedTopic.value, instructions, provider.value)
|
uiError.value = null
|
||||||
|
try {
|
||||||
|
await apiCreateBausteine(selectedTopic.value, instructions, provider.value)
|
||||||
|
} catch (e) {
|
||||||
|
uiError.value = e.message
|
||||||
|
return
|
||||||
|
}
|
||||||
await loadBausteine()
|
await loadBausteine()
|
||||||
startPolling()
|
startPolling()
|
||||||
}
|
}
|
||||||
@@ -237,7 +253,13 @@ async function handleFormatClick({ format, instructions }) {
|
|||||||
&& (g.status === 'generating' || g.status === 'queued'),
|
&& (g.status === 'generating' || g.status === 'queued'),
|
||||||
)
|
)
|
||||||
if (running) return
|
if (running) return
|
||||||
await apiCreate(selectedTopic.value, format, instructions, provider.value)
|
uiError.value = null
|
||||||
|
try {
|
||||||
|
await apiCreate(selectedTopic.value, format, instructions, provider.value)
|
||||||
|
} catch (e) {
|
||||||
|
uiError.value = e.message
|
||||||
|
return
|
||||||
|
}
|
||||||
await loadGuides()
|
await loadGuides()
|
||||||
startPolling()
|
startPolling()
|
||||||
}
|
}
|
||||||
@@ -276,20 +298,11 @@ async function handleDeleteGuide(guideId, slots = false) {
|
|||||||
await loadGuides()
|
await loadGuides()
|
||||||
}
|
}
|
||||||
|
|
||||||
function startPolling() {
|
const polling = usePolling(
|
||||||
stopPolling()
|
() => Promise.all([loadGuides(), loadBausteine(), loadTopics()]),
|
||||||
pollTimer = setInterval(async () => {
|
() => hasActiveGuides.value || activeBausteine.value.length > 0,
|
||||||
await Promise.all([loadGuides(), loadBausteine(), loadTopics()])
|
)
|
||||||
if (!hasActiveGuides.value && !activeBausteine.value.length) stopPolling()
|
const startPolling = polling.start
|
||||||
}, 3000)
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopPolling() {
|
|
||||||
if (pollTimer) {
|
|
||||||
clearInterval(pollTimer)
|
|
||||||
pollTimer = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCancel(guideId) {
|
async function handleCancel(guideId) {
|
||||||
await apiCancel(guideId)
|
await apiCancel(guideId)
|
||||||
@@ -311,16 +324,6 @@ async function handleDeleteTopic(topic) {
|
|||||||
await loadGuides()
|
await loadGuides()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onVisibility() {
|
|
||||||
if (document.hidden) {
|
|
||||||
stopPolling()
|
|
||||||
} else {
|
|
||||||
loadGuides()
|
|
||||||
loadBausteine()
|
|
||||||
if (hasActiveGuides.value || activeBausteine.value.length) startPolling()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadGuides(), loadTopics(), loadProjects(), loadProviders()])
|
await Promise.all([loadGuides(), loadTopics(), loadProjects(), loadProviders()])
|
||||||
const savedTopic = localStorage.getItem('lastTopic')
|
const savedTopic = localStorage.getItem('lastTopic')
|
||||||
@@ -333,12 +336,6 @@ onMounted(async () => {
|
|||||||
} else if (!selectedTopic.value && topics.value.length) {
|
} else if (!selectedTopic.value && topics.value.length) {
|
||||||
selectTopic(topics.value[0])
|
selectTopic(topics.value[0])
|
||||||
}
|
}
|
||||||
document.addEventListener('visibilitychange', onVisibility)
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
stopPolling()
|
|
||||||
document.removeEventListener('visibilitychange', onVisibility)
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -352,6 +349,8 @@ onUnmounted(() => {
|
|||||||
:selectedTopic="selectedTopic"
|
:selectedTopic="selectedTopic"
|
||||||
:stats="stats"
|
:stats="stats"
|
||||||
:fortschritt="fortschritt"
|
:fortschritt="fortschritt"
|
||||||
|
:locks="locks"
|
||||||
|
:uiError="uiError"
|
||||||
:doneByFormat="doneByFormat"
|
:doneByFormat="doneByFormat"
|
||||||
:latestByFormat="latestByFormat"
|
:latestByFormat="latestByFormat"
|
||||||
:allGuides="guides"
|
:allGuides="guides"
|
||||||
@@ -375,6 +374,7 @@ onUnmounted(() => {
|
|||||||
@cancelGuide="handleCancel"
|
@cancelGuide="handleCancel"
|
||||||
@deleteGuide="handleDeleteGuide"
|
@deleteGuide="handleDeleteGuide"
|
||||||
@dismissError="handleDismissError"
|
@dismissError="handleDismissError"
|
||||||
|
@dismissUiError="uiError = null"
|
||||||
@preview="handlePreview"
|
@preview="handlePreview"
|
||||||
@openElements="handleOpenElements"
|
@openElements="handleOpenElements"
|
||||||
@togglePin="toggleSidebarPin"
|
@togglePin="toggleSidebarPin"
|
||||||
@@ -392,8 +392,9 @@ onUnmounted(() => {
|
|||||||
:dark="darkMode"
|
:dark="darkMode"
|
||||||
:provider="provider"
|
:provider="provider"
|
||||||
:elementsOpen="elementsOpen"
|
:elementsOpen="elementsOpen"
|
||||||
|
:doneByFormat="doneByFormat"
|
||||||
|
:themaAbgeschlossen="!!fortschritt.abgeschlossen"
|
||||||
@progressChanged="loadStats(); loadBausteine()"
|
@progressChanged="loadStats(); loadBausteine()"
|
||||||
@openElements="elementsOpen = true"
|
|
||||||
/>
|
/>
|
||||||
<div v-else class="empty-main">
|
<div v-else class="empty-main">
|
||||||
<p>Thema in der Sidebar anlegen oder auswählen.</p>
|
<p>Thema in der Sidebar anlegen oder auswählen.</p>
|
||||||
|
|||||||
@@ -1,17 +1,35 @@
|
|||||||
const BASE = '/api'
|
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() {
|
export async function fetchGuides() {
|
||||||
const res = await fetch(`${BASE}/guides`)
|
const res = await fetch(`${BASE}/guides`)
|
||||||
return res.json()
|
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') {
|
export async function createGuide(topic, format, instructions = '', provider = 'claude') {
|
||||||
const res = await fetch(`${BASE}/guides`, {
|
const res = await fetch(`${BASE}/guides`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ topic, format, instructions, provider }),
|
body: JSON.stringify({ topic, format, instructions, provider }),
|
||||||
})
|
})
|
||||||
return res.json()
|
return jsonOrThrow(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchActiveBausteine() {
|
export async function fetchActiveBausteine() {
|
||||||
@@ -30,7 +48,7 @@ export async function createBausteine(topic, instructions = '', provider = 'clau
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ topic, instructions, provider }),
|
body: JSON.stringify({ topic, instructions, provider }),
|
||||||
})
|
})
|
||||||
return res.json()
|
return jsonOrThrow(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cancelBausteine(topic) {
|
export async function cancelBausteine(topic) {
|
||||||
@@ -41,6 +59,50 @@ export async function deleteBausteine(topic) {
|
|||||||
await fetch(`${BASE}/bausteine?topic=${encodeURIComponent(topic)}`, { method: 'DELETE' })
|
await fetch(`${BASE}/bausteine?topic=${encodeURIComponent(topic)}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Baustein-Lernen: Vertiefung, Chat, Prüfung ---
|
||||||
|
|
||||||
|
export async function fetchBausteinLernstand(topic) {
|
||||||
|
const res = await fetch(`${BASE}/bausteine/lernstand?topic=${encodeURIComponent(topic)}`)
|
||||||
|
return jsonOrThrow(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchVertiefung(topic, baustein, art = 'vertiefung') {
|
||||||
|
const res = await fetch(
|
||||||
|
`${BASE}/bausteine/vertiefung?topic=${encodeURIComponent(topic)}&baustein=${encodeURIComponent(baustein)}&art=${art}`
|
||||||
|
)
|
||||||
|
return jsonOrThrow(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createVertiefung({ topic, baustein, section, art = 'vertiefung', provider }) {
|
||||||
|
const res = await fetch(`${BASE}/bausteine/vertiefung`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ topic, baustein, section, art, provider }),
|
||||||
|
})
|
||||||
|
return jsonOrThrow(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function chatBaustein({ topic, baustein, section, messages, provider }) {
|
||||||
|
const res = await fetch(`${BASE}/bausteine/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ topic, baustein, section, messages, provider }),
|
||||||
|
})
|
||||||
|
return jsonOrThrow(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pruefeBaustein({
|
||||||
|
topic, baustein, section, provider,
|
||||||
|
aktion = 'frage', frage = '', letzte_bewertung = '', score_vor_frage = 0, tier2 = false, messages = [],
|
||||||
|
}) {
|
||||||
|
const res = await fetch(`${BASE}/bausteine/pruefung`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ topic, baustein, section, aktion, frage, letzte_bewertung, score_vor_frage, tier2, messages, provider }),
|
||||||
|
})
|
||||||
|
return jsonOrThrow(res)
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchTopicFortschritt(topic) {
|
export async function fetchTopicFortschritt(topic) {
|
||||||
const res = await fetch(`${BASE}/topics/fortschritt?topic=${encodeURIComponent(topic)}`)
|
const res = await fetch(`${BASE}/topics/fortschritt?topic=${encodeURIComponent(topic)}`)
|
||||||
return res.json()
|
return res.json()
|
||||||
@@ -96,20 +158,6 @@ export async function deleteTopic(name) {
|
|||||||
await fetch(`${BASE}/topics?topic=${encodeURIComponent(name)}`, { method: 'DELETE' })
|
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' }) {
|
export async function chatGuide(id, { section, outline, messages, provider = 'claude' }) {
|
||||||
const res = await fetch(`${BASE}/guides/${id}/chat`, {
|
const res = await fetch(`${BASE}/guides/${id}/chat`, {
|
||||||
method: 'POST',
|
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;
|
||||||
|
}
|
||||||
446
frontend/src/components/BausteinPanel.vue
Normal file
446
frontend/src/components/BausteinPanel.vue
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, nextTick, ref } from 'vue'
|
||||||
|
import { chatBaustein, createVertiefung, fetchVertiefung, pruefeBaustein } from '../api.js'
|
||||||
|
import { renderMarkdown } from '../markdown.js'
|
||||||
|
import { useChat, istUnten } from '../composables/useChat.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
topic: { type: String, required: true },
|
||||||
|
baustein: { type: String, required: true },
|
||||||
|
section: { type: String, default: '' },
|
||||||
|
provider: { type: String, default: 'claude' },
|
||||||
|
status: { type: Object, default: null }, // {gute_antworten, absolviert, verstanden, gemeistert, vertiefung}
|
||||||
|
tier2: { type: Boolean, default: false }, // Tier 2 frei (ganzer Guide absolviert)
|
||||||
|
tier3: { type: Boolean, default: false }, // Meisterpfad frei (ganzer Guide verstanden)
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['statusChanged'])
|
||||||
|
|
||||||
|
const NOETIG = 3 // absolviert
|
||||||
|
const MAX = 10 // verstanden
|
||||||
|
const MEISTERN = 25 // gemeistert (Maximum)
|
||||||
|
const st = computed(() => props.status || { gute_antworten: 0, absolviert: false, verstanden: false, gemeistert: false, vertiefung: false, deepdive: false })
|
||||||
|
|
||||||
|
// --- Toggle-Bereich ---
|
||||||
|
const activeTab = ref(null) // null | 'vertiefung' | 'deepdive' | 'chat' | 'pruefung'
|
||||||
|
|
||||||
|
function toggle(tab) {
|
||||||
|
activeTab.value = activeTab.value === tab ? null : tab
|
||||||
|
if (activeTab.value === 'vertiefung' || activeTab.value === 'deepdive') openText(activeTab.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Vertiefung (gleicher Stoff, mehr) + Amateur (gleicher Stoff, für Einsteiger), beide persistiert (intern art 'deepdive') ---
|
||||||
|
const texte = ref({
|
||||||
|
vertiefung: { md: null, loading: false, error: '' },
|
||||||
|
deepdive: { md: null, loading: false, error: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
async function openText(art) {
|
||||||
|
const t = texte.value[art]
|
||||||
|
if (t.md !== null || t.loading || !st.value[art]) return
|
||||||
|
t.loading = true
|
||||||
|
t.error = ''
|
||||||
|
try {
|
||||||
|
t.md = (await fetchVertiefung(props.topic, props.baustein, art)).md
|
||||||
|
} catch (e) {
|
||||||
|
t.error = e.message
|
||||||
|
} finally {
|
||||||
|
t.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateText(art) {
|
||||||
|
const t = texte.value[art]
|
||||||
|
t.loading = true
|
||||||
|
t.error = ''
|
||||||
|
try {
|
||||||
|
t.md = (await createVertiefung({
|
||||||
|
topic: props.topic, baustein: props.baustein, section: props.section, art, provider: props.provider,
|
||||||
|
})).md
|
||||||
|
emit('statusChanged', { ...st.value, [art]: true })
|
||||||
|
} catch (e) {
|
||||||
|
t.error = e.message
|
||||||
|
} finally {
|
||||||
|
t.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bausteinchat (flüchtig) ---
|
||||||
|
const chat = useChat((msgs) => chatBaustein({
|
||||||
|
topic: props.topic, baustein: props.baustein, section: props.section,
|
||||||
|
messages: msgs, provider: props.provider,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// --- Prüfung: gesteuerter Dialog (Verlauf flüchtig, Zähler serverseitig) ---
|
||||||
|
// Phasen: 'idle' (Frage anfordern) | 'frage_offen' (antworten/nachfragen) | 'bewertet' (diskutieren/neu bewerten/weiter)
|
||||||
|
const pruefMessages = ref([]) // {role, kind: 'frage'|'nachfrage'|'antwort'|'feedback'|'diskussion'|'fehler', content, bewertung?}
|
||||||
|
const pruefInput = ref('')
|
||||||
|
const pruefPhase = ref('idle')
|
||||||
|
const pruefLoading = ref(false)
|
||||||
|
const aktuelleFrage = ref('') // ankert Bewertung/Diskussion
|
||||||
|
const letztesFeedback = ref('') // Kontext für die Diskussion über eine Bewertung
|
||||||
|
const scoreVorFrage = ref(0) // Score, als die aktuelle Frage gestellt wurde → driftfreies (Re-)Bewerten
|
||||||
|
const naechsteFrage = ref(null) // im Hintergrund vorbereitete nächste Frage (Prefetch)
|
||||||
|
let prefetchPromise = null // laufender Hintergrund-Abruf (verhindert Doppel-Prefetch)
|
||||||
|
const pruefMessagesEl = ref(null)
|
||||||
|
const pruefInputEl = ref(null)
|
||||||
|
const pruefStick = ref(true) // nur auto-scrollen, wenn der Nutzer (fast) unten ist
|
||||||
|
let pruefRun = 0
|
||||||
|
|
||||||
|
function onPruefScroll() {
|
||||||
|
if (pruefMessagesEl.value) pruefStick.value = istUnten(pruefMessagesEl.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPruefung(res) {
|
||||||
|
emit('statusChanged', { ...st.value, gute_antworten: res.gute_antworten, absolviert: res.absolviert, verstanden: res.verstanden, gemeistert: res.gemeistert })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pruefScroll() {
|
||||||
|
await nextTick()
|
||||||
|
if (pruefMessagesEl.value && pruefStick.value) pruefMessagesEl.value.scrollTop = pruefMessagesEl.value.scrollHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur echte Gesprächs-Turns ans Backend; Feedback bleibt reines UI-Artefakt.
|
||||||
|
function pruefDialog() {
|
||||||
|
return pruefMessages.value
|
||||||
|
.filter((m) => m.kind !== 'feedback' && m.kind !== 'fehler')
|
||||||
|
.map((m) => ({ role: m.role, content: m.content }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pruefSenden(payload, onOk) {
|
||||||
|
const run = ++pruefRun
|
||||||
|
pruefStick.value = true // eigene Aktion = ans Ende; Hochscrollen während des Wartens setzt es wieder false
|
||||||
|
pruefLoading.value = true
|
||||||
|
pruefScroll()
|
||||||
|
try {
|
||||||
|
const res = await pruefeBaustein({
|
||||||
|
topic: props.topic, baustein: props.baustein, section: props.section,
|
||||||
|
provider: props.provider, messages: pruefDialog(), ...payload,
|
||||||
|
})
|
||||||
|
if (run !== pruefRun) return
|
||||||
|
onOk(res)
|
||||||
|
applyPruefung(res)
|
||||||
|
pruefScroll()
|
||||||
|
nextTick(() => pruefInputEl.value?.focus())
|
||||||
|
} catch {
|
||||||
|
if (run === pruefRun) pruefMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Hat nicht geklappt — bitte erneut.' })
|
||||||
|
} finally {
|
||||||
|
if (run === pruefRun) pruefLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frage anzeigen (frisch oder vorgemerkt). scoreVorFrage = LIVE-Score zur Anzeigezeit (driftfrei).
|
||||||
|
function frageZeigen(text) {
|
||||||
|
aktuelleFrage.value = text
|
||||||
|
letztesFeedback.value = ''
|
||||||
|
scoreVorFrage.value = st.value.gute_antworten
|
||||||
|
pruefMessages.value.push({ role: 'assistant', kind: 'frage', content: text })
|
||||||
|
pruefPhase.value = 'frage_offen'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nächste Frage im Hintergrund vorbereiten (überbrückt die Wartezeit). Ohne pruefLoading/pruefRun.
|
||||||
|
function prefetchFrage() {
|
||||||
|
if (naechsteFrage.value || prefetchPromise) return
|
||||||
|
prefetchPromise = pruefeBaustein({
|
||||||
|
topic: props.topic, baustein: props.baustein, section: props.section,
|
||||||
|
provider: props.provider, aktion: 'frage', messages: pruefDialog(),
|
||||||
|
})
|
||||||
|
.then((res) => { naechsteFrage.value = res.frage })
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => { prefetchPromise = null })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erste Frage (Phase idle): frisch generieren, dann die nächste vorbereiten.
|
||||||
|
function frageAnfordern() {
|
||||||
|
if (pruefLoading.value) return
|
||||||
|
pruefSenden({ aktion: 'frage' }, (res) => {
|
||||||
|
frageZeigen(res.frage)
|
||||||
|
prefetchFrage()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// „Nächste Frage": vorgemerkte sofort zeigen, sonst auf den Prefetch warten, sonst frisch holen.
|
||||||
|
async function naechsteFrageZeigen() {
|
||||||
|
if (pruefLoading.value) return
|
||||||
|
if (!naechsteFrage.value && prefetchPromise) {
|
||||||
|
pruefLoading.value = true
|
||||||
|
try { await prefetchPromise } finally { pruefLoading.value = false }
|
||||||
|
}
|
||||||
|
if (naechsteFrage.value) {
|
||||||
|
const text = naechsteFrage.value
|
||||||
|
naechsteFrage.value = null
|
||||||
|
frageZeigen(text)
|
||||||
|
pruefScroll()
|
||||||
|
nextTick(() => pruefInputEl.value?.focus())
|
||||||
|
prefetchFrage()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Fallback: kein Cache → frisch generieren.
|
||||||
|
pruefSenden({ aktion: 'frage' }, (res) => {
|
||||||
|
frageZeigen(res.frage)
|
||||||
|
prefetchFrage()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function nachfragen() {
|
||||||
|
const text = pruefInput.value.trim()
|
||||||
|
if (!text || pruefLoading.value) return
|
||||||
|
pruefMessages.value.push({ role: 'user', kind: 'nachfrage', content: text })
|
||||||
|
pruefInput.value = ''
|
||||||
|
pruefSenden(
|
||||||
|
{ aktion: 'diskussion', frage: aktuelleFrage.value, letzte_bewertung: letztesFeedback.value },
|
||||||
|
(res) => pruefMessages.value.push({ role: 'assistant', kind: 'diskussion', content: res.reply }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function bewerten(res) {
|
||||||
|
letztesFeedback.value = res.feedback || ''
|
||||||
|
pruefMessages.value.push({ role: 'assistant', kind: 'feedback', content: res.feedback || '', bewertung: res.bewertung })
|
||||||
|
pruefPhase.value = 'bewertet'
|
||||||
|
}
|
||||||
|
|
||||||
|
function antwortPayload() {
|
||||||
|
return {
|
||||||
|
aktion: 'antwort', frage: aktuelleFrage.value, score_vor_frage: scoreVorFrage.value,
|
||||||
|
tier2: props.tier2, tier3: props.tier3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function antwortAbgeben() {
|
||||||
|
const text = pruefInput.value.trim()
|
||||||
|
if (!text || pruefLoading.value) return
|
||||||
|
pruefMessages.value.push({ role: 'user', kind: 'antwort', content: text })
|
||||||
|
pruefInput.value = ''
|
||||||
|
pruefSenden(antwortPayload(), bewerten)
|
||||||
|
}
|
||||||
|
|
||||||
|
function neuBewerten() {
|
||||||
|
if (pruefLoading.value) return
|
||||||
|
pruefSenden(antwortPayload(), bewerten)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bp">
|
||||||
|
<div class="bp-toggles">
|
||||||
|
<button :class="{ active: activeTab === 'vertiefung' }" @click="toggle('vertiefung')">
|
||||||
|
Vertiefung
|
||||||
|
</button>
|
||||||
|
<button :class="{ active: activeTab === 'deepdive' }" @click="toggle('deepdive')">
|
||||||
|
Amateur
|
||||||
|
</button>
|
||||||
|
<button :class="{ active: activeTab === 'chat' }" @click="toggle('chat')">
|
||||||
|
Chat
|
||||||
|
</button>
|
||||||
|
<button :class="{ active: activeTab === 'pruefung' }" @click="toggle('pruefung')">
|
||||||
|
Prüfung
|
||||||
|
<span v-if="st.gemeistert" class="bp-chip gold" title="Gemeistert (25/25)">✓✓✓ Max</span>
|
||||||
|
<span v-else-if="st.verstanden && tier3" class="bp-chip lila" title="Meisterpfad">✓✓ {{ st.gute_antworten }}/{{ MEISTERN }}</span>
|
||||||
|
<span v-else-if="st.verstanden" class="bp-chip lila" title="Verstanden (10/10)">✓✓</span>
|
||||||
|
<span v-else-if="st.absolviert && tier2" class="bp-chip done">✓ {{ st.gute_antworten }}/{{ MAX }}</span>
|
||||||
|
<span v-else-if="st.absolviert" class="bp-chip done">✓</span>
|
||||||
|
<span v-else-if="st.gute_antworten" class="bp-chip">{{ Math.min(st.gute_antworten, NOETIG) }}/{{ NOETIG }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="activeTab" class="bp-panel">
|
||||||
|
<!-- Vertiefung (gleicher Stoff, mehr) / Amateur (gleicher Stoff, für Einsteiger) -->
|
||||||
|
<div v-if="activeTab === 'vertiefung' || activeTab === 'deepdive'">
|
||||||
|
<p v-if="texte[activeTab].loading" class="bp-hint">{{ texte[activeTab].md === null ? 'Generiere…' : 'Lade…' }}</p>
|
||||||
|
<template v-else-if="texte[activeTab].md">
|
||||||
|
<div class="markdown" v-html="renderMarkdown(texte[activeTab].md)"></div>
|
||||||
|
<button class="bp-action" @click="generateText(activeTab)">Neu generieren</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<p class="bp-hint">{{ activeTab === 'deepdive' ? 'Noch keine Amateur-Fassung zu diesem Baustein.' : 'Noch keine Vertiefung zu diesem Baustein.' }}</p>
|
||||||
|
<button class="bp-action" @click="generateText(activeTab)">
|
||||||
|
{{ activeTab === 'deepdive' ? 'Amateur generieren' : 'Vertiefung generieren' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<p v-if="texte[activeTab].error" class="bp-error">{{ texte[activeTab].error }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bausteinchat -->
|
||||||
|
<div v-else-if="activeTab === 'chat'">
|
||||||
|
<div :ref="chat.messagesEl" class="bp-messages" @scroll="chat.onScroll">
|
||||||
|
<p v-if="!chat.messages.value.length" class="bp-hint">Frag etwas zu diesem Baustein. Der Verlauf wird nicht gespeichert.</p>
|
||||||
|
<template v-for="(m, i) in chat.messages.value" :key="i">
|
||||||
|
<div v-if="m.role === 'assistant'" class="bp-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
|
||||||
|
<div v-else class="bp-msg user">{{ m.content }}</div>
|
||||||
|
</template>
|
||||||
|
<div v-if="chat.loading.value" class="bp-msg assistant bp-typing">Denkt…</div>
|
||||||
|
</div>
|
||||||
|
<div class="bp-input">
|
||||||
|
<textarea
|
||||||
|
:ref="chat.inputEl"
|
||||||
|
v-model="chat.input.value"
|
||||||
|
rows="2"
|
||||||
|
placeholder="Frage zum Baustein…"
|
||||||
|
@keydown.enter.exact.prevent="chat.send"
|
||||||
|
></textarea>
|
||||||
|
<button :disabled="!chat.input.value.trim() && !chat.loading.value" :class="{ cancel: chat.loading.value }" @click="chat.send">
|
||||||
|
{{ chat.loading.value ? '✕' : '➤' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Prüfung: gesteuerter Dialog -->
|
||||||
|
<div v-else>
|
||||||
|
<p class="bp-hint">
|
||||||
|
<template v-if="st.gemeistert">✓✓✓ Gemeistert ({{ MEISTERN }}/{{ MEISTERN }}) — Max. Du kannst dich weiter prüfen, ohne Punkte.</template>
|
||||||
|
<template v-else-if="st.verstanden && tier3">Meisterpfad: {{ st.gute_antworten }}/{{ MEISTERN }}. Richtig = +1, falsch = −2 (nicht unter {{ MAX }}). Bei {{ MEISTERN }} gemeistert.</template>
|
||||||
|
<template v-else-if="st.verstanden">✓✓ Verstanden. Der Meisterpfad ({{ MAX }}→{{ MEISTERN }}) öffnet, sobald der ganze Guide verstanden ist.</template>
|
||||||
|
<template v-else-if="st.absolviert && tier2">Mastery: {{ st.gute_antworten }}/{{ MAX }}. Richtig = +1, falsch = −1 (nicht unter {{ NOETIG }}). Bei {{ MAX }} verstanden.</template>
|
||||||
|
<template v-else-if="st.absolviert">✓ Absolviert. Mehr ({{ NOETIG }}→{{ MAX }}) gibt's, sobald der ganze Guide absolviert ist.</template>
|
||||||
|
<template v-else>{{ Math.min(st.gute_antworten, NOETIG) }}/{{ NOETIG }} guten Antworten. Frag nach, wenn etwas unklar ist — diskutieren ist erlaubt.</template>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="pruefMessages.length" ref="pruefMessagesEl" class="bp-messages" @scroll="onPruefScroll">
|
||||||
|
<template v-for="(m, i) in pruefMessages" :key="i">
|
||||||
|
<div v-if="m.kind === 'feedback'" class="bp-feedback" :class="m.bewertung">{{ m.content }}</div>
|
||||||
|
<div v-else-if="m.kind === 'fehler'" class="bp-error">{{ m.content }}</div>
|
||||||
|
<div v-else-if="m.role === 'assistant'" class="bp-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
|
||||||
|
<div v-else class="bp-msg user">{{ m.content }}</div>
|
||||||
|
</template>
|
||||||
|
<div v-if="pruefLoading" class="bp-msg assistant bp-typing">…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Phase idle: Frage anfordern -->
|
||||||
|
<div v-if="pruefPhase === 'idle'" class="bp-actions">
|
||||||
|
<button class="bp-action primary" :disabled="pruefLoading" @click="frageAnfordern">Frage anfordern</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Phase frage_offen / bewertet: Textfeld + Aktionen -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="bp-input">
|
||||||
|
<textarea
|
||||||
|
ref="pruefInputEl"
|
||||||
|
v-model="pruefInput"
|
||||||
|
rows="2"
|
||||||
|
:placeholder="pruefPhase === 'frage_offen' ? 'Antwort — oder Nachfrage bei Unklarheit…' : 'Nachhaken oder diskutieren…'"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="bp-actions">
|
||||||
|
<template v-if="pruefPhase === 'frage_offen'">
|
||||||
|
<button class="bp-action" :disabled="pruefLoading || !pruefInput.trim()" @click="nachfragen">Nachfragen</button>
|
||||||
|
<button class="bp-action primary" :disabled="pruefLoading || !pruefInput.trim()" @click="antwortAbgeben">Antwort abgeben</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<button class="bp-action" :disabled="pruefLoading || !pruefInput.trim()" @click="nachfragen">Nachhaken</button>
|
||||||
|
<button class="bp-action" :disabled="pruefLoading" @click="neuBewerten">Neu bewerten</button>
|
||||||
|
<button class="bp-action primary" :disabled="pruefLoading" @click="naechsteFrageZeigen">Nächste Frage</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bp { margin-top: 0.75rem; }
|
||||||
|
|
||||||
|
.bp-toggles { display: flex; gap: 0.4rem; }
|
||||||
|
.bp-toggles button {
|
||||||
|
display: inline-flex; align-items: center; gap: 0.35rem;
|
||||||
|
padding: 0.25rem 0.7rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.bp-toggles button:hover { border-color: var(--border-strong); color: var(--text); }
|
||||||
|
.bp-toggles button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
||||||
|
|
||||||
|
.bp-chip {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0 0.35rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.bp-chip.done { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
|
||||||
|
.bp-chip.lila { background: color-mix(in srgb, #8b5cf6 16%, var(--panel)); border-color: #8b5cf6; color: #6d28d9; }
|
||||||
|
.bp-chip.gold { background: color-mix(in srgb, #d4af37 20%, var(--panel)); border-color: #d4af37; color: #8a6d12; }
|
||||||
|
|
||||||
|
.bp-panel {
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
padding: 0.75rem 0.9rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bp-hint { font-size: 0.85rem; color: var(--text-muted); margin: 0 0 0.5rem; }
|
||||||
|
.bp-error { font-size: 0.85rem; color: var(--danger); margin: 0.5rem 0 0; }
|
||||||
|
|
||||||
|
.bp-action {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.3rem 0.8rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.bp-action:hover { border-color: var(--accent); }
|
||||||
|
.bp-action:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
.bp-action.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
||||||
|
.bp-action.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
||||||
|
|
||||||
|
.bp-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.5rem; }
|
||||||
|
.bp-actions .bp-action { margin-top: 0; }
|
||||||
|
|
||||||
|
.bp-messages { display: flex; flex-direction: column; gap: 0.4rem; max-height: 320px; overflow-y: auto; }
|
||||||
|
.bp-msg {
|
||||||
|
max-width: 88%;
|
||||||
|
padding: 0.4rem 0.65rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.bp-msg.user { align-self: flex-end; background: var(--accent); color: var(--on-accent); white-space: pre-wrap; }
|
||||||
|
.bp-msg.assistant { align-self: flex-start; background: var(--panel); border: 1px solid var(--border); }
|
||||||
|
.bp-typing { color: var(--text-faint); font-style: italic; }
|
||||||
|
|
||||||
|
/* Bewertung der letzten Antwort — getrennt über der nächsten Frage */
|
||||||
|
.bp-feedback {
|
||||||
|
align-self: flex-start;
|
||||||
|
max-width: 88%;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.bp-feedback.gut { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
|
||||||
|
.bp-feedback.schlecht { background: var(--danger-soft, #fee2e2); border-color: var(--danger-border, #f87171); color: var(--danger); }
|
||||||
|
|
||||||
|
.bp-input { display: flex; gap: 0.4rem; margin-top: 0.55rem; align-items: flex-end; }
|
||||||
|
.bp-input textarea {
|
||||||
|
flex: 1;
|
||||||
|
resize: none;
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.bp-input button {
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--on-accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.bp-input button:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
.bp-input button.cancel { background: var(--danger); }
|
||||||
|
</style>
|
||||||
@@ -161,45 +161,10 @@ function plain(text) {
|
|||||||
margin-bottom: 0.2rem;
|
margin-bottom: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Markdown im Guide-Stil */
|
/* Markdown: Basis global (assets/markdown.css), hier nur Grundschrift der Karten */
|
||||||
.markdown {
|
.markdown {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
color: var(--text);
|
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>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,20 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
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 { renderMarkdown } from '../markdown.js'
|
||||||
|
import { useChat } from '../composables/useChat.js'
|
||||||
|
import BausteinPanel from './BausteinPanel.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
previewGuide: { type: Object, default: null },
|
previewGuide: { type: Object, default: null },
|
||||||
dark: { type: Boolean, default: false },
|
dark: { type: Boolean, default: false },
|
||||||
provider: { type: String, default: 'claude' },
|
provider: { type: String, default: 'claude' },
|
||||||
elementsOpen: { type: Boolean, default: false }, // Element-Sidebar offen → Chat nach links
|
elementsOpen: { type: Boolean, default: false }, // Element-Sidebar offen → Chat nach links
|
||||||
|
doneByFormat: { type: Object, default: () => ({}) }, // Format → fertiger Guide (Themen-bezogen)
|
||||||
|
themaAbgeschlossen: { type: Boolean, default: false },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['progressChanged', 'openElements'])
|
const emit = defineEmits(['progressChanged'])
|
||||||
|
|
||||||
const isOnePager = computed(() => props.previewGuide?.format === 'OnePager')
|
const isOnePager = computed(() => props.previewGuide?.format === 'OnePager')
|
||||||
|
|
||||||
@@ -20,7 +24,6 @@ const CH_COLORS = ['#3b82f6', '#8b5cf6', '#14b8a6', '#f59e0b', '#22c55e', '#6366
|
|||||||
// --- Inhalt laden ---
|
// --- Inhalt laden ---
|
||||||
const content = ref(null)
|
const content = ref(null)
|
||||||
const loadError = ref(null)
|
const loadError = ref(null)
|
||||||
const doneChapters = ref(new Set())
|
|
||||||
const scrollEl = ref(null)
|
const scrollEl = ref(null)
|
||||||
|
|
||||||
watch(() => props.previewGuide?.id, loadContent, { immediate: true })
|
watch(() => props.previewGuide?.id, loadContent, { immediate: true })
|
||||||
@@ -28,7 +31,7 @@ watch(() => props.previewGuide?.id, loadContent, { immediate: true })
|
|||||||
async function loadContent() {
|
async function loadContent() {
|
||||||
content.value = null
|
content.value = null
|
||||||
loadError.value = null
|
loadError.value = null
|
||||||
doneChapters.value = new Set()
|
lernstand.value = {}
|
||||||
const g = props.previewGuide
|
const g = props.previewGuide
|
||||||
if (!g || g.status !== 'done') return
|
if (!g || g.status !== 'done') return
|
||||||
try {
|
try {
|
||||||
@@ -38,47 +41,36 @@ async function loadContent() {
|
|||||||
loadError.value = 'Inhalt nicht verfügbar — die Datei fehlt. Guide neu generieren (▶).'
|
loadError.value = 'Inhalt nicht verfügbar — die Datei fehlt. Guide neu generieren (▶).'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
if (g.format !== 'OnePager') {
|
||||||
const res = await fetchProgress(g.id)
|
try {
|
||||||
doneChapters.value = new Set(res.chapters || [])
|
lernstand.value = (await fetchBausteinLernstand(g.topic)).bausteine || {}
|
||||||
} catch { /* offline → leer */ }
|
} 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' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Chat ---
|
// --- Baustein-Lernen: Prüfungs-Stand pro Baustein-Titel ---
|
||||||
|
const lernstand = ref({})
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap pro Baustein folgt den erstellten Formaten: Guide vorhanden → bis 10, FullGuide → bis 25.
|
||||||
|
const tier2 = computed(() => !!props.doneByFormat?.Guide)
|
||||||
|
const tier3 = computed(() => !!props.doneByFormat?.FullGuide)
|
||||||
|
|
||||||
|
// --- 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 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)
|
const panelEl = ref(null)
|
||||||
|
|
||||||
function openChat() {
|
function openChat() {
|
||||||
@@ -88,10 +80,15 @@ function openChat() {
|
|||||||
|
|
||||||
function closeChat() {
|
function closeChat() {
|
||||||
chatOpen.value = false
|
chatOpen.value = false
|
||||||
messages.value = []
|
chat.reset()
|
||||||
input.value = ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
function onDocMouseDown(e) {
|
||||||
if (!chatOpen.value) return
|
if (!chatOpen.value) return
|
||||||
if (panelEl.value && panelEl.value.contains(e.target)) return
|
if (panelEl.value && panelEl.value.contains(e.target)) return
|
||||||
@@ -141,43 +138,6 @@ function extractContext() {
|
|||||||
return { section, outline }
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -187,12 +147,12 @@ async function send() {
|
|||||||
<header class="guide-head">
|
<header class="guide-head">
|
||||||
<h1>{{ previewGuide.topic }}</h1>
|
<h1>{{ previewGuide.topic }}</h1>
|
||||||
<span class="guide-format">{{ previewGuide.format }}</span>
|
<span class="guide-format">{{ previewGuide.format }}</span>
|
||||||
|
<span v-if="themaAbgeschlossen" class="thema-done" title="Alle FullGuide-Bausteine gemeistert (25/25)">✓ Thema abgeschlossen</span>
|
||||||
</header>
|
</header>
|
||||||
<section
|
<section
|
||||||
v-for="(ch, ci) in content.chapters"
|
v-for="(ch, ci) in content.chapters"
|
||||||
:key="ch.title"
|
:key="ch.title"
|
||||||
class="chapter"
|
class="chapter"
|
||||||
:class="{ 'ch-complete': doneChapters.has(ch.title) }"
|
|
||||||
:style="{ '--ch-accent': CH_COLORS[ci % CH_COLORS.length] }"
|
:style="{ '--ch-accent': CH_COLORS[ci % CH_COLORS.length] }"
|
||||||
>
|
>
|
||||||
<h2 class="chapter-title"><span class="ch-num">{{ ci + 1 }}</span>{{ ch.title }}</h2>
|
<h2 class="chapter-title"><span class="ch-num">{{ ci + 1 }}</span>{{ ch.title }}</h2>
|
||||||
@@ -200,19 +160,29 @@ async function send() {
|
|||||||
<article
|
<article
|
||||||
v-for="s in ch.sections"
|
v-for="s in ch.sections"
|
||||||
:key="s.num"
|
:key="s.num"
|
||||||
:class="['section-card', isOnePager && s.key ? 'op-card op-' + s.key : '']"
|
:class="['section-card', isOnePager && s.key ? 'op-card op-' + s.key : '', lernstand[s.title]?.gemeistert ? 'gemeistert' : (lernstand[s.title]?.verstanden ? 'verstanden' : (lernstand[s.title]?.absolviert ? 'absolviert' : ''))]"
|
||||||
:style="isOnePager && s.key ? { gridArea: s.key } : null"
|
:style="isOnePager && s.key ? { gridArea: s.key } : null"
|
||||||
>
|
>
|
||||||
<h3>{{ s.title }}</h3>
|
<h3>
|
||||||
|
{{ s.title }}
|
||||||
|
<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>
|
||||||
|
</h3>
|
||||||
<div class="section-body markdown" v-html="renderMarkdown(s.md)"></div>
|
<div class="section-body markdown" v-html="renderMarkdown(s.md)"></div>
|
||||||
|
<BausteinPanel
|
||||||
|
v-if="!isOnePager"
|
||||||
|
:topic="previewGuide.topic"
|
||||||
|
:baustein="s.title"
|
||||||
|
:section="s.md"
|
||||||
|
:provider="provider"
|
||||||
|
:status="lernstand[s.title]"
|
||||||
|
:tier2="tier2"
|
||||||
|
:tier3="tier3"
|
||||||
|
@status-changed="(st) => onBausteinStatus(s.title, st)"
|
||||||
|
/>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -226,14 +196,13 @@ async function send() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button v-if="previewGuide && !chatOpen" class="chat-fab" :class="{ shifted: elementsOpen }" title="Fragen zum Guide" @click="openChat">💬</button>
|
<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>
|
|
||||||
|
|
||||||
<div v-if="previewGuide && chatOpen" ref="panelEl" class="chat-panel" :class="{ shifted: elementsOpen }">
|
<div v-if="previewGuide && chatOpen" ref="panelEl" class="chat-panel" :class="{ shifted: elementsOpen }">
|
||||||
<header class="chat-header">
|
<header class="chat-header">
|
||||||
<span>Fragen zum Guide</span>
|
<span>Fragen zum Guide</span>
|
||||||
<button class="chat-close" title="Chat beenden" @click="closeChat">×</button>
|
<button class="chat-close" title="Chat beenden" @click="closeChat">×</button>
|
||||||
</header>
|
</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>
|
<p v-if="!messages.length" class="chat-hint">Stell eine Frage zum aktuellen Abschnitt.</p>
|
||||||
<template v-for="(m, i) in messages" :key="i">
|
<template v-for="(m, i) in messages" :key="i">
|
||||||
<div v-if="m.role === 'assistant'" class="chat-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
|
<div v-if="m.role === 'assistant'" class="chat-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
|
||||||
@@ -250,7 +219,12 @@ async function send() {
|
|||||||
@input="autoGrow"
|
@input="autoGrow"
|
||||||
@keydown.enter.exact.prevent="send"
|
@keydown.enter.exact.prevent="send"
|
||||||
></textarea>
|
></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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -305,6 +279,16 @@ async function send() {
|
|||||||
font-weight: 600;
|
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 {
|
.chapter {
|
||||||
margin-bottom: 2.5rem;
|
margin-bottom: 2.5rem;
|
||||||
}
|
}
|
||||||
@@ -333,10 +317,6 @@ async function send() {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chapter.ch-complete .sections {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-card {
|
.section-card {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -345,6 +325,51 @@ async function send() {
|
|||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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:not(.onepager) .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:not(.onepager) .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:not(.onepager) .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 (OnePager hat eigene op-card-Farben) */
|
/* Guides: Karten tragen die Kapitel-Akzentfarbe (OnePager hat eigene op-card-Farben) */
|
||||||
.guide-content:not(.onepager) .section-card {
|
.guide-content:not(.onepager) .section-card {
|
||||||
border-top: 3px solid color-mix(in srgb, var(--ch-accent, var(--accent)) 65%, transparent);
|
border-top: 3px solid color-mix(in srgb, var(--ch-accent, var(--accent)) 65%, transparent);
|
||||||
@@ -461,35 +486,6 @@ async function send() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-preview {
|
.empty-preview {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -498,56 +494,13 @@ async function send() {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Markdown (Sections + Chat) --- */
|
/* --- Markdown: Basis global (assets/markdown.css), hier nur Lese-Ansicht-Overrides --- */
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/* Breite Lese-Ansicht: Code scrollt horizontal statt umzubrechen */
|
||||||
.markdown :deep(pre) {
|
.markdown :deep(pre) {
|
||||||
background: var(--code-bg, #1e2330);
|
white-space: pre;
|
||||||
color: var(--code-fg, #e6e8ee);
|
overflow-wrap: normal;
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow-x: auto;
|
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 */
|
/* „Beispiel"-Überschriften in Karten als dezentes Uppercase-Label */
|
||||||
@@ -559,21 +512,6 @@ async function send() {
|
|||||||
margin: 0.9em 0 0.35em;
|
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 —
|
/* Lesbarkeit: ~17px Fließtext, Zeilenhöhe 1.6, Textspalte max. ~70 Zeichen —
|
||||||
Code-Blöcke dürfen die volle Kartenbreite nutzen */
|
Code-Blöcke dürfen die volle Kartenbreite nutzen */
|
||||||
.section-body {
|
.section-body {
|
||||||
@@ -614,10 +552,6 @@ async function send() {
|
|||||||
background: var(--accent-hover);
|
background: var(--accent-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.elements-fab {
|
|
||||||
right: 5.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Element-Sidebar (320px) offen → Chat links daneben anzeigen */
|
/* Element-Sidebar (320px) offen → Chat links daneben anzeigen */
|
||||||
.chat-fab.shifted {
|
.chat-fab.shifted {
|
||||||
right: calc(1.5rem + 320px);
|
right: calc(1.5rem + 320px);
|
||||||
@@ -627,6 +561,14 @@ async function send() {
|
|||||||
right: calc(1.5rem + 320px);
|
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 {
|
.chat-panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 1.5rem;
|
right: 1.5rem;
|
||||||
@@ -756,4 +698,8 @@ async function send() {
|
|||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-input button.cancel {
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
import { useConfirm } from '../composables/useConfirm.js'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
topics: { type: Array, required: true },
|
topics: { type: Array, required: true },
|
||||||
@@ -7,6 +8,8 @@ const props = defineProps({
|
|||||||
selectedTopic: { type: String, default: null },
|
selectedTopic: { type: String, default: null },
|
||||||
stats: { type: Object, default: null },
|
stats: { type: Object, default: null },
|
||||||
fortschritt: { type: Object, default: () => ({}) },
|
fortschritt: { type: Object, default: () => ({}) },
|
||||||
|
locks: { type: Object, default: () => ({}) },
|
||||||
|
uiError: { type: String, default: null },
|
||||||
doneByFormat: { type: Object, default: () => ({}) },
|
doneByFormat: { type: Object, default: () => ({}) },
|
||||||
latestByFormat: { type: Object, default: () => ({}) },
|
latestByFormat: { type: Object, default: () => ({}) },
|
||||||
allGuides: { type: Array, default: () => [] },
|
allGuides: { type: Array, default: () => [] },
|
||||||
@@ -19,14 +22,14 @@ const props = defineProps({
|
|||||||
providers: { type: Array, default: () => [] },
|
providers: { type: Array, default: () => [] },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['select', 'create', 'formatClick', 'bausteineClick', 'cancelBausteine', 'resetBausteine', 'deleteTopic', 'deleteProject', 'cancelGuide', 'deleteGuide', 'dismissError', 'preview', 'openElements', 'togglePin', 'sidebarLeave', 'toggleDark', 'setProvider'])
|
const emit = defineEmits(['select', 'create', 'formatClick', 'bausteineClick', 'cancelBausteine', 'resetBausteine', 'deleteTopic', 'deleteProject', 'cancelGuide', 'deleteGuide', 'dismissError', 'dismissUiError', 'preview', 'openElements', 'togglePin', 'sidebarLeave', 'toggleDark', 'setProvider'])
|
||||||
|
|
||||||
function providerAvailable(id) {
|
function providerAvailable(id) {
|
||||||
const p = props.providers.find((x) => x.id === id)
|
const p = props.providers.find((x) => x.id === id)
|
||||||
return p ? p.available : true
|
return p ? p.available : true
|
||||||
}
|
}
|
||||||
|
|
||||||
const PROVIDER_LABELS = { claude: 'Claude', minimax: 'MiniMax', 'minimax-direkt': 'MiniMax direkt', lokal: 'Lokal' }
|
const PROVIDER_LABELS = { claude: 'Claude', minimax: 'MiniMax', lokal: 'Lokal' }
|
||||||
|
|
||||||
// Tracker oben in der Navigation: Themen gesamt, pro Format erstellt/absolviert
|
// Tracker oben in der Navigation: Themen gesamt, pro Format erstellt/absolviert
|
||||||
const trackerItems = computed(() => {
|
const trackerItems = computed(() => {
|
||||||
@@ -53,31 +56,18 @@ const bausteineState = computed(() => {
|
|||||||
return props.bausteine.ready ? 'done' : 'none'
|
return props.bausteine.ready ? 'done' : 'none'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Nur FREMDE Themen — das gewählte Thema zeigt seinen Fortschritt inline an der Zeile
|
||||||
const activeGenerations = computed(() => {
|
const activeGenerations = computed(() => {
|
||||||
const bausteinLines = props.activeBausteine.map(
|
const bausteinLines = props.activeBausteine
|
||||||
(b) => `${b.topic} – Bausteine: ${b.progress || 'Wartend…'}`,
|
.filter((b) => b.topic !== props.selectedTopic)
|
||||||
)
|
.map((b) => `${b.topic} – Bausteine: ${b.progress || 'Wartend…'}`)
|
||||||
const guideLines = props.allGuides
|
const guideLines = props.allGuides
|
||||||
.filter((g) => g.status === 'generating' || g.status === 'queued')
|
.filter((g) => (g.status === 'generating' || g.status === 'queued') && g.topic !== props.selectedTopic)
|
||||||
.map((g) => `${g.topic} – ${g.format}: ${g.progress || 'Wartend…'}`)
|
.map((g) => `${g.topic} – ${g.format}: ${g.progress || 'Wartend…'}`)
|
||||||
return [...bausteinLines, ...guideLines]
|
return [...bausteinLines, ...guideLines]
|
||||||
})
|
})
|
||||||
|
|
||||||
// Inline-Bestätigung statt confirm(): erster Klick scharfschalten („Sicher?"),
|
const { pending: pendingConfirm, armOrRun } = useConfirm()
|
||||||
// zweiter Klick führt aus. Browser-Dialoge können unterdrückt sein (Firefox).
|
|
||||||
const pendingConfirm = ref(null)
|
|
||||||
let confirmTimer = null
|
|
||||||
|
|
||||||
function armOrRun(key, action) {
|
|
||||||
clearTimeout(confirmTimer)
|
|
||||||
if (pendingConfirm.value === key) {
|
|
||||||
pendingConfirm.value = null
|
|
||||||
action()
|
|
||||||
} else {
|
|
||||||
pendingConfirm.value = key
|
|
||||||
confirmTimer = setTimeout(() => { pendingConfirm.value = null }, 3000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmCancelBausteine() {
|
function confirmCancelBausteine() {
|
||||||
armOrRun('bausteine', () => emit('cancelBausteine'))
|
armOrRun('bausteine', () => emit('cancelBausteine'))
|
||||||
@@ -103,8 +93,8 @@ function guideStatus(format) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Schritt-Kugeln der Guide-Pipelines
|
// Schritt-Kugeln der Guide-Pipelines
|
||||||
const GUIDE_STEPS = ['Auswahl', 'Auswahl-Prüfung', 'Gliederung', 'Gliederungs-Prüfung', 'Schreiben', 'Lese-Prüfung']
|
const GUIDE_STEPS = ['Auswahl', 'Gliederung', 'Schreiben', 'Lese-Prüfung']
|
||||||
const ONEPAGER_STEPS = ['Recherche', 'Recherche-Prüfung', 'Bauen', 'Prüfung']
|
const ONEPAGER_STEPS = ['Recherche', 'Bauen', 'Prüfung']
|
||||||
|
|
||||||
// Kugeln werden wie bei den Bausteinen immer angezeigt:
|
// Kugeln werden wie bei den Bausteinen immer angezeigt:
|
||||||
// fertig = alle grün, laufend = live, abgebrochen = Teilfortschritt, sonst grau
|
// fertig = alle grün, laufend = live, abgebrochen = Teilfortschritt, sonst grau
|
||||||
@@ -112,7 +102,8 @@ function guideSteps(format) {
|
|||||||
const labels = format === 'OnePager' ? ONEPAGER_STEPS : GUIDE_STEPS
|
const labels = format === 'OnePager' ? ONEPAGER_STEPS : GUIDE_STEPS
|
||||||
const st = guideStatus(format)
|
const st = guideStatus(format)
|
||||||
if (st === 'generating' || st === 'queued') {
|
if (st === 'generating' || st === 'queued') {
|
||||||
const step = props.latestByFormat[format]?.step ?? -1
|
// Clamp: alte DB-Läufe können step-Werte oberhalb der neuen Listen haben
|
||||||
|
const step = Math.min(props.latestByFormat[format]?.step ?? -1, labels.length - 1)
|
||||||
return labels.map((label, i) => ({
|
return labels.map((label, i) => ({
|
||||||
label,
|
label,
|
||||||
state: i < step ? 'done' : i === step ? 'active' : 'pending',
|
state: i < step ? 'done' : i === step ? 'active' : 'pending',
|
||||||
@@ -122,7 +113,7 @@ function guideSteps(format) {
|
|||||||
return labels.map((label) => ({ label, state: 'done' }))
|
return labels.map((label) => ({ label, state: 'done' }))
|
||||||
}
|
}
|
||||||
if (abgebrochen(format)) {
|
if (abgebrochen(format)) {
|
||||||
const step = props.latestByFormat[format]?.step ?? 0
|
const step = Math.min(props.latestByFormat[format]?.step ?? 0, labels.length)
|
||||||
return labels.map((label, i) => ({ label, state: i < step ? 'done' : 'pending' }))
|
return labels.map((label, i) => ({ label, state: i < step ? 'done' : 'pending' }))
|
||||||
}
|
}
|
||||||
return labels.map((label) => ({ label, state: 'pending' }))
|
return labels.map((label) => ({ label, state: 'pending' }))
|
||||||
@@ -131,6 +122,7 @@ function guideSteps(format) {
|
|||||||
function errorMsg(format) {
|
function errorMsg(format) {
|
||||||
const latest = props.latestByFormat[format]
|
const latest = props.latestByFormat[format]
|
||||||
if (latest?.status !== 'error' || props.dismissedErrors.has(latest.id)) return ''
|
if (latest?.status !== 'error' || props.dismissedErrors.has(latest.id)) return ''
|
||||||
|
if (abgebrochen(format)) return '' // kein roter Fehler — das Pausiert-Badge zeigt den Zustand
|
||||||
return latest.error_msg || 'Fehler bei der Generierung'
|
return latest.error_msg || 'Fehler bei der Generierung'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,28 +139,11 @@ function handleFormatClick(format) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lernschulden-Regeln (nur Neu-Erstellungen; Resume + Neu-Generieren bestehender erlaubt):
|
// Sperr-Gründe kommen vom Backend (GET /guides/locks) — die Regeln existieren
|
||||||
// Progression pro Thema (MiniGuide → Guide → FullGuide) + max. 3 offene je Format.
|
// nur noch dort. Solange locks noch nicht geladen sind: Button frei, das
|
||||||
const VORSTUFE = { Guide: 'MiniGuide', FullGuide: 'Guide' }
|
// Backend weist ungültige Starts ohnehin ab (sichtbar über uiError).
|
||||||
|
|
||||||
function offeneGuides(format) {
|
|
||||||
const f = props.stats?.formate?.[format]
|
|
||||||
return (f?.erstellt ?? 0) - (f?.absolviert ?? 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
function playLock(format) {
|
function playLock(format) {
|
||||||
if (format === 'OnePager') return null
|
return props.locks?.[format] ?? null
|
||||||
if (!props.bausteine.ready) return 'Erst Bausteine erstellen'
|
|
||||||
if (props.doneByFormat[format] || abgebrochen(format)) return null
|
|
||||||
const vorstufe = VORSTUFE[format]
|
|
||||||
if (vorstufe && !props.fortschritt?.[vorstufe]) {
|
|
||||||
return `Erst den ${vorstufe} dieses Themas absolvieren`
|
|
||||||
}
|
|
||||||
const offen = offeneGuides(format)
|
|
||||||
if (offen >= 3) {
|
|
||||||
return `Erst ${format}s absolvieren — ${offen} offen (max. 3)`
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePlay(format) {
|
function handlePlay(format) {
|
||||||
@@ -192,6 +167,9 @@ function handleDelete(format) {
|
|||||||
)
|
)
|
||||||
if (running.length) {
|
if (running.length) {
|
||||||
for (const g of running) emit('cancelGuide', g.id)
|
for (const g of running) emit('cancelGuide', g.id)
|
||||||
|
} else if (abgebrochen(format)) {
|
||||||
|
// Pausierter Lauf: Teilfortschritt samt Schritt-Dateien löschen (Reset)
|
||||||
|
emit('deleteGuide', props.latestByFormat[format].id, true)
|
||||||
} else {
|
} else {
|
||||||
emit('deleteGuide', props.latestByFormat[format].id)
|
emit('deleteGuide', props.latestByFormat[format].id)
|
||||||
}
|
}
|
||||||
@@ -253,12 +231,24 @@ function confirmDeleteProject(name) {
|
|||||||
>{{ PROVIDER_LABELS[p.id] || p.id }}</button>
|
>{{ PROVIDER_LABELS[p.id] || p.id }}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="format-section" v-if="selectedTopic">
|
<div class="format-section" v-if="selectedTopic">
|
||||||
|
<div class="format-error ui-error" v-if="uiError">
|
||||||
|
<span class="format-error-text">{{ uiError }}</span>
|
||||||
|
<button class="format-error-x" title="Ausblenden" @click="emit('dismissUiError')">×</button>
|
||||||
|
</div>
|
||||||
<div class="progress-info" v-if="activeGenerations.length">
|
<div class="progress-info" v-if="activeGenerations.length">
|
||||||
<div v-for="(line, i) in activeGenerations" :key="i">{{ line }}</div>
|
<div v-for="(line, i) in activeGenerations" :key="i">{{ line }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="format-row bausteine-row ord-bausteine">
|
<div
|
||||||
|
class="format-row bausteine-row ord-bausteine"
|
||||||
|
:class="{ 'is-active': bausteineState === 'generating' || bausteine.partial }"
|
||||||
|
>
|
||||||
<div class="format-name bausteine-name">
|
<div class="format-name bausteine-name">
|
||||||
<span class="format-label">Bausteine</span>
|
<span class="format-label">Bausteine</span>
|
||||||
|
<span
|
||||||
|
v-if="bausteine.partial && bausteineState !== 'generating'"
|
||||||
|
class="resume-badge"
|
||||||
|
title="Abgebrochen — ▶ setzt fort"
|
||||||
|
>Pausiert</span>
|
||||||
<span class="step-dots">
|
<span class="step-dots">
|
||||||
<span
|
<span
|
||||||
v-for="s in (bausteine.steps || [])"
|
v-for="s in (bausteine.steps || [])"
|
||||||
@@ -293,14 +283,22 @@ function confirmDeleteProject(name) {
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="bausteine.error" class="format-error ord-bausteine">
|
<div v-if="bausteineState === 'generating'" class="format-progress ord-bausteine">
|
||||||
|
{{ bausteine.progress || 'Wartend…' }}
|
||||||
|
</div>
|
||||||
|
<div v-if="bausteine.error && !bausteine.error.startsWith('Abgebrochen')" class="format-error ord-bausteine">
|
||||||
<span class="format-error-text">{{ bausteine.error }}</span>
|
<span class="format-error-text">{{ bausteine.error }}</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- OnePager (unabhängig von Bausteinen) steht per CSS-order vor der Bausteine-Zeile -->
|
<!-- OnePager (unabhängig von Bausteinen) steht per CSS-order vor der Bausteine-Zeile -->
|
||||||
<div v-for="f in formats" :key="f.key" :style="{ order: f.key === 'OnePager' ? 1 : 3 }">
|
<div v-for="f in formats" :key="f.key" :style="{ order: f.key === 'OnePager' ? 1 : 3 }">
|
||||||
<div :class="['format-row', 'fmt-' + guideStatus(f.key)]">
|
<div :class="['format-row', 'fmt-' + guideStatus(f.key), { 'fmt-paused': abgebrochen(f.key) }]">
|
||||||
<button class="format-name" @click="handleFormatClick(f.key)">
|
<button class="format-name" @click="handleFormatClick(f.key)">
|
||||||
<span class="format-label">{{ f.label }}</span>
|
<span class="format-label">{{ f.label }}</span>
|
||||||
|
<span
|
||||||
|
v-if="abgebrochen(f.key)"
|
||||||
|
class="resume-badge"
|
||||||
|
title="Abgebrochen — ▶ setzt fort"
|
||||||
|
>Pausiert</span>
|
||||||
<span class="step-dots" v-if="guideSteps(f.key).length">
|
<span class="step-dots" v-if="guideSteps(f.key).length">
|
||||||
<span
|
<span
|
||||||
v-for="s in guideSteps(f.key)"
|
v-for="s in guideSteps(f.key)"
|
||||||
@@ -311,11 +309,12 @@ function confirmDeleteProject(name) {
|
|||||||
></span>
|
></span>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="guideStatus(f.key) !== 'none'"
|
v-if="guideStatus(f.key) !== 'none' || abgebrochen(f.key)"
|
||||||
class="format-x"
|
class="format-x"
|
||||||
:class="{ armed: pendingConfirm === 'fmt-' + f.key }"
|
:class="{ armed: pendingConfirm === 'fmt-' + f.key }"
|
||||||
@click.stop="handleDelete(f.key)"
|
@click.stop="handleDelete(f.key)"
|
||||||
:title="guideStatus(f.key) === 'generating' || guideStatus(f.key) === 'queued' ? 'Abbrechen' : 'Löschen'"
|
:title="guideStatus(f.key) === 'generating' || guideStatus(f.key) === 'queued' ? 'Abbrechen'
|
||||||
|
: abgebrochen(f.key) ? 'Fortschritt löschen' : 'Löschen'"
|
||||||
>{{ pendingConfirm === 'fmt-' + f.key ? 'Sicher?' : '×' }}</span>
|
>{{ pendingConfirm === 'fmt-' + f.key ? 'Sicher?' : '×' }}</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="format-actions">
|
<div class="format-actions">
|
||||||
@@ -329,6 +328,10 @@ function confirmDeleteProject(name) {
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="guideStatus(f.key) === 'generating' || guideStatus(f.key) === 'queued'"
|
||||||
|
class="format-progress"
|
||||||
|
>{{ latestByFormat[f.key]?.progress || 'Wartend…' }}</div>
|
||||||
<div v-if="errorMsg(f.key)" class="format-error">
|
<div v-if="errorMsg(f.key)" class="format-error">
|
||||||
<span class="format-error-text">{{ errorMsg(f.key) }}</span>
|
<span class="format-error-text">{{ errorMsg(f.key) }}</span>
|
||||||
<button class="format-error-x" title="Ausblenden" @click="dismissError(f.key)">×</button>
|
<button class="format-error-x" title="Ausblenden" @click="dismissError(f.key)">×</button>
|
||||||
@@ -666,6 +669,35 @@ function confirmDeleteProject(name) {
|
|||||||
animation: pulse 1.5s ease-in-out infinite;
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Abgewiesene Aktion (409/400) — oberhalb aller Format-Zeilen */
|
||||||
|
.ui-error {
|
||||||
|
order: 0;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
background: var(--warning-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fortschritts-Text direkt unter der laufenden Format-Zeile */
|
||||||
|
.format-progress {
|
||||||
|
padding: 0 0.75rem 5px calc(0.75rem + 8px);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--warning);
|
||||||
|
line-height: 1.3;
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resume-badge {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: var(--warning);
|
||||||
|
background: var(--warning-soft);
|
||||||
|
border: 1px solid var(--warning-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
.format-row {
|
.format-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -706,6 +738,14 @@ function confirmDeleteProject(name) {
|
|||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Laufend/pausiert: × immer zeigen — Hover gibt es auf Touch nicht */
|
||||||
|
.fmt-generating .format-x,
|
||||||
|
.fmt-queued .format-x,
|
||||||
|
.fmt-paused .format-x,
|
||||||
|
.bausteine-row.is-active .format-x {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
.format-x.armed,
|
.format-x.armed,
|
||||||
.format-error-x.armed,
|
.format-error-x.armed,
|
||||||
.delete-topic.armed {
|
.delete-topic.armed {
|
||||||
|
|||||||
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>
|
<script setup>
|
||||||
import { ref, nextTick } from 'vue'
|
import { ref, nextTick } from 'vue'
|
||||||
import { renderMarkdown } from '../markdown.js'
|
import { renderMarkdown } from '../../markdown.js'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
change: { type: Object, required: true },
|
change: { type: Object, required: true },
|
||||||
@@ -175,38 +175,10 @@ function submit() {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Markdown in der Vorschau */
|
/* Markdown: Basis global (assets/markdown.css); kompakte Vorschau-Code-Blöcke */
|
||||||
.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 :deep(pre) {
|
.markdown :deep(pre) {
|
||||||
background: var(--code-bg, #1e2330);
|
|
||||||
color: var(--code-fg, #e6e8ee);
|
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
white-space: pre-wrap;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
margin: 0.3em 0;
|
margin: 0.3em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown :deep(pre code) {
|
|
||||||
background: none;
|
|
||||||
padding: 0;
|
|
||||||
color: inherit;
|
|
||||||
font-size: 0.85em;
|
|
||||||
}
|
|
||||||
</style>
|
</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 { createApp } from 'vue'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
import './assets/markdown.css'
|
||||||
|
|
||||||
createApp(App).mount('#app')
|
createApp(App).mount('#app')
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { marked } from 'marked'
|
|||||||
import { markedHighlight } from 'marked-highlight'
|
import { markedHighlight } from 'marked-highlight'
|
||||||
import hljs from 'highlight.js'
|
import hljs from 'highlight.js'
|
||||||
import 'highlight.js/styles/github-dark.css'
|
import 'highlight.js/styles/github-dark.css'
|
||||||
|
import katex from 'katex'
|
||||||
|
import 'katex/dist/katex.min.css'
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
|
|
||||||
marked.use(markedHighlight({
|
marked.use(markedHighlight({
|
||||||
@@ -15,6 +17,40 @@ marked.use(markedHighlight({
|
|||||||
}))
|
}))
|
||||||
marked.setOptions({ breaks: true, gfm: true })
|
marked.setOptions({ breaks: true, gfm: true })
|
||||||
|
|
||||||
|
// LaTeX-Mathe via KaTeX. Eigene marked-Extensions (statt marked-katex-extension,
|
||||||
|
// 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)
|
// Rohes HTML im Markdown (z. B. <p>, <img> ohne Backticks aus Agenten-Output)
|
||||||
// als Text anzeigen statt rendern — sonst verschluckt der Browser den Inhalt.
|
// als Text anzeigen statt rendern — sonst verschluckt der Browser den Inhalt.
|
||||||
marked.use({
|
marked.use({
|
||||||
|
|||||||
@@ -1,26 +1,51 @@
|
|||||||
SECTION-AUFBAU
|
SECTION-AUFBAU
|
||||||
|
|
||||||
Jeder Baustein wird GENAU eine Section mit:
|
Jeder Baustein ist ein kleiner, eigenständiger Lern-Guide: er stellt EIN Konzept vor, erklärt es von Grund auf und macht es nutzbar. Der Leser bringt KEIN Vorwissen mit — du holst ihn ab und bringst ihm die Sache bei. Eine Section ist kein Stichwort-Zettel zum Nachschlagen.
|
||||||
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
|
Aufbau je Baustein — drei Beats, fließend ineinander, OHNE Zwischenüberschriften:
|
||||||
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.
|
1. Einordnung — welche Frage beantwortet der Baustein, welches Problem löst er? Ein Satz, der den Leser abholt. Bei selbsterklärenden Bausteinen weglassen.
|
||||||
|
2. Erklärung — was es ist UND wie/warum es funktioniert. Alltagssprache, von der Intuition zum Detail. Fachbegriffe beim ersten Auftreten in einem Halbsatz auflösen. Eine Analogie oder ein Bild ist erlaubt und oft besser als eine Definition.
|
||||||
|
3. Beispiel(e) — das Konzept konkret gemacht (siehe BEISPIELFORMAT).
|
||||||
|
|
||||||
|
LÄNGE — so lang wie nötig, so kurz wie möglich:
|
||||||
|
- KEIN festes Wort- oder Satzlimit. Die Länge richtet sich nach der Schwierigkeit des Konzepts: ein einfacher Baustein braucht 2–3 Sätze, ein kniffliger einen kurzen Absatz.
|
||||||
|
- Verständnis-Test (er entscheidet über die Länge): Versteht ein Anfänger das Konzept allein aus dieser Section? Wenn nein → eine Stufe einfacher erklären, NICHT mehr Fakten stapeln. Wenn ja und kein Satz lässt sich streichen, ohne dass Verständnis verloren geht → genau richtig.
|
||||||
|
- Kürze entsteht durch WEGLASSEN von Überflüssigem, nicht durch Verdichten von Nötigem.
|
||||||
|
- Weglassen: Füllsätze, Einleitungsfloskeln („In diesem Abschnitt…"), Wiederholungen, Fazit/Zusammenfassung. Nicht jeden Randfall nennen — das Übliche erklären; Varianten gehören in die Beispiele, mehr Tiefe in die Vertiefung.
|
||||||
|
|
||||||
|
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.
|
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, `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
|
### Beispiel
|
||||||
```php
|
```php
|
||||||
$preise = ['apfel' => 1.20, 'birne' => 1.50];
|
$preise = ['apfel' => 1.20, 'birne' => 1.50];
|
||||||
$preise['kirsche'] = 3.90; // ergänzen
|
$preise['kirsche'] = 3.90; // neuen Schlüssel ergänzen
|
||||||
echo $preise['apfel']; // 1.2
|
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.
|
||||||
|
|||||||
31
templates/Prompt/Baustein-Bewertung-Kritik.md
Normal file
31
templates/Prompt/Baustein-Bewertung-Kritik.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
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}
|
||||||
|
|
||||||
|
VERTIEFUNG (falls vorhanden):
|
||||||
|
{vertiefung_block}
|
||||||
|
|
||||||
|
PRÜFUNGS-VERLAUF (Antwort des Lerners und etwaige Diskussion):
|
||||||
|
{transcript}
|
||||||
|
|
||||||
|
ZU PRÜFENDE BEWERTUNG:
|
||||||
|
{bewertung_block}
|
||||||
|
|
||||||
|
PRÜFE GEGEN DIESE KRITERIEN:
|
||||||
|
- 50%-Schwelle: Eine Antwort, die den Kern trifft und MINDESTENS zur Hälfte korrekt ist, MUSS "gut" sein. Nur weniger als die Hälfte oder klar falsch ist "schlecht".
|
||||||
|
- Kein Fordern über das Material hinaus: Die Bewertung darf NICHTS verlangen, was nicht aus Guide/Vertiefung folgt. Wurde der Lerner für fehlenden guide-externen Stoff abgewertet → Fehlurteil. „Nicht im Material" darf nie zu seinen Lasten gehen.
|
||||||
|
- Kein Ablese-Test: Wurde eine sachlich RICHTIGE Antwort als "schlecht" gewertet, nur weil sie nicht den Guide-Begriff nutzt oder über den Guide hinausgeht → Fehlurteil. Korrekte Synonyme und gleichbedeutende Begriffe müssen "gut" sein.
|
||||||
|
- Asymmetrie: Weltwissen darf nur zum ANERKENNEN richtiger Antworten dienen, nie zum strengeren Fordern.
|
||||||
|
- "schlecht" ist nur fair, wenn die Antwort sachlich falsch ist oder dem Material widerspricht.
|
||||||
|
- KEIN Durchwinken: Wurde eine sachlich FALSCHE oder dem Material widersprechende Antwort als "gut" gewertet → Fehlurteil. Prüfe die Antwort SELBST auf Korrektheit (gegen Material UND Logik), nicht nur die Fairness — in BEIDE Richtungen.
|
||||||
|
- Widerspruchs-Check: Widerspricht sich das Feedback selbst (bejaht die Antwort UND nennt zugleich die Gegen-Lösung) oder passt es nicht zur Bewertung → Fehlurteil.
|
||||||
|
|
||||||
|
Beanstande NUR echte Fehlurteile. Ist die Bewertung fair, korrekt und materialtreu, ist sie in Ordnung.
|
||||||
|
|
||||||
|
Gib NUR JSON aus (kein weiterer Text):
|
||||||
|
- Bewertung in Ordnung: {{"ok": true}}
|
||||||
|
- Sonst: {{"probleme": ["was an der Bewertung falsch ist"]}}
|
||||||
40
templates/Prompt/Baustein-Bewertung.md
Normal file
40
templates/Prompt/Baustein-Bewertung.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
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}
|
||||||
|
|
||||||
|
VERTIEFUNG (falls vorhanden):
|
||||||
|
{vertiefung_block}
|
||||||
|
|
||||||
|
STAND: {gute_antworten} von {noetig} Antworten waren bisher gut.
|
||||||
|
|
||||||
|
PRÜFUNGS-VERLAUF (Antwort des Lerners und etwaige Diskussion):
|
||||||
|
{transcript}
|
||||||
|
|
||||||
|
Bewerte die Antwort des Lerners auf die GEPRÜFTE FRAGE — auf Basis seiner Antwort UND der Diskussion im Verlauf.
|
||||||
|
|
||||||
|
SO BEWERTEST DU — die 50%-Schwelle:
|
||||||
|
- "gut" = der Kern der Antwort ist sachlich RICHTIG und mindestens zur Hälfte korrekt. Eine knappe, richtige Antwort reicht.
|
||||||
|
- "schlecht" = weniger als die Hälfte richtig, oder klar falsch.
|
||||||
|
- Ist der Kern sachlich FALSCH (z. B. das Gegenteil der richtigen Aussage), ist das "schlecht" — egal wie selbstsicher es formuliert ist. Die Lockerheit unten gilt NUR für richtige Inhalte, nie um Falsches zu retten.
|
||||||
|
- Die Hälfte misst sich an dem, was die FRAGE verlangt — in JEDER korrekten Formulierung, nicht am Wortlaut des Guides. KEINE ideale Vollantwort verlangen.
|
||||||
|
|
||||||
|
DU PRÜFST VERSTÄNDNIS, NICHT ABLESEN — asymmetrische Material-Grenze:
|
||||||
|
- FORDERN: Verlange NIE mehr, als Frage und Material hergeben. Kein Detail, keine Technik, kein Begriff, der über das Material hinausgeht, darf gefordert werden. Zeigt der Lerner zu Recht, dass etwas nicht im Material steht → KEIN Fehler.
|
||||||
|
- AKZEPTIEREN: Eine sachlich RICHTIGE Antwort ist "gut" — auch mit anderen Worten als der Guide oder mit korrektem Wissen ÜBER den Guide hinaus. Korrekte Synonyme und gleichbedeutende Begriffe zählen voll. Mehr-richtig ist ein Plus, NIE ein Minus.
|
||||||
|
- "schlecht" NUR, wenn die Antwort sachlich FALSCH ist oder dem Material WIDERSPRICHT — NIEMALS, weil sie nicht den Guide-Begriff nutzt.
|
||||||
|
- WELTWISSEN: Nutze es, um eine richtige Antwort als "gut" ANZUERKENNEN. Nutze es NIE, um mehr zu fordern oder strenger zu prüfen.
|
||||||
|
- Behaupte selbst nichts Erfundenes. Gib nach, wenn der Lerner sachlich recht hat — aber NICHT aus Höflichkeit oder auf bloßes Beharren hin.
|
||||||
|
|
||||||
|
FELDER:
|
||||||
|
- `feedback`: max. 1 Satz, sprich den Lerner direkt an. Begründe knapp, warum gut oder schlecht. KEINE neue Frage. Kein Widerspruch — bejahe nicht die Antwort und nenne zugleich die Gegen-Lösung; entscheide dich.
|
||||||
|
- `bestanden`: true NUR, wenn du schon vor den {noetig} guten Antworten sicher bist, dass der Lerner den Baustein versteht. Im Zweifel false.
|
||||||
|
|
||||||
|
HINWEISE DES PRÜFERS ZUR LETZTEN FASSUNG:
|
||||||
|
{kritik_block}
|
||||||
|
|
||||||
|
Gib NUR dieses JSON aus (kein weiterer Text):
|
||||||
|
{{"feedback": "ein Satz", "bewertung": "gut" | "schlecht", "bestanden": false}}
|
||||||
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}
|
||||||
|
|
||||||
|
VERTIEFUNG (falls vorhanden):
|
||||||
|
{vertiefung_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:".
|
||||||
24
templates/Prompt/Baustein-Deepdive.md
Normal file
24
templates/Prompt/Baustein-Deepdive.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
Schreibe eine Einsteiger-Fassung („Amateur") des Bausteins "{baustein}" aus dem Lern-Guide zum Thema "{topic}". Gleicher Stoff wie im Guide — aber für absolute Anfänger aufbereitet: maximal verständlich, kleinschrittig, nichts vorausgesetzt.
|
||||||
|
|
||||||
|
GUIDE-FASSUNG DES BAUSTEINS:
|
||||||
|
{section_block}
|
||||||
|
|
||||||
|
So baust du es auf:
|
||||||
|
- Genau dasselbe Konzept wie der Guide — nur viel ausführlicher und einfacher erklärt.
|
||||||
|
- Sehr kleinschrittig: Gedanke für Gedanke, nichts überspringen.
|
||||||
|
- Viele Alltags-Analogien und Bilder, die das Konzept greifbar machen.
|
||||||
|
- Jeden Fachbegriff beim ersten Auftreten ausführlich erklären, in Alltagssprache.
|
||||||
|
- Beispiele langsam durchgehen — Schritt für Schritt zeigen, was passiert.
|
||||||
|
|
||||||
|
Was NICHT:
|
||||||
|
- NICHT schwerer oder tiefer. Keine Grenzfälle, keine Abgrenzung, kein Experten-Stoff.
|
||||||
|
- Du bringst mehr ERKLÄRUNG, nicht mehr Konzepte.
|
||||||
|
- Setze nichts voraus — auch nicht, dass der Leser die Guide-Fassung verstanden hat.
|
||||||
|
|
||||||
|
Stil:
|
||||||
|
- Klares, geduldiges Deutsch. Du nimmst den Leser an die Hand.
|
||||||
|
- Ausführlicher als die Guide-Fassung, aber kein Roman. Die Länge folgt dem Verständnis, nicht dem Wortzähler.
|
||||||
|
- Mathematik IMMER als LaTeX: inline `$…$` (z. B. `$\Sigma^*$`, `$O(n^d)$`), abgesetzt `$$…$$`. KEINE Unicode-Krücken (`x₁`, `¬`, `≤`).
|
||||||
|
- Markdown: Absätze, `inline-code`, Codeblöcke mit Sprachangabe nur für Code, `###` für Zwischenüberschriften, **fett** sparsam. KEINE Zusammenfassung am Ende, kein Fazit-Abschnitt.
|
||||||
|
|
||||||
|
Gib NUR das Markdown der Einsteiger-Fassung aus — keine Einleitung wie "Hier ist…", kein Code-Fence um das Ganze, kein JSON.
|
||||||
25
templates/Prompt/Baustein-Frage-Kritik.md
Normal file
25
templates/Prompt/Baustein-Frage-Kritik.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
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}
|
||||||
|
|
||||||
|
VERTIEFUNG (falls vorhanden):
|
||||||
|
{vertiefung_block}
|
||||||
|
|
||||||
|
BISHERIGER PRÜFUNGS-VERLAUF (nur frühere Fragen und Antworten):
|
||||||
|
{transcript}
|
||||||
|
|
||||||
|
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 einer Frage aus dem Verlauf.
|
||||||
|
- 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"]}}
|
||||||
30
templates/Prompt/Baustein-Frage.md
Normal file
30
templates/Prompt/Baustein-Frage.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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}
|
||||||
|
|
||||||
|
VERTIEFUNG (falls vorhanden):
|
||||||
|
{vertiefung_block}
|
||||||
|
|
||||||
|
BISHERIGER PRÜFUNGS-VERLAUF (nur frühere Fragen und Antworten):
|
||||||
|
{transcript}
|
||||||
|
|
||||||
|
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.
|
||||||
|
- Passt eine Transferfrage nicht in einen Satz, wähle eine einfachere Frage.
|
||||||
|
- Wiederhole keine Frage aus dem Verlauf.
|
||||||
|
|
||||||
|
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"}}
|
||||||
27
templates/Prompt/Baustein-Pruefung-Diskussion.md
Normal file
27
templates/Prompt/Baustein-Pruefung-Diskussion.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
Du bist Tutor in einer Prüfung zum Baustein "{baustein}" aus dem Lern-Guide zum Thema "{topic}". Der Lerner diskutiert mit dir — über die geprüfte Frage oder über deine letzte Bewertung. Du DISKUTIERST, du bewertest NICHT.
|
||||||
|
|
||||||
|
GEPRÜFTE FRAGE:
|
||||||
|
{frage}
|
||||||
|
|
||||||
|
DEINE LETZTE BEWERTUNG (falls vorhanden):
|
||||||
|
{letzte_bewertung_block}
|
||||||
|
|
||||||
|
BAUSTEIN AUS DEM GUIDE:
|
||||||
|
{section_block}
|
||||||
|
|
||||||
|
VERTIEFUNG (falls vorhanden):
|
||||||
|
{vertiefung_block}
|
||||||
|
|
||||||
|
BISHERIGER VERLAUF:
|
||||||
|
{transcript}
|
||||||
|
|
||||||
|
Antworte als Tutor auf die letzte Nutzer-Nachricht.
|
||||||
|
|
||||||
|
WICHTIG:
|
||||||
|
- Bleib bei der geprüften Frage und beim Material. Guide-Fassung und Vertiefung sind die fachliche Referenz.
|
||||||
|
- Ist die Frage unklar: erkläre sie, ohne die Lösung zu verraten.
|
||||||
|
- Zeigt der Lerner SACHLICH und mit Material-Bezug, dass deine Frage oder eine vorige Bewertung falsch war: räume es offen ein. Schlage dann vor, die Antwort erneut bewerten zu lassen.
|
||||||
|
- Gib NICHT aus Höflichkeit oder auf bloßes Beharren hin nach. Nur ein echtes Sach-Argument zählt.
|
||||||
|
- Du vergibst KEINE Bewertung und stellst KEINE neue Prüfungsfrage.
|
||||||
|
|
||||||
|
Antwortstil: kurz und klar, 1–3 Sätze. Keine Einleitung, kein Markdown-Drumherum, kein Präfix wie "Assistent:". Gib NUR die Antwort aus.
|
||||||
22
templates/Prompt/Baustein-Vertiefung.md
Normal file
22
templates/Prompt/Baustein-Vertiefung.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
Schreibe eine ausführlichere Fassung des Bausteins "{baustein}" aus dem Lern-Guide zum Thema "{topic}". Gleicher Stoff wie im Guide — nur umfangreicher. Keine Erweiterung, nur MEHR.
|
||||||
|
|
||||||
|
GUIDE-FASSUNG DES BAUSTEINS:
|
||||||
|
{section_block}
|
||||||
|
|
||||||
|
Was „mehr" heißt:
|
||||||
|
- Genau dasselbe Konzept, dieselbe Stufe, derselbe Leser (Anfänger ohne Vorwissen).
|
||||||
|
- Jeden Punkt der Guide-Fassung etwas weiter ausführen, langsameres Tempo.
|
||||||
|
- Mehr Beispiele und zusätzliche Varianten zum SELBEN Konzept.
|
||||||
|
|
||||||
|
Was NICHT:
|
||||||
|
- KEINE neuen Konzepte, keine Grenzfälle, keine Abgrenzung zu Nachbarthemen.
|
||||||
|
- Nichts „über den Guide hinaus" — du gehst nicht tiefer, du gehst breiter.
|
||||||
|
- Kein Aufblähen mit Floskeln. Mehr Text nur, wo er echtes Verständnis bringt.
|
||||||
|
|
||||||
|
Stil:
|
||||||
|
- Klares, direktes Deutsch. Merklich ausführlicher als die Guide-Fassung, aber kein Roman.
|
||||||
|
- Fachbegriffe beim ersten Auftreten in einem Halbsatz auflösen.
|
||||||
|
- Mathematik IMMER als LaTeX: inline `$…$` (z. B. `$\Sigma^*$`, `$O(n^d)$`), abgesetzt `$$…$$`. KEINE Unicode-Krücken (`x₁`, `¬`, `≤`).
|
||||||
|
- Markdown: Absätze, `inline-code`, Codeblöcke mit Sprachangabe nur für Code. KEINE Zwischenüberschriften, KEINE Zusammenfassung, kein Fazit.
|
||||||
|
|
||||||
|
Gib NUR das Markdown der Vertiefung aus — keine Einleitung wie "Hier ist…", kein Code-Fence um das Ganze, kein JSON.
|
||||||
@@ -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:
|
Regeln:
|
||||||
- Vereinige die Listen: erkenne gleiche Konzepte unter verschiedenen Titeln und führe sie zu einem Baustein zusammen.
|
- 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.
|
||||||
- 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).
|
- VERWERFEN, wenn er eine Dublette zum Konsens ist, nur eine Variante/Vertiefung eines vorhandenen Bausteins, Mikro-Granularität oder fachlich zweifelhaft.
|
||||||
- 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.
|
- Übernimm aufgenommene Einträge wörtlich ("Titel — Kurzbeschreibung"), nicht umformulieren.
|
||||||
- Verwirf Bausteine ohne Quelle oder die erfunden wirken. Behalte im Zweifel, was mindestens eine Recherche belegt.
|
- Nichts Aufnehmenswertes dabei → leere Liste. Das ist ein gültiges Ergebnis.
|
||||||
- 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.
|
|
||||||
|
|
||||||
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": []}}
|
||||||
|
|||||||
@@ -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.
|
||||||
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,13 +1,14 @@
|
|||||||
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}
|
{source}
|
||||||
|
|
||||||
Regeln:
|
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.
|
- 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.
|
- 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.
|
- 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).
|
- Schreibe Titel und Beschreibung auf DEUTSCH (Fachbegriffe/Code-Bezeichner bleiben original).
|
||||||
- Beschreibung maximal ~12 Wörter.
|
- Beschreibung maximal ~12 Wörter.
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ BISHERIGER CHAT-VERLAUF:
|
|||||||
Setze die letzte Nutzer-Anweisung in Änderungs-Vorschläge um. Halte die Element-Regeln ein:
|
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)
|
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
|
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>`."
|
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.
|
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:
|
Jeder Vorschlag:
|
||||||
- text: kurz, was geändert wird (max. 12 Wörter, reiner Text)
|
- text: kurz, was geändert wird (max. 12 Wörter, reiner Text)
|
||||||
- action: "entfernen" | "anpassen" | "hinzufuegen"
|
- action: "entfernen" | "anpassen" | "hinzufuegen"
|
||||||
- target: "title" | "description" | "examples" | "hints" | "aufgabe" | "loesung"
|
- target: "title" | "description" | "examples" | "hints"
|
||||||
- index: 0-basierte Position im AKTUELLEN examples- bzw. hints-Array (bei title/description/aufgabe/loesung und hinzufuegen: null)
|
- 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)
|
- content: der neue vollständige Inhalt (bei entfernen: leer)
|
||||||
|
|
||||||
"entfernen" nur für examples/hints. Nur Vorschläge machen, die die Nutzer-Anweisung verlangt.
|
"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):
|
KONTEXT (Auszüge aus dem Themen-Material):
|
||||||
{context}
|
{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:
|
Jeder Kandidat:
|
||||||
- text: kurze Beschreibung der Lücke (max. 12 Wörter, reiner Text)
|
- text: kurze Beschreibung der Lücke (max. 12 Wörter, reiner Text)
|
||||||
- target: "description" | "examples" | "hints" | "aufgabe" | "loesung"
|
- 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 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.
|
- 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:
|
Gib NUR gültiges JSON aus, ohne Code-Fence, ohne weiteren Text:
|
||||||
{{"suggestions": [{{"text": "...", "target": "hints", "content": "..."}}]}}
|
{{"suggestions": [{{"text": "...", "target": "hints", "content": "..."}}]}}
|
||||||
|
|||||||
@@ -9,10 +9,16 @@ KONTEXT (Auszüge aus dem Themen-Material):
|
|||||||
Erstelle GENAU EIN Element zum Stichwort:
|
Erstelle GENAU EIN Element zum Stichwort:
|
||||||
1. title — prägnanter Titel (max. 8 Wörter, reiner Text ohne Markdown/Backticks)
|
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
|
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.)
|
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.
|
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.
|
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:
|
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.
|
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:
|
Felder:
|
||||||
- text: kurz, was geändert wird (max. 12 Wörter, reiner Text)
|
- text: kurz, was geändert wird (max. 12 Wörter, reiner Text)
|
||||||
|
|||||||
@@ -6,20 +6,27 @@ AKTUELLES ELEMENT (JSON):
|
|||||||
STIL-REGELN:
|
STIL-REGELN:
|
||||||
1. title — prägnant, max. 8 Wörter, reiner Text ohne Markdown/Backticks
|
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
|
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.
|
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`."
|
Vorher: "Browser fügen standardmäßig vertikalen Abstand vor und nach `<p>` ein — anpassbar mit `margin`."
|
||||||
Nachher: "Browser-Abstand um `<p>` per `margin` anpassbar."
|
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.
|
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.
|
7. Tonalität: klares Deutsch, direkt, praxisorientiert. Keine Füllsätze.
|
||||||
|
|
||||||
Schlage für jeden Stil-Verstoß GENAU EINE Änderung vor:
|
Schlage für jeden Stil-Verstoß GENAU EINE Änderung vor:
|
||||||
- text: kurz, was und warum (max. 12 Wörter, reiner Text)
|
- text: kurz, was und warum (max. 12 Wörter, reiner Text)
|
||||||
- action: "entfernen" | "anpassen" | "hinzufuegen"
|
- action: "entfernen" | "anpassen" | "hinzufuegen"
|
||||||
- target: "title" | "description" | "examples" | "hints" | "aufgabe" | "loesung"
|
- target: "title" | "description" | "examples" | "hints"
|
||||||
- index: 0-basierte Position im AKTUELLEN examples- bzw. hints-Array (bei title/description/aufgabe/loesung: null; bei hinzufuegen: null)
|
- 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)
|
- 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.
|
"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.
|
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.
|
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.
|
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:
|
Gib NUR gültiges JSON aus, ohne Code-Fence, ohne weiteren Text:
|
||||||
{{"suggestions": [{{"text": "...", "target": "hints", "content": "..."}}]}}
|
{{"suggestions": [{{"text": "...", "target": "hints", "content": "..."}}]}}
|
||||||
|
|||||||
@@ -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}
|
|
||||||
22
templates/Prompt/Guide-Auswahl-Mapping.md
Normal file
22
templates/Prompt/Guide-Auswahl-Mapping.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{n} Agenten haben unabhängig Bausteine für {format_name} zum Thema "{topic}" gewählt. Der Konsens (Mehrheit) steht fest und ist nur Kontext. Sortiere die strittigen Einträge.
|
||||||
|
|
||||||
|
AUFTRAG DER AUSWAHL: {auswahl_auftrag}
|
||||||
|
|
||||||
|
BESCHLOSSENER KONSENS ({konsens_n} Bausteine — nicht ändern):
|
||||||
|
{konsens}
|
||||||
|
|
||||||
|
STRITTIGE EINTRÄGE MIT VOTUM:
|
||||||
|
{rest}
|
||||||
|
|
||||||
|
Regeln:
|
||||||
|
- Von der Mehrheit der {n} Agenten gewählt und dem Auftrag dienlich → `aufnehmen`.
|
||||||
|
- Von kaum jemandem gewählt oder für den Auftrag entbehrlich → verwerfen (in keine Liste).
|
||||||
|
- Uneindeutig → `rest`. Diese Einträge gehen in eine weitere Runde.{final}
|
||||||
|
- Zielgröße: Der Konsens plus `aufnehmen` muss am Ende in der Spanne {k_min}–{k_max} Bausteine landen. Plane mit `rest` entsprechend.
|
||||||
|
- Übernimm Titel EXAKT wie gelistet, 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"], "rest": ["Titel"]}}
|
||||||
@@ -7,7 +7,7 @@ BAUSTEINE (unsortiertes Inventar):
|
|||||||
|
|
||||||
Denke vom Ziel her: Was soll der Leser am Ende KÖNNEN?
|
Denke vom Ziel her: Was soll der Leser am Ende KÖNNEN?
|
||||||
- Wähle, was der Leser dafür praktisch braucht und wirklich benutzt.
|
- 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.
|
- Lass weg: Interna (was das Werkzeug oder die Theorie intern tut, ohne dass man es selbst anfasst), Spezialfälle und Alternativen zum selben Problem — ein Weg reicht.
|
||||||
- "Klingt fundamental" ist kein Kriterium. Frage stattdessen: Fasst der Leser das selbst an?
|
- "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.
|
- Verwende die Titel EXAKT so, wie sie in der Liste stehen. Keine neuen erfinden.
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
|
||||||
19
templates/Prompt/Guide-Gliederung-Judge.md
Normal file
19
templates/Prompt/Guide-Gliederung-Judge.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{n} Agenten haben die feste Baustein-Auswahl für {format_name} zum Thema "{topic}" unabhängig gegliedert ({zweck}). Wähle die beste Gliederung und finalisiere sie.
|
||||||
|
|
||||||
|
GEWÄHLTE BAUSTEINE (die Auswahl steht fest):
|
||||||
|
{bausteine}
|
||||||
|
|
||||||
|
DIE VORSCHLÄGE:
|
||||||
|
{gliederungen}
|
||||||
|
|
||||||
|
Vorgehen:
|
||||||
|
- Wähle den überzeugendsten Vorschlag als Basis: bester roter Faden, vom Bekannten zum Unbekannten, erster Erfolg vor Theorie.
|
||||||
|
- Übernimm überzeugende Ideen der anderen Vorschläge: besserer Kapitelschnitt, bessere Reihenfolge, klarere Kapiteltitel.
|
||||||
|
- Es gelten die Gliederungs-Regeln: JEDER Baustein in GENAU einem Kapitel, Titel EXAKT wie gelistet, 3–7 Bausteine pro Kapitel, Kapiteltitel kurz und konkret.
|
||||||
|
- Erfinde nichts Neues — du kombinierst und entscheidest, du gliederst nicht von vorn.
|
||||||
|
|
||||||
|
Schreibe NUR die JSON-Datei nach: {out_path}
|
||||||
|
|
||||||
|
Format:
|
||||||
|
{{"kapitel": [{{"titel": "Grundlagen", "bausteine": ["Exakter Titel", "Exakter Titel"]}}]}}
|
||||||
|
{extra}
|
||||||
23
templates/Prompt/Guide-Klaerung.md
Normal file
23
templates/Prompt/Guide-Klaerung.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
Bei der Baustein-Auswahl für {format_name} zum Thema "{topic}" sind Einträge strittig geblieben. Entscheide für jeden strittigen Eintrag: übernehmen oder weglassen.
|
||||||
|
|
||||||
|
AUFTRAG DER AUSWAHL: {auswahl_auftrag}
|
||||||
|
|
||||||
|
BESCHLOSSENER KONSENS (nur Kontext — nicht ändern):
|
||||||
|
{konsens}
|
||||||
|
|
||||||
|
STRITTIGE EINTRÄGE:
|
||||||
|
{rest}
|
||||||
|
|
||||||
|
Regeln:
|
||||||
|
- ÜBERNEHMEN, wenn der Baustein dem Auftrag dient und der Konsens das Thema dort nicht schon abdeckt.
|
||||||
|
- WEGLASSEN, wenn er für den Zweck entbehrlich ist, zu speziell oder vom Konsens abgedeckt.
|
||||||
|
- Einziges Kriterium ist der Auftrag — nicht persönliche Vollständigkeit.
|
||||||
|
- Übernimm Titel EXAKT wie gelistet, nicht umformulieren.
|
||||||
|
- Nichts Übernehmenswertes dabei → leere Liste. Das ist ein gültiges Ergebnis.
|
||||||
|
|
||||||
|
Schreibe NUR die JSON-Datei nach: {out_path}
|
||||||
|
|
||||||
|
Format (kein weiterer Text in der Datei):
|
||||||
|
{{"uebernehmen": ["Titel"]}}
|
||||||
|
Nichts übernehmen: {{"uebernehmen": []}}
|
||||||
|
{extra}
|
||||||
@@ -8,8 +8,8 @@ SECTIONS:
|
|||||||
{sections}
|
{sections}
|
||||||
|
|
||||||
Prüfe jede Section:
|
Prüfe jede Section:
|
||||||
1. Ist die Beschreibung für Anfänger verständlich und maximal 1–2 Sätze?
|
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? Sie soll so lang wie nötig und so kurz wie möglich sein: kein Roman, keine Füllsätze, keine Einleitungsfloskeln — aber auch nicht so verdichtet, dass nur jemand sie versteht, der das Thema schon kennt.
|
||||||
2. Sind die Beispiele kurz, simpel und plausibel korrekt?
|
2. 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)?
|
||||||
3. Ist das Markdown sauber (keine abgebrochenen Code-Blöcke, keine Platzhalter, kein Fremdtext)?
|
3. 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.
|
Du PRÜFST nur und notierst Probleme — du änderst nichts. Nur echte Mängel notieren, keine Geschmacksfragen.
|
||||||
|
|||||||
@@ -13,12 +13,10 @@ Behebe pro Section NUR das notierte Problem; was in Ordnung ist, bleibt inhaltli
|
|||||||
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:
|
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:
|
||||||
|
|
||||||
<!-- section: Exakter Section-Titel -->
|
<!-- section: Exakter Section-Titel -->
|
||||||
Beschreibung…
|
Erklärung (Einordnung → Wie/Warum) laut SECTION-SPEZIFIKATION…
|
||||||
|
|
||||||
### Beispiel
|
### 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.
|
Die Marker-Zeilen exakt so schreiben. Kein Text außerhalb der Sections.
|
||||||
{extra}
|
{extra}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Dir zugeteilt sind folgende Kapitel und Bausteine — verbindlich: jede zugeteil
|
|||||||
|
|
||||||
{facts}
|
{facts}
|
||||||
|
|
||||||
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.
|
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. Eine Variante ist eine eigenständige Verwendungsform des Bausteins — Code-Variante, Satzmuster oder Anwendungsfall.
|
||||||
|
|
||||||
SECTION-SPEZIFIKATION:
|
SECTION-SPEZIFIKATION:
|
||||||
{spec}
|
{spec}
|
||||||
@@ -14,12 +14,10 @@ Schreibe NUR die Datei {out_path} in GENAU diesem Format — pro Kapitel ein kap
|
|||||||
|
|
||||||
<!-- kapitel: Kapiteltitel -->
|
<!-- kapitel: Kapiteltitel -->
|
||||||
<!-- section: Exakter Baustein-Titel -->
|
<!-- section: Exakter Baustein-Titel -->
|
||||||
Beschreibung…
|
Erklärung (Einordnung → Wie/Warum) laut SECTION-SPEZIFIKATION…
|
||||||
|
|
||||||
### Beispiel
|
### 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.
|
Die Marker-Zeilen exakt so schreiben. Kein Text außerhalb der Sections, kein Dokument-Titel, kein Inhaltsverzeichnis.
|
||||||
{extra}
|
{extra}
|
||||||
19
templates/Prompt/OnePager-Bauen-Judge.md
Normal file
19
templates/Prompt/OnePager-Bauen-Judge.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{n} Agenten haben aus derselben Faktenbasis je einen vollständigen 7-Karten-Satz für den OnePager zum Thema "{topic}" gebaut. Wähle pro Karte die beste Fassung und kombiniere sie zum finalen Satz.
|
||||||
|
|
||||||
|
FAKTENBASIS (alleinige Quelle — Maßstab für Faktentreue):
|
||||||
|
{recherche}
|
||||||
|
|
||||||
|
DIE ENTWÜRFE:
|
||||||
|
{kartensaetze}
|
||||||
|
|
||||||
|
Regeln:
|
||||||
|
- Pro Karte (Schlüssel in eckigen Klammern) die beste Fassung wählen: faktentreu zur Faktenbasis, kompakt, klarste Stichpunkte.
|
||||||
|
- Karten dürfen wörtlich übernommen werden. Kleine Verbesserungen beim Kombinieren sind erlaubt, nichts hinzuerfinden.
|
||||||
|
- Kompaktheit gilt: max. 5 Stichpunkte pro Karte, je max. ~8 Wörter; keine Tabellen, keine Einleitungssätze.
|
||||||
|
- Alle 7 Karten müssen vorhanden sein (JSON-Schlüssel exakt): info, eigenschaften, beispiel, zusammenhaenge, voraussetzungen, modern, veraltet.
|
||||||
|
|
||||||
|
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": "Neu & aktuell", "md": "…"}}, "veraltet": {{"titel": "Veraltet & überholt", "md": "…"}}}}}}
|
||||||
|
{extra}
|
||||||
@@ -4,19 +4,19 @@ FAKTENBASIS (alleinige Quelle, nichts hinzuerfinden):
|
|||||||
{recherche}
|
{recherche}
|
||||||
|
|
||||||
Erstelle GENAU diese 7 Karten (JSON-Schlüssel exakt so):
|
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).
|
- "info" — Titel: "{topic}". Kurzbeschreibung in 1–2 Sätzen, darunter Eckdaten als Stichpunkte (je nach Thema: Art/Typ, Version/Lizenz/Verbreitung ODER Ursprung/Stand/Anwendungsfelder).
|
||||||
- "eigenschaften" — Titel: "Kerneigenschaften". Was einen IM Thema erwartet: kleine Übersicht der Inhalte/Teilgebiete.
|
- "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.
|
- "beispiel" — Titel: "Beispiel". EIN anschauliches, typisches Beispiel mit einem Satz Erklärung — Markdown-Codeblock bei Code-Themen, sonst Beispielsätze oder Mini-Szenario als normaler Text.
|
||||||
- "zusammenhaenge" — Titel: "Zusammenhänge". Mit welchen ANDEREN Themen es zusammenhängt — Nachbarthemen außerhalb dieses Themas, keine Inhalte des Themas selbst.
|
- "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.
|
- "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.
|
- "modern" — Titel: "Neu & aktuell". NUR was in den letzten Jahren neu dazugekommen ist (Features, Erkenntnisse, Empfehlungen). 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.
|
- "veraltet" — Titel: "Veraltet & überholt". Was nicht mehr verwendet wird bzw. als überholt gilt. Gibt es nichts Veraltetes: ehrlich "Keine." mit einem Satz Begründung — nichts erfinden.
|
||||||
|
|
||||||
KOMPAKTHEIT — der OnePager muss OHNE Scrollen auf eine Bildschirmseite passen:
|
KOMPAKTHEIT — der OnePager muss OHNE Scrollen auf eine Bildschirmseite passen:
|
||||||
- Maximal 5 Stichpunkte pro Karte, je maximal ~8 Wörter (Schlagwort + halber Satz).
|
- 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.
|
- Nur das Wichtigste — nicht alle Varianten aufzählen. Weglassen schlägt Vollständigkeit.
|
||||||
- Keine Tabellen, keine verschachtelten Listen, keine Einleitungssätze.
|
- Keine Tabellen, keine verschachtelten Listen, keine Einleitungssätze.
|
||||||
- Codebeispiel maximal ~12 kurze Zeilen.
|
- Beispiel maximal ~12 kurze Zeilen (Code) bzw. ~5 Zeilen (Prosa).
|
||||||
|
|
||||||
Inhalt auf DEUTSCH, alles aus der Faktenbasis belegbar.
|
Inhalt auf DEUTSCH, alles aus der Faktenbasis belegbar.
|
||||||
Stichpunkte als Markdown-Liste mit fettem Schlagwort: `- **Schlagwort**: Rest` (kein rohes •).
|
Stichpunkte als Markdown-Liste mit fettem Schlagwort: `- **Schlagwort**: Rest` (kein rohes •).
|
||||||
@@ -25,5 +25,5 @@ Code-Bezeichner und HTML-Tags im Text IMMER in Backticks (`<p>`, `src`) — nie
|
|||||||
Schreibe NUR die JSON-Datei nach: {out_path}
|
Schreibe NUR die JSON-Datei nach: {out_path}
|
||||||
|
|
||||||
Format:
|
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": "…"}}}}}}
|
{{"karten": {{"info": {{"titel": "{topic}", "md": "…"}}, "eigenschaften": {{"titel": "Kerneigenschaften", "md": "…"}}, "beispiel": {{"titel": "Beispiel", "md": "…"}}, "zusammenhaenge": {{"titel": "Zusammenhänge", "md": "…"}}, "voraussetzungen": {{"titel": "Voraussetzungen", "md": "…"}}, "modern": {{"titel": "Neu & aktuell", "md": "…"}}, "veraltet": {{"titel": "Veraltet & überholt", "md": "…"}}}}}}
|
||||||
{extra}
|
{extra}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
Recherchiere per Websuche: aktuelle Version, die Kernkonzepte und die wichtigsten Fakten zu "{topic}". Nimm nur auf, was du in der Recherche belegt hast.
|
Recherchiere per Websuche: aktuellen Stand (Version bzw. Forschungs-/Praxisstand), 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": ["…", "…"]}}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
Prüfe die Faktenbasis für einen OnePager zum Thema "{topic}".
|
|
||||||
|
|
||||||
FAKTENBASIS:
|
|
||||||
{recherche}
|
|
||||||
|
|
||||||
Sie muss diese Dimensionen abdecken:
|
|
||||||
1. Kurzbeschreibung (1–2 Sätze)
|
|
||||||
2. Technische Daten (Art/Typ, Version/Stand, Lizenz/Kosten)
|
|
||||||
3. Inhaltsübersicht (was einen im Thema erwartet)
|
|
||||||
4. Ein typisches Beispiel
|
|
||||||
5. Zusammenhänge mit ANDEREN Themen (Nachbarthemen, nicht Inhalte des Themas selbst)
|
|
||||||
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 belegt (Namen, Versionen, Zahlen — nicht vage)?
|
|
||||||
2. Hat jeder Punkt eine Quelle?
|
|
||||||
3. Wirkt etwas erfunden oder widersprüchlich?
|
|
||||||
|
|
||||||
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": ["…", "…"]}}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
Überarbeite die Faktenbasis für einen OnePager zum Thema "{topic}".
|
|
||||||
|
|
||||||
{source}
|
|
||||||
|
|
||||||
BISHERIGE FAKTENBASIS:
|
|
||||||
{recherche}
|
|
||||||
|
|
||||||
NOTIERTE PROBLEME (von der Prüfung):
|
|
||||||
{probleme}
|
|
||||||
|
|
||||||
Behebe NUR die notierten Probleme — Fehlendes anhand der oben genannten Quelle ergänzen, Vages konkretisieren, Unbelegtes belegen oder streichen. Alles andere bleibt erhalten.
|
|
||||||
|
|
||||||
Schreibe die VOLLSTÄNDIGE, überarbeitete Markdown-Datei nach: {out_path}
|
|
||||||
|
|
||||||
Kompakt, faktenorientiert, mit Quelle pro Punkt.
|
|
||||||
{extra}
|
|
||||||
13
templates/Prompt/OnePager-Recherche-Mapping.md
Normal file
13
templates/Prompt/OnePager-Recherche-Mapping.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{n} Agenten haben unabhängig die Faktenbasis für einen OnePager zum Thema "{topic}" recherchiert. Konsolidiere ihre Ergebnisse zu EINER Faktenbasis.
|
||||||
|
|
||||||
|
{recherchen}
|
||||||
|
|
||||||
|
Regeln:
|
||||||
|
- Behalte die Struktur der 7 Dimensionen (Kurzbeschreibung, Eckdaten, Inhaltsübersicht, Beispiel, Zusammenhänge, Voraussetzungen, Neuerungen vs. Veraltetes).
|
||||||
|
- Mehrfach belegte Fakten haben Vorrang. Dubletten zusammenführen, nicht wiederholen.
|
||||||
|
- Widersprüche zugunsten der besser belegten bzw. aktuelleren Version auflösen.
|
||||||
|
- Nur einmal Genanntes übernehmen, wenn es eine Quelle hat und plausibel ist — sonst weglassen.
|
||||||
|
- Quellenangaben pro Punkt behalten. Nichts hinzuerfinden.
|
||||||
|
- Wähle das beste Beispiel der Recherchen, nicht mehrere.
|
||||||
|
|
||||||
|
Schreibe NUR die Markdown-Datei nach: {out_path}
|
||||||
@@ -4,9 +4,9 @@ Sammle die Faktenbasis für einen OnePager — ein Übersichtsblatt auf einer Se
|
|||||||
|
|
||||||
Recherchiere gezielt diese Dimensionen:
|
Recherchiere gezielt diese Dimensionen:
|
||||||
1. Kurzbeschreibung: Was ist "{topic}" in 1–2 Sätzen?
|
1. Kurzbeschreibung: Was ist "{topic}" in 1–2 Sätzen?
|
||||||
2. Technische Daten: Art/Typ, aktuelle Version/Stand, Lizenz/Kosten, Verbreitung.
|
2. Eckdaten: Art/Typ des Themas; bei Software: aktuelle Version, Lizenz/Kosten, Verbreitung; bei Sprachen, Methoden, Theorien: Ursprung/Urheber, heutiger Stand, typische Anwendungsfelder.
|
||||||
3. Inhaltsübersicht: Was erwartet einen im Thema — die wichtigsten Inhalte/Teilgebiete.
|
3. Inhaltsübersicht: Was erwartet einen im Thema — die wichtigsten Inhalte/Teilgebiete.
|
||||||
4. Beispiel: ein minimales, typisches Code-/Anwendungsbeispiel.
|
4. Beispiel: ein minimales, typisches Beispiel im themengerechten Format (Code-Beispiel, Beispielsätze/Mini-Dialog oder Mini-Szenario).
|
||||||
5. Zusammenhänge: mit welchen ANDEREN Themen es zusammenhängt (Nachbarthemen außerhalb von "{topic}").
|
5. Zusammenhänge: mit welchen ANDEREN Themen es zusammenhängt (Nachbarthemen außerhalb von "{topic}").
|
||||||
6. Voraussetzungen: welche Themen man vorher bearbeitet haben sollte.
|
6. Voraussetzungen: welche Themen man vorher bearbeitet haben sollte.
|
||||||
7. Neuerungen vs. Veraltetes: was in den letzten Jahren neu dazugekommen ist — und was nicht mehr verwendet wird (falls es nichts gibt, jeweils ausdrücklich notieren).
|
7. Neuerungen vs. Veraltetes: was in den letzten Jahren neu dazugekommen ist — und was nicht mehr verwendet wird (falls es nichts gibt, jeweils ausdrücklich notieren).
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Prüfe:
|
|||||||
1. Sind alle 7 Karten vollständig ausgefüllt (keine abgebrochenen oder leeren Inhalte, keine Platzhalter)?
|
1. Sind alle 7 Karten vollständig ausgefüllt (keine abgebrochenen oder leeren Inhalte, keine Platzhalter)?
|
||||||
2. Stimmen alle Aussagen mit der Faktenbasis überein? Nichts Erfundenes?
|
2. Stimmen alle Aussagen mit der Faktenbasis überein? Nichts Erfundenes?
|
||||||
3. Ist jede Karte KOMPAKT — maximal 5 kurze Stichpunkte (je ~8 Wörter), keine Tabellen, Beispiel maximal ~12 Zeilen? Zu lange Karten sind ein Problem.
|
3. Ist jede Karte KOMPAKT — maximal 5 kurze Stichpunkte (je ~8 Wörter), keine Tabellen, Beispiel maximal ~12 Zeilen? Zu lange Karten sind ein Problem.
|
||||||
4. Ist jede Karte für sich verständlich? Ist das Beispiel ein lauffähig plausibler Codeblock?
|
4. Ist jede Karte für sich verständlich? Ist das Beispiel konkret und plausibel — lauffähig wirkender Code bzw. realistische Sätze/realistisches Szenario?
|
||||||
|
|
||||||
Du PRÜFST nur und notierst Probleme — du änderst nichts. Nenne die betroffene Karte über ihren Schlüssel (info, eigenschaften, beispiel, zusammenhaenge, voraussetzungen, modern, veraltet).
|
Du PRÜFST nur und notierst Probleme — du änderst nichts. Nenne die betroffene Karte über ihren Schlüssel (info, eigenschaften, beispiel, zusammenhaenge, voraussetzungen, modern, veraltet).
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user