@pentatonic-ai/ai-agent-sdk 0.10.22 → 0.10.24

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.
@@ -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;