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;
|
pendingEventType = null;
|
||||||
continue;
|
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") {
|
if (pendingEventType === "done") {
|
||||||
// Answer is complete; re-enable input while the backend finishes
|
// Answer is complete; re-enable input while the backend finishes
|
||||||
// slow post-processing (title, memory) on the still-open stream.
|
// 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>
|
: <span style={{ color: "#aaa" }}>Thinking...</span>
|
||||||
}
|
}
|
||||||
</div>
|
</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 && (
|
{msg.content && editingIdx !== idx && (
|
||||||
<div style={{ display: "flex", gap: "0.25rem", marginTop: "0.25rem" }}>
|
<div style={{ display: "flex", gap: "0.25rem", marginTop: "0.25rem" }}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -27,16 +27,37 @@ export function Documents() {
|
|||||||
|
|
||||||
useEffect(() => { load(); }, []);
|
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 onFile = (e) => {
|
||||||
const file = e.target.files?.[0];
|
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
|
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 () => {
|
const addDoc = async () => {
|
||||||
@@ -88,10 +109,11 @@ export function Documents() {
|
|||||||
<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} />
|
||||||
<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} />
|
value={content} onChange={e => setContent(e.target.value)} style={input} />
|
||||||
<div style={{ display: "flex", gap: "0.7rem", alignItems: "center" }}>
|
<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 }} />
|
style={{ color: "#aaa", flex: 1 }} />
|
||||||
<button onClick={addDoc} disabled={busy}
|
<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" }}>
|
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
|
python-dotenv
|
||||||
PyYAML
|
PyYAML
|
||||||
|
|
||||||
|
# Document ingest (RAG): pure-Python text extraction, no native deps
|
||||||
|
pypdf
|
||||||
|
python-docx
|
||||||
|
|
||||||
# Documentation Support
|
# Documentation Support
|
||||||
markdown-it-py
|
markdown-it-py
|
||||||
MarkupSafe
|
MarkupSafe
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ PyYAML
|
|||||||
python-dotenv
|
python-dotenv
|
||||||
psutil
|
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).
|
# 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.
|
||||||
pywebview
|
pywebview
|
||||||
|
|||||||
@@ -287,10 +287,12 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
|||||||
|
|
||||||
# Retrieve relevant uploaded documents (RAG) and inject the top chunks.
|
# Retrieve relevant uploaded documents (RAG) and inject the top chunks.
|
||||||
doc_hits = await store.search_documents(message, get_ollama_manager().embed, limit=3)
|
doc_hits = await store.search_documents(message, get_ollama_manager().embed, limit=3)
|
||||||
|
doc_titles: list = []
|
||||||
if doc_hits:
|
if doc_hits:
|
||||||
doc_block = "\n\n".join(f"[{d['title']}]\n{d['text']}" for d in 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"
|
separator = "\n\n---\nRelevant documents (cite as source material):\n\n"
|
||||||
system_prompt = (system_prompt + separator + doc_block) if system_prompt else doc_block
|
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
|
# Fetch web search results for time-sensitive queries
|
||||||
search_results = ""
|
search_results = ""
|
||||||
@@ -373,6 +375,10 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
|||||||
meta: dict = {}
|
meta: dict = {}
|
||||||
final_model = model
|
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 ────────────────────
|
# ── Phase 1: stream primary model response ────────────────────
|
||||||
try:
|
try:
|
||||||
async for chunk in stream_chat_response(
|
async for chunk in stream_chat_response(
|
||||||
@@ -953,6 +959,46 @@ async def add_document(payload: Dict[str, Any] = Body(...)):
|
|||||||
return result
|
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}")
|
@app.get("/documents/{doc_id}")
|
||||||
async def get_document(doc_id: str):
|
async def get_document(doc_id: str):
|
||||||
chunks = store.get_document(doc_id)
|
chunks = store.get_document(doc_id)
|
||||||
|
|||||||
@@ -55,3 +55,19 @@ def test_add_list_search_delete_roundtrip():
|
|||||||
def test_search_empty_query_returns_nothing():
|
def test_search_empty_query_returns_nothing():
|
||||||
s = _store()
|
s = _store()
|
||||||
assert asyncio.run(s.search_documents("", _fake_embed)) == []
|
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