Lukas Goldschmidt 3 годин тому
батько
коміт
1ca1fdbb0e
4 змінених файлів з 364 додано та 334 видалено
  1. 184 0
      API.md
  2. 36 0
      PROJECT.md
  3. 27 260
      README.md
  4. 117 74
      tests.sh

+ 184 - 0
API.md

@@ -0,0 +1,184 @@
+# API Reference — mem0-python-server
+
+This document defines the REST surface for mem0-python-server. All endpoints return JSON and use UTF‑8.
+
+## `GET /health`
+Returns server status, active collection names, and a preview of each extraction prompt.
+
+**Response**
+```json
+{
+  "status": "ok",
+  "reranker_url": "http://192.168.0.200:5200/rerank",
+  "collections": {
+    "conversational": "openclaw_mem",
+    "knowledge": "knowledge_mem"
+  },
+  "prompts": {
+    "conversational": "You are a personal memory assistant…",
+    "knowledge": "You are a knowledge extraction assistant…"
+  }
+}
+```
+
+---
+
+## `/memories` — conversational collection (OpenClaw)
+
+### `POST /memories`
+Add a memory. Accepts plain text or a messages array.
+
+**Request**
+```json
+{ "text": "I prefer Python over JavaScript.", "user_id": "alice" }
+```
+
+```json
+{
+  "messages": [{"role": "user", "content": "I've used Vim for 10 years."}],
+  "user_id": "alice"
+}
+```
+
+**Response**
+Returns the mem0 add response with the created memory payload.
+
+---
+
+### `POST /memories/search`
+Search with reranking. Fetches `limit × 3` candidates, reranks locally, returns top `limit`.
+
+**Request**
+```json
+{ "query": "editor preferences", "user_id": "alice", "limit": 5 }
+```
+
+**Response**
+Returns a list of memories with an added `rerank_score` field.
+
+---
+
+### `POST /memories/recent`
+Return the most recently created memories for a user.
+
+**Request**
+```json
+{ "user_id": "alice", "limit": 5 }
+```
+
+**Response**
+Returns the most recent memory entries.
+
+---
+
+### `DELETE /memories`
+Delete memories by filter.
+
+**Request**
+```json
+{ "filter": { "user_id": "alice" } }
+```
+
+**Response**
+Returns deletion metadata from the underlying mem0 store.
+
+---
+
+## `/knowledge` — knowledge collection (book-ingestor)
+
+Same shape as `/memories` with two additions:
+
+### `metadata`
+Arbitrary key/value dict stored alongside the memory. Useful for provenance tagging:
+
+```json
+{
+  "text": "Silvio Gesell proposed demurrage to discourage currency hoarding.",
+  "user_id": "knowledge_base",
+  "metadata": { "source_file": "gesell_neo.pdf", "chapter": 3, "page": 47 }
+}
+```
+
+### `infer: false`
+Bypasses LLM extraction and stores the text verbatim (best for pre‑summarised chunks).
+
+```json
+{
+  "text": "MIDI SysEx messages use a 7-bit checksum.",
+  "user_id": "knowledge_base",
+  "infer": false,
+  "metadata": { "source_file": "midi_spec.pdf", "chapter": 9, "page": 112 }
+}
+```
+
+### Endpoints
+- `POST /knowledge`
+- `POST /knowledge/search`
+- `POST /knowledge/recent`
+- `DELETE /knowledge`
+
+Each route uses the same request/response shape as its `/memories` counterpart.
+
+---
+
+## `POST /search` — merged search (both collections)
+Queries both collections simultaneously, tags each result with `_source`, then runs a single rerank pass over the merged pool.
+
+**Request**
+```json
+{ "query": "Gesell economic theory", "user_id": "knowledge_base", "limit": 8 }
+```
+
+**Response**
+Each result includes `"_source": "conversational"` or `"_source": "knowledge"`.
+
+---
+
+## Reranker contract
+The server expects a reranker at `RERANKER_URL` accepting:
+
+**Request**
+```json
+{
+  "query": "...",
+  "documents": ["doc1", "doc2"],
+  "top_k": 5
+}
+```
+
+**Response**
+```json
+{
+  "results": [
+    { "text": "doc1", "score": 0.99 },
+    { "text": "doc2", "score": 0.87 }
+  ]
+}
+```
+
+If the reranker is unreachable, the server falls back to raw mem0 results without error.
+
+---
+
+## Resetting a collection
+`reset_memory.py` deletes and recreates a collection via the Chroma HTTP API, then restarts the mem0server container:
+
+```bash
+python reset_memory.py openclaw_mem   # wipe conversational
+python reset_memory.py knowledge_mem  # wipe knowledge
+```
+
+---
+
+## Testing
+`tests.sh` exercises every endpoint. Run it after server changes:
+
+```bash
+bash tests.sh
+```
+
+Suggested checks:
+- `/knowledge/search` query `"Gesell demurrage"` → Gesell result ranked #1, MIDI near the bottom
+- `/knowledge/search` query `"free money currency hoarding"` → Gesell `rerank_score` > 0.9, MIDI < 0.001
+- `/search` results include `_source` on every item
+- `infer: false` stores text verbatim (not rewritten)

