@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.
@@ -27,10 +27,14 @@ import os
27
27
  import re
28
28
  import time
29
29
  from contextlib import asynccontextmanager
30
+ from datetime import datetime # noqa: F401 (used in type hints)
30
31
  from typing import Any
31
32
 
32
33
  # Canonical entity-ID scheme — byte-identical copy in extractor-async (entity_id.py).
34
+ from confidence import born_salience
33
35
  from entity_id import entity_id, normalize_surface_form # noqa: F401
36
+ # Source-time parsing — byte-identical copy in extractor-async (source_time.py).
37
+ from source_time import event_source_time
34
38
 
35
39
  import psycopg
36
40
  import psycopg.rows
@@ -394,17 +398,27 @@ RULES = {
394
398
 
395
399
  async def _upsert_event(cur: psycopg.AsyncCursor, req: ExtractRequest,
396
400
  event_id: str, content_hash: str) -> None:
397
- """ON CONFLICT DO NOTHING — re-emitting the same event is a no-op."""
401
+ """ON CONFLICT DO NOTHING — re-emitting the same event is a no-op.
402
+
403
+ `emitted_at` is the SOURCE time of the content (when the
404
+ email/meeting/message actually happened), parsed from
405
+ `attributes.timestamp`; `received_at` keeps its NOW() default and
406
+ means ingest time — exactly the split the schema comment at
407
+ 001_init.sql:112 promises. When the source time is absent or
408
+ unparseable we fall back to NOW() via COALESCE (never NULL a
409
+ NOT NULL column)."""
410
+ emitted_at = event_source_time({"attributes": req.attributes})
398
411
  await cur.execute(
399
412
  """
400
413
  INSERT INTO events (
401
414
  id, arena, client_id, user_id, event_type, source_kind,
402
415
  source_id, content, content_hash, participant_set,
403
- participant_kind, disclosure_class, attributes
416
+ participant_kind, disclosure_class, attributes, emitted_at
404
417
  ) VALUES (
405
418
  %s, %s, %s, %s, %s, %s::source_kind,
406
419
  %s, %s, %s, %s,
407
- %s::participant_kind, %s::disclosure_class, %s::jsonb
420
+ %s::participant_kind, %s::disclosure_class, %s::jsonb,
421
+ COALESCE(%s, NOW())
408
422
  )
409
423
  ON CONFLICT (id) DO NOTHING
410
424
  """,
@@ -416,13 +430,26 @@ async def _upsert_event(cur: psycopg.AsyncCursor, req: ExtractRequest,
416
430
  req.attributes.get("participant_kind", "unknown"),
417
431
  req.attributes.get("disclosure_class", "private"),
418
432
  psycopg.types.json.Json(req.attributes),
433
+ emitted_at,
419
434
  ),
420
435
  )
421
436
 
422
437
 
423
- async def _upsert_entities(cur: psycopg.AsyncCursor, entities: list[dict]) -> None:
438
+ async def _upsert_entities(
439
+ cur: psycopg.AsyncCursor,
440
+ entities: list[dict],
441
+ event_time: "datetime | None",
442
+ ) -> None:
424
443
  """Alias-aware idempotent entity upsert.
425
444
 
445
+ `event_time` is the SOURCE time of the originating event (parsed from
446
+ `attributes.timestamp`); it stamps `first_seen`/`last_seen` so the
447
+ graph tracks when the evidence actually happened, not when we
448
+ ingested it. `None` (no/garbage source time) falls back to NOW() via
449
+ COALESCE. On re-corroboration we widen the window with
450
+ LEAST(first_seen, ...) / GREATEST(last_seen, ...): "most recent
451
+ evidence" = newest SOURCE time, not newest ingest.
452
+
426
453
  For each entity, before inserting, look for an existing row in the
427
454
  same (arena, entity_type) whose canonical_name OR aliases overlap
428
455
  any of the incoming surface forms. If found, merge aliases +
@@ -488,23 +515,40 @@ async def _upsert_entities(cur: psycopg.AsyncCursor, entities: list[dict]) -> No
488
515
  UPDATE entities SET
489
516
  aliases = ARRAY(SELECT DISTINCT UNNEST(aliases || %s::text[])),
490
517
  provenance_event_ids = ARRAY(SELECT DISTINCT UNNEST(provenance_event_ids || %s::text[])),
491
- last_seen = NOW()
518
+ -- Widen the seen-window with this event's SOURCE time,
519
+ -- not NOW(): newest evidence = newest source time.
520
+ last_seen = GREATEST(last_seen, COALESCE(%s, NOW())),
521
+ first_seen = LEAST(first_seen, COALESCE(%s, NOW()))
492
522
  WHERE id = %s
493
523
  """,
494
- (e["aliases"], e["provenance_event_ids"], existing_id),
524
+ (e["aliases"], e["provenance_event_ids"],
525
+ event_time, event_time, existing_id),
495
526
  )
