Skip to content

Commit de6d375

Browse files
committed
feat: complete search quality plan
1 parent acaac0d commit de6d375

16 files changed

Lines changed: 724 additions & 57 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: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ Tests: `_shared/research.test.ts`, extended `source-quality.test.ts`, frontend `
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: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -924,7 +924,7 @@ function validateGenerateOutput(output: unknown): GenerateOutput {
924924
return parsed.data;
925925
}
926926

927-
type AutosaveFeature = 'generate' | 'attack' | 'brainstorm';
927+
type AutosaveFeature = 'generate' | 'attack' | 'brainstorm' | 'factoids';
928928

929929
async function autosaveDiscoveredSources(sources: DiscoveredSource[], capturedVia: AutosaveFeature): Promise<void> {
930930
if (sources.length === 0) return;
@@ -1021,8 +1021,8 @@ export async function brainstorm(input: BrainstormInput, config: ProviderConfig)
10211021
};
10221022
}
10231023

1024-
export async function factoids(input: FactoidsInput, config: ProviderConfig): Promise<FactoidsOutput> {
1025-
if (config.provider === 'mock') return mockFactoids(input.claim);
1024+
export async function factoids(input: FactoidsInput, config: ProviderConfig): Promise<ResultWithSources<FactoidsOutput>> {
1025+
if (config.provider === 'mock') return { result: await mockFactoids(input.claim), discoveredSources: [] };
10261026
// Inject global language into input for the edge function
10271027
const language = useSettingsStore.getState().outputLanguage;
10281028
const inputWithLanguage = {
@@ -1031,7 +1031,7 @@ export async function factoids(input: FactoidsInput, config: ProviderConfig): Pr
10311031
sourceCount: input.sourceCount || 5,
10321032
language,
10331033
} as Record<string, unknown>;
1034-
const data = await callSupabaseAI('factoids', inputWithLanguage, config) as { output: unknown };
1034+
const data = await callSupabaseAI('factoids', inputWithLanguage, config) as { output: unknown; discoveredSources?: unknown };
10351035
const parsed = FactoidsOutputSchema.safeParse(data.output);
10361036
if (!parsed.success) {
10371037
throw new AIResponseError('AI response did not match the expected factoids schema.', {
@@ -1042,10 +1042,16 @@ export async function factoids(input: FactoidsInput, config: ProviderConfig): Pr
10421042
const allFactoids = parsed.data.factoids;
10431043
const verifiedFactoids = allFactoids.filter((factoid) => factoid.citation.verified === 'verified');
10441044
const returnedFactoids = verifiedFactoids.slice(0, input.sourceCount || 5);
1045+
const discovered = parseDiscoveredSources(data.discoveredSources);
1046+
await autosaveDiscoveredSources(discovered, 'factoids');
1047+
10451048
return {
1046-
...parsed.data,
1047-
factoids: returnedFactoids,
1048-
filteredOutCount: Math.max(0, allFactoids.length - returnedFactoids.length),
1049+
result: {
1050+
...parsed.data,
1051+
factoids: returnedFactoids,
1052+
filteredOutCount: Math.max(0, allFactoids.length - returnedFactoids.length),
1053+
},
1054+
discoveredSources: discovered,
10491055
};
10501056
}
10511057

‎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" className="motion-swap">
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: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -501,8 +501,49 @@ describe('AI client branch coverage', () => {
501501

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

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

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

847888
expect(attack.overallAssessment.score).toBeTypeOf('number');
848889
expect(ideas.nextSteps).toBeInstanceOf(Array);
849-
expect(facts.factoids).toBeInstanceOf(Array);
890+
expect(facts.result.factoids).toBeInstanceOf(Array);
850891
expect(chat.response).toContain('hello');
851892
expect(invokeMock).not.toHaveBeenCalled();
852893
}, 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+
});

‎src/test/pages/ConversationPages.test.tsx‎

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { beforeEach, describe, expect, it, vi } from 'vitest';
33
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
44
import { BrowserRouter } from 'react-router-dom';
5+
import type { FactoidsOutput } from '@/lib/ai/schemas/factoids';
56

67
vi.mock('@/hooks/use-subscription', () => ({
78
useSubscription: () => ({ isPro: false, tier: 'trial', loading: false }),
@@ -140,8 +141,9 @@ vi.mock('@/components/features/factoids/FactoidsForm', () => ({
140141
}));
141142

142143
vi.mock('@/components/features/factoids/FactoidsResults', () => ({
143-
FactoidsResults: ({ onDiscuss, onSaveSource }: { onDiscuss: () => void, onSaveSource: (t: string, c: string, u?: string) => void }) => (
144+
FactoidsResults: ({ onDiscuss, onSaveSource, discoveredSources }: { onDiscuss: () => void, onSaveSource: (t: string, c: string, u?: string) => void, discoveredSources?: unknown[] }) => (
144145
<div>
146+
<output data-testid="factoids-source-count">{discoveredSources?.length ?? 0}</output>
145147
<button type="button" onClick={() => void onDiscuss()} data-testid="factoids-discuss">factoids-discuss</button>
146148
<button type="button" onClick={() => onSaveSource('Fact source', 'Fact content', 'https://example.com')} data-testid="factoids-save">
147149
factoids-save
@@ -165,6 +167,9 @@ describe('Conversation Pages', () => {
165167
sessionState.brainstormLoading = false;
166168
sessionState.factoidsResult = null;
167169
sessionState.factoidsLoading = false;
170+
sessionState.setFactoidsResult.mockImplementation((result: any) => {
171+
sessionState.factoidsResult = result;
172+
});
168173

169174
subErrorState.handleSubscriptionError.mockReturnValue(false);
170175
mockIsTimeoutError.mockReturnValue(false);
@@ -580,7 +585,8 @@ describe('Conversation Pages', () => {
580585
});
581586

582587
it('FactoidsPage submits and stores factoid results', async () => {
583-
mockFactoids.mockResolvedValueOnce({ factoids: [], searchSuggestions: [], warnings: [], verificationStatus: 'complete' });
588+
const factoidsResult: FactoidsOutput = { factoids: [], searchSuggestions: [], warnings: [], verificationStatus: 'complete' };
589+
mockFactoids.mockResolvedValueOnce({ result: factoidsResult, discoveredSources: [{ title: 'Consulted source', url: 'https://example.com' }] });
584590

585591
render(
586592
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
@@ -595,10 +601,30 @@ describe('Conversation Pages', () => {
595601
});
596602

597603
expect(sessionState.setFactoidsLoading).toHaveBeenCalledWith(true);
598-
expect(sessionState.setFactoidsResult).toHaveBeenCalledWith({ factoids: [], searchSuggestions: [], warnings: [], verificationStatus: 'complete' });
604+
expect(sessionState.setFactoidsResult).toHaveBeenCalledWith(factoidsResult);
605+
expect(screen.getByTestId('factoids-source-count')).toHaveTextContent('1');
599606
expect(sessionState.setFactoidsLoading).toHaveBeenCalledWith(false);
600607
});
601608

609+
it('FactoidsPage clears and replaces consulted sources for each request', async () => {
610+
const factoidsResult: FactoidsOutput = { factoids: [], searchSuggestions: [], warnings: [], verificationStatus: 'complete' };
611+
mockFactoids
612+
.mockResolvedValueOnce({ result: factoidsResult, discoveredSources: [{ title: 'First source', url: 'https://example.com/first' }] })
613+
.mockResolvedValueOnce({ result: factoidsResult, discoveredSources: [] });
614+
615+
render(
616+
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
617+
<FactoidsPage />
618+
</BrowserRouter>
619+
);
620+
621+
fireEvent.click(screen.getByTestId('factoids-submit'));
622+
await waitFor(() => expect(screen.getByTestId('factoids-source-count')).toHaveTextContent('1'));
623+
624+
fireEvent.click(screen.getByTestId('factoids-submit'));
625+
await waitFor(() => expect(screen.getByTestId('factoids-source-count')).toHaveTextContent('0'));
626+
});
627+
602628
it('FactoidsPage discuss flow navigates and appends assistant response', async () => {
603629
sessionState.factoidsResult = {
604630
factoids: [{ shortClaim: 'Claim A', citation: { publisher: 'Source A' } }],

0 commit comments

Comments
 (0)