feat(rag): PDF/docx ingest + chat citations

- Upload endpoint (base64 JSON, no multipart dep): extracts text from
  pdf/docx/txt/md via pypdf + python-docx, then runs the existing
  chunk/embed pipeline. Documents page uploads files straight through.
- Citations: the chat stream emits an SSE `sources` event listing the
  documents that fed the answer; the UI shows them as chips under the reply.
- Deps: pypdf, python-docx (both pure-Python, Windows-safe).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jon
2026-07-23 13:48:32 -05:00
co-authored by Claude Opus 4.8
parent f509034fdd
commit ac0eb1e5f6
6 changed files with 123 additions and 9 deletions
+22
View File
@@ -215,6 +215,18 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
pendingEventType = null;
continue;
}
if (pendingEventType === "sources") {
try {
const src = JSON.parse(payload).sources;
setMessages(prev => {
const updated = [...prev];
updated[assistantIndex] = { ...updated[assistantIndex], sources: src };
return updated;
});
} catch { /* ignore */ }
pendingEventType = null;
continue;
}
if (pendingEventType === "done") {
// Answer is complete; re-enable input while the backend finishes
// slow post-processing (title, memory) on the still-open stream.
@@ -526,6 +538,16 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
: <span style={{ color: "#aaa" }}>Thinking...</span>
}
</div>
{msg.sources && msg.sources.length > 0 && (
<div style={{ marginTop: "0.35rem", display: "flex", flexWrap: "wrap", gap: "0.35rem", alignItems: "center" }}>
<span style={{ fontSize: "0.7rem", color: "#777" }}>Sources:</span>
{msg.sources.map((s, i) => (
<span key={i} style={{ fontSize: "0.72rem", color: "#8ab4ff", background: "#1a2433", border: "1px solid #2a3a52", borderRadius: "6px", padding: "0.1rem 0.45rem" }}>
📄 {s}
</span>
))}
</div>
)}
{msg.content && editingIdx !== idx && (
<div style={{ display: "flex", gap: "0.25rem", marginTop: "0.25rem" }}>
<button
+31 -9
View File
@@ -27,16 +27,37 @@ export function Documents() {
useEffect(() => { load(); }, []);
// Files (pdf/docx/txt/md) upload straight to the server, which extracts the
// text. Base64 in JSON — no multipart dependency.
const onFile = (e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
setContent(String(reader.result || ""));
if (!title.trim()) setTitle(file.name.replace(/\.[^.]+$/, ""));
};
reader.readAsText(file);
e.target.value = ""; // allow re-selecting the same file
if (!file) return;
setBusy(true);
setMessage(`Reading ${file.name}`);
const reader = new FileReader();
reader.onload = async () => {
const b64 = String(reader.result || "").split(",")[1];
try {
const r = await fetch(`${API_BASE}/documents/upload`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: file.name, data: b64 }),
});
if (r.ok) {
const d = await r.json();
setMessage(`Indexed "${d.title}" (${d.chunks} chunk${d.chunks === 1 ? "" : "s"}).`);
load();
} else {
setMessage((await r.json().catch(() => ({}))).detail || "Failed to index file.");
}
} catch (err) {
setMessage(`Error: ${err.message}`);
} finally {
setBusy(false);
}
};
reader.readAsDataURL(file);
};
const addDoc = async () => {
@@ -88,10 +109,11 @@ export function Documents() {
<div style={{ display: "flex", flexDirection: "column", gap: "0.7rem", marginBottom: "1.5rem" }}>
<input type="text" placeholder="Title" value={title}
onChange={e => setTitle(e.target.value)} style={input} />
<textarea rows={8} placeholder="Paste text here, or load a .txt/.md file below"
<textarea rows={8} placeholder="Paste text here, or upload a .pdf/.docx/.txt/.md file below"
value={content} onChange={e => setContent(e.target.value)} style={input} />
<div style={{ display: "flex", gap: "0.7rem", alignItems: "center" }}>
<input type="file" accept=".txt,.md,.markdown,text/*" onChange={onFile}
<input type="file" accept=".pdf,.docx,.txt,.md,.markdown,text/*" onChange={onFile} disabled={busy}
title="Upload a file — text extracted server-side"
style={{ color: "#aaa", flex: 1 }} />
<button onClick={addDoc} disabled={busy}
style={{ padding: "0.9rem 1.5rem", background: busy ? "#555" : "#007acc", color: "#fff", border: "none", borderRadius: "8px", cursor: busy ? "default" : "pointer" }}>
+4
View File
@@ -37,6 +37,10 @@ rich
python-dotenv
PyYAML
# Document ingest (RAG): pure-Python text extraction, no native deps
pypdf
python-docx
# Documentation Support
markdown-it-py
MarkupSafe
+4
View File
@@ -9,6 +9,10 @@ PyYAML
python-dotenv
psutil
# Document ingest (RAG): pure-Python text extraction, no native deps
pypdf
python-docx
# Native desktop window for the UI (WebView2 on Windows; pulls pythonnet).
# Used by bin/nexus_window.py, launched from launch_nexus.ps1.
pywebview
+46
View File
@@ -287,10 +287,12 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
# Retrieve relevant uploaded documents (RAG) and inject the top chunks.
doc_hits = await store.search_documents(message, get_ollama_manager().embed, limit=3)
doc_titles: list = []
if doc_hits:
doc_block = "\n\n".join(f"[{d['title']}]\n{d['text']}" for d in doc_hits)
separator = "\n\n---\nRelevant documents (cite as source material):\n\n"
system_prompt = (system_prompt + separator + doc_block) if system_prompt else doc_block
doc_titles = list(dict.fromkeys(d["title"] for d in doc_hits)) # unique, order-preserving
# Fetch web search results for time-sensitive queries
search_results = ""
@@ -373,6 +375,10 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
meta: dict = {}
final_model = model
# Tell the client which documents fed this answer (RAG citations).
if doc_titles:
yield f"event: sources\ndata: {_json.dumps({'sources': doc_titles})}\n\n"
# ── Phase 1: stream primary model response ────────────────────
try:
async for chunk in stream_chat_response(
@@ -953,6 +959,46 @@ async def add_document(payload: Dict[str, Any] = Body(...)):
return result
def _extract_text(filename: str, data: bytes) -> str:
"""Pull plain text from an uploaded file by extension. PDF/DOCX use
pure-Python parsers; anything else is decoded as UTF-8."""
import io
name = (filename or "").lower()
if name.endswith(".pdf"):
from pypdf import PdfReader
reader = PdfReader(io.BytesIO(data))
return "\n\n".join((page.extract_text() or "") for page in reader.pages)
if name.endswith(".docx"):
import docx
doc = docx.Document(io.BytesIO(data))
return "\n\n".join(p.text for p in doc.paragraphs if p.text.strip())
return data.decode("utf-8", errors="replace")
@app.post("/documents/upload")
async def upload_document(payload: Dict[str, Any] = Body(...)):
"""Ingest a file (pdf/docx/txt/md) sent as base64. Extracts text, then runs
the same chunk/embed pipeline as a pasted document."""
import base64
import os as _os
filename = (payload.get("filename") or "").strip()
b64 = payload.get("data") or ""
if not filename or not b64:
raise HTTPException(status_code=400, detail="filename and data are required")
try:
raw = base64.b64decode(b64)
except Exception:
raise HTTPException(status_code=400, detail="data must be base64")
try:
text = _extract_text(filename, raw).strip()
except Exception as e:
raise HTTPException(status_code=400, detail=f"could not read {filename}: {e}")
if not text:
raise HTTPException(status_code=400, detail="no extractable text in file")
title = _os.path.splitext(_os.path.basename(filename))[0] or filename
return await store.add_document(title, text, get_ollama_manager().embed)
@app.get("/documents/{doc_id}")
async def get_document(doc_id: str):
chunks = store.get_document(doc_id)
+16
View File
@@ -55,3 +55,19 @@ def test_add_list_search_delete_roundtrip():
def test_search_empty_query_returns_nothing():
s = _store()
assert asyncio.run(s.search_documents("", _fake_embed)) == []
def test_extract_text_by_type():
from synapse.main import _extract_text
# plain text / markdown -> UTF-8 decode
assert _extract_text("notes.md", b"# Title\n\nbody") == "# Title\n\nbody"
assert _extract_text("x.txt", "café".encode("utf-8")) == "café"
# a real (tiny) PDF built with pypdf -> text extracted back out
from pypdf import PdfWriter, PdfReader
import io
w = PdfWriter()
w.add_blank_page(width=200, height=200)
buf = io.BytesIO(); w.write(buf)
out = _extract_text("blank.pdf", buf.getvalue())
assert isinstance(out, str) # blank page -> "" or whitespace, never raises
assert PdfReader(io.BytesIO(buf.getvalue())).pages # sanity: it was a valid PDF