[NOTICKET] fix(knowledge_extraction): move runner into the package; fix two ranking defects
Browse filesRunner relocated scripts/run_knowledge_extraction.py -> the package as
src/knowledge_extraction/cli.py. scripts/ is gitignored, and this runner is the
pipeline's operator entry point, so it has to ship with the module:
uv run --no-sync python -m src.knowledge_extraction.cli <artifact.json>
pyproject: narrow the T201 lint exemption to that one CLI file rather than all
of scripts/, so the ignore traces to this change and nothing else.
Two ranking defects found while verifying against the real 9-page BUMA standard
(both recorded as deviations V1/V2 in KNOWLEDGE_PIPELINE_CALIBRATION.md §9):
V1 — a heading that names the term now counts as a mention at offset 0 for
cue-proximity scoring. Indonesian standards are written "2.1.3 Physical of
Availability (PA)" / "Adalah ketersediaan fisik...": the heading names the term
and the body opens with the definitional cue WITHOUT repeating it, so the
definition section earned no cue bonus at all. Measured before the fix, PA's own
definition section scored 7.5 and ranked THIRD, behind a computation section
(8.5) that merely mentions PA often. After it, all five terms rank their own
definition section first: PA->2.1.3, UA->2.1.4, Qty->2.1.2, Pty->2.1.5,
MOHH->2.1.1. Same root cause as the literal-wording defect — the heading is a
separate field from the text, so anything reasoning over "term + its definition"
must be told to read both.
V2 — first_occurrence now follows document order, and ranking ties break on it.
Previously the +1.0 went to whichever chunk a set iteration happened to yield
first, which made the bonus arbitrary and the ranking unreproducible.
Both change which chunks reach the paid stage, so they can move schema-fill
precision when that branch lands; re-score against the frozen baseline then
rather than assuming the improvement carries.
Verification: ruff clean on all touched paths; full suite 470 passed, 7 skipped
(was 467 + 3 new local tests); ranking stable across PYTHONHASHSEED 0/1/7/42/99;
CLI green on the BUMA chunks and on Sofhia's draft artifact.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- KNOWLEDGE_PIPELINE_CALIBRATION.md +22 -0
- pyproject.toml +3 -3
- src/knowledge_extraction/cli.py +148 -0
- src/knowledge_extraction/rank/evidence.py +36 -14
|
@@ -185,3 +185,25 @@ clock, 3 fields rejected by span check.
|
|
| 185 |
**When quoting E2 externally:** the context doc's **6.2×** is a 1,000-page projection; **2.56×** is
|
| 186 |
the 9-page measurement. Compression grows as terms repeat across a corpus. Both are true — use the
|
| 187 |
right one for the audience.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
**When quoting E2 externally:** the context doc's **6.2×** is a 1,000-page projection; **2.56×** is
|
| 186 |
the 9-page measurement. Compression grows as terms repeat across a corpus. Both are true — use the
|
| 187 |
right one for the audience.
|
| 188 |
+
|
| 189 |
+
---
|
| 190 |
+
|
| 191 |
+
## 9. Deliberate deviations from the prototype (v2)
|
| 192 |
+
|
| 193 |
+
Changes made while rebuilding, with the reason. Each one alters behaviour, so
|
| 194 |
+
each is listed rather than folded silently into the tables above.
|
| 195 |
+
|
| 196 |
+
| # | Change | Why | Measured? |
|
| 197 |
+
|---|---|---|---|
|
| 198 |
+
| **V1** | **A heading that names the term counts as a mention at offset 0** for cue-proximity scoring | Indonesian standards are written *"2.1.3 Physical of Availability (PA)"* / *"Adalah ketersediaan fisik…"* — the heading names the term and the body opens with the definitional cue **without repeating it**. The prototype therefore awarded no cue bonus on the definition section itself. Measured on the BUMA standard: PA's own definition section (§2.1.3) scored 7.5 and ranked **third**, below a computation section (§2.2.4, 8.5) that merely mentions PA often. After the fix, all five terms rank their own definition section first — PA→2.1.3, UA→2.1.4, Qty→2.1.2, Pty→2.1.5, MOHH→2.1.1 | Structurally verified on the 9-page standard. **Not yet scored** — needs the paid stage to show up in E3 |
|
| 199 |
+
| **V2** | **`first_occurrence` follows document order**, and ranking ties break on document order | The prototype awarded the +1.0 to whichever chunk a `set` iteration yielded first, making the bonus arbitrary and the ranking unreproducible across runs. "First occurrence" should mean the term's first appearance in the document | Determinism locked by test; verified stable across `PYTHONHASHSEED` 0/1/7/42/99 |
|
| 200 |
+
| **V3** | **`source_wording` field added** to the glossary entry | The control for the literal-source-wording defect (§7). Not yet populated — needs the seam to carry the heading verbatim and the extraction prompt to fill it | No |
|
| 201 |
+
|
| 202 |
+
**Both V1 and V2 change which chunks reach the paid stage**, so they can move E3
|
| 203 |
+
when the extraction branch lands. Re-score against
|
| 204 |
+
`eval/knowledge/results/baseline_prototype_2026-08-13_145132.json` at that point
|
| 205 |
+
and record the delta rather than assuming the improvement carries.
|
| 206 |
+
|
| 207 |
+
**V1 and the R1 literal-wording defect share a root cause:** the heading is a
|
| 208 |
+
separate field from the chunk text, so anything that reasons over "the term and
|
| 209 |
+
its definition together" has to be told to look at both.
|
|
@@ -126,9 +126,9 @@ ignore = [
|
|
| 126 |
"tests/**" = ["S101", "S105", "S106", "S608"]
|
| 127 |
# T201: eval/ scripts are CLIs — print() is their intended output channel.
|
| 128 |
"eval/**" = ["T201"]
|
| 129 |
-
# Same rule, same reason:
|
| 130 |
-
#
|
| 131 |
-
"
|
| 132 |
|
| 133 |
[tool.mypy]
|
| 134 |
python_version = "3.12"
|
|
|
|
| 126 |
"tests/**" = ["S101", "S105", "S106", "S608"]
|
| 127 |
# T201: eval/ scripts are CLIs — print() is their intended output channel.
|
| 128 |
"eval/**" = ["T201"]
|
| 129 |
+
# Same rule, same reason: this is an operator-run CLI, and its printed output IS
|
| 130 |
+
# the deliverable.
|
| 131 |
+
"src/knowledge_extraction/cli.py" = ["T201"]
|
| 132 |
|
| 133 |
[tool.mypy]
|
| 134 |
python_version = "3.12"
|
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Offline runner for the knowledge-extraction pipeline (free stages only).
|
| 2 |
+
|
| 3 |
+
The pipeline runs a few times a year, triggered by an admin — so a script over a
|
| 4 |
+
parsed artifact is the honest entry point, and any HTTP surface is a convenience
|
| 5 |
+
layer over this, never the other way round.
|
| 6 |
+
|
| 7 |
+
Takes a **parsed-document artifact**, never a PDF: extraction does not parse.
|
| 8 |
+
Every stage writes its own JSON so a later stage can be re-run without repeating
|
| 9 |
+
an earlier one — which matters because prompt iteration is the main development
|
| 10 |
+
loop and re-running the span filter is the slow part.
|
| 11 |
+
|
| 12 |
+
Currently covers the free stages: adapter -> cue/legend filters -> cluster ->
|
| 13 |
+
evidence ranking. The paid extraction stage is not built yet.
|
| 14 |
+
|
| 15 |
+
uv run --no-sync python -m src.knowledge_extraction.cli <artifact.json>
|
| 16 |
+
uv run --no-sync python -m src.knowledge_extraction.cli <artifact.json> --out-dir out/
|
| 17 |
+
|
| 18 |
+
Lives inside the package rather than in `scripts/`, which is gitignored: this
|
| 19 |
+
runner is the pipeline's operator entry point and has to ship with the module.
|
| 20 |
+
|
| 21 |
+
Add --mentions <file.json> to supply span-NER mentions from elsewhere; without
|
| 22 |
+
it the runner clusters only what the legend filter found, which is enough to
|
| 23 |
+
exercise the wiring but is not a recall measurement.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import argparse
|
| 29 |
+
import json
|
| 30 |
+
import re
|
| 31 |
+
import sys
|
| 32 |
+
from pathlib import Path
|
| 33 |
+
|
| 34 |
+
from .adapter import parsed_doc_from_artifact
|
| 35 |
+
from .cluster import cluster_mentions
|
| 36 |
+
from .filters import abbrev_pairs, rule_candidates
|
| 37 |
+
from .models import Mention
|
| 38 |
+
from .rank import rank_evidence
|
| 39 |
+
from .settings import EVIDENCE_K
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def main(argv: list[str] | None = None) -> int:
|
| 43 |
+
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
| 44 |
+
parser.add_argument("artifact", type=Path, help="parsed-document artifact JSON")
|
| 45 |
+
parser.add_argument("--mentions", type=Path, help="span-NER mentions JSON")
|
| 46 |
+
parser.add_argument("--out-dir", type=Path, default=Path("out/knowledge"))
|
| 47 |
+
parser.add_argument("--doc-id", help="override the artifact's doc_id")
|
| 48 |
+
args = parser.parse_args(argv)
|
| 49 |
+
|
| 50 |
+
if not args.artifact.exists():
|
| 51 |
+
print(f"artifact not found: {args.artifact}", file=sys.stderr)
|
| 52 |
+
return 2
|
| 53 |
+
|
| 54 |
+
raw = json.loads(args.artifact.read_text(encoding="utf-8"))
|
| 55 |
+
doc = parsed_doc_from_artifact(
|
| 56 |
+
raw, doc_id=args.doc_id, source_ref=str(args.artifact)
|
| 57 |
+
)
|
| 58 |
+
print(
|
| 59 |
+
f"[parse ] {doc.doc_id}: {len(doc.chunks)} chunks, {doc.n_pages} pages, "
|
| 60 |
+
f"hash {doc.content_hash}, heading-split={doc.used_heading_split}"
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
pairs = abbrev_pairs(doc.chunks)
|
| 64 |
+
rules = rule_candidates(doc.chunks)
|
| 65 |
+
print(f"[filter] {len(pairs)} abbreviation pairs, {len(rules)} rule candidates")
|
| 66 |
+
for pair in pairs[:8]:
|
| 67 |
+
print(f" {pair.abbrev} -> {pair.expansion}")
|
| 68 |
+
|
| 69 |
+
mentions = _load_mentions(args.mentions) if args.mentions else _from_pairs(doc, pairs)
|
| 70 |
+
print(f"[filter] {len(mentions)} mentions in")
|
| 71 |
+
|
| 72 |
+
clustered = cluster_mentions(mentions, pairs, doc.doc_id)
|
| 73 |
+
print(
|
| 74 |
+
f"[cluster] {clustered.n_mentions} mentions -> {clustered.n_clusters} clusters "
|
| 75 |
+
f"(compression {clustered.compression_ratio}x)"
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
rank_evidence(clustered.clusters, doc.chunks)
|
| 79 |
+
print(f"[rank ] evidence ranked, K={EVIDENCE_K}")
|
| 80 |
+
for cluster in clustered.clusters[:10]:
|
| 81 |
+
top = cluster.evidence_chunk_ids[:EVIDENCE_K]
|
| 82 |
+
print(
|
| 83 |
+
f" {cluster.canonical:<28} mentions={cluster.mention_count:<4} "
|
| 84 |
+
f"evidence={len(cluster.evidence_chunk_ids)} top={top}"
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
args.out_dir.mkdir(parents=True, exist_ok=True)
|
| 88 |
+
_dump(args.out_dir / f"{doc.doc_id}.chunks.json", doc.model_dump(mode="json"))
|
| 89 |
+
_dump(
|
| 90 |
+
args.out_dir / f"{doc.doc_id}.filters.json",
|
| 91 |
+
{
|
| 92 |
+
"doc_id": doc.doc_id,
|
| 93 |
+
"abbrev_pairs": [p.model_dump(mode="json") for p in pairs],
|
| 94 |
+
"rule_candidates": [r.model_dump(mode="json") for r in rules],
|
| 95 |
+
},
|
| 96 |
+
)
|
| 97 |
+
_dump(args.out_dir / f"{doc.doc_id}.clusters.json", clustered.model_dump(mode="json"))
|
| 98 |
+
print(f"[write ] {args.out_dir}")
|
| 99 |
+
return 0
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _load_mentions(path: Path) -> list[Mention]:
|
| 103 |
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
| 104 |
+
items = raw.get("mentions", raw) if isinstance(raw, dict) else raw
|
| 105 |
+
return [Mention.model_validate(m) for m in items]
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _from_pairs(doc, pairs) -> list[Mention]:
|
| 109 |
+
"""Stand-in mentions from legend abbreviations, so the wiring is runnable
|
| 110 |
+
before the span filter lands.
|
| 111 |
+
|
| 112 |
+
NOT a recall measurement — it only sees terms a legend block already named.
|
| 113 |
+
|
| 114 |
+
Word-boundary matching, never substring: "PA" occurs inside "parameter",
|
| 115 |
+
"pada", "capacity" and "composite", and substring matching produced 126
|
| 116 |
+
spurious PA mentions on a 9-page document (77x compression instead of the
|
| 117 |
+
measured 2.56x). Same trap the evidence ranker documents for headings.
|
| 118 |
+
"""
|
| 119 |
+
surfaces = {p.abbrev for p in pairs} | {p.expansion for p in pairs}
|
| 120 |
+
patterns = [
|
| 121 |
+
(s, re.compile(rf"(?<!\w){re.escape(s)}(?!\w)", re.IGNORECASE))
|
| 122 |
+
for s in surfaces
|
| 123 |
+
]
|
| 124 |
+
out: list[Mention] = []
|
| 125 |
+
for chunk in doc.chunks:
|
| 126 |
+
for surface, pattern in patterns:
|
| 127 |
+
for match in pattern.finditer(chunk.text):
|
| 128 |
+
out.append(
|
| 129 |
+
Mention(
|
| 130 |
+
surface=surface,
|
| 131 |
+
chunk_id=chunk.chunk_id,
|
| 132 |
+
char_start=match.start(),
|
| 133 |
+
char_end=match.end(),
|
| 134 |
+
label="legend",
|
| 135 |
+
score=1.0,
|
| 136 |
+
)
|
| 137 |
+
)
|
| 138 |
+
return out
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _dump(path: Path, payload) -> None:
|
| 142 |
+
path.write_text(
|
| 143 |
+
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
if __name__ == "__main__":
|
| 148 |
+
raise SystemExit(main())
|
|
@@ -34,18 +34,26 @@ def rank_evidence(
|
|
| 34 |
and `evidence_scores`. Keeps the FULL ranked list, not just the top K."""
|
| 35 |
w = {**EVIDENCE_WEIGHTS, **(weights or {})}
|
| 36 |
by_id = {c.chunk_id: c for c in chunks}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
first_seen: set[str] = set()
|
| 38 |
|
| 39 |
for cluster in clusters:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
scored: list[tuple[float, str]] = []
|
| 41 |
-
for chunk_id in
|
| 42 |
chunk = by_id.get(chunk_id)
|
| 43 |
if chunk is None:
|
| 44 |
continue
|
| 45 |
positions = [m for m in cluster.mentions if m.chunk_id == chunk_id]
|
| 46 |
scored.append((_score_chunk(chunk, cluster, positions, w, first_seen), chunk_id))
|
| 47 |
-
# Tie-break on
|
| 48 |
-
scored.sort(key=lambda t: (-t[0], t[1]))
|
| 49 |
cluster.evidence_chunk_ids = [cid for _, cid in scored]
|
| 50 |
cluster.evidence_scores = [round(s, 2) for s, _ in scored]
|
| 51 |
|
|
@@ -78,24 +86,38 @@ def _score_chunk(
|
|
| 78 |
) -> float:
|
| 79 |
score = 0.0
|
| 80 |
|
| 81 |
-
cues = definitional_hits(chunk.text)
|
| 82 |
-
for mention in positions:
|
| 83 |
-
if any(
|
| 84 |
-
abs(cs - mention.char_start) <= CUE_PROXIMITY_CHARS
|
| 85 |
-
or abs(ce - mention.char_end) <= CUE_PROXIMITY_CHARS
|
| 86 |
-
for cs, ce in cues
|
| 87 |
-
):
|
| 88 |
-
score += w["definitional_cue_near"]
|
| 89 |
-
break
|
| 90 |
-
|
| 91 |
# Word-boundary match, NOT substring. "PA" is a substring of "Parameter",
|
| 92 |
# "pada" and "composite", so substring matching handed this bonus to almost
|
| 93 |
# every Indonesian heading and pushed real definition sections below
|
| 94 |
# formula tables.
|
| 95 |
heading = normalize(chunk.heading or "")
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
| 97 |
score += w["term_in_heading"]
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
legend_lines = find_legend_lines(chunk.text)
|
| 100 |
if legend_lines:
|
| 101 |
line_starts = _line_starts(chunk.text)
|
|
|
|
| 34 |
and `evidence_scores`. Keeps the FULL ranked list, not just the top K."""
|
| 35 |
w = {**EVIDENCE_WEIGHTS, **(weights or {})}
|
| 36 |
by_id = {c.chunk_id: c for c in chunks}
|
| 37 |
+
# Document order, so "first occurrence" means the term's first appearance in
|
| 38 |
+
# the document — not whichever element a set happened to yield first, which
|
| 39 |
+
# would hand the +1.0 to an arbitrary chunk and make ranking unreproducible.
|
| 40 |
+
doc_order = {c.chunk_id: i for i, c in enumerate(chunks)}
|
| 41 |
first_seen: set[str] = set()
|
| 42 |
|
| 43 |
for cluster in clusters:
|
| 44 |
+
chunk_ids = sorted(
|
| 45 |
+
{m.chunk_id for m in cluster.mentions},
|
| 46 |
+
key=lambda cid: (doc_order.get(cid, len(chunks)), cid),
|
| 47 |
+
)
|
| 48 |
scored: list[tuple[float, str]] = []
|
| 49 |
+
for chunk_id in chunk_ids:
|
| 50 |
chunk = by_id.get(chunk_id)
|
| 51 |
if chunk is None:
|
| 52 |
continue
|
| 53 |
positions = [m for m in cluster.mentions if m.chunk_id == chunk_id]
|
| 54 |
scored.append((_score_chunk(chunk, cluster, positions, w, first_seen), chunk_id))
|
| 55 |
+
# Tie-break on document order so ranking is stable across runs.
|
| 56 |
+
scored.sort(key=lambda t: (-t[0], doc_order.get(t[1], len(chunks)), t[1]))
|
| 57 |
cluster.evidence_chunk_ids = [cid for _, cid in scored]
|
| 58 |
cluster.evidence_scores = [round(s, 2) for s, _ in scored]
|
| 59 |
|
|
|
|
| 86 |
) -> float:
|
| 87 |
score = 0.0
|
| 88 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
# Word-boundary match, NOT substring. "PA" is a substring of "Parameter",
|
| 90 |
# "pada" and "composite", so substring matching handed this bonus to almost
|
| 91 |
# every Indonesian heading and pushed real definition sections below
|
| 92 |
# formula tables.
|
| 93 |
heading = normalize(chunk.heading or "")
|
| 94 |
+
term_in_heading = bool(heading) and any(
|
| 95 |
+
_word_match(normalize(v), heading) for v in cluster.variants
|
| 96 |
+
)
|
| 97 |
+
if term_in_heading:
|
| 98 |
score += w["term_in_heading"]
|
| 99 |
|
| 100 |
+
# Cue proximity. When the heading names the term, the heading counts as a
|
| 101 |
+
# mention at offset 0.
|
| 102 |
+
#
|
| 103 |
+
# Indonesian standards are written "2.1.3 Physical of Availability (PA)" /
|
| 104 |
+
# "Adalah ketersediaan fisik..." — the heading names the term and the body
|
| 105 |
+
# opens with the cue WITHOUT repeating it. Measured on the BUMA standard:
|
| 106 |
+
# without this, the PA definition section scored no cue bonus at all and
|
| 107 |
+
# ranked below a computation section that merely mentions PA often. The
|
| 108 |
+
# section's own definition is the single most valuable piece of evidence
|
| 109 |
+
# there is, so losing the signal there defeats the ranking.
|
| 110 |
+
cues = definitional_hits(chunk.text)
|
| 111 |
+
anchors = [(m.char_start, m.char_end) for m in positions]
|
| 112 |
+
if term_in_heading:
|
| 113 |
+
anchors.append((0, 0))
|
| 114 |
+
if any(
|
| 115 |
+
abs(cs - start) <= CUE_PROXIMITY_CHARS or abs(ce - end) <= CUE_PROXIMITY_CHARS
|
| 116 |
+
for cs, ce in cues
|
| 117 |
+
for start, end in anchors
|
| 118 |
+
):
|
| 119 |
+
score += w["definitional_cue_near"]
|
| 120 |
+
|
| 121 |
legend_lines = find_legend_lines(chunk.text)
|
| 122 |
if legend_lines:
|
| 123 |
line_starts = _line_starts(chunk.text)
|