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