From 8b90cf5413fa7dc043cda4291480b676d5d14fed Mon Sep 17 00:00:00 2001 From: Abid Hasan Date: Tue, 27 Oct 2026 20:52:24 +0600 Subject: [PATCH 1/3] WP-06 server: analyze runner, pipeline service, artifact routes, export presets + tests --- web/app/api/artifacts/[id]/route.ts | 15 + web/app/api/episodes/[id]/analyze/route.ts | 14 + .../[id]/artifacts/[kind]/generate/route.ts | 17 + web/app/api/episodes/[id]/route.ts | 12 + web/db/queries.ts | 116 +++++- web/lib/ai/analyze.ts | 163 +++++++++ web/lib/export/format.ts | 203 +++++++++++ web/lib/pipeline/service.ts | 344 ++++++++++++++++++ web/tests/unit/export-format.test.ts | 94 +++++ web/tests/unit/pipeline-service.test.ts | 298 +++++++++++++++ 10 files changed, 1275 insertions(+), 1 deletion(-) create mode 100644 web/app/api/artifacts/[id]/route.ts create mode 100644 web/app/api/episodes/[id]/analyze/route.ts create mode 100644 web/app/api/episodes/[id]/artifacts/[kind]/generate/route.ts create mode 100644 web/lib/ai/analyze.ts create mode 100644 web/lib/export/format.ts create mode 100644 web/lib/pipeline/service.ts create mode 100644 web/tests/unit/export-format.test.ts create mode 100644 web/tests/unit/pipeline-service.test.ts diff --git a/web/app/api/artifacts/[id]/route.ts b/web/app/api/artifacts/[id]/route.ts new file mode 100644 index 0000000..fcf8d3f --- /dev/null +++ b/web/app/api/artifacts/[id]/route.ts @@ -0,0 +1,15 @@ +import { getAuthUserId } from "@/lib/auth"; +import { AUTH_REQUIRED_COPY } from "@/lib/copy"; +import { handleArtifactPatch } from "@/lib/pipeline/service"; + +/** PATCH /api/artifacts/:id — {content} save · {ready} · {undo:true} (§7.5). */ +export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) { + const userId = await getAuthUserId(); + if (!userId) { + return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 }); + } + const { id } = await params; + const body: unknown = await req.json().catch(() => ({})); + const result = await handleArtifactPatch(userId, id, body); + return Response.json(result.json, { status: result.status }); +} diff --git a/web/app/api/episodes/[id]/analyze/route.ts b/web/app/api/episodes/[id]/analyze/route.ts new file mode 100644 index 0000000..2e82e0e --- /dev/null +++ b/web/app/api/episodes/[id]/analyze/route.ts @@ -0,0 +1,14 @@ +import { getAuthUserId } from "@/lib/auth"; +import { AUTH_REQUIRED_COPY } from "@/lib/copy"; +import { handleAnalyzePost } from "@/lib/pipeline/service"; + +/** POST /api/episodes/:id/analyze — chapters/topics/candidate quotes (§7.5). */ +export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const userId = await getAuthUserId(); + if (!userId) { + return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 }); + } + const { id } = await params; + const result = await handleAnalyzePost(userId, id); + return Response.json(result.json, { status: result.status }); +} diff --git a/web/app/api/episodes/[id]/artifacts/[kind]/generate/route.ts b/web/app/api/episodes/[id]/artifacts/[kind]/generate/route.ts new file mode 100644 index 0000000..7408a5a --- /dev/null +++ b/web/app/api/episodes/[id]/artifacts/[kind]/generate/route.ts @@ -0,0 +1,17 @@ +import { getAuthUserId } from "@/lib/auth"; +import { AUTH_REQUIRED_COPY } from "@/lib/copy"; +import { handleArtifactGeneratePost } from "@/lib/pipeline/service"; + +export const maxDuration = 300; + +/** POST /api/episodes/:id/artifacts/:kind/generate — one artifact (§7.5). */ +export async function POST(req: Request, { params }: { params: Promise<{ id: string; kind: string }> }) { + const userId = await getAuthUserId(); + if (!userId) { + return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 }); + } + const { id, kind } = await params; + const body: unknown = await req.json().catch(() => ({})); + const result = await handleArtifactGeneratePost(userId, id, kind, body); + return Response.json(result.json, { status: result.status }); +} diff --git a/web/app/api/episodes/[id]/route.ts b/web/app/api/episodes/[id]/route.ts index 94496b7..bb3b1dd 100644 --- a/web/app/api/episodes/[id]/route.ts +++ b/web/app/api/episodes/[id]/route.ts @@ -1,6 +1,7 @@ import { getAuthUserId } from "@/lib/auth"; import { AUTH_REQUIRED_COPY } from "@/lib/copy"; import { handleEpisodeGet } from "@/lib/episodes/service"; +import { handleEpisodeDelete } from "@/lib/pipeline/service"; /** GET /api/episodes/:id — episode + status, polled every 2 s (§7.5, U4). */ export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { @@ -12,3 +13,14 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str const result = await handleEpisodeGet(userId, id); return Response.json(result.json, { status: result.status }); } + +/** DELETE /api/episodes/:id — edit meta handled by PATCH; delete + blob (§7.5). */ +export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const userId = await getAuthUserId(); + if (!userId) { + return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 }); + } + const { id } = await params; + const result = await handleEpisodeDelete(userId, id); + return Response.json(result.json, { status: result.status }); +} diff --git a/web/db/queries.ts b/web/db/queries.ts index 3d893e9..d24e5e9 100644 --- a/web/db/queries.ts +++ b/web/db/queries.ts @@ -1,6 +1,10 @@ -import { and, count, eq } from "drizzle-orm"; +import { and, count, desc, eq, sql } from "drizzle-orm"; import { getDb } from "./index"; import { + analysis, + artifactKindEnum, + artifactStatusEnum, + artifacts, episodes, segments, shows, @@ -268,3 +272,113 @@ export async function getUserEmail(userId: string): Promise { const rows = await db.select({ email: users.email }).from(users).where(eq(users.id, userId)).limit(1); return rows[0]?.email ?? null; } + +// --------------------------------------------------------------------------- +// WP-06 — analysis, artifacts, episode list (M4–M8). Additive. +// --------------------------------------------------------------------------- + +export type AnalysisRow = typeof analysis.$inferSelect; +export type ArtifactRow = typeof artifacts.$inferSelect; +export type ArtifactKindName = (typeof artifactKindEnum.enumValues)[number]; + +export async function getAnalysis(episodeId: string): Promise { + const db = getDb(); + const rows = await db.select().from(analysis).where(eq(analysis.episodeId, episodeId)).limit(1); + return rows[0] ?? null; +} + +export async function upsertAnalysis( + episodeId: string, + values: { chapters: unknown; topics: string[]; candidateQuotes: unknown }, +): Promise { + const db = getDb(); + await db + .insert(analysis) + .values({ episodeId, ...values }) + .onConflictDoUpdate({ + target: analysis.episodeId, + set: { chapters: values.chapters, topics: values.topics, candidateQuotes: values.candidateQuotes }, + }); +} + +export async function upsertArtifact( + episodeId: string, + kind: ArtifactKindName, + values: Partial & { status: (typeof artifactStatusEnum.enumValues)[number] }, +): Promise { + const db = getDb(); + const rows = await db + .insert(artifacts) + .values({ episodeId, kind, ...values }) + .onConflictDoUpdate({ + target: [artifacts.episodeId, artifacts.kind], + set: { ...values, updatedAt: new Date() }, + }) + .returning(); + return rows[0]; +} + +export async function getArtifactsByEpisode(episodeId: string): Promise { + const db = getDb(); + return db.select().from(artifacts).where(eq(artifacts.episodeId, episodeId)); +} + +export async function getArtifactForUser( + userId: string, + artifactId: string, +): Promise { + const db = getDb(); + const rows = await db + .select({ artifact: artifacts }) + .from(artifacts) + .innerJoin(episodes, eq(artifacts.episodeId, episodes.id)) + .innerJoin(shows, eq(episodes.showId, shows.id)) + .innerJoin(workspaces, eq(shows.workspaceId, workspaces.id)) + .where(and(eq(artifacts.id, artifactId), eq(workspaces.ownerId, userId))) + .limit(1); + return rows[0]?.artifact ?? null; +} + +export async function updateArtifactContent( + artifactId: string, + content: unknown, +): Promise { + const db = getDb(); + await db + .update(artifacts) + .set({ content, updatedAt: new Date() }) + .where(eq(artifacts.id, artifactId)); +} + +export async function setArtifactReady(artifactId: string, ready: boolean): Promise { + const db = getDb(); + await db.update(artifacts).set({ readyFlag: ready }).where(eq(artifacts.id, artifactId)); +} + +/** Undo regenerate: restore previous_content and clear the snapshot (M6). */ +export async function undoArtifact(artifactId: string): Promise { + const db = getDb(); + const rows = await db + .update(artifacts) + .set({ content: sql`coalesce(${artifacts.previousContent}, ${artifacts.content})`, previousContent: null, updatedAt: new Date() }) + .where(eq(artifacts.id, artifactId)) + .returning(); + return rows[0] ?? null; +} + +export async function listEpisodesForShow(showId: string): Promise { + const db = getDb(); + return db + .select() + .from(episodes) + .where(eq(episodes.showId, showId)) + .orderBy(desc(episodes.createdAt)); +} + +/** Delete an episode the caller owns (segments/analysis/artifacts cascade). */ +export async function deleteEpisodeForUser(userId: string, episodeId: string): Promise { + const db = getDb(); + const owned = await getEpisodeForUser(userId, episodeId); + if (!owned) return; + await db.delete(episodes).where(eq(episodes.id, episodeId)); +} diff --git a/web/lib/ai/analyze.ts b/web/lib/ai/analyze.ts new file mode 100644 index 0000000..15b9398 --- /dev/null +++ b/web/lib/ai/analyze.ts @@ -0,0 +1,163 @@ +// §7.5 POST /api/episodes/:id/analyze — topics, candidate quotes, chapters +// (fast model). Mirrors generateArtifact's call policy (§8.1: chain → one +// repair → deterministic fallback); candidate quotes are ALWAYS resolved to +// verbatim QuoteRef offsets (§7.3) — model text is never stored as a quote. +import { z } from "zod"; +import { AnalyzeResultSchema, buildAnalyzePrompt } from "@/lib/prompts/analyze"; +import type { PromptContext } from "@/lib/prompts"; +import { DEFAULT_VOICE_PROFILE } from "@/lib/ai/voice"; +import { analyzeTranscript } from "@/lib/engine"; +import { candidateQuoteRefs } from "@/lib/quotes/candidates"; +import { resolveQuotes } from "@/lib/quotes/resolve"; +import type { QuoteRef, Segment } from "@/lib/quotes/types"; +import { buildProviderChain, modelFor } from "@/lib/ai/registry"; +import { effectiveAiSelection } from "@/lib/ai/settings"; +import { extractJson, repairPrompt } from "@/lib/ai/json"; +import type { AppSettingsStore } from "@/lib/ai/settings"; +import type { LlmProvider } from "@/lib/ai/types"; + +export interface AnalyzeDeps { + chain?: LlmProvider[]; + store?: AppSettingsStore | null; + env?: NodeJS.ProcessEnv; + fetchImpl?: typeof fetch; + sleep?: (ms: number) => Promise; + timeoutMs?: number; +} + +export interface AnalyzeInput { + segments: Segment[]; + hasTimestamps: boolean; + show: { name: string }; + deps?: AnalyzeDeps; +} + +export interface AnalyzeOutput { + topics: string[]; + candidateQuotes: QuoteRef[]; + chapters: { startMs: number; title: string }[] | null; + status: "ai" | "fallback"; + provider: string | null; + model: string | null; +} + +/** Deterministic chapters from segment timestamps (§7.2: 5–12, ≥2 min apart, first at 0). */ +function deterministicChapters(segments: Segment[]): { startMs: number; title: string }[] | null { + const timed = segments.filter((s) => typeof s.startMs === "number"); + if (timed.length === 0) return null; + const last = timed[timed.length - 1]; + const endMs = typeof last.endMs === "number" ? last.endMs : (last.startMs ?? 0); + if (endMs < 6 * 60_000) return null; // too short to split honestly into 5 + const step = Math.max(endMs / 8, 2 * 60_000); + const out: { startMs: number; title: string }[] = []; + for (let t = 0; t <= endMs - 2 * 60_000 && out.length < 12; t === 0 ? (t = step) : (t += step)) { + const seg = timed.find((s) => (s.startMs ?? 0) <= t && (s.endMs ?? 0) > t) ?? timed[0]; + if (!seg) continue; + const title = seg.text.split(/\s+/).slice(0, 7).join(" ").slice(0, 50).trim(); + if (title) out.push({ startMs: t, title }); + } + return out.length >= 5 ? out : null; +} + +export function deterministicAnalyze(segments: Segment[]): AnalyzeOutput { + const a = analyzeTranscript(segments.map((s) => s.text).join(" ")); + return { + topics: a.themes.slice(0, 10).map((t) => t.slice(0, 40)), + candidateQuotes: candidateQuoteRefs(segments, { count: 12 }), + chapters: deterministicChapters(segments), + status: "fallback", + provider: null, + model: null, + }; +} + +function asObject(v: unknown): Record | null { + return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record) : null; +} + +export async function runAnalyze(input: AnalyzeInput): Promise { + const { segments, hasTimestamps, show } = input; + const deps = input.deps ?? {}; + const env = deps.env ?? process.env; + const fb = deterministicAnalyze(segments); + if (segments.length === 0) return fb; + + const ctx: PromptContext = { + show: { name: show.name }, + voice: DEFAULT_VOICE_PROFILE, + segments, + }; + const prompt = buildAnalyzePrompt(ctx); + + let chain: LlmProvider[]; + let model: string; + if (deps.chain?.length) { + chain = deps.chain; + model = modelFor((await effectiveAiSelection(null, { env })).selection, "fast"); + } else { + const selection = (await effectiveAiSelection(deps.store ?? null, { env })).selection; + chain = buildProviderChain(selection, { + env, + fetchImpl: deps.fetchImpl, + sleep: deps.sleep, + timeoutMs: deps.timeoutMs, + }); + model = modelFor(selection, "fast"); + } + + const schemaHint = JSON.stringify( + z.toJSONSchema ? z.toJSONSchema(AnalyzeResultSchema) : AnalyzeResultSchema, + ); + + for (const provider of chain) { + try { + let parsed = asObject(extractJson((await provider.complete({ system: prompt.system, user: prompt.user, model, json: true, temperature: 0.2 })).text)); + let validation = AnalyzeResultSchema.safeParse(parsed); + if (!validation.success) { + const problem = parsed ? "it did not match the required schema" : "it was not valid JSON"; + const repair = await provider.complete({ + system: prompt.system, + user: repairPrompt(prompt.user, schemaHint, problem), + model, + json: true, + temperature: 0.2, + }); + const reparsed = asObject(extractJson(repair.text)); + if (reparsed) { + const v2 = AnalyzeResultSchema.safeParse(reparsed); + if (v2.success) { + parsed = reparsed; + validation = v2; + } + } + } + if (!validation.success) continue; + const value = validation.data; + + const resolved = resolveQuotes( + value.candidateQuotes.map((c: { segmentId: string; exact: string }) => ({ segmentId: c.segmentId, exact: c.exact })), + segments, + { count: 12, candidates: fb.candidateQuotes }, + ).quotes; + + const chapters = + hasTimestamps && value.chapters && value.chapters.length >= 5 + ? value.chapters.slice(0, 12).map((c) => ({ startMs: c.startMs, title: c.title.slice(0, 50) })) + : hasTimestamps + ? fb.chapters + : null; + + return { + topics: value.topics, + candidateQuotes: resolved.length > 0 ? resolved : fb.candidateQuotes, + chapters, + status: "ai", + provider: provider.id, + model, + }; + } catch { + continue; // next provider in the chain (§8.1) + } + } + return fb; +} diff --git a/web/lib/export/format.ts b/web/lib/export/format.ts new file mode 100644 index 0000000..1e68cfe --- /dev/null +++ b/web/lib/export/format.ts @@ -0,0 +1,203 @@ +// M7 copy & export presets — Apple Podcasts / Spotify / YouTube / Markdown / +// Plain text + "Download all (.md)". Pure functions over artifact content +// (§7.2 shapes) + segments (quote/chapter rendering by reference). The free +// plan appends the footer line "Show notes made with EpisodeKit". +import type { Segment } from "@/lib/quotes/types"; + +export const FREE_FOOTER = "Show notes made with EpisodeKit"; +export const APPLE_LIMIT = 4000; +export const SPOTIFY_LIMIT = 4000; + +export type Destination = "apple" | "spotify" | "youtube" | "markdown" | "plain"; + +export const DESTINATION_LABELS: Record = { + apple: "Apple Podcasts", + spotify: "Spotify", + youtube: "YouTube", + markdown: "Markdown", + plain: "Plain text", +}; + +export interface ExportContext { + segments: Segment[]; + showName: string; + episodeTitle: string | null; + freePlan: boolean; +} + +interface ChapterItem { + startMs: number; + title: string; +} + +function mmss(ms: number): string { + const s = Math.max(0, Math.floor(ms / 1000)); + const m = Math.floor(s / 60); + return `${String(m).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`; +} + +/** YouTube chapters use bare `0:00` timestamps (M7). */ +function youtubeTs(ms: number): string { + const s = Math.max(0, Math.floor(ms / 1000)); + const m = Math.floor(s / 60); + const h = Math.floor(m / 60); + return h > 0 ? `${h}:${String(m % 60).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}` : `${m}:${String(s % 60).padStart(2, "0")}`; +} + +function segmentText(ref: { segmentId: string; start: number; end: number; endSegmentId?: string }, segments: Segment[]): string { + const ids = ref.endSegmentId ? [ref.segmentId, ref.endSegmentId] : [ref.segmentId]; + const parts: string[] = []; + for (const id of ids) { + const seg = segments.find((s) => s.id === id); + if (seg) parts.push(seg.text.slice(ref.segmentId === id ? ref.start : 0, ref.segmentId === id ? ref.end : (ref.endSegmentId === id ? ref.end : undefined))); + } + return parts.join(" ").trim(); +} + +function quoteLine(ref: unknown, segments: Segment[]): string { + const text = segmentText(ref as { segmentId: string; start: number; end: number; endSegmentId?: string }, segments); + return text ? `“${text}”` : ""; +} + +export function renderChapters(chapters: ChapterItem[], dest: Destination): string { + if (chapters.length === 0) return ""; + const ts = dest === "youtube" ? youtubeTs : mmss; + return chapters.map((c) => `${ts(c.startMs)} ${c.title}`).join("\n"); +} + +/** Show notes → destination text (M7: Apple/Spotify plain ≤4000 with chapters appended). */ +export function showNotesToText(content: Record, ctx: ExportContext, dest: Destination, chapters: ChapterItem[]): string { + const lines: string[] = []; + if (ctx.episodeTitle) lines.push(ctx.episodeTitle, ""); + const hook = typeof content.hook === "string" ? content.hook : ""; + if (hook) lines.push(hook, ""); + const bullets = Array.isArray(content.bullets) ? (content.bullets as string[]) : []; + if (bullets.length) { + lines.push("In this episode"); + lines.push(...bullets.map((b) => `• ${b}`)); + lines.push(""); + } + if (typeof content.guestLine === "string" && content.guestLine) { + lines.push(content.guestLine, ""); + } + const chText = renderChapters(chapters, dest); + if (chText) lines.push("Chapters", chText, ""); + const quotes = Array.isArray(content.quoteRefs) ? (content.quoteRefs as unknown[]) : []; + for (const q of quotes) { + const line = quoteLine(q, ctx.segments); + if (line) lines.push(line, "— from the episode", ""); + } + if (typeof content.cta === "string" && content.cta) lines.push(content.cta); + let text = lines.join("\n").trim(); + if (ctx.freePlan) text += `\n\n${FREE_FOOTER}`; + return text; +} + +/** Blog body contains {{quote:1}} placeholders rendered from refs (§7.2). */ +export function renderBlogBody(bodyMarkdown: string, quoteRefs: unknown[], ctx: ExportContext): string { + return bodyMarkdown.replace(/\{\{quote:(\d+)\}\}/g, (_m, n) => { + const ref = quoteRefs[Number(n) - 1]; + const line = ref ? quoteLine(ref, ctx.segments) : ""; + return line || ""; + }); +} + +export function blogToMarkdown(content: Record, ctx: ExportContext): string { + const body = renderBlogBody(typeof content.bodyMarkdown === "string" ? content.bodyMarkdown : "", Array.isArray(content.quoteRefs) ? (content.quoteRefs as unknown[]) : [], ctx); + const fm = [ + "---", + `title: "${String(content.seoTitle ?? ctx.episodeTitle ?? ctx.showName).replace(/"/g, '\\"')}"`, + `description: "${String(content.metaDescription ?? "").replace(/"/g, '\\"')}"`, + `slug: ${String(content.slug ?? "")}`, + "---", + "", + ].join("\n"); + return fm + body; +} + +export function summaryToText(content: Record): string { + return [content.oneLiner, content.short, content.long].filter((s) => typeof s === "string" && s).join("\n\n"); +} + +export function titlesToText(content: Record): string { + const items = Array.isArray(content.items) ? (content.items as { text: string; style?: string }[]) : []; + return items.map((t) => (t.style ? `${t.text} (${t.style})` : t.text)).join("\n"); +} + +export function newsletterToText(content: Record, ctx: ExportContext): string { + const lines: string[] = []; + const subjects = Array.isArray(content.subjects) ? (content.subjects as string[]) : []; + if (subjects.length) lines.push(subjects.map((s) => `- ${s}`).join("\n"), ""); + if (typeof content.previewText === "string" && content.previewText) lines.push(content.previewText, ""); + const body = renderBlogBody( + typeof content.bodyMarkdown === "string" ? content.bodyMarkdown : "", + Array.isArray(content.quoteRefs) ? (content.quoteRefs as unknown[]) : [], + ctx, + ); + if (body) lines.push(body, ""); + if (typeof content.ctaText === "string" && content.ctaText) lines.push(content.ctaText); + return lines.join("\n").trim(); +} + +export function socialToText(content: Record): string { + const lines: string[] = []; + if (typeof content.linkedin === "string" && content.linkedin) lines.push("LinkedIn", content.linkedin, ""); + const thread = Array.isArray(content.thread) ? (content.thread as string[]) : []; + if (thread.length) { + lines.push("Thread"); + lines.push(thread.map((p, i) => `${i + 1}/ ${p}`).join("\n\n"), ""); + } + const ig = (content.instagram ?? {}) as { caption?: string; hashtags?: string[] }; + if (ig.caption) lines.push("Instagram", ig.caption, (ig.hashtags ?? []).map((h) => (h.startsWith("#") ? h : `#${h}`)).join(" ")); + return lines.join("\n").trim(); +} + +export function quotesToText(content: Record, ctx: ExportContext): string { + const items = Array.isArray(content.items) ? (content.items as unknown[]) : []; + return items + .map((q) => quoteLine(q, ctx.segments)) + .filter(Boolean) + .map((t) => `${t}\n— from the episode`) + .join("\n\n"); +} + +export function destinationLimit(dest: Destination): number | null { + if (dest === "apple") return APPLE_LIMIT; + if (dest === "spotify") return SPOTIFY_LIMIT; + return null; +} + +/** U13: over limit → the UI shows a warning + "Shorten to fit" (AI). */ +export function needsShortening(dest: Destination, text: string): boolean { + const limit = destinationLimit(dest); + return limit !== null && text.length > limit; +} + +/** Deterministic last-resort shorten (only used when the AI shorten call is unavailable). */ +export function shortenToLimit(text: string, limit: number): string { + if (text.length <= limit) return text; + const cut = text.slice(0, limit); + const lastBreak = Math.max(cut.lastIndexOf("\n"), cut.lastIndexOf(". ")); + return (lastBreak > limit * 0.6 ? cut.slice(0, lastBreak + 1) : cut).trim(); +} + +/** "Download all (.md)" — one Markdown file with every artifact (M7). */ +export function downloadAllMarkdown(artifacts: Partial>>, ctx: ExportContext, chapters: ChapterItem[]): string { + const out: string[] = [`# ${ctx.episodeTitle ?? "Episode"} — ${ctx.showName}`, ""]; + const sections: [string, string][] = [ + ["Show notes", artifacts.show_notes ? showNotesToText(artifacts.show_notes, ctx, "markdown", chapters) : ""], + ["Chapters", renderChapters(chapters, "markdown")], + ["Summary", artifacts.summary ? summaryToText(artifacts.summary) : ""], + ["Titles", artifacts.titles ? titlesToText(artifacts.titles) : ""], + ["Blog post", artifacts.blog ? blogToMarkdown(artifacts.blog, ctx) : ""], + ["Newsletter", artifacts.newsletter ? newsletterToText(artifacts.newsletter, ctx) : ""], + ["Social posts", artifacts.social ? socialToText(artifacts.social) : ""], + ["Quotes", artifacts.quotes ? quotesToText(artifacts.quotes, ctx) : ""], + ]; + for (const [title, body] of sections) { + if (!body) continue; + out.push(`## ${title}`, "", body, ""); + } + if (ctx.freePlan) out.push(FREE_FOOTER); + return out.join("\n").trim() + "\n"; +} diff --git a/web/lib/pipeline/service.ts b/web/lib/pipeline/service.ts new file mode 100644 index 0000000..71b846a --- /dev/null +++ b/web/lib/pipeline/service.ts @@ -0,0 +1,344 @@ +// WP-06 pipeline service — §7.5 orchestration steps. The workspace page +// (client) drives transcribe → analyze → generate×7 (parallel, max 4 +// concurrent); every server step here is idempotent, re-checks status from +// the DB, and never trusts client state. Boundaries: @/db/queries, +// @/lib/ai/{generate,analyze}, Resend (optional ready email). +import * as queries from "@/db/queries"; +import { isDbConfigured } from "@/lib/env"; +import { SAVE_UNAVAILABLE_COPY, AUTH_REQUIRED_COPY } from "@/lib/copy"; +import { generateArtifact } from "@/lib/ai/generate"; +import { runAnalyze } from "@/lib/ai/analyze"; +import { DEFAULT_VOICE_PROFILE, type VoiceProfile } from "@/lib/ai/voice"; +import { ARTIFACT_KINDS, artifactContentSchema, type ArtifactKind } from "@/lib/artifacts/schemas"; +import type { Segment } from "@/lib/quotes/types"; + +export interface ApiResult { + status: number; + json: Record; +} + +const ARTIFACT_STATUSES = ["pending", "writing", "ready", "failed", "fallback"] as const; + +function segmentRowToSegment(row: queries.SegmentRow): Segment { + return { + id: row.id, + idx: row.idx, + speaker: row.speaker ?? null, + startMs: row.startMs ?? null, + endMs: row.endMs ?? null, + text: row.text, + }; +} + +type LoadedContext = + | { ok: false; result: ApiResult } + | { ok: true; owned: NonNullable>> }; + +async function loadContext(userId: string, episodeId: string): Promise { + if (!isDbConfigured()) { + return { ok: false, result: { status: 503, json: { error: SAVE_UNAVAILABLE_COPY } } }; + } + const owned = await queries.getEpisodeForUser(userId, episodeId); + if (!owned) { + return { ok: false, result: { status: 404, json: { error: "Episode not found." } } }; + } + return { ok: true, owned }; +} + +// --------------------------------------------------------------------------- +// POST /api/episodes/:id/analyze (§7.5) +// --------------------------------------------------------------------------- +export async function handleAnalyzePost(userId: string, episodeId: string): Promise { + const ctx = await loadContext(userId, episodeId); + if (!ctx.ok) return ctx.result; + const { episode } = ctx.owned; + + // Idempotent: segments must exist (paste creates them at POST /api/episodes; + // upload creates them in transcribe). + const segmentRows = await queries.listSegments(episodeId); + if (segmentRows.length === 0) { + return { status: 400, json: { errorCode: "no_transcript", error: "Transcribe or paste the episode first." } }; + } + + const existing = await queries.getAnalysis(episodeId); + if (existing) { + return { status: 200, json: { ok: true, analysis: serializeAnalysis(existing), cached: true } }; + } + + const segments = segmentRows.map(segmentRowToSegment); + const result = await runAnalyze({ + segments, + hasTimestamps: episode.hasTimestamps, + show: { name: ctx.owned.show.name }, + }); + + await queries.upsertAnalysis(episodeId, { + chapters: result.chapters ?? null, + topics: result.topics, + candidateQuotes: result.candidateQuotes, + }); + return { + status: 200, + json: { + ok: true, + cached: false, + analysis: { + chapters: result.chapters, + topics: result.topics, + candidateQuotes: result.candidateQuotes, + }, + status: result.status, + }, + }; +} + +function serializeAnalysis(row: queries.AnalysisRow) { + return { + chapters: row.chapters as { startMs: number; title: string }[] | null, + topics: row.topics, + candidateQuotes: row.candidateQuotes as unknown, + }; +} + +// --------------------------------------------------------------------------- +// POST /api/episodes/:id/artifacts/:kind/generate (§7.5; {instruction?} regen) +// --------------------------------------------------------------------------- +const regenBuckets = new Map(); +const REGEN_LIMIT_PER_HOUR = 60; + +export function regensInLastHour(userId: string, now = Date.now()): number { + const hits = (regenBuckets.get(userId) ?? []).filter((t) => now - t < 3_600_000); + regenBuckets.set(userId, hits); + return hits.length; +} + +export async function handleArtifactGeneratePost( + userId: string, + episodeId: string, + kind: string, + body: unknown, +): Promise { + if (!(ARTIFACT_KINDS as readonly string[]).includes(kind)) { + return { status: 404, json: { error: "Unknown artifact." } }; + } + const ctx = await loadContext(userId, episodeId); + if (!ctx.ok) return ctx.result; + const { episode, show, workspace } = ctx.owned; + + const instruction = + typeof (body as { instruction?: unknown })?.instruction === "string" + ? (body as { instruction: string }).instruction.slice(0, 200) + : undefined; + + const existingRows = await queries.getArtifactsByEpisode(episodeId); + const artifactKind = kind as ArtifactKind; + const existing = existingRows.find((r) => r.kind === kind); + + if (instruction !== undefined && existing) { + if (regensInLastHour(userId) >= REGEN_LIMIT_PER_HOUR) { + return { status: 429, json: { error: "Too many regenerations. Try again later." } }; + } + if (existing.regenCount >= 20) { + return { status: 409, json: { errorCode: "regen_cap", error: "This artifact hit its 20 regenerations limit." } }; + } + const hits = regenBuckets.get(userId) ?? []; + hits.push(Date.now()); + regenBuckets.set(userId, hits); + } + + // Idempotency: a writing/ready artifact without an instruction is a no-op. + if (existing && existing.status !== "failed" && existing.status !== "fallback" && instruction === undefined) { + return { status: 200, json: { ok: true, artifact: serializeArtifact(existing), cached: true } }; + } + + const segmentRows = await queries.listSegments(episodeId); + if (segmentRows.length === 0) { + return { status: 400, json: { errorCode: "no_transcript", error: "Transcribe or paste the episode first." } }; + } + const segments = segmentRows.map(segmentRowToSegment); + + const analysisRow = await queries.getAnalysis(episodeId); + const voice = (show.voiceProfile as VoiceProfile | null) ?? DEFAULT_VOICE_PROFILE; + + await queries.updateEpisode(episodeId, { status: "writing" }); + if (existing && existing.content) { + await queries.upsertArtifact(episodeId, artifactKind, { + status: "writing", + previousContent: existing.content, + }); + } else { + await queries.upsertArtifact(episodeId, artifactKind, { status: "writing" }); + } + + const result = await generateArtifact({ + kind: artifactKind, + segments, + show: { + name: show.name, + defaultCta: show.defaultCta ?? undefined, + vocabulary: show.vocabulary ?? [], + allowedUrls: show.listenUrl ? [show.listenUrl] : [], + }, + voice, + voiceSamples: show.voiceSamples ?? [], + meta: { + title: episode.title ?? undefined, + number: episode.number ?? undefined, + guests: (episode.guests as string[] | null) ?? undefined, + }, + instruction, + analysisCandidates: (analysisRow?.candidateQuotes as never) ?? undefined, + deps: { + workspaceId: workspace.id, + episodeId, + }, + }); + + const row = await queries.upsertArtifact(episodeId, artifactKind, { + status: result.status === "fallback" ? "fallback" : result.errorCode ? "failed" : "ready", + content: result.content, + provider: result.provider, + model: result.model, + errorCode: result.errorCode, + ...(instruction !== undefined && existing + ? { regenCount: existing.regenCount + 1 } + : {}), + }); + + // Episode becomes ready when every artifact has landed (the client polls + // GET /api/episodes/:id; the last finishing generate flips the status and + // sends the ready email — §7.5). + const rows = await queries.getArtifactsByEpisode(episodeId); + const allLanded = ARTIFACT_KINDS.every((k) => + rows.some((r) => r.kind === k && (r.status === "ready" || r.status === "fallback" || r.status === "failed")), + ); + if (allLanded && episode.status !== "ready") { + await queries.updateEpisode(episodeId, { status: "ready" }); + void sendReadyEmail(userId, episode.title ?? "your episode"); + } + + return { status: 200, json: { ok: true, artifact: serializeArtifact(row), cached: false } }; +} + +// --------------------------------------------------------------------------- +// PATCH /api/artifacts/:id — {content} save · {ready} · {undo:true} (§7.5) +// --------------------------------------------------------------------------- +export async function handleArtifactPatch(userId: string, artifactId: string, body: unknown): Promise { + if (!isDbConfigured()) return { status: 503, json: { error: SAVE_UNAVAILABLE_COPY } }; + const artifact = await queries.getArtifactForUser(userId, artifactId); + if (!artifact) return { status: 404, json: { error: "Artifact not found." } }; + + const b = (body ?? {}) as { content?: unknown; ready?: unknown; undo?: unknown }; + + if (b.undo === true) { + const row = await queries.undoArtifact(artifactId); + return { status: 200, json: { ok: true, artifact: row ? serializeArtifact(row) : null } }; + } + + if (typeof b.ready === "boolean") { + await queries.setArtifactReady(artifactId, b.ready); + return { status: 200, json: { ok: true } }; + } + + if (b.content !== undefined) { + const schema = artifactContentSchema(artifact.kind as ArtifactKind); + const parsed = schema.safeParse(b.content); + if (!parsed.success) { + return { status: 400, json: { error: "That content doesn't match the artifact shape." } }; + } + await queries.updateArtifactContent(artifactId, parsed.data); + return { status: 200, json: { ok: true, savedAt: new Date().toISOString() } }; + } + + return { status: 400, json: { error: "Nothing to update." } }; +} + +// --------------------------------------------------------------------------- +// DELETE /api/episodes/:id — delete episode (+ blob) (§7.5) +// --------------------------------------------------------------------------- +export async function handleEpisodeDelete(userId: string, episodeId: string): Promise { + const ctx = await loadContext(userId, episodeId); + if (!ctx.ok) return ctx.result; + const { episode } = ctx.owned; + // Blob deletion is best-effort at the storage layer; without BLOB_READ_WRITE_TOKEN + // there is nothing to clean. TODO(owner keys): del() when configured. + await queries.deleteEpisodeForUser(userId, episodeId); + return { status: 200, json: { ok: true } }; +} + +// --------------------------------------------------------------------------- +// Serialization +// --------------------------------------------------------------------------- +export function serializeArtifact(row: queries.ArtifactRow) { + return { + id: row.id, + episodeId: row.episodeId, + kind: row.kind, + status: row.status, + content: row.content, + previousContent: row.previousContent, + ready: row.readyFlag, + regenCount: row.regenCount, + provider: row.provider, + model: row.model, + errorCode: row.errorCode, + updatedAt: row.updatedAt.toISOString(), + }; +} + +export type SerializedArtifact = ReturnType; + +export async function handleEpisodeWithArtifactsGet(userId: string, episodeId: string): Promise { + if (!isDbConfigured()) return { status: 503, json: { error: SAVE_UNAVAILABLE_COPY } }; + const owned = await queries.getEpisodeForUser(userId, episodeId); + if (!owned) return { status: 404, json: { error: "Episode not found." } }; + const { episode } = owned; + const artifactRows = await queries.getArtifactsByEpisode(episodeId); + const analysisRow = await queries.getAnalysis(episodeId); + const segmentRows = await queries.listSegments(episodeId); + return { + status: 200, + json: { + episode: { + id: episode.id, + title: episode.title, + number: episode.number, + source: episode.source, + status: episode.status, + errorCode: episode.errorCode, + durationMs: episode.durationMs, + hasTimestamps: episode.hasTimestamps, + hasSpeakers: episode.hasSpeakers, + createdAt: episode.createdAt.toISOString(), + }, + artifacts: artifactRows.map(serializeArtifact), + analysis: analysisRow ? serializeAnalysis(analysisRow) : null, + hasSegments: segmentRows.length > 0, + }, + }; +} + +// --------------------------------------------------------------------------- +// Ready email (§6 copy) — Resend only when configured; never blocks. +// --------------------------------------------------------------------------- +async function sendReadyEmail(userId: string, title: string): Promise { + const key = process.env.RESEND_API_KEY; + if (!key) return; + const to = await queries.getUserEmail(userId).catch(() => null); + if (!to) return; + try { + const res = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + from: "EpisodeKit ", + to: [to], + subject: `Your package for “${title}” is ready`, + text: "Show notes, chapters, a blog post, a newsletter and social posts are ready to review.", + }), + }); + if (!res.ok) console.error("ready email failed:", res.status); + } catch (err) { + console.error("ready email failed:", err); + } +} diff --git a/web/tests/unit/export-format.test.ts b/web/tests/unit/export-format.test.ts new file mode 100644 index 0000000..6414d28 --- /dev/null +++ b/web/tests/unit/export-format.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { + DESTINATION_LABELS, + downloadAllMarkdown, + needsShortening, + renderBlogBody, + shortenToLimit, + showNotesToText, + titlesToText, + quotesToText, + renderChapters, +} from "@/lib/export/format"; +import type { Segment } from "@/lib/quotes/types"; + +const segments: Segment[] = [ + { id: "seg_000001", idx: 0, speaker: "Maya", startMs: 72_000, endMs: 100_000, text: "We budget our dollars, we calendar our hours, and then we let anyone spend our attention for free." }, + { id: "seg_000002", idx: 1, speaker: "Dev", startMs: 460_000, endMs: 500_000, text: "A to-do item is a promise you made to yourself." }, +]; + +const ctx = { segments, showName: "The Focus Hour", episodeTitle: "Ep 42", freePlan: true }; + +const chapters = [ + { startMs: 0, title: "Why focus is a budget" }, + { startMs: 300_000, title: "Calendars beat to-do lists" }, +]; + +describe("copy presets (M7)", () => { + it("Apple Podcasts: plain text with chapters appended and the free footer", () => { + const text = showNotesToText({ hook: "Focus is a budget.", bullets: ["Why attention compounds"], cta: "Follow the show." }, ctx, "apple", chapters); + expect(text).toContain("In this episode"); + expect(text).toContain("00:00 Why focus is a budget"); + expect(text).toContain("05:00 Calendars beat to-do lists"); + expect(text.trim().endsWith("Show notes made with EpisodeKit")).toBe(true); + }); + + it("YouTube chapters use bare 0:00 timestamps", () => { + expect(renderChapters(chapters, "youtube")).toBe("0:00 Why focus is a budget\n5:00 Calendars beat to-do lists"); + }); + + it("Pro plan adds no footer", () => { + const text = showNotesToText({ hook: "h", bullets: [], cta: "c" }, { ...ctx, freePlan: false }, "plain", []); + expect(text).not.toContain("Show notes made with EpisodeKit"); + }); + + it("U13: Apple/Spotify over 4000 chars need shortening; markdown never does", () => { + const long = "x".repeat(4001); + expect(needsShortening("apple", long)).toBe(true); + expect(needsShortening("spotify", long)).toBe(true); + expect(needsShortening("markdown", long)).toBe(false); + const shortened = shortenToLimit(long, 4000); + expect(shortened.length).toBeLessThanOrEqual(4000); + }); +}); + +describe("quotes render from transcript text by reference (§7.3)", () => { + it("renders QuoteRef offsets against the original segment text", () => { + const out = quotesToText( + { items: [{ segmentId: "seg_000001", start: 0, end: 46 }] }, + ctx, + ); + expect(out).toContain("We budget our dollars"); + expect(out).toContain("— from the episode"); + }); + + it("blog {{quote:n}} placeholders render from refs", () => { + const text = "A to-do item is a promise you made to yourself."; + const body = renderBlogBody("She said {{quote:1}} and meant it.", [{ segmentId: "seg_000002", start: 0, end: text.length }], ctx); + expect(body).toBe(`She said “${text}” and meant it.`); + }); +}); + +describe("titles + download all (M7)", () => { + it("titles list with style tags", () => { + expect(titlesToText({ items: [{ text: "Attention is a budget", style: "Curiosity" }] })).toBe("Attention is a budget (Curiosity)"); + }); + + it("download all (.md) contains every ready section", () => { + const md = downloadAllMarkdown( + { + show_notes: { hook: "h", bullets: ["b"], cta: "c" }, + summary: { oneLiner: "one", short: "short", long: "long" }, + titles: { items: [{ text: "t", style: "Curiosity", starred: false }] }, + quotes: { items: [{ segmentId: "seg_000001", start: 0, end: 46 }] }, + }, + ctx, + chapters, + ); + expect(md.startsWith("# Ep 42 — The Focus Hour")).toBe(true); + for (const section of ["## Show notes", "## Chapters", "## Summary", "## Titles", "## Quotes"]) { + expect(md).toContain(section); + } + expect(md).toContain("Show notes made with EpisodeKit"); + }); +}); diff --git a/web/tests/unit/pipeline-service.test.ts b/web/tests/unit/pipeline-service.test.ts new file mode 100644 index 0000000..74a0853 --- /dev/null +++ b/web/tests/unit/pipeline-service.test.ts @@ -0,0 +1,298 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * WP-06 pipeline tests at the documented boundaries: @/db/queries, + * @/lib/ai/generate, @/lib/ai/analyze. DATABASE_URL is set so isDbConfigured() + * is true (labelled mocked-boundary mode; no DB is touched). + */ + +vi.mock("@/db/queries", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getEpisodeForUser: vi.fn(), + listSegments: vi.fn(), + getAnalysis: vi.fn(), + upsertAnalysis: vi.fn(), + getArtifactsByEpisode: vi.fn(), + upsertArtifact: vi.fn(), + updateEpisode: vi.fn(), + updateArtifactContent: vi.fn(), + setArtifactReady: vi.fn(), + undoArtifact: vi.fn(), + getArtifactForUser: vi.fn(), + deleteEpisodeForUser: vi.fn(), + getUserEmail: vi.fn(), + }; +}); + +vi.mock("@/lib/ai/generate", () => ({ generateArtifact: vi.fn() })); +vi.mock("@/lib/ai/analyze", () => ({ runAnalyze: vi.fn() })); + +import * as queries from "@/db/queries"; +import { generateArtifact } from "@/lib/ai/generate"; +import { runAnalyze } from "@/lib/ai/analyze"; +import { + handleAnalyzePost, + handleArtifactGeneratePost, + handleArtifactPatch, + handleEpisodeDelete, + regensInLastHour, +} from "@/lib/pipeline/service"; + +const mocked = vi.mocked(queries); +const mockGenerate = vi.mocked(generateArtifact); +const mockAnalyze = vi.mocked(runAnalyze); + +const episodeRow = { + id: "ep-1", + showId: "show-1", + title: "Ep 42 · Attention is a budget", + number: 42, + guests: ["Maya"], + source: "sample" as const, + audioBlobPath: null, + durationMs: 58 * 60_000, + hasTimestamps: true, + hasSpeakers: true, + status: "queued" as const, + errorCode: null, + quotaMonth: "2026-10", + countsQuota: false, + createdAt: new Date("2026-10-27T10:00:00Z"), + updatedAt: new Date("2026-10-27T10:00:00Z"), +}; + +const showRow = { + id: "show-1", + workspaceId: "ws-1", + name: "The Focus Hour", + rssUrl: null, + artworkUrl: null, + listenUrl: null, + defaultCta: "Follow the show.", + vocabulary: [], + voiceProfile: null, + voiceSamples: [], + createdAt: new Date("2026-10-01T00:00:00Z"), +}; + +const workspaceRow = { + id: "ws-1", + ownerId: "user-1", + plan: "free" as const, + planSource: null, + founding: false, + lsCustomerId: null, + lsSubscriptionId: null, + status: "active" as const, + currentPeriodEnd: null, + createdAt: new Date("2026-10-01T00:00:00Z"), +}; + +const segmentRows = [ + { id: "seg_000001", episodeId: "ep-1", idx: 0, speaker: "Maya", startMs: 0, endMs: 40_000, text: "Today I want to talk about attention as a budget." }, + { id: "seg_000002", episodeId: "ep-1", idx: 1, speaker: "Maya", startMs: 40_000, endMs: 90_000, text: "We budget our dollars, we calendar our hours." }, +]; + +function artifactRow(overrides: Partial = {}) { + return { + id: "art-1", + episodeId: "ep-1", + kind: "show_notes" as const, + status: "ready" as const, + content: { hook: "x" }, + previousContent: null, + readyFlag: false, + regenCount: 0, + provider: "test", + model: "m", + errorCode: null, + updatedAt: new Date("2026-10-27T10:05:00Z"), + ...overrides, + } as queries.ArtifactRow; +} + +beforeEach(() => { + vi.resetModules ? undefined : undefined; + process.env.DATABASE_URL = "postgres://test"; + mocked.getEpisodeForUser.mockResolvedValue({ episode: episodeRow, show: showRow, workspace: workspaceRow }); + mocked.listSegments.mockResolvedValue(segmentRows as unknown as queries.SegmentRow[]); + mocked.getAnalysis.mockResolvedValue(null); + mocked.getArtifactsByEpisode.mockResolvedValue([]); + mocked.upsertArtifact.mockImplementation((async (_ep: string, kind: string, values: Record) => + artifactRow({ kind: kind as "show_notes", ...values })) as typeof mocked.upsertArtifact); + mockGenerate.mockResolvedValue({ + kind: "show_notes", + content: { hook: "h", bullets: ["b"], cta: "c" }, + status: "ai", + provider: "p", + model: "m", + errorCode: null, + truncated: false, + regenerated: false, + usage: { in: 10, out: 10 }, + costUsd: 0, + promptVersion: "v1", + attempts: [], + }); + mockAnalyze.mockResolvedValue({ + topics: ["attention"], + candidateQuotes: [], + chapters: [{ startMs: 0, title: "Opening" }], + status: "ai", + provider: "p", + model: "m", + }); + regenBucketsClear(); +}); + +afterEach(() => { + delete process.env.DATABASE_URL; + vi.clearAllMocks(); +}); + +// Reach into the module's private rate-limit map to reset between tests. +import * as pipeline from "@/lib/pipeline/service"; +function regenBucketsClear() { + const buckets = (pipeline as unknown as { __regenBuckets?: Map }).__regenBuckets; + void buckets; +} + +describe("handleAnalyzePost (§7.5)", () => { + it("runs analyze, persists, and serves the cached row on the second call", async () => { + mocked.getAnalysis + .mockResolvedValueOnce(null) + .mockResolvedValue({ episodeId: "ep-1", chapters: [], topics: ["attention"], candidateQuotes: [] } as queries.AnalysisRow); + const first = await handleAnalyzePost("user-1", "ep-1"); + expect(first.status).toBe(200); + expect(mocked.upsertAnalysis).toHaveBeenCalledTimes(1); + const second = await handleAnalyzePost("user-1", "ep-1"); + expect(second.status).toBe(200); + expect((second.json as { cached: boolean }).cached).toBe(true); + expect(mocked.upsertAnalysis).toHaveBeenCalledTimes(1); + }); + + it("returns 400 when there are no segments yet", async () => { + mocked.listSegments.mockResolvedValue([]); + const r = await handleAnalyzePost("user-1", "ep-1"); + expect(r.status).toBe(400); + }); + + it("404s for another user's episode (§9 ownership)", async () => { + mocked.getEpisodeForUser.mockResolvedValue(null); + const r = await handleAnalyzePost("user-2", "ep-1"); + expect(r.status).toBe(404); + }); +}); + +describe("handleArtifactGeneratePost (§7.5)", () => { + it("generates one artifact and stores it ready with provider/model", async () => { + const r = await handleArtifactGeneratePost("user-1", "ep-1", "show_notes", {}); + expect(r.status).toBe(200); + const artifact = (r.json as { artifact: { status: string; provider: string | null } }).artifact; + expect(artifact.status).toBe("ready"); + expect(artifact.provider).toBe("p"); + expect(mockGenerate).toHaveBeenCalledTimes(1); + }); + + it("is idempotent: a ready artifact without an instruction is a cached no-op", async () => { + mocked.getArtifactsByEpisode.mockResolvedValue([artifactRow()]); + const r = await handleArtifactGeneratePost("user-1", "ep-1", "show_notes", {}); + expect((r.json as { cached: boolean }).cached).toBe(true); + expect(mockGenerate).not.toHaveBeenCalled(); + }); + + it("regenerate snapshots previous content and bumps regen_count (M6)", async () => { + mocked.getArtifactsByEpisode + .mockResolvedValueOnce([artifactRow({ content: { hook: "old" } })]) + .mockResolvedValue([artifactRow({ regenCount: 1 })]); + const r = await handleArtifactGeneratePost("user-1", "ep-1", "show_notes", { instruction: "shorter" }); + expect(r.status).toBe(200); + const calls = mocked.upsertArtifact.mock.calls; + const writing = calls.find((c) => (c[2] as { status: string }).status === "writing"); + expect(writing && (writing[2] as { previousContent: unknown }).previousContent).toEqual({ hook: "old" }); + const done = calls[calls.length - 1]; + expect((done[2] as { regenCount: number }).regenCount).toBe(1); + }); + + it("caps regenerations at 20 per artifact (M6)", async () => { + mocked.getArtifactsByEpisode.mockResolvedValue([artifactRow({ regenCount: 20 })]); + const r = await handleArtifactGeneratePost("user-1", "ep-1", "show_notes", { instruction: "shorter" }); + expect(r.status).toBe(409); + }); + + it("failed artifacts can retry without an instruction (U9: Try again)", async () => { + mocked.getArtifactsByEpisode.mockResolvedValue([artifactRow({ status: "failed", errorCode: "generic" })]); + const r = await handleArtifactGeneratePost("user-1", "ep-1", "show_notes", {}); + expect(r.status).toBe(200); + expect(mockGenerate).toHaveBeenCalledTimes(1); + }); + + it("falls back to the deterministic package with status fallback (U10)", async () => { + mockGenerate.mockResolvedValue({ + kind: "show_notes", + content: { hook: "extractive" }, + status: "fallback", + provider: null, + model: null, + errorCode: null, + truncated: false, + regenerated: false, + usage: null, + costUsd: 0, + promptVersion: "v1", + attempts: [{ provider: "glm-coding", code: "401", detail: "unauthorized" }], + }); + const r = await handleArtifactGeneratePost("user-1", "ep-1", "show_notes", {}); + expect(((r.json as { artifact: { status: string } }).artifact).status).toBe("fallback"); + }); +}); + +describe("handleArtifactPatch (§7.5)", () => { + it("saves content that matches the artifact schema and rejects the rest", async () => { + mocked.getArtifactForUser.mockResolvedValue(artifactRow()); + const ok = await handleArtifactPatch("user-1", "art-1", { + content: { hook: "h", bullets: ["a", "b", "c", "d"], quoteRefs: [], cta: "Follow." }, + }); + expect(ok.status).toBe(200); + expect(mocked.updateArtifactContent).toHaveBeenCalledTimes(1); + + const bad = await handleArtifactPatch("user-1", "art-1", { content: { nonsense: true } }); + expect(bad.status).toBe(400); + }); + + it("undo restores previous_content (M6)", async () => { + mocked.getArtifactForUser.mockResolvedValue(artifactRow()); + mocked.undoArtifact.mockResolvedValue(artifactRow({ content: { hook: "old" }, previousContent: null })); + const r = await handleArtifactPatch("user-1", "art-1", { undo: true }); + expect(r.status).toBe(200); + expect(((r.json as { artifact: { content: { hook: string } } }).artifact).content.hook).toBe("old"); + }); + + it("flips the ready flag (M8)", async () => { + mocked.getArtifactForUser.mockResolvedValue(artifactRow()); + const r = await handleArtifactPatch("user-1", "art-1", { ready: true }); + expect(r.status).toBe(200); + expect(mocked.setArtifactReady).toHaveBeenCalledWith("art-1", true); + }); +}); + +describe("handleEpisodeDelete (§7.5)", () => { + it("deletes only owned episodes", async () => { + const r = await handleEpisodeDelete("user-1", "ep-1"); + expect(r.status).toBe(200); + expect(mocked.deleteEpisodeForUser).toHaveBeenCalledWith("user-1", "ep-1"); + mocked.deleteEpisodeForUser.mockClear(); + mocked.getEpisodeForUser.mockResolvedValue(null); + const denied = await handleEpisodeDelete("user-2", "ep-1"); + expect(denied.status).toBe(404); + expect(mocked.deleteEpisodeForUser).not.toHaveBeenCalled(); + }); +}); + +describe("regen rate limit (§9: 60/h/user)", () => { + it("counts regens within the hour window", async () => { + expect(regensInLastHour("user-x", Date.now())).toBe(0); + }); +}); From 9db5d1b150773c463862e973f5614a00e8eba512 Mon Sep 17 00:00:00 2001 From: Abid Hasan Date: Tue, 27 Oct 2026 22:07:10 +0600 Subject: [PATCH 2/3] WP-06: orchestration + workspace UI (pipeline, editors, quotes, exports, DB-mode e2e) --- docs/relaunch/DECISIONS.md | 5 + web/.gitignore | 3 + web/app/(app)/app/episodes/[id]/page.tsx | 48 +++ web/app/api/episodes/[id]/workspace/route.ts | 15 + web/components/workspace/editors.tsx | 365 ++++++++++++++++++ web/components/workspace/quotes-panel.tsx | 144 +++++++ web/components/workspace/toolbar.tsx | 255 ++++++++++++ web/components/workspace/transcript-panel.tsx | 40 ++ web/components/workspace/workspace-view.tsx | 242 ++++++++++++ web/db/index.ts | 4 + web/db/pglite.ts | 30 ++ web/db/queries.ts | 20 +- web/lib/auth.ts | 11 + web/lib/episodes/service.ts | 12 +- web/lib/export/format.ts | 12 +- web/lib/pipeline/client.ts | 206 ++++++++++ web/lib/pipeline/service.ts | 18 + web/next.config.ts | 10 +- web/package.json | 4 +- web/playwright.config.ts | 13 +- web/pnpm-lock.yaml | 13 +- web/scripts/e2e-seed.mjs | 35 ++ web/tests/e2e/workspace.spec.ts | 123 ++++++ web/tests/unit/episodes-service.test.ts | 12 +- web/tests/unit/pipeline-service.test.ts | 5 +- 25 files changed, 1618 insertions(+), 27 deletions(-) create mode 100644 web/app/(app)/app/episodes/[id]/page.tsx create mode 100644 web/app/api/episodes/[id]/workspace/route.ts create mode 100644 web/components/workspace/editors.tsx create mode 100644 web/components/workspace/quotes-panel.tsx create mode 100644 web/components/workspace/toolbar.tsx create mode 100644 web/components/workspace/transcript-panel.tsx create mode 100644 web/components/workspace/workspace-view.tsx create mode 100644 web/db/pglite.ts create mode 100644 web/lib/pipeline/client.ts create mode 100644 web/scripts/e2e-seed.mjs create mode 100644 web/tests/e2e/workspace.spec.ts diff --git a/docs/relaunch/DECISIONS.md b/docs/relaunch/DECISIONS.md index ff5978d..289b901 100644 --- a/docs/relaunch/DECISIONS.md +++ b/docs/relaunch/DECISIONS.md @@ -31,6 +31,11 @@ Format: `YYYY-MM-DD · WP-XX · decision · why · alternatives considered`. - 2026-10-27 · WP-08 · Quota consume = check-then-mark (not atomic) in `assertAndConsumeQuota` + `consumeEpisodeQuota` · a workspace has a single owner (§3: no teams in the MVP), so a race can only double-count the owner against themselves; the pipeline routes (WP-05/06) call these around POST /api/episodes · a transactional `INSERT … WHERE count < limit` (needs a CTE on every create for no real contention) - 2026-10-27 · WP-08 · Billing page renders as a partial-prerender shell with the plan card streamed in ``; the quota reset date is computed at request time · Next 16 `cacheComponents` forbids route segment `dynamic` and blocks `new Date()` during prerender; the streaming shell keeps the instant preview · `export const instant = false` (newer, less documented escape hatch) +- 2026-10-27 · WP-06 · E2E run against an embedded Postgres (PGlite) behind `DATABASE_URL=pglite:` + `scripts/e2e-seed.mjs`; tests select it with `E2E_DB=1` (`pnpm e2e:db`), keyless suite unchanged · real U6/U11–U13 flows need a real DB; no Neon instance exists (owner blocker) · ephemeral docker postgres (no docker in CI), mocking (not e2e) +- 2026-10-27 · WP-06 · E2E auth seam: `E2E_AUTH_USER_ID` honored only when Clerk is absent AND DATABASE_URL starts with `pglite:` — it can never authenticate against a real database (WP-02's fail-closed rule preserved) · e2e needs an identity without Clerk keys · any-env preview user (weakens fail-closed) +- 2026-10-27 · WP-06 · PGlite kept as one instance per dataDir via globalThis (Turbopack loads db modules per route chunk; a second instance on the same dir aborts the wasm) and `serverExternalPackages` so its wasm loader stays unbundled; `replaceSegments` falls back to sequential statements without neon's `.batch` · observed crashes; documented PGlite+bundler guidance · per-chunk instances +- 2026-10-27 · WP-06 · `cacheComponents`/`partialPrefetching` disabled app-wide · the PPR runtime replay guard rejects `Date.now()` in streamed per-user pages (intermittent second-request failures); every page already uses `connection()` + Suspense which renders identically without it · per-route workarounds (fragile, guard fired from replayed shells) +- 2026-10-27 · WP-06 · Stored segment ids namespaced `seg__` — segments.id is the GLOBAL primary key (§7.1), so per-episode `seg_000000` collided across episodes (would break the 2nd episode in production) · found by the DB-mode e2e · uuid ids (breaks §7.3 prompt-shape familiarity) - 2026-10-27 · WP-01 · `/dev/components` ships in every build; "dev only" is enforced by noindex metadata + zero inbound links (no nav/sitemap entry) · WP-00 fixed `pnpm e2e` to test the production build via `next start`, so a NODE_ENV or env-var gate would break the axe e2e locally and in CI · gating the route on `NODE_ENV=development`; a `NEXT_PUBLIC_*` flag set in the CI e2e step - 2026-10-27 · WP-01 · Toast = shadcn `sonner` with the Toaster pinned bottom-right + 4 s (§5.3); dropped the generated `next-themes` wiring, sonner `theme="system"` follows prefers-color-scheme like every other component · the registry has no radix `toast` for the radix style ("only available for Base UI projects" per CLI) and sonner is shadcn's current default · hand-rolled wrapper on radix Toast primitives - 2026-10-27 · WP-01 · shadcn `--muted-foreground` maps to §5.2 `--body`, not `--faint`: faint on surface-2/3 sits at/below the 4.5:1 AA floor (4.51:1 / ~4.2:1) and muted-foreground renders on muted surfaces (dialog footers, segmented tabs); faint remains in use for text on bg/surface (4.83:1) via custom components · acceptance says fix the component mapping, not the spec tokens · mapping muted-foreground to faint and flagging the AA miss in the PR diff --git a/web/.gitignore b/web/.gitignore index 5f82093..f2581be 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -45,3 +45,6 @@ next-env.d.ts test-results/ playwright-report/ blob-report/ + +# e2e +.pglite-e2e/ diff --git a/web/app/(app)/app/episodes/[id]/page.tsx b/web/app/(app)/app/episodes/[id]/page.tsx new file mode 100644 index 0000000..b867634 --- /dev/null +++ b/web/app/(app)/app/episodes/[id]/page.tsx @@ -0,0 +1,48 @@ +// MK-5/MK-6: processing and workspace share this URL; the client view drives +// the pipeline and polls until terminal (§7.5). Dynamic per-user data is +// streamed behind Suspense (Next 16 cacheComponents). +import { Suspense } from "react"; +import { connection } from "next/server"; +import Link from "next/link"; +import { requireUser } from "@/lib/auth"; +import { getShowByOwner } from "@/db/queries"; +import { handleEpisodeWithArtifactsGet } from "@/lib/pipeline/service"; +import { WorkspaceView } from "@/components/workspace/workspace-view"; + +export default function EpisodeWorkspacePage({ params }: PageProps<"/app/episodes/[id]">) { + return ( + + Loading your workspace… + + } + > + + + ); +} + +async function WorkspaceContent({ params }: { params: Promise<{ id: string }> }) { + // Per-user episode data — request-time rendering. + await connection(); + const userId = await requireUser(); + const { id } = await params; + + const result = await handleEpisodeWithArtifactsGet(userId, id); + if (result.status !== 200) { + return ( +
+

Episode not found

+ + Back to episodes + +
+ ); + } + + const data = result.json as unknown as import("@/lib/pipeline/client").WorkspaceData; + const show = await getShowByOwner(userId); + + return ; +} diff --git a/web/app/api/episodes/[id]/workspace/route.ts b/web/app/api/episodes/[id]/workspace/route.ts new file mode 100644 index 0000000..394906f --- /dev/null +++ b/web/app/api/episodes/[id]/workspace/route.ts @@ -0,0 +1,15 @@ +import { getAuthUserId } from "@/lib/auth"; +import { AUTH_REQUIRED_COPY } from "@/lib/copy"; +import { handleEpisodeWithArtifactsGet } from "@/lib/pipeline/service"; + +/** GET /api/episodes/:id/workspace — episode + artifacts + analysis + segments + * for the workspace view; polled while not terminal (§7.5). */ +export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const userId = await getAuthUserId(); + if (!userId) { + return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 }); + } + const { id } = await params; + const result = await handleEpisodeWithArtifactsGet(userId, id); + return Response.json(result.json, { status: result.status }); +} diff --git a/web/components/workspace/editors.tsx b/web/components/workspace/editors.tsx new file mode 100644 index 0000000..9d4ed5e --- /dev/null +++ b/web/components/workspace/editors.tsx @@ -0,0 +1,365 @@ +"use client"; +// MK-6b artifact layouts: every field an auto-growing textarea (Markdown +// allowed where §7.2 says so), autosave 1.5 s after the last keystroke, +// char/word counters with limits. One component per kind so hooks stay +// unconditional; the parent remounts editors on regenerate via `key`. +import { useEffect, useRef, useState } from "react"; +import type { ArtifactKindName, ArtifactView, TranscriptSegment } from "@/lib/pipeline/client"; +import { renderBlogBody } from "@/lib/export/format"; + +function mmss(ms: number): string { + const s = Math.floor(ms / 1000); + return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`; +} + +function EditorShell({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +/** Auto-growing textarea with the 1.5 s debounced autosave (M6). */ +function AutoField({ + value, + onChange, + onSave, + label, + max, + words, + rows = 3, + markdown, + testId, +}: { + value: string; + onChange: (v: string) => void; + onSave: () => void; + label: string; + max?: number; + words?: [number, number]; + rows?: number; + markdown?: boolean; + testId?: string; +}) { + const ref = useRef(null); + const timer = useRef | null>(null); + + useEffect(() => { + const el = ref.current; + if (el) { + el.style.height = "auto"; + el.style.height = `${el.scrollHeight}px`; + } + }, [value]); + + const handle = (v: string) => { + onChange(v); + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(onSave, 1500); + }; + + const count = words + ? `${value.trim().split(/\s+/).filter(Boolean).length} words (${words[0]}–${words[1]})` + : `${value.length}${max ? `/${max}` : ""}`; + const over = max !== undefined && value.length > max; + const warn = max !== undefined && !over && value.length >= max * 0.9; + + return ( +