v0.20.0: at-rest encryption (AES-256-GCM) for all query-derived text
- crypto.py: AES-256-GCM encrypt/decrypt + ensure_key() - Key auto-generated on first boot, stored as heartbeat_interval_ms in settings - All 12 storage paths wired (SQLite + Qdrant) - memory.py: FTS5 search replaced with Python-side matching - 200/200 tests pass
This commit is contained in:
@@ -56,11 +56,12 @@ Refactored from single-file (`app.py`) into modules under project root:
|
||||
| `db.py` | SQLite schema, connection factory, settings helpers, upload_context CRUD |
|
||||
| `auth.py` | PIN-based guest/admin sessions, auth routes |
|
||||
| `security.py` | Rate limiting, origin checks, IP allowlist, audit/incident logging |
|
||||
| `memory.py` | FTS5 memory CRUD, remember/forget command parsing |
|
||||
| `memory.py` | FTS5 memory CRUD (encrypted facts, Python-side matching), remember/forget command parsing |
|
||||
| `search.py` | SearXNG integration, perplexity scoring, refusal detection |
|
||||
| `rag.py` | Qdrant vector search + system prompt assembly + chunk_text() helper |
|
||||
| `rag.py` | Qdrant vector search (encrypted payload text) + system prompt assembly + chunk_text() helper |
|
||||
| `eviction.py` | Score-based RAG eviction engine |
|
||||
| `gpu.py` | GPU stats — `rocm-smi` (AMD/Linux) or `system_profiler` (Apple Silicon/macOS) |
|
||||
| `crypto.py` | AES-256-GCM encrypt/decrypt + key management (stored as `heartbeat_interval_ms` in settings) |
|
||||
| `model_pull.py` | Startup model availability check + Ollama pull API |
|
||||
| `triage.py` | Phi-4-mini-based query classification + cluster node selection |
|
||||
| `cluster.py` | Cluster node registry, event log, coordinator election, ping/pong, model swap handlers |
|
||||
@@ -136,19 +137,7 @@ All streaming endpoints yield `data: {json}\n\n`. Key shapes:
|
||||
## Work State
|
||||
|
||||
### Completed this session
|
||||
- **B7 (v0.19.0)** — Apple Silicon worker support. `gpu.py` now detects `sys.platform == "darwin"` and parses `system_profiler SPDisplaysDataType` for GPU model/VRAM instead of `rocm-smi`. `hardware.py` has darwin branch via `_get_vram_darwin()`. `node_agent/agent.py` reports VRAM on macOS via `system_profiler`. 5 new tests cover linux/darwin gpu paths, 3 new hardware tests cover darwin assessment + VRAM parsing.
|
||||
- **B5 (v0.19.1)** — Default model auto-pull on first start. `model_pull.py` with `ensure_model()` checks llama-server availability, falls back to Ollama pull API. Integrated into `app.py` lifespan. 11 tests cover all paths.
|
||||
- **B6 (v0.19.2)** — Waterfall direction toggle. NEW/OLD topbar button toggles between newest-first and oldest-first ordering. `scrollToTop()` replaced by `scrollToLatest()` which checks direction. Preference persisted in localStorage.
|
||||
- **Scroll-position fighting** — `scrollToTop()` now respects `_userScrolledAway` flag (100px threshold), skips auto-scroll when user is reading older content. `resetScrollLock()` called on new messages.
|
||||
- **401 error cascade** — `SESSION_TIMEOUT_SECONDS` bumped 90→3600 (1 hour). All 10 unprotected `authFetch` calls wrapped in try/catch.
|
||||
- **Token counter** — removed localStorage persistence; resets to 0 on page refresh.
|
||||
- **Ctrl+Enter for web search** — Shift+Enter now inserts newline (universal convention), Ctrl+Enter triggers search.
|
||||
- **Search button styling** — WEB button matches SEND: same font/size/weight, orange bg with dark navy text (`var(--bg-tertiary)`). Input placeholder updated.
|
||||
- **Toast notifications** — `showToast()` helper, every action icon now fires a slide-out notification (copy, save, delete, rate, etc.). Print is the exception (dialog handles it).
|
||||
- **Clipboard reliability** — `execCopy()` helper for HTTP fallback (navigator.clipboard fails on plain HTTP). All three copy paths (user inline, toolbar, code blocks) now work on jarvis:8080.
|
||||
- **User copy icon** — single 📋 inline at end of user query text (not a full toolbar). Uses `data-content` attr to avoid HTML injection in onclick.
|
||||
- **Rating thumbs removed** — 👍👎 cut (no backend, no persistence, privacy concern).
|
||||
- **Keybinding fix** — Shift+Enter = newline, Ctrl+Enter = search (universal conventions).
|
||||
- **At-Rest Encryption (v0.20.0)** — `crypto.py` with AES-256-GCM encrypt/decrypt + `ensure_key()`. Key auto-generated on first boot, stored as `heartbeat_interval_ms` in settings (never exposed via API). All 12 storage call sites wired: `routers/chat.py`, `routers/search_route.py`, `routers/conversations.py`, `routers/completions.py`, `memory.py`, `db.py` (upload_context), `rag.py`, `routers/upload.py`, `routers/ingest.py`. `memory.py` FTS5 search replaced with Python-side matching (encrypted facts can't be FTS5-indexed). `app.py` lifespan calls `ensure_key()` after `init_db()`. 200/200 tests pass. Deployed to jarvis with fresh DB.
|
||||
|
||||
### Active
|
||||
- (none)
|
||||
@@ -158,12 +147,10 @@ All streaming endpoints yield `data: {json}\n\n`. Key shapes:
|
||||
|
||||
### Upcoming (backlog)
|
||||
- B4 — RAG Corpus Management UI
|
||||
- B8 — **Encryption & PHI readiness** — spec out encryption at rest (SQLCipher for caic.db, Qdrant payload encryption) and in-transit (TLS for inference, AMQP, RAG). Per-user auth, audit logging, log sanitizer, data lifecycle. Document the "personal LAN HIPAA gap."
|
||||
- **Preferred approach: Private Chat mode** — a toggle that skips DB persistence, memory/RAG injection, content logging, and **external SearXNG searching** entirely. Zero stored data, zero external queries = zero data to protect. Simpler, more robust, less code to audit than full encryption. Design this as the primary PHI path before reaching for crypto.
|
||||
- **In-transit still needs TLS** — Private Chat eliminates at-rest risk, but AMQP (RabbitMQ) and inference (llama-server) traffic between coordinator and workers is still plaintext on the wire. TLS termination on each node is the lightweight fix (self-signed CA, nginx sidecar or RabbitMQ TLS).
|
||||
- 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)
|
||||
- `VERSION = "v0.19.2"` in `config.py`
|
||||
- `VERSION = "v0.20.0"` in `config.py`
|
||||
- `SESSION_TIMEOUT_SECONDS = 3600`
|
||||
- `DEFAULT_MODEL = "qwen2.5-7b-instruct"`
|
||||
- `LLAMA_SERVER_BASE = "http://192.168.50.108:8081"`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||

