GraphResearcher / scripts /phase38_final_ui_stabilization.py
yugbirla's picture
Fix final UI export syntax issue
b0aa836
Raw
History Blame Contribute Delete
37.8 kB
from pathlib import Path
# Clean BOM
for path in Path("app").rglob("*.py"):
text = path.read_text(encoding="utf-8-sig")
text = text.replace("\ufeff", "")
path.write_text(text, encoding="utf-8")
Path("app/product").mkdir(parents=True, exist_ok=True)
Path("app/product/__init__.py").touch()
Path("app/product/final_product_ui.py").write_text(r'''
def get_home_html() -> str:
return """
<!DOCTYPE html>
<html>
<head>
<title>GraphResearcher</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: Inter, Arial, sans-serif; background: #f8fafc; color: #0f172a; }
.nav { height: 72px; display: flex; align-items: center; justify-content: space-between; padding: 0 56px; background: white; border-bottom: 1px solid #e5e7eb; }
.brand { font-weight: 900; font-size: 24px; letter-spacing: -0.7px; }
.nav a { text-decoration: none; color: #334155; margin-left: 22px; font-weight: 800; }
.hero { min-height: calc(100vh - 72px); display: grid; grid-template-columns: 1fr 0.9fr; gap: 52px; align-items: center; padding: 64px 72px; }
.badge { display: inline-block; background: #dbeafe; color: #1d4ed8; padding: 8px 12px; border-radius: 999px; font-weight: 900; font-size: 13px; margin-bottom: 18px; }
h1 { font-size: 58px; line-height: 1.04; margin: 0 0 20px; letter-spacing: -2px; }
p { font-size: 19px; line-height: 1.7; color: #475569; max-width: 720px; }
.actions { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 32px; }
.btn { display: inline-block; text-decoration: none; background: #2563eb; color: white; padding: 14px 22px; border-radius: 12px; font-weight: 900; }
.btn.dark { background: #0f172a; }
.preview { background: #0f172a; color: white; border-radius: 26px; padding: 24px; box-shadow: 0 28px 70px rgba(15,23,42,0.24); }
.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; }
.preview-card b { color: #bfdbfe; }
.features { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; padding: 0 72px 58px; }
.feature { background: white; border: 1px solid #e5e7eb; border-radius: 18px; padding: 24px; }
.feature p { font-size: 15px; color: #64748b; }
@media(max-width: 950px) {
.nav { padding: 0 22px; }
.hero { grid-template-columns: 1fr; padding: 42px 24px; }
h1 { font-size: 42px; }
.features { grid-template-columns: 1fr; padding: 0 24px 44px; }
}
</style>
</head>
<body>
<div class="nav">
<div class="brand">GraphResearcher</div>
<div><a href="/app">Launch App</a></div>
</div>
<section class="hero">
<div>
<div class="badge">Document Chat + GraphRAG</div>
<h1>Upload documents. Ask questions. Verify every answer.</h1>
<p>
GraphResearcher lets users chat with PDFs and reports using citation-grounded retrieval,
graph context, source verification, graph view, and document comparison.
</p>
<div class="actions">
<a class="btn" href="/app">Start Chatting</a>
<a class="btn dark" href="/login">Login</a>
</div>
</div>
<div class="preview">
<div class="preview-card"><b>1. Upload</b><br>Add a document from the app sidebar. No document ID needed.</div>
<div class="preview-card"><b>2. Chat</b><br>Ask natural questions and get source-backed answers.</div>
<div class="preview-card"><b>3. Verify</b><br>Open sources, see page/chunk metadata, and inspect the graph.</div>
<div class="preview-card"><b>4. Compare</b><br>Select a second document and compare them side by side.</div>
</div>
</section>
<section class="features">
<div class="feature"><h3>Simple workspace</h3><p>Upload, select, delete, and re-index documents from one clean UI.</p></div>
<div class="feature"><h3>Grounded answers</h3><p>Answers show evidence boxes and source cards for verification.</p></div>
<div class="feature"><h3>GraphRAG inside</h3><p>Graph view and graph retrieval stay available without exposing developer tools.</p></div>
</section>
</body>
</html>
"""
def get_product_app_html() -> str:
return """
<!DOCTYPE html>
<html>
<head>
<title>GraphResearcher App</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: Inter, Arial, sans-serif; background: #f8fafc; color: #111827; }
.app { display: grid; grid-template-columns: 310px 1fr 370px; height: 100vh; }
.sidebar { background: #0f172a; color: white; padding: 18px; overflow-y: auto; }
.brand { font-size: 24px; font-weight: 900; margin-bottom: 18px; letter-spacing: -0.7px; }
.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; }
.upload-box input { width: 100%; font-size: 12px; margin: 10px 0; }
button { border: none; border-radius: 10px; padding: 10px 13px; cursor: pointer; background: #2563eb; color: white; font-weight: 800; }
button:hover { background: #1d4ed8; }
button.secondary { background: #334155; }
button.green { background: #059669; }
button.red { background: #dc2626; }
button.light { background: #f1f5f9; color: #0f172a; border: 1px solid #cbd5e1; }
.full { width: 100%; margin-bottom: 9px; }
.small { font-size: 12px; color: #94a3b8; line-height: 1.5; }
.doc-card { padding: 12px; border-radius: 13px; background: rgba(255,255,255,0.07); margin-bottom: 9px; cursor: pointer; border: 1px solid transparent; }
.doc-card.active { background: #2563eb; border-color: #93c5fd; }
.doc-title { font-size: 14px; font-weight: 800; word-break: break-word; }
.doc-meta { font-size: 11px; color: #cbd5e1; margin-top: 4px; }
.main { display: flex; flex-direction: column; height: 100vh; }
.topbar { padding: 14px 20px; border-bottom: 1px solid #e5e7eb; background: white; display: flex; justify-content: space-between; gap: 14px; align-items: center; }
.selected-doc { font-weight: 900; font-size: 17px; }
.selected-doc span { display: block; color: #64748b; font-weight: 500; font-size: 12px; margin-top: 2px; }
.status-pill { font-size: 12px; padding: 7px 10px; border-radius: 999px; background: #e0f2fe; color: #075985; white-space: nowrap; font-weight: 800; }
.messages { flex: 1; overflow-y: auto; padding: 26px; }
.message { max-width: 940px; margin-bottom: 18px; display: flex; }
.message.user { margin-left: auto; justify-content: flex-end; }
.bubble { padding: 15px 17px; border-radius: 17px; line-height: 1.65; white-space: pre-wrap; font-size: 15.5px; }
.user .bubble { background: #2563eb; color: white; border-bottom-right-radius: 4px; }
.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); }
.empty { max-width: 760px; margin: 70px auto; text-align: center; color: #475569; }
.empty h1 { color: #0f172a; }
.composer { padding: 16px 20px; background: white; border-top: 1px solid #e5e7eb; }
.composer-box { display: flex; gap: 10px; align-items: flex-end; }
textarea { flex: 1; resize: none; min-height: 54px; max-height: 160px; padding: 13px; border: 1px solid #cbd5e1; border-radius: 12px; font-size: 15px; }
select { width: 100%; padding: 9px; border-radius: 9px; border: 1px solid #cbd5e1; background: white; }
.right-panel { background: white; border-left: 1px solid #e5e7eb; padding: 16px; overflow-y: auto; }
.panel-section { margin-bottom: 20px; }
.panel-section h3 { margin-bottom: 8px; font-size: 16px; }
.toggle-row { display: flex; flex-direction: column; gap: 8px; font-size: 14px; }
.metric { display: inline-block; background: #eef2ff; color: #3730a3; padding: 5px 8px; border-radius: 999px; font-size: 12px; margin: 3px; }
.citation-card { border: 1px solid #e5e7eb; background: #f8fafc; border-radius: 12px; padding: 12px; margin-bottom: 11px; font-size: 13px; }
.source-line { margin-top: 5px; color: #475569; }
.preview-text { margin-top: 8px; color: #64748b; font-size: 12px; line-height: 1.5; }
.answer-card { line-height: 1.72; }
.answer-card h2 { margin: 0 0 10px; font-size: 18px; color: #0f172a; }
.answer-card h3 { margin: 18px 0 8px; font-size: 15px; color: #1d4ed8; }
.answer-card p { margin: 8px 0; }
.evidence-box { background: #f8fafc; border: 1px solid #e5e7eb; border-radius: 12px; padding: 11px; margin-top: 9px; font-size: 13px; color: #475569; }
.source-chip { display: inline-block; background: #eef2ff; color: #3730a3; padding: 3px 7px; border-radius: 999px; font-size: 12px; margin: 2px; font-weight: 700; }
.warning { background: #fff7ed; border: 1px solid #fed7aa; color: #9a3412; padding: 10px; border-radius: 12px; margin: 10px 0; }
.compare-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 12px; }
.compare-card { border: 1px solid #e5e7eb; background: #f8fafc; border-radius: 14px; padding: 14px; line-height: 1.6; }
.danger-zone { border-top: 1px solid #e5e7eb; padding-top: 14px; }
@media (max-width: 1150px) { .app { grid-template-columns: 290px 1fr; } .right-panel { display: none; } }
@media (max-width: 850px) { .compare-grid { grid-template-columns: 1fr; } }
@media (max-width: 760px) { .app { grid-template-columns: 1fr; } .sidebar { display: none; } }
</style>
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="brand">GraphResearcher</div>
<div class="upload-box">
<b>Upload document</b>
<p class="small">Upload, select, and chat. If Hugging Face rebuilt recently, clear cache and re-upload.</p>
<input id="fileInput" type="file">
<button class="full green" onclick="uploadDocument()">Upload & Select</button>
<button class="full secondary" onclick="refreshDocuments()">Refresh Documents</button>
</div>
<div class="small" style="margin-bottom:8px;">My Documents</div>
<div id="documentList"></div>
<hr style="border-color:rgba(255,255,255,0.15); margin:18px 0;">
<button class="full secondary" onclick="newChat()">+ New Chat</button>
<button class="full secondary" onclick="reindexSelectedDocument()">Re-index Selected</button>
<button class="full secondary" onclick="buildGraph()">Build / Rebuild Graph</button>
<button class="full secondary" onclick="openGraphViewer()">View Graph</button>
<button class="full secondary" onclick="clearWorkspaceCache()">Clear Workspace Cache</button>
<button class="full secondary" onclick="window.location.href='/'">Home</button>
</aside>
<main class="main">
<div class="topbar">
<div class="selected-doc" id="selectedDocTitle">
No document selected
<span>Upload or select a document from the left sidebar.</span>
</div>
<span id="appStatus" class="status-pill">Ready</span>
</div>
<div id="messages" class="messages"></div>
<div class="composer">
<div class="composer-box">
<textarea id="messageInput" placeholder="Ask about the selected document..." onkeydown="handleKeyDown(event)"></textarea>
<button onclick="sendMessage()">Send</button>
</div>
<div class="small" style="margin-top:8px;color:#64748b;">
Tip: choose a second document in the right panel to compare two documents.
</div>
</div>
</main>
<aside class="right-panel">
<div class="panel-section">
<h3>Selected Document</h3>
<div id="docDetails" class="small" style="color:#64748b;">No document selected.</div>
</div>
<div class="panel-section">
<h3>Compare With</h3>
<select id="compareDocumentSelect" onchange="renderSelectedDocument()">
<option value="">No comparison</option>
</select>
<p class="small" style="color:#64748b;">Choose another document for side-by-side comparison.</p>
</div>
<div class="panel-section">
<h3>Graph</h3>
<button class="green" onclick="buildGraph()">Build / Rebuild Graph</button>
<button class="secondary" onclick="openGraphViewer()">View Graph</button>
</div>
<div class="panel-section">
<h3>Answer Style</h3>
<select id="answerStyle">
<option value="detailed" selected>Detailed</option>
<option value="step_by_step">Step-by-step</option>
<option value="concise">Concise</option>
<option value="research">Research style</option>
<option value="comparison">Comparison focused</option>
</select>
</div>
<div class="panel-section">
<h3>Advanced Settings</h3>
<div class="toggle-row">
<label><input type="checkbox" id="useLLM" checked> Use LLM</label>
<label><input type="checkbox" id="useReranker" checked> Use reranker</label>
<label><input type="checkbox" id="useGraph" checked> Use graph context</label>
<label><input type="checkbox" id="useGraphRetrieval" checked> Use graph retrieval fusion</label>
</div>
</div>
<div class="panel-section">
<h3>Last Answer Metrics</h3>
<div id="metricsBox"><span class="metric">No answer yet</span></div>
</div>
<div class="panel-section">
<h3>Sources</h3>
<div id="citationsBox" class="small" style="color:#64748b;">Sources will appear here after an answer.</div>
</div>
<div class="panel-section danger-zone">
<h3>Danger Zone</h3>
<button class="red" onclick="deleteSelectedDocument()">Delete Selected Document</button>
</div>
</aside>
</div>
<script>
let documents = JSON.parse(localStorage.getItem("graphrag_documents") || "[]");
let selectedDocumentId = localStorage.getItem("graphrag_selected_document_id") || null;
let conversations = JSON.parse(localStorage.getItem("graphrag_final_conversations") || "{}");
let lastSources = [];
function saveDocuments() {
localStorage.setItem("graphrag_documents", JSON.stringify(documents));
if (selectedDocumentId) localStorage.setItem("graphrag_selected_document_id", selectedDocumentId);
}
function saveConversations() { localStorage.setItem("graphrag_final_conversations", JSON.stringify(conversations)); }
function setStatus(text) { document.getElementById("appStatus").textContent = text; }
function getSelectedDocument() { return documents.find(d => d.id === selectedDocumentId) || null; }
function getCompareDocument() {
const id = document.getElementById("compareDocumentSelect")?.value || "";
if (!id) return null;
return documents.find(d => d.id === id) || null;
}
function escapeHtml(value) {
return String(value || "").replaceAll("&","&amp;").replaceAll("<","&lt;").replaceAll(">","&gt;").replaceAll('"',"&quot;");
}
function parseDocumentId(data) {
return data.document_id || data.id || data.doc_id || data.documentId ||
data.document?.document_id || data.document?.id ||
data.file?.document_id || data.result?.document_id || data.data?.document_id;
}
async function tryEndpoints(candidates, optionsFactory) {
let lastError = null;
for (const candidate of candidates) {
try {
const response = await fetch(candidate, optionsFactory(candidate));
const contentType = response.headers.get("content-type") || "";
const data = contentType.includes("application/json") ? await response.json() : await response.text();
if (response.ok) return { ok: true, endpoint: candidate, data };
lastError = { endpoint: candidate, status: response.status, data };
} catch (error) {
lastError = { endpoint: candidate, error: error.message };
}
}
return { ok: false, error: lastError };
}
function renderDocuments() {
const list = document.getElementById("documentList");
list.innerHTML = "";
if (documents.length === 0) list.innerHTML = `<div class="small">No documents yet. Upload one above.</div>`;
documents.forEach(doc => {
const div = document.createElement("div");
div.className = "doc-card" + (doc.id === selectedDocumentId ? " active" : "");
div.onclick = () => selectDocument(doc.id);
div.innerHTML = `<div class="doc-title">${escapeHtml(doc.name || "Untitled document")}</div>
<div class="doc-meta">${escapeHtml(doc.status || "uploaded")} • ${escapeHtml(doc.graphStatus || "graph not built")}</div>`;
list.appendChild(div);
});
renderCompareDropdown();
renderSelectedDocument();
}
function renderCompareDropdown() {
const select = document.getElementById("compareDocumentSelect");
if (!select) return;
const oldValue = select.value;
select.innerHTML = `<option value="">No comparison</option>`;
documents.filter(d => d.id !== selectedDocumentId).forEach(doc => {
const option = document.createElement("option");
option.value = doc.id;
option.textContent = doc.name || "Untitled document";
select.appendChild(option);
});
if ([...select.options].some(o => o.value === oldValue)) select.value = oldValue;
}
function renderSelectedDocument() {
const doc = getSelectedDocument();
const compareDoc = getCompareDocument();
const title = document.getElementById("selectedDocTitle");
const details = document.getElementById("docDetails");
if (!doc) {
title.innerHTML = `No document selected <span>Upload or select a document from the left sidebar.</span>`;
details.textContent = "No document selected.";
renderMessages();
return;
}
const subtitle = compareDoc ? `Compare mode active with ${compareDoc.name || "second document"}.` : "Ready for document chat.";
title.innerHTML = `${escapeHtml(doc.name || "Untitled document")} <span>${escapeHtml(subtitle)}</span>`;
details.innerHTML = `<b>Name:</b> ${escapeHtml(doc.name || "Untitled")}<br>
<b>Status:</b> ${escapeHtml(doc.status || "uploaded")}<br>
<b>Graph:</b> ${escapeHtml(doc.graphStatus || "not built")}<br>
<b>Uploaded:</b> ${escapeHtml(doc.uploadedAt || "unknown")}
${compareDoc ? `<br><br><b>Comparing with:</b> ${escapeHtml(compareDoc.name || "Untitled")}` : ""}`;
renderMessages();
}
function selectDocument(id) {
selectedDocumentId = id;
saveDocuments();
renderDocuments();
}
function getConversationKey() {
const compareDoc = getCompareDocument();
if (compareDoc) return `${selectedDocumentId}__compare__${compareDoc.id}`;
return selectedDocumentId || "default";
}
function getConversation() {
const key = getConversationKey();
if (!conversations[key]) conversations[key] = [];
return conversations[key];
}
function newChat() {
if (!selectedDocumentId) return alert("Select a document first.");
conversations[getConversationKey()] = [];
saveConversations();
renderMessages();
}
function renderMessages() {
const box = document.getElementById("messages");
const doc = getSelectedDocument();
const compareDoc = getCompareDocument();
if (!doc) {
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>`;
return;
}
const convo = getConversation();
if (convo.length === 0) {
box.innerHTML = `<div class="empty"><h1>${compareDoc ? "Compare documents" : "Chat with your document"}</h1>
<p>${compareDoc ? `You are comparing ${escapeHtml(doc.name)} with ${escapeHtml(compareDoc.name)}.` : `Ask a question about ${escapeHtml(doc.name)}.`}</p></div>`;
return;
}
box.innerHTML = "";
convo.forEach(msg => {
const wrapper = document.createElement("div");
wrapper.className = "message " + msg.role;
const bubble = document.createElement("div");
bubble.className = msg.type === "compare" ? "bubble" : "bubble";
if (msg.role === "assistant" && msg.html) bubble.innerHTML = msg.html;
else bubble.textContent = msg.content || "";
wrapper.appendChild(bubble);
box.appendChild(wrapper);
});
box.scrollTop = box.scrollHeight;
}
async function uploadDocument() {
const file = document.getElementById("fileInput").files[0];
if (!file) return alert("Choose a file first.");
setStatus("Uploading...");
const formData = new FormData();
formData.append("file", file);
const result = await tryEndpoints(["/documents/upload", "/upload", "/documents", "/api/documents/upload"], () => ({ method: "POST", body: formData }));
if (!result.ok) {
setStatus("Upload failed");
return alert("Upload failed: " + JSON.stringify(result.error));
}
const documentId = parseDocumentId(result.data);
if (!documentId) {
setStatus("Upload returned no document ID");
return alert("Upload worked but document ID was not found in response.");
}
const doc = { id: documentId, name: file.name, status: "uploaded", graphStatus: "not built", uploadedAt: new Date().toLocaleString() };
const index = documents.findIndex(d => d.id === documentId);
if (index >= 0) documents[index] = doc;
else documents.unshift(doc);
selectedDocumentId = documentId;
saveDocuments();
renderDocuments();
setStatus("Uploaded");
await autoIndexDocument(documentId);
await buildGraph(true);
}
async function autoIndexDocument(documentId) {
setStatus("Indexing...");
const result = await tryEndpoints([`/documents/${documentId}/index`, `/documents/${documentId}/process`, `/documents/${documentId}/ingest`, `/index/${documentId}`], () => ({ method: "POST" }));
const doc = documents.find(d => d.id === documentId);
if (doc) {
doc.status = result.ok ? "indexed" : "uploaded";
saveDocuments();
renderDocuments();
}
setStatus(result.ok ? "Indexed" : "Uploaded");
}
async function reindexSelectedDocument() {
const doc = getSelectedDocument();
if (!doc) return alert("Select a document first.");
await autoIndexDocument(doc.id);
await buildGraph(false);
alert("Re-index attempt complete. Ask again now.");
}
async function buildGraph(silent = false) {
const doc = getSelectedDocument();
if (!doc) {
if (!silent) alert("Select a document first.");
return;
}
setStatus("Building graph...");
try {
const response = await fetch(`/documents/${doc.id}/graph/build`, { method: "POST" });
const data = await response.json();
if (!response.ok) throw new Error(JSON.stringify(data));
doc.graphStatus = "graph built";
doc.graphData = { entities: data.total_entities ?? data.entity_count ?? null, relations: data.total_relations ?? data.relation_count ?? null };
saveDocuments();
renderDocuments();
document.getElementById("metricsBox").innerHTML = `<span class="metric">graph built</span>
<span class="metric">entities: ${doc.graphData.entities ?? "NA"}</span>
<span class="metric">relations: ${doc.graphData.relations ?? "NA"}</span>`;
setStatus("Graph ready");
} catch (error) {
doc.graphStatus = "graph not built";
saveDocuments();
renderDocuments();
setStatus("Graph skipped");
if (!silent) alert("Graph build failed. Re-index or re-upload if needed.");
}
}
function openGraphViewer() {
const doc = getSelectedDocument();
if (!doc) return alert("Select a document first.");
window.open(`/documents/${doc.id}/graph/view`, "_blank");
}
async function deleteSelectedDocument() {
const doc = getSelectedDocument();
if (!doc) return alert("Select a document first.");
if (!confirm(`Delete "${doc.name}"?`)) return;
setStatus("Deleting...");
await tryEndpoints([`/documents/${doc.id}/delete`, `/documents/${doc.id}`, `/api/documents/${doc.id}`], () => ({ method: "DELETE" }));
documents = documents.filter(d => d.id !== doc.id);
Object.keys(conversations).forEach(k => { if (k.includes(doc.id)) delete conversations[k]; });
selectedDocumentId = documents.length ? documents[0].id : null;
saveDocuments();
saveConversations();
renderDocuments();
renderMessages();
setStatus("Deleted");
}
function clearWorkspaceCache() {
if (!confirm("Clear browser document list and chat history? Use this after Hugging Face rebuilds.")) return;
["graphrag_documents", "graphrag_selected_document_id", "graphrag_conversations", "graphrag_conversations_v2", "graphrag_conversations_v3", "graphrag_final_conversations"].forEach(k => localStorage.removeItem(k));
documents = [];
selectedDocumentId = null;
conversations = {};
renderDocuments();
renderMessages();
setStatus("Cache cleared");
}
function refreshDocuments() { renderDocuments(); setStatus("Refreshed"); }
function handleKeyDown(event) { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); sendMessage(); } }
function buildContextualQuery(currentQuestion) {
const clean = String(currentQuestion || "").trim();
if (!clean) return clean;
const wordCount = clean.split(/\\s+/).filter(Boolean).length;
if (wordCount <= 8) {
const prev = getConversation().filter(m => m.role === "user").slice(-2).map(m => m.content).join(" ");
return prev ? prev + " " + clean : clean;
}
return clean;
}
function askPayload(query, documentId) {
return {
query,
document_id: documentId,
top_k: 8,
retrieval_mode: "hybrid",
use_reranker: document.getElementById("useReranker").checked,
use_llm: document.getElementById("useLLM").checked,
use_graph: document.getElementById("useGraph").checked,
graph_entity_limit: 12,
use_graph_retrieval: document.getElementById("useGraphRetrieval").checked,
graph_retrieval_top_k: 6
};
}
async function callAsk(payload) {
const response = await fetch("/ask", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
const data = await response.json();
if (!response.ok) throw new Error(JSON.stringify(data));
return data;
}
function valueFrom(obj, keys, fallback = "Not available") {
for (const key of keys) if (obj && obj[key] !== undefined && obj[key] !== null && obj[key] !== "") return obj[key];
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];
return fallback;
}
function buildSources(data, doc) {
const sources = [];
(data.citations || []).forEach(x => sources.push(x));
(data.retrieval_fusion?.fused_results || []).forEach(x => sources.push(x));
(data.sources || data.source_chunks || data.retrieved_sources || []).forEach(x => sources.push(x));
const cleaned = [];
const seen = new Set();
sources.forEach((src, i) => {
const sid = valueFrom(src, ["source_id", "id", "citation_id"], "S" + (i + 1));
const chunk = valueFrom(src, ["chunk_id", "source_chunk_id", "chunk"], sid);
const page = valueFrom(src, ["page_number", "page", "page_no"], "Not available");
const key = doc.id + "|" + sid + "|" + chunk + "|" + page;
if (seen.has(key)) return;
seen.add(key);
cleaned.push({
source_id: sid,
document_id: doc.id,
document_name: valueFrom(src, ["document_name", "source_file_name", "file_name", "filename", "document_title"], doc.name || "Selected document"),
page,
chunk_id: chunk,
preview: valueFrom(src, ["text_preview", "preview", "chunk_preview", "content_preview", "text", "content"], "Preview not available"),
raw: src
});
});
return cleaned.slice(0, 8);
}
function updateCitations(groups) {
const box = document.getElementById("citationsBox");
lastSources = [];
box.innerHTML = "";
groups.forEach(group => {
const h = document.createElement("div");
h.innerHTML = `<h4>${escapeHtml(group.label)}</h4>`;
box.appendChild(h);
group.sources.forEach(src => {
const idx = lastSources.length;
lastSources.push(src);
const card = document.createElement("div");
card.className = "citation-card";
card.innerHTML = `<b>Source ${idx + 1}: ${escapeHtml(src.source_id)}</b>
<div class="source-line"><b>Document:</b> ${escapeHtml(src.document_name)}</div>
<div class="source-line"><b>Page:</b> ${escapeHtml(src.page)}</div>
<div class="source-line"><b>Chunk:</b> ${escapeHtml(src.chunk_id)}</div>
<div class="preview-text">${escapeHtml(String(src.preview).slice(0, 260))}</div><br>
<button class="light" onclick="openSource(${idx})">Open source</button>`;
box.appendChild(card);
});
});
if (!lastSources.length) box.textContent = "No source details returned.";
}
function openSource(index) {
const src = lastSources[index];
if (!src) return;
window.open(`/documents/${src.document_id}/sources/${encodeURIComponent(src.source_id)}/view?page=${encodeURIComponent(src.page || "")}&chunk_id=${encodeURIComponent(src.chunk_id || "")}`, "_blank");
}
function updateMetrics(data, label) {
const fusion = data.retrieval_fusion || {};
const graph = data.graph_context || {};
document.getElementById("metricsBox").innerHTML += `<div style="margin-bottom:8px;"><b>${escapeHtml(label || "")}</b><br>
<span class="metric">strategy: ${escapeHtml(data.answer_strategy || "NA")}</span>
<span class="metric">LLM: ${data.used_llm}</span>
<span class="metric">graph: ${data.graph_used}</span>
<span class="metric">graph available: ${graph.graph_available}</span>
<span class="metric">fusion: ${fusion.fusion_used}</span></div>`;
}
function answerLooksWeak(answer) {
const words = String(answer || "").split(/\\s+/).filter(Boolean);
return words.length < 100;
}
function renderAnswerHtml(question, data, doc) {
const answer = String(data.answer || "I could not generate an answer.").trim();
const sources = buildSources(data, doc);
let html = `<div class="answer-card">`;
if (answer.toLowerCase().includes("i could not find relevant indexed sources")) {
html += `<h2>I could not find indexed evidence</h2>
<div class="warning">This usually means the browser remembers an old document but backend files were reset. Clear cache and upload again.</div></div>`;
return html;
}
html += `<h2>Answer</h2>`;
const escaped = escapeHtml(answer).replaceAll("\\n", "<br>");
html += `<p>${escaped}</p>`;
if (sources.length) {
html += `<h3>Evidence used</h3>`;
sources.slice(0, 5).forEach((src, i) => {
html += `<div class="evidence-box"><b>[${escapeHtml(src.source_id)}]</b>
<span class="source-chip">Page: ${escapeHtml(src.page)}</span>
<span class="source-chip">Chunk: ${escapeHtml(src.chunk_id)}</span>
<div style="margin-top:6px;">${escapeHtml(String(src.preview).slice(0, 320))}</div></div>`;
});
} else {
html += `<div class="warning">No source metadata was returned. Re-index or re-upload if this seems wrong.</div>`;
}
html += `</div>`;
return html;
}
async function sendMessage() {
const doc = getSelectedDocument();
const compareDoc = getCompareDocument();
const input = document.getElementById("messageInput");
const userText = input.value.trim();
if (!doc) return alert("Upload or select a document first.");
if (!userText) return;
const convo = getConversation();
convo.push({ role: "user", content: userText, createdAt: new Date().toISOString() });
input.value = "";
saveConversations();
renderMessages();
setStatus(compareDoc ? "Comparing..." : "Thinking...");
document.getElementById("metricsBox").innerHTML = "";
try {
if (compareDoc) {
let data;
try {
const res = await fetch("/documents/compare", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
primary_document_id: doc.id,
compare_document_id: compareDoc.id,
query: userText,
retrieval_mode: "hybrid",
top_k: 8,
use_reranker: true,
use_llm: true,
use_graph: true,
graph_entity_limit: 12,
use_graph_retrieval: true,
graph_retrieval_top_k: 6
})
});
data = await res.json();
if (!res.ok) throw new Error(JSON.stringify(data));
} catch (err) {
const dataA = await callAsk(askPayload(buildContextualQuery(userText), doc.id));
const dataB = await callAsk(askPayload(buildContextualQuery(userText), compareDoc.id));
data = {
document_a: { answer: dataA.answer, ask_response: dataA },
document_b: { answer: dataB.answer, ask_response: dataB }
};
}
const dataA = data.document_a?.ask_response || { answer: data.document_a?.answer || "No answer.", citations: data.document_a?.sources || [] };
const dataB = data.document_b?.ask_response || { answer: data.document_b?.answer || "No answer.", citations: data.document_b?.sources || [] };
const html = `<div class="answer-card"><h2>Comparison Answer</h2>
<div class="compare-grid">
<div class="compare-card"><h3>${escapeHtml(doc.name || "Document A")}</h3>${renderAnswerHtml(userText, dataA, doc)}</div>
<div class="compare-card"><h3>${escapeHtml(compareDoc.name || "Document B")}</h3>${renderAnswerHtml(userText, dataB, compareDoc)}</div>
</div></div>`;
convo.push({ role: "assistant", html, content: "Comparison answer", createdAt: new Date().toISOString(), rawCompare: data });
saveConversations();
renderMessages();
updateMetrics(dataA, doc.name || "Document A");
updateMetrics(dataB, compareDoc.name || "Document B");
updateCitations([
{ label: doc.name || "Document A", sources: buildSources(dataA, doc) },
{ label: compareDoc.name || "Document B", sources: buildSources(dataB, compareDoc) }
]);
setStatus("Comparison ready");
} else {
const data = await callAsk(askPayload(buildContextualQuery(userText), doc.id));
const html = renderAnswerHtml(userText, data, doc);
convo.push({ role: "assistant", content: data.answer || "Answer", html, createdAt: new Date().toISOString(), raw: data });
saveConversations();
renderMessages();
updateMetrics(data, doc.name || "Selected document");
updateCitations([{ label: doc.name || "Selected document", sources: buildSources(data, doc) }]);
setStatus("Ready");
}
} catch (error) {
convo.push({ role: "assistant", content: "Error: " + error.message, createdAt: new Date().toISOString() });
saveConversations();
renderMessages();
setStatus("Error");
}
}
renderDocuments();
</script>
</body>
</html>
"""
''', encoding="utf-8")
hf_path = Path("app/deployment/hf_status.py")
text = hf_path.read_text(encoding="utf-8-sig")
text = text.replace("\ufeff", "")
alias_block = """
# =====================================================
# Phase 38 final stable UI export
# This keeps old helper functions but forces / and /app to use the clean final UI.
# =====================================================
from app.product.final_product_ui import get_home_html, get_product_app_html
"""
if "Phase 38 final stable UI export" not in text:
text += "\\n\\n" + alias_block
print("Added Phase 38 final UI export alias.")
else:
print("Phase 38 final UI export already exists.")
hf_path.write_text(text, encoding="utf-8")
print("Phase 38 final UI stabilization complete.")