496
527
  else:
497
528
  # 3b. No match — insert new. ON CONFLICT (id) is a belt-
498
529
  # and-braces fallback for the rare case where two writers
499
530
  # collide on the same id under different surface forms;
500
531
  # the advisory lock above is the primary defence.
532
+ # Fusion Drive born-salience via the SHARED born_salience (no
533
+ # inline constants — they'd drift from the async path; #96 review
534
+ # §4). Sync entities are deterministic (names from structured
535
+ # email/calendar fields) so they're high-quality; the one junk
536
+ # class sync can still emit is a numeric-ID-as-person, flagged so
537
+ # it's born low and decay can evict it. The async distiller owns
538
+ # the full quality-flag set.
539
+ _digits = sum(c.isdigit() for c in e["canonical_name"] if not c.isspace())
540
+ _nonspace = sum(1 for c in e["canonical_name"] if not c.isspace()) or 1
541
+ _flags = ["numeric_id_person"] if (e["entity_type"] == "person" and _digits / _nonspace > 0.5) else []
542
+ _sal = born_salience(1, _flags)
501
543
  await cur.execute(
502
544
  """
503
545
  INSERT INTO entities (
504
546
  id, arena, entity_type, canonical_name, aliases,
505
- provenance_event_ids, participant_set, disclosure_class
547
+ provenance_event_ids, participant_set, disclosure_class,
548
+ first_seen, last_seen, salience
506
549
  ) VALUES (
507
- %s, %s, %s, %s, %s, %s, %s, %s::disclosure_class
550
+ %s, %s, %s, %s, %s, %s, %s, %s::disclosure_class,
551
+ COALESCE(%s, NOW()), COALESCE(%s, NOW()), %s
508
552
  )
509
553
  ON CONFLICT (id) DO UPDATE SET
510
554
  aliases = (
@@ -513,11 +557,14 @@ async def _upsert_entities(cur: psycopg.AsyncCursor, entities: list[dict]) -> No
513
557
  provenance_event_ids = (
514
558
  SELECT ARRAY(SELECT DISTINCT UNNEST(entities.provenance_event_ids || EXCLUDED.provenance_event_ids))
515
559
  ),
516
- last_seen = NOW()
560
+ salience = GREATEST(entities.salience, EXCLUDED.salience),
561
+ last_seen = GREATEST(entities.last_seen, EXCLUDED.last_seen),
562
+ first_seen = LEAST(entities.first_seen, EXCLUDED.first_seen)
517
563
  """,
518
564
  (e["id"], e["arena"], e["entity_type"], e["canonical_name"],
519
565
  e["aliases"], e["provenance_event_ids"],
520
- e["participant_set"], e["disclosure_class"]),
566
+ e["participant_set"], e["disclosure_class"],
567
+ event_time, event_time, _sal),
521
568
  )
522
569
 
523
570
 
@@ -584,7 +631,10 @@ async def extract(req: ExtractRequest):
584
631
  async with _pool.connection() as conn:
585
632
  async with conn.cursor() as cur:
586
633
  await _upsert_event(cur, req, event_id, content_hash)
