@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
|
@@ -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(
|
|
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
|
-
|
|
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"],
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
104
|
-
# (the
|
|
105
|
-
#
|
|
106
|
-
#
|
|
107
|
-
#
|
|
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
|
|