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
6 changes: 6 additions & 0 deletions docs/relaunch/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 13 additions & 17 deletions web/app/(app)/app/onboarding/voice/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mx-auto flex w-full max-w-xl flex-col items-center gap-6 py-10">
<StepDots step={2} />
<div className="w-full rounded-lg border p-6 text-center" data-testid="voice-placeholder">
<h1 className="text-lg font-semibold">Teach EpisodeKit your voice</h1>
<p className="mt-2 text-sm text-neutral-500">This page is coming soon.</p>
<Link
href="/app/onboarding/first"
className="mt-4 inline-block text-sm underline"
data-testid="skip-voice"
>
Skip for now
</Link>
<div className="w-full rounded-lg border p-6">
<VoiceOnboardingForm />
</div>
</div>
);
Expand Down
22 changes: 16 additions & 6 deletions web/app/(app)/app/settings/voice/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="rounded-lg border p-6" data-testid="voice-stub">
<h2 className="text-base font-semibold">Voice</h2>
<p className="mt-1 text-sm text-neutral-500">This page is coming soon.</p>
</div>
<VoiceSettingsForm
profile={resolveShowVoiceProfile(show)}
samples={show?.voiceSamples ?? []}
/>
);
}
14 changes: 14 additions & 0 deletions web/app/api/show/voice/learn/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
14 changes: 14 additions & 0 deletions web/app/api/show/voice/preview/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
228 changes: 228 additions & 0 deletions web/components/app/onboarding/voice-form.tsx
Original file line number Diff line number Diff line change
@@ -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<string[]>([""]);
const [phase, setPhase] = useState<"collect" | "learned">("collect");
const [profile, setProfile] = useState<VoiceProfile | null>(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<string | null>(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 (
<div className="flex flex-col gap-6" data-testid="voice-learned">
<div className="flex flex-col gap-1">
<h1 className="text-[22px] font-semibold leading-[30px]">Here&apos;s your voice</h1>
<p className="text-small text-body">
Adjust anything. You can change this later in Settings.
</p>
</div>

{deterministic ? (
<span className="rounded-md bg-warning-soft px-3 py-2 text-small text-warning" data-testid="voice-note">
{VOICE_BASIC_PROFILE_COPY}
</span>
) : null}
{!learnSaved ? (
<span className="rounded-md bg-surface-2 px-3 py-2 text-small text-body" data-testid="voice-saved-note">
{SAVE_UNAVAILABLE_COPY}
</span>
) : null}

<VoiceProfileEditor profile={profile} onChange={setProfile} />
<VoicePreviewCard profile={profile} autoLoad />

{error ? (
<span role="alert" className="text-small text-danger" data-testid="voice-error">
{error}
</span>
) : null}

<div className="flex items-center justify-between">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => router.push("/app/onboarding/show")}
data-testid="back-to-show"
>
← Back
</Button>
<Button
type="button"
onClick={continueToFirstEpisode}
disabled={!profileComplete(profile) || continuing}
data-testid="continue-voice"
>
{continuing ? "Saving…" : "Continue"}
</Button>
</div>
</div>
);
}

return (
<div className="flex flex-col gap-6" data-testid="voice-collect">
<div className="flex flex-col gap-1">
<h1 className="text-[22px] font-semibold leading-[30px]">Teach EpisodeKit your voice</h1>
<p className="text-small text-body">
Paste 1–3 past show notes or newsletters. We&apos;ll match how you write.
</p>
</div>

{samples.map((sample, index) => {
const words = countWords(sample);
return (
<div key={index} className="flex flex-col gap-1">
<label htmlFor={`voice-sample-${index}`} className="text-small font-medium">
Example {index + 1}
</label>
<Textarea
id={`voice-sample-${index}`}
rows={6}
value={sample}
onChange={(e) => updateSample(index, e.target.value)}
data-testid={`voice-sample-${index}`}
/>
<div className="flex items-center justify-between">
<span
className="font-mono text-xs tabular-nums text-faint"
aria-live="polite"
data-testid={`sample-counter-${index}`}
>
{words} words · at least {MIN_SAMPLE_WORDS}
</span>
{index === samples.length - 1 && samples.length < 3 ? (
<button
type="button"
className="text-small underline"
onClick={() => setSamples((current) => [...current, ""])}
data-testid={`add-sample-${index}`}
>
+ Add another example
</button>
) : null}
</div>
</div>
);
})}

{error ? (
<span role="alert" className="text-small text-danger" data-testid="voice-error">
{error}
</span>
) : null}
{filled.length > 0 && !canLearn ? (
<span role="status" className="text-small text-warning" data-testid="voice-min-hint">
{SAMPLE_TOO_SHORT_COPY}
</span>
) : null}

<div className="flex items-center justify-between">
<Button type="button" onClick={learn} disabled={!canLearn || learning} data-testid="learn-voice">
{learning ? "Learning your voice…" : "Learn my voice"}
</Button>
<button
type="button"
className="text-small underline"
onClick={() => router.push("/app/onboarding/first")}
data-testid="skip-voice"
>
Skip for now
</button>
</div>
</div>
);
}
Loading
Loading