587
- await _upsert_entities(cur, entities)
634
+ # Source time of THIS event — stamps the graph rows so
635
+ # first/last_seen track content time, not ingest time.
636
+ event_time = event_source_time({"attributes": req.attributes})
637
+ await _upsert_entities(cur, entities, event_time)
588
638
  # Facts + relationships are deliberately left to the async
589
639
  # distillation worker — the deterministic path can't
590
640
  # reliably extract decisions/commitments without LLM context.
@@ -0,0 +1,63 @@
1
+ """source_time — robust ISO-8601 source-time parsing for graph stamping.
2
+
3
+ The memory graph must stamp `events.emitted_at` and the graph rows'
4
+ `first_seen` / `last_seen` / `asserted_at` from the SOURCE time of the
5
+ content (when the email/meeting/message actually happened), NOT the
6
+ ingest wall-clock (`NOW()`). The source time is carried on the event as
7
+ `attributes.timestamp` (ISO-8601). This helper promotes it.
8
+
9
+ Mirrors `compat/server.py:_parse_ts` (handles the bare `Z` suffix that
10
+ `datetime.fromisoformat` only learned in 3.11) but returns a tz-aware
11
+ `datetime` rather than a unix float, because the destination columns are
12
+ `TIMESTAMPTZ` and we want psycopg to bind a datetime, not an epoch.
13
+
14
+ CONTRACT (load-bearing): callers MUST fall back to the existing default
15
+ (received / NOW) when the source time is absent or unparseable. This
16
+ helper NEVER raises and returns `None` on anything it can't parse — the
17
+ caller is responsible for the `or NOW()` fallback so we never NULL a
18
+ NOT NULL column or crash the ingest/distill path.
19
+
20
+ NOTE: keep this byte-identical with the copy in extractor-sync/. Same
21
+ convention as entity_id.py — two services, one parsing rule.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from datetime import datetime, timezone
27
+ from typing import Any
28
+
29
+
30
+ def parse_source_time(value: Any) -> datetime | None:
31
+ """Best-effort ISO-8601 -> tz-aware datetime. Returns None on
32
+ anything we can't parse (caller falls back to NOW()).
33
+
34
+ Accepts both the bare `Z` suffix and explicit offsets. A parsed
35
+ value with no offset is assumed UTC (the producers emit UTC ISO
36
+ strings; a naive datetime would break TIMESTAMPTZ comparisons)."""
37
+ if not isinstance(value, str) or not value:
38
+ return None
39
+ try:
40
+ # `fromisoformat` handles `+00:00` but not the bare `Z` suffix
41
+ # until Python 3.11; normalise to be safe across runtime
42
+ # versions on the engine box.
43
+ dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
44
+ except Exception:
45
+ return None
46
+ if dt.tzinfo is None:
47
+ # Producer emitted a naive ISO string; treat as UTC rather than
48
+ # letting psycopg interpret it in the server's local zone.
49
+ dt = dt.replace(tzinfo=timezone.utc)
50
+ return dt
51
+
52
+
53
+ def event_source_time(event: dict[str, Any]) -> datetime | None:
54
+ """Pull the source time off an event dict's attributes.
55
+
56
+ Precedence: `attributes.timestamp` (the source/content time) wins
57
+ over `attributes.emitted_at` (a producer-supplied emit-now, which is
58
+ closer to ingest time). Returns None if neither parses — caller
59
+ falls back to NOW()."""
60
+ attrs = event.get("attributes") or {}
61
+ return parse_source_time(attrs.get("timestamp")) or parse_source_time(
62
+ attrs.get("emitted_at")
63
+ )
@@ -0,0 +1,18 @@
1
+ """extractor-sync/confidence.py must stay byte-identical to extractor-async's
2
+ copy — both carry born_salience, whose scale must match the Fusion Drive decay
3
+ side. Same drift guard as test_entity_id_parity.py across the build contexts."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+
9
+
10
+ def test_sync_confidence_is_byte_identical_to_async():
11
+ here = os.path.dirname(__file__)
12
+ sync = os.path.join(here, "confidence.py")
13
+ async_ = os.path.join(here, "..", "extractor-async", "confidence.py")
14
+ with open(sync, "rb") as f:
15
+ a = f.read()
16
+ with open(async_, "rb") as f:
17
+ b = f.read()
18
+ assert a == b, "extractor-sync/confidence.py drifted from extractor-async/confidence.py"
@@ -273,7 +273,7 @@ def test_pool_keeps_default_tuple_row_factory() -> None:
273
273
  def test_upsert_entities_merge_branch_with_tuple_rows() -> None:
