@pentatonic-ai/ai-agent-sdk 0.10.22 → 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_structured_edges.py +471 -0
- package/packages/memory-engine-v2/extractor-async/worker.py +424 -4
- 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
|
@@ -1302,6 +1302,396 @@ def _email_plausibly_belongs(person_name: str, email: str) -> bool:
|
|
|
1302
1302
|
return False
|
|
1303
1303
|
|
|
1304
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
|
+
|
|
1305
1695
|
def upsert_entities(
|
|
1306
1696
|
conn: psycopg.Connection,
|
|
1307
1697
|
arena: str,
|
|
@@ -1519,6 +1909,19 @@ def upsert_entities(
|
|
|
1519
1909
|
elif etype == "org":
|
|
1520
1910
|
id_key = org_node_id_key(etype, name, stamped_domain)
|
|
1521
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
|
|
1522
1925
|
else:
|
|
1523
1926
|
id_key = name
|
|
1524
1927
|
has_hard_key = False
|
|
@@ -1762,21 +2165,27 @@ def upsert_relationships(
|
|
|
1762
2165
|
if not from_id or not to_id or not rtype:
|
|
1763
2166
|
continue
|
|
1764
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 {}
|
|
1765
2173
|
cur.execute(
|
|
1766
2174
|
"""
|
|
1767
2175
|
INSERT INTO relationships (
|
|
1768
2176
|
id, arena, from_entity_id, to_entity_id, relationship_type,
|
|
1769
2177
|
weight, provenance_event_ids, participant_set, disclosure_class,
|
|
1770
|
-
first_seen, last_seen
|
|
2178
|
+
attributes, first_seen, last_seen
|
|
1771
2179
|
) VALUES (
|
|
1772
2180
|
%s, %s, %s, %s, %s, %s, %s, %s, %s::disclosure_class,
|
|
1773
|
-
COALESCE(%s, NOW()), COALESCE(%s, NOW())
|
|
2181
|
+
%s::jsonb, COALESCE(%s, NOW()), COALESCE(%s, NOW())
|
|
1774
2182
|
)
|
|
1775
2183
|
ON CONFLICT (id) DO UPDATE SET
|
|
1776
2184
|
weight = relationships.weight + EXCLUDED.weight,
|
|
1777
2185
|
provenance_event_ids = (
|
|
1778
2186
|
SELECT ARRAY(SELECT DISTINCT UNNEST(relationships.provenance_event_ids || EXCLUDED.provenance_event_ids))
|
|
1779
2187
|
),
|
|
2188
|
+
attributes = relationships.attributes || EXCLUDED.attributes,
|
|
1780
2189
|
last_seen = GREATEST(relationships.last_seen, EXCLUDED.last_seen),
|
|
1781
2190
|
first_seen = LEAST(relationships.first_seen, EXCLUDED.first_seen)
|
|
1782
2191
|
""",
|
|
@@ -1784,6 +2193,7 @@ def upsert_relationships(
|
|
|
1784
2193
|
rid, arena, from_id, to_id, rtype,
|
|
1785
2194
|
float(r.get("confidence") or 0.5),
|
|
1786
2195
|
[event_id], participant_set, disclosure_class,
|
|
2196
|
+
json.dumps(edge_attrs),
|
|
1787
2197
|
event_time, event_time,
|
|
1788
2198
|
),
|
|
1789
2199
|
)
|
|
@@ -2364,9 +2774,18 @@ def _apply_extraction(
|
|
|
2364
2774
|
# `attributes.source` else coarse `source_kind`; None ⇒ column stays NULL.
|
|
2365
2775
|
src = fact_source(event)
|
|
2366
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
|
+
|
|
2367
2786
|
try:
|
|
2368
2787
|
name_to_id = upsert_entities(
|
|
2369
|
-
conn, arena, event_id, participant_set, disclosure, ents,
|
|
2788
|
+
conn, arena, event_id, participant_set, disclosure, ents + det_ents,
|
|
2370
2789
|
event_time, event.get("attributes"),
|
|
2371
2790
|
)
|
|
2372
2791
|
n_facts = upsert_facts(
|
|
@@ -2374,13 +2793,14 @@ def _apply_extraction(
|
|
|
2374
2793
|
event_time, due_at, src,
|
|
2375
2794
|
)
|
|
2376
2795
|
n_rels = upsert_relationships(
|
|
2377
|
-
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,
|
|
2378
2797
|
event_time,
|
|
2379
2798
|
)
|
|
2380
2799
|
mark_done(conn, queue_id)
|
|
2381
2800
|
log.info(
|
|
2382
2801
|
f"completed queue_id={queue_id} event_id={event_id} producer={producer} "
|
|
2383
2802
|
f"entities={len(name_to_id)} facts={n_facts} relationships={n_rels}"
|
|
2803
|
+
f" det_edges={len(det_rels)}"
|
|
2384
2804
|
+ (f" llm_ms={llm_ms:.0f}" if not stub_mode and llm_ms else "")
|
|
2385
2805
|
)
|
|
2386
2806
|
# Trace logging — best-effort. ONLY the teacher produces training gold;
|