Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/verify-debatelab/features/practice.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Preconditions:
- Doctor passes. The user is signed in. The recipe is `recipes/practice.spec.ts`.

- **Start a sparring round.** On `/app/sparring`, fill the field labelled `Motion`. Open the combobox named `Round format` and choose the option matching `Lincoln-Douglas · 5 speeches`. Click `Start round`. A new account has no Current Debate, so `Start a debate with this motion?` opens: `chooseForMotion(page, "create")`.
- **Speeches.** For each user turn, fill the textbox named `Draft for <turn>` (`Affirmative constructive`, `First affirmative rebuttal`, `Second affirmative rebuttal`) and click `Submit speech`. After each of the first two, the last `FAKE-PROVIDER opponent speech` is visible.
- **Speeches.** For each user turn, fill the textbox named `Draft for <turn>` (`Affirmative constructive`, `First affirmative rebuttal`, `Second affirmative rebuttal`) and click `Submit speech`. Each turn opens with a stopped clock (test id `sparring-ready`, button `Start speaking`); filling the draft starts it. After each of the first two, the last `FAKE-PROVIDER opponent speech` is visible.
- **Verdict.** The heading `Here is what happened in the room.` and the text `FAKE-PROVIDER verdict: the user had the edge.` appear. `providerCall("sparring").user` contains `SPARRING ACTION:`. `sparring_sessions` statuses for the user equal `["complete"]`, and `select("side,debates(motion)")` gives `{side: "pro", debates: {motion}}`.
- **Start Crossfire.** On `/app/crossfire`, fill test id `crossfire-resolution` and `crossfire-argument`. Click `mode-question-ai` (or `mode-ai-questions-user`), then `start-crossfire`, then `chooseForMotion(page, "create")`.
- **Crossfire turn.** Fill `crossfire-input` and click `submit-turn`. The text `FAKE-PROVIDER crossfire answer` and `Direct answer` appear. `providerCall("crossfire")` and `providerCall("jev")` both fired.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Preconditions:
- **Open the tool.** Create a workspace for the motion, click `R1`, and on a phone click `Working panel` first (one pane at a time). Open the `Practice` details if closed and click `Crossfire` or `Sparring` (`exact: true`).
- **Hold one save.** `page.route("**/rest/v1/rpc/save_workspace_artifact")` holds the first request whose `p_progress` matches a partial value (Crossfire: a resolution ending in ` n`; Sparring: a draft of `Coding`). The recipe types up to that value and waits until the save is held before typing the rest: the queue keeps only the newest pending save, so a value typed straight through can be merged away and never sent. The page keeps one save in flight, so no other save may be sent while it is held (`sentWhileHeld()` is 0); newer changes wait in the queue.
- **Release and check.** Let the held save through and wait for its answer. Polling `admin.from("workspace_artifacts")` for the workspace and tool must return one row with the full text, and it must still hold that text once every save the page sent has been answered.
- **Sparring.** Click `Start round` and wait for the textbox `Draft for <turn>` (`AI_TIMEOUT`). Click `Pause clock` before typing, because a running clock saves every tick and would hide a stale save.
- **Sparring.** Click `Start round` and wait for the textbox `Draft for <turn>` (`AI_TIMEOUT`). Each turn opens with a stopped clock and a `Start speaking` button; typing would start it. Click `Start speaking`, then `Pause clock`, before typing, because a running clock saves every tick and would hide a stale save.

