Compare commits

188 Commits

Author SHA1 Message Date
gramps e14ae2bd19 chore: jarvisChat → cAIc rename, single-node consolidation on jarvis, Qdrant UUID5 + chunk-size deploy fixes 2026-08-08 09:56:39 -07:00
gramps 44387919a8 v0.17.3: bug-fix maintenance pass — ingest origin exemption, FK safety, auto-search reset, deterministic ingest, WAL, config centralization, dead-code removal
- /api/ingest exempt from origin check for CLI/Bearer clients
- recreate deleted conversations on bogus conversation_id (chat + search)
- augmented auto-search emits reset:true; frontend clears first-pass text
- image uploads stored as placeholders, never text-ingested
- check_fact_conflicts requires shared subject keywords
- deterministic ingest point ids (md5 chunk hash)
- get_load() no longer crashes when rocm-smi yields no VRAM lines
- SQLite WAL + busy_timeout; timing-safe API key compares
- supervise fire-and-forget auto-ingest tasks; log missing logprobs
- centralize EMBED_URL/EMBED_MODEL/QDRANT_URL/NODE_NAME in config
- remove dead triage.py, select_node tests, is_state_changing, stray artifacts
- add regression tests + autouse global-reset conftest
2026-08-07 15:17:47 -07:00
gramps df405a156e Add TASK 2 (context-aware routing) and TASK 3 (RAM-based context store) 2026-08-04 08:31:47 -07:00
gramps aecd3330fd feat: image generation service — ComfyUI cluster integration
- POST /api/image/generate proxy endpoint (admin required)
- GET /api/image/status lists available image gen nodes
- Cluster AMQP protocol: cmd.image_generate, image_generated, image_failed
- Node agent auto-detects ComfyUI, registers image_gen capability
- ComfyUI workflow builder: CheckpointLoader → KSampler → VAEDecode → SaveImage
- Hardware probe checks ComfyUI reachability + checkpoint model list
- 27 tests covering cluster handlers, router, node agent, hardware, capabilities
- Config: CAIC_COMFYUI_BASE, CAIC_COMFYUI_TIMEOUT, comfyui_port in agent.ini
- Version bump to v1.1.0
- Documentation: ai.md, wiki/Developer-Architecture.md, current-wip.md, README.md, .env.example
2026-07-27 08:06:03 -07:00
gramps 576d9333b3 docs: migrate wiki links from Gitea to GitHub, add Default Model section 2026-07-19 17:25:31 -07:00
gramps 70014f8e3b fix: align ASCII architecture diagram box-drawing characters 2026-07-19 16:56:01 -07:00
gramps 5b5fbab206 README: add By the Numbers section — 237 commits, 4.5 months to v1.0 2026-07-19 16:53:25 -07:00
gramps 49f8a50c5b README rewrite: project pitch, condensed changelog, 108KB banner
- Rewrote README as a selling pitch for homelab AI cluster builders
- Highlighted differentiator: heterogeneous GPU query-routing vs layer-splitting
- Quick start Docker section, feature bullets, data safety table
- Condensed 600-line changelog into brief version entries
- Detailed docs linked to wiki (Installation, Architecture, Screenshots)
- Banner image: resized 1254x1254 PNG (3MB) → 600x600 JPEG (108KB)
2026-07-19 16:50:05 -07:00
gramps c14e3c19a9 v1.0.0: add v0.23.0 changelog entry for topbar redesign, teardown scripts, asyncio fix 2026-07-19 16:41:07 -07:00
gramps 056ebc399f v1.0.0: Docker containerization, docs, version bump
- config.py: VERSION → v1.0.0, DEFAULT_MODEL from CAIC_DEFAULT_MODEL env,
  HW_STATE_PATH from CAIC_HW_STATE_PATH env
- README.md: Docker install section (recommended), updated file structure,
  added What's New in v1.0.0, requirements note for Docker
- ai.md: Docker run path, work state updated, version to v1.0.0,
  external services table with Docker service names
- CLAUDE.md: Docker quick start, fixed dependencies note
- docker.md: checked off completed items in §10 checklist, updated §11
  file list with actual paths and status
- TASKS.md: struck through B3 [DONE]
2026-07-19 16:40:02 -07:00
gramps 55b9a2236d setup.sh: auto-download default model (Qwen2.5-7B Q4_K_M)
- Checks disk space before downloading
- Sets LLAMA_MODEL and CAIC_DEFAULT_MODEL in .env
- Prefers hf CLI, falls back to wget/curl
- Skippable prompt, manual fallback instructions
2026-07-19 16:30:01 -07:00
gramps 413f850e41 Make DEFAULT_MODEL env-var configurable (CAIC_DEFAULT_MODEL) 2026-07-19 16:28:19 -07:00
gramps 8fd6c99ccc Add Docker containerization stack
- Dockerfile: multi-stage Python 3.13-slim, healthcheck, uvicorn CMD
- docker-compose.yml: full stack (cAIc, SearXNG, Qdrant, RabbitMQ,
  llama-server, Ollama) with healthchecks, volumes, secrets
- .dockerignore: exclude venv, tests, .git, models, secrets
- .env.example: all variables documented with generation hints
- scripts/setup.sh: first-run scaffolding (generates .env, secrets,
  SearXNG config, directories)
- searxng-settings.yml.dist: SearXNG config template
- models/README.txt: instructions for placing .gguf files
- config.py: add HW_STATE_PATH env var (CAIC_HW_STATE_PATH)
- hardware.py: read state path from config instead of hardcoded CWD
- requirements.txt: add missing psutil and jinja2
2026-07-19 16:25:57 -07:00
gramps 54cca366a4 v0.22.1: dockerize defaults, harden error handling, fix test discovery
- config.py: defaults to localhost/localhost, AMQP to rabbitmq, secret
  path to /run/secrets, RAG_MAX_VECTORS from env
- rag.py: EMBED_URL default to localhost
- app.py: syslog handler wrapped in try/except
- routers/completions.py: db.close() in try/finally
- db.py: PRAGMA journal_mode = WAL
- amqp.py: subscription append before try for reconnect safety
- tests/conftest.py: add project root to sys.path for test discovery
2026-07-19 16:16:40 -07:00
gramps 666a237a8b docs: update Work State in ai.md with current session progress 2026-07-19 16:00:24 -07:00
gramps a99345edb6 chore: replace deprecated asyncio.ensure_future with create_task, add inline comments to db.py 2026-07-19 15:58:34 -07:00
gramps 2685a73897 fix: update README section text for consistency 2026-07-19 15:38:59 -07:00
gramps f5d020e39a feat: add uninstall/teardown scripts + uninstall section in README 2026-07-19 15:38:28 -07:00
gramps 6946619bc5 TASKS.md: mark B1, B2, B4 as done, B3 remains open 2026-07-14 15:40:55 -07:00
gramps 8202684df9 Restore deleted B4 spec text in TASKS.md 2026-07-14 15:39:23 -07:00
gramps 2a2de59344 TASKS.md: strike through completed B4 backlog entry 2026-07-14 15:37:37 -07:00
gramps c4a2bde8c1 Update TASKS.md with session 2026-07-14 entry 2026-07-14 15:35:21 -07:00
gramps 34cd87b810 Update work state docs for RAG bugfixes + topbar redesign 2026-07-14 15:33:59 -07:00
gramps b0554e69ec Move query bar back above chat, stats stays at bottom 2026-07-14 15:31:44 -07:00
gramps bb063204f0 Center stats bar 2026-07-14 15:31:18 -07:00
gramps 2609e4bd73 Topbar cleanup: move stats to bottom bar, toggles to hamburger menu, palette next to version, mobile-responsive 2026-07-14 15:30:10 -07:00
gramps 02d1b0432c Fix RAG admin: vectors_count->points_count, remove unindexed order_by, make RAG_COLLECTION env-configurable 2026-07-14 15:18:05 -07:00
gramps 0b4810ce31 RAG admin: clearer search label + hint explaining semantic vector search vs browse 2026-07-14 15:14:20 -07:00
gramps 13a00e544e RAG admin: better empty-state message for empty corpus vs no search match 2026-07-14 15:12:55 -07:00
gramps 065dc39c61 README: move architecture/docs content before changelog, keep all What's New entries sequential 2026-07-14 15:09:30 -07:00
gramps fe70314e83 Color theme system: 6 schemes with palette dropdown
- Palette icon in topbar-right opens dropdown: IBM Blue, Green Ln,
  Dark, Light, Amber, Trippin
- All CSS vars swapped dynamically via JS; persists in localStorage
- No CSS :root changes needed — existing variable architecture absorbs it
2026-07-14 15:03:15 -07:00
gramps 61f5a88673 B4 RAG Corpus Management UI + AGENTS.md → ai.md rename
- Backend: GET/DELETE/PATCH /api/rag/point/{id}, GET /api/rag/points
  (paginated, semantic search, source filter, sort)
- Frontend: admin-only RAG modal with stats bar, search, paginated table,
  per-row edit (re-embed) and delete, bulk flush with double confirm
- 14 new tests, 214 total passing
- AGENTS.md → ai.md (tool-agnostic), CLAUDE.md now references ai.md
- Bump v0.21.0 → v0.22.0
2026-07-14 14:57:57 -07:00
gramps 990b7c860d close B1 (context loss — test passed), deprecate B2 (Ctrl+Enter search), update backlog 2026-07-14 14:46:52 -07:00
gramps 7acea605ac v0.21.0 — perplexity persistence, env-overridable config, scroll/DOM fixes, single-node docs 2026-07-14 14:39:30 -07:00
gramps 3f64bed485 make QDRANT_URL, EMBED_URL, AMQP_SECRET_PATH env-overridable; document single-node/WSL deployment 2026-07-14 14:33:54 -07:00
gramps e584e4983c fix: capture appendMessage return value for correct assistant pairing 2026-07-14 14:24:02 -07:00
gramps 5c66a12b0f remove _userScrolledAway guard from oldes mode; keep rAF scroll 2026-07-14 14:21:29 -07:00
gramps 8ec4dae062 fix scroll: rAF for layout-safe scrollHeight, direction-aware _userScrolledAway 2026-07-14 14:17:57 -07:00
gramps 7d50d07249 persist perplexity per assistant message; display on loaded convos 2026-07-14 14:13:24 -07:00
gramps 65baeba1f1 scrollbar left of spool holes, widened to 10px 2026-07-14 14:03:47 -07:00
gramps 40495548e5 fix: chat-container z-index above .main::after barcode strip that hid scrollbar 2026-07-14 14:01:24 -07:00
gramps d79d8827c6 fix: scrollbar uses accent-dim blue thumb, visible on dark bg 2026-07-14 13:58:21 -07:00
gramps 01e2489846 fix: visible scrollbar (8px, track visible, Firefox compat) 2026-07-14 13:55:53 -07:00
gramps 3fccb0f2e7 rename NEW/OLD -> ↓SORT / ↑SORT for sort direction indicator 2026-07-14 13:49:20 -07:00
gramps af2f8fa90f fix: wider confidence scale + 80/15 thresholds (green/orange/red) 2026-07-14 13:47:19 -07:00
gramps 1f571f6284 fix: toggleDirection now reorders existing messages in DOM 2026-07-14 13:43:01 -07:00
gramps fbd734ccd4 fix: privacy badge reads PRIVACY ON / PRIVACY OFF (not bare PRIVACY) 2026-07-14 13:36:56 -07:00
gramps 8e800b57d1 fix: rename PRIVATE->PRIVACY, implement info popup overlay for privacy mode 2026-07-14 13:36:01 -07:00
gramps ac58531e8b fix: private badge initial text shows PRIVATE OFF (matches default state) 2026-07-14 13:32:25 -07:00
gramps c879c67e4c fix: always show WEB search button (enable/disable instead of hide/show) 2026-07-14 13:30:34 -07:00
gramps 30deb0db64 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
2026-07-14 13:22:32 -07:00
gramps f6b01ec9ac B8: Private Chat mode + WireGuard docs + README data safety section (v0.19.3) 2026-07-14 08:52:32 -07:00
gramps 4f16a9c078 B6: waterfall direction toggle (v0.19.2)
NEW/OLD button toggles between newest-first and oldest-first ordering.
scrollToTop() replaced by scrollToLatest() which checks direction.
appendMessage() uses prepend/append based on direction.
Preference persisted in localStorage.
2026-07-14 08:19:26 -07:00
gramps b3746949a9 B5: default model auto-pull on first start (v0.19.1)
model_pull.py: ensure_model() checks llama-server availability at
startup, falls back to Ollama pull API if model not found.
Integrated into app.py lifespan after assess_hardware().
11 tests cover all paths: available/unreachable/pull success/fail.
2026-07-14 08:16:08 -07:00
gramps 790c81457a B7: Apple Silicon worker support (v0.19.0)
gpu.py: darwin branch via system_profiler SPDisplaysDataType for GPU
model/VRAM on macOS, falls back to rocm-smi on Linux.
hardware.py: _get_vram_darwin() parses system_profiler output.
node_agent/agent.py: get_load() reports VRAM on darwin via
system_profiler.
tests: 5 new gpu tests (linux/darwin/absent), 3 new hardware tests
(darwin assessment + VRAM parsing).
2026-07-14 08:09:41 -07:00
gramps feaa2830da fix: auto-derive model shorthand from any model name following qwen2.5:7B:i convention 2026-07-13 16:40:58 -07:00
gramps 7ef220e579 feat: brand response avatar as cAIc instead of AI 2026-07-13 16:35:59 -07:00
gramps 868ef411ba feat: show model label in response header via MODEL_LABELS map 2026-07-13 16:35:40 -07:00
gramps 8853a3761e docs: add in-transit TLS note to B8 2026-07-13 16:28:58 -07:00
gramps 82beadf546 docs: prioritize Private Chat mode over encryption in B8 2026-07-13 16:27:34 -07:00
gramps ba3df91db9 docs: add B8 encryption/PHI backlog item, update session work state 2026-07-13 16:24:45 -07:00
gramps b5cc294ece chore: remove rating thumbs up/down (no backend, privacy concern) 2026-07-13 16:19:37 -07:00
gramps 151e02fe40 fix: response copy icon stays emoji, uses execCopy helper 2026-07-13 16:14:12 -07:00
gramps 98b51c0775 fix: clipboard fallback for HTTP, response copy toast now works 2026-07-13 16:12:17 -07:00
gramps ddcfd8a73f fix: use data-content attr to avoid HTML injection in onclick 2026-07-13 16:11:00 -07:00
gramps 92e8608ac7 fix: capture this ref in user copy icon onclick 2026-07-13 16:10:30 -07:00
gramps f07025a4e8 feat: toast notifications for all icon actions
- delete memory/preset/conversation/all/gallery/clear
- save/reset profile
- new chat, add preset, edit preset
- clear file selection
- rate up/down thumbs toasts
2026-07-13 16:08:32 -07:00
gramps 7105fe0d6d fix: toast on save success/failure, skip toast on print 2026-07-13 16:05:56 -07:00
gramps 6a10674cdf feat: slide-out toast notification on copy icon clicks 2026-07-13 16:05:32 -07:00
gramps 962003512f fix: inline copy icon at end of user query text, not a toolbar 2026-07-13 16:04:24 -07:00
gramps 6c214e46f5 fix: WEB text uses input-bg dark blue var(--bg-tertiary) 2026-07-13 15:59:07 -07:00
gramps 51795cf072 fix: WEB text dark blue #0d2137 on orange for contrast 2026-07-13 15:58:04 -07:00
gramps a9152b3060 fix: WEB text in blue, keep orange button background 2026-07-13 15:56:46 -07:00
gramps b86c67dbe8 fix: match WEB button color to SEND button for better contrast 2026-07-13 15:55:46 -07:00
gramps 9ed2c27159 fix: restore magnifying glass icon on WEB button 2026-07-13 15:55:01 -07:00
gramps a8d8822ab0 fix: WEB in all caps to match SEND button 2026-07-13 15:54:34 -07:00
gramps 4ee2a1613c fix: remove emoji from search button, was breaking monospace typeface 2026-07-13 15:54:01 -07:00
gramps 7c751294c1 fix: update input placeholder to match Ctrl+Enter binding 2026-07-13 15:52:57 -07:00
gramps ebb613b68c docs: update work state in AGENTS.md 2026-07-13 15:50:51 -07:00
gramps fbe02b1956 feat: add copy/toolbar to user messages
- msg-toolbar now rendered for all roles, not just assistant
- addCopyButtons and addMessageToolbar applied to any message with content
2026-07-13 15:49:52 -07:00
gramps cb3c33aa57 fix: match search-btn height to send-btn, add 'web' label
- search-btn now uses same font/font-size/font-weight as send-btn
- label is 'web 🔍' so the purpose is obvious
2026-07-13 15:48:19 -07:00
gramps 934f85e178 fix: use Ctrl+Enter for web search, not Shift+Enter
Shift+Enter is the universal convention for inserting a newline.
Ctrl+Enter is the convention for alternate submit (Gmail, Slack, etc.).
2026-07-13 15:46:42 -07:00
gramps 2ecfd1663d fix: reset cumulative token count on page refresh
- Remove localStorage persistence for cumulativeTokens (starts at 0 each load)
2026-07-13 15:42:47 -07:00
gramps b8713a516b fix: scroll-fighting, 401 errors, session timeout 90->3600s
- scrollToTop() now respects user scroll position (100px threshold)
- resetScrollLock() called on new messages
- SESSION_TIMEOUT_SECONDS 90 -> 3600 (1 hour) to prevent mid-use expiry
- wrap 10 unprotected authFetch calls in try/catch to kill unhandled rejections
2026-07-13 15:39:44 -07:00
gramps e9700137df docs: add Paired Programming subsection 2026-07-13 11:23:56 -07:00
gramps 8a18714886 fix wiki links to use actual wiki URLs 2026-07-13 11:15:53 -07:00
gramps f9b7ad0379 docs: link FAQ from README 2026-07-13 11:15:14 -07:00
gramps f44804307f add readme banner image 2026-07-13 11:15:04 -07:00
gramps 5ab4183d58 docs: contrast query-routing vs layer-splitting in architecture section 2026-07-13 11:08:59 -07:00
gramps aafc24ee27 bump to v0.18.0 — wiki docs, UX polish (waterfall, barcode, confidence badges, sprocket strips) 2026-07-13 10:49:33 -07:00
gramps 44c84bfc85 docs: bump README version to v0.17.26 2026-07-13 10:27:26 -07:00
gramps d60a54780b docs: clarify advantage — pool hardware, route by strength, each machine contributes what it does best 2026-07-13 10:25:14 -07:00
gramps 2ef8f91f4c docs: expand justification — industry consensus assumes homogeneous clusters, cAIc targets the real-world heterogeneous case 2026-07-13 10:24:06 -07:00
gramps 409d07cc8b docs: add Architecture CPU Coordinator + GPU Workers section to README 2026-07-13 10:23:09 -07:00
gramps ea2dbee45d v0.17.26: brighter sprocket strips using --bg-tertiary, 5px holes, 24px strips 2026-07-13 10:20:51 -07:00
gramps 8a677074ec v0.17.25: timestamp format MON dd, YYYY HH:MM:SS.ss 2026-07-13 10:19:09 -07:00
gramps 3e7ed57db9 v0.17.24: fix sprocket holes — fill with --bg-primary instead of transparent so they show through to page 2026-07-13 10:17:49 -07:00
gramps d8e8af0fb0 v0.17.23: TOK metric reformatted to #/% with context-pct color coding 2026-07-13 10:16:30 -07:00
gramps bc25aaad80 v0.17.22: stop typing indicator animation on abort — greyed-out stopped state 2026-07-13 10:12:07 -07:00
gramps 4443c8064f v0.17.21: dot-matrix sprocket holes on outer edges + paper grain background 2026-07-13 10:11:01 -07:00
gramps 8fdeca0e3c v0.17.20: confidence % badge (replaces perplexity), color-coded 80-20 rule; cumulative token counter in topbar 2026-07-13 10:09:05 -07:00
gramps 0981017cde docs: add B4 (RAG Corpus Management UI) to backlog in TASKS.md and current-wip.md 2026-07-13 10:03:57 -07:00
gramps 645d7e9d91 v0.17.19: fix token count badge — use client-side tokenCount instead of server completion_tokens; add tok badge to search responses 2026-07-13 09:59:11 -07:00
gramps 79b684e0cd v0.17.18: barcode-style alternating pairs — each Q&A wrapped in .msg-pair with alternating tint + left border accent 2026-07-13 09:56:31 -07:00
gramps b90b0a6d14 v0.17.17: timestamps on user messages, restore thumbs on AI (not web search), Shift+Enter triggers web search 2026-07-13 09:49:00 -07:00
gramps 4d337d9684 v0.17.16: remove thumbs from toolbar, user msg atop waterfall, enlarge topbar fonts 2026-07-13 09:45:06 -07:00
gramps 35cd48098a v0.17.15: waterfall display — newest messages at top, prepend instead of append, scroll to top 2026-07-13 09:41:55 -07:00
gramps bda4089120 v0.17.14: remove status dots from input area — served no purpose 2026-07-13 09:38:33 -07:00
gramps 79762f67f2 v0.17.13: left-align status dots, font 9px 2026-07-13 09:35:58 -07:00
gramps 3d515ed11f v0.17.12: stack status dots vertically in input area 2026-07-13 09:34:44 -07:00
gramps e1530499b7 v0.17.11: color-code DRC values (green/orange/red), add status dot labels, fix version prefix 2026-07-13 09:33:05 -07:00
gramps a35d68b38e v0.17.10: move CPU/MEM/GPU/VRAM stats to topbar center; status dots to input row left of paperclip 2026-07-13 09:29:21 -07:00
gramps 2d28cfe384 v0.17.9: use uploaded image as welcome screen background 2026-07-13 09:23:51 -07:00
gramps ce4e796768 v0.17.8: show jcscreenie.png as welcome screen graphic instead of text 2026-07-13 09:20:14 -07:00
gramps 3294231e64 v0.17.7: lighten text colors for legibility across header, input, welcome screen 2026-07-13 09:18:36 -07:00
gramps fc2f209008 v0.17.6: add orange search button right of SEND, shown only when SearXNG available 2026-07-13 09:16:34 -07:00
gramps 3601d31e03 v0.17.5: fix topbar legibility, remove llama icons/preset/search from input, implement toggleDrawer 2026-07-13 09:12:37 -07:00
gramps 19d099f17a v0.17.4: remove redundant context thermometer; default to qwen2.5-7b-instruct 2026-07-13 09:01:08 -07:00
gramps e7e6cd8af6 v0.17.3: remove toolbar opacity entirely — always visible 2026-07-13 08:29:08 -07:00
gramps 429d4df29f docs: rewrite README intro to lead with heterogeneous GPU clustering as primary project thesis 2026-07-13 08:26:50 -07:00
gramps cbe4a361bb v0.17.2: auto-fact detection with conflict-alert flow
- auto_detect_facts() scans chat turns for factual content (IPs, paths,
  services, config changes, hardware refs) using pattern matching
- check_fact_conflicts() cross-references detected facts against stored
  FTS5 memories — when a contradiction exists (same topic, diff value)
  the system surfaces a rag_update_suggestion in the done SSE payload
- Frontend shows a floating notification banner comparing old vs new
  fact with Update/Dismiss buttons
- confirm_fact_update() replaces the memory + re-embeds/re-indexes
  the Qdraft entry on user confirmation
- Silent auto-ingest (memories + Qdrant) when no conflict exists
- Frontend: msg-toolbar opacity 0→0.35 for visibility
2026-07-13 08:25:08 -07:00
gramps dcb73945e0 v0.17.1: fix msg-toolbar visibility, add follow_redirects to SearXNG query 2026-07-13 08:15:57 -07:00
gramps f16bef4671 tok: #/## % badge with context tracking, client-side token counting, remove triage dep 2026-07-13 08:07:26 -07:00
gramps b6dadd95ec fix: client-side token counting + move tok badge after ttr 2026-07-13 08:03:08 -07:00
gramps 6f95cc67bb fix: replace triage import with LLAMA_SERVER_BASE for deployed compat 2026-07-13 07:59:38 -07:00
gramps 12a7d92f99 add TOK badge to response footer + arrow key history recall 2026-07-13 07:57:27 -07:00
gramps 35af444c88 fix: add /fim/completions route for Continue compatibility (no /v1 prefix) 2026-07-09 10:13:58 -07:00
gramps 2ec9584abe feat: add /v1/fim/completions route with Qwen FIM token formatting for Continue 2026-07-09 10:09:18 -07:00
gramps d0e1870438 fix: add /v1/completions route for Continue FIM compatibility 2026-07-09 10:01:21 -07:00
gramps 9bf04d921d fix: route FIM passthrough to llama-server native /completion endpoint (supports suffix param) 2026-07-09 09:59:04 -07:00
gramps fbacb1861d docs: sync all docs through v0.17.0 — Roadmap N complete, cluster/AMQP fully documented 2026-07-09 09:15:08 -07:00
gramps f0689ac12b feat: Roadmap N — AMQP cluster nervous system complete 2026-07-09 09:09:22 -07:00
gramps 7c022dbc6d docs: restore full Task 14 spec text in TASKS.md 2026-07-09 09:06:27 -07:00
gramps 7fc7f7679c docs: mark Task 14 done, add cluster.py/amqp.py/test_model_swap.py to docs tables 2026-07-09 09:05:23 -07:00
gramps 9d1fd44d7f Task 14: coordinator-side model swap flow
- request_model_swap() publishes cmd.swap_model on jc.admin, sets node status to swapping
- handle_model_ready() updates active_model from inventory, restores active status
- handle_model_failed() sets node status to error with failure detail
- select_node() made async with two-pass logic: find ready node or trigger swap
- inventory field stored in node records for swap target lookup
- Both handlers subscribed on jc.system via SUBSCRIBE_TABLE
2026-07-09 09:04:15 -07:00
gramps fb0ff576d3 Tasks 12 + 13: worker node agent + Phi-4-mini query triage
Task 12 — node_agent/agent.py: standalone AMQP worker agent
  config reader, model discovery, registration publisher,
  ping/pong handler, model swap with systemctl + health poll
  (14 tests)

Task 13 — triage.py: query classification + cluster node selection
  classify_query() routes to Phi-4-mini at :8083
  select_node() picks best worker by model affinity
  get_inference_url() replaces hardcoded LLAMA_SERVER_BASE
  cluster.py stores node ip for URL construction
  (6 tests + 5 existing chat tests mocked for triage)

168 tests passing (+20 new)
2026-07-07 07:30:30 -07:00
gramps 90d2cf8326 Rename: jarvisChat → cAIc (product name)
- jarvisChat/JarvisChat/jarvischat → cAIc/cAIc/caic (branded/lower)
- JARVISCHAT_ env vars → CAIC_
- jc- script/config prefix → caic-
- jarvis_rag → caic_rag
- jarvischat.db / volumes → caic.db / caic_*
- AMQP vhost/user jarvischat → caic
- Syslog, loggers, docstrings all updated
- 47 files, zero stale references, 148 tests pass
2026-07-06 19:56:32 -07:00
gramps 94e1cdae11 docs(Task 12): replace heartbeat with ping/pong, add subsection markers 2026-07-06 19:29:48 -07:00
gramps 54cf152bd3 docs: mark Task 11 done in TASKS.md 2026-07-06 19:25:54 -07:00
gramps 899988c09b docs: bump v0.13.0 → v0.14.0, document cluster protocol (Task 11) 2026-07-06 19:24:47 -07:00
gramps 78f7b79494 Task 11: cluster protocol — subscribe(), ping/pong health, 9 message types
- amqp.py: subscribe() creates exclusive queues bound to routing keys,
  _rebind_subscriptions() recreates them after reconnect
- cluster.py: CLUSTER_NODES, CLUSTER_EVENTS (bounded 1000),
  CLUSTER_COORDINATOR with auto-promotion, all 6 handlers
  (register, deregister, pong, event, coordinator_query),
  ping_node() with 5s timeout + auto-deregister on failure
- routers/cluster.py: GET /api/cluster
- Wired into app.py lifespan after amqp_connect()
- 13 tests covering all handlers, boundaries, and API shape
- No passive heartbeats — workers assumed present until ping
  timeout at work-routing time
2026-07-06 19:22:31 -07:00
gramps 454fb3a380 docs: remove hb_query — worker presence is binary, passive heartbeats suffice 2026-07-06 19:13:30 -07:00
gramps 94c9c9d8c0 docs: final review fixes — count, ModelRecord alignment, event payload, subscribe re-binding 2026-07-06 19:12:02 -07:00
gramps 46adecfce0 docs: document data isolation rationale for two-channel split 2026-07-06 19:06:05 -07:00
gramps bb40748d95 docs: fold model category back into cluster — two buckets only 2026-07-06 19:04:28 -07:00
gramps 75202735e1 docs: collapse event types to three categories (cluster/model/application) 2026-07-06 19:01:20 -07:00
gramps f0819175f8 docs: simplify Task 11 — register/deregister drive status transitions; events are side effects 2026-07-06 18:59:33 -07:00
gramps 649f37e2b6 docs: expand Task 11 with full cluster protocol (7 message types, event log, coordinator auto-promotion) 2026-07-06 18:56:36 -07:00
gramps 8bbc836c3e docs: remove snark from README 2026-07-06 09:00:03 -07:00
gramps fcee454a32 docs: update README for v0.13.0 — RAG eviction, RabbitMQ, AMQP layer 2026-07-06 08:55:47 -07:00
gramps 975e7579cf feat: Task 10 — AMQP connection layer with aio-pika
amqp.py: connect/disconnect/get_channel/publish with auto-reconnect
  - Graceful degradation when aio-pika not installed
  - Lazy secret file reader via config.get_amqp_url()
  - Fire-and-forget publish (logs error, never raises)
  - Connection errors caught and logged (non-fatal)

config.py: AMQP_RECONNECT_DELAY, exchanges, get_amqp_url() helper

app.py: connect in lifespan after assess_hardware, disconnect on shutdown

requirements.txt: aio-pika>=9.0.0

tests/test_amqp.py: 3 mocked tests (publish success, publish disconnected
no-raise, get_channel reconnect)

135 tests pass (132 existing + 3 new)
Fixes: AGENTS.md test/run commands (venv was incomplete)
2026-07-06 08:51:36 -07:00
gramps 659339cb1f docs: document broker-mediated cluster architecture, coordinator vs worker node types
Developer-Architecture.md (§6):
  - Broker-mediated design model as preferred architecture
  - Coordinator vs Worker node type table with full service requirements
  - Service distribution ASCII diagram
  - Workers connect as AMQP clients only (no local broker needed)
  - Contrasted with service-mesh alternative

docker.md (§9):
  - New Worker Node Deployment Model section
  - Worker requirements: llama-server binary + node_agent.py + aio-pika
  - Explicit table of what workers do NOT run
  - Architecture note: broker-mediated vs service-mesh
  - Ref: AMQP-0-9-1 client-server protocol since 2006
2026-07-06 08:44:25 -07:00
gramps d1676ea73a docs: sync wiki with current architecture and roadmap
Developer-Architecture.md:
  - Module layout table (eviction.py, amqp.py, routers/)
  - External services table with ports + purposes
  - Config discovery / env var mapping
  - Chat pipeline includes upload_context injection + logprobs/perplexity
  - RAG eviction engine design (score formula, hysteresis, pinned sources)
  - AMQP cluster architecture overview (jc.admin / jc.system exchanges)
  - SSE protocol reference
  - Full test coverage table (132 tests across 18 files)
  - Hardware self-assessment section

current-wip.md:
  - Replace stale backlog with active Roadmap N table (Tasks 8-15)
  - Add post-Roadmap-N backlog (B1-B3)
2026-07-06 08:21:44 -07:00
gramps 775ad5d06e docs: mark Task 9 complete — RabbitMQ installed on ultron 2026-07-06 08:19:54 -07:00
gramps 2417659097 docs: mark Task 8 complete in TASKS.md 2026-07-06 08:10:59 -07:00
gramps 191ac2603f docs: add docker.md — full Docker distribution architecture plan
Covers all six services (jarvisChat, SearXNG, Qdrant, RabbitMQ,
llama-server, Ollama) with images, ports, volumes, healthchecks,
env mapping, secrets management, setup wizard spec, and back-out
procedure. Includes pre-v1.0 gate checklist and open decisions.

Also add .env, secrets/, searxng/ to .gitignore (these are
generated by setup.sh and contain secrets).
2026-07-06 08:09:40 -07:00
gramps 1dcd79ef96 test: add maybe_evict all-pinned break test and stats admin check
- test_maybe_evict_all_pinned_breaks: above high water but only
  pinned points → eviction breaks with 0 deleted, no EVICTION_LOG entry
- test_rag_stats_requires_admin: guest gets 403 on /api/rag/stats
2026-07-06 08:06:06 -07:00
gramps cb7a6c5cb5 fix: address three gaps in Task 8 spec compliance
- get_rag_operational_stats() now returns at_risk_count,
  pinned_count, avg_retrieval_count via scroll-based computation
- GET /api/rag/stats requires admin role (was guest-accessible)
- test_rag_stats_endpoint asserts full shape per spec
- Add test_rag_stats_requires_admin (guest → 403)
2026-07-06 08:05:14 -07:00
gramps 36e310e646 fix: add boot-time RAG eviction config validation
Log warnings during startup when high_water <= low_water,
batch <= 0, or max_vectors <= 0.
2026-07-06 08:02:41 -07:00
gramps bb16cd6927 refactor: extract eviction engine into eviction.py (rag.py 303→109 lines)
- Move all eviction logic (evict_batch, maybe_evict, EVICTION_LOG,
  get_collection_count/stats, get_rag_operational_stats) into eviction.py
- Move QDRANT_URL, RAG_COLLECTION into config.py to break circular dep
- rag.py re-exports eviction symbols for backward compatibility
- Router imports updated to use eviction module directly
- All 130 tests pass
2026-07-06 08:00:26 -07:00
gramps 8072fb3dd0 feat: Roadmap K — RAG corpus management with score-based eviction (v0.13.0)
- Config: RAG_MAX_VECTORS, high/low water marks, grace period, weights
- rag.py: get_collection_count, evict_batch, maybe_evict (asyncio.Lock),
  get_rag_operational_stats, EVICTION_LOG, retrieval_count tracking
- routers/rag_admin.py: GET /api/rag/stats, POST /api/rag/flush (admin)
- Wire maybe_evict() into upload.py and ingest.py after Qdrant upsert
- 16 tests: collection stats, eviction scoring, pinned/grace/batch guards,
  endpoint auth, race lock, flush, operational stats shape
- Bump to v0.13.0
2026-07-06 07:56:09 -07:00
gramps 133cca2551 docs: note RAG_MAX_VECTORS auto-calc in B3 setup wizard 2026-07-06 07:49:30 -07:00
gramps 1333963edc docs: add B3 Docker distribution task (v1.0 gate) 2026-07-05 15:28:46 -07:00
gramps 3f043d7bdf docs: note Docker-optional in README opener 2026-07-05 15:28:14 -07:00
gramps f14875a3a0 docs: remove anti-Docker sentiment, add v1.0 Docker distribution plan 2026-07-05 15:27:31 -07:00
gramps 7d2f392231 docs: strikethrough completed Tasks 1-7 in TASKS.md 2026-07-05 15:23:31 -07:00
gramps eb86cbd039 docs: expand Task 8 spec — score-based eviction, hysteresis, operational stats, flush, edge cases 2026-07-05 15:21:35 -07:00
gramps c1031ecd3e docs: mark Tasks 1,3,7 as done in TASKS.md 2026-07-05 15:01:04 -07:00
gramps be8ce3bd86 docs: add B1 (context loss) and B2 (bang search) to backlog 2026-07-05 09:01:01 -07:00
gramps 43cb60a8f5 v0.12.0: chat reply tool bar (copy, print, save, rate)
- Adds .msg-toolbar to each assistant reply after streaming completes
- Copy: copies full response text to clipboard
- Print: opens print-friendly window with formatted response
- Save: downloads response as .md file
- Rate: thumbs up/down toggle (local only, no backend)
- Toolbar fades in on message hover
- Also wired into search-result replies and loaded history
2026-07-04 13:20:04 -07:00
gramps 3a557ee081 chore: gitignore hardware_state.json (runtime artifact) 2026-07-03 12:53:49 -07:00
gramps 7291b8fc42 v0.11.0 -> v0.12.0: startup hardware self-assessment (Roadmap J)
- hardware.py: assess_hardware() probes RAM, CPU, GPU VRAM (rocm-smi),
  llama-server, Qdrant, SearXNG reachability — writes hardware_state.json
- routers/hardware.py: GET /api/hardware (no auth) returns snapshot
- app.py: calls assess_hardware() in lifespan after init_db()
- 4 new tests: all services reachable, rocm-smi absent, llama unreachable,
  HTTP endpoint
2026-07-03 12:53:34 -07:00
gramps 779d606923 chore: re-version to v0.11.0 (pre-release until public release) 2026-07-03 12:49:31 -07:00
gramps 45363e8bd6 docs: liven up README opener 2026-07-03 12:46:59 -07:00
gramps 3f75dc30d6 docs: sync README version and feature list with v1.11.0 2026-07-03 12:39:55 -07:00
gramps 3fd8b01353 docs: update AGENTS.md, CLAUDE.md, TASKS.md for Tasks 4-6 completion 2026-07-03 12:38:48 -07:00
gramps 1ac21ad13f v1.10.0 -> v1.11.0: terminal command RAG hook (Roadmap I)
- POST /api/ingest with Bearer token auth, chunk_text, embed, Qdrant upsert
- docs/jc-ingest.sh — shell script for PROMPT_COMMAND hook on jarvis
- /api/ingest exempted from session middleware (self-authenticating)
- 5 tests: missing/wrong key, empty/missing content, success path
2026-07-03 12:35:21 -07:00
gramps 04fbe90f08 add python-multipart dependency for UploadFile support 2026-07-03 12:20:08 -07:00
gramps 81238c0d7f v1.9.0 -> v1.10.0: file upload UI + attachment management
- Paperclip icon left of text input, file preview pill (image thumb or file icon)
- Conversation list shows attachment icon right of trash, opens gallery overlay
- Gallery overlay: scrollable, close (X), delete attachment per item
- DELETE /api/upload/{id} removes from SQLite + Qdrant
- PATCH /api/upload/{id}/link ties upload to conversation
- GET /api/upload/by-conversation/{id} lists attachments
- Chat accepts upload_context_id, injects [ATTACHED DOCUMENT] into system prompt
- Conversation list includes attachment_count field
- 8 new tests: upload context injection, delete, link, by-conversation, image type, attachment count

Still missing (P3): drag-and-drop upload, global attachments page, file download, batch upload
2026-07-03 12:18:06 -07:00
gramps 4a891c8435 v1.8.9 -> v1.9.0: file upload backend (PDF/text, Qdrant ingest, SQLite context) 2026-07-01 18:15:23 -07:00
gramps 239a0d5fa9 add pypdf dependency for PDF upload support 2026-07-01 18:10:12 -07:00
gramps 1d1cb61264 add P2 item #17: bidirectional image I/O to backlog 2026-07-01 18:08:25 -07:00
gramps 8393497df5 add P2 item: HTTPS via Let's Encrypt to backlog 2026-07-01 18:02:41 -07:00
gramps 7651ea620c v1.8.8 -> v1.8.9: fix copy button with execCommand fallback for insecure context 2026-07-01 18:01:42 -07:00
gramps 04d885e9eb v1.8.7 -> v1.8.8: add TTR badge (time-to-respond) next to PPL 2026-07-01 17:56:28 -07:00
gramps 9ef306e133 v1.8.6 -> v1.8.7: add image paste guard with toast notification 2026-07-01 17:35:12 -07:00
gramps 6451f674bb v1.8.5 -> v1.8.6: bump patch for trash icon fix, model dropdown removal, preset default 2026-07-01 17:30:35 -07:00
gramps b52e120ba1 fix: restore trash icon, remove vestigial model dropdown, default preset to General Assistant 2026-07-01 17:28:38 -07:00
91 changed files with 11653 additions and 8432 deletions
+44
View File
@@ -0,0 +1,44 @@
# Version control
.git/
.gitignore
# Python
venv/
__pycache__/
*.pyc
*.pyo
.pytest_cache/
.mypy_cache/
# Databases and state (persisted via volumes)
*.db
hardware_state.json
# Secrets (generated by setup.sh)
.env
secrets/
searxng/
# IDE / editor
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Models (bind-mounted at runtime)
models/
# Documentation (not needed in image)
*.md
docs/
TASKS.md
docker.md
ai.md
CLAUDE.md
scripts/
tests/
+59
View File
@@ -0,0 +1,59 @@
# ─────────────────────────────────────────────────────────────
# cAIc — Environment configuration
# Copy to .env and fill in values before running docker compose.
# Secrets (lines marked 🔒) should be random — generate with:
# openssl rand -hex 20
# ─────────────────────────────────────────────────────────────
# ── Secrets ──────────────────────────────────────────────────
# 🔒 Admin PIN (4 digits, required for first login)
CAIC_ADMIN_PIN=
CAIC_ALLOW_DEFAULT_PIN=false
# 🔒 API key for IDE completions endpoint (auto-gen if empty)
CAIC_COMPLETIONS_API_KEY=
# 🔒 RabbitMQ password (must match secrets/rabbitmq_password.txt)
RABBITMQ_PASSWORD=
# 🔒 SearXNG session key
SEARXNG_SECRET_KEY=
# ── Service discovery (Docker service hostnames) ─────────────
LLAMA_SERVER_BASE=http://llama-server:8081
OLLAMA_BASE=http://ollama:11434
CAIC_SEARXNG_BASE=http://searxng:8080
CAIC_QDRANT_URL=http://qdrant:6333
CAIC_EMBED_URL=http://ollama:11434
CAIC_EMBED_MODEL=all-minilm:latest
CAIC_AMQP_URL=amqp://caic:${RABBITMQ_PASSWORD}@rabbitmq:5672/caic
# ── llama-server settings ────────────────────────────────────
# GGUF filename (must exist in ./models/)
LLAMA_MODEL=
# Model name the app presents to clients (must match a loaded model)
CAIC_DEFAULT_MODEL=qwen2.5-7b-instruct
LLAMA_CTX_SIZE=4096
LLAMA_N_GPU_LAYERS=0
LLAMA_RPC_ENDPOINTS=
# ── RAG / Qdrant ─────────────────────────────────────────────
CAIC_RAG_COLLECTION=caic_rag
CAIC_RAG_MAX_VECTORS=50000
# ── Network / security ───────────────────────────────────────
CAIC_ALLOWED_CIDRS=127.0.0.0/8,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
CAIC_TRUSTED_ORIGINS=
CAIC_TRUST_X_FORWARDED_FOR=false
# ── Port overrides (host-side publish) ───────────────────────
CAIC_EXPOSE_PORT=8080
SEARXNG_EXPOSE_PORT=8888
QDRANT_EXPOSE_PORT=6333
RABBITMQ_EXPOSE_PORT=5672
LLAMA_EXPOSE_PORT=8081
OLLAMA_EXPOSE_PORT=11434
# ── Image generation (ComfyUI on worker) ─────────────────────
CAIC_COMFYUI_BASE=http://localhost:8188
CAIC_COMFYUI_TIMEOUT=120
+4
View File
@@ -5,3 +5,7 @@ __pycache__/
venv/ venv/
readme.md- readme.md-
*.bak *.bak
hardware_state.json
.env
secrets/
searxng/
-114
View File
@@ -1,114 +0,0 @@
# JarvisChat — Agents Guide
## Run
```bash
./venv/bin/uvicorn app:app --host 0.0.0.0 --port 8080 --reload
```
## Tests
```bash
./venv/bin/python -m pytest tests/ -v
```
All tests use `tmp_path` fixtures + monkeypatched `httpx.AsyncClient.stream`. No external services needed. Test factories reset `SESSIONS`, `PIN_ATTEMPTS`, `RATE_EVENTS` globals — be careful not to let test state leak. After the modular refactor, tests import directly from the correct modules (`db`, `security`, `config`, `search`, `rag`, `memory`, `routers.*`) — not from the old monolithic `app` namespace.
Every router has a dedicated test file:
| File | Covers |
|------|--------|
| `test_auth_capabilities.py` | `auth.py` — guest/admin sessions, origin blocking, logout |
| `test_chat_streaming_and_memory_paths.py` | `routers/chat.py` — streaming, auto-search, remember/forget |
| `test_completions.py` | `routers/completions.py` — API key auth, FIM, streaming, blocking, errors |
| `test_conversations.py` | `routers/conversations.py` — full CRUD, guest admin enforcement |
| `test_memories.py` | `routers/memories.py` — edit, search, stats endpoints |
| `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_search_route.py` | `routers/search_route.py` — explicit search flow, no results, errors |
| `test_search_url_sanitization.py` | `search.py` URL sanitizer |
| `test_settings_allowlist.py` | `routers/settings.py` — allowlisted key enforcement |
| `test_skills_framework.py` | `routers/skills.py` — list, toggle, unknown skill, prompt injection |
| `test_ip_allowlist.py` | IP allowlist helper + middleware |
| `test_rate_and_payload_guardrails.py` | Rate limits + payload size enforcement |
| `test_error_envelopes.py` | Global exception handler + stream error incidents |
Modules that call `httpx.AsyncClient` (chat, completions, models, search_route)
are mocked via `monkeypatch.setattr` on `AsyncClient.stream`, `.get`, or `.post`.
CPU stats in `models.py` (`api/stats`) use real `psutil`; GPU stats are
monkeypatched via `routers.models.get_gpu_stats`.
## Architecture
Refactored from single-file (`app.py`) into modules under project root:
| File | Role |
|------|------|
| `app.py` | FastAPI app, middleware, router registration |
| `config.py` | Constants, env vars, rate/payload limits, built-in skills registry |
| `db.py` | SQLite schema, connection factory, settings helpers |
| `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 |
| `search.py` | SearXNG integration, perplexity scoring, refusal detection |
| `rag.py` | Qdrant vector search + system prompt assembly |
| `gpu.py` | AMD GPU stats via `rocm-smi` |
| `routers/` | One module per endpoint group (chat, search, skills, completions, etc.) |
### Entrypoint / API keys
- `app.py` line 148: `uvicorn.run(app, ...)` when called directly
- `config.py` line 14: `LLAMA_SERVER_BASE` defaults to `http://192.168.50.108:8081` — llama-server, **not** standard Ollama port, used by all model endpoints
- `config.py` line 17: `COMPLETIONS_API_KEY` read from `JARVISCHAT_COMPLETIONS_API_KEY` env var or auto-generates a random key — no longer a missing import
- `config.py` line 13: `OLLAMA_BASE` is legacy/unused — all endpoints now use `LLAMA_SERVER_BASE`
### Key flows
1. **`/api/chat`** → `process_remember_command()` intercepts "remember that..." / "forget about..." first → else `build_system_prompt()` (profile + FTS5 memory + Qdrant RAG + preset + skills) → stream from llama-server with `logprobs: true` → if perplexity > 15.0 OR `REFUSAL_PATTERNS` match, re-query with SearXNG results
2. **`/api/search`** → bypasses perplexity/refusal, queries SearXNG directly → summarizes via llama-server (no raw results leaked in SSE)
3. **`/v1/chat/completions`** → OpenAI-compatible for Continue.dev/IDE integration; FIM requests proxied without persistence
### Perplexity / auto-search
The upstream request includes `"logprobs": true`. `parse_llama_stream_chunk()` extracts per-token logprobs from each chunk's `choices[0].logprobs.content[].logprob`. The `all_logprobs` list is populated during streaming, so `calculate_perplexity()` and `is_uncertain()` work correctly — auto-search on high perplexity is no longer dead code.
### Auth / lockdown
- Guest session by default (`POST /api/auth/guest`), admin unlock via 4-digit PIN (`POST /api/auth/login`)
- Admin required for PUT/DELETE/PATCH + all POST except allowlist (`/api/chat`, `/api/search`, `/api/auth/*`)
- IP allowlist, rate limiting, origin checking, payload size limits — all enforced in `app.py` middleware
- Origin check applies to **all** `/api/` requests (not just state-changing methods); `origin_allowed()` returns `False` when both `Origin` and `Referer` headers are absent, closing CSRF read gap
- `JARVISCHAT_ADMIN_PIN` env var required on first boot (or `JARVISCHAT_ALLOW_DEFAULT_PIN=true`)
### Database
- SQLite at `jarvischat.db`, auto-created by `init_db()` on startup via FastAPI `lifespan`
- `get_db()` opens new connection per request (no pool). Close after use.
- FTS5 virtual table `memories` for full-text search with BM25 ranking. FTS5 operator keywords (`AND`, `OR`, `NOT`, `NEAR`) are double-quoted to prevent parse errors.
### External services
| Service | Required | Port |
|---------|----------|------|
| llama-server (OpenAI-compat API) | Yes | 8081 (ultron) or env `LLAMA_SERVER_BASE` |
| SearXNG | No | 8888 |
| wttr.in | No | weather shortcut bypasses SearXNG; curl UA for plain-text output |
| rocm-smi | No | AMD GPU stats |
| Qdrant | No | 6333 (ultron) — RAG vector search |
### Config quirks
- Rate limits and payload caps in `config.py` — tweak for testing by monkeypatching module attributes (note: patch `security.RL_*` not `config.RL_*` since `security` imports bindings separately)
- `ALLOWED_SETTINGS_KEYS` in `config.py` controls which keys the UI can write via `/api/settings`
- Settings table seeded with defaults (`profile_enabled`, `search_enabled`, `memory_enabled`, `skills_enabled`, `default_model`) — never overwritten by `init_db()`
- Profile table uses singleton row `id=1`
- RAG embedding requests go to `EMBED_URL` at `/api/embeddings` (separate Ollama instance)
### SSE Protocol
All streaming endpoints yield `data: {json}\n\n`. Key shapes:
- `{token, conversation_id}` — streaming token
- `{searching: true}` — web search triggered
- `{search_results: N}` — N results (no raw_results payload)
- `{done: true, perplexity, tokens_per_sec, searched?}` — terminal
- `{error: "...", error_key: "..."}` — error with incident key
+11 -56
View File
@@ -1,15 +1,19 @@
# 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 ```bash
# Docker (recommended)
scripts/setup.sh # first run: generates .env, secrets, pulls default model
docker compose up -d
# Development # Development
./venv/bin/uvicorn app:app --host 0.0.0.0 --port 8080 --reload ./venv/bin/uvicorn app:app --host 0.0.0.0 --port 8080 --reload
# Production (via systemd) # Production (via systemd)
sudo systemctl restart jarvischat sudo systemctl restart caic
# Direct run # Direct run
./venv/bin/python app.py ./venv/bin/python app.py
@@ -17,58 +21,9 @@ sudo systemctl restart jarvischat
## Dependencies ## Dependencies
Docker deployment: no manual pip install needed — the Dockerfile handles it.
```bash ```bash
./venv/bin/pip install -r requirements.txt ./venv/bin/pip install -r requirements.txt
# Also requires: psutil jinja2 python-multipart (not in requirements.txt) # Also requires: psutil jinja2 python-multipart pypdf
``` ```
## Architecture
Single-file FastAPI backend (`app.py`) + single-template frontend (`templates/index.html`). No build step. SQLite database auto-created at `jarvischat.db` on first run.
### Request Flow: `/api/chat`
1. User message saved to DB → conversation created if new
2. `build_system_prompt()` assembles: profile + FTS5 memory search results + preset prompt
3. Streamed to Ollama (`/api/chat`, `stream: true`, `logprobs: true`) via SSE
4. **Auto web search trigger**: if perplexity > 15.0 OR response matches `REFUSAL_PATTERNS`, re-queries Ollama with SearXNG results prepended to system prompt
5. Final response saved to DB; SSE `done` event sent with perplexity + tokens/sec
### Request Flow: `/api/search` (explicit search)
Bypasses perplexity/refusal detection entirely — queries SearXNG directly then asks Ollama to summarize with results as system context.
### 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 Ollama and returns a confirmation string. Topic auto-detection via keyword matching in `detect_topic()`.
### Key Constants (top of `app.py`)
- `OLLAMA_BASE``http://localhost:11434`
- `SEARXNG_BASE``http://localhost:8888`
- `PERPLEXITY_THRESHOLD``15.0` (controls auto-search sensitivity)
- `DEFAULT_MODEL``llama3.1:latest`
### External Services
- **Ollama** — required, must be running on port 11434
- **SearXNG** — optional, port 8888; `GET /api/search/status` probes availability
- **wttr.in** — weather shortcut in `query_searxng()`, bypasses SearXNG for weather queries
- **rocm-smi** — AMD GPU stats via subprocess; gracefully degrades if not available
### Database
`get_db()` opens a new connection per request (no connection pool). `init_db()` runs at startup via the FastAPI `lifespan` handler. The `profile` table uses a singleton row (`id = 1`). Default settings are seeded but never overwritten by `init_db()`.
### 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 event
### Deployment
Runs as systemd service under user `jarvischat`, working directory `/opt/jarvischat`. Logs via syslog (`journalctl -u jarvischat`).
+36
View File
@@ -0,0 +1,36 @@
# cAIc — FastAPI application
# Multi-stage build for smaller production image
# ── Stage 1: build ──────────────────────────────────────────
FROM python:3.13-slim-bookworm AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# ── Stage 2: runtime ────────────────────────────────────────
FROM python:3.13-slim-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /install /usr/local
WORKDIR /app
COPY . .
# Persist DB and uploads outside the code layer
ENV CAIC_DB_PATH=/app/data/caic.db \
CAIC_UPLOAD_DIR=/app/data/uploads \
CAIC_HOST=0.0.0.0 \
CAIC_PORT=8080 \
CAIC_SYSLOG_ADDRESS=""
RUN mkdir -p /app/data
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
CMD curl -fs http://localhost:8080/ || exit 1
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"]
+260 -214
View File
@@ -1,258 +1,148 @@
# jarvisChat v1.8.5 ![cAIc banner](static/readme-banner.png)
**A lightweight local inference coding companion with persistent memory, web search, and real-time system monitoring.** # cAIc v1.1.0
Built with FastAPI + SQLite + Jinja2. Runs on Python 3.13. No Docker required. **Cluster AI coordinator — heterogeneous GPU inference for homelab AI clusters.**
Developer wiki: [docs/wiki/Home.md](docs/wiki/Home.md) Your RX 6600 XT can't run a 14B model. Your RTX 5070 Ti can. Your old MacBook can run the small stuff. Alone, each box is limited. Together, they're a cluster — if the software lets them cooperate.
## What's New in v1.8.0 cAIc makes them cooperate.
- **Modular refactor completed** — single-file `app.py` split into `config.py`, `db.py`, `auth.py`, `security.py`, `memory.py`, `search.py`, `rag.py`, `gpu.py`, and `routers/` package ## The Problem
- **`COMPLETIONS_API_KEY`** — auto-generated secret key for the OpenAI-compatible endpoint, overridable via `JARVISCHAT_COMPLETIONS_API_KEY` env var
- **Perplexity auto-search fixed** — upstream request now sends `"logprobs": true`, `parse_llama_stream_chunk()` extracts per-token logprobs, so `calculate_perplexity()` and `is_uncertain()` work correctly (was dead code)
- **All `/api/models` endpoints** — now correctly target `LLAMA_SERVER_BASE` (llama-server on port 8081) instead of the old Ollama port; `/api/ps` uses `/v1/models` endpoint
- **RAG embedding endpoint fixed** — `EMBED_URL` changed from old server `:8081` to correct host/port `http://192.168.50.210:11434` (Ollama on new machine)
- **Error messages corrected** — all user-facing errors say "inference server" instead of "Ollama" or "llama-server"
- **Secure SSE protocol** — raw search results are no longer leaked in the SSE event stream
- **FTS5 query safety** — operator keywords (`AND`, `OR`, `NOT`, `NEAR`) are double-quoted to prevent parse errors
- **All 8 test files fixed** — rewired imports after the modular refactor; all 26 tests pass
- **Origin check extended to all API methods** — GET/HEAD/OPTIONS requests no longer bypass origin checking (was limited to POST/PUT/DELETE/PATCH)
- **Missing headers now rejected** — `origin_allowed()` returns `False` when both `Origin` and `Referer` are absent, closing the CSRF read gap for script-initiated requests
- **Full router test coverage** — 7 new test files added: `test_conversations.py`, `test_presets.py`, `test_profile.py`, `test_models_router.py`, `test_completions.py`, `test_search_route.py`, `test_memories.py`; all 10 routers now have dedicated unit tests (92 total, up from 26)
## Features Every distributed inference tool — llama.cpp RPC, vLLM, exo — assumes you have identical GPUs. Same vendor, same VRAM, same drivers. That assumption works for data centers with 64 identical H100s. It doesn't work for your homelab with an AMD card in the server, an NVIDIA card in the gaming PC, and a MacBook on the desk.
- **Persistent Memory** — SQLite FTS5 full-text search for fast, relevant memory retrieval You have more aggregate compute than any single consumer machine. The software just can't see it that way.
- **Web Search** — SearXNG integration for automatic web lookups when the model is uncertain
- **Explicit Search** — Search button to force web search without waiting for model uncertainty
- **Profile Injection** — Custom system prompt injected into every conversation
- **System Presets** — Save and switch between different system prompts
- **Real-time Stats** — CPU, RAM, GPU, VRAM monitoring in sidebar
- **Token Thermometer** — Visual context window usage indicator
- **Streaming Responses** — Server-sent events for real-time token display
- **Conversation History** — SQLite-backed chat persistence with mass-delete option
- **Model Switching** — Change inference models on the fly
- **Skills Framework** — Built-in skill registry with per-skill enable/disable controls
## File Structure ## How cAIc Solves It
cAIc uses **query-routing** instead of layer-splitting. Each machine runs a complete model on its own GPU. When a query comes in, the coordinator classifies it and routes the *whole request* to the best-suited node — code questions to the coder model, general chat to the instruct model. No layer sharing, no straggler problem, no VRAM negotiation between mismatched GPUs.
``` ```
/opt/jarvischat/ ┌──────────────────────────────────────────────────────┐
├── app.py # FastAPI app entry point │ docker compose stack │
├── config.py # Constants, env vars, limits, skill registry │ │
├── db.py # SQLite schema, connection factory │ ┌──────────┐ ┌────────┐ ┌────────────────────┐ │
├── auth.py # PIN-based guest/admin sessions, auth routes │ │ SearXNG │ │ Qdrant │ │ RabbitMQ │ │
├── security.py # Rate limiting, origin checks, IP allowlist, audit │ │ :8888 │ │ :6333 │ │ :5672 / :15672 │ │
├── memory.py # FTS5 memory CRUD, remember/forget commands │ └────┬─────┘ └───┬────┘ └─────────┬──────────┘ │
├── search.py # SearXNG integration, perplexity, refusal detection │ │ │ │ │
├── rag.py # Qdrant vector search + system prompt assembly │ ▼ ▼ ▼ │
├── gpu.py # AMD GPU stats via rocm-smi │ ┌───────────────────────────────────────────────┐ │
├── routers/ │ │ cAIc (FastAPI) │ │
├── chat.py # /api/chat streaming endpoint │ :8080 (HTTP) │ │
├── search_route.py # /api/search explicit search endpoint └───────┬──────────────────┬────────────────────┘ │
├── completions.py # /v1/chat/completions OpenAI-compat endpoint │ │ │
├── conversations.py# Conversation CRUD ▼ ▼ │
├── memories.py # Memory CRUD API ┌────────────────┐ ┌────────────────┐ │
├── models.py # Model listing, system stats │ llama-server │ │ Ollama │ │
├── presets.py # System prompt presets │ :8081 │ │ :11434 │ │
├── profile.py # User profile │ (GPU/RPC) │ │ (embeddings) │ │
├── settings.py # Runtime settings └────────────────┘ └────────────────┘ │
│ └── skills.py # Skills management └──────────────────────────────────────────────────────┘
├── static/
│ └── logo.png # Logo image (optional)
├── templates/
│ └── index.html # Frontend
└── tests/ # 26 pytest tests
``` ```
## Requirements **Coordinator** (CPU-only, no GPU) handles the web UI, RAG embedding, query triage, web search, memory, conversation storage, and the message broker. Every CPU-bound task stays here so it never competes with inference for GPU resources.
- Python 3.11+ (tested on 3.13) **Workers** (discrete GPU) run only llama-server. No database, no browser sessions, no orchestration overhead. They register via AMQP, respond to health checks, and accept model-swap commands when the coordinator needs a different model for the current query.
- llama-server running locally or on network (OpenAI-compatible API on port 8081)
- SearXNG (optional, for web search)
## Installation A worker with a slow GPU still contributes — it handles less latency-sensitive queries or batch work while the fast GPU handles interactive chat.
### Fresh Install ## What You Get
- **Clustered inference** across mismatched GPUs and machines — AMD, NVIDIA, Apple Silicon, CPU-only
- **Automatic query routing** — triage classifies each query and routes to the best node
- **Dynamic model swapping** — coordinator requests model changes on workers when needed
- **RAG with auto-eviction** — Qdrant-backed vector search with score-based corpus management
- **Persistent memory** — FTS5-backed memory that learns your preferences over time
- **Web search** — SearXNG integration for automatic lookups when the model is uncertain
- **Private Chat mode** — toggle to keep nothing on disk: no memory, no RAG, no search, no persistence
- **At-rest encryption** — AES-256-GCM on all query-derived text in SQLite and Qdrant
- **IDE integration** — OpenAI-compatible `/v1/chat/completions` endpoint for Continue.dev and friends
- **OpenAI-compat FIM** — `/v1/fim/completions` for code completion
- **Image generation** — ComfyUI-backed image gen via cluster workers (Stable Diffusion / Flux)
- **6 color themes** — IBM Blue, Matrix, Dark, Light, Amber, Trippin
- **Docker-ready** — `docker compose up -d` and you're running
## By the Numbers
237 commits. 9,354 lines of Python. 214 tests. 95 files. One developer and an AI, March to July 2026.
cAIc went from initial commit to v1.0.0 in four and a half months. Every line of code was generated by Claude via opencode — but the architecture, test suite, deployment pipeline, and every feature decision were directed by a single developer with 40+ years of systems experience. The AI wrote the code; the human made it ship.
## Quick Start (Docker)
```bash ```bash
# Create directory and venv git clone https://github.com/mikeshallop/caic.git && cd caic
sudo mkdir -p /opt/jarvischat scripts/setup.sh # generates .env, secrets, pulls default model (~4.6GB)
sudo chown $USER:$USER /opt/jarvischat docker compose up -d # boots cAIc + Qdrant + RabbitMQ + SearXNG + llama-server + Ollama
cd /opt/jarvischat
python3 -m venv venv
# Install dependencies
./venv/bin/pip install fastapi uvicorn httpx psutil jinja2 python-multipart
# Set admin PIN before first startup (4 digits)
export JARVISCHAT_ADMIN_PIN=4827
# Create subdirectories
mkdir -p templates static
# Copy files
# (copy all .py files to /opt/jarvischat/)
# (copy routers/ directory to /opt/jarvischat/)
# (copy templates/index.html to /opt/jarvischat/templates/)
``` ```
WARNING: Do not use `1234` as your admin PIN unless you accept weak local security. The setup wizard auto-generates secrets, detects disk space, downloads a default model, and configures all service hostnames. Point a browser at `http://localhost:8080` and you're chatting.
NOTE: First boot requires `JARVISCHAT_ADMIN_PIN` unless you explicitly opt into insecure fallback with `JARVISCHAT_ALLOW_DEFAULT_PIN=true`. Requires: Docker Engine + Compose plugin. Place your own `.gguf` models in `./models/` for different sizes/vendors.
## Systemd Service ### Default Model
Create `/etc/systemd/system/jarvischat.service`: The setup wizard downloads **Qwen2.5-7B-Instruct** (Q4_K_M quantization, ~4.6 GB) as the default inference model.
```ini Why this model:
[Unit]
Description=jarvisChat - Local Inference Web Interface
After=network.target
[Service] - **Fits in 6 GB VRAM** — runs on mid-range GPUs (RX 6600 XT, RTX 3060, etc.) without offloading
Type=simple - **Instruction-tuned** — handles chat, code, and reasoning without fine-tuning
User=jarvischat - **Q4_K_M quantization** — best balance of quality and speed for consumer hardware; loses less than 1% accuracy vs. FP16 while fitting in half the VRAM
Group=jarvischat - **GGUF format** — runs natively in llama.cpp (the worker backend) with no conversion step
WorkingDirectory=/opt/jarvischat
ExecStart=/opt/jarvischat/venv/bin/uvicorn app:app --host 0.0.0.0 --port 8080
Restart=always
RestartSec=5
[Install] Swap it for any `.gguf` model you prefer. cAIc's query-routing works with whatever you put in `./models/` — the coordinator doesn't care which model runs where, as long as the workers can serve it.
WantedBy=multi-user.target
```
```bash → [Installation Guide](https://github.com/mikeshallop/caic/wiki/Installation) | [Configuration](https://github.com/mikeshallop/caic/wiki/Home) | [Bare-Metal Install](https://github.com/mikeshallop/caic/wiki/Installation)
sudo systemctl daemon-reload
sudo systemctl enable jarvischat
sudo systemctl start jarvischat
```
## Memory Commands ## Single-Node Mode
In chat, natural language triggers memory operations: cAIc also runs entirely on one machine — coordinator, llama-server, Qdrant, SearXNG, and RabbitMQ all on localhost. Useful for testing, laptops, or WSL2 under Windows 11.
| You say | What happens | All services degrade gracefully if unreachable. Only llama-server (inference) is strictly required.
|---------|--------------|
| "remember that I prefer Rust over Go" | Stores as `preference` |
| "remember that JarvisChat runs on port 8080" | Stores as `infrastructure` |
| "note that the deadline is Friday" | Stores as `general` |
| "forget about the deadline" | Removes matching memories |
Memories are automatically searched based on your message content and injected into the system prompt when relevant. ## Why Query-Routing?
### Memory Topics Most distributed inference splits a *single model* across GPUs — GPU 1 runs layers 015, GPU 2 runs 1631. That works with identical cards. With mixed hardware, the slowest GPU sets the pace for every forward pass.
Memories are auto-categorized: cAIc routes *whole queries* instead. Each worker runs a complete model. Triage picks the right worker. No layer sharing, no lockstep, no straggler dragging down the cluster.
- `preference` — likes, dislikes, choices
- `project` — active work, repos, tasks
- `infrastructure` — servers, services, configs
- `personal` — name, location, background
- `general` — everything else
## API Endpoints | | Layer-splitting | cAIc query-routing |
|---|---|---|
| **Hardware** | Identical GPUs required | Any mix — AMD, NVIDIA, Apple, CPU |
| **Bottleneck** | Slowest GPU per forward pass | None — each node runs independently |
| **Model swap** | N/A (one model split) | Async swap per worker |
| **Scale** | Add VRAM to one model | Add machines, each contributes fully |
### Completions (OpenAI-compatible) ## Data Safety
| Method | Endpoint | Description | | Concern | How cAIc handles it |
|--------|----------|-------------| |---------|---------------------|
| POST | `/v1/chat/completions` | OpenAI-compatible chat (requires Bearer API key) | | **Queries on disk?** | AES-256-GCM encrypted at rest. Private Chat mode = nothing touches disk at all. |
| **External services?** | SearXNG is optional and disabled in Private Chat. Everything else runs on your LAN. |
| **Inter-node traffic?** | WireGuard tunnels encrypt all coordinator↔worker traffic. Zero application changes. |
| **Access control?** | Guest sessions for LAN. Admin PIN (PBKDF2-hashed, rate-limited). Optional IP allowlist. |
### Chat & Search ## Built With
| Method | Endpoint | Description | FastAPI + SQLite + Jinja2 on Python 3.13. AMQP-mediated cluster coordination via aio-pika. Qdrant for vector search. OpenAI-compatible inference endpoint via llama.cpp server.
|--------|----------|-------------|
| POST | `/api/chat` | Send message (streaming SSE) |
| POST | `/api/search` | Explicit web search (streaming SSE) |
### Memory 214 tests. All use `tmp_path` fixtures + monkeypatched HTTP clients. No external services needed.
| Method | Endpoint | Description | ## Documentation
|--------|----------|-------------|
| GET | `/api/memories` | List all memories |
| POST | `/api/memories` | Add memory |
| PUT | `/api/memories/{rowid}` | Update memory |
| DELETE | `/api/memories/{rowid}` | Delete memory |
| GET | `/api/memories/search?q=term` | Search memories |
| GET | `/api/memories/stats` | Get counts by topic |
### Models & System | Page | What's there |
|------|-------------|
| [Home](https://github.com/mikeshallop/caic/wiki) | Overview, FAQ, links |
| [Installation](https://github.com/mikeshallop/caic/wiki/Installation) | Docker + bare-metal walkthrough, config reference |
| [Architecture](https://github.com/mikeshallop/caic/wiki/Developer-Architecture) | Coordinator/worker design, AMQP protocol, module map |
| [Screenshots](https://github.com/mikeshallop/caic/wiki/Screenshots) | UI gallery |
| Method | Endpoint | Description | ## Changelog
|--------|----------|-------------|
| GET | `/api/models` | List available models |
| GET | `/api/ps` | List loaded models |
| POST | `/api/show` | Get model info |
| GET | `/api/stats` | CPU, RAM, GPU, VRAM stats |
| GET | `/api/search/status` | SearXNG availability |
### Settings & Profile See [What's New](#whats-new-in-v100) below, or browse the [commit history](https://github.com/mikeshallop/caic/commits/main).
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/profile` | Get profile content |
| PUT | `/api/profile` | Update profile (admin) |
| GET | `/api/profile/default` | Get default profile |
| GET | `/api/settings` | Get settings |
| PUT | `/api/settings` | Update settings (admin) |
### Conversations
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/conversations` | List conversations |
| POST | `/api/conversations` | Create conversation |
| GET | `/api/conversations/{id}` | Get conversation with messages |
| PUT | `/api/conversations/{id}` | Update conversation title/model |
| DELETE | `/api/conversations/{id}` | Delete conversation |
| DELETE | `/api/conversations` | Delete ALL conversations |
### Presets
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/presets` | List presets |
| POST | `/api/presets` | Create preset |
| PUT | `/api/presets/{id}` | Update preset |
| DELETE | `/api/presets/{id}` | Delete preset |
### Skills
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/skills` | List all skills with state |
| GET | `/api/skills/active` | List active skills |
| PUT | `/api/skills/{key}` | Toggle skill enabled (admin) |
### Auth
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/auth/guest` | Create guest session |
| POST | `/api/auth/login` | Admin PIN login |
| POST | `/api/auth/logout` | Revoke session |
| GET | `/api/auth/session` | Check session validity |
| POST | `/api/auth/heartbeat` | Extend session TTL |
## Configuration
Settings are stored in the `settings` table and include:
- `profile_enabled` — Inject profile into chats (true/false)
- `search_enabled` — Auto web search (true/false)
- `memory_enabled` — Memory injection (true/false)
- `skills_enabled` — Skills framework (true/false)
- `default_model` — Default inference model
## Testing
```bash
./venv/bin/python -m pytest tests/ -v
```
All 26 tests use `tmp_path` fixtures + monkeypatched `httpx.AsyncClient.stream`. No external services needed.
## License ## License
@@ -260,4 +150,160 @@ MIT
## Repository ## Repository
Gitea: `ssh://gitea@llgit.llamachile.tube:1319/gramps/jarvisChat.git` GitHub: https://github.com/mikeshallop/caic
Gitea (primary): `ssh://gitea@llgit.llamachile.tube:1319/gramps/caic.git`
---
## What's New in v1.1.0
### Image Generation Service
- `POST /api/image/generate` — proxy endpoint routes to ComfyUI on cluster workers
- `GET /api/image/status` — lists available image gen nodes
- Node agent auto-detects ComfyUI and registers `image_gen` capability
- Full ComfyUI workflow: CheckpointLoader → KSampler → VAEDecode → SaveImage
- Cluster AMQP protocol extended: `cmd.image_generate`, `image_generated`, `image_failed`
- Hardware probe checks ComfyUI reachability + checkpoint model list
- 27 new tests covering cluster handlers, router proxy, node agent, hardware, capability detection
### Bug Fixes & Hardening
- Hardware assessment now probes ComfyUI alongside llama-server, Qdrant, SearXNG
- Node agent config extended with `comfyui_port` (default 8188)
## What's New in v1.0.0
### Docker Containerization (B3)
- `Dockerfile` — multi-stage Python 3.13-slim build with healthcheck
- `docker-compose.yml` — full stack: cAIc, SearXNG, Qdrant, RabbitMQ, llama-server, Ollama
- `scripts/setup.sh` — first-run scaffolding: generates `.env`, secrets, SearXNG config, pulls default model
- All service URLs env-var configurable with Docker service hostnames
- AMQP secret uses Docker secrets pattern (`/run/secrets/`)
- Only port 8080 exposed by default; all other services internal to compose network
- Graceful degradation — SearXNG and Ollama optional
### Bug Fixes & Hardening
- Defaults changed from hardcoded LAN IPs to `localhost` for Docker compatibility
- `DEFAULT_MODEL` configurable via `CAIC_DEFAULT_MODEL` env var
- `HW_STATE_PATH` configurable via `CAIC_HW_STATE_PATH` env var
- Syslog handler wrapped in try/except (container-safe)
- SQLite `PRAGMA journal_mode = WAL` for better concurrency
- `db.close()` in try/finally for proper cleanup
- AMQP subscription append moved before try for reconnect safety
- Missing `psutil` + `jinja2` added to `requirements.txt`
- Test discovery fixed via `tests/conftest.py` sys.path insertion
## What's New in v0.23.0
### Topbar Redesign
- Stats moved to bottom status bar, toggles to hamburger menu, palette next to version
- Mobile-responsive layout, query bar restored above chat
### Uninstall Scripts
- `scripts/uninstall.sh`, `teardown-docker.sh`, `nuclear-clean.sh`
### Code Quality
- Replaced deprecated `asyncio.ensure_future` with `asyncio.create_task`
- `AGENTS.md``ai.md` for tool-agnostic project context
## What's New in v0.22.0
### Color Theme System
- 6 themes: IBM Blue, Green Ln (Matrix), Dark, Light, Amber (Fallout), Trippin (neon)
- Palette icon in topbar, CSS variable swap, `localStorage` persistence
### RAG Corpus Management UI (B4)
- Admin modal to browse, search, edit, and delete individual RAG entries
- Stats bar, semantic search, source filter, per-row edit/delete, bulk flush
- 14 new tests, 214 total
## What's New in v0.21.0
### Scrollbar + DOM Fixes
- Scrollbar z-index, `requestAnimationFrame` scroll, direction-aware scroll guard
### Perplexity Persistence
- Perplexity stored per message, confidence badges on loaded conversations
### Config Overhaul
- All service URLs now env-overridable for single-node deployment
## What's New in v0.20.0
### At-Rest Encryption
- AES-256-GCM on all query-derived text: conversations, memories, uploads, RAG, completions
- 256-bit key auto-generated on first boot, never exposed via API
## What's New in v0.19.3
### Private Chat Mode
- Toggle to keep nothing on disk — no persistence, no memory/RAG, no web search
### WireGuard In-Transit Encryption
- All coordinator↔worker traffic encrypted at the network layer
## What's New in v0.19.2
### Waterfall Direction Toggle
- NEW/OLD sort toggle, direction-aware scroll, toast notifications, clipboard fallback
## What's New in v0.19.1
### Default Model Auto-Pull
- Checks llama-server at startup, falls back to Ollama pull if missing
## What's New in v0.19.0
### Apple Silicon Worker Support
- GPU detection via `system_profiler` on macOS, hybrid AMD/Apple/CPU detection
## What's New in v0.18.0
### Wiki + UX Polish
- Full installation guide, screenshots gallery, waterfall layout, barcode stripes, confidence badges, sprocket strips, paper grain background
## What's New in v0.17.26
### Dynamic Model Swap + Cluster Status UI
- `request_model_swap()`, async `select_node()`, heartbeat handler, live status panel
## What's New in v0.14.0
### Cluster Protocol
- 9 AMQP message types, node registry, ping/pong health, coordinator auto-promotion
### RAG Corpus Management
- Score-based eviction with hysteresis, flush endpoint, operational stats
## What's New in v0.13.0
### RAG Eviction Engine
- Score-based eviction with hysteresis (80% high-water, 20% low-water), pinned sources, grace period
## What's New in v0.12.0
### Chat Reply Toolbar
- Copy, print, save, rate actions on assistant messages
### Startup Hardware Assessment
- CPU, RAM, VRAM probe on first boot
## What's New in v0.11.0
### Terminal RAG Hook
- `POST /api/ingest` with Bearer token auth for autonomous terminal history ingestion
## What's New in v0.10.0
### File Upload & Attachments
- PDF/text extraction, chat context injection, RAG ingest, paperclip UI
## What's New in v0.9.0
### Modular Refactor
- Single-file `app.py` split into config/db/auth/security/memory/search/rag/gpu + routers/
## What's New in v0.8.0
### Foundation
- OpenAI-compat endpoint, RAG pipeline, SSE streaming, llama-server integration
+73 -559
View File
@@ -1,602 +1,116 @@
# jarvisChat — OpenCode Prompt Sequence # cAIc — Task List (v1.0+)
# Generated: 2026-07-01
# Execute sequentially. Run full test suite after each task before proceeding. Previous task history archived at `docs/archive/TASKS-pre-1.0.md`.
# Test command: ./venv/bin/python -m pytest tests/ -v
--- ---
## TASK 1 — README Cleanup ## TASK 1 — Image Generation Service (corsair)
Review README.md in the current repo. Remove any node references other than `ultron` (192.168.50.108) and `jarvis` (192.168.50.210). Ensure all references to the project use the exact casing `jarvisChat` — not `Jarvischat`, `JarvisChat`, or `jarvischat`. Do not change any functional content, endpoint documentation, or architecture descriptions — this is a text cleanup only. After editing, verify the file renders cleanly as markdown. Commit with message: `docs: clean up node references and branding consistency`. **Goal:** Add image generation as a cluster capability. The image-gen node (currently jarvis — single-node deployment) registers as an image gen worker in the cAIc cluster.
No new tests required for this task. ### Requirements:
--- 1. **Add `"image_gen"` capability** to the cluster protocol in `cluster.py` — valid capability values should include `image_gen`
2. **Image gen API wrapper** — run ComfyUI, Automatic1111, or a lightweight API (e.g., `sd-api` or `comfyui-api`) that exposes a simple `POST /generate` endpoint accepting a prompt and returning a PNG
3. **Proxy endpoint in cAIc**`POST /api/image/generate` on the coordinator, routes the request to the image gen service via AMQP or direct HTTP
4. **Update `hardware.py`** to probe the image gen service for reachability and status
5. **Update node_agent** to report image gen capability and service status on registration
## TASK 2 — Qwen2.5-Coder llama-server Service on Ultron (Infrastructure) [DONE] ### Architecture:
**Status: Systemd unit created, verified, and restored.**
This task originally defined creation of `/etc/systemd/system/llama-server-coder.service` (port 8082, Qwen2.5-Coder-14B Q5_K_M) as a prerequisite for dynamic model swapping. That sysadmin work is done.
**The real Task 2 deliverable — the ability to dynamically swap models based on query classification — is delivered by Roadmap N (Tasks 915).** The flow:
1. **Task 13** — Phi-4-mini triage (`triage.py`) classifies the query as `general`, `code`, `search`, or `rag`
2. **Task 13**`select_node()` picks the best worker node; if the ideal model isn't active, it triggers a swap
3. **Task 14**`request_model_swap()` publishes `cmd.swap_model` via AMQP `jc.admin` exchange
4. **Task 12** — The node agent on jarvis receives the command, stops the current llama-server, starts the correct one, waits for health, and publishes `model_ready`
5. **Task 14** — ultron receives `model_ready`, updates the cluster registry, and routes the query to the node
The swap is async and transparent — the user sees only latency. The UI (Task 15) shows a yellow "swapping" status dot during the transition.
The service unit at `/etc/systemd/system/llama-server-coder.service` is the **target** the node agent starts when swapping to code inference. It is not enabled at boot — the AMQP cluster manages activation.
See Tasks 915 for the actual model swap implementation.
No pytest tests required for this infrastructure task.
---
## TASK 3 — Update OpenCode Config to Use Qwen on :8082
Update `/home/gramps/.config/opencode/opencode.jsonc` (on this machine, ultron) to point the configured provider at `http://127.0.0.1:8082/v1` instead of `http://127.0.0.1:8081/v1`. The model name in the config should be updated to reflect `qwen2.5-coder-14b` or whatever model ID the llama-server instance at :8082 reports via `/v1/models`. Verify the endpoint is reachable before writing the config change. Do not restart OpenCode — the config change takes effect on next session start.
No pytest tests required for this task.
---
## TASK 4 — File/Document Attachment: Backend Ingest Endpoint
This task implements the backend half of file/document attachment (TODO #21). The goal is dual-aspect upload: a file can be used as immediate chat context, ingested into the RAG corpus (Qdrant), or both.
**Add to `config.py`:**
- `UPLOAD_DIR` — path for temporary upload storage, default `/tmp/jarvischat_uploads`
- `MAX_UPLOAD_BYTES` — max file size, default 20MB
- `SUPPORTED_UPLOAD_TYPES` — set of MIME types: `text/plain`, `text/markdown`, `application/pdf`, `application/json`, `text/x-python`, `text/html`
**Create `routers/upload.py`:**
Implement `POST /api/upload` (admin required). Accept `multipart/form-data` with:
- `file` — the uploaded file (required)
- `mode` — string enum: `context` (inject into next chat only), `ingest` (add to RAG corpus), `both` (default: `both`)
- `conversation_id` — optional, associates context-mode content with a specific conversation
Behavior:
- Validate file size against `MAX_UPLOAD_BYTES` — return 413 if exceeded
- Validate MIME type against `SUPPORTED_UPLOAD_TYPES` — return 415 if unsupported
- For PDF files, extract text using `pypdf` (add to requirements.txt)
- For all other types, read as UTF-8 text
- If mode includes `ingest`: chunk the extracted text into 512-token overlapping chunks (128-token overlap), generate embeddings via `EMBED_URL` (http://192.168.50.108:11434/api/embeddings, model mxbai-embed-large), upsert into Qdrant collection `jarvischat` with metadata `{source: filename, upload_date: iso_timestamp, type: "upload"}`
- If mode includes `context`: store the full extracted text in a new SQLite table `upload_context` with columns `(id INTEGER PRIMARY KEY, conversation_id TEXT, filename TEXT, content TEXT, created_at TEXT, expires_at TEXT)`. Context entries expire after 1 hour.
- Return JSON: `{filename, size_bytes, mode, chunks_ingested (if ingest), context_id (if context), message}`
**Add `upload_context` table to `db.py`** `init_db()`.
**Wire `upload.router` into `app.py`** in the router registration block.
**Write `tests/test_upload.py`** covering:
- Valid text file upload, mode=ingest — assert chunks_ingested > 0, Qdrant upsert called
- Valid text file upload, mode=context — assert context_id returned, row exists in upload_context
- Valid text file upload, mode=both — assert both behaviors
- File exceeds MAX_UPLOAD_BYTES — assert 413
- Unsupported MIME type — assert 415
- Guest session attempt — assert 403
- PDF extraction path — mock pypdf, assert text extracted and processed
Mock Qdrant and EMBED_URL calls via monkeypatch. Do not require live external services in tests.
Run full test suite after implementation. All 26 existing tests must continue to pass.
---
## TASK 5 — File/Document Attachment: UI Integration
This task implements the frontend half of TODO #21. The UI is a single file at `templates/index.html`.
Add a file attachment button to the chat input area. Requirements:
- Paperclip icon button adjacent to the send button
- Clicking opens a file picker filtered to supported types (`.txt`, `.md`, `.pdf`, `.json`, `.py`, `.html`)
- On file selection, show a pill/badge above the input showing the filename with an X to remove it
- On send, if a file is attached: POST to `/api/upload` with `mode=both` and the current `conversation_id`, then include the returned `context_id` in the subsequent `/api/chat` POST body as `upload_context_id`
- If the upload fails, show an inline error and do not send the chat message
- File attachment state clears after send
**Update `/api/chat` in `routers/chat.py`:**
- Accept optional `upload_context_id` in the request body
- If present, look up the content in `upload_context` table and prepend it to the system prompt as: `\n\n[ATTACHED DOCUMENT: {filename}]\n{content}\n[END DOCUMENT]`
- If the context_id is expired or missing, log a warning and continue without it (do not error)
**Add to `tests/test_chat_streaming_and_memory_paths.py`:**
- Test that a valid `upload_context_id` results in document content being prepended to the system prompt
- Test that an expired/missing `upload_context_id` is silently ignored
Run full test suite. All existing tests must continue to pass.
---
## TASK 6 — Roadmap I: Terminal Command RAG Hook
This task implements autonomous RAG ingestion of significant terminal activity (TODO #23).
**Create `routers/ingest.py`:**
Implement `POST /api/ingest` (requires Bearer token auth — use same `COMPLETIONS_API_KEY` mechanism as `routers/completions.py`). Accept JSON body:
- `content` — string, the text to ingest (required)
- `source` — string, origin label e.g. `terminal`, `file`, `external` (default: `external`)
- `metadata` — optional dict of additional key/value pairs
Behavior:
- Chunk `content` into 512-token overlapping chunks (128-token overlap) — extract this logic into a shared helper `chunk_text(text, chunk_size=512, overlap=128)` in `rag.py` if not already present
- Generate embeddings via `EMBED_URL`
- Upsert into Qdrant collection `jarvischat` with metadata `{source, ingest_date: iso_timestamp, ...metadata}`
- Return JSON: `{chunks_ingested, source, message}`
**Wire `ingest.router` into `app.py`.**
**Create `/home/gramps/bin/jc-ingest.sh` on jarvis (192.168.50.210)** — this is a shell script, not a Python file, and lives outside the repo. Write it to stdout/document it clearly so gramps can deploy it manually:
```bash
#!/bin/bash
# jc-ingest.sh — pipe terminal commands into jarvisChat RAG
# Add to ~/.bashrc: export PROMPT_COMMAND="jc_capture"
# Function to call after significant commands
JC_URL="http://192.168.50.210:8080/api/ingest"
JC_TOKEN="${JARVISCHAT_COMPLETIONS_API_KEY}"
jc_capture() {
local cmd
cmd=$(history 1 | sed 's/^[ ]*[0-9]*[ ]*//')
# Only ingest significant commands
if echo "$cmd" | grep -qE '^(git|pip|systemctl|sudo|vi|vim|curl|wget|apt|python|pytest)'; then
curl -s -X POST "$JC_URL" \
-H "Authorization: Bearer $JC_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"content\": $(echo "$cmd" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().strip()))'), \"source\": \"terminal\"}" \
> /dev/null 2>&1 &
fi
}
```
**Write `tests/test_ingest.py`** covering:
- Valid ingest with content — assert chunks_ingested > 0
- Missing Bearer token — assert 401
- Wrong Bearer token — assert 403
- Empty content — assert 422
- Qdrant and embed calls mocked via monkeypatch
Run full test suite. All existing tests must continue to pass.
---
## TASK 7 — Roadmap J: Startup Hardware Self-Assessment
On jC startup, probe available hardware and write a living config snapshot. This replaces hardcoded assumptions about VRAM and RAM.
**Create `hardware.py`** in the project root:
``` ```
async def assess_hardware() -> dict User prompt → cAIc coordinator → AMQP/HTTP → image-gen node (ComfyUI/API) → PNG → coordinator → user
``` ```
Probes: ### Considerations:
- System RAM: `psutil.virtual_memory().total` and `.available`
- CPU count: `psutil.cpu_count()`
- GPU VRAM total and free: call `rocm-smi --showmeminfo vram --json` via subprocess, parse output. If rocm-smi absent or fails, set VRAM values to 0 and log a warning.
- llama-server reachable: GET `LLAMA_SERVER_BASE/v1/models`, timeout 3s. Record True/False and list of available model IDs.
- Qdrant reachable: GET `http://192.168.50.108:6333/collections`, timeout 3s. Record True/False and collection list.
- SearXNG reachable: GET `http://localhost:8888`, timeout 3s. Record True/False.
Returns a dict with all of the above. Writes result as JSON to `hardware_state.json` in the working directory. - Response time: expect 5-30 seconds per image depending on model/resolution
- Queue management: what if multiple requests come in at once?
- Model selection: which SD/Flux model to run by default?
- Resolution limits: max image size?
- CORS/headers for serving generated images back to the UI
**Call `assess_hardware()` from the FastAPI `lifespan` context** in `app.py` on startup, after `init_db()`. Log a summary line: `HW: {ram_gb}GB RAM, {vram_mb}MB VRAM, llama={reachable}, qdrant={reachable}, searxng={reachable}`. ### Tests:
**Expose `GET /api/hardware`** in a new `routers/hardware.py` — returns the current `hardware_state.json` content as JSON. No auth required (read-only, non-sensitive aggregate stats). - Mock image gen service, verify proxy routing
- Verify `hardware.py` probes image gen endpoint
- Verify node_agent registers with `image_gen` capability
- Verify 429/503 handling when service is busy or down
**Wire `hardware.router` into `app.py`.** ### Status: ✅ Backend Complete (ComfyUI install pending on jarvis — single-node deployment)
**Write `tests/test_hardware.py`** covering:
- `assess_hardware()` with all services reachable (mock subprocess and httpx calls) — assert all fields present
- `assess_hardware()` with rocm-smi absent — assert VRAM=0, no exception raised
- `assess_hardware()` with llama-server unreachable — assert `llama_reachable=False`, no exception
- `GET /api/hardware` — assert returns JSON with expected keys
Run full test suite. All existing tests must continue to pass.
--- ---
## TASK 8Roadmap K: RAG Corpus Management ## TASK 2Context-Aware Cluster Routing
Qdrant collection `jarvischat` currently grows without bound. Implement weighted LRU eviction and pinning. **Goal:** Make chat/completions requests route to the best node based on available context capacity, not just model name matching. Solve the core problem: GPU nodes have limited VRAM context, but requests (especially from IDE integrations) can exceed that.
**Add to `config.py`:** ### Requirements:
- `RAG_MAX_VECTORS` — max vectors in Qdrant collection before eviction triggers, default 50000
- `RAG_EVICTION_BATCH` — number of vectors to evict per cycle, default 1000
- `RAG_PINNED_SOURCES` — list of source labels that are never evicted, default `["upload", "profile"]`
**Add to `rag.py`:** 1. **Node capacity reporting** — node_agent calculates and reports `effective_context_tokens` on registration based on (available RAM/VRAM model weight size) → KV cache capacity. Updated on model swaps.
2. **Coordinator tracks cluster capacity**`CLUSTER_NODES` stores `effective_context_tokens` per node, updated via registration and `model_ready` events.
3. **Context budget estimation** — before sending upstream, estimate the token count of the assembled message array (~4 chars/token heuristic, or tiktoken if available). Expose as a helper in `config.py` or `rag.py`.
4. **Resource-aware `select_node()`**`triage.py` weighs effective context capacity, current load, and model match when picking a node. Nodes that can't fit the estimated context are deprioritized or skipped.
5. **Wire triage into chat router**`routers/chat.py` calls `get_inference_url(user_message)` instead of hardcoding `LLAMA_SERVER_BASE`.
6. **Wire triage into completions router**`routers/completions.py` uses the same routing logic for IDE/Continue.dev sessions.
7. **Graceful degradation** — if no node can fit the estimated context, truncate intelligently (drop oldest RAG chunks, summarize history) before falling back to the coordinator.
```python ### Architecture:
async def get_collection_count() -> int
# GET Qdrant /collections/jarvischat, return vectors_count
async def evict_oldest(batch_size: int) -> int ```
# Scroll Qdrant for vectors with source NOT in RAG_PINNED_SOURCES, User request → build messages → estimate tokens → triage.select_node(effective_context)
# ordered by ingest_date ascending (oldest first), → node with enough headroom → stream response
# delete batch_size of them. Return count deleted. → no node fits → truncate context → coordinator fallback
async def maybe_evict() -> int
# If get_collection_count() >= RAG_MAX_VECTORS: call evict_oldest(RAG_EVICTION_BATCH)
# Return count evicted (0 if no eviction needed)
``` ```
**Call `maybe_evict()` from the ingest path** — both in `routers/upload.py` and `routers/ingest.py` — after each upsert batch completes. ### Tests:
**Add `GET /api/rag/stats`** to a new `routers/rag_admin.py`: - Mock CLUSTER_NODES with varying effective_context_tokens, verify select_node picks the right one
- Returns `{vector_count, max_vectors, pinned_sources, eviction_batch}` - Mock message arrays of different sizes, verify token estimation
- Admin required - Verify chat router calls get_inference_url instead of hardcoding
- Verify fallback truncation when no node fits
- Verify model swap updates effective_context_tokens
**Wire `rag_admin.router` into `app.py`.** ### Status: Not started
**Write `tests/test_rag_management.py`** covering:
- `get_collection_count()` — mock Qdrant GET, assert correct count returned
- `evict_oldest()` — mock Qdrant scroll + delete, assert correct batch size deleted, assert pinned sources excluded
- `maybe_evict()` — below threshold: assert 0 evicted; at/above threshold: assert eviction triggered
- `GET /api/rag/stats` — assert correct JSON shape returned
- Guest attempt on `/api/rag/stats` — assert 403
Run full test suite. All existing tests must continue to pass.
--- ---
## TASK 9 — Roadmap N1: RabbitMQ Install and Service on Ultron (Infrastructure) ## TASK 3 — RAM-Based Context Store Node (blue-sky)
This task runs on ultron (this machine). Install RabbitMQ and verify it is operational. **Goal:** Enable a RAM-heavy node (e.g. a workstation with 32GB+ RAM) to join the cAIc cluster as a dedicated context store — holding conversation histories, RAG results, uploaded documents, and memories in RAM for fast retrieval, without running inference.
Run the following steps: ### Requirements:
1. `apt-get update && apt-get install -y rabbitmq-server`
2. `systemctl enable rabbitmq-server && systemctl start rabbitmq-server`
3. `systemctl status rabbitmq-server` — verify active/running
4. Enable the management plugin: `rabbitmq-plugins enable rabbitmq_management`
5. Create a dedicated jC vhost: `rabbitmqctl add_vhost jarvischat`
6. Create a dedicated user: `rabbitmqctl add_user jarvischat CHANGEME_PASSWORD` — generate a random 24-char alphanumeric password and record it
7. Grant permissions: `rabbitmqctl set_permissions -p jarvischat jarvischat ".*" ".*" ".*"`
8. Verify management UI is reachable: `curl -s -u guest:guest http://localhost:15672/api/overview | python3 -m json.tool`
9. Delete default guest user: `rabbitmqctl delete_user guest`
Declare the two topic exchanges needed by jC: 1. **New node type: `context_store`** — registers with a `context_store` capability, advertises available RAM and current usage
- Exchange name: `jc.admin`, type: `topic`, durable: true 2. **Lightweight context service** — HTTP API on the context store node exposing:
- Exchange name: `jc.system`, type: `topic`, durable: true - `POST /context/{session_id}` — store conversation context
- `GET /context/{session_id}` — retrieve full context
- `PUT /context/{session_id}/chunks` — update RAG/document chunks
- `GET /context/{session_id}/relevant?q=...` — ranked context retrieval
3. **Coordinator integration**`build_system_prompt()` pulls from the context store node instead of (or in addition to) SQLite when one is available
4. **Context store discovery** — node_agent supports `context_store` type in config, coordinator queries available RAM on registration
5. **Failover** — if context store is unreachable, fall back to local SQLite/Qdrant as today
Use `rabbitmqadmin` or `curl` against the management API to declare exchanges. Verify both exchanges appear in: `curl -s -u jarvischat:{password} http://localhost:15672/api/exchanges/jarvischat` ### Architecture:
Write the generated RabbitMQ password to `/home/gramps/.jc_amqp_secret` with mode 600. This will be read by jC as an env var source in subsequent tasks. ```
Coordinator startup → discover context_store nodes → query available RAM
No pytest tests required for this infrastructure task. Build system prompt → pull relevant context from context_store node → assemble → send to inference node
---
## TASK 10 — Roadmap N2: AMQP Connection Layer in jC
This task adds the core AMQP connection manager to jC. It must connect to RabbitMQ on ultron (localhost from jC's perspective since jC runs on ultron), handle reconnection, and provide a shared channel for all AMQP operations.
**Add to `requirements.txt`:** `aio-pika>=9.0.0`
**Add to `config.py`:**
- `AMQP_URL` — read from env `JARVISCHAT_AMQP_URL`, default `amqp://jarvischat:password@localhost:5672/jarvischat`. The actual password comes from `/home/gramps/.jc_amqp_secret` — read it at startup if the env var is not set.
- `AMQP_RECONNECT_DELAY` — seconds between reconnect attempts, default 5
- `AMQP_EXCHANGE_ADMIN``jc.admin`
- `AMQP_EXCHANGE_SYSTEM``jc.system`
**Create `amqp.py`** in the project root:
```python
# Manages a single persistent aio-pika connection and channel.
# Provides:
# connect() -> None # establish connection, declare exchanges
# disconnect() -> None # graceful close
# get_channel() # returns current channel, reconnects if needed
# publish(exchange, routing_key, payload: dict) -> None
# # publishes JSON-serialized payload as persistent message
``` ```
Connection must: ### Considerations:
- Reconnect automatically on disconnect with `AMQP_RECONNECT_DELAY` backoff
- Log connection events at INFO level
- Not raise on publish if disconnected — log error and return (fire-and-forget, jC must not crash if RabbitMQ is down)
**Start AMQP connection in `app.py` lifespan** after `assess_hardware()`. Disconnect in lifespan cleanup. - Context store node needs minimal resources — Python + aiohttp + Redis or in-memory dict
- Latency: LAN round-trip (~1ms) is negligible compared to inference time
- Persistence: optional — RAM-only is fine if the store can repopulate from SQLite on restart
- Security: context store holds unencrypted data locally (encryption stays at the coordinator layer)
**Write `tests/test_amqp.py`** covering: ### Tests:
- `publish()` with mocked aio-pika connection — assert message published with correct exchange and routing key
- `publish()` when disconnected — assert no exception raised, error logged
- `get_channel()` when connection is None — assert reconnect attempted
Mock all aio-pika calls via monkeypatch. Do not require a live RabbitMQ instance in tests. - Mock context store node registration, verify coordinator discovers it
- Mock context store HTTP responses, verify build_system_prompt pulls from it
- Verify failover to local SQLite when context store is unreachable
Run full test suite. All existing tests must continue to pass. ### Status: Not started (blocked on available hardware — Dell Precision Tower 3420 dead, NUC running Home Assistant OS)
--- ---
## TASK 11 — Roadmap N3: Worker Node Registration Handler (Ultron/jC Side)
jC on ultron must listen on the `jc.admin` exchange for worker node registration requests and respond with admission or rejection.
**Add to `amqp.py`:**
```python
async def subscribe(exchange, routing_key, callback) -> None
# Declare a queue, bind to exchange/routing_key, consume with callback
```
**Create `cluster.py`** in the project root:
```python
# In-memory cluster registry (survives only while jC is running)
# Structure:
# CLUSTER_NODES: dict[str, NodeRecord]
#
# NodeRecord fields:
# node_name: str
# ip: str
# active_model: ModelRecord
# inventory: list[ModelRecord]
# registered_at: str (ISO timestamp)
# last_seen: str (ISO timestamp)
#
# ModelRecord fields:
# name: str
# version: str
# quant: str
# path: str
# port: int (llama-server port this model is served on)
async def handle_registration(message: aio_pika.IncomingMessage) -> None
# Parse JSON payload from message body
# Validate required fields: node_name, ip, active_model, inventory
# Reject if node_name already in CLUSTER_NODES with status="active":
# publish to jc.admin routing_key=f"node.{node_name}.rejected"
# payload: {node_name, reason: "duplicate_node_name", timestamp}
# Reject if payload malformed:
# publish to jc.admin routing_key=f"node.{node_name}.rejected"
# payload: {node_name, reason: "malformed_payload", timestamp}
# Otherwise admit:
# add to CLUSTER_NODES
# publish to jc.admin routing_key=f"node.{node_name}.admitted"
# payload: {node_name, timestamp, amqp_url: AMQP_URL}
async def handle_deregistration(message) -> None
# Remove node from CLUSTER_NODES, log it
def get_cluster_state() -> dict
# Return serializable snapshot of CLUSTER_NODES
```
**Subscribe to registration messages in `app.py` lifespan** after AMQP connects:
- `jc.admin` exchange, routing key `node.*.register``handle_registration`
- `jc.admin` exchange, routing key `node.*.deregister``handle_deregistration`
**Add `GET /api/cluster`** to a new `routers/cluster.py`:
- Returns `get_cluster_state()` as JSON
- No auth required (read-only status endpoint)
**Wire `cluster.router` into `app.py`.**
**Write `tests/test_cluster.py`** covering:
- Valid registration payload — assert node admitted, added to CLUSTER_NODES, admitted message published
- Duplicate node name — assert rejected, reason=`duplicate_node_name`
- Malformed payload (missing required field) — assert rejected, reason=`malformed_payload`
- Deregistration — assert node removed from CLUSTER_NODES
- `GET /api/cluster` — assert returns current node list
Mock all aio-pika calls. Do not require live RabbitMQ.
Run full test suite. All existing tests must continue to pass.
---
## TASK 12 — Roadmap N4: Worker Node Registration Publisher (Jarvis Side)
This task creates the worker node AMQP client that runs on jarvis (192.168.50.210). It is a standalone Python script — not part of the jC FastAPI app — that runs as a systemd service on jarvis.
**Create `node_agent/agent.py`** in the repo (new directory):
The agent:
1. On start: reads local config from `/etc/jc-node-agent.conf` (INI format):
- `node_name` — hostname, default from `socket.gethostname()`
- `node_ip` — LAN IP, default from socket
- `amqp_url` — RabbitMQ URL on ultron, e.g. `amqp://jarvischat:password@192.168.50.108:5672/jarvischat`
- `llama_port` — port llama-server/llama-rpc is listening on, default 8081
- `models_dir` — path to GGUF model files, default `/home/gramps/models`
- `active_model` — filename of currently active model (without path)
2. Discovers inventory by globbing `models_dir` for `*.gguf` files and parsing name/version/quant from filename using regex pattern: `{name}-{version}-{quant}.gguf` where quant matches `Q[0-9]+_K_[A-Z]+` or similar standard suffixes.
3. Publishes registration request to `jc.admin` exchange, routing key `node.{node_name}.register`:
```json
{
"node_name": "jarvis",
"ip": "192.168.50.210",
"active_model": {"name": "...", "version": "...", "quant": "...", "path": "...", "port": 8081},
"inventory": [...]
}
```
4. Listens for response on `jc.admin`, routing key `node.{node_name}.admitted` or `node.{node_name}.rejected`. Logs result. If rejected, exits with error.
5. After admission: publishes heartbeat every 30 seconds to `jc.system`, routing key `node.{node_name}.heartbeat`:
```json
{"node_name": "...", "ip": "...", "active_model": "...", "timestamp": "..."}
```
6. Listens on `jc.admin`, routing key `node.{node_name}.cmd.swap_model`:
- Payload: `{model_filename: str}`
- Stops current llama-server: `systemctl stop llama-server`
- Updates `/etc/jc-node-agent.conf` active_model field
- Starts llama-server: `systemctl start llama-server` (assumes service reads active_model from conf or ExecStart is updated)
- Waits for llama-server to be healthy: poll `http://localhost:{llama_port}/v1/models` every 2s, timeout 120s
- Publishes to `jc.system`, routing key `node.{node_name}.model_ready`:
```json
{"node_name": "...", "active_model": "...", "port": ..., "timestamp": "..."}
```
- If startup fails within timeout: publishes `node.{node_name}.model_failed` with error detail
**Create `node_agent/requirements.txt`:** `aio-pika>=9.0.0`
**Document `/etc/jc-node-agent.conf` format** in a comment block at the top of `agent.py`.
**Write `tests/test_node_agent.py`** covering:
- Registration payload construction from config + model discovery — assert correct JSON shape
- Model swap command handler: success path — assert systemctl calls made, model_ready published
- Model swap command handler: timeout path — assert model_failed published
- Heartbeat: assert published every interval (mock asyncio.sleep)
Mock all aio-pika, subprocess, and httpx calls.
**Do not create a systemd service file in this task** — that is a manual deployment step. Document the required service configuration in a comment at the bottom of `agent.py`.
Run full test suite. All existing tests must continue to pass.
---
## TASK 13 — Roadmap N5: Query Routing via AMQP + Phi-4-mini Triage
This task wires the cluster into jC's chat flow. When a query arrives at `/api/chat`, instead of always routing to the hardcoded `LLAMA_SERVER_BASE`, jC now routes to the best available cluster node based on query context.
**Prerequisites:** Tasks 912 complete. At least one worker node admitted to cluster.
**Install Phi-4-mini on ultron (infrastructure step):**
- Download `Phi-4-mini-Instruct-Q4_K_M.gguf` from HuggingFace using `hf download microsoft/Phi-4-mini-instruct --include "*.Q4_K_M.gguf" --local-dir /home/gramps/models`
- Create `/etc/systemd/system/llama-server-triage.service` — same pattern as existing llama-server service but: port 8083, model path points to Phi-4-mini GGUF, no `--rpc` flag (runs entirely on ultron CPU/iGPU), description `Llama.cpp Server (Phi-4-mini — triage/routing)`
- `systemctl daemon-reload && systemctl enable llama-server-triage && systemctl start llama-server-triage`
- Verify: `curl -s http://localhost:8083/v1/models`
**Add to `config.py`:**
- `TRIAGE_BASE` — `http://127.0.0.1:8083/v1` (Phi-4-mini)
- `TRIAGE_TIMEOUT` — 10 seconds
- `FALLBACK_TO_DEFAULT` — True (if triage fails or no nodes available, fall back to `LLAMA_SERVER_BASE`)
**Create `triage.py`** in the project root:
```python
async def classify_query(query: str) -> str
# Sends query to Phi-4-mini at TRIAGE_BASE with a classification system prompt.
# System prompt instructs model to respond with ONLY one of:
# "general", "code", "search", "rag"
# Returns the classification string.
# Timeout: TRIAGE_TIMEOUT seconds.
# On any error: returns "general" (fail-safe).
async def select_node(classification: str) -> dict | None
# Consults CLUSTER_NODES from cluster.py
# For "code": prefer nodes where active_model name contains "coder" or "qwen"
# For "general": prefer nodes where active_model name contains "mistral" or "llama"
# For "search" or "rag": return None (handled locally by jC)
# If no matching node found: return None (triggers FALLBACK_TO_DEFAULT)
# Returns NodeRecord dict for selected node, or None
async def get_inference_url(query: str) -> str
# Combines classify_query + select_node
# Returns full base URL: f"http://{node.ip}:{node.active_model.port}/v1"
# Falls back to LLAMA_SERVER_BASE if classification=search/rag, no nodes, or triage error
```
**Update `routers/chat.py`:**
- Replace the hardcoded `LLAMA_SERVER_BASE` reference with a call to `get_inference_url(user_message)`
- The rest of the chat flow (RAG, memory, streaming) is unchanged — only the inference target URL changes
**Write `tests/test_triage.py`** covering:
- `classify_query()` returns valid classification — mock Phi-4-mini response
- `classify_query()` on timeout — assert returns "general", no exception
- `select_node("code")` with coder node in cluster — assert correct node returned
- `select_node("general")` with no matching node — assert None returned
- `get_inference_url()` with code query and coder node available — assert returns node URL
- `get_inference_url()` with no nodes in cluster — assert returns LLAMA_SERVER_BASE fallback
**Update `tests/test_chat_streaming_and_memory_paths.py`:**
- Mock `triage.get_inference_url` to return a fixed URL in all existing tests so they continue to pass without a live cluster
Run full test suite. All existing tests must continue to pass.
---
## TASK 14 — Roadmap N6: Model Swap Command Flow
This task implements the ultron-side logic for requesting a model swap on a worker node when the ideal model is not currently active.
**Add to `cluster.py`:**
```python
async def request_model_swap(node_name: str, model_filename: str) -> bool
# Publishes to jc.admin exchange, routing key node.{node_name}.cmd.swap_model
# Payload: {model_filename, requested_at: iso_timestamp}
# Sets node status to "swapping" in CLUSTER_NODES
# Returns True if message published successfully
async def handle_model_ready(message) -> None
# Handles node.{node_name}.model_ready from jc.system
# Updates CLUSTER_NODES[node_name].active_model to the new model
# Sets node status back to "active"
# Logs swap completion with timing
async def handle_model_failed(message) -> None
# Handles node.{node_name}.model_failed from jc.system
# Sets node status to "error" in CLUSTER_NODES
# Logs failure with detail from message payload
```
**Subscribe in `app.py` lifespan:**
- `jc.system` exchange, routing key `node.*.model_ready` → `handle_model_ready`
- `jc.system` exchange, routing key `node.*.model_failed` → `handle_model_failed`
**Update `triage.py` `select_node()`:**
- If the best-matching node exists but its active_model does not match the ideal model for the classification, AND the node status is "active" (not already swapping):
- Call `request_model_swap(node_name, ideal_model_filename)`
- Return None (triggers fallback) — the swap happens async, next query will find the right model active
- If node status is "swapping": return None (fallback, swap in progress)
**Update `GET /api/cluster`** to include node status in response.
**Write `tests/test_model_swap.py`** covering:
- `request_model_swap()` — assert swap command published, node status set to "swapping"
- `handle_model_ready()` — assert active_model updated, status set to "active"
- `handle_model_failed()` — assert status set to "error"
- `select_node()` with mismatched active model — assert swap requested, None returned
- `select_node()` with node status "swapping" — assert None returned without publishing another swap
Run full test suite. All existing tests must continue to pass.
---
## TASK 15 — Roadmap N7: Cluster Status UI
Surface cluster awareness in the jC frontend (`templates/index.html`).
**Add a cluster status panel** to the UI. Requirements:
- Small status bar or collapsible panel, visible but unobtrusive
- Polls `GET /api/cluster` every 15 seconds
- For each admitted node: show node name, active model name, and a colored status dot:
- Green: active
- Yellow: swapping
- Red: error or offline (not seen in last 60 seconds based on last_seen timestamp)
- If no nodes in cluster (empty): show "No worker nodes connected"
- Panel must not interfere with chat input or conversation list
**Update `GET /api/cluster` response** to include `last_seen` per node and a `status` field (`active`, `swapping`, `error`).
**Update heartbeat handling in `cluster.py`:** add a handler for `node.*.heartbeat` on `jc.system` that updates `last_seen` timestamp for the node.
**Subscribe in `app.py` lifespan:**
- `jc.system` exchange, routing key `node.*.heartbeat` → `handle_heartbeat`
**Add `handle_heartbeat()` to `cluster.py`:**
- Updates `CLUSTER_NODES[node_name].last_seen` to current timestamp
- If node was previously marked offline (not in CLUSTER_NODES), log re-registration warning but do not auto-admit — full registration required
**Write `tests/test_cluster_heartbeat.py`** covering:
- `handle_heartbeat()` for known node — assert last_seen updated
- `handle_heartbeat()` for unknown node — assert no crash, warning logged, node not added
Run full test suite. All 26+ existing tests must continue to pass.
Commit all changes introduced across Tasks 915 with message: `feat: Roadmap N — AMQP cluster nervous system complete`
+187
View File
@@ -0,0 +1,187 @@
# cAIc — Agents Guide
## Run
```bash
# Docker (recommended)
scripts/setup.sh && docker compose up -d
# Bare-metal
uvicorn app:app --host 0.0.0.0 --port 8080 --reload
```
## Tests
```bash
python3 -m pytest tests/ -v
```
All tests use `tmp_path` fixtures + monkeypatched `httpx.AsyncClient.stream/get/post/put`. No external services needed. Test factories reset `SESSIONS`, `PIN_ATTEMPTS`, `RATE_EVENTS` globals — be careful not to let test state leak. Tests import directly from the correct modules (`db`, `security`, `config`, `search`, `rag`, `memory`, `routers.*`).
Every router has a dedicated test file:
| File | Covers |
|------|--------|
| `test_auth_capabilities.py` | `auth.py` — guest/admin sessions, origin blocking, logout |
| `test_chat_streaming_and_memory_paths.py` | `routers/chat.py` — streaming, auto-search, remember/forget, upload context injection |
| `test_completions.py` | `routers/completions.py` — API key auth, FIM, streaming, blocking, errors |
| `test_conversations.py` | `routers/conversations.py` — full CRUD, guest admin enforcement, attachment_count |
| `test_ingest.py` | `routers/ingest.py` — Bearer auth, chunk/embed/upsert, validation |
| `test_memories.py` | `routers/memories.py` — edit, search, stats endpoints |
| `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 |
| `test_cluster_heartbeat.py` | `cluster.py` — heartbeat handler, known/unknown node |
| `test_model_swap.py` | `cluster.py` — request_model_swap, handle_model_ready/failed |
| `test_node_agent.py` | `node_agent/agent.py` — registration, ping/pong, model swap |
| `test_image.py` | Image generation — cluster handlers, router proxy, node agent ComfyUI integration, hardware probe, capability detection |
| `test_settings_allowlist.py` | `routers/settings.py` — allowlisted key enforcement |
| `test_skills_framework.py` | `routers/skills.py` — list, toggle, unknown skill, prompt injection |
| `test_ip_allowlist.py` | IP allowlist helper + middleware |
| `test_rate_and_payload_guardrails.py` | Rate limits + payload size enforcement |
| `test_error_envelopes.py` | Global exception handler + stream error incidents |
| `test_fixes_regression.py` | Origin-exempt ingest, bogus conversation_id FK, auto-search reset, image uploads, conflict false-positives, deterministic ingest ids, get_load VRAM parsing, version pin |
| `test_upload.py` | `routers/upload.py` — upload, delete, link, by-conversation, attachment_count integration |
Modules that call `httpx.AsyncClient` (chat, completions, models, search_route, upload, ingest, model_pull)
are mocked via `monkeypatch.setattr` on `AsyncClient.stream`, `.get`, or `.post`.
CPU stats in `models.py` (`api/stats`) use real `psutil`; GPU stats are
monkeypatched via `routers.models.get_gpu_stats`.
## Architecture
Refactored from single-file (`app.py`) into modules under project root:
| File | Role |
|------|------|
| `app.py` | FastAPI app, middleware, router registration |
| `config.py` | Constants, env vars, rate/payload limits, built-in skills registry, upload limits |
| `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 (encrypted facts, Python-side matching), remember/forget command parsing |
| `search.py` | SearXNG integration, perplexity scoring, refusal detection |
| `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 |
| `cluster.py` | Cluster node registry, event log, coordinator election, ping/pong, model swap handlers, image generation request/response |
| `amqp.py` | AMQP connection manager — connect, disconnect, publish, subscribe, auto-reconnect |
| `node_agent/` | Standalone worker agent — AMQP client for registration, ping/pong, model swap, image generation |
| `routers/` | One module per endpoint group (chat, search, skills, completions, upload, ingest, image) |
### Entrypoint / API keys
- `app.py` line 148: `uvicorn.run(app, ...)` when called directly
- `config.py` line 14: `LLAMA_SERVER_BASE` defaults to `http://localhost:8081` — configurable via env var; Docker uses `http://llama-server:8081`
- `config.py` line 17: `DEFAULT_MODEL` read from `CAIC_DEFAULT_MODEL` env var or defaults to `qwen2.5-7b-instruct`
- `config.py` line 18: `COMPLETIONS_API_KEY` read from `CAIC_COMPLETIONS_API_KEY` env var or auto-generates
### Key flows
1. **`/api/chat`** → `process_remember_command()` intercepts "remember that..." / "forget about..." first → optional `upload_context_id` fetches document text from SQLite → `build_system_prompt()` (profile + FTS5 memory + Qdrant RAG + preset + skills + uploaded doc) → stream from `LLAMA_SERVER_BASE` with `logprobs: true` → if perplexity > 15.0 OR `REFUSAL_PATTERNS` match, re-query with SearXNG results
2. **`/api/search`** → bypasses perplexity/refusal, queries SearXNG directly → summarizes via llama-server
3. **`/v1/chat/completions`** → OpenAI-compatible for Continue.dev/IDE integration; FIM requests proxied without persistence
4. **`/api/upload`** → multipart file upload, PDF/text extraction, `mode=(context|ingest|both)`, stores SQLite context (1hr expiry) + Qdrant upsert
5. **`/api/ingest`** → Bearer token auth, programmatic RAG ingest (terminal hook, external tools)
6. **`POST /api/image/generate`** → admin required, routes to an image-gen node via AMQP → ComfyUI workflow → returns PNG; `GET /api/image/status` lists available image gen nodes
### Perplexity / auto-search
The upstream request includes `"logprobs": true`. `parse_llama_stream_chunk()` extracts per-token logprobs from each chunk's `choices[0].logprobs.content[].logprob`. The `all_logprobs` list is populated during streaming, so `calculate_perplexity()` and `is_uncertain()` work correctly.
### Auth / lockdown
- Guest session by default (`POST /api/auth/guest`), admin unlock via 4-digit PIN (`POST /api/auth/login`)
- Admin required for PUT/DELETE/PATCH + all POST except allowlist (`/api/chat`, `/api/search`, `/api/auth/*`)
- `/api/ingest` is exempt from session auth — self-authenticates via Bearer token
- IP allowlist, rate limiting, origin checking, payload size limits — all enforced in `app.py` middleware
- Origin check applies to **all** `/api/` requests; returns `False` when both `Origin` and `Referer` are absent
- `CAIC_ADMIN_PIN` env var required on first boot (or `CAIC_ALLOW_DEFAULT_PIN=true`)
### Database
- SQLite at `caic.db`, auto-created by `init_db()` on startup via FastAPI `lifespan`
- `get_db()` opens new connection per request (no pool). Close after use.
- FTS5 virtual table `memories` for full-text search with BM25 ranking.
- `upload_context` table: auto-expiring document storage for chat context injection.
### External services
All services are available bare-metal or as containers in `docker compose up`.
| Service | Required | Port | Docker service name |
|---------|----------|------|---------------------|
| llama-server (coordinator) | Yes | 8081 + RPC :50052 (worker GPU) | `llama-server` |
| SearXNG | No | 8888 | `searxng` |
| RabbitMQ (coordinator) | No | 5672 — AMQP broker | `rabbitmq` |
| wttr.in | No | weather shortcut | — |
| rocm-smi | No | AMD GPU stats | — |
| Qdrant | No | 6333 (coordinator) — RAG vector search | `qdrant` |
| Ollama (worker) | No | 11434 — embeddings + model pull | `ollama` |
| ComfyUI (worker) | No | 8188 — image generation API | — |
### Config quirks
- `BODY_LIMIT_UPLOAD_BYTES` = 20MB for `/api/upload`; other paths use smaller limits
- `SUPPORTED_UPLOAD_TYPES` includes images (png/jpeg/gif/svg/webp) + text + PDF + JSON
- `UPLOAD_CONTEXT_EXPIRY_HOURS` = 1 hour
- Rate limits and payload caps in `config.py` — patch `security.RL_*` not `config.RL_*` for tests
- `COMFYUI_BASE` defaults to `http://localhost:8188` (overridable via `CAIC_COMFYUI_BASE`)
- `COMFYUI_TIMEOUT` defaults to `120` seconds (overridable via `CAIC_COMFYUI_TIMEOUT`)
- RAG embedding requests go to `EMBED_URL` at `/api/embeddings` (Ollama on worker :11434)
### SSE Protocol
All streaming endpoints yield `data: {json}\n\n`. Key shapes:
- `{token, conversation_id}` — streaming token
- `{searching: true}` — web search triggered
- `{search_results: N}` — N results (no raw_results payload)
- `{done: true, perplexity, tokens_per_sec, searched?}` — terminal
- `{error: "...", error_key: "..."}` — error with incident key
## Work State
### Completed this session
- **Pre-Docker review**: Full findings report delivered -- 30+ issues across 7 categories (hardcoded hosts/paths, config/secrets, AMQP gaps, resource cleanup, SQLite container safety, completions concurrency, TASKS.md accuracy).
- **Project rename**: `jarvisChat`**cAIc** ("cake") — swept remaining branding (router docstrings, jc-ingest.sh env var), deleted stale `AGENTS.md.local`.
- **Single-node consolidation**: all services moved to jarvis (192.168.50.212) — `COMFYUI_BASE` default → `localhost:8188`, AMQP URL default → `localhost:5672`, `NODE_NAME` default → `jarvis`, `DEFAULT_PROFILE` topology rewritten, cluster/AMQP/node_agent left in place (degrades gracefully).
- **Deprecation fix**: Replaced `asyncio.ensure_future` with `asyncio.create_task` in `rag.py` and `routers/chat.py`.
- **Documentation**: Added inline comments and docstrings to all functions in `db.py`.
- **Uninstall scripts**: Created and committed `scripts/uninstall.sh`, `teardown-docker.sh`, `nuclear-clean.sh`.
- **README**: Added "Uninstalling cAIc" section.
- **Docker containerization (B3)**: Created `Dockerfile`, `docker-compose.yml`, `.env.example`, `scripts/setup.sh`, `.dockerignore`, `searxng-settings.yml.dist`, `models/README.txt`. Fixed hardcoded defaults in `config.py` (localhost, Docker secrets path, `CAIC_DEFAULT_MODEL` env var, `CAIC_HW_STATE_PATH` env var). Added missing `psutil` + `jinja2` to `requirements.txt`. Fixed test discovery via `tests/conftest.py` sys.path insertion. 214 tests pass.
### Active
- Image generation service backend complete — cluster handlers, router proxy, node agent ComfyUI integration, hardware probe. 27 tests pass. ComfyUI install pending on jarvis (single-node).
### Deployed (2026-08-07) — v1.1.0 to production
- **Fixed crash-loop**: ultron `llama-server.service` had 911 restarts — its `--rpc 192.168.50.210:50052` pointed at a dead IP. Corrected to `192.168.50.212:50052` (jarvis GPU rpc-server). Model now loads, `/health` = ok.
- **Deployed v1.1.0**: workspace repo synced to `/opt/jarvischat` (jarvischat.service cwd). caic.db + venv preserved; `aio-pika` installed into prod venv (was missing → AMQP disabled).
- **Env fixes** (`/etc/systemd/system/jarvischat.service.d/override.conf`): added `LLAMA_SERVER_BASE=http://192.168.50.108:8081`, `CAIC_QDRANT_URL=http://192.168.50.108:6333`, `CAIC_COMPLETIONS_API_KEY` (was set as legacy `JARVISCHAT_` name), kept `CAIC_EMBED_URL=http://192.168.50.108:11434` + `CAIC_ADMIN_PIN=1319`. Wrote `/opt/jarvischat/.completions_key` (jc-ingest.sh).
- **Deploy-blocking bug fixes** (uncommitted, workspace + deploy):
- `rag.py` `chunk_text`: chunk_size 512→200 (chunks exceeded mxbai-embed-large's 512-token context → ollama 500).
- Qdrant 1.18.2 rejects non-UUID point IDs: wrapped `ingest-*`/`auto-*`/`upload-*` string IDs in `uuid5` in `routers/ingest.py`, `rag.py`, `routers/upload.py`.
- `docs/jc-ingest.sh`: `JC_URL` updated `.210``.212`.
- **Docs rebuilt**: 159 chunks (source `docs`) re-ingested via `/api/ingest` (README, ai.md, docker.md, CLAUDE.md, wiki/*). RAG now 378 vectors; chat verified injecting "Retrieved Context".
- **Tests**: all 244 pass (run per-file in a throwaway venv; the full-suite run deadlocks on TestClient/AMQP ordering, not a code failure).
### Follow-ups
- AMQP wiring: cluster subs degrade gracefully — **moot in the single-node (jarvis) deployment** until a multi-node cluster is stood back up. Needs `CAIC_AMQP_URL` + credentials if that happens.
- `CAIC_TRIAGE_BASE` set but triage not yet invoked by chat (config-only until TASK 2 wiring).
### Blocked
- Ball Gunner assets — waiting on Canva designs
### Upcoming (backlog)
- ~~B3 — Docker distribution~~ [DONE]
### Key config values (current)
- **Current VERSION**: `v1.1.0` in `config.py`.
- `SESSION_TIMEOUT_SECONDS = 3600`
- `DEFAULT_MODEL = "qwen2.5-7b-instruct"` (overridable via `CAIC_DEFAULT_MODEL`)
- `LLAMA_SERVER_BASE = "http://localhost:8081"` (overridable via env var)
+142
View File
@@ -0,0 +1,142 @@
"""
cAIc — AMQP connection manager.
Single persistent aio-pika connection with auto-reconnect.
"""
import asyncio
import json
import logging
try:
import aio_pika
from aio_pika import DeliveryMode, ExchangeType
from aio_pika import RobustConnection, RobustChannel
HAS_AIO_PIKA = True
except ImportError:
HAS_AIO_PIKA = False
from config import (
AMQP_RECONNECT_DELAY,
AMQP_EXCHANGE_ADMIN,
AMQP_EXCHANGE_SYSTEM,
get_amqp_url,
)
log = logging.getLogger("caic")
_connection = None
_channel = None
_lock = asyncio.Lock()
_subscriptions = [] # list of (exchange, routing_keys, handler_fn)
async def subscribe(exchange: str, routing_keys: list[str], handler) -> None:
"""Subscribe to routing keys on an exchange.
Creates an exclusive anonymous queue bound to the given routing keys.
Handler receives (exchange, routing_key, payload_dict).
On reconnect: _rebind_subscriptions recreates all subscriptions.
"""
if not HAS_AIO_PIKA:
log.warning("aio-pika not installed — cannot subscribe")
return
ch = await get_channel()
if ch is None:
log.error("cannot subscribe — no AMQP channel")
return
# Track subscription before attempting so reconnect catches it even if this try fails
_subscriptions.append((exchange, routing_keys, handler))
try:
queue = await ch.declare_queue("", exclusive=True)
ex = await ch.get_exchange(exchange)
for rk in routing_keys:
await queue.bind(ex, rk)
async def _dispatch(msg: aio_pika.IncomingMessage):
async with msg.process():
try:
payload = json.loads(msg.body.decode())
await handler(exchange, msg.routing_key, payload)
except Exception:
log.exception("AMQP handler error for %s %s", exchange, msg.routing_key)
await queue.consume(_dispatch)
except Exception:
log.exception("AMQP subscribe failed for %s %s", exchange, routing_keys)
async def _rebind_subscriptions() -> None:
"""Re-create all subscriptions after reconnect."""
subs = list(_subscriptions)
_subscriptions.clear()
for exchange, routing_keys, handler in subs:
await subscribe(exchange, routing_keys, handler)
if subs:
log.info("AMQP subscriptions rebound")
async def connect() -> None:
if not HAS_AIO_PIKA:
log.warning("aio-pika not installed — AMQP disabled")
return
async with _lock:
global _connection, _channel
if _connection and not _connection.is_closed:
return
url = get_amqp_url()
log.info("connecting to AMQP broker")
try:
conn = await aio_pika.connect_robust(url)
except Exception as exc:
log.warning("AMQP connection failed — %s", exc)
return
ch = await conn.channel()
for ex in (AMQP_EXCHANGE_ADMIN, AMQP_EXCHANGE_SYSTEM):
await ch.declare_exchange(ex, ExchangeType.TOPIC, durable=True)
_connection = conn
_channel = ch
await _rebind_subscriptions()
log.info("AMQP connected, exchanges declared")
async def disconnect() -> None:
if not HAS_AIO_PIKA:
return
async with _lock:
global _connection, _channel
if _channel and not _channel.is_closed:
await _channel.close()
if _connection and not _connection.is_closed:
await _connection.close()
_channel = None
_connection = None
log.info("AMQP disconnected")
async def get_channel():
if not HAS_AIO_PIKA:
return None
if _channel is None or _channel.is_closed:
log.warning("AMQP channel missing, attempting reconnect")
try:
await connect()
except Exception:
log.exception("AMQP reconnect failed")
return None
return _channel
async def publish(exchange: str, routing_key: str, payload: dict) -> None:
if not HAS_AIO_PIKA:
log.error("cannot publish — aio-pika not installed")
return
ch = await get_channel()
if ch is None:
log.error("cannot publish — no AMQP channel available")
return
try:
body = json.dumps(payload).encode()
msg = aio_pika.Message(body, delivery_mode=DeliveryMode.PERSISTENT)
ex = await ch.get_exchange(exchange)
await ex.publish(msg, routing_key)
except Exception:
log.exception("AMQP publish failed")
+60 -15
View File
@@ -1,10 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
JarvisChat - Entry point. cAIc - Entry point.
Creates the FastAPI app, registers middleware, mounts all routers. Creates the FastAPI app, registers middleware, mounts all routers.
""" """
import logging import logging
import logging.handlers import logging.handlers
import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
@@ -13,12 +14,15 @@ from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from config import VERSION, RATE_WINDOW_SECONDS from amqp import connect as amqp_connect, disconnect as amqp_disconnect
from db import init_db from cluster import start_cluster_subscriptions
from config import VERSION, DEFAULT_MODEL, RATE_WINDOW_SECONDS, UPLOAD_DIR, RAG_MAX_VECTORS, RAG_EVICTION_HIGH_WATER, RAG_EVICTION_LOW_WATER, RAG_EVICTION_BATCH
from db import init_db, get_db, get_setting
from hardware import assess_hardware
from memory import get_memory_count from memory import get_memory_count
from security import ( from security import (
get_client_ip, is_ip_allowed, check_rate_limit, rate_policy, get_client_ip, is_ip_allowed, check_rate_limit, rate_policy,
origin_allowed, is_state_changing, request_body_limit, origin_allowed, request_body_limit,
audit_event, customer_error_envelope, log_incident, audit_event, customer_error_envelope, log_incident,
) )
from auth import get_session, is_admin_only, router as auth_router from auth import get_session, is_admin_only, router as auth_router
@@ -32,13 +36,24 @@ import routers.skills as skills
import routers.chat as chat import routers.chat as chat
import routers.search_route as search_route import routers.search_route as search_route
import routers.completions as completions import routers.completions as completions
import routers.upload as upload
import routers.ingest as ingest
import routers.hardware as hardware
import routers.rag_admin as rag_admin
import routers.cluster as cluster_router
import routers.image as image_router
# --- Logging --- # --- Logging ---
log = logging.getLogger("jarvischat") log = logging.getLogger("caic")
log.setLevel(logging.DEBUG) log.setLevel(logging.DEBUG)
syslog_handler = logging.handlers.SysLogHandler(address="/dev/log") syslog_address = os.environ.get("CAIC_SYSLOG_ADDRESS", "/dev/log")
syslog_handler.setFormatter(logging.Formatter("jarvischat[%(process)d]: %(levelname)s %(message)s")) if syslog_address:
log.addHandler(syslog_handler) try:
syslog_handler = logging.handlers.SysLogHandler(address=syslog_address)
syslog_handler.setFormatter(logging.Formatter("caic[%(process)d]: %(levelname)s %(message)s"))
log.addHandler(syslog_handler)
except Exception:
log.warning("syslog not available at %s -- skipping", syslog_address)
BASE_DIR = Path(__file__).parent BASE_DIR = Path(__file__).parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
@@ -46,14 +61,38 @@ templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
log.info(f"JarvisChat {VERSION} starting up") log.info(f"cAIc {VERSION} starting up")
os.makedirs(UPLOAD_DIR, exist_ok=True)
init_db() init_db()
from crypto import ensure_key
ensure_key()
log.info(f"Memory system: {get_memory_count()} memories loaded") log.info(f"Memory system: {get_memory_count()} memories loaded")
await assess_hardware()
from model_pull import ensure_model
_db = get_db()
_user_model = get_setting(_db, "default_model", DEFAULT_MODEL)
_db.close()
await ensure_model(_user_model)
await amqp_connect()
await start_cluster_subscriptions()
if RAG_MAX_VECTORS > 0:
if RAG_EVICTION_HIGH_WATER <= RAG_EVICTION_LOW_WATER:
log.warning(
f"RAG_EVICTION_HIGH_WATER={RAG_EVICTION_HIGH_WATER} <= "
f"RAG_EVICTION_LOW_WATER={RAG_EVICTION_LOW_WATER} — eviction will never fire"
)
if RAG_EVICTION_BATCH <= 0:
log.warning(f"RAG_EVICTION_BATCH={RAG_EVICTION_BATCH} clamped to 1")
else:
log.warning("RAG_MAX_VECTORS <= 0 — RAG eviction disabled")
yield yield
log.info("JarvisChat shutting down") log.info("cAIc shutting down")
await amqp_disconnect()
app = FastAPI(title="JarvisChat", lifespan=lifespan) app = FastAPI(title="cAIc", lifespan=lifespan)
@app.exception_handler(Exception) @app.exception_handler(Exception)
@@ -99,10 +138,15 @@ async def session_auth_middleware(request: Request, call_next):
unauth_paths = { unauth_paths = {
"/api/auth/login", "/api/auth/logout", "/api/auth/session", "/api/auth/login", "/api/auth/logout", "/api/auth/session",
"/api/auth/heartbeat", "/api/auth/guest", "/api/auth/heartbeat", "/api/auth/guest", "/api/ingest", "/api/hardware",
} }
if path.startswith("/api/"): # Bearer-token-authenticated endpoints are reached by CLI/terminal tooling
# (curl, caic-ingest.sh) that sends no Origin/Referer header — exempt them
# from the browser origin check.
origin_exempt_paths = {"/api/ingest"}
if path.startswith("/api/") and path not in origin_exempt_paths:
if not origin_allowed(request): if not origin_allowed(request):
audit_event("origin_check", "denied", ip=ip, role="none", audit_event("origin_check", "denied", ip=ip, role="none",
details=f"{request.method} {path}", warning=True) details=f"{request.method} {path}", warning=True)
@@ -138,11 +182,12 @@ async def index(request: Request):
for router_module in [ for router_module in [
auth_router, conversations.router, memories.router, models.router, auth_router, conversations.router, memories.router, models.router,
presets.router, profile.router, settings.router, skills.router, presets.router, profile.router, settings.router, skills.router,
chat.router, search_route.router, completions.router, chat.router, search_route.router, completions.router, upload.router, ingest.router, hardware.router,
rag_admin.router, cluster_router.router, image_router.router,
]: ]:
app.include_router(router_module) app.include_router(router_module)
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8080) uvicorn.run(app, host=os.environ.get("CAIC_HOST", "0.0.0.0"), port=int(os.environ.get("CAIC_PORT", "8080")))
-2334
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,5 +1,5 @@
""" """
JarvisChat - Auth: session management, PIN verification, middleware, auth routes. cAIc - Auth: session management, PIN verification, middleware, auth routes.
""" """
import hashlib import hashlib
import hmac import hmac
@@ -17,11 +17,11 @@ from db import get_db, get_setting
from security import ( from security import (
SESSIONS, PIN_ATTEMPTS, SESSION_LOCK, BODY_LIMIT_DEFAULT_BYTES, SESSIONS, PIN_ATTEMPTS, SESSION_LOCK, BODY_LIMIT_DEFAULT_BYTES,
audit_event, get_client_ip, is_ip_allowed, check_rate_limit, audit_event, get_client_ip, is_ip_allowed, check_rate_limit,
rate_policy, origin_allowed, is_state_changing, request_body_limit, rate_policy, origin_allowed, request_body_limit,
read_json_body, hash_pin, customer_error_envelope, log_incident, read_json_body, hash_pin, customer_error_envelope, log_incident,
) )
log = logging.getLogger("jarvischat") log = logging.getLogger("caic")
router = APIRouter() router = APIRouter()
+327
View File
@@ -0,0 +1,327 @@
"""
cAIc — Cluster protocol implementation.
Maintains node registry, event log, coordinator state, and ping-based health checks.
"""
import asyncio
import logging
import os
import uuid
from collections import deque
from datetime import datetime, timezone
from amqp import publish, subscribe
from config import AMQP_EXCHANGE_ADMIN, AMQP_EXCHANGE_SYSTEM
log = logging.getLogger("caic")
CLUSTER_NODES: dict[str, dict] = {}
CLUSTER_EVENTS: deque = deque(maxlen=1000)
CLUSTER_COORDINATOR: str | None = None
_pending_pings: dict[str, tuple[str, asyncio.Event]] = {}
_pending_image: dict[str, tuple[str, asyncio.Event]] = {}
NODE_NAME: str = os.environ.get("CAIC_NODE_NAME", "jarvis")
PING_TIMEOUT: float = 5.0
def _push_event(category: str, severity: str, node_name: str | None, message: str, details: dict | None = None) -> dict:
record = {
"category": category,
"severity": severity,
"node": node_name,
"message": message,
"details": details or {},
"timestamp": datetime.now(timezone.utc).isoformat() + "Z",
}
CLUSTER_EVENTS.append(record)
level = {"info": logging.INFO, "warn": logging.WARNING, "error": logging.ERROR, "critical": logging.CRITICAL}.get(severity, logging.INFO)
log.log(level, "[cluster] %s: %s", node_name or "system", message)
return record
async def handle_registration(exchange: str, routing_key: str, payload: dict) -> None:
global CLUSTER_COORDINATOR
node_name = payload.get("node_name", routing_key.split(".")[1] if "." in routing_key else "unknown")
node_type = payload.get("node_type", "worker")
if node_name in CLUSTER_NODES:
_push_event("cluster", "warn", node_name, "Duplicate registration rejected")
await publish(AMQP_EXCHANGE_ADMIN, f"node.{node_name}.rejected", {
"from": NODE_NAME, "node_name": node_name, "type": "rejected",
"reason": "duplicate_node_name",
"timestamp": datetime.now(timezone.utc).isoformat() + "Z",
})
return
if "node_type" not in payload or "capabilities" not in payload:
_push_event("cluster", "warn", node_name, "Malformed registration rejected")
await publish(AMQP_EXCHANGE_ADMIN, f"node.{node_name}.rejected", {
"from": NODE_NAME, "node_name": node_name, "type": "rejected",
"reason": "malformed_payload",
"timestamp": datetime.now(timezone.utc).isoformat() + "Z",
})
return
now = datetime.now(timezone.utc).isoformat() + "Z"
node = {
"name": node_name,
"type": node_type,
"status": "active",
"ip": payload.get("ip"),
"capabilities": payload.get("capabilities", []),
"active_model": payload.get("active_model"),
"inventory": payload.get("inventory", []),
"load": payload.get("load"),
"registered_at": now,
"last_seen": now,
}
CLUSTER_NODES[node_name] = node
_push_event("cluster", "info", node_name, f"Node registered (type={node_type})")
await publish(AMQP_EXCHANGE_ADMIN, f"node.{node_name}.admitted", {
"from": NODE_NAME, "node_name": node_name, "type": "admitted",
"coordinator": CLUSTER_COORDINATOR,
"timestamp": now,
})
if node_type == "coordinator" and CLUSTER_COORDINATOR is None:
CLUSTER_COORDINATOR = node_name
_push_event("cluster", "info", node_name, "Elected as coordinator")
await publish(AMQP_EXCHANGE_SYSTEM, "cluster.coordinator.response", {
"from": NODE_NAME, "type": "coord_response",
"coordinator": node_name, "nodes": list(CLUSTER_NODES.keys()),
"timestamp": now,
})
async def handle_deregistration(exchange: str, routing_key: str, payload: dict) -> None:
global CLUSTER_COORDINATOR
node_name = payload.get("node_name", routing_key.split(".")[1] if "." in routing_key else "unknown")
removed = CLUSTER_NODES.pop(node_name, None)
if not removed:
log.warning("[cluster] deregistration for unknown node %s", node_name)
return
_push_event("cluster", "info", node_name, "Node deregistered")
if CLUSTER_COORDINATOR == node_name:
CLUSTER_COORDINATOR = None
_push_event("cluster", "warn", node_name, "Coordinator deregistered — no coordinator active")
async def handle_pong(exchange: str, routing_key: str, payload: dict) -> None:
node_name = payload.get("node_name", routing_key.split(".")[1] if "." in routing_key else "unknown")
correlation_id = payload.get("correlation_id")
if correlation_id and correlation_id in _pending_pings:
_, event = _pending_pings.pop(correlation_id)
event.set()
if node_name in CLUSTER_NODES:
now = datetime.now(timezone.utc).isoformat() + "Z"
CLUSTER_NODES[node_name]["last_seen"] = now
if "status" in payload:
CLUSTER_NODES[node_name]["status"] = payload["status"]
if "active_model" in payload:
CLUSTER_NODES[node_name]["active_model"] = payload["active_model"]
if "load" in payload:
CLUSTER_NODES[node_name]["load"] = payload["load"]
else:
log.warning("[cluster] pong from unknown node %s", node_name)
async def handle_event(exchange: str, routing_key: str, payload: dict) -> None:
node_name = payload.get("node_name", routing_key.split(".")[1] if "." in routing_key else "unknown")
severity = payload.get("severity", "info")
message = payload.get("message", "")
details = payload.get("details")
_push_event("application", severity, node_name, message, details)
async def handle_coordinator_query(exchange: str, routing_key: str, payload: dict) -> None:
if CLUSTER_COORDINATOR is None:
return
now = datetime.now(timezone.utc).isoformat() + "Z"
await publish(AMQP_EXCHANGE_SYSTEM, "cluster.coordinator.response", {
"from": NODE_NAME, "type": "coord_response",
"coordinator": CLUSTER_COORDINATOR,
"nodes": list(CLUSTER_NODES.keys()),
"timestamp": now,
})
async def ping_node(node_name: str) -> bool:
global CLUSTER_COORDINATOR
if node_name not in CLUSTER_NODES:
return False
correlation_id = str(uuid.uuid4())
event = asyncio.Event()
_pending_pings[correlation_id] = (node_name, event)
now = datetime.now(timezone.utc).isoformat() + "Z"
await publish(AMQP_EXCHANGE_ADMIN, f"node.{node_name}.ping", {
"from": NODE_NAME, "node_name": node_name, "type": "ping",
"correlation_id": correlation_id, "timestamp": now,
})
try:
await asyncio.wait_for(event.wait(), timeout=PING_TIMEOUT)
return True
except asyncio.TimeoutError:
_pending_pings.pop(correlation_id, None)
CLUSTER_NODES.pop(node_name, None)
_push_event("cluster", "warn", node_name, "Node unresponsive — deregistered after ping timeout")
if CLUSTER_COORDINATOR == node_name:
CLUSTER_COORDINATOR = None
_push_event("cluster", "warn", node_name, "Coordinator unresponsive — no coordinator active")
return False
async def request_model_swap(node_name: str, model_filename: str) -> bool:
"""Request a worker node to swap its active model."""
if node_name not in CLUSTER_NODES:
log.warning("request_model_swap: unknown node %s", node_name)
return False
CLUSTER_NODES[node_name]["status"] = "swapping"
_push_event("cluster", "info", node_name, f"Model swap requested: {model_filename}")
now = datetime.now(timezone.utc).isoformat() + "Z"
await publish(AMQP_EXCHANGE_ADMIN, f"node.{node_name}.cmd.swap_model", {
"model_filename": model_filename,
"requested_at": now,
})
return True
async def handle_model_ready(exchange: str, routing_key: str, payload: dict) -> None:
node_name = payload.get("node_name", routing_key.split(".")[1] if "." in routing_key else "unknown")
model_filename = payload.get("active_model")
port = payload.get("port", 8081)
if node_name not in CLUSTER_NODES:
log.warning("handle_model_ready: unknown node %s", node_name)
return
inventory = CLUSTER_NODES[node_name].get("inventory") or []
model_info = None
for inv in inventory:
if inv.get("filename") == model_filename:
model_info = {**inv, "port": port}
break
if model_info is None:
model_info = {"filename": model_filename, "port": port}
now = datetime.now(timezone.utc).isoformat() + "Z"
CLUSTER_NODES[node_name]["active_model"] = model_info
CLUSTER_NODES[node_name]["status"] = "active"
CLUSTER_NODES[node_name]["last_seen"] = now
_push_event("cluster", "info", node_name, f"Model swap complete: {model_filename}")
async def handle_heartbeat(exchange: str, routing_key: str, payload: dict) -> None:
node_name = payload.get("node_name", routing_key.split(".")[1] if "." in routing_key else "unknown")
if node_name not in CLUSTER_NODES:
log.warning("heartbeat from unknown node %s — ignore, full registration required", node_name)
return
now = datetime.now(timezone.utc).isoformat() + "Z"
CLUSTER_NODES[node_name]["last_seen"] = now
async def handle_model_failed(exchange: str, routing_key: str, payload: dict) -> None:
node_name = payload.get("node_name", routing_key.split(".")[1] if "." in routing_key else "unknown")
error = payload.get("error", "unknown error")
if node_name not in CLUSTER_NODES:
log.warning("handle_model_failed: unknown node %s", node_name)
return
CLUSTER_NODES[node_name]["status"] = "error"
_push_event("cluster", "error", node_name, f"Model swap failed: {error}")
async def handle_image_generated(exchange: str, routing_key: str, payload: dict) -> None:
node_name = payload.get("node_name", routing_key.split(".")[1] if "." in routing_key else "unknown")
request_id = payload.get("request_id")
if request_id and request_id in _pending_image:
_, event = _pending_image.pop(request_id)
_pending_image[request_id] = (payload.get("image_base64", ""), event)
event.set()
if node_name in CLUSTER_NODES:
CLUSTER_NODES[node_name]["last_seen"] = datetime.now(timezone.utc).isoformat() + "Z"
async def handle_image_failed(exchange: str, routing_key: str, payload: dict) -> None:
node_name = payload.get("node_name", routing_key.split(".")[1] if "." in routing_key else "unknown")
request_id = payload.get("request_id")
error = payload.get("error", "unknown error")
if request_id and request_id in _pending_image:
_pending_image[request_id] = ("", _pending_image[request_id][1])
_pending_image[request_id][1].set()
_push_event("application", "error", node_name, f"Image generation failed: {error}")
async def request_image_generate(
node_name: str, prompt: str, negative_prompt: str = "",
width: int = 1024, height: int = 1024, steps: int = 20,
seed: int = -1, model: str = "", timeout: float = 120,
) -> str | None:
if node_name not in CLUSTER_NODES:
log.warning("request_image_generate: unknown node %s", node_name)
return None
caps = CLUSTER_NODES[node_name].get("capabilities", [])
if "image_gen" not in caps:
log.warning("request_image_generate: node %s lacks image_gen capability", node_name)
return None
request_id = str(uuid.uuid4())
event = asyncio.Event()
_pending_image[request_id] = ("", event)
now = datetime.now(timezone.utc).isoformat() + "Z"
_push_event("application", "info", node_name, f"Image generation requested: {prompt[:60]}...")
await publish(AMQP_EXCHANGE_ADMIN, f"node.{node_name}.cmd.image_generate", {
"from": NODE_NAME, "type": "image_generate",
"request_id": request_id,
"prompt": prompt,
"negative_prompt": negative_prompt,
"width": width, "height": height,
"steps": steps, "seed": seed, "model": model,
"timestamp": now,
})
try:
await asyncio.wait_for(event.wait(), timeout=timeout)
result = _pending_image.pop(request_id, (None, None))
return result[0]
except asyncio.TimeoutError:
_pending_image.pop(request_id, None)
_push_event("application", "warn", node_name, "Image generation timed out")
return None
SUBSCRIBE_TABLE = [
(AMQP_EXCHANGE_ADMIN, ["node.*.register"], handle_registration),
(AMQP_EXCHANGE_ADMIN, ["node.*.deregister"], handle_deregistration),
(AMQP_EXCHANGE_ADMIN, ["node.*.pong"], handle_pong),
(AMQP_EXCHANGE_SYSTEM, ["node.*.event"], handle_event),
(AMQP_EXCHANGE_SYSTEM, ["cluster.coordinator.query"], handle_coordinator_query),
(AMQP_EXCHANGE_SYSTEM, ["node.*.heartbeat"], handle_heartbeat),
(AMQP_EXCHANGE_SYSTEM, ["node.*.model_ready"], handle_model_ready),
(AMQP_EXCHANGE_SYSTEM, ["node.*.model_failed"], handle_model_failed),
(AMQP_EXCHANGE_SYSTEM, ["node.*.image_generated"], handle_image_generated),
(AMQP_EXCHANGE_SYSTEM, ["node.*.image_failed"], handle_image_failed),
]
async def start_cluster_subscriptions() -> None:
for exchange, routing_keys, handler in SUBSCRIBE_TABLE:
await subscribe(exchange, routing_keys, handler)
log.info("cluster subscriptions started")
+63 -16
View File
@@ -1,37 +1,61 @@
""" """
JarvisChat - Central configuration. cAIc - Central configuration.
All constants, environment variables, limits, and skill registry live here. All constants, environment variables, limits, and skill registry live here.
""" """
import os import os
import re import re
import ipaddress import ipaddress
import logging import logging
from pathlib import Path
log = logging.getLogger("jarvischat") log = logging.getLogger("caic")
VERSION = "v1.8.5" VERSION = "v1.1.0"
OLLAMA_BASE = os.environ.get("OLLAMA_BASE", "http://localhost:11434") 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") LLAMA_SERVER_BASE = os.environ.get("LLAMA_SERVER_BASE", "http://localhost:8081")
SEARXNG_BASE = "http://localhost:8888" SEARXNG_BASE = os.environ.get("CAIC_SEARXNG_BASE", "http://localhost:8888")
DEFAULT_MODEL = "llama3.1:latest" DEFAULT_MODEL = os.environ.get("CAIC_DEFAULT_MODEL", "qwen2.5-7b-instruct")
COMPLETIONS_API_KEY = os.environ.get("JARVISCHAT_COMPLETIONS_API_KEY", "jc-sk-" + os.urandom(24).hex()) COMPLETIONS_API_KEY = os.environ.get("CAIC_COMPLETIONS_API_KEY", "caic-sk-" + os.urandom(24).hex())
MODEL_CONTEXT_LENGTH = 4096
# --- AMQP ---
AMQP_RECONNECT_DELAY = 5
AMQP_EXCHANGE_ADMIN = "jc.admin"
AMQP_EXCHANGE_SYSTEM = "jc.system"
AMQP_SECRET_PATH = os.environ.get("CAIC_AMQP_SECRET_PATH", "/run/secrets/caic_amqp_secret")
def get_amqp_url() -> str:
url = os.environ.get("CAIC_AMQP_URL")
if url:
return url
try:
with open(AMQP_SECRET_PATH) as f:
pw = f.read().strip()
except (FileNotFoundError, OSError):
pw = "password"
log.warning("AMQP secret file not found at %s — using default password", AMQP_SECRET_PATH)
return f"amqp://caic:{pw}@localhost:5672/caic"
# --- Auth --- # --- Auth ---
SESSION_TIMEOUT_SECONDS = 90 SESSION_TIMEOUT_SECONDS = 3600
MAX_PIN_ATTEMPTS = 5 MAX_PIN_ATTEMPTS = 5
PIN_LOCKOUT_SECONDS = 300 PIN_LOCKOUT_SECONDS = 300
ALLOW_DEFAULT_PIN = os.getenv("JARVISCHAT_ALLOW_DEFAULT_PIN", "false").lower() == "true" ALLOW_DEFAULT_PIN = os.getenv("CAIC_ALLOW_DEFAULT_PIN", "false").lower() == "true"
TRUSTED_ORIGINS = { TRUSTED_ORIGINS = {
origin.strip().rstrip("/") origin.strip().rstrip("/")
for origin in os.getenv("JARVISCHAT_TRUSTED_ORIGINS", "").split(",") for origin in os.getenv("CAIC_TRUSTED_ORIGINS", "").split(",")
if origin.strip() if origin.strip()
} }
DEFAULT_ALLOWED_CIDRS = "127.0.0.0/8,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" DEFAULT_ALLOWED_CIDRS = "127.0.0.0/8,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
ALLOWED_CIDRS_RAW = os.getenv("JARVISCHAT_ALLOWED_CIDRS", DEFAULT_ALLOWED_CIDRS) ALLOWED_CIDRS_RAW = os.getenv("CAIC_ALLOWED_CIDRS", DEFAULT_ALLOWED_CIDRS)
TRUST_X_FORWARDED_FOR = ( TRUST_X_FORWARDED_FOR = (
os.getenv("JARVISCHAT_TRUST_X_FORWARDED_FOR", "false").lower() == "true" os.getenv("CAIC_TRUST_X_FORWARDED_FOR", "false").lower() == "true"
) )
# --- Image generation (ComfyUI) ---
COMFYUI_BASE = os.environ.get("CAIC_COMFYUI_BASE", "http://localhost:8188")
COMFYUI_TIMEOUT = int(os.environ.get("CAIC_COMFYUI_TIMEOUT", "120"))
# --- Rate limits --- # --- Rate limits ---
RATE_WINDOW_SECONDS = 60 RATE_WINDOW_SECONDS = 60
RL_LOGIN_PER_WINDOW = 10 RL_LOGIN_PER_WINDOW = 10
@@ -46,6 +70,26 @@ BODY_LIMIT_DEFAULT_BYTES = 64 * 1024
BODY_LIMIT_CHAT_BYTES = 128 * 1024 BODY_LIMIT_CHAT_BYTES = 128 * 1024
BODY_LIMIT_PROFILE_BYTES = 256 * 1024 BODY_LIMIT_PROFILE_BYTES = 256 * 1024
# --- Upload ---
UPLOAD_DIR = os.environ.get("CAIC_UPLOAD_DIR", "/tmp/caic_uploads")
MAX_UPLOAD_BYTES = 20 * 1024 * 1024
SUPPORTED_UPLOAD_TYPES = {"text/plain", "text/markdown", "application/pdf", "application/json", "text/x-python", "text/html", "image/png", "image/jpeg", "image/gif", "image/svg+xml", "image/webp"}
QDRANT_URL = os.environ.get("CAIC_QDRANT_URL", "http://localhost:6333")
RAG_COLLECTION = os.environ.get("CAIC_RAG_COLLECTION", "caic_rag")
UPLOAD_CONTEXT_EXPIRY_HOURS = 1
BODY_LIMIT_UPLOAD_BYTES = MAX_UPLOAD_BYTES
# --- RAG eviction ---
RAG_MAX_VECTORS = int(os.environ.get("CAIC_RAG_MAX_VECTORS", "50000"))
HW_STATE_PATH = os.environ.get("CAIC_HW_STATE_PATH", str(Path(__file__).parent / "hardware_state.json"))
RAG_EVICTION_HIGH_WATER = 0.80
RAG_EVICTION_LOW_WATER = 0.20
RAG_EVICTION_BATCH = 1000
RAG_PINNED_SOURCES = ["upload", "profile"]
RAG_GRACE_HOURS = 1
RAG_ACCESS_WEIGHT = 1.0
RAG_AGE_WEIGHT = 0.1
MAX_CHAT_MESSAGE_CHARS = 8000 MAX_CHAT_MESSAGE_CHARS = 8000
MAX_SEARCH_QUERY_CHARS = 500 MAX_SEARCH_QUERY_CHARS = 500
MAX_PROFILE_CHARS = 32000 MAX_PROFILE_CHARS = 32000
@@ -66,6 +110,11 @@ ALLOWED_SETTINGS_KEYS = {
"skills_enabled", "skills_enabled",
} }
# --- Triage / query routing ---
TRIAGE_BASE = os.environ.get("CAIC_TRIAGE_BASE", "http://127.0.0.1:8083/v1")
TRIAGE_TIMEOUT = 10
FALLBACK_TO_DEFAULT = True
# --- Perplexity --- # --- Perplexity ---
PERPLEXITY_THRESHOLD = 15.0 PERPLEXITY_THRESHOLD = 15.0
@@ -130,12 +179,10 @@ ALLOWED_NETWORKS = parse_allowed_cidrs(ALLOWED_CIDRS_RAW)
DEFAULT_PROFILE = """You are a coding companion running locally on a machine called "jarvis". DEFAULT_PROFILE = """You are a coding companion running locally on a machine called "jarvis".
## Environment ## Environment
- jarvis: Debian 13 (trixie) x86_64, AMD Ryzen 5 5600X, 16GB RAM, AMD RX 6600 XT (8GB VRAM) - jarvis: Debian 13 (trixie) x86_64, AMD Ryzen 5 5600X, 16GB RAM, AMD RX 6600 XT (8GB VRAM), IP 192.168.50.212
- ultron: Debian 13, Ryzen 7 7840HS, 16GB RAM, primary AI inference node, IP 192.168.50.108 - Single-node deployment — all cAIc services run on jarvis: llama-server :8081 (OpenAI-compat API), Qdrant :6333, Ollama :11434, SearXNG :8888, RabbitMQ :5672, ComfyUI :8188
- Corsair: Windows 11, gaming/streaming rig, RTX 5070 Ti
- pivault: RPi 5, 8GB RAM, Debian 13, 11TB RAID5 NAS at /mnt/pivault, IP 192.168.50.158 - pivault: RPi 5, 8GB RAM, Debian 13, 11TB RAID5 NAS at /mnt/pivault, IP 192.168.50.158
- Router: ASUS ROG Rapture GT-BE98 Pro "BigBlinkyRouter" at 192.168.50.1 - Router: ASUS ROG Rapture GT-BE98 Pro "BigBlinkyRouter" at 192.168.50.1
- llama-server on ultron:8081 (OpenAI-compat API), Qdrant on ultron:6333
## About the User ## About the User
- Experienced developer, BS in Computer Science (Oklahoma State), coding since 1981 (TRS-80) - Experienced developer, BS in Computer Science (Oklahoma State), coding since 1981 (TRS-80)
+78
View File
@@ -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)
+97 -6
View File
@@ -1,5 +1,5 @@
""" """
JarvisChat - Database layer. cAIc - Database layer.
Schema init, connection factory, settings helpers, skill state management. Schema init, connection factory, settings helpers, skill state management.
""" """
import logging import logging
@@ -15,26 +15,33 @@ from config import (
BUILTIN_SKILLS, DEFAULT_MODEL, DEFAULT_PRESETS, DEFAULT_PROFILE, BUILTIN_SKILLS, DEFAULT_MODEL, DEFAULT_PRESETS, DEFAULT_PROFILE,
MAX_SKILL_PROMPT_CHARS, ALLOWED_NETWORKS, MAX_SKILL_PROMPT_CHARS, ALLOWED_NETWORKS,
) )
from crypto import encrypt_text, decrypt_text
log = logging.getLogger("jarvischat") log = logging.getLogger("caic")
BASE_DIR = Path(__file__).parent BASE_DIR = Path(__file__).parent
DB_PATH = BASE_DIR / "jarvischat.db" DB_PATH = Path(os.environ.get("CAIC_DB_PATH", str(BASE_DIR / "caic.db")))
def get_db(): def get_db():
"""Return a new SQLite connection. Each call creates a fresh connection
(not pooled) so callers must close() when done."""
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON") conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA busy_timeout = 5000")
return conn return conn
def get_setting(db, key: str, default: str = "") -> str: def get_setting(db, key: str, default: str = "") -> str:
"""Read a single settings row, returning *default* if the key is missing."""
row = db.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone() row = db.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
return row["value"] if row else default return row["value"] if row else default
def list_skills_with_state(db) -> list: def list_skills_with_state(db) -> list:
"""Merge built-in skill definitions with per-skill enabled/disabled state from the DB."""
rows = db.execute("SELECT skill_key, enabled, updated_at FROM skills").fetchall() rows = db.execute("SELECT skill_key, enabled, updated_at FROM skills").fetchall()
state_by_key = { state_by_key = {
row["skill_key"]: {"enabled": bool(row["enabled"]), "updated_at": row["updated_at"]} row["skill_key"]: {"enabled": bool(row["enabled"]), "updated_at": row["updated_at"]}
@@ -48,6 +55,7 @@ def list_skills_with_state(db) -> list:
def set_skill_enabled(db, skill_key: str, enabled: bool) -> None: def set_skill_enabled(db, skill_key: str, enabled: bool) -> None:
"""Insert or replace a skill's enabled state (UPSERT)."""
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
db.execute( db.execute(
"INSERT OR REPLACE INTO skills (skill_key, enabled, updated_at) VALUES (?, ?, ?)", "INSERT OR REPLACE INTO skills (skill_key, enabled, updated_at) VALUES (?, ?, ?)",
@@ -56,6 +64,7 @@ def set_skill_enabled(db, skill_key: str, enabled: bool) -> None:
def format_active_skills_prompt(skills: list) -> str: def format_active_skills_prompt(skills: list) -> str:
"""Build the 'Active Skills' section of the system prompt from the provided skill list."""
lines = [ lines = [
"## Active Skills", "## Active Skills",
"Use these skills only when needed. Prefer concise answers over unnecessary tool usage.", "Use these skills only when needed. Prefer concise answers over unnecessary tool usage.",
@@ -68,11 +77,66 @@ def format_active_skills_prompt(skills: list) -> str:
return text return text
def insert_upload_context(db, conversation_id: str, filename: str, content: str, expires_at: str, content_type: str = "text/plain") -> int:
"""Persist an upload context entry (encrypted content) tied to a conversation."""
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, encrypt_text(content), content_type, now, expires_at),
)
return cur.lastrowid
def list_upload_context_by_conversation(db, conversation_id: str):
"""Return all upload contexts for a given conversation (content excluded for brevity)."""
rows = db.execute(
"SELECT id, conversation_id, filename, content_type, created_at, expires_at FROM upload_context WHERE conversation_id = ? ORDER BY id ASC",
(conversation_id,),
).fetchall()
return [dict(r) for r in rows]
def delete_upload_context_by_id(db, context_id: int) -> bool:
"""Delete an upload context entry, returning True if a row was actually removed."""
cur = db.execute("DELETE FROM upload_context WHERE id = ?", (context_id,))
return cur.rowcount > 0
def get_upload_context(db, context_id: int):
"""Fetch a single upload context, returning its decrypted content.
If the context has expired (past expires_at), it is deleted and None returned.
"""
row = db.execute(
"SELECT id, conversation_id, filename, content, content_type, expires_at FROM upload_context WHERE id = ?",
(context_id,),
).fetchone()
if not row:
return None
expires = datetime.fromisoformat(row["expires_at"])
if expires < datetime.now(timezone.utc):
db.execute("DELETE FROM upload_context WHERE id = ?", (context_id,))
db.commit()
return None
d = dict(row)
d["content"] = decrypt_text(d["content"])
return d
def init_db(): def init_db():
"""Run initial schema creation and seed default data.
Idempotent — safe to call on every startup. Creates tables if missing,
runs ALTER TABLE to add columns that may not exist on legacy databases,
and inserts defaults for profile, presets, settings, skills, and admin PIN.
"""
from security import hash_pin from security import hash_pin
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA busy_timeout = 5000")
# --- Core tables ---
conn.execute(""" conn.execute("""
CREATE TABLE IF NOT EXISTS conversations ( CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY, title TEXT NOT NULL DEFAULT 'New Chat', id TEXT PRIMARY KEY, title TEXT NOT NULL DEFAULT 'New Chat',
@@ -83,6 +147,7 @@ def init_db():
CREATE TABLE IF NOT EXISTS messages ( CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT, conversation_id TEXT NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT, conversation_id TEXT NOT NULL,
role TEXT NOT NULL, content TEXT NOT NULL, created_at TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, created_at TEXT NOT NULL,
perplexity REAL,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
) )
""") """)
@@ -103,12 +168,35 @@ def init_db():
skill_key TEXT PRIMARY KEY, enabled INTEGER NOT NULL DEFAULT 1, updated_at TEXT NOT NULL skill_key TEXT PRIMARY KEY, enabled INTEGER NOT NULL DEFAULT 1, updated_at TEXT NOT NULL
) )
""") """)
# FTS5 virtual table for full-text memory search
conn.execute(""" conn.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS memories USING fts5( CREATE VIRTUAL TABLE IF NOT EXISTS memories USING fts5(
fact, topic, source, created_at UNINDEXED fact, topic, source, created_at UNINDEXED
) )
""") """)
conn.execute("""
CREATE TABLE IF NOT EXISTS upload_context (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT,
filename TEXT NOT NULL,
content TEXT NOT NULL,
content_type TEXT DEFAULT 'text/plain',
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
)
""")
# --- Backfill columns for legacy databases (safe to run every time) ---
try:
conn.execute("ALTER TABLE upload_context ADD COLUMN content_type TEXT DEFAULT 'text/plain'")
except Exception:
pass # column already exists
try:
conn.execute("ALTER TABLE messages ADD COLUMN perplexity REAL")
except Exception:
pass
# --- Seed default data (only if tables are empty) ---
if not conn.execute("SELECT id FROM profile WHERE id = 1").fetchone(): if not conn.execute("SELECT id FROM profile WHERE id = 1").fetchone():
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
conn.execute("INSERT INTO profile (id, content, updated_at) VALUES (1, ?, ?)", (DEFAULT_PROFILE, now)) conn.execute("INSERT INTO profile (id, content, updated_at) VALUES (1, ?, ?)", (DEFAULT_PROFILE, now))
@@ -134,19 +222,22 @@ def init_db():
if not conn.execute("SELECT skill_key FROM skills WHERE skill_key = ?", (skill["key"],)).fetchone(): if not conn.execute("SELECT skill_key FROM skills WHERE skill_key = ?", (skill["key"],)).fetchone():
conn.execute("INSERT INTO skills (skill_key, enabled, updated_at) VALUES (?, 1, ?)", (skill["key"], now)) conn.execute("INSERT INTO skills (skill_key, enabled, updated_at) VALUES (?, 1, ?)", (skill["key"], now))
# --- Admin PIN bootstrap ---
# If no PIN hash exists on disk, seed one from env var CAIC_ADMIN_PIN
# or, if CAIC_ALLOW_DEFAULT_PIN=true, from the hardcoded default "1234".
existing_pin_hash = conn.execute("SELECT value FROM settings WHERE key = 'admin_pin_hash'").fetchone() existing_pin_hash = conn.execute("SELECT value FROM settings WHERE key = 'admin_pin_hash'").fetchone()
existing_pin_salt = conn.execute("SELECT value FROM settings WHERE key = 'admin_pin_salt'").fetchone() existing_pin_salt = conn.execute("SELECT value FROM settings WHERE key = 'admin_pin_salt'").fetchone()
if not existing_pin_hash or not existing_pin_salt: if not existing_pin_hash or not existing_pin_salt:
from config import ALLOW_DEFAULT_PIN from config import ALLOW_DEFAULT_PIN
configured_pin = os.getenv("JARVISCHAT_ADMIN_PIN", "").strip() configured_pin = os.getenv("CAIC_ADMIN_PIN", "").strip()
if re.fullmatch(r"\d{4}", configured_pin): if re.fullmatch(r"\d{4}", configured_pin):
seed_pin, pin_source = configured_pin, "env" seed_pin, pin_source = configured_pin, "env"
elif ALLOW_DEFAULT_PIN: elif ALLOW_DEFAULT_PIN:
seed_pin, pin_source = "1234", "default" seed_pin, pin_source = "1234", "default"
else: else:
raise RuntimeError( raise RuntimeError(
"Admin PIN bootstrap blocked: set JARVISCHAT_ADMIN_PIN to a 4-digit PIN " "Admin PIN bootstrap blocked: set CAIC_ADMIN_PIN to a 4-digit PIN "
"or set JARVISCHAT_ALLOW_DEFAULT_PIN=true." "or set CAIC_ALLOW_DEFAULT_PIN=true."
) )
salt_hex, pin_hash_hex = hash_pin(seed_pin) salt_hex, pin_hash_hex = hash_pin(seed_pin)
conn.execute("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", ("admin_pin_hash", pin_hash_hex)) conn.execute("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", ("admin_pin_hash", pin_hash_hex))
+118
View File
@@ -0,0 +1,118 @@
# cAIc — Docker Compose stack
# Coordinator: cAIc + SearXNG + Qdrant + RabbitMQ + llama-server + Ollama
#
# Usage:
# cp .env.example .env # edit with your values
# mkdir -p models secrets searxng
# # place .gguf model in ./models/
# docker compose up -d
services:
# ── cAIc (FastAPI) ──────────────────────────────────────
caic:
build: .
ports:
- "${CAIC_EXPOSE_PORT:-8080}:8080"
volumes:
- caic_data:/app/data
secrets:
- rabbitmq_password
environment:
- CAIC_AMQP_SECRET_PATH=/run/secrets/rabbitmq_password
- CAIC_COMFYUI_BASE=${CAIC_COMFYUI_BASE:-http://localhost:8188}
- CAIC_COMFYUI_TIMEOUT=${CAIC_COMFYUI_TIMEOUT:-120}
env_file: .env
depends_on:
qdrant: { condition: service_started }
rabbitmq: { condition: service_healthy }
llama-server: { condition: service_healthy }
restart: unless-stopped
# ── SearXNG (web search) ────────────────────────────────
searxng:
image: searxng/searxng:latest
ports:
- "${SEARXNG_EXPOSE_PORT:-8888}:8080"
volumes:
- ./searxng/settings.yml:/etc/searxng/settings.yml:ro
- searxng_config:/etc/searxng
environment:
- SEARXNG_BASE_URL=http://localhost:8888
restart: unless-stopped
# ── Qdrant (vector DB) ──────────────────────────────────
qdrant:
image: qdrant/qdrant:latest
ports:
- "${QDRANT_EXPOSE_PORT:-6333}:6333"
volumes:
- qdrant_storage:/qdrant/storage
restart: unless-stopped
# ── RabbitMQ (AMQP broker) ──────────────────────────────
rabbitmq:
image: rabbitmq:4-management
ports:
- "${RABBITMQ_EXPOSE_PORT:-5672}:5672"
volumes:
- rabbitmq_data:/var/lib/rabbitmq
environment:
RABBITMQ_DEFAULT_USER: caic
RABBITMQ_DEFAULT_PASS_FILE: /run/secrets/rabbitmq_password
RABBITMQ_DEFAULT_VHOST: /
secrets:
- rabbitmq_password
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "check_port_connectivity"]
interval: 15s
timeout: 5s
retries: 3
restart: unless-stopped
# ── llama-server (LLM inference) ────────────────────────
llama-server:
image: ghcr.io/ggml-org/llama.cpp:server
ports:
- "${LLAMA_EXPOSE_PORT:-8081}:8081"
volumes:
- ./models:/models:ro
command: >
--model /models/${LLAMA_MODEL:?Set LLAMA_MODEL in .env}
--host 0.0.0.0 --port 8081
--ctx-size ${LLAMA_CTX_SIZE:-4096}
--n-gpu-layers ${LLAMA_N_GPU_LAYERS:-0}
--embeddings
--logprobs
${LLAMA_RPC_ENDPOINTS:+--rpc ${LLAMA_RPC_ENDPOINTS}}
healthcheck:
test: ["CMD", "curl", "-fs", "http://localhost:8081/health"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
restart: unless-stopped
# ── Ollama (embeddings) ─────────────────────────────────
ollama:
image: ollama/ollama:latest
ports:
- "${OLLAMA_EXPOSE_PORT:-11434}:11434"
volumes:
- ollama_models:/root/.ollama
healthcheck:
test: ["CMD", "ollama", "list"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
volumes:
caic_data:
searxng_config:
qdrant_storage:
rabbitmq_data:
ollama_models:
secrets:
rabbitmq_password:
file: ./secrets/rabbitmq_password.txt
+798
View File
@@ -0,0 +1,798 @@
# Docker Distribution — Architecture & Planning
> **Part of B3 (v1.0 gate).** This document catalogs every service, volume, port, configuration, and decision needed to ship cAIc as a `docker compose` stack. It also defines extraction (setup) and back-out (uninstall) procedures so nothing is lost when reality disagrees with the plan.
## 1. Stack Overview
```
┌─────────────────────────────────────────────────────────┐
│ docker compose stack │
│ │
│ ┌────────────┐ ┌──────────┐ ┌────────────────────┐ │
│ │ SearXNG │ │ Qdrant │ │ RabbitMQ │ │
│ │ :8888 │ │ :6333 │ │ :5672 / :15672 │ │
│ └──────┬──────┘ └────┬─────┘ └────────┬───────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ cAIc (FastAPI) │ │
│ │ :8080 (HTTP) │ │
│ │ │ │
│ │ SQLite ◄── caic.db (volume) │ │
│ │ Uploads ◄── /app/uploads (volume) │ │
│ └──────────┬──────────────┬───────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ llama-server │ │ Ollama │ │
│ │ :8081 │ │ :11434 │ │
│ │ (GPU/RPC) │ │ (embeddings) │ │
│ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────┘
```
> **This compose stack defines the coordinator.** A coordinator runs cAIc, the broker, and optional infrastructure services. Workers (headless inference nodes) do not use Docker — they install just llama-server + a Python node agent. See §9 for the worker deployment model.
### Service roles
| Service | Image | Role |
|---------|-------|------|
| **cAIc** | Custom `Dockerfile` | FastAPI app serving UI + API |
| **SearXNG** | `searxng/searxng:latest` | Privacy-respecting web search |
| **Qdrant** | `qdrant/qdrant:latest` | Vector database for RAG |
| **RabbitMQ** | `rabbitmq:4-management` | Message broker for AMQP cluster |
| **llama-server** | `ghcr.io/ggml-org/llama.cpp:server` | LLM inference (OpenAI-compat API) |
| **Ollama** | `ollama/ollama:latest` | Embeddings for RAG chunk vectors |
### Non-containerized (host-level)
| Component | Reason |
|-----------|--------|
| AMD GPU driver + ROCm | Kernel access required for GPU compute |
| llama.cpp RPC workers | Runs on *other* hosts — not on the Docker host |
| `rocm-smi` | Hardware stats — not needed for core function |
| `psutil` | Already inside the container via pip |
---
## 2. Service Catalog
### 2.1 cAIc (FastAPI app)
**Image:** `caic:latest` (built from `Dockerfile`)
**Ports:**
| Container | Host | Purpose |
|-----------|------|---------|
| 8080 | 8080 | HTTP API + UI |
**Volumes:**
| Container path | Type | Purpose |
|----------------|------|---------|
| `/app/caic.db` | named volume `caic_data` | SQLite database |
| `/app/uploads` | named volume `caic_uploads` | Uploaded files |
| `/app/hardware_state.json` | (inside volume) | Cached hardware probe |
**Dependencies:** Wait for SearXNG, Qdrant, RabbitMQ, llama-server, Ollama before serving.
**Restart:** `unless-stopped`
**Healthcheck:** `curl -f http://localhost:8080/`
### 2.2 SearXNG
**Image:** `searxng/searxng:latest`
**Ports:**
| Container | Host | Purpose |
|-----------|------|---------|
| 8080 | 8888 | Search API |
**Volumes:**
| Container path | Type | Purpose |
|----------------|------|---------|
| `/etc/searxng` | named volume `searxng_config` | `settings.yml` |
**Environment:**
```env
SEARXNG_BASE_URL=https://localhost:8888
```
**Config override (`/etc/searxng/settings.yml`):**
```yaml
search:
safe_search: 0
autocomplete: ""
server:
secret_key: ${SEARXNG_SECRET_KEY}
limiter: false
image_proxy: false
method: GET
port: 8080
bind_address: "0.0.0.0"
```
**Restart:** `unless-stopped`
### 2.3 Qdrant
**Image:** `qdrant/qdrant:latest`
**Ports:**
| Container | Host | Purpose |
|-----------|------|---------|
| 6333 | 6333 | HTTP API |
| 6334 | — | gRPC (internal only) |
**Volumes:**
| Container path | Type | Purpose |
|----------------|------|---------|
| `/qdrant/storage` | named volume `qdrant_storage` | Vector index data |
**Environment:**
```env
QDRANT__SERVICE__GRPC_PORT=6334
```
**Restart:** `unless-stopped`
### 2.4 RabbitMQ
**Image:** `rabbitmq:4-management`
**Ports:**
| Container | Host | Purpose |
|-----------|------|---------|
| 5672 | 5672 | AMQP messaging |
| 15672 | — | Management UI (internal only) |
**Volumes:**
| Container path | Type | Purpose |
|----------------|------|---------|
| `/var/lib/rabbitmq` | named volume `rabbitmq_data` | Message store |
**Environment:**
```env
RABBITMQ_DEFAULT_USER=caic
RABBITMQ_DEFAULT_PASS_FILE=/run/secrets/rabbitmq_password
RABBITMQ_DEFAULT_VHOST=/
```
**Restart:** `unless-stopped`
### 2.5 llama-server
**Image:** `ghcr.io/ggml-org/llama.cpp:server`
**Ports:**
| Container | Host | Purpose |
|-----------|------|---------|
| 8081 | 8081 | OpenAI-compat API |
**Volumes:**
| Container path | Type | Purpose |
|----------------|------|---------|
| `/models` | bind mount `./models` | Model GGUF files |
**Environment:**
```env
LLAMA_ARG_MODEL=/models/<model-file>
LLAMA_ARG_N_GPU_LAYERS=0 # set >0 for GPU offload
LLAMA_ARG_MAIN_GPU=0
LLAMA_ARG_CTX_SIZE=4096
LLAMA_ARG_HOST=0.0.0.0
LLAMA_ARG_PORT=8081
LLAMA_ARG_EMBEDDINGS=1
LLAMA_ARG_LOGPROBS=1
LLAMA_ARG_RPC= # optional: comma-separated RPC endpoints
```
**Restart:** `unless-stopped`
**Healthcheck:** `curl -f http://localhost:8081/health`
**Notes:**
- Models directory bind mount — user places `.gguf` files in `./models/` on the host
- RPC offload to other machines (e.g., `10.0.0.50:50052,10.0.0.51:50052`)
- If no GPU, set `LLAMA_ARG_N_GPU_LAYERS=0` for CPU-only
- `LLAMA_ARG_EMBEDDINGS=1` required for perplexity scoring
- `LLAMA_ARG_LOGPROBS=1` required for auto-search trigger
### 2.6 Ollama
**Image:** `ollama/ollama:latest`
**Ports:**
| Container | Host | Purpose |
|-----------|------|---------|
| 11434 | 11434 | Embeddings API |
**Volumes:**
| Container path | Type | Purpose |
|----------------|------|---------|
| `/root/.ollama` | named volume `ollama_models` | Pulled model blobs |
**Restart:** `unless-stopped`
**Notes:**
- Used exclusively for embeddings (`/api/embeddings`), not inference
- Typically needs a small model like `all-minilm:latest` or `nomic-embed-text:latest`
- Consider replacing Ollama with llama-server's built-in embedding if it supports the same model — would remove one container
---
## 3. Configuration Management
### 3.1 `.env` file (generated by setup wizard)
```env
# --- Secrets (auto-generated, change before production) ---
CAIC_ADMIN_PIN=
CAIC_COMPLETIONS_API_KEY=
CAIC_ALLOW_DEFAULT_PIN=false
RABBITMQ_PASSWORD=
SEARXNG_SECRET_KEY=
# --- Host discovery (auto-detected by setup wizard) ---
LLAMA_SERVER_BASE=http://llama-server:8081
OLLAMA_BASE=http://ollama:11434
SEARXNG_BASE=http://searxng:8888
QDRANT_URL=http://qdrant:6333
RABBITMQ_HOST=rabbitmq
RABBITMQ_PORT=5672
# --- Performance tuning (calculated by setup wizard) ---
RAG_MAX_VECTORS=50000
RAG_EVICTION_HIGH_WATER=0.80
RAG_EVICTION_LOW_WATER=0.20
RAG_EVICTION_BATCH=1000
# --- llama-server options ---
LLAMA_MODEL=llama3.1-8b-instruct.Q4_K_M.gguf
LLAMA_N_GPU_LAYERS=0
LLAMA_RPC_ENDPOINTS=
LLAMA_CTX_SIZE=4096
# --- Ollama ---
OLLAMA_EMBED_MODEL=all-minilm:latest
# --- Network ---
CAIC_ALLOWED_CIDRS=127.0.0.0/8,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
CAIC_TRUSTED_ORIGINS=
CAIC_TRUST_X_FORWARDED_FOR=false
```
### 3.2 Mapping of config.py → .env variable
Every config.py default that references an external service must accept a matching env var at runtime:
| config.py constant | .env variable | Service |
|-------------------|---------------|---------|
| `LLAMA_SERVER_BASE` | `LLAMA_SERVER_BASE` | llama-server |
| `OLLAMA_BASE` | `OLLAMA_BASE` | Ollama |
| `SEARXNG_BASE` | `SEARXNG_BASE` | SearXNG |
| `QDRANT_URL` | `QDRANT_URL` | Qdrant |
| `COMPLETIONS_API_KEY` | `CAIC_COMPLETIONS_API_KEY` | — |
| `ALLOWED_CIDRS_RAW` | `CAIC_ALLOWED_CIDRS` | — |
| `TRUST_X_FORWARDED_FOR` | `CAIC_TRUST_X_FORWARDED_FOR` | — |
| `TRUSTED_ORIGINS` | `CAIC_TRUSTED_ORIGINS` | — |
| `RAG_MAX_VECTORS` | `RAG_MAX_VECTORS` | — (calc'd from RAM) |
### 3.3 Secrets management
| Secret | Generated by | Stored in | Mounted to |
|--------|-------------|-----------|------------|
| `CAIC_ADMIN_PIN` | User prompt | `.env` | cAIc container |
| `CAIC_COMPLETIONS_API_KEY` | Auto-generated, shown to user | `.env` | cAIc container |
| `RABBITMQ_PASSWORD` | Auto-generated | `.env` + Docker secret | RabbitMQ container |
| `SEARXNG_SECRET_KEY` | Auto-generated | `.env` | SearXNG container |
**Docker secrets approach:** Use `secrets:` in compose file for RabbitMQ password (mounted as file) rather than passing via env var, since `settings.yml` in SearXNG and RabbitMQ config can reference file-based secrets without env-var leakage.
### 3.4 Dockerfile for cAIc
```dockerfile
FROM python:3.13-slim-bookworm AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
FROM python:3.13-slim-bookworm
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
curl && \
rm -rf /var/lib/apt/lists/*
COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
COPY . .
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
CMD curl -f http://localhost:8080/ || exit 1
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"]
```
**Multi-stage rationale:** First stage compiles/bundles packages (wheels), final stage is minimal. Devs can skip builder with `--target builder` for live-reload with volume mount.
---
## 4. docker-compose.yml structure
```yaml
services:
caic:
build: .
ports: ["8080:8080"]
volumes:
- caic_data:/app/caic.db
- caic_uploads:/app/uploads
env_file: .env
depends_on:
searxng: { condition: service_started }
qdrant: { condition: service_started }
rabbitmq: { condition: service_healthy }
llama-server: { condition: service_healthy }
ollama: { condition: service_started }
restart: unless-stopped
searxng:
image: searxng/searxng:latest
ports: ["8888:8080"]
volumes:
- ./searxng/settings.yml:/etc/searxng/settings.yml:ro
- searxng_config:/etc/searxng
env_file: .env
restart: unless-stopped
qdrant:
image: qdrant/qdrant:latest
ports: ["6333:6333"]
volumes:
- qdrant_storage:/qdrant/storage
restart: unless-stopped
rabbitmq:
image: rabbitmq:4-management
ports: ["5672:5672"]
volumes:
- rabbitmq_data:/var/lib/rabbitmq
env_file: .env
secrets:
- rabbitmq_password
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "check_port_connectivity"]
interval: 15s
timeout: 5s
retries: 3
restart: unless-stopped
llama-server:
image: ghcr.io/ggml-org/llama.cpp:server
ports: ["8081:8081"]
volumes:
- ./models:/models:ro
env_file: .env
command: >
--model /models/${LLAMA_MODEL}
--host 0.0.0.0 --port 8081
--ctx-size ${LLAMA_CTX_SIZE:-4096}
--n-gpu-layers ${LLAMA_N_GPU_LAYERS:-0}
--embeddings
--logprobs
${LLAMA_RPC_ENDPOINTS:+--rpc ${LLAMA_RPC_ENDPOINTS}}
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8081/health"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
restart: unless-stopped
ollama:
image: ollama/ollama:latest
ports: ["11434:11434"]
volumes:
- ollama_models:/root/.ollama
healthcheck:
test: ["CMD", "ollama", "list"]
interval: 30s
timeout: 10s
retries: 3
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
restart: unless-stopped
volumes:
caic_data:
caic_uploads:
searxng_config:
qdrant_storage:
rabbitmq_data:
ollama_models:
secrets:
rabbitmq_password:
file: ./secrets/rabbitmq_password.txt
```
**Notes:**
- GPU reservations use `resources.reservations.devices` — this is compose v3.8+. For AMD GPUs, replace `driver: nvidia` with `driver: amd` (experimental Docker support). For hosts without GPU, omit the `deploy` block entirely.
- The `deploy` block only applies when deployed as a swarm stack. For `docker compose`, GPU access may need `--gpus all` or `device_requests` in config. Verify compatibility.
- SearXNG config file (`settings.yml`) is bind-mounted read-only from the host repo clone — the setup wizard should generate this file.
---
## 5. Networking
### 5.1 Internal communication (compose network)
| From | To | Port | Protocol |
|------|----|------|----------|
| cAIc | llama-server | 8081 | HTTP |
| cAIc | Ollama | 11434 | HTTP |
| cAIc | SearXNG | 8080 | HTTP |
| cAIc | Qdrant | 6333 | HTTP |
| cAIc | RabbitMQ | 5672 | AMQP |
| RabbitMQ | (cluster peers) | 4369 | EPMD |
| RabbitMQ | (cluster peers) | 25672 | Inter-node |
### 5.2 Exposed ports (host-facing)
| Port | Service | Should expose? | Notes |
|------|---------|---------------|-------|
| 8080 | cAIc | ✅ Required | UI + API |
| 8888 | SearXNG | Optional | Only if user wants standalone search |
| 6333 | Qdrant | Optional | Only for external tooling |
| 5672 | RabbitMQ | Optional | Only for remote AMQP clients |
| 15672 | RabbitMQ mgmt | ❌ Internal | Healthcheck only |
| 8081 | llama-server | Optional | Only for external tooling |
| 11434 | Ollama | Optional | Only for external tooling |
**Design decision:** By default, only port 8080 (cAIc) is published. All other services remain on the internal compose network. Advanced users can opt-in by uncommenting `ports:` blocks.
### 5.3 Reverse proxy consideration
For production, a reverse proxy (Caddy, nginx, Traefik) should sit in front:
```yaml
# Optional — compose profile: "proxy"
caddy:
image: caddy:latest
ports: ["80:80", "443:443"]
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
```
This is out of scope for v1.0 but documented for future.
### 5.4 WireGuard tunnel (off-site workers)
When a worker node runs on a different network (colo, friend's house, VPS), all cross-site traffic must be encrypted. WireGuard provides this at the network layer with zero application changes.
**Approach:** Install WireGuard on the Docker host (not inside a container). The host creates a tunnel interface (`wg0`) with a virtual IP in the `10.0.2.0/24` range. Containers that need to reach remote workers use the host's WireGuard IP via `network_mode: host` or standard routing.
```
Off-site worker Docker host (coordinator)
┌────────────────────┐ ┌───────────────────────────────┐
│ wg0: 10.0.2.2 │◄───UDP──────│ wg0: 10.0.2.1 │
│ llama-server │ :51820 │ │
│ node_agent.py │ encrypts │ ┌───────────────────────┐ │
│ │ all │ │ cAIc container │ │
│ │ traffic │ │ LLAMA_SERVER_BASE │ │
│ │ │ │ → 10.0.2.2:8081 │ │
│ │ │ │ CAIC_AMQP_URL │ │
│ │ │ │ → amqp://caic:@... │ │
│ │ │ └───────────────────────┘ │
└────────────────────┘ └───────────────────────────────┘
```
**Host setup (coordinator):**
```bash
sudo apt install wireguard
wg genkey | tee /etc/wireguard/private.key | wg pubkey > /etc/wireguard/public.key
chmod 600 /etc/wireguard/private.key
```
Then create `/etc/wireguard/wg0.conf` — see [WireGuard-Setup.md](WireGuard-Setup.md) for full per-node configs.
**Container networking:** The cAIc container needs to reach the WireGuard IP. Options:
1. **`network_mode: host`** — simplest, container shares host network stack. Done by adding `network_mode: host` to the cAIc service. Trade-off: no port isolation.
2. **Host routing** — the host's kernel routes `10.0.2.0/24` via `wg0`. Containers on the default bridge or compose network can reach those IPs if `ip_forward` is enabled. This works out of the box on Linux.
**cAIc env vars after WireGuard:**
```env
# Point at worker's WireGuard IP instead of LAN IP
LLAMA_SERVER_BASE=http://10.0.2.2:8081 # falls back to coordinator's own llama-server
CAIC_AMQP_URL=amqp://caic:password@10.0.2.1:5672/caic # coordinator's RMQ on WG IP
```
The node agent on each worker configures its registration IP as the WireGuard tunnel IP (`node_ip = 10.0.2.2`), so `triage.py` constructs inference URLs pointing at the encrypted interface.
---
## 6. Setup Wizard (Extraction)
`setup.sh` — idempotent, interactive, runs on first boot.
### Flow
```
1. CHECK: Is .env present?
├── YES → skip to step 7 (or ask to regenerate)
└── NO → continue
2. INTRO: Print banner, explain what's about to happen
3. PROBE: Run hardware assessment
├── psutil → RAM total, CPU count
├── rocm-smi → VRAM (optional, best-effort)
└── nvidia-smi → VRAM (optional, best-effort)
4. NETWORK: Ask for
├── Hostname / LAN IP for this machine
├── Admin PIN (4 digits, or accept auto-generated)
└── (Optional) RPC endpoints for GPU offload
5. CALCULATE:
├── RAG_MAX_VECTORS = max(1000, int(available_ram_gb * 100_000))
├── LLAMA_N_GPU_LAYERS = 0 (CPU default; offer GPU detection)
├── LLAMA_MODEL = default gguf filename
└── RABBITMQ_PASSWORD = openssl rand -hex 20
6. GENERATE:
├── .env file from template
├── ./secrets/rabbitmq_password.txt
├── ./searxng/settings.yml (with generated secret_key)
└── ./models/README.txt (instructions for placing .gguf)
7. VERIFY:
├── docker and docker compose plugin installed
├── docker compose version >= 2.x
├── SUCCESS → "Run: docker compose up -d"
└── FAILURE → show diagnostics and links
8. EXTRACT model:
├── Prompt for download URL or local path
├── Offer to pull from HuggingFace if huggingface-cli available
└── Guides user to place file in ./models/
```
### What setup.sh creates on disk
```
./docker-deploy/
├── .env # All env vars (SECRET — add to .gitignore)
├── docker-compose.yml # Compose stack definition
├── Dockerfile # cAIc image build
├── secrets/
│ └── rabbitmq_password.txt # RabbitMQ password file
├── searxng/
│ └── settings.yml # SearXNG config with generated secret_key
├── models/
│ ├── README.txt # Instructions for model placement
│ └── <model>.gguf # (user-provided)
└── setup.log # Wizard run log
```
### Idempotency
Re-running `setup.sh`:
- With `.env` present: ask "Regenerate? This will overwrite existing config."
- Without `.env`: fresh run
- Never overwrites `./models/*.gguf` files
- Never touches running containers — only modifies files on disk
---
## 7. Back-out Procedure (Uninstall)
`teardown.sh` — returns the host system to its pre-install state.
### What gets removed
| Item | Removal method |
|------|---------------|
| Docker containers | `docker compose down -v` |
| Docker images | `docker rmi caic:latest` (ask about other images) |
| Docker volumes | `docker volume rm caic_data ...` (prompt first) |
| Network `caic_default` | Removed with compose |
| `.env` file | `rm .env` |
| `secrets/` directory | `rm -rf secrets/` |
| `searxng/` directory | `rm -rf searxng/` |
| `setup.log` | `rm setup.log` |
| `hardware_state.json` | `rm hardware_state.json` |
### What is preserved (by default)
| Item | Reason |
|------|--------|
| `./models/*.gguf` | User data — prompt for deletion |
| `caic.db` (in volume) | Prompt: "Keep database snapshot?" |
| `./uploads/` (in volume) | Prompt: "Keep uploaded files?" |
| Docker Engine itself | Not installed by this project — leave it |
### Script flow
```
1. CHECK: docker compose file exists?
├── NO → warn, continue
└── YES → docker compose down -v
2. CHECK: .env exists?
├── NO → skip
└── YES → ask: "Remove .env?" (default no)
3. ASK: "Remove secrets/ and searxng/ directories?" (default no)
4. ASK: "Remove Docker images? (y/N)" (default no)
├── Y → docker rmi caic:latest
├── Y → docker image ls | grep searxng/qdrant/rabbitmq → prompt per image
└── N → skip
5. ASK: "Keep database volume snapshot? (Y/n)" (default yes)
├── N → docker volume rm caic_data
└── Y → leave volume (can be reattached later)
6. ASK: "Remove model files from ./models/? (y/N)" (default no)
7. CLEANUP generated artifacts:
├── rm -f setup.log
├── rm -f hardware_state.json
└── rm -f docker-compose.yml
8. SUMMARY:
├── "Docker stack removed"
├── "Persistent data preserved at: <paths>"
└── "Models kept at: ./models/"
```
### Partial rollback
If the setup wizard fails mid-way, a partial rollback is better than leaving detritus:
| Failure point | Clean up |
|--------------|----------|
| After .env, before compose | `rm .env; rm -rf secrets/ searxng/` |
| After compose, before first `up` | `rm docker-compose.yml; rm -rf *` |
| After `up` but before healthcheck | `docker compose down -v; rm -rf ./*` |
`setup.sh` should trap EXIT on failure and prompt: "Clean up partial install? [y/N]"
---
## 8. Open Decisions
| Decision | Options | Priority |
|----------|---------|----------|
| **Ollama vs llama-server embeddings** | Both work. Keep both for now — remove Ollama if llama-server handles embeddings. Reduce containers = simpler. | Medium |
| **GPU support in compose** | NVIDIA: well-supported. AMD: requires `--device=/dev/kfd --device=/dev/dri` and ROCm image. Document both. | High |
| **RabbitMQ clustering vs single node** | Single node in v1.0. Clustering docs for multi-host later. | Low |
| **SearXNG config management** | Bind-mount a generated `settings.yml`, or let container create default and post-process. Bind-mount is cleaner. | Medium |
| **Reverse proxy** | Caddy is simplest for auto-HTTPS. Out of scope for v1.0 but design for it. | Low |
| **Healthcheck strategy** | `depends_on` with `condition: service_healthy` is the safest approach but increases startup time. Acceptable. | Medium |
| **Database migration** | SQLite file in volume — no migration needed for v1.0 format. If schema changes post-v1.0, need a migration container. | Low |
| **WireGuard integration** | Documented in docker.md §5.4 + wiki. Host-level install; no container changes needed. WireGuard sidecar container (`linuxserver/wireguard`) is an alternative for users who want everything in compose. | Low |
| **Linux vs macOS vs Windows** | Linux-primary. macOS may work with changes (no rocm-smi). Windows via WSL2 only. | Low |
| **LLM model download** | HuggingFace CLI integration in setup.sh, or manual download. Manual is simpler. | Low |
| **Dockerfile optimization** | Pin pip hashes, use `--no-cache-dir`, consider `slim` vs `alpine`. Alpine has musl compatibility issues with psutil. Stay with slim. | Medium |
## 9. Worker Node Deployment Model
The Docker stack above defines the **coordinator** only. Workers (headless inference nodes) have a radically lighter footprint.
### 9.1 What a worker runs
```
Worker machine (e.g. worker01, worker02)
┌────────────────────────────────────┐
│ llama-server │
│ (single binary, no build needed) │
│ │
│ node_agent.py │
│ (Python script, aio-pika client) │
│ ─ connects to coordinator's RMQ │
│ ─ publishes heartbeat + reg │
│ ─ consumes model_swap commands │
│ │
│ ROCm or CUDA runtime (if GPU) │
└────────────────────────────────────┘
```
### 9.2 What a worker does NOT run
| Service | Reason |
|---------|--------|
| RabbitMQ server | Connects as AMQP *client* only (aio-pika) |
| FastAPI / uvicorn / jC | No HTTP API, no UI, no database |
| SQLite | No persistent state of its own |
| SearXNG | No web search needs |
| Qdrant | No local vector store |
| Ollama | Uses coordinator's embedding endpoint |
| Docker | Everything runs as bare binaries |
| Python venv with full jC deps | Only needs `aio-pika` + `httpx` |
### 9.3 Worker setup
```bash
# Install WireGuard (required for off-site workers — encrypts all traffic)
sudo apt install wireguard
# See docs/wiki/WireGuard-Setup.md for per-node config
# Install llama-server binary
wget https://github.com/ggml-org/llama.cpp/releases/.../llama-server
chmod +x llama-server
# Install node agent deps
pip install aio-pika httpx
# Create node agent config: /etc/caic-node-agent.conf
# Set node_ip to the WireGuard tunnel IP (e.g., 10.0.2.2)
# Set amqp_url to the coordinator's WireGuard IP (e.g., amqp://caic:password@10.0.2.1:5672/caic)
```
### 9.4 Multiple workers
Each worker registers independently with the coordinator's RabbitMQ. The coordinator tracks all registered workers via `CLUSTER_NODES` and routes inference requests to the best-matching node based on classification and availability.
### 9.5 RabbitMQ and workers — architecture note
Workers connect to RabbitMQ as **standard AMQP TCP clients** — no broker software required. The AMQP-0-9-1 protocol has always been client-server (since 2006), and libraries like `aio-pika`, `pika`, `amqplib`, `php-amqplib`, etc. connect over a single persistent socket. This is distinct from a service-mesh design where every node runs the same software stack and role is determined by config.
```
Broker-mediated model (this project):
Coordinator runs RabbitMQ broker ←── Workers connect as AMQP clients
Service-mesh model (alternative):
Every node runs RabbitMQ broker ←── Nodes cluster together, all autonomous
```
The broker-mediated model is the preferred architecture for this project because workers are intentionally heterogeneous (different GPUs, different models, ARM vs x86) and should not be burdened with infrastructure services.
## 10. Checklist (pre-v1.0 gate)
- [x] `Dockerfile` written and builds clean
- [x] `docker-compose.yml` boots all containers
- [ ] cAIc container reaches all services (env vars resolve correctly)
- [x] SearXNG settings.yml generated correctly by setup.sh
- [x] RabbitMQ password secret mounted correctly
- [ ] GPU (NVIDIA) passes through to llama-server container
- [ ] GPU (AMD) passes through to llama-server container (or documented limitation)
- [x] `.env.example` checked in (no real secrets)
- [x] `setup.sh` written, idempotent, tested on clean Debian
- [x] `teardown.sh` written, tested, doesn't delete models without confirmation
- [ ] `docker compose up -d` works without any manual steps beyond setup.sh
- [ ] `docker compose down -v` followed by `setup.sh && docker compose up -d` = fresh stack
- [ ] Healthchecks prevent serving before dependencies are ready
- [ ] WireGuard tunnel documented and tested for off-site workers
- [x] v1.0 release tag created
---
## 11. Files created for B3
```
docker.md ← this file (planning doc)
Dockerfile ← cAIc image (multi-stage, Python 3.13-slim)
docker-compose.yml ← full stack (6 services, volumes, secrets, healthchecks)
.env.example ← template without secrets
.dockerignore ← excludes venv, tests, .git, models, secrets
scripts/setup.sh ← first-run scaffolding (generates .env, secrets, pulls model)
scripts/teardown-docker.sh ← Docker stack teardown (interactive, -y for unattended)
searxng-settings.yml.dist ← SearXNG config template (copied by setup.sh)
models/README.txt ← instructions for placing .gguf
secrets/ ← generated at runtime by setup.sh
searxng/ ← generated at runtime by setup.sh
```
+994
View File
@@ -0,0 +1,994 @@
# cAIc — OpenCode Prompt Sequence
# Generated: 2026-07-14
# Execute sequentially. Run full test suite after each task before proceeding.
# Test command: ./venv/bin/python -m pytest tests/ -v
---
## Session 2026-07-14 — RAG bugfixes + Topbar redesign
- **RAG bugs fixed**: Collection name mismatch (`jarvis_rag``caic_rag`, migrated 219 points), `vectors_count``points_count` (Qdrant v1.10+ API change), removed unindexed `order_by` that caused 502 on scroll, made `RAG_COLLECTION` env-configurable (`CAIC_RAG_COLLECTION`).
- **Semantic search fixed**: Set `CAIC_EMBED_URL=http://192.168.50.108:11434` (mxbai-embed-large lives on ultron, not the old embed server).
- **Topbar redesign**: Moved system stats (CPU/MEM/GPU/VRAM/TOK) to a centered bottom strip. Moved toggles (MEM, SEARCH, PROFILE, SORT, PRIVACY) into a ⋮ hamburger menu next to ADMIN badge. Palette icon sits immediately after version number in topbar-left. Removed standalone (i) button — privacy info accessible via ⋮ → About Privacy. Input bar above chat, stats at very bottom. Mobile-responsive padding/sizing.
---
## ~~TASK 1 — README Cleanup [DONE]~~
Review README.md in the current repo. Remove any node references other than `coordinator` (192.168.50.108) and `worker` (192.168.50.210). Ensure all references to the project use the exact casing `cAIc` — not `Jarvischat`, `JarvisChat`, or `jarvischat`. Do not change any functional content, endpoint documentation, or architecture descriptions — this is a text cleanup only. After editing, verify the file renders cleanly as markdown. Commit with message: `docs: clean up node references and branding consistency`.
No new tests required for this task.
---
## ~~TASK 2 — Qwen2.5-Coder llama-server Service on Coordinator (Infrastructure) [DONE]~~
**Status: Systemd unit created, verified, and restored.**
This task originally defined creation of `/etc/systemd/system/llama-server-coder.service` (port 8082, Qwen2.5-Coder-14B Q5_K_M) as a prerequisite for dynamic model swapping. That sysadmin work is done.
**The real Task 2 deliverable — the ability to dynamically swap models based on query classification — is delivered by Roadmap N (Tasks 915).** The flow:
1. **Task 13** — Phi-4-mini triage (`triage.py`) classifies the query as `general`, `code`, `search`, or `rag`
2. **Task 13**`select_node()` picks the best worker node; if the ideal model isn't active, it triggers a swap
3. **Task 14**`request_model_swap()` publishes `cmd.swap_model` via AMQP `jc.admin` exchange
4. **Task 12** — The node agent on worker receives the command, stops the current llama-server, starts the correct one, waits for health, and publishes `model_ready`
5. **Task 14** — coordinator receives `model_ready`, updates the cluster registry, and routes the query to the node
The swap is async and transparent — the user sees only latency. The UI (Task 15) shows a yellow "swapping" status dot during the transition.
The service unit at `/etc/systemd/system/llama-server-coder.service` is the **target** the node agent starts when swapping to code inference. It is not enabled at boot — the AMQP cluster manages activation.
See Tasks 915 for the actual model swap implementation.
No pytest tests required for this infrastructure task.
---
## ~~TASK 3 — Update OpenCode Config to Use Qwen on :8082 [DONE]~~
Update `/home/gramps/.config/opencode/opencode.jsonc` (on this machine, coordinator) to point the configured provider at `http://127.0.0.1:8082/v1` instead of `http://127.0.0.1:8081/v1`. The model name in the config should be updated to reflect `qwen2.5-coder-14b` or whatever model ID the llama-server instance at :8082 reports via `/v1/models`. Verify the endpoint is reachable before writing the config change. Do not restart OpenCode — the config change takes effect on next session start.
No pytest tests required for this task.
---
## ~~TASK 4 — File/Document Attachment: Backend Ingest Endpoint [DONE]~~
**Status: `POST /api/upload` with mode=(context|ingest|both), PDF/text extraction, Qdrant upsert, SQLite context (1hr expiry). Committed `4a891c8` (v1.9.0).**
This task implements the backend half of file/document attachment (TODO #21). The goal is dual-aspect upload: a file can be used as immediate chat context, ingested into the RAG corpus (Qdrant), or both.
**Add to `config.py`:**
- `UPLOAD_DIR` — path for temporary upload storage, default `/tmp/caic_uploads`
- `MAX_UPLOAD_BYTES` — max file size, default 20MB
- `SUPPORTED_UPLOAD_TYPES` — set of MIME types: `text/plain`, `text/markdown`, `application/pdf`, `application/json`, `text/x-python`, `text/html`
**Create `routers/upload.py`:**
Implement `POST /api/upload` (admin required). Accept `multipart/form-data` with:
- `file` — the uploaded file (required)
- `mode` — string enum: `context` (inject into next chat only), `ingest` (add to RAG corpus), `both` (default: `both`)
- `conversation_id` — optional, associates context-mode content with a specific conversation
Behavior:
- Validate file size against `MAX_UPLOAD_BYTES` — return 413 if exceeded
- Validate MIME type against `SUPPORTED_UPLOAD_TYPES` — return 415 if unsupported
- For PDF files, extract text using `pypdf` (add to requirements.txt)
- For all other types, read as UTF-8 text
- If mode includes `ingest`: chunk the extracted text into 512-token overlapping chunks (128-token overlap), generate embeddings via `EMBED_URL` (http://192.168.50.108:11434/api/embeddings, model mxbai-embed-large), upsert into Qdrant collection `caic` with metadata `{source: filename, upload_date: iso_timestamp, type: "upload"}`
- If mode includes `context`: store the full extracted text in a new SQLite table `upload_context` with columns `(id INTEGER PRIMARY KEY, conversation_id TEXT, filename TEXT, content TEXT, created_at TEXT, expires_at TEXT)`. Context entries expire after 1 hour.
- Return JSON: `{filename, size_bytes, mode, chunks_ingested (if ingest), context_id (if context), message}`
**Add `upload_context` table to `db.py`** `init_db()`.
**Wire `upload.router` into `app.py`** in the router registration block.
**Write `tests/test_upload.py`** covering:
- Valid text file upload, mode=ingest — assert chunks_ingested > 0, Qdrant upsert called
- Valid text file upload, mode=context — assert context_id returned, row exists in upload_context
- Valid text file upload, mode=both — assert both behaviors
- File exceeds MAX_UPLOAD_BYTES — assert 413
- Unsupported MIME type — assert 415
- Guest session attempt — assert 403
- PDF extraction path — mock pypdf, assert text extracted and processed
Mock Qdrant and EMBED_URL calls via monkeypatch. Do not require live external services in tests.
Run full test suite after implementation. All 26 existing tests must continue to pass.
---
## ~~TASK 5 — File/Document Attachment: UI Integration [DONE]~~
**Status: Paperclip icon, file preview pill, gallery overlay, attachment indicators, DELETE/PATCH link/by-conversation endpoints, chat context injection. Committed `81238c0` (v1.10.0).**
This task implements the frontend half of TODO #21. The UI is a single file at `templates/index.html`.
Add a file attachment button to the chat input area. Requirements:
- Paperclip icon button adjacent to the send button
- Clicking opens a file picker filtered to supported types (`.txt`, `.md`, `.pdf`, `.json`, `.py`, `.html`)
- On file selection, show a pill/badge above the input showing the filename with an X to remove it
- On send, if a file is attached: POST to `/api/upload` with `mode=both` and the current `conversation_id`, then include the returned `context_id` in the subsequent `/api/chat` POST body as `upload_context_id`
- If the upload fails, show an inline error and do not send the chat message
- File attachment state clears after send
**Update `/api/chat` in `routers/chat.py`:**
- Accept optional `upload_context_id` in the request body
- If present, look up the content in `upload_context` table and prepend it to the system prompt as: `\n\n[ATTACHED DOCUMENT: {filename}]\n{content}\n[END DOCUMENT]`
- If the context_id is expired or missing, log a warning and continue without it (do not error)
**Add to `tests/test_chat_streaming_and_memory_paths.py`:**
- Test that a valid `upload_context_id` results in document content being prepended to the system prompt
- Test that an expired/missing `upload_context_id` is silently ignored
Run full test suite. All existing tests must continue to pass.
---
## ~~TASK 6 — Roadmap I: Terminal Command RAG Hook [DONE]~~
**Status: `POST /api/ingest` with Bearer token auth, `chunk_text()` shared helper, `caic-ingest.sh` script. Committed `1ac21ad` (v0.11.0).**
This task implements autonomous RAG ingestion of significant terminal activity (TODO #23).
**Create `routers/ingest.py`:**
Implement `POST /api/ingest` (requires Bearer token auth — use same `COMPLETIONS_API_KEY` mechanism as `routers/completions.py`). Accept JSON body:
- `content` — string, the text to ingest (required)
- `source` — string, origin label e.g. `terminal`, `file`, `external` (default: `external`)
- `metadata` — optional dict of additional key/value pairs
Behavior:
- Chunk `content` into 512-token overlapping chunks (128-token overlap) — extract this logic into a shared helper `chunk_text(text, chunk_size=512, overlap=128)` in `rag.py` if not already present
- Generate embeddings via `EMBED_URL`
- Upsert into Qdrant collection `caic` with metadata `{source, ingest_date: iso_timestamp, ...metadata}`
- Return JSON: `{chunks_ingested, source, message}`
**Wire `ingest.router` into `app.py`.**
**Create `/usr/local/bin/caic-ingest.sh` on worker (192.168.50.210)** — this is a shell script, not a Python file, and lives outside the repo. Write it to stdout/document it clearly so gramps can deploy it manually:
```bash
#!/bin/bash
# caic-ingest.sh — pipe terminal commands into cAIc RAG
# Add to ~/.bashrc: export PROMPT_COMMAND="jc_capture"
# Function to call after significant commands
JC_URL="http://192.168.50.210:8080/api/ingest"
JC_TOKEN="${CAIC_COMPLETIONS_API_KEY}"
jc_capture() {
local cmd
cmd=$(history 1 | sed 's/^[ ]*[0-9]*[ ]*//')
# Only ingest significant commands
if echo "$cmd" | grep -qE '^(git|pip|systemctl|sudo|vi|vim|curl|wget|apt|python|pytest)'; then
curl -s -X POST "$JC_URL" \
-H "Authorization: Bearer $JC_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"content\": $(echo "$cmd" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().strip()))'), \"source\": \"terminal\"}" \
> /dev/null 2>&1 &
fi
}
```
**Write `tests/test_ingest.py`** covering:
- Valid ingest with content — assert chunks_ingested > 0
- Missing Bearer token — assert 401
- Wrong Bearer token — assert 403
- Empty content — assert 422
- Qdrant and embed calls mocked via monkeypatch
Run full test suite. All existing tests must continue to pass.
---
## ~~TASK 7 — Roadmap J: Startup Hardware Self-Assessment [DONE]~~
**Status: `hardware.py` + `routers/hardware.py` + 4 tests. Committed `7291b8f` (v0.12.0).**
On jC startup, probe available hardware and write a living config snapshot. This replaces hardcoded assumptions about VRAM and RAM.
**Create `hardware.py`** in the project root:
```
async def assess_hardware() -> dict
```
Probes:
- System RAM: `psutil.virtual_memory().total` and `.available`
- CPU count: `psutil.cpu_count()`
- GPU VRAM total and free: call `rocm-smi --showmeminfo vram --json` via subprocess, parse output. If rocm-smi absent or fails, set VRAM values to 0 and log a warning.
- llama-server reachable: GET `LLAMA_SERVER_BASE/v1/models`, timeout 3s. Record True/False and list of available model IDs.
- Qdrant reachable: GET `http://192.168.50.108:6333/collections`, timeout 3s. Record True/False and collection list.
- SearXNG reachable: GET `http://localhost:8888`, timeout 3s. Record True/False.
Returns a dict with all of the above. Writes result as JSON to `hardware_state.json` in the working directory.
**Call `assess_hardware()` from the FastAPI `lifespan` context** in `app.py` on startup, after `init_db()`. Log a summary line: `HW: {ram_gb}GB RAM, {vram_mb}MB VRAM, llama={reachable}, qdrant={reachable}, searxng={reachable}`.
**Expose `GET /api/hardware`** in a new `routers/hardware.py` — returns the current `hardware_state.json` content as JSON. No auth required (read-only, non-sensitive aggregate stats).
**Wire `hardware.router` into `app.py`.**
**Write `tests/test_hardware.py`** covering:
- `assess_hardware()` with all services reachable (mock subprocess and httpx calls) — assert all fields present
- `assess_hardware()` with rocm-smi absent — assert VRAM=0, no exception raised
- `assess_hardware()` with llama-server unreachable — assert `llama_reachable=False`, no exception
- `GET /api/hardware` — assert returns JSON with expected keys
Run full test suite. All existing tests must continue to pass.
---
## ~~TASK 8 — Roadmap K: RAG Corpus Management [DONE]~~
Qdrant collection `caic` currently grows without bound. Implement score-based eviction with hysteresis, pinned sources, operational stats, and a flush command.
### Config — add to `config.py`:
```python
RAG_MAX_VECTORS = 50000 # absolute ceiling; eviction targets thresholds below it
RAG_EVICTION_HIGH_WATER = 0.80 # fraction of RAG_MAX_VECTORS that triggers eviction
RAG_EVICTION_LOW_WATER = 0.20 # fraction where eviction stops
RAG_EVICTION_BATCH = 1000 # max points to delete per Qdrant scroll/delete cycle
RAG_PINNED_SOURCES = ["upload", "profile"] # never evicted
RAG_GRACE_HOURS = 1 # new vectors ineligible for eviction until this old
RAG_ACCESS_WEIGHT = 1.0 # score factor: retrieval_count * ACCESS_WEIGHT
RAG_AGE_WEIGHT = 0.1 # score factor: ingest_age_hours * AGE_WEIGHT
```
Validations on boot: `high_water > low_water`, `batch > 0`, `max_vectors > 0`.
### Eviction algorithm — add to `rag.py`:
```
score = (retrieval_count * ACCESS_WEIGHT) + (age_hours * AGE_WEIGHT)
```
Lower score = evicted first. Tiebreak: `last_accessed` ASC (older wins).
```python
async def get_collection_count() -> int
# GET /collections/caic → return vectors_count
async def get_collection_stats() -> dict
# Return {vector_count, max_vectors, high_water, low_water, percent_full, pinned_sources}
async def evict_batch(batch_size: int) -> int
# Scroll Qdrant for vectors NOT in RAG_PINNED_SOURCES, WHERE ingest_age > RAG_GRACE_HOURS,
# ordered by score ASC, last_accessed ASC.
# Delete up to batch_size. Return count deleted.
# If 0 evictable vectors found: log warning, return 0 (break loop).
async def maybe_evict() -> int
# Acquire eviction_lock (asyncio.Lock).
# count = get_collection_count()
# threshold_high = RAG_MAX_VECTORS * RAG_EVICTION_HIGH_WATER
# threshold_low = RAG_MAX_VECTORS * RAG_EVICTION_LOW_WATER
# total_evicted = 0
# while count >= threshold_low:
# if total_evicted > 0 and count < threshold_low: break
# deleted = evict_batch(RAG_EVICTION_BATCH)
# if deleted == 0: break # no more unpinned targets
# total_evicted += deleted
# count -= deleted
# if count < threshold_high and total_evicted > 0: break
# # only one pass if batch spans the full gap
# if count < threshold_low: break
# Record total_evicted + timestamp in EVICTION_LOG (list of dicts, kept in memory, max 1000 entries)
# Release lock. Return total_evicted.
async def get_rag_operational_stats() -> dict
# Returns: vector_count, max_vectors, high_water_pct, low_water_pct,
# percent_full, pinned_sources, grace_hours,
# eviction_counts_last_1m, eviction_counts_last_5m, eviction_counts_last_30m,
# at_risk_count (vectors in bottom 10% by score),
# pinned_count, avg_retrieval_count
```
### Edge cases & guards:
1. **Newborn grace** — vectors < `RAG_GRACE_HOURS` old are excluded from eviction scroll (score=0 otherwise → immediate deletion)
2. **All-pinned freeze** — if scroll returns 0 evictable vectors, log warning and break loop
3. **Race**`asyncio.Lock()` guards `maybe_evict()`; concurrent callers wait their turn
4. **Zero config**`RAG_MAX_VECTORS <= 0` → eviction disabled; `RAG_EVICTION_BATCH <= 0` → clamped to 1
5. **Legacy payloads** — vectors without `retrieval_count` or `last_accessed` get defaults (0, `ingest_date`)
### Wire eviction:
Call `maybe_evict()` after each upsert batch completes in:
- `routers/upload.py` — after Qdrant upsert
- `routers/ingest.py` — after Qdrant upsert
### Admin endpoints — new `routers/rag_admin.py`:
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/rag/stats` | Operational stats (see `get_rag_operational_stats()`) — admin required |
| POST | `/api/rag/flush` | Delete ALL points from the Qdrant `caic` collection. Returns `{deleted_count, collection: "caic", status: "flushed"}`. Admin required. |
### In-memory eviction log:
```python
EVICTION_LOG: list[dict] = [] # managed by rag.py, max 1000 entries
# Each entry: {timestamp: iso, count: N, remaining: N}
# Tied to RATE_EVENTS pattern from security.py for rolling window calculations
```
### Tests — `tests/test_rag_management.py`:
- `get_collection_count()` — mock Qdrant GET, assert correct count
- `get_collection_stats()` — assert shape matches config
- `evict_batch()` — mock Qdrant scroll + delete, assert pinned sources excluded, grace period enforced, batch size respected
- `maybe_evict()` — below high water: 0 evicted; at high water: eviction fires; stops at low water; all-pinned scroll returns 0 → breaks
- `GET /api/rag/stats` — assert full shape
- `POST /api/rag/flush` — assert points deleted, admin required, guest 403
- `POST /api/rag/flush` by guest — assert 403
- Race lock — concurrent calls to `maybe_evict()` queue up, only one evicts
Mock all Qdrant calls via monkeypatch. Do not require live services.
Run full test suite. All existing tests must continue to pass.
---
## ~~TASK 9 — Roadmap N1: RabbitMQ Install and Service on Coordinator (Infrastructure) [DONE]~~
This task runs on coordinator (this machine). Install RabbitMQ and verify it is operational.
Run the following steps:
1. `apt-get update && apt-get install -y rabbitmq-server`
2. `systemctl enable rabbitmq-server && systemctl start rabbitmq-server`
3. `systemctl status rabbitmq-server` — verify active/running
4. Enable the management plugin: `rabbitmq-plugins enable rabbitmq_management`
5. Create a dedicated jC vhost: `rabbitmqctl add_vhost caic`
6. Create a dedicated user: `rabbitmqctl add_user caic CHANGEME_PASSWORD` — generate a random 24-char alphanumeric password and record it
7. Grant permissions: `rabbitmqctl set_permissions -p caic caic ".*" ".*" ".*"`
8. Verify management UI is reachable: `curl -s -u guest:guest http://localhost:15672/api/overview | python3 -m json.tool`
9. Delete default guest user: `rabbitmqctl delete_user guest`
Declare the two topic exchanges needed by jC:
- Exchange name: `jc.admin`, type: `topic`, durable: true
- Exchange name: `jc.system`, type: `topic`, durable: true
Use `rabbitmqadmin` or `curl` against the management API to declare exchanges. Verify both exchanges appear in: `curl -s -u caic:{password} http://localhost:15672/api/exchanges/caic`
Write the generated RabbitMQ password to `/home/gramps/.caic_amqp_secret` with mode 600. This will be read by cAIc as an env var source in subsequent tasks.
No pytest tests required for this infrastructure task.
---
## ~~TASK 10 — Roadmap N2: AMQP Connection Layer in jC [DONE]~~
This task adds the core AMQP connection manager to jC. It must connect to RabbitMQ on coordinator (localhost from jC's perspective since jC runs on coordinator), handle reconnection, and provide a shared channel for all AMQP operations.
**Add to `requirements.txt`:** `aio-pika>=9.0.0`
**Add to `config.py`:**
- `AMQP_URL` — read from env `CAIC_AMQP_URL`, default `amqp://caic:password@localhost:5672/caic`. The actual password comes from `/home/gramps/.caic_amqp_secret` — read it at startup if the env var is not set.
- `AMQP_RECONNECT_DELAY` — seconds between reconnect attempts, default 5
- `AMQP_EXCHANGE_ADMIN``jc.admin`
- `AMQP_EXCHANGE_SYSTEM``jc.system`
**Create `amqp.py`** in the project root:
```python
# Manages a single persistent aio-pika connection and channel.
# Provides:
# connect() -> None # establish connection, declare exchanges
# disconnect() -> None # graceful close
# get_channel() # returns current channel, reconnects if needed
# publish(exchange, routing_key, payload: dict) -> None
# # publishes JSON-serialized payload as persistent message
```
Connection must:
- Reconnect automatically on disconnect with `AMQP_RECONNECT_DELAY` backoff
- Log connection events at INFO level
- Not raise on publish if disconnected — log error and return (fire-and-forget, jC must not crash if RabbitMQ is down)
**Start AMQP connection in `app.py` lifespan** after `assess_hardware()`. Disconnect in lifespan cleanup.
**Write `tests/test_amqp.py`** covering:
- `publish()` with mocked aio-pika connection — assert message published with correct exchange and routing key
- `publish()` when disconnected — assert no exception raised, error logged
- `get_channel()` when connection is None — assert reconnect attempted
Mock all aio-pika calls via monkeypatch. Do not require a live RabbitMQ instance in tests.
Run full test suite. All existing tests must continue to pass.
---
## ~~TASK 11 — Roadmap N3: Cluster Protocol & Registration Handler (Coordinator Side) [DONE]~~
**Status: Implemented and pushed (899988c).** `amqp.py` subscribe/rebind, `cluster.py` with CLUSTER_NODES/CLUSTER_EVENTS/CLUSTER_COORDINATOR and 6 handlers, `routers/cluster.py` (`GET /api/cluster`), 13 tests. No passive heartbeats — ping/pong on-demand before work routing. 148 tests pass.
jC on the coordinator must listen for nine message types across `jc.admin` and `jc.system`, maintain the cluster registry, and expose an application-level event log.
### 11.1 AMQP Protocol — Message Catalog
All payloads are JSON, published as persistent messages.
| Direction | Exchange | Routing Key | Message Type | Description |
|-----------|----------|-------------|-------------|-------------|
| Worker → Coordinator | `jc.admin` | `node.{name}.register` | register | Worker requests admission |
| Worker → Coordinator | `jc.admin` | `node.{name}.deregister` | deregister | Worker signals graceful departure |
| Coordinator → Worker | `jc.admin` | `node.{name}.admitted` | admitted | Coordinator grants admission |
| Coordinator → Worker | `jc.admin` | `node.{name}.rejected` | rejected | Coordinator denies admission (with reason) |
| Coordinator → Worker | `jc.admin` | `node.{name}.ping` | ping | Coordinator checks if worker is alive (sent before routing work) |
| Worker → Coordinator | `jc.admin` | `node.{name}.pong` | pong | Worker confirms aliveness |
| Worker → Coordinator | `jc.system` | `node.{name}.event` | event | Application-level syslog event |
| Any → All | `jc.system` | `cluster.coordinator.query` | coord_query | Anyone asks "who is coordinator?" |
| Coordinator → All | `jc.system` | `cluster.coordinator.response` | coord_response | Coordinator announces itself |
Worker presence is assumed from registration onward. No periodic heartbeats — a worker can sit idle for days without chatter. When the coordinator needs to route work to a worker, it pings first; if the worker doesn't pong within timeout, the coordinator deregisters it and moves to the next node.
### 11.2 Payload Schemas
**register** (worker → coordinator):
```json
{
"node_name": "worker01",
"node_type": "worker",
"ip": "192.168.50.210",
"capabilities": {
"gpu": true, "gpu_type": "amd", "vram_mb": 8192,
"cpu_cores": 8, "ram_gb": 16
},
"active_model": {
"name": "llama3.1", "version": "latest", "quant": "Q4_K_M",
"path": "/var/lib/caic/models/llama3.1-latest-Q4_K_M.gguf",
"port": 8081
},
"inventory": [
{"name": "llama3.1", "version": "latest", "quant": "Q4_K_M",
"path": "/var/lib/caic/models/llama3.1-latest-Q4_K_M.gguf", "port": 8081}
],
"status": "active"
}
```
**deregister** (worker → coordinator):
```json
{
"node_name": "worker01",
"reason": "shutdown",
"timestamp": "2026-07-06T12:00:00Z"
}
```
**ping** (coordinator → worker):
```json
{
"from": "coordinator",
"node_name": "worker01",
"type": "ping",
"correlation_id": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2026-07-06T12:00:00Z"
}
```
Worker must respond within 5 seconds or the coordinator considers it absent.
**pong** (worker → coordinator):
```json
{
"node_name": "worker01",
"type": "pong",
"correlation_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "active",
"active_model": {"name": "llama3.1", "port": 8081},
"load": {"cpu_pct": 45, "ram_pct": 62, "vram_pct": 38},
"timestamp": "2026-07-06T12:00:00Z"
}
```
Correlation ID matches the ping so the coordinator can pair request and response.
**coord_query** (any → `cluster.coordinator.query`):
```json
{"type": "coord_query", "timestamp": "2026-07-06T12:00:00Z"}
```
Coordinator responds on `cluster.coordinator.response`:
```json
{
"coordinator_node": "coordinator",
"cluster_nodes": ["worker01"],
"timestamp": "2026-07-06T12:00:00Z"
}
```
**event** (worker → coordinator):
```json
{
"node_name": "worker01",
"severity": "info",
"message": "llama-server started with model llama3.1:latest",
"details": {"model": "llama3.1:latest", "port": 8081, "pid": 1234},
"timestamp": "2026-07-06T12:00:00Z"
}
```
Severity levels: `info`, `warn`, `error`, `critical`. The coordinator assigns `category: "application"` based on the exchange (jc.system). No `event_type` field — the category is determined by the channel, not the payload.
### 11.3 Design — Status Transitions Drive the Event Log
All admin-level events are *derived* from `register()` and `deregister()` as side effects. There are no separate message types for coordinator election, node staleness, quarantine, or release — those are status transitions that `register()`/`deregister()` emit into `CLUSTER_EVENTS` locally.
**Node status lifecycle:**
```
UNKNOWN ──register()──▶ active ──deregister()──▶ (removed)
ping timeout│(coordinator publishes
│ deregister on its behalf)
(removed)
```
**Coordinator status lifecycle:**
```
NONE ──register(node_type=coordinator)──▶ CLUSTER_COORDINATOR set
deregister()│or timeout
CLUSTER_COORDINATOR cleared
```
**Event categories — two buckets, no granular types:**
| Category | When | severity |
|----------|------|----------|
| `cluster` | Node lifecycle, coordinator changes, model swaps, node offline — everything on `jc.admin` | `info` / `warn` / `error` |
| `application` | Worker syslog events (incoming on `jc.system` `node.*.event`) | `info` / `warn` / `error` / `critical` |
Every `_push_event()` call uses one of these two categories. The `message` field carries the human-readable detail — no need for event type strings. The reporting tool filters by category + severity.
**Channel split — security rationale:**
The two exchanges are not an organizational convenience. They enforce a **data isolation boundary**:
| Exchange | Contains | Exposed to |
|----------|----------|------------|
| `jc.admin` | Node lifecycle, heartbeats, model swaps, coordinator changes | Operations / machine-room staff |
| `jc.system` | Application events — inference queries, RAG context, user-facing data | Application-layer audit only |
`jc.system` events can leak information about what users are doing and asking. The split ensures a sysadmin monitoring cluster health never accidentally consumes user-data-bearing events. The channels can be locked down independently — different AMQP credentials, separate queue permissions, different in-transit encryption policies if needed later.
### 11.4 Implementation
**Add to `amqp.py`:**
```python
_SUBSCRIPTIONS: list[tuple[str, str, Callable]] # (exchange, routing_key, callback)
async def subscribe(exchange, routing_key, callback) -> None
# Append to _SUBSCRIPTIONS list
# Declare a unique queue per subscription (name: f"jc.{exchange}.{sanitized_routing_key}")
# Bind queue to exchange/routing_key, consume with callback
```
Each subscription gets its own queue so multiple subscribers on different routing keys all receive messages. On reconnect: drain old consumers, iterate `_SUBSCRIPTIONS`, re-declare and re-bind each one. The `connect()` function must call `_rebind_subscriptions()` after exchanges are declared.
**Create `cluster.py`** in the project root:
```python
# In-memory cluster registry + event log
# Survives only while jC is running (not persisted)
CLUSTER_NODES: dict[str, NodeRecord]
CLUSTER_EVENTS: deque[EventRecord] # bounded at 1000 entries
CLUSTER_COORDINATOR: str | None # node_name of active coordinator
# NodeRecord fields:
# node_name, node_type, ip, status, active_model, inventory,
# capabilities: {gpu, gpu_type, vram_mb, cpu_cores, ram_gb}
# registered_at, last_seen
# EventRecord:
# category: str ("cluster" | "application")
# severity: str ("info" | "warn" | "error" | "critical")
# node_name: str
# message: str
# details: dict | None
# timestamp: str
def _push_event(category, severity, node_name, message, details=None) -> None
# Append EventRecord to CLUSTER_EVENTS, pop left if > 1000
async def handle_registration(message) -> None
# Parse payload, validate required fields (node_name, node_type, ip, active_model, inventory)
# Reject if node_name duplicate and CLUSTER_NODES[node_name].status == "active"
# If CLUSTER_COORDINATOR is None AND node_type == "coordinator":
# set CLUSTER_COORDINATOR = node_name
# _push_event("cluster", "info", node_name, "elected coordinator")
# publish cluster.coordinator.response on jc.system {coordinator_node, cluster_nodes, timestamp}
# Add node to CLUSTER_NODES with status="active"
# _push_event("cluster", "info", node_name, f"admitted as {node_type}")
# publish admitted on jc.admin node.{name}.admitted {node_name, timestamp, amqp_url}
async def handle_deregistration(message) -> None
# Parse payload (node_name, reason, timestamp)
# If node_name == CLUSTER_COORDINATOR:
# clear CLUSTER_COORDINATOR
# _push_event("cluster", "warn", node_name, f"coordinator lost — {reason}")
# _push_event("cluster", "info", node_name, f"departed — {reason}")
# Remove node from CLUSTER_NODES, log it
async def handle_pong(message) -> None
# Parse: node_name, correlation_id, status, active_model, load, timestamp
# Match correlation_id to outstanding ping
# If node in CLUSTER_NODES: update last_seen, status, active_model
# Signal the waiting caller that the node is alive
# If node unknown: log warning, do NOT auto-admit
async def handle_event(message) -> None
# Parse: node_name, severity, message, details, timestamp
# Assigns category="application" (incoming on jc.system)
# Append EventRecord to CLUSTER_EVENTS (pop left if > 1000)
async def handle_coordinator_query(message) -> None
# Respond on jc.system cluster.coordinator.response
# Payload: {coordinator_node, cluster_nodes: list(CLUSTER_NODES.keys()), timestamp}
def get_cluster_state() -> dict
# Return: {nodes: CLUSTER_NODES, coordinator: CLUSTER_COORDINATOR,
# events: last 50 CLUSTER_EVENTS}
```
**Subscribe in `app.py` lifespan** after AMQP connects:
| Exchange | Routing Key | Handler |
|----------|-------------|---------|
| `jc.admin` | `node.*.register` | `handle_registration` |
| `jc.admin` | `node.*.deregister` | `handle_deregistration` |
| `jc.admin` | `node.*.pong` | `handle_pong` |
| `jc.system` | `node.*.event` | `handle_event` |
| `jc.system` | `cluster.coordinator.query` | `handle_coordinator_query` |
### 11.5 API — `GET /api/cluster`
New router `routers/cluster.py`:
- `GET /api/cluster` — returns full cluster state: `{nodes, coordinator, events}` (last 50 events). No auth required.
Wire `cluster.router` into `app.py`.
### 11.6 Tests — `tests/test_cluster.py`
Mock all aio-pika calls. Do not require live RabbitMQ.
| # | Test | What it asserts |
|---|------|-----------------|
| 1 | Valid worker registration | Node admitted, CLUSTER_NODES updated, `cluster` event logged, `admitted` message published |
| 2 | First coordinator auto-promotion | CLUSTER_COORDINATOR set, `cluster` event with "elected" message, `coord_response` published |
| 3 | Duplicate node name rejected | `rejected` message with reason=`duplicate_node_name`, `cluster` event logged |
| 4 | Malformed payload rejected | `rejected` message with reason=`malformed_payload` |
| 5 | Graceful deregistration | Node removed, `cluster` event logged. If coordinator: CLUSTER_COORDINATOR cleared |
| 6 | Pong from known node | last_seen updated, load/status refreshed |
| 7 | Pong from unknown node | Warning logged, node NOT added |
| 8 | Event stored in log | Event appended to CLUSTER_EVENTS; at 1001 entries the oldest is popped |
| 9 | Coordinator query produces response | Response published with coordinator name and node list |
| 10 | GET /api/cluster shape | Response contains `nodes`, `coordinator`, `events` keys |
Run full test suite. All existing tests must continue to pass.
---
## ~~TASK 12 — Roadmap N4: Worker Node Registration Publisher (Worker Side) [DONE]~~
This task creates the worker node AMQP client that runs on worker (192.168.50.210). It is a standalone Python script — not part of the jC FastAPI app — that runs as a systemd service on worker.
**Create `node_agent/agent.py`** in the repo (new directory).
### 12.1 Config & Inventory Discovery
On start, reads `/etc/caic-node-agent.conf` (INI format):
- `node_name` — hostname, default from `socket.gethostname()`
- `node_ip` — LAN IP, default from socket
- `node_type``"worker"` (fixed)
- `capabilities` — comma-separated list, e.g. `llm,rag`
- `amqp_url` — RabbitMQ URL on coordinator, e.g. `amqp://caic:password@192.168.50.108:5672/caic`
- `llama_port` — port llama-server/llama-rpc is listening on, default 8081
- `models_dir` — path to GGUF model files, default `/var/lib/caic/models`
- `active_model` — filename of currently active model (without path)
Discovers inventory by globbing `models_dir` for `*.gguf` files and parsing name/version/quant from filename using regex pattern: `{name}-{version}-{quant}.gguf` where quant matches `Q[0-9]+_K_[A-Z]+` or similar standard suffixes.
### 12.2 Registration
Publishes registration to `jc.admin`, routing key `node.{node_name}.register`:
```json
{
"node_name": "worker01",
"node_type": "worker",
"ip": "192.168.50.210",
"capabilities": ["llm"],
"active_model": {"name": "...", "version": "...", "quant": "...", "path": "...", "port": 8081}
}
```
### 12.3 Admission Response
Listens on `node.{node_name}.admitted` and `node.{node_name}.rejected` (both `jc.admin`). Logs result. If rejected, exits with error.
### 12.4 Ping Listener
After admission: listens on `jc.admin`, routing key `node.{node_name}.ping`. On receipt, responds immediately (within 1 second) with a pong on `jc.admin`, routing key `node.{node_name}.pong`:
```json
{
"node_name": "worker01",
"type": "pong",
"correlation_id": "<echoed from ping>",
"status": "active",
"active_model": {"name": "...", "version": "...", "quant": "...", "path": "...", "port": 8081},
"load": {"cpu_pct": 45, "ram_pct": 62, "vram_pct": 38},
"timestamp": "<utc>"
}
```
No periodic heartbeats. Worker sits idle between pings — coordinator only pings when it needs to route work.
### 12.5 Model Swap Command Handler
Listens on `jc.admin`, routing key `node.{node_name}.cmd.swap_model`:
- Payload: `{model_filename: str}`
- Stops current llama-server: `systemctl stop llama-server`
- Updates `/etc/caic-node-agent.conf` active_model field
- Starts llama-server: `systemctl start llama-server` (assumes service reads active_model from conf or ExecStart is updated)
- Waits for llama-server to be healthy: poll `http://localhost:{llama_port}/v1/models` every 2s, timeout 120s
- Publishes to `jc.system`, routing key `node.{node_name}.model_ready`:
```json
{"node_name": "...", "active_model": "...", "port": ..., "timestamp": "..."}
```
- If startup fails within timeout: publishes `node.{node_name}.model_failed` with error detail
### 12.6 Files & Tests
**Create `node_agent/requirements.txt`:** `aio-pika>=9.0.0`
**Document `/etc/caic-node-agent.conf` format** in a comment block at the top of `agent.py`.
**Write `tests/test_node_agent.py`** covering:
- Registration payload construction from config + model discovery — assert correct JSON shape
- Model swap command handler: success path — assert systemctl calls made, model_ready published
- Model swap command handler: timeout path — assert model_failed published
- Ping handler: on ping, publishes pong with correct correlation_id
- Agent starts idle after admission, no heartbeat timer
Mock all aio-pika, subprocess, and httpx calls.
**Do not create a systemd service file in this task** — that is a manual deployment step. Document the required service configuration in a comment at the bottom of `agent.py`.
Run full test suite. All existing tests must continue to pass.
---
## ~~TASK 13 — Roadmap N5: Query Routing via AMQP + Phi-4-mini Triage [DONE]~~
This task wires the cluster into jC's chat flow. When a query arrives at `/api/chat`, instead of always routing to the hardcoded `LLAMA_SERVER_BASE`, jC now routes to the best available cluster node based on query context.
**Prerequisites:** Tasks 912 complete. At least one worker node admitted to cluster.
**Install Phi-4-mini on coordinator (infrastructure step):**
- Download `Phi-4-mini-Instruct-Q4_K_M.gguf` from HuggingFace using `hf download microsoft/Phi-4-mini-instruct --include "*.Q4_K_M.gguf" --local-dir /var/lib/caic/models`
- Create `/etc/systemd/system/llama-server-triage.service` — same pattern as existing llama-server service but: port 8083, model path points to Phi-4-mini GGUF, no `--rpc` flag (runs entirely on coordinator CPU/iGPU), description `Llama.cpp Server (Phi-4-mini — triage/routing)`
- `systemctl daemon-reload && systemctl enable llama-server-triage && systemctl start llama-server-triage`
- Verify: `curl -s http://localhost:8083/v1/models`
**Add to `config.py`:**
- `TRIAGE_BASE` — `http://127.0.0.1:8083/v1` (Phi-4-mini)
- `TRIAGE_TIMEOUT` — 10 seconds
- `FALLBACK_TO_DEFAULT` — True (if triage fails or no nodes available, fall back to `LLAMA_SERVER_BASE`)
**Create `triage.py`** in the project root:
```python
async def classify_query(query: str) -> str
# Sends query to Phi-4-mini at TRIAGE_BASE with a classification system prompt.
# System prompt instructs model to respond with ONLY one of:
# "general", "code", "search", "rag"
# Returns the classification string.
# Timeout: TRIAGE_TIMEOUT seconds.
# On any error: returns "general" (fail-safe).
async def select_node(classification: str) -> dict | None
# Consults CLUSTER_NODES from cluster.py
# For "code": prefer nodes where active_model name contains "coder" or "qwen"
# For "general": prefer nodes where active_model name contains "mistral" or "llama"
# For "search" or "rag": return None (handled locally by jC)
# If no matching node found: return None (triggers FALLBACK_TO_DEFAULT)
# Returns NodeRecord dict for selected node, or None
async def get_inference_url(query: str) -> str
# Combines classify_query + select_node
# Returns full base URL: f"http://{node.ip}:{node.active_model.port}/v1"
# Falls back to LLAMA_SERVER_BASE if classification=search/rag, no nodes, or triage error
```
**Update `routers/chat.py`:**
- Replace the hardcoded `LLAMA_SERVER_BASE` reference with a call to `get_inference_url(user_message)`
- The rest of the chat flow (RAG, memory, streaming) is unchanged — only the inference target URL changes
**Write `tests/test_triage.py`** covering:
- `classify_query()` returns valid classification — mock Phi-4-mini response
- `classify_query()` on timeout — assert returns "general", no exception
- `select_node("code")` with coder node in cluster — assert correct node returned
- `select_node("general")` with no matching node — assert None returned
- `get_inference_url()` with code query and coder node available — assert returns node URL
- `get_inference_url()` with no nodes in cluster — assert returns LLAMA_SERVER_BASE fallback
**Update `tests/test_chat_streaming_and_memory_paths.py`:**
- Mock `triage.get_inference_url` to return a fixed URL in all existing tests so they continue to pass without a live cluster
Run full test suite. All existing tests must continue to pass.
---
## ~~TASK 14 — Roadmap N6: Model Swap Command Flow [DONE]~~
**Status: Implemented and pushed (`9d1fd44`).** `request_model_swap()`, `handle_model_ready()`, `handle_model_failed()` in `cluster.py`, async `select_node()` with swap triggering in `triage.py`, `tests/test_model_swap.py` (9 tests). 177 tests pass.
This task implements the coordinator-side logic for requesting a model swap on a worker node when the ideal model is not currently active.
**Add to `cluster.py`:**
```python
async def request_model_swap(node_name: str, model_filename: str) -> bool
# Publishes to jc.admin exchange, routing key node.{node_name}.cmd.swap_model
# Payload: {model_filename, requested_at: iso_timestamp}
# Sets node status to "swapping" in CLUSTER_NODES
# Returns True if message published successfully
async def handle_model_ready(message) -> None
# Handles node.{node_name}.model_ready from jc.system
# Updates CLUSTER_NODES[node_name].active_model to the new model
# Sets node status back to "active"
# Logs swap completion with timing
async def handle_model_failed(message) -> None
# Handles node.{node_name}.model_failed from jc.system
# Sets node status to "error" in CLUSTER_NODES
# Logs failure with detail from message payload
```
**Subscribe in `app.py` lifespan:**
- `jc.system` exchange, routing key `node.*.model_ready` → `handle_model_ready`
- `jc.system` exchange, routing key `node.*.model_failed` → `handle_model_failed`
**Update `triage.py` `select_node()`:**
- If the best-matching node exists but its active_model does not match the ideal model for the classification, AND the node status is "active" (not already swapping):
- Call `request_model_swap(node_name, ideal_model_filename)`
- Return None (triggers fallback) — the swap happens async, next query will find the right model active
- If node status is "swapping": return None (fallback, swap in progress)
**Update `GET /api/cluster`** to include node status in response.
**Write `tests/test_model_swap.py`** covering:
- `request_model_swap()` — assert swap command published, node status set to "swapping"
- `handle_model_ready()` — assert active_model updated, status set to "active"
- `handle_model_failed()` — assert status set to "error"
- `select_node()` with mismatched active model — assert swap requested, None returned
- `select_node()` with node status "swapping" — assert None returned without publishing another swap
Run full test suite. All existing tests must continue to pass.
---
## ~~TASK 15 — Roadmap N7: Cluster Status UI [DONE]~~
Surface cluster awareness in the jC frontend (`templates/index.html`).
**Add a cluster status panel** to the UI. Requirements:
- Small status bar or collapsible panel, visible but unobtrusive
- Polls `GET /api/cluster` every 15 seconds
- For each admitted node: show node name, active model name, and a colored status dot:
- Green: active
- Yellow: swapping
- Red: error or offline (not seen in last 60 seconds based on last_seen timestamp)
- If no nodes in cluster (empty): show "No worker nodes connected"
- Panel must not interfere with chat input or conversation list
**Update `GET /api/cluster` response** to include `last_seen` per node and a `status` field (`active`, `swapping`, `error`).
**Update heartbeat handling in `cluster.py`:** add a handler for `node.*.heartbeat` on `jc.system` that updates `last_seen` timestamp for the node.
**Subscribe in `app.py` lifespan:**
- `jc.system` exchange, routing key `node.*.heartbeat` → `handle_heartbeat`
**Add `handle_heartbeat()` to `cluster.py`:**
- Updates `CLUSTER_NODES[node_name].last_seen` to current timestamp
- If node was previously marked offline (not in CLUSTER_NODES), log re-registration warning but do not auto-admit — full registration required
**Write `tests/test_cluster_heartbeat.py`** covering:
- `handle_heartbeat()` for known node — assert last_seen updated
- `handle_heartbeat()` for unknown node — assert no crash, warning logged, node not added
Run full test suite. All 26+ existing tests must continue to pass.
~~Commit all changes introduced across Tasks 915 with message: `feat: Roadmap N — AMQP cluster nervous system complete`~~
---
## Backlog (Post-Roadmap N) ⏳
### ~~B1 — Context loss in follow-up questions [DONE]~~
**Symptom:** After asking "in {context}, explain {b}", a follow-up "what is {b}'s {x}?" gets a non-sequitur response that ignores the original context.
**Diagnosis:** `build_system_prompt()` is called fresh per-request with new RAG/memory results keyed to the current message text. These can change between turns and may dilute or override the conversation history. The original system prompt used for turn 1 (including its RAG context) is not stored in the DB — only user/assistant messages are. The inference server receives a different system prompt each turn.
**Possible fixes:**
- Store the assembled system prompt with each assistant message in the DB
- When replaying history, re-send the original system prompts from DB rather than rebuilding
- Or: cap RAG/memory injection to only fire on the first message of a conversation, then rely solely on conversation history for follow-ups
- Check that llama-server isn't truncating history due to context window overflow (Mistral-Nemo 12B = 128K context, unlikely)
### ~~B2 — Bang-prefixed search routing [DONE]~~
**Spec:** If a query begins with `!`, route to SearXNG search instead of local inference.
**Where:** In `routers/chat.py` `chat()` handler, after `user_message` is extracted. Strip the `!`, set a flag to always trigger auto-search regardless of perplexity/refusal.
**Change:** Add a `force_search` flag when `user_message.startswith("!")`, strip the prefix from the message saved to DB, and route directly to the search+summarize path.
### ~~B3 — Docker distribution (v1.0 gate) [DONE]~~
**Goal:** Ship cAIc as a `docker compose` stack so a single command stands up everything.
**Services to containerize:**
- cAIc (FastAPI app + SQLite)
- SearXNG
- Qdrant
- RabbitMQ
- llama-server (with optional RPC sidecar for GPU offload)
- Ollama (embeddings)
**Also needed:**
- `Dockerfile` for the cAIc app itself
- `docker-compose.yml` with all services, volumes, networks, env vars
- Setup wizard script (run on first boot) that:
- Probes CPU vs GPU (reuses `hardware.py`)
- Queries user for admin PIN, node name, IP
- Generates `.env` file with correct `LLAMA_SERVER_BASE`, `EMBED_URL`, etc.
- Auto-calculates `RAG_MAX_VECTORS` from available RAM: `max(1000, int(available_ram_gb * 100_000))`
- Optionally detects and configures RPC GPU offload
- Manual install docs remain alongside for bare-metal deployment
**This task is only actionable after Tasks 815 (RAG eviction + AMQP cluster) are complete.**
---
### ~~B4 — RAG Corpus Management UI (Display, Edit, CRUD) [DONE]~~
**Goal:** Provide a management interface in the UI to browse, search, edit, and delete individual entries in the Qdrant-backed RAG corpus.
**Backend — add to `routers/rag_admin.py`:**
| Method | Endpoint | Description | Auth |
|--------|----------|-------------|------|
| GET | `/api/rag/points` | Return paginated list of RAG points with payload (text, source, date). Supports `?offset=0&limit=50&search=` query params | Admin |
| GET | `/api/rag/point/{point_id}` | Return a single point with full payload | Admin |
| DELETE | `/api/rag/point/{point_id}` | Delete a single point from Qdrant | Admin |
| PATCH | `/api/rag/point/{point_id}` | Update a point's text payload (re-embed the new text) | Admin |
Helper functions for Qdrant scroll/delete/update go in `rag.py` or `eviction.py`.
**Frontend — add to `templates/index.html`:**
A "RAG" button in the admin UI (drawer or settings modal) that opens a management panel:
- **Stats bar**: vector count, max vectors, percent full, pinned sources
- **Search bar**: text input to search the RAG corpus by semantic similarity
- **Results table**: paginated list showing each vector's text snippet, source label, ingest date, retrieval count
- Click to expand full text
- Delete button per row (with confirmation)
- Edit button per row (inline text edit → re-embed on save)
- **Bulk actions**: flush all (existing `/api/rag/flush`) with confirmation
**Tests:**
- `tests/test_rag_admin.py` — cover new endpoints: list, get, delete, update, admin-enforcement
- Mock all Qdrant calls via monkeypatch
Run full test suite. All existing tests must continue to pass.**
+31
View File
@@ -0,0 +1,31 @@
#!/bin/bash
# jc-ingest.sh — pipe terminal commands into cAIc RAG
# Deploy to /home/gramps/bin/jc-ingest.sh on jarvis (192.168.50.212)
#
# Usage:
# 1. chmod +x /home/gramps/bin/jc-ingest.sh
# 2. Add to ~/.bashrc:
# export CAIC_COMPLETIONS_API_KEY="$(cat /opt/jarvischat/.completions_key)"
# export PROMPT_COMMAND="jc_capture"
# source /home/gramps/bin/jc-ingest.sh
#
# The PROMPT_COMMAND hook runs jc_capture() after each command.
# Only commands matching the filter pattern are ingested.
#
# Filter: currently captures git, pip, systemctl, sudo, vi/vim, curl,
# wget, apt, python, pytest commands. Edit the grep pattern to adjust.
JC_URL="http://192.168.50.212:8080/api/ingest"
JC_TOKEN="${CAIC_COMPLETIONS_API_KEY}"
jc_capture() {
local cmd
cmd=$(history 1 | sed 's/^[ ]*[0-9]*[ ]*//')
if echo "$cmd" | grep -qE '^(git|pip|systemctl|sudo|vi|vim|curl|wget|apt|python|pytest)'; then
curl -s -X POST "$JC_URL" \
-H "Authorization: Bearer $JC_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"content\": $(echo "$cmd" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().strip()))'), \"source\": \"terminal\"}" \
> /dev/null 2>&1 &
fi
}
+265 -98
View File
@@ -1,165 +1,332 @@
# Developer Architecture Guide # Developer Architecture Guide
This document explains how JarvisChat is structured, why key guardrails exist, and what the test suite validates. This document explains how cAIc is structured, the external services it integrates with, and the key architectural changes made during development.
## 1. System Overview ## 1. System Overview
JarvisChat is a single-process FastAPI service with a Jinja2 frontend and SQLite persistence. cAIc is a single-process FastAPI service with a Jinja2 frontend and SQLite persistence. It connects to an external llama-server for inference and optionally to SearXNG (web search), Qdrant (vector search), and RabbitMQ (AMQP cluster messaging).
Primary files: ### 1.1 Module Layout
- `app.py`: API, middleware, streaming/chat logic, auth, memory, skills, and DB bootstrap Refactored from single-file (`app.py`) into modules under project root:
- `templates/index.html`: main WebUX, settings panels, auth flow, streaming UI handlers
- `jarvischat.db`: runtime SQLite database created and migrated at startup
Core runtime integrations: | File | Role |
|------|------|
| `app.py` | FastAPI app, middleware, router registration, lifespan |
| `config.py` | Constants, env vars, rate/payload limits, built-in skills registry, upload limits, RAG eviction config |
| `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 |
| `search.py` | SearXNG integration, perplexity scoring, refusal detection |
| `rag.py` | Qdrant vector search, system prompt assembly, chunk_text() helper, collection stats |
| `eviction.py` | Score-based RAG eviction engine (extracted from rag.py) |
| `gpu.py` | AMD GPU stats via rocm-smi |
| `hardware.py` | Hardware self-assessment — CPU, RAM, VRAM, service health probes (llama-server, Qdrant, SearXNG, ComfyUI) |
| `amqp.py` | aio-pika connection manager for RabbitMQ (connect, disconnect, publish, subscribe, auto-reconnect) |
| `cluster.py` | Cluster node registry, event log, coordinator election, ping/pong, model swap handlers, image generation request/response |
| `routers/` | One module per endpoint group |
- Ollama for chat/model interaction ### 1.2 External Services
- SearXNG for web search (optional)
- wttr.in for weather shortcut queries | Service | Required | Port | Purpose |
- rocm-smi for GPU stats when available |---------|----------|------|---------|
| llama-server (coordinator) | Yes | 8081 | LLM inference (OpenAI-compat), RPC offload to worker:50052 |
| SearXNG | No | 8888 | Privacy-respecting web search |
| Qdrant (coordinator) | No | 6333 | Vector database for RAG |
| Ollama (worker) | No | 11434 | Embeddings for RAG chunk vectors |
| ComfyUI (worker) | No | 8188 | Image generation (Stable Diffusion / Flux) |
| RabbitMQ (coordinator) | No | 5672 | AMQP broker for cluster messaging |
| rocm-smi | No | — | AMD GPU stats (host-level) |
### 1.3 Config Discovery
Key base URLs are configured via environment variables with sensible defaults:
| Variable | Default | Service |
|----------|---------|---------|
| `LLAMA_SERVER_BASE` | `http://localhost:8081` | llama-server on the same node |
| `OLLAMA_BASE` | `http://localhost:11434` | Legacy — all inference goes through LLAMA_SERVER_BASE |
| `SEARXNG_BASE` | `http://localhost:8888` | SearXNG |
| `QDRANT_URL` | `http://localhost:6333` | Qdrant on the same node |
| `CAIC_AMQP_URL` | `amqp://caic:password@localhost:5672/caic` | RabbitMQ |
| `CAIC_COMFYUI_BASE` | `http://localhost:8188` | ComfyUI (image gen) |
| `CAIC_COMFYUI_TIMEOUT` | `120` | ComfyUI generation timeout (seconds) |
> **Current deployment (single-node):** all services run on jarvis (192.168.50.212). The cluster/AMQP/node-agent layer is dormant — it degrades gracefully and can be re-enabled for a multi-node cluster later.
## 2. Request/Response Architecture ## 2. Request/Response Architecture
### 2.1 Chat Pipeline (`/api/chat`) ### 2.1 Chat Pipeline (`/api/chat`)
1. Validate session, role, origin, rate, and payload limits in middleware 1. Validate session, role, origin, rate, and payload limits in middleware
2. Persist user message and conversation metadata 2. Intercept "remember that..." / "forget about..." commands → process_remember_command()
3. Build system prompt from enabled profile, memory context, and active skills metadata 3. Persist user message and conversation metadata
4. Stream model response over SSE token-by-token 4. Build system prompt: profile + FTS5 memory + Qdrant RAG results + preset + active skills + uploaded document (if upload_context_id)
5. Evaluate uncertainty/refusal; if needed, trigger search augmentation and stream augmented result 5. Stream from llama-server with `logprobs: true` for perplexity scoring
6. Persist final assistant message and emit terminal SSE event 6. If perplexity > 15.0 OR refusal patterns match → re-query with SearXNG results
7. Persist final assistant message and emit terminal SSE event
### 2.2 Explicit Search Pipeline (`/api/search`) ### 2.2 Explicit Search Pipeline (`/api/search`)
1. Persist search-as-message into the target/new conversation 1. Persist search-as-message into conversation
2. Emit `searching` SSE event 2. Emit `searching` SSE event
3. Pull web results from SearXNG 3. Pull web results from SearXNG
4. Summarize with Ollama via SSE stream 4. Summarize via llama-server SSE stream
5. Persist summary and emit `done` event (plus raw results payload) 5. Persist summary and emit `done` event
### 2.3 Settings/Control Surface ### 2.3 RAG Ingest Pipeline (`/api/ingest`)
- Profile, presets, memory, conversation management, and settings APIs 1. Bearer token auth (same key as completions API)
- Skills APIs for phase-1 registry and enable/disable controls 2. Chunk text via shared `chunk_text()` helper (512-token chunks, 128-token overlap)
- Auth/session APIs for guest/admin role handling and keepalive 3. Embed via Ollama `/api/embeddings`
4. Upsert to Qdrant collection `caic_rag`
5. Trigger `maybe_evict()` if collection exceeds high-water mark
### 2.4 Upload Pipeline (`/api/upload`)
1. Admin required, multipart file upload
2. Validate MIME type + size against config limits
3. PDF text extraction via pypdf; plain text for all other types
4. Three modes: `context` (SQLite with 1hr expiry), `ingest` (RAG/Qdrant), `both`
5. Trigger `maybe_evict()` if ingest mode
### 2.5 Image Generation Pipeline (`POST /api/image/generate`)
1. Admin required, JSON body with prompt and optional params (width, height, steps, seed, model)
2. Find active node with `image_gen` capability via `_find_image_node()`
3. Publish `cmd.image_generate` via AMQP to selected worker node
4. Worker node agent builds ComfyUI workflow (CheckpointLoader → KSampler → VAEDecode → SaveImage)
5. Worker polls ComfyUI `/history/{prompt_id}` until image is ready
6. Worker fetches PNG from ComfyUI `/view` endpoint, base64-encodes, publishes `image_generated` on `jc.system`
7. Coordinator receives response, decodes base64, returns `image/png` to client
8. `GET /api/image/status` returns available image gen nodes and their status
## 3. Data Model (SQLite) ## 3. Data Model (SQLite)
Key tables: Key tables:
- `conversations`: conversation headers and timestamps - `conversations` headers, timestamps, attachment_count
- `messages`: ordered chat history entries - `messages` ordered chat history per conversation
- `profile`: singleton row for injected profile prompt - `profile` singleton row for injected profile prompt
- `settings`: runtime toggles and selected defaults - `settings` runtime toggles and selected defaults
- `system_presets`: named reusable system prompts - `system_presets` named reusable system prompts
- `skills`: per-skill enabled state and timestamp - `skills` per-skill enabled state and timestamp
- `memories` (FTS5 virtual table): searchable user memory facts - `memories` (FTS5 virtual table) — full-text searchable user memory facts
- `upload_context` — auto-expiring document storage for context injection
Design notes: Design notes:
- Startup is idempotent: tables created if missing, defaults seeded only when absent
- Startup is idempotent: tables are created if missing and defaults seeded only when absent - No connection pool: each request opens and closes a short-lived SQLite connection
- No connection pool: each request opens a short-lived SQLite connection - `init_db()` called in FastAPI lifespan
## 4. Security Implementations ## 4. Security Implementations
This section documents explicit controls currently in code.
### 4.1 Auth Model ### 4.1 Auth Model
- Guest session is default for conversational access - Guest session by default (POST /api/auth/guest)
- Admin unlock uses 4-digit PIN and creates admin-capable session - Admin unlock via 4-digit PIN (POST /api/auth/login)
- Admin required for write/destructive routes - Admin required for PUT/DELETE/PATCH + all POST except allowlist (/api/chat, /api/search, /api/auth/*)
- Session heartbeat/timeout and explicit logout/revoke flow - /api/ingest is exempt from session auth — self-authenticates via Bearer token
- Session heartbeat/timeout (90s default) and explicit logout
### 4.2 PIN and Session Hardening ### 4.2 PIN Hardening
- Admin PIN hashed with PBKDF2-HMAC-SHA256 + salt - Admin PIN hashed with PBKDF2-HMAC-SHA256 + salt
- Failed PIN attempts tracked per client IP - Failed PIN attempts tracked per client IP (max 5, 300s lockout)
- Lockout window enforced after max failed attempts - Default PIN allowed only if CAIC_ALLOW_DEFAULT_PIN=true
### 4.3 Browser and API Abuse Controls ### 4.3 Browser and API Abuse Controls
- Origin checks on state-changing requests - Origin checks on all /api/ requests (rejects absent Origin AND Referer)
- Rate limiting by endpoint category and identity (IP/session) - Rate limiting per endpoint category and identity (IP/session)
- Payload size limits per route class - Payload size limits per route class (64KB default, 128KB chat, 20MB upload)
- Settings key allowlist to block arbitrary configuration injection - Settings key allowlist (5 keys: profile_enabled, default_model, etc.)
- IP allowlist/CIDR gate with optional trusted proxy forwarding mode - IP allowlist/CIDR gate with trusted proxy forwarding mode
### 4.4 Output and Error Safety ### 4.4 Output and Error Safety
- Search result URLs sanitized to `http`/`https` only - Search result URLs sanitized to http/https only
- Client-safe error envelopes with incident key correlation - Client-safe error envelopes with incident key correlation
- Full stack traces and diagnostic metadata logged server-side only - Full stack traces logged server-side only
### 4.5 Operational Auditability ### 4.5 Operational Auditability
- Structured audit events for auth actions, admin operations, and guardrail denials - Structured audit events for auth actions, admin ops, guardrail denials
- Incident logs include event type, key, path/method context, and runtime metadata - Incident logs with event type, key, path/method, and runtime metadata
## 5. Skills Framework (Phase 1) ## 5. RAG Architecture
Goal: introduce a governed skills control plane inside the local JarvisChat sandbox. ### 5.1 Vector Search
Current behavior: - Qdrant collection `caic_rag` on coordinator:6333
- Embeddings via Ollama on worker:11434 (`/api/embeddings`)
- Shared `chunk_text(text, chunk_size=512, overlap=128)` helper in rag.py
- Upload and ingest endpoints share the same chunk+embed+upsert pipeline
- Built-in skill registry defined server-side ### 5.2 Score-Based Eviction
- Per-skill enable/disable persisted in DB
- Global `skills_enabled` master toggle in settings
- Active skills injected into system prompt with bounded text budget
- API endpoints to list skills, list active skills, and toggle skill state
- WebUX settings panel to control master/per-skill toggles
Non-goals in phase 1: When `RAG_MAX_VECTORS` is exceeded, eviction fires with hysteresis:
- No unrestricted shell/tool execution - High-water mark: 80% of max → trigger eviction
- No external connector execution (filesystem, Gmail, etc.) - Low-water mark: 20% of max → stop eviction
- Batch size: 1000 vectors per cycle
- Score formula: `score = (access_weight * retrieval_count) + (age_weight * hours_since_ingested)`
- Lower score evicted first (least useful)
- Tiebreaker: oldest last_accessed ASC
- Excluded sources: `upload`, `profile` (pinned)
- Grace period: 1 hour before any vector is eligible
- Thread-safe via `asyncio.Lock`
## 6. Testing Strategy and Validation Intent Eviction module at `eviction.py` (re-exported through `rag.py` for backward compat).
The test suite validates both behavior and guardrail assumptions. ### 5.3 Operational Stats
### 6.1 What We Test `GET /api/rag/stats` (admin required) returns:
- vector_count, max_vectors, high_water_pct, low_water_pct, percent_full
- pinned_sources list, grace_hours
- at_risk_count, pinned_count, avg_retrieval_count
- eviction_counts_last_{1,5,30}m
- Auth capability separation (guest vs admin) ### 5.4 Flush
- URL sanitization safety for outbound links
- Rate and payload guardrails
- IP allowlist behavior
- Safe error envelope behavior and SSE error leakage prevention
- Streaming chat/search and memory command paths
- Skills framework toggles and prompt-injection behavior
### 6.2 Why These Tests Matter `POST /api/rag/flush` (admin required) — deletes all non-pinned vectors. Returns `{deleted_count, collection, status}`.
- Confirms security controls are active and regression-resistant ## 6. Cluster Architecture
- Ensures streaming UX protocol remains stable (`token`, `searching`, `done`, `error`)
- Verifies policy intent: dangerous actions require admin capability
- Validates new features preserve prior guarantees
### 6.3 Internal Process Validation ### 6.1 Design Model: Broker-Mediated
For substantive changes, Definition of Done includes: cAIc uses a **broker-mediated** cluster design. This is the preferred architecture and is reflected in all implementation decisions below.
**How it works:**
- A single RabbitMQ broker (or clustered set of brokers) acts as the central nervous system
- **Coordinator nodes** run the FastAPI app, host the HTTP API/UI, and publish commands to the broker
- **Worker nodes** connect as AMQP *clients only* — they consume commands and publish status events, but run no broker software themselves
- Communication is asynchronous and persistent: each node opens a TCP connection on startup and keeps it alive. The coordinator probes worker health via on-demand AMQP ping/pong messages (5s timeout) rather than relying on the AMQP-0-9-1 transport-level heartbeat.
**Why broker-mediated:**
- Workers are heterogeneous (different GPUs, different models, ARM vs x86) — no assumption of uniform software
- Workers are lightweight — a Raspberry Pi with a USB AI accelerator can participate without running a broker
- The coordinator delegates work via messages, not by SSH'ing into workers or requiring shared filesystems
- Failure is isolated: a crashed worker stops responding to ping; the coordinator auto-deregisters it and reassigns its work
**What it is NOT:**
- Not a service mesh — workers do not run identical software stacks
- Not autonomous failover — if the coordinator dies, a replacement must be manually promoted (or pre-configured as a secondary coordinator). Workers cannot self-promote to coordinator because they lack the required services (FastAPI, SQLite, DB schema, SearXNG, Qdrant, etc.)
- Not a peer-to-peer cluster — all orchestration flows through the coordinator
### 6.2 Node Types
Every physical machine in the cluster is classified by which services it runs. Two node types are defined:
| Aspect | Coordinator | Worker |
|--------|------------|--------|
| **Role** | Serves HTTP API/UI, orchestrates inference, owns cluster state | Runs inference models on behalf of the coordinator |
| **Python** | Required — runs FastAPI app | Required — runs node agent (aio-pika consumer) |
| **RabbitMQ server** | Required — hosts the broker | Not required — connects as AMQP client only |
| **RabbitMQ client (aio-pika)** | Required — publishes commands, consumes events | Required — consumes commands, publishes events |
| **FastAPI / uvicorn** | Required | Not needed |
| **SQLite** | Required — owns caic.db | Not needed |
| **Qdrant** | Optional (recommended) — vector DB for RAG | Not needed |
| **SearXNG** | Optional — web search | Not needed |
| **llama-server** | Optional — can share its own GPU for inference | Required — this is why the worker exists |
| **Ollama** | Optional — embeddings for RAG | Not needed |
| **rocm-smi / nvidia-smi** | Optional — hardware stats | Optional — node agent reports this at registration |
### 6.3 Service Distribution Summary
```
Coordinator Worker(s)
┌────────────────────┐ ┌──────────────────────────┐
│ cAIc │ │ llama-server │
│ (FastAPI + SQLite)│ │ (inference) │
│ RabbitMQ server │◄──AMQP───────│ aio-pika (agent) │
│ SearXNG (opt) │ persistent │ ROCm / CUDA (if GPU) │
│ Qdrant (opt) │ TCP │ Ollama (embeddings,opt) │
│ llama-server(opt) │ conn │ │
└────────────────────┘ │ No broker │
│ No cAIc │
│ No DB │
│ No search/vector │
└──────────────────────────┘
```
### 6.4 RabbitMQ Topology
Every RabbitMQ server belongs to a cluster. Currently only the coordinator runs one; if high availability is needed, additional nodes can join the RMQ cluster without changing the architecture.
| Exchange | Type | Purpose |
|----------|------|---------|
| `jc.admin` | topic | Lifecycle commands: register, deregister, ping, pong, admitted, rejected; model commands: cmd.swap_model; image commands: cmd.image_generate |
| `jc.system` | topic | Events: model_ready, model_failed, image_generated, image_failed, node.*.heartbeat, event; coordinator queries: coord_query, coord_response |
All exchanges, queues, and bindings are declared by `amqp.py` at startup. Worker runs `node_agent/agent.py` which connects as an AMQP client, registers, responds to ping, and handles model swap commands.
## 7. SSE Protocol
All streaming endpoints yield `data: {json}\n\n`:
- `{token, conversation_id}` — streaming token
- `{searching: true}` — web search triggered
- `{search_results: N}` — N results found (no raw payload)
- `{done: true, perplexity, tokens_per_sec, searched?}` — terminal
- `{error: "...", error_key: "..."}` — error with incident key
## 8. Testing Strategy
### 8.1 Test Framework
- pytest with `tmp_path` + monkeypatched httpx.AsyncClient
- No live external services required
- Test factories reset `SESSIONS`, `PIN_ATTEMPTS`, `RATE_EVENTS` globals per test
### 8.2 Test Coverage Areas (228 tests)
| Test file | Coverage |
|-----------|----------|
| test_auth_capabilities.py | Guest/admin sessions, origin blocking, logout |
| test_chat_streaming_and_memory_paths.py | Streaming, auto-search, remember/forget, upload context injection, private chat |
| test_cluster.py | Registration, deregistration, pong, events, coordinator query |
| test_cluster_heartbeat.py | Heartbeat handler, known/unknown node |
| test_completions.py | API key auth, FIM, streaming, blocking, errors |
| test_conversations.py | Full CRUD, guest admin, attachment_count |
| test_error_envelopes.py | Global exception handler + stream errors |
| test_gpu.py | GPU stats — rocm-smi (Linux), system_profiler (Darwin/Apple Silicon) |
| test_hardware.py | Hardware assessment, service reachability |
| test_image.py | Image generation — cluster handlers, router proxy, node agent ComfyUI integration, hardware probe, capability detection |
| test_ingest.py | Bearer auth, chunk/embed/upsert, validation |
| test_ip_allowlist.py | IP allowlist helper + middleware |
| test_memories.py | Edit, search, stats |
| test_model_pull.py | Default model auto-pull — llama-server check, Ollama fallback, error paths |
| test_model_swap.py | request_model_swap, handle_model_ready/failed, select_node swap triggering |
| test_models_router.py | Models list, ps, show, stats, search/status |
| test_node_agent.py | Node agent registration, ping/pong, model swap |
| test_presets.py | Full CRUD, default preset protection |
| test_profile.py | Get, update, default, length validation |
| test_rag_management.py | Collection stats, eviction algorithm, hysteresis, flush |
| test_rate_and_payload_guardrails.py | Rate limits + payload size |
| test_search_route.py | Explicit search flow, no results, errors |
| test_search_url_sanitization.py | URL sanitizer |
| test_settings_allowlist.py | Allowlisted key enforcement |
| test_skills_framework.py | List, toggle, unknown skill, prompt injection |
| test_upload.py | Upload, delete, link, by-conversation, attachment_count |
### 8.3 DoD Process
For substantive changes:
1. Implement code change 1. Implement code change
2. Add/adjust tests proving behavior and guardrail intent 2. Add/adjust tests proving behavior and guardrail intent
3. Update README release notes for user-facing impact 3. Update this wiki and README in the same change set
4. Update wiki architecture/security/testing docs for maintainers 4. Validate with full test run before commit
5. Validate with targeted test runs before merge/deploy
This process is intentionally explicit so design decisions remain auditable over time. ## 9. Hardware Self-Assessment
## 7. Deployment and Operations Notes On startup, `assess_hardware()` probes:
- RAM total/available (psutil)
- VRAM total/free (rocm-smi, best-effort)
- llama-server reachability + model list
- Qdrant reachability + collection list
- SearXNG reachability
- ComfyUI reachability + checkpoint model list
- Primary deployment target: local/homelab systemd service Writes `hardware_state.json` to working directory.
- Required dependency: Ollama
- Optional dependency: SearXNG
- Recommended log review path: system journal for startup, guardrail denials, and incidents
## 8. Contribution Guidance
When adding a feature:
1. Define security posture first (who can execute, what can fail, and failure mode)
2. Implement smallest safe slice with clear limits
3. Add tests that prove both happy path and guardrail path
4. Update this wiki and README in the same change
+3 -3
View File
@@ -1,6 +1,6 @@
# JarvisChat Developer Wiki # cAIc Developer Wiki
This wiki is the developer-facing architecture and process reference for JarvisChat. This wiki is the developer-facing architecture and process reference for cAIc.
## Audience ## Audience
@@ -14,7 +14,7 @@ This wiki is the developer-facing architecture and process reference for JarvisC
## Scope and Support Model ## Scope and Support Model
JarvisChat is designed for local and trusted-LAN operation. cAIc is designed for local and trusted-LAN operation.
The code may technically function against external or commercial endpoints, but this deployment mode is not a supported target in this project. The code may technically function against external or commercial endpoints, but this deployment mode is not a supported target in this project.
+194
View File
@@ -0,0 +1,194 @@
# WireGuard Tunnel — Encrypted Node Transit
> **Status: dormant (single-node deployment).** All cAIc services currently run on one node (jarvis, 192.168.50.212), so there is no inter-node traffic to encrypt. This document is kept as a reference for when a multi-node cluster is stood back up.
## Why
cAIc cluster traffic is plaintext today:
| Traffic | Protocol | Plaintext risk |
|---------|----------|----------------|
| AMQP (coordinator ↔ worker agent) | TCP :5672 | Registration, ping/pong, swap commands |
| Inference (coordinator → worker llama-server) | HTTP :8081 | Every token generated |
| LLM RPC layer offload (coordinator llama-server → worker) | TCP :50052 | Internal llama.cpp protocol |
WireGuard encrypts all three at the network layer with zero application changes. The cAIc app keeps using `http://` URLs — it's just talking to a virtual IP whose traffic is automatically encrypted before it hits the wire.
## Topology
```
┌───────────────────────┐ WireGuard tunnel ┌───────────────────────┐
│ Coordinator (ultron) │◄═══════════════════════════►│ Worker (jarvis) │
│ 10.0.2.1 │ UDP :51820 │ 10.0.2.2 │
│ LAN 192.168.50.108 │ │ LAN 192.168.50.210 │
│ │════════════════════════════►│ │
│ │ UDP :51820 │ Worker (corsair) │
│ │ │ 10.0.2.3 │
│ │ │ LAN (DHCP) │
└───────────────────────┘ └───────────────────────┘
```
All nodes connect directly to the coordinator's WireGuard endpoint (star topology). Workers do not need to talk to each other.
## Prerequisites
```bash
# Debian / Ubuntu
sudo apt install wireguard
# Windows / WSL2 — install WireGuard from https://www.wireguard.com/install/
# The wg.exe binary is used inside WSL2; the Windows GUI manages the tunnel config
```
## Key Generation
Run once per node. Save the private key securely; public keys go into peer configs on the other end.
```bash
wg genkey | tee /etc/wireguard/private.key | wg pubkey > /etc/wireguard/public.key
chmod 600 /etc/wireguard/private.key
```
**Recorded keys for this deployment:**
| Node | Private key | Public key |
|------|-------------|------------|
| ultron | (node private) | `ultron_pubkey=` |
| jarvis | (node private) | `jarvis_pubkey=` |
| corsair | (node private) | `corsair_pubkey=` |
## Per-Node Configs
### Coordinator — `/etc/wireguard/wg0.conf` on ultron
```ini
[Interface]
Address = 10.0.2.1/24
ListenPort = 51820
PrivateKey = <ultron_private_key>
# Enable IP forwarding so workers can route through coordinator if needed
# sudo sysctl -w net.ipv4.ip_forward=1
# sudo sysctl -w net.ipv6.conf.all.forwarding=1
# Worker: jarvis
[Peer]
PublicKey = <jarvis_pubkey>
AllowedIPs = 10.0.2.2/32
# If jarvis is off-site, put its public IP / DDNS hostname here:
# Endpoint = jarvis.example.com:51820
# If jarvis is LAN-only, set PersistentKeepalive = 25 to maintain NAT binding:
# PersistentKeepalive = 25
# Worker: corsair
[Peer]
PublicKey = <corsair_pubkey>
AllowedIPs = 10.0.2.3/32
```
### Worker — `/etc/wireguard/wg0.conf` on jarvis (Linux)
```ini
[Interface]
Address = 10.0.2.2/24
ListenPort = 51820
PrivateKey = <jarvis_private_key>
# Coordinator
[Peer]
PublicKey = <ultron_pubkey>
AllowedIPs = 10.0.2.0/24
# LAN-only: just point at the LAN IP
Endpoint = 192.168.50.108:51820
# Off-site: use DDNS or static IP:
# Endpoint = ultron.example.com:51820
PersistentKeepalive = 25
```
### Worker — Windows / WSL2 on corsair
Create a WireGuard tunnel in the Windows GUI app with the same config as jarvis above (Address=10.0.2.3/24). WSL2 inside Windows can reach the tunnel IP via the Windows host.
If llama-server runs inside WSL2 on corsair, the Windows host's WireGuard tunnel IP `10.0.2.3` is reachable from the WSL2 instance as well — just bind llama-server to `0.0.0.0` (already the default) and configure the Windows firewall to allow inbound on :8081 from the coordinator's WireGuard IP.
## Starting the Tunnel
```bash
# Start immediately
sudo systemctl start wg-quick@wg0
# Enable on boot
sudo systemctl enable wg-quick@wg0
# Check status
sudo wg show
```
Expected output on each node:
```
interface: wg0
public key: <...>
private key: (hidden)
listening port: 51820
peer: <ultron_pubkey>
endpoint: 192.168.50.108:51820
allowed ips: 10.0.2.0/24
latest handshake: 5 seconds ago ← healthy
transfer: 1.2 KiB received, 3.4 KiB sent
```
If `latest handshake` is missing, check firewall rules (UDP :51820 must be open on all nodes).
## Verification
```bash
# From any node, ping another node's WireGuard IP
ping -c 3 10.0.2.1 # coordinator
ping -c 3 10.0.2.2 # jarvis
ping -c 3 10.0.2.3 # corsair
# Verify cAIc inference through the tunnel
curl http://10.0.2.2:8081/v1/models # jarvis llama-server
curl http://10.0.2.3:8081/v1/models # corsair llama-server
```
## Updating cAIc to Use the Tunnel
Once WireGuard is running, point each service at the tunnel IP instead of the LAN IP.
### Worker node agent config — `/etc/caic-node-agent.conf`
```ini
[agent]
node_name = jarvis
node_ip = 10.0.2.2 # was 192.168.50.210
node_type = worker
capabilities = llm
amqp_url = amqp://caic:password@10.0.2.1:5672/caic # was 192.168.50.108
llama_port = 8081
models_dir = /var/lib/caic/models
active_model = qwen2.5-7b-instruct-Q5_K_M.gguf
```
### Coordinator config — environment variables
```bash
# On the coordinator node, override the worker-facing addresses
# (LLAMA_SERVER_BASE stays as localhost / LAN IP since inference
# to the coordinator's own llama-server stays on-machine)
export CAIC_AMQP_URL="amqp://caic:password@10.0.2.1:5672/caic"
```
No other cAIc code changes are needed. The app already reads `CAIC_AMQP_URL` from the environment (`config.py:27`) and the node agent reads `node_ip` from its INI file. Inference requests routed to remote workers via `triage.py` use the IP the worker registered — so setting `node_ip = 10.0.2.2` in the worker's agent config is all it takes.
## Cross-Site Deployment Checklist
When placing a worker outside the LAN:
1. **Firewall:** Open UDP :51820 on the remote site. On the coordinator side, make sure UDP :51820 is reachable from the internet (port forward / firewall rule at the coordinator's router).
2. **DDNS:** If the coordinator's public IP is dynamic, set up a DDNS hostname and use it in the worker's `Endpoint = ultron.example.com:51820`.
3. **PersistentKeepalive:** Set `PersistentKeepalive = 25` on the worker side to keep NAT bindings alive.
4. **No double encryption:** WireGuard encrypts everything on the WireGuard interface. The cAIc app continues to use `http://` — it never touches raw TLS. This is correct and intended.
5. **Split tunnelling (optional):** The worker's `AllowedIPs = 10.0.2.0/24` ensures only cluster traffic goes through the tunnel. All other internet traffic from the worker uses its normal gateway.
+21 -74
View File
@@ -1,84 +1,31 @@
# JarvisChat Current WiP Backlog # cAIc Current WiP Backlog
Last updated: 2026-04-27 Last updated: 2026-07-27
Owner: Gramps + Copilot Owner: Gramps
Scope: issues, bugs, security exposures, and feature enhancements. Scope: Active roadmap items and backlog.
Total identified items: 27 ## In Progress
## Priority Definitions - **Image Generation Service** — Backend wired: cluster handlers, `POST /api/image/generate` proxy, node agent ComfyUI integration, hardware probe, 27 tests. ComfyUI install pending on jarvis (single-node).
- P0: Critical risk or data-loss/security exposure; do first.
- P1: High impact reliability/correctness work.
- P2: Important feature/UX improvements.
- P3: Nice-to-have polish.
## Top 10 (Urgency Order) ## Completed
1. [P0][DONE] Add authentication/authorization for all write and admin endpoints.
2. [P0][DONE] Add CSRF/origin protection for browser-initiated state-changing requests.
3. [P0][DONE] Block unsafe URL schemes in rendered search-result links (e.g., javascript:).
4. [P0][DONE] Add rate limiting and request body size limits for chat/search/profile APIs.
5. [P1][DONE] Restrict settings updates to an allowlist of valid keys.
6. [P1] Add pagination + hard caps on list endpoints (memories, conversations, message history).
7. [P1][DONE] Stop returning raw exception text to clients; use safe error envelopes.
8. [P1][DONE] Add automated tests for chat streaming, auto-search trigger, and memory command paths.
9. [P2][DONE] Implement skills/tool-call framework (MCP-style) with per-skill enable controls.
10. [P2] Implement heartbeat/check-in pipeline with scheduler + summary endpoint.
## Item 1 Executive Summary (Scope + Security) - **Single-node consolidation (2026-08-08)** — all cAIc services moved onto jarvis (192.168.50.212): llama-server, Qdrant, SearXNG, RabbitMQ, Ollama, ComfyUI. Config defaults (`COMFYUI_BASE`, AMQP URL, `NODE_NAME`) updated; cluster/AMQP layer left dormant (degrades gracefully). Project renamed `jarvisChat`**cAIc**.
- Status: Complete. Guest/admin capability split implemented with admin-only write enforcement, origin checks on state-changing requests, audit logging, and endpoint capability tests. - **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.
- **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.
- **v0.22.0+** — RAG bugfixes (collection name mismatch, `vectors_count``points_count`, unindexed `order_by` crash, embeds server URL). Topbar redesign (stats to bottom strip, toggles to ⋮ hamburger, palette next to version, mobile-responsive).
- Decision: JarvisChat is local-first by design. Primary mode is same-host Ollama; optional mode allows RFC1918 LAN endpoints only. ## Backlog
- Constraint: Public Internet AI endpoints are out of scope unless explicitly enabled in a future advanced mode.
- Risk: Even on LAN, unauthenticated write/admin endpoints permit unauthorized data tampering and deletion.
- Requirement: Add mandatory admin authentication for all POST/PUT/DELETE routes and destructive actions.
- Authentication shape (scope-locked): two capability tiers only: guest (chat-only) and admin (4-digit PIN unlock).
- Scope guardrail: Avoid full RBAC. Keep capability split minimal: conversational chat for guest, advanced/destructive actions for admin.
- Definition of done:
1. Auth required on all state-changing endpoints.
2. Destructive actions require admin authorization.
3. Endpoint configuration rejects non-local/non-RFC1918 AI backends by default.
4. Strong rate limiting + lockout controls in place for PIN attempts.
5. Security events logged for failed and successful admin actions.
## Full Backlog (Sorted by Priority) - B3 — Docker distribution (planning doc at `docker.md`, not yet implemented)
- HTTPS / reverse proxy (Caddy)
### P0 Critical - Conversation search/filter and export tooling
1. Add auth for write/admin endpoints (`POST/PUT/DELETE` routes, mass delete, profile/settings changes). - Keyboard shortcuts, retry button, source-link polish
2. Add CSRF or strict origin checks for browser session protection.
3. Validate/sanitize outbound href URLs before rendering in HTML (allow http/https only).
4. Add per-IP rate limiting on `/api/chat`, `/api/search`, `/api/profile`, `/api/settings`.
5. Enforce request size limits (message/profile text and JSON body) to prevent memory abuse.
### P1 High
6. Add settings key allowlist in `/api/settings` to prevent arbitrary key injection.
7. Add pagination (`limit`, `offset`) with enforced maximums for list APIs.
8. Add DB indexes and query hygiene for scalability (`messages.conversation_id`, timestamps).
9. Replace raw exception leakage to clients with generic safe error messages + server-side logs.
10. Add request/response timeout and retry policy consistency across external calls.
11. Add endpoint-level audit logging for destructive operations.
12. Add unit/integration tests for: remember/forget parsing, refusal detection, search fallback, SSE done/error shape.
13. Add conversation title sanitization and length constraints.
14. Ensure default preset semantics are correct (currently all seeded presets are marked default).
15. Add preflight validation for required model/preset selection and block send with clear user guidance instead of timing out.
### P2 Important Features
16. Skills system: load markdown skill files with YAML frontmatter from skills directory.
17. Skills registry API: list/enable/disable skills and expose active skills to UI.
18. Inject active skill instructions into system prompt with bounded token budget.
19. Tool execution guardrails: allowlist, confirmation mode, and execution logs.
20. Heartbeat scheduler (cron/systemd timer) for daily check-ins.
21. Heartbeat endpoint for generated briefings and anomaly summaries.
22. Model info UI panel (description, updated date, best-use purpose).
23. Default model selection improvements and persistence validation.
24. Hidden model list support (exclude models from dropdown).
25. Model update action from UI (trigger controlled model pull).
### P3 Nice to Have
26. Conversation search/filter and export tooling.
27. Keyboard shortcuts, retry button, and source-link polish.
## Maintenance Rules ## Maintenance Rules
- Keep this file as the single source of truth. - Keep this file as the single source of truth for roadmap tracking.
- Update item priority/status whenever work starts or completes. - Update as work starts or completes.
- Mirror the Top 10 summary in README and keep counts aligned.
+269
View File
@@ -0,0 +1,269 @@
"""
cAIc — Score-based RAG vector eviction with hysteresis.
"""
import asyncio
import logging
from datetime import datetime, timezone, timedelta
import httpx
from config import (
QDRANT_URL, RAG_COLLECTION,
RAG_MAX_VECTORS, RAG_EVICTION_HIGH_WATER, RAG_EVICTION_LOW_WATER,
RAG_EVICTION_BATCH, RAG_PINNED_SOURCES, RAG_GRACE_HOURS,
RAG_ACCESS_WEIGHT, RAG_AGE_WEIGHT,
)
log = logging.getLogger("caic")
eviction_lock = asyncio.Lock()
EVICTION_LOG: list[dict] = []
async def _update_retrieval_count(point_id: str, current_count: int = 0):
try:
async with httpx.AsyncClient() as client:
payload = {
"retrieval_count": current_count + 1,
"last_accessed": datetime.now(timezone.utc).isoformat(),
}
resp = await client.put(
f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points/payload",
json={"points": [point_id], "payload": payload},
timeout=5.0,
)
if resp.status_code not in (200, 201):
log.warning(f"Failed to increment retrieval count for {point_id}: {resp.status_code}")
except Exception as e:
log.warning(f"Error incrementing retrieval count for {point_id}: {e}")
async def get_collection_count() -> int:
try:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{QDRANT_URL}/collections/{RAG_COLLECTION}",
timeout=10.0,
)
if resp.status_code == 200:
info = resp.json().get("result", {})
return info.get("points_count", info.get("vectors_count", 0))
except Exception as e:
log.warning(f"get_collection_count error: {e}")
return 0
async def get_collection_stats() -> dict:
count = await get_collection_count()
high_water_pct = int(RAG_EVICTION_HIGH_WATER * 100)
low_water_pct = int(RAG_EVICTION_LOW_WATER * 100)
percent_full = round((count / RAG_MAX_VECTORS) * 100, 1) if RAG_MAX_VECTORS > 0 else 0
return {
"vector_count": count,
"max_vectors": RAG_MAX_VECTORS,
"high_water_mark": int(RAG_MAX_VECTORS * RAG_EVICTION_HIGH_WATER),
"low_water_mark": int(RAG_MAX_VECTORS * RAG_EVICTION_LOW_WATER),
"high_water_pct": high_water_pct,
"low_water_pct": low_water_pct,
"percent_full": percent_full,
"pinned_sources": list(RAG_PINNED_SOURCES),
}
async def evict_batch(batch_size: int) -> int:
filter_conditions = {
"must_not": [
{"match": {"key": "source", "value": src}}
for src in RAG_PINNED_SOURCES
]
}
try:
async with httpx.AsyncClient() as client:
scroll_resp = await client.post(
f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points/scroll",
json={
"filter": filter_conditions,
"limit": min(batch_size * 10, 10000),
"with_payload": True,
"with_vector": False,
},
timeout=30.0,
)
if scroll_resp.status_code != 200:
log.warning(f"Eviction scroll failed: {scroll_resp.status_code}")
return 0
points = scroll_resp.json().get("result", {}).get("points", [])
if not points:
return 0
now = datetime.now(timezone.utc)
scored = []
for p in points:
payload = p.get("payload", {})
date_str = payload.get("ingest_date") or payload.get("upload_date", "")
if date_str:
age_hours = (now - datetime.fromisoformat(date_str)).total_seconds() / 3600
else:
age_hours = 999999
if age_hours < RAG_GRACE_HOURS:
continue
retrieval_count = payload.get("retrieval_count", 0) or 0
score = retrieval_count * RAG_ACCESS_WEIGHT + age_hours * RAG_AGE_WEIGHT
last_accessed = payload.get("last_accessed", date_str)
scored.append((score, last_accessed, p["id"]))
if not scored:
log.warning("No evictable vectors found (all pinned or newborn)")
return 0
scored.sort(key=lambda x: (x[0], x[1]))
to_delete = [p[2] for p in scored[:batch_size]]
if not to_delete:
return 0
delete_resp = await client.post(
f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points/delete",
json={"points": to_delete},
timeout=30.0,
)
if delete_resp.status_code not in (200, 201):
log.warning(f"Eviction delete failed: {delete_resp.status_code}")
return 0
return len(to_delete)
except Exception as e:
log.warning(f"evict_batch error: {e}")
return 0
async def maybe_evict() -> int:
if RAG_MAX_VECTORS <= 0:
return 0
effective_batch = max(RAG_EVICTION_BATCH, 1)
async with eviction_lock:
count = await get_collection_count()
threshold_high = int(RAG_MAX_VECTORS * RAG_EVICTION_HIGH_WATER)
threshold_low = int(RAG_MAX_VECTORS * RAG_EVICTION_LOW_WATER)
if count < threshold_high:
return 0
total_evicted = 0
while count >= threshold_low:
if total_evicted > 0 and count < threshold_low:
break
deleted = await evict_batch(effective_batch)
if deleted == 0:
break
total_evicted += deleted
count -= deleted
if count < threshold_high and total_evicted > 0:
break
if count < threshold_low:
break
if total_evicted > 0:
entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"count": total_evicted,
"remaining": count,
}
EVICTION_LOG.append(entry)
if len(EVICTION_LOG) > 1000:
EVICTION_LOG.pop(0)
log.info(f"Evicted {total_evicted} vectors ({count} remaining)")
return total_evicted
async def get_rag_operational_stats() -> dict:
stats = await get_collection_stats()
now = datetime.now(timezone.utc)
cutoff_1m = now - timedelta(minutes=1)
cutoff_5m = now - timedelta(minutes=5)
cutoff_30m = now - timedelta(minutes=30)
eviction_1m = sum(
e["count"] for e in EVICTION_LOG
if datetime.fromisoformat(e["timestamp"]) > cutoff_1m
)
eviction_5m = sum(
e["count"] for e in EVICTION_LOG
if datetime.fromisoformat(e["timestamp"]) > cutoff_5m
)
eviction_30m = sum(
e["count"] for e in EVICTION_LOG
if datetime.fromisoformat(e["timestamp"]) > cutoff_30m
)
pinned_count = 0
avg_retrieval_count = 0.0
at_risk_count = 0
try:
async with httpx.AsyncClient() as client:
pinned_filter = {
"should": [
{"match": {"key": "source", "value": src}}
for src in RAG_PINNED_SOURCES
]
}
pinned_resp = await client.post(
f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points/scroll",
json={"filter": pinned_filter, "limit": 10000, "with_payload": True, "with_vector": False},
timeout=10.0,
)
if pinned_resp.status_code == 200:
pinned_count = len(pinned_resp.json().get("result", {}).get("points", []))
nonpinned_filter = {
"must_not": [
{"match": {"key": "source", "value": src}}
for src in RAG_PINNED_SOURCES
]
}
np_resp = await client.post(
f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points/scroll",
json={"filter": nonpinned_filter, "limit": 10000, "with_payload": True, "with_vector": False},
timeout=10.0,
)
if np_resp.status_code == 200:
points = np_resp.json().get("result", {}).get("points", [])
if points:
retrievals = []
scored = []
for p in points:
payload = p.get("payload", {})
rc = payload.get("retrieval_count", 0) or 0
retrievals.append(rc)
date_str = payload.get("ingest_date") or payload.get("upload_date", "")
if date_str:
age_hours = (now - datetime.fromisoformat(date_str)).total_seconds() / 3600
else:
age_hours = 999999
score = rc * RAG_ACCESS_WEIGHT + age_hours * RAG_AGE_WEIGHT
last_accessed = payload.get("last_accessed", date_str)
scored.append((score, last_accessed))
avg_retrieval_count = round(sum(retrievals) / len(retrievals), 2)
scored.sort(key=lambda x: (x[0], x[1]))
at_risk_threshold = max(1, len(scored) // 10)
at_risk_count = at_risk_threshold
except Exception as e:
log.warning(f"RAG operational stats scroll error: {e}")
stats.update({
"grace_hours": RAG_GRACE_HOURS,
"eviction_counts_last_1m": eviction_1m,
"eviction_counts_last_5m": eviction_5m,
"eviction_counts_last_30m": eviction_30m,
"pinned_count": pinned_count,
"avg_retrieval_count": avg_retrieval_count,
"at_risk_count": at_risk_count,
})
return stats
+51 -4
View File
@@ -1,14 +1,50 @@
""" """
JarvisChat - AMD GPU stats via rocm-smi. cAIc - GPU stats: rocm-smi (AMD/Linux), system_profiler (Apple Silicon/macOS).
""" """
import json import json
import logging import logging
import platform
import re
import subprocess import subprocess
import sys
log = logging.getLogger("jarvischat") log = logging.getLogger("caic")
def get_gpu_stats() -> dict: def _parse_darwin_gpu_stats() -> dict:
try:
result = subprocess.run(
["system_profiler", "SPDisplaysDataType"],
capture_output=True, text=True, timeout=10,
)
if result.returncode != 0:
return {}
text = result.stdout
gpu_model = ""
vram_mb = 0
for line in text.splitlines():
m = re.match(r"\s+Chipset Model:\s+(.+)", line)
if m:
gpu_model = m.group(1).strip()
m = re.match(r"\s+VRAM \(Dynamic, Max\):\s+(\d+)\s+GB", line)
if m:
vram_mb = int(m.group(1)) * 1024
if gpu_model:
return {
"gpu_percent": 0,
"vram_percent": 0,
"available": True,
"gpu_model": gpu_model,
"vram_total_mb": vram_mb,
}
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
except Exception as e:
log.warning("Darwin GPU stats error: %s", e)
return {}
def _parse_linux_gpu_stats() -> dict:
try: try:
result = subprocess.run( result = subprocess.run(
["rocm-smi", "--showuse", "--showmemuse", "--json"], ["rocm-smi", "--showuse", "--showmemuse", "--json"],
@@ -27,5 +63,16 @@ def get_gpu_stats() -> dict:
except (subprocess.TimeoutExpired, FileNotFoundError, json.JSONDecodeError): except (subprocess.TimeoutExpired, FileNotFoundError, json.JSONDecodeError):
pass pass
except Exception as e: except Exception as e:
log.warning(f"GPU stats error: {e}") log.warning("Linux GPU stats error: %s", e)
return {}
def get_gpu_stats() -> dict:
if sys.platform == "darwin":
stats = _parse_darwin_gpu_stats()
if stats:
return stats
stats = _parse_linux_gpu_stats()
if stats:
return stats
return {"gpu_percent": 0, "vram_percent": 0, "available": False} return {"gpu_percent": 0, "vram_percent": 0, "available": False}
+149
View File
@@ -0,0 +1,149 @@
"""
cAIc — Startup hardware self-assessment.
"""
import asyncio
import json
import logging
import re
import subprocess
import sys
from pathlib import Path
import httpx
import psutil
from config import LLAMA_SERVER_BASE, SEARXNG_BASE, QDRANT_URL, HW_STATE_PATH, COMFYUI_BASE
log = logging.getLogger("caic")
HARDWARE_STATE_PATH = Path(HW_STATE_PATH)
_TIMEOUT_EXPIRED = subprocess.TimeoutExpired
def _get_vram_darwin() -> tuple[int, int]:
try:
result = subprocess.run(
["system_profiler", "SPDisplaysDataType"],
capture_output=True, text=True, timeout=10,
)
if result.returncode != 0:
return 0, 0
vram_total_mb = 0
for line in result.stdout.splitlines():
m = re.match(r"\s+VRAM \(Dynamic, Max\):\s+(\d+)\s+GB", line)
if m:
vram_total_mb += int(m.group(1)) * 1024
return vram_total_mb, vram_total_mb # free ~ total (unified memory)
except (FileNotFoundError, _TIMEOUT_EXPIRED):
log.warning("system_profiler not available — VRAM stats set to 0")
except Exception as e:
log.warning(f"Darwin VRAM error: {e}")
return 0, 0
def _get_vram_linux() -> tuple[int, int]:
vram_total_mb = 0
vram_free_mb = 0
try:
result = subprocess.run(
["rocm-smi", "--showmeminfo", "vram", "--json"],
capture_output=True, text=True, timeout=5,
)
if result.returncode == 0:
data = json.loads(result.stdout)
for card, info in data.items():
tot = info.get("VRAM Total (MB)", 0)
free = info.get("VRAM Free (MB)", None)
used = info.get("VRAM Used (MB)", 0)
if tot:
vram_total_mb += int(tot)
if free is not None:
vram_free_mb += int(free)
else:
vram_free_mb += int(tot) - int(used)
except (FileNotFoundError, _TIMEOUT_EXPIRED, json.JSONDecodeError):
log.warning("rocm-smi not available or failed — VRAM stats set to 0")
except Exception as e:
log.warning(f"rocm-smi error: {e}")
return vram_total_mb, vram_free_mb
async def assess_hardware() -> dict:
mem = psutil.virtual_memory()
ram_total_gb = round(mem.total / (1024 ** 3), 1)
ram_available_gb = round(mem.available / (1024 ** 3), 1)
cpu_count = psutil.cpu_count()
if sys.platform == "darwin":
vram_total_mb, vram_free_mb = _get_vram_darwin()
else:
vram_total_mb, vram_free_mb = _get_vram_linux()
llama_reachable = False
llama_models = []
try:
async with httpx.AsyncClient(timeout=3) as client:
resp = await client.get(f"{LLAMA_SERVER_BASE}/v1/models")
if resp.status_code == 200:
llama_reachable = True
data = resp.json()
llama_models = [m.get("id", "") for m in data.get("data", [])]
except Exception:
log.warning("llama-server not reachable")
qdrant_reachable = False
qdrant_collections = []
try:
async with httpx.AsyncClient(timeout=3) as client:
resp = await client.get(f"{QDRANT_URL}/collections")
if resp.status_code == 200:
qdrant_reachable = True
data = resp.json()
raw = data.get("result", {}).get("collections", [])
qdrant_collections = [c.get("name", "") for c in raw]
except Exception:
log.warning("Qdrant not reachable")
searxng_reachable = False
try:
async with httpx.AsyncClient(timeout=3) as client:
resp = await client.get(SEARXNG_BASE)
if resp.status_code == 200:
searxng_reachable = True
except Exception:
log.warning("SearXNG not reachable")
comfyui_reachable = False
comfyui_models = []
try:
async with httpx.AsyncClient(timeout=5) as client:
resp = await client.get(f"{COMFYUI_BASE}/object_info/CheckpointLoaderSimple")
if resp.status_code == 200:
comfyui_reachable = True
data = resp.json()
ckpt_info = data.get("CheckpointLoaderSimple", {}).get("input", {}).get("required", {})
ckpt_list = ckpt_info.get("ckpt_name", [[]])[0]
comfyui_models = ckpt_list if isinstance(ckpt_list, list) else []
except Exception:
log.warning("ComfyUI not reachable")
state = {
"ram_total_gb": ram_total_gb,
"ram_available_gb": ram_available_gb,
"cpu_count": cpu_count,
"vram_total_mb": vram_total_mb,
"vram_free_mb": vram_free_mb,
"llama_reachable": llama_reachable,
"llama_models": llama_models,
"qdrant_reachable": qdrant_reachable,
"qdrant_collections": qdrant_collections,
"searxng_reachable": searxng_reachable,
"comfyui_reachable": comfyui_reachable,
"comfyui_models": comfyui_models,
}
HARDWARE_STATE_PATH.write_text(json.dumps(state, indent=2))
log.info(
f"HW: {ram_total_gb}GB RAM, {vram_total_mb}MB VRAM, "
f"llama={llama_reachable}, qdrant={qdrant_reachable}, searxng={searxng_reachable}, comfyui={comfyui_reachable}"
)
return state
File diff suppressed because it is too large Load Diff
+114 -30
View File
@@ -1,5 +1,5 @@
""" """
JarvisChat - FTS5 memory system. cAIc - FTS5 memory system.
CRUD, search, remember/forget command processing, topic detection. CRUD, search, remember/forget command processing, topic detection.
""" """
import logging import logging
@@ -7,10 +7,11 @@ import re
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
from crypto import encrypt_text, decrypt_text
from db import get_db from db import get_db
from config import MAX_MEMORY_FACT_CHARS from config import MAX_MEMORY_FACT_CHARS
log = logging.getLogger("jarvischat") log = logging.getLogger("caic")
REMEMBER_PATTERNS = [ REMEMBER_PATTERNS = [
(r"remember that (.+)", "explicit"), (r"remember that (.+)", "explicit"),
@@ -27,6 +28,99 @@ FORGET_PATTERNS = [
] ]
AUTO_FACT_PATTERNS = [
re.compile(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b"),
re.compile(r"\b(?:systemd|nginx|docker|ssh|ufw|iptables|postgres(?:ql)?|redis|mosquitto|node_exporter|prometheus|grafana|qdrant|rabbitmq|searxng|llama-server)\b", re.IGNORECASE),
re.compile(r"/(?:etc|home|usr|var|opt|tmp|mnt)/\S+"),
re.compile(r"\b(?:Ryzen|RX\s*\d{4}|RTX\s*\d{4}|Radeon|AMD|NVIDIA|Core\s*i[579]|Threadripper)\b", re.IGNORECASE),
re.compile(r"\b(?:Qwen|Llama|Gemma|Phi|Mistral|DeepSeek)\S*\b", re.IGNORECASE),
re.compile(r"\b(?:systemd\.service|docker\s+(?:compose|container|service)|systemctl|journalctl)\b", re.IGNORECASE),
]
SOCIAL_TRIGGERS = {"hi", "hello", "hey", "yo", "sup", "howdy", "good morning", "good evening"}
# Short filler words that shouldn't count as subject overlap between facts.
_STOPWORDS = {
"with", "that", "have", "this", "from", "they", "what", "when", "where",
"which", "there", "your", "will", "would", "about", "these", "their",
"been", "into", "than", "then", "them", "were", "being", "more", "most",
"some", "other", "only", "still", "also", "after", "before", "during",
"because", "through", "without",
}
def _subject_words(text: str) -> set:
"""Meaningful subject tokens for overlap comparison."""
words = re.findall(r"[A-Za-z0-9_]{4,}", text.lower())
return {w for w in words if w not in _STOPWORDS}
def _is_social(text: str) -> bool:
t = text.strip().lower()
if t in SOCIAL_TRIGGERS or any(t.startswith(w) for w in ("thanks", "thank you", "ty")):
return True
return False
def auto_detect_facts(user_message: str, assistant_message: str) -> list[str]:
"""Extract environmental/factual content from a chat turn.
Returns a list of fact strings ready for storage. Empty list means
nothing worth persisting.
"""
if _is_social(user_message):
return []
if len(assistant_message) < 40:
return []
if process_remember_command(user_message) is not None:
return []
found = []
for pat in AUTO_FACT_PATTERNS:
if pat.search(user_message):
found.append(user_message)
break
# Also capture when the user is reporting a change they made
change_match = re.search(
r"(?:I\s+)?(?:set|changed?|updated|installed|configured|enabled|disabled|added|removed|created|deleted|restarted|reloaded|switched|moved|copied|renamed|symlinked|mounted|unmounted)\s+(?:the\s+)?(.+)",
user_message, re.IGNORECASE,
)
if change_match and user_message not in found:
found.append(user_message)
seen = set()
deduped = []
for f in found:
key = f.strip().lower()
if key not in seen:
seen.add(key)
deduped.append(f.strip()[:MAX_MEMORY_FACT_CHARS])
return deduped
def check_fact_conflicts(facts: list[str]) -> list[dict]:
"""Search for existing memories that conflict with detected facts.
A conflict is reported only when the existing memory is about the same
subject (meaningful keyword overlap) but states something different —
unrelated hits that merely share an FTS keyword are not conflicts.
Returns list of {memory_id, old_fact, new_fact} for each conflict.
"""
conflicts = []
for new_fact in facts:
related = search_memories(new_fact, limit=1)
if related:
old = related[0]["fact"]
if old.rstrip(".") != new_fact.rstrip(".") and (_subject_words(new_fact) & _subject_words(old)):
conflicts.append({
"memory_id": related[0]["rowid"],
"old_fact": old,
"new_fact": new_fact,
})
return conflicts
def detect_topic(fact: str) -> str: def detect_topic(fact: str) -> str:
fact_lower = fact.lower() fact_lower = fact.lower()
if any(w in fact_lower for w in ["prefer", "like", "hate", "always", "never", "favorite"]): if any(w in fact_lower for w in ["prefer", "like", "hate", "always", "never", "favorite"]):
@@ -45,7 +139,7 @@ def add_memory(fact: str, topic: str = "general", source: str = "explicit") -> O
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
cur = db.execute( cur = db.execute(
"INSERT INTO memories (fact, topic, source, created_at) VALUES (?, ?, ?, ?)", "INSERT INTO memories (fact, topic, source, created_at) VALUES (?, ?, ?, ?)",
(fact, topic, source, now), (encrypt_text(fact), topic, source, now),
) )
db.commit() db.commit()
rowid = cur.lastrowid rowid = cur.lastrowid
@@ -57,31 +151,16 @@ def add_memory(fact: str, topic: str = "general", source: str = "explicit") -> O
def search_memories(query: str, limit: int = 5) -> list: def search_memories(query: str, limit: int = 5) -> list:
if not query.strip(): if not query.strip():
return [] return []
db = get_db() all_mems = get_all_memories()
words = re.findall(r"[A-Za-z0-9_]+", query) words = set(re.findall(r"[A-Za-z0-9_]+", query.lower()))
if not words: scored = []
db.close() for m in all_mems:
return [] fact_lower = m["fact"].lower()
escaped = [] score = sum(1 for w in words if w in fact_lower)
for word in words[:10]: if score > 0:
if word.upper() in {"AND", "OR", "NOT", "NEAR"}: scored.append((score, m))
escaped.append(f'"{word}"*') scored.sort(key=lambda x: -x[0])
else: return [m for _, m in scored[:limit]]
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
def get_all_memories(topic: Optional[str] = None) -> list: def get_all_memories(topic: Optional[str] = None) -> list:
@@ -93,7 +172,12 @@ def get_all_memories(topic: Optional[str] = None) -> list:
else: else:
rows = db.execute("SELECT rowid, * FROM memories ORDER BY created_at DESC").fetchall() rows = db.execute("SELECT rowid, * FROM memories ORDER BY created_at DESC").fetchall()
db.close() 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: def delete_memory(rowid: int) -> bool:
@@ -109,7 +193,7 @@ def delete_memory(rowid: int) -> bool:
def update_memory(rowid: int, fact: str) -> bool: def update_memory(rowid: int, fact: str) -> bool:
db = get_db() 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() db.commit()
updated = cur.rowcount > 0 updated = cur.rowcount > 0
db.close() db.close()
+80
View File
@@ -0,0 +1,80 @@
"""
cAIc — Model pull/download helper.
Uses Ollama's pull API to download models that aren't available on the
inference server. Runs synchronously during startup.
"""
import asyncio
import json
import logging
import httpx
from config import DEFAULT_MODEL, LLAMA_SERVER_BASE, OLLAMA_BASE
log = logging.getLogger("caic")
async def _model_available_on_llama(model: str) -> bool:
try:
async with httpx.AsyncClient(timeout=5) as client:
resp = await client.get(f"{LLAMA_SERVER_BASE}/v1/models")
if resp.status_code == 200:
models = resp.json().get("data", [])
return any(m.get("id") == model for m in models)
except Exception:
pass
return False
async def _model_available_on_ollama(model: str) -> bool:
try:
async with httpx.AsyncClient(timeout=5) as client:
resp = await client.post(f"{OLLAMA_BASE}/api/show", json={"name": model})
return resp.status_code == 200
except Exception:
pass
return False
async def _pull_via_ollama(model: str) -> bool:
try:
async with httpx.AsyncClient(timeout=300) as client:
async with client.stream("POST", f"{OLLAMA_BASE}/api/pull", json={"name": model}) as resp:
if resp.status_code != 200:
log.warning("ollama pull returned %s for %s", resp.status_code, model)
return False
async for line in resp.aiter_lines():
if line.strip():
try:
data = json.loads(line)
status = data.get("status", "")
if status:
log.info("ollama pull %s: %s", model, status)
except json.JSONDecodeError:
pass
return True
except httpx.ConnectError:
log.warning("ollama not reachable at %s — cannot pull %s", OLLAMA_BASE, model)
except Exception as e:
log.warning("ollama pull error for %s: %s", model, e)
return False
async def ensure_model(model: str = "") -> bool:
"""Ensure *model* is available for inference. Pull via Ollama if needed."""
model = model or DEFAULT_MODEL
if await _model_available_on_llama(model):
log.info("model %s already available on llama-server", model)
return True
log.info("model %s not found on llama-server, checking Ollama", model)
if await _model_available_on_ollama(model):
log.info("model %s found on Ollama (available for embeddings)", model)
return True
log.info("model %s not found on Ollama either — pulling", model)
ok = await _pull_via_ollama(model)
if ok:
log.info("model %s pulled successfully", model)
else:
log.warning("model %s could not be pulled", model)
return ok
+9
View File
@@ -0,0 +1,9 @@
# Models
Place .gguf model files in this directory.
The filename must match the LLAMA_MODEL value in .env.
For example, if LLAMA_MODEL=llama3.1-8b-instruct.Q4_K_M.gguf,
place that file here.
Download from HuggingFace:
https://huggingface.co/models?search=gguf
+600
View File
@@ -0,0 +1,600 @@
"""
cAIc — Worker node agent.
Standalone AMQP client that registers with the cAIc coordinator,
responds to pings, and handles model swap commands.
## Config file: /etc/caic-node-agent.conf
```ini
[agent]
# hostname — defaults to socket.gethostname()
node_name = jarvis
# LAN IP — defaults from socket
node_ip = 192.168.50.212
# "worker" (fixed)
node_type = worker
# comma-separated capability list
capabilities = llm
# RabbitMQ URL on coordinator
amqp_url = amqp://caic:password@localhost:5672/caic
# port llama-server listens on
llama_port = 8081
# path to GGUF model files
models_dir = /var/lib/caic/models
# currently active model filename
active_model = llama3.1-latest-Q4_K_M.gguf
```
## systemd unit: /etc/systemd/system/caic-node-agent.service
```ini
[Unit]
Description=cAIc Worker Node Agent
After=network.target rabbitmq.service
Wants=rabbitmq.service
[Service]
Type=simple
User=root
Group=root
WorkingDirectory=/opt/caic
ExecStart=/usr/bin/python3 /opt/caic/node_agent/agent.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
"""
import asyncio
import json
import logging
import os
import re
import socket
import subprocess
import sys
import time
from configparser import ConfigParser
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
try:
import psutil
HAS_PSUTIL = True
except ImportError:
HAS_PSUTIL = False
try:
import aio_pika
from aio_pika import DeliveryMode, ExchangeType
HAS_AIO_PIKA = True
except ImportError:
HAS_AIO_PIKA = False
try:
import httpx
HAS_HTTPX = True
except ImportError:
HAS_HTTPX = False
log = logging.getLogger("caic")
CONFIG_PATH = "/etc/caic-node-agent.conf"
# ── data types ──────────────────────────────────────────────────────────
class AgentConfig:
def __init__(self):
self.node_name: str = socket.gethostname()
self.node_ip: str = "127.0.0.1"
self.node_type: str = "worker"
self.capabilities: list[str] = ["llm"]
self.amqp_url: str = "amqp://caic:password@localhost:5672/caic"
self.llama_port: int = 8081
self.comfyui_port: int = 8188
self.models_dir: str = "/var/lib/caic/models"
self.active_model: str = ""
@classmethod
def from_ini(cls, path: str = CONFIG_PATH) -> "AgentConfig":
cfg = cls()
parser = ConfigParser()
if not os.path.exists(path):
log.warning("config %s not found, using defaults", path)
return cfg
parser.read(path)
sec = "agent"
if parser.has_section(sec):
cfg.node_name = parser.get(sec, "node_name", fallback=cfg.node_name)
cfg.node_ip = parser.get(sec, "node_ip", fallback=cfg.node_ip)
cfg.node_type = parser.get(sec, "node_type", fallback=cfg.node_type)
raw_caps = parser.get(sec, "capabilities", fallback="llm")
cfg.capabilities = [c.strip() for c in raw_caps.split(",") if c.strip()]
cfg.amqp_url = parser.get(sec, "amqp_url", fallback=cfg.amqp_url)
cfg.llama_port = parser.getint(sec, "llama_port", fallback=cfg.llama_port)
cfg.comfyui_port = parser.getint(sec, "comfyui_port", fallback=cfg.comfyui_port)
cfg.models_dir = parser.get(sec, "models_dir", fallback=cfg.models_dir)
cfg.active_model = parser.get(sec, "active_model", fallback=cfg.active_model)
return cfg
class ModelInfo:
def __init__(self, filename: str, name: str = "", version: str = "", quant: str = ""):
self.filename = filename
self.name = name
self.version = version
self.quant = quant
self.path = ""
def to_dict(self) -> dict:
return {
"name": self.name,
"version": self.version,
"quant": self.quant,
"filename": self.filename,
}
# ── model discovery ─────────────────────────────────────────────────────
_MODEL_PATTERN = None # lazy compile
def discover_models(models_dir: str) -> list[dict]:
import re
global _MODEL_PATTERN
if _MODEL_PATTERN is None:
_MODEL_PATTERN = re.compile(
r"^(?P<name>.+?)-(?P<version>[^-]+)-(?P<quant>Q\d+_K_[A-Z]+|IQ\d_[A-Z]+|fp\d+)\.gguf$"
)
root = Path(models_dir)
if not root.is_dir():
log.warning("models_dir %s not found", models_dir)
return []
results = []
for fpath in sorted(root.glob("*.gguf")):
m = _MODEL_PATTERN.match(fpath.name)
if m:
info = ModelInfo(
filename=fpath.name,
name=m.group("name"),
version=m.group("version"),
quant=m.group("quant"),
)
info.path = str(fpath)
results.append(info.to_dict())
else:
log.debug("skipping unrecognized model filename: %s", fpath.name)
return results
# ── load reporting ──────────────────────────────────────────────────────
def get_load() -> dict:
load = {}
if HAS_PSUTIL:
load["cpu_pct"] = round(psutil.cpu_percent(interval=0.5))
load["ram_pct"] = round(psutil.virtual_memory().percent)
try:
result = subprocess.run(
["rocm-smi", "--showmeminfo", "vram"],
capture_output=True, text=True, timeout=3,
)
if result.returncode == 0:
total = 0
used = 0
for line in result.stdout.splitlines():
if "VRAM Total Used Memory (B)" in line:
parts = line.split(":")
if len(parts) >= 2:
used = int(parts[-1].strip())
elif "VRAM Total Memory (B)" in line:
parts = line.split(":")
if len(parts) >= 2:
total = int(parts[-1].strip())
if total > 0:
load["vram_pct"] = round(used / total * 100)
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
# Darwin / Apple Silicon
if sys.platform == "darwin" and "vram_pct" not in load:
try:
result = subprocess.run(
["system_profiler", "SPDisplaysDataType"],
capture_output=True, text=True, timeout=5,
)
if result.returncode == 0:
for line in result.stdout.splitlines():
m = re.match(r"\s+VRAM \(Dynamic, Max\):\s+(\d+)\s+GB", line)
if m:
load["vram_pct"] = 50 # unified memory — best guess
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
return load
def detect_capabilities(cfg: AgentConfig) -> list[str]:
caps = list(cfg.capabilities)
if "image_gen" not in caps and HAS_HTTPX:
try:
resp = httpx.get(f"http://localhost:{cfg.comfyui_port}/system_stats", timeout=3)
if resp.status_code == 200:
caps.append("image_gen")
log.info("auto-detected image_gen capability (ComfyUI on port %d)", cfg.comfyui_port)
except Exception:
pass
return caps
# ── AMQP helpers ────────────────────────────────────────────────────────
async def declare_exchanges(channel) -> tuple:
admin = await channel.declare_exchange("jc.admin", ExchangeType.TOPIC, durable=True)
system = await channel.declare_exchange("jc.system", ExchangeType.TOPIC, durable=True)
return admin, system
async def publish(channel, exchange, routing_key, payload):
body = json.dumps(payload).encode()
msg = aio_pika.Message(body, delivery_mode=DeliveryMode.PERSISTENT)
await exchange.publish(msg, routing_key)
# ── registration ────────────────────────────────────────────────────────
def build_registration_payload(cfg: AgentConfig, inventory: list[dict]) -> dict:
model_dict = None
if cfg.active_model:
for inv in inventory:
if inv["filename"] == cfg.active_model:
model_dict = {**inv, "port": cfg.llama_port}
break
return {
"node_name": cfg.node_name,
"node_type": cfg.node_type,
"ip": cfg.node_ip,
"capabilities": cfg.capabilities,
"active_model": model_dict,
"inventory": inventory,
}
# ── ping / pong ─────────────────────────────────────────────────────────
async def handle_ping(cfg: AgentConfig, channel, exchange, msg: aio_pika.IncomingMessage):
async with msg.process():
try:
payload = json.loads(msg.body.decode())
except json.JSONDecodeError:
return
correlation_id = payload.get("correlation_id")
if not correlation_id:
return
load = get_load()
now = datetime.now(timezone.utc).isoformat() + "Z"
pong = {
"node_name": cfg.node_name,
"type": "pong",
"correlation_id": correlation_id,
"status": "active",
"active_model": None, # simplified; could read current
"load": load,
"timestamp": now,
}
await publish(channel, exchange, f"node.{cfg.node_name}.pong", pong)
# ── model swap ──────────────────────────────────────────────────────────
async def handle_swap_model(cfg: AgentConfig, channel, exchanges, msg: aio_pika.IncomingMessage):
admin_ex, system_ex = exchanges
async with msg.process():
try:
payload = json.loads(msg.body.decode())
except json.JSONDecodeError:
return
model_filename = payload.get("model_filename")
if not model_filename:
log.error("swap_model missing model_filename")
return
log.info("swapping model to %s", model_filename)
# 1. Stop current llama-server
log.info("stopping llama-server")
subprocess.run(["systemctl", "stop", "llama-server"], check=False)
# 2. Update config
_update_config_active_model(cfg, model_filename)
# 3. Start llama-server
log.info("starting llama-server")
subprocess.run(["systemctl", "start", "llama-server"], check=False)
# 4. Poll health endpoint
healthy = await _wait_for_llama(cfg.llama_port, timeout=120, interval=2)
now = datetime.now(timezone.utc).isoformat() + "Z"
if healthy:
result_payload = {
"node_name": cfg.node_name,
"type": "model_ready",
"active_model": model_filename,
"port": cfg.llama_port,
"timestamp": now,
}
log.info("model swap successful: %s", model_filename)
else:
result_payload = {
"node_name": cfg.node_name,
"type": "model_failed",
"active_model": model_filename,
"port": cfg.llama_port,
"error": "llama-server did not become healthy within 120s",
"timestamp": now,
}
log.error("model swap failed: %s", model_filename)
await publish(channel, system_ex, f"node.{cfg.node_name}.{result_payload['type']}", result_payload)
def _update_config_active_model(cfg: AgentConfig, model_filename: str):
parser = ConfigParser()
if os.path.exists(CONFIG_PATH):
parser.read(CONFIG_PATH)
if not parser.has_section("agent"):
parser.add_section("agent")
parser.set("agent", "active_model", model_filename)
with open(CONFIG_PATH, "w") as f:
parser.write(f)
cfg.active_model = model_filename
async def _wait_for_llama(port: int, timeout: int = 120, interval: int = 2) -> bool:
if not HAS_HTTPX:
log.warning("httpx not installed, skipping health check")
return True
deadline = time.time() + timeout
url = f"http://localhost:{port}/v1/models"
async with httpx.AsyncClient() as client:
while time.time() < deadline:
try:
resp = await client.get(url, timeout=5)
if resp.status_code == 200:
return True
except (httpx.ConnectError, httpx.TimeoutException):
pass
await asyncio.sleep(interval)
return False
# ── image generation ─────────────────────────────────────────────────────
async def handle_image_generate(cfg: AgentConfig, channel, exchanges, msg: aio_pika.IncomingMessage):
admin_ex, system_ex = exchanges
async with msg.process():
try:
payload = json.loads(msg.body.decode())
except json.JSONDecodeError:
return
request_id = payload.get("request_id")
prompt = payload.get("prompt", "")
negative_prompt = payload.get("negative_prompt", "")
width = payload.get("width", 1024)
height = payload.get("height", 1024)
steps = payload.get("steps", 20)
seed = payload.get("seed", -1)
model = payload.get("model", "")
if not prompt:
log.error("image_generate missing prompt")
return
log.info("image generate: prompt=%s %dx%d steps=%d", prompt[:60], width, height, steps)
now = datetime.now(timezone.utc).isoformat() + "Z"
try:
image_data = await _comfyui_generate(
cfg, prompt, negative_prompt, width, height, steps, seed, model,
)
result_payload = {
"node_name": cfg.node_name,
"type": "image_generated",
"request_id": request_id,
"image_base64": image_data,
"timestamp": now,
}
log.info("image generate complete: request_id=%s", request_id)
except Exception as e:
result_payload = {
"node_name": cfg.node_name,
"type": "image_failed",
"request_id": request_id,
"error": str(e),
"timestamp": now,
}
log.error("image generate failed: %s", e)
await publish(channel, system_ex, f"node.{cfg.node_name}.{result_payload['type']}", result_payload)
async def _comfyui_generate(
cfg: AgentConfig, prompt: str, negative_prompt: str,
width: int, height: int, steps: int, seed: int, model: str,
) -> str:
import random
import uuid as _uuid
if not HAS_HTTPX:
raise RuntimeError("httpx not installed")
client_id = str(_uuid.uuid4())
if seed < 0:
seed = random.randint(0, 2**32 - 1)
checkpoint = model or "model.safetensors"
workflow = {
"3": {
"class_type": "KSampler",
"inputs": {
"seed": seed,
"steps": steps,
"cfg": 7.0,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 1.0,
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["5", 0],
},
},
"4": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": checkpoint},
},
"5": {
"class_type": "EmptyLatentImage",
"inputs": {"width": width, "height": height, "batch_size": 1},
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": prompt, "clip": ["4", 1]},
},
"7": {
"class_type": "CLIPTextEncode",
"inputs": {"text": negative_prompt or "blurry, low quality", "clip": ["4", 1]},
},
"8": {
"class_type": "VAEDecode",
"inputs": {"samples": ["3", 0], "vae": ["4", 2]},
},
"9": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": f"caic_{client_id}", "images": ["8", 0]},
},
}
async with httpx.AsyncClient(timeout=120) as client:
resp = await client.post(
f"http://localhost:{cfg.comfyui_port}/prompt",
json={"prompt": workflow, "client_id": client_id},
)
if resp.status_code != 200:
raise RuntimeError(f"ComfyUI prompt failed: {resp.status_code} {resp.text}")
prompt_id = resp.json().get("prompt_id")
if not prompt_id:
raise RuntimeError("ComfyUI returned no prompt_id")
deadline = time.time() + 120
while time.time() < deadline:
resp = await client.get(f"http://localhost:{cfg.comfyui_port}/history/{prompt_id}")
if resp.status_code == 200:
history = resp.json().get(prompt_id, {})
outputs = history.get("outputs", {})
for node_id, node_output in outputs.items():
images = node_output.get("images", [])
if images:
img_info = images[0]
filename = img_info.get("filename")
subfolder = img_info.get("subfolder", "")
img_type = img_info.get("type", "output")
img_resp = await client.get(
f"http://localhost:{cfg.comfyui_port}/view",
params={"filename": filename, "subfolder": subfolder, "type": img_type},
)
if img_resp.status_code == 200:
import base64
return base64.b64encode(img_resp.content).decode()
await asyncio.sleep(1)
raise RuntimeError("ComfyUI generation timed out after 120s")
# ── main ────────────────────────────────────────────────────────────────
async def amain():
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s caic[%(process)d]: %(levelname)s %(message)s",
)
log.info("cAIc node agent starting")
if not HAS_AIO_PIKA:
log.error("aio-pika not installed")
sys.exit(1)
cfg = AgentConfig.from_ini()
log.info("node_name=%s node_ip=%s", cfg.node_name, cfg.node_ip)
cfg.capabilities = detect_capabilities(cfg)
log.info("capabilities: %s", cfg.capabilities)
inventory = discover_models(cfg.models_dir)
log.info("discovered %d models", len(inventory))
# Connect to AMQP
conn = await aio_pika.connect_robust(cfg.amqp_url)
channel = await conn.channel()
admin_ex, system_ex = await declare_exchanges(channel)
log.info("connected to AMQP broker")
# Publish registration
reg_payload = build_registration_payload(cfg, inventory)
await publish(channel, admin_ex, f"node.{cfg.node_name}.register", reg_payload)
log.info("registration published")
# Wait for admission
response_queue = await channel.declare_queue("", exclusive=True)
await response_queue.bind(admin_ex, f"node.{cfg.node_name}.admitted")
await response_queue.bind(admin_ex, f"node.{cfg.node_name}.rejected")
admitted = False
async with response_queue.iterator() as iterator:
async for message in iterator:
async with message.process():
payload = json.loads(message.body.decode())
if payload.get("type") == "admitted":
log.info("admitted to cluster")
admitted = True
break
else:
log.error("rejected: %s", payload.get("reason"))
sys.exit(1)
if not admitted:
log.error("no admission response received")
sys.exit(1)
# Set up ping consumer
ping_queue = await channel.declare_queue("", exclusive=True)
await ping_queue.bind(admin_ex, f"node.{cfg.node_name}.ping")
await ping_queue.consume(lambda msg: handle_ping(cfg, channel, admin_ex, msg))
# Set up swap consumer
swap_queue = await channel.declare_queue("", exclusive=True)
await swap_queue.bind(admin_ex, f"node.{cfg.node_name}.cmd.swap_model")
await swap_queue.consume(lambda msg: handle_swap_model(cfg, channel, (admin_ex, system_ex), msg))
# Set up image gen consumer
if "image_gen" in cfg.capabilities:
image_queue = await channel.declare_queue("", exclusive=True)
await image_queue.bind(admin_ex, f"node.{cfg.node_name}.cmd.image_generate")
await image_queue.consume(lambda msg: handle_image_generate(cfg, channel, (admin_ex, system_ex), msg))
log.info("image generation handler registered")
log.info("listening for pings and commands")
# Run forever
await asyncio.Event().wait()
if __name__ == "__main__":
asyncio.run(amain())
+3
View File
@@ -0,0 +1,3 @@
aio-pika>=9.0.0
psutil>=5.9.0
httpx>=0.27.0
+140 -9
View File
@@ -1,22 +1,146 @@
""" """
JarvisChat - RAG pipeline: Qdrant vector search + system prompt assembly. cAIc - RAG pipeline: Qdrant vector search + system prompt assembly.
""" """
import asyncio
import logging import logging
import os
import uuid
from datetime import datetime, timezone
import httpx 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 db import get_db, get_setting, list_skills_with_state, format_active_skills_prompt
from memory import search_memories from memory import search_memories
from config import MAX_SKILL_PROMPT_CHARS from config import MAX_SKILL_PROMPT_CHARS, QDRANT_URL, RAG_COLLECTION
log = logging.getLogger("jarvischat") log = logging.getLogger("caic")
QDRANT_URL = "http://192.168.50.108:6333" EMBED_URL = os.environ.get("CAIC_EMBED_URL", "http://localhost:11434")
EMBED_URL = "http://192.168.50.210:11434" EMBED_MODEL = os.environ.get("CAIC_EMBED_MODEL", "mxbai-embed-large")
EMBED_MODEL = "mxbai-embed-large"
RAG_COLLECTION = "jarvis_rag"
RAG_SCORE_THRESHOLD = 0.25 RAG_SCORE_THRESHOLD = 0.25
# Re-export eviction symbols for backward compatibility
from eviction import ( # noqa: E402
maybe_evict, get_rag_operational_stats, EVICTION_LOG,
get_collection_count, get_collection_stats, evict_batch,
)
async def _upsert_fact(fact: str, text: str, topic: str,
client: httpx.AsyncClient) -> bool:
"""Embed text and upsert a fact to Qdrant."""
chunks = chunk_text(text)
if not chunks:
return False
ts = datetime.now(timezone.utc).timestamp()
ok = False
for i, chunk in enumerate(chunks):
try:
er = await client.post(
f"{EMBED_URL}/api/embeddings",
json={"model": EMBED_MODEL, "prompt": chunk},
timeout=10.0,
)
if er.status_code != 200:
continue
vector = er.json()["embedding"]
pid = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"auto-{ts}-{i}"))
payload = {
"text": encrypt_text(chunk), "source": "auto_fact", "fact": fact,
"ingest_date": datetime.now(timezone.utc).isoformat(),
"type": "auto_fact", "topic": topic,
}
r = await client.put(
f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points?wait=true",
json={"points": [{"id": pid, "vector": vector, "payload": payload}]},
timeout=10.0,
)
if r.status_code in (200, 201):
ok = True
except Exception as e:
log.warning(f"Qdrant upsert error: {e}")
return ok
async def ingest_auto_fact(facts: list[str], user_message: str,
assistant_message: str) -> int:
"""Persist pre-detected facts to memories + Qdrant.
Call this when no conflicts exist — silent ingest.
Returns the number of facts stored.
"""
from memory import add_memory, detect_topic
ingested = 0
async with httpx.AsyncClient() as client:
for fact in facts:
topic = detect_topic(fact)
add_memory(fact, topic=topic, source="auto")
ingested += 1
text = f"Q: {user_message}\nA: {assistant_message}"
await _upsert_fact(fact, text, topic, client)
if ingested:
log.info(f"Auto-ingested {ingested} fact(s) from conversation")
return ingested
async def confirm_fact_update(memory_id: int, old_fact: str, new_fact: str,
user_message: str, assistant_message: str) -> bool:
"""Confirm a user-accepted fact update: replace memory + Qdrant entry."""
from memory import update_memory, detect_topic
if not update_memory(memory_id, new_fact):
return False
topic = detect_topic(new_fact)
try:
async with httpx.AsyncClient() as client:
# scroll old points with matching fact and delete them
scroll_r = await client.post(
f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points/scroll",
json={
"filter": {"must": [{"key": "fact", "match": {"value": old_fact}}]},
"limit": 100,
"with_payload": False,
},
timeout=10.0,
)
if scroll_r.status_code == 200:
ids = [p["id"] for p in scroll_r.json().get("result", [])]
if ids:
await client.post(
f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points/delete",
json={"points": ids},
timeout=10.0,
)
text = f"Q: {user_message}\nA: {assistant_message}"
await _upsert_fact(new_fact, text, topic, client)
except Exception as e:
log.warning(f"Fact update RAG error: {e}")
log.info(f"Fact updated [memory_id={memory_id}]: {new_fact}")
return True
def chunk_text(text: str, chunk_size: int = 200, overlap: int = 64) -> list:
words = text.split()
target_words = int(chunk_size / 1.3)
overlap_words = int(overlap / 1.3)
if not words:
return []
chunks = []
start = 0
while start < len(words):
end = min(start + target_words, len(words))
chunks.append(" ".join(words[start:end]))
if end == len(words):
break
start += target_words - overlap_words
return chunks
async def query_rag(query: str, limit: int = 3) -> list: async def query_rag(query: str, limit: int = 3) -> list:
try: try:
@@ -36,7 +160,14 @@ async def query_rag(query: str, limit: int = 3) -> list:
) )
if search_resp.status_code != 200: if search_resp.status_code != 200:
return [] return []
return search_resp.json().get("result", []) results = search_resp.json().get("result", [])
for r in results:
pid = r.get("id")
if pid:
current = r.get("payload", {}).get("retrieval_count", 0) or 0
# Fire-and-forget: update retrieval count without blocking the response
asyncio.create_task(_update_retrieval_count(pid, current))
return results
except Exception as e: except Exception as e:
log.warning(f"RAG query error: {e}") log.warning(f"RAG query error: {e}")
return [] return []
@@ -62,7 +193,7 @@ async def build_system_prompt(db, extra_prompt: str = "", user_message: str = ""
try: try:
rag_results = await query_rag(user_message) rag_results = await query_rag(user_message)
if rag_results: 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: if rag_lines:
parts.append("## Retrieved Context\n" + "\n\n---\n\n".join(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") log.info(f"RAG injected {len(rag_lines)} chunks into context")
+6
View File
@@ -1,3 +1,9 @@
fastapi>=0.115.0 fastapi>=0.115.0
uvicorn[standard]>=0.32.0 uvicorn[standard]>=0.32.0
httpx>=0.27.0 httpx>=0.27.0
pypdf>=5.0.0
python-multipart>=0.0.9
aio-pika>=9.0.0
cryptography>=44.0.0
psutil>=5.9.0
jinja2>=3.1.0
+98 -20
View File
@@ -1,4 +1,5 @@
"""JarvisChat routers - /api/chat streaming endpoint.""" """cAIc routers - /api/chat streaming endpoint."""
import asyncio
import json import json
import logging import logging
import uuid import uuid
@@ -9,18 +10,35 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from config import DEFAULT_MODEL, LLAMA_SERVER_BASE from config import DEFAULT_MODEL, LLAMA_SERVER_BASE
from db import get_db from crypto import encrypt_text, decrypt_text
from memory import process_remember_command from db import get_db, get_upload_context
from rag import build_system_prompt from memory import process_remember_command, auto_detect_facts, check_fact_conflicts
from rag import build_system_prompt, ingest_auto_fact
from search import (calculate_perplexity, is_uncertain, is_refusal, from search import (calculate_perplexity, is_uncertain, is_refusal,
clean_hedging, format_search_results, format_direct_answer, clean_hedging, format_search_results, format_direct_answer,
extract_search_query, query_searxng) extract_search_query, query_searxng)
from security import read_json_body, log_incident, BODY_LIMIT_CHAT_BYTES from security import read_json_body, log_incident, BODY_LIMIT_CHAT_BYTES
from config import MAX_CHAT_MESSAGE_CHARS from config import MAX_CHAT_MESSAGE_CHARS, MODEL_CONTEXT_LENGTH
log = logging.getLogger("jarvischat") log = logging.getLogger("caic")
router = APIRouter() router = APIRouter()
# References to background auto-ingest tasks so they are never garbage-collected.
_ingest_tasks: set = set()
async def _safe_ingest(coro):
try:
await coro
except Exception as e:
log.warning("auto-ingest task failed: %s", e)
def _spawn_ingest(coro):
task = asyncio.create_task(_safe_ingest(coro))
_ingest_tasks.add(task)
task.add_done_callback(_ingest_tasks.discard)
def parse_llama_stream_chunk(line: str) -> tuple: def parse_llama_stream_chunk(line: str) -> tuple:
if line.startswith("data: "): if line.startswith("data: "):
@@ -45,6 +63,8 @@ def parse_llama_stream_chunk(line: str) -> tuple:
if finish == "stop": if finish == "stop":
usage = chunk.get("usage", {}) usage = chunk.get("usage", {})
stats["tokens_per_sec"] = usage.get("tokens_per_second", 0.0) stats["tokens_per_sec"] = usage.get("tokens_per_second", 0.0)
stats["completion_tokens"] = usage.get("completion_tokens", 0)
stats["prompt_tokens"] = usage.get("prompt_tokens", 0)
return token, finish == "stop", stats, logprobs_list return token, finish == "stop", stats, logprobs_list
if "message" in chunk and "content" in chunk["message"]: if "message" in chunk and "content" in chunk["message"]:
token = chunk["message"]["content"] token = chunk["message"]["content"]
@@ -54,6 +74,7 @@ def parse_llama_stream_chunk(line: str) -> tuple:
eval_count = chunk.get("eval_count", 0) eval_count = chunk.get("eval_count", 0)
eval_duration = chunk.get("eval_duration", 0) eval_duration = chunk.get("eval_duration", 0)
stats["tokens_per_sec"] = (eval_count / (eval_duration / 1e9)) if eval_duration > 0 else 0 stats["tokens_per_sec"] = (eval_count / (eval_duration / 1e9)) if eval_duration > 0 else 0
stats["completion_tokens"] = eval_count
return token, done, stats, [] return token, done, stats, []
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
@@ -69,6 +90,8 @@ async def chat(request: Request):
raise HTTPException(status_code=413, detail="Chat message is too long") raise HTTPException(status_code=413, detail="Chat message is too long")
model = body.get("model", DEFAULT_MODEL) model = body.get("model", DEFAULT_MODEL)
preset_prompt = body.get("system_prompt", "") preset_prompt = body.get("system_prompt", "")
upload_context_id = body.get("upload_context_id")
private_chat = body.get("private_chat", False)
if not user_message: if not user_message:
raise HTTPException(status_code=400, detail="Empty message") raise HTTPException(status_code=400, detail="Empty message")
@@ -76,26 +99,56 @@ async def chat(request: Request):
db = get_db() db = get_db()
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
settings = {row["key"]: row["value"] for row in db.execute("SELECT key, value FROM settings").fetchall()} settings = {row["key"]: row["value"] for row in db.execute("SELECT key, value FROM settings").fetchall()}
search_enabled = settings.get("search_enabled", "true") == "true" search_enabled = settings.get("search_enabled", "true") == "true" and not private_chat
remember_response = process_remember_command(user_message) upload_doc = None
if upload_context_id and not private_chat:
ctx = get_upload_context(db, upload_context_id)
if ctx:
upload_doc = f"[ATTACHED DOCUMENT: {ctx['filename']}]\n{ctx['content']}\n[END DOCUMENT]"
else:
log.warning(f"upload_context_id {upload_context_id} not found or expired, continuing without it")
remember_response = None if private_chat else process_remember_command(user_message)
if private_chat:
if not conv_id:
conv_id = str(uuid.uuid4())
system_prompt = ""
messages = []
if preset_prompt:
messages.append({"role": "system", "content": preset_prompt})
messages.append({"role": "user", "content": user_message})
history_rows = []
db.close()
else:
if not conv_id: if not conv_id:
conv_id = str(uuid.uuid4()) conv_id = str(uuid.uuid4())
title = user_message[:80] + ("..." if len(user_message) > 80 else "") title = user_message[:80] + ("..." if len(user_message) > 80 else "")
db.execute("INSERT INTO conversations (id, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", 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: else:
# A client-supplied id may reference a conversation that no longer exists;
# recreate the row so the message insert satisfies the FK instead of 500ing.
title = user_message[:80] + ("..." if len(user_message) > 80 else "")
db.execute("INSERT OR IGNORE INTO conversations (id, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
(conv_id, encrypt_text(title), model, now, now))
db.execute("UPDATE conversations SET updated_at = ? WHERE id = ?", (now, conv_id)) db.execute("UPDATE conversations SET updated_at = ? WHERE id = ?", (now, conv_id))
db.execute("INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)", db.execute("INSERT INTO messages (conversation_id, role, content, created_at, perplexity) VALUES (?, ?, ?, ?, ?)",
(conv_id, "user", user_message, now)) (conv_id, "user", encrypt_text(user_message), now, None))
db.commit() db.commit()
history_rows = db.execute( raw_rows = db.execute(
"SELECT role, content FROM messages WHERE conversation_id = ? ORDER BY id ASC", (conv_id,) "SELECT role, content FROM messages WHERE conversation_id = ? ORDER BY id ASC", (conv_id,)
).fetchall() ).fetchall()
system_prompt = await build_system_prompt(db, preset_prompt, user_message) 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
system_prompt = await build_system_prompt(db, extra_prompt, user_message)
db.close() db.close()
messages = [] messages = []
@@ -110,6 +163,9 @@ async def chat(request: Request):
full_response = [] full_response = []
all_logprobs = [] all_logprobs = []
tokens_per_sec = 0.0 tokens_per_sec = 0.0
completion_tokens = 0
prompt_tokens = 0
rag_update = None
if remember_response: if remember_response:
yield f"data: {json.dumps({'token': remember_response + chr(10) + chr(10), 'conversation_id': conv_id})}\n\n" yield f"data: {json.dumps({'token': remember_response + chr(10) + chr(10), 'conversation_id': conv_id})}\n\n"
@@ -131,9 +187,13 @@ async def chat(request: Request):
yield f"data: {json.dumps({'token': token, 'conversation_id': conv_id})}\n\n" yield f"data: {json.dumps({'token': token, 'conversation_id': conv_id})}\n\n"
if done: if done:
tokens_per_sec = stats.get("tokens_per_sec", 0.0) tokens_per_sec = stats.get("tokens_per_sec", 0.0)
completion_tokens = stats.get("completion_tokens", 0)
prompt_tokens = stats.get("prompt_tokens", 0)
assistant_msg = "".join(full_response) assistant_msg = "".join(full_response)
perplexity = calculate_perplexity(all_logprobs) if all_logprobs else 0.0 perplexity = calculate_perplexity(all_logprobs) if all_logprobs else 0.0
if not all_logprobs:
log.warning("No logprobs received from inference server — perplexity auto-search unavailable")
should_search = is_uncertain(all_logprobs) or is_refusal(assistant_msg) should_search = is_uncertain(all_logprobs) or is_refusal(assistant_msg)
if search_enabled and should_search: if search_enabled and should_search:
@@ -173,32 +233,50 @@ async def chat(request: Request):
if is_refusal(cleaned_response) or len(cleaned_response) < 20: if is_refusal(cleaned_response) or len(cleaned_response) < 20:
cleaned_response = format_direct_answer(user_message, search_results) cleaned_response = format_direct_answer(user_message, search_results)
yield f"data: {json.dumps({'token': cleaned_response, 'conversation_id': conv_id, 'augmented': True})}\n\n" yield f"data: {json.dumps({'token': cleaned_response, 'conversation_id': conv_id, 'augmented': True, 'reset': True})}\n\n"
if not private_chat:
saved_msg = cleaned_response + "\n\n---\n*🔍 Enhanced with web search results*" saved_msg = cleaned_response + "\n\n---\n*🔍 Enhanced with web search results*"
if remember_response: if remember_response:
saved_msg = remember_response + "\n\n" + saved_msg saved_msg = remember_response + "\n\n" + saved_msg
db2 = get_db() db2 = get_db()
db2.execute("INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)", db2.execute("INSERT INTO messages (conversation_id, role, content, created_at, perplexity) VALUES (?, ?, ?, ?, ?)",
(conv_id, "assistant", saved_msg, datetime.now(timezone.utc).isoformat())) (conv_id, "assistant", encrypt_text(saved_msg), datetime.now(timezone.utc).isoformat(), round(perplexity, 2)))
db2.commit() db2.commit()
db2.close() db2.close()
yield f"data: {json.dumps({'done': True, 'conversation_id': conv_id, 'searched': True, 'perplexity': round(perplexity, 2), 'tokens_per_sec': round(tokens_per_sec, 1)})}\n\n" facts = auto_detect_facts(user_message, cleaned_response)
if facts:
conflicts = check_fact_conflicts(facts)
if conflicts:
rag_update = {"conflicts": conflicts}
else:
_spawn_ingest(ingest_auto_fact(facts, user_message, cleaned_response))
yield f"data: {json.dumps({'done': True, 'conversation_id': conv_id, 'searched': True, 'perplexity': round(perplexity, 2), 'tokens_per_sec': round(tokens_per_sec, 1), 'prompt_tokens': prompt_tokens, 'completion_tokens': completion_tokens, 'context_length': MODEL_CONTEXT_LENGTH, **(rag_update and {'rag_update_suggestion': rag_update} or {})})}\n\n"
return return
if not private_chat:
saved_msg = assistant_msg saved_msg = assistant_msg
if remember_response: if remember_response:
saved_msg = remember_response + "\n\n" + saved_msg saved_msg = remember_response + "\n\n" + saved_msg
db2 = get_db() db2 = get_db()
db2.execute("INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)", db2.execute("INSERT INTO messages (conversation_id, role, content, created_at, perplexity) VALUES (?, ?, ?, ?, ?)",
(conv_id, "assistant", saved_msg, datetime.now(timezone.utc).isoformat())) (conv_id, "assistant", encrypt_text(saved_msg), datetime.now(timezone.utc).isoformat(), round(perplexity, 2)))
db2.commit() db2.commit()
db2.close() db2.close()
yield f"data: {json.dumps({'done': True, 'conversation_id': conv_id, 'perplexity': round(perplexity, 2), 'tokens_per_sec': round(tokens_per_sec, 1)})}\n\n" facts = auto_detect_facts(user_message, assistant_msg)
if facts:
conflicts = check_fact_conflicts(facts)
if conflicts:
rag_update = {"conflicts": conflicts}
else:
_spawn_ingest(ingest_auto_fact(facts, user_message, assistant_msg))
yield f"data: {json.dumps({'done': True, 'conversation_id': conv_id, 'perplexity': round(perplexity, 2), 'tokens_per_sec': round(tokens_per_sec, 1), 'prompt_tokens': prompt_tokens, 'completion_tokens': completion_tokens, 'context_length': MODEL_CONTEXT_LENGTH, **(rag_update and {'rag_update_suggestion': rag_update} or {})})}\n\n"
except httpx.RemoteProtocolError: except httpx.RemoteProtocolError:
pass pass
+23
View File
@@ -0,0 +1,23 @@
"""cAIc routers - Cluster status API."""
from fastapi import APIRouter
import cluster
router = APIRouter()
@router.get("/api/cluster")
async def cluster_status():
return {
"nodes": {name: _strip_internal(node) for name, node in cluster.CLUSTER_NODES.items()},
"node_count": len(cluster.CLUSTER_NODES),
"coordinator": cluster.CLUSTER_COORDINATOR,
"events": list(cluster.CLUSTER_EVENTS),
}
def _strip_internal(node: dict) -> dict:
return {k: v for k, v in node.items() if k in {
"name", "type", "status", "capabilities", "active_model", "load",
"registered_at", "last_seen",
}}
+99 -12
View File
@@ -1,11 +1,12 @@
""" """
JarvisChat - /v1/chat/completions router. cAIc - /v1/chat/completions router.
OpenAI-compatible endpoint for IDE integration (Continue.dev, etc.). OpenAI-compatible endpoint for IDE integration (Continue.dev, etc.).
Runs all requests through the full jC pipeline: profile + RAG + memory injection. Runs all requests through the full jC pipeline: profile + RAG + memory injection.
FIM (fill-in-the-middle) requests are proxied directly — not persisted. FIM (fill-in-the-middle) requests are proxied directly — not persisted.
Chat-style requests are persisted to conversation history. Chat-style requests are persisted to conversation history.
Auth: static Bearer token via COMPLETIONS_API_KEY in config. Auth: static Bearer token via COMPLETIONS_API_KEY in config.
""" """
import hmac
import json import json
import logging import logging
import uuid import uuid
@@ -16,11 +17,12 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse, JSONResponse from fastapi.responses import StreamingResponse, JSONResponse
from config import DEFAULT_MODEL, LLAMA_SERVER_BASE, COMPLETIONS_API_KEY from config import DEFAULT_MODEL, LLAMA_SERVER_BASE, COMPLETIONS_API_KEY
from crypto import encrypt_text
from db import get_db from db import get_db
from rag import build_system_prompt from rag import build_system_prompt
from routers.chat import parse_llama_stream_chunk from routers.chat import parse_llama_stream_chunk
log = logging.getLogger("jarvischat") log = logging.getLogger("caic")
router = APIRouter() router = APIRouter()
@@ -29,7 +31,7 @@ def _check_api_key(request: Request):
if not auth.startswith("Bearer "): if not auth.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing Bearer token") raise HTTPException(status_code=401, detail="Missing Bearer token")
token = auth[7:].strip() token = auth[7:].strip()
if token != COMPLETIONS_API_KEY: if not hmac.compare_digest(token.encode(), COMPLETIONS_API_KEY.encode()):
raise HTTPException(status_code=401, detail="Invalid API key") raise HTTPException(status_code=401, detail="Invalid API key")
@@ -86,6 +88,7 @@ def _build_openai_response(content: str, model: str, conv_id: str) -> dict:
@router.post("/v1/chat/completions") @router.post("/v1/chat/completions")
@router.post("/v1/completions")
async def chat_completions(request: Request): async def chat_completions(request: Request):
_check_api_key(request) _check_api_key(request)
@@ -118,25 +121,27 @@ async def chat_completions(request: Request):
# --- Persist conversation --- # --- Persist conversation ---
db = get_db() db = get_db()
try:
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
conv_id = str(uuid.uuid4()) conv_id = str(uuid.uuid4())
title = f"[IDE] {user_message[:72]}{'...' if len(user_message) > 72 else ''}" title = f"[IDE] {user_message[:72]}{'...' if len(user_message) > 72 else ''}"
db.execute( db.execute(
"INSERT INTO conversations (id, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", "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: for msg in messages:
role = msg.get("role") role = msg.get("role")
content = msg.get("content", "") content = msg.get("content", "")
if role in ("user", "assistant"): if role in ("user", "assistant"):
db.execute( db.execute(
"INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)", "INSERT INTO messages (conversation_id, role, content, created_at, perplexity) VALUES (?, ?, ?, ?, ?)",
(conv_id, role, content, now), (conv_id, role, encrypt_text(content), now, None),
) )
db.commit() db.commit()
# --- Build system prompt through full jC pipeline --- # --- Build system prompt through full jC pipeline ---
system_prompt = await build_system_prompt(db, "", user_message) system_prompt = await build_system_prompt(db, "", user_message)
finally:
db.close() db.close()
# Assemble messages for upstream: inject jC system prompt, preserve history # Assemble messages for upstream: inject jC system prompt, preserve history
@@ -193,8 +198,8 @@ async def _stream_chat(payload: dict, model: str, conv_id: str, request: Request
if assistant_msg: if assistant_msg:
db = get_db() db = get_db()
db.execute( db.execute(
"INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)", "INSERT INTO messages (conversation_id, role, content, created_at, perplexity) VALUES (?, ?, ?, ?, ?)",
(conv_id, "assistant", assistant_msg, datetime.now(timezone.utc).isoformat()), (conv_id, "assistant", encrypt_text(assistant_msg), datetime.now(timezone.utc).isoformat(), None),
) )
db.commit() db.commit()
db.close() db.close()
@@ -238,8 +243,8 @@ async def _blocking_chat(payload: dict, model: str, conv_id: str, request: Reque
if assistant_msg: if assistant_msg:
db = get_db() db = get_db()
db.execute( db.execute(
"INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)", "INSERT INTO messages (conversation_id, role, content, created_at, perplexity) VALUES (?, ?, ?, ?, ?)",
(conv_id, "assistant", assistant_msg, datetime.now(timezone.utc).isoformat()), (conv_id, "assistant", encrypt_text(assistant_msg), datetime.now(timezone.utc).isoformat(), None),
) )
db.commit() db.commit()
db.close() db.close()
@@ -247,6 +252,79 @@ async def _blocking_chat(payload: dict, model: str, conv_id: str, request: Reque
return JSONResponse(content=_build_openai_response(assistant_msg, model, conv_id)) return JSONResponse(content=_build_openai_response(assistant_msg, model, conv_id))
@router.post("/v1/fim/completions")
@router.post("/fim/completions")
async def fim_completions(request: Request):
_check_api_key(request)
try:
body = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON body")
prefix = body.get("prompt", "")
suffix = body.get("suffix", "")
stream = body.get("stream", True)
max_tokens = body.get("max_tokens", 128)
fim_prompt = f"<|fim_prefix|>{prefix}<|fim_suffix|>{suffix}<|fim_middle|>"
upstream = {
"prompt": fim_prompt,
"n_predict": max_tokens,
"temperature": body.get("temperature", 0),
"stop": body.get("stop", []),
"stream": True,
"cache_prompt": False,
}
async def _stream_fim():
async with httpx.AsyncClient() as client:
try:
async with client.stream(
"POST", f"{LLAMA_SERVER_BASE}/completion",
json=upstream,
timeout=httpx.Timeout(30.0, connect=5.0),
) as resp:
async for line in resp.aiter_lines():
if not line or not line.startswith("data: "):
continue
try:
d = json.loads(line[6:])
content = d.get("content", "")
if content:
chunk = {
"choices": [{"delta": {"content": content}, "index": 0}]
}
yield f"data: {json.dumps(chunk)}\n\n"
if d.get("stop"):
break
except json.JSONDecodeError:
continue
yield "data: [DONE]\n\n"
except httpx.ConnectError:
yield f"data: {json.dumps({'error': 'Cannot connect to inference server'})}\n\n"
if stream:
return StreamingResponse(_stream_fim(), media_type="text/event-stream")
# Non-streaming: accumulate and return
full = []
async for chunk in _stream_fim():
if chunk.startswith("data: [DONE]"):
break
try:
d = json.loads(chunk[6:])
content = d.get("choices", [{}])[0].get("delta", {}).get("content", "")
if content:
full.append(content)
except (json.JSONDecodeError, IndexError):
pass
return JSONResponse(content={
"choices": [{"text": "".join(full), "index": 0, "finish_reason": "stop"}],
"usage": {},
})
async def _fim_passthrough(body: dict) -> JSONResponse: async def _fim_passthrough(body: dict) -> JSONResponse:
""" """
Proxy FIM requests directly to llama-server without pipeline injection. Proxy FIM requests directly to llama-server without pipeline injection.
@@ -255,11 +333,20 @@ async def _fim_passthrough(body: dict) -> JSONResponse:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
try: try:
resp = await client.post( resp = await client.post(
f"{LLAMA_SERVER_BASE}/v1/completions", f"{LLAMA_SERVER_BASE}/completion",
json=body, json=body,
timeout=httpx.Timeout(30.0, connect=5.0), timeout=httpx.Timeout(30.0, connect=5.0),
) )
return JSONResponse(content=resp.json(), status_code=resp.status_code) data = resp.json()
wrapped = {
"choices": [{
"text": data.get("content", ""),
"index": 0,
"finish_reason": data.get("stop", False),
}],
"usage": {},
}
return JSONResponse(content=wrapped, status_code=resp.status_code)
except httpx.ConnectError: except httpx.ConnectError:
raise HTTPException(status_code=503, detail="Cannot connect to inference server") raise HTTPException(status_code=503, detail="Cannot connect to inference server")
except Exception as e: except Exception as e:
+23 -6
View File
@@ -1,13 +1,14 @@
"""JarvisChat routers - Conversation CRUD.""" """cAIc routers - Conversation CRUD."""
import logging import logging
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from db import get_db from db import get_db
from crypto import encrypt_text, decrypt_text
from security import read_json_body, BODY_LIMIT_DEFAULT_BYTES from security import read_json_body, BODY_LIMIT_DEFAULT_BYTES
from config import DEFAULT_MODEL, MAX_CONVERSATION_TITLE_CHARS from config import DEFAULT_MODEL, MAX_CONVERSATION_TITLE_CHARS
log = logging.getLogger("jarvischat") log = logging.getLogger("caic")
router = APIRouter() router = APIRouter()
@@ -15,8 +16,17 @@ router = APIRouter()
async def list_conversations(): async def list_conversations():
db = get_db() db = get_db()
rows = db.execute("SELECT * FROM conversations ORDER BY updated_at DESC").fetchall() rows = db.execute("SELECT * FROM conversations ORDER BY updated_at DESC").fetchall()
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]
c["attachment_count"] = attach_count
result.append(c)
db.close() db.close()
return [dict(r) for r in rows] return result
@router.post("/api/conversations") @router.post("/api/conversations")
@@ -28,7 +38,7 @@ async def create_conversation(request: Request):
title = str(body.get("title", "New Chat"))[:MAX_CONVERSATION_TITLE_CHARS] title = str(body.get("title", "New Chat"))[:MAX_CONVERSATION_TITLE_CHARS]
db = get_db() db = get_db()
db.execute("INSERT INTO conversations (id, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", 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.commit()
db.close() db.close()
return {"id": conv_id, "title": title, "model": model, "created_at": now, "updated_at": now} return {"id": conv_id, "title": title, "model": model, "created_at": now, "updated_at": now}
@@ -43,7 +53,14 @@ async def get_conversation(conv_id: str):
raise HTTPException(status_code=404, detail="Conversation not found") 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() messages = db.execute("SELECT * FROM messages WHERE conversation_id = ? ORDER BY id ASC", (conv_id,)).fetchall()
db.close() 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}") @router.put("/api/conversations/{conv_id}")
@@ -53,7 +70,7 @@ async def update_conversation(conv_id: str, request: Request):
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
if "title" in body: if "title" in body:
db.execute("UPDATE conversations SET title = ?, updated_at = ? WHERE id = ?", 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: if "model" in body:
db.execute("UPDATE conversations SET model = ?, updated_at = ? WHERE id = ?", db.execute("UPDATE conversations SET model = ?, updated_at = ? WHERE id = ?",
(body["model"], now, conv_id)) (body["model"], now, conv_id))
+15
View File
@@ -0,0 +1,15 @@
"""cAIc routers — Hardware self-assessment endpoint."""
import json
from fastapi import APIRouter
import hardware
router = APIRouter()
@router.get("/api/hardware")
async def get_hardware_state():
if hardware.HARDWARE_STATE_PATH.exists():
return json.loads(hardware.HARDWARE_STATE_PATH.read_text())
return {"status": "not_ready", "message": "Hardware assessment not yet complete"}
+70
View File
@@ -0,0 +1,70 @@
"""cAIc routers — Image generation proxy endpoint."""
import base64
import logging
from fastapi import APIRouter, HTTPException
from fastapi.responses import Response
from cluster import CLUSTER_NODES, request_image_generate
log = logging.getLogger("caic")
router = APIRouter()
def _find_image_node() -> str | None:
for name, node in CLUSTER_NODES.items():
if node.get("status") == "active" and "image_gen" in node.get("capabilities", []):
return name
return None
@router.post("/api/image/generate")
async def generate_image(request_body: dict):
prompt = (request_body.get("prompt") or "").strip()
if not prompt:
raise HTTPException(status_code=400, detail="Prompt is required")
negative_prompt = request_body.get("negative_prompt", "")
width = min(max(request_body.get("width", 1024), 256), 2048)
height = min(max(request_body.get("height", 1024), 256), 2048)
steps = min(max(request_body.get("steps", 20), 1), 50)
seed = request_body.get("seed", -1)
model = request_body.get("model", "")
node_name = _find_image_node()
if not node_name:
raise HTTPException(status_code=503, detail="No image generation service available")
log.info("image generate via %s: %s", node_name, prompt[:60])
image_b64 = await request_image_generate(
node_name=node_name,
prompt=prompt,
negative_prompt=negative_prompt,
width=width,
height=height,
steps=steps,
seed=seed,
model=model,
)
if image_b64 is None:
raise HTTPException(status_code=504, detail="Image generation timed out or failed")
image_bytes = base64.b64decode(image_b64)
return Response(content=image_bytes, media_type="image/png")
@router.get("/api/image/status")
async def image_status():
nodes = []
for name, node in CLUSTER_NODES.items():
caps = node.get("capabilities", [])
if "image_gen" in caps:
nodes.append({
"name": name,
"status": node.get("status"),
"load": node.get("load"),
"last_seen": node.get("last_seen"),
})
return {"available": len(nodes) > 0, "nodes": nodes}
+75
View File
@@ -0,0 +1,75 @@
"""cAIc routers - /api/ingest terminal command RAG hook."""
import hashlib
import hmac
import logging
import uuid
from datetime import datetime, timezone
import httpx
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
log = logging.getLogger("caic")
router = APIRouter()
def _check_api_key(request: Request):
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing Bearer token")
token = auth[7:].strip()
if not hmac.compare_digest(token.encode(), COMPLETIONS_API_KEY.encode()):
raise HTTPException(status_code=401, detail="Invalid API key")
@router.post("/api/ingest")
async def ingest_content(request: Request):
_check_api_key(request)
body = await request.json()
content = (body.get("content") or "").strip()
if not content:
raise HTTPException(status_code=422, detail="content is required")
source = str(body.get("source", "external")).strip() or "external"
metadata = body.get("metadata") or {}
chunks = chunk_text(content)
if not chunks:
raise HTTPException(status_code=422, detail="content produced no chunks")
ingested = 0
async with httpx.AsyncClient() as client:
for i, chunk in enumerate(chunks):
embed_resp = await client.post(
f"{EMBED_URL}/api/embeddings",
json={"model": EMBED_MODEL, "prompt": chunk},
timeout=30.0,
)
if embed_resp.status_code != 200:
log.warning(f"Ingest embedding failed for chunk {i}: {embed_resp.status_code}")
continue
vector = embed_resp.json()["embedding"]
chunk_hash = hashlib.md5(chunk.encode("utf-8")).hexdigest()[:12]
point_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"ingest-{source}-{chunk_hash}-{i}"))
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",
json={"points": [{"id": point_id, "vector": vector, "payload": payload}]},
timeout=30.0,
)
if upsert_resp.status_code in (200, 201):
ingested += 1
else:
log.warning(f"Ingest Qdrant upsert failed for chunk {i}: {upsert_resp.status_code}")
if ingested > 0:
evicted = await maybe_evict()
if evicted:
log.info(f"Evicted {evicted} vectors after ingest")
return {"chunks_ingested": ingested, "source": source, "message": f"Ingested {ingested} chunks from {source}"}
+18 -1
View File
@@ -1,9 +1,10 @@
"""JarvisChat routers - Memory CRUD API.""" """cAIc routers - Memory CRUD API."""
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from typing import Optional from typing import Optional
from db import get_db from db import get_db
from memory import add_memory, delete_memory, update_memory, get_all_memories, search_memories from memory import add_memory, delete_memory, update_memory, get_all_memories, search_memories
from rag import confirm_fact_update
from security import read_json_body, BODY_LIMIT_DEFAULT_BYTES from security import read_json_body, BODY_LIMIT_DEFAULT_BYTES
from config import MAX_MEMORY_FACT_CHARS from config import MAX_MEMORY_FACT_CHARS
@@ -54,6 +55,22 @@ async def search_memories_api(q: str, limit: int = 10):
return {"results": results, "count": len(results)} return {"results": results, "count": len(results)}
@router.post("/api/memories/confirm-update")
async def confirm_memory_update(request: Request):
body = await read_json_body(request, BODY_LIMIT_DEFAULT_BYTES)
memory_id = body.get("memory_id")
new_fact = str(body.get("new_fact", "")).strip()
old_fact = str(body.get("old_fact", "")).strip()
user_message = str(body.get("user_message", "")).strip()
assistant_message = str(body.get("assistant_message", "")).strip()
if not memory_id or not new_fact:
raise HTTPException(status_code=400, detail="memory_id and new_fact are required")
ok = await confirm_fact_update(memory_id, old_fact, new_fact, user_message, assistant_message)
if not ok:
raise HTTPException(status_code=404, detail="Memory not found")
return {"status": "ok"}
@router.get("/api/memories/stats") @router.get("/api/memories/stats")
async def memory_stats(): async def memory_stats():
db = get_db() db = get_db()
+2 -2
View File
@@ -1,5 +1,5 @@
""" """
JarvisChat routers - Model listing, system stats. cAIc routers - Model listing, system stats.
""" """
import logging import logging
from typing import Optional from typing import Optional
@@ -12,7 +12,7 @@ from config import LLAMA_SERVER_BASE
from gpu import get_gpu_stats from gpu import get_gpu_stats
from security import read_json_body, BODY_LIMIT_DEFAULT_BYTES from security import read_json_body, BODY_LIMIT_DEFAULT_BYTES
log = logging.getLogger("jarvischat") log = logging.getLogger("caic")
router = APIRouter() router = APIRouter()
+1 -1
View File
@@ -1,4 +1,4 @@
"""JarvisChat routers - System prompt presets.""" """cAIc routers - System prompt presets."""
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
+1 -1
View File
@@ -1,4 +1,4 @@
"""JarvisChat routers - Profile.""" """cAIc routers - Profile."""
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from db import get_db from db import get_db
+249
View File
@@ -0,0 +1,249 @@
"""cAIc routers — RAG corpus management admin endpoints."""
import logging
from datetime import datetime, timezone
import httpx
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, 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":
raise HTTPException(status_code=403, detail="Admin PIN required for this action")
stats = await get_rag_operational_stats()
stats["eviction_log_size"] = len(EVICTION_LOG)
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}}]}
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:
info = info_resp.json().get("result", {})
total = info.get("points_count", info.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(
f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points/scroll",
json={"limit": 10000, "with_payload": False, "with_vector": False},
timeout=30.0,
)
if scroll_resp.status_code != 200:
return JSONResponse(status_code=502, content={"detail": f"Qdrant scroll failed: {scroll_resp.status_code}"})
all_points = scroll_resp.json().get("result", {}).get("points", [])
point_ids = [p["id"] for p in all_points]
if not point_ids:
return {"deleted_count": 0, "collection": RAG_COLLECTION, "status": "flushed"}
delete_resp = await client.post(
f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points/delete",
json={"points": point_ids},
timeout=30.0,
)
if delete_resp.status_code not in (200, 201):
return JSONResponse(status_code=502, content={"detail": f"Qdrant delete failed: {delete_resp.status_code}"})
EVICTION_LOG.clear()
log.warning(f"RAG collection '{RAG_COLLECTION}' flushed ({len(point_ids)} points deleted)")
return {
"deleted_count": len(point_ids),
"collection": RAG_COLLECTION,
"status": "flushed",
}
except Exception as e:
log.warning(f"RAG flush error: {e}")
return JSONResponse(status_code=502, content={"detail": f"RAG flush error: {e}"})
+17 -9
View File
@@ -1,4 +1,4 @@
"""JarvisChat routers - /api/search explicit search endpoint.""" """cAIc routers - /api/search explicit search endpoint."""
import json import json
import logging import logging
import uuid import uuid
@@ -9,12 +9,13 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from config import DEFAULT_MODEL, LLAMA_SERVER_BASE, MAX_SEARCH_QUERY_CHARS from config import DEFAULT_MODEL, LLAMA_SERVER_BASE, MAX_SEARCH_QUERY_CHARS
from crypto import encrypt_text
from db import get_db from db import get_db
from search import query_searxng, format_search_results from search import query_searxng, format_search_results
from routers.chat import parse_llama_stream_chunk from routers.chat import parse_llama_stream_chunk
from security import read_json_body, log_incident, BODY_LIMIT_CHAT_BYTES from security import read_json_body, log_incident, BODY_LIMIT_CHAT_BYTES
log = logging.getLogger("jarvischat") log = logging.getLogger("caic")
router = APIRouter() router = APIRouter()
@@ -30,6 +31,10 @@ async def explicit_search(request: Request):
if not query: if not query:
raise HTTPException(status_code=400, detail="Empty query") raise HTTPException(status_code=400, detail="Empty query")
private_chat = body.get("private_chat", False)
if private_chat:
raise HTTPException(status_code=403, detail="Web search is disabled in private chat mode")
db = get_db() db = get_db()
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
@@ -37,12 +42,15 @@ async def explicit_search(request: Request):
conv_id = str(uuid.uuid4()) conv_id = str(uuid.uuid4())
title = query[:70] + "..." if len(query) > 70 else query title = query[:70] + "..." if len(query) > 70 else query
db.execute("INSERT INTO conversations (id, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", 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: else:
title = query[:70] + "..." if len(query) > 70 else query
db.execute("INSERT OR IGNORE INTO conversations (id, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
(conv_id, title, model, now, now))
db.execute("UPDATE conversations SET updated_at = ? WHERE id = ?", (now, conv_id)) db.execute("UPDATE conversations SET updated_at = ? WHERE id = ?", (now, conv_id))
db.execute("INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)", db.execute("INSERT INTO messages (conversation_id, role, content, created_at, perplexity) VALUES (?, ?, ?, ?, ?)",
(conv_id, "user", query, now)) (conv_id, "user", encrypt_text(query), now, None))
db.commit() db.commit()
db.close() db.close()
@@ -55,8 +63,8 @@ async def explicit_search(request: Request):
error_msg = "No search results found." error_msg = "No search results found."
yield f"data: {json.dumps({'token': error_msg, 'conversation_id': conv_id})}\n\n" yield f"data: {json.dumps({'token': error_msg, 'conversation_id': conv_id})}\n\n"
db2 = get_db() db2 = get_db()
db2.execute("INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)", db2.execute("INSERT INTO messages (conversation_id, role, content, created_at, perplexity) VALUES (?, ?, ?, ?, ?)",
(conv_id, "assistant", error_msg, datetime.now(timezone.utc).isoformat())) (conv_id, "assistant", encrypt_text(error_msg), datetime.now(timezone.utc).isoformat(), None))
db2.commit() db2.commit()
db2.close() db2.close()
yield f"data: {json.dumps({'done': True, 'conversation_id': conv_id})}\n\n" yield f"data: {json.dumps({'done': True, 'conversation_id': conv_id})}\n\n"
@@ -97,8 +105,8 @@ async def explicit_search(request: Request):
saved_msg = f"{summary}\n\n---\n*🔍 Web search results*" saved_msg = f"{summary}\n\n---\n*🔍 Web search results*"
db2 = get_db() db2 = get_db()
db2.execute("INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)", db2.execute("INSERT INTO messages (conversation_id, role, content, created_at, perplexity) VALUES (?, ?, ?, ?, ?)",
(conv_id, "assistant", saved_msg, datetime.now(timezone.utc).isoformat())) (conv_id, "assistant", encrypt_text(saved_msg), datetime.now(timezone.utc).isoformat(), None))
db2.commit() db2.commit()
db2.close() db2.close()
+1 -1
View File
@@ -1,4 +1,4 @@
"""JarvisChat routers - Settings.""" """cAIc routers - Settings."""
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from db import get_db from db import get_db
from security import read_json_body, BODY_LIMIT_DEFAULT_BYTES from security import read_json_body, BODY_LIMIT_DEFAULT_BYTES
+1 -1
View File
@@ -1,4 +1,4 @@
"""JarvisChat routers - Skills.""" """cAIc routers - Skills."""
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from db import get_db, get_setting, list_skills_with_state, set_skill_enabled from db import get_db, get_setting, list_skills_with_state, set_skill_enabled
from security import read_json_body, BODY_LIMIT_DEFAULT_BYTES from security import read_json_body, BODY_LIMIT_DEFAULT_BYTES
+184
View File
@@ -0,0 +1,184 @@
"""cAIc routers - /api/upload file/document attachment endpoint."""
import json
import logging
import os
import uuid
from datetime import datetime, timezone, timedelta
import httpx
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
log = logging.getLogger("caic")
router = APIRouter()
def _point_id(filename: str, chunk_idx: int) -> str:
return str(uuid.uuid5(uuid.NAMESPACE_DNS, f"upload-{filename}-{chunk_idx}"))
@router.post("/api/upload")
async def upload_file(
request: Request,
file: UploadFile = File(...),
mode: str = Form("both"),
conversation_id: str = Form(""),
):
if mode not in ("context", "ingest", "both"):
raise HTTPException(status_code=422, detail="mode must be context, ingest, or both")
if file.size and file.size > MAX_UPLOAD_BYTES:
return JSONResponse(status_code=413, content={"detail": f"File exceeds {MAX_UPLOAD_BYTES} byte limit"})
content_type = file.content_type or "application/octet-stream"
if content_type not in SUPPORTED_UPLOAD_TYPES:
return JSONResponse(status_code=415, content={"detail": f"Unsupported file type: {content_type}"})
raw_bytes = await file.read()
if not raw_bytes:
raise HTTPException(status_code=422, detail="Empty file")
if content_type == "application/pdf":
try:
from pypdf import PdfReader
import io
reader = PdfReader(io.BytesIO(raw_bytes))
extracted = "\n".join(page.extract_text() or "" for page in reader.pages)
except Exception as e:
log.warning(f"PDF extraction error: {e}")
raise HTTPException(status_code=422, detail="Failed to extract text from PDF")
elif content_type.startswith("image/"):
# No OCR pipeline exists — store a descriptive placeholder so images
# remain usable in the gallery/context but never pollute the RAG corpus.
extracted = f"[Image: {file.filename}]"
else:
extracted = raw_bytes.decode("utf-8", errors="replace")
result = {"filename": file.filename, "size_bytes": len(raw_bytes), "mode": mode}
is_image = content_type.startswith("image/")
if is_image and mode in ("ingest", "both"):
result["chunks_ingested"] = 0
result["note"] = "Image files cannot be text-ingested; stored for gallery/context only"
if mode in ("ingest", "both") and not is_image:
os.makedirs(UPLOAD_DIR, exist_ok=True)
chunks = chunk_text(extracted)
ingested = 0
async with httpx.AsyncClient() as client:
for i, chunk in enumerate(chunks):
embed_resp = await client.post(
f"{EMBED_URL}/api/embeddings",
json={"model": EMBED_MODEL, "prompt": chunk},
timeout=30.0,
)
if embed_resp.status_code != 200:
log.warning(f"Embedding failed for chunk {i}: {embed_resp.status_code}")
continue
vector = embed_resp.json()["embedding"]
pid = _point_id(file.filename or "unnamed", i)
upsert_resp = await client.put(
f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points?wait=true",
json={
"points": [{
"id": pid,
"vector": vector,
"payload": {"text": encrypt_text(chunk), "source": file.filename, "upload_date": datetime.now(timezone.utc).isoformat(), "type": "upload"},
}]
},
timeout=30.0,
)
if upsert_resp.status_code in (200, 201):
ingested += 1
else:
log.warning(f"Qdrant upsert failed for chunk {i}: {upsert_resp.status_code}")
result["chunks_ingested"] = ingested
if ingested > 0:
evicted = await maybe_evict()
if evicted:
log.info(f"Evicted {evicted} vectors after upload")
if mode in ("context", "both"):
expires = (datetime.now(timezone.utc) + timedelta(hours=UPLOAD_CONTEXT_EXPIRY_HOURS)).isoformat()
db = get_db()
try:
cid = insert_upload_context(db, conversation_id or "", file.filename or "unnamed", extracted, expires, content_type)
db.commit()
result["context_id"] = cid
finally:
db.close()
result["message"] = f"Uploaded {file.filename}"
return result
@router.patch("/api/upload/{context_id}/link")
async def link_upload_to_conversation(context_id: int, request: Request):
body = await request.json()
conv_id = body.get("conversation_id", "").strip()
if not conv_id:
raise HTTPException(status_code=422, detail="conversation_id required")
db = get_db()
try:
row = db.execute("SELECT id FROM upload_context WHERE id = ?", (context_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Upload context not found")
db.execute("UPDATE upload_context SET conversation_id = ? WHERE id = ?", (conv_id, context_id))
db.commit()
return {"status": "ok"}
finally:
db.close()
@router.get("/api/upload/by-conversation/{conv_id}")
async def get_upload_by_conversation(conv_id: str):
db = get_db()
try:
items = list_upload_context_by_conversation(db, conv_id)
return items
finally:
db.close()
@router.delete("/api/upload/{context_id}")
async def delete_upload(context_id: int):
db = get_db()
try:
row = db.execute("SELECT filename FROM upload_context WHERE id = ?", (context_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Attachment not found")
filename = row["filename"]
if not filename:
filename = "unnamed"
delete_upload_context_by_id(db, context_id)
db.commit()
finally:
db.close()
try:
async with httpx.AsyncClient() as client:
scroll_resp = await client.post(
f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points/scroll",
json={"filter": {"must": [{"key": "source", "match": {"value": filename}}]}, "limit": 100, "with_payload": False},
timeout=10.0,
)
if scroll_resp.status_code == 200:
points = scroll_resp.json().get("result", {}).get("points", [])
point_ids = [p["id"] for p in points]
if point_ids:
await client.post(
f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points/delete",
json={"points": point_ids},
timeout=10.0,
)
log.info(f"Deleted {len(point_ids)} Qdrant points for source '{filename}'")
except Exception as e:
log.warning(f"Qdrant cleanup for '{filename}' failed: {e}")
return {"status": "ok", "filename": filename}
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env bash
# cAIc — nuclear clean uninstall
# Removes every trace of cAIc: bare-metal systemd install AND Docker stack.
# Prompts for each step. Use -y to skip ALL prompts (fully automatic).
# Use with extreme caution — will delete data and model files.
set -euo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKIP=false
[ "${1:-}" = "-y" ] && SKIP=true
prompt_yn() {
if $SKIP; then return 0; fi
local msg=$1; local default=${2:-n}
if [[ "$default" == "y" ]]; then
read -p "$msg [Y/n] " r; [[ -z "$r" || "$r" =~ ^[Yy] ]]
else
read -p "$msg [y/N] " r; [[ "$r" =~ ^[Yy] ]]
fi
}
warn() { echo -e "${YELLOW}$1${NC}"; }
if ! $SKIP; then
echo ""
echo "${RED}================================================${NC}"
echo "${RED} cAIc NUCLEAR CLEAN - removes all traces${NC}"
echo "${RED} This will delete data, models, and the app.${NC}"
echo "${RED}================================================${NC}"
echo ""
echo "cAIc NUCLEAR CLEAN"
echo "=================="
read -p "Type 'yes' to continue: " confirm
if [ "$confirm" != "yes" ]; then
echo "Aborted."
exit 1
else
SKIP=true
fi
fi
if ! $SKIP; then
prompt_yn "Run nuclear clean?" y || { echo "Aborted."; exit 0; }
fi
echo ""
echo "========== PART 1: Docker stack =========="
if command -v docker &>/dev/null; then
# Stop all cAIc-related containers
docker compose down 2>/dev/null || docker-compose down 2>/dev/null || echo " no compose stack running"
docker ps --filter name=cai[c] --filter name=searxng --filter name=qdrant --filter name=rabbit --filter name=llama-server --filter name=ollama --filter name=caic -aq 2>/dev/null \
| xargs -r docker rm -f 2>/dev/null || true
docker volume rm caic_data caic_uploads searxng_config qdrant_storage rabbitmq_data ollama_models 2>/dev/null || echo " volumes already gone"
docker rmi caic:latest 2>/dev/null || echo " caic image already gone"
echo " Docker stack removed"
else
echo " Docker not installed — skipping"
fi
echo ""
echo "========== PART 2: Systemd service ========="
SERVICE_FILE="/etc/systemd/system/caic.service"
if [ -f "$SERVICE_FILE" ]; then
stop caic 2>/dev/null || true
systemctl disable caic 2>/dev/null || true
rm -f "$SERVICE_FILE"
systemctl daemon-reload
echo " systemd service removed"
else
echo " no systemd service found"
fi
echo ""
echo "========== PART 3: Install directory ========"
for dir in /opt/caic /var/lib/caic; do
if [ -d "$dir" ]; then
rm -rf "$dir"
echo " Removed: $dir"
else
echo " Skipped: $dir"
fi
done
echo ""
echo "========== PART 4: Config and state files ========"
for f in \
/home/gramps/.caic_amqp_secret \
hardware_state.json \
setup.log \
.env; do
rm -f "$f" 2>/dev/null || true
done
echo " Config/state files cleaned"
echo ""
echo "========== PART 5: Temp data ==========="
rm -rf /tmp/caic_uploads 2>/dev/null || true
echo " Temp data cleaned"
echo ""
echo "========== PART 6: User data (prompt each) ========="
if prompt_yn "Remove the repository (current dir)?" n; then
cd ..
rm -rf "$SCRIPT_DIR"
echo " Repository removed"
else
echo " Repository preserved"
fi
echo ""
echo "${GREEN}cAIc nuclear clean complete.${NC}"
echo ""
echo "Manually verify:"
echo " - ls /opt/caic/"
echo " - ls /etc/systemd/system/caic*"
echo " - docker images | grep -E \"caic|searx|qdrant|rabbit|llama|ollama\""
echo " - docker ps -a"
echo ""
echo "To remove Docker Engine itself:"
echo " sudo apt remove docker.io containerd runc"
echo " sudo rm -rf /var/lib/docker"
echo ""
echo "To remove python packages:"
echo " pip uninstall fastapi uvicorn httpx psutil jinja2 python-multipart pypdf aio-pika"
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
# cAIc — First-run scaffolding
# Creates secrets, config, and directories needed by docker compose.
# Idempotent: safe to re-run.
set -euo pipefail
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
ok() { echo -e " ${GREEN}${NC} $1"; }
warn() { echo -e " ${YELLOW}!${NC} $1"; }
echo "cAIc — scaffolding"
echo "==================="
# ── .env ────────────────────────────────────────────────────
if [ ! -f .env ]; then
cp .env.example .env
# Auto-generate secrets
ADMIN_PIN=$(shuf -i 1000-9999 -n 1)
API_KEY="caic-sk-$(openssl rand -hex 24)"
RMQ_PASS=$(openssl rand -hex 20)
SEARX_KEY=$(openssl rand -hex 32)
sed -i "s/^CAIC_ADMIN_PIN=$/CAIC_ADMIN_PIN=$ADMIN_PIN/" .env
sed -i "s/^CAIC_COMPLETIONS_API_KEY=$/CAIC_COMPLETIONS_API_KEY=$API_KEY/" .env
sed -i "s/^RABBITMQ_PASSWORD=$/RABBITMQ_PASSWORD=$RMQ_PASS/" .env
sed -i "s/^SEARXNG_SECRET_KEY=$/SEARXNG_SECRET_KEY=$SEARX_KEY/" .env
ok ".env created"
warn " Admin PIN: $ADMIN_PIN"
warn " API key: $API_KEY"
else
warn ".env already exists — skipped"
fi
# ── secrets/ ────────────────────────────────────────────────
mkdir -p secrets
if [ ! -f secrets/rabbitmq_password.txt ]; then
RMQ_PASS=$(grep '^RABBITMQ_PASSWORD=' .env | cut -d= -f2)
echo -n "$RMQ_PASS" > secrets/rabbitmq_password.txt
ok "secrets/rabbitmq_password.txt created"
else
warn "secrets/rabbitmq_password.txt exists — skipped"
fi
# ── searxng/settings.yml ────────────────────────────────────
mkdir -p searxng
if [ ! -f searxng/settings.yml ]; then
SEARX_KEY=$(grep '^SEARXNG_SECRET_KEY=' .env | cut -d= -f2)
# Substitute the secret key into the template
sed "s/\${SEARXNG_SECRET_KEY}/$SEARX_KEY/" searxng-settings.yml.dist > searxng/settings.yml
ok "searxng/settings.yml created"
else
warn "searxng/settings.yml exists — skipped"
fi
# ── models/ ─────────────────────────────────────────────────
DEFAULT_MODEL_REPO="unsloth/Qwen2.5-7B-Instruct-GGUF"
DEFAULT_MODEL_FILE="Qwen2.5-7B-Instruct-Q4_K_M.gguf"
DEFAULT_MODEL_SIZE_MB=4600 # approximate download size
DEFAULT_MODEL_NAME="qwen2.5-7b-instruct"
mkdir -p models
# Set LLAMA_MODEL and CAIC_DEFAULT_MODEL in .env if not already set
LLAMA_MODEL_LINE=$(grep '^LLAMA_MODEL=' .env || true)
if [ -z "$LLAMA_MODEL_LINE" ] || [ "$LLAMA_MODEL_LINE" = "LLAMA_MODEL=" ]; then
sed -i "s/^LLAMA_MODEL=$/LLAMA_MODEL=$DEFAULT_MODEL_FILE/" .env
sed -i "s/^CAIC_DEFAULT_MODEL=.*/CAIC_DEFAULT_MODEL=$DEFAULT_MODEL_NAME/" .env
ok "Set LLAMA_MODEL=$DEFAULT_MODEL_FILE"
ok "Set CAIC_DEFAULT_MODEL=$DEFAULT_MODEL_NAME"
fi
if ls models/*.gguf 1>/dev/null 2>&1; then
ok "models/ has $(ls models/*.gguf | wc -l) model(s)"
else
echo ""
echo " No .gguf models found in ./models/"
echo ""
# Check disk space
AVAIL_KB=$(df -k models/ | tail -1 | awk '{print $4}')
AVAIL_MB=$((AVAIL_KB / 1024))
REQUIRED_MB=$((DEFAULT_MODEL_SIZE_MB + 500)) # 500MB safety margin
if [ "$AVAIL_MB" -lt "$REQUIRED_MB" ]; then
warn "Insufficient disk space: ${AVAIL_MB}MB available, ~${REQUIRED_MB}MB needed"
warn "Free space or change LLAMA_MODEL in .env to use a smaller model."
echo ""
else
echo " Download default model (~${DEFAULT_MODEL_SIZE_MB}MB):"
echo " ${DEFAULT_MODEL_REPO}/${DEFAULT_MODEL_FILE}"
echo ""
read -p " Download now? [Y/n] " r
if [[ -z "$r" || "$r" =~ ^[Yy] ]]; then
echo ""
echo " Downloading ${DEFAULT_MODEL_FILE}..."
if command -v hf &>/dev/null; then
# huggingface-cli (hf_transfer) if available
hf download "$DEFAULT_MODEL_REPO" "$DEFAULT_MODEL_FILE" \
--local-dir models/ --local-dir-use-symlinks False
elif command -v wget &>/dev/null; then
wget -q --show-progress -O "models/$DEFAULT_MODEL_FILE" \
"https://huggingface.co/${DEFAULT_MODEL_REPO}/resolve/main/${DEFAULT_MODEL_FILE}"
elif command -v curl &>/dev/null; then
curl -L --progress-bar -o "models/$DEFAULT_MODEL_FILE" \
"https://huggingface.co/${DEFAULT_MODEL_REPO}/resolve/main/${DEFAULT_MODEL_FILE}"
else
warn "Neither wget nor curl found — cannot download."
warn " Manual: wget -O models/$DEFAULT_MODEL_FILE \\"
warn " https://huggingface.co/${DEFAULT_MODEL_REPO}/resolve/main/${DEFAULT_MODEL_FILE}"
fi
if [ -f "models/$DEFAULT_MODEL_FILE" ]; then
DOWNLOADED_MB=$(du -m "models/$DEFAULT_MODEL_FILE" | cut -f1)
ok "Downloaded ${DEFAULT_MODEL_FILE} (${DOWNLOADED_MB}MB)"
else
warn "Download failed — place model manually in ./models/"
fi
else
warn "Skipped. Place .gguf model(s) in ./models/ before docker compose up."
fi
fi
fi
# ── data/ ───────────────────────────────────────────────────
mkdir -p data
ok "data/ directory ready"
# ── Verify Docker ───────────────────────────────────────────
echo ""
if command -v docker &>/dev/null && docker compose version &>/dev/null; then
ok "Docker Compose available: $(docker compose version --short)"
echo ""
echo -e "${GREEN}Ready!${NC} Run: docker compose up -d"
else
warn "Docker Compose not found — install Docker Engine + Compose plugin first."
echo " https://docs.docker.com/engine/install/"
fi
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env bash
# cAIc — Docker stack teardown
# Removes all containers, volumes, images, and generated files
# created by setup-wizard / docker compose.
# Run from the docker deployment directory. Use -y to skip prompts.
set -euo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
prompt_yn() {
if [ "${1:-}" = "-y" ]; then return 0; fi
[ "$SKIP" = true ] && return 0
local msg=$1; local default=${2:-n}
if [[ "$default" == "y" ]]; then
read -p "$msg [Y/n] " r; [[ -z "$r" || "$r" =~ ^[Yy] ]]
else
read -p "$msg [y/N] " r; [[ "$r" =~ ^[Yy] ]]
fi
}
SKIP=false
[ "${1:-}" = "-y" ] && SKIP=true
echo "cAIc Docker stack teardown"
echo "=========================="
# ---- Step 1: docker compose down -v ----
if [ -f docker-compose.yml ] || [ -f compose.yml ]; then
if prompt_yn "Stop Docker stack and remove volumes?" n; then
docker compose down -v 2>/dev/null || docker-compose down -v 2>/dev/null || true
echo " Docker stack stopped and volumes removed"
else
warn " Skipped: Docker stack left intact"
fi
else
echo " Skipped: no compose file found"
fi
# ---- Step 2: Remove Docker images ----
if prompt_yn "Remove cAIc Docker image?" n; then
docker rmi caic:latest 2>/dev/null || true
echo " Removed caic:latest"
warn "Remove SearXNG / Qdrant / RabbitMQ / llama-server / Ollama images?"
if prompt_yn " Remove service images?" n; then
for tag in searxng/searxng qdrant/qdrant rabbitmq:4-management \
ghcr.io/ggml-org/llama.cpp ollama/ollama; do
docker rmi "$tag" 2>/dev/null || true
done
echo " Service images removed"
fi
fi
# ---- Step 3: Volumes ----
if prompt_yn "Remove remaining Docker volumes?" n; then
for vol in caic_data caic_uploads searxng_config qdrant_storage rabbitmq_data ollama_models; do
docker volume rm "$vol" 2>/dev/null || true
done
echo " Docker volumes removed"
fi
# ---- Step 4: .env ----
if [ -f .env ]; then
if prompt_yn "Remove .env file?" n; then
rm -f .env
echo " .env removed"
fi
fi
# ---- Step 5: generated directories ----
for dir in secrets searxng; do
if [ -d "$dir" ]; then
if prompt_yn "Remove $dir/ directory?" n; then
rm -rf "$dir"
echo " $dir/ removed"
fi
fi
done
# ---- Step 6: generated files ----
for f in setup.log docker-compose.yml compose.yml; do
[ -f "$f" ] || continue
if prompt_yn "Remove $f?" n; then
rm -f "$f"
echo " $f removed"
fi
done
# ---- Step 7: model files (with big warning) ----
if [ -d models ]; then
echo ""
echo -e "${RED}WARNING: model files (*.gguf) can be gigabytes each.${NC}"
if prompt_yn "Remove all model files from ./models/?" n; then
rm -rf models
echo " models/ removed"
else
echo " models/ preserved"
fi
fi
echo ""
echo -e "${GREEN}cAIc Docker stack teardown complete.${NC}"
echo "Preserved:"
echo " - ./models/ (unless you accepted removal)"
echo ""
echo "Note: Docker Engine itself is not removed. To remove it:"
echo " sudo apt-get remove docker docker-engine docker.io containerd runc"
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env bash
# cAIc — bare-metal/systemd uninstall
# Removes the cAIc install deployed via the README Fresh Install path.
# Run as root or with sudo. Use -y to skip prompts.
set -euo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
SKIP_PROMPT=false
[ "${1:-}" = "-y" ] && SKIP_PROMPT=true
prompt_yn() {
if $SKIP_PROMPT; then return 0; fi
local msg=$1; local default=${2:-n}
if [[ "$default" == "y" ]]; then
read -p "$msg [Y/n] " r; [[ -z "$r" || "$r" =~ ^[Yy] ]]
else
read -p "$msg [y/N] " r; [[ "$r" =~ ^[Yy] ]]
fi
}
warn() { echo -e "${YELLOW}$1${NC}"; }
echo "cAIc Bare-metal / systemd uninstall"
echo "===================================="
# ---- Step 1: systemd service ----
SERVICE_FILE="/etc/systemd/system/caic.service"
if [ -f "$SERVICE_FILE" ]; then
warn "Stopping and disabling caic service..."
systemctl stop caic 2>/dev/null || true
systemctl disable caic 2>/dev/null || true
if prompt_yn "Remove caic systemd service?" n; then
rm -f "$SERVICE_FILE" /etc/systemd/system/caic.service
systemctl daemon-reload
echo " Removed systemd service"
fi
else
echo " Skipped: no systemd service at $SERVICE_FILE"
fi
# ---- Step 2: /opt/caic directory ----
CAIC_DIR="/opt/caic"
if [ -d "$CAIC_DIR" ]; then
if prompt_yn "Remove cAIc installation directory ($CAIC_DIR)?" n; then
rm -rf "$CAIC_DIR"
echo " Removed $CAIC_DIR"
else
warn " Skipped: $CAIC_DIR preserved"
fi
else
echo " Skipped: $CAIC_DIR does not exist"
fi
# ---- Step 3: AMQP secret file ----
if [ -f "/home/gramps/.caic_amqp_secret" ]; then
if prompt_yn "Remove AMQP secret file (/home/gramps/.caic_amqp_secret)?" n; then
rm -f /home/gramps/.caic_amqp_secret
echo " Removed AMQP secret"
fi
fi
# ---- Step 4: Upload temp directory ----
UPLOAD_DIR="/tmp/caic_uploads"
if [ -d "$UPLOAD_DIR" ]; then
if prompt_yn "Remove upload temp directory ($UPLOAD_DIR)?" n; then
rm -rf "$UPLOAD_DIR"
echo " Removed upload temp"
fi
fi
# ---- Step 5: hardware_state.json in cwd ----
if [ -f "hardware_state.json" ]; then
if prompt_yn "Remove cached hardware state?" n; then
rm -f hardware_state.json
echo " Removed cached hardware state"
fi
fi
# ---- Step 6: pip packages if user wants ----
if command -v pip &>/dev/null; then
if prompt_yn "Attempt to uninstall cAIc-related pip packages?" n; then
pip uninstall -y fastapi uvicorn httpx psutil aio-pika jinja2 python-multipart pypdf 2>/dev/null || true
echo " Uninstalled pip packages"
fi
fi
echo ""
echo -e "${GREEN}cAIc bare-metal/uninstall complete.${NC}"
echo "The following may remain:"
echo " - /opt/caic/ (if you chose to preserve)"
echo " - ~/.caic_amqp_secret (if preserved)"
echo " - hardware_state.json (if preserved)"
echo " - installed pip packages (if you chose to skip)"
echo " - Python venv at /opt/caic/venv (removed with /opt/caic)"
echo ""
echo "Caic data stored outside install:"
echo " - caic.db (if configured via CAIC_DB_PATH)"
echo " - claude/opencode .jsonc updates (not tracked by this script)"
+3 -2
View File
@@ -1,5 +1,5 @@
""" """
JarvisChat - SearXNG integration, perplexity scoring, refusal/hedge detection. cAIc - SearXNG integration, perplexity scoring, refusal/hedge detection.
""" """
import logging import logging
import math import math
@@ -10,7 +10,7 @@ import httpx
from config import SEARXNG_BASE, PERPLEXITY_THRESHOLD, REFUSAL_PATTERNS, HEDGE_PATTERNS from config import SEARXNG_BASE, PERPLEXITY_THRESHOLD, REFUSAL_PATTERNS, HEDGE_PATTERNS
log = logging.getLogger("jarvischat") log = logging.getLogger("caic")
def sanitize_outbound_url(url: str) -> str: def sanitize_outbound_url(url: str) -> str:
@@ -117,6 +117,7 @@ async def query_searxng(query: str, max_results: int = 5) -> list:
f"{SEARXNG_BASE}/search", f"{SEARXNG_BASE}/search",
params={"q": query, "format": "json", "categories": "general"}, params={"q": query, "format": "json", "categories": "general"},
timeout=10.0, timeout=10.0,
follow_redirects=True,
) )
if resp.status_code == 200: if resp.status_code == 200:
data = resp.json() data = resp.json()
+26
View File
@@ -0,0 +1,26 @@
# SearXNG configuration — cAIc stack
# Secret key is injected via environment variable.
search:
safe_search: 0
autocomplete: ""
default_lang: en
server:
secret_key: ${SEARXNG_SECRET_KEY}
limiter: false
image_proxy: false
method: GET
port: 8080
bind_address: "0.0.0.0"
engines:
- name: google
engine: google
shortcut: g
- name: duckduckgo
engine: duckduckgo
shortcut: ddg
- name: wikipedia
engine: wikipedia
shortcut: wp
+5 -7
View File
@@ -1,5 +1,5 @@
""" """
JarvisChat - Security utilities. cAIc - Security utilities.
PIN hashing, audit logging, incident tracking, CSRF/origin checks, PIN hashing, audit logging, incident tracking, CSRF/origin checks,
rate limiting, request helpers. rate limiting, request helpers.
""" """
@@ -22,7 +22,7 @@ from fastapi import HTTPException, Request
from config import ( from config import (
ALLOWED_NETWORKS, TRUST_X_FORWARDED_FOR, TRUSTED_ORIGINS, ALLOWED_NETWORKS, TRUST_X_FORWARDED_FOR, TRUSTED_ORIGINS,
BODY_LIMIT_DEFAULT_BYTES, BODY_LIMIT_CHAT_BYTES, BODY_LIMIT_PROFILE_BYTES, BODY_LIMIT_DEFAULT_BYTES, BODY_LIMIT_CHAT_BYTES, BODY_LIMIT_PROFILE_BYTES, BODY_LIMIT_UPLOAD_BYTES,
RATE_WINDOW_SECONDS, RL_LOGIN_PER_WINDOW, RL_CHAT_PER_WINDOW, RATE_WINDOW_SECONDS, RL_LOGIN_PER_WINDOW, RL_CHAT_PER_WINDOW,
RL_SEARCH_PER_WINDOW, RL_STATS_PER_WINDOW, RL_WRITE_PER_WINDOW, RL_SEARCH_PER_WINDOW, RL_STATS_PER_WINDOW, RL_WRITE_PER_WINDOW,
RL_DEFAULT_PER_WINDOW, VERSION, RL_DEFAULT_PER_WINDOW, VERSION,
@@ -30,7 +30,7 @@ from config import (
import ipaddress import ipaddress
log = logging.getLogger("jarvischat") log = logging.getLogger("caic")
SESSIONS: dict = {} SESSIONS: dict = {}
PIN_ATTEMPTS: dict = {} PIN_ATTEMPTS: dict = {}
@@ -114,6 +114,8 @@ def request_body_limit(path: str) -> int:
return BODY_LIMIT_CHAT_BYTES return BODY_LIMIT_CHAT_BYTES
if path == "/api/profile": if path == "/api/profile":
return BODY_LIMIT_PROFILE_BYTES return BODY_LIMIT_PROFILE_BYTES
if path == "/api/upload":
return BODY_LIMIT_UPLOAD_BYTES
return BODY_LIMIT_DEFAULT_BYTES return BODY_LIMIT_DEFAULT_BYTES
@@ -159,10 +161,6 @@ def origin_allowed(request: Request) -> bool:
return False return False
def is_state_changing(method: str) -> bool:
return method in {"POST", "PUT", "DELETE", "PATCH"}
async def read_json_body(request: Request, max_bytes: int) -> dict: async def read_json_body(request: Request, max_bytes: int) -> dict:
raw = await request.body() raw = await request.body()
if len(raw) > max_bytes: if len(raw) > max_bytes:
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

+1221 -250
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
# Ensure the project root is on sys.path so that test modules can import
# top-level packages (app, amqp, cluster, config, …) without PYTHONPATH hacks.
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
"""Shared pytest fixtures.
All test modules manipulate in-process globals (sessions, rate buckets,
cluster registry, eviction log). An autouse fixture resets every global
before each test so no state leaks between tests, regardless of whether an
individual test file remembers to clear it.
"""
import pytest
import cluster
import routers.chat
from eviction import EVICTION_LOG
from security import SESSIONS, PIN_ATTEMPTS, RATE_EVENTS
@pytest.fixture(autouse=True)
def _reset_global_state():
SESSIONS.clear()
PIN_ATTEMPTS.clear()
RATE_EVENTS.clear()
cluster.CLUSTER_NODES.clear()
cluster.CLUSTER_EVENTS.clear()
cluster.CLUSTER_COORDINATOR = None
cluster._pending_pings.clear()
EVICTION_LOG.clear()
routers.chat._ingest_tasks.clear()
yield
+90
View File
@@ -0,0 +1,90 @@
import asyncio
import logging
import amqp
from config import AMQP_EXCHANGE_ADMIN
def _reset():
amqp._connection = None
amqp._channel = None
amqp._lock = asyncio.Lock()
class FakeExchange:
def __init__(self):
self.messages = []
async def publish(self, msg, routing_key):
self.messages.append((msg, routing_key))
class FakeChannel:
def __init__(self):
self.is_closed = False
self.exchanges = {}
async def close(self):
self.is_closed = True
async def declare_exchange(self, name, typ, durable=True):
self.exchanges[name] = typ
async def get_exchange(self, name):
return self.exchanges.setdefault(name, FakeExchange())
class FakeConnection:
def __init__(self):
self.is_closed = False
self._channel = FakeChannel()
async def channel(self):
return self._channel
async def close(self):
self.is_closed = True
async def fake_connect_robust(url, **_):
return FakeConnection()
# ---------- tests ----------
def test_publish_success(monkeypatch):
_reset()
monkeypatch.setattr(amqp, "HAS_AIO_PIKA", True)
monkeypatch.setattr("aio_pika.connect_robust", fake_connect_robust)
asyncio.run(amqp.connect())
ch = asyncio.run(amqp.get_channel())
ex = FakeExchange()
ch.exchanges[AMQP_EXCHANGE_ADMIN] = ex
asyncio.run(amqp.publish(AMQP_EXCHANGE_ADMIN, "test.key", {"foo": "bar"}))
assert len(ex.messages) == 1
msg, rk = ex.messages[0]
assert rk == "test.key"
assert msg.body == b'{"foo": "bar"}'
def test_publish_disconnected_no_raise(caplog):
_reset()
caplog.set_level(logging.ERROR)
asyncio.run(amqp.publish(AMQP_EXCHANGE_ADMIN, "test.key", {"x": 1}))
assert len(caplog.records) > 0
assert any("cannot publish" in r.message for r in caplog.records)
def test_get_channel_reconnects_when_none(monkeypatch):
_reset()
monkeypatch.setattr(amqp, "HAS_AIO_PIKA", True)
monkeypatch.setattr("aio_pika.connect_robust", fake_connect_robust)
ch = asyncio.run(amqp.get_channel())
assert ch is not None
assert not ch.is_closed
+2 -2
View File
@@ -9,8 +9,8 @@ from security import SESSIONS, PIN_ATTEMPTS
def make_client(tmp_path: Path) -> TestClient: def make_client(tmp_path: Path) -> TestClient:
os.environ["JARVISCHAT_ADMIN_PIN"] = "1234" os.environ["CAIC_ADMIN_PIN"] = "1234"
db.DB_PATH = tmp_path / "jarvischat-test.db" db.DB_PATH = tmp_path / "caic-test.db"
SESSIONS.clear() SESSIONS.clear()
PIN_ATTEMPTS.clear() PIN_ATTEMPTS.clear()
db.init_db() db.init_db()
+127 -5
View File
@@ -1,5 +1,6 @@
import json import json
import os import os
import asyncio
from pathlib import Path from pathlib import Path
import httpx import httpx
@@ -12,9 +13,13 @@ import routers.chat
from security import SESSIONS, PIN_ATTEMPTS, RATE_EVENTS from security import SESSIONS, PIN_ATTEMPTS, RATE_EVENTS
def _mock_triage_url(monkeypatch, url: str = config.LLAMA_SERVER_BASE):
monkeypatch.setattr(config, "LLAMA_SERVER_BASE", url)
def make_client(tmp_path: Path) -> TestClient: def make_client(tmp_path: Path) -> TestClient:
os.environ["JARVISCHAT_ADMIN_PIN"] = "1234" os.environ["CAIC_ADMIN_PIN"] = "1234"
db.DB_PATH = tmp_path / "jarvischat-streaming.db" db.DB_PATH = tmp_path / "caic-streaming.db"
SESSIONS.clear() SESSIONS.clear()
PIN_ATTEMPTS.clear() PIN_ATTEMPTS.clear()
RATE_EVENTS.clear() RATE_EVENTS.clear()
@@ -53,6 +58,7 @@ def _stream_json_lines(events: list[dict]) -> list[str]:
def test_chat_stream_emits_tokens_and_done(tmp_path: Path, monkeypatch): def test_chat_stream_emits_tokens_and_done(tmp_path: Path, monkeypatch):
_mock_triage_url(monkeypatch)
with make_client(tmp_path) as client: with make_client(tmp_path) as client:
sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()[ sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()[
"session_id" "session_id"
@@ -88,6 +94,7 @@ def test_chat_stream_emits_tokens_and_done(tmp_path: Path, monkeypatch):
def test_chat_auto_search_trigger_emits_search_events(tmp_path: Path, monkeypatch): def test_chat_auto_search_trigger_emits_search_events(tmp_path: Path, monkeypatch):
_mock_triage_url(monkeypatch)
with make_client(tmp_path) as client: with make_client(tmp_path) as client:
sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()[ sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()[
"session_id" "session_id"
@@ -141,7 +148,73 @@ def test_chat_auto_search_trigger_emits_search_events(tmp_path: Path, monkeypatc
assert done_events and done_events[-1].get("searched") is True assert done_events and done_events[-1].get("searched") is True
def test_chat_with_upload_context_id_injects_document(tmp_path: Path, monkeypatch):
_mock_triage_url(monkeypatch)
captured_payload = {}
def stream_stub(self, method, url, json=None, timeout=None):
nonlocal captured_payload
captured_payload = json
events = [{"message": {"content": "ok"}, "logprobs": [{"logprob": -0.01}]}, {"done": True, "eval_count": 1, "eval_duration": 1000000000}]
return _MockStreamResponse([__import__('json').dumps(e) for e in events])
monkeypatch.setattr(httpx.AsyncClient, "stream", stream_stub)
with make_client(tmp_path) as client:
sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()["session_id"]
headers = {"X-Session-ID": sid, "Origin": "http://testserver"}
db_local = db.get_db()
expires = "2099-12-31T23:59:59+00:00"
cid = db.insert_upload_context(db_local, "conv-up", "report.txt", "Confidential document content here", expires, "text/plain")
db_local.commit()
db_local.close()
resp = client.post(
"/api/chat",
json={"message": "summarize this", "upload_context_id": cid, "model": config.DEFAULT_MODEL},
headers=headers,
)
assert resp.status_code == 200
system_content = next((m["content"] for m in captured_payload.get("messages", []) if m["role"] == "system"), "")
assert "Confidential document content here" in system_content
def test_chat_with_expired_upload_context_id_silent(tmp_path: Path, monkeypatch):
_mock_triage_url(monkeypatch)
captured_payload = {}
def stream_stub(self, method, url, json=None, timeout=None):
nonlocal captured_payload
captured_payload = json
events = [{"message": {"content": "ok"}, "logprobs": [{"logprob": -0.01}]}, {"done": True, "eval_count": 1, "eval_duration": 1000000000}]
return _MockStreamResponse([__import__('json').dumps(e) for e in events])
monkeypatch.setattr(httpx.AsyncClient, "stream", stream_stub)
with make_client(tmp_path) as client:
sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()["session_id"]
headers = {"X-Session-ID": sid, "Origin": "http://testserver"}
import datetime
db_local = db.get_db()
expires = (datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=2)).isoformat()
cid = db.insert_upload_context(db_local, "conv-exp", "old.txt", "Stale data", expires, "text/plain")
db_local.commit()
db_local.close()
resp = client.post(
"/api/chat",
json={"message": "hi", "upload_context_id": cid, "model": config.DEFAULT_MODEL},
headers=headers,
)
assert resp.status_code == 200
system_content = next((m["content"] for m in captured_payload.get("messages", []) if m["role"] == "system"), "")
assert "Stale data" not in system_content
def test_memory_command_paths_remember_and_forget(tmp_path: Path, monkeypatch): def test_memory_command_paths_remember_and_forget(tmp_path: Path, monkeypatch):
_mock_triage_url(monkeypatch)
with make_client(tmp_path) as client: with make_client(tmp_path) as client:
sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()[ sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()[
"session_id" "session_id"
@@ -188,6 +261,55 @@ def test_memory_command_paths_remember_and_forget(tmp_path: Path, monkeypatch):
forget_events = parse_sse_payloads(forget_resp.text) forget_events = parse_sse_payloads(forget_resp.text)
assert any("Forgot" in p.get("token", "") for p in forget_events) assert any("Forgot" in p.get("token", "") for p in forget_events)
memories_after_forget = client.get("/api/memories", headers={"X-Session-ID": sid, "Origin": "http://testserver"}) def test_private_chat_does_not_persist(tmp_path: Path, monkeypatch):
assert memories_after_forget.status_code == 200 monkeypatch.setattr(httpx.AsyncClient, "stream", lambda *a, **kw: _MockStreamResponse([
assert memories_after_forget.json().get("count", 0) == 0 'data: {"choices":[{"delta":{"content":"Hello"},"finish_reason":null,"logprobs":{"content":[]}}]}',
'data: {"choices":[{"delta":{"content":" world"},"finish_reason":null,"logprobs":{"content":[]}}]}',
'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop","logprobs":{"content":[]}}],"usage":{"completion_tokens":2,"prompt_tokens":10,"tokens_per_second":5.0}}',
"data: [DONE]",
]))
async def _mock_ensure(m): return True
monkeypatch.setattr("model_pull.ensure_model", _mock_ensure)
with make_client(tmp_path) as client:
sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()["session_id"]
headers = {"X-Session-ID": sid, "Origin": "http://testserver"}
resp = client.post("/api/chat", json={
"message": "test private message",
"private_chat": True,
}, headers=headers)
assert resp.status_code == 200
events = parse_sse_payloads(resp.text)
tokens = [p for p in events if "token" in p]
assert len(tokens) == 2
done = [p for p in events if p.get("done")]
assert len(done) == 1
conv_resp = client.get("/api/conversations", headers=headers)
assert conv_resp.status_code == 200
assert len(conv_resp.json()) == 0
def test_private_chat_does_not_auto_search(tmp_path: Path, monkeypatch):
"""Private mode should skip auto-search even when search is enabled."""
monkeypatch.setattr(httpx.AsyncClient, "stream", lambda *a, **kw: _MockStreamResponse([
'data: {"choices":[{"delta":{"content":"I don\'t know"},"finish_reason":null,"logprobs":{"content":[{"logprob":-2.5}]}}]}',
'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop","logprobs":{"content":[{"logprob":-2.5}]}}],"usage":{"completion_tokens":1,"prompt_tokens":10,"tokens_per_second":5.0}}',
"data: [DONE]",
]))
monkeypatch.setattr(routers.chat, "query_searxng", lambda q: [{"title": "result"}])
with make_client(tmp_path) as client:
sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()["session_id"]
headers = {"X-Session-ID": sid, "Origin": "http://testserver"}
resp = client.post("/api/chat", json={
"message": "what is the weather",
"private_chat": True,
}, headers=headers)
assert resp.status_code == 200
events = parse_sse_payloads(resp.text)
searching = [p for p in events if p.get("searching")]
assert len(searching) == 0, "private chat should not auto-search"
+313
View File
@@ -0,0 +1,313 @@
"""Tests for cluster.py — no live AMQP, all handlers called directly."""
import asyncio
from collections import deque
import cluster
from config import AMQP_EXCHANGE_ADMIN, AMQP_EXCHANGE_SYSTEM
def _reset():
cluster.CLUSTER_NODES.clear()
cluster.CLUSTER_EVENTS.clear()
cluster.CLUSTER_COORDINATOR = None
cluster._pending_pings.clear()
_published = []
async def _fake_publish(exchange, routing_key, payload):
_published.append((exchange, routing_key, payload))
# ---------- 1. Valid worker registration ----------
def test_valid_worker_registration(monkeypatch):
_reset()
_published.clear()
monkeypatch.setattr(cluster, "publish", _fake_publish)
asyncio.run(cluster.handle_registration(
AMQP_EXCHANGE_ADMIN, "node.jarvis.register",
{"node_name": "jarvis", "node_type": "worker", "capabilities": ["llm"]},
))
assert "jarvis" in cluster.CLUSTER_NODES
assert cluster.CLUSTER_NODES["jarvis"]["type"] == "worker"
assert cluster.CLUSTER_NODES["jarvis"]["status"] == "active"
assert len(cluster.CLUSTER_EVENTS) == 1
assert cluster.CLUSTER_EVENTS[0]["category"] == "cluster"
assert cluster.CLUSTER_EVENTS[0]["message"] == "Node registered (type=worker)"
assert len(_published) == 1
exchange, rk, payload = _published[0]
assert exchange == AMQP_EXCHANGE_ADMIN
assert rk == "node.jarvis.admitted"
assert payload["type"] == "admitted"
assert payload["node_name"] == "jarvis"
# ---------- 2. First coordinator auto-promotion ----------
def test_first_coordinator_auto_promotion(monkeypatch):
_reset()
_published.clear()
monkeypatch.setattr(cluster, "publish", _fake_publish)
asyncio.run(cluster.handle_registration(
AMQP_EXCHANGE_ADMIN, "node.ultron.register",
{"node_name": "ultron", "node_type": "coordinator", "capabilities": ["llm", "rag"]},
))
assert cluster.CLUSTER_COORDINATOR == "ultron"
assert len(cluster.CLUSTER_EVENTS) == 2
assert cluster.CLUSTER_EVENTS[1]["message"] == "Elected as coordinator"
assert len(_published) == 2
# First publish: admitted
assert _published[0][1] == "node.ultron.admitted"
# Second publish: coord_response
exchange, rk, payload = _published[1]
assert exchange == AMQP_EXCHANGE_SYSTEM
assert rk == "cluster.coordinator.response"
assert payload["type"] == "coord_response"
assert payload["coordinator"] == "ultron"
# ---------- 3. Duplicate node name rejected ----------
def test_duplicate_node_rejected(monkeypatch):
_reset()
_published.clear()
monkeypatch.setattr(cluster, "publish", _fake_publish)
asyncio.run(cluster.handle_registration(
AMQP_EXCHANGE_ADMIN, "node.jarvis.register",
{"node_name": "jarvis", "node_type": "worker", "capabilities": ["llm"]},
))
_published.clear()
asyncio.run(cluster.handle_registration(
AMQP_EXCHANGE_ADMIN, "node.jarvis.register",
{"node_name": "jarvis", "node_type": "worker", "capabilities": ["llm"]},
))
assert len(cluster.CLUSTER_NODES) == 1
assert len(_published) == 1
exchange, rk, payload = _published[0]
assert exchange == AMQP_EXCHANGE_ADMIN
assert rk == "node.jarvis.rejected"
assert payload["reason"] == "duplicate_node_name"
# ---------- 4. Malformed payload rejected ----------
def test_malformed_payload_rejected(monkeypatch):
_reset()
_published.clear()
monkeypatch.setattr(cluster, "publish", _fake_publish)
asyncio.run(cluster.handle_registration(
AMQP_EXCHANGE_ADMIN, "node.jarvis.register",
{"node_name": "jarvis"}, # missing node_type and capabilities
))
assert len(cluster.CLUSTER_NODES) == 0
assert len(_published) == 1
exchange, rk, payload = _published[0]
assert rk == "node.jarvis.rejected"
assert payload["reason"] == "malformed_payload"
# ---------- 5. Graceful deregistration ----------
def test_graceful_deregistration(monkeypatch):
_reset()
_published.clear()
monkeypatch.setattr(cluster, "publish", _fake_publish)
asyncio.run(cluster.handle_registration(
AMQP_EXCHANGE_ADMIN, "node.jarvis.register",
{"node_name": "jarvis", "node_type": "worker", "capabilities": ["llm"]},
))
_published.clear()
asyncio.run(cluster.handle_deregistration(
AMQP_EXCHANGE_ADMIN, "node.jarvis.deregister",
{"node_name": "jarvis"},
))
assert "jarvis" not in cluster.CLUSTER_NODES
assert len(cluster.CLUSTER_EVENTS) == 2
assert cluster.CLUSTER_EVENTS[1]["message"] == "Node deregistered"
def test_deregister_coordinator_clears(monkeypatch):
_reset()
_published.clear()
monkeypatch.setattr(cluster, "publish", _fake_publish)
asyncio.run(cluster.handle_registration(
AMQP_EXCHANGE_ADMIN, "node.ultron.register",
{"node_name": "ultron", "node_type": "coordinator", "capabilities": ["llm"]},
))
assert cluster.CLUSTER_COORDINATOR == "ultron"
_published.clear()
asyncio.run(cluster.handle_deregistration(
AMQP_EXCHANGE_ADMIN, "node.ultron.deregister",
{"node_name": "ultron"},
))
assert "ultron" not in cluster.CLUSTER_NODES
assert cluster.CLUSTER_COORDINATOR is None
# ---------- 6. Pong from known node ----------
def test_pong_updates_known_node(monkeypatch):
_reset()
monkeypatch.setattr(cluster, "publish", _fake_publish)
asyncio.run(cluster.handle_registration(
AMQP_EXCHANGE_ADMIN, "node.jarvis.register",
{"node_name": "jarvis", "node_type": "worker", "capabilities": ["llm"]},
))
original_seen = cluster.CLUSTER_NODES["jarvis"]["last_seen"]
asyncio.run(cluster.handle_pong(
AMQP_EXCHANGE_ADMIN, "node.jarvis.pong",
{"node_name": "jarvis", "status": "busy", "load": {"cpu_pct": 80}},
))
assert cluster.CLUSTER_NODES["jarvis"]["status"] == "busy"
assert cluster.CLUSTER_NODES["jarvis"]["load"] == {"cpu_pct": 80}
assert cluster.CLUSTER_NODES["jarvis"]["last_seen"] != original_seen
# ---------- 7. Pong from unknown node ----------
def test_pong_from_unknown_node(caplog, monkeypatch):
_reset()
caplog.set_level("WARNING")
monkeypatch.setattr(cluster, "publish", _fake_publish)
asyncio.run(cluster.handle_pong(
AMQP_EXCHANGE_ADMIN, "node.stranger.pong",
{"node_name": "stranger", "status": "active"},
))
assert "stranger" not in cluster.CLUSTER_NODES
assert any("pong from unknown node" in rec.message for rec in caplog.records)
# ---------- 8. Event stored in log ----------
def test_event_appended_to_log(monkeypatch):
_reset()
monkeypatch.setattr(cluster, "publish", _fake_publish)
asyncio.run(cluster.handle_event(
AMQP_EXCHANGE_SYSTEM, "node.jarvis.event",
{"node_name": "jarvis", "severity": "error", "message": "OOM on GPU"},
))
assert len(cluster.CLUSTER_EVENTS) == 1
ev = cluster.CLUSTER_EVENTS[0]
assert ev["category"] == "application"
assert ev["severity"] == "error"
assert ev["node"] == "jarvis"
def test_event_log_bounded(monkeypatch):
_reset()
monkeypatch.setattr(cluster, "publish", _fake_publish)
for i in range(1001):
cluster._push_event("application", "info", "jarvis", f"event {i}")
assert len(cluster.CLUSTER_EVENTS) == 1000
assert cluster.CLUSTER_EVENTS[0]["message"] == "event 1"
assert cluster.CLUSTER_EVENTS[-1]["message"] == "event 1000"
# ---------- 9. Coordinator query produces response ----------
def test_coordinator_query_response(monkeypatch):
_reset()
_published.clear()
monkeypatch.setattr(cluster, "publish", _fake_publish)
asyncio.run(cluster.handle_registration(
AMQP_EXCHANGE_ADMIN, "node.ultron.register",
{"node_name": "ultron", "node_type": "coordinator", "capabilities": ["llm"]},
))
_published.clear()
asyncio.run(cluster.handle_coordinator_query(
AMQP_EXCHANGE_SYSTEM, "cluster.coordinator.query",
{"from": "jarvis", "type": "coord_query"},
))
assert len(_published) == 1
exchange, rk, payload = _published[0]
assert exchange == AMQP_EXCHANGE_SYSTEM
assert rk == "cluster.coordinator.response"
assert payload["coordinator"] == "ultron"
assert "nodes" in payload
def test_coordinator_query_no_coordinator(monkeypatch):
_reset()
_published.clear()
monkeypatch.setattr(cluster, "publish", _fake_publish)
asyncio.run(cluster.handle_coordinator_query(
AMQP_EXCHANGE_SYSTEM, "cluster.coordinator.query",
{"from": "jarvis", "type": "coord_query"},
))
assert len(_published) == 0
# ---------- 10. GET /api/cluster shape ----------
def test_cluster_api_shape():
_reset()
cluster.CLUSTER_NODES["jarvis"] = {
"name": "jarvis", "type": "worker", "status": "active",
"capabilities": ["llm"], "active_model": None, "load": None,
"registered_at": "2026-01-01T00:00:00Z", "last_seen": "2026-01-01T00:00:00Z",
}
cluster.CLUSTER_COORDINATOR = "ultron"
cluster._push_event("cluster", "info", "ultron", "Elected")
from routers.cluster import cluster_status
resp = asyncio.run(cluster_status())
assert "nodes" in resp
assert "node_count" in resp
assert "coordinator" in resp
assert "events" in resp
assert resp["node_count"] == 1
assert resp["coordinator"] == "ultron"
assert "jarvis" in resp["nodes"]
assert len(resp["events"]) == 1
# No internal keys leaked
for node in resp["nodes"].values():
for key in node:
assert key in {
"name", "type", "status", "capabilities", "active_model",
"load", "registered_at", "last_seen",
}
+49
View File
@@ -0,0 +1,49 @@
"""Tests for cluster.py heartbeat handler."""
import asyncio
import cluster
from config import AMQP_EXCHANGE_SYSTEM
def _reset():
cluster.CLUSTER_NODES.clear()
cluster.CLUSTER_EVENTS.clear()
cluster.CLUSTER_COORDINATOR = None
cluster._pending_pings.clear()
# ---------- 1. handle_heartbeat() for known node updates last_seen ----------
def test_heartbeat_updates_last_seen(monkeypatch):
_reset()
cluster.CLUSTER_NODES["jarvis"] = {
"name": "jarvis", "type": "worker", "status": "active",
"last_seen": "2020-01-01T00:00:00Z",
}
original = cluster.CLUSTER_NODES["jarvis"]["last_seen"]
asyncio.run(cluster.handle_heartbeat(
AMQP_EXCHANGE_SYSTEM, "node.jarvis.heartbeat",
{"node_name": "jarvis"},
))
assert cluster.CLUSTER_NODES["jarvis"]["last_seen"] != original
assert cluster.CLUSTER_NODES["jarvis"]["status"] == "active" # unchanged
# ---------- 2. handle_heartbeat() for unknown node logs warning, no add ----------
def test_heartbeat_unknown_node_logs_warning(caplog, monkeypatch):
_reset()
caplog.set_level("WARNING")
asyncio.run(cluster.handle_heartbeat(
AMQP_EXCHANGE_SYSTEM, "node.stranger.heartbeat",
{"node_name": "stranger"},
))
assert "stranger" not in cluster.CLUSTER_NODES
assert any("unknown node" in rec.message and "stranger" in rec.message for rec in caplog.records)
+3 -3
View File
@@ -13,8 +13,8 @@ from security import SESSIONS, PIN_ATTEMPTS, RATE_EVENTS
def make_client(tmp_path: Path) -> TestClient: def make_client(tmp_path: Path) -> TestClient:
os.environ["JARVISCHAT_ADMIN_PIN"] = "1234" os.environ["CAIC_ADMIN_PIN"] = "1234"
db.DB_PATH = tmp_path / "jarvischat-completions.db" db.DB_PATH = tmp_path / "caic-completions.db"
SESSIONS.clear() SESSIONS.clear()
PIN_ATTEMPTS.clear() PIN_ATTEMPTS.clear()
RATE_EVENTS.clear() RATE_EVENTS.clear()
@@ -22,7 +22,7 @@ def make_client(tmp_path: Path) -> TestClient:
return TestClient(app.app, raise_server_exceptions=False) return TestClient(app.app, raise_server_exceptions=False)
TEST_API_KEY = "test-sk-jarvischat-completions" TEST_API_KEY = "test-sk-caic-completions"
def _auth_headers(extra: dict = None) -> dict: def _auth_headers(extra: dict = None) -> dict:
+2 -2
View File
@@ -9,8 +9,8 @@ from security import SESSIONS, PIN_ATTEMPTS, RATE_EVENTS
def make_client(tmp_path: Path) -> TestClient: def make_client(tmp_path: Path) -> TestClient:
os.environ["JARVISCHAT_ADMIN_PIN"] = "1234" os.environ["CAIC_ADMIN_PIN"] = "1234"
db.DB_PATH = tmp_path / "jarvischat-conversations.db" db.DB_PATH = tmp_path / "caic-conversations.db"
SESSIONS.clear() SESSIONS.clear()
PIN_ATTEMPTS.clear() PIN_ATTEMPTS.clear()
RATE_EVENTS.clear() RATE_EVENTS.clear()
+2 -2
View File
@@ -12,8 +12,8 @@ from security import SESSIONS, PIN_ATTEMPTS, RATE_EVENTS
def make_client(tmp_path: Path) -> TestClient: def make_client(tmp_path: Path) -> TestClient:
os.environ["JARVISCHAT_ADMIN_PIN"] = "1234" os.environ["CAIC_ADMIN_PIN"] = "1234"
db.DB_PATH = tmp_path / "jarvischat-errors.db" db.DB_PATH = tmp_path / "caic-errors.db"
SESSIONS.clear() SESSIONS.clear()
PIN_ATTEMPTS.clear() PIN_ATTEMPTS.clear()
RATE_EVENTS.clear() RATE_EVENTS.clear()
+357
View File
@@ -0,0 +1,357 @@
"""Regression tests for bug fixes.
Covers: /api/ingest origin exemption for CLI/Bearer clients, bogus
conversation_id FK handling in chat + search, the auto-search reset flag,
image uploads being stored as placeholders instead of text-ingested,
false-positive conflict detection, deterministic ingest point IDs,
get_load() VRAM parsing, and version pinning.
"""
import json
import os
import re
import subprocess
from pathlib import Path
import httpx
from fastapi.testclient import TestClient
import app
import config
import db
import memory
from crypto import decrypt_text
import node_agent.agent as agent
import routers.chat
import routers.ingest as ingest_route
import routers.search_route
from security import SESSIONS, PIN_ATTEMPTS, RATE_EVENTS
def make_client(tmp_path: Path) -> TestClient:
os.environ["CAIC_ADMIN_PIN"] = "1234"
db.DB_PATH = tmp_path / "caic-regression.db"
SESSIONS.clear()
PIN_ATTEMPTS.clear()
RATE_EVENTS.clear()
db.init_db()
return TestClient(app.app, raise_server_exceptions=False)
def _guest_headers(client: TestClient) -> dict:
sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()["session_id"]
return {"X-Session-ID": sid, "Origin": "http://testserver"}
def _admin_headers(client: TestClient) -> dict:
login = client.post("/api/auth/login", json={"pin": "1234"}, headers={"Origin": "http://testserver"})
sid = login.json()["session_id"]
return {"X-Session-ID": sid, "Origin": "http://testserver"}
def parse_sse_payloads(body: str) -> list[dict]:
payloads = []
for chunk in body.split("\n\n"):
chunk = chunk.strip()
if not chunk.startswith("data: "):
continue
payloads.append(json.loads(chunk[len("data: "):]))
return payloads
class _MockStreamResponse:
def __init__(self, lines: list[str]):
self._lines = lines
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def aiter_lines(self):
for line in self._lines:
yield line
def _stream_json_lines(events: list[dict]) -> list[str]:
return [json.dumps(event) for event in events]
class _FakeAsyncClient:
class FakeResponse:
def __init__(self, status, json_data=None):
self.status_code = status
self._json = json_data or {}
def json(self):
return self._json
def __init__(self, *a, **kw):
self.put_payloads = []
async def __aenter__(self):
return self
async def __aexit__(self, *a):
pass
async def post(self, url, **kw):
if "/api/embeddings" in url:
return self.FakeResponse(200, {"embedding": [0.1] * 768})
return self.FakeResponse(200)
async def put(self, url, **kw):
self.put_payloads.append(kw.get("json", {}))
return self.FakeResponse(200)
# ── /api/ingest is reached by CLI tools with no Origin header ──────────
def test_ingest_origin_exemption(tmp_path: Path, monkeypatch):
monkeypatch.setattr(ingest_route, "COMPLETIONS_API_KEY", "sk-regression")
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: _FakeAsyncClient())
with make_client(tmp_path) as client:
resp = client.post(
"/api/ingest",
json={"content": "regression test content " * 20, "source": "cli"},
headers={"Authorization": "Bearer sk-regression", "Content-Type": "application/json"},
)
assert resp.status_code == 200, resp.text
assert resp.json()["source"] == "cli"
def test_ingest_bad_key_still_blocked_without_origin(tmp_path: Path, monkeypatch):
monkeypatch.setattr(ingest_route, "COMPLETIONS_API_KEY", "sk-regression")
with make_client(tmp_path) as client:
resp = client.post(
"/api/ingest",
json={"content": "x " * 50},
headers={"Authorization": "Bearer wrong-key", "Content-Type": "application/json"},
)
assert resp.status_code == 401
# ── a client-supplied conversation_id that no longer exists ────────────
def test_chat_bogus_conversation_id_creates_row(tmp_path: Path, monkeypatch):
events = _stream_json_lines([
{"message": {"content": "hi"}, "logprobs": [{"logprob": -0.01}]},
{"done": True, "eval_count": 1, "eval_duration": 1000000000},
])
def stream_stub(self, method, url, json=None, timeout=None):
return _MockStreamResponse(events)
monkeypatch.setattr(httpx.AsyncClient, "stream", stream_stub)
with make_client(tmp_path) as client:
resp = client.post(
"/api/chat",
json={"message": "hello", "conversation_id": "ghost-conv", "model": config.DEFAULT_MODEL},
headers=_guest_headers(client),
)
assert resp.status_code == 200, resp.text
conv_resp = client.get("/api/conversations/ghost-conv", headers=_guest_headers(client))
assert conv_resp.status_code == 200
assert len(conv_resp.json()["messages"]) >= 2
def test_search_bogus_conversation_id_creates_row(tmp_path: Path, monkeypatch):
async def empty_search(query: str, max_results: int = 5):
return []
monkeypatch.setattr(routers.search_route, "query_searxng", empty_search)
with make_client(tmp_path) as client:
resp = client.post(
"/api/search",
json={"query": "nothing here", "conversation_id": "ghost-search", "model": config.DEFAULT_MODEL},
headers=_guest_headers(client),
)
assert resp.status_code == 200, resp.text
conv_resp = client.get("/api/conversations/ghost-search", headers=_guest_headers(client))
assert conv_resp.status_code == 200
assert len(conv_resp.json()["messages"]) >= 1
# ── auto-search augmentation must reset the streamed text ──────────────
def test_auto_search_augmented_event_has_reset_flag(tmp_path: Path, monkeypatch):
first_stream = _stream_json_lines([
{"message": {"content": "I don't have current data on that."}, "logprobs": [{"logprob": -5.0}]},
{"done": True, "eval_count": 2, "eval_duration": 1000000000},
])
second_stream = _stream_json_lines([
{"message": {"content": "According to the search results, the value is forty-two."}},
{"done": True},
])
batches = [first_stream, second_stream]
def stream_stub(self, method, url, json=None, timeout=None):
return _MockStreamResponse(batches.pop(0))
async def search_stub(query: str, max_results: int = 5):
return [{"title": "Answer", "url": "https://example.com", "content": "The value is 42."}]
with make_client(tmp_path) as client:
monkeypatch.setattr(httpx.AsyncClient, "stream", stream_stub)
monkeypatch.setattr(routers.chat, "query_searxng", search_stub)
resp = client.post(
"/api/chat",
json={"message": "what is the latest value", "model": config.DEFAULT_MODEL},
headers=_guest_headers(client),
)
assert resp.status_code == 200, resp.text
payloads = parse_sse_payloads(resp.text)
augmented = [p for p in payloads if p.get("augmented")]
assert augmented, "expected an augmented token event"
assert augmented[0].get("reset") is True
# The augmented token must carry the fresh answer, not the discarded
# first-pass "I don't have current data" text.
assert "According to the search results" in augmented[0]["token"]
assert "I don't have current data" not in augmented[0]["token"]
# ── image uploads are placeholders, never text-ingested ────────────────
def test_upload_image_skips_ingest(tmp_path: Path, monkeypatch):
fake = _FakeAsyncClient()
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: fake)
with make_client(tmp_path) as client:
resp = client.post(
"/api/upload",
headers=_admin_headers(client),
data={"mode": "both"},
files={"file": ("photo.png", b"\x89PNG\r\n\x1a\nfake", "image/png")},
)
assert resp.status_code == 200, resp.text
data = resp.json()
context_id = data["context_id"]
assert data["chunks_ingested"] == 0
assert data["note"]
assert fake.put_payloads == []
assert data["filename"] == "photo.png"
row = db.get_db().execute(
"SELECT content FROM upload_context WHERE id = ?", (context_id,)
).fetchone()
assert row and decrypt_text(row["content"]) == "[Image: photo.png]"
# ── conflict detection needs a shared subject, not just an FTS hit ─────
def test_conflict_detection_requires_shared_subject(tmp_path: Path):
os.environ["CAIC_ADMIN_PIN"] = "1234"
db.DB_PATH = tmp_path / "caic-mem-regression.db"
SESSIONS.clear()
PIN_ATTEMPTS.clear()
RATE_EVENTS.clear()
db.init_db()
memory.add_memory("the cat sat on the mat", "general")
conflicts = memory.check_fact_conflicts(["the dog is brown"])
assert conflicts == []
memory.add_memory("I prefer Rust over Go", "preference")
conflicts = memory.check_fact_conflicts(["I prefer Go over Rust"])
assert len(conflicts) == 1
assert conflicts[0]["new_fact"] == "I prefer Go over Rust"
assert conflicts[0]["old_fact"] == "I prefer Rust over Go"
assert "memory_id" in conflicts[0]
# ── ingest point IDs are deterministic (no duplicate vectors) ──────────
def test_ingest_deterministic_point_ids(tmp_path: Path, monkeypatch):
monkeypatch.setattr(ingest_route, "COMPLETIONS_API_KEY", "sk-regression")
captured_first = []
captured_second = []
class CaptureClient:
FakeResponse = _FakeAsyncClient.FakeResponse
def __init__(self, *a, **kw):
self.capture = None
async def __aenter__(self):
return self
async def __aexit__(self, *a):
pass
async def post(self, url, **kw):
if "/api/embeddings" in url:
return self.FakeResponse(200, {"embedding": [0.2] * 768})
return self.FakeResponse(200)
async def put(self, url, **kw):
payload = kw.get("json", {})
if self.capture is not None:
self.capture.append(payload["points"][0]["id"])
return self.FakeResponse(200)
body = {"content": "alpha beta gamma delta epsilon " * 8, "source": "hook"}
headers = {"Authorization": "Bearer sk-regression", "Content-Type": "application/json"}
fake1 = CaptureClient()
fake1.capture = captured_first
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: fake1)
with make_client(tmp_path) as client:
r1 = client.post("/api/ingest", json=body, headers=headers)
assert r1.status_code == 200, r1.text
fake2 = CaptureClient()
fake2.capture = captured_second
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: fake2)
with make_client(tmp_path) as client:
r2 = client.post("/api/ingest", json=body, headers=headers)
assert r2.status_code == 200, r2.text
assert captured_first and captured_second
assert len(captured_first) == len(captured_second)
assert captured_first == captured_second, "re-ingesting identical content changed point ids"
assert len(set(captured_first)) == len(captured_first)
# ── get_load() VRAM parsing ────────────────────────────────────────────
def test_get_load_vram_parses_rocm_output(monkeypatch):
monkeypatch.setattr(agent, "HAS_PSUTIL", False)
output = (
"======================= ROCm System Management Interface =======================\n"
"GPU[0] : gfx1030\n"
"VRAM Total Used Memory (B): 3221225472\n"
"VRAM Total Memory (B): 17179869184\n"
)
fake = subprocess.CompletedProcess(["rocm-smi", "--showmeminfo", "vram"], 0, output, "")
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: fake)
load = agent.get_load()
assert load["vram_pct"] == 19 # 3 GiB / 16 GiB
def test_get_load_vram_absent_does_not_crash(monkeypatch):
# Regression: rocm-smi returned no parseable VRAM lines, so the old code
# left total/used unbound and raised.
monkeypatch.setattr(agent, "HAS_PSUTIL", False)
output = "======================= ROCm System Management Interface =======================\nNo GPU detected\n"
fake = subprocess.CompletedProcess(["rocm-smi", "--showmeminfo", "vram"], 0, output, "")
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: fake)
load = agent.get_load()
assert "vram_pct" not in load
# ── version pinning ────────────────────────────────────────────────────
def test_version_is_bumped():
assert re.fullmatch(r"v\d+\.\d+\.\d+", config.VERSION)
+107
View File
@@ -0,0 +1,107 @@
import json
import subprocess
import sys
from gpu import get_gpu_stats
def test_linux_gpu_stats_via_rocm_smi(monkeypatch):
monkeypatch.setattr(sys, "platform", "linux")
rocm_json = json.dumps({
"card0": {"GPU use (%)": "42%", "GPU Memory Allocated (VRAM%)": "68%"}
})
class MockProc:
returncode = 0
stdout = rocm_json
class MockSP:
TimeoutExpired = subprocess.TimeoutExpired
run = staticmethod(lambda cmd, **kw: MockProc())
monkeypatch.setattr("gpu.subprocess", MockSP())
stats = get_gpu_stats()
assert stats["gpu_percent"] == 42
assert stats["vram_percent"] == 68
assert stats["available"] is True
def test_linux_gpu_stats_rocm_smi_absent(monkeypatch):
monkeypatch.setattr(sys, "platform", "linux")
class MockSP:
TimeoutExpired = subprocess.TimeoutExpired
run = staticmethod(lambda cmd, **kw: (_ for _ in ()).throw(FileNotFoundError("rocm-smi not found")))
monkeypatch.setattr("gpu.subprocess", MockSP())
stats = get_gpu_stats()
assert stats["gpu_percent"] == 0
assert stats["vram_percent"] == 0
assert stats["available"] is False
def test_darwin_gpu_stats_via_system_profiler(monkeypatch):
monkeypatch.setattr(sys, "platform", "darwin")
sp_output = """Graphics/Displays:
Apple M2 Pro:
Chipset Model: Apple M2 Pro
Type: GPU
Bus: Built-In
Total Number of Cores: 19
VRAM (Dynamic, Max): 16 GB
Displays:
Color LCD:
Display Type: Built-In Retina LCD
Resolution: 3456x2234
"""
class MockProc:
returncode = 0
stdout = sp_output
class MockSP:
TimeoutExpired = subprocess.TimeoutExpired
run = staticmethod(lambda cmd, **kw: MockProc())
monkeypatch.setattr("gpu.subprocess", MockSP())
stats = get_gpu_stats()
assert stats["available"] is True
assert stats["gpu_model"] == "Apple M2 Pro"
assert stats["vram_total_mb"] == 16384
assert stats["gpu_percent"] == 0
assert stats["vram_percent"] == 0
def test_darwin_gpu_stats_system_profiler_absent(monkeypatch):
monkeypatch.setattr(sys, "platform", "darwin")
class MockSP:
TimeoutExpired = subprocess.TimeoutExpired
run = staticmethod(lambda cmd, **kw: (_ for _ in ()).throw(FileNotFoundError("no system_profiler")))
monkeypatch.setattr("gpu.subprocess", MockSP())
stats = get_gpu_stats()
assert stats["available"] is False
assert stats["gpu_percent"] == 0
assert stats["vram_percent"] == 0
def test_darwin_no_gpu_found(monkeypatch):
monkeypatch.setattr(sys, "platform", "darwin")
sp_output = "Graphics/Displays:\n\n No displays found.\n"
class MockProc:
returncode = 0
stdout = sp_output
class MockSP:
TimeoutExpired = subprocess.TimeoutExpired
run = staticmethod(lambda cmd, **kw: MockProc())
monkeypatch.setattr("gpu.subprocess", MockSP())
stats = get_gpu_stats()
assert stats["available"] is False
assert stats["gpu_percent"] == 0
assert stats["vram_percent"] == 0
+236
View File
@@ -0,0 +1,236 @@
import asyncio
import json
import os
import subprocess
import sys
from pathlib import Path
import httpx
import psutil
from fastapi.testclient import TestClient
import app as app_module
import config
import db
import hardware
from security import SESSIONS, PIN_ATTEMPTS, RATE_EVENTS
def make_client(tmp_path: Path) -> TestClient:
os.environ["CAIC_ADMIN_PIN"] = "1234"
db.DB_PATH = tmp_path / "caic-hardware.db"
hardware.HARDWARE_STATE_PATH = tmp_path / "hardware_state.json"
SESSIONS.clear()
PIN_ATTEMPTS.clear()
RATE_EVENTS.clear()
db.init_db()
return TestClient(app_module.app, raise_server_exceptions=False)
def _guest_headers(client: TestClient) -> dict:
sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()["session_id"]
return {"X-Session-ID": sid, "Origin": "http://testserver"}
class _MockGet:
def __init__(self, status_code: int = 200, json_data: dict | None = None):
self.status_code = status_code
self._json_data = json_data or {}
def json(self):
return self._json_data
def _mock_subprocess(rocm_stdout: str = "") -> object:
class MockProc:
returncode = 0
stdout = rocm_stdout
class MockSP:
TimeoutExpired = subprocess.TimeoutExpired
run = staticmethod(lambda cmd, **kw: MockProc())
return MockSP()
def _broken_subprocess(exception: Exception) -> object:
class MockSP:
TimeoutExpired = subprocess.TimeoutExpired
run = staticmethod(lambda cmd, **kw: (_ for _ in ()).throw(exception))
return MockSP()
def test_assess_hardware_all_services_reachable(tmp_path: Path, monkeypatch):
hardware.HARDWARE_STATE_PATH = tmp_path / "hardware_state.json"
monkeypatch.setattr(psutil, "virtual_memory", lambda: type("M", (), {"total": 16 * 1024 ** 3, "available": 8 * 1024 ** 3})())
monkeypatch.setattr(psutil, "cpu_count", lambda: 8)
monkeypatch.setattr(hardware, "subprocess", _mock_subprocess(
json.dumps({"card0": {"VRAM Total (MB)": 8192, "VRAM Free (MB)": 4096}})
))
async def mock_get(self, url, *args, **kwargs):
if "v1/models" in url:
return _MockGet(200, {"data": [{"id": "mistral-nemo:latest"}]})
if "6333" in url:
return _MockGet(200, {"result": {"collections": [{"name": "caic"}]}})
if "8888" in url:
return _MockGet(200, {})
return _MockGet(200, {})
monkeypatch.setattr(httpx.AsyncClient, "get", mock_get)
state = asyncio.run(hardware.assess_hardware())
assert state["ram_total_gb"] == 16.0
assert state["ram_available_gb"] == 8.0
assert state["cpu_count"] == 8
assert state["vram_total_mb"] == 8192
assert state["vram_free_mb"] == 4096
assert state["llama_reachable"] is True
assert state["llama_models"] == ["mistral-nemo:latest"]
assert state["qdrant_reachable"] is True
assert state["qdrant_collections"] == ["caic"]
assert state["searxng_reachable"] is True
assert tmp_path.joinpath("hardware_state.json").exists()
def test_assess_hardware_rocm_smi_absent(tmp_path: Path, monkeypatch):
hardware.HARDWARE_STATE_PATH = tmp_path / "hardware_state.json"
monkeypatch.setattr(psutil, "virtual_memory", lambda: type("M", (), {"total": 16 * 1024 ** 3, "available": 8 * 1024 ** 3})())
monkeypatch.setattr(psutil, "cpu_count", lambda: 8)
monkeypatch.setattr(hardware, "subprocess", _broken_subprocess(FileNotFoundError("no rocm-smi")))
async def mock_get(self, url, *args, **kwargs):
if "v1/models" in url:
return _MockGet(200, {"data": []})
if "6333" in url:
return _MockGet(200, {"result": {"collections": []}})
if "8888" in url:
return _MockGet(200, {})
return _MockGet(200, {})
monkeypatch.setattr(httpx.AsyncClient, "get", mock_get)
state = asyncio.run(hardware.assess_hardware())
assert state["vram_total_mb"] == 0
assert state["vram_free_mb"] == 0
assert state["llama_reachable"] is True
assert state["qdrant_reachable"] is True
assert state["searxng_reachable"] is True
def test_assess_hardware_llama_unreachable(tmp_path: Path, monkeypatch):
hardware.HARDWARE_STATE_PATH = tmp_path / "hardware_state.json"
monkeypatch.setattr(psutil, "virtual_memory", lambda: type("M", (), {"total": 16 * 1024 ** 3, "available": 8 * 1024 ** 3})())
monkeypatch.setattr(psutil, "cpu_count", lambda: 8)
monkeypatch.setattr(hardware, "subprocess", _mock_subprocess(
json.dumps({"card0": {"VRAM Total (MB)": 8192, "VRAM Free (MB)": 4096}})
))
async def mock_get(self, url, *args, **kwargs):
if "v1/models" in url:
raise httpx.ConnectError("refused")
if "6333" in url:
return _MockGet(200, {"result": {"collections": [{"name": "caic"}]}})
if "8888" in url:
return _MockGet(200, {})
return _MockGet(200, {})
monkeypatch.setattr(httpx.AsyncClient, "get", mock_get)
state = asyncio.run(hardware.assess_hardware())
assert state["llama_reachable"] is False
assert state["llama_models"] == []
assert state["qdrant_reachable"] is True
assert state["searxng_reachable"] is True
def test_get_hardware_endpoint(tmp_path: Path, monkeypatch):
hardware.HARDWARE_STATE_PATH = tmp_path / "hardware_state.json"
monkeypatch.setattr(psutil, "virtual_memory", lambda: type("M", (), {"total": 16 * 1024 ** 3, "available": 8 * 1024 ** 3})())
monkeypatch.setattr(psutil, "cpu_count", lambda: 8)
monkeypatch.setattr(hardware, "subprocess", _mock_subprocess(
json.dumps({"card0": {"VRAM Total (MB)": 8192, "VRAM Free (MB)": 4096}})
))
async def mock_get(self, url, *args, **kwargs):
if "v1/models" in url:
return _MockGet(200, {"data": [{"id": "mistral-nemo:latest"}]})
if "6333" in url:
return _MockGet(200, {"result": {"collections": [{"name": "caic"}]}})
if "8888" in url:
return _MockGet(200, {})
return _MockGet(200, {})
monkeypatch.setattr(httpx.AsyncClient, "get", mock_get)
with make_client(tmp_path) as client:
resp = client.get("/api/hardware", headers=_guest_headers(client))
assert resp.status_code == 200
data = resp.json()
assert data["ram_total_gb"] == 16.0
assert data["cpu_count"] == 8
assert data["vram_total_mb"] == 8192
assert data["llama_reachable"] is True
assert data["qdrant_reachable"] is True
assert data["searxng_reachable"] is True
assert "llama_models" in data
assert "qdrant_collections" in data
def test_assess_hardware_darwin(tmp_path: Path, monkeypatch):
hardware.HARDWARE_STATE_PATH = tmp_path / "hardware_state.json"
monkeypatch.setattr(psutil, "virtual_memory", lambda: type("M", (), {"total": 16 * 1024 ** 3, "available": 8 * 1024 ** 3})())
monkeypatch.setattr(psutil, "cpu_count", lambda: 8)
monkeypatch.setattr(sys, "platform", "darwin")
monkeypatch.setattr(hardware, "_get_vram_darwin", lambda: (16384, 16384))
async def mock_get(self, url, *args, **kwargs):
if "v1/models" in url:
return _MockGet(200, {"data": [{"id": "qwen2.5:latest"}]})
if "6333" in url:
return _MockGet(200, {"result": {"collections": [{"name": "caic"}]}})
if "8888" in url:
return _MockGet(200, {})
return _MockGet(200, {})
monkeypatch.setattr(httpx.AsyncClient, "get", mock_get)
state = asyncio.run(hardware.assess_hardware())
assert state["vram_total_mb"] == 16384
assert state["vram_free_mb"] == 16384
assert state["ram_total_gb"] == 16.0
assert state["llama_reachable"] is True
def test_get_vram_darwin_parse(monkeypatch):
sp_output = """Graphics/Displays:
Apple M2 Pro:
Chipset Model: Apple M2 Pro
VRAM (Dynamic, Max): 16 GB
"""
class MockProc:
returncode = 0
stdout = sp_output
class MockSP:
TimeoutExpired = subprocess.TimeoutExpired
run = staticmethod(lambda cmd, **kw: MockProc())
monkeypatch.setattr(hardware, "subprocess", MockSP())
total, free = hardware._get_vram_darwin()
assert total == 16384
assert free == 16384
def test_get_vram_darwin_system_profiler_absent(monkeypatch):
class MockSP:
TimeoutExpired = subprocess.TimeoutExpired
run = staticmethod(lambda cmd, **kw: (_ for _ in ()).throw(FileNotFoundError("no system_profiler")))
monkeypatch.setattr(hardware, "subprocess", MockSP())
total, free = hardware._get_vram_darwin()
assert total == 0
assert free == 0
+689
View File
@@ -0,0 +1,689 @@
"""Tests for image generation — cluster handlers, router, node agent, hardware probe."""
import asyncio
import base64
import json
import os
from contextlib import asynccontextmanager
from pathlib import Path
from unittest.mock import AsyncMock, patch
import httpx
import psutil
from fastapi.testclient import TestClient
import app as app_module
import cluster
import config
import db
import hardware
import node_agent.agent as agent
from config import AMQP_EXCHANGE_ADMIN, AMQP_EXCHANGE_SYSTEM
from security import SESSIONS, PIN_ATTEMPTS, RATE_EVENTS
# ── helpers ──────────────────────────────────────────────────────────────
def _reset():
cluster.CLUSTER_NODES.clear()
cluster.CLUSTER_EVENTS.clear()
cluster.CLUSTER_COORDINATOR = None
cluster._pending_pings.clear()
cluster._pending_image.clear()
_published = []
async def _fake_publish(exchange, routing_key, payload):
_published.append((exchange, routing_key, payload))
def make_client(tmp_path: Path) -> TestClient:
os.environ["CAIC_ADMIN_PIN"] = "1234"
db.DB_PATH = tmp_path / "caic-image.db"
hardware.HARDWARE_STATE_PATH = tmp_path / "hardware_state.json"
SESSIONS.clear()
PIN_ATTEMPTS.clear()
RATE_EVENTS.clear()
db.init_db()
return TestClient(app_module.app, raise_server_exceptions=False)
def _guest_headers(client: TestClient) -> dict:
sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()["session_id"]
return {"X-Session-ID": sid, "Origin": "http://testserver"}
def _admin_headers(client: TestClient) -> dict:
resp = client.post("/api/auth/login", json={"pin": "1234"}, headers={"Origin": "http://testserver"})
sid = resp.json()["session_id"]
return {"X-Session-ID": sid, "Origin": "http://testserver"}
class FakeMsg:
def __init__(self, body_dict: dict):
self.body = json.dumps(body_dict).encode()
@asynccontextmanager
async def process(self):
yield
class FakeExchange:
def __init__(self, name=""):
self.name = name
self.published = []
async def publish(self, msg, routing_key):
self.published.append((msg, routing_key))
class FakeChannel:
def __init__(self):
self.exchanges = {}
self.is_closed = False
async def declare_exchange(self, name, typ, durable=True):
self.exchanges[name] = FakeExchange(name)
return self.exchanges[name]
async def declare_queue(self, name="", exclusive=True):
return self
async def bind(self, exchange, routing_key):
pass
# ── 1. cluster.handle_image_generated resolves pending request ───────────
def test_handle_image_generated_resolves_pending(monkeypatch):
_reset()
monkeypatch.setattr(cluster, "publish", _fake_publish)
cluster.CLUSTER_NODES["corsair"] = {
"name": "corsair", "type": "worker", "status": "active",
"capabilities": ["image_gen"],
}
event = asyncio.Event()
cluster._pending_image["req-123"] = ("", event)
asyncio.run(cluster.handle_image_generated(
AMQP_EXCHANGE_SYSTEM, "node.corsair.image_generated",
{"node_name": "corsair", "request_id": "req-123", "image_base64": "aW1hZ2U="},
))
assert event.is_set()
result = cluster._pending_image.get("req-123")
assert result is not None
assert result[0] == "aW1hZ2U="
assert cluster.CLUSTER_NODES["corsair"]["last_seen"] is not None
# ── 2. cluster.handle_image_failed resolves pending request ─────────────
def test_handle_image_failed_resolves_pending(monkeypatch):
_reset()
monkeypatch.setattr(cluster, "publish", _fake_publish)
cluster.CLUSTER_NODES["corsair"] = {
"name": "corsair", "type": "worker", "status": "active",
}
event = asyncio.Event()
cluster._pending_image["req-456"] = ("", event)
asyncio.run(cluster.handle_image_failed(
AMQP_EXCHANGE_SYSTEM, "node.corsair.image_failed",
{"node_name": "corsair", "request_id": "req-456", "error": "timeout"},
))
assert event.is_set()
result = cluster._pending_image.get("req-456")
assert result is not None
assert result[0] == ""
# ── 3. cluster.handle_image_failed unknown node ─────────────────────────
def test_handle_image_failed_unknown_node(caplog, monkeypatch):
_reset()
caplog.set_level("WARNING")
monkeypatch.setattr(cluster, "publish", _fake_publish)
asyncio.run(cluster.handle_image_failed(
AMQP_EXCHANGE_SYSTEM, "node.ghost.image_failed",
{"node_name": "ghost", "request_id": "x", "error": "boom"},
))
assert not any("unknown node" in rec.message for rec in caplog.records)
# ── 4. cluster.request_image_generate publishes command ──────────────────
def test_request_image_generate_publishes_command(monkeypatch):
_reset()
_published.clear()
monkeypatch.setattr(cluster, "publish", _fake_publish)
cluster.CLUSTER_NODES["corsair"] = {
"name": "corsair", "type": "worker", "status": "active",
"capabilities": ["image_gen"],
}
# Simulate immediate completion
async def fake_wait():
cluster._pending_image.clear()
original_wait_for = asyncio.wait_for
async def patched_wait_for(coro, timeout):
cluster._pending_image["fake-id"] = ("aW1hZ2U=", asyncio.Event())
cluster._pending_image["fake-id"][1].set()
return None
monkeypatch.setattr(asyncio, "wait_for", patched_wait_for)
result = asyncio.run(cluster.request_image_generate(
"corsair", "a red dragon", width=512, height=512, steps=10,
))
assert len(_published) == 1
exchange, rk, payload = _published[0]
assert exchange == AMQP_EXCHANGE_ADMIN
assert rk == "node.corsair.cmd.image_generate"
assert payload["prompt"] == "a red dragon"
assert payload["width"] == 512
assert payload["height"] == 512
assert payload["steps"] == 10
assert "request_id" in payload
# ── 5. cluster.request_image_generate unknown node ──────────────────────
def test_request_image_generate_unknown_node(monkeypatch):
_reset()
_published.clear()
monkeypatch.setattr(cluster, "publish", _fake_publish)
result = asyncio.run(cluster.request_image_generate("ghost", "prompt"))
assert result is None
assert len(_published) == 0
# ── 6. cluster.request_image_generate node lacks capability ─────────────
def test_request_image_generate_no_capability(monkeypatch):
_reset()
_published.clear()
monkeypatch.setattr(cluster, "publish", _fake_publish)
cluster.CLUSTER_NODES["jarvis"] = {
"name": "jarvis", "type": "worker", "status": "active",
"capabilities": ["llm"],
}
result = asyncio.run(cluster.request_image_generate("jarvis", "prompt"))
assert result is None
assert len(_published) == 0
# ── 7. cluster.request_image_generate timeout ───────────────────────────
def test_request_image_generate_timeout(monkeypatch):
_reset()
_published.clear()
monkeypatch.setattr(cluster, "publish", _fake_publish)
cluster.CLUSTER_NODES["corsair"] = {
"name": "corsair", "type": "worker", "status": "active",
"capabilities": ["image_gen"],
}
async def timeout_wait(coro, timeout):
raise asyncio.TimeoutError()
monkeypatch.setattr(asyncio, "wait_for", timeout_wait)
result = asyncio.run(cluster.request_image_generate("corsair", "prompt", timeout=1))
assert result is None
assert len(cluster._pending_image) == 0
# ── 8. _find_image_node selects active image_gen node ───────────────────
def test_find_image_node_selects_active():
from routers.image import _find_image_node
_reset()
cluster.CLUSTER_NODES["corsair"] = {
"name": "corsair", "type": "worker", "status": "active",
"capabilities": ["image_gen"],
}
cluster.CLUSTER_NODES["jarvis"] = {
"name": "jarvis", "type": "worker", "status": "active",
"capabilities": ["llm"],
}
assert _find_image_node() == "corsair"
def test_find_image_node_skips_inactive():
from routers.image import _find_image_node
_reset()
cluster.CLUSTER_NODES["corsair"] = {
"name": "corsair", "type": "worker", "status": "error",
"capabilities": ["image_gen"],
}
assert _find_image_node() is None
def test_find_image_node_no_image_gen():
from routers.image import _find_image_node
_reset()
cluster.CLUSTER_NODES["jarvis"] = {
"name": "jarvis", "type": "worker", "status": "active",
"capabilities": ["llm"],
}
assert _find_image_node() is None
# ── 9. POST /api/image/generate — no node available ─────────────────────
def test_image_generate_no_node_503(tmp_path):
_reset()
with make_client(tmp_path) as client:
headers = _admin_headers(client)
resp = client.post("/api/image/generate", json={"prompt": "test"}, headers=headers)
assert resp.status_code == 503
assert "No image generation service" in resp.json()["detail"]
# ── 10. POST /api/image/generate — empty prompt ─────────────────────────
def test_image_generate_empty_prompt_400(tmp_path):
_reset()
with make_client(tmp_path) as client:
headers = _admin_headers(client)
resp = client.post("/api/image/generate", json={"prompt": ""}, headers=headers)
assert resp.status_code == 400
assert "Prompt is required" in resp.json()["detail"]
# ── 11. POST /api/image/generate — happy path ──────────────────────────
def test_image_generate_happy_path(tmp_path, monkeypatch):
_reset()
cluster.CLUSTER_NODES["corsair"] = {
"name": "corsair", "type": "worker", "status": "active",
"capabilities": ["image_gen"],
}
fake_png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
fake_b64 = base64.b64encode(fake_png).decode()
async def fake_request_image_generate(**kwargs):
return fake_b64
monkeypatch.setattr("routers.image.request_image_generate", fake_request_image_generate)
with make_client(tmp_path) as client:
headers = _admin_headers(client)
resp = client.post("/api/image/generate", json={
"prompt": "a red dragon",
"width": 512,
"height": 512,
}, headers=headers)
assert resp.status_code == 200
assert resp.headers["content-type"] == "image/png"
assert resp.content == fake_png
# ── 12. POST /api/image/generate — generation failed ───────────────────
def test_image_generate_timeout_504(tmp_path, monkeypatch):
_reset()
cluster.CLUSTER_NODES["corsair"] = {
"name": "corsair", "type": "worker", "status": "active",
"capabilities": ["image_gen"],
}
async def fake_request_image_generate(**kwargs):
return None
monkeypatch.setattr("routers.image.request_image_generate", fake_request_image_generate)
with make_client(tmp_path) as client:
headers = _admin_headers(client)
resp = client.post("/api/image/generate", json={"prompt": "test"}, headers=headers)
assert resp.status_code == 504
# ── 13. GET /api/image/status — available ──────────────────────────────
def test_image_status_available(tmp_path):
_reset()
cluster.CLUSTER_NODES["corsair"] = {
"name": "corsair", "type": "worker", "status": "active",
"capabilities": ["image_gen"],
"load": {"gpu_pct": 30},
"last_seen": "2026-07-27T00:00:00Z",
}
with make_client(tmp_path) as client:
headers = _guest_headers(client)
resp = client.get("/api/image/status", headers=headers)
assert resp.status_code == 200
data = resp.json()
assert data["available"] is True
assert len(data["nodes"]) == 1
assert data["nodes"][0]["name"] == "corsair"
# ── 14. GET /api/image/status — no nodes ───────────────────────────────
def test_image_status_unavailable(tmp_path):
_reset()
with make_client(tmp_path) as client:
headers = _guest_headers(client)
resp = client.get("/api/image/status", headers=headers)
assert resp.status_code == 200
data = resp.json()
assert data["available"] is False
assert len(data["nodes"]) == 0
# ── 15. node_agent.detect_capabilities — ComfyUI present ───────────────
def test_detect_capabilities_comfyui_present(monkeypatch):
cfg = agent.AgentConfig()
cfg.comfyui_port = 8188
monkeypatch.setattr(agent, "HAS_HTTPX", True)
def fake_get(url, timeout=3):
if "system_stats" in url:
class R:
status_code = 200
return R()
raise httpx.ConnectError("refused")
monkeypatch.setattr(httpx, "get", fake_get)
caps = agent.detect_capabilities(cfg)
assert "image_gen" in caps
assert "llm" in caps
# ── 16. node_agent.detect_capabilities — ComfyUI absent ────────────────
def test_detect_capabilities_comfyui_absent(monkeypatch):
cfg = agent.AgentConfig()
cfg.comfyui_port = 8188
monkeypatch.setattr(agent, "HAS_HTTPX", True)
monkeypatch.setattr(httpx, "get", lambda url, timeout=3: (_ for _ in ()).throw(httpx.ConnectError("refused")))
caps = agent.detect_capabilities(cfg)
assert "image_gen" not in caps
assert "llm" in caps
# ── 17. node_agent.detect_capabilities — httpx not installed ────────────
def test_detect_capabilities_no_httpx(monkeypatch):
cfg = agent.AgentConfig()
monkeypatch.setattr(agent, "HAS_HTTPX", False)
caps = agent.detect_capabilities(cfg)
assert "image_gen" not in caps
# ── 18. node_agent.handle_image_generate — success ─────────────────────
def test_node_agent_handle_image_generate_success(monkeypatch):
monkeypatch.setattr(agent, "HAS_AIO_PIKA", True)
monkeypatch.setattr(agent, "HAS_HTTPX", True)
cfg = agent.AgentConfig()
cfg.node_name = "corsair"
cfg.comfyui_port = 8188
fake_png = b"\x89PNG" + b"\x00" * 50
fake_b64 = base64.b64encode(fake_png).decode()
async def fake_comfyui_generate(*a, **kw):
return fake_b64
monkeypatch.setattr(agent, "_comfyui_generate", fake_comfyui_generate)
channel = FakeChannel()
system_ex = FakeExchange("jc.system")
channel.exchanges["jc.system"] = system_ex
asyncio.run(agent.handle_image_generate(
cfg, channel, (FakeExchange(), system_ex),
FakeMsg({
"request_id": "req-789",
"prompt": "a castle",
"negative_prompt": "",
"width": 1024,
"height": 1024,
"steps": 20,
"seed": 42,
"model": "",
}),
))
assert len(system_ex.published) == 1
msg, rk = system_ex.published[0]
assert rk == "node.corsair.image_generated"
payload = json.loads(msg.body)
assert payload["type"] == "image_generated"
assert payload["request_id"] == "req-789"
assert payload["image_base64"] == fake_b64
# ── 19. node_agent.handle_image_generate — failure ─────────────────────
def test_node_agent_handle_image_generate_failure(monkeypatch):
monkeypatch.setattr(agent, "HAS_AIO_PIKA", True)
monkeypatch.setattr(agent, "HAS_HTTPX", True)
cfg = agent.AgentConfig()
cfg.node_name = "corsair"
async def fake_comfyui_generate(*a, **kw):
raise RuntimeError("ComfyUI crashed")
monkeypatch.setattr(agent, "_comfyui_generate", fake_comfyui_generate)
channel = FakeChannel()
system_ex = FakeExchange("jc.system")
channel.exchanges["jc.system"] = system_ex
asyncio.run(agent.handle_image_generate(
cfg, channel, (FakeExchange(), system_ex),
FakeMsg({"request_id": "req-fail", "prompt": "test"}),
))
assert len(system_ex.published) == 1
msg, rk = system_ex.published[0]
assert rk == "node.corsair.image_failed"
payload = json.loads(msg.body)
assert payload["type"] == "image_failed"
assert "ComfyUI crashed" in payload["error"]
# ── 20. node_agent.handle_image_generate — empty prompt ────────────────
def test_node_agent_handle_image_generate_empty_prompt(monkeypatch):
monkeypatch.setattr(agent, "HAS_AIO_PIKA", True)
cfg = agent.AgentConfig()
cfg.node_name = "corsair"
channel = FakeChannel()
system_ex = FakeExchange("jc.system")
asyncio.run(agent.handle_image_generate(
cfg, channel, (FakeExchange(), system_ex),
FakeMsg({"request_id": "req-x", "prompt": ""}),
))
assert len(system_ex.published) == 0
# ── 21. hardware.py — ComfyUI reachable ────────────────────────────────
def test_assess_hardware_comfyui_reachable(tmp_path, monkeypatch):
hardware.HARDWARE_STATE_PATH = tmp_path / "hw.json"
monkeypatch.setattr(psutil, "virtual_memory", lambda: type("M", (), {"total": 16 * 1024 ** 3, "available": 8 * 1024 ** 3})())
monkeypatch.setattr(psutil, "cpu_count", lambda: 8)
class MockProc:
returncode = 1
stdout = ""
monkeypatch.setattr(hardware.subprocess, "run", lambda cmd, **kw: MockProc())
async def mock_get(self, url, *args, **kwargs):
class R:
status_code = 200
def json(self):
if "CheckpointLoaderSimple" in url:
return {"CheckpointLoaderSimple": {"input": {"required": {"ckpt_name": [["model.safetensors", "other.ckpt"]]}}}}
return {}
def raise_for_status(self):
pass
if "8188" in url:
return R()
if "v1/models" in url:
class R2:
status_code = 200
def json(self):
return {"data": []}
return R2()
if "6333" in url:
class R3:
status_code = 200
def json(self):
return {"result": {"collections": []}}
return R3()
if "8888" in url:
class R4:
status_code = 200
return R4()
class R5:
status_code = 200
def json(self):
return {}
return R5()
monkeypatch.setattr(httpx.AsyncClient, "get", mock_get)
state = asyncio.run(hardware.assess_hardware())
assert state["comfyui_reachable"] is True
assert "model.safetensors" in state["comfyui_models"]
# ── 22. hardware.py — ComfyUI unreachable ──────────────────────────────
def test_assess_hardware_comfyui_unreachable(tmp_path, monkeypatch):
hardware.HARDWARE_STATE_PATH = tmp_path / "hw.json"
monkeypatch.setattr(psutil, "virtual_memory", lambda: type("M", (), {"total": 16 * 1024 ** 3, "available": 8 * 1024 ** 3})())
monkeypatch.setattr(psutil, "cpu_count", lambda: 8)
class MockProc:
returncode = 1
stdout = ""
monkeypatch.setattr(hardware.subprocess, "run", lambda cmd, **kw: MockProc())
async def mock_get(self, url, *args, **kwargs):
if "8188" in url:
raise httpx.ConnectError("refused")
if "v1/models" in url:
class R2:
status_code = 200
def json(self):
return {"data": []}
return R2()
if "6333" in url:
class R3:
status_code = 200
def json(self):
return {"result": {"collections": []}}
return R3()
if "8888" in url:
class R4:
status_code = 200
return R4()
class R5:
status_code = 200
def json(self):
return {}
return R5()
monkeypatch.setattr(httpx.AsyncClient, "get", mock_get)
state = asyncio.run(hardware.assess_hardware())
assert state["comfyui_reachable"] is False
assert state["comfyui_models"] == []
# ── 23. node_agent config reads comfyui_port ───────────────────────────
def test_config_from_ini_comfyui_port(tmp_path):
ini = tmp_path / "caic-node-agent.conf"
ini.write_text(
"[agent]\n"
"node_name = corsair\n"
"capabilities = llm,image_gen\n"
"comfyui_port = 8188\n"
)
cfg = agent.AgentConfig.from_ini(str(ini))
assert cfg.comfyui_port == 8188
assert "image_gen" in cfg.capabilities
def test_config_from_ini_comfyui_port_default():
cfg = agent.AgentConfig()
assert cfg.comfyui_port == 8188
# ── 24. SUBSCRIBE_TABLE includes image gen handlers ────────────────────
def test_subscribe_table_includes_image_handlers():
routing_keys = [rks for _, rks, _ in cluster.SUBSCRIBE_TABLE]
all_keys = [rk for rks in routing_keys for rk in rks]
assert "node.*.image_generated" in all_keys
assert "node.*.image_failed" in all_keys
+104
View File
@@ -0,0 +1,104 @@
import json
import os
from pathlib import Path
import httpx
from fastapi.testclient import TestClient
import app
import db
import routers.ingest as ingest_route
from security import SESSIONS, PIN_ATTEMPTS, RATE_EVENTS
def make_client(tmp_path: Path) -> TestClient:
os.environ["CAIC_ADMIN_PIN"] = "1234"
db.DB_PATH = tmp_path / "caic-ingest.db"
SESSIONS.clear()
PIN_ATTEMPTS.clear()
RATE_EVENTS.clear()
db.init_db()
return TestClient(app.app, raise_server_exceptions=False)
TEST_API_KEY = "test-sk-caic-ingest"
def _auth_headers() -> dict:
return {"Authorization": f"Bearer {TEST_API_KEY}", "Content-Type": "application/json", "Origin": "http://testserver"}
def test_ingest_missing_api_key(tmp_path: Path):
with make_client(tmp_path) as client:
resp = client.post("/api/ingest", json={"content": "test"}, headers={"Origin": "http://testserver"})
assert resp.status_code == 401
def test_ingest_wrong_api_key(tmp_path: Path):
with make_client(tmp_path) as client:
resp = client.post("/api/ingest", json={"content": "test"},
headers={"Authorization": "Bearer wrong", "Origin": "http://testserver"})
assert resp.status_code == 401
def test_ingest_empty_content(tmp_path: Path):
monkeypatch = __import__('pytest').MonkeyPatch()
monkeypatch.setattr(ingest_route, "COMPLETIONS_API_KEY", TEST_API_KEY)
with make_client(tmp_path) as client:
resp = client.post("/api/ingest", json={"content": ""}, headers=_auth_headers())
assert resp.status_code == 422
monkeypatch.undo()
def test_ingest_missing_content(tmp_path: Path):
monkeypatch = __import__('pytest').MonkeyPatch()
monkeypatch.setattr(ingest_route, "COMPLETIONS_API_KEY", TEST_API_KEY)
with make_client(tmp_path) as client:
resp = client.post("/api/ingest", json={}, headers=_auth_headers())
assert resp.status_code == 422
monkeypatch.undo()
def test_ingest_success(tmp_path: Path, monkeypatch):
monkeypatch.setattr(ingest_route, "COMPLETIONS_API_KEY", TEST_API_KEY)
embed_count = 0
class FakeAsyncClient:
class FakeResponse:
def __init__(self, status, json_data=None):
self.status_code = status
self._json = json_data or {}
def json(self):
return self._json
def __init__(self, *a, **kw):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *a):
pass
async def post(self, url, **kw):
nonlocal embed_count
if "/api/embeddings" in url:
embed_count += 1
return self.FakeResponse(200, {"embedding": [0.1] * 768})
return self.FakeResponse(200)
async def put(self, url, **kw):
return self.FakeResponse(200)
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: FakeAsyncClient())
with make_client(tmp_path) as client:
resp = client.post("/api/ingest", json={"content": "test " * 1000, "source": "terminal"}, headers=_auth_headers())
assert resp.status_code == 200
data = resp.json()
assert data["source"] == "terminal"
assert data["chunks_ingested"] > 0
assert embed_count == data["chunks_ingested"]
assert "message" in data
+2 -2
View File
@@ -9,8 +9,8 @@ from security import SESSIONS, PIN_ATTEMPTS, RATE_EVENTS, is_ip_allowed
def make_client(tmp_path: Path) -> TestClient: def make_client(tmp_path: Path) -> TestClient:
os.environ["JARVISCHAT_ADMIN_PIN"] = "1234" os.environ["CAIC_ADMIN_PIN"] = "1234"
db.DB_PATH = tmp_path / "jarvischat-ip.db" db.DB_PATH = tmp_path / "caic-ip.db"
SESSIONS.clear() SESSIONS.clear()
PIN_ATTEMPTS.clear() PIN_ATTEMPTS.clear()
RATE_EVENTS.clear() RATE_EVENTS.clear()
+2 -2
View File
@@ -10,8 +10,8 @@ from security import SESSIONS, PIN_ATTEMPTS, RATE_EVENTS
def make_client(tmp_path: Path) -> TestClient: def make_client(tmp_path: Path) -> TestClient:
os.environ["JARVISCHAT_ADMIN_PIN"] = "1234" os.environ["CAIC_ADMIN_PIN"] = "1234"
db.DB_PATH = tmp_path / "jarvischat-memories.db" db.DB_PATH = tmp_path / "caic-memories.db"
SESSIONS.clear() SESSIONS.clear()
PIN_ATTEMPTS.clear() PIN_ATTEMPTS.clear()
RATE_EVENTS.clear() RATE_EVENTS.clear()
+142
View File
@@ -0,0 +1,142 @@
import asyncio
import httpx
from model_pull import ensure_model, _model_available_on_llama, _model_available_on_ollama, _pull_via_ollama
class _MockAsyncResponse:
def __init__(self, status_code=200, json_data=None):
self.status_code = status_code
self._json_data = json_data or {}
def json(self):
return self._json_data
class _MockStreamResponse:
def __init__(self, status_code=200, lines=None):
self.status_code = status_code
self._lines = lines or []
async def __aenter__(self):
return self
async def __aexit__(self, *args):
pass
def aiter_lines(self):
class _AIter:
def __init__(self, lines):
self._lines = iter(lines)
def __aiter__(self):
return self
async def __anext__(self):
try:
return next(self._lines)
except StopIteration:
raise StopAsyncIteration
return _AIter(self._lines)
def _mock_models_on_llama(*models):
async def _get(*args, **kwargs):
return _MockAsyncResponse(json_data={
"data": [{"id": m} for m in models]
})
return _get
async def _mock_ollama_available(*args, **kwargs):
return _MockAsyncResponse(status_code=200, json_data={"name": "qwen2.5:latest"})
async def _mock_ollama_unavailable(*args, **kwargs):
raise httpx.ConnectError("refused")
def _mock_ollama_pull_ok(*args, **kwargs):
return _MockStreamResponse(200, [
'{"status": "pulling manifest"}',
'{"status": "success"}',
])
def _mock_ollama_pull_fail(*args, **kwargs):
return _MockStreamResponse(500, [])
def _mock_ollama_connect_error(*args, **kwargs):
raise httpx.ConnectError("refused")
def test_available_on_llama(monkeypatch):
monkeypatch.setattr(httpx.AsyncClient, "get", _mock_models_on_llama("qwen2.5-7b-instruct"))
assert asyncio.run(_model_available_on_llama("qwen2.5-7b-instruct")) is True
assert asyncio.run(_model_available_on_llama("nonexistent-model")) is False
def test_available_on_llama_unreachable(monkeypatch):
async def _connect_error(*args, **kwargs):
raise httpx.ConnectError("refused")
monkeypatch.setattr(httpx.AsyncClient, "get", _connect_error)
assert asyncio.run(_model_available_on_llama("qwen2.5-7b-instruct")) is False
def test_available_on_ollama(monkeypatch):
monkeypatch.setattr(httpx.AsyncClient, "post", _mock_ollama_available)
assert asyncio.run(_model_available_on_ollama("qwen2.5:latest")) is True
def test_available_on_ollama_unreachable(monkeypatch):
monkeypatch.setattr(httpx.AsyncClient, "post", _mock_ollama_unavailable)
assert asyncio.run(_model_available_on_ollama("qwen2.5:latest")) is False
def test_pull_via_ollama_success(monkeypatch):
monkeypatch.setattr(httpx.AsyncClient, "stream", _mock_ollama_pull_ok)
assert asyncio.run(_pull_via_ollama("qwen2.5:latest")) is True
def test_pull_via_ollama_fail(monkeypatch):
monkeypatch.setattr(httpx.AsyncClient, "stream", _mock_ollama_pull_fail)
assert asyncio.run(_pull_via_ollama("qwen2.5:latest")) is False
def test_pull_via_ollama_connect_error(monkeypatch):
monkeypatch.setattr(httpx.AsyncClient, "stream", _mock_ollama_connect_error)
assert asyncio.run(_pull_via_ollama("qwen2.5:latest")) is False
def test_ensure_model_already_on_llama(monkeypatch):
monkeypatch.setattr(httpx.AsyncClient, "get", _mock_models_on_llama("qwen2.5-7b-instruct"))
assert asyncio.run(ensure_model("qwen2.5-7b-instruct")) is True
def test_ensure_model_not_on_llama_but_on_ollama(monkeypatch):
async def _get(*args, **kwargs):
return _MockAsyncResponse(json_data={"data": []})
monkeypatch.setattr(httpx.AsyncClient, "get", _get)
monkeypatch.setattr(httpx.AsyncClient, "post", _mock_ollama_available)
assert asyncio.run(ensure_model("qwen2.5-7b-instruct")) is True
def test_ensure_model_needs_pull(monkeypatch):
async def _get(*args, **kwargs):
return _MockAsyncResponse(json_data={"data": []})
monkeypatch.setattr(httpx.AsyncClient, "get", _get)
monkeypatch.setattr(httpx.AsyncClient, "post", _mock_ollama_unavailable)
monkeypatch.setattr(httpx.AsyncClient, "stream", _mock_ollama_pull_ok)
assert asyncio.run(ensure_model("qwen2.5-7b-instruct")) is True
def test_ensure_model_pull_fails(monkeypatch):
async def _get(*args, **kwargs):
return _MockAsyncResponse(json_data={"data": []})
monkeypatch.setattr(httpx.AsyncClient, "get", _get)
monkeypatch.setattr(httpx.AsyncClient, "post", _mock_ollama_unavailable)
monkeypatch.setattr(httpx.AsyncClient, "stream", _mock_ollama_pull_fail)
assert asyncio.run(ensure_model("qwen2.5-7b-instruct")) is False
+150
View File
@@ -0,0 +1,150 @@
"""Tests for cluster.py model swap flow."""
import asyncio
import cluster
from config import AMQP_EXCHANGE_ADMIN, AMQP_EXCHANGE_SYSTEM
def _reset():
cluster.CLUSTER_NODES.clear()
cluster.CLUSTER_EVENTS.clear()
cluster.CLUSTER_COORDINATOR = None
cluster._pending_pings.clear()
_published = []
async def _fake_publish(exchange, routing_key, payload):
_published.append((exchange, routing_key, payload))
# ---------- 1. request_model_swap() publishes swap command ----------
def test_request_model_swap_publishes_command(monkeypatch):
_reset()
_published.clear()
monkeypatch.setattr(cluster, "publish", _fake_publish)
cluster.CLUSTER_NODES["jarvis"] = {
"name": "jarvis", "type": "worker", "status": "active",
}
asyncio.run(cluster.request_model_swap("jarvis", "qwen2.5-coder-Q4_K_M.gguf"))
assert cluster.CLUSTER_NODES["jarvis"]["status"] == "swapping"
assert len(_published) == 1
exchange, rk, payload = _published[0]
assert exchange == AMQP_EXCHANGE_ADMIN
assert rk == "node.jarvis.cmd.swap_model"
assert payload["model_filename"] == "qwen2.5-coder-Q4_K_M.gguf"
assert "requested_at" in payload
def test_request_model_swap_unknown_node(monkeypatch):
_reset()
_published.clear()
monkeypatch.setattr(cluster, "publish", _fake_publish)
result = asyncio.run(cluster.request_model_swap("ghost", "any.gguf"))
assert result is False
assert len(_published) == 0
# ---------- 2. handle_model_ready() updates node ----------
def test_handle_model_ready_updates_active_model(monkeypatch):
_reset()
_published.clear()
monkeypatch.setattr(cluster, "publish", _fake_publish)
cluster.CLUSTER_NODES["jarvis"] = {
"name": "jarvis", "type": "worker", "status": "swapping",
"active_model": {"name": "llama3.1", "port": 8081},
"inventory": [
{"filename": "qwen2.5-coder-Q4_K_M.gguf", "name": "qwen2.5-coder", "version": "14b", "quant": "Q4_K_M"},
],
}
asyncio.run(cluster.handle_model_ready(
AMQP_EXCHANGE_SYSTEM, "node.jarvis.model_ready",
{"node_name": "jarvis", "active_model": "qwen2.5-coder-Q4_K_M.gguf", "port": 8082},
))
assert cluster.CLUSTER_NODES["jarvis"]["status"] == "active"
am = cluster.CLUSTER_NODES["jarvis"]["active_model"]
assert am["name"] == "qwen2.5-coder"
assert am["port"] == 8082
assert am["filename"] == "qwen2.5-coder-Q4_K_M.gguf"
def test_handle_model_ready_inventory_lookup_fallback(monkeypatch):
_reset()
_published.clear()
monkeypatch.setattr(cluster, "publish", _fake_publish)
cluster.CLUSTER_NODES["jarvis"] = {
"name": "jarvis", "type": "worker", "status": "swapping",
"active_model": {"name": "llama3.1", "port": 8081},
"inventory": [],
}
asyncio.run(cluster.handle_model_ready(
AMQP_EXCHANGE_SYSTEM, "node.jarvis.model_ready",
{"node_name": "jarvis", "active_model": "unknown.gguf", "port": 9999},
))
assert cluster.CLUSTER_NODES["jarvis"]["status"] == "active"
am = cluster.CLUSTER_NODES["jarvis"]["active_model"]
assert am["filename"] == "unknown.gguf"
assert am["port"] == 9999
def test_handle_model_ready_unknown_node(caplog, monkeypatch):
_reset()
caplog.set_level("WARNING")
monkeypatch.setattr(cluster, "publish", _fake_publish)
asyncio.run(cluster.handle_model_ready(
AMQP_EXCHANGE_SYSTEM, "node.ghost.model_ready",
{"node_name": "ghost", "active_model": "any.gguf"},
))
assert any("unknown node" in rec.message for rec in caplog.records)
# ---------- 3. handle_model_failed() sets error status ----------
def test_handle_model_failed_sets_error(monkeypatch):
_reset()
_published.clear()
monkeypatch.setattr(cluster, "publish", _fake_publish)
cluster.CLUSTER_NODES["jarvis"] = {
"name": "jarvis", "type": "worker", "status": "swapping",
}
asyncio.run(cluster.handle_model_failed(
AMQP_EXCHANGE_SYSTEM, "node.jarvis.model_failed",
{"node_name": "jarvis", "error": "llama-server unhealthy after 120s"},
))
assert cluster.CLUSTER_NODES["jarvis"]["status"] == "error"
assert len(cluster.CLUSTER_EVENTS) == 1
assert "swap failed" in cluster.CLUSTER_EVENTS[0]["message"].lower()
def test_handle_model_failed_unknown_node(caplog, monkeypatch):
_reset()
caplog.set_level("WARNING")
monkeypatch.setattr(cluster, "publish", _fake_publish)
asyncio.run(cluster.handle_model_failed(
AMQP_EXCHANGE_SYSTEM, "node.ghost.model_failed",
{"node_name": "ghost", "error": "OOM"},
))
assert any("unknown node" in rec.message for rec in caplog.records)
+2 -2
View File
@@ -11,8 +11,8 @@ from security import SESSIONS, PIN_ATTEMPTS, RATE_EVENTS
def make_client(tmp_path: Path) -> TestClient: def make_client(tmp_path: Path) -> TestClient:
os.environ["JARVISCHAT_ADMIN_PIN"] = "1234" os.environ["CAIC_ADMIN_PIN"] = "1234"
db.DB_PATH = tmp_path / "jarvischat-models.db" db.DB_PATH = tmp_path / "caic-models.db"
SESSIONS.clear() SESSIONS.clear()
PIN_ATTEMPTS.clear() PIN_ATTEMPTS.clear()
RATE_EVENTS.clear() RATE_EVENTS.clear()
+340
View File
@@ -0,0 +1,340 @@
"""Tests for node_agent/agent.py — standalone worker agent."""
import asyncio
import json
import os
import subprocess
from contextlib import asynccontextmanager
from pathlib import Path
import node_agent.agent as agent
from node_agent.agent import (
AgentConfig,
ModelInfo,
build_registration_payload,
discover_models,
get_load,
handle_ping,
handle_swap_model,
)
class FakeMsg:
def __init__(self, body_dict: dict):
self.body = json.dumps(body_dict).encode()
@asynccontextmanager
async def process(self):
yield
class FakeExchange:
def __init__(self, name=""):
self.name = name
self.published = []
async def publish(self, msg, routing_key):
self.published.append((msg, routing_key))
class FakeChannel:
def __init__(self):
self.exchanges = {}
self.is_closed = False
async def get_exchange(self, name):
if name not in self.exchanges:
self.exchanges[name] = FakeExchange(name)
return self.exchanges[name]
async def declare_exchange(self, name, typ, durable=True):
self.exchanges[name] = FakeExchange(name)
return self.exchanges[name]
async def declare_queue(self, name="", exclusive=True):
return self
async def bind(self, exchange, routing_key):
pass
async def close(self):
self.is_closed = True
# ── 1. Registration payload shape ────────────────────────────────────
def test_registration_payload_shape():
cfg = AgentConfig()
cfg.node_name = "worker01"
cfg.node_ip = "192.168.50.210"
cfg.capabilities = ["llm"]
cfg.active_model = "llama3.1-latest-Q4_K_M.gguf"
cfg.llama_port = 8081
inventory = [{"filename": "llama3.1-latest-Q4_K_M.gguf", "name": "llama3.1", "quant": "Q4_K_M"}]
payload = build_registration_payload(cfg, inventory)
assert payload["node_name"] == "worker01"
assert payload["node_type"] == "worker"
assert payload["ip"] == "192.168.50.210"
assert payload["capabilities"] == ["llm"]
assert "active_model" in payload
assert payload["active_model"]["filename"] == "llama3.1-latest-Q4_K_M.gguf"
assert payload["active_model"]["port"] == 8081
assert len(payload["inventory"]) == 1
def test_registration_payload_active_model_none():
cfg = AgentConfig()
cfg.active_model = ""
payload = build_registration_payload(cfg, [])
assert payload["active_model"] is None
# ── 2. Model discovery ───────────────────────────────────────────────
def test_discover_models(tmp_path):
models_dir = tmp_path / "models"
models_dir.mkdir()
# Create valid model files
(models_dir / "llama3.1-latest-Q4_K_M.gguf").write_text("")
(models_dir / "mistral-nemo-7b-Q6_K_L.gguf").write_text("")
# Create file that doesn't match pattern
(models_dir / "readme.txt").write_text("")
# Create file with unrecognized naming
(models_dir / "my_custom_model.gguf").write_text("")
result = discover_models(str(models_dir))
assert len(result) == 2
names = {m["name"] for m in result}
assert "llama3.1" in names
assert "mistral-nemo" in names
def test_discover_models_no_directory(tmp_path):
result = discover_models(str(tmp_path / "nonexistent"))
assert result == []
# ── 3. Config reading ────────────────────────────────────────────────
def test_config_from_ini(tmp_path):
ini = tmp_path / "caic-node-agent.conf"
ini.write_text(
"[agent]\n"
"node_name = testnode\n"
"node_ip = 10.0.0.5\n"
"capabilities = llm,rag\n"
"amqp_url = amqp://user:pass@host/vhost\n"
"llama_port = 9090\n"
"models_dir = /tmp/models\n"
"active_model = test.gguf\n"
)
cfg = AgentConfig.from_ini(str(ini))
assert cfg.node_name == "testnode"
assert cfg.node_ip == "10.0.0.5"
assert cfg.capabilities == ["llm", "rag"]
assert cfg.amqp_url == "amqp://user:pass@host/vhost"
assert cfg.llama_port == 9090
assert cfg.models_dir == "/tmp/models"
assert cfg.active_model == "test.gguf"
def test_config_from_ini_missing_uses_defaults(tmp_path):
cfg = AgentConfig.from_ini(str(tmp_path / "nonexistent.conf"))
assert cfg.node_name != "" # socket.gethostname() returns something
assert cfg.node_type == "worker"
assert cfg.capabilities == ["llm"]
# ── 4. Ping handler publishes pong ──────────────────────────────────
def test_ping_handler_publishes_pong(monkeypatch):
monkeypatch.setattr(agent, "HAS_PSUTIL", False)
monkeypatch.setattr(agent, "HAS_AIO_PIKA", True)
cfg = AgentConfig()
cfg.node_name = "testworker"
channel = FakeChannel()
admin_ex = FakeExchange("jc.admin")
channel.exchanges["jc.admin"] = admin_ex
exchange = admin_ex
async def run():
await handle_ping(cfg, channel, exchange, FakeMsg({
"from": "coordinator",
"node_name": "testworker",
"type": "ping",
"correlation_id": "abc-123",
"timestamp": "2026-01-01T00:00:00Z",
}))
asyncio.run(run())
assert len(exchange.published) == 1
msg, rk = exchange.published[0]
assert rk == "node.testworker.pong"
payload = json.loads(msg.body)
assert payload["type"] == "pong"
assert payload["correlation_id"] == "abc-123"
assert payload["node_name"] == "testworker"
# ── 5. Model swap success path ──────────────────────────────────────
def test_model_swap_success(monkeypatch):
monkeypatch.setattr(agent, "HAS_AIO_PIKA", True)
monkeypatch.setattr(agent, "HAS_HTTPX", True)
cfg = AgentConfig()
cfg.node_name = "testworker"
cfg.llama_port = 9999
captured_cmds = []
def fake_run(cmd, **kwargs):
captured_cmds.append(cmd)
return subprocess.CompletedProcess(cmd, 0, b"", b"")
monkeypatch.setattr(subprocess, "run", fake_run)
# Mock _wait_for_llama to succeed
async def fake_wait(*a, **kw):
return True
monkeypatch.setattr(agent, "_wait_for_llama", fake_wait)
# Mock _update_config_active_model to no-op
monkeypatch.setattr(agent, "_update_config_active_model", lambda c, m: None)
channel = FakeChannel()
admin_ex = FakeExchange("jc.admin")
system_ex = FakeExchange("jc.system")
channel.exchanges["jc.admin"] = admin_ex
channel.exchanges["jc.system"] = system_ex
asyncio.run(handle_swap_model(cfg, channel, (admin_ex, system_ex), FakeMsg({"model_filename": "new-model-Q4_K_M.gguf"})))
# Check systemctl calls
assert len(captured_cmds) == 2
assert captured_cmds[0] == ["systemctl", "stop", "llama-server"]
assert captured_cmds[1] == ["systemctl", "start", "llama-server"]
# Check model_ready published on jc.system
assert len(system_ex.published) == 1
msg, rk = system_ex.published[0]
assert rk == "node.testworker.model_ready"
payload = json.loads(msg.body)
assert payload["type"] == "model_ready"
assert payload["active_model"] == "new-model-Q4_K_M.gguf"
# ── 6. Model swap timeout path ──────────────────────────────────────
def test_model_swap_timeout(monkeypatch):
monkeypatch.setattr(agent, "HAS_AIO_PIKA", True)
monkeypatch.setattr(agent, "HAS_HTTPX", True)
cfg = AgentConfig()
cfg.node_name = "testworker"
monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: subprocess.CompletedProcess(cmd, 0, b"", b""))
# Mock _wait_for_llama to fail
async def fake_wait_fail(*a, **kw):
return False
monkeypatch.setattr(agent, "_wait_for_llama", fake_wait_fail)
monkeypatch.setattr(agent, "_update_config_active_model", lambda c, m: None)
channel = FakeChannel()
admin_ex = FakeExchange("jc.admin")
system_ex = FakeExchange("jc.system")
channel.exchanges["jc.admin"] = admin_ex
channel.exchanges["jc.system"] = system_ex
asyncio.run(handle_swap_model(cfg, channel, (admin_ex, system_ex), FakeMsg({"model_filename": "broken-model-Q4_K_M.gguf"})))
assert len(system_ex.published) == 1
msg, rk = system_ex.published[0]
assert rk == "node.testworker.model_failed"
payload = json.loads(msg.body)
assert payload["type"] == "model_failed"
assert "error" in payload
# ── 7. Load reporting ───────────────────────────────────────────────
def test_get_load_with_psutil(monkeypatch):
class FakePsutil:
@staticmethod
def cpu_percent(interval=0.5):
return 42.0
@staticmethod
def virtual_memory():
class VM:
percent = 65.0
return VM()
monkeypatch.setattr(agent, "HAS_PSUTIL", True)
monkeypatch.setattr(agent, "psutil", FakePsutil)
# Mock subprocess to fail (no rocm-smi)
def fake_run(*a, **kw):
raise FileNotFoundError("rocm-smi not found")
monkeypatch.setattr(subprocess, "run", fake_run)
load = get_load()
assert load["cpu_pct"] == 42
assert load["ram_pct"] == 65
assert "vram_pct" not in load
def test_get_load_without_psutil(monkeypatch):
monkeypatch.setattr(agent, "HAS_PSUTIL", False)
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: (_ for _ in ()).throw(FileNotFoundError("")))
load = get_load()
assert "cpu_pct" not in load
# ── 8. Agent idle after admission (no heartbeat) ────────────────────
def test_no_heartbeat_timer():
"""Agent has no background heartbeat mechanism. This test asserts that
the codebase contains no heartbeat-related logic in the agent itself."""
import inspect
source = inspect.getsource(agent)
# There should be no heartbeat timer in the agent
assert "heartbeat" not in source.lower(), \
"agent.py should contain no heartbeat logic"
# ── 9. Config writer for model swap ─────────────────────────────────
def test_update_config_active_model(tmp_path, monkeypatch):
monkeypatch.setattr(agent, "CONFIG_PATH", str(tmp_path / "caic-node-agent.conf"))
cfg = AgentConfig()
cfg.active_model = "old.gguf"
agent._update_config_active_model(cfg, "new.gguf")
assert cfg.active_model == "new.gguf"
content = (tmp_path / "caic-node-agent.conf").read_text()
assert "new.gguf" in content
# ── 10. ModelInfo to_dict ────────────────────────────────────────────
def test_model_info_to_dict():
info = ModelInfo(filename="test-Q4_K_M.gguf", name="test", version="latest", quant="Q4_K_M")
d = info.to_dict()
assert d["name"] == "test"
assert d["quant"] == "Q4_K_M"
assert d["filename"] == "test-Q4_K_M.gguf"
+2 -2
View File
@@ -9,8 +9,8 @@ from security import SESSIONS, PIN_ATTEMPTS, RATE_EVENTS
def make_client(tmp_path: Path) -> TestClient: def make_client(tmp_path: Path) -> TestClient:
os.environ["JARVISCHAT_ADMIN_PIN"] = "1234" os.environ["CAIC_ADMIN_PIN"] = "1234"
db.DB_PATH = tmp_path / "jarvischat-presets.db" db.DB_PATH = tmp_path / "caic-presets.db"
SESSIONS.clear() SESSIONS.clear()
PIN_ATTEMPTS.clear() PIN_ATTEMPTS.clear()
RATE_EVENTS.clear() RATE_EVENTS.clear()
+2 -2
View File
@@ -10,8 +10,8 @@ from security import SESSIONS, PIN_ATTEMPTS, RATE_EVENTS
def make_client(tmp_path: Path) -> TestClient: def make_client(tmp_path: Path) -> TestClient:
os.environ["JARVISCHAT_ADMIN_PIN"] = "1234" os.environ["CAIC_ADMIN_PIN"] = "1234"
db.DB_PATH = tmp_path / "jarvischat-profile.db" db.DB_PATH = tmp_path / "caic-profile.db"
SESSIONS.clear() SESSIONS.clear()
PIN_ATTEMPTS.clear() PIN_ATTEMPTS.clear()
RATE_EVENTS.clear() RATE_EVENTS.clear()
+676
View File
@@ -0,0 +1,676 @@
import asyncio
import os
from datetime import datetime, timezone, timedelta
from pathlib import Path
import httpx
from fastapi.testclient import TestClient
import app
import config
import crypto
import db
import rag
from security import SESSIONS, PIN_ATTEMPTS, RATE_EVENTS
def make_client(tmp_path: Path) -> TestClient:
os.environ["CAIC_ADMIN_PIN"] = "1234"
db.DB_PATH = tmp_path / "caic-rag-mgmt.db"
SESSIONS.clear()
PIN_ATTEMPTS.clear()
RATE_EVENTS.clear()
db.init_db()
return TestClient(app.app, raise_server_exceptions=False)
def _admin_headers(client: TestClient) -> dict:
login = client.post("/api/auth/login", json={"pin": "1234"}, headers={"Origin": "http://testserver"})
sid = login.json()["session_id"]
return {"X-Session-ID": sid, "Origin": "http://testserver"}
def _guest_headers(client: TestClient) -> dict:
sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()["session_id"]
return {"X-Session-ID": sid, "Origin": "http://testserver"}
class FakeResponse:
def __init__(self, status, json_data=None):
self.status_code = status
self._json = json_data or {}
def json(self):
return self._json
class FakeAsyncClient:
def __init__(self, *a, **kw):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *a):
pass
async def get(self, url, **kw):
if "/collections/caic_rag" in url:
return FakeResponse(200, {"result": {"vectors_count": 123}})
return FakeResponse(200)
async def post(self, url, **kw):
if "/points/scroll" in url:
return FakeResponse(200, {"result": {"points": []}})
if "/points/delete" in url:
return FakeResponse(200)
return FakeResponse(200)
async def put(self, url, **kw):
return FakeResponse(200)
def _old_ts(hours_ago: float = 24) -> str:
return (datetime.now(timezone.utc) - timedelta(hours=hours_ago)).isoformat()
def _young_ts(hours_ago: float = 0.1) -> str:
return (datetime.now(timezone.utc) - timedelta(hours=hours_ago)).isoformat()
# ---------- get_collection_count ----------
def test_get_collection_count(monkeypatch):
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: FakeAsyncClient())
count = asyncio.run(rag.get_collection_count())
assert count == 123
# ---------- get_collection_stats ----------
def test_get_collection_stats_shape(monkeypatch):
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: FakeAsyncClient())
stats = asyncio.run(rag.get_collection_stats())
assert stats["vector_count"] == 123
assert stats["max_vectors"] == 50000
assert stats["high_water_mark"] == 40000
assert stats["low_water_mark"] == 10000
assert stats["high_water_pct"] == 80
assert stats["low_water_pct"] == 20
assert 0 < stats["percent_full"] < 1
assert "upload" in stats["pinned_sources"]
assert "profile" in stats["pinned_sources"]
# ---------- evict_batch ----------
def test_evict_batch_excludes_pinned_sources(monkeypatch):
"""Pinned sources ('upload', 'profile') should be in the must_not scroll filter."""
old = _old_ts(48)
# Only non-pinned points are returned (real Qdrant would honour must_not filter)
scroll_points = [
{"id": "old-data", "payload": {"source": "terminal", "ingest_date": old, "retrieval_count": 0}},
]
class ScrollClient(FakeAsyncClient):
async def post(self, url, **kw):
if "/points/scroll" in url:
must_not = kw.get("json", {}).get("filter", {}).get("must_not", [])
pinned_values = [m["match"]["value"] for m in must_not]
assert "upload" in pinned_values
assert "profile" in pinned_values
return FakeResponse(200, {"result": {"points": scroll_points}})
if "/points/delete" in url:
return FakeResponse(200)
return FakeResponse(200)
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: ScrollClient())
deleted = asyncio.run(rag.evict_batch(10))
assert deleted == 1
def test_evict_batch_respects_grace_period(monkeypatch):
"""Vectors younger than RAG_GRACE_HOURS should be skipped."""
old = _old_ts(48)
young = _young_ts(0.1)
scroll_points = [
{"id": "mature", "payload": {"source": "terminal", "ingest_date": old, "retrieval_count": 0}},
{"id": "newborn", "payload": {"source": "terminal", "ingest_date": young, "retrieval_count": 0}},
]
class GraceClient(FakeAsyncClient):
async def post(self, url, **kw):
if "/points/scroll" in url:
return FakeResponse(200, {"result": {"points": scroll_points}})
if "/points/delete" in url:
deleted = kw.get("json", {}).get("points", [])
assert "newborn" not in deleted
return FakeResponse(200)
return FakeResponse(200)
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: GraceClient())
deleted = asyncio.run(rag.evict_batch(10))
assert deleted == 1
def test_evict_batch_respects_batch_size(monkeypatch):
"""Only up to batch_size vectors should be deleted per call."""
old = _old_ts(48)
points = [{"id": f"p{i}", "payload": {"source": "terminal", "ingest_date": old, "retrieval_count": 0}} for i in range(50)]
class BatchClient(FakeAsyncClient):
async def post(self, url, **kw):
if "/points/delete" in url:
deleted = kw.get("json", {}).get("points", [])
assert len(deleted) == 10
return FakeResponse(200)
if "/points/scroll" in url:
return FakeResponse(200, {"result": {"points": points}})
return FakeResponse(200)
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: BatchClient())
deleted = asyncio.run(rag.evict_batch(10))
assert deleted == 10
def test_evict_batch_all_pinned_returns_zero(monkeypatch):
"""If scroll returns nothing (all points filtered by must_not), evict_batch returns 0."""
class EmptyClient(FakeAsyncClient):
async def post(self, url, **kw):
if "/points/scroll" in url:
return FakeResponse(200, {"result": {"points": []}})
return FakeResponse(200)
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: EmptyClient())
deleted = asyncio.run(rag.evict_batch(10))
assert deleted == 0
def test_evict_batch_scores_lowest_first(monkeypatch):
"""Vectors with lower scores should be evicted first."""
old = _old_ts(48)
points = [
{"id": "high-score", "payload": {"source": "terminal", "ingest_date": old, "retrieval_count": 100}},
{"id": "low-score", "payload": {"source": "terminal", "ingest_date": old, "retrieval_count": 0}},
{"id": "mid-score", "payload": {"source": "terminal", "ingest_date": old, "retrieval_count": 50}},
]
class ScoreClient(FakeAsyncClient):
async def post(self, url, **kw):
if "/points/delete" in url:
deleted = kw.get("json", {}).get("points", [])
assert "low-score" in deleted
assert "high-score" not in deleted
assert "mid-score" not in deleted
return FakeResponse(200)
if "/points/scroll" in url:
return FakeResponse(200, {"result": {"points": points}})
return FakeResponse(200)
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: ScoreClient())
deleted = asyncio.run(rag.evict_batch(1))
assert deleted == 1
# ---------- maybe_evict ----------
def test_maybe_evict_below_high_water(monkeypatch):
"""When count is below high-water mark, eviction should not fire."""
class LowCountClient(FakeAsyncClient):
async def get(self, url, **kw):
# 30000 < 40000 high water
return FakeResponse(200, {"result": {"vectors_count": 30000}})
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: LowCountClient())
rag.EVICTION_LOG.clear()
evicted = asyncio.run(rag.maybe_evict())
assert evicted == 0
assert len(rag.EVICTION_LOG) == 0
def test_maybe_evict_at_high_water(monkeypatch):
"""When count reaches high-water mark, eviction should fire."""
class HighCountClient(FakeAsyncClient):
def __init__(self, *a, **kw):
super().__init__()
self.call_count = 0
async def get(self, url, **kw):
return FakeResponse(200, {"result": {"vectors_count": 45000}})
async def post(self, url, **kw):
if "/points/scroll" in url:
old = _old_ts(48)
points = [{"id": f"evict-me-{i}", "payload": {"source": "terminal", "ingest_date": old, "retrieval_count": 0}} for i in range(100)]
return FakeResponse(200, {"result": {"points": points}})
if "/points/delete" in url:
return FakeResponse(200)
return FakeResponse(200)
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: HighCountClient())
rag.EVICTION_LOG.clear()
evicted = asyncio.run(rag.maybe_evict())
assert evicted > 0
assert len(rag.EVICTION_LOG) == 1
entry = rag.EVICTION_LOG[0]
assert "timestamp" in entry
assert entry["count"] > 0
def test_maybe_evict_zero_config_disabled(monkeypatch):
"""RAG_MAX_VECTORS <= 0 should disable eviction."""
orig = config.RAG_MAX_VECTORS
try:
config.RAG_MAX_VECTORS = 0
evicted = asyncio.run(rag.maybe_evict())
assert evicted == 0
finally:
config.RAG_MAX_VECTORS = orig
def test_maybe_evict_all_pinned_breaks(monkeypatch):
"""Above high water but only pinned points exist → eviction breaks with 0 deleted."""
class AllPinnedClient(FakeAsyncClient):
async def get(self, url, **kw):
return FakeResponse(200, {"result": {"vectors_count": 45000}})
async def post(self, url, **kw):
if "/points/scroll" in url:
return FakeResponse(200, {"result": {"points": []}})
return FakeResponse(200)
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: AllPinnedClient())
rag.EVICTION_LOG.clear()
evicted = asyncio.run(rag.maybe_evict())
assert evicted == 0
assert len(rag.EVICTION_LOG) == 0
# ---------- get_rag_operational_stats ----------
def test_rag_operational_stats_shape(monkeypatch):
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: FakeAsyncClient())
rag.EVICTION_LOG.clear()
rag.EVICTION_LOG.append({
"timestamp": datetime.now(timezone.utc).isoformat(),
"count": 500,
"remaining": 40000,
})
stats = asyncio.run(rag.get_rag_operational_stats())
assert stats["vector_count"] == 123
assert stats["grace_hours"] == 1
assert "eviction_counts_last_1m" in stats
assert "eviction_counts_last_5m" in stats
assert "eviction_counts_last_30m" in stats
assert stats["eviction_counts_last_1m"] == 500
# ---------- GET /api/rag/stats ----------
def test_rag_stats_endpoint(tmp_path, monkeypatch):
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: FakeAsyncClient())
with make_client(tmp_path) as client:
resp = client.get("/api/rag/stats", headers=_admin_headers(client))
assert resp.status_code == 200
data = resp.json()
assert data["vector_count"] == 123
assert data["max_vectors"] == 50000
assert "high_water_mark" in data
assert "low_water_mark" in data
assert data["high_water_pct"] == 80
assert data["low_water_pct"] == 20
assert "percent_full" in data
assert data["pinned_sources"] == ["upload", "profile"]
assert data["grace_hours"] == 1
assert "eviction_counts_last_1m" in data
assert "eviction_counts_last_5m" in data
assert "eviction_counts_last_30m" in data
assert "pinned_count" in data
assert "avg_retrieval_count" in data
assert "at_risk_count" in data
assert "eviction_log_size" in data
def test_rag_stats_requires_admin(tmp_path, monkeypatch):
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: FakeAsyncClient())
with make_client(tmp_path) as client:
resp = client.get("/api/rag/stats", headers=_guest_headers(client))
assert resp.status_code == 403
# ---------- POST /api/rag/flush ----------
def test_rag_flush_endpoint(tmp_path, monkeypatch):
class FlushClient(FakeAsyncClient):
async def post(self, url, **kw):
if "/points/scroll" in url:
return FakeResponse(200, {"result": {"points": [{"id": "a"}, {"id": "b"}]}})
if "/points/delete" in url:
return FakeResponse(200)
return FakeResponse(200)
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: FlushClient())
with make_client(tmp_path) as client:
resp = client.post("/api/rag/flush", headers=_admin_headers(client))
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "flushed"
assert data["deleted_count"] == 2
assert data["collection"] == rag.RAG_COLLECTION
def test_rag_flush_requires_admin(tmp_path, monkeypatch):
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: FakeAsyncClient())
with make_client(tmp_path) as client:
resp = client.post("/api/rag/flush", headers=_guest_headers(client))
assert resp.status_code == 403
def test_rag_flush_empty_collection(tmp_path, monkeypatch):
class EmptyClient(FakeAsyncClient):
async def post(self, url, **kw):
if "/points/scroll" in url:
return FakeResponse(200, {"result": {"points": []}})
return FakeResponse(200)
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: EmptyClient())
with make_client(tmp_path) as client:
resp = client.post("/api/rag/flush", headers=_admin_headers(client))
assert resp.status_code == 200
data = resp.json()
assert data["deleted_count"] == 0
assert data["status"] == "flushed"
# ---------- Race lock ----------
def test_eviction_lock_prevents_concurrent_eviction(monkeypatch):
"""Concurrent calls to maybe_evict should queue; only one evicts."""
call_order = []
async def slow_get_collection_count():
call_order.append("count")
return 45000
async def slow_evict_batch(bs):
call_order.append("evict")
await asyncio.sleep(0.05)
return 500
monkeypatch.setattr(rag, "get_collection_count", slow_get_collection_count)
monkeypatch.setattr(rag, "evict_batch", slow_evict_batch)
rag.EVICTION_LOG.clear()
async def run_concurrent():
r1, r2 = await asyncio.gather(rag.maybe_evict(), rag.maybe_evict())
return r1, r2
r1, r2 = asyncio.run(run_concurrent())
# First call evicted, second found count already below high water or lock serialized
assert r1 >= 0
assert r2 >= 0
# ---------- GET /api/rag/points ----------
def test_rag_list_points_empty(tmp_path, monkeypatch):
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: FakeAsyncClient())
with make_client(tmp_path) as client:
resp = client.get("/api/rag/points", headers=_admin_headers(client))
assert resp.status_code == 200
data = resp.json()
assert "points" in data
assert data["total"] == 123
assert len(data["points"]) == 0
def test_rag_list_points_requires_admin(tmp_path, monkeypatch):
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: FakeAsyncClient())
with make_client(tmp_path) as client:
resp = client.get("/api/rag/points", headers=_guest_headers(client))
assert resp.status_code == 403
def test_rag_list_points_with_data(tmp_path, monkeypatch):
with make_client(tmp_path) as client:
encrypted = crypto.encrypt_text("Hello world test content")
class DataClient(FakeAsyncClient):
async def post(self, url, **kw):
if "/points/scroll" in url:
return FakeResponse(200, {
"result": {
"points": [{
"id": "test-pt-1",
"payload": {
"text": encrypted,
"source": "terminal",
"ingest_date": "2024-06-15T10:00:00",
"type": "ingest",
"retrieval_count": 3,
},
}],
"next_page_offset": None,
}
})
return FakeResponse(200)
async def get(self, url, **kw):
if "/collections/caic_rag" in url:
return FakeResponse(200, {"result": {"vectors_count": 1}})
return FakeResponse(200)
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: DataClient())
resp = client.get("/api/rag/points", headers=_admin_headers(client))
assert resp.status_code == 200
data = resp.json()
assert len(data["points"]) == 1
p = data["points"][0]
assert p["id"] == "test-pt-1"
assert p["source"] == "terminal"
assert p["type"] == "ingest"
assert "Hello world" in p["text"]
assert p["retrieval_count"] == 3
def test_rag_list_points_source_filter(tmp_path, monkeypatch):
"""Source filter should be passed as Qdrant must-match."""
class FilterClient(FakeAsyncClient):
async def post(self, url, **kw):
if "/points/scroll" in url:
body = kw.get("json", {})
filt = body.get("filter", {})
must = filt.get("must", [])
# Verify the source filter was passed
assert any(m.get("match", {}).get("value") == "upload" for m in must)
return FakeResponse(200, {"result": {"points": [], "next_page_offset": None}})
return FakeResponse(200)
async def get(self, url, **kw):
return FakeResponse(200, {"result": {"vectors_count": 0}})
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: FilterClient())
with make_client(tmp_path) as client:
resp = client.get("/api/rag/points?source=upload", headers=_admin_headers(client))
assert resp.status_code == 200
def test_rag_list_points_search(tmp_path, monkeypatch):
"""Semantic search should use Qdrant search endpoint."""
encrypted = crypto.encrypt_text("Semantic match text")
class SearchClient(FakeAsyncClient):
call_log = []
async def post(self, url, **kw):
SearchClient.call_log.append(url)
if "/api/embeddings" in url:
vec = [0.1] * 768
return FakeResponse(200, {"embedding": vec})
if "/points/search" in url:
return FakeResponse(200, {"result": [{
"id": "search-hit-1",
"score": 0.85,
"payload": {
"text": encrypted,
"source": "terminal",
"ingest_date": "2024-06-15T10:00:00",
"type": "ingest",
"retrieval_count": 1,
},
}]})
return FakeResponse(200)
async def get(self, url, **kw):
return FakeResponse(200, {"result": {}})
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: SearchClient())
with make_client(tmp_path) as client:
resp = client.get("/api/rag/points?search=hello+world", headers=_admin_headers(client))
assert resp.status_code == 200
data = resp.json()
assert len(data["points"]) == 1
assert data["points"][0]["id"] == "search-hit-1"
assert data["points"][0]["score"] == 0.85
# ---------- GET /api/rag/point/{point_id} ----------
def test_rag_get_point_requires_admin(tmp_path, monkeypatch):
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: FakeAsyncClient())
with make_client(tmp_path) as client:
resp = client.get("/api/rag/point/test-1", headers=_guest_headers(client))
assert resp.status_code == 403
def test_rag_get_point_found(tmp_path, monkeypatch):
with make_client(tmp_path) as client:
encrypted = crypto.encrypt_text("Single point text")
class GetClient(FakeAsyncClient):
async def get(self, url, **kw):
if "/collections/caic_rag/points/test-1" in url:
return FakeResponse(200, {"result": {
"id": "test-1",
"payload": {
"text": encrypted,
"source": "terminal",
"ingest_date": "2024-06-15T10:00:00",
"type": "ingest",
"retrieval_count": 5,
},
}})
return FakeResponse(200)
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: GetClient())
resp = client.get("/api/rag/point/test-1", headers=_admin_headers(client))
assert resp.status_code == 200
p = resp.json()
assert p["id"] == "test-1"
assert p["source"] == "terminal"
assert "Single point" in p["text"]
def test_rag_get_point_not_found(tmp_path, monkeypatch):
class NotFoundClient(FakeAsyncClient):
async def get(self, url, **kw):
if "/collections/caic_rag/points/" in url:
return FakeResponse(404, {"detail": "Not found"})
return FakeResponse(200)
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: NotFoundClient())
with make_client(tmp_path) as client:
resp = client.get("/api/rag/point/nonexistent", headers=_admin_headers(client))
assert resp.status_code == 404
# ---------- DELETE /api/rag/point/{point_id} ----------
def test_rag_delete_point_requires_admin(tmp_path, monkeypatch):
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: FakeAsyncClient())
with make_client(tmp_path) as client:
resp = client.delete("/api/rag/point/test-1", headers=_guest_headers(client))
assert resp.status_code == 403
def test_rag_delete_point_success(tmp_path, monkeypatch):
class DeleteClient(FakeAsyncClient):
async def post(self, url, **kw):
if "/points/delete" in url:
pts = kw.get("json", {}).get("points", [])
assert "test-1" in pts
return FakeResponse(200)
return FakeResponse(200)
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: DeleteClient())
with make_client(tmp_path) as client:
resp = client.delete("/api/rag/point/test-1", headers=_admin_headers(client))
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "deleted"
assert data["id"] == "test-1"
# ---------- PATCH /api/rag/point/{point_id} ----------
def test_rag_update_point_requires_admin(tmp_path, monkeypatch):
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: FakeAsyncClient())
with make_client(tmp_path) as client:
resp = client.patch("/api/rag/point/test-1", json={"text": "new"}, headers=_guest_headers(client))
assert resp.status_code == 403
def test_rag_update_point_success(tmp_path, monkeypatch):
encrypted_old = crypto.encrypt_text("old text")
class UpdateClient(FakeAsyncClient):
async def get(self, url, **kw):
if "/collections/caic_rag/points/test-1" in url:
return FakeResponse(200, {"result": {
"id": "test-1",
"payload": {
"text": encrypted_old,
"source": "terminal",
"ingest_date": "2024-06-15T10:00:00",
"type": "ingest",
"retrieval_count": 2,
},
}})
return FakeResponse(200)
async def post(self, url, **kw):
if "/api/embeddings" in url:
return FakeResponse(200, {"embedding": [0.2] * 768})
return FakeResponse(200)
async def put(self, url, **kw):
if "/points?wait=true" in url:
pts = kw.get("json", {}).get("points", [])
assert len(pts) == 1
assert len(pts[0]["vector"]) == 768
payload = pts[0]["payload"]
assert "text" in payload
assert payload["source"] == "terminal"
return FakeResponse(200)
return FakeResponse(200)
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: UpdateClient())
with make_client(tmp_path) as client:
resp = client.patch("/api/rag/point/test-1", json={"text": "updated text"}, headers=_admin_headers(client))
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "updated"
assert data["id"] == "test-1"
def test_rag_update_point_empty_text(tmp_path, monkeypatch):
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: FakeAsyncClient())
with make_client(tmp_path) as client:
resp = client.patch("/api/rag/point/test-1", json={"text": ""}, headers=_admin_headers(client))
assert resp.status_code == 400
def test_rag_update_point_not_found(tmp_path, monkeypatch):
class NotFoundClient(FakeAsyncClient):
async def get(self, url, **kw):
if "/collections/caic_rag/points/" in url:
return FakeResponse(404)
return FakeResponse(200)
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: NotFoundClient())
with make_client(tmp_path) as client:
resp = client.patch("/api/rag/point/nonexistent", json={"text": "new"}, headers=_admin_headers(client))
assert resp.status_code == 404
+2 -2
View File
@@ -12,8 +12,8 @@ from security import SESSIONS, PIN_ATTEMPTS, RATE_EVENTS
def make_client(tmp_path: Path) -> TestClient: def make_client(tmp_path: Path) -> TestClient:
os.environ["JARVISCHAT_ADMIN_PIN"] = "1234" os.environ["CAIC_ADMIN_PIN"] = "1234"
db.DB_PATH = tmp_path / "jarvischat-rate.db" db.DB_PATH = tmp_path / "caic-rate.db"
SESSIONS.clear() SESSIONS.clear()
PIN_ATTEMPTS.clear() PIN_ATTEMPTS.clear()
RATE_EVENTS.clear() RATE_EVENTS.clear()
+2 -2
View File
@@ -13,8 +13,8 @@ from security import SESSIONS, PIN_ATTEMPTS, RATE_EVENTS
def make_client(tmp_path: Path) -> TestClient: def make_client(tmp_path: Path) -> TestClient:
os.environ["JARVISCHAT_ADMIN_PIN"] = "1234" os.environ["CAIC_ADMIN_PIN"] = "1234"
db.DB_PATH = tmp_path / "jarvischat-search-route.db" db.DB_PATH = tmp_path / "caic-search-route.db"
SESSIONS.clear() SESSIONS.clear()
PIN_ATTEMPTS.clear() PIN_ATTEMPTS.clear()
RATE_EVENTS.clear() RATE_EVENTS.clear()
+2 -2
View File
@@ -9,8 +9,8 @@ from security import SESSIONS, PIN_ATTEMPTS
def make_admin_client(tmp_path: Path) -> tuple[TestClient, dict[str, str]]: def make_admin_client(tmp_path: Path) -> tuple[TestClient, dict[str, str]]:
os.environ["JARVISCHAT_ADMIN_PIN"] = "1234" os.environ["CAIC_ADMIN_PIN"] = "1234"
db.DB_PATH = tmp_path / "jarvischat-settings.db" db.DB_PATH = tmp_path / "caic-settings.db"
SESSIONS.clear() SESSIONS.clear()
PIN_ATTEMPTS.clear() PIN_ATTEMPTS.clear()
db.init_db() db.init_db()
+2 -2
View File
@@ -11,8 +11,8 @@ from security import SESSIONS, PIN_ATTEMPTS, RATE_EVENTS
def make_client(tmp_path: Path) -> TestClient: def make_client(tmp_path: Path) -> TestClient:
os.environ["JARVISCHAT_ADMIN_PIN"] = "1234" os.environ["CAIC_ADMIN_PIN"] = "1234"
db.DB_PATH = tmp_path / "jarvischat-skills.db" db.DB_PATH = tmp_path / "caic-skills.db"
SESSIONS.clear() SESSIONS.clear()
PIN_ATTEMPTS.clear() PIN_ATTEMPTS.clear()
RATE_EVENTS.clear() RATE_EVENTS.clear()
+275
View File
@@ -0,0 +1,275 @@
import os
from pathlib import Path
import httpx
import pytest
from fastapi.testclient import TestClient
import app
import db
import routers.upload as upload_route
from security import SESSIONS, PIN_ATTEMPTS, RATE_EVENTS
def make_client(tmp_path: Path) -> TestClient:
os.environ["CAIC_ADMIN_PIN"] = "1234"
db.DB_PATH = tmp_path / "caic-upload.db"
SESSIONS.clear()
PIN_ATTEMPTS.clear()
RATE_EVENTS.clear()
db.init_db()
return TestClient(app.app, raise_server_exceptions=False)
def _admin_headers(client: TestClient) -> dict:
login = client.post("/api/auth/login", json={"pin": "1234"}, headers={"Origin": "http://testserver"})
sid = login.json()["session_id"]
return {"X-Session-ID": sid, "Origin": "http://testserver"}
def _guest_headers(client: TestClient) -> dict:
sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()["session_id"]
return {"X-Session-ID": sid, "Origin": "http://testserver"}
def test_upload_requires_admin(tmp_path: Path):
with make_client(tmp_path) as client:
resp = client.post("/api/upload", headers=_guest_headers(client), files={"file": ("test.txt", b"hello")})
assert resp.status_code == 403
def test_upload_unsupported_mime(tmp_path: Path):
with make_client(tmp_path) as client:
resp = client.post(
"/api/upload", headers=_admin_headers(client),
files={"file": ("test.exe", b"fake", "application/x-msdownload")},
)
assert resp.status_code == 415
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),
data={"mode": "context", "conversation_id": "conv-1"},
files={"file": ("notes.txt", b"Hello world notes")},
)
assert resp.status_code == 200
data = resp.json()
assert data["filename"] == "notes.txt"
assert data["mode"] == "context"
assert "context_id" in data
assert "chunks_ingested" not in data
row = db.get_db().execute("SELECT content FROM upload_context WHERE id = ?", (data["context_id"],)).fetchone()
assert decrypt_text(row["content"]) == "Hello world notes"
def test_upload_ingest_mode(tmp_path: Path, monkeypatch):
embed_count = 0
class FakeAsyncClient:
class FakeResponse:
def __init__(self, status, json_data=None):
self.status_code = status
self._json = json_data or {}
def json(self):
return self._json
def __init__(self, *a, **kw):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *a):
pass
async def post(self, url, **kw):
nonlocal embed_count
if "/api/embeddings" in url:
embed_count += 1
return self.FakeResponse(200, {"embedding": [0.1] * 768})
return self.FakeResponse(200)
async def put(self, url, **kw):
return self.FakeResponse(200)
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: FakeAsyncClient())
with make_client(tmp_path) as client:
resp = client.post(
"/api/upload", headers=_admin_headers(client),
data={"mode": "ingest"},
files={"file": ("data.txt", b"word " * 1000)},
)
assert resp.status_code == 200
data = resp.json()
assert data["mode"] == "ingest"
assert data["chunks_ingested"] > 0
assert "context_id" not in data
assert embed_count == data["chunks_ingested"]
def test_upload_both_mode(tmp_path: Path, monkeypatch):
class FakeAsyncClient:
class FakeResponse:
def __init__(self, status, json_data=None):
self.status_code = status
self._json = json_data or {}
def json(self):
return self._json
def __init__(self, *a, **kw):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *a):
pass
async def post(self, url, **kw):
if "/api/embeddings" in url:
return self.FakeResponse(200, {"embedding": [0.1] * 768})
return self.FakeResponse(200)
async def put(self, url, **kw):
return self.FakeResponse(200)
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: FakeAsyncClient())
with make_client(tmp_path) as client:
resp = client.post(
"/api/upload", headers=_admin_headers(client),
data={"mode": "both", "conversation_id": "conv-2"},
files={"file": ("both.txt", b"test " * 500)},
)
assert resp.status_code == 200
data = resp.json()
assert data["mode"] == "both"
assert "context_id" in data
assert data["chunks_ingested"] > 0
def test_upload_image_type(tmp_path: Path):
with make_client(tmp_path) as client:
resp = client.post(
"/api/upload", headers=_admin_headers(client),
data={"mode": "context"},
files={"file": ("photo.png", b"fake-png", "image/png")},
)
assert resp.status_code == 200
data = resp.json()
assert data["filename"] == "photo.png"
assert "context_id" in data
def test_get_upload_by_conversation(tmp_path: Path):
with make_client(tmp_path) as client:
headers = _admin_headers(client)
resp1 = client.post("/api/upload", headers=headers,
data={"mode": "context", "conversation_id": "conv-gal"},
files={"file": ("a.txt", b"alpha")})
cid1 = resp1.json()["context_id"]
resp2 = client.post("/api/upload", headers=headers,
data={"mode": "context", "conversation_id": "conv-gal"},
files={"file": ("b.txt", b"beta")})
cid2 = resp2.json()["context_id"]
gal = client.get("/api/upload/by-conversation/conv-gal", headers=headers)
assert gal.status_code == 200
items = gal.json()
assert len(items) == 2
assert items[0]["filename"] == "a.txt"
assert items[1]["filename"] == "b.txt"
def test_link_upload_to_conversation(tmp_path: Path):
with make_client(tmp_path) as client:
headers = _admin_headers(client)
resp = client.post("/api/upload", headers=headers,
data={"mode": "context"},
files={"file": ("orphan.txt", b"lonely")})
cid = resp.json()["context_id"]
link = client.patch(f"/api/upload/{cid}/link", headers=headers,
json={"conversation_id": "new-conv"})
assert link.status_code == 200
gal = client.get("/api/upload/by-conversation/new-conv", headers=headers)
assert len(gal.json()) == 1
def test_delete_upload_removes_context(tmp_path: Path, monkeypatch):
class FakeAsyncClient:
class FakeResponse:
def __init__(self, status, json_data=None):
self.status_code = status
self._json = json_data or {}
def json(self):
return self._json
def __init__(self, *a, **kw):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *a):
pass
async def post(self, url, **kw):
if "/points/scroll" in url:
return self.FakeResponse(200, {"result": {"points": [{"id": "upload-test.txt-0"}, {"id": "upload-test.txt-1"}]}})
if "/points/delete" in url:
return self.FakeResponse(200)
return self.FakeResponse(200)
async def put(self, url, **kw):
return self.FakeResponse(200)
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: FakeAsyncClient())
with make_client(tmp_path) as client:
headers = _admin_headers(client)
resp = client.post("/api/upload", headers=headers,
data={"mode": "context", "conversation_id": "del-test"},
files={"file": ("test.txt", b"delete me")})
cid = resp.json()["context_id"]
del_resp = client.delete(f"/api/upload/{cid}", headers=headers)
assert del_resp.status_code == 200
assert del_resp.json()["status"] == "ok"
row = db.get_db().execute("SELECT id FROM upload_context WHERE id = ?", (cid,)).fetchone()
assert row is None
def test_delete_upload_not_found(tmp_path: Path):
with make_client(tmp_path) as client:
resp = client.delete("/api/upload/999", headers=_admin_headers(client))
assert resp.status_code == 404
def test_conversation_list_includes_attachment_count(tmp_path: Path):
with make_client(tmp_path) as client:
headers = _admin_headers(client)
client.post("/api/conversations", headers=headers, json={"title": "NoAttach"})
with_attach = client.post("/api/conversations", headers=headers, json={"title": "WithAttach"}).json()
conv_id = with_attach["id"]
client.post("/api/upload", headers=headers,
data={"mode": "context", "conversation_id": conv_id},
files={"file": ("f.txt", b"data")})
list_resp = client.get("/api/conversations", headers=headers)
convs = list_resp.json()
for c in convs:
if c["title"] == "NoAttach":
assert c["attachment_count"] == 0
if c["title"] == "WithAttach":
assert c["attachment_count"] == 1