Add LLM role-check grounding + labelled training-set pipeline

- llm_extract.py: match mode now window-parallel with retrieval pre-filter,
  claim dedup, retry, and enable_thinking=false (vLLM) -> ~36x faster per call;
  n_failed_windows/ok flags so an interrupted run never records bogus 0s.
- build_rdf_dataset.py:
  - gold now includes the share-class level (hasShareClass/ticker/className)
  - grounding modes alias|llm|name|context|none (--grounding); llm reads the
    role-check verdicts from match_all.jsonl
  - label stage: per-triple extractable + per-sample FULL/PARTIAL/NONE
  - trainset stage: combines GROUNDED triples with focused TEXT EXCERPTS cut
    around the actual provider statement (evidence), not the multi-MB book
  - split --src to split trainset.jsonl (trust-level, no leakage)
- helper scripts: watch_match.sh, resume_match.sh (crash/sleep-safe resume),
  finalize_dataset.sh
- final dataset: 335/335 trusts, 85% text<->gold agreement, 334 samples,
  10,689 grounded triples, train/val/test 264/35/35

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Herzog 2026-06-10 13:52:50 +02:00
parent 09798eb27a
commit 991715ab76
7 changed files with 884 additions and 394 deletions

12
.gitignore vendored
View File

@ -44,3 +44,15 @@ __pycache__/
# OS / editor # OS / editor
.DS_Store .DS_Store
.claude/ .claude/
data/rdf_poc/samples_labelled.jsonl
data/rdf_poc/samples_full.jsonl
data/rdf_poc/labelled_partial.jsonl
data/rdf_poc/labelled_none.jsonl
data/rdf_poc/labelled_full.jsonl
data/rdf_poc/trainset.jsonl
data/rdf_poc/match_input_remaining.jsonl
data/rdf_poc/match_remaining*.jsonl
data/rdf_poc/match_all_clean*.jsonl
data/rdf_poc/match_input_sc.jsonl
data/rdf_poc/match_remaining.bak
data/rdf_poc/match_all.jsonl

View File

