Commit
·
88cc76a
1
Parent(s):
06ee524
Remove auth, fix quota issues with retries, and update agent graph
Browse files- agentic_rag_graph.py +160 -0
- frontend/analytics.html +242 -241
- frontend/index.html +266 -184
- langgraph_rag.py +110 -0
- main.py +88 -232
- migrate_bm25.py +23 -0
- rag_eval_logs.jsonl +40 -0
- rag_store.py +87 -91
- requirements.txt +2 -0
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 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
</head>
|
| 194 |
|
| 195 |
<body>
|
| 196 |
-
|
| 197 |
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 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 |
-
<
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
<div class="empty-state">
|
| 242 |
<h2>No data yet</h2>
|
| 243 |
<p>Start asking questions to see analytics!</p>
|
| 244 |
</div>
|
| 245 |
`;
|
| 246 |
-
|
| 247 |
-
|
| 248 |
|
| 249 |
-
|
| 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 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
<div class="empty-state">
|
| 322 |
<h2>Error loading analytics</h2>
|
| 323 |
<p>${e.message}</p>
|
| 324 |
</div>
|
| 325 |
`;
|
| 326 |
-
|
| 327 |
-
|
| 328 |
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 88 |
-
|
| 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 |
-
|
| 132 |
-
|
| 133 |
-
|
| 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:
|
| 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 |
-
<
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 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 |
-
<
|
|
|
|
| 236 |
</div>
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
</div>
|
| 245 |
</div>
|
| 246 |
-
|
| 247 |
-
<div id="answerBox" class="answer" style="display:none;"></div>
|
| 248 |
-
|
| 249 |
</div>
|
| 250 |
|
| 251 |
<script>
|
| 252 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
if (!files.length) {
|
| 287 |
-
alert("Please select
|
| 288 |
return;
|
| 289 |
}
|
| 290 |
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
|
| 295 |
const fd = new FormData();
|
| 296 |
for (let f of files) fd.append("files", f);
|
| 297 |
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = "
|
| 317 |
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
});
|
| 324 |
|
| 325 |
-
|
| 326 |
|
| 327 |
-
|
| 328 |
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
|
|
|
| 332 |
|
| 333 |
-
|
| 334 |
-
|
| 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 |
-
|
| 344 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
}
|
| 346 |
|
| 347 |
-
|
|
|
|
| 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,
|
| 12 |
-
from eval_logger import log_eval
|
| 13 |
from analytics import get_analytics
|
|
|
|
| 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 |
-
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
# =========================================================
|
| 46 |
-
|
| 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":
|
| 80 |
)
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
file.file.
|
| 84 |
-
|
| 85 |
-
|
| 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":
|
| 93 |
)
|
| 94 |
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 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
|
| 108 |
# ---------------------------------------------------------
|
| 109 |
@app.post("/ask")
|
| 110 |
async def ask(data: PromptRequest):
|
| 111 |
-
|
| 112 |
-
key =
|
| 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 |
-
|
| 122 |
-
|
| 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 |
-
|
| 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 |
-
|
| 201 |
-
|
| 202 |
-
""
|
| 203 |
-
|
| 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":
|
| 250 |
-
"confidence": 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 |
-
|
| 265 |
-
|
| 266 |
-
#
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 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":
|
| 312 |
-
"confidence": confidence,
|
| 313 |
"citations": list({
|
| 314 |
-
(
|
| 315 |
-
for
|
| 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 |
-
|
| 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 |
-
|
| 94 |
-
|
| 95 |
-
if name.endswith(".pdf"):
|
| 96 |
reader = PdfReader(file.file)
|
| 97 |
for i, page in enumerate(reader.pages):
|
| 98 |
-
|
| 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":
|
| 107 |
|
| 108 |
-
elif
|
| 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":
|
| 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 |
-
|
| 130 |
-
|
| 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 |
-
|
| 151 |
-
|
| 152 |
-
convert_to_numpy=True,
|
| 153 |
-
normalize_embeddings=True
|
| 154 |
-
)
|
| 155 |
-
|
| 156 |
scores, indices = index.search(qvec, top_k)
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
|
| 176 |
if USE_RERANKER and candidates:
|
| 177 |
pairs = [(query, c["text"]) for c in candidates]
|
| 178 |
-
|
| 179 |
-
for c,
|
| 180 |
-
c["rerank"] = float(
|
| 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 |
-
|
| 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
|