@pentatonic-ai/ai-agent-sdk 0.10.8 → 0.10.9

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.
@@ -37,14 +37,35 @@ import uuid
37
37
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "fusion_drive"))
38
38
  import canonical as C # noqa: E402
39
39
  from merge import build_entity_merge_plan, build_fact_merge_plan # noqa: E402
40
+ from adjudicate import adjudicate_entities, adjudicate_facts # noqa: E402
40
41
 
41
42
  try:
43
+ import httpx
42
44
  import psycopg
43
45
  from psycopg.rows import dict_row
44
46
  except ModuleNotFoundError:
45
- print("psycopg required", file=sys.stderr)
47
+ print("psycopg (+ httpx for --llm-endpoint) required", file=sys.stderr)
46
48
  raise
47
49
 
50
+ # Current distiller served-model — the in-VPC LLM used for adjudication
51
+ # (no egress; same model that extracted this content). Override with --model.
52
+ DEFAULT_MODEL = "qwen3.6-27b-fp8"
53
+
54
+
55
+ def _distiller_post_fn(endpoint: str, model: str):
56
+ """Build a post_fn(messages)->str hitting the distiller's OpenAI
57
+ /v1/chat/completions (temperature 0, thinking off — same shape the worker
58
+ uses). In-VPC: memory content never leaves the network. Raises on failure
59
+ so the adjudicator fails closed (treats it as 'unsure', never merges)."""
60
+ def post(messages):
61
+ r = httpx.post(endpoint, timeout=60, json={
62
+ "model": model, "messages": messages, "temperature": 0.0,
63
+ "max_tokens": 120, "chat_template_kwargs": {"enable_thinking": False},
64
+ })
65
+ r.raise_for_status()
66
+ return r.json()["choices"][0]["message"]["content"]
67
+ return post
68
+
48
69
 
49
70
  def _norm(s: str) -> str:
50
71
  return " ".join(s.lower().split())
@@ -101,18 +122,52 @@ def _entity_dup_sets(cur, arena: str) -> list[list[dict]]:
101
122
  return exact + cross
102
123
 
103
124
 
104
- def _candidates(group: list[dict]) -> list[C.CanonicalCandidate]:
125
+ def _authority_signals(cur, arena: str, entity_ids: list[str], current_model: str) -> dict:
126
+ """Batch-resolve the canonical-scoring authority signals (#3) for a set of
127
+ entities, from data that actually exists:
128
+ - grounded: the entity's canonical_name appears verbatim in the content
129
+ of at least one of its provenance events (not a hallucinated name).
130
+ - from_current_teacher: at least one provenance event was distilled by
131
+ the CURRENT teacher (distillation_traces.llm_model = current_model) —
132
+ prefer the newer teacher's rendering over a superseded one.
133
+ (in_directory is left False — there's no authoritative directory/contacts
134
+ table in the schema yet; that's a separate data-source decision, noted in
135
+ the RFC. The scorer already supports it for when one lands.)
136
+ Returns {entity_id: {"grounded": bool, "from_current_teacher": bool}}."""
137
+ out = {eid: {"grounded": False, "from_current_teacher": False} for eid in entity_ids}
138
+ if not entity_ids:
139
+ return out
140
+ cur.execute(
141
+ """SELECT e.id, e.canonical_name,
142
+ EXISTS (SELECT 1 FROM events ev
143
+ WHERE ev.id = ANY(e.provenance_event_ids)
144
+ AND position(e.canonical_name in ev.content) > 0) AS grounded,
145
+ EXISTS (SELECT 1 FROM distillation_traces t
146
+ WHERE t.event_id = ANY(e.provenance_event_ids)
147
+ AND t.llm_model = %s) AS cur_teacher
148
+ FROM entities e WHERE e.arena = %s AND e.id = ANY(%s)""",
149
+ (current_model, arena, entity_ids),
150
+ )
151
+ for eid, name, grounded, cur_teacher in cur.fetchall():
152
+ # A numeric-ID-as-person name (e.g. "1716801984") substring-matches any
153
+ # stray digit-run in content (epochs, order/invoice numbers) → would
154
+ # falsely mark junk "grounded" and BOOST its authority. Never credit a
155
+ # looks-like-id name as grounded. (#96 review §2)
156
+ grounded = bool(grounded) and not C.looks_like_id(name)
157
+ out[eid] = {"grounded": grounded, "from_current_teacher": bool(cur_teacher)}
158
+ return out
159
+
160
+
161
+ def _candidates(group: list[dict], signals: dict | None = None) -> list[C.CanonicalCandidate]:
162
+ signals = signals or {}
105
163
  return [
106
164
  C.CanonicalCandidate(
107
165
  entity_id=e["id"],
108
166
  canonical_name=e["canonical_name"],
109
167
  n_provenance=len(e["provenance_event_ids"] or []),
110
168
  aliases=e["aliases"] or [],
111
- # in_directory / grounded / from_current_teacher would be resolved
112
- # from an authority table + provenance content + trace llm_model;
113
- # left False here (no-LLM tier) so scoring leans on grounding-by-
114
- # corroboration + the ID/bare penalties. Wire authority in a
115
- # follow-up — the scoring already supports it.
169
+ grounded=signals.get(e["id"], {}).get("grounded", False),
170
+ from_current_teacher=signals.get(e["id"], {}).get("from_current_teacher", False),
116
171
  )
117
172
  for e in group
118
173
  ]
