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:
parent
9889308bf4
commit
c0c6652dd5
2 changed files with 72 additions and 5 deletions
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue