Shouvik599 commited on
Commit
56da115
Β·
1 Parent(s): 3611fcd

Added the multi turn conversation feature

Browse files
Files changed (4) hide show
  1. README.md +314 -41
  2. app.py +127 -42
  3. frontend/index.html +302 -437
  4. rag_chain.py +178 -198
README.md CHANGED
@@ -1,3 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Sacred Texts RAG
3
  emoji: πŸ•ŠοΈ
@@ -7,52 +194,52 @@ sdk: docker
7
  app_port: 7860
8
  pinned: false
9
  ---
10
-
11
  # πŸ•ŠοΈ Sacred Texts RAG β€” Multi-Religion Knowledge Base
12
-
13
- A Retrieval-Augmented Generation (RAG) application that answers spiritual queries using Bhagavad Gita, Quran, Bible and the Guru Granth Sahib as the sole knowledge sources.
14
-
15
  ---
16
-
17
  ## πŸ“ Project Structure
18
-
19
  ```
20
  sacred-texts-rag/
21
  β”œβ”€β”€ README.md
22
  β”œβ”€β”€ requirements.txt
23
  β”œβ”€β”€ .env.example
24
  β”œβ”€β”€ ingest.py # Step 1: Load PDFs β†’ chunk β†’ embed β†’ store
25
- β”œβ”€β”€ rag_chain.py # Core RAG chain logic
26
  β”œβ”€β”€ app.py # FastAPI backend server
27
  └── frontend/
28
- └── index.html # Chat UI (open in browser)
29
  ```
30
-
31
  ---
32
-
33
  ## βš™οΈ Setup Instructions
34
-
35
  ### 1. Install Dependencies
36
  ```bash
37
  pip install -r requirements.txt
38
  ```
39
-
40
  ### 2. Configure Environment
41
  ```bash
42
  cp .env.example .env
43
  # Edit .env and add your NVIDIA_API_KEY
44
  ```
45
-
46
  ### 3. Add Your PDF Books
47
  Place your PDF files in a `books/` folder:
48
  ```
49
  books/
50
  β”œβ”€β”€ bhagavad_gita.pdf
51
  β”œβ”€β”€ quran.pdf
52
- └── bible.pdf
53
  └── guru_granth_sahib.pdf
54
  ```
55
-
56
  ### 4. Ingest the Books (Run Once)
57
  ```bash
58
  python ingest.py
@@ -62,58 +249,144 @@ This will:
62
  - Split into semantic chunks
63
  - Create embeddings using NVIDIA's `llama-nemotron-embed-vl-1b-v2` model
64
  - Store in a local ChromaDB vector store (`./chroma_db/`)
65
-
66
  ### 5. Start the Backend
67
  ```bash
68
  python app.py
69
  ```
70
- Server runs at: `http://localhost:8000`
71
-
72
  ### 6. Open the Frontend
73
- Open `frontend/index.html` in your browser β€” no server needed for the UI.
74
-
75
  ---
76
-
77
  ## πŸ”‘ Environment Variables
78
-
79
- | Variable | Description |
80
- |---|---|
81
- | `NVIDIA_API_KEY` | Your NVIDIA API key |
82
- | `CHROMA_DB_PATH` | Path to ChromaDB storage (default: `./chroma_db`) |
83
- | `CHUNKS_PER_BOOK` | Number of chunks to retrieve per query (default: `3`) |
84
-
 
 
 
 
85
  ---
86
-
87
  ## 🧠 How It Works
88
-
89
  ```
90
  User Query
91
  β”‚
92
  β–Ό
93
- [Embedding Model] ←── NVIDIA llama-nemotron-embed-vl-1b-v2
 
 
 
 
 
 
94
  β”‚
95
  β–Ό
96
- [ChromaDB Vector Store] ←── Semantic similarity search
97
- β”‚ (retrieves top-K chunks from Gita, Quran, Bible, and the Guru Granth Sahib)
98
  β”‚
99
  β–Ό
100
- [Prompt with Context]
 
 
 
101
  β”‚
102
  β–Ό
103
  [Llama-3.3-70b-instruct] ←── Answer grounded ONLY in retrieved texts
104
  β”‚
105
  β–Ό
106
- Response with source citations (book + chapter/verse)
107
  ```
108
-
109
  ---
110
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  ## πŸ“ Notes
112
-
113
  - The LLM is instructed **never** to answer from outside the provided texts
114
- - Each response includes **source citations** (which book the answer came from)
115
  - Responses synthesize wisdom **across all books** when relevant
116
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  ## 🎬 Demo
 
 
 
118
 
119
- App Link : https://shouvik99-lifeguide.hf.space/
 
1
+
2
+ Can you connect to my github account i have added as a connector
3
+
4
+ 12:50 AM
5
+ Connector search
6
+ 1 connector
7
+ Microsoft Learn
8
+ It looks like GitHub isn't showing up as a connected or available connector in the registry right now. A few things to check:
9
+
10
+ Make sure it's enabled in the Tools menu β€” Click the tools/connector icon in the chat interface and confirm GitHub is toggled on for this conversation.
11
+ Re-authenticate β€” Sometimes connectors need to be reconnected. Go to your connector settings and try reconnecting your GitHub account.
12
+ Check the connector was added to the right workspace β€” If you added it under a different account or session, it may not carry over.
13
+ Once it's connected and enabled, I'll be able to interact with your GitHub repos, issues, PRs, and more. Let me know if you run into any trouble setting it up!
14
+
15
+ Shouvik599/LifeGuide
16
+ main
17
+
18
+ GITHUB
19
+
20
+ from the mentioned list of features to add, choose and and work on it
21
+
22
+ 12:51 AM
23
+ Looking at your features_to_add.txt, let me pick the best candidate based on impact vs. implementation effort for your current stack.
24
+
25
+ The strongest choice is Multi-turn conversation β€” it directly improves UX, fits naturally into your existing streaming architecture, and the LangChain ConversationBufferMemory pattern slots cleanly into rag_chain.py without touching ingestion or the vector store.
26
+
27
+ Let me also check what's in your features list more carefully before diving in.
28
+
29
+ The features listed are:
30
+
31
+ Contextual chunk expansion
32
+ HyDE (Hypothetical Document Embedding)
33
+ Multi-turn conversation ← best ROI right now
34
+ Answer faithfulness scoring
35
+ Query rewriting
36
+ Multi-language support
37
+ Incremental ingestion
38
+ Book versioning
39
+ Snippet preview on hover
40
+ Query suggestions
41
+ Topic explorer
42
+ Compare mode
43
+ Hallucination guardrail
44
+ Out-of-scope detection
45
+ Rate limiting
46
+ API key security
47
+ I'll implement Multi-turn conversation β€” here's what changes:
48
+
49
+ rag_chain.py β€” session-based ConversationBufferMemory, history injected into the prompt
50
+ app.py β€” session ID via header/cookie, memory store per session
51
+ frontend/index.html β€” passes session_id, shows conversation continuity
52
+ Let me build it:
53
+
54
+
55
+ Check skills available
56
+ Check skills available
57
+ Now let me write the updated files. Starting with rag_chain.py:
58
+
59
+
60
+ Created 2 files
61
+ Created 2 files
62
+ Now the frontend β€” this is the most involved change. Adding session persistence, a "New Conversation" button, and follow-up UX cues:
63
+
64
+
65
+ Created a file, ran a command, read a file
66
+ Created a file, ran a command, read a file
67
+
68
+ Updated frontend index.html with multi-turn conversation support
69
+ index.html
70
+
71
+ Copy files to outputs
72
+
73
+ Script
74
+
75
+ Presented 3 files
76
+ Done
77
+ Here's everything that changed and why:
78
+
79
+ rag_chain.py
80
+
81
+ The core addition is an in-memory _session_store dict mapping session_id β†’ [HumanMessage, AIMessage, ...]. The prompt template now uses MessagesPlaceholder(variable_name="history") so prior turns get injected directly into the LLM's context. Two important nuances: for follow-up questions that are too short/vague (under 8 words like "elaborate on point 2"), the retrieval query is augmented with the previous human turn so ChromaDB can find relevant chunks. The semantic cache is also bypassed for follow-ups since the same question in a different context should retrieve fresh context. History is trimmed to the last 6 turns (configurable via MAX_HISTORY_TURNS in .env) to avoid context window bloat.
82
+
83
+ app.py
84
+
85
+ Three new endpoints: POST /clear to reset a session's history, GET /history for debugging/inspection. The /ask endpoint now accepts an optional session_id in the request body and also reads/sets it via an httponly cookie as fallback. The session ID is echoed back in an X-Session-Id response header so the frontend can capture it.
86
+
87
+ frontend/index.html
88
+
89
+ The session ID is persisted to localStorage across page reloads. A session bar appears in the header once a conversation starts showing the turn count and a "β†Ί New Conversation" button that calls /clear and resets the UI. User messages get a subtle follow-up pill badge after turn 1, and the textarea placeholder changes to hint that follow-ups work. The session_id is included in every /ask payload.
90
+
91
+ Rag chain
92
+ PY
93
+ App
94
+ PY
95
+ Index
96
+ Code Β· HTML
97
+ create a feature branch and push the changes to that branch
98
+
99
+ 12:57 AM
100
+ Connector search
101
+ 1 connector
102
+ Microsoft Learn
103
+ GitHub isn't available as a connected MCP tool, so I can't push directly. However, I can give you the exact commands to do it yourself β€” it'll take about 30 seconds.
104
+
105
+ bash
106
+ # 1. Navigate to your project root
107
+ cd /path/to/sacred-texts-rag
108
+
109
+ # 2. Make sure you're on main and up to date
110
+ git checkout main
111
+ git pull origin main
112
+
113
+ # 3. Create and switch to the feature branch
114
+ git checkout -b feature/multi-turn-conversation
115
+
116
+ # 4. Copy the updated files in (if you downloaded them)
117
+ # Or just overwrite in-place if you edited directly
118
+
119
+ # 5. Stage the three changed files
120
+ git add rag_chain.py app.py frontend/index.html
121
+
122
+ # 6. Commit
123
+ git commit -m "feat: add multi-turn conversation memory
124
+
125
+ - Add session-based ConversationBufferMemory in rag_chain.py
126
+ - Inject chat history via MessagesPlaceholder into LLM prompt
127
+ - Augment retrieval query for short follow-up questions
128
+ - Bypass semantic cache for follow-up turns
129
+ - Add /clear and /history endpoints in app.py
130
+ - Session ID via cookie + X-Session-Id header + request body
131
+ - Frontend: session bar, turn counter, follow-up pill badge,
132
+ localStorage session persistence, New Conversation button"
133
+
134
+ # 7. Push the branch
135
+ git push -u origin feature/multi-turn-conversation
136
+ Once pushed, GitHub will show a "Compare & pull request" banner on the repo page if you want to open a PR into main.
137
+
138
+ If you want to connect your GitHub account so I can do this directly next time, you can add the GitHub connector from the Tools menu in settings and I'll be able to create branches, commit, and push on your behalf.
139
+
140
+ Any update for the README file
141
+
142
+ 1:05 AM
143
+ Yes, the README needs updating to reflect the new multi-turn conversation feature, the two new API endpoints, and the new environment variable. Here's the updated file:
144
+
145
+
146
+ Created a file, ran a command, read a file
147
+ Created a file, ran a command, read a file
148
+
149
+ Updated README.md with multi-turn conversation feature docs
150
+ README.md
151
+
152
+ Copy to outputs
153
+
154
+ Script
155
+
156
+ Presented file
157
+ Done
158
+ Here's what was updated vs. the original:
159
+
160
+ Added / changed sections:
161
+
162
+ The intro line now mentions multi-turn conversation
163
+ MAX_HISTORY_TURNS added to the environment variables table
164
+ The "How It Works" diagram now shows the two new steps β€” Session Memory and Query Augmentation β€” at the top of the pipeline
165
+ A new πŸ’¬ Multi-Turn Conversation section explains the follow-up UX with an example dialogue, how session IDs work, and the "New Conversation" button
166
+ The API Endpoints table now includes /clear and /history, and has a proper request/response example for /ask showing the NDJSON streaming format including cache hits
167
+ The notes section now documents the cache threshold and follow-up query augmentation behaviour
168
+ The features list at the bottom is cleaned up and deduplicated against what's already built
169
+ To push this along with the other files:
170
+
171
+ bash
172
+ git add README.md
173
+ git commit -m "docs: update README for multi-turn conversation feature"
174
+ git push
175
+ Readme
176
+ Document Β· MD
177
+
178
+
179
+ You've used 90% of your session limit
180
+ Upgrade
181
+
182
+
183
+
184
+ Claude is AI and can make mistakes. Please double-check responses.
185
+ Readme Β· MD
186
+ Copy
187
+
188
  ---
189
  title: Sacred Texts RAG
190
  emoji: πŸ•ŠοΈ
 
194
  app_port: 7860
195
  pinned: false
196
  ---
197
+
198
  # πŸ•ŠοΈ Sacred Texts RAG β€” Multi-Religion Knowledge Base
199
+
200
+ A Retrieval-Augmented Generation (RAG) application that answers spiritual queries using the Bhagavad Gita, Quran, Bible, and Guru Granth Sahib as the sole knowledge sources. Now with **multi-turn conversation memory** β€” ask follow-up questions naturally, just like a real dialogue.
201
+
202
  ---
203
+
204
  ## πŸ“ Project Structure
205
+
206
  ```
207
  sacred-texts-rag/
208
  β”œβ”€β”€ README.md
209
  β”œβ”€β”€ requirements.txt
210
  β”œβ”€β”€ .env.example
211
  β”œβ”€β”€ ingest.py # Step 1: Load PDFs β†’ chunk β†’ embed β†’ store
212
+ β”œβ”€β”€ rag_chain.py # Core RAG chain logic (with session memory)
213
  β”œβ”€β”€ app.py # FastAPI backend server
214
  └── frontend/
215
+ └── index.html # Chat UI (served by FastAPI)
216
  ```
217
+
218
  ---
219
+
220
  ## βš™οΈ Setup Instructions
221
+
222
  ### 1. Install Dependencies
223
  ```bash
224
  pip install -r requirements.txt
225
  ```
226
+
227
  ### 2. Configure Environment
228
  ```bash
229
  cp .env.example .env
230
  # Edit .env and add your NVIDIA_API_KEY
231
  ```
232
+
233
  ### 3. Add Your PDF Books
234
  Place your PDF files in a `books/` folder:
235
  ```
236
  books/
237
  β”œβ”€β”€ bhagavad_gita.pdf
238
  β”œβ”€β”€ quran.pdf
239
+ β”œβ”€β”€ bible.pdf
240
  └── guru_granth_sahib.pdf
241
  ```
242
+
243
  ### 4. Ingest the Books (Run Once)
244
  ```bash
245
  python ingest.py
 
249
  - Split into semantic chunks
250
  - Create embeddings using NVIDIA's `llama-nemotron-embed-vl-1b-v2` model
251
  - Store in a local ChromaDB vector store (`./chroma_db/`)
252
+
253
  ### 5. Start the Backend
254
  ```bash
255
  python app.py
256
  ```
257
+ Server runs at: `http://localhost:7860`
258
+
259
  ### 6. Open the Frontend
260
+ Navigate to `http://localhost:7860` in your browser β€” the FastAPI server serves the UI directly.
261
+
262
  ---
263
+
264
  ## πŸ”‘ Environment Variables
265
+
266
+ | Variable | Description | Default |
267
+ |---|---|---|
268
+ | `NVIDIA_API_KEY` | Your NVIDIA API key | β€” |
269
+ | `CHROMA_DB_PATH` | Path to ChromaDB storage | `./chroma_db` |
270
+ | `COLLECTION_NAME` | ChromaDB collection name | `sacred_texts` |
271
+ | `CHUNKS_PER_BOOK` | Chunks retrieved per book per query | `3` |
272
+ | `MAX_HISTORY_TURNS` | Max conversation turns kept in memory per session | `6` |
273
+ | `HOST` | Server bind host | `0.0.0.0` |
274
+ | `PORT` | Server port | `7860` |
275
+
276
  ---
277
+
278
  ## 🧠 How It Works
279
+
280
  ```
281
  User Query
282
  β”‚
283
  β–Ό
284
+ [Session Memory] ←── Injects prior conversation turns into LLM context
285
+ β”‚
286
+ β–Ό
287
+ [Query Augmentation] ←── Short follow-ups are enriched with previous question
288
+ β”‚
289
+ β–Ό
290
+ [Hybrid Retrieval: BM25 + Vector Search] ←── Per-book guaranteed slots
291
  β”‚
292
  β–Ό
293
+ [NVIDIA Reranker] ←── llama-3.2-nv-rerankqa-1b-v2 re-scores pooled candidates
 
294
  β”‚
295
  β–Ό
296
+ [Semantic Cache Check] ←── Skip LLM if a similar question was answered before
297
+ β”‚
298
+ β–Ό
299
+ [Prompt with Context + History]
300
  β”‚
301
  β–Ό
302
  [Llama-3.3-70b-instruct] ←── Answer grounded ONLY in retrieved texts
303
  β”‚
304
  β–Ό
305
+ Streamed response with source citations (book + chapter/verse)
306
  ```
307
+
308
  ---
309
+
310
+ ## πŸ’¬ Multi-Turn Conversation
311
+
312
+ The app maintains per-session conversation history so you can ask natural follow-up questions:
313
+
314
+ ```
315
+ You: "What do the scriptures say about forgiveness?"
316
+ AI: [Answer citing Gita, Quran, Bible, Guru Granth Sahib]
317
+
318
+ You: "Elaborate on the second point" ← follow-up, no context needed
319
+ AI: [Continues from previous answer]
320
+
321
+ You: "What does the Bible say specifically?" ← drill-down
322
+ AI: [Focuses on Bible passages from the thread]
323
+ ```
324
+
325
+ **How sessions work:**
326
+ - A session ID is created automatically on your first question and persisted in the browser's `localStorage`
327
+ - The server keeps the last `MAX_HISTORY_TURNS` (default: 6) human+AI pairs in memory
328
+ - Click **β†Ί New Conversation** in the header to clear history and start fresh
329
+ - Sessions are scoped to the server process β€” they reset on server restart
330
+
331
+ ---
332
+
333
+ ## 🌐 API Endpoints
334
+
335
+ | Method | Endpoint | Description |
336
+ |---|---|---|
337
+ | `POST` | `/ask` | Ask a question; streams NDJSON response |
338
+ | `POST` | `/clear` | Clear conversation history for a session |
339
+ | `GET` | `/history` | Inspect conversation history for a session |
340
+ | `GET` | `/books` | List all books indexed in the knowledge base |
341
+ | `GET` | `/health` | Health check |
342
+ | `GET` | `/` | Serves the frontend UI |
343
+ | `GET` | `/docs` | Swagger UI |
344
+
345
+ ### `/ask` Request Body
346
+ ```json
347
+ {
348
+ "question": "What do the scriptures say about compassion?",
349
+ "session_id": "optional-uuid-string"
350
+ }
351
+ ```
352
+
353
+ ### `/ask` Response (streamed NDJSON)
354
+ ```json
355
+ {"type": "token", "data": "The Bhagavad Gita teaches..."}
356
+ {"type": "token", "data": " compassion as..."}
357
+ {"type": "sources", "data": [{"book": "Bhagavad Gita 2:47", "page": "2:47", "snippet": "..."}]}
358
+ ```
359
+ Cache hits return a single `{"type": "cache", "data": {"answer": "...", "sources": [...]}}` line.
360
+
361
+ ---
362
+
363
  ## πŸ“ Notes
364
+
365
  - The LLM is instructed **never** to answer from outside the provided texts
366
+ - Each response includes **source citations** (book + chapter/verse where available)
367
  - Responses synthesize wisdom **across all books** when relevant
368
+ - The semantic cache skips the LLM for repeated or near-identical questions (cosine distance < 0.35)
369
+ - Follow-up retrieval automatically augments vague short queries with the previous question for better semantic matching
370
+
371
+ ---
372
+
373
+ ## πŸ—ΊοΈ Planned Features
374
+
375
+ - Contextual chunk expansion (fetch Β±1 surrounding chunks)
376
+ - HyDE β€” Hypothetical Document Embedding for abstract queries
377
+ - Answer faithfulness scoring (LLM-as-judge)
378
+ - Query rewriting for vague inputs
379
+ - Snippet preview on source hover
380
+ - Query suggestions after each answer
381
+ - Compare mode β€” side-by-side view across books
382
+ - Hallucination guardrail
383
+ - Out-of-scope detection
384
+ - Rate limiting & API key hardening
385
+
386
+ ---
387
+
388
  ## 🎬 Demo
389
+
390
+ App Link: https://shouvik99-lifeguide.hf.space/
391
+
392
 
 
app.py CHANGED
@@ -2,7 +2,9 @@
2
  app.py β€” FastAPI backend server for the Sacred Texts RAG application.
3
 
4
  Endpoints:
5
- POST /ask β€” Ask a question, get an answer with sources
 
 
6
  GET /health β€” Health check
7
  GET /books β€” List books currently in the knowledge base
8
 
@@ -11,13 +13,20 @@ Run with:
11
  """
12
 
13
  import os
14
- from fastapi import FastAPI, HTTPException
 
15
  from fastapi.middleware.cors import CORSMiddleware
16
  from pydantic import BaseModel, Field
17
  from dotenv import load_dotenv
18
- from fastapi.responses import StreamingResponse, FileResponse
19
- from rag_chain import query_sacred_texts, get_embeddings, get_vector_store # ← FIXED
20
- from starlette.concurrency import run_in_threadpool
 
 
 
 
 
 
21
 
22
  load_dotenv()
23
 
@@ -26,34 +35,54 @@ load_dotenv()
26
  app = FastAPI(
27
  title="Sacred Texts RAG API",
28
  description="Ask questions answered exclusively from Bhagavad Gita, Quran, Bible, and Guru Granth Sahib",
29
- version="1.0.0",
30
  )
31
 
32
- # Allow requests from the local frontend (index.html opened as file://)
33
  app.add_middleware(
34
  CORSMiddleware,
35
- allow_origins=["*"], # Restrict in production
36
  allow_credentials=True,
37
  allow_methods=["*"],
38
  allow_headers=["*"],
 
39
  )
40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
  # ─── Request / Response Models ────────────────────────────────────────────────
43
 
44
  class AskRequest(BaseModel):
45
  question: str = Field(..., min_length=3, max_length=1000,
46
  example="What do the scriptures say about compassion?")
47
-
48
- class Source(BaseModel):
49
- book: str
50
- page: int | str
51
- snippet: str
52
-
53
- class AskResponse(BaseModel):
54
- question: str
55
- answer: str
56
- sources: list[Source]
57
 
58
  class HealthResponse(BaseModel):
59
  status: str
@@ -63,49 +92,67 @@ class BooksResponse(BaseModel):
63
  books: list[str]
64
  total_chunks: int
65
 
 
 
 
 
 
 
 
 
 
 
 
 
66
 
67
  # ─── Routes ───────────────────────────────────────────────────────────────────
68
 
69
  @app.get("/health", response_model=HealthResponse, tags=["System"])
70
  def health_check():
71
- """Check that the API is running."""
72
  return {"status": "ok", "message": "Sacred Texts RAG is running πŸ•ŠοΈ"}
73
 
74
 
75
  @app.get("/books", response_model=BooksResponse, tags=["Knowledge Base"])
76
  def list_books():
77
- """List all books currently indexed in the knowledge base."""
78
  try:
79
- embeddings = get_embeddings() # ← FIXED Step 1
80
- vector_store = get_vector_store(embeddings) # ← FIXED Step 2
81
- collection = vector_store._collection
82
- results = collection.get(include=["metadatas"])
83
- metadatas = results.get("metadatas", [])
84
-
85
- books = sorted(set(
86
- m.get("book", "Unknown")
87
- for m in metadatas
88
- if m # guard against None
89
- ))
90
  return {"books": books, "total_chunks": len(metadatas)}
91
  except Exception as e:
92
  raise HTTPException(status_code=500, detail=f"Could not read knowledge base: {e}")
93
 
94
 
95
  @app.post("/ask", tags=["Query"])
96
- async def ask(request: AskRequest):
97
  """
98
  Ask a spiritual or philosophical question.
99
- The answer is grounded strictly in the sacred texts.
 
 
100
  """
101
- if not request.question.strip():
102
  raise HTTPException(status_code=400, detail="Question cannot be empty.")
103
 
 
 
 
 
 
 
104
  try:
105
-
 
 
 
 
106
  return StreamingResponse(
107
- query_sacred_texts(request.question),
108
- media_type="application/json"
 
109
  )
110
  except FileNotFoundError:
111
  raise HTTPException(
@@ -115,26 +162,64 @@ async def ask(request: AskRequest):
115
  except Exception as e:
116
  raise HTTPException(status_code=500, detail=str(e))
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  @app.get("/", include_in_schema=False)
119
  async def serve_frontend():
120
- """Serves the static frontend HTML file."""
121
  frontend_path = "frontend/index.html"
122
  if os.path.exists(frontend_path):
123
  return FileResponse(frontend_path)
124
  return {"message": "Sacred Texts RAG API is live. Visit /docs for Swagger UI."}
125
 
 
126
  # ─── Entry Point ──────────────────────────────────────────────────────────────
127
 
128
  if __name__ == "__main__":
129
  import uvicorn
130
 
131
- # HF Spaces uses 7860 by default
132
  host = os.getenv("HOST", "0.0.0.0")
133
- port = int(os.getenv("PORT", "7860"))
134
 
135
- print(f"\nπŸ•ŠοΈ Sacred Texts RAG β€” API Server")
136
  print(f"{'─' * 40}")
137
  print(f"🌐 Running at : http://{host}:{port}")
 
138
  print(f"{'─' * 40}\n")
139
 
140
- uvicorn.run("app:app", host=host, port=port, reload=False) # reload=False for production
 
2
  app.py β€” FastAPI backend server for the Sacred Texts RAG application.
3
 
4
  Endpoints:
5
+ POST /ask β€” Ask a question, get a streamed answer with sources
6
+ POST /clear β€” Clear conversation history for a session
7
+ GET /history β€” Retrieve conversation history for a session
8
  GET /health β€” Health check
9
  GET /books β€” List books currently in the knowledge base
10
 
 
13
  """
14
 
15
  import os
16
+ import uuid
17
+ from fastapi import FastAPI, HTTPException, Request, Response
18
  from fastapi.middleware.cors import CORSMiddleware
19
  from pydantic import BaseModel, Field
20
  from dotenv import load_dotenv
21
+ from fastapi.responses import StreamingResponse, FileResponse, JSONResponse
22
+ from rag_chain import (
23
+ query_sacred_texts,
24
+ get_embeddings,
25
+ get_vector_store,
26
+ clear_session,
27
+ get_history,
28
+ )
29
+ from langchain_core.messages import HumanMessage, AIMessage
30
 
31
  load_dotenv()
32
 
 
35
  app = FastAPI(
36
  title="Sacred Texts RAG API",
37
  description="Ask questions answered exclusively from Bhagavad Gita, Quran, Bible, and Guru Granth Sahib",
38
+ version="2.0.0",
39
  )
40
 
 
41
  app.add_middleware(
42
  CORSMiddleware,
43
+ allow_origins=["*"],
44
  allow_credentials=True,
45
  allow_methods=["*"],
46
  allow_headers=["*"],
47
+ expose_headers=["X-Session-Id"],
48
  )
49
 
50
+ SESSION_COOKIE = "rag_session_id"
51
+
52
+
53
+ # ─── Helpers ─────────────────────────────────────────────────────────────────
54
+
55
+ def get_or_create_session(request: Request, response: Response) -> str:
56
+ """
57
+ Read the session ID from the cookie (or X-Session-Id header).
58
+ If absent, generate a new one and set it on the response cookie.
59
+ """
60
+ session_id = (
61
+ request.cookies.get(SESSION_COOKIE)
62
+ or request.headers.get("X-Session-Id")
63
+ )
64
+ if not session_id:
65
+ session_id = str(uuid.uuid4())
66
+ response.set_cookie(
67
+ key=SESSION_COOKIE,
68
+ value=session_id,
69
+ httponly=True,
70
+ samesite="lax",
71
+ max_age=60 * 60 * 24, # 24 hours
72
+ )
73
+ return session_id
74
+
75
 
76
  # ─── Request / Response Models ────────────────────────────────────────────────
77
 
78
  class AskRequest(BaseModel):
79
  question: str = Field(..., min_length=3, max_length=1000,
80
  example="What do the scriptures say about compassion?")
81
+ session_id: str | None = Field(
82
+ default=None,
83
+ description="Optional session ID for multi-turn conversations. "
84
+ "If omitted, the server reads/creates one via cookie.",
85
+ )
 
 
 
 
 