@@ -209,53 +264,168 @@ def _dedup_master_facts(cur, arena: str, master_id: str) -> int:
209
264
  return deduped
210
265
 
211
266
 
267
+ def _ambiguous_cross_run(cur, arena: str) -> list[dict]:
268
+ """Co-occurrence groups the deterministic tier PUNTED on: a junk-leaning
269
+ node sharing an event with MULTIPLE non-junk candidates (so which real
270
+ entity it belongs to is ambiguous). Returns [{junk, candidates:[...]}] for
271
+ the LLM tier to adjudicate. Restricted disclosure excluded."""
272
+ cur.execute(
273
+ """SELECT id, entity_type, canonical_name, aliases, provenance_event_ids
274
+ FROM entities WHERE arena = %s AND disclosure_class <> 'restricted'""",
275
+ (arena,),
276
+ )
277
+ ents = cur.fetchall()
278
+ by_event_type: dict[tuple, list[dict]] = {}
279
+ for e in ents:
280
+ for ev in (e["provenance_event_ids"] or []):
281
+ by_event_type.setdefault((e["entity_type"], ev), []).append(e)
282
+ out, seen = [], set()
283
+ for members in by_event_type.values():
284
+ junk = [m for m in members if C.looks_like_id(m["canonical_name"])]
285
+ non_junk = [m for m in members if not C.looks_like_id(m["canonical_name"])]
286
+ if not junk or len(non_junk) < 2:
287
+ continue
288
+ for j in junk:
289
+ key = (j["id"], tuple(sorted(c["id"] for c in non_junk)))
290
+ if key in seen:
291
+ continue
292
+ seen.add(key)
293
+ out.append({"junk": j, "candidates": non_junk})
294
+ return out
295
+
296
+
297
+ def _entity_context(cur, arena: str, eid: str) -> list[str]:
298
+ cur.execute(
299
+ "SELECT statement FROM facts WHERE arena=%s AND (subject_entity_id=%s OR object_entity_id=%s) LIMIT 5",
300
+ (arena, eid, eid))
301
+ return [r["statement"] for r in cur.fetchall()]
302
+
303
+
304
+ def _semantic_fact_groups(cur, arena: str) -> list[list[dict]]:
305
+ """Facts sharing (subject_entity_id, predicate) but with DIFFERENT
306
+ statements — candidate same-assertion-different-words pairs for LLM
307
+ adjudication. Exact-triple dupes already collapse elsewhere; this is the
308
+ semantic tier. Restricted disclosure excluded.
309
+
310
+ RECALL SCOPE (#96 review §5): grouping on (subject, predicate) catches only
311
+ same-predicate wording variants ("decided X" vs "decided X, confirmed").
312
+ CROSS-predicate synonyms ("joined Acme" / "works at Acme") are NOT grouped
313
+ here — that needs a predicate-synonym map or subject-level pairwise
314
+ adjudication (O(n²), deferred). adjudicate_facts() itself handles any pair;
315
+ it's the candidate generation that's intentionally narrow to bound LLM calls."""
316
+ cur.execute(
317
+ """SELECT id, subject_entity_id, predicate, object_entity_id, statement,
318
+ confidence, provenance_event_ids
319
+ FROM facts WHERE arena=%s AND disclosure_class <> 'restricted'
320
+ AND subject_entity_id IS NOT NULL""",
321
+ (arena,))
322
+ groups: dict[tuple, list[dict]] = {}
323
+ for f in cur.fetchall():
324
+ groups.setdefault((f["subject_entity_id"], f["predicate"]), []).append(f)
325
+ return [g for g in groups.values()
326
+ if len({_norm(x["statement"]) for x in g}) > 1] # >1 distinct statement
327
+
328
+
212
329
  def main() -> int:
