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

@@ -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))
if not schwelle:
return await haupt
fertig, _ = await asyncio.wait({haupt}, timeout=schwelle)
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_ZEICHEN:
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)