diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 72e5f9ba0..5ec39825f 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -17,13 +17,14 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp ## Layout -- `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; the form sheets `debates.tsx` (switcher), `new-debate.tsx`, `delete-account.tsx`, `add-link.tsx`, `new-note.tsx` and `source/[id].tsx`; and `ask.tsx`, `generate.tsx`, `case/[id].tsx`, `attack/index.tsx` and `attack/[id].tsx`, full screen above the tabs. All are declared in the root `_layout.tsx`. `(prepare)/coming/[tool].tsx` and `(practice)/soon/[tool].tsx` stand in for tools not in the app yet. +- `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; the form sheets `debates.tsx` (switcher), `new-debate.tsx`, `delete-account.tsx`, `add-link.tsx`, `new-note.tsx` and `source/[id].tsx`; and `ask.tsx`, `generate.tsx`, `case/[id].tsx`, `attack/`, `brainstorm/` and `factoids/` (each a form `index.tsx` and a document `[id].tsx`), full screen above the tabs. All are declared in the root `_layout.tsx`. `(prepare)/coming/[tool].tsx` (Flowchart) and `(practice)/soon/[tool].tsx` stand in for tools not in the app yet. - `src/components/`: shared UI. `app-tabs.tsx` uses native tabs; `app-tabs.web.tsx` draws the mock's tab bar for the web preview. - `src/lib/`: auth (`auth.tsx`), the Supabase client, session storage (AES key in SecureStore, ciphertext in AsyncStorage) and i18n. `debates.ts`, `settings.tsx`, `subscription.ts` and `account.ts` read and write the website's tables the way its stores do (`src/lib/stores/debate-store.ts`, `settings-store.ts`, `src/hooks/use-subscription.ts`, `src/lib/account.ts`), so the app and the website share one Current Debate and one set of settings. Change them together with the website code they mirror. - `sources.ts` mirrors the session store's `addSource` and `removeSource` and `src/lib/source-import.ts`. The Library always shows the Current Debate, also in one-off mode, as on the website. Adding a file (the website parses files in the browser) and Scan pages are not in the app yet. - Ask mirrors `src/lib/ai/streaming.ts` and `askChat` in `src/lib/ai/client.ts`: `ask.ts` sends the requests (plain questions stream from `ai-stream` through `expo/fetch`; with Citations, Web search, Pros/Cons or Argument map on, the whole answer comes from `ai`, because `ai-stream` refuses those options), `ai-request.ts` holds the shared POST with the website's headers, timeout and error kinds, `feature-config.ts` reads each tool's model from `user_settings.feature_configs`, `context-request.ts` builds the Current Debate context with the website's default selection and pins, and `chats.ts` saves finished exchanges to `chats` and `messages`. The SSE parser is shared in `packages/core/src/ai/sse.ts`. `markdown.ts` reads answers, because react-markdown needs the DOM. - Generate and Attack mirror `generateCase` and `attackCase` in `src/lib/ai/client.ts` and the session store's `recordResult`: `results.ts` sends the requests through `ai-request.ts`, checks the output with the website's transform (moved to `packages/core/src/ai/generate-output.ts`), keeps researched sources in the debate's Auto-saved folder (`packages/core/src/ai/discovered-sources.ts`), saves the server's source rankings onto the Library sources a result used (`packages/core/src/ai/source-assessments.ts`, also for Ask's whole answers), says when the server shortened the input, and saves rows to `cases` and `attacks`. `motion.ts` and `motion-gate.ts` are the website's motion gate. `open-results.ts` holds results made in this session, so one-off work and failed saves still open as documents. It and the last Generate and Attack forms live in `session-memory.ts`, which the auth provider clears on every account change, including sign-out. `components/document.tsx` draws results as documents (mock 03); Brainstorm and Factoids reuse it. Export renders `export-html.ts` to a PDF with expo-print and shares it (`export.ts`); the web preview cannot print, so `export.web.ts` keeps the HTML and copied text on `window` for the screenshot script. Word export and Google Docs are not in the app. -- One-off mode is session-only and never written, as on the website. The switcher turns it on with or without a Current Debate, and `OneOffNotice` shows it on the Debate tab, Prepare, Ask, Library, Generate and Attack (through `WorkingIn`). The Debate tab, Prepare and Library still list the Current Debate's work in one-off mode, as the website's home does. Students prepare both sides of a motion, so a debate has no side: the Debate tab's In favor / Against toggle only chooses which side's work it shows, using the website's rule (`'both'` counts for either side, neutral and side-less work for neither). It starts on In favor and is never stored. +- Brainstorm and Factoids mirror `brainstorm` and `factoids` in `src/lib/ai/client.ts` and their pages, through the same `results.ts`: the brainstorm transform and the verified-factoid selection live in `packages/core/src/ai/brainstorm-output.ts` and `factoids-output.ts`, rows go to `brainstorms` and `factoids`. Brainstorm has the motion gate; Factoids, like Attack, follows the Current Debate and saves with side `neutral`. A factoid's bookmark saves it to the Current Debate's `Factoids` folder, and a search suggestion reopens the Factoids form with that query. `prepareToolHref` in `components/prepare-tools.ts` is where both tabs open a tool. +- One-off mode is session-only and never written, as on the website. The switcher turns it on with or without a Current Debate, and `OneOffNotice` shows it on the Debate tab, Prepare, Ask, Library and the Generate, Attack, Brainstorm and Factoids forms (through `WorkingIn`). The Debate tab, Prepare and Library still list the Current Debate's work in one-off mode, as the website's home does. Students prepare both sides of a motion, so a debate has no side: the Debate tab's In favor / Against toggle only chooses which side's work it shows, using the website's rule (`'both'` counts for either side, neutral and side-less work for neither). It starts on In favor and is never stored. - `src/theme/`: colours from the mocks and the website's dark palette, Inter fonts. - `scripts/screenshots.ts`: web-export screenshots. `scripts/render-tab-icons.ts` redraws the tab bar PNGs. diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index c206daf5b..054026a2f 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -37,7 +37,8 @@ * for the "generating" shot, and answers case, attack and source writes * locally. The PDF export's HTML (the web export cannot run expo-print) is * rendered in a page of its own, and the "Copy as text" output is written - * next to the screenshots. + * next to the screenshots. Brainstorm and Factoids are shot the same way, + * from a fixed brainstorm and fixed factoids, and save nothing. */ import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page } from '@playwright/test'; import { createReadStream, existsSync, statSync } from 'node:fs'; @@ -318,16 +319,50 @@ const FAKE_ROW = (table: string) => ({ created_at: new Date().toISOString(), }); -/** Fakes Generate's and Attack's AI calls and saves for this page (see the header). `hold` keeps Generate waiting. */ -async function fakeResults(page: Page, hold: boolean) { +// A made-up brainstorm in the shape the model returns (Top 5), before the website's transform. +const FAKE_BRAINSTORM = { + needsMoreInfo: false, + topFive: [ + { rank: 1, whyTop: 'It turns a convenience debate into a health one.', idea: { id: 'a', title: 'Cleaner air for children', description: 'Traffic fumes peak at school gates; closing centres to cars cuts them where children walk.', side: 'pro', strength: 'strong', tradeoffs: ['Needs air-quality data'], relatedTo: [] } }, + { rank: 2, whyTop: 'Shops are the opposition’s strongest group.', idea: { id: 'b', title: 'Footfall helps local shops', description: 'People on foot stop more often than drivers passing through.', side: 'pro', strength: 'moderate', tradeoffs: ['Evidence varies by city size'], relatedTo: [] } }, + { rank: 3, whyTop: 'Pre-empts the fairness attack.', idea: { id: 'c', title: 'Who loses access', description: 'Carers, disabled drivers and night-shift workers rely on cars.', side: 'con', strength: 'strong', tradeoffs: [], relatedTo: [] } }, + { rank: 4, whyTop: 'A cheap, concrete mechanism.', idea: { id: 'd', title: 'Faster buses', description: 'Empty streets let buses keep to their timetable.', side: 'pro', strength: 'moderate', tradeoffs: [], relatedTo: [] } }, + { rank: 5, whyTop: 'Frames the debate on space, not cars.', idea: { id: 'e', title: 'Street space as a public good', description: 'Parking takes space that could be parks or cafés.', side: 'neutral', strength: 'weak', tradeoffs: ['Abstract for some judges'], relatedTo: [] } }, + ], + nextSteps: ['Find one city that closed its centre and measured the air', 'Prepare an answer on exemptions'], + warningsAndTradeoffs: ['The footfall evidence comes from large cities.'], +}; +// Made-up factoids: three verified, one unverified that the website's selection drops. +const FAKE_FACTOIDS = { + factoids: [ + { id: 'f1', shortClaim: 'Asthma admissions fell after a city centre closed to cars', keyFigure: '−12% in two years', context: 'A made-up city compared admissions before and after its car-free zone.', limitations: 'One city; other changes happened at the same time.', citation: { title: 'Clean Streets Review', publisher: 'Example Health Institute', date: '2024', url: 'https://example.org/clean-streets', verified: 'verified' }, howToUse: 'Lead with it in the first argument.', relevanceScore: 92 }, + { id: 'f2', shortClaim: 'Shops in pedestrian streets report more visits', keyFigure: '+18% footfall', context: 'Counts on three streets before and after closure.', limitations: 'Weekday counts only.', citation: { title: 'Footfall Study', publisher: 'Example Retail Council', date: '2023', url: 'https://example.org/footfall', verified: 'verified' }, howToUse: 'Answer the shopkeepers’ objection.', relevanceScore: 81 }, + { id: 'f3', shortClaim: 'Buses ran closer to their timetable', keyFigure: '4 minutes saved per trip', context: 'Average delay on routes through the centre.', limitations: 'Peak hours only.', citation: { title: 'Transit Report', publisher: 'Example Transport Board', url: 'https://example.org/transit-report', verified: 'verified' }, howToUse: 'Use it for the access argument.', relevanceScore: 74 }, + { id: 'f4', shortClaim: 'An unverified claim', keyFigure: '?', context: '', limitations: '', citation: { title: 'Unknown', publisher: 'Unknown', verified: 'unverified' }, howToUse: '', relevanceScore: 10 }, + ], + searchSuggestions: ['car-free city centre asthma study', 'pedestrian zone retail footfall'], + warnings: ['Most studies come from large European cities.'], + verificationStatus: 'complete', +}; + +type FakeFeature = 'generate' | 'brainstorm' | 'factoids'; + +/** Fakes the tools' AI calls and saves for this page (see the header). `hold` keeps that tool waiting. */ +async function fakeResults(page: Page, hold: FakeFeature | null) { + const outputs: Record = { + generate: { output: FAKE_CASE, discoveredSources: FAKE_SOURCES }, + attack: { output: FAKE_ATTACK, discoveredSources: [] }, + brainstorm: { output: FAKE_BRAINSTORM, discoveredSources: FAKE_SOURCES }, + factoids: { output: FAKE_FACTOIDS, discoveredSources: FAKE_SOURCES }, + }; await page.route(`https://${STAGING_HOST}/functions/v1/ai`, async (route) => { const feature = (route.request().postDataJSON() as { feature?: string } | null)?.feature; - if (feature === 'generate' && hold) return; // Never answered: the page shows its progress state. - if (feature === 'generate') return route.fulfill({ status: 200, headers: { 'access-control-allow-origin': '*' }, json: { output: FAKE_CASE, discoveredSources: FAKE_SOURCES } }); - if (feature === 'attack') return route.fulfill({ status: 200, headers: { 'access-control-allow-origin': '*' }, json: { output: FAKE_ATTACK, discoveredSources: [] } }); + if (feature && feature === hold) return; // Never answered: the page shows its progress state. + const json = feature ? outputs[feature] : undefined; + if (json) return route.fulfill({ status: 200, headers: { 'access-control-allow-origin': '*' }, json }); return route.abort(); }); - for (const table of ['cases', 'attacks', 'sources', 'source_folders']) { + for (const table of ['cases', 'attacks', 'brainstorms', 'factoids', 'sources', 'source_folders']) { await page.route(`${REST}/${table}*`, (route) => route.request().method() === 'POST' ? route.fulfill({ status: 201, json: FAKE_ROW(table) }) : route.continue(), ); @@ -341,7 +376,7 @@ async function shootResultScreens(context: BrowserContext, prefix: string, langu watchErrors(page, problems, `${prefix} results`); page.on('dialog', (dialog) => void dialog.dismiss()); await pinSettings(page, language); - await fakeResults(page, hold); + await fakeResults(page, hold ? 'generate' : null); return page; }; @@ -399,6 +434,67 @@ async function shootResultScreens(context: BrowserContext, prefix: string, langu await page.close(); } +const TOOLS = { + brainstorm: { field: 'brainstorm-motion', submit: /^(Start Brainstorming|Ötletelés Indítása|Comenzar Lluvia de Ideas)$/, document: 'brainstorm-document', working: 'brainstorming' }, + factoids: { field: 'factoids-claim', submit: /^(Find Factoids|Tények Keresése|Buscar Datos)$/, document: 'factoids-document', working: 'finding-factoids' }, +} as const; +const FACTOIDS_CLAIM = 'Car-free city centres cut asthma admissions'; + +/** Brainstorm or Factoids: the form, its progress, the document open and collapsed, and Export, from fixed outputs. */ +async function shootTool(context: BrowserContext, prefix: string, language: string, problems: string[], tool: keyof typeof TOOLS) { + const spec = TOOLS[tool]; + const open = async (hold: boolean) => { + const page = await context.newPage(); + watchErrors(page, problems, `${prefix} ${tool}`); + page.on('dialog', (dialog) => void dialog.dismiss()); + await pinSettings(page, language); + await fakeResults(page, hold ? tool : null); + await page.goto(`${ORIGIN}/prepare`); + await page.getByTestId(`prepare-tool-${tool}`).waitFor({ timeout: 30_000 }); + await settle(page); + await page.getByTestId(`prepare-tool-${tool}`).click(); + await page.getByTestId(spec.field).waitFor({ timeout: 30_000 }); + // Brainstorm takes the Current Debate's motion; Factoids research a claim the student types. + if (tool === 'brainstorm') await page.waitForFunction((id) => (document.querySelector(`[data-testid="${id}"]`) as HTMLTextAreaElement | null)?.value.trim(), spec.field); + else await page.getByTestId(spec.field).fill(FACTOIDS_CLAIM); + return page; + }; + + const waiting = await open(true); + await shoot(waiting, `${prefix}-${tool}-form`); + await waiting.getByRole('button', { name: spec.submit }).click(); + await waiting.getByTestId('tool-progress').waitFor({ timeout: 15_000 }); + await shoot(waiting, `${prefix}-${spec.working}`); + await waiting.close(); + + const page = await open(false); + await page.getByRole('button', { name: spec.submit }).click(); + await page.getByTestId(spec.document).waitFor({ timeout: 30_000 }); + await page.waitForTimeout(400); + await shoot(page, `${prefix}-${tool}`); + // Close the open item, so every item shows collapsed. + await page.getByTestId('document-item').first().click(); + await shoot(page, `${prefix}-${tool}-collapsed`); + await page.getByTestId('document-item').first().click(); + + await page.getByTestId('export-button').click(); + await page.getByTestId('export-menu').waitFor(); + await shoot(page, `${prefix}-${tool}-export-menu`); + await page.getByTestId('export-menu').getByRole('menuitem').first().click(); + await page.getByTestId('export-button').click(); + await page.getByTestId('export-menu').getByRole('menuitem').nth(1).click(); + const exported = await page.evaluate(() => (window as unknown as { __debatelabExport?: { html?: string; fileName?: string; copied?: string } }).__debatelabExport ?? {}); + if (!exported.html || !exported.copied) throw new Error(`${tool} export did not hand over the PDF HTML or the copied text`); + await writeFile(join(OUT, `${prefix}-${tool}-copy.txt`), exported.copied); + const pdf = await context.newPage(); + await pdf.setViewportSize({ width: 794, height: 1123 }); + await pdf.setContent(exported.html); + await pdf.screenshot({ path: join(OUT, `${prefix}-${tool}-pdf-html.png`), fullPage: true }); + await pdf.close(); + console.log(`${prefix} ${tool}: PDF file name ${exported.fileName}, copied ${exported.copied.length} characters`); + await page.close(); +} + async function shootLibraryScreens(page: Page, prefix: string) { await page.goto(`${ORIGIN}/library`); await page.getByTestId('library-add').waitFor({ timeout: 30_000 }); @@ -477,6 +573,13 @@ async function shootOneOffTools(page: Page, prefix: string) { await page.getByTestId('one-off-notice').filter({ visible: true }).waitFor(); await settle(page); await shoot(page, `${prefix}-prepare-oneoff`); + // Brainstorm and Factoids say it through the same strip; a reload would end one-off mode, so go back in the app. + for (const tool of ['brainstorm', 'factoids'] as const) { + await page.getByTestId(`prepare-tool-${tool}`).click(); + await page.getByTestId(`${tool}-screen`).getByTestId('one-off-notice').waitFor({ timeout: 30_000 }); + await page.goBack(); + await page.getByTestId('prepare-tool-generate').filter({ visible: true }).waitFor(); + } await page.getByTestId('prepare-tool-generate').click(); await page.getByTestId('generate-motion').waitFor({ timeout: 30_000 }); const notice = page.getByTestId('generate-screen').getByTestId('one-off-notice'); @@ -644,6 +747,10 @@ async function main() { shots += 3; await shootResultScreens(signedIn, prefix, language, problems); shots += 9; + for (const tool of ['brainstorm', 'factoids'] as const) { + await shootTool(signedIn, prefix, language, problems, tool); + shots += 6; + } const oneOffPage = await signedIn.newPage(); watchErrors(oneOffPage, problems, `${prefix} one-off tools`); await pinSettings(oneOffPage, language); diff --git a/apps/mobile/src/app/(tabs)/(debate)/index.tsx b/apps/mobile/src/app/(tabs)/(debate)/index.tsx index 95eec2b0a..e53a03cd7 100644 --- a/apps/mobile/src/app/(tabs)/(debate)/index.tsx +++ b/apps/mobile/src/app/(tabs)/(debate)/index.tsx @@ -9,7 +9,7 @@ import { Card, IconTile } from '@/components/card'; import { Icon } from '@/components/icon'; import type { IconName } from '@/components/icon-paths'; import { OneOffNotice } from '@/components/one-off-notice'; -import { PREPARE_TOOLS } from '@/components/prepare-tools'; +import { PREPARE_TOOLS, prepareToolHref } from '@/components/prepare-tools'; import { Screen } from '@/components/screen'; import { SegmentedControl } from '@/components/segmented-control'; import { Text } from '@/components/text'; @@ -194,10 +194,7 @@ function DebateDetails({ debateId }: { debateId: string }) { key={tool.key} icon={tool.icon} label={t(`nav.${tool.key}`)} - onPress={() => - tool.key === 'generate' - ? router.push({ pathname: '/generate', params: { from: 'debate' } }) - : router.push({ pathname: '/coming/[tool]', params: { tool: tool.key } })} + onPress={() => router.push(prepareToolHref(tool.key, 'debate'))} /> ))} diff --git a/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx b/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx index 5885cfe17..625c6f5fa 100644 --- a/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx +++ b/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx @@ -7,7 +7,7 @@ import { TextButton } from '@/components/buttons'; import { Card, IconTile } from '@/components/card'; import { Icon } from '@/components/icon'; import { OneOffNotice } from '@/components/one-off-notice'; -import { PREPARE_TOOLS, type PrepareTool } from '@/components/prepare-tools'; +import { PREPARE_TOOLS, prepareToolHref, type PrepareTool } from '@/components/prepare-tools'; import { Screen } from '@/components/screen'; import { ScreenTitle } from '@/components/screen-title'; import { Text } from '@/components/text'; @@ -34,8 +34,7 @@ export default function PrepareScreen() { const items = loaded?.debateId === debateId ? loaded.items : null; const failed = loaded?.debateId === debateId && loaded.failed; - const openTool = (tool: PrepareTool) => - tool === 'generate' ? router.push({ pathname: '/generate', params: { from: 'prepare' } }) : router.push({ pathname: '/coming/[tool]', params: { tool } }); + const openTool = (tool: PrepareTool) => router.push(prepareToolHref(tool, 'prepare')); // Every load, from focus or Retry, takes a number; only the newest may write, so a late Retry for another debate never replaces this one's. const loads = useRef(0); diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 289e641ee..29815cd40 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -81,11 +81,15 @@ function Root() { {/* Full screen above the tabs, as in mock 07. */} - {/* Generate, Attack and their documents, full screen above the tabs, as in mock 03. */} + {/* Generate, Attack, Brainstorm, Factoids and their documents, full screen above the tabs, as in mock 03. */} + + + + diff --git a/apps/mobile/src/app/brainstorm/[id].tsx b/apps/mobile/src/app/brainstorm/[id].tsx new file mode 100644 index 000000000..980bac6a5 --- /dev/null +++ b/apps/mobile/src/app/brainstorm/[id].tsx @@ -0,0 +1,112 @@ +import type { BrainstormInput } from '@debatelab/core/ai/schemas/brainstorm'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { ActionMenu } from '@/components/action-menu'; +import { BrainstormDocument } from '@/components/brainstorm-document'; +import { PrimaryButton, SecondaryButton } from '@/components/buttons'; +import { DocumentScreen, DocumentTitle } from '@/components/document'; +import { DocumentHeader, type HeaderAction } from '@/components/document-header'; +import { DocumentStatus } from '@/components/document-status'; +import { backLabel } from '@/lib/back-label'; +import { formatWhen } from '@/lib/dates'; +import { copyText, sharePdf, shareText } from '@/lib/export'; +import { BRAINSTORM_EXPORT_FILE_NAME, brainstormExportHtml } from '@/lib/export-html'; +import { brainstormToText, discussBrainstormMessage } from '@/lib/result-text'; +import { readBrainstorm } from '@/lib/results'; +import { arrivalNoticeText, useResultDocument } from '@/lib/use-result-document'; + +/** + * A brainstorm read as a document. Its pinned next steps are the website's: + * Discuss, which takes the ideas to Ask, and Export. + */ +export default function BrainstormResultScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const params = useLocalSearchParams<{ id: string; from?: string; notice?: string; failed?: string }>(); + const doc = useResultDocument('brainstorm', params.id); + const [exportOpen, setExportOpen] = useState(false); + const { setNotice } = doc; + const { notice: arrival, failed } = params; + + // Problems from saving a fresh result are told once, on arrival. + useEffect(() => { + const text = arrivalNoticeText(t, arrival, failed); + if (text) setNotice(text); + }, [arrival, failed, setNotice, t]); + + const result = doc.state.status === 'ready' ? doc.state.result : null; + // Saved rows are read through the schema, so an old or broken one shows the notice below instead of crashing. + const raw = result?.output; + const output = useMemo(() => (raw === undefined ? null : readBrainstorm(raw)), [raw]); + const back = backLabel(t, params.from, 'prepare'); + + if (!result || !output) { + return ( + }> + + + ); + } + + const title = result.title || t('export.brainstormResults'); + const questionsOnly = Boolean(output.needsMoreInfo && output.clarifyingQuestions?.length); + const run = (work: () => Promise) => { + work().catch((cause: unknown) => { + console.warn('Export failed:', cause instanceof Error ? cause.message : cause); + setNotice(t('mobile.results.exportFailed')); + }); + }; + + const actions: HeaderAction[] = [ + ...(doc.autoSaved.length > 0 + ? [{ key: 'undo-sources', label: t('autoSavedSources.undo'), symbol: 'arrow.uturn.backward' as const, icon: 'history' as const, onPress: () => void doc.undoAutoSaved() }] + : []), + ...(result.savedId ? [{ key: 'delete', label: t('common.delete'), symbol: 'trash' as const, icon: 'trash' as const, destructive: true, onPress: () => void doc.remove() }] : []), + ]; + + return ( + run(() => shareText(title, brainstormToText(output, t)))} actions={actions} />} + // The website shows only the questions when the model needs more, with no export or discussion. + actions={questionsOnly ? undefined : ( + <> + router.push({ pathname: '/ask', params: { prompt: discussBrainstormMessage(output, t) } })} + testID="discuss-button" + /> + setExportOpen(true)} disabled={doc.busy} testID="export-button" /> + + )}> + + } sources={result.sources} /> + setExportOpen(false)} + onSelect={(choice) => { + if (choice === 'pdf') run(() => sharePdf(brainstormExportHtml(output, t), BRAINSTORM_EXPORT_FILE_NAME, title)); + if (choice === 'copy') run(async () => { + await copyText(brainstormToText(output, t)); + setNotice(t('generateResults.copiedToClipboard')); + }); + }} + /> + + ); +} diff --git a/apps/mobile/src/app/brainstorm/index.tsx b/apps/mobile/src/app/brainstorm/index.tsx new file mode 100644 index 000000000..ea2db2207 --- /dev/null +++ b/apps/mobile/src/app/brainstorm/index.tsx @@ -0,0 +1,274 @@ +import type { BrainstormInput } from '@debatelab/core/ai/schemas/brainstorm'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet } from 'react-native'; + +import { MainActionButton } from '@/components/buttons'; +import { DocumentScreen } from '@/components/document'; +import { DocumentHeader } from '@/components/document-header'; +import { Field } from '@/components/field'; +import { FormGroup, SelectRow } from '@/components/form-rows'; +import { Text } from '@/components/text'; +import { ToolError, ToolProgress, ToolTitle, WorkingIn } from '@/components/tool-form'; +import { AIError, isAbortError, type AIErrorKind } from '@/lib/ai-request'; +import { useAuth } from '@/lib/auth'; +import { backLabel } from '@/lib/back-label'; +import { buildDebateContext } from '@/lib/context-request'; +import { useDebates } from '@/lib/debate-context'; +import { fetchFeatureConfig } from '@/lib/feature-config'; +import { decideMotionTarget, shouldPrefillMotion } from '@/lib/motion'; +import { chooseMotionTarget } from '@/lib/motion-gate'; +import { keepOpenResult } from '@/lib/open-results'; +import { arrivalNotices, autosaveSources, brainstormIdeas, keepSourceAssessments, recordResult } from '@/lib/results'; +import { sessionSlot } from '@/lib/session-memory'; +import { useSettings } from '@/lib/settings'; +import { useTheme } from '@/theme/use-theme'; + +// The website's defaults (BrainstormForm). +const DEFAULT_FORM: BrainstormInput = { motion: '', side: 'both', context: '', outputMode: 'topfive' }; + +// The last form stays for the session, as on the website; session memory forgets it when the account changes. +const lastForm = sessionSlot(() => DEFAULT_FORM); + +const PROGRESS = ['brainstormForm.loadingMsg1', 'brainstormForm.loadingMsg2', 'brainstormForm.loadingMsg3', 'brainstormForm.loadingMsg4', 'brainstormForm.loadingMsg5']; + +/** Brainstorm, as the website's BrainstormPage: a native form, a calm wait, then the ideas as a document. */ +export default function BrainstormScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const { from } = useLocalSearchParams<{ from?: string }>(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const { debates, current, oneOff, create, switchTo, refresh } = useDebates(); + const { outputLanguage } = useSettings(); + const { colors } = useTheme(); + const [form, setFormState] = useState(lastForm.get); + const [motionError, setMotionError] = useState(false); + const [phase, setPhase] = useState<'form' | 'working'>('form'); + const [error, setError] = useState(null); + const requests = useRef(0); + const abort = useRef(null); + const live = useRef(true); + const owner = useRef(userId); + + const setForm = useCallback((patch: Partial) => + setFormState((previous) => { + const next = { ...previous, ...patch }; + lastForm.set(next); + return next; + }), []); + + const abortActive = useCallback(() => { + requests.current += 1; + abort.current?.abort(); + abort.current = null; + }, []); + + useEffect(() => () => { + live.current = false; + abortActive(); + }, [abortActive]); + + // Another account signing in cancels this one's request and drops its form. + useEffect(() => { + if (owner.current === userId) return; + owner.current = userId; + abortActive(); + setPhase('form'); + setError(null); + setFormState(lastForm.get()); + }, [abortActive, userId]); + + // The motion follows the Current Debate unless the student typed their own. + useEffect(() => { + if (oneOff || !current) return; + if (shouldPrefillMotion(lastForm.get().motion, current.motion, debates.map((debate) => debate.motion))) setForm({ motion: current.motion }); + }, [current, debates, oneOff, setForm, userId]); + + const start = async (input: BrainstormInput, debateId: string | null, forUser: string) => { + abortActive(); + const request = requests.current; + const isCurrent = () => requests.current === request && live.current && owner.current === forUser; + const controller = new AbortController(); + abort.current = controller; + setError(null); + setPhase('working'); + try { + const [config, debateContext] = await Promise.all([ + fetchFeatureConfig(forUser, 'brainstorm'), + debateId ? buildDebateContext(forUser, debateId, 'brainstorm', input.side) : Promise.resolve(undefined), + ]); + if (!isCurrent()) return; + const { result, discoveredSources, truncated, assessments } = await brainstormIdeas(input, { config, language: outputLanguage, debateContext, signal: controller.signal }); + if (!isCurrent()) return; + const assessed = await keepSourceAssessments(forUser, debateId, assessments, controller.signal); + if (!isCurrent()) return; + const sources = await autosaveSources(forUser, discoveredSources, 'brainstorm', debateId, controller.signal); + if (!isCurrent()) return; + let savedId: string | null = null; + let recordFailed = false; + if (debateId) { + try { + savedId = await recordResult(forUser, 'brainstorm', { title: input.motion, input, output: result, debateId, side: input.side }); + } catch (cause) { + console.warn('Could not save the brainstorm to the debate:', cause instanceof Error ? cause.message : cause); + recordFailed = true; + } + } + if (!isCurrent()) return; + const key = keepOpenResult({ + kind: 'brainstorm', + savedId, + title: input.motion, + side: input.side, + debateId, + createdAt: Date.now(), + input, + output: result, + sources: discoveredSources, + }); + const notice = arrivalNotices({ recordFailed, sourcesFailed: sources.failed, assessed, truncated }); + router.replace({ + pathname: '/brainstorm/[id]', + params: { + id: key, + from: from ?? 'prepare', + ...(notice ? { notice } : {}), + ...(sources.failed > 0 ? { failed: String(sources.failed) } : {}), + }, + }); + } catch (cause) { + if (!isCurrent() || isAbortError(cause)) return; + const kind = cause instanceof AIError ? cause.kind : 'failed'; + if (!(cause instanceof AIError)) console.warn('Brainstorm failed:', cause instanceof Error ? cause.message : cause); + // A missing debate was deleted elsewhere; reload the list so the app stops using it. + if (kind === 'debateMissing') void refresh(); + setError(kind); + setPhase('form'); + } finally { + if (requests.current === request) abort.current = null; + } + }; + + const submit = async () => { + if (!userId) return; + const input = { ...form, motion: form.motion.trim() }; + if (!input.motion) { + setMotionError(true); + return; + } + const forUser = userId; + // Where the brainstorm belongs is settled before anything is sent, as the website's motion gate does. + const decision = decideMotionTarget(input.motion, { debates, current, oneOff }); + if (decision.kind !== 'ask') { + void start(input, decision.kind === 'attach' ? decision.debateId : null, forUser); + return; + } + const choice = await chooseMotionTarget(decision.matches, { + title: current ? t('debate.mismatchTitle') : t('debate.offerTitle'), + message: `“${input.motion}”\n\n${current ? t('debate.mismatchDesc', { motion: current.motion }) : t('debate.offerDesc')}`, + switchLabel: t('debate.switchToExisting'), + createLabel: current ? t('debate.createFromMotion') : t('debate.startDebate'), + onceLabel: current ? t('debate.useOnce') : t('debate.offerNotNow'), + cancel: t('common.cancel'), + }); + if (!choice || !live.current || owner.current !== forUser) return; + let debateId: string | null = null; + try { + if (choice.kind === 'switch') { + await switchTo(choice.debateId); + debateId = choice.debateId; + } else if (choice.kind === 'create') { + debateId = (await create({ motion: input.motion, settings: {} })).id; + } + } catch (cause) { + console.warn('Could not save the debate:', cause instanceof Error ? cause.message : cause); + if (live.current) setError('debate'); + return; + } + if (live.current && owner.current === forUser) void start(input, debateId, forUser); + }; + + const cancel = () => { + abortActive(); + setPhase('form'); + }; + + const working = phase === 'working'; + + return ( + } + actions={working ? undefined : void submit()} />}> + + + {working ? ( + t(key))} detail={form.motion} onCancel={cancel} /> + ) : ( + <> + { + setForm({ motion }); + if (motion.trim()) setMotionError(false); + }} + placeholder={t('brainstormForm.motionPlaceholder')} + multiline + invalid={motionError} + style={styles.motion} + testID="brainstorm-motion" + /> + {motionError ? ( + + {t('debate.motionRequired')} + + ) : null} + + + first + label={t('brainstormForm.side')} + value={form.side} + onChange={(side) => setForm({ side })} + options={[ + { value: 'both', label: t('brainstormForm.sideBoth') }, + { value: 'pro', label: t('brainstormForm.sidePro') }, + { value: 'con', label: t('brainstormForm.sideCon') }, + ]} + testID="brainstorm-side" + /> + + label={t('brainstormForm.outputFormat')} + value={form.outputMode} + onChange={(outputMode) => setForm({ outputMode })} + options={[ + { value: 'topfive', label: t('brainstormForm.outputTop5') }, + { value: 'mindmap', label: t('brainstormForm.outputMindmap') }, + { value: 'buckets', label: t('brainstormForm.outputBuckets') }, + ]} + testID="brainstorm-output" + /> + + setForm({ context })} + placeholder={t('brainstormForm.existingPlaceholder')} + multiline + style={styles.context} + testID="brainstorm-context" + /> + {error ? void submit()} /> : null} + + )} + + ); +} + +const styles = StyleSheet.create({ + motion: { minHeight: 92, paddingTop: 13, paddingBottom: 13, textAlignVertical: 'top' }, + context: { minHeight: 100, paddingTop: 13, paddingBottom: 13, textAlignVertical: 'top' }, + invalid: { fontSize: 14, marginTop: -6, marginLeft: 4 }, +}); diff --git a/apps/mobile/src/app/factoids/[id].tsx b/apps/mobile/src/app/factoids/[id].tsx new file mode 100644 index 000000000..9ccf523e7 --- /dev/null +++ b/apps/mobile/src/app/factoids/[id].tsx @@ -0,0 +1,167 @@ +import type { Factoid, FactoidsInput } from '@debatelab/core/ai/schemas/factoids'; +import { SafeWebUrlSchema } from '@debatelab/core/ai/schemas/url'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { ActionMenu } from '@/components/action-menu'; +import { PrimaryButton, SecondaryButton } from '@/components/buttons'; +import { openCitation } from '@/components/citation-target'; +import { DocumentScreen, DocumentTitle } from '@/components/document'; +import { DocumentHeader, type HeaderAction } from '@/components/document-header'; +import { DocumentStatus } from '@/components/document-status'; +import { FactoidsDocument } from '@/components/factoids-document'; +import { useAuth } from '@/lib/auth'; +import { backLabel } from '@/lib/back-label'; +import { formatWhen } from '@/lib/dates'; +import { useDebates } from '@/lib/debate-context'; +import { copyText, sharePdf, shareText } from '@/lib/export'; +import { FACTOIDS_EXPORT_FILE_NAME, factoidsExportHtml } from '@/lib/export-html'; +import { discussFactoidsMessage, factoidsToText } from '@/lib/result-text'; +import { readFactoids } from '@/lib/results'; +import { addSource } from '@/lib/sources'; +import { arrivalNoticeText, useResultDocument } from '@/lib/use-result-document'; + +/** + * Factoids read as a document. Each factoid's source opens from its + * citation chip, and can be kept in the Current Debate's Library, as the + * website's bookmark button does. The pinned next steps are the website's: + * Discuss and Export. + */ +export default function FactoidsResultScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const params = useLocalSearchParams<{ id: string; from?: string; notice?: string; failed?: string }>(); + const doc = useResultDocument('factoids', params.id); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const { activeDebateId } = useDebates(); + const [exportOpen, setExportOpen] = useState(false); + const [saving, setSaving] = useState(false); + const live = useRef(true); + const owner = useRef(userId); + const { setNotice } = doc; + const { notice: arrival, failed } = params; + + useEffect(() => { + owner.current = userId; + }, [userId]); + useEffect(() => () => { + live.current = false; + }, []); + + useEffect(() => { + const text = arrivalNoticeText(t, arrival, failed); + if (text) setNotice(text); + }, [arrival, failed, setNotice, t]); + + const result = doc.state.status === 'ready' ? doc.state.result : null; + // Saved rows are read through the schema, so an old or broken one shows the notice below instead of crashing. + const raw = result?.output; + const output = useMemo(() => (raw === undefined ? null : readFactoids(raw)), [raw]); + const back = backLabel(t, params.from, 'prepare'); + + if (!result || !output) { + return ( + }> + + + ); + } + + const title = result.title || t('export.researchFactoids'); + const run = (work: () => Promise) => { + work().catch((cause: unknown) => { + console.warn('Export failed:', cause instanceof Error ? cause.message : cause); + setNotice(t('mobile.results.exportFailed')); + }); + }; + + /** The website's handleSaveToLibrary: into the Current Debate, read when the student taps. */ + const saveToLibrary = async (factoid: Factoid) => { + if (!userId) return; + const forUser = userId; + const debateId = activeDebateId; + if (!debateId) { + setNotice(t('debate.sourceNeedsDebate')); + return; + } + const url = SafeWebUrlSchema.safeParse(factoid.citation?.url); + setSaving(true); + try { + await addSource(forUser, { + title: factoid.shortClaim, + content: `${factoid.context}\n\nKey Figure: ${factoid.keyFigure}\nLimitations: ${factoid.limitations}`, + type: 'url', + origin: url.success ? url.data : undefined, + folder: 'Factoids', + debateId, + }); + if (live.current && owner.current === forUser) setNotice(`${t('factoids.savedToLibrary')}. ${t('factoids.savedToLibraryDesc')}`); + } catch (cause) { + console.warn('Could not save the factoid to the Library:', cause instanceof Error ? cause.message : cause); + if (live.current && owner.current === forUser) setNotice(t('factoids.saveFailed')); + } finally { + if (live.current) setSaving(false); + } + }; + + const actions: HeaderAction[] = [ + ...(doc.autoSaved.length > 0 + ? [{ key: 'undo-sources', label: t('autoSavedSources.undo'), symbol: 'arrow.uturn.backward' as const, icon: 'history' as const, onPress: () => void doc.undoAutoSaved() }] + : []), + ...(result.savedId ? [{ key: 'delete', label: t('common.delete'), symbol: 'trash' as const, icon: 'trash' as const, destructive: true, onPress: () => void doc.remove() }] : []), + ]; + + return ( + run(() => shareText(title, factoidsToText(output, t)))} actions={actions} />} + actions={ + <> + router.push({ pathname: '/ask', params: { prompt: discussFactoidsMessage(output) } })} + testID="discuss-button" + /> + setExportOpen(true)} disabled={doc.busy} testID="export-button" /> + + }> + + } + library={doc.library} + sources={result.sources} + saving={saving} + onCite={(target) => openCitation(router, target)} + onSave={(factoid) => void saveToLibrary(factoid)} + onSearch={(query) => router.push({ pathname: '/factoids', params: { claim: query, from: params.from ?? 'prepare' } })} + /> + setExportOpen(false)} + onSelect={(choice) => { + if (choice === 'pdf') run(() => sharePdf(factoidsExportHtml(output, t), FACTOIDS_EXPORT_FILE_NAME, title)); + if (choice === 'copy') run(async () => { + await copyText(factoidsToText(output, t)); + setNotice(t('generateResults.copiedToClipboard')); + }); + }} + /> + + ); +} diff --git a/apps/mobile/src/app/factoids/index.tsx b/apps/mobile/src/app/factoids/index.tsx new file mode 100644 index 000000000..a4f918a90 --- /dev/null +++ b/apps/mobile/src/app/factoids/index.tsx @@ -0,0 +1,269 @@ +import type { FactoidsInput } from '@debatelab/core/ai/schemas/factoids'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { MainActionButton } from '@/components/buttons'; +import { DocumentScreen } from '@/components/document'; +import { DocumentHeader } from '@/components/document-header'; +import { Field } from '@/components/field'; +import { FormGroup, SelectRow } from '@/components/form-rows'; +import { SegmentedControl } from '@/components/segmented-control'; +import { Text } from '@/components/text'; +import { ToolError, ToolProgress, ToolTitle, WorkingIn } from '@/components/tool-form'; +import { AIError, isAbortError, type AIErrorKind } from '@/lib/ai-request'; +import { useAuth } from '@/lib/auth'; +import { backLabel } from '@/lib/back-label'; +import { buildDebateContext } from '@/lib/context-request'; +import { useDebates } from '@/lib/debate-context'; +import { fetchFeatureConfig } from '@/lib/feature-config'; +import { keepOpenResult } from '@/lib/open-results'; +import { arrivalNotices, autosaveSources, findFactoids, keepSourceAssessments, recordResult } from '@/lib/results'; +import { sessionSlot } from '@/lib/session-memory'; +import { useSettings } from '@/lib/settings'; +import { useTheme } from '@/theme/use-theme'; + +// The website's defaults (FactoidsForm). +const DEFAULT_FORM: FactoidsInput = { claim: '', direction: 'for', region: '', timeframe: '', sourceType: 'any', sourceCount: 5 }; +const SOURCE_COUNTS = Array.from({ length: 13 }, (_, index) => String(index + 3)); + +// The last form stays for the session, as on the website; session memory forgets it when the account changes. +const lastForm = sessionSlot(() => DEFAULT_FORM); + +const PROGRESS = ['factoidsForm.loadingMsg1', 'factoidsForm.loadingMsg2', 'factoidsForm.loadingMsg3', 'factoidsForm.loadingMsg4', 'factoidsForm.loadingMsg5']; + +/** + * Factoids, as the website's FactoidsPage. Factoids research a claim, not + * the motion, so there is no motion gate: the result follows the Current + * Debate, and one-off work is not saved. A search suggestion from a result + * opens here with its query as the claim and runs once. + */ +export default function FactoidsScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const { from, claim: suggested } = useLocalSearchParams<{ from?: string; claim?: string }>(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const { activeDebateId, refresh } = useDebates(); + const { outputLanguage } = useSettings(); + const { colors } = useTheme(); + const [form, setFormState] = useState(lastForm.get); + const [claimError, setClaimError] = useState(false); + const [phase, setPhase] = useState<'form' | 'working'>('form'); + const [error, setError] = useState(null); + const requests = useRef(0); + const abort = useRef(null); + const live = useRef(true); + const owner = useRef(userId); + const ranSuggestion = useRef(false); + + const setForm = useCallback((patch: Partial) => + setFormState((previous) => { + const next = { ...previous, ...patch }; + lastForm.set(next); + return next; + }), []); + + const abortActive = useCallback(() => { + requests.current += 1; + abort.current?.abort(); + abort.current = null; + }, []); + + useEffect(() => () => { + live.current = false; + abortActive(); + }, [abortActive]); + + // Another account signing in cancels this one's request and drops its form. + useEffect(() => { + if (owner.current === userId) return; + owner.current = userId; + abortActive(); + setPhase('form'); + setError(null); + setFormState(lastForm.get()); + }, [abortActive, userId]); + + const submit = async (input: FactoidsInput) => { + if (!userId) return; + if (!input.claim.trim()) { + setClaimError(true); + return; + } + const forUser = userId; + // Read the Current Debate when the student asks; one-off work belongs to none. + const debateId = activeDebateId; + abortActive(); + const request = requests.current; + const isCurrent = () => requests.current === request && live.current && owner.current === forUser; + const controller = new AbortController(); + abort.current = controller; + setError(null); + setPhase('working'); + try { + const [config, debateContext] = await Promise.all([ + fetchFeatureConfig(forUser, 'factoids'), + debateId ? buildDebateContext(forUser, debateId, 'factoids', input.direction === 'against' ? 'con' : 'pro') : Promise.resolve(undefined), + ]); + if (!isCurrent()) return; + const { result, discoveredSources, truncated, assessments } = await findFactoids(input, { config, language: outputLanguage, debateContext, signal: controller.signal }); + if (!isCurrent()) return; + const assessed = await keepSourceAssessments(forUser, debateId, assessments, controller.signal); + if (!isCurrent()) return; + const sources = await autosaveSources(forUser, discoveredSources, 'factoids', debateId, controller.signal); + if (!isCurrent()) return; + let savedId: string | null = null; + let recordFailed = false; + if (debateId) { + try { + savedId = await recordResult(forUser, 'factoids', { title: input.claim, input, output: result, debateId, side: 'neutral' }); + } catch (cause) { + console.warn('Could not save the factoids to the debate:', cause instanceof Error ? cause.message : cause); + recordFailed = true; + } + } + if (!isCurrent()) return; + const key = keepOpenResult({ + kind: 'factoids', + savedId, + title: input.claim, + side: 'neutral', + debateId, + createdAt: Date.now(), + input, + output: result, + sources: discoveredSources, + }); + const notice = arrivalNotices({ recordFailed, sourcesFailed: sources.failed, assessed, truncated }); + router.replace({ + pathname: '/factoids/[id]', + params: { + id: key, + from: from ?? 'prepare', + ...(notice ? { notice } : {}), + ...(sources.failed > 0 ? { failed: String(sources.failed) } : {}), + }, + }); + } catch (cause) { + if (!isCurrent() || isAbortError(cause)) return; + const kind = cause instanceof AIError ? cause.kind : 'failed'; + if (!(cause instanceof AIError)) console.warn('Factoids failed:', cause instanceof Error ? cause.message : cause); + if (kind === 'debateMissing') void refresh(); + setError(kind); + setPhase('form'); + } finally { + if (requests.current === request) abort.current = null; + } + }; + + // A suggestion searches its query with the rest of the last form, once, as the website's handleUseSuggestion. + useEffect(() => { + if (!suggested?.trim() || ranSuggestion.current || !userId) return; + ranSuggestion.current = true; + const input = { ...lastForm.get(), claim: suggested.trim() }; + setForm({ claim: input.claim }); + void submit(input); + // submit reads the latest debate and account itself; the suggestion runs only on arrival. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [suggested, userId]); + + const cancel = () => { + abortActive(); + setPhase('form'); + }; + + const working = phase === 'working'; + + return ( + } + actions={working ? undefined : void submit({ ...form, claim: form.claim.trim() })} />}> + + + {working ? ( + t(key))} detail={form.claim} onCancel={cancel} /> + ) : ( + <> + { + setForm({ claim }); + if (claim.trim()) setClaimError(false); + }} + placeholder={t('factoidsForm.claimPlaceholder')} + multiline + invalid={claimError} + style={styles.claim} + testID="factoids-claim" + /> + {claimError ? ( + + {t('mobile.factoids.claimRequired')} + + ) : null} + + + {t('factoidsForm.direction')} + + + options={[ + { value: 'for', label: t('factoidsForm.directionFor') }, + { value: 'against', label: t('factoidsForm.directionAgainst') }, + ]} + value={form.direction} + onChange={(direction) => setForm({ direction })} + /> + + + + first + label={t('factoidsForm.sourceType')} + value={form.sourceType} + onChange={(sourceType) => setForm({ sourceType })} + options={[ + { value: 'any', label: t('factoidsForm.sourceAny') }, + { value: 'academic', label: t('factoidsForm.sourceAcademic') }, + { value: 'government', label: t('factoidsForm.sourceGovernment') }, + { value: 'news', label: t('factoidsForm.sourceNews') }, + ]} + testID="factoids-source-type" + /> + setForm({ sourceCount: Number(count) })} + options={SOURCE_COUNTS.map((count) => ({ value: count, label: count }))} + testID="factoids-source-count" + /> + + setForm({ region })} + placeholder={t('factoidsForm.regionPlaceholder')} + testID="factoids-region" + /> + setForm({ timeframe })} + placeholder={t('factoidsForm.timeframePlaceholder')} + testID="factoids-timeframe" + /> + {error ? void submit({ ...form, claim: form.claim.trim() })} /> : null} + + )} + + ); +} + +const styles = StyleSheet.create({ + claim: { minHeight: 72, paddingTop: 13, paddingBottom: 13, textAlignVertical: 'top' }, + invalid: { fontSize: 14, marginTop: -6, marginLeft: 4 }, + side: { gap: 7 }, + label: { fontSize: 14, marginLeft: 4 }, +}); diff --git a/apps/mobile/src/components/brainstorm-document.tsx b/apps/mobile/src/components/brainstorm-document.tsx new file mode 100644 index 000000000..dd81835f9 --- /dev/null +++ b/apps/mobile/src/components/brainstorm-document.tsx @@ -0,0 +1,167 @@ +import type { DiscoveredSource } from '@debatelab/core/ai/discovered-sources'; +import type { BrainstormInput, BrainstormOutput, Idea } from '@debatelab/core/ai/schemas/brainstorm'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { ResearchedSources } from './case-document'; +import { Bullets, DocSection, FramingCard, Labeled, NumberedItems, Paragraph, SectionHeading, Tag, type NumberedItem } from './document'; +import { Text } from './text'; + +import { useTheme } from '@/theme/use-theme'; + +const STRENGTHS = ['strong', 'moderate', 'weak'] as const; +const SIDES = ['pro', 'con', 'neutral'] as const; + +/** An idea's side and strength, with the website's defaults for old or broken rows. */ +function ideaLabels(idea: Partial | undefined) { + const side = SIDES.find((value) => value === idea?.side) ?? 'neutral'; + const strength = STRENGTHS.find((value) => value === idea?.strength) ?? 'moderate'; + return { side, strength }; +} + +function IdeaBody({ idea, whyTop }: { idea: Partial | undefined; whyTop?: string }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const { side, strength } = ideaLabels(idea); + const sideColor = { pro: colors.primary, con: colors.destructive, neutral: colors.secondary }[side]; + const tradeoffs = Array.isArray(idea?.tradeoffs) ? idea.tradeoffs : []; + return ( + <> + + + + + {idea?.description ? : null} + {tradeoffs.length > 0 ? : null} + {whyTop ? : null} + + ); +} + +/** Collapsed, an idea says its side and strength under its title. */ +function ideaItem(t: (key: string) => string, key: string, idea: Partial | undefined, whyTop?: string): NumberedItem { + const { side, strength } = ideaLabels(idea); + return { + key, + title: idea?.title || t('brainstormResults.untitled'), + summary: `${t(`brainstormResults.${side}`)} · ${t(`mobile.brainstorm.strengths.${strength}`)}`, + body: , + }; +} + +function NumberedList({ items }: { items: string[] }) { + const { colors } = useTheme(); + return ( + + {items.map((item, index) => ( + + + {`${index + 1}.`} + + + {item} + + + ))} + + ); +} + +/** + * Every part of the website's BrainstormResults in the document style: the + * top five, the argument map or the buckets as numbered ideas that collapse, + * then the next steps and warnings. A brainstorm that needs more information + * shows only its questions, as on the website. + */ +export function BrainstormDocument({ output, input, sources }: { output: BrainstormOutput; input: Partial; sources: DiscoveredSource[] }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + + if (output.needsMoreInfo && output.clarifyingQuestions?.length) { + return ( + + + + ); + } + + const topFive = Array.isArray(output.topFive) ? output.topFive : []; + const mindmap = Array.isArray(output.mindmap) ? output.mindmap : []; + const buckets = Array.isArray(output.buckets) ? output.buckets : []; + const nextSteps = Array.isArray(output.nextSteps) ? output.nextSteps : []; + const warnings = Array.isArray(output.warningsAndTradeoffs) ? output.warningsAndTradeoffs : []; + const sideLabel = { both: t('brainstormForm.sideBoth'), pro: t('brainstormForm.sidePro'), con: t('brainstormForm.sideCon') }[input.side ?? 'both'] ?? t('brainstormForm.sideBoth'); + const formatLabel = { topfive: t('brainstormForm.outputTop5'), mindmap: t('brainstormForm.outputMindmap'), buckets: t('brainstormForm.outputBuckets') }[input.outputMode ?? 'topfive']; + + return ( + <> + block.text.trim())} + /> + + {topFive.length === 0 && mindmap.length === 0 && buckets.length === 0 ? ( + + + {t('brainstormResults.noIdeasDesc')} + + + ) : null} + + {topFive.length > 0 ? ( + <> + + ideaItem(t, `${index}`, item.idea, item.whyTop))} testID="brainstorm-ideas" /> + + ) : null} + + {mindmap.length > 0 ? : null} + {mindmap.map((category, index) => { + const items = Array.isArray(category.items) ? category.items : []; + return ( + + + ideaItem(t, `${ideaIndex}`, idea))} testID={index === 0 ? 'brainstorm-ideas' : undefined} /> + + ); + })} + + {buckets.map((bucket, index) => { + const ideas = Array.isArray(bucket.ideas) ? bucket.ideas : []; + return ( + + + ideaItem(t, `${ideaIndex}`, idea))} testID={index === 0 && mindmap.length === 0 ? 'brainstorm-ideas' : undefined} /> + + ); + })} + + {nextSteps.length > 0 ? ( + + + + ) : null} + + {warnings.length > 0 ? ( + + + + ) : null} + + + + ); +} + +const styles = StyleSheet.create({ + tags: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 }, + group: { gap: 8 }, + body: { fontSize: 15.5, lineHeight: 22 }, + list: { gap: 8 }, + listRow: { flexDirection: 'row', gap: 8 }, + listNumber: { fontSize: 15.5, lineHeight: 22, minWidth: 18 }, + listText: { flex: 1, fontSize: 15.5, lineHeight: 22 }, +}); diff --git a/apps/mobile/src/components/buttons.tsx b/apps/mobile/src/components/buttons.tsx index 1a69af448..ba990759b 100644 --- a/apps/mobile/src/components/buttons.tsx +++ b/apps/mobile/src/components/buttons.tsx @@ -127,16 +127,20 @@ export function TextButton({ onPress, disabled, destructive, + accessibilityLabel, }: { label: string; onPress: () => void; disabled?: boolean; destructive?: boolean; + /** For VoiceOver when the visible label repeats on a list of items. */ + accessibilityLabel?: string; }) { const { colors } = useTheme(); return ( ): string | undefined { + const parsed = SafeWebUrlSchema.safeParse(factoid.citation?.url); + return parsed.success ? parsed.data : undefined; +} + +/** A factoid's source as a chip: the debate's own copy in the Library when it has one, otherwise the web page. */ +function citationChip(factoid: Partial, n: number, library: Source[]): CitationTarget | null { + const url = safeCitationUrl(factoid); + if (!url) return null; + const saved = library.find((source) => source.origin === url); + return { n, label: shorten(factoid.citation?.publisher || factoid.citation?.title || hostOf(url)), url, sourceId: saved?.id }; +} + +/** + * Every part of the website's FactoidsResults in the document style: the + * verification status, its notes, each verified factoid as a numbered item + * with its key figure, context, limitations and source, then the searches + * that would find more. + */ +export function FactoidsDocument({ + output, + input, + library, + sources, + onCite, + onSave, + onSearch, + saving, +}: { + output: FactoidsOutput; + input: Partial; + library: Source[]; + /** Researched sources, known only for factoids found in this session. */ + sources: DiscoveredSource[]; + onCite: (target: CitationTarget) => void; + onSave: (factoid: Factoid) => void; + onSearch: (query: string) => void; + saving: boolean; +}) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const factoids = Array.isArray(output.factoids) ? output.factoids : []; + const warnings = Array.isArray(output.warnings) ? output.warnings : []; + const suggestions = Array.isArray(output.searchSuggestions) ? output.searchSuggestions : []; + const shown = factoids.filter((factoid) => factoid.citation?.verified === 'verified'); + const share = Math.round((shown.length / Math.max(factoids.length, 1)) * 100); + const sourceType = { any: t('factoidsForm.sourceAny'), academic: t('factoidsForm.sourceAcademic'), government: t('factoidsForm.sourceGovernment'), news: t('factoidsForm.sourceNews') }[input.sourceType ?? 'any']; + + const items = shown.map((factoid, index) => { + const chip = citationChip(factoid, index + 1, library); + const citation = [factoid.citation?.title || t('factoidsResults.unknownSource'), [factoid.citation?.publisher || t('factoidsResults.unknown'), factoid.citation?.date].filter(Boolean).join(' • ')].join('\n'); + return { + key: `${index}`, + title: factoid.shortClaim, + summary: [factoid.keyFigure, factoid.citation?.publisher].filter(Boolean).join(' · '), + body: ( + <> + + + + {`${t('factoidsResults.relevance')} ${factoid.relevanceScore}%`} + + + + + {factoid.keyFigure} + + + + + + {chip ? null : ( + {t('factoidsResults.urlNeedsVerification')} + )} + + + onSave(factoid)} + disabled={saving} + /> + + + ), + }; + }); + + return ( + <> + block.text.trim())} + /> + + + + + + + {`${share}%`} + + + + {warnings.length > 0 ? ( + + + + ) : null} + + + {items.length > 0 ? ( + + ) : ( + + + {t('factoidsResults.noVerified')} + + + )} + + {suggestions.length > 0 ? ( + + {suggestions.map((query, index) => ( + onSearch(query)} + style={({ pressed }) => [styles.suggestion, { backgroundColor: colors.background }, pressed && styles.pressed]}> + + {query} + + + + ))} + + ) : null} + + + + ); +} + +const styles = StyleSheet.create({ + tags: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 8 }, + relevance: { fontSize: 13.5 }, + figure: { fontSize: 18, lineHeight: 24 }, + unverifiedUrl: { fontSize: 13.5 }, + saveRow: { flexDirection: 'row' }, + statusRow: { flexDirection: 'row', alignItems: 'center', gap: 12 }, + track: { flex: 1, height: 10, borderRadius: 5, overflow: 'hidden' }, + fill: { height: '100%', borderRadius: 5 }, + percent: { fontSize: 15 }, + body: { fontSize: 15.5, lineHeight: 22 }, + suggestion: { flexDirection: 'row', alignItems: 'center', gap: 10, borderRadius: 10, paddingHorizontal: 12, paddingVertical: 10 }, + suggestionText: { flex: 1, fontSize: 14.5, lineHeight: 20 }, + pressed: { opacity: 0.7 }, +}); diff --git a/apps/mobile/src/components/prepare-tools.ts b/apps/mobile/src/components/prepare-tools.ts index 3a36b2e34..e9be47242 100644 --- a/apps/mobile/src/components/prepare-tools.ts +++ b/apps/mobile/src/components/prepare-tools.ts @@ -1,6 +1,8 @@ +import type { Href } from 'expo-router'; + import type { IconName } from './icon-paths'; -/** The Prepare tools, in the website's order. Generate is in the app; the others follow in later updates. */ +/** The Prepare tools, in the website's order. Flowchart follows in a later update. */ export const PREPARE_TOOLS = [ { key: 'generate', icon: 'sparkles' }, { key: 'brainstorm', icon: 'bulb' }, @@ -9,3 +11,17 @@ export const PREPARE_TOOLS = [ ] as const satisfies readonly { key: string; icon: IconName }[]; export type PrepareTool = (typeof PREPARE_TOOLS)[number]['key']; + +/** Where a Prepare tool opens, remembering the tab it was opened from for the back button. */ +export function prepareToolHref(tool: PrepareTool, from: 'debate' | 'prepare'): Href { + switch (tool) { + case 'generate': + return { pathname: '/generate', params: { from } }; + case 'brainstorm': + return { pathname: '/brainstorm', params: { from } }; + case 'factoids': + return { pathname: '/factoids', params: { from } }; + default: + return { pathname: '/coming/[tool]', params: { tool } }; + } +} diff --git a/apps/mobile/src/components/work-row.tsx b/apps/mobile/src/components/work-row.tsx index d8811744f..e4813f9c9 100644 --- a/apps/mobile/src/components/work-row.tsx +++ b/apps/mobile/src/components/work-row.tsx @@ -26,8 +26,8 @@ export const KIND_ICONS: Record = { export type OpenedFrom = 'debate' | 'prepare' | 'practice'; /** - * Where a saved item opens. Cases and attacks open read-only as documents; - * the Prepare tools not in the app yet open their "on its way" screen, and + * Where a saved item opens. Cases, attacks, brainstorms and factoids open + * read-only as documents; Flowcharts open their "on its way" screen, and * Practice work opens the Practice tab. */ export function workHref(item: DebateItem, from: OpenedFrom): Href { @@ -37,7 +37,9 @@ export function workHref(item: DebateItem, from: OpenedFrom): Href { case 'attack': return { pathname: '/attack/[id]', params: { id: item.id, from } }; case 'brainstorm': + return { pathname: '/brainstorm/[id]', params: { id: item.id, from } }; case 'factoids': + return { pathname: '/factoids/[id]', params: { id: item.id, from } }; case 'flowchart': return { pathname: '/coming/[tool]', params: { tool: item.kind } }; default: diff --git a/apps/mobile/src/lib/export-html.ts b/apps/mobile/src/lib/export-html.ts index bb07b6411..f57b2918f 100644 --- a/apps/mobile/src/lib/export-html.ts +++ b/apps/mobile/src/lib/export-html.ts @@ -1,4 +1,6 @@ import type { AttackOutput } from '@debatelab/core/ai/schemas/attack'; +import type { BrainstormOutput } from '@debatelab/core/ai/schemas/brainstorm'; +import type { FactoidsOutput } from '@debatelab/core/ai/schemas/factoids'; import type { GenerateOutput } from '@debatelab/core/ai/schemas/generate'; type Translate = (key: string, options?: Record) => string; @@ -124,3 +126,69 @@ export function attackExportHtml(result: AttackOutput, t: Translate): string { } return page(t('export.caseAttack'), `${t('export.overallScore')}: ${score}/100`, parts.join(''), t); } + +// The website's Brainstorm and Factoids PDFs open with a table; these pages draw it the same way. +const TABLE_STYLE = ``; + +function table(headers: string[], rows: string[][]): string { + return `${headers.map((header) => ``).join('')}${ + rows.map((row) => `${row.map((cell) => ``).join('')}`).join('') + }
${escape(header)}
${escape(cell)}
`; +} + +/** The website's PDF file name (exportBrainstormToPdf). */ +export const BRAINSTORM_EXPORT_FILE_NAME = 'BrainstormResults.pdf'; +/** The website's PDF file name (exportFactoidsToPdf). */ +export const FACTOIDS_EXPORT_FILE_NAME = 'ResearchFactoids.pdf'; + +export function brainstormExportHtml(result: BrainstormOutput, t: Translate): string { + const parts: string[] = [TABLE_STYLE]; + if (result.topFive?.length) { + parts.push(`

