From 3248a46cd529fdd914aa88d64211e52ef8fe1359 Mon Sep 17 00:00:00 2001 From: Abid Hasan Date: Tue, 27 Oct 2026 20:08:39 +0600 Subject: [PATCH] WP-07: Voice onboarding + settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Onboarding step 2 (M1, MK-2, U3): paste 1-3 past show notes (>=150 words, word counters, + add another), Learn my voice -> chips/sliders/segmented editor with preview, Skip for now -> §7.4 default via read-time resolution. Settings -> Voice (M9, MK-8): chips + sliders + Never say / Always include + samples list; preview card with Update preview. Routes (§7.5): POST /api/show/voice/learn and /api/show/voice/preview via lib/show/voice.ts; persistence through the @/db/queries boundary (shows.voice_profile / voice_samples). Deterministic no-AI learn/preview added at the lib/ai boundary (labelled 'Basic', never passed off as AI); learn/preview provider timeout capped at 12 s for the U3 <=15 s budget. Tests: 28 unit (default byte-for-byte, deterministic path, word-count validation, boundary-mocked route behaviour), 8 e2e (learn flow, skip, tone chip gating, settings preview + keyless save copy). Fixture tests/fixtures/voice-sample.txt is synthetic productivity-podcast text. --- docs/relaunch/DECISIONS.md | 5 + web/app/(app)/app/onboarding/voice/page.tsx | 30 +- web/app/(app)/app/settings/voice/page.tsx | 22 +- web/app/api/show/voice/learn/route.ts | 14 + web/app/api/show/voice/preview/route.ts | 14 + web/components/app/onboarding/voice-form.tsx | 228 ++++++++++++ web/components/app/settings/voice-form.tsx | 186 ++++++++++ web/components/app/voice/chip-row.tsx | 147 ++++++++ web/components/app/voice/preview-card.tsx | 111 ++++++ web/components/app/voice/profile-editor.tsx | 129 +++++++ web/db/queries.ts | 3 + web/lib/ai/voice.ts | 86 +++++ web/lib/copy.ts | 16 + web/lib/show/input.ts | 50 ++- web/lib/show/service.ts | 2 + web/lib/show/voice.ts | 162 +++++++++ web/lib/words.ts | 10 + web/tests/e2e/onboarding.spec.ts | 6 +- web/tests/e2e/settings.spec.ts | 4 +- web/tests/e2e/voice.spec.ts | 138 ++++++++ web/tests/fixtures/voice-sample.txt | 5 + web/tests/unit/show-voice.test.ts | 348 +++++++++++++++++++ 22 files changed, 1685 insertions(+), 31 deletions(-) create mode 100644 web/app/api/show/voice/learn/route.ts create mode 100644 web/app/api/show/voice/preview/route.ts create mode 100644 web/components/app/onboarding/voice-form.tsx create mode 100644 web/components/app/settings/voice-form.tsx create mode 100644 web/components/app/voice/chip-row.tsx create mode 100644 web/components/app/voice/preview-card.tsx create mode 100644 web/components/app/voice/profile-editor.tsx create mode 100644 web/lib/show/voice.ts create mode 100644 web/lib/words.ts create mode 100644 web/tests/e2e/voice.spec.ts create mode 100644 web/tests/fixtures/voice-sample.txt create mode 100644 web/tests/unit/show-voice.test.ts diff --git a/docs/relaunch/DECISIONS.md b/docs/relaunch/DECISIONS.md index 91fd4a5..9071b8a 100644 --- a/docs/relaunch/DECISIONS.md +++ b/docs/relaunch/DECISIONS.md @@ -3,6 +3,11 @@ Agents append one entry per tie-break decision (01-product.md §0.4). Newest first. Format: `YYYY-MM-DD · WP-XX · decision · why · alternatives considered`. +- 2026-10-27 · WP-07 · deterministic voice learn/preview added at the lib/ai boundary (`learnVoiceProfileDeterministic`/`previewVoiceDeterministic` in `lib/ai/voice.ts`): learn tries the §8.1 provider chain first, and only when every provider fails derives the profile extractively from the pasted samples (person from first-person pronouns, sentence length from average words/sentence, emoji from pictograph counts); fields a lexicon-free analyzer cannot honestly infer (tone, formality/energy/humor, signature phrases, ctaStyle) stay at the §7.4 defaults, and both the learn response (`status:"deterministic"`) and the UI ("Basic profile/version — our AI writer was unavailable.") label the result · U3's ≤15 s budget must hold with no usable AI keys; hard rule: never fake a learned/AI profile when no AI ran — labelled basic, not silent default · returning the plain §7.4 default with no label; forking WP-04's learn loop +- 2026-10-27 · WP-07 · learn/preview provider calls cap the timeout at 12 s (`VOICE_TIMEOUT_MS`) instead of §8.1's 60 s · both are interactive (a user is waiting on step 2 / the preview card) and U3 budgets 15 s end-to-end; 60 s targets background generation · keeping the 60 s default +- 2026-10-27 · WP-07 · Skip never writes anything: a null `shows.voice_profile` resolves to the §7.4 default at read time via `resolveShowVoiceProfile()` (unit-tested field-for-field) · one source of truth for the default, and nothing false is persisted after Skip · writing the default profile on Skip +- 2026-10-27 · WP-07 · tone stays exactly 3 chips (§7.4 `tone[3]`): removing a chip disables Continue/Save voice until a replacement is added ("Add a tone word.") · the WP-04 schema fixes the length and silently padding would fabricate a value · relaxing the schema to 1–3 +- 2026-10-27 · WP-07 · learned samples persist to `shows.voice_samples` as raw pasted text; the MK-8 samples list therefore shows a text snippet + word count instead of MK-8's user labels ("Ep 39 show notes") · §7.1 has no label column and adding one is out of MVP scope · adding a labels schema field - 2026-10-27 · WP-04 · WP-02 integration landed in this branch via merge: `resolveAppSettingsStore()` now returns a real Drizzle store over `app_settings` (WP-02 schema), null only when `DATABASE_URL` is unset (keyless preview); `requireOwner` uses `lib/env.isClerkConfigured()` for clean 503-vs-401 classification and still denies keyless previews (admin AI switching must never work where auth is fake) · completes the documented WP-02 seams instead of leaving them stubbed; U18 now exercises the real store path in tests via the same interface · keeping the null stub until WP-09 - 2026-10-27 · WP-04 · Groq fallback models: `openai/gpt-oss-120b` (long) / `openai/gpt-oss-20b` (fast) · §8.1 requires the choice to be made from Groq's current model list at build time; console.groq.com/docs/models (2026-10-27) lists the llama-3.3/3.1 models as Enterprise ("contact sales") while gpt-oss-120b/20b are self-serve with public prices ($0.15/$0.60 and $0.075/$0.30 per M) and 131k context · llama-3.3-70b-versatile long + llama-3.1-8b-instant fast diff --git a/web/app/(app)/app/onboarding/voice/page.tsx b/web/app/(app)/app/onboarding/voice/page.tsx index 93f92a4..19368fa 100644 --- a/web/app/(app)/app/onboarding/voice/page.tsx +++ b/web/app/(app)/app/onboarding/voice/page.tsx @@ -1,26 +1,22 @@ -import Link from "next/link"; +import { redirect } from "next/navigation"; +import { VoiceOnboardingForm } from "@/components/app/onboarding/voice-form"; import { StepDots } from "@/components/app/step-dots"; +import { requireUser } from "@/lib/auth"; +import { isDbConfigured } from "@/lib/env"; +import { getShowByOwnerSafe } from "@/lib/show/queries-safe"; -export const metadata = { title: "Onboarding · EpisodeKit" }; +export const metadata = { title: "Teach EpisodeKit your voice · EpisodeKit" }; -/** - * Step 2 placeholder — the voice-learning UI ships in WP-07 (U3). - * The route exists so the onboarding flow is complete end to end. - */ -export default function OnboardingVoicePage() { +/** Onboarding step 2 (M1): voice samples → profile (U3, MK-2). */ +export default async function OnboardingVoicePage() { + const userId = await requireUser(); + const show = await getShowByOwnerSafe(userId); + if (!show && isDbConfigured()) redirect("/app/onboarding/show"); return (
-
-

