feat: workspaces, agentic action tools, local Whisper STT, vec recall
- Projects/workspaces: documents grouped into projects; chat RAG scopes to the active project. Switcher in the Documents page. - Agentic action tools: web_search, fetch_url, and remember (first write tool), allowlist-gated per playbook. - Local Whisper STT (faster-whisper, no torch): on-device dictation replacing the browser Web Speech API. POST /stt + GET /stt/status; browser fallback. - Vector index extended to conversation recall (message_vectors), with the brute-force cosine scan kept as the fallback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -19,23 +19,64 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
|||||||
const [editingIdx, setEditingIdx] = useState(null); // user message being edited
|
const [editingIdx, setEditingIdx] = useState(null); // user message being edited
|
||||||
const [editText, setEditText] = useState("");
|
const [editText, setEditText] = useState("");
|
||||||
const [listening, setListening] = useState(false); // mic dictation active
|
const [listening, setListening] = useState(false); // mic dictation active
|
||||||
|
const [transcribing, setTranscribing] = useState(false); // local STT running
|
||||||
|
const [sttLocal, setSttLocal] = useState(false); // backend Whisper available
|
||||||
const [speakingIdx, setSpeakingIdx] = useState(null); // message being read aloud
|
const [speakingIdx, setSpeakingIdx] = useState(null); // message being read aloud
|
||||||
const recognitionRef = useRef(null);
|
const recognitionRef = useRef(null);
|
||||||
|
const mediaRecRef = useRef(null);
|
||||||
|
|
||||||
// Web Speech API — browser-native, no backend/model. Absent on unsupported browsers.
|
// Web Speech API — browser-native, no backend/model. Absent on unsupported browsers.
|
||||||
const SpeechRec = typeof window !== "undefined" && (window.SpeechRecognition || window.webkitSpeechRecognition);
|
const SpeechRec = typeof window !== "undefined" && (window.SpeechRecognition || window.webkitSpeechRecognition);
|
||||||
const ttsSupported = typeof window !== "undefined" && "speechSynthesis" in window;
|
const ttsSupported = typeof window !== "undefined" && "speechSynthesis" in window;
|
||||||
|
const canRecord = typeof navigator !== "undefined" && navigator.mediaDevices && window.MediaRecorder;
|
||||||
|
|
||||||
const toggleMic = () => {
|
// Prefer local Whisper (on-device) over browser speech (Chrome routes audio to Google).
|
||||||
if (!SpeechRec) return;
|
useEffect(() => {
|
||||||
if (listening) { recognitionRef.current?.stop(); return; }
|
fetch(`${API_BASE}/stt/status`).then(r => r.ok ? r.json() : null)
|
||||||
|
.then(d => setSttLocal(Boolean(d && d.available))).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _appendTranscript = (text) => {
|
||||||
|
if (text) setInput(prev => (prev ? prev + " " : "") + text);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Local path: record audio, POST to /stt (faster-whisper transcribes on-device).
|
||||||
|
const startLocalDictation = async () => {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
const rec = new MediaRecorder(stream);
|
||||||
|
const chunks = [];
|
||||||
|
rec.ondataavailable = e => e.data.size && chunks.push(e.data);
|
||||||
|
rec.onstop = async () => {
|
||||||
|
stream.getTracks().forEach(t => t.stop());
|
||||||
|
setListening(false);
|
||||||
|
setTranscribing(true);
|
||||||
|
try {
|
||||||
|
const b64 = await new Promise((res) => {
|
||||||
|
const fr = new FileReader();
|
||||||
|
fr.onload = () => res(String(fr.result).split(",")[1]);
|
||||||
|
fr.readAsDataURL(new Blob(chunks, { type: rec.mimeType }));
|
||||||
|
});
|
||||||
|
const r = await fetch(`${API_BASE}/stt`, {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ audio: b64 }),
|
||||||
|
});
|
||||||
|
if (r.ok) _appendTranscript((await r.json()).text);
|
||||||
|
} catch { /* ignore */ } finally {
|
||||||
|
setTranscribing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
mediaRecRef.current = rec;
|
||||||
|
setListening(true);
|
||||||
|
rec.start();
|
||||||
|
} catch { setListening(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const startBrowserDictation = () => {
|
||||||
const rec = new SpeechRec();
|
const rec = new SpeechRec();
|
||||||
rec.lang = "en-US";
|
rec.lang = "en-US";
|
||||||
rec.interimResults = false;
|
rec.interimResults = false;
|
||||||
rec.onresult = (e) => {
|
rec.onresult = (e) => _appendTranscript(Array.from(e.results).map(r => r[0].transcript).join(" ").trim());
|
||||||
const text = Array.from(e.results).map(r => r[0].transcript).join(" ").trim();
|
|
||||||
if (text) setInput(prev => (prev ? prev + " " : "") + text);
|
|
||||||
};
|
|
||||||
rec.onend = () => setListening(false);
|
rec.onend = () => setListening(false);
|
||||||
rec.onerror = () => setListening(false);
|
rec.onerror = () => setListening(false);
|
||||||
recognitionRef.current = rec;
|
recognitionRef.current = rec;
|
||||||
@@ -43,6 +84,17 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
|||||||
rec.start();
|
rec.start();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleMic = () => {
|
||||||
|
if (transcribing) return;
|
||||||
|
if (listening) {
|
||||||
|
if (sttLocal && mediaRecRef.current) mediaRecRef.current.stop();
|
||||||
|
else recognitionRef.current?.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sttLocal && canRecord) startLocalDictation();
|
||||||
|
else if (SpeechRec) startBrowserDictation();
|
||||||
|
};
|
||||||
|
|
||||||
// Read a reply aloud (light markdown strip so symbols aren't spoken).
|
// Read a reply aloud (light markdown strip so symbols aren't spoken).
|
||||||
const speak = (idx, text) => {
|
const speak = (idx, text) => {
|
||||||
if (!ttsSupported) return;
|
if (!ttsSupported) return;
|
||||||
@@ -617,10 +669,11 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
|
|||||||
📎
|
📎
|
||||||
<input type="file" accept="image/*" multiple onChange={onImagePick} disabled={loading} style={{ display: "none" }} />
|
<input type="file" accept="image/*" multiple onChange={onImagePick} disabled={loading} style={{ display: "none" }} />
|
||||||
</label>
|
</label>
|
||||||
{SpeechRec && (
|
{((sttLocal && canRecord) || SpeechRec) && (
|
||||||
<button onClick={toggleMic} title={listening ? "Stop dictation" : "Dictate (speech to text)"}
|
<button onClick={toggleMic}
|
||||||
style={{ padding: "0.9rem", background: listening ? "#3a1a1a" : "#222", border: "1px solid " + (listening ? "#c0392b" : "#333"), borderRadius: "10px", cursor: "pointer", color: listening ? "#ff8a80" : "#ccc" }}>
|
title={transcribing ? "Transcribing…" : listening ? "Stop dictation" : sttLocal ? "Dictate (on-device Whisper)" : "Dictate (browser speech)"}
|
||||||
{listening ? "🔴" : "🎤"}
|
style={{ padding: "0.9rem", background: listening ? "#3a1a1a" : "#222", border: "1px solid " + (listening ? "#c0392b" : "#333"), borderRadius: "10px", cursor: transcribing ? "default" : "pointer", color: listening ? "#ff8a80" : "#ccc" }}>
|
||||||
|
{transcribing ? "⏳" : listening ? "🔴" : "🎤"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -10,6 +10,43 @@ export function Documents() {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [viewing, setViewing] = useState(null); // {title, chunks} being previewed
|
const [viewing, setViewing] = useState(null); // {title, chunks} being previewed
|
||||||
|
const [projects, setProjects] = useState([]);
|
||||||
|
const [activeProject, setActiveProject] = useState(""); // "" = All
|
||||||
|
|
||||||
|
const loadProjects = async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API_BASE}/projects`);
|
||||||
|
if (r.ok) { const d = await r.json(); setProjects(d.projects || []); setActiveProject(d.active || ""); }
|
||||||
|
} catch { /* offline */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Switch the active workspace (persisted in settings; scopes chat RAG too).
|
||||||
|
const setActive = async (pid) => {
|
||||||
|
await fetch(`${API_BASE}/settings`, {
|
||||||
|
method: "PUT", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ active_project: pid }),
|
||||||
|
});
|
||||||
|
setActiveProject(pid);
|
||||||
|
load();
|
||||||
|
loadProjects();
|
||||||
|
};
|
||||||
|
|
||||||
|
const newProject = async () => {
|
||||||
|
const name = window.prompt("New project name:");
|
||||||
|
if (!name || !name.trim()) return;
|
||||||
|
const r = await fetch(`${API_BASE}/projects`, {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: name.trim() }),
|
||||||
|
});
|
||||||
|
if (r.ok) { const p = await r.json(); await setActive(p.id); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeProject = async () => {
|
||||||
|
if (!activeProject) return;
|
||||||
|
if (!window.confirm("Delete this project? Its documents are kept but become unscoped.")) return;
|
||||||
|
await fetch(`${API_BASE}/projects/${encodeURIComponent(activeProject)}`, { method: "DELETE" });
|
||||||
|
await setActive("");
|
||||||
|
};
|
||||||
|
|
||||||
const openDoc = async (doc) => {
|
const openDoc = async (doc) => {
|
||||||
try {
|
try {
|
||||||
@@ -25,7 +62,7 @@ export function Documents() {
|
|||||||
} catch { /* offline — leave list as-is */ }
|
} catch { /* offline — leave list as-is */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { load(); }, []);
|
useEffect(() => { load(); loadProjects(); }, []);
|
||||||
|
|
||||||
// Files (pdf/docx/txt/md) upload straight to the server, which extracts the
|
// Files (pdf/docx/txt/md) upload straight to the server, which extracts the
|
||||||
// text. Base64 in JSON — no multipart dependency.
|
// text. Base64 in JSON — no multipart dependency.
|
||||||
@@ -106,6 +143,30 @@ export function Documents() {
|
|||||||
material when a message is relevant.
|
material when a message is relevant.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem", marginBottom: "1.2rem", flexWrap: "wrap" }}>
|
||||||
|
<span style={{ color: "#888", fontSize: "0.85rem" }}>Workspace:</span>
|
||||||
|
<select
|
||||||
|
value={activeProject}
|
||||||
|
onChange={e => (e.target.value === "__new__" ? newProject() : setActive(e.target.value))}
|
||||||
|
style={{ padding: "0.5rem 0.7rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "8px" }}
|
||||||
|
>
|
||||||
|
<option value="">All documents</option>
|
||||||
|
{projects.map(p => (
|
||||||
|
<option key={p.id} value={p.id}>{p.name} ({p.docs})</option>
|
||||||
|
))}
|
||||||
|
<option value="__new__">+ New project…</option>
|
||||||
|
</select>
|
||||||
|
{activeProject && (
|
||||||
|
<button onClick={removeProject} title="Delete this project (documents kept)"
|
||||||
|
style={{ padding: "0.4rem 0.7rem", background: "#2a1a1a", color: "#ff8a80", border: "1px solid #5a2a2a", borderRadius: "8px", cursor: "pointer", fontSize: "0.8rem" }}>
|
||||||
|
Delete project
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span style={{ color: "#666", fontSize: "0.78rem" }}>
|
||||||
|
{activeProject ? "Chat scopes to this project's documents." : "Chat searches all documents."}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.7rem", marginBottom: "1.5rem" }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.7rem", marginBottom: "1.5rem" }}>
|
||||||
<input type="text" placeholder="Title" value={title}
|
<input type="text" placeholder="Title" value={title}
|
||||||
onChange={e => setTitle(e.target.value)} style={input} />
|
onChange={e => setTitle(e.target.value)} style={input} />
|
||||||
|
|||||||
@@ -360,7 +360,7 @@ export function Playbook() {
|
|||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Tools (comma separated): search_memory, search_history, search_documents, list_models, get_time"
|
placeholder="Tools: search_memory, search_history, search_documents, list_models, get_time, web_search, fetch_url, remember"
|
||||||
value={form.tools}
|
value={form.tools}
|
||||||
onChange={e => setForm(prev => ({ ...prev, tools: e.target.value }))}
|
onChange={e => setForm(prev => ({ ...prev, tools: e.target.value }))}
|
||||||
style={{ padding: "0.9rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "10px" }}
|
style={{ padding: "0.9rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "10px" }}
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ python-docx
|
|||||||
# Vector search: loadable SQLite extension (prebuilt wheels). Brute-force cosine
|
# Vector search: loadable SQLite extension (prebuilt wheels). Brute-force cosine
|
||||||
# stays as the fallback when the host Python can't load extensions.
|
# stays as the fallback when the host Python can't load extensions.
|
||||||
sqlite-vec
|
sqlite-vec
|
||||||
|
# Local speech-to-text: CTranslate2-based, no torch, keeps dictation on-device.
|
||||||
|
faster-whisper
|
||||||
|
|
||||||
# Documentation Support
|
# Documentation Support
|
||||||
markdown-it-py
|
markdown-it-py
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ python-docx
|
|||||||
# Vector search: loadable SQLite extension (prebuilt wheels). Brute-force cosine
|
# Vector search: loadable SQLite extension (prebuilt wheels). Brute-force cosine
|
||||||
# stays as the fallback when the host Python can't load extensions.
|
# stays as the fallback when the host Python can't load extensions.
|
||||||
sqlite-vec
|
sqlite-vec
|
||||||
|
# Local speech-to-text: CTranslate2-based, no torch, keeps dictation on-device.
|
||||||
|
faster-whisper
|
||||||
|
|
||||||
# Native desktop window for the UI (WebView2 on Windows; pulls pythonnet).
|
# Native desktop window for the UI (WebView2 on Windows; pulls pythonnet).
|
||||||
# Used by bin/nexus_window.py, launched from launch_nexus.ps1.
|
# Used by bin/nexus_window.py, launched from launch_nexus.ps1.
|
||||||
|
|||||||
+62
-3
@@ -290,6 +290,7 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
|||||||
message, get_ollama_manager().embed,
|
message, get_ollama_manager().embed,
|
||||||
limit=app_settings.get("rag_top_k", 3),
|
limit=app_settings.get("rag_top_k", 3),
|
||||||
min_score=app_settings.get("rag_min_score", 0.6),
|
min_score=app_settings.get("rag_min_score", 0.6),
|
||||||
|
project_id=app_settings.get("active_project", "") or None,
|
||||||
)
|
)
|
||||||
doc_titles: list = []
|
doc_titles: list = []
|
||||||
if doc_hits:
|
if doc_hits:
|
||||||
@@ -943,12 +944,70 @@ async def delete_playbook_endpoint(id: UUID):
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# Speech-to-text (local Whisper)
|
||||||
|
# -------------------------
|
||||||
|
from . import stt as _stt
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/stt/status")
|
||||||
|
async def stt_status():
|
||||||
|
return {"available": _stt.available()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/stt")
|
||||||
|
async def stt_transcribe(payload: Dict[str, Any] = Body(...)):
|
||||||
|
if not _stt.available():
|
||||||
|
raise HTTPException(status_code=503, detail="local STT (faster-whisper) not installed")
|
||||||
|
audio = payload.get("audio") or ""
|
||||||
|
if not audio:
|
||||||
|
raise HTTPException(status_code=400, detail="audio (base64) is required")
|
||||||
|
try:
|
||||||
|
text = await _asyncio.to_thread(_stt.transcribe_b64, audio)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"transcription failed: {e}")
|
||||||
|
return {"text": text}
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# Projects / workspaces
|
||||||
|
# -------------------------
|
||||||
|
def _active_project() -> str:
|
||||||
|
"""The active project id, or '' for the unscoped 'All' view."""
|
||||||
|
return store.get_settings().get("active_project", "") or ""
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/projects")
|
||||||
|
async def list_projects():
|
||||||
|
return {"projects": store.list_projects(), "active": _active_project()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/projects")
|
||||||
|
async def create_project(payload: Dict[str, Any] = Body(...)):
|
||||||
|
name = (payload.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=400, detail="name is required")
|
||||||
|
return store.create_project(name)
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/projects/{project_id}")
|
||||||
|
async def delete_project(project_id: str):
|
||||||
|
if not store.delete_project(project_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Not Found")
|
||||||
|
# If the deleted project was active, fall back to the "All" view.
|
||||||
|
if _active_project() == project_id:
|
||||||
|
store.update_settings({"active_project": ""})
|
||||||
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# Documents (RAG)
|
# Documents (RAG)
|
||||||
# -------------------------
|
# -------------------------
|
||||||
@app.get("/documents")
|
@app.get("/documents")
|
||||||
async def list_documents():
|
async def list_documents():
|
||||||
return {"documents": store.list_documents()}
|
# Scope to the active project; "" (All) lists everything.
|
||||||
|
active = _active_project()
|
||||||
|
return {"documents": store.list_documents(active if active else None)}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/documents")
|
@app.post("/documents")
|
||||||
@@ -957,7 +1016,7 @@ async def add_document(payload: Dict[str, Any] = Body(...)):
|
|||||||
content = (payload.get("content") or "").strip()
|
content = (payload.get("content") or "").strip()
|
||||||
if not title or not content:
|
if not title or not content:
|
||||||
raise HTTPException(status_code=400, detail="title and content are required")
|
raise HTTPException(status_code=400, detail="title and content are required")
|
||||||
result = await store.add_document(title, content, get_ollama_manager().embed)
|
result = await store.add_document(title, content, get_ollama_manager().embed, _active_project())
|
||||||
if result["chunks"] == 0:
|
if result["chunks"] == 0:
|
||||||
raise HTTPException(status_code=400, detail="no text to index")
|
raise HTTPException(status_code=400, detail="no text to index")
|
||||||
return result
|
return result
|
||||||
@@ -1000,7 +1059,7 @@ async def upload_document(payload: Dict[str, Any] = Body(...)):
|
|||||||
if not text:
|
if not text:
|
||||||
raise HTTPException(status_code=400, detail="no extractable text in file")
|
raise HTTPException(status_code=400, detail="no extractable text in file")
|
||||||
title = _os.path.splitext(_os.path.basename(filename))[0] or filename
|
title = _os.path.splitext(_os.path.basename(filename))[0] or filename
|
||||||
return await store.add_document(title, text, get_ollama_manager().embed)
|
return await store.add_document(title, text, get_ollama_manager().embed, _active_project())
|
||||||
|
|
||||||
|
|
||||||
@app.get("/documents/{doc_id}")
|
@app.get("/documents/{doc_id}")
|
||||||
|
|||||||
+162
-31
@@ -191,6 +191,20 @@ class PersistentMemoryStore:
|
|||||||
""")
|
""")
|
||||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_documents_doc_id ON documents (doc_id)")
|
cur.execute("CREATE INDEX IF NOT EXISTS idx_documents_doc_id ON documents (doc_id)")
|
||||||
|
|
||||||
|
# Projects / workspaces: group documents so RAG can scope to one set.
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at REAL NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
# documents.project_id — "" (or missing) means unscoped / All.
|
||||||
|
try:
|
||||||
|
cur.execute("ALTER TABLE documents ADD COLUMN project_id TEXT NOT NULL DEFAULT ''")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
@@ -569,26 +583,31 @@ class PersistentMemoryStore:
|
|||||||
"INSERT OR REPLACE INTO message_vectors (message_id, embedding) VALUES (?, ?)",
|
"INSERT OR REPLACE INTO message_vectors (message_id, embedding) VALUES (?, ?)",
|
||||||
(row["id"], json.dumps(vec)),
|
(row["id"], json.dumps(vec)),
|
||||||
)
|
)
|
||||||
|
self._vec_upsert_msg(conn, row["id"], vec) # mirror into the ANN index
|
||||||
if missing:
|
if missing:
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
# Score every stored message against the query vector.
|
# Rank messages by similarity. Fast path: the sqlite-vec ANN index over an
|
||||||
cur.execute("""
|
# over-fetch (conversation dedup below thins it); else brute-force cosine.
|
||||||
SELECT v.message_id, v.embedding, m.conversation_id
|
scored = None
|
||||||
FROM message_vectors v
|
if self.vec_enabled:
|
||||||
JOIN messages m ON m.id = v.message_id
|
scored = self._vec_search_messages(conn, query_vec, max(limit * 5, 20), min_score)
|
||||||
""")
|
if scored is None:
|
||||||
scored = []
|
cur.execute("""
|
||||||
for row in cur.fetchall():
|
SELECT v.message_id, v.embedding, m.conversation_id
|
||||||
try:
|
FROM message_vectors v
|
||||||
vec = json.loads(row["embedding"])
|
JOIN messages m ON m.id = v.message_id
|
||||||
except Exception:
|
""")
|
||||||
continue
|
scored = []
|
||||||
score = _cosine(query_vec, vec)
|
for row in cur.fetchall():
|
||||||
if score >= min_score:
|
try:
|
||||||
scored.append((score, row["message_id"], row["conversation_id"]))
|
vec = json.loads(row["embedding"])
|
||||||
|
except Exception:
|
||||||
scored.sort(reverse=True)
|
continue
|
||||||
|
score = _cosine(query_vec, vec)
|
||||||
|
if score >= min_score:
|
||||||
|
scored.append((score, row["message_id"], row["conversation_id"]))
|
||||||
|
scored.sort(reverse=True)
|
||||||
|
|
||||||
results: List[dict] = []
|
results: List[dict] = []
|
||||||
seen_convs: set = set()
|
seen_convs: set = set()
|
||||||
@@ -616,6 +635,70 @@ class PersistentMemoryStore:
|
|||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
# --- message vector index (sqlite-vec) — same pattern as documents --------
|
||||||
|
def _ensure_vec_msgs(self, conn, dim: int) -> None:
|
||||||
|
conn.execute(
|
||||||
|
f"CREATE VIRTUAL TABLE IF NOT EXISTS vec_messages "
|
||||||
|
f"USING vec0(embedding float[{dim}] distance_metric=cosine)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _vec_upsert_msg(self, conn, message_id: int, vec: list) -> None:
|
||||||
|
if not (self.vec_enabled and vec):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import sqlite_vec
|
||||||
|
self._ensure_vec_msgs(conn, len(vec))
|
||||||
|
conn.execute("DELETE FROM vec_messages WHERE rowid = ?", (message_id,))
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO vec_messages(rowid, embedding) VALUES (?, ?)",
|
||||||
|
(message_id, sqlite_vec.serialize_float32(vec)),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _backfill_vec_msgs(self, conn, dim: int) -> None:
|
||||||
|
"""Index any message_vectors rows missing from vec_messages."""
|
||||||
|
try:
|
||||||
|
import sqlite_vec
|
||||||
|
self._ensure_vec_msgs(conn, dim)
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT mv.message_id AS mid, mv.embedding AS emb FROM message_vectors mv "
|
||||||
|
"LEFT JOIN vec_messages v ON v.rowid = mv.message_id WHERE v.rowid IS NULL"
|
||||||
|
).fetchall()
|
||||||
|
for r in rows:
|
||||||
|
try:
|
||||||
|
vec = json.loads(r["emb"])
|
||||||
|
if len(vec) == dim:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO vec_messages(rowid, embedding) VALUES (?, ?)",
|
||||||
|
(r["mid"], sqlite_vec.serialize_float32(vec)),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _vec_search_messages(self, conn, query_vec: list, fetch: int, min_score: float):
|
||||||
|
"""Top message hits via the vec index as sorted [(score, msg_id, conv_id)],
|
||||||
|
or None to fall back to the brute-force scan. Stale rows for deleted
|
||||||
|
messages are dropped by the inner join, so they never surface."""
|
||||||
|
try:
|
||||||
|
import sqlite_vec
|
||||||
|
self._backfill_vec_msgs(conn, len(query_vec))
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT v.rowid AS mid, v.distance AS distance, m.conversation_id AS cid "
|
||||||
|
"FROM vec_messages v JOIN messages m ON m.id = v.rowid "
|
||||||
|
"WHERE v.embedding MATCH ? ORDER BY v.distance LIMIT ?",
|
||||||
|
(sqlite_vec.serialize_float32(query_vec), fetch),
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
(1.0 - r["distance"], r["mid"], r["cid"])
|
||||||
|
for r in rows if (1.0 - r["distance"]) >= min_score
|
||||||
|
] # distance-asc == score-desc, already sorted
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
def _exchange_pair(self, cur, conv_id: str, message_id: int) -> Optional[dict]:
|
def _exchange_pair(self, cur, conv_id: str, message_id: int) -> Optional[dict]:
|
||||||
"""Build a {id, updated_at, matches} record with the full user+assistant
|
"""Build a {id, updated_at, matches} record with the full user+assistant
|
||||||
pair surrounding `message_id`, in the shape search callers expect."""
|
pair surrounding `message_id`, in the shape search callers expect."""
|
||||||
@@ -677,7 +760,7 @@ class PersistentMemoryStore:
|
|||||||
chunks.append(buf)
|
chunks.append(buf)
|
||||||
return chunks
|
return chunks
|
||||||
|
|
||||||
async def add_document(self, title: str, content: str, embed_fn) -> dict:
|
async def add_document(self, title: str, content: str, embed_fn, project_id: str = "") -> dict:
|
||||||
"""Chunk, embed, and store a document. Returns {doc_id, chunks}."""
|
"""Chunk, embed, and store a document. Returns {doc_id, chunks}."""
|
||||||
import uuid as _uuid
|
import uuid as _uuid
|
||||||
doc_id = str(_uuid.uuid4())
|
doc_id = str(_uuid.uuid4())
|
||||||
@@ -688,16 +771,47 @@ class PersistentMemoryStore:
|
|||||||
for i, piece in enumerate(pieces):
|
for i, piece in enumerate(pieces):
|
||||||
vec = await embed_fn(self._EMBED_DOC_PREFIX + piece[:2000])
|
vec = await embed_fn(self._EMBED_DOC_PREFIX + piece[:2000])
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"INSERT INTO documents (id, doc_id, title, chunk_idx, text, embedding, created_at)"
|
"INSERT INTO documents (id, doc_id, title, chunk_idx, text, embedding, created_at, project_id)"
|
||||||
" VALUES (?, ?, ?, ?, ?, ?, ?)",
|
" VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
(str(_uuid.uuid4()), doc_id, title, i, piece,
|
(str(_uuid.uuid4()), doc_id, title, i, piece,
|
||||||
json.dumps(vec) if vec else None, now),
|
json.dumps(vec) if vec else None, now, project_id or ""),
|
||||||
)
|
)
|
||||||
self._vec_upsert(conn, cur.lastrowid, vec) # mirror into the ANN index
|
self._vec_upsert(conn, cur.lastrowid, vec) # mirror into the ANN index
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
return {"doc_id": doc_id, "title": title, "chunks": len(pieces)}
|
return {"doc_id": doc_id, "title": title, "chunks": len(pieces)}
|
||||||
|
|
||||||
|
# --- projects / workspaces --------------------------------------------------
|
||||||
|
def create_project(self, name: str) -> dict:
|
||||||
|
import uuid as _uuid
|
||||||
|
pid = str(_uuid.uuid4())
|
||||||
|
conn = self._connect()
|
||||||
|
conn.execute("INSERT INTO projects (id, name, created_at) VALUES (?, ?, ?)",
|
||||||
|
(pid, name.strip(), time.time()))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return {"id": pid, "name": name.strip()}
|
||||||
|
|
||||||
|
def list_projects(self) -> List[dict]:
|
||||||
|
conn = self._connect()
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT p.id, p.name, p.created_at,
|
||||||
|
(SELECT COUNT(DISTINCT doc_id) FROM documents d WHERE d.project_id = p.id) AS docs
|
||||||
|
FROM projects p ORDER BY p.created_at ASC
|
||||||
|
""").fetchall()
|
||||||
|
conn.close()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
def delete_project(self, project_id: str) -> bool:
|
||||||
|
"""Delete a project; its documents survive but become unscoped ("")."""
|
||||||
|
conn = self._connect()
|
||||||
|
conn.execute("UPDATE documents SET project_id = '' WHERE project_id = ?", (project_id,))
|
||||||
|
cur = conn.execute("DELETE FROM projects WHERE id = ?", (project_id,))
|
||||||
|
deleted = cur.rowcount
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return deleted > 0
|
||||||
|
|
||||||
# --- vector index (sqlite-vec) — accelerator over the JSON embedding column,
|
# --- vector index (sqlite-vec) — accelerator over the JSON embedding column,
|
||||||
# --- with brute-force cosine below as the guaranteed fallback ---------------
|
# --- with brute-force cosine below as the guaranteed fallback ---------------
|
||||||
def _ensure_vec_docs(self, conn, dim: int) -> None:
|
def _ensure_vec_docs(self, conn, dim: int) -> None:
|
||||||
@@ -768,13 +882,21 @@ class PersistentMemoryStore:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def list_documents(self) -> List[dict]:
|
def list_documents(self, project_id: Optional[str] = None) -> List[dict]:
|
||||||
|
"""All documents, or just one project's when project_id is given."""
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("""
|
if project_id is not None:
|
||||||
SELECT doc_id, title, COUNT(*) AS chunks, MIN(created_at) AS created_at
|
cur.execute("""
|
||||||
FROM documents GROUP BY doc_id, title ORDER BY created_at DESC
|
SELECT doc_id, title, COUNT(*) AS chunks, MIN(created_at) AS created_at
|
||||||
""")
|
FROM documents WHERE project_id = ?
|
||||||
|
GROUP BY doc_id, title ORDER BY created_at DESC
|
||||||
|
""", (project_id,))
|
||||||
|
else:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT doc_id, title, COUNT(*) AS chunks, MIN(created_at) AS created_at
|
||||||
|
FROM documents GROUP BY doc_id, title ORDER BY created_at DESC
|
||||||
|
""")
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
@@ -807,23 +929,30 @@ class PersistentMemoryStore:
|
|||||||
return deleted > 0
|
return deleted > 0
|
||||||
|
|
||||||
async def search_documents(
|
async def search_documents(
|
||||||
self, query: str, embed_fn, limit: int = 3, min_score: float = 0.6
|
self, query: str, embed_fn, limit: int = 3, min_score: float = 0.6,
|
||||||
|
project_id: Optional[str] = None,
|
||||||
) -> List[dict]:
|
) -> List[dict]:
|
||||||
"""Top-`limit` document chunks most similar to `query`. Returns
|
"""Top-`limit` document chunks most similar to `query`. Returns
|
||||||
[{title, text, score}]. Empty on no query / embeddings down."""
|
[{title, text, score}]. Empty on no query / embeddings down.
|
||||||
|
When project_id is given, only that project's docs are searched.
|
||||||
|
ponytail: scoped search uses the brute-force path (easy SQL filter, few
|
||||||
|
docs per project); the vec index accelerates the unscoped "All" case."""
|
||||||
if not query or not query.strip():
|
if not query or not query.strip():
|
||||||
return []
|
return []
|
||||||
query_vec = await embed_fn(self._EMBED_QUERY_PREFIX + query.strip())
|
query_vec = await embed_fn(self._EMBED_QUERY_PREFIX + query.strip())
|
||||||
if not query_vec:
|
if not query_vec:
|
||||||
return []
|
return []
|
||||||
# Fast path: the sqlite-vec ANN index. None => fall through to brute force.
|
# Fast path (unscoped only): the sqlite-vec ANN index.
|
||||||
if self.vec_enabled:
|
if not project_id and self.vec_enabled:
|
||||||
hits = self._vec_search(query_vec, limit, min_score)
|
hits = self._vec_search(query_vec, limit, min_score)
|
||||||
if hits is not None:
|
if hits is not None:
|
||||||
return hits
|
return hits
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("SELECT title, text, embedding FROM documents WHERE embedding IS NOT NULL")
|
if project_id:
|
||||||
|
cur.execute("SELECT title, text, embedding FROM documents WHERE embedding IS NOT NULL AND project_id = ?", (project_id,))
|
||||||
|
else:
|
||||||
|
cur.execute("SELECT title, text, embedding FROM documents WHERE embedding IS NOT NULL")
|
||||||
scored = []
|
scored = []
|
||||||
for row in cur.fetchall():
|
for row in cur.fetchall():
|
||||||
try:
|
try:
|
||||||
@@ -852,6 +981,8 @@ class PersistentMemoryStore:
|
|||||||
# cosine similarity (0-1) a chunk must clear to count as relevant.
|
# cosine similarity (0-1) a chunk must clear to count as relevant.
|
||||||
"rag_top_k": 3,
|
"rag_top_k": 3,
|
||||||
"rag_min_score": 0.6,
|
"rag_min_score": 0.6,
|
||||||
|
# Active project/workspace; "" = all documents (unscoped).
|
||||||
|
"active_project": "",
|
||||||
"system_prompt": "",
|
"system_prompt": "",
|
||||||
"timeout": 120,
|
"timeout": 120,
|
||||||
# How long Ollama keeps the model resident in VRAM between messages.
|
# How long Ollama keeps the model resident in VRAM between messages.
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Local speech-to-text via faster-whisper.
|
||||||
|
|
||||||
|
Deliberately torch-free: faster-whisper runs on CTranslate2 (CPU, int8), so it
|
||||||
|
fits the "no ML stack in the venv" rule that keeps Promethean small. Replaces
|
||||||
|
the browser's Web Speech API, which in Chrome ships audio to Google — the whole
|
||||||
|
point is to keep dictation on-device.
|
||||||
|
|
||||||
|
Model size via NEXUS_STT_MODEL (default "base"); downloaded and cached on first
|
||||||
|
use. If faster-whisper isn't installed, `available()` is False and the backend
|
||||||
|
reports it so the UI can fall back.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
_log = logging.getLogger("nexus.stt")
|
||||||
|
_MODEL = None
|
||||||
|
_MODEL_SIZE = os.getenv("NEXUS_STT_MODEL", "base")
|
||||||
|
|
||||||
|
|
||||||
|
def available() -> bool:
|
||||||
|
try:
|
||||||
|
import faster_whisper # noqa: F401
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _get_model():
|
||||||
|
global _MODEL
|
||||||
|
if _MODEL is None:
|
||||||
|
from faster_whisper import WhisperModel
|
||||||
|
_log.info("loading whisper model %s (cpu/int8)", _MODEL_SIZE)
|
||||||
|
_MODEL = WhisperModel(_MODEL_SIZE, device="cpu", compute_type="int8")
|
||||||
|
return _MODEL
|
||||||
|
|
||||||
|
|
||||||
|
def transcribe_b64(audio_b64: str) -> str:
|
||||||
|
"""Transcribe a base64 audio blob (any container PyAV can decode — the
|
||||||
|
browser's MediaRecorder produces webm/opus)."""
|
||||||
|
raw = base64.b64decode(audio_b64)
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".webm", delete=False) as f:
|
||||||
|
f.write(raw)
|
||||||
|
path = f.name
|
||||||
|
try:
|
||||||
|
segments, _info = _get_model().transcribe(path, vad_filter=True)
|
||||||
|
return " ".join(s.text for s in segments).strip()
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
+95
-6
@@ -1,17 +1,19 @@
|
|||||||
"""Read-only tools a playbook can call during chat.
|
"""Tools a playbook can call during chat.
|
||||||
|
|
||||||
Ollama drives the calling: `/api/chat` with a `tools` param returns
|
Ollama drives the calling: `/api/chat` with a `tools` param returns
|
||||||
`message.tool_calls`, and this module is just the registry + dispatch. Every
|
`message.tool_calls`, and this module is just the registry + dispatch.
|
||||||
tool here only READS local state (SQLite, Ollama) — no side effects. The
|
|
||||||
per-playbook allowlist (`PlaybookItem.tools`) is the security boundary; keep the
|
Most tools READ local state (memory, history, documents, models). A few act:
|
||||||
registry read-only until the loop is trusted.
|
`web_search`/`fetch_url` make outbound HTTP requests, and `remember` WRITES a
|
||||||
|
memory fact. The per-playbook allowlist (`PlaybookItem.tools`) is the security
|
||||||
|
boundary — an action tool only fires when a playbook explicitly lists it.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import Awaitable, Callable
|
from typing import Awaitable, Callable
|
||||||
|
|
||||||
from .memory.store import store
|
from .memory.store import store, MemoryItem
|
||||||
from .ollama_manager import get_ollama_manager
|
from .ollama_manager import get_ollama_manager
|
||||||
|
|
||||||
|
|
||||||
@@ -51,6 +53,45 @@ async def _get_time(**_) -> str:
|
|||||||
return json.dumps({"now": datetime.now().isoformat(timespec="seconds")})
|
return json.dumps({"now": datetime.now().isoformat(timespec="seconds")})
|
||||||
|
|
||||||
|
|
||||||
|
async def _web_search(query: str = "", **_) -> str:
|
||||||
|
import asyncio as _a
|
||||||
|
from .search import web_search
|
||||||
|
res = await _a.to_thread(web_search, query or "", 4)
|
||||||
|
return res or "(no results)"
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_url(url: str = "", **_) -> str:
|
||||||
|
import re
|
||||||
|
import httpx
|
||||||
|
url = (url or "").strip()
|
||||||
|
if not url.startswith(("http://", "https://")):
|
||||||
|
return json.dumps({"error": "url must start with http:// or https://"})
|
||||||
|
# ponytail: no SSRF allow/deny-list — local single-user assistant, and the
|
||||||
|
# tool only runs when a playbook explicitly grants fetch_url. Add host
|
||||||
|
# filtering if this ever serves multiple/untrusted users.
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as c:
|
||||||
|
r = await c.get(url, headers={"User-Agent": "NexusOS/1.0"})
|
||||||
|
r.raise_for_status()
|
||||||
|
html = r.text
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": f"fetch failed: {e}"})
|
||||||
|
text = re.sub(r"(?is)<(script|style).*?</\1>", " ", html)
|
||||||
|
text = re.sub(r"(?s)<[^>]+>", " ", text)
|
||||||
|
text = re.sub(r"\s+", " ", text).strip()
|
||||||
|
return text[:4000]
|
||||||
|
|
||||||
|
|
||||||
|
async def _remember(text: str = "", section: str = "General", **_) -> str:
|
||||||
|
"""WRITE tool: persist a memory fact. First action tool — allowlist-gated."""
|
||||||
|
import uuid as _uuid
|
||||||
|
text = (text or "").strip()
|
||||||
|
if not text:
|
||||||
|
return json.dumps({"error": "text is required"})
|
||||||
|
store.add(MemoryItem(id=str(_uuid.uuid4()), section=(section or "General"), text=text))
|
||||||
|
return json.dumps({"saved": text, "section": section or "General"})
|
||||||
|
|
||||||
|
|
||||||
# name -> (schema, callable). Schema is the OpenAI/Ollama function-tool format.
|
# name -> (schema, callable). Schema is the OpenAI/Ollama function-tool format.
|
||||||
REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
|
REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
|
||||||
"search_memory": (
|
"search_memory": (
|
||||||
@@ -119,6 +160,54 @@ REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
|
|||||||
},
|
},
|
||||||
_get_time,
|
_get_time,
|
||||||
),
|
),
|
||||||
|
"web_search": (
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "web_search",
|
||||||
|
"description": "Search the web (DuckDuckGo) and return the top result snippets.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"query": {"type": "string"}},
|
||||||
|
"required": ["query"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_web_search,
|
||||||
|
),
|
||||||
|
"fetch_url": (
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "fetch_url",
|
||||||
|
"description": "Fetch a web page and return its visible text (truncated).",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"url": {"type": "string", "description": "http(s) URL"}},
|
||||||
|
"required": ["url"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_fetch_url,
|
||||||
|
),
|
||||||
|
"remember": (
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "remember",
|
||||||
|
"description": "Save a durable fact to the user's persistent memory.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"text": {"type": "string", "description": "the fact to remember"},
|
||||||
|
"section": {"type": "string", "description": "optional category, e.g. Health"},
|
||||||
|
},
|
||||||
|
"required": ["text"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_remember,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,62 @@ def test_search_empty_query_returns_nothing():
|
|||||||
assert asyncio.run(s.search_documents("", _fake_embed)) == []
|
assert asyncio.run(s.search_documents("", _fake_embed)) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_conversation_recall_uses_vec_and_matches_brute_force():
|
||||||
|
s = _store()
|
||||||
|
if not s.vec_enabled:
|
||||||
|
import pytest
|
||||||
|
pytest.skip("sqlite-vec not loadable on this host")
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
s.create_conversation("c1")
|
||||||
|
s.add_message("c1", "user", "tell me about lego star wars")
|
||||||
|
s.add_message("c1", "assistant", "lego star wars is a fun game")
|
||||||
|
s.create_conversation("c2")
|
||||||
|
s.add_message("c2", "user", "gpu vega vram notes")
|
||||||
|
s.add_message("c2", "assistant", "vega has 4gb")
|
||||||
|
|
||||||
|
hits = await s.semantic_search_conversations("lego star wars", _fake_embed, limit=2, min_score=0.1)
|
||||||
|
assert hits and hits[0]["id"] == "c1"
|
||||||
|
|
||||||
|
conn = s._connect()
|
||||||
|
n = conn.execute("SELECT COUNT(*) FROM vec_messages").fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
assert n >= 2 # dual-write populated the message vec index
|
||||||
|
|
||||||
|
s.vec_enabled = False
|
||||||
|
bf = await s.semantic_search_conversations("lego star wars", _fake_embed, limit=2, min_score=0.1)
|
||||||
|
assert bf[0]["id"] == hits[0]["id"]
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_projects_scope_documents_and_survive_delete():
|
||||||
|
s = _store()
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
p = s.create_project("Star Wars")
|
||||||
|
assert [x["name"] for x in s.list_projects()] == ["Star Wars"]
|
||||||
|
|
||||||
|
await s.add_document("Lego", "lego star wars boss tips", _fake_embed, project_id=p["id"])
|
||||||
|
await s.add_document("GPU", "gpu vega notes", _fake_embed) # unscoped
|
||||||
|
|
||||||
|
# project sees only its own; unscoped/all sees both
|
||||||
|
assert [d["title"] for d in s.list_documents(p["id"])] == ["Lego"]
|
||||||
|
assert len(s.list_documents(None)) == 2
|
||||||
|
|
||||||
|
# scoped search only returns the project's docs
|
||||||
|
scoped = await s.search_documents("star wars", _fake_embed, min_score=0.1, project_id=p["id"])
|
||||||
|
assert scoped and all(h["title"] == "Lego" for h in scoped)
|
||||||
|
|
||||||
|
# deleting the project keeps the docs but unscopes them
|
||||||
|
assert s.delete_project(p["id"]) is True
|
||||||
|
assert s.list_projects() == []
|
||||||
|
assert len(s.list_documents(None)) == 2
|
||||||
|
assert len(s.list_documents(p["id"])) == 0 # nothing left in that project
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
def test_chunker_overlap_and_hard_split():
|
def test_chunker_overlap_and_hard_split():
|
||||||
s = _store()
|
s = _store()
|
||||||
# a single oversized paragraph (no blank lines, as in PDF text) is split
|
# a single oversized paragraph (no blank lines, as in PDF text) is split
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ def test_keep_alive_pins_the_model():
|
|||||||
assert "keep_alive" not in mgr._apply_keep_alive({"model": "x"})
|
assert "keep_alive" not in mgr._apply_keep_alive({"model": "x"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_stt_status_endpoint():
|
||||||
|
# Reports whether local Whisper is installed; wiring must respond either way.
|
||||||
|
resp = TestClient(app).get("/stt/status")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert isinstance(resp.json()["available"], bool)
|
||||||
|
|
||||||
|
|
||||||
def test_num_ctx_option_only_when_positive():
|
def test_num_ctx_option_only_when_positive():
|
||||||
# 0 / None -> omit num_ctx so Ollama uses the model default; positive -> set it.
|
# 0 / None -> omit num_ctx so Ollama uses the model default; positive -> set it.
|
||||||
from synapse.ollama_manager import _chat_options
|
from synapse.ollama_manager import _chat_options
|
||||||
|
|||||||
@@ -23,6 +23,24 @@ async def _drain(gen):
|
|||||||
return [s async for s in gen]
|
return [s async for s in gen]
|
||||||
|
|
||||||
|
|
||||||
|
def test_remember_writes_a_memory_fact(tmp_path, monkeypatch):
|
||||||
|
# The `remember` action tool persists a fact through the store.
|
||||||
|
import synapse.memory.store as store_mod
|
||||||
|
from synapse.memory.store import PersistentMemoryStore
|
||||||
|
fresh = PersistentMemoryStore(tmp_path / "m.db")
|
||||||
|
monkeypatch.setattr(tools, "store", fresh)
|
||||||
|
out = asyncio.run(tools.dispatch("remember", {"text": "user likes tea", "section": "Prefs"}))
|
||||||
|
assert "user likes tea" in out
|
||||||
|
assert any(it.text == "user likes tea" for it in fresh.all())
|
||||||
|
|
||||||
|
|
||||||
|
def test_action_tools_registered():
|
||||||
|
for name in ("web_search", "fetch_url", "remember"):
|
||||||
|
assert name in tools.REGISTRY
|
||||||
|
names = [s["function"]["name"] for s in tools.schemas_for(["web_search", "remember", "nope"])]
|
||||||
|
assert names == ["web_search", "remember"]
|
||||||
|
|
||||||
|
|
||||||
class _FakeManager:
|
class _FakeManager:
|
||||||
"""Returns a tool_call on the first chat() call, plain content after."""
|
"""Returns a tool_call on the first chat() call, plain content after."""
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user