@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.
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/packages/memory-engine-v2/extractor-async/Dockerfile +5 -0
- package/packages/memory-engine-v2/extractor-async/backfill_structured_edges.py +221 -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
|
@@ -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
|