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 {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@ -12,6 +13,7 @@ import {
|
|||||||
} from "@/client/features/ai-search/platformLabels";
|
} from "@/client/features/ai-search/platformLabels";
|
||||||
import { formatUrlForDisplay } from "@/client/components/table/url";
|
import { formatUrlForDisplay } from "@/client/components/table/url";
|
||||||
import type {
|
import type {
|
||||||
|
PromptExplorerCitation,
|
||||||
PromptExplorerModelResult,
|
PromptExplorerModelResult,
|
||||||
PromptExplorerResult,
|
PromptExplorerResult,
|
||||||
} from "@/types/schemas/ai-search";
|
} from "@/types/schemas/ai-search";
|
||||||
@ -84,39 +86,10 @@ function ModelResultCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{modelResult.citations.length > 0 ? (
|
{modelResult.citations.length > 0 ? (
|
||||||
<div className="border-t border-base-200 bg-base-200/30 px-5 py-3">
|
<CitationsList
|
||||||
<p className="mb-2 text-xs font-medium uppercase tracking-wider text-base-content/50">
|
citations={modelResult.citations}
|
||||||
Cited sources ({modelResult.citations.length})
|
highlightBrand={highlightBrand}
|
||||||
</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>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{modelResult.fanOutQueries.length > 0 ? (
|
{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({
|
function ModelHeader({
|
||||||
model,
|
model,
|
||||||
modelName,
|
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();
|
return textParts.join("\n\n").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractCitations(
|
export function extractCitations(
|
||||||
response: LlmResponseResult,
|
response: LlmResponseResult,
|
||||||
): PromptExplorerCitation[] {
|
): PromptExplorerCitation[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@ -217,10 +217,10 @@ function extractCitations(
|
|||||||
if (item.type !== "message") continue;
|
if (item.type !== "message") continue;
|
||||||
for (const section of item.sections ?? []) {
|
for (const section of item.sections ?? []) {
|
||||||
for (const annotation of section.annotations ?? []) {
|
for (const annotation of section.annotations ?? []) {
|
||||||
if (annotation.type !== "citation") continue;
|
// DataForSEO annotations are untyped `{ title, url }` reference
|
||||||
// Drop non-http(s) URLs — LLMs can be coaxed into emitting
|
// objects (AnnotationInfo) — there is no citation-type discriminator
|
||||||
// `javascript:` payloads as "citations" and we render these as
|
// to filter on. Guard on URL safety only: LLMs can be coaxed into
|
||||||
// <a href> in the UI.
|
// emitting `javascript:` payloads, and we render these as <a href>.
|
||||||
const safeUrl = safeHttpUrl(annotation.url);
|
const safeUrl = safeHttpUrl(annotation.url);
|
||||||
if (!safeUrl || seen.has(safeUrl)) continue;
|
if (!safeUrl || seen.has(safeUrl)) continue;
|
||||||
seen.add(safeUrl);
|
seen.add(safeUrl);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user