fix: prompt explorer citations always empty (annotations have no type field) (#82)

This commit is contained in:
bookingseo 2026-07-19 13:02:31 +07:00 committed by GitHub
parent 57524b5b97
commit 4aaeade3b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 147 additions and 38 deletions

View File

@ -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,12 +86,52 @@ function ModelResultCard({
</div>
{modelResult.citations.length > 0 ? (
<CitationsList
citations={modelResult.citations}
highlightBrand={highlightBrand}
/>
) : null}
{modelResult.fanOutQueries.length > 0 ? (
<div className="border-t border-base-200 px-5 py-3">
<p className="mb-2 text-xs font-medium uppercase tracking-wider text-base-content/50">
Related queries the model considered
</p>
<div className="flex flex-wrap gap-1.5">
{modelResult.fanOutQueries.map((query, index) => (
<span
key={`${query}-${index}`}
className="rounded-full border border-base-300 px-2.5 py-0.5 text-xs text-base-content/70"
>
{query}
</span>
))}
</div>
</div>
) : null}
</article>
);
}
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 ({modelResult.citations.length})
Cited sources ({citations.length})
</p>
<ul className="space-y-1.5">
{modelResult.citations.map((citation, index) => (
{visible.map((citation, index) => (
<li
key={`${citation.url}-${index}`}
className="flex items-start gap-2 text-sm"
@ -116,27 +158,16 @@ function ModelResultCard({
</li>
))}
</ul>
</div>
) : null}
{modelResult.fanOutQueries.length > 0 ? (
<div className="border-t border-base-200 px-5 py-3">
<p className="mb-2 text-xs font-medium uppercase tracking-wider text-base-content/50">
Related queries the model considered
</p>
<div className="flex flex-wrap gap-1.5">
{modelResult.fanOutQueries.map((query, index) => (
<span
key={`${query}-${index}`}
className="rounded-full border border-base-300 px-2.5 py-0.5 text-xs text-base-content/70"
{citations.length > 3 ? (
<button
type="button"
onClick={() => setExpanded((current) => !current)}
className="mt-1.5 text-xs text-base-content/50 hover:text-base-content"
>
{query}
</span>
))}
</div>
</div>
{expanded ? "Show less" : `+${remaining} more`}
</button>
) : null}
</article>
</div>
);
}

View File

@ -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([]);
});
});

View File

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