diff --git a/e2e/search-quality.spec.ts b/e2e/search-quality.spec.ts new file mode 100644 index 000000000..ba833cf53 --- /dev/null +++ b/e2e/search-quality.spec.ts @@ -0,0 +1,90 @@ +import { expect, test } from '@playwright/test'; +import { setLocale, stubSupabaseSession } from './helpers/auth'; + +const strongSourceUrl = 'https://energy.gov/reports/solar-costs-2024'; +const weakSourceUrl = 'https://example.com/broad-commentary'; + +test('authenticated Factoids shows the consulted-source quality panel', async ({ page }) => { + await setLocale(page, 'en'); + await stubSupabaseSession(page); + + await page.route('**/functions/v1/ai', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + output: { + factoids: [{ + id: 'fact-1', + shortClaim: 'Solar costs fell by 42 percent', + keyFigure: '42%', + context: 'The official report compares the relevant period.', + limitations: 'The result depends on the measured market segment.', + citation: { + title: 'Official energy statistics', + publisher: 'Department of Energy', + date: '2024', + url: strongSourceUrl, + verified: 'verified', + }, + howToUse: 'Use this as a bounded cost trend.', + relevanceScore: 92, + }], + searchSuggestions: [], + warnings: [], + verificationStatus: 'complete', + }, + discoveredSources: [{ + title: 'Official energy statistics', + url: strongSourceUrl, + content: 'Official government statistics.', + sourceType: 'government', + qualityScore: 91, + qualityLabel: 'strong', + query: 'solar energy costs government evidence', + }, { + title: 'Broad commentary', + url: weakSourceUrl, + content: 'General commentary.', + sourceType: 'generic', + qualityScore: 30, + qualityLabel: 'weak', + query: 'solar energy costs commentary', + }], + }), + }); + }); + + await page.route('**/rest/v1/source_folders*', async (route) => { + await route.fulfill({ status: 201, contentType: 'application/json', body: '{}' }); + }); + await page.route('**/rest/v1/sources*', async (route) => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + id: 'source-1', + title: 'Consulted source', + content: 'Evidence', + type: 'url', + origin: strongSourceUrl, + folder: 'Auto-saved', + metadata: {}, + is_active: true, + created_at: new Date().toISOString(), + }), + }); + }); + + await page.goto('/app/factoids'); + await page.getByRole('textbox', { name: /statement/i }).fill('Solar energy costs are falling'); + await page.getByRole('button', { name: /find factoids/i }).click(); + + await expect(page.getByRole('heading', { name: /researched sources/i })).toBeVisible(); + await expect(page.getByRole('link', { name: /official energy statistics/i }).first()).toHaveAttribute('href', strongSourceUrl); + await expect(page.getByRole('link', { name: /broad commentary/i })).toHaveAttribute('href', weakSourceUrl); + await expect(page.getByText('strong', { exact: true })).toBeVisible(); + await expect(page.getByText('weak', { exact: true })).toBeVisible(); + await expect(page.getByText('fallback context only', { exact: true })).toBeVisible(); + await expect(page.getByText('Solar costs fell by 42 percent')).toBeVisible(); +}); diff --git a/plans/search-quality.md b/plans/search-quality.md index e11af3e59..90cf0f395 100644 --- a/plans/search-quality.md +++ b/plans/search-quality.md @@ -43,7 +43,7 @@ Implemented: 1. Research pipeline extracted to `supabase/functions/_shared/research.ts` (Exa search, follow-ups, ranking context, citation sources) with an injected model caller so it is testable; ranking/query builders moved to `_shared/source-quality.ts`. 2. Topic-specific query builders (tournament, policy/statistics, legal/current events) were already present and are now covered by tests in the shared module. 3. All discovered-source returns carry quality metadata; Factoids now also returns `discoveredSources`. -4. Source display: shared `SourceQualityList` renders strong/moderate/weak labels plus "fallback context only" for weak sources in Generate, Attack, and Brainstorm results. Factoids shows only verified citations (quality-labeled consulted-list display deferred). +4. Source display: shared `SourceQualityList` renders strong/moderate/weak labels plus "fallback context only" for weak sources in Generate, Attack, Brainstorm, and Factoids results. Factoids still limits evidence cards to verified citations while listing every consulted source separately. 5. Provenance persists to saved sources: `sources.metadata` jsonb (migration `20260822000001`, prepared but NOT applied) stores url, sourceType, qualityScore, qualityLabel, originating query, capturedVia. 6. Source-quality floor completed: factoid citations verify only against fetched non-weak research content (`applyFactoidsVerification`); weak or unknown URLs are downgraded and filtered. Ask fallback already excluded weak sources. @@ -51,6 +51,8 @@ Tests: `_shared/research.test.ts`, extended `source-quality.test.ts`, frontend ` Deferred: -- Replace inline `site:gov` query text with Exa's dedicated domain-filter request field (needs a live Exa parameter-verification pass). -- Handler-level integration test that mocks Exa end-to-end through `Deno.serve`; current coverage is at the extracted-function level plus frontend mocks. -- Factoids UI panel listing all consulted sources with quality chips. +- None. The remaining search-quality scope was completed on `t3code/search-quality-completion`: + - Exa search requests use the documented camelCase fields (`includeDomains`, `excludeDomains`, `category`, `startPublishedDate`, `endPublishedDate`, `numResults`, and `contents.text.maxCharacters`) instead of inline `site:` operators. Domain/category filters retry without the restrictive filter when they return no results. + - The exported Edge handler has a Deno boundary test that mocks authentication, quota, Exa search/content, and the model response, then verifies citation quality and consulted-source provenance in the HTTP response. + - Factoids returns `discoveredSources`, autosaves their provenance, and renders the shared consulted-source quality list, including the weak-source fallback warning. + - Authenticated Playwright coverage exercises the Factoids flow on isolated desktop and mobile Chromium projects. diff --git a/src/components/features/factoids/FactoidsResults.tsx b/src/components/features/factoids/FactoidsResults.tsx index ff622a5a9..8e2bce65e 100644 --- a/src/components/features/factoids/FactoidsResults.tsx +++ b/src/components/features/factoids/FactoidsResults.tsx @@ -16,10 +16,13 @@ import { toast } from 'sonner'; import { ExportToolbar } from '@/components/features/ExportToolbar'; import { GoogleDriveAuthModal } from '@/components/features/export/GoogleDriveAuthModal'; import { openExternalUrl } from '@/lib/native'; +import { SourceQualityList } from '@/components/features/shared/SourceQualityList'; +import type { DiscoveredSource } from '@/lib/ai/client'; interface FactoidsResultsProps { result: FactoidsOutput; + discoveredSources?: DiscoveredSource[]; onDiscuss?: () => void; onSaveSource?: (title: string, content: string, url?: string) => void; onUseSuggestion?: (query: string) => void; @@ -137,7 +140,7 @@ function FactoidCard({ factoid, onSave }: { factoid: Factoid, onSave?: FactoidsR ); } -export function FactoidsResults({ result, onDiscuss, onSaveSource, onUseSuggestion }: FactoidsResultsProps) { +export function FactoidsResults({ result, discoveredSources, onDiscuss, onSaveSource, onUseSuggestion }: FactoidsResultsProps) { const { t } = useTranslation(); const [isExportingGDocs, setIsExportingGDocs] = useState(false); const [showDriveAuth, setShowDriveAuth] = useState(false); @@ -195,6 +198,21 @@ export function FactoidsResults({ result, onDiscuss, onSaveSource, onUseSuggesti discussLabel={t('factoidsResults.discuss')} /> + {discoveredSources && discoveredSources.length > 0 && ( + + + {t('sourceQuality.researchedSources')} + + + + {t('sourceQuality.fallbackNote')} + + + )} + {/* Verification Status */} { if (sources.length === 0) return; @@ -1122,8 +1124,8 @@ export async function brainstorm(input: BrainstormInput, config: ProviderConfig) return response; } -export async function factoids(input: FactoidsInput, config: ProviderConfig): Promise { - if (config.provider === 'mock') return mockFactoids(input.claim); +export async function factoids(input: FactoidsInput, config: ProviderConfig): Promise> { + if (config.provider === 'mock') return { result: await mockFactoids(input.claim), discoveredSources: [] }; // Inject global language into input for the edge function const language = useSettingsStore.getState().outputLanguage; const inputWithLanguage = { @@ -1132,9 +1134,9 @@ export async function factoids(input: FactoidsInput, config: ProviderConfig): Pr sourceCount: input.sourceCount || 5, language, } as Record; - return await callSupabaseAI('factoids', inputWithLanguage, config, { + const response = await callSupabaseAI>('factoids', inputWithLanguage, config, { transformResponse: (rawData) => { - const data = rawData as { output: unknown }; + const data = rawData as { output: unknown; discoveredSources?: unknown }; const parsed = FactoidsOutputSchema.safeParse(data.output); if (!parsed.success) { throw new AIResponseError('AI response did not match the expected factoids schema.', { @@ -1146,12 +1148,17 @@ export async function factoids(input: FactoidsInput, config: ProviderConfig): Pr const verifiedFactoids = allFactoids.filter((factoid) => factoid.citation.verified === 'verified'); const returnedFactoids = verifiedFactoids.slice(0, input.sourceCount || 5); return { - ...parsed.data, - factoids: returnedFactoids, - filteredOutCount: Math.max(0, allFactoids.length - returnedFactoids.length), + result: { + ...parsed.data, + factoids: returnedFactoids, + filteredOutCount: Math.max(0, allFactoids.length - returnedFactoids.length), + }, + discoveredSources: parseDiscoveredSources(data.discoveredSources), }; }, }); + await autosaveDiscoveredSources(response.discoveredSources, 'factoids'); + return response; } export interface AskChatOptions { diff --git a/src/pages/FactoidsPage.tsx b/src/pages/FactoidsPage.tsx index 320bb4cf9..dfe200d1c 100644 --- a/src/pages/FactoidsPage.tsx +++ b/src/pages/FactoidsPage.tsx @@ -1,10 +1,11 @@ +import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useSessionStore } from '@/lib/stores/session-store'; import { useSettingsStore } from '@/lib/stores/settings-store'; import { FactoidsForm } from '@/components/features/factoids/FactoidsForm'; import { FactoidsResults } from '@/components/features/factoids/FactoidsResults'; -import { factoids, askChat } from '@/lib/ai/client'; +import { factoids, askChat, type DiscoveredSource } from '@/lib/ai/client'; import { useSubscriptionError } from '@/hooks/use-subscription-error'; import { getUserFacingAIError } from '@/lib/ai/error-display'; import { LimitReachedModal, ModelRestrictedModal, PricingModal } from '@/components/features/subscription'; @@ -22,11 +23,14 @@ export default function FactoidsPage() { const navigate = useNavigate(); const subError = useSubscriptionError(); const { t } = useTranslation(); + const [discoveredSources, setDiscoveredSources] = useState([]); const handleSubmit = async (data: FactoidsInput) => { + setDiscoveredSources([]); setFactoidsLoading(true); try { - const result = await factoids(data, config); + const { result, discoveredSources: sources } = await factoids(data, config); + setDiscoveredSources(sources); setFactoidsResult(result); toast({ title: t('factoids.complete', 'Research complete'), description: t('factoids.completeDesc', 'Verified evidence is ready.'), variant: 'success' }); } catch (error) { @@ -130,6 +134,7 @@ export default function FactoidsPage() { { title: 'Source A', content: 'A content', type: 'url', - origin: 'https://a.com', + origin: 'https://a.com/', folder: 'Auto-saved', isActive: true, - metadata: expect.objectContaining({ capturedVia: 'generate', url: 'https://a.com' }), + metadata: expect.objectContaining({ capturedVia: 'generate', url: 'https://a.com/' }), }); }); @@ -311,7 +311,9 @@ describe('AI client branch coverage', () => { qualityLabel: 'unknown', query: 42, }, + { title: 'Protocol-less source', url: 'example.com/source-two' }, { title: '', url: 'https://example.com/rejected' }, + { title: 'Unsafe protocol', url: 'httpx://example.com/rejected' }, ], }, error: null, @@ -321,6 +323,7 @@ describe('AI client branch coverage', () => { expect(outcome.discoveredSources).toEqual([ { title: 'Source with malformed metadata', url: 'https://example.com/source' }, + { title: 'Protocol-less source', url: 'https://example.com/source-two' }, ]); expect(addSourceMock).toHaveBeenCalledWith(expect.objectContaining({ title: 'Source with malformed metadata', @@ -427,10 +430,10 @@ describe('AI client branch coverage', () => { title: 'Source C', content: 'C content', type: 'url', - origin: 'https://c.com', + origin: 'https://c.com/', folder: 'Auto-saved', isActive: true, - metadata: expect.objectContaining({ capturedVia: 'brainstorm', url: 'https://c.com' }), + metadata: expect.objectContaining({ capturedVia: 'brainstorm', url: 'https://c.com/' }), }); }); @@ -501,8 +504,49 @@ describe('AI client branch coverage', () => { const result = await factoids({ claim: 'Claim', direction: 'for', sourceType: 'any', sourceCount: 3 }, config); - expect(result.factoids).toHaveLength(3); - expect(result.filteredOutCount).toBe(1); + expect(result.result.factoids).toHaveLength(3); + expect(result.result.filteredOutCount).toBe(1); + }); + + it('returns and autosaves discovered sources for factoids responses', async () => { + invokeMock.mockResolvedValueOnce({ + data: { + output: { + factoids: [], + searchSuggestions: [], + warnings: [], + verificationStatus: 'complete', + }, + discoveredSources: [{ + title: 'Factoids source', + url: 'https://example.com/factoids', + content: 'Evidence content', + sourceType: 'government', + qualityLabel: 'moderate', + qualityScore: 70, + query: 'factoids query', + }], + }, + error: null, + }); + + const outcome = await factoids({ claim: 'Claim', direction: 'for', sourceType: 'any', sourceCount: 3 }, config); + + expect(outcome.discoveredSources).toEqual([{ + title: 'Factoids source', + url: 'https://example.com/factoids', + content: 'Evidence content', + sourceType: 'government', + qualityLabel: 'moderate', + qualityScore: 70, + query: 'factoids query', + }]); + expect(addSourceMock).toHaveBeenCalledWith(expect.objectContaining({ + title: 'Factoids source', + origin: 'https://example.com/factoids', + isActive: true, + metadata: expect.objectContaining({ capturedVia: 'factoids', qualityLabel: 'moderate' }), + })); }); it('maps invoke errors with 401 status to AIAuthError', async () => { @@ -846,7 +890,7 @@ describe('AI client branch coverage', () => { expect(attack.overallAssessment.score).toBeTypeOf('number'); expect(ideas.nextSteps).toBeInstanceOf(Array); - expect(facts.factoids).toBeInstanceOf(Array); + expect(facts.result.factoids).toBeInstanceOf(Array); expect(chat.response).toContain('hello'); expect(invokeMock).not.toHaveBeenCalled(); }, 15000); diff --git a/src/test/components/FactoidsResults.test.tsx b/src/test/components/FactoidsResults.test.tsx index da05479a5..70fb97a22 100644 --- a/src/test/components/FactoidsResults.test.tsx +++ b/src/test/components/FactoidsResults.test.tsx @@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react'; import { vi } from 'vitest'; import { FactoidsResults } from '@/components/features/factoids/FactoidsResults'; import type { FactoidsOutput } from '@/lib/ai/schemas/factoids'; +import type { DiscoveredSource } from '@/lib/ai/client'; vi.mock('@/components/features/ExportToolbar', () => ({ ExportToolbar: (): null => null, @@ -35,3 +36,18 @@ test('does not expose source verification while no verification behavior exists' expect(screen.queryByRole('button', { name: /verify all sources/i })).not.toBeInTheDocument(); }); + +test('renders consulted sources with a fallback label for weak sources', () => { + const discoveredSources: DiscoveredSource[] = [{ + title: 'Broad fallback article', + url: 'https://example.com/fallback', + qualityLabel: 'weak', + qualityScore: 20, + }]; + + render(); + + expect(screen.getByRole('heading', { name: /researched sources/i })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /broad fallback article/i })).toHaveAttribute('href', discoveredSources[0].url); + expect(screen.getByText('fallback context only')).toBeInTheDocument(); +}); diff --git a/src/test/pages/ConversationPages.test.tsx b/src/test/pages/ConversationPages.test.tsx index 2eb43c047..3611c71c7 100644 --- a/src/test/pages/ConversationPages.test.tsx +++ b/src/test/pages/ConversationPages.test.tsx @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { BrowserRouter } from 'react-router-dom'; +import type { FactoidsOutput } from '@/lib/ai/schemas/factoids'; vi.mock('@/hooks/use-subscription', () => ({ useSubscription: () => ({ isPro: false, tier: 'trial', loading: false }), @@ -140,8 +141,9 @@ vi.mock('@/components/features/factoids/FactoidsForm', () => ({ })); vi.mock('@/components/features/factoids/FactoidsResults', () => ({ - FactoidsResults: ({ onDiscuss, onSaveSource }: { onDiscuss: () => void, onSaveSource: (t: string, c: string, u?: string) => void }) => ( + FactoidsResults: ({ onDiscuss, onSaveSource, discoveredSources }: { onDiscuss: () => void, onSaveSource: (t: string, c: string, u?: string) => void, discoveredSources?: unknown[] }) => ( + {discoveredSources?.length ?? 0} void onDiscuss()} data-testid="factoids-discuss">factoids-discuss onSaveSource('Fact source', 'Fact content', 'https://example.com')} data-testid="factoids-save"> factoids-save @@ -165,6 +167,9 @@ describe('Conversation Pages', () => { sessionState.brainstormLoading = false; sessionState.factoidsResult = null; sessionState.factoidsLoading = false; + sessionState.setFactoidsResult.mockImplementation((result: any) => { + sessionState.factoidsResult = result; + }); subErrorState.handleSubscriptionError.mockReturnValue(false); mockIsTimeoutError.mockReturnValue(false); @@ -580,7 +585,8 @@ describe('Conversation Pages', () => { }); it('FactoidsPage submits and stores factoid results', async () => { - mockFactoids.mockResolvedValueOnce({ factoids: [], searchSuggestions: [], warnings: [], verificationStatus: 'complete' }); + const factoidsResult: FactoidsOutput = { factoids: [], searchSuggestions: [], warnings: [], verificationStatus: 'complete' }; + mockFactoids.mockResolvedValueOnce({ result: factoidsResult, discoveredSources: [{ title: 'Consulted source', url: 'https://example.com' }] }); render( @@ -595,10 +601,30 @@ describe('Conversation Pages', () => { }); expect(sessionState.setFactoidsLoading).toHaveBeenCalledWith(true); - expect(sessionState.setFactoidsResult).toHaveBeenCalledWith({ factoids: [], searchSuggestions: [], warnings: [], verificationStatus: 'complete' }); + expect(sessionState.setFactoidsResult).toHaveBeenCalledWith(factoidsResult); + expect(screen.getByTestId('factoids-source-count')).toHaveTextContent('1'); expect(sessionState.setFactoidsLoading).toHaveBeenCalledWith(false); }); + it('FactoidsPage clears and replaces consulted sources for each request', async () => { + const factoidsResult: FactoidsOutput = { factoids: [], searchSuggestions: [], warnings: [], verificationStatus: 'complete' }; + mockFactoids + .mockResolvedValueOnce({ result: factoidsResult, discoveredSources: [{ title: 'First source', url: 'https://example.com/first' }] }) + .mockResolvedValueOnce({ result: factoidsResult, discoveredSources: [] }); + + render( + + + + ); + + fireEvent.click(screen.getByTestId('factoids-submit')); + await waitFor(() => expect(screen.getByTestId('factoids-source-count')).toHaveTextContent('1')); + + fireEvent.click(screen.getByTestId('factoids-submit')); + await waitFor(() => expect(screen.getByTestId('factoids-source-count')).toHaveTextContent('0')); + }); + it('FactoidsPage discuss flow navigates and appends assistant response', async () => { sessionState.factoidsResult = { factoids: [{ shortClaim: 'Claim A', citation: { publisher: 'Source A' } }], diff --git a/supabase/functions/_shared/research.test.ts b/supabase/functions/_shared/research.test.ts index 7a2c41855..74ab208ac 100644 --- a/supabase/functions/_shared/research.test.ts +++ b/supabase/functions/_shared/research.test.ts @@ -168,6 +168,45 @@ Deno.test("Exa search keeps caller cancellation active during JSON body read", a } }); +Deno.test("Exa search sends supported first-class filter fields", async () => { + const originalFetch = globalThis.fetch; + const previousApiKey = Deno.env.get("EXA_API_KEY"); + let requestBody: Record | undefined; + Deno.env.set("EXA_API_KEY", "test-key"); + globalThis.fetch = ((_input: Request | URL | string, init?: RequestInit) => { + requestBody = JSON.parse(String(init?.body)) as Record; + return Promise.resolve(new Response(JSON.stringify({ results: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })); + }) as typeof fetch; + + try { + await exaSearch({ + query: "official energy data", + includeDomains: ["*.gov", "worldbank.org"], + excludeDomains: ["reddit.com"], + startPublishedDate: "2024-01-01T00:00:00.000Z", + category: "news", + }); + if (!requestBody) throw new Error("search body was not captured"); + if (requestBody.numResults !== 10) throw new Error("numResults was not sent in Exa's supported casing"); + if (!Array.isArray(requestBody.includeDomains) || !requestBody.includeDomains.includes("*.gov")) { + throw new Error("includeDomains was not sent"); + } + if (requestBody.startPublishedDate !== "2024-01-01T00:00:00.000Z") { + throw new Error("startPublishedDate was not sent"); + } + const contents = requestBody.contents as { text?: { maxCharacters?: number } } | undefined; + if (contents?.text?.maxCharacters !== 10000) throw new Error("contents maxCharacters used unsupported casing"); + if ("num_results" in requestBody) throw new Error("legacy snake_case field leaked into request"); + } finally { + globalThis.fetch = originalFetch; + if (previousApiKey === undefined) Deno.env.delete("EXA_API_KEY"); + else Deno.env.set("EXA_API_KEY", previousApiKey); + } +}); + Deno.test("Exa contents keeps caller cancellation active during JSON body read", async () => { const previousApiKey = Deno.env.get("EXA_API_KEY"); Deno.env.set("EXA_API_KEY", "test-key"); @@ -179,6 +218,72 @@ Deno.test("Exa contents keeps caller cancellation active during JSON body read", } }); +Deno.test("Exa contents sends the documented maxCharacters field", async () => { + const originalFetch = globalThis.fetch; + const previousApiKey = Deno.env.get("EXA_API_KEY"); + let requestBody: Record | undefined; + Deno.env.set("EXA_API_KEY", "test-key"); + globalThis.fetch = ((_input: Request | URL | string, init?: RequestInit) => { + requestBody = JSON.parse(String(init?.body)) as Record; + return Promise.resolve(new Response(JSON.stringify({ results: [{ text: "content" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })); + }) as typeof fetch; + + try { + await exaGetContents("https://example.com/report"); + const text = requestBody?.text as { maxCharacters?: number; max_characters?: number } | undefined; + if (text?.maxCharacters !== 8000) throw new Error("contents maxCharacters was not sent"); + if (text?.max_characters !== undefined) throw new Error("legacy contents casing leaked into request"); + } finally { + globalThis.fetch = originalFetch; + if (previousApiKey === undefined) Deno.env.delete("EXA_API_KEY"); + else Deno.env.set("EXA_API_KEY", previousApiKey); + } +}); + +Deno.test("filtered research retries without domain filters when Exa returns no results", async () => { + const originalFetch = globalThis.fetch; + const previousApiKey = Deno.env.get("EXA_API_KEY"); + const bodies: Array> = []; + Deno.env.set("EXA_API_KEY", "test-key"); + globalThis.fetch = ((_input: Request | URL | string, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as Record; + bodies.push(body); + const filtered = Array.isArray(body.includeDomains); + return Promise.resolve(new Response(JSON.stringify({ + results: filtered ? [] : [{ + title: "Official procurement guidance", + url: "https://gsa.gov/policy/procurement", + text: "Official government procurement policy and supporting evidence with detailed methodology.", + }], + }), { status: 200, headers: { "Content-Type": "application/json" } })); + }) as typeof fetch; + + try { + const result = await deepResearch("federal procurement reform", "United States", { + provider: "openai", + model: "test-model", + callModel: async () => ({ output: { queries: [] } }), + }, { + maxFollowUpQueries: 0, + maxUrlsToRead: 0, + maxSeedQueries: 1, + overallTimeoutMs: 1000, + preferredSourceType: "government", + }); + if (bodies.length !== 2) throw new Error(`expected filtered request and fallback, got ${bodies.length}`); + if (!Array.isArray(bodies[0].includeDomains)) throw new Error("first request was not domain-filtered"); + if (bodies[1].includeDomains !== undefined) throw new Error("fallback retained includeDomains"); + if (!result.rankedResults.some((item) => item.url?.includes("gsa.gov"))) throw new Error("fallback result was lost"); + } finally { + globalThis.fetch = originalFetch; + if (previousApiKey === undefined) Deno.env.delete("EXA_API_KEY"); + else Deno.env.set("EXA_API_KEY", previousApiKey); + } +}); + Deno.test("JSON body timeout cancels a read that stalled after headers", async () => { const originalFetch = globalThis.fetch; let bodyStartedResolve!: () => void; diff --git a/supabase/functions/_shared/research.ts b/supabase/functions/_shared/research.ts index c52b2745d..9a13e3241 100644 --- a/supabase/functions/_shared/research.ts +++ b/supabase/functions/_shared/research.ts @@ -4,11 +4,13 @@ // and free of provider-specific code. import { - buildSearchQueries, + buildFollowUpSearchRequest, + buildSearchRequests, formatRankedSearchResults, rankSearchResults, sourceTitleFromUrl, type ExaResult, + type ExaSearchRequest, type RankedSearchResult, type ResearchSource, } from "./source-quality.ts"; @@ -252,7 +254,9 @@ export async function fetchJsonWithTimeout( // ============== EXA FUNCTIONS ============== // exaSearch: web search using Exa's neural search (type "auto" = balanced relevance & speed) -export async function exaSearch(query: string, signal?: AbortSignal): Promise { +export async function exaSearch(search: string | ExaSearchRequest, signal?: AbortSignal): Promise { + const request = typeof search === "string" ? { query: search } : search; + const query = request.query; const apiKey = Deno.env.get("EXA_API_KEY"); if (!apiKey) { console.warn("[EXA] EXA_API_KEY not configured"); @@ -264,10 +268,10 @@ export async function exaSearch(query: string, signal?: AbortSignal): Promise("https://api.exa.ai/contents", { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": apiKey }, - body: JSON.stringify({ urls: [url], text: { max_characters: 8000 } }), + body: JSON.stringify({ urls: [url], text: { maxCharacters: 8000 } }), }, 30000, signal); throwIfAborted(signal); if (!response.ok) { @@ -431,15 +435,27 @@ async function deepResearchInner( console.log(`[RESEARCH] Starting (mode: ${options.maxFollowUpQueries > 0 ? "deep" : "light"}) for: ${claim}`); const preferredSourceType = options.preferredSourceType || "any"; - const seedQueries = buildSearchQueries(claim, region, preferredSourceType, options.timeframe).slice(0, options.maxSeedQueries); + const seedRequests = buildSearchRequests(claim, region, preferredSourceType, options.timeframe).slice(0, options.maxSeedQueries); const allResults: Array = []; const matchesPreferredSourceType = (result: RankedSearchResult) => ( preferredSourceType === "any" || result.sourceType === preferredSourceType ); - const seedResults = await Promise.all(seedQueries.map(async (query) => { - const results = await exaSearch(query, signal); + const searchWithFallback = async (request: ExaSearchRequest) => { + const filtered = await exaSearch(request, signal); + if (filtered.length > 0 || (!request.includeDomains && !request.category)) return filtered; + const { + includeDomains: _includeDomains, + category: _category, + ...fallbackRequest + } = request; + return exaSearch(fallbackRequest, signal); + }; + + const seedResults = await Promise.all(seedRequests.map(async (request) => { + const { query } = request; + const results = await searchWithFallback(request); throwIfAborted(signal); const ranked = rankSearchResults(results.map((result) => ({ ...result, query })), claim, preferredSourceType); const visibleRanked = preferredSourceType === "any" ? ranked : ranked.filter(matchesPreferredSourceType); @@ -459,7 +475,7 @@ async function deepResearchInner( console.log(`[RESEARCH] Follow-up queries: ${followUpQueries.join(", ")}`); const followUpPromises = followUpQueries.slice(0, options.maxFollowUpQueries).map(async (query) => { - const results = await exaSearch(query, signal); + const results = await searchWithFallback(buildFollowUpSearchRequest(query, preferredSourceType, options.timeframe)); throwIfAborted(signal); const ranked = rankSearchResults(results.map((result) => ({ ...result, query })), claim, preferredSourceType); const visibleRanked = preferredSourceType === "any" ? ranked : ranked.filter(matchesPreferredSourceType); diff --git a/supabase/functions/_shared/source-quality.ts b/supabase/functions/_shared/source-quality.ts index a628b71aa..37b1e8e01 100644 --- a/supabase/functions/_shared/source-quality.ts +++ b/supabase/functions/_shared/source-quality.ts @@ -25,6 +25,17 @@ export type ResearchSource = { qualityLabel: "strong" | "moderate" | "weak"; }; +export type ExaSearchCategory = "news" | "publication"; + +export type ExaSearchRequest = { + query: string; + includeDomains?: string[]; + excludeDomains?: string[]; + startPublishedDate?: string; + endPublishedDate?: string; + category?: ExaSearchCategory; +}; + const WEAK_DOMAINS = [ "youtube.com", "youtu.be", @@ -39,6 +50,49 @@ const WEAK_DOMAINS = [ "medium.com", ]; +const DEBATE_RESULT_DOMAINS = [ + "tabbycat.com", + "*.tabbycat.com", + "calicotab.com", + "*.calicotab.com", + "tabroom.com", + "debatekeeper.com", +]; + +export const GOVERNMENT_SEARCH_DOMAINS = [ + "usa.gov", + "congress.gov", + "census.gov", + "bls.gov", + "bea.gov", + "energy.gov", + "epa.gov", + "cdc.gov", + "nih.gov", + "justice.gov", + "federalregister.gov", + "supremecourt.gov", + "gov.uk", + "supremecourt.uk", + "canada.ca", + "gc.ca", + "gov.au", + "abs.gov.au", + "europa.eu", + "ec.europa.eu", + "eurostat.ec.europa.eu", + "worldbank.org", + "oecd.org", + "imf.org", + "un.org", + "who.int", +]; + +const LEGAL_PRIMARY_DOMAINS = [ + ...GOVERNMENT_SEARCH_DOMAINS, + "curia.europa.eu", +]; + const RESULTS_PATTERNS = [ /\btab\b/i, /\btabs\b/i, @@ -91,7 +145,10 @@ function inferSourceType(result: ExaResult): SourceType { const host = hostFromUrl(url); const haystack = `${result.title || ""} ${url} ${result.content || result.description || ""}`; - if (/(^|\.)gov(\.|$)|\.gov\//i.test(host) || /\.europa\.eu$/i.test(host)) return "government"; + if ( + /(^|\.)gov(\.|$)|\.gov\//i.test(host) || /\.europa\.eu$/i.test(host) || + GOVERNMENT_SEARCH_DOMAINS.some((domain) => hostMatchesDomain(host, domain)) + ) return "government"; if (/\.edu$/i.test(host) || /\.ac\.[a-z]{2}$/i.test(host) || /\b(arxiv|jstor|ssrn|pubmed|springer|sciencedirect|nature|science\.org)\b/i.test(host)) return "academic"; if (/\b(youtube\.com|youtu\.be)\b/i.test(host)) return "video"; if (/\b(wikipedia\.org)\b/i.test(host)) return "encyclopedia"; @@ -205,52 +262,131 @@ export function rankSearchResults( }); } -export function buildSearchQueries(claim: string, region = "", preferredSourceType = "any", timeframe = ""): string[] { +function publishedDateRangeFromTimeframe(timeframe: string): Pick { + const years = timeframe.match(/\b(?:19|20)\d{2}\b/g)?.map(Number) ?? []; + if (years.length === 0) return {}; + return { + startPublishedDate: `${Math.min(...years)}-01-01T00:00:00.000Z`, + endPublishedDate: `${Math.max(...years)}-12-31T23:59:59.999Z`, + }; +} + +function filtersForSourceType(preferredSourceType: string, timeframe: string): Omit { + const publishedDateRange = publishedDateRangeFromTimeframe(timeframe); + if (preferredSourceType === "government") { + return { + includeDomains: GOVERNMENT_SEARCH_DOMAINS, + excludeDomains: WEAK_DOMAINS, + ...publishedDateRange, + }; + } + if (preferredSourceType === "academic") { + return { + category: "publication", + excludeDomains: WEAK_DOMAINS, + ...publishedDateRange, + }; + } + if (preferredSourceType === "news") { + return { + category: "news", + excludeDomains: WEAK_DOMAINS, + ...publishedDateRange, + }; + } + return { + excludeDomains: WEAK_DOMAINS, + ...publishedDateRange, + }; +} + +export function buildFollowUpSearchRequest( + query: string, + preferredSourceType = "any", + timeframe = "", +): ExaSearchRequest { + return { query, ...filtersForSourceType(preferredSourceType, timeframe) }; +} + +export function buildSearchRequests(claim: string, region = "", preferredSourceType = "any", timeframe = ""): ExaSearchRequest[] { const base = `${claim} ${region}`.trim().replace(/\s+/g, " "); const years = timeframe || (/\b202[0-9]\b/.test(base) ? "" : "2025 2026"); - const queries: string[] = []; + const requests: ExaSearchRequest[] = []; + const add = (query: string, filters: Omit = {}) => { + requests.push({ query: query.trim(), excludeDomains: WEAK_DOMAINS, ...filters }); + }; + const publishedDateRange = publishedDateRangeFromTimeframe(timeframe); // Topic-specific queries first (highest priority) if (OFFICIAL_DEBATE_PATTERNS.some((pattern) => pattern.test(base)) || /\bdebate|debating\b/i.test(base)) { - queries.push(`${base} tab results official`.trim()); - queries.push(`${base} Tabbycat Calicotab results`.trim()); + add(`${base} official tournament results`, { includeDomains: DEBATE_RESULT_DOMAINS }); + add(`${base} Tabbycat Calicotab results`, { includeDomains: DEBATE_RESULT_DOMAINS }); } if (POLICY_STAT_PATTERNS.some((pattern) => pattern.test(base))) { - queries.push(`${base} World Bank OECD IMF UN data ${years}`.trim()); - queries.push(`${base} government statistics report ${years}`.trim()); + add(`${base} World Bank OECD IMF UN data ${years}`, { + includeDomains: GOVERNMENT_SEARCH_DOMAINS, + ...publishedDateRange, + }); + add(`${base} government statistics report ${years}`, { + includeDomains: GOVERNMENT_SEARCH_DOMAINS, + ...publishedDateRange, + }); } if (LEGAL_CURRENT_PATTERNS.some((pattern) => pattern.test(base))) { - queries.push(`${base} official law court ruling ${years}`.trim()); - queries.push(`${base} primary source legislation regulation ${years}`.trim()); + add(`${base} official law court ruling ${years}`, { + includeDomains: LEGAL_PRIMARY_DOMAINS, + ...publishedDateRange, + }); + add(`${base} primary source legislation regulation ${years}`, { + includeDomains: LEGAL_PRIMARY_DOMAINS, + ...publishedDateRange, + }); } // Preferred source type queries - if (preferredSourceType === "government") queries.push(`${base} site:gov ${years}`.trim()); - if (preferredSourceType === "academic") queries.push(`${base} study journal university ${years}`.trim()); - if (preferredSourceType === "news") queries.push(`${base} Reuters AP BBC report ${years}`.trim()); + if (preferredSourceType === "government") add(`${base} government evidence ${years}`, filtersForSourceType("government", timeframe)); + if (preferredSourceType === "academic") add(`${base} study journal research ${years}`, filtersForSourceType("academic", timeframe)); + if (preferredSourceType === "news") add(`${base} current reporting ${years}`, filtersForSourceType("news", timeframe)); // General fallback queries - queries.push(`${base} official data evidence ${years}`.trim()); - queries.push(`${base} statistics report study ${years}`.trim()); + add(`${base} official data evidence ${years}`, filtersForSourceType(preferredSourceType, timeframe)); + add(`${base} statistics report study ${years}`, filtersForSourceType(preferredSourceType, timeframe)); // Deduplicate by token overlap: skip when more than 85% of the smaller query's tokens already appear in an accepted query - const deduplicated: string[] = []; - for (const q of queries) { - const qTokens = new Set(q.toLowerCase().split(/\s+/)); + const deduplicated: ExaSearchRequest[] = []; + for (const request of requests) { + const qTokens = new Set(request.query.toLowerCase().split(/\s+/)); const isDuplicate = deduplicated.some((existing) => { - const existingTokens = new Set(existing.toLowerCase().split(/\s+/)); + const existingTokens = new Set(existing.query.toLowerCase().split(/\s+/)); const intersection = [...qTokens].filter((t) => existingTokens.has(t)).length; const smaller = Math.min(qTokens.size, existingTokens.size); - return smaller > 0 && intersection / smaller > 0.85; + const sameFilters = JSON.stringify({ + includeDomains: existing.includeDomains ?? [], + excludeDomains: existing.excludeDomains ?? [], + category: existing.category ?? null, + startPublishedDate: existing.startPublishedDate ?? null, + endPublishedDate: existing.endPublishedDate ?? null, + }) === JSON.stringify({ + includeDomains: request.includeDomains ?? [], + excludeDomains: request.excludeDomains ?? [], + category: request.category ?? null, + startPublishedDate: request.startPublishedDate ?? null, + endPublishedDate: request.endPublishedDate ?? null, + }); + return sameFilters && smaller > 0 && intersection / smaller > 0.85; }); - if (!isDuplicate) deduplicated.push(q); + if (!isDuplicate) deduplicated.push(request); } return deduplicated.slice(0, 6); } +export function buildSearchQueries(claim: string, region = "", preferredSourceType = "any", timeframe = ""): string[] { + return buildSearchRequests(claim, region, preferredSourceType, timeframe).map((request) => request.query); +} + // Bounded per-result snippet: Exa may return up to 10k characters per result, // which would bloat prompts when several results are included. const MAX_RESULT_SNIPPET_CHARS = 1200; diff --git a/supabase/functions/ai/control-matrix.test.ts b/supabase/functions/ai/control-matrix.test.ts index 1857acd01..b5d16fede 100644 --- a/supabase/functions/ai/control-matrix.test.ts +++ b/supabase/functions/ai/control-matrix.test.ts @@ -11,7 +11,7 @@ import { enforceGenerateControls, isAskStreamingModeAllowed, } from "../_shared/control-enforcement.ts"; -import { buildSearchQueries } from "../_shared/source-quality.ts"; +import { buildSearchRequests } from "../_shared/source-quality.ts"; function assertIncludes(text: string, expected: string, label: string) { if (!text.includes(expected)) throw new Error(`[${label}] prompt missing "${expected}"`); @@ -99,8 +99,10 @@ Deno.test("factoids controls propagate to prompt and query construction", () => "Timeframe: 2024-2026", "factoids.timeframe prompt", ); - const govQueries = buildSearchQueries("C", "EU", "government", "2024-2026"); - assert(govQueries.some((q) => q.includes("site:gov")), "factoids.sourceType retrieval"); + const govRequests = buildSearchRequests("C", "EU", "government", "2024-2026"); + assert(govRequests.some((request) => request.includeDomains?.includes("energy.gov")), "factoids.sourceType retrieval"); + assert(govRequests.some((request) => request.startPublishedDate?.startsWith("2024")), "factoids.timeframe retrieval"); + assert(govRequests.some((request) => request.endPublishedDate?.startsWith("2026")), "factoids.timeframe upper bound"); }); Deno.test("ask toggles and modes appear verbatim in the request prompt", () => { diff --git a/supabase/functions/ai/handler-research.test.ts b/supabase/functions/ai/handler-research.test.ts new file mode 100644 index 000000000..89f17cff2 --- /dev/null +++ b/supabase/functions/ai/handler-research.test.ts @@ -0,0 +1,162 @@ +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +const environment = { + SUPABASE_URL: "https://supabase.test", + SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", + SPARRING_INTEGRITY_SECRET: "test-sparring-secret", + EXA_API_KEY: "test-exa-key", + OPENAI_API_KEY: "test-openai-key", +}; + +for (const [key, value] of Object.entries(environment)) Deno.env.set(key, value); + +const { handleRequest } = await import("./index.ts"); + +Deno.test("factoids handler carries Exa filters and consulted-source metadata across the request boundary", async () => { + const originalFetch = globalThis.fetch; + const exaSearchBodies: Array> = []; + let providerCalls = 0; + const server = Deno.serve({ hostname: "127.0.0.1", port: 0, onListen: () => {} }, handleRequest); + const serverAddress = server.addr as Deno.NetAddr; + + globalThis.fetch = (async (input: Request | URL | string, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + + if (url.hostname === "127.0.0.1" && url.port === String(serverAddress.port)) { + return originalFetch(input, init); + } + + if (url.origin === "https://supabase.test") { + if (url.pathname === "/auth/v1/user") return jsonResponse({ id: "user-1", aud: "authenticated" }); + if (url.pathname.includes("/rest/v1/user_roles")) return jsonResponse({ role: "user" }); + if (url.pathname.includes("/rest/v1/user_settings")) { + return jsonResponse({ + subscription_tier: "trial", + daily_limit_override: 50, + trial_ends_at: "2099-01-01T00:00:00.000Z", + grace_period_ends_at: null, + }); + } + if (url.pathname.includes("/rest/v1/rate_limits")) return jsonResponse({ limit_value: 50 }); + if (url.pathname.includes("/rest/v1/rpc/consume_daily_quota")) { + return jsonResponse([{ allowed: true, current_count: 1 }]); + } + if (url.pathname.includes("/rest/v1/global_prompts")) return jsonResponse({ prompt_text: null }); + if (url.pathname.includes("/rest/v1/ai_requests")) return new Response(null, { status: 201 }); + throw new Error(`Unhandled Supabase request: ${url}`); + } + + if (url.href === "https://api.exa.ai/search") { + const body = JSON.parse(String(init?.body)) as Record; + exaSearchBodies.push(body); + return jsonResponse({ + results: [{ + title: "Official energy statistics", + url: "https://energy.gov/reports/solar-costs-2024", + text: "Official government statistics report showing a 42 percent cost reduction in solar energy.", + publishedDate: "2024-06-01T00:00:00.000Z", + }], + }); + } + + if (url.href === "https://api.exa.ai/contents") { + return jsonResponse({ + results: [{ + url: "https://energy.gov/reports/solar-costs-2024", + text: "Official government statistics report showing a 42 percent cost reduction in solar energy.", + }], + }); + } + + if (url.href === "https://api.openai.com/v1/chat/completions") { + providerCalls += 1; + const content = providerCalls === 1 + ? { queries: [] } + : { + factoids: [{ + id: "fact-1", + shortClaim: "Solar costs fell by 42 percent", + keyFigure: "42%", + context: "The official report compares the relevant period.", + limitations: "The result depends on the measured market segment.", + citation: { + title: "Official energy statistics", + publisher: "Department of Energy", + date: "2024", + url: "https://energy.gov/reports/solar-costs-2024", + verified: "pending", + }, + howToUse: "Use this as a bounded cost trend.", + relevanceScore: 92, + }], + searchSuggestions: [], + warnings: [], + verificationStatus: "pending", + }; + return jsonResponse({ + choices: [{ message: { content: JSON.stringify(content) } }], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }); + } + + throw new Error(`Unhandled request: ${url}`); + }) as typeof fetch; + + try { + const response = await originalFetch(`http://127.0.0.1:${serverAddress.port}`, { + method: "POST", + headers: { + Authorization: "Bearer test-user-token", + "Content-Type": "application/json", + Origin: "http://localhost:8080", + }, + body: JSON.stringify({ + feature: "factoids", + model: "gpt-5.4-mini", + input: { + claim: "solar energy costs", + direction: "for", + region: "United States", + sourceType: "government", + timeframe: "2024-2026", + sourceCount: 5, + }, + }), + }); + + if (response.status !== 200) throw new Error(`handler returned ${response.status}: ${await response.text()}`); + const payload = await response.json() as { + output?: { factoids?: Array<{ citation?: { verified?: string } }> }; + discoveredSources?: Array<{ url?: string; qualityLabel?: string; sourceType?: string; query?: string }>; + }; + if (payload.output?.factoids?.[0]?.citation?.verified !== "verified") { + throw new Error("handler did not verify a fetched non-weak citation"); + } + const source = payload.discoveredSources?.[0]; + if (source?.qualityLabel !== "strong" || source.sourceType !== "government" || !source.query) { + throw new Error(`consulted-source provenance missing: ${JSON.stringify(source)}`); + } + if (exaSearchBodies.length === 0) throw new Error("handler never called Exa search"); + for (const body of exaSearchBodies) { + const query = typeof body.query === "string" ? body.query : ""; + if (query.includes("site:")) throw new Error(`site operator leaked into Exa query: ${query}`); + if (!Array.isArray(body.includeDomains) || !body.includeDomains.includes("energy.gov")) { + throw new Error(`government includeDomains missing: ${JSON.stringify(body)}`); + } + if (body.startPublishedDate !== "2024-01-01T00:00:00.000Z") { + throw new Error(`published-date filter missing: ${JSON.stringify(body)}`); + } + if (body.endPublishedDate !== "2026-12-31T23:59:59.999Z") { + throw new Error(`published-date upper bound missing: ${JSON.stringify(body)}`); + } + } + } finally { + globalThis.fetch = originalFetch; + await server.shutdown(); + } +}); diff --git a/supabase/functions/ai/index.ts b/supabase/functions/ai/index.ts index 541979c9f..8b15b6522 100644 --- a/supabase/functions/ai/index.ts +++ b/supabase/functions/ai/index.ts @@ -1030,7 +1030,7 @@ function crossfireReplyOutput(value: unknown, expectedReply: "question" | "answe // ============== MAIN HANDLER ============== -Deno.serve(async (req) => { +export async function handleRequest(req: Request): Promise { const requestId = generateRequestId(); const corsHeaders = getCorsHeaders(req.headers.get("origin")); @@ -1722,4 +1722,8 @@ Deno.serve(async (req) => { ); return new Response(JSON.stringify({ error: message }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }); } -}); +} + +if (import.meta.main) { + Deno.serve(handleRequest); +} diff --git a/supabase/functions/ai/source-quality.test.ts b/supabase/functions/ai/source-quality.test.ts index 936c7f23d..965a95f42 100644 --- a/supabase/functions/ai/source-quality.test.ts +++ b/supabase/functions/ai/source-quality.test.ts @@ -1,4 +1,4 @@ -import { buildSearchQueries, rankSearchResults } from "../_shared/source-quality.ts"; +import { buildSearchQueries, buildSearchRequests, rankSearchResults } from "../_shared/source-quality.ts"; function assert(condition: boolean, message: string) { if (!condition) throw new Error(message); @@ -52,9 +52,14 @@ Deno.test("source ranking respects requested government source type", () => { }); Deno.test("search query builder adds debate tab queries for tournament claims", () => { - const queries = buildSearchQueries("best country in WSDC debate 2026"); + const requests = buildSearchRequests("best country in WSDC debate 2026"); + const queries = requests.map((request) => request.query); assert(queries.some((query) => /tabbycat|calicotab/i.test(query)), `Expected tab search query, got ${queries.join("; ")}`); assert(queries.some((query) => /official/i.test(query)), `Expected official search query, got ${queries.join("; ")}`); + assert( + requests.some((request) => request.includeDomains?.includes("*.tabbycat.com")), + "Expected first-class debate result domain filters", + ); }); Deno.test("search query builder adds policy and statistics source queries", () => { @@ -68,3 +73,32 @@ Deno.test("search query builder adds legal primary-source queries", () => { assert(queries.some((query) => /official law court/i.test(query)), `Expected official legal source query, got ${queries.join("; ")}`); assert(queries.some((query) => /primary source/i.test(query)), `Expected primary source query, got ${queries.join("; ")}`); }); + +Deno.test("preferred source filters use Exa fields instead of site operators", () => { + const government = buildSearchRequests("solar costs", "United States", "government", "2024-2026"); + assert(government.every((request) => !request.query.includes("site:")), "site operators must not remain in query text"); + assert(government.some((request) => request.includeDomains?.includes("energy.gov")), "government domains must use includeDomains"); + assert( + government.some((request) => request.startPublishedDate === "2024-01-01T00:00:00.000Z"), + "timeframe must use startPublishedDate", + ); + assert( + government.some((request) => request.endPublishedDate === "2026-12-31T23:59:59.999Z"), + "timeframe must use endPublishedDate", + ); + + const academic = buildSearchRequests("carbon pricing", "", "academic", "2023"); + assert(academic.some((request) => request.category === "publication"), "academic search must use publication category"); + + const news = buildSearchRequests("election policy", "", "news", "2025-2026"); + assert(news.some((request) => request.category === "news"), "news search must use news category"); +}); + +Deno.test("intergovernmental domains satisfy the government source preference", () => { + const ranked = rankSearchResults([{ + title: "World development indicators", + url: "https://data.worldbank.org/indicator/NY.GDP.MKTP.CD", + content: "World Bank official indicator data with methodology and country statistics.", + }], "GDP statistics", "government"); + assert(ranked[0]?.sourceType === "government", `Expected World Bank to classify as government, got ${ranked[0]?.sourceType}`); +}); diff --git a/vitest.config.ts b/vitest.config.ts index ace8792e6..32259f446 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from "vitest/config"; +import { configDefaults, defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react-swc"; import path from "path"; @@ -12,6 +12,10 @@ export default defineConfig({ "src/**/*.{test,spec}.{ts,tsx}", "supabase/functions/**/*.{test,spec}.{ts,tsx}", ], + // This boundary test imports the deployed Edge Function entrypoint and its + // remote Deno dependencies. Run it with `deno test`; Vite cannot resolve + // those URL imports in the browser-oriented Vitest suite. + exclude: [...configDefaults.exclude, "supabase/functions/ai/handler-research.test.ts"], coverage: { provider: "v8", // Omit `all` intentionally: this is a selected-scope gate for the audited
+ {t('sourceQuality.fallbackNote')} +