This commit is contained in:
team3
2026-07-13 12:31:25 +02:00
parent 32d7ad9ea1
commit 9cd8e02e22
34 changed files with 747 additions and 606 deletions

View File

@@ -24,9 +24,10 @@ E1 Inventar Abschnitte (~12k Zeichen) → 2 Reader je Abschnitt → Atome mit
E2 Artefakte je Soll-Punkt-Gruppe: Flashcards + 1 typisiertes Beispiel pro Atom
(Form: text|mathe|code|tabelle, beste Passung) → Form-Gate
(Syntax/Struktur parse-only, kein Ausführen) → Verify-Panel gegen Anker → Fix
E3 Struktur Lernziele+Zuordnung aus Atomen (je Soll-Punkt) → Ziel-Check → Kanten auflösen
Zyklen brechen (Judge) → Bausteine = Ziel-Gruppen im Band (split/merge)
topologische Ordnung (Bausteine + Atome darin)
E3 Struktur Lernziele+Zuordnung aus Atomen (je Soll-Punkt) → Ziel-Check
→ Bausteine = Ziel-Gruppen im Band (split/merge)
Ordnung per Judge je Level (Quellpositions-Median als Prior,
Permutations-Gate, Fallback = Quellreihenfolge mit Befund)
E4 Guide je Baustein: Writer (Facts inline, Atom-Marker; Ausgabe DELIMITED, nicht JSON
— LaTeX-Backslashes) → det. Checks → Prüfer → Fix (Fix-Cap gegen Churn).
Beispiel-Marker setzt der Code DETERMINISTISCH (nicht der Writer);
@@ -45,7 +46,7 @@ Stillstand oder 10 Iterationen. Erst dann startet die nächste Ebene.
- `soll(id, topic, punkt, status kandidat|bestaetigt, belege JSON [{quelle,zitat}])`
- `atome(id, topic, titel, typ, definition, level, status, soll_id, ziel_id, baustein_id, ord, merged_into)`
- `anker(atom_id, quelle_id, start, ende, zitat)`
- `kanten(topic, von_atom, zu_atom, art braucht|verwandt, status)`
- `kanten(topic, von_atom, zu_atom, art verwandt, status)` — Dedup-Gedächtnis
- `artefakte(id, atom_id, typ flashcard|beispiel, inhalt JSON, status kandidat|verifiziert|verworfen)`
— beispiel-`inhalt` trägt `form` + formspezifische Felder (text|code+sprache|tabelle);
code/tabelle kommen verbatim via `<!-- beispiel: id -->` in den Guide (Assembly-Swap)

View File

@@ -34,6 +34,7 @@ _api_sem = asyncio.Semaphore(MAX_CONCURRENT_API_AGENTS)
_active: dict[str, float] = {} # key → Startzeit (Anzeige)
_prozesse: dict[str, asyncio.subprocess.Process] = {}
_abgebrochen: set[str] = set() # Key-Präfixe abgebrochener Läufe
_pausiert: set[str] = set() # Key-Präfixe sanft pausierter Läufe
# ── Globale 429-Bremse: Backoff pro Call reicht nicht — 28 Parallel-Calls
# kollidieren nach der Wartezeit sofort wieder. Ein 429 drosselt deshalb ALLE:
@@ -108,14 +109,25 @@ def abbrechen(prefix: str) -> None:
_kill(p)
def pausieren(prefix: str) -> None:
"""Sanfte Pause: Wartende + Neue liefern sofort „pausiert", Laufende laufen
aus und ihre Ergebnisse landen noch in der DB (Gegenstück zu abbrechen)."""
_pausiert.add(prefix)
def abbruch_aufheben(prefix: str) -> None:
_abgebrochen.discard(prefix)
_pausiert.discard(prefix)
def _ist_abgebrochen(key: str) -> bool:
return any(key.startswith(p) for p in _abgebrochen)
def _ist_pausiert(key: str) -> bool:
return any(key.startswith(p) for p in _pausiert)
def provider_verfuegbar(provider: str) -> bool:
cfg = PROVIDERS.get(provider)
if not cfg:
@@ -136,6 +148,8 @@ async def run_agent(key: str, prompt: str, timeout: int, *, provider: str,
role: str = "quick", capabilities: str = "none") -> AgentErgebnis:
"""Ein Call. capabilities: none (nur Text) | files (Read/Write/Bash) | full (+Web).
Wirft asyncio.TimeoutError bei Timeout (Infra-Behandlung macht llm.py)."""
if _ist_pausiert(key): # VOR dem Fake-Shortcut — sonst ist Pause untestbar
return AgentErgebnis(1, "", "pausiert")
if os.getenv("CREATOR_FAKE_AGENTS"):
import fake_agents
return await fake_agents.antwort(key, prompt, capabilities)
@@ -154,6 +168,8 @@ async def run_agent(key: str, prompt: str, timeout: int, *, provider: str,
global _api_inflight
warte_start = time.time() # Queue-Zeit getrennt ausweisen (Ledger wait_ms)
async with sem:
if _ist_pausiert(key): # wartete im Semaphor, als die Pause kam
return AgentErgebnis(1, "", "pausiert")
if _ist_abgebrochen(key):
return AgentErgebnis(1, "", "abgebrochen")
_active[key] = time.time()

View File

@@ -74,7 +74,9 @@ SOLL_PUNKTE_PER_SQRT = 1.0
SOLL_PUNKTE_MIN = 5
# ── Ebene 1: Inventar ─────────────────────────────────────────────────────────
ABSCHNITT_CHARS = 12_000 # lost-in-the-middle-Guard
ABSCHNITT_CHARS = 6_000 # lost-in-the-middle-Guard; 12k riss bei dichten
# tex-Quellen das 32k-Output-Cap (aak: ø 11.7k, Spitzen
# 32k → 5-min-Calls, Hedge-Zwillinge, Halbier-Kaskade)
READER_JE_ABSCHNITT = 2 # unabhängige Reader (Konsens entsteht über Dedup, nicht Union)
ANKER_OVERLAP_MERGE = 0.5 # Span-Überlappung ab der zwei Atome automatisch mergen
# Fuzzy-Anker-Stufe 4 (Lektion 83, Hypothes.is-Muster exakt→locker→fuzzy):
@@ -84,9 +86,6 @@ MERGE_PANEL = 2 # Judges pro Merge-Kandidat — Merge nur einstimmi
MERGE_KANDIDAT_COS = 0.75 # Definitions-Ähnlichkeit ab der ein Paar zum Panel geht
MERGE_KANDIDAT_JACCARD = 0.3 # Schwelle im lexikalischen Fallback (misst viel niedriger als Kosinus)
LUECKEN_RUNDEN_MAX = 2 # gezielte Nachextraktion pro Soll-Lücke
# braucht-Titel, die norm/kern nicht auflösen (Kompositum↔Expansion misst ~0.53 Cosinus,
# Embedding schlägt sie NIE vor — Lektion 28): Substring-Prefilter → 1 Judge (n=1, reversibel).
BRAUCHT_KANDIDATEN = 8 # Kandidaten-Cap je unaufgelöstem braucht-Titel (Lektion 34)
# ── Ebene 2: Artefakte ────────────────────────────────────────────────────────
BEISPIEL_FORMEN = ("text", "mathe", "code", "tabelle") # Worked-Example-Formen
@@ -131,7 +130,6 @@ TIMEOUTS = {
"soll_konsens": (300, 2),
"extraktion": (450, 0),
"merge": (150, 8),
"braucht": (200, 0),
"soll_zuordnung": (300, 8),
"luecke": (450, 0),
"artefakt": (450, 20),
@@ -142,7 +140,7 @@ TIMEOUTS = {
"ziele": (300, 6),
"level": (200, 4),
"kapitel": (200, 2),
"zyklus": (150, 0),
"ordnung": (300, 2),
"writer": (450, 60),
"pruefer": (600, 5),
"pruefer_urteil": (300, 5),

View File

@@ -72,7 +72,7 @@ CREATE TABLE IF NOT EXISTS atome(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, titel TEXT NOT NULL, typ TEXT NOT NULL DEFAULT '',
definition TEXT NOT NULL DEFAULT '', level TEXT NOT NULL DEFAULT 'M',
status TEXT NOT NULL DEFAULT 'neu', soll_id INTEGER, ziel_id INTEGER, baustein_id INTEGER,
ord INTEGER DEFAULT 0, merged_into INTEGER, braucht TEXT NOT NULL DEFAULT '[]');
ord INTEGER DEFAULT 0, merged_into INTEGER);
CREATE INDEX IF NOT EXISTS idx_atome_topic ON atome(topic);
CREATE TABLE IF NOT EXISTS anker(
id INTEGER PRIMARY KEY, atom_id INTEGER NOT NULL, quelle_id INTEGER NOT NULL,
@@ -93,7 +93,7 @@ CREATE TABLE IF NOT EXISTS lernziele(
CREATE TABLE IF NOT EXISTS bausteine(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, ziel_id INTEGER NOT NULL, titel TEXT NOT NULL,
ord INTEGER DEFAULT 0, status TEXT NOT NULL DEFAULT 'neu', kapitel_id INTEGER,
level TEXT NOT NULL DEFAULT 'M');
level TEXT NOT NULL DEFAULT 'M', ordnung TEXT NOT NULL DEFAULT 'judge');
CREATE TABLE IF NOT EXISTS kapitel(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, titel TEXT NOT NULL,
intro TEXT NOT NULL DEFAULT '', ord INTEGER DEFAULT 0,
@@ -143,11 +143,16 @@ def _init_schema(con: sqlite3.Connection) -> None:
"ALTER TABLE kapitel ADD COLUMN level TEXT NOT NULL DEFAULT 'M'",
"ALTER TABLE soll ADD COLUMN geprueft TEXT NOT NULL DEFAULT '[]'",
"ALTER TABLE quellen ADD COLUMN atome_stand TEXT NOT NULL DEFAULT ''",
"ALTER TABLE sections ADD COLUMN fix_versuche INTEGER NOT NULL DEFAULT 0"):
"ALTER TABLE sections ADD COLUMN fix_versuche INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE bausteine ADD COLUMN ordnung TEXT NOT NULL DEFAULT 'judge'",
# braucht-Maschinerie entfernt: Ordnung kommt vom Judge,
# nicht mehr vom (zu dünnen) Abhängigkeits-Graph
"ALTER TABLE atome DROP COLUMN braucht"):
try:
con.execute(zusatz)
except sqlite3.OperationalError:
pass # Spalte existiert schon
pass # Spalte existiert schon (bzw. ist schon weg)
con.execute("DELETE FROM kanten WHERE art='braucht'") # tote Daten, kein Leser mehr
# Status-Marker entfernter Ebenen (diagramme/fehlersuche) zurückrollen — sonst
# kennt _ORDNUNG den Status nicht und der Resume fiele fälschlich auf korpus zurück.
con.execute("UPDATE topics SET status='struktur_fertig'"

View File

@@ -16,13 +16,11 @@ FAKE_TEXT = f"{SATZ_DFA}\n\n{SATZ_POTENZ}\n\n{SATZ_PUMPING}\n"
ATOME = [
{"titel": "Deterministischer endlicher Automat", "typ": "begriff",
"definition": SATZ_DFA, "zitat": SATZ_DFA, "level": "E", "braucht": []},
"definition": SATZ_DFA, "zitat": SATZ_DFA, "level": "E"},
{"titel": "Potenzmengenkonstruktion", "typ": "verfahren",
"definition": SATZ_POTENZ, "zitat": SATZ_POTENZ, "level": "M",
"braucht": ["Deterministischer endlicher Automat"]},
"definition": SATZ_POTENZ, "zitat": SATZ_POTENZ, "level": "M"},
{"titel": "Pumping-Lemma", "typ": "aussage",
"definition": SATZ_PUMPING, "zitat": SATZ_PUMPING, "level": "S",
"braucht": ["Deterministischer endlicher Automat"]},
"definition": SATZ_PUMPING, "zitat": SATZ_PUMPING, "level": "S"},
]
PUNKTE = [("Endliche Automaten", (SATZ_DFA, SATZ_POTENZ)),
("Nachweis von Nicht-Regularität", (SATZ_PUMPING,))]
@@ -218,31 +216,10 @@ def _kapitel_schnitt(prompt: str):
for n, g in enumerate(dict.fromkeys(grenzen))]
def _zyklus(prompt: str):
m = re.search(r"^(\d+)→(\d+):", _text_nach(prompt, "ZYKLUS:"), re.MULTILINE)
return {"von": int(m.group(1)), "zu": int(m.group(2))} if m else {}
def _braucht_aufloesung(prompt: str):
# je ANGABE den Kandidaten mit den meisten geteilten Tokens (Substring, ≥4 Zeichen)
# wählen, sonst null — spiegelt den Substring-Prefilter der Auflösung.
def toks(s):
return {t for t in re.findall(r"\w+", s.lower()) if len(t) >= 4}
out = []
bloecke = re.split(r"ANGABE (\d+):", prompt)[1:]
for i in range(0, len(bloecke), 2):
n, body = int(bloecke[i]), bloecke[i + 1]
mp = re.search(r"Voraussetzung „([^“]+)", body)
ph = mp.group(1) if mp else ""
pl, pt = ph.lower(), toks(ph)
ranked = []
for kid, ktitel in re.findall(r"- atom (\d+): (.+?) —", body):
geteilt = {t for t in toks(ktitel) if t in pl} | {t for t in pt if t in ktitel.lower()}
if geteilt:
ranked.append((-len(geteilt), len(ktitel), int(kid)))
ranked.sort()
out.append({"phrase": n, "atom": ranked[0][2] if ranked else None})
return out
def _baustein_ordnung(prompt: str):
nummern = [int(i) for i in re.findall(r"^(\d+): ", _text_nach(prompt, "BAUSTEINE"),
re.MULTILINE)]
return nummern # Identität: Prior-Ordnung ist im Fake die Lehr-Reihenfolge
def _writer(prompt: str):
@@ -303,7 +280,7 @@ _HANDLER = {
"Aussagen-Generate": _aussagen_generate, "Aussagen-Verify": _aussagen_verify,
"Lernziele": _lernziele, "Kapitel-Intro": _kapitel_intro,
"Kapitel-Schnitt": _kapitel_schnitt, "Level-Kalibrierung": _level_kalibrierung,
"Kanten-Zyklus": _zyklus, "Braucht-Aufloesung": _braucht_aufloesung,
"Baustein-Ordnung": _baustein_ordnung,
"Guide-Writer": _writer, "Guide-Pruefer": _pruefer,
"Guide-Pruefer-Urteil": _pruefer_urteil, "Guide-Fix": _fix,
"QA-Guide-Falsch": _qa_falsch, "QA-Guide-Falsch-Check": _qa_falsch_check,

View File

@@ -186,23 +186,14 @@ def _laenge_band(n_atome: int) -> tuple[int, int]:
def _anknuepf_kontext(b: dict) -> str:
"""Was der Leser aus früheren Durchgängen schon kennt: tieferstufige Atome
desselben Ziels plus direkte braucht-Voraussetzungen. Der Writer knüpft an,
statt neu zu erklären (Dopplungs-Schutz zwischen den Durchgängen)."""
desselben Ziels. Der Writer knüpft an, statt neu zu erklären
(Dopplungs-Schutz zwischen den Durchgängen)."""
rang = LEVEL_RANG.get(b["level"], 1)
atome = _atome_von(b["id"])
ids = {a["id"] for a in atome}
kontext: dict[int, dict] = {}
for a in db.query("SELECT * FROM atome WHERE ziel_id=? AND status NOT IN"
" ('gemerged','verworfen')", (b["ziel_id"],)):
if LEVEL_RANG.get(a["level"], 1) < rang:
kontext[a["id"]] = a
if ids:
marks = ",".join("?" * len(ids))
for a in db.query(f"SELECT a.* FROM kanten k JOIN atome a ON a.id=k.zu_atom"
f" WHERE k.art='braucht' AND k.status='aktiv'"
f" AND k.von_atom IN ({marks})", tuple(ids)):
if a["id"] not in ids and LEVEL_RANG.get(a["level"], 1) < rang:
kontext[a["id"]] = a
return "\n".join(f"- {a['titel']}: {a['definition']}" for a in kontext.values())

View File

@@ -14,7 +14,7 @@ import embedding
import korpus
import llm
import textkit
from config import (ANKER_OVERLAP_MERGE, BRAUCHT_KANDIDATEN, LUECKEN_RUNDEN_MAX,
from config import (ANKER_OVERLAP_MERGE, LUECKEN_RUNDEN_MAX,
MERGE_KANDIDAT_COS, MERGE_KANDIDAT_JACCARD,
MERGE_PANEL, READER_JE_ABSCHNITT, ZIELE_CHUNK_ATOME)
@@ -75,12 +75,10 @@ async def _extrahiere_quelle(ctx: llm.Kontext, quelle: dict, force: bool = False
if not titel or not definition or typ not in TYPEN:
continue
span = textkit.finde_zitat(chunk, zitat)
braucht = [str(t).strip() for t in a.get("braucht", []) if str(t).strip()]
atom_id = db.insert("atome", topic=ctx.topic, titel=titel, typ=typ,
definition=definition,
level=level if level in LEVELS else "M",
status="neu" if span else "ohne_anker",
braucht=db.j(braucht))
status="neu" if span else "ohne_anker")
if span:
db.insert("anker", atom_id=atom_id, quelle_id=quelle["id"],
start=offset + span[0], ende=offset + span[1], zitat=zitat)
@@ -114,7 +112,7 @@ def _wurzel(atom_id: int) -> int:
def _merge(topic: str, gewinner: int, verlierer: int) -> None:
"""Anker + braucht wandern zum Gewinner; der Verlierer bleibt als gemerged sichtbar."""
"""Anker wandern zum Gewinner; der Verlierer bleibt als gemerged sichtbar."""
gewinner, verlierer = _wurzel(gewinner), _wurzel(verlierer)
if gewinner == verlierer:
return
@@ -123,8 +121,6 @@ def _merge(topic: str, gewinner: int, verlierer: int) -> None:
if not g or not v or g["status"] == "verworfen" or v["status"] == "verworfen":
return
db.execute("UPDATE anker SET atom_id=? WHERE atom_id=?", (gewinner, verlierer))
braucht = sorted(set(db.uj(g["braucht"]) + db.uj(v["braucht"])))
db.update("atome", "id", gewinner, braucht=db.j(braucht))
db.update("atome", "id", verlierer, status="gemerged", merged_into=gewinner)
# Kanten einzeln umhängen: ein blindes UPDATE kollidiert mit dem UNIQUE-Index,
# wenn der Gewinner dieselbe Kante schon hat; Selbstkanten entstehen beim
@@ -246,121 +242,6 @@ def _erster_anker(atom_id: int) -> str:
return (row["zitat"][:300] if row else "(kein Anker)")
def _kanten_aufloesen(topic: str) -> None:
"""braucht-Titel → Atom-IDs. Norm-Gleichheit zuerst, Titel-Kern als zweite
Stufe (Norm allein löste nur 7279 % auf — der Rest fiel still weg und
Ordnung/Level-Logik arbeiteten auf einem lückigen Graph)."""
atome = aktive_atome(topic)
je_norm = {textkit.norm(a["titel"]): a["id"] for a in atome}
je_kern: dict[str, int] = {}
for a in atome:
if kern := textkit.titel_kern(a["titel"]):
je_kern.setdefault(kern, a["id"])
for a in atome:
for titel in db.uj(a["braucht"]):
ziel = je_norm.get(textkit.norm(titel))
if not ziel and (kern := textkit.titel_kern(titel)):
ziel = je_kern.get(kern)
if ziel and ziel != a["id"]:
db.execute("INSERT OR IGNORE INTO kanten(topic, von_atom, zu_atom, art)"
" VALUES(?,?,?,'braucht')", (topic, a["id"], ziel))
def _braucht_unaufgeloest(atome: list[dict]) -> list[tuple[dict, str]]:
"""(Atom, braucht-Titel), die _kanten_aufloesen (norm/kern) NICHT traf. Selbe
Auflösungslogik — was sie fand, ist erledigt und bleibt außen vor."""
je_norm = {textkit.norm(a["titel"]): a["id"] for a in atome}
je_kern: dict[str, int] = {}
for a in atome:
if kern := textkit.titel_kern(a["titel"]):
je_kern.setdefault(kern, a["id"])
offen = []
for a in atome:
for titel in db.uj(a["braucht"]):
ziel = je_norm.get(textkit.norm(titel))
if not ziel and (kern := textkit.titel_kern(titel)):
ziel = je_kern.get(kern)
if not ziel or ziel == a["id"]:
offen.append((a, titel))
return offen
def _trigramm(s: str) -> set[str]:
s = textkit.norm(s)
return {s[i:i + 3] for i in range(len(s) - 2)} or {s}
def _tri_dice(a: str, b: str) -> float:
"""Zeichen-Trigramm-Dice — fuzzy Titel-Ähnlichkeit, die geteilte Wortstämme UND
Kopfnomen erfasst („Approximationsalgorithmus" ~ „Approximativer Algorithmus")."""
A, B = _trigramm(a), _trigramm(b)
return 2 * len(A & B) / (len(A) + len(B)) if A or B else 0.0
def _braucht_kandidaten(titel: str, atome: list[dict], selbst_id: int, cap: int) -> list[dict]:
"""Expliziter Kandidatengenerator für Kompositum↔Expansion (Lektion 28: Cosinus
~0.53, Embedding schlägt sie nie vor). Zulassung (hohe Trefferquote): ein
signifikantes Token (≥5 Zeichen) ist Substring des anderen normierten Titels
(„algorithmus" ⊂ „approximationsalgorithmus"). Rang nach Trigramm-Dice — der
ganze Titel zählt, nicht nur das Kopfnomen, so steht der Grundbegriff vor
generischen Distraktoren („Algorithmus"). Der Judge trennt dann fein."""
nt = textkit.norm(titel)
bt = {t for t in textkit.tokens(titel) if len(t) >= 5}
treffer = []
for a in atome:
if a["id"] == selbst_id:
continue
na = textkit.norm(a["titel"])
at = {t for t in textkit.tokens(a["titel"]) if len(t) >= 5}
if {t for t in at if t in nt} | {t for t in bt if t in na}:
treffer.append((_tri_dice(titel, a["titel"]), a["id"], a))
treffer.sort(key=lambda x: (-x[0], x[1]))
return [a for _, _, a in treffer[:cap]]
async def _braucht_fallback(ctx: llm.Kontext) -> None:
"""braucht-Titel, die norm/kern nicht auflösten, per Judge auf Atome mappen.
Deterministischer Substring-Prefilter (Kandidaten) → EIN Judge (n=1) als
Präzisions-Gate. braucht-Kanten sind reversibel/billiger als Merges → kein Panel
(Lektion 78). Im Zweifel null: eine Falsch-Kante verschiebt Ordnung + Level.
Idempotent (INSERT OR IGNORE); läuft nur in bauen, damit ein Bausteine-Reset ihn
ohne Re-Extraktion mitnimmt."""
topic = ctx.topic
atome = aktive_atome(topic)
offen = _braucht_unaufgeloest(atome)
if not offen:
return
angaben = [(a, titel, kand) for a, titel in offen
if (kand := _braucht_kandidaten(titel, atome, a["id"], BRAUCHT_KANDIDATEN))]
if not angaben:
log.info("braucht-Fallback: %d unaufgelöste Titel, keine Kandidaten", len(offen))
return
geloest = 0
async def chunk_loesen(chunk: list) -> None:
nonlocal geloest
liste = "\n\n".join(
f"ANGABE {n}: Voraussetzung „{titel}“ (gebraucht von Atom {a['id']}"
f"{a['titel']}“)\nKandidaten:\n"
+ "\n".join(f" - atom {k['id']}: {k['titel']}{k['definition']}" for k in kand)
for n, (a, titel, kand) in enumerate(chunk, 1))
res = await llm.call(ctx, stage="braucht", template="Braucht-Aufloesung",
werte={"angaben": liste}, role="judge",
n=len(chunk), item=f"br{chunk[0][0]['id']}", erwartet=list)
wahl = {e.get("phrase"): e.get("atom") for e in res or [] if isinstance(e, dict)}
for n, (a, titel, kand) in enumerate(chunk, 1):
zid = wahl.get(n)
if zid in {k["id"] for k in kand} and zid != a["id"]:
db.execute("INSERT OR IGNORE INTO kanten(topic, von_atom, zu_atom, art)"
" VALUES(?,?,?,'braucht')", (topic, a["id"], zid))
geloest += 1
chunks = [angaben[i:i + PAAR_CHUNK] for i in range(0, len(angaben), PAAR_CHUNK)]
await llm.alle(chunk_loesen(c) for c in chunks)
log.info("braucht-Fallback: %d/%d Titel aufgelöst (%d ohne Kandidaten)",
geloest, len(offen), len(offen) - len(angaben))
# ── Soll-Zuordnung ────────────────────────────────────────────────────────────
async def _soll_zuordnen(ctx: llm.Kontext, nur_offene: bool = True) -> None:
@@ -392,7 +273,6 @@ async def bauen(ctx: llm.Kontext) -> None:
await llm.alle(_extrahiere_quelle(ctx, q) for q in offene)
_anker_dedup(ctx.topic)
await _judge_dedup(ctx)
_kanten_aufloesen(ctx.topic)
await _soll_zuordnen(ctx)
@@ -403,10 +283,13 @@ _TITEL_KATALOG = re.compile(r"^(Satz|Lemma|Korollar|Bemerkung|Definition|Aufgabe
def _titel_kaputt(titel: str) -> bool:
"""Katalognummern-Titel (Satz 6.26 — der Leser hat kein nummeriertes Skript)
und abgerissene Fragmente (aak: „Teste für alle V 0 ⊆ V mit |")."""
"""Katalognummern-Titel (Satz 6.26 — der Leser hat kein nummeriertes Skript),
abgerissene Fragmente (aak: „Teste für alle V 0 ⊆ V mit |") und LaTeX-Markup
(Titel sind Identifikatoren — Board/Dedup/Verweise rendern kein KaTeX)."""
if _TITEL_KATALOG.match(titel):
return True
if "$" in titel or "\\" in titel:
return True
if titel.rstrip().endswith(("|", ",", ";", ":", "-", "", "(", "{", "", "=")):
return True
return titel.count("(") != titel.count(")")
@@ -480,7 +363,6 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
if bewegt:
_anker_dedup(ctx.topic)
await _judge_dedup(ctx)
_kanten_aufloesen(ctx.topic)
await _soll_zuordnen(ctx)
return bewegt
@@ -658,7 +540,7 @@ async def _luecke_schliessen(ctx: llm.Kontext, soll_id: int) -> bool:
offset = text.find(fenster)
atom_id = db.insert("atome", topic=ctx.topic, titel=titel, typ=typ,
definition=definition, level="M", status="neu",
soll_id=soll_id, braucht=db.j([]))
soll_id=soll_id)
db.insert("anker", atom_id=atom_id, quelle_id=q["id"],
start=offset + span[0], ende=offset + span[1], zitat=zitat)
neu = True

View File

@@ -13,16 +13,40 @@ _FENCE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL)
_UNGUELTIGES_ESCAPE = re.compile(r'\\(?![\"\\/bfnrt]|u[0-9a-fA-F]{4})')
_ESCAPE_JE_ZEICHEN = {"\n": "\\n", "\r": "\\r", "\t": "\\t"}
def _iterativ_reparieren(s: str):
"""Positionsgenaue Reparatur am ORIGINAL (das Regex-Doubling beschädigt
\\\\x-Folgen): rohe Kontrollzeichen in Strings escapen (Modelle zitieren
mehrzeilige Passagen wörtlich — Markup-Zitat-Regel), ungültige Escapes
doubeln. Iterativ an der jeweils ersten Fehlerstelle, nur im Fehlerfall."""
for _ in range(300):
try:
return json.loads(s)
except json.JSONDecodeError as e:
if e.pos >= len(s):
return None
if "Invalid control character" in e.msg:
s = s[:e.pos] + _ESCAPE_JE_ZEICHEN.get(s[e.pos], " ") + s[e.pos + 1:]
elif "Invalid \\escape" in e.msg:
s = s[:e.pos] + "\\" + s[e.pos:]
else:
return None
return None
def _loads(s: str):
"""json.loads mit EINER Reparaturstufe für rohe LaTeX-Backslashes. Valides
JSON durchläuft unverändert (Reparatur nur im Fehlerfall)."""
"""json.loads mit Reparaturstufen für rohe LaTeX-Backslashes und rohe
Kontrollzeichen in Strings. Valides JSON durchläuft unverändert
(Reparatur nur im Fehlerfall)."""
try:
return json.loads(s)
except ValueError:
try:
return json.loads(_UNGUELTIGES_ESCAPE.sub(r"\\\\", s))
except ValueError:
return None
return _iterativ_reparieren(s)
def parse(text: str):

View File

@@ -25,6 +25,11 @@ class LaufPause(Exception):
"""Infrastruktur erschöpft — Lauf pausieren, nicht weiterrechnen."""
class ManuellePause(LaufPause):
"""Nutzer-Pause: Wartende stoppen, Laufende auslaufen lassen (alle() drainiert
statt zu canceln), dann endet der Lauf als 'paused' — Resume via Start."""
class Kontext:
"""Ein Lauf: wandert durch alle Ebenen, trägt Ledger-Zuordnung."""
@@ -57,9 +62,16 @@ async def _roher_call(key: str, prompt: str, timeout: int, ctx: Kontext,
role=role, capabilities=caps)
haupt = asyncio.ensure_future(einer(key))
try:
if not schwelle:
return await haupt
fertig, _ = await asyncio.wait({haupt}, timeout=schwelle)
except asyncio.CancelledError:
# Stop cancelt nur den WARTENDEN — der ensure_future-Task liefe sonst
# verwaist weiter (aak: 10 Extraktions-Calls überlebten Stop, hielten
# API-Slots und zogen Tokens, bis der Timeout sie erlöste)
haupt.cancel()
raise
if fertig:
return haupt.result() # kein Zwilling gestartet → kein Hedge-Log
zwilling = asyncio.ensure_future(einer(f"{key}-h"))
@@ -129,6 +141,9 @@ async def call(ctx: Kontext, *, stage: str, template: str, werte: dict,
tokens, err = res.tokens, res.err
wait_ms = int(res.wait_s * 1000)
model = res.model
if res.err == "pausiert": # Nutzer-Pause: kein Retry, Lauf sauber beenden
status = "pause"
raise ManuellePause("manuell pausiert")
if res.ok:
if erwartet is str:
status = "ok"
@@ -143,6 +158,9 @@ async def call(ctx: Kontext, *, stage: str, template: str, werte: dict,
status = "ok"
return daten
status, err = "parse", f"unparsbar/falscher Typ: {res.text[:200]}"
elif "stop=max_tokens" in res.err:
status = "cap" # Output-Cap: gleicher Prompt → gleiches Cap,
err = res.err # Neuversuch ist deterministisch sinnlos
elif _ist_infra(res.err):
status = "infra"
else:
@@ -159,6 +177,11 @@ async def call(ctx: Kontext, *, stage: str, template: str, werte: dict,
wait_ms=wait_ms,
tokens=tokens, meta={"err": err[:300]} if err else None)
ledger.budget_pruefen(ctx.run_id)
if status == "cap":
# sofort aufgeben — der Aufrufer reagiert strukturell (Extraktion
# halbiert den Chunk); 2 Retries verbrannten sonst ~64k Tokens je Fall
log.warning("%s: Output-Cap (stop=max_tokens) — kein Neuversuch", key)
return None
if status == "infra":
infra_rest -= 1
if infra_rest < 0:
@@ -177,10 +200,15 @@ async def call(ctx: Kontext, *, stage: str, template: str, werte: dict,
async def alle(coros) -> list:
"""gather-Variante, die bei der ersten Exception (LaufPause/Budget) die
Geschwister ABBRICHT — sonst brennen Hunderte laufende Repair-Tasks weiter."""
Geschwister ABBRICHT — sonst brennen Hunderte laufende Repair-Tasks weiter.
Ausnahme ManuellePause: sanft — Laufende zu Ende laufen lassen (Ergebnisse
landen noch in der DB), Wartende liefern selbst „pausiert"."""
tasks = [asyncio.ensure_future(c) for c in coros]
try:
return await asyncio.gather(*tasks)
except ManuellePause:
await asyncio.gather(*tasks, return_exceptions=True)
raise
except BaseException:
for t in tasks:
if not t.done():

View File

@@ -131,6 +131,12 @@ async def lauf_stop(topic: str):
return {"ok": True}
@app.post("/api/topics/{topic}/pause")
async def lauf_pause(topic: str):
pipeline.lauf_pausieren(topic)
return {"ok": True}
def _befunde_aktuell(topic: str) -> list[dict]:
"""Offene Befunde des jeweils jüngsten Laufs je Ebene — in zwei billigen Pässen."""
neueste = db.query(

View File

@@ -85,6 +85,13 @@ def aktualisierung_starten(topic: str, ebene: str, tief: bool = False) -> int:
return run_id
def lauf_pausieren(topic: str) -> None:
"""Sanfte Pause: Wartende + Neue stoppen, Laufende laufen aus. Der Lauf endet
als 'paused', sobald der nächste Call die Pause bemerkt (ManuellePause);
Start setzt normal fort (abbruch_aufheben hebt die Pause auf)."""
agents.pausieren(f"{topic}-")
def lauf_stoppen(topic: str) -> None:
agents.abbrechen(f"{topic}-")
task = _laeufe.get(topic)

View File

@@ -26,8 +26,8 @@ GEWICHTE = {
"soll_kandidat_offen": 3.0,
"atom_ohne_anker": 3.0, "atom_ohne_soll": 1.5, "soll_ohne_atom": 3.0,
"atom_ohne_flashcard": 1.5, "atom_ohne_aussage": 1.5, "artefakt_unentschieden": 0.5,
"partition": 3.0, "band": 0.5, "vorwaerts_kante": 1.5, "zyklus": 3.0,
"level_konflikt": 3.0, "level_mix": 3.0, "kapitel_level": 3.0,
"partition": 3.0, "band": 0.5, "ordnung_fallback": 1.5,
"level_mix": 3.0, "kapitel_level": 3.0,
"baustein_ohne_kapitel": 3.0, "kapitel_leer": 1.5, "kapitel_fallback": 1.5,
"kapitel_zerstueckelt": 1.5, "titel_katalog": 1.5, "quelle_unvollstaendig": 3.0,
"section_fehlt": 3.0, "marker_fehlend": 3.0, "marker_fremd": 3.0,

View File

@@ -1,15 +1,13 @@
"""Ebene 3: Struktur. Backward Design: Lernziele aus den Atomen je Soll-Punkt
(Zuordnung im selben Call — Lektion 52), dann wird Struktur RECHENBAR:
Baustein-Schnitt = Partition der Ziel-Gruppen ins Größenband, Reihenfolge =
topologische Sortierung des braucht-Graphen. Zyklen bricht ein Judge an der
schwächsten Kante (deterministischer Fallback)."""
(Zuordnung im selben Call — Lektion 52), Baustein-Schnitt = Partition der
Ziel-Gruppen ins Größenband. Reihenfolge = Judge je Level (Dozenten-Blick)
mit Quellpositions-Prior — der braucht-Graph war zu dünn (aak: 77 wirksame
von 385 Kanten) und ordnete faktisch per Zufall; die Maschinerie ist raus."""
import asyncio
import logging
from collections import defaultdict
import db
import inventar
import llm
from config import (BAUSTEIN_MAX_ATOME, BAUSTEIN_MIN_ATOME, DURCHGANG,
KAPITEL_BAUSTEINE, LEVEL_RANG, ZIELE_CHUNK_ATOME)
@@ -24,11 +22,6 @@ def _atome(topic: str) -> list[dict]:
" ('gemerged','verworfen') ORDER BY id", (topic,))
def _braucht_kanten(topic: str) -> list[dict]:
return db.query("SELECT * FROM kanten WHERE topic=? AND art='braucht' AND status='aktiv'",
(topic,))
# ── Lernziele ─────────────────────────────────────────────────────────────────
async def _ziele_bilden(ctx: llm.Kontext) -> None:
@@ -78,119 +71,41 @@ async def _ziele_bilden(ctx: llm.Kontext) -> None:
await llm.alle(eine_gruppe(s, g) for s, g in gruppen.items())
# ── Zyklen brechen ────────────────────────────────────────────────────────────
def _finde_zyklus(knoten: list[int], kanten: list[tuple[int, int]]) -> list[tuple[int, int]] | None:
"""Ein Zyklus als Kantenliste, oder None (iterative DFS mit Farben)."""
nach = defaultdict(list)
for v, z in kanten:
nach[v].append(z)
farbe = {k: 0 for k in knoten} # 0 weiß, 1 grau, 2 schwarz
eltern: dict[int, int] = {}
for start in knoten:
if farbe[start]:
continue
stapel = [(start, iter(nach[start]))]
farbe[start] = 1
while stapel:
v, it = stapel[-1]
fortschritt = False
for z in it:
if z not in farbe:
continue
if farbe[z] == 0:
farbe[z] = 1
eltern[z] = v
stapel.append((z, iter(nach[z])))
fortschritt = True
break
if farbe[z] == 1: # Rückkante → Zyklus rekonstruieren
pfad = [(v, z)]
k = v
while k != z:
pfad.append((eltern[k], k))
k = eltern[k]
return pfad
if not fortschritt:
farbe[v] = 2
stapel.pop()
return None
async def _zyklen_brechen(ctx: llm.Kontext) -> None:
while True:
atome = _atome(ctx.topic)
ids = [a["id"] for a in atome]
kanten = [(k["von_atom"], k["zu_atom"]) for k in _braucht_kanten(ctx.topic)
if k["von_atom"] in ids and k["zu_atom"] in ids]
zyklus = _finde_zyklus(ids, kanten)
if not zyklus:
return
je_id = {a["id"]: a for a in atome}
liste = "\n".join(f"{v}{z}: {je_id[v]['titel']} braucht {je_id[z]['titel']}"
for v, z in zyklus)
res = await llm.call(ctx, stage="zyklus", template="Kanten-Zyklus",
werte={"kanten": liste}, role="judge",
item=f"z{zyklus[0][0]}", erwartet=dict)
wahl = None
if res:
paar = (res.get("von"), res.get("zu"))
if paar in zyklus:
wahl = paar
if wahl is None: # deterministischer Fallback: kleinste Kante
wahl = min(zyklus)
db.execute("UPDATE kanten SET status='gebrochen' WHERE topic=? AND von_atom=?"
" AND zu_atom=? AND art='braucht'", (ctx.topic, wahl[0], wahl[1]))
log.info("Zyklus gebrochen: %s%s", wahl[0], wahl[1])
# ── Bausteine schneiden + ordnen ──────────────────────────────────────────────
def _topo(knoten: list[int], kanten: list[tuple[int, int]],
rang: dict[int, tuple]) -> list[int]:
"""Kahn; kanten (v, z) = v braucht z ⇒ z kommt vor v. Ties nach rang (stabil).
Kanten STRIKT auf die Knotenmenge filtern — Kanten zu fremden Atomen zogen
sonst fremde Knoten in die Ausgabe und verdrängten Gruppenmitglieder
(gemessen aak: 22 Atome ohne Baustein)."""
kn = set(knoten)
vorher = defaultdict(set)
nachher = defaultdict(set)
for v, z in kanten:
if v in kn and z in kn:
vorher[v].add(z)
nachher[z].add(v)
offen = sorted([k for k in knoten if not vorher[k]], key=lambda k: rang[k])
out = []
erledigt: set[int] = set()
while offen:
k = offen.pop(0)
out.append(k)
erledigt.add(k)
neu = []
for n in nachher[k]:
if not (vorher[n] - erledigt) and n not in erledigt and n not in offen and n not in neu:
neu.append(n)
offen = sorted(offen + neu, key=lambda kk: rang[kk])
out += sorted([k for k in knoten if k not in erledigt], key=lambda k: rang[k])
return out
def _anker_rang(topic: str) -> dict[int, tuple]:
"""Natürliche Quellreihenfolge als Tie-Break: (quelle_id, start) des ersten Ankers."""
"""Natürliche Quellreihenfolge: (quelle_id, start) des ersten Ankers,
stoff-Quellen bevorzugt — Aufgabensammlungen ordnen nicht didaktisch."""
out = {}
for a in _atome(topic):
row = db.one("SELECT quelle_id, start FROM anker WHERE atom_id=? AND start>=0"
" ORDER BY quelle_id, start LIMIT 1", (a["id"],))
row = db.one("SELECT k.quelle_id, k.start FROM anker k"
" JOIN quellen q ON q.id=k.quelle_id"
" WHERE k.atom_id=? AND k.start>=0"
" ORDER BY (q.rolle != 'stoff'), k.quelle_id, k.start LIMIT 1",
(a["id"],))
out[a["id"]] = (row["quelle_id"], row["start"]) if row else (999_999, a["id"])
return out
def _bausteine_schneiden(ctx: llm.Kontext) -> None:
def _baustein_rang(bausteine: list[dict], atome: list[dict],
rang: dict[int, tuple]) -> dict[int, tuple]:
"""Quellpositions-Prior je Baustein: MEDIAN der Atom-Ränge — robust gegen
einzelne Merge-Importe mit fremdem Rang (aak: ein „Komplexitätstheorie"-Atom
von Zeichen 464 zog den ETH-Baustein per min() an Position 0)."""
b_rang = {}
for b in bausteine:
raenge = sorted(rang.get(a["id"], (9, 9)) for a in atome
if a["baustein_id"] == b["id"])
b_rang[b["id"]] = raenge[len(raenge) // 2] if raenge else (9, 9)
return b_rang
async def _bausteine_schneiden(ctx: llm.Kontext) -> None:
"""Deterministisch, idempotent: alte Zuordnung verwerfen, neu schneiden.
Gruppen sind (Ziel, Level) — je Level entstehen GETRENNTE Bausteine
(E/M/S-Durchgänge). Kleine Gruppen level-intern mergen (das Atom wechselt
sein Ziel, Baustein bleibt EIN Ziel + EIN Level), große entlang der
Topo-Reihenfolge splitten ("Teil n")."""
Quellreihenfolge splitten ("Teil n")."""
topic = ctx.topic
# Aufträge hängen an Baustein-IDs — beim Neuschnitt mitlöschen, sonst Waisen
db.execute("DELETE FROM auftraege WHERE baustein_id IN"
@@ -198,23 +113,14 @@ def _bausteine_schneiden(ctx: llm.Kontext) -> None:
db.execute("DELETE FROM bausteine WHERE topic=?", (topic,))
atome = _atome(topic)
rang = _anker_rang(topic)
kanten = [(k["von_atom"], k["zu_atom"]) for k in _braucht_kanten(topic)]
gruppen: dict = defaultdict(list)
for a in atome:
gruppen[(a["ziel_id"], a["level"])].append(a)
# kleine Gruppen mergen (kleinste zuerst), nur innerhalb des Levels.
# Partnerwahl thematisch statt per Soll-Punkt — das Soll ist seit der
# Entkopplung reine Checkliste (152 feine Punkte hätten sonst keine
# Merge-Partner): meiste braucht-Kanten zwischen den Gruppen, sonst
# Nachbar in der Quellreihenfolge.
def kanten_score(g1: list[dict], g2: list[dict]) -> int:
ids1 = {a["id"] for a in g1}
ids2 = {a["id"] for a in g2}
return sum(1 for v, z in kanten
if (v in ids1 and z in ids2) or (v in ids2 and z in ids1))
# Partnerwahl über Quell-Nähe — das Soll ist seit der Entkopplung reine
# Checkliste (152 feine Punkte hätten sonst keine Merge-Partner).
def gruppen_rang(g: list[dict]) -> tuple:
return min(rang[a["id"]] for a in g)
@@ -235,8 +141,7 @@ def _bausteine_schneiden(ctx: llm.Kontext) -> None:
def naehe(pp: tuple) -> tuple:
r = gruppen_rang(gruppen[pp])
return (-kanten_score(gruppen[k], gruppen[pp]),
(abs(r[0] - eigener[0]), abs(r[1] - eigener[1])),
return ((abs(r[0] - eigener[0]), abs(r[1] - eigener[1])),
len(gruppen[pp]))
p = min(passende, key=naehe)
@@ -252,7 +157,7 @@ def _bausteine_schneiden(ctx: llm.Kontext) -> None:
# Bausteine anlegen (große Gruppen splitten), Atome zuordnen
ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?", (topic,))}
for (ziel_id, level), gruppe in gruppen.items():
ordnung = _topo([a["id"] for a in gruppe], kanten, rang)
ordnung = sorted((a["id"] for a in gruppe), key=lambda i: rang[i])
z = ziele.get(ziel_id, {})
titel = z.get("titel") or z.get("text", "Baustein") # Kurztitel; Zieltext nur Fallback
n_teile = -(-len(gruppe) // BAUSTEIN_MAX_ATOME)
@@ -273,55 +178,54 @@ def _bausteine_schneiden(ctx: llm.Kontext) -> None:
for pos, atom_id in enumerate(teil_ids):
db.update("atome", "id", atom_id, baustein_id=b_id, ord=pos)
_bausteine_ordnen(topic, kanten, rang)
await _bausteine_ordnen(ctx)
def _bausteine_ordnen(topic: str, kanten: list[tuple[int, int]], rang: dict) -> None:
"""Baustein-DAG aus aggregierten Atom-Kanten, JE LEVEL getrennt geordnet;
ord läuft global fortlaufend E→M→S (eine Achse — Vorwärts-Logik und Kapitel-
Kontiguität bleiben level-blind korrekt). Cross-Level-Kanten zeigen nach der
Konfliktlösung immer auf tiefere Level und sind durch die Blockordnung
automatisch erfüllt. Zyklen deterministisch an der schwächsten Aggregat-Kante
gebrochen (wenigste Atom-Kanten)."""
async def _bausteine_ordnen(ctx: llm.Kontext) -> None:
"""Didaktische Ordnung je Level per Judge (Dozenten-Blick); ord läuft global
fortlaufend E→M→S (eine Achse — Vorwärts-Logik und Kapitel-Kontiguität
bleiben level-blind korrekt). Eingabe ist nach Quellpositions-Prior
vorsortiert (das Skript IST vom Dozenten geordnet); der Code erzwingt eine
exakte Permutation — ungültig → 1 Retry → Prior-Ordnung mit Befund
(ordnung_fallback, Muster wie kapitel_fallback)."""
topic = ctx.topic
bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))
atome = _atome(topic)
je_atom = {a["id"]: a["baustein_id"] for a in atome}
b_level = {b["id"]: b["level"] for b in bausteine}
gewicht: dict = defaultdict(int)
for v, z in kanten: # v braucht z ⇒ Baustein(z) vor Baustein(v)
bv, bz = je_atom.get(v), je_atom.get(z)
if bv and bz and bv != bz and b_level[bv] == b_level[bz]:
gewicht[(bv, bz)] += 1
# Tie-Break: Quellposition des Bausteins (früheste Belegstelle); Kanten gewinnen
# weiter. Kapitel entstehen erst NACH der Ordnung (Entkopplung vom Soll).
b_rang = {}
for b in bausteine:
b_rang[b["id"]] = min((rang.get(a["id"], (9, 9)) for a in atome
if a["baustein_id"] == b["id"]), default=(9, 9))
b_rang = _baustein_rang(bausteine, atome, _anker_rang(topic))
ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?", (topic,))}
offset = 0
for level in LEVEL_RANG:
b_ids = [b["id"] for b in bausteine if b["level"] == level]
if not b_ids:
folge = sorted((b for b in bausteine if b["level"] == level),
key=lambda b: b_rang[b["id"]])
if not folge:
continue
b_kanten = {k for k in gewicht if b_level[k[0]] == level}
while True:
zyklus = _finde_zyklus(b_ids, list(b_kanten))
if not zyklus:
break
schwach = min(zyklus, key=lambda k: (gewicht[k], k))
b_kanten.discard(schwach)
# Die Atom-Kanten hinter der geopferten Aggregat-Kante stilllegen — sonst
# meldet die QA sie ewig als Vorwärts-Kante (Baustein-Zyklen kann keine
# Ordnung vollständig rückwärts auflösen).
bv, bz = schwach
db.execute("UPDATE kanten SET status='geopfert' WHERE topic=? AND art='braucht'"
" AND status='aktiv'"
" AND von_atom IN (SELECT id FROM atome WHERE baustein_id=?)"
" AND zu_atom IN (SELECT id FROM atome WHERE baustein_id=?)",
(topic, bv, bz))
for pos, b_id in enumerate(_topo(b_ids, list(b_kanten), b_rang)):
db.update("bausteine", "id", b_id, ord=offset + pos)
offset += len(b_ids)
liste = "\n".join(f"{i + 1}: {b['titel']}"
f"{ziele.get(b['ziel_id'], {}).get('text', '')}"
for i, b in enumerate(folge))
soll = list(range(1, len(folge) + 1))
async def ordnen(hinweis: str, tag: str) -> list[int] | None:
res = await llm.call(ctx, stage="ordnung", template="Baustein-Ordnung",
role="judge", n=len(folge), item=tag, erwartet=list,
werte={"bausteine": liste, "hinweis": hinweis,
"durchgang": DURCHGANG[level]})
perm = [x for x in res or [] if isinstance(x, int)]
return perm if sorted(perm) == soll else None
perm = await ordnen("", f"ordnung-{level}")
if perm is None:
perm = await ordnen(
"ACHTUNG: Der letzte Versuch war ungültig. Antworte mit GENAU"
f" den Nummern 1{len(folge)}, jede genau einmal.",
f"ordnung-{level}-2")
art = "judge"
if perm is None: # Prior-Ordnung ist der ehrliche Fallback — mit Befund
art = "fallback"
perm = soll
for pos, nr in enumerate(perm):
db.update("bausteine", "id", folge[nr - 1]["id"],
ord=offset + pos, ordnung=art)
offset += len(folge)
async def _level_kalibrieren(ctx: llm.Kontext) -> None:
@@ -361,29 +265,6 @@ async def _level_kalibrieren(ctx: llm.Kontext) -> None:
await llm.alle(einer(z, g) for z, g in gruppen.items())
def _level_konflikte_loesen(topic: str) -> None:
"""Deterministisch: Eine Voraussetzung darf nie über dem Level ihres Nutzers
liegen — sonst fehlt dem Durchgang sein Fundament. Auflösung durch ABSENKEN
der Voraussetzung (sie gehört ins Grundgerüst des Abhängigen); Anheben würde
Kerninhalte aus dem E-Durchgang verdrängen. Fixpunkt: Level fallen monoton,
terminiert nach max. 2 Stufen je Atom."""
level = {a["id"]: a["level"] for a in _atome(topic)}
kanten = [(k["von_atom"], k["zu_atom"]) for k in _braucht_kanten(topic)
if k["von_atom"] in level and k["zu_atom"] in level]
gesenkt = 0
geaendert = True
while geaendert:
geaendert = False
for v, z in kanten: # v braucht z ⇒ level(z) ≤ level(v)
if LEVEL_RANG[level[z]] > LEVEL_RANG[level[v]]:
level[z] = level[v]
db.update("atome", "id", z, level=level[v])
gesenkt += 1
geaendert = True
if gesenkt:
log.info("Level-Konflikte: %d Voraussetzungen abgesenkt", gesenkt)
async def _kapitel_bilden(ctx: llm.Kontext) -> None:
"""Kapitel = kontiguierliche Segmente der geordneten Baustein-Folge, JE
DURCHGANG (Level) geschnitten; kapitel.ord läuft global E→M→S weiter. Das
@@ -469,12 +350,8 @@ async def _kapitel_bilden(ctx: llm.Kontext) -> None:
async def bauen(ctx: llm.Kontext) -> None:
ctx.ebene = EBENE
await _ziele_bilden(ctx)
await inventar._braucht_fallback(ctx) # Kompositum↔Expansion-braucht-Titel nachziehen,
# bevor Zyklen/Level auf dem Graph rechnen
await _zyklen_brechen(ctx)
await _level_kalibrieren(ctx) # VOR dem Schnitt: Level bestimmt die Bausteine
_level_konflikte_loesen(ctx.topic)
_bausteine_schneiden(ctx)
await _bausteine_schneiden(ctx)
await _kapitel_bilden(ctx)
@@ -503,13 +380,6 @@ def messen(ctx: llm.Kontext) -> list[dict]:
befunde.append({"art": "level_mix", "item": str(a["id"]),
"detail": f"Atom {a['level']} in Baustein {b['level']}"
f" ({b['titel']})"})
level_von = {a["id"]: a["level"] for a in atome}
for k in _braucht_kanten(ctx.topic): # Voraussetzung nie über dem Nutzer-Level
lv, lz = level_von.get(k["von_atom"]), level_von.get(k["zu_atom"])
if lv and lz and LEVEL_RANG[lz] > LEVEL_RANG[lv]:
befunde.append({"art": "level_konflikt", "item": str(k["id"]),
"detail": f"Atom {k['von_atom']} ({lv}) braucht"
f" {k['zu_atom']} ({lz})"})
kaps = db.query("SELECT * FROM kapitel WHERE topic=?", (ctx.topic,))
kap_ids = {k["id"] for k in kaps}
kap_level = {k["id"]: k["level"] for k in kaps}
@@ -541,18 +411,11 @@ def messen(ctx: llm.Kontext) -> list[dict]:
if k["ord"] != letzte:
befunde.append({"art": "kapitel_zerstueckelt", "item": str(k["id"]),
"detail": k["titel"]})
ord_von = {b["id"]: b["ord"] for b in bausteine}
for k in _braucht_kanten(ctx.topic):
bv = next((a["baustein_id"] for a in atome if a["id"] == k["von_atom"]), None)
bz = next((a["baustein_id"] for a in atome if a["id"] == k["zu_atom"]), None)
if bv and bz and bv != bz and ord_von.get(bz, 0) > ord_von.get(bv, 0):
befunde.append({"art": "vorwaerts_kante", "item": str(k["id"]),
"detail": f"Atom {k['von_atom']} braucht {k['zu_atom']} (später)"})
ids = [a["id"] for a in atome]
kanten = [(k["von_atom"], k["zu_atom"]) for k in _braucht_kanten(ctx.topic)
if k["von_atom"] in ids and k["zu_atom"] in ids]
if _finde_zyklus(ids, kanten):
befunde.append({"art": "zyklus", "item": "", "detail": "braucht-Graph hat Zyklus"})
# stiller Ordnungs-Fallback wäre unsichtbarer Qualitätsverlust (wie kapitel_fallback)
for lv in sorted({b["level"] for b in bausteine if b["ordnung"] == "fallback"}):
befunde.append({"art": "ordnung_fallback", "item": lv,
"detail": f"Durchgang {lv}: Judge lieferte keine gültige"
f" Permutation — Quellreihenfolge übernommen"})
return befunde
@@ -561,9 +424,7 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
if not befunde:
return False
await _ziele_bilden(ctx) # fängt Partition-Lücken
await _zyklen_brechen(ctx) # fängt Zyklen
await _level_kalibrieren(ctx)
_level_konflikte_loesen(ctx.topic) # fängt level_konflikt
_bausteine_schneiden(ctx) # Neu-Schnitt fängt Band, Vorwärts-Kanten, level_mix
await _bausteine_schneiden(ctx) # Neu-Schnitt fängt Band, level_mix, ordnung_fallback
await _kapitel_bilden(ctx) # fängt Kapitel-Befunde
return True

View File

@@ -47,9 +47,48 @@ def _normiert_mit_map(text: str) -> tuple[str, list[int]]:
return "".join(out), mapping
# LaTeX-Kontrollwörter für den Vergleich neutralisieren: das Modell zitiert die
# GERENDERTE Lesart („k ∈ "), die tex-Quelle enthält Markup ($k \in \mathbb{N}$)
# — Anker-Identität muss unter diesem Rendering invariant sein (aak: 57/146 Atome
# ohne Anker). Griechische Befehle → Unicode-Buchstabe (überlebt die alnum-Faltung
# auf beiden Seiten wie das σ im Zitat); alle anderen Befehle fallen weg — ihre
# gerenderten Symbole (∈, ⊆, ≥) verwirft die alnum-Faltung ebenfalls.
# \begin{itemize} usw. KOMPLETT schlucken (erste Alternative) — sonst bleibt der
# Umgebungsname als Residuum in der Faltung („itemize") und Zitate über
# Listenstarts scheitern; danach normale Befehlswörter
_LATEX_WORT = re.compile(r"\\(?:begin|end)\{[^}]*\}|\\[a-zA-Z]+")
_GRIECHISCH = {g: chr(c) for g, c in (
("alpha", 0x3B1), ("beta", 0x3B2), ("gamma", 0x3B3), ("delta", 0x3B4),
("epsilon", 0x3B5), ("varepsilon", 0x3B5), ("zeta", 0x3B6), ("eta", 0x3B7),
("theta", 0x3B8), ("iota", 0x3B9), ("kappa", 0x3BA), ("lambda", 0x3BB),
("mu", 0x3BC), ("nu", 0x3BD), ("xi", 0x3BE), ("pi", 0x3C0), ("rho", 0x3C1),
("sigma", 0x3C3), ("tau", 0x3C4), ("phi", 0x3C6), ("varphi", 0x3C6),
("chi", 0x3C7), ("psi", 0x3C8), ("omega", 0x3C9),
("Gamma", 0x393), ("Delta", 0x394), ("Theta", 0x398), ("Lambda", 0x39B),
("Xi", 0x39E), ("Pi", 0x3A0), ("Sigma", 0x3A3), ("Phi", 0x3A6),
("Psi", 0x3A8), ("Omega", 0x3A9))}
def _latex_falten(text: str) -> list[tuple[str, int]]:
"""→ (Zeichen, Original-Position)-Paare mit neutralisierten LaTeX-Befehlen."""
paare: list[tuple[str, int]] = []
pos = 0
for m in _LATEX_WORT.finditer(text):
for i in range(pos, m.start()):
paare.append((text[i], i))
if "{" not in m.group(0): # Umgebungen komplett schlucken
for ch in _GRIECHISCH.get(m.group(0)[1:], ""):
paare.append((ch, m.start()))
pos = m.end()
for i in range(pos, len(text)):
paare.append((text[i], i))
return paare
def _locker_mit_map(text: str) -> tuple[str, list[int]]:
"""Nur Buchstaben/Ziffern (NFKD + casefold, Kombinationszeichen raus) + Map auf
Original-Positionen. Für die dritte Matching-Stufe: PDF-Extrakte und Reader variieren
Original-Positionen, LaTeX-Befehle neutralisiert (s. o.). Für die dritte
Matching-Stufe: PDF-Extrakte und Reader variieren
Interpunktion, Anführungszeichen, Dash-Typen und Trennungen — der Wortlaut bleibt
gleich. NFKD statt NFKC (Lektion 82): pdftotext liefert DEKOMPONIERTE Umlaute
(„a"+Kombinationspunkt), das LLM-Zitat präkomponierte („ä") — pro Einzelzeichen
@@ -57,7 +96,7 @@ def _locker_mit_map(text: str) -> tuple[str, list[int]]:
ohne das scheiterte fast jedes deutsche Zitat (aak: 59/96 Atome ohne Anker)."""
out: list[str] = []
mapping: list[int] = []
for i, c in enumerate(text):
for c, i in _latex_falten(text):
for cn in unicodedata.normalize("NFKD", c).casefold():
if cn.isalnum() and not unicodedata.combining(cn):
out.append(cn)
@@ -77,7 +116,8 @@ def _casefold_mit_map(text: str) -> tuple[str, list[int]]:
return "".join(out), mapping
_LOCKER_MIN_ZEICHEN = 20 # kürzere Zitate sind alnum-gefaltet nicht mehr eindeutig
_LOCKER_MIN_ZEICHEN = 20 # darunter: nur bei EINDEUTIGEM Vorkommen matchen
_LOCKER_MIN_EINDEUTIG = 10 # darunter: nie (zu wenig Signal, auch wenn einmalig)
def finde_zitat(text: str, zitat: str) -> tuple[int, int] | None:
@@ -107,11 +147,24 @@ def finde_zitat(text: str, zitat: str) -> tuple[int, int] | None:
return start, ende
hay_l, map_l = _locker_mit_map(text)
ndl_l, _ = _locker_mit_map(zitat)
if len(ndl_l) < _LOCKER_MIN_EINDEUTIG:
return None
if len(ndl_l) < _LOCKER_MIN_ZEICHEN:
# Eindeutigkeit war der eigentliche Grund des Längen-Gates — direkt
# prüfen statt Proxy-Länge: kurze Formel-Zitate ($LPT(I)=\OPT(I)$)
# sind oft einmalig und damit sichere Anker. Kein Fuzzy für Kurze.
if hay_l.count(ndl_l) != 1:
return None
pos = hay_l.find(ndl_l)
return map_l[pos], map_l[min(pos + len(ndl_l) - 1, len(map_l) - 1)] + 1
pos = hay_l.find(ndl_l)
if pos < 0:
return _fuzzy_span(hay_l, map_l, ndl_l)
span = _fuzzy_span(hay_l, map_l, ndl_l)
# Negations-Guard auch für Stufe 4: eine eingefügte Negation ist nur
# ~5 Zeichen Distanz — semantisch aber das Gegenteil (nie ankern)
if span and negations_menge(text[span[0]:span[1]]) != negations_menge(zitat):
span = None
return span or _wort_span(text, zitat)
return map_l[pos], map_l[min(pos + len(ndl_l) - 1, len(map_l) - 1)] + 1
@@ -138,6 +191,108 @@ def _fuzzy_span(hay_l: str, map_l: list[int], ndl_l: str) -> tuple[int, int] | N
return map_l[best.start], map_l[min(best.end - 1, len(map_l) - 1)] + 1
# ── Stufe 5: Wort-Alignment ──────────────────────────────────────────────────
# Modelle zitieren semantisch, nie byte-treu: \cdot → „mal", \sqrt{} → „sqrt()",
# kleine Auslassungen. Auf Zeichen-Ebene sprengt jede dieser Abweichungen die
# 8-%-Schranke — auf WORT-Ebene ist es je 1 Edit. Echte Paraphrasen bleiben
# draußen: dort sind die Wörter selbst anders (Wort-Distanz > Schranke).
WORT_MIN = 8 # kürzere Zitate: Wort-Matching nicht mehr eindeutig genug
WORT_FEHLERQUOTE = 0.25 # max. Wort-Edits als Anteil der Zitat-Wortzahl
def _wort_folge(text: str) -> tuple[list[str], list[tuple[int, int]]]:
"""Gefaltete Wörter + (start, ende)-Spans im Original. Gleiche Faltung wie
Stufe 3 (LaTeX neutralisiert, NFKD-alnum), aber mit Wortgrenzen."""
woerter: list[str] = []
spans: list[tuple[int, int]] = []
aktuell: list[str] = []
w_start = w_ende = -1
for c, i in _latex_falten(text):
emittiert = False
for cn in unicodedata.normalize("NFKD", c).casefold():
if cn.isalnum() and not unicodedata.combining(cn):
if not aktuell:
w_start = i
aktuell.append(cn)
w_ende = i
emittiert = True
if not emittiert and aktuell:
woerter.append("".join(aktuell))
spans.append((w_start, w_ende + 1))
aktuell = []
if aktuell:
woerter.append("".join(aktuell))
spans.append((w_start, w_ende + 1))
return woerter, spans
def _wort_distanz(a: list[str], b: list[str], limit: int) -> int:
"""Levenshtein über Wortfolgen, mit Abbruch oberhalb von limit."""
if abs(len(a) - len(b)) > limit:
return limit + 1
vorher = list(range(len(b) + 1))
for i, wa in enumerate(a, 1):
zeile = [i]
minimum = i
for j, wb in enumerate(b, 1):
d = min(vorher[j] + 1, zeile[j - 1] + 1, vorher[j - 1] + (wa != wb))
zeile.append(d)
minimum = min(minimum, d)
if minimum > limit:
return limit + 1
vorher = zeile
return vorher[-1]
def _negationen(woerter: list[str]) -> frozenset[str]:
return frozenset(w for w in woerter if w in _NEGATION or w.startswith("nicht"))
def _wort_span(text: str, zitat: str) -> tuple[int, int] | None:
"""Bestes Fenster im Text mit minimaler WORT-Edit-Distanz zum Zitat.
Kandidaten über die seltensten Zitat-Wörter (kein Voll-Scan); Schranken:
Fehlerquote, Eindeutigkeits-Guard, Negations-Gleichheit
(Falsch-Anker >> fehlender Anker)."""
ndl, _ = _wort_folge(zitat)
if len(ndl) < WORT_MIN:
return None
hay, spans = _wort_folge(text)
limit = max(2, int(len(ndl) * WORT_FEHLERQUOTE))
vorkommen: dict[str, list[int]] = {}
for i, w in enumerate(hay):
vorkommen.setdefault(w, []).append(i)
# seltenste Zitat-Wörter als Kandidaten-Anker (bis 3 Wörter, je ≤ 50 Stellen)
selten = sorted(((len(vorkommen.get(w, [])), idx, w) for idx, w in enumerate(ndl)
if 0 < len(vorkommen.get(w, [])) <= 50))[:3]
starts: set[int] = set()
for _, idx, w in selten:
for p in vorkommen[w]:
for versatz in (-2, -1, 0, 1, 2):
s = p - idx + versatz
if 0 <= s < len(hay):
starts.add(s)
treffer: list[tuple[int, int, int]] = [] # (dist, start, ende)
for s in starts:
for laenge in range(max(WORT_MIN, len(ndl) - 2), len(ndl) + 4):
fenster = hay[s:s + laenge]
if len(fenster) < WORT_MIN:
continue
d = _wort_distanz(ndl, fenster, limit)
if d <= limit:
treffer.append((d, s, s + len(fenster)))
if not treffer:
return None
best = min(treffer)
schranke = best[0] + max(1, int(len(ndl) * 0.08))
for d, s, e in treffer:
if (e <= best[1] or s >= best[2]) and d <= schranke:
return None # mehrdeutig — zwei getrennte, fast gleich gute Stellen
if _negationen(ndl) != _negationen(hay[best[1]:best[2]]):
return None # Negations-Guard: nie „regulär" auf „nicht regulär" ankern
return spans[best[1]][0], spans[best[2] - 1][1]
def titel_kern(titel: str) -> str:
"""Alnum-gefalteter Titel (NFKC + casefold) — gleicher Kern über
Schreibvarianten hinweg (U+2011-Dashes, Spacing, Groß/Klein). Dient als

View File

@@ -106,15 +106,18 @@ def importieren(d: dict) -> None:
z_map = _einfuegen("lernziele", d["lernziele"], soll_id=s_map)
k_map = _einfuegen("kapitel", d["kapitel"])
b_map = _einfuegen("bausteine", d["bausteine"], ziel_id=z_map, kapitel_id=k_map)
# merged_into zeigt auf atome selbst → zweiphasig
a_map = _einfuegen("atome", [{**z, "merged_into": None} for z in d["atome"]],
# merged_into zeigt auf atome selbst → zweiphasig; braucht-Key aus
# Alt-Exporten verwerfen (Spalte existiert nicht mehr)
a_map = _einfuegen("atome", [{**{k: v for k, v in z.items() if k != "braucht"},
"merged_into": None} for z in d["atome"]],
soll_id=s_map, ziel_id=z_map, baustein_id=b_map)
for z in d["atome"]:
if z.get("merged_into") is not None:
db.update("atome", "id", a_map[z["id"]],
merged_into=a_map.get(z["merged_into"]))
_einfuegen("anker", d["anker"], atom_id=a_map, quelle_id=q_map)
_einfuegen("kanten", d["kanten"], von_atom=a_map, zu_atom=a_map)
_einfuegen("kanten", [z for z in d["kanten"] if z.get("art") != "braucht"],
von_atom=a_map, zu_atom=a_map)
ar_map = _einfuegen("artefakte", d["artefakte"], atom_id=a_map)
for z in d["leitner"]: # PK ist artefakt_id — kein eigenes id-Feld
z = dict(z)

View File

@@ -100,6 +100,11 @@ async function stoppen() {
catch (e) { fehler.value = String(e) } // sonst läuft der Lauf weiter, ohne Signal
}
async function pausieren() {
try { fehler.value = ''; await api.pause(topic.value); await stateLaden() }
catch (e) { fehler.value = String(e) }
}
const transferOk = ref(false) // nur im lokalen Dev-Betrieb (Server: 403)
const uebertragung = ref('')
@@ -186,6 +191,8 @@ function runBadge(run) {
</div>
<template v-if="tab === 'board'">
<button @click="starten" :disabled="!topic || state?.run?.status === 'running'">Start</button>
<button class="sekundaer" title="Wartende stoppen, Laufende laufen aus — Resume via Start"
@click="pausieren" :disabled="state?.run?.status !== 'running'">Pause</button>
<button class="sekundaer" @click="stoppen" :disabled="state?.run?.status !== 'running'">Stop</button>
</template>
<template v-if="transferOk && tab === 'board'">

View File

@@ -19,6 +19,7 @@ export const api = {
start: (topic, budget) =>
req(`/api/topics/${e(topic)}/start`, { method: 'POST', body: JSON.stringify({ budget }) }),
stop: (topic) => req(`/api/topics/${e(topic)}/stop`, { method: 'POST', body: '{}' }),
pause: (topic) => req(`/api/topics/${e(topic)}/pause`, { method: 'POST', body: '{}' }),
sollReset: (topic) => req(`/api/topics/${e(topic)}/soll-reset`, { method: 'POST', body: '{}' }),
vollReset: (topic) => req(`/api/topics/${e(topic)}/voll-reset`, { method: 'POST', body: '{}' }),
setAuto: (topic, ebene, an) =>

View File

@@ -6,6 +6,10 @@ WÖRTLICH (exakt, Zeichen für Zeichen, 540 Wörter). Regeln:
- Nur melden, wenn die Passage das Atom wirklich belegt. Steht ein Atom nicht in
diesem Abschnitt: weglassen.
- Nichts paraphrasieren, nichts erfinden. Ids wörtlich übernehmen.
- Zitat-Disziplin: auch LaTeX-Markup unverändert mitkopieren ($…$, \textsc{{…}},
\item) — NICHT in Symbole übersetzen. Bei Listen/mehrschrittigen Verfahren
GENAU EINEN zusammenhängenden Ausschnitt zitieren (Einleitungssatz oder ein
Listenelement) — niemals mehrere Stellen verbinden oder zusammenfassen.
ATOME:
{atome}

View File

@@ -9,6 +9,7 @@ Regeln:
Aufgabe (konkrete Zahlen, erfundene Namen, Story-Verkleidungen, Punktzahlen).
Beispiel: Die Aufgabe „Zeigen Sie, dass Problem X mit deg ≥ 42 einen Hamiltonkreis…"
ergibt das Atom „NP-Vollständigkeit des Hamiltonkreis-Problems" — nie „X mit deg ≥ 42".
KEIN LaTeX-Markup im Titel — Formeln verbal benennen.
- Aufgabeninstanzen, Aufgabennummern, Punktzahlen sind KEINE Atome und gehören in
kein Feld außer dem Zitat.
- „typ": begriff | aussage | verfahren.
@@ -16,15 +17,18 @@ Regeln:
| S = Feinheiten/Spezialfälle. Relativ zum restlichen Stoff einstufen.
- „zitat": die Aufgabenstelle WÖRTLICH kopiert (540 Wörter) — sie ist der Beleg,
dass dieses Konzept geübt wird.
- Zitat-Disziplin: auch LaTeX-Markup unverändert mitkopieren ($…$, \textsc{{…}},
\item) — NICHT in Symbole übersetzen. Bei Listen/mehrschrittigen Verfahren
GENAU EINEN zusammenhängenden Ausschnitt zitieren (Einleitungssatz oder ein
Listenelement) — niemals mehrere Stellen verbinden oder zusammenfassen.
- Prüft eine Aufgabe mehrere Konzepte, wird jedes ein eigenes Atom.
- SPRACHE: „titel" und „definition" IMMER auf Deutsch (fremdsprachige Quellen
sinngemäß übersetzen; etablierte Fachtermini bleiben). Nur das „zitat" bleibt original.
- Arbeite AUSSCHLIESSLICH mit dem Quelltext, nichts erfinden.
- „braucht": Titel anderer Atome DIESES Auszugs, die Voraussetzung sind. Leer, wenn keine.
QUELLTEXT:
{text}
Antworte NUR mit JSON (keine Code-Fences, keine Prosa, KEINE Datei):
[{{"titel": "…", "typ": "aussage", "definition": "…", "zitat": "…", "level": "E", "braucht": []}},
{{"titel": "…", "typ": "verfahren", "definition": "…", "zitat": "…", "level": "M", "braucht": []}}]
[{{"titel": "…", "typ": "aussage", "definition": "…", "zitat": "…", "level": "E"}},
{{"titel": "…", "typ": "verfahren", "definition": "…", "zitat": "…", "level": "M"}}]

View File

@@ -14,22 +14,25 @@ KEINE Atome. Regeln:
Quellen sinngemäß übersetzen. Etablierte Fachtermini ohne deutsche Entsprechung
bleiben original. Nur das „zitat" bleibt in der Quellsprache.
- „titel": Konzeptname aus den Begriffen der Quelle. Katalognummern („Satz 7.13") sind
KEIN Titel; kein Lehrbuch-Oberbegriff, der nicht im Text steht.
KEIN Titel; kein Lehrbuch-Oberbegriff, der nicht im Text steht. KEIN
LaTeX-Markup im Titel — Formeln verbal benennen.
- „definition": 12 Sätze, eigenständig verständlich (dient später dem Dubletten-Vergleich).
- „zitat": die Kernpassage WÖRTLICH aus dem Quelltext kopiert (exakt, Zeichen für Zeichen,
540 Wörter). Ohne wörtliches Zitat kein Atom.
- Zitat-Disziplin: auch LaTeX-Markup unverändert mitkopieren ($…$, \textsc{{…}},
\item) — NICHT in Symbole übersetzen. Bei Listen/mehrschrittigen Verfahren
GENAU EINEN zusammenhängenden Ausschnitt zitieren (Einleitungssatz oder ein
Listenelement) — niemals mehrere Stellen verbinden oder zusammenfassen.
- „level": E = Grundgerüst (Kernbegriffe/Kernaussagen, die man beim ERSTEN Durchgang
durchs Thema braucht) | M = Standardstoff (Sätze, Verfahren, Standardbeweise)
| S = Feinheiten (Spezialfälle, Beweisdetails, Optimierungen). Relativ zum
restlichen Stoff des Auszugs einstufen — Stufen sind Komplexitätsschichten,
keine Bauchgefühl-Schwierigkeit.
- „braucht": Titel anderer Atome DIESES Auszugs, die man vorher verstanden haben muss
(nur echte Voraussetzungen, keine bloße Verwandtschaft). Leer, wenn keine.
- Vollständigkeit zählt: JEDES lernbare Konzept des Auszugs wird ein Atom.
QUELLTEXT:
{text}
Antworte NUR mit JSON (keine Code-Fences, keine Prosa, KEINE Datei):
[{{"titel": "…", "typ": "begriff", "definition": "…", "zitat": "…", "level": "E", "braucht": []}},
{{"titel": "…", "typ": "aussage", "definition": "…", "zitat": "…", "level": "M", "braucht": ["…"]}}]
[{{"titel": "…", "typ": "begriff", "definition": "…", "zitat": "…", "level": "E"}},
{{"titel": "…", "typ": "aussage", "definition": "…", "zitat": "…", "level": "M"}}]

View File

@@ -5,6 +5,10 @@ Der QUELLTEXT unten enthält die Belegstelle dieses Punkts. Extrahiere die Atome
(kleinste lernbare Einheiten) GENAU ZU DIESEM PUNKT. Gleiche Regeln wie immer:
- typ: begriff | aussage | verfahren. Beispiele/Aufgaben sind keine Atome.
- „zitat": Kernpassage WÖRTLICH kopiert (540 Wörter). Ohne wörtliches Zitat kein Atom.
- Zitat-Disziplin: auch LaTeX-Markup unverändert mitkopieren ($…$, \textsc{{…}},
\item) — NICHT in Symbole übersetzen. Bei Listen/mehrschrittigen Verfahren
GENAU EINEN zusammenhängenden Ausschnitt zitieren (Einleitungssatz oder ein
Listenelement) — niemals mehrere Stellen verbinden oder zusammenfassen.
- Nur was im Text steht, nichts erfinden. Keine Katalognummern als Titel.
QUELLTEXT:

View File

@@ -5,6 +5,8 @@ Katalognummern („Satz 6.26") oder abgerissene Fragmente.
Gib jedem Atom einen sauberen Titel:
- Konzeptname aus der Definition, 26 Wörter, auf Deutsch.
- KEINE Nummern, keine Quellen-Referenzen, keine Satzzeichen am Ende.
- KEIN LaTeX-Markup ($…$, Backslash-Befehle) — Formeln verbal benennen
(„Variablen in $\alpha_k$" wird „alpha-Variablen der Kodierung").
- Der Titel muss das Konzept eindeutig benennen (dient als Verweis-Anker).
ATOME:

View File

@@ -0,0 +1,17 @@
<!-- template:Baustein-Ordnung -->
Du bist Dozent und legst die Lehr-Reihenfolge dieser Lernbausteine fest.
Durchgang: {durchgang}
BAUSTEINE (aktuell in der Reihenfolge des Quellmaterials):
{bausteine}
Regeln:
- Grundbegriffe und Definitionen VOR ihrer ersten Verwendung.
- Was ein Baustein inhaltlich voraussetzt, muss vorher behandelt sein.
- Die Quellreihenfolge ist ein guter Ausgangspunkt — weiche nur mit
didaktischem Grund ab.
{hinweis}
Antworte NUR mit JSON (keine Code-Fences): ALLE Nummern in Lehr-Reihenfolge,
jede genau einmal.
[1, 2, 3]

View File

@@ -1,23 +0,0 @@
<!-- template:Braucht-Aufloesung -->
Unten Voraussetzungs-Angaben aus Lern-Atomen. Jede nennt eine PHRASE (ein
Konzept, das ein Atom voraussetzt) und eine Liste KANDIDATEN-Atome (id, Titel,
Definition), die dieses Konzept meinen KÖNNTEN.
Entscheide je Angabe: Welches Kandidaten-Atom DEFINIERT genau das Konzept, das die
Phrase bezeichnet? Antworte mit dessen `atom`-id — oder `null`, wenn keines eindeutig
passt.
Regeln:
- Es geht um KONZEPT-Gleichheit, nicht Wortgleichheit. „Approximationsalgorithmus"
(Kompositum) und „Approximativer Algorithmus" (Phrase) sind dasselbe Konzept.
- Wähle das Atom, das die Phrase als eigenen GEGENSTAND definiert — nicht ein Atom,
das sie nur benutzt oder einen Spezialfall behandelt.
- Genau EIN Treffer je Phrase. Passt keiner klar, oder passen mehrere gleich gut →
`null`. Eine falsche Kante verschiebt Reihenfolge und Level; im Zweifel `null`.
- Antworte für JEDE Angabe.
ANGABEN:
{angaben}
Antworte NUR mit JSON (keine Code-Fences):
[{{"phrase": 1, "atom": 2065}}, {{"phrase": 2, "atom": null}}]

View File

@@ -1,12 +0,0 @@
<!-- template:Kanten-Zyklus -->
Der Voraussetzungs-Graph enthält einen Zyklus (unten die Kanten, Format
„von→zu: X braucht Y").
Wähle die SCHWÄCHSTE Kante — die, deren Voraussetzungs-Behauptung am wenigsten
zwingend ist (eher Verwandtschaft als echte Voraussetzung). Genau eine.
ZYKLUS:
{kanten}
Antworte NUR mit JSON (keine Code-Fences), mit den ids der gewählten Kante:
{{"von": 12, "zu": 7}}

View File

@@ -31,9 +31,9 @@ async def test_nur_ohne_sieht_fehlende_flashcard():
"""Ein lebendes Beispiel darf die Flashcard-Nachgenerierung nicht blockieren."""
topic = topic_anlegen("fcfehlt")
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
status="neu", soll_id=1, braucht=db.j([]))
status="neu", soll_id=1)
b = db.insert("atome", topic=topic, titel="Y", typ="begriff", definition="d",
status="neu", soll_id=1, braucht=db.j([]))
status="neu", soll_id=1)
# a: nur ein Beispiel, keine Flashcard → muss nachgeneriert werden
db.insert("artefakte", atom_id=a, typ="beispiel", status="verifiziert",
inhalt=db.j({"text": "Beispiel"}))
@@ -50,7 +50,7 @@ async def test_reverify_ausfall_laesst_kandidat(monkeypatch):
topic = topic_anlegen("reverify")
run = run_anlegen(topic)
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
status="neu", braucht=db.j([]))
status="neu")
k = db.insert("artefakte", atom_id=a, typ="flashcard", status="kandidat",
inhalt=db.j({"frage": "Was ist X?", "antwort": "Y", "text": ""}))
# Fix liefert saubere Karte; Re-Verify antwortet ohne Eintrag zur id → kein Urteil
@@ -68,7 +68,7 @@ async def test_guard_verwirft_kandidat_und_repair_kann_nachlegen():
topic = topic_anlegen("guard")
run = run_anlegen(topic)
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
status="neu", braucht=db.j([]))
status="neu")
k = db.insert("artefakte", atom_id=a, typ="flashcard", status="kandidat",
inhalt=db.j({"frage": "Was steht im Beleg?", "antwort": "x", "text": ""}))
ctx = llm.Kontext(run, topic, "minimax")
@@ -103,7 +103,7 @@ def test_referenziert_quelle_ignoriert_code():
def test_formen_gate_verwirft_kaputte_tabelle():
topic = topic_anlegen("formgate")
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
status="neu", braucht=db.j([]))
status="neu")
gut = db.insert("artefakte", atom_id=a, typ="beispiel", status="kandidat",
inhalt=db.j({"form": "tabelle", "tabelle": "| A | B |\n|---|---|\n| 1 | 2 |"}))
schlecht = db.insert("artefakte", atom_id=a, typ="beispiel", status="kandidat",
@@ -118,7 +118,7 @@ def test_beispiel_einsetzen_tabelle():
topic = topic_anlegen("bsp-tab")
b = db.insert("bausteine", topic=topic, ziel_id=1, titel="B", ord=0, status="neu")
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
status="neu", baustein_id=b, ord=0, braucht=db.j([]))
status="neu", baustein_id=b, ord=0)
md = "| A | B |\n|---|---|\n| 1 | 2 |"
art = db.insert("artefakte", atom_id=a, typ="beispiel", status="verifiziert",
inhalt=db.j({"form": "tabelle", "tabelle": md}))
@@ -141,7 +141,7 @@ def test_code_syntax_gate():
def test_formen_gate_verwirft_kaputten_code():
topic = topic_anlegen("codegate")
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
status="neu", braucht=db.j([]))
status="neu")
gut = db.insert("artefakte", atom_id=a, typ="beispiel", status="kandidat",
inhalt=db.j({"form": "code", "sprache": "python", "code": "print(1+1)"}))
schlecht = db.insert("artefakte", atom_id=a, typ="beispiel", status="kandidat",
@@ -156,7 +156,7 @@ async def test_guide_messen_toter_beispiel_marker():
topic = topic_anlegen("bspqa")
b = db.insert("bausteine", topic=topic, ziel_id=1, titel="B", ord=0, status="neu")
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
status="neu", baustein_id=b, ord=0, braucht=db.j([]))
status="neu", baustein_id=b, ord=0)
db.insert("lernziele", topic=topic, id=1, text="Z", status="aktiv")
db.insert("sections", baustein_id=b, stage="done", text_kompakt="- k",
text_lang=f"<!-- atom: {a} | X -->\n<!-- beispiel: 777 -->\n" + "Wort " * 60)

View File

@@ -14,7 +14,7 @@ def _baustein(topic, lang_extra=""):
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
status="neu", baustein_id=b_id, braucht=db.j([]))
status="neu", baustein_id=b_id)
lang = f"<!-- atom: {a} | A -->\n" + ("Wort " * 60).strip() + lang_extra
db.insert("sections", baustein_id=b_id, stage="pruefer", text_lang=lang,
text_kompakt="- p")

View File

@@ -177,7 +177,7 @@ def test_det_auftraege_blockquote_und_artefakt():
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
status="neu", baustein_id=b_id, braucht=db.j([]))
status="neu", baustein_id=b_id)
lang = (f"<!-- atom: {a} | A -->\n" + "Fließtext. " * 45
+ "\n> wörtliches Rohzitat aus der Quelle\nEs gilt u 6= v."
+ "\n\nDas Blank-Symbol $[. ist speziell und liegt in $[ \\in \\Gamma$.")
@@ -197,7 +197,7 @@ def test_det_auftraege_neue_checks():
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
status="neu", baustein_id=b_id, braucht=db.j([]))
status="neu", baustein_id=b_id)
wand = "Wort " * 130
lang = (f"<!-- atom: {a} | A -->\n<!-- atom: 99999 | Fremd -->\n"
"Der Beweis folgt aus Satz 6.25, dazu später mehr.\n\n"
@@ -231,7 +231,7 @@ async def test_fix_fehlschlag_behaelt_auftraege(monkeypatch):
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
status="neu", baustein_id=b_id, braucht=db.j([]))
status="neu", baustein_id=b_id)
db.insert("sections", baustein_id=b_id, stage="fix", text_lang="alt", text_kompakt="")
au = db.insert("auftraege", baustein_id=b_id, art="falsch", detail="y")
# Fix liefert Text OHNE Marker → Marker-Invariante bricht → Fehlschlag-Pfad
@@ -256,9 +256,9 @@ def test_marker_titel_erzeugen_keine_auftraege():
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
a1 = db.insert("atome", topic=topic, titel="Folge a_n", typ="begriff", definition="d",
status="neu", baustein_id=b_id, braucht=db.j([]))
status="neu", baustein_id=b_id)
a2 = db.insert("atome", topic=topic, titel="A*-Suche", typ="begriff", definition="d",
status="neu", baustein_id=b_id, braucht=db.j([]))
status="neu", baustein_id=b_id)
lang = (f"<!-- atom: {a1} | Folge a_n -->\n<!-- atom: {a2} | A*-Suche -->\n"
+ "Sauberer Fließtext ohne Formeln. " * 12)
joined = " ".join(guide._det_auftraege(topic, {"id": b_id, "ord": 0}, lang, ""))
@@ -293,7 +293,7 @@ async def test_auftrag_offen_in_messen():
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
status="neu", baustein_id=b_id, braucht=db.j([]))
status="neu", baustein_id=b_id)
lang = f"<!-- atom: {a} | A -->\n" + "Wort " * 60
db.insert("sections", baustein_id=b_id, stage="done", text_lang=lang, text_kompakt="- p")
db.insert("auftraege", baustein_id=b_id, art="luecke", detail="Atom-Inhalt fehlt")
@@ -320,7 +320,7 @@ async def test_reparieren_bewegt_false_bei_identischem_text(monkeypatch):
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
status="neu", baustein_id=b_id, braucht=db.j([]))
status="neu", baustein_id=b_id)
lang = f"<!-- atom: {a} | A -->\n" + ("Wort " * 60).strip() # ohne Trailing-Space
db.insert("sections", baustein_id=b_id, stage="fix", text_lang=lang,
text_kompakt="- p")
@@ -413,7 +413,7 @@ async def test_fix_cap_friert_section_ein(monkeypatch):
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
b = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
status="neu", baustein_id=b, braucht=db.j([]))
status="neu", baustein_id=b)
lang = f"<!-- atom: {a} | A -->\n" + "Wort " * 60
db.insert("sections", baustein_id=b, stage="fix", text_lang=lang, text_kompakt="- p",
fix_versuche=guide.FIX_MAX_VERSUCHE) # schon am Cap
@@ -433,7 +433,7 @@ async def test_marker_platzieren_deterministisch():
topic = topic_anlegen("platz")
b = db.insert("bausteine", topic=topic, ziel_id=1, titel="B", ord=0, status="neu")
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
status="neu", baustein_id=b, ord=0, braucht=db.j([]))
status="neu", baustein_id=b, ord=0)
art = db.insert("artefakte", atom_id=a, typ="beispiel", status="verifiziert",
inhalt=db.j({"form": "tabelle", "tabelle": "| A | B |\n|---|---|\n| 1 | 2 |"}))
lang = f"#### X\n<!-- atom: {a} | X -->\nErklärung von X.\n\nWeiterer Absatz zum Thema."
@@ -444,3 +444,90 @@ async def test_marker_platzieren_deterministisch():
# idempotent: zweiter Lauf dupliziert nicht
assert guide._marker_platzieren(db.one("SELECT * FROM bausteine WHERE id=?", (b,)),
out).count("<!-- beispiel:") == 1
def test_finde_zitat_latex_gerendert():
"""tex-Quellen: das Modell zitiert die gerenderte Lesart (k ∈ ), die Quelle
trägt Markup ($k \\in \\mathbb{N}$) — die Faltung macht beide gleich."""
text = ("Für das \\textsc{VertexCover} Problem ist neben einem Graphen $G$ eine"
" Zahl $k \\in \\mathbb{N}_{\\geq 0}$ gegeben und es wird gefragt, ob ein"
" Vertex Cover mit Größe höchstens $k$ in $G$ existiert.")
zitat = ("Für das VertexCover Problem ist neben einem Graphen G eine Zahl k ∈ "
" gegeben und es wird gefragt, ob ein Vertex Cover mit Größe höchstens k"
" in G existiert.")
span = textkit.finde_zitat(text, zitat)
assert span is not None and text[span[0]:span[1]].startswith("Für das")
# griechische Befehle ↔ Unicode-Buchstaben
text2 = ("Ein Wort $w \\in \\Sigma^*$ über dem Alphabet $\\Sigma$ ist eine"
" endliche Folge von Zeichen aus dem Alphabet.")
zitat2 = ("Ein Wort w ∈ Σ* über dem Alphabet Σ ist eine endliche Folge von"
" Zeichen aus dem Alphabet.")
assert textkit.finde_zitat(text2, zitat2) is not None
# LaTeX-Zitat gegen LaTeX-Quelle bleibt symmetrisch matchbar
assert textkit.finde_zitat(text, "Zahl $k \\in \\mathbb{N}_{\\geq 0}$ gegeben und"
" es wird gefragt, ob ein Vertex Cover") is not None
def test_finde_zitat_kurz_nur_bei_eindeutigkeit():
"""Kurze Zitate (gefaltet 1019 Zeichen) matchen nur bei GENAU EINEM
Vorkommen — Eindeutigkeit direkt geprüft statt Proxy-Länge."""
text = ("Vorher steht Text. Definiere $\\alpha_{\\text{Anfang}} = z_{00} \\wedge"
" s_{00}$ als Startbelegung. Nachher steht mehr.")
zitat = "α_Anfang = z_00 ∧ s_00" # gerendert, gefaltet 13 Zeichen
span = textkit.finde_zitat(text, zitat)
assert span is not None and "alpha" in text[span[0]:span[1]]
# zweites Vorkommen → mehrdeutig → None
assert textkit.finde_zitat(
text + " Wieder $\\alpha_{\\text{Anfang}} = z_{00} \\wedge s_{00}$.", zitat) is None
# unter 10 gefalteten Zeichen: nie
assert textkit.finde_zitat("Es gilt x=y hier.", "x = y") is None
def test_jsonx_repariert_zeilenumbruch_in_string():
# rohes mehrzeiliges Zitat (Markup-Zitat-Regel) → Kontrollzeichen-Reparatur
assert jsonx.parse('[{"zitat": "\\\\item Zeile eins\nZeile zwei"}]') == [
{"zitat": "\\item Zeile eins\nZeile zwei"}]
assert jsonx.parse('{"a": "x\n\ny\tz"}') == {"a": "x\n\ny\tz"}
# intaktes JSON bleibt unverändert
assert jsonx.parse('{"a": "x\\nb"}') == {"a": "x\nb"}
def test_finde_zitat_ueber_listenstart():
"""\\begin{itemize} darf kein „itemize"-Residuum in der Faltung lassen."""
text = ("Ein Algorithmus löst das Problem, wenn für alle $x \\in U$ gilt:\n"
"\\begin{itemize}\n\\item $x \\in L$ impliziert $A(x) = 1$,\n"
"\\item $x \\notin L$ impliziert $A(x) = 0$.\n\\end{itemize}")
zitat = ("Ein Algorithmus löst das Problem, wenn für alle x ∈ U gilt: x ∈ L"
" impliziert A(x) = 1, x ∉ L impliziert A(x) = 0.")
assert textkit.finde_zitat(text, zitat) is not None
def test_finde_zitat_wortalignment_verbalisierung():
"""Stufe 5: Modell verbalisiert Mathe (\\cdot → „mal", \\sqrt → sqrt()) —
auf Wort-Ebene wenige Edits → Treffer trotz Zeichen-Chaos."""
text = ("Angenommen es gäbe einen Algorithmus, der VertexCover in "
"$2^{o(|V'|)} \\cdot |I|^{O(1)}$ löst. Durch die Kombination der "
"Reduktionen entsteht ein Widerspruch zur ETH und damit die Aussage.")
zitat = ("Angenommen es gäbe einen Algorithmus, der VertexCover in "
"2^{o(|V'|)} mal |I|^{O(1)} löst. Durch die Kombination der "
"Reduktionen entsteht ein Widerspruch zur ETH und damit die Aussage.")
span = textkit.finde_zitat(text, zitat)
assert span is not None and text[span[0]:span[1]].startswith("Angenommen")
def test_finde_zitat_wortalignment_paraphrase_bleibt_draussen():
"""Echte Umformulierung: Wörter selbst anders → Wort-Distanz über Schranke."""
text = ("Dann wird geprüft, ob $|X| \\leq k$ gilt und es wird abgelehnt, "
"falls es nicht gilt. Danach wird ein Graph konstruiert, in dem alle "
"Knoten entfernt wurden und die Suche neu beginnt.")
zitat = ("Prüfe, ob |X| ≤ k, sonst ablehnen. Konstruiere den Graphen ohne "
"diese Knoten und starte die Suche von vorn im Verfahren.")
assert textkit.finde_zitat(text, zitat) is None
def test_finde_zitat_wortalignment_negations_guard():
text = ("Die Sprache ist in diesem Modell regulär und wird von einem "
"endlichen Automaten mit wenigen Zuständen ohne Keller akzeptiert.")
zitat = ("Die Sprache ist in diesem Modell nicht regulär und wird von einem "
"endlichen Automaten mit wenigen Zuständen ohne Keller akzeptiert.")
assert textkit.finde_zitat(text, zitat) is None

View File

@@ -14,7 +14,7 @@ def _quelle(topic, tmp_path, text, name="q.md"):
def _atom(topic):
return db.insert("atome", topic=topic, titel="T", typ="verfahren",
definition="d", status="neu", braucht=db.j([]))
definition="d", status="neu")
def test_fenster_umgibt_anker(tmp_path):

View File

@@ -184,3 +184,101 @@ async def test_inhaltsfehler_kein_laufabbruch(monkeypatch):
res = await llm.call(ctx, stage="soll", template="Korpus-Soll",
werte={"topic": topic, "quelle": "q", "text": "x"}, erwartet=list)
assert res is None # begrenzte Restarts, dann None — kein Absturz, keine Pause
async def test_stop_cancelt_verwaisten_haupt_task(monkeypatch):
"""Stop cancelt den Wartenden — der ensure_future-Haupt-Task muss MIT
sterben, sonst hält er Slot + Tokens bis zum Timeout (aak: 10 Waisen)."""
import asyncio
import agents
import llm
from conftest import run_anlegen, topic_anlegen
topic = topic_anlegen("orphan")
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
ctx.ebene = "inventar"
gestartet = asyncio.Event()
gecancelt = asyncio.Event()
async def langsamer_agent(key, prompt, timeout, **kw):
gestartet.set()
try:
await asyncio.sleep(60)
except asyncio.CancelledError:
gecancelt.set()
raise
return agents.AgentErgebnis(0, "nie", "")
monkeypatch.setattr(agents, "run_agent", langsamer_agent)
monkeypatch.setattr(llm, "HEDGE_NACH_S", 0) # Pfad: return await haupt
task = asyncio.ensure_future(llm._roher_call("k", "p", 10, ctx, "judge", "none"))
await gestartet.wait()
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
await asyncio.wait_for(gecancelt.wait(), timeout=2) # Waise wurde mitgecancelt
async def test_pause_drainiert_statt_zu_canceln(monkeypatch):
"""ManuellePause in llm.alle: Laufende laufen zu Ende (Ergebnis zählt),
nichts wird gecancelt."""
import asyncio
import llm
fertig = {"langsam": False}
async def schnell_pausiert():
raise llm.ManuellePause("manuell pausiert")
async def langsam():
await asyncio.sleep(0.05)
fertig["langsam"] = True
import pytest
with pytest.raises(llm.ManuellePause):
await llm.alle([schnell_pausiert(), langsam()])
assert fertig["langsam"] is True # NICHT gecancelt — ausgelaufen
async def test_pause_beendet_lauf_als_paused():
"""Pause-Flag → nächster Agent liefert „pausiert" → Lauf endet 'paused';
Start setzt normal fort (Flag wird aufgehoben)."""
import db
import pipeline
from conftest import topic_anlegen
topic = topic_anlegen("pausetest")
run_id = pipeline.lauf_starten(topic)
pipeline.lauf_pausieren(topic) # vor dem ersten Call — deterministisch
await pipeline._laeufe[topic]
run = db.one("SELECT * FROM runs WHERE id=?", (run_id,))
assert run["status"] == "paused" and "pausiert" in run["grund"]
# Resume: Start hebt die Pause auf, Lauf läuft durch
pipeline.lauf_starten(topic)
await pipeline._laeufe[topic]
assert db.one("SELECT status FROM topics WHERE name=?", (topic,))["status"] == "fertig"
async def test_output_cap_ohne_neuversuch(monkeypatch):
"""stop=max_tokens ist deterministisch — sofort None statt 2 Neuversuche
(der Aufrufer halbiert den Chunk)."""
import agents
import db
import llm
from conftest import run_anlegen, topic_anlegen
topic = topic_anlegen("cap")
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
ctx.ebene = "inventar"
aufrufe = {"n": 0}
async def cap_agent(key, prompt, timeout, **kw):
aufrufe["n"] += 1
return agents.AgentErgebnis(1, "", "leere Antwort (stop=max_tokens)",
{"input": 100, "output": 32000})
monkeypatch.setattr(agents, "run_agent", cap_agent)
monkeypatch.setattr(llm, "HEDGE_NACH_S", 0)
res = await llm.call(ctx, stage="extraktion", template="Atom-Extraktion",
werte={"topic": topic, "quelle": "q", "text": "x"}, erwartet=list)
assert res is None and aufrufe["n"] == 1 # genau EIN Versuch
ev = db.one("SELECT status, tok_out FROM events WHERE run_id=? AND stage='extraktion'",
(ctx.run_id,))
assert ev["status"] == "cap" and ev["tok_out"] == 32000

