@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,216 @@
1
+ #!/usr/bin/env python3
2
+ """Backfill person `attributes.email` + `attributes.domain` from existing alias
3
+ emails (SEE-192). DRY-RUN by default.
4
+
5
+ WHY: upsert_entities now stamps a person node's own email/domain as a queryable
6
+ attribute at extract time (worker.py / server.py, SDK >=0.10.22), but the
7
+ ~38k EXISTING person nodes minted before that carry the email only inside the
8
+ `aliases` array (0 had `attributes.email`). This one-shot populates them so
9
+ retrieval + context-aware mention resolution (SEE-346 Layer B Phase 2) can match
10
+ on a discrete email/domain without waiting for natural re-distill.
11
+
12
+ ADDITIVE + NON-DESTRUCTIVE: only ever does `attributes = attributes || jsonb`
13
+ (merge, never clobbers an existing key), only on rows that LACK `attributes.email`
14
+ (idempotent), never deletes/merges/tombstones a node. So unlike fusion_defrag it
15
+ needs no snapshot gate — but it's still dry-run-first and arena-scoped.
16
+
17
+ THE §A GUARD (don't stamp a bystander/fabricated email onto a person):
18
+ 1. `_email_plausibly_belongs` (verbatim from worker.py #128) — the email's
19
+ local-part must contain a name token or match the person's initials. Drops
20
+ the over-merge pollution (a hotel/newsletter/stranger's gmail folded onto
21
+ the node).
22
+ 2. Placeholder/fabricated domains (example.com, test, localhost, …) are
23
+ dropped even if the local-part matches the name — `katrin.bauer@example.com`
24
+ (0 source events, extractor-invented) passes #128 but must NOT be stamped.
25
+ 3. AMBIGUITY: if a node has >=2 plausible, non-placeholder emails spanning
26
+ DIFFERENT domains, SKIP it (leave to the resolution pass) — never guess
27
+ which is the person's real one.
28
+
29
+ Usage (run on the engine box / anywhere with org_model reach):
30
+ python backfill_person_email_attr.py --arena 'pentatonic-team:%' # dry-run report
31
+ python backfill_person_email_attr.py --arena 'pentatonic-team:%' --json out.json
32
+ python backfill_person_email_attr.py --arena 'pentatonic-team:%' --apply # write
33
+ PG_DSN env or --pg-dsn. --arena is REQUIRED (never defaults to all arenas;
34
+ pip-agents is frozen legacy — scope deliberately).
35
+ """
36
+ from __future__ import annotations
37
+
38
+ import argparse
39
+ import json
40
+ import os
41
+ import re
42
+ import sys
43
+
44
+
45
+ def _connect(dsn: str):
46
+ import psycopg
47
+ import psycopg.rows
48
+
49
+ return psycopg.connect(dsn, row_factory=psycopg.rows.dict_row)
50
+
51
+
52
+ # --- helpers REPLICATED from extractor-async/worker.py (per the build-context
53
+ # split, scripts can't import the extractor cleanly — keep these BYTE-FAITHFUL
54
+ # to worker.py `_email_plausibly_belongs` / `_domain_of`; a drift here re-opens
55
+ # the bystander-pollution the #128 guard closes). ---------------------------
56
+ _ALIAS_NONALPHA = re.compile(r"[^a-z]")
57
+ _ALIAS_SPLIT = re.compile(r"[^a-z]+")
58
+ _EMAIL_DOMAIN_RE = re.compile(r"@([A-Za-z0-9.-]+\.[A-Za-z]{2,})")
59
+
60
+ # Placeholder / non-routable domains an extractor may have invented by templating
61
+ # `firstname.lastname@<org-in-context>` — these have ~0 real provenance and must
62
+ # never be stamped as a person's email (SEE-341 §A fabrication mode).
63
+ _PLACEHOLDER_DOMAINS = {
64
+ "example.com", "example.org", "example.net", "example.edu",
65
+ "test.com", "test", "localhost", "invalid", "none.com", "email.com",
66
+ "domain.com", "company.com", "yourcompany.com", "acme.com",
67
+ }
68
+
69
+
70
+ def _email_plausibly_belongs(person_name: str, email: str) -> bool:
71
+ """VERBATIM from worker.py #128. True ⇒ the email plausibly belongs to
72
+ person_name (name token in local-part, or local-part is the initials)."""
73
+ local = email.split("@", 1)[0].lower()
74
+ local_letters = _ALIAS_NONALPHA.sub("", local)
75
+ name_tokens = {t for t in _ALIAS_SPLIT.split(person_name.lower()) if len(t) >= 2}
76
+ if not name_tokens or not local_letters:
77
+ return True # nothing to check against — don't over-filter
78
+ if any(nt in local_letters for nt in name_tokens):
79
+ return True
80
+ initials = "".join(t[0] for t in person_name.lower().split() if t[:1].isalpha())
81
+ if len(initials) >= 2 and local_letters in (initials, initials[::-1]):
82
+ return True
83
+ return False
84
+
85
+
86
+ def _domain_of(value: str) -> str | None:
87
+ """Lowercased domain of an email in `value`, else None. (worker.py)."""
88
+ if not value or "@" not in value:
89
+ return None
90
+ m = _EMAIL_DOMAIN_RE.search(value)
91
+ return m.group(1).lower() if m else None
92
+
93
+
94
+ def _is_bare_email(a: str) -> bool:
95
+ return bool(a) and "@" in a and " " not in a
96
+
97
+
98
+ def pick_person_email(
99
+ canonical_name: str, aliases: list[str]
100
+ ) -> tuple[str | None, str | None, str]:
101
+ """PURE. Choose the email/domain to stamp on a person from its aliases.
102
+
103
+ Returns `(email_lower, domain, reason)`. `email` is None when nothing safe to
104
+ stamp; `reason` is one of: stamped | none (no email alias) | no_plausible
105
+ (emails exist but none pass #128) | placeholder (only placeholder/fabricated)
106
+ | ambiguous (>=2 distinct real domains — leave to resolution).
107
+ """
108
+ emails = [a.strip().lower() for a in (aliases or []) if _is_bare_email(a)]
109
+ if not emails:
110
+ return None, None, "none"
111
+ # de-dupe, keep order
112
+ seen: set[str] = set()
113
+ emails = [e for e in emails if not (e in seen or seen.add(e))]
114
+ plausible = [e for e in emails if _email_plausibly_belongs(canonical_name, e)]
115
+ if not plausible:
116
+ return None, None, "no_plausible"
117
+ real = [e for e in plausible if (_domain_of(e) or "") not in _PLACEHOLDER_DOMAINS]
118
+ if not real:
119
+ return None, None, "placeholder"
120
+ domains = {_domain_of(e) for e in real}
121
+ if len(domains) >= 2:
122
+ return None, None, "ambiguous"
123
+ # one real domain (1+ addresses on it) → stamp the shortest local-part as the
124
+ # canonical address (e.g. johann@ over johann.boedecker@), its domain.
125
+ email = sorted(real, key=lambda e: (len(e), e))[0]
126
+ return email, _domain_of(email), "stamped"
127
+
128
+
129
+ def main() -> int:
130
+ ap = argparse.ArgumentParser(
131
+ description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
132
+ )
133
+ ap.add_argument("--arena", required=True, help="arena LIKE filter (REQUIRED)")
134
+ ap.add_argument("--pg-dsn", default="", help="Postgres DSN (or PG_DSN env)")
135
+ ap.add_argument("--json", help="write the per-row plan + summary as JSON")
136
+ ap.add_argument("--limit", type=int, default=0, help="cap rows scanned (0 = all)")
137
+ ap.add_argument(
138
+ "--apply",
139
+ action="store_true",
140
+ help="WRITE the attributes (default: dry-run report only)",
141
+ )
142
+ args = ap.parse_args()
143
+
144
+ dsn = args.pg_dsn or os.environ.get("PG_DSN", "")
145
+ if not dsn:
146
+ print("FATAL: no --pg-dsn / PG_DSN", file=sys.stderr)
147
+ return 2
148
+
149
+ counts = {"stamped": 0, "none": 0, "no_plausible": 0, "placeholder": 0, "ambiguous": 0}
150
+ samples: list[dict] = []
151
+ plan: list[tuple[str, str, str]] = [] # (id, email, domain) to write
152
+
153
+ with _connect(dsn) as conn:
154
+ with conn.cursor() as cur:
155
+ if not args.apply:
156
+ cur.execute("SET default_transaction_read_only = on") # dry-run safety
157
+ sql = (
158
+ "SELECT id, canonical_name, aliases FROM entities "
159
+ "WHERE arena LIKE %s AND entity_type = 'person' "
160
+ "AND NOT (attributes ? 'email')"
161
+ )
162
+ if args.limit:
163
+ sql += f" LIMIT {int(args.limit)}"
164
+ cur.execute(sql, (args.arena,))
165
+ rows = cur.fetchall()
166
+ print(f"scanning {len(rows)} person nodes lacking attributes.email "
167
+ f"(arena LIKE {args.arena!r})")
168
+
169
+ for r in rows:
170
+ email, domain, reason = pick_person_email(
171
+ r["canonical_name"] or "", r["aliases"] or []
172
+ )
173
+ counts[reason] += 1
174
+ if reason == "stamped" and email:
175
+ plan.append((r["id"], email, domain or ""))
176
+ if len(samples) < 15:
177
+ samples.append({
178
+ "name": r["canonical_name"], "email": email, "domain": domain,
179
+ })
180
+
181
+ if args.apply and plan:
182
+ for eid, email, domain in plan:
183
+ patch = {"email": email}
184
+ if domain:
185
+ patch["domain"] = domain
186
+ cur.execute(
187
+ "UPDATE entities SET attributes = attributes || %s::jsonb "
188
+ "WHERE id = %s AND NOT (attributes ? 'email')",
189
+ (json.dumps(patch), eid),
190
+ )
191
+ conn.commit()
192
+ else:
193
+ conn.rollback()
194
+
195
+ total = sum(counts.values())
196
+ print("\n=== backfill summary ===")
197
+ print(f" total scanned : {total}")
198
+ for k in ("stamped", "ambiguous", "placeholder", "no_plausible", "none"):
199
+ print(f" {k:13}: {counts[k]}")
200
+ print("\n sample stamps:")
201
+ for s in samples:
202
+ print(f" {s['name']!r:40} -> {s['email']} ({s['domain']})")
203
+ print(f"\n{'APPLIED' if args.apply else 'DRY-RUN (no writes; rerun with --apply)'}: "
204
+ f"{counts['stamped']} nodes "
205
+ f"{'updated' if args.apply else 'would be stamped'}.")
206
+
207
+ if args.json:
208
+ with open(args.json, "w") as f:
209
+ json.dump({"counts": counts, "samples": samples,
210
+ "applied": bool(args.apply)}, f, indent=2)
211
+ print(f" wrote {args.json}")
212
+ return 0
213
+
214
+
215
+ if __name__ == "__main__":
216
+ sys.exit(main())
@@ -0,0 +1,84 @@
1
+ """Guards `pick_person_email` (SEE-192 backfill) against stamping the WRONG
2
+ email on a person. Existing nodes carry polluted aliases (the over-merge), so the
3
+ backfill must reuse the #128 plausibility guard AND additionally reject
4
+ placeholder/fabricated domains and genuine ambiguity — pure-logic tests, no DB."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import importlib
9
+ import os
10
+ import sys
11
+
12
+ HERE = os.path.dirname(__file__)
13
+
14
+
15
+ def _load():
16
+ sys.path.insert(0, HERE)
17
+ sys.modules.pop("backfill_person_email_attr", None)
18
+ return importlib.import_module("backfill_person_email_attr")
19
+
20
+
21
+ bk = _load()
22
+ pick = bk.pick_person_email
23
+
24
+
25
+ def test_stamps_the_persons_own_email_drops_bystanders() -> None:
26
+ # the live Johann case: own email + a hotel + a stranger gmail co-folded.
27
+ assert pick(
28
+ "Johann Boedecker",
29
+ ["Johann Boedecker", "johann@pentatonic.com", "reservations.nyc@acehotel.com"],
30
+ ) == ("johann@pentatonic.com", "pentatonic.com", "stamped")
31
+
32
+
33
+ def test_bystander_only_node_is_not_stamped() -> None:
34
+ # over-merge pollution with NO plausible own-email → stamp nothing.
35
+ assert pick(
36
+ "Johann Boedecker",
37
+ ["reservations.nyc@acehotel.com", "martinvasquez87@gmail.com"],
38
+ ) == (None, None, "no_plausible")
39
+
40
+
41
+ def test_fabricated_placeholder_domain_rejected() -> None:
42
+ # katrin.bauer@example.com passes #128 (name in local-part) but is the
43
+ # extractor-invented placeholder (0 source events) — must NOT be stamped.
44
+ assert pick("Katrin Bauer", ["Katrin Bauer", "katrin.bauer@example.com"]) == (
45
+ None,
46
+ None,
47
+ "placeholder",
48
+ )
49
+
50
+
51
+ def test_two_distinct_real_domains_is_ambiguous_skip() -> None:
52
+ assert pick(
53
+ "Katie Cooper",
54
+ ["katie.cooper@sosv.com", "katie.cooper@booking.com"],
55
+ ) == (None, None, "ambiguous")
56
+
57
+
58
+ def test_no_email_alias() -> None:
59
+ assert pick("Will Vickers", ["Will Vickers", "William F. Vickers"]) == (
60
+ None,
61
+ None,
62
+ "none",
63
+ )
64
+
65
+
66
+ def test_prefers_shortest_local_on_one_domain() -> None:
67
+ assert pick(
68
+ "Johann Boedecker",
69
+ ["johann.boedecker@pentatonic.com", "johann@pentatonic.com"],
70
+ ) == ("johann@pentatonic.com", "pentatonic.com", "stamped")
71
+
72
+
73
+ def test_initials_local_part() -> None:
74
+ assert pick("Johann Boedecker", ["jb@pentatonic.com"]) == (
75
+ "jb@pentatonic.com",
76
+ "pentatonic.com",
77
+ "stamped",
78
+ )
79
+
80
+
81
+ def test_display_name_form_is_not_a_bare_address() -> None:
82
+ # graph aliases carry the bare email; a "X <a@b>" form (has a space) is not
83
+ # treated as the address — matches person_id_key selection semantics.
84
+ assert pick("Bob Smith", ["Bob Smith <bob@lego.com>"]) == (None, None, "none")