Skip to content

Commit bc3773f

Browse files
fix(web): twelve findings from the agent bug sweep
Sweep = per-surface finder agents + adversarial verification against the working tree; every item below was confirmed reachable. - Inspection trace silently ignored the Force-paper filter that chat honors from the same settings object; it now passes paperId. - Negative rerank scores produced FULL relevance bars: a negative CSS width is invalid, gets dropped, and the display:block fill defaults to width:auto. ScoreBar now clamps to 0..1 as a backstop. - The source-page modal fed raw rerank logits to its "passage sim" bar, pegging it at 100% for nearly every chunk; the bar now renders only for genuine 0..1 similarities, the number always shows. - Inspection rendered a fabricated route pill and "text/default" Route stage on routerless deployments, contradicting the visual-store copy next to it; both now say the router is off. - .insp-scores kept a vestigial two-column grid, squeezing the lone relevance row into half its width. - The Retrieve stage credited "the dense index" for what is a hybrid BM25+dense RRF pool cut down by the reranker. - readSse silently swallowed OpenRouter mid-stream error frames (HTTP-200 streams carrying {"error":...}), rendering a finished empty answer; it now throws and the chat shows an honest notice. - buildMessages had no image budget: ctx=16 could inline 15+ MB of base64 pages; capped at six chunk pages plus two referenced-figure extras. - A transient failure of the 80-token BYOK condense call killed the whole turn; it now falls back to the raw message like condenseDemo. - The /query warm-up retry showed "warming up" for two minutes even when the server said "Retriever not configured" (a state that never self-heals); that case now gets honest copy and a 20s budget. - referencedFigurePages missed plural references ("figures 2 and 3"); the regex now captures keyword-less number lists. - A demo 502 (whole free pool down) told keyless visitors their nonexistent key was invalid, with raw JSON in the message; it now renders a notice naming the real cause.
1 parent a55a4f2 commit bc3773f

5 files changed

Lines changed: 85 additions & 29 deletions

File tree

web/app/api.js

Lines changed: 52 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,12 @@
108108
return { results: data.results || [], routing: data.routing || null, trace: data.trace || null };
109109
}
110110