View File

@@ -10,18 +10,18 @@ from conftest import run_anlegen, topic_anlegen
def test_merge_kollision_und_kette():
topic = topic_anlegen("mergekante")
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="lang genug",
status="neu", braucht=db.j([]))
status="neu")
b = db.insert("atome", topic=topic, titel="B", typ="begriff", definition="kurz",
status="neu", braucht=db.j([]))
status="neu")
c = db.insert("atome", topic=topic, titel="C", typ="begriff", definition="x",
status="neu", braucht=db.j([]))
status="neu")
# beide haben dieselbe Kante zu C → blindes Umhängen würde UNIQUE verletzen
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'verwandt')",
(topic, a, c))
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'verwandt')",
(topic, b, c))
# und eine Kante zwischen den Merge-Partnern → würde Selbstkante
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'verwandt')",
(topic, b, a))
inventar._merge(topic, a, b)
kanten = db.query("SELECT * FROM kanten WHERE topic=?", (topic,))
@@ -39,7 +39,7 @@ async def test_anker_rematch_ohne_llm(tmp_path):
q = db.insert("quellen", topic=topic, art="datei", titel="q",
snapshot=str(snap), hash="h", status="atome")
a = db.insert("atome", topic=topic, titel="T", typ="begriff", definition="d",
status="ohne_anker", braucht=db.j([]))
status="ohne_anker")
# Extraktion fand das Zitat nicht (Whitespace-Differenz), hat es aber gespeichert
db.insert("anker", atom_id=a, quelle_id=q, start=-1, ende=-1,
zitat="Der Satz steht hier drin.")
@@ -67,7 +67,7 @@ async def test_resume_liest_teilextrahierte_quelle_weiter(tmp_path):
q = db.insert("quellen", topic=topic, art="datei", titel="q",
snapshot=str(snap), hash="h", status="extrahiert", atome_stand="")
a = db.insert("atome", topic=topic, titel="Alt", typ="begriff", definition="d",
status="neu", braucht=db.j([]))
status="neu")
db.insert("anker", atom_id=a, quelle_id=q, start=0, ende=5, zitat="Absatz")
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "inventar"
@@ -94,7 +94,7 @@ async def test_stichentscheid_braucht_zwei_stimmen(monkeypatch):
def neu_atom():
return db.insert("atome", topic=topic, titel="X", typ="begriff",
definition="d", status="neu", braucht=db.j([]))
definition="d", status="neu")
def panel_mit(votes):
async def _p(ctx, groesse, **kw):
@@ -127,7 +127,7 @@ async def test_anker_batch_ausfall_verwirft_nicht(monkeypatch, tmp_path):
q = db.insert("quellen", topic=topic, art="datei", titel="q",
snapshot=str(snap), hash="h", status="atome")
a = db.insert("atome", topic=topic, titel="T", typ="begriff", definition="d",
status="ohne_anker", braucht=db.j([]))
status="ohne_anker")
db.insert("anker", atom_id=a, quelle_id=q, start=-1, ende=-1,
zitat="Ein Zitat das nirgends im Quelltext steht und lang genug ist.")
monkeypatch.setitem(fake_agents._HANDLER, "Atom-Anker-Fix-Batch", lambda p: {})
@@ -174,14 +174,20 @@ async def test_titel_dublette_wird_gemerged_trotz_ferner_definition():
a = db.insert("atome", topic=topic, titel="VERTEX COVER Problem", typ="begriff",
definition="Entscheidungsproblem, ob ein Graph ein Vertex Cover"
" der Größe höchstens k enthält.",
status="neu", braucht=db.j([]))
status="neu")
b = db.insert("atome", topic=topic, titel="VERTEX COVER Problem", typ="begriff",
definition="Gefragt wird nach einer Knotenmenge, die jede Kante"
" abdeckt und maximal k Elemente hat.",
status="neu", braucht=db.j([]))
status="neu")
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "inventar"
await inventar._judge_dedup(ctx)
stati = {r["id"]: r["status"] for r in db.query(
"SELECT id, status FROM atome WHERE topic=?", (topic,))}
assert sorted(stati.values()) == ["gemerged", "neu"]
def test_titel_kaputt_flaggt_latex():
assert inventar._titel_kaputt("Variablen in $\\alpha_k$")
assert inventar._titel_kaputt("Der \\textsc{VC}-Beweis")
assert not inventar._titel_kaputt("Vertex Cover und Entscheidungsvariante")