${escape(t('export.topArgumentAngles'))}

`); + parts.push(table( + [t('export.rank'), t('export.title'), t('export.description'), t('export.side'), t('export.whyTop')], + result.topFive.map((item) => [`#${item.rank}`, item.idea?.title || '', item.idea?.description || '', item.idea?.side?.toUpperCase() || '', item.whyTop || '']), + )); + } + if (result.mindmap?.length) { + parts.push(`

${escape(t('export.argumentCategories'))}

`); + result.mindmap.forEach((category) => { + parts.push(`

${escape(category.category)}

`); + parts.push(bullets((category.items ?? []).map((idea) => `${idea.title}: ${idea.description}`))); + }); + } + if (result.buckets?.length) { + parts.push(`

${escape(t('export.strategicBuckets'))}

`); + result.buckets.forEach((bucket) => parts.push(`

${escape(bucket.name)}: ${escape(bucket.keyInsight)}

`)); + } + if (result.nextSteps?.length) parts.push(`

${escape(t('export.recommendedNextSteps'))}

${bullets(result.nextSteps)}`); + if (result.warningsAndTradeoffs?.length) parts.push(`

${escape(t('export.warningsAndTradeoffs'))}

${bullets(result.warningsAndTradeoffs)}`); + return page(t('export.brainstormResults'), t('export.topIdeasCount', { count: result.topFive?.length || 0 }), parts.join(''), t); +} + +export function factoidsExportHtml(result: FactoidsOutput, t: Translate): string { + const factoids = result.factoids ?? []; + const verified = factoids.filter((f) => f.citation?.verified === 'verified').length; + const parts: string[] = [TABLE_STYLE]; + if (factoids.length) { + parts.push(`