@ -146,6 +146,18 @@ def build_gold(ncen_dir: Path, custodian_scope: str = "primary"):
custodians = by_fund("CUSTODIAN.tsv", "CUSTODIAN_NAME", "CUSTODIAN_LEI") custodians = by_fund("CUSTODIAN.tsv", "CUSTODIAN_NAME", "CUSTODIAN_LEI")
admins = by_fund("ADMIN.tsv", "ADMIN_NAME", "ADMIN_LEI") admins = by_fund("ADMIN.tsv", "ADMIN_NAME", "ADMIN_LEI")
# Share classes per FUND_ID (the 3rd hierarchy level: Trust->Fund->ShareClass).
# Each class has a full name and a ticker, both typically in the prospectus.
share_classes = defaultdict(list)
for r in _read_tsv(d / "SHARES_OUTSTANDING.tsv"):
fid = r.get("FUND_ID", "")
cid = (r.get("CLASS_ID", "") or "").strip()
nm = (r.get("CLASS_NAME", "") or "").strip()
if fid and cid:
share_classes[fid].append(
{"class_id": cid, "name": nm,
"ticker": (r.get("TICKER", "") or "").strip()})
# Custodian scoping. Foreign sub-custodians (IS_SUB_CUSTODIAN=Y, ~88% of rows) # Custodian scoping. Foreign sub-custodians (IS_SUB_CUSTODIAN=Y, ~88% of rows)
# appear ONLY in N-CEN, never in the prospectus prose, and dominate the edge # appear ONLY in N-CEN, never in the prospectus prose, and dominate the edge
# count (66% of all edges). They are therefore unextractable noise for a # count (66% of all edges). They are therefore unextractable noise for a
@ -225,6 +237,24 @@ def build_gold(ncen_dir: Path, custodian_scope: str = "primary"):
entities[o] = {"type": "Administrator", "label": a["name"], "lei": a["lei"]} entities[o] = {"type": "Administrator", "label": a["name"], "lei": a["lei"]}
triples.append((f_iri, "administrator", o)) triples.append((f_iri, "administrator", o))
# share classes (3rd level): Fund -hasShareClass-> Class,
# Class -ticker-> "TICKER", Class -className-> "Full Name".
# ticker/className objects are literals (stored as label).
for sc in share_classes.get(fid, []):
c_iri = "class:" + sc["class_id"]
entities[c_iri] = {"type": "ShareClass",
"label": sc["name"] or sc["class_id"],
"class_id": sc["class_id"]}
triples.append((f_iri, "hasShareClass", c_iri))
if sc["name"]:
lit = "lit:" + _slug(sc["name"])
entities[lit] = {"type": "Literal", "label": sc["name"]}
triples.append((c_iri, "className", lit))
if sc["ticker"]:
lit = "lit:" + _slug(sc["ticker"])
entities[lit] = {"type": "Literal", "label": sc["ticker"]}
triples.append((c_iri, "ticker", lit))
# dedupe triples # dedupe triples
triples = sorted(set(triples)) triples = sorted(set(triples))
if not triples: if not triples:
@ -1015,18 +1045,20 @@ def _bucket(cik: str) -> float:
return int(h[:8], 16) / 0xFFFFFFFF return int(h[:8], 16) / 0xFFFFFFFF
def build_split(val_frac: float = 0.10, test_frac: float = 0.10): def build_split(val_frac: float = 0.10, test_frac: float = 0.10, src=None):
"""Split samples.jsonl into train/val/test JSONL, partitioned by TRUST (CIK). """Split a sample file into train/val/test JSONL, partitioned by TRUST (CIK).
Splitting by trust (not by fund) prevents leakage: two funds of the same Splitting by trust (not by fund) prevents leakage: two funds of the same
trust share advisers, distributors and custodians, so allowing them into trust share advisers, distributors and custodians, so allowing them into
different splits would let the model memorise trust-specific entities. The different splits would let the model memorise trust-specific entities. The
assignment is a deterministic hash of the CIK, so the split is reproducible assignment is a deterministic hash of the CIK, so the split is reproducible
and stable as new samples are added. and stable as new samples are added. `src` defaults to samples.jsonl; pass
trainset.jsonl to split the training-ready set.
""" """
if not SAMPLES_PATH.exists(): src_path = Path(src) if src else SAMPLES_PATH
log.error("run `samples` first"); return if not src_path.exists():
rows = [json.loads(l) for l in open(SAMPLES_PATH, encoding="utf-8")] log.error("source not found: %s", src_path); return
rows = [json.loads(l) for l in open(src_path, encoding="utf-8")]
out = {"train": [], "val": [], "test": []} out = {"train": [], "val": [], "test": []}
for r in rows: for r in rows:
b = _bucket(r["cik"]) b = _bucket(r["cik"])
@ -1052,6 +1084,196 @@ def build_split(val_frac: float = 0.10, test_frac: float = 0.10):
print(f" trust overlap across splits: {len(overlap)} (should be 0)") print(f" trust overlap across splits: {len(overlap)} (should be 0)")
SAMPLES_LABELLED_PATH = OUT_DIR / "samples_labelled.jsonl"
def build_label():
"""Label extractability per triple and per sample, using the LLM match verdict.
Reads the full annotated samples (SAMPLES_FULL_PATH, all gold triples with the
cheap alias/grounded/context flags) and the LLM role-check verdicts
(LLM_MATCH_PATH). For each triple it sets:
- extractable : the authoritative label = LLM role-check verdict
(the entity plays that role in the text);
- alias_grounded/... : the cheap lexical flags (already present) for analysis.
For each sample it sets:
- n_extractable / n_triples
- extractability = FULL (all triples extractable)
| PARTIAL (some)
| NONE (none)
Writes:
- SAMPLES_LABELLED_PATH (every sample, every triple labelled)
- samples_full/partial/none.jsonl (samples split by label)
- a distribution report (per label and per relation).
"""
if not SAMPLES_FULL_PATH.exists():
log.error("run `samples` first (need %s)", SAMPLES_FULL_PATH); return
verdicts = _load_llm_verdicts()
if not verdicts:
log.error("no LLM match verdicts at %s — run `llm_extract.py --mode match` "
"first", LLM_MATCH_PATH); return
by_label = {"FULL": [], "PARTIAL": [], "NONE": []}
rel_tot = defaultdict(int); rel_ext = defaultdict(int)
n = 0
with open(SAMPLES_LABELLED_PATH, "w", encoding="utf-8") as out:
for line in open(SAMPLES_FULL_PATH, encoding="utf-8"):
r = json.loads(line)
cik = r["cik"]
triples = []
n_ext = 0
for t in r.get("target_triples", []):
ext = verdicts.get((cik, t["p"], t["o"]), False)
rel_tot[t["p"]] += 1
rel_ext[t["p"]] += int(ext)
n_ext += int(ext)
triples.append({**t, "extractable": ext})
ntot = len(triples)
label = ("FULL" if ntot and n_ext == ntot else
"NONE" if n_ext == 0 else "PARTIAL")
r["target_triples"] = triples
r["extractability"] = label
r.setdefault("stats", {})
r["stats"]["n_extractable"] = n_ext
r["stats"]["n_triples"] = ntot
out.write(json.dumps(r, ensure_ascii=False) + "\n")
by_label[label].append(r)
n += 1
for lab, recs in by_label.items():
with open(OUT_DIR / f"labelled_{lab.lower()}.jsonl", "w", encoding="utf-8") as f:
for r in recs:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
tot_tr = sum(rel_tot.values()); ext_tr = sum(rel_ext.values())
print(f"\nLabelled {n} samples -> {SAMPLES_LABELLED_PATH}")
print(f" sample extractability:")
for lab in ("FULL", "PARTIAL", "NONE"):
c = len(by_label[lab])
print(f" {lab:8s} {c:>5} ({100*c/max(1,n):>3.0f}%) -> labelled_{lab.lower()}.jsonl")
print(f" triple extractability: {ext_tr}/{tot_tr} = {100*ext_tr/max(1,tot_tr):.0f}%")
print(f"\n {'relation':16s}{'extractable':>12}{'%':>6}")
for p in sorted(rel_tot, key=lambda x: -rel_tot[x]):
print(f" {p:16s}{str(rel_ext[p])+'/'+str(rel_tot[p]):>12}"
f"{100*rel_ext[p]/rel_tot[p]:>5.0f}%")
TRAINSET_PATH = OUT_DIR / "trainset.jsonl"
EXCERPT_RADIUS = 600 # chars around each grounded name
_ROLE_HINT = ("custodian", "custody", "advis", "transfer agent", "administrat",
"distributor", "underwriter", "serves as", "acts as", "series of")
def _evidence_excerpt(text_norm_full, raw_text, label, predicate=None):
"""Cut a coherent excerpt of raw_text around the BEST mention of the entity.
A common firm token (e.g. "alliancebernstein") appears hundreds of times, so
the first occurrence is usually boilerplate. We therefore score every
occurrence of the name's most distinctive token by whether a role keyword
(custodian/adviser/transfer agent/...) appears within +/- a window, and pick
the occurrence with the nearest role hint -- that is the actual service-provider
statement, the evidence we want. Falls back to the consecutive-tokens match,
then the first token occurrence. Returns (excerpt, start) or (None, -1).
"""
tk = _alias_tokens(label)
if not tk:
return None, -1
low = raw_text.lower()
distinctive = max(tk, key=len)
occurrences = [m.start() for m in re.finditer(re.escape(distinctive), low)]
pos = -1
if occurrences:
# prefer an occurrence with a role keyword nearby
best = None
for p in occurrences:
win = low[max(0, p - 250): p + 250]
if any(k in win for k in _ROLE_HINT):
best = p
break
pos = best if best is not None else occurrences[0]
if pos == -1:
ac = _acronym(label)
if len(ac) >= 3:
m = re.search(r"\b" + re.escape(ac) + r"\b", low)
pos = m.start() if m else -1
if pos == -1:
return None, -1
a = max(0, pos - EXCERPT_RADIUS)
b = min(len(raw_text), pos + EXCERPT_RADIUS)
return raw_text[a:b].strip(), a
def build_trainset():
"""Assemble the training-ready dataset: per sample, the relevant TEXT EXCERPTS
combined with ONLY the grounded (extractable) triples as the target.
For each labelled sample (samples_labelled.jsonl):
- keep only triples with extractable=True (every target is in the text);
- for each, cut a coherent excerpt of the prospectus around the entity name
and merge overlapping excerpts into a compact `input_text` (fits a model
context window, unlike the multi-MB full book);
- emit both serializations of the kept triples.
Samples with no grounded triple (NONE) are skipped. Output: trainset.jsonl,
then split into train/val/test by trust.
"""
if not SAMPLES_LABELLED_PATH.exists():
log.error("run `label` first (need %s)", SAMPLES_LABELLED_PATH); return
gold = {json.loads(l)["cik"]: json.loads(l)
for l in open(GOLD_PATH, encoding="utf-8")}
n = n_skip = 0
tot_in = tot_tr = 0
with open(TRAINSET_PATH, "w", encoding="utf-8") as out:
for line in open(SAMPLES_LABELLED_PATH, encoding="utf-8"):
r = json.loads(line)
ents = gold.get(r["cik"], {}).get("entities", {})
full = r["input_text"]
kept = [t for t in r["target_triples"] if t.get("extractable")]
if not kept:
n_skip += 1
continue
# collect excerpts around each grounded object name; merge overlaps
spans = []
for t in kept:
lbl = ents.get(t["o"], {}).get("label", t["o"])
exc, a = _evidence_excerpt(None, full, lbl)
if exc:
spans.append((a, a + len(exc)))
spans.sort()
merged = []
for a, b in spans:
if merged and a <= merged[-1][1] + 200:
merged[-1] = (merged[-1][0], max(merged[-1][1], b))
else:
merged.append((a, b))
excerpt = "\n...\n".join(full[a:b].strip() for a, b in merged) or full[:2000]
# restrict entities to kept triples
ref = set()
for t in kept:
ref.add(t["s"]); ref.add(t["o"])
se = {k: ents[k] for k in ref if k in ents}
rec = {
"sample_id": r["sample_id"], "cik": r["cik"],
"trust_name": r.get("trust_name"),
"input_text": excerpt,
"ontology": ontology_schema(kept, se),
"target_triples": kept,
"target_serialized": serialize_triples(kept, se),
"target_serialized_plain": serialize_triples_plain(kept, se),
"stats": {"input_chars": len(excerpt), "n_triples": len(kept),
"text_to_json_ratio": round(
len(excerpt) / max(1, len(serialize_triples(kept, se))), 1)},
}
out.write(json.dumps(rec, ensure_ascii=False) + "\n")
n += 1; tot_in += len(excerpt); tot_tr += len(kept)
print(f"\nTrainset: {n} samples -> {TRAINSET_PATH} ({n_skip} NONE-samples skipped)")
if n:
print(f" grounded triples (targets): {tot_tr:,}")
print(f" mean input excerpt: {tot_in//n:,} chars (was multi-MB full book)")
print(f" -> split with: python build_rdf_dataset.py split --src trainset.jsonl")
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def main(): def main():
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
@ -1073,9 +1295,12 @@ def main():
help="context=keep triples whose name+relation-keyword appear " help="context=keep triples whose name+relation-keyword appear "
"(default, enforces text-extractability); name=name anywhere; " "(default, enforces text-extractability); name=name anywhere; "
"none=keep all, only annotate") "none=keep all, only annotate")
sub.add_parser("label") # label extractability (needs prior match run)
sub.add_parser("trainset") # assemble training set: excerpts + grounded triples
sp = sub.add_parser("split") sp = sub.add_parser("split")
sp.add_argument("--val-frac", type=float, default=0.10) sp.add_argument("--val-frac", type=float, default=0.10)
sp.add_argument("--test-frac", type=float, default=0.10) sp.add_argument("--test-frac", type=float, default=0.10)
sp.add_argument("--src", default=None, help="source jsonl (default samples.jsonl; use trainset.jsonl)")
a = sub.add_parser("all"); a.add_argument("--ncen", default="data/ncen/2025q3") a = sub.add_parser("all"); a.add_argument("--ncen", default="data/ncen/2025q3")
a.add_argument("--limit", type=int, default=25) a.add_argument("--limit", type=int, default=25)
a.add_argument("--max-filings", type=int, default=8) a.add_argument("--max-filings", type=int, default=8)
@ -1095,8 +1320,12 @@ def main():
elif args.cmd == "samples": elif args.cmd == "samples":
GROUNDING_MODE = args.grounding GROUNDING_MODE = args.grounding
build_samples(per_fund=not args.whole_trust) build_samples(per_fund=not args.whole_trust)
elif args.cmd == "label":
build_label()
elif args.cmd == "trainset":
build_trainset()
elif args.cmd == "split": elif args.cmd == "split":
build_split(val_frac=args.val_frac, test_frac=args.test_frac) build_split(val_frac=args.val_frac, test_frac=args.test_frac, src=args.src)
elif args.cmd == "all": elif args.cmd == "all":
GROUNDING_MODE = args.grounding GROUNDING_MODE = args.grounding
build_gold(Path(args.ncen), custodian_scope=args.custodian_scope) build_gold(Path(args.ncen), custodian_scope=args.custodian_scope)

