Claude Claude Opus 4.8 commited on
Commit
d9f07b6
·
unverified ·
1 Parent(s): 52739ad

Add retrieval + chat loop (Session 3)

Browse files

Backend:
- 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 CHANGED
@@ -84,9 +84,9 @@ Goal: the core question→answer experience.
84
 
85
  **Acceptance criteria**
86
 
87
- - [ ] Asking a question about an uploaded doc returns a relevant passage.
88
- - [ ] The source passage is visibly shown under each answer.
89
- - [ ] Empty question or unknown document id is handled gracefully.
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
 
README.md CHANGED
@@ -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 2upload & parse flow is live. Drag in a PDF (or paste
10
- > text) and the backend extracts, chunks, and indexes it in memory, returning a
11
- > document id. Retrieval/chat and the metrics dashboard land in the following
12
- > sessions (see [`PROJECT_SPEC.md`](./PROJECT_SPEC.md)).
 
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 3the 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
 
backend/Dockerfile CHANGED
@@ -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"]
backend/app/main.py CHANGED
@@ -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
+ }
backend/app/retrieval.py ADDED
@@ -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]
backend/app/store.py CHANGED
@@ -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:
backend/requirements.txt CHANGED
@@ -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
backend/tests/test_ask.py ADDED
@@ -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
frontend/src/App.jsx CHANGED
@@ -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-md px-6 py-10">
48
- <h2 className="mb-1 text-lg font-semibold">Add a document</h2>
49
- <p className="mb-5 text-sm text-slate-500">
50
- Upload a PDF or paste text. We extract and index it so you can ask
51
- questions about it.
52
- </p>
53
- <DocumentUploader onReady={(doc) => console.log("ready", doc)} />
 
 
 
 
 
 
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
  );
frontend/src/Chat.jsx ADDED
@@ -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
+ }
frontend/src/api.js CHANGED
@@ -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
+ }