+ 36 - 0
PROJECT.md

@@ -0,0 +1,36 @@
+# PROJECT.md — mem0-python-server
+
+## Purpose
+`mem0-python-server` is a lightweight FastAPI service that wraps the mem0 library to provide persistent memory over a REST API. It powers OpenClaw’s memory workflows by offering two distinct memory collections (conversational + knowledge), local reranking, and a stable HTTP contract for storage, recall, and cleanup.
+
+## Scope
+This project focuses on:
+- Running a reliable HTTP API for memory storage and search
+- Keeping conversational vs. knowledge memories separate
+- Providing predictable, structured responses for OpenClaw and ingest pipelines
+- Supporting local reranking and metadata passthrough
+
+## Where the API is documented
+The **full endpoint reference** (requests, responses, reranker contract, testing notes) now lives in `API.md`.
+The README provides a lightweight overview + quick links to both PROJECT.md and API.md so the entry point stays concise.
+
+## Important files
+- `mem0server.py` — entrypoint, exposes `app` for uvicorn
+- `mem0core/` — core FastAPI app, routes, prompts, reranker, storage
+- `README.md` — architecture overview, quickstart notes, and doc links
+- `API.md` — canonical endpoint reference for all routes
+- `reset_memory.py` — resets a Chroma collection and restarts the container
+- `tests.sh` — endpoint smoke tests
+
+## Operating assumptions
+- Chroma runs at `192.168.0.200:8001`
+- Embedder runs via Ollama at `192.168.0.200:11434`
+- Reranker at `192.168.0.200:5200` (optional; server falls back if offline)
+- OpenAI/Groq API key available in environment (see README)
+
+## Conventions
+- Conversational memory routes are mounted at `/memories`.
+- Knowledge memory routes are mounted at `/knowledge`.
+- A merged search is exposed at `/search`.
+- The API is stable and intended to remain backward-compatible for OpenClaw.
+

+ 27 - 260
README.md

@@ -1,274 +1,41 @@
-# mem0server
+# mem0-python-server 🧠
 
