Skip to content

Commit 03f0e54

Browse files
authored
Merge pull request #131 from Programcode/native-ios/04b-brainstorm-factoids
Native iOS app: Brainstorm and Factoids
2 parents 73ee099 + a5705dc commit 03f0e54

25 files changed

Lines changed: 1766 additions & 147 deletions

‎apps/mobile/AGENTS.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,14 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp
1717

1818
## Layout
1919

20-
- `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.
20+
- `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.
2121
- `src/components/`: shared UI. `app-tabs.tsx` uses native tabs; `app-tabs.web.tsx` draws the mock's tab bar for the web preview.
2222
- `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.
2323
- `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.
2424
- 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.
2525
- 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.
26-
- 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.
26+
- 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.
27+
- 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.
2728
- `src/theme/`: colours from the mocks and the website's dark palette, Inter fonts.
2829
- `scripts/screenshots.ts`: web-export screenshots. `scripts/render-tab-icons.ts` redraws the tab bar PNGs.
2930

‎apps/mobile/scripts/screenshots.ts‎

Lines changed: 115 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@
3737
* for the "generating" shot, and answers case, attack and source writes
3838
* locally. The PDF export's HTML (the web export cannot run expo-print) is
3939
* rendered in a page of its own, and the "Copy as text" output is written
40-
* next to the screenshots.
40+
* next to the screenshots. Brainstorm and Factoids are shot the same way,
41+
* from a fixed brainstorm and fixed factoids, and save nothing.
4142
*/
4243
import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page } from '@playwright/test';
4344
import { createReadStream, existsSync, statSync } from 'node:fs';
@@ -318,16 +319,50 @@ const FAKE_ROW = (table: string) => ({
318319
created_at: new Date().toISOString(),
319320
});
320321

