Adoption commited on
Commit
6bc6eba
·
1 Parent(s): f4c5cee
.dockerignore CHANGED
@@ -2,6 +2,8 @@
2
  venv
3
  ENV
4
  env
 
 
5
  __pycache__
6
  *.py[cod]
7
  .env
@@ -9,4 +11,9 @@ src/.env
9
  .streamlit
10
  .vscode
11
  .idea
 
 
 
 
12
  src/sermon_chunks.zip
 
 
2
  venv
3
  ENV
4
  env
5
+ frontend/node_modules
6
+ frontend/dist
7
  __pycache__
8
  *.py[cod]
9
  .env
 
11
  .streamlit
12
  .vscode
13
  .idea
14
+ src/sermons
15
+ src/sermons_all
16
+ src/sermon_chunks.pkl
17
+ src/sermon_chunks_backup.pkl
18
  src/sermon_chunks.zip
19
+ src/web
.env.example CHANGED
@@ -1,6 +1,8 @@
 
1
  LLM_PROVIDER=groq
2
  GROQ_MODEL=llama-3.3-70b-versatile
3
  GROQ_API_KEY=replace-with-your-rotated-groq-key
4
 
5
  GOOGLE_API_KEY=replace-with-your-google-key
6
  PINECONE_API_KEY=replace-with-your-pinecone-key
 
 
1
+ APP_PASSWORD=replace-with-a-shared-password
2
  LLM_PROVIDER=groq
3
  GROQ_MODEL=llama-3.3-70b-versatile
4
  GROQ_API_KEY=replace-with-your-rotated-groq-key
5
 
6
  GOOGLE_API_KEY=replace-with-your-google-key
7
  PINECONE_API_KEY=replace-with-your-pinecone-key
8
+ SERMON_CHUNKS_URL=replace-with-your-google-drive-or-direct-download-url
.gitignore CHANGED
@@ -24,6 +24,8 @@ wheels/
24
  venv/
25
  ENV/
26
  env/
 
 
27
 
28
  # Environment Variables
29
  .env
@@ -43,7 +45,10 @@ Thumbs.db
43
  # Project specific
44
  src/sermons/
45
  src/sermons_all/
 
46
  src/sermon_chunks_backup.pkl
 
 
47
 
48
  src/out.txt
49
  src/scratch_*
 
24
  venv/
25
  ENV/
26
  env/
27
+ frontend/node_modules/
28
+ frontend/dist/
29
 
30
  # Environment Variables
31
  .env
 
45
  # Project specific
46
  src/sermons/
47
  src/sermons_all/
48
+ src/sermon_chunks.pkl
49
  src/sermon_chunks_backup.pkl
50
+ src/web/
51
+ src/feedback_log.jsonl
52
 
53
  src/out.txt
54
  src/scratch_*
.python-version ADDED
@@ -0,0 +1 @@
 
 
1
+ 3.11
Dockerfile CHANGED
@@ -1,3 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
1
  FROM python:3.11-slim
2
 
3
  WORKDIR /app