-A lightweight FastAPI wrapper around [mem0](https://github.com/mem0ai/mem0) providing persistent memory over a REST API, with dual-collection storage, metadata passthrough, and local reranking.
+A focused FastAPI wrapper around [mem0](https://github.com/mem0ai/mem0) that provides persistent memory over a REST API for OpenClaw and related pipelines.
 
-## Architecture
+## Highlights ✨
+- Two dedicated collections: **conversational** and **knowledge**
+- Local reranking with graceful fallback when reranker is down
+- Clear REST contract for storage, search, and recall
+- Docker-first workflow with hot reload
 
-| Component | Provider | Address |
-|-----------|----------|---------|
-| LLM | Groq (`meta-llama/llama-4-scout-17b-16e-instruct`) | cloud |
-| Vector store | Chroma | `192.168.0.200:8001` |
-| Embedder | Ollama (`nomic-embed-text`) | `192.168.0.200:11434` |
-| Reranker | local REST server | `192.168.0.200:5200` |
+## Quick links 🔗
+- **PROJECT.md** — purpose, scope, and operating assumptions
+- **API.md** — full endpoint reference (requests + responses)
 
-## Collections
+## Architecture (at a glance) 🧩
+- **LLM:** Groq (default: `meta-llama/llama-4-scout-17b-16e-instruct`)
+- **Vector store:** Chroma (`192.168.0.200:8001`)
+- **Embedder:** Ollama (`nomic-embed-text`)
+- **Reranker:** local REST server (`192.168.0.200:5200`)
 
-The server maintains two independent Chroma collections with separate extraction prompts:
+## Collections 📚
+- **Conversational** → Chroma collection: `openclaw_mem` → `/memories`
+- **Knowledge** → Chroma collection: `knowledge_mem` → `/knowledge`
 
-| Collection | Chroma name | Endpoint prefix | Used by | Extraction style |
-|------------|-------------|-----------------|---------|-----------------|
-| Conversational | `openclaw_mem` | `/memories` | OpenClaw agent | User-centric facts (`"User prefers…"`) |
-| Knowledge | `knowledge_mem` | `/knowledge` | book-ingestor | Objective, encyclopedic facts |
-
-Prompts for both collections are defined in `mem0core/prompts.py`, so you can re-tune the extraction/update wording without touching the routing layer.
-
-## Code layout
-
-| Module | Role |
-|--------|------|
-| `mem0server.py` | backward-compatible entrypoint `uvicorn mem0server:app` that delegates to `mem0core.create_app()`. |
-| `mem0core/app.py` | FastAPI factory that wires memory instances and mounts the router built in `mem0core/routes.py`. |
-| `mem0core/prompts.py`, `mem0core/config.py`, `mem0core/memory_factory.py`, `mem0core/storage.py`, `mem0core/reranker.py` | Shared config/prompt helpers, mem0 instance construction, storage patches, and reranking logic—good places to change when you swap DBs or vector stores. |
-| `mem0core/handlers.py`, `mem0core/responses.py` | Shared helper functions (metadata sanitization, add/search/recent shared flows, SafeJSONResponse). |
-| `mem0core/routes.py` | All endpoints plus docstring metadata so `/docs` presents concise summaries.
-
-## Environment variables
-
-| Variable | Required | Default | Description |
-|----------|----------|---------|-------------|
-| `GROQ_API_KEY` | ✅ yes | — | Groq API key |
-| `RERANKER_URL` | no | `http://192.168.0.200:5200/rerank` | Local reranker endpoint |
+## Run it (Docker) 🐳
+```bash
+docker compose up --build
+```
 
+## Config 🔐
 Create a `.env` file (never commit it):
-
 ```env
 GROQ_API_KEY=your_key_here
 RERANKER_URL=http://192.168.0.200:5200/rerank
 ```
 
-## Docker (recommended)
-
-The recommended setup mounts `mem0server.py` and the new `mem0core/` package so uvicorn reloads on local edits without rebuilding the image. Only touch `docker compose build` when `requirements.txt` changes.
-
-### `docker-compose.yml`
-
-```yaml
-services:
-  mem0server:
-    build: .
-    image: mem0server:latest
-    container_name: mem0server
-    ports:
-      - "8420:8420"
-    volumes:
-      - ./mem0server.py:/app/mem0server.py:ro
-      - ./mem0core:/app/mem0core:ro
-      - ./dashboard.html:/app/dashboard.html:ro
-    env_file:
-      - .env
-    restart: unless-stopped
-```
-
-### `Dockerfile`
-
-```dockerfile
-FROM python:3.11-slim
-WORKDIR /app
-COPY requirements.txt .
-RUN pip install --no-cache-dir -r requirements.txt
-# mem0server.py is mounted at runtime via docker-compose volume
-EXPOSE 8420
-CMD ["uvicorn", "mem0server:app", "--host", "0.0.0.0", "--port", "8420", "--reload"]
-```
-
-### Workflow
-
-| Situation | Command |
-|-----------|---------|
-| First time / deps changed | `docker compose up --build` |
-| Code edit | Just save — uvicorn reloads automatically |
-| Restart container | `docker compose restart` |
-| View logs | `docker compose logs -f` |
-
-> ⚠️ Never bake `.env` into the image. Always pass secrets at runtime via `env_file` or `-e`.
-
----
-
-## API
-
-### `GET /health`
-
-Returns server status, active collection names, and a preview of each extraction prompt.
-
-```json
-{
-  "status": "ok",
-  "reranker_url": "http://192.168.0.200:5200/rerank",
-  "collections": {
-    "conversational": "openclaw_mem",
-    "knowledge": "knowledge_mem"
-  },
-  "prompts": {
-    "conversational": "You are a personal memory assistant…",
-    "knowledge": "You are a knowledge extraction assistant…"
-  }
-}
-```
-
----
-
-### `/memories` — conversational collection (OpenClaw)
-
-#### `POST /memories`
-
-Add a memory. Accepts plain text or a messages array.
-
-```json
-{ "text": "I prefer Python over JavaScript.", "user_id": "alice" }
-```
-
-```json
-{
-  "messages": [{"role": "user", "content": "I've used Vim for 10 years."}],
-  "user_id": "alice"
-}
-```
-
-#### `POST /memories/search`
-
-Search with reranking. Fetches `limit × 3` candidates, reranks locally, returns top `limit`.
-
-```json
-{ "query": "editor preferences", "user_id": "alice", "limit": 5 }
-```
-
-Results include a `rerank_score` field.
-
-#### `POST /memories/recent`
-
-Return the most recently created memories for a user.
-
-```json
-{ "user_id": "alice", "limit": 5 }
-```
-
-#### `DELETE /memories`
-
-Delete memories by filter.
-
-```json
-{ "filter": { "user_id": "alice" } }
-```
-
----
-
-### `/knowledge` — knowledge collection (book-ingestor)
-
-Same shape as `/memories` with two additions:
-
-**`metadata`** — arbitrary key/value dict stored alongside the memory. Useful for provenance tagging:
-
-```json
-{
-  "text": "Silvio Gesell proposed demurrage to discourage currency hoarding.",
-  "user_id": "knowledge_base",
-  "metadata": { "source_file": "gesell_neo.pdf", "chapter": 3, "page": 47 }
-}
-```
-
-**`infer: false`** — bypasses LLM extraction entirely and stores the text verbatim. Use for pre-summarised chunks where you want exact content preserved:
-
-```json
-{
-  "text": "MIDI SysEx messages use a 7-bit checksum.",
-  "user_id": "knowledge_base",
-  "infer": false,
-  "metadata": { "source_file": "midi_spec.pdf", "chapter": 9, "page": 112 }
-}
-```
-
-#### `POST /knowledge/search`
-#### `POST /knowledge/recent`
-#### `DELETE /knowledge`
-
-Same request/response shape as their `/memories` counterparts.
-
----
-
-### `POST /search` — merged search (both collections)
-
-Queries both collections simultaneously, tags each result with `_source`, then runs a single rerank pass over the merged pool. Intended for OpenClaw's autorecall webhook when it should draw on both conversational memory and ingested knowledge.
-
-```json
-{ "query": "Gesell economic theory", "user_id": "knowledge_base", "limit": 8 }
-```
-
-Each result includes `"_source": "conversational"` or `"_source": "knowledge"`.
-
----
-
-## Reranker contract
-
-The server expects a reranker at `RERANKER_URL` accepting:
-
-```json
-{
-  "query": "...",
-  "documents": ["doc1", "doc2"],
-  "top_k": 5
-}
-```
-
-And returning:
-
-```json
-{
-  "results": [
-    { "text": "doc1", "score": 0.99 },
-    { "text": "doc2", "score": 0.87 }
-  ]
-}
-```
-
-If the reranker is unreachable the server falls back gracefully to raw mem0 results — no crash, no error returned to the caller.
-
----
-
-## Resetting a collection
-
-`reset_memory.py` deletes and recreates a collection directly via the Chroma HTTP API, then restarts the mem0server container so it re-acquires the fresh collection object:
-
-```bash
-python reset_memory.py openclaw_mem   # wipe conversational
-python reset_memory.py knowledge_mem  # wipe knowledge
-```
-
-```python
-# reset_memory.py
-import sys, subprocess, requests
-
-base = "http://192.168.0.200:8001/api/v1"
-name = sys.argv[1]
-requests.delete(f"{base}/collections/{name}")
-requests.post(f"{base}/collections", json={"name": name})
-print(f"collection reset: {name}")
-subprocess.run(["docker", "compose", "restart", "mem0server"])
-print("mem0server restarted")
-```
-
----
-
-## Testing
-
-`tests.sh` exercises every endpoint. Run it after any server change:
-
-```bash
-bash tests.sh
-```
-
-Key assertions to eyeball manually:
+## Docs
+- API reference: **API.md**
+- Project overview: **PROJECT.md**
 
-- `/knowledge/search` query `"Gesell demurrage"` → Gesell result ranked #1, MIDI result near the bottom
-- `/knowledge/search` query `"free money currency hoarding"` → Gesell `rerank_score` should be `> 0.9`, MIDI `< 0.001`  
-- `/search` (merged) results should include `_source` field on every item
-- `infer: false` response body should show the text stored verbatim, not an LLM-rewritten version
+If you want the README expanded again or a different doc split, say the word.

+ 117 - 74
tests.sh

@@ -6,6 +6,9 @@
 BASE="${1:-http://192.168.0.200:8420}"
 PASS=0
 FAIL=0
+LAST_JSON=""
+LAST_STATUS=""
+MEMORY_MARKER="mem0-test-case"
 
 # Colours
 GREEN='\033[0;32m'
@@ -18,9 +21,13 @@ sep()  { echo -e "\n${CYAN}${BOLD}━━━ $* ━━━${RESET}"; }
 ok()   { echo -e "${GREEN}✓ $*${RESET}"; ((PASS++)); }
 fail() { echo -e "${RED}✗ $*${RESET}"; ((FAIL++)); }
 
-# Run curl, pretty-print with jq, check HTTP 200
 run() {
   local label="$1"; shift
+  local expected="200"
+  if [[ "$1" =~ ^[0-9]{3}$ ]]; then
+    expected="$1"
+    shift
+  fi
   echo -e "\n${BOLD}▶ $label${RESET}"
   local body
   body=$(curl -s -w "\n__STATUS__%{http_code}" "$@")
@@ -31,134 +38,170 @@ run() {
 
   echo "$json" | jq . 2>/dev/null || echo "$json"
 
-  if [[ "$status" == "200" ]]; then
+  LAST_STATUS="$status"
+  LAST_JSON="$json"
+
+  if [[ "$status" == "$expected" ]]; then
     ok "HTTP $status"
   else
-    fail "HTTP $status"
+    fail "HTTP $status (expected $expected)"
   fi
 }
 
-# ─────────────────────────────────────────────
-sep "HEALTH"
-# ─────────────────────────────────────────────
+assert_json() {
+  local label="$1" expr="$2"
+  if echo "$LAST_JSON" | jq -e "$expr" >/dev/null 2>&1; then
+    ok "$label"
+  else
+    fail "$label (jq: $expr)"
+  fi
+}
 
-run "GET /health" \
-  "$BASE/health"
+assert_contains() {
+  local label="$1" substring="$2"
+  if echo "$LAST_JSON" | grep -qF "$substring"; then
+    ok "$label"
+  else
+    fail "$label (missing '$substring')"
+  fi
+}
 
-# ─────────────────────────────────────────────
-sep "/memories  (conversational — OpenClaw)"
-# ─────────────────────────────────────────────
+sep "HEALTH"
+run "GET /health" "$BASE/health"
+assert_json "health -> collections object" '.collections | type == "object"'
+assert_json "health -> prompts present" '.prompts | type == "object"'
+
+sep "DASHBOARD"
+run "GET /dashboard" "$BASE/dashboard"
+assert_contains "dashboard contains HTML" "<html"
 
+sep "/memories  (conversational)"
 run "POST /memories — plain text" \
   -X POST "$BASE/memories" \
   -H "Content-Type: application/json" \
-  -d '{"text": "I love building AI agents and I prefer Python over JavaScript", "user_id": "testuser"}'
+  -d '{"text": "${MEMORY_MARKER} plain text entry for testing", "user_id": "testuser"}'
 
 run "POST /memories — messages array" \
   -X POST "$BASE/memories" \
   -H "Content-Type: application/json" \
-  -d '{
-    "messages": [{"role": "user", "content": "I have been using Vim for 10 years and hate GUI editors"}],
-    "user_id": "testuser"
-  }'
+  -d '{"messages": [{"role": "user", "content": "${MEMORY_MARKER} message entry for testing"}], "user_id": "testuser"}'
+
+run "POST /memories/all" \
+  -X POST "$BASE/memories/all" \
+  -H "Content-Type: application/json" \
+  -d '{"user_id": "testuser"}'
+assert_json "memories/all returns array" '.results | type == "array"'
 
-run "POST /memories/search — programming preferences" \
+run "POST /memories/search — capture marker" \
   -X POST "$BASE/memories/search" \
   -H "Content-Type: application/json" \
-  -d '{"query": "programming preferences", "user_id": "testuser", "limit": 5}'
+  -d '{"query": "$MEMORY_MARKER", "user_id": "testuser", "limit": 5 }'
+assert_json "memories marker search returns array" '.results | type == "array"'
+
+MEMORY_IDS=$(echo "$LAST_JSON" | jq -r '.results[] | .id')
+if [[ -z "$MEMORY_IDS" ]]; then
+  fail "no test memory IDs captured"
+else
+  ok "captured conversational memory IDs"
+fi
+
+run "POST /memories/search" \
+  -X POST "$BASE/memories/search" \
+  -H "Content-Type: application/json" \
+  -d '{"query": "programming preferences", "user_id": "testuser", "limit": 5 }'
+assert_json "memories search returns array" '.results | type == "array"'
 
 run "POST /memories/recent" \
   -X POST "$BASE/memories/recent" \
   -H "Content-Type: application/json" \
   -d '{"user_id": "testuser", "limit": 5}'
-
-# ─────────────────────────────────────────────
-sep "/knowledge  (objective facts — book-ingestor)"
-# ─────────────────────────────────────────────
-
-run "POST /knowledge — Gesell with metadata (LLM extraction)" \
+assert_json "memories recent returns array" '.results | type == "array"'
+
+if [[ -n "$MEMORY_IDS" ]]; then
+  for id in $MEMORY_IDS; do
+    run "DELETE /memory/$id" \
+      -X DELETE "$BASE/memory/$id" \
+      -H "Content-Type: application/json" \
+      -d '{"collection": "conversational"}'
+  done
+else
+  fail "conversational memory IDs missing before cleanup"
+fi
+
+sep "/knowledge  (knowledge base)"
+run "POST /knowledge — Gesell metadata" \
   -X POST "$BASE/knowledge" \
   -H "Content-Type: application/json" \
-  -d '{
-    "text": "Silvio Gesell proposed demurrage as a mechanism to discourage hoarding of currency. He described this in The Natural Economic Order published in 1916.",
-    "user_id": "knowledge_base",
-    "metadata": {"source_file": "gesell_neo.pdf", "chapter": 3, "page": 47}
-  }'
+  -d '{"text": "Silvio Gesell proposed demurrage to discourage hoarding of currency.", "user_id": "knowledge_base", "metadata": {"source_file": "gesell_neo.pdf", "chapter": 3, "page": 47}}'
 
-run "POST /knowledge — MIDI verbatim (infer:false)" \
+run "POST /knowledge — MIDI verbatim" \
   -X POST "$BASE/knowledge" \
   -H "Content-Type: application/json" \
-  -d '{
-    "text": "MIDI SysEx messages use a 7-bit checksum computed as the twos complement of the sum of all data bytes.",
-    "user_id": "knowledge_base",
-    "infer": false,
-    "metadata": {"source_file": "midi_spec.pdf", "chapter": 9, "page": 112}
-  }'
+  -d '{"text": "MIDI SysEx uses a 7-bit checksum.", "user_id": "knowledge_base", "infer": false, "metadata": {"source_file": "midi_spec.pdf", "chapter": 9, "page": 112}}'
 
