From f075d973e2166044635695ec75a81433f981824f Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 13:31:32 +0000 Subject: [PATCH 1/5] Move the Brainstorm transform and factoid selection into core The app sends Brainstorm and Factoids the way the website does, so the pure parts of brainstorm and factoids in src/lib/ai/client.ts now live in packages/core: the brainstorm response transform, the factoids request defaults and the verified-factoid selection. The website imports them back and behaves as before. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/ai/brainstorm-output.ts | 99 ++++++++++++++++++ packages/core/src/ai/factoids-output.ts | 25 +++++ src/lib/ai/client.ts | 116 +--------------------- 3 files changed, 129 insertions(+), 111 deletions(-) create mode 100644 packages/core/src/ai/brainstorm-output.ts create mode 100644 packages/core/src/ai/factoids-output.ts diff --git a/packages/core/src/ai/brainstorm-output.ts b/packages/core/src/ai/brainstorm-output.ts new file mode 100644 index 000000000..4f63aa4f7 --- /dev/null +++ b/packages/core/src/ai/brainstorm-output.ts @@ -0,0 +1,99 @@ +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 + 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); +} 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), }; }, From f0dd3c7577664a7cdc46e7a53cc8cc30afbb5e27 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 14:01:19 +0000 Subject: [PATCH 2/5] Add Brainstorm and Factoids to the app Both tools work as on the website: a native form with its fields and defaults, the website's progress messages while the request runs, and the result as a document in 4a's style with Discuss and Export pinned and Undo auto-saved sources and Delete in the "..." menu. Brainstorm goes through the motion gate and saves to brainstorms with the chosen side. Factoids follows the Current Debate, as the website's page does, and saves with side neutral. Each factoid can be kept in the debate's Factoids folder, and a search suggestion reopens the form with that query. The PDFs use the website's file names and sections, and "Copy as text" follows its Word export. Both tabs and saved rows open the real screens now. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 5 +- apps/mobile/src/app/(tabs)/(debate)/index.tsx | 7 +- .../src/app/(tabs)/(prepare)/prepare.tsx | 5 +- apps/mobile/src/app/_layout.tsx | 6 +- apps/mobile/src/app/brainstorm/[id].tsx | 110 +++++++ apps/mobile/src/app/brainstorm/index.tsx | 274 ++++++++++++++++++ apps/mobile/src/app/factoids/[id].tsx | 165 +++++++++++ apps/mobile/src/app/factoids/index.tsx | 269 +++++++++++++++++ .../src/components/brainstorm-document.tsx | 167 +++++++++++ .../src/components/factoids-document.tsx | 179 ++++++++++++ apps/mobile/src/components/prepare-tools.ts | 18 +- apps/mobile/src/components/work-row.tsx | 6 +- apps/mobile/src/lib/export-html.ts | 68 +++++ apps/mobile/src/lib/result-text.ts | 91 ++++++ apps/mobile/src/lib/results.ts | 61 +++- apps/mobile/src/lib/use-result-document.ts | 12 +- src/locales/en.json | 18 +- src/locales/es.json | 18 +- src/locales/hu.json | 18 +- 19 files changed, 1470 insertions(+), 27 deletions(-) create mode 100644 apps/mobile/src/app/brainstorm/[id].tsx create mode 100644 apps/mobile/src/app/brainstorm/index.tsx create mode 100644 apps/mobile/src/app/factoids/[id].tsx create mode 100644 apps/mobile/src/app/factoids/index.tsx create mode 100644 apps/mobile/src/components/brainstorm-document.tsx create mode 100644 apps/mobile/src/components/factoids-document.tsx diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 0d9febb40..ab7376fba 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -16,12 +16,13 @@ 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. +- 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). 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. +- 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. 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/src/app/(tabs)/(debate)/index.tsx b/apps/mobile/src/app/(tabs)/(debate)/index.tsx index d96f3b6bc..49a502fbc 100644 --- a/apps/mobile/src/app/(tabs)/(debate)/index.tsx +++ b/apps/mobile/src/app/(tabs)/(debate)/index.tsx @@ -8,7 +8,7 @@ import { MainActionButton, TextButton } from '@/components/buttons'; import { Card, IconTile } from '@/components/card'; import { Icon } from '@/components/icon'; import type { IconName } from '@/components/icon-paths'; -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'; @@ -205,10 +205,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 b3da5f601..d2a4b2eef 100644 --- a/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx +++ b/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx @@ -6,7 +6,7 @@ import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; import { TextButton } from '@/components/buttons'; import { Card, IconTile } from '@/components/card'; import { Icon } from '@/components/icon'; -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'; @@ -32,8 +32,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..48d4c49ab --- /dev/null +++ b/apps/mobile/src/app/brainstorm/[id].tsx @@ -0,0 +1,110 @@ +import type { BrainstormInput } from '@debatelab/core/ai/schemas/brainstorm'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useEffect, 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 { isCompleteBrainstorm } 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; + const output = result && isCompleteBrainstorm(result.output) ? result.output : null; + 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..4efe3d370 --- /dev/null +++ b/apps/mobile/src/app/factoids/[id].tsx @@ -0,0 +1,165 @@ +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, 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 { isCompleteFactoids } 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; + const output = result && isCompleteFactoids(result.output) ? result.output : null; + 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/factoids-document.tsx b/apps/mobile/src/components/factoids-document.tsx new file mode 100644 index 000000000..771291ef7 --- /dev/null +++ b/apps/mobile/src/components/factoids-document.tsx @@ -0,0 +1,179 @@ +import type { DiscoveredSource } from '@debatelab/core/ai/discovered-sources'; +import type { Factoid, FactoidsInput, FactoidsOutput } from '@debatelab/core/ai/schemas/factoids'; +import { SafeWebUrlSchema } from '@debatelab/core/ai/schemas/url'; +import { useTranslation } from 'react-i18next'; +import { Pressable, StyleSheet, View } from 'react-native'; + +import { TextButton } from './buttons'; +import { ResearchedSources } from './case-document'; +import { hostOf, shorten, type CitationTarget } from './citation-target'; +import { Bullets, Callout, DocSection, FramingCard, Labeled, NumberedItems, Quote, SectionHeading, Tag } from './document'; +import { Icon } from './icon'; +import { Text } from './text'; + +import type { Source } from '@/lib/sources'; +import { useTheme } from '@/theme/use-theme'; + +/** A citation's address, if it is a real web address: saved rows can be old, so it is checked again. */ +function safeCitationUrl(factoid: Partial): 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.ts b/apps/mobile/src/lib/results.ts index 9d496c52a..f80354ce7 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 type { BrainstormInput, 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,31 @@ 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); + return { + result: transformBrainstormResponse(output as Record), + 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 +135,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 +282,13 @@ export function isCompleteAttack(output: unknown): output is AttackOutput { const value = output as Partial | null; return Boolean(value?.overallAssessment && Array.isArray(value.weaknesses)); } + +/** A brainstorm with no ideas is still complete: the website shows "No ideas returned" or its questions. */ +export function isCompleteBrainstorm(output: unknown): output is BrainstormOutput { + return Boolean(output && typeof output === 'object' && !Array.isArray(output)); +} + +export function isCompleteFactoids(output: unknown): output is FactoidsOutput { + const value = output as Partial | null; + return Boolean(value && Array.isArray(value.factoids)); +} 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/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." } } } From 6ae55d0e528ac74d0ae4d4e8d1c728429cf0f3b2 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 14:08:40 +0000 Subject: [PATCH 3/5] Screenshot Brainstorm and Factoids The screenshot script now shoots both tools' forms, their progress, the documents open and collapsed, and the Export menu, from a fixed brainstorm and fixed factoids inside the page. It renders each PDF's HTML and writes the copied text next to the screenshots. Nothing calls a model or saves. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/scripts/screenshots.ts | 116 +++++++++++++++++++++++++++-- 1 file changed, 108 insertions(+), 8 deletions(-) diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index 9628be503..c2e7aaea5 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -34,7 +34,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'; @@ -292,16 +293,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(), ); @@ -315,7 +350,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; }; @@ -373,6 +408,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 }); @@ -578,6 +674,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; + } await signedIn.close(); } } From ab989ccc4709531ef311eba292963ed5c9c214e0 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 16:35:50 +0000 Subject: [PATCH 4/5] Check the one-off notice on Brainstorm and Factoids Both forms show it through WorkingIn since #129. The screenshot script now opens each from Prepare in one-off mode and waits for the notice, and the app guide names them. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 2 +- apps/mobile/scripts/screenshots.ts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 9e18d6cc8..5ec39825f 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -24,7 +24,7 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp - 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. - 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, 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. +- 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 202548565..054026a2f 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -573,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'); From a5705dcbb0f0ca6f70e99b7739ae593584102f67 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 16:36:49 +0000 Subject: [PATCH 5/5] Check Brainstorm and Factoids output against their schemas A brainstorm whose nextSteps held an object passed the transform, was saved, and then crashed its document and the export. The shared transform now keeps only text in its string lists, and the app reads every brainstorm, fresh or saved, through the transform and BrainstormOutputSchema. Saved factoids rows go through FactoidsOutputSchema too, so a broken old row shows the incomplete notice instead of crashing. Each factoid's save button now tells VoiceOver which claim it saves, with the website's factoidsResults.saveClaimToLibrary. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/brainstorm/[id].tsx | 8 +++-- apps/mobile/src/app/factoids/[id].tsx | 8 +++-- apps/mobile/src/components/buttons.tsx | 4 +++ .../src/components/factoids-document.tsx | 7 ++++- apps/mobile/src/lib/results.test.ts | 30 ++++++++++++++++++- apps/mobile/src/lib/results.ts | 25 +++++++++++----- packages/core/src/ai/brainstorm-output.ts | 9 +++--- 7 files changed, 71 insertions(+), 20 deletions(-) diff --git a/apps/mobile/src/app/brainstorm/[id].tsx b/apps/mobile/src/app/brainstorm/[id].tsx index 48d4c49ab..980bac6a5 100644 --- a/apps/mobile/src/app/brainstorm/[id].tsx +++ b/apps/mobile/src/app/brainstorm/[id].tsx @@ -1,6 +1,6 @@ import type { BrainstormInput } from '@debatelab/core/ai/schemas/brainstorm'; import { useLocalSearchParams, useRouter } from 'expo-router'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ActionMenu } from '@/components/action-menu'; @@ -14,7 +14,7 @@ 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 { isCompleteBrainstorm } from '@/lib/results'; +import { readBrainstorm } from '@/lib/results'; import { arrivalNoticeText, useResultDocument } from '@/lib/use-result-document'; /** @@ -37,7 +37,9 @@ export default function BrainstormResultScreen() { }, [arrival, failed, setNotice, t]); const result = doc.state.status === 'ready' ? doc.state.result : null; - const output = result && isCompleteBrainstorm(result.output) ? result.output : 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) { diff --git a/apps/mobile/src/app/factoids/[id].tsx b/apps/mobile/src/app/factoids/[id].tsx index 4efe3d370..9ccf523e7 100644 --- a/apps/mobile/src/app/factoids/[id].tsx +++ b/apps/mobile/src/app/factoids/[id].tsx @@ -1,7 +1,7 @@ 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, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ActionMenu } from '@/components/action-menu'; @@ -18,7 +18,7 @@ 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 { isCompleteFactoids } from '@/lib/results'; +import { readFactoids } from '@/lib/results'; import { addSource } from '@/lib/sources'; import { arrivalNoticeText, useResultDocument } from '@/lib/use-result-document'; @@ -56,7 +56,9 @@ export default function FactoidsResultScreen() { }, [arrival, failed, setNotice, t]); const result = doc.state.status === 'ready' ? doc.state.result : null; - const output = result && isCompleteFactoids(result.output) ? result.output : 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) { 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 ( - onSave(factoid)} disabled={saving} /> + onSave(factoid)} + disabled={saving} + /> ), 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 f80354ce7..267dd43dc 100644 --- a/apps/mobile/src/lib/results.ts +++ b/apps/mobile/src/lib/results.ts @@ -10,7 +10,7 @@ import { transformBrainstormResponse } from '@debatelab/core/ai/brainstorm-outpu 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 type { BrainstormInput, BrainstormOutput } from '@debatelab/core/ai/schemas/brainstorm'; +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'; @@ -104,8 +104,10 @@ 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: transformBrainstormResponse(output as Record), + result, discoveredSources: parseDiscoveredSources(data.discoveredSources), ...responseExtras(data), }; @@ -283,12 +285,19 @@ export function isCompleteAttack(output: unknown): output is AttackOutput { return Boolean(value?.overallAssessment && Array.isArray(value.weaknesses)); } -/** A brainstorm with no ideas is still complete: the website shows "No ideas returned" or its questions. */ -export function isCompleteBrainstorm(output: unknown): output is BrainstormOutput { - return Boolean(output && typeof output === 'object' && !Array.isArray(output)); +/** + * 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; } -export function isCompleteFactoids(output: unknown): output is FactoidsOutput { - const value = output as Partial | null; - return Boolean(value && Array.isArray(value.factoids)); +/** 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/packages/core/src/ai/brainstorm-output.ts b/packages/core/src/ai/brainstorm-output.ts index 4f63aa4f7..b3c3da29a 100644 --- a/packages/core/src/ai/brainstorm-output.ts +++ b/packages/core/src/ai/brainstorm-output.ts @@ -6,12 +6,13 @@ import type { BrainstormOutput } from './schemas/brainstorm'; * This handles cases where the AI returns incomplete structures. */ export function transformBrainstormResponse(response: Record): BrainstormOutput { - // Ensure required base fields exist + // 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: Array.isArray(response.clarifyingQuestions) ? response.clarifyingQuestions : [], - nextSteps: Array.isArray(response.nextSteps) ? response.nextSteps : [], - warningsAndTradeoffs: Array.isArray(response.warningsAndTradeoffs) ? response.warningsAndTradeoffs : [], + clarifyingQuestions: strings(response.clarifyingQuestions), + nextSteps: strings(response.nextSteps), + warningsAndTradeoffs: strings(response.warningsAndTradeoffs), }; // Helper to normalize an Idea object