Add retrieval + chat loop (Session 3)
Browse filesBackend:
- POST /ask takes {document_id, question}, runs hybrid retrieval over the
document's chunks, and returns an extractive answer plus the source
passage it came from (with chunk index and score).
- retrieval.py: a per-document index combining BM25 (rank_bm25) with a
FAISS cosine search over TF-IDF vectors; scores are min-max normalised
and averaged. The index is built lazily on first /ask and cached.
- Empty question -> 400, unknown document id -> 404.
- Dockerfile installs libgomp1 (faiss-cpu OpenMP runtime dep).
Frontend:
- Chat interface: question input, message history, a typing indicator
while /ask is in flight, and each answer shows a collapsible source
passage underneath. App swaps the uploader for the chat once a document
is ready, with a 'New document' reset.
Tests:
- test_ask.py covers index ranking, sentence extraction, the /ask happy
path, empty-question, and unknown-document paths.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WN4QRr6dTE2W7hQ2SDnLmY
- PROJECT_SPEC.md +3 -3
- README.md +5 -4
- backend/Dockerfile +3 -2
- backend/app/main.py +40 -0
- backend/app/retrieval.py +89 -0
- backend/app/store.py +3 -0
- backend/requirements.txt +4 -0
- backend/tests/test_ask.py +57 -0
- frontend/src/App.jsx +15 -7
- frontend/src/Chat.jsx +137 -0
- frontend/src/api.js +11 -0
|
@@ -84,9 +84,9 @@ Goal: the core question→answer experience.
|
|
| 84 |
|
| 85 |
**Acceptance criteria**
|
| 86 |
|
| 87 |
-
- [
|
| 88 |
-
- [
|
| 89 |
-
- [
|
| 90 |
|
| 91 |
## Session 4 — Data-collection & feedback layer (the score-lifting part)
|
| 92 |
|
|
|
|
| 84 |
|
| 85 |
**Acceptance criteria**
|
| 86 |
|
| 87 |
+
- [x] Asking a question about an uploaded doc returns a relevant passage.
|
| 88 |
+
- [x] The source passage is visibly shown under each answer.
|
| 89 |
+
- [x] Empty question or unknown document id is handled gracefully.
|
| 90 |
|
| 91 |
## Session 4 — Data-collection & feedback layer (the score-lifting part)
|
| 92 |
|
|
@@ -6,10 +6,11 @@ A full-stack **document Q&A web app**: upload a PDF (or paste text), ask
|
|
| 6 |
questions in a chat box, and get answers with the **source passage** shown —
|
| 7 |
plus a live feedback/telemetry dashboard.
|
| 8 |
|
| 9 |
-
> **Status:** Session
|
| 10 |
-
>
|
| 11 |
-
>
|
| 12 |
-
> sessions (see
|
|
|
|
| 13 |
|
| 14 |
## Run it (one command)
|
| 15 |
|
|
|
|
| 6 |
questions in a chat box, and get answers with the **source passage** shown —
|
| 7 |
plus a live feedback/telemetry dashboard.
|
| 8 |
|
| 9 |
+
> **Status:** Session 3 — the chat loop is live. Upload a PDF (or paste text),
|
| 10 |
+
> then ask questions in a chat box; each answer shows the source passage it came
|
| 11 |
+
> from. Retrieval is a lightweight BM25 + FAISS (TF-IDF) hybrid. The
|
| 12 |
+
> feedback/telemetry dashboard lands in the next sessions (see
|
| 13 |
+
> [`PROJECT_SPEC.md`](./PROJECT_SPEC.md)).
|
| 14 |
|
| 15 |
## Run it (one command)
|
| 16 |
|
|
@@ -10,9 +10,10 @@ COPY . .
|
|
| 10 |
|
| 11 |
EXPOSE 8000
|
| 12 |
|
| 13 |
-
# curl is used by the docker-compose healthcheck
|
|
|
|
| 14 |
RUN apt-get update \
|
| 15 |
-
&& apt-get install -y --no-install-recommends curl \
|
| 16 |
&& rm -rf /var/lib/apt/lists/*
|
| 17 |
|
| 18 |
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
|
|
|
| 10 |
|
| 11 |
EXPOSE 8000
|
| 12 |
|
| 13 |
+
# curl is used by the docker-compose healthcheck; libgomp1 is required at
|
| 14 |
+
# runtime by faiss-cpu (OpenMP) and is not present in the slim base image.
|
| 15 |
RUN apt-get update \
|
| 16 |
+
&& apt-get install -y --no-install-recommends curl libgomp1 \
|
| 17 |
&& rm -rf /var/lib/apt/lists/*
|
| 18 |
|
| 19 |
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
|
@@ -11,9 +11,11 @@ import os
|
|
| 11 |
|
| 12 |
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
| 13 |
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
| 14 |
|
| 15 |
from . import __version__
|
| 16 |
from .parsing import DocumentError, chunk_text, extract_pdf_text
|
|
|
|
| 17 |
from .store import store
|
| 18 |
|
| 19 |
logger = logging.getLogger("docuask")
|
|
@@ -134,3 +136,41 @@ def get_document(document_id: str) -> dict[str, object]:
|
|
| 134 |
"num_chars": doc.num_chars,
|
| 135 |
"status": "ready",
|
| 136 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
| 13 |
from fastapi.middleware.cors import CORSMiddleware
|
| 14 |
+
from pydantic import BaseModel
|
| 15 |
|
| 16 |
from . import __version__
|
| 17 |
from .parsing import DocumentError, chunk_text, extract_pdf_text
|
| 18 |
+
from .retrieval import DocumentIndex, best_sentence
|
| 19 |
from .store import store
|
| 20 |
|
| 21 |
logger = logging.getLogger("docuask")
|
|
|
|
| 136 |
"num_chars": doc.num_chars,
|
| 137 |
"status": "ready",
|
| 138 |
}
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
class AskRequest(BaseModel):
|
| 142 |
+
document_id: str
|
| 143 |
+
question: str
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
@app.post("/ask")
|
| 147 |
+
def ask(req: AskRequest) -> dict[str, object]:
|
| 148 |
+
"""Answer a question about a stored document.
|
| 149 |
+
|
| 150 |
+
Runs hybrid retrieval over the document's chunks and returns the extractive
|
| 151 |
+
answer plus the source passage it came from, so the UI can show its work.
|
| 152 |
+
"""
|
| 153 |
+
question = req.question.strip()
|
| 154 |
+
if not question:
|
| 155 |
+
raise HTTPException(status_code=400, detail="Question must not be empty.")
|
| 156 |
+
|
| 157 |
+
doc = store.get(req.document_id)
|
| 158 |
+
if doc is None:
|
| 159 |
+
raise HTTPException(status_code=404, detail="Document not found.")
|
| 160 |
+
|
| 161 |
+
# Build the index on first use and cache it on the document.
|
| 162 |
+
if doc.index is None:
|
| 163 |
+
doc.index = DocumentIndex(doc.chunks)
|
| 164 |
+
|
| 165 |
+
hits = doc.index.search(question, k=3)
|
| 166 |
+
top_index, score = hits[0]
|
| 167 |
+
passage = doc.chunks[top_index]
|
| 168 |
+
|
| 169 |
+
return {
|
| 170 |
+
"document_id": doc.id,
|
| 171 |
+
"question": question,
|
| 172 |
+
"answer": best_sentence(passage, question),
|
| 173 |
+
"source_passage": passage,
|
| 174 |
+
"chunk_index": top_index,
|
| 175 |
+
"score": round(score, 4),
|
| 176 |
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hybrid retrieval for the chat loop (Session 3).
|
| 2 |
+
|
| 3 |
+
Per the project spec the retrieval is intentionally lightweight: a single
|
| 4 |
+
in-memory index per document combining lexical **BM25** with a **FAISS** cosine
|
| 5 |
+
search over TF-IDF vectors. There is no generation model in the stack, so the
|
| 6 |
+
"answer" is extractive — the sentence in the top passage that best overlaps the
|
| 7 |
+
question.
|
| 8 |
+
|
| 9 |
+
The two signals are min-max normalised across the document's chunks and
|
| 10 |
+
averaged, which keeps a keyword hit (BM25) and a fuzzy/semantic-ish match
|
| 11 |
+
(TF-IDF/FAISS) both able to surface the right passage.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import re
|
| 17 |
+
|
| 18 |
+
import faiss
|
| 19 |
+
import numpy as np
|
| 20 |
+
from rank_bm25 import BM25Okapi
|
| 21 |
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
| 22 |
+
|
| 23 |
+
_TOKEN_RE = re.compile(r"[a-z0-9]+")
|
| 24 |
+
_SENTENCE_RE = re.compile(r"(?<=[.!?])\s+")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _tokenize(text: str) -> list[str]:
|
| 28 |
+
return _TOKEN_RE.findall(text.lower())
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _minmax(scores: np.ndarray) -> np.ndarray:
|
| 32 |
+
"""Scale to [0, 1]; return zeros when every score is equal (no signal)."""
|
| 33 |
+
lo = float(scores.min())
|
| 34 |
+
hi = float(scores.max())
|
| 35 |
+
if hi - lo < 1e-9:
|
| 36 |
+
return np.zeros_like(scores)
|
| 37 |
+
return (scores - lo) / (hi - lo)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def best_sentence(passage: str, question: str) -> str:
|
| 41 |
+
"""Return the sentence in ``passage`` with the most question-term overlap."""
|
| 42 |
+
q_terms = set(_tokenize(question))
|
| 43 |
+
best = passage.strip()
|
| 44 |
+
best_score = -1
|
| 45 |
+
for sentence in _SENTENCE_RE.split(passage):
|
| 46 |
+
overlap = len(q_terms & set(_tokenize(sentence)))
|
| 47 |
+
if overlap > best_score:
|
| 48 |
+
best_score = overlap
|
| 49 |
+
best = sentence.strip()
|
| 50 |
+
return best
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class DocumentIndex:
|
| 54 |
+
"""BM25 + TF-IDF/FAISS index over a single document's chunks."""
|
| 55 |
+
|
| 56 |
+
def __init__(self, chunks: list[str]) -> None:
|
| 57 |
+
if not chunks:
|
| 58 |
+
raise ValueError("cannot index an empty document")
|
| 59 |
+
self.chunks = chunks
|
| 60 |
+
|
| 61 |
+
self._bm25 = BM25Okapi([_tokenize(c) for c in chunks])
|
| 62 |
+
|
| 63 |
+
self._vectorizer = TfidfVectorizer()
|
| 64 |
+
dense = self._vectorizer.fit_transform(chunks).toarray().astype("float32")
|
| 65 |
+
faiss.normalize_L2(dense)
|
| 66 |
+
self._faiss = faiss.IndexFlatIP(dense.shape[1])
|
| 67 |
+
self._faiss.add(dense)
|
| 68 |
+
|
| 69 |
+
def _cosine_scores(self, query: str) -> np.ndarray:
|
| 70 |
+
"""Cosine similarity of the query against every chunk (0 if no terms)."""
|
| 71 |
+
n = len(self.chunks)
|
| 72 |
+
scores = np.zeros(n, dtype="float32")
|
| 73 |
+
vec = self._vectorizer.transform([query]).toarray().astype("float32")
|
| 74 |
+
if float(np.linalg.norm(vec)) == 0.0:
|
| 75 |
+
return scores # no overlapping vocabulary
|
| 76 |
+
faiss.normalize_L2(vec)
|
| 77 |
+
sims, idx = self._faiss.search(vec, n)
|
| 78 |
+
scores[idx[0]] = sims[0]
|
| 79 |
+
return scores
|
| 80 |
+
|
| 81 |
+
def search(self, query: str, k: int = 3) -> list[tuple[int, float]]:
|
| 82 |
+
"""Return up to ``k`` ``(chunk_index, combined_score)`` pairs, best first."""
|
| 83 |
+
bm25 = np.asarray(self._bm25.get_scores(_tokenize(query)), dtype="float32")
|
| 84 |
+
cosine = self._cosine_scores(query)
|
| 85 |
+
combined = 0.5 * _minmax(bm25) + 0.5 * _minmax(cosine)
|
| 86 |
+
|
| 87 |
+
k = min(k, len(self.chunks))
|
| 88 |
+
top = np.argsort(-combined)[:k]
|
| 89 |
+
return [(int(i), float(combined[i])) for i in top]
|
|
@@ -20,6 +20,9 @@ class Document:
|
|
| 20 |
chunks: list[str]
|
| 21 |
num_chars: int
|
| 22 |
filename: str | None = None
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
@property
|
| 25 |
def num_chunks(self) -> int:
|
|
|
|
| 20 |
chunks: list[str]
|
| 21 |
num_chars: int
|
| 22 |
filename: str | None = None
|
| 23 |
+
# Retrieval index, built lazily on the first /ask and cached here. Kept out
|
| 24 |
+
# of repr/eq so the dataclass stays cheap to print and compare.
|
| 25 |
+
index: object | None = field(default=None, repr=False, compare=False)
|
| 26 |
|
| 27 |
@property
|
| 28 |
def num_chunks(self) -> int:
|
|
@@ -2,5 +2,9 @@ fastapi==0.115.6
|
|
| 2 |
uvicorn[standard]==0.34.0
|
| 3 |
python-multipart==0.0.20
|
| 4 |
pypdf==5.1.0
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
httpx==0.28.1
|
| 6 |
pytest==8.3.4
|
|
|
|
| 2 |
uvicorn[standard]==0.34.0
|
| 3 |
python-multipart==0.0.20
|
| 4 |
pypdf==5.1.0
|
| 5 |
+
numpy==1.26.4
|
| 6 |
+
scikit-learn==1.6.0
|
| 7 |
+
rank-bm25==0.2.2
|
| 8 |
+
faiss-cpu==1.9.0.post1
|
| 9 |
httpx==0.28.1
|
| 10 |
pytest==8.3.4
|
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the retrieval + chat loop (Session 3)."""
|
| 2 |
+
|
| 3 |
+
from fastapi.testclient import TestClient
|
| 4 |
+
|
| 5 |
+
from app.main import app
|
| 6 |
+
from app.retrieval import DocumentIndex, best_sentence
|
| 7 |
+
|
| 8 |
+
client = TestClient(app)
|
| 9 |
+
|
| 10 |
+
FACTS = [
|
| 11 |
+
"The mitochondria is the powerhouse of the cell.",
|
| 12 |
+
"Python is a popular programming language for data science.",
|
| 13 |
+
"The Eiffel Tower is located in Paris, France.",
|
| 14 |
+
]
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _ingest(text: str) -> str:
|
| 18 |
+
response = client.post("/documents", data={"text": text})
|
| 19 |
+
assert response.status_code == 201
|
| 20 |
+
return response.json()["document_id"]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_index_ranks_relevant_chunk_first():
|
| 24 |
+
index = DocumentIndex(FACTS)
|
| 25 |
+
hits = index.search("Where is the Eiffel Tower?", k=1)
|
| 26 |
+
assert FACTS[hits[0][0]] == FACTS[2]
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_best_sentence_picks_overlapping_sentence():
|
| 30 |
+
passage = " ".join(FACTS)
|
| 31 |
+
assert "Eiffel" in best_sentence(passage, "Where is the Eiffel Tower?")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def test_ask_returns_relevant_passage_and_answer():
|
| 35 |
+
doc_id = _ingest(" ".join(FACTS))
|
| 36 |
+
response = client.post(
|
| 37 |
+
"/ask",
|
| 38 |
+
json={"document_id": doc_id, "question": "Where is the Eiffel Tower?"},
|
| 39 |
+
)
|
| 40 |
+
assert response.status_code == 200
|
| 41 |
+
body = response.json()
|
| 42 |
+
assert "Paris" in body["source_passage"]
|
| 43 |
+
assert "Eiffel" in body["answer"] or "Paris" in body["answer"]
|
| 44 |
+
assert body["chunk_index"] >= 0
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_ask_empty_question_is_rejected():
|
| 48 |
+
doc_id = _ingest(" ".join(FACTS))
|
| 49 |
+
response = client.post("/ask", json={"document_id": doc_id, "question": " "})
|
| 50 |
+
assert response.status_code == 400
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_ask_unknown_document_returns_404():
|
| 54 |
+
response = client.post(
|
| 55 |
+
"/ask", json={"document_id": "does-not-exist", "question": "Hello?"}
|
| 56 |
+
)
|
| 57 |
+
assert response.status_code == 404
|
|
@@ -1,9 +1,11 @@
|
|
| 1 |
import { useEffect, useState } from "react";
|
| 2 |
import DocumentUploader from "./DocumentUploader";
|
|
|
|
| 3 |
import { getHealth } from "./api";
|
| 4 |
|
| 5 |
export default function App() {
|
| 6 |
const [status, setStatus] = useState("loading");
|
|
|
|
| 7 |
|
| 8 |
useEffect(() => {
|
| 9 |
let cancelled = false;
|
|
@@ -44,13 +46,19 @@ export default function App() {
|
|
| 44 |
</div>
|
| 45 |
</header>
|
| 46 |
|
| 47 |
-
<main className="mx-auto max-w-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
</main>
|
| 55 |
</div>
|
| 56 |
);
|
|
|
|
| 1 |
import { useEffect, useState } from "react";
|
| 2 |
import DocumentUploader from "./DocumentUploader";
|
| 3 |
+
import Chat from "./Chat";
|
| 4 |
import { getHealth } from "./api";
|
| 5 |
|
| 6 |
export default function App() {
|
| 7 |
const [status, setStatus] = useState("loading");
|
| 8 |
+
const [doc, setDoc] = useState(null);
|
| 9 |
|
| 10 |
useEffect(() => {
|
| 11 |
let cancelled = false;
|
|
|
|
| 46 |
</div>
|
| 47 |
</header>
|
| 48 |
|
| 49 |
+
<main className="mx-auto max-w-xl px-6 py-10">
|
| 50 |
+
{doc ? (
|
| 51 |
+
<Chat doc={doc} onReset={() => setDoc(null)} />
|
| 52 |
+
) : (
|
| 53 |
+
<div className="mx-auto max-w-md">
|
| 54 |
+
<h2 className="mb-1 text-lg font-semibold">Add a document</h2>
|
| 55 |
+
<p className="mb-5 text-sm text-slate-500">
|
| 56 |
+
Upload a PDF or paste text. We extract and index it so you can ask
|
| 57 |
+
questions about it.
|
| 58 |
+
</p>
|
| 59 |
+
<DocumentUploader onReady={setDoc} />
|
| 60 |
+
</div>
|
| 61 |
+
)}
|
| 62 |
</main>
|
| 63 |
</div>
|
| 64 |
);
|
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef, useState } from "react";
|
| 2 |
+
import { askQuestion } from "./api";
|
| 3 |
+
|
| 4 |
+
// A single turn. Assistant turns carry the source passage so it can be shown
|
| 5 |
+
// under the answer. `pending` marks the in-flight assistant bubble.
|
| 6 |
+
function makeMessage(role, text, extra = {}) {
|
| 7 |
+
return { id: crypto.randomUUID(), role, text, ...extra };
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export default function Chat({ doc, onReset }) {
|
| 11 |
+
const [messages, setMessages] = useState([]);
|
| 12 |
+
const [question, setQuestion] = useState("");
|
| 13 |
+
const [busy, setBusy] = useState(false);
|
| 14 |
+
const endRef = useRef(null);
|
| 15 |
+
|
| 16 |
+
useEffect(() => {
|
| 17 |
+
endRef.current?.scrollIntoView({ behavior: "smooth" });
|
| 18 |
+
}, [messages]);
|
| 19 |
+
|
| 20 |
+
async function send(e) {
|
| 21 |
+
e.preventDefault();
|
| 22 |
+
const q = question.trim();
|
| 23 |
+
if (!q || busy) return;
|
| 24 |
+
|
| 25 |
+
setMessages((m) => [...m, makeMessage("user", q)]);
|
| 26 |
+
setQuestion("");
|
| 27 |
+
setBusy(true);
|
| 28 |
+
try {
|
| 29 |
+
const res = await askQuestion(doc.document_id, q);
|
| 30 |
+
setMessages((m) => [
|
| 31 |
+
...m,
|
| 32 |
+
makeMessage("assistant", res.answer, { source: res.source_passage }),
|
| 33 |
+
]);
|
| 34 |
+
} catch (err) {
|
| 35 |
+
setMessages((m) => [...m, makeMessage("error", err.message)]);
|
| 36 |
+
} finally {
|
| 37 |
+
setBusy(false);
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
return (
|
| 42 |
+
<div className="flex h-[70vh] flex-col rounded-2xl bg-white shadow-lg ring-1 ring-slate-200">
|
| 43 |
+
<div className="flex items-center justify-between border-b border-slate-200 px-5 py-3">
|
| 44 |
+
<div className="min-w-0">
|
| 45 |
+
<p className="truncate text-sm font-semibold text-slate-800">
|
| 46 |
+
{doc.filename || "Pasted text"}
|
| 47 |
+
</p>
|
| 48 |
+
<p className="text-xs text-slate-400">{doc.num_chunks} chunks indexed</p>
|
| 49 |
+
</div>
|
| 50 |
+
<button
|
| 51 |
+
onClick={onReset}
|
| 52 |
+
className="shrink-0 rounded-lg px-3 py-1.5 text-xs font-medium text-slate-500 ring-1 ring-slate-200 transition hover:bg-slate-50"
|
| 53 |
+
>
|
| 54 |
+
New document
|
| 55 |
+
</button>
|
| 56 |
+
</div>
|
| 57 |
+
|
| 58 |
+
<div className="flex-1 space-y-4 overflow-y-auto px-5 py-5">
|
| 59 |
+
{messages.length === 0 && (
|
| 60 |
+
<p className="mt-10 text-center text-sm text-slate-400">
|
| 61 |
+
Ask a question about this document to get started.
|
| 62 |
+
</p>
|
| 63 |
+
)}
|
| 64 |
+
|
| 65 |
+
{messages.map((m) => {
|
| 66 |
+
if (m.role === "user") {
|
| 67 |
+
return (
|
| 68 |
+
<div key={m.id} className="flex justify-end">
|
| 69 |
+
<div className="max-w-[80%] rounded-2xl rounded-br-sm bg-slate-800 px-4 py-2 text-sm text-white">
|
| 70 |
+
{m.text}
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
);
|
| 74 |
+
}
|
| 75 |
+
if (m.role === "error") {
|
| 76 |
+
return (
|
| 77 |
+
<div key={m.id} className="flex justify-start">
|
| 78 |
+
<div className="max-w-[80%] rounded-2xl bg-red-50 px-4 py-2 text-sm text-red-700 ring-1 ring-red-200">
|
| 79 |
+
⚠ {m.text}
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
);
|
| 83 |
+
}
|
| 84 |
+
return (
|
| 85 |
+
<div key={m.id} className="flex flex-col items-start gap-1">
|
| 86 |
+
<div className="max-w-[85%] rounded-2xl rounded-bl-sm bg-slate-100 px-4 py-2 text-sm text-slate-800">
|
| 87 |
+
{m.text}
|
| 88 |
+
</div>
|
| 89 |
+
{m.source && (
|
| 90 |
+
<details className="max-w-[85%] rounded-lg bg-emerald-50 px-3 py-2 text-xs text-slate-600 ring-1 ring-emerald-100">
|
| 91 |
+
<summary className="cursor-pointer font-medium text-emerald-700">
|
| 92 |
+
Source passage
|
| 93 |
+
</summary>
|
| 94 |
+
<p className="mt-1 whitespace-pre-wrap leading-relaxed">{m.source}</p>
|
| 95 |
+
</details>
|
| 96 |
+
)}
|
| 97 |
+
</div>
|
| 98 |
+
);
|
| 99 |
+
})}
|
| 100 |
+
|
| 101 |
+
{busy && (
|
| 102 |
+
<div className="flex justify-start">
|
| 103 |
+
<div className="flex gap-1 rounded-2xl rounded-bl-sm bg-slate-100 px-4 py-3">
|
| 104 |
+
<Dot /> <Dot delay="150ms" /> <Dot delay="300ms" />
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
)}
|
| 108 |
+
<div ref={endRef} />
|
| 109 |
+
</div>
|
| 110 |
+
|
| 111 |
+
<form onSubmit={send} className="flex gap-2 border-t border-slate-200 p-3">
|
| 112 |
+
<input
|
| 113 |
+
value={question}
|
| 114 |
+
onChange={(e) => setQuestion(e.target.value)}
|
| 115 |
+
placeholder="Ask a question…"
|
| 116 |
+
className="flex-1 rounded-lg border border-slate-300 bg-slate-50 px-3 py-2 text-sm outline-none transition focus:border-slate-400 focus:bg-white"
|
| 117 |
+
/>
|
| 118 |
+
<button
|
| 119 |
+
type="submit"
|
| 120 |
+
disabled={busy || !question.trim()}
|
| 121 |
+
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:bg-slate-300"
|
| 122 |
+
>
|
| 123 |
+
Ask
|
| 124 |
+
</button>
|
| 125 |
+
</form>
|
| 126 |
+
</div>
|
| 127 |
+
);
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
function Dot({ delay = "0ms" }) {
|
| 131 |
+
return (
|
| 132 |
+
<span
|
| 133 |
+
className="inline-block h-2 w-2 animate-bounce rounded-full bg-slate-400"
|
| 134 |
+
style={{ animationDelay: delay }}
|
| 135 |
+
/>
|
| 136 |
+
);
|
| 137 |
+
}
|
|
@@ -39,3 +39,14 @@ export async function uploadText(text) {
|
|
| 39 |
if (!res.ok) throw new Error(await errorDetail(res));
|
| 40 |
return res.json();
|
| 41 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
if (!res.ok) throw new Error(await errorDetail(res));
|
| 40 |
return res.json();
|
| 41 |
}
|
| 42 |
+
|
| 43 |
+
/** POST /ask — resolves to { answer, source_passage, score, ... }. */
|
| 44 |
+
export async function askQuestion(documentId, question) {
|
| 45 |
+
const res = await fetch(`${API_BASE}/ask`, {
|
| 46 |
+
method: "POST",
|
| 47 |
+
headers: { "Content-Type": "application/json" },
|
| 48 |
+
body: JSON.stringify({ document_id: documentId, question }),
|
| 49 |
+
});
|
| 50 |
+
if (!res.ok) throw new Error(await errorDetail(res));
|
| 51 |
+
return res.json();
|
| 52 |
+
}
|