-run "POST /knowledge/search — Gesell demurrage (should NOT return MIDI)" \
+run "POST /knowledge/search — Gesell demurrage" \
   -X POST "$BASE/knowledge/search" \
   -H "Content-Type: application/json" \
   -d '{"query": "Gesell demurrage", "user_id": "knowledge_base", "limit": 5}'
+assert_json "knowledge search results array" '.results | type == "array"'
 
-run "POST /knowledge/search — free money currency hoarding" \
-  -X POST "$BASE/knowledge/search" \
+run "POST /knowledge/recent" \
+  -X POST "$BASE/knowledge/recent" \
   -H "Content-Type: application/json" \
-  -d '{"query": "free money currency hoarding", "user_id": "knowledge_base", "limit": 5}'
+  -d '{"user_id": "knowledge_base", "limit": 5}'
+assert_json "knowledge recent returns array" '.results | type == "array"'
 
-run "POST /knowledge/search — MIDI checksum (should NOT return Gesell)" \
-  -X POST "$BASE/knowledge/search" \
+run "POST /knowledge/sources" \
+  -X POST "$BASE/knowledge/sources" \
   -H "Content-Type: application/json" \
-  -d '{"query": "MIDI checksum SysEx", "user_id": "knowledge_base", "limit": 5}'
+  -d '{"user_id": "knowledge_base"}'
+assert_json "knowledge sources array" '.sources | type == "array"'
+assert_json "knowledge sources include Gesell" '.sources | any(.source_file == "gesell_neo.pdf")'
 
