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

@@ -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