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
This commit is contained in:
gramps
2026-08-07 14:42:35 -07:00
parent df405a156e
commit 44387919a8
24 changed files with 478 additions and 7171 deletions
+26 -5
View File
@@ -23,6 +23,22 @@ from config import MAX_CHAT_MESSAGE_CHARS, MODEL_CONTEXT_LENGTH
log = logging.getLogger("caic")
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:
if line.startswith("data: "):
@@ -112,6 +128,11 @@ async def chat(request: Request):
db.execute("INSERT INTO conversations (id, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
(conv_id, encrypt_text(title), model, now, now))
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("INSERT INTO messages (conversation_id, role, content, created_at, perplexity) VALUES (?, ?, ?, ?, ?)",
@@ -171,6 +192,8 @@ async def chat(request: Request):
assistant_msg = "".join(full_response)
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)
if search_enabled and should_search:
@@ -210,7 +233,7 @@ async def chat(request: Request):
if is_refusal(cleaned_response) or len(cleaned_response) < 20:
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*"
@@ -229,8 +252,7 @@ async def chat(request: Request):
if conflicts:
rag_update = {"conflicts": conflicts}
else:
# Fire-and-forget: persist facts silently, don't block the response
asyncio.create_task(ingest_auto_fact(facts, user_message, cleaned_response))
_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
@@ -252,8 +274,7 @@ async def chat(request: Request):
if conflicts:
rag_update = {"conflicts": conflicts}
else:
# Fire-and-forget: persist facts silently, don't block the response
asyncio.create_task(ingest_auto_fact(facts, user_message, assistant_msg))
_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"
+2 -1
View File
@@ -6,6 +6,7 @@ FIM (fill-in-the-middle) requests are proxied directly — not persisted.
Chat-style requests are persisted to conversation history.
Auth: static Bearer token via COMPLETIONS_API_KEY in config.
"""
import hmac
import json
import logging
import uuid
@@ -30,7 +31,7 @@ def _check_api_key(request: Request):
if not auth.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing Bearer token")
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")
+5 -2
View File
@@ -1,4 +1,6 @@
"""JarvisChat routers - /api/ingest terminal command RAG hook."""
import hashlib
import hmac
import logging
from datetime import datetime, timezone
@@ -20,7 +22,7 @@ def _check_api_key(request: Request):
if not auth.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing Bearer token")
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")
@@ -50,7 +52,8 @@ async def ingest_content(request: Request):
log.warning(f"Ingest embedding failed for chunk {i}: {embed_resp.status_code}")
continue
vector = embed_resp.json()["embedding"]
point_id = f"ingest-{source}-{datetime.now(timezone.utc).timestamp()}-{i}"
chunk_hash = hashlib.md5(chunk.encode("utf-8")).hexdigest()[:12]
point_id = 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(
+3
View File
@@ -44,6 +44,9 @@ async def explicit_search(request: Request):
db.execute("INSERT INTO conversations (id, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
(conv_id, encrypt_text(title), model, now, now))
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("INSERT INTO messages (conversation_id, role, content, created_at, perplexity) VALUES (?, ?, ?, ?, ?)",
+10 -1
View File
@@ -52,12 +52,21 @@ async def upload_file(
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}
if mode in ("ingest", "both"):
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