update
This commit is contained in:
@@ -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 72–79 % 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
|
||||
|
||||
Reference in New Issue
Block a user