diff --git a/CLAUDE.md b/CLAUDE.md index 9638f08..b193d79 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,8 +1,8 @@ -# CLAUDE.md +# ai.md — Project Context -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +Detailed project context, work state, architecture, and configuration have moved to [`ai.md`](ai.md). This file is kept for backward compatibility — the canonical reference is `ai.md`. -## Running the App +## Quick start ```bash # Development @@ -21,74 +21,3 @@ sudo systemctl restart caic ./venv/bin/pip install -r requirements.txt # Also requires: psutil jinja2 python-multipart pypdf (not in requirements.txt) ``` - -## Architecture - -Modular FastAPI app — `app.py` wires routers, middleware, and lifespan. SQLite database auto-created at `caic.db` on first run. No build step, single `templates/index.html`. - -### Request Flow: `/api/chat` - -1. User message saved to DB → conversation created if new -2. `process_remember_command()` intercepts "remember that..." / "forget about..." first -3. Optional `upload_context_id` → fetches document text from `upload_context` table, injects `[ATTACHED DOCUMENT]` into system prompt -4. `build_system_prompt()` assembles: profile + FTS5 memory search + Qdrant RAG + preset + skills + uploaded doc -5. Streamed to llama-server (`/v1/chat/completions`, `stream: true`, `logprobs: true`) via SSE -6. **Auto web search trigger**: if perplexity > 15.0 OR response matches `REFUSAL_PATTERNS`, re-queries with SearXNG results -7. Final response saved to DB; SSE `done` event sent with perplexity + tokens/sec - -### Request Flow: `/api/search` (explicit search) - -Bypasses perplexity/refusal — queries SearXNG directly then asks llama-server to summarize results. - -### Request Flow: `/api/upload` - -Multipart file upload → PDF/text extraction + chunking → optional Qdrant upsert + SQLite context storage (1hr expiry). Supports `mode=(context|ingest|both)`. Images upload as storage only — model cannot process image content. - -### Request Flow: `/api/ingest` - -Bearer-token-authenticated terminal RAG hook. Accepts raw text, chunks via `chunk_text()`, embeds via Ollama `/api/embeddings`, upserts to Qdrant. - -### Memory System - -FTS5 virtual table (`memories`) in SQLite. `search_memories()` uses BM25 ranking. `process_remember_command()` intercepts "remember that..." / "forget about..." before the message reaches the model and returns a confirmation string. - -### Key Constants (`config.py`) - -- `LLAMA_SERVER_BASE` — `http://192.168.50.108:8081` (coordinator llama-server, RPC offloads to worker GPU) -- `SEARXNG_BASE` — `http://localhost:8888` -- `QDRANT_URL` — `http://192.168.50.108:6333` (Qdrant on coordinator) -- `TRIAGE_BASE` — `http://127.0.0.1:8083/v1` (Phi-4-mini) -- `AMQP_URL` — `amqp://caic:{pw}@localhost:5672/caic` (RabbitMQ, pw read from `~/.caic_amqp_secret`) -- `PERPLEXITY_THRESHOLD` — `15.0` -- `EMBED_URL` — `http://192.168.50.210:11434/api/embeddings` (Ollama on worker) -- `VERSION` — current version string - -### External Services - -| Service | Required | Port | -|---------|----------|------| -| **llama-server** (coordinator) | Yes | 8081 + RPC :50052 (worker GPU) | -| **Phi-4-mini** (triage) | No | 8083 | -| **SearXNG** | No | 8888 | -| **RabbitMQ** (coordinator) | No | 5672 — AMQP broker | -| **wttr.in** | No | weather shortcut | -| **rocm-smi** | No | AMD GPU stats | -| **Qdrant** (coordinator) | No | 6333 — RAG vector search | -| **Ollama** (worker) | No | 11434 — embeddings only | - -### Database - -`get_db()` opens a new connection per request (no pool). `init_db()` runs at startup via FastAPI `lifespan`. Tables: `conversations`, `messages`, `settings`, `profile` (singleton id=1), `memories` (FTS5), `upload_context`. Default settings seeded but never overwritten. - -### SSE Protocol - -All streaming endpoints yield `data: {json}\n\n`. Key event shapes: -- `{token, conversation_id}` — streaming token -- `{searching: true}` — web search triggered -- `{search_results: N}` — N results retrieved -- `{done: true, perplexity, tokens_per_sec, searched?}` — terminal event -- `{error: "...", error_key: "..."}` — error with incident key - -### Deployment - -Runs as systemd service under user `caic`, working directory `/opt/caic`. Logs via syslog (`journalctl -u caic`). Version bumps via git tag + commit, deployed via `git pull && systemctl restart caic`. diff --git a/README.md b/README.md index d75cec9..f21bcd4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@  -# cAIc v0.21.0 +# cAIc v0.22.0 Consumer AI hardware is a wasteland of incompatibility. NVIDIA speaks CUDA, AMD speaks ROCm. Your RTX 5070 Ti lives in one machine with 16 GB VRAM; your RX 6600 XT lives in another with 12 GB. Alone, neither can run a 14B model at usable speed. Together, they could — if the software stack didn't treat heterogeneous hardware as a bug instead of a feature. @@ -55,6 +55,16 @@ Untested: Windows 11 / WSL2 (Debian). The codebase is pure Python with no platfo Under the hood: FastAPI + SQLite + Jinja2 on Python 3.13. AMQP-mediated cluster coordination with an OpenAI-compatible inference endpoint. +## What's New in v0.22.0 + +### RAG Corpus Management UI (B4) +- New admin modal (RAG button in drawer header) to browse, search, edit, and delete individual RAG corpus entries. +- **Endpoints**: `GET /api/rag/points` (paginated list with semantic search and source filter), `GET /api/rag/point/{id}` (single point detail), `DELETE /api/rag/point/{id}` (single point deletion), `PATCH /api/rag/point/{id}` (edit text with re-embed). +- **Frontend**: Stats bar (vector count, % full, pinned, avg retrievals, at-risk, eviction rate), semantic search bar, source filter dropdown, paginated results table, per-row edit/delete with confirmation, double-confirm bulk flush. +- All endpoints admin-protected; text decrypted for display, re-encrypted on edit. +- 14 new tests (32 total in test_rag_management.py). 214 tests pass overall. +- Renamed `AGENTS.md` → `ai.md` for tool-agnostic project context. `CLAUDE.md` now points to `ai.md`. + ## What's New in v0.21.0 ### Scrollbar + DOM Fixes diff --git a/AGENTS.md b/ai.md similarity index 93% rename from AGENTS.md rename to ai.md index d7163f8..de970cc 100644 --- a/AGENTS.md +++ b/ai.md @@ -26,6 +26,7 @@ Every router has a dedicated test file: | `test_models_router.py` | `routers/models.py` — models list, ps, show, stats, search/status | | `test_presets.py` | `routers/presets.py` — full CRUD, default preset protection | | `test_profile.py` | `routers/profile.py` — get, update, default, length validation | +| `test_rag_management.py` | `eviction.py` + `routers/rag_admin.py` — eviction engine, stats, flush, browse, search, edit, delete individual points | | `test_search_route.py` | `routers/search_route.py` — explicit search flow, no results, errors | | `test_search_url_sanitization.py` | `search.py` URL sanitizer | | `test_cluster.py` | `cluster.py` — registration, deregistration, pong, events, coordinator query | @@ -137,7 +138,7 @@ All streaming endpoints yield `data: {json}\n\n`. Key shapes: ## Work State ### Completed this session -- **v0.21.0 Release** — Scrollbar/DOM fixes, perplexity persistence, all service URLs env-overridable, single-node deployment docs, hardware.py Qdrant URL bugfix. See README for full changelog. +- **B4 — RAG Corpus Management UI** — Paginated browse, semantic search, source filter, edit text with re-embed, single-point delete, bulk flush. Backend: `GET /api/rag/points`, `GET /api/rag/point/{id}`, `DELETE /api/rag/point/{id}`, `PATCH /api/rag/point/{id}`. Frontend: admin-only modal with stats bar, search bar, paginated table, per-row edit/delete, double-confirm flush. ### Active - (none) @@ -145,16 +146,11 @@ All streaming endpoints yield `data: {json}\n\n`. Key shapes: ### Blocked - (none) -### Blocked -- (none) - ### Upcoming (backlog) - B3 — Docker distribution -- B4 — RAG Corpus Management UI -- Private Chat mode was implemented in B8 (v0.19.3). WireGuard in-transit encryption documented in wiki. At-rest encryption implemented in v0.20.0. ### Key config values (current) -- **Current VERSION**: `v0.21.0` in `config.py`. +- **Current VERSION**: `v0.22.0` in `config.py`. - `SESSION_TIMEOUT_SECONDS = 3600` - `DEFAULT_MODEL = "qwen2.5-7b-instruct"` - `LLAMA_SERVER_BASE = "http://192.168.50.108:8081"` diff --git a/config.py b/config.py index 24b2582..68809dc 100644 --- a/config.py +++ b/config.py @@ -9,7 +9,7 @@ import logging log = logging.getLogger("caic") -VERSION = "v0.21.0" +VERSION = "v0.22.0" OLLAMA_BASE = os.environ.get("OLLAMA_BASE", "http://localhost:11434") LLAMA_SERVER_BASE = os.environ.get("LLAMA_SERVER_BASE", "http://192.168.50.108:8081") SEARXNG_BASE = os.environ.get("CAIC_SEARXNG_BASE", "http://localhost:8888") diff --git a/docs/wiki/current-wip.md b/docs/wiki/current-wip.md index 18b4356..af74e24 100644 --- a/docs/wiki/current-wip.md +++ b/docs/wiki/current-wip.md @@ -10,11 +10,11 @@ Scope: Active roadmap items and backlog. - **WireGuard TLS (v0.19.4)** — Self-signed WireGuard mesh encrypts all inter-node traffic (AMQP, inference, RPC). No code changes to cAIc. Documented in wiki/WireGuard-Setup.md + docker.md §5.4. - **At-Rest Encryption (v0.20.0)** — AES-256-GCM encrypts all query-derived text at rest. crypto.py with auto-keygen, key stored as `heartbeat_interval_ms` in settings. All 12 storage paths wired (SQLite: messages, conversations, memories, upload_context; Qdrant: RAG chunks, ingest, upload). 200 tests pass. - **v0.21.0** — Scrollbar/DOM fixes, perplexity persistence per message, all service URLs env-overridable, single-node deployment docs, DOM pairing bugfix, hardware.py Qdrant URL bugfix. +- **v0.22.0** — B4 RAG Corpus Management UI: paginated browse, semantic search, source filter, edit with re-embed, single-point delete, bulk flush. `routers/rag_admin.py` expanded with 4 new endpoints; `CLAUDE.md` → `ai.md` rename. ## Backlog - B3 — Docker distribution (planning doc at `docker.md`, not yet implemented) -- **B4 — RAG Corpus Management UI (deferred)** — browse, search, edit, delete individual RAG entries - HTTPS / reverse proxy (Caddy) - Conversation search/filter and export tooling - Keyboard shortcuts, retry button, source-link polish diff --git a/routers/rag_admin.py b/routers/rag_admin.py index 6235ba3..7ff87f8 100644 --- a/routers/rag_admin.py +++ b/routers/rag_admin.py @@ -1,17 +1,38 @@ """JarvisChat routers — RAG corpus management admin endpoints.""" import logging +from datetime import datetime, timezone import httpx -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, HTTPException, Query, Request from fastapi.responses import JSONResponse +from crypto import decrypt_text, encrypt_text from eviction import get_rag_operational_stats, EVICTION_LOG -from rag import QDRANT_URL, RAG_COLLECTION +from rag import QDRANT_URL, RAG_COLLECTION, EMBED_URL, EMBED_MODEL log = logging.getLogger("caic") router = APIRouter() +def _normalise_date(payload: dict) -> str: + return payload.get("ingest_date") or payload.get("upload_date") or "" + + +def _decrypt_payload(payload: dict) -> dict: + text = payload.get("text", "") + return { + "text": decrypt_text(text), + "source": payload.get("source"), + "type": payload.get("type"), + "date": _normalise_date(payload), + "retrieval_count": payload.get("retrieval_count", 0), + "topic": payload.get("topic"), + "fact": payload.get("fact"), + "filename": payload.get("filename"), + "last_accessed": payload.get("last_accessed"), + } + + @router.get("/api/rag/stats") async def rag_stats(request: Request): if getattr(request.state, "session_role", "none") != "admin": @@ -21,8 +42,178 @@ async def rag_stats(request: Request): return stats +@router.get("/api/rag/points") +async def rag_list_points( + request: Request, + limit: int = Query(20, ge=1, le=100), + offset: str = Query(None), + search: str = Query(None), + source: str = Query(None), + sort: str = Query("date"), + order: str = Query("desc"), +): + if getattr(request.state, "session_role", "none") != "admin": + raise HTTPException(status_code=403, detail="Admin PIN required for this action") + + async with httpx.AsyncClient() as client: + if search: + embed_resp = await client.post( + f"{EMBED_URL}/api/embeddings", + json={"model": EMBED_MODEL, "prompt": search}, + timeout=10.0, + ) + if embed_resp.status_code != 200: + return JSONResponse(status_code=502, content={"detail": "Embedding failed"}) + vector = embed_resp.json()["embedding"] + search_body = {"vector": vector, "limit": limit, "with_payload": True} + if source: + search_body["filter"] = {"must": [{"match": {"key": "source", "value": source}}]} + sr = await client.post( + f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points/search", + json=search_body, + timeout=10.0, + ) + if sr.status_code != 200: + return JSONResponse(status_code=502, content={"detail": "Qdrant search failed"}) + results = sr.json().get("result", []) + points = [] + for r in results: + p = _decrypt_payload(r.get("payload", {})) + p["id"] = r["id"] + p["score"] = r.get("score") + points.append(p) + return {"points": points, "total": len(points), "next_offset": None} + + scroll_body = { + "limit": limit, + "with_payload": True, + "with_vector": False, + } + if offset: + scroll_body["offset"] = offset + if source: + scroll_body["filter"] = {"must": [{"match": {"key": "source", "value": source}}]} + if sort: + direction = "asc" if order == "asc" else "desc" + scroll_body["order_by"] = {"key": sort, "direction": direction} + + sr = await client.post( + f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points/scroll", + json=scroll_body, + timeout=10.0, + ) + if sr.status_code != 200: + return JSONResponse(status_code=502, content={"detail": "Qdrant scroll failed"}) + result = sr.json().get("result", {}) + raw_points = result.get("points", []) + next_offset = result.get("next_page_offset") + + info_resp = await client.get( + f"{QDRANT_URL}/collections/{RAG_COLLECTION}", + timeout=5.0, + ) + total = 0 + if info_resp.status_code == 200: + total = info_resp.json().get("result", {}).get("vectors_count", 0) + + points = [] + for r in raw_points: + p = _decrypt_payload(r.get("payload", {})) + p["id"] = r["id"] + points.append(p) + return {"points": points, "total": total, "next_offset": next_offset} + + +@router.get("/api/rag/point/{point_id}") +async def rag_get_point(point_id: str, request: Request): + if getattr(request.state, "session_role", "none") != "admin": + raise HTTPException(status_code=403, detail="Admin PIN required for this action") + + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points/{point_id}", + timeout=10.0, + ) + if resp.status_code != 200: + raise HTTPException(status_code=404, detail="Point not found") + result = resp.json().get("result", {}) + if not result: + raise HTTPException(status_code=404, detail="Point not found") + payload = result.get("payload", {}) + point = _decrypt_payload(payload) + point["id"] = result["id"] + return point + + +@router.delete("/api/rag/point/{point_id}") +async def rag_delete_point(point_id: str, request: Request): + if getattr(request.state, "session_role", "none") != "admin": + raise HTTPException(status_code=403, detail="Admin PIN required for this action") + + async with httpx.AsyncClient() as client: + resp = await client.post( + f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points/delete", + json={"points": [point_id]}, + timeout=10.0, + ) + if resp.status_code not in (200, 201) and resp.status_code != 404: + return JSONResponse(status_code=502, content={"detail": "Qdrant delete failed"}) + log.info(f"RAG point {point_id} deleted by admin") + return {"status": "deleted", "id": point_id} + + +@router.patch("/api/rag/point/{point_id}") +async def rag_update_point(point_id: str, request: Request): + if getattr(request.state, "session_role", "none") != "admin": + raise HTTPException(status_code=403, detail="Admin PIN required for this action") + + body = await request.json() + new_text = (body.get("text") or "").strip() + if not new_text: + raise HTTPException(status_code=400, detail="text required") + + async with httpx.AsyncClient() as client: + get_resp = await client.get( + f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points/{point_id}", + timeout=10.0, + ) + if get_resp.status_code != 200: + raise HTTPException(status_code=404, detail="Point not found") + existing = get_resp.json().get("result", {}) + if not existing: + raise HTTPException(status_code=404, detail="Point not found") + + old_payload = existing.get("payload", {}) + embed_resp = await client.post( + f"{EMBED_URL}/api/embeddings", + json={"model": EMBED_MODEL, "prompt": new_text}, + timeout=10.0, + ) + if embed_resp.status_code != 200: + return JSONResponse(status_code=502, content={"detail": "Embedding failed"}) + vector = embed_resp.json()["embedding"] + + new_payload = dict(old_payload) + new_payload["text"] = encrypt_text(new_text) + date_field = "ingest_date" if "ingest_date" in old_payload else "upload_date" + new_payload[date_field] = datetime.now(timezone.utc).isoformat() + + upsert_resp = await client.put( + f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points?wait=true", + json={"points": [{"id": point_id, "vector": vector, "payload": new_payload}]}, + timeout=10.0, + ) + if upsert_resp.status_code not in (200, 201): + return JSONResponse(status_code=502, content={"detail": "Qdrant upsert failed"}) + + log.info(f"RAG point {point_id} updated by admin") + return {"status": "updated", "id": point_id} + + @router.post("/api/rag/flush") async def rag_flush(request: Request): + if getattr(request.state, "session_role", "none") != "admin": + raise HTTPException(status_code=403, detail="Admin PIN required for this action") try: async with httpx.AsyncClient() as client: scroll_resp = await client.post( diff --git a/templates/index.html b/templates/index.html index 841c5d8..77c10fb 100644 --- a/templates/index.html +++ b/templates/index.html @@ -129,6 +129,39 @@ body { font-family: var(--font-body); background: var(--bg-primary); color: var( .modal-close { background:none; border:none; color:var(--text-muted); font-size:24px; cursor:pointer; } .modal-close:hover { color:var(--text-primary); } .modal-body { padding: 20px 24px; } +.rag-stats-bar { display:flex; flex-wrap:wrap; gap:12px 24px; padding:10px 14px; background:var(--bg-tertiary); border-radius:var(--radius); margin-bottom:14px; font-size:11px; font-family:var(--font-mono); } +.rag-stat-item { display:flex; gap:4px; align-items:center; } +.rag-stat-label { color:var(--text-muted); text-transform:uppercase; } +.rag-stat-value { color:var(--text-primary); font-weight:600; } +.rag-toolbar { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:12px; flex-wrap:wrap; } +.rag-toolbar-left { display:flex; gap:8px; align-items:center; flex:1; } +.rag-toolbar-right { display:flex; gap:8px; align-items:center; flex-shrink:0; } +.rag-search-input { flex:1; min-width:160px; background:var(--bg-tertiary); border:1px solid var(--border); color:var(--text-primary); font-family:var(--font-mono); font-size:12px; padding:8px 12px; border-radius:var(--radius); } +.rag-search-input:focus { outline:none; border-color:var(--accent-dim); } +.rag-source-select { background:var(--bg-tertiary); border:1px solid var(--border); color:var(--text-primary); font-family:var(--font-mono); font-size:12px; padding:7px 10px; border-radius:var(--radius); cursor:pointer; } +.rag-source-select:focus { outline:none; border-color:var(--accent-dim); } +.rag-table-wrap { overflow-x:auto; margin-bottom:12px; } +.rag-table { width:100%; border-collapse:collapse; font-size:11px; font-family:var(--font-mono); } +.rag-table th { text-align:left; padding:8px 6px; border-bottom:1px solid var(--border); color:var(--text-secondary); text-transform:uppercase; letter-spacing:0.5px; font-size:10px; white-space:nowrap; } +.rag-table td { padding:8px 6px; border-bottom:1px solid var(--border); color:var(--text-primary); vertical-align:top; } +.rag-table tr:hover td { background:var(--bg-hover); } +.rag-col-id { width:80px; } +.rag-col-text { min-width:200px; } +.rag-col-source { width:100px; } +.rag-col-type { width:80px; } +.rag-col-date { width:140px; } +.rag-col-retr { width:50px; text-align:center; } +.rag-col-actions { width:80px; text-align:center; white-space:nowrap; } +.rag-text-snippet { display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; line-height:1.4; word-break:break-word; cursor:pointer; } +.rag-text-snippet.expanded { -webkit-line-clamp:unset; } +.rag-action-btn { background:none; border:1px solid var(--border); border-radius:var(--radius); cursor:pointer; font-size:11px; padding:3px 7px; color:var(--text-muted); transition:all 0.15s; } +.rag-action-btn:hover { border-color:var(--accent-dim); color:var(--accent); } +.rag-action-btn.danger:hover { border-color:var(--danger); color:var(--danger); } +.rag-pagination { display:flex; align-items:center; justify-content:center; gap:12px; padding:8px 0; } +.rag-page-info { font-size:11px; color:var(--text-muted); font-family:var(--font-mono); } +.rag-edit-modal { max-width:700px; } +.rag-edit-textarea { width:100%; min-height:200px; background:var(--bg-tertiary); border:1px solid var(--border); color:var(--text-primary); font-family:var(--font-mono); font-size:12px; padding:12px; border-radius:var(--radius); resize:vertical; line-height:1.6; } +.rag-edit-textarea:focus { outline:none; border-color:var(--accent-dim); } .modal-section { margin-bottom: 24px; } .modal-section h3 { font-family:var(--font-mono); font-size:13px; color:var(--text-secondary); text-transform:uppercase; letter-spacing:1px; margin-bottom:8px; } .modal-section p.desc { font-size:12px; color:var(--text-muted); margin-bottom:10px; line-height:1.5; } @@ -318,6 +351,7 @@ body { font-family: var(--font-body); background: var(--bg-primary); color: var( cAIc {{ version }}