-run "POST /knowledge/recent" \
-  -X POST "$BASE/knowledge/recent" \
+run "DELETE /knowledge/by-source — gesell_neo.pdf" \
+  -X DELETE "$BASE/knowledge/by-source" \
   -H "Content-Type: application/json" \
-  -d '{"user_id": "knowledge_base", "limit": 5}'
+  -d '{"source_file": "gesell_neo.pdf", "user_id": "knowledge_base"}'
+assert_json "knowledge by-source removed entries" '.deleted | tonumber > 0'
 
-# ─────────────────────────────────────────────
-sep "/search  (merged — both collections)"
-# ─────────────────────────────────────────────
+run "DELETE /knowledge/by-source — midi_spec.pdf" \
+  -X DELETE "$BASE/knowledge/by-source" \
+  -H "Content-Type: application/json" \
+  -d '{"source_file": "midi_spec.pdf", "user_id": "knowledge_base"}'
+assert_json "knowledge by-source removed MIDI entries" '.deleted | tonumber > 0'
 
-run "POST /search — Python programming (expect _source tags on results)" \
+sep "/search  (merged)"
+run "POST /search — Python programming" \
   -X POST "$BASE/search" \
   -H "Content-Type: application/json" \
   -d '{"query": "Python programming", "user_id": "testuser", "limit": 8}'