@@ -9,12 +20,14 @@ RUN apt-get update && apt-get install -y \
9
  && rm -rf /var/lib/apt/lists/*
10
 
11
  COPY requirements.txt ./
12
- COPY src/ ./src/
13
 
14
- RUN pip3 install -r requirements.txt
 
 
15
 
16
- EXPOSE 8501
17
 
18
- HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health
19
 
20
- ENTRYPOINT ["streamlit", "run", "src/streamlit_app.py", "--server.port=8501", "--server.address=0.0.0.0"]
 
1
+ FROM node:22-slim AS frontend-build
2
+
3
+ WORKDIR /app/frontend
4
+
5
+ COPY frontend/package*.json ./
6
+ RUN npm install
7
+
8
+ COPY frontend/ ./
9
+ RUN npm run build
10
+
11
+
12
  FROM python:3.11-slim
13
 
14
  WORKDIR /app
 
20
  && rm -rf /var/lib/apt/lists/*
21
 
22
  COPY requirements.txt ./
23
+ RUN pip install --no-cache-dir -r requirements.txt
24
 
25
+ COPY src/ ./src/
26
+ COPY scripts/ ./scripts/
27
+ COPY --from=frontend-build /app/src/web ./src/web
28
 
29
+ EXPOSE 8000
30
 
31
+ HEALTHCHECK CMD curl --fail http://localhost:${PORT:-8000}/api/health || exit 1
32
 
33
+ CMD ["sh", "-c", "python scripts/download_chunks.py && uvicorn src.server:api --host 0.0.0.0 --port ${PORT:-8000}"]
README.md CHANGED
@@ -1,20 +1,46 @@
1
- ---
2
- title: 7th Handle
3
- emoji: 🚀
4
- colorFrom: red
5
- colorTo: red
6
- sdk: docker
7
- app_port: 8501
8
- tags:
9
- - streamlit
10
- pinned: false
11
- short_description: Streamlit template space
12
- license: mit
13
- ---
14
-
15
- # Welcome to Streamlit!
16
-
17
- Edit `/src/streamlit_app.py` to customize this app to your heart's desire. :heart:
18
-
19
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
20
- forums](https://discuss.streamlit.io).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # The 7th Handle
2
+
3
+ Production RAG app for grounded sermon research.
4
+
5
+ ## Architecture
6
+
7
+ - FastAPI serves the API and built React assets.
8
+ - React/Vite provides the dark monochrome chat UI.
9
+ - Pinecone and BM25 provide hybrid retrieval.
10
+ - Groq generates citation-bound answers.
11
+ - `sermon_chunks.pkl` is downloaded at startup from `SERMON_CHUNKS_URL` when it is not present.
12
+
13
+ ## Required Environment
14
+
15
+ ```env
16
+ APP_PASSWORD=choose-a-shared-password
17
+ LLM_PROVIDER=groq
18
+ GROQ_MODEL=llama-3.3-70b-versatile
19
+ GROQ_API_KEY=your-rotated-groq-key
20
+ PINECONE_API_KEY=your-pinecone-key
21
+ SERMON_CHUNKS_URL=your-google-drive-or-direct-download-url
22
+ ```
23
+
24
+ ## Local Backend
25
+
26
+ ```bash
27
+ pip install -r requirements.txt
28
+ python scripts/download_chunks.py
29
+ uvicorn src.server:api --host 0.0.0.0 --port 8000
30
+ ```
31
+
32
+ ## Local Frontend
33
+
34
+ ```bash
35
+ cd frontend
36
+ npm install
37
+ npm run dev
38
+ ```
39
+
40
+ Vite proxies `/api` requests to `http://localhost:8000`.
41
+
42
+ ## Production
43
+
44
+ Deploy with Render as a Docker Web Service. The included `render.yaml` sets the Docker service, health check, and required environment variable names.
45
+
46
+ The production container builds the React frontend, installs Python dependencies, downloads sermon chunks at startup, then starts Uvicorn on `$PORT`.
frontend/index.html ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>The 7th Handle</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "the-7th-handle-ui",
3
+ "private": true,
4
+ "version": "1.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite --host 0.0.0.0",
8
+ "build": "tsc && vite build",
9
+ "preview": "vite preview --host 0.0.0.0"
10
+ },
11
+ "dependencies": {
12
+ "react": "^19.0.0",
13
+ "react-dom": "^19.0.0",
14
+ "react-markdown": "^10.0.0"
15
+ },
16
+ "devDependencies": {
17
+ "@types/react": "^19.0.0",
18
+ "@types/react-dom": "^19.0.0",
19
+ "@vitejs/plugin-react": "^5.0.0",
20
+ "typescript": "^5.8.0",
21
+ "vite": "^7.0.0"
22
+ }
23
+ }
frontend/src/main.tsx ADDED
@@ -0,0 +1,804 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import ReactMarkdown from "react-markdown";
4
+ import "./styles.css";
5
+
6
+ type AnswerMode = "Strict Mode" | "Study Mode" | "Summary Mode";
7
+ type AppMode = "chat" | "conversation" | "search";
8
+ type GroundingStatus = "supported" | "partial" | "insufficient" | "pending" | "unknown";
9
+
10
+ type SourceDoc = {
11
+ source: string;
12
+ paragraph: string | number;
13
+ content: string;
14
+ full_text?: string;
15
+ retrieval_origin: string;
16
+ retrieval_rank: string | number;
17
+ messagehub_url: string;
18
+ };
19
+
20
+ type Diagnostics = Record<string, unknown>;
21
+
22
+ type Message = {
23
+ id: string;
24
+ role: "user" | "assistant";
25
+ content: string;
26
+ question?: string;
27
+ appMode?: AppMode;
28
+ answerMode?: AnswerMode;
29
+ groundingStatus?: GroundingStatus;
30
+ diagnostics?: Diagnostics;
31
+ sources?: SourceDoc[];
32
+ pending?: boolean;
33
+ loadingStage?: "Retrieving" | "Checking grounding" | "Generating";
34
+ };
35
+
36
+ type SseEvent = {
37
+ event: string;
38
+ data: Record<string, unknown>;
39
+ };
40
+
41
+ const PASSWORD_KEY = "the7thhandle.password";
42
+
43
+ const quickTopics = [
44
+ ["Seven Seals", "what are the seven seals? list each seal and state its events and meaning"],
45
+ ["Seven Thunders", "what are the seven thunders?"],
46
+ ["Serpent's Seed", "What is the relationship between the Serpent's Seed and Cain?"],
47
+ ["Adoption", "Summarize the Adoption series."],
48
+ ["Demonology", "Summarize the Demonology series."],
49
+ ["Church Ages", "Summarize the seven church ages."],
50
+ ];
51
+
52
+ const modeOptions: Array<{
53
+ value: AppMode;
54
+ title: string;
55
+ description: string;
56
+ }> = [
57
+ {
58
+ value: "chat",
59
+ title: "Chat",
60
+ description: "Single-turn answers from retrieved sermon excerpts.",
61
+ },
62
+ {
63
+ value: "conversation",
64
+ title: "Conversational",
65
+ description: "Rewrites follow-ups into standalone retrieval questions.",
66
+ },
67
+ {
68
+ value: "search",
69
+ title: "Search",
70
+ description: "Find matching paragraphs without generation.",
71
+ },
72
+ ];
73
+
74
+ function id() {
75
+ return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
76
+ }
77
+
78
+ function getStoredPassword() {
79
+ return sessionStorage.getItem(PASSWORD_KEY) || "";
80
+ }
81
+
82
+ function App() {
83
+ const [messages, setMessages] = React.useState<Message[]>([]);
84
+ const [appMode, setAppMode] = React.useState<AppMode>("chat");
85
+ const [answerMode, setAnswerMode] = React.useState<AnswerMode>("Strict Mode");
86
+ const [sermonScope, setSermonScope] = React.useState("");
87
+ const [prompt, setPrompt] = React.useState("");
88
+ const [searchQuery, setSearchQuery] = React.useState("");
89
+ const [searchResults, setSearchResults] = React.useState<SourceDoc[]>([]);
90
+ const [searchDiagnostics, setSearchDiagnostics] = React.useState<Diagnostics>({});
91
+ const [searchSearched, setSearchSearched] = React.useState(false);
92
+ const [searchError, setSearchError] = React.useState("");
93
+ const [password, setPassword] = React.useState(getStoredPassword);
94
+ const [passwordRequired, setPasswordRequired] = React.useState(true);
95
+ const [authError, setAuthError] = React.useState("");
96
+ const [busy, setBusy] = React.useState(false);
97
+ const promptInputRef = React.useRef<HTMLInputElement>(null);
98
+ const searchInputRef = React.useRef<HTMLInputElement>(null);
99
+
100
+ React.useEffect(() => {
101
+ fetch("/api/health")
102
+ .then((res) => res.json())
103
+ .then((data) => setPasswordRequired(Boolean(data.password_required)))
104
+ .catch(() => setPasswordRequired(true));
105
+ }, []);
106
+
107
+ const authenticated = !passwordRequired || Boolean(password);
108
+ const activeMode = modeOptions.find((mode) => mode.value === appMode) || modeOptions[0];
109
+
110
+ function focusModeInput(mode: AppMode = appMode) {
111
+ window.setTimeout(() => {
112
+ if (mode === "search") {
113
+ searchInputRef.current?.focus();
114
+ } else {
115
+ promptInputRef.current?.focus();
116
+ }
117
+ }, 0);
118
+ }
119
+
120
+ function changeAppMode(mode: AppMode) {
121
+ setAppMode(mode);
122
+ focusModeInput(mode);
123
+ }
124
+
125
+ async function submitQuestion(rawQuestion: string) {
126
+ const question = rawQuestion.trim();
127
+ if (!question || busy) return;
128
+ if (appMode === "search") {
129
+ await submitSearch(question);
130
+ return;
131
+ }
132
+
133
+ setAuthError("");
134
+ setBusy(true);
135
+ setPrompt("");
136
+
137
+ const userMessage: Message = { id: id(), role: "user", content: question, appMode };
138
+ const assistantId = id();
139
+ const assistantMessage: Message = {
140
+ id: assistantId,
141
+ role: "assistant",
142
+ content: "",
143
+ question,
144
+ appMode,
145
+ answerMode,
146
+ groundingStatus: "pending",
147
+ diagnostics: {},
148
+ sources: [],
149
+ pending: true,
150
+ loadingStage: "Retrieving",
151
+ };
152
+
153
+ setMessages((current) => [...current, userMessage, assistantMessage]);
154
+
155
+ try {
156
+ await runStreamingAnswer(question, assistantId, appMode === "conversation");
157
+ } catch (error) {
158
+ const message = error instanceof Error ? error.message : "Request failed";
159
+ if (message.includes("401")) {
160
+ sessionStorage.removeItem(PASSWORD_KEY);
161
+ setPassword("");
162
+ setAuthError("Password rejected. Enter the current app password.");
163
+ }
164
+ patchMessage(assistantId, {
165
+ content: `Request failed: ${message}`,
166
+ groundingStatus: "insufficient",
167
+ pending: false,
168
+ loadingStage: undefined,
169
+ });
170
+ } finally {
171
+ setBusy(false);
172
+ }
173
+ }
174
+
175
+ function patchMessage(messageId: string, patch: Partial<Message>) {
176
+ setMessages((current) =>
177
+ current.map((message) => (message.id === messageId ? { ...message, ...patch } : message)),
178
+ );
179
+ }
180
+
181
+ function authHeaders(): Record<string, string> {
182
+ return password ? { "X-App-Password": password } : {};
183
+ }
184
+
185
+ async function submitSearch(rawQuery: string) {
186
+ const question = rawQuery.trim();
187
+ if (!question || busy) return;
188
+
189
+ setAuthError("");
190
+ setSearchError("");
191
+ setBusy(true);
192
+ setSearchQuery(question);
193
+ setSearchSearched(true);
194
+ setSearchResults([]);
195
+ setSearchDiagnostics({ stage: "Retrieving" });
196
+
197
+ try {
198
+ const params = new URLSearchParams({ q: question });
199
+ const res = await fetch(`/api/search?${params.toString()}`, {
200
+ headers: authHeaders(),
201
+ });
202
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
203
+ const data = await res.json();
204
+ setSearchResults(data.results || []);
205
+ setSearchDiagnostics({
206
+ source_count: data.results.length,
207
+ debug_log: data.debug_log,
208
+ query: data.query,
209
+ });
210
+ } catch (error) {
211
+ const message = error instanceof Error ? error.message : "Request failed";
212
+ if (message.includes("401")) {
213
+ sessionStorage.removeItem(PASSWORD_KEY);
214
+ setPassword("");
215
+ setAuthError("Password rejected. Enter the current app password.");
216
+ }
217
+ setSearchError(message);
218
+ setSearchDiagnostics({});
219
+ } finally {
220
+ setBusy(false);
221
+ }
222
+ }
223
+
224
+ function conversationHistory() {
225
+ return messages
226
+ .filter((message) => !message.pending && message.content.trim())
227
+ .slice(-8)
228
+ .map((message) => {
229
+ const codes = sourceCodesFromSources(message.sources || []);
230
+ const sourceNote = codes.length ? `\nCITED_SERMONS: ${codes.join(", ")}` : "";
231
+ return {
232
+ role: message.role,
233
+ content: `${message.content}${sourceNote}`,
234
+ };
235
+ });
236
+ }
237
+
238
+ async function runStreamingAnswer(
239
+ question: string,
240
+ assistantId: string,
241
+ conversationMode: boolean,
242
+ ) {
243
+ const res = await fetch("/api/answer/stream", {
244
+ method: "POST",
245
+ headers: {
246
+ "Content-Type": "application/json",
247
+ ...authHeaders(),
248
+ },
249
+ body: JSON.stringify({
250
+ question,
251
+ answer_mode: answerMode,
252
+ sermon_scope: sermonScope,
253
+ conversation_mode: conversationMode,
254
+ history: conversationMode ? conversationHistory() : [],
255
+ }),
256
+ });
257
+ if (!res.ok || !res.body) throw new Error(`${res.status} ${res.statusText}`);
258
+
259
+ let fullAnswer = "";
260
+ for await (const event of readSse(res.body)) {
261
+ if (event.event === "metadata") {
262
+ patchMessage(assistantId, {
263
+ diagnostics: event.data.diagnostics as Diagnostics,
264
+ sources: (event.data.sources as SourceDoc[]) || [],
265
+ groundingStatus: (event.data.grounding_status as GroundingStatus) || "pending",
266
+ loadingStage: "Checking grounding",
267
+ });
268
+ }
269
+ if (event.event === "token") {
270
+ fullAnswer += String(event.data.text || "");
271
+ patchMessage(assistantId, { content: fullAnswer, loadingStage: "Generating" });
272
+ }
273
+ if (event.event === "done") {
274
+ patchMessage(assistantId, {
275
+ content: String(event.data.answer || fullAnswer),
276
+ diagnostics: event.data.diagnostics as Diagnostics,
277
+ sources: (event.data.sources as SourceDoc[]) || [],
278
+ groundingStatus: (event.data.grounding_status as GroundingStatus) || "unknown",
279
+ pending: false,
280
+ loadingStage: undefined,
281
+ });
282
+ }
283
+ if (event.event === "error") {
284
+ throw new Error(String(event.data.detail || "Streaming failed"));
285
+ }
286
+ }
287
+ }
288
+
289
+ function savePassword(value: string) {
290
+ sessionStorage.setItem(PASSWORD_KEY, value);
291
+ setPassword(value);
292
+ setAuthError("");
293
+ }
294
+
295
+ return (
296
+ <main className={`app-shell mode-${appMode}`}>
297
+ {passwordRequired && !authenticated ? (
298
+ <PasswordGate onSubmit={savePassword} error={authError} />
299
+ ) : null}
300
+
301
+ <aside className="rail">
302
+ <div className="brand">
303
+ <div className="label">The 7th Handle</div>
304
+ <h1>Grounded sermon research</h1>
305
+ <p>Generated answers stay tied to retrieved excerpts and visible references.</p>
306
+ </div>
307
+
308
+ <div className="mode-block">
309
+ <span>Workspace</span>
310
+ <div className="mode-grid" role="tablist" aria-label="Research mode">
311
+ {modeOptions.map((mode) => (
312
+ <button
313
+ key={mode.value}
314
+ type="button"
315
+ role="tab"
316
+ aria-selected={appMode === mode.value}
317
+ className={appMode === mode.value ? "mode-card active" : "mode-card"}
318
+ onClick={() => changeAppMode(mode.value)}
319
+ >
320
+ <strong>{mode.title}</strong>
321
+ <small>{mode.description}</small>
322
+ </button>
323
+ ))}
324
+ </div>
325
+ </div>
326
+
327
+ <Control label="Answer mode">
328
+ <select
329
+ value={answerMode}
330
+ disabled={appMode === "search"}
331
+ onChange={(event) => setAnswerMode(event.target.value as AnswerMode)}
332
+ >
333
+ <option>Strict Mode</option>
334
+ <option>Study Mode</option>
335
+ <option>Summary Mode</option>
336
+ </select>
337
+ </Control>
338
+
339
+ <Control label="Target sermon code">
340
+ <input
341
+ value={sermonScope}
342
+ disabled={appMode === "search"}
343
+ onChange={(event) => setSermonScope(event.target.value)}
344
+ placeholder="63-0324E"
345
+ />
346
+ </Control>
347
+
348
+ <div className="quick-list">
349
+ <span>Quick topics</span>
350
+ {quickTopics.map(([label, question]) => (
351
+ <button key={label} type="button" onClick={() => submitQuestion(question)}>
352
+ {label}
353
+ </button>
354
+ ))}
355
+ </div>
356
+
357
+ <button
358
+ className="ghost"
359
+ type="button"
360
+ onClick={() => {
361
+ setMessages([]);
362
+ setSearchResults([]);
363
+ setSearchDiagnostics({});
364
+ setSearchSearched(false);
365
+ setSearchError("");
366
+ }}
367
+ >
368
+ Clear screen
369
+ </button>
370
+ {passwordRequired ? (
371
+ <button
372
+ className="ghost"
373
+ type="button"
374
+ onClick={() => {
375
+ sessionStorage.removeItem(PASSWORD_KEY);
376
+ setPassword("");
377
+ }}
378
+ >
379
+ Lock app
380
+ </button>
381
+ ) : null}
382
+ </aside>
383
+
384
+ <section className={appMode === "search" ? "chat-pane search-pane" : "chat-pane"}>
385
+ <div className="top-stack">
386
+ <header className="topbar">
387
+ <div>
388
+ <div className="label">{activeMode.title}</div>
389
+ <h2>{modeHeading(appMode)}</h2>
390
+ <p>{activeMode.description}</p>
391
+ </div>
392
+ <div className="api-pill">{passwordRequired ? "Protected" : "Local open"}</div>
393
+ </header>
394
+ {appMode !== "search" ? (
395
+ <div className={`mode-strip ${appMode === "conversation" ? "conversation-strip" : "chat-strip"}`}>
396
+ {appMode === "conversation"
397
+ ? "Conversation memory is on. Follow-up rewrites appear with the answer evidence."
398
+ : "Single-turn chat. Each question retrieves fresh evidence without prior turns."}
399
+ </div>
400
+ ) : null}
401
+ </div>
402
+
403
+ {appMode === "search" ? (
404
+ <SearchView
405
+ query={searchQuery}
406
+ results={searchResults}
407
+ diagnostics={searchDiagnostics}
408
+ searched={searchSearched}
409
+ error={searchError}
410
+ busy={busy}
411
+ authenticated={authenticated}
412
+ inputRef={searchInputRef}
413
+ onQueryChange={setSearchQuery}
414
+ onSearch={submitSearch}
415
+ />
416
+ ) : (
417
+ <>
418
+ <section className={`thread ${appMode === "conversation" ? "conversation-thread" : ""}`} aria-live="polite">
419
+ {messages.length === 0 ? <EmptyState mode={appMode} onPick={submitQuestion} /> : null}
420
+ {messages.map((message) => (
421
+ <MessageCard key={message.id} message={message} />
422
+ ))}
423
+ </section>
424
+
425
+ <form
426
+ className={`composer ${appMode === "conversation" ? "conversation-composer" : ""}`}
427
+ onSubmit={(event) => {
428
+ event.preventDefault();
429
+ submitQuestion(prompt);
430
+ }}
431
+ >
432
+ <input
433
+ ref={promptInputRef}
434
+ value={prompt}
435
+ onChange={(event) => setPrompt(event.target.value)}
436
+ placeholder={composerPlaceholder(appMode)}
437
+ disabled={busy || !authenticated}
438
+ />
439
+ <button type="submit" disabled={busy || !prompt.trim() || !authenticated}>
440
+ {busy ? "Working" : "Ask"}
441
+ </button>
442
+ </form>
443
+ </>
444
+ )}
445
+ </section>
446
+ </main>
447
+ );
448
+ }
449
+
450
+ function modeHeading(mode: AppMode) {
451
+ if (mode === "conversation") return "Ask follow-ups with visible rewriting";
452
+ if (mode === "search") return "Search the sermon archive";
453
+ return "Ask a grounded question";
454
+ }
455
+
456
+ function composerPlaceholder(mode: AppMode) {
457
+ if (mode === "conversation") return "Ask a follow-up...";
458
+ return "Ask a question...";
459
+ }
460
+
461
+ function Control({ label, children }: { label: string; children: React.ReactNode }) {
462
+ return (
463
+ <label className="control">
464
+ <span>{label}</span>
465
+ {children}
466
+ </label>
467
+ );
468
+ }
469
+
470
+ function PasswordGate({ onSubmit, error }: { onSubmit: (password: string) => void; error: string }) {
471
+ const [value, setValue] = React.useState("");
472
+ return (
473
+ <div className="gate">
474
+ <form
475
+ className="gate-panel"
476
+ onSubmit={(event) => {
477
+ event.preventDefault();
478
+ if (value.trim()) onSubmit(value.trim());
479
+ }}
480
+ >
481
+ <div className="label">Protected access</div>
482
+ <h2>Enter the app password</h2>
483
+ <p>This deployment is gated before it sends questions to the RAG API.</p>
484
+ <input
485
+ type="password"
486
+ autoFocus
487
+ value={value}
488
+ onChange={(event) => setValue(event.target.value)}
489
+ placeholder="App password"
490
+ />
491
+ {error ? <div className="form-error">{error}</div> : null}
492
+ <button type="submit">Unlock</button>
493
+ </form>
494
+ </div>
495
+ );
496
+ }
497
+
498
+ function SearchView({
499
+ query,
500
+ results,
501
+ diagnostics,
502
+ searched,
503
+ error,
504
+ busy,
505
+ authenticated,
506
+ inputRef,
507
+ onQueryChange,
508
+ onSearch,
509
+ }: {
510
+ query: string;
511
+ results: SourceDoc[];
512
+ diagnostics: Diagnostics;
513
+ searched: boolean;
514
+ error: string;
515
+ busy: boolean;
516
+ authenticated: boolean;
517
+ inputRef: React.RefObject<HTMLInputElement | null>;
518
+ onQueryChange: (value: string) => void;
519
+ onSearch: (query: string) => void;
520
+ }) {
521
+ const debugLog = Array.isArray(diagnostics.debug_log) ? diagnostics.debug_log : [];
522
+ return (
523
+ <section className="search-workspace" aria-live="polite">
524
+ <form
525
+ className="search-form"
526
+ onSubmit={(event) => {
527
+ event.preventDefault();
528
+ onSearch(query);
529
+ }}
530
+ >
531
+ <div className="search-input-row">
532
+ <input
533
+ ref={inputRef}
534
+ value={query}
535
+ onChange={(event) => onQueryChange(event.target.value)}
536
+ placeholder="Search exact words, sermon titles, dates, or topics..."
537
+ disabled={busy || !authenticated}
538
+ />
539
+ <button type="submit" disabled={busy || !query.trim() || !authenticated}>
540
+ {busy ? "Searching" : "Search"}
541
+ </button>
542
+ </div>
543
+ <div className="search-hints">
544
+ Try a phrase like <span>white horse</span>, a topic like <span>Serpent's Seed</span>, or a date code like <span>63-0318</span>.
545
+ </div>
546
+ </form>
547
+
548
+ {busy ? (
549
+ <div className="search-stage">
550
+ <LoadingStage stage="Retrieving" />
551
+ </div>
552
+ ) : null}
553
+
554
+ {error ? <div className="search-error">Search failed: {error}</div> : null}
555
+
556
+ {!searched ? (
557
+ <div className="search-empty">
558
+ <div className="label">Archive search</div>
559
+ <h2>Find paragraphs first, then open the source text.</h2>
560
+ </div>
561
+ ) : null}
562
+
563
+ {searched && !busy ? (
564
+ <div className="search-summary">
565
+ <strong>{results.length}</strong>
566
+ <span>paragraphs</span>
567
+ <strong>{distinctSourceCount(results)}</strong>
568
+ <span>sermons</span>
569
+ {debugLog.length ? <small>{debugLog.join(" - ")}</small> : null}
570
+ </div>
571
+ ) : null}
572
+
573
+ {searched && !busy && results.length ? <SearchResultGroups results={results} /> : null}
574
+
575
+ {searched && !busy && !results.length && !error ? (
576
+ <div className="search-empty">
577
+ <div className="label">No results</div>
578
+ <h2>Try fewer words or a different sermon title.</h2>
579
+ </div>
580
+ ) : null}
581
+ </section>
582
+ );
583
+ }
584
+
585
+ function SearchResultGroups({ results }: { results: SourceDoc[] }) {
586
+ const groups = results.reduce<Record<string, SourceDoc[]>>((acc, source) => {
587
+ const key = source.source || "Unknown source";
588
+ acc[key] = acc[key] || [];
589
+ acc[key].push(source);
590
+ return acc;
591
+ }, {});
592
+
593
+ return (
594
+ <div className="search-results">
595
+ {Object.entries(groups).map(([source, items]) => (
596
+ <section className="search-result-group" key={source}>
597
+ <div className="result-group-heading">
598
+ <h3>{source}</h3>
599
+ <span>{items.length} paragraphs</span>
600
+ </div>
601
+ <div className="result-list">
602
+ {items.map((item, index) => (
603
+ <article className="result-card" key={`${source}-${item.paragraph}-${index}`}>
604
+ <a href={item.messagehub_url || "#"} target="_blank" rel="noreferrer">
605
+ Para {item.paragraph || "-"}
606
+ </a>
607
+ <p>{item.full_text || item.content}</p>
608
+ </article>
609
+ ))}
610
+ </div>
611
+ </section>
612
+ ))}
613
+ </div>
614
+ );
615
+ }
616
+
617
+ function EmptyState({ mode, onPick }: { mode: AppMode; onPick: (question: string) => void }) {
618
+ return (
619
+ <div className={mode === "conversation" ? "empty conversation-empty" : "empty"}>
620
+ <div className="label">{mode === "conversation" ? "Conversation" : "Start with a grounded question"}</div>
621
+ <h2>{mode === "conversation" ? "Follow up on the current thread." : "Choose a topic or ask directly."}</h2>
622
+ <div className="empty-actions">
623
+ {quickTopics.slice(0, 3).map(([label, question]) => (
624
+ <button key={label} type="button" onClick={() => onPick(question)}>
625
+ {label}
626
+ </button>
627
+ ))}
628
+ </div>
629
+ </div>
630
+ );
631
+ }
632
+
633
+ function MessageCard({ message }: { message: Message }) {
634
+ return (
635
+ <article className={`message ${message.role}`}>
636
+ <div className="avatar">{message.role === "user" ? "You" : "RAG"}</div>
637
+ <div className="bubble">
638
+ {message.pending ? <LoadingStage stage={message.loadingStage || "Retrieving"} /> : null}
639
+ <div className="message-content">
640
+ {message.role === "assistant" ? (
641
+ <ReactMarkdown>{message.content}</ReactMarkdown>
642
+ ) : (
643
+ <p>{message.content}</p>
644
+ )}
645
+ </div>
646
+ {message.role === "assistant" ? <EvidencePanel message={message} /> : null}
647
+ </div>
648
+ </article>
649
+ );
650
+ }
651
+
652
+ function LoadingStage({ stage }: { stage: NonNullable<Message["loadingStage"]> }) {
653
+ return (
654
+ <div className="stage-line">
655
+ <span aria-hidden="true" />
656
+ {stage}
657
+ </div>
658
+ );
659
+ }
660
+
661
+ function EvidencePanel({ message }: { message: Message }) {
662
+ const diagnostics = message.diagnostics || {};
663
+ const sources = message.sources || [];
664
+ const retrievalQuestion = typeof diagnostics.retrieval_question === "string"
665
+ ? diagnostics.retrieval_question
666
+ : "";
667
+ const showRetrievalQuestion =
668
+ message.appMode === "conversation" &&
669
+ retrievalQuestion &&
670
+ normalizeText(retrievalQuestion) !== normalizeText(message.question || "");
671
+ return (
672
+ <div className="evidence">
673
+ {showRetrievalQuestion ? (
674
+ <div className="rewrite-card">
675
+ <span>Standalone retrieval question</span>
676
+ <p>{retrievalQuestion}</p>
677
+ </div>
678
+ ) : null}
679
+
680
+ <div className="metrics">
681
+ <Metric label="Grounding" value={labelStatus(message.groundingStatus)} />
682
+ <Metric label="Excerpts" value={String(diagnostics.source_count ?? sources.length)} />
683
+ <Metric label="Sermons" value={String(diagnostics.distinct_sermon_count ?? "-")} />
684
+ <Metric label="Pinecone" value={String(diagnostics.pinecone_status ?? "unknown")} />
685
+ <Metric label="Latency" value={`${diagnostics.total_latency ?? "-"}s`} />
686
+ </div>
687
+
688
+ <details>
689
+ <summary>References</summary>
690
+ <GroupedSources sources={sources} />
691
+ </details>
692
+
693
+ <details>
694
+ <summary>Advanced diagnostics</summary>
695
+ <pre>{JSON.stringify({ diagnostics, sources }, null, 2)}</pre>
696
+ </details>
697
+ </div>
698
+ );
699
+ }
700
+
701
+ function Metric({ label, value }: { label: string; value: string }) {
702
+ return (
703
+ <div className="metric">
704
+ <span>{label}</span>
705
+ <strong>{value}</strong>
706
+ </div>
707
+ );
708
+ }
709
+
710
+ function labelStatus(status: GroundingStatus | undefined) {
711
+ if (status === "supported") return "Supported";
712
+ if (status === "partial") return "Partial";
713
+ if (status === "insufficient") return "Insufficient";
714
+ if (status === "pending") return "Pending";
715
+ return "Unknown";
716
+ }
717
+
718
+ function normalizeText(value: string) {
719
+ return value.replace(/\s+/g, " ").trim().toLowerCase();
720
+ }
721
+
722
+ function sourceCodesFromSources(sources: SourceDoc[]) {
723
+ const codes = new Set<string>();
724
+ for (const source of sources) {
725
+ const match = source.source.match(/\b\d{2}-\d{4}[A-Z]?\b/i);
726
+ if (match) codes.add(match[0].toUpperCase());
727
+ }
728
+ return [...codes];
729
+ }
730
+
731
+ function distinctSourceCount(sources: SourceDoc[]) {
732
+ return new Set(sources.map((source) => source.source || "Unknown source")).size;
733
+ }
734
+
735
+ function GroupedSources({ sources }: { sources: SourceDoc[] }) {
736
+ if (!sources.length) return <div className="muted block">No source excerpts were returned.</div>;
737
+ const groups = sources.reduce<Record<string, SourceDoc[]>>((acc, source) => {
738
+ const key = source.source || "Unknown source";
739
+ acc[key] = acc[key] || [];
740
+ acc[key].push(source);
741
+ return acc;
742
+ }, {});
743
+
744
+ return (
745
+ <div className="source-groups">
746
+ {Object.entries(groups).map(([source, items]) => (
747
+ <details key={source} className="source-group">
748
+ <summary>
749
+ {source} <span>{items.length} excerpts</span>
750
+ </summary>
751
+ <div className="source-list">
752
+ {items.map((item, index) => (
753
+ <div className="source-item" key={`${source}-${item.paragraph}-${index}`}>
754
+ <a href={item.messagehub_url || "#"} target="_blank" rel="noreferrer">
755
+ Para {item.paragraph || "-"}
756
+ </a>
757
+ <span>
758
+ {item.retrieval_origin || "unknown"} - rank {item.retrieval_rank || "-"}
759
+ </span>
760
+ <p>{item.full_text || item.content}</p>
761
+ </div>
762
+ ))}
763
+ </div>
764
+ </details>
765
+ ))}
766
+ </div>
767
+ );
768
+ }
769
+
770
+ async function* readSse(stream: ReadableStream<Uint8Array>): AsyncGenerator<SseEvent> {
771
+ const reader = stream.getReader();
772
+ const decoder = new TextDecoder();
773
+ let buffer = "";
774
+
775
+ while (true) {
776
+ const { value, done } = await reader.read();
777
+ if (done) break;
778
+ buffer += decoder.decode(value, { stream: true });
779
+ const parts = buffer.split("\n\n");
780
+ buffer = parts.pop() || "";
781
+ for (const raw of parts) {
782
+ const event = parseSse(raw);
783
+ if (event) yield event;
784
+ }
785
+ }
786
+
787
+ if (buffer.trim()) {
788
+ const event = parseSse(buffer);
789
+ if (event) yield event;
790
+ }
791
+ }
792
+
793
+ function parseSse(raw: string): SseEvent | null {
794
+ let event = "message";
795
+ const dataLines: string[] = [];
796
+ for (const line of raw.split("\n")) {
797
+ if (line.startsWith("event:")) event = line.slice(6).trim();
798
+ if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
799
+ }
800
+ if (!dataLines.length) return null;
801
+ return { event, data: JSON.parse(dataLines.join("\n")) };
802
+ }
803
+
804
+ createRoot(document.getElementById("root")!).render(<App />);
frontend/src/styles.css ADDED
@@ -0,0 +1,856 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ color-scheme: dark;
3
+ --bg: #0f0f10;
4
+ --panel: #171719;
5
+ --panel-2: #1d1d20;
6
+ --panel-3: #242428;
7
+ --text: #f2f2f3;
8
+ --muted: #a7a7ac;
9
+ --subtle: #73737a;
10
+ --line: #303036;
11
+ --line-strong: #4a4a52;
12
+ --shadow: 0 24px 80px rgba(0, 0, 0, 0.45);
13
+ }
14
+
15
+ * {
16
+ box-sizing: border-box;
17
+ }
18
+
19
+ html,
20
+ body,
21
+ #root {
22
+ height: 100%;
23
+ min-height: 100%;
24
+ }
25
+
26
+ body {
27
+ margin: 0;
28
+ overflow: hidden;
29
+ color: var(--text);
30
+ background: var(--bg);
31
+ font-family:
32
+ ui-sans-serif,
33
+ -apple-system,
34
+ BlinkMacSystemFont,
35
+ "Segoe UI",
36
+ sans-serif;
37
+ }
38
+
39
+ button,
40
+ input,
41
+ select {
42
+ font: inherit;
43
+ }
44
+
45
+ button {
46
+ cursor: pointer;
47
+ }
48
+
49
+ .app-shell {
50
+ display: grid;
51
+ grid-template-columns: 300px minmax(0, 1fr);
52
+ height: 100vh;
53
+ min-height: 100vh;
54
+ overflow: hidden;
55
+ }
56
+
57
+ .rail {
58
+ display: flex;
59
+ flex-direction: column;
60
+ gap: 18px;
61
+ min-height: 0;
62
+ overflow-y: auto;
63
+ padding: 22px;
64
+ border-right: 1px solid var(--line);
65
+ background: #121214;
66
+ }
67
+
68
+ .brand {
69
+ display: grid;
70
+ gap: 10px;
71
+ padding-bottom: 8px;
72
+ }
73
+
74
+ .label {
75
+ color: var(--muted);
76
+ font-size: 0.74rem;
77
+ font-weight: 700;
78
+ letter-spacing: 0.12em;
79
+ text-transform: uppercase;
80
+ }
81
+
82
+ h1,
83
+ h2,
84
+ p {
85
+ margin: 0;
86
+ }
87
+
88
+ h1 {
89
+ max-width: 14rem;
90
+ font-size: 1.55rem;
91
+ line-height: 1.08;
92
+ letter-spacing: 0;
93
+ }
94
+
95
+ h2 {
96
+ font-size: 1.45rem;
97
+ line-height: 1.18;
98
+ letter-spacing: 0;
99
+ }
100
+
101
+ p {
102
+ color: var(--muted);
103
+ line-height: 1.55;
104
+ }
105
+
106
+ .control {
107
+ display: grid;
108
+ gap: 8px;
109
+ color: var(--muted);
110
+ font-size: 0.86rem;
111
+ font-weight: 650;
112
+ }
113
+
114
+ .mode-block {
115
+ display: grid;
116
+ gap: 9px;
117
+ }
118
+
119
+ .mode-block > span {
120
+ color: var(--muted);
121
+ font-size: 0.86rem;
122
+ font-weight: 650;
123
+ }
124
+
125
+ .mode-grid {
126
+ display: grid;
127
+ gap: 8px;
128
+ }
129
+
130
+ .mode-card {
131
+ display: grid;
132
+ gap: 4px;
133
+ width: 100%;
134
+ padding: 11px;
135
+ border: 1px solid var(--line);
136
+ border-radius: 12px;
137
+ color: var(--muted);
138
+ background: var(--panel);
139
+ text-align: left;
140
+ }
141
+
142
+ .mode-card strong {
143
+ color: var(--text);
144
+ font-size: 0.95rem;
145
+ }
146
+
147
+ .mode-card small {
148
+ color: var(--muted);
149
+ line-height: 1.35;
150
+ }
151
+
152
+ .mode-card:hover,
153
+ .mode-card.active {
154
+ border-color: var(--line-strong);
155
+ background: var(--panel-2);
156
+ }
157
+
158
+ input,
159
+ select {
160
+ width: 100%;
161
+ min-height: 42px;
162
+ padding: 10px 12px;
163
+ border: 1px solid var(--line);
164
+ border-radius: 10px;
165
+ color: var(--text);
166
+ background: var(--panel);
167
+ outline: none;
168
+ }
169
+
170
+ input:focus,
171
+ select:focus {
172
+ border-color: var(--line-strong);
173
+ }
174
+
175
+ input:disabled,
176
+ select:disabled {
177
+ color: var(--subtle);
178
+ background: #141416;
179
+ }
180
+
181
+ .quick-list {
182
+ display: grid;
183
+ gap: 8px;
184
+ }
185
+
186
+ .quick-list span {
187
+ color: var(--muted);
188
+ font-size: 0.86rem;
189
+ font-weight: 650;
190
+ }
191
+
192
+ .quick-list button,
193
+ .ghost,
194
+ .empty-actions button {
195
+ min-height: 40px;
196
+ border: 1px solid var(--line);
197
+ border-radius: 10px;
198
+ color: var(--text);
199
+ background: var(--panel);
200
+ text-align: left;
201
+ }
202
+
203
+ .quick-list button,
204
+ .ghost {
205
+ padding: 9px 11px;
206
+ }
207
+
208
+ .quick-list button:hover,
209
+ .ghost:hover,
210
+ .empty-actions button:hover,
211
+ .search-form button:hover {
212
+ border-color: var(--line-strong);
213
+ background: var(--panel-2);
214
+ }
215
+
216
+ button:disabled {
217
+ cursor: not-allowed;
218
+ opacity: 0.48;
219
+ }
220
+
221
+ .ghost {
222
+ text-align: center;
223
+ }
224
+
225
+ .chat-pane {
226
+ display: grid;
227
+ grid-template-rows: auto minmax(0, 1fr) auto;
228
+ height: 100vh;
229
+ min-width: 0;
230
+ min-height: 0;
231
+ overflow: hidden;
232
+ }
233
+
234
+ .search-pane {
235
+ grid-template-rows: auto minmax(0, 1fr);
236
+ }
237
+
238
+ .top-stack {
239
+ min-width: 0;
240
+ }
241
+
242
+ .topbar {
243
+ display: flex;
244
+ align-items: center;
245
+ justify-content: space-between;
246
+ gap: 16px;
247
+ padding: 22px 26px;
248
+ border-bottom: 1px solid var(--line);
249
+ background: rgba(15, 15, 16, 0.92);
250
+ }
251
+
252
+ .topbar p {
253
+ max-width: 620px;
254
+ margin-top: 5px;
255
+ font-size: 0.92rem;
256
+ }
257
+
258
+ .mode-strip {
259
+ padding: 10px 26px;
260
+ border-bottom: 1px solid var(--line);
261
+ color: var(--muted);
262
+ background: #101012;
263
+ font-size: 0.9rem;
264
+ }
265
+
266
+ .conversation-strip {
267
+ border-bottom-style: dashed;
268
+ background: #141416;
269
+ }
270
+
271
+ .api-pill {
272
+ flex: 0 0 auto;
273
+ padding: 8px 10px;
274
+ border: 1px solid var(--line);
275
+ border-radius: 999px;
276
+ color: var(--muted);
277
+ background: var(--panel);
278
+ font-size: 0.84rem;
279
+ }
280
+
281
+ .thread {
282
+ display: flex;
283
+ flex-direction: column;
284
+ gap: 18px;
285
+ min-height: 0;
286
+ overflow-y: auto;
287
+ padding: 24px 26px;
288
+ }
289
+
290
+ .mode-conversation .topbar {
291
+ background: #121214;
292
+ }
293
+
294
+ .conversation-thread {
295
+ background:
296
+ linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px) 0 0 / 32px 32px,
297
+ var(--bg);
298
+ }
299
+
300
+ .conversation-empty {
301
+ border-style: dashed;
302
+ }
303
+
304
+ .empty {
305
+ display: grid;
306
+ gap: 16px;
307
+ max-width: 780px;
308
+ padding: 24px;
309
+ border: 1px solid var(--line);
310
+ border-radius: 16px;
311
+ background: var(--panel);
312
+ box-shadow: var(--shadow);
313
+ }
314
+
315
+ .empty-actions {
316
+ display: flex;
317
+ flex-wrap: wrap;
318
+ gap: 10px;
319
+ }
320
+
321
+ .empty-actions button {
322
+ padding: 9px 12px;
323
+ }
324
+
325
+ .search-workspace {
326
+ min-height: 0;
327
+ overflow-y: auto;
328
+ padding: 26px;
329
+ }
330
+
331
+ .search-form {
332
+ display: grid;
333
+ gap: 10px;
334
+ max-width: 1040px;
335
+ padding: 18px;
336
+ border: 1px solid var(--line);
337
+ border-radius: 18px;
338
+ background: var(--panel);
339
+ box-shadow: var(--shadow);
340
+ }
341
+
342
+ .search-input-row {
343
+ display: grid;
344
+ grid-template-columns: minmax(0, 1fr) auto;
345
+ gap: 10px;
346
+ }
347
+
348
+ .search-input-row input {
349
+ min-height: 58px;
350
+ border-radius: 14px;
351
+ font-size: 1.02rem;
352
+ }
353
+
354
+ .search-form button {
355
+ min-width: 112px;
356
+ border: 1px solid var(--line-strong);
357
+ border-radius: 14px;
358
+ color: var(--bg);
359
+ background: var(--text);
360
+ font-weight: 750;
361
+ }
362
+
363
+ .search-hints {
364
+ color: var(--muted);
365
+ font-size: 0.9rem;
366
+ }
367
+
368
+ .search-hints span {
369
+ color: var(--text);
370
+ }
371
+
372
+ .search-stage,
373
+ .search-error,
374
+ .search-empty,
375
+ .search-summary,
376
+ .search-result-group {
377
+ max-width: 1040px;
378
+ }
379
+
380
+ .search-stage {
381
+ margin-top: 16px;
382
+ }
383
+
384
+ .search-error {
385
+ margin-top: 16px;
386
+ padding: 12px;
387
+ border: 1px solid var(--line-strong);
388
+ border-radius: 12px;
389
+ color: var(--text);
390
+ background: var(--panel-2);
391
+ }
392
+
393
+ .search-empty {
394
+ display: grid;
395
+ gap: 8px;
396
+ margin-top: 18px;
397
+ padding: 20px;
398
+ border: 1px solid var(--line);
399
+ border-radius: 16px;
400
+ background: #141416;
401
+ }
402
+
403
+ .search-summary {
404
+ display: flex;
405
+ flex-wrap: wrap;
406
+ align-items: baseline;
407
+ gap: 8px;
408
+ margin-top: 18px;
409
+ padding: 12px 0;
410
+ color: var(--muted);
411
+ }
412
+
413
+ .search-summary strong {
414
+ color: var(--text);
415
+ font-size: 1.4rem;
416
+ }
417
+
418
+ .search-summary small {
419
+ flex-basis: 100%;
420
+ color: var(--subtle);
421
+ }
422
+
423
+ .search-results {
424
+ display: grid;
425
+ gap: 18px;
426
+ margin-top: 6px;
427
+ padding-bottom: 28px;
428
+ }
429
+
430
+ .search-result-group {
431
+ display: grid;
432
+ gap: 10px;
433
+ padding: 14px;
434
+ border: 1px solid var(--line);
435
+ border-radius: 16px;
436
+ background: var(--panel);
437
+ }
438
+
439
+ .result-group-heading {
440
+ display: flex;
441
+ align-items: baseline;
442
+ justify-content: space-between;
443
+ gap: 12px;
444
+ }
445
+
446
+ .result-group-heading h3 {
447
+ margin: 0;
448
+ color: var(--text);
449
+ font-size: 1rem;
450
+ line-height: 1.35;
451
+ }
452
+
453
+ .result-group-heading span {
454
+ flex: 0 0 auto;
455
+ color: var(--muted);
456
+ font-size: 0.84rem;
457
+ }
458
+
459
+ .result-list {
460
+ display: grid;
461
+ gap: 10px;
462
+ }
463
+
464
+ .result-card {
465
+ display: grid;
466
+ gap: 8px;
467
+ padding: 14px;
468
+ border: 1px solid var(--line);
469
+ border-radius: 12px;
470
+ background: var(--panel-2);
471
+ }
472
+
473
+ .result-card a {
474
+ width: fit-content;
475
+ color: var(--text);
476
+ font-weight: 750;
477
+ text-decoration: none;
478
+ }
479
+
480
+ .result-card a:hover {
481
+ text-decoration: underline;
482
+ }
483
+
484
+ .result-card p {
485
+ color: var(--muted);
486
+ font-size: 0.95rem;
487
+ line-height: 1.65;
488
+ white-space: pre-wrap;
489
+ }
490
+
491
+ .message {
492
+ display: grid;
493
+ grid-template-columns: 48px minmax(0, 1fr);
494
+ gap: 12px;
495
+ align-items: start;
496
+ }
497
+
498
+ .avatar {
499
+ display: grid;
500
+ place-items: center;
501
+ width: 48px;
502
+ height: 36px;
503
+ border: 1px solid var(--line);
504
+ border-radius: 999px;
505
+ color: var(--muted);
506
+ background: var(--panel);
507
+ font-size: 0.78rem;
508
+ font-weight: 700;
509
+ }
510
+
511
+ .bubble {
512
+ max-width: 960px;
513
+ padding: 16px;
514
+ border: 1px solid var(--line);
515
+ border-radius: 16px;
516
+ background: var(--panel);
517
+ }
518
+
519
+ .message.user .bubble {
520
+ background: #141416;
521
+ }
522
+
523
+ .message-content {
524
+ color: var(--text);
525
+ line-height: 1.62;
526
+ }
527
+
528
+ .stage-line {
529
+ display: inline-flex;
530
+ align-items: center;
531
+ gap: 8px;
532
+ width: fit-content;
533
+ margin-bottom: 10px;
534
+ padding: 7px 10px;
535
+ border: 1px solid var(--line);
536
+ border-radius: 999px;
537
+ color: var(--muted);
538
+ background: #141416;
539
+ font-size: 0.84rem;
540
+ font-weight: 650;
541
+ }
542
+
543
+ .stage-line span {
544
+ width: 7px;
545
+ height: 7px;
546
+ border-radius: 999px;
547
+ background: var(--muted);
548
+ animation: stage-pulse 1s ease-in-out infinite;
549
+ }
550
+
551
+ @keyframes stage-pulse {
552
+ 0%,
553
+ 100% {
554
+ opacity: 0.4;
555
+ }
556
+
557
+ 50% {
558
+ opacity: 1;
559
+ }
560
+ }
561
+
562
+ .message-content p,
563
+ .message-content li {
564
+ color: var(--text);
565
+ }
566
+
567
+ .message-content h1,
568
+ .message-content h2,
569
+ .message-content h3 {
570
+ margin: 12px 0 8px;
571
+ font-size: 1.06rem;
572
+ line-height: 1.3;
573
+ }
574
+
575
+ .message-content ul,
576
+ .message-content ol {
577
+ padding-left: 20px;
578
+ }
579
+
580
+ .muted {
581
+ color: var(--muted);
582
+ }
583
+
584
+ .block {
585
+ display: block;
586
+ padding: 12px;
587
+ }
588
+
589
+ .evidence {
590
+ display: grid;
591
+ gap: 10px;
592
+ margin-top: 16px;
593
+ }
594
+
595
+ .rewrite-card {
596
+ display: grid;
597
+ gap: 6px;
598
+ padding: 12px;
599
+ border: 1px solid var(--line);
600
+ border-radius: 12px;
601
+ background: #141416;
602
+ }
603
+
604
+ .rewrite-card span {
605
+ color: var(--muted);
606
+ font-size: 0.78rem;
607
+ font-weight: 750;
608
+ text-transform: uppercase;
609
+ letter-spacing: 0.08em;
610
+ }
611
+
612
+ .rewrite-card p {
613
+ color: var(--text);
614
+ font-size: 0.95rem;
615
+ }
616
+
617
+ .metrics {
618
+ display: grid;
619
+ grid-template-columns: repeat(5, minmax(0, 1fr));
620
+ gap: 8px;
621
+ }
622
+
623
+ .metric {
624
+ min-width: 0;
625
+ padding: 10px;
626
+ border: 1px solid var(--line);
627
+ border-radius: 10px;
628
+ background: var(--panel-2);
629
+ }
630
+
631
+ .metric span,
632
+ .source-item span {
633
+ display: block;
634
+ color: var(--muted);
635
+ font-size: 0.76rem;
636
+ }
637
+
638
+ .metric strong {
639
+ display: block;
640
+ margin-top: 4px;
641
+ overflow: hidden;
642
+ color: var(--text);
643
+ font-size: 0.9rem;
644
+ text-overflow: ellipsis;
645
+ white-space: nowrap;
646
+ }
647
+
648
+ details {
649
+ border: 1px solid var(--line);
650
+ border-radius: 12px;
651
+ background: #141416;
652
+ }
653
+
654
+ summary {
655
+ padding: 11px 12px;
656
+ color: var(--text);
657
+ cursor: pointer;
658
+ font-size: 0.92rem;
659
+ font-weight: 700;
660
+ }
661
+
662
+ .source-groups {
663
+ display: grid;
664
+ gap: 8px;
665
+ padding: 0 10px 10px;
666
+ }
667
+
668
+ .source-group {
669
+ background: var(--panel);
670
+ }
671
+
672
+ .source-group summary {
673
+ color: var(--muted);
674
+ }
675
+
676
+ .source-group summary span {
677
+ color: var(--subtle);
678
+ font-weight: 500;
679
+ }
680
+
681
+ .source-list {
682
+ display: grid;
683
+ gap: 12px;
684
+ padding: 0 10px 10px;
685
+ }
686
+
687
+ .source-item {
688
+ display: grid;
689
+ gap: 8px;
690
+ padding: 14px;
691
+ border: 1px solid var(--line);
692
+ border-radius: 10px;
693
+ background: var(--panel-2);
694
+ }
695
+
696
+ .source-item a {
697
+ color: var(--text);
698
+ font-weight: 700;
699
+ text-decoration: none;
700
+ }
701
+
702
+ .source-item a:hover {
703
+ text-decoration: underline;
704
+ }
705
+
706
+ .source-item p {
707
+ color: var(--muted);
708
+ font-size: 0.95rem;
709
+ line-height: 1.65;
710
+ white-space: pre-wrap;
711
+ }
712
+
713
+ pre {
714
+ max-height: 360px;
715
+ margin: 0;
716
+ overflow: auto;
717
+ padding: 12px;
718
+ border-top: 1px solid var(--line);
719
+ color: var(--muted);
720
+ background: #0d0d0e;
721
+ font-size: 0.82rem;
722
+ }
723
+
724
+ .composer {
725
+ position: sticky;
726
+ bottom: 0;
727
+ z-index: 5;
728
+ display: grid;
729
+ grid-template-columns: minmax(0, 1fr) auto;
730
+ gap: 10px;
731
+ padding: 18px 26px 22px;
732
+ border-top: 1px solid var(--line);
733
+ background: rgba(15, 15, 16, 0.94);
734
+ }
735
+
736
+ .composer input {
737
+ min-height: 50px;
738
+ border-radius: 14px;
739
+ }
740
+
741
+ .composer button,
742
+ .gate-panel button {
743
+ min-width: 108px;
744
+ border: 1px solid var(--line-strong);
745
+ border-radius: 14px;
746
+ color: var(--bg);
747
+ background: var(--text);
748
+ font-weight: 750;
749
+ }
750
+
751
+ .composer button:disabled {
752
+ cursor: wait;
753
+ opacity: 0.45;
754
+ }
755
+
756
+ .conversation-composer {
757
+ background: #121214;
758
+ }
759
+
760
+ .gate {
761
+ position: fixed;
762
+ inset: 0;
763
+ z-index: 20;
764
+ display: grid;
765
+ place-items: center;
766
+ padding: 20px;
767
+ background: rgba(5, 5, 6, 0.82);
768
+ backdrop-filter: blur(18px);
769
+ }
770
+
771
+ .gate-panel {
772
+ display: grid;
773
+ gap: 14px;
774
+ width: min(440px, 100%);
775
+ padding: 24px;
776
+ border: 1px solid var(--line-strong);
777
+ border-radius: 18px;
778
+ background: var(--panel);
779
+ box-shadow: var(--shadow);
780
+ }
781
+
782
+ .gate-panel button {
783
+ min-height: 46px;
784
+ }
785
+
786
+ .form-error {
787
+ color: var(--text);
788
+ padding: 10px;
789
+ border: 1px solid var(--line-strong);
790
+ border-radius: 10px;
791
+ background: var(--panel-3);
792
+ }
793
+
794
+ @media (max-width: 900px) {
795
+ body {
796
+ overflow: auto;
797
+ }
798
+
799
+ .app-shell {
800
+ grid-template-columns: 1fr;
801
+ height: auto;
802
+ overflow: visible;
803
+ }
804
+
805
+ .rail {
806
+ max-height: none;
807
+ border-right: 0;
808
+ border-bottom: 1px solid var(--line);
809
+ }
810
+
811
+ .chat-pane {
812
+ height: calc(100vh - 1px);
813
+ }
814
+
815
+ .metrics {
816
+ grid-template-columns: repeat(2, minmax(0, 1fr));
817
+ }
818
+
819
+ .search-workspace {
820
+ padding: 18px;
821
+ }
822
+ }
823
+
824
+ @media (max-width: 620px) {
825
+ .topbar,
826
+ .thread,
827
+ .composer {
828
+ padding-left: 16px;
829
+ padding-right: 16px;
830
+ }
831
+
832
+ .message {
833
+ grid-template-columns: 1fr;
834
+ }
835
+
836
+ .avatar {
837
+ width: fit-content;
838
+ padding: 0 12px;
839
+ }
840
+
841
+ .composer {
842
+ grid-template-columns: 1fr;
843
+ }
844
+
845
+ .search-input-row {
846
+ grid-template-columns: 1fr;
847
+ }
848
+
849
+ .search-form button {
850
+ min-height: 46px;
851
+ }
852
+
853
+ .composer button {
854
+ min-height: 46px;
855
+ }
856
+ }
frontend/tsconfig.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["DOM", "DOM.Iterable", "ES2020"],
6
+ "allowJs": false,
7
+ "skipLibCheck": true,
8
+ "esModuleInterop": true,
9
+ "allowSyntheticDefaultImports": true,
10
+ "strict": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "module": "ESNext",
13
+ "moduleResolution": "Node",
14
+ "resolveJsonModule": true,
15
+ "isolatedModules": true,
16
+ "noEmit": true,
17
+ "jsx": "react-jsx"
18
+ },
19
+ "include": ["src"],
20
+ "references": []
21
+ }
frontend/vite.config.ts ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ build: {
7
+ outDir: "../src/web",
8
+ emptyOutDir: true,
9
+ },
10
+ server: {
11
+ port: 5173,
12
+ proxy: {
13
+ "/api": "http://localhost:8000",
14
+ },
15
+ },
16
+ });
render.yaml ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ - type: web
3
+ name: the-7th-handle
4
+ runtime: docker
5
+ plan: starter
6
+ healthCheckPath: /api/health
7
+ envVars:
8
+ - key: APP_PASSWORD
9
+ sync: false
10
+ - key: LLM_PROVIDER
11
+ value: groq
12
+ - key: GROQ_MODEL
13
+ value: llama-3.3-70b-versatile
14
+ - key: GROQ_API_KEY
15
+ sync: false
16
+ - key: PINECONE_API_KEY
17
+ sync: false
18
+ - key: SERMON_CHUNKS_URL
19
+ sync: false
20
+ - key: HF_HOME
21
+ value: /app/.cache/huggingface
22
+ - key: TRANSFORMERS_CACHE
23
+ value: /app/.cache/huggingface
requirements.txt CHANGED
@@ -8,7 +8,9 @@ langchain-groq
8
  pinecone>=5.0.0
9
  rank_bm25
10
  python-dotenv
11
- streamlit
 
 
12
  tqdm
13
  pymupdf
14
- sentence_transformers
 
8
  pinecone>=5.0.0
9
  rank_bm25
10
  python-dotenv
11
+ fastapi
12
+ uvicorn[standard]
13
+ gdown
14
  tqdm
15
  pymupdf
16
+ sentence_transformers
scripts/download_chunks.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pathlib import Path
3
+
4
+
5
+ ROOT = Path(__file__).resolve().parents[1]
6
+ TARGET = ROOT / "src" / "sermon_chunks.pkl"
7
+
8
+
9
+ def main() -> None:
10
+ if TARGET.exists() and TARGET.stat().st_size > 0:
11
+ print(f"Using existing chunk file: {TARGET}")
12
+ return
13
+
14
+ url = os.getenv("SERMON_CHUNKS_URL", "").strip()
15
+ if not url:
16
+ raise RuntimeError(
17
+ "src/sermon_chunks.pkl is missing and SERMON_CHUNKS_URL is not set."
18
+ )
19
+
20
+ TARGET.parent.mkdir(parents=True, exist_ok=True)
21
+ tmp_path = TARGET.with_suffix(".pkl.download")
22
+
23
+ try:
24
+ import gdown
25
+
26
+ output = gdown.download(url=url, output=str(tmp_path), quiet=False, fuzzy=True)
27
+ if not output:
28
+ raise RuntimeError("gdown did not return an output path.")
29
+ except Exception as exc:
30
+ if tmp_path.exists():
31
+ tmp_path.unlink()
32
+ raise RuntimeError(f"Failed to download sermon chunks: {exc}") from exc
33
+
34
+ if not tmp_path.exists() or tmp_path.stat().st_size == 0:
35
+ raise RuntimeError("Downloaded sermon chunk file is empty.")
36
+
37
+ tmp_path.replace(TARGET)
38
+ print(f"Downloaded sermon chunks to: {TARGET}")
39
+
40
+
41
+ if __name__ == "__main__":
42
+ main()
server-8010.err.log ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ INFO: Started server process [40516]
2
+ INFO: Waiting for application startup.
3
+ INFO: Application startup complete.
4
+ INFO: Uvicorn running on http://127.0.0.1:8010 (Press CTRL+C to quit)
5
+ ERROR: Exception in ASGI application
6
+ Traceback (most recent call last):
7
+ File "C:\Users\Hp\Documents\Codex\7th-handle\7th_handle\venv\Lib\site-packages\uvicorn\protocols\http\httptools_impl.py", line 421, in run_asgi
8
+ result = await app( # type: ignore[func-returns-value]
9
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
10
+ File "C:\Users\Hp\Documents\Codex\7th-handle\7th_handle\venv\Lib\site-packages\uvicorn\middleware\proxy_headers.py", line 56, in __call__
11
+ return await self.app(scope, receive, send)
12
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
13
+ File "C:\Users\Hp\Documents\Codex\7th-handle\7th_handle\venv\Lib\site-packages\fastapi\applications.py", line 1159, in __call__
14
+ await super().__call__(scope, receive, send)
15
+ File "C:\Users\Hp\Documents\Codex\7th-handle\7th_handle\venv\Lib\site-packages\starlette\applications.py", line 90, in __call__
16
+ await self.middleware_stack(scope, receive, send)
17
+ File "C:\Users\Hp\Documents\Codex\7th-handle\7th_handle\venv\Lib\site-packages\starlette\middleware\errors.py", line 186, in __call__
18
+ raise exc
19
+ File "C:\Users\Hp\Documents\Codex\7th-handle\7th_handle\venv\Lib\site-packages\starlette\middleware\errors.py", line 164, in __call__
20
+ await self.app(scope, receive, _send)
21
+ File "C:\Users\Hp\Documents\Codex\7th-handle\7th_handle\venv\Lib\site-packages\starlette\middleware\cors.py", line 88, in __call__
22
+ await self.app(scope, receive, send)
23
+ File "C:\Users\Hp\Documents\Codex\7th-handle\7th_handle\venv\Lib\site-packages\starlette\middleware\exceptions.py", line 63, in __call__
24
+ await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
25
+ File "C:\Users\Hp\Documents\Codex\7th-handle\7th_handle\venv\Lib\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
26
+ raise exc
27
+ File "C:\Users\Hp\Documents\Codex\7th-handle\7th_handle\venv\Lib\site-packages\starlette\_exception_handler.py", line 42, in wrapped_app
28
+ await app(scope, receive, sender)
29
+ File "C:\Users\Hp\Documents\Codex\7th-handle\7th_handle\venv\Lib\site-packages\fastapi\middleware\asyncexitstack.py", line 18, in __call__
30
+ await self.app(scope, receive, send)
31
+ File "C:\Users\Hp\Documents\Codex\7th-handle\7th_handle\venv\Lib\site-packages\starlette\routing.py", line 660, in __call__
32
+ await self.middleware_stack(scope, receive, send)
33
+ File "C:\Users\Hp\Documents\Codex\7th-handle\7th_handle\venv\Lib\site-packages\starlette\routing.py", line 680, in app
34
+ await route.handle(scope, receive, send)
35
+ File "C:\Users\Hp\Documents\Codex\7th-handle\7th_handle\venv\Lib\site-packages\starlette\routing.py", line 276, in handle
36
+ await self.app(scope, receive, send)
37
+ File "C:\Users\Hp\Documents\Codex\7th-handle\7th_handle\venv\Lib\site-packages\fastapi\routing.py", line 134, in app
38
+ await wrap_app_handling_exceptions(app, request)(scope, receive, send)
39
+ File "C:\Users\Hp\Documents\Codex\7th-handle\7th_handle\venv\Lib\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
40
+ raise exc
41
+ File "C:\Users\Hp\Documents\Codex\7th-handle\7th_handle\venv\Lib\site-packages\starlette\_exception_handler.py", line 42, in wrapped_app
42
+ await app(scope, receive, sender)
43
+ File "C:\Users\Hp\Documents\Codex\7th-handle\7th_handle\venv\Lib\site-packages\fastapi\routing.py", line 120, in app
44
+ response = await f(request)
45
+ ^^^^^^^^^^^^^^^^
46
+ File "C:\Users\Hp\Documents\Codex\7th-handle\7th_handle\venv\Lib\site-packages\fastapi\routing.py", line 674, in app
47
+ raw_response = await run_endpoint_function(
48
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
49
+ File "C:\Users\Hp\Documents\Codex\7th-handle\7th_handle\venv\Lib\site-packages\fastapi\routing.py", line 330, in run_endpoint_function
50
+ return await run_in_threadpool(dependant.call, **values)
51
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
52
+ File "C:\Users\Hp\Documents\Codex\7th-handle\7th_handle\venv\Lib\site-packages\starlette\concurrency.py", line 32, in run_in_threadpool
53
+ return await anyio.to_thread.run_sync(func)
54
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
55
+ File "C:\Users\Hp\Documents\Codex\7th-handle\7th_handle\venv\Lib\site-packages\anyio\to_thread.py", line 63, in run_sync
56
+ return await get_async_backend().run_sync_in_worker_thread(
57
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
58
+ File "C:\Users\Hp\Documents\Codex\7th-handle\7th_handle\venv\Lib\site-packages\anyio\_backends\_asyncio.py", line 2518, in run_sync_in_worker_thread
59
+ return await future
60
+ ^^^^^^^^^^^^
61
+ File "C:\Users\Hp\Documents\Codex\7th-handle\7th_handle\venv\Lib\site-packages\anyio\_backends\_asyncio.py", line 1002, in run
62
+ result = context.run(func, *args)
63
+ ^^^^^^^^^^^^^^^^^^^^^^^^
64
+ File "C:\Users\Hp\Documents\Codex\7th-handle\7th_handle\src\server.py", line 134, in answer
65
+ result = answer_question(
66
+ ^^^^^^^^^^^^^^^^
67
+ File "C:\Users\Hp\Documents\Codex\7th-handle\7th_handle\src\app.py", line 1020, in answer_question
68
+ response = llm.invoke(prepared["prompt_text"])
69
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
70
+ File "C:\Users\Hp\Documents\Codex\7th-handle\7th_handle\venv\Lib\site-packages\langchain_core\language_models\chat_models.py", line 395, in invoke
71
+ self.generate_prompt(
72
+ File "C:\Users\Hp\Documents\Codex\7th-handle\7th_handle\venv\Lib\site-packages\langchain_core\language_models\chat_models.py", line 1025, in generate_prompt
73
+ return self.generate(prompt_messages, stop=stop, callbacks=callbacks, **kwargs)
74
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
75
+ File "C:\Users\Hp\Documents\Codex\7th-handle\7th_handle\venv\Lib\site-packages\langchain_core\language_models\chat_models.py", line 842, in generate
76
+ self._generate_with_cache(
77
+ File "C:\Users\Hp\Documents\Codex\7th-handle\7th_handle\venv\Lib\site-packages\langchain_core\language_models\chat_models.py", line 1091, in _generate_with_cache
78
+ result = self._generate(
79
+ ^^^^^^^^^^^^^^^
80
+ File "C:\Users\Hp\Documents\Codex\7th-handle\7th_handle\venv\Lib\site-packages\langchain_groq\chat_models.py", line 533, in _generate
81
+ response = self.client.create(messages=message_dicts, **params)
82
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
83
+ File "C:\Users\Hp\Documents\Codex\7th-handle\7th_handle\venv\Lib\site-packages\groq\resources\chat\completions.py", line 461, in create
84
+ return self._post(
85
+ ^^^^^^^^^^^
86
+ File "C:\Users\Hp\Documents\Codex\7th-handle\7th_handle\venv\Lib\site-packages\groq\_base_client.py", line 1242, in post
87
+ return cast(ResponseT, self.request(cast_to, opts, stream=stream, stream_cls=stream_cls))
88
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
89
+ File "C:\Users\Hp\Documents\Codex\7th-handle\7th_handle\venv\Lib\site-packages\groq\_base_client.py", line 1044, in request
90
+ raise self._make_status_error_from_response(err.response) from None
91
+ groq.RateLimitError: Error code: 429 - {'error': {'message': 'Rate limit reached for model `llama-3.3-70b-versatile` in organization `org_01k1yk3zshf45vcjsnq5xh5ta4` service tier `on_demand` on tokens per minute (TPM): Limit 12000, Used 9219, Requested 2804. Please try again in 114.999999ms. Need more tokens? Upgrade to Dev Tier today at https://console.groq.com/settings/billing', 'type': 'tokens', 'code': 'rate_limit_exceeded'}}
server-8010.log ADDED
The diff for this file is too large to render. See raw diff
 
src/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
src/app.py CHANGED
@@ -7,7 +7,7 @@ import time
7
  import urllib.error
8
  import urllib.request
9
  from functools import lru_cache
10
- from typing import List
11
  from dotenv import load_dotenv
12
  from pinecone import Pinecone
13
 
@@ -256,7 +256,16 @@ def messagehub_link(filename: str) -> str:
256
 
257
 
258
  STOPWORDS = {
259
- "the", "a", "an", "of", "in", "on", "at", "and", "to", "for", "with", "by"
 
 
 
 
 
 
 
 
 
260
  }
261
 
262
 
@@ -301,6 +310,36 @@ def sermon_title_matches(user_query: str, filename: str) -> bool:
301
  return title_tokens.issubset(query_tokens)
302
 
303
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
  def detect_single_seal_title(query: str) -> str | None:
305
  query_clean = normalize_text(query)
306
  for alias, title in SINGLE_SEAL_ALIASES.items():
@@ -390,6 +429,55 @@ def select_context_docs(docs: List[Document], query: str, answer_mode: str) -> L
390
  selected.extend(rank_by_query_terms(other_docs, query)[:remaining])
391
  return selected[:max_docs]
392
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
393
  # ===============================
394
  # RETRIEVER
395
  # ===============================
@@ -466,7 +554,7 @@ def retrieve_documents_with_diagnostics(query: str, answer_mode: str = "Strict M
466
  target_code = extract_date_code(single_seal_title).upper()
467
  sermon_chunks = [
468
  d for d in chunks
469
- if source_matches_title(d.metadata.get("source", ""), title)
470
  ]
471
  if sermon_chunks:
472
  seal_query = SERIES_QUERY_HINTS.get(target_code, query)
@@ -492,7 +580,7 @@ def retrieve_documents_with_diagnostics(query: str, answer_mode: str = "Strict M
492
  else:
493
  title_chunks = [
494
  d for d in chunks
495
- if sermon_title_matches(query, d.metadata.get("source", ""))
496
  ]
497
  if title_chunks:
498
  if answer_mode == "Summary Mode":
@@ -502,20 +590,40 @@ def retrieve_documents_with_diagnostics(query: str, answer_mode: str = "Strict M
502
  for d in rank_by_query_terms(title_chunks, query)[:PRIORITY_K]:
503
  add_doc(d, "priority")
504
 
505
- for d in get_bm25_retriever().invoke(query):
506
- add_doc(d, "bm25")
507
-
508
- try:
509
- vec_docs = get_vector_store().as_retriever(
510
- search_kwargs={"k": VECTOR_K}
511
- ).invoke(query)
512
- pinecone_status = "ok"
513
- for d in vec_docs:
514
- add_doc(d, "pinecone")
515
- except Exception as exc:
516
- pinecone_status = "failed"
517
- pinecone_error = str(exc)
518
- logger.warning("Pinecone vector search failed: %s", exc)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
519
 
520
  final_docs = select_context_docs(results, query, answer_mode)
521
  distinct_sermons = {
@@ -535,8 +643,9 @@ def retrieve_documents_with_diagnostics(query: str, answer_mode: str = "Strict M
535
  "pinecone_error": pinecone_error,
536
  "context_policy": answer_mode,
537
  "candidate_count": len(results),
538
- "max_context_docs": CONTEXT_POLICY.get(answer_mode, CONTEXT_POLICY["Strict Mode"])["max_docs"],
539
  }
 
540
 
541
  return final_docs, diagnostics
542
 
@@ -596,6 +705,55 @@ QUESTION:
596
  ANSWER:
597
  """
598
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
599
  PROMPT = PromptTemplate(
600
  template=PROMPT_TEMPLATE,
601
  input_variables=["context_str", "question"],
@@ -613,6 +771,11 @@ DOCUMENT_PROMPT = PromptTemplate(
613
  input_variables=["page_content", "source", "paragraph"],
614
  )
615
 
 
 
 
 
 
616
  # ===============================
617
  # PUBLIC API
618
  # ===============================
@@ -680,29 +843,156 @@ def classify_grounding(answer: str, docs: List[Document]) -> str:
680
  return "supported"
681
 
682
 
683
- def answer_question(question: str, answer_mode: str = "Strict Mode"):
684
- total_started_at = time.perf_counter()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
685
  if answer_mode not in ANSWER_MODE_INSTRUCTIONS:
686
- answer_mode = "Strict Mode"
 
687
 
688
- docs, diagnostics = retrieve_documents_with_diagnostics(question, answer_mode=answer_mode)
689
- llm = get_llm()
690
 
 
691
  context_str = format_documents_for_context(docs)
692
- prompt_text = PROMPT_TEMPLATE.format(
693
  context_str=context_str,
694
  question=question,
695
  answer_mode_instructions=ANSWER_MODE_INSTRUCTIONS[answer_mode],
696
  )
697
- response = llm.invoke(prompt_text)
698
- answer = getattr(response, "content", str(response))
699
- grounding_status = classify_grounding(answer, docs)
700
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
701
  diagnostics["total_latency"] = round(time.perf_counter() - total_started_at, 3)
702
  diagnostics["answer_mode"] = answer_mode
703
  diagnostics["llm_provider"] = LLM_PROVIDER
704
  diagnostics["llm_model"] = GROQ_MODEL if LLM_PROVIDER == "groq" else GEMINI_MODEL
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
705
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
706
  return {
707
  "answer": answer,
708
  "source_documents": docs,
@@ -711,6 +1001,94 @@ def answer_question(question: str, answer_mode: str = "Strict Mode"):
711
  }
712
 
713
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
714
  def search_archives(query: str):
715
  """
716
  Used by Search mode only.
 
7
  import urllib.error
8
  import urllib.request
9
  from functools import lru_cache
10
+ from typing import Any, Iterator, List
11
  from dotenv import load_dotenv
12
  from pinecone import Pinecone
13
 
 
256
 
257
 
258
  STOPWORDS = {
259
+ "the", "a", "an", "of", "in", "on", "at", "and", "to", "for", "with", "by",
260
+ "what", "who", "where", "when", "why", "how", "did", "does", "do", "is",
261
+ "are", "was", "were", "be", "been", "being", "about", "teach", "taught",
262
+ "said", "say", "says", "explain", "explained", "brother", "branham",
263
+ "william", "sermon", "sermons", "message", "messages", "summarize",
264
+ "summary", "list", "state", "meaning", "events", "relationship",
265
+ }
266
+ GENERIC_TITLE_TOKENS = {
267
+ "know", "believe", "faith", "love", "life", "way", "truth", "word",
268
+ "god", "lord", "jesus", "christ", "christian", "church", "message",
269
  }
270
 
271
 
 
310
  return title_tokens.issubset(query_tokens)
311
 
312
 
313
+ def explicit_title_lookup(query: str) -> bool:
314
+ query_clean = normalize_text(query)
315
+ if extract_query_sermon_code(query):
316
+ return True
317
+ title_lookup_phrases = {
318
+ "sermon called",
319
+ "message called",
320
+ "sermon titled",
321
+ "message titled",
322
+ "sermon named",
323
+ "message named",
324
+ "the sermon",
325
+ "the message",
326
+ "summarize",
327
+ "summary of",
328
+ }
329
+ return any(phrase in query_clean for phrase in title_lookup_phrases)
330
+
331
+
332
+ def safe_sermon_title_matches(user_query: str, filename: str) -> bool:
333
+ title_tokens = tokenize_meaningful(extract_sermon_title(filename))
334
+ if not title_tokens:
335
+ return False
336
+ if len(title_tokens) == 1 and next(iter(title_tokens)) in GENERIC_TITLE_TOKENS:
337
+ return False
338
+ if len(title_tokens) < 2 and not explicit_title_lookup(user_query):
339
+ return False
340
+ return sermon_title_matches(user_query, filename)
341
+
342
+
343
  def detect_single_seal_title(query: str) -> str | None:
344
  query_clean = normalize_text(query)
345
  for alias, title in SINGLE_SEAL_ALIASES.items():
 
429
  selected.extend(rank_by_query_terms(other_docs, query)[:remaining])
430
  return selected[:max_docs]
431
 
432
+
433
+ def query_support_terms(query: str) -> set[str]:
434
+ terms = query_entity_tokens(query)
435
+ return {
436
+ term for term in terms
437
+ if term not in STOPWORDS and not re.fullmatch(r"\d+", term)
438
+ }
439
+
440
+
441
+ def evaluate_retrieval_confidence(
442
+ query: str,
443
+ docs: List[Document],
444
+ diagnostics: dict[str, Any],
445
+ ) -> dict[str, Any]:
446
+ terms = query_support_terms(query)
447
+ context = normalize_text(" ".join(doc.page_content for doc in docs))
448
+ supported_terms = sorted(term for term in terms if term in context)
449
+ missing_terms = sorted(terms - set(supported_terms))
450
+ coverage = len(supported_terms) / len(terms) if terms else 1.0
451
+
452
+ confidence = "supported"
453
+ reason = "priority_or_supported_terms"
454
+ should_refuse = False
455
+
456
+ if not docs:
457
+ confidence = "insufficient"
458
+ reason = "no_documents"
459
+ should_refuse = True
460
+ elif diagnostics.get("priority_count", 0) > 0:
461
+ confidence = "supported"
462
+ reason = "priority_match"
463
+ elif terms and missing_terms:
464
+ confidence = "insufficient"
465
+ reason = "missing_query_terms_without_priority_match"
466
+ should_refuse = True
467
+ elif diagnostics.get("source_count", 0) < 2:
468
+ confidence = "partial"
469
+ reason = "too_few_documents"
470
+
471
+ return {
472
+ "retrieval_confidence": confidence,
473
+ "confidence_reason": reason,
474
+ "should_refuse_generation": should_refuse,
475
+ "query_terms": sorted(terms),
476
+ "supported_query_terms": supported_terms,
477
+ "missing_query_terms": missing_terms,
478
+ "query_term_coverage": round(coverage, 3),
479
+ }
480
+
481
  # ===============================
482
  # RETRIEVER
483
  # ===============================
 
554
  target_code = extract_date_code(single_seal_title).upper()
555
  sermon_chunks = [
556
  d for d in chunks
557
+ if source_matches_title(d.metadata.get("source", ""), single_seal_title)
558
  ]
559
  if sermon_chunks:
560
  seal_query = SERIES_QUERY_HINTS.get(target_code, query)
 
580
  else:
581
  title_chunks = [
582
  d for d in chunks
583
+ if safe_sermon_title_matches(query, d.metadata.get("source", ""))
584
  ]
585
  if title_chunks:
586
  if answer_mode == "Summary Mode":
 
590
  for d in rank_by_query_terms(title_chunks, query)[:PRIORITY_K]:
591
  add_doc(d, "priority")
592
 
593
+ max_context_docs = CONTEXT_POLICY.get(answer_mode, CONTEXT_POLICY["Strict Mode"])["max_docs"]
594
+ if counts.get("priority", 0) < max_context_docs:
595
+ for d in get_bm25_retriever().invoke(query):
596
+ add_doc(d, "bm25")
597
+
598
+ pre_vector_docs = select_context_docs(results, query, answer_mode)
599
+ pre_vector_diagnostics = {
600
+ "source_count": len(pre_vector_docs),
601
+ "priority_count": sum(
602
+ 1 for d in pre_vector_docs if d.metadata.get("retrieval_origin") == "priority"
603
+ ),
604
+ }
605
+ pre_vector_confidence = evaluate_retrieval_confidence(
606
+ query,
607
+ pre_vector_docs,
608
+ pre_vector_diagnostics,
609
+ )
610
+
611
+ if counts.get("priority", 0) > 0:
612
+ pinecone_status = "skipped_priority_match"
613
+ elif pre_vector_confidence.get("should_refuse_generation"):
614
+ pinecone_status = "skipped_low_confidence"
615
+ else:
616
+ try:
617
+ vec_docs = get_vector_store().as_retriever(
618
+ search_kwargs={"k": VECTOR_K}
619
+ ).invoke(query)
620
+ pinecone_status = "ok"
621
+ for d in vec_docs:
622
+ add_doc(d, "pinecone")
623
+ except Exception as exc:
624
+ pinecone_status = "failed"
625
+ pinecone_error = str(exc)
626
+ logger.warning("Pinecone vector search failed: %s", exc)
627
 
628
  final_docs = select_context_docs(results, query, answer_mode)
629
  distinct_sermons = {
 
643
  "pinecone_error": pinecone_error,
644
  "context_policy": answer_mode,
645
  "candidate_count": len(results),
646
+ "max_context_docs": max_context_docs,
647
  }
648
+ diagnostics.update(evaluate_retrieval_confidence(query, final_docs, diagnostics))
649
 
650
  return final_docs, diagnostics
651
 
 
705
  ANSWER:
706
  """
707
 
708
+ CONVERSATIONAL_PROMPT_TEMPLATE = """
709
+ You are a careful research assistant helping one person understand William Marrion Branham's sermons.
710
+ Do not roleplay, impersonate, or speak as William Marrion Branham.
711
+ Always refer to him in the third person as "Brother Branham" or "William Branham."
712
+
713
+ RULES:
714
+ - Be faithful to the sermons provided.
715
+ - Use the conversation history only to understand what the user's latest question refers to.
716
+ - Answer only from the provided context. Do not use outside knowledge.
717
+ - If the context does not clearly support the answer, say: "The retrieved sermon excerpts do not give enough support to answer that safely."
718
+ - If the context is partial, say what is supported and what is not supported.
719
+ - Use a natural conversational tone, but do not preach or impersonate.
720
+ - Phrase claims as "Brother Branham said/taught/explained..." when the context supports them.
721
+ - Do not say "I testified," "my ministry," "my dear friend," or anything that makes the assistant sound like Brother Branham.
722
+ - Include source references for important claims using this format: [source, Para paragraph].
723
+ - Do not cite a source unless it appears in the provided context.
724
+ - Keep direct quotes short. Prefer paraphrase with source references.
725
+
726
+ ANSWER MODE:
727
+ {answer_mode_instructions}
728
+
729
+ CONVERSATION HISTORY:
730
+ {conversation_history}
731
+
732
+ CONTEXT:
733
+ {context_str}
734
+
735
+ LATEST QUESTION:
736
+ {question}
737
+
738
+ ANSWER:
739
+ """
740
+
741
+ QUESTION_REWRITE_PROMPT_TEMPLATE = """
742
+ Rewrite the user's latest question as a standalone search query for retrieving William Branham sermon excerpts.
743
+ Use the conversation history only to resolve references like "it", "that", "he", "those", "the first one", or "explain more".
744
+ Do not answer the question.
745
+ Do not add doctrine or facts not present in the conversation.
746
+ Return only the standalone question.
747
+
748
+ CONVERSATION HISTORY:
749
+ {conversation_history}
750
+
751
+ LATEST QUESTION:
752
+ {question}
753
+
754
+ STANDALONE QUESTION:
755
+ """
756
+
757
  PROMPT = PromptTemplate(
758
  template=PROMPT_TEMPLATE,
759
  input_variables=["context_str", "question"],
 
771
  input_variables=["page_content", "source", "paragraph"],
772
  )
773
 
774
+ QUESTION_REWRITE_PROMPT = PromptTemplate(
775
+ template=QUESTION_REWRITE_PROMPT_TEMPLATE,
776
+ input_variables=["conversation_history", "question"],
777
+ )
778
+
779
  # ===============================
780
  # PUBLIC API
781
  # ===============================
 
843
  return "supported"
844
 
845
 
846
+ def format_conversation_history(history: List[dict[str, str]], max_messages: int = 8) -> str:
847
+ rows = []
848
+ for item in history[-max_messages:]:
849
+ role = item.get("role", "").strip().lower()
850
+ content = " ".join(item.get("content", "").split())
851
+ if role not in {"user", "assistant"} or not content:
852
+ continue
853
+ if len(content) > 900:
854
+ content = content[:900].rstrip() + "..."
855
+ rows.append(f"{role.upper()}: {content}")
856
+ return "\n".join(rows) if rows else "No prior conversation."
857
+
858
+
859
+ def rewrite_conversational_question(question: str, history: List[dict[str, str]]) -> str:
860
+ if not history:
861
+ return question
862
+ prompt_text = QUESTION_REWRITE_PROMPT.format(
863
+ conversation_history=format_conversation_history(history),
864
+ question=question,
865
+ )
866
+ response = get_llm().invoke(prompt_text)
867
+ rewritten = getattr(response, "content", str(response)).strip()
868
+ return rewritten or question
869
+
870
+
871
+ def normalize_answer_mode(answer_mode: str) -> str:
872
  if answer_mode not in ANSWER_MODE_INSTRUCTIONS:
873
+ return "Strict Mode"
874
+ return answer_mode
875
 
 
 
876
 
877
+ def build_answer_prompt(question: str, answer_mode: str, docs: List[Document]) -> str:
878
  context_str = format_documents_for_context(docs)
879
+ return PROMPT_TEMPLATE.format(
880
  context_str=context_str,
881
  question=question,
882
  answer_mode_instructions=ANSWER_MODE_INSTRUCTIONS[answer_mode],
883
  )
 
 
 
884
 
885
+
886
+ def build_conversational_answer_prompt(
887
+ question: str,
888
+ retrieval_question: str,
889
+ answer_mode: str,
890
+ docs: List[Document],
891
+ history: List[dict[str, str]],
892
+ ) -> str:
893
+ context_str = format_documents_for_context(docs)
894
+ latest_question = question
895
+ if retrieval_question != question:
896
+ latest_question = f"{question}\n\nStandalone retrieval question: {retrieval_question}"
897
+ return CONVERSATIONAL_PROMPT_TEMPLATE.format(
898
+ context_str=context_str,
899
+ conversation_history=format_conversation_history(history),
900
+ question=latest_question,
901
+ answer_mode_instructions=ANSWER_MODE_INSTRUCTIONS[answer_mode],
902
+ )
903
+
904
+
905
+ def add_generation_diagnostics(
906
+ diagnostics: dict[str, Any],
907
+ total_started_at: float,
908
+ answer_mode: str,
909
+ ) -> dict[str, Any]:
910
  diagnostics["total_latency"] = round(time.perf_counter() - total_started_at, 3)
911
  diagnostics["answer_mode"] = answer_mode
912
  diagnostics["llm_provider"] = LLM_PROVIDER
913
  diagnostics["llm_model"] = GROQ_MODEL if LLM_PROVIDER == "groq" else GEMINI_MODEL
914
+ return diagnostics
915
+
916
+
917
+ def build_refusal_result(
918
+ diagnostics: dict[str, Any],
919
+ total_started_at: float,
920
+ answer_mode: str,
921
+ ) -> dict[str, Any]:
922
+ diagnostics["retrieved_source_count"] = diagnostics.get("source_count", 0)
923
+ diagnostics["retrieved_distinct_sermon_count"] = diagnostics.get("distinct_sermon_count", 0)
924
+ diagnostics["source_count"] = 0
925
+ diagnostics["distinct_sermon_count"] = 0
926
+ diagnostics["sermons"] = []
927
+ add_generation_diagnostics(diagnostics, total_started_at, answer_mode)
928
+ return {
929
+ "answer": UNSUPPORTED_ANSWER,
930
+ "source_documents": [],
931
+ "grounding_status": "insufficient",
932
+ "diagnostics": diagnostics,
933
+ }
934
+
935
+
936
+ def prepare_answer_context(
937
+ question: str,
938
+ answer_mode: str = "Strict Mode",
939
+ history: List[dict[str, str]] | None = None,
940
+ conversational: bool = False,
941
+ ) -> dict[str, Any]:
942
+ total_started_at = time.perf_counter()
943
+ answer_mode = normalize_answer_mode(answer_mode)
944
+ history = history or []
945
+ retrieval_question = (
946
+ rewrite_conversational_question(question, history)
947
+ if conversational and history
948
+ else question
949
+ )
950
+ docs, diagnostics = retrieve_documents_with_diagnostics(
951
+ retrieval_question,
952
+ answer_mode=answer_mode,
953
+ )
954
+ diagnostics["retrieval_question"] = retrieval_question
955
+ diagnostics["conversation_mode"] = conversational
956
+
957
+ if diagnostics.get("should_refuse_generation"):
958
+ return {
959
+ "answer_mode": answer_mode,
960
+ "docs": [],
961
+ "diagnostics": diagnostics,
962
+ "prompt_text": "",
963
+ "total_started_at": total_started_at,
964
+ "refusal_result": build_refusal_result(diagnostics, total_started_at, answer_mode),
965
+ }
966
 
967
+ return {
968
+ "answer_mode": answer_mode,
969
+ "docs": docs,
970
+ "diagnostics": diagnostics,
971
+ "prompt_text": (
972
+ build_conversational_answer_prompt(
973
+ question,
974
+ retrieval_question,
975
+ answer_mode,
976
+ docs,
977
+ history,
978
+ )
979
+ if conversational
980
+ else build_answer_prompt(question, answer_mode, docs)
981
+ ),
982
+ "total_started_at": total_started_at,
983
+ "refusal_result": None,
984
+ }
985
+
986
+
987
+ def finalize_answer_result(
988
+ answer: str,
989
+ docs: List[Document],
990
+ diagnostics: dict[str, Any],
991
+ total_started_at: float,
992
+ answer_mode: str,
993
+ ) -> dict[str, Any]:
994
+ grounding_status = classify_grounding(answer, docs)
995
+ add_generation_diagnostics(diagnostics, total_started_at, answer_mode)
996
  return {
997
  "answer": answer,
998
  "source_documents": docs,
 
1001
  }
1002
 
1003
 
1004
+ def answer_question(
1005
+ question: str,
1006
+ answer_mode: str = "Strict Mode",
1007
+ history: List[dict[str, str]] | None = None,
1008
+ conversational: bool = False,
1009
+ ):
1010
+ prepared = prepare_answer_context(
1011
+ question,
1012
+ answer_mode,
1013
+ history,
1014
+ conversational,
1015
+ )
1016
+ if prepared["refusal_result"] is not None:
1017
+ return prepared["refusal_result"]
1018
+
1019
+ llm = get_llm()
1020
+ response = llm.invoke(prepared["prompt_text"])
1021
+ answer = getattr(response, "content", str(response))
1022
+ return finalize_answer_result(
1023
+ answer,
1024
+ prepared["docs"],
1025
+ prepared["diagnostics"],
1026
+ prepared["total_started_at"],
1027
+ prepared["answer_mode"],
1028
+ )
1029
+
1030
+
1031
+ def stream_answer_tokens(prompt_text: str) -> Iterator[str]:
1032
+ for chunk in get_llm().stream(prompt_text):
1033
+ text = getattr(chunk, "content", "")
1034
+ if isinstance(text, list):
1035
+ text = "".join(str(part) for part in text)
1036
+ if text:
1037
+ yield str(text)
1038
+
1039
+
1040
+ def stream_answer_question(
1041
+ question: str,
1042
+ answer_mode: str = "Strict Mode",
1043
+ history: List[dict[str, str]] | None = None,
1044
+ conversational: bool = False,
1045
+ ) -> Iterator[dict[str, Any]]:
1046
+ prepared = prepare_answer_context(
1047
+ question,
1048
+ answer_mode,
1049
+ history,
1050
+ conversational,
1051
+ )
1052
+ if prepared["refusal_result"] is not None:
1053
+ result = prepared["refusal_result"]
1054
+ yield {
1055
+ "event": "metadata",
1056
+ "data": {
1057
+ "sources": result["source_documents"],
1058
+ "diagnostics": result["diagnostics"],
1059
+ "grounding_status": result["grounding_status"],
1060
+ "answer_mode": prepared["answer_mode"],
1061
+ },
1062
+ }
1063
+ yield {"event": "token", "data": {"text": result["answer"]}}
1064
+ yield {"event": "done", "data": result}
1065
+ return
1066
+
1067
+ parts = []
1068
+ yield {
1069
+ "event": "metadata",
1070
+ "data": {
1071
+ "sources": prepared["docs"],
1072
+ "diagnostics": prepared["diagnostics"],
1073
+ "grounding_status": "pending",
1074
+ "answer_mode": prepared["answer_mode"],
1075
+ },
1076
+ }
1077
+ for token in stream_answer_tokens(prepared["prompt_text"]):
1078
+ parts.append(token)
1079
+ yield {"event": "token", "data": {"text": token}}
1080
+
1081
+ answer = "".join(parts)
1082
+ result = finalize_answer_result(
1083
+ answer,
1084
+ prepared["docs"],
1085
+ prepared["diagnostics"],
1086
+ prepared["total_started_at"],
1087
+ prepared["answer_mode"],
1088
+ )
1089
+ yield {"event": "done", "data": result}
1090
+
1091
+
1092
  def search_archives(query: str):
1093
  """
1094
  Used by Search mode only.
src/server.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hmac
2
+ import json
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Any, Iterable
6
+
7
+ from fastapi import FastAPI, HTTPException, Query, Request
8
+ from fastapi.middleware.cors import CORSMiddleware
9
+ from fastapi.responses import FileResponse, StreamingResponse
10
+ from fastapi.staticfiles import StaticFiles
11
+ from pydantic import BaseModel, Field
12
+
13
+ from .app import answer_question, search_archives, stream_answer_question
14
+
15
+
16
+ BASE_DIR = Path(__file__).resolve().parent
17
+ WEB_DIR = BASE_DIR / "web"
18
+ ANSWER_MODES = {"Strict Mode", "Study Mode", "Summary Mode"}
19
+ APP_PASSWORD = os.getenv("APP_PASSWORD", "")
20
+
21
+ api = FastAPI(title="The 7th Handle API", version="1.0.0")
22
+
23
+ api.add_middleware(
24
+ CORSMiddleware,
25
+ allow_origins=["*"],
26
+ allow_credentials=False,
27
+ allow_methods=["GET", "POST"],
28
+ allow_headers=["*"],
29
+ )
30
+
31
+
32
+ class AnswerRequest(BaseModel):
33
+ question: str = Field(..., min_length=1, max_length=4000)
34
+ answer_mode: str = Field("Strict Mode")
35
+ sermon_scope: str | None = Field(None, max_length=20)
36
+ conversation_mode: bool = Field(False)
37
+ history: list[dict[str, str]] = Field(default_factory=list)
38
+
39
+
40
+ def require_password(request: Request) -> None:
41
+ if not APP_PASSWORD:
42
+ return
43
+ provided = request.headers.get("X-App-Password", "")
44
+ if not hmac.compare_digest(provided, APP_PASSWORD):
45
+ raise HTTPException(status_code=401, detail="Invalid app password.")
46
+
47
+
48
+ def normalize_mode(answer_mode: str) -> str:
49
+ return answer_mode if answer_mode in ANSWER_MODES else "Strict Mode"
50
+
51
+
52
+ def sanitize_history(history: list[dict[str, str]]) -> list[dict[str, str]]:
53
+ clean = []
54
+ for item in history[-8:]:
55
+ role = str(item.get("role", "")).strip().lower()
56
+ content = " ".join(str(item.get("content", "")).split())
57
+ if role not in {"user", "assistant"} or not content:
58
+ continue
59
+ clean.append({"role": role, "content": content[:1200]})
60
+ return clean
61
+
62
+
63
+ def messagehub_link(filename: str) -> str:
64
+ if not filename:
65
+ return "#"
66
+ code = filename.replace(".pdf", "").replace(".PDF", "").strip().split()[0]
67
+ return f"https://www.messagehub.info/en/read.do?ref_num={code}"
68
+
69
+
70
+ def serialize_doc(doc) -> dict[str, Any]:
71
+ source = doc.metadata.get("source", "")
72
+ paragraph_text = doc.page_content or ""
73
+ return {
74
+ "source": source,
75
+ "paragraph": doc.metadata.get("paragraph", ""),
76
+ "content": paragraph_text,
77
+ "full_text": paragraph_text,
78
+ "retrieval_origin": doc.metadata.get("retrieval_origin", "unknown"),
79
+ "retrieval_rank": doc.metadata.get("retrieval_rank", ""),
80
+ "messagehub_url": messagehub_link(source),
81
+ }
82
+
83
+
84
+ def serialize_result(result: dict[str, Any], question: str, answer_mode: str) -> dict[str, Any]:
85
+ sources = [serialize_doc(doc) for doc in result.get("source_documents", [])]
86
+ return {
87
+ "answer": result.get("answer", ""),
88
+ "sources": sources,
89
+ "grounding_status": result.get("grounding_status", "unknown"),
90
+ "diagnostics": result.get("diagnostics", {}),
91
+ "question": question,
92
+ "answer_mode": answer_mode,
93
+ }
94
+
95
+
96
+ def serialize_stream_data(data: dict[str, Any], question: str, answer_mode: str) -> dict[str, Any]:
97
+ payload = dict(data)
98
+ if "source_documents" in payload:
99
+ payload["sources"] = [serialize_doc(doc) for doc in payload.pop("source_documents")]
100
+ if "sources" in payload:
101
+ payload["sources"] = [
102
+ serialize_doc(doc) if hasattr(doc, "metadata") else doc
103
+ for doc in payload["sources"]
104
+ ]
105
+ payload.setdefault("question", question)
106
+ payload.setdefault("answer_mode", answer_mode)
107
+ return payload
108
+
109
+
110
+ def apply_sermon_scope(question: str, sermon_scope: str | None) -> str:
111
+ scope = (sermon_scope or "").strip()
112
+ if not scope:
113
+ return question.strip()
114
+ if scope.upper() in question.upper():
115
+ return question.strip()
116
+ return f"{scope} {question.strip()}"
117
+
118
+
119
+ def sse(event: str, data: dict[str, Any]) -> str:
120
+ return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=True)}\n\n"
121
+
122
+
123
+ @api.get("/api/health")
124
+ def health() -> dict[str, Any]:
125
+ return {"status": "ok", "password_required": bool(APP_PASSWORD)}
126
+
127
+
128
+ @api.post("/api/answer")
129
+ def answer(request: Request, payload: AnswerRequest) -> dict[str, Any]:
130
+ require_password(request)
131
+ mode = normalize_mode(payload.answer_mode)
132
+ question = apply_sermon_scope(payload.question, payload.sermon_scope)
133
+ try:
134
+ result = answer_question(
135
+ question,
136
+ answer_mode=mode,
137
+ history=sanitize_history(payload.history),
138
+ conversational=payload.conversation_mode,
139
+ )
140
+ except ValueError as exc:
141
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
142
+ return serialize_result(result, question, mode)
143
+
144
+
145
+ @api.post("/api/answer/stream")
146
+ def answer_stream(request: Request, payload: AnswerRequest) -> StreamingResponse:
147
+ require_password(request)
148
+ mode = normalize_mode(payload.answer_mode)
149
+ question = apply_sermon_scope(payload.question, payload.sermon_scope)
150
+ history = sanitize_history(payload.history)
151
+
152
+ def events() -> Iterable[str]:
153
+ try:
154
+ for event in stream_answer_question(
155
+ question,
156
+ answer_mode=mode,
157
+ history=history,
158
+ conversational=payload.conversation_mode,
159
+ ):
160
+ yield sse(
161
+ event["event"],
162
+ serialize_stream_data(event["data"], question, mode),
163
+ )
164
+ except Exception as exc:
165
+ yield sse("error", {"detail": str(exc), "question": question, "answer_mode": mode})
166
+
167
+ return StreamingResponse(events(), media_type="text/event-stream")
168
+
169
+
170
+ @api.get("/api/search")
171
+ def search(
172
+ request: Request,
173
+ q: str = Query(..., min_length=1, max_length=4000),
174
+ ) -> dict[str, Any]:
175
+ require_password(request)
176
+ docs, debug_log = search_archives(q)
177
+ return {
178
+ "query": q,
179
+ "results": [serialize_doc(doc) for doc in docs],
180
+ "debug_log": debug_log,
181
+ }
182
+
183
+
184
+ if (WEB_DIR / "assets").exists():
185
+ api.mount("/assets", StaticFiles(directory=WEB_DIR / "assets"), name="assets")
186
+
187
+
188
+ @api.get("/")
189
+ def index() -> FileResponse:
190
+ return FileResponse(WEB_DIR / "index.html")
191
+
192
+
193
+ @api.get("/{path:path}")
194
+ def spa_fallback(path: str) -> FileResponse:
195
+ if path.startswith("api/"):
196
+ raise HTTPException(status_code=404, detail="Not found")
197
+ return FileResponse(WEB_DIR / "index.html")
src/streamlit_app.py DELETED
@@ -1,331 +0,0 @@
1
- from collections import defaultdict
2
- import json
3
- import os
4
- import time
5
-
6
- import streamlit as st
7
-
8
-
9
- st.set_page_config(
10
- page_title="Voice of the Sign",
11
- page_icon="🦅",
12
- layout="wide",
13
- initial_sidebar_state="expanded",
14
- )
15
-
16
-
17
- backend_loaded = False
18
- try:
19
- from app import answer_question, search_archives
20
-
21
- backend_loaded = True
22
- except Exception as e:
23
- st.error(f"Backend failed to load:\n\n{e}")
24
-
25
-
26
- def messagehub_link(filename: str):
27
- if not filename:
28
- return "#"
29
-
30
- name = filename.replace(".pdf", "").replace(".PDF", "").strip()
31
- code = name.split()[0]
32
- return f"https://www.messagehub.info/en/read.do?ref_num={code}"
33
-
34
-
35
- def excerpt(text: str, length: int = 320) -> str:
36
- clean = " ".join((text or "").split())
37
- if len(clean) <= length:
38
- return clean
39
- return clean[: length - 1].rstrip() + "..."
40
-
41
-
42
- def grouped_sources(docs):
43
- groups = defaultdict(list)
44
- for doc in docs or []:
45
- groups[doc.metadata.get("source", "Unknown source")].append(doc)
46
- return groups
47
-
48
-
49
- def render_grounding(status: str, diagnostics: dict):
50
- labels = {
51
- "supported": "Supported",
52
- "partial": "Partial",
53
- "insufficient": "Insufficient",
54
- }
55
- status_label = labels.get(status, "Unknown")
56
-
57
- if status == "supported":
58
- st.success(f"Grounding: {status_label}")
59
- elif status == "partial":
60
- st.warning(f"Grounding: {status_label}")
61
- else:
62
- st.error(f"Grounding: {status_label}")
63
-
64
- col1, col2, col3, col4, col5 = st.columns(5)
65
- col1.metric("Excerpts", diagnostics.get("source_count", 0))
66
- col2.metric("Sermons", diagnostics.get("distinct_sermon_count", 0))
67
- col3.metric("Pinecone", diagnostics.get("pinecone_status", "unknown"))
68
- col4.metric("Latency", f"{diagnostics.get('total_latency', 0)}s")
69
- col5.metric("Model", diagnostics.get("llm_model", "unknown"))
70
-
71
-
72
- def render_references(docs):
73
- if not docs:
74
- st.info("No source excerpts were returned.")
75
- return
76
-
77
- for source, source_docs in grouped_sources(docs).items():
78
- with st.expander(f"{source} ({len(source_docs)} excerpts)", expanded=False):
79
- for doc in source_docs:
80
- para = doc.metadata.get("paragraph", "")
81
- origin = doc.metadata.get("retrieval_origin", "unknown")
82
- rank = doc.metadata.get("retrieval_rank", "")
83
- link = messagehub_link(source)
84
- st.markdown(f"[Para {para}]({link}) · origin: `{origin}` · rank: `{rank}`")
85
- st.caption(excerpt(doc.page_content))
86
- st.divider()
87
-
88
-
89
- def render_debug(docs, diagnostics):
90
- with st.expander("Advanced retrieval diagnostics", expanded=False):
91
- st.json(diagnostics)
92
- rows = []
93
- for doc in docs or []:
94
- rows.append(
95
- {
96
- "rank": doc.metadata.get("retrieval_rank", ""),
97
- "origin": doc.metadata.get("retrieval_origin", ""),
98
- "source": doc.metadata.get("source", ""),
99
- "paragraph": doc.metadata.get("paragraph", ""),
100
- "excerpt": excerpt(doc.page_content, 180),
101
- }
102
- )
103
- if rows:
104
- st.dataframe(rows, use_container_width=True, hide_index=True)
105
-
106
-
107
- def render_assistant_metadata(msg):
108
- diagnostics = msg.get("diagnostics", {})
109
- sources = msg.get("sources", [])
110
- grounding_status = msg.get("grounding_status", "unknown")
111
-
112
- render_grounding(grounding_status, diagnostics)
113
- render_references(sources)
114
- render_debug(sources, diagnostics)
115
-
116
-
117
- def append_user_message(content: str):
118
- st.session_state.chat_history.append(
119
- {
120
- "role": "user",
121
- "content": content,
122
- }
123
- )
124
-
125
-
126
- def append_assistant_message(response: dict, answer_mode: str):
127
- st.session_state.chat_history.append(
128
- {
129
- "role": "assistant",
130
- "content": response.get("answer", ""),
131
- "sources": response.get("source_documents", []),
132
- "diagnostics": response.get("diagnostics", {}),
133
- "grounding_status": response.get("grounding_status", "unknown"),
134
- "answer_mode": answer_mode,
135
- }
136
- )
137
-
138
-
139
- def log_feedback(msg: dict, rating: str):
140
- feedback_file = "feedback_log.json"
141
- log_entry = {
142
- "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
143
- "answer": msg.get("content", ""),
144
- "sources": [
145
- {
146
- "source": doc.metadata.get("source", ""),
147
- "paragraph": doc.metadata.get("paragraph", ""),
148
- "content": doc.page_content[:300] + "..."
149
- }
150
- for doc in msg.get("sources", [])
151
- ],
152
- "diagnostics": msg.get("diagnostics", {}),
153
- "grounding_status": msg.get("grounding_status", "unknown"),
154
- "rating": rating
155
- }
156
-
157
- logs = []
158
- if os.path.exists(feedback_file):
159
- try:
160
- with open(feedback_file, "r", encoding="utf-8") as f:
161
- logs = json.load(f)
162
- except Exception:
163
- pass
164
-
165
- logs.append(log_entry)
166
- with open(feedback_file, "w", encoding="utf-8") as f:
167
- json.dump(logs, f, indent=4)
168
-
169
-
170
- st.markdown(
171
- """
172
- <style>
173
- html, body, [class*="css"] {
174
- font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
175
- }
176
-
177
- div[data-testid="stChatMessage"][data-test-role="assistant"] {
178
- border-left: 4px solid #D4AF37;
179
- background-color: rgba(212,175,55,0.045);
180
- }
181
-
182
- .safety-note {
183
- padding: 12px 14px;
184
- border-left: 4px solid #D4AF37;
185
- background: rgba(212,175,55,0.07);
186
- border-radius: 6px;
187
- font-size: 0.92rem;
188
- margin-bottom: 12px;
189
- }
190
-
191
- .topic-row button {
192
- min-height: 2.5rem;
193
- }
194
-
195
- #MainMenu {visibility: hidden;}
196
- header {visibility: visible;}
197
- </style>
198
- """,
199
- unsafe_allow_html=True,
200
- )
201
-
202
-
203
- if "chat_history" not in st.session_state:
204
- st.session_state.chat_history = []
205
- if "pending_prompt" not in st.session_state:
206
- st.session_state.pending_prompt = None
207
-
208
-
209
- with st.sidebar:
210
- st.title("Controls")
211
- mode = st.radio(
212
- "Mode",
213
- ["Chat with The Message", "Search The Word"],
214
- index=0,
215
- label_visibility="collapsed",
216
- )
217
-
218
- st.divider()
219
-
220
- answer_mode = st.selectbox(
221
- "Answer mode",
222
- ["Strict Mode", "Study Mode", "Summary Mode"],
223
- index=0,
224
- help=(
225
- "Strict sends the best 8 excerpts. Study sends up to 14. "
226
- "Summary sends up to 20 and spreads sermon summaries across the source."
227
- ),
228
- )
229
-
230
- sermon_scope = st.text_input(
231
- "Search within sermon code",
232
- placeholder="Example: 63-0324E",
233
- help="Optional. Adds a sermon code to the question for targeted retrieval.",
234
- ).strip()
235
-
236
- st.divider()
237
- st.caption("Quick topics")
238
- topic_prompts = {
239
- "Seven Seals": "what are the seven seals? list each seal and state its events and meaning",
240
- "Seven Thunders": "what are the seven thunders?",
241
- "Serpent's Seed": "What is the relationship between the Serpent's Seed and Cain?",
242
- "Adoption": "Summarize the Adoption series.",
243
- "Demonology": "Summarize the Demonology series.",
244
- "Church Ages": "Summarize the seven church ages.",
245
- }
246
- for label, prompt_text in topic_prompts.items():
247
- if st.button(label, use_container_width=True):
248
- st.session_state.pending_prompt = prompt_text
249
- st.rerun()
250
-
251
- st.divider()
252
- if st.button("Clear Screen", use_container_width=True):
253
- st.session_state.chat_history = []
254
- st.session_state.pending_prompt = None
255
- st.rerun()
256
-
257
-
258
- st.title("The 7th Handle" if mode == "Chat with The Message" else "The Table")
259
- st.divider()
260
-
261
- if not backend_loaded:
262
- st.stop()
263
-
264
-
265
- if mode == "Chat with The Message":
266
- for msg in st.session_state.chat_history:
267
- with st.chat_message(msg["role"], avatar="👤" if msg["role"] == "user" else "🦅"):
268
- st.markdown(msg["content"], unsafe_allow_html=False)
269
- if msg["role"] == "assistant":
270
- render_assistant_metadata(msg)
271
-
272
- # Render thumbs up / thumbs down feedback buttons for evaluating the response
273
- msg_idx = st.session_state.chat_history.index(msg)
274
- if f"feedback_{msg_idx}" not in st.session_state:
275
- st.session_state[f"feedback_{msg_idx}"] = None
276
-
277
- col1, col2, _ = st.columns([1, 1, 15])
278
- with col1:
279
- if st.button("👍", key=f"thumbs_up_{msg_idx}", help="Accurate, helpful, and properly grounded"):
280
- st.session_state[f"feedback_{msg_idx}"] = "thumbs_up"
281
- log_feedback(msg, "thumbs_up")
282
- st.toast("Response marked as helpful! Thanks.", icon="👍")
283
- with col2:
284
- if st.button("👎", key=f"thumbs_down_{msg_idx}", help="Inaccurate or poorly grounded"):
285
- st.session_state[f"feedback_{msg_idx}"] = "thumbs_down"
286
- log_feedback(msg, "thumbs_down")
287
- st.toast("Issue logged. We will review the grounding!", icon="👎")
288
-
289
- st.markdown(
290
- """
291
- <div class="safety-note">
292
- Answers are generated from retrieved sermon excerpts. Verify doctrinal conclusions against the cited source text.
293
- </div>
294
- """,
295
- unsafe_allow_html=True,
296
- )
297
-
298
- prompt = st.session_state.pending_prompt
299
- st.session_state.pending_prompt = None
300
- typed_prompt = st.chat_input("Ask a question, or use a quick topic from the sidebar.")
301
- if typed_prompt:
302
- prompt = typed_prompt
303
-
304
- if prompt:
305
- final_prompt = prompt
306
- if sermon_scope and sermon_scope.upper() not in final_prompt.upper():
307
- final_prompt = f"{sermon_scope} {final_prompt}"
308
-
309
- append_user_message(final_prompt)
310
- with st.spinner("Retrieving sermon excerpts and checking grounding..."):
311
- response = answer_question(final_prompt, answer_mode=answer_mode)
312
- append_assistant_message(response, answer_mode)
313
- st.rerun()
314
-
315
- else:
316
- query = st.chat_input("Search for a keyword or phrase...")
317
-
318
- if query:
319
- st.subheader(f"Results for: {query}")
320
-
321
- with st.spinner("Scanning archives..."):
322
- docs, debug_log = search_archives(query)
323
-
324
- with st.expander("Search diagnostics", expanded=False):
325
- for line in debug_log:
326
- st.write(line)
327
-
328
- if not docs:
329
- st.info("No exact records found.")
330
- else:
331
- render_references(docs)