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:
+26
-5
@@ -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"
|
||||
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user