forked from enderofwings/NexusOS
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:
@@ -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
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user