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.
- folio_resolve/__init__.py +107 -0
- folio_resolve/annotate/__init__.py +46 -0
- folio_resolve/annotate/feedback_store.py +95 -0
- folio_resolve/annotate/lifecycle.py +71 -0
- folio_resolve/annotate/models.py +135 -0
- folio_resolve/annotate/render.py +69 -0
- folio_resolve/blocklist.py +145 -0
- folio_resolve/calibration.py +89 -0
- folio_resolve/data/__init__.py +1 -0
- folio_resolve/data/alias_blocklist.json +36 -0
- folio_resolve/decompose.py +105 -0
- folio_resolve/domain_prior.py +198 -0
- folio_resolve/embedding.py +139 -0
- folio_resolve/entity_ruler.py +114 -0
- folio_resolve/gates.py +124 -0
- folio_resolve/judge.py +161 -0
- folio_resolve/matching/__init__.py +5 -0
- folio_resolve/matching/aho_corasick.py +139 -0
- folio_resolve/ontology.py +162 -0
- folio_resolve/pipeline.py +210 -0
- folio_resolve/reconciler.py +230 -0
- folio_resolve/resolve.py +133 -0
- folio_resolve/scoring.py +298 -0
- folio_resolve/sources.py +72 -0
- folio_resolve/spec.py +109 -0
- folio_resolve-0.1.0.dist-info/METADATA +169 -0
- folio_resolve-0.1.0.dist-info/RECORD +29 -0
- folio_resolve-0.1.0.dist-info/WHEEL +4 -0
- folio_resolve-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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()
|