@pentatonic-ai/ai-agent-sdk 0.9.6 → 0.10.0

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.
Files changed (127) hide show
  1. package/README.md +3 -3
  2. package/bin/cli.js +1 -1
  3. package/bin/commands/config.js +1 -1
  4. package/dist/index.cjs +1 -1
  5. package/dist/index.js +1 -1
  6. package/package.json +2 -2
  7. package/packages/doctor/src/checks/local-memory.js +2 -2
  8. package/packages/memory/README.md +2 -2
  9. package/packages/memory/openclaw-plugin/README.md +2 -2
  10. package/packages/memory/openclaw-plugin/openclaw.plugin.json +1 -1
  11. package/packages/memory/src/server.js +2 -2
  12. package/packages/memory-engine-v2/.env.example +30 -0
  13. package/packages/memory-engine-v2/README.md +125 -0
  14. package/packages/memory-engine-v2/compat/Dockerfile +11 -0
  15. package/packages/memory-engine-v2/compat/requirements.txt +6 -0
  16. package/packages/memory-engine-v2/compat/server.py +1047 -0
  17. package/packages/memory-engine-v2/docker-compose.aws.yml +78 -0
  18. package/packages/memory-engine-v2/docker-compose.yml +206 -0
  19. package/packages/memory-engine-v2/extractor-async/Dockerfile +14 -0
  20. package/packages/memory-engine-v2/extractor-async/confidence.py +62 -0
  21. package/packages/memory-engine-v2/extractor-async/noise_filter.py +144 -0
  22. package/packages/memory-engine-v2/extractor-async/requirements.txt +2 -0
  23. package/packages/memory-engine-v2/extractor-async/test_confidence.py +76 -0
  24. package/packages/memory-engine-v2/extractor-async/test_noise_filter.py +177 -0
  25. package/packages/memory-engine-v2/extractor-async/worker.py +797 -0
  26. package/packages/memory-engine-v2/extractor-sync/Dockerfile +11 -0
  27. package/packages/memory-engine-v2/extractor-sync/requirements.txt +4 -0
  28. package/packages/memory-engine-v2/extractor-sync/server.py +424 -0
  29. package/packages/memory-engine-v2/org-model/migrations/001_init.sql +390 -0
  30. package/packages/memory-engine-v2/tests/e2e_smoke.py +356 -0
  31. package/packages/memory-engine-v2/tests/fixtures/generate_synthetic_corpus.py +758 -0
  32. package/packages/memory-engine/.env.example +0 -13
  33. package/packages/memory-engine/MIGRATION.md +0 -219
  34. package/packages/memory-engine/README.md +0 -145
  35. package/packages/memory-engine/bench/README.md +0 -99
  36. package/packages/memory-engine/bench/scorecards-engine/agent-coding__pentatonic-baseline__20260427-142523.json +0 -1115
  37. package/packages/memory-engine/bench/scorecards-engine/chat-recall__pentatonic-baseline__20260427-142648.json +0 -819
  38. package/packages/memory-engine/bench/scorecards-engine/circular-economy__pentatonic-baseline__20260427-142757.json +0 -1278
  39. package/packages/memory-engine/bench/scorecards-engine/customer-support__pentatonic-baseline__20260427-142900.json +0 -1018
  40. package/packages/memory-engine/bench/scorecards-engine/marketplace-ops__pentatonic-baseline__20260427-142957.json +0 -1038
  41. package/packages/memory-engine/bench/scorecards-engine/product-catalogue__pentatonic-baseline__20260427-143122.json +0 -961
  42. package/packages/memory-engine/bench/scorecards-engine-via-docker/agent-coding__pentatonic-memory__20260427-161812.json +0 -1115
  43. package/packages/memory-engine/bench/scorecards-engine-via-docker/chat-recall__pentatonic-memory__20260427-161701.json +0 -819
  44. package/packages/memory-engine/bench/scorecards-engine-via-docker/circular-economy__pentatonic-memory__20260427-161713.json +0 -1278
  45. package/packages/memory-engine/bench/scorecards-engine-via-docker/customer-support__pentatonic-memory__20260427-161723.json +0 -1018
  46. package/packages/memory-engine/bench/scorecards-engine-via-docker/marketplace-ops__pentatonic-memory__20260427-161732.json +0 -1038
  47. package/packages/memory-engine/bench/scorecards-engine-via-docker/product-catalogue__pentatonic-memory__20260427-161741.json +0 -937
  48. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/agent-coding__pentatonic-memory__20260427-184718.json +0 -1115
  49. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/chat-recall__pentatonic-memory__20260427-184614.json +0 -819
  50. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/circular-economy__pentatonic-memory__20260427-184809.json +0 -1278
  51. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/customer-support__pentatonic-memory__20260427-184854.json +0 -1018
  52. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/marketplace-ops__pentatonic-memory__20260427-184929.json +0 -1038
  53. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/product-catalogue__pentatonic-memory__20260427-185015.json +0 -961
  54. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/agent-coding__pentatonic-memory__20260427-175252.json +0 -1115
  55. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/chat-recall__pentatonic-memory__20260427-175312.json +0 -819
  56. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/circular-economy__pentatonic-memory__20260427-175335.json +0 -1278
  57. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/customer-support__pentatonic-memory__20260427-175355.json +0 -1018
  58. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/marketplace-ops__pentatonic-memory__20260427-175413.json +0 -1038
  59. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/product-catalogue__pentatonic-memory__20260427-175430.json +0 -883
  60. package/packages/memory-engine/bench/scorecards-engine-via-shim/agent-coding__pentatonic-memory__20260427-155409.json +0 -1115
  61. package/packages/memory-engine/bench/scorecards-engine-via-shim/chat-recall__pentatonic-memory__20260427-155421.json +0 -819
  62. package/packages/memory-engine/bench/scorecards-engine-via-shim/circular-economy__pentatonic-memory__20260427-155433.json +0 -1278
  63. package/packages/memory-engine/bench/scorecards-engine-via-shim/customer-support__pentatonic-memory__20260427-155443.json +0 -1018
  64. package/packages/memory-engine/bench/scorecards-engine-via-shim/marketplace-ops__pentatonic-memory__20260427-155453.json +0 -1038
  65. package/packages/memory-engine/bench/scorecards-engine-via-shim/product-catalogue__pentatonic-memory__20260427-155503.json +0 -937
  66. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/agent-coding__pentatonic-memory-latest__20260427-145103.json +0 -1115
  67. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/agent-coding__pentatonic-memory__20260427-144909.json +0 -1115
  68. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/chat-recall__pentatonic-memory-latest__20260427-145153.json +0 -819
  69. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/chat-recall__pentatonic-memory__20260427-145120.json +0 -542
  70. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/circular-economy__pentatonic-memory-latest__20260427-145313.json +0 -1278
  71. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/circular-economy__pentatonic-memory__20260427-145207.json +0 -894
  72. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/customer-support__pentatonic-memory-latest__20260427-145412.json +0 -1018
  73. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/customer-support__pentatonic-memory__20260427-145327.json +0 -680
  74. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/marketplace-ops__pentatonic-memory-latest__20260427-145517.json +0 -1038
  75. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/marketplace-ops__pentatonic-memory__20260427-145422.json +0 -693
  76. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/product-catalogue__pentatonic-memory-latest__20260427-145616.json +0 -961
  77. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/product-catalogue__pentatonic-memory__20260427-145528.json +0 -727
  78. package/packages/memory-engine/compat/Dockerfile +0 -22
  79. package/packages/memory-engine/compat/server.py +0 -1255
  80. package/packages/memory-engine/docker-compose.test.yml +0 -59
  81. package/packages/memory-engine/docker-compose.yml +0 -255
  82. package/packages/memory-engine/engine/README.md +0 -52
  83. package/packages/memory-engine/engine/l2-hybridrag-proxy.py +0 -1543
  84. package/packages/memory-engine/engine/l5-comms-layer.py +0 -663
  85. package/packages/memory-engine/engine/l6-document-store.py +0 -1018
  86. package/packages/memory-engine/engine/services/_shared/__init__.py +0 -1
  87. package/packages/memory-engine/engine/services/_shared/embed_provider.py +0 -562
  88. package/packages/memory-engine/engine/services/l2/Dockerfile +0 -50
  89. package/packages/memory-engine/engine/services/l2/init_databases.py +0 -81
  90. package/packages/memory-engine/engine/services/l2/l2-hybridrag-proxy.py +0 -2721
  91. package/packages/memory-engine/engine/services/l5/Dockerfile +0 -11
  92. package/packages/memory-engine/engine/services/l5/l5-comms-layer.py +0 -808
  93. package/packages/memory-engine/engine/services/l6/Dockerfile +0 -30
  94. package/packages/memory-engine/engine/services/l6/l6-document-store.py +0 -1221
  95. package/packages/memory-engine/engine/services/nv-embed/Dockerfile +0 -28
  96. package/packages/memory-engine/engine/services/nv-embed/server.py +0 -152
  97. package/packages/memory-engine/pme_memory/__init__.py +0 -0
  98. package/packages/memory-engine/pme_memory/__main__.py +0 -129
  99. package/packages/memory-engine/pme_memory/artifacts.py +0 -95
  100. package/packages/memory-engine/pme_memory/embed.py +0 -74
  101. package/packages/memory-engine/pme_memory/health.py +0 -36
  102. package/packages/memory-engine/pme_memory/hygiene.py +0 -159
  103. package/packages/memory-engine/pme_memory/indexer.py +0 -200
  104. package/packages/memory-engine/pme_memory/needs.py +0 -55
  105. package/packages/memory-engine/pme_memory/provenance.py +0 -80
  106. package/packages/memory-engine/pme_memory/scoring.py +0 -168
  107. package/packages/memory-engine/pme_memory/search.py +0 -52
  108. package/packages/memory-engine/pme_memory/store.py +0 -86
  109. package/packages/memory-engine/pme_memory/synthesis.py +0 -114
  110. package/packages/memory-engine/pyproject.toml +0 -65
  111. package/packages/memory-engine/scripts/kg-extractor.py +0 -557
  112. package/packages/memory-engine/scripts/kg-preflexor-v2.py +0 -738
  113. package/packages/memory-engine/scripts/wipe-legacy-l3-entities.py +0 -128
  114. package/packages/memory-engine/tests/e2e_arena.sh +0 -259
  115. package/packages/memory-engine/tests/embed_stub/Dockerfile +0 -13
  116. package/packages/memory-engine/tests/embed_stub/server.py +0 -80
  117. package/packages/memory-engine/tests/test_aggregate.py +0 -333
  118. package/packages/memory-engine/tests/test_api_contract.sh +0 -57
  119. package/packages/memory-engine/tests/test_arena_safety.py +0 -232
  120. package/packages/memory-engine/tests/test_channel_stat_reader.py +0 -437
  121. package/packages/memory-engine/tests/test_channel_stat_rollups.py +0 -308
  122. package/packages/memory-engine/tests/test_compat_nv_embed_probe.py +0 -48
  123. package/packages/memory-engine/tests/test_embed_provider.py +0 -693
  124. package/packages/memory-engine/tests/test_l2_qmd_vec_search.py +0 -280
  125. package/packages/memory-engine/tests/test_l3_arena_isolation.py +0 -412
  126. package/packages/memory-engine/tests/test_l6_module_load.py +0 -84
  127. package/packages/memory-engine/tests/test_people_list_reader.py +0 -432
