@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.
- package/README.md +3 -3
- package/bin/cli.js +1 -1
- package/bin/commands/config.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/package.json +2 -2
- package/packages/doctor/src/checks/local-memory.js +2 -2
- package/packages/memory/README.md +2 -2
- package/packages/memory/openclaw-plugin/README.md +2 -2
- package/packages/memory/openclaw-plugin/openclaw.plugin.json +1 -1
- package/packages/memory/src/server.js +2 -2
- package/packages/memory-engine-v2/.env.example +30 -0
- package/packages/memory-engine-v2/README.md +125 -0
- package/packages/memory-engine-v2/compat/Dockerfile +11 -0
- package/packages/memory-engine-v2/compat/requirements.txt +6 -0
- package/packages/memory-engine-v2/compat/server.py +1047 -0
- package/packages/memory-engine-v2/docker-compose.aws.yml +78 -0
- package/packages/memory-engine-v2/docker-compose.yml +206 -0
- package/packages/memory-engine-v2/extractor-async/Dockerfile +14 -0
- package/packages/memory-engine-v2/extractor-async/confidence.py +62 -0
- package/packages/memory-engine-v2/extractor-async/noise_filter.py +144 -0
- package/packages/memory-engine-v2/extractor-async/requirements.txt +2 -0
- package/packages/memory-engine-v2/extractor-async/test_confidence.py +76 -0
- package/packages/memory-engine-v2/extractor-async/test_noise_filter.py +177 -0
- package/packages/memory-engine-v2/extractor-async/worker.py +797 -0
- package/packages/memory-engine-v2/extractor-sync/Dockerfile +11 -0
- package/packages/memory-engine-v2/extractor-sync/requirements.txt +4 -0
- package/packages/memory-engine-v2/extractor-sync/server.py +424 -0
- package/packages/memory-engine-v2/org-model/migrations/001_init.sql +390 -0
- package/packages/memory-engine-v2/tests/e2e_smoke.py +356 -0
- package/packages/memory-engine-v2/tests/fixtures/generate_synthetic_corpus.py +758 -0
- package/packages/memory-engine/.env.example +0 -13
- package/packages/memory-engine/MIGRATION.md +0 -219
- package/packages/memory-engine/README.md +0 -145
- package/packages/memory-engine/bench/README.md +0 -99
- package/packages/memory-engine/bench/scorecards-engine/agent-coding__pentatonic-baseline__20260427-142523.json +0 -1115
- package/packages/memory-engine/bench/scorecards-engine/chat-recall__pentatonic-baseline__20260427-142648.json +0 -819
- package/packages/memory-engine/bench/scorecards-engine/circular-economy__pentatonic-baseline__20260427-142757.json +0 -1278
- package/packages/memory-engine/bench/scorecards-engine/customer-support__pentatonic-baseline__20260427-142900.json +0 -1018
- package/packages/memory-engine/bench/scorecards-engine/marketplace-ops__pentatonic-baseline__20260427-142957.json +0 -1038
- package/packages/memory-engine/bench/scorecards-engine/product-catalogue__pentatonic-baseline__20260427-143122.json +0 -961
- package/packages/memory-engine/bench/scorecards-engine-via-docker/agent-coding__pentatonic-memory__20260427-161812.json +0 -1115
- package/packages/memory-engine/bench/scorecards-engine-via-docker/chat-recall__pentatonic-memory__20260427-161701.json +0 -819
- package/packages/memory-engine/bench/scorecards-engine-via-docker/circular-economy__pentatonic-memory__20260427-161713.json +0 -1278
- package/packages/memory-engine/bench/scorecards-engine-via-docker/customer-support__pentatonic-memory__20260427-161723.json +0 -1018
- package/packages/memory-engine/bench/scorecards-engine-via-docker/marketplace-ops__pentatonic-memory__20260427-161732.json +0 -1038
- package/packages/memory-engine/bench/scorecards-engine-via-docker/product-catalogue__pentatonic-memory__20260427-161741.json +0 -937
- package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/agent-coding__pentatonic-memory__20260427-184718.json +0 -1115
- package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/chat-recall__pentatonic-memory__20260427-184614.json +0 -819
- package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/circular-economy__pentatonic-memory__20260427-184809.json +0 -1278
- package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/customer-support__pentatonic-memory__20260427-184854.json +0 -1018
- package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/marketplace-ops__pentatonic-memory__20260427-184929.json +0 -1038
- package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/product-catalogue__pentatonic-memory__20260427-185015.json +0 -961
- package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/agent-coding__pentatonic-memory__20260427-175252.json +0 -1115
- package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/chat-recall__pentatonic-memory__20260427-175312.json +0 -819
- package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/circular-economy__pentatonic-memory__20260427-175335.json +0 -1278
- package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/customer-support__pentatonic-memory__20260427-175355.json +0 -1018
- package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/marketplace-ops__pentatonic-memory__20260427-175413.json +0 -1038
- package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/product-catalogue__pentatonic-memory__20260427-175430.json +0 -883
- package/packages/memory-engine/bench/scorecards-engine-via-shim/agent-coding__pentatonic-memory__20260427-155409.json +0 -1115
- package/packages/memory-engine/bench/scorecards-engine-via-shim/chat-recall__pentatonic-memory__20260427-155421.json +0 -819
- package/packages/memory-engine/bench/scorecards-engine-via-shim/circular-economy__pentatonic-memory__20260427-155433.json +0 -1278
- package/packages/memory-engine/bench/scorecards-engine-via-shim/customer-support__pentatonic-memory__20260427-155443.json +0 -1018
- package/packages/memory-engine/bench/scorecards-engine-via-shim/marketplace-ops__pentatonic-memory__20260427-155453.json +0 -1038
- package/packages/memory-engine/bench/scorecards-engine-via-shim/product-catalogue__pentatonic-memory__20260427-155503.json +0 -937
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/agent-coding__pentatonic-memory-latest__20260427-145103.json +0 -1115
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/agent-coding__pentatonic-memory__20260427-144909.json +0 -1115
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/chat-recall__pentatonic-memory-latest__20260427-145153.json +0 -819
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/chat-recall__pentatonic-memory__20260427-145120.json +0 -542
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/circular-economy__pentatonic-memory-latest__20260427-145313.json +0 -1278
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/circular-economy__pentatonic-memory__20260427-145207.json +0 -894
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/customer-support__pentatonic-memory-latest__20260427-145412.json +0 -1018
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/customer-support__pentatonic-memory__20260427-145327.json +0 -680
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/marketplace-ops__pentatonic-memory-latest__20260427-145517.json +0 -1038
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/marketplace-ops__pentatonic-memory__20260427-145422.json +0 -693
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/product-catalogue__pentatonic-memory-latest__20260427-145616.json +0 -961
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/product-catalogue__pentatonic-memory__20260427-145528.json +0 -727
- package/packages/memory-engine/compat/Dockerfile +0 -22
- package/packages/memory-engine/compat/server.py +0 -1255
- package/packages/memory-engine/docker-compose.test.yml +0 -59
- package/packages/memory-engine/docker-compose.yml +0 -255
- package/packages/memory-engine/engine/README.md +0 -52
- package/packages/memory-engine/engine/l2-hybridrag-proxy.py +0 -1543
- package/packages/memory-engine/engine/l5-comms-layer.py +0 -663
- package/packages/memory-engine/engine/l6-document-store.py +0 -1018
- package/packages/memory-engine/engine/services/_shared/__init__.py +0 -1
- package/packages/memory-engine/engine/services/_shared/embed_provider.py +0 -562
- package/packages/memory-engine/engine/services/l2/Dockerfile +0 -50
- package/packages/memory-engine/engine/services/l2/init_databases.py +0 -81
- package/packages/memory-engine/engine/services/l2/l2-hybridrag-proxy.py +0 -2721
- package/packages/memory-engine/engine/services/l5/Dockerfile +0 -11
- package/packages/memory-engine/engine/services/l5/l5-comms-layer.py +0 -808
- package/packages/memory-engine/engine/services/l6/Dockerfile +0 -30
- package/packages/memory-engine/engine/services/l6/l6-document-store.py +0 -1221
- package/packages/memory-engine/engine/services/nv-embed/Dockerfile +0 -28
- package/packages/memory-engine/engine/services/nv-embed/server.py +0 -152
- package/packages/memory-engine/pme_memory/__init__.py +0 -0
- package/packages/memory-engine/pme_memory/__main__.py +0 -129
- package/packages/memory-engine/pme_memory/artifacts.py +0 -95
- package/packages/memory-engine/pme_memory/embed.py +0 -74
- package/packages/memory-engine/pme_memory/health.py +0 -36
- package/packages/memory-engine/pme_memory/hygiene.py +0 -159
- package/packages/memory-engine/pme_memory/indexer.py +0 -200
- package/packages/memory-engine/pme_memory/needs.py +0 -55
- package/packages/memory-engine/pme_memory/provenance.py +0 -80
- package/packages/memory-engine/pme_memory/scoring.py +0 -168
- package/packages/memory-engine/pme_memory/search.py +0 -52
- package/packages/memory-engine/pme_memory/store.py +0 -86
- package/packages/memory-engine/pme_memory/synthesis.py +0 -114
- package/packages/memory-engine/pyproject.toml +0 -65
- package/packages/memory-engine/scripts/kg-extractor.py +0 -557
- package/packages/memory-engine/scripts/kg-preflexor-v2.py +0 -738
- package/packages/memory-engine/scripts/wipe-legacy-l3-entities.py +0 -128
- package/packages/memory-engine/tests/e2e_arena.sh +0 -259
- package/packages/memory-engine/tests/embed_stub/Dockerfile +0 -13
- package/packages/memory-engine/tests/embed_stub/server.py +0 -80
- package/packages/memory-engine/tests/test_aggregate.py +0 -333
- package/packages/memory-engine/tests/test_api_contract.sh +0 -57
- package/packages/memory-engine/tests/test_arena_safety.py +0 -232
- package/packages/memory-engine/tests/test_channel_stat_reader.py +0 -437
- package/packages/memory-engine/tests/test_channel_stat_rollups.py +0 -308
- package/packages/memory-engine/tests/test_compat_nv_embed_probe.py +0 -48
- package/packages/memory-engine/tests/test_embed_provider.py +0 -693
- package/packages/memory-engine/tests/test_l2_qmd_vec_search.py +0 -280
- package/packages/memory-engine/tests/test_l3_arena_isolation.py +0 -412
- package/packages/memory-engine/tests/test_l6_module_load.py +0 -84
- 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
|