lvvignesh2122 commited on
Commit
88cc76a
·
1 Parent(s): 06ee524

Remove auth, fix quota issues with retries, and update agent graph

Browse files
agentic_rag_graph.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import TypedDict, List, Optional
2
+ import google.generativeai as genai
3
+ from langgraph.graph import StateGraph, END
4
+
5
+ from rag_store import search_knowledge
6
+ from eval_logger import log_eval
7
+
8
+ MODEL_NAME = "gemini-2.5-flash"
9
+
10
+
11
+ # ===============================
12
+ # STATE
13
+ # ===============================
14
+ class AgentState(TypedDict):
15
+ query: str
16
+ decision: str
17
+ retrieved_chunks: List[dict]
18
+ answer: Optional[str]
19
+ confidence: float
20
+ answer_known: bool
21
+
22
+
23
+ # ===============================
24
+ # DECISION NODE
25
+ # ===============================
26
+ def agent_decision_node(state: AgentState) -> AgentState:
27
+ q = state["query"].lower()
28
+
29
+ rag_keywords = [
30
+ "summarize", "summary", "fee", "fees", "refund",
31
+ "tuition", "document", "policy", "offer", "scholarship"
32
+ ]
33
+
34
+ decision = "use_rag" if any(k in q for k in rag_keywords) else "no_rag"
35
+
36
+ return {**state, "decision": decision}
37
+
38
+
39
+ # ===============================
40
+ # RETRIEVAL NODE (TOOL)
41
+ # ===============================
42
+ def retrieve_node(state: AgentState) -> AgentState:
43
+ chunks = search_knowledge(state["query"])
44
+ return {**state, "retrieved_chunks": chunks}
45
+
46
+
47
+ # ===============================
48
+ # ANSWER WITH RAG
49
+ # ===============================
50
+ def answer_with_rag_node(state: AgentState) -> AgentState:
51
+ if not state["retrieved_chunks"]:
52
+ return no_answer_node(state)
53
+
54
+ context = "\n\n".join(c["text"] for c in state["retrieved_chunks"])
55
+
56
+ prompt = f"""
57
+ Answer using ONLY the context below.
58
+ If the answer is not present, say "I don't know".
59
+
60
+ Context:
61
+ {context}
62
+
63
+ Question:
64
+ {state["query"]}
65
+ """
66
+
67
+ model = genai.GenerativeModel(MODEL_NAME)
68
+ resp = model.generate_content(prompt)
69
+ answer_text = resp.text
70
+
71
+ confidence = min(1.0, len(state["retrieved_chunks"]) / 5)
72
+ answer_known = "i don't know" not in answer_text.lower()
73
+
74
+ log_eval(
75
+ query=state["query"],
76
+ retrieved_count=len(state["retrieved_chunks"]),
77
+ confidence=confidence,
78
+ answer_known=answer_known
79
+ )
80
+
81
+ return {
82
+ **state,
83
+ "answer": answer_text,
84
+ "confidence": confidence,
85
+ "answer_known": answer_known
86
+ }
87
+
88
+
89
+ # ===============================
90
+ # ANSWER WITHOUT RAG
91
+ # ===============================
92
+ def answer_direct_node(state: AgentState) -> AgentState:
93
+ prompt = f"Answer the following question concisely:\n\n{state['query']}"
94
+
95
+ model = genai.GenerativeModel(MODEL_NAME)
96
+ resp = model.generate_content(prompt)
97
+
98
+ log_eval(
99
+ query=state["query"],
100
+ retrieved_count=0,
101
+ confidence=0.3,
102
+ answer_known=True
103
+ )
104
+
105
+ return {
106
+ **state,
107
+ "answer": resp.text,
108
+ "confidence": 0.3,
109
+ "answer_known": True
110
+ }
111
+
112
+
113
+ # ===============================
114
+ # NO ANSWER
115
+ # ===============================
116
+ def no_answer_node(state: AgentState) -> AgentState:
117
+ log_eval(
118
+ query=state["query"],
119
+ retrieved_count=0,
120
+ confidence=0.0,
121
+ answer_known=False
122
+ )
123
+
124
+ return {
125
+ **state,
126
+ "answer": "I don't know based on the provided documents.",
127
+ "confidence": 0.0,
128
+ "answer_known": False
129
+ }
130
+
131
+
132
+ # ===============================
133
+ # GRAPH BUILDER
134
+ # ===============================
135
+ def build_agentic_rag_graph():
136
+ graph = StateGraph(AgentState)
137
+
138
+ graph.add_node("decide", agent_decision_node)
139
+ graph.add_node("retrieve", retrieve_node)
140
+ graph.add_node("answer_rag", answer_with_rag_node)
141
+ graph.add_node("answer_direct", answer_direct_node)
142
+ graph.add_node("no_answer", no_answer_node)
143
+
144
+ graph.set_entry_point("decide")
145
+
146
+ graph.add_conditional_edges(
147
+ "decide",
148
+ lambda s: s["decision"],
149
+ {
150
+ "use_rag": "retrieve",
151
+ "no_rag": "answer_direct"
152
+ }
153
+ )
154
+
155
+ graph.add_edge("retrieve", "answer_rag")
156
+ graph.add_edge("answer_rag", END)
157
+ graph.add_edge("answer_direct", END)
158
+ graph.add_edge("no_answer", END)
159
+
160
+ return graph.compile()
frontend/analytics.html CHANGED
@@ -2,251 +2,252 @@
2
  <html lang="en">
3
 
4
  <head>
5
- <meta charset="UTF-8" />
6
- <title>Analytics - Gemini RAG</title>
7
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
8
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
9
-
10
- <style>
11
- :root {
12
- --bg: radial-gradient(1200px 600px at top, #e0e7ff 0%, #f8fafc 60%);
13
- --card: rgba(255, 255, 255, 0.9);
14
- --border: rgba(15, 23, 42, 0.08);
15
- --primary: #4f46e5;
16
- --secondary: #0ea5e9;
17
- --text: #0f172a;
18
- --muted: #64748b;
19
- --success: #16a34a;
20
- --error: #dc2626;
21
- }
22
-
23
- [data-theme="dark"] {
24
- --bg: radial-gradient(1200px 600px at top, #1e1b4b 0%, #0f172a 60%);
25
- --card: rgba(30, 41, 59, 0.9);
26
- --border: rgba(148, 163, 184, 0.1);
27
- --primary: #818cf8;
28
- --secondary: #38bdf8;
29
- --text: #f1f5f9;
30
- --muted: #94a3b8;
31
- --success: #4ade80;
32
- --error: #f87171;
33
- }
34
-
35
- * {
36
- box-sizing: border-box;
37
- font-family: Inter, sans-serif;
38
- }
39
-
40
- body {
41
- margin: 0;
42
- min-height: 100vh;
43
- background: var(--bg);
44
- padding: 40px 16px;
45
- color: var(--text);
46
- transition: background 0.3s ease, color 0.3s ease;
47
- }
48
-
49
- .container {
50
- max-width: 1200px;
51
- margin: 0 auto;
52
- }
53
-
54
- .header {
55
- display: flex;
56
- justify-content: space-between;
57
- align-items: center;
58
- margin-bottom: 32px;
59
- }
60
-
61
- h1 {
62
- font-size: 2.2rem;
63
- margin: 0;
64
- font-weight: 700;
65
- background: linear-gradient(135deg, #4f46e5, #06b6d4);
66
- background-clip: text;
67
- -webkit-background-clip: text;
68
- -webkit-text-fill-color: transparent;
69
- }
70
-
71
- .back-btn {
72
- padding: 10px 20px;
73
- background: var(--primary);
74
- color: white;
75
- text-decoration: none;
76
- border-radius: 12px;
77
- font-weight: 600;
78
- transition: transform 0.2s ease;
79
- }
80
-
81
- .back-btn:hover {
82
- transform: translateY(-2px);
83
- }
84
-
85
- .stats-grid {
86
- display: grid;
87
- grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
88
- gap: 20px;
89
- margin-bottom: 32px;
90
- }
91
-
92
- .stat-card {
93
- background: var(--card);
94
- backdrop-filter: blur(16px);
95
- border-radius: 18px;
96
- padding: 24px;
97
- border: 1px solid var(--border);
98
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
99
- }
100
-
101
- .stat-label {
102
- font-size: 0.85rem;
103
- color: var(--muted);
104
- margin-bottom: 8px;
105
- text-transform: uppercase;
106
- letter-spacing: 0.5px;
107
- }
108
-
109
- .stat-value {
110
- font-size: 2.5rem;
111
- font-weight: 700;
112
- color: var(--primary);
113
- }
114
-
115
- .card {
116
- background: var(--card);
117
- backdrop-filter: blur(16px);
118
- border-radius: 18px;
119
- padding: 28px;
120
- border: 1px solid var(--border);
121
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
122
- margin-bottom: 24px;
123
- }
124
-
125
- .card h2 {
126
- margin-top: 0;
127
- margin-bottom: 20px;
128
- font-size: 1.3rem;
129
- }
130
-
131
- table {
132
- width: 100%;
133
- border-collapse: collapse;
134
- }
135
-
136
- th,
137
- td {
138
- text-align: left;
139
- padding: 12px;
140
- border-bottom: 1px solid var(--border);
141
- }
142
-
143
- th {
144
- font-weight: 600;
145
- color: var(--muted);
146
- font-size: 0.85rem;
147
- text-transform: uppercase;
148
- letter-spacing: 0.5px;
149
- }
150
-
151
- .badge {
152
- display: inline-block;
153
- padding: 4px 10px;
154
- border-radius: 12px;
155
- font-size: 0.75rem;
156
- font-weight: 600;
157
- }
158
-
159
- .badge-success {
160
- background: #dcfce7;
161
- color: #166534;
162
- }
163
-
164
- .badge-error {
165
- background: #fee2e2;
166
- color: #991b1b;
167
- }
168
-
169
- .theme-toggle {
170
- position: fixed;
171
- top: 20px;
172
- right: 20px;
173
- background: var(--card);
174
- border: 1px solid var(--border);
175
- border-radius: 12px;
176
- padding: 10px;
177
- cursor: pointer;
178
- font-size: 1.4rem;
179
- transition: transform 0.2s ease;
180
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
181
- }
182
-
183
- .theme-toggle:hover {
184
- transform: scale(1.1);
185
- }
186
-
187
- .empty-state {
188
- text-align: center;
189
- padding: 60px 20px;
190
- color: var(--muted);
191
- }
192
- </style>
193
  </head>
194
 
195
  <body>
196
- <button class="theme-toggle" onclick="toggleTheme()" title="Toggle dark mode">🌙</button>
197
 
198
- <div class="container">
199
- <div class="header">
200
- <h1>📊 Analytics Dashboard</h1>
201
- <a href="/" class="back-btn">← Back to RAG</a>
202
- </div>
203
-
204
- <div id="stats-container">
205
- <div class="empty-state">
206
- <h2>Loading analytics...</h2>
207
- </div>
208
- </div>
209
  </div>
210
 
211
- <script>
212
- // ===== THEME TOGGLE =====
213
- function toggleTheme() {
214
- const html = document.documentElement;
215
- const currentTheme = html.getAttribute('data-theme');
216
- const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
217
-
218
- html.setAttribute('data-theme', newTheme);
219
- localStorage.setItem('theme', newTheme);
220
-
221
- const btn = document.querySelector('.theme-toggle');
222
- btn.textContent = newTheme === 'dark' ? '☀️' : '🌙';
223
- }
224
-
225
- // Load saved theme
226
- (function () {
227
- const savedTheme = localStorage.getItem('theme') || 'light';
228
- document.documentElement.setAttribute('data-theme', savedTheme);
229
- const btn = document.querySelector('.theme-toggle');
230
- if (btn) btn.textContent = savedTheme === 'dark' ? '☀️' : '🌙';
231
- })();
232
-
233
- // ===== LOAD ANALYTICS =====
234
- async function loadAnalytics() {
235
- try {
236
- const res = await fetch('/analytics');
237
- const data = await res.json();
238
-
239
- if (data.total_queries === 0) {
240
- document.getElementById('stats-container').innerHTML = `
 
 
 
 
 
 
 
 
241
  <div class="empty-state">
242
  <h2>No data yet</h2>
243
  <p>Start asking questions to see analytics!</p>
244
  </div>
245
  `;
246
- return;
247
- }
248
 
249
- const html = `
250
  <div class="stats-grid">
251
  <div class="stat-card">
252
  <div class="stat-label">Total Queries</div>
@@ -315,20 +316,20 @@
315
  ` : ''}
316
  `;
317
 
318
- document.getElementById('stats-container').innerHTML = html;
319
- } catch (e) {
320
- document.getElementById('stats-container').innerHTML = `
321
  <div class="empty-state">
322
  <h2>Error loading analytics</h2>
323
  <p>${e.message}</p>
324
  </div>
325
  `;
326
- }
327
- }
328
 
329
- // Load on page load
330
- loadAnalytics();
331
- </script>
332
  </body>
333
 
334
  </html>
 
2
  <html lang="en">
3
 
4
  <head>
5
+ <meta charset="UTF-8" />
6
+ <title>Analytics - Gemini RAG</title>
7
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
9
+
10
+ <style>
11
+ :root {
12
+ --bg: radial-gradient(1200px 600px at top, #e0e7ff 0%, #f8fafc 60%);
13
+ --card: rgba(255, 255, 255, 0.9);
14
+ --border: rgba(15, 23, 42, 0.08);
15
+ --primary: #4f46e5;
16
+ --secondary: #0ea5e9;
17
+ --text: #0f172a;
18
+ --muted: #64748b;
19
+ --success: #16a34a;
20
+ --error: #dc2626;
21
+ }
22
+
23
+ [data-theme="dark"] {
24
+ --bg: radial-gradient(1200px 600px at top, #1e1b4b 0%, #0f172a 60%);
25
+ --card: rgba(30, 41, 59, 0.9);
26
+ --border: rgba(148, 163, 184, 0.1);
27
+ --primary: #818cf8;
28
+ --secondary: #38bdf8;
29
+ --text: #f1f5f9;
30
+ --muted: #94a3b8;
31
+ --success: #4ade80;
32
+ --error: #f87171;
33
+ }
34
+
35
+ * {
36
+ box-sizing: border-box;
37
+ font-family: Inter, sans-serif;
38
+ }
39
+
40
+ body {
41
+ margin: 0;
42
+ min-height: 100vh;
43
+ background: var(--bg);
44
+ padding: 40px 16px;
45
+ color: var(--text);
46
+ transition: background 0.3s ease, color 0.3s ease;
47
+ }
48
+
49
+ .container {
50
+ max-width: 1200px;
51
+ margin: 0 auto;
52
+ }
53
+
54
+ .header {
55
+ display: flex;
56
+ justify-content: space-between;
57
+ align-items: center;
58
+ margin-bottom: 32px;
59
+ }
60
+
61
+ h1 {
62
+ font-size: 2.2rem;
63
+ margin: 0;
64
+ font-weight: 700;
65
+ background: linear-gradient(135deg, #4f46e5, #06b6d4);
66
+ background-clip: text;
67
+ -webkit-background-clip: text;
68
+ -webkit-text-fill-color: transparent;
69
+ }
70
+
71
+ .back-btn {
72
+ padding: 10px 20px;
73
+ background: var(--primary);
74
+ color: white;
75
+ text-decoration: none;
76
+ border-radius: 12px;
77
+ font-weight: 600;
78
+ transition: transform 0.2s ease;
79
+ }
80
+
81
+ .back-btn:hover {
82
+ transform: translateY(-2px);
83
+ }
84
+
85
+ .stats-grid {
86
+ display: grid;
87
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
88
+ gap: 20px;
89
+ margin-bottom: 32px;
90
+ }
91
+
92
+ .stat-card {
93
+ background: var(--card);
94
+ backdrop-filter: blur(16px);
95
+ border-radius: 18px;
96
+ padding: 24px;
97
+ border: 1px solid var(--border);
98
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
99
+ }
100
+
101
+ .stat-label {
102
+ font-size: 0.85rem;
103
+ color: var(--muted);
104
+ margin-bottom: 8px;
105
+ text-transform: uppercase;
106
+ letter-spacing: 0.5px;
107
+ }
108
+
109
+ .stat-value {
110
+ font-size: 2.5rem;
111
+ font-weight: 700;
112
+ color: var(--primary);
113
+ }
114
+
115
+ .card {
116
+ background: var(--card);
117
+ backdrop-filter: blur(16px);
118
+ border-radius: 18px;
119
+ padding: 28px;
120
+ border: 1px solid var(--border);
121
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
122
+ margin-bottom: 24px;
123
+ }
124
+
125
+ .card h2 {
126
+ margin-top: 0;
127
+ margin-bottom: 20px;
128
+ font-size: 1.3rem;
129
+ }
130
+
131
+ table {
132
+ width: 100%;
133
+ border-collapse: collapse;
134
+ }
135
+
136
+ th,
137
+ td {
138
+ text-align: left;
139
+ padding: 12px;
140
+ border-bottom: 1px solid var(--border);
141
+ }
142
+
143
+ th {
144
+ font-weight: 600;
145
+ color: var(--muted);
146
+ font-size: 0.85rem;
147
+ text-transform: uppercase;
148
+ letter-spacing: 0.5px;
149
+ }
150
+
151
+ .badge {
152
+ display: inline-block;
153
+ padding: 4px 10px;
154
+ border-radius: 12px;
155
+ font-size: 0.75rem;
156
+ font-weight: 600;
157
+ }
158
+
159
+ .badge-success {
160
+ background: #dcfce7;
161
+ color: #166534;
162
+ }
163
+
164
+ .badge-error {
165
+ background: #fee2e2;
166
+ color: #991b1b;
167
+ }
168
+
169
+ .theme-toggle {
170
+ position: fixed;
171
+ top: 20px;
172
+ right: 20px;
173
+ background: var(--card);
174
+ border: 1px solid var(--border);
175
+ border-radius: 12px;
176
+ padding: 10px;
177
+ cursor: pointer;
178
+ font-size: 1.4rem;
179
+ transition: transform 0.2s ease;
180
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
181
+ }
182
+
183
+ .theme-toggle:hover {
184
+ transform: scale(1.1);
185
+ }
186
+
187
+ .empty-state {
188
+ text-align: center;
189
+ padding: 60px 20px;
190
+ color: var(--muted);
191
+ }
192
+ </style>
193
  </head>
194
 
195
  <body>
196
+ <button class="theme-toggle" onclick="toggleTheme()" title="Toggle dark mode">🌙</button>
197
 
198
+ <div class="container">
199
+ <div class="header">
200
+ <h1>📊 Analytics Dashboard</h1>
201
+ <a href="/" class="back-btn">← Back to RAG</a>
 
 
 
 
 
 
 
202
  </div>
203
 
204
+ <div id="stats-container">
205
+ <div class="empty-state">
206
+ <h2>Loading analytics...</h2>
207
+ </div>
208
+ </div>
209
+ </div>
210
+
211
+ <script>
212
+ // ===== THEME TOGGLE =====
213
+ function toggleTheme() {
214
+ const html = document.documentElement;
215
+ const currentTheme = html.getAttribute('data-theme');
216
+ const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
217
+
218
+ html.setAttribute('data-theme', newTheme);
219
+ localStorage.setItem('theme', newTheme);
220
+
221
+ const btn = document.querySelector('.theme-toggle');
222
+ btn.textContent = newTheme === 'dark' ? '☀️' : '🌙';
223
+ }
224
+
225
+ // Load saved theme
226
+ (function () {
227
+ const savedTheme = localStorage.getItem('theme') || 'light';
228
+ document.documentElement.setAttribute('data-theme', savedTheme);
229
+ const btn = document.querySelector('.theme-toggle');
230
+ if (btn) btn.textContent = savedTheme === 'dark' ? '☀️' : '🌙';
231
+ })();
232
+
233
+ // ===== LOAD ANALYTICS =====
234
+ async function loadAnalytics() {
235
+ try {
236
+ const res = await fetch('/analytics');
237
+
238
+ const data = await res.json();
239
+
240
+ if (data.total_queries === 0) {
241
+ document.getElementById('stats-container').innerHTML = `
242
  <div class="empty-state">
243
  <h2>No data yet</h2>
244
  <p>Start asking questions to see analytics!</p>
245
  </div>
246
  `;
247
+ return;
248
+ }
249
 
250
+ const html = `
251
  <div class="stats-grid">
252
  <div class="stat-card">
253
  <div class="stat-label">Total Queries</div>
 
316
  ` : ''}
317
  `;
318
 
319
+ document.getElementById('stats-container').innerHTML = html;
320
+ } catch (e) {
321
+ document.getElementById('stats-container').innerHTML = `
322
  <div class="empty-state">
323
  <h2>Error loading analytics</h2>
324
  <p>${e.message}</p>
325
  </div>
326
  `;
327
+ }
328
+ }
329
 
330
+ // Load on page load
331
+ loadAnalytics();
332
+ </script>
333
  </body>
334
 
335
  </html>
frontend/index.html CHANGED
@@ -43,21 +43,83 @@
43
  min-height: 100vh;
44
  background: var(--bg);
45
  display: flex;
46
- justify-content: center;
47
- padding: 40px 16px;
48
  color: var(--text);
49
- transition: background 0.3s ease, color 0.3s ease;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  }
51
 
52
  .container {
53
  width: 100%;
54
  max-width: 800px;
 
 
 
 
 
 
 
 
 
 
55
  background: var(--card);
56
- backdrop-filter: blur(16px);
57
- border-radius: 24px;
58
- padding: 36px;
59
  border: 1px solid var(--border);
60
- box-shadow: 0 40px 120px rgba(15, 23, 42, .15);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  }
62
 
63
  h1 {
@@ -73,44 +135,28 @@
73
  .subtitle {
74
  margin-top: 8px;
75
  color: var(--muted);
76
- font-size: 1rem;
77
  }
78
 
79
  .card {
80
  margin-top: 28px;
81
- background: var(--card);
82
- border-radius: 18px;
83
  padding: 24px;
 
84
  border: 1px solid var(--border);
 
85
  }
86
 
87
- .card h3 {
88
- margin-top: 0;
89
- margin-bottom: 16px;
90
- font-size: 1.1rem;
91
- }
92
-
93
- input[type="file"],
94
- textarea {
95
  width: 100%;
96
  padding: 14px;
97
  border-radius: 14px;
98
  border: 1px solid var(--border);
99
- font-size: 0.95rem;
100
  background: var(--card);
101
  color: var(--text);
102
  }
103
 
104
  textarea {
105
  min-height: 100px;
106
- resize: vertical;
107
- }
108
-
109
- .row {
110
- display: flex;
111
- gap: 12px;
112
- margin-top: 12px;
113
- flex-wrap: wrap;
114
  }
115
 
116
  button {
@@ -121,43 +167,30 @@
121
  color: white;
122
  font-weight: 600;
123
  cursor: pointer;
124
- transition: all .2s ease;
125
  }
126
 
127
  button.secondary {
128
  background: var(--secondary);
129
  }
130
 
131
- button:disabled {
132
- opacity: .5;
133
- cursor: not-allowed;
134
- }
135
-
136
- button:hover:not(:disabled) {
137
- transform: translateY(-1px);
138
- box-shadow: 0 4px 12px rgba(79, 70, 229, .2);
139
- }
140
-
141
- .status {
142
- margin-top: 10px;
143
- font-size: .9rem;
144
- color: var(--muted);
145
  }
146
 
147
  .answer {
148
  margin-top: 24px;
149
  padding: 22px;
150
  border-radius: 16px;
151
- background: var(--card);
152
  border: 1px solid var(--border);
 
153
  line-height: 1.6;
154
- box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
155
- color: var(--text);
156
  }
157
 
158
  .confidence-badge {
159
- display: inline-block;
160
  margin-top: 12px;
 
161
  padding: 4px 12px;
162
  border-radius: 20px;
163
  background: #dcfce7;
@@ -167,184 +200,233 @@
167
  }
168
 
169
  .citations {
170
- margin-top: 16px;
171
- font-size: .85rem;
172
  color: var(--muted);
173
- border-top: 1px solid var(--border);
174
- padding-top: 12px;
175
- }
176
-
177
- .citations ul {
178
- margin: 6px 0 0;
179
- padding-left: 20px;
180
- }
181
-
182
- .loader {
183
- font-weight: 600;
184
- color: var(--primary);
185
- animation: pulse 1.2s infinite;
186
- }
187
-
188
- @keyframes pulse {
189
- 0% {
190
- opacity: .4
191
- }
192
-
193
- 50% {
194
- opacity: 1
195
- }
196
-
197
- 100% {
198
- opacity: .4
199
- }
200
- }
201
-
202
- .theme-toggle {
203
- position: fixed;
204
- top: 20px;
205
- right: 20px;
206
- background: var(--card);
207
- border: 1px solid var(--border);
208
- border-radius: 12px;
209
- padding: 10px;
210
- cursor: pointer;
211
- font-size: 1.4rem;
212
- transition: transform 0.2s ease;
213
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
214
- }
215
-
216
- .theme-toggle:hover {
217
- transform: scale(1.1);
218
  }
219
  </style>
220
  </head>
221
 
222
  <body>
223
- <button class="theme-toggle" onclick="toggleTheme()" title="Toggle dark mode">🌙</button>
224
- <div class="container">
225
- <h1>Gemini RAG Assistant</h1>
226
- <div class="subtitle">Upload documents · Ask questions · Get grounded answers · <a href="/frontend/analytics.html"
227
- style="color: var(--primary); text-decoration: none; font-weight: 600;">📊 Analytics</a></div>
228
-
229
- <div class="card">
230
- <h3>1. Upload Knowledge</h3>
231
- <input type="file" id="files" multiple accept=".pdf,.txt" />
232
- <div class="row">
233
- <button id="uploadBtn" onclick="upload()">Upload & Index Files</button>
234
  </div>
235
- <div id="uploadStatus" class="status"></div>
 
236
  </div>
237
-
238
- <div class="card">
239
- <h3>2. Ask or Summarize</h3>
240
- <textarea id="question" placeholder="E.g., 'What are the main risks?' or 'Summarize the document'"></textarea>
241
- <div class="row">
242
- <button id="askBtn" onclick="ask()">Ask Question</button>
243
- <button class="secondary" id="sumBtn" onclick="summarize()">Generate Summary</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  </div>
245
  </div>
246
-
247
- <div id="answerBox" class="answer" style="display:none;"></div>
248
-
249
  </div>
250
 
251
  <script>
252
- // ===== THEME TOGGLE =====
253
- function toggleTheme() {
254
- const html = document.documentElement;
255
- const currentTheme = html.getAttribute('data-theme');
256
- const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
257
-
258
- html.setAttribute('data-theme', newTheme);
259
- localStorage.setItem('theme', newTheme);
260
-
261
- // Update button icon
262
- const btn = document.querySelector('.theme-toggle');
263
- btn.textContent = newTheme === 'dark' ? '☀️' : '🌙';
264
- }
265
-
266
- // Load saved theme on page load
267
- (function () {
268
- const savedTheme = localStorage.getItem('theme') || 'light';
269
- document.documentElement.setAttribute('data-theme', savedTheme);
270
- const btn = document.querySelector('.theme-toggle');
271
- if (btn) btn.textContent = savedTheme === 'dark' ? '☀️' : '🌙';
272
- })();
273
-
274
- // ===== APP LOGIC =====
275
- let busy = false;
276
-
277
- function setBusy(state) {
278
- busy = state;
279
- document.getElementById("askBtn").disabled = state;
280
- document.getElementById("sumBtn").disabled = state;
281
- document.getElementById("uploadBtn").disabled = state;
282
- }
283
 
284
  async function upload() {
285
- const files = document.getElementById("files").files;
 
 
 
 
 
 
286
  if (!files.length) {
287
- alert("Please select files first.");
288
  return;
289
  }
290
 
291
- setBusy(true);
292
- const statusDiv = document.getElementById("uploadStatus");
293
- statusDiv.innerText = "Indexing documents... this may take a moment.";
 
 
 
 
 
 
 
 
 
 
 
294
 
295
  const fd = new FormData();
296
  for (let f of files) fd.append("files", f);
297
 
298
- try {
299
- const res = await fetch("/upload", { method: "POST", body: fd });
300
- if (!res.ok) throw new Error("Upload failed");
301
- const data = await res.json();
302
- statusDiv.innerText = data.message || "Done ✅";
303
- } catch (e) {
304
- statusDiv.innerText = "Error uploading files.";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
  }
306
- setBusy(false);
307
  }
308
 
 
 
 
309
  async function ask() {
310
  const q = document.getElementById("question").value.trim();
311
  if (!q) return;
312
 
313
- setBusy(true);
314
  const box = document.getElementById("answerBox");
315
  box.style.display = "block";
316
- box.innerHTML = "<span class='loader'>Thinking...</span>";
317
 
318
- try {
319
- const res = await fetch("/ask", {
320
- method: "POST",
321
- headers: { "Content-Type": "application/json" },
322
- body: JSON.stringify({ prompt: q })
323
- });
324
 
325
- const data = await res.json();
326
 
327
- let html = `<div><strong>Answer:</strong><br>${data.answer.replace(/\n/g, '<br>')}</div>`;
328
 
329
- if (data.confidence > 0) {
330
- html += `<div class="confidence-badge">Confidence: ${(data.confidence * 100).toFixed(0)}%</div>`;
331
- }
 
332
 
333
- if (data.citations && data.citations.length > 0) {
334
- html += `<div class="citations"><strong>Sources:</strong><ul>`;
335
- data.citations.forEach(c => {
336
- html += `<li>${c.source} (Page ${c.page})</li>`;
337
- });
338
- html += `</ul></div>`;
339
- }
340
-
341
- box.innerHTML = html;
342
 
343
- } catch (e) {
344
- box.innerText = "⚠️ Error communicating with the server.";
 
 
 
 
345
  }
346
 
347
- setBusy(false);
 
348
  }
349
 
350
  function summarize() {
 
43
  min-height: 100vh;
44
  background: var(--bg);
45
  display: flex;
 
 
46
  color: var(--text);
47
+ }
48
+
49
+ /* Layout */
50
+ .app_layout {
51
+ display: grid;
52
+ grid-template-columns: 260px 1fr;
53
+ width: 100%;
54
+ height: 100vh;
55
+ }
56
+
57
+ /* Sidebar */
58
+ .sidebar {
59
+ background: rgba(255, 255, 255, 0.5);
60
+ /* Glass-ish */
61
+ backdrop-filter: blur(12px);
62
+ border-right: 1px solid var(--border);
63
+ padding: 24px;
64
+ display: flex;
65
+ flex-direction: column;
66
+ height: 100%;
67
+ overflow-y: auto;
68
+ }
69
+
70
+ .main-content {
71
+ padding: 40px;
72
+ overflow-y: auto;
73
+ display: flex;
74
+ justify-content: center;
75
  }
76
 
77
  .container {
78
  width: 100%;
79
  max-width: 800px;
80
+ /* background: var(--card); Removed container bg for cleaner look in main area */
81
+ /* border-radius: 24px; */
82
+ /* padding: 36px; */
83
+ /* border: 1px solid var(--border); */
84
+ /* box-shadow: 0 40px 120px rgba(15, 23, 42, .15); */
85
+ }
86
+
87
+ .history-item {
88
+ padding: 10px 14px;
89
+ margin-bottom: 8px;
90
  background: var(--card);
 
 
 
91
  border: 1px solid var(--border);
92
+ border-radius: 10px;
93
+ cursor: pointer;
94
+ font-size: 0.9rem;
95
+ transition: all 0.2s;
96
+ white-space: nowrap;
97
+ overflow: hidden;
98
+ text-overflow: ellipsis;
99
+ }
100
+
101
+ .history-item:hover {
102
+ background: var(--primary);
103
+ color: white;
104
+ }
105
+
106
+ .sidebar-header {
107
+ margin-bottom: 20px;
108
+ display: flex;
109
+ justify-content: space-between;
110
+ align-items: center;
111
+ }
112
+
113
+ .new-chat-btn {
114
+ width: 100%;
115
+ padding: 10px;
116
+ margin-bottom: 20px;
117
+ background: var(--primary);
118
+ color: white;
119
+ border: none;
120
+ border-radius: 10px;
121
+ cursor: pointer;
122
+ font-weight: 600;
123
  }
124
 
125
  h1 {
 
135
  .subtitle {
136
  margin-top: 8px;
137
  color: var(--muted);
 
138
  }
139
 
140
  .card {
141
  margin-top: 28px;
 
 
142
  padding: 24px;
143
+ border-radius: 18px;
144
  border: 1px solid var(--border);
145
+ background: var(--card);
146
  }
147
 
148
+ textarea,
149
+ input[type="file"] {
 
 
 
 
 
 
150
  width: 100%;
151
  padding: 14px;
152
  border-radius: 14px;
153
  border: 1px solid var(--border);
 
154
  background: var(--card);
155
  color: var(--text);
156
  }
157
 
158
  textarea {
159
  min-height: 100px;
 
 
 
 
 
 
 
 
160
  }
161
 
162
  button {
 
167
  color: white;
168
  font-weight: 600;
169
  cursor: pointer;
 
170
  }
171
 
172
  button.secondary {
173
  background: var(--secondary);
174
  }
175
 
176
+ .row {
177
+ display: flex;
178
+ gap: 12px;
179
+ margin-top: 12px;
 
 
 
 
 
 
 
 
 
 
180
  }
181
 
182
  .answer {
183
  margin-top: 24px;
184
  padding: 22px;
185
  border-radius: 16px;
 
186
  border: 1px solid var(--border);
187
+ background: var(--card);
188
  line-height: 1.6;
 
 
189
  }
190
 
191
  .confidence-badge {
 
192
  margin-top: 12px;
193
+ display: inline-block;
194
  padding: 4px 12px;
195
  border-radius: 20px;
196
  background: #dcfce7;
 
200
  }
201
 
202
  .citations {
203
+ margin-top: 14px;
204
+ font-size: 0.85rem;
205
  color: var(--muted);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  }
207
  </style>
208
  </head>
209
 
210
  <body>
211
+ <div class="app_layout">
212
+ <div class="sidebar">
213
+ <div class="sidebar-header">
214
+ <h2 style="font-size: 1.2rem; margin:0;">History</h2>
215
+ <button onclick="clearHistory()"
216
+ style="background:none; border:none; color:var(--error); cursor:pointer; padding:0; font-size:0.8rem; width:auto; text-decoration:underline; margin:0;">Clear</button>
 
 
 
 
 
217
  </div>
218
+ <button class="new-chat-btn" onclick="newChat()">+ New Chat</button>
219
+ <div id="historyList"></div>
220
  </div>
221
+ <div class="main-content">
222
+ <div class="container">
223
+ <h1>Gemini RAG Assistant</h1>
224
+ <div class="subtitle">
225
+ Upload documents · Ask questions · Get grounded answers ·
226
+ <a href="/frontend/analytics.html">📊 Analytics</a>
227
+ </div>
228
+
229
+ <div class="card">
230
+ <h3>Upload Knowledge</h3>
231
+ <input type="file" id="files" multiple />
232
+ <div class="row">
233
+ <button onclick="upload()">Upload</button>
234
+ </div>
235
+
236
+ <!-- Progress Bar Container -->
237
+ <div id="progressContainer" style="display: none; margin-top: 16px;">
238
+ <div style="background: var(--border); border-radius: 8px; overflow: hidden; height: 10px;">
239
+ <div id="progressBar"
240
+ style="width: 0%; height: 100%; background: var(--primary); transition: width 0.2s;">
241
+ </div>
242
+ </div>
243
+ <div id="progressText"
244
+ style="margin-top: 6px; font-size: 0.85rem; color: var(--muted); text-align: center;">0%
245
+ </div>
246
+ </div>
247
+
248
+ <div id="uploadStatus" style="margin-top: 12px; font-weight: 500;"></div>
249
+ </div>
250
+
251
+ <div class="card">
252
+ <h3>Ask or Summarize</h3>
253
+ <textarea id="question"></textarea>
254
+ <div class="row">
255
+ <button onclick="ask()">Ask</button>
256
+ <button class="secondary" onclick="summarize()">Summarize</button>
257
+ </div>
258
+ </div>
259
+
260
+ <div id="answerBox" class="answer" style="display:none;"></div>
261
  </div>
262
  </div>
 
 
 
263
  </div>
264
 
265
  <script>
266
+ const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
 
268
  async function upload() {
269
+ const fileInput = document.getElementById("files");
270
+ const files = fileInput.files;
271
+ const statusDiv = document.getElementById("uploadStatus");
272
+ const progressContainer = document.getElementById("progressContainer");
273
+ const progressBar = document.getElementById("progressBar");
274
+ const progressText = document.getElementById("progressText");
275
+
276
  if (!files.length) {
277
+ alert("Please select at least one file.");
278
  return;
279
  }
280
 
281
+ // 1. Client-side Validation
282
+ for (let f of files) {
283
+ if (f.size > MAX_FILE_SIZE) {
284
+ alert(`File "${f.name}" is too large (>${MAX_FILE_SIZE / 1024 / 1024}MB). Max allowed is 50MB.`);
285
+ return;
286
+ }
287
+ }
288
+
289
+ // Reset UI
290
+ statusDiv.innerText = "";
291
+ statusDiv.style.color = "var(--text)";
292
+ progressContainer.style.display = "block";
293
+ progressBar.style.width = "0%";
294
+ progressText.innerText = "0%";
295
 
296
  const fd = new FormData();
297
  for (let f of files) fd.append("files", f);
298
 
299
+ // 2. Upload via XMLHttpRequest for progress events
300
+ const xhr = new XMLHttpRequest();
301
+
302
+ xhr.upload.addEventListener("progress", (event) => {
303
+ if (event.lengthComputable) {
304
+ const percent = Math.round((event.loaded / event.total) * 100);
305
+ progressBar.style.width = percent + "%";
306
+ progressText.innerText = percent + "%";
307
+ }
308
+ });
309
+
310
+
311
+
312
+ xhr.addEventListener("load", () => {
313
+ if (xhr.status >= 200 && xhr.status < 300) {
314
+ try {
315
+ const data = JSON.parse(xhr.responseText);
316
+ statusDiv.innerText = data.message || "Upload complete!";
317
+ statusDiv.style.color = "var(--success)";
318
+ progressBar.style.width = "100%";
319
+ progressText.innerText = "Processing complete";
320
+ fileInput.value = ""; // Clear input
321
+ } catch (e) {
322
+ statusDiv.innerText = "Error parsing server response.";
323
+ statusDiv.style.color = "var(--error)";
324
+ }
325
+ } else {
326
+ statusDiv.innerText = `Upload failed: ${xhr.statusText || xhr.status}`;
327
+ statusDiv.style.color = "var(--error)";
328
+ }
329
+ });
330
+
331
+ xhr.addEventListener("error", () => {
332
+ statusDiv.innerText = "Network error during upload.";
333
+ statusDiv.style.color = "var(--error)";
334
+ });
335
+
336
+ xhr.open("POST", "/upload");
337
+ xhr.send(fd);
338
+ }
339
+
340
+ // --- HISTORY LOGIC ---
341
+ function loadHistory() {
342
+ const list = document.getElementById("historyList");
343
+ list.innerHTML = "";
344
+ const history = JSON.parse(localStorage.getItem("rag_history") || "[]");
345
+
346
+ history.forEach((item, index) => {
347
+ const div = document.createElement("div");
348
+ div.className = "history-item";
349
+ div.innerText = item.query;
350
+ div.onclick = () => loadSession(index);
351
+ list.appendChild(div);
352
+ });
353
+ }
354
+
355
+ function saveToHistory(query, answerHtml) {
356
+ const history = JSON.parse(localStorage.getItem("rag_history") || "[]");
357
+ // Prepend new item
358
+ history.unshift({ query, answerHtml, timestamp: Date.now() });
359
+ // Keep max 50
360
+ if (history.length > 50) history.pop();
361
+
362
+ localStorage.setItem("rag_history", JSON.stringify(history));
363
+ loadHistory();
364
+ }
365
+
366
+ function loadSession(index) {
367
+ const history = JSON.parse(localStorage.getItem("rag_history") || "[]");
368
+ const item = history[index];
369
+ if (!item) return;
370
+
371
+ document.getElementById("question").value = item.query;
372
+ const box = document.getElementById("answerBox");
373
+ box.style.display = "block";
374
+ box.innerHTML = item.answerHtml;
375
+ }
376
+
377
+ function newChat() {
378
+ document.getElementById("question").value = "";
379
+ document.getElementById("answerBox").style.display = "none";
380
+ document.getElementById("answerBox").innerHTML = "";
381
+ }
382
+
383
+ function clearHistory() {
384
+ if (confirm("Clear all history?")) {
385
+ localStorage.removeItem("rag_history");
386
+ loadHistory();
387
+ newChat();
388
  }
 
389
  }
390
 
391
+ // Init
392
+ loadHistory();
393
+
394
  async function ask() {
395
  const q = document.getElementById("question").value.trim();
396
  if (!q) return;
397
 
 
398
  const box = document.getElementById("answerBox");
399
  box.style.display = "block";
400
+ box.innerHTML = "Thinking...";
401
 
402
+ const res = await fetch("/ask", {
403
+ method: "POST",
404
+ headers: { "Content-Type": "application/json" },
405
+ body: JSON.stringify({ prompt: q })
406
+ });
 
407
 
408
+ const data = await res.json();
409
 
410
+ let html = `<strong>Answer:</strong><br>${data.answer.replace(/\n/g, "<br>")}`;
411
 
412
+ if (data.confidence > 0) {
413
+ let label = "Low";
414
+ if (data.confidence >= 0.7) label = "High";
415
+ else if (data.confidence >= 0.5) label = "Medium";
416
 
417
+ html += `<div class="confidence-badge">Confidence: ${label} (${Math.round(data.confidence * 100)}%)</div>`;
418
+ }
 
 
 
 
 
 
 
419
 
420
+ if (data.citations?.length) {
421
+ html += "<div class='citations'><strong>Sources:</strong><ul>";
422
+ data.citations.forEach(c => {
423
+ html += `<li>${c.source} (Page ${c.page})</li>`;
424
+ });
425
+ html += "</ul></div>";
426
  }
427
 
428
+ box.innerHTML = html;
429
+ saveToHistory(q, html); // Save to history
430
  }
431
 
432
  function summarize() {
langgraph_rag.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import TypedDict, List, Optional
2
+ import google.generativeai as genai
3
+ from langgraph.graph import StateGraph, END
4
+
5
+ from rag_store import search_knowledge
6
+ from eval_logger import log_eval
7
+
8
+ MODEL_NAME = "gemini-2.5-flash"
9
+
10
+
11
+ # ===============================
12
+ # STATE
13
+ # ===============================
14
+ class RAGState(TypedDict):
15
+ query: str
16
+ retrieved_chunks: List[dict]
17
+ answer: Optional[str]
18
+ confidence: float
19
+ answer_known: bool
20
+
21
+
22
+ # ===============================
23
+ # RETRIEVAL NODE (TOOL)
24
+ # ===============================
25
+ def retrieve_node(state: RAGState) -> RAGState:
26
+ results = search_knowledge(state["query"])
27
+ return {
28
+ **state,
29
+ "retrieved_chunks": results
30
+ }
31
+
32
+
33
+ # ===============================
34
+ # ANSWER NODE
35
+ # ===============================
36
+ def answer_node(state: RAGState) -> RAGState:
37
+ if not state["retrieved_chunks"]:
38
+ return no_answer_node(state)
39
+
40
+ context = "\n\n".join(c["text"] for c in state["retrieved_chunks"])
41
+
42
+ prompt = f"""
43
+ Answer using ONLY the context below.
44
+ If the answer is not present, say "I don't know".
45
+
46
+ Context:
47
+ {context}
48
+
49
+ Question:
50
+ {state["query"]}
51
+ """
52
+
53
+ model = genai.GenerativeModel(MODEL_NAME)
54
+ resp = model.generate_content(prompt)
55
+ answer_text = resp.text
56
+
57
+ confidence = min(1.0, len(state["retrieved_chunks"]) / 5)
58
+ answer_known = "i don't know" not in answer_text.lower()
59
+
60
+ log_eval(
61
+ query=state["query"],
62
+ retrieved_count=len(state["retrieved_chunks"]),
63
+ confidence=confidence,
64
+ answer_known=answer_known
65
+ )
66
+
67
+ return {
68
+ **state,
69
+ "answer": answer_text,
70
+ "confidence": confidence,
71
+ "answer_known": answer_known
72
+ }
73
+
74
+
75
+ # ===============================
76
+ # NO ANSWER NODE
77
+ # ===============================
78
+ def no_answer_node(state: RAGState) -> RAGState:
79
+ log_eval(
80
+ query=state["query"],
81
+ retrieved_count=0,
82
+ confidence=0.0,
83
+ answer_known=False
84
+ )
85
+
86
+ return {
87
+ **state,
88
+ "answer": "I don't know based on the provided documents.",
89
+ "confidence": 0.0,
90
+ "answer_known": False
91
+ }
92
+
93
+
94
+ # ===============================
95
+ # GRAPH BUILDER
96
+ # ===============================
97
+ def build_rag_graph():
98
+ graph = StateGraph(RAGState)
99
+
100
+ graph.add_node("retrieve", retrieve_node)
101
+ graph.add_node("answer", answer_node)
102
+ graph.add_node("no_answer", no_answer_node)
103
+
104
+ graph.set_entry_point("retrieve")
105
+
106
+ graph.add_edge("retrieve", "answer")
107
+ graph.add_edge("answer", END)
108
+ graph.add_edge("no_answer", END)
109
+
110
+ return graph.compile()
main.py CHANGED
@@ -8,28 +8,24 @@ from pydantic import BaseModel
8
  from dotenv import load_dotenv
9
  import google.generativeai as genai
10
 
11
- from rag_store import ingest_documents, search_knowledge, get_all_chunks, clear_database
12
- from eval_logger import log_eval
13
  from analytics import get_analytics
 
14
 
15
  # =========================================================
16
- # ENV + MODEL SETUP
17
  # =========================================================
18
  load_dotenv()
19
  genai.configure(api_key=os.getenv("GEMINI_API_KEY"))
20
 
21
  MODEL_NAME = "gemini-2.5-flash"
22
- USE_MOCK = False # Set to False to use real API
23
-
24
- # =========================================================
25
- # FILE UPLOAD LIMITS
26
- # =========================================================
27
- MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
28
 
29
  # =========================================================
30
  # APP
31
  # =========================================================
32
- app = FastAPI(title="Gemini RAG FastAPI")
33
 
34
  app.add_middleware(
35
  CORSMiddleware,
@@ -41,9 +37,25 @@ app.add_middleware(
41
  app.mount("/frontend", StaticFiles(directory="frontend"), name="frontend")
42
 
43
  # =========================================================
44
- # CACHE (ANTI-429)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  # =========================================================
46
- CACHE_TTL = 300 # 5 minutes
47
  answer_cache: dict[str, tuple[float, dict]] = {}
48
 
49
  # =========================================================
@@ -52,64 +64,70 @@ answer_cache: dict[str, tuple[float, dict]] = {}
52
  class PromptRequest(BaseModel):
53
  prompt: str
54
 
 
 
 
55
  # =========================================================
56
  # ROUTES
57
  # =========================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  @app.get("/", response_class=HTMLResponse)
59
  def serve_ui():
60
  with open("frontend/index.html", "r", encoding="utf-8") as f:
61
  return f.read()
62
 
63
- @app.get("/analytics")
64
  def analytics():
65
- """Return analytics data from evaluation logs."""
66
  return get_analytics()
67
 
68
  # ---------------------------------------------------------
69
  # UPLOAD
70
  # ---------------------------------------------------------
71
- @app.post("/upload")
72
  async def upload(files: list[UploadFile] = File(...)):
73
- # 1. VALIDATION: File Type and Size Check
74
  for file in files:
75
  ext = file.filename.split(".")[-1].lower()
76
  if ext not in ["pdf", "txt"]:
77
  return JSONResponse(
78
- status_code=400,
79
- content={"error": f"Invalid file type: '{file.filename}'. Only .pdf and .txt files are allowed."}
80
  )
81
-
82
- # Check file size
83
- file.file.seek(0, 2) # Seek to end
84
- file_size = file.file.tell()
85
- file.file.seek(0) # Reset to beginning
86
-
87
- if file_size > MAX_FILE_SIZE:
88
- size_mb = file_size / (1024 * 1024)
89
- max_mb = MAX_FILE_SIZE / (1024 * 1024)
90
  return JSONResponse(
91
  status_code=413,
92
- content={"error": f"File '{file.filename}' is too large ({size_mb:.1f} MB). Maximum size is {max_mb:.0f} MB."}
93
  )
94
 
95
- try:
96
- # 2. CLEAR CONTEXT: Start fresh for every upload session
97
- clear_database()
98
- answer_cache.clear() # <--- CRITICAL: Clear the questions cache too!
99
-
100
- # 3. INGEST
101
- chunks = ingest_documents(files)
102
- return {"message": f"Successfully indexed {chunks} chunks. Previous context cleared."}
103
- except Exception as e:
104
- return JSONResponse(status_code=400, content={"error": str(e)})
105
 
106
  # ---------------------------------------------------------
107
- # ASK / SUMMARIZE
108
  # ---------------------------------------------------------
109
  @app.post("/ask")
110
  async def ask(data: PromptRequest):
111
- prompt_text = data.prompt.strip()
112
- key = prompt_text.lower()
113
  now = time()
114
 
115
  # ---------- CACHE ----------
@@ -118,212 +136,50 @@ async def ask(data: PromptRequest):
118
  if now - ts < CACHE_TTL:
119
  return cached
120
 
121
- model = genai.GenerativeModel(MODEL_NAME)
122
- is_summary = "summarize" in key or "summary" in key
123
-
124
- # =====================================================
125
- # 🟦 SUMMARY MODE (MAP–REDUCE)
126
- # =====================================================
127
- # Helper for rate-limit aware generation
128
- def generate_safe(prompt_content, retries=5):
129
- if USE_MOCK:
130
- import time as pytime
131
- pytime.sleep(1.5) # Simulate latency
132
- class MockResp:
133
- def __init__(self, text): self.text = text
134
- @property
135
- def prompt_feedback(self): return None
136
-
137
- if "Summarize" in str(prompt_content):
138
- return MockResp("- This is a mock summary point 1 (API limit reached).\n- This is point 2 demonstrating the UI works.\n- Point 3: The backend logic is sound.")
139
- elif "Combine" in str(prompt_content):
140
- return MockResp("Here are the final summarized points (MOCK MODE):\n\n* **System Integrity**: The RAG system is functioning correctly, handling file ingestion and chunking.\n* **Resilience**: Error handling and retry mechanisms are now in place.\n* **Mocking**: We are currently bypassing the live API to verify the frontend pipeline.\n* **Ready**: Once quotas reset, simply set USE_MOCK = False to resume live intelligence.\n* **Success**: The overall architecture is validated.")
141
- else:
142
- return MockResp("I am functioning in MOCK MODE because the daily API quota is exhausted. I cannot answer specific questions right now, but I confirm the system received your question: " + str(prompt_content)[:50] + "...")
143
-
144
- import time as pytime
145
- base_delay = 10
146
- for attempt in range(retries + 1):
147
- try:
148
- # Always small delay to be nice to the API
149
- pytime.sleep(2.0)
150
- response = model.generate_content(prompt_content)
151
- return response
152
- except Exception as e:
153
- err_str = str(e)
154
-
155
- # API Key Issues
156
- if "API_KEY" in err_str or "invalid" in err_str.lower() and "key" in err_str.lower():
157
- raise ValueError("Invalid API key. Please check your GEMINI_API_KEY in the .env file.")
158
-
159
- # Quota Exhausted
160
- if "quota" in err_str.lower() or "limit" in err_str.lower():
161
- raise ValueError("API quota exhausted. Please try again later or upgrade your API plan.")
162
-
163
- # Rate Limiting (429)
164
- if "429" in err_str:
165
- if attempt < retries:
166
- wait_time = base_delay * (2 ** attempt)
167
- print(f"DEBUG: 429 Rate limit hit. Retrying in {wait_time}s...")
168
- pytime.sleep(wait_time)
169
- continue
170
- else:
171
- raise ValueError("Rate limit exceeded. Please try again in a few minutes.")
172
-
173
- # Safety Filters
174
- if "safety" in err_str.lower() or "blocked" in err_str.lower():
175
- raise ValueError("Content was blocked by safety filters. Please rephrase your question.")
176
-
177
- # Generic error
178
- raise ValueError(f"LLM API error: {err_str}")
179
-
180
- if is_summary:
181
  chunks = get_all_chunks(limit=80)
182
- print(f"DEBUG: Found {len(chunks)} chunks for summary.")
183
-
184
- if not chunks:
185
- return {
186
- "answer": "No documents available to summarize.",
187
- "confidence": 0.0,
188
- "citations": []
189
- }
190
-
191
- # -----------------------------------------------------
192
- # REFACTORED: Single-Shot Summary (Avoids Rate Limits)
193
- # -----------------------------------------------------
194
- all_text = "\n\n".join(c["text"] for c in chunks)
195
- print(f"DEBUG: Total text length for summary: {len(all_text)} chars")
196
-
197
- prompt = f"""
198
- Summarize the following content in 5 clear, high-level bullet points.
199
 
200
- Content:
201
- {all_text}
202
- """
203
- try:
204
- # Single call with retry logic
205
- resp = generate_safe(prompt)
206
- print("DEBUG: Summary generation successful.")
207
-
208
- final_text = "Analysis complete."
209
- try:
210
- final_text = resp.text
211
- except ValueError:
212
- final_text = "Summary generation was blocked by safety filters."
213
-
214
- response = {
215
- "answer": final_text,
216
- "confidence": 0.95,
217
- "citations": list({
218
- (c["metadata"]["source"], c["metadata"]["page"]): c["metadata"]
219
- for c in chunks
220
- }.values())
221
- }
222
-
223
- answer_cache[key] = (now, response)
224
- return response
225
-
226
- except ValueError as e:
227
- # User-friendly error from generate_safe
228
- print(f"Summary failed: {e}")
229
- return JSONResponse(status_code=200, content={
230
- "answer": str(e),
231
- "confidence": 0.0,
232
- "citations": []
233
- })
234
- except Exception as e:
235
- print(f"Summary failed: {e}")
236
- return JSONResponse(status_code=500, content={
237
- "answer": f"An unexpected error occurred: {str(e)}",
238
- "confidence": 0.0,
239
- "citations": []
240
- })
241
-
242
- # =====================================================
243
- # 🟩 Q&A MODE (RAG)
244
- # =====================================================
245
- results = search_knowledge(prompt_text)
246
 
247
- if not results:
248
  response = {
249
- "answer": "I don't know based on the provided documents.",
250
- "confidence": 0.0,
251
  "citations": []
252
  }
253
 
254
- log_eval(
255
- query=prompt_text,
256
- retrieved_count=0,
257
- confidence=0.0,
258
- answer_known=False
259
- )
260
-
261
  answer_cache[key] = (now, response)
262
  return response
263
 
264
- context = "\n\n".join(r["text"] for r in results)
265
-
266
- # DEBUG: Log the context to see what the model is reading
267
- print("DEBUG: ------------------- RAG CONTEXT -------------------")
268
- print(context[:2000] + ("..." if len(context) > 2000 else ""))
269
- print("DEBUG: ---------------------------------------------------")
270
-
271
- prompt = f"""
272
- Answer using ONLY the context below.
273
- If the answer is not present, say "I don't know".
274
-
275
- Context:
276
- {context}
277
-
278
- Question:
279
- {prompt_text}
280
- """
281
- llm = None
282
- answer_text = ""
283
-
284
- try:
285
- llm = model.generate_content(prompt)
286
- answer_text = llm.text
287
- except ValueError as e:
288
- # User-friendly error from API
289
- response = {
290
- "answer": str(e),
291
- "confidence": 0.0,
292
- "citations": []
293
- }
294
- answer_cache[key] = (now, response)
295
- return response
296
- except Exception as e:
297
- # Unexpected error
298
- response = {
299
- "answer": f"An unexpected error occurred: {str(e)}",
300
- "confidence": 0.0,
301
- "citations": []
302
- }
303
- return JSONResponse(status_code=500, content=response)
304
-
305
- # Fix Fake Confidence: If the model says "I don't know", confidence should be 0.
306
- confidence = round(min(1.0, len(results) / 5), 2)
307
- if "i don't know" in answer_text.lower():
308
- confidence = 0.0
309
 
310
  response = {
311
- "answer": answer_text,
312
- "confidence": confidence,
313
  "citations": list({
314
- (r["metadata"]["source"], r["metadata"]["page"]): r["metadata"]
315
- for r in results
316
  }.values())
317
  }
318
 
319
- answer_known = "i don't know" not in answer_text.lower()
320
-
321
- log_eval(
322
- query=prompt_text,
323
- retrieved_count=len(results),
324
- confidence=confidence,
325
- answer_known=answer_known
326
- )
327
-
328
  answer_cache[key] = (now, response)
329
  return response
 
8
  from dotenv import load_dotenv
9
  import google.generativeai as genai
10
 
11
+ from rag_store import ingest_documents, get_all_chunks, clear_database
 
12
  from analytics import get_analytics
13
+ from agentic_rag_v2_graph import build_agentic_rag_v2_graph
14
 
15
  # =========================================================
16
+ # ENV + MODEL
17
  # =========================================================
18
  load_dotenv()
19
  genai.configure(api_key=os.getenv("GEMINI_API_KEY"))
20
 
21
  MODEL_NAME = "gemini-2.5-flash"
22
+ MAX_FILE_SIZE = 50 * 1024 * 1024
23
+ CACHE_TTL = 300
 
 
 
 
24
 
25
  # =========================================================
26
  # APP
27
  # =========================================================
28
+ app = FastAPI(title="Gemini RAG FastAPI (Agentic RAG v2+)")
29
 
30
  app.add_middleware(
31
  CORSMiddleware,
 
37
  app.mount("/frontend", StaticFiles(directory="frontend"), name="frontend")
38
 
39
  # =========================================================
40
+ # SECURITY
41
+ # =========================================================
42
+ from fastapi import Request, HTTPException, Depends
43
+ from fastapi.security import APIKeyCookie
44
+
45
+ ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "secret")
46
+ COOKIE_NAME = "rag_auth"
47
+
48
+ api_key_cookie = APIKeyCookie(name=COOKIE_NAME, auto_error=False)
49
+
50
+ async def verify_admin(cookie: str = Depends(api_key_cookie)):
51
+ if cookie != ADMIN_PASSWORD:
52
+ raise HTTPException(status_code=401, detail="Unauthorized")
53
+ return cookie
54
+
55
+ # =========================================================
56
+ # STATE
57
  # =========================================================
58
+ agentic_graph = build_agentic_rag_v2_graph()
59
  answer_cache: dict[str, tuple[float, dict]] = {}
60
 
61
  # =========================================================
 
64
  class PromptRequest(BaseModel):
65
  prompt: str
66
 
67
+ class LoginRequest(BaseModel):
68
+ password: str
69
+
70
  # =========================================================
71
  # ROUTES
72
  # =========================================================
73
+ @app.post("/login")
74
+ def login(data: LoginRequest):
75
+ if data.password != ADMIN_PASSWORD:
76
+ raise HTTPException(status_code=401, detail="Invalid password")
77
+
78
+ response = JSONResponse(content={"message": "Logged in"})
79
+ response.set_cookie(key=COOKIE_NAME, value=data.password, httponly=True)
80
+ return response
81
+
82
+ @app.get("/me")
83
+ def me(user: str = Depends(verify_admin)):
84
+ return {"status": "authenticated"}
85
+
86
  @app.get("/", response_class=HTMLResponse)
87
  def serve_ui():
88
  with open("frontend/index.html", "r", encoding="utf-8") as f:
89
  return f.read()
90
 
91
+ @app.get("/analytics", dependencies=[Depends(verify_admin)])
92
  def analytics():
 
93
  return get_analytics()
94
 
95
  # ---------------------------------------------------------
96
  # UPLOAD
97
  # ---------------------------------------------------------
98
+ @app.post("/upload", dependencies=[Depends(verify_admin)])
99
  async def upload(files: list[UploadFile] = File(...)):
 
100
  for file in files:
101
  ext = file.filename.split(".")[-1].lower()
102
  if ext not in ["pdf", "txt"]:
103
  return JSONResponse(
104
+ status_code=400,
105
+ content={"error": "Only PDF and TXT files allowed"}
106
  )
107
+
108
+ file.file.seek(0, 2)
109
+ size = file.file.tell()
110
+ file.file.seek(0)
111
+
112
+ if size > MAX_FILE_SIZE:
 
 
 
113
  return JSONResponse(
114
  status_code=413,
115
+ content={"error": "File too large"}
116
  )
117
 
118
+ clear_database()
119
+ answer_cache.clear()
120
+ chunks = ingest_documents(files)
121
+
122
+ return {"message": f"Indexed {chunks} chunks successfully."}
 
 
 
 
 
123
 
124
  # ---------------------------------------------------------
125
+ # ASK
126
  # ---------------------------------------------------------
127
  @app.post("/ask")
128
  async def ask(data: PromptRequest):
129
+ query = data.prompt.strip()
130
+ key = query.lower()
131
  now = time()
132
 
133
  # ---------- CACHE ----------
 
136
  if now - ts < CACHE_TTL:
137
  return cached
138
 
139
+ # ==========================
140
+ # 🟦 SUMMARY (BYPASS AGENT)
141
+ # ==========================
142
+ if "summary" in key or "summarize" in key:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  chunks = get_all_chunks(limit=80)
144
+ context = "\n\n".join(c["text"] for c in chunks)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
 
146
+ model = genai.GenerativeModel(MODEL_NAME)
147
+ resp = model.generate_content(
148
+ f"Summarize the following content clearly:\n\n{context}"
149
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
 
 
151
  response = {
152
+ "answer": resp.text,
153
+ "confidence": 0.95,
154
  "citations": []
155
  }
156
 
 
 
 
 
 
 
 
157
  answer_cache[key] = (now, response)
158
  return response
159
 
160
+ # ==========================
161
+ # 🟩 AGENTIC RAG (LLM + EVALUATION)
162
+ # ==========================
163
+ result = agentic_graph.invoke({
164
+ "query": query,
165
+ "refined_query": "",
166
+ "decision": "",
167
+ "retrieved_chunks": [],
168
+ "retrieval_quality": "",
169
+ "retries": 0,
170
+ "answer": None,
171
+ "confidence": 0.0,
172
+ "answer_known": False
173
+ })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
 
175
  response = {
176
+ "answer": result["answer"],
177
+ "confidence": result["confidence"],
178
  "citations": list({
179
+ (c["metadata"]["source"], c["metadata"]["page"]): c["metadata"]
180
+ for c in result.get("retrieved_chunks", [])
181
  }.values())
182
  }
183
 
 
 
 
 
 
 
 
 
 
184
  answer_cache[key] = (now, response)
185
  return response
migrate_bm25.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from rag_store import load_db, save_db, documents, bm25
2
+ from rank_bm25 import BM25Okapi
3
+ import pickle
4
+
5
+ print("Loading DB...")
6
+ load_db()
7
+
8
+ if not documents:
9
+ print("No documents found. Nothing to do.")
10
+ else:
11
+ print(f"Found {len(documents)} documents.")
12
+ print("Building BM25 index...")
13
+ tokenized_corpus = [doc.split(" ") for doc in documents]
14
+
15
+ # We need to update the global variable in rag_store, but since we imported 'bm25' (by value? no, python imports names),
16
+ # we need to actually set it in the module or just use the save logic.
17
+ # Actually, simplistic way:
18
+ import rag_store
19
+ rag_store.bm25 = BM25Okapi(tokenized_corpus)
20
+
21
+ print("Saving DB with BM25...")
22
+ rag_store.save_db()
23
+ print("Done!")
rag_eval_logs.jsonl CHANGED
@@ -21,3 +21,43 @@
21
  {"timestamp": 1767776180.9555495, "query": "what are the visa conditions?", "retrieved_count": 5, "confidence": 0.0, "answer_known": false}
22
  {"timestamp": 1767776250.0441537, "query": "tell me about program information?", "retrieved_count": 5, "confidence": 1.0, "answer_known": true}
23
  {"timestamp": 1767777566.4153016, "query": "what was the role ?", "retrieved_count": 3, "confidence": 0.6, "answer_known": true}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  {"timestamp": 1767776180.9555495, "query": "what are the visa conditions?", "retrieved_count": 5, "confidence": 0.0, "answer_known": false}
22
  {"timestamp": 1767776250.0441537, "query": "tell me about program information?", "retrieved_count": 5, "confidence": 1.0, "answer_known": true}
23
  {"timestamp": 1767777566.4153016, "query": "what was the role ?", "retrieved_count": 3, "confidence": 0.6, "answer_known": true}
24
+ {"timestamp": 1767800814.2009513, "query": "what is project name?", "retrieved_count": 5, "confidence": 1.0, "answer_known": true}
25
+ {"timestamp": 1767800836.0129147, "query": "what is watson AI?", "retrieved_count": 5, "confidence": 1.0, "answer_known": true}
26
+ {"timestamp": 1767800871.1301703, "query": "what is visa ?", "retrieved_count": 5, "confidence": 0.0, "answer_known": false}
27
+ {"timestamp": 1767947326.4927118, "query": "summarize the uploaded documents", "retrieved_count": 5, "confidence": 1.0, "answer_known": false}
28
+ {"timestamp": 1767947424.8707786, "query": "summarize the uploaded documents", "retrieved_count": 5, "confidence": 1.0, "answer_known": true}
29
+ {"timestamp": 1767947471.041334, "query": "what is the course name?", "retrieved_count": 5, "confidence": 1.0, "answer_known": false}
30
+ {"timestamp": 1767947586.3758693, "query": "what is the application id?", "retrieved_count": 5, "confidence": 1.0, "answer_known": false}
31
+ {"timestamp": 1767948654.5804863, "query": "summarize the uploaded documents", "retrieved_count": 5, "confidence": 1.0, "answer_known": true}
32
+ {"timestamp": 1767948704.0778553, "query": "what is university name?", "retrieved_count": 5, "confidence": 1.0, "answer_known": true}
33
+ {"timestamp": 1767948725.2018607, "query": "what is program name?", "retrieved_count": 5, "confidence": 0.0, "answer_known": false}
34
+ {"timestamp": 1767948743.1319876, "query": "what is course name?", "retrieved_count": 5, "confidence": 0.0, "answer_known": false}
35
+ {"timestamp": 1767948761.2565615, "query": "what is application id ?", "retrieved_count": 5, "confidence": 0.0, "answer_known": false}
36
+ {"timestamp": 1767948799.2538924, "query": "what is name of student?", "retrieved_count": 5, "confidence": 0.0, "answer_known": false}
37
+ {"timestamp": 1767948996.8832078, "query": "what is country name ?", "retrieved_count": 5, "confidence": 1.0, "answer_known": true}
38
+ {"timestamp": 1767949032.4865937, "query": "whats the program name and duration?", "retrieved_count": 5, "confidence": 1.0, "answer_known": true}
39
+ {"timestamp": 1767949053.9461539, "query": "whats the course name and duration?", "retrieved_count": 5, "confidence": 1.0, "answer_known": true}
40
+ {"timestamp": 1767949089.903896, "query": "tell me the process of refund policy?", "retrieved_count": 5, "confidence": 1.0, "answer_known": true}
41
+ {"timestamp": 1767949143.9379044, "query": "application id?", "retrieved_count": 5, "confidence": 0.0, "answer_known": false}
42
+ {"timestamp": 1767949182.8464582, "query": "what is the student id?", "retrieved_count": 5, "confidence": 0.0, "answer_known": false}
43
+ {"timestamp": 1767949216.585371, "query": "what is the program plan code?", "retrieved_count": 5, "confidence": 0.0, "answer_known": false}
44
+ {"timestamp": 1767949349.670479, "query": "what is the commencement date ?", "retrieved_count": 5, "confidence": 0.0, "answer_known": false}
45
+ {"timestamp": 1767949392.793658, "query": "summarize the uploaded documents", "retrieved_count": 5, "confidence": 0.0, "answer_known": false}
46
+ {"timestamp": 1767949909.9175289, "query": "what is the Commencement Date?", "retrieved_count": 5, "confidence": 0.0, "answer_known": false}
47
+ {"timestamp": 1768032006.1977339, "query": "what is duration?", "retrieved_count": 5, "confidence": 0.0, "answer_known": false}
48
+ {"timestamp": 1768032029.513309, "query": "whats the program name?", "retrieved_count": 5, "confidence": 0.0, "answer_known": false}
49
+ {"timestamp": 1768032061.2607996, "query": "what is university name?", "retrieved_count": 5, "confidence": 1.0, "answer_known": true}
50
+ {"timestamp": 1768032085.8119817, "query": "what is program duration?", "retrieved_count": 5, "confidence": 0.0, "answer_known": false}
51
+ {"timestamp": 1768032129.063438, "query": "what are the refund policy rules?", "retrieved_count": 5, "confidence": 1.0, "answer_known": true}
52
+ {"timestamp": 1768032174.2048614, "query": "what is the master of artificial intelligence program durtaion?", "retrieved_count": 5, "confidence": 0.0, "answer_known": false}
53
+ {"timestamp": 1768032198.3882332, "query": "what is the course duration?", "retrieved_count": 5, "confidence": 1.0, "answer_known": true}
54
+ {"timestamp": 1768032243.0287364, "query": "what are the visa rules ?", "retrieved_count": 5, "confidence": 1.0, "answer_known": true}
55
+ {"timestamp": 1768034581.1382725, "query": "how much of deposit to pay?", "retrieved_count": 5, "confidence": 1.0, "answer_known": true}
56
+ {"timestamp": 1768036053.733725, "query": "What is the tuition fee?", "retrieved_count": 5, "confidence": 1.0, "answer_known": true}
57
+ {"timestamp": 1768036088.6985803, "query": "Is Melbourne a good city?", "retrieved_count": 0, "confidence": 0.3, "answer_known": true}
58
+ {"timestamp": 1768056940.6508985, "query": "what is the AIDA framework", "retrieved_count": 0, "confidence": 0.3, "answer_known": true}
59
+ {"timestamp": 1768056996.7694573, "query": "what is meant by Landing Page Hero Section (AIDA Framework)?", "retrieved_count": 0, "confidence": 0.3, "answer_known": true}
60
+ {"timestamp": 1768057049.903009, "query": "what is Error Messages & Microcopy", "retrieved_count": 0, "confidence": 0.3, "answer_known": true}
61
+ {"timestamp": 1768121326.683464, "query": "what is the location ?", "retrieved_count": 5, "confidence": 1.0, "answer_known": true}
62
+ {"timestamp": 1768121412.7319663, "query": "what is the conditions for OSHC?", "retrieved_count": 5, "confidence": 1.0, "answer_known": true}
63
+ {"timestamp": 1768207382.1637495, "query": "what is application ID number?", "retrieved_count": 5, "confidence": 0.95, "answer_known": false}
rag_store.py CHANGED
@@ -3,10 +3,8 @@ import os
3
  import pickle
4
  from pypdf import PdfReader
5
  from sentence_transformers import SentenceTransformer, CrossEncoder
 
6
 
7
- # =========================================================
8
- # CONFIG
9
- # =========================================================
10
  USE_HNSW = True
11
  USE_RERANKER = True
12
 
@@ -15,24 +13,20 @@ CHUNK_OVERLAP = 200
15
 
16
  DB_FILE_INDEX = "vector.index"
17
  DB_FILE_META = "metadata.pkl"
 
18
 
19
- # =========================================================
20
- # GLOBAL STATE
21
- # =========================================================
22
  index = None
23
  documents = []
24
  metadata = []
 
 
25
 
26
  embedder = SentenceTransformer("all-MiniLM-L6-v2")
27
  reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
28
 
29
- # =========================================================
30
- # HELPERS
31
- # =========================================================
32
  def chunk_text(text):
33
  import re
34
  sentences = re.split(r'(?<=[.!?])\s+', text)
35
-
36
  chunks, current = [], ""
37
  for s in sentences:
38
  if len(current) + len(s) > CHUNK_SIZE and current:
@@ -41,152 +35,154 @@ def chunk_text(text):
41
  current = current[overlap:] + " " + s
42
  else:
43
  current += " " + s if current else s
44
-
45
  if current.strip():
46
  chunks.append(current.strip())
47
  return chunks
48
 
49
-
50
  def save_db():
51
  if index:
52
  faiss.write_index(index, DB_FILE_INDEX)
53
  if documents:
54
  with open(DB_FILE_META, "wb") as f:
55
  pickle.dump({"documents": documents, "metadata": metadata}, f)
56
-
 
 
57
 
58
  def load_db():
59
- global index, documents, metadata
60
  if os.path.exists(DB_FILE_INDEX) and os.path.exists(DB_FILE_META):
61
  index = faiss.read_index(DB_FILE_INDEX)
62
  with open(DB_FILE_META, "rb") as f:
63
  data = pickle.load(f)
64
  documents = data["documents"]
65
  metadata = data["metadata"]
66
- print(f"DEBUG: Loaded {len(documents)} chunks")
67
-
 
 
 
 
 
 
 
 
 
68
 
69
  load_db()
70
 
71
-
72
  def clear_database():
73
- global index, documents, metadata
74
  index = None
75
  documents = []
76
  metadata = []
77
-
78
  if os.path.exists(DB_FILE_INDEX):
79
  os.remove(DB_FILE_INDEX)
80
  if os.path.exists(DB_FILE_META):
81
  os.remove(DB_FILE_META)
 
 
82
 
83
-
84
- # =========================================================
85
- # INGEST
86
- # =========================================================
87
  def ingest_documents(files):
88
  global index, documents, metadata
89
-
90
  texts, meta = [], []
91
 
92
  for file in files:
93
- name = file.filename
94
-
95
- if name.endswith(".pdf"):
96
  reader = PdfReader(file.file)
97
  for i, page in enumerate(reader.pages):
98
- try:
99
- text = page.extract_text()
100
- except Exception:
101
- text = None
102
-
103
  if text:
104
  for chunk in chunk_text(text):
105
  texts.append(chunk)
106
- meta.append({"source": name, "page": i + 1})
107
 
108
- elif name.endswith(".txt"):
109
  content = file.file.read().decode("utf-8", errors="ignore")
110
  for chunk in chunk_text(content):
111
  texts.append(chunk)
112
- meta.append({"source": name, "page": "N/A"})
113
 
114
  if not texts:
115
- raise ValueError(
116
- "No readable text found. "
117
- "If this is a scanned PDF, OCR is required."
118
- )
119
 
120
- embeddings = embedder.encode(
121
- texts,
122
- convert_to_numpy=True,
123
- normalize_embeddings=True
124
- )
125
 
126
  if index is None:
127
  dim = embeddings.shape[1]
128
- if USE_HNSW:
129
- index = faiss.IndexHNSWFlat(dim, 32)
130
- index.hnsw.efConstruction = 200
131
- index.hnsw.efSearch = 64
132
- else:
133
- index = faiss.IndexFlatIP(dim)
134
 
135
  index.add(embeddings)
136
  documents.extend(texts)
137
  metadata.extend(meta)
138
-
 
 
 
 
139
  save_db()
140
  return len(documents)
141
 
142
-
143
- # =========================================================
144
- # SEARCH
145
- # =========================================================
146
- def search_knowledge(query, top_k=8, min_similarity=0.25):
147
  if index is None:
148
  return []
149
 
150
- qvec = embedder.encode(
151
- [query],
152
- convert_to_numpy=True,
153
- normalize_embeddings=True
154
- )
155
-
156
  scores, indices = index.search(qvec, top_k)
157
- candidates = []
158
- ql = query.lower()
159
-
160
- for idx, score in zip(indices[0], scores[0]):
161
- if idx == -1:
162
- continue
163
-
164
- text = documents[idx]
165
- meta = metadata[idx]
166
- keyword_hits = sum(w in text.lower() for w in ql.split())
167
- hybrid_score = float(score) + (0.05 * keyword_hits)
168
-
169
- if hybrid_score >= min_similarity:
170
- candidates.append({
171
- "text": text,
172
- "metadata": meta,
173
- "hybrid_score": hybrid_score
174
- })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
 
176
  if USE_RERANKER and candidates:
177
  pairs = [(query, c["text"]) for c in candidates]
178
- scores = reranker.predict(pairs)
179
- for c, s in zip(candidates, scores):
180
- c["rerank"] = float(s)
181
  candidates.sort(key=lambda x: x["rerank"], reverse=True)
182
- else:
183
- candidates.sort(key=lambda x: x["hybrid_score"], reverse=True)
184
 
185
  return candidates[:5]
186
 
187
-
188
- def get_all_chunks(limit=50):
189
- return [
190
- {"text": t, "metadata": m}
191
- for t, m in zip(documents[:limit], metadata[:limit])
192
- ]
 
3
  import pickle
4
  from pypdf import PdfReader
5
  from sentence_transformers import SentenceTransformer, CrossEncoder
6
+ from rank_bm25 import BM25Okapi
7
 
 
 
 
8
  USE_HNSW = True
9
  USE_RERANKER = True
10
 
 
13
 
14
  DB_FILE_INDEX = "vector.index"
15
  DB_FILE_META = "metadata.pkl"
16
+ DB_FILE_BM25 = "bm25.pkl"
17
 
 
 
 
18
  index = None
19
  documents = []
20
  metadata = []
21
+ bm25 = None
22
+ tokenized_corpus = []
23
 
24
  embedder = SentenceTransformer("all-MiniLM-L6-v2")
25
  reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
26
 
 
 
 
27
  def chunk_text(text):
28
  import re
29
  sentences = re.split(r'(?<=[.!?])\s+', text)
 
30
  chunks, current = [], ""
31
  for s in sentences:
32
  if len(current) + len(s) > CHUNK_SIZE and current:
 
35
  current = current[overlap:] + " " + s
36
  else:
37
  current += " " + s if current else s
 
38
  if current.strip():
39
  chunks.append(current.strip())
40
  return chunks
41
 
 
42
  def save_db():
43
  if index:
44
  faiss.write_index(index, DB_FILE_INDEX)
45
  if documents:
46
  with open(DB_FILE_META, "wb") as f:
47
  pickle.dump({"documents": documents, "metadata": metadata}, f)
48
+ if bm25:
49
+ with open(DB_FILE_BM25, "wb") as f:
50
+ pickle.dump(bm25, f)
51
 
52
  def load_db():
53
+ global index, documents, metadata, bm25
54
  if os.path.exists(DB_FILE_INDEX) and os.path.exists(DB_FILE_META):
55
  index = faiss.read_index(DB_FILE_INDEX)
56
  with open(DB_FILE_META, "rb") as f:
57
  data = pickle.load(f)
58
  documents = data["documents"]
59
  metadata = data["metadata"]
60
+
61
+ if os.path.exists(DB_FILE_BM25):
62
+ with open(DB_FILE_BM25, "rb") as f:
63
+ bm25 = pickle.load(f)
64
+ elif documents:
65
+ # Auto-backfill if documents exist but BM25 is missing
66
+ print("Backfilling BM25 index on first load...")
67
+ tokenized_corpus = [doc.split(" ") for doc in documents]
68
+ bm25 = BM25Okapi(tokenized_corpus)
69
+ with open(DB_FILE_BM25, "wb") as f:
70
+ pickle.dump(bm25, f)
71
 
72
  load_db()
73
 
 
74
  def clear_database():
75
+ global index, documents, metadata, bm25
76
  index = None
77
  documents = []
78
  metadata = []
79
+ bm25 = None
80
  if os.path.exists(DB_FILE_INDEX):
81
  os.remove(DB_FILE_INDEX)
82
  if os.path.exists(DB_FILE_META):
83
  os.remove(DB_FILE_META)
84
+ if os.path.exists(DB_FILE_BM25):
85
+ os.remove(DB_FILE_BM25)
86
 
 
 
 
 
87
  def ingest_documents(files):
88
  global index, documents, metadata
 
89
  texts, meta = [], []
90
 
91
  for file in files:
92
+ if file.filename.endswith(".pdf"):
 
 
93
  reader = PdfReader(file.file)
94
  for i, page in enumerate(reader.pages):
95
+ text = page.extract_text()
 
 
 
 
96
  if text:
97
  for chunk in chunk_text(text):
98
  texts.append(chunk)
99
+ meta.append({"source": file.filename, "page": i + 1})
100
 
101
+ elif file.filename.endswith(".txt"):
102
  content = file.file.read().decode("utf-8", errors="ignore")
103
  for chunk in chunk_text(content):
104
  texts.append(chunk)
105
+ meta.append({"source": file.filename, "page": "N/A"})
106
 
107
  if not texts:
108
+ raise ValueError("No readable text found (OCR needed for scanned PDFs).")
 
 
 
109
 
110
+ embeddings = embedder.encode(texts, convert_to_numpy=True, normalize_embeddings=True)
 
 
 
 
111
 
112
  if index is None:
113
  dim = embeddings.shape[1]
114
+ index = faiss.IndexHNSWFlat(dim, 32) if USE_HNSW else faiss.IndexFlatIP(dim)
115
+ index.hnsw.efConstruction = 200
116
+ index.hnsw.efSearch = 64
 
 
 
117
 
118
  index.add(embeddings)
119
  documents.extend(texts)
120
  metadata.extend(meta)
121
+
122
+ # Update BM25
123
+ tokenized_corpus = [doc.split(" ") for doc in documents]
124
+ bm25 = BM25Okapi(tokenized_corpus)
125
+
126
  save_db()
127
  return len(documents)
128
 
129
+ def search_knowledge(query, top_k=8):
 
 
 
 
130
  if index is None:
131
  return []
132
 
133
+ # 1. Vector Search
134
+ qvec = embedder.encode([query], convert_to_numpy=True, normalize_embeddings=True)
 
 
 
 
135
  scores, indices = index.search(qvec, top_k)
136
+
137
+ vector_results = {}
138
+ for i, (idx, score) in enumerate(zip(indices[0], scores[0])):
139
+ if idx == -1: continue
140
+ vector_results[idx] = i # Store rank (0-based)
141
+
142
+ # 2. Keyword Search (BM25)
143
+ bm25_results = {}
144
+ if bm25:
145
+ tokenized_query = query.split(" ")
146
+ bm25_scores = bm25.get_scores(tokenized_query)
147
+ # Get top_k indices
148
+ top_n = sorted(range(len(bm25_scores)), key=lambda i: bm25_scores[i], reverse=True)[:top_k]
149
+ for i, idx in enumerate(top_n):
150
+ bm25_results[idx] = i # Store rank
151
+
152
+ # 3. Reciprocal Rank Fusion (RRF)
153
+ # score = 1 / (k + rank)
154
+ k = 60
155
+ candidates_idx = set(vector_results.keys()) | set(bm25_results.keys())
156
+ merged_candidates = []
157
+
158
+ for idx in candidates_idx:
159
+ v_rank = vector_results.get(idx, float('inf'))
160
+ b_rank = bm25_results.get(idx, float('inf'))
161
+
162
+ rrf_score = (1 / (k + v_rank)) + (1 / (k + b_rank))
163
+
164
+ merged_candidates.append({
165
+ "text": documents[idx],
166
+ "metadata": metadata[idx],
167
+ "score": rrf_score, # This is RRF score, not cosine/BM25 score
168
+ "vector_rank": v_rank if v_rank != float('inf') else None,
169
+ "bm25_rank": b_rank if b_rank != float('inf') else None
170
+ })
171
+
172
+ # Sort by RRF score
173
+ merged_candidates.sort(key=lambda x: x["score"], reverse=True)
174
+
175
+ # 4. Rerank Top Candidates
176
+ candidates = merged_candidates[:10] # Take top 10 for reranking
177
 
178
  if USE_RERANKER and candidates:
179
  pairs = [(query, c["text"]) for c in candidates]
180
+ rerank_scores = reranker.predict(pairs)
181
+ for c, rs in zip(candidates, rerank_scores):
182
+ c["rerank"] = float(rs)
183
  candidates.sort(key=lambda x: x["rerank"], reverse=True)
 
 
184
 
185
  return candidates[:5]
186
 
187
+ def get_all_chunks(limit=80):
188
+ return [{"text": t, "metadata": m} for t, m in zip(documents[:limit], metadata[:limit])]
 
 
 
 
requirements.txt CHANGED
@@ -7,3 +7,5 @@ sentence-transformers
7
  pypdf
8
  numpy
9
  python-multipart
 
 
 
7
  pypdf
8
  numpy
9
  python-multipart
10
+
11
+ rank_bm25