Spaces:
Running on Zero
Running on Zero
File size: 6,128 Bytes
465b477 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 | """Per-chunk knowledge-graph extraction with verbatim provenance.
Each chunk is sent to the extractor model once. Every returned triple or result must
carry a quote that anchors verbatim in that chunk (``quotes.locate``); anything else is
rejected with a recorded reason. Accepted records keep the chunk ID and character
offsets of their evidence. Raw extractor replies live in the LLM cache; this module
writes one JSONL record per chunk with accepted and rejected items, so precision of the
extraction stage can be audited and measured.
"""
from __future__ import annotations
import json
from collections import Counter
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from .canonical import is_generic
from .llm import LLMClient, LLMError, UsageMeter, call_structured
from .prompts import EXTRACT_SYSTEM, THIS_PAPER, ExtractionOut, extract_user
from .quotes import locate
from .schemas import SIGNATURES, Chunk, Document
from .verification import strip_headings
def _validate_triple(t, chunk_text: str) -> tuple[dict | None, str | None]:
subj_type = "Paper" if t.subject.strip() == THIS_PAPER else t.subject_type
obj_type = t.object_type
if t.object.strip() == THIS_PAPER:
return None, "paper_as_object"
if (subj_type, obj_type) not in SIGNATURES[t.relation]:
return None, "signature"
if subj_type == "Paper" and t.subject.strip() != THIS_PAPER:
return None, "foreign_paper_subject"
if subj_type != "Paper" and is_generic(t.subject):
return None, "generic_name"
if is_generic(t.object):
return None, "generic_name"
loc = locate(t.quote, chunk_text)
if loc is None:
return None, "unanchored_quote"
return {
"subject": t.subject.strip(),
"subject_type": subj_type,
"relation": t.relation,
"object": t.object.strip(),
"object_type": obj_type,
"quote": t.quote,
"start_char": loc[0],
"end_char": loc[1],
"elided": loc[2],
}, None
def _validate_result(r, chunk_text: str) -> tuple[dict | None, str | None]:
# THIS_PAPER as the method is resolved to the paper's proposed method at graph build
# (or dropped there); it is never allowed as a dataset or metric.
if (r.method.strip() != THIS_PAPER and is_generic(r.method)) or is_generic(r.dataset) or is_generic(r.metric):
return None, "generic_name"
if not any(ch.isdigit() for ch in r.value):
return None, "non_numeric_value"
loc = locate(r.quote, chunk_text)
if loc is None:
return None, "unanchored_quote"
if r.value.strip().rstrip("%") not in chunk_text[loc[0] : loc[1]]:
return None, "value_not_in_quote"
return {
"method": r.method.strip(),
"dataset": r.dataset.strip(),
"metric": r.metric.strip(),
"value": r.value.strip(),
"quote": r.quote,
"start_char": loc[0],
"end_char": loc[1],
"elided": loc[2],
}, None
def extract_chunk(client: LLMClient, doc: Document, chunk: Chunk, meter: UsageMeter | None = None) -> dict:
"""One chunk -> record with accepted/rejected triples and results."""
# Quotes are anchored against the chunk text exactly as stored (headings included).
rec: dict = {"chunk_id": chunk.chunk_id, "doc_id": doc.doc_id, "extractor_model": client.spec.model}
try:
out = call_structured(
client, meter, "extract", EXTRACT_SYSTEM, extract_user(doc.title, chunk.section_heading, strip_headings(chunk.text)), ExtractionOut, max_tokens=4096
)
except LLMError as e:
rec.update(error=str(e), triples=[], results=[], rejected=[])
return rec
triples, results, rejected = [], [], []
for t in out.triples:
ok, why = _validate_triple(t, chunk.text)
(triples.append(ok) if ok else rejected.append({"kind": "triple", "reason": why, **t.model_dump()}))
for r in out.results:
ok, why = _validate_result(r, chunk.text)
(results.append(ok) if ok else rejected.append({"kind": "result", "reason": why, **r.model_dump()}))
rec.update(error=None, triples=triples, results=results, rejected=rejected)
return rec
def extract_corpus(
client: LLMClient,
docs: dict[str, Document],
chunks: list[Chunk],
out_path: str | Path,
workers: int = 4,
) -> dict:
"""Resumable: chunks already present in ``out_path`` are skipped."""
out_path = Path(out_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
done: set[str] = set()
if out_path.exists():
for line in out_path.read_text(encoding="utf-8").splitlines():
if line.strip():
r = json.loads(line)
if not r.get("error"):
done.add(r["chunk_id"])
todo = [c for c in chunks if c.chunk_id not in done]
meter = UsageMeter()
with out_path.open("a", encoding="utf-8") as f, ThreadPoolExecutor(max_workers=workers) as pool:
for rec in pool.map(lambda c: extract_chunk(client, docs[c.doc_id], c, meter), todo):
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
f.flush()
return {"chunks_total": len(chunks), "chunks_skipped": len(done), "chunks_processed": len(todo), "usage": meter.summary()}
def extraction_stats(records: list[dict]) -> dict:
reasons = Counter(r["reason"] for rec in records for r in rec.get("rejected", []))
accepted_t = sum(len(rec.get("triples", [])) for rec in records)
accepted_r = sum(len(rec.get("results", [])) for rec in records)
rejected = sum(reasons.values())
proposed = accepted_t + accepted_r + rejected
return {
"chunks": len(records),
"chunk_errors": sum(1 for r in records if r.get("error")),
"triples_accepted": accepted_t,
"results_accepted": accepted_r,
"items_rejected": rejected,
"rejection_reasons": dict(reasons),
"unanchored_rate": (reasons.get("unanchored_quote", 0) / proposed) if proposed else None,
"relations": dict(Counter(t["relation"] for rec in records for t in rec.get("triples", []))),
}
|