diff --git a/.github/workflows/agentic-e2e.yml b/.github/workflows/agentic-e2e.yml index 59ce2aacf..be4cbde71 100644 --- a/.github/workflows/agentic-e2e.yml +++ b/.github/workflows/agentic-e2e.yml @@ -11,6 +11,7 @@ on: pull_request: paths: - "src/**" + - "packages/**" - "public/**" - "index.html" - "supabase/functions/**" diff --git a/.github/workflows/database.yml b/.github/workflows/database.yml index 313d4c64d..5d4c91132 100644 --- a/.github/workflows/database.yml +++ b/.github/workflows/database.yml @@ -12,6 +12,7 @@ on: - "src/lib/supabase/**" - "src/lib/stores/**" - "src/lib/ai/**" + - "packages/**" - "e2e/full-stack/**" - "e2e/workspace-collaboration.spec.ts" - "playwright.workspace.config.ts" @@ -31,6 +32,7 @@ on: - "src/lib/supabase/**" - "src/lib/stores/**" - "src/lib/ai/**" + - "packages/**" - "e2e/full-stack/**" - "e2e/workspace-collaboration.spec.ts" - "playwright.workspace.config.ts" diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index ecb81e64f..e1adf8564 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -8,6 +8,7 @@ on: paths: - "supabase/functions/**" - "src/lib/ai/**" + - "packages/**" - "e2e/live/**" - "e2e/full-stack/fake-openrouter.mjs" - ".superset/db-gate.sh" diff --git a/.github/workflows/model-matrix.yml b/.github/workflows/model-matrix.yml index b92e1ecf7..19db35d24 100644 --- a/.github/workflows/model-matrix.yml +++ b/.github/workflows/model-matrix.yml @@ -10,6 +10,7 @@ on: - supabase/functions/_shared/** - supabase/functions/ai/** - src/lib/ai/** + - packages/** - e2e/model-matrix/** - .github/workflows/model-matrix.yml schedule: diff --git a/AGENTS.md b/AGENTS.md index 02e6d895e..e6a14e557 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ The local Supabase stack still uses shared default ports and container names. In - `src/` is the React 18 and TypeScript client. Routes and lazy page entry points live in `src/App.tsx`. - `src/lib/stores/` owns browser state and Supabase hydration. `src/lib/ai/client.ts` is the client AI boundary. +- `packages/core/` (`@debatelab/core`, npm workspace) holds platform-neutral shared code: Zod schemas, validation, languages, Crossfire and Sparring logic. No DOM globals, React, or `import.meta` there; old `src/lib/` paths re-export it. Stores and i18n catalogs stay in `src/`. - `supabase/functions/` contains Deno Edge Functions. Shared auth, CORS, research, prompts, cost, and validation code lives in `supabase/functions/_shared/`. - `supabase/migrations/` is the database history and schema source of truth. - `src/lib/stores/debate-store.ts` owns the Current Debate: every source and saved result belongs to one debate or to one-off work, and nothing crosses debates. diff --git a/README.md b/README.md index 0ca884e4c..3ce2e0f8f 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,8 @@ Key entry points: ## Repository map ```text -src/ React client, stores, hooks, schemas, and unit tests +src/ React client, stores, hooks, and unit tests +packages/core/ Platform-neutral shared code: schemas, validation, languages supabase/ Edge Functions, migrations, pgTAP tests, local config e2e/ Playwright browser, full-stack, live, staging, and canary tests services/ Collaboration service for the hidden Workspace diff --git a/docs/architecture.md b/docs/architecture.md index cf01b22d4..bfc5088db 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -23,6 +23,10 @@ Collaborative Workspace (`/app/workspaces/*`, the `workspace-invites` function, `AuthProvider`, TanStack Query, theme and motion providers, toast systems, the offline indicator, and the route error boundary wrap the routed application. +## Workspace layout + +The repository is an npm workspace. The web app is the root package and still lives in `src/`. [`packages/core/`](../packages/core/README.md) (`@debatelab/core`) holds platform-neutral TypeScript the iOS app will share: languages, input validation, the Zod schemas for AI requests and results, Crossfire and Sparring logic, and the Edge Function error reader. Its code may not use DOM globals, React, `import.meta`, or Deno specifiers; its tsconfig has no DOM library and ESLint enforces the rest. Each moved module leaves a one-line re-export at its old `src/lib/` path, so web imports and test mocks are unchanged. The stores, the AI client and streaming, the Supabase client, and the i18n catalogs stay in `src/` for now. + ## Client state [`src/lib/stores/session-store.ts`](../src/lib/stores/session-store.ts) owns active AI results, conversations, saved work, sources, folders, flowcharts, autosave state, and persisted form state. It persists selected local state and hydrates authenticated rows from Supabase. @@ -37,7 +41,7 @@ Collaborative Workspace (`/app/workspaces/*`, the `workspace-invites` function, ## Language behavior -[`src/lib/languages.ts`](../src/lib/languages.ts) is the shared catalog for English, Spanish, and Hungarian language codes, labels, and Intl locales. [`src/i18n.ts`](../src/i18n.ts) owns the locale resources and applies the selected interface language to i18next, document metadata, and ``. Logged-out startup preserves local language preferences; authenticated hydration accepts only catalog languages. +[`packages/core/src/languages.ts`](../packages/core/src/languages.ts) is the shared catalog for English, Spanish, and Hungarian language codes, labels, and Intl locales. [`src/i18n.ts`](../src/i18n.ts) owns the locale resources and applies the selected interface language to i18next, document metadata, and ``. Logged-out startup preserves local language preferences; authenticated hydration accepts only catalog languages. Interface language and AI-output language are separate settings. Interface language controls rendered copy, accessible names, generated app boilerplate, dates, numbers, relative time, PDF chrome, and Web Speech's recognition locale for dictation. Voice rounds recognise speech in the AI-output language instead, because that is the language the round is held in. AI-output language is sent through every structured, streaming, Crossfire, and Sparring request path and becomes an explicit backend prompt instruction. Existing user/provider content and previously saved titles are not rewritten when either setting changes. @@ -68,7 +72,7 @@ For a validated Crossfire next turn, the server returns the reply first with a s Sparring and Crossfire can be spoken. The setup screen offers a voice switch only where the browser has the Web Speech recogniser and the microphone is not blocked; otherwise it shows a one-line reason and the round is typed as before. The first time the switch is turned on, a notice says where audio and text go, and [`src/lib/voice/availability.ts`](../src/lib/voice/availability.ts) remembers the acknowledgement in that browser. -The student's speech never reaches DebateLab as audio. [`src/hooks/use-speech-capture.ts`](../src/hooks/use-speech-capture.ts) feeds the microphone to the browser's recogniser (in Chrome that sends audio to Google), restarts it when the browser ends a session on silence, and puts the recognised words into the same draft a typed turn uses. It also times the take, and [`src/lib/voice/delivery.ts`](../src/lib/voice/delivery.ts) turns that into measured facts: speaking time, words per minute, filler words from a per-language list, and silences of three seconds or more. The facts ride on the user's speech or turn as an optional `delivery` field, so sessions and drafts from before voice rounds still load. The sparring scorecard and the Crossfire debrief show them as numbers. The sparring judge receives them with a prompt that allows it to quote those numbers and comment on structure, and forbids any claim about tone, confidence, or volume; typed rounds keep the typed prompt unchanged. The Crossfire debrief is never asked to judge delivery. +The student's speech never reaches DebateLab as audio. [`src/hooks/use-speech-capture.ts`](../src/hooks/use-speech-capture.ts) feeds the microphone to the browser's recogniser (in Chrome that sends audio to Google), restarts it when the browser ends a session on silence, and puts the recognised words into the same draft a typed turn uses. It also times the take, and [`packages/core/src/voice/delivery.ts`](../packages/core/src/voice/delivery.ts) turns that into measured facts: speaking time, words per minute, filler words from a per-language list, and silences of three seconds or more. The facts ride on the user's speech or turn as an optional `delivery` field, so sessions and drafts from before voice rounds still load. The sparring scorecard and the Crossfire debrief show them as numbers. The sparring judge receives them with a prompt that allows it to quote those numbers and comment on structure, and forbids any claim about tone, confidence, or volume; typed rounds keep the typed prompt unchanged. The Crossfire debrief is never asked to judge delivery. A voice request adds a top-level `voice: true` beside `input`, where a server from before voice rounds ignores it. The opponent prompt then allows a short allowlist of inline audio tags such as `[sighs]` and `[sarcastic]`. [`supabase/functions/_shared/voice-tags.ts`](../supabase/functions/_shared/voice-tags.ts) splits the model's reply in two: the clean text, with every tag removed, is what the response's `output` carries, what is signed, shown, saved, and read by the fallback voice; the spoken text keeps allowlisted tags, turns `[pause]` into the ellipsis Eleven v4 documents for pauses, rations tags to at most one per sentence and never two sentences running, and is cut into segments of at most 1,000 characters. Up to 12 segments are returned in a top-level `voice.segments` list, each with a 30-minute HMAC token from [`_shared/voice-token.ts`](../supabase/functions/_shared/voice-token.ts) that binds the text to the user and the feature; the verifier allows 30 seconds of clock difference between the two functions. Text past the twelfth segment comes back unsigned and without tags in `voice.overflow`, which only the browser voice reads, so a long reply is heard in full without raising the ElevenLabs cost of one reply. The spoken text lives only in memory in the browser. diff --git a/docs/development.md b/docs/development.md index 08165c2c3..f383aeb64 100644 --- a/docs/development.md +++ b/docs/development.md @@ -31,6 +31,8 @@ nvm use npm ci ``` +The repository is an npm workspace: `npm ci` at the root installs the web app and every package under `packages/`, and links `@debatelab/core` into `node_modules`. Run every script from the root. + ## Start the client Outside Superset, start Vite directly: @@ -85,7 +87,9 @@ The main entry points are: - `src/components/features/` for feature UI. - `src/lib/stores/` for state and Supabase hydration. - [`src/lib/ai/client.ts`](../src/lib/ai/client.ts) for AI requests. -- `src/lib/ai/schemas/` for response validation. +- `packages/core/src/ai/schemas/` for response validation. + +Platform-neutral code shared with the iOS app lives in [`packages/core/`](../packages/core/README.md). It mirrors `src/lib/` paths, and each old path re-exports it, so web code keeps importing `@/lib/...`. To add a module, put it under `packages/core/src/`, import siblings by relative path, leave a re-export at the old `src/lib/` path if web code used it, and follow the package's no-DOM rule. The stores, the AI client, and the i18n catalogs stay in `src/` for now. Follow existing functional React, Zustand, Zod, and shadcn/Radix patterns. Reuse current helpers before adding another abstraction. diff --git a/eslint.config.js b/eslint.config.js index 1ede366cd..184bd0060 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,10 +5,11 @@ import reactRefresh from "eslint-plugin-react-refresh"; import tseslint from "typescript-eslint"; export default tseslint.config( - { ignores: ["dist", "coverage", ".remember"] }, + { ignores: ["dist", "coverage", ".remember", "packages/**/node_modules", "packages/**/dist"] }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ["**/*.{ts,tsx}"], + ignores: ["packages/**"], languageOptions: { ecmaVersion: 2020, globals: globals.browser, @@ -23,11 +24,47 @@ export default tseslint.config( "@typescript-eslint/no-unused-vars": "off", }, }, + { + // Shared packages run on the web and in React Native, so they get no + // browser globals and no web-only libraries. See packages/core/README.md. + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ["packages/**/*.ts"], + languageOptions: { + ecmaVersion: 2022, + globals: globals.es2021, + }, + rules: { + "@typescript-eslint/no-unused-vars": "off", + "no-restricted-globals": [ + "error", + ...["window", "document", "localStorage", "sessionStorage", "navigator", "location", "self"].map((name) => ({ + name, + message: "Shared packages cannot use browser globals. Take the value as a parameter.", + })), + ], + "no-restricted-syntax": [ + "error", + { selector: "MetaProperty[meta.name='import']", message: "Shared packages cannot use import.meta. Take the value as a parameter." }, + ], + "no-restricted-imports": [ + "error", + { + paths: [ + "react", "react-dom", "react-native", "react-i18next", "zustand", "@supabase/supabase-js", "posthog-js", + "sonner", "react-router-dom", "reactflow", "lucide-react", "framer-motion", "jspdf", "docx", + ].map((name) => ({ name, message: "Shared packages stay platform-neutral; keep this in the app." })), + patterns: [ + { group: ["@sentry/*", "@radix-ui/*"], message: "Shared packages stay platform-neutral; keep this in the app." }, + ], + }, + ], + }, + }, { // A promise nobody awaits or catches turns a failed write into an // unhandled rejection with no message for the user. Type-aware, so it - // runs on the client app only. - files: ["src/**/*.{ts,tsx}"], + // runs on the client app and the shared packages only. + files: ["src/**/*.{ts,tsx}", "packages/**/src/**/*.ts"], ignores: ["src/test/**"], languageOptions: { parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname }, diff --git a/knip.json b/knip.json index 55052f431..593c8c361 100644 --- a/knip.json +++ b/knip.json @@ -1,36 +1,45 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", - "entry": [ - "scripts/*.{mjs,ts}", - "scripts/staging/seed.mjs", - "e2e/full-stack/fake-openrouter.mjs", - "e2e.config.ts", - "agentic/**/*.e2e.ts", - "services/collaboration/src/index.ts", - "services/collaboration/src/**/*.test.ts", - ".claude/skills/verify-debatelab/bin/*.mjs", - ".claude/skills/verify-debatelab/recipes/*.spec.ts", - "e2e/**/*.setup.ts", - ".claude/skills/verify-debatelab/playwright.verify.config.ts" - ], - "project": [ - "src/**/*.{ts,tsx}", - "scripts/**/*.{mjs,ts}", - "e2e/**/*.{ts,mjs}", - "agentic/**/*.ts", - "services/collaboration/src/**/*.ts", - ".claude/skills/verify-debatelab/**/*.{ts,mjs}" - ], - "playwright": { - "config": [ - "playwright.config.ts", - "playwright.production.config.ts", - "playwright.workspace.config.ts" - ] + "workspaces": { + ".": { + "entry": [ + "scripts/*.{mjs,ts}", + "scripts/staging/seed.mjs", + "e2e/full-stack/fake-openrouter.mjs", + "e2e.config.ts", + "agentic/**/*.e2e.ts", + "services/collaboration/src/index.ts", + "services/collaboration/src/**/*.test.ts", + ".claude/skills/verify-debatelab/bin/*.mjs", + ".claude/skills/verify-debatelab/recipes/*.spec.ts", + "e2e/**/*.setup.ts", + ".claude/skills/verify-debatelab/playwright.verify.config.ts" + ], + "project": [ + "src/**/*.{ts,tsx}", + "scripts/**/*.{mjs,ts}", + "e2e/**/*.{ts,mjs}", + "agentic/**/*.ts", + "services/collaboration/src/**/*.ts", + ".claude/skills/verify-debatelab/**/*.{ts,mjs}" + ], + "playwright": { + "config": [ + "playwright.config.ts", + "playwright.production.config.ts", + "playwright.workspace.config.ts" + ] + }, + "ignore": [ + "src/lib/workspace/document-conversion.ts" + ] + }, + "packages/core": { + "project": [ + "src/**/*.ts" + ] + } }, - "ignore": [ - "src/lib/workspace/document-conversion.ts" - ], "ignoreBinaries": [ "supabase" ], diff --git a/package-lock.json b/package-lock.json index 04b1a313c..545dd24d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "vite_react_shadcn_ts", "version": "0.0.0", + "workspaces": [ + "packages/*" + ], "dependencies": { "@hocuspocus/provider": "4.7.0", "@hocuspocus/server": "4.7.0", @@ -506,6 +509,10 @@ "node": ">= 20.12.0" } }, + "node_modules/@debatelab/core": { + "resolved": "packages/core", + "link": true + }, "node_modules/@e2e-dev/web": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@e2e-dev/web/-/web-0.12.0.tgz", @@ -14266,6 +14273,13 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } + }, + "packages/core": { + "name": "@debatelab/core", + "version": "0.0.0", + "dependencies": { + "zod": "^4.3.6" + } } } } diff --git a/package.json b/package.json index 193090f52..862ded4f7 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,9 @@ "engines": { "node": "22.x" }, + "workspaces": [ + "packages/*" + ], "scripts": { "dev": "vite", "dev:preview": "VITE_STAGING_PREVIEW=true vite --host 127.0.0.1", @@ -16,7 +19,7 @@ "build:dev": "vite build --mode development", "lint": "eslint . && npm run lint:repo", "lint:repo": "knip --no-config-hints && node scripts/check-repo-rules.mjs", - "typecheck": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.agentic.json", + "typecheck": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.agentic.json && tsc --noEmit -p packages/core/tsconfig.json", "preview": "vite preview", "smoke:jev": "node scripts/smoke-jev.mjs", "test": "vitest run", diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 000000000..f07f6b168 --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,22 @@ +# @debatelab/core + +Platform-neutral TypeScript shared by the web app and, later, the iOS app: languages, input validation, Zod schemas for AI requests and results, Crossfire and Sparring logic, and the Edge Function error reader. Files keep their old `src/lib/` relative paths, and each old path is now a one-line re-export, so web imports did not change. + +## The rule + +Code here must run in a browser and in React Native without changes: + +- No DOM globals: no `window`, `document`, `localStorage`, `sessionStorage`, `navigator`, or `location`. Take a value or a small interface as a parameter instead, as [`crossfire/storage.ts`](src/crossfire/storage.ts) does with `StorageLike`. +- No React, no web-only libraries (stores, i18n bindings, the Supabase client, analytics, UI kits). +- No `import.meta`, and no Deno or `esm.sh` specifiers. +- Plain TypeScript. The only dependency is `zod`, at the same range as the root. + +[`tsconfig.json`](tsconfig.json) has no DOM library, and ESLint rejects browser globals and web-only imports here. [`src/globals.d.ts`](src/globals.d.ts) declares the few runtime globals both platforms have that the ES library lacks; add to it only what package code uses. + +`src/contract/` re-exports the request contracts in `supabase/functions/_shared/` so package code has one import path for them. Those files stay where the Edge Functions import them. + +## Adding a module + +1. Move or write the file under `src/`, importing siblings by relative path (never `@/`). +2. If web code already imports it from `src/lib/`, leave `export * from '@debatelab/core/';` at the old path. +3. Run `npm run typecheck` and `npm run lint` from the repository root. diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 000000000..62bafba3c --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,12 @@ +{ + "name": "@debatelab/core", + "private": true, + "version": "0.0.0", + "type": "module", + "exports": { + "./*": "./src/*.ts" + }, + "dependencies": { + "zod": "^4.3.6" + } +} diff --git a/packages/core/src/ai/providers/types.ts b/packages/core/src/ai/providers/types.ts new file mode 100644 index 000000000..8fcf04971 --- /dev/null +++ b/packages/core/src/ai/providers/types.ts @@ -0,0 +1,31 @@ +// packages/core/src/ai/providers/types.ts + +import { MODEL_CATALOG } from '../../contract/model-catalog'; +import type { ReasoningEffort } from '../../contract/model-catalog'; + +export { + AUTO_MODEL_SELECTIONS, + DEFAULT_MODEL, + DEFAULT_PRO_MODEL, + isAutoModelSelection, + isModelSelection, + isReasoningEffort, +} from '../../contract/model-catalog'; +export type { + AutoModelSelection, + ModelSelection, + ReasoningEffort, +} from '../../contract/model-catalog'; +export type { ModelCatalogEntry as AIModel } from '../../contract/model-catalog'; + +/** Every accepted model routes through OpenRouter; see _shared/model-catalog. */ +export const PROVIDER_ID = 'openrouter'; + +export const AVAILABLE_MODELS = MODEL_CATALOG; + +export interface ProviderConfig { + provider: string; + model: string; + temperature: number; + reasoningEffort?: ReasoningEffort; +} diff --git a/packages/core/src/ai/schemas/ask.ts b/packages/core/src/ai/schemas/ask.ts new file mode 100644 index 000000000..9a6554c6a --- /dev/null +++ b/packages/core/src/ai/schemas/ask.ts @@ -0,0 +1,58 @@ +// packages/core/src/ai/schemas/ask.ts +import { z } from 'zod'; +import { SafeWebUrlSchema } from './url'; + +export const AskInputSchema = z.object({ + message: z.string().min(1, 'Message is required'), + mode: z.enum(['general', 'clarify', 'summarize', 'proscons', 'argumentmap']), + citationMode: z.boolean(), + webSearch: z.boolean().optional(), + prosConsMode: z.boolean().optional(), + argumentMapMode: z.boolean().optional(), + conversationHistory: z.array(z.object({ + role: z.enum(['user', 'assistant']), + content: z.string(), + })), +}); + +export type AskInput = z.infer; + +export const AskOutputSchema = z.object({ + response: z.string(), + model: z.string().optional(), + provider: z.string().optional(), + durationMs: z.number().optional(), + generatedAt: z.string().optional(), + citations: z.array(z.object({ + claim: z.string(), + source: z.string().optional(), + verified: z.boolean(), + note: z.string().optional(), + })).optional(), + searchInfo: z.object({ + webSearchUsed: z.boolean(), + queries: z.array(z.string()), + sources: z.array(z.object({ + title: z.string(), + url: SafeWebUrlSchema, + verified: z.boolean(), + sourceType: z.string().optional(), + qualityScore: z.number().optional(), + qualityLabel: z.enum(['strong', 'moderate', 'weak']).optional(), + })), + }).optional(), + followUpQuestions: z.array(z.string()), + prosConsAnalysis: z.object({ + pros: z.array(z.string()), + cons: z.array(z.string()), + }).optional(), + argumentMap: z.object({ + mainClaim: z.string(), + supportingPoints: z.array(z.string()), + counterpoints: z.array(z.string()), + assumptions: z.array(z.string()), + }).optional(), + uncertainties: z.array(z.string()), +}); + +export type AskOutput = z.infer; diff --git a/packages/core/src/ai/schemas/attack.ts b/packages/core/src/ai/schemas/attack.ts new file mode 100644 index 000000000..9db862b71 --- /dev/null +++ b/packages/core/src/ai/schemas/attack.ts @@ -0,0 +1,50 @@ +// packages/core/src/ai/schemas/attack.ts +import { z } from 'zod'; + +export const AttackInputSchema = z.object({ + caseText: z.string().min(1, 'Case text is required'), + aggressiveness: z.enum(['mild', 'moderate', 'ruthless']), + judgeParadigm: z.enum(['logic', 'values', 'policy']), + detectFallacies: z.boolean(), + analyzeWeighing: z.boolean(), +}); + +export type AttackInput = z.infer; + +export const WeaknessSchema = z.object({ + id: z.string(), + severity: z.enum(['critical', 'major', 'minor']), + type: z.enum(['logical', 'evidential', 'structural', 'strategic']), + description: z.string(), + whyWeak: z.string(), + howOpponentAttacks: z.string(), + improvedVersion: z.string(), + affectedQuote: z.string().optional(), +}); + +export const AttackOutputSchema = z.object({ + overallAssessment: z.object({ + score: z.number().min(0).max(100), + summary: z.string(), + strengthAreas: z.array(z.string()), + }), + weaknesses: z.array(WeaknessSchema), + fallaciesDetected: z.array(z.object({ + name: z.string(), + location: z.string(), + explanation: z.string(), + })), + crossExQuestions: z.array(z.object({ + question: z.string(), + purpose: z.string(), + expectedTrap: z.string(), + })), + weighingAnalysis: z.object({ + currentWeighing: z.string(), + missingComparisons: z.array(z.string()), + improvementSuggestions: z.array(z.string()), + }), +}); + +export type AttackOutput = z.infer; +export type Weakness = z.infer; diff --git a/packages/core/src/ai/schemas/brainstorm.ts b/packages/core/src/ai/schemas/brainstorm.ts new file mode 100644 index 000000000..75f50a622 --- /dev/null +++ b/packages/core/src/ai/schemas/brainstorm.ts @@ -0,0 +1,47 @@ +// packages/core/src/ai/schemas/brainstorm.ts +import { z } from 'zod'; + +export const BrainstormInputSchema = z.object({ + motion: z.string().min(1, 'Motion is required'), + side: z.enum(['pro', 'con', 'both']), + context: z.string().optional(), + outputMode: z.enum(['mindmap', 'buckets', 'topfive']), +}); + +export type BrainstormInput = z.infer; + +export const IdeaSchema = z.object({ + id: z.string(), + title: z.string(), + description: z.string(), + side: z.enum(['pro', 'con', 'neutral']), + strength: z.enum(['strong', 'moderate', 'weak']), + tradeoffs: z.array(z.string()), + relatedTo: z.array(z.string()), +}); + +export const BucketSchema = z.object({ + name: z.string(), + ideas: z.array(IdeaSchema), + keyInsight: z.string(), +}); + +export const BrainstormOutputSchema = z.object({ + clarifyingQuestions: z.array(z.string()).optional(), + needsMoreInfo: z.boolean(), + mindmap: z.array(z.object({ + category: z.string(), + items: z.array(IdeaSchema), + })).optional(), + buckets: z.array(BucketSchema).optional(), + topFive: z.array(z.object({ + rank: z.number(), + idea: IdeaSchema, + whyTop: z.string(), + })).optional(), + nextSteps: z.array(z.string()), + warningsAndTradeoffs: z.array(z.string()), +}); + +export type BrainstormOutput = z.infer; +export type Idea = z.infer; diff --git a/packages/core/src/ai/schemas/factoids.ts b/packages/core/src/ai/schemas/factoids.ts new file mode 100644 index 000000000..cb1e60e6a --- /dev/null +++ b/packages/core/src/ai/schemas/factoids.ts @@ -0,0 +1,46 @@ +// packages/core/src/ai/schemas/factoids.ts +import { z } from 'zod'; +import { SafeWebUrlSchema } from './url'; + +export const FactoidsInputSchema = z.object({ + claim: z.string().min(1, 'Claim is required'), + direction: z.enum(['for', 'against']).default('for'), + region: z.string().optional(), + sourceType: z.enum(['academic', 'government', 'news', 'any']), + timeframe: z.string().optional(), + sourceCount: z.coerce.number().int().min(3).max(15).default(5), +}); + +export type FactoidsInput = z.infer; + +export const CitationSchema = z.object({ + title: z.string(), + publisher: z.string(), + date: z.string().optional(), + url: SafeWebUrlSchema.optional(), + verified: z.enum(['verified', 'unverified', 'pending']), + verifiedAt: z.string().optional(), +}); + +export const FactoidSchema = z.object({ + id: z.string(), + shortClaim: z.string(), + keyFigure: z.string(), + context: z.string(), + limitations: z.string(), + citation: CitationSchema, + howToUse: z.string(), + relevanceScore: z.number().min(0).max(100), +}); + +export const FactoidsOutputSchema = z.object({ + factoids: z.array(FactoidSchema), + searchSuggestions: z.array(z.string()), + warnings: z.array(z.string()), + verificationStatus: z.enum(['pending', 'inProgress', 'complete']), + filteredOutCount: z.number().int().nonnegative().optional(), +}); + +export type FactoidsOutput = z.infer; +export type Factoid = z.infer; +export type Citation = z.infer; diff --git a/packages/core/src/ai/schemas/generate.ts b/packages/core/src/ai/schemas/generate.ts new file mode 100644 index 000000000..1d6c6a6fd --- /dev/null +++ b/packages/core/src/ai/schemas/generate.ts @@ -0,0 +1,72 @@ +// packages/core/src/ai/schemas/generate.ts +import { z } from 'zod'; + +export const GenerateInputSchema = z.object({ + motion: z.string().min(1, 'Motion is required'), + side: z.enum(['pro', 'con']), + format: z.enum(['custom', 'bp', 'policy', 'classroom']), // Kept others for backward compatibility but UI will restrict + role: z.enum(['intro', 'r1', 'r2', 'conc', 'general']), + length: z.enum(['brief', 'standard', 'comprehensive']), + audience: z.enum(['beginner', 'intermediate', 'advanced']), + tone: z.enum(['formal', 'conversational', 'aggressive']), +}); + +export type GenerateInput = z.infer; + +export const ContentionSchema = z.object({ + id: z.string(), + claim: z.string(), + reasoning: z.string(), + evidence: z.string(), + needsSource: z.boolean(), + sourceQuery: z.string().optional(), + impact: z.string(), +}); + +export const ExordioSchema = z.object({ + hook: z.string(), + transition: z.string(), +}); + +export const GenerateOutputSchema = z.object({ + exordio: ExordioSchema.optional(), + caseStructure: z.object({ + thesis: z.string(), + framework: z.string(), + keyDefinitions: z.array(z.string()), + burdenOfProof: z.string(), + }), + contentions: z.array(ContentionSchema), + predictedOpponentCase: z.object({ + likelyArguments: z.array(z.string()), + strategicWeaknesses: z.array(z.string()), + keyClashes: z.array(z.string()), + }), + counters: z.array(z.object({ + targetArgument: z.string(), + counterpoint: z.string(), + rebuttal: z.string(), + })), + metadata: z.object({ + side: z.enum(['pro', 'con']), + format: z.string(), + motion: z.string().optional(), + generatedAt: z.string(), + model: z.string().optional(), + provider: z.string().optional(), + durationMs: z.number().optional(), + sourceUsage: z.array(z.object({ + key: z.string(), + id: z.string().optional(), + title: z.string(), + origin: z.string().optional(), + importance: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)]), + strength: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)]), + probability: z.number().min(0).max(1), + judgment: z.enum(['used', 'uncertain', 'not_used']), + })).max(10).optional(), + }), +}); + +export type GenerateOutput = z.infer; +export type Contention = z.infer; diff --git a/packages/core/src/ai/schemas/source-import.ts b/packages/core/src/ai/schemas/source-import.ts new file mode 100644 index 000000000..8a809da41 --- /dev/null +++ b/packages/core/src/ai/schemas/source-import.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; +import { SafeWebUrlSchema } from './url'; + +export const SourceImportOutputSchema = z.object({ + title: z.string().min(1), + content: z.string().min(1), + wordCount: z.number().int().nonnegative(), + author: z.string().optional(), + publishedDate: z.string().optional(), + domain: z.string().min(1), + excerpt: z.string(), + url: SafeWebUrlSchema, +}); + +export type SourceImportOutput = z.infer; diff --git a/packages/core/src/ai/schemas/sparring.ts b/packages/core/src/ai/schemas/sparring.ts new file mode 100644 index 000000000..54a9504c5 --- /dev/null +++ b/packages/core/src/ai/schemas/sparring.ts @@ -0,0 +1,154 @@ +import { z } from 'zod'; +import { SpeechDeliverySchema } from '../../voice/delivery'; + +export const SparringSideSchema = z.enum(['pro', 'con']); +export const SparringFormatSchema = z.enum(['standard', 'ld', 'pf']); +export const SparringDifficultySchema = z.enum(['easy', 'medium', 'hard']); +export const SparringSpeakerSchema = z.enum(['user', 'ai']); +export const SparringTurnKindSchema = z.enum([ + 'introductor', + 'r1', + 'r2', + 'conclusor', + 'opening', + 'constructive', + 'rebuttal', + 'summary', + 'final', +]); + +export const SparringTurnSchema = z.object({ + id: z.string().trim().min(1), + label: z.string().trim().min(1), + side: SparringSideSchema, + kind: SparringTurnKindSchema, + timeLimitSeconds: z.number().int().positive(), +}); + +/** A completed turn from either debater, sent as context for the next call. */ +export const SparringSpeechSchema = z.object({ + id: z.string().trim().min(1), + turnId: z.string().trim().min(1), + speaker: SparringSpeakerSchema, + side: SparringSideSchema, + role: z.string().trim().min(1).max(120), + content: z.string().trim().min(1).max(6_000), + createdAt: z.number().finite(), + timeLimitSeconds: z.number().int().positive(), + timeUsedSeconds: z.number().int().nonnegative(), + integrityToken: z.string().trim().min(32).max(128).optional(), + /** Measured facts for a user speech that was spoken aloud in a voice round. */ + delivery: SpeechDeliverySchema.optional(), +}); + +/** Shared, bounded session context carried by both sparring actions. */ +export const SparringSessionSchema = z.object({ + sessionId: z.string().uuid(), + motion: z.string().trim().min(1).max(1_200), + userSide: SparringSideSchema, + format: SparringFormatSchema, + difficulty: SparringDifficultySchema, + speeches: z.array(SparringSpeechSchema).max(12), + language: z.string().trim().min(1).max(32), +}); + +export const SparringRespondInputSchema = SparringSessionSchema.extend({ + action: z.literal('respond'), + currentTurn: SparringTurnSchema, +}); + +export const SparringScoreInputSchema = SparringSessionSchema.extend({ + action: z.literal('score'), +}); + +export const SparringInputSchema = z.discriminatedUnion('action', [ + SparringRespondInputSchema, + SparringScoreInputSchema, +]); + +export const SparringTurnResponseSchema = z.object({ + action: z.literal('respond'), + speech: z.object({ + content: z.string().trim().min(1).max(6_000), + strategy: z.string().trim().min(1).max(2_000), + targetedPoints: z.array(z.string().trim().min(1).max(500)).max(8), + integrityToken: z.string().trim().min(32).max(128).optional(), + }), +}); + +export const SparringScoreCategoryNameSchema = z.enum([ + 'Logic', + 'Evidence', + 'Delivery', + 'Strategy', +]); + +export const SparringScoreCategorySchema = z.object({ + name: SparringScoreCategoryNameSchema, + userScore: z.number().int().min(0).max(25), + aiScore: z.number().int().min(0).max(25), + feedback: z.string().trim().min(1).max(2_000), +}); + +const REQUIRED_SCORE_CATEGORIES = new Set(['Logic', 'Evidence', 'Delivery', 'Strategy']); + +export const SparringScorecardSchema = z.object({ + userScore: z.number().int().min(0).max(100), + aiScore: z.number().int().min(0).max(100), + categories: z.array(SparringScoreCategorySchema).length(4).superRefine((categories, ctx) => { + const names: Set = new Set(categories.map((category) => category.name)); + if (names.size !== REQUIRED_SCORE_CATEGORIES.size || ['Logic', 'Evidence', 'Delivery', 'Strategy'].some((name) => !names.has(name))) { + ctx.addIssue({ + code: 'custom', + message: 'Categories must include exactly Logic, Evidence, Delivery, and Strategy.', + }); + } + }), + overallFeedback: z.string().trim().min(1).max(4_000), + keyMoments: z.array(z.string().trim().min(1).max(1_000)).max(12), + improvementAreas: z.array(z.string().trim().min(1).max(1_000)).max(12), + winner: z.enum(['user', 'ai', 'tie']), + verdict: z.string().trim().min(1).max(2_000), +}).superRefine((scorecard, ctx) => { + const categoryUserScore = scorecard.categories.reduce((total, category) => total + category.userScore, 0); + const categoryAiScore = scorecard.categories.reduce((total, category) => total + category.aiScore, 0); + if (scorecard.userScore !== categoryUserScore) { + ctx.addIssue({ code: 'custom', path: ['userScore'], message: 'userScore must equal the category score total.' }); + } + if (scorecard.aiScore !== categoryAiScore) { + ctx.addIssue({ code: 'custom', path: ['aiScore'], message: 'aiScore must equal the category score total.' }); + } + const expectedWinner = scorecard.userScore === scorecard.aiScore + ? 'tie' + : scorecard.userScore > scorecard.aiScore ? 'user' : 'ai'; + if (scorecard.winner !== expectedWinner) { + ctx.addIssue({ code: 'custom', path: ['winner'], message: 'winner must match the aggregate scores.' }); + } +}); + +export const SparringScoreResponseSchema = z.object({ + action: z.literal('score'), + scorecard: SparringScorecardSchema, +}); + +export const SparringResponseSchema = z.discriminatedUnion('action', [ + SparringTurnResponseSchema, + SparringScoreResponseSchema, +]); + +export type SparringSide = z.infer; +export type SparringFormat = z.infer; +export type SparringDifficulty = z.infer; +export type SparringSpeaker = z.infer; +export type SparringTurnKind = z.infer; +export type SparringTurn = z.infer; +export type SparringSpeech = z.infer; +export type SparringSession = z.infer; +export type SparringRespondInput = z.infer; +export type SparringScoreInput = z.infer; +export type SparringInput = z.infer; +export type SparringTurnResponse = z.infer; +export type SparringScoreCategory = z.infer; +export type SparringScorecard = z.infer; +export type SparringScoreResponse = z.infer; +export type SparringResponse = z.infer; diff --git a/src/lib/ai/schemas/url.ts b/packages/core/src/ai/schemas/url.ts similarity index 100% rename from src/lib/ai/schemas/url.ts rename to packages/core/src/ai/schemas/url.ts diff --git a/packages/core/src/contract/debate-context.ts b/packages/core/src/contract/debate-context.ts new file mode 100644 index 000000000..e7eaad42a --- /dev/null +++ b/packages/core/src/contract/debate-context.ts @@ -0,0 +1 @@ +export * from '../../../../supabase/functions/_shared/debate-context'; diff --git a/packages/core/src/contract/model-catalog.ts b/packages/core/src/contract/model-catalog.ts new file mode 100644 index 000000000..38eff78f4 --- /dev/null +++ b/packages/core/src/contract/model-catalog.ts @@ -0,0 +1 @@ +export * from '../../../../supabase/functions/_shared/model-catalog'; diff --git a/packages/core/src/contract/source-bounds.ts b/packages/core/src/contract/source-bounds.ts new file mode 100644 index 000000000..ae572842b --- /dev/null +++ b/packages/core/src/contract/source-bounds.ts @@ -0,0 +1 @@ +export * from '../../../../supabase/functions/_shared/source-bounds'; diff --git a/packages/core/src/contract/voice-tags.ts b/packages/core/src/contract/voice-tags.ts new file mode 100644 index 000000000..c29e52ae9 --- /dev/null +++ b/packages/core/src/contract/voice-tags.ts @@ -0,0 +1 @@ +export * from '../../../../supabase/functions/_shared/voice-tags'; diff --git a/packages/core/src/crossfire/prefill.ts b/packages/core/src/crossfire/prefill.ts new file mode 100644 index 000000000..e437eadec --- /dev/null +++ b/packages/core/src/crossfire/prefill.ts @@ -0,0 +1,48 @@ +import type { AttackOutput } from '../ai/schemas/attack'; +import type { GenerateOutput } from '../ai/schemas/generate'; +import { + CrossfirePrefillSchema, + MAX_CROSSFIRE_ARGUMENT_CHARS, + MAX_CROSSFIRE_SEED_QUESTIONS, + MAX_CROSSFIRE_TURN_CHARS, + type CrossfirePrefill, +} from './schemas'; + +function boundedArgument(parts: string[]): string { + return parts + .map((part) => part.trim()) + .filter(Boolean) + .join('\n\n') + .slice(0, MAX_CROSSFIRE_ARGUMENT_CHARS); +} + +export function prefillFromGenerate(resolution: string, result: GenerateOutput): CrossfirePrefill { + const contentionText = result.contentions.map((contention, index) => [ + `Contention ${index + 1}: ${contention.claim}`, + `Reasoning: ${contention.reasoning}`, + `Evidence: ${contention.evidence}`, + `Impact: ${contention.impact}`, + ].join('\n')); + + return CrossfirePrefillSchema.parse({ + source: 'generate', + resolution: resolution.trim().slice(0, 500), + argument: boundedArgument([ + `Thesis: ${result.caseStructure.thesis}`, + `Framework: ${result.caseStructure.framework}`, + `Burden of proof: ${result.caseStructure.burdenOfProof}`, + ...contentionText, + ]), + }); +} + +export function prefillFromAttack(caseText: string, result: AttackOutput): CrossfirePrefill { + return CrossfirePrefillSchema.parse({ + source: 'attack', + argument: caseText.trim().slice(0, MAX_CROSSFIRE_ARGUMENT_CHARS), + seedQuestions: result.crossExQuestions + .slice(0, MAX_CROSSFIRE_SEED_QUESTIONS) + .map((item) => item.question.trim().slice(0, MAX_CROSSFIRE_TURN_CHARS)) + .filter(Boolean), + }); +} diff --git a/packages/core/src/crossfire/reducer.ts b/packages/core/src/crossfire/reducer.ts new file mode 100644 index 000000000..084e663d3 --- /dev/null +++ b/packages/core/src/crossfire/reducer.ts @@ -0,0 +1,225 @@ +import { + CrossfireConfigSchema, + CrossfireDebriefSchema, + CrossfireSetupConfigSchema, + CrossfireStateSchema, + CrossfireTurnSchema, + CrossfireTurnSignalSchema, + type CrossfireConfig, + type CrossfireDebrief, + type CrossfirePrefill, + type CrossfireState, + type CrossfireSetupConfig, + type CrossfireTurn, + type CrossfireTurnSignal, +} from './schemas'; + +export type CrossfireAction = + | { type: 'config-patched'; patch: Partial } + | { type: 'voice-toggled'; voice: boolean } + | { type: 'session-started'; sessionId: string; now: number } + | { type: 'user-turn-submitted'; turn: CrossfireTurn; expectedReply: 'question' | 'answer' } + | { type: 'ai-turn-received'; turn: CrossfireTurn; signal?: CrossfireTurnSignal } + | { type: 'signal-received'; signal: CrossfireTurnSignal } + | { + type: 'request-failed'; + expectedReply: 'question' | 'answer'; + errorKind: 'timeout' | 'response' | 'request'; + message: string; + } + | { type: 'request-retried' } + | { type: 'session-ended'; now: number; reason: 'manual' | 'timer' } + | { + type: 'debrief-failed'; + errorKind: 'timeout' | 'response' | 'request'; + message: string; + } + | { type: 'debrief-retried' } + | { type: 'debrief-received'; debrief: CrossfireDebrief } + | { type: 'state-restored'; state: CrossfireState } + | { type: 'session-reset'; prefill?: CrossfirePrefill }; + +const DEFAULT_CONFIG: CrossfireSetupConfig = { + resolution: '', + argument: '', + mode: 'question-ai', + style: 'cooperative', + durationSeconds: 180, + useSourceLibrary: false, + seedQuestions: [], +}; + +export function createCrossfireSetupState(prefill?: CrossfirePrefill): CrossfireState { + return { + phase: 'setup', + config: { + ...DEFAULT_CONFIG, + resolution: prefill?.resolution ?? '', + argument: prefill?.argument ?? '', + seedQuestions: prefill?.seedQuestions ?? [], + }, + }; +} + +function appendTurn(turns: CrossfireTurn[], turn: CrossfireTurn): CrossfireTurn[] | null { + const parsed = CrossfireTurnSchema.safeParse(turn); + if (!parsed.success) return null; + if (turns.some((existing) => existing.id === parsed.data.id)) return null; + if (parsed.data.sequence !== turns.length + 1) return null; + return [...turns, parsed.data]; +} + +function attachSignal(turns: CrossfireTurn[], value: CrossfireTurnSignal): CrossfireTurn[] | null { + const signal = CrossfireTurnSignalSchema.safeParse(value); + if (!signal.success) return null; + const targetIndex = turns.findIndex((turn) => turn.id === signal.data.answerTurnId && turn.kind === 'answer'); + if (targetIndex < 0) return null; + const target = CrossfireTurnSchema.safeParse({ ...turns[targetIndex], signal: signal.data }); + if (!target.success) return null; + return turns.map((turn, index) => index === targetIndex ? target.data : turn); +} + +export function crossfireReducer(state: CrossfireState, action: CrossfireAction): CrossfireState { + switch (action.type) { + case 'config-patched': { + if (state.phase !== 'setup') return state; + const config = CrossfireSetupConfigSchema.safeParse({ ...state.config, ...action.patch }); + return config.success ? { ...state, config: config.data } : state; + } + case 'voice-toggled': + return state.phase === 'setup' ? { ...state, voice: action.voice } : state; + case 'session-started': { + if (state.phase !== 'setup') return state; + const config = CrossfireConfigSchema.safeParse(state.config); + if (!config.success) return state; + return { + phase: 'active', + sessionId: action.sessionId, + config: config.data, + turns: [], + startedAt: action.now, + endsAt: action.now + config.data.durationSeconds * 1_000, + request: config.data.mode === 'ai-questions-user' + ? { status: 'pending', expectedReply: 'question' } + : { status: 'idle' }, + ...(state.voice ? { voice: true } : {}), + }; + } + case 'user-turn-submitted': { + if (state.phase !== 'active' || state.request.status !== 'idle') return state; + const expectedKind = state.config.mode === 'question-ai' ? 'question' : 'answer'; + const expectedReply = state.config.mode === 'question-ai' ? 'answer' : 'question'; + if (action.turn.speaker !== 'user' || action.turn.kind !== expectedKind || action.expectedReply !== expectedReply) { + return state; + } + const turns = appendTurn(state.turns, action.turn); + if (!turns) return state; + return { + ...state, + turns, + request: { status: 'pending', expectedReply }, + }; + } + case 'ai-turn-received': { + if (state.phase !== 'active' || state.request.status !== 'pending') return state; + if (action.turn.speaker !== 'ai' || action.turn.kind !== state.request.expectedReply) return state; + let turns = appendTurn(state.turns, action.turn); + if (!turns) return state; + if (action.signal) { + turns = attachSignal(turns, action.signal); + if (!turns) return state; + } + return { ...state, turns, request: { status: 'idle' } }; + } + case 'signal-received': { + if (state.phase === 'setup') return state; + const turns = attachSignal(state.turns, action.signal); + return turns ? { ...state, turns } : state; + } + case 'request-failed': { + if (state.phase !== 'active' || state.request.status !== 'pending') return state; + if (action.expectedReply !== state.request.expectedReply) return state; + return { + ...state, + request: { + status: 'failed', + expectedReply: action.expectedReply, + errorKind: action.errorKind, + message: action.message.slice(0, 1_000), + }, + }; + } + case 'request-retried': { + if (state.phase !== 'active' || state.request.status !== 'failed') return state; + return { + ...state, + request: { status: 'pending', expectedReply: state.request.expectedReply }, + }; + } + case 'session-ended': { + if (state.phase !== 'active' || state.turns.length === 0) return state; + return { + phase: 'debriefing', + sessionId: state.sessionId, + config: state.config, + turns: state.turns, + startedAt: state.startedAt, + endedAt: action.now, + endedReason: action.reason, + requestStatus: 'pending', + ...(state.voice ? { voice: true } : {}), + }; + } + case 'debrief-failed': { + if (state.phase !== 'debriefing' || state.requestStatus !== 'pending') return state; + return { + ...state, + requestStatus: 'failed', + errorKind: action.errorKind, + errorMessage: action.message.slice(0, 1_000), + }; + } + case 'debrief-retried': { + if (state.phase !== 'debriefing' || state.requestStatus !== 'failed') return state; + const { errorKind: _errorKind, errorMessage: _errorMessage, ...rest } = state; + return { ...rest, requestStatus: 'pending' }; + } + case 'debrief-received': { + if (state.phase !== 'debriefing' || state.requestStatus !== 'pending') return state; + const debrief = CrossfireDebriefSchema.safeParse(action.debrief); + if (!debrief.success) return state; + return { + phase: 'complete', + sessionId: state.sessionId, + config: state.config, + turns: state.turns, + startedAt: state.startedAt, + endedAt: state.endedAt, + endedReason: state.endedReason, + debrief: debrief.data, + ...(state.voice ? { voice: true } : {}), + }; + } + case 'state-restored': { + const restored = CrossfireStateSchema.safeParse(action.state); + return restored.success ? restored.data : createCrossfireSetupState(); + } + case 'session-reset': + return createCrossfireSetupState(action.prefill); + default: { + const exhaustive: never = action; + return exhaustive; + } + } +} + +export function expectedUserTurnKind(state: CrossfireState): 'question' | 'answer' | null { + if (state.phase !== 'active' || state.request.status !== 'idle') return null; + if (state.config.mode === 'question-ai') return 'question'; + const latest = state.turns.at(-1); + return latest?.speaker === 'ai' && latest.kind === 'question' ? 'answer' : null; +} + +export function canEndCrossfireSession(state: CrossfireState): boolean { + return state.phase === 'active'; +} diff --git a/packages/core/src/crossfire/schemas.ts b/packages/core/src/crossfire/schemas.ts new file mode 100644 index 000000000..561bae3db --- /dev/null +++ b/packages/core/src/crossfire/schemas.ts @@ -0,0 +1,253 @@ +import { z } from 'zod'; +import { SpeechDeliverySchema } from '../voice/delivery'; + +export const MAX_CROSSFIRE_RESOLUTION_CHARS = 500; +export const MAX_CROSSFIRE_ARGUMENT_CHARS = 16_000; +export const MAX_CROSSFIRE_SEED_QUESTIONS = 10; +export const MAX_CROSSFIRE_TURN_CHARS = 2_000; +export const MAX_CROSSFIRE_TRANSCRIPT_TURNS = 40; +export const MAX_CROSSFIRE_TRANSCRIPT_CHARS = 24_000; +export const MAX_CROSSFIRE_REPLY_CHARS = 2_500; +export const MAX_CROSSFIRE_DEBRIEF_ITEMS = 8; +export const CROSSFIRE_DURATION_OPTIONS = [180, 300, 480] as const; + +const boundedText = (minimum: number, maximum: number) => z.string().trim().min(minimum).max(maximum); + +export const CrossfireModeSchema = z.enum(['question-ai', 'ai-questions-user']); +export type CrossfireMode = z.infer; + +export const CrossfireStyleSchema = z.enum(['cooperative', 'evasive', 'aggressive']); +export type CrossfireStyle = z.infer; + +export const CrossfireConfigSchema = z.object({ + resolution: boundedText(1, MAX_CROSSFIRE_RESOLUTION_CHARS), + argument: boundedText(1, MAX_CROSSFIRE_ARGUMENT_CHARS), + mode: CrossfireModeSchema, + style: CrossfireStyleSchema, + durationSeconds: z.union([ + z.literal(CROSSFIRE_DURATION_OPTIONS[0]), + z.literal(CROSSFIRE_DURATION_OPTIONS[1]), + z.literal(CROSSFIRE_DURATION_OPTIONS[2]), + ]), + useSourceLibrary: z.boolean(), + seedQuestions: z.array(boundedText(1, MAX_CROSSFIRE_TURN_CHARS)).max(MAX_CROSSFIRE_SEED_QUESTIONS), +}).strict(); +export type CrossfireConfig = z.infer; + +export const CrossfireSetupConfigSchema = CrossfireConfigSchema.extend({ + resolution: z.string().max(MAX_CROSSFIRE_RESOLUTION_CHARS), + argument: z.string().max(MAX_CROSSFIRE_ARGUMENT_CHARS), +}); +export type CrossfireSetupConfig = z.infer; + +export const CrossfirePrefillSchema = z.object({ + resolution: z.string().trim().max(MAX_CROSSFIRE_RESOLUTION_CHARS).optional(), + argument: z.string().trim().max(MAX_CROSSFIRE_ARGUMENT_CHARS).optional(), + seedQuestions: z.array(boundedText(1, MAX_CROSSFIRE_TURN_CHARS)).max(MAX_CROSSFIRE_SEED_QUESTIONS).optional(), + source: z.enum(['generate', 'attack']).optional(), +}).strict(); +export type CrossfirePrefill = z.infer; + +export const CrossfireTurnSignalSchema = z.object({ + answerTurnId: z.string().trim().min(1).max(40), + answerQuality: z.enum(['direct', 'partial', 'evasive']), + answerQualityConfidence: z.number().min(0).max(1), + concession: z.enum(['none', 'implicit', 'explicit']), + concessionConfidence: z.number().min(0).max(1), + followUpOpportunity: z.number().min(0).max(1), +}).strict(); +export type CrossfireTurnSignal = z.infer; + +export const CrossfireTurnSchema = z.object({ + id: z.string().trim().min(1).max(40), + sequence: z.number().int().positive().max(MAX_CROSSFIRE_TRANSCRIPT_TURNS), + speaker: z.enum(['user', 'ai']), + kind: z.enum(['question', 'answer']), + text: boundedText(1, MAX_CROSSFIRE_TURN_CHARS), + createdAt: z.number().int().nonnegative(), + signal: CrossfireTurnSignalSchema.optional(), + /** Measured facts for a user turn that was spoken aloud in a voice round. */ + delivery: SpeechDeliverySchema.optional(), +}).strict().superRefine((turn, context) => { + if (turn.delivery && turn.speaker !== 'user') { + context.addIssue({ code: 'custom', path: ['delivery'], message: 'Only user turns carry delivery facts' }); + } + if (!turn.signal) return; + if (turn.kind !== 'answer' || turn.signal.answerTurnId !== turn.id) { + context.addIssue({ code: 'custom', path: ['signal', 'answerTurnId'], message: 'Signal must reference this answer turn' }); + } +}); +export type CrossfireTurn = z.infer; + +export const CrossfireTranscriptSchema = z.array(CrossfireTurnSchema) + .max(MAX_CROSSFIRE_TRANSCRIPT_TURNS) + .superRefine((turns, context) => { + const ids = new Set(); + turns.forEach((turn, index) => { + if (ids.has(turn.id)) { + context.addIssue({ + code: 'custom', + path: [index, 'id'], + message: `Transcript contains duplicate turn id ${turn.id}`, + }); + } + ids.add(turn.id); + if (turn.sequence !== index + 1) { + context.addIssue({ + code: 'custom', + path: [index, 'sequence'], + message: `Transcript turn ${index + 1} must have sequence ${index + 1}`, + }); + } + }); + const total = turns.reduce((sum, turn) => sum + turn.text.length, 0); + if (total > MAX_CROSSFIRE_TRANSCRIPT_CHARS) { + context.addIssue({ + code: 'custom', + message: `Transcript exceeds ${MAX_CROSSFIRE_TRANSCRIPT_CHARS} characters`, + }); + } + }); + +export const CrossfireReplySchema = z.object({ + type: z.enum(['question', 'answer']), + text: boundedText(1, MAX_CROSSFIRE_REPLY_CHARS), +}).strict(); +export type CrossfireReply = z.infer; + +const TurnReferenceSchema = z.object({ + turnIds: z.array(z.string().trim().min(1).max(40)).min(1).max(6), + claim: boundedText(1, 800), +}).strict(); + +const BetterFollowUpSchema = z.object({ + turnIds: z.array(z.string().trim().min(1).max(40)).min(1).max(6), + question: boundedText(1, MAX_CROSSFIRE_REPLY_CHARS), + reason: boundedText(1, 800), +}).strict(); + +export const CrossfireDebriefSchema = z.object({ + concessionsSecured: z.array(TurnReferenceSchema).max(MAX_CROSSFIRE_DEBRIEF_ITEMS), + controlledQuestions: z.array(TurnReferenceSchema).max(MAX_CROSSFIRE_DEBRIEF_ITEMS), + weakOrRepetitiveQuestions: z.array(TurnReferenceSchema).max(MAX_CROSSFIRE_DEBRIEF_ITEMS), + evasiveOrIncompleteAnswers: z.array(TurnReferenceSchema).max(MAX_CROSSFIRE_DEBRIEF_ITEMS), + missedFollowUps: z.array(TurnReferenceSchema).max(MAX_CROSSFIRE_DEBRIEF_ITEMS), + betterFollowUpQuestions: z.array(BetterFollowUpSchema).max(MAX_CROSSFIRE_DEBRIEF_ITEMS), + strongestExchange: TurnReferenceSchema.nullable(), +}).strict(); +export type CrossfireDebrief = z.infer; + +export const CrossfireNextTurnInputSchema = z.object({ + operation: z.literal('next-turn'), + config: CrossfireConfigSchema, + transcript: CrossfireTranscriptSchema, + expectedReply: z.enum(['question', 'answer']), +}).strict(); + +export const CrossfireDebriefInputSchema = z.object({ + operation: z.literal('debrief'), + config: CrossfireConfigSchema, + transcript: CrossfireTranscriptSchema.min(1), +}).strict(); + +export const CrossfireAIInputSchema = z.discriminatedUnion('operation', [ + CrossfireNextTurnInputSchema, + CrossfireDebriefInputSchema, +]); +export type CrossfireAIInput = z.infer; + +export const CrossfireSignalInputSchema = z.object({ + config: CrossfireConfigSchema, + transcript: CrossfireTranscriptSchema, + reply: CrossfireReplySchema, + signalToken: z.string().trim().min(1).max(128), +}).strict(); +export type CrossfireSignalInput = z.infer; + +export const CrossfireRequestStateSchema = z.discriminatedUnion('status', [ + z.object({ status: z.literal('idle') }).strict(), + z.object({ + status: z.literal('pending'), + expectedReply: z.enum(['question', 'answer']), + }).strict(), + z.object({ + status: z.literal('failed'), + expectedReply: z.enum(['question', 'answer']), + errorKind: z.enum(['timeout', 'response', 'request']), + message: z.string().trim().min(1).max(1_000), + }).strict(), +]); +export type CrossfireRequestState = z.infer; + +// `voice` marks a spoken round. It is optional so drafts and saved rounds +// from before voice rounds still load, as typed. +const SetupStateSchema = z.object({ + phase: z.literal('setup'), + config: CrossfireSetupConfigSchema, + voice: z.boolean().optional(), +}).strict(); + +const ActiveStateSchema = z.object({ + phase: z.literal('active'), + sessionId: z.string().trim().min(1).max(80), + config: CrossfireConfigSchema, + turns: CrossfireTranscriptSchema, + startedAt: z.number().int().nonnegative(), + endsAt: z.number().int().positive(), + request: CrossfireRequestStateSchema, + voice: z.boolean().optional(), +}).strict(); + +const DebriefingStateSchema = z.object({ + phase: z.literal('debriefing'), + sessionId: z.string().trim().min(1).max(80), + config: CrossfireConfigSchema, + turns: CrossfireTranscriptSchema.min(1), + startedAt: z.number().int().nonnegative(), + endedAt: z.number().int().nonnegative(), + endedReason: z.enum(['manual', 'timer']), + requestStatus: z.enum(['pending', 'failed']), + errorKind: z.enum(['timeout', 'response', 'request']).optional(), + errorMessage: z.string().trim().min(1).max(1_000).optional(), + voice: z.boolean().optional(), +}).strict(); + +const CompleteStateSchema = z.object({ + phase: z.literal('complete'), + sessionId: z.string().trim().min(1).max(80), + config: CrossfireConfigSchema, + turns: CrossfireTranscriptSchema.min(1), + startedAt: z.number().int().nonnegative(), + endedAt: z.number().int().nonnegative(), + endedReason: z.enum(['manual', 'timer']), + debrief: CrossfireDebriefSchema, + voice: z.boolean().optional(), +}).strict(); + +export const CrossfireStateSchema = z.discriminatedUnion('phase', [ + SetupStateSchema, + ActiveStateSchema, + DebriefingStateSchema, + CompleteStateSchema, +]); +export type CrossfireState = z.infer; + +export const PersistedCrossfireDraftSchema = z.object({ + version: z.literal(1), + ownerId: z.string().trim().min(1).max(128), + savedAt: z.number().int().nonnegative(), + state: z.discriminatedUnion('phase', [SetupStateSchema, ActiveStateSchema, DebriefingStateSchema]), +}).strict(); +export type PersistedCrossfireDraft = z.infer; + +export function collectDebriefTurnIds(debrief: CrossfireDebrief): string[] { + return [ + ...debrief.concessionsSecured, + ...debrief.controlledQuestions, + ...debrief.weakOrRepetitiveQuestions, + ...debrief.evasiveOrIncompleteAnswers, + ...debrief.missedFollowUps, + ...debrief.betterFollowUpQuestions, + ...(debrief.strongestExchange ? [debrief.strongestExchange] : []), + ].flatMap((item) => item.turnIds); +} diff --git a/packages/core/src/crossfire/storage.ts b/packages/core/src/crossfire/storage.ts new file mode 100644 index 000000000..67f487d23 --- /dev/null +++ b/packages/core/src/crossfire/storage.ts @@ -0,0 +1,103 @@ +import { + PersistedCrossfireDraftSchema, + type CrossfireState, + type PersistedCrossfireDraft, +} from './schemas'; + +export const CROSSFIRE_STORAGE_KEY_PREFIX = 'debatelab-crossfire-draft'; +export const MAX_CROSSFIRE_STORAGE_CHARS = 80_000; + +/** The slice of a key-value store the draft needs; the web app passes browser storage. */ +export interface StorageLike { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +export function crossfireStorageKey(ownerId: string): string { + return `${CROSSFIRE_STORAGE_KEY_PREFIX}:${ownerId}`; +} + +export function loadCrossfireDraft( + ownerId: string, + storage: StorageLike | null, +): PersistedCrossfireDraft['state'] | null { + if (!storage || !ownerId) return null; + const key = crossfireStorageKey(ownerId); + let serialized: string | null; + try { + serialized = storage.getItem(key); + } catch { + return null; + } + if (!serialized) return null; + if (serialized.length > MAX_CROSSFIRE_STORAGE_CHARS) { + try { storage.removeItem(key); } catch { return null; } + return null; + } + + try { + const parsed = PersistedCrossfireDraftSchema.safeParse(JSON.parse(serialized)); + if (!parsed.success || parsed.data.ownerId !== ownerId) { + try { storage.removeItem(key); } catch { return null; } + return null; + } + return parsed.data.state; + } catch { + try { storage.removeItem(key); } catch { return null; } + return null; + } +} + +export function saveCrossfireDraft( + ownerId: string, + state: CrossfireState, + storage: StorageLike | null, + now = Date.now(), +): boolean { + if (!storage || !ownerId) return false; + const key = crossfireStorageKey(ownerId); + if (state.phase === 'complete') { + try { + storage.removeItem(key); + return true; + } catch { + return false; + } + } + + const draft: PersistedCrossfireDraft = { + version: 1, + ownerId, + savedAt: now, + state, + }; + const parsed = PersistedCrossfireDraftSchema.safeParse(draft); + if (!parsed.success) return false; + + const serialized = JSON.stringify(parsed.data); + if (serialized.length > MAX_CROSSFIRE_STORAGE_CHARS) return false; + try { + storage.setItem(key, serialized); + return true; + } catch { + return false; + } +} + +export function clearCrossfireDraft(ownerId: string, storage: StorageLike | null): void { + if (!ownerId || !storage) return; + try { storage.removeItem(crossfireStorageKey(ownerId)); } catch { /* storage unavailable */ } +} + +export function clearCrossfireDraftOnAuthChange( + previousUserId: string | undefined, + nextUserId: string | undefined, + storage: StorageLike | null, +): boolean { + if (previousUserId === nextUserId) return false; + if (previousUserId) { + try { storage?.removeItem(crossfireStorageKey(previousUserId)); } catch { /* storage unavailable */ } + } + return true; +} diff --git a/packages/core/src/globals.d.ts b/packages/core/src/globals.d.ts new file mode 100644 index 000000000..a9aa58ae9 --- /dev/null +++ b/packages/core/src/globals.d.ts @@ -0,0 +1,11 @@ +// Globals that both browsers and React Native's Hermes provide but the ES lib +// does not declare. Declare only what package code uses; never add DOM types. +declare const console: { + warn(...data: unknown[]): void; +}; + +declare class URL { + constructor(url: string, base?: string); + readonly href: string; + readonly protocol: string; +} diff --git a/packages/core/src/languages.ts b/packages/core/src/languages.ts new file mode 100644 index 000000000..04950d4bf --- /dev/null +++ b/packages/core/src/languages.ts @@ -0,0 +1,19 @@ +export const APP_LANGUAGES = { + en: { label: 'English', intlLocale: 'en-GB', aiName: 'English' }, + es: { label: 'Español', intlLocale: 'es-ES', aiName: 'Spanish' }, + hu: { label: 'Magyar', intlLocale: 'hu-HU', aiName: 'Hungarian' }, +} as const; + +export type AppLanguage = keyof typeof APP_LANGUAGES; + +export function isAppLanguage(value: unknown): value is AppLanguage { + return typeof value === 'string' && Object.prototype.hasOwnProperty.call(APP_LANGUAGES, value); +} + +export function toIntlLocale(language: AppLanguage): string { + return APP_LANGUAGES[language].intlLocale; +} + +export function languageName(language: AppLanguage): string { + return APP_LANGUAGES[language].aiName; +} diff --git a/packages/core/src/sparring/formats.ts b/packages/core/src/sparring/formats.ts new file mode 100644 index 000000000..54574f42c --- /dev/null +++ b/packages/core/src/sparring/formats.ts @@ -0,0 +1,96 @@ +import type { SparringFormat, SparringSide } from '../ai/schemas/sparring'; + +export type SparringTurnKind = 'introductor' | 'r1' | 'r2' | 'conclusor' | 'opening' | 'constructive' | 'rebuttal' | 'summary' | 'final'; + +export interface SparringTurn { + id: string; + label: string; + side: SparringSide; + speaker: 'user' | 'ai'; + kind: SparringTurnKind; + timeLimitSeconds: number; +} + +export interface SparringFormatDefinition { + id: SparringFormat; + name: string; + shortDescription: string; + turnCount: number; + approximateMinutes: number; +} + +const FORMAT_DEFINITIONS: Record = { + standard: { + id: 'standard', + name: 'School 4-6-6-3', + shortDescription: 'Your school format: Introductor, R1, R2, and Conclusor for both sides.', + turnCount: 8, + approximateMinutes: 38, + }, + ld: { + id: 'ld', + name: 'Lincoln-Douglas', + shortDescription: 'A focused values round with constructives and two rebuttal stages.', + turnCount: 5, + approximateMinutes: 26, + }, + pf: { + id: 'pf', + name: 'Public Forum', + shortDescription: 'Constructive, rebuttal, summary, and final focus for both sides.', + turnCount: 8, + approximateMinutes: 26, + }, +}; + +type TurnTemplate = Omit; + +const TURN_TEMPLATES: Record = { + standard: [ + { label: 'Pro Introductor', side: 'pro', kind: 'introductor', timeLimitSeconds: 4 * 60 }, + { label: 'Con Introductor', side: 'con', kind: 'introductor', timeLimitSeconds: 4 * 60 }, + { label: 'Pro R1', side: 'pro', kind: 'r1', timeLimitSeconds: 6 * 60 }, + { label: 'Con R1', side: 'con', kind: 'r1', timeLimitSeconds: 6 * 60 }, + { label: 'Pro R2', side: 'pro', kind: 'r2', timeLimitSeconds: 6 * 60 }, + { label: 'Con R2', side: 'con', kind: 'r2', timeLimitSeconds: 6 * 60 }, + { label: 'Pro Conclusor', side: 'pro', kind: 'conclusor', timeLimitSeconds: 3 * 60 }, + { label: 'Con Conclusor', side: 'con', kind: 'conclusor', timeLimitSeconds: 3 * 60 }, + ], + ld: [ + { label: 'Affirmative constructive', side: 'pro', kind: 'constructive', timeLimitSeconds: 6 * 60 }, + { label: 'Negative constructive', side: 'con', kind: 'constructive', timeLimitSeconds: 7 * 60 }, + { label: 'First affirmative rebuttal', side: 'pro', kind: 'rebuttal', timeLimitSeconds: 4 * 60 }, + { label: 'Negative rebuttal', side: 'con', kind: 'rebuttal', timeLimitSeconds: 6 * 60 }, + { label: 'Second affirmative rebuttal', side: 'pro', kind: 'final', timeLimitSeconds: 3 * 60 }, + ], + pf: [ + { label: 'Pro constructive', side: 'pro', kind: 'constructive', timeLimitSeconds: 4 * 60 }, + { label: 'Con constructive', side: 'con', kind: 'constructive', timeLimitSeconds: 4 * 60 }, + { label: 'Pro rebuttal', side: 'pro', kind: 'rebuttal', timeLimitSeconds: 4 * 60 }, + { label: 'Con rebuttal', side: 'con', kind: 'rebuttal', timeLimitSeconds: 4 * 60 }, + { label: 'Pro summary', side: 'pro', kind: 'summary', timeLimitSeconds: 3 * 60 }, + { label: 'Con summary', side: 'con', kind: 'summary', timeLimitSeconds: 3 * 60 }, + { label: 'Pro final focus', side: 'pro', kind: 'final', timeLimitSeconds: 2 * 60 }, + { label: 'Con final focus', side: 'con', kind: 'final', timeLimitSeconds: 2 * 60 }, + ], +}; + +export const SPARRING_FORMATS = Object.values(FORMAT_DEFINITIONS); + +export function getSparringFormat(format: SparringFormat): SparringFormatDefinition { + return FORMAT_DEFINITIONS[format]; +} + +export function buildSparringTurns(format: SparringFormat, userSide: SparringSide): SparringTurn[] { + return TURN_TEMPLATES[format].map((turn, index) => ({ + ...turn, + id: `${format}-${index + 1}`, + speaker: turn.side === userSide ? 'user' : 'ai', + })); +} + +export function formatRoundTime(seconds: number): string { + const safeSeconds = Math.max(0, Math.floor(seconds)); + const minutes = Math.floor(safeSeconds / 60); + return `${minutes}:${String(safeSeconds % 60).padStart(2, '0')}`; +} diff --git a/packages/core/src/supabase/edge-function-error.ts b/packages/core/src/supabase/edge-function-error.ts new file mode 100644 index 000000000..ab688a4fb --- /dev/null +++ b/packages/core/src/supabase/edge-function-error.ts @@ -0,0 +1,50 @@ +export async function getEdgeFunctionErrorMessage(error: unknown): Promise { + if (!error) return 'Request failed'; + + const err = error as { + message?: unknown; + context?: unknown; + }; + + const context = err.context; + let status: number | undefined; + let bodyObj: unknown; + + const maybeResponse = context as { status?: unknown; text?: () => Promise }; + if (maybeResponse && typeof maybeResponse.status === 'number') { + status = maybeResponse.status; + if (typeof maybeResponse.text === 'function') { + try { + const text = await maybeResponse.text(); + if (text) bodyObj = JSON.parse(text); + } catch { + bodyObj = undefined; + } + } + } else if (context && typeof context === 'object') { + const plainContext = context as { status?: unknown; body?: unknown }; + status = typeof plainContext.status === 'number' ? plainContext.status : undefined; + const body = plainContext.body; + if (typeof body === 'string' && body) { + try { + bodyObj = JSON.parse(body); + } catch { + bodyObj = undefined; + } + } else if (body && typeof body === 'object') { + bodyObj = body; + } + } + + const bodyError = bodyObj && typeof bodyObj === 'object' && 'error' in bodyObj + ? (bodyObj as { error?: unknown }).error + : undefined; + + if (typeof bodyError === 'string' && bodyError.trim()) return bodyError; + + const msg = typeof err.message === 'string' ? err.message : ''; + if (msg && msg !== 'Edge Function returned a non-2xx status code') return msg; + + if (status) return `Request failed (HTTP ${status})`; + return 'Request failed'; +} diff --git a/packages/core/src/validation/file.ts b/packages/core/src/validation/file.ts new file mode 100644 index 000000000..06f7ddef0 --- /dev/null +++ b/packages/core/src/validation/file.ts @@ -0,0 +1,126 @@ +/** + * File upload validation utilities + * Enforces size limits, file count, and allowed MIME types + */ + +// Maximum file size in bytes (10 MB) +export const MAX_FILE_SIZE = 10 * 1024 * 1024; + +// Maximum number of files +export const MAX_FILE_COUNT = 5; + +// Allowed MIME types +const ALLOWED_MIME_TYPES = new Set([ + // Text files + 'text/plain', + 'text/markdown', + 'text/csv', + 'text/html', + 'text/css', + 'text/xml', + // Code files + 'application/json', + 'application/xml', + 'application/javascript', + 'text/javascript', + 'application/typescript', + // Application-specific text + 'application/x-yaml', + 'text/yaml', + 'text/x-python', + 'text/x-java', + 'text/x-c', + 'text/x-c++', +]); + +// Allowed file extensions (lowercase, without dot) +const ALLOWED_EXTENSIONS = new Set([ + // Text + 'txt', 'md', 'markdown', 'csv', 'json', 'yaml', 'yml', 'xml', + // Code + 'js', 'jsx', 'ts', 'tsx', 'py', 'java', 'c', 'cpp', 'h', 'hpp', + // Web + 'html', 'htm', 'css', 'scss', 'sass', 'less', + // Config + 'toml', 'ini', 'cfg', 'conf', 'env', +]); + +/** The fields validation reads; a browser File satisfies it. */ +export interface FileLike { + name: string; + size: number; + type: string; +} + +export interface FileValidationResult { + valid: boolean; + error?: string; +} + +/** + * Validates a single file for size and type. + */ +export function validateSingleFile(file: FileLike): FileValidationResult { + // Check file size + if (file.size > MAX_FILE_SIZE) { + const sizeMB = (file.size / (1024 * 1024)).toFixed(1); + return { + valid: false, + error: `File "${file.name}" is too large (${sizeMB} MB). Maximum size is 10 MB.`, + }; + } + + // Get file extension + const extension = file.name.split('.').pop()?.toLowerCase() || ''; + + // Check if extension is allowed + if (!ALLOWED_EXTENSIONS.has(extension)) { + return { + valid: false, + error: `File "${file.name}" has an unsupported file type. Only text and code files are allowed.`, + }; + } + + // Check MIME type if available (some browsers don't provide it) + if (file.type && !ALLOWED_MIME_TYPES.has(file.type) && !file.type.startsWith('text/')) { + return { + valid: false, + error: `File "${file.name}" has an unsupported file type. Only text and code files are allowed.`, + }; + } + + return { valid: true }; +} + +/** + * Validates an array of files for upload. + * Checks: file count, individual file sizes, and allowed types. + */ +export function validateFileUpload(files: FileLike[]): FileValidationResult { + // Check file count + if (files.length > MAX_FILE_COUNT) { + return { + valid: false, + error: `Too many files selected (${files.length}). Maximum is ${MAX_FILE_COUNT} files.`, + }; + } + + // Validate each file + for (const file of files) { + const result = validateSingleFile(file); + if (!result.valid) { + return result; + } + } + + return { valid: true }; +} + +/** + * Formats a file size in bytes to a human-readable string. + */ +export function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} diff --git a/packages/core/src/validation/url.ts b/packages/core/src/validation/url.ts new file mode 100644 index 000000000..f33f4eb72 --- /dev/null +++ b/packages/core/src/validation/url.ts @@ -0,0 +1,57 @@ +/** + * URL validation utilities + * Validates URLs to prevent XSS and other security issues + */ + +const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']); +const BLOCKED_PROTOCOLS = new Set(['javascript:', 'data:', 'vbscript:', 'file:']); + +/** + * Validates a URL string and returns a safe URL or undefined. + * + * - Allows only http: and https: protocols + * - Blocks javascript:, data:, and other dangerous schemes + * - Uses URL constructor for robust parsing + * + * @param url - The URL string to validate + * @returns The validated URL string, or undefined if invalid/unsafe + */ +export function validateUrl(url?: string): string | undefined { + if (!url || typeof url !== 'string') { + return undefined; + } + + // Trim whitespace + const trimmed = url.trim(); + if (!trimmed) { + return undefined; + } + + // Handle URLs without protocol (add https:// prefix) + let urlToValidate = trimmed; + if (!trimmed.includes('://') && trimmed.includes('.')) { + urlToValidate = `https://${trimmed}`; + } + + try { + const parsed = new URL(urlToValidate); + + // Check for blocked protocols first + if (BLOCKED_PROTOCOLS.has(parsed.protocol.toLowerCase())) { + console.warn(`[SECURITY] Blocked dangerous URL protocol: ${parsed.protocol}`); + return undefined; + } + + // Only allow http and https + if (!ALLOWED_PROTOCOLS.has(parsed.protocol.toLowerCase())) { + console.warn(`[SECURITY] Blocked non-http URL protocol: ${parsed.protocol}`); + return undefined; + } + + // Return the normalized URL + return parsed.href; + } catch { + // Invalid URL + return undefined; + } +} diff --git a/packages/core/src/voice/delivery.ts b/packages/core/src/voice/delivery.ts new file mode 100644 index 000000000..903a3e559 --- /dev/null +++ b/packages/core/src/voice/delivery.ts @@ -0,0 +1,131 @@ +import { z } from 'zod'; +import type { AppLanguage } from '../languages'; + +/** + * Facts measured while a student speaks a turn aloud: the clock and the + * browser's transcript, nothing else. Nobody hears the audio, so nothing here + * describes tone, confidence, or volume. + */ +export const SpeechDeliverySchema = z.object({ + speakingSeconds: z.number().int().min(0).max(86_400), + wordCount: z.number().int().min(0).max(6_000), + wordsPerMinute: z.number().int().min(0).max(600), + fillerCount: z.number().int().min(0).max(6_000), + fillers: z.array(z.object({ + word: z.string().trim().min(1).max(40), + count: z.number().int().min(1).max(6_000), + }).strict()).max(12), + longPauses: z.number().int().min(0).max(1_000), + longestPauseSeconds: z.number().int().min(0).max(86_400), +}).strict(); +export type SpeechDelivery = z.infer; + +/** A silence at least this long between recognised words counts as a long pause. */ +export const LONG_PAUSE_SECONDS = 3; +const MAX_REPORTED_FILLERS = 12; + +// Hesitation sounds and filler phrases per language. Words that are often +// real content ("like", "este", "bueno") are left out: a measured count +// should not accuse a student of a filler they did not use. +const FILLERS: Record = { + en: ['um', 'uh', 'er', 'erm', 'hmm', 'you know', 'I mean', 'sort of', 'kind of', 'basically', 'literally'], + es: ['eh', 'em', 'ehm', 'mmm', 'o sea', 'en plan', 'a ver', 'digamos', 'pues'], + hu: ['ööö', 'hát', 'izé', 'szóval', 'ugye', 'úgymond', 'tulajdonképpen'], +}; + +function words(text: string): string[] { + return text.toLowerCase().match(/[\p{L}\p{M}]+(?:['’][\p{L}\p{M}]+)*/gu) ?? []; +} + +// "ummm" and "um" are the same hesitation, so runs of one letter collapse. +function collapse(word: string): string { + return word.replace(/(.)\1+/gu, '$1'); +} + +interface FillerPattern { + label: string; + tokens: string[]; +} + +const FILLER_PATTERNS = Object.fromEntries( + (Object.entries(FILLERS) as Array<[AppLanguage, readonly string[]]>).map(([language, fillers]) => [ + language, + fillers + .map((label): FillerPattern => ({ label, tokens: words(label).map(collapse) })) + // Longer phrases first, so "you know" is not split. + .sort((left, right) => right.tokens.length - left.tokens.length), + ]), +) as Record; + +export function countWords(text: string): number { + return words(text).length; +} + +export function countFillers(text: string, language: AppLanguage): Array<{ word: string; count: number }> { + const tokens = words(text).map(collapse); + const counts = new Map(); + for (let index = 0; index < tokens.length;) { + const match = FILLER_PATTERNS[language].find((pattern) => + pattern.tokens.every((token, offset) => tokens[index + offset] === token)); + if (match) { + counts.set(match.label, (counts.get(match.label) ?? 0) + 1); + index += match.tokens.length; + } else { + index += 1; + } + } + return [...counts.entries()] + .map(([word, count]) => ({ word, count })) + .sort((left, right) => right.count - left.count || left.word.localeCompare(right.word)) + .slice(0, MAX_REPORTED_FILLERS); +} + +/** What one or more microphone takes within a turn recorded. */ +export interface SpeechTake { + /** Everything the browser transcribed, before any manual edits. */ + text: string; + /** Time the microphone was listening. */ + speakingMs: number; + /** Silences between recognised words of at least LONG_PAUSE_SECONDS. */ + pausesMs: number[]; +} + +/** Turn a take into delivery facts; null when nothing was spoken. */ +export function measureDelivery(take: SpeechTake, language: AppLanguage): SpeechDelivery | null { + const wordCount = Math.min(countWords(take.text), 6_000); + if (wordCount === 0) return null; + const speakingSeconds = Math.min(86_400, Math.max(1, Math.round(take.speakingMs / 1_000))); + const fillers = countFillers(take.text, language); + return { + speakingSeconds, + wordCount, + wordsPerMinute: Math.min(600, Math.round(wordCount / (speakingSeconds / 60))), + fillerCount: fillers.reduce((total, filler) => total + filler.count, 0), + fillers, + longPauses: Math.min(1_000, take.pausesMs.length), + longestPauseSeconds: Math.min(86_400, Math.round(Math.max(0, ...take.pausesMs) / 1_000)), + }; +} + +/** Add up the facts from several spoken turns, for a round-level summary. */ +export function combineDelivery(deliveries: SpeechDelivery[]): SpeechDelivery | null { + if (deliveries.length === 0) return null; + const speakingSeconds = deliveries.reduce((total, item) => total + item.speakingSeconds, 0); + const wordCount = deliveries.reduce((total, item) => total + item.wordCount, 0); + const fillerCounts = new Map(); + for (const item of deliveries) { + for (const filler of item.fillers) fillerCounts.set(filler.word, (fillerCounts.get(filler.word) ?? 0) + filler.count); + } + return { + speakingSeconds, + wordCount, + wordsPerMinute: speakingSeconds > 0 ? Math.min(600, Math.round(wordCount / (speakingSeconds / 60))) : 0, + fillerCount: deliveries.reduce((total, item) => total + item.fillerCount, 0), + fillers: [...fillerCounts.entries()] + .map(([word, count]) => ({ word, count })) + .sort((left, right) => right.count - left.count || left.word.localeCompare(right.word)) + .slice(0, MAX_REPORTED_FILLERS), + longPauses: deliveries.reduce((total, item) => total + item.longPauses, 0), + longestPauseSeconds: Math.max(...deliveries.map((item) => item.longestPauseSeconds)), + }; +} diff --git a/packages/core/src/voice/payload.ts b/packages/core/src/voice/payload.ts new file mode 100644 index 000000000..53b757667 --- /dev/null +++ b/packages/core/src/voice/payload.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; +import { MAX_VOICE_SEGMENT_CHARS, MAX_VOICE_SEGMENTS } from '../contract/voice-tags'; + +// What an AI response carries for a voice round: the opponent's reply cut +// into segments, each signed by the server so the voice function will speak +// it. The spoken text may hold audio tags and is never shown or saved. + +export type VoiceFeature = 'sparring' | 'crossfire'; + +const VoiceSegmentSchema = z.object({ + text: z.string().min(1).max(MAX_VOICE_SEGMENT_CHARS), + token: z.string().min(1).max(128), +}); +// The unsigned rest of a reply too long for MAX_VOICE_SEGMENTS. It is read +// by the browser voice only; replies are at most 6,000 characters. +const MAX_VOICE_OVERFLOW_CHARS = 12_000; +const VoicePayloadSchema = z.object({ + segments: z.array(VoiceSegmentSchema).min(1).max(MAX_VOICE_SEGMENTS), + overflow: z.string().min(1).max(MAX_VOICE_OVERFLOW_CHARS).optional(), +}); +export type VoiceSegment = z.infer; +export type VoicePayload = z.infer; + +/** The signed segments an AI response carries; undefined when absent or malformed. */ +export function parseVoicePayload(value: unknown): VoicePayload | undefined { + const parsed = VoicePayloadSchema.safeParse(value); + return parsed.success ? parsed.data : undefined; +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 000000000..b4c3ae079 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "types": [], + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs index 9e9eb5555..541cacd23 100644 --- a/scripts/check-docs.mjs +++ b/scripts/check-docs.mjs @@ -34,6 +34,7 @@ function toRepositoryPath(path) { function isExcluded(relativePath) { if (relativePath === "plans/" || relativePath === PLAN_INDEX) return false; + if (relativePath.split("/").includes("node_modules")) return true; return EXCLUDED_PREFIXES.some((prefix) => relativePath.startsWith(prefix)); } diff --git a/scripts/staging/fixtures.mjs b/scripts/staging/fixtures.mjs index af4aaa491..231842ad6 100644 --- a/scripts/staging/fixtures.mjs +++ b/scripts/staging/fixtures.mjs @@ -2,7 +2,7 @@ // person, publication, figure and URL here is made up. Row ids are fixed so a // rerun updates rows instead of adding copies. Output shapes follow what the // client saves and renders: GenerateOutput, AttackOutput, BrainstormOutput -// (src/lib/ai/schemas/) and FactoidsOutput (src/lib/ai/schemas/factoids.ts). +// (packages/core/src/ai/schemas/) and FactoidsOutput (packages/core/src/ai/schemas/factoids.ts). const id = (n) => `5eed0000-0000-4000-8000-${String(n).padStart(12, "0")}`; diff --git a/src/lib/ai/providers/types.ts b/src/lib/ai/providers/types.ts index b3ae18a4c..f39b5ca64 100644 --- a/src/lib/ai/providers/types.ts +++ b/src/lib/ai/providers/types.ts @@ -1,31 +1 @@ -// src/lib/ai/providers/types.ts - -import { MODEL_CATALOG } from '../../../../supabase/functions/_shared/model-catalog'; -import type { ReasoningEffort } from '../../../../supabase/functions/_shared/model-catalog'; - -export { - AUTO_MODEL_SELECTIONS, - DEFAULT_MODEL, - DEFAULT_PRO_MODEL, - isAutoModelSelection, - isModelSelection, - isReasoningEffort, -} from '../../../../supabase/functions/_shared/model-catalog'; -export type { - AutoModelSelection, - ModelSelection, - ReasoningEffort, -} from '../../../../supabase/functions/_shared/model-catalog'; -export type { ModelCatalogEntry as AIModel } from '../../../../supabase/functions/_shared/model-catalog'; - -/** Every accepted model routes through OpenRouter; see _shared/model-catalog. */ -export const PROVIDER_ID = 'openrouter'; - -export const AVAILABLE_MODELS = MODEL_CATALOG; - -export interface ProviderConfig { - provider: string; - model: string; - temperature: number; - reasoningEffort?: ReasoningEffort; -} +export * from '@debatelab/core/ai/providers/types'; diff --git a/src/lib/ai/schemas/ask.ts b/src/lib/ai/schemas/ask.ts index 8ba4ab00a..0f2501079 100644 --- a/src/lib/ai/schemas/ask.ts +++ b/src/lib/ai/schemas/ask.ts @@ -1,58 +1 @@ -// src/lib/ai/schemas/ask.ts -import { z } from 'zod'; -import { SafeWebUrlSchema } from './url'; - -export const AskInputSchema = z.object({ - message: z.string().min(1, 'Message is required'), - mode: z.enum(['general', 'clarify', 'summarize', 'proscons', 'argumentmap']), - citationMode: z.boolean(), - webSearch: z.boolean().optional(), - prosConsMode: z.boolean().optional(), - argumentMapMode: z.boolean().optional(), - conversationHistory: z.array(z.object({ - role: z.enum(['user', 'assistant']), - content: z.string(), - })), -}); - -export type AskInput = z.infer; - -export const AskOutputSchema = z.object({ - response: z.string(), - model: z.string().optional(), - provider: z.string().optional(), - durationMs: z.number().optional(), - generatedAt: z.string().optional(), - citations: z.array(z.object({ - claim: z.string(), - source: z.string().optional(), - verified: z.boolean(), - note: z.string().optional(), - })).optional(), - searchInfo: z.object({ - webSearchUsed: z.boolean(), - queries: z.array(z.string()), - sources: z.array(z.object({ - title: z.string(), - url: SafeWebUrlSchema, - verified: z.boolean(), - sourceType: z.string().optional(), - qualityScore: z.number().optional(), - qualityLabel: z.enum(['strong', 'moderate', 'weak']).optional(), - })), - }).optional(), - followUpQuestions: z.array(z.string()), - prosConsAnalysis: z.object({ - pros: z.array(z.string()), - cons: z.array(z.string()), - }).optional(), - argumentMap: z.object({ - mainClaim: z.string(), - supportingPoints: z.array(z.string()), - counterpoints: z.array(z.string()), - assumptions: z.array(z.string()), - }).optional(), - uncertainties: z.array(z.string()), -}); - -export type AskOutput = z.infer; +export * from '@debatelab/core/ai/schemas/ask'; diff --git a/src/lib/ai/schemas/attack.ts b/src/lib/ai/schemas/attack.ts index f9c57b60b..18e5a9ee2 100644 --- a/src/lib/ai/schemas/attack.ts +++ b/src/lib/ai/schemas/attack.ts @@ -1,50 +1 @@ -// src/lib/ai/schemas/attack.ts -import { z } from 'zod'; - -export const AttackInputSchema = z.object({ - caseText: z.string().min(1, 'Case text is required'), - aggressiveness: z.enum(['mild', 'moderate', 'ruthless']), - judgeParadigm: z.enum(['logic', 'values', 'policy']), - detectFallacies: z.boolean(), - analyzeWeighing: z.boolean(), -}); - -export type AttackInput = z.infer; - -export const WeaknessSchema = z.object({ - id: z.string(), - severity: z.enum(['critical', 'major', 'minor']), - type: z.enum(['logical', 'evidential', 'structural', 'strategic']), - description: z.string(), - whyWeak: z.string(), - howOpponentAttacks: z.string(), - improvedVersion: z.string(), - affectedQuote: z.string().optional(), -}); - -export const AttackOutputSchema = z.object({ - overallAssessment: z.object({ - score: z.number().min(0).max(100), - summary: z.string(), - strengthAreas: z.array(z.string()), - }), - weaknesses: z.array(WeaknessSchema), - fallaciesDetected: z.array(z.object({ - name: z.string(), - location: z.string(), - explanation: z.string(), - })), - crossExQuestions: z.array(z.object({ - question: z.string(), - purpose: z.string(), - expectedTrap: z.string(), - })), - weighingAnalysis: z.object({ - currentWeighing: z.string(), - missingComparisons: z.array(z.string()), - improvementSuggestions: z.array(z.string()), - }), -}); - -export type AttackOutput = z.infer; -export type Weakness = z.infer; +export * from '@debatelab/core/ai/schemas/attack'; diff --git a/src/lib/ai/schemas/brainstorm.ts b/src/lib/ai/schemas/brainstorm.ts index f497e48be..9562c2552 100644 --- a/src/lib/ai/schemas/brainstorm.ts +++ b/src/lib/ai/schemas/brainstorm.ts @@ -1,47 +1 @@ -// src/lib/ai/schemas/brainstorm.ts -import { z } from 'zod'; - -export const BrainstormInputSchema = z.object({ - motion: z.string().min(1, 'Motion is required'), - side: z.enum(['pro', 'con', 'both']), - context: z.string().optional(), - outputMode: z.enum(['mindmap', 'buckets', 'topfive']), -}); - -export type BrainstormInput = z.infer; - -export const IdeaSchema = z.object({ - id: z.string(), - title: z.string(), - description: z.string(), - side: z.enum(['pro', 'con', 'neutral']), - strength: z.enum(['strong', 'moderate', 'weak']), - tradeoffs: z.array(z.string()), - relatedTo: z.array(z.string()), -}); - -export const BucketSchema = z.object({ - name: z.string(), - ideas: z.array(IdeaSchema), - keyInsight: z.string(), -}); - -export const BrainstormOutputSchema = z.object({ - clarifyingQuestions: z.array(z.string()).optional(), - needsMoreInfo: z.boolean(), - mindmap: z.array(z.object({ - category: z.string(), - items: z.array(IdeaSchema), - })).optional(), - buckets: z.array(BucketSchema).optional(), - topFive: z.array(z.object({ - rank: z.number(), - idea: IdeaSchema, - whyTop: z.string(), - })).optional(), - nextSteps: z.array(z.string()), - warningsAndTradeoffs: z.array(z.string()), -}); - -export type BrainstormOutput = z.infer; -export type Idea = z.infer; +export * from '@debatelab/core/ai/schemas/brainstorm'; diff --git a/src/lib/ai/schemas/factoids.ts b/src/lib/ai/schemas/factoids.ts index 0004bf524..59bc189da 100644 --- a/src/lib/ai/schemas/factoids.ts +++ b/src/lib/ai/schemas/factoids.ts @@ -1,46 +1 @@ -// src/lib/ai/schemas/factoids.ts -import { z } from 'zod'; -import { SafeWebUrlSchema } from './url'; - -export const FactoidsInputSchema = z.object({ - claim: z.string().min(1, 'Claim is required'), - direction: z.enum(['for', 'against']).default('for'), - region: z.string().optional(), - sourceType: z.enum(['academic', 'government', 'news', 'any']), - timeframe: z.string().optional(), - sourceCount: z.coerce.number().int().min(3).max(15).default(5), -}); - -export type FactoidsInput = z.infer; - -export const CitationSchema = z.object({ - title: z.string(), - publisher: z.string(), - date: z.string().optional(), - url: SafeWebUrlSchema.optional(), - verified: z.enum(['verified', 'unverified', 'pending']), - verifiedAt: z.string().optional(), -}); - -export const FactoidSchema = z.object({ - id: z.string(), - shortClaim: z.string(), - keyFigure: z.string(), - context: z.string(), - limitations: z.string(), - citation: CitationSchema, - howToUse: z.string(), - relevanceScore: z.number().min(0).max(100), -}); - -export const FactoidsOutputSchema = z.object({ - factoids: z.array(FactoidSchema), - searchSuggestions: z.array(z.string()), - warnings: z.array(z.string()), - verificationStatus: z.enum(['pending', 'inProgress', 'complete']), - filteredOutCount: z.number().int().nonnegative().optional(), -}); - -export type FactoidsOutput = z.infer; -export type Factoid = z.infer; -export type Citation = z.infer; +export * from '@debatelab/core/ai/schemas/factoids'; diff --git a/src/lib/ai/schemas/generate.ts b/src/lib/ai/schemas/generate.ts index afa1b1c72..cca44e0b0 100644 --- a/src/lib/ai/schemas/generate.ts +++ b/src/lib/ai/schemas/generate.ts @@ -1,72 +1 @@ -// src/lib/ai/schemas/generate.ts -import { z } from 'zod'; - -export const GenerateInputSchema = z.object({ - motion: z.string().min(1, 'Motion is required'), - side: z.enum(['pro', 'con']), - format: z.enum(['custom', 'bp', 'policy', 'classroom']), // Kept others for backward compatibility but UI will restrict - role: z.enum(['intro', 'r1', 'r2', 'conc', 'general']), - length: z.enum(['brief', 'standard', 'comprehensive']), - audience: z.enum(['beginner', 'intermediate', 'advanced']), - tone: z.enum(['formal', 'conversational', 'aggressive']), -}); - -export type GenerateInput = z.infer; - -export const ContentionSchema = z.object({ - id: z.string(), - claim: z.string(), - reasoning: z.string(), - evidence: z.string(), - needsSource: z.boolean(), - sourceQuery: z.string().optional(), - impact: z.string(), -}); - -export const ExordioSchema = z.object({ - hook: z.string(), - transition: z.string(), -}); - -export const GenerateOutputSchema = z.object({ - exordio: ExordioSchema.optional(), - caseStructure: z.object({ - thesis: z.string(), - framework: z.string(), - keyDefinitions: z.array(z.string()), - burdenOfProof: z.string(), - }), - contentions: z.array(ContentionSchema), - predictedOpponentCase: z.object({ - likelyArguments: z.array(z.string()), - strategicWeaknesses: z.array(z.string()), - keyClashes: z.array(z.string()), - }), - counters: z.array(z.object({ - targetArgument: z.string(), - counterpoint: z.string(), - rebuttal: z.string(), - })), - metadata: z.object({ - side: z.enum(['pro', 'con']), - format: z.string(), - motion: z.string().optional(), - generatedAt: z.string(), - model: z.string().optional(), - provider: z.string().optional(), - durationMs: z.number().optional(), - sourceUsage: z.array(z.object({ - key: z.string(), - id: z.string().optional(), - title: z.string(), - origin: z.string().optional(), - importance: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)]), - strength: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)]), - probability: z.number().min(0).max(1), - judgment: z.enum(['used', 'uncertain', 'not_used']), - })).max(10).optional(), - }), -}); - -export type GenerateOutput = z.infer; -export type Contention = z.infer; +export * from '@debatelab/core/ai/schemas/generate'; diff --git a/src/lib/ai/schemas/source-import.ts b/src/lib/ai/schemas/source-import.ts index 8a809da41..ef53d5724 100644 --- a/src/lib/ai/schemas/source-import.ts +++ b/src/lib/ai/schemas/source-import.ts @@ -1,15 +1 @@ -import { z } from 'zod'; -import { SafeWebUrlSchema } from './url'; - -export const SourceImportOutputSchema = z.object({ - title: z.string().min(1), - content: z.string().min(1), - wordCount: z.number().int().nonnegative(), - author: z.string().optional(), - publishedDate: z.string().optional(), - domain: z.string().min(1), - excerpt: z.string(), - url: SafeWebUrlSchema, -}); - -export type SourceImportOutput = z.infer; +export * from '@debatelab/core/ai/schemas/source-import'; diff --git a/src/lib/ai/schemas/sparring.ts b/src/lib/ai/schemas/sparring.ts index ba46dded1..768ed089e 100644 --- a/src/lib/ai/schemas/sparring.ts +++ b/src/lib/ai/schemas/sparring.ts @@ -1,154 +1 @@ -import { z } from 'zod'; -import { SpeechDeliverySchema } from '@/lib/voice/delivery'; - -export const SparringSideSchema = z.enum(['pro', 'con']); -export const SparringFormatSchema = z.enum(['standard', 'ld', 'pf']); -export const SparringDifficultySchema = z.enum(['easy', 'medium', 'hard']); -export const SparringSpeakerSchema = z.enum(['user', 'ai']); -export const SparringTurnKindSchema = z.enum([ - 'introductor', - 'r1', - 'r2', - 'conclusor', - 'opening', - 'constructive', - 'rebuttal', - 'summary', - 'final', -]); - -export const SparringTurnSchema = z.object({ - id: z.string().trim().min(1), - label: z.string().trim().min(1), - side: SparringSideSchema, - kind: SparringTurnKindSchema, - timeLimitSeconds: z.number().int().positive(), -}); - -/** A completed turn from either debater, sent as context for the next call. */ -export const SparringSpeechSchema = z.object({ - id: z.string().trim().min(1), - turnId: z.string().trim().min(1), - speaker: SparringSpeakerSchema, - side: SparringSideSchema, - role: z.string().trim().min(1).max(120), - content: z.string().trim().min(1).max(6_000), - createdAt: z.number().finite(), - timeLimitSeconds: z.number().int().positive(), - timeUsedSeconds: z.number().int().nonnegative(), - integrityToken: z.string().trim().min(32).max(128).optional(), - /** Measured facts for a user speech that was spoken aloud in a voice round. */ - delivery: SpeechDeliverySchema.optional(), -}); - -/** Shared, bounded session context carried by both sparring actions. */ -export const SparringSessionSchema = z.object({ - sessionId: z.string().uuid(), - motion: z.string().trim().min(1).max(1_200), - userSide: SparringSideSchema, - format: SparringFormatSchema, - difficulty: SparringDifficultySchema, - speeches: z.array(SparringSpeechSchema).max(12), - language: z.string().trim().min(1).max(32), -}); - -export const SparringRespondInputSchema = SparringSessionSchema.extend({ - action: z.literal('respond'), - currentTurn: SparringTurnSchema, -}); - -export const SparringScoreInputSchema = SparringSessionSchema.extend({ - action: z.literal('score'), -}); - -export const SparringInputSchema = z.discriminatedUnion('action', [ - SparringRespondInputSchema, - SparringScoreInputSchema, -]); - -export const SparringTurnResponseSchema = z.object({ - action: z.literal('respond'), - speech: z.object({ - content: z.string().trim().min(1).max(6_000), - strategy: z.string().trim().min(1).max(2_000), - targetedPoints: z.array(z.string().trim().min(1).max(500)).max(8), - integrityToken: z.string().trim().min(32).max(128).optional(), - }), -}); - -export const SparringScoreCategoryNameSchema = z.enum([ - 'Logic', - 'Evidence', - 'Delivery', - 'Strategy', -]); - -export const SparringScoreCategorySchema = z.object({ - name: SparringScoreCategoryNameSchema, - userScore: z.number().int().min(0).max(25), - aiScore: z.number().int().min(0).max(25), - feedback: z.string().trim().min(1).max(2_000), -}); - -const REQUIRED_SCORE_CATEGORIES = new Set(['Logic', 'Evidence', 'Delivery', 'Strategy']); - -export const SparringScorecardSchema = z.object({ - userScore: z.number().int().min(0).max(100), - aiScore: z.number().int().min(0).max(100), - categories: z.array(SparringScoreCategorySchema).length(4).superRefine((categories, ctx) => { - const names: Set = new Set(categories.map((category) => category.name)); - if (names.size !== REQUIRED_SCORE_CATEGORIES.size || ['Logic', 'Evidence', 'Delivery', 'Strategy'].some((name) => !names.has(name))) { - ctx.addIssue({ - code: 'custom', - message: 'Categories must include exactly Logic, Evidence, Delivery, and Strategy.', - }); - } - }), - overallFeedback: z.string().trim().min(1).max(4_000), - keyMoments: z.array(z.string().trim().min(1).max(1_000)).max(12), - improvementAreas: z.array(z.string().trim().min(1).max(1_000)).max(12), - winner: z.enum(['user', 'ai', 'tie']), - verdict: z.string().trim().min(1).max(2_000), -}).superRefine((scorecard, ctx) => { - const categoryUserScore = scorecard.categories.reduce((total, category) => total + category.userScore, 0); - const categoryAiScore = scorecard.categories.reduce((total, category) => total + category.aiScore, 0); - if (scorecard.userScore !== categoryUserScore) { - ctx.addIssue({ code: 'custom', path: ['userScore'], message: 'userScore must equal the category score total.' }); - } - if (scorecard.aiScore !== categoryAiScore) { - ctx.addIssue({ code: 'custom', path: ['aiScore'], message: 'aiScore must equal the category score total.' }); - } - const expectedWinner = scorecard.userScore === scorecard.aiScore - ? 'tie' - : scorecard.userScore > scorecard.aiScore ? 'user' : 'ai'; - if (scorecard.winner !== expectedWinner) { - ctx.addIssue({ code: 'custom', path: ['winner'], message: 'winner must match the aggregate scores.' }); - } -}); - -export const SparringScoreResponseSchema = z.object({ - action: z.literal('score'), - scorecard: SparringScorecardSchema, -}); - -export const SparringResponseSchema = z.discriminatedUnion('action', [ - SparringTurnResponseSchema, - SparringScoreResponseSchema, -]); - -export type SparringSide = z.infer; -export type SparringFormat = z.infer; -export type SparringDifficulty = z.infer; -export type SparringSpeaker = z.infer; -export type SparringTurnKind = z.infer; -export type SparringTurn = z.infer; -export type SparringSpeech = z.infer; -export type SparringSession = z.infer; -export type SparringRespondInput = z.infer; -export type SparringScoreInput = z.infer; -export type SparringInput = z.infer; -export type SparringTurnResponse = z.infer; -export type SparringScoreCategory = z.infer; -export type SparringScorecard = z.infer; -export type SparringScoreResponse = z.infer; -export type SparringResponse = z.infer; +export * from '@debatelab/core/ai/schemas/sparring'; diff --git a/src/lib/crossfire/prefill.ts b/src/lib/crossfire/prefill.ts index c1a255ca6..441b11995 100644 --- a/src/lib/crossfire/prefill.ts +++ b/src/lib/crossfire/prefill.ts @@ -1,48 +1 @@ -import type { AttackOutput } from '@/lib/ai/schemas/attack'; -import type { GenerateOutput } from '@/lib/ai/schemas/generate'; -import { - CrossfirePrefillSchema, - MAX_CROSSFIRE_ARGUMENT_CHARS, - MAX_CROSSFIRE_SEED_QUESTIONS, - MAX_CROSSFIRE_TURN_CHARS, - type CrossfirePrefill, -} from './schemas'; - -function boundedArgument(parts: string[]): string { - return parts - .map((part) => part.trim()) - .filter(Boolean) - .join('\n\n') - .slice(0, MAX_CROSSFIRE_ARGUMENT_CHARS); -} - -export function prefillFromGenerate(resolution: string, result: GenerateOutput): CrossfirePrefill { - const contentionText = result.contentions.map((contention, index) => [ - `Contention ${index + 1}: ${contention.claim}`, - `Reasoning: ${contention.reasoning}`, - `Evidence: ${contention.evidence}`, - `Impact: ${contention.impact}`, - ].join('\n')); - - return CrossfirePrefillSchema.parse({ - source: 'generate', - resolution: resolution.trim().slice(0, 500), - argument: boundedArgument([ - `Thesis: ${result.caseStructure.thesis}`, - `Framework: ${result.caseStructure.framework}`, - `Burden of proof: ${result.caseStructure.burdenOfProof}`, - ...contentionText, - ]), - }); -} - -export function prefillFromAttack(caseText: string, result: AttackOutput): CrossfirePrefill { - return CrossfirePrefillSchema.parse({ - source: 'attack', - argument: caseText.trim().slice(0, MAX_CROSSFIRE_ARGUMENT_CHARS), - seedQuestions: result.crossExQuestions - .slice(0, MAX_CROSSFIRE_SEED_QUESTIONS) - .map((item) => item.question.trim().slice(0, MAX_CROSSFIRE_TURN_CHARS)) - .filter(Boolean), - }); -} +export * from '@debatelab/core/crossfire/prefill'; diff --git a/src/lib/crossfire/reducer.ts b/src/lib/crossfire/reducer.ts index 084e663d3..55874c9b0 100644 --- a/src/lib/crossfire/reducer.ts +++ b/src/lib/crossfire/reducer.ts @@ -1,225 +1 @@ -import { - CrossfireConfigSchema, - CrossfireDebriefSchema, - CrossfireSetupConfigSchema, - CrossfireStateSchema, - CrossfireTurnSchema, - CrossfireTurnSignalSchema, - type CrossfireConfig, - type CrossfireDebrief, - type CrossfirePrefill, - type CrossfireState, - type CrossfireSetupConfig, - type CrossfireTurn, - type CrossfireTurnSignal, -} from './schemas'; - -export type CrossfireAction = - | { type: 'config-patched'; patch: Partial } - | { type: 'voice-toggled'; voice: boolean } - | { type: 'session-started'; sessionId: string; now: number } - | { type: 'user-turn-submitted'; turn: CrossfireTurn; expectedReply: 'question' | 'answer' } - | { type: 'ai-turn-received'; turn: CrossfireTurn; signal?: CrossfireTurnSignal } - | { type: 'signal-received'; signal: CrossfireTurnSignal } - | { - type: 'request-failed'; - expectedReply: 'question' | 'answer'; - errorKind: 'timeout' | 'response' | 'request'; - message: string; - } - | { type: 'request-retried' } - | { type: 'session-ended'; now: number; reason: 'manual' | 'timer' } - | { - type: 'debrief-failed'; - errorKind: 'timeout' | 'response' | 'request'; - message: string; - } - | { type: 'debrief-retried' } - | { type: 'debrief-received'; debrief: CrossfireDebrief } - | { type: 'state-restored'; state: CrossfireState } - | { type: 'session-reset'; prefill?: CrossfirePrefill }; - -const DEFAULT_CONFIG: CrossfireSetupConfig = { - resolution: '', - argument: '', - mode: 'question-ai', - style: 'cooperative', - durationSeconds: 180, - useSourceLibrary: false, - seedQuestions: [], -}; - -export function createCrossfireSetupState(prefill?: CrossfirePrefill): CrossfireState { - return { - phase: 'setup', - config: { - ...DEFAULT_CONFIG, - resolution: prefill?.resolution ?? '', - argument: prefill?.argument ?? '', - seedQuestions: prefill?.seedQuestions ?? [], - }, - }; -} - -function appendTurn(turns: CrossfireTurn[], turn: CrossfireTurn): CrossfireTurn[] | null { - const parsed = CrossfireTurnSchema.safeParse(turn); - if (!parsed.success) return null; - if (turns.some((existing) => existing.id === parsed.data.id)) return null; - if (parsed.data.sequence !== turns.length + 1) return null; - return [...turns, parsed.data]; -} - -function attachSignal(turns: CrossfireTurn[], value: CrossfireTurnSignal): CrossfireTurn[] | null { - const signal = CrossfireTurnSignalSchema.safeParse(value); - if (!signal.success) return null; - const targetIndex = turns.findIndex((turn) => turn.id === signal.data.answerTurnId && turn.kind === 'answer'); - if (targetIndex < 0) return null; - const target = CrossfireTurnSchema.safeParse({ ...turns[targetIndex], signal: signal.data }); - if (!target.success) return null; - return turns.map((turn, index) => index === targetIndex ? target.data : turn); -} - -export function crossfireReducer(state: CrossfireState, action: CrossfireAction): CrossfireState { - switch (action.type) { - case 'config-patched': { - if (state.phase !== 'setup') return state; - const config = CrossfireSetupConfigSchema.safeParse({ ...state.config, ...action.patch }); - return config.success ? { ...state, config: config.data } : state; - } - case 'voice-toggled': - return state.phase === 'setup' ? { ...state, voice: action.voice } : state; - case 'session-started': { - if (state.phase !== 'setup') return state; - const config = CrossfireConfigSchema.safeParse(state.config); - if (!config.success) return state; - return { - phase: 'active', - sessionId: action.sessionId, - config: config.data, - turns: [], - startedAt: action.now, - endsAt: action.now + config.data.durationSeconds * 1_000, - request: config.data.mode === 'ai-questions-user' - ? { status: 'pending', expectedReply: 'question' } - : { status: 'idle' }, - ...(state.voice ? { voice: true } : {}), - }; - } - case 'user-turn-submitted': { - if (state.phase !== 'active' || state.request.status !== 'idle') return state; - const expectedKind = state.config.mode === 'question-ai' ? 'question' : 'answer'; - const expectedReply = state.config.mode === 'question-ai' ? 'answer' : 'question'; - if (action.turn.speaker !== 'user' || action.turn.kind !== expectedKind || action.expectedReply !== expectedReply) { - return state; - } - const turns = appendTurn(state.turns, action.turn); - if (!turns) return state; - return { - ...state, - turns, - request: { status: 'pending', expectedReply }, - }; - } - case 'ai-turn-received': { - if (state.phase !== 'active' || state.request.status !== 'pending') return state; - if (action.turn.speaker !== 'ai' || action.turn.kind !== state.request.expectedReply) return state; - let turns = appendTurn(state.turns, action.turn); - if (!turns) return state; - if (action.signal) { - turns = attachSignal(turns, action.signal); - if (!turns) return state; - } - return { ...state, turns, request: { status: 'idle' } }; - } - case 'signal-received': { - if (state.phase === 'setup') return state; - const turns = attachSignal(state.turns, action.signal); - return turns ? { ...state, turns } : state; - } - case 'request-failed': { - if (state.phase !== 'active' || state.request.status !== 'pending') return state; - if (action.expectedReply !== state.request.expectedReply) return state; - return { - ...state, - request: { - status: 'failed', - expectedReply: action.expectedReply, - errorKind: action.errorKind, - message: action.message.slice(0, 1_000), - }, - }; - } - case 'request-retried': { - if (state.phase !== 'active' || state.request.status !== 'failed') return state; - return { - ...state, - request: { status: 'pending', expectedReply: state.request.expectedReply }, - }; - } - case 'session-ended': { - if (state.phase !== 'active' || state.turns.length === 0) return state; - return { - phase: 'debriefing', - sessionId: state.sessionId, - config: state.config, - turns: state.turns, - startedAt: state.startedAt, - endedAt: action.now, - endedReason: action.reason, - requestStatus: 'pending', - ...(state.voice ? { voice: true } : {}), - }; - } - case 'debrief-failed': { - if (state.phase !== 'debriefing' || state.requestStatus !== 'pending') return state; - return { - ...state, - requestStatus: 'failed', - errorKind: action.errorKind, - errorMessage: action.message.slice(0, 1_000), - }; - } - case 'debrief-retried': { - if (state.phase !== 'debriefing' || state.requestStatus !== 'failed') return state; - const { errorKind: _errorKind, errorMessage: _errorMessage, ...rest } = state; - return { ...rest, requestStatus: 'pending' }; - } - case 'debrief-received': { - if (state.phase !== 'debriefing' || state.requestStatus !== 'pending') return state; - const debrief = CrossfireDebriefSchema.safeParse(action.debrief); - if (!debrief.success) return state; - return { - phase: 'complete', - sessionId: state.sessionId, - config: state.config, - turns: state.turns, - startedAt: state.startedAt, - endedAt: state.endedAt, - endedReason: state.endedReason, - debrief: debrief.data, - ...(state.voice ? { voice: true } : {}), - }; - } - case 'state-restored': { - const restored = CrossfireStateSchema.safeParse(action.state); - return restored.success ? restored.data : createCrossfireSetupState(); - } - case 'session-reset': - return createCrossfireSetupState(action.prefill); - default: { - const exhaustive: never = action; - return exhaustive; - } - } -} - -export function expectedUserTurnKind(state: CrossfireState): 'question' | 'answer' | null { - if (state.phase !== 'active' || state.request.status !== 'idle') return null; - if (state.config.mode === 'question-ai') return 'question'; - const latest = state.turns.at(-1); - return latest?.speaker === 'ai' && latest.kind === 'question' ? 'answer' : null; -} - -export function canEndCrossfireSession(state: CrossfireState): boolean { - return state.phase === 'active'; -} +export * from '@debatelab/core/crossfire/reducer'; diff --git a/src/lib/crossfire/schemas.ts b/src/lib/crossfire/schemas.ts index ae6d65aa1..094143578 100644 --- a/src/lib/crossfire/schemas.ts +++ b/src/lib/crossfire/schemas.ts @@ -1,253 +1 @@ -import { z } from 'zod'; -import { SpeechDeliverySchema } from '@/lib/voice/delivery'; - -export const MAX_CROSSFIRE_RESOLUTION_CHARS = 500; -export const MAX_CROSSFIRE_ARGUMENT_CHARS = 16_000; -export const MAX_CROSSFIRE_SEED_QUESTIONS = 10; -export const MAX_CROSSFIRE_TURN_CHARS = 2_000; -export const MAX_CROSSFIRE_TRANSCRIPT_TURNS = 40; -export const MAX_CROSSFIRE_TRANSCRIPT_CHARS = 24_000; -export const MAX_CROSSFIRE_REPLY_CHARS = 2_500; -export const MAX_CROSSFIRE_DEBRIEF_ITEMS = 8; -export const CROSSFIRE_DURATION_OPTIONS = [180, 300, 480] as const; - -const boundedText = (minimum: number, maximum: number) => z.string().trim().min(minimum).max(maximum); - -export const CrossfireModeSchema = z.enum(['question-ai', 'ai-questions-user']); -export type CrossfireMode = z.infer; - -export const CrossfireStyleSchema = z.enum(['cooperative', 'evasive', 'aggressive']); -export type CrossfireStyle = z.infer; - -export const CrossfireConfigSchema = z.object({ - resolution: boundedText(1, MAX_CROSSFIRE_RESOLUTION_CHARS), - argument: boundedText(1, MAX_CROSSFIRE_ARGUMENT_CHARS), - mode: CrossfireModeSchema, - style: CrossfireStyleSchema, - durationSeconds: z.union([ - z.literal(CROSSFIRE_DURATION_OPTIONS[0]), - z.literal(CROSSFIRE_DURATION_OPTIONS[1]), - z.literal(CROSSFIRE_DURATION_OPTIONS[2]), - ]), - useSourceLibrary: z.boolean(), - seedQuestions: z.array(boundedText(1, MAX_CROSSFIRE_TURN_CHARS)).max(MAX_CROSSFIRE_SEED_QUESTIONS), -}).strict(); -export type CrossfireConfig = z.infer; - -export const CrossfireSetupConfigSchema = CrossfireConfigSchema.extend({ - resolution: z.string().max(MAX_CROSSFIRE_RESOLUTION_CHARS), - argument: z.string().max(MAX_CROSSFIRE_ARGUMENT_CHARS), -}); -export type CrossfireSetupConfig = z.infer; - -export const CrossfirePrefillSchema = z.object({ - resolution: z.string().trim().max(MAX_CROSSFIRE_RESOLUTION_CHARS).optional(), - argument: z.string().trim().max(MAX_CROSSFIRE_ARGUMENT_CHARS).optional(), - seedQuestions: z.array(boundedText(1, MAX_CROSSFIRE_TURN_CHARS)).max(MAX_CROSSFIRE_SEED_QUESTIONS).optional(), - source: z.enum(['generate', 'attack']).optional(), -}).strict(); -export type CrossfirePrefill = z.infer; - -export const CrossfireTurnSignalSchema = z.object({ - answerTurnId: z.string().trim().min(1).max(40), - answerQuality: z.enum(['direct', 'partial', 'evasive']), - answerQualityConfidence: z.number().min(0).max(1), - concession: z.enum(['none', 'implicit', 'explicit']), - concessionConfidence: z.number().min(0).max(1), - followUpOpportunity: z.number().min(0).max(1), -}).strict(); -export type CrossfireTurnSignal = z.infer; - -export const CrossfireTurnSchema = z.object({ - id: z.string().trim().min(1).max(40), - sequence: z.number().int().positive().max(MAX_CROSSFIRE_TRANSCRIPT_TURNS), - speaker: z.enum(['user', 'ai']), - kind: z.enum(['question', 'answer']), - text: boundedText(1, MAX_CROSSFIRE_TURN_CHARS), - createdAt: z.number().int().nonnegative(), - signal: CrossfireTurnSignalSchema.optional(), - /** Measured facts for a user turn that was spoken aloud in a voice round. */ - delivery: SpeechDeliverySchema.optional(), -}).strict().superRefine((turn, context) => { - if (turn.delivery && turn.speaker !== 'user') { - context.addIssue({ code: 'custom', path: ['delivery'], message: 'Only user turns carry delivery facts' }); - } - if (!turn.signal) return; - if (turn.kind !== 'answer' || turn.signal.answerTurnId !== turn.id) { - context.addIssue({ code: 'custom', path: ['signal', 'answerTurnId'], message: 'Signal must reference this answer turn' }); - } -}); -export type CrossfireTurn = z.infer; - -export const CrossfireTranscriptSchema = z.array(CrossfireTurnSchema) - .max(MAX_CROSSFIRE_TRANSCRIPT_TURNS) - .superRefine((turns, context) => { - const ids = new Set(); - turns.forEach((turn, index) => { - if (ids.has(turn.id)) { - context.addIssue({ - code: 'custom', - path: [index, 'id'], - message: `Transcript contains duplicate turn id ${turn.id}`, - }); - } - ids.add(turn.id); - if (turn.sequence !== index + 1) { - context.addIssue({ - code: 'custom', - path: [index, 'sequence'], - message: `Transcript turn ${index + 1} must have sequence ${index + 1}`, - }); - } - }); - const total = turns.reduce((sum, turn) => sum + turn.text.length, 0); - if (total > MAX_CROSSFIRE_TRANSCRIPT_CHARS) { - context.addIssue({ - code: 'custom', - message: `Transcript exceeds ${MAX_CROSSFIRE_TRANSCRIPT_CHARS} characters`, - }); - } - }); - -export const CrossfireReplySchema = z.object({ - type: z.enum(['question', 'answer']), - text: boundedText(1, MAX_CROSSFIRE_REPLY_CHARS), -}).strict(); -export type CrossfireReply = z.infer; - -const TurnReferenceSchema = z.object({ - turnIds: z.array(z.string().trim().min(1).max(40)).min(1).max(6), - claim: boundedText(1, 800), -}).strict(); - -const BetterFollowUpSchema = z.object({ - turnIds: z.array(z.string().trim().min(1).max(40)).min(1).max(6), - question: boundedText(1, MAX_CROSSFIRE_REPLY_CHARS), - reason: boundedText(1, 800), -}).strict(); - -export const CrossfireDebriefSchema = z.object({ - concessionsSecured: z.array(TurnReferenceSchema).max(MAX_CROSSFIRE_DEBRIEF_ITEMS), - controlledQuestions: z.array(TurnReferenceSchema).max(MAX_CROSSFIRE_DEBRIEF_ITEMS), - weakOrRepetitiveQuestions: z.array(TurnReferenceSchema).max(MAX_CROSSFIRE_DEBRIEF_ITEMS), - evasiveOrIncompleteAnswers: z.array(TurnReferenceSchema).max(MAX_CROSSFIRE_DEBRIEF_ITEMS), - missedFollowUps: z.array(TurnReferenceSchema).max(MAX_CROSSFIRE_DEBRIEF_ITEMS), - betterFollowUpQuestions: z.array(BetterFollowUpSchema).max(MAX_CROSSFIRE_DEBRIEF_ITEMS), - strongestExchange: TurnReferenceSchema.nullable(), -}).strict(); -export type CrossfireDebrief = z.infer; - -export const CrossfireNextTurnInputSchema = z.object({ - operation: z.literal('next-turn'), - config: CrossfireConfigSchema, - transcript: CrossfireTranscriptSchema, - expectedReply: z.enum(['question', 'answer']), -}).strict(); - -export const CrossfireDebriefInputSchema = z.object({ - operation: z.literal('debrief'), - config: CrossfireConfigSchema, - transcript: CrossfireTranscriptSchema.min(1), -}).strict(); - -export const CrossfireAIInputSchema = z.discriminatedUnion('operation', [ - CrossfireNextTurnInputSchema, - CrossfireDebriefInputSchema, -]); -export type CrossfireAIInput = z.infer; - -export const CrossfireSignalInputSchema = z.object({ - config: CrossfireConfigSchema, - transcript: CrossfireTranscriptSchema, - reply: CrossfireReplySchema, - signalToken: z.string().trim().min(1).max(128), -}).strict(); -export type CrossfireSignalInput = z.infer; - -export const CrossfireRequestStateSchema = z.discriminatedUnion('status', [ - z.object({ status: z.literal('idle') }).strict(), - z.object({ - status: z.literal('pending'), - expectedReply: z.enum(['question', 'answer']), - }).strict(), - z.object({ - status: z.literal('failed'), - expectedReply: z.enum(['question', 'answer']), - errorKind: z.enum(['timeout', 'response', 'request']), - message: z.string().trim().min(1).max(1_000), - }).strict(), -]); -export type CrossfireRequestState = z.infer; - -// `voice` marks a spoken round. It is optional so drafts and saved rounds -// from before voice rounds still load, as typed. -const SetupStateSchema = z.object({ - phase: z.literal('setup'), - config: CrossfireSetupConfigSchema, - voice: z.boolean().optional(), -}).strict(); - -const ActiveStateSchema = z.object({ - phase: z.literal('active'), - sessionId: z.string().trim().min(1).max(80), - config: CrossfireConfigSchema, - turns: CrossfireTranscriptSchema, - startedAt: z.number().int().nonnegative(), - endsAt: z.number().int().positive(), - request: CrossfireRequestStateSchema, - voice: z.boolean().optional(), -}).strict(); - -const DebriefingStateSchema = z.object({ - phase: z.literal('debriefing'), - sessionId: z.string().trim().min(1).max(80), - config: CrossfireConfigSchema, - turns: CrossfireTranscriptSchema.min(1), - startedAt: z.number().int().nonnegative(), - endedAt: z.number().int().nonnegative(), - endedReason: z.enum(['manual', 'timer']), - requestStatus: z.enum(['pending', 'failed']), - errorKind: z.enum(['timeout', 'response', 'request']).optional(), - errorMessage: z.string().trim().min(1).max(1_000).optional(), - voice: z.boolean().optional(), -}).strict(); - -const CompleteStateSchema = z.object({ - phase: z.literal('complete'), - sessionId: z.string().trim().min(1).max(80), - config: CrossfireConfigSchema, - turns: CrossfireTranscriptSchema.min(1), - startedAt: z.number().int().nonnegative(), - endedAt: z.number().int().nonnegative(), - endedReason: z.enum(['manual', 'timer']), - debrief: CrossfireDebriefSchema, - voice: z.boolean().optional(), -}).strict(); - -export const CrossfireStateSchema = z.discriminatedUnion('phase', [ - SetupStateSchema, - ActiveStateSchema, - DebriefingStateSchema, - CompleteStateSchema, -]); -export type CrossfireState = z.infer; - -export const PersistedCrossfireDraftSchema = z.object({ - version: z.literal(1), - ownerId: z.string().trim().min(1).max(128), - savedAt: z.number().int().nonnegative(), - state: z.discriminatedUnion('phase', [SetupStateSchema, ActiveStateSchema, DebriefingStateSchema]), -}).strict(); -export type PersistedCrossfireDraft = z.infer; - -export function collectDebriefTurnIds(debrief: CrossfireDebrief): string[] { - return [ - ...debrief.concessionsSecured, - ...debrief.controlledQuestions, - ...debrief.weakOrRepetitiveQuestions, - ...debrief.evasiveOrIncompleteAnswers, - ...debrief.missedFollowUps, - ...debrief.betterFollowUpQuestions, - ...(debrief.strongestExchange ? [debrief.strongestExchange] : []), - ].flatMap((item) => item.turnIds); -} +export * from '@debatelab/core/crossfire/schemas'; diff --git a/src/lib/crossfire/storage.ts b/src/lib/crossfire/storage.ts index dbb17cb47..bbb1020e8 100644 --- a/src/lib/crossfire/storage.ts +++ b/src/lib/crossfire/storage.ts @@ -1,51 +1,19 @@ -import { - PersistedCrossfireDraftSchema, - type CrossfireState, - type PersistedCrossfireDraft, -} from './schemas'; +import * as core from '@debatelab/core/crossfire/storage'; +import type { StorageLike } from '@debatelab/core/crossfire/storage'; +import type { CrossfireState, PersistedCrossfireDraft } from '@debatelab/core/crossfire/schemas'; -export const CROSSFIRE_STORAGE_KEY_PREFIX = 'debatelab-crossfire-draft'; -export const MAX_CROSSFIRE_STORAGE_CHARS = 80_000; - -type StorageLike = Pick; +export { CROSSFIRE_STORAGE_KEY_PREFIX, MAX_CROSSFIRE_STORAGE_CHARS, crossfireStorageKey } from '@debatelab/core/crossfire/storage'; +// The shared module takes storage explicitly; the web defaults to localStorage. function browserStorage(): StorageLike | null { return typeof window === 'undefined' ? null : window.localStorage; } -export function crossfireStorageKey(ownerId: string): string { - return `${CROSSFIRE_STORAGE_KEY_PREFIX}:${ownerId}`; -} - export function loadCrossfireDraft( ownerId: string, storage: StorageLike | null = browserStorage(), ): PersistedCrossfireDraft['state'] | null { - if (!storage || !ownerId) return null; - const key = crossfireStorageKey(ownerId); - let serialized: string | null; - try { - serialized = storage.getItem(key); - } catch { - return null; - } - if (!serialized) return null; - if (serialized.length > MAX_CROSSFIRE_STORAGE_CHARS) { - try { storage.removeItem(key); } catch { return null; } - return null; - } - - try { - const parsed = PersistedCrossfireDraftSchema.safeParse(JSON.parse(serialized)); - if (!parsed.success || parsed.data.ownerId !== ownerId) { - try { storage.removeItem(key); } catch { return null; } - return null; - } - return parsed.data.state; - } catch { - try { storage.removeItem(key); } catch { return null; } - return null; - } + return core.loadCrossfireDraft(ownerId, storage); } export function saveCrossfireDraft( @@ -54,39 +22,11 @@ export function saveCrossfireDraft( storage: StorageLike | null = browserStorage(), now = Date.now(), ): boolean { - if (!storage || !ownerId) return false; - const key = crossfireStorageKey(ownerId); - if (state.phase === 'complete') { - try { - storage.removeItem(key); - return true; - } catch { - return false; - } - } - - const draft: PersistedCrossfireDraft = { - version: 1, - ownerId, - savedAt: now, - state, - }; - const parsed = PersistedCrossfireDraftSchema.safeParse(draft); - if (!parsed.success) return false; - - const serialized = JSON.stringify(parsed.data); - if (serialized.length > MAX_CROSSFIRE_STORAGE_CHARS) return false; - try { - storage.setItem(key, serialized); - return true; - } catch { - return false; - } + return core.saveCrossfireDraft(ownerId, state, storage, now); } export function clearCrossfireDraft(ownerId: string, storage: StorageLike | null = browserStorage()): void { - if (!ownerId || !storage) return; - try { storage.removeItem(crossfireStorageKey(ownerId)); } catch { /* storage unavailable */ } + core.clearCrossfireDraft(ownerId, storage); } export function clearCrossfireDraftOnAuthChange( @@ -94,9 +34,5 @@ export function clearCrossfireDraftOnAuthChange( nextUserId: string | undefined, storage: StorageLike | null = browserStorage(), ): boolean { - if (previousUserId === nextUserId) return false; - if (previousUserId) { - try { storage?.removeItem(crossfireStorageKey(previousUserId)); } catch { /* storage unavailable */ } - } - return true; + return core.clearCrossfireDraftOnAuthChange(previousUserId, nextUserId, storage); } diff --git a/src/lib/languages.ts b/src/lib/languages.ts index 04950d4bf..4aaef005c 100644 --- a/src/lib/languages.ts +++ b/src/lib/languages.ts @@ -1,19 +1 @@ -export const APP_LANGUAGES = { - en: { label: 'English', intlLocale: 'en-GB', aiName: 'English' }, - es: { label: 'Español', intlLocale: 'es-ES', aiName: 'Spanish' }, - hu: { label: 'Magyar', intlLocale: 'hu-HU', aiName: 'Hungarian' }, -} as const; - -export type AppLanguage = keyof typeof APP_LANGUAGES; - -export function isAppLanguage(value: unknown): value is AppLanguage { - return typeof value === 'string' && Object.prototype.hasOwnProperty.call(APP_LANGUAGES, value); -} - -export function toIntlLocale(language: AppLanguage): string { - return APP_LANGUAGES[language].intlLocale; -} - -export function languageName(language: AppLanguage): string { - return APP_LANGUAGES[language].aiName; -} +export * from '@debatelab/core/languages'; diff --git a/src/lib/sparring/formats.ts b/src/lib/sparring/formats.ts index d2a1d0395..50988705b 100644 --- a/src/lib/sparring/formats.ts +++ b/src/lib/sparring/formats.ts @@ -1,96 +1 @@ -import type { SparringFormat, SparringSide } from '@/lib/ai/schemas/sparring'; - -export type SparringTurnKind = 'introductor' | 'r1' | 'r2' | 'conclusor' | 'opening' | 'constructive' | 'rebuttal' | 'summary' | 'final'; - -export interface SparringTurn { - id: string; - label: string; - side: SparringSide; - speaker: 'user' | 'ai'; - kind: SparringTurnKind; - timeLimitSeconds: number; -} - -export interface SparringFormatDefinition { - id: SparringFormat; - name: string; - shortDescription: string; - turnCount: number; - approximateMinutes: number; -} - -const FORMAT_DEFINITIONS: Record = { - standard: { - id: 'standard', - name: 'School 4-6-6-3', - shortDescription: 'Your school format: Introductor, R1, R2, and Conclusor for both sides.', - turnCount: 8, - approximateMinutes: 38, - }, - ld: { - id: 'ld', - name: 'Lincoln-Douglas', - shortDescription: 'A focused values round with constructives and two rebuttal stages.', - turnCount: 5, - approximateMinutes: 26, - }, - pf: { - id: 'pf', - name: 'Public Forum', - shortDescription: 'Constructive, rebuttal, summary, and final focus for both sides.', - turnCount: 8, - approximateMinutes: 26, - }, -}; - -type TurnTemplate = Omit; - -const TURN_TEMPLATES: Record = { - standard: [ - { label: 'Pro Introductor', side: 'pro', kind: 'introductor', timeLimitSeconds: 4 * 60 }, - { label: 'Con Introductor', side: 'con', kind: 'introductor', timeLimitSeconds: 4 * 60 }, - { label: 'Pro R1', side: 'pro', kind: 'r1', timeLimitSeconds: 6 * 60 }, - { label: 'Con R1', side: 'con', kind: 'r1', timeLimitSeconds: 6 * 60 }, - { label: 'Pro R2', side: 'pro', kind: 'r2', timeLimitSeconds: 6 * 60 }, - { label: 'Con R2', side: 'con', kind: 'r2', timeLimitSeconds: 6 * 60 }, - { label: 'Pro Conclusor', side: 'pro', kind: 'conclusor', timeLimitSeconds: 3 * 60 }, - { label: 'Con Conclusor', side: 'con', kind: 'conclusor', timeLimitSeconds: 3 * 60 }, - ], - ld: [ - { label: 'Affirmative constructive', side: 'pro', kind: 'constructive', timeLimitSeconds: 6 * 60 }, - { label: 'Negative constructive', side: 'con', kind: 'constructive', timeLimitSeconds: 7 * 60 }, - { label: 'First affirmative rebuttal', side: 'pro', kind: 'rebuttal', timeLimitSeconds: 4 * 60 }, - { label: 'Negative rebuttal', side: 'con', kind: 'rebuttal', timeLimitSeconds: 6 * 60 }, - { label: 'Second affirmative rebuttal', side: 'pro', kind: 'final', timeLimitSeconds: 3 * 60 }, - ], - pf: [ - { label: 'Pro constructive', side: 'pro', kind: 'constructive', timeLimitSeconds: 4 * 60 }, - { label: 'Con constructive', side: 'con', kind: 'constructive', timeLimitSeconds: 4 * 60 }, - { label: 'Pro rebuttal', side: 'pro', kind: 'rebuttal', timeLimitSeconds: 4 * 60 }, - { label: 'Con rebuttal', side: 'con', kind: 'rebuttal', timeLimitSeconds: 4 * 60 }, - { label: 'Pro summary', side: 'pro', kind: 'summary', timeLimitSeconds: 3 * 60 }, - { label: 'Con summary', side: 'con', kind: 'summary', timeLimitSeconds: 3 * 60 }, - { label: 'Pro final focus', side: 'pro', kind: 'final', timeLimitSeconds: 2 * 60 }, - { label: 'Con final focus', side: 'con', kind: 'final', timeLimitSeconds: 2 * 60 }, - ], -}; - -export const SPARRING_FORMATS = Object.values(FORMAT_DEFINITIONS); - -export function getSparringFormat(format: SparringFormat): SparringFormatDefinition { - return FORMAT_DEFINITIONS[format]; -} - -export function buildSparringTurns(format: SparringFormat, userSide: SparringSide): SparringTurn[] { - return TURN_TEMPLATES[format].map((turn, index) => ({ - ...turn, - id: `${format}-${index + 1}`, - speaker: turn.side === userSide ? 'user' : 'ai', - })); -} - -export function formatRoundTime(seconds: number): string { - const safeSeconds = Math.max(0, Math.floor(seconds)); - const minutes = Math.floor(safeSeconds / 60); - return `${minutes}:${String(safeSeconds % 60).padStart(2, '0')}`; -} +export * from '@debatelab/core/sparring/formats'; diff --git a/src/lib/supabase/edge-function-error.ts b/src/lib/supabase/edge-function-error.ts index ab688a4fb..dd9ab9cf7 100644 --- a/src/lib/supabase/edge-function-error.ts +++ b/src/lib/supabase/edge-function-error.ts @@ -1,50 +1 @@ -export async function getEdgeFunctionErrorMessage(error: unknown): Promise { - if (!error) return 'Request failed'; - - const err = error as { - message?: unknown; - context?: unknown; - }; - - const context = err.context; - let status: number | undefined; - let bodyObj: unknown; - - const maybeResponse = context as { status?: unknown; text?: () => Promise }; - if (maybeResponse && typeof maybeResponse.status === 'number') { - status = maybeResponse.status; - if (typeof maybeResponse.text === 'function') { - try { - const text = await maybeResponse.text(); - if (text) bodyObj = JSON.parse(text); - } catch { - bodyObj = undefined; - } - } - } else if (context && typeof context === 'object') { - const plainContext = context as { status?: unknown; body?: unknown }; - status = typeof plainContext.status === 'number' ? plainContext.status : undefined; - const body = plainContext.body; - if (typeof body === 'string' && body) { - try { - bodyObj = JSON.parse(body); - } catch { - bodyObj = undefined; - } - } else if (body && typeof body === 'object') { - bodyObj = body; - } - } - - const bodyError = bodyObj && typeof bodyObj === 'object' && 'error' in bodyObj - ? (bodyObj as { error?: unknown }).error - : undefined; - - if (typeof bodyError === 'string' && bodyError.trim()) return bodyError; - - const msg = typeof err.message === 'string' ? err.message : ''; - if (msg && msg !== 'Edge Function returned a non-2xx status code') return msg; - - if (status) return `Request failed (HTTP ${status})`; - return 'Request failed'; -} +export * from '@debatelab/core/supabase/edge-function-error'; diff --git a/src/lib/validation/file.ts b/src/lib/validation/file.ts index 03e3bc4ad..ce268e1b2 100644 --- a/src/lib/validation/file.ts +++ b/src/lib/validation/file.ts @@ -1,119 +1 @@ -/** - * File upload validation utilities - * Enforces size limits, file count, and allowed MIME types - */ - -// Maximum file size in bytes (10 MB) -export const MAX_FILE_SIZE = 10 * 1024 * 1024; - -// Maximum number of files -export const MAX_FILE_COUNT = 5; - -// Allowed MIME types -const ALLOWED_MIME_TYPES = new Set([ - // Text files - 'text/plain', - 'text/markdown', - 'text/csv', - 'text/html', - 'text/css', - 'text/xml', - // Code files - 'application/json', - 'application/xml', - 'application/javascript', - 'text/javascript', - 'application/typescript', - // Application-specific text - 'application/x-yaml', - 'text/yaml', - 'text/x-python', - 'text/x-java', - 'text/x-c', - 'text/x-c++', -]); - -// Allowed file extensions (lowercase, without dot) -const ALLOWED_EXTENSIONS = new Set([ - // Text - 'txt', 'md', 'markdown', 'csv', 'json', 'yaml', 'yml', 'xml', - // Code - 'js', 'jsx', 'ts', 'tsx', 'py', 'java', 'c', 'cpp', 'h', 'hpp', - // Web - 'html', 'htm', 'css', 'scss', 'sass', 'less', - // Config - 'toml', 'ini', 'cfg', 'conf', 'env', -]); - -export interface FileValidationResult { - valid: boolean; - error?: string; -} - -/** - * Validates a single file for size and type. - */ -export function validateSingleFile(file: File): FileValidationResult { - // Check file size - if (file.size > MAX_FILE_SIZE) { - const sizeMB = (file.size / (1024 * 1024)).toFixed(1); - return { - valid: false, - error: `File "${file.name}" is too large (${sizeMB} MB). Maximum size is 10 MB.`, - }; - } - - // Get file extension - const extension = file.name.split('.').pop()?.toLowerCase() || ''; - - // Check if extension is allowed - if (!ALLOWED_EXTENSIONS.has(extension)) { - return { - valid: false, - error: `File "${file.name}" has an unsupported file type. Only text and code files are allowed.`, - }; - } - - // Check MIME type if available (some browsers don't provide it) - if (file.type && !ALLOWED_MIME_TYPES.has(file.type) && !file.type.startsWith('text/')) { - return { - valid: false, - error: `File "${file.name}" has an unsupported file type. Only text and code files are allowed.`, - }; - } - - return { valid: true }; -} - -/** - * Validates an array of files for upload. - * Checks: file count, individual file sizes, and allowed types. - */ -export function validateFileUpload(files: File[]): FileValidationResult { - // Check file count - if (files.length > MAX_FILE_COUNT) { - return { - valid: false, - error: `Too many files selected (${files.length}). Maximum is ${MAX_FILE_COUNT} files.`, - }; - } - - // Validate each file - for (const file of files) { - const result = validateSingleFile(file); - if (!result.valid) { - return result; - } - } - - return { valid: true }; -} - -/** - * Formats a file size in bytes to a human-readable string. - */ -export function formatFileSize(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} +export * from '@debatelab/core/validation/file'; diff --git a/src/lib/validation/url.ts b/src/lib/validation/url.ts index f33f4eb72..75b8aac61 100644 --- a/src/lib/validation/url.ts +++ b/src/lib/validation/url.ts @@ -1,57 +1 @@ -/** - * URL validation utilities - * Validates URLs to prevent XSS and other security issues - */ - -const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']); -const BLOCKED_PROTOCOLS = new Set(['javascript:', 'data:', 'vbscript:', 'file:']); - -/** - * Validates a URL string and returns a safe URL or undefined. - * - * - Allows only http: and https: protocols - * - Blocks javascript:, data:, and other dangerous schemes - * - Uses URL constructor for robust parsing - * - * @param url - The URL string to validate - * @returns The validated URL string, or undefined if invalid/unsafe - */ -export function validateUrl(url?: string): string | undefined { - if (!url || typeof url !== 'string') { - return undefined; - } - - // Trim whitespace - const trimmed = url.trim(); - if (!trimmed) { - return undefined; - } - - // Handle URLs without protocol (add https:// prefix) - let urlToValidate = trimmed; - if (!trimmed.includes('://') && trimmed.includes('.')) { - urlToValidate = `https://${trimmed}`; - } - - try { - const parsed = new URL(urlToValidate); - - // Check for blocked protocols first - if (BLOCKED_PROTOCOLS.has(parsed.protocol.toLowerCase())) { - console.warn(`[SECURITY] Blocked dangerous URL protocol: ${parsed.protocol}`); - return undefined; - } - - // Only allow http and https - if (!ALLOWED_PROTOCOLS.has(parsed.protocol.toLowerCase())) { - console.warn(`[SECURITY] Blocked non-http URL protocol: ${parsed.protocol}`); - return undefined; - } - - // Return the normalized URL - return parsed.href; - } catch { - // Invalid URL - return undefined; - } -} +export * from '@debatelab/core/validation/url'; diff --git a/src/lib/voice/delivery.ts b/src/lib/voice/delivery.ts index bc9eb7149..52c540745 100644 --- a/src/lib/voice/delivery.ts +++ b/src/lib/voice/delivery.ts @@ -1,131 +1 @@ -import { z } from 'zod'; -import type { AppLanguage } from '@/lib/languages'; - -/** - * Facts measured while a student speaks a turn aloud: the clock and the - * browser's transcript, nothing else. Nobody hears the audio, so nothing here - * describes tone, confidence, or volume. - */ -export const SpeechDeliverySchema = z.object({ - speakingSeconds: z.number().int().min(0).max(86_400), - wordCount: z.number().int().min(0).max(6_000), - wordsPerMinute: z.number().int().min(0).max(600), - fillerCount: z.number().int().min(0).max(6_000), - fillers: z.array(z.object({ - word: z.string().trim().min(1).max(40), - count: z.number().int().min(1).max(6_000), - }).strict()).max(12), - longPauses: z.number().int().min(0).max(1_000), - longestPauseSeconds: z.number().int().min(0).max(86_400), -}).strict(); -export type SpeechDelivery = z.infer; - -/** A silence at least this long between recognised words counts as a long pause. */ -export const LONG_PAUSE_SECONDS = 3; -const MAX_REPORTED_FILLERS = 12; - -// Hesitation sounds and filler phrases per language. Words that are often -// real content ("like", "este", "bueno") are left out: a measured count -// should not accuse a student of a filler they did not use. -const FILLERS: Record = { - en: ['um', 'uh', 'er', 'erm', 'hmm', 'you know', 'I mean', 'sort of', 'kind of', 'basically', 'literally'], - es: ['eh', 'em', 'ehm', 'mmm', 'o sea', 'en plan', 'a ver', 'digamos', 'pues'], - hu: ['ööö', 'hát', 'izé', 'szóval', 'ugye', 'úgymond', 'tulajdonképpen'], -}; - -function words(text: string): string[] { - return text.toLowerCase().match(/[\p{L}\p{M}]+(?:['’][\p{L}\p{M}]+)*/gu) ?? []; -} - -// "ummm" and "um" are the same hesitation, so runs of one letter collapse. -function collapse(word: string): string { - return word.replace(/(.)\1+/gu, '$1'); -} - -interface FillerPattern { - label: string; - tokens: string[]; -} - -const FILLER_PATTERNS = Object.fromEntries( - (Object.entries(FILLERS) as Array<[AppLanguage, readonly string[]]>).map(([language, fillers]) => [ - language, - fillers - .map((label): FillerPattern => ({ label, tokens: words(label).map(collapse) })) - // Longer phrases first, so "you know" is not split. - .sort((left, right) => right.tokens.length - left.tokens.length), - ]), -) as Record; - -export function countWords(text: string): number { - return words(text).length; -} - -export function countFillers(text: string, language: AppLanguage): Array<{ word: string; count: number }> { - const tokens = words(text).map(collapse); - const counts = new Map(); - for (let index = 0; index < tokens.length;) { - const match = FILLER_PATTERNS[language].find((pattern) => - pattern.tokens.every((token, offset) => tokens[index + offset] === token)); - if (match) { - counts.set(match.label, (counts.get(match.label) ?? 0) + 1); - index += match.tokens.length; - } else { - index += 1; - } - } - return [...counts.entries()] - .map(([word, count]) => ({ word, count })) - .sort((left, right) => right.count - left.count || left.word.localeCompare(right.word)) - .slice(0, MAX_REPORTED_FILLERS); -} - -/** What one or more microphone takes within a turn recorded. */ -export interface SpeechTake { - /** Everything the browser transcribed, before any manual edits. */ - text: string; - /** Time the microphone was listening. */ - speakingMs: number; - /** Silences between recognised words of at least LONG_PAUSE_SECONDS. */ - pausesMs: number[]; -} - -/** Turn a take into delivery facts; null when nothing was spoken. */ -export function measureDelivery(take: SpeechTake, language: AppLanguage): SpeechDelivery | null { - const wordCount = Math.min(countWords(take.text), 6_000); - if (wordCount === 0) return null; - const speakingSeconds = Math.min(86_400, Math.max(1, Math.round(take.speakingMs / 1_000))); - const fillers = countFillers(take.text, language); - return { - speakingSeconds, - wordCount, - wordsPerMinute: Math.min(600, Math.round(wordCount / (speakingSeconds / 60))), - fillerCount: fillers.reduce((total, filler) => total + filler.count, 0), - fillers, - longPauses: Math.min(1_000, take.pausesMs.length), - longestPauseSeconds: Math.min(86_400, Math.round(Math.max(0, ...take.pausesMs) / 1_000)), - }; -} - -/** Add up the facts from several spoken turns, for a round-level summary. */ -export function combineDelivery(deliveries: SpeechDelivery[]): SpeechDelivery | null { - if (deliveries.length === 0) return null; - const speakingSeconds = deliveries.reduce((total, item) => total + item.speakingSeconds, 0); - const wordCount = deliveries.reduce((total, item) => total + item.wordCount, 0); - const fillerCounts = new Map(); - for (const item of deliveries) { - for (const filler of item.fillers) fillerCounts.set(filler.word, (fillerCounts.get(filler.word) ?? 0) + filler.count); - } - return { - speakingSeconds, - wordCount, - wordsPerMinute: speakingSeconds > 0 ? Math.min(600, Math.round(wordCount / (speakingSeconds / 60))) : 0, - fillerCount: deliveries.reduce((total, item) => total + item.fillerCount, 0), - fillers: [...fillerCounts.entries()] - .map(([word, count]) => ({ word, count })) - .sort((left, right) => right.count - left.count || left.word.localeCompare(right.word)) - .slice(0, MAX_REPORTED_FILLERS), - longPauses: deliveries.reduce((total, item) => total + item.longPauses, 0), - longestPauseSeconds: Math.max(...deliveries.map((item) => item.longestPauseSeconds)), - }; -} +export * from '@debatelab/core/voice/delivery'; diff --git a/src/lib/voice/payload.ts b/src/lib/voice/payload.ts index f85716f2a..08f30452f 100644 --- a/src/lib/voice/payload.ts +++ b/src/lib/voice/payload.ts @@ -1,28 +1 @@ -import { z } from 'zod'; -import { MAX_VOICE_SEGMENT_CHARS, MAX_VOICE_SEGMENTS } from '../../../supabase/functions/_shared/voice-tags'; - -// What an AI response carries for a voice round: the opponent's reply cut -// into segments, each signed by the server so the voice function will speak -// it. The spoken text may hold audio tags and is never shown or saved. - -export type VoiceFeature = 'sparring' | 'crossfire'; - -const VoiceSegmentSchema = z.object({ - text: z.string().min(1).max(MAX_VOICE_SEGMENT_CHARS), - token: z.string().min(1).max(128), -}); -// The unsigned rest of a reply too long for MAX_VOICE_SEGMENTS. It is read -// by the browser voice only; replies are at most 6,000 characters. -const MAX_VOICE_OVERFLOW_CHARS = 12_000; -const VoicePayloadSchema = z.object({ - segments: z.array(VoiceSegmentSchema).min(1).max(MAX_VOICE_SEGMENTS), - overflow: z.string().min(1).max(MAX_VOICE_OVERFLOW_CHARS).optional(), -}); -export type VoiceSegment = z.infer; -export type VoicePayload = z.infer; - -/** The signed segments an AI response carries; undefined when absent or malformed. */ -export function parseVoicePayload(value: unknown): VoicePayload | undefined { - const parsed = VoicePayloadSchema.safeParse(value); - return parsed.success ? parsed.data : undefined; -} +export * from '@debatelab/core/voice/payload';