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.
Files changed (31) hide show
  1. wikidata_google_knowledge_mcp/__init__.py +1 -0
  2. wikidata_google_knowledge_mcp/arbitration.py +283 -0
  3. wikidata_google_knowledge_mcp/batch.py +367 -0
  4. wikidata_google_knowledge_mcp/budget.py +46 -0
  5. wikidata_google_knowledge_mcp/cache.py +157 -0
  6. wikidata_google_knowledge_mcp/cli.py +353 -0
  7. wikidata_google_knowledge_mcp/compact.py +114 -0
  8. wikidata_google_knowledge_mcp/config.py +174 -0
  9. wikidata_google_knowledge_mcp/errors.py +40 -0
  10. wikidata_google_knowledge_mcp/evidence.py +169 -0
  11. wikidata_google_knowledge_mcp/evidence_export.py +508 -0
  12. wikidata_google_knowledge_mcp/google_kg.py +165 -0
  13. wikidata_google_knowledge_mcp/kinds.py +166 -0
  14. wikidata_google_knowledge_mcp/mcp_server.py +154 -0
  15. wikidata_google_knowledge_mcp/normalize.py +356 -0
  16. wikidata_google_knowledge_mcp/policy.py +253 -0
  17. wikidata_google_knowledge_mcp/reconcile.py +456 -0
  18. wikidata_google_knowledge_mcp/redact.py +40 -0
  19. wikidata_google_knowledge_mcp/resolver.py +808 -0
  20. wikidata_google_knowledge_mcp/secrets.py +90 -0
  21. wikidata_google_knowledge_mcp/service.py +535 -0
  22. wikidata_google_knowledge_mcp/sparql_guard.py +87 -0
  23. wikidata_google_knowledge_mcp/store.py +103 -0
  24. wikidata_google_knowledge_mcp/wikidata.py +275 -0
  25. wikidata_google_knowledge_mcp/wikidata_bulk.py +387 -0
  26. wikidata_google_knowledge_mcp/wikidata_parse.py +180 -0
  27. wikidata_google_knowledge_mcp-0.2.0.dist-info/METADATA +515 -0
  28. wikidata_google_knowledge_mcp-0.2.0.dist-info/RECORD +31 -0
  29. wikidata_google_knowledge_mcp-0.2.0.dist-info/WHEEL +4 -0
  30. wikidata_google_knowledge_mcp-0.2.0.dist-info/entry_points.txt +3 -0
  31. 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"}