thisness 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.
- thisness/__init__.py +13 -0
- thisness/_canonical.py +41 -0
- thisness/adjudicate.py +173 -0
- thisness/assistant.py +246 -0
- thisness/benchmark/__init__.py +0 -0
- thisness/benchmark/__main__.py +87 -0
- thisness/benchmark/data/README.md +13 -0
- thisness/benchmark/data/uk/README.md +7 -0
- thisness/benchmark/data/uk/ch.parquet +0 -0
- thisness/benchmark/data/uk/gleif.parquet +0 -0
- thisness/benchmark/data/uk/gleif_rr.parquet +0 -0
- thisness/benchmark/data/wfo_fixture.csv.gz +0 -0
- thisness/benchmark/evaluate.py +290 -0
- thisness/benchmark/oracle.py +142 -0
- thisness/benchmark/organizations.py +338 -0
- thisness/benchmark/people.py +716 -0
- thisness/benchmark/taxa.py +453 -0
- thisness/benchmark/uk.py +788 -0
- thisness/browse.py +148 -0
- thisness/candidates.py +171 -0
- thisness/canonicalize.py +146 -0
- thisness/cli.py +546 -0
- thisness/comparisons/__init__.py +0 -0
- thisness/comparisons/base.py +59 -0
- thisness/comparisons/organization.py +227 -0
- thisness/comparisons/person.py +249 -0
- thisness/comparisons/taxon.py +232 -0
- thisness/consensus.py +424 -0
- thisness/core/__init__.py +106 -0
- thisness/core/base.py +14 -0
- thisness/core/components.py +53 -0
- thisness/core/decisions.py +185 -0
- thisness/core/evidence.py +58 -0
- thisness/core/identity.py +134 -0
- thisness/core/invocation.py +49 -0
- thisness/core/ledger.py +49 -0
- thisness/core/observation.py +86 -0
- thisness/core/review.py +34 -0
- thisness/core/run.py +266 -0
- thisness/core/types.py +51 -0
- thisness/dossier.py +554 -0
- thisness/edges.py +92 -0
- thisness/engine.py +1108 -0
- thisness/fellegi_sunter.py +369 -0
- thisness/ids.py +176 -0
- thisness/labels.py +87 -0
- thisness/llm/__init__.py +23 -0
- thisness/llm/client.py +219 -0
- thisness/llm/prices.py +31 -0
- thisness/llm/privacy.py +57 -0
- thisness/llm/prompts/.keep +0 -0
- thisness/llm/prompts/adjudicate_pairs.v1.md +22 -0
- thisness/llm/prompts/adjudicate_pairs.v2.md +20 -0
- thisness/llm/prompts/group_names.v1.md +14 -0
- thisness/llm/prompts/group_names.v2.md +13 -0
- thisness/llm/prompts/recommend_review.v1.md +21 -0
- thisness/llm/prompts/recommend_review.v2.md +21 -0
- thisness/llm/prompts.py +83 -0
- thisness/llm/providers/__init__.py +7 -0
- thisness/llm/providers/anthropic.py +60 -0
- thisness/llm/providers/function.py +24 -0
- thisness/llm/providers/openai_compatible.py +90 -0
- thisness/llm/providers/replay.py +55 -0
- thisness/llm/types.py +57 -0
- thisness/name_only.py +450 -0
- thisness/normalize/__init__.py +0 -0
- thisness/normalize/organization.py +283 -0
- thisness/normalize/person.py +417 -0
- thisness/normalize/taxon.py +317 -0
- thisness/policy.py +54 -0
- thisness/profiles/__init__.py +0 -0
- thisness/profiles/base.py +306 -0
- thisness/profiles/data/organization_starter.json +184 -0
- thisness/profiles/data/person_starter.json +240 -0
- thisness/profiles/data/taxon_starter.json +189 -0
- thisness/profiles/organization.py +100 -0
- thisness/profiles/person.py +129 -0
- thisness/profiles/taxon.py +129 -0
- thisness/py.typed +0 -0
- thisness/relationships.py +113 -0
- thisness/reviews.py +294 -0
- thisness/scoring.py +196 -0
- thisness/starter.py +108 -0
- thisness/storage/__init__.py +21 -0
- thisness/storage/_commit.py +632 -0
- thisness/storage/duckdb/__init__.py +3 -0
- thisness/storage/duckdb/invariants.py +243 -0
- thisness/storage/duckdb/migrations/0001_initial.sql +243 -0
- thisness/storage/duckdb/migrations/0002_pair_constraints.sql +13 -0
- thisness/storage/duckdb/migrations/0003_model_invocations.sql +28 -0
- thisness/storage/duckdb/store.py +1615 -0
- thisness/storage/errors.py +49 -0
- thisness/storage/protocol.py +136 -0
- thisness/training.py +151 -0
- thisness/ui/__init__.py +5 -0
- thisness/ui/app.py +538 -0
- thisness/ui/static/THIRD_PARTY.md +203 -0
- thisness/ui/static/app.css +408 -0
- thisness/ui/static/app.js +135 -0
- thisness/ui/static/icons/adjustments-horizontal.svg +23 -0
- thisness/ui/static/icons/alert-triangle.svg +17 -0
- thisness/ui/static/icons/arrow-back-up.svg +16 -0
- thisness/ui/static/icons/arrows-join.svg +17 -0
- thisness/ui/static/icons/arrows-split.svg +18 -0
- thisness/ui/static/icons/briefcase.svg +18 -0
- thisness/ui/static/icons/building.svg +22 -0
- thisness/ui/static/icons/cake.svg +17 -0
- thisness/ui/static/icons/chart-dots.svg +20 -0
- thisness/ui/static/icons/chevron-down.svg +15 -0
- thisness/ui/static/icons/chevron-right.svg +15 -0
- thisness/ui/static/icons/circle-check.svg +16 -0
- thisness/ui/static/icons/circle-dashed.svg +22 -0
- thisness/ui/static/icons/circle-x.svg +16 -0
- thisness/ui/static/icons/clock.svg +16 -0
- thisness/ui/static/icons/database.svg +17 -0
- thisness/ui/static/icons/external-link.svg +17 -0
- thisness/ui/static/icons/file-text.svg +19 -0
- thisness/ui/static/icons/filter.svg +15 -0
- thisness/ui/static/icons/fingerprint.svg +19 -0
- thisness/ui/static/icons/git-branch.svg +20 -0
- thisness/ui/static/icons/git-merge.svg +19 -0
- thisness/ui/static/icons/hash.svg +18 -0
- thisness/ui/static/icons/history.svg +16 -0
- thisness/ui/static/icons/home.svg +17 -0
- thisness/ui/static/icons/id.svg +19 -0
- thisness/ui/static/icons/layout-sidebar-right.svg +16 -0
- thisness/ui/static/icons/leaf.svg +16 -0
- thisness/ui/static/icons/link.svg +17 -0
- thisness/ui/static/icons/list-check.svg +20 -0
- thisness/ui/static/icons/mail.svg +16 -0
- thisness/ui/static/icons/map-pin.svg +16 -0
- thisness/ui/static/icons/message-question.svg +19 -0
- thisness/ui/static/icons/phone.svg +15 -0
- thisness/ui/static/icons/player-play.svg +15 -0
- thisness/ui/static/icons/plus.svg +16 -0
- thisness/ui/static/icons/scale.svg +19 -0
- thisness/ui/static/icons/search.svg +16 -0
- thisness/ui/static/icons/sparkles.svg +15 -0
- thisness/ui/static/icons/timeline-event.svg +18 -0
- thisness/ui/static/icons/user.svg +16 -0
- thisness/ui/static/icons/users.svg +18 -0
- thisness/ui/static/icons/world.svg +19 -0
- thisness/ui/static/icons/x.svg +16 -0
- thisness/ui/static/theme.css +525 -0
- thisness/ui/static/vendor/Sortable.min.js +2 -0
- thisness/ui/static/vendor/htmx.min.js +1 -0
- thisness/ui/static/vendor/inter-latin-ext-wght-normal.woff2 +0 -0
- thisness/ui/static/vendor/inter-latin-wght-normal.woff2 +0 -0
- thisness/ui/templates/_macros.html +27 -0
- thisness/ui/templates/_panel_head.html +4 -0
- thisness/ui/templates/base.html +53 -0
- thisness/ui/templates/entities.html +48 -0
- thisness/ui/templates/entity.html +71 -0
- thisness/ui/templates/entity_tab.html +106 -0
- thisness/ui/templates/llm.html +32 -0
- thisness/ui/templates/model.html +35 -0
- thisness/ui/templates/not_found.html +4 -0
- thisness/ui/templates/panel_candidate.html +23 -0
- thisness/ui/templates/panel_decision.html +41 -0
- thisness/ui/templates/panel_entity.html +37 -0
- thisness/ui/templates/panel_invocation.html +16 -0
- thisness/ui/templates/panel_observation.html +22 -0
- thisness/ui/templates/relationships.html +32 -0
- thisness/ui/templates/review.html +106 -0
- thisness/ui/templates/reviews.html +32 -0
- thisness/ui/templates/runs.html +20 -0
- thisness/ui/templates/search_results.html +7 -0
- thisness-0.1.0.dist-info/METADATA +183 -0
- thisness-0.1.0.dist-info/RECORD +172 -0
- thisness-0.1.0.dist-info/WHEEL +4 -0
- thisness-0.1.0.dist-info/entry_points.txt +2 -0
- thisness-0.1.0.dist-info/licenses/LICENSE +201 -0
thisness/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Thisness: identity resolution for imperfect observations."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from thisness.engine import ObservationOutcome, RunResult, Thisness
|
|
6
|
+
from thisness.ids import IdFactory
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
__version__ = version("thisness")
|
|
10
|
+
except PackageNotFoundError: # running from a source tree without an install
|
|
11
|
+
__version__ = "0.0.0"
|
|
12
|
+
|
|
13
|
+
__all__ = ["IdFactory", "ObservationOutcome", "RunResult", "Thisness", "__version__"]
|
thisness/_canonical.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Canonical JSON and content digests.
|
|
2
|
+
|
|
3
|
+
Fingerprints and component config hashes must not depend on dict key order or
|
|
4
|
+
on how a value was spelled in Python. Sets are rejected because their order is
|
|
5
|
+
not defined.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
from datetime import date, datetime
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _default(value: Any) -> Any:
|
|
17
|
+
if isinstance(value, datetime):
|
|
18
|
+
if value.tzinfo is None:
|
|
19
|
+
raise TypeError("naive datetimes are ambiguous; use timezone-aware datetimes")
|
|
20
|
+
return value.isoformat()
|
|
21
|
+
if isinstance(value, date):
|
|
22
|
+
return value.isoformat()
|
|
23
|
+
if isinstance(value, (set, frozenset)):
|
|
24
|
+
raise TypeError("sets have no defined order; use a sorted list")
|
|
25
|
+
raise TypeError(f"{type(value).__name__} is not JSON-serializable")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def canonical_json(value: Any) -> str:
|
|
29
|
+
return json.dumps(
|
|
30
|
+
value,
|
|
31
|
+
sort_keys=True,
|
|
32
|
+
separators=(",", ":"),
|
|
33
|
+
ensure_ascii=False,
|
|
34
|
+
allow_nan=False,
|
|
35
|
+
default=_default,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def digest(value: Any) -> str:
|
|
40
|
+
"""SHA-256 hex digest of the canonical JSON of ``value``."""
|
|
41
|
+
return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
|
thisness/adjudicate.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""The LLM adjudicator (design 05 §4, PRD appendix A.7).
|
|
2
|
+
|
|
3
|
+
A model judges the node pairs a run's Fellegi-Sunter edge policy left contested, as
|
|
4
|
+
one more pair resolver in its own family. It is given each pair's two records,
|
|
5
|
+
filtered by the profile's privacy policy, and the comparison results behind the
|
|
6
|
+
pair, each with an id it must cite. Answers that cite ids they were not given, or
|
|
7
|
+
name a relation the profile does not offer, are discarded.
|
|
8
|
+
|
|
9
|
+
In ``shadow`` mode answers are recorded and change nothing. In ``decide`` mode a
|
|
10
|
+
``same`` answer makes the edge strong when the Fellegi-Sunter weight is at least
|
|
11
|
+
``floor_bits``, and a ``different`` answer makes it negative; consensus, holdout
|
|
12
|
+
and the blast-radius policy then apply exactly as they do to any edge.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Mapping, Sequence
|
|
18
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
19
|
+
from dataclasses import dataclass, field, replace
|
|
20
|
+
from typing import Any, Literal
|
|
21
|
+
|
|
22
|
+
from pydantic import BaseModel, Field
|
|
23
|
+
|
|
24
|
+
from thisness.core.components import ComponentRef
|
|
25
|
+
from thisness.llm.client import Answer, BudgetExhausted, LLMClient
|
|
26
|
+
from thisness.llm.privacy import filter_attributes
|
|
27
|
+
from thisness.llm.prompts import Prompt
|
|
28
|
+
|
|
29
|
+
Decision = Literal["same", "different", "related", "unsure"]
|
|
30
|
+
ReasonCode = Literal[
|
|
31
|
+
"NAME_EQUIVALENT", "NAME_TYPO", "ABBREVIATION_OR_ACRONYM", "LEGAL_FORM_ONLY",
|
|
32
|
+
"NICKNAME_OR_TRANSLITERATION", "SHARED_IDENTIFIER", "CONFLICTING_IDENTIFIER",
|
|
33
|
+
"SHARED_CONTACT", "SAME_LOCATION", "DIFFERENT_LOCATION", "DIFFERENT_DESCRIPTOR",
|
|
34
|
+
"COMMON_NAME", "SHARED_HOUSEHOLD", "DIFFERENT_BIRTH_DATE", "GROUP_OR_PARENT",
|
|
35
|
+
"INSUFFICIENT_EVIDENCE", "OTHER",
|
|
36
|
+
] # fmt: skip
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PairAnswer(BaseModel):
|
|
40
|
+
pair: str
|
|
41
|
+
decision: Decision
|
|
42
|
+
relation: str | None = None
|
|
43
|
+
supporting: list[str] = Field(default_factory=list)
|
|
44
|
+
contradicting: list[str] = Field(default_factory=list)
|
|
45
|
+
reason_codes: list[ReasonCode] = Field(default_factory=list)
|
|
46
|
+
rationale: str | None = Field(default=None, max_length=600)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AdjudicationAnswer(BaseModel):
|
|
50
|
+
answers: list[PairAnswer]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def default_prompt() -> Prompt:
|
|
54
|
+
return Prompt.load("adjudicate_pairs", "2", AdjudicationAnswer)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class PairCase:
|
|
59
|
+
"""One contested node pair, as the adjudicator sees it."""
|
|
60
|
+
|
|
61
|
+
nodes: tuple[str, str]
|
|
62
|
+
records: tuple[dict[str, Any], dict[str, Any]] # unfiltered; filtered on the way out
|
|
63
|
+
comparisons: tuple[dict[str, Any], ...] # {"id", "comparison", "level", "bits"}
|
|
64
|
+
evidence_keys: Mapping[str, tuple[str, str, str]] # local id -> (left, right, comparison)
|
|
65
|
+
weight: float # the strongest Fellegi-Sunter weight behind the pair
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class Adjudication:
|
|
70
|
+
case: PairCase
|
|
71
|
+
answer: PairAnswer | None
|
|
72
|
+
invocation_id: str | None
|
|
73
|
+
status: str # ok, invalid_citation, unknown_relation, missing, failed, skipped
|
|
74
|
+
applied: bool = False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class Adjudicator:
|
|
79
|
+
client: LLMClient
|
|
80
|
+
model: str = "claude-sonnet-5"
|
|
81
|
+
mode: Literal["shadow", "decide"] = "shadow"
|
|
82
|
+
floor_bits: float = 6.0
|
|
83
|
+
pairs_per_call: int = 8
|
|
84
|
+
max_pairs: int | None = 500
|
|
85
|
+
prompt: Prompt = field(default_factory=default_prompt)
|
|
86
|
+
# A model's "same" yields to a contradiction (design 06 §10.7): where the edges it
|
|
87
|
+
# set strong would put a negative or cannot-link pair inside one component, they go
|
|
88
|
+
# back to contested and the component is settled on the evidence alone. A wrong
|
|
89
|
+
# "same" then costs a review of that pair, not a hold on a whole taxon.
|
|
90
|
+
same_yields: bool = True
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def ref(self) -> ComponentRef:
|
|
94
|
+
config = {"model": self.model, "mode": self.mode, "floor_bits": self.floor_bits,
|
|
95
|
+
"pairs_per_call": self.pairs_per_call, "prompt": self.prompt.ref.ref} # fmt: skip
|
|
96
|
+
if not self.same_yields: # the original policy keeps its original ref
|
|
97
|
+
return ComponentRef.of("resolver", "llm_adjudicator", "1", config)
|
|
98
|
+
return ComponentRef.of("resolver", "llm_adjudicator", "2", {**config, "same_yields": True})
|
|
99
|
+
|
|
100
|
+
def edge(self, adjudication: Adjudication) -> str | None:
|
|
101
|
+
"""The edge class a decided answer sets, or None to leave the edge as it is."""
|
|
102
|
+
answer = adjudication.answer
|
|
103
|
+
if self.mode != "decide" or answer is None:
|
|
104
|
+
return None
|
|
105
|
+
if answer.decision == "same" and adjudication.case.weight >= self.floor_bits:
|
|
106
|
+
return "strong"
|
|
107
|
+
if answer.decision == "different":
|
|
108
|
+
return "negative"
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
def for_profile(self, profile: Any) -> Adjudicator:
|
|
112
|
+
"""This adjudicator with the profile's guidance in its prompt (design 06 §6)."""
|
|
113
|
+
prompt = self.prompt.for_profile(profile)
|
|
114
|
+
return self if prompt is self.prompt else replace(self, prompt=prompt)
|
|
115
|
+
|
|
116
|
+
def adjudicate(
|
|
117
|
+
self,
|
|
118
|
+
cases: Sequence[PairCase],
|
|
119
|
+
*,
|
|
120
|
+
entity_type: str,
|
|
121
|
+
attributes: Mapping[str, Any],
|
|
122
|
+
relations: Sequence[str],
|
|
123
|
+
run_id: str | None = None,
|
|
124
|
+
stage_id: str | None = None,
|
|
125
|
+
) -> list[Adjudication]:
|
|
126
|
+
ordered = sorted(cases, key=lambda c: (-c.weight, c.nodes))
|
|
127
|
+
chosen = ordered if self.max_pairs is None else ordered[: self.max_pairs]
|
|
128
|
+
skipped = [Adjudication(c, None, None, "skipped") for c in ordered[len(chosen) :]]
|
|
129
|
+
batches = [chosen[i : i + self.pairs_per_call]
|
|
130
|
+
for i in range(0, len(chosen), self.pairs_per_call)] # fmt: skip
|
|
131
|
+
local = self.client.provider.local
|
|
132
|
+
|
|
133
|
+
def payload(batch: Sequence[PairCase]) -> dict[str, Any]:
|
|
134
|
+
pairs = []
|
|
135
|
+
for i, case in enumerate(batch):
|
|
136
|
+
a, b = (dict(zip(("fields", "withheld"),
|
|
137
|
+
filter_attributes(r, attributes, local=local), strict=True))
|
|
138
|
+
for r in case.records) # fmt: skip
|
|
139
|
+
pairs.append({"pair": f"P{i + 1}", "a": {"ref": case.nodes[0], **a},
|
|
140
|
+
"b": {"ref": case.nodes[1], **b},
|
|
141
|
+
"comparisons": list(case.comparisons)}) # fmt: skip
|
|
142
|
+
return {"entity_type": entity_type, "relations": list(relations), "pairs": pairs}
|
|
143
|
+
|
|
144
|
+
def run(batch: Sequence[PairCase]) -> list[Adjudication]:
|
|
145
|
+
try:
|
|
146
|
+
result: Answer = self.client.call(self.prompt, model=self.model,
|
|
147
|
+
payload=payload(batch), run_id=run_id,
|
|
148
|
+
stage_id=stage_id) # fmt: skip
|
|
149
|
+
except BudgetExhausted:
|
|
150
|
+
return [Adjudication(c, None, None, "skipped") for c in batch]
|
|
151
|
+
invocation = result.invocation.invocation_id
|
|
152
|
+
if not result.ok:
|
|
153
|
+
return [Adjudication(c, None, invocation, "failed") for c in batch]
|
|
154
|
+
by_slot = {}
|
|
155
|
+
for answer in result.parsed.answers:
|
|
156
|
+
by_slot.setdefault(answer.pair, answer)
|
|
157
|
+
out = []
|
|
158
|
+
for i, case in enumerate(batch):
|
|
159
|
+
answer = by_slot.get(f"P{i + 1}")
|
|
160
|
+
if answer is None:
|
|
161
|
+
out.append(Adjudication(case, None, invocation, "missing"))
|
|
162
|
+
elif not set(answer.supporting + answer.contradicting) <= set(case.evidence_keys):
|
|
163
|
+
out.append(Adjudication(case, None, invocation, "invalid_citation"))
|
|
164
|
+
elif answer.decision == "related" and answer.relation not in relations:
|
|
165
|
+
out.append(Adjudication(case, None, invocation, "unknown_relation"))
|
|
166
|
+
else:
|
|
167
|
+
out.append(Adjudication(case, answer, invocation, "ok"))
|
|
168
|
+
return out
|
|
169
|
+
|
|
170
|
+
workers = max(1, self.client.policy.concurrency)
|
|
171
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
172
|
+
results = [a for batch in pool.map(run, batches) for a in batch]
|
|
173
|
+
return results + skipped
|
thisness/assistant.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""The review assistant (design 05 §5, PRD appendix A.15).
|
|
2
|
+
|
|
3
|
+
A model answers an open review's own question from its case, and the answer is
|
|
4
|
+
recorded as a ``ReviewRecommendation``: shown to the reviewer as a suggestion that
|
|
5
|
+
can be applied to the board, never submitted, never a label. When the reviewer
|
|
6
|
+
answers, their ``ReviewDecision`` records which recommendation they saw, so the
|
|
7
|
+
model's agreement with people is measured on real decisions.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from itertools import combinations
|
|
16
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel, Field
|
|
19
|
+
|
|
20
|
+
from thisness.adjudicate import ReasonCode
|
|
21
|
+
from thisness.core.components import ComponentRef
|
|
22
|
+
from thisness.core.decisions import Actor, ReviewDecision, ReviewRecommendation
|
|
23
|
+
from thisness.llm.client import BudgetExhausted, LLMClient
|
|
24
|
+
from thisness.llm.privacy import filter_attributes
|
|
25
|
+
from thisness.llm.prompts import Prompt
|
|
26
|
+
from thisness.profiles.base import AttributeDefinition
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from thisness.engine import Thisness
|
|
30
|
+
|
|
31
|
+
MAX_COMPARISONS = 40
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RecommendationAnswer(BaseModel):
|
|
35
|
+
resolution: Literal["same", "different", "groups", "related", "unsure"]
|
|
36
|
+
groups: list[list[str]] | None = None
|
|
37
|
+
relation: str | None = None
|
|
38
|
+
cited: list[str] = Field(default_factory=list)
|
|
39
|
+
reason_codes: list[ReasonCode] = Field(default_factory=list)
|
|
40
|
+
rationale: str | None = Field(default=None, max_length=800)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def default_prompt() -> Prompt:
|
|
44
|
+
return Prompt.load("recommend_review", "2", RecommendationAnswer)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class Recommended:
|
|
49
|
+
review_id: str
|
|
50
|
+
recommendation: ReviewRecommendation | None
|
|
51
|
+
invocation_id: str | None
|
|
52
|
+
status: str # ok, invalid_groups, invalid_citation, unknown_relation, failed, skipped
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def review_labels(engine: Thisness, case: Any) -> tuple[dict[str, str], list[tuple]]:
|
|
56
|
+
"""The labels a review's records and comparisons carry in the assistant's request:
|
|
57
|
+
records R1, R2, ... in the case's order; comparisons C1, C2, ... strongest first,
|
|
58
|
+
as ``(label, judgment, record label, record label, weight)``. The Workbench shows the
|
|
59
|
+
same labels, so a suggestion's rationale reads against the board."""
|
|
60
|
+
refs = {node.node_id: f"R{i + 1}" for i, node in enumerate(case.nodes)}
|
|
61
|
+
member_of = {member: node.node_id for node in case.nodes if node.kind == "entity"
|
|
62
|
+
for member in engine.members(node.node_id)} # fmt: skip
|
|
63
|
+
|
|
64
|
+
def ref_of(obs: str) -> str | None:
|
|
65
|
+
return refs.get(obs) or refs.get(member_of.get(obs, ""))
|
|
66
|
+
|
|
67
|
+
scored = []
|
|
68
|
+
for j in case.judgments:
|
|
69
|
+
a, b = ref_of(j.left_id), ref_of(j.right_id)
|
|
70
|
+
if a is None or b is None or a == b:
|
|
71
|
+
continue
|
|
72
|
+
weight = next((float(s.value) for s in j.scores
|
|
73
|
+
if s.kind == "fellegi_sunter_weight"), None) # fmt: skip
|
|
74
|
+
scored.append((abs(weight) if weight is not None else 0.0, j, a, b, weight))
|
|
75
|
+
ranked = sorted(scored, key=lambda x: (-x[0], x[1].decision_id))[:MAX_COMPARISONS]
|
|
76
|
+
return refs, [(f"C{i + 1}", j, a, b, w) for i, (_, j, a, b, w) in enumerate(ranked)]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class _Request:
|
|
81
|
+
review_id: str
|
|
82
|
+
prompt: Prompt
|
|
83
|
+
payload: dict[str, Any]
|
|
84
|
+
refs: dict[str, str]
|
|
85
|
+
node_of_ref: dict[str, str]
|
|
86
|
+
current: dict[str, list[str]]
|
|
87
|
+
judgment_of: dict[str, str]
|
|
88
|
+
relations: list[str]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class ReviewAssistant:
|
|
93
|
+
client: LLMClient
|
|
94
|
+
model: str = "claude-sonnet-5"
|
|
95
|
+
prompt: Prompt = field(default_factory=default_prompt)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def ref(self) -> ComponentRef:
|
|
99
|
+
return ComponentRef.of("resolver", "review_assistant", "1",
|
|
100
|
+
{"model": self.model, "prompt": self.prompt.ref.ref}) # fmt: skip
|
|
101
|
+
|
|
102
|
+
def _prepare(self, engine: Thisness, review_id: str) -> _Request:
|
|
103
|
+
"""Everything the request needs, read from the store."""
|
|
104
|
+
case = engine.review_case(review_id)
|
|
105
|
+
refs, labelled = review_labels(engine, case)
|
|
106
|
+
node_of_ref = {r: n for n, r in refs.items()}
|
|
107
|
+
entity_type = None
|
|
108
|
+
current: dict[str, list[str]] = {}
|
|
109
|
+
records = []
|
|
110
|
+
local = self.client.provider.local
|
|
111
|
+
for node in case.nodes:
|
|
112
|
+
current.setdefault(node.entity_id or node.node_id, []).append(refs[node.node_id])
|
|
113
|
+
profile = engine.profiles.get(engine.get_entity(node.entity_id)[0].entity_type) \
|
|
114
|
+
if node.entity_id else None # fmt: skip
|
|
115
|
+
entity_type = entity_type or (profile.entity_type if profile else None)
|
|
116
|
+
definitions = dict(profile.attributes) if profile else {}
|
|
117
|
+
attributes = dict(node.attributes)
|
|
118
|
+
if node.kind == "entity":
|
|
119
|
+
attributes["observation_count"] = node.size
|
|
120
|
+
definitions["observation_count"] = AttributeDefinition(
|
|
121
|
+
name="observation_count", data_type="integer") # fmt: skip
|
|
122
|
+
fields, withheld = filter_attributes(attributes, definitions, local=local)
|
|
123
|
+
source = node.source.get("type") if node.source else None
|
|
124
|
+
records.append({"ref": refs[node.node_id], "record_id": node.node_id,
|
|
125
|
+
"kind": node.kind, "source": source, "fields": fields,
|
|
126
|
+
"withheld": withheld}) # fmt: skip
|
|
127
|
+
|
|
128
|
+
comparisons, judgment_of = [], {}
|
|
129
|
+
evidence = {e.evidence_id: e for e in engine.store.evidence(
|
|
130
|
+
sorted({e for _, j, *_ in labelled for e in j.evidence_ids}))} # fmt: skip
|
|
131
|
+
for local_id, j, a, b, weight in labelled:
|
|
132
|
+
levels = {e.attribute: e.level for i in j.evidence_ids if (e := evidence.get(i))
|
|
133
|
+
and e.attribute and e.level} # fmt: skip
|
|
134
|
+
comparisons.append({"id": local_id, "a": a, "b": b, "by": j.actor.kind,
|
|
135
|
+
"weight": None if weight is None else round(weight, 1),
|
|
136
|
+
"verdict": str(j.verdict), "levels": levels}) # fmt: skip
|
|
137
|
+
judgment_of[local_id] = j.decision_id
|
|
138
|
+
|
|
139
|
+
profile = engine.profiles.get(entity_type) if entity_type else None
|
|
140
|
+
relations = list(profile.relations) if profile else ["related_to"]
|
|
141
|
+
payload: dict[str, Any] = {
|
|
142
|
+
"entity_type": entity_type, "question": case.question, "relations": relations,
|
|
143
|
+
"records": records, "current_groups": sorted(current.values()),
|
|
144
|
+
"comparisons": comparisons,
|
|
145
|
+
} # fmt: skip
|
|
146
|
+
prompt = self.prompt.for_profile(engine.profiles.get(entity_type) if entity_type else None)
|
|
147
|
+
return _Request(review_id, prompt, payload, refs, node_of_ref, current, judgment_of,
|
|
148
|
+
relations) # fmt: skip
|
|
149
|
+
|
|
150
|
+
def _ask(self, request: _Request) -> Any:
|
|
151
|
+
"""The model call: the only step that runs in parallel."""
|
|
152
|
+
try:
|
|
153
|
+
return self.client.call(request.prompt, model=self.model, payload=request.payload)
|
|
154
|
+
except BudgetExhausted:
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
def _finish(self, engine: Thisness, request: _Request, answer: Any) -> Recommended:
|
|
158
|
+
"""Check the answer and record it, one review at a time."""
|
|
159
|
+
review_id, prompt, refs = request.review_id, request.prompt, request.refs
|
|
160
|
+
node_of_ref, current = request.node_of_ref, request.current
|
|
161
|
+
judgment_of, relations = request.judgment_of, request.relations
|
|
162
|
+
if answer is None:
|
|
163
|
+
return Recommended(review_id, None, None, "skipped")
|
|
164
|
+
invocation = answer.invocation.invocation_id
|
|
165
|
+
if not answer.ok:
|
|
166
|
+
return Recommended(review_id, None, invocation, "failed")
|
|
167
|
+
parsed: RecommendationAnswer = answer.parsed
|
|
168
|
+
if not set(parsed.cited) <= set(judgment_of):
|
|
169
|
+
return Recommended(review_id, None, invocation, "invalid_citation")
|
|
170
|
+
groups = _partition(parsed, sorted(current.values()), sorted(refs.values()))
|
|
171
|
+
if parsed.resolution != "unsure" and groups is None:
|
|
172
|
+
return Recommended(review_id, None, invocation, "invalid_groups")
|
|
173
|
+
if parsed.resolution == "related" and parsed.relation not in relations:
|
|
174
|
+
return Recommended(review_id, None, invocation, "unknown_relation")
|
|
175
|
+
engine.store.register_component(self.ref, {"model": self.model})
|
|
176
|
+
recommendation = ReviewRecommendation(
|
|
177
|
+
decision_id=engine.ids.decision(), actor=Actor(kind="agent", id=self.model),
|
|
178
|
+
review_id=review_id, resolution=parsed.resolution,
|
|
179
|
+
groups=None if groups is None else tuple(
|
|
180
|
+
tuple(sorted(node_of_ref[r] for r in g)) for g in groups),
|
|
181
|
+
relation=parsed.relation if parsed.resolution == "related" else None,
|
|
182
|
+
reason_codes=tuple(parsed.reason_codes), rationale=parsed.rationale,
|
|
183
|
+
cited_judgments=tuple(judgment_of[c] for c in parsed.cited),
|
|
184
|
+
component_refs=(self.ref.ref, prompt.ref.ref),
|
|
185
|
+
invocation_id=invocation, created_at=engine.clock(),
|
|
186
|
+
) # fmt: skip
|
|
187
|
+
engine.store.record_decisions([recommendation])
|
|
188
|
+
return Recommended(review_id, recommendation, invocation, "ok")
|
|
189
|
+
|
|
190
|
+
def recommend(self, engine: Thisness, review_id: str) -> Recommended:
|
|
191
|
+
request = self._prepare(engine, review_id)
|
|
192
|
+
return self._finish(engine, request, self._ask(request))
|
|
193
|
+
|
|
194
|
+
def recommend_many(self, engine: Thisness, review_ids: Sequence[str]) -> list[Recommended]:
|
|
195
|
+
"""Recommendations for many reviews, the model asked ``policy.concurrency`` at a
|
|
196
|
+
time. Reading the store and recording answers stay sequential and in order."""
|
|
197
|
+
requests = [self._prepare(engine, r) for r in review_ids]
|
|
198
|
+
workers = max(1, min(self.client.policy.concurrency, len(requests)))
|
|
199
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
200
|
+
answers = list(pool.map(self._ask, requests))
|
|
201
|
+
return [self._finish(engine, r, a) for r, a in zip(requests, answers, strict=True)]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _partition(answer: RecommendationAnswer, current: list[list[str]],
|
|
205
|
+
refs: list[str]) -> list[list[str]] | None: # fmt: skip
|
|
206
|
+
"""The grouping an answer means, or None when it is not a valid one."""
|
|
207
|
+
if answer.resolution == "unsure":
|
|
208
|
+
return None
|
|
209
|
+
if answer.resolution == "same":
|
|
210
|
+
return [refs]
|
|
211
|
+
if answer.resolution == "different":
|
|
212
|
+
return current
|
|
213
|
+
if answer.resolution == "related":
|
|
214
|
+
return current if len(current) == 2 else None
|
|
215
|
+
groups = [list(g) for g in answer.groups or [] if g]
|
|
216
|
+
flat = [r for g in groups for r in g]
|
|
217
|
+
return groups if sorted(flat) == refs and len(flat) == len(set(flat)) else None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def agreement(decisions: Sequence[ReviewDecision],
|
|
221
|
+
recommendations: dict[str, ReviewRecommendation]) -> dict[str, Any]: # fmt: skip
|
|
222
|
+
"""How often reviewers' answers matched the recommendation they were shown: the
|
|
223
|
+
whole grouping, and pair by pair (same group or not)."""
|
|
224
|
+
exact = pairs = agreed = n = 0
|
|
225
|
+
by_resolution: dict[str, list[int]] = {}
|
|
226
|
+
for decision in decisions:
|
|
227
|
+
rec = recommendations.get(decision.recommendation_id or "")
|
|
228
|
+
if rec is None or rec.groups is None or decision.groups is None:
|
|
229
|
+
continue
|
|
230
|
+
n += 1
|
|
231
|
+
mine = {node: i for i, g in enumerate(decision.groups) for node in g}
|
|
232
|
+
theirs = {node: i for i, g in enumerate(rec.groups) for node in g}
|
|
233
|
+
same_grouping = {frozenset(g) for g in decision.groups} == {frozenset(g)
|
|
234
|
+
for g in rec.groups} # fmt: skip
|
|
235
|
+
exact += same_grouping
|
|
236
|
+
by_resolution.setdefault(rec.resolution, []).append(int(same_grouping))
|
|
237
|
+
for a, b in combinations(sorted(mine), 2):
|
|
238
|
+
if a in theirs and b in theirs:
|
|
239
|
+
pairs += 1
|
|
240
|
+
agreed += (mine[a] == mine[b]) == (theirs[a] == theirs[b])
|
|
241
|
+
return {
|
|
242
|
+
"reviews": n,
|
|
243
|
+
"exact": exact / n if n else None,
|
|
244
|
+
"pairwise": agreed / pairs if pairs else None,
|
|
245
|
+
"exact_by_recommendation": {k: sum(v) / len(v) for k, v in sorted(by_resolution.items())},
|
|
246
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""``python -m thisness.benchmark run``: resolve synthetic benchmarks and report.
|
|
2
|
+
``python -m thisness.benchmark starter``: rebuild a shipped starter model."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from thisness.benchmark import organizations, people, taxa
|
|
11
|
+
from thisness.benchmark.evaluate import PROFILES, run_benchmark
|
|
12
|
+
from thisness.starter import write_starter
|
|
13
|
+
|
|
14
|
+
GENERATORS = {"organization": organizations.generate, "person": people.generate,
|
|
15
|
+
"taxon": taxa.standard} # fmt: skip
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main() -> None:
|
|
19
|
+
parser = argparse.ArgumentParser(prog="python -m thisness.benchmark")
|
|
20
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
21
|
+
run = sub.add_parser("run", help="resolve benchmarks and print reports")
|
|
22
|
+
run.add_argument("--type", default="organization", choices=sorted(GENERATORS))
|
|
23
|
+
run.add_argument("--size", "--orgs", type=int, nargs="+", default=[805], dest="orgs",
|
|
24
|
+
help="entities per benchmark") # fmt: skip
|
|
25
|
+
run.add_argument("--seeds", type=int, nargs="+", default=[11])
|
|
26
|
+
run.add_argument("--json", action="store_true", help="print full reports as JSON")
|
|
27
|
+
run.add_argument("--reference", action="store_true",
|
|
28
|
+
help="taxon only: WFO's own rows as observations too (design 06 §5)") # fmt: skip
|
|
29
|
+
run.add_argument(
|
|
30
|
+
"--col",
|
|
31
|
+
action="store_true",
|
|
32
|
+
help="taxon only: the Catalogue of Life's rows too, a second authority",
|
|
33
|
+
)
|
|
34
|
+
run.add_argument(
|
|
35
|
+
"--authority",
|
|
36
|
+
default=None,
|
|
37
|
+
help="taxon only: the reference authority's source (default wfo)",
|
|
38
|
+
)
|
|
39
|
+
sub.add_parser("wfo-fixture", help="rebuild the shipped WFO fixture (fetches three families)")
|
|
40
|
+
sub.add_parser("uk-stage", help="fetch GLEIF and Companies House and stage them (design 07)")
|
|
41
|
+
sub.add_parser("uk-truth", help="count how good the GLEIF to Companies House join is")
|
|
42
|
+
uk_audit = sub.add_parser("uk-audit", help="read a sample of the join")
|
|
43
|
+
uk_audit.add_argument("--n", type=int, default=400)
|
|
44
|
+
sub.add_parser("uk-fixture", help="rebuild the shipped UK fixture from the staged data")
|
|
45
|
+
starter = sub.add_parser("starter", help="rebuild a starter model")
|
|
46
|
+
starter.add_argument("--type", default="organization", choices=sorted(GENERATORS))
|
|
47
|
+
starter.add_argument("--out", type=Path, default=None)
|
|
48
|
+
args = parser.parse_args()
|
|
49
|
+
|
|
50
|
+
if args.command == "run":
|
|
51
|
+
for n in args.orgs:
|
|
52
|
+
for seed in args.seeds:
|
|
53
|
+
options = {k: True for k in ("reference", "col") if getattr(args, k)}
|
|
54
|
+
profile = (PROFILES[args.type](authority=args.authority)
|
|
55
|
+
if args.authority else None) # fmt: skip
|
|
56
|
+
report, _, _ = run_benchmark(GENERATORS[args.type](n, seed=seed, **options),
|
|
57
|
+
profile=profile) # fmt: skip
|
|
58
|
+
print(json.dumps(report.as_dict(), indent=1) if args.json else report.summary())
|
|
59
|
+
elif args.command.startswith("uk-"):
|
|
60
|
+
from thisness.benchmark import uk
|
|
61
|
+
|
|
62
|
+
if args.command == "uk-stage":
|
|
63
|
+
print(f"staged {uk.stage()}")
|
|
64
|
+
elif args.command == "uk-truth":
|
|
65
|
+
print(json.dumps(uk.truth_report(), indent=1))
|
|
66
|
+
elif args.command == "uk-audit":
|
|
67
|
+
report = uk.audit(n=args.n)
|
|
68
|
+
print(json.dumps({k: v for k, v in report.items() if k != "differing_names"}))
|
|
69
|
+
for d in report["differing_names"][:25]:
|
|
70
|
+
print(f"- {d['gleif']!r} | Companies House: {d['companies_house']!r}")
|
|
71
|
+
else:
|
|
72
|
+
print(f"wrote {uk.make_fixture()}")
|
|
73
|
+
elif args.command == "wfo-fixture":
|
|
74
|
+
target = Path(taxa.__file__).parent / "data" / "wfo_fixture.csv.gz"
|
|
75
|
+
taxa.write_rows(taxa.families(taxa.FIXTURE_FAMILIES), target)
|
|
76
|
+
print(f"wrote {target}")
|
|
77
|
+
else:
|
|
78
|
+
import thisness.profiles
|
|
79
|
+
|
|
80
|
+
profile = PROFILES[args.type]()
|
|
81
|
+
default = Path(thisness.profiles.__file__).parent / "data" / profile.starter_model
|
|
82
|
+
path = write_starter(profile, args.out or default)
|
|
83
|
+
print(f"wrote {path}")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
main()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Benchmark data
|
|
2
|
+
|
|
3
|
+
`wfo_fixture.csv.gz`: every species-level name (accepted, and synonyms with the accepted
|
|
4
|
+
taxon each belongs to) in three plant families, Pinaceae, Betulaceae and Juglandaceae,
|
|
5
|
+
from the **World Flora Online Plant List 2026-06**, as published on ChecklistBank
|
|
6
|
+
(dataset 2004). The data is dedicated to the public domain under **CC0 1.0**; we credit
|
|
7
|
+
it as a courtesy:
|
|
8
|
+
|
|
9
|
+
> The World Flora Online Consortium et al. World Flora Online Plant List 2026-06.
|
|
10
|
+
> https://www.worldfloraonline.org, via ChecklistBank (https://www.checklistbank.org/dataset/2004).
|
|
11
|
+
|
|
12
|
+
Rebuild with `python -m thisness.benchmark wfo-fixture`. Other families are fetched on
|
|
13
|
+
demand and cached under `~/.cache/thisness/wfo`.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# UK organizations fixture
|
|
2
|
+
|
|
3
|
+
A small slice of real data for tests (design 07): GLEIF Golden Copy Level 1 and 2 records registered with Companies House, and the Companies House records of those companies and their live near-namesakes.
|
|
4
|
+
|
|
5
|
+
Contains GLEIF Golden Copy data (CC0 1.0) and Companies House data, public sector information licensed under the Open Government Licence v3.0.
|
|
6
|
+
|
|
7
|
+
Built from: BasicCompanyDataAsOneFile-2026-10-01.zip (companies_house), 20261009-0800-gleif-goldencopy-lei2-golden-copy.csv.zip (lei2), 20261009-0800-gleif-goldencopy-rr-golden-copy.csv.zip (rr).
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|