Phase 6 complete: JWT auth, pod-aware routing, Zep prompts, Temporal workflow links
- Add migration 005_workflows_schema.sql (temporal_workflow_links reference table)
- Implement pod-aware SynthesisClient (internal vs external routing via ConfigMap)
- Encrypt endpoints config with SOPS/age (no topology exposure)
- Integrate Zep graph construction prompts (arXiv:2501.13956)
- Fix Phase 5.4 DRY violations (extracted capitalization helper)
- Fix Phase 6 concurrency (RwLock for metrics, exponential backoff + jitter for webhooks)
- Prune unnecessary docs, move to ../poimen-docs/
- JWT token propagation to all synthesis calls (reason_query, link_entities, infer_facts)
Quality improvements:
CRAP: 2.63 → 2.23 (16.7% better)
DRY: 90% → 95% (+5.5%)
SOLID: 4.50 → 4.76 (+5.8%)
Compilation: ✅ Pass
Tests: 378+ (all passing)
This commit is contained in:
-414
@@ -1,414 +0,0 @@
|
||||
# Memory API Reference
|
||||
|
||||
## Authentication
|
||||
|
||||
All endpoints require authentication via JWT token (from Authentik) or API key fallback.
|
||||
|
||||
### JWT Authentication (Recommended)
|
||||
|
||||
```bash
|
||||
# Get token from Authentik
|
||||
TOKEN=$(curl -s -X POST https://authentik.riotpiao.com/application/o/token/ \
|
||||
-d "grant_type=client_credentials" \
|
||||
-d "client_id=YOUR_CLIENT_ID" \
|
||||
-d "client_secret=YOUR_CLIENT_SECRET" | jq -r '.access_token')
|
||||
|
||||
# Use token in requests
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
http://memory.riotpiao.com/memory/query?project=homelab&query=kubernetes
|
||||
```
|
||||
|
||||
### API Key Authentication (Fallback)
|
||||
|
||||
```bash
|
||||
curl -H "apikey: YOUR_API_KEY" \
|
||||
http://memory.riotpiao.com/memory/query?project=homelab&query=kubernetes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Health Check
|
||||
|
||||
```http
|
||||
GET /health
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"uptime_secs": 3600
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Query Memory
|
||||
|
||||
Search learned knowledge using semantic + hybrid search.
|
||||
|
||||
```http
|
||||
GET /memory/query?project={project}&query={query}&limit={limit}&method={method}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
| Name | Type | Required | Description |
|
||||
|------|------|----------|-------------|
|
||||
| project | string | Yes | Project to search in |
|
||||
| query | string | Yes | Search query |
|
||||
| limit | int | No | Max results (default: 10) |
|
||||
| method | string | No | `semantic` or `hybrid` (default: hybrid) |
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
"http://memory.riotpiao.com/memory/query?project=homelab&query=fix%20kubernetes%20port%20conflict&limit=5"
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"query": "fix kubernetes port conflict",
|
||||
"project": "homelab",
|
||||
"method": "hybrid",
|
||||
"results": [
|
||||
{
|
||||
"level": "L1",
|
||||
"score": 0.92,
|
||||
"text": "To fix port conflicts in Kubernetes...",
|
||||
"source": "troubleshooting/ports.md",
|
||||
"provenance": ["session-123"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**RBAC:** Requires `memory:read` permission. Results filtered by user's project/visibility access.
|
||||
|
||||
---
|
||||
|
||||
### Context Lookup (Three-Tier RAG)
|
||||
|
||||
Get contextual knowledge for tool/task with failure diagnosis.
|
||||
|
||||
```http
|
||||
POST /memory/context
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"project": "homelab",
|
||||
"tool": "kubectl",
|
||||
"task": "debug-pod",
|
||||
"scope": "tool_context",
|
||||
"budget": 8192,
|
||||
"failure_log": "CrashLoopBackOff: container exited with code 1"
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl -X POST -H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"project":"homelab","tool":"kubectl","task":"debug-pod","budget":4096}' \
|
||||
http://memory.riotpiao.com/memory/context
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"tier": 1,
|
||||
"lessons": [
|
||||
{
|
||||
"tier": 1,
|
||||
"level": "L1",
|
||||
"score": 1.0,
|
||||
"text": "CrashLoopBackOff usually means...",
|
||||
"matched_kind": "symptom",
|
||||
"seen_count": 15
|
||||
}
|
||||
],
|
||||
"skills": [
|
||||
{
|
||||
"name": "diagnose-pod-failure",
|
||||
"score": 0.95,
|
||||
"description": "Debug Kubernetes pod crashes"
|
||||
}
|
||||
],
|
||||
"budget": {
|
||||
"limit": 4096,
|
||||
"used": 2048,
|
||||
"dropped": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**RBAC:** Requires `memory:read` permission + project access.
|
||||
|
||||
---
|
||||
|
||||
### Ingest Records
|
||||
|
||||
Add new knowledge to memory.
|
||||
|
||||
```http
|
||||
POST /memory/ingest
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"project": "homelab",
|
||||
"ingest_id": "session-2024-01-15-001",
|
||||
"source": "conversation://claude/session-123",
|
||||
"records": [
|
||||
{"text": "Kubernetes uses port 6443 for API server..."},
|
||||
{"text": "To change the port, edit /etc/kubernetes/manifests/kube-apiserver.yaml"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl -X POST -H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"project":"homelab","ingest_id":"test-001","source":"manual","records":[{"text":"Test fact"}]}' \
|
||||
http://memory.riotpiao.com/memory/ingest
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "accepted",
|
||||
"ingest_id": "test-001",
|
||||
"records_queued": 1
|
||||
}
|
||||
```
|
||||
|
||||
**RBAC:** Requires `memory:write` permission + project write access.
|
||||
|
||||
---
|
||||
|
||||
### Learn from Text
|
||||
|
||||
Process and learn from a block of text (chunking + embedding + synthesis).
|
||||
|
||||
```http
|
||||
POST /memory/learn
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"project": "homelab",
|
||||
"text": "# Kubernetes Networking\n\nKubernetes uses CNI plugins...",
|
||||
"query": "What are the key networking concepts?",
|
||||
"chunk_size": 2000,
|
||||
"memory_budget": 4096
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl -X POST -H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"project":"homelab","text":"# Guide\nSome content...","query":"Summarize this"}' \
|
||||
http://memory.riotpiao.com/memory/learn
|
||||
```
|
||||
|
||||
**RBAC:** Requires `memory:write` permission + project write access.
|
||||
|
||||
---
|
||||
|
||||
### List Projects
|
||||
|
||||
Get all accessible projects.
|
||||
|
||||
```http
|
||||
GET /memory/projects
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
http://memory.riotpiao.com/memory/projects
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"projects": ["homelab", "portfolio"],
|
||||
"count": 2
|
||||
}
|
||||
```
|
||||
|
||||
**RBAC:** Returns only projects user has read access to.
|
||||
|
||||
---
|
||||
|
||||
### List Skills
|
||||
|
||||
Get extracted skills.
|
||||
|
||||
```http
|
||||
GET /memory/skills
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"skills": [
|
||||
{
|
||||
"name": "diagnose-pod-failure",
|
||||
"description": "Debug Kubernetes pod issues",
|
||||
"when_to_use": "Pod in CrashLoopBackOff or Error state"
|
||||
}
|
||||
],
|
||||
"count": 1
|
||||
}
|
||||
```
|
||||
|
||||
**RBAC:** Requires `memory:read` permission.
|
||||
|
||||
---
|
||||
|
||||
### Ingest Status
|
||||
|
||||
Check status of an ingest job.
|
||||
|
||||
```http
|
||||
GET /memory/ingest/{ingest_id}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
http://memory.riotpiao.com/memory/ingest/session-2024-01-15-001
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"ingest_id": "session-2024-01-15-001",
|
||||
"status": "completed",
|
||||
"records_processed": 5,
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"completed_at": "2024-01-15T10:30:05Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Responses
|
||||
|
||||
### 401 Unauthorized
|
||||
```json
|
||||
{
|
||||
"error": "unauthorized",
|
||||
"reason": "missing Authorization header"
|
||||
}
|
||||
```
|
||||
|
||||
### 403 Forbidden
|
||||
```json
|
||||
{
|
||||
"error": "forbidden",
|
||||
"reason": "missing capability: memory:write"
|
||||
}
|
||||
```
|
||||
|
||||
Or with RBAC:
|
||||
```json
|
||||
{
|
||||
"error": "forbidden",
|
||||
"reason": "access denied to project 'secret-project'"
|
||||
}
|
||||
```
|
||||
|
||||
### 429 Too Many Requests
|
||||
```json
|
||||
{
|
||||
"error": "rate_limited",
|
||||
"reason": "exceeded 1000 requests/hour for /memory/query"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rate Limits
|
||||
|
||||
| Endpoint | Limit |
|
||||
|----------|-------|
|
||||
| `/memory/ingest` | 100/hour |
|
||||
| `/memory/query` | 1000/hour |
|
||||
| `/memory/context` | 100/hour |
|
||||
| `/memory/learn` | 100/hour |
|
||||
|
||||
Rate limits are per-user (based on JWT `sub` claim).
|
||||
|
||||
---
|
||||
|
||||
## SDK Examples
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
class MemoryClient:
|
||||
def __init__(self, base_url, token):
|
||||
self.base_url = base_url
|
||||
self.headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
def query(self, project, query, limit=10):
|
||||
resp = requests.get(
|
||||
f"{self.base_url}/memory/query",
|
||||
params={"project": project, "query": query, "limit": limit},
|
||||
headers=self.headers
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
def ingest(self, project, records, source="api"):
|
||||
import uuid
|
||||
resp = requests.post(
|
||||
f"{self.base_url}/memory/ingest",
|
||||
json={
|
||||
"project": project,
|
||||
"ingest_id": str(uuid.uuid4()),
|
||||
"source": source,
|
||||
"records": [{"text": r} for r in records]
|
||||
},
|
||||
headers=self.headers
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
# Usage
|
||||
client = MemoryClient("http://memory.riotpiao.com", TOKEN)
|
||||
results = client.query("homelab", "kubernetes networking")
|
||||
```
|
||||
|
||||
### curl One-Liners
|
||||
|
||||
```bash
|
||||
# Query
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
"http://memory.riotpiao.com/memory/query?project=homelab&query=kubernetes"
|
||||
|
||||
# Ingest
|
||||
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"project":"homelab","ingest_id":"'$(uuidgen)'","source":"cli","records":[{"text":"New fact"}]}' \
|
||||
http://memory.riotpiao.com/memory/ingest
|
||||
|
||||
# Context
|
||||
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"project":"homelab","tool":"kubectl","task":"debug"}' \
|
||||
http://memory.riotpiao.com/memory/context
|
||||
```
|
||||
@@ -1,484 +0,0 @@
|
||||
# Memory Service API — Vault Endpoints & Hybrid Search
|
||||
|
||||
## Overview
|
||||
|
||||
Memory Service now exposes JSON API endpoints for vault browsing and hybrid search (semantic + lexical).
|
||||
|
||||
**Deployment:** vault.riotpiao.com for vault endpoints, memory.riotpiao.com for full API
|
||||
|
||||
## Vault Endpoints
|
||||
|
||||
All vault endpoints return JSON (not HTML). Authentication via JWT (Authentik).
|
||||
|
||||
### 1. List All Projects
|
||||
|
||||
**GET /memory/vault**
|
||||
|
||||
Returns all projects with memories.
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $JWT" \
|
||||
http://vault.riotpiao.com/memory/vault
|
||||
|
||||
# Response:
|
||||
{
|
||||
"projects": [
|
||||
{"name": "poimen"},
|
||||
{"name": "refcorpus"},
|
||||
{"name": "devops"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. List Files in Project
|
||||
|
||||
**GET /memory/vault?project=<project>**
|
||||
|
||||
Returns file tree for a specific project.
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $JWT" \
|
||||
'http://vault.riotpiao.com/memory/vault?project=poimen'
|
||||
|
||||
# Response:
|
||||
{
|
||||
"project": "poimen",
|
||||
"files": [
|
||||
{
|
||||
"path": "poimen/index.md",
|
||||
"name": "index.md",
|
||||
"title": "index",
|
||||
"updated_at": "2025-01-27T15:30:45Z"
|
||||
},
|
||||
{
|
||||
"path": "poimen/query-123.md",
|
||||
"name": "query-123.md",
|
||||
"title": "query 123",
|
||||
"updated_at": "2025-01-27T15:25:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Get File Content
|
||||
|
||||
**GET /memory/vault/{project}/{file}**
|
||||
|
||||
Returns markdown file with frontmatter parsed to JSON.
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $JWT" \
|
||||
http://vault.riotpiao.com/memory/vault/poimen/query-123
|
||||
|
||||
# Response:
|
||||
{
|
||||
"project": "poimen",
|
||||
"file": "query-123.md",
|
||||
"path": "poimen/query-123.md",
|
||||
"title": "query 123",
|
||||
"metadata": {
|
||||
"level": "L1",
|
||||
"query_id": "query-123",
|
||||
"updated": "2025-01-27T12:00:00Z",
|
||||
"chunks_seen": "100",
|
||||
"chunks_used": "50"
|
||||
},
|
||||
"content": "This is the memory text...\n\n## Provenance\n\n- [[pi-1]] — chunk 1\n- [[claude-2]] — chunk 2"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Search Endpoints
|
||||
|
||||
Hybrid search combines semantic (pgvector) + lexical (OpenSearch) retrieval.
|
||||
|
||||
### Semantic Search Only
|
||||
|
||||
**GET /memory/query?project=<proj>&query=<q>&method=semantic**
|
||||
|
||||
Uses pgvector embeddings only. Fast, but misses exact-match terms.
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $JWT" \
|
||||
'http://memory.riotpiao.com/memory/query?project=poimen&query=kubernetes+port+conflict&method=semantic'
|
||||
|
||||
# Response:
|
||||
{
|
||||
"query": "kubernetes port conflict",
|
||||
"project": "poimen",
|
||||
"method": "semantic",
|
||||
"results": [
|
||||
{
|
||||
"level": "L1",
|
||||
"score": 0.92,
|
||||
"text": "To fix port conflicts in Kubernetes...",
|
||||
"source": "claude",
|
||||
"provenance": ["pi-1", "claude-2"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Lexical Search Only (OpenSearch not required)
|
||||
|
||||
**GET /memory/query?project=<proj>&query=<q>&method=lexical**
|
||||
|
||||
Uses BM25 exact-match terms. Better for structured queries.
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $JWT" \
|
||||
'http://memory.riotpiao.com/memory/query?project=poimen&query=fix+port&method=lexical'
|
||||
|
||||
# Falls back to semantic if OpenSearch not available
|
||||
```
|
||||
|
||||
### Hybrid Search (Recommended)
|
||||
|
||||
**GET /memory/query?project=<proj>&query=<q>&method=hybrid** (default)
|
||||
|
||||
Combines semantic (60%) + lexical (40%) scores. Best accuracy.
|
||||
|
||||
**Requires:** OpenSearch deployment
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $JWT" \
|
||||
'http://memory.riotpiao.com/memory/query?project=poimen&query=kubernetes+port+conflict'
|
||||
|
||||
# Response (with fallback):
|
||||
{
|
||||
"query": "kubernetes port conflict",
|
||||
"project": "poimen",
|
||||
"method": "hybrid", # or "semantic_fallback" if OpenSearch unavailable
|
||||
"results": [
|
||||
{
|
||||
"level": "L1",
|
||||
"score": 0.992,
|
||||
"text": "...",
|
||||
"source": "claude",
|
||||
"provenance": ["pi-1", "claude-2"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Score Calculation (Hybrid):**
|
||||
```
|
||||
final_score = 0.6 * semantic_score + 0.4 * lexical_score
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Semantic Path (pgvector)
|
||||
|
||||
```
|
||||
Query → LLM Embed (768-dim) → pgvector IVFFlat search
|
||||
↓
|
||||
Top-50 results (cosine distance)
|
||||
```
|
||||
|
||||
**Index:** `memory_vector (kind='Text')`
|
||||
**Partial Index:** `ON (kind = 'Text') WHERE level IN ('L0', 'L1')`
|
||||
|
||||
### Lexical Path (OpenSearch BM25)
|
||||
|
||||
```
|
||||
Query → Tokenize → OpenSearch BM25 search (with JWT auth)
|
||||
↓
|
||||
Top-50 results (TF-IDF score)
|
||||
```
|
||||
|
||||
**Index:** `vault-* indices` with `multi_match` on `content^2, breadcrumb`
|
||||
**Security:** JWT realm validates Authentik tokens
|
||||
|
||||
### Fusion (Hybrid Only)
|
||||
|
||||
```
|
||||
semantic_norm[0..1] + lexical_norm[0..1]
|
||||
↓
|
||||
merge results by ID
|
||||
↓
|
||||
final_score = 0.6*sem + 0.4*lex
|
||||
↓
|
||||
sort descending → top-10
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment Checklist
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [ ] Memory Service pod running (with JWT validator configured)
|
||||
- [ ] pgvector database running (memory-db-0/1)
|
||||
- [ ] Authentik OIDC issuer configured
|
||||
|
||||
### Deploy OpenSearch (Optional for Hybrid)
|
||||
|
||||
```bash
|
||||
# 1. Apply manifests
|
||||
kubectl apply -k k8s/infra/databases/
|
||||
|
||||
# 2. Wait for OpenSearch cluster to be ready
|
||||
kubectl get pods -n poimen -l app.kubernetes.io/name=opensearch -w
|
||||
|
||||
# 3. Verify health
|
||||
kubectl port-forward -n poimen svc/opensearch-internal 9200:9200 &
|
||||
curl http://localhost:9200/_cluster/health
|
||||
# Should see: "status":"green"
|
||||
```
|
||||
|
||||
### Configure Memory Service
|
||||
|
||||
Set environment variables in deployment:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
- name: OPENSEARCH_HOSTS
|
||||
value: "opensearch-internal.poimen.svc.cluster.local:9200"
|
||||
- name: MEM_AUTH_MODE
|
||||
value: "jwt"
|
||||
```
|
||||
|
||||
Restart pods:
|
||||
```bash
|
||||
kubectl rollout restart deployment poimen-memory -n poimen
|
||||
```
|
||||
|
||||
### Test API
|
||||
|
||||
```bash
|
||||
# Get JWT from Authentik
|
||||
TOKEN=$(curl -X POST http://authentik:9000/application/o/token/ \
|
||||
-d "client_id=..." \
|
||||
-d "grant_type=client_credentials" | jq -r .access_token)
|
||||
|
||||
# Test vault endpoint
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
http://vault.riotpiao.com/memory/vault
|
||||
|
||||
# Test hybrid search
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
'http://memory.riotpiao.com/memory/query?project=poimen&query=fix+port'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Guide: HTML → JSON
|
||||
|
||||
### Before (Old)
|
||||
|
||||
```bash
|
||||
GET /memory/vault
|
||||
# Returns: <html><body>..Project list...</body></html>
|
||||
|
||||
GET /memory/vault/poimen
|
||||
# Returns: <html>File listing</html>
|
||||
|
||||
GET /memory/vault/poimen/query-123
|
||||
# Returns: <html>Rendered markdown</html>
|
||||
```
|
||||
|
||||
### After (New)
|
||||
|
||||
```bash
|
||||
GET /memory/vault
|
||||
# Returns: {"projects": [...]}
|
||||
|
||||
GET /memory/vault?project=poimen
|
||||
# Returns: {"project": "poimen", "files": [...]}
|
||||
|
||||
GET /memory/vault/poimen/query-123
|
||||
# Returns: {"project": "...", "file": "...", "metadata": {...}, "content": "..."}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend Integration
|
||||
|
||||
### React/Vue Implementation
|
||||
|
||||
```typescript
|
||||
// Vault browser
|
||||
async function getProjectVault(project: string, token: string) {
|
||||
const res = await fetch(
|
||||
`/memory/vault?project=${project}`,
|
||||
{ headers: { 'Authorization': `Bearer ${token}` } }
|
||||
);
|
||||
const data = await res.json();
|
||||
return data.files; // Array of {path, name, title, updated_at}
|
||||
}
|
||||
|
||||
// Get file content
|
||||
async function getFileContent(project: string, file: string, token: string) {
|
||||
const res = await fetch(
|
||||
`/memory/vault/${project}/${file}`,
|
||||
{ headers: { 'Authorization': `Bearer ${token}` } }
|
||||
);
|
||||
return await res.json();
|
||||
// {metadata: {...}, content: "..."}
|
||||
}
|
||||
|
||||
// Hybrid search
|
||||
async function search(query: string, project: string, token: string) {
|
||||
const res = await fetch(
|
||||
`/memory/query?project=${project}&query=${encodeURIComponent(query)}`,
|
||||
{ headers: { 'Authorization': `Bearer ${token}` } }
|
||||
);
|
||||
const data = await res.json();
|
||||
return data.results; // Top-10 hybrid results
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DNS & Ingress
|
||||
|
||||
### DNS Records
|
||||
|
||||
Add to your DNS:
|
||||
|
||||
```
|
||||
vault.riotpiao.com IN A 203.x.x.x (cluster IP)
|
||||
memory.riotpiao.com IN A 203.x.x.x (same)
|
||||
```
|
||||
|
||||
### Ingress Configuration
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: memory-ingress
|
||||
namespace: poimen
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- vault.riotpiao.com
|
||||
- memory.riotpiao.com
|
||||
secretName: memory-tls
|
||||
rules:
|
||||
# Vault endpoints
|
||||
- host: vault.riotpiao.com
|
||||
http:
|
||||
paths:
|
||||
- path: /memory/vault
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: poimen-memory
|
||||
port:
|
||||
number: 8080
|
||||
# Full API
|
||||
- host: memory.riotpiao.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: poimen-memory
|
||||
port:
|
||||
number: 8080
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fallback Behavior
|
||||
|
||||
If OpenSearch is unavailable:
|
||||
|
||||
1. Hybrid requests fall back to semantic-only (no error)
|
||||
2. Returns `method: "semantic_fallback"` in response
|
||||
3. Lexical-specific queries not supported (return 400 Bad Request)
|
||||
|
||||
To require hybrid (fail if unavailable):
|
||||
|
||||
```bash
|
||||
curl '...?query=...&method=hybrid&strict=true'
|
||||
# Returns 503 Service Unavailable if OpenSearch down
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Tuning
|
||||
|
||||
### pgvector Index Parameters
|
||||
|
||||
```sql
|
||||
-- Current: IVFFlat with 100 lists
|
||||
CREATE INDEX ON memory_vector
|
||||
USING ivfflat (embedding vector_cosine_ops)
|
||||
WITH (lists=100);
|
||||
|
||||
-- For larger datasets (>1M vectors):
|
||||
-- Use lists=sqrt(rows), e.g., lists=1000 for 1M
|
||||
```
|
||||
|
||||
### OpenSearch Shard Configuration
|
||||
|
||||
```yaml
|
||||
# In opensearch.yaml
|
||||
index:
|
||||
number_of_shards: 3
|
||||
number_of_replicas: 1
|
||||
codec: best_compression
|
||||
```
|
||||
|
||||
### Caching
|
||||
|
||||
OpenSearchClient has 1-hour query cache. Clear if needed:
|
||||
|
||||
```bash
|
||||
curl -X POST http://opensearch:9200/vault-*/_cache/clear
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### JWT Validation
|
||||
|
||||
✅ Memory Service validates Authentik tokens
|
||||
✅ OpenSearch has JWT realm configured
|
||||
⚠️ No TLS between Memory Service → OpenSearch (K8s network isolated)
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
```
|
||||
/memory/vault/*: 100 req/hr per API key
|
||||
/memory/query: 1000 req/hr per API key
|
||||
```
|
||||
|
||||
### Field-Level Access Control
|
||||
|
||||
⚠️ Future: row-level security per project_id (not yet implemented)
|
||||
|
||||
---
|
||||
|
||||
## Metrics
|
||||
|
||||
Monitor these endpoints for production:
|
||||
|
||||
```prometheus
|
||||
# Latency
|
||||
histogram_quantile(0.95, http_request_duration_seconds{endpoint="/memory/query"})
|
||||
|
||||
# Cache hit rate
|
||||
opensearch_query_cache_hit_count / (opensearch_query_cache_hit_count + opensearch_query_cache_miss_count)
|
||||
|
||||
# Cluster health
|
||||
opensearch_cluster_health_status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. ✅ Deploy OpenSearch manifests (`k8s/infra/databases/opensearch.yaml`)
|
||||
2. ✅ Configure Memory Service env vars (OPENSEARCH_HOSTS)
|
||||
3. ✅ Update ingress for vault.riotpiao.com
|
||||
4. ⬜ Frontend React app (vault browser UI, search form)
|
||||
5. ⬜ GRC endpoints (git + merge workflow)
|
||||
@@ -1,344 +0,0 @@
|
||||
# Context Optimizer — Pre-LLM Compression Layer
|
||||
|
||||
## Motivation
|
||||
|
||||
Agent transcripts and tool outputs are noisy. A 50-chunk ingestion run might
|
||||
feed the GRU-Mem gate evidence that's 43% tool results, full of timestamps,
|
||||
temp paths, ANSI codes, and verbose JSON. The model wastes tokens parsing noise,
|
||||
risks hallucinating on irrelevant details, and we pay full price for bloated
|
||||
input. LLM provider KV caches miss because dynamic content (timestamps, session
|
||||
IDs) pollutes the prefix.
|
||||
|
||||
**This layer sits between retrieval and the LLM call.** Search indexes
|
||||
(pgvector + OpenSearch) stay untouched at full fidelity. Only the evidence
|
||||
chunks entering the prompt get optimized.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Query → Hybrid Search (pgvector 60% + OpenSearch 40%)
|
||||
│
|
||||
│ full-fidelity chunks (untouched)
|
||||
▼
|
||||
┌───────────────────────┐
|
||||
│ CONTEXT OPTIMIZER │ ← THIS MODULE
|
||||
│ │
|
||||
│ 1. CacheAligner │ Move dynamic content (timestamps, UUIDs)
|
||||
│ │ to end of context. Keep static prefix
|
||||
│ │ stable for KV cache hits.
|
||||
│ │
|
||||
│ 2. ContentRouter │ Auto-detect content type per chunk:
|
||||
│ │ JSON, code, logs, diffs, plain text.
|
||||
│ │ Route each to best compressor.
|
||||
│ │
|
||||
│ 3. Compressors │ Per-type compression:
|
||||
│ - JsonCrusher │ Statistical field analysis, keep keys
|
||||
│ - LogCompressor │ Keep errors/stack traces, drop noise
|
||||
│ - CodeCompressor │ AST-aware: keep signatures, drop bodies
|
||||
│ - TextCompressor │ Token importance scoring
|
||||
│ - DiffCompressor │ Keep change hunks, drop context
|
||||
│ │
|
||||
│ 4. CCR Store │ Cache full originals with hash reference.
|
||||
│ │ Inject retrieval hint so model can fetch
|
||||
│ │ full content if needed.
|
||||
│ │
|
||||
└───────────┬───────────┘
|
||||
│
|
||||
│ optimized chunks (smaller, cleaner)
|
||||
▼
|
||||
┌───────────────────────┐
|
||||
│ Cache-Aligned Prompt │ (already built)
|
||||
│ system | query | turn│
|
||||
└───────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
LLM Gateway
|
||||
```
|
||||
|
||||
## What Each Stage Does
|
||||
|
||||
### Stage 1: CacheAligner
|
||||
|
||||
**Goal:** Maximize LLM provider KV cache hits by stabilizing the prompt prefix.
|
||||
|
||||
LLM providers (Anthropic, OpenAI) cache based on exact prefix match. A single
|
||||
changing timestamp or session ID early in the prompt invalidates the entire
|
||||
cache. CacheAligner:
|
||||
|
||||
1. Scans for dynamic patterns in the prompt prefix:
|
||||
- ISO timestamps (`2026-08-28T...`)
|
||||
- UUIDs (`550e8400-e29b-...`)
|
||||
- Session tokens, run IDs
|
||||
- Temp paths (`/tmp/abc123`)
|
||||
|
||||
2. Moves detected dynamic content to the end of the context (after static
|
||||
instructions and query), preserving the stable prefix for cache hits.
|
||||
|
||||
3. Reports drift metrics: how much of the prefix changed vs. previous call.
|
||||
|
||||
**Implementation:** Regex-based pattern detection + reordering. No ML needed.
|
||||
Reuses existing normalisation patterns from `lesson.rs` (M3.7.7).
|
||||
|
||||
```rust
|
||||
pub struct CacheAligner;
|
||||
|
||||
impl CacheAligner {
|
||||
/// Stabilize prompt prefix by moving dynamic content to tail.
|
||||
/// Returns (stable_prefix, dynamic_tail).
|
||||
pub fn align(content: &str) -> AlignedContent {
|
||||
// Detect and extract dynamic patterns
|
||||
// Reorder so static content comes first
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Stage 2: ContentRouter (Magika ML + regex fallback)
|
||||
|
||||
**Goal:** Auto-detect content type and route to the best compressor.
|
||||
|
||||
**Primary classifier:** Google Magika (`magika` crate v1.1.0) — fast encoder-only
|
||||
ONNX model that classifies content into 100+ types. <1ms per classification.
|
||||
No LLM calls, no network — runs locally with embedded ONNX model.
|
||||
|
||||
**Fallback:** Regex heuristics for content types Magika doesn't distinguish
|
||||
well (e.g., build logs vs. plain text) or when confidence is below threshold.
|
||||
|
||||
```rust
|
||||
use magika::Session;
|
||||
|
||||
pub struct ContentRouter {
|
||||
magika: Session,
|
||||
confidence_threshold: f32, // default 0.7
|
||||
}
|
||||
|
||||
pub enum ContentType {
|
||||
Json,
|
||||
Code { language: String },
|
||||
Log,
|
||||
Diff,
|
||||
Config,
|
||||
Text,
|
||||
}
|
||||
|
||||
impl ContentRouter {
|
||||
pub fn detect(&self, content: &str) -> ContentType {
|
||||
// 1. Try Magika ML classification
|
||||
if let Ok(result) = self.magika.identify_content_sync(content.as_bytes()) {
|
||||
let label = result.info().label;
|
||||
let score = result.score();
|
||||
|
||||
if score >= self.confidence_threshold {
|
||||
return match label {
|
||||
"json" | "jsonl" => ContentType::Json,
|
||||
"python" | "javascript" | "typescript" | "rust" | "go" | "shell"
|
||||
=> ContentType::Code { language: label.to_string() },
|
||||
"diff" => ContentType::Diff,
|
||||
"yaml" | "toml" | "ini" | "xml" => ContentType::Config,
|
||||
_ => self.regex_fallback(content),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback to regex heuristics
|
||||
self.regex_fallback(content)
|
||||
}
|
||||
|
||||
fn regex_fallback(&self, content: &str) -> ContentType {
|
||||
if is_json(content) { return ContentType::Json; }
|
||||
if is_log(content) { return ContentType::Log; }
|
||||
if is_diff(content) { return ContentType::Diff; }
|
||||
if is_code(content) { return ContentType::Code { language: "unknown".into() }; }
|
||||
ContentType::Text
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Magika label → compressor mapping:**
|
||||
|
||||
| Magika Label | ContentType | Compressor |
|
||||
|---|---|---|
|
||||
| `json`, `jsonl` | Json | JsonCrusher |
|
||||
| `python`, `javascript`, `rust`, `go`, `typescript`, `shell` | Code | CodeCompressor |
|
||||
| `diff` | Diff | DiffCompressor |
|
||||
| `yaml`, `toml`, `ini`, `xml` | Config | (passthrough, already compact) |
|
||||
| `txt` + log heuristics | Log | LogCompressor |
|
||||
| everything else | Text | TextCompressor |
|
||||
|
||||
### Stage 3: Compressors
|
||||
|
||||
**Goal:** Reduce token count per content type while preserving signal.
|
||||
|
||||
#### JsonCrusher (70-90% savings)
|
||||
- Analyse field-level variance across JSON array elements
|
||||
- Keep: keys, structure, error markers, boundary items (first/last)
|
||||
- Drop: redundant mid-array elements, long string values, whitespace
|
||||
- Allocation: 30% start (schema), 15% end (recency), 55% importance
|
||||
|
||||
#### LogCompressor (85-95% savings)
|
||||
- Keep: error lines, stack traces, exit codes, FAIL markers
|
||||
- Drop: passing test output, INFO-level noise, repeated patterns
|
||||
- Reuses M3.7.7 signature extraction patterns (markers, cascade detection)
|
||||
|
||||
#### CodeCompressor (40-70% savings, opt-in)
|
||||
- Keep: imports, function/method signatures, type annotations
|
||||
- Drop: function bodies, inline comments, blank lines
|
||||
- Uses simple AST heuristics (brace counting), not full parser
|
||||
|
||||
#### DiffCompressor (60-80% savings)
|
||||
- Keep: change hunks (`+`/`-` lines), hunk headers
|
||||
- Drop: unchanged context lines (the `@@` surrounding context)
|
||||
|
||||
#### TextCompressor (30-50% savings)
|
||||
- Token importance scoring: keep high-entropy tokens (IDs, hashes, error codes)
|
||||
- Drop: low-information prose, repeated phrases, filler words
|
||||
|
||||
```rust
|
||||
pub trait Compressor {
|
||||
fn compress(&self, content: &str, budget: usize) -> CompressResult;
|
||||
}
|
||||
|
||||
pub struct CompressResult {
|
||||
pub compressed: String,
|
||||
pub original_tokens: usize,
|
||||
pub compressed_tokens: usize,
|
||||
pub content_type: ContentType,
|
||||
/// Hash of original content for CCR retrieval
|
||||
pub ccr_hash: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
### Stage 4: CCR Store (Compress-Cache-Retrieve)
|
||||
|
||||
**Goal:** Lossless compression — model can retrieve full originals if needed.
|
||||
|
||||
When a chunk is compressed, the full original is cached with a SHA256 hash.
|
||||
A retrieval hint is injected into the compressed output:
|
||||
|
||||
```
|
||||
[compressed content...]
|
||||
<!-- CCR:abc123 — full content available via retrieval -->
|
||||
```
|
||||
|
||||
If the model determines it needs more detail, it can request the full content.
|
||||
This makes compression aggressive but reversible.
|
||||
|
||||
```rust
|
||||
pub struct CcrStore {
|
||||
cache: HashMap<String, String>, // hash → original content
|
||||
max_entries: usize,
|
||||
ttl: Duration,
|
||||
}
|
||||
|
||||
impl CcrStore {
|
||||
pub fn store(&mut self, content: &str) -> String; // returns hash
|
||||
pub fn retrieve(&self, hash: &str) -> Option<&str>;
|
||||
}
|
||||
```
|
||||
|
||||
## Integration with Existing Code
|
||||
|
||||
### Where It Plugs In
|
||||
|
||||
The context optimizer sits in `mem-core` as a new module, called by
|
||||
`PromptBuilder::build_cache_aligned()` before assembling the final prompt:
|
||||
|
||||
```rust
|
||||
// In PromptBuilder::build_cache_aligned()
|
||||
let chunk_text = Self::render_chunk(chunk)?;
|
||||
|
||||
// NEW: Optimize before prompt assembly
|
||||
let optimized = ContextOptimizer::optimize(&chunk_text, &OptimizeConfig {
|
||||
cache_align: true,
|
||||
compress: true,
|
||||
ccr_enabled: true,
|
||||
token_budget: BUDGET_CHUNK_MAX,
|
||||
})?;
|
||||
|
||||
let turn_msg = CACHE_TURN
|
||||
.replace("{memory}", memory_text)
|
||||
.replace("{chunk}", &optimized.compressed);
|
||||
```
|
||||
|
||||
### What Already Exists (Reuse)
|
||||
|
||||
| Existing Code | Reuse For |
|
||||
|---|---|
|
||||
| `lesson.rs` normalise() | CacheAligner pattern detection (timestamps, paths, SHAs) |
|
||||
| `lesson.rs` markers() | LogCompressor error line detection |
|
||||
| `lesson.rs` is_cascade() | LogCompressor cascade suppression |
|
||||
| `lesson.rs` strip_ansi() | Pre-processing for all compressors |
|
||||
| `symptom_projection.rs` stop words | TextCompressor low-value token detection |
|
||||
|
||||
### What's New
|
||||
|
||||
| New Code | Location | Est. LOC |
|
||||
|---|---|---|
|
||||
| `context_optimizer.rs` | `crates/mem-core/src/` | 150 |
|
||||
| `content_router.rs` | `crates/mem-core/src/` | 150 |
|
||||
| `compressors/json.rs` | `crates/mem-core/src/` | 200 |
|
||||
| `compressors/log.rs` | `crates/mem-core/src/` | 150 |
|
||||
| `compressors/code.rs` | `crates/mem-core/src/` | 150 |
|
||||
| `compressors/diff.rs` | `crates/mem-core/src/` | 100 |
|
||||
| `compressors/text.rs` | `crates/mem-core/src/` | 100 |
|
||||
| `ccr_store.rs` | `crates/mem-core/src/` | 80 |
|
||||
| **Total** | | **~1030** |
|
||||
|
||||
## Performance Targets
|
||||
|
||||
| Metric | Target |
|
||||
|---|---|
|
||||
| Detection + compression | < 10ms per chunk |
|
||||
| JSON compression ratio | 70-90% |
|
||||
| Log compression ratio | 85-95% |
|
||||
| Code compression ratio | 40-70% |
|
||||
| Cache hit rate improvement | > 50% on repeated queries |
|
||||
| Zero false negatives | Model should never miss real errors |
|
||||
|
||||
## Phasing
|
||||
|
||||
### Phase 1: Foundation (1-2 days)
|
||||
- `ContextOptimizer` orchestrator
|
||||
- `ContentRouter` with detection heuristics
|
||||
- `LogCompressor` (reuses M3.7.7 lesson.rs patterns)
|
||||
- 10 unit tests
|
||||
|
||||
### Phase 2: JSON + Diff (1-2 days)
|
||||
- `JsonCrusher` with field variance analysis
|
||||
- `DiffCompressor` with hunk preservation
|
||||
- 10 unit tests
|
||||
|
||||
### Phase 3: CCR + CacheAligner (1 day)
|
||||
- `CcrStore` with LRU cache
|
||||
- `CacheAligner` with dynamic pattern extraction
|
||||
- Integration with `PromptBuilder::build_cache_aligned()`
|
||||
- 10 unit tests
|
||||
|
||||
### Phase 4: Code + Text (1 day)
|
||||
- `CodeCompressor` (opt-in, brace-counting heuristics)
|
||||
- `TextCompressor` (token importance scoring)
|
||||
- 10 unit tests
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
1. **Magika ML for detection, rules for compression.** Content type detection
|
||||
uses Google's Magika ONNX model (<1ms, local, no network). Compression
|
||||
itself uses deterministic algorithms (no LLM calls in hot path).
|
||||
|
||||
2. **Search indexes untouched.** Compression happens AFTER retrieval. pgvector
|
||||
and OpenSearch see full-fidelity text. Only the LLM prompt is optimized.
|
||||
|
||||
3. **Reversible via CCR.** Every compression is reversible. The model can
|
||||
request full originals via hash lookup. Aggressive compression is safe
|
||||
because nothing is permanently lost.
|
||||
|
||||
4. **Reuse existing code.** M3.7.7's normalisation patterns, marker detection,
|
||||
and cascade suppression are directly reusable for log compression. M3.7.8's
|
||||
stop words help text compression.
|
||||
|
||||
5. **Budget-aware.** Each compressor respects a token budget. If the chunk is
|
||||
already under budget, no compression is applied (zero overhead).
|
||||
|
||||
---
|
||||
|
||||
Inspired by [Headroom](https://docs.headroomlabs.ai/docs/how-compression-works).
|
||||
Adapted for Rust, integrated with poimen-memory's hybrid search architecture.
|
||||
@@ -1,315 +0,0 @@
|
||||
# Deployment Checklist: Memory Service API Ready
|
||||
|
||||
## Phase 1: API Endpoints Ready ✅
|
||||
|
||||
### Vault Endpoints (JSON API)
|
||||
|
||||
- [x] `GET /memory/vault` — list projects
|
||||
- [x] `GET /memory/vault?project=X` — file tree
|
||||
- [x] `GET /memory/vault/{project}/{file}` — file content (JSON)
|
||||
- [x] YAML frontmatter parsing to JSON metadata
|
||||
- [x] JWT auth on all endpoints
|
||||
|
||||
### Search Endpoints
|
||||
|
||||
- [x] `GET /memory/query?method=semantic` — pgvector only
|
||||
- [x] `GET /memory/query?method=hybrid` — semantic + OpenSearch (with fallback)
|
||||
- [x] Hybrid score fusion (60% semantic + 40% lexical)
|
||||
- [x] JWT auth required
|
||||
|
||||
### Code Changes
|
||||
|
||||
- [x] `crates/mem-cli/src/http_server.rs` — vault + search handlers
|
||||
- `vault_browser_handler()` — returns JSON projects list
|
||||
- `vault_project_tree()` — helper for file tree
|
||||
- `vault_project_handler()` — GET /{project} → file tree
|
||||
- `vault_file_handler()` — GET /{project}/{file} → JSON content
|
||||
- `query_handler()` — updated for hybrid search with OpenSearch fallback
|
||||
- `AppState.opensearch_client` — optional OpenSearch integration
|
||||
- [x] Environment variable: `OPENSEARCH_HOSTS` (optional)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: OpenSearch Deployment
|
||||
|
||||
### K8s Manifests
|
||||
|
||||
- [x] `k8s/infra/databases/opensearch.yaml`
|
||||
- StatefulSet: 2 replicas (opensearch-0, opensearch-1)
|
||||
- Service: `opensearch` (headless), `opensearch-internal` (ClusterIP)
|
||||
- ConfigMap: opensearch.yml configuration
|
||||
- PVC: 30Gi per pod (Longhorn)
|
||||
- ServiceAccount + NetworkPolicy
|
||||
- Probes: liveness (60s), readiness (30s)
|
||||
- Security: security plugin disabled (assume K8s network isolation)
|
||||
|
||||
- [x] Updated `k8s/infra/databases/kustomization.yaml`
|
||||
- Added `- opensearch.yaml` to resources
|
||||
|
||||
### Deployment Steps
|
||||
|
||||
```bash
|
||||
# 1. Deploy OpenSearch
|
||||
kubectl apply -k k8s/infra/databases/
|
||||
|
||||
# 2. Wait for StatefulSet ready
|
||||
kubectl get pods -n poimen -l app.kubernetes.io/name=opensearch -w
|
||||
|
||||
# Expected:
|
||||
# opensearch-0 1/1 Running
|
||||
# opensearch-1 1/1 Running
|
||||
|
||||
# 3. Verify cluster health
|
||||
kubectl port-forward -n poimen svc/opensearch-internal 9200:9200 &
|
||||
curl http://localhost:9200/_cluster/health
|
||||
# {"status":"green","...}
|
||||
|
||||
# 4. Configure Memory Service
|
||||
kubectl set env deployment poimen-memory \
|
||||
-n poimen \
|
||||
OPENSEARCH_HOSTS=opensearch-internal.poimen.svc.cluster.local:9200
|
||||
|
||||
# 5. Restart Memory Service
|
||||
kubectl rollout restart deployment poimen-memory -n poimen
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: DNS & Ingress
|
||||
|
||||
### DNS Records
|
||||
|
||||
```
|
||||
vault.riotpiao.com IN A <cluster-ip>
|
||||
memory.riotpiao.com IN A <cluster-ip>
|
||||
```
|
||||
|
||||
### Ingress Routes
|
||||
|
||||
**vault.riotpiao.com** → /memory/vault/* endpoints (vault browser)
|
||||
**memory.riotpiao.com** → full API (search, projects, skills, etc.)
|
||||
|
||||
Sample Ingress:
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: memory-ingress
|
||||
namespace: poimen
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- vault.riotpiao.com
|
||||
- memory.riotpiao.com
|
||||
secretName: memory-tls
|
||||
rules:
|
||||
- host: vault.riotpiao.com
|
||||
http:
|
||||
paths:
|
||||
- path: /memory/vault
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: poimen-memory
|
||||
port: {number: 8080}
|
||||
- host: memory.riotpiao.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: poimen-memory
|
||||
port: {number: 8080}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Testing
|
||||
|
||||
### Test Vault Endpoints
|
||||
|
||||
```bash
|
||||
# Authenticate
|
||||
TOKEN=$(curl -X POST https://authentik.riotpiao.com/application/o/token/ \
|
||||
-d "client_id=poimen-memory" \
|
||||
-d "client_secret=..." \
|
||||
-d "grant_type=client_credentials" | jq -r .access_token)
|
||||
|
||||
# List projects
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
https://vault.riotpiao.com/memory/vault
|
||||
# Expected: {"projects": ["poimen", ...]}
|
||||
|
||||
# List files in project
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
'https://vault.riotpiao.com/memory/vault?project=poimen'
|
||||
# Expected: {"project": "poimen", "files": [...]}
|
||||
|
||||
# Get file content
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
https://vault.riotpiao.com/memory/vault/poimen/index
|
||||
# Expected: {"project": "poimen", "file": "index.md", "metadata": {...}, "content": "..."}
|
||||
```
|
||||
|
||||
### Test Search Endpoints
|
||||
|
||||
```bash
|
||||
# Semantic only
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
'https://memory.riotpiao.com/memory/query?project=poimen&query=kubernetes&method=semantic'
|
||||
# Expected: {"method": "semantic", "results": [...]}
|
||||
|
||||
# Hybrid (best)
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
'https://memory.riotpiao.com/memory/query?project=poimen&query=kubernetes'
|
||||
# Expected: {"method": "hybrid", "results": [...]}
|
||||
# (or "semantic_fallback" if OpenSearch not ready)
|
||||
```
|
||||
|
||||
### Load Test
|
||||
|
||||
```bash
|
||||
ab -n 1000 -c 10 \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
'https://memory.riotpiao.com/memory/query?project=poimen&query=test'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Frontend Deployment (Next)
|
||||
|
||||
Waiting on:
|
||||
- [ ] React SPA build
|
||||
- [ ] Vault browser UI
|
||||
- [ ] Search form + result display
|
||||
- [ ] GRC workflow (edit → MR → merge)
|
||||
- [ ] Agent execution tracking
|
||||
|
||||
---
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Endpoints Health
|
||||
|
||||
```bash
|
||||
# Memory Service
|
||||
curl https://memory.riotpiao.com/health
|
||||
|
||||
# OpenSearch
|
||||
curl https://vault.riotpiao.com/memory/query?project=test&query=test
|
||||
# If errors → check OPENSEARCH_HOSTS config
|
||||
|
||||
# Metrics
|
||||
kubectl logs -n poimen -l app.kubernetes.io/name=poimen-memory --tail=100
|
||||
```
|
||||
|
||||
### Dashboard Metrics
|
||||
|
||||
```prometheus
|
||||
# Query latency
|
||||
histogram_quantile(0.95, http_request_duration_seconds{method="GET", endpoint="/memory/query"})
|
||||
|
||||
# Cache hit rate
|
||||
opensearch_query_cache_hit_count / opensearch_query_cache_total
|
||||
|
||||
# Cluster health
|
||||
opensearch_cluster_health_status # 1 = green, 0 = red
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fallback Behavior
|
||||
|
||||
### If OpenSearch is Down
|
||||
|
||||
✅ /memory/vault/* endpoints work (no OpenSearch dependency)
|
||||
✅ /memory/query with method=semantic works
|
||||
⚠️ /memory/query with method=hybrid falls back to semantic (no error)
|
||||
❌ /memory/query with method=hybrid&strict=true returns 503
|
||||
|
||||
### If pgvector is Down
|
||||
|
||||
❌ All endpoints fail (core dependency)
|
||||
|
||||
### If Authentik is Down
|
||||
|
||||
❌ All endpoints fail with 401 (no auth)
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### OpenSearch not found
|
||||
|
||||
**Symptom:** "semantic_fallback" always returned, no hybrid scores
|
||||
|
||||
**Fix:**
|
||||
```bash
|
||||
# Check env var
|
||||
kubectl get deployment -n poimen poimen-memory -o yaml | grep OPENSEARCH_HOSTS
|
||||
|
||||
# Set it
|
||||
kubectl set env deployment poimen-memory -n poimen \
|
||||
OPENSEARCH_HOSTS=opensearch-internal.poimen.svc.cluster.local:9200
|
||||
kubectl rollout restart deployment poimen-memory -n poimen
|
||||
|
||||
# Verify connectivity from pod
|
||||
kubectl exec -n poimen <pod> -- curl http://opensearch-internal:9200/_cluster/health
|
||||
```
|
||||
|
||||
### OpenSearch cluster red
|
||||
|
||||
**Symptom:** opensearch-1 not starting, cluster unhealthy
|
||||
|
||||
**Fix:**
|
||||
```bash
|
||||
# Check logs
|
||||
kubectl logs -n poimen opensearch-1
|
||||
|
||||
# Common issues:
|
||||
# 1. vm.max_map_count too low (init container should fix)
|
||||
# 2. PVC not provisioned (check Longhorn)
|
||||
# 3. Memory limit too low (increase to 1Gi)
|
||||
|
||||
# Reset cluster
|
||||
kubectl delete pvc opensearch-data-opensearch-1 -n poimen
|
||||
kubectl delete pod opensearch-1 -n poimen
|
||||
```
|
||||
|
||||
### JWT validation fails on queries
|
||||
|
||||
**Symptom:** `401 Unauthorized: JWT validation failed`
|
||||
|
||||
**Fix:**
|
||||
```bash
|
||||
# Check Authentik JWKS accessible
|
||||
curl https://authentik.riotpiao.com/application/o/poimen-memory/jwks/
|
||||
|
||||
# Check token not expired
|
||||
jwt decode <your-token> # look at 'exp' claim
|
||||
|
||||
# Check token has required claims
|
||||
# Should have: iss, aud, sub, roles, permissions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist Summary
|
||||
|
||||
### Ready for Production
|
||||
|
||||
- [x] API endpoints return JSON (not HTML)
|
||||
- [x] Vault tree endpoint working
|
||||
- [x] Hybrid search implemented (with fallback)
|
||||
- [x] OpenSearch manifests created
|
||||
- [x] JWT auth on all endpoints
|
||||
- [x] Environment variables documented
|
||||
- [x] Deployment guide written
|
||||
- [ ] Frontend React app deployed
|
||||
- [ ] GRC workflow endpoints implemented
|
||||
- [ ] Load tested at scale
|
||||
- [ ] Monitoring configured
|
||||
|
||||
**Status:** Ready to deploy OpenSearch + test API
|
||||
@@ -1,332 +0,0 @@
|
||||
# Configurable Embeddings Models
|
||||
|
||||
**Status**: Implemented
|
||||
**Feature**: Customer-selectable embedding models via `EMBEDDINGS_MODEL` environment variable
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The memory system supports multiple embedding models, all configured to return **exactly 768 dimensions** to match the pgvector schema and HNSW indexes.
|
||||
|
||||
Switch models without schema changes by setting `EMBEDDINGS_MODEL` environment variable.
|
||||
|
||||
---
|
||||
|
||||
## Supported Models
|
||||
|
||||
### 1. nomic-ai/nomic-embed-text-v2-moe (Default)
|
||||
|
||||
**Characteristics**:
|
||||
- **Dimensions**: 768
|
||||
- **Speed**: Fast (MoE optimization)
|
||||
- **Quality**: Good
|
||||
- **Languages**: 30+ (multilingual)
|
||||
- **Use case**: Default, production recommended
|
||||
|
||||
```bash
|
||||
export EMBEDDINGS_MODEL=nomic-ai/nomic-embed-text-v2-moe
|
||||
```
|
||||
|
||||
### 2. nomic-ai/nomic-embed-text-v1.5
|
||||
|
||||
**Characteristics**:
|
||||
- **Dimensions**: 768
|
||||
- **Speed**: Slower than v2-moe
|
||||
- **Quality**: Slightly better than v2-moe
|
||||
- **Languages**: 30+ (multilingual)
|
||||
- **Use case**: When quality matters more than speed
|
||||
|
||||
```bash
|
||||
export EMBEDDINGS_MODEL=nomic-ai/nomic-embed-text-v1.5
|
||||
```
|
||||
|
||||
### 3. all-MiniLM-L6-v2
|
||||
|
||||
**Characteristics**:
|
||||
- **Dimensions**: 384 (padded to 768 for schema compatibility)
|
||||
- **Speed**: Very fast (lightweight)
|
||||
- **Quality**: Good for similarity
|
||||
- **Languages**: English
|
||||
- **Use case**: High-throughput, English-only scenarios
|
||||
|
||||
```bash
|
||||
export EMBEDDINGS_MODEL=all-MiniLM-L6-v2
|
||||
# or
|
||||
export EMBEDDINGS_MODEL=sentence-transformers/all-MiniLM-L6-v2
|
||||
```
|
||||
|
||||
### 4. BAAI/bge-small-en-v1.5
|
||||
|
||||
**Characteristics**:
|
||||
- **Dimensions**: 384 (padded to 768)
|
||||
- **Speed**: Very fast
|
||||
- **Quality**: Good for English retrieval
|
||||
- **Languages**: English
|
||||
- **Use case**: Fast English-only systems
|
||||
|
||||
```bash
|
||||
export EMBEDDINGS_MODEL=BAAI/bge-small-en-v1.5
|
||||
```
|
||||
|
||||
### 5. BAAI/bge-base-en-v1.5
|
||||
|
||||
**Characteristics**:
|
||||
- **Dimensions**: 768 (native)
|
||||
- **Speed**: Medium
|
||||
- **Quality**: Excellent for English
|
||||
- **Languages**: English
|
||||
- **Use case**: Best quality for English-only deployments
|
||||
|
||||
```bash
|
||||
export EMBEDDINGS_MODEL=BAAI/bge-base-en-v1.5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# Select embedding model (default: nomic-ai/nomic-embed-text-v2-moe)
|
||||
export EMBEDDINGS_MODEL=nomic-ai/nomic-embed-text-v2-moe
|
||||
|
||||
# Gateway endpoint (default: https://api.riotpiao.com)
|
||||
export LLM_API_BASE=https://api.riotpiao.com
|
||||
|
||||
# Optional: API key for gated models
|
||||
export LLM_API_KEY=your-api-key
|
||||
```
|
||||
|
||||
### Kubernetes Deployment
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: poimen-memory
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: memory
|
||||
env:
|
||||
- name: EMBEDDINGS_MODEL
|
||||
value: "nomic-ai/nomic-embed-text-v1.5" # Higher quality
|
||||
- name: LLM_API_BASE
|
||||
value: "https://api.riotpiao.com"
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker run \
|
||||
-e EMBEDDINGS_MODEL=all-MiniLM-L6-v2 \
|
||||
-e LLM_API_BASE=https://api.riotpiao.com \
|
||||
poimen-memory:latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comparison Table
|
||||
|
||||
| Model | Dims | Speed | Quality | Languages | Use Case |
|
||||
|-------|------|-------|---------|-----------|----------|
|
||||
| **nomic-v2-moe** (default) | 768 | ⚡⚡⚡ | ⭐⭐⭐ | 30+ | **Production default** |
|
||||
| **nomic-v1.5** | 768 | ⚡⚡ | ⭐⭐⭐⭐ | 30+ | Quality-first, multilingual |
|
||||
| **all-MiniLM-L6** | 384→768 | ⚡⚡⚡⚡ | ⭐⭐⭐ | English | High throughput |
|
||||
| **bge-small-en** | 384→768 | ⚡⚡⚡⚡ | ⭐⭐⭐ | English | Fast retrieval |
|
||||
| **bge-base-en** | 768 | ⚡⚡ | ⭐⭐⭐⭐⭐ | English | English-only best quality |
|
||||
|
||||
---
|
||||
|
||||
## Performance Impact
|
||||
|
||||
### Embedding Latency (per text)
|
||||
|
||||
```
|
||||
nomic-v2-moe: ~50ms ← Default (good balance)
|
||||
all-MiniLM-L6: ~30ms ← Fastest
|
||||
nomic-v1.5: ~80ms
|
||||
bge-base-en: ~60ms
|
||||
```
|
||||
|
||||
### Throughput at 10 batch size
|
||||
|
||||
```
|
||||
nomic-v2-moe: ~200 texts/sec
|
||||
all-MiniLM-L6: ~330 texts/sec ← Highest throughput
|
||||
nomic-v1.5: ~125 texts/sec
|
||||
bge-base-en: ~165 texts/sec
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Switching Models
|
||||
|
||||
### 1. While Running
|
||||
|
||||
```bash
|
||||
# Change env var
|
||||
kubectl set env deployment/poimen-memory \
|
||||
EMBEDDINGS_MODEL=nomic-ai/nomic-embed-text-v1.5
|
||||
|
||||
# Restart pods (will use new model)
|
||||
kubectl rollout restart deployment/poimen-memory
|
||||
|
||||
# Monitor logs
|
||||
kubectl logs -f deployment/poimen-memory | grep "Embeddings client"
|
||||
# Expected: "Embeddings client initialized: model=nomic-ai/nomic-embed-text-v1.5"
|
||||
```
|
||||
|
||||
### 2. New Ingests
|
||||
|
||||
Changing models only affects **new** ingests. Existing chunks keep their old embeddings.
|
||||
|
||||
To re-embed existing chunks:
|
||||
```bash
|
||||
# 1. Mark all chunks as pending re-embedding
|
||||
psql -h memory-db -U app memory -c \
|
||||
"UPDATE chunks SET indexed_in_pgvector = false, opensearch_pending = true;"
|
||||
|
||||
# 2. Restart queue worker (reprocesses all chunks)
|
||||
kubectl rollout restart deployment/poimen-memory
|
||||
|
||||
# Wait for queue to clear
|
||||
# Monitor: kubectl logs -f deployment/poimen-memory | grep "messages_processed"
|
||||
```
|
||||
|
||||
### 3. Validate Model
|
||||
|
||||
```bash
|
||||
# Check logs for model initialization
|
||||
kubectl logs -n poimen deployment/poimen-memory | grep "Embeddings client"
|
||||
|
||||
# Test embedding endpoint
|
||||
curl -X POST https://api.riotpiao.com/v1/embeddings \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "nomic-ai/nomic-embed-text-v1.5",
|
||||
"input": ["hello world"]
|
||||
}' | jq '.data[0].embedding | length'
|
||||
# Output: 768
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Unsupported embedding model" Error
|
||||
|
||||
**Error**:
|
||||
```
|
||||
thread 'actix-web' panicked at 'Unsupported embedding model: bert-base-uncased'
|
||||
```
|
||||
|
||||
**Fix**:
|
||||
1. Check supported models list above
|
||||
2. Use one of the validated models
|
||||
3. If you have a custom model, update `validate_model()` in embeddings.rs
|
||||
|
||||
### Wrong Dimensionality
|
||||
|
||||
**Error**:
|
||||
```
|
||||
model custom-embed-384 returned 384-dim vector, expected 768
|
||||
```
|
||||
|
||||
**Cause**: Model returns 384-dim vectors, but pgvector schema expects 768
|
||||
|
||||
**Solutions**:
|
||||
1. Use a model that returns 768-dim (e.g., `nomic-ai/nomic-embed-text-v1.5`)
|
||||
2. Or manually pad 384-dim vectors to 768-dim by adding zeros
|
||||
3. Or re-migrate schema to 384-dim (complex, not recommended)
|
||||
|
||||
### Slow Embedding Performance
|
||||
|
||||
**If latency > 200ms per text**:
|
||||
|
||||
```bash
|
||||
# Try faster model
|
||||
kubectl set env deployment/poimen-memory \
|
||||
EMBEDDINGS_MODEL=all-MiniLM-L6-v2
|
||||
|
||||
# Check embedding service health
|
||||
curl https://api.riotpiao.com/v1/models | jq '.data[] | select(.id | contains("embed"))'
|
||||
|
||||
# Monitor queue worker metrics
|
||||
kubectl logs -f deployment/poimen-memory | grep "processing_time"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration with Queue Worker
|
||||
|
||||
The Queue Worker automatically uses the configured embedding model:
|
||||
|
||||
```rust
|
||||
// crates/mem-cli/src/queue_worker.rs
|
||||
let embedding_vec = embeddings.embed_one(&content).await?;
|
||||
// ↑ Uses model from EMBEDDINGS_MODEL env var
|
||||
```
|
||||
|
||||
When queue worker logs show:
|
||||
```
|
||||
Embeddings client initialized: model=nomic-ai/nomic-embed-text-v1.5
|
||||
```
|
||||
|
||||
All embeddings are computed with that model.
|
||||
|
||||
---
|
||||
|
||||
## Validation at Startup
|
||||
|
||||
The system validates model compatibility on HTTP server startup:
|
||||
|
||||
```
|
||||
INFO Embeddings client initialized: model=nomic-ai/nomic-embed-text-v2-moe, base_url=https://api.riotpiao.com
|
||||
```
|
||||
|
||||
If validation fails, server refuses to start:
|
||||
|
||||
```
|
||||
ERROR Unsupported embedding model: unknown-model. Supported models: [...]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding Custom Models
|
||||
|
||||
To support a new embedding model:
|
||||
|
||||
1. **Verify dimension**: Test with gateway
|
||||
```bash
|
||||
curl -X POST https://api.riotpiao.com/v1/embeddings \
|
||||
-d '{"model": "my-custom-model", "input": ["test"]}'
|
||||
# Check dimension count in response
|
||||
```
|
||||
|
||||
2. **Add to allowed list** (crates/mem-llm/src/embeddings.rs):
|
||||
```rust
|
||||
let supported_models = vec![
|
||||
"nomic-ai/nomic-embed-text-v2-moe",
|
||||
"my-custom-model", // ← Add here
|
||||
];
|
||||
```
|
||||
|
||||
3. **Document dimensions** in this file
|
||||
|
||||
4. **Test**:
|
||||
```bash
|
||||
EMBEDDINGS_MODEL=my-custom-model cargo run --bin mem -- serve
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Nomic AI Models](https://www.nomic.ai/)
|
||||
- [BGE Models (BAAI)](https://huggingface.co/BAAI/bge-base-en-v1.5)
|
||||
- [Sentence Transformers](https://www.sbert.net/)
|
||||
- [Gateway Embeddings API](https://api.riotpiao.com/docs#/embeddings)
|
||||
@@ -1,301 +0,0 @@
|
||||
# M8.7 & M8.8 — Index Tuning & Accuracy Benchmarks Results
|
||||
|
||||
**Date**: 2024-08-28
|
||||
**Baseline**: Commit `df29334` (M8.3-M8.6 complete)
|
||||
**Status**: ✅ COMPLETE
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Semantic (pgvector) | Lexical (OpenSearch) | Hybrid (RRF) |
|
||||
|--------|-----|--------|---------|
|
||||
| **NDCG@10** | 0.82 | 0.75 | 0.88 |
|
||||
| **MRR** | 0.91 | 0.68 | 0.92 |
|
||||
| **Precision@10** | 0.80 | 0.72 | 0.85 |
|
||||
| **Recall@10** | 0.78 | 0.71 | 0.86 |
|
||||
| **Query Latency (p95)** | 95ms | 65ms | 120ms |
|
||||
|
||||
**Conclusion**: Hybrid search with RRF fusion outperforms both semantic-only and lexical-only approaches across all metrics.
|
||||
|
||||
---
|
||||
|
||||
## pgvector Index Tuning (M8.7)
|
||||
|
||||
### Baseline Configuration (Commit df29334)
|
||||
```sql
|
||||
CREATE INDEX idx_chunks_embedding ON chunks USING hnsw (embedding vector_cosine_ops)
|
||||
WHERE indexed_in_pgvector = true AND embedding IS NOT NULL;
|
||||
```
|
||||
|
||||
**Parameters**: HNSW defaults
|
||||
- `m = 16` (max connections per node)
|
||||
- `ef_construction = 64` (build-time search width)
|
||||
- `ef_search = 40` (query-time search width)
|
||||
|
||||
### Tuning Process
|
||||
|
||||
1. **Baseline Measurement** (20 test queries)
|
||||
- NDCG@10: 0.80
|
||||
- MRR: 0.89
|
||||
- Recall@10: 0.76
|
||||
- Latency (p95): 98ms
|
||||
|
||||
2. **Increase ef_construction to 128**
|
||||
- Hypothesis: Better recall without significant latency impact
|
||||
- Result: NDCG@10 improved to 0.82
|
||||
- Latency (p95): 105ms (acceptable)
|
||||
- **Decision**: KEEP
|
||||
|
||||
3. **Increase m to 20**
|
||||
- Hypothesis: Higher degree = better connectivity = better recall
|
||||
- Result: NDCG@10 plateaued at 0.82
|
||||
- Latency (p95): 110ms
|
||||
- **Decision**: REVERT (diminishing returns)
|
||||
|
||||
### Final Configuration
|
||||
```sql
|
||||
DROP INDEX IF EXISTS idx_chunks_embedding;
|
||||
|
||||
CREATE INDEX idx_chunks_embedding ON chunks USING hnsw (embedding vector_cosine_ops)
|
||||
WHERE indexed_in_pgvector = true AND embedding IS NOT NULL
|
||||
WITH (m = 16, ef_construction = 128);
|
||||
|
||||
-- Set query-time parameter
|
||||
SET hnsw.ef_search = 40;
|
||||
```
|
||||
|
||||
**Performance**: NDCG@10 = 0.82 (+2.5% vs baseline)
|
||||
|
||||
---
|
||||
|
||||
## OpenSearch Index Tuning (M8.7)
|
||||
|
||||
### Baseline Configuration
|
||||
```json
|
||||
{
|
||||
"settings": {
|
||||
"index.analysis.analyzer.standard": {
|
||||
"type": "standard"
|
||||
}
|
||||
},
|
||||
"mappings": {
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "text",
|
||||
"analyzer": "standard",
|
||||
"boost": 2.0
|
||||
},
|
||||
"source": {"type": "keyword"},
|
||||
"breadcrumb": {"type": "keyword"}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Baseline Metrics**:
|
||||
- NDCG@10: 0.72
|
||||
- Recall@10: 0.68
|
||||
- Latency (p95): 68ms
|
||||
|
||||
### Tuning: Add Synonyms
|
||||
|
||||
**Change**: Add synonym filter for common abbreviations
|
||||
```json
|
||||
{
|
||||
"settings": {
|
||||
"index.analysis.filter.synonyms": {
|
||||
"type": "synonym",
|
||||
"synonyms": [
|
||||
"k8s,kubernetes",
|
||||
"db,database",
|
||||
"cfg,config",
|
||||
"api,application programming interface"
|
||||
]
|
||||
},
|
||||
"index.analysis.analyzer.text_analyzer": {
|
||||
"type": "custom",
|
||||
"tokenizer": "standard",
|
||||
"filter": ["lowercase", "stop", "synonyms"]
|
||||
}
|
||||
},
|
||||
"mappings": {
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "text",
|
||||
"analyzer": "text_analyzer",
|
||||
"boost": 2.0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Results**: NDCG@10 improved to 0.74 (+2.8%)
|
||||
|
||||
**Decision**: KEEP
|
||||
|
||||
### Tuning: Add Edge N-gram for Typo Tolerance
|
||||
|
||||
**Change**: Support partial term matching
|
||||
```json
|
||||
{
|
||||
"settings": {
|
||||
"index.analysis.tokenizer.edge_ngram_tokenizer": {
|
||||
"type": "edge_ngram",
|
||||
"min_gram": 2,
|
||||
"max_gram": 15,
|
||||
"token_chars": ["letter", "digit"]
|
||||
},
|
||||
"index.analysis.analyzer.text_analyzer": {
|
||||
"type": "custom",
|
||||
"tokenizer": "edge_ngram_tokenizer",
|
||||
"filter": ["lowercase", "stop", "synonyms"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Results**: NDCG@10 improved to 0.75 (+4.2% from baseline)
|
||||
Latency (p95): 71ms (minimal impact)
|
||||
|
||||
**Decision**: KEEP
|
||||
|
||||
### Field Boost Tuning
|
||||
|
||||
**Tested**: Adjusting `boost` parameters
|
||||
|
||||
| Configuration | NDCG@10 | Latency (p95) |
|
||||
|---|---|---|
|
||||
| content^2.0, source^1.0, breadcrumb^0.8 (baseline) | 0.72 | 68ms |
|
||||
| content^2.5, source^0.8, breadcrumb^0.5 | 0.74 | 70ms |
|
||||
| content^1.8, source^1.2, breadcrumb^1.0 | 0.71 | 68ms |
|
||||
|
||||
**Decision**: Keep baseline config; boost tuning had minimal impact
|
||||
|
||||
### Final OpenSearch Configuration
|
||||
```json
|
||||
{
|
||||
"settings": {
|
||||
"number_of_shards": 2,
|
||||
"number_of_replicas": 1,
|
||||
"index.analysis.filter.synonyms": {
|
||||
"type": "synonym",
|
||||
"synonyms": [
|
||||
"k8s,kubernetes",
|
||||
"db,database",
|
||||
"cfg,config"
|
||||
]
|
||||
},
|
||||
"index.analysis.tokenizer.edge_ngram_tokenizer": {
|
||||
"type": "edge_ngram",
|
||||
"min_gram": 2,
|
||||
"max_gram": 15,
|
||||
"token_chars": ["letter", "digit"]
|
||||
},
|
||||
"index.analysis.analyzer.text_analyzer": {
|
||||
"type": "custom",
|
||||
"tokenizer": "edge_ngram_tokenizer",
|
||||
"filter": ["lowercase", "stop", "synonyms"]
|
||||
}
|
||||
},
|
||||
"mappings": {
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "text",
|
||||
"analyzer": "text_analyzer",
|
||||
"boost": 2.0
|
||||
},
|
||||
"source": {"type": "keyword", "boost": 1.0},
|
||||
"breadcrumb": {"type": "keyword", "boost": 0.8}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Performance**: NDCG@10 = 0.75 (+4.2% vs baseline)
|
||||
|
||||
---
|
||||
|
||||
## Hybrid Search Fusion (M8.4/M8.6)
|
||||
|
||||
### RRF Configuration
|
||||
```rust
|
||||
pub struct RRFConfig {
|
||||
pub k: usize = 60, // Standard per Cormack et al. 2009
|
||||
}
|
||||
```
|
||||
|
||||
### Metrics
|
||||
|
||||
| Configuration | NDCG@10 | MRR | Latency (p95) |
|
||||
|---|---|---|---|
|
||||
| Semantic only | 0.82 | 0.91 | 95ms |
|
||||
| Lexical only | 0.75 | 0.68 | 65ms |
|
||||
| Hybrid (RRF k=60) | 0.88 | 0.92 | 120ms |
|
||||
|
||||
**Improvement**: Hybrid RRF fusion improved NDCG@10 by **7.3%** vs semantic-only
|
||||
|
||||
---
|
||||
|
||||
## Test Query Set
|
||||
|
||||
**File**: `fixtures/search_queries.yaml`
|
||||
**Queries**: 20 diverse queries across 4 types
|
||||
- Factual: 8 queries
|
||||
- Procedural: 6 queries
|
||||
- Comparative: 2 queries
|
||||
- Troubleshooting: 4 queries
|
||||
|
||||
---
|
||||
|
||||
## Implementation Artifacts
|
||||
|
||||
### Code
|
||||
- `crates/mem-cli/src/accuracy_metrics.rs` (350 LOC)
|
||||
- NDCG@K, MRR, Precision@K, Recall@K calculation
|
||||
- BenchmarkSummary for multi-query stats
|
||||
- 8 unit tests
|
||||
|
||||
### Configuration
|
||||
- OpenSearch index template with synonyms + edge_ngram
|
||||
- pgvector HNSW parameters optimized (m=16, ef_construction=128)
|
||||
|
||||
### Test Data
|
||||
- `fixtures/search_queries.yaml` (20 queries with relevance judgments)
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Verify pgvector HNSW index
|
||||
psql -U postgres -d memory -c "SELECT indexname, indexdef FROM pg_indexes WHERE tablename='chunks' AND indexname LIKE '%hnsw%';"
|
||||
|
||||
# Verify OpenSearch settings
|
||||
curl -k https://opensearch-internal:9200/vault-*/_settings | jq '.*.settings.index.analysis'
|
||||
|
||||
# Run accuracy benchmarks
|
||||
cargo run --bin mem -- bench-search \
|
||||
--queries fixtures/search_queries.yaml \
|
||||
--output docs/INDEX_TUNING_RESULTS.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lessons Learned
|
||||
|
||||
1. **HNSW better than IVFFlat**: Default HNSW parameters provide 2% recall improvement
|
||||
2. **Synonyms help**: Common abbreviations boost NDCG by ~3%
|
||||
3. **Edge n-grams add value**: Typo tolerance increases coverage by 1-2%
|
||||
4. **RRF fusion powerful**: Combining semantic + lexical improves NDCG by 7%
|
||||
5. **Hybrid latency acceptable**: 120ms p95 vs 95ms semantic-only is reasonable tradeoff
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
✅ M8.7: Index optimization complete
|
||||
✅ M8.8: Accuracy benchmarks documented
|
||||
⏳ M8.9: Composition gate validation (verify hybrid > semantic baseline)
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
# JWT Authentication for Poimen Memory Service
|
||||
|
||||
## Overview
|
||||
|
||||
Memory service validates incoming requests using JWT tokens issued by Authentik OIDC provider. The API Gateway (homelab-frontend) fetches a token from Authentik and passes it to Memory service as a bearer token. Memory service validates the token directly against Authentik's JWKS endpoint without requiring Vault in the request path.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Client/Gateway
|
||||
↓ (Authorization: Bearer <JWT>)
|
||||
Memory Service (http_server)
|
||||
↓ validate_jwt_token()
|
||||
Authentik JWKS Endpoint (cached)
|
||||
↓ (signature + claims validation)
|
||||
JwtClaims (sub, iss, aud, permissions, groups)
|
||||
↓ (check has_capability())
|
||||
Route Handler (ingest, query, vault, etc.)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Required for JWT mode:
|
||||
- `MEM_AUTH_MODE=jwt` — Enable JWT validation (default: apikey)
|
||||
- `AUTHENTIK_ISSUER` — Authentik OIDC issuer, e.g. `https://authentik.riotpiao.com/application/o/memory/`
|
||||
- `AUTHENTIK_AUDIENCE` — Memory service's client ID in Authentik, e.g. `poimen-memory`
|
||||
|
||||
Optional:
|
||||
- `JWT_CACHE_TTL_SECS` — JWKS cache TTL in seconds (default: 3600)
|
||||
|
||||
### K8s Deployment
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: poimen-memory
|
||||
namespace: poimen
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: memory
|
||||
image: registry/poimen-memory:latest
|
||||
env:
|
||||
- name: MEM_AUTH_MODE
|
||||
value: "jwt"
|
||||
- name: AUTHENTIK_ISSUER
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: poimen-config
|
||||
key: authentik-issuer-url
|
||||
- name: AUTHENTIK_AUDIENCE
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: poimen-config
|
||||
key: memory-client-id
|
||||
- name: JWT_CACHE_TTL_SECS
|
||||
value: "3600"
|
||||
# ... other env vars
|
||||
```
|
||||
|
||||
## Capabilities
|
||||
|
||||
JWT tokens must include a `permissions` claim with one of:
|
||||
- `memory:read` — Read-only: query, skills, projects, vault browsing
|
||||
- `memory:write` — Write: ingest, source sync, vault generation
|
||||
- `*` — Wildcard: all capabilities (typically for homelab-admins group)
|
||||
|
||||
Example permissions claim in token:
|
||||
```json
|
||||
{
|
||||
"permissions": ["memory:read", "memory:write"]
|
||||
}
|
||||
```
|
||||
|
||||
## API Request Format
|
||||
|
||||
```bash
|
||||
# Fetch JWT from Authentik (typically done by API Gateway)
|
||||
TOKEN=$(curl -s -X POST https://authentik.riotpiao.com/application/o/token/ \
|
||||
-d "grant_type=client_credentials&client_id=...&client_secret=...")
|
||||
|
||||
# Call Memory API with bearer token
|
||||
curl -H "Authorization: Bearer ${TOKEN}" \
|
||||
http://poimen-memory/memory/query?project=myproject&query=topic
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Algorithm Pinning**: Only RS256 accepted (defense against algorithm confusion attacks)
|
||||
2. **Signature Validation**: All tokens verified against Authentik's public keys
|
||||
3. **Claim Pinning**: `iss` (issuer) and `aud` (audience) must match configured values
|
||||
4. **Expiry Check**: Expired tokens rejected (60s clock skew tolerance)
|
||||
5. **JWKS Caching**: Keys cached with TTL; refreshed on key ID miss (handles rotation)
|
||||
6. **Token TTL**: Memory service does not cache validation results; each request re-validates
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
Default `MEM_AUTH_MODE=apikey` preserves old behavior:
|
||||
- Checks `apikey` header against `MEM_API_KEY` env var
|
||||
- Grants synthetic `*` permission
|
||||
- Useful for local dev/test
|
||||
|
||||
Switch to JWT by setting `MEM_AUTH_MODE=jwt`.
|
||||
|
||||
## Capability Checking in Handlers
|
||||
|
||||
Each handler checks for required capability:
|
||||
|
||||
```rust
|
||||
// In ingest_handler (write operation)
|
||||
if !has_capability(&claims, "memory:write") {
|
||||
return HttpResponse::Forbidden().json(...);
|
||||
}
|
||||
|
||||
// In query_handler (read operation)
|
||||
if !has_capability(&claims, "memory:read") {
|
||||
return HttpResponse::Forbidden().json(...);
|
||||
}
|
||||
```
|
||||
|
||||
Wildcard permission `*` grants all.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit tests in `tests/it_jwt_auth.rs`:
|
||||
- Bearer token extraction
|
||||
- JWT claims structures
|
||||
- Permission validation
|
||||
- Wildcard permission handling
|
||||
|
||||
```bash
|
||||
cargo test --test it_jwt_auth
|
||||
```
|
||||
|
||||
Example test:
|
||||
```rust
|
||||
#[test]
|
||||
fn test_jwt_permissions_claim() {
|
||||
let claims = JwtClaims {
|
||||
permissions: Some(vec!["memory:read".to_string()]),
|
||||
...
|
||||
};
|
||||
assert!(claims.permissions.unwrap().contains(&"memory:read".to_string()));
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Invalid Authorization header format"
|
||||
- Ensure request includes `Authorization: Bearer <token>` (capital B)
|
||||
- Token must not be empty
|
||||
|
||||
### "JWT validation failed: Token validation failed"
|
||||
- Check token signature: ensure Authentik JWKS endpoint is reachable
|
||||
- Verify issuer matches `AUTHENTIK_ISSUER` env var
|
||||
- Verify audience matches `AUTHENTIK_AUDIENCE` env var
|
||||
|
||||
### "Missing capability: memory:write"
|
||||
- Token's `permissions` claim must include `memory:write` or `*`
|
||||
- Check Authentik app scope configuration includes `permissions` claim
|
||||
|
||||
### JWKS fetch timeout
|
||||
- Ensure Authentik is reachable from Memory pod
|
||||
- Check network policies / firewall rules
|
||||
- Verify `AUTHENTIK_ISSUER` URL is correct
|
||||
|
||||
## Related Files
|
||||
|
||||
- `crates/mem-cli/src/jwt_validator.rs` — Token validation logic
|
||||
- `crates/mem-cli/src/http_server.rs` — Handler integration
|
||||
- `tests/it_jwt_auth.rs` — Integration tests
|
||||
- `/Users/rockliang/workplace/homelab/project-usage/jwt-auth-rollout.md` — Cluster-wide OIDC setup
|
||||
@@ -1,364 +0,0 @@
|
||||
# M3.7 Failure Diagnosis Pipeline — Complete Design
|
||||
|
||||
**Phase:** M3.7 — Tool context
|
||||
**Status:** M3.7.7 ✅ Complete | M3.7.8 🔄 In design | M3.7.4 ⬜ Pending M8.2
|
||||
**Last updated:** 2026-08-28
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
M3.7 answers: **"What do we already know about this failure or tool?"** over HTTP.
|
||||
|
||||
Provides three-tier context lookups with increasing cost and falling confidence:
|
||||
|
||||
1. **Tier 1 (Exact)** — Exact signature match (past solution)
|
||||
2. **Tier 2 (Semantic)** — Hybrid search (similar cases)
|
||||
3. **Tier 3 (Reference)** — Documentation (general guidance)
|
||||
|
||||
---
|
||||
|
||||
## Pipeline Architecture
|
||||
|
||||
```
|
||||
User Query
|
||||
│ (e.g., "npm ERESOLVE error resolving typescript")
|
||||
│
|
||||
├─────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ LAYER 1: SYMPTOM PROJECTION (M3.7.8) │
|
||||
│ ├─ Normalize query to symptom vector │
|
||||
│ ├─ Extract keywords, expand abbreviations (ERESOLVE → error) │
|
||||
│ ├─ Generate deterministic hash (sym_sha) │
|
||||
│ └─ Output: SymptomVector { tool, normalised, sym_sha } │
|
||||
│ │
|
||||
└──────────────────────┬──────────────────────────────────────────┘
|
||||
│
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ LAYER 2: SIGNATURE MATCHING (M3.7.7 + M3.7.8) │
|
||||
│ ├─ Query DB: lessons WHERE sym_sha = ? │
|
||||
│ ├─ If hit → TIER 1: Return cached solution │
|
||||
│ └─ If miss → Continue to LAYER 3 │
|
||||
│ │
|
||||
└──────────────────────┬──────────────────────────────────────────┘
|
||||
│ (Tier 1 miss)
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ LAYER 3: HYBRID SEARCH (M8) │
|
||||
│ ├─ Embed query (semantic, 60% weight) │
|
||||
│ ├─ Search pgvector (cosine similarity) │
|
||||
│ ├─ Search OpenSearch (BM25, 40% weight) │
|
||||
│ ├─ Fuse results (weighted linear: 0.6*sem + 0.4*lex) │
|
||||
│ └─ TIER 2: Return top-10 ranked results │
|
||||
│ │
|
||||
└──────────────────────┬──────────────────────────────────────────┘
|
||||
│ (Tier 2 miss)
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ LAYER 4: REFERENCE CORPUS (M3.6) │
|
||||
│ ├─ Query reference docs (kubectl, npm, etc.) │
|
||||
│ └─ TIER 3: Return general guidance (lowest confidence) │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Response (M3.7.4 context endpoint):
|
||||
{
|
||||
"tier": 1,
|
||||
"confidence": "high",
|
||||
"query_normalised": "npm error resolve dependency typescript",
|
||||
"results": [
|
||||
{
|
||||
"source": "lesson",
|
||||
"title": "Fix npm ERESOLVE errors",
|
||||
"solution": "npm ci (deterministic); npm install (latest versions)"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task Breakdown
|
||||
|
||||
### M3.7.7 ✅ COMPLETE — Failure Signature Extraction
|
||||
|
||||
**Commit:** `463958b`
|
||||
**Status:** 18/18 unit tests passing
|
||||
**Implementation:** `crates/mem-core/src/lesson.rs` (871 LOC)
|
||||
|
||||
#### What it does:
|
||||
- Extracts root error from 50KB failure logs
|
||||
- Normalizes timestamps, paths, SHAs, addresses
|
||||
- Generates deterministic SHA256 hash (sig_sha)
|
||||
- Handles cascading failures (picks root cause, not consequence)
|
||||
|
||||
#### Example:
|
||||
```
|
||||
Raw log (50KB):
|
||||
2026-08-21T10:02:11.482Z
|
||||
/home/runner/work/Poimen/memory/k8s/app/opensearch.yaml
|
||||
error: error validating data: [ValidationError(...)]
|
||||
The server is rejecting the request. (422)
|
||||
[... 100 lines of structured output ...]
|
||||
|
||||
↓ [M3.7.7 extract()]
|
||||
|
||||
Signature:
|
||||
tool: "kubectl"
|
||||
raw: "error: error validating data: [ValidationError(...)]"
|
||||
normalised: "error validating data kubernetes"
|
||||
sig_sha: "abc123def456789..." ← deterministic
|
||||
rule: "kubectl_error_line"
|
||||
```
|
||||
|
||||
#### Tests (18 passing):
|
||||
- ✅ Same failure (2 runs) → identical hash
|
||||
- ✅ Different failures → different hashes
|
||||
- ✅ Removes ANSI, timestamps, paths, SHAs
|
||||
- ✅ Picks first error (cascade suppression)
|
||||
- ✅ Unknown tools fallback gracefully
|
||||
- ✅ Tool name part of identity
|
||||
- ✅ Similar wording still matches
|
||||
- ✅ + 11 more (see `cargo test -p mem-core lesson`)
|
||||
|
||||
---
|
||||
|
||||
### M3.7.8 🔄 IN DESIGN — Symptom Projection
|
||||
|
||||
**Status:** Full design complete (see `docs/M3.7.8-SYMPTOM_PROJECTION.md`)
|
||||
**Estimated:** 1–2 days
|
||||
**Implementation Plan:** 250 LOC + 6 test assertions
|
||||
|
||||
#### What it does:
|
||||
Transforms **user queries** into normalized **symptom vectors** that can match extracted signatures.
|
||||
|
||||
**Three-stage pipeline:**
|
||||
|
||||
**Stage 1: Extract Keywords**
|
||||
```
|
||||
Query: "npm can't find tslib module error"
|
||||
↓
|
||||
Keywords: ["npm", "find", "tslib", "module", "error"]
|
||||
```
|
||||
|
||||
**Stage 2: Normalize**
|
||||
```
|
||||
Keywords: ["npm", "find", "tslib", "module", "error"]
|
||||
↓ [Remove stop words: can, find (weak), t]
|
||||
Normalized: "error module npm tslib"
|
||||
↓ [Sort alphabetically for idempotence]
|
||||
Canonical: "error module npm tslib"
|
||||
```
|
||||
|
||||
**Stage 3: Generate Hash**
|
||||
```
|
||||
Canonical: "error module npm tslib"
|
||||
↓ [SHA256(tool + "\n" + normalized)]
|
||||
sym_sha: "xyz789abc..." ← matches M3.7.7 signature if similar
|
||||
```
|
||||
|
||||
#### Example Match:
|
||||
```
|
||||
Extracted signature (M3.7.7):
|
||||
normalised: "error module tslib"
|
||||
sig_sha: "abc123..."
|
||||
|
||||
User query (M3.7.8):
|
||||
query: "npm cannot resolve tslib"
|
||||
normalised: "error module npm tslib" (after expand "cannot")
|
||||
sym_sha: "abc123..." ← MATCH!
|
||||
|
||||
→ Tier 1 hit: Return past solution
|
||||
```
|
||||
|
||||
#### Tests (6 assertions):
|
||||
1. Same symptom variant → same hash
|
||||
2. Abbreviation expansion (ERESOLVE, ERR, OOM, EACCES)
|
||||
3. Stop word removal (can, the, able, to)
|
||||
4. Tool consistency (npm != cargo for same error)
|
||||
5. Case insensitive (NPM = npm)
|
||||
6. Keyword order irrelevant (sorted before hash)
|
||||
|
||||
#### Files to create:
|
||||
- `crates/mem-core/src/symptom_projection.rs` (250 LOC)
|
||||
- `tests/it_symptom_projection.rs` (400 LOC, 6 assertions)
|
||||
- `fixtures/symptoms/` (test query examples)
|
||||
|
||||
---
|
||||
|
||||
### M3.7.4 ⬜ PENDING — Context Endpoint Integration
|
||||
|
||||
**Status:** Waiting for M3.7.8 + M8.2 (hybrid search live)
|
||||
**Purpose:** HTTP endpoint gluing tiers 1–3 together
|
||||
**Endpoint:** `GET /memory/context?query=...&tool=...`
|
||||
|
||||
#### Three-tier logic:
|
||||
```rust
|
||||
pub async fn get_context(query: &str, tool: Option<&str>) -> ContextResult {
|
||||
// TIER 1: Exact signature match
|
||||
let symptom = project_symptom(tool.unwrap_or("unknown"), query); // M3.7.8
|
||||
if let Some(lesson) = find_lesson_by_sym_sha(&symptom.sym_sha) {
|
||||
return high_confidence_result(lesson);
|
||||
}
|
||||
|
||||
// TIER 2: Hybrid search (requires M8.2)
|
||||
let hybrid = hybrid_search(query, tool).await?;
|
||||
if !hybrid.is_empty() {
|
||||
return medium_confidence_result(hybrid);
|
||||
}
|
||||
|
||||
// TIER 3: Reference corpus (requires M3.6)
|
||||
let reference = reference_corpus_search(query, tool).await?;
|
||||
return low_confidence_result(reference);
|
||||
}
|
||||
```
|
||||
|
||||
#### Response format:
|
||||
```json
|
||||
{
|
||||
"tier": 1,
|
||||
"confidence": "high",
|
||||
"query_normalised": "error module npm tslib",
|
||||
"results": [
|
||||
{
|
||||
"source": "lesson",
|
||||
"title": "Fix npm module not found",
|
||||
"keywords": ["npm", "error", "module"],
|
||||
"solution": "Check package.json, npm install",
|
||||
"applies_to": ["npm", "yarn"],
|
||||
"severity": "medium"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### M3.7.6 ⬜ PENDING — Composition Gate
|
||||
|
||||
**Status:** Depends on M3.7.4 + M3.7.8
|
||||
**Purpose:** Verify tiers work end-to-end (accuracy, latency, coverage)
|
||||
|
||||
#### Gate assertions:
|
||||
1. Tier 1 queries resolve in < 50ms (cached lookup)
|
||||
2. Tier 2 queries resolve in < 500ms (hybrid search)
|
||||
3. Tier 3 queries resolve in < 1000ms (reference corpus)
|
||||
4. Exactly one tier returns results (no duplicates across tiers)
|
||||
5. Confidence scores monotonically decrease (tier 1 > 2 > 3)
|
||||
6. Coverage: 95% of real failures find a tier 1 or 2 match
|
||||
|
||||
---
|
||||
|
||||
## Retired Tasks
|
||||
|
||||
✅ **M3.7.3** — `GET /memory/skills?task=...` (skill matching)
|
||||
→ Redundant with M8 hybrid search
|
||||
→ Removed: 1 task
|
||||
|
||||
✅ **M3.7.5** — `tool-failures` standing query
|
||||
→ Redundant with M8 hybrid search
|
||||
→ Removed: 1 task
|
||||
|
||||
**Outcome:** M3.7 reduced from 6 tasks → 4 tasks (gain: 2 simplified, no loss)
|
||||
|
||||
---
|
||||
|
||||
## Integration Points
|
||||
|
||||
| Component | Depends On | Used By |
|
||||
|-----------|-----------|---------|
|
||||
| M3.7.7 (Signature) | M3.6.1 (DocCorpus) | M3.7.8 (Symptom) |
|
||||
| M3.7.8 (Symptom) | M3.7.7 (Signature) | M3.7.4 (Context endpoint) |
|
||||
| M3.7.4 (Context) | M3.7.8 + M8.2 + M3.6 | M3.7.6 (Gate) |
|
||||
| M3.7.6 (Gate) | M3.7.4 | — |
|
||||
|
||||
**Critical path:**
|
||||
```
|
||||
M3.7.7 ✅ → M3.7.8 🔄 → M3.7.4 ⬜
|
||||
↓ [waits for M8.2]
|
||||
M3.7.6 ⬜
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Targets
|
||||
|
||||
| Operation | Latency | Notes |
|
||||
|-----------|---------|-------|
|
||||
| M3.7.7: Extract signature | < 50ms | 50KB log → hash (rule-based) |
|
||||
| M3.7.8: Project symptom | < 10ms | Query → normalized vector |
|
||||
| Tier 1 lookup (M3.7.4) | < 50ms | DB hash lookup |
|
||||
| Tier 2 lookup (M3.7.4) | < 500ms | Hybrid search (parallel engines) |
|
||||
| Tier 3 lookup (M3.7.4) | < 1000ms | Reference corpus search |
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### M3.7.7 ✅
|
||||
- [x] 18 unit tests passing (signature extraction)
|
||||
- [x] Deterministic hashing (same failure → same hash)
|
||||
- [x] CLI command: `mem sig explain`
|
||||
- [x] Fixtures: real captured logs (npm, cargo, kubectl, etc.)
|
||||
|
||||
### M3.7.8 🔄
|
||||
- [ ] 6 integration tests passing (symptom projection)
|
||||
- [ ] Deterministic hashing (same query variant → same hash)
|
||||
- [ ] Abbreviation expansion per tool (ERESOLVE, ERR, EACCES, etc.)
|
||||
- [ ] Integrated with M3.7.4 context endpoint
|
||||
- [ ] Tier 1 lookups work end-to-end
|
||||
|
||||
### M3.7.4 ⬜
|
||||
- [ ] HTTP endpoint operational
|
||||
- [ ] Three-tier logic working
|
||||
- [ ] Response includes `tier`, `confidence` fields
|
||||
- [ ] Integrated with M8.2 (hybrid search)
|
||||
|
||||
### M3.7.6 ⬜
|
||||
- [ ] Gate assertions passing (latency, coverage, accuracy)
|
||||
- [ ] End-to-end failure diagnosis working
|
||||
|
||||
---
|
||||
|
||||
## Documents
|
||||
|
||||
- **Core Implementation:** `crates/mem-core/src/lesson.rs` (M3.7.7, 871 LOC)
|
||||
- **M3.7.8 Design:** `docs/M3.7.8-SYMPTOM_PROJECTION.md` (this guide, 14KB)
|
||||
- **Pipeline Flow:** `memory-flow.md` § "M3.7.7 → M3.7.8: Failure Diagnosis Pipeline" (176 lines)
|
||||
- **Tests:** `tests/it_signature.rs` (M3.7.7, 9 assertions)
|
||||
|
||||
---
|
||||
|
||||
## Timeline
|
||||
|
||||
**Completed:** M3.7.7 (18 unit tests passing)
|
||||
**Next (1–2 days):** M3.7.8 symptom projection
|
||||
**Then (1 day):** M3.7.4 context endpoint
|
||||
**Finally (1 day):** M3.7.6 composition gate
|
||||
|
||||
**Total M3.7:** ~5 days (1 done, 4 remaining)
|
||||
|
||||
---
|
||||
|
||||
## Team Notes
|
||||
|
||||
- **Determinism is critical:** Both M3.7.7 and M3.7.8 must hash identically across runs
|
||||
- Use sorted keywords (not insertion order)
|
||||
- Include tool name in identity
|
||||
- Strip all volatile data (timestamps, addresses, etc.)
|
||||
|
||||
- **Tier 1 only fires if M3.7.8 sym_sha matches M3.7.7 sig_sha**
|
||||
- If query is ambiguous or doesn't match any signature → skip to tier 2
|
||||
- Fall back to hybrid search (tier 2) gracefully
|
||||
|
||||
- **M8.2 is a blocker for M3.7.4**
|
||||
- Hybrid search must be live before context endpoint can work
|
||||
- (Tier 2 fallback requires functional search)
|
||||
|
||||
---
|
||||
|
||||
**End of M3.7 summary.**
|
||||
@@ -1,554 +0,0 @@
|
||||
# M3.7.8 — Symptom Projection at Ingest
|
||||
|
||||
**Status**: Design · Ready to implement
|
||||
**Size**: M (1–2 days)
|
||||
**Depends**: M3.7.7 (signature extraction)
|
||||
**Blocks**: M3.7.4 (context endpoint), M3.7.6 (gate)
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Transform incoming user queries and agent failure reports into normalized symptom vectors that can be matched against extracted failure signatures from M3.7.7, enabling tier 1 (exact match) lookups in the three-tier context endpoint (M3.7.4).
|
||||
|
||||
---
|
||||
|
||||
## The Problem
|
||||
|
||||
**M3.7.7 extracts** failure signatures from logs and produces deterministic hashes:
|
||||
```
|
||||
Log: npm ERR! code ERESOLVE unable to resolve dependency tree
|
||||
Signature: npm_ERR_ERESOLVE_dependency_tree (normalized)
|
||||
sig_sha: abc123def... (deterministic)
|
||||
```
|
||||
|
||||
**M3.7.8 must handle** user queries that don't match the log format:
|
||||
```
|
||||
User query: "npm can't find module tslib"
|
||||
Expected: "npm error module not found"
|
||||
Problem: These don't normalize to the same string
|
||||
```
|
||||
|
||||
**Solution:** Symptom projection normalizes BOTH:
|
||||
- Extracted signature ← M3.7.7 (log normalization)
|
||||
- User query ← M3.7.8 (query normalization)
|
||||
- If both normalize to the same sym_sha → tier 1 hit ✅
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### Three-Stage Normalization Pipeline
|
||||
|
||||
#### Stage 1: Extract Symptom Keywords
|
||||
|
||||
**Goal**: Pull actionable error signals from free text.
|
||||
|
||||
```rust
|
||||
Input query: "npm error: unable to resolve typescript dependency tree"
|
||||
|
||||
Step 1a: Detect tool
|
||||
→ "npm" (from query context or user param)
|
||||
|
||||
Step 1b: Identify error patterns
|
||||
• Keywords: ["unable", "resolve", "typescript", "dependency", "tree"]
|
||||
• Error phrases: ["unable to resolve", "dependency tree"]
|
||||
|
||||
Step 1c: Expand abbreviations
|
||||
• "ERESOLVE" → ["error", "resolve"]
|
||||
• "ERR" → ["error"]
|
||||
• "cannot" → "can not" (splits)
|
||||
• "can't" → "cannot" (normalizes)
|
||||
|
||||
Step 1d: Extract entity types
|
||||
• Module/package names (typescript)
|
||||
• Error codes (E400, EACCES)
|
||||
• Version specifiers (^1.0, 2.1.3)
|
||||
|
||||
Output: SymptomTokens {
|
||||
tool: "npm",
|
||||
keywords: ["unable", "resolve", "typescript", "dependency", "tree"],
|
||||
error_codes: [],
|
||||
modules: ["typescript"]
|
||||
}
|
||||
```
|
||||
|
||||
#### Stage 2: Normalize to Canonical Form
|
||||
|
||||
**Goal**: Create identical strings from variant phrasings.
|
||||
|
||||
```rust
|
||||
Input: SymptomTokens {
|
||||
tool: "npm",
|
||||
keywords: ["unable", "resolve", "typescript", "dependency", "tree"],
|
||||
error_codes: [],
|
||||
modules: ["typescript"]
|
||||
}
|
||||
|
||||
Step 2a: Remove stop words
|
||||
Remove: [a, an, the, is, are, be, can, may, to, in, of, ...]
|
||||
Remaining: ["unable", "resolve", "typescript", "dependency", "tree"]
|
||||
|
||||
Step 2b: Normalize case
|
||||
→ ["unable", "resolve", "typescript", "dependency", "tree"]
|
||||
|
||||
Step 2c: Apply stemming (if needed)
|
||||
resolve ← resolved, resolving, resolution
|
||||
depend ← dependency, dependent
|
||||
|
||||
Step 2d: Expand tool-specific shorthand
|
||||
For npm:
|
||||
• ERESOLVE → "error resolve"
|
||||
• ENOENT → "not found"
|
||||
• EACCES → "permission denied"
|
||||
For cargo:
|
||||
• "error[E0599]" → "error 0599"
|
||||
For kubectl:
|
||||
• "connection refused" → "connection refused"
|
||||
|
||||
Step 2e: Dedup and sort (for idempotence)
|
||||
Before: ["dependency", "error", "npm", "resolve", "tree"]
|
||||
After: ["dependency", "error", "npm", "resolve", "tree"]
|
||||
|
||||
Output: "dependency error npm resolve tree"
|
||||
```
|
||||
|
||||
#### Stage 3: Generate Deterministic Hash
|
||||
|
||||
**Goal**: Create sym_sha that matches M3.7.7 signatures.
|
||||
|
||||
```rust
|
||||
Input: "dependency error npm resolve tree" (sorted, deduplicated)
|
||||
|
||||
Step 3a: Construct hashable string
|
||||
→ "npm\ndependency error npm resolve tree"
|
||||
↑
|
||||
tool is part of identity
|
||||
(npm ERR vs cargo error are different)
|
||||
|
||||
Step 3b: Hash with SHA256
|
||||
sym_sha = SHA256("npm\ndependency error npm resolve tree")
|
||||
= "xyz789abc123..."
|
||||
|
||||
Output: SymptomVector {
|
||||
tool: "npm",
|
||||
raw_query: "npm error: unable to resolve typescript dependency tree",
|
||||
normalised: "dependency error npm resolve tree",
|
||||
sym_sha: "xyz789abc123...",
|
||||
keywords: ["dependency", "error", "npm", "resolve", "tree"],
|
||||
confidence: 0.85 // based on keyword match strength
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### Core Functions
|
||||
|
||||
#### `project_symptom(tool: &str, query: &str) -> SymptomVector`
|
||||
|
||||
**Purpose**: Transform user query into normalized symptom vector.
|
||||
|
||||
```rust
|
||||
pub fn project_symptom(tool: &str, query: &str) -> SymptomVector {
|
||||
// Stage 1: Extract keywords
|
||||
let tokens = extract_keywords(tool, query);
|
||||
|
||||
// Stage 2: Normalize
|
||||
let normalised = normalize_tokens(&tokens);
|
||||
|
||||
// Stage 3: Hash
|
||||
let sym_sha = sha256(&format!("{}\n{}", tool, normalised));
|
||||
|
||||
SymptomVector {
|
||||
tool: tool.to_string(),
|
||||
raw_query: query.to_string(),
|
||||
normalised,
|
||||
sym_sha,
|
||||
keywords: tokens.keywords,
|
||||
confidence: tokens.confidence,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `extract_keywords(tool: &str, query: &str) -> SymptomTokens`
|
||||
|
||||
**Goal**: Pull error signals from query text.
|
||||
|
||||
```rust
|
||||
fn extract_keywords(tool: &str, query: &str) -> SymptomTokens {
|
||||
let mut keywords = Vec::new();
|
||||
let mut error_codes = Vec::new();
|
||||
let mut modules = Vec::new();
|
||||
|
||||
// Tokenize by whitespace + punctuation
|
||||
let tokens = tokenize(query);
|
||||
|
||||
for token in tokens {
|
||||
// Skip stop words
|
||||
if STOP_WORDS.contains(&token.to_lowercase()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Expand abbreviations
|
||||
let expanded = expand_abbrev(tool, &token);
|
||||
for word in expanded.split_whitespace() {
|
||||
if !word.is_empty() {
|
||||
keywords.push(word.to_lowercase());
|
||||
}
|
||||
}
|
||||
|
||||
// Extract structured signals
|
||||
if let Some(code) = extract_error_code(&token) {
|
||||
error_codes.push(code);
|
||||
}
|
||||
if let Some(module) = extract_module_name(tool, &token) {
|
||||
modules.push(module);
|
||||
}
|
||||
}
|
||||
|
||||
// Dedup + sort for idempotence
|
||||
keywords.sort();
|
||||
keywords.dedup();
|
||||
|
||||
SymptomTokens {
|
||||
keywords,
|
||||
error_codes,
|
||||
modules,
|
||||
confidence: keyword_strength(&keywords),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `normalize_tokens(tokens: &SymptomTokens) -> String`
|
||||
|
||||
```rust
|
||||
fn normalize_tokens(tokens: &SymptomTokens) -> String {
|
||||
let mut normalized = tokens.keywords.clone();
|
||||
|
||||
// Remove duplicates (again, for safety)
|
||||
normalized.sort();
|
||||
normalized.dedup();
|
||||
|
||||
// Join with spaces
|
||||
normalized.join(" ")
|
||||
}
|
||||
```
|
||||
|
||||
#### `expand_abbrev(tool: &str, word: &str) -> String`
|
||||
|
||||
**Per-tool abbreviation mappings:**
|
||||
|
||||
```rust
|
||||
fn expand_abbrev(tool: &str, word: &str) -> String {
|
||||
let lower = word.to_lowercase();
|
||||
|
||||
// Universal abbreviations
|
||||
match lower.as_str() {
|
||||
"err" | "error" => return "error".to_string(),
|
||||
"rc" | "return" => return "return code".to_string(),
|
||||
"oom" => return "out of memory".to_string(),
|
||||
"enoent" => return "not found".to_string(),
|
||||
"eacces" => return "permission denied".to_string(),
|
||||
"eperm" => return "operation not permitted".to_string(),
|
||||
"econnrefused" => return "connection refused".to_string(),
|
||||
"econnreset" => return "connection reset".to_string(),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Tool-specific abbreviations
|
||||
match tool {
|
||||
"npm" => match lower.as_str() {
|
||||
"eresolve" => "error resolve",
|
||||
"eexist" => "already exists",
|
||||
_ => word,
|
||||
},
|
||||
"cargo" => match lower.as_str() {
|
||||
"e0599" => "error 0599 no method",
|
||||
"e0308" => "error 0308 type mismatch",
|
||||
_ => word,
|
||||
},
|
||||
"kubectl" => match lower.as_str() {
|
||||
"crd" => "custom resource definition",
|
||||
"etcd" => "etcd",
|
||||
_ => word,
|
||||
},
|
||||
_ => word,
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
```
|
||||
|
||||
#### `sha256(s: &str) -> String`
|
||||
|
||||
```rust
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
fn sha256(s: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(s.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Structures
|
||||
|
||||
### SymptomVector
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SymptomVector {
|
||||
/// Tool name (npm, cargo, kubectl, etc.)
|
||||
pub tool: String,
|
||||
|
||||
/// Original user query (for display)
|
||||
pub raw_query: String,
|
||||
|
||||
/// Normalized, sorted keywords
|
||||
pub normalised: String,
|
||||
|
||||
/// Deterministic hash (for lookup)
|
||||
pub sym_sha: String,
|
||||
|
||||
/// Extracted keywords
|
||||
pub keywords: Vec<String>,
|
||||
|
||||
/// Confidence score (0.0-1.0) based on keyword coverage
|
||||
pub confidence: f32,
|
||||
}
|
||||
|
||||
impl SymptomVector {
|
||||
/// Check if this symptom matches a signature
|
||||
pub fn matches_signature(&self, sig: &Signature) -> bool {
|
||||
self.sym_sha == sig.sig_sha && self.tool == sig.tool
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### SymptomTokens (internal)
|
||||
|
||||
```rust
|
||||
struct SymptomTokens {
|
||||
keywords: Vec<String>,
|
||||
error_codes: Vec<String>,
|
||||
modules: Vec<String>,
|
||||
confidence: f32,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stop Words
|
||||
|
||||
```rust
|
||||
const STOP_WORDS: &[&str] = &[
|
||||
// Articles
|
||||
"a", "an", "the",
|
||||
|
||||
// Common verbs
|
||||
"is", "are", "be", "been", "being",
|
||||
"have", "has", "had",
|
||||
"do", "does", "did",
|
||||
"can", "could", "may", "might", "must", "shall", "should", "will", "would",
|
||||
|
||||
// Prepositions
|
||||
"in", "on", "at", "to", "from", "of", "for", "by", "with", "about",
|
||||
|
||||
// Conjunctions
|
||||
"and", "or", "but", "nor", "yet", "so",
|
||||
|
||||
// Pronouns
|
||||
"i", "you", "he", "she", "it", "we", "they",
|
||||
|
||||
// Common words
|
||||
"not", "no", "yes", "what", "which", "who", "when", "where", "why", "how",
|
||||
|
||||
// Extra
|
||||
"this", "that", "these", "those", "there", "here",
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tests (6 assertions)
|
||||
|
||||
### `tests/it_symptom_projection.rs`
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn a1_same_symptom_same_hash() {
|
||||
// Multiple query formulations normalize to same sym_sha
|
||||
let q1 = "npm ERR! ERESOLVE unable to resolve typescript";
|
||||
let q2 = "npm error: eresolve dependency tree";
|
||||
let q3 = "cannot resolve typescript (npm)";
|
||||
|
||||
let sym1 = project_symptom("npm", q1);
|
||||
let sym2 = project_symptom("npm", q2);
|
||||
let sym3 = project_symptom("npm", q3);
|
||||
|
||||
assert_eq!(sym1.sym_sha, sym2.sym_sha);
|
||||
assert_eq!(sym2.sym_sha, sym3.sym_sha);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a2_abbrev_expansion() {
|
||||
// ERESOLVE, ERR, OOM, EACCES expand correctly
|
||||
let cases = vec![
|
||||
("npm", "ERESOLVE", "error resolve"),
|
||||
("npm", "ERR", "error"),
|
||||
("cargo", "E0599", "error 0599"),
|
||||
("kubectl", "CRD", "custom resource definition"),
|
||||
];
|
||||
|
||||
for (tool, abbrev, expected) in cases {
|
||||
let expanded = expand_abbrev(tool, abbrev);
|
||||
assert!(expanded.contains(&expected.replace(" ", "")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a3_stop_word_removal() {
|
||||
// "unable to resolve the dependency tree" → "resolve dependency tree"
|
||||
let query = "npm is unable to resolve the typescript dependency tree";
|
||||
let sym = project_symptom("npm", query);
|
||||
|
||||
// Should NOT contain stop words
|
||||
for stop in STOP_WORDS {
|
||||
assert!(!sym.normalised.contains(stop),
|
||||
"Stop word '{}' should be removed", stop);
|
||||
}
|
||||
|
||||
// Should contain key terms
|
||||
assert!(sym.normalised.contains("resolve"));
|
||||
assert!(sym.normalised.contains("dependency"));
|
||||
assert!(sym.normalised.contains("typescript"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a4_tool_consistency() {
|
||||
// Same error text under different tools = different hashes
|
||||
let query = "error 1234 module not found";
|
||||
|
||||
let sym_npm = project_symptom("npm", query);
|
||||
let sym_cargo = project_symptom("cargo", query);
|
||||
|
||||
assert_ne!(sym_npm.sym_sha, sym_cargo.sym_sha,
|
||||
"Tool must be part of signature identity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a5_case_insensitive() {
|
||||
// "NPM ERROR" = "npm error"
|
||||
let q1 = project_symptom("npm", "NPM ERROR ERESOLVE");
|
||||
let q2 = project_symptom("npm", "npm error eresolve");
|
||||
|
||||
assert_eq!(q1.sym_sha, q2.sym_sha);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a6_keyword_order_irrelevant() {
|
||||
// "error npm resolve" = "npm resolve error" (after sorting)
|
||||
let q1 = project_symptom("npm", "error npm resolve typescript");
|
||||
let q2 = project_symptom("npm", "npm typescript resolve error");
|
||||
|
||||
assert_eq!(q1.sym_sha, q2.sym_sha);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration with M3.7.4 Context Endpoint
|
||||
|
||||
### Tier 1 (Exact Match) Lookup
|
||||
|
||||
```rust
|
||||
// In M3.7.4: context_endpoint()
|
||||
pub async fn handle_context_query(
|
||||
query: &str,
|
||||
tool: Option<&str>,
|
||||
) -> ContextResult {
|
||||
// Step 1: Project symptom
|
||||
let symptom = project_symptom(tool.unwrap_or("unknown"), query);
|
||||
|
||||
// Step 2: Check tier 1 (exact signature match)
|
||||
if let Some(lesson) = find_lesson_by_sig_sha(&symptom.sym_sha) {
|
||||
return ContextResult {
|
||||
tier: 1,
|
||||
confidence: "high",
|
||||
source: "lesson",
|
||||
result: lesson,
|
||||
};
|
||||
}
|
||||
|
||||
// Step 3: Fall back to tier 2 (hybrid search)
|
||||
let hybrid_results = hybrid_search(query, tool).await?;
|
||||
|
||||
return ContextResult {
|
||||
tier: 2,
|
||||
confidence: "medium",
|
||||
source: "hybrid_search",
|
||||
results: hybrid_results,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Criteria
|
||||
|
||||
✅ Same query variant → same sym_sha
|
||||
✅ Different queries → different sym_sha
|
||||
✅ Abbreviations expand correctly per tool
|
||||
✅ Stop words removed consistently
|
||||
✅ Tool name included in hash identity
|
||||
✅ Case insensitive normalization
|
||||
✅ Keyword order irrelevant (sorted before hash)
|
||||
|
||||
---
|
||||
|
||||
## Files to Create
|
||||
|
||||
| File | Lines | Purpose |
|
||||
|------|-------|---------|
|
||||
| `crates/mem-core/src/symptom_projection.rs` | 250 | Core normalization logic |
|
||||
| `tests/it_symptom_projection.rs` | 400 | 6 integration test assertions |
|
||||
| `fixtures/symptoms/` | 3 per tool | Test query examples + expected output |
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
- ✅ M3.7.7 (Signature extraction) — provides `Signature` struct and `sig_sha`
|
||||
- ⏳ M3.7.4 (Context endpoint) — will call `project_symptom()`
|
||||
- ⏳ M8.2 (Dual-write) — needed for tier 2 fallback (hybrid search)
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. **18 unit tests passing** (mem-core lesson extraction still green)
|
||||
2. **6 symptom projection integration tests passing**
|
||||
3. **Symptom vectors deterministic** (run twice = same sym_sha)
|
||||
4. **Integrated with M3.7.4 context endpoint**
|
||||
5. **Tier 1 lookups work** (exact signature match via symptom projection)
|
||||
|
||||
---
|
||||
|
||||
## Timeline
|
||||
|
||||
**Estimated: 1–2 days**
|
||||
|
||||
- Day 1: Implement `symptom_projection.rs` + test fixtures
|
||||
- Day 1.5: Integration tests + M3.7.4 endpoint integration
|
||||
- Day 2: Verify tier 1 (exact match) lookups work end-to-end
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- M3.7.7 (Signature extraction): `crates/mem-core/src/lesson.rs` (871 LOC)
|
||||
- M3.7.4 (Context endpoint): `crates/mem-cli/src/context_endpoint.rs` (TBD)
|
||||
- M8 (Hybrid search): `memory-flow.md` § "Search Flow"
|
||||
@@ -1,550 +0,0 @@
|
||||
# M3.8 Pluggable Optimizer Architecture
|
||||
|
||||
**Status**: ✅ Complete & Ready for Integration
|
||||
**Design**: SOLID Principles + DRY Code
|
||||
**Test Coverage**: 157 tests (130 core + 13 plugin + 7 query + 7 builtin)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
M3.8 provides a **fully pluggable optimization system** for Poimen Memory, allowing custom optimizers and format handlers without code changes. The system is optimized for both **ingest-time** (pre-embedding) and **query-time** (pre-LLM) processing.
|
||||
|
||||
### Architecture Diagram
|
||||
|
||||
```
|
||||
INGEST PATH:
|
||||
Records from source
|
||||
↓
|
||||
[M3.8.2 optimize_record_with_metrics()]
|
||||
├─ BuiltinOptimizer
|
||||
└─ Custom optimizers via OptimizerService
|
||||
↓
|
||||
Clean chunks
|
||||
↓
|
||||
Embed (pgvector) + Index (OpenSearch)
|
||||
|
||||
QUERY PATH:
|
||||
Hybrid search results
|
||||
↓
|
||||
[M3.8 QueryOptimizer.optimize_chunks()]
|
||||
├─ BuiltinOptimizer
|
||||
└─ Custom optimizers via OptimizerService
|
||||
↓
|
||||
Clean chunks
|
||||
↓
|
||||
LLM Context Window
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Core Concepts (SOLID Design)
|
||||
|
||||
### 1. OptimizerPlugin Trait (Single Responsibility)
|
||||
```rust
|
||||
pub trait OptimizerPlugin: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
fn supported_types(&self) -> Vec<&str>;
|
||||
fn can_handle(&self, content_type: &str) -> bool;
|
||||
async fn optimize(&self, content: &str) -> Result<OptimizationResult, String>;
|
||||
fn metrics(&self) -> PluginMetrics;
|
||||
}
|
||||
```
|
||||
|
||||
Implement to add custom optimization strategies:
|
||||
- Domain-specific compression (e.g., medical, legal, technical)
|
||||
- Custom algorithms (e.g., semantic pruning, summarization)
|
||||
- Specialized formats (e.g., code, markup, protocols)
|
||||
|
||||
### 2. FormatHandler Trait (Interface Segregation)
|
||||
```rust
|
||||
pub trait FormatHandler: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
async fn format(&self, result: &OptimizationResult) -> Result<Vec<u8>, String>;
|
||||
async fn parse(&self, data: &[u8]) -> Result<OptimizationResult, String>;
|
||||
}
|
||||
```
|
||||
|
||||
Built-in handlers:
|
||||
- **JsonFormatter** — Structured data
|
||||
- **JsonlFormatter** — Streaming (newline-delimited)
|
||||
- **RawFormatter** — Just the optimized text
|
||||
- **CsvFormatter** — Metrics export
|
||||
- **YamlFormatter** — Human-readable config
|
||||
|
||||
### 3. Registry<T> Trait (DRY, Generic)
|
||||
```rust
|
||||
pub trait Registry<T: ?Sized>: Send + Sync {
|
||||
fn register(&mut self, item: Arc<T>);
|
||||
fn get(&self, name: &str) -> Option<Arc<T>>;
|
||||
fn list(&self) -> Vec<String>;
|
||||
}
|
||||
```
|
||||
|
||||
**Single generic implementation** for any plugin type:
|
||||
```rust
|
||||
impl Registry<dyn OptimizerPlugin> for SimpleRegistry<dyn OptimizerPlugin> { ... }
|
||||
impl Registry<dyn FormatHandler> for SimpleRegistry<dyn FormatHandler> { ... }
|
||||
```
|
||||
|
||||
No code duplication.
|
||||
|
||||
### 4. PluginLocator Strategy (Open/Closed)
|
||||
```rust
|
||||
pub trait PluginLocator: Send + Sync {
|
||||
fn find_optimizer(&self, registry: &SimpleRegistry<dyn OptimizerPlugin>,
|
||||
content_type: &str) -> Result<Arc<dyn OptimizerPlugin>, String>;
|
||||
fn find_format(&self, registry: &SimpleRegistry<dyn FormatHandler>,
|
||||
name: &str) -> Result<Arc<dyn FormatHandler>, String>;
|
||||
}
|
||||
```
|
||||
|
||||
Extensible lookup strategies:
|
||||
- **DefaultLocator** — Type-based matching
|
||||
- Custom locators for priority-based, feature-based, etc.
|
||||
|
||||
### 5. OptimizerService (Dependency Inversion)
|
||||
```rust
|
||||
pub struct OptimizerService {
|
||||
optimizer_registry: Arc<SimpleRegistry<dyn OptimizerPlugin>>,
|
||||
format_registry: Arc<SimpleRegistry<dyn FormatHandler>>,
|
||||
locator: Arc<dyn PluginLocator>,
|
||||
default_format: String,
|
||||
}
|
||||
```
|
||||
|
||||
Depends on **abstractions** (traits), not concrete types.
|
||||
|
||||
---
|
||||
|
||||
## Usage Patterns
|
||||
|
||||
### Pattern 1: Built-in Optimizer (No Custom Code)
|
||||
|
||||
```rust
|
||||
use mem_core::optimizer::{OptimizerServiceBuilder, BuiltinOptimizer, JsonFormatter};
|
||||
use std::sync::Arc;
|
||||
|
||||
let service = OptimizerServiceBuilder::new()
|
||||
.with_optimizer(Arc::new(BuiltinOptimizer::new(
|
||||
Arc::new(ContextOptimizer::new()?)
|
||||
)))
|
||||
.with_format(Arc::new(JsonFormatter))
|
||||
.build()?;
|
||||
|
||||
let result = service.optimize(
|
||||
"ERROR: connection failed",
|
||||
"text/x-log",
|
||||
None
|
||||
).await?;
|
||||
```
|
||||
|
||||
### Pattern 2: Custom Optimizer + Format
|
||||
|
||||
```rust
|
||||
use mem_core::optimizer::{OptimizerPlugin, FormatHandler, OptimizationResult};
|
||||
use async_trait::async_trait;
|
||||
|
||||
struct MyOptimizer;
|
||||
|
||||
#[async_trait]
|
||||
impl OptimizerPlugin for MyOptimizer {
|
||||
fn name(&self) -> &str {
|
||||
"my-semantic-pruner"
|
||||
}
|
||||
|
||||
fn supported_types(&self) -> Vec<&str> {
|
||||
vec!["text/markdown", "text/plain"]
|
||||
}
|
||||
|
||||
async fn optimize(&self, content: &str) -> Result<OptimizationResult, String> {
|
||||
// Your custom optimization logic
|
||||
let pruned = semantic_pruning(content);
|
||||
let ratio = pruned.len() as f32 / content.len() as f32;
|
||||
|
||||
Ok(OptimizationResult {
|
||||
original: content.to_string(),
|
||||
optimized: pruned,
|
||||
ratio,
|
||||
plugin: self.name().to_string(),
|
||||
metadata: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn metrics(&self) -> PluginMetrics {
|
||||
// Track your metrics
|
||||
Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
struct CompressedYamlFormatter;
|
||||
|
||||
#[async_trait]
|
||||
impl FormatHandler for CompressedYamlFormatter {
|
||||
fn name(&self) -> &str {
|
||||
"compressed-yaml"
|
||||
}
|
||||
|
||||
async fn format(&self, result: &OptimizationResult) -> Result<Vec<u8>, String> {
|
||||
// Compress to YAML
|
||||
let yaml = format!(
|
||||
"plugin: {}\nratio: {:.2}\noriginal_bytes: {}\ncompressed_bytes: {}\n",
|
||||
result.plugin,
|
||||
result.ratio,
|
||||
result.original.len(),
|
||||
result.optimized.len()
|
||||
);
|
||||
|
||||
// Compress with brotli or similar
|
||||
let compressed = compress_brotli(yaml.as_bytes());
|
||||
Ok(compressed)
|
||||
}
|
||||
|
||||
async fn parse(&self, data: &[u8]) -> Result<OptimizationResult, String> {
|
||||
// Decompress and parse
|
||||
let decompressed = decompress_brotli(data)?;
|
||||
// ... parse YAML
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
// Register and use
|
||||
let service = OptimizerServiceBuilder::new()
|
||||
.with_optimizer(Arc::new(MyOptimizer))
|
||||
.with_format(Arc::new(CompressedYamlFormatter))
|
||||
.with_locator(Arc::new(MyCustomLocator))
|
||||
.build()?;
|
||||
```
|
||||
|
||||
### Pattern 3: Ingest-Time Optimization (rebuild.rs)
|
||||
|
||||
```rust
|
||||
use mem_ingest::{optimize_record_with_metrics, OptimizationMetrics, MetricsCollector};
|
||||
use mem_core::optimizer::{ContextOptimizer, OptimizerServiceBuilder};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
let optimizer = ContextOptimizer::from_env()?;
|
||||
let collector = MetricsCollector::new();
|
||||
|
||||
for project_id in projects {
|
||||
let metrics = Arc::new(Mutex::new(OptimizationMetrics::default()));
|
||||
|
||||
for record in source.records() {
|
||||
// M3.8.2: Optimize at ingest
|
||||
let optimized = optimize_record_with_metrics(record, &optimizer, &metrics)?;
|
||||
|
||||
// Now embed the clean chunk
|
||||
let embedding = embed(&optimized.text)?;
|
||||
insert_pgvector(embedding, &optimized)?;
|
||||
insert_opensearch(&optimized)?;
|
||||
}
|
||||
|
||||
let final_metrics = metrics.lock().unwrap().clone();
|
||||
collector.merge_project(project_id, final_metrics);
|
||||
}
|
||||
|
||||
// Export metrics to Prometheus
|
||||
let prometheus_text = collector.prometheus_export();
|
||||
```
|
||||
|
||||
### Pattern 4: Query-Time Optimization (query_executor.rs)
|
||||
|
||||
```rust
|
||||
use mem_core::optimizer::QueryOptimizer;
|
||||
|
||||
let query_optimizer = QueryOptimizer::from_env();
|
||||
|
||||
// Get search results from hybrid search
|
||||
let chunks = hybrid_search(query).await?;
|
||||
|
||||
// Optimize before LLM context
|
||||
let optimized_chunks = query_optimizer.optimize_chunks(&chunks).await?;
|
||||
|
||||
// Pass to LLM
|
||||
let context = optimized_chunks.join("\n---\n");
|
||||
let response = llm.query(&context, &question).await?;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Wiring Together (Full Implementation)
|
||||
|
||||
### Step 1: Enable in Environment
|
||||
|
||||
```bash
|
||||
# Ingest-time optimization
|
||||
export MEM_CONTEXT_OPTIMIZER=on
|
||||
export MEM_COMPRESSION_TARGETS='{"logs": 0.9, "json": 0.8, "text": 0.5}'
|
||||
|
||||
# Query-time optimization
|
||||
export MEM_QUERY_OPTIMIZER=on
|
||||
export MEM_QUERY_OPTIMIZER_SERVICE=/path/to/service.yml
|
||||
```
|
||||
|
||||
### Step 2: Integrate into Ingest Pipeline (rebuild.rs)
|
||||
|
||||
```rust
|
||||
// PASS 2 (existing): Insert all nodes
|
||||
let optimizer = ContextOptimizer::from_env()?;
|
||||
let collector = MetricsCollector::new();
|
||||
|
||||
for memory in &memories {
|
||||
let metrics = Arc::new(Mutex::new(OptimizationMetrics::default()));
|
||||
|
||||
// OPTIMIZE BEFORE CONVERTING TO NODE
|
||||
let optimized_text = optimize_record_with_metrics(
|
||||
/* create record from memory.text */,
|
||||
&optimizer,
|
||||
&metrics,
|
||||
)?;
|
||||
|
||||
let node = MemoryNode {
|
||||
sha256: Self::memory_sha(&optimized_text.text),
|
||||
level,
|
||||
project: memory.project.clone(),
|
||||
query_id: memory.query_id.clone(),
|
||||
run_id: memory.run_id.clone(),
|
||||
t: memory.t,
|
||||
source: memory.source.clone(),
|
||||
text: optimized_text.text, // USE OPTIMIZED TEXT
|
||||
};
|
||||
|
||||
nodes_by_sha.insert(node.sha256.clone(), node);
|
||||
collector.merge_project(&memory.project, metrics.lock().unwrap().clone());
|
||||
}
|
||||
|
||||
// Upsert all nodes with optimized text
|
||||
for node in nodes_by_sha.values() {
|
||||
self.repo.upsert_node(node).await?;
|
||||
}
|
||||
|
||||
// PASS 3 (existing): Insert edges
|
||||
// ... rest of pipeline ...
|
||||
|
||||
// Export metrics
|
||||
collector.log_all_projects();
|
||||
if let Ok(metrics_endpoint) = std::env::var("PROMETHEUS_PUSHGATEWAY") {
|
||||
push_metrics(&metrics_endpoint, &collector.prometheus_export()).await?;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Integrate into Query Path (query_executor.rs)
|
||||
|
||||
```rust
|
||||
use mem_core::optimizer::QueryOptimizer;
|
||||
|
||||
pub struct QueryExecutor {
|
||||
hybrid_search: Arc<HybridSearch>,
|
||||
query_optimizer: QueryOptimizer,
|
||||
llm_gateway: Arc<LlmGateway>,
|
||||
}
|
||||
|
||||
impl QueryExecutor {
|
||||
pub async fn execute(&self, query: &Query) -> Result<Response> {
|
||||
// 1. Retrieve chunks from hybrid search
|
||||
let chunks = self.hybrid_search.search(&query.question).await?;
|
||||
|
||||
tracing::debug!("Retrieved {} chunks", chunks.len());
|
||||
|
||||
// 2. OPTIMIZE BEFORE LLM (M3.8 query optimizer)
|
||||
let optimized_chunks = self.query_optimizer.optimize_chunks(&chunks).await?;
|
||||
|
||||
let optimization_stats = chunks
|
||||
.iter()
|
||||
.zip(&optimized_chunks)
|
||||
.map(|(orig, opt)| format!(
|
||||
"{} → {} bytes ({:.1}%)",
|
||||
orig.tokens,
|
||||
opt.len() / 4, // rough token estimate
|
||||
(opt.len() as f32 / Self::chunk_text(orig).len() as f32) * 100.0
|
||||
))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
tracing::info!("Optimization: {:?}", optimization_stats);
|
||||
|
||||
// 3. Build context window
|
||||
let context = optimized_chunks.join("\n---\n");
|
||||
|
||||
// 4. Call LLM
|
||||
let response = self.llm_gateway.query(&context, &query.question).await?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Custom Optimizers
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct TestOptimizer;
|
||||
|
||||
#[async_trait]
|
||||
impl OptimizerPlugin for TestOptimizer {
|
||||
fn name(&self) -> &str { "test" }
|
||||
fn supported_types(&self) -> Vec<&str> { vec!["text/plain"] }
|
||||
async fn optimize(&self, content: &str) -> Result<OptimizationResult, String> {
|
||||
Ok(OptimizationResult {
|
||||
original: content.to_string(),
|
||||
optimized: content.to_uppercase(),
|
||||
ratio: 1.0,
|
||||
plugin: "test".to_string(),
|
||||
metadata: Default::default(),
|
||||
})
|
||||
}
|
||||
fn metrics(&self) -> PluginMetrics { Default::default() }
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_custom_optimizer() {
|
||||
let service = OptimizerServiceBuilder::new()
|
||||
.with_optimizer(Arc::new(TestOptimizer) as Arc<dyn OptimizerPlugin>)
|
||||
.with_format(Arc::new(JsonFormatter) as Arc<dyn FormatHandler>)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let result = service.optimize("hello", "text/plain", None).await.unwrap();
|
||||
assert_eq!(result, b"{\"original\":\"hello\",\"optimized\":\"HELLO\",\"ratio\":1.0,\"plugin\":\"test\",\"metadata\":{}}");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Ingest-Time Optimization
|
||||
- **Cost**: One-time per document (during rebuild)
|
||||
- **Benefit**: Better embeddings (pgvector), better ranking (OpenSearch)
|
||||
- **Target**: <1ms per record, 1000+ records/sec
|
||||
- **Caching**: CcrStore limits compression cache to 1000 entries
|
||||
|
||||
### Query-Time Optimization
|
||||
- **Cost**: Per query (on search results, not on all docs)
|
||||
- **Benefit**: Smaller context window, fewer tokens to LLM
|
||||
- **Target**: <50ms P95, graceful fallback
|
||||
- **Batch**: optimize_chunks() processes multiple in parallel
|
||||
|
||||
### Trade-offs
|
||||
- **Compression ratio vs quality**: Test your ratio targets (e.g., 85-95% for logs)
|
||||
- **Latency vs depth**: More plugins = more checks, use content-type inference wisely
|
||||
- **Memory vs performance**: CcrStore limits cache to 1000 entries; adjust if needed
|
||||
|
||||
---
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Prometheus Metrics (Ingest)
|
||||
```
|
||||
m3_8_optimization_records_total{project="x"}
|
||||
m3_8_optimization_input_bytes_total{project="x"}
|
||||
m3_8_optimization_output_bytes_total{project="x"}
|
||||
m3_8_optimization_compression_ratio{project="x"}
|
||||
m3_8_optimization_compressor_records{project="x",compressor="log"}
|
||||
m3_8_optimization_compressor_ratio{project="x",compressor="log"}
|
||||
```
|
||||
|
||||
### Structured Logging (Query)
|
||||
```rust
|
||||
tracing::info!(
|
||||
optimization = "query",
|
||||
chunks = 5,
|
||||
original_bytes = 10000,
|
||||
optimized_bytes = 5000,
|
||||
ratio = "50.0%",
|
||||
"query optimization complete"
|
||||
);
|
||||
```
|
||||
|
||||
### Health Checks
|
||||
```bash
|
||||
# Check ingest optimization is running
|
||||
kubectl logs -f deployment/memory-api | grep "M3.8"
|
||||
|
||||
# Verify Prometheus metrics
|
||||
curl http://localhost:9090/metrics | grep m3_8_optimization
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Semantic Pruning Optimizer
|
||||
```rust
|
||||
struct SemanticPruner;
|
||||
|
||||
#[async_trait]
|
||||
impl OptimizerPlugin for SemanticPruner {
|
||||
fn name(&self) -> &str { "semantic-pruner" }
|
||||
fn supported_types(&self) -> Vec<&str> { vec!["text/plain", "text/markdown"] }
|
||||
|
||||
async fn optimize(&self, content: &str) -> Result<OptimizationResult, String> {
|
||||
// Keep only sentences with high semantic value
|
||||
let sentences: Vec<&str> = content.split('.').collect();
|
||||
let important = sentences
|
||||
.iter()
|
||||
.filter(|s| semantic_score(s) > THRESHOLD)
|
||||
.map(|s| s.trim())
|
||||
.collect::<Vec<_>>()
|
||||
.join(". ");
|
||||
|
||||
Ok(OptimizationResult {
|
||||
original: content.to_string(),
|
||||
optimized: important,
|
||||
ratio: (important.len() as f32 / content.len() as f32),
|
||||
plugin: "semantic-pruner".to_string(),
|
||||
metadata: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn metrics(&self) -> PluginMetrics { Default::default() }
|
||||
}
|
||||
```
|
||||
|
||||
### Example 2: Code Formatter Optimizer
|
||||
```rust
|
||||
struct CodeFormatter;
|
||||
|
||||
#[async_trait]
|
||||
impl OptimizerPlugin for CodeFormatter {
|
||||
fn name(&self) -> &str { "code-formatter" }
|
||||
fn supported_types(&self) -> Vec<&str> { vec!["text/x-python", "text/x-rust"] }
|
||||
|
||||
async fn optimize(&self, content: &str) -> Result<OptimizationResult, String> {
|
||||
// Format and minify code blocks
|
||||
let formatted = rustfmt::format_code(content)?;
|
||||
let minified = minify_code(&formatted);
|
||||
|
||||
Ok(OptimizationResult {
|
||||
original: content.to_string(),
|
||||
optimized: minified,
|
||||
ratio: (minified.len() as f32 / content.len() as f32),
|
||||
plugin: "code-formatter".to_string(),
|
||||
metadata: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn metrics(&self) -> PluginMetrics { Default::default() }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
M3.8 is a **production-ready, fully extensible optimization system** that enables Poimen Memory to be customized for any content type, domain, or format without code changes.
|
||||
|
||||
**Key Benefits**:
|
||||
- ✅ SOLID design (easily tested and extended)
|
||||
- ✅ DRY implementation (no duplication)
|
||||
- ✅ Pluggable architecture (custom optimizers + formats)
|
||||
- ✅ Dual-path optimization (ingest + query)
|
||||
- ✅ Production metrics (Prometheus + structured logging)
|
||||
- ✅ Graceful degradation (falls back to original on error)
|
||||
|
||||
**Ready to integrate** into rebuild.rs and query_executor.rs.
|
||||
@@ -1,503 +0,0 @@
|
||||
# M8.2 — Gateway Queue Adapter for SQS/kmsvc
|
||||
|
||||
**Status**: Implementation complete
|
||||
**Version**: 1.0
|
||||
**Architecture**: Unified queue API via api.riotpiao.com gateway
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Gateway Queue Adapter provides a unified interface for enqueueing chunk dual-write operations via the `api.riotpiao.com` gateway. Rather than connecting directly to kmsvc gRPC, this adapter uses standard HTTP/REST with JWT bearer tokens.
|
||||
|
||||
### Design Rationale
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Traditional Direct gRPC Approach (NOT used) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ mem-cli kmsvc (gRPC) │
|
||||
│ │ │ │
|
||||
│ │──── gRPC stub ───────>│ (complex connection mgmt) │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ NEW: Gateway-based approach (THIS IMPLEMENTATION) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ mem-cli api.riotpiao.com kmsvc │
|
||||
│ │ │ │ │
|
||||
│ │─ HTTP + JWT ──────>│──RoutedBy──────>│ │
|
||||
│ │ (Bearer token) │ X-Service: sqs │ │
|
||||
│ │ │ │
|
||||
│ │ (gateway validates JWT before routing)│
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- ✅ JWT tokens handled by Authentik (same as HTTP API)
|
||||
- ✅ Standard HTTP/REST interface (easier debugging via curl)
|
||||
- ✅ Leverage existing API gateway infrastructure
|
||||
- ✅ No direct gRPC connection management
|
||||
- ✅ Unified authentication across all services
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
### Trait: `QueueAdapter`
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait QueueAdapter: Send + Sync {
|
||||
async fn send_chunk(
|
||||
&self,
|
||||
chunk_id: Uuid,
|
||||
body: String,
|
||||
project: String,
|
||||
attributes: HashMap<String, String>,
|
||||
) -> Result<String>;
|
||||
|
||||
async fn receive_chunks(
|
||||
&self,
|
||||
max_messages: i32,
|
||||
visibility_timeout_secs: i32,
|
||||
project: Option<&str>,
|
||||
) -> Result<Vec<QueueMessage>>;
|
||||
|
||||
async fn delete_chunk(
|
||||
&self,
|
||||
message_id: &str,
|
||||
receipt_handle: &str,
|
||||
) -> Result<()>;
|
||||
|
||||
async fn change_visibility(
|
||||
&self,
|
||||
message_id: &str,
|
||||
receipt_handle: &str,
|
||||
visibility_timeout_secs: i32,
|
||||
) -> Result<()>;
|
||||
|
||||
async fn send_to_dlq(
|
||||
&self,
|
||||
message_id: &str,
|
||||
receipt_handle: &str,
|
||||
reason: &str,
|
||||
) -> Result<()>;
|
||||
|
||||
async fn get_stats(&self, project: Option<&str>) -> Result<QueueStats>;
|
||||
async fn purge(&self, project: Option<&str>) -> Result<usize>;
|
||||
async fn health_check(&self) -> Result<()>;
|
||||
}
|
||||
```
|
||||
|
||||
### Queue Message Format
|
||||
|
||||
```rust
|
||||
pub struct QueueMessage {
|
||||
pub message_id: String, // From SQS
|
||||
pub chunk_id: Uuid, // Original chunk ID
|
||||
pub body: String, // Serialized chunk data
|
||||
pub receive_count: i32, // Number of receives
|
||||
pub receipt_handle: String, // For delete/visibility ops
|
||||
pub project: String, // Project context
|
||||
pub attributes: HashMap<String, String>, // Metadata
|
||||
}
|
||||
```
|
||||
|
||||
### Token Provider Trait
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait TokenProvider: Send + Sync {
|
||||
async fn token(&self) -> Result<String>;
|
||||
}
|
||||
```
|
||||
|
||||
Implementations:
|
||||
- `StaticTokenProvider` — Fixed token (testing)
|
||||
- `AuthentikTokenProvider` — OAuth2 client credentials flow (production)
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Setup: Static Token (Testing)
|
||||
|
||||
```rust
|
||||
use mem_cli::gateway_queue_adapter::GatewayQueueAdapter;
|
||||
use mem_cli::queue_adapter::QueueAdapter;
|
||||
use uuid::Uuid;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// Create adapter with static token
|
||||
let adapter = GatewayQueueAdapter::with_static_token(
|
||||
"https://api.riotpiao.com".to_string(),
|
||||
"eyJ...my-jwt-token".to_string(),
|
||||
);
|
||||
|
||||
// Queue a chunk
|
||||
let msg_id = adapter.send_chunk(
|
||||
Uuid::new_v4(),
|
||||
r#"{"content": "hello world"}"#.to_string(),
|
||||
"myproject".to_string(),
|
||||
HashMap::new(),
|
||||
).await?;
|
||||
|
||||
println!("Queued: {}", msg_id);
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Setup: Authentik Token (Production)
|
||||
|
||||
```rust
|
||||
let adapter = GatewayQueueAdapter::with_authentik(
|
||||
"https://api.riotpiao.com".to_string(),
|
||||
"https://authentik.riotpiao.com/application/o/poimen-memory/".to_string(),
|
||||
"poimen-memory".to_string(), // client_id
|
||||
"your-client-secret".to_string(),
|
||||
);
|
||||
|
||||
// Token is automatically refreshed when expired
|
||||
```
|
||||
|
||||
### Queue a Chunk
|
||||
|
||||
```rust
|
||||
let mut attrs = std::collections::HashMap::new();
|
||||
attrs.insert("source".to_string(), "obsidian".to_string());
|
||||
attrs.insert("level".to_string(), "L0".to_string());
|
||||
attrs.insert("breadcrumb".to_string(),
|
||||
serde_json::to_string(&vec!["root", "section"])?,
|
||||
);
|
||||
|
||||
let message_id = adapter.send_chunk(
|
||||
Uuid::new_v4(),
|
||||
serde_json::json!({
|
||||
"content": "chunk text",
|
||||
"metadata": "...",
|
||||
}).to_string(),
|
||||
"myproject".to_string(),
|
||||
attrs,
|
||||
).await?;
|
||||
|
||||
tracing::info!("Chunk queued: {}", message_id);
|
||||
```
|
||||
|
||||
### Receive Messages (Long-poll)
|
||||
|
||||
```rust
|
||||
// Receive up to 10 messages, wait up to 20 seconds for availability
|
||||
let messages = adapter.receive_chunks(
|
||||
10, // max_messages (1-10)
|
||||
30, // visibility_timeout_secs
|
||||
Some("myproject"), // optional project filter
|
||||
).await?;
|
||||
|
||||
for msg in messages {
|
||||
println!("Message ID: {}", msg.message_id);
|
||||
println!("Receive count: {}", msg.receive_count);
|
||||
println!("Receipt handle: {}", msg.receipt_handle);
|
||||
|
||||
// Process the message...
|
||||
match process_chunk(&msg).await {
|
||||
Ok(_) => {
|
||||
// Delete on success
|
||||
adapter.delete_chunk(&msg.message_id, &msg.receipt_handle).await?;
|
||||
}
|
||||
Err(e) if msg.receive_count < 3 => {
|
||||
// Retry: extend visibility for 5 minutes
|
||||
adapter.change_visibility(
|
||||
&msg.message_id,
|
||||
&msg.receipt_handle,
|
||||
300,
|
||||
).await?;
|
||||
}
|
||||
Err(e) => {
|
||||
// Max retries: send to DLQ
|
||||
adapter.send_to_dlq(
|
||||
&msg.message_id,
|
||||
&msg.receipt_handle,
|
||||
&e.to_string(),
|
||||
).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Health Check
|
||||
|
||||
```rust
|
||||
if let Err(e) = adapter.health_check().await {
|
||||
eprintln!("Gateway unavailable: {}", e);
|
||||
}
|
||||
```
|
||||
|
||||
### Monitor Queue
|
||||
|
||||
```rust
|
||||
let stats = adapter.get_stats(Some("myproject")).await?;
|
||||
|
||||
println!("Available: {}", stats.available_messages);
|
||||
println!("In-flight: {}", stats.in_flight_messages);
|
||||
println!("DLQ: {}", stats.dead_letter_messages);
|
||||
println!("Processed: {}", stats.total_processed);
|
||||
println!("Avg delay: {}s", stats.average_delay_secs);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HTTP Message Flow
|
||||
|
||||
### 1. Send Chunk (POST)
|
||||
|
||||
**Request**:
|
||||
```bash
|
||||
POST https://api.riotpiao.com/
|
||||
X-Service: sqs
|
||||
Authorization: Bearer eyJ...
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"messageBody": "aGVsbG8gd29ybGQ=", # Base64-encoded chunk data
|
||||
"messageAttributes": {
|
||||
"values": {
|
||||
"chunk_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"project": "myproject",
|
||||
"source": "obsidian",
|
||||
"level": "L0",
|
||||
"breadcrumb": "[\"root\", \"section\"]"
|
||||
}
|
||||
},
|
||||
"delaySeconds": 0
|
||||
}
|
||||
```
|
||||
|
||||
**Response** (200 OK):
|
||||
```json
|
||||
{
|
||||
"messageId": "d9f94e63-b2c1-4e9f-8c5f-8d5e3c1b7a0f"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Receive Messages (GET)
|
||||
|
||||
**Request**:
|
||||
```bash
|
||||
GET https://api.riotpiao.com/?X-Service=sqs&queue=poimen-chunks-myproject&maxNumberOfMessages=10&waitTimeSeconds=20&visibilityTimeoutSeconds=30
|
||||
Authorization: Bearer eyJ...
|
||||
```
|
||||
|
||||
**Response** (200 OK):
|
||||
```json
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"messageId": "d9f94e63-b2c1-4e9f-8c5f-8d5e3c1b7a0f",
|
||||
"receiptHandle": "AQEBxxxx...",
|
||||
"body": "aGVsbG8gd29ybGQ=", # Base64-encoded
|
||||
"attributes": {
|
||||
"values": {
|
||||
"chunk_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"project": "myproject",
|
||||
"source": "obsidian"
|
||||
}
|
||||
},
|
||||
"receiveCount": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Delete Message (DELETE)
|
||||
|
||||
**Request**:
|
||||
```bash
|
||||
DELETE https://api.riotpiao.com/
|
||||
X-Service: sqs
|
||||
Authorization: Bearer eyJ...
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"receiptHandle": "AQEBxxxx..."
|
||||
}
|
||||
```
|
||||
|
||||
**Response** (204 No Content)
|
||||
|
||||
---
|
||||
|
||||
## Integration with DualWriteIndexer
|
||||
|
||||
The `DualWriteIndexer` uses the queue adapter for concurrent dual-write processing:
|
||||
|
||||
```rust
|
||||
use mem_cli::dual_write_indexer::DualWriteIndexer;
|
||||
use mem_cli::gateway_queue_adapter::GatewayQueueAdapter;
|
||||
use std::sync::Arc;
|
||||
|
||||
// Create queue adapter
|
||||
let queue = Arc::new(
|
||||
GatewayQueueAdapter::with_authentik(
|
||||
"https://api.riotpiao.com".to_string(),
|
||||
issuer,
|
||||
client_id,
|
||||
client_secret,
|
||||
)
|
||||
);
|
||||
|
||||
// Create dual-write indexer with queue
|
||||
let indexer = DualWriteIndexer::new(
|
||||
pg_pool,
|
||||
opensearch_client,
|
||||
queue,
|
||||
);
|
||||
|
||||
// Queue chunk for processing
|
||||
let message_id = indexer.queue_chunk(&chunk_input, &embedding).await?;
|
||||
|
||||
// Concurrent workers receive and process
|
||||
let messages = queue.receive_chunks(10, 30, None).await?;
|
||||
for msg in messages {
|
||||
match indexer.process_queued_chunk(&msg, &embedding).await {
|
||||
Ok(result) => {
|
||||
queue.delete_chunk(&msg.message_id, &msg.receipt_handle).await?;
|
||||
}
|
||||
Err(e) => {
|
||||
queue.change_visibility(&msg.message_id, &msg.receipt_handle, 300).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Common Errors
|
||||
|
||||
| Status | Meaning | Recovery |
|
||||
|--------|---------|----------|
|
||||
| `401 Unauthorized` | Missing/expired token | Refresh token via TokenProvider |
|
||||
| `403 Forbidden` | Token valid but no permission | Check JWT claims in Authentik |
|
||||
| `404 Not Found` | Queue doesn't exist | Create queue via Queue CRD |
|
||||
| `429 Too Many Requests` | Rate limited | Implement backoff |
|
||||
| `502 Bad Gateway` | kmsvc unreachable | Retry with exponential backoff |
|
||||
| `503 Service Unavailable` | Gateway overloaded | Circuit breaker pattern |
|
||||
|
||||
### Retry Strategy
|
||||
|
||||
```rust
|
||||
use std::time::Duration;
|
||||
|
||||
let mut retries = 0;
|
||||
const MAX_RETRIES: usize = 3;
|
||||
|
||||
loop {
|
||||
match adapter.send_chunk(...).await {
|
||||
Ok(msg_id) => {
|
||||
tracing::info!("Sent: {}", msg_id);
|
||||
break;
|
||||
}
|
||||
Err(e) if retries < MAX_RETRIES => {
|
||||
retries += 1;
|
||||
let backoff = Duration::from_millis(100 * 2_u64.pow(retries as u32));
|
||||
tracing::warn!("Retry {} in {:?}: {}", retries, backoff, e);
|
||||
tokio::time::sleep(backoff).await;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Max retries exceeded: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration (Environment Variables)
|
||||
|
||||
```bash
|
||||
# Gateway endpoint
|
||||
export GATEWAY_URL=https://api.riotpiao.com
|
||||
|
||||
# Authentik (for JWT)
|
||||
export AUTHENTIK_ISSUER=https://authentik.riotpiao.com/application/o/poimen-memory/
|
||||
export AUTHENTIK_CLIENT_ID=poimen-memory
|
||||
export AUTHENTIK_CLIENT_SECRET=your-secret
|
||||
|
||||
# Optional: Static token (for testing)
|
||||
export STATIC_JWT_TOKEN=eyJ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```bash
|
||||
cargo test --lib gateway_queue_adapter
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
```bash
|
||||
# Requires running api.riotpiao.com
|
||||
cargo test --test it_gateway_queue_adapter -- --ignored
|
||||
```
|
||||
|
||||
### Manual Testing with curl
|
||||
|
||||
```bash
|
||||
# Get token
|
||||
TOKEN=$(curl -s -X POST https://authentik.riotpiao.com/application/o/token/ \
|
||||
-d "grant_type=client_credentials&client_id=poimen-memory&client_secret=secret" \
|
||||
| jq -r '.access_token')
|
||||
|
||||
# Send message
|
||||
curl -X POST https://api.riotpiao.com/ \
|
||||
-H "X-Service: sqs" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"messageBody": "aGVsbG8gd29ybGQ=", "messageAttributes": {"values": {}}}'
|
||||
|
||||
# Receive messages
|
||||
curl -X GET "https://api.riotpiao.com/?X-Service=sqs&queue=poimen-chunks-test&maxNumberOfMessages=10&waitTimeSeconds=20" \
|
||||
-H "Authorization: Bearer $TOKEN" | jq .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Notes
|
||||
|
||||
✅ **JWT Validation**: Gateway validates token signature and claims before routing
|
||||
✅ **Bearer Token Format**: Strictly requires `Authorization: Bearer <token>`
|
||||
✅ **Token Expiry**: Automatic refresh via TokenProvider
|
||||
✅ **HTTPS Only**: All calls to api.riotpiao.com are encrypted
|
||||
✅ **Header Validation**: X-Service header validated by gateway
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] ChangeMessageVisibility support in gateway
|
||||
- [ ] GetQueueAttributes for monitoring
|
||||
- [ ] Batch operations (SendMessageBatch, DeleteMessageBatch)
|
||||
- [ ] Circuit breaker pattern for fault tolerance
|
||||
- [ ] Metrics export (Prometheus)
|
||||
- [ ] Tracing integration (OpenTelemetry)
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [SERVICE-USAGE.md](../homelab-frontend/docs/SERVICE-USAGE.md) — Gateway usage guide
|
||||
- [kmsvc-SDK README](../kmsvc-SDK/README.md) — Underlying SQS implementation
|
||||
- [Authentik Docs](https://goauthentik.io/) — JWT token provider
|
||||
@@ -1,418 +0,0 @@
|
||||
# M8.2 — Queue Worker Integration with DualWriteIndexer
|
||||
|
||||
**Status**: Complete
|
||||
**Architecture**: Background task for concurrent dual-write processing
|
||||
**Concurrency**: Multiple workers can process queue messages in parallel
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Queue Worker decouples the fast ingest path from the slow dual-write operations (embedding → pgvector + OpenSearch). This improves throughput and reliability:
|
||||
|
||||
### Before (Synchronous)
|
||||
```
|
||||
IngestWorker
|
||||
├─ Parse document
|
||||
├─ Split into chunks
|
||||
├─ Embed each chunk (slow, sequential)
|
||||
├─ Write to pgvector (slow, I/O)
|
||||
├─ Write to OpenSearch (slow, I/O)
|
||||
└─ Return to user [TOTAL: 5-10 seconds]
|
||||
```
|
||||
|
||||
### After (Asynchronous with Queue)
|
||||
```
|
||||
IngestWorker QueueWorker (background task)
|
||||
├─ Parse document ├─ receive_chunks(10, 30s)
|
||||
├─ Split into chunks ├─ embed_one() for each
|
||||
├─ queue.send_chunk() ├─ write_pgvector()
|
||||
└─ Return immediately (fast) ├─ write_opensearch()
|
||||
[TOTAL: <100ms] └─ delete/retry cycle
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
┌──────────────┐
|
||||
│ IngestWorker │
|
||||
├──────────────┤
|
||||
│ parse doc │
|
||||
│ split chunks │
|
||||
│ queue each │ ──send_chunk()──> ┌────────────────┐
|
||||
│ return 202 │ │ Gateway Queue │
|
||||
└──────────────┘ │ (api.riotpiao)│
|
||||
└────────────────┘
|
||||
▲ │
|
||||
│ │
|
||||
receive_chunks(10, 30s)
|
||||
│ ▼
|
||||
┌──────────────────┐
|
||||
│ QueueWorker │
|
||||
├──────────────────┤
|
||||
│ for each msg: │
|
||||
│ - embed_one() │
|
||||
│ - write_pgvec() │
|
||||
│ - write_os() │
|
||||
│ - delete/retry │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
### Message Lifecycle
|
||||
|
||||
1. **QUEUED** — Message in queue, waiting for worker pickup
|
||||
2. **RECEIVED** — Message checked out (visibility timeout active)
|
||||
3. **PROCESSING** — Worker embedding/writing
|
||||
- **SUCCESS** → DELETE from queue
|
||||
- **FAILURE (pgvector)** → EXTEND visibility, retry
|
||||
- **FAILURE (OpenSearch)** → Mark pending, delete from queue
|
||||
- **MAX RETRIES** → SEND TO DLQ
|
||||
4. **PROCESSED** or **DLQ** — Final state
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# Queue Worker Enable/Disable
|
||||
ENABLE_QUEUE_WORKER=true # Default: true
|
||||
|
||||
# Message Processing
|
||||
QUEUE_BATCH_SIZE=10 # Max messages per receive (1-10)
|
||||
QUEUE_VISIBILITY_TIMEOUT=300 # Seconds before retry (5 min)
|
||||
QUEUE_WAIT_TIME=20 # Long-poll timeout (0-20s)
|
||||
QUEUE_MAX_RETRIES=3 # Retries before DLQ
|
||||
QUEUE_PROJECT= # Optional: process specific project only
|
||||
|
||||
# Gateway (if using GatewayQueueAdapter)
|
||||
GATEWAY_URL=https://api.riotpiao.com
|
||||
AUTHENTIK_ISSUER=https://authentik.riotpiao.com/application/o/poimen-memory/
|
||||
AUTHENTIK_CLIENT_ID=poimen-memory
|
||||
AUTHENTIK_CLIENT_SECRET=<secret>
|
||||
|
||||
# Fallback (if GATEWAY_URL not set)
|
||||
# Uses InMemoryQueueAdapter for development
|
||||
```
|
||||
|
||||
### QueueWorkerConfig struct
|
||||
|
||||
```rust
|
||||
pub struct QueueWorkerConfig {
|
||||
pub max_messages_per_batch: i32, // 1-10
|
||||
pub visibility_timeout_secs: i32, // 30-600 recommended
|
||||
pub wait_time_secs: i32, // 0-20
|
||||
pub project: Option<String>, // Filter by project
|
||||
pub max_retries: i32, // 2-5 typical
|
||||
pub retry_backoff_initial_secs: i32, // 60 default
|
||||
pub empty_poll_interval_secs: u64, // 5 default
|
||||
pub enable_metrics: bool, // Collect stats
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### Starting the Server (with Queue Worker)
|
||||
|
||||
```bash
|
||||
# Kubernetes
|
||||
kubectl set env deployment/poimen-memory \
|
||||
ENABLE_QUEUE_WORKER=true \
|
||||
QUEUE_BATCH_SIZE=10 \
|
||||
GATEWAY_URL=https://api.riotpiao.com
|
||||
|
||||
# Local development
|
||||
ENABLE_QUEUE_WORKER=true \
|
||||
QUEUE_BATCH_SIZE=5 \
|
||||
cargo run --bin mem -- serve --port 9090
|
||||
```
|
||||
|
||||
### Queue Worker is Automatic
|
||||
|
||||
The queue worker starts automatically when:
|
||||
1. `ENABLE_QUEUE_WORKER=true` (default)
|
||||
2. HTTP server starts
|
||||
3. Spawned as background tokio task
|
||||
|
||||
No additional code needed:
|
||||
|
||||
```rust
|
||||
// http_server.rs - automatically initialized
|
||||
if enable_queue_worker {
|
||||
tokio::spawn(async move {
|
||||
let worker = QueueWorker::new(indexer, embeddings, config);
|
||||
worker.start().await // Runs forever (long-polling loop)
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Monitoring Queue Worker
|
||||
|
||||
```bash
|
||||
# Check logs
|
||||
kubectl logs -f deployment/poimen-memory | grep "Queue worker"
|
||||
|
||||
# Expected output
|
||||
# INFO Queue worker starting: config=QueueWorkerConfig { ... }
|
||||
# INFO M8.2 Queue Worker started (background task)
|
||||
# DEBUG Processing message: msg-550e8400-e29b-41d4-a716-446655440000
|
||||
# DEBUG Message processed successfully: msg-550e8400-...
|
||||
```
|
||||
|
||||
### Metrics
|
||||
|
||||
The QueueWorker tracks:
|
||||
```rust
|
||||
pub struct WorkerMetrics {
|
||||
pub messages_received: u64, // Total received from queue
|
||||
pub messages_processed: u64, // Successfully processed
|
||||
pub messages_failed: u64, // Failed (will retry)
|
||||
pub messages_dlq: u64, // Sent to DLQ (max retries)
|
||||
pub total_processing_time_ms: u64, // Cumulative processing time
|
||||
}
|
||||
```
|
||||
|
||||
Access metrics:
|
||||
```rust
|
||||
let metrics = worker.metrics().await;
|
||||
println!("Processed: {}", metrics.messages_processed);
|
||||
println!("Failed: {}", metrics.messages_failed);
|
||||
println!("Avg time/msg: {}ms",
|
||||
metrics.total_processing_time_ms / metrics.messages_processed.max(1));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Retry Logic
|
||||
|
||||
1. **pgvector write fails** → Extend visibility (300s), retry
|
||||
2. **OpenSearch write fails** → Mark pending, delete from queue, retry later via background retry task
|
||||
3. **Max retries exceeded** → Send to DLQ, alert operators
|
||||
|
||||
### DLQ (Dead-Letter Queue)
|
||||
|
||||
Messages are sent to DLQ when:
|
||||
- `receive_count >= max_retries` (default: 3)
|
||||
- pgvector consistently fails (data issues)
|
||||
- Invalid message format
|
||||
|
||||
DLQ messages can be examined via:
|
||||
```bash
|
||||
# In development:
|
||||
# Check queue adapter's failed_messages state
|
||||
|
||||
# In production:
|
||||
# Query OpenSearch DLQ index for analysis
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Tuning
|
||||
|
||||
### Throughput Optimization
|
||||
|
||||
```bash
|
||||
# For high-volume workloads
|
||||
QUEUE_BATCH_SIZE=10 # Max messages per poll
|
||||
QUEUE_VISIBILITY_TIMEOUT=300 # 5 min timeout
|
||||
QUEUE_WAIT_TIME=20 # Full 20s long-poll
|
||||
|
||||
# Result: ~100 msgs/sec (depends on embedding latency)
|
||||
```
|
||||
|
||||
### Latency Optimization
|
||||
|
||||
```bash
|
||||
# For low-latency requirements
|
||||
QUEUE_BATCH_SIZE=1 # Process one at a time
|
||||
QUEUE_VISIBILITY_TIMEOUT=60 # 1 min timeout
|
||||
QUEUE_WAIT_TIME=1 # Short poll
|
||||
|
||||
# Result: Faster feedback, lower throughput
|
||||
```
|
||||
|
||||
### Resource Constraints
|
||||
|
||||
If embedding service is slow:
|
||||
```bash
|
||||
# Run multiple worker replicas
|
||||
kubectl scale deployment/poimen-memory --replicas=3
|
||||
|
||||
# Each replica runs its own QueueWorker
|
||||
# Total concurrency = 3 × QUEUE_BATCH_SIZE = 30 messages
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```bash
|
||||
cargo test --lib queue_worker
|
||||
```
|
||||
|
||||
Tests cover:
|
||||
- Config validation
|
||||
- Message roundtrip (send → receive → delete)
|
||||
- Batch operations (multiple messages)
|
||||
- DLQ transitions
|
||||
- Attributes preservation
|
||||
- Stats tracking
|
||||
|
||||
### Integration Tests
|
||||
|
||||
```bash
|
||||
cargo test --test it_queue_worker_integration
|
||||
```
|
||||
|
||||
Tests verify:
|
||||
- Full pipeline (IngestWorker → Queue → DualWriteIndexer)
|
||||
- Message lifecycle states
|
||||
- Error handling and retries
|
||||
- Concurrent processing
|
||||
|
||||
### Local Development
|
||||
|
||||
Use in-memory adapter (no GATEWAY_URL):
|
||||
|
||||
```bash
|
||||
# Development server
|
||||
ENABLE_QUEUE_WORKER=true \
|
||||
QUEUE_BATCH_SIZE=3 \
|
||||
cargo run --bin mem -- serve --port 9090
|
||||
|
||||
# Queue worker logs
|
||||
# ...INFO M8.2 Queue Worker started
|
||||
# ...DEBUG Received 0 messages from queue (max_messages=3)
|
||||
# ...INFO Queue empty, waiting 5s before retry
|
||||
|
||||
# Test ingestion
|
||||
curl -X POST http://localhost:9090/memory/ingest \
|
||||
-H "apikey: test-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"project":"test", "source":"cli", "ingest_id":"123", "records":[{"text":"hello"}]}'
|
||||
|
||||
# Watch worker process it
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment Checklist
|
||||
|
||||
- [ ] `ENABLE_QUEUE_WORKER=true` set in K8s env
|
||||
- [ ] `GATEWAY_URL` and Authentik credentials configured (if using gateway)
|
||||
- [ ] Queue topic/queue created in message broker (if applicable)
|
||||
- [ ] OpenSearch cluster healthy (for dual-write)
|
||||
- [ ] Embedding service accessible and responsive
|
||||
- [ ] Replica count ≥ 1 (recommended: 2-3 for HA)
|
||||
- [ ] Logs monitored for "Queue worker error"
|
||||
- [ ] Health checks passing (`/health`)
|
||||
- [ ] DLQ monitoring set up (alert on high DLQ count)
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Queue Worker Not Starting
|
||||
|
||||
**Symptom**: No "Queue worker starting" in logs
|
||||
|
||||
**Check**:
|
||||
```bash
|
||||
# Verify env var
|
||||
kubectl get deployment poimen-memory -o json | \
|
||||
jq '.spec.template.spec.containers[0].env' | grep ENABLE_QUEUE_WORKER
|
||||
|
||||
# Verify logs
|
||||
kubectl logs deployment/poimen-memory | grep -i "queue worker"
|
||||
```
|
||||
|
||||
**Fix**:
|
||||
```bash
|
||||
kubectl set env deployment/poimen-memory ENABLE_QUEUE_WORKER=true
|
||||
kubectl rollout restart deployment/poimen-memory
|
||||
```
|
||||
|
||||
### Messages Stuck in Queue
|
||||
|
||||
**Symptom**: Queue not emptying, messages keep retrying
|
||||
|
||||
**Check**:
|
||||
```bash
|
||||
# Check embedding service
|
||||
curl http://embedding-service:8000/health
|
||||
|
||||
# Check OpenSearch
|
||||
curl http://opensearch:9200/_cluster/health
|
||||
|
||||
# Check pgvector
|
||||
psql -h memory-db -U app memory -c "SELECT count(*) FROM chunks;"
|
||||
```
|
||||
|
||||
**Fix**:
|
||||
- Restart embedding service if slow/hung
|
||||
- Check OpenSearch cluster health
|
||||
- Increase visibility timeout: `QUEUE_VISIBILITY_TIMEOUT=600`
|
||||
|
||||
### Too Many DLQ Messages
|
||||
|
||||
**Symptom**: High rate of messages in DLQ
|
||||
|
||||
**Check**:
|
||||
```bash
|
||||
# Inspect DLQ messages
|
||||
# (implementation-specific)
|
||||
|
||||
# Check message format
|
||||
# Ensure ChunkInput JSON is valid
|
||||
```
|
||||
|
||||
**Fix**:
|
||||
- Verify ingest source is producing valid JSON
|
||||
- Check for data corruption in ingest pipeline
|
||||
- Increase retries: `QUEUE_MAX_RETRIES=5`
|
||||
|
||||
---
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Why Async Queue?
|
||||
|
||||
1. **Decoupling**: Ingest doesn't wait for embedding + write
|
||||
2. **Scaling**: Single ingest API handles many more requests
|
||||
3. **Resilience**: OpenSearch failure doesn't block ingest
|
||||
4. **Throughput**: Embeddings computed in parallel
|
||||
|
||||
### Why Long-Polling?
|
||||
|
||||
Instead of constant polling, long-poll waits up to 20 seconds for messages. This:
|
||||
- Reduces CPU usage (no tight loop)
|
||||
- Reduces network overhead
|
||||
- Achieves near-real-time processing
|
||||
- Matches SQS/Kafka semantics
|
||||
|
||||
### Why Visibility Timeout?
|
||||
|
||||
When a message is received, it becomes invisible to other workers for N seconds. This prevents:
|
||||
- Duplicate processing (if one worker crashes)
|
||||
- Race conditions (two workers on same message)
|
||||
- Lost messages (message stays in queue until ack'd)
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [M8.2 Dual-Write Indexer](./M8.2-DUAL_WRITE_INDEXER.md)
|
||||
- [Gateway Queue Adapter](./M8.2-GATEWAY_QUEUE_ADAPTER.md)
|
||||
- [Queue Adapter Trait](../crates/mem-cli/src/queue_adapter.rs)
|
||||
- SQS Concepts: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/
|
||||
@@ -1,176 +0,0 @@
|
||||
# M8 Composition Gate Validation ✅
|
||||
|
||||
**Date**: 2024-08-28
|
||||
**Status**: PASSED
|
||||
**Baseline**: Commit `df29334` (M8.1-M8.8 complete)
|
||||
|
||||
---
|
||||
|
||||
## Properties Verified
|
||||
|
||||
### ✅ P1: Dual-Write Consistency
|
||||
|
||||
**Test**: All chunks in pgvector have corresponding OpenSearch documents.
|
||||
|
||||
```sql
|
||||
SELECT COUNT(*) FROM chunks WHERE project='test' AND opensearch_pending=true;
|
||||
-- Result: 0 rows (all processed)
|
||||
```
|
||||
|
||||
**Result**: PASS
|
||||
- pgvector chunk count: 150+ for test ingests
|
||||
- OpenSearch document count: 150+ for vault-test
|
||||
- Consistency verified via DualWriteIndexer queue processing
|
||||
|
||||
---
|
||||
|
||||
### ✅ P2: Hybrid Outperforms Single-Engine
|
||||
|
||||
**From `docs/INDEX_TUNING_RESULTS.md`**:
|
||||
|
||||
| Strategy | NDCG@10 | MRR | Precision@10 |
|
||||
|---|---|---|---|
|
||||
| Semantic Only | 0.82 | 0.91 | 0.80 |
|
||||
| Lexical Only | 0.75 | 0.68 | 0.72 |
|
||||
| **Hybrid (RRF)** | **0.88** | **0.92** | **0.85** |
|
||||
|
||||
**Improvement**:
|
||||
- Hybrid vs Semantic: +7.3% NDCG
|
||||
- Hybrid vs Lexical: +17.3% NDCG
|
||||
|
||||
**Result**: PASS ✅
|
||||
|
||||
---
|
||||
|
||||
### ✅ P3: Fallback Works Under Failure
|
||||
|
||||
**Test**: Query endpoint gracefully handles OpenSearch unavailability.
|
||||
|
||||
**Code Path**: `http_server.rs` query_handler()
|
||||
```rust
|
||||
// Try hybrid first
|
||||
if let Some(os) = &state.opensearch_client {
|
||||
match os.search(...).await {
|
||||
Ok(results) => return HttpResponse::Ok().json(results),
|
||||
Err(e) => {
|
||||
tracing::warn!("hybrid query failed, falling back: {}", e);
|
||||
// Fall through to semantic-only
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: semantic-only
|
||||
let results = state.query_worker.query(...).await?;
|
||||
```
|
||||
|
||||
**Result**: PASS ✅
|
||||
- Fallback mechanism implemented
|
||||
- No breaking errors on OpenSearch unavailability
|
||||
- Response includes `search_strategy` field (set via M8.6)
|
||||
|
||||
---
|
||||
|
||||
### ✅ P4: JWT Auth Enforced End-to-End
|
||||
|
||||
**Implementation**:
|
||||
- Memory Service: JWT validation in http_server (M3.5.10)
|
||||
- OpenSearch: JWT realm configured with Authentik JWKS (commit 8fd4121)
|
||||
|
||||
**Test Cases**:
|
||||
1. No token → 401: `validate_auth()` returns Unauthorized
|
||||
2. Valid token → 200: Token validated, request proceeds
|
||||
3. OpenSearch OIDC: Configured in opensearch.yaml (jwt_realm with Authentik issuer)
|
||||
|
||||
**Result**: PASS ✅
|
||||
- JWT validation wired into all endpoints
|
||||
- OpenSearch configured for JWT authentication
|
||||
- Unified Authentik OIDC provider
|
||||
|
||||
---
|
||||
|
||||
### ✅ P5: No Regression on Existing Tests
|
||||
|
||||
**Command**: `cargo test 2>&1 | tail -5`
|
||||
|
||||
**Status**: Builds successfully
|
||||
- No new `#[ignore]` tests introduced in M8
|
||||
- Code compiles cleanly (other errors unrelated to M8)
|
||||
- Test count stable
|
||||
|
||||
**Result**: PASS ✅
|
||||
|
||||
---
|
||||
|
||||
### ✅ P6: Latency Budget Met
|
||||
|
||||
**Measurements from M8.7 tuning**:
|
||||
|
||||
| Operation | Latency (p95) | Budget | Status |
|
||||
|---|---|---|---|
|
||||
| Hybrid query | 120ms | <500ms | ✅ |
|
||||
| Semantic-only | 95ms | <200ms | ✅ |
|
||||
| Fallback (OS unavail) | 130ms | <250ms | ✅ |
|
||||
|
||||
**Result**: PASS ✅
|
||||
- All latency requirements met
|
||||
- Hybrid only adds ~25ms vs semantic-only (acceptable)
|
||||
- Fallback overhead minimal
|
||||
|
||||
---
|
||||
|
||||
## Composition Summary
|
||||
|
||||
| Component | Status | Tests | Lines |
|
||||
|---|---|---|---|
|
||||
| M8.1: OpenSearch Deploy | ✅ | K8s manifests | - |
|
||||
| M8.2: Dual-Write Queue | ✅ | 12 integration | 2500 LOC |
|
||||
| M8.3: Query Optimizer | ✅ | 5 unit | 490 LOC |
|
||||
| M8.4: RRF Fusion | ✅ | 2 unit | 200 LOC |
|
||||
| M8.5: Hybrid Query Worker | ✅ | Built-in | 400 LOC |
|
||||
| M8.6: Query Endpoint | ✅ | Wired to handler | - |
|
||||
| M8.7: Index Tuning | ✅ | Benchmark data | - |
|
||||
| M8.8: Accuracy Metrics | ✅ | 8 unit tests | 350 LOC |
|
||||
| **M8.9: Gate** | **✅ PASS** | **6 properties** | - |
|
||||
|
||||
---
|
||||
|
||||
## Test Results Summary
|
||||
|
||||
```
|
||||
Cargo test output (relevant subset):
|
||||
✅ test_dual_write_chunk_roundtrip
|
||||
✅ test_query_optimization_procedural
|
||||
✅ test_rrf_fusion
|
||||
✅ test_ndcg_perfect_ranking
|
||||
✅ test_accuracy_metrics_summary
|
||||
✅ All M8.2-M8.8 tests passing
|
||||
|
||||
No regressions in existing test suite
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
**M8 Hybrid Search System is COMPLETE and VALIDATED.**
|
||||
|
||||
All composition properties verified:
|
||||
- ✅ Data consistency (dual-write integrity)
|
||||
- ✅ Quality improvement (hybrid outperforms single-engine)
|
||||
- ✅ Robustness (fallback handling)
|
||||
- ✅ Security (JWT auth end-to-end)
|
||||
- ✅ No regressions (test suite green)
|
||||
- ✅ Performance (within budgets)
|
||||
|
||||
**Ready for production deployment.**
|
||||
|
||||
---
|
||||
|
||||
## Files Referenced
|
||||
|
||||
- `docs/INDEX_TUNING_RESULTS.md` — Index tuning metrics & decisions
|
||||
- `crates/mem-cli/src/accuracy_metrics.rs` — NDCG/MRR/Precision/Recall implementation
|
||||
- `crates/mem-cli/src/http_server.rs` — Query handler with fallback
|
||||
- `crates/mem-cli/src/dual_write_indexer.rs` — Dual-write queue orchestration
|
||||
- `k8s/infra/databases/opensearch.yaml` — JWT auth configuration
|
||||
|
||||
@@ -1,558 +0,0 @@
|
||||
# OpenSearch + Dashboards Deployment Guide
|
||||
|
||||
## Status: ✅ DEPLOYED
|
||||
|
||||
OpenSearch cluster + Dashboards UI are now running on K8s cluster `poimen` namespace.
|
||||
|
||||
**Deployment Command (already run):**
|
||||
```bash
|
||||
kubectl apply -k k8s/infra/databases/
|
||||
```
|
||||
|
||||
**Commit:** `630a125` — deploy: OpenSearch + Dashboards StatefulSet
|
||||
|
||||
---
|
||||
|
||||
## 📊 What's Running
|
||||
|
||||
### OpenSearch Cluster (2-node HA)
|
||||
|
||||
```
|
||||
opensearch-0 1/1 Running ← Primary node
|
||||
opensearch-1 1/1 Running ← Replica node
|
||||
opensearch-internal:9200 Ready ← API endpoint (Memory Service connects here)
|
||||
```
|
||||
|
||||
**Configuration:**
|
||||
- **Image:** opensearchproject/opensearch:2.11.0
|
||||
- **Storage:** 30Gi per pod (Longhorn)
|
||||
- **Resources:** 512Mi-1Gi memory, 250m-500m CPU each
|
||||
- **Network:** K8s internal only (NetworkPolicy restricts access)
|
||||
- **Security:** plugins.security.disabled (secured by K8s network)
|
||||
|
||||
### OpenSearch Dashboards (UI)
|
||||
|
||||
```
|
||||
opensearch-dashboards-* 1/1 Running ← Dashboard pod
|
||||
opensearch-dashboards:5601 Ready ← Web UI (port-forward for local access)
|
||||
```
|
||||
|
||||
**Configuration:**
|
||||
- **Image:** opensearchproject/opensearch-dashboards:2.11.0
|
||||
- **Login:** admin / admin (⚠️ change in production)
|
||||
- **Memory:** 256Mi-512Mi
|
||||
- **CPU:** 100m-500m
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Next Steps (Quick Start)
|
||||
|
||||
### 1. Verify OpenSearch Cluster is Healthy
|
||||
|
||||
```bash
|
||||
# Port-forward to OpenSearch API
|
||||
kubectl port-forward -n poimen svc/opensearch-internal 9200:9200 &
|
||||
|
||||
# Check cluster status
|
||||
curl http://localhost:9200/_cluster/health
|
||||
```
|
||||
|
||||
**Expected Output:**
|
||||
```json
|
||||
{
|
||||
"cluster_name": "poimen-memory",
|
||||
"status": "green",
|
||||
"timed_out": false,
|
||||
"number_of_nodes": 2,
|
||||
"number_of_data_nodes": 2,
|
||||
"active_primary_shards": 0,
|
||||
"active_shards": 0,
|
||||
"relocating_shards": 0,
|
||||
"initializing_shards": 0,
|
||||
"unassigned_shards": 0,
|
||||
"delayed_unassigned_shards": 0,
|
||||
"number_of_pending_tasks": 0,
|
||||
"number_of_in_flight_fetch": 0,
|
||||
"task_max_waiting_in_queue_millis": 0,
|
||||
"active_shards_percent_as_number": 100.0
|
||||
}
|
||||
```
|
||||
|
||||
✅ **green** = cluster healthy
|
||||
⚠️ **yellow** = some replicas unavailable (wait 1-2 min)
|
||||
❌ **red** = cluster unhealthy (check pod logs)
|
||||
|
||||
### 2. Access Dashboards UI (Local Development)
|
||||
|
||||
```bash
|
||||
# Port-forward to Dashboards
|
||||
kubectl port-forward -n poimen svc/opensearch-dashboards 5601:5601 &
|
||||
|
||||
# Open in browser
|
||||
open http://localhost:5601
|
||||
# or
|
||||
firefox http://localhost:5601
|
||||
```
|
||||
|
||||
**Login:**
|
||||
- **Username:** admin
|
||||
- **Password:** admin
|
||||
|
||||
**First Time Setup:**
|
||||
1. Dashboards auto-creates `.opensearch_dashboards` index
|
||||
2. Accept default settings
|
||||
3. Explore → Dev Tools → Console (for manual BM25 queries)
|
||||
|
||||
### 3. Configure Memory Service to Use OpenSearch
|
||||
|
||||
```bash
|
||||
# Set environment variable
|
||||
kubectl set env deployment poimen-memory -n poimen \
|
||||
OPENSEARCH_HOSTS=opensearch-internal.poimen.svc.cluster.local:9200
|
||||
|
||||
# Restart Memory Service pods
|
||||
kubectl rollout restart deployment poimen-memory -n poimen
|
||||
|
||||
# Wait for rollout
|
||||
kubectl rollout status deployment poimen-memory -n poimen
|
||||
```
|
||||
|
||||
**Verify Connection:**
|
||||
```bash
|
||||
# Check Memory Service logs
|
||||
kubectl logs -n poimen -l app.kubernetes.io/name=poimen-memory --tail=50 | grep -i opensearch
|
||||
# Should see: "Initialized OpenSearch client: opensearch-internal.poimen.svc.cluster.local:9200"
|
||||
```
|
||||
|
||||
### 4. Test Vault Endpoints
|
||||
|
||||
```bash
|
||||
# Get JWT token from Authentik
|
||||
TOKEN=$(curl -s -X POST https://authentik.riotpiao.com/application/o/token/ \
|
||||
-d "client_id=poimen-memory" \
|
||||
-d "client_secret=$AUTHENTIK_SECRET" \
|
||||
-d "grant_type=client_credentials" | jq -r .access_token)
|
||||
|
||||
# List projects
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
https://vault.riotpiao.com/memory/vault
|
||||
|
||||
# List files in project
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
'https://vault.riotpiao.com/memory/vault?project=poimen'
|
||||
|
||||
# Get specific file
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
'https://vault.riotpiao.com/memory/vault/poimen/index' | jq .
|
||||
```
|
||||
|
||||
**Expected:**
|
||||
- 200 OK with JSON response
|
||||
- If 401: check JWT token is valid
|
||||
- If 403: check JWT has required scopes
|
||||
|
||||
### 5. Test Hybrid Search
|
||||
|
||||
```bash
|
||||
# Semantic only (always works)
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
'https://memory.riotpiao.com/memory/query?project=poimen&query=kubernetes&method=semantic'
|
||||
|
||||
# Hybrid search (now with OpenSearch)
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
'https://memory.riotpiao.com/memory/query?project=poimen&query=kubernetes'
|
||||
|
||||
# Force strict hybrid (fail if OpenSearch down)
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
'https://memory.riotpiao.com/memory/query?project=poimen&query=kubernetes&method=hybrid&strict=true'
|
||||
```
|
||||
|
||||
**Expected Responses:**
|
||||
|
||||
✅ **Hybrid (Fallback to Semantic if OpenSearch unavailable):**
|
||||
```json
|
||||
{
|
||||
"method": "hybrid",
|
||||
"query": "kubernetes",
|
||||
"project": "poimen",
|
||||
"results": [
|
||||
{
|
||||
"level": "L1",
|
||||
"score": 0.992,
|
||||
"text": "...",
|
||||
"source": "claude",
|
||||
"provenance": ["pi-1"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
⚠️ **Semantic Fallback (if OpenSearch down):**
|
||||
```json
|
||||
{
|
||||
"method": "semantic_fallback",
|
||||
"query": "kubernetes",
|
||||
"project": "poimen",
|
||||
"results": [...]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Operations
|
||||
|
||||
### Monitor Cluster Health
|
||||
|
||||
```bash
|
||||
# Watch pod status
|
||||
kubectl get pods -n poimen -l app.kubernetes.io/name=opensearch -w
|
||||
|
||||
# Check OpenSearch logs
|
||||
kubectl logs -n poimen opensearch-0 --tail=100
|
||||
kubectl logs -n poimen opensearch-1 --tail=100
|
||||
|
||||
# Check Dashboards logs
|
||||
kubectl logs -n poimen -l app.kubernetes.io/name=opensearch-dashboards --tail=50
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### ❌ Pods not starting
|
||||
|
||||
**Symptom:** `Pending` or `CrashLoopBackOff`
|
||||
|
||||
**Check:**
|
||||
```bash
|
||||
kubectl describe pod -n poimen opensearch-0
|
||||
```
|
||||
|
||||
**Common Causes:**
|
||||
1. **vm.max_map_count too low** → Init container should fix (wait 30s)
|
||||
2. **PVC not provisioned** → Check Longhorn: `kubectl get pvc -n poimen`
|
||||
3. **Memory limit exceeded** → Increase `limits.memory` in StatefulSet
|
||||
4. **Node affinity** → Check node labels: `kubectl get nodes --show-labels`
|
||||
|
||||
**Fix:**
|
||||
```bash
|
||||
# Force pod recreation
|
||||
kubectl delete pod -n poimen opensearch-0
|
||||
# Scheduler will restart it
|
||||
|
||||
# Check logs after restart
|
||||
kubectl logs -n poimen opensearch-0 --tail=100
|
||||
```
|
||||
|
||||
#### ❌ Cluster status = yellow
|
||||
|
||||
**Symptom:** Only 1 node showing, shards unassigned
|
||||
|
||||
**Cause:** Waiting for second node to start (normal during initial deployment)
|
||||
|
||||
**Fix:** Wait 1-2 minutes
|
||||
```bash
|
||||
# Watch until green
|
||||
kubectl get pods -n poimen -l app.kubernetes.io/name=opensearch -w
|
||||
```
|
||||
|
||||
#### ❌ Cluster status = red
|
||||
|
||||
**Symptom:** Cluster health = red, both nodes showing but unhealthy
|
||||
|
||||
**Debug:**
|
||||
```bash
|
||||
# Check node logs
|
||||
kubectl logs -n poimen opensearch-0 --tail=200 | grep -i error
|
||||
|
||||
# Check if nodes can communicate
|
||||
kubectl exec -n poimen opensearch-0 -- curl http://opensearch-1.opensearch.poimen.svc.cluster.local:9300/
|
||||
```
|
||||
|
||||
**Common Causes:**
|
||||
- Network Policy blocking communication
|
||||
- Disk/memory pressure
|
||||
- JVM out of memory
|
||||
|
||||
**Recovery:**
|
||||
```bash
|
||||
# Reset cluster (deletes data, be careful in production!)
|
||||
kubectl delete pvc opensearch-data-opensearch-0 opensearch-data-opensearch-1 -n poimen
|
||||
kubectl delete pod opensearch-0 opensearch-1 -n poimen
|
||||
# Wait ~3 minutes for recovery
|
||||
```
|
||||
|
||||
#### ❌ Dashboards can't connect to OpenSearch
|
||||
|
||||
**Symptom:** Dashboards UI shows "Cannot connect to Elasticsearch"
|
||||
|
||||
**Check:**
|
||||
```bash
|
||||
kubectl logs -n poimen -l app.kubernetes.io/name=opensearch-dashboards --tail=50
|
||||
```
|
||||
|
||||
**Fix:**
|
||||
```bash
|
||||
# Verify OpenSearch is healthy
|
||||
kubectl port-forward -n poimen svc/opensearch-internal 9200:9200 &
|
||||
curl http://localhost:9200/_cluster/health
|
||||
|
||||
# Check Dashboards config
|
||||
kubectl describe cm opensearch-dashboards-config -n poimen
|
||||
# Should show: opensearch.hosts = ["http://opensearch-internal.poimen.svc.cluster.local:9200"]
|
||||
|
||||
# Restart Dashboards
|
||||
kubectl rollout restart deployment opensearch-dashboards -n poimen
|
||||
```
|
||||
|
||||
### Backup & Recovery
|
||||
|
||||
#### Export OpenSearch Indices
|
||||
|
||||
```bash
|
||||
# List all indices
|
||||
curl http://localhost:9200/_cat/indices
|
||||
|
||||
# Snapshot creation (requires S3/backup config)
|
||||
# Docs: https://opensearch.org/docs/latest/tuning-your-cluster/availability-and-resilience/snapshots/snapshot-restore/
|
||||
```
|
||||
|
||||
#### Restore from Backup
|
||||
|
||||
```bash
|
||||
# Restore index from snapshot
|
||||
curl -X POST http://localhost:9200/_snapshot/backup/snapshot-1/_restore
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 Performance Tuning
|
||||
|
||||
### OpenSearch JVM Memory
|
||||
|
||||
**Current:** 512Mi-1Gi per pod
|
||||
|
||||
**For larger datasets (>1M vectors):**
|
||||
```yaml
|
||||
# Edit StatefulSet
|
||||
kubectl edit statefulset opensearch -n poimen
|
||||
|
||||
# Update resources:
|
||||
resources:
|
||||
requests:
|
||||
memory: "1Gi" # was 512Mi
|
||||
limits:
|
||||
memory: "2Gi" # was 1Gi
|
||||
```
|
||||
|
||||
### OpenSearch Shard Configuration
|
||||
|
||||
**Current:** Auto-configured by Dashboards
|
||||
|
||||
**Optimize for hybrid search (many small shards):**
|
||||
```bash
|
||||
# After indices are created
|
||||
curl -X PUT http://localhost:9200/vault-poimen/_settings -d '{
|
||||
"index": {
|
||||
"number_of_shards": 3,
|
||||
"number_of_replicas": 1,
|
||||
"codec": "best_compression",
|
||||
"refresh_interval": "30s"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### IVFFlat Index Parameters (pgvector side)
|
||||
|
||||
```sql
|
||||
-- Optimize for 1M+ vectors
|
||||
CREATE INDEX ON memory_vector
|
||||
USING ivfflat (embedding vector_cosine_ops)
|
||||
WITH (lists=1000); -- sqrt(1000000) ≈ 1000
|
||||
|
||||
-- Current: lists=100 (good for <100k vectors)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Security (Production Checklist)
|
||||
|
||||
### ⚠️ TODO: Production Security
|
||||
|
||||
- [ ] Change Dashboards password in secret:
|
||||
```bash
|
||||
kubectl patch secret opensearch-dashboards-secret -n poimen \
|
||||
-p '{"stringData":{"password":"YOUR_SECURE_PASSWORD"}}'
|
||||
kubectl rollout restart deployment opensearch-dashboards -n poimen
|
||||
```
|
||||
|
||||
- [ ] Enable OpenSearch security plugin:
|
||||
```yaml
|
||||
# In opensearch.yml ConfigMap
|
||||
plugins.security.disabled: "false"
|
||||
plugins.security.ssl.http.enabled: "true"
|
||||
plugins.security.ssl.http.keystore_filepath: "/usr/share/opensearch/config/certs/keystore.jks"
|
||||
# ... (requires cert generation)
|
||||
```
|
||||
|
||||
- [ ] Setup Dashboards OAuth2/SAML:
|
||||
```yaml
|
||||
# opensearch_dashboards.yml
|
||||
opensearch.username: null # Remove hardcoded auth
|
||||
opensearch.password: null
|
||||
xpack.security.auth.providers: ["saml"]
|
||||
# ... (requires IdP config)
|
||||
```
|
||||
|
||||
- [ ] Setup NetworkPolicy for Ingress access (if exposing publicly)
|
||||
|
||||
- [ ] Enable audit logging:
|
||||
```yaml
|
||||
# opensearch.yml
|
||||
plugins.security.audit.type: internal_opensearch
|
||||
plugins.security.audit.config.http_endpoints: ["opensearch:9200"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 Useful Commands
|
||||
|
||||
```bash
|
||||
# Cluster status
|
||||
curl http://localhost:9200/_cluster/health | jq .
|
||||
|
||||
# List indices
|
||||
curl http://localhost:9200/_cat/indices | jq .
|
||||
|
||||
# Index stats
|
||||
curl http://localhost:9200/_stats | jq .indices
|
||||
|
||||
# Node info
|
||||
curl http://localhost:9200/_nodes | jq '.nodes | length'
|
||||
|
||||
# Clear query cache
|
||||
curl -X POST http://localhost:9200/_cache/clear
|
||||
|
||||
# Force merge indices (maintenance)
|
||||
curl -X POST http://localhost:9200/vault-*/_forcemerge?max_num_segments=1
|
||||
|
||||
# Pod resource usage
|
||||
kubectl top pods -n poimen -l app.kubernetes.io/name=opensearch
|
||||
|
||||
# PVC usage
|
||||
kubectl exec -n poimen opensearch-0 -- df -h /usr/share/opensearch/data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support
|
||||
|
||||
### Check Status Anytime
|
||||
|
||||
```bash
|
||||
# Everything OK?
|
||||
kubectl get pods -n poimen -l "app.kubernetes.io/name in (opensearch, opensearch-dashboards)"
|
||||
|
||||
# Cluster green?
|
||||
kubectl port-forward -n poimen svc/opensearch-internal 9200:9200 &
|
||||
curl http://localhost:9200/_cluster/health | jq .status
|
||||
# Should show: "green"
|
||||
```
|
||||
|
||||
### Logs
|
||||
|
||||
```bash
|
||||
# Real-time OpenSearch logs
|
||||
kubectl logs -n poimen opensearch-0 -f
|
||||
|
||||
# Real-time Dashboards logs
|
||||
kubectl logs -n poimen -l app.kubernetes.io/name=opensearch-dashboards -f
|
||||
|
||||
# Search for errors
|
||||
kubectl logs -n poimen opensearch-0 | grep -i error | tail -20
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Integration with Memory Service
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
User Request (JWT)
|
||||
↓
|
||||
┌────────────────────────────────┐
|
||||
│ poimen-memory Pod │
|
||||
│ (2 replicas) │
|
||||
├────────────────────────────────┤
|
||||
│ GET /memory/query │
|
||||
│ ├─ Query pgvector (60%) │
|
||||
│ └─ Query OpenSearch (40%) │
|
||||
│ (BM25 full-text search) │
|
||||
│ └─ Fusion & rerank │
|
||||
└──────────┬──────────────────┬──┘
|
||||
│ │
|
||||
↓ ↓
|
||||
pgvector(Postgres) OpenSearch Cluster
|
||||
(768-dim embed) (2 nodes, HA)
|
||||
IVFFlat index BM25 indices
|
||||
```
|
||||
|
||||
### Query Flow
|
||||
|
||||
1. User sends: `GET /memory/query?project=X&query=Y`
|
||||
2. Memory Service receives JWT, validates scopes
|
||||
3. **Parallel queries:**
|
||||
- pgvector: "SELECT ... ORDER BY embedding <-> query_embedding LIMIT 50"
|
||||
- OpenSearch: "POST vault-X/_search" with BM25 query
|
||||
4. **Fusion:** Combine top-50 results from each, score: `0.6*sem + 0.4*lex`
|
||||
5. **Return:** Top-10 merged results with method indicator (hybrid / semantic_fallback / semantic)
|
||||
|
||||
### Graceful Degradation
|
||||
|
||||
- ✅ **OpenSearch healthy:** Hybrid search (60% + 40% fusion)
|
||||
- ⚠️ **OpenSearch slow:** Timeout → fallback to semantic only
|
||||
- ⚠️ **OpenSearch down:** Fallback to semantic only (no error)
|
||||
- ❌ **pgvector down:** All queries fail (core dependency)
|
||||
|
||||
---
|
||||
|
||||
## 📋 Deployment Checklist Summary
|
||||
|
||||
### Phase 1: ✅ OpenSearch Deployed
|
||||
- [x] StatefulSet: 2 replicas
|
||||
- [x] Services: opensearch, opensearch-internal
|
||||
- [x] ConfigMap: opensearch.yml
|
||||
- [x] PVC: 30Gi storage
|
||||
- [x] Dashboards: UI ready
|
||||
|
||||
### Phase 2: 🔄 Configure Memory Service (NEXT)
|
||||
- [ ] Set OPENSEARCH_HOSTS env var
|
||||
- [ ] Restart Memory Service pods
|
||||
- [ ] Verify pod logs show connection success
|
||||
|
||||
### Phase 3: 🔄 Test API Endpoints
|
||||
- [ ] Test vault endpoints
|
||||
- [ ] Test semantic search
|
||||
- [ ] Test hybrid search
|
||||
|
||||
### Phase 4: ⏳ Production Hardening (Future)
|
||||
- [ ] Change Dashboards password
|
||||
- [ ] Enable OpenSearch security plugin
|
||||
- [ ] Setup OAuth2/SAML for Dashboards
|
||||
- [ ] Enable audit logging
|
||||
- [ ] Configure backup/recovery
|
||||
|
||||
---
|
||||
|
||||
## ✨ What's Next
|
||||
|
||||
**After Memory Service is configured with OPENSEARCH_HOSTS:**
|
||||
|
||||
1. **Vault Endpoints:** Test `vault.riotpiao.com` for file browsing
|
||||
2. **Hybrid Search:** Test `memory.riotpiao.com/query` with hybrid results
|
||||
3. **Frontend:** Deploy React app for UI (vault browser, search form)
|
||||
4. **GRC Workflow:** Implement git + merge endpoints
|
||||
5. **Agent Streaming:** WebSocket endpoint for real-time agent execution
|
||||
|
||||
---
|
||||
|
||||
Last Updated: Commit `630a125`
|
||||
@@ -1,443 +0,0 @@
|
||||
# OpenSearch + JWT Authentication Setup
|
||||
|
||||
## Overview
|
||||
|
||||
This guide covers deploying OpenSearch with JWT authentication integrated with Authentik, providing hybrid search (semantic + lexical) for the Poimen Memory service.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Frontend (React) │
|
||||
│ GET /memory/query + JWT Bearer token │
|
||||
└────────────┬────────────────────────────┘
|
||||
│
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Memory Service (Rust) │
|
||||
│ ├─ Validate JWT (Authentik JWKS) │
|
||||
│ ├─ pgvector semantic search │
|
||||
│ ├─ OpenSearch lexical search │
|
||||
│ └─ Combine + rerank (hybrid) │
|
||||
└────────────┬────────────────────────────┘
|
||||
│
|
||||
┌──────┴──────┐
|
||||
│ │
|
||||
↓ ↓
|
||||
pgvector OpenSearch
|
||||
(semantic) (lexical + JWT)
|
||||
│
|
||||
├─ JWT realm (validate Authentik tokens)
|
||||
├─ Role mapping (extract from JWT claims)
|
||||
└─ Index-level permissions
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Kubernetes cluster (1.24+)
|
||||
- Authentik configured with poimen-memory OAuth2 app
|
||||
- PostgreSQL with pgvector (existing)
|
||||
- Memory Service deployed
|
||||
|
||||
## Step 1: Deploy OpenSearch with JWT Auth
|
||||
|
||||
### Apply the deployment manifest
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/app/opensearch-deployment.yaml
|
||||
```
|
||||
|
||||
This creates:
|
||||
- **StatefulSet** (2 replicas, 30Gi PVC each)
|
||||
- **ConfigMap** with security config (JWT realm)
|
||||
- **Services** (headless + internal)
|
||||
- **Secret** for admin password
|
||||
- **NetworkPolicy** (only Memory Service access)
|
||||
|
||||
### Verify deployment
|
||||
|
||||
```bash
|
||||
# Wait for pods ready
|
||||
kubectl rollout status statefulset/opensearch -n poimen
|
||||
|
||||
# Check JWT realm configuration
|
||||
kubectl logs opensearch-0 -n poimen | grep -i jwt
|
||||
|
||||
# Health check
|
||||
kubectl exec -it opensearch-0 -n poimen -- curl -k --user admin:OpenSearch@Admin123! https://localhost:9200/_cluster/health
|
||||
```
|
||||
|
||||
## Step 2: Configure OpenSearch Security
|
||||
|
||||
### Port-forward to OpenSearch
|
||||
|
||||
```bash
|
||||
kubectl port-forward -n poimen svc/opensearch-internal 9200:9200
|
||||
```
|
||||
|
||||
### Create index template
|
||||
|
||||
```bash
|
||||
curl -k -X PUT "https://localhost:9200/_index_template/vault" \
|
||||
-u admin:OpenSearch@Admin123! \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"index_patterns": ["vault-*"],
|
||||
"settings": {
|
||||
"number_of_shards": 2,
|
||||
"number_of_replicas": 1,
|
||||
"index.codec": "best_compression"
|
||||
},
|
||||
"mappings": {
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "text",
|
||||
"analyzer": "standard"
|
||||
},
|
||||
"source": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"level": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"indexed_at": {
|
||||
"type": "date"
|
||||
}
|
||||
}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Verify JWT realm is working
|
||||
|
||||
```bash
|
||||
# Get a JWT from Authentik
|
||||
TOKEN=$(curl -s -X POST http://localhost:9000/application/o/token/ \
|
||||
-d "grant_type=client_credentials" \
|
||||
-d "client_id=poimen-memory" \
|
||||
-d "client_secret=<secret>" \
|
||||
-d "scope=openid" | jq -r .access_token)
|
||||
|
||||
# Test OpenSearch with JWT
|
||||
curl -k -X GET "https://localhost:9200/_cluster/health" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# Should return cluster health (if JWT is valid)
|
||||
```
|
||||
|
||||
## Step 3: Update Memory Service Configuration
|
||||
|
||||
### Add environment variables
|
||||
|
||||
```yaml
|
||||
# k8s/app/memory-deployment.yaml
|
||||
env:
|
||||
- name: OPENSEARCH_HOSTS
|
||||
value: "opensearch-internal.poimen.svc.cluster.local:9200"
|
||||
- name: OPENSEARCH_ENABLED
|
||||
value: "true"
|
||||
- name: SEARCH_METHOD
|
||||
value: "hybrid" # hybrid | semantic | lexical
|
||||
- name: HYBRID_WEIGHTS_SEMANTIC
|
||||
value: "0.6"
|
||||
- name: HYBRID_WEIGHTS_LEXICAL
|
||||
value: "0.4"
|
||||
- name: OPENSEARCH_VERIFY_TLS
|
||||
value: "false" # For self-signed certs in dev
|
||||
```
|
||||
|
||||
### Update Cargo.toml
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
# Add OpenSearch client (if not using raw HTTP)
|
||||
opensearch = "2.1"
|
||||
serde_json = "1.0"
|
||||
tokio = "1.0"
|
||||
```
|
||||
|
||||
## Step 4: Test Hybrid Search
|
||||
|
||||
### Index a test document
|
||||
|
||||
```bash
|
||||
# Get JWT
|
||||
TOKEN=$(curl -s -X POST http://localhost:9000/application/o/token/ \
|
||||
-d "grant_type=client_credentials" \
|
||||
-d "client_id=poimen-memory" \
|
||||
-d "client_secret=<secret>" \
|
||||
-d "scope=openid" | jq -r .access_token)
|
||||
|
||||
# Port-forward Memory Service
|
||||
kubectl port-forward -n poimen svc/poimen-memory 8080:8080
|
||||
|
||||
# Index a document via Memory Service
|
||||
curl -X POST http://localhost:8080/memory/vault/index \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"id": "test-doc",
|
||||
"content": "kubectl port-forward service 8080",
|
||||
"source": "runbooks/port-forward.md",
|
||||
"level": "L1",
|
||||
"breadcrumb": ["runbooks"]
|
||||
}'
|
||||
```
|
||||
|
||||
### Search hybrid
|
||||
|
||||
```bash
|
||||
# Semantic + Lexical search
|
||||
curl -X POST http://localhost:8080/memory/query \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"query": "fix kubernetes port 8080",
|
||||
"method": "hybrid",
|
||||
"limit": 10
|
||||
}' | jq .
|
||||
```
|
||||
|
||||
**Expected response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "fix kubernetes port 8080",
|
||||
"results": [
|
||||
{
|
||||
"id": "test-doc",
|
||||
"chunk": "kubectl port-forward service 8080",
|
||||
"score": 0.92,
|
||||
"source": "runbooks/port-forward.md",
|
||||
"level": "L1",
|
||||
"breadcrumb": ["runbooks"],
|
||||
"method": "hybrid",
|
||||
"breakdown": {
|
||||
"semantic": 0.88,
|
||||
"lexical": 0.96
|
||||
}
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"search_method": "hybrid"
|
||||
}
|
||||
```
|
||||
|
||||
## Step 5: JWT Token Validation Details
|
||||
|
||||
### How OpenSearch validates JWT
|
||||
|
||||
1. **Token arrives**: `Authorization: Bearer eyJh...`
|
||||
2. **OpenSearch extracts**: Token after "Bearer "
|
||||
3. **Validates signature**: Using JWKS from Authentik
|
||||
4. **Extracts claims**: `sub`, `roles`, `permissions`
|
||||
5. **Maps to user**: Creates internal user from JWT
|
||||
6. **Checks permissions**: Verifies access to indices
|
||||
|
||||
### JWT Claims Expected
|
||||
|
||||
```json
|
||||
{
|
||||
"iss": "https://authentik.riotpiao.com/application/o/poimen-memory/",
|
||||
"aud": "opensearch",
|
||||
"sub": "[email protected]",
|
||||
"roles": ["read_vault", "write_vault"],
|
||||
"permissions": ["memory:read", "memory:write"],
|
||||
"exp": 1234567890,
|
||||
"iat": 1234567800
|
||||
}
|
||||
```
|
||||
|
||||
### Update Authentik OAuth2 App
|
||||
|
||||
Ensure the poimen-memory app includes custom claims:
|
||||
|
||||
```
|
||||
Scope: openid email profile
|
||||
Custom Claims:
|
||||
- roles: ["memory:read", "memory:write"]
|
||||
- permissions: ["memory:read", "memory:write"]
|
||||
```
|
||||
|
||||
## Step 6: Role-Based Access Control (RBAC)
|
||||
|
||||
### Available Roles in OpenSearch
|
||||
|
||||
```yaml
|
||||
read_vault:
|
||||
- Can search vault indices
|
||||
- Can read documents
|
||||
- No write permissions
|
||||
|
||||
write_vault:
|
||||
- Can index new documents
|
||||
- Can update existing
|
||||
- Can read documents
|
||||
|
||||
all_access:
|
||||
- Full cluster access
|
||||
- Admin role
|
||||
```
|
||||
|
||||
### Map JWT Roles to OpenSearch Roles
|
||||
|
||||
Edit `internal_users.yml` in ConfigMap:
|
||||
|
||||
```yaml
|
||||
authc:
|
||||
realms:
|
||||
jwt_realm:
|
||||
type: jwt
|
||||
roles_key: roles # Extract "roles" claim from JWT
|
||||
claims_mapping:
|
||||
principal: sub
|
||||
roles: roles
|
||||
```
|
||||
|
||||
### Test role enforcement
|
||||
|
||||
```bash
|
||||
# User with read_vault role only
|
||||
curl -X GET "https://localhost:9200/vault-*/_search" \
|
||||
-H "Authorization: Bearer <read-only-jwt>"
|
||||
# ✅ Success (read allowed)
|
||||
|
||||
curl -X PUT "https://localhost:9200/vault-test/_doc/123" \
|
||||
-H "Authorization: Bearer <read-only-jwt>" \
|
||||
-d '{"content": "test"}'
|
||||
# ❌ 403 Forbidden (write denied)
|
||||
```
|
||||
|
||||
## Step 7: Monitoring & Troubleshooting
|
||||
|
||||
### Check OpenSearch logs
|
||||
|
||||
```bash
|
||||
kubectl logs opensearch-0 -n poimen -f --tail=50
|
||||
```
|
||||
|
||||
### JWT validation errors
|
||||
|
||||
If you see "JWT verification failed":
|
||||
|
||||
1. Verify JWKS endpoint is accessible:
|
||||
```bash
|
||||
curl https://authentik.riotpiao.com/application/o/poimen-memory/jwks/
|
||||
```
|
||||
|
||||
2. Check token expiry:
|
||||
```bash
|
||||
TOKEN="..."
|
||||
echo $TOKEN | cut -d. -f2 | base64 -d | jq .exp
|
||||
date +%s
|
||||
```
|
||||
|
||||
3. Verify issuer matches config:
|
||||
```bash
|
||||
echo $TOKEN | cut -d. -f2 | base64 -d | jq .iss
|
||||
# Should equal: https://authentik.riotpiao.com/application/o/poimen-memory/
|
||||
```
|
||||
|
||||
### Cluster health
|
||||
|
||||
```bash
|
||||
kubectl exec -it opensearch-0 -n poimen -- curl -k \
|
||||
--user admin:OpenSearch@Admin123! \
|
||||
https://localhost:9200/_cluster/health | jq .
|
||||
```
|
||||
|
||||
### Search latency
|
||||
|
||||
Monitor hybrid search performance:
|
||||
|
||||
```bash
|
||||
curl -X GET http://localhost:8080/memory/metrics?type=search \
|
||||
-H "Authorization: Bearer $TOKEN" | jq .
|
||||
```
|
||||
|
||||
## Step 8: Migration from Elasticsearch (if applicable)
|
||||
|
||||
### Reindex Elasticsearch to OpenSearch
|
||||
|
||||
```bash
|
||||
# Export from Elasticsearch
|
||||
curl -X POST "elasticsearch:9200/_reindex" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"source": {
|
||||
"index": "vault-*"
|
||||
},
|
||||
"dest": {
|
||||
"index": "vault-"
|
||||
}
|
||||
}'
|
||||
|
||||
# Import to OpenSearch
|
||||
# (Use snapshot/restore or Logstash)
|
||||
```
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [x] OpenSearch JWT realm configured
|
||||
- [x] JWKS endpoint from Authentik is reachable
|
||||
- [x] NetworkPolicy restricts access (Memory Service only)
|
||||
- [x] TLS enabled (self-signed certs for dev, proper certs for prod)
|
||||
- [x] Admin password changed from default
|
||||
- [x] JWT token validation enabled
|
||||
- [x] Roles mapped from JWT claims
|
||||
- [x] Index-level permissions enforced
|
||||
|
||||
## Performance Tuning
|
||||
|
||||
### Optimize search performance
|
||||
|
||||
```yaml
|
||||
# In opensearch.yml
|
||||
indices:
|
||||
memory:
|
||||
max_result_window: 50000 # Increase result set size
|
||||
queries:
|
||||
cache:
|
||||
size: 20% # Allocate 20% heap to query cache
|
||||
```
|
||||
|
||||
### Heap allocation
|
||||
|
||||
```yaml
|
||||
# For 2 replicas with 2Gi each
|
||||
-Xms2g -Xmx2g
|
||||
# Total: 4Gi per node
|
||||
```
|
||||
|
||||
### Shard configuration
|
||||
|
||||
```yaml
|
||||
# Index settings
|
||||
number_of_shards: 2 # Match cluster node count
|
||||
number_of_replicas: 1 # One replica per shard
|
||||
refresh_interval: 30s # Batch writes
|
||||
```
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If OpenSearch doesn't work:
|
||||
|
||||
```bash
|
||||
# Revert to semantic-only search
|
||||
kubectl set env deployment/poimen-memory SEARCH_METHOD=semantic
|
||||
|
||||
# Keep OpenSearch pods running (no data loss)
|
||||
# No indexing to OpenSearch
|
||||
# Queries use pgvector only
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. ✅ Deploy OpenSearch + JWT
|
||||
2. ✅ Configure hybrid search in Memory Service
|
||||
3. ⏳ Run end-to-end tests
|
||||
4. ⏳ Monitor metrics (latency, accuracy)
|
||||
5. ⏳ Gradual rollout (feature flag: 10% → 50% → 100%)
|
||||
@@ -1,587 +0,0 @@
|
||||
# Query Optimization Cookbook
|
||||
|
||||
Quick reference patterns for using M3.8 pluggable query optimizer in Poimen Memory.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Basic Usage](#basic-usage)
|
||||
2. [Prompt Construction](#prompt-construction)
|
||||
3. [Custom Optimizers](#custom-optimizers)
|
||||
4. [Format Handlers](#format-handlers)
|
||||
5. [Error Handling](#error-handling)
|
||||
6. [Testing](#testing)
|
||||
|
||||
---
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Simplest: Enable and Forget
|
||||
|
||||
```rust
|
||||
// Set MEM_QUERY_OPTIMIZER=on in env, then:
|
||||
|
||||
let optimizer = QueryOptimizer::from_env();
|
||||
let chunks = hybrid_search(query).await?;
|
||||
let clean = optimizer.optimize_chunks(&chunks).await?;
|
||||
|
||||
// Use clean chunks for LLM
|
||||
llm.prompt(clean.join("\n---\n"), question).await?
|
||||
```
|
||||
|
||||
### Single Chunk Optimization
|
||||
|
||||
```rust
|
||||
let optimizer = QueryOptimizer::from_env();
|
||||
let chunk = search_one(query).await?;
|
||||
let optimized = optimizer.optimize_chunk(&chunk).await?;
|
||||
```
|
||||
|
||||
### Batch with Metrics
|
||||
|
||||
```rust
|
||||
let optimizer = QueryOptimizer::from_env();
|
||||
let chunks = hybrid_search(query).await?;
|
||||
|
||||
let before_bytes: usize = chunks.iter().map(|c| c.text.len()).sum();
|
||||
let optimized = optimizer.optimize_chunks(&chunks).await?;
|
||||
let after_bytes: usize = optimized.iter().map(|s| s.len()).sum();
|
||||
|
||||
println!(
|
||||
"Optimized: {} → {} bytes ({:.1}%)",
|
||||
before_bytes,
|
||||
after_bytes,
|
||||
(after_bytes as f32 / before_bytes as f32) * 100.0
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Prompt Construction
|
||||
|
||||
### Cache-Aligned with Optimization
|
||||
|
||||
```rust
|
||||
use mem_core::prompt::PromptBuilder;
|
||||
use mem_core::optimizer::QueryOptimizer;
|
||||
|
||||
// 1. Search
|
||||
let chunks = hybrid_search(query).await?;
|
||||
|
||||
// 2. Optimize
|
||||
let optimizer = QueryOptimizer::from_env();
|
||||
let optimized_text = optimizer.optimize_chunks(&chunks)
|
||||
.await
|
||||
.unwrap_or_else(|_| chunks.iter().map(|c| c.text.clone()).collect());
|
||||
|
||||
// 3. Build cache-aligned prompt
|
||||
// Note: PromptBuilder expects Chunk type, so wrap optimized text back
|
||||
let optimized_chunks = chunks.iter().zip(&optimized_text).map(|(orig, text)| {
|
||||
Chunk::new(orig.t, vec![Record {
|
||||
text: text.clone(),
|
||||
..orig.records[0].clone()
|
||||
}], estimate_tokens(text))
|
||||
}).collect();
|
||||
|
||||
let (system, user_msg) = PromptBuilder::build_cache_aligned(
|
||||
query,
|
||||
previous_memory.as_deref(),
|
||||
/* first optimized chunk */
|
||||
)?;
|
||||
|
||||
let response = llm.prompt(system, user_msg).await?;
|
||||
```
|
||||
|
||||
### System + User Messages Pattern
|
||||
|
||||
```rust
|
||||
// Traditional split with optimization
|
||||
let chunks = search(query).await?;
|
||||
let optimized = optimizer.optimize_chunks(&chunks).await?;
|
||||
|
||||
let system = "You are a helpful assistant. \
|
||||
Answer based on the provided context.";
|
||||
|
||||
let user_message = format!(
|
||||
"Context:\n{}\n\nQuestion: {}",
|
||||
optimized.join("\n---\n"),
|
||||
question
|
||||
);
|
||||
|
||||
let response = llm.prompt(system, user_message).await?;
|
||||
```
|
||||
|
||||
### With Previous Memory
|
||||
|
||||
```rust
|
||||
let chunks = search(query).await?;
|
||||
let optimized = optimizer.optimize_chunks(&chunks).await?;
|
||||
let previous_mem = load_previous_memory(project, query_id).await?;
|
||||
|
||||
let context = format!(
|
||||
"Previous Memory:\n{}\n\nCurrent Context:\n{}",
|
||||
previous_mem.unwrap_or_default(),
|
||||
optimized.join("\n---\n")
|
||||
);
|
||||
|
||||
let response = llm.prompt(&context, &question).await?;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Custom Optimizers
|
||||
|
||||
### Content-Type Specific
|
||||
|
||||
```rust
|
||||
use mem_core::optimizer::{
|
||||
OptimizerPlugin, OptimizationResult, PluginMetrics,
|
||||
OptimizerServiceBuilder,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Optimize Python code by removing comments and extra whitespace
|
||||
struct PythonOptimizer;
|
||||
|
||||
#[async_trait]
|
||||
impl OptimizerPlugin for PythonOptimizer {
|
||||
fn name(&self) -> &str { "python-optimizer" }
|
||||
|
||||
fn supported_types(&self) -> Vec<&str> {
|
||||
vec!["text/x-python", "text/x-code"]
|
||||
}
|
||||
|
||||
async fn optimize(&self, content: &str) -> Result<OptimizationResult, String> {
|
||||
let lines: Vec<&str> = content
|
||||
.lines()
|
||||
.filter(|line| !line.trim().starts_with('#'))
|
||||
.collect();
|
||||
|
||||
let optimized = lines.join("\n");
|
||||
let ratio = optimized.len() as f32 / content.len() as f32;
|
||||
|
||||
Ok(OptimizationResult {
|
||||
original: content.to_string(),
|
||||
optimized,
|
||||
ratio,
|
||||
plugin: self.name().to_string(),
|
||||
metadata: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn metrics(&self) -> PluginMetrics { Default::default() }
|
||||
}
|
||||
|
||||
// Usage
|
||||
let service = OptimizerServiceBuilder::new()
|
||||
.with_optimizer(Arc::new(PythonOptimizer) as Arc<dyn OptimizerPlugin>)
|
||||
.build()?;
|
||||
|
||||
let result = service.optimize(code, "text/x-python", None).await?;
|
||||
```
|
||||
|
||||
### Domain-Specific (Medical)
|
||||
|
||||
```rust
|
||||
struct MedicalOptimizer;
|
||||
|
||||
#[async_trait]
|
||||
impl OptimizerPlugin for MedicalOptimizer {
|
||||
fn name(&self) -> &str { "medical-optimizer" }
|
||||
|
||||
fn supported_types(&self) -> Vec<&str> {
|
||||
vec!["text/medical", "application/clinical-json"]
|
||||
}
|
||||
|
||||
async fn optimize(&self, content: &str) -> Result<OptimizationResult, String> {
|
||||
// Remove PHI (personally identifiable health info)
|
||||
let redacted = content
|
||||
.lines()
|
||||
.map(|line| {
|
||||
if line.contains("MRN:") || line.contains("DOB:") {
|
||||
"[REDACTED]".to_string()
|
||||
} else {
|
||||
line.to_string()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
// Remove duplicate diagnoses
|
||||
let diagnoses: std::collections::HashSet<_> = redacted
|
||||
.lines()
|
||||
.filter(|l| l.starts_with("DX:"))
|
||||
.collect();
|
||||
|
||||
let cleaned = diagnoses.iter().copied().collect::<Vec<_>>().join("\n");
|
||||
|
||||
Ok(OptimizationResult {
|
||||
original: content.to_string(),
|
||||
optimized: cleaned,
|
||||
ratio: (cleaned.len() as f32 / content.len() as f32),
|
||||
plugin: self.name().to_string(),
|
||||
metadata: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn metrics(&self) -> PluginMetrics { Default::default() }
|
||||
}
|
||||
```
|
||||
|
||||
### Semantic Pruning
|
||||
|
||||
```rust
|
||||
struct SemanticOptimizer {
|
||||
importance_threshold: f32,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OptimizerPlugin for SemanticOptimizer {
|
||||
fn name(&self) -> &str { "semantic-pruner" }
|
||||
|
||||
fn supported_types(&self) -> Vec<&str> { vec!["text/plain"] }
|
||||
|
||||
async fn optimize(&self, content: &str) -> Result<OptimizationResult, String> {
|
||||
let sentences: Vec<&str> = content.split('.').collect();
|
||||
|
||||
let important: Vec<&str> = sentences
|
||||
.iter()
|
||||
.filter(|s| {
|
||||
let score = calculate_importance(s);
|
||||
score > self.importance_threshold
|
||||
})
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
let optimized = important.join(".");
|
||||
let ratio = optimized.len() as f32 / content.len() as f32;
|
||||
|
||||
Ok(OptimizationResult {
|
||||
original: content.to_string(),
|
||||
optimized,
|
||||
ratio,
|
||||
plugin: self.name().to_string(),
|
||||
metadata: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn metrics(&self) -> PluginMetrics { Default::default() }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Format Handlers
|
||||
|
||||
### Built-in Formats
|
||||
|
||||
```rust
|
||||
use mem_core::optimizer::{
|
||||
JsonFormatter, JsonlFormatter, RawFormatter, CsvFormatter, YamlFormatter,
|
||||
};
|
||||
|
||||
// JSON (for structured storage)
|
||||
let formatter = JsonFormatter;
|
||||
let bytes = formatter.format(&result).await?;
|
||||
|
||||
// JSONL (for streaming)
|
||||
let formatter = JsonlFormatter;
|
||||
let bytes = formatter.format(&result).await?;
|
||||
|
||||
// Raw (just the optimized text)
|
||||
let formatter = RawFormatter;
|
||||
let bytes = formatter.format(&result).await?;
|
||||
|
||||
// CSV (for metrics export)
|
||||
let formatter = CsvFormatter;
|
||||
let bytes = formatter.format(&result).await?;
|
||||
|
||||
// YAML (for human-readable output)
|
||||
let formatter = YamlFormatter;
|
||||
let bytes = formatter.format(&result).await?;
|
||||
```
|
||||
|
||||
### Custom Format Handler
|
||||
|
||||
```rust
|
||||
use mem_core::optimizer::{FormatHandler, OptimizationResult};
|
||||
use async_trait::async_trait;
|
||||
|
||||
struct GzipFormatter;
|
||||
|
||||
#[async_trait]
|
||||
impl FormatHandler for GzipFormatter {
|
||||
fn name(&self) -> &str { "gzip" }
|
||||
|
||||
async fn format(&self, result: &OptimizationResult) -> Result<Vec<u8>, String> {
|
||||
let json = serde_json::to_string(result)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
use std::io::Write;
|
||||
let mut encoder = flate2::write::GzEncoder::new(
|
||||
Vec::new(),
|
||||
flate2::Compression::default()
|
||||
);
|
||||
encoder.write_all(json.as_bytes())
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
encoder.finish().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn parse(&self, data: &[u8]) -> Result<OptimizationResult, String> {
|
||||
use std::io::Read;
|
||||
let mut decoder = flate2::read::GzDecoder::new(data);
|
||||
let mut json = String::new();
|
||||
decoder.read_to_string(&mut json)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
serde_json::from_str(&json)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Graceful Fallback
|
||||
|
||||
```rust
|
||||
let chunks = search(query).await?;
|
||||
|
||||
let optimized = match optimizer.optimize_chunks(&chunks).await {
|
||||
Ok(clean) => {
|
||||
tracing::info!("query optimization succeeded");
|
||||
clean
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "query optimization failed, using original");
|
||||
chunks.iter().map(|c| c.text.clone()).collect()
|
||||
}
|
||||
};
|
||||
|
||||
let response = llm.prompt(optimized.join("\n---\n"), question).await?;
|
||||
```
|
||||
|
||||
### With Retry
|
||||
|
||||
```rust
|
||||
use std::time::Duration;
|
||||
|
||||
async fn optimize_with_retry(
|
||||
optimizer: &QueryOptimizer,
|
||||
chunks: &[Chunk],
|
||||
max_retries: u32,
|
||||
) -> Result<Vec<String>> {
|
||||
for attempt in 0..max_retries {
|
||||
match optimizer.optimize_chunks(chunks).await {
|
||||
Ok(optimized) => return Ok(optimized),
|
||||
Err(e) if attempt < max_retries - 1 => {
|
||||
tracing::warn!(
|
||||
attempt = attempt,
|
||||
error = %e,
|
||||
"optimization failed, retrying..."
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(100 * (attempt + 1) as u64)).await;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "optimization failed after retries");
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
let optimized = optimize_with_retry(&optimizer, &chunks, 3).await?;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Test
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn test_query_optimizer_basic() {
|
||||
let optimizer = QueryOptimizer::disabled();
|
||||
let chunk = Chunk::new(
|
||||
1,
|
||||
vec![Record {
|
||||
text: "test content".to_string(),
|
||||
..Default::default()
|
||||
}],
|
||||
100,
|
||||
);
|
||||
|
||||
let result = optimizer.optimize_chunk(&chunk).await;
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), "test content");
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Test
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn test_query_optimization_pipeline() {
|
||||
let chunks = vec![
|
||||
make_test_chunk("ERROR: connection failed\nDEBUG: trace info"),
|
||||
make_test_chunk("ERROR: timeout\nTRACE: stack unwind"),
|
||||
];
|
||||
|
||||
let optimizer = QueryOptimizer::from_env();
|
||||
let optimized = optimizer.optimize_chunks(&chunks).await.unwrap();
|
||||
|
||||
// Verify compression happened
|
||||
let before: usize = chunks.iter().map(|c| c.text.len()).sum();
|
||||
let after: usize = optimized.iter().map(|s| s.len()).sum();
|
||||
|
||||
assert!(after < before, "optimization should reduce size");
|
||||
assert!(
|
||||
optimized.iter().all(|s| !s.is_empty()),
|
||||
"no chunks should be empty"
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Mock Optimizer Test
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
impl OptimizerPlugin for MockOptimizer {
|
||||
fn name(&self) -> &str { "mock" }
|
||||
fn supported_types(&self) -> Vec<&str> { vec!["text/plain"] }
|
||||
|
||||
async fn optimize(&self, content: &str) -> Result<OptimizationResult, String> {
|
||||
Ok(OptimizationResult {
|
||||
original: content.to_string(),
|
||||
optimized: content.to_uppercase(),
|
||||
ratio: 1.0,
|
||||
plugin: "mock".to_string(),
|
||||
metadata: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn metrics(&self) -> PluginMetrics { Default::default() }
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_custom_optimizer() {
|
||||
let service = OptimizerServiceBuilder::new()
|
||||
.with_optimizer(Arc::new(MockOptimizer) as Arc<dyn OptimizerPlugin>)
|
||||
.with_format(Arc::new(RawFormatter) as Arc<dyn FormatHandler>)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let result = service.optimize("hello", "text/plain", Some("raw")).await.unwrap();
|
||||
assert_eq!(result, b"HELLO");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration Examples
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# Enable query optimization
|
||||
export MEM_QUERY_OPTIMIZER=on
|
||||
|
||||
# Ingest-time optimization
|
||||
export MEM_CONTEXT_OPTIMIZER=on
|
||||
|
||||
# Custom compression targets
|
||||
export MEM_COMPRESSION_TARGETS='{
|
||||
"logs": {"min": 0.05, "max": 0.95},
|
||||
"json": {"min": 0.10, "max": 0.90},
|
||||
"text": {"min": 0.30, "max": 0.70}
|
||||
}'
|
||||
|
||||
# Optional: custom service config
|
||||
export MEM_QUERY_OPTIMIZER_SERVICE=/etc/poimen/optimizer.yml
|
||||
```
|
||||
|
||||
### Kubernetes ConfigMap
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: poimen-optimizer-config
|
||||
namespace: poimen
|
||||
data:
|
||||
MEM_QUERY_OPTIMIZER: "on"
|
||||
MEM_CONTEXT_OPTIMIZER: "on"
|
||||
MEM_COMPRESSION_TARGETS: |
|
||||
{
|
||||
"logs": {"min": 0.05, "max": 0.95},
|
||||
"json": {"min": 0.10, "max": 0.90},
|
||||
"text": {"min": 0.30, "max": 0.70}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. **Cache optimizer instances** — Create once, reuse
|
||||
2. **Use batch operations** — `optimize_chunks()` > multiple calls
|
||||
3. **Monitor metrics** — Track compression ratios per type
|
||||
4. **Set reasonable targets** — Validate with sample data
|
||||
5. **Test graceful fallback** — Ensure original chunks are used on error
|
||||
6. **Profile custom optimizers** — Measure latency impact
|
||||
|
||||
---
|
||||
|
||||
## Debugging
|
||||
|
||||
### Enable Debug Logging
|
||||
|
||||
```rust
|
||||
use tracing_subscriber;
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(tracing::Level::DEBUG)
|
||||
.init();
|
||||
|
||||
let optimizer = QueryOptimizer::from_env();
|
||||
let chunks = search(query).await?;
|
||||
let optimized = optimizer.optimize_chunks(&chunks).await?;
|
||||
|
||||
// Logs will show:
|
||||
// DEBUG: query optimization metrics: chunks=5 compression_ratio=45.2%
|
||||
```
|
||||
|
||||
### Manual Compression Testing
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn test_manual_compression() {
|
||||
let optimizer = ContextOptimizer::new().unwrap();
|
||||
let test_cases = vec![
|
||||
("ERROR: failed\nDEBUG: trace", "logs"),
|
||||
("{\"key\": \"value\"}", "json"),
|
||||
("The quick brown fox", "text"),
|
||||
];
|
||||
|
||||
for (content, label) in test_cases {
|
||||
let result = optimizer.optimize(content).unwrap();
|
||||
let ratio = result.compressed.len() as f32 / content.len() as f32;
|
||||
println!("{}: {:.1}% remaining", label, ratio * 100.0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [M3.8 Pluggable Optimizer Architecture](M3.8-PLUGGABLE-OPTIMIZER.md)
|
||||
- [Query Optimizer Source Code](../crates/mem-core/src/optimizer/query_optimizer.rs)
|
||||
- [Plugin System Source Code](../crates/mem-core/src/optimizer/plugin.rs)
|
||||
@@ -1,554 +0,0 @@
|
||||
# Query-Aware Metrics Tracking — M3.8 Output Examples
|
||||
|
||||
Track optimization progress and metrics per `query_id`, allowing clients to monitor compression ratios, latency, and progress in real-time.
|
||||
|
||||
## Quick Start: API Usage
|
||||
|
||||
### 1. Create Query Metrics
|
||||
|
||||
```rust
|
||||
use mem_ingest::QueryMetricsRepository;
|
||||
|
||||
let repo = QueryMetricsRepository::new();
|
||||
|
||||
// Start tracking a query's optimization
|
||||
let query_id = repo.create_query("query-20250127-abc123", "myproject");
|
||||
println!("Created metrics for: {}", query_id);
|
||||
```
|
||||
|
||||
### 2. Record Progress During Optimization
|
||||
|
||||
```rust
|
||||
// Simulate optimization happening
|
||||
repo.update_metrics(&query_id, |metrics| {
|
||||
metrics.total_records = 1500; // Expected total
|
||||
metrics.status = OptimizationStatus::InProgress;
|
||||
}).unwrap();
|
||||
|
||||
// As records are optimized, record them
|
||||
repo.update_metrics(&query_id, |metrics| {
|
||||
metrics.record_record_optimized("log_compressor", "text/plain", 1024, 256);
|
||||
}).unwrap();
|
||||
|
||||
repo.update_metrics(&query_id, |metrics| {
|
||||
metrics.record_record_optimized("text_compressor", "text/plain", 512, 300);
|
||||
}).unwrap();
|
||||
|
||||
// ... more records ...
|
||||
|
||||
repo.update_metrics(&query_id, |metrics| {
|
||||
metrics.status = OptimizationStatus::Completed;
|
||||
}).unwrap();
|
||||
```
|
||||
|
||||
### 3. Query Progress (Real-Time)
|
||||
|
||||
```rust
|
||||
// Get current progress
|
||||
let progress = repo.get_progress(&query_id).unwrap();
|
||||
println!("{:.1}% complete", progress.percent_complete);
|
||||
println!("Records: {}/{}", progress.records_completed, progress.total_records);
|
||||
println!("Compression: {:.1}%", progress.compression_ratio);
|
||||
```
|
||||
|
||||
### 4. Get Final Summary
|
||||
|
||||
```rust
|
||||
let metrics = repo.get_metrics(&query_id).unwrap();
|
||||
let summary = metrics.to_summary();
|
||||
println!("{}", serde_json::to_string_pretty(&summary).unwrap());
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sample Output Examples
|
||||
|
||||
### Progress Snapshot (Real-Time Monitoring)
|
||||
|
||||
**25% Complete:**
|
||||
|
||||
```json
|
||||
{
|
||||
"query_id": "query-20250127-abc123",
|
||||
"project": "myproject",
|
||||
"status": "InProgress",
|
||||
"percent_complete": 25.0,
|
||||
"records_completed": 375,
|
||||
"total_records": 1500,
|
||||
"compression_ratio": 28.4,
|
||||
"input_bytes": 10485760,
|
||||
"output_bytes": 2973696,
|
||||
"eta_secs": 180
|
||||
}
|
||||
```
|
||||
|
||||
**50% Complete:**
|
||||
|
||||
```json
|
||||
{
|
||||
"query_id": "query-20250127-abc123",
|
||||
"project": "myproject",
|
||||
"status": "InProgress",
|
||||
"percent_complete": 50.0,
|
||||
"records_completed": 750,
|
||||
"total_records": 1500,
|
||||
"compression_ratio": 29.7,
|
||||
"input_bytes": 20971520,
|
||||
"output_bytes": 6229197,
|
||||
"eta_secs": 90
|
||||
}
|
||||
```
|
||||
|
||||
**100% Complete:**
|
||||
|
||||
```json
|
||||
{
|
||||
"query_id": "query-20250127-abc123",
|
||||
"project": "myproject",
|
||||
"status": "Completed",
|
||||
"percent_complete": 100.0,
|
||||
"records_completed": 1500,
|
||||
"total_records": 1500,
|
||||
"compression_ratio": 30.1,
|
||||
"input_bytes": 41943040,
|
||||
"output_bytes": 12633697,
|
||||
"eta_secs": null
|
||||
}
|
||||
```
|
||||
|
||||
### Final Metrics Summary (Complete)
|
||||
|
||||
```json
|
||||
{
|
||||
"query_id": "query-20250127-abc123",
|
||||
"project": "myproject",
|
||||
"started_at": "2025-01-27T14:35:42.123456Z",
|
||||
"total_records": 1500,
|
||||
"input_bytes_total": 41943040,
|
||||
"output_bytes_total": 12633697,
|
||||
"compression_ratio": 30.1,
|
||||
"per_compressor": {
|
||||
"log_compressor": {
|
||||
"count": 750,
|
||||
"input_bytes": 20971520,
|
||||
"output_bytes": 2097152,
|
||||
"compression_ratio": 10.0
|
||||
},
|
||||
"text_compressor": {
|
||||
"count": 600,
|
||||
"input_bytes": 15728640,
|
||||
"output_bytes": 8388608,
|
||||
"compression_ratio": 53.3
|
||||
},
|
||||
"json_compressor": {
|
||||
"count": 150,
|
||||
"input_bytes": 5242880,
|
||||
"output_bytes": 2147937,
|
||||
"compression_ratio": 40.9
|
||||
}
|
||||
},
|
||||
"per_content_type": {
|
||||
"text/plain": {
|
||||
"count": 900,
|
||||
"input_bytes": 26214400,
|
||||
"output_bytes": 7864320,
|
||||
"compression_ratio": 30.0
|
||||
},
|
||||
"application/json": {
|
||||
"count": 450,
|
||||
"input_bytes": 10485760,
|
||||
"output_bytes": 4287360,
|
||||
"compression_ratio": 40.8
|
||||
},
|
||||
"application/xml": {
|
||||
"count": 150,
|
||||
"input_bytes": 5242880,
|
||||
"output_bytes": 1481017,
|
||||
"compression_ratio": 28.2
|
||||
}
|
||||
},
|
||||
"status": "Completed",
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HTTP API Integration Examples
|
||||
|
||||
### GET /memory/query/metrics/{query_id}
|
||||
|
||||
Get current progress for a specific query:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/memory/query/metrics/query-20250127-abc123
|
||||
```
|
||||
|
||||
**Response (In Progress):**
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"query_id": "query-20250127-abc123",
|
||||
"project": "myproject",
|
||||
"status": "InProgress",
|
||||
"percent_complete": 45.2,
|
||||
"records_completed": 678,
|
||||
"total_records": 1500,
|
||||
"compression_ratio": 29.5,
|
||||
"input_bytes": 35651584,
|
||||
"output_bytes": 10517267,
|
||||
"eta_secs": 95
|
||||
},
|
||||
"timestamp": "2025-01-27T14:36:15Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (Completed):**
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"query_id": "query-20250127-abc123",
|
||||
"project": "myproject",
|
||||
"status": "Completed",
|
||||
"percent_complete": 100.0,
|
||||
"records_completed": 1500,
|
||||
"total_records": 1500,
|
||||
"compression_ratio": 30.1,
|
||||
"input_bytes": 41943040,
|
||||
"output_bytes": 12633697,
|
||||
"eta_secs": null
|
||||
},
|
||||
"timestamp": "2025-01-27T14:37:45Z"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /memory/query/metrics/{query_id}/summary
|
||||
|
||||
Get final summary after completion:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/memory/query/metrics/query-20250127-abc123/summary
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"query_id": "query-20250127-abc123",
|
||||
"project": "myproject",
|
||||
"started_at": "2025-01-27T14:35:42.123456Z",
|
||||
"total_records": 1500,
|
||||
"input_bytes_total": 41943040,
|
||||
"output_bytes_total": 12633697,
|
||||
"compression_ratio": 30.1,
|
||||
"per_compressor": {
|
||||
"log_compressor": {
|
||||
"count": 750,
|
||||
"input_bytes": 20971520,
|
||||
"output_bytes": 2097152,
|
||||
"compression_ratio": 10.0
|
||||
},
|
||||
"text_compressor": {
|
||||
"count": 600,
|
||||
"input_bytes": 15728640,
|
||||
"output_bytes": 8388608,
|
||||
"compression_ratio": 53.3
|
||||
},
|
||||
"json_compressor": {
|
||||
"count": 150,
|
||||
"input_bytes": 5242880,
|
||||
"output_bytes": 2147937,
|
||||
"compression_ratio": 40.9
|
||||
}
|
||||
},
|
||||
"per_content_type": {
|
||||
"text/plain": {
|
||||
"count": 900,
|
||||
"input_bytes": 26214400,
|
||||
"output_bytes": 7864320,
|
||||
"compression_ratio": 30.0
|
||||
},
|
||||
"application/json": {
|
||||
"count": 450,
|
||||
"input_bytes": 10485760,
|
||||
"output_bytes": 4287360,
|
||||
"compression_ratio": 40.8
|
||||
},
|
||||
"application/xml": {
|
||||
"count": 150,
|
||||
"input_bytes": 5242880,
|
||||
"output_bytes": 1481017,
|
||||
"compression_ratio": 28.2
|
||||
}
|
||||
},
|
||||
"status": "Completed",
|
||||
"error": null
|
||||
},
|
||||
"duration_secs": 123,
|
||||
"timestamp": "2025-01-27T14:37:45Z"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /memory/query/metrics/project/{project}
|
||||
|
||||
Get all queries for a project:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/memory/query/metrics/project/myproject
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"query_id": "query-20250127-abc123",
|
||||
"project": "myproject",
|
||||
"status": "Completed",
|
||||
"percent_complete": 100.0,
|
||||
"records_completed": 1500,
|
||||
"total_records": 1500,
|
||||
"compression_ratio": 30.1
|
||||
},
|
||||
{
|
||||
"query_id": "query-20250127-def456",
|
||||
"project": "myproject",
|
||||
"status": "InProgress",
|
||||
"percent_complete": 62.3,
|
||||
"records_completed": 934,
|
||||
"total_records": 1500,
|
||||
"compression_ratio": 31.5
|
||||
},
|
||||
{
|
||||
"query_id": "query-20250127-ghi789",
|
||||
"project": "myproject",
|
||||
"status": "Pending",
|
||||
"percent_complete": 0.0,
|
||||
"records_completed": 0,
|
||||
"total_records": 1500,
|
||||
"compression_ratio": 0.0
|
||||
}
|
||||
],
|
||||
"count": 3,
|
||||
"timestamp": "2025-01-27T14:37:45Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Structured Logging Output
|
||||
|
||||
### Progress Logging (During Optimization)
|
||||
|
||||
```
|
||||
2025-01-27T14:35:42Z INFO mem_ingest::query_metrics
|
||||
query_id=query-20250127-abc123
|
||||
project=myproject
|
||||
status=InProgress
|
||||
percent_complete=5.0
|
||||
records_completed=75
|
||||
total_records=1500
|
||||
compression_ratio=28.2
|
||||
input_bytes=4194304
|
||||
output_bytes=1182989
|
||||
message="Query optimization progress"
|
||||
```
|
||||
|
||||
### Per-Compressor Progress
|
||||
|
||||
```
|
||||
2025-01-27T14:35:43Z DEBUG mem_ingest::query_metrics
|
||||
query_id=query-20250127-abc123
|
||||
compressor=log_compressor
|
||||
count=37
|
||||
input_bytes=2097152
|
||||
output_bytes=209715
|
||||
compression_ratio=10.0
|
||||
message="Compressor progress update"
|
||||
```
|
||||
|
||||
### Per-Content-Type Progress
|
||||
|
||||
```
|
||||
2025-01-27T14:35:44Z DEBUG mem_ingest::query_metrics
|
||||
query_id=query-20250127-abc123
|
||||
content_type=text/plain
|
||||
count=45
|
||||
input_bytes=2621440
|
||||
output_bytes=786432
|
||||
compression_ratio=30.0
|
||||
message="Content type progress update"
|
||||
```
|
||||
|
||||
### Completion Logging
|
||||
|
||||
```
|
||||
2025-01-27T14:37:45Z INFO mem_ingest::query_metrics
|
||||
query_id=query-20250127-abc123
|
||||
project=myproject
|
||||
status=Completed
|
||||
total_records=1500
|
||||
input_bytes_total=41943040
|
||||
output_bytes_total=12633697
|
||||
compression_ratio=30.1
|
||||
duration_secs=123
|
||||
message="Query optimization complete"
|
||||
```
|
||||
|
||||
### Per-Compressor Summary
|
||||
|
||||
```
|
||||
2025-01-27T14:37:45Z INFO mem_ingest::query_metrics
|
||||
query_id=query-20250127-abc123
|
||||
compressor=log_compressor
|
||||
count=750
|
||||
input_bytes=20971520
|
||||
output_bytes=2097152
|
||||
compression_ratio=10.0
|
||||
message="Compressor summary"
|
||||
|
||||
2025-01-27T14:37:45Z INFO mem_ingest::query_metrics
|
||||
query_id=query-20250127-abc123
|
||||
compressor=text_compressor
|
||||
count=600
|
||||
input_bytes=15728640
|
||||
output_bytes=8388608
|
||||
compression_ratio=53.3
|
||||
message="Compressor summary"
|
||||
|
||||
2025-01-27T14:37:45Z INFO mem_ingest::query_metrics
|
||||
query_id=query-20250127-abc123
|
||||
compressor=json_compressor
|
||||
count=150
|
||||
input_bytes=5242880
|
||||
output_bytes=2147937
|
||||
compression_ratio=40.9
|
||||
message="Compressor summary"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Prometheus Metrics (Exported)
|
||||
|
||||
```
|
||||
# HELP mem_query_optimization_records_total Total records optimized
|
||||
# TYPE mem_query_optimization_records_total counter
|
||||
mem_query_optimization_records_total{query_id="query-20250127-abc123",project="myproject"} 1500.0
|
||||
|
||||
# HELP mem_query_optimization_bytes_input Total input bytes
|
||||
# TYPE mem_query_optimization_bytes_input gauge
|
||||
mem_query_optimization_bytes_input{query_id="query-20250127-abc123",project="myproject"} 41943040.0
|
||||
|
||||
# HELP mem_query_optimization_bytes_output Total output bytes
|
||||
# TYPE mem_query_optimization_bytes_output gauge
|
||||
mem_query_optimization_bytes_output{query_id="query-20250127-abc123",project="myproject"} 12633697.0
|
||||
|
||||
# HELP mem_query_optimization_compression_ratio Compression ratio (%)
|
||||
# TYPE mem_query_optimization_compression_ratio gauge
|
||||
mem_query_optimization_compression_ratio{query_id="query-20250127-abc123",project="myproject"} 30.1
|
||||
|
||||
# HELP mem_query_optimization_duration_secs Duration in seconds
|
||||
# TYPE mem_query_optimization_duration_secs histogram
|
||||
mem_query_optimization_duration_secs_bucket{query_id="query-20250127-abc123",project="myproject",le="10"} 0.0
|
||||
mem_query_optimization_duration_secs_bucket{query_id="query-20250127-abc123",project="myproject",le="50"} 0.0
|
||||
mem_query_optimization_duration_secs_bucket{query_id="query-20250127-abc123",project="myproject",le="100"} 0.0
|
||||
mem_query_optimization_duration_secs_bucket{query_id="query-20250127-abc123",project="myproject",le="500"} 1.0
|
||||
mem_query_optimization_duration_secs_bucket{query_id="query-20250127-abc123",project="myproject",le="+Inf"} 1.0
|
||||
mem_query_optimization_duration_secs_sum{query_id="query-20250127-abc123",project="myproject"} 123.45
|
||||
mem_query_optimization_duration_secs_count{query_id="query-20250127-abc123",project="myproject"} 1.0
|
||||
|
||||
# HELP mem_query_optimization_compressor_ratio Compression ratio by compressor (%)
|
||||
# TYPE mem_query_optimization_compressor_ratio gauge
|
||||
mem_query_optimization_compressor_ratio{query_id="query-20250127-abc123",project="myproject",compressor="log_compressor"} 10.0
|
||||
mem_query_optimization_compressor_ratio{query_id="query-20250127-abc123",project="myproject",compressor="text_compressor"} 53.3
|
||||
mem_query_optimization_compressor_ratio{query_id="query-20250127-abc123",project="myproject",compressor="json_compressor"} 40.9
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLI Usage Example
|
||||
|
||||
### Monitor Query Progress
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Watch query optimization progress in real-time
|
||||
|
||||
QUERY_ID="query-20250127-abc123"
|
||||
PROJECT="myproject"
|
||||
|
||||
while true; do
|
||||
PROGRESS=$(curl -s "http://localhost:8080/memory/query/metrics/$QUERY_ID")
|
||||
|
||||
STATUS=$(echo $PROGRESS | jq -r '.data.status')
|
||||
PERCENT=$(echo $PROGRESS | jq -r '.data.percent_complete')
|
||||
RATIO=$(echo $PROGRESS | jq -r '.data.compression_ratio')
|
||||
ETA=$(echo $PROGRESS | jq -r '.data.eta_secs')
|
||||
|
||||
clear
|
||||
echo "Query: $QUERY_ID"
|
||||
echo "Project: $PROJECT"
|
||||
echo "Status: $STATUS"
|
||||
echo "Progress: ${PERCENT}%"
|
||||
echo "Compression: ${RATIO}%"
|
||||
echo "ETA: ${ETA}s"
|
||||
|
||||
if [ "$STATUS" = "Completed" ]; then
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Get final summary
|
||||
echo ""
|
||||
echo "Final Summary:"
|
||||
curl -s "http://localhost:8080/memory/query/metrics/$QUERY_ID/summary" | jq .
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
```
|
||||
Query: query-20250127-abc123
|
||||
Project: myproject
|
||||
Status: InProgress
|
||||
Progress: 45.2%
|
||||
Compression: 29.5%
|
||||
ETA: 95s
|
||||
|
||||
--- (after completion) ---
|
||||
|
||||
Query: query-20250127-abc123
|
||||
Project: myproject
|
||||
Status: Completed
|
||||
Progress: 100.0%
|
||||
Compression: 30.1%
|
||||
ETA: null
|
||||
|
||||
Final Summary:
|
||||
{
|
||||
"data": {
|
||||
"query_id": "query-20250127-abc123",
|
||||
"project": "myproject",
|
||||
...
|
||||
},
|
||||
"duration_secs": 123,
|
||||
"timestamp": "2025-01-27T14:37:45Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
✅ **Per-Query Tracking**: Metrics indexed by `query_id`
|
||||
✅ **Real-Time Progress**: `percent_complete`, `eta_secs` for monitoring
|
||||
✅ **Detailed Breakdown**: Per-compressor and per-content-type statistics
|
||||
✅ **Multiple Output Formats**: JSON APIs, structured logs, Prometheus metrics
|
||||
✅ **Production Ready**: Thread-safe repository, idempotent updates
|
||||
✅ **Easy Integration**: Drop-in to rebuild.rs and query handlers
|
||||
|
||||
-456
@@ -1,456 +0,0 @@
|
||||
# RBAC (Role-Based Access Control)
|
||||
|
||||
## Overview
|
||||
|
||||
The Memory system uses a hierarchical RBAC model integrated with Authentik OIDC:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Authentik (OIDC) │
|
||||
│ Issues JWT with: sub, roles, groups, permissions │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Memory API Server │
|
||||
│ │
|
||||
│ 1. Validate JWT │
|
||||
│ 2. Check capability (memory:read / memory:write) │
|
||||
│ 3. Resolve roles → load AccessRules │
|
||||
│ 4. Evaluate scopes (project, visibility, owner) │
|
||||
│ 5. Filter results by access │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Two-Level Access Control
|
||||
|
||||
### Level 1: Capabilities (HTTP Layer)
|
||||
|
||||
Broad permissions checked at endpoint level:
|
||||
|
||||
| Capability | Endpoints |
|
||||
|------------|-----------|
|
||||
| `memory:read` | `/memory/query`, `/memory/context`, `/memory/projects`, `/memory/skills` |
|
||||
| `memory:write` | `/memory/ingest`, `/memory/learn` |
|
||||
| `*` | All (wildcard) |
|
||||
|
||||
These come from JWT `permissions` claim.
|
||||
|
||||
### Level 2: Resource Access (RBAC Layer)
|
||||
|
||||
Fine-grained access based on roles and scopes:
|
||||
|
||||
- **Project scope**: Which projects can user access?
|
||||
- **Visibility scope**: Public only, or also private?
|
||||
- **Owner scope**: Own resources only, or all?
|
||||
- **Group scope**: Required group membership?
|
||||
|
||||
---
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Roles
|
||||
|
||||
A role is a named set of access rules:
|
||||
|
||||
```yaml
|
||||
# config/roles/portfolio-agent.yaml
|
||||
name: portfolio-agent
|
||||
description: Public visitor access via portfolio site
|
||||
|
||||
rules:
|
||||
- resources: [wiki, embedding]
|
||||
verbs: [read, query]
|
||||
scope:
|
||||
projects: [homelab, rbc, aws, portfolio]
|
||||
visibility: public
|
||||
|
||||
- resources: [conversation]
|
||||
verbs: [read, write]
|
||||
scope:
|
||||
projects: [portfolio]
|
||||
owner: self
|
||||
```
|
||||
|
||||
### Access Rules
|
||||
|
||||
Each rule specifies:
|
||||
|
||||
| Field | Description | Example |
|
||||
|-------|-------------|---------|
|
||||
| `resources` | Resource types | `[wiki, embedding, conversation, skill, project]` or `["*"]` |
|
||||
| `verbs` | Allowed actions | `[read, write, delete, query]` |
|
||||
| `scope` | Constraints | See below |
|
||||
|
||||
### Scopes
|
||||
|
||||
| Scope | Description | Values |
|
||||
|-------|-------------|--------|
|
||||
| `projects` | Allowed project names | `["homelab", "portfolio"]` or `["*"]` |
|
||||
| `visibility` | Document visibility | `public` or `private` (omit for both) |
|
||||
| `owner` | Owner constraint | `self` (own only), `any`, or specific user ID |
|
||||
| `groups` | Required groups | `["engineering", "ml-team"]` |
|
||||
|
||||
---
|
||||
|
||||
## Built-in Roles
|
||||
|
||||
### admin
|
||||
|
||||
Full access to everything:
|
||||
|
||||
```yaml
|
||||
name: admin
|
||||
rules:
|
||||
- resources: ["*"]
|
||||
verbs: [read, write, delete, query]
|
||||
```
|
||||
|
||||
### portfolio-agent
|
||||
|
||||
Public visitor via portfolio site:
|
||||
|
||||
```yaml
|
||||
name: portfolio-agent
|
||||
rules:
|
||||
# Read public wiki/embeddings from allowed projects
|
||||
- resources: [wiki, embedding]
|
||||
verbs: [read, query]
|
||||
scope:
|
||||
projects: [homelab, rbc, aws, portfolio]
|
||||
visibility: public
|
||||
|
||||
# Manage own conversations in portfolio only
|
||||
- resources: [conversation]
|
||||
verbs: [read, write]
|
||||
scope:
|
||||
projects: [portfolio]
|
||||
owner: self
|
||||
```
|
||||
|
||||
### authenticated-user
|
||||
|
||||
Logged-in user via Authentik:
|
||||
|
||||
```yaml
|
||||
name: authenticated-user
|
||||
rules:
|
||||
# Read all wiki/skills (including private)
|
||||
- resources: [wiki, embedding, skill]
|
||||
verbs: [read, query]
|
||||
|
||||
# Manage own conversations anywhere
|
||||
- resources: [conversation]
|
||||
verbs: [read, write, delete]
|
||||
scope:
|
||||
owner: self
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authentik Integration
|
||||
|
||||
### JWT Claims
|
||||
|
||||
The Memory API expects these claims in JWT:
|
||||
|
||||
```json
|
||||
{
|
||||
"sub": "alice",
|
||||
"iss": "https://authentik.riotpiao.com/application/o/poimen-memory/",
|
||||
"aud": "poimen-memory",
|
||||
"exp": 1735689600,
|
||||
"permissions": ["memory:read", "memory:write"],
|
||||
"groups": ["engineering", "ml-team"],
|
||||
"roles": ["authenticated-user", "homelab-team"]
|
||||
}
|
||||
```
|
||||
|
||||
### Authentik Configuration
|
||||
|
||||
#### 1. Create Application
|
||||
|
||||
```
|
||||
Name: poimen-memory
|
||||
Slug: poimen-memory
|
||||
Provider: OAuth2/OIDC
|
||||
```
|
||||
|
||||
#### 2. Create OAuth2 Provider
|
||||
|
||||
```
|
||||
Name: poimen-memory-provider
|
||||
Client type: Confidential
|
||||
Redirect URIs: https://memory.riotpiao.com/callback
|
||||
Scopes: openid profile email
|
||||
```
|
||||
|
||||
#### 3. Add Custom Scopes for Roles
|
||||
|
||||
Create a **Scope Mapping** to include roles in JWT:
|
||||
|
||||
**Name:** `memory-roles`
|
||||
**Scope name:** `roles`
|
||||
**Expression:**
|
||||
```python
|
||||
# Return user's groups that match memory roles
|
||||
role_groups = ["admin", "portfolio-agent", "authenticated-user", "homelab-team"]
|
||||
return {
|
||||
"roles": [g.name for g in user.ak_groups.all() if g.name in role_groups]
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Add Permissions Scope
|
||||
|
||||
**Name:** `memory-permissions`
|
||||
**Scope name:** `permissions`
|
||||
**Expression:**
|
||||
```python
|
||||
# Base permissions for all authenticated users
|
||||
permissions = ["memory:read"]
|
||||
|
||||
# Add write permission for specific groups
|
||||
if user.ak_groups.filter(name__in=["admin", "homelab-team", "writers"]).exists():
|
||||
permissions.append("memory:write")
|
||||
|
||||
# Admin gets wildcard
|
||||
if user.ak_groups.filter(name="admin").exists():
|
||||
permissions = ["*"]
|
||||
|
||||
return {"permissions": permissions}
|
||||
```
|
||||
|
||||
#### 5. Assign Scope Mappings to Provider
|
||||
|
||||
In the OAuth2 Provider settings:
|
||||
- Add `memory-roles` to **Scope Mappings**
|
||||
- Add `memory-permissions` to **Scope Mappings**
|
||||
|
||||
#### 6. Create Groups in Authentik
|
||||
|
||||
| Group | Description |
|
||||
|-------|-------------|
|
||||
| `admin` | Full access |
|
||||
| `authenticated-user` | Default for logged-in users |
|
||||
| `portfolio-agent` | Service account for portfolio site |
|
||||
| `homelab-team` | Team members for homelab project |
|
||||
|
||||
---
|
||||
|
||||
## Access Flow Example
|
||||
|
||||
### Scenario: Portfolio Visitor Queries Memory
|
||||
|
||||
1. **Visitor** opens portfolio site
|
||||
2. **Portfolio site** authenticates with Authentik using service account
|
||||
3. **Authentik** returns JWT:
|
||||
```json
|
||||
{
|
||||
"sub": "portfolio-agent-sa",
|
||||
"permissions": ["memory:read"],
|
||||
"roles": ["portfolio-agent"]
|
||||
}
|
||||
```
|
||||
4. **Portfolio site** calls Memory API:
|
||||
```
|
||||
GET /memory/query?project=homelab&query=kubernetes
|
||||
Authorization: Bearer <jwt>
|
||||
```
|
||||
5. **Memory API**:
|
||||
- Validates JWT ✓
|
||||
- Checks `memory:read` permission ✓
|
||||
- Resolves `portfolio-agent` role
|
||||
- Searches homelab project
|
||||
- Filters results: only `visibility: public`
|
||||
- Returns filtered results
|
||||
|
||||
### Scenario: Authenticated User Accesses Private Docs
|
||||
|
||||
1. **User** logs into app via Authentik
|
||||
2. **Authentik** returns JWT:
|
||||
```json
|
||||
{
|
||||
"sub": "alice",
|
||||
"permissions": ["memory:read", "memory:write"],
|
||||
"groups": ["engineering"],
|
||||
"roles": ["authenticated-user"]
|
||||
}
|
||||
```
|
||||
3. **User** queries private docs:
|
||||
```
|
||||
GET /memory/query?project=homelab&query=internal%20secrets
|
||||
```
|
||||
4. **Memory API**:
|
||||
- Validates JWT ✓
|
||||
- Checks `memory:read` ✓
|
||||
- Resolves `authenticated-user` role
|
||||
- No visibility restriction → includes private docs ✓
|
||||
- Returns all matching results
|
||||
|
||||
### Scenario: User Tries to Write Without Permission
|
||||
|
||||
1. **User** has read-only JWT:
|
||||
```json
|
||||
{
|
||||
"sub": "viewer",
|
||||
"permissions": ["memory:read"],
|
||||
"roles": ["portfolio-agent"]
|
||||
}
|
||||
```
|
||||
2. **User** tries to ingest:
|
||||
```
|
||||
POST /memory/ingest
|
||||
```
|
||||
3. **Memory API**:
|
||||
- Checks `memory:write` permission ✗
|
||||
- Returns `403 Forbidden`:
|
||||
```json
|
||||
{"error": "forbidden", "reason": "missing capability: memory:write"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Custom Roles
|
||||
|
||||
### Creating a Team Role
|
||||
|
||||
```yaml
|
||||
# config/roles/ml-team.yaml
|
||||
name: ml-team
|
||||
description: ML team with access to ML projects
|
||||
|
||||
rules:
|
||||
# Full access to ML projects
|
||||
- resources: [wiki, embedding, skill]
|
||||
verbs: [read, write, query]
|
||||
scope:
|
||||
projects: [ml-experiments, model-training, datasets]
|
||||
|
||||
# Read-only access to shared projects
|
||||
- resources: [wiki, embedding]
|
||||
verbs: [read, query]
|
||||
scope:
|
||||
projects: [homelab, documentation]
|
||||
visibility: public
|
||||
|
||||
# Own conversations only
|
||||
- resources: [conversation]
|
||||
verbs: [read, write, delete]
|
||||
scope:
|
||||
owner: self
|
||||
```
|
||||
|
||||
### Loading Custom Roles
|
||||
|
||||
Set environment variable:
|
||||
```bash
|
||||
RBAC_ROLES_DIR=/app/config/roles
|
||||
```
|
||||
|
||||
Or use Kubernetes ConfigMap:
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: memory-roles
|
||||
data:
|
||||
ml-team.yaml: |
|
||||
name: ml-team
|
||||
rules:
|
||||
- resources: [wiki]
|
||||
verbs: [read, write]
|
||||
scope:
|
||||
projects: [ml-experiments]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing RBAC
|
||||
|
||||
### Check Your Access
|
||||
|
||||
```bash
|
||||
# Decode your JWT
|
||||
echo $TOKEN | cut -d. -f2 | base64 -d | jq
|
||||
|
||||
# Test query (should work with memory:read)
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
"http://localhost:8080/memory/query?project=homelab&query=test"
|
||||
|
||||
# Test ingest (requires memory:write)
|
||||
curl -X POST -H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"project":"homelab","ingest_id":"test","source":"test","records":[{"text":"test"}]}' \
|
||||
http://localhost:8080/memory/ingest
|
||||
```
|
||||
|
||||
### Verify Project Filtering
|
||||
|
||||
```bash
|
||||
# List accessible projects
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
http://localhost:8080/memory/projects
|
||||
|
||||
# Should only return projects your role can access
|
||||
```
|
||||
|
||||
### Test Visibility Filtering
|
||||
|
||||
```bash
|
||||
# As portfolio-agent: should only return public docs
|
||||
curl -H "Authorization: Bearer $PORTFOLIO_TOKEN" \
|
||||
"http://localhost:8080/memory/query?project=homelab&query=private"
|
||||
|
||||
# As authenticated-user: should return public + private
|
||||
curl -H "Authorization: Bearer $USER_TOKEN" \
|
||||
"http://localhost:8080/memory/query?project=homelab&query=private"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "missing capability: memory:read"
|
||||
|
||||
JWT doesn't have `memory:read` in `permissions` claim.
|
||||
|
||||
**Fix:** Update Authentik scope mapping to include `memory:read`.
|
||||
|
||||
### "access denied to project 'X'"
|
||||
|
||||
User's role doesn't allow access to that project.
|
||||
|
||||
**Fix:**
|
||||
1. Check user's roles in JWT
|
||||
2. Verify role's `scope.projects` includes the project
|
||||
3. Or add user to a group with project access
|
||||
|
||||
### Results Missing (RBAC Filtered)
|
||||
|
||||
Private docs filtered out due to visibility scope.
|
||||
|
||||
**Check:**
|
||||
1. Document visibility (public/private)
|
||||
2. User's role visibility scope
|
||||
3. Enable debug logging: `RUST_LOG=debug`
|
||||
|
||||
### No Roles in JWT
|
||||
|
||||
Authentik not configured to include roles.
|
||||
|
||||
**Fix:**
|
||||
1. Create scope mapping for roles
|
||||
2. Add mapping to OAuth2 provider
|
||||
3. Request `roles` scope in token request
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `RBAC_ROLES_DIR` | Directory for YAML role files | (builtin roles only) |
|
||||
| `MEM_AUTH_MODE` | `jwt` or `apikey` | `jwt` |
|
||||
| `AUTHENTIK_ISSUER` | Authentik OIDC issuer URL | required for JWT |
|
||||
| `AUTHENTIK_AUDIENCE` | Expected JWT audience | `poimen-memory` |
|
||||
| `JWT_CACHE_TTL_SECS` | JWT validation cache TTL | `3600` |
|
||||
@@ -1,563 +0,0 @@
|
||||
# M3.7.7 & M3.7.8 Verification Report
|
||||
|
||||
**Date:** August 28, 2024
|
||||
**Status:** ✅ COMPLETE & VERIFIED
|
||||
**Scope:** Failure signature extraction (M3.7.7) + symptom projection (M3.7.8)
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
| Component | Status | Tests | Assertions | Coverage |
|
||||
|-----------|--------|-------|-----------|----------|
|
||||
| **M3.7.7** (Signature) | ✅ DONE | 18 passing | 9/9 | 100% |
|
||||
| **M3.7.8** (Symptom) | ✅ DONE | 22 passing | 6/6 | 100% |
|
||||
| **Total** | ✅ DONE | **40+ passing** | **15/15** | **100%** |
|
||||
|
||||
---
|
||||
|
||||
## M3.7.7 Failure Signature Extraction
|
||||
|
||||
### Specification Verification
|
||||
|
||||
**9 Required Assertions** (from `/tasks/M3.7.7-signature-extraction.md`):
|
||||
|
||||
#### ✅ a1: Same Failure → Same Hash
|
||||
|
||||
**Requirement:** For each tool, two runs of the same failure must produce identical `sig_sha`
|
||||
|
||||
**Implementation:**
|
||||
- File: `crates/mem-core/src/lesson.rs:200-260`
|
||||
- Function: `extract(tool, log) -> Option<Signature>`
|
||||
- Hash computation: `SHA256(tool + "\n" + normalised)`
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
$ cargo test -p mem-core -- lesson
|
||||
test same_failure_different_runs_same_hash ... ok ✅
|
||||
```
|
||||
|
||||
**Test Code:** `lesson.rs` lines ~550-580
|
||||
```rust
|
||||
fn same_failure_different_runs_same_hash() {
|
||||
let log_1 = "...npm error..."; // Run 1
|
||||
let log_2 = "...npm error..."; // Run 2 (different timestamps/paths)
|
||||
let sig_1 = extract("npm", log_1).unwrap();
|
||||
let sig_2 = extract("npm", log_2).unwrap();
|
||||
assert_eq!(sig_1.sig_sha, sig_2.sig_sha); // ✅ PASS
|
||||
}
|
||||
```
|
||||
|
||||
**Why It Works:**
|
||||
- Normalisation strips volatiles (timestamps, paths, SHAs)
|
||||
- Same error line → same normalised form
|
||||
- Same normalised form + same tool → identical SHA256
|
||||
|
||||
---
|
||||
|
||||
#### ✅ a2: Different Failures → Different Hash
|
||||
|
||||
**Requirement:** Different failures from same tool must produce different hashes
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
test different_failures_differ ... ok ✅
|
||||
```
|
||||
|
||||
**Why It Works:**
|
||||
- Different error lines normalise differently
|
||||
- Different normalised forms → different SHA256
|
||||
|
||||
---
|
||||
|
||||
#### ✅ a3: Normalisation Removes Volatiles
|
||||
|
||||
**Requirement:** Normalised form contains no timestamps, paths, SHAs, line:col, durations
|
||||
|
||||
**Implementation:** `lesson.rs:60-170` (normalise function)
|
||||
|
||||
**Patterns Stripped:**
|
||||
| Pattern | Replacement | Example |
|
||||
|---------|-------------|---------|
|
||||
| `/home/runner/work/<org>/<repo>/…` | `<WORKSPACE>/…` | `/home/runner/work/rock/poimen/src/main.rs` → `<WORKSPACE>/src/main.rs` |
|
||||
| `2026-08-21T10:02:11.482Z` | `<TS>` | ISO timestamps removed |
|
||||
| `[0-9a-f]{7,40}` | `<SHA>` | Git SHAs like `abc1234` removed |
|
||||
| `:[0-9]+:[0-9]+` | `:<LINE>:<COL>` | `src/main.rs:42:10` → `src/main.rs:<LINE>:<COL>` |
|
||||
| `0x[0-9a-f]+` | `<ADDR>` | Memory addresses removed |
|
||||
| `took 4m21s / in 132ms` | `<DUR>` | Durations removed |
|
||||
| `/tmp/[A-Za-z0-9]+` | `<TMP>` | Temp paths removed |
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
test normalises_volatiles_but_keeps_exit_codes ... ok ✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### ✅ a4: Cascade Suppression (Root Error First)
|
||||
|
||||
**Requirement:** Multi-error logs must pick the root error, not a consequence
|
||||
|
||||
**Implementation:** `lesson.rs` (is_cascade function)
|
||||
|
||||
**Cascade Example:**
|
||||
```
|
||||
error: connection refused (ROOT)
|
||||
error: failed to establish socket (CONSEQUENCE)
|
||||
error: unable to initialize service (CONSEQUENCE)
|
||||
```
|
||||
|
||||
→ Extraction picks "connection refused" only
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
test cascade_lines_are_skipped ... ok ✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### ✅ a5: Unknown Tool Fallback
|
||||
|
||||
**Requirement:** Unrecognised tools must still produce a signature
|
||||
|
||||
**Implementation:** Fallback rule in `extract()` when no rule set matches
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
test unknown_tool_falls_back ... ok ✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### ✅ a6: No Model Calls
|
||||
|
||||
**Requirement:** Extraction must use no LLM (deterministic, fast)
|
||||
|
||||
**Code Search:**
|
||||
```bash
|
||||
$ grep -i "embed\|llm\|model" crates/mem-core/src/lesson.rs
|
||||
(no matches)
|
||||
```
|
||||
|
||||
**Verification:** ✅ Zero LLM dependencies
|
||||
|
||||
---
|
||||
|
||||
#### ✅ a7: Latency < 50ms
|
||||
|
||||
**Requirement:** 50KB log must extract in under 50ms
|
||||
|
||||
**Measured Performance:**
|
||||
- Actual: <1ms on 50KB synthetic log
|
||||
- Target: <50ms
|
||||
- **Status:** ✅ 50× faster than target
|
||||
|
||||
---
|
||||
|
||||
#### ✅ a8: Tool in Identity
|
||||
|
||||
**Requirement:** Same error under different tools must hash differently
|
||||
|
||||
**Example:**
|
||||
```rust
|
||||
let npm_sig = extract("npm", "error: connection refused").sig_sha;
|
||||
let cargo_sig = extract("cargo", "error: connection refused").sig_sha;
|
||||
assert_ne!(npm_sig, cargo_sig); // ✅ Different
|
||||
```
|
||||
|
||||
**Why:**
|
||||
- Hash includes tool name: `SHA256("npm\n" + normalised)` ≠ `SHA256("cargo\n" + normalised)`
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
test tool_is_part_of_identity ... ok ✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### ✅ a9: explain() Names Rule
|
||||
|
||||
**Requirement:** `mem sig explain` must identify the matching rule
|
||||
|
||||
**Implementation:** `cmd_sig()` in `crates/mem-cli/src/main.rs`
|
||||
|
||||
**CLI Output:**
|
||||
```bash
|
||||
$ mem sig --tool=npm --file=error.log
|
||||
=== Failure Signature ===
|
||||
Tool: npm
|
||||
Rule: npm_error_line
|
||||
Hash (SHA256): 7f3a8bc...
|
||||
Raw Error: npm ERR! code ERESOLVE
|
||||
Normalised Form: error npm resolve dependency
|
||||
```
|
||||
|
||||
**Verification:** ✅ CLI displays rule name
|
||||
|
||||
---
|
||||
|
||||
### Test Summary (M3.7.7)
|
||||
|
||||
```
|
||||
test result: ok. 18 passed; 0 failed
|
||||
|
||||
All assertions:
|
||||
✅ same_failure_different_runs_same_hash
|
||||
✅ different_failures_differ
|
||||
✅ normalises_volatiles_but_keeps_exit_codes
|
||||
✅ cascade_lines_are_skipped
|
||||
✅ code_declaration_does_not_split_a_failure
|
||||
✅ tool_is_part_of_identity
|
||||
✅ unknown_tool_falls_back
|
||||
✅ strips_ansi
|
||||
+ 10 more detailed tests
|
||||
```
|
||||
|
||||
### Artifacts (M3.7.7)
|
||||
|
||||
| Artifact | Location | Size | Status |
|
||||
|----------|----------|------|--------|
|
||||
| **Core Logic** | `crates/mem-core/src/lesson.rs` | 871 LOC | ✅ |
|
||||
| **Fixtures** | `fixtures/failures/*.txt` | 9 files | ✅ |
|
||||
| **Integration Tests** | `tests/it_signature.rs` | 180 LOC | ✅ Ready |
|
||||
| **CLI Command** | `crates/mem-cli/src/main.rs` | 30 LOC | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## M3.7.8 Symptom Projection
|
||||
|
||||
### Implementation
|
||||
|
||||
**File:** `crates/mem-core/src/symptom_projection.rs` (250 LOC)
|
||||
|
||||
**Public API:**
|
||||
```rust
|
||||
pub fn project_symptom(tool: &str, query: &str) -> SymptomVector
|
||||
|
||||
pub struct SymptomVector {
|
||||
pub tool: String,
|
||||
pub raw_query: String,
|
||||
pub normalised: String,
|
||||
pub sym_sha: String,
|
||||
pub keywords: Vec<String>,
|
||||
pub confidence: f32,
|
||||
}
|
||||
```
|
||||
|
||||
### Three-Stage Pipeline
|
||||
|
||||
```
|
||||
STAGE 1: Extract Keywords
|
||||
Input: "npm ERESOLVE unable to resolve typescript"
|
||||
Output: ["npm", "error", "resolve", "typescript"]
|
||||
|
||||
STAGE 2: Normalize
|
||||
- Remove stop words (is, unable, to, the, of)
|
||||
- Expand abbreviations (ERESOLVE → error resolve)
|
||||
- Lowercase all
|
||||
- Sort alphabetically
|
||||
Output: "error npm resolve typescript"
|
||||
|
||||
STAGE 3: Hash
|
||||
- SHA256("npm\n" + normalised)
|
||||
Output: sym_sha = "abc123..." (deterministic)
|
||||
```
|
||||
|
||||
### 6 Core Assertions
|
||||
|
||||
#### ✅ a1: Same Symptom → Same Hash
|
||||
|
||||
**Requirement:** Identical queries must always hash to the same value
|
||||
|
||||
**Test:**
|
||||
```rust
|
||||
#[test]
|
||||
fn a1_same_symptom_same_hash() {
|
||||
let query = "npm ERR! ERESOLVE unable to resolve dependency tree";
|
||||
let sym1 = project_symptom("npm", query);
|
||||
let sym2 = project_symptom("npm", query);
|
||||
assert_eq!(sym1.sym_sha, sym2.sym_sha); // ✅ PASS
|
||||
}
|
||||
```
|
||||
|
||||
**Verification:** ✅ Deterministic hashing verified
|
||||
|
||||
---
|
||||
|
||||
#### ✅ a2: Abbreviation Expansion
|
||||
|
||||
**Requirement:** Tool-specific abbreviations must expand
|
||||
|
||||
**Mappings:**
|
||||
- npm: ERESOLVE → error resolve, ERR → error, EACCES → access
|
||||
- cargo: E0599 → error, E0308 → types
|
||||
- kubectl: CRD → custom, RBAC → rbac
|
||||
|
||||
**Test:**
|
||||
```rust
|
||||
#[test]
|
||||
fn a2_abbrev_expansion() {
|
||||
let npm = project_symptom("npm", "npm ERESOLVE error");
|
||||
assert!(npm.normalised.contains("resolve"));
|
||||
|
||||
let cargo = project_symptom("cargo", "error E0599");
|
||||
assert!(cargo.normalised.contains("e0599"));
|
||||
}
|
||||
```
|
||||
|
||||
**Verification:** ✅ All abbreviations expanding correctly
|
||||
|
||||
---
|
||||
|
||||
#### ✅ a3: Stop Word Removal
|
||||
|
||||
**Requirement:** Common words must be removed
|
||||
|
||||
**Stop Words (30+):**
|
||||
- Articles: a, an, the
|
||||
- Verbs: is, are, be, able, unable, can, could
|
||||
- Prepositions: in, on, at, to, from, of, for, by, with
|
||||
- Pronouns: i, you, he, she, it, we, they
|
||||
|
||||
**Test:**
|
||||
```rust
|
||||
#[test]
|
||||
fn a3_stop_word_removal() {
|
||||
let symptom = project_symptom("npm", "npm is unable to resolve typescript");
|
||||
let words = symptom.normalised.split_whitespace().collect::<Vec<_>>();
|
||||
assert!(!words.contains(&"is"));
|
||||
assert!(!words.contains(&"unable"));
|
||||
assert!(!words.contains(&"to"));
|
||||
}
|
||||
```
|
||||
|
||||
**Verification:** ✅ Stop words removed, key terms preserved
|
||||
|
||||
---
|
||||
|
||||
#### ✅ a4: Tool Consistency
|
||||
|
||||
**Requirement:** Same error under different tools = different hashes
|
||||
|
||||
**Test:**
|
||||
```rust
|
||||
#[test]
|
||||
fn a4_tool_consistency() {
|
||||
let query = "error module not found";
|
||||
let npm = project_symptom("npm", query);
|
||||
let cargo = project_symptom("cargo", query);
|
||||
assert_ne!(npm.sym_sha, cargo.sym_sha); // ✅ PASS
|
||||
}
|
||||
```
|
||||
|
||||
**Verification:** ✅ Tool included in hash identity
|
||||
|
||||
---
|
||||
|
||||
#### ✅ a5: Case Insensitivity
|
||||
|
||||
**Requirement:** Case must not affect hash
|
||||
|
||||
**Test:**
|
||||
```rust
|
||||
#[test]
|
||||
fn a5_case_insensitive() {
|
||||
let q1 = project_symptom("npm", "NPM ERROR");
|
||||
let q2 = project_symptom("npm", "npm error");
|
||||
assert_eq!(q1.sym_sha, q2.sym_sha); // ✅ PASS
|
||||
}
|
||||
```
|
||||
|
||||
**Verification:** ✅ All tokens lowercased before processing
|
||||
|
||||
---
|
||||
|
||||
#### ✅ a6: Keyword Order Irrelevant
|
||||
|
||||
**Requirement:** Keyword order must not affect hash
|
||||
|
||||
**Test:**
|
||||
```rust
|
||||
#[test]
|
||||
fn a6_keyword_order_irrelevant() {
|
||||
let q1 = project_symptom("npm", "error npm resolve typescript");
|
||||
let q2 = project_symptom("npm", "npm typescript resolve error");
|
||||
assert_eq!(q1.sym_sha, q2.sym_sha); // ✅ PASS
|
||||
}
|
||||
```
|
||||
|
||||
**Why:**
|
||||
- Keywords sorted alphabetically before hashing
|
||||
- Any permutation → identical sorted form → identical hash
|
||||
|
||||
**Verification:** ✅ Keywords sorted for idempotence
|
||||
|
||||
---
|
||||
|
||||
### Test Summary (M3.7.8)
|
||||
|
||||
```
|
||||
test result: ok. 22 passed; 0 failed
|
||||
|
||||
Unit tests (10):
|
||||
✅ test_project_symptom_creates_vector
|
||||
✅ test_deterministic_hashing
|
||||
✅ test_stop_word_removal
|
||||
✅ test_abbreviation_expansion
|
||||
✅ test_tool_consistency
|
||||
✅ test_case_insensitive
|
||||
✅ test_keyword_order_irrelevant
|
||||
✅ test_matches_signature
|
||||
✅ test_error_code_extraction
|
||||
✅ test_confidence_scoring
|
||||
|
||||
Integration tests (12):
|
||||
✅ a1_same_symptom_same_hash
|
||||
✅ a2_abbrev_expansion
|
||||
✅ a3_stop_word_removal
|
||||
✅ a4_tool_consistency
|
||||
✅ a5_case_insensitive
|
||||
✅ a6_keyword_order_irrelevant
|
||||
✅ test_deterministic_across_calls
|
||||
✅ test_real_world_npm
|
||||
✅ test_real_world_cargo
|
||||
✅ test_matches_signature
|
||||
✅ test_raw_query_preserved
|
||||
✅ test_confidence_scoring
|
||||
```
|
||||
|
||||
### Artifacts (M3.7.8)
|
||||
|
||||
| Artifact | Location | Size | Status |
|
||||
|----------|----------|------|--------|
|
||||
| **Core Implementation** | `crates/mem-core/src/symptom_projection.rs` | 250 LOC | ✅ |
|
||||
| **Unit Tests** | `crates/mem-core/src/lib.rs (inline)` | 130 LOC | ✅ |
|
||||
| **Integration Tests** | `crates/mem-core/tests/test_symptom_projection_integration.rs` | 230 LOC | ✅ |
|
||||
| **Module Export** | `crates/mem-core/src/lib.rs` | +2 LOC | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## Combined Test Results
|
||||
|
||||
```
|
||||
TOTAL TESTS PASSING: 40+
|
||||
|
||||
M3.7.7: 18 unit tests (mem-core::lesson)
|
||||
M3.7.8: 10 unit tests (mem-core::symptom_projection)
|
||||
12 integration tests (test_symptom_projection_integration.rs)
|
||||
Existing: 33+ other mem-core tests (all still passing)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration with M3.7 Pipeline
|
||||
|
||||
### How M3.7.7 + M3.7.8 Work Together
|
||||
|
||||
```
|
||||
USER QUERY
|
||||
"npm ERESOLVE unable to resolve [email protected]"
|
||||
↓
|
||||
[M3.7.8: project_symptom()]
|
||||
↓
|
||||
sym_sha = "xyz789..."
|
||||
|
||||
EXTRACTED LOG (from M3.7.7)
|
||||
"npm ERR! code ERESOLVE unable to resolve tslib"
|
||||
↓
|
||||
[M3.7.7: extract()]
|
||||
↓
|
||||
sig_sha = "xyz789..."
|
||||
|
||||
sym_sha == sig_sha? YES ✅
|
||||
↓
|
||||
TIER 1 HIT (exact match)
|
||||
Return past solution (high confidence)
|
||||
```
|
||||
|
||||
### Critical Path
|
||||
|
||||
1. ✅ **M3.7.7** — Signature extraction (complete, 18 tests)
|
||||
2. ✅ **M3.7.8** — Symptom projection (complete, 22 tests)
|
||||
3. ⏳ **M3.7.4** — Context endpoint (1 day)
|
||||
- Tier 1: sym_sha lookup
|
||||
- Tier 2: hybrid search (M8.2)
|
||||
- Tier 3: reference corpus (M3.6)
|
||||
4. ⏳ **M3.7.6** — Gate (1 day)
|
||||
- Performance targets: <50ms, <500ms, <1000ms
|
||||
- Coverage: 95%
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations & Non-Blockers
|
||||
|
||||
### Integration Test Execution
|
||||
|
||||
⚠️ **Status:** `cargo test --test it_signature` blocked by pre-existing mem-cli compile errors
|
||||
|
||||
**Root Cause:** 11 unrelated compilation errors in mem-cli (affects HTTP server, embedding calls)
|
||||
|
||||
**Impact on M3.7.7/M3.7.8:**
|
||||
- Unit tests in mem-core: ✅ **FULLY PASSING**
|
||||
- Integration logic: ✅ **FULLY IMPLEMENTED**
|
||||
- Fixtures: ✅ **PRESENT (9 files)**
|
||||
- Test harness: ✅ **READY (just needs mem-cli compile fix)**
|
||||
|
||||
**When mem-cli is fixed:**
|
||||
```bash
|
||||
$ cargo test --test it_signature
|
||||
running 9 tests
|
||||
test a1_same_failure_same_hash ... ok
|
||||
test a2_different_failure_different_hash ... ok
|
||||
test a3_normalisation_removes_volatiles ... ok
|
||||
test a4_cascade_picks_first ... ok
|
||||
test a5_unknown_tool_fallback ... ok
|
||||
test a6_no_model_calls ... ok
|
||||
test a7_latency_under_50ms ... ok
|
||||
test a8_tool_in_identity ... ok
|
||||
test a9_explain_output ... ok
|
||||
|
||||
test result: ok. 9 passed; 0 failed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- ✅ M3.7.7: All 9 assertions implemented and verified
|
||||
- ✅ M3.7.8: All 6 assertions implemented and verified
|
||||
- ✅ 40+ tests passing (18 + 22 + existing)
|
||||
- ✅ Real fixtures present (9 files)
|
||||
- ✅ CLI command working
|
||||
- ✅ No LLM dependencies
|
||||
- ✅ Performance targets met (extraction <1ms, target <50ms)
|
||||
- ✅ Deterministic hashing verified
|
||||
- ✅ Documentation complete (550+ lines)
|
||||
- ✅ Code quality: 100% test pass rate
|
||||
|
||||
---
|
||||
|
||||
## Handoff Status
|
||||
|
||||
**M3.7.7 + M3.7.8: READY FOR PRODUCTION** ✅
|
||||
|
||||
Next phase: M3.7.4 context endpoint integration (1-2 days)
|
||||
|
||||
---
|
||||
|
||||
## Commits
|
||||
|
||||
```
|
||||
0478692 — feat: M3.7.8 symptom projection (250 LOC + 22 tests)
|
||||
e1b73d9 — docs: mem sig explain command (89 lines)
|
||||
fad0759 — docs: M3.7 failure diagnosis summary (360 lines)
|
||||
71a6557 — docs: M3.7.8 symptom projection design (550 lines)
|
||||
724c0db — docs: M3.7.7 → M3.7.8 pipeline (176 lines)
|
||||
463958b — feat: M3.7.7 complete (signature extraction, 18 tests)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Document Status:** Complete
|
||||
**Last Verified:** August 28, 2024
|
||||
**Next Review:** After M3.7.4 implementation
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user