update
This commit is contained in:
@@ -33,11 +33,14 @@ EMB_FLOOR = 0.82 # casefolded title cosine (own threshold, NOT the pipel
|
||||
SECTION_CHARS = 4000 # own paragraph splitter — independent of _text_sections
|
||||
COVER_MIN_TOKENS = 2 # distinctive block tokens a section must share to count as covered
|
||||
FREMD_MIN_TOKENS = 1 # distinctive title tokens that must appear in the corpus
|
||||
CONCEPT_COVER_RATIO = 0.6 # fraction of a named result's stems a block must share to count as covered
|
||||
LLM_SAMPLE = 12 # pairs/sections per judge call with --llm
|
||||
# Note 0-10, deterministisch aus den Quoten (transparent, diffbar — keine LLM-"Gefühlsnote").
|
||||
# Lücken/Fremd wiegen am schwersten (fehlender/falscher Stoff); Dubletten-VERDACHT enthält
|
||||
# bewusst Rauschen und wiegt daher wenig.
|
||||
NOTE_GEWICHTE = {"luecken": 3.0, "fremd": 2.5, "unechte_bloecke": 2.5, "hygiene": 0.5}
|
||||
# Lücken/Fremd wiegen am schwersten (fehlender/falscher Stoff). konzept_luecken (benannte Kernresultate
|
||||
# ohne Baustein) ist heuristisch → mittleres Gewicht. Dubletten-VERDACHT enthält bewusst Rauschen,
|
||||
# bildet aber das Nutzerproblem (Dopplungen) ab → leichtestes Gewicht, aber nicht null.
|
||||
NOTE_GEWICHTE = {"luecken": 3.0, "fremd": 2.5, "unechte_bloecke": 2.5, "konzept_luecken": 1.5,
|
||||
"dubletten_verdacht": 1.0, "hygiene": 0.5}
|
||||
# subs/artefacts only exist after board 2 — at gate time these quotas would always be 0
|
||||
# and water down the inventory score, hence a separate score.
|
||||
# sub_dubletten counts only with --llm (confirmed pairs); the bare candidate list is
|
||||
@@ -181,6 +184,48 @@ def luecken(blocks: list[dict], subs_by_norm: dict[str, list[str]], corpus: dict
|
||||
return out
|
||||
|
||||
|
||||
# A NAMED result carries a concept name, not just a number: "Satz 7.13 (Christofides)",
|
||||
# "Satz 6.24: Cook-Levin". These are the core statements the token-based `luecken` misses — 108
|
||||
# over-granular blocks cover every SECTION, yet the named core result may have no block at all.
|
||||
# Generic + domain-safe: the NUMBER is mandatory (a numbered labeled unit is a formal statement in
|
||||
# a structured document, any field), so prose like "ein Satz von Goethe" (no number) never matches.
|
||||
_NAMED_RESULT_RE = re.compile(
|
||||
r'\b(?:Satz|Lemma|Korollar|Theorem|Proposition|Folgerung|Definition|Algorithmus)\s+\d+(?:\.\d+)*\s*'
|
||||
r'(?:\(\s*([^()\n]{3,60}?)\s*\)|:\s*([^\n.;·]{3,60}?)\s*(?:[.\n;·]|$))', re.M)
|
||||
|
||||
|
||||
def _stem(t: str) -> str:
|
||||
"""Declension-tolerant token stem: drop trailing digits, keep the 5-char prefix
|
||||
('Eulerschen'/'Eulerscher' → 'euler', 'Kreise'/'Kreis' → 'kreis')."""
|
||||
return t.rstrip("0123456789")[:5]
|
||||
|
||||
|
||||
def _named_results(corpus: dict[str, str]) -> dict[str, set[str]]:
|
||||
"""Named/attributed corpus results → {concept name: distinctive stems}. A bare 'Satz 7.18'
|
||||
(number, no name) yields nothing to match. Same catalogue vocabulary as the title strip."""
|
||||
out: dict[str, set[str]] = {}
|
||||
for text in corpus.values():
|
||||
for m in _NAMED_RESULT_RE.finditer(text):
|
||||
name = (m.group(1) or m.group(2) or "").strip()
|
||||
toks = _distinctive(name)
|
||||
if len(name) >= 3 and toks:
|
||||
out.setdefault(name, set()).update(_stem(t) for t in toks)
|
||||
return out
|
||||
|
||||
|
||||
def konzept_luecken(blocks: list[dict], named: dict[str, set[str]]) -> list[str]:
|
||||
"""Named corpus results that NO block covers — concept gaps the token-based `luecken` cannot see.
|
||||
Covered = a block whose title+description share ≥ CONCEPT_COVER_RATIO of the result's distinctive
|
||||
stems (declension-tolerant). Errs toward 'covered' so the heuristic never invents a false gap."""
|
||||
anchors = [{_stem(t) for t in _distinctive(b["title"]) | _distinctive(b.get("description") or "")}
|
||||
for b in blocks]
|
||||
out = []
|
||||
for name, stems in named.items():
|
||||
if not any(len(stems & a) >= CONCEPT_COVER_RATIO * len(stems) for a in anchors):
|
||||
out.append(name)
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def fremd(blocks: list[dict], corpus: dict[str, str]) -> list[str]:
|
||||
"""Blocks whose distinctive title tokens never appear in the corpus (scope creep).
|
||||
Token/stem match, NOT raw substring — 'bergang' ⊂ 'Übergang' had whitewashed the
|
||||
@@ -384,6 +429,8 @@ async def qa_report(topic: str, llm: bool = False) -> dict | None:
|
||||
sd = sub_dubletten(sub_rows)
|
||||
lk = luecken(blocks, subs_by_norm, corpus) if corpus else []
|
||||
fr = fremd(blocks, corpus) if corpus else []
|
||||
named = _named_results(corpus) if corpus else {}
|
||||
kl = konzept_luecken(blocks, named) if corpus else []
|
||||
bl = beleg(blocks, sub_rows)
|
||||
hy = hygiene(blocks)
|
||||
n_sections = sum(len(_sections(t)) for t in corpus.values()) or 1
|
||||
@@ -452,13 +499,15 @@ async def qa_report(topic: str, llm: bool = False) -> dict | None:
|
||||
"dubletten_verdacht": round(len(d) / max(len(blocks), 1), 3),
|
||||
"luecken": round(len(_zaehlbare_luecken(lk, llm)) / n_sections, 3),
|
||||
"fremd": round(len(fr) / max(len(blocks), 1), 3),
|
||||
**({"konzept_luecken": round(len(kl) / max(len(named), 1), 3)} if corpus else {}),
|
||||
"hygiene": round(len(hy) / max(len(blocks), 1), 3),
|
||||
**({"unechte_bloecke": round(len(unecht) / max(len(blocks), 1), 3)} if unecht is not None else {}),
|
||||
},
|
||||
"quoten_artefakte": quoten_art,
|
||||
**({"unecht": unecht} if unecht is not None else {}),
|
||||
**({"fremd_freigesprochen": fremd_frei} if fremd_frei else {}),
|
||||
"dubletten": d, "sub_dubletten": sd, "luecken": lk, "fremd": fr, "beleg": bl, "hygiene": hy,
|
||||
"dubletten": d, "sub_dubletten": sd, "luecken": lk, "konzept_luecken": kl,
|
||||
"fremd": fr, "beleg": bl, "hygiene": hy,
|
||||
"artefakte": art,
|
||||
"lauf": summary,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user