View File

@@ -8,7 +8,7 @@ from conftest import topic_anlegen
def _setup(topic):
b = db.insert("bausteine", topic=topic, ziel_id=1, titel="B", ord=0, status="neu")
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
status="neu", baustein_id=b, ord=0, braucht=db.j([]))
status="neu", baustein_id=b, ord=0)
db.insert("artefakte", atom_id=a, typ="flashcard", status="verifiziert",
inhalt=db.j({"frage": "F1", "antwort": "A1"}))
db.insert("artefakte", atom_id=a, typ="flashcard", status="kandidat", # unverifiziert

View File

@@ -1,7 +1,8 @@
"""Struktur-Ebene: Topo-Sortierung, Zyklenbruch, Band-Schnitt — mit synthetischen Atomen."""
"""Struktur-Ebene: Band-Schnitt, Judge-Ordnung mit Quellpositions-Prior,
Kapitel — mit synthetischen Atomen."""
import db
import inventar
import fake_agents
import llm
import struktur
from conftest import run_anlegen, topic_anlegen
@@ -10,7 +11,7 @@ from conftest import run_anlegen, topic_anlegen
def _atom(topic, titel, ziel_id=None, soll_id=1):
return db.insert("atome", topic=topic, titel=titel, typ="begriff",
definition=f"Definition {titel}", status="neu",
soll_id=soll_id, ziel_id=ziel_id, braucht=db.j([]))
soll_id=soll_id, ziel_id=ziel_id)
def _baustein_groessen(topic):
@@ -29,7 +30,7 @@ async def test_band_split_43_atome():
_atom(topic, f"A{i}", ziel, soll)
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
struktur._bausteine_schneiden(ctx)
await struktur._bausteine_schneiden(ctx)
groessen = _baustein_groessen(topic)
assert sum(groessen) == 43
assert all(struktur.BAUSTEIN_MIN_ATOME <= n <= struktur.BAUSTEIN_MAX_ATOME
@@ -51,7 +52,7 @@ async def test_band_messen_konsistent_mit_schnitt():
_atom(topic, f"G{i}", z2, soll)
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
struktur._bausteine_schneiden(ctx)
await struktur._bausteine_schneiden(ctx)
assert all(4 <= n <= 8 for n in _baustein_groessen(topic))
assert "band" not in {b["art"] for b in struktur.messen(ctx)}
@@ -65,52 +66,10 @@ async def test_einsame_kleingruppe_kein_band():
_atom(topic, f"A{i}", ziel, soll)
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
struktur._bausteine_schneiden(ctx)
await struktur._bausteine_schneiden(ctx)
assert "band" not in {b["art"] for b in struktur.messen(ctx)}
def test_topo_ordnung():
rang = {1: (0, 1), 2: (0, 2), 3: (0, 3)}
# 3 braucht 1, 2 braucht 3 → 1, 3, 2
assert struktur._topo([1, 2, 3], [(3, 1), (2, 3)], rang) == [1, 3, 2]
def test_topo_fremde_kanten_bleiben_draussen():
# Kante zu Atom 3 (nicht in der Gruppe) darf weder 3 hineinziehen
# noch Gruppenmitglieder verdrängen (aak-Bug: 22 Atome ohne Baustein)
rang = {1: (0, 1), 2: (0, 2), 3: (0, 3)}
out = struktur._topo([1, 2], [(1, 3), (2, 1)], rang)
assert out == [1, 2]
def test_zyklus_finden():
assert struktur._finde_zyklus([1, 2], [(1, 2), (2, 1)]) is not None
assert struktur._finde_zyklus([1, 2, 3], [(2, 1), (3, 2)]) is None
async def test_zyklen_brechen_und_schneiden():
topic = topic_anlegen()
run = run_anlegen(topic)
soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([]))
ziel = db.insert("lernziele", topic=topic, text="Kann P", soll_id=soll, status="aktiv")
a = _atom(topic, "A", ziel, soll)
b = _atom(topic, "B", ziel, soll)
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
(topic, a, b))
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
(topic, b, a))
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
await struktur._zyklen_brechen(ctx)
aktiv = db.query("SELECT * FROM kanten WHERE topic=? AND art='braucht' AND status='aktiv'",
(topic,))
assert len(aktiv) == 1 # genau eine Kante gebrochen
struktur._bausteine_schneiden(ctx)
atome = struktur._atome(topic)
assert all(x["baustein_id"] for x in atome)
async def test_band_split():
topic = topic_anlegen("band")
run = run_anlegen(topic)
@@ -119,34 +78,31 @@ async def test_band_split():
for i in range(18): # 18 Atome in EINEM Ziel → muss in ≤8er-Teile splitten
_atom(topic, f"A{i}", ziel, soll)
ctx = llm.Kontext(run, topic, "minimax")
struktur._bausteine_schneiden(ctx)
await struktur._bausteine_schneiden(ctx)
bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))
assert len(bausteine) >= 3
for bs in bausteine:
n = db.one("SELECT COUNT(*) n FROM atome WHERE baustein_id=?", (bs["id"],))["n"]
assert 4 <= n <= 8
befunde = struktur.messen(ctx)
assert not [x for x in befunde if x["art"] in ("band", "partition", "zyklus")]
assert not [x for x in befunde if x["art"] in ("band", "partition")]
async def test_merge_ohne_soll_schranke_und_kapitel():
# Entkopplung: kleines Ziel merged über Soll-Punkt-Grenzen (Partner per Kante);
# Kapitel entstehen danach als kontiguierliche Segmente
# Entkopplung: kleines Ziel merged über Soll-Punkt-Grenzen (Partner per
# Quell-Nähe); Kapitel entstehen danach als kontiguierliche Segmente
topic = topic_anlegen("kapitel")
run = run_anlegen(topic)
s1 = db.insert("soll", topic=topic, punkt="P1", status="bestaetigt", belege=db.j([]))
s2 = db.insert("soll", topic=topic, punkt="P2", status="bestaetigt", belege=db.j([]))
z1 = db.insert("lernziele", topic=topic, text="Kann P1", soll_id=s1, status="aktiv")
z2 = db.insert("lernziele", topic=topic, text="Kann P2", soll_id=s2, status="aktiv")
a = _atom(topic, "A", z1, s1) # 1-Atom-Ziel, anderer Soll-Punkt als z2
b0 = _atom(topic, "B0", z2, s2)
for i in (1, 2, 3):
_atom(topic, "A", z1, s1) # 1-Atom-Ziel, anderer Soll-Punkt als z2
for i in (0, 1, 2, 3):
_atom(topic, f"B{i}", z2, s2)
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
(topic, a, b0))
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
struktur._bausteine_schneiden(ctx)
await struktur._bausteine_schneiden(ctx)
atome = struktur._atome(topic)
assert len({x["baustein_id"] for x in atome}) == 1 # gemerged trotz fremdem Soll
await struktur._kapitel_bilden(ctx)
@@ -155,8 +111,79 @@ async def test_merge_ohne_soll_schranke_und_kapitel():
assert struktur.messen(ctx) == []
async def test_ordnung_judge_permutation(monkeypatch):
"""Der Ordnungs-Judge bestimmt die Lehr-Reihenfolge; Prior ist die
Quellposition (Eingabe-Nummerierung)."""
topic = topic_anlegen("ordjudge")
run = run_anlegen(topic)
soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([]))
ziele = [db.insert("lernziele", topic=topic, text=f"Kann Z{i}", soll_id=soll,
status="aktiv") for i in range(2)]
q = db.insert("quellen", topic=topic, art="datei", titel="s.txt", snapshot="s",
rolle="stoff", status="atome")
for zi, ziel in enumerate(ziele):
for i in range(4):
a = _atom(topic, f"Z{zi}A{i}", ziel, soll)
db.insert("anker", atom_id=a, quelle_id=q, start=zi * 1000 + i, ende=0,
zitat="x")
monkeypatch.setitem(fake_agents._HANDLER, "Baustein-Ordnung",
lambda p: list(reversed(fake_agents._baustein_ordnung(p))))
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
await struktur._bausteine_schneiden(ctx)
bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
# Prior wäre Z0 vor Z1 — der Judge hat umgedreht, und das gilt
assert [b["ziel_id"] for b in bausteine] == [ziele[1], ziele[0]]
assert all(b["ordnung"] == "judge" for b in bausteine)
assert "ordnung_fallback" not in {x["art"] for x in struktur.messen(ctx)}
async def test_ordnung_retry_und_fallback(monkeypatch):
topic = topic_anlegen("ordfall")
run = run_anlegen(topic)
soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([]))
z1 = db.insert("lernziele", topic=topic, text="Z1", soll_id=soll, status="aktiv")
z2 = db.insert("lernziele", topic=topic, text="Z2", soll_id=soll, status="aktiv")
for ziel in (z1, z2):
for i in range(4):
_atom(topic, f"{ziel}A{i}", ziel, soll)
aufrufe = {"n": 0}
def judge(prompt):
aufrufe["n"] += 1
if aufrufe["n"] == 1:
return [1, 1] # keine Permutation
return [2, 1]
monkeypatch.setitem(fake_agents._HANDLER, "Baustein-Ordnung", judge)
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
await struktur._bausteine_schneiden(ctx)
assert aufrufe["n"] == 2 # Retry hat gegriffen
assert all(b["ordnung"] == "judge" for b in
db.query("SELECT * FROM bausteine WHERE topic=?", (topic,)))
# beide Versuche ungültig → Prior-Ordnung + Befund
monkeypatch.setitem(fake_agents._HANDLER, "Baustein-Ordnung", lambda p: ["x"])
await struktur._bausteine_schneiden(ctx)
assert all(b["ordnung"] == "fallback" for b in
db.query("SELECT * FROM bausteine WHERE topic=?", (topic,)))
assert "ordnung_fallback" in {x["art"] for x in struktur.messen(ctx)}
def test_baustein_rang_median_gegen_merge_gift():
"""ETH-Muster: EIN importiertes Atom vom Quellanfang darf den Baustein nicht
nach vorn ziehen — der Median liegt bei den echten Mitgliedern."""
bausteine = [{"id": 1}, {"id": 2}]
atome = [{"id": 10, "baustein_id": 1}, {"id": 11, "baustein_id": 1},
{"id": 12, "baustein_id": 1}, # Merge-Import mit Rang vom Dateianfang
{"id": 20, "baustein_id": 2}, {"id": 21, "baustein_id": 2}]
rang = {10: (1, 47000), 11: (1, 48000), 12: (1, 464),
20: (1, 5000), 21: (1, 6000)}
b_rang = struktur._baustein_rang(bausteine, atome, rang)
assert b_rang[2] < b_rang[1] # trotz 464-Import bleibt Baustein 1 hinten
async def test_kapitel_retry_und_fallback(monkeypatch):
import fake_agents
topic = topic_anlegen("kapfall")
run = run_anlegen(topic)
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
@@ -185,14 +212,13 @@ async def test_kapitel_retry_und_fallback(monkeypatch):
async def test_level_kalibrierung(monkeypatch):
import fake_agents
topic = topic_anlegen("level")
run = run_anlegen(topic)
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
a1 = db.insert("atome", topic=topic, titel="Kern", typ="begriff", definition="d",
level="M", status="neu", ziel_id=ziel, braucht=db.j([]))
level="M", status="neu", ziel_id=ziel)
a2 = db.insert("atome", topic=topic, titel="Detail", typ="aussage", definition="d",
level="M", status="neu", ziel_id=ziel, braucht=db.j([]))
level="M", status="neu", ziel_id=ziel)
def judge(prompt):
return [{"atom": a1, "level": "E"}, {"atom": a2, "level": "S"},
@@ -205,50 +231,6 @@ async def test_level_kalibrierung(monkeypatch):
assert db.one("SELECT level FROM atome WHERE id=?", (a2,))["level"] == "S"
def test_level_konflikt_absenkung():
# v(E) braucht z(S), z braucht w(M) → Kaskade senkt z UND w auf E
topic = topic_anlegen("konflikt")
run = run_anlegen(topic)
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
def atom(titel, level):
return db.insert("atome", topic=topic, titel=titel, typ="begriff",
definition="d", level=level, status="neu", ziel_id=ziel,
braucht=db.j([]))
v, z, w = atom("V", "E"), atom("Z", "S"), atom("W", "M")
for von, zu in ((v, z), (z, w)):
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art)"
" VALUES(?,?,?,'braucht')", (topic, von, zu))
struktur._level_konflikte_loesen(topic)
assert db.one("SELECT level FROM atome WHERE id=?", (z,))["level"] == "E"
assert db.one("SELECT level FROM atome WHERE id=?", (w,))["level"] == "E"
ctx = llm.Kontext(run, topic, "minimax")
assert not [b for b in struktur.messen(ctx) if b["art"] == "level_konflikt"]
async def test_braucht_fallback_kompositum():
# braucht-Titel "Approximationsalgorithmus" (Kompositum) trifft weder norm noch
# titel_kern das Atom "Approximativer Algorithmus" (Phrase). Der Fallback-Judge
# legt die Kante; _level_konflikte_loesen senkt die Voraussetzung von M auf E.
topic = topic_anlegen("brfall")
run = run_anlegen(topic)
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
a = db.insert("atome", topic=topic, titel="Absolute Güte", typ="begriff",
definition="d", level="E", status="neu", ziel_id=ziel,
braucht=db.j(["Approximationsalgorithmus"]))
b = db.insert("atome", topic=topic, titel="Approximativer Algorithmus", typ="begriff",
definition="d", level="M", status="neu", ziel_id=ziel, braucht=db.j([]))
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
inventar._kanten_aufloesen(topic) # deterministisch: verfehlt das Kompositum
assert db.query("SELECT * FROM kanten WHERE topic=? AND von_atom=?", (topic, a)) == []
await inventar._braucht_fallback(ctx)
kante = db.one("SELECT * FROM kanten WHERE topic=? AND von_atom=? AND zu_atom=?",
(topic, a, b))
assert kante and kante["art"] == "braucht"
struktur._level_konflikte_loesen(topic)
assert db.one("SELECT level FROM atome WHERE id=?", (b,))["level"] == "E"
async def test_level_split_ordnung_kapitel():
# Ein Ziel mit 4×E + 4×M → zwei Bausteine mit level, E-ord < M-ord,
# Kapitel je Durchgang, kein level_mix/kapitel_level
@@ -258,12 +240,12 @@ async def test_level_split_ordnung_kapitel():
ziel = db.insert("lernziele", topic=topic, text="Kann P", soll_id=soll, status="aktiv")
for i in range(4):
db.insert("atome", topic=topic, titel=f"E{i}", typ="begriff", definition="d",
level="E", status="neu", soll_id=soll, ziel_id=ziel, braucht=db.j([]))
level="E", status="neu", soll_id=soll, ziel_id=ziel)
db.insert("atome", topic=topic, titel=f"M{i}", typ="begriff", definition="d",
level="M", status="neu", soll_id=soll, ziel_id=ziel, braucht=db.j([]))
level="M", status="neu", soll_id=soll, ziel_id=ziel)
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
struktur._bausteine_schneiden(ctx)
await struktur._bausteine_schneiden(ctx)
bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
assert [b["level"] for b in bausteine] == ["E", "M"]
await struktur._kapitel_bilden(ctx)
@@ -273,7 +255,7 @@ async def test_level_split_ordnung_kapitel():
assert not arten & {"level_mix", "kapitel_level", "band", "partition"}
def test_kein_merge_ueber_level():
async def test_kein_merge_ueber_level():
# kleine E-Gruppe darf NICHT mit kleiner M-Gruppe mergen
topic = topic_anlegen("levelmerge")
run = run_anlegen(topic)
@@ -282,11 +264,11 @@ def test_kein_merge_ueber_level():
z2 = db.insert("lernziele", topic=topic, text="Kann P2", soll_id=soll, status="aktiv")
for i in range(2):
db.insert("atome", topic=topic, titel=f"E{i}", typ="begriff", definition="d",
level="E", status="neu", soll_id=soll, ziel_id=z1, braucht=db.j([]))
level="E", status="neu", soll_id=soll, ziel_id=z1)
db.insert("atome", topic=topic, titel=f"M{i}", typ="begriff", definition="d",
level="M", status="neu", soll_id=soll, ziel_id=z2, braucht=db.j([]))
level="M", status="neu", soll_id=soll, ziel_id=z2)
ctx = llm.Kontext(run, topic, "minimax")
struktur._bausteine_schneiden(ctx)
await struktur._bausteine_schneiden(ctx)
for b in db.query("SELECT * FROM bausteine WHERE topic=?", (topic,)):
levels = {a["level"] for a in struktur._atome(topic)
if a["baustein_id"] == b["id"]}

