diff --git a/src/server/features/ai-search/services/promptExplorer.test.ts b/src/server/features/ai-search/services/promptExplorer.test.ts new file mode 100644 index 00000000..7d891226 --- /dev/null +++ b/src/server/features/ai-search/services/promptExplorer.test.ts @@ -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([]); + }); +}); diff --git a/src/server/features/ai-search/services/promptExplorer.ts b/src/server/features/ai-search/services/promptExplorer.ts index 6312f9e7..21516875 100644 --- a/src/server/features/ai-search/services/promptExplorer.ts +++ b/src/server/features/ai-search/services/promptExplorer.ts @@ -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(); @@ -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 - // 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 . const safeUrl = safeHttpUrl(annotation.url); if (!safeUrl || seen.has(safeUrl)) continue; seen.add(safeUrl);