wikidata-google-knowledge-mcp 0.2.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.
- wikidata_google_knowledge_mcp/__init__.py +1 -0
- wikidata_google_knowledge_mcp/arbitration.py +283 -0
- wikidata_google_knowledge_mcp/batch.py +367 -0
- wikidata_google_knowledge_mcp/budget.py +46 -0
- wikidata_google_knowledge_mcp/cache.py +157 -0
- wikidata_google_knowledge_mcp/cli.py +353 -0
- wikidata_google_knowledge_mcp/compact.py +114 -0
- wikidata_google_knowledge_mcp/config.py +174 -0
- wikidata_google_knowledge_mcp/errors.py +40 -0
- wikidata_google_knowledge_mcp/evidence.py +169 -0
- wikidata_google_knowledge_mcp/evidence_export.py +508 -0
- wikidata_google_knowledge_mcp/google_kg.py +165 -0
- wikidata_google_knowledge_mcp/kinds.py +166 -0
- wikidata_google_knowledge_mcp/mcp_server.py +154 -0
- wikidata_google_knowledge_mcp/normalize.py +356 -0
- wikidata_google_knowledge_mcp/policy.py +253 -0
- wikidata_google_knowledge_mcp/reconcile.py +456 -0
- wikidata_google_knowledge_mcp/redact.py +40 -0
- wikidata_google_knowledge_mcp/resolver.py +808 -0
- wikidata_google_knowledge_mcp/secrets.py +90 -0
- wikidata_google_knowledge_mcp/service.py +535 -0
- wikidata_google_knowledge_mcp/sparql_guard.py +87 -0
- wikidata_google_knowledge_mcp/store.py +103 -0
- wikidata_google_knowledge_mcp/wikidata.py +275 -0
- wikidata_google_knowledge_mcp/wikidata_bulk.py +387 -0
- wikidata_google_knowledge_mcp/wikidata_parse.py +180 -0
- wikidata_google_knowledge_mcp-0.2.0.dist-info/METADATA +515 -0
- wikidata_google_knowledge_mcp-0.2.0.dist-info/RECORD +31 -0
- wikidata_google_knowledge_mcp-0.2.0.dist-info/WHEEL +4 -0
- wikidata_google_knowledge_mcp-0.2.0.dist-info/entry_points.txt +3 -0
- wikidata_google_knowledge_mcp-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Optional cheap-model arbitration for the unresolved tail.
|
|
2
|
+
|
|
3
|
+
The model sees one entity at a time: a compact packet with the caller input,
|
|
4
|
+
at most 3 (hard max 5) Wikidata-bearing and 3 (max 5) Google-only candidates,
|
|
5
|
+
their joins, local evidence and conflicts. No raw provider JSON, no articles,
|
|
6
|
+
no tools, no browsing. Candidates are named by packet-local handles (C1, C2...);
|
|
7
|
+
the model can only pick one of those handles, so it cannot mint a QID or a
|
|
8
|
+
Google id. Anything else - a schema violation, an unknown handle, a timeout,
|
|
9
|
+
a refused budget, a missing price - is a failure and the row stays HOLD.
|
|
10
|
+
|
|
11
|
+
The model name, endpoint, credential and prices are configuration. The call
|
|
12
|
+
budget and the cost cap are checked before every call, using a worst-case
|
|
13
|
+
estimate, and the actual usage is recorded after it.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import time
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from typing import Any, Callable
|
|
23
|
+
|
|
24
|
+
import anyio
|
|
25
|
+
import httpx2
|
|
26
|
+
|
|
27
|
+
from .config import ARBITRATION_KEY_ENV, USER_AGENT, Settings
|
|
28
|
+
from .normalize import EntityInput, parse_url
|
|
29
|
+
from .policy import CONFLICTS, EVIDENCE, RESOLVER_POLICY_VERSION
|
|
30
|
+
from .redact import redact, register_secret, short_error
|
|
31
|
+
from .secrets import Credential, load_credential
|
|
32
|
+
|
|
33
|
+
ARB_DECISIONS = ("MATCH", "NO_MATCH", "AMBIGUOUS", "CONFLICT")
|
|
34
|
+
ARB_REASONS = (
|
|
35
|
+
"NAME_AND_LOCATION_CONSISTENT", "NAME_AND_ROLE_CONSISTENT", "NAME_AND_CREATOR_CONSISTENT",
|
|
36
|
+
"NAME_AND_DATE_CONSISTENT", "OFFICIAL_SITE_CONSISTENT", "DESCRIPTION_CONSISTENT", "PROVIDERS_CONCORDANT",
|
|
37
|
+
"MULTIPLE_PLAUSIBLE", "INSUFFICIENT_EVIDENCE", "KIND_MISMATCH", "LOCATION_MISMATCH", "DATE_MISMATCH",
|
|
38
|
+
"GRAIN_MISMATCH", "NO_PLAUSIBLE_CANDIDATE",
|
|
39
|
+
)
|
|
40
|
+
NORMAL_PER_PROVIDER = 3
|
|
41
|
+
MAX_PER_PROVIDER = 5
|
|
42
|
+
SYSTEM_PROMPT = (
|
|
43
|
+
"You check whether a local entity is the same real-world entity as one of the listed candidates. "
|
|
44
|
+
"Use only the facts in the packet. Candidates are named C1, C2, ...; never output any other identifier. "
|
|
45
|
+
"Answer MATCH with one candidate only when the packet's facts make that identity clear. "
|
|
46
|
+
"A person needs more than a shared name. A dated event occurrence is not its festival or series, and a "
|
|
47
|
+
"series is not one dated edition. If several candidates remain plausible answer AMBIGUOUS; if the facts "
|
|
48
|
+
"contradict each other answer CONFLICT; if no candidate fits answer NO_MATCH."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _short(s: Any, n: int) -> str:
|
|
53
|
+
s = "" if s is None else str(s)
|
|
54
|
+
return s if len(s) <= n else s[: n - 1] + "…"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _ctx(ev_ctx: dict, qids: list[str], key: str = "label_en", n: int = 3) -> list[str]:
|
|
58
|
+
out: list[str] = []
|
|
59
|
+
for q in qids or []:
|
|
60
|
+
for v in (ev_ctx.get(q) or {}).get(key) or (ev_ctx.get(q) or {}).get("labels") or []:
|
|
61
|
+
if v not in out:
|
|
62
|
+
out.append(v)
|
|
63
|
+
break
|
|
64
|
+
if len(out) >= n:
|
|
65
|
+
break
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _candidate_entry(handle: str, ident: dict, ctx: dict) -> dict:
|
|
70
|
+
f = ident.get("facts") or {}
|
|
71
|
+
refs = f.get("refs") or {}
|
|
72
|
+
g = ident.get("gitem") or {}
|
|
73
|
+
e: dict[str, Any] = {"id": handle, "source": "both" if (ident.get("qid") and ident.get("gid")) else (
|
|
74
|
+
"wikidata" if ident.get("qid") else "google"), "label": _short(ident.get("label"), 80),
|
|
75
|
+
"desc": _short(ident.get("desc") or g.get("desc"), 160), "kinds": ident.get("kinds") or []}
|
|
76
|
+
if f:
|
|
77
|
+
cls = _ctx(ctx, refs.get("classes", []))
|
|
78
|
+
if cls:
|
|
79
|
+
e["classes"] = cls
|
|
80
|
+
iso = sorted({v for q in refs.get("country", []) for v in (ctx.get(q) or {}).get("iso") or []})
|
|
81
|
+
if iso:
|
|
82
|
+
e["country"] = iso
|
|
83
|
+
places = _ctx(ctx, refs.get("admin", []) + refs.get("location", []), "labels")
|
|
84
|
+
if places:
|
|
85
|
+
e["places"] = places
|
|
86
|
+
for bucket in ("occupation", "creator", "organizer"):
|
|
87
|
+
vals = _ctx(ctx, refs.get(bucket, []), "labels")
|
|
88
|
+
if vals:
|
|
89
|
+
e[bucket] = vals
|
|
90
|
+
dates = []
|
|
91
|
+
for pid in ("P585", "P580", "P582", "P577", "P571"):
|
|
92
|
+
for d in (f.get("dates") or {}).get(pid, [])[:1]:
|
|
93
|
+
dates.append(f"{pid}:{d['y']}" + (f"-{d['m']:02d}" if d.get("m") and d.get("p", 0) >= 10 else ""))
|
|
94
|
+
if dates:
|
|
95
|
+
e["dates"] = dates[:3]
|
|
96
|
+
hosts = [u["host"] for u in (parse_url(w) for w in f.get("websites") or []) if u]
|
|
97
|
+
if hosts:
|
|
98
|
+
e["website_hosts"] = hosts[:2]
|
|
99
|
+
if g:
|
|
100
|
+
if g.get("types"):
|
|
101
|
+
e["google_types"] = g["types"][:3]
|
|
102
|
+
gh = parse_url(g.get("website"))
|
|
103
|
+
if gh:
|
|
104
|
+
e["google_website_host"] = gh["host"]
|
|
105
|
+
if (ident.get("info") or {}).get("distance_km") is not None:
|
|
106
|
+
e["distance_km"] = ident["info"]["distance_km"]
|
|
107
|
+
e["evidence"] = sorted(c for c in ident["codes"] if c in EVIDENCE)
|
|
108
|
+
e["conflicts"] = sorted(c for c in ident["conflicts"] if c in CONFLICTS)
|
|
109
|
+
return e
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def build_packet(inp: EntityInput, decided: dict, ctx: dict, per_provider: int = NORMAL_PER_PROVIDER
|
|
113
|
+
) -> tuple[dict, dict[str, dict]]:
|
|
114
|
+
"""Packet for one entity plus {handle: {"qid", "gid"}} of the observed ids behind each handle."""
|
|
115
|
+
n = max(1, min(MAX_PER_PROVIDER, per_provider))
|
|
116
|
+
live = [i for i in decided["live"]]
|
|
117
|
+
wd = [i for i in live if i.get("qid")][:n]
|
|
118
|
+
gonly = [i for i in live if not i.get("qid")][:n]
|
|
119
|
+
handles: dict[str, dict] = {}
|
|
120
|
+
cands = []
|
|
121
|
+
for k, ident in enumerate(wd + gonly, start=1):
|
|
122
|
+
h = f"C{k}"
|
|
123
|
+
handles[h] = {"qid": ident.get("qid"), "gid": ident.get("gid")}
|
|
124
|
+
cands.append(_candidate_entry(h, ident, ctx))
|
|
125
|
+
entity = inp.compact()
|
|
126
|
+
entity.pop("source_id", None)
|
|
127
|
+
for k in ("existing_id_trusted", "official_url_reviewed"):
|
|
128
|
+
entity.pop(k, None)
|
|
129
|
+
packet = {"policy": RESOLVER_POLICY_VERSION, "entity": entity, "candidates": cands,
|
|
130
|
+
"deterministic": {"decision": decided["decision"], "reasons": decided["reasons"]}}
|
|
131
|
+
return packet, handles
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def packet_hash(packet: dict) -> str:
|
|
135
|
+
return "pk1:" + hashlib.sha256(json.dumps(packet, sort_keys=True, ensure_ascii=False,
|
|
136
|
+
separators=(",", ":")).encode()).hexdigest()[:32]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def output_schema(handles: list[str]) -> dict:
|
|
140
|
+
return {
|
|
141
|
+
"type": "object", "additionalProperties": False,
|
|
142
|
+
"required": ["decision", "candidate", "reason_codes"],
|
|
143
|
+
"properties": {
|
|
144
|
+
"decision": {"type": "string", "enum": list(ARB_DECISIONS)},
|
|
145
|
+
"candidate": {"anyOf": [{"type": "string", "enum": handles}, {"type": "null"}]},
|
|
146
|
+
"reason_codes": {"type": "array", "maxItems": 4, "items": {"type": "string", "enum": list(ARB_REASONS)}},
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def validate_output(obj: Any, handles: dict[str, dict]) -> tuple[dict | None, str | None]:
|
|
152
|
+
if not isinstance(obj, dict) or set(obj) - {"decision", "candidate", "reason_codes"}:
|
|
153
|
+
return None, "schema_violation"
|
|
154
|
+
d, c, r = obj.get("decision"), obj.get("candidate"), obj.get("reason_codes")
|
|
155
|
+
if d not in ARB_DECISIONS or not isinstance(r, list) or len(r) > 4 or any(x not in ARB_REASONS for x in r):
|
|
156
|
+
return None, "schema_violation"
|
|
157
|
+
if d == "MATCH":
|
|
158
|
+
if not isinstance(c, str) or c not in handles:
|
|
159
|
+
return None, "unknown_candidate"
|
|
160
|
+
elif c is not None and c not in handles:
|
|
161
|
+
return None, "unknown_candidate"
|
|
162
|
+
return {"decision": d, "candidate": c if d == "MATCH" else None, "reason_codes": sorted(set(r))}, None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def est_tokens(n_bytes: int) -> int:
|
|
166
|
+
"""Estimate only: UTF-8 bytes / 4 (no tokenizer is bundled)."""
|
|
167
|
+
return (n_bytes + 3) // 4
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class Arbiter:
|
|
171
|
+
"""One instance per run: it owns the call counter and the spent-cost total."""
|
|
172
|
+
|
|
173
|
+
def __init__(self, settings: Settings, credential: Credential | None = None,
|
|
174
|
+
transport: httpx2.AsyncBaseTransport | None = None, clock: Callable[[], float] = time.time,
|
|
175
|
+
cache_get: Callable[[str], dict | None] | None = None,
|
|
176
|
+
cache_put: Callable[[str, dict], None] | None = None):
|
|
177
|
+
self.settings = settings
|
|
178
|
+
self.credential = credential if credential is not None else load_credential(ARBITRATION_KEY_ENV,
|
|
179
|
+
settings.secrets_file)
|
|
180
|
+
register_secret(self.credential.value)
|
|
181
|
+
self._transport = transport
|
|
182
|
+
self._clock = clock
|
|
183
|
+
self._get = cache_get or (lambda k: None)
|
|
184
|
+
self._put = cache_put or (lambda k, v: None)
|
|
185
|
+
self.calls = 0
|
|
186
|
+
self.cost_usd = 0.0
|
|
187
|
+
self.tokens_in = 0
|
|
188
|
+
self.tokens_out = 0
|
|
189
|
+
self.cache_hits = 0
|
|
190
|
+
|
|
191
|
+
def availability(self) -> str | None:
|
|
192
|
+
"""None when a paid call is allowed at all, else the reason it is not."""
|
|
193
|
+
s = self.settings
|
|
194
|
+
if not s.arbitration_enabled:
|
|
195
|
+
return "disabled"
|
|
196
|
+
if not s.arbitration_model:
|
|
197
|
+
return "model_not_configured"
|
|
198
|
+
if not self.credential.present:
|
|
199
|
+
return "credential_missing"
|
|
200
|
+
if s.arbitration_price_in_per_mtok is None or s.arbitration_price_out_per_mtok is None:
|
|
201
|
+
return "price_unknown"
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
def _worst_cost(self, prompt_bytes: int) -> float:
|
|
205
|
+
s = self.settings
|
|
206
|
+
return (est_tokens(prompt_bytes) * s.arbitration_price_in_per_mtok +
|
|
207
|
+
s.arbitration_max_output_tokens * s.arbitration_price_out_per_mtok) / 1e6
|
|
208
|
+
|
|
209
|
+
def _key(self, phash: str) -> str:
|
|
210
|
+
return hashlib.sha256(f"{phash}|{self.settings.arbitration_model}|{RESOLVER_POLICY_VERSION}".encode()).hexdigest()
|
|
211
|
+
|
|
212
|
+
async def arbitrate(self, packet: dict, handles: dict[str, dict]) -> dict:
|
|
213
|
+
s = self.settings
|
|
214
|
+
phash = packet_hash(packet)
|
|
215
|
+
base = {"model": s.arbitration_model, "policy": RESOLVER_POLICY_VERSION, "packet_hash": phash,
|
|
216
|
+
"at": datetime.fromtimestamp(self._clock(), tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")}
|
|
217
|
+
if not handles:
|
|
218
|
+
return {**base, "status": "skipped", "error": "no_candidates"}
|
|
219
|
+
cached = self._get(self._key(phash))
|
|
220
|
+
if cached is not None:
|
|
221
|
+
self.cache_hits += 1
|
|
222
|
+
return {**cached, "cached": True}
|
|
223
|
+
why = self.availability()
|
|
224
|
+
if why:
|
|
225
|
+
return {**base, "status": "unavailable", "error": why}
|
|
226
|
+
messages = [{"role": "system", "content": SYSTEM_PROMPT},
|
|
227
|
+
{"role": "user", "content": json.dumps(packet, ensure_ascii=False, separators=(",", ":"))}]
|
|
228
|
+
body = {"model": s.arbitration_model, "messages": messages,
|
|
229
|
+
"max_completion_tokens": s.arbitration_max_output_tokens,
|
|
230
|
+
"response_format": {"type": "json_schema", "json_schema": {
|
|
231
|
+
"name": "entity_arbitration", "strict": True, "schema": output_schema(sorted(handles))}}}
|
|
232
|
+
prompt_bytes = len(json.dumps(messages, ensure_ascii=False).encode())
|
|
233
|
+
if self.calls >= s.arbitration_max_calls:
|
|
234
|
+
return {**base, "status": "budget", "error": "max_calls"}
|
|
235
|
+
worst = self._worst_cost(prompt_bytes)
|
|
236
|
+
if self.cost_usd + worst > s.arbitration_max_cost_usd:
|
|
237
|
+
return {**base, "status": "budget", "error": "max_cost"}
|
|
238
|
+
self.calls += 1
|
|
239
|
+
headers = {"Authorization": f"Bearer {self.credential.value}", "User-Agent": USER_AGENT,
|
|
240
|
+
"Content-Type": "application/json"}
|
|
241
|
+
try:
|
|
242
|
+
with anyio.fail_after(s.arbitration_timeout_s):
|
|
243
|
+
async with httpx2.AsyncClient(transport=self._transport,
|
|
244
|
+
timeout=httpx2.Timeout(s.arbitration_timeout_s)) as http:
|
|
245
|
+
resp = await http.post(s.arbitration_url, json=body, headers=headers)
|
|
246
|
+
except (TimeoutError, httpx2.TimeoutException):
|
|
247
|
+
self.cost_usd += worst # unknown usage: assume the worst case was billed
|
|
248
|
+
return {**base, "status": "failed", "error": "timeout", "cost_usd": round(worst, 6), "cost_basis": "worst_case"}
|
|
249
|
+
except httpx2.TransportError as exc:
|
|
250
|
+
return {**base, "status": "failed", "error": redact(short_error(exc))}
|
|
251
|
+
if resp.status_code != 200:
|
|
252
|
+
return {**base, "status": "failed", "error": f"http_{resp.status_code}"}
|
|
253
|
+
try:
|
|
254
|
+
data = resp.json()
|
|
255
|
+
usage = data.get("usage") or {}
|
|
256
|
+
msg = data["choices"][0]["message"]
|
|
257
|
+
content = msg.get("content")
|
|
258
|
+
obj = json.loads(content) if isinstance(content, str) else None
|
|
259
|
+
except (ValueError, KeyError, IndexError, TypeError):
|
|
260
|
+
data, usage, obj = {}, {}, None
|
|
261
|
+
tin, tout = usage.get("prompt_tokens"), usage.get("completion_tokens")
|
|
262
|
+
if isinstance(tin, int) and isinstance(tout, int):
|
|
263
|
+
cost = (tin * s.arbitration_price_in_per_mtok + tout * s.arbitration_price_out_per_mtok) / 1e6
|
|
264
|
+
basis = "measured_usage"
|
|
265
|
+
self.tokens_in += tin
|
|
266
|
+
self.tokens_out += tout
|
|
267
|
+
else:
|
|
268
|
+
cost, basis = worst, "worst_case"
|
|
269
|
+
self.cost_usd += cost
|
|
270
|
+
result, err = validate_output(obj, handles)
|
|
271
|
+
rec = {**base, "usage": {"prompt_tokens": tin, "completion_tokens": tout}, "cost_usd": round(cost, 6),
|
|
272
|
+
"cost_basis": basis}
|
|
273
|
+
if result is None:
|
|
274
|
+
return {**rec, "status": "failed", "error": err or "malformed"}
|
|
275
|
+
picked = handles.get(result["candidate"]) if result["candidate"] else None
|
|
276
|
+
out = {**rec, "status": "ok", **result, "selected": picked}
|
|
277
|
+
self._put(self._key(phash), out)
|
|
278
|
+
return out
|
|
279
|
+
|
|
280
|
+
def summary(self) -> dict:
|
|
281
|
+
return {"calls": self.calls, "cache_hits": self.cache_hits, "cost_usd": round(self.cost_usd, 6),
|
|
282
|
+
"tokens_in": self.tokens_in, "tokens_out": self.tokens_out,
|
|
283
|
+
"availability": self.availability() or "available", "model": self.settings.arbitration_model}
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""resolve-batch: streamed JSONL in, JSONL out, plus a receipt.
|
|
2
|
+
|
|
3
|
+
- Input is read line by line and resolved in chunks; memory is bounded by the
|
|
4
|
+
chunk size, not the corpus.
|
|
5
|
+
- Every row gets exactly one output record, in input order, even when another
|
|
6
|
+
row is invalid or a provider fails.
|
|
7
|
+
- The output file is the checkpoint. On restart, rows whose
|
|
8
|
+
(source_id, input_fingerprint) already have a record are skipped; rows that
|
|
9
|
+
ended in BUDGET_EXHAUSTED (always) or PROVIDER_ERROR (with retry_errors) are
|
|
10
|
+
removed from the output first and resolved again. A torn last line from a
|
|
11
|
+
crash is dropped.
|
|
12
|
+
- A provider budget running out stops the run after the current chunk.
|
|
13
|
+
- The receipt is recomputed from the complete output file, so it is the same
|
|
14
|
+
however many restarts produced that file.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import time
|
|
22
|
+
from collections import Counter
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Callable, Iterator
|
|
25
|
+
|
|
26
|
+
from .compact import dumps
|
|
27
|
+
from .evidence_export import unavailable
|
|
28
|
+
from .errors import InvalidRequest
|
|
29
|
+
from .normalize import richness
|
|
30
|
+
from .policy import DECISIONS, RESOLVER_POLICY_VERSION
|
|
31
|
+
from .resolver import Resolver, Row, invalid_record, make_row
|
|
32
|
+
|
|
33
|
+
RETRY_ALWAYS = {"BUDGET_EXHAUSTED"}
|
|
34
|
+
RETRY_ON_REQUEST = {"PROVIDER_ERROR", "ARBITRATION_FAILED", "ARBITRATION_BUDGET"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def evidence_path(out: Path) -> Path:
|
|
38
|
+
return out.with_name(out.name + ".evidence.jsonl")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _row_key(rec: dict) -> tuple:
|
|
42
|
+
return (rec.get("source_id"), rec.get("input_fingerprint"))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _read_input(path: Path) -> Iterator[tuple[int, object]]:
|
|
46
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
47
|
+
for i, line in enumerate(fh):
|
|
48
|
+
if not line.strip():
|
|
49
|
+
continue
|
|
50
|
+
try:
|
|
51
|
+
yield i, json.loads(line)
|
|
52
|
+
except ValueError:
|
|
53
|
+
yield i, {"__invalid_json__": True}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _prepare_output(out: Path, retry_errors: bool, policy: str, mode: str) -> tuple[Counter, bool]:
|
|
57
|
+
"""Drop a torn last line and rows that must be recomputed; return (completed keys,
|
|
58
|
+
whether a dropped row sat before a kept one, i.e. the file must be re-sorted at the end).
|
|
59
|
+
An output file holds one policy version and one provider mode only."""
|
|
60
|
+
done: Counter = Counter()
|
|
61
|
+
gap = reorder = False
|
|
62
|
+
if not out.exists():
|
|
63
|
+
return done, False
|
|
64
|
+
retry = RETRY_ALWAYS | (RETRY_ON_REQUEST if retry_errors else set())
|
|
65
|
+
keep: list[str] = []
|
|
66
|
+
changed = False
|
|
67
|
+
with out.open("r", encoding="utf-8") as fh:
|
|
68
|
+
for line in fh:
|
|
69
|
+
if not line.endswith("\n"):
|
|
70
|
+
changed = True # torn write from an interrupted run
|
|
71
|
+
break
|
|
72
|
+
try:
|
|
73
|
+
rec = json.loads(line)
|
|
74
|
+
except ValueError:
|
|
75
|
+
changed = True
|
|
76
|
+
continue
|
|
77
|
+
if rec.get("input_fingerprint") is not None and (
|
|
78
|
+
rec.get("resolver_policy_version") != policy or rec.get("providers_mode") not in (None, mode)):
|
|
79
|
+
raise InvalidRequest(f"{out.name} holds results of policy {rec.get('resolver_policy_version')} / "
|
|
80
|
+
f"mode {rec.get('providers_mode')}; write this run to a new --out file")
|
|
81
|
+
if set(rec.get("reasons") or []) & retry:
|
|
82
|
+
changed = gap = True
|
|
83
|
+
continue
|
|
84
|
+
reorder = reorder or gap
|
|
85
|
+
keep.append(line)
|
|
86
|
+
done[_row_key(rec)] += 1
|
|
87
|
+
if changed:
|
|
88
|
+
tmp = out.with_suffix(out.suffix + ".tmp")
|
|
89
|
+
with tmp.open("w", encoding="utf-8") as fh:
|
|
90
|
+
fh.writelines(keep)
|
|
91
|
+
fh.flush()
|
|
92
|
+
os.fsync(fh.fileno())
|
|
93
|
+
os.replace(tmp, out)
|
|
94
|
+
_sync_sidecar(out)
|
|
95
|
+
return done, reorder
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _sync_sidecar(out: Path) -> None:
|
|
99
|
+
"""Keep exactly one evidence line per kept output row (drops torn lines and the
|
|
100
|
+
evidence of rows that will be recomputed)."""
|
|
101
|
+
side = evidence_path(out)
|
|
102
|
+
if not side.exists():
|
|
103
|
+
return
|
|
104
|
+
wanted = Counter(json.loads(line).get("evidence_ref") for line in out.open("r", encoding="utf-8"))
|
|
105
|
+
keep: list[str] = []
|
|
106
|
+
with side.open("r", encoding="utf-8") as fh:
|
|
107
|
+
for line in fh:
|
|
108
|
+
if not line.endswith("\n"):
|
|
109
|
+
break
|
|
110
|
+
try:
|
|
111
|
+
ref = json.loads(line).get("evidence_ref")
|
|
112
|
+
except ValueError:
|
|
113
|
+
continue
|
|
114
|
+
if wanted[ref] > 0:
|
|
115
|
+
wanted[ref] -= 1
|
|
116
|
+
keep.append(line)
|
|
117
|
+
tmp = side.with_suffix(side.suffix + ".tmp")
|
|
118
|
+
with tmp.open("w", encoding="utf-8") as fh:
|
|
119
|
+
fh.writelines(keep)
|
|
120
|
+
fh.flush()
|
|
121
|
+
os.fsync(fh.fileno())
|
|
122
|
+
os.replace(tmp, side)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _input_key(i: int, raw: object) -> tuple:
|
|
126
|
+
if isinstance(raw, dict) and raw.get("__invalid_json__"):
|
|
127
|
+
return (f"line:{i + 1}", None)
|
|
128
|
+
row = make_row(i, raw)
|
|
129
|
+
return (row.source_id, row.fp)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _reorder_output(out: Path, in_path: Path) -> None:
|
|
133
|
+
_reorder_file(out, in_path, _row_key)
|
|
134
|
+
side = evidence_path(out)
|
|
135
|
+
if side.exists():
|
|
136
|
+
_reorder_file(side, in_path, lambda e: (e.get("source_id"), e.get("input_fingerprint")))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _reorder_file(out: Path, in_path: Path, keyfn) -> None:
|
|
140
|
+
"""Restore input order after retried rows were appended at the end. Keeps only
|
|
141
|
+
integer positions and byte offsets in memory, not the records."""
|
|
142
|
+
positions: dict[tuple, list[int]] = {}
|
|
143
|
+
for pos, (i, raw) in enumerate(_read_input(in_path)):
|
|
144
|
+
positions.setdefault(_input_key(i, raw), []).append(pos)
|
|
145
|
+
order: list[tuple[int, int, int]] = []
|
|
146
|
+
seen: Counter = Counter()
|
|
147
|
+
with out.open("rb") as fh:
|
|
148
|
+
while True:
|
|
149
|
+
off = fh.tell()
|
|
150
|
+
line = fh.readline()
|
|
151
|
+
if not line:
|
|
152
|
+
break
|
|
153
|
+
key = keyfn(json.loads(line))
|
|
154
|
+
plist = positions.get(key, [])
|
|
155
|
+
k = seen[key]
|
|
156
|
+
seen[key] += 1
|
|
157
|
+
order.append((plist[k] if k < len(plist) else 1 << 60, off, len(line)))
|
|
158
|
+
order.sort()
|
|
159
|
+
tmp = out.with_suffix(out.suffix + ".tmp")
|
|
160
|
+
with out.open("rb") as src, tmp.open("wb") as dst:
|
|
161
|
+
for _, off, n in order:
|
|
162
|
+
src.seek(off)
|
|
163
|
+
dst.write(src.read(n))
|
|
164
|
+
dst.flush()
|
|
165
|
+
os.fsync(dst.fileno())
|
|
166
|
+
os.replace(tmp, out)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def summarize(out: Path) -> dict:
|
|
170
|
+
"""Deterministic summary of a complete output file."""
|
|
171
|
+
dec: Counter = Counter()
|
|
172
|
+
kinds: Counter = Counter()
|
|
173
|
+
rich: Counter = Counter()
|
|
174
|
+
by_kind: dict[str, Counter] = {}
|
|
175
|
+
reasons: Counter = Counter()
|
|
176
|
+
calls: Counter = Counter()
|
|
177
|
+
views: Counter = Counter()
|
|
178
|
+
transitions: Counter = Counter()
|
|
179
|
+
n = llm_needed = llm_used = res_hits = prov_hits = minimal_google = google_called = 0
|
|
180
|
+
cost = 0.0
|
|
181
|
+
with out.open("r", encoding="utf-8") as fh:
|
|
182
|
+
for line in fh:
|
|
183
|
+
rec = json.loads(line)
|
|
184
|
+
n += 1
|
|
185
|
+
d = rec["decision"]
|
|
186
|
+
dec[d] += 1
|
|
187
|
+
k = rec.get("kind") or "none"
|
|
188
|
+
kinds[k] += 1
|
|
189
|
+
rich[rec.get("richness") or "invalid"] += 1
|
|
190
|
+
by_kind.setdefault(k, Counter())[d] += 1
|
|
191
|
+
for r in rec.get("reasons") or []:
|
|
192
|
+
reasons[r] += 1
|
|
193
|
+
for p, v in (rec.get("provider_calls") or {}).items():
|
|
194
|
+
calls[p] += v
|
|
195
|
+
llm_needed += bool(rec.get("llm_needed"))
|
|
196
|
+
llm_used += bool(rec.get("arbitration_used"))
|
|
197
|
+
cost += rec.get("arbitration_cost") or 0.0
|
|
198
|
+
res_hits += bool((rec.get("cache_hits") or {}).get("resolution"))
|
|
199
|
+
prov_hits += (rec.get("cache_hits") or {}).get("provider") or 0
|
|
200
|
+
google_called += bool(rec.get("google_called"))
|
|
201
|
+
v = rec.get("views")
|
|
202
|
+
if v:
|
|
203
|
+
views[("wikidata", v["wikidata"]["decision"])] += 1
|
|
204
|
+
views[("dual", v["dual"]["decision"])] += 1
|
|
205
|
+
transitions[f'{v["wikidata"]["decision"]}->{v["dual"]["decision"]}'] += 1
|
|
206
|
+
minimal_google += bool(v.get("minimal_would_call_google"))
|
|
207
|
+
out_bytes = out.stat().st_size if out.exists() else 0
|
|
208
|
+
summary = {
|
|
209
|
+
"policy": RESOLVER_POLICY_VERSION,
|
|
210
|
+
"rows": n,
|
|
211
|
+
"decisions": {d: dec.get(d, 0) for d in DECISIONS},
|
|
212
|
+
"decision_share": {d: round(dec.get(d, 0) / n, 4) if n else 0.0 for d in DECISIONS},
|
|
213
|
+
"kinds": dict(sorted(kinds.items())),
|
|
214
|
+
"richness": dict(sorted(rich.items())),
|
|
215
|
+
"decisions_by_kind": {k: dict(sorted(c.items())) for k, c in sorted(by_kind.items())},
|
|
216
|
+
"reasons": dict(sorted(reasons.items())),
|
|
217
|
+
"llm_needed": llm_needed,
|
|
218
|
+
"llm_used": llm_used,
|
|
219
|
+
"arbitration_cost_usd": round(cost, 6),
|
|
220
|
+
"dedicated_provider_calls": dict(sorted(calls.items())),
|
|
221
|
+
"rows_with_google_called": google_called,
|
|
222
|
+
"resolution_cache_hits": res_hits,
|
|
223
|
+
"provider_cache_hits": prov_hits,
|
|
224
|
+
"output_bytes": out_bytes,
|
|
225
|
+
}
|
|
226
|
+
if views:
|
|
227
|
+
summary["ablation"] = {
|
|
228
|
+
"wikidata_only": {d: views.get(("wikidata", d), 0) for d in DECISIONS},
|
|
229
|
+
"dual": {d: views.get(("dual", d), 0) for d in DECISIONS},
|
|
230
|
+
"transitions": dict(sorted(transitions.items())),
|
|
231
|
+
"minimal_would_call_google": minimal_google,
|
|
232
|
+
}
|
|
233
|
+
return summary
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
async def run_batch(resolver: Resolver, in_path: Path, out_path: Path, *, chunk: int = 25,
|
|
237
|
+
retry_errors: bool = False, limit: int | None = None,
|
|
238
|
+
clock: Callable[[], float] = time.monotonic) -> dict:
|
|
239
|
+
t0 = clock()
|
|
240
|
+
done, reorder = _prepare_output(out_path, retry_errors, RESOLVER_POLICY_VERSION, resolver.providers)
|
|
241
|
+
skipped = processed = 0
|
|
242
|
+
stop_reason = None
|
|
243
|
+
interrupted = False
|
|
244
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
245
|
+
fh = out_path.open("a", encoding="utf-8")
|
|
246
|
+
efh = evidence_path(out_path).open("a", encoding="utf-8")
|
|
247
|
+
pending: list[Row] = []
|
|
248
|
+
|
|
249
|
+
async def flush() -> str | None:
|
|
250
|
+
nonlocal processed
|
|
251
|
+
if not pending:
|
|
252
|
+
return None
|
|
253
|
+
try:
|
|
254
|
+
await resolver.resolve_rows(pending)
|
|
255
|
+
except Exception: # noqa: BLE001 - isolate: retry one by one
|
|
256
|
+
for r in pending:
|
|
257
|
+
if r.result is None and r.inp is not None:
|
|
258
|
+
try:
|
|
259
|
+
await resolver.resolve_rows([r])
|
|
260
|
+
except Exception as exc: # noqa: BLE001
|
|
261
|
+
r.error = f"internal_error:{type(exc).__name__}"
|
|
262
|
+
r.result = invalid_record(r)
|
|
263
|
+
r.result["reasons"] = ["PROVIDER_ERROR"]
|
|
264
|
+
r.result["warnings"] = [r.error]
|
|
265
|
+
reason = None
|
|
266
|
+
for r in pending:
|
|
267
|
+
ev = r.evidence or unavailable(r.result, "invalid_input" if r.inp is None else "evidence_not_built")
|
|
268
|
+
efh.write(json.dumps(ev, ensure_ascii=False, sort_keys=True) + "\n")
|
|
269
|
+
efh.flush()
|
|
270
|
+
os.fsync(efh.fileno())
|
|
271
|
+
for r in pending:
|
|
272
|
+
fh.write(dumps(r.result) + "\n")
|
|
273
|
+
processed += 1
|
|
274
|
+
if "BUDGET_EXHAUSTED" in (r.result.get("reasons") or []):
|
|
275
|
+
reason = "budget_exhausted"
|
|
276
|
+
fh.flush()
|
|
277
|
+
os.fsync(fh.fileno())
|
|
278
|
+
pending.clear()
|
|
279
|
+
return reason
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
for i, raw in _read_input(in_path):
|
|
283
|
+
if limit is not None and processed + len(pending) + skipped >= limit:
|
|
284
|
+
break
|
|
285
|
+
if isinstance(raw, dict) and raw.get("__invalid_json__"):
|
|
286
|
+
row = Row(idx=i, source_id=f"line:{i + 1}", error="invalid JSON")
|
|
287
|
+
row.result = invalid_record(row)
|
|
288
|
+
else:
|
|
289
|
+
row = make_row(i, raw)
|
|
290
|
+
key = (row.source_id, row.fp)
|
|
291
|
+
if done[key] > 0:
|
|
292
|
+
done[key] -= 1
|
|
293
|
+
skipped += 1
|
|
294
|
+
continue
|
|
295
|
+
pending.append(row)
|
|
296
|
+
if len(pending) >= chunk:
|
|
297
|
+
stop_reason = await flush()
|
|
298
|
+
if stop_reason:
|
|
299
|
+
break
|
|
300
|
+
if not stop_reason:
|
|
301
|
+
stop_reason = await flush()
|
|
302
|
+
except KeyboardInterrupt:
|
|
303
|
+
interrupted = True # rows already written stay; the open chunk is redone on restart
|
|
304
|
+
stop_reason = "interrupted"
|
|
305
|
+
finally:
|
|
306
|
+
fh.close()
|
|
307
|
+
efh.close()
|
|
308
|
+
if reorder and not interrupted:
|
|
309
|
+
_reorder_output(out_path, in_path)
|
|
310
|
+
receipt = {
|
|
311
|
+
"tool": "wdkg", "op": "resolve-batch", "policy": RESOLVER_POLICY_VERSION,
|
|
312
|
+
"input": str(in_path), "output": str(out_path), "evidence": str(evidence_path(out_path)),
|
|
313
|
+
"mode": {"providers": resolver.providers, "llm": resolver.llm, "search": resolver.search_strategy,
|
|
314
|
+
"candidates": resolver.k, "chunk": chunk},
|
|
315
|
+
"complete": stop_reason is None and not interrupted,
|
|
316
|
+
"next": None if stop_reason is None else "re-run the same command to resume (budget-stopped rows are redone)",
|
|
317
|
+
"stop_reason": stop_reason,
|
|
318
|
+
"session": {
|
|
319
|
+
"processed": processed, "skipped_already_done": skipped,
|
|
320
|
+
"duration_s": round(clock() - t0, 3),
|
|
321
|
+
"upstream_requests": dict(resolver.budget.used),
|
|
322
|
+
"request_caps": dict(getattr(resolver.budget, "limits", {})),
|
|
323
|
+
"raw_bytes": dict(resolver.budget.raw_bytes),
|
|
324
|
+
"cache_hits": {k: v for k, v in resolver.stats.items()},
|
|
325
|
+
"arbitration": resolver.arbiter.summary(),
|
|
326
|
+
},
|
|
327
|
+
"summary": summarize(out_path),
|
|
328
|
+
}
|
|
329
|
+
return receipt
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def dry_run(resolver: Resolver, in_path: Path, limit: int | None = None) -> dict:
|
|
333
|
+
"""Parse, normalise and check the resolution cache. No network, no output."""
|
|
334
|
+
n = invalid = cached = 0
|
|
335
|
+
kinds: Counter = Counter()
|
|
336
|
+
rich: Counter = Counter()
|
|
337
|
+
with_url = with_ids = 0
|
|
338
|
+
for i, raw in _read_input(in_path):
|
|
339
|
+
if limit is not None and n >= limit:
|
|
340
|
+
break
|
|
341
|
+
n += 1
|
|
342
|
+
row = make_row(i, raw) if not (isinstance(raw, dict) and raw.get("__invalid_json__")) else None
|
|
343
|
+
if row is None or row.inp is None:
|
|
344
|
+
invalid += 1
|
|
345
|
+
continue
|
|
346
|
+
kinds[row.inp.kind or "none"] += 1
|
|
347
|
+
rich[richness(row.inp)] += 1
|
|
348
|
+
with_url += row.inp.url is not None
|
|
349
|
+
with_ids += bool(row.inp.existing_wikidata_qid or row.inp.existing_google_kg_id)
|
|
350
|
+
rec = resolver.store.get_resolution(resolver._res_key(row.fp)) if resolver.use_cache else None
|
|
351
|
+
if rec is not None and (rec.get("evidence") or not resolver.require_evidence) and \
|
|
352
|
+
resolver._refs_fresh(rec.get("refs") or {}):
|
|
353
|
+
cached += 1
|
|
354
|
+
todo = n - invalid - cached
|
|
355
|
+
k = resolver.k
|
|
356
|
+
est = {
|
|
357
|
+
"wikidata_label_batches": -(-todo // 60),
|
|
358
|
+
"wikidata_wbsearch_upper_bound": todo if resolver.search_strategy != "label" else 0,
|
|
359
|
+
"wikidata_entity_batches_upper_bound": -(-(todo * k) // 50),
|
|
360
|
+
"wikidata_context_batches_estimate": -(-(todo * k * 6) // 150),
|
|
361
|
+
"google_upper_bound": (todo + -(-(todo * k) // 20)) if resolver.providers == "dual" else with_url,
|
|
362
|
+
}
|
|
363
|
+
return {"op": "resolve-batch", "dry_run": True, "policy": RESOLVER_POLICY_VERSION, "rows": n,
|
|
364
|
+
"invalid": invalid, "resolution_cache_hits": cached, "to_resolve": todo,
|
|
365
|
+
"kinds": dict(sorted(kinds.items())), "richness": dict(sorted(rich.items())),
|
|
366
|
+
"with_official_url": with_url, "with_existing_ids": with_ids,
|
|
367
|
+
"estimated_requests": est, "note": "estimates are upper bounds before any cache reuse"}
|