Skip to content

Commit eee2cdf

Browse files
authored
Complete search quality plan (#17)
* feat: complete search quality plan * docs: mark Factoids source list complete * fix: validate discovered source URLs * fix: normalize discovered source URLs
1 parent 589f827 commit eee2cdf

16 files changed

Lines changed: 736 additions & 65 deletions

‎e2e/search-quality.spec.ts‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { expect, test } from '@playwright/test';
2+
import { setLocale, stubSupabaseSession } from './helpers/auth';
3+
4+
const strongSourceUrl = 'https://energy.gov/reports/solar-costs-2024';
5+
const weakSourceUrl = 'https://example.com/broad-commentary';
6+
7+
test('authenticated Factoids shows the consulted-source quality panel', async ({ page }) => {
8+
await setLocale(page, 'en');
9+
await stubSupabaseSession(page);
10+
11+
await page.route('**/functions/v1/ai', async (route) => {
12+
await route.fulfill({
13+
status: 200,
14+
contentType: 'application/json',
15+
body: JSON.stringify({
16+
output: {
17+
factoids: [{
18+
id: 'fact-1',
19+
shortClaim: 'Solar costs fell by 42 percent',
20+
keyFigure: '42%',
21+
context: 'The official report compares the relevant period.',
22+
limitations: 'The result depends on the measured market segment.',
23+
citation: {
24+
title: 'Official energy statistics',
25+
publisher: 'Department of Energy',
26+
date: '2024',
27+
url: strongSourceUrl,
28+
verified: 'verified',
29+
},
30+
howToUse: 'Use this as a bounded cost trend.',
31+
relevanceScore: 92,
32+
}],
33+
searchSuggestions: [],
34+
warnings: [],
35+
verificationStatus: 'complete',
36+
},
37+
discoveredSources: [{
38+
title: 'Official energy statistics',
39+
url: strongSourceUrl,
40+
content: 'Official government statistics.',
41+
sourceType: 'government',
42+
qualityScore: 91,
43+
qualityLabel: 'strong',
44+
query: 'solar energy costs government evidence',
45+
}, {
46+
title: 'Broad commentary',
47+
url: weakSourceUrl,
48+
content: 'General commentary.',
49+
sourceType: 'generic',
50+
qualityScore: 30,
51+
qualityLabel: 'weak',
52+
query: 'solar energy costs commentary',
53+
}],
54+
}),
55+
});
56+
});
57+
58+
await page.route('**/rest/v1/source_folders*', async (route) => {
59+
await route.fulfill({ status: 201, contentType: 'application/json', body: '{}' });
60+
});
61+
await page.route('**/rest/v1/sources*', async (route) => {
62+
await route.fulfill({
63+
status: 201,
64+
contentType: 'application/json',
65+
body: JSON.stringify({
66+
id: 'source-1',
67+
title: 'Consulted source',
68+
content: 'Evidence',
69+
type: 'url',
70+
origin: strongSourceUrl,
71+
folder: 'Auto-saved',
72+
metadata: {},
73+
is_active: true,
74+
created_at: new Date().toISOString(),
75+
}),
76+
});
77+
});
78+
79+
await page.goto('/app/factoids');
80+
await page.getByRole('textbox', { name: /statement/i }).fill('Solar energy costs are falling');
81+
await page.getByRole('button', { name: /find factoids/i }).click();
82+
83+
await expect(page.getByRole('heading', { name: /researched sources/i })).toBeVisible();
84+
await expect(page.getByRole('link', { name: /official energy statistics/i }).first()).toHaveAttribute('href', strongSourceUrl);
85+
await expect(page.getByRole('link', { name: /broad commentary/i })).toHaveAttribute('href', weakSourceUrl);
86+
await expect(page.getByText('strong', { exact: true })).toBeVisible();
87+
await expect(page.getByText('weak', { exact: true })).toBeVisible();
88+
await expect(page.getByText('fallback context only', { exact: true })).toBeVisible();
89+
await expect(page.getByText('Solar costs fell by 42 percent')).toBeVisible();
90+
});

‎plans/search-quality.md‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,16 @@ Implemented:
4343
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`.
4444
2. Topic-specific query builders (tournament, policy/statistics, legal/current events) were already present and are now covered by tests in the shared module.
4545
3. All discovered-source returns carry quality metadata; Factoids now also returns `discoveredSources`.
46-
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).
46+
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.
4747
5. Provenance persists to saved sources: `sources.metadata` jsonb (migration `20260822000001`, prepared but NOT applied) stores url, sourceType, qualityScore, qualityLabel, originating query, capturedVia.
4848
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.
4949

5050
Tests: `_shared/research.test.ts`, extended `source-quality.test.ts`, frontend `SourceQualityList.test.tsx`, session-store provenance round-trip, client discoveredSources tests.
5151

5252
Deferred:
5353

54-
- Replace inline `site:gov` query text with Exa's dedicated domain-filter request field (needs a live Exa parameter-verification pass).
55-
- Handler-level integration test that mocks Exa end-to-end through `Deno.serve`; current coverage is at the extracted-function level plus frontend mocks.
56-
- Factoids UI panel listing all consulted sources with quality chips.
54+
- None. The remaining search-quality scope was completed on `t3code/search-quality-completion`:
55+
- 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.
56+
- 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.
57+
- Factoids returns `discoveredSources`, autosaves their provenance, and renders the shared consulted-source quality list, including the weak-source fallback warning.
58+
- Authenticated Playwright coverage exercises the Factoids flow on isolated desktop and mobile Chromium projects.

‎src/components/features/factoids/FactoidsResults.tsx‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,13 @@ import { toast } from 'sonner';
1616
import { ExportToolbar } from '@/components/features/ExportToolbar';
1717
import { GoogleDriveAuthModal } from '@/components/features/export/GoogleDriveAuthModal';
1818
import { openExternalUrl } from '@/lib/native';
19+
import { SourceQualityList } from '@/components/features/shared/SourceQualityList';
20+
import type { DiscoveredSource } from '@/lib/ai/client';
1921

2022

2123
interface FactoidsResultsProps {
2224
result: FactoidsOutput;
25+
discoveredSources?: DiscoveredSource[];
2326
onDiscuss?: () => void;
2427
onSaveSource?: (title: string, content: string, url?: string) => void;
2528
onUseSuggestion?: (query: string) => void;
@@ -137,7 +140,7 @@ function FactoidCard({ factoid, onSave }: { factoid: Factoid, onSave?: FactoidsR
137140
);
138141
}
139142

140-
export function FactoidsResults({ result, onDiscuss, onSaveSource, onUseSuggestion }: FactoidsResultsProps) {
143+
export function FactoidsResults({ result, discoveredSources, onDiscuss, onSaveSource, onUseSuggestion }: FactoidsResultsProps) {
141144
const { t } = useTranslation();
142145
const [isExportingGDocs, setIsExportingGDocs] = useState(false);
143146
const [showDriveAuth, setShowDriveAuth] = useState(false);
@@ -195,6 +198,21 @@ export function FactoidsResults({ result, onDiscuss, onSaveSource, onUseSuggesti
195198
discussLabel={t('factoidsResults.discuss')}
196199
/>
197200

201+
{discoveredSources && discoveredSources.length > 0 && (
202+
<section
203+
aria-labelledby="factoids-consulted-sources"
204+
className="rounded-lg border bg-card/50 p-4 sm:p-5"
205+
>
206+
<h3 id="factoids-consulted-sources" className="mb-2 text-sm font-medium">
207+
{t('sourceQuality.researchedSources')}
208+
</h3>
209+
<SourceQualityList sources={discoveredSources} className="[&_a]:break-words" />
210+
<p className="mt-3 text-xs text-muted-foreground">
211+
{t('sourceQuality.fallbackNote')}
212+
</p>
213+
</section>
214+
)}
215+
198216
{/* Verification Status */}
199217
<SectionCard
200218
title={t('factoidsResults.verificationStatus')}

‎src/lib/ai/client.ts‎

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import type { SourceProvenance } from '../stores/session-store';
2828
import { supabase } from '@/lib/supabase/client';
2929
import { toast } from '@/hooks/use-toast';
3030
import { captureException, captureProductEvent } from '@/lib/observability';
31+
import { validateUrl } from '@/lib/validation/url';
3132
import {
3233
CrossfireAIInputSchema,
3334
CrossfireDebriefSchema,
@@ -112,9 +113,10 @@ function parseDiscoveredSources(value: unknown): DiscoveredSource[] {
112113

113114
const title = typeof candidate.title === 'string' ? candidate.title.trim() : '';
114115
const url = typeof candidate.url === 'string' ? candidate.url.trim() : '';
115-
if (!title || !url) return [];
116+
const normalizedUrl = validateUrl(url);
117+
if (!title || !normalizedUrl) return [];
116118

117-
const source: DiscoveredSource = { title, url };
119+
const source: DiscoveredSource = { title, url: normalizedUrl };
118120
if (typeof candidate.content === 'string') source.content = candidate.content;
119121

120122
if (typeof candidate.sourceType === 'string' && candidate.sourceType.trim()) {
@@ -1021,7 +1023,7 @@ function validateGenerateOutput(output: unknown): GenerateOutput {
10211023
return parsed.data;
10221024
}
10231025

1024-
type AutosaveFeature = 'generate' | 'attack' | 'brainstorm';
1026+
type AutosaveFeature = 'generate' | 'attack' | 'brainstorm' | 'factoids';
10251027

10261028
async function autosaveDiscoveredSources(sources: DiscoveredSource[], capturedVia: AutosaveFeature): Promise<void> {
10271029
if (sources.length === 0) return;
@@ -1122,8 +1124,8 @@ export async function brainstorm(input: BrainstormInput, config: ProviderConfig)
11221124
return response;
11231125
}
11241126

1125-
export async function factoids(input: FactoidsInput, config: ProviderConfig): Promise<FactoidsOutput> {
1126-
if (config.provider === 'mock') return mockFactoids(input.claim);
1127+
export async function factoids(input: FactoidsInput, config: ProviderConfig): Promise<ResultWithSources<FactoidsOutput>> {
1128+
if (config.provider === 'mock') return { result: await mockFactoids(input.claim), discoveredSources: [] };
11271129
// Inject global language into input for the edge function
11281130
const language = useSettingsStore.getState().outputLanguage;
11291131
const inputWithLanguage = {
@@ -1132,9 +1134,9 @@ export async function factoids(input: FactoidsInput, config: ProviderConfig): Pr
11321134
sourceCount: input.sourceCount || 5,
11331135
language,
11341136
} as Record<string, unknown>;
1135-
return await callSupabaseAI<FactoidsOutput>('factoids', inputWithLanguage, config, {
1137+
const response = await callSupabaseAI<ResultWithSources<FactoidsOutput>>('factoids', inputWithLanguage, config, {
11361138
transformResponse: (rawData) => {
1137-
const data = rawData as { output: unknown };
1139+
const data = rawData as { output: unknown; discoveredSources?: unknown };
11381140
const parsed = FactoidsOutputSchema.safeParse(data.output);
11391141
if (!parsed.success) {
11401142
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
11461148
const verifiedFactoids = allFactoids.filter((factoid) => factoid.citation.verified === 'verified');
11471149
const returnedFactoids = verifiedFactoids.slice(0, input.sourceCount || 5);
11481150
return {
1149-
...parsed.data,
1150-
factoids: returnedFactoids,
1151-
filteredOutCount: Math.max(0, allFactoids.length - returnedFactoids.length),
1151+
result: {
1152+
...parsed.data,
1153+
factoids: returnedFactoids,
1154+
filteredOutCount: Math.max(0, allFactoids.length - returnedFactoids.length),
1155+
},
1156+
discoveredSources: parseDiscoveredSources(data.discoveredSources),
11521157
};
11531158
},
11541159
});
1160+
await autosaveDiscoveredSources(response.discoveredSources, 'factoids');
1161+
return response;
11551162
}
11561163

11571164
export interface AskChatOptions {

‎src/pages/FactoidsPage.tsx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1+
import { useState } from 'react';
12
import { useNavigate } from 'react-router-dom';
23
import { useTranslation } from 'react-i18next';
34
import { useSessionStore } from '@/lib/stores/session-store';
45
import { useSettingsStore } from '@/lib/stores/settings-store';
56
import { FactoidsForm } from '@/components/features/factoids/FactoidsForm';
67
import { FactoidsResults } from '@/components/features/factoids/FactoidsResults';
7-
import { factoids, askChat } from '@/lib/ai/client';
8+
import { factoids, askChat, type DiscoveredSource } from '@/lib/ai/client';
89
import { useSubscriptionError } from '@/hooks/use-subscription-error';
910
import { getUserFacingAIError } from '@/lib/ai/error-display';
1011
import { LimitReachedModal, ModelRestrictedModal, PricingModal } from '@/components/features/subscription';
@@ -22,11 +23,14 @@ export default function FactoidsPage() {
2223
const navigate = useNavigate();
2324
const subError = useSubscriptionError();
2425
const { t } = useTranslation();
26+
const [discoveredSources, setDiscoveredSources] = useState<DiscoveredSource[]>([]);
2527

2628
const handleSubmit = async (data: FactoidsInput) => {
29+
setDiscoveredSources([]);
2730
setFactoidsLoading(true);
2831
try {
29-
const result = await factoids(data, config);
32+
const { result, discoveredSources: sources } = await factoids(data, config);
33+
setDiscoveredSources(sources);
3034
setFactoidsResult(result);
3135
toast({ title: t('factoids.complete', 'Research complete'), description: t('factoids.completeDesc', 'Verified evidence is ready.'), variant: 'success' });
3236
} catch (error) {
@@ -130,6 +134,7 @@ export default function FactoidsPage() {
130134
<div key="factoids-results">
131135
<FactoidsResults
132136
result={factoidsResult}
137+
discoveredSources={discoveredSources}
133138
onDiscuss={handleDiscuss}
134139
onSaveSource={handleSaveToLibrary}
135140
onUseSuggestion={handleUseSuggestion}

‎src/test/ai/client-branches.test.ts‎

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -259,10 +259,10 @@ describe('AI client branch coverage', () => {
259259
title: 'Source A',
260260
content: 'A content',
261261
type: 'url',
262-
origin: 'https://a.com',
262+
origin: 'https://a.com/',
263263
folder: 'Auto-saved',
264264
isActive: true,
265-
metadata: expect.objectContaining({ capturedVia: 'generate', url: 'https://a.com' }),
265+
metadata: expect.objectContaining({ capturedVia: 'generate', url: 'https://a.com/' }),
266266
});
267267
});
268268

@@ -311,7 +311,9 @@ describe('AI client branch coverage', () => {
311311
qualityLabel: 'unknown',
312312
query: 42,
313313
},
314+
{ title: 'Protocol-less source', url: 'example.com/source-two' },
314315
{ title: '', url: 'https://example.com/rejected' },
316+
{ title: 'Unsafe protocol', url: 'httpx://example.com/rejected' },
315317
],
316318
},
317319
error: null,
@@ -321,6 +323,7 @@ describe('AI client branch coverage', () => {
321323

322324
expect(outcome.discoveredSources).toEqual([
323325
{ title: 'Source with malformed metadata', url: 'https://example.com/source' },
326+
{ title: 'Protocol-less source', url: 'https://example.com/source-two' },
324327
]);
325328
expect(addSourceMock).toHaveBeenCalledWith(expect.objectContaining({
326329
title: 'Source with malformed metadata',
@@ -427,10 +430,10 @@ describe('AI client branch coverage', () => {
427430
title: 'Source C',
428431
content: 'C content',
429432
type: 'url',
430-
origin: 'https://c.com',
433+
origin: 'https://c.com/',
431434
folder: 'Auto-saved',
432435
isActive: true,
433-
metadata: expect.objectContaining({ capturedVia: 'brainstorm', url: 'https://c.com' }),
436+
metadata: expect.objectContaining({ capturedVia: 'brainstorm', url: 'https://c.com/' }),
434437
});
435438
});
436439

@@ -501,8 +504,49 @@ describe('AI client branch coverage', () => {
501504

502505
const result = await factoids({ claim: 'Claim', direction: 'for', sourceType: 'any', sourceCount: 3 }, config);
503506

504-
expect(result.factoids).toHaveLength(3);
505-
expect(result.filteredOutCount).toBe(1);
507+
expect(result.result.factoids).toHaveLength(3);
508+
expect(result.result.filteredOutCount).toBe(1);
509+
});
510+
511+
it('returns and autosaves discovered sources for factoids responses', async () => {
512+
invokeMock.mockResolvedValueOnce({
513+
data: {
514+
output: {
515+
factoids: [],
516+
searchSuggestions: [],
517+
warnings: [],
518+
verificationStatus: 'complete',
519+
},
520+
discoveredSources: [{
521+
title: 'Factoids source',
522+
url: 'https://example.com/factoids',
523+
content: 'Evidence content',
524+
sourceType: 'government',
525+
qualityLabel: 'moderate',
526+
qualityScore: 70,
527+
query: 'factoids query',
528+
}],
529+
},
530+
error: null,
531+
});
532+
533+
const outcome = await factoids({ claim: 'Claim', direction: 'for', sourceType: 'any', sourceCount: 3 }, config);
534+
535+
expect(outcome.discoveredSources).toEqual([{
536+
title: 'Factoids source',
537+
url: 'https://example.com/factoids',
538+
content: 'Evidence content',
539+
sourceType: 'government',
540+
qualityLabel: 'moderate',
541+
qualityScore: 70,
542+
query: 'factoids query',
543+
}]);
544+
expect(addSourceMock).toHaveBeenCalledWith(expect.objectContaining({
545+
title: 'Factoids source',
546+
origin: 'https://example.com/factoids',
547+
isActive: true,
548+
metadata: expect.objectContaining({ capturedVia: 'factoids', qualityLabel: 'moderate' }),
549+
}));
506550
});
507551

508552
it('maps invoke errors with 401 status to AIAuthError', async () => {
@@ -846,7 +890,7 @@ describe('AI client branch coverage', () => {
846890

847891
expect(attack.overallAssessment.score).toBeTypeOf('number');
848892
expect(ideas.nextSteps).toBeInstanceOf(Array);
849-
expect(facts.factoids).toBeInstanceOf(Array);
893+
expect(facts.result.factoids).toBeInstanceOf(Array);
850894
expect(chat.response).toContain('hello');
851895
expect(invokeMock).not.toHaveBeenCalled();
852896
}, 15000);

‎src/test/components/FactoidsResults.test.tsx‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react';
22
import { vi } from 'vitest';
33
import { FactoidsResults } from '@/components/features/factoids/FactoidsResults';
44
import type { FactoidsOutput } from '@/lib/ai/schemas/factoids';
5+
import type { DiscoveredSource } from '@/lib/ai/client';
56

67
vi.mock('@/components/features/ExportToolbar', () => ({
78
ExportToolbar: (): null => null,
@@ -35,3 +36,18 @@ test('does not expose source verification while no verification behavior exists'
3536

3637
expect(screen.queryByRole('button', { name: /verify all sources/i })).not.toBeInTheDocument();
3738
});
39+
40+
test('renders consulted sources with a fallback label for weak sources', () => {
41+
const discoveredSources: DiscoveredSource[] = [{
42+
title: 'Broad fallback article',
43+
url: 'https://example.com/fallback',
44+
qualityLabel: 'weak',
45+
qualityScore: 20,
46+
}];
47+
48+
render(<FactoidsResults result={result} discoveredSources={discoveredSources} />);
49+
50+
expect(screen.getByRole('heading', { name: /researched sources/i })).toBeInTheDocument();
51+
expect(screen.getByRole('link', { name: /broad fallback article/i })).toHaveAttribute('href', discoveredSources[0].url);
52+
expect(screen.getByText('fallback context only')).toBeInTheDocument();
53+
});

0 commit comments

Comments
 (0)