86
 
87
  class HealthResponse(BaseModel):
88
  status: str
 
92
  books: list[str]
93
  total_chunks: int
94
 
95
+ class ClearRequest(BaseModel):
96
+ session_id: str | None = None
97
+
98
+ class HistoryItem(BaseModel):
99
+ role: str # "human" | "ai"
100
+ content: str
101
+
102
+ class HistoryResponse(BaseModel):
103
+ session_id: str
104
+ turns: int
105
+ messages: list[HistoryItem]
106
+
107
 
108
  # ─── Routes ───────────────────────────────────────────────────────────────────
109
 
110
  @app.get("/health", response_model=HealthResponse, tags=["System"])
111
  def health_check():
 
112
  return {"status": "ok", "message": "Sacred Texts RAG is running πŸ•ŠοΈ"}
113
 
114
 
115
  @app.get("/books", response_model=BooksResponse, tags=["Knowledge Base"])
116
  def list_books():
 
117
  try:
118
+ embeddings = get_embeddings()
119
+ vector_store = get_vector_store(embeddings)
120
+ collection = vector_store._collection
121
+ results = collection.get(include=["metadatas"])
122
+ metadatas = results.get("metadatas", [])
123
+ books = sorted(set(m.get("book", "Unknown") for m in metadatas if m))
 
 
 
 
 
124
  return {"books": books, "total_chunks": len(metadatas)}
125
  except Exception as e:
126
  raise HTTPException(status_code=500, detail=f"Could not read knowledge base: {e}")
127
 
128
 
129
  @app.post("/ask", tags=["Query"])
130
+ async def ask(request_body: AskRequest, request: Request, response: Response):
131
  """
132
  Ask a spiritual or philosophical question.
133
+ Streams the answer as NDJSON (one JSON object per line).
134
+ Maintains per-session conversation history automatically via cookie or
135
+ the `session_id` field in the request body.
136
  """
137
+ if not request_body.question.strip():
138
  raise HTTPException(status_code=400, detail="Question cannot be empty.")
139
 
140
+ # Resolve session: body field > cookie/header > new
141
+ if request_body.session_id:
142
+ session_id = request_body.session_id
143
+ else:
144
+ session_id = get_or_create_session(request, response)
145
+
146
  try:
147
+ stream = query_sacred_texts(request_body.question, session_id=session_id)
148
+
149
+ # We need to forward the session_id so the frontend can persist it
150
+ headers = {"X-Session-Id": session_id}
151
+
152
  return StreamingResponse(
153
+ stream,
154
+ media_type="application/x-ndjson",
155
+ headers=headers,
156
  )
157
  except FileNotFoundError:
158
  raise HTTPException(
 
162
  except Exception as e:
163
  raise HTTPException(status_code=500, detail=str(e))
164
 
165
+
166
+ @app.post("/clear", tags=["Session"])
167
+ async def clear_conversation(body: ClearRequest, request: Request, response: Response):
168
+ """
169
+ Clear the conversation history for the given session.
170
+ If session_id is omitted, clears the session identified by cookie.
171
+ """
172
+ session_id = body.session_id or request.cookies.get(SESSION_COOKIE)
173
+ if not session_id:
174
+ raise HTTPException(status_code=400, detail="No session to clear.")
175
+ clear_session(session_id)
176
+ return {"status": "cleared", "session_id": session_id}
177
+
178
+
179
+ @app.get("/history", response_model=HistoryResponse, tags=["Session"])
180
+ async def conversation_history(session_id: str | None = None, request: Request = None):
181
+ """
182
+ Return the conversation history for a session (for debugging / display).
183
+ """
184
+ sid = session_id or (request.cookies.get(SESSION_COOKIE) if request else None)
185
+ if not sid:
186
+ raise HTTPException(status_code=400, detail="Provide session_id query param or cookie.")
187
+
188
+ messages = get_history(sid)
189
+ items = []
190
+ for msg in messages:
191
+ if isinstance(msg, HumanMessage):
192
+ items.append(HistoryItem(role="human", content=msg.content))
193
+ elif isinstance(msg, AIMessage):
194
+ items.append(HistoryItem(role="ai", content=msg.content))
195
+
196
+ return HistoryResponse(
197
+ session_id=sid,
198
+ turns=len(items) // 2,
199
+ messages=items,
200
+ )
201
+
202
+
203
  @app.get("/", include_in_schema=False)
204
  async def serve_frontend():
 
205
  frontend_path = "frontend/index.html"
206
  if os.path.exists(frontend_path):
207
  return FileResponse(frontend_path)
208
  return {"message": "Sacred Texts RAG API is live. Visit /docs for Swagger UI."}
209
 
210
+
211
  # ─── Entry Point ──────────────────────────────────────────────────────────────
212
 
213
  if __name__ == "__main__":
214
  import uvicorn
215
 
 
216
  host = os.getenv("HOST", "0.0.0.0")
217
+ port = int(os.getenv("PORT", "7860"))
218
 
219
+ print(f"\nπŸ•ŠοΈ Sacred Texts RAG β€” API Server v2.0")
220
  print(f"{'─' * 40}")
221
  print(f"🌐 Running at : http://{host}:{port}")
222
+ print(f"🧠 Multi-turn conversation: ENABLED")
223
  print(f"{'─' * 40}\n")
224
 
225
+ uvicorn.run("app:app", host=host, port=port, reload=False)
frontend/index.html CHANGED
@@ -13,13 +13,7 @@
13
 
14
  <style>
15
  /* ── Reset & Base ─────────────────────────────────────────── */
16
- *,
17
- *::before,
18
- *::after {
19
- box-sizing: border-box;
20
- margin: 0;
21
- padding: 0;
22
- }
23
 
24
  :root {
25
  --bg: #0d0b07;
@@ -32,68 +26,13 @@
32
  --cream: #f0e6cc;
33
  --muted: #7a6a4a;
34
  --gita: #e07b3b;
35
- /* saffron */
36
  --quran: #3bba85;
37
- /* green */
38
  --bible: #5b8ce0;
39
- /* blue */
40
  --granth: #b07ce0;
41
- /* violet β€” Sikh royal purple */
42
- }
43
-
44
- /* Animated Thinking state for streaming */
45
- .thinking-dots {
46
- display: inline-flex;
47
- gap: 4px;
48
- margin-left: 4px;
49
- }
50
-
51
- .thinking-dots span {
52
- width: 4px;
53
- height: 4px;
54
- background: var(--gold);
55
- border-radius: 50%;
56
- animation: bounce 1.4s infinite ease-in-out;
57
- }
58
-
59
- @keyframes bounce {
60
-
61
- 0%,
62
- 80%,
63
- 100% {
64
- transform: scale(0);
65
- }
66
-
67
- 40% {
68
- transform: scale(1);
69
- }
70
  }
71
 
72
- /* Make streaming text fade in slightly for smoothness */
73
- #currentStreamingMsg p {
74
- animation: fadeIn 0.3s ease-in;
75
- }
76
-
77
- @keyframes fadeIn {
78
- from {
79
- opacity: 0.7;
80
- }
81
-
82
- to {
83
- opacity: 1;
84
- }
85
- }
86
-
87
- /* Ensure the bubble has a minimum height so it doesn't look like a "small block" */
88
- .msg-bubble:empty::before {
89
- content: "Writing wisdom...";
90
- color: var(--muted);
91
- font-style: italic;
92
- font-size: 0.9rem;
93
- }
94
-
95
- html,
96
- body {
97
  height: 100%;
98
  background: var(--bg);
99
  color: var(--cream);
@@ -103,15 +42,14 @@
103
  overflow: hidden;
104
  }
105
 
