@pentatonic-ai/ai-agent-sdk 0.9.5 → 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 +39 -72
- package/dist/index.js +36 -69
- package/package.json +10 -3
- 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/package-lock.json +49 -33
- package/packages/memory/package.json +4 -1
- package/packages/memory/src/__tests__/engine.test.js +40 -5
- package/packages/memory/src/engine.js +38 -3
- 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 -240
- 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 -468
- 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 -492
- 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,808 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""L5 Communications Layer — Deep semantic search over life data.
|
|
3
|
-
|
|
4
|
-
Collections:
|
|
5
|
-
- chats: Telegram, WhatsApp, iMessage, Slack transcripts
|
|
6
|
-
- emails: Email archives (markdown summaries)
|
|
7
|
-
- contacts: People profiles + contact records
|
|
8
|
-
- memory: Daily notes, project docs, research files
|
|
9
|
-
|
|
10
|
-
Usage:
|
|
11
|
-
python3 l5-comms-layer.py index # Index all sources
|
|
12
|
-
python3 l5-comms-layer.py index chats # Index just chats
|
|
13
|
-
python3 l5-comms-layer.py search "query" # Search across all collections
|
|
14
|
-
python3 l5-comms-layer.py search "query" --collection chats
|
|
15
|
-
python3 l5-comms-layer.py health # Health check
|
|
16
|
-
python3 l5-comms-layer.py stats # Collection stats
|
|
17
|
-
python3 l5-comms-layer.py serve # Run as HTTP server (port 8034)
|
|
18
|
-
"""
|
|
19
|
-
|
|
20
|
-
import argparse
|
|
21
|
-
import logging
|
|
22
|
-
import os
|
|
23
|
-
import glob
|
|
24
|
-
import hashlib
|
|
25
|
-
import json
|
|
26
|
-
import sys
|
|
27
|
-
import time
|
|
28
|
-
from datetime import datetime
|
|
29
|
-
from pathlib import Path
|
|
30
|
-
|
|
31
|
-
import httpx
|
|
32
|
-
from pymilvus import MilvusClient, DataType, CollectionSchema, FieldSchema
|
|
33
|
-
|
|
34
|
-
# Shared embed client lives at engine/services/_shared/.
|
|
35
|
-
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
36
|
-
from _shared.embed_provider import EmbedClient # noqa: E402
|
|
37
|
-
|
|
38
|
-
# --- Config ---
|
|
39
|
-
DB_PATH = os.environ.get(
|
|
40
|
-
"L5_DB_PATH",
|
|
41
|
-
str(Path.home() / "memory-l5" / "comms.db"),
|
|
42
|
-
)
|
|
43
|
-
WORKSPACE = Path(os.environ.get("PME_WORKSPACE", ".pentatonic"))
|
|
44
|
-
CLAWD_CHATS_DIR = Path.home() / "clawd" / "chats" # Legacy archive
|
|
45
|
-
CHATS_DIR = WORKSPACE / "chats"
|
|
46
|
-
EMAILS_DIR = WORKSPACE / "memory" / "chats" / "email"
|
|
47
|
-
PEOPLE_DIR = WORKSPACE / "memory" / "people"
|
|
48
|
-
CONTACTS_DIR = WORKSPACE / "memory" / "contacts"
|
|
49
|
-
MEMORY_DIR = WORKSPACE / "memory"
|
|
50
|
-
|
|
51
|
-
_embed: EmbedClient | None = None
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def _embed_client() -> EmbedClient:
|
|
55
|
-
"""Lazily build the shared EmbedClient for L5."""
|
|
56
|
-
global _embed
|
|
57
|
-
if _embed is None:
|
|
58
|
-
_embed = EmbedClient.from_env(
|
|
59
|
-
prefix="L5_",
|
|
60
|
-
default_url="http://localhost:8041/v1/embeddings",
|
|
61
|
-
)
|
|
62
|
-
return _embed
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def _embed_post(texts):
|
|
66
|
-
"""Embed a batch of texts via the shared EmbedClient.
|
|
67
|
-
|
|
68
|
-
Provider profile (auth scheme + URL path + body/response shape) is
|
|
69
|
-
chosen by L5_EMBED_PROVIDER env var (openai | pentatonic-gateway |
|
|
70
|
-
cohere | custom). Auto-detects on 401 unless L5_EMBED_AUTODETECT=false.
|
|
71
|
-
|
|
72
|
-
Returns: list[list[float]] (one embedding per input text).
|
|
73
|
-
"""
|
|
74
|
-
return _embed_client().embed_batch(texts)
|
|
75
|
-
|
|
76
|
-
# Ollama fallback path. URL/model can be overridden so the L5 container can
|
|
77
|
-
# reach an Ollama instance running on the docker host (host.docker.internal)
|
|
78
|
-
# or on a co-located service. Mirrors the env-var pattern used by L2.
|
|
79
|
-
OLLAMA_EMBED_URL = os.environ.get(
|
|
80
|
-
"L5_OLLAMA_EMBED_URL", "http://localhost:11434/api/embed"
|
|
81
|
-
)
|
|
82
|
-
OLLAMA_EMBED_MODEL = os.environ.get("L5_OLLAMA_EMBED_MODEL", "nomic-embed-text")
|
|
83
|
-
# Vector dim. Default matches NV-Embed-v2; override for smaller-dim models
|
|
84
|
-
# (e.g. 768 for nomic-embed-text, 1024 for mxbai-embed-large). Milvus
|
|
85
|
-
# collections are created at this dim; existing data won't survive a dim
|
|
86
|
-
# change — wipe the L5 volume to switch.
|
|
87
|
-
EMBED_DIM = int(os.environ.get("L5_EMBED_DIM", "4096"))
|
|
88
|
-
# Dim of the Ollama-fallback model. If equal to EMBED_DIM, the fallback
|
|
89
|
-
# returns vectors as-is; if smaller, they're zero-padded to EMBED_DIM.
|
|
90
|
-
OLLAMA_DIM = int(os.environ.get("L5_OLLAMA_DIM", "768"))
|
|
91
|
-
CHUNK_SIZE = 512 # chars per chunk
|
|
92
|
-
CHUNK_OVERLAP = 64
|
|
93
|
-
BATCH_SIZE = 100 # embeddings per batch
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def get_client():
|
|
97
|
-
return MilvusClient(uri=DB_PATH)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def embed_texts(texts: list[str]) -> list[list[float]]:
|
|
101
|
-
"""Get embeddings — NV-Embed-v2 batch primary, Ollama fallback."""
|
|
102
|
-
# Try batch NV-Embed first
|
|
103
|
-
batch_result = _embed_nv_batch(texts)
|
|
104
|
-
if batch_result is not None:
|
|
105
|
-
return batch_result
|
|
106
|
-
# Fallback: one at a time
|
|
107
|
-
results = []
|
|
108
|
-
for text in texts:
|
|
109
|
-
emb = _embed_nv_single(text)
|
|
110
|
-
if emb is None:
|
|
111
|
-
emb = _embed_ollama(text)
|
|
112
|
-
results.append(emb if emb else [0.0] * EMBED_DIM)
|
|
113
|
-
return results
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def _embed_nv_batch(texts: list[str]) -> list[list[float]] | None:
|
|
117
|
-
"""Batch embed via NV-Embed-v2 (4096-dim). Returns None on failure."""
|
|
118
|
-
if not texts:
|
|
119
|
-
return []
|
|
120
|
-
try:
|
|
121
|
-
truncated = [t[:4000] for t in texts]
|
|
122
|
-
embeddings = _embed_post(truncated)
|
|
123
|
-
if all(len(e) == EMBED_DIM for e in embeddings):
|
|
124
|
-
return embeddings
|
|
125
|
-
except Exception:
|
|
126
|
-
logging.debug(f"Suppressed error in l5-comms-layer.py")
|
|
127
|
-
return None
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def _embed_nv_single(text: str) -> list[float] | None:
|
|
131
|
-
"""Embed single text via NV-Embed-v2 (4096-dim)."""
|
|
132
|
-
try:
|
|
133
|
-
embs = _embed_post([text[:4000]])
|
|
134
|
-
emb = embs[0]
|
|
135
|
-
if len(emb) == EMBED_DIM:
|
|
136
|
-
return emb
|
|
137
|
-
except Exception:
|
|
138
|
-
logging.debug(f"Suppressed error in l5-comms-layer.py")
|
|
139
|
-
return None
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
def _embed_ollama(text: str) -> list[float] | None:
|
|
143
|
-
"""Fallback: Ollama nomic-embed (768-dim), zero-padded to EMBED_DIM."""
|
|
144
|
-
try:
|
|
145
|
-
r = httpx.post(OLLAMA_EMBED_URL, json={"model": OLLAMA_EMBED_MODEL, "input": text}, timeout=30)
|
|
146
|
-
r.raise_for_status()
|
|
147
|
-
data = r.json()
|
|
148
|
-
emb = data.get("embeddings", [data.get("embedding", [])])[0]
|
|
149
|
-
if isinstance(emb, list) and len(emb) == OLLAMA_DIM:
|
|
150
|
-
# Zero-pad to 4096 for Milvus compatibility
|
|
151
|
-
return emb + [0.0] * (EMBED_DIM - OLLAMA_DIM)
|
|
152
|
-
except Exception as e:
|
|
153
|
-
print(f" Embed error: {e}")
|
|
154
|
-
return None
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
def chunk_text(text: str, chunk_size=CHUNK_SIZE, overlap=CHUNK_OVERLAP) -> list[str]:
|
|
158
|
-
"""Split text into overlapping chunks."""
|
|
159
|
-
if len(text) <= chunk_size:
|
|
160
|
-
return [text] if text.strip() else []
|
|
161
|
-
chunks = []
|
|
162
|
-
start = 0
|
|
163
|
-
while start < len(text):
|
|
164
|
-
end = start + chunk_size
|
|
165
|
-
chunk = text[start:end].strip()
|
|
166
|
-
if chunk:
|
|
167
|
-
chunks.append(chunk)
|
|
168
|
-
start = end - overlap
|
|
169
|
-
return chunks
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
def text_id(text: str, source: str) -> str:
|
|
173
|
-
return hashlib.md5(f"{source}:{text[:200]}".encode()).hexdigest()
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
def ensure_collection(client, name: str):
|
|
177
|
-
"""Create collection if not exists."""
|
|
178
|
-
if client.has_collection(name):
|
|
179
|
-
return
|
|
180
|
-
schema = client.create_schema(auto_id=False, enable_dynamic_field=True)
|
|
181
|
-
schema.add_field("id", DataType.VARCHAR, is_primary=True, max_length=64)
|
|
182
|
-
schema.add_field("vector", DataType.FLOAT_VECTOR, dim=EMBED_DIM)
|
|
183
|
-
schema.add_field("text", DataType.VARCHAR, max_length=8192)
|
|
184
|
-
schema.add_field("source", DataType.VARCHAR, max_length=512)
|
|
185
|
-
schema.add_field("channel", DataType.VARCHAR, max_length=64)
|
|
186
|
-
schema.add_field("contact", DataType.VARCHAR, max_length=256)
|
|
187
|
-
schema.add_field("timestamp", DataType.VARCHAR, max_length=32)
|
|
188
|
-
|
|
189
|
-
index_params = client.prepare_index_params()
|
|
190
|
-
index_params.add_index(field_name="vector", index_type="FLAT", metric_type="COSINE")
|
|
191
|
-
client.create_collection(collection_name=name, schema=schema, index_params=index_params)
|
|
192
|
-
print(f" Created collection: {name}")
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
# --- Indexers ---
|
|
196
|
-
|
|
197
|
-
def index_chats(client):
|
|
198
|
-
"""Index JSONL chat transcripts."""
|
|
199
|
-
ensure_collection(client, "chats")
|
|
200
|
-
total = 0
|
|
201
|
-
|
|
202
|
-
# Walk all JSONL files under chats/
|
|
203
|
-
jsonl_files = list(CHATS_DIR.rglob("*.jsonl"))
|
|
204
|
-
# Also grab .txt chat exports
|
|
205
|
-
txt_files = list(CHATS_DIR.rglob("*.txt"))
|
|
206
|
-
|
|
207
|
-
print(f" Found {len(jsonl_files)} JSONL + {len(txt_files)} TXT chat files")
|
|
208
|
-
|
|
209
|
-
for f in jsonl_files:
|
|
210
|
-
try:
|
|
211
|
-
lines = f.read_text(errors="replace").strip().split("\n")
|
|
212
|
-
batch_data = []
|
|
213
|
-
|
|
214
|
-
for line in lines:
|
|
215
|
-
try:
|
|
216
|
-
msg = json.loads(line)
|
|
217
|
-
except json.JSONDecodeError:
|
|
218
|
-
continue
|
|
219
|
-
|
|
220
|
-
text = msg.get("text", "")
|
|
221
|
-
if not text or len(text) < 10:
|
|
222
|
-
continue
|
|
223
|
-
|
|
224
|
-
channel = msg.get("channel", "unknown")
|
|
225
|
-
contact = msg.get("contact", msg.get("sender", ""))
|
|
226
|
-
ts = msg.get("timestamp", "")
|
|
227
|
-
source = str(f.relative_to(WORKSPACE))
|
|
228
|
-
|
|
229
|
-
for chunk in chunk_text(text):
|
|
230
|
-
doc_id = text_id(chunk, source)
|
|
231
|
-
batch_data.append({
|
|
232
|
-
"id": doc_id,
|
|
233
|
-
"text": chunk[:8000],
|
|
234
|
-
"source": source[:500],
|
|
235
|
-
"channel": channel[:60],
|
|
236
|
-
"contact": str(contact)[:250],
|
|
237
|
-
"timestamp": str(ts)[:30],
|
|
238
|
-
})
|
|
239
|
-
|
|
240
|
-
if len(batch_data) >= BATCH_SIZE:
|
|
241
|
-
vectors = embed_texts([d["text"] for d in batch_data])
|
|
242
|
-
for d, v in zip(batch_data, vectors):
|
|
243
|
-
d["vector"] = v
|
|
244
|
-
client.upsert(collection_name="chats", data=batch_data)
|
|
245
|
-
total += len(batch_data)
|
|
246
|
-
batch_data = []
|
|
247
|
-
|
|
248
|
-
# Flush remaining
|
|
249
|
-
if batch_data:
|
|
250
|
-
vectors = embed_texts([d["text"] for d in batch_data])
|
|
251
|
-
for d, v in zip(batch_data, vectors):
|
|
252
|
-
d["vector"] = v
|
|
253
|
-
client.upsert(collection_name="chats", data=batch_data)
|
|
254
|
-
total += len(batch_data)
|
|
255
|
-
|
|
256
|
-
except Exception as e:
|
|
257
|
-
print(f" Error indexing {f}: {e}")
|
|
258
|
-
|
|
259
|
-
# Index markdown chat summaries
|
|
260
|
-
for channel_dir in ["telegram", "whatsapp", "imessage", "slack", "unknown"]:
|
|
261
|
-
chat_md_dir = WORKSPACE / "memory" / "chats" / channel_dir
|
|
262
|
-
if not chat_md_dir.exists():
|
|
263
|
-
continue
|
|
264
|
-
for f in chat_md_dir.glob("*.md"):
|
|
265
|
-
try:
|
|
266
|
-
text = f.read_text(errors="replace")
|
|
267
|
-
if len(text) < 20:
|
|
268
|
-
continue
|
|
269
|
-
source = str(f.relative_to(WORKSPACE))
|
|
270
|
-
batch_data = []
|
|
271
|
-
for chunk in chunk_text(text):
|
|
272
|
-
doc_id = text_id(chunk, source)
|
|
273
|
-
batch_data.append({
|
|
274
|
-
"id": doc_id,
|
|
275
|
-
"text": chunk[:8000],
|
|
276
|
-
"source": source[:500],
|
|
277
|
-
"channel": channel_dir,
|
|
278
|
-
"contact": f.stem[:250],
|
|
279
|
-
"timestamp": "",
|
|
280
|
-
})
|
|
281
|
-
if batch_data:
|
|
282
|
-
vectors = embed_texts([d["text"] for d in batch_data])
|
|
283
|
-
for d, v in zip(batch_data, vectors):
|
|
284
|
-
d["vector"] = v
|
|
285
|
-
client.upsert(collection_name="chats", data=batch_data)
|
|
286
|
-
total += len(batch_data)
|
|
287
|
-
except Exception as e:
|
|
288
|
-
print(f" Error indexing {f}: {e}")
|
|
289
|
-
|
|
290
|
-
print(f" Indexed {total} chat chunks")
|
|
291
|
-
return total
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
def index_emails(client):
|
|
295
|
-
"""Index email archives."""
|
|
296
|
-
ensure_collection(client, "emails")
|
|
297
|
-
total = 0
|
|
298
|
-
|
|
299
|
-
if not EMAILS_DIR.exists():
|
|
300
|
-
print(" No email directory found")
|
|
301
|
-
return 0
|
|
302
|
-
|
|
303
|
-
for f in EMAILS_DIR.glob("*.md"):
|
|
304
|
-
try:
|
|
305
|
-
text = f.read_text(errors="replace")
|
|
306
|
-
if len(text) < 20:
|
|
307
|
-
continue
|
|
308
|
-
source = str(f.relative_to(WORKSPACE))
|
|
309
|
-
# Extract contact from filename
|
|
310
|
-
contact = f.stem.replace("", "").replace("_", " ")[:250]
|
|
311
|
-
batch_data = []
|
|
312
|
-
for chunk in chunk_text(text):
|
|
313
|
-
doc_id = text_id(chunk, source)
|
|
314
|
-
batch_data.append({
|
|
315
|
-
"id": doc_id,
|
|
316
|
-
"text": chunk[:8000],
|
|
317
|
-
"source": source[:500],
|
|
318
|
-
"channel": "email",
|
|
319
|
-
"contact": contact,
|
|
320
|
-
"timestamp": "",
|
|
321
|
-
})
|
|
322
|
-
if batch_data:
|
|
323
|
-
vectors = embed_texts([d["text"] for d in batch_data])
|
|
324
|
-
for d, v in zip(batch_data, vectors):
|
|
325
|
-
d["vector"] = v
|
|
326
|
-
client.upsert(collection_name="emails", data=batch_data)
|
|
327
|
-
total += len(batch_data)
|
|
328
|
-
except Exception as e:
|
|
329
|
-
print(f" Error indexing {f}: {e}")
|
|
330
|
-
|
|
331
|
-
print(f" Indexed {total} email chunks")
|
|
332
|
-
return total
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
def index_contacts(client):
|
|
336
|
-
"""Index people profiles and contacts."""
|
|
337
|
-
ensure_collection(client, "contacts")
|
|
338
|
-
total = 0
|
|
339
|
-
|
|
340
|
-
# People profiles
|
|
341
|
-
if PEOPLE_DIR.exists():
|
|
342
|
-
for f in PEOPLE_DIR.glob("*.md"):
|
|
343
|
-
try:
|
|
344
|
-
text = f.read_text(errors="replace")
|
|
345
|
-
if len(text) < 20:
|
|
346
|
-
continue
|
|
347
|
-
source = str(f.relative_to(WORKSPACE))
|
|
348
|
-
batch_data = []
|
|
349
|
-
for chunk in chunk_text(text):
|
|
350
|
-
doc_id = text_id(chunk, source)
|
|
351
|
-
batch_data.append({
|
|
352
|
-
"id": doc_id,
|
|
353
|
-
"text": chunk[:8000],
|
|
354
|
-
"source": source[:500],
|
|
355
|
-
"channel": "profile",
|
|
356
|
-
"contact": f.stem[:250],
|
|
357
|
-
"timestamp": "",
|
|
358
|
-
})
|
|
359
|
-
if batch_data:
|
|
360
|
-
vectors = embed_texts([d["text"] for d in batch_data])
|
|
361
|
-
for d, v in zip(batch_data, vectors):
|
|
362
|
-
d["vector"] = v
|
|
363
|
-
client.upsert(collection_name="contacts", data=batch_data)
|
|
364
|
-
total += len(batch_data)
|
|
365
|
-
except Exception as e:
|
|
366
|
-
print(f" Error: {e}")
|
|
367
|
-
|
|
368
|
-
# Contact files
|
|
369
|
-
if CONTACTS_DIR.exists():
|
|
370
|
-
for f in CONTACTS_DIR.glob("*"):
|
|
371
|
-
if not f.is_file():
|
|
372
|
-
continue
|
|
373
|
-
try:
|
|
374
|
-
text = f.read_text(errors="replace")
|
|
375
|
-
if len(text) < 20:
|
|
376
|
-
continue
|
|
377
|
-
source = str(f.relative_to(WORKSPACE))
|
|
378
|
-
batch_data = []
|
|
379
|
-
for chunk in chunk_text(text, chunk_size=1024):
|
|
380
|
-
doc_id = text_id(chunk, source)
|
|
381
|
-
batch_data.append({
|
|
382
|
-
"id": doc_id,
|
|
383
|
-
"text": chunk[:8000],
|
|
384
|
-
"source": source[:500],
|
|
385
|
-
"channel": "contacts",
|
|
386
|
-
"contact": "",
|
|
387
|
-
"timestamp": "",
|
|
388
|
-
})
|
|
389
|
-
if batch_data:
|
|
390
|
-
vectors = embed_texts([d["text"] for d in batch_data])
|
|
391
|
-
for d, v in zip(batch_data, vectors):
|
|
392
|
-
d["vector"] = v
|
|
393
|
-
client.upsert(collection_name="contacts", data=batch_data)
|
|
394
|
-
total += len(batch_data)
|
|
395
|
-
except Exception as e:
|
|
396
|
-
print(f" Error: {e}")
|
|
397
|
-
|
|
398
|
-
print(f" Indexed {total} contact chunks")
|
|
399
|
-
return total
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
def index_memory(client):
|
|
403
|
-
"""Index memory markdown files (daily notes, projects, research, rules)."""
|
|
404
|
-
ensure_collection(client, "memory")
|
|
405
|
-
total = 0
|
|
406
|
-
|
|
407
|
-
# Skip chats/ (handled separately) and evolution run logs (too many, low value)
|
|
408
|
-
skip_patterns = ["chats/", "evolution/loop-run-", "evolution/v3/runs/"]
|
|
409
|
-
|
|
410
|
-
for f in MEMORY_DIR.rglob("*.md"):
|
|
411
|
-
source = str(f.relative_to(WORKSPACE))
|
|
412
|
-
if any(p in source for p in skip_patterns):
|
|
413
|
-
continue
|
|
414
|
-
try:
|
|
415
|
-
text = f.read_text(errors="replace")
|
|
416
|
-
if len(text) < 30:
|
|
417
|
-
continue
|
|
418
|
-
batch_data = []
|
|
419
|
-
for chunk in chunk_text(text):
|
|
420
|
-
doc_id = text_id(chunk, source)
|
|
421
|
-
batch_data.append({
|
|
422
|
-
"id": doc_id,
|
|
423
|
-
"text": chunk[:8000],
|
|
424
|
-
"source": source[:500],
|
|
425
|
-
"channel": "memory",
|
|
426
|
-
"contact": "",
|
|
427
|
-
"timestamp": "",
|
|
428
|
-
})
|
|
429
|
-
if batch_data:
|
|
430
|
-
vectors = embed_texts([d["text"] for d in batch_data])
|
|
431
|
-
for d, v in zip(batch_data, vectors):
|
|
432
|
-
d["vector"] = v
|
|
433
|
-
client.upsert(collection_name="memory", data=batch_data)
|
|
434
|
-
total += len(batch_data)
|
|
435
|
-
except Exception as e:
|
|
436
|
-
print(f" Error: {e}")
|
|
437
|
-
|
|
438
|
-
print(f" Indexed {total} memory chunks")
|
|
439
|
-
return total
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
# --- Search ---
|
|
443
|
-
|
|
444
|
-
def search(query: str, collection: str = None, limit: int = 10,
|
|
445
|
-
arena: str = None, arenas=None):
|
|
446
|
-
"""Search across collections.
|
|
447
|
-
|
|
448
|
-
arena / arenas (optional): when set, filter rows whose `arena`
|
|
449
|
-
dynamic field matches. Multi-arena uses Milvus `in [...]` so a
|
|
450
|
-
single-pass user-scoped search (tenant + own user) returns rows
|
|
451
|
-
from both buckets. Records without an arena tag are dropped under
|
|
452
|
-
multi-tenant safety.
|
|
453
|
-
"""
|
|
454
|
-
client = get_client()
|
|
455
|
-
vectors = embed_texts([query])
|
|
456
|
-
if not vectors or all(v == 0.0 for v in vectors[0]):
|
|
457
|
-
print("Failed to embed query")
|
|
458
|
-
return []
|
|
459
|
-
|
|
460
|
-
collections = [collection] if collection else ["chats", "emails", "contacts", "memory"]
|
|
461
|
-
all_results = []
|
|
462
|
-
|
|
463
|
-
# Normalize arenas list and build the Milvus filter expression.
|
|
464
|
-
if arenas is None:
|
|
465
|
-
arena_list = [arena] if arena else []
|
|
466
|
-
else:
|
|
467
|
-
arena_list = [a for a in arenas if a]
|
|
468
|
-
filter_expr = ""
|
|
469
|
-
if len(arena_list) == 1:
|
|
470
|
-
safe = str(arena_list[0]).replace('"', '\\"')
|
|
471
|
-
filter_expr = f'arena == "{safe}"'
|
|
472
|
-
elif len(arena_list) > 1:
|
|
473
|
-
quoted = ", ".join(
|
|
474
|
-
'"{}"'.format(str(a).replace('"', '\\"')) for a in arena_list
|
|
475
|
-
)
|
|
476
|
-
filter_expr = f'arena in [{quoted}]'
|
|
477
|
-
|
|
478
|
-
for coll in collections:
|
|
479
|
-
if not client.has_collection(coll):
|
|
480
|
-
continue
|
|
481
|
-
try:
|
|
482
|
-
results = client.search(
|
|
483
|
-
collection_name=coll,
|
|
484
|
-
data=[vectors[0]],
|
|
485
|
-
limit=limit,
|
|
486
|
-
filter=filter_expr,
|
|
487
|
-
output_fields=["text", "source", "channel", "contact", "timestamp", "arena"],
|
|
488
|
-
)
|
|
489
|
-
for hits in results:
|
|
490
|
-
for hit in hits:
|
|
491
|
-
entity = hit.get("entity", {})
|
|
492
|
-
all_results.append({
|
|
493
|
-
"id": hit.get("id", ""),
|
|
494
|
-
"collection": coll,
|
|
495
|
-
"score": round(hit.get("distance", 0), 4),
|
|
496
|
-
"text": entity.get("text", ""),
|
|
497
|
-
"source": entity.get("source", ""),
|
|
498
|
-
"channel": entity.get("channel", ""),
|
|
499
|
-
"contact": entity.get("contact", ""),
|
|
500
|
-
"timestamp": entity.get("timestamp", ""),
|
|
501
|
-
"arena": entity.get("arena", ""),
|
|
502
|
-
})
|
|
503
|
-
except Exception as e:
|
|
504
|
-
print(f" Search error in {coll}: {e}")
|
|
505
|
-
|
|
506
|
-
all_results.sort(key=lambda x: x["score"], reverse=True)
|
|
507
|
-
return all_results[:limit]
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
# --- Health / Stats ---
|
|
511
|
-
|
|
512
|
-
def health():
|
|
513
|
-
"""Check L5 health.
|
|
514
|
-
|
|
515
|
-
Reports "ok" iff the Milvus client can list collections — that's
|
|
516
|
-
L5's actual data plane. Embeddings are intentionally NOT probed
|
|
517
|
-
here: that's a separate concern reported by the compat shim's
|
|
518
|
-
nv_embed entry. Probing an external embedding endpoint on every
|
|
519
|
-
/health adds latency and false negatives for layers that only
|
|
520
|
-
embed on demand.
|
|
521
|
-
"""
|
|
522
|
-
try:
|
|
523
|
-
client = get_client()
|
|
524
|
-
collections = ["chats", "emails", "contacts", "memory"]
|
|
525
|
-
out = {"status": "ok", "db_path": DB_PATH, "collections": {}}
|
|
526
|
-
for coll in collections:
|
|
527
|
-
if client.has_collection(coll):
|
|
528
|
-
stats = client.get_collection_stats(coll)
|
|
529
|
-
count = stats.get("row_count", 0)
|
|
530
|
-
out["collections"][coll] = {"exists": True, "count": count}
|
|
531
|
-
else:
|
|
532
|
-
out["collections"][coll] = {"exists": False, "count": 0}
|
|
533
|
-
out["total_chunks"] = sum(c["count"] for c in out["collections"].values())
|
|
534
|
-
return out
|
|
535
|
-
except Exception as e:
|
|
536
|
-
return {"status": "error", "error": str(e)}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
def stats():
|
|
540
|
-
"""Print collection stats."""
|
|
541
|
-
h = health()
|
|
542
|
-
print(f"\nL5 Communications Layer — {h.get('status', 'unknown')}")
|
|
543
|
-
print(f"DB: {h.get('db_path', '?')}")
|
|
544
|
-
print(f"Embeddings: {'OK' if h.get('embeddings') else 'UNAVAILABLE'}")
|
|
545
|
-
print(f"\nCollections:")
|
|
546
|
-
for name, info in h.get("collections", {}).items():
|
|
547
|
-
if info["exists"]:
|
|
548
|
-
print(f" {name}: {info['count']:,} chunks")
|
|
549
|
-
else:
|
|
550
|
-
print(f" {name}: not created")
|
|
551
|
-
print(f"\nTotal: {h.get('total_chunks', 0):,} chunks")
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
# --- HTTP Server ---
|
|
555
|
-
|
|
556
|
-
def serve(port=8034):
|
|
557
|
-
"""Run as HTTP API server."""
|
|
558
|
-
from fastapi import FastAPI, Query
|
|
559
|
-
import uvicorn
|
|
560
|
-
|
|
561
|
-
# Bootstrap all 4 collections on startup. Previously only `chats`
|
|
562
|
-
# was being created (the indexer entrypoints below each call their
|
|
563
|
-
# own ensure_collection lazily, so collections without an indexer
|
|
564
|
-
# — i.e. those fed solely via /index-internal or the compat shim's
|
|
565
|
-
# _index_l5 fan-out — never came into existence and writes to them
|
|
566
|
-
# 500'd). Idempotent: ensure_collection short-circuits if exists.
|
|
567
|
-
try:
|
|
568
|
-
bootstrap_client = get_client()
|
|
569
|
-
for _name in ("chats", "emails", "contacts", "memory"):
|
|
570
|
-
ensure_collection(bootstrap_client, _name)
|
|
571
|
-
logging.info("L5 collections bootstrapped: chats, emails, contacts, memory")
|
|
572
|
-
except Exception as exc:
|
|
573
|
-
logging.warning(f"L5 collection bootstrap failed (continuing): {exc}")
|
|
574
|
-
|
|
575
|
-
api = FastAPI(title="L5 Communications Layer")
|
|
576
|
-
|
|
577
|
-
@api.get("/health")
|
|
578
|
-
def api_health():
|
|
579
|
-
return health()
|
|
580
|
-
|
|
581
|
-
@api.get("/search")
|
|
582
|
-
def api_search(q: str = Query(...), collection: str = None, limit: int = 10,
|
|
583
|
-
arena: str = None, arenas: list = Query(default=[])):
|
|
584
|
-
# `arenas` (repeated query param) wins when both are present.
|
|
585
|
-
results = search(
|
|
586
|
-
q, collection=collection, limit=limit,
|
|
587
|
-
arena=arena, arenas=arenas or None,
|
|
588
|
-
)
|
|
589
|
-
return {"query": q, "results": results, "count": len(results)}
|
|
590
|
-
|
|
591
|
-
@api.get("/stats")
|
|
592
|
-
def api_stats():
|
|
593
|
-
return health()
|
|
594
|
-
|
|
595
|
-
@api.post("/index-batch")
|
|
596
|
-
def api_index_batch(req: dict):
|
|
597
|
-
"""Index a batch of pre-formed records using a single batched
|
|
598
|
-
NV-Embed call + a single milvus insert. Roughly 30-50x faster
|
|
599
|
-
than calling /index for each item or running the legacy
|
|
600
|
-
per-chunk indexers, which is critical for tests, smoke runs and
|
|
601
|
-
bench harnesses where a few dozen docs need to land quickly.
|
|
602
|
-
|
|
603
|
-
Request body::
|
|
604
|
-
|
|
605
|
-
{
|
|
606
|
-
"collection": "chats", # one of: chats|emails|contacts|memory
|
|
607
|
-
"records": [
|
|
608
|
-
{
|
|
609
|
-
"id": "opt-stable-id", # optional, auto-generated if absent
|
|
610
|
-
"text": "…", # required
|
|
611
|
-
"source": "…", # optional
|
|
612
|
-
"channel": "…", # optional
|
|
613
|
-
"contact": "…" # optional
|
|
614
|
-
}, …
|
|
615
|
-
]
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
Returns::
|
|
619
|
-
|
|
620
|
-
{"status": "ok", "collection": "chats", "inserted": N,
|
|
621
|
-
"embed_ms": float, "insert_ms": float}
|
|
622
|
-
"""
|
|
623
|
-
import time as _time, hashlib as _hashlib
|
|
624
|
-
collection = req.get("collection", "chats")
|
|
625
|
-
records = req.get("records") or []
|
|
626
|
-
if not records:
|
|
627
|
-
return {"status": "ok", "inserted": 0, "collection": collection}
|
|
628
|
-
|
|
629
|
-
client = get_client()
|
|
630
|
-
ensure_collection(client, collection)
|
|
631
|
-
|
|
632
|
-
# Shared-embed shortcut: caller (compat shim) computed vectors
|
|
633
|
-
# once and forwards them so we skip the embed RPC. Length must
|
|
634
|
-
# match records — fall back to per-layer embed if it doesn't.
|
|
635
|
-
texts = [(r.get("text") or "")[:8192] for r in records]
|
|
636
|
-
shared_embs = req.get("embeddings")
|
|
637
|
-
t0 = _time.time()
|
|
638
|
-
if isinstance(shared_embs, list) and len(shared_embs) == len(records):
|
|
639
|
-
embs = shared_embs
|
|
640
|
-
else:
|
|
641
|
-
try:
|
|
642
|
-
embs = _embed_post(texts)
|
|
643
|
-
except Exception as exc:
|
|
644
|
-
return {"status": "error", "error": f"embed failed: {exc}"}
|
|
645
|
-
embed_ms = (_time.time() - t0) * 1000.0
|
|
646
|
-
|
|
647
|
-
# Single batched insert. Mirror every field the chats collection
|
|
648
|
-
# schema requires (id/vector/text/source/channel/contact/timestamp).
|
|
649
|
-
from datetime import datetime as _dt, timezone as _tz
|
|
650
|
-
_now = _dt.now(_tz.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
651
|
-
rows = []
|
|
652
|
-
for r, emb, txt in zip(records, embs, texts):
|
|
653
|
-
if emb is None:
|
|
654
|
-
continue
|
|
655
|
-
rid = r.get("id") or _hashlib.sha1(txt.encode("utf-8")).hexdigest()[:32]
|
|
656
|
-
rows.append({
|
|
657
|
-
"id": rid[:63],
|
|
658
|
-
"vector": emb,
|
|
659
|
-
"text": txt,
|
|
660
|
-
"source": (r.get("source") or "")[:512],
|
|
661
|
-
"channel": (r.get("channel") or "")[:64],
|
|
662
|
-
"contact": (r.get("contact") or "")[:256],
|
|
663
|
-
"timestamp": (r.get("timestamp") or _now)[:32],
|
|
664
|
-
# arena lands in the dynamic-field section of the
|
|
665
|
-
# collection (enable_dynamic_field=True). Filterable
|
|
666
|
-
# via `arena == "..."` in /search.
|
|
667
|
-
"arena": (r.get("arena") or "general")[:64],
|
|
668
|
-
})
|
|
669
|
-
t1 = _time.time()
|
|
670
|
-
if rows:
|
|
671
|
-
client.insert(collection_name=collection, data=rows)
|
|
672
|
-
insert_ms = (_time.time() - t1) * 1000.0
|
|
673
|
-
return {
|
|
674
|
-
"status": "ok",
|
|
675
|
-
"collection": collection,
|
|
676
|
-
"inserted": len(rows),
|
|
677
|
-
"embed_ms": round(embed_ms, 1),
|
|
678
|
-
"insert_ms": round(insert_ms, 1),
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
@api.get("/health/deep")
|
|
682
|
-
def api_health_deep():
|
|
683
|
-
"""Real functional probe: assert all 4 collections exist + run
|
|
684
|
-
embed+insert+search of a sentinel chunk in the `chats` collection.
|
|
685
|
-
Catches missing-collection regressions (Issue 3) and embed/insert/
|
|
686
|
-
search path bugs the shallow /health misses."""
|
|
687
|
-
import time as _time, hashlib as _hashlib
|
|
688
|
-
out = {"status": "ok", "ok": True}
|
|
689
|
-
client = get_client()
|
|
690
|
-
|
|
691
|
-
# 1. Collection presence
|
|
692
|
-
expected = ("chats", "emails", "contacts", "memory")
|
|
693
|
-
present = {n: client.has_collection(n) for n in expected}
|
|
694
|
-
out["collections"] = present
|
|
695
|
-
missing = [n for n, ok in present.items() if not ok]
|
|
696
|
-
if missing:
|
|
697
|
-
out["status"] = f"missing_collections:{','.join(missing)}"
|
|
698
|
-
out["ok"] = False
|
|
699
|
-
return out
|
|
700
|
-
|
|
701
|
-
# 2. Synthetic embed → insert → search in `chats`
|
|
702
|
-
sentinel_id = "__healthcheck__sentinel"
|
|
703
|
-
sentinel_text = "healthcheck sentinel — L5 embed-write-search round-trip verifier"
|
|
704
|
-
try:
|
|
705
|
-
t0 = _time.time()
|
|
706
|
-
embs = _embed_post([sentinel_text])
|
|
707
|
-
out["embed_ms"] = round((_time.time() - t0) * 1000.0, 1)
|
|
708
|
-
if not embs or embs[0] is None:
|
|
709
|
-
out["status"] = "embed_failed"
|
|
710
|
-
out["ok"] = False
|
|
711
|
-
return out
|
|
712
|
-
|
|
713
|
-
t1 = _time.time()
|
|
714
|
-
from datetime import datetime as _dt, timezone as _tz
|
|
715
|
-
client.upsert(collection_name="chats", data=[{
|
|
716
|
-
"id": sentinel_id,
|
|
717
|
-
"vector": embs[0],
|
|
718
|
-
"text": sentinel_text,
|
|
719
|
-
"source": "healthcheck",
|
|
720
|
-
"channel": "__healthcheck__",
|
|
721
|
-
"contact": "",
|
|
722
|
-
"timestamp": _dt.now(_tz.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
723
|
-
"arena": "__healthcheck__",
|
|
724
|
-
}])
|
|
725
|
-
out["write_ms"] = round((_time.time() - t1) * 1000.0, 1)
|
|
726
|
-
|
|
727
|
-
t2 = _time.time()
|
|
728
|
-
hits = client.search(
|
|
729
|
-
collection_name="chats",
|
|
730
|
-
data=[embs[0]],
|
|
731
|
-
limit=1,
|
|
732
|
-
filter='arena == "__healthcheck__"',
|
|
733
|
-
output_fields=["id"],
|
|
734
|
-
)
|
|
735
|
-
out["search_ms"] = round((_time.time() - t2) * 1000.0, 1)
|
|
736
|
-
found = bool(hits and hits[0] and hits[0][0].get("entity", {}).get("id") == sentinel_id)
|
|
737
|
-
out["hit"] = found
|
|
738
|
-
if not found:
|
|
739
|
-
out["status"] = "sentinel_missing"
|
|
740
|
-
out["ok"] = False
|
|
741
|
-
except Exception as exc:
|
|
742
|
-
out["status"] = f"probe_error: {type(exc).__name__}: {exc}"
|
|
743
|
-
out["ok"] = False
|
|
744
|
-
return out
|
|
745
|
-
|
|
746
|
-
print(f"\n L5 Communications Layer — http://127.0.0.1:{port}")
|
|
747
|
-
uvicorn.run(api, host=os.environ.get("HOST","127.0.0.1"), port=port, log_level="warning")
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
# --- CLI ---
|
|
751
|
-
|
|
752
|
-
def main():
|
|
753
|
-
parser = argparse.ArgumentParser(description="L5 Communications Layer")
|
|
754
|
-
parser.add_argument("command", choices=["index", "search", "health", "stats", "serve"])
|
|
755
|
-
parser.add_argument("args", nargs="*")
|
|
756
|
-
parser.add_argument("--collection", "-c", default=None)
|
|
757
|
-
parser.add_argument("--limit", "-l", type=int, default=10)
|
|
758
|
-
parser.add_argument("--port", "-p", type=int, default=8034)
|
|
759
|
-
args = parser.parse_args()
|
|
760
|
-
|
|
761
|
-
if args.command == "index":
|
|
762
|
-
client = get_client()
|
|
763
|
-
targets = args.args if args.args else ["chats", "emails", "contacts", "memory"]
|
|
764
|
-
t0 = time.time()
|
|
765
|
-
total = 0
|
|
766
|
-
for target in targets:
|
|
767
|
-
print(f"\nIndexing {target}...")
|
|
768
|
-
if target == "chats":
|
|
769
|
-
total += index_chats(client)
|
|
770
|
-
elif target == "emails":
|
|
771
|
-
total += index_emails(client)
|
|
772
|
-
elif target == "contacts":
|
|
773
|
-
total += index_contacts(client)
|
|
774
|
-
elif target == "memory":
|
|
775
|
-
total += index_memory(client)
|
|
776
|
-
else:
|
|
777
|
-
print(f" Unknown target: {target}")
|
|
778
|
-
elapsed = time.time() - t0
|
|
779
|
-
print(f"\nDone: {total:,} chunks indexed in {elapsed:.1f}s")
|
|
780
|
-
|
|
781
|
-
elif args.command == "search":
|
|
782
|
-
query = " ".join(args.args) if args.args else ""
|
|
783
|
-
if not query:
|
|
784
|
-
print("Usage: l5-comms-layer.py search 'your query'")
|
|
785
|
-
return
|
|
786
|
-
results = search(query, collection=args.collection, limit=args.limit)
|
|
787
|
-
for i, r in enumerate(results, 1):
|
|
788
|
-
print(f"\n--- [{i}] {r['collection']} (score: {r['score']}) ---")
|
|
789
|
-
print(f"Source: {r['source']}")
|
|
790
|
-
if r["contact"]:
|
|
791
|
-
print(f"Contact: {r['contact']}")
|
|
792
|
-
if r["timestamp"]:
|
|
793
|
-
print(f"Time: {r['timestamp']}")
|
|
794
|
-
print(r["text"][:300])
|
|
795
|
-
|
|
796
|
-
elif args.command == "health":
|
|
797
|
-
h = health()
|
|
798
|
-
print(json.dumps(h, indent=2))
|
|
799
|
-
|
|
800
|
-
elif args.command == "stats":
|
|
801
|
-
stats()
|
|
802
|
-
|
|
803
|
-
elif args.command == "serve":
|
|
804
|
-
serve(port=args.port)
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
if __name__ == "__main__":
|
|
808
|
-
main()
|