diff --git a/config.py b/config.py index 4aa5af0..0ca63e8 100644 --- a/config.py +++ b/config.py @@ -9,7 +9,7 @@ import logging log = logging.getLogger("caic") -VERSION = "v0.17.1" +VERSION = "v0.17.2" OLLAMA_BASE = os.environ.get("OLLAMA_BASE", "http://localhost:11434") LLAMA_SERVER_BASE = os.environ.get("LLAMA_SERVER_BASE", "http://192.168.50.108:8081") SEARXNG_BASE = "http://localhost:8888" diff --git a/memory.py b/memory.py index cf89ba3..1cef528 100644 --- a/memory.py +++ b/memory.py @@ -27,6 +27,80 @@ 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"} + + +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. + + 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("."): + conflicts.append({ + "memory_id": related[0]["rowid"], + "old_fact": old, + "new_fact": new_fact, + }) + return conflicts + + def detect_topic(fact: str) -> str: fact_lower = fact.lower() if any(w in fact_lower for w in ["prefer", "like", "hate", "always", "never", "favorite"]): diff --git a/rag.py b/rag.py index 95e1d9f..98cbf0b 100644 --- a/rag.py +++ b/rag.py @@ -3,6 +3,7 @@ cAIc - RAG pipeline: Qdrant vector search + system prompt assembly. """ import asyncio import logging +from datetime import datetime, timezone import httpx @@ -24,6 +25,103 @@ from eviction import ( # noqa: E402 ) +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 = f"auto-{ts}-{i}" + payload = { + "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 = 512, overlap: int = 128) -> list: words = text.split() target_words = int(chunk_size / 1.3) diff --git a/routers/chat.py b/routers/chat.py index 0fc9232..c472d9e 100644 --- a/routers/chat.py +++ b/routers/chat.py @@ -1,4 +1,5 @@ """JarvisChat routers - /api/chat streaming endpoint.""" +import asyncio import json import logging import uuid @@ -10,8 +11,8 @@ from fastapi.responses import StreamingResponse from config import DEFAULT_MODEL, LLAMA_SERVER_BASE from db import get_db, get_upload_context -from memory import process_remember_command -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, clean_hedging, format_search_results, format_direct_answer, extract_search_query, query_searxng) @@ -127,6 +128,7 @@ async def chat(request: Request): tokens_per_sec = 0.0 completion_tokens = 0 prompt_tokens = 0 + rag_update = None if remember_response: yield f"data: {json.dumps({'token': remember_response + chr(10) + chr(10), 'conversation_id': conv_id})}\n\n" @@ -204,7 +206,15 @@ async def chat(request: Request): db2.commit() 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), 'prompt_tokens': prompt_tokens, 'completion_tokens': completion_tokens, 'context_length': MODEL_CONTEXT_LENGTH})}\n\n" + facts = auto_detect_facts(user_message, cleaned_response) + if facts: + conflicts = check_fact_conflicts(facts) + if conflicts: + rag_update = {"conflicts": conflicts} + else: + asyncio.ensure_future(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 saved_msg = assistant_msg @@ -217,7 +227,15 @@ async def chat(request: Request): db2.commit() 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), 'prompt_tokens': prompt_tokens, 'completion_tokens': completion_tokens, 'context_length': MODEL_CONTEXT_LENGTH})}\n\n" + facts = auto_detect_facts(user_message, assistant_msg) + if facts: + conflicts = check_fact_conflicts(facts) + if conflicts: + rag_update = {"conflicts": conflicts} + else: + asyncio.ensure_future(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: pass diff --git a/routers/memories.py b/routers/memories.py index fdd0768..bb4ff42 100644 --- a/routers/memories.py +++ b/routers/memories.py @@ -4,6 +4,7 @@ from typing import Optional from db import get_db 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 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)} +@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") async def memory_stats(): db = get_db() diff --git a/templates/index.html b/templates/index.html index 5dd3de2..f3155c0 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1418,6 +1418,9 @@ async function sendMessage() { addCopyButtons(assistantDiv); addMessageToolbar(assistantDiv); setStreamingState(false); + if (data.rag_update_suggestion && data.rag_update_suggestion.conflicts) { + showFactConflictBanner(data.rag_update_suggestion.conflicts, data.conversation_id); + } await loadConversations(); await loadMemoryStats(); checkOllamaStatus(); @@ -1609,8 +1612,60 @@ userInput.addEventListener('keydown', e => { } }); const toastStyle = document.createElement('style'); -toastStyle.textContent = '.toast-error { position:fixed; bottom:80px; left:50%; transform:translateX(-50%); background:var(--danger); color:#fff; padding:12px 24px; border-radius:var(--radius); font-family:var(--font-body); font-size:14px; z-index:9999; animation:fadeIn 0.2s; } @keyframes fadeIn { from{opacity:0;transform:translateX(-50%) translateY(10px)} to{opacity:1;transform:translateX(-50%) translateY(0)} }'; +toastStyle.textContent = '.toast-error { position:fixed; bottom:80px; left:50%; transform:translateX(-50%); background:var(--danger); color:#fff; padding:12px 24px; border-radius:var(--radius); font-family:var(--font-body); font-size:14px; z-index:9999; animation:fadeIn 0.2s; } @keyframes fadeIn { from{opacity:0;transform:translateX(-50%) translateY(10px)} to{opacity:1;transform:translateX(-50%) translateY(0)} } .fact-conflict-banner { position:fixed; bottom:80px; left:50%; transform:translateX(-50%); background:var(--bg-secondary); border:1px solid var(--accent); border-radius:var(--radius); padding:12px 20px; font-family:var(--font-body); font-size:13px; z-index:9999; box-shadow:0 4px 20px rgba(0,0,0,0.3); max-width:600px; line-height:1.5; } .fact-conflict-banner .actions { margin-top:8px; display:flex; gap:8px; } .fact-conflict-banner button { padding:4px 14px; border-radius:var(--radius); cursor:pointer; font-size:12px; } .fact-conflict-banner .btn-confirm { background:var(--accent); color:var(--bg-primary); border:none; } .fact-conflict-banner .btn-dismiss { background:transparent; color:var(--text-muted); border:1px solid var(--border); }'; document.head.appendChild(toastStyle); +function showFactConflictBanner(conflicts, convId) { + if (!conflicts || !conflicts.length) return; + const existing = document.querySelector('.fact-conflict-banner'); + if (existing) existing.remove(); + let html = '
'; + html += '📝 Knowledge conflict detected'; + html += '
'; + conflicts.forEach((c, i) => { + html += `
`; + html += `
Stored: ${escapeHtml(c.old_fact)}
`; + html += `
Now: ${escapeHtml(c.new_fact)}
`; + html += '
'; + html += ``; + html += ''; + html += '
'; + }); + html += '
'; + document.body.insertAdjacentHTML('beforeend', html); +} + +async function confirmFactUpdate(memoryId, ts, btn) { + const banner = btn.closest('.fact-conflict-banner'); + const text = banner ? banner.textContent : ''; + const lines = text.split('\n').map(l => l.trim()).filter(l => l); + const newFact = lines.length > 0 ? lines[lines.length-1].replace(/^Now:\s*/, '') : ''; + btn.textContent = 'Updating...'; + btn.disabled = true; + try { + const resp = await authFetch('/api/memories/confirm-update', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + memory_id: memoryId, + new_fact: newFact, + old_fact: '', + user_message: '', + assistant_message: '', + }), + }); + if (resp.ok) { + btn.textContent = '✓ Updated'; + setTimeout(() => { if (banner) banner.remove(); }, 1500); + } else { + btn.textContent = 'Failed'; + btn.style.background = 'var(--danger)'; + } + } catch(e) { + btn.textContent = 'Error'; + btn.style.background = 'var(--danger)'; + } +} + userInput.addEventListener('paste', e => { const hasImage = Array.from(e.clipboardData.items).some(i => i.type.startsWith('image/')); if (hasImage) {