"""core/knowledge.py — it should remember, not re-ask. Phase 6. Two things live in one store: * **turns** — every conversation turn, so "what did I say yesterday" is a query and not a scroll-back; * **chunks** — documents the user (or JARVIS) chose to keep, embedded for semantic recall. Embeddings are Gemini's, stored as float32 blobs in plain SQLite, and searched with a cosine over numpy. PLAN.md originally said sqlite-vec; that is a Rust extension with no wheel for this platform, and adding a compiler to the Space image to save 20ms of search on a few thousand chunks is the wrong trade. The search is O(n·d) in numpy and stays instant to ~50k chunks. The important design choice is the **lexical fallback**: if there is no API key, or the embedding call fails, search still works on token overlap. Recall that degrades honestly is far better than a vector store that returns nothing and looks broken. """ from __future__ import annotations import json import math import re import sqlite3 import threading import time import urllib.request from pathlib import Path from typing import Any, Optional from core.data_paths import data_root CHUNK_CHARS = 900 # ~a paragraph or two; big enough to be a fact CHUNK_OVERLAP = 120 # so a fact spanning the cut is still found # gemini-embedding-001 is the model this API version actually serves; # text-embedding-004 404s on v1beta embed_content. Probed, not assumed. EMBED_MODEL = "gemini-embedding-001" DIM = 768 _lock = threading.RLock() _db: Optional[sqlite3.Connection] = None # ── store ──────────────────────────────────────────────────────────────────── def _path() -> Path: return data_root() / "knowledge.sqlite" def db() -> sqlite3.Connection: global _db with _lock: if _db is not None: return _db p = _path() p.parent.mkdir(parents=True, exist_ok=True) # check_same_thread=False is safe HERE specifically because every access # in this module is wrapped in _lock. Without it, the server's # asyncio.to_thread workers each get a different thread and the second # one to touch the database raises. conn = sqlite3.connect(str(p), timeout=15, check_same_thread=False) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") conn.execute("""CREATE TABLE IF NOT EXISTS docs ( id INTEGER PRIMARY KEY, title TEXT, source TEXT, uri TEXT, created REAL, meta TEXT, chunks INTEGER DEFAULT 0)""") conn.execute("""CREATE TABLE IF NOT EXISTS chunks ( id INTEGER PRIMARY KEY, doc_id INTEGER, ord INTEGER, text TEXT, vec BLOB, dim INTEGER, ts REAL)""") conn.execute("""CREATE TABLE IF NOT EXISTS turns ( id INTEGER PRIMARY KEY, ts REAL, role TEXT, text TEXT, session TEXT)""") conn.execute("CREATE INDEX IF NOT EXISTS chunks_doc ON chunks(doc_id)") conn.execute("CREATE INDEX IF NOT EXISTS turns_ts ON turns(ts)") conn.commit() _db = conn return _db def reset() -> None: """Test/deploy hook.""" global _db with _lock: if _db is not None: try: _db.close() except Exception: pass _db = None # ── embeddings ─────────────────────────────────────────────────────────────── def _api_key() -> str: try: from core import gemini return (gemini.api_key() or "").strip() except Exception: return "" def embed(texts: list[str], *, timeout: float = 25.0) -> Optional[list[list[float]]]: """Gemini embeddings, or None. Never raises: callers fall back to lexical.""" if not texts: return [] key = _api_key() if not key: return None try: from google import genai from google.genai import types as gtypes client = genai.Client(api_key=key) out: list[list[float]] = [] # one call for the whole batch where it fits, chunked to stay in limits for i in range(0, len(texts), 20): batch = [t[:6000] for t in texts[i:i + 20]] resp = client.models.embed_content( model=EMBED_MODEL, contents=batch, config=gtypes.EmbedContentConfig(output_dimensionality=DIM)) for e in (resp.embeddings or []): out.append(list((e.values or []))) return out or None except Exception: return None def _pack(vec: list[float]) -> bytes: import struct return struct.pack(f"<{len(vec)}f", *[float(v) for v in vec]) def _unpack(blob: bytes) -> list[float]: import struct n = len(blob) // 4 return list(struct.unpack(f"<{n}f", blob)) # ── lexical fallback ───────────────────────────────────────────────────────── _WORD = re.compile(r"[a-z0-9_]{2,}") _STOP = {"the", "and", "for", "you", "that", "this", "with", "was", "are", "but", "not", "have", "has", "had", "from", "they", "them", "his", "her", "she", "him", "its", "our", "your", "what", "when", "where", "who", "how", "why", "can", "could", "would", "should", "will", "did", "does", "been", "there", "here", "about", "just", "like", "get", "got"} def _tokens(text: str) -> list[str]: return [w for w in _WORD.findall((text or "").lower()) if w not in _STOP] def _lexical_score(query: str, text: str) -> float: """Token overlap with a small idf-ish twist: rarer words count more.""" q = set(_tokens(query)) if not q: return 0.0 t = _tokens(text) if not t: return 0.0 overlap = sum(1 for w in set(t) if w in q) if not overlap: return 0.0 # reward density, not just presence density = overlap / (len(set(t)) ** 0.5) return overlap * 0.6 + density # ── transcripts ────────────────────────────────────────────────────────────── def record_turn(role: str, text: str, *, session: str = "") -> bool: """One conversation turn. Cheap enough to always call.""" text = str(text or "").strip() if not text: return False try: with _lock: db().execute( "INSERT INTO turns (ts, role, text, session) VALUES (?,?,?,?)", (time.time(), str(role or "?"), text[:4000], str(session or ""))) db().commit() return True except Exception: return False def recent_turns(limit: int = 20, *, role: str = "", query: str = "") -> list[dict]: with _lock: c = db() if query: rows = c.execute( "SELECT * FROM turns WHERE text LIKE ? ORDER BY ts DESC LIMIT ?", (f"%{query}%", max(1, min(limit, 500)))).fetchall() else: rows = c.execute( "SELECT * FROM turns WHERE (? = '' OR role = ?) " "ORDER BY ts DESC LIMIT ?", (role, role, max(1, min(limit, 500)))).fetchall() return [{"ts": r["ts"], "role": r["role"], "text": r["text"], "session": r["session"]} for r in rows] def turn_count() -> int: with _lock: return int(db().execute("SELECT COUNT(*) c FROM turns").fetchone()["c"]) # ── ingest ─────────────────────────────────────────────────────────────────── def _chunk_text(text: str) -> list[str]: """Split on paragraph boundaries, then hard-wrap anything oversized.""" text = re.sub(r"\n{3,}", "\n\n", (text or "").strip()) if not text: return [] paras = [p.strip() for p in text.split("\n\n") if p.strip()] out: list[str] = [] buf = "" for para in paras: while len(para) > CHUNK_CHARS: head, para = para[:CHUNK_CHARS], para[CHUNK_CHARS - CHUNK_OVERLAP:] out.append(head) if len(buf) + len(para) + 2 <= CHUNK_CHARS: buf = f"{buf}\n\n{para}" if buf else para else: if buf: out.append(buf) buf = para if buf: out.append(buf) return [c for c in out if len(c) > 20] def ingest(title: str, text: str, *, source: str = "note", uri: str = "", meta: Optional[dict] = None) -> dict: """Store a document and make it recallable. Returns the doc record.""" parts = _chunk_text(text) if not parts: raise ValueError("nothing worth storing — that text is empty or too short") vecs = embed(parts) with _lock: c = db() cur = c.execute( "INSERT INTO docs (title, source, uri, created, meta, chunks) " "VALUES (?,?,?,?,?,?)", (str(title or "untitled")[:160], str(source or "note")[:40], str(uri or "")[:300], time.time(), json.dumps(meta or {}, ensure_ascii=False), len(parts))) doc_id = int(cur.lastrowid) now = time.time() for i, part in enumerate(parts): vec = vecs[i] if vecs and i < len(vecs) else None c.execute( "INSERT INTO chunks (doc_id, ord, text, vec, dim, ts) " "VALUES (?,?,?,?,?,?)", (doc_id, i, part, _pack(vec) if vec else None, len(vec) if vec else 0, now)) c.commit() return {"id": doc_id, "title": str(title or "untitled")[:160], "source": str(source or "note")[:40], "uri": str(uri or "")[:300], "chunks": len(parts), "embedded": bool(vecs)} def fetch_url(url: str, *, limit: int = 40000) -> tuple[str, str]: """Title + readable text from a URL. Deliberately plain: strip tags, keep paragraph breaks, no readability library to keep in the image.""" req = urllib.request.Request(url, headers={"User-Agent": "Jaarvis/1.0"}) with urllib.request.urlopen(req, timeout=25) as r: raw = r.read().decode("utf-8", "replace") ctype = r.headers.get("content-type", "") m = re.search(r"