Compare commits
14 Commits
cfc666055c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
003f6d3b8e | ||
|
|
33a4440404 | ||
|
|
25a07ede4d | ||
|
|
08dd0ccd69 | ||
|
|
6e5d673ca7 | ||
|
|
54adcdc50c | ||
|
|
77fd6156f6 | ||
|
|
143e6d6f7c | ||
|
|
2b89e21cd3 | ||
|
|
822f6ee3e9 | ||
|
|
8382d6f27a | ||
|
|
a7fd345bb6 | ||
|
|
0ba708dc54 | ||
|
|
78d5833fe4 |
@@ -26,6 +26,12 @@ _active_processes: dict[str, asyncio.subprocess.Process] = {}
|
||||
_batch_sem = asyncio.Semaphore(MAX_CONCURRENT_AGENTS)
|
||||
_interactive_sem = asyncio.Semaphore(MAX_CONCURRENT_INTERACTIVE)
|
||||
|
||||
# OpenCode-Starts serialisieren: gleichzeitig startende Prozesse kollidieren an
|
||||
# der internen Session-DB ("database is locked", Exit nach <1s). Der kurze
|
||||
# Versatz entzerrt die Starts; danach laufen die Prozesse normal parallel.
|
||||
_opencode_start_lock = asyncio.Lock()
|
||||
_OPENCODE_START_DELAY = 1.0
|
||||
|
||||
# Capability → Claude --allowedTools
|
||||
_CLAUDE_TOOLS = {
|
||||
"full": "Write,Bash,Read,WebSearch,WebFetch",
|
||||
@@ -92,14 +98,23 @@ async def run_agent(
|
||||
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]:
|
||||
start = time.monotonic()
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdin=asyncio.subprocess.PIPE if stdin_data is not None else asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
async def spawn():
|
||||
return await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdin=asyncio.subprocess.PIPE if stdin_data is not None else asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
if stagger:
|
||||
async with _opencode_start_lock:
|
||||
process = await spawn()
|
||||
await asyncio.sleep(_OPENCODE_START_DELAY)
|
||||
else:
|
||||
process = await spawn()
|
||||
_active_processes[agent_key] = process
|
||||
try:
|
||||
try:
|
||||
@@ -154,7 +169,7 @@ async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str
|
||||
"-f", str(prompt_path),
|
||||
]
|
||||
try:
|
||||
rc, stdout, stderr = await _communicate(agent_key, cmd, None, timeout)
|
||||
rc, stdout, stderr = await _communicate(agent_key, cmd, None, timeout, stagger=True)
|
||||
return rc, _clean_opencode_output(stdout), stderr
|
||||
finally:
|
||||
prompt_path.unlink(missing_ok=True)
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
"""Bausteine-Pipeline: 4x Recherche (3 nötig) → 2x Auswahl (1) → Prüfung — reines Inventar, unsortiert."""
|
||||
"""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
|
||||
@@ -7,15 +17,15 @@ import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from agents import kill_process
|
||||
from config import DEFAULT_PROVIDER
|
||||
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,
|
||||
_semaphore, _timeout, run_single_slot,
|
||||
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, _titel_aufloesen, _titel_index
|
||||
from textkit import _eindeutige_titel, _parse_auswahl, _titel_aufloesen, _titel_index, _vormerge
|
||||
|
||||
log = logging.getLogger("creator.bausteine")
|
||||
|
||||
@@ -24,11 +34,11 @@ _bausteine_errors: dict[str, str] = {}
|
||||
_bausteine_cancelled: set[str] = set()
|
||||
_bausteine_step: dict[str, int] = {}
|
||||
|
||||
BAUSTEINE_STEPS = ("Recherche", "Auswahl", "Prüfung")
|
||||
BAUSTEINE_STEPS = ("Recherche", "Konsolidierung", "Klärung")
|
||||
|
||||
|
||||
def _bausteine_steps(topic: str) -> tuple:
|
||||
"""Projekte haben einen 4. Schritt: Themenfeld-Ergänzung per Web-Recherche."""
|
||||
"""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
|
||||
@@ -36,18 +46,24 @@ def _bausteine_steps(topic: str) -> tuple:
|
||||
|
||||
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)],
|
||||
"auswahl": [arbeit / f"auswahl-{i}.md" for i in (1, 2)],
|
||||
"auswahl_check": arbeit / "auswahl-check.json",
|
||||
"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["auswahl"], files["auswahl_check"], files["ergaenzung"]]
|
||||
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:
|
||||
@@ -63,9 +79,14 @@ def _resume_step(topic: str) -> int:
|
||||
files = _bausteine_files(topic)
|
||||
if sum(p.exists() for p in files["recherche"]) < 3:
|
||||
return 0
|
||||
if not any(p.exists() for p in files["auswahl"]):
|
||||
if not files["recherche_mapping"].exists():
|
||||
return 1
|
||||
if not files["auswahl_check"].exists():
|
||||
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
|
||||
@@ -168,25 +189,15 @@ def _file_payload(path: Path):
|
||||
return text if _parse_auswahl(text) else None
|
||||
|
||||
|
||||
def _auswahl_payload(path: Path):
|
||||
if not path.exists():
|
||||
return None
|
||||
text = path.read_text(encoding="utf-8")
|
||||
entries = _parse_auswahl(text)
|
||||
return (text, entries) if entries else None
|
||||
|
||||
|
||||
def _auswahl_check_schema(data):
|
||||
"""{"nachtraege": [...], "streichen": [...]} — None bei Schema-Verstoß."""
|
||||
def _mapping_schema(data):
|
||||
"""{"bausteine": [str, ≥1], "rest": [str]} → (bausteine, rest) · sonst None."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
nach = data.get("nachtraege", [])
|
||||
streich = data.get("streichen", [])
|
||||
if not isinstance(nach, list) or not isinstance(streich, list):
|
||||
bausteine = _str_liste(data.get("bausteine"))
|
||||
rest = _str_liste(data.get("rest"))
|
||||
if not bausteine or rest is None:
|
||||
return None
|
||||
if not all(isinstance(x, str) for x in [*nach, *streich]):
|
||||
return None
|
||||
return {"nachtraege": nach, "streichen": streich}
|
||||
return bausteine, rest
|
||||
|
||||
|
||||
async def generate_bausteine(topic: str, instructions: str = "", provider: str = DEFAULT_PROVIDER) -> None:
|
||||
@@ -223,17 +234,18 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
|
||||
for p_alt in _alle_slot_dateien(files):
|
||||
p_alt.unlink(missing_ok=True)
|
||||
|
||||
# Schritt 1: 4 Recherche-Agenten, 3 gültige nötig — vorhandene Slot-Dateien zählen
|
||||
# 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 and len(recherchen) < 3:
|
||||
if text is not None:
|
||||
recherchen.append(text)
|
||||
else:
|
||||
offen.append((i, path))
|
||||
vorhanden = len(recherchen)
|
||||
set_p(f"Recherche läuft ({vorhanden}/3 gültig)…", step=0)
|
||||
set_p(f"Recherche läuft ({vorhanden} gültig, min. 3)…", step=0)
|
||||
if vorhanden < 3:
|
||||
caps = "files" if project else "full"
|
||||
slots = [
|
||||
@@ -247,80 +259,136 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
|
||||
]
|
||||
neue = await _race(
|
||||
topic, "Recherche", slots, 3 - vorhanden, _timeout("recherche"), provider,
|
||||
on_update=lambda c: set_p(f"Recherche läuft ({vorhanden + c}/3 gültig)…"),
|
||||
cancelled=is_cancelled,
|
||||
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 (Quorum nicht erreicht)"
|
||||
_bausteine_errors[topic] = "Recherche fehlgeschlagen (Minimum nicht erreicht)"
|
||||
return
|
||||
recherchen += neue
|
||||
|
||||
# Schritt 2: 2 Auswahl-Agenten, der erste gewinnt — vorhandene gültige Datei wird übernommen
|
||||
n_est = max(len(_parse_auswahl(t)) for t in recherchen)
|
||||
bestehende = next((res for p in files["auswahl"] if (res := _auswahl_payload(p)) is not None), None)
|
||||
if bestehende is not None:
|
||||
flat, entries = bestehende
|
||||
else:
|
||||
# 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)
|
||||
results_block = "\n\n".join(f"### Recherche {i}\n\n{text}" for i, text in enumerate(recherchen, 1))
|
||||
slots = [
|
||||
{
|
||||
"key": f"bausteine-{topic}-auswahl-{i}",
|
||||
"prompt": _prompt("Bausteine-Auswahl", topic=topic, results=results_block, out_path=path),
|
||||
"role": "fast", "capabilities": "files",
|
||||
"payload": (lambda result, p=path: _auswahl_payload(p)),
|
||||
}
|
||||
for i, path in enumerate(files["auswahl"], 1)
|
||||
]
|
||||
auswahl = await _race(topic, "Auswahl", slots, 1, _timeout("auswahl", n_est), provider, cancelled=is_cancelled)
|
||||
if is_cancelled():
|
||||
abgebrochen()
|
||||
return
|
||||
if auswahl is None:
|
||||
_bausteine_errors[topic] = "Auswahl fehlgeschlagen (kein gültiges Ergebnis)"
|
||||
return
|
||||
flat, entries = auswahl[0]
|
||||
|
||||
# Schritt 2b: Auswahl-Prüfung gegen die Recherche-Titel (JSON, nicht fatal)
|
||||
set_p("Prüfe Auswahl…", step=2)
|
||||
check_path = files["auswahl_check"]
|
||||
patch = _auswahl_check_schema(_json_datei(check_path))
|
||||
if patch is None:
|
||||
check_path.unlink(missing_ok=True)
|
||||
titel_listen = "\n\n".join(
|
||||
f"### Recherche {i}\n" + "\n".join(f"- {_titel(t)}" for t in _parse_auswahl(text).values())
|
||||
for i, text in enumerate(recherchen, 1)
|
||||
)
|
||||
status, check = await run_single_slot(
|
||||
ctx, "Auswahl-Check",
|
||||
key=f"bausteine-{topic}-auswahlcheck-1",
|
||||
prompt=_prompt("Bausteine-Auswahl-Check", topic=topic, results=titel_listen, auswahl=flat, out_path=check_path),
|
||||
role="fast", capabilities="files",
|
||||
payload=lambda result: _auswahl_check_schema(_json_datei(check_path)),
|
||||
timeout=_timeout("auswahl_check", len(entries)),
|
||||
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:
|
||||
_log(topic, "Auswahl-Check fehlgeschlagen — fahre ohne Korrekturen fort")
|
||||
else:
|
||||
patch = check
|
||||
if patch is not None and (patch["streichen"] or patch["nachtraege"]):
|
||||
idx = _titel_index(entries)
|
||||
weg = {num for t in patch["streichen"] if (num := _titel_aufloesen(idx, t)) is not None}
|
||||
if weg:
|
||||
_log(topic, f"Auswahl-Check streicht Duplikate: {sorted(weg)}")
|
||||
entries = {n: t for n, t in entries.items() if n not in weg}
|
||||
if patch["nachtraege"]:
|
||||
_log(topic, f"Auswahl-Check ergänzt {len(patch['nachtraege'])} Bausteine")
|
||||
texts = [t for _, t in sorted(entries.items())] + list(patch["nachtraege"])
|
||||
entries = {i: t for i, t in enumerate(texts, 1)}
|
||||
_bausteine_errors[topic] = "Recherche-Mapping fehlgeschlagen"
|
||||
return
|
||||
konsens, rest = mapping
|
||||
|
||||
# Schritt 4 (nur Projekte): Themenfeld-Ergänzung — Skript/Projekt ist ein Ausschnitt,
|
||||
# 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)
|
||||
|
||||
@@ -15,20 +15,33 @@ MAX_CONCURRENT_GENERATIONS = 10
|
||||
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).
|
||||
# Gilt für alle Provider gleich — wer zu langsam ist, wird neu gestartet bzw. überholt.
|
||||
TIMEOUTS = {
|
||||
"recherche": (1800, 0), # fix 30 min
|
||||
"auswahl": (600, 10),
|
||||
"auswahl_check": (300, 2),
|
||||
"recherche_mapping": (600, 3), # n = vorgemergte Einträge
|
||||
"auswahl": (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)
|
||||
"guide_auswahl": (300, 5), # pro Baustein im Inventar
|
||||
"guide_check": (300, 2), # Auswahl-/Gliederungs-Prüfung (nur Titellisten)
|
||||
"plan": (300, 5),
|
||||
"plan_judge": (600, 5), # Judge liest bis zu 5 Gliederungen, n = Sections
|
||||
"writer": (600, 120), # pro Section im Chunk
|
||||
"lese_check": (300, 10), # pro Section im Paket
|
||||
"onepager_recherche": (900, 0),
|
||||
"onepager_mapping": (600, 0), # Konsolidierung der Recherchen
|
||||
"onepager_bauen": (300, 0),
|
||||
"onepager_judge": (600, 0), # Judge über die Karten-Sätze
|
||||
"onepager_verify": (300, 0),
|
||||
}
|
||||
|
||||
@@ -41,31 +54,27 @@ FORMAT_ANTEIL = {
|
||||
|
||||
# Provider-Stacks: komplett unabhängig, einer kann jederzeit entfernt werden.
|
||||
# Rollen: "quick" = Massenarbeit (Recherche, Einordnung),
|
||||
# "fast" = Urteilsaufgaben mit kleinem Output (Auswahl, Final, OnePager, Chat),
|
||||
# "guide" = große Generierung (Plan, Writer).
|
||||
# "fast" = Interaktion + Voten (Chat, Prüfung, Klärung, Elemente),
|
||||
# "judge" = Mapping-/Judge-/Prüf-Agenten — kalt (niedrige Temperature,
|
||||
# ohne Thinking) für stabile Urteile; Claude/Lokal mappen auf "fast",
|
||||
# "guide" = große Generierung (Vorschläge, Writer).
|
||||
DEFAULT_PROVIDER = "claude"
|
||||
PROVIDERS = {
|
||||
"claude": {
|
||||
"cli": "claude",
|
||||
"guide": "claude-opus-4-8[1m]",
|
||||
"fast": "claude-sonnet-4-6",
|
||||
"judge": "claude-sonnet-4-6", # CLI kennt keine Temperature
|
||||
"quick": "claude-sonnet-4-6",
|
||||
"env_key": None, # Auth via CLAUDE_CODE_OAUTH_TOKEN oder ~/.claude
|
||||
},
|
||||
# "minimax-kalt/…" ist KEIN eigener Stack, nur ein opencode-Provider-Eintrag
|
||||
# (dev-ops/opencode.json) mit niedriger Temperature; M3 dort ohne Thinking.
|
||||
"minimax": {
|
||||
"cli": "opencode",
|
||||
"guide": "minimax/MiniMax-M3",
|
||||
"fast": "minimax/MiniMax-M2.7-highspeed",
|
||||
"quick": "minimax/MiniMax-M2.7-highspeed",
|
||||
"env_key": "MINIMAX_API_KEY",
|
||||
},
|
||||
# Wie "minimax", aber Chat/Elemente (Rolle "fast") laufen auf M3 OHNE Thinking.
|
||||
# M2.x kann Thinking nicht abschalten — nur M3 respektiert thinking:disabled.
|
||||
# guide/quick bleiben identisch zur Thinking-Variante.
|
||||
"minimax-direkt": {
|
||||
"cli": "opencode",
|
||||
"guide": "minimax/MiniMax-M3",
|
||||
"fast": "minimax-direkt/MiniMax-M3",
|
||||
"fast": "minimax-kalt/MiniMax-M2.7-highspeed",
|
||||
"judge": "minimax-kalt/MiniMax-M3",
|
||||
"quick": "minimax/MiniMax-M2.7-highspeed",
|
||||
"env_key": "MINIMAX_API_KEY",
|
||||
},
|
||||
@@ -73,6 +82,7 @@ PROVIDERS = {
|
||||
"cli": "opencode",
|
||||
"guide": "ollama/qwen3.6:27b",
|
||||
"fast": "ollama/qwen3.5:9b",
|
||||
"judge": "ollama/qwen3.5:9b",
|
||||
"quick": "ollama/qwen3.5:9b",
|
||||
"env_key": None,
|
||||
"check_url": "http://localhost:11434/api/tags", # Ollama erreichbar?
|
||||
|
||||
@@ -47,6 +47,31 @@ CREATE TABLE IF NOT EXISTS elements (
|
||||
)
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -67,10 +92,28 @@ async def init_db():
|
||||
await db.execute(CREATE_PROGRESS)
|
||||
await db.execute(CREATE_TOPICS)
|
||||
await db.execute(CREATE_ELEMENTS)
|
||||
await db.execute(CREATE_BAUSTEIN_TEXTE)
|
||||
await db.execute(CREATE_BAUSTEIN_PROGRESS)
|
||||
try: # Migration für Bestands-DBs ohne step-Spalte
|
||||
await db.execute("ALTER TABLE guides ADD COLUMN step INTEGER")
|
||||
except aiosqlite.OperationalError:
|
||||
pass
|
||||
try: # Migration für Bestands-DBs ohne verstanden-Spalte (Mastery-Stufe)
|
||||
await db.execute("ALTER TABLE baustein_progress ADD COLUMN verstanden TEXT")
|
||||
except aiosqlite.OperationalError:
|
||||
pass
|
||||
try: # Migration für Bestands-DBs ohne gemeistert-Spalte (Meisterpfad 25)
|
||||
await db.execute("ALTER TABLE baustein_progress ADD COLUMN gemeistert TEXT")
|
||||
except aiosqlite.OperationalError:
|
||||
pass
|
||||
# Migration: alte vertiefungen-Tabelle → baustein_texte (Bestand = lange Form, art 'deepdive')
|
||||
cursor = await db.execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'vertiefungen'")
|
||||
if await cursor.fetchone():
|
||||
await db.execute(
|
||||
"INSERT OR IGNORE INTO baustein_texte (topic, baustein, art, md, created_at, updated_at) "
|
||||
"SELECT topic, baustein, 'deepdive', md, created_at, updated_at FROM vertiefungen"
|
||||
)
|
||||
await db.execute("DROP TABLE vertiefungen")
|
||||
await db.execute(
|
||||
"UPDATE guides SET status = 'error', progress = NULL, error_msg = 'Server-Neustart' "
|
||||
"WHERE status IN ('queued', 'generating')"
|
||||
@@ -255,3 +298,137 @@ async def delete_progress(guide_id: str) -> None:
|
||||
db = await get_db()
|
||||
await db.execute("DELETE FROM guide_progress WHERE guide_id = ?", (guide_id,))
|
||||
await db.commit()
|
||||
|
||||
|
||||
# --- Baustein-Lernen: Vertiefungen + Prüfungs-Fortschritt ---
|
||||
|
||||
def _now() -> str:
|
||||
from datetime import datetime, timezone
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
async def 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_absolviert_all() -> dict[str, set[str]]:
|
||||
"""Alle absolvierten Bausteine in einem Query: topic → Baustein-Titel."""
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT topic, baustein FROM baustein_progress WHERE absolviert IS NOT NULL"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
out: dict[str, set[str]] = {}
|
||||
for topic, baustein in rows:
|
||||
out.setdefault(topic, set()).add(baustein)
|
||||
return out
|
||||
|
||||
|
||||
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()
|
||||
|
||||
@@ -82,14 +82,17 @@ def _topic_context(topic: str, limit: int = 12000) -> str:
|
||||
return text[:limit] if text else "(kein Material vorhanden)"
|
||||
|
||||
|
||||
async def generate_element(topic: str, hint: str, provider: str = DEFAULT_PROVIDER) -> dict:
|
||||
async def generate_element(topic: str, hint: str, provider: str = DEFAULT_PROVIDER, extra_context: str = "") -> dict:
|
||||
"""Erstellt Element-Felder per KI. Fallback: nur Titel aus dem Stichwort."""
|
||||
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=_topic_context(topic),
|
||||
context=context,
|
||||
)
|
||||
returncode, stdout, _ = await run_agent(
|
||||
"element-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
|
||||
|
||||
538
backend/guide.py
538
backend/guide.py
@@ -1,11 +1,14 @@
|
||||
"""Guide-Generierung: 6 Schritte mit Prüfung nach jeder Phase (OnePager hat einen eigenen Weg).
|
||||
"""Guide-Generierung als Konsens-Pipeline (OnePager hat einen eigenen Weg).
|
||||
|
||||
Prüf-Agenten notieren nur Probleme; das Anpassen übernimmt der jeweilige Erzeuger-Typ.
|
||||
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 json
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
@@ -13,17 +16,20 @@ from pathlib import Path
|
||||
|
||||
from agents import run_agent
|
||||
from bausteine import _pdfs_konvertieren
|
||||
from config import DEFAULT_PROVIDER, FORMAT_ANTEIL, TEMPLATES_DIR
|
||||
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, atomic_write_text
|
||||
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, _check_then_fix, _claude_error, _extra,
|
||||
_fail, _gather_error, _log, _prompt, _race, _semaphore, _set_progress,
|
||||
_set_step, _timeout, clear_guide_cancelled, is_guide_cancelled,
|
||||
run_single_slot,
|
||||
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,
|
||||
@@ -32,7 +38,7 @@ from textkit import (
|
||||
|
||||
log = logging.getLogger("creator.guide")
|
||||
|
||||
GUIDE_STEPS = ("Auswahl", "Auswahl-Prüfung", "Gliederung", "Gliederungs-Prüfung", "Schreiben", "Lese-Prüfung")
|
||||
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
|
||||
@@ -43,12 +49,18 @@ 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 {
|
||||
"auswahl": d / f"{stem}.auswahl.json",
|
||||
"auswahl_check": d / f"{stem}.auswahl-check.json",
|
||||
"gliederung": d / f"{stem}.gliederung.json",
|
||||
"gliederung_check": d / f"{stem}.gliederung-check.json",
|
||||
# chunk-/lese-check-/fix-Dateien sind dynamisch: {stem}.chunk-i.md usw.
|
||||
# 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
|
||||
}
|
||||
|
||||
|
||||
@@ -131,6 +143,221 @@ def _resolve_gliederung(data, entries: dict[int, str], soll_min: int, soll_max:
|
||||
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,
|
||||
@@ -152,134 +379,83 @@ async def _generate_sections(
|
||||
"Wähle, was diesem Zweck dient — lass weg, was dafür nicht nötig ist."
|
||||
)
|
||||
|
||||
# Schritt 1: Auswahl — vorhandene gültige Datei wird übernommen (Resume)
|
||||
auswahl = _resolve_auswahl(_json_datei(files["auswahl"]), entries, k_min, k_max)
|
||||
if auswahl is None:
|
||||
await _set_step(guide_id, 0, "Wähle Bausteine…")
|
||||
files["auswahl"].unlink(missing_ok=True)
|
||||
status, auswahl = await run_single_slot(
|
||||
ctx, "Guide-Auswahl",
|
||||
key=f"{guide_id}-auswahl",
|
||||
prompt=_prompt(
|
||||
"Guide-Auswahl",
|
||||
topic=topic, format_name=format_name, bausteine=bausteine_liste,
|
||||
auswahl_auftrag=auswahl_auftrag, out_path=files["auswahl"], extra=_extra(instructions),
|
||||
),
|
||||
role="guide", capabilities="files",
|
||||
payload=lambda result: _resolve_auswahl(_json_datei(files["auswahl"]), entries, k_min, k_max),
|
||||
timeout=_timeout("guide_auswahl", n),
|
||||
)
|
||||
if status == CANCELLED:
|
||||
return None
|
||||
if status == FAILED:
|
||||
await _fail(guide_id, "Auswahl fehlgeschlagen")
|
||||
return None
|
||||
|
||||
def auswahl_titel() -> str:
|
||||
return "\n".join(f"- {_titel(entries[num])}" for num in auswahl)
|
||||
|
||||
def auswahl_json() -> str:
|
||||
return json.dumps({"bausteine": [_titel(entries[num]) for num in auswahl]}, ensure_ascii=False)
|
||||
|
||||
# Schritt 2: Auswahl-Prüfung — notiert Probleme; Anpassung macht ein Auswahl-Agent
|
||||
status, fixed = await _check_then_fix(
|
||||
ctx, name="Auswahl", step=1,
|
||||
check_key=f"{guide_id}-auswahl-check",
|
||||
check_prompt=_prompt(
|
||||
"Guide-Auswahl-Check",
|
||||
topic=topic, format_name=format_name, auswahl_auftrag=auswahl_auftrag,
|
||||
bausteine=bausteine_liste, auswahl=auswahl_titel(),
|
||||
out_path=files["auswahl_check"], extra=_extra(instructions),
|
||||
),
|
||||
check_path=files["auswahl_check"], check_timeout=_timeout("guide_check", len(auswahl)),
|
||||
fix_key=f"{guide_id}-auswahl-fix",
|
||||
build_fix_prompt=lambda probleme: _prompt(
|
||||
"Guide-Auswahl-Fix",
|
||||
topic=topic, format_name=format_name, auswahl_auftrag=auswahl_auftrag,
|
||||
bausteine=bausteine_liste, auswahl=auswahl_titel(),
|
||||
probleme="\n".join(f"- {p}" for p in probleme),
|
||||
out_path=files["auswahl"], extra=_extra(instructions),
|
||||
),
|
||||
fix_payload=lambda result: _resolve_auswahl(_json_datei(files["auswahl"]), entries, k_min, k_max),
|
||||
fix_timeout=_timeout("guide_auswahl", n), fix_role="guide",
|
||||
on_fix_invalid=lambda: atomic_write_text(files["auswahl"], auswahl_json()),
|
||||
# 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 status == CANCELLED:
|
||||
if auswahl is None:
|
||||
return None
|
||||
if status == FAILED:
|
||||
await _fail(guide_id, "Auswahl-Prüfung fehlgeschlagen")
|
||||
return None
|
||||
if fixed is not None:
|
||||
auswahl = fixed
|
||||
|
||||
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 3: Gliederung der festen Auswahl
|
||||
# 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, 2, "Plane Gliederung…")
|
||||
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, "Gliederung",
|
||||
key=f"{guide_id}-gliederung",
|
||||
ctx, "Gliederungs-Judge",
|
||||
key=f"{guide_id}-gliederung-judge",
|
||||
prompt=_prompt(
|
||||
"Guide-Gliederung",
|
||||
topic=topic, format_name=format_name, bausteine=sel_liste,
|
||||
"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="guide", capabilities="files",
|
||||
role="judge", capabilities="files",
|
||||
payload=lambda result: _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll),
|
||||
timeout=_timeout("plan", soll),
|
||||
timeout=_timeout("plan_judge", soll),
|
||||
)
|
||||
if status == CANCELLED:
|
||||
return None
|
||||
if status == FAILED:
|
||||
await _fail(guide_id, "Gliederung fehlgeschlagen")
|
||||
await _fail(guide_id, "Gliederung fehlgeschlagen (Judge ohne gültiges Ergebnis)")
|
||||
return None
|
||||
|
||||
def gliederung_text() -> str:
|
||||
return "\n".join(_zuteilung_text([ch], {num: _titel(entries[num]) for num in ch["nums"]}) for ch in plan)
|
||||
|
||||
def gliederung_json() -> str:
|
||||
return json.dumps(
|
||||
{"kapitel": [{"titel": ch["title"], "bausteine": [_titel(entries[num]) for num in ch["nums"]]} for ch in plan]},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
# Schritt 4: Gliederungs-Prüfung
|
||||
status, fixed = await _check_then_fix(
|
||||
ctx, name="Gliederung", step=3,
|
||||
check_key=f"{guide_id}-gliederung-check",
|
||||
check_prompt=_prompt(
|
||||
"Guide-Gliederung-Check",
|
||||
topic=topic, format_name=format_name, zweck=zweck,
|
||||
auswahl=auswahl_titel(), gliederung=gliederung_text(),
|
||||
out_path=files["gliederung_check"], extra=_extra(instructions),
|
||||
),
|
||||
check_path=files["gliederung_check"], check_timeout=_timeout("guide_check", soll),
|
||||
fix_key=f"{guide_id}-gliederung-fix",
|
||||
build_fix_prompt=lambda probleme: _prompt(
|
||||
"Guide-Gliederung-Fix",
|
||||
topic=topic, format_name=format_name,
|
||||
auswahl=auswahl_titel(), gliederung=gliederung_text(),
|
||||
probleme="\n".join(f"- {p}" for p in probleme),
|
||||
out_path=files["gliederung"], extra=_extra(instructions),
|
||||
),
|
||||
fix_payload=lambda result: _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll),
|
||||
fix_timeout=_timeout("plan", soll), fix_role="guide",
|
||||
on_fix_invalid=lambda: atomic_write_text(files["gliederung"], gliederung_json()),
|
||||
)
|
||||
if status == CANCELLED:
|
||||
return None
|
||||
if status == FAILED:
|
||||
await _fail(guide_id, "Gliederungs-Prüfung fehlgeschlagen")
|
||||
return None
|
||||
if fixed is not None:
|
||||
plan = fixed
|
||||
|
||||
# Schritt 5: Schreiben — vorhandene Chunk-Dateien werden übernommen (Resume)
|
||||
# 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]
|
||||
@@ -288,7 +464,7 @@ async def _generate_sections(
|
||||
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, 4, f"Schreibe Sections ({writer_count} Writer)…" if writer_count > 1 else "Schreibe Sections…")
|
||||
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}",
|
||||
@@ -329,61 +505,69 @@ async def _generate_sections(
|
||||
await _fail(guide_id, "Keine Sections in der Writer-Ausgabe gefunden")
|
||||
return None
|
||||
|
||||
# Schritt 6: Lese-Prüfung pro Writer-Paket — Fix beauftragt Writer nur mit beanstandeten Sections
|
||||
# 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]
|
||||
check_paths = [content_path.parent / f"{content_path.stem}.lese-check-{i}.json" for i in range(1, writer_count + 1)]
|
||||
offen_checks = [i for i, p in enumerate(check_paths) if _lese_probleme_schema(_json_datei(p)) is None and chunk_nums[i]]
|
||||
if offen_checks:
|
||||
await _set_step(guide_id, 5, f"Prüfe Lesbarkeit ({len(offen_checks)} Prüfer)…" if len(offen_checks) > 1 else "Prüfe Lesbarkeit…")
|
||||
|
||||
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 sections_text(nums: list[int]) -> str:
|
||||
return "\n\n".join(f"SECTION: {_titel(entries[num])}\n{by_num[num]['md']}" for num in nums)
|
||||
|
||||
slots = [{
|
||||
"key": f"{guide_id}-lese-check-{i + 1}",
|
||||
"prompt": _prompt(
|
||||
"Guide-Lese-Check",
|
||||
topic=topic, format_name=format_name, spec=spec,
|
||||
sections=sections_text(chunk_nums[i]),
|
||||
out_path=check_paths[i], extra=_extra(instructions),
|
||||
),
|
||||
"role": "fast", "capabilities": "files",
|
||||
"payload": (lambda result, p=check_paths[i]: _lese_probleme_schema(_json_datei(p))),
|
||||
} for i in offen_checks]
|
||||
res = await _race(topic, "Lese-Prüfung", slots, len(slots), _timeout("lese_check", max(chunk_sizes)), provider, cancelled=is_cancelled)
|
||||
if is_cancelled():
|
||||
return None
|
||||
if res is None:
|
||||
await _fail(guide_id, "Lese-Prüfung fehlgeschlagen")
|
||||
return None
|
||||
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
|
||||
)
|
||||
|
||||
probleme_by_num: dict[int, str] = {}
|
||||
for p in check_paths:
|
||||
for item in (_lese_probleme_schema(_json_datei(p)) or []):
|
||||
num = _titel_aufloesen(idx, item["section"])
|
||||
if num in by_num and num not in probleme_by_num:
|
||||
probleme_by_num[num] = item["problem"]
|
||||
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
|
||||
|
||||
if probleme_by_num:
|
||||
_log(topic, f"Lese-Prüfung: {len(probleme_by_num)} Section(s) beanstandet")
|
||||
await _set_step(guide_id, 5, f"Überarbeite {len(probleme_by_num)} Section(s)…")
|
||||
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_offen = [i for i, nums in enumerate(fix_chunks) if nums]
|
||||
fix_paths = [content_path.parent / f"{content_path.stem}.fix-{i + 1}.md" for i in range(writer_count)]
|
||||
|
||||
def auftraege_text(nums: list[int]) -> str:
|
||||
return "\n\n".join(
|
||||
f"SECTION: {_titel(entries[num])}\nPROBLEM: {probleme_by_num[num]}\nAKTUELLER INHALT:\n{by_num[num]['md']}"
|
||||
for num in 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-w{i + 1}",
|
||||
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]),
|
||||
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",
|
||||
@@ -394,17 +578,23 @@ async def _generate_sections(
|
||||
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} fehlgeschlagen — Original bleibt")
|
||||
ersetzt = 0
|
||||
for i in fix_offen:
|
||||
if not fix_paths[i].exists():
|
||||
_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(fix_paths[i].read_text(encoding="utf-8")):
|
||||
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 += 1
|
||||
_log(topic, f"Lese-Prüfung: {ersetzt} Section(s) überarbeitet")
|
||||
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] = []
|
||||
|
||||
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)
|
||||
@@ -8,7 +8,7 @@ FormatType = Literal[
|
||||
"FullGuide",
|
||||
]
|
||||
|
||||
ProviderType = Literal["claude", "minimax", "minimax-direkt", "lokal"]
|
||||
ProviderType = Literal["claude", "minimax", "lokal"]
|
||||
|
||||
|
||||
class GuideCreateRequest(BaseModel):
|
||||
@@ -156,3 +156,70 @@ class ProgressUpdate(BaseModel):
|
||||
|
||||
class ProgressResponse(BaseModel):
|
||||
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]
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
"""OnePager-Pipeline: Recherche → Recherche-Prüfung → Bauen → Prüfung (7 Karten im 3×3-Raster)."""
|
||||
"""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 fsutil import atomic_write_json
|
||||
from config import KONSENS_GRACE, KONSENS_MAX_RUNDEN
|
||||
from jsonio import read_json_file as _json_datei
|
||||
from pipeline import (
|
||||
CANCELLED, FAILED, GenContext, _check_then_fix, _extra, _fail,
|
||||
_prompt, _set_step, _timeout, is_guide_cancelled, run_single_slot,
|
||||
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:
|
||||
ctx = GenContext(topic=topic, provider=provider, is_cancelled=lambda: is_guide_cancelled(guide_id), guide_id=guide_id)
|
||||
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")
|
||||
@@ -38,116 +48,193 @@ async def _generate_onepager(
|
||||
return out
|
||||
|
||||
d, stem = content_path.parent, content_path.stem
|
||||
recherche_path = d / f"{stem}.recherche.md"
|
||||
recherche_check_path = d / f"{stem}.recherche-check.json"
|
||||
karten_path = d / f"{stem}.karten.json"
|
||||
check_path = d / f"{stem}.onepager-check.json"
|
||||
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"
|
||||
recherche_check_template = "OnePager-Recherche-Check-Projekt"
|
||||
else:
|
||||
source = _prompt("OnePager-Quelle-Thema", topic=topic)
|
||||
recherche_template = "OnePager-Recherche"
|
||||
recherche_check_template = "OnePager-Recherche-Check"
|
||||
|
||||
def recherche_payload(result=None):
|
||||
if not recherche_path.exists():
|
||||
def text_payload(path: Path):
|
||||
if not path.exists():
|
||||
return None
|
||||
text = recherche_path.read_text(encoding="utf-8").strip()
|
||||
text = path.read_text(encoding="utf-8").strip()
|
||||
return text or None
|
||||
|
||||
# Schritt 1: Recherche — vorhandene Datei wird übernommen (Resume)
|
||||
recherche = recherche_payload()
|
||||
# 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…")
|
||||
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, "OnePager-Recherche",
|
||||
key=f"{guide_id}-recherche",
|
||||
prompt=_prompt(recherche_template, topic=topic, source=source, out_path=recherche_path, extra=_extra(instructions)),
|
||||
role="quick", capabilities="files" if project else "full",
|
||||
payload=recherche_payload, timeout=_timeout("onepager_recherche"),
|
||||
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, "OnePager-Recherche fehlgeschlagen")
|
||||
await _fail(guide_id, "Recherche-Konsolidierung fehlgeschlagen")
|
||||
return None
|
||||
|
||||
# Schritt 2: Recherche-Prüfung — notiert Probleme; Anpassung macht ein Recherche-Agent
|
||||
status, fixed = await _check_then_fix(
|
||||
ctx, name="Recherche", step=1,
|
||||
check_key=f"{guide_id}-recherche-check",
|
||||
check_prompt=_prompt(recherche_check_template, topic=topic, recherche=recherche, out_path=recherche_check_path),
|
||||
check_path=recherche_check_path, check_timeout=_timeout("onepager_verify"),
|
||||
fix_key=f"{guide_id}-recherche-fix",
|
||||
build_fix_prompt=lambda probleme: _prompt(
|
||||
"OnePager-Recherche-Fix",
|
||||
topic=topic, source=source, recherche=recherche,
|
||||
probleme="\n".join(f"- {p}" for p in probleme),
|
||||
out_path=recherche_path, extra=_extra(instructions),
|
||||
),
|
||||
fix_payload=recherche_payload, fix_timeout=_timeout("onepager_recherche"),
|
||||
fix_role="quick", fix_caps="files" if project else "full",
|
||||
)
|
||||
if status == CANCELLED:
|
||||
return None
|
||||
if status == FAILED:
|
||||
await _fail(guide_id, "Recherche-Prüfung fehlgeschlagen")
|
||||
return None
|
||||
if fixed is not None:
|
||||
recherche = fixed
|
||||
|
||||
# Schritt 3: Bauen — Karten nur aus der Faktenbasis (Resume: gültige Datei wird übernommen)
|
||||
# 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, 2, "Baue OnePager…")
|
||||
karten_path.unlink(missing_ok=True)
|
||||
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, "OnePager-Bauen",
|
||||
key=f"{guide_id}-bauen",
|
||||
prompt=_prompt("OnePager-Bauen", topic=topic, recherche=recherche, out_path=karten_path, extra=_extra(instructions)),
|
||||
role="fast", capabilities="files",
|
||||
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_bauen"),
|
||||
timeout=_timeout("onepager_judge"),
|
||||
)
|
||||
if status == CANCELLED:
|
||||
return None
|
||||
if status == FAILED:
|
||||
await _fail(guide_id, "OnePager-Bau fehlgeschlagen")
|
||||
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 4: Prüfung — notiert Probleme; Anpassung macht ein Bauen-Agent
|
||||
status, fixed = await _check_then_fix(
|
||||
ctx, name="OnePager", step=3,
|
||||
check_key=f"{guide_id}-verify",
|
||||
check_prompt=_prompt("OnePager-Verifikation", topic=topic, recherche=recherche, karten=karten_block(), out_path=check_path),
|
||||
check_path=check_path, check_timeout=_timeout("onepager_verify"),
|
||||
fix_key=f"{guide_id}-karten-fix",
|
||||
build_fix_prompt=lambda probleme: _prompt(
|
||||
"OnePager-Fix",
|
||||
topic=topic, recherche=recherche, karten=karten_block(),
|
||||
probleme="\n".join(f"- {p}" for p in probleme),
|
||||
out_path=karten_path, extra=_extra(instructions),
|
||||
),
|
||||
fix_payload=lambda result: karten_schema(_json_datei(karten_path)),
|
||||
fix_timeout=_timeout("onepager_bauen"),
|
||||
on_fix_invalid=lambda: atomic_write_json(
|
||||
karten_path, {"karten": {k["key"]: {"titel": k["titel"], "md": k["md"]} for k in karten}},
|
||||
),
|
||||
)
|
||||
if status == CANCELLED:
|
||||
return None
|
||||
if status == FAILED:
|
||||
await _fail(guide_id, "OnePager-Prüfung fehlgeschlagen")
|
||||
return None
|
||||
if fixed is not None:
|
||||
# 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 = [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Pipeline-Grundbausteine: Agent-Races, Single-Slot, Check→Fix, Prompts, Guide-Status.
|
||||
"""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
|
||||
@@ -105,10 +105,39 @@ def _probleme_schema(data):
|
||||
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) -> list | None:
|
||||
async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: int, provider: str, on_update=None, cancelled=None, *, grace: int | None = None) -> list | None:
|
||||
"""Startet alle Slots parallel und sammelt `quorum` gültige Ergebnisse.
|
||||
|
||||
Slot-Spec: {key, prompt, role, capabilities, payload}. `payload(result)`
|
||||
@@ -116,9 +145,16 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
||||
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]
|
||||
@@ -136,7 +172,15 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
||||
while tasks:
|
||||
if cancelled and cancelled():
|
||||
return None
|
||||
done, _ = await asyncio.wait(tasks.keys(), return_when=asyncio.FIRST_COMPLETED)
|
||||
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
|
||||
@@ -155,16 +199,24 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
||||
|
||||
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:
|
||||
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
|
||||
if attempts[i] <= _MAX_RESTARTS and not (cancelled and cancelled()):
|
||||
# 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:
|
||||
@@ -184,7 +236,7 @@ class GenContext:
|
||||
guide_id: str | None = None
|
||||
|
||||
|
||||
# Ergebnis-Status von run_single_slot/_check_then_fix
|
||||
# Ergebnis-Status von run_single_slot
|
||||
OK, CANCELLED, FAILED = "ok", "cancelled", "failed"
|
||||
|
||||
|
||||
@@ -205,47 +257,3 @@ async def run_single_slot(
|
||||
return OK, res[0]
|
||||
|
||||
|
||||
async def _check_then_fix(
|
||||
ctx: GenContext, *, name: str, step: int,
|
||||
check_key: str, check_prompt: str, check_path: Path, check_timeout: int,
|
||||
fix_key: str, build_fix_prompt, fix_payload, fix_timeout: int,
|
||||
fix_role: str = "fast", fix_caps: str = "files",
|
||||
on_fix_invalid=None,
|
||||
) -> tuple[str, object]:
|
||||
"""Check→Fix-Muster: Prüf-Agent notiert Probleme (JSON), Fix-Agent behebt sie.
|
||||
|
||||
Resume: existierende Check-Datei überspringt den ganzen Schritt.
|
||||
Check ist fatal (FAILED), Fix nicht — Original bleibt; on_fix_invalid kann
|
||||
das kanonische Original zurückschreiben, falls der Fix-Agent die
|
||||
Artefakt-Datei zerschrieben hat.
|
||||
Lese-Check (Multi-Slot, Section-genau) und Bausteine-Auswahl-Check
|
||||
(Patch-Semantik) passen bewusst NICHT in dieses Muster.
|
||||
→ (OK, neues_artefakt | None=unverändert) | (CANCELLED, None) | (FAILED, None)
|
||||
"""
|
||||
if check_path.exists():
|
||||
return OK, None
|
||||
await _set_step(ctx.guide_id, step, f"Prüfe {name}…")
|
||||
status, probleme = await run_single_slot(
|
||||
ctx, f"{name}-Prüfung", key=check_key, prompt=check_prompt,
|
||||
role="fast", capabilities="files",
|
||||
payload=lambda result: _probleme_schema(_json_datei(check_path)),
|
||||
timeout=check_timeout,
|
||||
)
|
||||
if status != OK:
|
||||
return status, None
|
||||
if not probleme:
|
||||
return OK, None
|
||||
_log(ctx.topic, f"{name}-Prüfung: {len(probleme)} Problem(e) notiert")
|
||||
await _set_step(ctx.guide_id, step, f"Passe {name} an…")
|
||||
status, fixed = await run_single_slot(
|
||||
ctx, f"{name}-Fix", key=fix_key, prompt=build_fix_prompt(probleme),
|
||||
role=fix_role, capabilities=fix_caps, payload=fix_payload, timeout=fix_timeout,
|
||||
)
|
||||
if status == CANCELLED:
|
||||
return CANCELLED, None
|
||||
if status == FAILED:
|
||||
_log(ctx.topic, f"{name}-Fix ungültig — Original bleibt")
|
||||
if on_fix_invalid:
|
||||
on_fix_invalid()
|
||||
return OK, None
|
||||
return OK, fixed
|
||||
|
||||
@@ -3,35 +3,64 @@
|
||||
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_guides, list_progress_all
|
||||
from database import list_baustein_absolviert_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"}
|
||||
FORMATE = ("MiniGuide", "Guide", "FullGuide")
|
||||
|
||||
|
||||
async def lade_lernstand() -> tuple[list[dict], dict[str, set[str]]]:
|
||||
"""Guides + kompletter Kapitel-Fortschritt in zwei Queries."""
|
||||
return await list_guides(), await list_progress_all()
|
||||
async def lade_lernstand() -> tuple[list[dict], dict[str, set[str]], dict[str, set[str]]]:
|
||||
"""Guides + Kapitel-Fortschritt + absolvierte Bausteine in drei Queries.
|
||||
|
||||
bausteine_done: topic → normalisierte Titel der Bausteine mit bestandener Prüfung.
|
||||
"""
|
||||
bausteine_done = {
|
||||
topic: {_norm_titel(b) for b in titel}
|
||||
for topic, titel in (await list_baustein_absolviert_all()).items()
|
||||
}
|
||||
return await list_guides(), await list_progress_all(), bausteine_done
|
||||
|
||||
|
||||
def _kapitel_titel(topic: str, fmt: str) -> set[str] | None:
|
||||
def _content_json(topic: str, fmt: str) -> dict | None:
|
||||
path = guide_content_path(topic, fmt)
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
chapters = json.loads(path.read_text(encoding="utf-8")).get("chapters", [])
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except ValueError:
|
||||
return None
|
||||
return {c.get("title") for c in chapters}
|
||||
|
||||
|
||||
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]:
|
||||
@@ -44,28 +73,31 @@ def _neueste_done(guides: list[dict], fmt: str) -> dict[str, dict]:
|
||||
return neueste
|
||||
|
||||
|
||||
def _guide_absolviert(g: dict, progress: dict[str, set[str]]) -> bool:
|
||||
titles = _kapitel_titel(g["topic"], g["format"])
|
||||
return bool(titles) and titles <= progress.get(g["id"], set())
|
||||
def _guide_absolviert(g: dict, progress: dict[str, set[str]], bausteine_done: dict[str, set[str]]) -> bool:
|
||||
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 <= bausteine_done.get(g["topic"], set())
|
||||
|
||||
|
||||
def ist_absolviert(topic: str, fmt: str, guides: list[dict], progress: dict[str, set[str]]) -> bool:
|
||||
"""Alle Kapitel des neuesten fertigen Guides (Thema+Format) abgehakt?"""
|
||||
def ist_absolviert(topic: str, fmt: str, guides: list[dict], progress: dict[str, set[str]], bausteine_done: dict[str, set[str]]) -> bool:
|
||||
"""Alle Bausteine des neuesten fertigen Guides (Thema+Format) per Prüfung absolviert?"""
|
||||
g = _neueste_done(guides, fmt).get(topic)
|
||||
return g is not None and _guide_absolviert(g, progress)
|
||||
return g is not None and _guide_absolviert(g, progress, bausteine_done)
|
||||
|
||||
|
||||
def formate_stats(guides: list[dict], progress: dict[str, set[str]]) -> dict:
|
||||
def formate_stats(guides: list[dict], progress: dict[str, set[str]], bausteine_done: 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_absolviert(g, progress))
|
||||
absolviert = sum(1 for g in neueste.values() if _guide_absolviert(g, progress, bausteine_done))
|
||||
formate[fmt] = {"erstellt": len(neueste), "absolviert": absolviert}
|
||||
return formate
|
||||
|
||||
|
||||
def guide_lock(topic: str, fmt: str, guides: list[dict], progress: dict[str, set[str]]) -> str | None:
|
||||
def guide_lock(topic: str, fmt: str, guides: list[dict], progress: dict[str, set[str]], bausteine_done: 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,
|
||||
@@ -79,9 +111,9 @@ def guide_lock(topic: str, fmt: str, guides: list[dict], progress: dict[str, set
|
||||
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 and not ist_absolviert(topic, vorstufe, guides, progress):
|
||||
return f"Erst den {vorstufe} dieses Themas absolvieren"
|
||||
stat = formate_stats(guides, progress).get(fmt, {"erstellt": 0, "absolviert": 0})
|
||||
if vorstufe and not ist_absolviert(topic, vorstufe, guides, progress, bausteine_done):
|
||||
return f"Erst den {vorstufe} dieses Themas absolvieren (alle Bausteine prüfen)"
|
||||
stat = formate_stats(guides, progress, bausteine_done).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)"
|
||||
|
||||
@@ -13,9 +13,12 @@ from database import (
|
||||
create_topic, list_topics as db_list_topics, delete_topic,
|
||||
list_progress, set_progress, delete_progress,
|
||||
create_element, list_elements, get_element, update_element, delete_element,
|
||||
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 bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine
|
||||
from elements import generate_element, chat_with_guide, chat_with_element, check_element, style_element, refine_suggestion
|
||||
from lernen import NOETIG, MASTERY, MEISTERN, baustein_chat, baustein_diskussion, baustein_element_anlegen, pruefung_bewertung, pruefung_frage, score_berechnen, vertiefung_generieren
|
||||
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
|
||||
@@ -28,6 +31,9 @@ from models import (
|
||||
ElementUpdateRequest, ElementCheckRequest, ElementCheckResponse, ElementStyleResponse,
|
||||
ElementRefineRequest, ElementRefineResponse,
|
||||
ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo,
|
||||
VertiefungRequest, VertiefungResponse,
|
||||
BausteinChatRequest, BausteinChatResponse,
|
||||
BausteinPruefungRequest, BausteinPruefungResponse, BausteinLernstandResponse,
|
||||
)
|
||||
from paths import bausteine_topics, guide_content_path, project_dir, topic_dir
|
||||
|
||||
@@ -53,18 +59,18 @@ async def get_topics():
|
||||
@router.get("/stats")
|
||||
async def get_stats():
|
||||
"""Tracker: Themen-Anzahl + pro Format erstellt/absolviert."""
|
||||
guides, progress = await lade_lernstand()
|
||||
guides, progress, bausteine_done = await lade_lernstand()
|
||||
themen = set(await db_list_topics()) | {g["topic"] for g in guides} | set(bausteine_topics())
|
||||
if PROJECTS_DIR.is_dir():
|
||||
themen |= {e.name for e in PROJECTS_DIR.iterdir() if e.is_dir()}
|
||||
return {"themen": len(themen), "formate": formate_stats(guides, progress)}
|
||||
return {"themen": len(themen), "formate": formate_stats(guides, progress, bausteine_done)}
|
||||
|
||||
|
||||
@router.get("/topics/fortschritt")
|
||||
async def topic_fortschritt(topic: str):
|
||||
"""Absolviert-Status pro Format — fürs Freischalten der nächsten Ausbaustufe."""
|
||||
guides, progress = await lade_lernstand()
|
||||
return {fmt: ist_absolviert(topic, fmt, guides, progress) for fmt in FORMATE}
|
||||
guides, progress, bausteine_done = await lade_lernstand()
|
||||
return {fmt: ist_absolviert(topic, fmt, guides, progress, bausteine_done) for fmt in FORMATE}
|
||||
|
||||
|
||||
@router.post("/topics")
|
||||
@@ -76,6 +82,7 @@ async def add_topic(req: TopicCreateRequest):
|
||||
@router.delete("/topics")
|
||||
async def remove_topic(topic: str):
|
||||
await delete_topic(topic)
|
||||
await delete_baustein_daten(topic)
|
||||
shutil.rmtree(topic_dir(topic), ignore_errors=True)
|
||||
return {"ok": True}
|
||||
|
||||
@@ -138,12 +145,133 @@ async def remove_bausteine(topic: str):
|
||||
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 ---
|
||||
|
||||
@router.post("/guides", response_model=GuideResponse)
|
||||
async def create(req: GuideCreateRequest):
|
||||
guides, progress = await lade_lernstand()
|
||||
grund = guide_lock(req.topic.strip(), req.format, guides, progress)
|
||||
guides, progress, bausteine_done = await lade_lernstand()
|
||||
grund = guide_lock(req.topic.strip(), req.format, guides, progress, bausteine_done)
|
||||
if grund:
|
||||
raise HTTPException(400 if grund == "Erst Bausteine erstellen" else 409, grund)
|
||||
await create_topic(req.topic.strip())
|
||||
@@ -171,8 +299,8 @@ async def list_all():
|
||||
@router.get("/guides/locks")
|
||||
async def guide_locks(topic: str):
|
||||
"""Sperr-Gründe pro Format für den ▶-Button — None = erstellbar."""
|
||||
guides, progress = await lade_lernstand()
|
||||
return {fmt: guide_lock(topic, fmt, guides, progress) for fmt in ("OnePager", *FORMATE)}
|
||||
guides, progress, bausteine_done = await lade_lernstand()
|
||||
return {fmt: guide_lock(topic, fmt, guides, progress, bausteine_done) for fmt in ("OnePager", *FORMATE)}
|
||||
|
||||
|
||||
@router.get("/guides/{guide_id}", response_model=GuideResponse)
|
||||
|
||||
@@ -42,6 +42,30 @@ def _eindeutige_titel(entries: dict[int, str]) -> dict[int, str]:
|
||||
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()}
|
||||
|
||||
|
||||
@@ -11,19 +11,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"minimax-direkt": {
|
||||
"minimax-kalt": {
|
||||
"npm": "@ai-sdk/anthropic",
|
||||
"name": "MiniMax (ohne Thinking)",
|
||||
"name": "MiniMax (kalt — niedrige Temperature, ohne Thinking)",
|
||||
"options": {
|
||||
"baseURL": "https://api.minimax.io/anthropic/v1",
|
||||
"apiKey": "{env:MINIMAX_API_KEY}"
|
||||
},
|
||||
"models": {
|
||||
"MiniMax-M3": {
|
||||
"name": "MiniMax M3 (ohne Thinking)",
|
||||
"name": "MiniMax M3 (kalt)",
|
||||
"options": {
|
||||
"temperature": 0.2,
|
||||
"thinking": { "type": "disabled" }
|
||||
}
|
||||
},
|
||||
"MiniMax-M2.7-highspeed": {
|
||||
"name": "MiniMax M2.7 highspeed (kalt)",
|
||||
"options": {
|
||||
"temperature": 0.3
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
26
frontend/package-lock.json
generated
26
frontend/package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"dompurify": "^3.4.7",
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.17.0",
|
||||
"marked": "^18.0.4",
|
||||
"marked-highlight": "^2.2.4",
|
||||
"vue": "^3.5.32"
|
||||
@@ -1187,6 +1188,15 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
|
||||
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -1468,6 +1478,22 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.17.0.tgz",
|
||||
"integrity": "sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==",
|
||||
"funding": [
|
||||
"https://opencollective.com/katex",
|
||||
"https://github.com/sponsors/katex"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^8.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"katex": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/kolorist": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"dompurify": "^3.4.7",
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.17.0",
|
||||
"marked": "^18.0.4",
|
||||
"marked-highlight": "^2.2.4",
|
||||
"vue": "^3.5.32"
|
||||
|
||||
@@ -393,7 +393,6 @@ onMounted(async () => {
|
||||
:provider="provider"
|
||||
:elementsOpen="elementsOpen"
|
||||
@progressChanged="loadStats(); loadBausteine()"
|
||||
@openElements="elementsOpen = true"
|
||||
/>
|
||||
<div v-else class="empty-main">
|
||||
<p>Thema in der Sidebar anlegen oder auswählen.</p>
|
||||
|
||||
@@ -59,6 +59,50 @@ export async function deleteBausteine(topic) {
|
||||
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) {
|
||||
const res = await fetch(`${BASE}/topics/fortschritt?topic=${encodeURIComponent(topic)}`)
|
||||
return res.json()
|
||||
@@ -114,20 +158,6 @@ export async function deleteTopic(name) {
|
||||
await fetch(`${BASE}/topics?topic=${encodeURIComponent(name)}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function fetchProgress(id) {
|
||||
const res = await fetch(`${BASE}/guides/${id}/progress`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function setProgress(id, chapter, done) {
|
||||
const res = await fetch(`${BASE}/guides/${id}/progress`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chapter, done }),
|
||||
})
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function chatGuide(id, { section, outline, messages, provider = 'claude' }) {
|
||||
const res = await fetch(`${BASE}/guides/${id}/chat`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
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;
|
||||
}
|
||||
|
||||
402
frontend/src/components/BausteinPanel.vue
Normal file
402
frontend/src/components/BausteinPanel.vue
Normal file
@@ -0,0 +1,402 @@
|
||||
<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 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
|
||||
}
|
||||
}
|
||||
|
||||
function frageAnfordern() {
|
||||
if (pruefLoading.value) return
|
||||
pruefSenden({ aktion: 'frage' }, (res) => {
|
||||
aktuelleFrage.value = res.frage
|
||||
letztesFeedback.value = ''
|
||||
scoreVorFrage.value = res.gute_antworten // Basis für (Re-)Bewertung dieser Frage
|
||||
pruefMessages.value.push({ role: 'assistant', kind: 'frage', content: res.frage })
|
||||
pruefPhase.value = 'frage_offen'
|
||||
})
|
||||
}
|
||||
|
||||
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="frageAnfordern">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>
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { fetchGuideContent, chatGuide, fetchProgress, setProgress } from '../api.js'
|
||||
import { fetchGuideContent, chatGuide, fetchBausteinLernstand } from '../api.js'
|
||||
import { renderMarkdown } from '../markdown.js'
|
||||
import { useChat } from '../composables/useChat.js'
|
||||
import BausteinPanel from './BausteinPanel.vue'
|
||||
|
||||
const props = defineProps({
|
||||
previewGuide: { type: Object, default: null },
|
||||
@@ -11,7 +12,7 @@ const props = defineProps({
|
||||
elementsOpen: { type: Boolean, default: false }, // Element-Sidebar offen → Chat nach links
|
||||
})
|
||||
|
||||
const emit = defineEmits(['progressChanged', 'openElements'])
|
||||
const emit = defineEmits(['progressChanged'])
|
||||
|
||||
const isOnePager = computed(() => props.previewGuide?.format === 'OnePager')
|
||||
|
||||
@@ -21,7 +22,6 @@ const CH_COLORS = ['#3b82f6', '#8b5cf6', '#14b8a6', '#f59e0b', '#22c55e', '#6366
|
||||
// --- Inhalt laden ---
|
||||
const content = ref(null)
|
||||
const loadError = ref(null)
|
||||
const doneChapters = ref(new Set())
|
||||
const scrollEl = ref(null)
|
||||
|
||||
watch(() => props.previewGuide?.id, loadContent, { immediate: true })
|
||||
@@ -29,7 +29,7 @@ watch(() => props.previewGuide?.id, loadContent, { immediate: true })
|
||||
async function loadContent() {
|
||||
content.value = null
|
||||
loadError.value = null
|
||||
doneChapters.value = new Set()
|
||||
lernstand.value = {}
|
||||
const g = props.previewGuide
|
||||
if (!g || g.status !== 'done') return
|
||||
try {
|
||||
@@ -39,40 +39,33 @@ async function loadContent() {
|
||||
loadError.value = 'Inhalt nicht verfügbar — die Datei fehlt. Guide neu generieren (▶).'
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await fetchProgress(g.id)
|
||||
doneChapters.value = new Set(res.chapters || [])
|
||||
} 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
|
||||
if (g.format !== 'OnePager') {
|
||||
try {
|
||||
lernstand.value = (await fetchBausteinLernstand(g.topic)).bausteine || {}
|
||||
} catch { /* offline → leer */ }
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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
|
||||
}
|
||||
|
||||
// Tier 2 (Score 3→10) frei, sobald ALLE Bausteine absolviert; Tier 3 (Meisterpfad 10→25) frei, sobald ALLE verstanden.
|
||||
const guideSections = computed(() => (content.value?.chapters || []).flatMap((ch) => ch.sections))
|
||||
const guideAbsolviert = computed(() => {
|
||||
const secs = guideSections.value
|
||||
return secs.length > 0 && secs.every((s) => lernstand.value[s.title]?.absolviert)
|
||||
})
|
||||
const guideVerstanden = computed(() => {
|
||||
const secs = guideSections.value
|
||||
return secs.length > 0 && secs.every((s) => lernstand.value[s.title]?.verstanden)
|
||||
})
|
||||
|
||||
// --- Chat (Mechanik in useChat; Kontext-Extraktion bleibt hier) ---
|
||||
const chat = useChat((msgs) => {
|
||||
const { section, outline } = extractContext()
|
||||
@@ -80,7 +73,7 @@ const chat = useChat((msgs) => {
|
||||
section, outline, messages: msgs, provider: props.provider,
|
||||
})
|
||||
})
|
||||
const { messages, input, loading, messagesEl, inputEl, send } = chat
|
||||
const { messages, input, loading, messagesEl, inputEl, onScroll, send } = chat
|
||||
const autoGrow = () => chat.autoGrow()
|
||||
const chatOpen = ref(false)
|
||||
const panelEl = ref(null)
|
||||
@@ -164,7 +157,6 @@ function extractContext() {
|
||||
v-for="(ch, ci) in content.chapters"
|
||||
:key="ch.title"
|
||||
class="chapter"
|
||||
:class="{ 'ch-complete': doneChapters.has(ch.title) }"
|
||||
:style="{ '--ch-accent': CH_COLORS[ci % CH_COLORS.length] }"
|
||||
>
|
||||
<h2 class="chapter-title"><span class="ch-num">{{ ci + 1 }}</span>{{ ch.title }}</h2>
|
||||
@@ -172,19 +164,29 @@ function extractContext() {
|
||||
<article
|
||||
v-for="s in ch.sections"
|
||||
: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"
|
||||
>
|
||||
<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>
|
||||
<BausteinPanel
|
||||
v-if="!isOnePager"
|
||||
:topic="previewGuide.topic"
|
||||
:baustein="s.title"
|
||||
:section="s.md"
|
||||
:provider="provider"
|
||||
:status="lernstand[s.title]"
|
||||
:tier2="guideAbsolviert"
|
||||
:tier3="guideVerstanden"
|
||||
@status-changed="(st) => onBausteinStatus(s.title, st)"
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
<button
|
||||
v-if="!isOnePager"
|
||||
class="ch-toggle"
|
||||
:class="{ 'is-done': doneChapters.has(ch.title) }"
|
||||
@click="toggleChapter(ch.title)"
|
||||
>{{ doneChapters.has(ch.title) ? '✓ Erledigt – rückgängig' : 'Kapitel als erledigt markieren' }}</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -198,14 +200,13 @@ function extractContext() {
|
||||
</div>
|
||||
|
||||
<button v-if="previewGuide && !chatOpen" class="chat-fab" :class="{ shifted: elementsOpen }" title="Fragen zum Guide" @click="openChat">💬</button>
|
||||
<button v-if="previewGuide && !chatOpen && !elementsOpen" class="chat-fab elements-fab" title="Elemente öffnen" @click="emit('openElements')">🗂</button>
|
||||
|
||||
<div v-if="previewGuide && chatOpen" ref="panelEl" class="chat-panel" :class="{ shifted: elementsOpen }">
|
||||
<header class="chat-header">
|
||||
<span>Fragen zum Guide</span>
|
||||
<button class="chat-close" title="Chat beenden" @click="closeChat">×</button>
|
||||
</header>
|
||||
<div ref="messagesEl" class="chat-messages">
|
||||
<div ref="messagesEl" class="chat-messages" @scroll="onScroll">
|
||||
<p v-if="!messages.length" class="chat-hint">Stell eine Frage zum aktuellen Abschnitt.</p>
|
||||
<template v-for="(m, i) in messages" :key="i">
|
||||
<div v-if="m.role === 'assistant'" class="chat-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
|
||||
@@ -310,10 +311,6 @@ function extractContext() {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chapter.ch-complete .sections {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
@@ -322,6 +319,51 @@ function extractContext() {
|
||||
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) */
|
||||
.guide-content:not(.onepager) .section-card {
|
||||
border-top: 3px solid color-mix(in srgb, var(--ch-accent, var(--accent)) 65%, transparent);
|
||||
@@ -438,35 +480,6 @@ function extractContext() {
|
||||
}
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -533,10 +546,6 @@ function extractContext() {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.elements-fab {
|
||||
right: 5.25rem;
|
||||
}
|
||||
|
||||
/* Element-Sidebar (320px) offen → Chat links daneben anzeigen */
|
||||
.chat-fab.shifted {
|
||||
right: calc(1.5rem + 320px);
|
||||
|
||||
@@ -29,7 +29,7 @@ function providerAvailable(id) {
|
||||
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
|
||||
const trackerItems = computed(() => {
|
||||
@@ -93,8 +93,8 @@ function guideStatus(format) {
|
||||
}
|
||||
|
||||
// Schritt-Kugeln der Guide-Pipelines
|
||||
const GUIDE_STEPS = ['Auswahl', 'Auswahl-Prüfung', 'Gliederung', 'Gliederungs-Prüfung', 'Schreiben', 'Lese-Prüfung']
|
||||
const ONEPAGER_STEPS = ['Recherche', 'Recherche-Prüfung', 'Bauen', 'Prüfung']
|
||||
const GUIDE_STEPS = ['Auswahl', 'Gliederung', 'Schreiben', 'Lese-Prüfung']
|
||||
const ONEPAGER_STEPS = ['Recherche', 'Bauen', 'Prüfung']
|
||||
|
||||
// Kugeln werden wie bei den Bausteinen immer angezeigt:
|
||||
// fertig = alle grün, laufend = live, abgebrochen = Teilfortschritt, sonst grau
|
||||
@@ -102,7 +102,8 @@ function guideSteps(format) {
|
||||
const labels = format === 'OnePager' ? ONEPAGER_STEPS : GUIDE_STEPS
|
||||
const st = guideStatus(format)
|
||||
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) => ({
|
||||
label,
|
||||
state: i < step ? 'done' : i === step ? 'active' : 'pending',
|
||||
@@ -112,7 +113,7 @@ function guideSteps(format) {
|
||||
return labels.map((label) => ({ label, state: 'done' }))
|
||||
}
|
||||
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) => ({ label, state: 'pending' }))
|
||||
|
||||
@@ -11,7 +11,7 @@ const props = defineProps({
|
||||
const emit = defineEmits(['changes'])
|
||||
|
||||
const chat = useChat((msgs) => chatElement(props.element.id, msgs, props.provider))
|
||||
const { messages, input, loading, messagesEl, inputEl } = chat
|
||||
const { messages, input, loading, messagesEl, inputEl, onScroll } = chat
|
||||
|
||||
// Anderes Element gewählt → Verlauf verwerfen
|
||||
watch(() => props.element.id, () => chat.reset())
|
||||
@@ -24,7 +24,7 @@ async function send() {
|
||||
|
||||
<template>
|
||||
<div class="el-chat">
|
||||
<div ref="messagesEl" class="chat-messages">
|
||||
<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>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
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.
|
||||
@@ -9,11 +14,17 @@ export function useChat(performRequest) {
|
||||
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) messagesEl.value.scrollTop = messagesEl.value.scrollHeight
|
||||
if (messagesEl.value && stick.value) messagesEl.value.scrollTop = messagesEl.value.scrollHeight
|
||||
}
|
||||
|
||||
function autoGrow(max = 140) {
|
||||
@@ -43,6 +54,7 @@ export function useChat(performRequest) {
|
||||
}
|
||||
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 = ''
|
||||
@@ -52,7 +64,13 @@ export function useChat(performRequest) {
|
||||
try {
|
||||
const res = await performRequest(messages.value)
|
||||
if (current !== run) return null
|
||||
messages.value.push({ role: 'assistant', content: res.reply || '…' })
|
||||
// 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
|
||||
@@ -67,5 +85,5 @@ export function useChat(performRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
return { messages, input, loading, messagesEl, inputEl, send, cancel, reset, scrollToBottom, autoGrow }
|
||||
return { messages, input, loading, messagesEl, inputEl, stick, onScroll, send, cancel, reset, scrollToBottom, autoGrow }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { marked } from 'marked'
|
||||
import { markedHighlight } from 'marked-highlight'
|
||||
import hljs from 'highlight.js'
|
||||
import 'highlight.js/styles/github-dark.css'
|
||||
import katex from 'katex'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
marked.use(markedHighlight({
|
||||
@@ -15,6 +17,40 @@ marked.use(markedHighlight({
|
||||
}))
|
||||
marked.setOptions({ breaks: true, gfm: true })
|
||||
|
||||
// LaTeX-Mathe via KaTeX. Eigene marked-Extensions (statt marked-katex-extension,
|
||||
// die marked v18 hinterherhinkt). marked tokenisiert Code zuerst → $…$ in Code-
|
||||
// Blöcken wird NICHT als Mathe erkannt. throwOnError:false zeigt defektes TeX rot.
|
||||
function renderTex(tex, displayMode) {
|
||||
return katex.renderToString(tex, { displayMode, throwOnError: false, output: 'html' })
|
||||
}
|
||||
|
||||
const blockMath = {
|
||||
name: 'blockMath',
|
||||
level: 'block',
|
||||
start(src) { const i = src.indexOf('$$'); return i < 0 ? undefined : i },
|
||||
tokenizer(src) {
|
||||
const m = /^\$\$([\s\S]+?)\$\$/.exec(src)
|
||||
if (m) return { type: 'blockMath', raw: m[0], text: m[1].trim() }
|
||||
},
|
||||
renderer(token) { return renderTex(token.text, true) },
|
||||
}
|
||||
|
||||
const inlineMath = {
|
||||
name: 'inlineMath',
|
||||
level: 'inline',
|
||||
start(src) { const i = src.indexOf('$'); return i < 0 ? undefined : i },
|
||||
tokenizer(src) {
|
||||
// $…$: kein $$, kein Leerzeichen direkt hinter dem öffnenden $ und vor dem
|
||||
// schließenden $ (pandoc-Stil) → mindert Kollisionen mit Fließtext-Dollarzeichen.
|
||||
const m = /^\$(?![\s$])((?:\\\$|[^$])+?)\$/.exec(src)
|
||||
if (!m || /\s$/.test(m[1])) return
|
||||
return { type: 'inlineMath', raw: m[0], text: m[1].trim() }
|
||||
},
|
||||
renderer(token) { return renderTex(token.text, false) },
|
||||
}
|
||||
|
||||
marked.use({ extensions: [blockMath, inlineMath] })
|
||||
|
||||
// Rohes HTML im Markdown (z. B. <p>, <img> ohne Backticks aus Agenten-Output)
|
||||
// als Text anzeigen statt rendern — sonst verschluckt der Browser den Inhalt.
|
||||
marked.use({
|
||||
|
||||
@@ -1,44 +1,51 @@
|
||||
SECTION-AUFBAU
|
||||
|
||||
Jeder Baustein wird GENAU eine Section mit:
|
||||
1. Titel — der Baustein-Titel (kommt aus dem Marker, nicht in den Body schreiben)
|
||||
2. Beschreibung — was es ist und wozu: MAXIMAL 1–2 Sätze
|
||||
3. Beispiele — KURZ und SIMPEL: das Minimalbeispiel im themengerechten Format (siehe BEISPIELFORMAT), keine Realwelt-Komplexität. Höchstens 1 knapper Satz Einordnung dazu. Ein Beispiel pro relevanter Variante: simple Bausteine eines, variantenreiche mehrere. Geordnet vom Üblichen zum Speziellen. Weglassen, wenn ohne Mehrwert.
|
||||
Jeder Baustein ist ein kleiner, eigenständiger Lern-Guide: er stellt EIN Konzept vor, erklärt es von Grund auf und macht es nutzbar. Der Leser bringt KEIN Vorwissen mit — du holst ihn ab und bringst ihm die Sache bei. Eine Section ist kein Stichwort-Zettel zum Nachschlagen.
|
||||
|
||||
Aufbau je Baustein — drei Beats, fließend ineinander, OHNE Zwischenüberschriften:
|
||||
1. Einordnung — 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): ein Mini-Szenario in 2–4 Sätzen (Situation → Anwendung → Wirkung), ein Schema oder eine Formel.
|
||||
- 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.
|
||||
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:**).
|
||||
Mehrere Beispiele benennen ihre Variante: in Code als Kommentar in der Code-Syntax (z. B. `<!-- Einzelner Absatz -->`, `// Mit Default-Wert`), in Prosa als vorangestelltes fettes Label (z. B. **Höfliche Bitte:**). Bei nur einem Beispiel ist kein Label nötig.
|
||||
|
||||
Jede Section ist ATOMAR: allein verständlich, ohne dass der Leser eine andere Section gelesen hat. Test: Ergibt der Text Sinn, wenn man NUR diese Section liest? Verweise auf andere Bausteine sind erlaubt, ihr Inhalt darf aber nie vorausgesetzt werden — benutzte Begriffe in einem Halbsatz auflösen.
|
||||
|
||||
Umfang: kurz. Die Länge einer Section kommt aus der ZAHL der Beispiele (Varianten), nie aus langen Texten.
|
||||
Tonalität: klares, direktes Deutsch. Du erklärst, du referierst nicht. Praxisorientiert, ohne Füllsätze.
|
||||
|
||||
Tonalität: klares Deutsch, direkt, praxisorientiert. Fachbegriffe beim ersten Auftreten kurz erklären. Keine Füllsätze, keine Einleitungsfloskeln.
|
||||
Markdown im Section-Body: erklärende Absätze in normalem Text, `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 NUR für Code-Beispiele — Beispielsätze, Dialoge und Szenarien als normaler Text, NIE in einen Codeblock zwingen. **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 (Code-Thema, nur der Body):
|
||||
|
||||
Arrays speichern mehrere Werte unter einem Namen. PHP unterscheidet indizierte Arrays (`[0 => 'a']`) und assoziative Arrays (`['key' => 'wert']`) — intern sind beide geordnete Hashmaps.
|
||||
Arrays lösen ein simples Problem: Du willst viele Werte unter einem Namen halten, statt für jeden eine eigene Variable. In PHP gibt es zwei Sorten. Indizierte Arrays nummerieren die Werte durch (`[0 => 'a']`). Assoziative Arrays geben jedem Wert einen eigenen Schlüssel (`['key' => 'wert']`) — praktisch, wenn die Position egal ist, der Name aber zählt. Intern sind beide dasselbe: geordnete Hashmaps.
|
||||
|
||||
### Beispiel
|
||||
```php
|
||||
$preise = ['apfel' => 1.20, 'birne' => 1.50];
|
||||
$preise['kirsche'] = 3.90; // ergänzen
|
||||
echo $preise['apfel']; // 1.2
|
||||
$preise['kirsche'] = 3.90; // neuen Schlüssel ergänzen
|
||||
echo $preise['apfel']; // 1.2 — Zugriff über den Namen
|
||||
```
|
||||
Assoziative Arrays sind der Arbeitsalltag: Datenbankzeilen, Konfiguration, JSON.
|
||||
So sieht der Alltag aus: Datenbankzeilen, Konfiguration, JSON landen fast immer in assoziativen Arrays.
|
||||
|
||||
Beispiel einer fertigen Section (Konzept-Thema, nur der Body):
|
||||
|
||||
Paraphrasieren wiederholt die Aussage des Gegenübers in eigenen Worten, um Verständnis zu prüfen und Eskalation zu bremsen.
|
||||
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
|
||||
**Vorwurf abfedern:**
|
||||
A: „Nie hältst du dich an Absprachen!"
|
||||
B: „Du bist sauer, weil ich den Termin gestern verschoben habe — richtig?"
|
||||
Die Paraphrase bestätigt nicht den Vorwurf, sondern prüft die Botschaft dahinter.
|
||||
B übernimmt nicht das Wort „nie", sondern benennt das konkrete Anliegen. Das öffnet das Gespräch, statt es zu eskalieren.
|
||||
|
||||
29
templates/Prompt/Baustein-Bewertung-Kritik.md
Normal file
29
templates/Prompt/Baustein-Bewertung-Kritik.md
Normal file
@@ -0,0 +1,29 @@
|
||||
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".
|
||||
- Material-Grenze: Die Bewertung darf NICHTS verlangen, was nicht aus Guide/Vertiefung folgt — kein Detail, keine Technik, kein Begriff außerhalb des Materials. Wurde der Lerner für solchen Stoff abgewertet → Fehlurteil.
|
||||
- Zeigt der Lerner zu Recht, dass etwas nicht im Material steht, darf das NICHT als Fehler zählen.
|
||||
- Konsistenz: Die Bewertung darf der Guide-Fassung und der Vertiefung nicht widersprechen, behauptet nichts Erfundenes.
|
||||
- Hat der Lerner mit Material-Bezug recht, muss die Bewertung "gut" sein — auch gegen die Frage-Annahme.
|
||||
|
||||
Beanstande NUR echte Fehlurteile. Ist die Bewertung fair 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"]}}
|
||||
39
templates/Prompt/Baustein-Bewertung.md
Normal file
39
templates/Prompt/Baustein-Bewertung.md
Normal file
@@ -0,0 +1,39 @@
|
||||
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" = die Antwort trifft den Kern der Frage und ist MINDESTENS zur Hälfte korrekt. Eine knappe, richtige Antwort reicht.
|
||||
- "schlecht" = weniger als die Hälfte richtig, oder klar falsch.
|
||||
- Die Hälfte misst sich an dem, was Guide und Vertiefung hergeben — NICHT an einer idealen Vollantwort.
|
||||
|
||||
MATERIAL-GRENZE — WICHTIG:
|
||||
- Guide-Fassung und Vertiefung sind die einzige fachliche Referenz. Bewerte AUSSCHLIESSLICH gegen sie.
|
||||
- Verlange NICHTS, was nicht aus dem Material folgt. Kein Detail, keine Technik, kein Begriff, der dort nicht steht.
|
||||
- Zeigt der Lerner zu Recht, dass etwas nicht im Material steht: das ist KEIN Fehler — nicht dafür abwerten.
|
||||
- Behaupte nichts, was nicht aus dem Material folgt. Erfinde keine Zusatzannahmen.
|
||||
- Hat der Lerner mit Material-Bezug SACHLICH recht (auch gegen deine Annahme): gib es zu und werte "gut" — 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.
|
||||
- `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? Die Zusammenfassung mehrerer Mikro-Einträge zu einer Lerneinheit ist KEIN Verlust — fehlend ist ein Konzept nur, wenn es nirgends, auch nicht innerhalb eines zusammengefassten Bausteins, enthalten ist.
|
||||
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,16 +1,19 @@
|
||||
Drei Recherche-Agenten haben unabhängig voneinander die Bausteine des Themas "{topic}" ermittelt. Konsolidiere ihre Ergebnisse zur endgültigen Baustein-Liste.
|
||||
Bei der Auswahl der Bausteine zum Thema "{topic}" sind einige Einträge strittig. Entscheide für jeden strittigen Eintrag: aufnehmen oder verwerfen.
|
||||
|
||||
{results}
|
||||
BESCHLOSSENER KONSENS (nur Kontext — nicht ändern):
|
||||
{konsens}
|
||||
|
||||
STRITTIGE EINTRÄGE:
|
||||
{rest}
|
||||
|
||||
Regeln:
|
||||
- Vereinige die Listen: erkenne gleiche Konzepte unter verschiedenen Titeln und führe sie zu einem Baustein zusammen.
|
||||
- Ein Baustein löst GENAU EIN PROBLEM. Einträge, die Varianten derselben Lösung sind, werden zu EINEM Baustein zusammengefasst (richtig: ein Baustein `<input>` für alle Typen, 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.
|
||||
- Verwirf Bausteine, die erfunden wirken. Eine fehlende Quelle allein ist kein Streichgrund, wenn mindestens zwei Recherchen den Baustein unabhängig nennen. Behalte im Zweifel, was mindestens eine Recherche belegt.
|
||||
- KEINE Kategorien, KEINE Bewertung — eine flache, durchnummerierte Liste.
|
||||
- Lass die Quellen weg. Titel und Kurzbeschreibung (max. ~12 Wörter) auf DEUTSCH (Code-Bezeichner bleiben original). Jeder Titel muss EINDEUTIG sein.
|
||||
- AUFNEHMEN, wenn der Eintrag eine eigenständige Lerneinheit des Themas ist, die der Konsens nicht abdeckt — auch nicht unter anderem Titel oder innerhalb eines Sammeleintrags.
|
||||
- VERWERFEN, wenn er eine Dublette zum Konsens ist, nur eine Variante/Vertiefung eines vorhandenen Bausteins, Mikro-Granularität oder fachlich zweifelhaft.
|
||||
- Übernimm aufgenommene Einträge wörtlich ("Titel — Kurzbeschreibung"), nicht umformulieren.
|
||||
- Nichts Aufnehmenswertes dabei → leere Liste. Das ist ein gültiges Ergebnis.
|
||||
|
||||
Schreibe NUR die Markdown-Datei nach: {out_path}
|
||||
Schreibe NUR die JSON-Datei nach: {out_path}
|
||||
|
||||
Format: GENAU eine Zeile pro Baustein: `N. Titel — Kurzbeschreibung`. Kein weiterer Text in der Datei.
|
||||
Format (kein weiterer Text in der Datei):
|
||||
{{"uebernehmen": ["Titel — Kurzbeschreibung"]}}
|
||||
Nichts aufnehmen: {{"uebernehmen": []}}
|
||||
|
||||
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,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"]}}
|
||||
@@ -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,7 +8,7 @@ SECTIONS:
|
||||
{sections}
|
||||
|
||||
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, 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)?
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ 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:
|
||||
|
||||
<!-- section: Exakter Section-Titel -->
|
||||
Beschreibung…
|
||||
Erklärung (Einordnung → Wie/Warum) laut SECTION-SPEZIFIKATION…
|
||||
|
||||
### Beispiel
|
||||
(Beispiel im themengerechten Format laut SECTION-SPEZIFIKATION: Codeblock NUR bei Code-Themen, sonst Beispielsätze oder Mini-Szenario)
|
||||
|
||||
@@ -14,7 +14,7 @@ Schreibe NUR die Datei {out_path} in GENAU diesem Format — pro Kapitel ein kap
|
||||
|
||||
<!-- kapitel: Kapiteltitel -->
|
||||
<!-- section: Exakter Baustein-Titel -->
|
||||
Beschreibung…
|
||||
Erklärung (Einordnung → Wie/Warum) laut SECTION-SPEZIFIKATION…
|
||||
|
||||
### Beispiel
|
||||
(Beispiel im themengerechten Format laut SECTION-SPEZIFIKATION: Codeblock NUR bei Code-Themen, sonst Beispielsätze oder Mini-Szenario)
|
||||
|
||||
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}
|
||||
@@ -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. Eckdaten (Art/Typ; bei Software: Version, Lizenz/Kosten; bei Sprachen, Methoden, Theorien: Ursprung/Urheber, heutiger Stand, Anwendungsfelder)
|
||||
3. Inhaltsübersicht (was einen im Thema erwartet)
|
||||
4. Ein typisches Beispiel im themengerechten Format (Code, Beispielsätze oder Mini-Szenario)
|
||||
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, Zahlen, Versionen bzw. Urheber/Jahreszahlen — 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}
|
||||
Reference in New Issue
Block a user