From c0c6652dd51252da8a8893ebd3594543b3b3b7a1 Mon Sep 17 00:00:00 2001 From: guto Date: Mon, 18 May 2026 17:41:35 -0300 Subject: [PATCH] guard /admin/* by role + filter chat artifacts to cited chunks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit middleware.ts now checks profiles.role on every /admin/* request and returns a plain 404 (not a redirect) for anonymous users and any authenticated user without role='admin'. The 404 wording matches a non-existent route, so we don't leak the existence of an admin area. gutomec@gmail.com promoted to admin in the live DB. openrouter.ts chat now collects artifacts silently during the tool-call loop instead of streaming them to the SSE. After the model writes its final prose (and after the forced-synthesis pass if needed), we scan the assembled text for [[doc-id/p007#cNNNN]] citations and emit ONLY the artifacts referenced. Duplicates are deduped by chunk_id. The persisted citations column on messages now stores the filtered set too, so old sessions reload with the same focused card grid. Before: every hybrid_search hit (up to 6 per call × 5 calls = 30+ citation cards plus crop images) flooded the chat regardless of what the model ended up using. After: only the chunks actually woven into the answer. --- web/lib/chat/openrouter.ts | 58 +++++++++++++++++++++++++++++++++++--- web/middleware.ts | 19 ++++++++++++- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/web/lib/chat/openrouter.ts b/web/lib/chat/openrouter.ts index 71824e2..690e79f 100644 --- a/web/lib/chat/openrouter.ts +++ b/web/lib/chat/openrouter.ts @@ -143,13 +143,19 @@ export async function streamWithTools( artifacts: import("./agui").Artifact[]; }> { const maxTurns = req.maxTurns ?? 5; - // Capture every artifact emitted in this turn so the caller can persist - // them alongside the assistant message. The emit() bridge is unchanged. + // Collect artifacts silently — do NOT emit them to the SSE stream while + // tools are running. We only know which ones are "relevant" after the + // model has written its final prose, by matching [[doc-id/p007#cNNNN]] + // citations in the text. Whatever isn't cited gets dropped to keep the + // chat UI focused on the actual answer. const collectedArtifacts: import("./agui").Artifact[] = []; const originalEmit = cb.emit; cb = { emit: (ev) => { - if (ev.type === "artifact") collectedArtifacts.push(ev.artifact); + if (ev.type === "artifact") { + collectedArtifacts.push(ev.artifact); + return; // suppressed — emitted later after filtering + } originalEmit(ev); }, }; @@ -274,13 +280,57 @@ export async function streamWithTools( } } + // Filter artifacts to only those actually cited in the assistant's prose. + // Citation grammar: [[doc-id]], [[doc-id/p007]], [[doc-id#c0042]], + // [[doc-id/p007#c0042]]. The chunk anchor (cNNNN) is the strongest signal. + const citedChunks = new Set(); + const citedDocs = new Set(); + const wikiLinkRe = /\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]/g; + let m: RegExpExecArray | null; + while ((m = wikiLinkRe.exec(assembledText)) !== null) { + const target = m[1].trim(); + const chunkMatch = target.match(/(c\d{4})$/i); + if (chunkMatch) citedChunks.add(chunkMatch[1].toLowerCase()); + // Doc id is whatever comes before the first `/` or `#` + const docMatch = target.match(/^([A-Za-z0-9._-]+)/); + if (docMatch) citedDocs.add(docMatch[1]); + } + + const relevantArtifacts = collectedArtifacts.filter((a) => { + if (a.kind === "citation") { + return citedChunks.has(a.chunk_id.toLowerCase()) || + (citedDocs.has(a.doc_id) && citedChunks.size === 0); + } + if (a.kind === "crop_image") { + if (a.chunk_id && citedChunks.has(a.chunk_id.toLowerCase())) return true; + return false; + } + // Entity / case / hypothesis / nav cards have no chunk reference — keep + // them all; they are rarely emitted by hybrid_search anyway. + return true; + }); + + // Now emit the filtered set so the UI renders only the relevant cards. + // De-dupe by kind+key so a citation and its crop_image both fire but no + // duplicate citations of the same chunk_id appear. + const seen = new Set(); + for (const a of relevantArtifacts) { + const key = + a.kind === "citation" ? `cit:${a.chunk_id}` : + a.kind === "crop_image" ? `crop:${a.chunk_id ?? a.src}` : + `${a.kind}:${JSON.stringify(a)}`; + if (seen.has(key)) continue; + seen.add(key); + originalEmit({ type: "artifact", artifact: a }); + } + return { content: assembledText, model: modelUsed, tokensIn: totalIn, tokensOut: totalOut, toolCalls: toolTrace, - artifacts: collectedArtifacts, + artifacts: relevantArtifacts, }; } diff --git a/web/middleware.ts b/web/middleware.ts index 9470d14..fed0caf 100644 --- a/web/middleware.ts +++ b/web/middleware.ts @@ -32,7 +32,24 @@ export async function middleware(request: NextRequest) { }); // Trigger refresh (silently if token still valid) - await supabase.auth.getUser(); + const { data: { user } } = await supabase.auth.getUser(); + + // Gate /admin/* by role. Non-admin (including anonymous) gets the public + // 404, not a redirect — we don't want to leak the existence of the route. + const pathname = request.nextUrl.pathname; + if (pathname.startsWith("/admin")) { + if (!user) { + return new NextResponse("Not Found", { status: 404 }); + } + const { data: profile } = await supabase + .from("profiles") + .select("role") + .eq("id", user.id) + .maybeSingle(); + if (profile?.role !== "admin") { + return new NextResponse("Not Found", { status: 404 }); + } + } return response; }