File diff suppressed because one or more lines are too long

48
finalize_dataset.sh Normal file
View File

@ -0,0 +1,48 @@
#!/usr/bin/env bash
# ------------------------------------------------------------------
# finalize_dataset.sh — konsolidiert alle Match-Teile und baut den
# trainingsfertigen Datensatz: label -> trainset -> split.
#
# bash finalize_dataset.sh
#
# Idempotent: konsolidiert nur SAUBERE Trusts (n_failed_windows==0) aus
# allen match_*.jsonl Teil-Dateien in match_all.jsonl, dann Pipeline.
# ------------------------------------------------------------------
set -euo pipefail
cd "$(dirname "$0")"
python3 - <<'PY'
import json, glob, os
parts = (["data/rdf_poc/match_all_clean79.jsonl",
"data/rdf_poc/match_remaining.jsonl"]
+ glob.glob("data/rdf_poc/match_remaining_*.jsonl") + glob.glob("data/rdf_poc/match_remaining_final.jsonl"))
good = {}
for p in parts:
if not os.path.exists(p): continue
for l in open(p):
try: r = json.loads(l)
except: continue
if r.get("n_failed_windows", 0) == 0 and r.get("triples") is not None:
good[r["cik"]] = r
with open("data/rdf_poc/match_all.jsonl", "w") as f:
for r in good.values():
f.write(json.dumps(r, ensure_ascii=False) + "\n")
allin = [json.loads(l) for l in open("data/rdf_poc/match_input.jsonl")]
remaining = [r for r in allin if r["cik"] not in good]
tg = tt = 0
for r in good.values():
for t in r["triples"]:
tt += 1; tg += 1 if t.get("llm_grounded") else 0
print(f"konsolidiert: {len(good)}/335 Trusts, {100*tg/max(1,tt):.0f}% Uebereinstimmung, "
f"{len(remaining)} noch offen")
PY
echo ""
echo "=== label (FULL/PARTIAL/NONE) ==="
python3 build_rdf_dataset.py label
echo ""
echo "=== trainset (Auszuege + grounded Tripel) ==="
python3 build_rdf_dataset.py trainset
echo ""
echo "Fertig. trainset.jsonl ist der trainingsfertige Datensatz."
echo "Split: python3 build_rdf_dataset.py split (auf samples-Basis)"