106
- /* ── Background texture ───────────────────────────────────── */
107
  body::before {
108
  content: '';
109
  position: fixed;
110
  inset: 0;
111
  background:
112
- radial-gradient(ellipse 80% 60% at 20% 10%, rgba(201, 153, 58, .07) 0%, transparent 60%),
113
- radial-gradient(ellipse 60% 80% at 80% 90%, rgba(91, 140, 224, .05) 0%, transparent 60%),
114
- radial-gradient(ellipse 50% 50% at 50% 50%, rgba(176, 124, 224, .04) 0%, transparent 60%),
115
  url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='400'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='400' height='400' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E");
116
  pointer-events: none;
117
  z-index: 0;
@@ -131,106 +69,121 @@
131
 
132
  /* ── Header ───────────────────────────────────────────────── */
133
  header {
134
- padding: 28px 0 18px;
135
  text-align: center;
136
  border-bottom: 1px solid var(--border);
 
137
  }
138
 
139
  .mandala {
140
- font-size: 2rem;
141
  letter-spacing: .5rem;
142
  color: var(--gold);
143
  opacity: .6;
144
- margin-bottom: 8px;
145
  animation: spin 60s linear infinite;
146
  display: inline-block;
147
  }
148
-
149
- @keyframes spin {
150
- to {
151
- transform: rotate(360deg);
152
- }
153
- }
154
 
155
  h1 {
156
  font-family: 'Cinzel Decorative', serif;
157
- font-size: clamp(1.2rem, 3vw, 1.9rem);
158
  font-weight: 400;
159
  color: var(--gold-pale);
160
  letter-spacing: .12em;
161
- text-shadow: 0 0 40px rgba(201, 153, 58, .3);
162
  }
163
 
164
  .subtitle {
165
  font-family: 'IM Fell English', serif;
166
  font-style: italic;
167
- font-size: .95rem;
168
  color: var(--muted);
169
- margin-top: 4px;
170
  }
171
 
172
  .badges {
173
  display: flex;
174
  justify-content: center;
175
- gap: 12px;
176
- margin-top: 12px;
177
  flex-wrap: wrap;
178
  }
179
 
180
  .badge {
181
- font-size: .72rem;
182
  letter-spacing: .1em;
183
  text-transform: uppercase;
184
- padding: 3px 10px;
185
  border-radius: 20px;
186
  border: 1px solid;
187
  font-family: 'Cormorant Garamond', serif;
188
  font-weight: 600;
189
  }
 
 
 
 
190
 
191
- .badge-gita {
192
- color: var(--gita);
193
- border-color: var(--gita);
194
- background: rgba(224, 123, 59, .1);
 
 
 
 
 
 
 
 
 
195
  }
196
 
197
- .badge-quran {
198
- color: var(--quran);
199
- border-color: var(--quran);
200
- background: rgba(59, 186, 133, .1);
 
201
  }
202
 
203
- .badge-bible {
204
- color: var(--bible);
205
- border-color: var(--bible);
206
- background: rgba(91, 140, 224, .1);
207
  }
208
 
209
- .badge-granth {
210
- color: var(--granth);
211
- border-color: var(--granth);
212
- background: rgba(176, 124, 224, .1);
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  }
214
 
215
  /* ── Chat Window ──────────────────────────────────────────── */
216
  .chat-window {
217
  overflow-y: auto;
218
- padding: 28px 0;
219
  display: flex;
220
  flex-direction: column;
221
  gap: 24px;
222
  scrollbar-width: thin;
223
  scrollbar-color: var(--border) transparent;
224
  }
225
-
226
- .chat-window::-webkit-scrollbar {
227
- width: 4px;
228
- }
229
-
230
- .chat-window::-webkit-scrollbar-thumb {
231
- background: var(--border);
232
- border-radius: 4px;
233
- }
234
 
235
  /* ── Welcome State ────────────────────────────────────────── */
236
  .welcome {
@@ -239,84 +192,46 @@
239
  padding: 20px;
240
  max-width: 500px;
241
  }
242
-
243
- .welcome-icon {
244
- font-size: 3.5rem;
245
- margin-bottom: 16px;
246
- filter: drop-shadow(0 0 20px rgba(201, 153, 58, .4));
247
- }
248
-
249
  .welcome h2 {
250
  font-family: 'IM Fell English', serif;
251
  font-style: italic;
252
- font-size: 1.5rem;
253
  color: var(--gold-light);
254
- margin-bottom: 10px;
255
- }
256
-
257
- .welcome p {
258
- font-size: .95rem;
259
- color: var(--muted);
260
- line-height: 1.8;
261
- }
262
-
263
- .suggested-queries {
264
- margin-top: 24px;
265
- display: flex;
266
- flex-direction: column;
267
- gap: 8px;
268
  }
 
269
 
 
270
  .suggested-queries button {
271
  background: var(--surface);
272
  border: 1px solid var(--border);
273
  color: var(--cream);
274
- padding: 10px 16px;
275
  border-radius: 8px;
276
  font-family: 'Cormorant Garamond', serif;
277
- font-size: .95rem;
278
  font-style: italic;
279
  cursor: pointer;
280
  transition: all .2s;
281
  text-align: left;
282
  }
283
-
284
- .suggested-queries button:hover {
285
- border-color: var(--gold);
286
- color: var(--gold-pale);
287
- background: var(--surface-2);
288
- }
289
 
290
  /* ── Messages ─────────────────────────────────────────────── */
291
  .message {
292
  display: flex;
293
  flex-direction: column;
294
- gap: 8px;
295
  animation: fadeUp .4s ease both;
296
  }
 
297
 
298
- @keyframes fadeUp {
299
- from {
300
- opacity: 0;
301
- transform: translateY(12px);
302
- }
303
-
304
- to {
305
- opacity: 1;
306
- transform: translateY(0);
307
- }
308
- }
309
-
310
- .message-user {
311
- align-items: flex-end;
312
- }
313
-
314
- .message-assistant {
315
- align-items: flex-start;
316
- }
317
 
318
  .msg-label {
319
- font-size: .7rem;
320
  letter-spacing: .15em;
321
  text-transform: uppercase;
322
  color: var(--muted);
@@ -326,7 +241,7 @@
326
 
327
  .msg-bubble {
328
  max-width: 92%;
329
- padding: 16px 20px;
330
  border-radius: 12px;
331
  line-height: 1.75;
332
  }
@@ -336,40 +251,40 @@
336
  border: 1px solid var(--border);
337
  color: var(--cream);
338
  font-style: italic;
339
- font-size: 1rem;
340
  border-bottom-right-radius: 4px;
341
  }
342
 
343
  .message-assistant .msg-bubble {
344
- background: linear-gradient(135deg, var(--surface) 0%, rgba(30, 26, 17, .95) 100%);
345
- border: 1px solid rgba(201, 153, 58, .2);
346
  color: var(--cream);
347
- font-size: 1rem;
348
  border-bottom-left-radius: 4px;
349
- box-shadow: 0 4px 24px rgba(0, 0, 0, .4), inset 0 1px 0 rgba(201, 153, 58, .1);
350
- }
351
-
352
- .msg-bubble p {
353
- margin-bottom: 1em;
354
  }
355
 
356
- .msg-bubble p:last-child {
357
- margin-bottom: 0;
358
- }
359
 
360
- .msg-bubble strong {
361
- color: var(--gold-light);
362
- font-weight: 600;
 
 
 
 
 
 
 
 
363
  }
364
 
365
  /* ── Sources Panel ────────────────────────────────────────── */
366
- .sources {
367
- max-width: 92%;
368
- margin-top: 4px;
369
- }
370
-
371
  .sources-label {
372
- font-size: .72rem;
373
  letter-spacing: .12em;
374
  text-transform: uppercase;
375
  color: var(--muted);
@@ -378,27 +293,12 @@
378
  align-items: center;
379
  gap: 6px;
380
  }
 
 
381
 
382
- .sources-label::before,
383
- .sources-label::after {
384
- content: '';
385
- flex: 1;
386
- height: 1px;
387
- background: var(--border);
388
- }
389
-
390
- .sources-label::before {
391
- max-width: 20px;
392
- }
393
-
394
- .source-tags {
395
- display: flex;
396
- flex-wrap: wrap;
397
- gap: 6px;
398
- }
399
-
400
  .source-tag {
401
- font-size: .78rem;
402
  padding: 4px 10px;
403
  border-radius: 6px;
404
  border: 1px solid;
@@ -406,101 +306,55 @@
406
  cursor: default;
407
  transition: all .2s;
408
  }
 
409
 
410
- .source-tag:hover {
411
- transform: translateY(-1px);
412
- filter: brightness(1.2);
413
- }
414
-
415
- .source-gita {
416
- color: var(--gita);
417
- border-color: rgba(224, 123, 59, .4);
418
- background: rgba(224, 123, 59, .08);
419
- }
420
-
421
- .source-quran {
422
- color: var(--quran);
423
- border-color: rgba(59, 186, 133, .4);
424
- background: rgba(59, 186, 133, .08);
425
- }
426
-
427
- .source-bible {
428
- color: var(--bible);
429
- border-color: rgba(91, 140, 224, .4);
430
- background: rgba(91, 140, 224, .08);
431
- }
432
-
433
- .source-granth {
434
- color: var(--granth);
435
- border-color: rgba(176, 124, 224, .4);
436
- background: rgba(176, 124, 224, .08);
437
- }
438
-
439
- .source-other {
440
- color: var(--gold-light);
441
- border-color: rgba(201, 153, 58, .4);
442
- background: rgba(201, 153, 58, .08);
443
- }
444
 
445
  /* ── Loading ──────────────────────────────────────────────── */
446
  .loading {
447
  display: flex;
448
  align-items: center;
449
- gap: 12px;
450
- padding: 14px 18px;
451
- border: 1px solid rgba(201, 153, 58, .15);
452
  border-radius: 12px;
453
  background: var(--surface);
454
  width: fit-content;
455
  max-width: 280px;
456
  }
457
-
458
- .loading-dots {
459
- display: flex;
460
- gap: 5px;
461
- }
462
-
463
  .loading-dots span {
464
- width: 6px;
465
- height: 6px;
466
  border-radius: 50%;
467
  background: var(--gold);
468
  animation: dot-pulse 1.4s ease-in-out infinite;
469
  }
470
-
471
- .loading-dots span:nth-child(2) {
472
- animation-delay: .2s;
473
- }
474
-
475
- .loading-dots span:nth-child(3) {
476
- animation-delay: .4s;
477
- }
478
-
479
  @keyframes dot-pulse {
480
-
481
- 0%,
482
- 80%,
483
- 100% {
484
- opacity: .2;
485
- transform: scale(.8);
486
- }
487
-
488
- 40% {
489
- opacity: 1;
490
- transform: scale(1.1);
491
- }
492
  }
 
493
 
494
- .loading-text {
495
- font-size: .85rem;
496
- font-style: italic;
497
- color: var(--muted);
 
 
 
498
  }
 
499
 
500
  /* ── Error ────────────────────────────────────────────────── */
501
  .error-bubble {
502
- background: rgba(180, 60, 60, .1);
503
- border: 1px solid rgba(180, 60, 60, .3);
504
  color: #e08080;
505
  padding: 12px 16px;
506
  border-radius: 10px;
@@ -509,52 +363,38 @@
509
  }
510
 
511
  /* ── Input Area ───────────────────────────────────────────── */
512
- .input-area {
513
- padding: 16px 0 24px;
514
- border-top: 1px solid var(--border);
515
- }
516
-
517
- .input-row {
518
- display: flex;
519
- gap: 10px;
520
- align-items: flex-end;
521
- }
522
 
523
  textarea {
524
  flex: 1;
525
  background: var(--surface);
526
  border: 1px solid var(--border);
527
  color: var(--cream);
528
- padding: 14px 16px;
529
  border-radius: 12px;
530
  font-family: 'Cormorant Garamond', serif;
531
- font-size: 1rem;
532
  line-height: 1.6;
533
  resize: none;
534
- min-height: 52px;
535
- max-height: 140px;
536
  outline: none;
537
  transition: border-color .2s, box-shadow .2s;
538
  }
539
-
540
- textarea::placeholder {
541
- color: var(--muted);
542
- font-style: italic;
543
- }
544
-
545
  textarea:focus {
546
- border-color: rgba(201, 153, 58, .5);
547
- box-shadow: 0 0 0 3px rgba(201, 153, 58, .08);
548
  }
549
 
550
  .send-btn {
551
- width: 52px;
552
- height: 52px;
553
  border-radius: 12px;
554
- border: 1px solid rgba(201, 153, 58, .4);
555
- background: linear-gradient(135deg, rgba(201, 153, 58, .2), rgba(201, 153, 58, .05));
556
  color: var(--gold);
557
- font-size: 1.3rem;
558
  cursor: pointer;
559
  transition: all .2s;
560
  display: flex;
@@ -562,36 +402,15 @@
562
  justify-content: center;
563
  flex-shrink: 0;
564
  }
565
-
566
  .send-btn:hover:not(:disabled) {
567
- background: linear-gradient(135deg, rgba(201, 153, 58, .35), rgba(201, 153, 58, .15));
568
  border-color: var(--gold);
569
  transform: translateY(-1px);
570
- box-shadow: 0 4px 16px rgba(201, 153, 58, .2);
571
  }
 
572
 
573
- .send-btn:disabled {
574
- opacity: .3;
575
- cursor: not-allowed;
576
- transform: none;
577
- }
578
-
579
- .input-hint {
580
- font-size: .72rem;
581
- color: var(--muted);
582
- margin-top: 8px;
583
- text-align: center;
584
- font-style: italic;
585
- }
586
-
587
- /* ── Divider line ─────────────────────────────────────────── */
588
- .ornament {
589
- text-align: center;
590
- color: var(--border);
591
- font-size: .8rem;
592
- letter-spacing: .4em;
593
- margin: 4px 0;
594
- }
595
  </style>
596
  </head>
597
 
@@ -609,6 +428,16 @@
609
  <span class="badge badge-bible">Bible</span>
610
  <span class="badge badge-granth">Guru Granth Sahib</span>
611
  </div>
 
 
 
 
 
 
 
 
 
 
612
  </header>
613
 
614
  <!-- Chat Window -->
@@ -616,15 +445,18 @@
616
  <div class="welcome" id="welcomePane">
617
  <div class="welcome-icon">πŸ•ŠοΈ</div>
618
  <h2>"Seek, and it shall be given unto you"</h2>
619
- <p>Ask any spiritual or philosophical question. Answers are drawn exclusively from the Bhagavad Gita, Quran,
620
- Bible, and Guru Granth Sahib.</p>
 
 
 
 
621
  <div class="suggested-queries">
622
  <button onclick="askSuggested(this)">What do the scriptures say about forgiveness?</button>
623
  <button onclick="askSuggested(this)">How should one face fear and death?</button>
624
  <button onclick="askSuggested(this)">What is the purpose of prayer and worship?</button>
625
  <button onclick="askSuggested(this)">What is the nature of the soul according to each religion?</button>
626
- <button onclick="askSuggested(this)">What do the scriptures teach about humility and selfless
627
- service?</button>
628
  </div>
629
  </div>
630
  </div>
@@ -632,26 +464,88 @@
632
  <!-- Input -->
633
  <div class="input-area">
634
  <div class="input-row">
635
- <textarea id="questionInput" placeholder="Ask a question from the sacred texts…" rows="1"
636
- onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea>
637
- <button class="send-btn" id="sendBtn" onclick="sendQuestion()" title="Ask (Enter)">
638
- ✦
639
- </button>
 
640
  </div>
641
- <p class="input-hint">Press Enter to ask Β· Shift+Enter for new line Β· Answers grounded strictly in the sacred
642
- texts</p>
643
  </div>
644
 
645
  </div>
646
 
647
  <script>
648
  const API_BASE = window.location.origin;
649
- let isLoading = false;
 
 
 
 
 
 
 
 
 
 
 
 
650
 
651
- // ── Helpers ────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
652
  function getSourceClass(book) {
653
  const b = book.toLowerCase();
654
- if (b.includes("gita")) return "source-gita";
655
  if (b.includes("quran") || b.includes("koran")) return "source-quran";
656
  if (b.includes("bible") || b.includes("testament")) return "source-bible";
657
  if (b.includes("granth") || b.includes("guru")) return "source-granth";
@@ -670,23 +564,28 @@
670
 
671
  function autoResize(el) {
672
  el.style.height = "auto";
673
- el.style.height = Math.min(el.scrollHeight, 140) + "px";
674
  }
675
 
676
  function formatAnswer(text) {
677
- // Convert markdown-ish bold (**text**) to <strong>
678
  text = text.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>");
679
- // Wrap paragraphs
680
  return text.split(/\n\n+/).filter(p => p.trim()).map(p => `<p>${p.trim()}</p>`).join("");
681
  }
682
 
683
- // ── Append message to chat ─────────────────────────────────
684
- function appendUserMessage(question) {
 
 
 
 
685
  const w = document.getElementById("chatWindow");
686
  const div = document.createElement("div");
687
  div.className = "message message-user";
 
 
 
688
  div.innerHTML = `
689
- <span class="msg-label">You</span>
690
  <div class="msg-bubble">${escapeHtml(question)}</div>
691
  `;
692
  w.appendChild(div);
@@ -710,63 +609,46 @@
710
  return div;
711
  }
712
 
713
- function replaceLoadingWithAnswer(loadingEl, data) {
714
- const w = document.getElementById("chatWindow");
715
-
716
- // Build source tags
717
- const sourceTags = (data.sources || []).map(s => {
718
  const cls = getSourceClass(s.book);
719
- return `<span class="source-tag ${cls}" title="Page ${s.page}">πŸ“– ${s.book}</span>`;
720
  }).join("");
721
-
722
- const sourcesHtml = sourceTags ? `
723
- <div class="sources">
724
- <div class="sources-label">References</div>
725
- <div class="source-tags">${sourceTags}</div>
726
- </div>
727
- ` : "";
728
-
729
- loadingEl.innerHTML = `
730
- <span class="msg-label">Sacred Texts</span>
731
- <div class="msg-bubble">${formatAnswer(data.answer)}</div>
732
- ${sourcesHtml}
733
- `;
734
- scrollToBottom();
735
- }
736
-
737
- function replaceLoadingWithError(loadingEl, msg) {
738
- loadingEl.innerHTML = `
739
- <span class="msg-label">Error</span>
740
- <div class="error-bubble">⚠️ ${escapeHtml(msg)}</div>
741
- `;
742
- scrollToBottom();
743
- }
744
-
745
- function escapeHtml(str) {
746
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
747
  }
748
 
749
- // ── Send question ──────────────────────────────────────────
750
  async function sendQuestion() {
751
  if (isLoading) return;
752
- const input = document.getElementById("questionInput");
753
  const question = input.value.trim();
754
  if (!question) return;
755
 
756
  hideWelcome();
 
 
757
  isLoading = true;
758
  document.getElementById("sendBtn").disabled = true;
759
  input.value = "";
760
  input.style.height = "auto";
761
 
762
- appendUserMessage(question);
763
  const loadingEl = appendLoading();
764
 
765
  try {
 
 
 
766
  const res = await fetch(`${API_BASE}/ask`, {
767
- method: "POST",
768
  headers: { "Content-Type": "application/json" },
769
- body: JSON.stringify({ question }),
770
  });
771
 
772
  if (!res.ok) {
@@ -774,36 +656,36 @@
774
  throw new Error(err.detail || "Server error");
775
  }
776
 
777
- // Initialize variables to build the UI
778
- const reader = res.body.getReader();
779
- const decoder = new TextDecoder();
780
- let fullAnswer = "";
781
- let buffer = "";
782
 
783
- // Prepare the assistant UI bubble immediately
784
  loadingEl.innerHTML = `
785
- <span class="msg-label">Sacred Texts</span>
786
- <div class="msg-bubble" id="currentStreamingMsg">
787
- <div class="loading-text">The scriptures are being revealed<span class="thinking-dots"><span></span><span></span><span></span></span></div>
788
- </div>
789
- <div id="currentStreamingSources"></div>
790
- `;
791
- const bubble = document.getElementById("currentStreamingMsg");
 
 
792
  const sourcesContainer = document.getElementById("currentStreamingSources");
793
- let firstTokenReceived = false;
 
 
 
 
 
794
 
795
  while (true) {
796
  const { done, value } = await reader.read();
797
  if (done) break;
798
 
799
- // Append new data to the buffer
800
  buffer += decoder.decode(value, { stream: true });
801
-
802
- // Split by newline
803
  const lines = buffer.split("\n");
804
-
805
- buffer = lines.pop();
806
-
807
 
808
  for (const line of lines) {
809
  if (!line.trim()) continue;
@@ -811,20 +693,13 @@
811
  const parsed = JSON.parse(line);
812
 
813
  if (parsed.type === "token") {
814
- //Remove the loading text as soon as the first word arrives
815
- if (!firstTokenReceived) {
816
- bubble.innerHTML = "";
817
- firstTokenReceived = true;
818
- }
819
-
820
  fullAnswer += parsed.data;
821
- // Dynamically update the bubble with formatted markdown/paragraphs
822
  bubble.innerHTML = formatAnswer(fullAnswer);
823
  scrollToBottom();
824
  }
825
  else if (parsed.type === "sources") {
826
- sourcesData = parsed.data;
827
- renderSourcesInPlace(sourcesContainer, sourcesData);
828
  }
829
  else if (parsed.type === "cache") {
830
  bubble.innerHTML = formatAnswer(parsed.data.answer);
@@ -832,18 +707,24 @@
832
  scrollToBottom();
833
  }
834
  } catch (e) {
835
- console.error("Stream parsing error", e);
836
  }
837
  }
838
  }
839
 
840
- // Clean up IDs once done so next messages don't conflict
 
 
 
 
841
  bubble.removeAttribute("id");
842
  sourcesContainer.removeAttribute("id");
843
 
844
  } catch (err) {
845
- let msg = err.message;
846
- replaceLoadingWithError(loadingEl, msg);
 
 
847
  } finally {
848
  isLoading = false;
849
  document.getElementById("sendBtn").disabled = false;
@@ -851,27 +732,9 @@
851
  }
852
  }
853
 
854
- // Helper to render sources inside the streaming flow
855
- function renderSourcesInPlace(container, sources) {
856
- const sourceTags = (sources || []).map(s => {
857
- const cls = getSourceClass(s.book);
858
- // Use verse citations as the primary text
859
- return `<span class="source-tag ${cls}" title="${s.snippet}">πŸ“– ${s.book}</span>`;
860
- }).join("");
861
-
862
- if (sourceTags) {
863
- container.innerHTML = `
864
- <div class="sources">
865
- <div class="sources-label">Citations</div>
866
- <div class="source-tags">${sourceTags}</div>
867
- </div>
868
- `;
869
- }
870
- }
871
-
872
  function askSuggested(btn) {
873
  const input = document.getElementById("questionInput");
874
- input.value = btn.textContent;
875
  autoResize(input);
876
  sendQuestion();
877
  }
@@ -882,7 +745,9 @@
882
  sendQuestion();
883
  }