Teach EpisodeKit your voice

-

This page is coming soon.

- - Skip for now - +
+
); diff --git a/web/app/(app)/app/settings/voice/page.tsx b/web/app/(app)/app/settings/voice/page.tsx index 2a1166d..5ab0a76 100644 --- a/web/app/(app)/app/settings/voice/page.tsx +++ b/web/app/(app)/app/settings/voice/page.tsx @@ -1,11 +1,21 @@ +import { redirect } from "next/navigation"; +import { VoiceSettingsForm } from "@/components/app/settings/voice-form"; +import { requireUser } from "@/lib/auth"; +import { isDbConfigured } from "@/lib/env"; +import { getShowByOwnerSafe } from "@/lib/show/queries-safe"; +import { resolveShowVoiceProfile } from "@/lib/show/voice"; + export const metadata = { title: "Settings · EpisodeKit" }; -/** Voice settings ship in WP-07 (M9 Voice, MK-8). */ -export default function SettingsVoicePage() { +/** Settings → Voice (M9, MK-8): §7.4 profile editor, samples, preview. */ +export default async function SettingsVoicePage() { + const userId = await requireUser(); + const show = await getShowByOwnerSafe(userId); + if (!show && isDbConfigured()) redirect("/app/onboarding/voice"); return ( -
-

Voice

-

This page is coming soon.

-
+ ); } diff --git a/web/app/api/show/voice/learn/route.ts b/web/app/api/show/voice/learn/route.ts new file mode 100644 index 0000000..bfd7c16 --- /dev/null +++ b/web/app/api/show/voice/learn/route.ts @@ -0,0 +1,14 @@ +// §7.5 POST /api/show/voice/learn — onboarding step 2 (U3). +import { getAuthUserId } from "@/lib/auth"; +import { AUTH_REQUIRED_COPY } from "@/lib/copy"; +import { handleVoiceLearn } from "@/lib/show/voice"; + +export async function POST(req: Request) { + const userId = await getAuthUserId(); + if (!userId) { + return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 }); + } + const body: unknown = await req.json().catch(() => null); + const result = await handleVoiceLearn(userId, body); + return Response.json(result.json, { status: result.status }); +} diff --git a/web/app/api/show/voice/preview/route.ts b/web/app/api/show/voice/preview/route.ts new file mode 100644 index 0000000..ff5bc39 --- /dev/null +++ b/web/app/api/show/voice/preview/route.ts @@ -0,0 +1,14 @@ +// §7.5 POST /api/show/voice/preview — Settings → Voice preview (M9/MK-8). +import { getAuthUserId } from "@/lib/auth"; +import { AUTH_REQUIRED_COPY } from "@/lib/copy"; +import { handleVoicePreview } from "@/lib/show/voice"; + +export async function POST(req: Request) { + const userId = await getAuthUserId(); + if (!userId) { + return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 }); + } + const body: unknown = await req.json().catch(() => null); + const result = await handleVoicePreview(userId, body); + return Response.json(result.json, { status: result.status }); +} diff --git a/web/components/app/onboarding/voice-form.tsx b/web/components/app/onboarding/voice-form.tsx new file mode 100644 index 0000000..bbc1e5a --- /dev/null +++ b/web/components/app/onboarding/voice-form.tsx @@ -0,0 +1,228 @@ +"use client"; + +// Onboarding step 2 (M1, MK-2, U3): paste 1–3 past show notes (≥150 words +// each), "Learn my voice" → chips/sliders, or "Skip for now" → the §7.4 +// default stays (null profile). The deterministic no-AI answer is labelled; +// keyless previews say saving is unavailable instead of faking a save. +import { useRouter } from "next/navigation"; +import { useMemo, useState } from "react"; +import type { VoiceProfile } from "@/lib/ai/voice"; +import { + SAVE_UNAVAILABLE_COPY, + SAMPLE_TOO_SHORT_COPY, + VOICE_BASIC_PROFILE_COPY, +} from "@/lib/copy"; +import { MIN_SAMPLE_WORDS, countWords } from "@/lib/words"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { profileComplete, VoiceProfileEditor } from "@/components/app/voice/profile-editor"; +import { VoicePreviewCard } from "@/components/app/voice/preview-card"; + +type LearnResponse = { + status?: "learned" | "deterministic"; + profile?: VoiceProfile; + saved?: boolean; + error?: string; +}; + +export function VoiceOnboardingForm() { + const router = useRouter(); + const [samples, setSamples] = useState([""]); + const [phase, setPhase] = useState<"collect" | "learned">("collect"); + const [profile, setProfile] = useState(null); + const [learnSaved, setLearnSaved] = useState(false); + const [deterministic, setDeterministic] = useState(false); + const [learning, setLearning] = useState(false); + const [continuing, setContinuing] = useState(false); + const [error, setError] = useState(null); + + const filled = useMemo( + () => samples.map((s) => s.trim()).filter((s) => s.length > 0), + [samples], + ); + const canLearn = filled.length > 0 && filled.every((s) => countWords(s) >= MIN_SAMPLE_WORDS); + + function updateSample(index: number, value: string) { + setSamples((current) => current.map((s, i) => (i === index ? value : s))); + } + + async function learn() { + if (learning || !canLearn) return; + setError(null); + setLearning(true); + try { + const res = await fetch("/api/show/voice/learn", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ samples: filled }), + }); + const data = (await res.json().catch(() => ({}))) as LearnResponse; + if (!res.ok || !data.profile) { + setError(data.error ?? "Something went wrong. Try again."); + return; + } + setProfile(data.profile); + setLearnSaved(data.saved === true); + setDeterministic(data.status === "deterministic"); + setPhase("learned"); + } catch { + setError("Something went wrong. Try again."); + } finally { + setLearning(false); + } + } + + async function continueToFirstEpisode() { + if (!profile || continuing) return; + setContinuing(true); + try { + if (learnSaved) { + const res = await fetch("/api/show", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ voiceProfile: profile }), + }); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { error?: string }; + if (res.status === 503) { + setError(SAVE_UNAVAILABLE_COPY); + } else { + setError(data.error ?? "Something went wrong. Try again."); + } + setContinuing(false); + return; + } + } + router.push("/app/onboarding/first"); + } catch { + setError("Something went wrong. Try again."); + setContinuing(false); + } + } + + if (phase === "learned" && profile) { + return ( +
+
+

Here's your voice

+

+ Adjust anything. You can change this later in Settings. +

+
+ + {deterministic ? ( + + {VOICE_BASIC_PROFILE_COPY} + + ) : null} + {!learnSaved ? ( + + {SAVE_UNAVAILABLE_COPY} + + ) : null} + + + + + {error ? ( + + {error} + + ) : null} + +
+ + +
+
+ ); + } + + return ( +
+
+

Teach EpisodeKit your voice

+

+ Paste 1–3 past show notes or newsletters. We'll match how you write. +

+
+ + {samples.map((sample, index) => { + const words = countWords(sample); + return ( +
+ +