274
274
  """Entity already exists → UPDATE branch runs, id taken from row[0]."""
275
275
  cur = _FakeCursor(existing_id="e_existing")
276
- asyncio.run(sync_server._upsert_entities(cur, [_entity_stub()]))
276
+ asyncio.run(sync_server._upsert_entities(cur, [_entity_stub()], None))
277
277
  updates = [(s, p) for s, p in cur.executed if s.startswith("UPDATE entities")]
278
278
  assert len(updates) == 1
279
279
  _, params = updates[0]
@@ -283,7 +283,7 @@ def test_upsert_entities_merge_branch_with_tuple_rows() -> None:
283
283
 
284
284
  def test_upsert_entities_insert_branch_when_no_match() -> None:
285
285
  cur = _FakeCursor(existing_id=None)
286
- asyncio.run(sync_server._upsert_entities(cur, [_entity_stub()]))
286
+ asyncio.run(sync_server._upsert_entities(cur, [_entity_stub()], None))
287
287
  inserts = [s for s, _ in cur.executed if s.startswith("INSERT INTO entities")]
288
288
  assert len(inserts) == 1
289
289
  assert not any(s.startswith("UPDATE entities") for s, _ in cur.executed)
@@ -0,0 +1,85 @@
1
+ """Fusion Drive — LLM adjudication via the self-hosted distiller (no egress).
2
+
3
+ When fusion's deterministic tiers (exact-name, cross-run-shared-provenance,
4
+ exact-triple facts) leave AMBIGUOUS candidates — two entities in the
5
+ 0.75–0.92 embedding band, or two facts that look like the same assertion in
6
+ different words — we ask the **in-VPC distiller** (Qwen3.6-27B-FP8, the same
7
+ LLM that extracted this content) to adjudicate. Using the distiller instead
8
+ of a hosted API means the memory content NEVER leaves the VPC: no third-party
9
+ egress, no disclosure_class sign-off, no per-token cost.
10
+
11
+ This module is pure: the HTTP call is injected (`post_fn`), so verdict parsing
12
+ and prompt construction are unit-tested without a GPU. The caller supplies a
13
+ `post_fn(messages) -> str` that hits the distiller's OpenAI /v1/chat/completions
14
+ (temperature 0, chat_template_kwargs enable_thinking=false — same shape the
15
+ worker uses). If post_fn raises / returns None, adjudication is treated as
16
+ UNSURE (never auto-merge on an LLM failure) — graceful degradation when no
17
+ distiller box is up.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import re
24
+
25
+ # Conservative default: anything that isn't a clear "yes" does NOT merge.
26
+ ENTITY_PROMPT = (
27
+ "You decide whether two extracted entities refer to the SAME real-world "
28
+ "thing. Be conservative: only say yes if you are confident they are the "
29
+ "same. Two different people who merely share a first name are NOT the same.\n\n"
30
+ "Entity A: type={a_type} name={a_name} aliases={a_aliases}\n"
31
+ "Context A (facts mentioning A): {a_ctx}\n\n"
32
+ "Entity B: type={b_type} name={b_name} aliases={b_aliases}\n"
33
+ "Context B (facts mentioning B): {b_ctx}\n\n"
34
+ 'Reply with ONLY a JSON object: {{"same": true|false, "reason": "<short>"}}'
35
+ )
36
+
37
+ FACT_PROMPT = (
38
+ "You decide whether two statements assert the SAME fact (same subject, "
39
+ "same claim), even if worded differently. Be conservative.\n\n"
40
+ "Statement A: {a}\n"
41
+ "Statement B: {b}\n\n"
42
+ 'Reply with ONLY a JSON object: {{"same": true|false, "reason": "<short>"}}'
43
+ )
44
+
45
+
46
+ def _parse_verdict(raw: str | None) -> dict:
47
+ """Parse the model's JSON verdict. Anything unparseable / non-affirmative
48
+ → {'same': False} (fail closed — never merge on doubt)."""
49
+ if not raw:
50
+ return {"same": False, "reason": "no response (unsure)"}
51
+ m = re.search(r"\{.*\}", raw, re.DOTALL)
52
+ if not m:
53
+ return {"same": False, "reason": "unparseable verdict"}
54
+ try:
55
+ v = json.loads(m.group(0))
56
+ except ValueError:
57
+ return {"same": False, "reason": "invalid json verdict"}
58
+ return {"same": bool(v.get("same") is True), "reason": str(v.get("reason", ""))[:200]}
59
+
60
+
61
+ def adjudicate_entities(a: dict, b: dict, post_fn) -> dict:
62
+ """a/b: {entity_type, canonical_name, aliases, context (list[str] of facts)}.
63
+ Returns {'same': bool, 'reason': str}. Fail-closed on any error."""
64
+ msg = ENTITY_PROMPT.format(
65
+ a_type=a.get("entity_type"), a_name=a.get("canonical_name"),
66
+ a_aliases=", ".join(a.get("aliases") or []) or "(none)",
67
+ a_ctx=" | ".join((a.get("context") or [])[:5]) or "(none)",
68
+ b_type=b.get("entity_type"), b_name=b.get("canonical_name"),
69
+ b_aliases=", ".join(b.get("aliases") or []) or "(none)",
70
+ b_ctx=" | ".join((b.get("context") or [])[:5]) or "(none)",
71
+ )
72
+ try:
73
+ raw = post_fn([{"role": "user", "content": msg}])
74
+ except Exception:
75
+ return {"same": False, "reason": "adjudicator unreachable (unsure)"}
76
+ return _parse_verdict(raw)
77
+
78
+
79
+ def adjudicate_facts(stmt_a: str, stmt_b: str, post_fn) -> dict:
80
+ msg = FACT_PROMPT.format(a=stmt_a, b=stmt_b)
81
+ try:
82
+ raw = post_fn([{"role": "user", "content": msg}])
83
+ except Exception:
84
+ return {"same": False, "reason": "adjudicator unreachable (unsure)"}
85
+ return _parse_verdict(raw)
@@ -0,0 +1,65 @@
1
+ """Unit tests for distiller-based adjudication (pure; post_fn injected)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from adjudicate import adjudicate_entities, adjudicate_facts, _parse_verdict
6
+
7
+
8
+ class TestParseVerdict:
9
+ def test_clean_yes(self):
10
+ assert _parse_verdict('{"same": true, "reason": "same person"}')["same"] is True
11
+
12
+ def test_clean_no(self):
13
+ assert _parse_verdict('{"same": false, "reason": "different"}')["same"] is False
14
+
15
+ def test_prose_wrapped_json(self):
16
+ assert _parse_verdict('Sure: {"same": true, "reason": "x"} done')["same"] is True
17
+
18
+ def test_none_is_unsure_false(self):
19
+ assert _parse_verdict(None)["same"] is False
20
+
21
+ def test_unparseable_is_false(self):
22
+ assert _parse_verdict("yeah probably the same tbh")["same"] is False
23
+
24
+ def test_invalid_json_is_false(self):
25
+ assert _parse_verdict('{"same": tru')["same"] is False
26
+
27
+ def test_missing_same_key_is_false(self):
28
+ assert _parse_verdict('{"reason": "hmm"}')["same"] is False
29
+
30
+
31
+ class TestAdjudicateEntities:
32
+ def test_yes_path(self):
33
+ post = lambda msgs: '{"same": true, "reason": "same"}'
34
+ v = adjudicate_entities({"canonical_name": "Phil"}, {"canonical_name": "Philip"}, post)
35
+ assert v["same"] is True
36
+
37
+ def test_post_fn_raises_is_failclosed(self):
38
+ def boom(msgs):
39
+ raise RuntimeError("no distiller up")
40
+ v = adjudicate_entities({"canonical_name": "A"}, {"canonical_name": "B"}, boom)
41
+ assert v["same"] is False and "unsure" in v["reason"]
42
+
43
+ def test_prompt_includes_both_entities(self):
44
+ captured = {}
45
+ def post(msgs):
46
+ captured["content"] = msgs[0]["content"]
47
+ return '{"same": false}'
48
+ adjudicate_entities(
49
+ {"entity_type": "person", "canonical_name": "Katie Cooper", "aliases": ["KC"],
50
+ "context": ["Katie organised Ramen Day"]},
51
+ {"entity_type": "person", "canonical_name": "1716801984", "context": []},
52
+ post,
53
+ )
54
+ assert "Katie Cooper" in captured["content"] and "1716801984" in captured["content"]
55
+
56
+
57
+ class TestAdjudicateFacts:
58
+ def test_same_assertion(self):
59
+ post = lambda msgs: '{"same": true, "reason": "same claim"}'
60
+ assert adjudicate_facts("joined Acme", "works at Acme", post)["same"] is True
61
+
62
+ def test_unreachable_is_failclosed(self):
63
+ def boom(msgs):
64
+ raise ConnectionError()
65
+ assert adjudicate_facts("a", "b", boom)["same"] is False
@@ -100,11 +100,30 @@ def _scan(cur, arena: str, now: datetime) -> tuple[dict, list[dict]]:
100
100
  evictable.append({"node_kind": "entity", "id": eid, "salience": cur_sal})