884
  }
 
 
 
885
  </script>
886
  </body>
887
-
888
  </html>
 
13
 
14
  <style>
15
  /* ── Reset & Base ─────────────────────────────────────────── */
16
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
 
 
 
 
 
 
17
 
18
  :root {
19
  --bg: #0d0b07;
 
26
  --cream: #f0e6cc;
27
  --muted: #7a6a4a;
28
  --gita: #e07b3b;
 
29
  --quran: #3bba85;
 
30
  --bible: #5b8ce0;
 
31
  --granth: #b07ce0;
32
+ --danger: #e06060;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  }
34
 
35
+ html, body {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  height: 100%;
37
  background: var(--bg);
38
  color: var(--cream);
 
42
  overflow: hidden;
43
  }
44
 
 
45
  body::before {
46
  content: '';
47
  position: fixed;
48
  inset: 0;
49
  background:
50
+ radial-gradient(ellipse 80% 60% at 20% 10%, rgba(201,153,58,.07) 0%, transparent 60%),
51
+ radial-gradient(ellipse 60% 80% at 80% 90%, rgba(91,140,224,.05) 0%, transparent 60%),
52
+ radial-gradient(ellipse 50% 50% at 50% 50%, rgba(176,124,224,.04) 0%, transparent 60%),
53
  url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='400'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='400' height='400' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E");
54
  pointer-events: none;
55
  z-index: 0;
 
69
 
70
  /* ── Header ───────────────────────────────────────────────── */
71
  header {
72
+ padding: 20px 0 14px;
73
  text-align: center;
74
  border-bottom: 1px solid var(--border);
75
+ position: relative;
76
  }
77
 
78
  .mandala {
79
+ font-size: 1.8rem;
80
  letter-spacing: .5rem;
81
  color: var(--gold);
82
  opacity: .6;
83
+ margin-bottom: 6px;
84
  animation: spin 60s linear infinite;
85
  display: inline-block;
86
  }
87
+ @keyframes spin { to { transform: rotate(360deg); } }
 
 
 
 
 
88
 
89
  h1 {
90
  font-family: 'Cinzel Decorative', serif;
91
+ font-size: clamp(1.1rem, 3vw, 1.7rem);
92
  font-weight: 400;
93
  color: var(--gold-pale);
94
  letter-spacing: .12em;
95
+ text-shadow: 0 0 40px rgba(201,153,58,.3);
96
  }
97
 
98
  .subtitle {
99
  font-family: 'IM Fell English', serif;
100
  font-style: italic;
101
+ font-size: .9rem;
102
  color: var(--muted);
103
+ margin-top: 3px;
104
  }
105
 
106
  .badges {
107
  display: flex;
108
  justify-content: center;
109
+ gap: 10px;
110
+ margin-top: 10px;
111
  flex-wrap: wrap;
112
  }
113
 
114
  .badge {
115
+ font-size: .7rem;
116
  letter-spacing: .1em;
117
  text-transform: uppercase;
118
+ padding: 2px 9px;
119
  border-radius: 20px;
120
  border: 1px solid;
121
  font-family: 'Cormorant Garamond', serif;
122
  font-weight: 600;
123
  }
124
+ .badge-gita { color: var(--gita); border-color: var(--gita); background: rgba(224,123,59,.1); }
125
+ .badge-quran { color: var(--quran); border-color: var(--quran); background: rgba(59,186,133,.1); }
126
+ .badge-bible { color: var(--bible); border-color: var(--bible); background: rgba(91,140,224,.1); }
127
+ .badge-granth { color: var(--granth); border-color: var(--granth); background: rgba(176,124,224,.1); }
128
 
129
+ /* ── Session bar ──────────────────────────────────────────── */
130
+ .session-bar {
131
+ display: none; /* hidden until a conversation starts */
132
+ align-items: center;
133
+ justify-content: space-between;
134
+ gap: 8px;
135
+ margin-top: 10px;
136
+ padding: 5px 10px;
137
+ border: 1px solid var(--border);
138
+ border-radius: 8px;
139
+ background: var(--surface);
140
+ font-size: .75rem;
141
+ color: var(--muted);
142
  }
143
 
144
+ .session-bar.visible { display: flex; }
145
+
146
+ .session-turn-count {
147
+ font-family: 'Cormorant Garamond', serif;
148
+ font-style: italic;
149
  }
150
 
151
+ .session-turn-count span {
152
+ color: var(--gold-light);
153
+ font-weight: 600;
 
154
  }
155
 
156
+ .new-convo-btn {
157
+ display: flex;
158
+ align-items: center;
159
+ gap: 5px;
160
+ background: none;
161
+ border: 1px solid var(--border);
162
+ color: var(--muted);
163
+ padding: 3px 10px;
164
+ border-radius: 6px;
165
+ font-family: 'Cormorant Garamond', serif;
166
+ font-size: .75rem;
167
+ cursor: pointer;
168
+ transition: all .2s;
169
+ }
170
+ .new-convo-btn:hover {
171
+ border-color: var(--danger);
172
+ color: var(--danger);
173
  }
174
 
175
  /* ── Chat Window ──────────────────────────────────────────── */
176
  .chat-window {
177
  overflow-y: auto;
178
+ padding: 24px 0;
179
  display: flex;
180
  flex-direction: column;
181
  gap: 24px;
182
  scrollbar-width: thin;
183
  scrollbar-color: var(--border) transparent;
184
  }
185
+ .chat-window::-webkit-scrollbar { width: 4px; }
186
+ .chat-window::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
 
 
 
 
 
 
 
187
 
188
  /* ── Welcome State ────────────────────────────────────────── */
189
  .welcome {
 
192
  padding: 20px;
193
  max-width: 500px;
194
  }
195
+ .welcome-icon { font-size: 3.2rem; margin-bottom: 14px; filter: drop-shadow(0 0 20px rgba(201,153,58,.4)); }
 
 
 
 
 
 
196
  .welcome h2 {
197
  font-family: 'IM Fell English', serif;
198
  font-style: italic;
199
+ font-size: 1.4rem;
200
  color: var(--gold-light);
201
+ margin-bottom: 8px;
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  }
203
+ .welcome p { font-size: .92rem; color: var(--muted); line-height: 1.8; }
204
 
205
+ .suggested-queries { margin-top: 20px; display: flex; flex-direction: column; gap: 7px; }
206
  .suggested-queries button {
207
  background: var(--surface);
208
  border: 1px solid var(--border);
209
  color: var(--cream);
210
+ padding: 9px 14px;
211
  border-radius: 8px;
212
  font-family: 'Cormorant Garamond', serif;
213
+ font-size: .92rem;
214
  font-style: italic;
215
  cursor: pointer;
216
  transition: all .2s;
217
  text-align: left;
218
  }
219
+ .suggested-queries button:hover { border-color: var(--gold); color: var(--gold-pale); background: var(--surface-2); }
 
 
 
 
 
220
 
221
  /* ── Messages ─────────────────────────────────────────────── */
222
  .message {
223
  display: flex;
224
  flex-direction: column;
225
+ gap: 6px;
226
  animation: fadeUp .4s ease both;
227
  }
228
+ @keyframes fadeUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
229
 
230
+ .message-user { align-items: flex-end; }
231
+ .message-assistant { align-items: flex-start; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
 
233
  .msg-label {
234
+ font-size: .68rem;
235
  letter-spacing: .15em;
236
  text-transform: uppercase;
237
  color: var(--muted);
 
241
 
242
  .msg-bubble {
243
  max-width: 92%;
244
+ padding: 14px 18px;
245
  border-radius: 12px;
246
  line-height: 1.75;
247
  }
 
251
  border: 1px solid var(--border);
252
  color: var(--cream);
253
  font-style: italic;
254
+ font-size: .97rem;
255
  border-bottom-right-radius: 4px;
256
  }
257
 
258
  .message-assistant .msg-bubble {
259
+ background: linear-gradient(135deg, var(--surface) 0%, rgba(30,26,17,.95) 100%);
260
+ border: 1px solid rgba(201,153,58,.2);
261
  color: var(--cream);
262
+ font-size: .97rem;
263
  border-bottom-left-radius: 4px;
264
+ box-shadow: 0 4px 24px rgba(0,0,0,.4), inset 0 1px 0 rgba(201,153,58,.1);
 
 
 
 
265
  }
266
 
267
+ .msg-bubble p { margin-bottom: 1em; }
268
+ .msg-bubble p:last-child { margin-bottom: 0; }
269
+ .msg-bubble strong { color: var(--gold-light); font-weight: 600; }
270
 
271
+ /* Follow-up continuation pill */
272
+ .followup-pill {
273
+ font-size: .68rem;
274
+ padding: 2px 8px;
275
+ border-radius: 10px;
276
+ background: rgba(201,153,58,.08);
277
+ border: 1px solid rgba(201,153,58,.2);
278
+ color: var(--muted);
279
+ margin-left: 6px;
280
+ font-style: italic;
281
+ vertical-align: middle;
282
  }
283
 
284
  /* ── Sources Panel ────────────────────────────────────────── */
285
+ .sources { max-width: 92%; margin-top: 4px; }
 
 
 
 
286
  .sources-label {
287
+ font-size: .7rem;
288
  letter-spacing: .12em;
289
  text-transform: uppercase;
290
  color: var(--muted);
 
293
  align-items: center;
294
  gap: 6px;
295
  }
296
+ .sources-label::before, .sources-label::after { content: ''; flex: 1; height: 1px; background: var(--border); }
297
+ .sources-label::before { max-width: 20px; }
298
 
299
+ .source-tags { display: flex; flex-wrap: wrap; gap: 6px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
  .source-tag {
301
+ font-size: .76rem;
302
  padding: 4px 10px;
303
  border-radius: 6px;
304
  border: 1px solid;
 
306
  cursor: default;
307
  transition: all .2s;
308
  }
309
+ .source-tag:hover { transform: translateY(-1px); filter: brightness(1.2); }
310
 
311
+ .source-gita { color: var(--gita); border-color: rgba(224,123,59,.4); background: rgba(224,123,59,.08); }
312
+ .source-quran { color: var(--quran); border-color: rgba(59,186,133,.4); background: rgba(59,186,133,.08); }
313
+ .source-bible { color: var(--bible); border-color: rgba(91,140,224,.4); background: rgba(91,140,224,.08); }
314
+ .source-granth { color: var(--granth); border-color: rgba(176,124,224,.4); background: rgba(176,124,224,.08); }
315
+ .source-other { color: var(--gold-light); border-color: rgba(201,153,58,.4); background: rgba(201,153,58,.08); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
 
317
  /* ── Loading ──────────────────────────────────────────────── */
318
  .loading {
319
  display: flex;
320
  align-items: center;
321
+ gap: 10px;
322
+ padding: 12px 16px;
323
+ border: 1px solid rgba(201,153,58,.15);
324
  border-radius: 12px;
325
  background: var(--surface);
326
  width: fit-content;
327
  max-width: 280px;
328
  }
329
+ .loading-dots { display: flex; gap: 5px; }
 
 
 
 
 
330
  .loading-dots span {
331
+ width: 6px; height: 6px;
 
332
  border-radius: 50%;
333
  background: var(--gold);
334
  animation: dot-pulse 1.4s ease-in-out infinite;
335
  }
336
+ .loading-dots span:nth-child(2) { animation-delay: .2s; }
337
+ .loading-dots span:nth-child(3) { animation-delay: .4s; }
 
 
 
 
 
 
 
338
  @keyframes dot-pulse {
339
+ 0%,80%,100% { opacity: .2; transform: scale(.8); }
340
+ 40% { opacity: 1; transform: scale(1.1); }
 
 
 
 
 
 
 
 
 
 
341
  }
342
+ .loading-text { font-size: .82rem; font-style: italic; color: var(--muted); }
343
 
344
+ /* ── Thinking dots (streaming) ────────────────────────────── */
345
+ .thinking-dots { display: inline-flex; gap: 4px; margin-left: 4px; }
346
+ .thinking-dots span {
347
+ width: 4px; height: 4px;
348
+ background: var(--gold);
349
+ border-radius: 50%;
350
+ animation: bounce 1.4s infinite ease-in-out;
351
  }
352
+ @keyframes bounce { 0%,80%,100% { transform: scale(0); } 40% { transform: scale(1); } }
353
 
354
  /* ── Error ────────────────────────────────────────────────── */
355
  .error-bubble {
356
+ background: rgba(180,60,60,.1);
357
+ border: 1px solid rgba(180,60,60,.3);
358
  color: #e08080;
359
  padding: 12px 16px;
360
  border-radius: 10px;
 
363
  }
364
 
365
  /* ── Input Area ───────────────────────────────────────────── */
366
+ .input-area { padding: 14px 0 22px; border-top: 1px solid var(--border); }
367
+ .input-row { display: flex; gap: 10px; align-items: flex-end; }
 
 
 
 
 
 
 
 
368
 
369
  textarea {
370
  flex: 1;
371
  background: var(--surface);
372
  border: 1px solid var(--border);
373
  color: var(--cream);
374
+ padding: 13px 15px;
375
  border-radius: 12px;
376
  font-family: 'Cormorant Garamond', serif;
377
+ font-size: .97rem;
378
  line-height: 1.6;
379
  resize: none;
380
+ min-height: 50px;
381
+ max-height: 130px;
382
  outline: none;
383
  transition: border-color .2s, box-shadow .2s;
384
  }
385
+ textarea::placeholder { color: var(--muted); font-style: italic; }
 
 
 
 
 
386
  textarea:focus {
387
+ border-color: rgba(201,153,58,.5);
388
+ box-shadow: 0 0 0 3px rgba(201,153,58,.08);
389
  }
390
 
391
  .send-btn {
392
+ width: 50px; height: 50px;
 
393
  border-radius: 12px;
394
+ border: 1px solid rgba(201,153,58,.4);
395
+ background: linear-gradient(135deg, rgba(201,153,58,.2), rgba(201,153,58,.05));
396
  color: var(--gold);
397
+ font-size: 1.25rem;
398
  cursor: pointer;
399
  transition: all .2s;
400
  display: flex;
 
402
  justify-content: center;
403
  flex-shrink: 0;
404
  }
 
405
  .send-btn:hover:not(:disabled) {
406
+ background: linear-gradient(135deg, rgba(201,153,58,.35), rgba(201,153,58,.15));
407
  border-color: var(--gold);
408
  transform: translateY(-1px);
409
+ box-shadow: 0 4px 16px rgba(201,153,58,.2);
410
  }
411
+ .send-btn:disabled { opacity: .3; cursor: not-allowed; transform: none; }
412
 
413
+ .input-hint { font-size: .7rem; color: var(--muted); margin-top: 7px; text-align: center; font-style: italic; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
414
  </style>
415
  </head>
416
 
 
428
  <span class="badge badge-bible">Bible</span>
429
  <span class="badge badge-granth">Guru Granth Sahib</span>
430
  </div>
431
+
432
+ <!-- Session status bar β€” visible once conversation starts -->
433
+ <div class="session-bar" id="sessionBar">
434
+ <span class="session-turn-count" id="turnCountLabel">
435
+ Turn <span id="turnCount">0</span>
436
+ </span>
437
+ <button class="new-convo-btn" onclick="startNewConversation()" title="Clear history and start fresh">
438
+ β†Ί New Conversation
439
+ </button>
440
+ </div>
441
  </header>
442
 
443
  <!-- Chat Window -->
 
445
  <div class="welcome" id="welcomePane">
446
  <div class="welcome-icon">πŸ•ŠοΈ</div>
447
  <h2>"Seek, and it shall be given unto you"</h2>
448
+ <p>Ask any spiritual or philosophical question. Answers are drawn exclusively from the
449
+ Bhagavad Gita, Quran, Bible, and Guru Granth Sahib.<br><br>
450
+ <em style="color:var(--gold-light); font-size:.9rem;">
451
+ You can now ask follow-up questions β€” the guide remembers the conversation.
452
+ </em>
453
+ </p>
454
  <div class="suggested-queries">
455
  <button onclick="askSuggested(this)">What do the scriptures say about forgiveness?</button>
456
  <button onclick="askSuggested(this)">How should one face fear and death?</button>
457
  <button onclick="askSuggested(this)">What is the purpose of prayer and worship?</button>
458
  <button onclick="askSuggested(this)">What is the nature of the soul according to each religion?</button>
459
+ <button onclick="askSuggested(this)">What do the scriptures teach about humility and selfless service?</button>
 
460
  </div>
461
  </div>
462
  </div>
 
464
  <!-- Input -->
465
  <div class="input-area">
466
  <div class="input-row">
467
+ <textarea id="questionInput"
468
+ placeholder="Ask a question, or follow up on the previous answer…"
469
+ rows="1"
470
+ onkeydown="handleKey(event)"
471
+ oninput="autoResize(this)"></textarea>
472
+ <button class="send-btn" id="sendBtn" onclick="sendQuestion()" title="Ask (Enter)">✦</button>
473
  </div>
474
+ <p class="input-hint">Enter to ask Β· Shift+Enter for new line Β· Follow-ups like "elaborate on point 2" work!</p>
 
475
  </div>
476
 
477
  </div>
478
 
479
  <script>
480
  const API_BASE = window.location.origin;
481
+ let isLoading = false;
482
+ let sessionId = null; // persisted across the page session
483
+ let turnCount = 0; // how many full turns this session
484
+
485
+ // ── Session helpers ────────────────────────────────────────
486
+ function loadSession() {
487
+ sessionId = localStorage.getItem("rag_session_id") || null;
488
+ }
489
+
490
+ function saveSession(id) {
491
+ sessionId = id;
492
+ localStorage.setItem("rag_session_id", id);
493
+ }
494
 
495
+ function updateSessionBar() {
496
+ const bar = document.getElementById("sessionBar");
497
+ const count = document.getElementById("turnCount");
498
+ if (turnCount > 0) {
499
+ bar.classList.add("visible");
500
+ count.textContent = turnCount;
501
+ } else {
502
+ bar.classList.remove("visible");
503
+ }
504
+ }
505
+
506
+ async function startNewConversation() {
507
+ if (!sessionId) return;
508
+ if (turnCount > 0 && !confirm("Start a new conversation? This will clear all history.")) return;
509
+
510
+ try {
511
+ await fetch(`${API_BASE}/clear`, {
512
+ method: "POST",
513
+ headers: { "Content-Type": "application/json" },
514
+ body: JSON.stringify({ session_id: sessionId }),
515
+ });
516
+ } catch (_) {}
517
+
518
+ // Reset everything
519
+ sessionId = null;
520
+ turnCount = 0;
521
+ localStorage.removeItem("rag_session_id");
522
+ updateSessionBar();
523
+
524
+ const chatWindow = document.getElementById("chatWindow");
525
+ chatWindow.innerHTML = `
526
+ <div class="welcome" id="welcomePane">
527
+ <div class="welcome-icon">πŸ•ŠοΈ</div>
528
+ <h2>"Seek, and it shall be given unto you"</h2>
529
+ <p>Ask any spiritual or philosophical question. Answers are drawn exclusively from the
530
+ Bhagavad Gita, Quran, Bible, and Guru Granth Sahib.<br><br>
531
+ <em style="color:var(--gold-light); font-size:.9rem;">
532
+ You can now ask follow-up questions β€” the guide remembers the conversation.
533
+ </em>
534
+ </p>
535
+ <div class="suggested-queries">
536
+ <button onclick="askSuggested(this)">What do the scriptures say about forgiveness?</button>
537
+ <button onclick="askSuggested(this)">How should one face fear and death?</button>
538
+ <button onclick="askSuggested(this)">What is the purpose of prayer and worship?</button>
539
+ <button onclick="askSuggested(this)">What is the nature of the soul according to each religion?</button>
540
+ <button onclick="askSuggested(this)">What do the scriptures teach about humility and selfless service?</button>
541
+ </div>
542
+ </div>`;
543
+ }
544
+
545
+ // ── DOM Helpers ────────────────────────────────────────────
546
  function getSourceClass(book) {
547
  const b = book.toLowerCase();
548
+ if (b.includes("gita")) return "source-gita";
549
  if (b.includes("quran") || b.includes("koran")) return "source-quran";
550
  if (b.includes("bible") || b.includes("testament")) return "source-bible";
551
  if (b.includes("granth") || b.includes("guru")) return "source-granth";
 
564
 
565
  function autoResize(el) {
566
  el.style.height = "auto";
567
+ el.style.height = Math.min(el.scrollHeight, 130) + "px";
568
  }
569
 
570
  function formatAnswer(text) {
 
571
  text = text.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>");
 
572
  return text.split(/\n\n+/).filter(p => p.trim()).map(p => `<p>${p.trim()}</p>`).join("");
573
  }
574
 
575
+ function escapeHtml(str) {
576
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
577
+ }
578
+
579
+ // ── Message rendering ──────────────────────────────────────
580
+ function appendUserMessage(question, isFollowup) {
581
  const w = document.getElementById("chatWindow");
582
  const div = document.createElement("div");
583
  div.className = "message message-user";
584
+ const pill = isFollowup
585
+ ? `<span class="followup-pill">follow-up</span>`
586
+ : "";
587
  div.innerHTML = `
588
+ <span class="msg-label">You${pill}</span>
589
  <div class="msg-bubble">${escapeHtml(question)}</div>
590
  `;
591
  w.appendChild(div);
 
609
  return div;
610
  }
611
 
612
+ function renderSourcesInPlace(container, sources) {
613
+ const sourceTags = (sources || []).map(s => {
 
 
 
614
  const cls = getSourceClass(s.book);
615
+ return `<span class="source-tag ${cls}" title="${escapeHtml(s.snippet || '')}">πŸ“– ${escapeHtml(s.book)}</span>`;
616
  }).join("");
617
+ if (sourceTags) {
618
+ container.innerHTML = `
619
+ <div class="sources">
620
+ <div class="sources-label">Citations</div>
621
+ <div class="source-tags">${sourceTags}</div>
622
+ </div>`;
623
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
624
  }
625
 
626
+ // ── Core send flow ─────────────────────────────────────────
627
  async function sendQuestion() {
628
  if (isLoading) return;
629
+ const input = document.getElementById("questionInput");
630
  const question = input.value.trim();
631
  if (!question) return;
632
 
633
  hideWelcome();
634
+ const isFollowup = turnCount > 0;
635
+
636
  isLoading = true;
637
  document.getElementById("sendBtn").disabled = true;
638
  input.value = "";
639
  input.style.height = "auto";
640
 
641
+ appendUserMessage(question, isFollowup);
642
  const loadingEl = appendLoading();
643
 
644
  try {
645
+ const payload = { question };
646
+ if (sessionId) payload.session_id = sessionId;
647
+
648
  const res = await fetch(`${API_BASE}/ask`, {
649
+ method: "POST",
650
  headers: { "Content-Type": "application/json" },
651
+ body: JSON.stringify(payload),
652
  });
653
 
654
  if (!res.ok) {
 
656
  throw new Error(err.detail || "Server error");
657
  }
658
 
659
+ // Capture session ID returned by the server
660
+ const returnedSession = res.headers.get("X-Session-Id");
661
+ if (returnedSession) saveSession(returnedSession);
 
 
662
 
663
+ // Set up streaming bubble
664
  loadingEl.innerHTML = `
665
+ <span class="msg-label">Sacred Texts</span>
666
+ <div class="msg-bubble" id="currentStreamingMsg">
667
+ <div class="loading-text">The scriptures are being revealed
668
+ <span class="thinking-dots"><span></span><span></span><span></span></span>
669
+ </div>
670
+ </div>
671
+ <div id="currentStreamingSources"></div>`;
672
+
673
+ const bubble = document.getElementById("currentStreamingMsg");
674
  const sourcesContainer = document.getElementById("currentStreamingSources");
675
+ let fullAnswer = "";
676
+ let buffer = "";
677
+ let firstToken = false;
678
+
679
+ const reader = res.body.getReader();
680
+ const decoder = new TextDecoder();
681
 
682
  while (true) {
683
  const { done, value } = await reader.read();
684
  if (done) break;
685
 
 
686
  buffer += decoder.decode(value, { stream: true });
 
 
687
  const lines = buffer.split("\n");
688
+ buffer = lines.pop(); // keep incomplete line in buffer
 
 
689
 
690
  for (const line of lines) {
691
  if (!line.trim()) continue;
 
693
  const parsed = JSON.parse(line);
694
 
695
  if (parsed.type === "token") {
696
+ if (!firstToken) { bubble.innerHTML = ""; firstToken = true; }
 
 
 
 
 
697
  fullAnswer += parsed.data;
 
698
  bubble.innerHTML = formatAnswer(fullAnswer);
699
  scrollToBottom();
700
  }
701
  else if (parsed.type === "sources") {
702
+ renderSourcesInPlace(sourcesContainer, parsed.data);
 
703
  }
704
  else if (parsed.type === "cache") {
705
  bubble.innerHTML = formatAnswer(parsed.data.answer);
 
707
  scrollToBottom();
708
  }
709
  } catch (e) {
710
+ console.warn("Stream parse error:", e);
711
  }
712
  }
713
  }
714
 
715
+ // Increment turn counter
716
+ turnCount++;
717
+ updateSessionBar();
718
+
719
+ // Clean up streaming IDs
720
  bubble.removeAttribute("id");
721
  sourcesContainer.removeAttribute("id");
722
 
723
  } catch (err) {
724
+ loadingEl.innerHTML = `
725
+ <span class="msg-label">Error</span>
726
+ <div class="error-bubble">⚠️ ${escapeHtml(err.message)}</div>`;
727
+ scrollToBottom();
728
  } finally {
729
  isLoading = false;
730
  document.getElementById("sendBtn").disabled = false;
 
732
  }
733
  }
734
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
735
  function askSuggested(btn) {
736
  const input = document.getElementById("questionInput");
737
+ input.value = btn.textContent.trim();
738
  autoResize(input);
739
  sendQuestion();
740
  }
 
745
  sendQuestion();
746
  }
747
  }
748
+
749
+ // ── Init ───────────────────────────────────────────────────
750
+ loadSession();
751
  </script>
752
  </body>
 
753
  </html>
rag_chain.py CHANGED
@@ -1,43 +1,38 @@
1
  """
2
- rag_chain.py β€” Core RAG chain using LangChain + Gemini.
3
-
4
- KEY FIX: Uses per-book retrieval (guaranteed slots per scripture) instead of
5
- a single similarity search β€” so no book gets starved from the context window
6
- when the query is semantically closer to another book's language.
7
-
8
- This module exposes a single function:
9
- answer = query_sacred_texts(user_question)
10
-
11
- Returns a dict with:
12
- {
13
- "answer": "...",
14
- "sources": [
15
- {"book": "Bhagavad Gita", "page": 42, "snippet": "..."},
16
- ...
17
- ]
18
- }
19
  """
20
 
21
  import os
22
- from pydoc import doc
23
  from dotenv import load_dotenv
24
  from langchain_nvidia_ai_endpoints import NVIDIAEmbeddings, ChatNVIDIA, NVIDIARerank
25
  from langchain_chroma import Chroma
26
- from langchain_core.prompts import ChatPromptTemplate
27
  from langchain_core.output_parsers import StrOutputParser
 
28
  from langchain_community.retrievers import BM25Retriever
29
  from langchain_classic.retrievers import EnsembleRetriever, ContextualCompressionRetriever
30
- load_dotenv()
31
- import json
32
 
33
- NVIDIA_API_KEY = os.getenv("NVIDIA_API_KEY")
34
- CHROMA_DB_PATH = os.getenv("CHROMA_DB_PATH", "./chroma_db")
35
- COLLECTION_NAME = os.getenv("COLLECTION_NAME", "sacred_texts")
36
 
37
- # Chunks retrieved PER BOOK β€” guarantees every scripture contributes to the answer
38
- CHUNKS_PER_BOOK = int(os.getenv("CHUNKS_PER_BOOK", "3"))
 
 
 
 
39
 
40
- # All books currently in the knowledge base β€” add new books here as you ingest them
41
  KNOWN_BOOKS = [
42
  "Bhagavad Gita",
43
  "Quran",
@@ -45,8 +40,32 @@ KNOWN_BOOKS = [
45
  "Guru Granth Sahib",
46
  ]
47
 
48
- # Create a separate collection for semantic cache
49
- CACHE_COLLECTION = "semantic_cache"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
  # ─── System Prompt ────────────────────────────────────────────────────────────
52
 
@@ -62,6 +81,10 @@ STRICT RULES you must ALWAYS follow:
62
  address EACH of those books separately, then synthesise the common thread.
63
  6. Be respectful and neutral toward all faiths β€” treat each text with equal reverence.
64
  7. Do NOT speculate, invent verses, or add information beyond the context.
 
 
 
 
65
 
66
  FORMAT your response as:
67
  - A clear, thoughtful answer (2–4 paragraphs)
@@ -73,8 +96,6 @@ Context passages from the sacred texts (guaranteed passages from each book):
73
  ────────────────────────────────────────
74
  """
75
 
76
- HUMAN_PROMPT = "Question: {question}"
77
-
78
 
79
  # ─── Embeddings & Vector Store ────────────────────────────────────────────────
80
 
@@ -94,42 +115,17 @@ def get_vector_store(embeddings):
94
  )
95
 
96
 
97
- # ─── Per-Book Retrieval ───────────────────────────────────────────────────────
98
-
99
- def get_reranked_retriever(base_retriever):
100
- """
101
- Wraps your Hybrid/Per-Book retriever with a Reranking layer.
102
- """
103
- # 1. Initialize the NVIDIA Reranker (NIM or API Catalog)
104
- # Using nvidia/llama-3.2-nv-rerankqa-1b-v2 or similar
105
- reranker = NVIDIARerank(
106
- model="nvidia/llama-3.2-nv-rerankqa-1b-v2",
107
- api_key=NVIDIA_API_KEY,
108
- top_n=5 # Only send the top 5 most relevant chunks to the LLM
109
- )
110
-
111
- # 2. Wrap the base retriever
112
- compression_retriever = ContextualCompressionRetriever(
113
- base_compressor=reranker,
114
- base_retriever=base_retriever
115
- )
116
-
117
- return compression_retriever
118
 
119
  def retrieve_per_book(question: str, vector_store: Chroma) -> list:
120
  """
121
- Retrieve CHUNKS_PER_BOOK chunks from EACH known book independently,
122
- using a metadata filter. This guarantees every scripture is represented
123
- in the context β€” no book can be crowded out by higher-scoring chunks
124
- from another book.
125
  """
126
  all_candidates = []
127
-
128
- # Detect if user is asking about a specific book
129
- target_books = []
130
  question_lower = question.lower()
131
-
132
- # Check for keywords in the question
133
  if any(kw in question_lower for kw in ["gita", "bhagavad", "hindu", "hinduism"]):
134
  target_books.append("Bhagavad Gita")
135
  if any(kw in question_lower for kw in ["quran", "koran", "islam", "muslim", "muhammad"]):
@@ -138,63 +134,52 @@ def retrieve_per_book(question: str, vector_store: Chroma) -> list:
138
  target_books.append("Bible")
139
  if any(kw in question_lower for kw in ["granth", "guru", "sikh", "sikhism", "nanak"]):
140
  target_books.append("Guru Granth Sahib")
141
-
142
- # If no specific book is detected, use all books
143
  books_to_search = target_books if target_books else KNOWN_BOOKS
144
-
145
  print(f"🎯 Routing query to: {books_to_search}")
146
-
 
 
147
  for book in books_to_search:
148
  try:
149
- # Increase k for the base retrieval to 10
150
- CANDIDATE_COUNT = 10
151
-
152
- # Get the full collection of documents for this book to build BM25
153
- # For small demo, we can pull into memory; for larger corpora, consider a more efficient approach
154
  book_data = vector_store.get(where={"book": book})
155
- book_docs = []
156
- from langchain_core.documents import Document
157
- book_docs = [Document(page_content=d, metadata=m)
158
- for d, m in zip(book_data["documents"], book_data["metadatas"])]
159
  if not book_docs:
160
  continue
161
-
162
-
163
- # Setup BM25
164
  bm25_retriever = BM25Retriever.from_documents(book_docs)
165
  bm25_retriever.k = CANDIDATE_COUNT
166
-
167
-
168
- # Setup vector retriever
169
- vector_retriever = vector_store.as_retriever(search_kwargs={"k": CANDIDATE_COUNT, "filter": {"book": book}})
170
-
171
-
172
- # Combine into ensemble retriever
173
- ensemble_retriver = EnsembleRetriever(retrievers=[bm25_retriever, vector_retriever], weights=[0.5, 0.5])
174
-
175
- # Colect candidates without reranking yet
176
- book_candidates = ensemble_retriver.invoke(question)
177
  all_candidates.extend(book_candidates)
178
- print(f" πŸ“¦ {book}: Found {len(book_candidates)} candidates")
179
-
180
  except Exception as e:
181
  print(f" ❌ {book}: retrieval error β€” {e}")
182
-
183
-
184
- # Rerank the entire pool at once
185
  if not all_candidates:
186
  return []
187
-
188
  print(f"πŸš€ Reranking {len(all_candidates)} total candidates...")
189
  reranker = NVIDIARerank(
190
- model="nvidia/llama-3.2-nv-rerankqa-1b-v2",
191
  api_key=NVIDIA_API_KEY,
192
- top_n=5 # Final count for LLM context
193
  )
194
-
195
- # Use the reranker directly to compress the full list
196
- final_docs = reranker.compress_documents(all_candidates, question)
197
-
198
  for i, doc in enumerate(final_docs):
199
  score = doc.metadata.get("relevance_score", "N/A")
200
  print(f"Rank {i+1} [{doc.metadata['book']}]: Score {score}")
@@ -205,11 +190,6 @@ def retrieve_per_book(question: str, vector_store: Chroma) -> list:
205
  # ─── Format Retrieved Docs ────────────────────────────────────────────────────
206
 
207
  def format_docs(docs: list) -> str:
208
- """
209
- Format retrieved documents grouped by book for clarity.
210
- Each chunk is labelled with book and page number.
211
- """
212
- # Group by book to keep context readable
213
  by_book: dict[str, list] = {}
214
  for doc in docs:
215
  book = doc.metadata.get("book", "Unknown")
@@ -220,19 +200,16 @@ def format_docs(docs: list) -> str:
220
  header = f"═══ {book} ═══"
221
  chunks = []
222
  for i, doc in enumerate(book_docs, 1):
223
- page = doc.metadata.get("page", "?")
224
- ch = doc.metadata.get("chapter")
225
- vs = doc.metadata.get("verse")
226
  ang = doc.metadata.get("ang")
227
-
228
- # Create a clean citation string
229
  if ang:
230
  citation = f"Ang {ang}"
231
  elif ch and vs:
232
  citation = f"{ch}:{vs}"
233
  else:
234
  citation = f"Page {doc.metadata.get('page', '?')}"
235
- chunks.append(f" [{i}] ({citation}): {doc.page_content.strip()}")
236
  sections.append(header + "\n" + "\n\n".join(chunks))
237
 
238
  return "\n\n".join(sections)
@@ -241,8 +218,7 @@ def format_docs(docs: list) -> str:
241
  # ─── Build the RAG Chain ──────────────────────────────────────────────────────
242
 
243
  def build_chain():
244
- """Build and return the LLM chain and vector store."""
245
- embeddings = get_embeddings()
246
  vector_store = get_vector_store(embeddings)
247
 
248
  llm = ChatNVIDIA(
@@ -253,137 +229,141 @@ def build_chain():
253
  max_output_tokens=2048,
254
  )
255
 
 
256
  prompt = ChatPromptTemplate.from_messages([
257
  ("system", SYSTEM_PROMPT),
258
- ("human", HUMAN_PROMPT),
 
259
  ])
260
 
261
- # Chain: prompt β†’ LLM β†’ string output
262
- # (retrieval is handled manually in query_sacred_texts for per-book control)
263
  llm_chain = prompt | llm | StrOutputParser()
264
-
265
  return llm_chain, vector_store
266
 
267
 
268
- # ─── Public API ───────────────────────────────────────────────────────────────
269
 
270
- _llm_chain = None
271
  _vector_store = None
272
 
273
 
274
- def query_sacred_texts(question: str):
275
- """
276
- Query the sacred texts knowledge base with guaranteed per-book retrieval.
277
 
278
- Args:
279
- question: The user's spiritual/philosophical question.
 
 
280
 
281
- Returns:
282
- {
283
- "answer": str,
284
- "sources": list[dict] # [{book, page, snippet}, ...]
285
- }
286
  """
287
  global _llm_chain, _vector_store
288
 
289
  if _llm_chain is None:
290
  print("πŸ”§ Initialising RAG chain (first call)...")
291
  _llm_chain, _vector_store = build_chain()
292
-
293
- # --- Semantic cache check ---
294
- cache_coll = _vector_store._client.get_or_create_collection(CACHE_COLLECTION)
295
- cache_results = cache_coll.query(
296
- query_texts=[question],
297
- n_results=1
298
- )
299
 
300
- THRESHOLD = 0.35
301
- # FIXED: Added check for cache_results['ids'] and ensuring distances is not empty
302
- if cache_results['ids'] and cache_results['ids'][0]:
303
- distance = cache_results['distances'][0][0]
304
- if distance < THRESHOLD: # Similarity threshold
305
- print(f"⚑️ Semantic Cache Hit! (Distance: {distance:.4f})")
306
- yield json.dumps({"type": "cache","data": json.loads(cache_results['metadatas'][0][0]['response_json'])}) + "\n"
307
- return
308
-
309
- # Step 1: Retrieve per-book (guaranteed slots for every scripture)
310
- print(f"\nπŸ” Retrieving {CHUNKS_PER_BOOK} chunks per book for: '{question}'")
311
- source_docs = retrieve_per_book(question, _vector_store)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
 
313
  if not source_docs:
314
  yield json.dumps({"type": "token", "data": "No content found in the knowledge base."}) + "\n"
315
  return
316
 
317
- # 3. Step 2: Format sources for the UI immediately
318
- seen_sources = set()
319
  sources = []
320
  for doc in source_docs:
321
  book = doc.metadata.get("book", "Unknown")
322
- ch = doc.metadata.get("chapter")
323
- vs = doc.metadata.get("verse")
324
- ang = doc.metadata.get("ang")
325
-
326
  if ang:
327
  cite_val = f"Ang {ang}"
328
  elif ch and vs:
329
  cite_val = f"{ch}:{vs}"
330
  else:
331
  cite_val = f"p. {doc.metadata.get('page', '?')}"
332
-
333
  display_name = f"{book} {cite_val}"
334
  snippet = doc.page_content[:200].strip() + "..."
335
  if display_name not in seen_sources:
336
  seen_sources.add(display_name)
337
- print("Display name:", display_name)
338
- print("Page:", cite_val)
339
  sources.append({"book": display_name, "page": cite_val, "snippet": snippet})
340
- # Step 2: Format context grouped by book
341
- context = format_docs(source_docs)
342
- full_answer =""
343
 
344
- # Step 3: Stream from the chain:
345
- for chunk in _llm_chain.invoke({"context": context, "question": question}):
 
 
 
 
 
 
 
346
  full_answer += chunk
347
- yield json.dumps({"type": "token", "data": chunk}) + "\n" # Stream the answer as it's generated
348
-
349
-
350
- # Filter sources to only those the LLM actually referenced
351
- final_sources = []
352
- ansnwer_lower = full_answer.lower()
353
-
354
- for s in sources:
355
- if s["book"].lower() in ansnwer_lower:
356
- final_sources.append(s)
357
-
358
- # If the LLM didn't explicitly reference any sources, we can optionally include all retrieved ones or none
359
- display_sources = final_sources if final_sources else []
360
-
361
- # Step 4: After streaming is done, save to semantic cache for future similar queries
362
- result = {
363
- "answer": full_answer,
364
- "sources": display_sources,
365
- }
366
-
367
-
368
- cache_coll.add(
369
- documents=[question],
370
- metadatas=[{"response_json": json.dumps(result)}],
371
- ids=[question]
372
- )
373
-
374
- # Send sources as a final message after the answer is fully streamed
375
  yield json.dumps({"type": "sources", "data": sources}) + "\n"
376
-
377
 
378
 
379
  # ─── Quick CLI Test ───────────────────────────────────────────────────────────
380
 
381
  if __name__ == "__main__":
382
- test_q = "In what aspects do the Quran and Gita teach the same thing?"
383
  print(f"\nπŸ” Test query: {test_q}\n")
384
- result = query_sacred_texts(test_q)
385
- print("πŸ“ Answer:\n")
386
- print(result["answer"])
387
- print("\nπŸ“š Sources retrieved:")
388
- for s in result["sources"]:
389
- print(f" - {s['book']} (page {s['page']})")
 
1
  """
2
+ rag_chain.py β€” Core RAG chain using LangChain + NVIDIA.
3
+
4
+ KEY FEATURES:
5
+ - Per-book retrieval (guaranteed slots per scripture)
6
+ - Hybrid BM25 + vector search with NVIDIA reranking
7
+ - Semantic cache for repeated/similar questions
8
+ - Multi-turn conversation memory (session-based ConversationBufferMemory)
9
+
10
+ Public API:
11
+ query_sacred_texts(question, session_id) -> Generator[str, None, None]
12
+ clear_session(session_id)
 
 
 
 
 
 
13
  """
14
 
15
  import os
16
+ import json
17
  from dotenv import load_dotenv
18
  from langchain_nvidia_ai_endpoints import NVIDIAEmbeddings, ChatNVIDIA, NVIDIARerank
19
  from langchain_chroma import Chroma
20
+ from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
21
  from langchain_core.output_parsers import StrOutputParser
22
+ from langchain_core.messages import HumanMessage, AIMessage
23
  from langchain_community.retrievers import BM25Retriever
24
  from langchain_classic.retrievers import EnsembleRetriever, ContextualCompressionRetriever
25
+ from langchain_core.documents import Document
 
26
 
27
+ load_dotenv()
 
 
28
 
29
+ NVIDIA_API_KEY = os.getenv("NVIDIA_API_KEY")
30
+ CHROMA_DB_PATH = os.getenv("CHROMA_DB_PATH", "./chroma_db")
31
+ COLLECTION_NAME = os.getenv("COLLECTION_NAME", "sacred_texts")
32
+ CHUNKS_PER_BOOK = int(os.getenv("CHUNKS_PER_BOOK", "3"))
33
+ CACHE_COLLECTION = "semantic_cache"
34
+ MAX_HISTORY_TURNS = int(os.getenv("MAX_HISTORY_TURNS", "6")) # last N human+AI pairs kept
35
 
 
36
  KNOWN_BOOKS = [
37
  "Bhagavad Gita",
38
  "Quran",
 
40
  "Guru Granth Sahib",
41
  ]
42
 
43
+ # ─── In-memory session store ──────────────────────────────────────────────────
44
+ # { session_id: [HumanMessage | AIMessage, ...] }
45
+ _session_store: dict[str, list] = {}
46
+
47
+
48
+ def get_history(session_id: str) -> list:
49
+ return _session_store.get(session_id, [])
50
+
51
+
52
+ def append_turn(session_id: str, human_msg: str, ai_msg: str):
53
+ history = _session_store.setdefault(session_id, [])
54
+ history.append(HumanMessage(content=human_msg))
55
+ history.append(AIMessage(content=ai_msg))
56
+ # Trim to last MAX_HISTORY_TURNS pairs (each pair = 2 messages)
57
+ if len(history) > MAX_HISTORY_TURNS * 2:
58
+ _session_store[session_id] = history[-(MAX_HISTORY_TURNS * 2):]
59
+
60
+
61
+ def clear_session(session_id: str):
62
+ """Wipe the conversation history for a session."""
63
+ _session_store.pop(session_id, None)
64
+
65
+
66
+ def list_sessions() -> list[str]:
67
+ return list(_session_store.keys())
68
+
69
 
70
  # ─── System Prompt ────────────────────────────────────────────────────────────
71
 
 
81
  address EACH of those books separately, then synthesise the common thread.
82
  6. Be respectful and neutral toward all faiths β€” treat each text with equal reverence.
83
  7. Do NOT speculate, invent verses, or add information beyond the context.
84
+ 8. You have access to the conversation history. Use it to:
85
+ - Understand follow-up questions (e.g. "elaborate on the second point", "what about the Bible?")
86
+ - Maintain continuity across turns without repeating yourself unnecessarily
87
+ - Resolve pronouns and references ("it", "that teaching", "the verse you mentioned") from history
88
 
89
  FORMAT your response as:
90
  - A clear, thoughtful answer (2–4 paragraphs)
 
96
  ────────────────────────────────────────
97
  """
98
 
 
 
99
 
100
  # ─── Embeddings & Vector Store ────────────────────────────────────────────────
101
 
 
115
  )
116
 
117
 
118
+ # ─── Per-Book Hybrid Retrieval ────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
 
120
  def retrieve_per_book(question: str, vector_store: Chroma) -> list:
121
  """
122
+ Retrieve CHUNKS_PER_BOOK chunks from EACH known book independently using
123
+ a hybrid BM25+vector ensemble, then rerank the pooled candidates.
 
 
124
  """
125
  all_candidates = []
 
 
 
126
  question_lower = question.lower()
127
+
128
+ target_books = []
129
  if any(kw in question_lower for kw in ["gita", "bhagavad", "hindu", "hinduism"]):
130
  target_books.append("Bhagavad Gita")
131
  if any(kw in question_lower for kw in ["quran", "koran", "islam", "muslim", "muhammad"]):
 
134
  target_books.append("Bible")
135
  if any(kw in question_lower for kw in ["granth", "guru", "sikh", "sikhism", "nanak"]):
136
  target_books.append("Guru Granth Sahib")
137
+
 
138
  books_to_search = target_books if target_books else KNOWN_BOOKS
 
139
  print(f"🎯 Routing query to: {books_to_search}")
140
+
141
+ CANDIDATE_COUNT = 10
142
+
143
  for book in books_to_search:
144
  try:
 
 
 
 
 
145
  book_data = vector_store.get(where={"book": book})
146
+ book_docs = [
147
+ Document(page_content=d, metadata=m)
148
+ for d, m in zip(book_data["documents"], book_data["metadatas"])
149
+ ]
150
  if not book_docs:
151
  continue
152
+
 
 
153
  bm25_retriever = BM25Retriever.from_documents(book_docs)
154
  bm25_retriever.k = CANDIDATE_COUNT
155
+
156
+ vector_retriever = vector_store.as_retriever(
157
+ search_kwargs={"k": CANDIDATE_COUNT, "filter": {"book": book}}
158
+ )
159
+
160
+ ensemble = EnsembleRetriever(
161
+ retrievers=[bm25_retriever, vector_retriever],
162
+ weights=[0.5, 0.5],
163
+ )
164
+
165
+ book_candidates = ensemble.invoke(question)
166
  all_candidates.extend(book_candidates)
167
+ print(f" πŸ“¦ {book}: {len(book_candidates)} candidates")
168
+
169
  except Exception as e:
170
  print(f" ❌ {book}: retrieval error β€” {e}")
171
+
 
 
172
  if not all_candidates:
173
  return []
174
+
175
  print(f"πŸš€ Reranking {len(all_candidates)} total candidates...")
176
  reranker = NVIDIARerank(
177
+ model="nvidia/llama-3.2-nv-rerankqa-1b-v2",
178
  api_key=NVIDIA_API_KEY,
179
+ top_n=5,
180
  )
181
+ final_docs = reranker.compress_documents(all_candidates, question)
182
+
 
 
183
  for i, doc in enumerate(final_docs):
184
  score = doc.metadata.get("relevance_score", "N/A")
185
  print(f"Rank {i+1} [{doc.metadata['book']}]: Score {score}")
 
190
  # ─── Format Retrieved Docs ────────────────────────────────────────────────────
191
 
192
  def format_docs(docs: list) -> str:
 
 
 
 
 
193
  by_book: dict[str, list] = {}
194
  for doc in docs:
195
  book = doc.metadata.get("book", "Unknown")
 
200
  header = f"═══ {book} ═══"
201
  chunks = []
202
  for i, doc in enumerate(book_docs, 1):
 
 
 
203
  ang = doc.metadata.get("ang")
204
+ ch = doc.metadata.get("chapter")
205
+ vs = doc.metadata.get("verse")
206
  if ang:
207
  citation = f"Ang {ang}"
208
  elif ch and vs:
209
  citation = f"{ch}:{vs}"
210
  else:
211
  citation = f"Page {doc.metadata.get('page', '?')}"
212
+ chunks.append(f" [{i}] ({citation}): {doc.page_content.strip()}")
213
  sections.append(header + "\n" + "\n\n".join(chunks))
214
 
215
  return "\n\n".join(sections)
 
218
  # ─── Build the RAG Chain ──────────────────────────────────────────────────────
219
 
220
  def build_chain():
221
+ embeddings = get_embeddings()
 
222
  vector_store = get_vector_store(embeddings)
223
 
224
  llm = ChatNVIDIA(
 
229
  max_output_tokens=2048,
230
  )
231
 
232
+ # Prompt now includes a chat-history placeholder so prior turns are visible
233
  prompt = ChatPromptTemplate.from_messages([
234
  ("system", SYSTEM_PROMPT),
235
+ MessagesPlaceholder(variable_name="history"), # ← injected per-request
236
+ ("human", "{question}"),
237
  ])
238
 
 
 
239
  llm_chain = prompt | llm | StrOutputParser()
 
240
  return llm_chain, vector_store
241
 
242
 
243
+ # ─── Singleton init ───────────────────────────────────────────────────────────
244
 
245
+ _llm_chain = None
246
  _vector_store = None
247
 
248
 
249
+ # ─── Public API ───────────────────────────────────────────────────────────────
 
 
250
 
251
+ def query_sacred_texts(question: str, session_id: str = "default"):
252
+ """
253
+ Stream an answer grounded in the sacred texts, maintaining per-session
254
+ conversation history for natural follow-up questions.
255
 
256
+ Yields JSON-lines of the form:
257
+ {"type": "token", "data": "<chunk>"}
258
+ {"type": "sources", "data": [...]}
259
+ {"type": "cache", "data": {"answer": "...", "sources": [...]}}
 
260
  """
261
  global _llm_chain, _vector_store
262
 
263
  if _llm_chain is None:
264
  print("πŸ”§ Initialising RAG chain (first call)...")
265
  _llm_chain, _vector_store = build_chain()
 
 
 
 
 
 
 
266
 
267
+ # ── Semantic cache check (skip for follow-ups that reference history) ──
268
+ history = get_history(session_id)
269
+ is_followup = len(history) > 0
270
+
271
+ if not is_followup:
272
+ cache_coll = _vector_store._client.get_or_create_collection(CACHE_COLLECTION)
273
+ cache_results = cache_coll.query(query_texts=[question], n_results=1)
274
+
275
+ THRESHOLD = 0.35
276
+ if cache_results["ids"] and cache_results["ids"][0]:
277
+ distance = cache_results["distances"][0][0]
278
+ if distance < THRESHOLD:
279
+ print(f"⚑️ Semantic Cache Hit! (Distance: {distance:.4f})")
280
+ cached = json.loads(cache_results["metadatas"][0][0]["response_json"])
281
+ # Store this cache hit in session memory too
282
+ append_turn(session_id, question, cached["answer"])
283
+ yield json.dumps({"type": "cache", "data": cached}) + "\n"
284
+ return
285
+
286
+ # ── Retrieval ──────────────────────────────────────────────────────────
287
+ # For follow-ups, augment the question with the last human turn for better
288
+ # semantic search (the follow-up itself may be too short/vague)
289
+ retrieval_query = question
290
+ if is_followup and len(question.split()) < 8:
291
+ last_human = next(
292
+ (m.content for m in reversed(history) if isinstance(m, HumanMessage)), ""
293
+ )
294
+ retrieval_query = f"{last_human} {question}".strip()
295
+ print(f"πŸ” Follow-up detected β€” augmented retrieval query: '{retrieval_query}'")
296
+
297
+ print(f"\nπŸ” Retrieving chunks for: '{retrieval_query}'")
298
+ source_docs = retrieve_per_book(retrieval_query, _vector_store)
299
 
300
  if not source_docs:
301
  yield json.dumps({"type": "token", "data": "No content found in the knowledge base."}) + "\n"
302
  return
303
 
304
+ # ── Build sources list ─────────────────────────────────────────────────
305
+ seen_sources: set[str] = set()
306
  sources = []
307
  for doc in source_docs:
308
  book = doc.metadata.get("book", "Unknown")
309
+ ang = doc.metadata.get("ang")
310
+ ch = doc.metadata.get("chapter")
311
+ vs = doc.metadata.get("verse")
 
312
  if ang:
313
  cite_val = f"Ang {ang}"
314
  elif ch and vs:
315
  cite_val = f"{ch}:{vs}"
316
  else:
317
  cite_val = f"p. {doc.metadata.get('page', '?')}"
 
318
  display_name = f"{book} {cite_val}"
319
  snippet = doc.page_content[:200].strip() + "..."
320
  if display_name not in seen_sources:
321
  seen_sources.add(display_name)
 
 
322
  sources.append({"book": display_name, "page": cite_val, "snippet": snippet})
 
 
 
323
 
324
+ context = format_docs(source_docs)
325
+ full_answer = ""
326
+
327
+ # ── Stream LLM response (history injected here) ────────────────────────
328
+ for chunk in _llm_chain.stream({
329
+ "context": context,
330
+ "question": question,
331
+ "history": history, # ← the conversation so far
332
+ }):
333
  full_answer += chunk
334
+ yield json.dumps({"type": "token", "data": chunk}) + "\n"
335
+
336
+ # ── Filter sources to those actually cited in the answer ───────────────
337
+ answer_lower = full_answer.lower()
338
+ final_sources = [s for s in sources if s["book"].lower() in answer_lower] or []
339
+
340
+ # ── Persist this turn into session memory ─────────────────────────────
341
+ append_turn(session_id, question, full_answer)
342
+ print(f"πŸ’Ύ Session '{session_id}': {len(get_history(session_id)) // 2} turn(s) stored")
343
+
344
+ # ── Cache first-turn answers only ─────────────────────────────────────
345
+ if not is_followup:
346
+ result_to_cache = {"answer": full_answer, "sources": final_sources}
347
+ try:
348
+ cache_coll = _vector_store._client.get_or_create_collection(CACHE_COLLECTION)
349
+ cache_coll.add(
350
+ documents=[question],
351
+ metadatas=[{"response_json": json.dumps(result_to_cache)}],
352
+ ids=[question],
353
+ )
354
+ except Exception as e:
355
+ print(f"⚠️ Cache write failed: {e}")
356
+
 
 
 
 
 
357
  yield json.dumps({"type": "sources", "data": sources}) + "\n"
 
358
 
359
 
360
  # ─── Quick CLI Test ───────────────────────────────────────────────────────────
361
 
362
  if __name__ == "__main__":
363
+ test_q = "What do the scriptures say about forgiveness?"
364
  print(f"\nπŸ” Test query: {test_q}\n")
365
+ for line in query_sacred_texts(test_q, session_id="cli-test"):
366
+ obj = json.loads(line)
367
+ if obj["type"] == "token":
368
+ print(obj["data"], end="", flush=True)
369
+ print("\n")