213
330
  ap = argparse.ArgumentParser()
214
331
  ap.add_argument("--arena", required=True)
215
332
  ap.add_argument("--pg-dsn", default=os.environ.get("PG_DSN"))
216
333
  ap.add_argument("--apply", action="store_true", help="execute merges (default: dry-run)")
334
+ ap.add_argument("--llm-endpoint", default=os.environ.get("PME_V2_LLM_ENDPOINT"),
335
+ help="in-VPC distiller /v1/chat/completions for adjudication "
336
+ "(no egress). Omit to skip the LLM tier (deterministic only).")
337
+ ap.add_argument("--model", default=DEFAULT_MODEL)
217
338
  args = ap.parse_args()
218
339
  if not args.pg_dsn:
219
340
  print("PG_DSN required", file=sys.stderr)
220
341
  return 2
342
+ post_fn = _distiller_post_fn(args.llm_endpoint, args.model) if args.llm_endpoint else None
221
343
 
222
- proposals = 0
223
- merged = 0
344
+ proposals = merged = llm_entity_merges = llm_fact_merges = 0
224
345
  with psycopg.connect(args.pg_dsn, row_factory=dict_row) as conn:
225
346
  with conn.cursor() as cur:
226
- groups = _entity_dup_sets(cur, args.arena)
227
- for group in groups:
228
- master_c, losers_c = C.pick_master(_candidates(group))
347
+ def do_merge(group, signal_note=""):
348
+ nonlocal proposals, merged
349
+ sig = _authority_signals(cur, args.arena, [e["id"] for e in group], args.model)
350
+ master_c, losers_c = C.pick_master(_candidates(group, sig))
229
351
  loser_ids = [l.entity_id for l in losers_c]
230
352
  if not loser_ids:
231
- continue
353
+ return 0
232
354
  proposals += 1
233
355
  by_id = {e["id"]: e for e in group}
234
- master = by_id[master_c.entity_id]
235
- losers = [by_id[i] for i in loser_ids]
356
+ master, losers = by_id[master_c.entity_id], [by_id[i] for i in loser_ids]
236
357
  facts, rels = _touching(cur, args.arena, loser_ids)
237
- plan = build_entity_merge_plan(
238
- arena=args.arena, master=master, losers=losers, facts=facts, relationships=rels)
239
- print(f" MERGE → master '{master['canonical_name']}' ({master['id']}) "
240
- f"absorbs {[l['canonical_name'] for l in losers]} "
241
- f"[facts:{len(plan.fact_subject_repoints)+len(plan.fact_object_repoints)} "
242
- f"rels:{len(plan.rel_endpoint_repoints)} collisions:{len(plan.rel_collisions)}]")
358
+ plan = build_entity_merge_plan(arena=args.arena, master=master, losers=losers,
359
+ facts=facts, relationships=rels)
360
+ print(f" MERGE{signal_note} → '{master['canonical_name']}' ({master['id']}) "
361
+ f"absorbs {[l['canonical_name'] for l in losers]}")
243
362
  if args.apply:
244
363
  _execute_entity_plan(cur, plan)
245
364
  _dedup_master_facts(cur, args.arena, master["id"])