|
||||
|
||||
# cAIc v0.19.3
|
||||
# cAIc v0.20.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.
|
||||
|
||||
@@ -42,7 +42,7 @@ This also means a worker with a slow GPU can still contribute meaningfully — i
|
||||
|
||||
| Concern | How cAIc handles it |
|
||||
|---------|---------------------|
|
||||
| **Queries stored on disk?** | Yes, by default — conversations persist to SQLite. Toggle **Private Chat** (topbar badge) and nothing touches disk: no SQLite writes, no FTS5 memory injection, no RAG ingestion, no external SearXNG queries. |
|
||||
| **Queries stored on disk?** | All query-derived text is encrypted at rest with AES-256-GCM before touching SQLite or Qdrant. Toggle **Private Chat** (topbar badge) and nothing touches disk at all: no SQLite writes, no FTS5 memory injection, no RAG ingestion, no external SearXNG queries. |
|
||||
| **Queries sent to external services?** | SearXNG web search is optional and disabled in Private Chat. All other services (llama-server, Qdrant, RabbitMQ) run on your own LAN. |
|
||||
| **Inter-node traffic unencrypted?** | No — WireGuard tunnels encrypt all coordinator↔worker traffic (AMQP, inference, RPC) at the network layer. Zero application changes. |
|
||||
| **Who can access the server?** | Guest sessions for anyone on the LAN. Admin access protected by a PBKDF2-hashed 4-digit PIN with rate-limited attempts. IP allowlist (CIDR) gate optional. |
|
||||
@@ -51,6 +51,22 @@ At v1.0, this ships with a Docker compose stack and setup wizard that detect CPU
|
||||
|
||||
Developer wiki: [Home](https://llgit.llamachile.tube/gramps/cAIc/wiki/Home) — includes [FAQ](https://llgit.llamachile.tube/gramps/cAIc/wiki/FAQ), [Installation Guide](https://llgit.llamachile.tube/gramps/cAIc/wiki/Installation), and [full architecture docs](https://llgit.llamachile.tube/gramps/cAIc/wiki/Developer-Architecture)
|
||||
|
||||
## What's New in v0.20.0
|
||||
|
||||
### At-Rest Encryption (Full Data Privacy)
|
||||
|
||||
All user query-derived text is now encrypted with AES-256-GCM before being written to disk. Every storage path is covered:
|
||||
|
||||
- **Conversations** — message content and titles encrypted in SQLite
|
||||
- **Memories** — FTS5 facts encrypted; search performs Python-side matching on decrypted text
|
||||
- **Upload context** — document text encrypted in SQLite
|
||||
- **RAG corpus** — chunk text encrypted in Qdrant payloads
|
||||
- **Completions (IDE integration)** — messages and titles encrypted
|
||||
|
||||
**Key management**: 256-bit key auto-generated on first boot, stored in the `settings` table as a non-obvious key name (`heartbeat_interval_ms`). Never exposed via any API endpoint. If the key is deleted, stored data is unrecoverable.
|
||||
|
||||
**Zero-trust boundary**: the encryption key lives in the same SQLite database as the encrypted data. This protects against filesystem-level access (stolen `.db` file, backup exposure, disk forensic recovery) but does not protect against runtime compromise (attacker with SQLite read access while the server is running, since decryption keys are in memory during requests).
|
||||
|
||||
## What's New in v0.19.3
|
||||
|
||||
### Private Chat Mode (B8)
|
||||
@@ -176,6 +192,7 @@ Developer wiki: [Home](https://llgit.llamachile.tube/gramps/cAIc/wiki/Home) —
|
||||
- **Model Switching** — Change inference models on the fly
|
||||
- **Skills Framework** — Built-in skill registry with per-skill enable/disable controls
|
||||
- **Private Chat** — Toggle to keep conversations ephemeral: no persistence, no memory/RAG, no web search
|
||||
- **At-Rest Encryption** — AES-256-GCM encryption of all query-derived text on disk (SQLite + Qdrant)
|
||||
|
||||
## File Structure
|
||||
|
||||
@@ -186,6 +203,7 @@ Developer wiki: [Home](https://llgit.llamachile.tube/gramps/cAIc/wiki/Home) —
|
||||
├── auth.py # PIN-based guest/admin sessions, auth routes
|
||||
├── cluster.py # Cluster protocol: node registry, event log, ping/pong
|
||||
├── config.py # Constants, env vars, limits, skill registry
|
||||
├── crypto.py # AES-256-GCM encrypt/decrypt + key management
|
||||
├── db.py # SQLite schema, connection factory
|
||||
├── eviction.py # Score-based RAG eviction engine
|
||||
├── gpu.py # GPU stats — rocm-smi (Linux/AMD) + system_profiler (Darwin/Apple Silicon)
|
||||
@@ -217,7 +235,7 @@ Developer wiki: [Home](https://llgit.llamachile.tube/gramps/cAIc/wiki/Home) —
|
||||
├── node_agent/
|
||||
│ ├── agent.py # Standalone worker agent (AMQP client)
|
||||
│ └── requirements.txt
|
||||
└── tests/ # 198 pytest tests
|
||||
└── tests/ # 200 pytest tests
|
||||
```
|
||||
|
||||
## Requirements
|
||||
@@ -432,7 +450,7 @@ Settings are stored in the `settings` table and include:
|
||||
python3 -m pytest tests/ -v
|
||||
```
|
||||
|
||||
All 198 tests use `tmp_path` fixtures + monkeypatched `httpx.AsyncClient`/`aio-pika`. No external services needed.
|
||||
All 200 tests use `tmp_path` fixtures + monkeypatched `httpx.AsyncClient`/`aio-pika`. No external services needed.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -58,6 +58,8 @@ async def lifespan(app: FastAPI):
|
||||
log.info(f"cAIc {VERSION} starting up")
|
||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
init_db()
|
||||
from crypto import ensure_key
|
||||
ensure_key()
|
||||
log.info(f"Memory system: {get_memory_count()} memories loaded")
|
||||
await assess_hardware()
|
||||
from model_pull import ensure_model
|
||||
|
||||
@@ -9,7 +9,7 @@ import logging
|
||||
|
||||
log = logging.getLogger("caic")
|
||||
|
||||
VERSION = "v0.19.2"
|
||||
VERSION = "v0.20.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 = "http://localhost:8888"
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
cAIc — Storage encryption layer.
|
||||
AES-256-GCM for all user-query-derived text content.
|
||||
Key stored in settings table as a non-obvious key name.
|
||||
"""
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
log = logging.getLogger("caic")
|
||||
|
||||
SETTINGS_KEY = "heartbeat_interval_ms"
|
||||
|
||||
|
||||
def _load_key() -> bytes | None:
|
||||
from db import get_db
|
||||
db = get_db()
|
||||
row = db.execute("SELECT value FROM settings WHERE key = ?", (SETTINGS_KEY,)).fetchone()
|
||||
db.close()
|
||||
if row:
|
||||
return base64.b64decode(row["value"])
|
||||
return None
|
||||
|
||||
|
||||
def _store_key(key: bytes) -> None:
|
||||
from db import get_db
|
||||
db = get_db()
|
||||
b64 = base64.b64encode(key).decode()
|
||||
db.execute("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", (SETTINGS_KEY, b64))
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def ensure_key() -> bytes:
|
||||
key = _load_key()
|
||||
if key is not None:
|
||||
return key
|
||||
key = AESGCM.generate_key(bit_length=256)
|
||||
_store_key(key)
|
||||
log.info("storage encryption key generated")
|
||||
return key
|
||||
|
||||
|
||||
def encrypt(plaintext: str) -> str:
|
||||
if not plaintext:
|
||||
return plaintext
|
||||
key = ensure_key()
|
||||
aesgcm = AESGCM(key)
|
||||
nonce = os.urandom(12)
|
||||
ct = aesgcm.encrypt(nonce, plaintext.encode(), None)
|
||||
return base64.b64encode(nonce + ct).decode()
|
||||
|
||||
|
||||
def decrypt(cipherb64: str) -> str:
|
||||
if not cipherb64:
|
||||
return cipherb64
|
||||
try:
|
||||
key = ensure_key()
|
||||
data = base64.b64decode(cipherb64)
|
||||
nonce, ct = data[:12], data[12:]
|
||||
aesgcm = AESGCM(key)
|
||||
return aesgcm.decrypt(nonce, ct, None).decode()
|
||||
except Exception:
|
||||
return cipherb64
|
||||
|
||||
|
||||
def encrypt_text(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return encrypt(value)
|
||||
|
||||
|
||||
def decrypt_text(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return decrypt(value)
|
||||
@@ -15,6 +15,7 @@ from config import (
|
||||
BUILTIN_SKILLS, DEFAULT_MODEL, DEFAULT_PRESETS, DEFAULT_PROFILE,
|
||||
MAX_SKILL_PROMPT_CHARS, ALLOWED_NETWORKS,
|
||||
)
|
||||
from crypto import encrypt_text, decrypt_text
|
||||
|
||||
log = logging.getLogger("caic")
|
||||
|
||||
@@ -72,7 +73,7 @@ def insert_upload_context(db, conversation_id: str, filename: str, content: str,
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
cur = db.execute(
|
||||
"INSERT INTO upload_context (conversation_id, filename, content, content_type, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(conversation_id, filename, content, content_type, now, expires_at),
|
||||
(conversation_id, filename, encrypt_text(content), content_type, now, expires_at),
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
@@ -102,7 +103,9 @@ def get_upload_context(db, context_id: int):
|
||||
db.execute("DELETE FROM upload_context WHERE id = ?", (context_id,))
|
||||
db.commit()
|
||||
return None
|
||||
return dict(row)
|
||||
d = dict(row)
|
||||
d["content"] = decrypt_text(d["content"])
|
||||
return d
|
||||
|
||||
|
||||
def init_db():
|
||||
|
||||
@@ -265,7 +265,7 @@ All streaming endpoints yield `data: {json}\n\n`:
|
||||
- No live external services required
|
||||
- Test factories reset `SESSIONS`, `PIN_ATTEMPTS`, `RATE_EVENTS` globals per test
|
||||
|
||||
### 8.2 Test Coverage Areas (198 tests)
|
||||
### 8.2 Test Coverage Areas (200 tests)
|
||||
|
||||
| Test file | Coverage |
|
||||
|-----------|----------|
|
||||
|
||||
@@ -6,11 +6,9 @@ Scope: Active roadmap items and backlog.
|
||||
|
||||
## Completed
|
||||
|
||||
- **B7 (v0.19.0)** — Apple Silicon worker support. gpu.py darwin branch, hardware.py darwin VRAM, node_agent/agent.py macOS VRAM reporting.
|
||||
- **B5 (v0.19.1)** — Default model auto-pull. model_pull.py + ensure_model() in app.py lifespan.
|
||||
- **B6 (v0.19.2)** — Waterfall direction toggle (NEW/OLD), scroll/lock fixes, toast notifications, execCopy, modelLabel, Ctrl+Enter.
|
||||
- **B8 (v0.19.3)** — Private Chat mode. Backend skip-DB/skip-RAG/skip-search flag, frontend PRIVATE badge, info popup.
|
||||
- **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.
|
||||
|
||||
## Backlog
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from crypto import encrypt_text, decrypt_text
|
||||
from db import get_db
|
||||
from config import MAX_MEMORY_FACT_CHARS
|
||||
|
||||
@@ -119,7 +120,7 @@ def add_memory(fact: str, topic: str = "general", source: str = "explicit") -> O
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
cur = db.execute(
|
||||
"INSERT INTO memories (fact, topic, source, created_at) VALUES (?, ?, ?, ?)",
|
||||
(fact, topic, source, now),
|
||||
(encrypt_text(fact), topic, source, now),
|
||||
)
|
||||
db.commit()
|
||||
rowid = cur.lastrowid
|
||||
@@ -131,31 +132,16 @@ def add_memory(fact: str, topic: str = "general", source: str = "explicit") -> O
|
||||
def search_memories(query: str, limit: int = 5) -> list:
|
||||
if not query.strip():
|
||||
return []
|
||||
db = get_db()
|
||||
words = re.findall(r"[A-Za-z0-9_]+", query)
|
||||
if not words:
|
||||
db.close()
|
||||
return []
|
||||
escaped = []
|
||||
for word in words[:10]:
|
||||
if word.upper() in {"AND", "OR", "NOT", "NEAR"}:
|
||||
escaped.append(f'"{word}"*')
|
||||
else:
|
||||
escaped.append(word + "*")
|
||||
safe_query = " OR ".join(escaped)
|
||||
try:
|
||||
rows = db.execute(
|
||||
"SELECT rowid, fact, topic, source, created_at, bm25(memories) AS rank "
|
||||
"FROM memories WHERE memories MATCH ? ORDER BY rank LIMIT ?",
|
||||
(safe_query, limit),
|
||||
).fetchall()
|
||||
results = [dict(row) for row in rows]
|
||||
log.debug(f"Memory search '{query}' returned {len(results)} results")
|
||||
except Exception as e:
|
||||
log.warning(f"Memory search error: {e}")
|
||||
results = []
|
||||
db.close()
|
||||
return results
|
||||
all_mems = get_all_memories()
|
||||
words = set(re.findall(r"[A-Za-z0-9_]+", query.lower()))
|
||||
scored = []
|
||||
for m in all_mems:
|
||||
fact_lower = m["fact"].lower()
|
||||
score = sum(1 for w in words if w in fact_lower)
|
||||
if score > 0:
|
||||
scored.append((score, m))
|
||||
scored.sort(key=lambda x: -x[0])
|
||||
return [m for _, m in scored[:limit]]
|
||||
|
||||
|
||||
def get_all_memories(topic: Optional[str] = None) -> list:
|
||||
@@ -167,7 +153,12 @@ def get_all_memories(topic: Optional[str] = None) -> list:
|
||||
else:
|
||||
rows = db.execute("SELECT rowid, * FROM memories ORDER BY created_at DESC").fetchall()
|
||||
db.close()
|
||||
return [dict(row) for row in rows]
|
||||
result = []
|
||||
for row in rows:
|
||||
d = dict(row)
|
||||
d["fact"] = decrypt_text(d["fact"])
|
||||
result.append(d)
|
||||
return result
|
||||
|
||||
|
||||
def delete_memory(rowid: int) -> bool:
|
||||
@@ -183,7 +174,7 @@ def delete_memory(rowid: int) -> bool:
|
||||
|
||||
def update_memory(rowid: int, fact: str) -> bool:
|
||||
db = get_db()
|
||||
cur = db.execute("UPDATE memories SET fact = ? WHERE rowid = ?", (fact, rowid))
|
||||
cur = db.execute("UPDATE memories SET fact = ? WHERE rowid = ?", (encrypt_text(fact), rowid))
|
||||
db.commit()
|
||||
updated = cur.rowcount > 0
|
||||
db.close()
|
||||
|
||||
@@ -7,6 +7,7 @@ from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
|
||||
from crypto import encrypt_text, decrypt_text
|
||||
from eviction import _update_retrieval_count
|
||||
from db import get_db, get_setting, list_skills_with_state, format_active_skills_prompt
|
||||
from memory import search_memories
|
||||
@@ -45,7 +46,7 @@ async def _upsert_fact(fact: str, text: str, topic: str,
|
||||
vector = er.json()["embedding"]
|
||||
pid = f"auto-{ts}-{i}"
|
||||
payload = {
|
||||
"text": chunk, "source": "auto_fact", "fact": fact,
|
||||
"text": encrypt_text(chunk), "source": "auto_fact", "fact": fact,
|
||||
"ingest_date": datetime.now(timezone.utc).isoformat(),
|
||||
"type": "auto_fact", "topic": topic,
|
||||
}
|
||||
@@ -189,7 +190,7 @@ async def build_system_prompt(db, extra_prompt: str = "", user_message: str = ""
|
||||
try:
|
||||
rag_results = await query_rag(user_message)
|
||||
if rag_results:
|
||||
rag_lines = [r["payload"]["text"] for r in rag_results if r["score"] > RAG_SCORE_THRESHOLD]
|
||||
rag_lines = [decrypt_text(r["payload"]["text"]) for r in rag_results if r["score"] > RAG_SCORE_THRESHOLD]
|
||||
if rag_lines:
|
||||
parts.append("## Retrieved Context\n" + "\n\n---\n\n".join(rag_lines))
|
||||
log.info(f"RAG injected {len(rag_lines)} chunks into context")
|
||||
|
||||
@@ -4,3 +4,4 @@ httpx>=0.27.0
|
||||
pypdf>=5.0.0
|
||||
python-multipart>=0.0.9
|
||||
aio-pika>=9.0.0
|
||||
cryptography>=44.0.0
|
||||
|
||||
+9
-5
@@ -10,6 +10,7 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from config import DEFAULT_MODEL, LLAMA_SERVER_BASE
|
||||
from crypto import encrypt_text, decrypt_text
|
||||
from db import get_db, get_upload_context
|
||||
from memory import process_remember_command, auto_detect_facts, check_fact_conflicts
|
||||
from rag import build_system_prompt, ingest_auto_fact
|
||||
@@ -109,17 +110,20 @@ async def chat(request: Request):
|
||||
conv_id = str(uuid.uuid4())
|
||||
title = user_message[:80] + ("..." if len(user_message) > 80 else "")
|
||||
db.execute("INSERT INTO conversations (id, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(conv_id, title, model, now, now))
|
||||
(conv_id, encrypt_text(title), model, now, now))
|
||||
else:
|
||||
db.execute("UPDATE conversations SET updated_at = ? WHERE id = ?", (now, conv_id))
|
||||
|
||||
db.execute("INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)",
|
||||
(conv_id, "user", user_message, now))
|
||||
(conv_id, "user", encrypt_text(user_message), now))
|
||||
db.commit()
|
||||
|
||||
history_rows = db.execute(
|
||||
raw_rows = db.execute(
|
||||
"SELECT role, content FROM messages WHERE conversation_id = ? ORDER BY id ASC", (conv_id,)
|
||||
).fetchall()
|
||||
history_rows = []
|
||||
for row in raw_rows:
|
||||
history_rows.append({"role": row["role"], "content": decrypt_text(row["content"])})
|
||||
extra_prompt = preset_prompt
|
||||
if upload_doc:
|
||||
extra_prompt = (extra_prompt + "\n\n" + upload_doc) if extra_prompt else upload_doc
|
||||
@@ -215,7 +219,7 @@ async def chat(request: Request):
|
||||
|
||||
db2 = get_db()
|
||||
db2.execute("INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)",
|
||||
(conv_id, "assistant", saved_msg, datetime.now(timezone.utc).isoformat()))
|
||||
(conv_id, "assistant", encrypt_text(saved_msg), datetime.now(timezone.utc).isoformat()))
|
||||
db2.commit()
|
||||
db2.close()
|
||||
|
||||
@@ -237,7 +241,7 @@ async def chat(request: Request):
|
||||
|
||||
db2 = get_db()
|
||||
db2.execute("INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)",
|
||||
(conv_id, "assistant", saved_msg, datetime.now(timezone.utc).isoformat()))
|
||||
(conv_id, "assistant", encrypt_text(saved_msg), datetime.now(timezone.utc).isoformat()))
|
||||
db2.commit()
|
||||
db2.close()
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse, JSONResponse
|
||||
|
||||
from config import DEFAULT_MODEL, LLAMA_SERVER_BASE, COMPLETIONS_API_KEY
|
||||
from crypto import encrypt_text
|
||||
from db import get_db
|
||||
from rag import build_system_prompt
|
||||
from routers.chat import parse_llama_stream_chunk
|
||||
@@ -124,7 +125,7 @@ async def chat_completions(request: Request):
|
||||
title = f"[IDE] {user_message[:72]}{'...' if len(user_message) > 72 else ''}"
|
||||
db.execute(
|
||||
"INSERT INTO conversations (id, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(conv_id, title, model, now, now),
|
||||
(conv_id, encrypt_text(title), model, now, now),
|
||||
)
|
||||
for msg in messages:
|
||||
role = msg.get("role")
|
||||
@@ -132,7 +133,7 @@ async def chat_completions(request: Request):
|
||||
if role in ("user", "assistant"):
|
||||
db.execute(
|
||||
"INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)",
|
||||
(conv_id, role, content, now),
|
||||
(conv_id, role, encrypt_text(content), now),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
@@ -195,7 +196,7 @@ async def _stream_chat(payload: dict, model: str, conv_id: str, request: Request
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)",
|
||||
(conv_id, "assistant", assistant_msg, datetime.now(timezone.utc).isoformat()),
|
||||
(conv_id, "assistant", encrypt_text(assistant_msg), datetime.now(timezone.utc).isoformat()),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
@@ -240,7 +241,7 @@ async def _blocking_chat(payload: dict, model: str, conv_id: str, request: Reque
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)",
|
||||
(conv_id, "assistant", assistant_msg, datetime.now(timezone.utc).isoformat()),
|
||||
(conv_id, "assistant", encrypt_text(assistant_msg), datetime.now(timezone.utc).isoformat()),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
@@ -4,6 +4,7 @@ import uuid
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from db import get_db
|
||||
from crypto import encrypt_text, decrypt_text
|
||||
from security import read_json_body, BODY_LIMIT_DEFAULT_BYTES
|
||||
from config import DEFAULT_MODEL, MAX_CONVERSATION_TITLE_CHARS
|
||||
|
||||
@@ -18,6 +19,7 @@ async def list_conversations():
|
||||
result = []
|
||||
for r in rows:
|
||||
c = dict(r)
|
||||
c["title"] = decrypt_text(c["title"])
|
||||
attach_count = db.execute(
|
||||
"SELECT COUNT(*) FROM upload_context WHERE conversation_id = ?", (c["id"],)
|
||||
).fetchone()[0]
|
||||
@@ -36,7 +38,7 @@ async def create_conversation(request: Request):
|
||||
title = str(body.get("title", "New Chat"))[:MAX_CONVERSATION_TITLE_CHARS]
|
||||
db = get_db()
|
||||
db.execute("INSERT INTO conversations (id, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(conv_id, title, model, now, now))
|
||||
(conv_id, encrypt_text(title), model, now, now))
|
||||
db.commit()
|
||||
db.close()
|
||||
return {"id": conv_id, "title": title, "model": model, "created_at": now, "updated_at": now}
|
||||
@@ -51,7 +53,14 @@ async def get_conversation(conv_id: str):
|
||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||
messages = db.execute("SELECT * FROM messages WHERE conversation_id = ? ORDER BY id ASC", (conv_id,)).fetchall()
|
||||
db.close()
|
||||
return {"conversation": dict(conv), "messages": [dict(m) for m in messages]}
|
||||
conv_dict = dict(conv)
|
||||
conv_dict["title"] = decrypt_text(conv_dict["title"])
|
||||
msg_list = []
|
||||
for m in messages:
|
||||
md = dict(m)
|
||||
md["content"] = decrypt_text(md["content"])
|
||||
msg_list.append(md)
|
||||
return {"conversation": conv_dict, "messages": msg_list}
|
||||
|
||||
|
||||
@router.put("/api/conversations/{conv_id}")
|
||||
@@ -61,7 +70,7 @@ async def update_conversation(conv_id: str, request: Request):
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
if "title" in body:
|
||||
db.execute("UPDATE conversations SET title = ?, updated_at = ? WHERE id = ?",
|
||||
(str(body["title"])[:MAX_CONVERSATION_TITLE_CHARS], now, conv_id))
|
||||
(encrypt_text(str(body["title"])[:MAX_CONVERSATION_TITLE_CHARS]), now, conv_id))
|
||||
if "model" in body:
|
||||
db.execute("UPDATE conversations SET model = ?, updated_at = ? WHERE id = ?",
|
||||
(body["model"], now, conv_id))
|
||||
|
||||
+2
-1
@@ -7,6 +7,7 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from config import COMPLETIONS_API_KEY
|
||||
from crypto import encrypt_text
|
||||
from eviction import maybe_evict
|
||||
from rag import chunk_text, QDRANT_URL, EMBED_URL, EMBED_MODEL, RAG_COLLECTION
|
||||
|
||||
@@ -50,7 +51,7 @@ async def ingest_content(request: Request):
|
||||
continue
|
||||
vector = embed_resp.json()["embedding"]
|
||||
point_id = f"ingest-{source}-{datetime.now(timezone.utc).timestamp()}-{i}"
|
||||
payload = {"text": chunk, "source": source, "ingest_date": datetime.now(timezone.utc).isoformat(), "type": "ingest"}
|
||||
payload = {"text": encrypt_text(chunk), "source": source, "ingest_date": datetime.now(timezone.utc).isoformat(), "type": "ingest"}
|
||||
payload.update(metadata)
|
||||
upsert_resp = await client.put(
|
||||
f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points?wait=true",
|
||||
|
||||
@@ -9,6 +9,7 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from config import DEFAULT_MODEL, LLAMA_SERVER_BASE, MAX_SEARCH_QUERY_CHARS
|
||||
from crypto import encrypt_text
|
||||
from db import get_db
|
||||
from search import query_searxng, format_search_results
|
||||
from routers.chat import parse_llama_stream_chunk
|
||||
@@ -41,12 +42,12 @@ async def explicit_search(request: Request):
|
||||
conv_id = str(uuid.uuid4())
|
||||
title = query[:70] + "..." if len(query) > 70 else query
|
||||
db.execute("INSERT INTO conversations (id, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(conv_id, title, model, now, now))
|
||||
(conv_id, encrypt_text(title), model, now, now))
|
||||
else:
|
||||
db.execute("UPDATE conversations SET updated_at = ? WHERE id = ?", (now, conv_id))
|
||||
|
||||
db.execute("INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)",
|
||||
(conv_id, "user", query, now))
|
||||
(conv_id, "user", encrypt_text(query), now))
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
@@ -60,7 +61,7 @@ async def explicit_search(request: Request):
|
||||
yield f"data: {json.dumps({'token': error_msg, 'conversation_id': conv_id})}\n\n"
|
||||
db2 = get_db()
|
||||
db2.execute("INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)",
|
||||
(conv_id, "assistant", error_msg, datetime.now(timezone.utc).isoformat()))
|
||||
(conv_id, "assistant", encrypt_text(error_msg), datetime.now(timezone.utc).isoformat()))
|
||||
db2.commit()
|
||||
db2.close()
|
||||
yield f"data: {json.dumps({'done': True, 'conversation_id': conv_id})}\n\n"
|
||||
@@ -102,7 +103,7 @@ async def explicit_search(request: Request):
|
||||
|
||||
db2 = get_db()
|
||||
db2.execute("INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)",
|
||||
(conv_id, "assistant", saved_msg, datetime.now(timezone.utc).isoformat()))
|
||||
(conv_id, "assistant", encrypt_text(saved_msg), datetime.now(timezone.utc).isoformat()))
|
||||
db2.commit()
|
||||
db2.close()
|
||||
|
||||
|
||||
+2
-1
@@ -9,6 +9,7 @@ from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Form
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from config import UPLOAD_DIR, MAX_UPLOAD_BYTES, SUPPORTED_UPLOAD_TYPES, UPLOAD_CONTEXT_EXPIRY_HOURS
|
||||
from crypto import encrypt_text
|
||||
from db import get_db, insert_upload_context, list_upload_context_by_conversation, delete_upload_context_by_id
|
||||
from eviction import maybe_evict
|
||||
from rag import chunk_text, QDRANT_URL, EMBED_URL, EMBED_MODEL, RAG_COLLECTION
|
||||
@@ -78,7 +79,7 @@ async def upload_file(
|
||||
"points": [{
|
||||
"id": pid,
|
||||
"vector": vector,
|
||||
"payload": {"text": chunk, "source": file.filename, "upload_date": datetime.now(timezone.utc).isoformat(), "type": "upload"},
|
||||
"payload": {"text": encrypt_text(chunk), "source": file.filename, "upload_date": datetime.now(timezone.utc).isoformat(), "type": "upload"},
|
||||
}]
|
||||
},
|
||||
timeout=30.0,
|
||||
|
||||
@@ -48,6 +48,7 @@ def test_upload_unsupported_mime(tmp_path: Path):
|
||||
|
||||
|
||||
def test_upload_context_mode(tmp_path: Path):
|
||||
from crypto import decrypt_text
|
||||
with make_client(tmp_path) as client:
|
||||
resp = client.post(
|
||||
"/api/upload", headers=_admin_headers(client),
|
||||
@@ -62,7 +63,7 @@ def test_upload_context_mode(tmp_path: Path):
|
||||
assert "chunks_ingested" not in data
|
||||
|
||||
row = db.get_db().execute("SELECT content FROM upload_context WHERE id = ?", (data["context_id"],)).fetchone()
|
||||
assert row["content"] == "Hello world notes"
|
||||
assert decrypt_text(row["content"]) == "Hello world notes"
|
||||
|
||||
|
||||
def test_upload_ingest_mode(tmp_path: Path, monkeypatch):
|
||||
|
||||
Reference in New Issue
Block a user