@pentatonic-ai/ai-agent-sdk 0.10.21 → 0.10.23
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/extractor-async/backfill_structured_edges.py +218 -0
- package/packages/memory-engine-v2/extractor-async/test_backfill_structured_edges.py +57 -0
- package/packages/memory-engine-v2/extractor-async/test_org_domain.py +42 -0
- package/packages/memory-engine-v2/extractor-async/test_structured_edges.py +471 -0
- package/packages/memory-engine-v2/extractor-async/worker.py +451 -4
- package/packages/memory-engine-v2/extractor-sync/server.py +22 -3
- package/packages/memory-engine-v2/scripts/backfill_person_email_attr.py +216 -0
- package/packages/memory-engine-v2/scripts/test_backfill_person_email_attr.py +84 -0
|
@@ -1098,6 +1098,18 @@ def _domain_of(value: str) -> str | None:
|
|
|
1098
1098
|
return m.group(1).lower() if m else None
|
|
1099
1099
|
|
|
1100
1100
|
|
|
1101
|
+
def _person_email_domain(aliases: list[str]) -> tuple[str | None, str | None]:
|
|
1102
|
+
"""A person's own email (first alias that looks like a bare address) and its
|
|
1103
|
+
domain, for stamping as queryable node attributes (SEE-192). Returns
|
|
1104
|
+
`(lowercased_email, domain)` or `(None, None)`. Callers run this AFTER the
|
|
1105
|
+
email-alias guard, so a surviving address is the person's own — not a
|
|
1106
|
+
bystander. Mirrors the org-domain stamp; reuses `_domain_of`."""
|
|
1107
|
+
email = next((a for a in aliases if a and "@" in a and " " not in a), None)
|
|
1108
|
+
if not email:
|
|
1109
|
+
return None, None
|
|
1110
|
+
return email.strip().lower(), _domain_of(email)
|
|
1111
|
+
|
|
1112
|
+
|
|
1101
1113
|
def _bare_domain(value: str) -> str | None:
|
|
1102
1114
|
"""Normalise a structured domain field to a bare host — 'acme.com' from
|
|
1103
1115
|
'Acme.com', 'https://www.acme.com/about', etc. Used for system-of-record
|
|
@@ -1290,6 +1302,396 @@ def _email_plausibly_belongs(person_name: str, email: str) -> bool:
|
|
|
1290
1302
|
return False
|
|
1291
1303
|
|
|
1292
1304
|
|
|
1305
|
+
# ----------------------------------------------------------------------
|
|
1306
|
+
# Deterministic structured edges (SEE-490)
|
|
1307
|
+
#
|
|
1308
|
+
# The participant envelope every event already carries — gmail From/To/Cc, drive
|
|
1309
|
+
# author/owner — encodes connections that are TRUE BY CONSTRUCTION: who emailed
|
|
1310
|
+
# whom, who authored what. Today those edges reach the graph ONLY if the LLM
|
|
1311
|
+
# re-derives them from prose (capped at MAX_RELATIONSHIPS_PER_EVENT, lossy, can
|
|
1312
|
+
# hallucinate), while the structured bag is consumed only for entity *resolution*
|
|
1313
|
+
# (`event_org_domains`, `event_record_subject`). Here we build the edges directly,
|
|
1314
|
+
# with no model call, keyed on the SAME hard keys the resolver uses (person → its
|
|
1315
|
+
# email via `person_id_key`; document → its drive file-id) so endpoints converge
|
|
1316
|
+
# onto the existing nodes instead of forking new ones. The async LLM pass still
|
|
1317
|
+
# runs and still emits the inferred/semantic edges only it can find — this just
|
|
1318
|
+
# adds the deterministic backbone it shouldn't have to guess at.
|
|
1319
|
+
#
|
|
1320
|
+
# `structured_graph_elements` returns (entities, relationships) in the EXACT dict
|
|
1321
|
+
# shape `upsert_entities` / `upsert_relationships` already consume, so the caller
|
|
1322
|
+
# just concatenates them onto the LLM output before the existing upserts. Pure +
|
|
1323
|
+
# total: never raises on bad input (it runs in the hot distill path) — it returns
|
|
1324
|
+
# empty lists for anything it can't parse, and a misbehaving builder is caught and
|
|
1325
|
+
# logged, never propagated.
|
|
1326
|
+
#
|
|
1327
|
+
# Closed predicate vocabulary — ONE canonical form per concept (not the LLM's
|
|
1328
|
+
# "works at"/"works for"/"works on" spread), so retrieval is a typed traversal
|
|
1329
|
+
# (`WHERE relationship_type = 'authored'`) rather than an OR over prose variants.
|
|
1330
|
+
# ----------------------------------------------------------------------
|
|
1331
|
+
|
|
1332
|
+
EDGE_CORRESPONDED = "corresponded_with" # email sender → each recipient (To+Cc)
|
|
1333
|
+
EDGE_AUTHORED = "authored" # drive author/owner → document
|
|
1334
|
+
EDGE_MEMBER_OF = "member_of" # slack author → channel
|
|
1335
|
+
EDGE_MENTIONED = "mentioned" # slack author → @-mentioned person
|
|
1336
|
+
EDGE_ATTENDED = "attended" # meeting attendee → meeting
|
|
1337
|
+
EDGE_EMPLOYED_BY = "employed_by" # person → org (by their email domain)
|
|
1338
|
+
|
|
1339
|
+
# Deterministic edges are flag-revertible: if a builder ever misfires on live
|
|
1340
|
+
# data, flip this off and the graph falls back to LLM-only edges. No data loss —
|
|
1341
|
+
# rows already written stay; only new structured edges stop being emitted.
|
|
1342
|
+
STRUCTURED_EDGES_ENABLED = _envflag("STRUCTURED_EDGES_ENABLED", "true")
|
|
1343
|
+
|
|
1344
|
+
# `employed_by` (person → org by email domain) is gated SEPARATELY because it has
|
|
1345
|
+
# a known tradeoff the other builders don't: it mints an org node keyed on the
|
|
1346
|
+
# domain (the spine's deterministic org hard key), but when the LLM already
|
|
1347
|
+
# extracted that org under a name whose token doesn't contain the domain label
|
|
1348
|
+
# (accents/abbreviations: `loreal.com` vs "L'Oréal"), the two don't auto-merge via
|
|
1349
|
+
# alias-overlap and Fusion has to reconcile them. Correct-but-fragmenting, so it's
|
|
1350
|
+
# independently flippable pending a Fusion-reconcile decision. Freemail + the
|
|
1351
|
+
# tenant's own domain are always excluded (reusing `event_org_domains`' rules).
|
|
1352
|
+
STRUCTURED_EMPLOYED_BY_ENABLED = _envflag("STRUCTURED_EMPLOYED_BY_ENABLED", "true")
|
|
1353
|
+
|
|
1354
|
+
# Defensive fan-out bound: a pathological event (a huge distribution list) must
|
|
1355
|
+
# not write thousands of edge rows. Real person-to-person mail sits far below
|
|
1356
|
+
# this; above it we log and truncate rather than let one event dominate the table.
|
|
1357
|
+
STRUCTURED_EDGE_FANOUT_CAP = int(os.environ.get("STRUCTURED_EDGE_FANOUT_CAP", "200"))
|
|
1358
|
+
|
|
1359
|
+
# Stable resolution-key prefixes for "container" nodes (document / slack channel /
|
|
1360
|
+
# meeting) — mirrors the org domain / person email hard keys (keyed in
|
|
1361
|
+
# `upsert_entities` §3b). Each carries its source system's PERMANENT id as a
|
|
1362
|
+
# prefixed alias, so re-ingesting the same file/channel/meeting (even renamed)
|
|
1363
|
+
# converges to one node instead of fragmenting by title. Only the deterministic
|
|
1364
|
+
# edge pass mints these aliases, so there's no collision with LLM-extracted nodes.
|
|
1365
|
+
DRIVE_DOC_KEY_PREFIX = "drive:"
|
|
1366
|
+
SLACK_CHANNEL_KEY_PREFIX = "slack:channel:"
|
|
1367
|
+
MEETING_KEY_PREFIX = "meeting:"
|
|
1368
|
+
_EXTERNAL_KEY_PREFIXES = (DRIVE_DOC_KEY_PREFIX, SLACK_CHANNEL_KEY_PREFIX, MEETING_KEY_PREFIX)
|
|
1369
|
+
# Entity types whose identity is one of those external ids (when the alias is
|
|
1370
|
+
# present). `document`/`doc` = drive files; `channel` = slack channels; `event` =
|
|
1371
|
+
# meetings. LLM-minted nodes of these types carry no prefixed alias, so they keep
|
|
1372
|
+
# name-keying — this only changes the deterministically-minted ones.
|
|
1373
|
+
_EXTERNAL_KEY_TYPES = frozenset({"document", "doc", "channel", "event"})
|
|
1374
|
+
|
|
1375
|
+
# Automated / no-reply local-parts — a "person" node for one is pure noise (a
|
|
1376
|
+
# mailer, not a human correspondent). Conservative: only the unambiguous machine
|
|
1377
|
+
# senders; a real human is never on this list.
|
|
1378
|
+
_NONHUMAN_LOCALPARTS = frozenset({
|
|
1379
|
+
"noreply", "no-reply", "donotreply", "do-not-reply", "no_reply",
|
|
1380
|
+
"mailer-daemon", "mailerdaemon", "postmaster", "bounce", "bounces",
|
|
1381
|
+
"notifications", "notification", "notify", "mailer", "automated",
|
|
1382
|
+
})
|
|
1383
|
+
|
|
1384
|
+
_ADDR_RE = re.compile(
|
|
1385
|
+
r"[\w.+-]+@[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9-]+)+", re.I
|
|
1386
|
+
)
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
def _emails_in(value: Any) -> list[str]:
|
|
1390
|
+
"""Lowercased bare email addresses found in a To/Cc/From field — accepts a
|
|
1391
|
+
JSON list (the common case), a delimited string, or a `Name <addr>` form,
|
|
1392
|
+
uniformly via regex. Order-preserving, no dedup (callers dedup). Never raises;
|
|
1393
|
+
`[]` on anything non-stringy."""
|
|
1394
|
+
if isinstance(value, list):
|
|
1395
|
+
items = [v for v in value if isinstance(v, str)]
|
|
1396
|
+
elif isinstance(value, str):
|
|
1397
|
+
items = [value]
|
|
1398
|
+
else:
|
|
1399
|
+
return []
|
|
1400
|
+
out: list[str] = []
|
|
1401
|
+
for it in items:
|
|
1402
|
+
out.extend(m.lower() for m in _ADDR_RE.findall(it))
|
|
1403
|
+
return out
|
|
1404
|
+
|
|
1405
|
+
|
|
1406
|
+
def _is_human_address(email: str) -> bool:
|
|
1407
|
+
"""False for the unambiguous machine senders (`_NONHUMAN_LOCALPARTS`) — we
|
|
1408
|
+
don't mint person nodes (or edges) for mailers/no-reply addresses."""
|
|
1409
|
+
local = email.split("@", 1)[0].lower()
|
|
1410
|
+
# strip the +tag so "noreply+123@" still matches "noreply"
|
|
1411
|
+
local = local.split("+", 1)[0]
|
|
1412
|
+
return local not in _NONHUMAN_LOCALPARTS
|
|
1413
|
+
|
|
1414
|
+
|
|
1415
|
+
def _person_node(email: str, *, name: str | None = None) -> tuple[str | None, dict | None]:
|
|
1416
|
+
"""A `(canonical_name, entity_dict)` for the person behind `email`, in the
|
|
1417
|
+
shape `upsert_entities` consumes (`type`/`name`/`aliases`). The email rides in
|
|
1418
|
+
`aliases` so `person_id_key` keys the node id on it — converging with the LLM
|
|
1419
|
+
and sync passes for the same person. `canonical_name` is the display name when
|
|
1420
|
+
we have one (gmail's `author`), else the email itself (same as the sync pass).
|
|
1421
|
+
Returns `(None, None)` for a non-address or a machine sender."""
|
|
1422
|
+
email = (email or "").strip().lower()
|
|
1423
|
+
if "@" not in email or not _is_human_address(email):
|
|
1424
|
+
return None, None
|
|
1425
|
+
canonical = (name or "").strip() or email
|
|
1426
|
+
return canonical, {"type": "person", "name": canonical, "aliases": [email]}
|
|
1427
|
+
|
|
1428
|
+
|
|
1429
|
+
def _stamp(source: str) -> dict[str, str]:
|
|
1430
|
+
"""Edge `attributes` marking a deterministic edge — makes "structured vs
|
|
1431
|
+
LLM-inferred" a queryable distinction (the empty `{}` LLM edges carry today)."""
|
|
1432
|
+
return {"derivation": "structured", "source": source}
|
|
1433
|
+
|
|
1434
|
+
|
|
1435
|
+
def _gmail_structured_edges(arena: str, attrs: dict[str, Any]) -> tuple[list[dict], list[dict]]:
|
|
1436
|
+
"""`corresponded_with`: the message sender (`contact_email`/`from_email`,
|
|
1437
|
+
named by `author`) → each To+Cc recipient. Directed sender→recipient; the
|
|
1438
|
+
sender's own address is dropped from the recipient set (no self-edge)."""
|
|
1439
|
+
senders = _emails_in(attrs.get("contact_email")) or _emails_in(attrs.get("from_email"))
|
|
1440
|
+
if not senders:
|
|
1441
|
+
return [], []
|
|
1442
|
+
sender = senders[0]
|
|
1443
|
+
s_name, s_ent = _person_node(sender, name=attrs.get("author"))
|
|
1444
|
+
if s_ent is None:
|
|
1445
|
+
return [], []
|
|
1446
|
+
recipients: list[str] = []
|
|
1447
|
+
seen = {sender}
|
|
1448
|
+
for key in ("to_emails", "cc_emails"):
|
|
1449
|
+
for r in _emails_in(attrs.get(key)):
|
|
1450
|
+
if r in seen:
|
|
1451
|
+
continue
|
|
1452
|
+
seen.add(r)
|
|
1453
|
+
recipients.append(r)
|
|
1454
|
+
if len(recipients) > STRUCTURED_EDGE_FANOUT_CAP:
|
|
1455
|
+
log.info(
|
|
1456
|
+
f"structured_edges: gmail event over fan-out cap "
|
|
1457
|
+
f"({len(recipients)} > {STRUCTURED_EDGE_FANOUT_CAP}); truncating"
|
|
1458
|
+
)
|
|
1459
|
+
recipients = recipients[:STRUCTURED_EDGE_FANOUT_CAP]
|
|
1460
|
+
ents: list[dict] = [s_ent]
|
|
1461
|
+
edges: list[dict] = []
|
|
1462
|
+
stamp = _stamp("gmail")
|
|
1463
|
+
for r in recipients:
|
|
1464
|
+
r_name, r_ent = _person_node(r)
|
|
1465
|
+
if r_ent is None:
|
|
1466
|
+
continue
|
|
1467
|
+
ents.append(r_ent)
|
|
1468
|
+
edges.append({
|
|
1469
|
+
"from": s_name, "to": r_name, "type": EDGE_CORRESPONDED,
|
|
1470
|
+
"confidence": 1.0, "attributes": stamp,
|
|
1471
|
+
})
|
|
1472
|
+
# No edges ⇒ no point minting the lone sender node here (the LLM/sync passes
|
|
1473
|
+
# cover senders); only contribute nodes that anchor an edge.
|
|
1474
|
+
return (ents, edges) if edges else ([], [])
|
|
1475
|
+
|
|
1476
|
+
|
|
1477
|
+
def _drive_structured_edges(arena: str, attrs: dict[str, Any]) -> tuple[list[dict], list[dict]]:
|
|
1478
|
+
"""`authored`: the drive file's owner/author (`contact_email`) → the document
|
|
1479
|
+
node, keyed on the permanent drive file-id so the same file converges across
|
|
1480
|
+
re-ingests and renames."""
|
|
1481
|
+
authors = _emails_in(attrs.get("contact_email")) or _emails_in(attrs.get("author"))
|
|
1482
|
+
file_id = (attrs.get("drive_file_id") or "").strip()
|
|
1483
|
+
if not authors or not file_id:
|
|
1484
|
+
return [], []
|
|
1485
|
+
a_name, a_ent = _person_node(authors[0])
|
|
1486
|
+
if a_ent is None:
|
|
1487
|
+
return [], []
|
|
1488
|
+
doc_name = (attrs.get("drive_file_name") or "").strip() or file_id
|
|
1489
|
+
doc_key = DRIVE_DOC_KEY_PREFIX + file_id
|
|
1490
|
+
doc_ent = {"type": "document", "name": doc_name, "aliases": [doc_key]}
|
|
1491
|
+
edge = {
|
|
1492
|
+
"from": a_name, "to": doc_name, "type": EDGE_AUTHORED,
|
|
1493
|
+
"confidence": 1.0, "attributes": _stamp("drive"),
|
|
1494
|
+
}
|
|
1495
|
+
return [a_ent, doc_ent], [edge]
|
|
1496
|
+
|
|
1497
|
+
|
|
1498
|
+
def _container_node(etype: str, key_prefix: str, ext_id: str, title: str) -> tuple[str, dict]:
|
|
1499
|
+
"""A `(canonical_name, entity_dict)` for a container node (slack channel /
|
|
1500
|
+
meeting) keyed on its source system's permanent id (via the prefixed alias →
|
|
1501
|
+
`upsert_entities` §3b). `canonical_name` is the human title; the edge
|
|
1502
|
+
references it (the name_to_id key), the alias makes it converge across
|
|
1503
|
+
renames."""
|
|
1504
|
+
canonical = (title or "").strip() or ext_id
|
|
1505
|
+
return canonical, {
|
|
1506
|
+
"type": etype, "name": canonical, "aliases": [key_prefix + ext_id],
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
|
|
1510
|
+
def _slack_structured_edges(arena: str, attrs: dict[str, Any]) -> tuple[list[dict], list[dict]]:
|
|
1511
|
+
"""`member_of`: the message author (`contact_email`, named by `author`) → the
|
|
1512
|
+
channel. `mentioned`: the author → each @-mentioned person (`mentions` is a
|
|
1513
|
+
list of emails). Author + mentioned people key on their email; the channel
|
|
1514
|
+
keys on its permanent channel-id."""
|
|
1515
|
+
authors = _emails_in(attrs.get("contact_email"))
|
|
1516
|
+
if not authors:
|
|
1517
|
+
return [], []
|
|
1518
|
+
a_name, a_ent = _person_node(authors[0], name=attrs.get("author"))
|
|
1519
|
+
if a_ent is None:
|
|
1520
|
+
return [], []
|
|
1521
|
+
ents: list[dict] = [a_ent]
|
|
1522
|
+
edges: list[dict] = []
|
|
1523
|
+
stamp = _stamp("slack")
|
|
1524
|
+
# member_of → channel
|
|
1525
|
+
channel_id = (attrs.get("channel_id") or "").strip()
|
|
1526
|
+
if channel_id:
|
|
1527
|
+
c_name, c_ent = _container_node(
|
|
1528
|
+
"channel", SLACK_CHANNEL_KEY_PREFIX, channel_id, attrs.get("channel_name"),
|
|
1529
|
+
)
|
|
1530
|
+
ents.append(c_ent)
|
|
1531
|
+
edges.append({
|
|
1532
|
+
"from": a_name, "to": c_name, "type": EDGE_MEMBER_OF,
|
|
1533
|
+
"confidence": 1.0, "attributes": stamp,
|
|
1534
|
+
})
|
|
1535
|
+
# mentioned → each distinct @-mentioned person (skip self-mentions)
|
|
1536
|
+
seen = {authors[0]}
|
|
1537
|
+
for m in _emails_in(attrs.get("mentions"))[:STRUCTURED_EDGE_FANOUT_CAP]:
|
|
1538
|
+
if m in seen:
|
|
1539
|
+
continue
|
|
1540
|
+
seen.add(m)
|
|
1541
|
+
m_name, m_ent = _person_node(m)
|
|
1542
|
+
if m_ent is None:
|
|
1543
|
+
continue
|
|
1544
|
+
ents.append(m_ent)
|
|
1545
|
+
edges.append({
|
|
1546
|
+
"from": a_name, "to": m_name, "type": EDGE_MENTIONED,
|
|
1547
|
+
"confidence": 1.0, "attributes": stamp,
|
|
1548
|
+
})
|
|
1549
|
+
return (ents, edges) if edges else ([], [])
|
|
1550
|
+
|
|
1551
|
+
|
|
1552
|
+
def _attended_edges(
|
|
1553
|
+
meeting_id: str, title: Any, attendees_value: Any, source: str,
|
|
1554
|
+
) -> tuple[list[dict], list[dict]]:
|
|
1555
|
+
"""`attended`: each attendee email → the meeting node. Shared by the granola
|
|
1556
|
+
and calendar builders (they differ only in which field carries the meeting's
|
|
1557
|
+
permanent id). Attendees key on their email; the meeting on its source id, so
|
|
1558
|
+
recurring same-title meetings stay distinct nodes (unlike the LLM's title
|
|
1559
|
+
collapse). `attendees_value` is a list of emails (or a delimited string)."""
|
|
1560
|
+
meeting_id = (meeting_id or "").strip()
|
|
1561
|
+
attendees = _emails_in(attendees_value)
|
|
1562
|
+
if not meeting_id or not attendees:
|
|
1563
|
+
return [], []
|
|
1564
|
+
m_name, m_ent = _container_node("event", MEETING_KEY_PREFIX, meeting_id, title)
|
|
1565
|
+
ents: list[dict] = [m_ent]
|
|
1566
|
+
edges: list[dict] = []
|
|
1567
|
+
stamp = _stamp(source)
|
|
1568
|
+
seen: set[str] = set()
|
|
1569
|
+
for a in attendees[:STRUCTURED_EDGE_FANOUT_CAP]:
|
|
1570
|
+
if a in seen:
|
|
1571
|
+
continue
|
|
1572
|
+
seen.add(a)
|
|
1573
|
+
a_name, a_ent = _person_node(a)
|
|
1574
|
+
if a_ent is None:
|
|
1575
|
+
continue
|
|
1576
|
+
ents.append(a_ent)
|
|
1577
|
+
edges.append({
|
|
1578
|
+
"from": a_name, "to": m_name, "type": EDGE_ATTENDED,
|
|
1579
|
+
"confidence": 1.0, "attributes": stamp,
|
|
1580
|
+
})
|
|
1581
|
+
return (ents, edges) if edges else ([], [])
|
|
1582
|
+
|
|
1583
|
+
|
|
1584
|
+
def _granola_structured_edges(arena: str, attrs: dict[str, Any]) -> tuple[list[dict], list[dict]]:
|
|
1585
|
+
"""Granola meeting: attendees `attended` the meeting, keyed on the granola
|
|
1586
|
+
doc id (`thread_id`)."""
|
|
1587
|
+
return _attended_edges(
|
|
1588
|
+
attrs.get("thread_id"), attrs.get("title"), attrs.get("attendees"), "granola",
|
|
1589
|
+
)
|
|
1590
|
+
|
|
1591
|
+
|
|
1592
|
+
def _calendar_structured_edges(arena: str, attrs: dict[str, Any]) -> tuple[list[dict], list[dict]]:
|
|
1593
|
+
"""Calendar event: attendees `attended` the meeting, keyed on the gcal event
|
|
1594
|
+
id (`source_id`; calendar events aren't threaded). Reads the structured
|
|
1595
|
+
`attendees[]` array (SEE-506) — empty/absent on un-upgraded events, so this
|
|
1596
|
+
is a no-op until the emitter stamps it."""
|
|
1597
|
+
return _attended_edges(
|
|
1598
|
+
attrs.get("source_id"), attrs.get("title"), attrs.get("attendees"), "calendar",
|
|
1599
|
+
)
|
|
1600
|
+
|
|
1601
|
+
|
|
1602
|
+
# Dispatch on the fine producer label (`attributes.source`). Adding a source =
|
|
1603
|
+
# adding one entry + its builder; the wire-in and upsert path are untouched.
|
|
1604
|
+
# `pip-calendar-ingest` reads the structured `attendees[]` the emitter stamps
|
|
1605
|
+
# (SEE-506) — a no-op on events ingested before that ships.
|
|
1606
|
+
# Follow-ups (SEE-490 P3): hubspot associated_contact/at_company (needs the CRM
|
|
1607
|
+
# contact-id→email map — assoc_contact_ids are raw numeric ids, the contact nodes
|
|
1608
|
+
# are email-keyed); teams (emitter carries no participant envelope — only Entra
|
|
1609
|
+
# ids + display names, no emails; needs connector-side member resolution).
|
|
1610
|
+
_STRUCTURED_EDGE_BUILDERS = {
|
|
1611
|
+
"pip-gmail-ingest": _gmail_structured_edges,
|
|
1612
|
+
"pip-drive-ingest": _drive_structured_edges,
|
|
1613
|
+
"pip-slack-ingest": _slack_structured_edges,
|
|
1614
|
+
"pip-granola-ingest": _granola_structured_edges,
|
|
1615
|
+
"pip-calendar-ingest": _calendar_structured_edges,
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
|
|
1619
|
+
def _employed_by_elements(
|
|
1620
|
+
person_ents: list[dict], source: str,
|
|
1621
|
+
) -> tuple[list[dict], list[dict]]:
|
|
1622
|
+
"""`employed_by`: for each PERSON node a builder already minted, link them to
|
|
1623
|
+
the org of their email domain (the org's deterministic hard key). Scans the
|
|
1624
|
+
builder's own person entities — so it works uniformly across every source
|
|
1625
|
+
without threading anything through the builders. Drops freemail + the tenant's
|
|
1626
|
+
own domain (an internal person isn't 'employed_by' an external org node, and
|
|
1627
|
+
freemail isn't an org). The org node is keyed on the domain (canonical = its
|
|
1628
|
+
registrable label); see `STRUCTURED_EMPLOYED_BY_ENABLED` for the merge caveat.
|
|
1629
|
+
Deduped per (person, org) and per org node."""
|
|
1630
|
+
org_by_domain: dict[str, str] = {}
|
|
1631
|
+
org_ents: list[dict] = []
|
|
1632
|
+
edges: list[dict] = []
|
|
1633
|
+
seen_edge: set[tuple[str, str]] = set()
|
|
1634
|
+
stamp = _stamp(source)
|
|
1635
|
+
for e in person_ents:
|
|
1636
|
+
if e.get("type") != "person":
|
|
1637
|
+
continue
|
|
1638
|
+
email = next((a for a in (e.get("aliases") or []) if "@" in a), None)
|
|
1639
|
+
if not email:
|
|
1640
|
+
continue
|
|
1641
|
+
dom = _domain_of(email)
|
|
1642
|
+
if not dom or dom in FREEMAIL_DOMAINS or dom in TENANT_EMAIL_DOMAINS:
|
|
1643
|
+
continue
|
|
1644
|
+
label = domain_label(dom)
|
|
1645
|
+
if not label or len(label) < 2:
|
|
1646
|
+
continue
|
|
1647
|
+
if dom not in org_by_domain:
|
|
1648
|
+
org_by_domain[dom] = label
|
|
1649
|
+
org_ents.append({"type": "org", "name": label, "aliases": [dom]})
|
|
1650
|
+
org_name = org_by_domain[dom]
|
|
1651
|
+
person_name = e.get("name")
|
|
1652
|
+
key = (person_name, org_name)
|
|
1653
|
+
if not person_name or key in seen_edge:
|
|
1654
|
+
continue
|
|
1655
|
+
seen_edge.add(key)
|
|
1656
|
+
edges.append({
|
|
1657
|
+
"from": person_name, "to": org_name, "type": EDGE_EMPLOYED_BY,
|
|
1658
|
+
"confidence": 1.0, "attributes": stamp,
|
|
1659
|
+
})
|
|
1660
|
+
return org_ents, edges
|
|
1661
|
+
|
|
1662
|
+
|
|
1663
|
+
def structured_graph_elements(
|
|
1664
|
+
arena: str, event_attrs: dict[str, Any] | None
|
|
1665
|
+
) -> tuple[list[dict], list[dict]]:
|
|
1666
|
+
"""Deterministic `(entities, relationships)` from one event's structured
|
|
1667
|
+
envelope — concatenated onto the LLM extraction before the upserts. Empty
|
|
1668
|
+
when the flag is off, attributes are missing, the source has no builder, or
|
|
1669
|
+
the builder finds nothing. Never raises (hot path): a builder exception is
|
|
1670
|
+
logged and swallowed."""
|
|
1671
|
+
if not STRUCTURED_EDGES_ENABLED or not event_attrs:
|
|
1672
|
+
return [], []
|
|
1673
|
+
source = (event_attrs.get("source") or "").strip()
|
|
1674
|
+
builder = _STRUCTURED_EDGE_BUILDERS.get(source)
|
|
1675
|
+
if builder is None:
|
|
1676
|
+
return [], []
|
|
1677
|
+
try:
|
|
1678
|
+
ents, edges = builder(arena, event_attrs)
|
|
1679
|
+
# employed_by is derived from the person nodes the builder produced —
|
|
1680
|
+
# independently gated (it can fragment orgs pending Fusion) but on by
|
|
1681
|
+
# default. Skipped entirely when a builder returned nothing.
|
|
1682
|
+
if STRUCTURED_EMPLOYED_BY_ENABLED and ents:
|
|
1683
|
+
org_ents, eb_edges = _employed_by_elements(ents, source)
|
|
1684
|
+
ents = ents + org_ents
|
|
1685
|
+
edges = edges + eb_edges
|
|
1686
|
+
return ents, edges
|
|
1687
|
+
except Exception as exc: # noqa: BLE001 — must never break distillation
|
|
1688
|
+
log.warning(
|
|
1689
|
+
f"structured_edges: builder for {source!r} failed "
|
|
1690
|
+
f"({type(exc).__name__}: {exc}); skipping"
|
|
1691
|
+
)
|
|
1692
|
+
return [], []
|
|
1693
|
+
|
|
1694
|
+
|
|
1293
1695
|
def upsert_entities(
|
|
1294
1696
|
conn: psycopg.Connection,
|
|
1295
1697
|
arena: str,
|
|
@@ -1416,6 +1818,21 @@ def upsert_entities(
|
|
|
1416
1818
|
if subject_ext_id not in aliases:
|
|
1417
1819
|
aliases.append(subject_ext_id)
|
|
1418
1820
|
attrs_patch["external_id"] = subject_ext_id
|
|
1821
|
+
# Person email/domain as a QUERYABLE node attribute (SEE-192). The
|
|
1822
|
+
# email already mints the id (person_id_key) and survives the alias
|
|
1823
|
+
# guard above, but was never persisted as a discrete attribute — so
|
|
1824
|
+
# retrieval / context-aware mention resolution had to dig it out of
|
|
1825
|
+
# the aliases array (text-search, not indexed) and 0/37,648 person
|
|
1826
|
+
# nodes carried `attributes.email`/`domain`. Stamp it (lowercased) +
|
|
1827
|
+
# its domain, mirroring the org-domain stamp, so a person node is
|
|
1828
|
+
# directly matchable by email/domain. Merge-only (`|| jsonb`); the
|
|
1829
|
+
# guard already dropped bystander emails, so this is the person's own.
|
|
1830
|
+
if etype == "person":
|
|
1831
|
+
_pemail, _pdomain = _person_email_domain(aliases)
|
|
1832
|
+
if _pemail:
|
|
1833
|
+
attrs_patch["email"] = _pemail
|
|
1834
|
+
if _pdomain:
|
|
1835
|
+
attrs_patch["domain"] = _pdomain
|
|
1419
1836
|
attrs_update = json.dumps(attrs_patch)
|
|
1420
1837
|
|
|
1421
1838
|
# Sort (don't `list(set(...))`) so lock acquisition order
|
|
@@ -1492,6 +1909,19 @@ def upsert_entities(
|
|
|
1492
1909
|
elif etype == "org":
|
|
1493
1910
|
id_key = org_node_id_key(etype, name, stamped_domain)
|
|
1494
1911
|
has_hard_key = stamped_domain is not None
|
|
1912
|
+
elif etype in _EXTERNAL_KEY_TYPES:
|
|
1913
|
+
# Symmetric to org/person: a container node (drive document /
|
|
1914
|
+
# slack channel / meeting) carrying a prefixed external-id
|
|
1915
|
+
# alias (only the deterministic edge pass mints these) keys on
|
|
1916
|
+
# that permanent id, so the same file/channel/meeting converges
|
|
1917
|
+
# to one node across re-ingests/renames instead of fragmenting
|
|
1918
|
+
# by title. Title-keyed otherwise (LLM-extracted nodes).
|
|
1919
|
+
ext_key = next(
|
|
1920
|
+
(a for a in aliases if a.startswith(_EXTERNAL_KEY_PREFIXES)),
|
|
1921
|
+
None,
|
|
1922
|
+
)
|
|
1923
|
+
id_key = ext_key or name
|
|
1924
|
+
has_hard_key = ext_key is not None
|
|
1495
1925
|
else:
|
|
1496
1926
|
id_key = name
|
|
1497
1927
|
has_hard_key = False
|
|
@@ -1735,21 +2165,27 @@ def upsert_relationships(
|
|
|
1735
2165
|
if not from_id or not to_id or not rtype:
|
|
1736
2166
|
continue
|
|
1737
2167
|
rid = _content_id(arena, from_id, to_id, rtype)
|
|
2168
|
+
# Edge `attributes` — `{derivation,source}` for deterministic edges
|
|
2169
|
+
# (SEE-490), `{}` for LLM edges (unchanged from before). Makes
|
|
2170
|
+
# "structured vs inferred" queryable; merge-only on conflict so a
|
|
2171
|
+
# later corroboration never clears the provenance stamp.
|
|
2172
|
+
edge_attrs = r.get("attributes") if isinstance(r.get("attributes"), dict) else {}
|
|
1738
2173
|
cur.execute(
|
|
1739
2174
|
"""
|
|
1740
2175
|
INSERT INTO relationships (
|
|
1741
2176
|
id, arena, from_entity_id, to_entity_id, relationship_type,
|
|
1742
2177
|
weight, provenance_event_ids, participant_set, disclosure_class,
|
|
1743
|
-
first_seen, last_seen
|
|
2178
|
+
attributes, first_seen, last_seen
|
|
1744
2179
|
) VALUES (
|
|
1745
2180
|
%s, %s, %s, %s, %s, %s, %s, %s, %s::disclosure_class,
|
|
1746
|
-
COALESCE(%s, NOW()), COALESCE(%s, NOW())
|
|
2181
|
+
%s::jsonb, COALESCE(%s, NOW()), COALESCE(%s, NOW())
|
|
1747
2182
|
)
|
|
1748
2183
|
ON CONFLICT (id) DO UPDATE SET
|
|
1749
2184
|
weight = relationships.weight + EXCLUDED.weight,
|
|
1750
2185
|
provenance_event_ids = (
|
|
1751
2186
|
SELECT ARRAY(SELECT DISTINCT UNNEST(relationships.provenance_event_ids || EXCLUDED.provenance_event_ids))
|
|
1752
2187
|
),
|
|
2188
|
+
attributes = relationships.attributes || EXCLUDED.attributes,
|
|
1753
2189
|
last_seen = GREATEST(relationships.last_seen, EXCLUDED.last_seen),
|
|
1754
2190
|
first_seen = LEAST(relationships.first_seen, EXCLUDED.first_seen)
|
|
1755
2191
|
""",
|
|
@@ -1757,6 +2193,7 @@ def upsert_relationships(
|
|
|
1757
2193
|
rid, arena, from_id, to_id, rtype,
|
|
1758
2194
|
float(r.get("confidence") or 0.5),
|
|
1759
2195
|
[event_id], participant_set, disclosure_class,
|
|
2196
|
+
json.dumps(edge_attrs),
|
|
1760
2197
|
event_time, event_time,
|
|
1761
2198
|
),
|
|
1762
2199
|
)
|
|
@@ -2337,9 +2774,18 @@ def _apply_extraction(
|
|
|
2337
2774
|
# `attributes.source` else coarse `source_kind`; None ⇒ column stays NULL.
|
|
2338
2775
|
src = fact_source(event)
|
|
2339
2776
|
|
|
2777
|
+
# Deterministic structured edges (SEE-490): build person↔person / person↔doc /
|
|
2778
|
+
# person↔channel / person↔meeting edges (+ their endpoint nodes) directly from
|
|
2779
|
+
# the event's structured envelope and concatenate them onto the LLM output.
|
|
2780
|
+
# Endpoints key on the same hard keys (`person_id_key` email / drive file-id /
|
|
2781
|
+
# slack channel-id / meeting-id) as the resolver, so they merge onto existing
|
|
2782
|
+
# nodes; the extra entities land in `name_to_id` for the edges to resolve
|
|
2783
|
+
# against. Empty unless the source has a builder + the flag is on.
|
|
2784
|
+
det_ents, det_rels = structured_graph_elements(arena, event.get("attributes"))
|
|
2785
|
+
|
|
2340
2786
|
try:
|
|
2341
2787
|
name_to_id = upsert_entities(
|
|
2342
|
-
conn, arena, event_id, participant_set, disclosure, ents,
|
|
2788
|
+
conn, arena, event_id, participant_set, disclosure, ents + det_ents,
|
|
2343
2789
|
event_time, event.get("attributes"),
|
|
2344
2790
|
)
|
|
2345
2791
|
n_facts = upsert_facts(
|
|
@@ -2347,13 +2793,14 @@ def _apply_extraction(
|
|
|
2347
2793
|
event_time, due_at, src,
|
|
2348
2794
|
)
|
|
2349
2795
|
n_rels = upsert_relationships(
|
|
2350
|
-
conn, arena, event_id, participant_set, disclosure, rels, name_to_id,
|
|
2796
|
+
conn, arena, event_id, participant_set, disclosure, rels + det_rels, name_to_id,
|
|
2351
2797
|
event_time,
|
|
2352
2798
|
)
|
|
2353
2799
|
mark_done(conn, queue_id)
|
|
2354
2800
|
log.info(
|
|
2355
2801
|
f"completed queue_id={queue_id} event_id={event_id} producer={producer} "
|
|
2356
2802
|
f"entities={len(name_to_id)} facts={n_facts} relationships={n_rels}"
|
|
2803
|
+
f" det_edges={len(det_rels)}"
|
|
2357
2804
|
+ (f" llm_ms={llm_ms:.0f}" if not stub_mode and llm_ms else "")
|
|
2358
2805
|
)
|
|
2359
2806
|
# Trace logging — best-effort. ONLY the teacher produces training gold;
|
|
@@ -36,6 +36,8 @@ from entity_id import entity_id, normalize_surface_form, person_id_key # noqa:
|
|
|
36
36
|
# Source-time parsing — byte-identical copy in extractor-async (source_time.py).
|
|
37
37
|
from source_time import event_source_time
|
|
38
38
|
|
|
39
|
+
import json
|
|
40
|
+
|
|
39
41
|
import psycopg
|
|
40
42
|
import psycopg.rows
|
|
41
43
|
from fastapi import FastAPI, HTTPException
|
|
@@ -270,6 +272,18 @@ def _person_entity(
|
|
|
270
272
|
if a:
|
|
271
273
|
aliases_set.add(a)
|
|
272
274
|
|
|
275
|
+
# Email/domain as a QUERYABLE node attribute (SEE-192). The sync pass keys
|
|
276
|
+
# the node on the ENVELOPE email (the most reliable source — not LLM-
|
|
277
|
+
# promoted), so stamp it (lowercased) + its domain here so a person node is
|
|
278
|
+
# directly matchable by email/domain instead of only via the aliases array.
|
|
279
|
+
attrs: dict[str, str] = {}
|
|
280
|
+
if email:
|
|
281
|
+
em = email.strip().lower()
|
|
282
|
+
attrs["email"] = em
|
|
283
|
+
dom = em.rsplit("@", 1)[-1] if "@" in em else ""
|
|
284
|
+
if dom and "." in dom and " " not in dom:
|
|
285
|
+
attrs["domain"] = dom
|
|
286
|
+
|
|
273
287
|
return {
|
|
274
288
|
"id": _entity_id(req.arena, "person", id_key),
|
|
275
289
|
"arena": req.arena,
|
|
@@ -279,6 +293,7 @@ def _person_entity(
|
|
|
279
293
|
"provenance_event_ids": [event_id],
|
|
280
294
|
"participant_set": req.attributes.get("participant_set", [req.arena]),
|
|
281
295
|
"disclosure_class": req.attributes.get("disclosure_class", "private"),
|
|
296
|
+
"attributes": attrs,
|
|
282
297
|
}
|
|
283
298
|
|
|
284
299
|
|
|
@@ -550,10 +565,10 @@ async def _upsert_entities(
|
|
|
550
565
|
INSERT INTO entities (
|
|
551
566
|
id, arena, entity_type, canonical_name, aliases,
|
|
552
567
|
provenance_event_ids, participant_set, disclosure_class,
|
|
553
|
-
first_seen, last_seen, salience
|
|
568
|
+
first_seen, last_seen, salience, attributes
|
|
554
569
|
) VALUES (
|
|
555
570
|
%s, %s, %s, %s, %s, %s, %s, %s::disclosure_class,
|
|
556
|
-
COALESCE(%s, NOW()), COALESCE(%s, NOW()), %s
|
|
571
|
+
COALESCE(%s, NOW()), COALESCE(%s, NOW()), %s, %s::jsonb
|
|
557
572
|
)
|
|
558
573
|
ON CONFLICT (id) DO UPDATE SET
|
|
559
574
|
aliases = (
|
|
@@ -562,6 +577,9 @@ async def _upsert_entities(
|
|
|
562
577
|
provenance_event_ids = (
|
|
563
578
|
SELECT ARRAY(SELECT DISTINCT UNNEST(entities.provenance_event_ids || EXCLUDED.provenance_event_ids))
|
|
564
579
|
),
|
|
580
|
+
-- merge the email/domain hard key (no-op for the `{}` default
|
|
581
|
+
-- on entity types that don't stamp it); never clobbers.
|
|
582
|
+
attributes = entities.attributes || EXCLUDED.attributes,
|
|
565
583
|
salience = GREATEST(entities.salience, EXCLUDED.salience),
|
|
566
584
|
last_seen = GREATEST(entities.last_seen, EXCLUDED.last_seen),
|
|
567
585
|
first_seen = LEAST(entities.first_seen, EXCLUDED.first_seen)
|
|
@@ -569,7 +587,8 @@ async def _upsert_entities(
|
|
|
569
587
|
(e["id"], e["arena"], e["entity_type"], e["canonical_name"],
|
|
570
588
|
e["aliases"], e["provenance_event_ids"],
|
|
571
589
|
e["participant_set"], e["disclosure_class"],
|
|
572
|
-
event_time, event_time, _sal
|
|
590
|
+
event_time, event_time, _sal,
|
|
591
|
+
json.dumps(e.get("attributes") or {})),
|
|
573
592
|
)
|
|
574
593
|
|
|
575
594
|
|