246
- merged += len(loser_ids)
247
- conn.commit() # per-merge: a bad merge can't roll back the good ones, and locks stay short
365
+ conn.commit()
366
+ merged += len(loser_ids)
367
+ return len(loser_ids)
368
+
369
+ # Tier 1 — deterministic (exact-name + unambiguous cross-run)
370
+ for group in _entity_dup_sets(cur, args.arena):
371
+ do_merge(group)
372
+
373
+ # Tier 2 — LLM adjudication via the in-VPC distiller (no egress)
374
+ if post_fn:
375
+ # 2a. ambiguous cross-run: which real entity does the junk match?
376
+ for amb in _ambiguous_cross_run(cur, args.arena):
377
+ j = amb["junk"]
378
+ jctx = _entity_context(cur, args.arena, j["id"])
379
+ for cand in amb["candidates"]:
380
+ v = adjudicate_entities(
381
+ {**j, "context": jctx},
382
+ {**cand, "context": _entity_context(cur, args.arena, cand["id"])},
383
+ post_fn)
384
+ if v["same"]:
385
+ print(f" [llm:{v['reason'][:40]}]", end="")
386
+ llm_entity_merges += do_merge([cand, j], signal_note=" (llm)")
387
+ break
388
+ # 2b. semantic fact fusion: same assertion, different words?
389
+ for fg in _semantic_fact_groups(cur, args.arena):
390
+ fg_sorted = sorted(fg, key=lambda f: (f.get("confidence", 0), f["id"]), reverse=True)
391
+ keep = fg_sorted[0]
392
+ same = [keep]
393
+ for other in fg_sorted[1:]:
394
+ if adjudicate_facts(keep["statement"], other["statement"], post_fn)["same"]:
395
+ same.append(other)
396
+ if len(same) > 1:
397
+ plan = build_fact_merge_plan(arena=args.arena, dup_facts=same)
398
+ print(f" FACT-MERGE (llm) → '{keep['statement'][:50]}' absorbs {len(same)-1}")
399
+ if args.apply and plan:
400
+ cur.execute("UPDATE facts SET provenance_event_ids=%s WHERE id=%s",
401
+ (plan["master_provenance"], plan["master_id"]))
402
+ for a in plan["audit_rows"]:
403
+ cur.execute(
404
+ """INSERT INTO fact_merges (id, arena, canonical_id, deprecated_id,
405
+ deprecated_statement, merge_signal, provenance_unioned, rollback_payload)
406
+ VALUES (%s,%s,%s,%s,%s,'llm_adjudication',%s,%s::jsonb)""",
407
+ ("fm_" + uuid.uuid4().hex[:20], a["arena"], a["canonical_id"],
408
+ a["deprecated_id"], a["deprecated_statement"], a["provenance_unioned"],
409
+ json.dumps(a["rollback_payload"], default=str)))
410
+ cur.execute("DELETE FROM facts WHERE id = ANY(%s)", (plan["deprecated_ids"],))
411
+ conn.commit()
412
+ llm_fact_merges += len(same) - 1
413
+
248
414
  run_id = "fdr_" + uuid.uuid4().hex[:20]
415
+ detail = {"proposals": proposals, "merged": merged,
416
+ "llm_entity_merges": llm_entity_merges, "llm_fact_merges": llm_fact_merges,
417
+ "llm_tier": bool(post_fn)}
249
418
  cur.execute(
250
419
  """INSERT INTO fusion_drive_runs (id, arena, pass_kind, mode, scanned, changed, detail, finished_at)
251
420
  VALUES (%s,%s,'fusion',%s,%s,%s,%s::jsonb,NOW())""",
252
421
  (run_id, args.arena, "apply" if args.apply else "dry_run",
253
- proposals, merged, json.dumps({"proposals": proposals, "merged": merged})),
254
- )
422
+ proposals, merged + llm_fact_merges, json.dumps(detail)))
255
423
  conn.commit()
256
424
 
257
- label = "APPLY (merged, reversible via entity_merges)" if args.apply else "DRY-RUN"
258
- print(f"[fusion-drive:fuse] {label} arena={args.arena}: {proposals} proposal(s), {merged} entities merged")
425
+ label = "APPLY (reversible via entity_merges/fact_merges)" if args.apply else "DRY-RUN"
426
+ print(f"[fusion-drive:fuse] {label} arena={args.arena}: {proposals} entity proposal(s), "
427
+ f"{merged} entities merged ({llm_entity_merges} via llm), {llm_fact_merges} facts merged via llm. "
428
+ f"LLM tier: {'on (distiller)' if post_fn else 'off'}")
259
429
  print(f" ledger: {run_id}")
260
430
  return 0
261
431