fix: prompt explorer citations always empty (annotations have no type field) (#82)
This commit is contained in:
parent
57524b5b97
commit
4aaeade3b0
@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
@ -12,6 +13,7 @@ import {
|
||||
} from "@/client/features/ai-search/platformLabels";
|
||||
import { formatUrlForDisplay } from "@/client/components/table/url";
|
||||
import type {
|
||||
PromptExplorerCitation,
|
||||
PromptExplorerModelResult,
|
||||
PromptExplorerResult,
|
||||
} from "@/types/schemas/ai-search";
|
||||
@ -84,39 +86,10 @@ function ModelResultCard({
|
||||
</div>
|
||||
|
||||
{modelResult.citations.length > 0 ? (
|
||||
<div className="border-t border-base-200 bg-base-200/30 px-5 py-3">
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wider text-base-content/50">
|
||||
Cited sources ({modelResult.citations.length})
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{modelResult.citations.map((citation, index) => (
|
||||
<li
|
||||
key={`${citation.url}-${index}`}
|
||||
className="flex items-start gap-2 text-sm"
|
||||
>
|
||||
<span className="mt-1 size-1 shrink-0 rounded-full bg-base-content/30" />
|
||||
<a
|
||||
href={citation.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={`link inline-flex items-start gap-1 ${
|
||||
citation.matchedBrand ? "link-primary font-medium" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="break-all">
|
||||
{citation.title || formatUrlForDisplay(citation.url)}
|
||||
</span>
|
||||
<ExternalLink className="mt-1 size-3 shrink-0" />
|
||||
</a>
|
||||
{citation.matchedBrand && highlightBrand ? (
|
||||
<span className="badge badge-primary badge-xs">
|
||||
{highlightBrand}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<CitationsList
|
||||
citations={modelResult.citations}
|
||||
highlightBrand={highlightBrand}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{modelResult.fanOutQueries.length > 0 ? (
|
||||
@ -140,6 +113,64 @@ function ModelResultCard({
|
||||
);
|
||||
}
|
||||
|
||||
function CitationsList({
|
||||
citations,
|
||||
highlightBrand,
|
||||
}: {
|
||||
citations: PromptExplorerCitation[];
|
||||
highlightBrand: string | null;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const visible = expanded ? citations : citations.slice(0, 3);
|
||||
const remaining = citations.length - visible.length;
|
||||
|
||||
return (
|
||||
<div className="border-t border-base-200 bg-base-200/30 px-5 py-3">
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wider text-base-content/50">
|
||||
Cited sources ({citations.length})
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{visible.map((citation, index) => (
|
||||
<li
|
||||
key={`${citation.url}-${index}`}
|
||||
className="flex items-start gap-2 text-sm"
|
||||
>
|
||||
<span className="mt-1 size-1 shrink-0 rounded-full bg-base-content/30" />
|
||||
<a
|
||||
href={citation.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={`link inline-flex items-start gap-1 ${
|
||||
citation.matchedBrand ? "link-primary font-medium" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="break-all">
|
||||
{citation.title || formatUrlForDisplay(citation.url)}
|
||||
</span>
|
||||
<ExternalLink className="mt-1 size-3 shrink-0" />
|
||||
</a>
|
||||
{citation.matchedBrand && highlightBrand ? (
|
||||
<span className="badge badge-primary badge-xs">
|
||||
{highlightBrand}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{citations.length > 3 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
className="mt-1.5 text-xs text-base-content/50 hover:text-base-content"
|
||||
>
|
||||
{expanded ? "Show less" : `+${remaining} more`}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelHeader({
|
||||
model,
|
||||
modelName,
|
||||
|
||||
@ -0,0 +1,78 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { LlmResponseResult } from "@/server/lib/dataforseoLlmSchemas";
|
||||
|
||||
vi.mock("cloudflare:workers", () => ({ waitUntil: vi.fn() }));
|
||||
|
||||
const { extractCitations } = await import("./promptExplorer");
|
||||
|
||||
// DataForSEO's LLM Responses payload nests references as untyped
|
||||
// `{ title, url }` objects under items[].sections[].annotations — mirroring the
|
||||
// SDK's AnnotationInfo, which has no citation-type discriminator.
|
||||
function response(
|
||||
annotations: Array<{ title?: string; url?: string }>,
|
||||
): LlmResponseResult {
|
||||
return {
|
||||
model_name: "gpt-5",
|
||||
web_search: true,
|
||||
items: [
|
||||
{
|
||||
type: "reasoning",
|
||||
sections: [{ type: "summary_text", text: "thinking" }],
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
sections: [{ type: "text", text: "answer", annotations }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("extractCitations", () => {
|
||||
it("keeps untyped annotations (no citation-type discriminator exists)", () => {
|
||||
const citations = extractCitations(
|
||||
response([
|
||||
{ title: "Town & Country", url: "https://www.townandcountrymag.com/x" },
|
||||
{ title: "Stylevana", url: "https://www.stylevana.com/y" },
|
||||
]),
|
||||
);
|
||||
expect(citations.map((c) => c.url)).toEqual([
|
||||
"https://www.townandcountrymag.com/x",
|
||||
"https://www.stylevana.com/y",
|
||||
]);
|
||||
expect(citations[0]?.domain).toBe("townandcountrymag.com");
|
||||
expect(citations[0]?.title).toBe("Town & Country");
|
||||
});
|
||||
|
||||
it("dedupes repeated URLs and drops unsafe schemes", () => {
|
||||
const citations = extractCitations(
|
||||
response([
|
||||
{ title: "A", url: "https://example.com/a" },
|
||||
{ title: "A dup", url: "https://example.com/a" },
|
||||
{ title: "evil", url: "javascript:alert(1)" },
|
||||
{ title: "no url" },
|
||||
]),
|
||||
);
|
||||
expect(citations).toHaveLength(1);
|
||||
expect(citations[0]?.url).toBe("https://example.com/a");
|
||||
});
|
||||
|
||||
it("ignores annotations outside message items and returns [] when absent", () => {
|
||||
expect(extractCitations({ items: [] })).toEqual([]);
|
||||
expect(
|
||||
extractCitations({
|
||||
items: [
|
||||
{
|
||||
type: "reasoning",
|
||||
sections: [
|
||||
{
|
||||
type: "summary_text",
|
||||
text: "t",
|
||||
annotations: [{ title: "x", url: "https://x.test/1" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@ -207,7 +207,7 @@ function extractText(response: LlmResponseResult): string {
|
||||
return textParts.join("\n\n").trim();
|
||||
}
|
||||
|
||||
function extractCitations(
|
||||
export function extractCitations(
|
||||
response: LlmResponseResult,
|
||||
): PromptExplorerCitation[] {
|
||||
const seen = new Set<string>();
|
||||
@ -217,10 +217,10 @@ function extractCitations(
|
||||
if (item.type !== "message") continue;
|
||||
for (const section of item.sections ?? []) {
|
||||
for (const annotation of section.annotations ?? []) {
|
||||
if (annotation.type !== "citation") continue;
|
||||
// Drop non-http(s) URLs — LLMs can be coaxed into emitting
|
||||
// `javascript:` payloads as "citations" and we render these as
|
||||
// <a href> in the UI.
|
||||
// DataForSEO annotations are untyped `{ title, url }` reference
|
||||
// objects (AnnotationInfo) — there is no citation-type discriminator
|
||||
// to filter on. Guard on URL safety only: LLMs can be coaxed into
|
||||
// emitting `javascript:` payloads, and we render these as <a href>.
|
||||
const safeUrl = safeHttpUrl(annotation.url);
|
||||
if (!safeUrl || seen.has(safeUrl)) continue;
|
||||
seen.add(safeUrl);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user