@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.
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/packages/memory-engine-v2/RFC-fusion-drive.md +15 -9
- package/packages/memory-engine-v2/extractor-async/source_time.py +63 -0
- package/packages/memory-engine-v2/extractor-async/test_source_time.py +102 -0
- package/packages/memory-engine-v2/extractor-async/worker.py +89 -16
- package/packages/memory-engine-v2/extractor-sync/Dockerfile +3 -1
- package/packages/memory-engine-v2/extractor-sync/confidence.py +99 -0
- package/packages/memory-engine-v2/extractor-sync/server.py +61 -11
- package/packages/memory-engine-v2/extractor-sync/source_time.py +63 -0
- package/packages/memory-engine-v2/extractor-sync/test_confidence_parity.py +18 -0
- package/packages/memory-engine-v2/extractor-sync/test_paired_extraction.py +2 -2
- package/packages/memory-engine-v2/fusion_drive/adjudicate.py +85 -0
- package/packages/memory-engine-v2/fusion_drive/test_adjudicate.py +65 -0
- package/packages/memory-engine-v2/scripts/fusion_drive_decay.py +24 -5
- package/packages/memory-engine-v2/scripts/fusion_drive_fuse.py +197 -27
|
@@ -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
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
239
|
-
print(f" MERGE →
|
|
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
|
-
|
|
247
|
-
|
|
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(
|
|
254
|
-
)
|
|
422
|
+
proposals, merged + llm_fact_merges, json.dumps(detail)))
|
|
255
423
|
conn.commit()
|
|
256
424
|
|
|
257
|
-
label = "APPLY (
|
|
258
|
-
print(f"[fusion-drive:fuse] {label} arena={args.arena}: {proposals} proposal(s),
|
|
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
|
|