yugbirla commited on
Commit
b0aa836
·
1 Parent(s): 9b441aa

Fix final UI export syntax issue

Browse files
app/deployment/hf_status.py CHANGED
@@ -6213,3 +6213,8 @@ window.sendMessage = async function() {
6213
  html = html.replace("</body>", js + "\n</body>")
6214
 
6215
  return html
 
 
 
 
 
 
6213
  html = html.replace("</body>", js + "\n</body>")
6214
 
6215
  return html
6216
+ # =====================================================
6217
+ # Phase 38 final stable UI export
6218
+ # This keeps old helper functions but forces / and /app to use the clean final UI.
6219
+ # =====================================================
6220
+ from app.product.final_product_ui import get_home_html, get_product_app_html
app/product/final_product_ui.py ADDED
@@ -0,0 +1,758 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ def get_home_html() -> str:
3
+ return """
4
+ <!DOCTYPE html>
5
+ <html>
6
+ <head>
7
+ <title>GraphResearcher</title>
8
+ <meta name="viewport" content="width=device-width, initial-scale=1">
9
+ <style>
10
+ * { box-sizing: border-box; }
11
+ body { margin: 0; font-family: Inter, Arial, sans-serif; background: #f8fafc; color: #0f172a; }
12
+ .nav { height: 72px; display: flex; align-items: center; justify-content: space-between; padding: 0 56px; background: white; border-bottom: 1px solid #e5e7eb; }
13
+ .brand { font-weight: 900; font-size: 24px; letter-spacing: -0.7px; }
14
+ .nav a { text-decoration: none; color: #334155; margin-left: 22px; font-weight: 800; }
15
+ .hero { min-height: calc(100vh - 72px); display: grid; grid-template-columns: 1fr 0.9fr; gap: 52px; align-items: center; padding: 64px 72px; }
16
+ .badge { display: inline-block; background: #dbeafe; color: #1d4ed8; padding: 8px 12px; border-radius: 999px; font-weight: 900; font-size: 13px; margin-bottom: 18px; }
17
+ h1 { font-size: 58px; line-height: 1.04; margin: 0 0 20px; letter-spacing: -2px; }
18
+ p { font-size: 19px; line-height: 1.7; color: #475569; max-width: 720px; }
19
+ .actions { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 32px; }
20
+ .btn { display: inline-block; text-decoration: none; background: #2563eb; color: white; padding: 14px 22px; border-radius: 12px; font-weight: 900; }
21
+ .btn.dark { background: #0f172a; }
22
+ .preview { background: #0f172a; color: white; border-radius: 26px; padding: 24px; box-shadow: 0 28px 70px rgba(15,23,42,0.24); }
23
+ .preview-card { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.12); border-radius: 18px; padding: 16px; margin-bottom: 14px; line-height: 1.55; }
24
+ .preview-card b { color: #bfdbfe; }
25
+ .features { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; padding: 0 72px 58px; }
26
+ .feature { background: white; border: 1px solid #e5e7eb; border-radius: 18px; padding: 24px; }
27
+ .feature p { font-size: 15px; color: #64748b; }
28
+ @media(max-width: 950px) {
29
+ .nav { padding: 0 22px; }
30
+ .hero { grid-template-columns: 1fr; padding: 42px 24px; }
31
+ h1 { font-size: 42px; }
32
+ .features { grid-template-columns: 1fr; padding: 0 24px 44px; }
33
+ }
34
+ </style>
35
+ </head>
36
+ <body>
37
+ <div class="nav">
38
+ <div class="brand">GraphResearcher</div>
39
+ <div><a href="/app">Launch App</a></div>
40
+ </div>
41
+
42
+ <section class="hero">
43
+ <div>
44
+ <div class="badge">Document Chat + GraphRAG</div>
45
+ <h1>Upload documents. Ask questions. Verify every answer.</h1>
46
+ <p>
47
+ GraphResearcher lets users chat with PDFs and reports using citation-grounded retrieval,
48
+ graph context, source verification, graph view, and document comparison.
49
+ </p>
50
+ <div class="actions">
51
+ <a class="btn" href="/app">Start Chatting</a>
52
+ <a class="btn dark" href="/login">Login</a>
53
+ </div>
54
+ </div>
55
+
56
+ <div class="preview">
57
+ <div class="preview-card"><b>1. Upload</b><br>Add a document from the app sidebar. No document ID needed.</div>
58
+ <div class="preview-card"><b>2. Chat</b><br>Ask natural questions and get source-backed answers.</div>
59
+ <div class="preview-card"><b>3. Verify</b><br>Open sources, see page/chunk metadata, and inspect the graph.</div>
60
+ <div class="preview-card"><b>4. Compare</b><br>Select a second document and compare them side by side.</div>
61
+ </div>
62
+ </section>
63
+
64
+ <section class="features">
65
+ <div class="feature"><h3>Simple workspace</h3><p>Upload, select, delete, and re-index documents from one clean UI.</p></div>
66
+ <div class="feature"><h3>Grounded answers</h3><p>Answers show evidence boxes and source cards for verification.</p></div>
67
+ <div class="feature"><h3>GraphRAG inside</h3><p>Graph view and graph retrieval stay available without exposing developer tools.</p></div>
68
+ </section>
69
+ </body>
70
+ </html>
71
+ """
72
+
73
+
74
+ def get_product_app_html() -> str:
75
+ return """
76
+ <!DOCTYPE html>
77
+ <html>
78
+ <head>
79
+ <title>GraphResearcher App</title>
80
+ <meta name="viewport" content="width=device-width, initial-scale=1">
81
+
82
+ <style>
83
+ * { box-sizing: border-box; }
84
+ body { margin: 0; font-family: Inter, Arial, sans-serif; background: #f8fafc; color: #111827; }
85
+ .app { display: grid; grid-template-columns: 310px 1fr 370px; height: 100vh; }
86
+ .sidebar { background: #0f172a; color: white; padding: 18px; overflow-y: auto; }
87
+ .brand { font-size: 24px; font-weight: 900; margin-bottom: 18px; letter-spacing: -0.7px; }
88
+ .upload-box { border: 1px dashed rgba(255,255,255,0.35); border-radius: 16px; padding: 14px; background: rgba(255,255,255,0.06); margin-bottom: 20px; }
89
+ .upload-box input { width: 100%; font-size: 12px; margin: 10px 0; }
90
+ button { border: none; border-radius: 10px; padding: 10px 13px; cursor: pointer; background: #2563eb; color: white; font-weight: 800; }
91
+ button:hover { background: #1d4ed8; }
92
+ button.secondary { background: #334155; }
93
+ button.green { background: #059669; }
94
+ button.red { background: #dc2626; }
95
+ button.light { background: #f1f5f9; color: #0f172a; border: 1px solid #cbd5e1; }
96
+ .full { width: 100%; margin-bottom: 9px; }
97
+ .small { font-size: 12px; color: #94a3b8; line-height: 1.5; }
98
+ .doc-card { padding: 12px; border-radius: 13px; background: rgba(255,255,255,0.07); margin-bottom: 9px; cursor: pointer; border: 1px solid transparent; }
99
+ .doc-card.active { background: #2563eb; border-color: #93c5fd; }
100
+ .doc-title { font-size: 14px; font-weight: 800; word-break: break-word; }
101
+ .doc-meta { font-size: 11px; color: #cbd5e1; margin-top: 4px; }
102
+ .main { display: flex; flex-direction: column; height: 100vh; }
103
+ .topbar { padding: 14px 20px; border-bottom: 1px solid #e5e7eb; background: white; display: flex; justify-content: space-between; gap: 14px; align-items: center; }
104
+ .selected-doc { font-weight: 900; font-size: 17px; }
105
+ .selected-doc span { display: block; color: #64748b; font-weight: 500; font-size: 12px; margin-top: 2px; }
106
+ .status-pill { font-size: 12px; padding: 7px 10px; border-radius: 999px; background: #e0f2fe; color: #075985; white-space: nowrap; font-weight: 800; }
107
+ .messages { flex: 1; overflow-y: auto; padding: 26px; }
108
+ .message { max-width: 940px; margin-bottom: 18px; display: flex; }
109
+ .message.user { margin-left: auto; justify-content: flex-end; }
110
+ .bubble { padding: 15px 17px; border-radius: 17px; line-height: 1.65; white-space: pre-wrap; font-size: 15.5px; }
111
+ .user .bubble { background: #2563eb; color: white; border-bottom-right-radius: 4px; }
112
+ .assistant .bubble { background: white; border: 1px solid #e5e7eb; color: #111827; border-bottom-left-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
113
+ .empty { max-width: 760px; margin: 70px auto; text-align: center; color: #475569; }
114
+ .empty h1 { color: #0f172a; }
115
+ .composer { padding: 16px 20px; background: white; border-top: 1px solid #e5e7eb; }
116
+ .composer-box { display: flex; gap: 10px; align-items: flex-end; }
117
+ textarea { flex: 1; resize: none; min-height: 54px; max-height: 160px; padding: 13px; border: 1px solid #cbd5e1; border-radius: 12px; font-size: 15px; }
118
+ select { width: 100%; padding: 9px; border-radius: 9px; border: 1px solid #cbd5e1; background: white; }
119
+ .right-panel { background: white; border-left: 1px solid #e5e7eb; padding: 16px; overflow-y: auto; }
120
+ .panel-section { margin-bottom: 20px; }
121
+ .panel-section h3 { margin-bottom: 8px; font-size: 16px; }
122
+ .toggle-row { display: flex; flex-direction: column; gap: 8px; font-size: 14px; }
123
+ .metric { display: inline-block; background: #eef2ff; color: #3730a3; padding: 5px 8px; border-radius: 999px; font-size: 12px; margin: 3px; }
124
+ .citation-card { border: 1px solid #e5e7eb; background: #f8fafc; border-radius: 12px; padding: 12px; margin-bottom: 11px; font-size: 13px; }
125
+ .source-line { margin-top: 5px; color: #475569; }
126
+ .preview-text { margin-top: 8px; color: #64748b; font-size: 12px; line-height: 1.5; }
127
+ .answer-card { line-height: 1.72; }
128
+ .answer-card h2 { margin: 0 0 10px; font-size: 18px; color: #0f172a; }
129
+ .answer-card h3 { margin: 18px 0 8px; font-size: 15px; color: #1d4ed8; }
130
+ .answer-card p { margin: 8px 0; }
131
+ .evidence-box { background: #f8fafc; border: 1px solid #e5e7eb; border-radius: 12px; padding: 11px; margin-top: 9px; font-size: 13px; color: #475569; }
132
+ .source-chip { display: inline-block; background: #eef2ff; color: #3730a3; padding: 3px 7px; border-radius: 999px; font-size: 12px; margin: 2px; font-weight: 700; }
133
+ .warning { background: #fff7ed; border: 1px solid #fed7aa; color: #9a3412; padding: 10px; border-radius: 12px; margin: 10px 0; }
134
+ .compare-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 12px; }
135
+ .compare-card { border: 1px solid #e5e7eb; background: #f8fafc; border-radius: 14px; padding: 14px; line-height: 1.6; }
136
+ .danger-zone { border-top: 1px solid #e5e7eb; padding-top: 14px; }
137
+ @media (max-width: 1150px) { .app { grid-template-columns: 290px 1fr; } .right-panel { display: none; } }
138
+ @media (max-width: 850px) { .compare-grid { grid-template-columns: 1fr; } }
139
+ @media (max-width: 760px) { .app { grid-template-columns: 1fr; } .sidebar { display: none; } }
140
+ </style>
141
+ </head>
142
+
143
+ <body>
144
+ <div class="app">
145
+ <aside class="sidebar">
146
+ <div class="brand">GraphResearcher</div>
147
+
148
+ <div class="upload-box">
149
+ <b>Upload document</b>
150
+ <p class="small">Upload, select, and chat. If Hugging Face rebuilt recently, clear cache and re-upload.</p>
151
+ <input id="fileInput" type="file">
152
+ <button class="full green" onclick="uploadDocument()">Upload & Select</button>
153
+ <button class="full secondary" onclick="refreshDocuments()">Refresh Documents</button>
154
+ </div>
155
+
156
+ <div class="small" style="margin-bottom:8px;">My Documents</div>
157
+ <div id="documentList"></div>
158
+
159
+ <hr style="border-color:rgba(255,255,255,0.15); margin:18px 0;">
160
+
161
+ <button class="full secondary" onclick="newChat()">+ New Chat</button>
162
+ <button class="full secondary" onclick="reindexSelectedDocument()">Re-index Selected</button>
163
+ <button class="full secondary" onclick="buildGraph()">Build / Rebuild Graph</button>
164
+ <button class="full secondary" onclick="openGraphViewer()">View Graph</button>
165
+ <button class="full secondary" onclick="clearWorkspaceCache()">Clear Workspace Cache</button>
166
+ <button class="full secondary" onclick="window.location.href='/'">Home</button>
167
+ </aside>
168
+
169
+ <main class="main">
170
+ <div class="topbar">
171
+ <div class="selected-doc" id="selectedDocTitle">
172
+ No document selected
173
+ <span>Upload or select a document from the left sidebar.</span>
174
+ </div>
175
+ <span id="appStatus" class="status-pill">Ready</span>
176
+ </div>
177
+
178
+ <div id="messages" class="messages"></div>
179
+
180
+ <div class="composer">
181
+ <div class="composer-box">
182
+ <textarea id="messageInput" placeholder="Ask about the selected document..." onkeydown="handleKeyDown(event)"></textarea>
183
+ <button onclick="sendMessage()">Send</button>
184
+ </div>
185
+ <div class="small" style="margin-top:8px;color:#64748b;">
186
+ Tip: choose a second document in the right panel to compare two documents.
187
+ </div>
188
+ </div>
189
+ </main>
190
+
191
+ <aside class="right-panel">
192
+ <div class="panel-section">
193
+ <h3>Selected Document</h3>
194
+ <div id="docDetails" class="small" style="color:#64748b;">No document selected.</div>
195
+ </div>
196
+
197
+ <div class="panel-section">
198
+ <h3>Compare With</h3>
199
+ <select id="compareDocumentSelect" onchange="renderSelectedDocument()">
200
+ <option value="">No comparison</option>
201
+ </select>
202
+ <p class="small" style="color:#64748b;">Choose another document for side-by-side comparison.</p>
203
+ </div>
204
+
205
+ <div class="panel-section">
206
+ <h3>Graph</h3>
207
+ <button class="green" onclick="buildGraph()">Build / Rebuild Graph</button>
208
+ <button class="secondary" onclick="openGraphViewer()">View Graph</button>
209
+ </div>
210
+
211
+ <div class="panel-section">
212
+ <h3>Answer Style</h3>
213
+ <select id="answerStyle">
214
+ <option value="detailed" selected>Detailed</option>
215
+ <option value="step_by_step">Step-by-step</option>
216
+ <option value="concise">Concise</option>
217
+ <option value="research">Research style</option>
218
+ <option value="comparison">Comparison focused</option>
219
+ </select>
220
+ </div>
221
+
222
+ <div class="panel-section">
223
+ <h3>Advanced Settings</h3>
224
+ <div class="toggle-row">
225
+ <label><input type="checkbox" id="useLLM" checked> Use LLM</label>
226
+ <label><input type="checkbox" id="useReranker" checked> Use reranker</label>
227
+ <label><input type="checkbox" id="useGraph" checked> Use graph context</label>
228
+ <label><input type="checkbox" id="useGraphRetrieval" checked> Use graph retrieval fusion</label>
229
+ </div>
230
+ </div>
231
+
232
+ <div class="panel-section">
233
+ <h3>Last Answer Metrics</h3>
234
+ <div id="metricsBox"><span class="metric">No answer yet</span></div>
235
+ </div>
236
+
237
+ <div class="panel-section">
238
+ <h3>Sources</h3>
239
+ <div id="citationsBox" class="small" style="color:#64748b;">Sources will appear here after an answer.</div>
240
+ </div>
241
+
242
+ <div class="panel-section danger-zone">
243
+ <h3>Danger Zone</h3>
244
+ <button class="red" onclick="deleteSelectedDocument()">Delete Selected Document</button>
245
+ </div>
246
+ </aside>
247
+ </div>
248
+
249
+ <script>
250
+ let documents = JSON.parse(localStorage.getItem("graphrag_documents") || "[]");
251
+ let selectedDocumentId = localStorage.getItem("graphrag_selected_document_id") || null;
252
+ let conversations = JSON.parse(localStorage.getItem("graphrag_final_conversations") || "{}");
253
+ let lastSources = [];
254
+
255
+ function saveDocuments() {
256
+ localStorage.setItem("graphrag_documents", JSON.stringify(documents));
257
+ if (selectedDocumentId) localStorage.setItem("graphrag_selected_document_id", selectedDocumentId);
258
+ }
259
+ function saveConversations() { localStorage.setItem("graphrag_final_conversations", JSON.stringify(conversations)); }
260
+ function setStatus(text) { document.getElementById("appStatus").textContent = text; }
261
+ function getSelectedDocument() { return documents.find(d => d.id === selectedDocumentId) || null; }
262
+ function getCompareDocument() {
263
+ const id = document.getElementById("compareDocumentSelect")?.value || "";
264
+ if (!id) return null;
265
+ return documents.find(d => d.id === id) || null;
266
+ }
267
+ function escapeHtml(value) {
268
+ return String(value || "").replaceAll("&","&amp;").replaceAll("<","&lt;").replaceAll(">","&gt;").replaceAll('"',"&quot;");
269
+ }
270
+ function parseDocumentId(data) {
271
+ return data.document_id || data.id || data.doc_id || data.documentId ||
272
+ data.document?.document_id || data.document?.id ||
273
+ data.file?.document_id || data.result?.document_id || data.data?.document_id;
274
+ }
275
+ async function tryEndpoints(candidates, optionsFactory) {
276
+ let lastError = null;
277
+ for (const candidate of candidates) {
278
+ try {
279
+ const response = await fetch(candidate, optionsFactory(candidate));
280
+ const contentType = response.headers.get("content-type") || "";
281
+ const data = contentType.includes("application/json") ? await response.json() : await response.text();
282
+ if (response.ok) return { ok: true, endpoint: candidate, data };
283
+ lastError = { endpoint: candidate, status: response.status, data };
284
+ } catch (error) {
285
+ lastError = { endpoint: candidate, error: error.message };
286
+ }
287
+ }
288
+ return { ok: false, error: lastError };
289
+ }
290
+
291
+ function renderDocuments() {
292
+ const list = document.getElementById("documentList");
293
+ list.innerHTML = "";
294
+
295
+ if (documents.length === 0) list.innerHTML = `<div class="small">No documents yet. Upload one above.</div>`;
296
+
297
+ documents.forEach(doc => {
298
+ const div = document.createElement("div");
299
+ div.className = "doc-card" + (doc.id === selectedDocumentId ? " active" : "");
300
+ div.onclick = () => selectDocument(doc.id);
301
+ div.innerHTML = `<div class="doc-title">${escapeHtml(doc.name || "Untitled document")}</div>
302
+ <div class="doc-meta">${escapeHtml(doc.status || "uploaded")} • ${escapeHtml(doc.graphStatus || "graph not built")}</div>`;
303
+ list.appendChild(div);
304
+ });
305
+
306
+ renderCompareDropdown();
307
+ renderSelectedDocument();
308
+ }
309
+ function renderCompareDropdown() {
310
+ const select = document.getElementById("compareDocumentSelect");
311
+ if (!select) return;
312
+
313
+ const oldValue = select.value;
314
+ select.innerHTML = `<option value="">No comparison</option>`;
315
+
316
+ documents.filter(d => d.id !== selectedDocumentId).forEach(doc => {
317
+ const option = document.createElement("option");
318
+ option.value = doc.id;
319
+ option.textContent = doc.name || "Untitled document";
320
+ select.appendChild(option);
321
+ });
322
+
323
+ if ([...select.options].some(o => o.value === oldValue)) select.value = oldValue;
324
+ }
325
+ function renderSelectedDocument() {
326
+ const doc = getSelectedDocument();
327
+ const compareDoc = getCompareDocument();
328
+ const title = document.getElementById("selectedDocTitle");
329
+ const details = document.getElementById("docDetails");
330
+
331
+ if (!doc) {
332
+ title.innerHTML = `No document selected <span>Upload or select a document from the left sidebar.</span>`;
333
+ details.textContent = "No document selected.";
334
+ renderMessages();
335
+ return;
336
+ }
337
+
338
+ const subtitle = compareDoc ? `Compare mode active with ${compareDoc.name || "second document"}.` : "Ready for document chat.";
339
+ title.innerHTML = `${escapeHtml(doc.name || "Untitled document")} <span>${escapeHtml(subtitle)}</span>`;
340
+ details.innerHTML = `<b>Name:</b> ${escapeHtml(doc.name || "Untitled")}<br>
341
+ <b>Status:</b> ${escapeHtml(doc.status || "uploaded")}<br>
342
+ <b>Graph:</b> ${escapeHtml(doc.graphStatus || "not built")}<br>
343
+ <b>Uploaded:</b> ${escapeHtml(doc.uploadedAt || "unknown")}
344
+ ${compareDoc ? `<br><br><b>Comparing with:</b> ${escapeHtml(compareDoc.name || "Untitled")}` : ""}`;
345
+ renderMessages();
346
+ }
347
+ function selectDocument(id) {
348
+ selectedDocumentId = id;
349
+ saveDocuments();
350
+ renderDocuments();
351
+ }
352
+ function getConversationKey() {
353
+ const compareDoc = getCompareDocument();
354
+ if (compareDoc) return `${selectedDocumentId}__compare__${compareDoc.id}`;
355
+ return selectedDocumentId || "default";
356
+ }
357
+ function getConversation() {
358
+ const key = getConversationKey();
359
+ if (!conversations[key]) conversations[key] = [];
360
+ return conversations[key];
361
+ }
362
+ function newChat() {
363
+ if (!selectedDocumentId) return alert("Select a document first.");
364
+ conversations[getConversationKey()] = [];
365
+ saveConversations();
366
+ renderMessages();
367
+ }
368
+ function renderMessages() {
369
+ const box = document.getElementById("messages");
370
+ const doc = getSelectedDocument();
371
+ const compareDoc = getCompareDocument();
372
+
373
+ if (!doc) {
374
+ box.innerHTML = `<div class="empty"><h1>Upload a document to start</h1><p>No document ID needed. Upload a file from the left sidebar, then chat normally.</p></div>`;
375
+ return;
376
+ }
377
+
378
+ const convo = getConversation();
379
+ if (convo.length === 0) {
380
+ box.innerHTML = `<div class="empty"><h1>${compareDoc ? "Compare documents" : "Chat with your document"}</h1>
381
+ <p>${compareDoc ? `You are comparing ${escapeHtml(doc.name)} with ${escapeHtml(compareDoc.name)}.` : `Ask a question about ${escapeHtml(doc.name)}.`}</p></div>`;
382
+ return;
383
+ }
384
+
385
+ box.innerHTML = "";
386
+ convo.forEach(msg => {
387
+ const wrapper = document.createElement("div");
388
+ wrapper.className = "message " + msg.role;
389
+
390
+ const bubble = document.createElement("div");
391
+ bubble.className = msg.type === "compare" ? "bubble" : "bubble";
392
+
393
+ if (msg.role === "assistant" && msg.html) bubble.innerHTML = msg.html;
394
+ else bubble.textContent = msg.content || "";
395
+
396
+ wrapper.appendChild(bubble);
397
+ box.appendChild(wrapper);
398
+ });
399
+
400
+ box.scrollTop = box.scrollHeight;
401
+ }
402
+
403
+ async function uploadDocument() {
404
+ const file = document.getElementById("fileInput").files[0];
405
+ if (!file) return alert("Choose a file first.");
406
+
407
+ setStatus("Uploading...");
408
+ const formData = new FormData();
409
+ formData.append("file", file);
410
+
411
+ const result = await tryEndpoints(["/documents/upload", "/upload", "/documents", "/api/documents/upload"], () => ({ method: "POST", body: formData }));
412
+ if (!result.ok) {
413
+ setStatus("Upload failed");
414
+ return alert("Upload failed: " + JSON.stringify(result.error));
415
+ }
416
+
417
+ const documentId = parseDocumentId(result.data);
418
+ if (!documentId) {
419
+ setStatus("Upload returned no document ID");
420
+ return alert("Upload worked but document ID was not found in response.");
421
+ }
422
+
423
+ const doc = { id: documentId, name: file.name, status: "uploaded", graphStatus: "not built", uploadedAt: new Date().toLocaleString() };
424
+ const index = documents.findIndex(d => d.id === documentId);
425
+ if (index >= 0) documents[index] = doc;
426
+ else documents.unshift(doc);
427
+
428
+ selectedDocumentId = documentId;
429
+ saveDocuments();
430
+ renderDocuments();
431
+
432
+ setStatus("Uploaded");
433
+ await autoIndexDocument(documentId);
434
+ await buildGraph(true);
435
+ }
436
+ async function autoIndexDocument(documentId) {
437
+ setStatus("Indexing...");
438
+ const result = await tryEndpoints([`/documents/${documentId}/index`, `/documents/${documentId}/process`, `/documents/${documentId}/ingest`, `/index/${documentId}`], () => ({ method: "POST" }));
439
+ const doc = documents.find(d => d.id === documentId);
440
+ if (doc) {
441
+ doc.status = result.ok ? "indexed" : "uploaded";
442
+ saveDocuments();
443
+ renderDocuments();
444
+ }
445
+ setStatus(result.ok ? "Indexed" : "Uploaded");
446
+ }
447
+ async function reindexSelectedDocument() {
448
+ const doc = getSelectedDocument();
449
+ if (!doc) return alert("Select a document first.");
450
+ await autoIndexDocument(doc.id);
451
+ await buildGraph(false);
452
+ alert("Re-index attempt complete. Ask again now.");
453
+ }
454
+ async function buildGraph(silent = false) {
455
+ const doc = getSelectedDocument();
456
+ if (!doc) {
457
+ if (!silent) alert("Select a document first.");
458
+ return;
459
+ }
460
+
461
+ setStatus("Building graph...");
462
+ try {
463
+ const response = await fetch(`/documents/${doc.id}/graph/build`, { method: "POST" });
464
+ const data = await response.json();
465
+ if (!response.ok) throw new Error(JSON.stringify(data));
466
+
467
+ doc.graphStatus = "graph built";
468
+ doc.graphData = { entities: data.total_entities ?? data.entity_count ?? null, relations: data.total_relations ?? data.relation_count ?? null };
469
+ saveDocuments();
470
+ renderDocuments();
471
+
472
+ document.getElementById("metricsBox").innerHTML = `<span class="metric">graph built</span>
473
+ <span class="metric">entities: ${doc.graphData.entities ?? "NA"}</span>
474
+ <span class="metric">relations: ${doc.graphData.relations ?? "NA"}</span>`;
475
+
476
+ setStatus("Graph ready");
477
+ } catch (error) {
478
+ doc.graphStatus = "graph not built";
479
+ saveDocuments();
480
+ renderDocuments();
481
+ setStatus("Graph skipped");
482
+ if (!silent) alert("Graph build failed. Re-index or re-upload if needed.");
483
+ }
484
+ }
485
+ function openGraphViewer() {
486
+ const doc = getSelectedDocument();
487
+ if (!doc) return alert("Select a document first.");
488
+ window.open(`/documents/${doc.id}/graph/view`, "_blank");
489
+ }
490
+ async function deleteSelectedDocument() {
491
+ const doc = getSelectedDocument();
492
+ if (!doc) return alert("Select a document first.");
493
+ if (!confirm(`Delete "${doc.name}"?`)) return;
494
+
495
+ setStatus("Deleting...");
496
+ await tryEndpoints([`/documents/${doc.id}/delete`, `/documents/${doc.id}`, `/api/documents/${doc.id}`], () => ({ method: "DELETE" }));
497
+
498
+ documents = documents.filter(d => d.id !== doc.id);
499
+ Object.keys(conversations).forEach(k => { if (k.includes(doc.id)) delete conversations[k]; });
500
+ selectedDocumentId = documents.length ? documents[0].id : null;
501
+ saveDocuments();
502
+ saveConversations();
503
+ renderDocuments();
504
+ renderMessages();
505
+ setStatus("Deleted");
506
+ }
507
+ function clearWorkspaceCache() {
508
+ if (!confirm("Clear browser document list and chat history? Use this after Hugging Face rebuilds.")) return;
509
+ ["graphrag_documents", "graphrag_selected_document_id", "graphrag_conversations", "graphrag_conversations_v2", "graphrag_conversations_v3", "graphrag_final_conversations"].forEach(k => localStorage.removeItem(k));
510
+ documents = [];
511
+ selectedDocumentId = null;
512
+ conversations = {};
513
+ renderDocuments();
514
+ renderMessages();
515
+ setStatus("Cache cleared");
516
+ }
517
+ function refreshDocuments() { renderDocuments(); setStatus("Refreshed"); }
518
+ function handleKeyDown(event) { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); sendMessage(); } }
519
+
520
+ function buildContextualQuery(currentQuestion) {
521
+ const clean = String(currentQuestion || "").trim();
522
+ if (!clean) return clean;
523
+
524
+ const wordCount = clean.split(/\\s+/).filter(Boolean).length;
525
+ if (wordCount <= 8) {
526
+ const prev = getConversation().filter(m => m.role === "user").slice(-2).map(m => m.content).join(" ");
527
+ return prev ? prev + " " + clean : clean;
528
+ }
529
+ return clean;
530
+ }
531
+ function askPayload(query, documentId) {
532
+ return {
533
+ query,
534
+ document_id: documentId,
535
+ top_k: 8,
536
+ retrieval_mode: "hybrid",
537
+ use_reranker: document.getElementById("useReranker").checked,
538
+ use_llm: document.getElementById("useLLM").checked,
539
+ use_graph: document.getElementById("useGraph").checked,
540
+ graph_entity_limit: 12,
541
+ use_graph_retrieval: document.getElementById("useGraphRetrieval").checked,
542
+ graph_retrieval_top_k: 6
543
+ };
544
+ }
545
+ async function callAsk(payload) {
546
+ const response = await fetch("/ask", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
547
+ const data = await response.json();
548
+ if (!response.ok) throw new Error(JSON.stringify(data));
549
+ return data;
550
+ }
551
+ function valueFrom(obj, keys, fallback = "Not available") {
552
+ for (const key of keys) if (obj && obj[key] !== undefined && obj[key] !== null && obj[key] !== "") return obj[key];
553
+ if (obj && obj.metadata) for (const key of keys) if (obj.metadata[key] !== undefined && obj.metadata[key] !== null && obj.metadata[key] !== "") return obj.metadata[key];
554
+ return fallback;
555
+ }
556
+ function buildSources(data, doc) {
557
+ const sources = [];
558
+ (data.citations || []).forEach(x => sources.push(x));
559
+ (data.retrieval_fusion?.fused_results || []).forEach(x => sources.push(x));
560
+ (data.sources || data.source_chunks || data.retrieved_sources || []).forEach(x => sources.push(x));
561
+
562
+ const cleaned = [];
563
+ const seen = new Set();
564
+
565
+ sources.forEach((src, i) => {
566
+ const sid = valueFrom(src, ["source_id", "id", "citation_id"], "S" + (i + 1));
567
+ const chunk = valueFrom(src, ["chunk_id", "source_chunk_id", "chunk"], sid);
568
+ const page = valueFrom(src, ["page_number", "page", "page_no"], "Not available");
569
+ const key = doc.id + "|" + sid + "|" + chunk + "|" + page;
570
+ if (seen.has(key)) return;
571
+ seen.add(key);
572
+
573
+ cleaned.push({
574
+ source_id: sid,
575
+ document_id: doc.id,
576
+ document_name: valueFrom(src, ["document_name", "source_file_name", "file_name", "filename", "document_title"], doc.name || "Selected document"),
577
+ page,
578
+ chunk_id: chunk,
579
+ preview: valueFrom(src, ["text_preview", "preview", "chunk_preview", "content_preview", "text", "content"], "Preview not available"),
580
+ raw: src
581
+ });
582
+ });
583
+
584
+ return cleaned.slice(0, 8);
585
+ }
586
+ function updateCitations(groups) {
587
+ const box = document.getElementById("citationsBox");
588
+ lastSources = [];
589
+ box.innerHTML = "";
590
+
591
+ groups.forEach(group => {
592
+ const h = document.createElement("div");
593
+ h.innerHTML = `<h4>${escapeHtml(group.label)}</h4>`;
594
+ box.appendChild(h);
595
+
596
+ group.sources.forEach(src => {
597
+ const idx = lastSources.length;
598
+ lastSources.push(src);
599
+ const card = document.createElement("div");
600
+ card.className = "citation-card";
601
+ card.innerHTML = `<b>Source ${idx + 1}: ${escapeHtml(src.source_id)}</b>
602
+ <div class="source-line"><b>Document:</b> ${escapeHtml(src.document_name)}</div>
603
+ <div class="source-line"><b>Page:</b> ${escapeHtml(src.page)}</div>
604
+ <div class="source-line"><b>Chunk:</b> ${escapeHtml(src.chunk_id)}</div>
605
+ <div class="preview-text">${escapeHtml(String(src.preview).slice(0, 260))}</div><br>
606
+ <button class="light" onclick="openSource(${idx})">Open source</button>`;
607
+ box.appendChild(card);
608
+ });
609
+ });
610
+
611
+ if (!lastSources.length) box.textContent = "No source details returned.";
612
+ }
613
+ function openSource(index) {
614
+ const src = lastSources[index];
615
+ if (!src) return;
616
+ window.open(`/documents/${src.document_id}/sources/${encodeURIComponent(src.source_id)}/view?page=${encodeURIComponent(src.page || "")}&chunk_id=${encodeURIComponent(src.chunk_id || "")}`, "_blank");
617
+ }
618
+ function updateMetrics(data, label) {
619
+ const fusion = data.retrieval_fusion || {};
620
+ const graph = data.graph_context || {};
621
+ document.getElementById("metricsBox").innerHTML += `<div style="margin-bottom:8px;"><b>${escapeHtml(label || "")}</b><br>
622
+ <span class="metric">strategy: ${escapeHtml(data.answer_strategy || "NA")}</span>
623
+ <span class="metric">LLM: ${data.used_llm}</span>
624
+ <span class="metric">graph: ${data.graph_used}</span>
625
+ <span class="metric">graph available: ${graph.graph_available}</span>
626
+ <span class="metric">fusion: ${fusion.fusion_used}</span></div>`;
627
+ }
628
+ function answerLooksWeak(answer) {
629
+ const words = String(answer || "").split(/\\s+/).filter(Boolean);
630
+ return words.length < 100;
631
+ }
632
+ function renderAnswerHtml(question, data, doc) {
633
+ const answer = String(data.answer || "I could not generate an answer.").trim();
634
+ const sources = buildSources(data, doc);
635
+
636
+ let html = `<div class="answer-card">`;
637
+
638
+ if (answer.toLowerCase().includes("i could not find relevant indexed sources")) {
639
+ html += `<h2>I could not find indexed evidence</h2>
640
+ <div class="warning">This usually means the browser remembers an old document but backend files were reset. Clear cache and upload again.</div></div>`;
641
+ return html;
642
+ }
643
+
644
+ html += `<h2>Answer</h2>`;
645
+
646
+ const escaped = escapeHtml(answer).replaceAll("\\n", "<br>");
647
+ html += `<p>${escaped}</p>`;
648
+
649
+ if (sources.length) {
650
+ html += `<h3>Evidence used</h3>`;
651
+ sources.slice(0, 5).forEach((src, i) => {
652
+ html += `<div class="evidence-box"><b>[${escapeHtml(src.source_id)}]</b>
653
+ <span class="source-chip">Page: ${escapeHtml(src.page)}</span>
654
+ <span class="source-chip">Chunk: ${escapeHtml(src.chunk_id)}</span>
655
+ <div style="margin-top:6px;">${escapeHtml(String(src.preview).slice(0, 320))}</div></div>`;
656
+ });
657
+ } else {
658
+ html += `<div class="warning">No source metadata was returned. Re-index or re-upload if this seems wrong.</div>`;
659
+ }
660
+
661
+ html += `</div>`;
662
+ return html;
663
+ }
664
+
665
+ async function sendMessage() {
666
+ const doc = getSelectedDocument();
667
+ const compareDoc = getCompareDocument();
668
+ const input = document.getElementById("messageInput");
669
+ const userText = input.value.trim();
670
+
671
+ if (!doc) return alert("Upload or select a document first.");
672
+ if (!userText) return;
673
+
674
+ const convo = getConversation();
675
+ convo.push({ role: "user", content: userText, createdAt: new Date().toISOString() });
676
+ input.value = "";
677
+ saveConversations();
678
+ renderMessages();
679
+
680
+ setStatus(compareDoc ? "Comparing..." : "Thinking...");
681
+ document.getElementById("metricsBox").innerHTML = "";
682
+
683
+ try {
684
+ if (compareDoc) {
685
+ let data;
686
+ try {
687
+ const res = await fetch("/documents/compare", {
688
+ method: "POST",
689
+ headers: { "Content-Type": "application/json" },
690
+ body: JSON.stringify({
691
+ primary_document_id: doc.id,
692
+ compare_document_id: compareDoc.id,
693
+ query: userText,
694
+ retrieval_mode: "hybrid",
695
+ top_k: 8,
696
+ use_reranker: true,
697
+ use_llm: true,
698
+ use_graph: true,
699
+ graph_entity_limit: 12,
700
+ use_graph_retrieval: true,
701
+ graph_retrieval_top_k: 6
702
+ })
703
+ });
704
+ data = await res.json();
705
+ if (!res.ok) throw new Error(JSON.stringify(data));
706
+ } catch (err) {
707
+ const dataA = await callAsk(askPayload(buildContextualQuery(userText), doc.id));
708
+ const dataB = await callAsk(askPayload(buildContextualQuery(userText), compareDoc.id));
709
+ data = {
710
+ document_a: { answer: dataA.answer, ask_response: dataA },
711
+ document_b: { answer: dataB.answer, ask_response: dataB }
712
+ };
713
+ }
714
+
715
+ const dataA = data.document_a?.ask_response || { answer: data.document_a?.answer || "No answer.", citations: data.document_a?.sources || [] };
716
+ const dataB = data.document_b?.ask_response || { answer: data.document_b?.answer || "No answer.", citations: data.document_b?.sources || [] };
717
+
718
+ const html = `<div class="answer-card"><h2>Comparison Answer</h2>
719
+ <div class="compare-grid">
720
+ <div class="compare-card"><h3>${escapeHtml(doc.name || "Document A")}</h3>${renderAnswerHtml(userText, dataA, doc)}</div>
721
+ <div class="compare-card"><h3>${escapeHtml(compareDoc.name || "Document B")}</h3>${renderAnswerHtml(userText, dataB, compareDoc)}</div>
722
+ </div></div>`;
723
+
724
+ convo.push({ role: "assistant", html, content: "Comparison answer", createdAt: new Date().toISOString(), rawCompare: data });
725
+ saveConversations();
726
+ renderMessages();
727
+
728
+ updateMetrics(dataA, doc.name || "Document A");
729
+ updateMetrics(dataB, compareDoc.name || "Document B");
730
+ updateCitations([
731
+ { label: doc.name || "Document A", sources: buildSources(dataA, doc) },
732
+ { label: compareDoc.name || "Document B", sources: buildSources(dataB, compareDoc) }
733
+ ]);
734
+ setStatus("Comparison ready");
735
+ } else {
736
+ const data = await callAsk(askPayload(buildContextualQuery(userText), doc.id));
737
+ const html = renderAnswerHtml(userText, data, doc);
738
+ convo.push({ role: "assistant", content: data.answer || "Answer", html, createdAt: new Date().toISOString(), raw: data });
739
+ saveConversations();
740
+ renderMessages();
741
+
742
+ updateMetrics(data, doc.name || "Selected document");
743
+ updateCitations([{ label: doc.name || "Selected document", sources: buildSources(data, doc) }]);
744
+ setStatus("Ready");
745
+ }
746
+ } catch (error) {
747
+ convo.push({ role: "assistant", content: "Error: " + error.message, createdAt: new Date().toISOString() });
748
+ saveConversations();
749
+ renderMessages();
750
+ setStatus("Error");
751
+ }
752
+ }
753
+
754
+ renderDocuments();
755
+ </script>
756
+ </body>
757
+ </html>
758
+ """
scripts/fix_phase38_literal_newline_error.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ path = Path("app/deployment/hf_status.py")
4
+ text = path.read_text(encoding="utf-8-sig")
5
+ text = text.replace("\ufeff", "")
6
+
7
+ fixed_lines = []
8
+
9
+ for line in text.splitlines():
10
+ stripped = line.strip()
11
+
12
+ # Remove broken literal backslash-n lines accidentally written as Python code
13
+ if stripped in {r"\n", r"\n\n", r"\n\n\n"}:
14
+ continue
15
+
16
+ # Fix if the broken literal got attached before a comment/import
17
+ if stripped.startswith(r"\n\n#"):
18
+ line = line.replace(r"\n\n", "", 1)
19
+
20
+ if stripped.startswith(r"\n\nfrom "):
21
+ line = line.replace(r"\n\n", "", 1)
22
+
23
+ fixed_lines.append(line)
24
+
25
+ fixed = "\n".join(fixed_lines) + "\n"
26
+
27
+ # Extra targeted safety for Phase 38 export block
28
+ fixed = fixed.replace(r"\n\n# =====================================================", "\n\n# =====================================================")
29
+ fixed = fixed.replace(r"\n\nfrom app.product.final_product_ui", "\n\nfrom app.product.final_product_ui")
30
+
31
+ path.write_text(fixed, encoding="utf-8")
32
+
33
+ print("Fixed literal newline syntax error in hf_status.py")
scripts/phase38_final_ui_stabilization.py ADDED
@@ -0,0 +1,792 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ # Clean BOM
4
+ for path in Path("app").rglob("*.py"):
5
+ text = path.read_text(encoding="utf-8-sig")
6
+ text = text.replace("\ufeff", "")
7
+ path.write_text(text, encoding="utf-8")
8
+
9
+ Path("app/product").mkdir(parents=True, exist_ok=True)
10
+ Path("app/product/__init__.py").touch()
11
+
12
+ Path("app/product/final_product_ui.py").write_text(r'''
13
+ def get_home_html() -> str:
14
+ return """
15
+ <!DOCTYPE html>
16
+ <html>
17
+ <head>
18
+ <title>GraphResearcher</title>
19
+ <meta name="viewport" content="width=device-width, initial-scale=1">
20
+ <style>
21
+ * { box-sizing: border-box; }
22
+ body { margin: 0; font-family: Inter, Arial, sans-serif; background: #f8fafc; color: #0f172a; }
23
+ .nav { height: 72px; display: flex; align-items: center; justify-content: space-between; padding: 0 56px; background: white; border-bottom: 1px solid #e5e7eb; }
24
+ .brand { font-weight: 900; font-size: 24px; letter-spacing: -0.7px; }
25
+ .nav a { text-decoration: none; color: #334155; margin-left: 22px; font-weight: 800; }
26
+ .hero { min-height: calc(100vh - 72px); display: grid; grid-template-columns: 1fr 0.9fr; gap: 52px; align-items: center; padding: 64px 72px; }
27
+ .badge { display: inline-block; background: #dbeafe; color: #1d4ed8; padding: 8px 12px; border-radius: 999px; font-weight: 900; font-size: 13px; margin-bottom: 18px; }
28
+ h1 { font-size: 58px; line-height: 1.04; margin: 0 0 20px; letter-spacing: -2px; }
29
+ p { font-size: 19px; line-height: 1.7; color: #475569; max-width: 720px; }
30
+ .actions { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 32px; }
31
+ .btn { display: inline-block; text-decoration: none; background: #2563eb; color: white; padding: 14px 22px; border-radius: 12px; font-weight: 900; }
32
+ .btn.dark { background: #0f172a; }
33
+ .preview { background: #0f172a; color: white; border-radius: 26px; padding: 24px; box-shadow: 0 28px 70px rgba(15,23,42,0.24); }
34
+ .preview-card { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.12); border-radius: 18px; padding: 16px; margin-bottom: 14px; line-height: 1.55; }
35
+ .preview-card b { color: #bfdbfe; }
36
+ .features { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; padding: 0 72px 58px; }
37
+ .feature { background: white; border: 1px solid #e5e7eb; border-radius: 18px; padding: 24px; }
38
+ .feature p { font-size: 15px; color: #64748b; }
39
+ @media(max-width: 950px) {
40
+ .nav { padding: 0 22px; }
41
+ .hero { grid-template-columns: 1fr; padding: 42px 24px; }
42
+ h1 { font-size: 42px; }
43
+ .features { grid-template-columns: 1fr; padding: 0 24px 44px; }
44
+ }
45
+ </style>
46
+ </head>
47
+ <body>
48
+ <div class="nav">
49
+ <div class="brand">GraphResearcher</div>
50
+ <div><a href="/app">Launch App</a></div>
51
+ </div>
52
+
53
+ <section class="hero">
54
+ <div>
55
+ <div class="badge">Document Chat + GraphRAG</div>
56
+ <h1>Upload documents. Ask questions. Verify every answer.</h1>
57
+ <p>
58
+ GraphResearcher lets users chat with PDFs and reports using citation-grounded retrieval,
59
+ graph context, source verification, graph view, and document comparison.
60
+ </p>
61
+ <div class="actions">
62
+ <a class="btn" href="/app">Start Chatting</a>
63
+ <a class="btn dark" href="/login">Login</a>
64
+ </div>
65
+ </div>
66
+
67
+ <div class="preview">
68
+ <div class="preview-card"><b>1. Upload</b><br>Add a document from the app sidebar. No document ID needed.</div>
69
+ <div class="preview-card"><b>2. Chat</b><br>Ask natural questions and get source-backed answers.</div>
70
+ <div class="preview-card"><b>3. Verify</b><br>Open sources, see page/chunk metadata, and inspect the graph.</div>
71
+ <div class="preview-card"><b>4. Compare</b><br>Select a second document and compare them side by side.</div>
72
+ </div>
73
+ </section>
74
+
75
+ <section class="features">
76
+ <div class="feature"><h3>Simple workspace</h3><p>Upload, select, delete, and re-index documents from one clean UI.</p></div>
77
+ <div class="feature"><h3>Grounded answers</h3><p>Answers show evidence boxes and source cards for verification.</p></div>
78
+ <div class="feature"><h3>GraphRAG inside</h3><p>Graph view and graph retrieval stay available without exposing developer tools.</p></div>
79
+ </section>
80
+ </body>
81
+ </html>
82
+ """
83
+
84
+
85
+ def get_product_app_html() -> str:
86
+ return """
87
+ <!DOCTYPE html>
88
+ <html>
89
+ <head>
90
+ <title>GraphResearcher App</title>
91
+ <meta name="viewport" content="width=device-width, initial-scale=1">
92
+
93
+ <style>
94
+ * { box-sizing: border-box; }
95
+ body { margin: 0; font-family: Inter, Arial, sans-serif; background: #f8fafc; color: #111827; }
96
+ .app { display: grid; grid-template-columns: 310px 1fr 370px; height: 100vh; }
97
+ .sidebar { background: #0f172a; color: white; padding: 18px; overflow-y: auto; }
98
+ .brand { font-size: 24px; font-weight: 900; margin-bottom: 18px; letter-spacing: -0.7px; }
99
+ .upload-box { border: 1px dashed rgba(255,255,255,0.35); border-radius: 16px; padding: 14px; background: rgba(255,255,255,0.06); margin-bottom: 20px; }
100
+ .upload-box input { width: 100%; font-size: 12px; margin: 10px 0; }
101
+ button { border: none; border-radius: 10px; padding: 10px 13px; cursor: pointer; background: #2563eb; color: white; font-weight: 800; }
102
+ button:hover { background: #1d4ed8; }
103
+ button.secondary { background: #334155; }
104
+ button.green { background: #059669; }
105
+ button.red { background: #dc2626; }
106
+ button.light { background: #f1f5f9; color: #0f172a; border: 1px solid #cbd5e1; }
107
+ .full { width: 100%; margin-bottom: 9px; }
108
+ .small { font-size: 12px; color: #94a3b8; line-height: 1.5; }
109
+ .doc-card { padding: 12px; border-radius: 13px; background: rgba(255,255,255,0.07); margin-bottom: 9px; cursor: pointer; border: 1px solid transparent; }
110
+ .doc-card.active { background: #2563eb; border-color: #93c5fd; }
111
+ .doc-title { font-size: 14px; font-weight: 800; word-break: break-word; }
112
+ .doc-meta { font-size: 11px; color: #cbd5e1; margin-top: 4px; }
113
+ .main { display: flex; flex-direction: column; height: 100vh; }
114
+ .topbar { padding: 14px 20px; border-bottom: 1px solid #e5e7eb; background: white; display: flex; justify-content: space-between; gap: 14px; align-items: center; }
115
+ .selected-doc { font-weight: 900; font-size: 17px; }
116
+ .selected-doc span { display: block; color: #64748b; font-weight: 500; font-size: 12px; margin-top: 2px; }
117
+ .status-pill { font-size: 12px; padding: 7px 10px; border-radius: 999px; background: #e0f2fe; color: #075985; white-space: nowrap; font-weight: 800; }
118
+ .messages { flex: 1; overflow-y: auto; padding: 26px; }
119
+ .message { max-width: 940px; margin-bottom: 18px; display: flex; }
120
+ .message.user { margin-left: auto; justify-content: flex-end; }
121
+ .bubble { padding: 15px 17px; border-radius: 17px; line-height: 1.65; white-space: pre-wrap; font-size: 15.5px; }
122
+ .user .bubble { background: #2563eb; color: white; border-bottom-right-radius: 4px; }
123
+ .assistant .bubble { background: white; border: 1px solid #e5e7eb; color: #111827; border-bottom-left-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
124
+ .empty { max-width: 760px; margin: 70px auto; text-align: center; color: #475569; }
125
+ .empty h1 { color: #0f172a; }
126
+ .composer { padding: 16px 20px; background: white; border-top: 1px solid #e5e7eb; }
127
+ .composer-box { display: flex; gap: 10px; align-items: flex-end; }
128
+ textarea { flex: 1; resize: none; min-height: 54px; max-height: 160px; padding: 13px; border: 1px solid #cbd5e1; border-radius: 12px; font-size: 15px; }
129
+ select { width: 100%; padding: 9px; border-radius: 9px; border: 1px solid #cbd5e1; background: white; }
130
+ .right-panel { background: white; border-left: 1px solid #e5e7eb; padding: 16px; overflow-y: auto; }
131
+ .panel-section { margin-bottom: 20px; }
132
+ .panel-section h3 { margin-bottom: 8px; font-size: 16px; }
133
+ .toggle-row { display: flex; flex-direction: column; gap: 8px; font-size: 14px; }
134
+ .metric { display: inline-block; background: #eef2ff; color: #3730a3; padding: 5px 8px; border-radius: 999px; font-size: 12px; margin: 3px; }
135
+ .citation-card { border: 1px solid #e5e7eb; background: #f8fafc; border-radius: 12px; padding: 12px; margin-bottom: 11px; font-size: 13px; }
136
+ .source-line { margin-top: 5px; color: #475569; }
137
+ .preview-text { margin-top: 8px; color: #64748b; font-size: 12px; line-height: 1.5; }
138
+ .answer-card { line-height: 1.72; }
139
+ .answer-card h2 { margin: 0 0 10px; font-size: 18px; color: #0f172a; }
140
+ .answer-card h3 { margin: 18px 0 8px; font-size: 15px; color: #1d4ed8; }
141
+ .answer-card p { margin: 8px 0; }
142
+ .evidence-box { background: #f8fafc; border: 1px solid #e5e7eb; border-radius: 12px; padding: 11px; margin-top: 9px; font-size: 13px; color: #475569; }
143
+ .source-chip { display: inline-block; background: #eef2ff; color: #3730a3; padding: 3px 7px; border-radius: 999px; font-size: 12px; margin: 2px; font-weight: 700; }
144
+ .warning { background: #fff7ed; border: 1px solid #fed7aa; color: #9a3412; padding: 10px; border-radius: 12px; margin: 10px 0; }
145
+ .compare-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 12px; }
146
+ .compare-card { border: 1px solid #e5e7eb; background: #f8fafc; border-radius: 14px; padding: 14px; line-height: 1.6; }
147
+ .danger-zone { border-top: 1px solid #e5e7eb; padding-top: 14px; }
148
+ @media (max-width: 1150px) { .app { grid-template-columns: 290px 1fr; } .right-panel { display: none; } }
149
+ @media (max-width: 850px) { .compare-grid { grid-template-columns: 1fr; } }
150
+ @media (max-width: 760px) { .app { grid-template-columns: 1fr; } .sidebar { display: none; } }
151
+ </style>
152
+ </head>
153
+
154
+ <body>
155
+ <div class="app">
156
+ <aside class="sidebar">
157
+ <div class="brand">GraphResearcher</div>
158
+
159
+ <div class="upload-box">
160
+ <b>Upload document</b>
161
+ <p class="small">Upload, select, and chat. If Hugging Face rebuilt recently, clear cache and re-upload.</p>
162
+ <input id="fileInput" type="file">
163
+ <button class="full green" onclick="uploadDocument()">Upload & Select</button>
164
+ <button class="full secondary" onclick="refreshDocuments()">Refresh Documents</button>
165
+ </div>
166
+
167
+ <div class="small" style="margin-bottom:8px;">My Documents</div>
168
+ <div id="documentList"></div>
169
+
170
+ <hr style="border-color:rgba(255,255,255,0.15); margin:18px 0;">
171
+
172
+ <button class="full secondary" onclick="newChat()">+ New Chat</button>
173
+ <button class="full secondary" onclick="reindexSelectedDocument()">Re-index Selected</button>
174
+ <button class="full secondary" onclick="buildGraph()">Build / Rebuild Graph</button>
175
+ <button class="full secondary" onclick="openGraphViewer()">View Graph</button>
176
+ <button class="full secondary" onclick="clearWorkspaceCache()">Clear Workspace Cache</button>
177
+ <button class="full secondary" onclick="window.location.href='/'">Home</button>
178
+ </aside>
179
+
180
+ <main class="main">
181
+ <div class="topbar">
182
+ <div class="selected-doc" id="selectedDocTitle">
183
+ No document selected
184
+ <span>Upload or select a document from the left sidebar.</span>
185
+ </div>
186
+ <span id="appStatus" class="status-pill">Ready</span>
187
+ </div>
188
+
189
+ <div id="messages" class="messages"></div>
190
+
191
+ <div class="composer">
192
+ <div class="composer-box">
193
+ <textarea id="messageInput" placeholder="Ask about the selected document..." onkeydown="handleKeyDown(event)"></textarea>
194
+ <button onclick="sendMessage()">Send</button>
195
+ </div>
196
+ <div class="small" style="margin-top:8px;color:#64748b;">
197
+ Tip: choose a second document in the right panel to compare two documents.
198
+ </div>
199
+ </div>
200
+ </main>
201
+
202
+ <aside class="right-panel">
203
+ <div class="panel-section">
204
+ <h3>Selected Document</h3>
205
+ <div id="docDetails" class="small" style="color:#64748b;">No document selected.</div>
206
+ </div>
207
+
208
+ <div class="panel-section">
209
+ <h3>Compare With</h3>
210
+ <select id="compareDocumentSelect" onchange="renderSelectedDocument()">
211
+ <option value="">No comparison</option>
212
+ </select>
213
+ <p class="small" style="color:#64748b;">Choose another document for side-by-side comparison.</p>
214
+ </div>
215
+
216
+ <div class="panel-section">
217
+ <h3>Graph</h3>
218
+ <button class="green" onclick="buildGraph()">Build / Rebuild Graph</button>
219
+ <button class="secondary" onclick="openGraphViewer()">View Graph</button>
220
+ </div>
221
+
222
+ <div class="panel-section">
223
+ <h3>Answer Style</h3>
224
+ <select id="answerStyle">
225
+ <option value="detailed" selected>Detailed</option>
226
+ <option value="step_by_step">Step-by-step</option>
227
+ <option value="concise">Concise</option>
228
+ <option value="research">Research style</option>
229
+ <option value="comparison">Comparison focused</option>
230
+ </select>
231
+ </div>
232
+
233
+ <div class="panel-section">
234
+ <h3>Advanced Settings</h3>
235
+ <div class="toggle-row">
236
+ <label><input type="checkbox" id="useLLM" checked> Use LLM</label>
237
+ <label><input type="checkbox" id="useReranker" checked> Use reranker</label>
238
+ <label><input type="checkbox" id="useGraph" checked> Use graph context</label>
239
+ <label><input type="checkbox" id="useGraphRetrieval" checked> Use graph retrieval fusion</label>
240
+ </div>
241
+ </div>
242
+
243
+ <div class="panel-section">
244
+ <h3>Last Answer Metrics</h3>
245
+ <div id="metricsBox"><span class="metric">No answer yet</span></div>
246
+ </div>
247
+
248
+ <div class="panel-section">
249
+ <h3>Sources</h3>
250
+ <div id="citationsBox" class="small" style="color:#64748b;">Sources will appear here after an answer.</div>
251
+ </div>
252
+
253
+ <div class="panel-section danger-zone">
254
+ <h3>Danger Zone</h3>
255
+ <button class="red" onclick="deleteSelectedDocument()">Delete Selected Document</button>
256
+ </div>
257
+ </aside>
258
+ </div>
259
+
260
+ <script>
261
+ let documents = JSON.parse(localStorage.getItem("graphrag_documents") || "[]");
262
+ let selectedDocumentId = localStorage.getItem("graphrag_selected_document_id") || null;
263
+ let conversations = JSON.parse(localStorage.getItem("graphrag_final_conversations") || "{}");
264
+ let lastSources = [];
265
+
266
+ function saveDocuments() {
267
+ localStorage.setItem("graphrag_documents", JSON.stringify(documents));
268
+ if (selectedDocumentId) localStorage.setItem("graphrag_selected_document_id", selectedDocumentId);
269
+ }
270
+ function saveConversations() { localStorage.setItem("graphrag_final_conversations", JSON.stringify(conversations)); }
271
+ function setStatus(text) { document.getElementById("appStatus").textContent = text; }
272
+ function getSelectedDocument() { return documents.find(d => d.id === selectedDocumentId) || null; }
273
+ function getCompareDocument() {
274
+ const id = document.getElementById("compareDocumentSelect")?.value || "";
275
+ if (!id) return null;
276
+ return documents.find(d => d.id === id) || null;
277
+ }
278
+ function escapeHtml(value) {
279
+ return String(value || "").replaceAll("&","&amp;").replaceAll("<","&lt;").replaceAll(">","&gt;").replaceAll('"',"&quot;");
280
+ }
281
+ function parseDocumentId(data) {
282
+ return data.document_id || data.id || data.doc_id || data.documentId ||
283
+ data.document?.document_id || data.document?.id ||
284
+ data.file?.document_id || data.result?.document_id || data.data?.document_id;
285
+ }
286
+ async function tryEndpoints(candidates, optionsFactory) {
287
+ let lastError = null;
288
+ for (const candidate of candidates) {
289
+ try {
290
+ const response = await fetch(candidate, optionsFactory(candidate));
291
+ const contentType = response.headers.get("content-type") || "";
292
+ const data = contentType.includes("application/json") ? await response.json() : await response.text();
293
+ if (response.ok) return { ok: true, endpoint: candidate, data };
294
+ lastError = { endpoint: candidate, status: response.status, data };
295
+ } catch (error) {
296
+ lastError = { endpoint: candidate, error: error.message };
297
+ }
298
+ }
299
+ return { ok: false, error: lastError };
300
+ }
301
+
302
+ function renderDocuments() {
303
+ const list = document.getElementById("documentList");
304
+ list.innerHTML = "";
305
+
306
+ if (documents.length === 0) list.innerHTML = `<div class="small">No documents yet. Upload one above.</div>`;
307
+
308
+ documents.forEach(doc => {
309
+ const div = document.createElement("div");
310
+ div.className = "doc-card" + (doc.id === selectedDocumentId ? " active" : "");
311
+ div.onclick = () => selectDocument(doc.id);
312
+ div.innerHTML = `<div class="doc-title">${escapeHtml(doc.name || "Untitled document")}</div>
313
+ <div class="doc-meta">${escapeHtml(doc.status || "uploaded")} • ${escapeHtml(doc.graphStatus || "graph not built")}</div>`;
314
+ list.appendChild(div);
315
+ });
316
+
317
+ renderCompareDropdown();
318
+ renderSelectedDocument();
319
+ }
320
+ function renderCompareDropdown() {
321
+ const select = document.getElementById("compareDocumentSelect");
322
+ if (!select) return;
323
+
324
+ const oldValue = select.value;
325
+ select.innerHTML = `<option value="">No comparison</option>`;
326
+
327
+ documents.filter(d => d.id !== selectedDocumentId).forEach(doc => {
328
+ const option = document.createElement("option");
329
+ option.value = doc.id;
330
+ option.textContent = doc.name || "Untitled document";
331
+ select.appendChild(option);
332
+ });
333
+
334
+ if ([...select.options].some(o => o.value === oldValue)) select.value = oldValue;
335
+ }
336
+ function renderSelectedDocument() {
337
+ const doc = getSelectedDocument();
338
+ const compareDoc = getCompareDocument();
339
+ const title = document.getElementById("selectedDocTitle");
340
+ const details = document.getElementById("docDetails");
341
+
342
+ if (!doc) {
343
+ title.innerHTML = `No document selected <span>Upload or select a document from the left sidebar.</span>`;
344
+ details.textContent = "No document selected.";
345
+ renderMessages();
346
+ return;
347
+ }
348
+
349
+ const subtitle = compareDoc ? `Compare mode active with ${compareDoc.name || "second document"}.` : "Ready for document chat.";
350
+ title.innerHTML = `${escapeHtml(doc.name || "Untitled document")} <span>${escapeHtml(subtitle)}</span>`;
351
+ details.innerHTML = `<b>Name:</b> ${escapeHtml(doc.name || "Untitled")}<br>
352
+ <b>Status:</b> ${escapeHtml(doc.status || "uploaded")}<br>
353
+ <b>Graph:</b> ${escapeHtml(doc.graphStatus || "not built")}<br>
354
+ <b>Uploaded:</b> ${escapeHtml(doc.uploadedAt || "unknown")}
355
+ ${compareDoc ? `<br><br><b>Comparing with:</b> ${escapeHtml(compareDoc.name || "Untitled")}` : ""}`;
356
+ renderMessages();
357
+ }
358
+ function selectDocument(id) {
359
+ selectedDocumentId = id;
360
+ saveDocuments();
361
+ renderDocuments();
362
+ }
363
+ function getConversationKey() {
364
+ const compareDoc = getCompareDocument();
365
+ if (compareDoc) return `${selectedDocumentId}__compare__${compareDoc.id}`;
366
+ return selectedDocumentId || "default";
367
+ }
368
+ function getConversation() {
369
+ const key = getConversationKey();
370
+ if (!conversations[key]) conversations[key] = [];
371
+ return conversations[key];
372
+ }
373
+ function newChat() {
374
+ if (!selectedDocumentId) return alert("Select a document first.");
375
+ conversations[getConversationKey()] = [];
376
+ saveConversations();
377
+ renderMessages();
378
+ }
379
+ function renderMessages() {
380
+ const box = document.getElementById("messages");
381
+ const doc = getSelectedDocument();
382
+ const compareDoc = getCompareDocument();
383
+
384
+ if (!doc) {
385
+ box.innerHTML = `<div class="empty"><h1>Upload a document to start</h1><p>No document ID needed. Upload a file from the left sidebar, then chat normally.</p></div>`;
386
+ return;
387
+ }
388
+
389
+ const convo = getConversation();
390
+ if (convo.length === 0) {
391
+ box.innerHTML = `<div class="empty"><h1>${compareDoc ? "Compare documents" : "Chat with your document"}</h1>
392
+ <p>${compareDoc ? `You are comparing ${escapeHtml(doc.name)} with ${escapeHtml(compareDoc.name)}.` : `Ask a question about ${escapeHtml(doc.name)}.`}</p></div>`;
393
+ return;
394
+ }
395
+
396
+ box.innerHTML = "";
397
+ convo.forEach(msg => {
398
+ const wrapper = document.createElement("div");
399
+ wrapper.className = "message " + msg.role;
400
+
401
+ const bubble = document.createElement("div");
402
+ bubble.className = msg.type === "compare" ? "bubble" : "bubble";
403
+
404
+ if (msg.role === "assistant" && msg.html) bubble.innerHTML = msg.html;
405
+ else bubble.textContent = msg.content || "";
406
+
407
+ wrapper.appendChild(bubble);
408
+ box.appendChild(wrapper);
409
+ });
410
+
411
+ box.scrollTop = box.scrollHeight;
412
+ }
413
+
414
+ async function uploadDocument() {
415
+ const file = document.getElementById("fileInput").files[0];
416
+ if (!file) return alert("Choose a file first.");
417
+
418
+ setStatus("Uploading...");
419
+ const formData = new FormData();
420
+ formData.append("file", file);
421
+
422
+ const result = await tryEndpoints(["/documents/upload", "/upload", "/documents", "/api/documents/upload"], () => ({ method: "POST", body: formData }));
423
+ if (!result.ok) {
424
+ setStatus("Upload failed");
425
+ return alert("Upload failed: " + JSON.stringify(result.error));
426
+ }
427
+
428
+ const documentId = parseDocumentId(result.data);
429
+ if (!documentId) {
430
+ setStatus("Upload returned no document ID");
431
+ return alert("Upload worked but document ID was not found in response.");
432
+ }
433
+
434
+ const doc = { id: documentId, name: file.name, status: "uploaded", graphStatus: "not built", uploadedAt: new Date().toLocaleString() };
435
+ const index = documents.findIndex(d => d.id === documentId);
436
+ if (index >= 0) documents[index] = doc;
437
+ else documents.unshift(doc);
438
+
439
+ selectedDocumentId = documentId;
440
+ saveDocuments();
441
+ renderDocuments();
442
+
443
+ setStatus("Uploaded");
444
+ await autoIndexDocument(documentId);
445
+ await buildGraph(true);
446
+ }
447
+ async function autoIndexDocument(documentId) {
448
+ setStatus("Indexing...");
449
+ const result = await tryEndpoints([`/documents/${documentId}/index`, `/documents/${documentId}/process`, `/documents/${documentId}/ingest`, `/index/${documentId}`], () => ({ method: "POST" }));
450
+ const doc = documents.find(d => d.id === documentId);
451
+ if (doc) {
452
+ doc.status = result.ok ? "indexed" : "uploaded";
453
+ saveDocuments();
454
+ renderDocuments();
455
+ }
456
+ setStatus(result.ok ? "Indexed" : "Uploaded");
457
+ }
458
+ async function reindexSelectedDocument() {
459
+ const doc = getSelectedDocument();
460
+ if (!doc) return alert("Select a document first.");
461
+ await autoIndexDocument(doc.id);
462
+ await buildGraph(false);
463
+ alert("Re-index attempt complete. Ask again now.");
464
+ }
465
+ async function buildGraph(silent = false) {
466
+ const doc = getSelectedDocument();
467
+ if (!doc) {
468
+ if (!silent) alert("Select a document first.");
469
+ return;
470
+ }
471
+
472
+ setStatus("Building graph...");
473
+ try {
474
+ const response = await fetch(`/documents/${doc.id}/graph/build`, { method: "POST" });
475
+ const data = await response.json();
476
+ if (!response.ok) throw new Error(JSON.stringify(data));
477
+
478
+ doc.graphStatus = "graph built";
479
+ doc.graphData = { entities: data.total_entities ?? data.entity_count ?? null, relations: data.total_relations ?? data.relation_count ?? null };
480
+ saveDocuments();
481
+ renderDocuments();
482
+
483
+ document.getElementById("metricsBox").innerHTML = `<span class="metric">graph built</span>
484
+ <span class="metric">entities: ${doc.graphData.entities ?? "NA"}</span>
485
+ <span class="metric">relations: ${doc.graphData.relations ?? "NA"}</span>`;
486
+
487
+ setStatus("Graph ready");
488
+ } catch (error) {
489
+ doc.graphStatus = "graph not built";
490
+ saveDocuments();
491
+ renderDocuments();
492
+ setStatus("Graph skipped");
493
+ if (!silent) alert("Graph build failed. Re-index or re-upload if needed.");
494
+ }
495
+ }
496
+ function openGraphViewer() {
497
+ const doc = getSelectedDocument();
498
+ if (!doc) return alert("Select a document first.");
499
+ window.open(`/documents/${doc.id}/graph/view`, "_blank");
500
+ }
501
+ async function deleteSelectedDocument() {
502
+ const doc = getSelectedDocument();
503
+ if (!doc) return alert("Select a document first.");
504
+ if (!confirm(`Delete "${doc.name}"?`)) return;
505
+
506
+ setStatus("Deleting...");
507
+ await tryEndpoints([`/documents/${doc.id}/delete`, `/documents/${doc.id}`, `/api/documents/${doc.id}`], () => ({ method: "DELETE" }));
508
+
509
+ documents = documents.filter(d => d.id !== doc.id);
510
+ Object.keys(conversations).forEach(k => { if (k.includes(doc.id)) delete conversations[k]; });
511
+ selectedDocumentId = documents.length ? documents[0].id : null;
512
+ saveDocuments();
513
+ saveConversations();
514
+ renderDocuments();
515
+ renderMessages();
516
+ setStatus("Deleted");
517
+ }
518
+ function clearWorkspaceCache() {
519
+ if (!confirm("Clear browser document list and chat history? Use this after Hugging Face rebuilds.")) return;
520
+ ["graphrag_documents", "graphrag_selected_document_id", "graphrag_conversations", "graphrag_conversations_v2", "graphrag_conversations_v3", "graphrag_final_conversations"].forEach(k => localStorage.removeItem(k));
521
+ documents = [];
522
+ selectedDocumentId = null;
523
+ conversations = {};
524
+ renderDocuments();
525
+ renderMessages();
526
+ setStatus("Cache cleared");
527
+ }
528
+ function refreshDocuments() { renderDocuments(); setStatus("Refreshed"); }
529
+ function handleKeyDown(event) { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); sendMessage(); } }
530
+
531
+ function buildContextualQuery(currentQuestion) {
532
+ const clean = String(currentQuestion || "").trim();
533
+ if (!clean) return clean;
534
+
535
+ const wordCount = clean.split(/\\s+/).filter(Boolean).length;
536
+ if (wordCount <= 8) {
537
+ const prev = getConversation().filter(m => m.role === "user").slice(-2).map(m => m.content).join(" ");
538
+ return prev ? prev + " " + clean : clean;
539
+ }
540
+ return clean;
541
+ }
542
+ function askPayload(query, documentId) {
543
+ return {
544
+ query,
545
+ document_id: documentId,
546
+ top_k: 8,
547
+ retrieval_mode: "hybrid",
548
+ use_reranker: document.getElementById("useReranker").checked,
549
+ use_llm: document.getElementById("useLLM").checked,
550
+ use_graph: document.getElementById("useGraph").checked,
551
+ graph_entity_limit: 12,
552
+ use_graph_retrieval: document.getElementById("useGraphRetrieval").checked,
553
+ graph_retrieval_top_k: 6
554
+ };
555
+ }
556
+ async function callAsk(payload) {
557
+ const response = await fetch("/ask", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
558
+ const data = await response.json();
559
+ if (!response.ok) throw new Error(JSON.stringify(data));
560
+ return data;
561
+ }
562
+ function valueFrom(obj, keys, fallback = "Not available") {
563
+ for (const key of keys) if (obj && obj[key] !== undefined && obj[key] !== null && obj[key] !== "") return obj[key];
564
+ if (obj && obj.metadata) for (const key of keys) if (obj.metadata[key] !== undefined && obj.metadata[key] !== null && obj.metadata[key] !== "") return obj.metadata[key];
565
+ return fallback;
566
+ }
567
+ function buildSources(data, doc) {
568
+ const sources = [];
569
+ (data.citations || []).forEach(x => sources.push(x));
570
+ (data.retrieval_fusion?.fused_results || []).forEach(x => sources.push(x));
571
+ (data.sources || data.source_chunks || data.retrieved_sources || []).forEach(x => sources.push(x));
572
+
573
+ const cleaned = [];
574
+ const seen = new Set();
575
+
576
+ sources.forEach((src, i) => {
577
+ const sid = valueFrom(src, ["source_id", "id", "citation_id"], "S" + (i + 1));
578
+ const chunk = valueFrom(src, ["chunk_id", "source_chunk_id", "chunk"], sid);
579
+ const page = valueFrom(src, ["page_number", "page", "page_no"], "Not available");
580
+ const key = doc.id + "|" + sid + "|" + chunk + "|" + page;
581
+ if (seen.has(key)) return;
582
+ seen.add(key);
583
+
584
+ cleaned.push({
585
+ source_id: sid,
586
+ document_id: doc.id,
587
+ document_name: valueFrom(src, ["document_name", "source_file_name", "file_name", "filename", "document_title"], doc.name || "Selected document"),
588
+ page,
589
+ chunk_id: chunk,
590
+ preview: valueFrom(src, ["text_preview", "preview", "chunk_preview", "content_preview", "text", "content"], "Preview not available"),
591
+ raw: src
592
+ });
593
+ });
594
+
595
+ return cleaned.slice(0, 8);
596
+ }
597
+ function updateCitations(groups) {
598
+ const box = document.getElementById("citationsBox");
599
+ lastSources = [];
600
+ box.innerHTML = "";
601
+
602
+ groups.forEach(group => {
603
+ const h = document.createElement("div");
604
+ h.innerHTML = `<h4>${escapeHtml(group.label)}</h4>`;
605
+ box.appendChild(h);
606
+
607
+ group.sources.forEach(src => {
608
+ const idx = lastSources.length;
609
+ lastSources.push(src);
610
+ const card = document.createElement("div");
611
+ card.className = "citation-card";
612
+ card.innerHTML = `<b>Source ${idx + 1}: ${escapeHtml(src.source_id)}</b>
613
+ <div class="source-line"><b>Document:</b> ${escapeHtml(src.document_name)}</div>
614
+ <div class="source-line"><b>Page:</b> ${escapeHtml(src.page)}</div>
615
+ <div class="source-line"><b>Chunk:</b> ${escapeHtml(src.chunk_id)}</div>
616
+ <div class="preview-text">${escapeHtml(String(src.preview).slice(0, 260))}</div><br>
617
+ <button class="light" onclick="openSource(${idx})">Open source</button>`;
618
+ box.appendChild(card);
619
+ });
620
+ });
621
+
622
+ if (!lastSources.length) box.textContent = "No source details returned.";
623
+ }
624
+ function openSource(index) {
625
+ const src = lastSources[index];
626
+ if (!src) return;
627
+ window.open(`/documents/${src.document_id}/sources/${encodeURIComponent(src.source_id)}/view?page=${encodeURIComponent(src.page || "")}&chunk_id=${encodeURIComponent(src.chunk_id || "")}`, "_blank");
628
+ }
629
+ function updateMetrics(data, label) {
630
+ const fusion = data.retrieval_fusion || {};
631
+ const graph = data.graph_context || {};
632
+ document.getElementById("metricsBox").innerHTML += `<div style="margin-bottom:8px;"><b>${escapeHtml(label || "")}</b><br>
633
+ <span class="metric">strategy: ${escapeHtml(data.answer_strategy || "NA")}</span>
634
+ <span class="metric">LLM: ${data.used_llm}</span>
635
+ <span class="metric">graph: ${data.graph_used}</span>
636
+ <span class="metric">graph available: ${graph.graph_available}</span>
637
+ <span class="metric">fusion: ${fusion.fusion_used}</span></div>`;
638
+ }
639
+ function answerLooksWeak(answer) {
640
+ const words = String(answer || "").split(/\\s+/).filter(Boolean);
641
+ return words.length < 100;
642
+ }
643
+ function renderAnswerHtml(question, data, doc) {
644
+ const answer = String(data.answer || "I could not generate an answer.").trim();
645
+ const sources = buildSources(data, doc);
646
+
647
+ let html = `<div class="answer-card">`;
648
+
649
+ if (answer.toLowerCase().includes("i could not find relevant indexed sources")) {
650
+ html += `<h2>I could not find indexed evidence</h2>
651
+ <div class="warning">This usually means the browser remembers an old document but backend files were reset. Clear cache and upload again.</div></div>`;
652
+ return html;
653
+ }
654
+
655
+ html += `<h2>Answer</h2>`;
656
+
657
+ const escaped = escapeHtml(answer).replaceAll("\\n", "<br>");
658
+ html += `<p>${escaped}</p>`;
659
+
660
+ if (sources.length) {
661
+ html += `<h3>Evidence used</h3>`;
662
+ sources.slice(0, 5).forEach((src, i) => {
663
+ html += `<div class="evidence-box"><b>[${escapeHtml(src.source_id)}]</b>
664
+ <span class="source-chip">Page: ${escapeHtml(src.page)}</span>
665
+ <span class="source-chip">Chunk: ${escapeHtml(src.chunk_id)}</span>
666
+ <div style="margin-top:6px;">${escapeHtml(String(src.preview).slice(0, 320))}</div></div>`;
667
+ });
668
+ } else {
669
+ html += `<div class="warning">No source metadata was returned. Re-index or re-upload if this seems wrong.</div>`;
670
+ }
671
+
672
+ html += `</div>`;
673
+ return html;
674
+ }
675
+
676
+ async function sendMessage() {
677
+ const doc = getSelectedDocument();
678
+ const compareDoc = getCompareDocument();
679
+ const input = document.getElementById("messageInput");
680
+ const userText = input.value.trim();
681
+
682
+ if (!doc) return alert("Upload or select a document first.");
683
+ if (!userText) return;
684
+
685
+ const convo = getConversation();
686
+ convo.push({ role: "user", content: userText, createdAt: new Date().toISOString() });
687
+ input.value = "";
688
+ saveConversations();
689
+ renderMessages();
690
+
691
+ setStatus(compareDoc ? "Comparing..." : "Thinking...");
692
+ document.getElementById("metricsBox").innerHTML = "";
693
+
694
+ try {
695
+ if (compareDoc) {
696
+ let data;
697
+ try {
698
+ const res = await fetch("/documents/compare", {
699
+ method: "POST",
700
+ headers: { "Content-Type": "application/json" },
701
+ body: JSON.stringify({
702
+ primary_document_id: doc.id,
703
+ compare_document_id: compareDoc.id,
704
+ query: userText,
705
+ retrieval_mode: "hybrid",
706
+ top_k: 8,
707
+ use_reranker: true,
708
+ use_llm: true,
709
+ use_graph: true,
710
+ graph_entity_limit: 12,
711
+ use_graph_retrieval: true,
712
+ graph_retrieval_top_k: 6
713
+ })
714
+ });
715
+ data = await res.json();
716
+ if (!res.ok) throw new Error(JSON.stringify(data));
717
+ } catch (err) {
718
+ const dataA = await callAsk(askPayload(buildContextualQuery(userText), doc.id));
719
+ const dataB = await callAsk(askPayload(buildContextualQuery(userText), compareDoc.id));
720
+ data = {
721
+ document_a: { answer: dataA.answer, ask_response: dataA },
722
+ document_b: { answer: dataB.answer, ask_response: dataB }
723
+ };
724
+ }
725
+
726
+ const dataA = data.document_a?.ask_response || { answer: data.document_a?.answer || "No answer.", citations: data.document_a?.sources || [] };
727
+ const dataB = data.document_b?.ask_response || { answer: data.document_b?.answer || "No answer.", citations: data.document_b?.sources || [] };
728
+
729
+ const html = `<div class="answer-card"><h2>Comparison Answer</h2>
730
+ <div class="compare-grid">
731
+ <div class="compare-card"><h3>${escapeHtml(doc.name || "Document A")}</h3>${renderAnswerHtml(userText, dataA, doc)}</div>
732
+ <div class="compare-card"><h3>${escapeHtml(compareDoc.name || "Document B")}</h3>${renderAnswerHtml(userText, dataB, compareDoc)}</div>
733
+ </div></div>`;
734
+
735
+ convo.push({ role: "assistant", html, content: "Comparison answer", createdAt: new Date().toISOString(), rawCompare: data });
736
+ saveConversations();
737
+ renderMessages();
738
+
739
+ updateMetrics(dataA, doc.name || "Document A");
740
+ updateMetrics(dataB, compareDoc.name || "Document B");
741
+ updateCitations([
742
+ { label: doc.name || "Document A", sources: buildSources(dataA, doc) },
743
+ { label: compareDoc.name || "Document B", sources: buildSources(dataB, compareDoc) }
744
+ ]);
745
+ setStatus("Comparison ready");
746
+ } else {
747
+ const data = await callAsk(askPayload(buildContextualQuery(userText), doc.id));
748
+ const html = renderAnswerHtml(userText, data, doc);
749
+ convo.push({ role: "assistant", content: data.answer || "Answer", html, createdAt: new Date().toISOString(), raw: data });
750
+ saveConversations();
751
+ renderMessages();
752
+
753
+ updateMetrics(data, doc.name || "Selected document");
754
+ updateCitations([{ label: doc.name || "Selected document", sources: buildSources(data, doc) }]);
755
+ setStatus("Ready");
756
+ }
757
+ } catch (error) {
758
+ convo.push({ role: "assistant", content: "Error: " + error.message, createdAt: new Date().toISOString() });
759
+ saveConversations();
760
+ renderMessages();
761
+ setStatus("Error");
762
+ }
763
+ }
764
+
765
+ renderDocuments();
766
+ </script>
767
+ </body>
768
+ </html>
769
+ """
770
+ ''', encoding="utf-8")
771
+
772
+ hf_path = Path("app/deployment/hf_status.py")
773
+ text = hf_path.read_text(encoding="utf-8-sig")
774
+ text = text.replace("\ufeff", "")
775
+
776
+ alias_block = """
777
+ # =====================================================
778
+ # Phase 38 final stable UI export
779
+ # This keeps old helper functions but forces / and /app to use the clean final UI.
780
+ # =====================================================
781
+ from app.product.final_product_ui import get_home_html, get_product_app_html
782
+ """
783
+
784
+ if "Phase 38 final stable UI export" not in text:
785
+ text += "\\n\\n" + alias_block
786
+ print("Added Phase 38 final UI export alias.")
787
+ else:
788
+ print("Phase 38 final UI export already exists.")
789
+
790
+ hf_path.write_text(text, encoding="utf-8")
791
+
792
+ print("Phase 38 final UI stabilization complete.")