321-
/** Fakes Generate's and Attack's AI calls and saves for this page (see the header). `hold` keeps Generate waiting. */
322-
async function fakeResults(page: Page, hold: boolean) {
322+
// A made-up brainstorm in the shape the model returns (Top 5), before the website's transform.
323+
const FAKE_BRAINSTORM = {
324+
needsMoreInfo: false,
325+
topFive: [
326+
{ 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: [] } },
327+
{ 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: [] } },
328+
{ 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: [] } },
329+
{ 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: [] } },
330+
{ 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: [] } },
331+
],
332+
nextSteps: ['Find one city that closed its centre and measured the air', 'Prepare an answer on exemptions'],
333+
warningsAndTradeoffs: ['The footfall evidence comes from large cities.'],
334+
};
335+
// Made-up factoids: three verified, one unverified that the website's selection drops.
336+
const FAKE_FACTOIDS = {
337+
factoids: [
338+
{ 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 },
339+
{ 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 },
340+
{ 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 },
341+
{ id: 'f4', shortClaim: 'An unverified claim', keyFigure: '?', context: '', limitations: '', citation: { title: 'Unknown', publisher: 'Unknown', verified: 'unverified' }, howToUse: '', relevanceScore: 10 },
342+
],
343+
searchSuggestions: ['car-free city centre asthma study', 'pedestrian zone retail footfall'],
344+
warnings: ['Most studies come from large European cities.'],
345+
verificationStatus: 'complete',
346+
};
347+
348+
type FakeFeature = 'generate' | 'brainstorm' | 'factoids';
349+
350+
/** Fakes the tools' AI calls and saves for this page (see the header). `hold` keeps that tool waiting. */
351+
async function fakeResults(page: Page, hold: FakeFeature | null) {
352+
const outputs: Record<string, { output: unknown; discoveredSources: unknown[] }> = {
353+
generate: { output: FAKE_CASE, discoveredSources: FAKE_SOURCES },
354+
attack: { output: FAKE_ATTACK, discoveredSources: [] },
355+
brainstorm: { output: FAKE_BRAINSTORM, discoveredSources: FAKE_SOURCES },
356+
factoids: { output: FAKE_FACTOIDS, discoveredSources: FAKE_SOURCES },
357+
};
323358
await page.route(`https://${STAGING_HOST}/functions/v1/ai`, async (route) => {
324359
const feature = (route.request().postDataJSON() as { feature?: string } | null)?.feature;
325-
if (feature === 'generate' && hold) return; // Never answered: the page shows its progress state.
326-
if (feature === 'generate') return route.fulfill({ status: 200, headers: { 'access-control-allow-origin': '*' }, json: { output: FAKE_CASE, discoveredSources: FAKE_SOURCES } });
327-
if (feature === 'attack') return route.fulfill({ status: 200, headers: { 'access-control-allow-origin': '*' }, json: { output: FAKE_ATTACK, discoveredSources: [] } });
360+
if (feature && feature === hold) return; // Never answered: the page shows its progress state.
361+
const json = feature ? outputs[feature] : undefined;
362+
if (json) return route.fulfill({ status: 200, headers: { 'access-control-allow-origin': '*' }, json });
328363
return route.abort();
329364
});
330-
for (const table of ['cases', 'attacks', 'sources', 'source_folders']) {
365+
for (const table of ['cases', 'attacks', 'brainstorms', 'factoids', 'sources', 'source_folders']) {
331366
await page.route(`${REST}/${table}*`, (route) =>
332367
route.request().method() === 'POST' ? route.fulfill({ status: 201, json: FAKE_ROW(table) }) : route.continue(),
333368
);
@@ -341,7 +376,7 @@ async function shootResultScreens(context: BrowserContext, prefix: string, langu
341376
watchErrors(page, problems, `${prefix} results`);
342377
page.on('dialog', (dialog) => void dialog.dismiss());
343378
await pinSettings(page, language);
344-
await fakeResults(page, hold);
379+
await fakeResults(page, hold ? 'generate' : null);
345380
return page;
346381
};
347382

@@ -399,6 +434,67 @@ async function shootResultScreens(context: BrowserContext, prefix: string, langu
399434
await page.close();
400435
}
401436

437+
const TOOLS = {
438+
brainstorm: { field: 'brainstorm-motion', submit: /^(Start Brainstorming|Ötletelés Indítása|Comenzar Lluvia de Ideas)$/, document: 'brainstorm-document', working: 'brainstorming' },
439+
factoids: { field: 'factoids-claim', submit: /^(Find Factoids|Tények Keresése|Buscar Datos)$/, document: 'factoids-document', working: 'finding-factoids' },
440+
} as const;
441+
const FACTOIDS_CLAIM = 'Car-free city centres cut asthma admissions';
442+
443+
/** Brainstorm or Factoids: the form, its progress, the document open and collapsed, and Export, from fixed outputs. */
444+
async function shootTool(context: BrowserContext, prefix: string, language: string, problems: string[], tool: keyof typeof TOOLS) {
445+
const spec = TOOLS[tool];
446+
const open = async (hold: boolean) => {
447+
const page = await context.newPage();
448+
watchErrors(page, problems, `${prefix} ${tool}`);
449+
page.on('dialog', (dialog) => void dialog.dismiss());
450+
await pinSettings(page, language);
451+
await fakeResults(page, hold ? tool : null);
452+
await page.goto(`${ORIGIN}/prepare`);
453+
await page.getByTestId(`prepare-tool-${tool}`).waitFor({ timeout: 30_000 });
454+
await settle(page);
455+
await page.getByTestId(`prepare-tool-${tool}`).click();
456+
await page.getByTestId(spec.field).waitFor({ timeout: 30_000 });
457+
// Brainstorm takes the Current Debate's motion; Factoids research a claim the student types.
458+
if (tool === 'brainstorm') await page.waitForFunction((id) => (document.querySelector(`[data-testid="${id}"]`) as HTMLTextAreaElement | null)?.value.trim(), spec.field);
459+
else await page.getByTestId(spec.field).fill(FACTOIDS_CLAIM);
460+
return page;
461+
};
462+
463+
const waiting = await open(true);
464+
await shoot(waiting, `${prefix}-${tool}-form`);
465+
await waiting.getByRole('button', { name: spec.submit }).click();
466+
await waiting.getByTestId('tool-progress').waitFor({ timeout: 15_000 });
467+
await shoot(waiting, `${prefix}-${spec.working}`);
468+
await waiting.close();
469+
470+
const page = await open(false);
471+
await page.getByRole('button', { name: spec.submit }).click();
472+
await page.getByTestId(spec.document).waitFor({ timeout: 30_000 });
473+
await page.waitForTimeout(400);
474+
await shoot(page, `${prefix}-${tool}`);
475+
// Close the open item, so every item shows collapsed.
476+
await page.getByTestId('document-item').first().click();
477+
await shoot(page, `${prefix}-${tool}-collapsed`);
478+
await page.getByTestId('document-item').first().click();
479+
480+
await page.getByTestId('export-button').click();
481+
await page.getByTestId('export-menu').waitFor();
482+
await shoot(page, `${prefix}-${tool}-export-menu`);
483+
await page.getByTestId('export-menu').getByRole('menuitem').first().click();
484+
await page.getByTestId('export-button').click();
485+
await page.getByTestId('export-menu').getByRole('menuitem').nth(1).click();
486+
const exported = await page.evaluate(() => (window as unknown as { __debatelabExport?: { html?: string; fileName?: string; copied?: string } }).__debatelabExport ?? {});
487+
if (!exported.html || !exported.copied) throw new Error(`${tool} export did not hand over the PDF HTML or the copied text`);
488+
await writeFile(join(OUT, `${prefix}-${tool}-copy.txt`), exported.copied);
489+
const pdf = await context.newPage();
490+
await pdf.setViewportSize({ width: 794, height: 1123 });
491+
await pdf.setContent(exported.html);
492+
await pdf.screenshot({ path: join(OUT, `${prefix}-${tool}-pdf-html.png`), fullPage: true });
493+
await pdf.close();
494+
console.log(`${prefix} ${tool}: PDF file name ${exported.fileName}, copied ${exported.copied.length} characters`);
495+
await page.close();
496+
}
497+
402498
async function shootLibraryScreens(page: Page, prefix: string) {
403499
await page.goto(`${ORIGIN}/library`);
404500
await page.getByTestId('library-add').waitFor({ timeout: 30_000 });
@@ -477,6 +573,13 @@ async function shootOneOffTools(page: Page, prefix: string) {
477573
await page.getByTestId('one-off-notice').filter({ visible: true }).waitFor();
478574
await settle(page);
479575
await shoot(page, `${prefix}-prepare-oneoff`);
576+
// Brainstorm and Factoids say it through the same strip; a reload would end one-off mode, so go back in the app.
577+
for (const tool of ['brainstorm', 'factoids'] as const) {
578+
await page.getByTestId(`prepare-tool-${tool}`).click();
579+
await page.getByTestId(`${tool}-screen`).getByTestId('one-off-notice').waitFor({ timeout: 30_000 });
580+
await page.goBack();
581+
await page.getByTestId('prepare-tool-generate').filter({ visible: true }).waitFor();
582+
}
480583
await page.getByTestId('prepare-tool-generate').click();
481584
await page.getByTestId('generate-motion').waitFor({ timeout: 30_000 });
482585
const notice = page.getByTestId('generate-screen').getByTestId('one-off-notice');
@@ -644,6 +747,10 @@ async function main() {
644747
shots += 3;
645748
await shootResultScreens(signedIn, prefix, language, problems);
646749
shots += 9;
750+
for (const tool of ['brainstorm', 'factoids'] as const) {
751+
await shootTool(signedIn, prefix, language, problems, tool);
752+
shots += 6;
753+
}
647754
const oneOffPage = await signedIn.newPage();
648755
watchErrors(oneOffPage, problems, `${prefix} one-off tools`);
649756
await pinSettings(oneOffPage, language);

‎apps/mobile/src/app/(tabs)/(debate)/index.tsx‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { Card, IconTile } from '@/components/card';
99
import { Icon } from '@/components/icon';
1010
import type { IconName } from '@/components/icon-paths';
1111
import { OneOffNotice } from '@/components/one-off-notice';
12-
import { PREPARE_TOOLS } from '@/components/prepare-tools';
12+
import { PREPARE_TOOLS, prepareToolHref } from '@/components/prepare-tools';
1313
import { Screen } from '@/components/screen';
1414
import { SegmentedControl } from '@/components/segmented-control';
1515
import { Text } from '@/components/text';
@@ -194,10 +194,7 @@ function DebateDetails({ debateId }: { debateId: string }) {
194194
key={tool.key}
195195
icon={tool.icon}
196196
label={t(`nav.${tool.key}`)}
197-
onPress={() =>
198-
tool.key === 'generate'
199-
? router.push({ pathname: '/generate', params: { from: 'debate' } })
200-
: router.push({ pathname: '/coming/[tool]', params: { tool: tool.key } })}
197+
onPress={() => router.push(prepareToolHref(tool.key, 'debate'))}
201198
/>
202199
))}
203200
</View>

0 commit comments

Comments
 (0)