diff --git a/docs/relaunch/DECISIONS.md b/docs/relaunch/DECISIONS.md index ff5978d..6f7c01b 100644 --- a/docs/relaunch/DECISIONS.md +++ b/docs/relaunch/DECISIONS.md @@ -3,6 +3,12 @@ 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-05 · Live ASR smoke test skipped: `.env.relaunch` holds sanitized placeholders (values literally `"[SENSITIVE]"`, no `gsk_` key, ASR_PROVIDER ≠ groq) · the handoff rule says skip the live call when ASR_PROVIDER≠groq; the Groq adapter is covered by fetch-injected unit tests (request shape, error mapping, language gate) instead · running a live call with a placeholder key - 2026-10-27 · WP-05 · Chunk manifest (path + offsetMs per ≤10-min chunk) serialized as JSON into `episodes.audio_blob_path` · §7.1 has a single text column and the server must re-run transcription idempotently after a refresh without client state (U4); a plain single-path string still parses as one chunk at 0 ms · new table/column (schema is WP-02's), body-only manifest (lost on refresh) - 2026-10-27 · WP-05 · POST /api/episodes accepts `blobPaths[]` + parallel `chunkOffsetsMs[]` beyond the §7.5 body shape · §8.3 requires per-chunk offsets for the overlap dedupe and §7.5 pins `blobPaths[]`; parallel arrays are the smallest coherent extension · nested `chunks[]` objects in create (used only on transcribe), path-embedded offsets 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 ( +
+ +