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 .superset/db-gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ for url in "$mailer_url/api/v1/messages" "$app_origin" "http://127.0.0.1:18090/_
done
curl --fail --silent --output /dev/null "$url"
done
for function_name in workspace-invites ai ai-stream demo-code-login admin announcements account; do
for function_name in workspace-invites ai ai-stream voice demo-code-login admin announcements account; do
function_url="$api_url/functions/v1/$function_name"
for _ in $(seq 1 200); do
if curl --fail --silent --output /dev/null --request OPTIONS --header "Origin: $app_origin" "$function_url"; then break; fi
Expand Down
16 changes: 14 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Collaborative Workspace (`/app/workspaces/*`, the `workspace-invites` function,

[`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 `<html lang>`. 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. 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.
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.

## Supabase environments and sessions

Expand All @@ -64,6 +64,18 @@ Client-side environment selection is a developer convenience, not authorization.

For a validated Crossfire next turn, the server returns the reply first with a short-lived signed sidecar token. The client then requests a Jev classification of only the newest completed question-answer exchange. The token is atomically claimed through its own one-use counter, separate from the product's daily AI quota. The optional directness, concession, and follow-up signal is attached by turn ID to the answer; a missing, slow, replayed, forged, or failed sidecar never delays or invalidates the main reply. Draft version 1 remains compatible because turn signals are optional.

### Voice rounds

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.

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.

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

A configured mock provider bypasses Supabase for local and test behavior. It is not authenticated backend evidence.

## Research and source import
Expand Down Expand Up @@ -112,7 +124,7 @@ Supabase Auth owns user sessions. Protected routes provide a client navigation b

Checkout and portal functions create Stripe sessions after authenticated ownership and redirect validation. The webhook updates subscription state and records event processing for idempotency. AI handlers record the provider-reported usage and charge for each request.

All model requests use the single OpenRouter backend route and `OPENROUTER_API_KEY`. The shared ten-model catalog, Luna Basic default, Pro-only entries, and request controls live in [`supabase/functions/_shared/model-catalog.ts`](../supabase/functions/_shared/model-catalog.ts). OpenRouter usage remains raw in the audit row, and the reported charge is unknown when the provider does not return a complete cost.
All model requests use the single OpenRouter backend route and `OPENROUTER_API_KEY`. The shared ten-model catalog, Luna Basic default, Pro-only entries, and request controls live in [`supabase/functions/_shared/model-catalog.ts`](../supabase/functions/_shared/model-catalog.ts). OpenRouter usage remains raw in the audit row, and the reported charge is unknown when the provider does not return a complete cost. The opponent's voice is the one exception to the OpenRouter route: the `voice` function calls ElevenLabs with `ELEVENLABS_API_KEY`. ElevenLabs reports billed characters, not dollars, so [`supabase/functions/_shared/cost.ts`](../supabase/functions/_shared/cost.ts) prices those rows from a dated list-price snapshot; promotions and plan credits are not modelled, so a voice row can overstate the charge.

`Auto Fast` and `Auto Pro` are virtual selections, not provider model IDs. The Edge Functions resolve them through TypeSafe Jev on OpenRouter before calling the concrete writing model. Auto Fast is available to Basic accounts and normally resolves to GLM 5.3 Flash; manual GLM selection keeps its Pro restriction. Auto Pro remains Pro-only. Provider calls, usage rows, and response metadata record the concrete resolved model, while response metadata also retains the requested automatic selection. A Jev router call uses the same OpenRouter key and does not consume a second daily quota unit.

Expand Down
5 changes: 4 additions & 1 deletion docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Vercel builds and serves the frontend from the Git repository. Merging to `main`
- SPA fallback to `index.html`;
- no-cache headers for `index.html`, `theme-init.js`, and `sw.js`, and immutable caching for hashed `/assets/`;
- `noindex` headers for `/app`, `/login`, and `/share` routes;
- Content Security Policy and the other response security headers.
- Content Security Policy and the other response security headers, including the `Permissions-Policy` that allows the microphone for the app's own origin only.

[`src/test/vercel-deployment-config.test.ts`](../src/test/vercel-deployment-config.test.ts) guards these rules. Update it together with `vercel.json`, and do not paste the full configuration into another guide.

Expand Down Expand Up @@ -139,6 +139,8 @@ Auth patterns include:

A successful deploy command proves upload only. Verify the behavior through the real endpoint or protected staging flow.

Voice rounds span three independent steps, and the frontend works at every stage of them. The frontend alone (a merge) offers voice rounds that speak with the browser's voice. Deploying `ai` adds audio tags and signed segments to voice replies; deploying `voice` adds the endpoint, which answers `voice_unavailable` until `ELEVENLABS_API_KEY` is set. Deploy `ai` and `voice` together, since they share the token code. Once the key is set, check one Sparring and one Crossfire reply in a real browser, and confirm `voice_sparring` and `voice_crossfire` rows with a cost in `ai_requests`. Removing the key returns every round to the browser voice without a deploy.

## CORS

Current functions mostly use local explicit origin allowlists. The exact list can differ by endpoint. Treat handler code as the source of truth.
Expand All @@ -153,6 +155,7 @@ Secret names may be documented; values may not. Server-side categories include:

- Supabase service credentials;
- AI provider credentials;
- the ElevenLabs credential for the opponent's voice (`ELEVENLABS_API_KEY`);
- research provider credentials;
- Stripe API and webhook credentials;
- demo-code hashing material;
Expand Down
5 changes: 5 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ A browser-oriented Vitest pass does not replace native Deno tests. The stream ac

Current Debate context has focused Deno tests: `_shared/debate-context.test.ts` covers selection rules, the IDs-only request parser, digest budgets, per-feature policy, and loader isolation against real PostgREST query shapes; `ai/debate-context.test.ts` drives the `ai` and `ai-stream` handlers with forged debates, cross-debate IDs, a retired custom prompt, and stored text that tries to close its block.

Voice has its own Deno tests. `voice/index_test.ts` drives the `voice` handler against a fake ElevenLabs: a signed segment becomes audio and one costed usage row; a missing key, an invalid or expired token, edited text, another user's token, a reached cap, an expired or banned account, and a provider failure each return their code without reaching paid work they should not; every provider call keeps its clip however it ends, and an oversized body is refused without being read. `ai/voice.test.ts` drives the `ai` handler and checks that tags reach only the voice payload, that typed requests and the typed prompt are unchanged, and that measured delivery facts reach the judge and the saved round. `_shared/voice-tags.test.ts`, `_shared/voice-token.test.ts`, and `_shared/cost.test.ts` cover tag stripping and rationing, token binding, and character pricing. The permission list includes `ELEVENLABS_API_KEY` because the handler reads it; no real key is used.

`_shared/prompts/prompt-examples.test.ts` checks the two representative examples per feature in [`supabase/functions/_shared/prompts/prompt-examples.ts`](../supabase/functions/_shared/prompts/prompt-examples.ts) against each feature's quality rules and the fake provider's routing lines.

### Prompt example comparison
Expand Down Expand Up @@ -124,6 +126,8 @@ The server is never reused. CI uses retries and two workers. On failure, traces,

The speech unit test proves that Hungarian selects `recognition.lang = 'hu-HU'`. Credential-free browser automation has no real microphone or speech service, so it does not prove recognition accuracy or hardware support.

Voice rounds are tested without a microphone, a speech service, or an ElevenLabs key. `src/test/voice/` covers the capture hook against a scripted recogniser (interim words, restart on silence, last words after stop, pause timing, refused microphone), the delivery measurements in all three languages, and the opponent voice player against a scripted Web Audio and `speechSynthesis` (segment order, every fallback path, stop, and blocked autoplay). `src/test/pages/SparringVoice.test.tsx` and `CrossfireVoice.test.tsx` drive both pages through a spoken turn. `e2e/voice-rounds.spec.ts` does the same in Chromium with the scripted recogniser and `speechSynthesis` from `e2e/helpers/voice.ts`, once with the `voice` route returning audio and once with it returning `voice_unavailable`, and asserts that no audio tag reaches the page or storage. None of this proves how ElevenLabs sounds, that a real browser recogniser hears a student, or that the live `voice` function is deployed with a key.

AI client and Edge tests prove that every request path carries a validated language and that prompts require Hungarian output. Provider responses are mocked; only an authorized protected-staging run can prove that a live provider follows the instruction across every prose field.

## Full credential-free gate
Expand Down Expand Up @@ -182,6 +186,7 @@ Each test signs in through the Login page with a fresh demo code and deletes tha
- a fresh account's first run in English and Hungarian: the tour starts the first debate, the language picked before sign-up survives the first sign-in, and a tool offers a debate from the typed motion when none is current;
- Current Debate context: every request carries the debate's motion, instructions, and sources; Review drops a source and pins a saved case for exactly one request; the pin is stored; Crossfire leaves instructions out; and one-off work sends nothing;
- a judged Lincoln-Douglas sparring round and a Crossfire debrief after a Jev signal attaches to an answer;
- spoken Sparring and Crossfire rounds against the real `ai` and `voice` functions with no ElevenLabs key: the opponent prompt carries the voice rules, the reply comes back clean with signed segments, `voice` answers `voice_unavailable`, the browser voice reads the clean text, the judge gets the measured facts, and the saved round holds no audio tag (the recogniser and the browser voice are scripted);
- Library activation and deletion, output-language sync across devices, flowchart saves, and Delete All My Data (it must leave another account's rows intact);
- Download my data (only the caller's rows, even when the request names another user) and Delete account (no row in any `public` or `private` table mentions the user afterwards, another account is untouched, and a call without a session or with another user's confirmation deletes nothing);
- a promo code redeemed in Settings that restores AI access, and signed Stripe webhooks that grant and revoke Pro while a forged or replayed one changes nothing;
Expand Down
15 changes: 10 additions & 5 deletions e2e/full-stack/fake-openrouter.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,9 @@ const ask = () => ({
uncertainties: [],
});

const sparring = (user) => /^SPARRING ACTION: score/m.test(user)
// In a voice round the prompt allows audio tags. The fake uses one allowed tag
// and one invented tag, so the journey can prove neither reaches the page.
const sparring = (user, system) => /^SPARRING ACTION: score/m.test(user)
? {
action: "score",
scorecard: {
Expand All @@ -129,7 +131,9 @@ const sparring = (user) => /^SPARRING ACTION: score/m.test(user)
: {
action: "respond",
speech: {
content: `${MARK} opponent speech: the policy needs a stronger comparative case.`,
content: system.includes("SPOKEN ROUND:")
? `[sighs] ${MARK} opponent speech: the policy needs a stronger comparative case. [dramatic gasp] It is not there yet.`
: `${MARK} opponent speech: the policy needs a stronger comparative case.`,
strategy: "Test the warrant, then weigh the alternative.",
targetedPoints: ["The comparative mechanism is underdeveloped."],
},
Expand All @@ -149,7 +153,8 @@ const crossfire = (user) => {
}
const type = /must be type "(question|answer)"/.exec(user)?.[1];
if (!type) return null;
return { type, text: `${MARK} crossfire ${type}: what evidence links fewer cars to safer streets?` };
const tag = user.includes("SPOKEN EXCHANGE:") ? "[scoffs] " : "";
return { type, text: `${tag}${MARK} crossfire ${type}: what evidence links fewer cars to safer streets?` };
};

// Order matters only where one marker could appear inside another prompt.
Expand All @@ -160,7 +165,7 @@ const routes = [
["attack", "Analyze the opponent's case clearly", attack],
["brainstorm", "Brainstorm arguments like experienced competitive debaters", brainstorm],
["factoids", "You are a debate research specialist", factoids],
["sparring", "live debate sparring opponent", (user) => sparring(user)],
["sparring", "live debate sparring opponent", (user, system) => sparring(user, system)],
["crossfire", "You are DebateLab's crossfire coach", (user) => crossfire(user)],
["ask", "You are a debate coach chat bot", ask],
];
Expand All @@ -174,7 +179,7 @@ function classify(body) {
}
const route = routes.find(([, marker]) => system.includes(marker));
if (!route) return { feature: null, system, user };
return { feature: route[0], system, user, output: route[2](user) };
return { feature: route[0], system, user, output: route[2](user, system) };
}

function jevAnswers(questions) {
Expand Down
Loading
Loading