update
This commit is contained in:
@@ -21,14 +21,15 @@ import time
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import database as db
|
import database as db
|
||||||
|
import embedding
|
||||||
from agents import kill_process, cancel_scope, clear_scope, run_agent
|
from agents import kill_process, cancel_scope, clear_scope, run_agent
|
||||||
from config import KONSENS_GRACE, RECHERCHE_GRACE, KONSENS_MAX_RUNDEN, DEFAULT_PROVIDER, CRAWL_KEEP_PATTERNS, CRAWL_NOISE_PATTERNS, CRAWL_MIN_CHARS, QUELLE_RELEVANZ_CHUNK, QUELLE_RELEVANZ_SNIPPET
|
from config import KONSENS_GRACE, RECHERCHE_GRACE, KONSENS_MAX_RUNDEN, DEFAULT_PROVIDER, CRAWL_KEEP_PATTERNS, CRAWL_NOISE_PATTERNS, CRAWL_MIN_CHARS, QUELLE_RELEVANZ_CHUNK, QUELLE_RELEVANZ_SNIPPET, EMBEDDING_AKTIV
|
||||||
from fsutil import atomic_write_text, atomic_write_json
|
from fsutil import atomic_write_text, atomic_write_json
|
||||||
from jsonio import read_json_file as _json_datei
|
from jsonio import read_json_file as _json_datei
|
||||||
from paths import arbeit_dir, bausteine_path, frage_muster_path, project_dir, subbausteine_path, quelle_path, quelle_crawl_dir, safe_ordner
|
from paths import arbeit_dir, bausteine_path, frage_muster_path, project_dir, subbausteine_path, quelle_path, quelle_crawl_dir, safe_ordner
|
||||||
from crawl import crawl
|
from crawl import crawl
|
||||||
from pipeline import (
|
from pipeline import (
|
||||||
CANCELLED, FAILED, GenContext, _extra, _gather_fortschritt, _janein_schema, _log, _prompt, _race,
|
CANCELLED, FAILED, OK, GenContext, _extra, _gather_fortschritt, _janein_schema, _log, _prompt, _race,
|
||||||
_relevanz_schema, _runde_schema, _semaphore, _str_liste, _stufen_schema, _timeout, run_single_slot,
|
_relevanz_schema, _runde_schema, _semaphore, _str_liste, _stufen_schema, _timeout, run_single_slot,
|
||||||
)
|
)
|
||||||
from textkit import (
|
from textkit import (
|
||||||
@@ -52,7 +53,9 @@ RECHERCHE_KAPPE = 1800 # Sicherheits-Deckel je Batch-Agent
|
|||||||
RECHERCHE_ABSCHNITT_ZEICHEN = 12000
|
RECHERCHE_ABSCHNITT_ZEICHEN = 12000
|
||||||
# Sichtung (Content/Noise) ist jetzt ein deterministischer Regel-Filter (config.CRAWL_*).
|
# Sichtung (Content/Noise) ist jetzt ein deterministischer Regel-Filter (config.CRAWL_*).
|
||||||
SUBBAUSTEIN_KAPPE = 900 # Subbaustein-Finde-Loop je Chunk (15 min)
|
SUBBAUSTEIN_KAPPE = 900 # Subbaustein-Finde-Loop je Chunk (15 min)
|
||||||
KONSOLIDIERUNG_CHUNK = 600 # bis hierher EIN globaler Judge (dedupt alles); darüber chunked + Merge-Pass
|
KONSOLIDIERUNG_CHUNK = 600 # bis hierher EIN globaler Judge (dedupt alles); darüber chunked + Merge-Pass — nur Fallback-Pfad
|
||||||
|
DEDUP_MAX_RUNDEN = 3 # finaler Dedup-Pass: max. Iterationen (die kleinere Liste blockt je Runde neu)
|
||||||
|
DEDUP_MIN_DELTA = 3 # Abbruch, wenn eine Runde weniger als dieses % der Liste entfernt (konvergiert)
|
||||||
# Frage-Muster-Chunks per LPT nach Sub-Last balancieren (Makespan), statt nach Baustein-Anzahl.
|
# Frage-Muster-Chunks per LPT nach Sub-Last balancieren (Makespan), statt nach Baustein-Anzahl.
|
||||||
FRAGE_CHUNK_SUBS = 50 # Ziel-Summe relevanter Subs je Chunk
|
FRAGE_CHUNK_SUBS = 50 # Ziel-Summe relevanter Subs je Chunk
|
||||||
FAKTEN_CHUNK_SUBS = 25 # Fakten-Extraktion: kleinere Chunks (Fakten sind umfangreicher als Muster)
|
FAKTEN_CHUNK_SUBS = 25 # Fakten-Extraktion: kleinere Chunks (Fakten sind umfangreicher als Muster)
|
||||||
@@ -204,7 +207,7 @@ def _bausteine_steps(topic: str) -> tuple:
|
|||||||
laufen alle Pakete parallel; der Schritt bleibt, bis das letzte Paket fertig ist.
|
laufen alle Pakete parallel; der Schritt bleibt, bis das letzte Paket fertig ist.
|
||||||
"""
|
"""
|
||||||
q = lade_quelle(topic)
|
q = lade_quelle(topic)
|
||||||
base = ("Recherche", "Konsolidierung", "Klärung")
|
base = ("Recherche", "Konsolidierung", "Klärung", "Dedup")
|
||||||
rest = (
|
rest = (
|
||||||
"Subbausteine finden", "Subbausteine wählen", "Subbausteine klären",
|
"Subbausteine finden", "Subbausteine wählen", "Subbausteine klären",
|
||||||
"Fakten finden", "Fakten prüfen", "Fakten fix",
|
"Fakten finden", "Fakten prüfen", "Fakten fix",
|
||||||
@@ -234,7 +237,7 @@ def _melde_p(set_p, topic: str, schritt: str):
|
|||||||
# Sonderschritte (Quelle laden, Ergänzung) gehören zur Phase „Inventar".
|
# Sonderschritte (Quelle laden, Ergänzung) gehören zur Phase „Inventar".
|
||||||
PHASEN = (
|
PHASEN = (
|
||||||
("Quelle", ("Quelle aufbereiten",)),
|
("Quelle", ("Quelle aufbereiten",)),
|
||||||
("Inventar", ("Recherche", "Konsolidierung", "Klärung", "Ergänzung")),
|
("Inventar", ("Recherche", "Konsolidierung", "Klärung", "Dedup", "Ergänzung")),
|
||||||
("Subbausteine", ("Subbausteine finden", "Subbausteine wählen", "Subbausteine klären")),
|
("Subbausteine", ("Subbausteine finden", "Subbausteine wählen", "Subbausteine klären")),
|
||||||
("Fakten", ("Fakten finden", "Fakten prüfen", "Fakten fix")),
|
("Fakten", ("Fakten finden", "Fakten prüfen", "Fakten fix")),
|
||||||
("Stufen", ("Stufen finden", "Stufen wählen", "Stufen klären")),
|
("Stufen", ("Stufen finden", "Stufen wählen", "Stufen klären")),
|
||||||
@@ -295,7 +298,7 @@ def _alle_slot_dateien(files: dict) -> list[Path]:
|
|||||||
dyn = (list(arbeit.glob("subbaustein-*")) + list(arbeit.glob("fakten-*")) + list(arbeit.glob("stufe-*")) + list(arbeit.glob("relevanz-*"))
|
dyn = (list(arbeit.glob("subbaustein-*")) + list(arbeit.glob("fakten-*")) + list(arbeit.glob("stufe-*")) + list(arbeit.glob("relevanz-*"))
|
||||||
+ list(arbeit.glob("frage-muster-*")) + list(arbeit.glob("gliederung-*")) + list(arbeit.glob("artefakt-*"))
|
+ list(arbeit.glob("frage-muster-*")) + list(arbeit.glob("gliederung-*")) + list(arbeit.glob("artefakt-*"))
|
||||||
+ list(arbeit.glob("recherche-*")) + list(arbeit.glob("konsolidierung-*"))
|
+ list(arbeit.glob("recherche-*")) + list(arbeit.glob("konsolidierung-*"))
|
||||||
+ list(arbeit.glob("klaerung*"))) if arbeit.is_dir() else []
|
+ list(arbeit.glob("klaerung*")) + list(arbeit.glob("dedup-*"))) if arbeit.is_dir() else []
|
||||||
return [
|
return [
|
||||||
*files["recherche"], files["recherche_mapping"],
|
*files["recherche"], files["recherche_mapping"],
|
||||||
*(p for slots in files["auswahl"].values() for p in slots),
|
*(p for slots in files["auswahl"].values() for p in slots),
|
||||||
@@ -1767,18 +1770,18 @@ async def _recherche_batch(ctx: GenContext, set_p, files: dict, q: dict, ordner,
|
|||||||
await db.delete_bausteine(topic) # Coverage/inhalt gehört der Sichtung — NICHT löschen
|
await db.delete_bausteine(topic) # Coverage/inhalt gehört der Sichtung — NICHT löschen
|
||||||
await db.set_step_status(topic, "Recherche", "laufend")
|
await db.set_step_status(topic, "Recherche", "laufend")
|
||||||
|
|
||||||
async def _ingest(text: str) -> None:
|
async def _ingest(reader_id: str, text: str) -> None:
|
||||||
gesehen = set()
|
gesehen = set()
|
||||||
for eintrag in _parse_auswahl(text).values():
|
for eintrag in _parse_auswahl(text).values():
|
||||||
titel = _titel(eintrag)
|
titel = _titel(eintrag)
|
||||||
norm = _norm_titel(titel)
|
norm = _norm_titel(titel)
|
||||||
if not norm or norm in gesehen:
|
if not norm or norm in gesehen:
|
||||||
continue
|
continue
|
||||||
gesehen.add(norm)
|
gesehen.add(norm) # ein Reader = eine Stimme je Konzept
|
||||||
teile = [t.strip() for t in eintrag.split(" — ")]
|
teile = [t.strip() for t in eintrag.split(" — ")]
|
||||||
besch = teile[1] if len(teile) >= 2 else ""
|
besch = teile[1] if len(teile) >= 2 else ""
|
||||||
quelle = [teile[2]] if len(teile) >= 3 and teile[2] else []
|
quelle = [teile[2]] if len(teile) >= 3 and teile[2] else []
|
||||||
await db.upsert_baustein(topic, norm, titel, besch, quelle)
|
await db.upsert_baustein(topic, norm, titel, besch, quelle, reader=reader_id)
|
||||||
|
|
||||||
pages = await db.list_content(topic) # von der Sichtung als Content markierte Seiten
|
pages = await db.list_content(topic) # von der Sichtung als Content markierte Seiten
|
||||||
if not pages and ordner:
|
if not pages and ordner:
|
||||||
@@ -1795,7 +1798,7 @@ async def _recherche_batch(ctx: GenContext, set_p, files: dict, q: dict, ordner,
|
|||||||
"key": f"bausteine-{topic}-recherche-{i}",
|
"key": f"bausteine-{topic}-recherche-{i}",
|
||||||
"prompt": _build_recherche_prompt(topic, p, instructions, q["type"], ordner),
|
"prompt": _build_recherche_prompt(topic, p, instructions, q["type"], ordner),
|
||||||
"role": "quick", "capabilities": caps,
|
"role": "quick", "capabilities": caps,
|
||||||
"payload": (lambda result, p=p: _file_payload(p)),
|
"payload": (lambda result, p=p, rid=f"t{i}": ((rid, t) if (t := _file_payload(p)) else None)),
|
||||||
} for i, p in enumerate(paths, 1)]
|
} for i, p in enumerate(paths, 1)]
|
||||||
texte = await _race(topic, "Recherche", slots, 3, _timeout("recherche"), provider,
|
texte = await _race(topic, "Recherche", slots, 3, _timeout("recherche"), provider,
|
||||||
cancelled=is_cancelled, grace=RECHERCHE_GRACE)
|
cancelled=is_cancelled, grace=RECHERCHE_GRACE)
|
||||||
@@ -1804,8 +1807,8 @@ async def _recherche_batch(ctx: GenContext, set_p, files: dict, q: dict, ordner,
|
|||||||
if not texte:
|
if not texte:
|
||||||
_bausteine_errors[topic] = "Recherche fehlgeschlagen (Minimum nicht erreicht)"
|
_bausteine_errors[topic] = "Recherche fehlgeschlagen (Minimum nicht erreicht)"
|
||||||
return False
|
return False
|
||||||
for text in texte:
|
for rid, text in texte:
|
||||||
await _ingest(text)
|
await _ingest(rid, text)
|
||||||
await db.set_step_status(topic, "Recherche", "fertig")
|
await db.set_step_status(topic, "Recherche", "fertig")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -1827,6 +1830,13 @@ async def _recherche_batch(ctx: GenContext, set_p, files: dict, q: dict, ordner,
|
|||||||
f"VOLLSTÄNDIG, überspringe nichts. Notiere `{fn}` als Quelle jedes Bausteins. "
|
f"VOLLSTÄNDIG, überspringe nichts. Notiere `{fn}` als Quelle jedes Bausteins. "
|
||||||
f"Suche NICHT im Web — nur dieser Abschnitt zählt.\n\n-----\n{absch}\n-----")
|
f"Suche NICHT im Web — nur dieser Abschnitt zählt.\n\n-----\n{absch}\n-----")
|
||||||
paths = [arbeit / f"recherche-a{ei}-{i}.md" for i in range(1, RECHERCHE_READERS + 1)]
|
paths = [arbeit / f"recherche-a{ei}-{i}.md" for i in range(1, RECHERCHE_READERS + 1)]
|
||||||
|
# Reader-Datei-Wiederverwendung: liegen alle Reader-Outputs valide vor (Resume /
|
||||||
|
# Re-Run ohne Recherche-Änderung), re-ingestieren statt erneut Agenten zu spawnen.
|
||||||
|
vorhanden = [(f"a{ei}-{i}", t) for i, p in enumerate(paths, 1) if (t := _file_payload(p))]
|
||||||
|
if len(vorhanden) == len(paths):
|
||||||
|
for rid, text in vorhanden:
|
||||||
|
await _ingest(rid, text)
|
||||||
|
return
|
||||||
for p in paths:
|
for p in paths:
|
||||||
p.unlink(missing_ok=True)
|
p.unlink(missing_ok=True)
|
||||||
if is_cancelled():
|
if is_cancelled():
|
||||||
@@ -1835,14 +1845,14 @@ async def _recherche_batch(ctx: GenContext, set_p, files: dict, q: dict, ordner,
|
|||||||
"key": f"bausteine-{topic}-recherche-a{ei}-{i}",
|
"key": f"bausteine-{topic}-recherche-a{ei}-{i}",
|
||||||
"prompt": _build_recherche_prompt(topic, p, instructions, q["type"], ordner, abschnitt=block),
|
"prompt": _build_recherche_prompt(topic, p, instructions, q["type"], ordner, abschnitt=block),
|
||||||
"role": "quick", "capabilities": "files",
|
"role": "quick", "capabilities": "files",
|
||||||
"payload": (lambda result, p=p: _file_payload(p)),
|
"payload": (lambda result, p=p, rid=f"a{ei}-{i}": ((rid, t) if (t := _file_payload(p)) else None)),
|
||||||
} for i, p in enumerate(paths, 1)]
|
} for i, p in enumerate(paths, 1)]
|
||||||
# Quorum 2: beide Reader pro Abschnitt sollen durch (mehr Augen = mehr Konzepte +
|
# Quorum 2: beide Reader pro Abschnitt sollen durch (mehr Augen = mehr Konzepte +
|
||||||
# echter Konsens); nach Timeout fällt _race auf das Vorhandene zurück.
|
# echter Konsens); nach Timeout fällt _race auf das Vorhandene zurück.
|
||||||
texte = await _race(topic, f"Recherche Abschnitt {ei}", slots, 2, _timeout("recherche", 1),
|
texte = await _race(topic, f"Recherche Abschnitt {ei}", slots, 2, _timeout("recherche", 1),
|
||||||
provider, cancelled=is_cancelled, grace=RECHERCHE_GRACE)
|
provider, cancelled=is_cancelled, grace=RECHERCHE_GRACE)
|
||||||
for text in (texte or []):
|
for rid, text in (texte or []):
|
||||||
await _ingest(text)
|
await _ingest(rid, text)
|
||||||
|
|
||||||
await _gather_fortschritt([_lese_abschnitt(ei, fn, a) for ei, (fn, a) in enumerate(eintraege, 1)],
|
await _gather_fortschritt([_lese_abschnitt(ei, fn, a) for ei, (fn, a) in enumerate(eintraege, 1)],
|
||||||
len(eintraege), _melde_p(set_p, topic, "Recherche"))
|
len(eintraege), _melde_p(set_p, topic, "Recherche"))
|
||||||
@@ -1873,12 +1883,12 @@ async def _recherche_batch(ctx: GenContext, set_p, files: dict, q: dict, ordner,
|
|||||||
"key": f"bausteine-{topic}-recherche-b{bi}-{i}",
|
"key": f"bausteine-{topic}-recherche-b{bi}-{i}",
|
||||||
"prompt": _build_recherche_prompt(topic, p, instructions, q["type"], ordner, fokus=fokus),
|
"prompt": _build_recherche_prompt(topic, p, instructions, q["type"], ordner, fokus=fokus),
|
||||||
"role": "quick", "capabilities": "files",
|
"role": "quick", "capabilities": "files",
|
||||||
"payload": (lambda result, p=p: _file_payload(p)),
|
"payload": (lambda result, p=p, rid=f"b{bi}-{i}": ((rid, t) if (t := _file_payload(p)) else None)),
|
||||||
} for i, p in enumerate(paths, 1)]
|
} for i, p in enumerate(paths, 1)]
|
||||||
texte = await _race(topic, f"Recherche Batch {bi}", slots, 1, _timeout("recherche", len(batch)),
|
texte = await _race(topic, f"Recherche Batch {bi}", slots, 1, _timeout("recherche", len(batch)),
|
||||||
provider, cancelled=is_cancelled, grace=RECHERCHE_GRACE)
|
provider, cancelled=is_cancelled, grace=RECHERCHE_GRACE)
|
||||||
for text in (texte or []):
|
for rid, text in (texte or []):
|
||||||
await _ingest(text)
|
await _ingest(rid, text)
|
||||||
await db.mark_quellen_gelesen(topic, batch) # alle zugeteilten Seiten abhaken (auch ohne Treffer)
|
await db.mark_quellen_gelesen(topic, batch) # alle zugeteilten Seiten abhaken (auch ohne Treffer)
|
||||||
return not is_cancelled()
|
return not is_cancelled()
|
||||||
|
|
||||||
@@ -1896,11 +1906,168 @@ async def _recherche_batch(ctx: GenContext, set_p, files: dict, q: dict, ordner,
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _grp_schema(data, ids: set[int]):
|
||||||
|
"""{"gruppen": [[1,3],[2], …]} → Partition von `ids` als Liste von Index-Gruppen.
|
||||||
|
Tolerant: ignoriert Fremd-/Doppel-Nummern; vergessene Kandidaten werden eigenständig
|
||||||
|
(Singleton-Gruppe) ergänzt. None nur bei strukturell kaputtem JSON."""
|
||||||
|
if not isinstance(data, dict) or not isinstance(data.get("gruppen"), list):
|
||||||
|
return None
|
||||||
|
gruppen, gesehen = [], set()
|
||||||
|
for g in data["gruppen"]:
|
||||||
|
if not isinstance(g, list):
|
||||||
|
return None
|
||||||
|
grp = []
|
||||||
|
for x in g:
|
||||||
|
try:
|
||||||
|
num = int(x)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
if num in ids and num not in gesehen:
|
||||||
|
gesehen.add(num)
|
||||||
|
grp.append(num)
|
||||||
|
if grp:
|
||||||
|
gruppen.append(grp)
|
||||||
|
gruppen += [[r] for r in sorted(ids - gesehen)] # vergessene Kandidaten bleiben eigenständig
|
||||||
|
return gruppen or None
|
||||||
|
|
||||||
|
|
||||||
|
_ASPEKT_MARKER = ("∈ np", "∈np", " in np", "np-schwer", "np-vollständig", "verifizierer",
|
||||||
|
"zertifikat", "ndtm", "nicht-determ", "lower bound", "untere schranke",
|
||||||
|
"bzgl", "als sprache")
|
||||||
|
|
||||||
|
|
||||||
|
def _aspekt_marker(titel: str) -> int:
|
||||||
|
"""Anzahl Eigenschafts-Marker im Titel (∈NP, NP-schwer, Verifizierer, Lower Bound …).
|
||||||
|
0 = generisches Hauptkonzept (das Problem selbst); >0 = eine Eigenschaft davon."""
|
||||||
|
t = titel.casefold()
|
||||||
|
return sum(1 for m in _ASPEKT_MARKER if m in t)
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical(kandidaten: list[dict], idxs: list[int], gesehen_norm: set[str]) -> dict:
|
||||||
|
"""Repräsentant eines Clusters = das Hauptkonzept (wenigste Eigenschafts-Marker — das Problem
|
||||||
|
selbst, nicht „… ∈ NP"); Tie → häufigster norm-Titel → meiste Reader. Titel global eindeutig
|
||||||
|
(Suffix ' (2)'), damit er als Schlüssel taugt."""
|
||||||
|
by_norm: dict[str, list[int]] = {}
|
||||||
|
for k in idxs:
|
||||||
|
by_norm.setdefault(_norm_titel(kandidaten[k]["titel"]), []).append(k)
|
||||||
|
|
||||||
|
def gewicht(nb: str):
|
||||||
|
ms = by_norm[nb]
|
||||||
|
reader = set().union(*[set(kandidaten[m]["reader"]) for m in ms]) if ms else set()
|
||||||
|
return (-_aspekt_marker(nb), len(ms), len(reader)) # aspekt-arm zuerst = Hauptkonzept
|
||||||
|
|
||||||
|
best = max(by_norm, key=gewicht)
|
||||||
|
k = max(by_norm[best], key=lambda m: len(kandidaten[m]["beschreibung"]))
|
||||||
|
titel = kandidaten[k]["titel"]
|
||||||
|
n = 2
|
||||||
|
while _norm_titel(titel) in gesehen_norm:
|
||||||
|
titel = f"{kandidaten[k]['titel']} ({n})"
|
||||||
|
n += 1
|
||||||
|
gesehen_norm.add(_norm_titel(titel))
|
||||||
|
return {"titel": titel, "beschreibung": kandidaten[k]["beschreibung"]}
|
||||||
|
|
||||||
|
|
||||||
|
async def _block_gruppieren(ctx: GenContext, set_p, arbeit: Path, kandidaten: list[dict],
|
||||||
|
blocks: list[list[int]], praefix: str = "konsolidierung",
|
||||||
|
schritt: str = "Konsolidierung") -> list[list[int]]:
|
||||||
|
"""Je Ähnlichkeits-Block gruppiert ein Judge die Titel in die echten Bausteine (merge
|
||||||
|
Paraphrasen, split Über-Merges). Singletons direkt. Fehler/Timeout → konservativ jeder
|
||||||
|
Kandidat einzeln (vermeidet fälschliches Über-Mergen). → finale Gruppen (globale Indizes).
|
||||||
|
`praefix`/`schritt` trennen Konsolidierung und Dedup (Artefakte, Race-Key, Fortschritt)."""
|
||||||
|
topic, is_cancelled = ctx.topic, ctx.is_cancelled
|
||||||
|
multi = [(bi, b) for bi, b in enumerate(blocks) if len(b) > 1]
|
||||||
|
ergebnis: list[list[int]] = [list(b) for b in blocks if len(b) == 1] # Singletons direkt
|
||||||
|
|
||||||
|
def _zeile(k: int, g: int) -> str:
|
||||||
|
b = kandidaten[g]
|
||||||
|
return f"{k}. {b['titel']}" + (f" — {b['beschreibung']}" if b["beschreibung"] else "")
|
||||||
|
|
||||||
|
async def _grp(bi: int, block: list[int]) -> None:
|
||||||
|
ids = set(range(1, len(block) + 1))
|
||||||
|
p = arbeit / f"{praefix}-block-c{bi}.json"
|
||||||
|
part = _grp_schema(_json_datei(p), ids)
|
||||||
|
if part is None: # Resume: gültige Datei nicht neu rechnen
|
||||||
|
p.unlink(missing_ok=True)
|
||||||
|
if is_cancelled():
|
||||||
|
return
|
||||||
|
zeilen = [_zeile(k, block[k - 1]) for k in range(1, len(block) + 1)]
|
||||||
|
status, part = await run_single_slot(
|
||||||
|
ctx, f"Block-Gruppieren {bi}",
|
||||||
|
key=f"bausteine-{topic}-{praefix}-block-c{bi}",
|
||||||
|
prompt=_prompt("Bausteine-Block-Gruppieren", topic=topic, eintraege="\n".join(zeilen), out_path=p),
|
||||||
|
role="judge", capabilities="files",
|
||||||
|
payload=(lambda result, p=p, ids=ids: _grp_schema(_json_datei(p), ids)),
|
||||||
|
timeout=_timeout("recherche_mapping", len(block)),
|
||||||
|
)
|
||||||
|
part = part if status == OK else None
|
||||||
|
if part is None: # Judge gescheitert → einzeln (kein Über-Merge)
|
||||||
|
ergebnis.extend([idx] for idx in block)
|
||||||
|
else: # lokale Nummern → globale Kandidaten-Indizes
|
||||||
|
ergebnis.extend([block[k - 1] for k in g] for g in part)
|
||||||
|
|
||||||
|
await _gather_fortschritt([_grp(bi, b) for bi, b in multi],
|
||||||
|
len(multi), _melde_p(set_p, topic, schritt))
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
async def _konsolidiere_embedding(ctx: GenContext, set_p, files: dict, kandidaten: list[dict]) -> bool:
|
||||||
|
"""Zweistufig: Embeddings → grobe Capped-Blocks (High-Recall) → je Multi-Block ein Judge,
|
||||||
|
der die Titel in die echten Bausteine gruppiert → Reader-Union (≥2 = Konsens)."""
|
||||||
|
topic, is_cancelled = ctx.topic, ctx.is_cancelled
|
||||||
|
arbeit = files["arbeit"]
|
||||||
|
texts = [f"{b['titel']} — {b['beschreibung']}" if b["beschreibung"] else b["titel"] for b in kandidaten]
|
||||||
|
sims = await asyncio.to_thread(embedding.embed_sims, texts)
|
||||||
|
if sims is None: # Modell doch nicht verfügbar → Fallback
|
||||||
|
return await _konsolidiere_llm(ctx, set_p, files, kandidaten)
|
||||||
|
# Stufe 1: grobe Ähnlichkeits-Blocks (gedeckelt, kein Giant-Component).
|
||||||
|
blocks = await asyncio.to_thread(embedding.capped_blocks, sims, None, None)
|
||||||
|
# Stufe 2: ein Judge gruppiert JEDEN Multi-Block in die echten Bausteine.
|
||||||
|
gruppen = await _block_gruppieren(ctx, set_p, arbeit, kandidaten, blocks)
|
||||||
|
if is_cancelled():
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _min_cos(idxs): # interne Kohärenz zur Kontrolle (Ketten hätten ~0,3)
|
||||||
|
if len(idxs) < 2:
|
||||||
|
return 1.0
|
||||||
|
return round(min(float(sims[i][j]) for n, i in enumerate(idxs) for j in idxs[n + 1:]), 3)
|
||||||
|
|
||||||
|
# Konsens = ≥2 distinkte Reader pro Cluster. Legacy-DBs ohne Reader-Tracking (Recherche lief
|
||||||
|
# vor der Migration, kein Re-Ingest) haben leere Reader-Sets → Rückfall auf Titel-Heuristik
|
||||||
|
# (sonst landete ALLES im Rest).
|
||||||
|
hat_reader = any(b["reader"] for b in kandidaten)
|
||||||
|
konsens, rest, debug, gesehen_norm = [], [], [], set()
|
||||||
|
for idxs in gruppen:
|
||||||
|
reader = set().union(*[set(kandidaten[k]["reader"]) for k in idxs]) if idxs else set()
|
||||||
|
if hat_reader:
|
||||||
|
score = len(reader)
|
||||||
|
else: # ohne Reader-Daten: max(Nennungen, Anzahl distinkter Titel-Varianten im Cluster)
|
||||||
|
score = max(max(kandidaten[k]["nennungen"] for k in idxs),
|
||||||
|
len({kandidaten[k]["titel_norm"] for k in idxs}))
|
||||||
|
rep = _canonical(kandidaten, idxs, gesehen_norm)
|
||||||
|
eintrag = f"{rep['titel']} — {rep['beschreibung']}" if rep["beschreibung"] else rep["titel"]
|
||||||
|
(konsens if score >= 2 else rest).append(eintrag)
|
||||||
|
debug.append({"titel": rep["titel"], "reader": sorted(reader), "score": score,
|
||||||
|
"konsens": score >= 2, "min_cos": _min_cos(idxs),
|
||||||
|
"mitglieder": [kandidaten[k]["titel"] for k in idxs]})
|
||||||
|
atomic_write_json(arbeit / "konsolidierung-cluster.json", debug, indent=1)
|
||||||
|
multi_blocks = sum(1 for b in blocks if len(b) > 1)
|
||||||
|
_log(topic, f"Konsolidierung (Embedding): {len(blocks)} Blocks ({multi_blocks} per LLM gruppiert) "
|
||||||
|
f"→ {len(gruppen)} Cluster aus {len(kandidaten)} Kandidaten "
|
||||||
|
f"→ {len(konsens)} Konsens / {len(rest)} Rest")
|
||||||
|
|
||||||
|
await db.delete_bausteine(topic)
|
||||||
|
for t in konsens:
|
||||||
|
await _set_inventar(topic, t, "konsens")
|
||||||
|
for t in rest:
|
||||||
|
await _set_inventar(topic, t, "rest")
|
||||||
|
await db.set_step_status(topic, "Konsolidierung", "fertig")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def _konsolidiere(ctx: GenContext, set_p, files: dict) -> bool:
|
async def _konsolidiere(ctx: GenContext, set_p, files: dict) -> bool:
|
||||||
"""Panel (KONSOLIDIERUNG_PANEL Judges) mergt Kandidaten semantisch; ein Reconcile-Judge führt die
|
"""Mergt Roh-Kandidaten zu Konsens (≥2 Reader)/Rest. Deterministisch per Embedding-Clustering;
|
||||||
Panel-Ausgaben zur finalen Konsens (≥2)/Rest (1×)-Liste zusammen. Status in DB.
|
fehlt das Modell → Rückfall auf das LLM-Panel (`_konsolidiere_llm`). Status in DB."""
|
||||||
Panel statt Einzel-Judge: ein einzelner Judge ist bias-anfällig (Position/Verbosity) und instabil."""
|
topic = ctx.topic
|
||||||
topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled
|
|
||||||
if await db.get_step_status(topic, "Konsolidierung") == "fertig":
|
if await db.get_step_status(topic, "Konsolidierung") == "fertig":
|
||||||
return True
|
return True
|
||||||
set_p("Konsolidiere Recherche…", step=_step_idx(topic, "Konsolidierung"))
|
set_p("Konsolidiere Recherche…", step=_step_idx(topic, "Konsolidierung"))
|
||||||
@@ -1908,6 +2075,16 @@ async def _konsolidiere(ctx: GenContext, set_p, files: dict) -> bool:
|
|||||||
if not kandidaten:
|
if not kandidaten:
|
||||||
_bausteine_errors[topic] = "Konsolidierung: keine Kandidaten"
|
_bausteine_errors[topic] = "Konsolidierung: keine Kandidaten"
|
||||||
return False
|
return False
|
||||||
|
if EMBEDDING_AKTIV and await asyncio.to_thread(embedding.verfuegbar):
|
||||||
|
return await _konsolidiere_embedding(ctx, set_p, files, kandidaten)
|
||||||
|
return await _konsolidiere_llm(ctx, set_p, files, kandidaten)
|
||||||
|
|
||||||
|
|
||||||
|
async def _konsolidiere_llm(ctx: GenContext, set_p, files: dict, kandidaten: list[dict]) -> bool:
|
||||||
|
"""Fallback (nur ohne Embedding-Modell): Panel (KONSOLIDIERUNG_PANEL Judges) mergt Kandidaten
|
||||||
|
semantisch; ein Reconcile-Judge führt die Panel-Ausgaben zur finalen Konsens (≥2)/Rest (1×)-Liste
|
||||||
|
zusammen. Panel statt Einzel-Judge: ein einzelner Judge ist bias-anfällig und instabil."""
|
||||||
|
topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled
|
||||||
arbeit = files["arbeit"]
|
arbeit = files["arbeit"]
|
||||||
chunks = _chunk_nums(kandidaten, max(1, math.ceil(len(kandidaten) / KONSOLIDIERUNG_CHUNK)))
|
chunks = _chunk_nums(kandidaten, max(1, math.ceil(len(kandidaten) / KONSOLIDIERUNG_CHUNK)))
|
||||||
|
|
||||||
@@ -2026,7 +2203,6 @@ async def _klaere_inventar(ctx: GenContext, set_p, files: dict) -> bool:
|
|||||||
set_p("Klärung läuft…", step=_step_idx(topic, "Klärung"))
|
set_p("Klärung läuft…", step=_step_idx(topic, "Klärung"))
|
||||||
rest_rows = await db.list_bausteine(topic, status="rest")
|
rest_rows = await db.list_bausteine(topic, status="rest")
|
||||||
if rest_rows:
|
if rest_rows:
|
||||||
konsens = [b["titel"] for b in await db.list_bausteine(topic, status="konsens")]
|
|
||||||
arbeit = files["arbeit"]
|
arbeit = files["arbeit"]
|
||||||
paths = [arbeit / f"klaerung-j{j}.json" for j in range(1, KONSOLIDIERUNG_PANEL + 1)]
|
paths = [arbeit / f"klaerung-j{j}.json" for j in range(1, KONSOLIDIERUNG_PANEL + 1)]
|
||||||
offen = [(j, p) for j, p in enumerate(paths, 1) if _runde_schema(_json_datei(p), final=True) is None]
|
offen = [(j, p) for j, p in enumerate(paths, 1) if _runde_schema(_json_datei(p), final=True) is None]
|
||||||
@@ -2037,7 +2213,6 @@ async def _klaere_inventar(ctx: GenContext, set_p, files: dict) -> bool:
|
|||||||
"key": f"bausteine-{topic}-klaerung-j{j}",
|
"key": f"bausteine-{topic}-klaerung-j{j}",
|
||||||
"prompt": _prompt(
|
"prompt": _prompt(
|
||||||
"Bausteine-Klaerung", topic=topic,
|
"Bausteine-Klaerung", topic=topic,
|
||||||
konsens="\n".join(f"- {t}" for t in konsens) or "(noch leer)",
|
|
||||||
rest="\n".join(f"- {b['titel']}" for b in rest_rows),
|
rest="\n".join(f"- {b['titel']}" for b in rest_rows),
|
||||||
final="\n- Entscheide JEDEN Eintrag. `rest` MUSS leer sein.",
|
final="\n- Entscheide JEDEN Eintrag. `rest` MUSS leer sein.",
|
||||||
out_path=p,
|
out_path=p,
|
||||||
@@ -2066,6 +2241,52 @@ async def _klaere_inventar(ctx: GenContext, set_p, files: dict) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _dedup_inventar(ctx: GenContext, set_p, files: dict) -> bool:
|
||||||
|
"""Finaler iterativer Dedup-Pass über die fertige Konsens-Liste: Embedding-Block + LLM-
|
||||||
|
Gruppierung. Fängt Dubletten, die Konsolidierung (Block-Grenzen, Cap) und Klärung (lange
|
||||||
|
Liste, Lost-in-the-Middle) übersehen. Pro Gruppe bleibt EIN Repräsentant, der Rest wird
|
||||||
|
verworfen. Iteriert, weil die kleinere Liste je Runde neu blockt (Cross-Block-Reste)."""
|
||||||
|
topic, is_cancelled = ctx.topic, ctx.is_cancelled
|
||||||
|
if await db.get_step_status(topic, "Dedup") == "fertig":
|
||||||
|
return True
|
||||||
|
if not (EMBEDDING_AKTIV and await asyncio.to_thread(embedding.verfuegbar)):
|
||||||
|
await db.set_step_status(topic, "Dedup", "fertig") # ohne Modell: still überspringen
|
||||||
|
return True
|
||||||
|
set_p("Dedup…", step=_step_idx(topic, "Dedup"))
|
||||||
|
arbeit = files["arbeit"]
|
||||||
|
for runde in range(1, DEDUP_MAX_RUNDEN + 1):
|
||||||
|
konsens = await db.list_bausteine(topic, status="konsens")
|
||||||
|
if len(konsens) < 2:
|
||||||
|
break
|
||||||
|
texts = [f"{b['titel']} — {b['beschreibung']}" if b["beschreibung"] else b["titel"] for b in konsens]
|
||||||
|
sims = await asyncio.to_thread(embedding.embed_sims, texts)
|
||||||
|
if sims is None:
|
||||||
|
break
|
||||||
|
blocks = await asyncio.to_thread(embedding.capped_blocks, sims, None, None)
|
||||||
|
gruppen = await _block_gruppieren(ctx, set_p, arbeit, konsens, blocks,
|
||||||
|
praefix=f"dedup-r{runde}", schritt="Dedup")
|
||||||
|
if is_cancelled():
|
||||||
|
return False
|
||||||
|
weg = 0
|
||||||
|
for idxs in gruppen:
|
||||||
|
if len(idxs) < 2:
|
||||||
|
continue
|
||||||
|
# Repräsentant = Hauptkonzept (wenigste Eigenschafts-Marker), dann kürzester Titel; Rest verwerfen.
|
||||||
|
rep = min(idxs, key=lambda k: (_aspekt_marker(konsens[k]["titel"]), len(konsens[k]["titel"]), k))
|
||||||
|
for k in idxs:
|
||||||
|
if k != rep:
|
||||||
|
await db.set_baustein_status(topic, konsens[k]["titel_norm"], "verworfen")
|
||||||
|
weg += 1
|
||||||
|
atomic_write_json(arbeit / f"dedup-runde-{runde}.json",
|
||||||
|
{"vorher": len(konsens), "entfernt": weg,
|
||||||
|
"gruppen": [[konsens[k]["titel"] for k in g] for g in gruppen if len(g) > 1]}, indent=1)
|
||||||
|
_log(topic, f"Dedup Runde {runde}: {len(konsens)} → {len(konsens) - weg} (−{weg})")
|
||||||
|
if weg <= max(1, len(konsens) * DEDUP_MIN_DELTA // 100): # konvergiert → Schluss
|
||||||
|
break
|
||||||
|
await db.set_step_status(topic, "Dedup", "fertig")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
# --- Gliederung (Bausteine-Artefakt: Kapitel-Struktur, vom Guide nur gelesen) ---
|
# --- Gliederung (Bausteine-Artefakt: Kapitel-Struktur, vom Guide nur gelesen) ---
|
||||||
|
|
||||||
def _gliederung_komplett(files: dict) -> bool:
|
def _gliederung_komplett(files: dict) -> bool:
|
||||||
@@ -2459,7 +2680,7 @@ async def _reset_db_ab_phase(topic: str, label: str) -> None:
|
|||||||
await db.delete_subbausteine(topic)
|
await db.delete_subbausteine(topic)
|
||||||
if idx <= 1: # Inventar: Inventar + Recherche-Schritte — Sichtung bleibt
|
if idx <= 1: # Inventar: Inventar + Recherche-Schritte — Sichtung bleibt
|
||||||
await db.delete_bausteine(topic)
|
await db.delete_bausteine(topic)
|
||||||
await db.delete_pipeline_state(topic, ["Recherche", "Konsolidierung", "Klärung"])
|
await db.delete_pipeline_state(topic, ["Recherche", "Konsolidierung", "Klärung", "Dedup"])
|
||||||
if idx <= 0: # Quelle: Sichtung neu (Coverage/inhalt + Schritt)
|
if idx <= 0: # Quelle: Sichtung neu (Coverage/inhalt + Schritt)
|
||||||
await db.delete_coverage(topic)
|
await db.delete_coverage(topic)
|
||||||
await db.delete_pipeline_state(topic, ["Quelle aufbereiten"])
|
await db.delete_pipeline_state(topic, ["Quelle aufbereiten"])
|
||||||
@@ -2534,6 +2755,10 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
|
|||||||
if is_cancelled():
|
if is_cancelled():
|
||||||
abgebrochen()
|
abgebrochen()
|
||||||
return
|
return
|
||||||
|
if not await _dedup_inventar(ctx, set_p, files):
|
||||||
|
if is_cancelled():
|
||||||
|
abgebrochen()
|
||||||
|
return
|
||||||
konsens_rows = await db.list_bausteine(topic, status="konsens")
|
konsens_rows = await db.list_bausteine(topic, status="konsens")
|
||||||
entries = {
|
entries = {
|
||||||
i: (f"{b['titel']} — {b['beschreibung']}" if b["beschreibung"] else b["titel"])
|
i: (f"{b['titel']} — {b['beschreibung']}" if b["beschreibung"] else b["titel"])
|
||||||
|
|||||||
@@ -21,6 +21,23 @@ LESBARKEIT_MAX = 3.5 # Section zu schwer, wenn der Satz-Schnitt darübe
|
|||||||
LESBARKEIT_HART = 4.0 # Einzelsatz ab hier „hart"
|
LESBARKEIT_HART = 4.0 # Einzelsatz ab hier „hart"
|
||||||
LESBARKEIT_HART_ANTEIL = 0.30 # … ODER wenn dieser Anteil der Sätze hart ist
|
LESBARKEIT_HART_ANTEIL = 0.30 # … ODER wenn dieser Anteil der Sätze hart ist
|
||||||
|
|
||||||
|
# Bausteine-Konsolidierung: semantisches Embedding-Clustering statt LLM-Listen-Merge.
|
||||||
|
# Ein kleines mehrsprachiges Satz-Embedding (mean-pool) bildet die Kandidaten-Cluster
|
||||||
|
# GLOBAL (kein Chunk-Verlust) per Cosine + Union-Find. Titel-Varianten desselben Konzepts
|
||||||
|
# ("Vertex Cover" / "Vertex Cover Definition") verschmelzen; der Konsens zählt danach die
|
||||||
|
# echten Reader pro Cluster (≥2 = Konsens). Fehlen transformers/torch oder lädt das Modell
|
||||||
|
# nicht → Embedding stumm aus, `_konsolidiere` fällt auf den alten Panel-Judge-Pfad zurück.
|
||||||
|
EMBEDDING_AKTIV = True
|
||||||
|
EMBEDDING_MODELL = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2" # CPU, mehrsprachig, ~470 MB
|
||||||
|
# Stärkere (größere) CPU-Alternative bei Bedarf: "BAAI/bge-m3".
|
||||||
|
# Konsolidierung = zweistufig: (1) Embedding bildet GROBE Ähnlichkeits-Blocks (High-Recall),
|
||||||
|
# (2) ein LLM-Judge gruppiert JEDEN Block in die echten Bausteine (merge Paraphrasen, split
|
||||||
|
# Über-Merges). Reines Threshold-Blocking erzeugt einen Giant-Component (alles verkettet) →
|
||||||
|
# darum „Capped-Blocking": greedy nach Cosine mergen, aber Blockgröße deckeln. So bleiben die
|
||||||
|
# LLM-Listen kurz und stabil (belegt: Embedding-Block + LLM-Judge ≈ 95 % Precision).
|
||||||
|
EMBEDDING_BLOCK_FLOOR = 0.5 # Mindest-Cosine, damit zwei Kandidaten in EINEN Block dürfen
|
||||||
|
EMBEDDING_BLOCK_CAP = 25 # max. Titel je Block (LLM-Liste kurz/stabil halten)
|
||||||
|
|
||||||
# Deckel für gleichzeitige CLI-Agenten-Prozesse (über alle Generierungen hinweg).
|
# Deckel für gleichzeitige CLI-Agenten-Prozesse (über alle Generierungen hinweg).
|
||||||
# Eigene Spur für interaktive Aufrufe (Chat, Elemente), damit sie nicht hinter
|
# Eigene Spur für interaktive Aufrufe (Chat, Elemente), damit sie nicht hinter
|
||||||
# laufenden Writern in der Warteschlange hängen.
|
# laufenden Writern in der Warteschlange hängen.
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ CREATE TABLE IF NOT EXISTS bausteine (
|
|||||||
nennungen INTEGER NOT NULL DEFAULT 1,
|
nennungen INTEGER NOT NULL DEFAULT 1,
|
||||||
status TEXT NOT NULL DEFAULT 'kandidat',
|
status TEXT NOT NULL DEFAULT 'kandidat',
|
||||||
quellen TEXT NOT NULL DEFAULT '[]',
|
quellen TEXT NOT NULL DEFAULT '[]',
|
||||||
|
reader TEXT NOT NULL DEFAULT '[]',
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
PRIMARY KEY (topic, titel_norm)
|
PRIMARY KEY (topic, titel_norm)
|
||||||
)
|
)
|
||||||
@@ -266,6 +267,10 @@ async def init_db():
|
|||||||
await db.execute("ALTER TABLE subbausteine ADD COLUMN fakten TEXT NOT NULL DEFAULT ''") # DEFAULT '' nötig für NOT NULL beim ADD COLUMN
|
await db.execute("ALTER TABLE subbausteine ADD COLUMN fakten TEXT NOT NULL DEFAULT ''") # DEFAULT '' nötig für NOT NULL beim ADD COLUMN
|
||||||
except aiosqlite.OperationalError:
|
except aiosqlite.OperationalError:
|
||||||
pass
|
pass
|
||||||
|
try: # Migration: bausteine.reader (Reader-Set je Kandidat, JSON) — exakte Konsens-Zählung (≥2 Reader)
|
||||||
|
await db.execute("ALTER TABLE bausteine ADD COLUMN reader TEXT NOT NULL DEFAULT '[]'")
|
||||||
|
except aiosqlite.OperationalError:
|
||||||
|
pass
|
||||||
# Migration: alte vertiefungen-Tabelle → baustein_texte (Bestand = lange Form, art 'deepdive')
|
# Migration: alte vertiefungen-Tabelle → baustein_texte (Bestand = lange Form, art 'deepdive')
|
||||||
cursor = await db.execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'vertiefungen'")
|
cursor = await db.execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'vertiefungen'")
|
||||||
if await cursor.fetchone():
|
if await cursor.fetchone():
|
||||||
@@ -714,15 +719,31 @@ async def delete_baustein_daten(topic: str) -> None:
|
|||||||
|
|
||||||
# --- Bausteine-Pipeline-Inhalt: Inventar / Subbausteine / Frage-Muster / Coverage / State / Quelle ---
|
# --- Bausteine-Pipeline-Inhalt: Inventar / Subbausteine / Frage-Muster / Coverage / State / Quelle ---
|
||||||
|
|
||||||
async def upsert_baustein(topic: str, titel_norm: str, titel: str, beschreibung: str = "", quellen: list | None = None) -> None:
|
async def upsert_baustein(topic: str, titel_norm: str, titel: str, beschreibung: str = "",
|
||||||
"""Kandidat einfügen oder Nennungszähler erhöhen. Erst-Beschreibung bleibt erhalten."""
|
quellen: list | None = None, reader: str | None = None) -> None:
|
||||||
|
"""Kandidat einfügen oder Reader-Set vereinigen. Erst-Beschreibung bleibt erhalten.
|
||||||
|
|
||||||
|
`reader` = ID des Recherche-Readers (z.B. "a5-1"). Die Vereinigung läuft race-frei in
|
||||||
|
EINEM Statement (json1), weil mehrere Reader-Coroutinen nebenläufig upserten — ein
|
||||||
|
read-modify-write über `await` würde Mitglieder verlieren. `nennungen` bleibt synchron
|
||||||
|
zu `len(reader)`. `reader=None` (z.B. aus `_set_inventar`) lässt das Set unverändert."""
|
||||||
db = await get_db()
|
db = await get_db()
|
||||||
|
rid = reader if isinstance(reader, str) and reader else None
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""INSERT INTO bausteine (topic, titel_norm, titel, beschreibung, nennungen, status, quellen, updated_at)
|
"""INSERT INTO bausteine (topic, titel_norm, titel, beschreibung, nennungen, status, quellen, reader, updated_at)
|
||||||
VALUES (?, ?, ?, ?, 1, 'kandidat', ?, ?)
|
VALUES (?, ?, ?, ?, 1, 'kandidat', ?, ?, ?)
|
||||||
ON CONFLICT(topic, titel_norm) DO UPDATE SET
|
ON CONFLICT(topic, titel_norm) DO UPDATE SET
|
||||||
nennungen = nennungen + 1, quellen = excluded.quellen, updated_at = excluded.updated_at""",
|
reader = (SELECT json_group_array(v) FROM (
|
||||||
(topic, titel_norm, titel, beschreibung, json.dumps(quellen or [], ensure_ascii=False), _now()),
|
SELECT value AS v FROM json_each(bausteine.reader)
|
||||||
|
UNION SELECT ? WHERE ? IS NOT NULL)),
|
||||||
|
nennungen = (SELECT count(*) FROM (
|
||||||
|
SELECT value AS v FROM json_each(bausteine.reader)
|
||||||
|
UNION SELECT ? WHERE ? IS NOT NULL)),
|
||||||
|
quellen = excluded.quellen, updated_at = excluded.updated_at""",
|
||||||
|
(topic, titel_norm, titel, beschreibung,
|
||||||
|
json.dumps(quellen or [], ensure_ascii=False),
|
||||||
|
json.dumps([rid] if rid else [], ensure_ascii=False), _now(),
|
||||||
|
rid, rid, rid, rid),
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
@@ -738,6 +759,7 @@ async def list_bausteine(topic: str, status: str | None = None) -> list[dict]:
|
|||||||
for row in rows:
|
for row in rows:
|
||||||
d = _row_to_dict(row, cursor)
|
d = _row_to_dict(row, cursor)
|
||||||
d["quellen"] = json.loads(d.get("quellen") or "[]")
|
d["quellen"] = json.loads(d.get("quellen") or "[]")
|
||||||
|
d["reader"] = json.loads(d.get("reader") or "[]")
|
||||||
out.append(d)
|
out.append(d)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|||||||
123
backend/embedding.py
Normal file
123
backend/embedding.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
"""Semantisches Embedding-Clustering für die Baustein-Konsolidierung.
|
||||||
|
|
||||||
|
Mean-Pool-Embeddings eines mehrsprachigen Satz-Modells bilden über Cosine-Blocking +
|
||||||
|
Union-Find GLOBALE Kandidaten-Cluster (kein Chunk-Verlust). Sichere Paare (Ähnlichkeit
|
||||||
|
≥ HART) werden ohne LLM gemergt; Grenz-Paare im Band [BAND_LOW, HART) gibt der Aufrufer
|
||||||
|
einem LLM-Judge zur ja/nein-Entscheidung. Fehlen `transformers`/`torch` oder lädt das
|
||||||
|
Modell nicht → `embed_sims()` liefert `None`, der Aufrufer fällt auf den alten
|
||||||
|
Panel-Judge-Pfad zurück (silente Deaktivierung, wie das Lesbarkeits-Gate).
|
||||||
|
|
||||||
|
CPU genügt; der Aufrufer wrappt die blockierende Inferenz in `asyncio.to_thread`.
|
||||||
|
`numpy` ist transitiv über torch vorhanden (bewusst nicht in requirements.txt, analog torch).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from config import EMBEDDING_AKTIV, EMBEDDING_MODELL, EMBEDDING_BLOCK_FLOOR, EMBEDDING_BLOCK_CAP
|
||||||
|
|
||||||
|
log = logging.getLogger("creator.embedding")
|
||||||
|
|
||||||
|
_modell_cache = None # (tokenizer, model, torch) — Singleton
|
||||||
|
_ladeversuch = False # schon versucht zu laden?
|
||||||
|
|
||||||
|
EMBEDDING_BATCH = 32 # Inferenz-Batchgröße (CPU)
|
||||||
|
EMBEDDING_MAX_LEN = 128 # Titel + Kurzbeschreibung sind kurz → kleiner Truncation-Cap genügt
|
||||||
|
|
||||||
|
|
||||||
|
def _modell():
|
||||||
|
"""Lädt das Modell einmalig. None = Clustering aus (deaktiviert oder Lade-Fehler)."""
|
||||||
|
global _modell_cache, _ladeversuch
|
||||||
|
if _ladeversuch:
|
||||||
|
return _modell_cache
|
||||||
|
_ladeversuch = True
|
||||||
|
if not EMBEDDING_AKTIV:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
from transformers import AutoModel, AutoTokenizer
|
||||||
|
tok = AutoTokenizer.from_pretrained(EMBEDDING_MODELL)
|
||||||
|
model = AutoModel.from_pretrained(EMBEDDING_MODELL)
|
||||||
|
model.eval()
|
||||||
|
_modell_cache = (tok, model, torch)
|
||||||
|
log.info("Embedding-Modell geladen: %s", EMBEDDING_MODELL)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Embedding-Clustering deaktiviert (Modell nicht ladbar): %s", e)
|
||||||
|
_modell_cache = None
|
||||||
|
return _modell_cache
|
||||||
|
|
||||||
|
|
||||||
|
def verfuegbar() -> bool:
|
||||||
|
"""True, wenn das Modell geladen werden konnte. Lädt beim ersten Aufruf (blockierend)."""
|
||||||
|
return _modell() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def embed(texts: list[str]) -> "np.ndarray | None":
|
||||||
|
"""Texte → (n, d) L2-normalisierte, mean-gepoolte Embeddings. None = Modell aus."""
|
||||||
|
if _modell() is None:
|
||||||
|
return None
|
||||||
|
tok, model, torch = _modell_cache
|
||||||
|
out = []
|
||||||
|
for i in range(0, len(texts), EMBEDDING_BATCH):
|
||||||
|
batch = texts[i:i + EMBEDDING_BATCH]
|
||||||
|
enc = tok(batch, return_tensors="pt", truncation=True, max_length=EMBEDDING_MAX_LEN, padding=True)
|
||||||
|
with torch.no_grad():
|
||||||
|
hidden = model(**enc).last_hidden_state # (b, t, d)
|
||||||
|
mask = enc["attention_mask"].unsqueeze(-1).type_as(hidden)
|
||||||
|
vec = (hidden * mask).sum(1) / mask.sum(1).clamp(min=1e-9) # mean-pool ohne Padding
|
||||||
|
vec = torch.nn.functional.normalize(vec, p=2, dim=1) # L2 → Cosine = Skalarprodukt
|
||||||
|
out.append(vec.cpu().numpy())
|
||||||
|
return np.vstack(out).astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def _find(parent: list[int], x: int) -> int:
|
||||||
|
while parent[x] != x:
|
||||||
|
parent[x] = parent[parent[x]] # Pfad-Kompression
|
||||||
|
x = parent[x]
|
||||||
|
return x
|
||||||
|
|
||||||
|
|
||||||
|
def _union(parent: list[int], a: int, b: int) -> None:
|
||||||
|
ra, rb = _find(parent, a), _find(parent, b)
|
||||||
|
if ra != rb:
|
||||||
|
parent[max(ra, rb)] = min(ra, rb) # kleinster Index = Wurzel (deterministisch)
|
||||||
|
|
||||||
|
|
||||||
|
def embed_sims(texts: list[str]):
|
||||||
|
"""Texte → (n, n) Cosine-Matrix · None = Modell nicht verfügbar (Fallback)."""
|
||||||
|
embs = embed(texts)
|
||||||
|
if embs is None:
|
||||||
|
return None
|
||||||
|
return embs @ embs.T # (n, n) Cosine, float32 (~2 MB bei n=700)
|
||||||
|
|
||||||
|
|
||||||
|
def capped_blocks(sims, floor: float | None = None, cap: int | None = None) -> list[list[int]]:
|
||||||
|
"""Grobe Ähnlichkeits-Blocks für den LLM — High-Recall, aber Größe gedeckelt.
|
||||||
|
|
||||||
|
Greedy: alle Paare mit Cosine ≥ `floor` nach Cosine absteigend; zwei Blocks werden nur
|
||||||
|
verschmolzen, wenn der resultierende Block ≤ `cap` bleibt. Verhindert den Giant-Component
|
||||||
|
(reines Threshold-Blocking verkettet sonst fast alles) und hält die LLM-Listen kurz.
|
||||||
|
→ Liste von Blocks (Index-Listen), jeder Knoten in genau einem Block.
|
||||||
|
"""
|
||||||
|
fl = EMBEDDING_BLOCK_FLOOR if floor is None else floor
|
||||||
|
cp = EMBEDDING_BLOCK_CAP if cap is None else cap
|
||||||
|
n = len(sims)
|
||||||
|
parent = list(range(n))
|
||||||
|
size = [1] * n
|
||||||
|
if n >= 2:
|
||||||
|
iu = np.triu_indices(n, k=1)
|
||||||
|
s = sims[iu]
|
||||||
|
kept = np.where(s >= fl)[0]
|
||||||
|
# höchste Cosine zuerst → engste Paare bilden zuerst Blocks
|
||||||
|
for k in kept[np.argsort(-s[kept])]:
|
||||||
|
i, j = int(iu[0][k]), int(iu[1][k])
|
||||||
|
ri, rj = _find(parent, i), _find(parent, j)
|
||||||
|
if ri != rj and size[ri] + size[rj] <= cp:
|
||||||
|
_union(parent, i, j)
|
||||||
|
r = _find(parent, i)
|
||||||
|
size[r] = size[ri] + size[rj]
|
||||||
|
blocks: dict[int, list[int]] = {}
|
||||||
|
for i in range(n):
|
||||||
|
blocks.setdefault(_find(parent, i), []).append(i)
|
||||||
|
return list(blocks.values())
|
||||||
@@ -55,6 +55,14 @@ def _titel_aufloesen(idx: dict[str, int], t: str) -> int | None:
|
|||||||
return idx.get(_norm_titel(t)) or idx.get(_norm_titel(_titel(t)))
|
return idx.get(_norm_titel(t)) or idx.get(_norm_titel(_titel(t)))
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_dash(s: str) -> str:
|
||||||
|
"""Space-umgebene Dash-Varianten (en/em/figure/bar/hyphen) → einheitlicher Trenner ' — '.
|
||||||
|
Manche Modelle (v.a. nicht-westliche) setzen statt des Em-Dashs einen En-Dash „–"; ohne
|
||||||
|
Normalisierung scheitert der ` — `-Split komplett und der ganze Eintrag wird zum Titel.
|
||||||
|
ASCII-Bindestrich „-" bleibt unangetastet (sonst zerlegt es Formeln wie „n - 1")."""
|
||||||
|
return re.sub(r"\s+[‒–—―‐]\s+", " — ", s)
|
||||||
|
|
||||||
|
|
||||||
def _parse_auswahl(text: str) -> dict[int, str]:
|
def _parse_auswahl(text: str) -> dict[int, str]:
|
||||||
"""Parst eine Baustein-Liste: `N. Titel — Kurzbeschreibung` pro Zeile."""
|
"""Parst eine Baustein-Liste: `N. Titel — Kurzbeschreibung` pro Zeile."""
|
||||||
entries: dict[int, str] = {}
|
entries: dict[int, str] = {}
|
||||||
@@ -63,9 +71,9 @@ def _parse_auswahl(text: str) -> dict[int, str]:
|
|||||||
m = re.match(r"\s*(\d+)[.)]\s+(.*\S)", line)
|
m = re.match(r"\s*(\d+)[.)]\s+(.*\S)", line)
|
||||||
if m:
|
if m:
|
||||||
last = int(m.group(1))
|
last = int(m.group(1))
|
||||||
entries[last] = m.group(2)
|
entries[last] = _norm_dash(m.group(2))
|
||||||
elif last is not None and line.strip():
|
elif last is not None and line.strip():
|
||||||
entries[last] += " " + line.strip()
|
entries[last] += " " + _norm_dash(line.strip())
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
20
templates/Prompt/Bausteine-Block-Gruppieren.md
Normal file
20
templates/Prompt/Bausteine-Block-Gruppieren.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
Unten stehen nummerierte Baustein-Kandidaten für das Thema "{topic}". Sie stammen aus einem Ähnlichkeits-Block. Manche bezeichnen DENSELBEN Baustein oder eine Eigenschaft davon, andere sind verschieden. Gruppiere sie.
|
||||||
|
|
||||||
|
KANDIDATEN:
|
||||||
|
{eintraege}
|
||||||
|
|
||||||
|
Regeln:
|
||||||
|
- Bilde Gruppen: Nummern, die ZUM SELBEN Baustein gehören, kommen in EINE Gruppe.
|
||||||
|
- **Achte auf die Kern-Entität** (das Problem/Objekt): Clique, Vertex Cover, Set Cover, Knapsack, Dominating Set, LPT/List Scheduling … Verschiedene Entität → verschiedene Gruppen, auch bei ähnlichem Satzbau ("Lower Bound Clique" ≠ "Lower Bound Vertex Cover").
|
||||||
|
- Echte Paraphrasen ZUSAMMEN, auch bei anderen Worten ("List Scheduling" = "LPT-Algorithmus"; "Set Cover" = "Mengenüberdeckung").
|
||||||
|
- **Eigenschaften eines Problems gehören ZUM Problem-Baustein — nicht eigenständig.** Bündle mit dem Problem: seinen Komplexitäts-Status (∈ NP, NP-schwer, NP-vollständig), seinen Verifizierer / Zertifikat / NDTM, "… als Sprache / Definition", seine einzelnen Lower-Bound-Parameter (k / r / |U|).
|
||||||
|
- Beispiel: "Knapsack", "Knapsack ∈ NP", "Knapsack NP-schwer", "Knapsack NP-vollständig", "Verifizierer für Knapsack" → EINE Gruppe (Baustein "Knapsack").
|
||||||
|
- Beispiel: "Hitting Set Lower Bound (k)", "(r)", "(|U|)" → EINE Gruppe.
|
||||||
|
- GETRENNT bleiben (eigene Bausteine): verschiedene Probleme (Clique-Member ≠ Clique-Nomember); eine REDUKTION zwischen zwei Problemen ist eine eigene Technik (z.B. "3-SAT ⪯ k-Clique" bleibt eigenständig); verschiedene Verfahren/Sätze mit eigener Aussage.
|
||||||
|
- Im Zweifel zwischen zwei verschiedenen Problemen → TRENNEN. Bei Problem + seiner Eigenschaft → BÜNDELN.
|
||||||
|
- JEDE Nummer kommt in GENAU EINE Gruppe. Einzelne Bausteine sind eine Gruppe mit einem Element.
|
||||||
|
|
||||||
|
Schreibe NUR die JSON-Datei nach: {out_path}
|
||||||
|
|
||||||
|
Format (Listen von Kandidaten-Nummern; jede Nummer genau einmal):
|
||||||
|
{{"gruppen": [[1, 3], [2], [4, 5]]}}
|
||||||
@@ -1,15 +1,23 @@
|
|||||||
Zum Thema "{topic}" sind unten Bausteine, die in der Recherche nur EINMAL genannt wurden. Die Quelle wurde vollständig durchsucht. Entscheide jeden Eintrag einzeln.
|
Zum Thema "{topic}" sind unten Bausteine, die in der Recherche nur EINMAL genannt wurden. Die Quelle wurde vollständig durchsucht. Entscheide für JEDEN Eintrag: ist er ein echter, eigenständiger Baustein → `aufnehmen`, oder nicht → verwerfen (in keine Liste).
|
||||||
|
|
||||||
BEREITS BESTÄTIGTES INVENTAR (nur Kontext — nicht ändern, nicht duplizieren):
|
|
||||||
{konsens}
|
|
||||||
|
|
||||||
EINZELN GENANNTE EINTRÄGE (jeweils entscheiden):
|
EINZELN GENANNTE EINTRÄGE (jeweils entscheiden):
|
||||||
{rest}
|
{rest}
|
||||||
|
|
||||||
|
Was ein Baustein IST: eine eigenständige LERNEINHEIT — ein Konzept, ein Verfahren, ein Problem, eine Definition, ein benannter Satz MIT inhaltlicher Aussage.
|
||||||
|
|
||||||
|
Was KEIN Baustein ist (→ verwerfen):
|
||||||
|
- Bloße Verweise/Nummern ohne Inhalt: "Satz 7.18", "Bemerkung 7.22", "Lemma 6.2", "D*".
|
||||||
|
- Beweis-Fragmente/Zwischenschritte: "Beweis (b) Güte", "Beweis ⊂ Richtung", "Eigenschaft (a)".
|
||||||
|
- Zu Vages oder erkennbar Erfundenes.
|
||||||
|
|
||||||
|
Beispiele:
|
||||||
|
- "Clique-Member — k-Clique, die einen festen Knoten v enthält" → **aufnehmen** (eigenständiges Problem).
|
||||||
|
- "Satz 7.18" → **verwerfen** (bloßer Verweis, keine Aussage).
|
||||||
|
- "Beweis (b) Güte" → **verwerfen** (Fragment).
|
||||||
|
- "Satz 7.8: ΔTSP1 hat Güte 2" → **aufnehmen** (benannter Satz MIT Aussage).
|
||||||
|
|
||||||
Regeln:
|
Regeln:
|
||||||
- Fachlich valider, eigenständiger Baustein und KEINE Dublette/Variante des Inventars → `aufnehmen`.
|
- Im Zweifel an der Eigenständigkeit → eher aufnehmen. Dubletten werden später separat entfernt; hier zählt nur: echter Baustein oder Müll.
|
||||||
- Erfunden, zu vage, oder Dublette/Variante eines Inventar-Bausteins → verwerfen (in keine Liste).
|
|
||||||
- Doppelt ein Eintrag inhaltlich einen ANDEREN einzeln genannten Eintrag (gleiche Sache, andere Worte) → nur EINEN aufnehmen, den anderen verwerfen.
|
|
||||||
- Übernimm aufgenommene Einträge WÖRTLICH ("Titel — Kurzbeschreibung"), nicht umformulieren.{final}
|
- Übernimm aufgenommene Einträge WÖRTLICH ("Titel — Kurzbeschreibung"), nicht umformulieren.{final}
|
||||||
|
|
||||||
Schreibe NUR die JSON-Datei nach: {out_path}
|
Schreibe NUR die JSON-Datei nach: {out_path}
|
||||||
|
|||||||
20
templates/Prompt/Bausteine-Paar-Filter.md
Normal file
20
templates/Prompt/Bausteine-Paar-Filter.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
Zwei Recherchen haben Bausteine für das Thema "{topic}" notiert. Entscheide für JEDES Paar, ob A und B DENSELBEN Baustein bezeichnen (dasselbe Konzept, nur anders formuliert) → **ja**, oder ob es ZWEI UNTERSCHIEDLICHE Bausteine sind → **nein**.
|
||||||
|
|
||||||
|
PAARE:
|
||||||
|
{paare}
|
||||||
|
|
||||||
|
Regeln:
|
||||||
|
- **Achte zuerst auf die KERN-ENTITÄT** (das Problem/Objekt, um das es geht): Clique, Vertex Cover, Independent Set, Dominating Set, Set Cover, FVS, Knapsack … Sind die Entitäten VERSCHIEDEN → **nein**, egal wie gleich der Satzbau ist.
|
||||||
|
- Gleicher Satzbau täuscht. Diese Paare sind **nein** (verschiedene Entität trotz fast identischer Formulierung):
|
||||||
|
- "Lower Bound **Clique** bzgl. Knoten" ↔ "Lower Bound **Vertex Cover** bzgl. Knoten"
|
||||||
|
- "Lower Bound Clique bzgl. **Knoten**" ↔ "Lower Bound Clique bzgl. **Kanten**"
|
||||||
|
- "Verifizierer für **FVS**" ↔ "Verifizierer für **Knapsack**"
|
||||||
|
- "**Cliquenproblem**" ↔ "**Vertex-Cover-Problem**"
|
||||||
|
- **ja** nur bei echter Bedeutungsgleichheit: gleiche Lösung desselben Problems, dieselbe Entität, nur andere Formulierung/Benennung (z. B. "SET COVER" ↔ "Mengenüberdeckungsproblem", "Cliquenproblem" ↔ "k-CLIQUE", "List Scheduling" ↔ "LPT-Algorithmus").
|
||||||
|
- **nein** auch bei verschiedenen Aspekten desselben Problems: "Set Cover (Problem)" ↔ "Set Cover ETH-Schranke"; ein Problem ↔ seine Reduktion auf ein anderes; ein Problem ↔ sein Verifizierer.
|
||||||
|
- Im Zweifel **nein** — lieber zwei getrennte Bausteine als zwei Konzepte fälschlich verschmelzen.
|
||||||
|
|
||||||
|
Schreibe NUR die JSON-Datei nach: {out_path}
|
||||||
|
|
||||||
|
Format (jede Paar-Nummer aus der Liste mit "ja" oder "nein"; kein weiterer Text in der Datei):
|
||||||
|
{{"paare": {{"1": "ja", "2": "nein"}}}}
|
||||||
Reference in New Issue
Block a user