111-
// /query returns 503 ("Retriever not configured") during the post-cold-start
112-
// warm-up while the model + index load in the background. That is "wait",
113-
// not "failed": retry until ready, surfacing an honest notice. Any other
114-
// non-OK status, or 503 past the budget, throws.
115-
const warmupDeadline = performance.now() + 120000;
111+
// The server wires the retriever during lifespan startup, so a /query 503
112+
// is either Cloud Run still routing to a starting instance (transient) or
113+
// "Retriever not configured" — a corpus that failed to load, which no
114+
// amount of waiting fixes. Retry both briefly (transient 503s are real),
115+
// but say which one is happening; give the permanent case a short budget.
116+
const start = performance.now();
116117
while (true) {
117118
const res = await fetch("/query", {
118119
method: "POST",
@@ -124,11 +125,13 @@
124125
return { results: data.results || [], routing: data.routing || null, trace: null };
125126
}
126127
const detail = await res.text();
127-
if (res.status === 503 && performance.now() < warmupDeadline) {
128+
const noCorpus = detail.includes("Retriever not configured");
129+
const budget = noCorpus ? 20000 : 120000;
130+
if (res.status === 503 && performance.now() - start < budget) {
128131
onStatus &&
129-
onStatus(
130-
"Server is warming up after a cold start. The first query loads the model and index and can take a minute or two. Retrying automatically…"
131-
);
132+
onStatus(noCorpus
133+
? "The server reports no corpus is loaded. Retrying briefly in case it is still starting…"
134+
: "Server is warming up after a cold start. The first query can take a minute or two. Retrying automatically…");
132135
await new Promise((r) => setTimeout(r, 3000));
133136
continue;
134137
}
@@ -223,12 +226,18 @@
223226
// the top retrieved chunks; at most two extra pages.
224227
function referencedFigurePages(question, chunks, figureIndex) {
225228
if (!Array.isArray(figureIndex) || figureIndex.length === 0) return [];
226-
const refs = [...question.matchAll(/\b(fig(?:ure)?\.?|table)\s*(\d+)\b/gi)];
229+
// Plural-aware: "figures 2 and 3" / "tables 1, 2" carry one keyword for a
230+
// list of numbers, so capture the whole number list and split it.
231+
const refs = [];
232+
for (const m of question.matchAll(/\b(figs?\.?|figures?|tables?)\s*(\d+(?:\s*(?:,|and|&||-)\s*\d+)*)\b/gi)) {
233+
const isTable = /^t/i.test(m[1]);
234+
for (const num of m[2].match(/\d+/g) || []) refs.push({ isTable, num });
235+
}
227236
if (refs.length === 0) return [];
228237
const papers = [...new Set(chunks.slice(0, 3).map((c) => c.paper_id))];
229238
const out = [];
230-
for (const [, kindRaw, num] of refs) {
231-
const re = /^t/i.test(kindRaw)
239+
for (const { isTable, num } of refs) {
240+
const re = isTable
232241
? new RegExp(`^table\\.?\\s*${num}\\b`, "i")
233242
: new RegExp(`^fig(?:ure)?\\.?\\s*${num}\\b`, "i");
234243
for (const paperId of papers) {
@@ -263,18 +272,25 @@
263272

264273
const content = [];
265274
const seenPages = new Set();
275+
// Page images average ~0.5 MB as base64; with the ctx slider at 16 an
276+
// uncapped loop could inline 15+ MB and blow provider payload limits or
277+
// the demo relay's read timeout. Six chunk pages plus the two referenced-
278+
// figure extras keeps the body well under that.
279+
let chunkImages = 0;
266280
for (const c of chunks) {
267281
content.push({
268282
type: "text",
269283
text: `[chunk ${c.chunk_id}] paper=${c.paper_id} pages=${(c.page_numbers || []).join(",")}\n${c.text || ""}`,
270284
});
271285
if (useImages && Array.isArray(c.page_numbers)) {
272286
for (const page of c.page_numbers) {
287+
if (chunkImages >= 6) break;
273288
const key = `${c.paper_id}:${page}`;
274289
if (seenPages.has(key)) continue;
275290
seenPages.add(key);
276291
const dataUrl = await imageToDataUrl(pageImageUrl(c.paper_id, page));
277292
if (dataUrl) {
293+
chunkImages += 1;
278294
// Label the image so visual claims have a citable id (the system
279295
// prompt directs figure descriptions at these, not text chunks).
280296
content.push({ type: "text", text: `[page image ${c.paper_id}::p${page}::page]` });
@@ -321,17 +337,26 @@
321337
if (!line.startsWith("data:")) continue;
322338
const payload = line.slice(5).trim();
323339
if (payload === "[DONE]") continue;
340+
let obj = null;
324341
try {
325-
const obj = JSON.parse(payload);
326-
const delta = obj.choices?.[0]?.delta?.content || "";
327-
if (delta) {
328-
acc += delta;
329-
onDelta(delta);
330-
}
331-
if (obj.usage) usage = obj.usage;
342+
obj = JSON.parse(payload);
332343
} catch {
333-
// heartbeat / partial — skip
344+
continue; // heartbeat / partial — skip
345+
}
346+
// OpenRouter delivers mid-stream failures as an error frame on an
347+
// HTTP-200 stream (common on free-tier endpoints under load).
348+
// Swallowing it would render a finished, silently empty answer.
349+
if (obj.error) {
350+
const err = new Error(obj.error.message || "The model provider failed mid-answer.");
351+
err.code = "stream_error";
352+
throw err;
334353
}
354+
const delta = obj.choices?.[0]?.delta?.content || "";
355+
if (delta) {
356+
acc += delta;
357+
onDelta(delta);
358+
}
359+
if (obj.usage) usage = obj.usage;
335360
}
336361
}
337362
return { text: acc, usage };
@@ -378,6 +403,13 @@
378403
err.code = "demo_quota";
379404
throw err;
380405
}
406+
if (res.status === 502) {
407+
// The server tried the whole free-model chain and every endpoint was
408+
// down (free-pool churn). Keyless visitors have no key to blame.
409+
const err = new Error("The free demo models are all busy right now. Retry in a minute, or add your own OpenRouter key (top-right).");
410+
err.code = "demo_down";
411+
throw err;
412+
}
381413
if (!res.ok || !res.body) {
382414
throw new Error(`${res.status} ${res.statusText}: ${await res.text()}`);
383415
}

web/app/chat.jsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,14 @@ function ChatView({ settings, set, layout, resetSignal, apiKey, model, papers, f
348348
let searchQuery = q;
349349
if (priorTurns.length) {
350350
if (hasKeyForCondense) {
351-
searchQuery = await window.RAG.condense(apiKey, model, priorTurns, q);
351+
// A transient 429/5xx on this 80-token call must not kill the turn:
352+
// retrieval needs no key — fall back to the raw message, like the
353+
// keyless condenseDemo does.
354+
try {
355+
searchQuery = await window.RAG.condense(apiKey, model, priorTurns, q);
356+
} catch {
357+
searchQuery = q;
358+
}
352359
} else if (demoAvailable) {
353360
setStatus("Condensing the follow-up into a search query…");
354361
searchQuery = await window.RAG.condenseDemo(priorTurns, q);
@@ -437,6 +444,14 @@ function ChatView({ settings, set, layout, resetSignal, apiKey, model, papers, f
437444
streaming: false,
438445
notice: true,
439446
});
447+
} else if (err && (err.code === "demo_down" || err.code === "stream_error")) {
448+
// Upstream model failure, not the visitor's fault — say so without
449+
// blaming a key they may not even have.
450+
updateLast({
451+
answer: `${(err && err.message) || "The model provider failed."} Retrieval still worked — the chunks are on the right.`,
452+
streaming: false,
453+
notice: true,
454+
});
440455
} else {
441456
updateLast({
442457
answer: `Request failed: ${(err && err.message) || err}. Either the server isn't reachable, or your OpenRouter key is invalid.`,

web/app/inspection.jsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ function InspectionView({ settings, papers, routingAvailable }) {
7575
topK: settings.topk,
7676
forceRoute: settings.route === "text" ? "text" : settings.route === "visual" ? "hybrid" : "",
7777
routingMode: settings.routingMode || "",
78+
paperId: settings.paper || "",
7879
onStatus: setStatus,
7980
});
8081
setStatus("");
@@ -96,8 +97,10 @@ function InspectionView({ settings, papers, routingAvailable }) {
9697
const stageInfo = {
9798
query: { icon: "text", title: "Query", body: result ? `Normalized query: “${result.query}”` : "The turn, resolved against conversation history and classified by intent." },
9899
embed: { icon: "layers", title: "Embed", body: "Encoded with bge-m3 into a 1024-d dense vector (L2-normalized) for nearest-neighbour search. The raw vector isn't surfaced by the API." },
99-
route: { icon: "route", title: "Route gate", body: result ? `Routing mode: ${result.routing?.mode || settings.routingMode || "default"} · path: ${result.routing?.path || "text"}${result.routing?.forced ? " · forced" : ""}${result.routing?.category ? " · category: " + result.routing.category : ""}` : "A gate predicts which store(s) hold the answer." },
100-
retrieve: { icon: "search", title: "Retrieve", body: result ? `${cands.length} candidates returned (${textCands.length} text, ${visCands.length} visual) from the dense index.` : "Pulls top-k candidates from each enabled store." },
100+
route: { icon: "route", title: "Route gate", body: routingAvailable === false
101+
? "No router on this deployment — every query retrieves text-side. The gate runs where the GPU visual leg is built."
102+
: result ? `Routing mode: ${result.routing?.mode || settings.routingMode || "default"} · path: ${result.routing?.path || "text"}${result.routing?.forced ? " · forced" : ""}${result.routing?.category ? " · category: " + result.routing.category : ""}` : "A gate predicts which store(s) hold the answer." },
103+
retrieve: { icon: "search", title: "Retrieve", body: result ? `Top ${cands.length} after reranking the hybrid BM25 + dense (RRF-fused) candidate pool (${textCands.length} text, ${visCands.length} visual).` : "Pulls top-k candidates from each enabled store." },
101104
rerank: { icon: "filter", title: "Rerank", body: "A MiniLM cross-encoder re-scores the candidate pool; the score shown on each row is the post-rerank relevance." },
102105
assemble: { icon: "check", title: "Assemble", body: result ? `Top ${cands.length} chunks packed into the generation context (budget ${settings.topk}).` : "Greedily packs the highest-scoring chunks under a token budget." },
103106
};
@@ -114,7 +117,7 @@ function InspectionView({ settings, papers, routingAvailable }) {
114117
<button className="btn primary" onClick={() => trace()} disabled={!draft.trim() || busy}>
115118
<Icon name="route" size={15} /> {busy ? "Tracing…" : "Trace"}
116119
</button>
117-
{result && <RoutePill route={routeLabel} />}
120+
{result && routingAvailable !== false && <RoutePill route={routeLabel} />}
118121
</div>
119122
<div className="insp-examples">
120123
<span className="mono insp-ex-label">EXAMPLES</span>
@@ -134,7 +137,7 @@ function InspectionView({ settings, papers, routingAvailable }) {
134137
<div className="pipeline">
135138
<Stage icon="text" label="Query" value="1 turn" selected={activeStage === "query"} onClick={() => setActiveStage((a) => a === "query" ? null : "query")} />
136139
<Stage icon="layers" label="Embed" value="1024-d" sub="bge-m3" selected={activeStage === "embed"} onClick={() => setActiveStage((a) => a === "embed" ? null : "embed")} />
137-
<Stage icon="route" label="Route" value={result.routing?.path || "text"} sub={result.routing?.mode || "default"} selected={activeStage === "route"} onClick={() => setActiveStage((a) => a === "route" ? null : "route")} />
140+
<Stage icon="route" label="Route" value={routingAvailable === false ? "off" : (result.routing?.path || "text")} sub={routingAvailable === false ? "no router" : (result.routing?.mode || "default")} selected={activeStage === "route"} onClick={() => setActiveStage((a) => a === "route" ? null : "route")} />
138141
<Stage icon="search" label="Retrieve" value={"k=" + settings.topk} sub={cands.length + " cands"} selected={activeStage === "retrieve"} onClick={() => setActiveStage((a) => a === "retrieve" ? null : "retrieve")} />
139142
<Stage icon="filter" label="Rerank" value="x-enc" sub="MiniLM" selected={activeStage === "rerank"} onClick={() => setActiveStage((a) => a === "rerank" ? null : "rerank")} />
140143
<Stage icon="check" label="Assemble" value={"top " + cands.length} sub={"budget " + settings.topk} last selected={activeStage === "assemble"} onClick={() => setActiveStage((a) => a === "assemble" ? null : "assemble")} />

web/app/shared.jsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,11 @@ function RoutePill({ route }) {
5858

5959
function ScoreBar({ score, kind = "text", dropped = false }) {
6060
const cls = ["scorebar", kind === "visual" ? "visual" : "", dropped ? "dropped" : ""].join(" ");
61-
return <div className={cls}><i style={{ width: Math.round(score * 100) + "%" }}></i></div>;
61+
// Clamp both ends: a negative width is invalid CSS, gets dropped, and the
62+
// display:block fill then defaults to width:auto — a FULL bar on the worst
63+
// scores. Callers normalize logits; this is the backstop.
64+
const w = Math.round(Math.max(0, Math.min(1, score || 0)) * 100);
65+
return <div className={cls}><i style={{ width: w + "%" }}></i></div>;
6266
}
6367

6468
/* ---- tiny markdown -> react (bold, lists, citations) ---- */
@@ -215,8 +219,10 @@ function PageRegionModal({ item, onClose, paperTitle }) {
215219

216220
{typeof item.score === "number" && (
217221
<div className="pm-score">
218-
<div className="pm-score-row"><span className="isk">{isVis ? "patch sim" : "passage sim"}</span><span className="isv mono">{item.score.toFixed(3)}</span></div>
219-
<ScoreBar score={item.score} kind={item.kind} />
222+
<div className="pm-score-row"><span className="isk">{isVis ? "patch sim" : "relevance"}</span><span className="isv mono">{item.score.toFixed(3)}</span></div>
223+
{/* Rerank scores are logits — a 0..1 bar only makes sense for
224+
similarity-scaled values; otherwise the number stands alone. */}
225+
{item.score >= 0 && item.score <= 1 && <ScoreBar score={item.score} kind={item.kind} />}
220226
</div>
221227
)}
222228

web/app/styles2.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@
250250
.insp-move { font-size: 11px; color: var(--good); }
251251
.insp-row.dropped .insp-move { color: var(--text-faint); }
252252
.insp-row-head .cand-status { margin-left: auto; }
253-
.insp-scores { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
253+
.insp-scores { display: grid; grid-template-columns: 1fr; gap: 14px; }
254254
.insp-score { display: grid; grid-template-columns: 50px 1fr 42px; align-items: center; gap: 8px; }
255255
.isk { font-size: 10px; color: var(--text-faint); font-family: "IBM Plex Mono", monospace; text-transform: uppercase; letter-spacing: .04em; }
256256
.isv { font-size: 11px; color: var(--text); text-align: right; }

0 commit comments

Comments
 (0)