+assert_json "merged search results array" '.results | type == "array"'
+assert_json "merged search items have _source" 'if (.results | length) == 0 then true else (.results[] | has("_source")) end'
 
-run "POST /search — Gesell economic theory (cross-collection)" \
+run "POST /search — Gesell economic theory" \
   -X POST "$BASE/search" \
   -H "Content-Type: application/json" \
   -d '{"query": "Gesell economic theory", "user_id": "knowledge_base", "limit": 5}'
+assert_json "merged search results array (Gesell)" '.results | type == "array"'
+assert_json "merged Gesell results tag _source" 'if (.results | length) == 0 then true else (.results[] | has("_source")) end'
 
-# ─────────────────────────────────────────────
 sep "ERROR HANDLING"
-# ─────────────────────────────────────────────
-
-run "POST /memories — missing text/messages (expect 400)" \
+run "POST /memories — missing text" 400 \
   -X POST "$BASE/memories" \
   -H "Content-Type: application/json" \
   -d '{"user_id": "testuser"}'
 
-run "POST /memories — infer:false ignored on /memories (no verbatim)" \
+run "POST /memories — infer:false ignored" \
   -X POST "$BASE/memories" \
   -H "Content-Type: application/json" \
   -d '{"text": "This should still go through LLM extraction", "user_id": "testuser", "infer": false}'
 