## Gotchas

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,9 @@ test("ws-sparring-autosave: a slow Sparring save does not overwrite a newer draf
const draft = page.getByRole("textbox", { name: /^Draft for / });
await expect(draft).toBeVisible({ timeout: AI_TIMEOUT });
// A running clock saves the session every tick, which would hide a stale save.
const pause = page.getByRole("button", { name: "Pause clock" });
if (await pause.isVisible()) await pause.click();
// The turn opens on its ready step; start it, then pause, so typing does not restart the clock.
await page.getByRole("button", { name: "Start speaking" }).click();
await page.getByRole("button", { name: "Pause clock" }).click();
await expect(page.getByRole("button", { name: "Resume clock" })).toBeVisible();

const saves = await holdSave(page, (progress) => progress.draft === "Coding");
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ The student's speech never reaches DebateLab as audio. [`src/hooks/use-speech-ca

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.

[`src/lib/voice/opponent-voice.ts`](../src/lib/voice/opponent-voice.ts) plays a reply segment by segment through the [`voice`](../supabase/functions/voice/index.ts) Edge Function, fetching one segment ahead and decoding with Web Audio. The function authenticates the caller, verifies the token so it speaks only text the server wrote, and returns `voice_unavailable` when `ELEVENLABS_API_KEY` is not set, before it reserves anything. It then checks the account, reserves one clip from the per-user daily voice cap through `consume_daily_quota` under the `voice` key (60 clips for paid access, 12 on a trial, overridable with a `daily_voice_clips` row in `rate_limits`), calls ElevenLabs (`eleven_v4` for Sparring, `eleven_v4_turbo` for Crossfire), and logs an `ai_requests` row with the billed characters. Every attempt keeps its clip, including one ElevenLabs refuses, so replayed tokens cannot reach the provider without limit while it fails. Whenever a segment cannot be had, because the key is missing, the cap is reached, or the call fails, the browser's `speechSynthesis` reads the rest of the reply from the clean text and the round continues. After any refusal from the function other than an invalid token, the browser stops asking for the rest of that round and tries ElevenLabs again when the next round starts; a network error is retried on the next reply. In Sparring the student's clock waits while the opponent speaks. Starting to speak stops the opponent's audio.
[`src/lib/voice/opponent-voice.ts`](../src/lib/voice/opponent-voice.ts) plays a reply segment by segment through the [`voice`](../supabase/functions/voice/index.ts) Edge Function, fetching one segment ahead and decoding with Web Audio. The function authenticates the caller, verifies the token so it speaks only text the server wrote, and returns `voice_unavailable` when `ELEVENLABS_API_KEY` is not set, before it reserves anything. It then checks the account, reserves one clip from the per-user daily voice cap through `consume_daily_quota` under the `voice` key (60 clips for paid access, 12 on a trial, overridable with a `daily_voice_clips` row in `rate_limits`), calls ElevenLabs (`eleven_v4` for Sparring, `eleven_v4_turbo` for Crossfire), and logs an `ai_requests` row with the billed characters. Every attempt keeps its clip, including one ElevenLabs refuses, so replayed tokens cannot reach the provider without limit while it fails. Whenever a segment cannot be had, because the key is missing, the cap is reached, or the call fails, the browser's `speechSynthesis` reads the rest of the reply from the clean text and the round continues. After any refusal from the function other than an invalid token, the browser stops asking for the rest of that round and tries ElevenLabs again when the next round starts; a network error is retried on the next reply. In Sparring each of the student's turns opens with a stopped clock, so it waits while the opponent speaks; it runs once the student presses `Start speaking`, starts the microphone, or starts typing. Starting to speak stops the opponent's audio.

[`vercel.json`](../vercel.json) allows the microphone for the app's own origin only (`microphone=(self)`); dictation and voice rounds both depend on it.

Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/ai/schemas/sparring.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from 'zod';
import { SpeechDeliverySchema } from '../../voice/delivery';
import { MAX_MOTION_LENGTH } from '../../validation/motion';

export const SparringSideSchema = z.enum(['pro', 'con']);
export const SparringFormatSchema = z.enum(['standard', 'ld', 'pf']);
Expand Down Expand Up @@ -44,7 +45,7 @@ export const SparringSpeechSchema = z.object({
/** 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),
motion: z.string().trim().min(1).max(MAX_MOTION_LENGTH),
userSide: SparringSideSchema,
format: SparringFormatSchema,
difficulty: SparringDifficultySchema,
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/validation/motion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** The longest motion a debate, Sparring round, or tool form accepts. */
export const MAX_MOTION_LENGTH = 1200;
10 changes: 9 additions & 1 deletion src/components/features/brainstorm/BrainstormResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { toast } from 'sonner';
import { EmptyState } from '@/components/ui/empty-state';
import { ExportToolbar } from '@/components/features/ExportToolbar';
import { ResultHeader } from '@/components/features/tools/ResultHeader';
import { CopyResultButton, SaveResultAction } from '@/components/features/tools/result-actions';
import { brainstormText } from '@/components/features/tools/result-text';
import { ResearchedSources } from '@/components/features/tools/ResearchedSources';
import { GoogleDriveAuthModal } from '@/components/features/export/GoogleDriveAuthModal';
import { SourceQualityList } from '@/components/features/shared/SourceQualityList';
Expand All @@ -20,6 +22,10 @@ interface BrainstormResultsProps {
result: BrainstormOutput;
discoveredSources?: DiscoveredSource[];
onDiscuss?: () => void;
onSave?: () => void;
/** The result is already in History; shows “Saved” instead of Save. */
saved?: boolean;
saving?: boolean;
}

const strengthColors = {
Expand Down Expand Up @@ -65,7 +71,7 @@ function IdeaCard({ idea }: { idea: Idea }) {
);
}

export function BrainstormResults({ result, discoveredSources, onDiscuss }: BrainstormResultsProps) {
export function BrainstormResults({ result, discoveredSources, onDiscuss, onSave, saved = false, saving = false }: BrainstormResultsProps) {
const { t } = useTranslation();
const [isExportingGDocs, setIsExportingGDocs] = useState(false);
const [showDriveAuth, setShowDriveAuth] = useState(false);
Expand Down Expand Up @@ -155,6 +161,8 @@ export function BrainstormResults({ result, discoveredSources, onDiscuss }: Brai
isExportingGDocs={isExportingGDocs}
onDiscuss={onDiscuss}
discussLabel={t('brainstormResults.discuss')}
extraExports={<CopyResultButton getText={() => brainstormText(result, t)} />}
secondaryActions={onSave || saved ? <SaveResultAction saved={saved} onSave={onSave} disabled={saving} /> : undefined}
/>
)}
/>
Expand Down
21 changes: 11 additions & 10 deletions src/components/features/debate/DebateContextControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,23 +60,27 @@ export function DebateContextControl({ feature, side, includeSources = true, deb

const summary = useMemo(() => {
if (!plan) return null;
const sentSources = plan.sources.filter((source) => source.included && !source.overLimit).length;
const sentOutputs = plan.outputs.filter((output) => output.included && !output.overLimit && !output.empty);
// "Sent" means it reaches the model; anything chosen but over the limit or too large did not fit.
const sent = (item: { included: boolean; overLimit: boolean; omittedForSize: boolean }) => item.included && !item.overLimit && !item.omittedForSize;
const chosenSources = plan.sources.filter((source) => source.included).length;
const sentSources = plan.sources.filter(sent).length;
const chosenOutputs = plan.outputs.filter((output) => output.included && !output.empty);
const sentOutputs = chosenOutputs.filter(sent);
const count = (key: 'sourceCount' | 'resultCount', used: number, chosen: number) => used === chosen
? t(`debateContext.${key}`, { count: used })
: `${t(`debateContext.${key}Of`, { used, count: chosen })} ${t('debateContext.didntFit', { count: chosen - used })}`;
const parts = [
sentSources > 0 && t('debateContext.sourceCount', { count: sentSources }),
sentOutputs.length > 0 && t('debateContext.resultCount', { count: sentOutputs.length }),
chosenSources > 0 && count('sourceCount', sentSources, chosenSources),
chosenOutputs.length > 0 && count('resultCount', sentOutputs.length, chosenOutputs.length),
].filter((part): part is string => Boolean(part));
const names = sentOutputs.map((output) => output.side === 'pro' || output.side === 'con'
? t('debateContext.resultName', { kind: t(`debate.kinds.${output.ref.kind}`), side: t(`debate.side.${output.side}`) })
: t(`debate.kinds.${output.ref.kind}`));
const leftOut = plan.sources.filter((source) => source.omittedForSize || source.overLimit).length
+ plan.outputs.filter((output) => output.omittedForSize || output.overLimit).length;
return {
text: parts.length > 0
? t('debateContext.using', { list: listFormat(i18n.resolvedLanguage ?? 'en', parts) })
: t('debateContext.usingMotionOnly'),
names: names.join(', '),
leftOut,
};
}, [i18n.resolvedLanguage, plan, t]);

Expand All @@ -98,9 +102,6 @@ export function DebateContextControl({ feature, side, includeSources = true, deb
<p className="min-w-0 flex-1 basis-56">
<span className="font-medium text-foreground">{summary.text}</span>
{summary.names && <span className="text-muted-foreground">: {summary.names}</span>}
{summary.leftOut > 0 && (
<span className="block text-xs text-muted-foreground">{t('debateContext.leftOut', { count: summary.leftOut })}</span>
)}
</p>
<Button type="button" size="sm" variant="ghost" onClick={() => setOpen(true)} aria-haspopup="dialog">
{t('debateContext.review', 'Review')}
Expand Down
2 changes: 2 additions & 0 deletions src/components/features/debate/DebateDialogs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ export function DebateDialogs() {
idPrefix="new-debate"
initial={{ motion: dialogMotion }}
submitLabel={t('debate.startDebate', 'Start debate')}
pendingLabel={t('debate.creating', 'Creating…')}
onCancel={close}
onSubmit={async (values) => {
await create(values);
Expand Down Expand Up @@ -238,6 +239,7 @@ export function InactiveDebateGate() {
<DebateForm
idPrefix="gate-debate"
submitLabel={t('debate.startDebate', 'Start debate')}
pendingLabel={t('debate.creating', 'Creating…')}
onCancel={() => setMode('ask')}
onSubmit={async (values) => {
await create(values);
Expand Down
24 changes: 20 additions & 4 deletions src/components/features/debate/DebateForm.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { useState, type FormEvent } from 'react';
import { useRef, useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { MAX_INSTRUCTIONS_LENGTH, MAX_MOTION_LENGTH, type DebateSettings } from '@/lib/stores/debate-store';
import { formatNumber } from '@/lib/date-format';

export interface DebateFormValues {
motion: string;
Expand All @@ -25,20 +27,24 @@ const ROLE_LABEL_KEYS: Record<(typeof ROLES)[number], string> = {
interface DebateFormProps {
initial?: Partial<DebateFormValues>;
submitLabel: string;
/** Shown on the submit button while `onSubmit` runs, such as “Creating…”. */
pendingLabel?: string;
onSubmit: (values: DebateFormValues) => Promise<void>;
onCancel?: () => void;
/** Motion is fixed when editing only settings. */
showDetails?: boolean;
idPrefix?: string;
}

export function DebateForm({ initial, submitLabel, onSubmit, onCancel, showDetails = true, idPrefix = 'debate' }: DebateFormProps) {
export function DebateForm({ initial, submitLabel, pendingLabel, onSubmit, onCancel, showDetails = true, idPrefix = 'debate' }: DebateFormProps) {
const { t } = useTranslation();
const [motion, setMotion] = useState(initial?.motion ?? '');
const [format, setFormat] = useState(initial?.settings?.format ?? 'custom');
const [role, setRole] = useState(initial?.settings?.role ?? 'general');
const [instructions, setInstructions] = useState(initial?.instructions ?? '');
const [saving, setSaving] = useState(false);
// One click is one create: a second submit before the button re-renders disabled is ignored.
const savingRef = useRef(false);
const [error, setError] = useState('');

const submit = async (event: FormEvent) => {
Expand All @@ -47,13 +53,16 @@ export function DebateForm({ initial, submitLabel, onSubmit, onCancel, showDetai
setError(t('debate.motionRequired', 'Enter the motion you are preparing.'));
return;
}
if (savingRef.current) return;
savingRef.current = true;
setSaving(true);
setError('');
try {
await onSubmit({ motion: motion.trim(), settings: { format, role }, instructions: instructions.trim() });
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : t('debate.saveFailed', 'Could not save the debate.'));
} finally {
savingRef.current = false;
setSaving(false);
}
};
Expand All @@ -70,7 +79,11 @@ export function DebateForm({ initial, submitLabel, onSubmit, onCancel, showDetai
rows={3}
placeholder={t('debate.motionPlaceholder', 'This House would…')}
autoFocus
aria-describedby={`${idPrefix}-motion-count`}
/>
<p id={`${idPrefix}-motion-count`} className="text-right text-xs tabular-nums text-muted-foreground">
{formatNumber(motion.length)}/{formatNumber(MAX_MOTION_LENGTH)}
</p>
</div>
{showDetails && (
<>
Expand Down Expand Up @@ -108,14 +121,17 @@ export function DebateForm({ initial, submitLabel, onSubmit, onCancel, showDetai
rows={3}
placeholder={t('debate.instructionsPlaceholder', 'Roles, judge expectations, agreed definitions, school conventions, writing preferences…')}
/>
<p className="text-right text-xs tabular-nums text-muted-foreground">{instructions.length}/{MAX_INSTRUCTIONS_LENGTH}</p>
<p className="text-right text-xs tabular-nums text-muted-foreground">{formatNumber(instructions.length)}/{formatNumber(MAX_INSTRUCTIONS_LENGTH)}</p>
</div>
</>
)}
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
<div className="flex flex-wrap justify-end gap-2">
{onCancel && <Button type="button" variant="outline" onClick={onCancel}>{t('common.cancel', 'Cancel')}</Button>}
<Button type="submit" disabled={saving || !motion.trim()}>{submitLabel}</Button>
<Button type="submit" disabled={saving || !motion.trim()} aria-busy={saving}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />}
{saving ? pendingLabel ?? submitLabel : submitLabel}
</Button>
</div>
</form>
);
Expand Down
Loading
Loading