View File

@@ -63,11 +63,18 @@ async def test_import_remappt_auftrag_atom_ids():
db.insert("auftraege", baustein_id=b["id"], art="falsch",
detail=f"Atom {a['id']} widerspricht dem Text")
d = json.loads(json.dumps(transfer.export(topic)))
# Alt-Export simulieren: befunde-Freitext an einer Section
# Alt-Export simulieren: befunde-Freitext an einer Section, braucht-Spalte
# an Atomen, braucht-Kante — alles muss crashfrei toleriert werden
d["sections"][0]["befunde"] = db.j([
f"KRITISCH (luecke): Atom {a['id']} fehlt inhaltlich",
"Langtext, Absatz 3 hat 120 Wörter (Regel: 4090): teilen."]) # → verwerfen
for z in d["atome"]:
z["braucht"] = "[]"
d["kanten"].append({"id": 999, "topic": topic, "von_atom": d["atome"][0]["id"],
"zu_atom": d["atome"][-1]["id"], "art": "braucht",
"status": "aktiv"})
transfer.importieren(d)
assert db.query("SELECT * FROM kanten WHERE art='braucht'") == []
atom_ids = {r["id"] for r in db.query("SELECT id FROM atome WHERE topic=?", (topic,))}
zeilen = db.query("SELECT au.* FROM auftraege au JOIN bausteine b"
" ON b.id=au.baustein_id WHERE b.topic=?", (topic,))