${escape(t('export.keyFactsAndEvidence'))}

`); + parts.push(table( + [t('export.claim'), t('export.keyFigure'), t('export.source'), t('export.statusLabel'), t('export.relevance')], + factoids.map((f) => [f.shortClaim || '', f.keyFigure || '', f.citation?.title || '', t(`export.status.${f.citation?.verified || 'pending'}`), `${f.relevanceScore || 0}%`]), + )); + parts.push(`

${escape(t('export.detailedAnalysis'))}

`); + factoids.forEach((f, i) => { + parts.push(`

${i + 1}.${escape(f.shortClaim)}

${ + paragraph(t('export.context'), f.context || 'N/A')}${paragraph(t('export.limitations'), f.limitations || 'N/A')}${paragraph(t('export.howToUse'), f.howToUse || 'N/A')}${ + f.citation?.url ? paragraph(t('export.source'), f.citation.url) : ''}
`); + }); + } + if (result.searchSuggestions?.length) parts.push(`

${escape(t('export.furtherResearchSuggestions'))}

${bullets(result.searchSuggestions)}`); + if (result.warnings?.length) parts.push(`

${escape(t('export.warnings'))}

${bullets(result.warnings)}`); + return page(t('export.researchFactoids'), t('export.factoidsSummary', { count: factoids.length, verified }), parts.join(''), t); +} diff --git a/apps/mobile/src/lib/result-text.ts b/apps/mobile/src/lib/result-text.ts index 588e0cb84..564c4000c 100644 --- a/apps/mobile/src/lib/result-text.ts +++ b/apps/mobile/src/lib/result-text.ts @@ -1,4 +1,6 @@ import type { AttackOutput } from '@debatelab/core/ai/schemas/attack'; +import type { BrainstormOutput } from '@debatelab/core/ai/schemas/brainstorm'; +import type { FactoidsOutput } from '@debatelab/core/ai/schemas/factoids'; import type { GenerateOutput } from '@debatelab/core/ai/schemas/generate'; type Translate = (key: string, options?: Record) => string; @@ -91,3 +93,92 @@ export function discussAttackMessage(result: AttackOutput): string { Can you help me improve this?`; } + +/** + * A brainstorm as plain text. The website has no clipboard button for + * brainstorms, so this follows its Word export's sections (exportBrainstormToDocx), + * with the PDF export's translated headings where the Word export has English ones. + */ +export function brainstormToText(result: BrainstormOutput, t: Translate): string { + const lines: string[] = [t('export.brainstormResults')]; + const topFive = result.topFive ?? []; + if (topFive.length > 0) { + lines.push('', t('export.topArgumentAngles').toUpperCase()); + topFive.forEach((item) => { + lines.push('', `#${item.rank}: ${item.idea?.title || t('brainstormResults.untitled')}`); + lines.push(item.idea?.description || ''); + lines.push(`${t('export.whyTop')}: ${item.whyTop || ''}`); + }); + } + const mindmap = result.mindmap ?? []; + if (mindmap.length > 0) { + lines.push('', t('export.argumentCategories').toUpperCase()); + mindmap.forEach((category) => { + lines.push('', category.category); + (category.items ?? []).forEach((idea) => lines.push(`• ${idea.title}: ${idea.description}`)); + }); + } + const buckets = result.buckets ?? []; + if (buckets.length > 0) { + lines.push('', t('export.strategicBuckets').toUpperCase()); + buckets.forEach((bucket) => { + lines.push('', bucket.name); + lines.push(bucket.keyInsight || ''); + (bucket.ideas ?? []).forEach((idea) => lines.push(`• ${idea.title}`)); + }); + } + const nextSteps = result.nextSteps ?? []; + if (nextSteps.length > 0) { + lines.push('', t('export.recommendedNextSteps').toUpperCase()); + nextSteps.forEach((step, i) => lines.push(`${i + 1}. ${step}`)); + } + return lines.join('\n'); +} + +/** + * Factoids as plain text, following the website's Word export + * (exportFactoidsToDocx) as attackToText does for attacks. + */ +export function factoidsToText(result: FactoidsOutput, t: Translate): string { + const lines: string[] = [t('export.researchFactoids'), '', t('export.keyFactsAndEvidence').toUpperCase()]; + const factoids = result.factoids ?? []; + if (factoids.length === 0) lines.push(t('export.noFactoids')); + factoids.forEach((f, i) => { + lines.push('', `${i + 1}. ${f.shortClaim}`); + lines.push(`${t('export.keyFigure')}: ${f.keyFigure || ''}`); + lines.push(`${t('export.context')}: ${f.context || ''}`); + lines.push(`${t('export.howToUse')}: ${f.howToUse || ''}`); + lines.push(`${t('export.source')}: ${f.citation?.title || t('export.unknown')} - ${f.citation?.publisher || t('export.unknown')}`); + }); + const warnings = result.warnings ?? []; + if (warnings.length > 0) { + lines.push('', t('export.warnings').toUpperCase()); + warnings.forEach((warning) => lines.push(`⚠ ${warning}`)); + } + return lines.join('\n'); +} + +/** The message the website's Discuss button sends to Ask for a brainstorm (BrainstormPage). */ +export function discussBrainstormMessage(result: BrainstormOutput, t: Translate): string { + let summary = "I'd like to discuss these brainstormed ideas.\n\n"; + if (result.topFive) { + summary += '**Top Ideas:**\n' + result.topFive.map((i) => { + const side = i.idea.side ? t(`brainstormResults.${i.idea.side}`, { defaultValue: i.idea.side }) : 'N/A'; + return `- ${i.idea.title} (${side})`; + }).join('\n'); + } else if (result.buckets) { + summary += '**Key Buckets:**\n' + result.buckets.map((b) => `- ${b.name}: ${b.keyInsight}`).join('\n'); + } + summary += '\n\nCan you help me expand on one of these?'; + return summary; +} + +/** The message the website's Discuss button sends to Ask for factoids (FactoidsPage). */ +export function discussFactoidsMessage(result: FactoidsOutput): string { + return `I'd like to discuss the evidence I found. + + **Factoids:** + ${result.factoids.slice(0, 3).map((f) => `- ${f.shortClaim} (${f.citation.publisher})`).join('\n')} + + How can I best use this evidence?`; +} diff --git a/apps/mobile/src/lib/results.test.ts b/apps/mobile/src/lib/results.test.ts index ea0e54a89..2641a42dc 100644 --- a/apps/mobile/src/lib/results.test.ts +++ b/apps/mobile/src/lib/results.test.ts @@ -6,7 +6,7 @@ vi.mock('./supabase', () => ({ supabase: {} })); vi.mock('expo/fetch', () => ({ fetch: vi.fn() })); vi.mock('./sources', () => ({ addSource, applySourceAssessments: vi.fn(), fetchSourceIdsInFolder: vi.fn(), removeSource: vi.fn() })); -import { autosaveSources } from './results'; +import { autosaveSources, readBrainstorm, readFactoids } from './results'; describe('autosaveSources', () => { it('writes no further source once Generate or Attack is cancelled during a write', async () => { @@ -24,3 +24,31 @@ describe('autosaveSources', () => { expect(addSource).toHaveBeenCalledTimes(1); }); }); + +describe('reading Brainstorm and Factoids output', () => { + it('keeps only text in a brainstorm list, so an object never reaches the document or the export', () => { + const output = readBrainstorm({ + needsMoreInfo: false, + topFive: [{ rank: 1, idea: { title: 'Cleaner air', description: 'Fewer cars in the centre' }, whyTop: 'Health' }], + nextSteps: [{ action: 'Find local air-quality data' }, 'Ask the council'], + warningsAndTradeoffs: [], + }); + expect(output?.nextSteps).toEqual(['Ask the council']); + expect(output?.topFive?.[0].idea.title).toBe('Cleaner air'); + }); + + it('rejects a saved factoids row whose factoid does not fit the schema', () => { + const factoid = { + id: 'f1', + shortClaim: { text: 'Air quality improved' }, + keyFigure: '30%', + context: '', + limitations: '', + citation: { title: 'Report', publisher: 'Agency', verified: 'verified' }, + howToUse: '', + relevanceScore: 80, + }; + expect(readFactoids({ factoids: [factoid], searchSuggestions: [], warnings: [], verificationStatus: 'complete' })).toBeNull(); + expect(readFactoids({ factoids: [{ ...factoid, shortClaim: 'Air quality improved' }], searchSuggestions: [], warnings: [], verificationStatus: 'complete' })?.factoids).toHaveLength(1); + }); +}); diff --git a/apps/mobile/src/lib/results.ts b/apps/mobile/src/lib/results.ts index 9d496c52a..267dd43dc 100644 --- a/apps/mobile/src/lib/results.ts +++ b/apps/mobile/src/lib/results.ts @@ -6,8 +6,12 @@ import { type DiscoveredSource, type ResultWithSources, } from '@debatelab/core/ai/discovered-sources'; +import { transformBrainstormResponse } from '@debatelab/core/ai/brainstorm-output'; +import { factoidsRequestInput, selectVerifiedFactoids } from '@debatelab/core/ai/factoids-output'; import { expectedContentionCount, transformGenerateResponse } from '@debatelab/core/ai/generate-output'; import { AttackOutputSchema, type AttackInput, type AttackOutput } from '@debatelab/core/ai/schemas/attack'; +import { BrainstormOutputSchema, type BrainstormInput, type BrainstormOutput } from '@debatelab/core/ai/schemas/brainstorm'; +import { FactoidsOutputSchema, type FactoidsInput, type FactoidsOutput } from '@debatelab/core/ai/schemas/factoids'; import { GenerateOutputSchema, type GenerateInput, type GenerateOutput } from '@debatelab/core/ai/schemas/generate'; import type { SourceAssessmentUpdate } from '@debatelab/core/ai/source-assessments'; import type { DebateContextRequest } from '@debatelab/core/contract/debate-context'; @@ -18,21 +22,27 @@ import { addSource, applySourceAssessments, fetchSourceIdsInFolder, removeSource import { supabase } from './supabase'; /** - * Generate and Attack, sent and saved the way the website does it: - * generateCase and attackCase in src/lib/ai/client.ts for the requests and - * their researched sources, and recordResult and saveCurrentCase in - * src/lib/stores/session-store.ts for the saved rows. + * Generate, Attack, Brainstorm and Factoids, sent and saved the way the + * website does it: generateCase, attackCase, brainstorm and factoids in + * src/lib/ai/client.ts for the requests and their researched sources, and + * recordResult and saveCurrentCase in src/lib/stores/session-store.ts for the + * saved rows. */ -export type ResultKind = 'case' | 'attack'; +export type ResultKind = 'case' | 'attack' | 'brainstorm' | 'factoids'; -const TABLES = { case: 'cases', attack: 'attacks' } as const satisfies Record; +const TABLES = { case: 'cases', attack: 'attacks', brainstorm: 'brainstorms', factoids: 'factoids' } as const satisfies Record; export interface OutputFor { case: GenerateOutput; attack: AttackOutput; + brainstorm: BrainstormOutput; + factoids: FactoidsOutput; } +/** The feature each kind is sent as, and the name its researched sources are captured under. */ +type Feature = 'generate' | 'attack' | 'brainstorm' | 'factoids'; + export interface SavedResult { id: string; kind: K; @@ -52,7 +62,7 @@ interface RequestOptions { signal?: AbortSignal; } -function requestBody(feature: 'generate' | 'attack', input: Record, options: RequestOptions) { +function requestBody(feature: Feature, input: Record, options: RequestOptions) { return { feature, input: { ...input, language: options.language }, @@ -91,6 +101,33 @@ export function attackCase(input: AttackInput, options: RequestOptions): Promise }); } +export function brainstormIdeas(input: BrainstormInput, options: RequestOptions): Promise & ResponseExtras> { + return postFunction('ai', requestBody('brainstorm', input, options), options.signal, async (response) => { + const { output, data } = await functionOutput(response); + const result = readBrainstorm(output); + if (!result) throw new AIError('failed', 'AI response did not match the expected brainstorm schema.'); + return { + result, + discoveredSources: parseDiscoveredSources(data.discoveredSources), + ...responseExtras(data), + }; + }); +} + +/** Factoids, as the website asks for them: verified ones only, up to the number of sources chosen. */ +export function findFactoids(input: FactoidsInput, options: RequestOptions): Promise & ResponseExtras> { + return postFunction('ai', requestBody('factoids', factoidsRequestInput(input), options), options.signal, async (response) => { + const { output, data } = await functionOutput(response); + const parsed = FactoidsOutputSchema.safeParse(output); + if (!parsed.success) throw new AIError('failed', 'AI response did not match the expected factoids schema.'); + return { + result: selectVerifiedFactoids(parsed.data, input.sourceCount), + discoveredSources: parseDiscoveredSources(data.discoveredSources), + ...responseExtras(data), + }; + }); +} + /** * Keeps the researched sources worth keeping in the debate's Auto-saved * folder, one at a time, as the website does. One-off research is not kept. @@ -100,7 +137,7 @@ export function attackCase(input: AttackInput, options: RequestOptions): Promise export async function autosaveSources( userId: string, sources: DiscoveredSource[], - capturedVia: 'generate' | 'attack', + capturedVia: Feature, debateId: string | null, signal?: AbortSignal, ): Promise<{ saved: number; failed: number }> { @@ -247,3 +284,20 @@ export function isCompleteAttack(output: unknown): output is AttackOutput { const value = output as Partial | null; return Boolean(value?.overallAssessment && Array.isArray(value.weaknesses)); } + +/** + * A brainstorm, from the model or a saved row, normalized by the website's transform and then + * checked against its schema; null when it still does not fit. A brainstorm with no ideas is + * still complete: the website shows "No ideas returned" or its questions. + */ +export function readBrainstorm(output: unknown): BrainstormOutput | null { + if (!output || typeof output !== 'object' || Array.isArray(output)) return null; + const parsed = BrainstormOutputSchema.safeParse(transformBrainstormResponse(output as Record)); + return parsed.success ? parsed.data : null; +} + +/** A saved factoids row, checked against the schema its request was checked with; null when it does not fit. */ +export function readFactoids(output: unknown): FactoidsOutput | null { + const parsed = FactoidsOutputSchema.safeParse(output); + return parsed.success ? parsed.data : null; +} diff --git a/apps/mobile/src/lib/use-result-document.ts b/apps/mobile/src/lib/use-result-document.ts index 1d48539c5..144d80206 100644 --- a/apps/mobile/src/lib/use-result-document.ts +++ b/apps/mobile/src/lib/use-result-document.ts @@ -32,6 +32,14 @@ export function arrivalNoticeText(t: TFunction, notice: string | undefined, fail .join(' '); } +/** The delete confirmation for each kind: the website's own for cases, the app's for the rest. */ +const DELETE_TEXT: Record string; message: (t: TFunction) => string }> = { + case: { title: (t) => t('historyPage.deleteCaseTitle'), message: (t) => t('historyPage.deleteCaseDesc') }, + attack: { title: (t) => t('mobile.results.deleteAttackTitle'), message: (t) => t('mobile.results.deleteAttackDesc') }, + brainstorm: { title: (t) => t('mobile.results.deleteBrainstormTitle'), message: (t) => t('mobile.results.deleteBrainstormDesc') }, + factoids: { title: (t) => t('mobile.results.deleteFactoidsTitle'), message: (t) => t('mobile.results.deleteFactoidsDesc') }, +}; + /** * A result's document: the result itself (from this session or the saved * row), the debate's Library for its citation chips, the Current Debate's @@ -151,8 +159,8 @@ export function useResultDocument(kind: K, id: string) { const forUser = userId; const savedId = result.savedId; const confirmed = await confirmDestructive({ - title: kind === 'case' ? t('historyPage.deleteCaseTitle') : t('mobile.results.deleteAttackTitle'), - message: kind === 'case' ? t('historyPage.deleteCaseDesc') : t('mobile.results.deleteAttackDesc'), + title: DELETE_TEXT[kind].title(t), + message: DELETE_TEXT[kind].message(t), confirm: t('common.delete'), cancel: t('common.cancel'), }); diff --git a/packages/core/src/ai/brainstorm-output.ts b/packages/core/src/ai/brainstorm-output.ts new file mode 100644 index 000000000..b3c3da29a --- /dev/null +++ b/packages/core/src/ai/brainstorm-output.ts @@ -0,0 +1,100 @@ +import { cleanGeneratedOutput } from './generate-output'; +import type { BrainstormOutput } from './schemas/brainstorm'; + +/** + * Transforms AI brainstorm response to ensure all required fields exist. + * This handles cases where the AI returns incomplete structures. + */ +export function transformBrainstormResponse(response: Record): BrainstormOutput { + // Ensure required base fields exist. Only text entries are kept: an object in a list reached React as a child. + const strings = (value: unknown): string[] => (Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []); + const result: BrainstormOutput = { + needsMoreInfo: Boolean(response.needsMoreInfo), + clarifyingQuestions: strings(response.clarifyingQuestions), + nextSteps: strings(response.nextSteps), + warningsAndTradeoffs: strings(response.warningsAndTradeoffs), + }; + + // Helper to normalize an Idea object + const normalizeIdea = (idea: unknown): { id: string; title: string; description: string; side: 'pro' | 'con' | 'neutral'; strength: 'strong' | 'moderate' | 'weak'; tradeoffs: string[]; relatedTo: string[] } | null => { + if (!idea || typeof idea !== 'object') return null; + const i = idea as Record; + return { + id: String(i.id || `idea-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`), + title: String(i.title || 'Untitled'), + description: String(i.description || ''), + side: (['pro', 'con', 'neutral'].includes(String(i.side)) ? i.side : 'neutral') as 'pro' | 'con' | 'neutral', + strength: (['strong', 'moderate', 'weak'].includes(String(i.strength)) ? i.strength : 'moderate') as 'strong' | 'moderate' | 'weak', + tradeoffs: Array.isArray(i.tradeoffs) ? i.tradeoffs.map(String) : [], + relatedTo: Array.isArray(i.relatedTo) ? i.relatedTo.map(String) : [], + }; + }; + + const normalizeTopFiveItems = (items: unknown[]) => items + .map((item: unknown, index) => { + if (!item) return null; + if (typeof item !== 'object') { + const idea = normalizeIdea({ title: String(item || `Idea ${index + 1}`), description: String(item || '') }); + return idea ? { rank: index + 1, idea, whyTop: '' } : null; + } + const entry = item as Record; + const idea = normalizeIdea(entry.idea || entry); + if (!idea) return null; + return { + rank: typeof entry.rank === 'number' ? entry.rank : index + 1, + idea, + whyTop: String(entry.whyTop || entry.rationale || entry.reason || ''), + }; + }) + .filter((item): item is NonNullable => item !== null); + + // Transform topFive if present + if (response.topFive && Array.isArray(response.topFive)) { + result.topFive = normalizeTopFiveItems(response.topFive); + } + + // Transform mindmap if present + if (response.mindmap && Array.isArray(response.mindmap)) { + result.mindmap = response.mindmap + .map((category: unknown) => { + if (!category || typeof category !== 'object') return null; + const c = category as Record; + const items = Array.isArray(c.items) + ? c.items.map(normalizeIdea).filter((i): i is NonNullable => i !== null) + : []; + return { + category: String(c.category || 'Uncategorized'), + items, + }; + }) + .filter((cat): cat is NonNullable => cat !== null && cat.items.length > 0); + } + + // Transform buckets if present + if (response.buckets && Array.isArray(response.buckets)) { + result.buckets = response.buckets + .map((bucket: unknown) => { + if (!bucket || typeof bucket !== 'object') return null; + const b = bucket as Record; + const ideas = Array.isArray(b.ideas) + ? b.ideas.map(normalizeIdea).filter((i): i is NonNullable => i !== null) + : []; + return { + name: String(b.name || 'Unnamed Bucket'), + ideas, + keyInsight: String(b.keyInsight || ''), + }; + }) + .filter((b): b is NonNullable => b !== null && b.ideas.length > 0); + } + + if (!result.topFive?.length && !result.mindmap?.length && !result.buckets?.length) { + const fallbackItems = [response.ideas, response.angles, response.recommendations, response.results] + .find((value): value is unknown[] => Array.isArray(value)); + if (fallbackItems) { + result.topFive = normalizeTopFiveItems(fallbackItems).slice(0, 5); + } + } + + return cleanGeneratedOutput(result); +} diff --git a/packages/core/src/ai/factoids-output.ts b/packages/core/src/ai/factoids-output.ts new file mode 100644 index 000000000..6967d9721 --- /dev/null +++ b/packages/core/src/ai/factoids-output.ts @@ -0,0 +1,25 @@ +import type { FactoidsInput, FactoidsOutput } from './schemas/factoids'; + +/** The input a factoids request sends: an empty region means global, and five sources unless asked otherwise. */ +export function factoidsRequestInput(input: FactoidsInput): FactoidsInput { + return { + ...input, + region: input.region?.trim() || 'Global', + sourceCount: input.sourceCount || 5, + }; +} + +/** + * Keeps only verified factoids, up to the number of sources asked for, and + * records how many were dropped. Runs on output that already passed FactoidsOutputSchema. + */ +export function selectVerifiedFactoids(output: FactoidsOutput, sourceCount: number | undefined): FactoidsOutput { + const allFactoids = output.factoids; + const verifiedFactoids = allFactoids.filter((factoid) => factoid.citation.verified === 'verified'); + const returnedFactoids = verifiedFactoids.slice(0, sourceCount || 5); + return { + ...output, + factoids: returnedFactoids, + filteredOutCount: Math.max(0, allFactoids.length - returnedFactoids.length), + }; +} diff --git a/src/lib/ai/client.ts b/src/lib/ai/client.ts index be681c7d3..3f0e00678 100644 --- a/src/lib/ai/client.ts +++ b/src/lib/ai/client.ts @@ -19,8 +19,10 @@ import { activeDebateId, useDebateStore } from '../stores/debate-store'; import { parseDebateContextUsage, useDebateContextStore } from '../stores/debate-context-store'; import { currentDebateContextPlan } from '../debate/context-state'; import { requestSide, type DebateContextRequest } from '../../../supabase/functions/_shared/debate-context'; -import { cleanGeneratedOutput, expectedContentionCount, transformGenerateResponse } from '@debatelab/core/ai/generate-output'; +import { expectedContentionCount, transformGenerateResponse } from '@debatelab/core/ai/generate-output'; import { parseSourceAssessments } from '@debatelab/core/ai/source-assessments'; +import { transformBrainstormResponse } from '@debatelab/core/ai/brainstorm-output'; +import { factoidsRequestInput, selectVerifiedFactoids } from '@debatelab/core/ai/factoids-output'; import { autoSavedSourceRow, parseDiscoveredSources, @@ -894,103 +896,6 @@ function isProviderErrorCode(code?: string): boolean { return !!code && /quota|billing|api[_-]?key|provider|insufficient|overloaded|rate/i.test(code); } -/** - * Transforms AI brainstorm response to ensure all required fields exist. - * This handles cases where the AI returns incomplete structures. - */ -function transformBrainstormResponse(response: Record): BrainstormOutput { - // Ensure required base fields exist - const result: BrainstormOutput = { - needsMoreInfo: Boolean(response.needsMoreInfo), - clarifyingQuestions: Array.isArray(response.clarifyingQuestions) ? response.clarifyingQuestions : [], - nextSteps: Array.isArray(response.nextSteps) ? response.nextSteps : [], - warningsAndTradeoffs: Array.isArray(response.warningsAndTradeoffs) ? response.warningsAndTradeoffs : [], - }; - - // Helper to normalize an Idea object - const normalizeIdea = (idea: unknown): { id: string; title: string; description: string; side: 'pro' | 'con' | 'neutral'; strength: 'strong' | 'moderate' | 'weak'; tradeoffs: string[]; relatedTo: string[] } | null => { - if (!idea || typeof idea !== 'object') return null; - const i = idea as Record; - return { - id: String(i.id || `idea-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`), - title: String(i.title || 'Untitled'), - description: String(i.description || ''), - side: (['pro', 'con', 'neutral'].includes(String(i.side)) ? i.side : 'neutral') as 'pro' | 'con' | 'neutral', - strength: (['strong', 'moderate', 'weak'].includes(String(i.strength)) ? i.strength : 'moderate') as 'strong' | 'moderate' | 'weak', - tradeoffs: Array.isArray(i.tradeoffs) ? i.tradeoffs.map(String) : [], - relatedTo: Array.isArray(i.relatedTo) ? i.relatedTo.map(String) : [], - }; - }; - - const normalizeTopFiveItems = (items: unknown[]) => items - .map((item: unknown, index) => { - if (!item) return null; - if (typeof item !== 'object') { - const idea = normalizeIdea({ title: String(item || `Idea ${index + 1}`), description: String(item || '') }); - return idea ? { rank: index + 1, idea, whyTop: '' } : null; - } - const entry = item as Record; - const idea = normalizeIdea(entry.idea || entry); - if (!idea) return null; - return { - rank: typeof entry.rank === 'number' ? entry.rank : index + 1, - idea, - whyTop: String(entry.whyTop || entry.rationale || entry.reason || ''), - }; - }) - .filter((item): item is NonNullable => item !== null); - - // Transform topFive if present - if (response.topFive && Array.isArray(response.topFive)) { - result.topFive = normalizeTopFiveItems(response.topFive); - } - - // Transform mindmap if present - if (response.mindmap && Array.isArray(response.mindmap)) { - result.mindmap = response.mindmap - .map((category: unknown) => { - if (!category || typeof category !== 'object') return null; - const c = category as Record; - const items = Array.isArray(c.items) - ? c.items.map(normalizeIdea).filter((i): i is NonNullable => i !== null) - : []; - return { - category: String(c.category || 'Uncategorized'), - items, - }; - }) - .filter((cat): cat is NonNullable => cat !== null && cat.items.length > 0); - } - - // Transform buckets if present - if (response.buckets && Array.isArray(response.buckets)) { - result.buckets = response.buckets - .map((bucket: unknown) => { - if (!bucket || typeof bucket !== 'object') return null; - const b = bucket as Record; - const ideas = Array.isArray(b.ideas) - ? b.ideas.map(normalizeIdea).filter((i): i is NonNullable => i !== null) - : []; - return { - name: String(b.name || 'Unnamed Bucket'), - ideas, - keyInsight: String(b.keyInsight || ''), - }; - }) - .filter((b): b is NonNullable => b !== null && b.ideas.length > 0); - } - - if (!result.topFive?.length && !result.mindmap?.length && !result.buckets?.length) { - const fallbackItems = [response.ideas, response.angles, response.recommendations, response.results] - .find((value): value is unknown[] => Array.isArray(value)); - if (fallbackItems) { - result.topFive = normalizeTopFiveItems(fallbackItems).slice(0, 5); - } - } - - return cleanGeneratedOutput(result); -} - /** * Validates the attack response at the trust boundary. Attack outputs were * previously cast without validation; now malformed provider responses fail @@ -1095,11 +1000,7 @@ export async function brainstorm(input: BrainstormInput, config: ProviderConfig, } export async function factoids(input: FactoidsInput, config: ProviderConfig, workspaceContext?: WorkspaceToolContext, debateId?: string | null): Promise> { - const inputWithLanguage = withOutputLanguage({ - ...input, - region: input.region?.trim() || 'Global', - sourceCount: input.sourceCount || 5, - }); + const inputWithLanguage = withOutputLanguage(factoidsRequestInput(input)); const response = await callSupabaseAI>('factoids', inputWithLanguage, config, { workspaceContext, debateId, @@ -1112,15 +1013,8 @@ export async function factoids(input: FactoidsInput, config: ProviderConfig, wor }); } - const allFactoids = parsed.data.factoids; - const verifiedFactoids = allFactoids.filter((factoid) => factoid.citation.verified === 'verified'); - const returnedFactoids = verifiedFactoids.slice(0, input.sourceCount || 5); return { - result: { - ...parsed.data, - factoids: returnedFactoids, - filteredOutCount: Math.max(0, allFactoids.length - returnedFactoids.length), - }, + result: selectVerifiedFactoids(parsed.data, input.sourceCount), discoveredSources: parseDiscoveredSources(data.discoveredSources), }; }, diff --git a/src/locales/en.json b/src/locales/en.json index 9b7fa90ca..79e713b93 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2521,10 +2521,26 @@ "tooLarge": "This is too long to send. Shorten it and try again.", "debateMissing": "This debate no longer exists. Pick another debate and try again.", "failed": "Something went wrong. Try again." - } + }, + "deleteBrainstormTitle": "Delete brainstorm?", + "deleteBrainstormDesc": "This removes the brainstorm from your history.", + "deleteFactoidsTitle": "Delete factoids?", + "deleteFactoidsDesc": "This removes the factoids from your history." }, "attack": { "caseRequired": "Paste or write the case you want attacked." + }, + "brainstorm": { + "strengths": { + "strong": "Strong", + "moderate": "Moderate", + "weak": "Weak" + }, + "ideaCount_one": "{{count}} idea", + "ideaCount_other": "{{count}} ideas" + }, + "factoids": { + "claimRequired": "Enter a statement." } } } diff --git a/src/locales/es.json b/src/locales/es.json index 309581610..f43fa9c5f 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2521,10 +2521,26 @@ "tooLarge": "Es demasiado largo para enviarlo. Acórtalo y vuelve a intentarlo.", "debateMissing": "Este debate ya no existe. Elige otro debate y vuelve a intentarlo.", "failed": "Algo salió mal. Inténtalo de nuevo." - } + }, + "deleteBrainstormTitle": "¿Eliminar la lluvia de ideas?", + "deleteBrainstormDesc": "Esto elimina la lluvia de ideas de tu historial.", + "deleteFactoidsTitle": "¿Eliminar los datos?", + "deleteFactoidsDesc": "Esto elimina los datos de tu historial." }, "attack": { "caseRequired": "Pega o escribe el caso que quieres atacar." + }, + "brainstorm": { + "strengths": { + "strong": "Fuerte", + "moderate": "Moderada", + "weak": "Débil" + }, + "ideaCount_one": "{{count}} idea", + "ideaCount_other": "{{count}} ideas" + }, + "factoids": { + "claimRequired": "Escribe una afirmación." } } } diff --git a/src/locales/hu.json b/src/locales/hu.json index 7abbb88ac..f4661bc0e 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2521,10 +2521,26 @@ "tooLarge": "Ez túl hosszú az elküldéshez. Rövidítsd le, és próbáld újra.", "debateMissing": "Ez a vita már nem létezik. Válassz másik vitát, és próbáld újra.", "failed": "Valami hiba történt. Próbáld újra." - } + }, + "deleteBrainstormTitle": "Törlöd az ötletelést?", + "deleteBrainstormDesc": "Ez eltávolítja az ötletelést az előzményeid közül.", + "deleteFactoidsTitle": "Törlöd a tényeket?", + "deleteFactoidsDesc": "Ez eltávolítja a tényeket az előzményeid közül." }, "attack": { "caseRequired": "Illeszd be vagy írd le a megtámadandó esetet." + }, + "brainstorm": { + "strengths": { + "strong": "Erős", + "moderate": "Közepes", + "weak": "Gyenge" + }, + "ideaCount_one": "{{count}} ötlet", + "ideaCount_other": "{{count}} ötlet" + }, + "factoids": { + "claimRequired": "Írj be egy állítást." } } }