guard /admin/* by role + filter chat artifacts to cited chunks

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.
This commit is contained in:
guto 2026-05-18 17:41:35 -03:00
parent 9889308bf4
commit c0c6652dd5
2 changed files with 72 additions and 5 deletions

View file

@ -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<string>();
const citedDocs = new Set<string>();
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<string>();
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,
};
}

View file

@ -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;
}