-# ─────────────────────────────────────────────
-sep "DELETE  (comment out if you want to keep test data)"
-# ─────────────────────────────────────────────
-
-# Uncomment to clean up after testing:
-# run "DELETE /memories — testuser" \
-#   -X DELETE "$BASE/memories" \
-#   -H "Content-Type: application/json" \
-#   -d '{"filter": {"user_id": "testuser"}}'
-#
-# run "DELETE /knowledge — knowledge_base" \
-#   -X DELETE "$BASE/knowledge" \
-#   -H "Content-Type: application/json" \
-#   -d '{"filter": {"user_id": "knowledge_base"}}'
-
-# ─────────────────────────────────────────────
+run "POST /search — empty query" \
+  -X POST "$BASE/search" \
+  -H "Content-Type: application/json" \
+  -d '{"query": "", "user_id": "testuser"}'
+assert_json "empty search returns results array" '.results | type == "array"'
+assert_json "empty search returns zero results" '.results | length == 0'
+
 sep "RESULTS"
-# ─────────────────────────────────────────────
-echo -e "\n${BOLD}Passed: ${GREEN}$PASS${RESET}  ${BOLD}Failed: ${RED}$FAIL${RESET}\n"
+echo -e "\n${BOLD}Passed: ${GREEN}$PASS${RESET}  ${BOLD}Failed: ${RED}$FAIL${RESET}\n"