folio-resolve 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,107 @@
1
+ """folio-resolve — the shared FOLIO source-text-to-concept matching engine.
2
+
3
+ A lift-and-improve extraction of the matching intelligence that previously lived — and diverged —
4
+ across folio-mapper, folio-enrich, and folio-insights (informally shared through a fragile
5
+ ``sys.path`` hack). This package is the single pinned source of truth: the word-order-invariant
6
+ scorer, span decomposition, the place-name / short-label gates, the alias blocklist, the
7
+ domain-prior judge, and the annotate primitives.
8
+
9
+ Public surface (see module docstrings for provenance):
10
+
11
+ * scoring — ``compute_relevance_score``, ``word_overlap``, ``LEGAL_TERM_EXPANSIONS``
12
+ * ontology — ``Concept``, ``OntologyProvider``, ``InMemoryOntology``, ``FolioPythonProvider``
13
+ * entity_ruler — ``FOLIOEntityRuler`` (pure-Python Aho-Corasick)
14
+ * reconciler — ``Reconciler``, ``ConceptMatch``
15
+ * decompose — ``decompose`` (conjunction split + shared-head)
16
+ * gates — ``PlaceNameGate``, ``ShortLabelGate``
17
+ * blocklist — ``AliasBlocklist`` (Action != Auction)
18
+ * sources — ``SourceClassifier`` (metadata/front-matter exclusion)
19
+ * domain_prior — ``DomainPrior``, ``DomainPriorSuggester``, ``TaxonomyNode`` (multi-tag)
20
+ * calibration — ``ScoreCalibration`` (weak-band recalibration)
21
+ * judge — ``Judge``, ``build_judge_prompt``, ``parse_judge_json``
22
+ * pipeline — ``MatchPipeline`` (filter -> expand -> rank -> judge)
23
+ * resolve — ``LabelResolver``, ``ResolvedConcept`` (decompose-first, branch-carrying)
24
+ * annotate — ``ConceptTag``, ``TagVerdict``, ``Annotation``, ``FeedbackStore``, lifecycle
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ __version__ = "0.1.0"
30
+
31
+ from .blocklist import AliasBlocklist, BlockedAlias, load_seed_blocklist
32
+ from .calibration import CalibrationSample, ScoreCalibration
33
+ from .decompose import decompose
34
+ from .domain_prior import DomainPrior, DomainPriorSuggester, SubjectTag, TagStatus, TaxonomyNode
35
+ from .entity_ruler import FOLIOEntityRuler
36
+ from .gates import GateDecision, PlaceNameGate, ShortLabelGate
37
+ from .judge import Judge, build_judge_prompt, enforce_verdict, parse_judge_json
38
+ from .ontology import Concept, FolioPythonProvider, InMemoryOntology, LabelInfo, OntologyProvider
39
+ from .pipeline import MatchCandidate, MatchPipeline
40
+ from .reconciler import ConceptMatch, Reconciler, ReconciliationResult
41
+ from .resolve import (
42
+ CONJUNCT_THRESHOLD,
43
+ WHOLE_STRING_THRESHOLD,
44
+ LabelResolver,
45
+ ResolvedConcept,
46
+ )
47
+ from .scoring import (
48
+ LEGAL_TERM_EXPANSIONS,
49
+ SEARCH_STOPWORDS,
50
+ compute_relevance_score,
51
+ content_words,
52
+ generate_search_terms,
53
+ tokenize,
54
+ word_overlap,
55
+ )
56
+ from .sources import SourceClassifier, SourceType
57
+ from .spec import BUILTIN_SPECS, CANON_SPEC, FOLIO_SPEC, OntologySpec
58
+
59
+ __all__ = [
60
+ "BUILTIN_SPECS",
61
+ "CANON_SPEC",
62
+ "CONJUNCT_THRESHOLD",
63
+ "FOLIO_SPEC",
64
+ "LEGAL_TERM_EXPANSIONS",
65
+ "SEARCH_STOPWORDS",
66
+ "WHOLE_STRING_THRESHOLD",
67
+ "AliasBlocklist",
68
+ "BlockedAlias",
69
+ "CalibrationSample",
70
+ "Concept",
71
+ "ConceptMatch",
72
+ "DomainPrior",
73
+ "DomainPriorSuggester",
74
+ "FOLIOEntityRuler",
75
+ "FolioPythonProvider",
76
+ "GateDecision",
77
+ "InMemoryOntology",
78
+ "Judge",
79
+ "LabelInfo",
80
+ "LabelResolver",
81
+ "MatchCandidate",
82
+ "MatchPipeline",
83
+ "OntologyProvider",
84
+ "OntologySpec",
85
+ "PlaceNameGate",
86
+ "Reconciler",
87
+ "ReconciliationResult",
88
+ "ResolvedConcept",
89
+ "ScoreCalibration",
90
+ "ShortLabelGate",
91
+ "SourceClassifier",
92
+ "SourceType",
93
+ "SubjectTag",
94
+ "TagStatus",
95
+ "TaxonomyNode",
96
+ "__version__",
97
+ "build_judge_prompt",
98
+ "compute_relevance_score",
99
+ "content_words",
100
+ "decompose",
101
+ "enforce_verdict",
102
+ "generate_search_terms",
103
+ "load_seed_blocklist",
104
+ "parse_judge_json",
105
+ "tokenize",
106
+ "word_overlap",
107
+ ]
@@ -0,0 +1,46 @@
1
+ """Annotate primitives — the feature-rich display + feedback layer.
2
+
3
+ Per Damien's ``feedback_share`` directive: folio-resolve must be first-class, not a minimal
4
+ extraction. This package lifts folio-enrich's confidence scores, per-item feedback, notes,
5
+ reject/restore lifecycle, and feedback insights as library primitives — plus the new **per-tag
6
+ verdict** (``correct`` / ``weak`` / ``wrong`` + note) the Ch02 review demanded.
7
+ """
8
+
9
+ from .lifecycle import (
10
+ bulk_reject,
11
+ cascade_promote,
12
+ promote,
13
+ reject,
14
+ restore,
15
+ )
16
+ from .models import (
17
+ Annotation,
18
+ ConceptTag,
19
+ FeedbackEntry,
20
+ FeedbackItem,
21
+ InsightsSummary,
22
+ Span,
23
+ StageEvent,
24
+ TagVerdict,
25
+ Verdict,
26
+ )
27
+ from .render import RenderedSegment, render_segments
28
+
29
+ __all__ = [
30
+ "Annotation",
31
+ "ConceptTag",
32
+ "FeedbackEntry",
33
+ "FeedbackItem",
34
+ "InsightsSummary",
35
+ "RenderedSegment",
36
+ "Span",
37
+ "StageEvent",
38
+ "TagVerdict",
39
+ "Verdict",
40
+ "bulk_reject",
41
+ "cascade_promote",
42
+ "promote",
43
+ "reject",
44
+ "render_segments",
45
+ "restore",
46
+ ]
@@ -0,0 +1,95 @@
1
+ """File-backed feedback store with insights aggregation.
2
+
3
+ Ported from folio-enrich ``storage/feedback_store.py``: one JSON file per feedback entry, written
4
+ atomically (tempfile + rename), plus ``get_insights`` aggregation. Synchronous here (no aiofiles
5
+ dependency) — the atomic-rename guarantee is preserved.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import tempfile
12
+ from collections import Counter
13
+ from pathlib import Path
14
+
15
+ from .models import FeedbackEntry, InsightsSummary
16
+
17
+
18
+ class FeedbackStore:
19
+ def __init__(self, base_dir: str | Path) -> None:
20
+ self._base = Path(base_dir)
21
+ self._base.mkdir(parents=True, exist_ok=True)
22
+
23
+ def _path(self, feedback_id: str) -> Path:
24
+ return self._base / f"{feedback_id}.json"
25
+
26
+ def save(self, entry: FeedbackEntry) -> None:
27
+ path = self._path(entry.id)
28
+ fd, tmp = tempfile.mkstemp(dir=self._base, suffix=".tmp")
29
+ try:
30
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
31
+ fh.write(entry.model_dump_json(indent=2))
32
+ os.replace(tmp, path)
33
+ finally:
34
+ if os.path.exists(tmp):
35
+ os.unlink(tmp)
36
+
37
+ def load(self, feedback_id: str) -> FeedbackEntry | None:
38
+ path = self._path(feedback_id)
39
+ if not path.exists():
40
+ return None
41
+ return FeedbackEntry.model_validate_json(path.read_text(encoding="utf-8"))
42
+
43
+ def list_all(self) -> list[FeedbackEntry]:
44
+ out: list[FeedbackEntry] = []
45
+ for p in self._base.glob("*.json"):
46
+ out.append(FeedbackEntry.model_validate_json(p.read_text(encoding="utf-8")))
47
+ return out
48
+
49
+ def delete(self, feedback_id: str) -> bool:
50
+ path = self._path(feedback_id)
51
+ if path.exists():
52
+ path.unlink()
53
+ return True
54
+ return False
55
+
56
+ def find_by_annotation(self, job_id: str, annotation_id: str) -> FeedbackEntry | None:
57
+ for entry in self.list_all():
58
+ if entry.job_id == job_id and entry.annotation_id == annotation_id:
59
+ return entry
60
+ return None
61
+
62
+ def get_insights(self, job_id: str | None = None) -> InsightsSummary:
63
+ entries = [e for e in self.list_all() if job_id is None or e.job_id == job_id]
64
+ summary = InsightsSummary(total_feedback=len(entries))
65
+ by_stage: dict[str, dict[str, int]] = {}
66
+ down_counter: Counter[str] = Counter()
67
+ dismissed_counter: Counter[str] = Counter()
68
+ down_labels: dict[str, str] = {}
69
+ for e in entries:
70
+ if e.rating == "up":
71
+ summary.thumbs_up += 1
72
+ elif e.rating == "down":
73
+ summary.thumbs_down += 1
74
+ elif e.rating == "dismissed":
75
+ summary.total_dismissed += 1
76
+ bucket = by_stage.setdefault(e.stage or "overall", {"up": 0, "down": 0, "dismissed": 0})
77
+ if e.rating in bucket:
78
+ bucket[e.rating] += 1
79
+ if e.rating == "down" and e.folio_iri:
80
+ down_counter[e.folio_iri] += 1
81
+ down_labels[e.folio_iri] = e.folio_label or ""
82
+ if e.rating == "dismissed" and e.folio_iri:
83
+ dismissed_counter[e.folio_iri] += 1
84
+ down_labels.setdefault(e.folio_iri, e.folio_label or "")
85
+ summary.by_stage = by_stage
86
+ summary.most_downvoted_concepts = [
87
+ {"iri": iri, "label": down_labels.get(iri, ""), "count": n}
88
+ for iri, n in down_counter.most_common(10)
89
+ ]
90
+ summary.most_dismissed_concepts = [
91
+ {"iri": iri, "label": down_labels.get(iri, ""), "count": n}
92
+ for iri, n in dismissed_counter.most_common(10)
93
+ ]
94
+ summary.recent_feedback = sorted(entries, key=lambda e: e.created_at, reverse=True)[:20]
95
+ return summary
@@ -0,0 +1,71 @@
1
+ """Annotation lifecycle transitions.
2
+
3
+ Ported from folio-enrich's ``api/routes/enrich.py`` endpoints as pure functions: reject / restore
4
+ / promote / cascade-promote / bulk-reject. Each stamps a ``StageEvent(stage="user", ...)`` onto
5
+ the annotation's lineage — the per-annotation audit trail — exactly as the source did at the route
6
+ layer. Keeping these framework-free lets any consumer (FastAPI, CLI, the folio-insights annotator)
7
+ reuse the same transitions.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .models import Annotation, StageEvent, _now
13
+
14
+
15
+ def reject(annotation: Annotation, *, comment: str = "") -> Annotation:
16
+ annotation.state = "rejected"
17
+ annotation.dismissed_at = _now()
18
+ annotation.lineage.append(
19
+ StageEvent(stage="user", action="user_rejected", detail=comment)
20
+ )
21
+ return annotation
22
+
23
+
24
+ def restore(annotation: Annotation) -> Annotation:
25
+ annotation.state = "confirmed"
26
+ annotation.dismissed_at = None
27
+ annotation.feedback = []
28
+ annotation.lineage.append(StageEvent(stage="user", action="user_restored"))
29
+ return annotation
30
+
31
+
32
+ def promote(annotation: Annotation, concept_index: int) -> Annotation:
33
+ """Swap a backup concept to primary (index 0)."""
34
+ if not 0 <= concept_index < len(annotation.concepts):
35
+ raise IndexError(f"concept_index {concept_index} out of range")
36
+ concept = annotation.concepts.pop(concept_index)
37
+ annotation.concepts.insert(0, concept)
38
+ annotation.state = "confirmed"
39
+ annotation.lineage.append(
40
+ StageEvent(stage="user", action="user_promotion", detail=f"promoted {concept.iri}")
41
+ )
42
+ return annotation
43
+
44
+
45
+ def cascade_promote(
46
+ annotations: list[Annotation], *, old_iri: str, new_iri: str
47
+ ) -> list[Annotation]:
48
+ """Promote ``new_iri`` over ``old_iri`` across every annotation where both are present."""
49
+ updated: list[Annotation] = []
50
+ for ann in annotations:
51
+ if ann.primary_iri != old_iri:
52
+ continue
53
+ idx = next((i for i, c in enumerate(ann.concepts) if c.iri == new_iri), None)
54
+ if idx is None:
55
+ continue
56
+ promote(ann, idx)
57
+ ann.lineage.append(
58
+ StageEvent(stage="user", action="user_promotion", detail=f"Cascade: {old_iri} -> {new_iri}")
59
+ )
60
+ updated.append(ann)
61
+ return updated
62
+
63
+
64
+ def bulk_reject(annotations: list[Annotation], *, folio_iri: str, comment: str = "") -> list[str]:
65
+ """Reject every non-rejected annotation whose primary IRI is ``folio_iri``. Returns their ids."""
66
+ rejected_ids: list[str] = []
67
+ for ann in annotations:
68
+ if ann.state != "rejected" and ann.primary_iri == folio_iri:
69
+ reject(ann, comment=comment)
70
+ rejected_ids.append(ann.id)
71
+ return rejected_ids
@@ -0,0 +1,135 @@
1
+ """Annotation data models.
2
+
3
+ Lifted from folio-enrich ``models/annotation.py`` + ``models/feedback.py`` and extended with
4
+ ``ConceptTag`` and ``TagVerdict`` — the per-tag ``correct``/``weak``/``wrong`` + note affordance
5
+ the Ch02 review directed ("classification must be PER TAG, not per unit").
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import UTC, datetime
11
+ from enum import StrEnum
12
+ from uuid import uuid4
13
+
14
+ from pydantic import BaseModel, Field
15
+
16
+
17
+ def _now() -> str:
18
+ return datetime.now(UTC).isoformat()
19
+
20
+
21
+ class Verdict(StrEnum):
22
+ CORRECT = "correct"
23
+ WEAK = "weak"
24
+ WRONG = "wrong"
25
+
26
+
27
+ class Span(BaseModel):
28
+ start: int
29
+ end: int
30
+ text: str
31
+ sentence_text: str | None = None
32
+
33
+
34
+ class StageEvent(BaseModel):
35
+ """A single step in an annotation's lineage (which stage did what, when)."""
36
+
37
+ stage: str
38
+ action: str # "created" | "confirmed" | "rejected" | "restored" | "user_promotion" | ...
39
+ detail: str = ""
40
+ confidence: float | None = None
41
+ reasoning: str = ""
42
+ timestamp: str = Field(default_factory=_now)
43
+
44
+
45
+ class FeedbackItem(BaseModel):
46
+ """Thumbs up/down + note on an annotation or a specific stage event."""
47
+
48
+ id: str = Field(default_factory=lambda: str(uuid4()))
49
+ rating: str # "up" | "down"
50
+ stage: str | None = None
51
+ comment: str = ""
52
+ created_at: str = Field(default_factory=_now)
53
+
54
+
55
+ class ConceptTag(BaseModel):
56
+ """A FOLIO concept anchored to a span within a unit, with why-it-fired provenance."""
57
+
58
+ iri: str
59
+ label: str
60
+ confidence: float = 0.0
61
+ branch: str = ""
62
+ extraction_path: str = "" # "entity_ruler" | "llm" | "semantic" | "heading_context" | "proposed_class"
63
+ match_score: float | None = None
64
+ span: Span | None = None
65
+
66
+
67
+ class TagVerdict(BaseModel):
68
+ """A reviewer's per-tag judgment — the missing per-tag layer.
69
+
70
+ Feeds the self-improving loop: a ``wrong`` verdict on a homonym appends to the alias
71
+ blocklist; ``correct``/``weak``/``wrong`` at the match score train the score calibration; each
72
+ verdict is a regression fixture.
73
+ """
74
+
75
+ id: str = Field(default_factory=lambda: str(uuid4()))
76
+ unit_id: str
77
+ run_id: str = ""
78
+ corpus_name: str = ""
79
+ tag_iri: str
80
+ tag_label: str = ""
81
+ extraction_path: str = ""
82
+ match_score: float | None = None
83
+ verdict: Verdict
84
+ note: str = ""
85
+ domain_prior: str = ""
86
+ book: str = ""
87
+ chapter: str = ""
88
+ reviewer: str = ""
89
+ reviewed_at: str = Field(default_factory=_now)
90
+
91
+
92
+ class Annotation(BaseModel):
93
+ """A span with one or more concept candidates, a state, lineage, and feedback."""
94
+
95
+ id: str = Field(default_factory=lambda: str(uuid4()))
96
+ span: Span
97
+ concepts: list[ConceptTag] = Field(default_factory=list)
98
+ state: str = "preliminary" # "preliminary" | "confirmed" | "rejected"
99
+ dismissed_at: str | None = None
100
+ lineage: list[StageEvent] = Field(default_factory=list)
101
+ feedback: list[FeedbackItem] = Field(default_factory=list)
102
+
103
+ @property
104
+ def primary_iri(self) -> str:
105
+ return self.concepts[0].iri if self.concepts else ""
106
+
107
+
108
+ class FeedbackEntry(BaseModel):
109
+ """A self-contained feedback record that outlives job cleanup."""
110
+
111
+ id: str = Field(default_factory=lambda: str(uuid4()))
112
+ job_id: str
113
+ annotation_id: str
114
+ rating: str # "up" | "down" | "dismissed"
115
+ stage: str | None = None
116
+ comment: str = ""
117
+ annotation_text: str = ""
118
+ sentence_text: str | None = None
119
+ folio_iri: str | None = None
120
+ folio_label: str | None = None
121
+ lineage: list[dict[str, object]] = Field(default_factory=list)
122
+ created_at: str = Field(default_factory=_now)
123
+
124
+
125
+ class InsightsSummary(BaseModel):
126
+ """Aggregated feedback insights."""
127
+
128
+ total_feedback: int = 0
129
+ thumbs_up: int = 0
130
+ thumbs_down: int = 0
131
+ total_dismissed: int = 0
132
+ by_stage: dict[str, dict[str, int]] = Field(default_factory=dict)
133
+ most_downvoted_concepts: list[dict[str, object]] = Field(default_factory=list)
134
+ most_dismissed_concepts: list[dict[str, object]] = Field(default_factory=list)
135
+ recent_feedback: list[FeedbackEntry] = Field(default_factory=list)
@@ -0,0 +1,69 @@
1
+ """Span-layout primitive — the boundary-sweep, ported to Python.
2
+
3
+ folio-enrich's ``renderAnnotatedText`` computes non-overlapping display segments from possibly
4
+ overlapping/nested annotation spans by collecting all boundary points, sorting them, and, for each
5
+ adjacent-point segment, finding the annotations that cover it. That algorithm is display-framework
6
+ agnostic, so we lift it as a **library primitive** that returns ``RenderedSegment`` offset tuples;
7
+ the folio-insights v2 Svelte component (separate op) consumes these to draw nested chips. Returning
8
+ offsets — not HTML — keeps the library UI-neutral.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+
15
+ from .models import Annotation
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class RenderedSegment:
20
+ start: int
21
+ end: int
22
+ text: str
23
+ annotation_ids: tuple[str, ...] # covering annotations, highest-confidence-first
24
+
25
+
26
+ def render_segments(text: str, annotations: list[Annotation]) -> list[RenderedSegment]:
27
+ """Flatten overlapping annotation spans into ordered, non-overlapping display segments."""
28
+ if not text:
29
+ return []
30
+
31
+ points: set[int] = {0, len(text)}
32
+ for ann in annotations:
33
+ points.add(max(0, ann.span.start))
34
+ points.add(min(len(text), ann.span.end))
35
+ ordered = sorted(p for p in points if 0 <= p <= len(text))
36
+
37
+ segments: list[RenderedSegment] = []
38
+ for i in range(len(ordered) - 1):
39
+ seg_start, seg_end = ordered[i], ordered[i + 1]
40
+ if seg_start >= seg_end:
41
+ continue
42
+ covering = [
43
+ ann for ann in annotations if ann.span.start <= seg_start and ann.span.end >= seg_end
44
+ ]
45
+ # Dedup by primary IRI, keeping the highest-confidence annotation per IRI.
46
+ best_by_iri: dict[str, Annotation] = {}
47
+ for ann in covering:
48
+ iri = ann.primary_iri or f"__no_iri_{ann.id}"
49
+ conf = ann.concepts[0].confidence if ann.concepts else 0.0
50
+ existing = best_by_iri.get(iri)
51
+ existing_conf = (
52
+ existing.concepts[0].confidence if existing and existing.concepts else -1.0
53
+ )
54
+ if existing is None or conf > existing_conf:
55
+ best_by_iri[iri] = ann
56
+ ranked = sorted(
57
+ best_by_iri.values(),
58
+ key=lambda a: (a.concepts[0].confidence if a.concepts else 0.0),
59
+ reverse=True,
60
+ )
61
+ segments.append(
62
+ RenderedSegment(
63
+ start=seg_start,
64
+ end=seg_end,
65
+ text=text[seg_start:seg_end],
66
+ annotation_ids=tuple(a.id for a in ranked),
67
+ )
68
+ )
69
+ return segments
@@ -0,0 +1,145 @@
1
+ """Alias / homonym blocklist.
2
+
3
+ Ch02 unit ``4b06a90c``: *"Action != Auction. Awful."* — a deterministic homonym the LLM judge
4
+ should never have to catch. folio-mapper and folio-enrich both lack a deterministic homonym
5
+ guard. This module is that guard: a versioned set of ``(surface_term, blocked_iri, domain)``
6
+ triples. A ``wrong`` per-tag verdict on a homonym appends a triple here, and the matcher consults
7
+ it to drop the bad candidate before it ever reaches a judge.
8
+
9
+ The blocklist is domain-scoped: ``domain=None`` blocks the pairing everywhere; a specific domain
10
+ (e.g. ``"litigation"``) blocks it only when that domain prior is active, so an alias that is wrong
11
+ in one practice area can remain valid in another.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ from collections.abc import Iterable
19
+ from dataclasses import dataclass
20
+ from importlib import resources
21
+ from pathlib import Path
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # The shipped seed lives as package data so every consumer loads the same recorded verdicts.
26
+ SEED_RESOURCE = ("folio_resolve.data", "alias_blocklist.json")
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class BlockedAlias:
31
+ surface_term: str
32
+ blocked_iri: str
33
+ domain: str | None = None
34
+ reason: str = ""
35
+
36
+ def key(self) -> tuple[str, str, str]:
37
+ return (self.surface_term.lower(), self.blocked_iri, (self.domain or "").lower())
38
+
39
+
40
+ class AliasBlocklist:
41
+ """A set of blocked ``(surface_term, iri, domain)`` pairings the matcher consults."""
42
+
43
+ def __init__(self, entries: Iterable[BlockedAlias] | None = None) -> None:
44
+ self._entries: dict[tuple[str, str, str], BlockedAlias] = {}
45
+ for entry in entries or []:
46
+ self.add(entry)
47
+
48
+ def add(self, entry: BlockedAlias) -> None:
49
+ self._entries[entry.key()] = entry
50
+
51
+ def block(
52
+ self, surface_term: str, blocked_iri: str, domain: str | None = None, reason: str = ""
53
+ ) -> None:
54
+ self.add(BlockedAlias(surface_term, blocked_iri, domain, reason))
55
+
56
+ def is_blocked(self, surface_term: str, iri: str, *, domains: Iterable[str] | None = None) -> bool:
57
+ """True if this surface_term→iri pairing is blocked globally or in any active domain."""
58
+ term = surface_term.lower()
59
+ # Global block (domain unset) applies everywhere.
60
+ if (term, iri, "") in self._entries:
61
+ return True
62
+ return any((term, iri, domain.lower()) in self._entries for domain in domains or [])
63
+
64
+ def filter_candidates(
65
+ self,
66
+ surface_term: str,
67
+ candidates: Iterable[tuple[str, float]],
68
+ *,
69
+ domains: Iterable[str] | None = None,
70
+ ) -> list[tuple[str, float]]:
71
+ """Drop ``(iri, score)`` candidates blocked for this surface term."""
72
+ domain_list = list(domains or [])
73
+ return [
74
+ (iri, score)
75
+ for iri, score in candidates
76
+ if not self.is_blocked(surface_term, iri, domains=domain_list)
77
+ ]
78
+
79
+ def __len__(self) -> int:
80
+ return len(self._entries)
81
+
82
+ def is_empty(self) -> bool:
83
+ return not self._entries
84
+
85
+ def entries(self) -> list[BlockedAlias]:
86
+ return list(self._entries.values())
87
+
88
+ # -- persistence -------------------------------------------------------
89
+
90
+ @classmethod
91
+ def load(cls, path: str | Path) -> AliasBlocklist:
92
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
93
+ return cls(
94
+ BlockedAlias(
95
+ surface_term=row["surface_term"],
96
+ blocked_iri=row["blocked_iri"],
97
+ domain=row.get("domain"),
98
+ reason=row.get("reason", ""),
99
+ )
100
+ for row in data.get("blocked_aliases", [])
101
+ )
102
+
103
+ @classmethod
104
+ def from_seed(cls) -> AliasBlocklist:
105
+ """Load the blocklist shipped as package data (the recorded Ch01/Ch02 verdicts).
106
+
107
+ Returns an empty blocklist (never raises) if the seed resource is missing, so a consumer
108
+ degrades to "no homonym vetoes" rather than crashing the tagger.
109
+ """
110
+ pkg, name = SEED_RESOURCE
111
+ try:
112
+ data = (resources.files(pkg) / name).read_text(encoding="utf-8")
113
+ except (FileNotFoundError, ModuleNotFoundError, OSError):
114
+ logger.warning("Alias blocklist seed %s/%s not found; using empty blocklist", pkg, name)
115
+ return cls()
116
+ rows = json.loads(data).get("blocked_aliases", [])
117
+ return cls(
118
+ BlockedAlias(
119
+ surface_term=row["surface_term"],
120
+ blocked_iri=row["blocked_iri"],
121
+ domain=row.get("domain"),
122
+ reason=row.get("reason", ""),
123
+ )
124
+ for row in rows
125
+ )
126
+
127
+ def save(self, path: str | Path) -> None:
128
+ payload = {
129
+ "version": 1,
130
+ "blocked_aliases": [
131
+ {
132
+ "surface_term": e.surface_term,
133
+ "blocked_iri": e.blocked_iri,
134
+ "domain": e.domain,
135
+ "reason": e.reason,
136
+ }
137
+ for e in self._entries.values()
138
+ ],
139
+ }
140
+ Path(path).write_text(json.dumps(payload, indent=2), encoding="utf-8")
141
+
142
+
143
+ def load_seed_blocklist() -> AliasBlocklist:
144
+ """Module-level convenience: the shipped seed blocklist (recorded Ch01/Ch02 verdicts)."""
145
+ return AliasBlocklist.from_seed()