This commit is contained in:
team3
2026-07-12 04:20:07 +02:00
parent 14bcf1b8e9
commit a284e03225
29 changed files with 866 additions and 329 deletions

View File

@@ -7,6 +7,7 @@ Danach Soll-Zuordnung je Atom; Soll-Punkte ohne Atom lösen gezielte Nachextrakt
import asyncio
import logging
import re
import db
import embedding
@@ -22,7 +23,7 @@ log = logging.getLogger("creator2.inventar")
EBENE = "inventar"
TYPEN = ("begriff", "aussage", "verfahren")
LEVELS = ("E", "M", "S")
PAAR_CHUNK = 40 # Merge-Paare pro Judge-Call (Lektion 34/55)
PAAR_CHUNK = 10 # Merge-Paare pro Judge-Call (40 riss das Output-Cap: 5871 % Parse-Fehler)
def aktive_atome(topic: str) -> list[dict]:
@@ -32,25 +33,39 @@ def aktive_atome(topic: str) -> list[dict]:
# ── Extraktion ────────────────────────────────────────────────────────────────
async def _extrahiere_quelle(ctx: llm.Kontext, quelle: dict) -> None:
async def _extrahiere_quelle(ctx: llm.Kontext, quelle: dict, force: bool = False) -> None:
# Idempotenz-Guard: hat die Quelle schon verankerte Atome (z. B. nach Soll-Reset),
# wird sie nicht erneut gelesen — sonst Doppel-Extraktion.
# wird sie nicht erneut gelesen — sonst Doppel-Extraktion. force=True (Repair
# quelle_unvollstaendig) liest trotzdem; Doppelte fängt der Anker-Dedup.
schon = db.one(
"SELECT a.id FROM atome a JOIN anker k ON k.atom_id=a.id"
" WHERE a.topic=? AND k.quelle_id=? AND a.status NOT IN ('gemerged','verworfen')"
" LIMIT 1", (ctx.topic, quelle["id"]))
if schon:
if schon and not force:
db.update("quellen", "id", quelle["id"], status="atome")
return
text = korpus.quelltext(quelle)
# aufgaben-Quellen: das GEÜBTE Konzept destillieren, nie die Aufgabeninstanz
template = ("Atom-Extraktion-Aufgaben" if quelle.get("rolle") == "aufgaben"
else "Atom-Extraktion")
fehl: list[int] = []
async def reader(offset: int, chunk: str, r: int) -> None:
async def reader(offset: int, chunk: str, r: int, tiefe: int = 0) -> None:
res = await llm.call(ctx, stage="extraktion", template=template,
werte={"topic": ctx.topic, "quelle": quelle["titel"], "text": chunk},
role="quick", item=f"q{quelle['id']}-o{offset}-r{r}", erwartet=list)
role="quick",
item=f"q{quelle['id']}-o{offset}-r{r}" + "-h" * tiefe,
erwartet=list)
if res is None:
# Leerantwort/Parse-Fehler ist meist das Output-Cap (stop=max_tokens,
# deterministisch — stumpfes Wiederholen hilft nie): Chunk halbieren.
if tiefe < 2 and len(chunk) > 2000:
mitte = len(chunk) // 2
await asyncio.gather(reader(offset, chunk[:mitte], r, tiefe + 1),
reader(offset + mitte, chunk[mitte:], r, tiefe + 1))
else:
fehl.append(offset) # nicht still: Quelle bleibt unvollständig
return
for a in res or []:
titel = str(a.get("titel", "")).strip()
definition = str(a.get("definition", "")).strip()
@@ -79,7 +94,9 @@ async def _extrahiere_quelle(ctx: llm.Kontext, quelle: dict) -> None:
await asyncio.gather(*(reader(off, chunk, r)
for off, chunk in stuecke
for r in range(READER_JE_ABSCHNITT)))
db.update("quellen", "id", quelle["id"], status="atome")
if fehl: # stiller Chunk-Verlust hieß bisher trotzdem „atome" (Lernen: 41 Calls leer)
log.warning("Quelle %s: %d Abschnitt(e) ohne Extraktion", quelle["id"], len(fehl))
db.update("quellen", "id", quelle["id"], status="teilweise" if fehl else "atome")
# ── Dedup ─────────────────────────────────────────────────────────────────────
@@ -202,7 +219,11 @@ async def _judge_dedup(ctx: llm.Kontext) -> None:
if isinstance(e, dict) and e.get("paar") == n:
urteile.append(bool(e.get("gleich")))
break
if len(urteile) == MERGE_PANEL and all(urteile):
if len(urteile) < MERGE_PANEL:
continue # Panel unvollständig (Parse-/Infra-Ausfall): Paar bleibt
# offen für die nächste Runde — ein dauerhaftes „nein" ließ
# Dubletten systematisch überleben (aak: 42/73 Calls unparsbar)
if all(urteile):
gew, ver = ((a["id"], b["id"]) if len(a["definition"]) >= len(b["definition"])
else (b["id"], a["id"]))
_merge(topic, gew, ver)
@@ -221,12 +242,20 @@ def _erster_anker(atom_id: int) -> str:
def _kanten_aufloesen(topic: str) -> None:
"""braucht-Titel → Atom-IDs (Norm-Gleichheit; Unauflösbares fällt still weg)."""
"""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))
@@ -269,6 +298,20 @@ async def bauen(ctx: llm.Kontext) -> None:
# ── QA + Repair ───────────────────────────────────────────────────────────────
_TITEL_KATALOG = re.compile(r"^(Satz|Lemma|Korollar|Bemerkung|Definition|Aufgabe"
r"|Übung|Transformation|Theorem)\s+[IVX]*\d", re.IGNORECASE)
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 |")."""
if _TITEL_KATALOG.match(titel):
return True
if titel.rstrip().endswith(("|", ",", ";", ":", "-", "", "(", "{", "", "=")):
return True
return titel.count("(") != titel.count(")")
def messen(ctx: llm.Kontext) -> list[dict]:
befunde = []
atome = aktive_atome(ctx.topic)
@@ -281,9 +324,15 @@ def messen(ctx: llm.Kontext) -> list[dict]:
befunde.append({"art": "atom_ohne_soll", "item": str(a["id"]), "detail": a["titel"]})
else:
belegte_soll.add(a["soll_id"])
if _titel_kaputt(a["titel"]):
befunde.append({"art": "titel_katalog", "item": str(a["id"]), "detail": a["titel"]})
for p in db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'", (ctx.topic,)):
if p["id"] not in belegte_soll:
befunde.append({"art": "soll_ohne_atom", "item": str(p["id"]), "detail": p["punkt"]})
for q in db.query("SELECT * FROM quellen WHERE topic=? AND status='teilweise'",
(ctx.topic,)):
befunde.append({"art": "quelle_unvollstaendig", "item": str(q["id"]),
"detail": q["titel"]})
return befunde
@@ -299,6 +348,11 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
return await _soll_stichentscheid(ctx, item)
if b["art"] == "soll_ohne_atom":
return await _luecke_schliessen(ctx, item)
if b["art"] == "quelle_unvollstaendig":
q = db.one("SELECT * FROM quellen WHERE id=?", (item,))
if q:
await _extrahiere_quelle(ctx, q, force=True)
return True
return False
# Anker-Fixes gebündelt (Batch je Quelle statt Atom×Quelle — war 83 % der
@@ -306,7 +360,11 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
anker_ids = [int(b["item"]) for b in befunde
if b["art"] == "atom_ohne_anker" and str(b["item"]).isdigit()]
bewegt = await _anker_fixen_batch(ctx, anker_ids) if anker_ids else False
rest = [b for b in befunde if b["art"] != "atom_ohne_anker"]
titel_ids = [int(b["item"]) for b in befunde
if b["art"] == "titel_katalog" and str(b["item"]).isdigit()]
if titel_ids:
bewegt |= await _titel_fixen(ctx, titel_ids)
rest = [b for b in befunde if b["art"] not in ("atom_ohne_anker", "titel_katalog")]
bewegt |= any(await llm.alle(einer(b) for b in rest))
if bewegt:
_anker_dedup(ctx.topic)
@@ -316,6 +374,27 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
return bewegt
async def _titel_fixen(ctx: llm.Kontext, atom_ids: list[int]) -> bool:
"""Kaputte Titel (Katalognummern, Fragmente) aus der Definition neu benennen."""
atome = [a for i in atom_ids if (a := db.one("SELECT * FROM atome WHERE id=?", (i,)))
and a["status"] not in ("gemerged", "verworfen")]
if not atome:
return False
liste = "\n".join(f"{a['id']}: {a['titel']}{a['definition']}" for a in atome)
res = await llm.call(ctx, stage="titel_fix", template="Atom-Titel-Fix",
schritt="fix", werte={"atome": liste}, role="judge",
n=len(atome), item=f"t{atome[0]['id']}", erwartet=list)
gueltig = {a["id"] for a in atome}
bewegt = False
for e in res or []:
if isinstance(e, dict) and e.get("atom") in gueltig:
titel = str(e.get("titel", "")).strip()
if titel and not _titel_kaputt(titel):
db.update("atome", "id", e["atom"], titel=titel)
bewegt = True
return bewegt
ANKER_FIX_CHUNK = 20 # Atome pro Batch-Call (ein Quelltext-Abschnitt trägt viele Fixes)