@@ -1,84 +0,0 @@
1
- """Regression test for the L6 _embed_client shadowing bug introduced in v0.8.0.
2
-
3
- When the EmbedClient refactor landed in 0.8.0, the new `def _embed_client()`
4
- factory function was added at module top, but the legacy module-level
5
- `_embed_client = httpx.Client(timeout=60)` binding (used by Ollama entity
6
- extraction) was left in place. Python's top-to-bottom evaluation rebound
7
- the name to the httpx.Client instance, so any subsequent call to
8
- `_embed_client()` raised `TypeError: 'Client' object is not callable`.
9
-
10
- This silently 500'd every L6 /index-batch and /search request from 0.8.0
11
- through 0.8.2 — the bug couldn't be caught by /health because the process
12
- itself stays up, only the request handlers fail.
13
-
14
- This is a static-source test (parses the file) rather than an import-time
15
- test because L6's heavy imports (pymilvus, spacy) aren't available in the
16
- unit-test venv. The check: scan the AST for any non-function rebinding of
17
- identifiers that are also defined as `def` in the same module. Catches
18
- this exact bug shape across any service that uses the EmbedClient factory
19
- pattern.
20
- """
21
-
22
- from __future__ import annotations
23
-
24
- import ast
25
- from pathlib import Path
26
-
27
- import pytest
28
-
29
- SERVICES_DIR = Path(__file__).parent.parent / "engine" / "services"
30
-
31
- # Services that use the lazy EmbedClient factory pattern.
32
- SERVICES_WITH_EMBED_FACTORY = [
33
- SERVICES_DIR / "l4" / "server.py",
34
- SERVICES_DIR / "l5" / "l5-comms-layer.py",
35
- SERVICES_DIR / "l6" / "l6-document-store.py",
36
- SERVICES_DIR / "l2" / "l2-hybridrag-proxy.py",
37
- ]
38
-
39
-
40
- def _module_level_defs_and_assigns(source: str) -> tuple[set[str], set[str]]:
41
- """Return (function names, non-function-assigned names) at module level."""
42
- tree = ast.parse(source)
43
- funcs: set[str] = set()
44
- assigns: set[str] = set()
45
- for node in tree.body:
46
- if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
47
- funcs.add(node.name)
48
- elif isinstance(node, ast.Assign):
49
- for target in node.targets:
50
- if isinstance(target, ast.Name):
51
- assigns.add(target.id)
52
- elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
53
- assigns.add(node.target.id)
54
- return funcs, assigns
55
-
56
-
57
- @pytest.mark.parametrize("service_file", SERVICES_WITH_EMBED_FACTORY, ids=lambda p: f"{p.parent.name}/{p.name}")
58
- def test_no_module_level_shadowing_of_factory_functions(service_file: Path):
59
- """A module-level `def foo()` must not also have a module-level `foo = ...`
60
- later in the file. That's exactly the shape that caused the v0.8.0 L6 bug."""
61
- source = service_file.read_text()
62
- funcs, assigns = _module_level_defs_and_assigns(source)
63
- overlap = funcs & assigns
64
- assert not overlap, (
65
- f"{service_file.relative_to(SERVICES_DIR.parent.parent)} has module-level "
66
- f"identifier(s) defined as both `def` and `name = ...`: {sorted(overlap)}. "
67
- f"This causes silent name shadowing — the assignment wins and any call "
68
- f"to {sorted(overlap)[0]}() raises TypeError at runtime."
69
- )
70
-
71
-
72
- def test_l6_uses_renamed_ollama_http_not_embed_client():
73
- """Belt-and-suspenders: explicitly assert L6's Ollama HTTP client is at
74
- `_ollama_http`, not `_embed_client`. If someone reintroduces the original
75
- binding by accident, this test catches it without depending on AST traversal."""
76
- source = (SERVICES_DIR / "l6" / "l6-document-store.py").read_text()
77
- assert "_embed_client = httpx.Client" not in source, (
78
- "L6 reintroduced the legacy `_embed_client = httpx.Client(...)` binding "
79
- "that shadowed the EmbedClient factory in v0.8.0. Rename to _ollama_http."
80
- )
81
- assert "_ollama_http = httpx.Client" in source, (
82
- "L6 is missing the renamed Ollama HTTP client (`_ollama_http = httpx.Client(...)`). "
83
- "The Ollama entity-extraction call path needs an httpx.Client somewhere."
84
- )
@@ -1,432 +0,0 @@
1
- """Integration tests for the /people-list-internal endpoint.
2
-
3
- Sister file to ``test_channel_stat_reader.py``: that one covers
4
- ``aggregate_internal`` (per-person aggregate); this one covers
5
- ``people_list_internal`` (corpus-level aggregate — one row per
6
- Person across one or more arenas).
7
-
8
- The endpoint backs the Pip Relationships UI list page. Where
9
- ``aggregate_internal`` returns the per-channel breakdown FOR one
10
- person, ``people_list_internal`` returns one row PER PERSON across
11
- the whole arena set, with the per-channel breakdown nested.
12
-
13
- Gated on NEO4J_TEST_URI + NEO4J_TEST_PASSWORD; skip cleanly when
14
- those env vars are absent so unit-only test runs stay fast.
15
-
16
- Run:
17
-
18
- cd packages/memory-engine
19
- NEO4J_TEST_URI=bolt://localhost:17687 \\
20
- NEO4J_TEST_PASSWORD=testpassword \\
21
- .venv/bin/python -m pytest tests/test_people_list_reader.py -v
22
- """
23
- from __future__ import annotations
24
-
25
- import asyncio
26
- import importlib.util
27
- import os
28
- import sys
29
- import uuid
30
- from pathlib import Path
31
-
32
- import pytest
33
-
34
-
35
- _NEO4J_URI = os.environ.get("NEO4J_TEST_URI")
36
- _NEO4J_USER = os.environ.get("NEO4J_TEST_USER", "neo4j")
37
- _NEO4J_PASSWORD = os.environ.get("NEO4J_TEST_PASSWORD")
38
-
39
- _skip_no_neo4j = pytest.mark.skipif(
40
- not (_NEO4J_URI and _NEO4J_PASSWORD),
41
- reason="set NEO4J_TEST_URI + NEO4J_TEST_PASSWORD to run integration tests",
42
- )
43
-
44
-
45
- ENGINE_ROOT = Path(__file__).resolve().parent.parent / "engine" / "services" / "l2"
46
- sys.path.insert(0, str(ENGINE_ROOT))
47
-
48
-
49
- @pytest.fixture(scope="module")
50
- def proxy_module():
51
- """Mirror of the helper in test_channel_stat_reader.py — load
52
- l2-hybridrag-proxy as a module so we can call the FastAPI handler
53
- directly without HTTP. Override NEO4J_URI/NEO4J_AUTH at runtime
54
- rather than at import time."""
55
- spec = importlib.util.spec_from_file_location(
56
- "l2_proxy_module",
57
- ENGINE_ROOT / "l2-hybridrag-proxy.py",
58
- )
59
- assert spec and spec.loader
60
- try:
61
- mod = importlib.util.module_from_spec(spec)
62
- spec.loader.exec_module(mod)
63
- except ImportError:
64
- pytest.skip("l2 proxy deps unavailable in this venv (fine for unit-only runs)")
65
- mod.NEO4J_URI = _NEO4J_URI
66
- mod.NEO4J_AUTH = (_NEO4J_USER, _NEO4J_PASSWORD)
67
- return mod
68
-
69
-
70
- @pytest.fixture
71
- def neo4j_driver():
72
- """Per-test driver + cleanup. Three arenas so multi-arena tests
73
- have something to span across."""
74
- from neo4j import GraphDatabase
75
-
76
- driver = GraphDatabase.driver(_NEO4J_URI, auth=(_NEO4J_USER, _NEO4J_PASSWORD))
77
- arenas = [
78
- f"pl_a_{uuid.uuid4().hex[:8]}",
79
- f"pl_b_{uuid.uuid4().hex[:8]}",
80
- f"pl_c_{uuid.uuid4().hex[:8]}",
81
- ]
82
- yield driver, arenas
83
- with driver.session() as session:
84
- for arena in arenas:
85
- session.run(
86
- "MATCH (n) WHERE n.arena = $arena DETACH DELETE n",
87
- arena=arena,
88
- )
89
- driver.close()
90
-
91
-
92
- def _ensure_indexes(session) -> None:
93
- """Idempotent index + constraint setup matching the writer block."""
94
- session.run(
95
- "CREATE INDEX channelstat_arena_email IF NOT EXISTS "
96
- "FOR (s:ChannelStat) ON (s.arena, s.person_email)"
97
- )
98
- session.run(
99
- "CREATE CONSTRAINT channelstat_unique IF NOT EXISTS "
100
- "FOR (s:ChannelStat) REQUIRE (s.arena, s.person_email, s.channel) IS UNIQUE"
101
- )
102
-
103
-
104
- def _write_stat(
105
- session,
106
- arena: str,
107
- email: str,
108
- channel: str,
109
- count: int = 1,
110
- inbound: int = 0,
111
- outbound: int = 0,
112
- last_seen: str = "2026-05-10T00:00:00Z",
113
- first_seen: str = "2024-01-01T00:00:00Z",
114
- name: str | None = None,
115
- ) -> None:
116
- """Insert a ChannelStat node + matching Person (with optional
117
- name). Skips the Chunk + COMMUNICATED edge — those aren't read
118
- by ``people_list_internal`` since it reads the denorm directly."""
119
- session.run(
120
- """
121
- MERGE (s:ChannelStat {arena: $arena, person_email: $email, channel: $channel})
122
- SET s.count = $count,
123
- s.inbound = $inbound,
124
- s.outbound = $outbound,
125
- s.last_seen = $last_seen,
126
- s.first_seen = $first_seen
127
- """,
128
- arena=arena, email=email, channel=channel,
129
- count=count, inbound=inbound, outbound=outbound,
130
- last_seen=last_seen, first_seen=first_seen,
131
- )
132
- # Person node carries the display name. Email is the join key.
133
- # OPTIONAL MATCH in the reader joins on email + arena.
134
- session.run(
135
- """
136
- MERGE (p:Entity:Person {arena: $arena, email: $email})
137
- SET p.name = $name
138
- """,
139
- arena=arena, email=email, name=name,
140
- )
141
-
142
-
143
- def _call_people_list(proxy_module, **kwargs):
144
- """Invoke people_list_internal directly. Same shape as
145
- _call_aggregate in the sister file."""
146
- req = proxy_module.PeopleListInternalRequest(**kwargs)
147
- return asyncio.run(proxy_module.people_list_internal(req))
148
-
149
-
150
- # ---------------------------------------------------------------------------
151
- # Single-arena basic behaviour.
152
- # ---------------------------------------------------------------------------
153
-
154
-
155
- @_skip_no_neo4j
156
- def test_returns_one_row_per_person_with_channels_nested(
157
- neo4j_driver, proxy_module
158
- ) -> None:
159
- """Three ChannelStats for two people in one arena → two list rows.
160
- Channels collapse into the nested ``channels`` list per person."""
161
- driver, (arena, _, _) = neo4j_driver
162
- with driver.session() as session:
163
- _ensure_indexes(session)
164
- # Alex: email + slack
165
- _write_stat(session, arena, "alex@x.io", "email", count=3, inbound=2, outbound=1,
166
- last_seen="2026-05-10T00:00:00Z", name="Alex Tong")
167
- _write_stat(session, arena, "alex@x.io", "slack", count=1, inbound=1, outbound=0,
168
- last_seen="2026-05-08T00:00:00Z", name="Alex Tong")
169
- # Bea: email only
170
- _write_stat(session, arena, "bea@y.io", "email", count=5, inbound=5, outbound=0,
171
- last_seen="2026-05-09T00:00:00Z", name="Bea Chen")
172
-
173
- out = _call_people_list(proxy_module, arenas=[arena])
174
- assert out.total_count == 2
175
- assert out.has_more is False
176
- emails = sorted(item.person_email for item in out.items)
177
- assert emails == ["alex@x.io", "bea@y.io"]
178
- alex = next(item for item in out.items if item.person_email == "alex@x.io")
179
- assert alex.person_name == "Alex Tong"
180
- assert alex.total == 4 # 3 email + 1 slack
181
- assert alex.inbound == 3
182
- assert alex.outbound == 1
183
- assert alex.last_seen == "2026-05-10T00:00:00Z"
184
- assert {ch.channel for ch in alex.channels} == {"email", "slack"}
185
-
186
-
187
- @_skip_no_neo4j
188
- def test_default_order_is_last_seen_desc(neo4j_driver, proxy_module) -> None:
189
- """Default sort: most-recently-active person first. Backs the
190
- Relationships UI's default landing view."""
191
- driver, (arena, _, _) = neo4j_driver
192
- with driver.session() as session:
193
- _ensure_indexes(session)
194
- _write_stat(session, arena, "old@x.io", "email", last_seen="2025-01-01T00:00:00Z")
195
- _write_stat(session, arena, "new@x.io", "email", last_seen="2026-05-12T00:00:00Z")
196
- _write_stat(session, arena, "mid@x.io", "email", last_seen="2026-01-01T00:00:00Z")
197
-
198
- out = _call_people_list(proxy_module, arenas=[arena])
199
- assert [i.person_email for i in out.items] == ["new@x.io", "mid@x.io", "old@x.io"]
200
-
201
-
202
- # ---------------------------------------------------------------------------
203
- # Multi-arena behaviour.
204
- # ---------------------------------------------------------------------------
205
-
206
-
207
- @_skip_no_neo4j
208
- def test_multi_arena_returns_persons_from_both_arenas(
209
- neo4j_driver, proxy_module
210
- ) -> None:
211
- """A vendor who appears in arena A AND arena B should be one row
212
- with both arenas' channel data. Backs the "people known by Philip
213
- OR Jeanne" use case."""
214
- driver, (arena_a, arena_b, _) = neo4j_driver
215
- with driver.session() as session:
216
- _ensure_indexes(session)
217
- # Same vendor in both arenas
218
- _write_stat(session, arena_a, "vendor@v.io", "email", count=2,
219
- last_seen="2026-05-10T00:00:00Z", name="Vendor Co")
220
- _write_stat(session, arena_b, "vendor@v.io", "slack", count=3,
221
- last_seen="2026-05-11T00:00:00Z", name="Vendor Co")
222
- # Unique-to-A person
223
- _write_stat(session, arena_a, "only@a.io", "email", last_seen="2026-05-09T00:00:00Z")
224
-
225
- out = _call_people_list(proxy_module, arenas=[arena_a, arena_b])
226
- emails = sorted(i.person_email for i in out.items)
227
- assert emails == ["only@a.io", "vendor@v.io"]
228
- vendor = next(i for i in out.items if i.person_email == "vendor@v.io")
229
- # Total across both arenas
230
- assert vendor.total == 5
231
- # Both channels surface
232
- assert {ch.channel for ch in vendor.channels} == {"email", "slack"}
233
- # last_seen is the max across arenas
234
- assert vendor.last_seen == "2026-05-11T00:00:00Z"
235
-
236
-
237
- @_skip_no_neo4j
238
- def test_arena_filter_excludes_other_arenas(neo4j_driver, proxy_module) -> None:
239
- """A person in arena C must NOT appear when only A+B are requested."""
240
- driver, (arena_a, arena_b, arena_c) = neo4j_driver
241
- with driver.session() as session:
242
- _ensure_indexes(session)
243
- _write_stat(session, arena_a, "a-only@x.io", "email")
244
- _write_stat(session, arena_c, "c-only@x.io", "email")
245
-
246
- out = _call_people_list(proxy_module, arenas=[arena_a, arena_b])
247
- emails = {i.person_email for i in out.items}
248
- assert "a-only@x.io" in emails
249
- assert "c-only@x.io" not in emails
250
-
251
-
252
- # ---------------------------------------------------------------------------
253
- # Filters.
254
- # ---------------------------------------------------------------------------
255
-
256
-
257
- @_skip_no_neo4j
258
- def test_emails_filter_restricts_to_listed_addresses(
259
- neo4j_driver, proxy_module
260
- ) -> None:
261
- """``emails`` is the batched-mode filter — used by Pip's nightly
262
- health-recompute to fetch facets for many specific people in one
263
- call. Cuts 8k×9 SQL queries to ~9 GraphQL calls."""
264
- driver, (arena, _, _) = neo4j_driver
265
- with driver.session() as session:
266
- _ensure_indexes(session)
267
- _write_stat(session, arena, "alex@x.io", "email")
268
- _write_stat(session, arena, "bea@y.io", "email")
269
- _write_stat(session, arena, "carl@z.io", "email")
270
-
271
- out = _call_people_list(
272
- proxy_module,
273
- arenas=[arena],
274
- emails=["alex@x.io", "carl@z.io"],
275
- )
276
- emails = sorted(i.person_email for i in out.items)
277
- assert emails == ["alex@x.io", "carl@z.io"]
278
-
279
-
280
- @_skip_no_neo4j
281
- def test_search_substring_matches_email_or_name(
282
- neo4j_driver, proxy_module
283
- ) -> None:
284
- """Search is case-insensitive substring on person_name and
285
- person_email. Backs the Relationships UI search box."""
286
- driver, (arena, _, _) = neo4j_driver
287
- with driver.session() as session:
288
- _ensure_indexes(session)
289
- _write_stat(session, arena, "alex@pentatonic.com", "email", name="Alex Tong")
290
- _write_stat(session, arena, "bea@pentatonic.com", "email", name="Bea Chen")
291
- _write_stat(session, arena, "carl@external.com", "email", name="Carl X")
292
-
293
- # Search by name fragment
294
- out = _call_people_list(proxy_module, arenas=[arena], search="alex")
295
- assert {i.person_email for i in out.items} == {"alex@pentatonic.com"}
296
-
297
- # Search by email-domain fragment matches everyone at pentatonic
298
- out = _call_people_list(proxy_module, arenas=[arena], search="pentatonic")
299
- assert {i.person_email for i in out.items} == {
300
- "alex@pentatonic.com", "bea@pentatonic.com",
301
- }
302
-
303
-
304
- @_skip_no_neo4j
305
- def test_search_matches_name_when_email_does_not(
306
- neo4j_driver, proxy_module
307
- ) -> None:
308
- """Regression: an early-WHERE on ``ChannelStat`` filtered rows by
309
- email-only before the Person join, so a person whose NAME matched
310
- the search term but whose EMAIL didn't was silently dropped. Fixed
311
- by deferring the whole search filter until after the OPTIONAL MATCH
312
- on Person. Sentinel case: email ``ag@x.io`` / name ``Alex Tong`` /
313
- search ``alex`` — must match on name even though email has no
314
- substring overlap."""
315
- driver, (arena, _, _) = neo4j_driver
316
- with driver.session() as session:
317
- _ensure_indexes(session)
318
- _write_stat(session, arena, "ag@x.io", "email", name="Alex Tong")
319
- _write_stat(session, arena, "other@x.io", "email", name="Bea Chen")
320
-
321
- out = _call_people_list(proxy_module, arenas=[arena], search="alex")
322
- assert {i.person_email for i in out.items} == {"ag@x.io"}
323
-
324
-
325
- @_skip_no_neo4j
326
- def test_search_does_not_bypass_filter_when_person_node_missing(
327
- neo4j_driver, proxy_module
328
- ) -> None:
329
- """Regression: the previous WHERE clause had a ``person_name IS
330
- NULL`` short-circuit that bypassed the search filter for anyone
331
- without a Person node — they matched any search term. Fixed by
332
- using ``coalesce(person_name, '')`` so the name probe just fails
333
- cleanly when no Person record exists, falling through to the email
334
- probe."""
335
- driver, (arena, _, _) = neo4j_driver
336
- with driver.session() as session:
337
- _ensure_indexes(session)
338
- # Insert a ChannelStat WITHOUT a Person node — simulates a
339
- # contact who's been emailed but never had a Person record
340
- # materialised. Use a raw write so _write_stat's MERGE doesn't
341
- # auto-create a Person.
342
- session.run(
343
- "MERGE (s:ChannelStat {arena: $arena, person_email: $email, channel: 'email'}) "
344
- "SET s.count = 1, s.inbound = 1, s.outbound = 0, "
345
- " s.last_seen = '2026-05-10T00:00:00Z', "
346
- " s.first_seen = '2026-05-10T00:00:00Z'",
347
- arena=arena, email="orphan@x.io",
348
- )
349
- _write_stat(session, arena, "alex@x.io", "email", name="Alex Tong")
350
-
351
- # Search "alex" must NOT match orphan@x.io — neither name (missing)
352
- # nor email contains "alex".
353
- out = _call_people_list(proxy_module, arenas=[arena], search="alex")
354
- assert {i.person_email for i in out.items} == {"alex@x.io"}
355
-
356
-
357
- # ---------------------------------------------------------------------------
358
- # Pagination.
359
- # ---------------------------------------------------------------------------
360
-
361
-
362
- @_skip_no_neo4j
363
- def test_pagination_limit_and_offset(neo4j_driver, proxy_module) -> None:
364
- """`limit` slices the page; `total_count` is the unfiltered count
365
- BEFORE pagination so the UI can render "Showing N of M"."""
366
- driver, (arena, _, _) = neo4j_driver
367
- with driver.session() as session:
368
- _ensure_indexes(session)
369
- # 5 people, last_seen ascending so a desc sort puts e first
370
- for i, letter in enumerate("abcde"):
371
- _write_stat(
372
- session, arena, f"{letter}@x.io", "email",
373
- last_seen=f"2026-05-{10 + i:02d}T00:00:00Z",
374
- )
375
-
376
- page1 = _call_people_list(proxy_module, arenas=[arena], limit=2, offset=0)
377
- page2 = _call_people_list(proxy_module, arenas=[arena], limit=2, offset=2)
378
- page3 = _call_people_list(proxy_module, arenas=[arena], limit=2, offset=4)
379
- assert page1.total_count == 5 == page2.total_count == page3.total_count
380
- assert page1.has_more is True
381
- assert page2.has_more is True
382
- assert page3.has_more is False
383
- assert [i.person_email for i in page1.items] == ["e@x.io", "d@x.io"]
384
- assert [i.person_email for i in page2.items] == ["c@x.io", "b@x.io"]
385
- assert [i.person_email for i in page3.items] == ["a@x.io"]
386
-
387
-
388
- @_skip_no_neo4j
389
- def test_order_by_total_desc(neo4j_driver, proxy_module) -> None:
390
- driver, (arena, _, _) = neo4j_driver
391
- with driver.session() as session:
392
- _ensure_indexes(session)
393
- _write_stat(session, arena, "many@x.io", "email", count=100)
394
- _write_stat(session, arena, "few@x.io", "email", count=5)
395
- _write_stat(session, arena, "mid@x.io", "email", count=50)
396
-
397
- out = _call_people_list(proxy_module, arenas=[arena], order_by="total_desc")
398
- assert [i.person_email for i in out.items] == [
399
- "many@x.io", "mid@x.io", "few@x.io",
400
- ]
401
-
402
-
403
- # ---------------------------------------------------------------------------
404
- # Validation.
405
- # ---------------------------------------------------------------------------
406
-
407
-
408
- @_skip_no_neo4j
409
- def test_empty_arenas_list_rejected(neo4j_driver, proxy_module) -> None:
410
- """An empty arenas list should 400, not silently return everything.
411
- Multi-tenant safety: a missing/empty filter must not become an
412
- 'all tenants' query."""
413
- from fastapi import HTTPException
414
-
415
- with pytest.raises(HTTPException) as exc:
416
- _call_people_list(proxy_module, arenas=[])
417
- assert exc.value.status_code == 400
418
-
419
-
420
- @_skip_no_neo4j
421
- def test_unknown_order_by_rejected(neo4j_driver, proxy_module) -> None:
422
- """Whitelisted sort keys — anything else 400s. Belt-and-braces
423
- against ORDER BY templating becoming an injection vector."""
424
- from fastapi import HTTPException
425
-
426
- driver, (arena, _, _) = neo4j_driver
427
- with pytest.raises(HTTPException) as exc:
428
- _call_people_list(
429
- proxy_module, arenas=[arena],
430
- order_by="totally_made_up",
431
- )
432
- assert exc.value.status_code == 400