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[];
|
artifacts: import("./agui").Artifact[];
|
||||||
}> {
|
}> {
|
||||||
const maxTurns = req.maxTurns ?? 5;
|
const maxTurns = req.maxTurns ?? 5;
|
||||||
// Capture every artifact emitted in this turn so the caller can persist
|
// Collect artifacts silently — do NOT emit them to the SSE stream while
|
||||||
// them alongside the assistant message. The emit() bridge is unchanged.
|
// 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 collectedArtifacts: import("./agui").Artifact[] = [];
|
||||||
const originalEmit = cb.emit;
|
const originalEmit = cb.emit;
|
||||||
cb = {
|
cb = {
|
||||||
emit: (ev) => {
|
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);
|
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 {
|
return {
|
||||||
content: assembledText,
|
content: assembledText,
|
||||||
model: modelUsed,
|
model: modelUsed,
|
||||||
tokensIn: totalIn,
|
tokensIn: totalIn,
|
||||||
tokensOut: totalOut,
|
tokensOut: totalOut,
|
||||||
toolCalls: toolTrace,
|
toolCalls: toolTrace,
|
||||||
artifacts: collectedArtifacts,
|
artifacts: relevantArtifacts,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,24 @@ export async function middleware(request: NextRequest) {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Trigger refresh (silently if token still valid)
|
// 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;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue