@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.
@@ -0,0 +1,471 @@
1
+ """Deterministic structured edges (SEE-490) — the builders that turn an event's
2
+ structured envelope (gmail From/To/Cc, drive author/file) into person↔person and
3
+ person↔doc edges WITHOUT the LLM.
4
+
5
+ Pure-logic tests only (no DB): they exercise the envelope parsing, the canonical
6
+ predicates, the precision guards (self-edge / machine-sender / fan-out cap), the
7
+ hard-key convergence (email/file-id), and the flag/dispatch behaviour. The upsert
8
+ SQL that consumes the dicts is covered by the engine image build+import check in
9
+ CI (and is byte-compiled here by importing worker.py)."""
10
+
11
+ from __future__ import annotations
12
+
13
+ import importlib.util
14
+ from pathlib import Path
15
+
16
+ import pytest
17
+
18
+
19
+ _THIS = Path(__file__).resolve().parent
20
+ _SPEC = importlib.util.spec_from_file_location("extractor_async_worker",
21
+ _THIS / "worker.py")
22
+ assert _SPEC and _SPEC.loader
23
+ worker = importlib.util.module_from_spec(_SPEC)
24
+ try:
25
+ _SPEC.loader.exec_module(worker)
26
+ except ImportError as e:
27
+ pytest.skip(f"extractor-async deps unavailable: {e}", allow_module_level=True)
28
+
29
+
30
+ ARENA = "pip-agents:abc"
31
+
32
+
33
+ def _by_type(ents, etype):
34
+ return [e for e in ents if e["type"] == etype]
35
+
36
+
37
+ def _edge_types(edges):
38
+ return sorted(e["type"] for e in edges)
39
+
40
+
41
+ # ----------------------------------------------------------------------
42
+ # _emails_in — envelope field normalisation
43
+ # ----------------------------------------------------------------------
44
+
45
+ def test_emails_in_json_list():
46
+ assert worker._emails_in(["A@X.com", "b@y.com"]) == ["a@x.com", "b@y.com"]
47
+
48
+
49
+ def test_emails_in_delimited_string():
50
+ assert worker._emails_in("a@x.com, b@y.com; c@z.com") == [
51
+ "a@x.com", "b@y.com", "c@z.com",
52
+ ]
53
+
54
+
55
+ def test_emails_in_name_angle_bracket_form():
56
+ assert worker._emails_in(["Jane Doe <jane@x.com>"]) == ["jane@x.com"]
57
+
58
+
59
+ def test_emails_in_garbage_is_empty():
60
+ assert worker._emails_in(None) == []
61
+ assert worker._emails_in(42) == []
62
+ assert worker._emails_in(["not an email", ""]) == []
63
+
64
+
65
+ # ----------------------------------------------------------------------
66
+ # _is_human_address / _person_node — machine-sender guard + node shape
67
+ # ----------------------------------------------------------------------
68
+
69
+ @pytest.mark.parametrize("addr", [
70
+ "noreply@example.com", "no-reply@example.com", "mailer-daemon@x.com",
71
+ "notifications@github.com", "bounce@sendgrid.net", "noreply+tag@x.com",
72
+ ])
73
+ def test_machine_senders_rejected(addr):
74
+ assert worker._is_human_address(addr) is False
75
+ name, ent = worker._person_node(addr)
76
+ assert (name, ent) == (None, None)
77
+
78
+
79
+ @pytest.mark.parametrize("addr", [
80
+ "philip.mossop@pentatonic.com", "johann@pentatonic.com", "jane@acme.io",
81
+ ])
82
+ def test_real_people_accepted(addr):
83
+ assert worker._is_human_address(addr) is True
84
+ name, ent = worker._person_node(addr)
85
+ assert ent["type"] == "person"
86
+ assert ent["aliases"] == [addr]
87
+
88
+
89
+ def test_person_node_uses_display_name_but_keys_on_email():
90
+ name, ent = worker._person_node("Philip.Mossop@Pentatonic.com", name="Philip Mossop")
91
+ # canonical is the nice display name…
92
+ assert name == "Philip Mossop"
93
+ assert ent["name"] == "Philip Mossop"
94
+ # …but the email (lowercased) is the alias, so person_id_key keys on it.
95
+ assert ent["aliases"] == ["philip.mossop@pentatonic.com"]
96
+
97
+
98
+ def test_person_node_falls_back_to_email_as_canonical():
99
+ name, ent = worker._person_node("recipient@acme.io")
100
+ assert name == "recipient@acme.io"
101
+ assert ent["name"] == "recipient@acme.io"
102
+
103
+
104
+ # ----------------------------------------------------------------------
105
+ # gmail builder — corresponded_with
106
+ # ----------------------------------------------------------------------
107
+
108
+ def _gmail_attrs(**over):
109
+ base = {
110
+ "source": "pip-gmail-ingest",
111
+ "contact_email": "philip.mossop@pentatonic.com",
112
+ "author": "Philip Mossop",
113
+ "to_emails": ["johann@pentatonic.com", "jane@acme.io"],
114
+ "cc_emails": ["cc@partner.com"],
115
+ }
116
+ base.update(over)
117
+ return base
118
+
119
+
120
+ def test_gmail_sender_to_each_recipient():
121
+ ents, edges = worker._gmail_structured_edges(ARENA, _gmail_attrs())
122
+ # 3 recipients ⇒ 3 corresponded_with edges, all from the sender.
123
+ assert _edge_types(edges) == [worker.EDGE_CORRESPONDED] * 3
124
+ assert {e["from"] for e in edges} == {"Philip Mossop"}
125
+ assert {e["to"] for e in edges} == {
126
+ "johann@pentatonic.com", "jane@acme.io", "cc@partner.com",
127
+ }
128
+ # sender + 3 recipient person nodes.
129
+ assert len(_by_type(ents, "person")) == 4
130
+
131
+
132
+ def test_gmail_edges_carry_structured_provenance():
133
+ _ents, edges = worker._gmail_structured_edges(ARENA, _gmail_attrs())
134
+ for e in edges:
135
+ assert e["attributes"] == {"derivation": "structured", "source": "gmail"}
136
+ assert e["confidence"] == 1.0
137
+
138
+
139
+ def test_gmail_no_self_edge_when_sender_also_in_recipients():
140
+ attrs = _gmail_attrs(to_emails=["philip.mossop@pentatonic.com", "johann@pentatonic.com"])
141
+ _ents, edges = worker._gmail_structured_edges(ARENA, attrs)
142
+ assert all(e["to"] != "Philip Mossop" for e in edges)
143
+ assert {e["to"] for e in edges} == {"johann@pentatonic.com", "cc@partner.com"}
144
+
145
+
146
+ def test_gmail_dedupes_repeated_recipient():
147
+ attrs = _gmail_attrs(to_emails=["johann@pentatonic.com", "johann@pentatonic.com"],
148
+ cc_emails=[])
149
+ _ents, edges = worker._gmail_structured_edges(ARENA, attrs)
150
+ assert len(edges) == 1
151
+
152
+
153
+ def test_gmail_skips_machine_recipient():
154
+ attrs = _gmail_attrs(to_emails=["noreply@x.com", "johann@pentatonic.com"], cc_emails=[])
155
+ _ents, edges = worker._gmail_structured_edges(ARENA, attrs)
156
+ assert {e["to"] for e in edges} == {"johann@pentatonic.com"}
157
+
158
+
159
+ def test_gmail_no_sender_yields_nothing():
160
+ attrs = _gmail_attrs(contact_email="", from_email="")
161
+ assert worker._gmail_structured_edges(ARENA, attrs) == ([], [])
162
+
163
+
164
+ def test_gmail_no_recipients_yields_nothing():
165
+ # A sender but no resolvable recipients ⇒ don't mint a lone dangling node.
166
+ attrs = _gmail_attrs(to_emails=[], cc_emails=[])
167
+ assert worker._gmail_structured_edges(ARENA, attrs) == ([], [])
168
+
169
+
170
+ def test_gmail_fanout_cap(monkeypatch):
171
+ monkeypatch.setattr(worker, "STRUCTURED_EDGE_FANOUT_CAP", 5)
172
+ attrs = _gmail_attrs(to_emails=[f"user{i}@acme.io" for i in range(20)], cc_emails=[])
173
+ _ents, edges = worker._gmail_structured_edges(ARENA, attrs)
174
+ assert len(edges) == 5
175
+
176
+
177
+ def test_gmail_endpoint_strings_match_minted_node_names():
178
+ # The edge from/to MUST equal a minted node's `name` — that's the key
179
+ # name_to_id resolves on at upsert. Guard against drift.
180
+ ents, edges = worker._gmail_structured_edges(ARENA, _gmail_attrs())
181
+ names = {e["name"] for e in ents}
182
+ for e in edges:
183
+ assert e["from"] in names
184
+ assert e["to"] in names
185
+
186
+
187
+ # ----------------------------------------------------------------------
188
+ # drive builder — authored
189
+ # ----------------------------------------------------------------------
190
+
191
+ def _drive_attrs(**over):
192
+ base = {
193
+ "source": "pip-drive-ingest",
194
+ "contact_email": "scott.royalty@thewholegroup.com",
195
+ "drive_file_id": "1AbCxyz",
196
+ "drive_file_name": "Q3 Plan.pdf",
197
+ }
198
+ base.update(over)
199
+ return base
200
+
201
+
202
+ def test_drive_author_to_document():
203
+ ents, edges = worker._drive_structured_edges(ARENA, _drive_attrs())
204
+ assert _edge_types(edges) == [worker.EDGE_AUTHORED]
205
+ (edge,) = edges
206
+ assert edge["from"] == "scott.royalty@thewholegroup.com"
207
+ assert edge["to"] == "Q3 Plan.pdf"
208
+ assert edge["attributes"] == {"derivation": "structured", "source": "drive"}
209
+ docs = _by_type(ents, "document")
210
+ assert len(docs) == 1
211
+ # document keyed on the permanent drive file-id (hard key, not the title).
212
+ assert docs[0]["aliases"] == ["drive:1AbCxyz"]
213
+
214
+
215
+ def test_drive_doc_name_falls_back_to_file_id():
216
+ ents, edges = worker._drive_structured_edges(ARENA, _drive_attrs(drive_file_name=""))
217
+ assert edges[0]["to"] == "1AbCxyz"
218
+ assert _by_type(ents, "document")[0]["name"] == "1AbCxyz"
219
+
220
+
221
+ def test_drive_requires_author_and_file():
222
+ assert worker._drive_structured_edges(ARENA, _drive_attrs(contact_email="")) == ([], [])
223
+ assert worker._drive_structured_edges(ARENA, _drive_attrs(drive_file_id="")) == ([], [])
224
+
225
+
226
+ # ----------------------------------------------------------------------
227
+ # structured_graph_elements — dispatch, flag, robustness
228
+ # ----------------------------------------------------------------------
229
+
230
+ def test_dispatch_routes_by_source():
231
+ g_ents, g_edges = worker.structured_graph_elements(ARENA, _gmail_attrs())
232
+ assert g_edges and g_edges[0]["type"] == worker.EDGE_CORRESPONDED
233
+ d_ents, d_edges = worker.structured_graph_elements(ARENA, _drive_attrs())
234
+ assert d_edges and d_edges[0]["type"] == worker.EDGE_AUTHORED
235
+
236
+
237
+ def test_unknown_source_is_noop():
238
+ assert worker.structured_graph_elements(ARENA, {"source": "pip-slack-ingest"}) == ([], [])
239
+ assert worker.structured_graph_elements(ARENA, {}) == ([], [])
240
+ assert worker.structured_graph_elements(ARENA, None) == ([], [])
241
+
242
+
243
+ def test_flag_off_disables(monkeypatch):
244
+ monkeypatch.setattr(worker, "STRUCTURED_EDGES_ENABLED", False)
245
+ assert worker.structured_graph_elements(ARENA, _gmail_attrs()) == ([], [])
246
+
247
+
248
+ def test_builder_exception_is_swallowed(monkeypatch):
249
+ def boom(arena, attrs):
250
+ raise ValueError("kaboom")
251
+ monkeypatch.setitem(worker._STRUCTURED_EDGE_BUILDERS, "pip-gmail-ingest", boom)
252
+ # must not propagate — the distill path can't be broken by a bad builder.
253
+ assert worker.structured_graph_elements(ARENA, _gmail_attrs()) == ([], [])
254
+
255
+
256
+ # ----------------------------------------------------------------------
257
+ # slack builder — member_of + mentioned
258
+ # ----------------------------------------------------------------------
259
+
260
+ def _slack_attrs(**over):
261
+ base = {
262
+ "source": "pip-slack-ingest",
263
+ "contact_email": "philip.mossop@pentatonic.com",
264
+ "author": "Philip | Pentatonic",
265
+ "channel_id": "C0BA2PCEQGN",
266
+ "channel_name": "seesa-engineering",
267
+ "mentions": ["alex.tong@pentatonic.com", "ben.gordon@pentatonic.com"],
268
+ }
269
+ base.update(over)
270
+ return base
271
+
272
+
273
+ def test_slack_member_of_channel():
274
+ ents, edges = worker._slack_structured_edges(ARENA, _slack_attrs())
275
+ member = [e for e in edges if e["type"] == worker.EDGE_MEMBER_OF]
276
+ assert len(member) == 1
277
+ assert member[0]["from"] == "Philip | Pentatonic"
278
+ assert member[0]["to"] == "seesa-engineering"
279
+ # channel node keyed on the permanent channel-id.
280
+ chans = _by_type(ents, "channel")
281
+ assert chans[0]["aliases"] == ["slack:channel:C0BA2PCEQGN"]
282
+
283
+
284
+ def test_slack_mentions_each_person():
285
+ _ents, edges = worker._slack_structured_edges(ARENA, _slack_attrs())
286
+ mentioned = sorted(e["to"] for e in edges if e["type"] == worker.EDGE_MENTIONED)
287
+ assert mentioned == ["alex.tong@pentatonic.com", "ben.gordon@pentatonic.com"]
288
+ assert all(e["from"] == "Philip | Pentatonic" for e in edges if e["type"] == worker.EDGE_MENTIONED)
289
+
290
+
291
+ def test_slack_channel_name_falls_back_to_id():
292
+ _ents, edges = worker._slack_structured_edges(ARENA, _slack_attrs(channel_name=""))
293
+ member = [e for e in edges if e["type"] == worker.EDGE_MEMBER_OF]
294
+ assert member[0]["to"] == "C0BA2PCEQGN"
295
+
296
+
297
+ def test_slack_no_author_email_yields_nothing():
298
+ assert worker._slack_structured_edges(ARENA, _slack_attrs(contact_email="")) == ([], [])
299
+
300
+
301
+ def test_slack_channel_only_no_mentions():
302
+ ents, edges = worker._slack_structured_edges(ARENA, _slack_attrs(mentions=[]))
303
+ assert _edge_types(edges) == [worker.EDGE_MEMBER_OF]
304
+
305
+
306
+ def test_slack_self_mention_dropped():
307
+ attrs = _slack_attrs(mentions=["philip.mossop@pentatonic.com"], channel_id="")
308
+ assert worker._slack_structured_edges(ARENA, attrs) == ([], [])
309
+
310
+
311
+ def test_slack_provenance_stamp():
312
+ _ents, edges = worker._slack_structured_edges(ARENA, _slack_attrs())
313
+ for e in edges:
314
+ assert e["attributes"] == {"derivation": "structured", "source": "slack"}
315
+
316
+
317
+ # ----------------------------------------------------------------------
318
+ # granola builder — attended
319
+ # ----------------------------------------------------------------------
320
+
321
+ def _granola_attrs(**over):
322
+ base = {
323
+ "source": "pip-granola-ingest",
324
+ "thread_id": "bacf4ffc-cad9-4223-9513-8d02a6c01c2d",
325
+ "title": "TES Daily standup",
326
+ "attendees": ["philip.mossop@pentatonic.com", "phil.hauser@pentatonic.com"],
327
+ }
328
+ base.update(over)
329
+ return base
330
+
331
+
332
+ def test_granola_attendees_to_meeting():
333
+ ents, edges = worker._granola_structured_edges(ARENA, _granola_attrs())
334
+ assert _edge_types(edges) == [worker.EDGE_ATTENDED, worker.EDGE_ATTENDED]
335
+ assert {e["to"] for e in edges} == {"TES Daily standup"}
336
+ assert {e["from"] for e in edges} == {
337
+ "philip.mossop@pentatonic.com", "phil.hauser@pentatonic.com",
338
+ }
339
+ meeting = _by_type(ents, "event")
340
+ assert len(meeting) == 1
341
+ # keyed on the granola doc id, NOT the (recurring) title.
342
+ assert meeting[0]["aliases"] == ["meeting:bacf4ffc-cad9-4223-9513-8d02a6c01c2d"]
343
+
344
+
345
+ def test_granola_requires_meeting_id_and_attendees():
346
+ assert worker._granola_structured_edges(ARENA, _granola_attrs(thread_id="")) == ([], [])
347
+ assert worker._granola_structured_edges(ARENA, _granola_attrs(attendees=[])) == ([], [])
348
+
349
+
350
+ def test_granola_dedupes_attendee():
351
+ attrs = _granola_attrs(attendees=["a@x.com", "a@x.com", "b@y.com"])
352
+ _ents, edges = worker._granola_structured_edges(ARENA, attrs)
353
+ assert len(edges) == 2
354
+
355
+
356
+ # ----------------------------------------------------------------------
357
+ # calendar builder — attended (keyed on source_id; reads attendees[])
358
+ # ----------------------------------------------------------------------
359
+
360
+ def _calendar_attrs(**over):
361
+ base = {
362
+ "source": "pip-calendar-ingest",
363
+ "source_id": "gcal-545npq4q",
364
+ "title": "Mastercard visits Pentatonic",
365
+ "attendees": ["philip@pentatonic.com", "yan@mastercard.com"],
366
+ }
367
+ base.update(over)
368
+ return base
369
+
370
+
371
+ def test_calendar_attendees_to_meeting_keyed_on_source_id():
372
+ ents, edges = worker._calendar_structured_edges(ARENA, _calendar_attrs())
373
+ assert _edge_types(edges) == [worker.EDGE_ATTENDED, worker.EDGE_ATTENDED]
374
+ assert {e["to"] for e in edges} == {"Mastercard visits Pentatonic"}
375
+ meeting = _by_type(ents, "event")
376
+ assert meeting[0]["aliases"] == ["meeting:gcal-545npq4q"]
377
+ for e in edges:
378
+ assert e["attributes"] == {"derivation": "structured", "source": "calendar"}
379
+
380
+
381
+ def test_calendar_noop_without_structured_attendees():
382
+ # un-upgraded events (pre-SEE-506) have no attendees[] → no edges.
383
+ assert worker._calendar_structured_edges(ARENA, _calendar_attrs(attendees=None)) == ([], [])
384
+ assert worker.structured_graph_elements(
385
+ ARENA, {"source": "pip-calendar-ingest", "source_id": "x"}
386
+ ) == ([], [])
387
+
388
+
389
+ def test_calendar_dispatches():
390
+ _e, edges = worker.structured_graph_elements(ARENA, _calendar_attrs())
391
+ # attended for both attendees; plus employed_by for the corporate one
392
+ # (yan@mastercard.com — philip@pentatonic.com is the tenant, excluded).
393
+ assert worker.EDGE_ATTENDED in {e["type"] for e in edges}
394
+ eb = [e for e in edges if e["type"] == worker.EDGE_EMPLOYED_BY]
395
+ assert {e["to"] for e in eb} == {"mastercard"}
396
+
397
+
398
+ # ----------------------------------------------------------------------
399
+ # employed_by — person → org by email domain (derived post-pass)
400
+ # ----------------------------------------------------------------------
401
+
402
+ def _person(email, name=None):
403
+ return {"type": "person", "name": name or email, "aliases": [email]}
404
+
405
+
406
+ def test_employed_by_corporate_only():
407
+ ents = [
408
+ _person("philip@pentatonic.com", "Philip"), # tenant → excluded
409
+ _person("jane@acme.io"), # corporate → acme
410
+ _person("bob@gmail.com"), # freemail → excluded
411
+ ]
412
+ org_ents, edges = worker._employed_by_elements(ents, "gmail")
413
+ assert [o["name"] for o in org_ents] == ["acme"]
414
+ assert org_ents[0]["aliases"] == ["acme.io"]
415
+ assert len(edges) == 1
416
+ assert edges[0]["from"] == "jane@acme.io"
417
+ assert edges[0]["to"] == "acme"
418
+ assert edges[0]["type"] == worker.EDGE_EMPLOYED_BY
419
+ assert edges[0]["attributes"] == {"derivation": "structured", "source": "gmail"}
420
+
421
+
422
+ def test_employed_by_dedupes_org_and_edge():
423
+ ents = [_person("a@acme.io"), _person("b@acme.io"), _person("a@acme.io")]
424
+ org_ents, edges = worker._employed_by_elements(ents, "gmail")
425
+ assert len(org_ents) == 1 # one acme org node
426
+ assert sorted(e["from"] for e in edges) == ["a@acme.io", "b@acme.io"]
427
+
428
+
429
+ def test_employed_by_ignores_non_person():
430
+ ents = [{"type": "document", "name": "x", "aliases": ["drive:1"]}]
431
+ assert worker._employed_by_elements(ents, "drive") == ([], [])
432
+
433
+
434
+ def test_gmail_dispatch_adds_employed_by():
435
+ # Through structured_graph_elements: gmail recipients on corporate domains
436
+ # get employed_by edges alongside corresponded_with.
437
+ _ents, edges = worker.structured_graph_elements(ARENA, _gmail_attrs())
438
+ types = {e["type"] for e in edges}
439
+ assert worker.EDGE_CORRESPONDED in types
440
+ assert worker.EDGE_EMPLOYED_BY in types
441
+ # jane@acme.io and cc@partner.com are corporate; pentatonic is tenant.
442
+ eb = {e["to"] for e in edges if e["type"] == worker.EDGE_EMPLOYED_BY}
443
+ assert eb == {"acme", "partner"}
444
+
445
+
446
+ def test_employed_by_flag_off(monkeypatch):
447
+ monkeypatch.setattr(worker, "STRUCTURED_EMPLOYED_BY_ENABLED", False)
448
+ _ents, edges = worker.structured_graph_elements(ARENA, _gmail_attrs())
449
+ assert all(e["type"] != worker.EDGE_EMPLOYED_BY for e in edges)
450
+ # corresponded_with still present.
451
+ assert any(e["type"] == worker.EDGE_CORRESPONDED for e in edges)
452
+
453
+
454
+ # ----------------------------------------------------------------------
455
+ # dispatch covers the P2 sources too
456
+ # ----------------------------------------------------------------------
457
+
458
+ def test_dispatch_routes_slack_and_granola():
459
+ _e, slack_edges = worker.structured_graph_elements(ARENA, _slack_attrs())
460
+ assert worker.EDGE_MEMBER_OF in {e["type"] for e in slack_edges}
461
+ _e, gran_edges = worker.structured_graph_elements(ARENA, _granola_attrs())
462
+ assert {e["type"] for e in gran_edges} == {worker.EDGE_ATTENDED}
463
+
464
+
465
+ def test_container_endpoint_strings_match_minted_nodes():
466
+ for attrs in (_slack_attrs(), _granola_attrs()):
467
+ ents, edges = worker.structured_graph_elements(ARENA, attrs)
468
+ names = {e["name"] for e in ents}
469
+ for e in edges:
470
+ assert e["from"] in names
471
+ assert e["to"] in names