From 2de153154ba4f18611d0592dd0bbdcd85c966c87 Mon Sep 17 00:00:00 2001 From: bookingseo <68512992+bookingseo@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:23:54 +0700 Subject: [PATCH] fix(ai-search): stop dropping every LLM citation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extractCitations filtered annotations on `type === "citation"`, but DataForSEO's LLM Responses payload returns untyped `{ title, url }` reference objects — the SDK's AnnotationInfo has no type discriminator, so the filter matched nothing and the prompt explorer's citations were always empty even with web search enabled. Guard on URL safety only (the real reason the loop existed) and add a unit test covering the untyped-annotation, dedupe, unsafe-scheme, and non-message-item cases. --- .../ai-search/services/promptExplorer.test.ts | 78 +++++++++++++++++++ .../ai-search/services/promptExplorer.ts | 10 +-- 2 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 src/server/features/ai-search/services/promptExplorer.test.ts 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);