101
101
  report["entities"] = {"scanned": len(rows), "evict_candidates": ecand}
102
102
 
103
- # NOTE: relationship DECAY/eviction is intentionally NOT done here yet
104
- # (the migration adds salience to relationships, but seeding + a clock
105
- # policy for edges is a follow-up). Relationships only leave via the
106
- # entity-merge collision path or cascade and the guard above prevents
107
- # cascade from silently dropping a live edge.
103
+ # relationships: an edge between two surviving entities is "referenced"
104
+ # (it IS the evidence they interacted) and is kept regardless of salience.
105
+ # Only a dangling/decayed edge low salience, stale, and missing at least
106
+ # one endpoint is evictable. (rels FK entities ON DELETE CASCADE, so a
107
+ # live-endpoint edge is never orphaned by the entity pass either.)
108
+ cur.execute(
109
+ """SELECT r.id, r.relationship_type, r.salience, r.last_seen, r.last_accessed, r.disclosure_class,
110
+ (EXISTS (SELECT 1 FROM entities e WHERE e.id = r.from_entity_id)
111
+ AND EXISTS (SELECT 1 FROM entities e WHERE e.id = r.to_entity_id)) AS both_live
112
+ FROM relationships r WHERE r.arena = %s""",
113
+ (arena,),
114
+ )
115
+ rows = cur.fetchall()
116
+ rcand = 0
117
+ for rid, rtype, sal, last_seen, accessed, disc, both_live in rows:
118
+ clock = max([t for t in (accessed, last_seen) if t is not None], default=None)
119
+ age = _age_days(clock, now)
120
+ cur_sal = S.decayed_salience(sal, age, S.half_life_days("relationship"))
121
+ if S.is_evictable(current_salience=cur_sal, age_days=age,
122
+ referenced_by_live_node=bool(both_live), disclosure_class=disc or "private"):
123
+ rcand += 1
124
+ evictable.append({"node_kind": "relationship", "id": rid, "salience": cur_sal})
125
+ report["relationships"] = {"scanned": len(rows), "evict_candidates": rcand}
126
+
108
127
  return report, evictable
109
128
 
110
129