View File

@ -46,6 +46,8 @@ OLLAMA_URL = "http://localhost:11434/api/chat"
BACKEND = "ollama" BACKEND = "ollama"
VLLM_URL = "http://silicon.fhgr.ch:7080/v1/chat/completions" VLLM_URL = "http://silicon.fhgr.ch:7080/v1/chat/completions"
API_KEY = "" API_KEY = ""
NO_THINK = True # disable reasoning "thinking" on vLLM (huge speedup, see below)
WINDOW_WORKERS = 8 # concurrent windows per trust (trust-workers x this <= server cap)
SYSTEM = ( SYSTEM = (
"You are an information-extraction engine. You read a U.S. mutual-fund " "You are an information-extraction engine. You read a U.S. mutual-fund "
@ -94,12 +96,14 @@ def ollama_chat(model, system, prompt, timeout=600, num_ctx=32768):
"model": model, "model": model,
"messages": messages, "messages": messages,
"temperature": 0, "temperature": 0,
# Reasoning model: it spends several thousand tokens "thinking" before
# emitting JSON. Too small a budget -> it never reaches the answer and
# returns empty content. Keep this generous.
"max_tokens": 8000, "max_tokens": 8000,
"response_format": {"type": "json_object"}, "response_format": {"type": "json_object"},
} }
# Disable the reasoning model's "thinking": for this yes/no grounding task
# it spends thousands of tokens deliberating before the JSON, making each
# call ~16x slower (7.8s -> 0.5s measured) with no quality gain.
if NO_THINK:
body["chat_template_kwargs"] = {"enable_thinking": False}
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
if API_KEY: if API_KEY:
headers["Authorization"] = f"Bearer {API_KEY}" headers["Authorization"] = f"Bearer {API_KEY}"
@ -209,12 +213,18 @@ MATCH_SYSTEM = (
) )
MATCH_PROMPT = """\ MATCH_PROMPT = """\
For each numbered CLAIM below, decide whether the TEXT supports it for the fund. For each numbered CLAIM below, decide whether the TEXT supports it.
A claim is supported only if the TEXT states that the named entity plays that Use ONLY the TEXT; treat name variants/abbreviations as equal (e.g. "BNY Mellon"
role (advisedBy=investment adviser, subAdvisedBy=sub-adviser, = "The Bank of New York Mellon").
transferAgent=transfer agent, custodian=custodian, administrator=administrator,
underwrittenBy=distributor/underwriter, seriesOf=the fund is a series of that Two kinds of claim:
trust). Use ONLY the TEXT; treat name variants/abbreviations as equal. - ROLE claims (advisedBy, subAdvisedBy, transferAgent, custodian, administrator,
underwrittenBy, seriesOf): supported only if the TEXT states the named entity
plays that role for the fund (advisedBy=investment adviser, underwrittenBy=
distributor/underwriter, seriesOf=the fund is a series of that trust, etc.).
- PRESENCE claims (hasShareClass, className, ticker): supported if the value
simply appears in the TEXT the share-class name or ticker symbol is printed
anywhere (e.g. in the fee table or cover). No role needed.
Output JSON: {{"results": [{{"id": <n>, "supported": true|false}}, ...]}} with one Output JSON: {{"results": [{{"id": <n>, "supported": true|false}}, ...]}} with one
entry per claim id. entry per claim id.
@ -281,7 +291,7 @@ def _window_relevant(w_low, name_tokens):
return any(tok in w_low for tok in name_tokens) return any(tok in w_low for tok in name_tokens)
def match_windowed(model, gold_triples, text, win_chars, overlap, num_ctx, timeout=600): def match_windowed(model, gold_triples, text, win_chars, overlap, num_ctx, timeout=150):
"""For each gold triple, is it supported somewhere in the text? """For each gold triple, is it supported somewhere in the text?
Retrieval pre-filter: only windows that contain a relation keyword or a Retrieval pre-filter: only windows that contain a relation keyword or a
@ -297,37 +307,65 @@ def match_windowed(model, gold_triples, text, win_chars, overlap, num_ctx, timeo
name_tokens.update(_name_tokens(t.get("o", ""))) name_tokens.update(_name_tokens(t.get("o", "")))
step = max(1, win_chars - overlap) step = max(1, win_chars - overlap)
windows = [text[i:i + win_chars] for i in range(0, max(1, len(text)), step)] windows = [text[i:i + win_chars] for i in range(0, max(1, len(text)), step)]
# keep only relevant windows (retrieval pre-filter)
kept = [w for w in windows
if len(w.strip()) >= 200 and _window_relevant(w.lower(), name_tokens)]
supported = [False] * len(gold_triples) supported = [False] * len(gold_triples)
n_win = n_fail = 0 evidence = [None] * len(gold_triples) # supporting window text per triple
for w in windows: n_fail = 0
if len(w.strip()) < 200:
continue def run_window(w):
if not _window_relevant(w.lower(), name_tokens):
continue # retrieval pre-filter: no provider signal in this window
prompt = MATCH_PROMPT.format(claims=claims, text=w) prompt = MATCH_PROMPT.format(claims=claims, text=w)
ctx = min(num_ctx, max(8192, int(len(prompt) / 3.2) + 2048)) ctx = min(num_ctx, max(8192, int(len(prompt) / 3.2) + 2048))
ok = False for attempt in range(2):
for attempt in range(2): # one retry on transient failure
try: try:
raw = ollama_chat(model, MATCH_SYSTEM, prompt, timeout=timeout, num_ctx=ctx) raw = ollama_chat(model, MATCH_SYSTEM, prompt, timeout=timeout, num_ctx=ctx)
res = parse_results(raw, len(gold_triples)) return parse_results(raw, len(gold_triples)), w
supported = [a or b for a, b in zip(supported, res)]
ok = True
break
except Exception as e: except Exception as e:
if attempt == 0: if attempt == 0:
time.sleep(3) time.sleep(3)
else: else:
print(f" window error: {e}") print(f" window error: {e}")
if not ok: return None, w
n_fail += 1
n_win += 1 # Send a trust's windows CONCURRENTLY: one big book's ~10-15 windows then
# early exit: once every claim is supported, no need to scan further # saturate the server's batch on their own (sequential per-window left the
if all(supported): # server idle). WINDOW_WORKERS caps in-flight calls per trust.
break from concurrent.futures import ThreadPoolExecutor
# If windows failed, the 'False' entries are unreliable (could be missed calls, if kept:
# not true absences). Signal this so the caller does not record bogus 0s. with ThreadPoolExecutor(max_workers=min(WINDOW_WORKERS, len(kept))) as ex:
return supported, n_win, n_fail for res, w in ex.map(run_window, kept):
if res is None:
n_fail += 1
continue
for i, s in enumerate(res):
if s and not supported[i]:
supported[i] = True
evidence[i] = w # first window that supports triple i
return supported, len(kept), n_fail, evidence
def _excerpt(window, name, radius=400):
"""Return a tight excerpt of `window` around the first occurrence of `name`.
Locates the most distinctive token of the entity name and returns +/- radius
chars around it (the evidence the model used). Falls back to the window head
if the token is not locatable. Returns None if no window.
"""
if not window:
return None
toks = [w for w in re.sub(r"[^a-z0-9 ]", " ", (name or "").lower()).split()
if len(w) >= 4]
low = window.lower()
pos = -1
for tok in sorted(toks, key=len, reverse=True):
p = low.find(tok)
if p != -1:
pos = p; break
if pos == -1:
return window[:radius * 2].strip()
a = max(0, pos - radius)
return window[a:pos + radius].strip()
def _match_one(args, r): def _match_one(args, r):
@ -351,19 +389,29 @@ def _match_one(args, r):
distinct.append({"p": t["p"], "o": t.get("o")}) distinct.append({"p": t["p"], "o": t.get("o")})
key_of.append(seen[k]) key_of.append(seen[k])
n_fail = 0 n_fail = 0
d_ev = [None] * len(distinct)
try: try:
if args.window and len(text) > args.window: if args.window and len(text) > args.window:
d_sup, n_win, n_fail = match_windowed( d_sup, n_win, n_fail, d_ev = match_windowed(
args.model, distinct, text, args.window, args.overlap, args.num_ctx) args.model, distinct, text, args.window, args.overlap, args.num_ctx)
else: else:
prompt = MATCH_PROMPT.format(claims=_claim_lines(distinct), text=text) prompt = MATCH_PROMPT.format(claims=_claim_lines(distinct), text=text)
ctx = min(args.num_ctx, max(8192, int(len(prompt) / 3.2) + 2048)) ctx = min(args.num_ctx, max(8192, int(len(prompt) / 3.2) + 2048))
raw = ollama_chat(args.model, MATCH_SYSTEM, prompt, num_ctx=ctx) raw = ollama_chat(args.model, MATCH_SYSTEM, prompt, num_ctx=ctx)
d_sup = parse_results(raw, len(distinct)); n_win = 1 d_sup = parse_results(raw, len(distinct)); n_win = 1
d_ev = [text if s else None for s in d_sup]
except Exception as e: except Exception as e:
print(f" [{r.get('sample_id')}] ERROR: {e}") print(f" [{r.get('sample_id')}] ERROR: {e}")
d_sup = [False] * len(distinct); n_win = 0; n_fail = 1 d_sup = [False] * len(distinct); n_win = 0; n_fail = 1
ann = [{**t, "llm_grounded": bool(d_sup[key_of[i]])} for i, t in enumerate(gold)] # per distinct claim, cut a tight excerpt (sentence around the name) from its
# supporting window, so the training sample carries the evidence text, not the
# whole 120KB window.
d_exc = [_excerpt(d_ev[j], distinct[j]["o"]) if d_sup[j] else None
for j in range(len(distinct))]
ann = []
for i, t in enumerate(gold):
j = key_of[i]
ann.append({**t, "llm_grounded": bool(d_sup[j]), "evidence": d_exc[j]})
return {"sample_id": r.get("sample_id"), "cik": r["cik"], "fund": r.get("fund"), return {"sample_id": r.get("sample_id"), "cik": r["cik"], "fund": r.get("fund"),
"n_windows": n_win, "n_failed_windows": n_fail, "n_distinct": len(distinct), "n_windows": n_win, "n_failed_windows": n_fail, "n_distinct": len(distinct),
"ok": n_fail == 0, "triples": ann} "ok": n_fail == 0, "triples": ann}
@ -377,15 +425,27 @@ def run_match(args):
sequentially within its worker. Results are written as they complete. sequentially within its worker. Results are written as they complete.
""" """
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
import os
rows = [json.loads(l) for l in open(args.inp, encoding="utf-8")] rows = [json.loads(l) for l in open(args.inp, encoding="utf-8")]
if args.limit: if args.limit:
rows = rows[:args.limit] rows = rows[:args.limit]
# RESUME: skip samples already in the output (append mode). A server blip that
# kills the run no longer wastes completed work — just re-launch the same cmd.
done_ids = set()
if os.path.exists(args.out):
for l in open(args.out, encoding="utf-8"):
try:
done_ids.add(json.loads(l).get("sample_id"))
except Exception:
pass
todo = [r for r in rows if r.get("sample_id") not in done_ids]
print(f"resume: {len(done_ids)} done, {len(todo)} to do (of {len(rows)})")
done = tot_g = tot_t = 0 done = tot_g = tot_t = 0
t0 = time.time() t0 = time.time()
workers = max(1, args.workers) workers = max(1, args.workers)
with open(args.out, "w", encoding="utf-8") as out, \ with open(args.out, "a", encoding="utf-8") as out, \
ThreadPoolExecutor(max_workers=workers) as ex: ThreadPoolExecutor(max_workers=workers) as ex:
futs = {ex.submit(_match_one, args, r): r for r in rows} futs = {ex.submit(_match_one, args, r): r for r in todo}
for fut in as_completed(futs): for fut in as_completed(futs):
rec = fut.result() rec = fut.result()
ng = sum(t.get("llm_grounded") for t in rec["triples"]) ng = sum(t.get("llm_grounded") for t in rec["triples"])
@ -393,10 +453,10 @@ def run_match(args):
out.write(json.dumps(rec, ensure_ascii=False) + "\n"); out.flush() out.write(json.dumps(rec, ensure_ascii=False) + "\n"); out.flush()
done += 1 done += 1
dt = time.time() - t0 dt = time.time() - t0
print(f"[{done}/{len(rows)}] {rec.get('fund') or rec['cik']:.38s} " print(f"[{done}/{len(todo)}] {rec.get('fund') or rec['cik']:.38s} "
f"grounded {ng}/{len(rec['triples'])} " f"grounded {ng}/{len(rec['triples'])} "
f"({dt/done:.1f}s/sample avg, {workers}w)") f"({dt/done:.1f}s/sample avg, {workers}w)")
print(f"\nmatched {done} samples -> {args.out} " print(f"\nmatched {done} new samples -> {args.out} "
f"(LLM-grounded {tot_g}/{tot_t} = {100*tot_g/max(1,tot_t):.0f}%)") f"(LLM-grounded {tot_g}/{tot_t} = {100*tot_g/max(1,tot_t):.0f}%)")
@ -438,7 +498,7 @@ def run_extract(args):
def main(): def main():
global BACKEND, VLLM_URL, API_KEY global BACKEND, VLLM_URL, API_KEY, NO_THINK
ap = argparse.ArgumentParser(description=__doc__) ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--mode", choices=["extract", "match"], default="extract", ap.add_argument("--mode", choices=["extract", "match"], default="extract",
help="extract = free triple extraction (baseline); " help="extract = free triple extraction (baseline); "
@ -454,6 +514,8 @@ def main():
"qwen3.5-122b-a10b-fp8 (vllm)") "qwen3.5-122b-a10b-fp8 (vllm)")
ap.add_argument("--vllm-url", default=VLLM_URL) ap.add_argument("--vllm-url", default=VLLM_URL)
ap.add_argument("--api-key", default="") ap.add_argument("--api-key", default="")
ap.add_argument("--think", action="store_true",
help="keep reasoning thinking on (vllm); default off for speed")
ap.add_argument("--limit", type=int, default=0) ap.add_argument("--limit", type=int, default=0)
ap.add_argument("--num-ctx", type=int, default=32768) ap.add_argument("--num-ctx", type=int, default=32768)
ap.add_argument("--window", type=int, default=0, ap.add_argument("--window", type=int, default=0,
@ -469,6 +531,7 @@ def main():
args = ap.parse_args() args = ap.parse_args()
BACKEND = args.backend BACKEND = args.backend
NO_THINK = not args.think
VLLM_URL = args.vllm_url VLLM_URL = args.vllm_url
API_KEY = args.api_key API_KEY = args.api_key
if not args.model: if not args.model:

72
resume_match.sh Normal file
View File

@ -0,0 +1,72 @@
#!/usr/bin/env bash
# ------------------------------------------------------------------
# resume_match.sh — LLM-Match sauber fortsetzen nach Unterbruch
# (Deckel zu / Schlaf / Crash).
#
# bash resume_match.sh
#
# Idee: Alle bereits sauber gematchten Trusts (ueber ALLE Teil-Dateien,
# OHNE fehlgeschlagene Fenster) werden in data/rdf_poc/match_all.jsonl
# konsolidiert; alle fehlenden ODER crash-verseuchten Trusts werden neu
# gematcht. Beliebig oft wiederholbar — jeder Lauf macht nur den Rest.
#
# Sicher gegen Schlaf: laeuft unter caffeinate. ABER: bei DECKEL ZU auf
# einem MacBook ohne externen Monitor schlaeft das System trotzdem
# (clamshell). Dann einfach nach dem Aufklappen erneut starten.
# ------------------------------------------------------------------
set -euo pipefail
cd "$(dirname "$0")"
OUT="data/rdf_poc/match_all.jsonl"
INPUT="data/rdf_poc/match_input.jsonl"
WIN=120000; OVERLAP=10000; WORKERS=2; WINWORKERS=6
# 1) Konsolidiere alle sauberen Ergebnisse aus allen match_*.jsonl Teil-Dateien
python3 - "$OUT" "$INPUT" <<'PY'
import json, glob, sys, os
out_path, input_path = sys.argv[1], sys.argv[2]
parts = sorted(glob.glob("data/rdf_poc/match_all.jsonl")
+ glob.glob("data/rdf_poc/match_all_clean*.jsonl")
+ glob.glob("data/rdf_poc/match_remaining*.jsonl"))
good = {} # cik -> record, only if no failed windows
for p in parts:
if not os.path.exists(p): continue
for l in open(p):
try: r = json.loads(l)
except: continue
if r.get("n_failed_windows", 0) == 0 and r.get("triples") is not None:
good[r["cik"]] = r # last clean wins
with open(out_path, "w") as f:
for r in good.values():
f.write(json.dumps(r, ensure_ascii=False) + "\n")
# Restliche (fehlende oder verseuchte) Trusts -> remaining-Input
allin = [json.loads(l) for l in open(input_path)]
remaining = [r for r in allin if r["cik"] not in good]
with open("data/rdf_poc/match_input_remaining.jsonl", "w") as f:
for r in remaining:
f.write(json.dumps(r) + "\n")
print(f"sauber konsolidiert: {len(good)} Trusts -> {out_path}")
print(f"noch zu matchen: {len(remaining)} Trusts")
PY
REMAIN=$(wc -l < data/rdf_poc/match_input_remaining.jsonl | tr -d ' ')
if [ "$REMAIN" = "0" ]; then
echo "ALLES fertig — match_all.jsonl ist vollstaendig."
exit 0
fi
echo "Starte Nachlauf fuer $REMAIN Trusts (unter caffeinate)..."
# unter caffeinate, damit System-Schlaf (nicht Deckel) den Lauf nicht killt
caffeinate -i -m -s python3 -u -c "
import llm_extract as L, sys
L.WINDOW_WORKERS=$WINWORKERS
sys.argv=['x','--mode','match','--backend','vllm',
'--in','data/rdf_poc/match_input_remaining.jsonl',
'--out','data/rdf_poc/match_remaining_$(date +%H%M%S).jsonl',
'--window','$WIN','--overlap','$OVERLAP','--num-ctx','131072','--workers','$WORKERS']
L.main()
"
echo ""
echo "Nachlauf-Teil fertig. Erneut 'bash resume_match.sh' ausfuehren,"
echo "um zu konsolidieren und (falls noch Rest) weiterzumachen."

66
watch_match.sh Normal file
View File

@ -0,0 +1,66 @@
#!/usr/bin/env bash
# ------------------------------------------------------------------
# watch_match.sh — Fortschritt des LLM-Match-Laufs ueberwachen
#
# bash watch_match.sh -> Dashboard, aktualisiert alle 15s (Strg-C beendet)
# bash watch_match.sh live -> rohes Log live mitlaufen (tail -f)
# bash watch_match.sh once -> einmaliger Stand, dann zurueck zur Shell
#
# Beendet nur die ANSICHT, nicht den Match-Lauf (der laeuft im Hintergrund).
# ------------------------------------------------------------------
LOG="/private/tmp/claude-501/-Users-florianherzog-development-AISE-Admin-Thesis--Florian-Code/745ff40e-0ecf-4430-886c-33fbc724df69/tasks/bfe2v693w.output"
OUT="data/rdf_poc/match_remaining.jsonl"
TOTAL=256
SERVER="http://silicon.fhgr.ch:7080/v1/models"
show_once () {
local done last errs srv tg tt
done=$(wc -l < "$OUT" 2>/dev/null | tr -d ' '); done=${done:-0}
last=$(grep -oE '\[[0-9]+/'"$TOTAL"'\]' "$LOG" 2>/dev/null | tail -1)
errs=$(grep -cE 'error|ERROR|Traceback' "$LOG" 2>/dev/null | tr -d ' ')
# Server erreichbar?
# 2 Versuche mit grosszuegigem Timeout: unter Last antwortet /v1/models
# langsamer, ein kurzes Timeout meldet faelschlich "nicht erreichbar".
if curl -s -m 20 "$SERVER" >/dev/null 2>&1 || curl -s -m 20 "$SERVER" >/dev/null 2>&1; then
srv="OK"; else srv="NICHT erreichbar (oder ausgelastet)"; fi
# bisherige Uebereinstimmung aus den fertigen Samples
read tg tt < <(python3 - "$OUT" <<'PY' 2>/dev/null
import json,sys
g=t=0
try:
for l in open(sys.argv[1]):
r=json.loads(l)
for x in r.get("triples",[]):
t+=1; g+=1 if x.get("llm_grounded") else 0
except FileNotFoundError: pass
print(g,t)
PY
)
tg=${tg:-0}; tt=${tt:-0}
local pct=0; [ "$tt" -gt 0 ] && pct=$((100*tg/tt))
local dpct=$((100*done/TOTAL))
printf "\n LLM-Match-Lauf\n"
printf " ------------------------------------------\n"
printf " Fortschritt : %d / %d Trusts (%d%%) letzte Logzeile: %s\n" "$done" "$TOTAL" "$dpct" "${last:-}"
printf " Tripel : %d von %d belegt (%d%% Uebereinstimmung bisher)\n" "$tg" "$tt" "$pct"
printf " Fehler/Log : %s\n" "${errs:-0}"
printf " vLLM-Server : %s\n" "$srv"
printf " ------------------------------------------\n"
}
case "${1:-dash}" in
live) echo "Live-Log (Strg-C beendet die Ansicht):"; exec tail -f "$LOG" ;;
once) show_once ;;
*)
echo "Dashboard aktualisiert alle 15s (Strg-C beendet die Ansicht, nicht den Lauf)"
while true; do
clear 2>/dev/null
show_once
done=$(wc -l < "$OUT" 2>/dev/null | tr -d ' ')
if grep -q "matched .* samples" "$LOG" 2>/dev/null; then
echo " FERTIG. $(grep 'matched .* samples' "$LOG" | tail -1)"
break
fi
sleep 15
done ;;
esac