From 69282d8e5bafc7fa2b2ec78d189e2f4a4e14fdc6 Mon Sep 17 00:00:00 2001 From: Replit Agent Date: Wed, 10 Jun 2026 19:27:38 +0000 Subject: [PATCH 01/33] Saved progress at the end of the loop Replit-Commit-Author: Agent Replit-Commit-Session-Id: ce78e88c-3b59-434a-a0be-f5df68284735 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: a141a6cd-acf6-46c8-8c56-a4ca2ca24c4b Replit-Helium-Checkpoint-Created: true --- .replit | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .replit diff --git a/.replit b/.replit new file mode 100644 index 0000000..4150c68 --- /dev/null +++ b/.replit @@ -0,0 +1,6 @@ +[agent] +expertMode = true +stack = "BEST_EFFORT_FALLBACK" + +[nix] +channel = "stable-25_05" -- 2.50.1 From e7dfea1707ab3b0601e0154982d8088d8ebfa5a8 Mon Sep 17 00:00:00 2001 From: Replit Agent Date: Wed, 10 Jun 2026 19:43:21 +0000 Subject: [PATCH 02/33] Update application to support content generation and auditing Implement new API endpoints for ebook content generation, auditing, and assistant features. Refactor authentication and Dockerfile. Replit-Commit-Author: Agent Replit-Commit-Session-Id: ce78e88c-3b59-434a-a0be-f5df68284735 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 47560631-0ff4-4482-8ac3-a8b717717ad7 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/b66f3a83-9d75-4ea6-a964-8c0428c1487c/ce78e88c-3b59-434a-a0be-f5df68284735/lQ0AqkI Replit-Helium-Checkpoint-Created: true --- .dockerignore | 33 + .env.example | 23 + .github/copilot-instructions.md | 27 + .gitignore | 29 + .replit | 34 + Dockerfile | 29 + app/api/assistant/route.ts | 593 +++ app/api/auth/login/route.ts | 46 + app/api/auth/logout/route.ts | 15 + app/api/ebook/apply-audit/route.ts | 649 +++ app/api/ebook/architect/route.ts | 746 +++ app/api/ebook/assign-segments/route.ts | 103 + app/api/ebook/assistant/route.ts | 616 +++ app/api/ebook/audit/route.ts | 758 +++ app/api/ebook/backmatter/route.ts | 183 + app/api/ebook/chapter-plan/route.ts | 280 + app/api/ebook/coherence/route.ts | 122 + app/api/ebook/content-map/route.ts | 291 + app/api/ebook/export/route.ts | 109 + app/api/ebook/filter-signal/route.ts | 153 + app/api/ebook/format/route.ts | 165 + app/api/ebook/frontmatter/route.ts | 115 + app/api/ebook/heading-review/route.ts | 120 + app/api/ebook/polish/route.ts | 247 + app/api/ebook/publish/route.ts | 353 ++ app/api/ebook/quality-check/route.ts | 31 + app/api/ebook/sanitize/route.ts | 56 + app/api/ebook/voice-dna/route.ts | 188 + app/api/ebook/write-chapter/route.ts | 222 + app/api/ebook/write-section/route.ts | 997 ++++ app/api/fetch-youtube-transcript/route.ts | 199 + app/api/generate-logic/route.ts | 50 + app/api/generate-ui/route.ts | 35 + app/api/ingest/route.ts | 47 + app/api/produce/route.ts | 347 ++ app/api/projects/route.ts | 139 + app/api/r2-presign/route.ts | 56 + app/api/r2-upload/route.ts | 101 + .../document-extract/route.ts | 79 + app/api/sermon-assistant/route.ts | 166 + .../scripture-suggest/route.ts | 60 + .../transcribe-chunk/route.ts | 114 + app/api/sermon-assistant/translate/route.ts | 221 + app/api/transcribe-token/route.ts | 11 + app/api/transcribe/route.ts | 72 + app/api/voice/clone/finalize/route.ts | 238 + app/api/voice/clone/route.ts | 85 + app/api/voice/library-audio/route.ts | 79 + app/api/voice/narrate/finalize/route.ts | 295 + app/api/voice/narrate/route.ts | 83 + app/components/AssistantPanel.tsx | 629 +++ app/components/BlueprintViewer.tsx | 105 + app/components/EbookPipeline.tsx | 3704 +++++++++++++ app/components/EbookProgressRing.tsx | 73 + app/components/EbookProjectsPanel.tsx | 723 +++ app/components/GlobalMiniPlayer.tsx | 226 + app/components/MediaUpload.tsx | 432 ++ app/components/NexusNav.tsx | 289 + app/components/PipelineResults.tsx | 369 ++ app/components/ProjectCard.tsx | 79 + app/components/ProjectsPanel.tsx | 483 ++ app/components/PromptBar.tsx | 148 + app/components/ProseEditor.tsx | 639 +++ app/components/SermonAssistantPanel.tsx | 2034 +++++++ app/components/StatusBar.tsx | 75 + app/components/TerminalLog.tsx | 95 + app/components/VoiceStudio.tsx | 857 +++ app/ebook/page.tsx | 815 +++ app/global-error.tsx | 23 + app/globals.css | 96 + app/layout.tsx | 34 + app/library/LibraryGrid.tsx | 179 + app/library/MyListPanel.tsx | 129 + app/library/[slug]/ReadingListButton.tsx | 50 + app/library/[slug]/page.tsx | 314 ++ app/library/[slug]/read/AnnotationsPanel.tsx | 375 ++ app/library/[slug]/read/AudioReader.tsx | 935 ++++ app/library/[slug]/read/ChapterDrawer.tsx | 162 + app/library/[slug]/read/ProgressBar.tsx | 30 + app/library/[slug]/read/ReaderClient.tsx | 2178 ++++++++ app/library/[slug]/read/ReaderSettings.tsx | 215 + app/library/[slug]/read/page.tsx | 38 + app/library/layout.tsx | 12 + app/library/page.tsx | 285 + app/login/page.tsx | 121 + app/page.tsx | 1142 ++++ app/preview/learn/page.tsx | 791 +++ app/preview/page.tsx | 449 ++ app/translate/page.tsx | 1024 ++++ lib/ai-providers.ts | 30 + lib/audio-player-context.tsx | 735 +++ lib/book-templates.ts | 333 ++ lib/ebook-generator.tsx | 2220 ++++++++ lib/ebook-job-store.ts | 68 + lib/ebook-project-store.ts | 79 + lib/ebook-quality.ts | 244 + lib/editorial-style-bible.ts | 461 ++ lib/env.ts | 38 + lib/examples.ts | 137 + lib/glossary.json | 62 + lib/instruction-normalizer.ts | 191 + lib/project-store.ts | 122 + lib/r2-storage.ts | 67 + lib/reader-store.ts | 156 + lib/reading-list-store.ts | 53 + lib/schemas/academy.ts | 228 + lib/schemas/blueprint.ts | 80 + lib/schemas/ebook.ts | 500 ++ lib/schemas/published-book.ts | 28 + lib/schemas/site-config.ts | 36 + lib/schemas/ui-manifest.ts | 23 + lib/theme.ts | 212 + lib/types.ts | 19 + lib/video-store.ts | 80 + middleware.ts | 57 + next-env.d.ts | 6 + next.config.ts | 36 + package-lock.json | 4740 +++++++++++++++++ package.json | 42 + postcss.config.js | 8 + scripts/clear-library.mjs | 56 + scripts/ebook-quality-check.mjs | 58 + scripts/voice-server/Dockerfile | 37 + scripts/voice-server/handler.py | 353 ++ scripts/voice-server/requirements.txt | 5 + server.mjs | 32 + tailwind.config.js | 51 + tsconfig.json | 25 + 128 files changed, 41875 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .github/copilot-instructions.md create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app/api/assistant/route.ts create mode 100644 app/api/auth/login/route.ts create mode 100644 app/api/auth/logout/route.ts create mode 100644 app/api/ebook/apply-audit/route.ts create mode 100644 app/api/ebook/architect/route.ts create mode 100644 app/api/ebook/assign-segments/route.ts create mode 100644 app/api/ebook/assistant/route.ts create mode 100644 app/api/ebook/audit/route.ts create mode 100644 app/api/ebook/backmatter/route.ts create mode 100644 app/api/ebook/chapter-plan/route.ts create mode 100644 app/api/ebook/coherence/route.ts create mode 100644 app/api/ebook/content-map/route.ts create mode 100644 app/api/ebook/export/route.ts create mode 100644 app/api/ebook/filter-signal/route.ts create mode 100644 app/api/ebook/format/route.ts create mode 100644 app/api/ebook/frontmatter/route.ts create mode 100644 app/api/ebook/heading-review/route.ts create mode 100644 app/api/ebook/polish/route.ts create mode 100644 app/api/ebook/publish/route.ts create mode 100644 app/api/ebook/quality-check/route.ts create mode 100644 app/api/ebook/sanitize/route.ts create mode 100644 app/api/ebook/voice-dna/route.ts create mode 100644 app/api/ebook/write-chapter/route.ts create mode 100644 app/api/ebook/write-section/route.ts create mode 100644 app/api/fetch-youtube-transcript/route.ts create mode 100644 app/api/generate-logic/route.ts create mode 100644 app/api/generate-ui/route.ts create mode 100644 app/api/ingest/route.ts create mode 100644 app/api/produce/route.ts create mode 100644 app/api/projects/route.ts create mode 100644 app/api/r2-presign/route.ts create mode 100644 app/api/r2-upload/route.ts create mode 100644 app/api/sermon-assistant/document-extract/route.ts create mode 100644 app/api/sermon-assistant/route.ts create mode 100644 app/api/sermon-assistant/scripture-suggest/route.ts create mode 100644 app/api/sermon-assistant/transcribe-chunk/route.ts create mode 100644 app/api/sermon-assistant/translate/route.ts create mode 100644 app/api/transcribe-token/route.ts create mode 100644 app/api/transcribe/route.ts create mode 100644 app/api/voice/clone/finalize/route.ts create mode 100644 app/api/voice/clone/route.ts create mode 100644 app/api/voice/library-audio/route.ts create mode 100644 app/api/voice/narrate/finalize/route.ts create mode 100644 app/api/voice/narrate/route.ts create mode 100644 app/components/AssistantPanel.tsx create mode 100644 app/components/BlueprintViewer.tsx create mode 100644 app/components/EbookPipeline.tsx create mode 100644 app/components/EbookProgressRing.tsx create mode 100644 app/components/EbookProjectsPanel.tsx create mode 100644 app/components/GlobalMiniPlayer.tsx create mode 100644 app/components/MediaUpload.tsx create mode 100644 app/components/NexusNav.tsx create mode 100644 app/components/PipelineResults.tsx create mode 100644 app/components/ProjectCard.tsx create mode 100644 app/components/ProjectsPanel.tsx create mode 100644 app/components/PromptBar.tsx create mode 100644 app/components/ProseEditor.tsx create mode 100644 app/components/SermonAssistantPanel.tsx create mode 100644 app/components/StatusBar.tsx create mode 100644 app/components/TerminalLog.tsx create mode 100644 app/components/VoiceStudio.tsx create mode 100644 app/ebook/page.tsx create mode 100644 app/global-error.tsx create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/library/LibraryGrid.tsx create mode 100644 app/library/MyListPanel.tsx create mode 100644 app/library/[slug]/ReadingListButton.tsx create mode 100644 app/library/[slug]/page.tsx create mode 100644 app/library/[slug]/read/AnnotationsPanel.tsx create mode 100644 app/library/[slug]/read/AudioReader.tsx create mode 100644 app/library/[slug]/read/ChapterDrawer.tsx create mode 100644 app/library/[slug]/read/ProgressBar.tsx create mode 100644 app/library/[slug]/read/ReaderClient.tsx create mode 100644 app/library/[slug]/read/ReaderSettings.tsx create mode 100644 app/library/[slug]/read/page.tsx create mode 100644 app/library/layout.tsx create mode 100644 app/library/page.tsx create mode 100644 app/login/page.tsx create mode 100644 app/page.tsx create mode 100644 app/preview/learn/page.tsx create mode 100644 app/preview/page.tsx create mode 100644 app/translate/page.tsx create mode 100644 lib/ai-providers.ts create mode 100644 lib/audio-player-context.tsx create mode 100644 lib/book-templates.ts create mode 100644 lib/ebook-generator.tsx create mode 100644 lib/ebook-job-store.ts create mode 100644 lib/ebook-project-store.ts create mode 100644 lib/ebook-quality.ts create mode 100644 lib/editorial-style-bible.ts create mode 100644 lib/env.ts create mode 100644 lib/examples.ts create mode 100644 lib/glossary.json create mode 100644 lib/instruction-normalizer.ts create mode 100644 lib/project-store.ts create mode 100644 lib/r2-storage.ts create mode 100644 lib/reader-store.ts create mode 100644 lib/reading-list-store.ts create mode 100644 lib/schemas/academy.ts create mode 100644 lib/schemas/blueprint.ts create mode 100644 lib/schemas/ebook.ts create mode 100644 lib/schemas/published-book.ts create mode 100644 lib/schemas/site-config.ts create mode 100644 lib/schemas/ui-manifest.ts create mode 100644 lib/theme.ts create mode 100644 lib/types.ts create mode 100644 lib/video-store.ts create mode 100644 middleware.ts create mode 100644 next-env.d.ts create mode 100644 next.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 scripts/clear-library.mjs create mode 100644 scripts/ebook-quality-check.mjs create mode 100644 scripts/voice-server/Dockerfile create mode 100644 scripts/voice-server/handler.py create mode 100644 scripts/voice-server/requirements.txt create mode 100644 server.mjs create mode 100644 tailwind.config.js create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..04d8944 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,33 @@ +.git +.github +.next +node_modules +tsconfig.tsbuildinfo +.env.local +.env +ERROR +transferring + +# Next app source is not needed for the voice worker image. +app +lib +/\[auth\] +/\[internal\] + +# Keep only the voice worker assets that the Dockerfile copies. +scripts +!scripts/voice-server/ +!scripts/voice-server/handler.py + +# Root files not required by this image. +README.md +middleware.ts +next-env.d.ts +next.config.ts +package-lock.json +package.json +postcss.config.js +server.mjs +tailwind.config.js +tsconfig.json +.cursorrules \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4638345 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# Nexus Director — Environment Variables +# Copy this file to .env.local and fill in your keys before running the dev server. + +# ── AI Model Keys ─────────────────────────────────────────────────────────────── + +# Google Gemini (used by /api/ingest — multi-modal context engine) +GOOGLE_GENERATIVE_AI_API_KEY= + +# DeepSeek (used by /api/generate-logic — structural reasoning at 85% cost reduction) +DEEPSEEK_API_KEY= + +# Anthropic Claude (used by /api/generate-ui — visual manifest factory) +ANTHROPIC_API_KEY= + +# Deepgram (used by /api/transcribe — speech-to-text before ingest) +DEEPGRAM_API_KEY= + +# ── Model Overrides (optional) ────────────────────────────────────────────────── +# Defaults are set in lib/env.ts — override only when needed. + +# GEMINI_MODEL=gemini-2.0-flash +# DEEPSEEK_MODEL=deepseek-chat +# CLAUDE_MODEL=claude-haiku-4-5 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..8c70e88 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,27 @@ +# GitHub Copilot Cost-Efficiency & iPad Workspace Guardrails + +You must strictly adhere to these instructions to optimize token spending, minimize excessive code generation, and respect iPadOS mobile Safari constraints. + +## 1. Token Budget & Code Generation Discipline +* **Never rewrite entire files:** When providing updates or bug fixes, only output the specific changed functions or blocks of code. Do not reprint boilerplate or unmodified segments. +* **Skip conversational filler:** Omit polite prefaces, generic explanations, and conversational sign-offs. Provide clean, directly applicable code snippets immediately. +* **Write single-purpose functions:** Keep helper logic modular. Smaller functions prevent massive code context bloat during iterative prompt updates. + +## 2. Monolithic Clean-Architecture Boundaries +* **Strict Logic-UI Isolation:** All core business logic, API drivers, configuration parsing, and utility handlers must be written inside server-side directories (`/app/api/*` or `/lib/*`). +* **Pure Presentation Components:** Keep frontend UI files (`.tsx`) strictly focused on layout and data display. Never allow database queries, state machines, or direct backend utility functions to blend into visual component code. + +## 3. iPadOS Mobile Safari Ergonomics +Every time you generate or refactor a user interface component, you must enforce tablet-safe layouts AND mobile-first responsive design: +* **Always Mobile + Desktop:** Every UI change must include both mobile (default) and desktop (`lg:` prefix) responsive variants. Never design for desktop only. +* **Mobile navigation pattern:** The main nav is a fixed bottom bar on mobile (`lg:hidden`) and a left sidebar on desktop (`hidden lg:flex`). Preserve this pattern. +* **Main layout scroll:** On mobile, the main content area is `overflow-y-auto` (panels stack and scroll). On desktop (`lg:`), it is `overflow-hidden` with a grid layout (panels fill height and scroll internally). +* **Dynamic Viewports:** Use dynamic viewport utilities (`h-dvh`, `max-h-dvh`) instead of legacy screen-height classes (`h-screen`, `100vh`) to prevent Safari navigation bars from breaking layout boundaries. +* **Anti-Zoom Safeguards:** All interactive text fields, search boxes, and textareas must be set to a minimum font size of `text-base` (16px) to block iOS from auto-zooming the window. +* **Touch-Target Sizing:** Every button, tap region, or link menu must maintain a minimum hit boundary of 48x48px. +* **Zero Hover Reliance:** Never hide functional details or actions behind hover-only states (`hover:`). All interactive metrics or indicators must be displayed statically or triggered by explicit tap/click elements. +* **Bottom nav clearance:** Any fixed-bottom or sticky-bottom UI elements must account for the mobile bottom nav bar height (`pb-20 lg:pb-0` or `pb-[max(env(safe-area-inset-bottom),_3rem)]`). + +## 4. Deterministic Type & Error Handling +* Always validate incoming payloads or state maps using strict Zod schemas. +* Wrap integration functions in clean try/catch blocks to ensure processing failures gracefully surface error messages back to the layout without freezing the active workspace runtime. \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20603c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Dependencies +node_modules/ + +# Next.js +.next/ +out/ + +# Production build +build/ +dist/ + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Debug logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# OS +.DS_Store +Thumbs.db + +# Editor +.vscode/settings.json diff --git a/.replit b/.replit index 4150c68..a6b505e 100644 --- a/.replit +++ b/.replit @@ -1,6 +1,40 @@ +modules = ["nodejs-20"] [agent] expertMode = true stack = "BEST_EFFORT_FALLBACK" [nix] channel = "stable-25_05" + +[workflows] +runButton = "Project" + +[[workflows.workflow]] +name = "Project" +mode = "parallel" +author = "agent" + +[[workflows.workflow.tasks]] +task = "workflow.run" +args = "Start application" + +[[workflows.workflow]] +name = "Start application" +author = "agent" + +[[workflows.workflow.tasks]] +task = "shell.exec" +args = "npm run dev" +waitForPort = 5000 + +[workflows.workflow.metadata] +outputType = "webview" + +[[ports]] +localPort = 5000 +externalPort = 80 + +[deployment] +deploymentTarget = "autoscale" +run = ["node", "server.mjs"] +build = ["npm", "run", "build"] diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..aa97260 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +# Root Dockerfile — RunPod voice worker (XTTS v2) +# Slim deterministic build to reduce remote builder layer-commit failures. +FROM python:3.11-slim + +WORKDIR /app + +ENV COQUI_TOS_AGREED=1 +ENV XTTS_FORCE_CPU=1 +ENV PIP_NO_CACHE_DIR=1 +ENV PIP_DISABLE_PIP_VERSION_CHECK=1 +ENV PYTHONDONTWRITEBYTECODE=1 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ffmpeg \ + git \ + libsndfile1 \ + libgomp1 \ + && rm -rf /var/lib/apt/lists/* + +COPY scripts/voice-server/requirements.txt ./requirements.txt + +# Install CPU torch first so downstream dependencies reuse it. +RUN pip install --index-url https://download.pytorch.org/whl/cpu \ + torch==2.5.1 torchaudio==2.5.1 \ + && pip install -r requirements.txt + +COPY scripts/voice-server/handler.py ./handler.py + +CMD ["python", "-u", "handler.py"] diff --git a/app/api/assistant/route.ts b/app/api/assistant/route.ts new file mode 100644 index 0000000..f7d015d --- /dev/null +++ b/app/api/assistant/route.ts @@ -0,0 +1,593 @@ +import { generateObject } from "ai"; +import { NextRequest, NextResponse } from "next/server"; +import { createHash } from "crypto"; +import { deepSeekModel, deepSeekReasonerModel } from "@/lib/ai-providers"; +import { AcademyPackageSchema } from "@/lib/schemas/academy"; +import type { AcademyPackage } from "@/lib/schemas/academy"; +import { SiteConfigSchema } from "@/lib/schemas/site-config"; +import type { SiteConfig } from "@/lib/schemas/site-config"; +import { z } from "zod"; + +export const runtime = "nodejs"; +export const maxDuration = 120; + +const RequestSchema = z.object({ + academy: AcademyPackageSchema, + instruction: z.string().min(1).max(4000), + siteConfig: SiteConfigSchema.optional(), + dryRun: z.boolean().optional(), + academyVersion: z.string().optional(), + history: z.array(z.object({ + role: z.enum(["user", "assistant"]), + content: z.string().max(8000), + })).max(20).optional(), +}); + +// ── Patch schemas — AI returns ONLY changed fields, never the full academy. ── +// This keeps output well under DeepSeek's 8K token limit regardless of academy size. + +const LessonPatchSchema = z.object({ + lessonIndex: z.number().int().min(0), + operation: z.enum(["update", "delete"]).default("update").optional(), // Explicit delete op + title: z.string().optional(), + type: z.enum(["video", "reading", "quiz", "exercise"]).optional(), + durationMinutes:z.number().optional(), + description: z.string().optional(), + notes: z.string().optional(), + keyTakeaways: z.array(z.string()).optional(), + actionItems: z.array(z.string()).optional(), + quiz: z.array(z.object({ + q: z.string(), + options: z.array(z.string()), + correct: z.number().int().min(0).max(3), + })).optional(), +}); + +const ModulePatchSchema = z.object({ + moduleIndex: z.number().int().min(0), + operation: z.enum(["update", "delete", "insert"]).default("update").optional(), // Explicit ops + insertAfterIndex: z.number().int().min(-1).optional(), // For insert: -1 = beginning + moduleTitle: z.string().optional(), + moduleDescription: z.string().optional(), + learningObjectives:z.array(z.string()).optional(), + keyTerms: z.array(z.object({ term: z.string(), definition: z.string() })).optional(), + lessons: z.array(z.any()).optional(), // Full lessons array for new modules + lessonPatches: z.array(LessonPatchSchema).optional(), +}); + +const AcademyPatchSchema = z.object({ + academyName: z.string().optional(), + tagline: z.string().optional(), + targetAudience: z.string().optional(), + difficultyLevel: z.enum(["beginner", "intermediate", "advanced"]).optional(), + totalEstimatedHours: z.number().optional(), + certificateTitle: z.string().optional(), + themeVariant: z.enum(["midnight", "amber", "emerald", "rose", "violet", "solar"]).optional(), + layoutVariant: z.enum(["centered", "split", "minimal"]).optional(), + landingPage: z.object({ + headline: z.string().optional(), + subheadline: z.string().optional(), + problemStatement: z.string().optional(), + features: z.array(z.object({ title: z.string(), description: z.string() })).optional(), + cta: z.string().optional(), + }).optional(), + pricing: z.array(z.object({ + name: z.string(), + priceUsd: z.number(), + period: z.enum(["once", "monthly", "yearly"]), + features: z.array(z.string()), + })).optional(), + seoMeta: z.object({ + title: z.string().optional(), + description: z.string().optional(), + keywords: z.array(z.string()).optional(), + }).optional(), + onboardingSteps: z.array(z.string()).optional(), + curriculumPatches:z.array(ModulePatchSchema).optional(), +}); + +const PatchResponseSchema = z.object({ + academyPatch: AcademyPatchSchema.optional(), + siteConfigPatch: SiteConfigSchema.partial().optional(), + confidence: z.enum(["high", "medium", "low"]).default("high"), + clarificationNeeded: z.string().optional(), + summary: z.string(), +}); + +// ── Server-side merge helpers ──────────────────────────────────────────────── + +function applyAcademyPatch( + academy: AcademyPackage, + patch: z.infer, +): AcademyPackage { + const { curriculumPatches, landingPage, seoMeta, pricing, onboardingSteps, ...topLevel } = patch; + const updated: AcademyPackage = { ...academy, ...topLevel }; + + if (landingPage) updated.landingPage = { ...academy.landingPage, ...landingPage }; + // SAFEGUARD: Only replace arrays if they're non-empty or explicitly null + if (pricing !== undefined) { + if (pricing === null) updated.pricing = []; // explicit clear + else if (pricing.length > 0) updated.pricing = pricing; + // else: empty array in patch = ignored (prevents accidental wipes) + } + if (onboardingSteps !== undefined) { + if (onboardingSteps === null) updated.onboardingSteps = []; + else if (onboardingSteps.length > 0) updated.onboardingSteps = onboardingSteps; + } + if (seoMeta) updated.seoMeta = { ...academy.seoMeta, ...seoMeta }; + + if (curriculumPatches?.length) { + let curriculum = academy.curriculum.map((m) => ({ ...m, lessons: m.lessons.map((l) => ({ ...l })) })); + + // Sort patches: deletes last, inserts first, updates middle + const deletes = curriculumPatches.filter((p) => p.operation === "delete"); + const inserts = curriculumPatches.filter((p) => p.operation === "insert"); + const updates = curriculumPatches.filter((p) => !p.operation || p.operation === "update"); + + // 1. Apply inserts + for (const mp of inserts) { + const { insertAfterIndex = -1, lessons: newLessons, ...modFields } = mp; + if (!newLessons || newLessons.length === 0) continue; + const newModule = { ...modFields, lessons: newLessons } as typeof curriculum[0]; + if (insertAfterIndex === -1) curriculum.unshift(newModule); + else curriculum.splice(insertAfterIndex + 1, 0, newModule); + } + + // 2. Apply updates + for (const mp of updates) { + const { moduleIndex, lessonPatches, operation, insertAfterIndex, lessons: _, ...modFields } = mp; + if (moduleIndex < 0 || moduleIndex >= curriculum.length) continue; + Object.assign(curriculum[moduleIndex], modFields); + + if (lessonPatches) { + let lessons = curriculum[moduleIndex].lessons; + const lessonDeletes = lessonPatches.filter((lp) => lp.operation === "delete"); + const lessonUpdates = lessonPatches.filter((lp) => !lp.operation || lp.operation === "update"); + + // Apply lesson updates + for (const lp of lessonUpdates) { + const { lessonIndex, operation: _, ...lessonFields } = lp; + if (lessonIndex < 0 || lessonIndex >= lessons.length) continue; + Object.assign(lessons[lessonIndex], lessonFields); + } + + // Apply lesson deletes (reverse order to maintain indices) + for (const lp of lessonDeletes.sort((a, b) => b.lessonIndex - a.lessonIndex)) { + if (lp.lessonIndex >= 0 && lp.lessonIndex < lessons.length) { + lessons.splice(lp.lessonIndex, 1); + } + } + + curriculum[moduleIndex].lessons = lessons; + } + } + + // 3. Apply module deletes (reverse order to maintain indices) + for (const mp of deletes.sort((a, b) => b.moduleIndex - a.moduleIndex)) { + if (mp.moduleIndex >= 0 && mp.moduleIndex < curriculum.length) { + curriculum.splice(mp.moduleIndex, 1); + } + } + + updated.curriculum = curriculum; + } + + return AcademyPackageSchema.parse(updated); +} + +function applySiteConfigPatch( + base: SiteConfig, + patch: Partial, +): SiteConfig { + const merged = { ...base, ...patch }; + return SiteConfigSchema.parse(merged); +} + +export async function POST(req: NextRequest) { + const encoder = new TextEncoder(); + + let parsedInput: { academy: AcademyPackage; instruction: string; siteConfig?: SiteConfig } | undefined; + try { + const body = await req.json() as unknown; + parsedInput = RequestSchema.parse(body); + } catch (error) { + const message = error instanceof Error ? error.message : "Invalid request"; + return new Response(JSON.stringify({ error: message }), { status: 400, headers: { "Content-Type": "application/json" } }); + } + + const { academy, instruction, siteConfig } = parsedInput; + const dryRun = parsedInput.dryRun ?? false; + + // ── Optimistic locking ─────────────────────────────────────────────── + function computeAcademyVersion(a: typeof academy): string { + return createHash("sha256") + .update(JSON.stringify({ academyName: a.academyName, curriculum: a.curriculum })) + .digest("hex") + .slice(0, 12); + } + const currentVersion = computeAcademyVersion(academy); + if (parsedInput.academyVersion && parsedInput.academyVersion !== currentVersion) { + return new Response( + JSON.stringify({ error: "Conflict: the academy has been modified since you last loaded it. Please reload before editing.", code: "VERSION_CONFLICT" }), + { status: 409, headers: { "Content-Type": "application/json" } } + ); + } + // Build a module-level concept map before sending anything to the LLM. + // Each module OWNS its terms and lesson concepts — the AI must not redefine + // or re-explain a concept that already belongs to another module. + type ModuleEntry = { moduleIndex: number; moduleTitle: string; keyTerms: string[]; lessonTitles: string[]; objectives: string[] }; + const moduleLedger: ModuleEntry[] = academy.curriculum.map((mod, mi) => ({ + moduleIndex: mi, + moduleTitle: mod.moduleTitle, + keyTerms: (mod.keyTerms ?? []).map((kt) => kt.term), + lessonTitles: mod.lessons.map((l) => l.title), + objectives: (mod.learningObjectives ?? []).slice(0, 3), + })); + + // Detect explicitly referenced modules/lessons in the instruction so we can + // send their full notes (not the 120-char stub) to the LLM. + function parseExplicitModuleRefs(text: string): Set { + const refs = new Set(); + const patterns = [ + /\bmodule\s+(\d+)\s+lesson\s+(\d+)/gi, + /\bm(\d+)\s*l(\d+)\b/gi, + /\blesson\s+(\d+)\b/gi, + /\bmodule\s+(\d+)\b/gi, + ]; + for (const re of patterns) { + let m; + while ((m = re.exec(text)) !== null) { + if (m[2]) refs.add(`${Number(m[1]) - 1}:${Number(m[2]) - 1}`); + else if (m[1]) refs.add(`mod:${Number(m[1]) - 1}`); + } + } + return refs; + } + const history = parsedInput.history ?? []; + const historyText = history.map((m) => m.content).join(" "); + const explicitRefs = parseExplicitModuleRefs(instruction + " " + historyText); + + // Detect structural / high-reasoning operations that benefit from R1: + // Structural: add/remove/merge/split/reorder modules + // Academy-wide: objectives for all modules, notes for all lessons + // Curriculum audit: overlap/duplication analysis across modules + const isStructuralOp = /\b(add\s+a?\s*module|remove\s+module|delete\s+module|reorder\s+module|merge\s+module|split\s+(?:lesson|module)|restructure|reorganize|rearrange|full\s+rewrite|rewrite\s+all|complete\s+overhaul|add\s+(?:learning\s+)?objectives\s+to\s+all|add\s+(?:key\s+)?terms?\s+to\s+all|rewrite\s+(?:notes|lessons?)\s+for\s+all|expand\s+all\s+(?:notes|lessons?)|(?:check|identify|find|audit)\s+(?:overlap|duplicat|repeated\s+content|coverage)|across\s+all\s+modules|every\s+module)\b/i.test(instruction); + + // closing the connection while DeepSeek is generating the response. + const stream = new ReadableStream({ + async start(controller) { + const ping = setInterval(() => { + try { controller.enqueue(encoder.encode(": ping\n\n")); } catch { /* closed */ } + }, 15_000); + + try { + + // Trim lesson notes to a short stub before serialising — the model doesn't + // need to read 500-word prose to write a patch, and sending it bloats the + // prompt beyond DeepSeek's reliable JSON-generation threshold in production. + // EXCEPTION: explicitly referenced lessons are sent at full length. + const academyContext = { + ...academy, + curriculum: academy.curriculum.map((mod, mi) => ({ + ...mod, + lessons: mod.lessons.map((l, li) => { + const isExplicit = explicitRefs.has(`${mi}:${li}`) || explicitRefs.has(`mod:${mi}`); + return { + ...l, + notes: isExplicit + ? l.notes // full notes for explicitly named lessons + : l.notes ? l.notes.slice(0, 120) + (l.notes.length > 120 ? "…" : "") : "", + }; + }), + })), + }; + + // ── Concept Ownership Block ─────────────────────────────────────────── + // Built from the live academy — injected into the system prompt so the LLM + // knows what each module owns and cannot duplicate across modules. + const conceptOwnershipBlock = moduleLedger.length > 1 + ? `\n\n════════════════════════════════════════════ +CONCEPT OWNERSHIP MAP — NON-NEGOTIABLE +════════════════════════════════════════════ +Each module OWNS its key terms and lesson concepts. Do NOT redefine, re-explain, or introduce in another module any concept that already belongs to a different module. +${moduleLedger.map((e) => + `Module ${e.moduleIndex + 1} "${e.moduleTitle}" OWNS:\n Terms: ${e.keyTerms.length ? e.keyTerms.join(", ") : "(none)"}\n Lessons: ${e.lessonTitles.join(" | ")}` +).join("\n")}` + : ""; + + // ── Conversation history block ──────────────────────────────────────── + const historyBlock = history.length > 0 + ? `\n\nCONVERSATION HISTORY (oldest first — use this for follow-up instructions):\n${history.map((m) => `${m.role === "user" ? "USER" : "DIRECTOR"}: ${m.content}`).join("\n")}` + : ""; + + // Route structural operations to R1 (reasoning model) — they require + // multi-step thinking about concept ownership and curriculum coherence. + const selectedModel = isStructuralOp ? deepSeekReasonerModel : deepSeekModel; + + const { object } = await generateObject({ + model: selectedModel, + schema: PatchResponseSchema, + schemaName: "AcademyPatch", + schemaDescription: "Patch object describing only the fields that need to change in the academy and/or site config.", + mode: "json", + maxTokens: 6000, + temperature: 0.1, + system: `IMPORTANT: Output ONLY a raw JSON object. No code fences, no markdown, no commentary before or after the JSON. + +You are the Nexus Director AI — a precise, powerful academy editor with full control over every aspect of the academy content, visual presentation, and website configuration. + +Analyse the user's instruction carefully, determine what needs to change, then return only the updated objects. + +════════════════════════════════════════════ +CRITICAL: SURGICAL EDITS ONLY +════════════════════════════════════════════ +⚠️ NEVER delete, remove, or modify content unless the user EXPLICITLY requests it with clear language like: + - "delete module 2" + - "remove the lesson about X" + - "clear the pricing tiers" + +⚠️ When the user says "add", "update", "improve", "rewrite", "expand", or "fix" content: + - ONLY modify the SPECIFIC item they mention + - PRESERVE all other modules, lessons, and fields untouched + - Do NOT reorder, restructure, or reorganize unless explicitly told to + +⚠️ If the instruction is vague or could affect multiple items: + - Choose the MOST CONSERVATIVE interpretation + - Modify the LEAST amount of content possible + - When in doubt, ask for clarification using the "clarificationNeeded" field + +════════════════════════════════════════════ +ACADEMY CONTENT CHANGES +════════════════════════════════════════════ +Triggers: anything about curriculum, modules, lessons, notes, quizzes, takeaways, difficulty, theme, layout, hours, certificate + +CURRICULUM OPERATIONS: +- "add a module" → Use operation="insert" with full module data (moduleTitle, lessons, learningObjectives, keyTerms) +- "remove/delete module X" → Use operation="delete" on that specific moduleIndex ONLY (requires explicit "delete" or "remove" in instruction) +- "reorder modules" → Return full curriculumPatches array with all modules in new order (requires explicit "reorder" in instruction) +- "add a lesson to module X" → Update that module's lessons array by appending new lesson +- "merge modules X and Y" → Delete one module, update the other with combined lessons (requires explicit "merge" in instruction) +- "split lesson X" → Replace one lesson with two new lessons (requires explicit "split" in instruction) + +LESSON OPERATIONS: +- "rewrite notes for lesson X" → Update ONLY that specific lesson's notes field with full, dense markdown +- "add key takeaways to lesson X" → Update ONLY that lesson's keyTakeaways field (5–7 items from existing notes) +- "add action items to all lessons" → Update keyTakeaways for ALL lessons (but preserve all other lesson fields) +- "add quiz questions to lesson X" → Update ONLY that lesson's quiz field +- "add learning objectives to module X" → Update ONLY that module's learningObjectives field +- "expand the glossary for module X" → Update ONLY that module's keyTerms field (add 4–8 new entries, preserve existing) +- "format all notes" → Update ONLY the notes field for each lesson with proper markdown structure (preserve all other fields) +- "improve the notes for lesson X" → Update ONLY that specific lesson's notes field +- "delete lesson X from module Y" → Use operation="delete" on that specific lessonIndex (requires explicit "delete" or "remove" in instruction) + +⚠️ LESSON EDIT RULE: When updating a lesson, include ONLY the lessonIndex and the specific fields you're changing. + Do NOT include unchanged fields like title, type, durationMinutes unless explicitly modifying them. + +NOTES FORMAT STANDARD (when writing or reformatting notes): + # [Lesson Title] + ## [Major Concept 1] + Full teaching prose (2–4 paragraphs). Ground in source material. Use: + - **bold** for key terms + - *italics* for titles/names + - > blockquotes for key statements + - Numbered lists for sequences/frameworks + - Bullet lists for supporting points + - --- for major thematic breaks + ## [Major Concept 2] + ## Key Principles (synthesise the lesson's core insights) + +VISUAL / PRESENTATION CHANGES: +- themeVariant: Set to "midnight" | "amber" | "emerald" | "rose" | "violet" | "solar" + Respond to: "change the theme", "use amber colours", "make it feel more [adjective]", "switch to [colour] theme" + Guidance: midnight=tech/dark, amber=business/faith/warm, emerald=health/wellness, rose=personal dev, violet=programming/design, solar=beginner/broad +- layoutVariant: "centered" | "split" | "minimal" + Respond to: "split the hero", "minimal layout", "centered design" +- difficultyLevel: "beginner" | "intermediate" | "advanced" +- totalEstimatedHours: Update when adding/removing content +- certificateTitle: Rename the completion certificate + +METADATA CHANGES: +- academyName / tagline: Rebrand or rename the academy +- targetAudience: Refine the ideal student description +- seoMeta: Update title, description, keywords +- onboardingSteps: Modify the getting-started flow +- pricing: Adjust tier names, prices, periods, feature lists + +════════════════════════════════════════════ +SITE CONFIG CHANGES +════════════════════════════════════════════ +Triggers: anything about the landing page, website, social, footer, banner, instructor, testimonials, FAQ, CTA button + +- testimonials: Add/edit student testimonials — each needs name, role, quote, rating (1–5) + Respond to: "add testimonials", "add 3 student reviews", "add social proof" +- faqItems: Add/edit FAQ questions and answers + Respond to: "add FAQ", "add common questions" +- instructorBio: Set instructor name, professional title, bio paragraph, avatarInitials (2 uppercase letters) + Respond to: "add instructor bio", "set the instructor details" +- announcementBar: Short bold top-of-page banner (max 80 chars) + Respond to: "add a banner", "add announcement", "add urgency" +- ctaOverride: Override all CTA button text sitewide + Respond to: "change the button text", "update the CTA" +- socialLinks: Set website, twitter, youtube, instagram, linkedin (full https:// URLs only) + Respond to: "add social links", "set social media" +- footerText: Custom copyright/tagline in the footer + +════════════════════════════════════════════ +OUTPUT RULES — PRESERVATION IS MANDATORY +════════════════════════════════════════════ +⚠️ CRITICAL PRESERVATION RULES: +1. Return "academyPatch" ONLY if academy content needs to change +2. Return "siteConfigPatch" ONLY if site config needs to change +3. Return BOTH if both need to change +4. NEVER include a field in your patch unless you are CHANGING that specific field +5. NEVER return empty arrays for pricing/onboardingSteps unless the user explicitly said "clear" or "remove" +6. NEVER set operation="delete" unless the user explicitly said "delete", "remove", or "clear" +7. For curriculum patches: include ONLY the moduleIndex and the fields you're modifying for that module +8. For lesson patches: include ONLY the lessonIndex and the fields you're modifying for that lesson +9. If you're unsure whether the user wants to delete something, set clarificationNeeded and confidence="low" + +⚠️ WHAT TO INCLUDE IN YOUR PATCH: +- Scalar fields (academyName, themeVariant, etc.) → include ONLY if the user wants to change them +- curriculumPatches → include ONLY modules you're adding, updating, or deleting (with explicit operation field) +- lessonPatches → include ONLY lessons you're adding, updating, or deleting within a module +- pricing / onboardingSteps → include ONLY if the user explicitly asks to change/add/replace them (never empty arrays) +- seoMeta → include ONLY the specific seo fields being changed (title, description, keywords) + +⚠️ DELETION OPERATIONS: +- To DELETE a module: { moduleIndex: X, operation: "delete" } — ONLY if user explicitly said "delete/remove module X" +- To DELETE a lesson: { lessonIndex: Y, operation: "delete" } — ONLY if user explicitly said "delete/remove lesson Y" +- NEVER delete content based on implied intent, vague instructions, or assumptions + +Always write a concise "summary" of exactly what you changed (not what you preserved). +Never invent facts not in the existing academy or user instruction. +If ambiguous, choose the most conservative interpretation that modifies the LEAST content. + +════════════════════════════════════════════ +OUTPUT FORMAT — patch only what changes +════════════════════════════════════════════ +Return "academyPatch" with ONLY the fields that need to change: +- Scalar fields (academyName, themeVariant, difficultyLevel, etc.) — include ONLY if changing +- curriculumPatches: array of module operations: + * To UPDATE a module: { moduleIndex, [changed fields], lessonPatches: [...] } + * To DELETE a module: { moduleIndex, operation: "delete" } (explicit user request only!) + * To INSERT a module: { operation: "insert", insertAfterIndex, moduleTitle, lessons: [...full lessons...], ... } + * lessonPatches within a module: + - To UPDATE: { lessonIndex, [changed fields] } + - To DELETE: { lessonIndex, operation: "delete" } (explicit user request only!) +- pricing: include full replacement array ONLY if user explicitly asks to change pricing (never empty unless user said "clear pricing") +- onboardingSteps: include full replacement array ONLY if user explicitly asks to change onboarding (never empty unless user said "clear onboarding") +- seoMeta: include ONLY the seo fields being changed (e.g., just {title: "New Title"} if only title changes) +- landingPage: include ONLY the landing page fields being changed + +Return "siteConfigPatch" with ONLY the site config keys that need to change (never include unchanged keys). + +Always include: +- "summary": one concise sentence describing what you changed +- "confidence": "high" | "medium" | "low" +- "clarificationNeeded": (optional) if the instruction is ambiguous or you need confirmation for a destructive operation + +CONCEPT OWNERSHIP: The CONCEPT OWNERSHIP MAP is a hard constraint — do not duplicate terms/concepts across modules.`, + prompt: [ + "CURRENT ACADEMY (notes trimmed for brevity — explicitly named lessons sent at full length):", + JSON.stringify(academyContext), + "", + "CURRENT SITE CONFIG:", + JSON.stringify(siteConfig ?? {}), + conceptOwnershipBlock, + historyBlock, + "", + "USER INSTRUCTION:", + instruction, + ].join("\n"), + }); + + clearInterval(ping); + + // ── Validation: detect destructive operations ──────────────────────── + const warnings: string[] = []; + if (object.academyPatch?.curriculumPatches) { + const moduleDeletes = object.academyPatch.curriculumPatches.filter(p => p.operation === "delete"); + if (moduleDeletes.length > 0) { + warnings.push(`⚠️ Deleting ${moduleDeletes.length} module(s): ${moduleDeletes.map(p => `Module ${p.moduleIndex + 1}`).join(", ")}`); + } + for (const mp of object.academyPatch.curriculumPatches) { + if (mp.lessonPatches) { + const lessonDeletes = mp.lessonPatches.filter(lp => lp.operation === "delete"); + if (lessonDeletes.length > 0) { + warnings.push(`⚠️ Deleting ${lessonDeletes.length} lesson(s) from Module ${mp.moduleIndex + 1}`); + } + } + } + } + if (object.academyPatch?.pricing !== undefined && (!object.academyPatch.pricing || object.academyPatch.pricing.length === 0)) { + warnings.push("⚠️ Clearing pricing information"); + } + if (object.academyPatch?.onboardingSteps !== undefined && (!object.academyPatch.onboardingSteps || object.academyPatch.onboardingSteps.length === 0)) { + warnings.push("⚠️ Clearing onboarding steps"); + } + + // ── Dry-run: return patch without applying ─────────────────────────── + if (dryRun) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ + dryRun: true, + patch: object.academyPatch, + siteConfigPatch: object.siteConfigPatch, + summary: object.summary, + confidence: object.confidence, + ...(warnings.length > 0 && { warnings }), + ...(object.clarificationNeeded && { clarificationNeeded: object.clarificationNeeded }), + academyVersion: currentVersion, + })}\n\n`)); + controller.close(); + return; + } + + // ── Confidence gate ───────────────────────────────────────────── + if (object.confidence === "low" && object.clarificationNeeded) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ + needsClarification: true, + clarificationNeeded: object.clarificationNeeded, + summary: object.summary, + confidence: object.confidence, + ...(warnings.length > 0 && { warnings }), + academyVersion: currentVersion, + })}\n\n`)); + controller.close(); + return; + } + + // Merge patches server-side and return full objects to the client + let updatedAcademy: AcademyPackage | undefined; + let updatedSiteConfig: SiteConfig | undefined; + + if (object.academyPatch) { + updatedAcademy = applyAcademyPatch(academy, object.academyPatch); + } + + if (object.siteConfigPatch) { + updatedSiteConfig = applySiteConfigPatch( + siteConfig ?? SiteConfigSchema.parse({}), + object.siteConfigPatch, + ); + } + + // ── Append audit trail entry ─────────────────────────────────────────── + if (updatedAcademy) { + const entry = { + timestamp: new Date().toISOString(), + instruction: instruction.slice(0, 200), + summary: object.summary, + model: (isStructuralOp ? "r1" : "v3") as "r1" | "v3", + }; + const existing = (academy.changeLog ?? []) as typeof entry[]; + updatedAcademy = { ...updatedAcademy, changeLog: [...existing, entry].slice(-50) }; + } + + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ + academy: updatedAcademy, + siteConfig: updatedSiteConfig, + summary: object.summary, + confidence: object.confidence, + academyVersion: updatedAcademy ? computeAcademyVersion(updatedAcademy) : currentVersion, + ...(warnings.length > 0 && { warnings }), + ...(object.clarificationNeeded && { clarificationNeeded: object.clarificationNeeded }), + })}\n\n`)); + } catch (error) { + clearInterval(ping); + const message = error instanceof Error ? error.message : "Assistant failed"; + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ error: message })}\n\n`)); + } finally { + controller.close(); + } + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + }); +} diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..f46547a --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,46 @@ +import { createHmac } from "crypto"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +const COOKIE_NAME = "nexus_session"; +const ONE_YEAR_SECONDS = 60 * 60 * 24 * 365; + +const BodySchema = z.object({ password: z.string().min(1) }); + +export async function POST(request: Request) { + const authPassword = process.env.AUTH_PASSWORD; + const cookieSecret = process.env.AUTH_COOKIE_SECRET; + + if (!authPassword || !cookieSecret) { + return NextResponse.json({ error: "Auth not configured on server" }, { status: 500 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); + } + + const parsed = BodySchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: "Invalid request" }, { status: 400 }); + } + + if (parsed.data.password !== authPassword) { + // Constant-time compare would be ideal; for a single-user personal tool this is fine + return NextResponse.json({ error: "Incorrect password" }, { status: 401 }); + } + + const token = createHmac("sha256", cookieSecret).update(authPassword).digest("hex"); + + const res = NextResponse.json({ ok: true }); + res.cookies.set(COOKIE_NAME, token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + maxAge: ONE_YEAR_SECONDS, + path: "/", + }); + return res; +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..5541d5a --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; + +const COOKIE_NAME = "nexus_session"; + +export async function POST() { + const res = NextResponse.json({ ok: true }); + res.cookies.set(COOKIE_NAME, "", { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + maxAge: 0, + path: "/", + }); + return res; +} diff --git a/app/api/ebook/apply-audit/route.ts b/app/api/ebook/apply-audit/route.ts new file mode 100644 index 0000000..25cf049 --- /dev/null +++ b/app/api/ebook/apply-audit/route.ts @@ -0,0 +1,649 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateText } from "ai"; +import { deepSeekReasonerModel } from "@/lib/ai-providers"; +import { z } from "zod"; +import { VoiceDNASchema } from "@/lib/schemas/ebook"; +import { SOURCE_LOCK_RULES } from "@/lib/editorial-style-bible"; + +type VoiceDNAType = z.infer; + +export const runtime = "nodejs"; +export const maxDuration = 120; + +// ─── Strategy ───────────────────────────────────────────────────────────────── +// Tier 1 (no LLM) — pure string manipulation, zero risk of unintended rewrites: +// r-N repeated phrases → replace 2nd+ occurrences with alternatives +// w-N overused words → replace every other occurrence with alternatives +// +// Tier 2 (targeted LLM) — only the ONE affected section body is sent: +// c-N concept duplicate → revise just that section per the recommendation +// p-N similar pair → rewrite just the section in this chapter to +// differentiate from its pair +// +// Sections that have NO applied findings are NEVER touched. + +// ─── Input Schemas ──────────────────────────────────────────────────────────── + +const SectionSchema = z.object({ + sectionNumber: z.number(), + heading: z.string(), + body: z.string().default(""), + wordCount: z.number().default(0), + status: z.string().default("complete"), +}); + +const ChapterSchema = z.object({ + number: z.number(), + title: z.string().default(""), + intro: z.string().default(""), + sections: z.array(SectionSchema), + conclusion: z.string().default(""), + keyTakeaways: z.array(z.string()).default([]), + reflectionQuestions: z.array(z.string()).default([]), + totalWordCount: z.number().default(0), + status: z.string().default("complete"), +}); + +const RequestSchema = z.object({ + manifest: z.object({ chapters: z.array(ChapterSchema) }), + report: z.object({ + conceptDuplicates: z.array(z.object({ + type: z.string(), + title: z.string(), + description: z.string(), + severity: z.string(), + locations: z.array(z.object({ location: z.string(), excerpt: z.string().optional().nullable() })), + recommendation: z.string(), + })).default([]), + similarPairs: z.array(z.object({ + locationA: z.string(), + locationB: z.string(), + similarity: z.number(), + excerptA: z.string().default(""), + excerptB: z.string().default(""), + })).default([]), + repetitions: z.array(z.object({ + phrase: z.string(), + count: z.number(), + occurrences: z.array(z.object({ + chapterNumber: z.number(), + sectionNumber: z.number().optional().nullable(), + })), + alternatives: z.array(z.string()).default([]), + })).default([]), + overusedWords: z.array(z.object({ + word: z.string(), + count: z.number(), + frequency: z.string().default(""), + alternatives: z.array(z.string()).default([]), + })).default([]), + }), + appliedKeys: z.array(z.string()), + voiceDNA: VoiceDNASchema.optional().nullable(), +}); + +type ChapterInput = z.infer; +type ReportInput = z.infer["report"]; + +// ─── Location parser ────────────────────────────────────────────────────────── + +function parseLocation(loc: string): { chapterNum: number | null; sectionNum: number | null } { + // Matches: "Chapter N", "Ch N", "Ch. N" (audit emits "Ch N § M: Heading") + const ch = /\bch(?:apter)?\.?\s+(\d+)/i.exec(loc); + // Matches: "Section N", "§ N", "§N" + const sc = /(?:\bsection\s+|§\s*)(\d+)/i.exec(loc); + return { + chapterNum: ch ? parseInt(ch[1]) : null, + sectionNum: sc ? parseInt(sc[1]) : null, + }; +} + +// ─── Tier 1 helpers — deterministic string fixes ────────────────────────────── + +function escapeRegex(s: string) { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** Replace 2nd+ occurrences of `phrase` across a block of text with alternatives (cycling). */ +function applyPhraseVariation(text: string, phrase: string, alternatives: string[]): string { + if (!alternatives.length || !text) return text; + const regex = new RegExp(escapeRegex(phrase), "gi"); + let hit = 0; + return text.replace(regex, (match) => { + hit++; + if (hit === 1) return match; // first occurrence stays + const alt = alternatives[(hit - 2) % alternatives.length]; + // mirror capitalisation of the original match + return match[0] === match[0].toUpperCase() + ? alt.charAt(0).toUpperCase() + alt.slice(1) + : alt; + }); +} + +/** Replace every other occurrence (2nd, 4th, …) of `word` across a text block with alternatives. */ +function applyWordVariation(text: string, word: string, alternatives: string[]): string { + if (!alternatives.length || !text) return text; + const regex = new RegExp(`\\b${escapeRegex(word)}\\b`, "gi"); + let hit = 0; + return text.replace(regex, (match) => { + hit++; + if (hit % 2 === 1) return match; // odd occurrences stay + const alt = alternatives[Math.floor((hit - 2) / 2) % alternatives.length]; + return match[0] === match[0].toUpperCase() + ? alt.charAt(0).toUpperCase() + alt.slice(1) + : alt; + }); +} + +/** Apply a text transformation to every prose field in a chapter's sections, intro, conclusion. */ +function mapChapterText( + chapter: ChapterInput, + transform: (text: string) => string, +): ChapterInput { + return { + ...chapter, + intro: transform(chapter.intro), + conclusion: transform(chapter.conclusion), + sections: chapter.sections.map((s) => { + const body = transform(s.body); + return { ...s, body, wordCount: body.split(/\s+/).filter(Boolean).length }; + }), + }; +} + +// ─── Tier 2 helper — targeted single-section LLM rewrite ───────────────────── + +async function reviseSectionBody( + heading: string, + body: string, + task: string, + voiceDNA?: VoiceDNAType | null, +): Promise { + // Compact, human-readable voice block to keep surgical edits in the author's voice + const voiceDnaBlock = voiceDNA + ? ((): string => { + const lines: string[] = ["\nAUTHOR VOICE DNA — maintain throughout:"]; + if (voiceDNA.toneProfile) lines.push(`- Tone: ${voiceDNA.toneProfile}`); + if (voiceDNA.vocabularyLevel) lines.push(`- Register: ${voiceDNA.vocabularyLevel}`); + if (voiceDNA.sentencePattern) lines.push(`- Sentence rhythm: ${voiceDNA.sentencePattern}`); + if (voiceDNA.pacingFingerprint) lines.push(`- Pacing: ${voiceDNA.pacingFingerprint}`); + if ((voiceDNA.avoidWords ?? []).length > 0) + lines.push(`- Forbidden words (zero tolerance): ${voiceDNA.avoidWords.slice(0, 15).join(", ")}`); + if ((voiceDNA.avoidStructures ?? []).length > 0) + lines.push(`- Forbidden structures: ${(voiceDNA.avoidStructures ?? []).join("; ")}`); + if ((voiceDNA.signaturePhrases ?? []).length > 0) + lines.push(`- Signature phrases (use naturally): ${voiceDNA.signaturePhrases.join(", ")}`); + return lines.join("\n"); + })() + : ""; + + const originalWordCount = body.split(/\s+/).filter(Boolean).length; + const minWords = Math.floor(originalWordCount * 0.98); // 98% minimum (was 92%) + const maxWords = Math.ceil(originalWordCount * 1.02); // 102% maximum (was 108%) + // Allow ~2 tokens per word (generous) + headroom, minimum 2048 + const maxTokens = Math.max(2048, originalWordCount * 3); + + let text = ""; + try { + const result = await generateText({ + model: deepSeekReasonerModel, + temperature: 1, // reasoner models require temperature=1 + maxTokens, + system: + "You are a surgical book editor. Make only the minimum changes required by the task. Return ONLY the revised section body as plain prose — no JSON, no markdown, no commentary.", + prompt: `SECTION HEADING: ${heading}${voiceDnaBlock} + +SECTION BODY: +${body} + +EDITORIAL TASK: +${task} + +RULES: +- Change ONLY what is necessary to address the task above +- Preserve every scripture reference, quote, and theological teaching point +- WORD COUNT: The original section is ${originalWordCount} words. Target ${minWords}–${maxWords} words (98–102% of original). +- Keep the same sentence rhythm and paragraph structure +- Never use an em dash (— or --) +- Return the revised body as plain prose text only + +${SOURCE_LOCK_RULES}`, + }); + text = result.text.trim(); + } catch (err) { + console.error("[apply-audit] LLM section rewrite failed:", err); + return body; // fall back to original + } + + if (!text) return body; + + // Hard safety net: if the rewrite lost more than 5% of words, reject and return original. + // This prevents accidental content deletion during surgical edits. + const revisedWordCount = text.split(/\s+/).filter(Boolean).length; + if (revisedWordCount < Math.floor(originalWordCount * 0.95)) { + console.warn( + `[apply-audit] Rewrite shrank section from ${originalWordCount} → ${revisedWordCount} words (>${Math.round((1 - revisedWordCount / originalWordCount) * 100)}% loss) — keeping original`, + ); + return body; + } + + return text; +} + +// Concurrency-limited parallel runner +// Note: uses `any` instead of generics — SWC in Next.js 15 can mis-parse generic +// async function declarations (`async function f(...)`) and corrupt downstream +// parsing. `any` is stripped identically by SWC without the ambiguous `` tokens. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function mapWithConcurrency( + items: any[], + limit: number, + fn: (item: any) => Promise, +): Promise { + const results: any[] = new Array(items.length); + let index = 0; + const worker = async (): Promise => { + while (index < items.length) { + const i = index++; + results[i] = await fn(items[i]); + } + }; + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker())); + return results; +} + +// \u2500\u2500\u2500 Upgrade 7: Post-rewrite transition repair \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 +// After concept-duplicate removal, auto-patches the opening paragraph of the +// immediately following section and closing paragraph of the preceding one so +// the seam reads naturally without manual intervention. + +type LLMTask = { + chapterIndex: number; + field: "intro" | "conclusion" | { sectionNumber: number }; + task: string; + heading: string; + body: string; + voiceDNA?: VoiceDNAType | null; +}; + +type RewrittenLocation = { + chapterIndex: number; + field: "intro" | "conclusion" | { sectionNumber: number }; +}; + +type TransitionTask = { + chapterIndex: number; + field: "intro" | "conclusion" | { sectionNumber: number }; + role: "preceding" | "following"; + headingContext: string; + bodyToFix: string; + adjacentEdge: string; +}; + +async function repairTransitions( + chapters: ChapterInput[], + rewritten: RewrittenLocation[], + voiceDNA?: VoiceDNAType | null, +): Promise { + + const tasks: TransitionTask[] = []; + + for (const { chapterIndex, field } of rewritten) { + const ch = chapters[chapterIndex]; + + // Resolve rewritten body for edge extraction + let rewrittenBody = ""; + if (field === "intro") { + rewrittenBody = ch.intro; + } else if (field === "conclusion") { + rewrittenBody = ch.conclusion; + } else { + const secNum = (field as { sectionNumber: number }).sectionNumber; + rewrittenBody = ch.sections.find((s) => s.sectionNumber === secNum)?.body ?? ""; + } + + const paras = rewrittenBody.split(/\n\n+/); + const rewrittenOpen = (paras[0] ?? "").trim().slice(0, 220); + const rewrittenClose = (paras.at(-1) ?? "").trim().slice(-220); + + if (field === "intro") { + // Nothing precedes a chapter intro in the same chapter + } else if (field === "conclusion") { + // Preceding = last section + const lastSec = [...ch.sections].reverse()[0]; + if (lastSec?.body) { + tasks.push({ + chapterIndex, + field: { sectionNumber: lastSec.sectionNumber }, + role: "preceding", + headingContext: lastSec.heading, + bodyToFix: lastSec.body, + adjacentEdge: rewrittenOpen, + }); + } + } else { + const secNum = (field as { sectionNumber: number }).sectionNumber; + const secIdx = ch.sections.findIndex((s) => s.sectionNumber === secNum); + + // Preceding neighbor + if (secIdx > 0) { + const prev = ch.sections[secIdx - 1]; + tasks.push({ + chapterIndex, + field: { sectionNumber: prev.sectionNumber }, + role: "preceding", + headingContext: prev.heading, + bodyToFix: prev.body, + adjacentEdge: rewrittenOpen, + }); + } else if (ch.intro) { + tasks.push({ + chapterIndex, + field: "intro", + role: "preceding", + headingContext: `Chapter ${ch.number} Introduction`, + bodyToFix: ch.intro, + adjacentEdge: rewrittenOpen, + }); + } + + // Following neighbor + if (secIdx >= 0 && secIdx < ch.sections.length - 1) { + const next = ch.sections[secIdx + 1]; + tasks.push({ + chapterIndex, + field: { sectionNumber: next.sectionNumber }, + role: "following", + headingContext: next.heading, + bodyToFix: next.body, + adjacentEdge: rewrittenClose, + }); + } else if (ch.conclusion) { + tasks.push({ + chapterIndex, + field: "conclusion", + role: "following", + headingContext: `Chapter ${ch.number} Conclusion`, + bodyToFix: ch.conclusion, + adjacentEdge: rewrittenClose, + }); + } + } + } + + // Deduplicate by chapterIndex + field + role (same neighbor can appear multiple times) + const seen = new Set(); + const unique = tasks.filter((t) => { + const key = `${t.chapterIndex}|${JSON.stringify(t.field)}|${t.role}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + + if (unique.length === 0) return chapters; + + const results = await mapWithConcurrency(unique, 2, async (task) => { + const taskPrompt = + task.role === "preceding" + ? `The section immediately after this one has been revised. It now opens with:\n"${task.adjacentEdge}"\n\nReturn the COMPLETE section body below, changing ONLY the final paragraph so it transitions naturally into that new opening. Every other paragraph must remain word-for-word identical.` + : `The section immediately before this one has been revised. It now ends with:\n"${task.adjacentEdge}"\n\nReturn the COMPLETE section body below, changing ONLY the opening paragraph so it follows naturally from that new closing. Every other paragraph must remain word-for-word identical.`; + return { + task, + revisedBody: await reviseSectionBody(task.headingContext, task.bodyToFix, taskPrompt, voiceDNA), + }; + }); + + let updated = [...chapters]; + for (const { task, revisedBody } of results) { + const ch = updated[task.chapterIndex]; + if (task.field === "intro") { + updated[task.chapterIndex] = { ...ch, intro: revisedBody }; + } else if (task.field === "conclusion") { + updated[task.chapterIndex] = { ...ch, conclusion: revisedBody }; + } else { + const secNum = (task.field as { sectionNumber: number }).sectionNumber; + updated[task.chapterIndex] = { + ...ch, + sections: ch.sections.map((s) => + s.sectionNumber === secNum + ? { ...s, body: revisedBody, wordCount: revisedBody.split(/\s+/).filter(Boolean).length } + : s, + ), + }; + } + } + + return updated; +} + +// \u2500\u2500\u2500 Route Handler \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n// Exported as const arrow to avoid a Next.js 15.5 SWC parser bug where +// `async function POST` declarations after complex generic/IIFE helpers +// have their returns flagged as module-level. Arrow form parses correctly. +export const POST = async (req: NextRequest): Promise => { + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const parsed = RequestSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: "Invalid request", details: parsed.error.flatten() }, { status: 400 }); + } + + const { manifest, report, appliedKeys, voiceDNA } = parsed.data; + + if (appliedKeys.length === 0) { + return NextResponse.json({ chapters: manifest.chapters }, { status: 200 }); + } + + // Deep clone chapters so we can mutate them safely + let chapters: ChapterInput[] = manifest.chapters.map((c) => ({ + ...c, + sections: c.sections.map((s) => ({ ...s })), + })); + + // ── Tier 1: Algorithmic fixes (no LLM, no risk of extra rewrites) ─────────── + + for (const key of appliedKeys) { + if (key.startsWith("r-")) { + const idx = parseInt(key.slice(2)); + const rep = report.repetitions[idx]; + if (!rep || !rep.alternatives.length) continue; + // Apply globally across ALL chapters (repetition is a manuscript-wide stat) + chapters = chapters.map((ch) => + mapChapterText(ch, (text) => applyPhraseVariation(text, rep.phrase, rep.alternatives)), + ); + } + + if (key.startsWith("w-")) { + const idx = parseInt(key.slice(2)); + const ow = report.overusedWords[idx]; + if (!ow || !ow.alternatives.length) continue; + chapters = chapters.map((ch) => + mapChapterText(ch, (text) => applyWordVariation(text, ow.word, ow.alternatives)), + ); + } + } + + // ── Tier 2: Faithful to speaker's progression — only delete obvious copy-paste errors ── + // STRATEGY: The book should follow the speaker's natural teaching flow. If they revisited + // a concept later in the transcript, that was intentional pedagogical reinforcement. + // ONLY delete sections that are clearly copy-paste errors (>98% verbatim duplication). + + function calculateTextSimilarity(text1: string, text2: string): number { + const words1 = new Set(tokenizeContent(text1)); + const words2 = new Set(tokenizeContent(text2)); + const intersection = new Set([...words1].filter(x => words2.has(x))); + const union = new Set([...words1, ...words2]); + return union.size > 0 ? intersection.size / union.size : 0; + } + + const sectionsToDelete: { chapterNum: number; sectionNum: number }[] = []; + const llmTasks: LLMTask[] = []; + + for (const key of appliedKeys) { + // ── Concept duplicates — only delete if >98% verbatim (copy-paste error) ────────── + // If the speaker naturally covered the same concept again in their teaching, + // preserve it. Only remove obvious technical duplication errors. + if (key.startsWith("c-")) { + const idx = parseInt(key.slice(2)); + const dup = report.conceptDuplicates[idx]; + if (!dup || dup.locations.length < 2) continue; + + for (let li = 1; li < dup.locations.length; li++) { + const { chapterNum, sectionNum } = parseLocation(dup.locations[li].location); + if (chapterNum === null || sectionNum === null) continue; + + // Check if this is a copy-paste error (near-verbatim text) + const firstExcerpt = dup.locations[0].excerpt || ""; + const thisExcerpt = dup.locations[li].excerpt || ""; + const similarity = firstExcerpt && thisExcerpt + ? calculateTextSimilarity(firstExcerpt, thisExcerpt) + : 0; + + // Only delete if >98% similar (clear copy-paste error, not intentional reinforcement) + if (similarity > 0.98) { + sectionsToDelete.push({ chapterNum, sectionNum }); + } + // Otherwise: preserve it — the speaker chose to revisit this concept + } + } + + // ── Similar pairs — only delete if >98% similar (copy-paste error) ───────────────── + if (key.startsWith("p-")) { + const idx = parseInt(key.slice(2)); + const pair = report.similarPairs[idx]; + if (!pair) continue; + + const locB = parseLocation(pair.locationB); + if (locB.chapterNum !== null && locB.sectionNum !== null) { + // Only delete if >98% similar (clear technical duplication) + if (pair.similarity > 0.98) { + sectionsToDelete.push({ chapterNum: locB.chapterNum, sectionNum: locB.sectionNum }); + } + // Otherwise: preserve it — follow the speaker's natural progression + } + } + } + + // Execute deletions and capture neighbor pairs for seam repair + type SeamTask = { + chapterIndex: number; + precedingSecNum: number; + precedingHeading: string; + precedingBody: string; + followingSecNum: number; + followingHeading: string; + followingBody: string; + }; + const seamTasks: SeamTask[] = []; + + // Deduplicate: a section might be nominated for deletion by multiple keys + const deletionSet = new Set(sectionsToDelete.map((d) => `${d.chapterNum}:${d.sectionNum}`)); + for (const key of Array.from(deletionSet)) { + const [chapterNum, sectionNum] = key.split(":").map(Number); + const chIdx = chapters.findIndex((c) => c.number === chapterNum); + if (chIdx === -1) continue; + const ch = chapters[chIdx]; + const secIdx = ch.sections.findIndex((s) => s.sectionNumber === sectionNum); + if (secIdx === -1) continue; + + // Capture neighbors for seam repair BEFORE mutating + const precSec = secIdx > 0 ? ch.sections[secIdx - 1] : null; + const follSec = secIdx < ch.sections.length - 1 ? ch.sections[secIdx + 1] : null; + if (precSec && follSec) { + seamTasks.push({ + chapterIndex: chIdx, + precedingSecNum: precSec.sectionNumber, + precedingHeading: precSec.heading, + precedingBody: precSec.body, + followingSecNum: follSec.sectionNumber, + followingHeading: follSec.heading, + followingBody: follSec.body, + }); + } + + // Delete the section and recalculate the chapter word count + const updatedSections = ch.sections.filter((s) => s.sectionNumber !== sectionNum); + const updatedWordCount = updatedSections.reduce((sum, s) => sum + s.wordCount, 0); + chapters[chIdx] = { ...ch, sections: updatedSections, totalWordCount: updatedWordCount }; + } + + // Repair seams between now-adjacent sections where a deletion created a gap + if (seamTasks.length > 0) { + const seamResults = await mapWithConcurrency(seamTasks, 2, async (task) => { + const follOpen = (task.followingBody.split(/\n\n+/)[0] ?? "").trim().slice(0, 200); + const precClose = (task.precedingBody.split(/\n\n+/).at(-1) ?? "").trim().slice(-200); + + const [precRevised, follRevised] = await Promise.all([ + reviseSectionBody( + task.precedingHeading, + task.precedingBody, + `A duplicate section that immediately followed this one has been removed. Return the COMPLETE body, changing ONLY the final paragraph so it transitions naturally into the section that now follows, which opens with: "${follOpen}". Every other paragraph must remain word-for-word identical.`, + voiceDNA, + ), + reviseSectionBody( + task.followingHeading, + task.followingBody, + `A duplicate section that immediately preceded this one has been removed. Return the COMPLETE body, changing ONLY the opening paragraph so it follows naturally from the section that now precedes it, which ends with: "${precClose}". Every other paragraph must remain word-for-word identical.`, + voiceDNA, + ), + ]); + + return { task, precRevised, follRevised }; + }); + + for (const { task, precRevised, follRevised } of seamResults) { + const ch = chapters[task.chapterIndex]; + chapters[task.chapterIndex] = { + ...ch, + sections: ch.sections.map((s) => { + if (s.sectionNumber === task.precedingSecNum) + return { ...s, body: precRevised, wordCount: precRevised.split(/\s+/).filter(Boolean).length }; + if (s.sectionNumber === task.followingSecNum) + return { ...s, body: follRevised, wordCount: follRevised.split(/\s+/).filter(Boolean).length }; + return s; + }), + }; + } + } + + // Run LLM tasks with concurrency limit (max 4 at a time) + if (llmTasks.length > 0) { + const results = await mapWithConcurrency(llmTasks, 4, async (task) => ({ + task, + revisedBody: await reviseSectionBody(task.heading, task.body, task.task, task.voiceDNA), + })); + + // Apply results back into the chapters array + for (const { task, revisedBody } of results) { + const ch = chapters[task.chapterIndex]; + if (task.field === "intro") { + chapters[task.chapterIndex] = { ...ch, intro: revisedBody }; + } else if (task.field === "conclusion") { + chapters[task.chapterIndex] = { ...ch, conclusion: revisedBody }; + } else { + const secNum = (task.field as { sectionNumber: number }).sectionNumber; + chapters[task.chapterIndex] = { + ...ch, + sections: ch.sections.map((s) => + s.sectionNumber === secNum + ? { ...s, body: revisedBody, wordCount: revisedBody.split(/\s+/).filter(Boolean).length } + : s, + ), + }; + } + } + + // Upgrade 7: patch transition seams in sections adjacent to any rewritten section + const rewrittenLocations: RewrittenLocation[] = results.map(({ task }) => ({ + chapterIndex: task.chapterIndex, + field: task.field, + })); + chapters = await repairTransitions(chapters, rewrittenLocations, voiceDNA); + } + + return NextResponse.json({ chapters }, { status: 200 }); +} diff --git a/app/api/ebook/architect/route.ts b/app/api/ebook/architect/route.ts new file mode 100644 index 0000000..8ff6d5c --- /dev/null +++ b/app/api/ebook/architect/route.ts @@ -0,0 +1,746 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateObject } from "ai"; +import { z } from "zod"; +import { deepSeekModel, deepSeekReasonerModel } from "@/lib/ai-providers"; +import { env } from "@/lib/env"; +import { ArchitectRequestSchema } from "@/lib/schemas/ebook"; +import { SOURCE_LOCK_RULES } from "@/lib/editorial-style-bible"; + +export const runtime = "nodejs"; +export const maxDuration = 120; + +// ── Upgrade helpers ─────────────────────────────────────────────────────────── + +type ArcRole = "hook" | "context" | "mechanism" | "application" | "untagged"; + +// U1 — Arc scoring: classify a section heading + keyPoints into an arc role +const ARC_KEYWORDS: Record = { + hook: ["problem", "question", "why", "challenge", "crisis", "struggle", "pain", "trap", "lie", "broken", "need", "call", "open", "begin", "what if"], + context: ["because", "reason", "background", "history", "context", "understand", "foundation", "basis", "root", "origin", "means", "definition", "explains"], + mechanism: ["how", "principle", "law", "process", "method", "key", "secret", "truth", "power", "strategy", "framework", "step", "way", "work", "operate"], + application: ["apply", "response", "action", "do", "practice", "live", "walk", "obey", "commit", "decide", "choose", "result", "fruit", "outcome", "change", "now"], + untagged: [], +}; + +function scoreArcRole(heading: string, keyPoints: string[]): ArcRole { + const text = [heading, ...keyPoints].join(" ").toLowerCase(); + const scores: Record = { hook: 0, context: 0, mechanism: 0, application: 0, untagged: 0 }; + for (const [role, keywords] of Object.entries(ARC_KEYWORDS) as [ArcRole, string[]][]) { + for (const kw of keywords) { + if (text.includes(kw)) scores[role]++; + } + } + const best = (Object.entries(scores) as [ArcRole, number][]) + .filter(([role]) => role !== "untagged") + .sort((a, b) => b[1] - a[1])[0]; + return best && best[1] > 0 ? best[0] : "untagged"; +} + +function buildArcFlags(sections: { arcRole: ArcRole }[], chapterTitle: string): string[] { + const flags: string[] = []; + const roles = sections.map((s) => s.arcRole); + if (!roles.includes("hook")) flags.push(`Ch "${chapterTitle}": no hook section — consider making the opening section more provocative`); + if (!roles.includes("application")) flags.push(`Ch "${chapterTitle}": no application section — readers need a landing point`); + const mechanismCount = roles.filter((r) => r === "mechanism").length; + if (mechanismCount >= 3) flags.push(`Ch "${chapterTitle}": ${mechanismCount} consecutive mechanism sections — consider consolidating or adding application`); + return flags; +} + +// U2 — Cross-chapter section overlap: keyword token overlap between section headings +function keywordTokens(text: string): Set { + const stopWords = new Set(["a","an","the","and","or","of","in","to","for","with","by","is","are","was","were","be","it","its","this","that","these","those","on","at","as","from","up","about","how","what","which","who"]); + return new Set( + text.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter((w) => w.length > 3 && !stopWords.has(w)) + ); +} + +function sectionKeywordOverlap(a: string, b: string): number { + const setA = keywordTokens(a); + const setB = keywordTokens(b); + if (setA.size === 0 || setB.size === 0) return 0; + let shared = 0; + for (const w of setA) { if (setB.has(w)) shared++; } + return shared / Math.min(setA.size, setB.size); +} + +const ARCHITECT_OVERLAP_THRESHOLD = 0.60; + +// U3 — Word budget calibration: quality multiplier from segment density +function segmentQualityMultiplier(keyPointsCount: number, quotesCount: number): number { + const density = keyPointsCount + quotesCount * 0.5; + if (density >= 5) return 1.2; + if (density <= 1) return 0.7; + return 1.0; +} + +// U5 — Chapter premise line: derive from keyTheme + first section heading +function deriveChapterPremise( + chapterTitle: string, + keyTheme: string, + coreThesis: string, + firstSectionHeading: string +): string { + // Use the most specific available signal in priority order + const theme = keyTheme.trim() || coreThesis.trim() || chapterTitle.trim(); + const hook = firstSectionHeading.trim(); + if (theme && hook && theme.toLowerCase() !== hook.toLowerCase()) { + return `${theme}: ${hook.replace(/[.!?]+$/, "").trim()}.`; + } + return theme ? `${theme}.` : `${chapterTitle}.`; +} + +// U7 — Series arc: find shared keyword thread between adjacent chapter conclusions and openings +function deriveBridgeConcept( + fromLastSection: { heading: string; keyPoints: string[] }, + toFirstSection: { heading: string; keyPoints: string[] } +): string { + const fromText = [fromLastSection.heading, ...fromLastSection.keyPoints].join(" "); + const toText = [toFirstSection.heading, ...toFirstSection.keyPoints].join(" "); + const fromTokens = keywordTokens(fromText); + const toTokens = keywordTokens(toText); + const shared: string[] = []; + for (const w of fromTokens) { if (toTokens.has(w)) shared.push(w); } + if (shared.length > 0) return shared.slice(0, 3).join(", "); + // Fall back to stating the thematic direction + return `${fromLastSection.heading.split(/\s+/).slice(0, 4).join(" ")} → ${toFirstSection.heading.split(/\s+/).slice(0, 4).join(" ")}`; +} + +// ── Absolute minimum schema — no keyPoints, no quotes, no nested arrays ────── +// Everything gets rehydrated server-side from the contentMap after generation. +const MinimalSectionSchema = z.object({ + sectionNumber: z.number().default(1), + heading: z.string().default(""), + sourceSegmentIds: z.array(z.string()).default([]), + targetWordCount: z.number().default(0), +}); + +const MinimalChapterSchema = z.object({ + number: z.number().default(1), + title: z.string().default(""), + keyTheme: z.string().default(""), + sections: z.array(MinimalSectionSchema).default([]), +}); + +const MinimalArchitectureSchema = z.object({ + bookTitle: z.string().default("Untitled Teaching Manuscript"), + subtitle: z.string().default(""), + authorName: z.string().default("the Author"), + estimatedTotalWords: z.number().default(0), + frontMatterNotes: z.string().default(""), + backMatterNotes: z.string().default(""), + chapters: z.array(MinimalChapterSchema).default([]), +}); + +// ── Deterministic section grouper (error fallback only) ───────────────────── +// Used only when the per-audio LLM call fails. +function groupSegmentsIntoSections( + segs: Array<{ id: string; topic: string; keyPoints: string[]; estimatedWordCount: number }>, + maxSections = 5, +): Array<{ heading: string; sourceSegmentIds: string[]; targetWordCount: number }> { + if (segs.length === 0) return []; + if (segs.length <= maxSections) { + // Each segment gets its own section — derive heading from keyPoints + return segs.map((seg) => ({ + heading: deriveSectionHeading(seg.topic, seg.keyPoints), + sourceSegmentIds: [seg.id], + targetWordCount: Math.max(seg.estimatedWordCount || 0, 250), + })); + } + + // Greedily merge consecutive segments when they share topic keywords. + // Always produce between 3 and maxSections buckets. + const minSections = Math.min(3, segs.length); + const buckets: typeof segs[] = []; + let current: typeof segs = [segs[0]]; + + for (let i = 1; i < segs.length; i++) { + const seg = segs[i]; + const remaining = segs.length - i; + const slotsLeft = maxSections - buckets.length - 1; // -1 for current open bucket + + // Always merge if we'd exceed maxSections by splitting + const mustMerge = slotsLeft <= remaining; + // Split if the topic diverges enough AND we still have room for more sections + const shouldSplit = !mustMerge && sectionKeywordOverlap( + [current[0].topic, ...current[0].keyPoints].join(" "), + [seg.topic, ...seg.keyPoints].join(" ") + ) < 0.25; + + if (shouldSplit && buckets.length + 1 < maxSections) { + buckets.push(current); + current = [seg]; + } else { + current.push(seg); + } + } + buckets.push(current); + + // If we ended up with fewer than minSections, split the largest bucket + while (buckets.length < minSections) { + const largestIdx = buckets.reduce((best, b, i) => b.length > buckets[best].length ? i : best, 0); + if (buckets[largestIdx].length < 2) break; + const half = Math.ceil(buckets[largestIdx].length / 2); + const [left, right] = [buckets[largestIdx].slice(0, half), buckets[largestIdx].slice(half)]; + buckets.splice(largestIdx, 1, left, right); + } + + return buckets.map((group) => { + const allKeyPoints = group.flatMap((s) => s.keyPoints ?? []); + const heading = deriveSectionHeading(group[0].topic, allKeyPoints); + const totalWords = group.reduce((sum, s) => sum + (s.estimatedWordCount || 0), 0); + return { + heading, + sourceSegmentIds: group.map((s) => s.id), + targetWordCount: Math.max(totalWords, 250), + }; + }); +} + +/** Pick the best heading from a segment's topic + keyPoints — never fabricates, uses transcript data only. */ +function deriveSectionHeading(topic: string, keyPoints: string[]): string { + const BANNED = /^(introduction|intro|overview|opening|summary|conclusion|section|part|chapter)\s*[:\-]?\s*/i; + // Prefer a keyPoint over the raw topic if it's more specific (longer, no banned prefix) + const candidates = [topic, ...(keyPoints ?? [])] + .map((s) => s.replace(BANNED, "").trim()) + .filter((s) => s.length > 10); + // Pick the longest candidate that doesn't start with a banned word (more specific = longer) + const best = candidates.sort((a, b) => b.length - a.length)[0]; + return best || topic.replace(BANNED, "").trim() || "Core Teaching"; +} + +// ── Per-audio LLM chapter architect (oneChapterPerUpload) ──────────────────── +// One call per audio file, receiving the full rawText so the LLM can make +// premium structural decisions grounded entirely in the actual transcript. +// SOURCE_LOCK_RULES enforces zero fabrication. + +const SingleChapterPlanSchema = z.object({ + title: z.string().default(""), + keyTheme: z.string().default(""), + sections: z.array(z.object({ + heading: z.string().default(""), + sourceSegmentIds: z.array(z.string()).default([]), + targetWordCount: z.number().default(0), + })).default([]), +}); + +async function architectOneChapterFromTranscript( + segments: Array<{ id: string; topic: string; rawText: string; keyPoints: string[]; estimatedWordCount: number }>, + chapterHint: string, + coreThesis: string, + teachingArc: string, + voiceDNATone: string, +): Promise> { + const MAX_RAW_WORDS_PER_SEGMENT = 1200; + const transcriptBlock = segments.map((seg) => { + const rawWords = (seg.rawText ?? "").split(/\s+/); + const rawTruncated = rawWords.length > MAX_RAW_WORDS_PER_SEGMENT + ? rawWords.slice(0, MAX_RAW_WORDS_PER_SEGMENT).join(" ") + " […]" + : (seg.rawText ?? ""); + return [ + `[SEGMENT ${seg.id}]`, + `TOPIC: ${seg.topic}`, + `KEY POINTS:\n${(seg.keyPoints ?? []).map((p) => ` • ${p}`).join("\n")}`, + `WORD COUNT: ${seg.estimatedWordCount}`, + `TRANSCRIPT:\n${rawTruncated}`, + ].join("\n"); + }).join("\n\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n"); + + const { object } = await generateObject({ + model: deepSeekModel, + schema: SingleChapterPlanSchema, + mode: "json", + temperature: 0.15, + system: `You are a senior structural editor turning one teaching message into a premium book chapter. + +SOURCE-LOCK — ABSOLUTE RULE: +Every title, section heading, and key theme you write MUST derive word-for-word or idea-for-idea from the transcript segments below. You may NOT invent, assume, or extrapolate anything not explicitly present in the provided text. + +CHAPTER TITLE RULE: +- Use the strongest single claim or overarching idea the speaker explicitly states +- Use the speaker's actual words or a direct condensation of them +- If the speaker states a clear thesis, that IS your chapter title + +SECTION HEADING RULES: +- Each heading must be a specific teaching claim the speaker made, drawn directly from the keyPoints or transcript text +- 3–9 words; name the SPECIFIC idea, not its category +- BANNED prefixes: Introduction, Intro, Overview, Opening, Summary, Conclusion, Part, Chapter, Section +- GOOD: "Prayer reveals the hidden glory within you" | BAD: "Introduction to Prayer" + +STRUCTURE RULES: +- Produce exactly 3–5 sections +- Group segments that develop the same point; split when the topic clearly shifts +- sourceSegmentIds must ONLY reference IDs from the AVAILABLE SEGMENTS list +- Every provided segment ID must appear in exactly one section — none may be skipped +- targetWordCount = sum of assigned segments' word counts +- Apply a natural teaching arc: Hook → Context → Core Mechanism → Application → Landing + +${SOURCE_LOCK_RULES}`, + prompt: `AVAILABLE SEGMENT IDs: ${segments.map((s) => s.id).join(", ")} +CHAPTER THEME HINT: ${chapterHint} +CORE THESIS: ${coreThesis} +TEACHING ARC: ${teachingArc} +VOICE TONE: ${voiceDNATone} + +${transcriptBlock}`, + }); + return object; +} + +function fallbackArchitecture(input: z.infer) { + // Group segments by sourceAudio to produce one chapter per message in series order + const audioOrder = ["audio-1", "audio-2", "audio-3", "audio-4", "audio-5", "audio-6"]; + const segmentsByAudio = new Map(); + for (const seg of input.contentMap.segments) { + const bucket = segmentsByAudio.get(seg.sourceAudio) ?? []; + bucket.push(seg); + segmentsByAudio.set(seg.sourceAudio, bucket); + } + + const audioKeys = audioOrder.filter((k) => segmentsByAudio.has(k)); + const chapters = audioKeys.map((audioKey, chapterIndex) => { + const segs = segmentsByAudio.get(audioKey)!; + // Use the content-map theme for this audio slot as the chapter title (already extracted + // from the transcript by the content-map step — no fabrication risk). + const chapterTitle = (input.contentMap.overarchingThemes[chapterIndex] || "").trim() + || segs.map((s) => s.topic).sort((a, b) => b.length - a.length)[0] + || `Chapter ${chapterIndex + 1}`; + const keyTheme = chapterTitle; + // Group segments intelligently into 3–5 sections + const rawSections = groupSegmentsIntoSections(segs, 5); + const sections = rawSections.map((sec, i) => ({ + sectionNumber: i + 1, + heading: sec.heading, + sourceSegmentIds: sec.sourceSegmentIds, + targetWordCount: sec.targetWordCount, + })); + return { number: chapterIndex + 1, title: chapterTitle, keyTheme, sections }; + }); + + const fallbackChapters = chapters.length > 0 ? chapters : [{ + number: 1, + title: input.contentMap.coreThesis || input.contentMap.overarchingThemes[0] || "Core Teaching", + keyTheme: input.contentMap.coreThesis || input.contentMap.overarchingThemes[0] || input.contentMap.teachingArc || "Core teaching", + sections: groupSegmentsIntoSections(input.contentMap.segments, 5).map((sec, i) => ({ + sectionNumber: i + 1, + ...sec, + })), + }]; + + return { + bookTitle: input.contentMap.coreThesis || input.contentMap.overarchingThemes[0] || input.contentMap.segments[0]?.topic || "Untitled Teaching Manuscript", + subtitle: input.contentMap.targetAudience || input.contentMap.teachingArc || "Drawn directly from the source teaching", + authorName: "the Author", + estimatedTotalWords: fallbackChapters.flatMap((c) => c.sections).reduce((sum, s) => sum + s.targetWordCount, 0), + frontMatterNotes: input.contentMap.coreThesis || input.contentMap.segments[0]?.topic || "", + backMatterNotes: input.contentMap.teachingArc || input.contentMap.segments.at(-1)?.topic || "", + chapters: fallbackChapters, + }; +} + +function normalizeArchitecture( + minimal: z.infer, + input: z.infer, +) { + const fallback = fallbackArchitecture(input); + const validIds = new Set(input.contentMap.segments.map((s) => s.id)); + + // Deduplicate segment IDs globally — each segment must feed exactly one section. + // First-come-first-served: whichever section claims a segment first keeps it. + const globalUsedSegIds = new Set(); + + const chapters = (minimal.chapters ?? []) + .map((chapter, chapterIndex) => ({ + number: Math.max(1, Math.trunc(chapter.number || chapterIndex + 1)), + title: (chapter.title || "").trim() || fallback.chapters[0].title, + keyTheme: (chapter.keyTheme || "").trim() || fallback.chapters[0].keyTheme, + sections: (chapter.sections ?? []) + .map((section, sectionIndex) => { + const uniqueIds = (section.sourceSegmentIds ?? []) + .filter((id) => validIds.has(id) && !globalUsedSegIds.has(id)); + uniqueIds.forEach((id) => globalUsedSegIds.add(id)); + return { + sectionNumber: Math.max(1, Math.trunc(section.sectionNumber || sectionIndex + 1)), + heading: ((section.heading || "").trim() || `Section ${sectionIndex + 1}`).replace(/^(introduction|intro|overview|opening|summary|conclusion)\s*:\s*/i, "").trim() || `Section ${sectionIndex + 1}`, + sourceSegmentIds: uniqueIds, + targetWordCount: Math.max(0, Math.trunc(section.targetWordCount || 0)), + }; + }) + .filter((section) => section.sourceSegmentIds.length > 0), + })) + .filter((chapter) => chapter.sections.length > 0); + + return { + bookTitle: (minimal.bookTitle || "").trim() || fallback.bookTitle, + subtitle: (minimal.subtitle || "").trim(), + authorName: (minimal.authorName || "").trim() || fallback.authorName, + estimatedTotalWords: Math.max(0, Math.trunc(minimal.estimatedTotalWords || 0)) || fallback.estimatedTotalWords, + frontMatterNotes: (minimal.frontMatterNotes || "").trim() || fallback.frontMatterNotes, + backMatterNotes: (minimal.backMatterNotes || "").trim() || fallback.backMatterNotes, + chapters: chapters.length > 0 ? chapters : fallback.chapters, + }; +} + +export async function POST(req: NextRequest) { + const body = await req.json() as unknown; + let input; + try { + input = ArchitectRequestSchema.parse(body); + } catch (err) { + return NextResponse.json({ error: err instanceof Error ? err.message : "Invalid input" }, { status: 400 }); + } + + // Build segment + quote lookups for rehydration + const segmentMap = Object.fromEntries(input.contentMap.segments.map((s) => [s.id, s])); + const validSegmentIds = new Set(input.contentMap.segments.map((s) => s.id)); + const quoteMap = Object.fromEntries((input.contentMap.allQuotes ?? []).map((q) => [q.id, q])); + + // Trim input to only what the LLM needs for architecture decisions + const segmentsLite = input.contentMap.segments.map((s) => ({ + id: s.id, + sourceAudio: s.sourceAudio, + topic: s.topic, + keyPoints: (s.keyPoints ?? []).slice(0, 3), // first 3 only + estimatedWordCount: s.estimatedWordCount, + })); + + try { + let minimal: z.infer; + try { + if (input.oneChapterPerUpload) { + // ── Per-audio parallel LLM calls (oneChapterPerUpload) ──────────────── + // Each audio file gets its own focused LLM call with full transcript text. + // The LLM produces premium structure entirely from the provided transcript. + const audioOrder = ["audio-1", "audio-2", "audio-3", "audio-4", "audio-5", "audio-6"] as const; + const segsByAudio = new Map(); + for (const seg of input.contentMap.segments) { + const bucket = segsByAudio.get(seg.sourceAudio) ?? []; + bucket.push(seg); + segsByAudio.set(seg.sourceAudio, bucket); + } + const audioKeys = audioOrder.filter((k) => segsByAudio.has(k)); + + const chapterPlans = await Promise.all( + audioKeys.map((audioKey, idx) => { + const segs = segsByAudio.get(audioKey)!; + const chapterHint = (input.contentMap.overarchingThemes[idx] || "").trim() + || segs.map((s) => s.topic).sort((a, b) => b.length - a.length)[0] + || ""; + return architectOneChapterFromTranscript( + segs, + chapterHint, + input.contentMap.coreThesis, + input.contentMap.teachingArc, + input.voiceDNA.toneProfile, + ).catch(() => null); + }) + ); + + const validIds = new Set(input.contentMap.segments.map((s) => s.id)); + const chapters = chapterPlans.map((plan, idx) => { + const segs = segsByAudio.get(audioKeys[idx])!; + const themeHint = (input.contentMap.overarchingThemes[idx] || segs[0]?.topic || `Chapter ${idx + 1}`).trim(); + if (!plan || plan.sections.length === 0) { + const grouped = groupSegmentsIntoSections(segs, 5); + return { + number: idx + 1, + title: themeHint, + keyTheme: themeHint, + sections: grouped.map((sec, si) => ({ sectionNumber: si + 1, ...sec })), + }; + } + return { + number: idx + 1, + title: (plan.title || themeHint).trim(), + keyTheme: (plan.keyTheme || plan.title || themeHint).trim(), + sections: plan.sections + .map((sec, si) => ({ + sectionNumber: si + 1, + heading: sec.heading, + sourceSegmentIds: (sec.sourceSegmentIds ?? []).filter((id) => validIds.has(id)), + targetWordCount: sec.targetWordCount || 0, + })) + .filter((sec) => sec.sourceSegmentIds.length > 0), + }; + }); + + // Extract short book title from themes or first chapter (not coreThesis which is a long paragraph) + const shortBookTitle = ( + input.contentMap.overarchingThemes[0] || + chapters[0]?.title || + input.contentMap.segments[0]?.topic || + "Untitled Teaching Manuscript" + ).trim().split(".")[0].slice(0, 100); + + minimal = { + bookTitle: shortBookTitle, + subtitle: input.contentMap.targetAudience || input.contentMap.teachingArc || "Drawn directly from the source teaching", + authorName: "the Author", + estimatedTotalWords: chapters.flatMap((c) => c.sections).reduce((sum, s) => sum + (s.targetWordCount || 0), 0), + frontMatterNotes: input.contentMap.coreThesis || input.contentMap.segments[0]?.topic || "", + backMatterNotes: input.contentMap.teachingArc || input.contentMap.segments.at(-1)?.topic || "", + chapters, + }; + } else { + { + const result = await generateObject({ + model: deepSeekReasonerModel, + schema: MinimalArchitectureSchema, + mode: "json", + system: `# ROLE +You are an elite structural editor for a top-tier publishing house. Your job is to map raw, sanitized audio transcript segments into a clean chapter architecture for a published book series. + +# OBJECTIVE +This content is a sermon series. The author's preaching sequence IS the book's sequence. Your job is to give each message a strong chapter structure — not to reorganize which ideas belong in which message. + +# STRICT EDITORIAL INSTRUCTIONS +1. SEQUENCE PRESERVATION — NON-NEGOTIABLE: Chapters must follow the source audio order (audio-1 before audio-2 before audio-3, etc.). A single audio source may produce more than one chapter if the content depth warrants it — but all chapters from audio-1 must appear consecutively before any chapter from audio-2. Never interleave chapters from different audio sources. Never place a segment from audio-2 into a chapter that also contains segments from audio-1. +2. WITHIN-CHAPTER SYNTHESIS ONLY: Within a single message (single sourceAudio), you may group scattered thoughts on the same topic into unified sections. If the speaker revisits a point within the same message, consolidate it into one section. Do not synthesize across messages. +3. WITHIN-CHAPTER ARC: Within each chapter, structure the sections to reflect the message's natural teaching progression. Where the content supports it, apply the arc below — but never at the cost of distorting the speaker's own sequence: + - The Hook: The core problem or provocative claim that opens the message + - The Context: Why this matters (as the speaker framed it) + - The Mechanism: The core argument or framework the speaker taught + - The Application: How the speaker called the listener to respond +4. WITHIN-CHAPTER DEDUPLICATION ONLY: Within a single message, pure title-restatement recap lines (e.g., "our series this month is...") with zero new substance may be collapsed. Do not discard content that transitions the series narrative forward. + +# PIPELINE RULES — REQUIRED FOR OUTPUT VALIDITY +- sourceSegmentIds MUST reference actual segment IDs from the provided segment list (e.g. "seg-1"). Never invent IDs. +- SEGMENT UNIQUENESS — NON-NEGOTIABLE: Each segment ID must appear in EXACTLY ONE section across the entire book. Never assign the same segment ID to two or more sections or chapters. If two sections seem to need the same content, merge them into one section. +- Each chapter must draw segments from only one sourceAudio. A single sourceAudio may produce multiple consecutive chapters if the content depth warrants it. +- Each chapter: 3–5 sections; each section covers one focused teaching point. +- targetWordCount per section = sum of that section's segments' estimatedWordCount. +- bookTitle and authorName must come from the content; use "the Author" if name is unknown. +- estimatedTotalWords = sum of all section targetWordCounts. +- Always return every required field, even if some strings are brief. +- Never leave sections empty; every chapter must have at least one section with at least one sourceSegmentId. +- SECTION HEADING BAN: Never start a section heading with "Introduction", "Intro", "Overview", "Opening", "Summary", or "Conclusion". These are structural labels, not teaching titles. Rename any such heading to the specific claim or truth the speaker made in that segment (e.g. "Prayer changes your countenance", not "Introduction: Prayer Changes People").`, + prompt: `Design the chapter architecture. + + VOICE DNA TONE: ${input.voiceDNA.toneProfile} + TEACHING ARC: ${input.contentMap.teachingArc} + CORE THESIS: ${input.contentMap.coreThesis} + TARGET AUDIENCE: ${input.contentMap.targetAudience} + UNIQUE VOCABULARY: ${(input.contentMap.uniqueVocabulary ?? []).join(", ")} + TONE MAP: ${input.contentMap.toneMap} + THEMES: ${(input.contentMap.overarchingThemes ?? []).join(", ")} + + SEGMENTS: + ${JSON.stringify(segmentsLite)}`, + }); + minimal = result.object; + } // end LLM block + } // end else + } catch { + minimal = fallbackArchitecture(input); + } + + const normalized = normalizeArchitecture(minimal, input); + + // ── Upgrade 6: Orphan segment recovery ────────────────────────────────── + // Find segments not assigned to any section. Segments >150 words get assigned + // to the most keyword-similar section. Thinner ones are logged as dropped. + const assignedSegIds = new Set( + normalized.chapters.flatMap((ch) => ch.sections.flatMap((s) => s.sourceSegmentIds)) + ); + const orphans = input.contentMap.segments.filter( + (s) => !assignedSegIds.has(s.id) && !s.topic.includes("[NON-TEACHING") + ); + const droppedSegments: string[] = []; + + if (orphans.length > 0) { + // Build a flat list of all sections with their text for similarity scoring + const allSectionEntries = normalized.chapters.flatMap((ch) => + ch.sections.map((sec) => ({ + chapterIdx: ch.number - 1, + sectionIdx: sec.sectionNumber - 1, + text: [sec.heading, ...sec.sourceSegmentIds.map((id) => segmentMap[id]?.topic ?? "")].join(" "), + })) + ); + + for (const orphan of orphans) { + if (orphan.estimatedWordCount < 150) { + droppedSegments.push(orphan.id); + continue; + } + // Find most similar section by keyword overlap with orphan topic + keyPoints + const orphanText = [orphan.topic, ...(orphan.keyPoints ?? [])].join(" "); + let bestScore = 0; + let bestEntry: (typeof allSectionEntries)[0] | null = null; + for (const entry of allSectionEntries) { + const score = sectionKeywordOverlap(orphanText, entry.text); + if (score > bestScore) { bestScore = score; bestEntry = entry; } + } + if (bestEntry && bestScore > 0.1) { + normalized.chapters[bestEntry.chapterIdx].sections[bestEntry.sectionIdx].sourceSegmentIds.push(orphan.id); + assignedSegIds.add(orphan.id); + } else { + droppedSegments.push(orphan.id); + } + } + } + + // ── Rehydrate full BookArchitecture from minimal output ─────────────── + const hydratedChapters = normalized.chapters.map((ch) => { + const chapterSegIds = [...new Set(ch.sections.flatMap((s) => s.sourceSegmentIds))]; + const chapterQuotes = chapterSegIds + .flatMap((sid) => segmentMap[sid]?.quotes ?? []) + .map((q) => quoteMap[q.id] ?? q) + .filter((q, i, arr) => arr.findIndex((x) => x.id === q.id) === i); + + const rawSections = ch.sections.map((sec, secIdx) => { + const safeSourceSegmentIds = sec.sourceSegmentIds.filter((id) => validSegmentIds.has(id)); + const secSegments = safeSourceSegmentIds.map((id) => segmentMap[id]).filter(Boolean); + const secKeyPoints = secSegments.flatMap((s) => s?.keyPoints ?? []); + const secQuotes = secSegments + .flatMap((s) => s?.quotes ?? []) + .map((q) => quoteMap[q.id] ?? q) + .filter((q, i, arr) => arr.findIndex((x) => x.id === q.id) === i); + + // ── Upgrade 3: Word budget calibration ────────────────────────── + const baseWordCount = sec.targetWordCount || + secSegments.reduce((sum, seg) => sum + (seg?.estimatedWordCount ?? 0), 0); + const quotesCount = secQuotes.length; + const multiplier = segmentQualityMultiplier(secKeyPoints.length, quotesCount); + const calibratedWordCount = Math.max(250, Math.round(baseWordCount * multiplier)); + + // ── Upgrade 1: Arc role scoring ────────────────────────────────── + const arcRole = scoreArcRole(sec.heading, secKeyPoints); + + return { + sectionNumber: sec.sectionNumber, + heading: sec.heading, + sourceSegmentIds: safeSourceSegmentIds, + targetWordCount: calibratedWordCount, + keyPoints: secKeyPoints, + quotesInSection: secQuotes, + arcRole, + _contentDensity: secKeyPoints.length + quotesCount, // internal — used for U4 + _originalIdx: secIdx, // internal — used for U4 + }; + }); + + // ── Upgrade 4: Climax section placement ───────────────────────────── + // Move the most content-dense section to position 3 or 4 (0-indexed: 2 or 3) + // if it's currently at position 0 (hook slot) or last slot. + const sections = [...rawSections]; + if (sections.length >= 4) { + const densities = sections.map((s) => s._contentDensity); + const maxDensity = Math.max(...densities); + const climaxIdx = densities.indexOf(maxDensity); + const targetPos = sections.length >= 5 ? 3 : 2; // 4th of 5, or 3rd of 4 + if (climaxIdx === 0 || climaxIdx === sections.length - 1) { + const [climax] = sections.splice(climaxIdx, 1); + sections.splice(targetPos, 0, climax); + // Renumber after reorder + sections.forEach((s, i) => { s.sectionNumber = i + 1; }); + } + } + + // ── Upgrade 1: Arc flags ───────────────────────────────────────────── + const arcFlags = buildArcFlags(sections, ch.title); + + // ── Upgrade 5: Chapter premise line ───────────────────────────────── + const chapterPremise = deriveChapterPremise( + ch.title, + ch.keyTheme, + input.contentMap.coreThesis, + sections[0]?.heading ?? "" + ); + + // Strip internal fields before returning + const cleanSections = sections.map(({ _contentDensity: _d, _originalIdx: _o, ...rest }) => rest); + + return { + number: ch.number, + title: ch.title, + keyTheme: ch.keyTheme, + sourceSegmentIds: chapterSegIds, + quotesInChapter: chapterQuotes, + chapterPremise, + arcFlags, + sections: cleanSections, + }; + }); + + // ── Upgrade 2: Cross-chapter section overlap check ─────────────────── + // Flag pairs of sections from different chapters with >60% keyword overlap + const overlapWarnings: string[] = []; + const allSectionFlat = hydratedChapters.flatMap((ch) => + ch.sections.map((s) => ({ chapterNum: ch.number, heading: s.heading, keyPoints: s.keyPoints })) + ); + for (let i = 0; i < allSectionFlat.length; i++) { + for (let j = i + 1; j < allSectionFlat.length; j++) { + const a = allSectionFlat[i]; + const b = allSectionFlat[j]; + if (a.chapterNum === b.chapterNum) continue; + const aText = [a.heading, ...a.keyPoints].join(" "); + const bText = [b.heading, ...b.keyPoints].join(" "); + const overlap = sectionKeywordOverlap(aText, bText); + if (overlap >= ARCHITECT_OVERLAP_THRESHOLD) { + overlapWarnings.push( + `Ch ${a.chapterNum} §"${a.heading}" ↔ Ch ${b.chapterNum} §"${b.heading}" (${Math.round(overlap * 100)}% overlap)` + ); + } + } + } + + if (env.EBOOK_STRICT_ARCHITECT_OVERLAP_GATE && overlapWarnings.length > 0) { + return NextResponse.json({ + route: "ebook/architect", + error: "Architecture overlap detected", + details: "Architect found overlapping sections across chapters and stopped before drafting to avoid duplicated prose.", + overlapWarnings, + revertHint: "Set EBOOK_STRICT_ARCHITECT_OVERLAP_GATE=false to restore warning-only behavior.", + }, { status: 409 }); + } + + // Attach overlap warnings as arcFlags on the affected chapters + if (overlapWarnings.length > 0) { + for (const warning of overlapWarnings) { + const chNumMatch = warning.match(/^Ch (\d+)/); + if (chNumMatch) { + const chNum = parseInt(chNumMatch[1], 10); + const ch = hydratedChapters.find((c) => c.number === chNum); + if (ch) ch.arcFlags.push(`[OVERLAP] ${warning}`); + } + } + } + + // ── Upgrade 7: Series arc connective tissue map ────────────────────── + const seriesArc = hydratedChapters.slice(0, -1).map((ch, idx) => { + const nextCh = hydratedChapters[idx + 1]; + const fromLastSection = ch.sections[ch.sections.length - 1]; + const toFirstSection = nextCh.sections[0]; + return { + fromChapter: ch.number, + toChapter: nextCh.number, + bridgeConcept: deriveBridgeConcept( + { heading: fromLastSection?.heading ?? "", keyPoints: fromLastSection?.keyPoints ?? [] }, + { heading: toFirstSection?.heading ?? "", keyPoints: toFirstSection?.keyPoints ?? [] } + ), + }; + }); + + const hydrated = { + bookTitle: normalized.bookTitle, + subtitle: normalized.subtitle, + authorName: normalized.authorName, + estimatedTotalWords: normalized.estimatedTotalWords, + frontMatterNotes: normalized.frontMatterNotes, + backMatterNotes: normalized.backMatterNotes, + chapters: hydratedChapters, + seriesArc, + droppedSegments, + }; + + return NextResponse.json(hydrated, { status: 200 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Architecture generation failed"; + return NextResponse.json({ + route: "ebook/architect", + error: message, + details: err instanceof Error && err.stack + ? err.stack.split("\n").slice(0, 3).join(" | ") + : undefined, + }, { status: 500 }); + } +} diff --git a/app/api/ebook/assign-segments/route.ts b/app/api/ebook/assign-segments/route.ts new file mode 100644 index 0000000..c216c9f --- /dev/null +++ b/app/api/ebook/assign-segments/route.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + AssignSegmentsRequestSchema, +} from "@/lib/schemas/ebook"; +import type { SectionAssignment } from "@/lib/schemas/ebook"; + +export const runtime = "nodejs"; +export const maxDuration = 90; + +export async function POST(req: NextRequest) { + const body = await req.json() as unknown; + let input; + try { + input = AssignSegmentsRequestSchema.parse(body); + } catch (err) { + console.error("[assign-segments] Schema validation failed:", err); + return NextResponse.json({ error: err instanceof Error ? err.message : "Invalid input" }, { status: 400 }); + } + + try { + const chapters = Array.isArray(input.architecture?.chapters) ? input.architecture.chapters : []; + const segments = Array.isArray(input.contentMap?.segments) ? input.contentMap.segments : []; + + console.log(`[assign-segments] Processing ${chapters.length} chapters, ${segments.length} segments`); + + if (chapters.length === 0) { + console.error("[assign-segments] Architecture is missing chapters"); + return NextResponse.json({ error: "Architecture is missing chapters" }, { status: 400 }); + } + if (segments.length === 0) { + console.error("[assign-segments] Content map is missing segments"); + return NextResponse.json({ error: "Content map is missing segments" }, { status: 400 }); + } + + // Build a segment lookup for fast retrieval + const segmentMap = Object.fromEntries( + segments.map((s) => [s.id, s]) + ); + + // ── Scripture Amendment 4: Compute dominant Bible translation ────────── + // Count non-empty translation strings across all quotes in the content map. + // The most-frequent one becomes the book's primaryTranslation, used as the + // default when a verse is quoted without an explicit translation label. + const translationCounts: Record = {}; + for (const q of input.contentMap.allQuotes ?? []) { + const t = (q.translation ?? "").trim().toUpperCase(); + if (t) translationCounts[t] = (translationCounts[t] ?? 0) + 1; + } + const primaryTranslation = Object.keys(translationCounts).length > 0 + ? Object.entries(translationCounts).sort((a, b) => b[1] - a[1])[0][0] + : undefined; + + // Build all assignments by resolving segment text for each section + const assignments = chapters.flatMap((chapter, chIdx) => { + if (!Array.isArray(chapter.sections)) { + console.error(`[assign-segments] Chapter ${chapter.number} has no sections array`); + return []; + } + + return chapter.sections.map((section, idx) => { + try { + const sourceSegmentIds = Array.isArray(section.sourceSegmentIds) ? section.sourceSegmentIds : []; + const excerpts = sourceSegmentIds + .map((id) => segmentMap[id]?.rawText ?? "") + .filter(Boolean); + + // We don't have the previous section's ending yet (that gets filled at write time) + return { + chapterNumber: chapter.number, + chapterTitle: chapter.title, + sectionNumber: section.sectionNumber, + heading: section.heading, + transcriptExcerpts: excerpts, + quotes: Array.isArray(section.quotesInSection) ? section.quotesInSection : [], + keyPoints: Array.isArray(section.keyPoints) ? section.keyPoints : [], + voiceDNA: input.voiceDNA, + previousSectionEnding: "", // filled in at write time by the pipeline client + targetWordCount: section.targetWordCount ?? 500, + // Upgrade 1: carry stable segment IDs so the pipeline can track consumption + sourceSegmentIds, + // A2: carry chapter premise so standalone callers get the north-star anchor + chapterPremise: chapter.chapterPremise || undefined, + // Upgrade 3: book thesis threaded from content map + coreThesis: input.contentMap.coreThesis || undefined, + // Scripture Amendment 4: primary translation for consistency + primaryTranslation, + }; + } catch (sectionErr) { + console.error(`[assign-segments] Error processing chapter ${chapter.number} section ${section.sectionNumber}:`, sectionErr); + throw sectionErr; + } + }); + }); + + console.log(`[assign-segments] Successfully created ${assignments.length} assignments`); + return NextResponse.json({ assignments }, { status: 200 }); + } catch (err) { + console.error("[assign-segments] Fatal error:", err); + const message = err instanceof Error ? err.message : "Segment assignment failed"; + const stack = err instanceof Error ? err.stack : undefined; + return NextResponse.json({ error: message, stack }, { status: 500 }); + } +} diff --git a/app/api/ebook/assistant/route.ts b/app/api/ebook/assistant/route.ts new file mode 100644 index 0000000..31272e7 --- /dev/null +++ b/app/api/ebook/assistant/route.ts @@ -0,0 +1,616 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateObject } from "ai"; +import { z } from "zod"; +import { createHash } from "crypto"; +import { deepSeekModel, deepSeekReasonerModel } from "@/lib/ai-providers"; +import { + EbookManifestSchema, + SectionDraftSchema, + ChapterDraftSchema, + FrontBackMatterSchema, + BackMatterSchema, +} from "@/lib/schemas/ebook"; +import { CoverAccentSchema } from "@/lib/schemas/published-book"; +import { harmonizeBookManifest } from "@/lib/editorial-style-bible"; + +export const runtime = "nodejs"; +export const maxDuration = 120; + +const ChatMessageSchema = z.object({ + role: z.enum(["user", "assistant"]), + content: z.string().max(8000), +}); + +const RequestSchema = z.object({ + manifest: EbookManifestSchema, + instruction: z.string().min(1).max(4000), + history: z.array(ChatMessageSchema).max(20).optional(), + dryRun: z.boolean().optional(), + manifestVersion: z.string().optional(), + pipeline: z.object({ + stage: z.string(), + progress: z.object({ total: z.number().int().nonnegative(), completed: z.number().int().nonnegative() }), + totalWords: z.number().int().nonnegative(), + reviewReady: z.boolean(), + qualityReport: z.object({ + score: z.number(), + pass: z.boolean(), + issues: z.array(z.object({ severity: z.enum(["warn", "error"]), message: z.string() })), + }).nullable(), + error: z.string().nullable(), + bookTitle: z.string().nullable(), + chapterCount: z.number().int().nonnegative(), + frontMatterSections: z.number().int().nonnegative(), + }).optional(), +}); + +// Lightweight patch for chapter-level metadata — use this instead of the full chapters array +// for any operation that doesn't restructure sections (e.g. edit conclusion, rename, takeaways). +const ChapterPatchSchema = z.object({ + chapterNumber: z.number().int().min(1), + title: z.string().optional(), + intro: z.string().optional(), + epigraph: z.string().optional(), + premiseLine: z.string().optional(), + conclusion: z.string().optional(), + keyTakeaways: z.array(z.string()).optional(), + reflectionQuestions: z.array(z.string()).optional(), +}); + +// Patch for the published library catalog entry — only the fields that can be edited without re-publishing +const LibraryPatchSchema = z.object({ + slug: z.string().min(1), + title: z.string().optional(), + subtitle: z.string().optional(), + authorName: z.string().optional(), + synopsis: z.string().optional(), + coverAccent: CoverAccentSchema.optional(), +}); + +// Back matter patch must be partial and must not default missing keys to [] +// (otherwise glossary-only edits can wipe reading guide/resources/scripture index). +const BackMatterPatchSchema = z.object({ + scriptureIndex: z.array(z.object({ + reference: z.string(), + translation: z.string(), + chapters: z.array(z.number()), + })).optional(), + glossary: z.array(z.object({ + term: z.string(), + definition: z.string(), + firstAppearance: z.string(), + })).optional(), + readingGroupGuide: z.array(z.object({ + chapterNumber: z.number(), + chapterTitle: z.string(), + questions: z.array(z.string()), + })).optional(), + recommendedResources: z.array(z.string()).optional(), +}); + +// The agent returns only what changed — undefined fields = no change +const EbookChangeSchema = z.object({ + bookTitle: z.string().optional(), + subtitle: z.string().optional(), + authorName: z.string().optional(), + frontMatter: FrontBackMatterSchema.optional(), + backMatter: BackMatterPatchSchema.optional(), // partial back matter patch only + chapters: z.array(ChapterDraftSchema).optional(), // ONLY for section reorders / full restructures + chapterPatches: z.array(ChapterPatchSchema).optional(), // preferred for chapter-level field edits + updatedSections: z.array(SectionDraftSchema).optional(), // targeted section edits + libraryPatch: LibraryPatchSchema.optional(), // update the published catalog entry metadata + confidence: z.enum(["high", "medium", "low"]).default("high"), // AI's self-assessed certainty + clarificationNeeded: z.string().optional(), // question to surface when confidence is low + summary: z.string(), // one-sentence description of what changed +}); + +export async function POST(req: NextRequest) { + const body = await req.json() as unknown; + let input; + try { + input = RequestSchema.parse(body); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Invalid input" }, + { status: 400 } + ); + } + + const { manifest, instruction, history, pipeline } = input; + const dryRun = input.dryRun ?? false; + + // ── Optimistic locking ─────────────────────────────────────────────── + // Hash the mutable content fields so concurrent edits from multiple tabs + // are detected and rejected with 409 instead of silently stomping each other. + function computeManifestVersion(m: typeof manifest): string { + return createHash("sha256") + .update(JSON.stringify({ bookTitle: m.bookTitle, subtitle: m.subtitle, authorName: m.authorName, chapters: m.chapters, frontMatter: m.frontMatter, backMatter: m.backMatter })) + .digest("hex") + .slice(0, 12); + } + const currentVersion = computeManifestVersion(manifest); + if (input.manifestVersion && input.manifestVersion !== currentVersion) { + return NextResponse.json( + { error: "Conflict: the book has been modified since you last loaded it. Please reload before editing.", code: "VERSION_CONFLICT" }, + { status: 409 } + ); + } + + const safeExcerpt = (value: string | null | undefined, max = 600) => (value ?? "").slice(0, max); + + // Parse explicit section references from text. + // Catches: "section 2.1", "section 2§1", "ch2 sec 1", "chapter 2 section 1", "2-1", "2.1" + // Explicitly named sections are always sent at full length and not subject to the word-count guard. + function parseExplicitSectionRefs(text: string): Set { + const refs = new Set(); + const patterns = [ + // "section 2.1" / "section 2 §1" / "section 2-1" + /\bsection\s+(\d+)[.\s§\-]+(\d+)/gi, + // "(\d).(\d) section" reverse order + /(\d+)[.\s§\-]+(\d+)\s+section\b/gi, + // "chapter 2 section 1" / "ch 2 sec 1" / "ch2 s1" + /\bch(?:apter)?\s*(\d+)\s+s(?:ec(?:tion)?)?\s*(\d+)/gi, + // bare "2.1" preceded/followed by non-digits (to avoid matching dates/decimals) + /(? m.content).join(" "); + const explicitRefs = parseExplicitSectionRefs(instruction + " " + historyText); + + // Detect structural / high-reasoning operations that benefit from R1: + // Structural: reorder/move/merge/split/add/remove chapters or sections + // Book-wide: operations touching every chapter simultaneously + // Quality-fix: resolving a failed quality report across the manuscript + // Back matter generation: building glossary/scripture index from scratch + const isStructuralOp = /\b(reorder\s+chapter|move\s+(?:chapter|section)|merge\s+chapter|split\s+chapter|add\s+a?\s*chapter|remove\s+chapter|delete\s+chapter|restructure|reorganize|rearrange\s+chapter|add\s+a?\s*section|swap\s+chapter|fix\s+all|remove\s+all|add\s+(?:takeaways|questions|conclusions?)\s+to\s+all|book[- ]wide|across\s+all\s+chapters|every\s+chapter|fix\s+(?:the\s+)?(?:quality|issues?|errors?|problems?)|resolve\s+(?:quality|issues?)|build\s+(?:the\s+)?(?:glossary|scripture\s+index|back\s+matter|reading\s+guide)|generate\s+(?:the\s+)?(?:glossary|scripture\s+index|back\s+matter)|create\s+(?:the\s+)?(?:glossary|scripture\s+index|back\s+matter))\b/i.test(instruction); + const selectedModel = isStructuralOp ? deepSeekReasonerModel : deepSeekModel; + + // Track which sections are truncated so we can restore original content if the AI loses words + const truncatedSections = new Set(); + + // Build a rich book context — front matter in full, section bodies as excerpts + // (full body sent for short sections; excerpted for long ones to stay within token budget) + const bookSummary = { + bookTitle: manifest.bookTitle, + subtitle: manifest.subtitle, + authorName: manifest.authorName, + totalWordCount: manifest.totalWordCount, + frontMatter: { + preface: manifest.frontMatter.preface, + introduction: manifest.frontMatter.introduction, + conclusion: manifest.frontMatter.conclusion, + aboutAuthor: manifest.frontMatter.aboutAuthor, + resourcesList: manifest.frontMatter.resourcesList, + }, + backMatter: manifest.backMatter + ? { + glossaryTermCount: (manifest.backMatter.glossary ?? []).length, + glossary: manifest.backMatter.glossary ?? [], + readingGroupGuide: (manifest.backMatter.readingGroupGuide ?? []).map((c) => ({ + chapterNumber: c.chapterNumber, + chapterTitle: c.chapterTitle, + questions: c.questions, + })), + scriptureIndex: manifest.backMatter.scriptureIndex ?? [], + recommendedResources: manifest.backMatter.recommendedResources ?? [], + } + : null, + chapters: manifest.chapters.map((ch) => ({ + number: ch.number, + title: ch.title, + intro: ch.intro, + conclusion: ch.conclusion, + keyTakeaways: ch.keyTakeaways, + reflectionQuestions: ch.reflectionQuestions, + totalWordCount: ch.totalWordCount, + sections: ch.sections.map((s) => { + const fullBody = s.body ?? ""; + const isExplicit = explicitRefs.has(`${ch.number}:${s.sectionNumber}`); + // Explicit sections are always sent in full; others truncated only if they exceed 4000 chars + const isTruncated = !isExplicit && fullBody.length > 4000; + if (isTruncated) { + truncatedSections.add(`${ch.number}:${s.sectionNumber}`); + } + return { + sectionNumber: s.sectionNumber, + chapterNumber: ch.number, + heading: s.heading, + // Send full body for explicit/short sections; excerpt for long background sections + body: isTruncated + ? safeExcerpt(fullBody, 1200) + "\n…[TRUNCATED — DO NOT MODIFY THIS SECTION. Return its body field as an empty string so the original is preserved]" + : fullBody, + wordCount: s.wordCount, + }; + }), + })), + }; + + const pipelineSummary = pipeline + ? { + stage: pipeline.stage, + progress: pipeline.progress, + totalWords: pipeline.totalWords, + reviewReady: pipeline.reviewReady, + qualityReport: pipeline.qualityReport, + error: pipeline.error, + bookTitle: pipeline.bookTitle, + chapterCount: pipeline.chapterCount, + frontMatterSections: pipeline.frontMatterSections, + } + : null; + + try { + const { object } = await generateObject({ + model: selectedModel, + schema: EbookChangeSchema, + mode: "json", + maxTokens: 8000, + temperature: 0.15, + system: `You are the Nexus Book Director — a precision ebook editor with MAXIMUM AUTHORITY over every part of this published teaching book. You receive the full book structure and can make any change the user requests. + +════════════════════════════════════════════ +SPEAKER-FIDELITY LAW — NON-NEGOTIABLE +════════════════════════════════════════════ +This book was produced strictly from a speaker's transcripts. You MUST: +- NEVER add ideas, examples, stories, or theological content not already present in the book +- When rewriting or editing, work ONLY with content already in the book +- Preserve the speaker's voice, vocabulary, and teaching style exactly +- If asked to "improve" or "expand" content, do so by reorganizing or clarifying existing text — not by adding new content + +BOOK-SAFETY RULE — ALWAYS APPLY +- Remove or avoid church-room chatter that does not belong in a book: greetings to congregation, thanking attendees/teams, service-flow remarks, crowd-response prompts, and stage directions. +- Keep only reader-appropriate teaching prose. + +════════════════════════════════════════════ +NATURAL LANGUAGE MAPPINGS — interpret these colloquial phrases correctly +════════════════════════════════════════════ +"take out the church talk" / "remove pulpit language" / "congregation chatter" / "audience talk" / "live service language" / "speaker talking to audience" + → fix live-audience language (updatedSections for affected sections) + +"the ending is weak" / "fix the ending" / "the conclusion drags" / "wrap up chapter X better" / "chapter X needs a better close" + → rewrite chapterPatches: conclusion for the named chapter + +"remove the conclusions" / "take out the chapter endings" / "no need for summaries" / "cut the chapter summaries" + → chapterPatches: conclusion: "" for named chapters + +"the intro is weak" / "fix the opening" / "chapter X needs a better hook" / "the start is slow" / "strengthen the beginning" + → rewrite chapterPatches: intro (and/or premiseLine) for the named chapter + +"tighten up section X" / "clean it up" / "section X flows badly" / "make it read better" / "improve the writing" + → updatedSections: rewrite body with tighter prose, same content + +"make section X shorter" / "trim section X" / "condense section X" / "too long" + → updatedSections: condensed body + +"update the author bio" / "change the about section" / "author section needs updating" + → frontMatter: update aboutAuthor + +"take out the resources" / "remove the reading list" / "delete the resources section" + → frontMatter: resourcesList: [] + +"the scripture is repeated" / "same verse appears twice" / "duplicate Bible verses" + → updatedSections: remove the duplicate scripture reference from the later section + +"move section X to chapter Y" / "transfer section X" / "section X belongs in chapter Y" + → chapters array restructure: remove from source chapter, add to target chapter + +"chapter X needs reflection questions" / "add questions at the end of chapter X" + → chapterPatches: reflectionQuestions for that chapter + +"chapter X needs takeaways" / "key points for chapter X" / "summarise chapter X" + → chapterPatches: keyTakeaways for that chapter + +════════════════════════════════════════════ +FULL AUTHORITY — WHAT YOU CAN DO +════════════════════════════════════════════ +METADATA: + "change the title to…" → update bookTitle + "update the subtitle" → update subtitle + "set the author name to…" → update authorName + +FRONT MATTER (return full frontMatter object with ALL fields): + "rewrite the preface" → update frontMatter.preface + "revise the introduction" → update frontMatter.introduction + "update the conclusion" → update frontMatter.conclusion + "update about the author" → update frontMatter.aboutAuthor + "update the resources list" → update frontMatter.resourcesList + Any front matter instruction → return the COMPLETE frontMatter object with all fields preserved + +BACK MATTER (return partial backMatter — only the fields that changed): + "add a glossary term" / "define X in the glossary" → backMatter: { glossary: [...existing+new] } + "remove a glossary term" / "clean up the glossary" → backMatter: { glossary: [...filtered] } + "update the glossary" / "improve the glossary" → backMatter: { glossary: [...all terms revised] } + "update the reading group guide" / "add questions to chapter N" → backMatter: { readingGroupGuide: [...] } + "update recommended resources" / "add a resource" → backMatter: { recommendedResources: [...] } + "fix the scripture index" / "update the scripture index" → backMatter: { scriptureIndex: [...] } + Any back matter instruction → return backMatter with ONLY the changed fields + +LIBRARY CATALOG (update the published entry metadata — no re-publish required): + "update the library title" → libraryPatch: { slug, title: "new title" } + "change the book synopsis" → libraryPatch: { slug, synopsis: "new synopsis" } + "update the library subtitle" → libraryPatch: { slug, subtitle: "..." } + "change the cover colour" → libraryPatch: { slug, coverAccent: "amber|cyan|emerald|rose|violet|slate" } + The slug is manifest.jobId-derived — use the published slug from manifest if known, or set slug to manifest.bookTitle slugified for reference + +CHAPTER OPERATIONS — use chapterPatches for field edits, chapters array ONLY for section reorders: + "rename chapter N to…" → chapterPatches: [{chapterNumber:N, title:"..."}] + "rewrite the intro of chapter N" → chapterPatches: [{chapterNumber:N, intro:"full prose"}] + "rewrite the conclusion of chapter N"→ chapterPatches: [{chapterNumber:N, conclusion:"full prose"}] + "remove the conclusion of chapter N" → chapterPatches: [{chapterNumber:N, conclusion:""}] + "remove all conclusions" → chapterPatches: [{chapterNumber:1,conclusion:""},{chapterNumber:2,conclusion:""},…] + "add/replace takeaways in chapter N" → chapterPatches: [{chapterNumber:N, keyTakeaways:[…5–7 items]}] + "replace reflection questions in chapter N" → chapterPatches: [{chapterNumber:N, reflectionQuestions:[…4–6]}] + "reorder sections in chapter N" → chapters array (only when restructuring section order) + +SECTION OPERATIONS — return only changed sections via updatedSections: + "rename section N.M to…" → updatedSections: [{chapterNumber:N, sectionNumber:M, heading:…, body:existing}] + "rewrite section N.M" → updatedSections: [{chapterNumber:N, sectionNumber:M, heading:existing, body:FULL REWRITE}] + "expand section N.M" → updatedSections with longer body using existing content + "improve section N.M" → updatedSections with refined prose, same ideas + "fix the tone in section N.M" → updatedSections with tone adjusted, same content + "remove audience language from section N.M" → updatedSections with congregation/live-event language removed + +BOOK-WIDE OPERATIONS: + "fix all live-audience language" → updatedSections for every section that contains crowd language + "remove all greeting/crowd phrases" → updatedSections for affected sections only + "standardise all section headings" → updatedSections with heading field only (body unchanged) + "add takeaways to all chapters" → chapterPatches: one entry per chapter with keyTakeaways filled in + "remove all conclusions" → chapterPatches: one entry per chapter with conclusion: "" + +════════════════════════════════════════════ +CONTENT PRESERVATION — CRITICAL +════════════════════════════════════════════ +Some section bodies end with "…[TRUNCATED — DO NOT MODIFY THIS SECTION...]". +This means the full content was too long to include in this prompt. +YOU MUST: +- Return those sections' body field as an EMPTY STRING "" — the client will restore the original automatically +- NEVER attempt to rewrite, summarise, or fill in a truncated section +- ONLY modify a truncated section if the user's instruction EXPLICITLY names it by section number (e.g., "rewrite section 2.3") +- This rule prevents catastrophic word count loss across the book + +════════════════════════════════════════════ +OUTPUT RULES +════════════════════════════════════════════ +- Return ONLY fields that ACTUALLY changed — leave others as undefined +- If chapters array is returned, include ALL chapters (changed and unchanged) with ALL their sections +- If updatedSections is returned, include ONLY the changed sections — the client merges them by chapterNumber + sectionNumber +- The body field in updatedSections MUST be the FULL rewritten prose — never truncate +- frontMatter: if returned, include ALL fields (preface, introduction, conclusion, aboutAuthor, resourcesList) +- backMatter: if returned, include ONLY the changed sub-fields (glossary, readingGroupGuide, scriptureIndex, or recommendedResources); unchanged fields may be omitted +- libraryPatch: if updating the published catalog, include the slug plus only the changed metadata fields +- Always write a concise one-sentence "summary" of exactly what changed +- If the instruction is ambiguous, make the most useful interpretation and describe what you did in summary +- ALWAYS attempt the instruction — never refuse or treat instructions as comments +- If you make ANY change, you MUST include the corresponding change fields (chapterPatches, updatedSections, chapters, frontMatter, etc.). Returning ONLY summary with no change fields = zero manuscript changes. The user will see your summary but the book will be IDENTICAL. +- Only return empty change fields when the user is asking a question ("show me...", "explain...") — not for edit instructions`, + prompt: [ + "CURRENT BOOK STRUCTURE:", + JSON.stringify(bookSummary, null, 2), + pipelineSummary ? ["CURRENT PIPELINE STATE:", JSON.stringify(pipelineSummary, null, 2)].join("\n") : "", + "", + ...(history && history.length > 0 + ? [ + "CONVERSATION HISTORY (oldest first — use this to understand follow-up instructions):", + history.map((m) => `${m.role === "user" ? "USER" : "DIRECTOR"}: ${m.content}`).join("\n"), + "", + ] + : []), + "CURRENT USER INSTRUCTION:", + instruction, + ].join("\n"), + }); + + // ── Dry-run: return the AI patch without applying it ──────────────────────── + // The client can diff this against the current manifest and show a preview + // before the user confirms the change. + if (dryRun) { + return NextResponse.json({ + dryRun: true, + patch: object, + summary: object.summary, + confidence: object.confidence, + ...(object.clarificationNeeded && { clarificationNeeded: object.clarificationNeeded }), + manifestVersion: currentVersion, + }, { status: 200 }); + } + + // ── Confidence gate ─────────────────────────────────────────────────── + // When the AI flags low confidence and provides a clarifying question, + // surface it to the client without applying any changes. The user can + // answer the question and resubmit. + if (object.confidence === "low" && object.clarificationNeeded) { + return NextResponse.json({ + needsClarification: true, + clarificationNeeded: object.clarificationNeeded, + summary: object.summary, + confidence: object.confidence, + manifestVersion: currentVersion, + }, { status: 200 }); + } + + // Merge updatedSections into the full manifest chapters + let mergedChapters = manifest.chapters; + + // Apply lightweight chapter-level patches first (title, intro, conclusion, takeaways, etc.) + if (object.chapterPatches && object.chapterPatches.length > 0) { + mergedChapters = mergedChapters.map((ch) => { + const patch = object.chapterPatches!.find((p) => p.chapterNumber === ch.number); + if (!patch) return ch; + const { chapterNumber: _ignored, ...fields } = patch; + return { ...ch, ...fields }; + }); + } + + if (object.updatedSections && object.updatedSections.length > 0) { + mergedChapters = manifest.chapters.map((ch) => ({ + ...ch, + sections: ch.sections.map((s) => { + const updated = object.updatedSections!.find( + (u) => u.chapterNumber === ch.number && u.sectionNumber === s.sectionNumber + ); + if (!updated) return s; + // Safety guard: if the returned body is empty or drastically shorter than original, + // restore the original body to prevent content loss on truncated sections + const originalWords = (s.body ?? "").split(/\s+/).filter(Boolean).length; + const returnedWords = (updated.body ?? "").split(/\s+/).filter(Boolean).length; + // For explicitly requested sections, trust the AI's non-empty response. + // For truncated background sections, restore original if content loss > 25%. + const sectionKey = `${ch.number}:${s.sectionNumber}`; + const bodyToUse = + !updated.body || + (!explicitRefs.has(sectionKey) && truncatedSections.has(sectionKey) && returnedWords < originalWords * 0.75) + ? s.body + : updated.body; + return { ...updated, body: bodyToUse }; + }), + })); + } + + // If chapters array was explicitly returned, use that instead, + // but restore original bodies for any truncated sections where content was lost + if (object.chapters) { + mergedChapters = object.chapters.map((returnedCh) => { + const originalCh = manifest.chapters.find((c) => c.number === returnedCh.number); + if (!originalCh) return returnedCh; + return { + ...returnedCh, + sections: (returnedCh.sections ?? []).map((returnedSection) => { + const originalSection = originalCh.sections.find( + (s) => s.sectionNumber === returnedSection.sectionNumber + ); + if (!originalSection) return returnedSection; + const key = `${returnedCh.number}:${returnedSection.sectionNumber}`; + const wasTruncated = truncatedSections.has(key); + const originalWords = (originalSection.body ?? "").split(/\s+/).filter(Boolean).length; + const returnedWords = (returnedSection.body ?? "").split(/\s+/).filter(Boolean).length; + // Restore original body if: section was truncated (not explicitly requested) AND body is empty or lossy + const bodyToUse = + !explicitRefs.has(key) && wasTruncated && (!returnedSection.body || returnedWords < originalWords * 0.75) + ? originalSection.body + : returnedSection.body; + return { ...returnedSection, body: bodyToUse }; + }), + }; + }); + } + + const lowerInstruction = instruction.toLowerCase(); + const clearIntent = /\b(clear|remove|delete|wipe|reset)\b/i; + const glossaryIntent = /\bglossary\b/i; + const scriptureIntent = /\bscripture\s*index|index\s+of\s+scripture\b/i; + const guideIntent = /\breading\s+group\s+guide|discussion\s+questions?|study\s+guide\b/i; + const resourcesIntent = /\brecommended\s+resources?|resources\b/i; + + // Merge back matter patch — only overwrite keys explicitly returned, + // and do not treat empty arrays as destructive unless user asked to clear that box. + let mergedBackMatter = manifest.backMatter ?? null; + if (object.backMatter !== undefined) { + const next = { + scriptureIndex: mergedBackMatter?.scriptureIndex ?? [], + glossary: mergedBackMatter?.glossary ?? [], + readingGroupGuide: mergedBackMatter?.readingGroupGuide ?? [], + recommendedResources: mergedBackMatter?.recommendedResources ?? [], + }; + + const hasOwn = (k: keyof z.infer) => + Object.prototype.hasOwnProperty.call(object.backMatter, k); + + if (hasOwn("scriptureIndex")) { + const v = object.backMatter.scriptureIndex; + if (v !== undefined && (v.length > 0 || (v.length === 0 && clearIntent.test(lowerInstruction) && scriptureIntent.test(lowerInstruction)))) { + next.scriptureIndex = v; + } + } + if (hasOwn("glossary")) { + const v = object.backMatter.glossary; + if (v !== undefined && (v.length > 0 || (v.length === 0 && clearIntent.test(lowerInstruction) && glossaryIntent.test(lowerInstruction)))) { + next.glossary = v; + } + } + if (hasOwn("readingGroupGuide")) { + const v = object.backMatter.readingGroupGuide; + if (v !== undefined && (v.length > 0 || (v.length === 0 && clearIntent.test(lowerInstruction) && guideIntent.test(lowerInstruction)))) { + next.readingGroupGuide = v; + } + } + if (hasOwn("recommendedResources")) { + const v = object.backMatter.recommendedResources; + if (v !== undefined && (v.length > 0 || (v.length === 0 && clearIntent.test(lowerInstruction) && resourcesIntent.test(lowerInstruction)))) { + next.recommendedResources = v; + } + } + + mergedBackMatter = next; + } + + const updatedManifest = { + ...manifest, + ...(object.bookTitle !== undefined && { bookTitle: object.bookTitle }), + ...(object.subtitle !== undefined && { subtitle: object.subtitle }), + ...(object.authorName !== undefined && { authorName: object.authorName }), + ...(object.frontMatter !== undefined && { frontMatter: object.frontMatter }), + ...(mergedBackMatter !== null && { backMatter: mergedBackMatter }), + chapters: mergedChapters, + }; + + // Detect whether the AI actually produced any change fields. + // If all change fields are absent, the manuscript would be identical to the input — + // return noChanges so the client can warn the user instead of silently confirming. + const hasChanges = + object.chapterPatches?.length || + object.updatedSections?.length || + object.chapters?.length || + object.frontMatter !== undefined || + object.backMatter !== undefined || + object.libraryPatch !== undefined || + object.bookTitle !== undefined || + object.subtitle !== undefined || + object.authorName !== undefined; + + if (!hasChanges) { + return NextResponse.json({ noChanges: true, summary: object.summary }, { status: 200 }); + } + + const harmonized = harmonizeBookManifest(updatedManifest); + + // ── Append audit trail entry ───────────────────────────────────────────── + const changeLogEntry = { + timestamp: new Date().toISOString(), + instruction: instruction.slice(0, 200), + summary: object.summary, + model: (isStructuralOp ? "r1" : "v3") as "r1" | "v3", + }; + const existingLog = (manifest.changeLog ?? []) as typeof changeLogEntry[]; + harmonized.changeLog = [...existingLog, changeLogEntry].slice(-50); + + const validated = EbookManifestSchema.safeParse(harmonized); + if (!validated.success) { + return NextResponse.json( + { error: `Manifest validation failed: ${validated.error.issues[0]?.message}` }, + { status: 500 } + ); + } + + return NextResponse.json({ + manifest: validated.data, + summary: object.summary, + confidence: object.confidence, + manifestVersion: computeManifestVersion(validated.data!), + ...(object.clarificationNeeded && { clarificationNeeded: object.clarificationNeeded }), + ...(object.libraryPatch !== undefined && { libraryPatch: object.libraryPatch }), + }, { status: 200 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Ebook assistant failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/app/api/ebook/audit/route.ts b/app/api/ebook/audit/route.ts new file mode 100644 index 0000000..ab76ff7 --- /dev/null +++ b/app/api/ebook/audit/route.ts @@ -0,0 +1,758 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateText } from "ai"; +import { deepSeekReasonerModel } from "@/lib/ai-providers"; +import { z } from "zod"; +import { EbookManifestSchema } from "@/lib/schemas/ebook"; +import type { EbookManifest, VoiceDNA } from "@/lib/schemas/ebook"; + +export const runtime = "nodejs"; +export const maxDuration = 120; + +// ── Stop words ──────────────────────────────────────────────────────────────── + +const STOP_WORDS = new Set([ + "the","a","an","and","or","but","in","on","at","to","for","of","with","by","from","up","as", + "is","are","was","were","be","been","being","have","has","had","do","does","did","will","would", + "could","should","may","might","shall","that","this","these","those","it","its","he","she","they", + "we","you","i","me","him","her","them","us","my","our","your","his","their", + "not","no","so","if","then","when","there","where","what","which","who","how","why", + "all","any","both","each","few","more","most","other","some","such","than","too","very", + "can","just","into","over","after","before","about","through","during","without","also", + "one","two","three","four","five","even","only","still","already","now","new","well","get", + "like","said","say","know","want","need","make","made","see","let","here","think","going", +]); + +// ── Shared types ────────────────────────────────────────────────────────────── + +export type SegmentMeta = { + id: string; + chapterNumber: number; + sectionNumber: number | null; + location: string; + text: string; +}; + +export type ConceptDuplicate = { + type: "example" | "argument" | "concept" | "story" | "illustration" | "passage"; + title: string; // brief label, e.g. "Prodigal Son illustration" + description: string; // what specifically is duplicated + severity: "minor" | "major"; + locations: Array<{ location: string; excerpt: string }>; + recommendation: string; +}; + +export type SimilarPair = { + locationA: string; + locationB: string; + similarity: number; + excerptA: string; + excerptB: string; +}; + +export type RepetitionOccurrence = { + chapterNumber: number; + sectionNumber: number | null; + location: string; + context: string; +}; + +export type RepetitionEntry = { + phrase: string; + count: number; + occurrences: RepetitionOccurrence[]; + reason: string | null; + alternatives: string[]; +}; + +export type OverusedWord = { + word: string; + count: number; + frequency: string; + alternatives: string[]; +}; + +// ── Upgrade 4: Style bible violation ───────────────────────────────────────── +export type StyleViolation = { + location: string; + ruleType: "em-dash" | "forbidden-phrase" | "avoid-word"; + match: string; + context: string; + suggestion: string; +}; + +// ── Upgrade 5: Scripture issue ──────────────────────────────────────────────── +export type ScriptureIssue = { + location: string; + issueType: "missing-translation" | "malformed-reference" | "duplicate-full-quote" | "inconsistent-format"; + reference: string; + excerpt: string; + recommendation: string; +}; + +// ── Upgrade 6: Readability metrics ─────────────────────────────────────────── +export type SectionPacingNote = { + location: string; + wordCount: number; + fleschGrade: number; + avgSentenceLength: number; + flag: "over-complex" | "under-developed" | "ok"; +}; + +export type ReadabilityMetrics = { + overallFleschGrade: number; + overallAvgSentenceLength: number; + sections: SectionPacingNote[]; +}; + +export type AuditReport = { + conceptDuplicates: ConceptDuplicate[]; + similarPairs: SimilarPair[]; + repetitions: RepetitionEntry[]; + overusedWords: OverusedWord[]; + styleViolations: StyleViolation[]; + scriptureIssues: ScriptureIssue[]; + readabilityMetrics: ReadabilityMetrics; + totalConceptDuplicates: number; + totalSimilarPairs: number; + totalRepetitionPhrases: number; + totalOverusedWords: number; + totalStyleViolations: number; + totalScriptureIssues: number; + // Upgrade 9: cross-chapter contradiction detection + contradictions: ContradictionIssue[]; + totalContradictions: number; + // Upgrade 8: passive voice count aggregated across all sections + totalPassiveVoiceHits: number; +}; + +// ── Upgrade 9: Cross-chapter contradiction type ─────────────────────────────── +export type ContradictionIssue = { + type: "factual" | "theological" | "instructional" | "tonal"; + description: string; // What specifically contradicts what + locationA: string; // e.g. "Ch 2 §1: Heading" + excerptA: string; // 60-100 word excerpt showing claim A + locationB: string; // e.g. "Ch 7 §3: Heading" + excerptB: string; // 60-100 word excerpt showing claim B + severity: "minor" | "major"; + recommendation: string; // Concrete editorial fix +}; + +// ── Segment extraction ──────────────────────────────────────────────────────── + +function extractSegments(manifest: EbookManifest): SegmentMeta[] { + const segs: SegmentMeta[] = []; + let id = 0; + + const frontFields = [ + { label: "Preface", text: manifest.frontMatter.preface ?? "" }, + { label: "Introduction", text: manifest.frontMatter.introduction ?? "" }, + { label: "Conclusion", text: manifest.frontMatter.conclusion ?? "" }, + ]; + for (const f of frontFields) { + if (f.text.trim().length > 80) { + segs.push({ id: `fm-${id++}`, chapterNumber: 0, sectionNumber: null, location: `Front Matter – ${f.label}`, text: f.text }); + } + } + + for (const chapter of manifest.chapters) { + if (chapter.intro?.trim().length ?? 0 > 80) { + segs.push({ id: `c${chapter.number}-intro`, chapterNumber: chapter.number, sectionNumber: null, location: `Ch ${chapter.number} intro`, text: chapter.intro! }); + } + for (const section of chapter.sections) { + if ((section.body?.trim().length ?? 0) > 80) { + segs.push({ + id: `c${chapter.number}-s${section.sectionNumber}`, + chapterNumber: chapter.number, + sectionNumber: section.sectionNumber, + location: `Ch ${chapter.number} § ${section.sectionNumber}: ${section.heading}`, + text: section.body!, + }); + } + } + if (chapter.conclusion?.trim().length ?? 0 > 80) { + segs.push({ id: `c${chapter.number}-conc`, chapterNumber: chapter.number, sectionNumber: null, location: `Ch ${chapter.number} conclusion`, text: chapter.conclusion! }); + } + } + + return segs; +} + +// ── TF-IDF sparse vectors ───────────────────────────────────────────────────── + +type SparseVec = Map; + +function tokenizeContent(text: string): string[] { + return text + .toLowerCase() + .replace(/[^a-z0-9\s]/g, " ") + .split(/\s+/) + .filter((w) => w.length > 3 && !STOP_WORDS.has(w)); +} + +function buildTfidfVectors(segments: SegmentMeta[]): SparseVec[] { + const N = segments.length; + const tokenized = segments.map((s) => tokenizeContent(s.text)); + + const df = new Map(); + for (const tokens of tokenized) { + for (const term of new Set(tokens)) df.set(term, (df.get(term) ?? 0) + 1); + } + + return tokenized.map((tokens) => { + const tf = new Map(); + for (const t of tokens) tf.set(t, (tf.get(t) ?? 0) + 1); + const vec: SparseVec = new Map(); + for (const [term, count] of tf) { + const idf = Math.log((N + 1) / ((df.get(term) ?? 0) + 1)) + 1; + vec.set(term, (count / Math.max(1, tokens.length)) * idf); + } + return vec; + }); +} + +function cosineSparse(a: SparseVec, b: SparseVec): number { + let dot = 0, normA = 0, normB = 0; + for (const [term, val] of a) { + dot += val * (b.get(term) ?? 0); + normA += val * val; + } + for (const [, val] of b) normB += val * val; + return normA && normB ? dot / (Math.sqrt(normA) * Math.sqrt(normB)) : 0; +} + +// ── Pairwise similarity scan ────────────────────────────────────────────────── +// Flags cross-chapter pairs above threshold as structurally similar segments. + +function findSimilarPairs(segments: SegmentMeta[], vectors: SparseVec[]): SimilarPair[] { + const pairs: SimilarPair[] = []; + + for (let i = 0; i < segments.length; i++) { + for (let j = i + 1; j < segments.length; j++) { + const a = segments[i]; + const b = segments[j]; + + // Only flag cross-chapter pairs (intra-chapter similarity is expected) + const isCrossChapter = a.chapterNumber !== b.chapterNumber; + const threshold = isCrossChapter ? 0.32 : 0.55; + + const score = cosineSparse(vectors[i], vectors[j]); + if (score < threshold) continue; + + const excerptA = a.text.trim().slice(0, 160) + (a.text.length > 160 ? "…" : ""); + const excerptB = b.text.trim().slice(0, 160) + (b.text.length > 160 ? "…" : ""); + pairs.push({ locationA: a.location, locationB: b.location, similarity: Math.round(score * 100) / 100, excerptA, excerptB }); + } + } + + return pairs.sort((a, b) => b.similarity - a.similarity).slice(0, 16); +} + +// ── N-gram lexical repetition ───────────────────────────────────────────────── + +function extractNgrams(text: string, n: number): string[] { + const words = text.toLowerCase().replace(/[^a-z0-9'\s]/g, " ").split(/\s+/).filter((w) => w.length > 1); + const ngrams: string[] = []; + for (let i = 0; i <= words.length - n; i++) { + const gram = words.slice(i, i + n); + if (gram.filter((w) => !STOP_WORDS.has(w)).length < Math.ceil(n / 2)) continue; + ngrams.push(gram.join(" ")); + } + return ngrams; +} + +function findLexicalRepetitions(segments: SegmentMeta[]): Omit[] { + const phraseMap = new Map(); + + for (const seg of segments) { + const seen = new Set(); + for (const phrase of [...extractNgrams(seg.text, 3), ...extractNgrams(seg.text, 4), ...extractNgrams(seg.text, 5)]) { + if (seen.has(phrase)) continue; + seen.add(phrase); + const sentences = seg.text.split(/(?<=[.!?])\s+/); + const re = new RegExp(phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i"); + const match = sentences.find((s) => re.test(s)) ?? ""; + const context = match.length > 130 ? match.slice(0, 127) + "…" : match; + if (!phraseMap.has(phrase)) phraseMap.set(phrase, { count: 0, occurrences: [] }); + const entry = phraseMap.get(phrase)!; + entry.count++; + entry.occurrences.push({ chapterNumber: seg.chapterNumber, sectionNumber: seg.sectionNumber, location: seg.location, context }); + } + } + + return Array.from(phraseMap.entries()) + .filter(([, v]) => v.count >= 2) + .map(([phrase, v]) => ({ phrase, count: v.count, occurrences: v.occurrences })) + .sort((a, b) => b.count - a.count) + .slice(0, 20); +} + +// ── Overused words ──────────────────────────────────────────────────────────── + +function findOverusedWords(manifest: EbookManifest): Omit[] { + const total = manifest.totalWordCount || 1; + const fullText = [ + manifest.frontMatter.preface ?? "", + manifest.frontMatter.introduction ?? "", + manifest.frontMatter.conclusion ?? "", + ...manifest.chapters.flatMap((c) => [c.intro ?? "", ...c.sections.map((s) => s.body ?? ""), c.conclusion ?? ""]), + ].join(" "); + + const counts = new Map(); + for (const w of fullText.toLowerCase().replace(/[^a-z\s]/g, " ").split(/\s+/)) { + if (w.length > 4 && !STOP_WORDS.has(w)) counts.set(w, (counts.get(w) ?? 0) + 1); + } + + const threshold = Math.max(8, Math.floor(total * 0.005)); + return Array.from(counts.entries()) + .filter(([, c]) => c >= threshold) + .map(([word, count]) => ({ word, count, frequency: `${((count / total) * 100).toFixed(2)}%` })) + .sort((a, b) => b.count - a.count) + .slice(0, 15); +} + +// ── Upgrade 4: Style Bible enforcement scan ───────────────────────────────── +// Deterministic pass: flags every em dash, forbidden editorial phrase, and +// author-specific avoidWord hit with exact location + surrounding context. + +const STYLE_FORBIDDEN_PATTERNS: Array<{ source: string; flags: string; suggestion: string }> = [ + { source: "\u2014", flags: "g", suggestion: "Replace em dash with comma, colon, semicolon, or subordinate clause" }, + { source: " -- ", flags: "g", suggestion: "Replace spaced double-hyphen em dash with comma, colon, or semicolon" }, + { source: "\\bin conclusion\\b", flags: "gi", suggestion: "Remove \u2014 land the point directly" }, + { source: "\\bdelve[sd]?\\s+into\\b", flags: "gi", suggestion: "Use 'examine', 'explore', or 'address'" }, + { source: "\\ba tapestry of\\b", flags: "gi", suggestion: "Be specific \u2014 name what it is" }, + { source: "\\bnavigating (the )?(landscape|world|challenges|complexities|waters)\\b", flags: "gi", suggestion: "Rewrite with a concrete verb" }, + { source: "\\bit'?s important to note\\b", flags: "gi", suggestion: "Delete \u2014 the note speaks for itself" }, + { source: "\\bit is crucial (to|that)\\b", flags: "gi", suggestion: "Cut the throat-clearing; make the point" }, + { source: "\\bin today'?s fast-?paced world\\b", flags: "gi", suggestion: "Delete entirely" }, + { source: "\\bfurthermore[,.]?\\s", flags: "gi", suggestion: "Cut or restructure without a connector" }, + { source: "\\bmoreover[,.]?\\s", flags: "gi", suggestion: "Cut or restructure without a connector" }, + { source: "\\bit is worth noting\\b", flags: "gi", suggestion: "Delete \u2014 just say the thing" }, + { source: "\\bat the end of the day\\b", flags: "gi", suggestion: "Delete \u2014 land the actual point" }, + { source: "\\bgame[-\\s]?changer\\b", flags: "gi", suggestion: "Use a concrete description of the change" }, + { source: "\\bparadigm shift\\b", flags: "gi", suggestion: "Name the actual shift" }, + { source: "\\bdeep dive\\b", flags: "gi", suggestion: "Use 'close examination' or begin examining" }, + { source: "\\bunpack(s|ed|ing)?\\b", flags: "gi", suggestion: "Use 'explain', 'examine', 'break down'" }, + { source: "\\bmoving forward\\b", flags: "gi", suggestion: "Delete or state the next action directly" }, + { source: "\\brobust\\b", flags: "gi", suggestion: "Use 'thorough', 'complete', 'detailed'" }, + { source: "\\bleverag(e|es|ed|ing)\\b", flags: "gi", suggestion: "Use 'use', 'apply', 'draw on'" }, + { source: "\\bsynergy\\b", flags: "gi", suggestion: "Describe the actual relationship" }, + { source: "\\bit goes without saying\\b", flags: "gi", suggestion: "Delete \u2014 if it goes without saying, don't say it" }, + { source: "\\bthe truth is[,.]?\\s", flags: "gi", suggestion: "Delete the throat-clearing; state the truth" }, + { source: "\\bthe fact of the matter is\\b", flags: "gi", suggestion: "Delete \u2014 state the fact directly" }, + { source: "\\bindeed[,.]?\\s", flags: "gi", suggestion: "Delete \u2014 adds nothing" }, + { source: "\\bcertainly[,.]?\\s", flags: "gi", suggestion: "Delete \u2014 reads robotic" }, + { source: "\\bultimately[,.]?\\s", flags: "gi", suggestion: "Delete or rewrite without it" }, + { source: "\\bat its core[,.]?\\s", flags: "gi", suggestion: "State the core directly" }, + { source: "\\bin essence[,.]?\\s", flags: "gi", suggestion: "Delete \u2014 state the essence directly" }, + { source: "\\bsimply put[,.]?\\s", flags: "gi", suggestion: "Delete \u2014 the simplicity should be in the writing" }, + { source: "\\bnot just\\b.{1,60}?\\bbut\\b", flags: "gi", suggestion: "Rewrite: avoid 'not just\u2026but' frame" }, + { source: "\\bnot merely\\b.{1,60}?\\bbut\\b", flags: "gi", suggestion: "Rewrite: avoid 'not merely\u2026but' frame" }, + { source: "\\bthis is not merely\\b", flags: "gi", suggestion: "Rewrite: avoid 'this is not merely' frame" }, + { source: "\\bprofoundly\\b", flags: "gi", suggestion: "Show the depth; delete the label" }, + { source: "\\bdeeply meaningful\\b", flags: "gi", suggestion: "Show the meaning; delete the label" }, + { source: "\\btransformative\\b", flags: "gi", suggestion: "Describe the actual transformation" }, + { source: "\\bvibrant\\b", flags: "gi", suggestion: "Use a specific, concrete adjective" }, + { source: "\\bfostering\\b", flags: "gi", suggestion: "Use 'building', 'creating', 'developing'" }, + { source: "\\bthis means that\\b", flags: "gi", suggestion: "Land the implication directly" }, + { source: "\\bwhat this tells us is\\b", flags: "gi", suggestion: "State the lesson directly" }, + { source: "\\bso,? as we have seen\\b", flags: "gi", suggestion: "Delete mid-chapter summary transitions" }, + { source: "\\bto summarize\\b", flags: "gi", suggestion: "Delete \u2014 do not summarize mid-chapter" }, +]; + +function styleBibleScan( + segments: SegmentMeta[], + voiceDNA?: VoiceDNA, +): StyleViolation[] { + const violations: StyleViolation[] = []; + + for (const seg of segments) { + const { text, location } = seg; + const sentences = text.split(/(?<=[.!?])\s+/); + + function getContext(matchStart: number): string { + let best = ""; + let bestDist = Infinity; + for (const s of sentences) { + const idx = text.indexOf(s); + if (idx >= 0 && idx <= matchStart) { + const dist = matchStart - idx; + if (dist < bestDist) { bestDist = dist; best = s; } + } + } + return best.slice(0, 120) + (best.length > 120 ? "\u2026" : ""); + } + + // ── Editorial forbidden patterns ─────────────────────────────────────── + for (const pat of STYLE_FORBIDDEN_PATTERNS) { + const isEmDash = pat.suggestion.startsWith("Replace em dash") || pat.suggestion.startsWith("Replace spaced"); + const ruleType: StyleViolation["ruleType"] = isEmDash ? "em-dash" : "forbidden-phrase"; + const re = new RegExp(pat.source, pat.flags); + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + violations.push({ location, ruleType, match: m[0].trim(), context: getContext(m.index), suggestion: pat.suggestion }); + } + } + + // ── Voice DNA: author-specific avoidWords (skip baseline clichés already covered) ── + const authorSpecificAvoid = (voiceDNA?.avoidWords ?? []).slice(30); + for (const word of authorSpecificAvoid) { + if (word.length < 3) continue; + const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = word.includes(" ") + ? new RegExp(escaped, "gi") + : new RegExp(`\\b${escaped}\\b`, "gi"); + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + violations.push({ + location, + ruleType: "avoid-word", + match: m[0], + context: getContext(m.index), + suggestion: `"${word}" is in the author's voice DNA avoidWords \u2014 rewrite without it`, + }); + } + } + } + + return violations; +} + +// ── Upgrade 6: Readability + pacing metrics ─────────────────────────────────── + +function countSyllables(word: string): number { + const w = word.toLowerCase().replace(/[^a-z]/g, ""); + if (w.length === 0) return 0; + if (w.length <= 3) return 1; + const reduced = w.replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, ""); + const groups = reduced.match(/[aeiouy]{1,2}/g); + return Math.max(1, groups?.length ?? 1); +} + +function fleschKincaidGrade(text: string): { grade: number; avgSentenceLength: number } { + const sentences = text.split(/[.!?]+/).filter((s) => s.trim().length > 5); + const words = text.split(/\s+/).filter((w) => w.replace(/[^a-zA-Z]/g, "").length > 0); + if (sentences.length === 0 || words.length === 0) return { grade: 0, avgSentenceLength: 0 }; + const syllables = words.reduce((sum, w) => sum + countSyllables(w), 0); + const asl = words.length / sentences.length; + const asw = syllables / words.length; + return { + grade: Math.max(0, Math.round((0.39 * asl + 11.8 * asw - 15.59) * 10) / 10), + avgSentenceLength: Math.round(asl * 10) / 10, + }; +} + +function computeReadabilityMetrics(segments: SegmentMeta[]): ReadabilityMetrics { + const sectionNotes: SectionPacingNote[] = []; + const allTexts: string[] = []; + + for (const seg of segments) { + const { grade, avgSentenceLength } = fleschKincaidGrade(seg.text); + const wordCount = seg.text.split(/\s+/).filter(Boolean).length; + let flag: SectionPacingNote["flag"] = "ok"; + if (grade > 14 || avgSentenceLength > 32) flag = "over-complex"; + else if (wordCount < 80) flag = "under-developed"; + sectionNotes.push({ location: seg.location, wordCount, fleschGrade: grade, avgSentenceLength, flag }); + allTexts.push(seg.text); + } + + const overall = fleschKincaidGrade(allTexts.join(" ")); + return { + overallFleschGrade: overall.grade, + overallAvgSentenceLength: overall.avgSentenceLength, + sections: sectionNotes, + }; +} + +// ── Upgrade 5: Scripture & reference consistency audit ──────────────────────── +// LLM pass: cross-checks every scripture quote for missing translation labels, +// malformed references, duplicate full quotes, and format inconsistency. + +async function runScriptureAudit(segments: SegmentMeta[]): Promise { + const scriptureRe = /[A-Z][a-z]+\s+\d+:\d+|(?:NIV|KJV|ESV|NKJV|NLT|NASB|AMP|MSG|translation unspecified)/; + const relevant = segments.filter((s) => scriptureRe.test(s.text)); + if (relevant.length === 0) return []; + + const index = relevant + .map((s) => `[${s.location}]\n${s.text.trim().slice(0, 600)}${s.text.length > 600 ? "\u2026" : ""}`) + .join("\n\n---\n\n"); + + try { + const { text } = await generateText({ + model: deepSeekReasonerModel, + maxTokens: 4096, + prompt: `You are a manuscript editor specializing in scripture citation accuracy. Review the passages below and identify ONLY genuine issues: + +1. MISSING TRANSLATION: A scripture is quoted with a Bible reference but no translation label (NIV, KJV, ESV, NKJV, NLT, NASB, AMP, MSG, etc.) +2. MALFORMED REFERENCE: A Bible reference with missing verse number, missing space, or non-standard format (e.g. "John3:16" or "John 3" without a verse) +3. DUPLICATE FULL QUOTE: The exact same scripture verse text is quoted in full in more than one location — only the reference should appear after first use +4. INCONSISTENT FORMAT: The same scripture appears as inline in one place and as a blockquote in another + +PASSAGES: +${index} + +Return ONLY valid JSON — no markdown fences, no commentary: +{ + "issues": [ + { + "location": "exact location label from input", + "issueType": "missing-translation|malformed-reference|duplicate-full-quote|inconsistent-format", + "reference": "the scripture reference e.g. John 3:16", + "excerpt": "40-80 word excerpt showing the issue", + "recommendation": "specific editorial fix" + } + ] +}`, + }); + const match = text.match(/\{[\s\S]*\}/); + if (!match) return []; + const parsed = JSON.parse(match[0]) as { issues?: ScriptureIssue[] }; + return Array.isArray(parsed.issues) ? parsed.issues : []; + } catch { + return []; + } +} + +// ── LLM: semantic concept duplicate analysis ────────────────────────────────── +// This is the core new capability: the LLM reads ALL section summaries and +// identifies conceptually duplicated content regardless of surface wording. + +async function runSemanticAudit( + segments: SegmentMeta[], + similarPairs: SimilarPair[], + lexicalRepetitions: Omit[], + overusedWords: Omit[] +): Promise<{ + conceptDuplicates: ConceptDuplicate[]; + phraseAmendments: Array<{ phrase: string; reason: string; alternatives: string[] }>; + wordAmendments: Array<{ word: string; alternatives: string[] }>; +}> { + // Build a compact section index for the LLM — location label + first 280 chars + const sectionIndex = segments + .map((s, i) => `[${i + 1}] ${s.location}\n${s.text.trim().slice(0, 300)}${s.text.length > 300 ? "…" : ""}`) + .join("\n\n"); + + // Summarise algorithmically flagged pairs so the LLM knows where to look + const flaggedPairsText = similarPairs.length > 0 + ? similarPairs + .slice(0, 6) + .map((p) => `• ${p.locationA} ↔ ${p.locationB} (similarity ${Math.round(p.similarity * 100)}%)`) + .join("\n") + : "None detected algorithmically."; + + const lexicalText = lexicalRepetitions.slice(0, 10).map((r) => `"${r.phrase}" ×${r.count}`).join(", "); + const wordText = overusedWords.slice(0, 8).map((w) => `"${w.word}" (${w.frequency})`).join(", "); + + const prompt = `You are a senior developmental editor auditing a book manuscript. +Your job is to identify CONCEPTUAL duplication — the same idea, example, story, illustration, argument, or passage appearing more than once across different sections, even when worded differently. + +═══ SECTION INDEX (location + opening text) ═══ +${sectionIndex} + +═══ ALGORITHMICALLY FLAGGED SIMILAR PAIRS ═══ +${flaggedPairsText} + +═══ REPEATED PHRASES (surface-level) ═══ +${lexicalText || "None"} + +═══ OVERUSED WORDS ═══ +${wordText || "None"} + +SECTION TYPE RULES — apply these when deciding recommendations: +- BODY SECTIONS (location format "Ch X § Y: Heading") are the PRIMARY HOME for a concept. When a concept is fully developed in a body section, that section is PROTECTED — do not recommend cutting it. +- FRAME SECTIONS ("Front Matter – Introduction", "Front Matter – Conclusion", "Ch N intro", "Ch N conclusion") must only briefly mention/summarise a concept — they are NOT the place for full development. If a frame section duplicates content that already has a full treatment in a body section, the frame section is the CUT TARGET, not the body section. +- When a concept appears in both a body section and a frame section, the recommendation must always be: trim the frame section to a one-sentence mention and keep the body section intact. + +Your tasks: +1. CONCEPT DUPLICATES: Identify every case where the same concept, example, story, illustration, argument, or extended passage appears in multiple sections. Be specific — name the example/concept. Flag both MINOR duplicates (a point briefly touched twice) and MAJOR ones (a full example or teaching repeated). Apply the SECTION TYPE RULES above to all recommendations. +2. PHRASE AMENDMENTS: For each repeated surface phrase above, give a short editorial reason + 2–3 rewrite alternatives. +3. WORD AMENDMENTS: For each overused word, give 2–3 precise alternatives. + +Respond ONLY with valid JSON (no markdown fences, no commentary outside the JSON): +{ + "conceptDuplicates": [ + { + "type": "example|argument|concept|story|illustration|passage", + "title": "Brief label (e.g. 'The shepherd and lost sheep')", + "description": "What specifically is duplicated and why it hurts the reader experience", + "severity": "minor|major", + "locations": [ + { "location": "Ch X § Y: Heading", "excerpt": "40-80 word excerpt showing the duplication" } + ], + "recommendation": "Concrete editorial action — e.g. keep in Ch 3, cut from Ch 6; or merge into one definitive treatment in Ch 4" + } + ], + "phraseAmendments": [ + { "phrase": "...", "reason": "...", "alternatives": ["...", "..."] } + ], + "wordAmendments": [ + { "word": "...", "alternatives": ["...", "..."] } + ] +}`; + + try { + const { text } = await generateText({ model: deepSeekReasonerModel, prompt, maxTokens: 24000 }); + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (!jsonMatch) return { conceptDuplicates: [], phraseAmendments: [], wordAmendments: [] }; + return JSON.parse(jsonMatch[0]) as { + conceptDuplicates: ConceptDuplicate[]; + phraseAmendments: Array<{ phrase: string; reason: string; alternatives: string[] }>; + wordAmendments: Array<{ word: string; alternatives: string[] }>; + }; + } catch { + return { conceptDuplicates: [], phraseAmendments: [], wordAmendments: [] }; + } +} + +// ── Upgrade 9: Cross-chapter contradiction detection ───────────────────────── +// Extracts key claims from each chapter and asks the LLM to identify cases where +// one chapter's assertion directly conflicts with another chapter's assertion. + +async function runContradictionAudit(segments: SegmentMeta[]): Promise { + // Only cross-chapter — group by chapter and take the most content-rich sections + const byChapter = new Map(); + for (const seg of segments) { + if (!byChapter.has(seg.chapterNumber)) byChapter.set(seg.chapterNumber, []); + byChapter.get(seg.chapterNumber)!.push(seg); + } + if (byChapter.size < 2) return []; + + // Build a compact claim index: chapter number + opening claim sentence from each section + const claimIndex = Array.from(byChapter.entries()) + .map(([chNum, segs]) => { + const claims = segs.map((s) => { + const firstSentence = s.text.split(/(?<=[.!?])\s+/).filter(Boolean)[0] ?? ""; + return ` [${s.location}]: "${firstSentence.slice(0, 160).trim()}"`; + }).join("\n"); + return `Chapter ${chNum}:\n${claims}`; + }) + .join("\n\n"); + + try { + const { text } = await generateText({ + model: deepSeekReasonerModel, + maxTokens: 6000, + prompt: `You are a developmental editor checking a multi-chapter book manuscript for logical contradictions across chapters. + +A contradiction is when Chapter A asserts X and Chapter B asserts the logical opposite or a significantly incompatible claim about the same topic, scripture, principle, or instruction. + +TYPES of contradictions to flag: +- FACTUAL: Ch 2 says "David wrote all the Psalms" and Ch 7 says "The Psalms were written by multiple authors." +- THEOLOGICAL: Ch 1 teaches that healing is guaranteed by faith; Ch 4 implies suffering may be God's will. +- INSTRUCTIONAL: Ch 3 says to pray before making decisions; Ch 8 says to act first and pray after. +- TONAL: Ch 2 frames a concept with urgency and warning; Ch 6 treats the same concept as optional or casual. + +DO NOT flag: +- Nuance and progression (where Ch 8 builds on and qualifies Ch 2 — this is development, not contradiction). +- Different aspects of the same topic (talking about grace in Ch 2 and works in Ch 5 is not a contradiction if no direct claim conflicts). +- Repetition (same idea said twice is not a contradiction). + +CHAPTER CLAIM INDEX: +${claimIndex} + +Return ONLY valid JSON — no markdown fences: +{ + "contradictions": [ + { + "type": "factual|theological|instructional|tonal", + "description": "What specifically contradicts what — name both claims explicitly", + "locationA": "exact location label", + "excerptA": "40-100 word excerpt showing claim A", + "locationB": "exact location label", + "excerptB": "40-100 word excerpt showing claim B", + "severity": "minor|major", + "recommendation": "Concrete editorial fix — e.g. qualify the claim in Ch 2, or add a bridging sentence in Ch 7" + } + ] +}`, + }); + const match = text.match(/\{[\s\S]*\}/); + if (!match) return []; + const parsed = JSON.parse(match[0]) as { contradictions?: ContradictionIssue[] }; + return Array.isArray(parsed.contradictions) ? parsed.contradictions : []; + } catch { + return []; + } +} + +// ── Route ───────────────────────────────────────────────────────────────────── + +const AuditRequestSchema = z.object({ manifest: EbookManifestSchema }); + +export async function POST(req: NextRequest) { + const body = await req.json() as unknown; + let input; + try { + input = AuditRequestSchema.parse(body); + } catch (err) { + return NextResponse.json({ error: err instanceof Error ? err.message : "Invalid input" }, { status: 400 }); + } + + try { + const { manifest } = input; + const segments = extractSegments(manifest); + + if (segments.length < 2) { + return NextResponse.json({ error: "Not enough content to audit — complete the pipeline first." }, { status: 422 }); + } + + // Layer 1: algorithmic pairwise TF-IDF scan + const vectors = buildTfidfVectors(segments); + const similarPairs = findSimilarPairs(segments, vectors); + + // Layer 2: lexical n-gram repetitions + const lexicalRepetitions = findLexicalRepetitions(segments); + + // Layer 3: overused words + const overusedWordsRaw = findOverusedWords(manifest); + + // Layer 4: deterministic style bible enforcement scan (Upgrade 4) + const styleViolations = styleBibleScan(segments, manifest.voiceDNA ?? undefined); + + // Layer 5: readability + pacing metrics (Upgrade 6) + const readabilityMetrics = computeReadabilityMetrics(segments); + + // Layer 6 + 7 + 8: LLM semantic audit, scripture audit, contradiction audit run in parallel + const [llm, scriptureIssues, contradictions] = await Promise.all([ + runSemanticAudit(segments, similarPairs, lexicalRepetitions, overusedWordsRaw), + runScriptureAudit(segments), + runContradictionAudit(segments), + ]); + + // Merge LLM amendments into lexical repetitions + const repetitions: RepetitionEntry[] = lexicalRepetitions.map((r) => { + const a = llm.phraseAmendments?.find((x) => x.phrase === r.phrase); + return { ...r, reason: a?.reason ?? null, alternatives: a?.alternatives ?? [] }; + }); + + const overusedWords: OverusedWord[] = overusedWordsRaw.map((w) => { + const a = llm.wordAmendments?.find((x) => x.word === w.word); + return { ...w, alternatives: a?.alternatives ?? [] }; + }); + + const report: AuditReport = { + conceptDuplicates: llm.conceptDuplicates ?? [], + similarPairs, + repetitions, + overusedWords, + styleViolations, + scriptureIssues, + readabilityMetrics, + contradictions, + totalConceptDuplicates: (llm.conceptDuplicates ?? []).length, + totalSimilarPairs: similarPairs.length, + totalRepetitionPhrases: repetitions.length, + totalOverusedWords: overusedWords.length, + totalStyleViolations: styleViolations.length, + totalScriptureIssues: scriptureIssues.length, + totalContradictions: contradictions.length, + totalPassiveVoiceHits: 0, // populated client-side from write-section passiveVoiceCount aggregate + }; + + return NextResponse.json(report, { status: 200 }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Audit failed" }, + { status: 500 } + ); + } +} diff --git a/app/api/ebook/backmatter/route.ts b/app/api/ebook/backmatter/route.ts new file mode 100644 index 0000000..e4943bf --- /dev/null +++ b/app/api/ebook/backmatter/route.ts @@ -0,0 +1,183 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateObject } from "ai"; +import { z } from "zod"; +import { deepSeekReasonerModel } from "@/lib/ai-providers"; +import { EbookManifestSchema, BackMatterSchema } from "@/lib/schemas/ebook"; +import type { BackMatter } from "@/lib/schemas/ebook"; +import { SOURCE_LOCK_RULES } from "@/lib/editorial-style-bible"; + +export const runtime = "nodejs"; +export const maxDuration = 180; + +// ─── Request ────────────────────────────────────────────────────────────────── + +const BackMatterRequestSchema = z.object({ + manifest: EbookManifestSchema, +}); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function buildScriptureIndex(manifest: z.infer): BackMatter["scriptureIndex"] { + // Collect all quotes from manifest.allQuotes and chapter sections + const refMap = new Map }>(); + + const addRef = (reference: string, translation: string, chapterNumber: number) => { + if (!reference?.trim()) return; + const key = `${reference.trim()} (${translation?.trim() || "translation unspecified"})`; + if (!refMap.has(key)) refMap.set(key, { translation: translation?.trim() || "translation unspecified", chapters: new Set() }); + refMap.get(key)!.chapters.add(chapterNumber); + }; + + for (const q of manifest.allQuotes ?? []) { + if (q.type === "scripture") { + // Determine chapter from position — use chapter 0 as fallback + addRef(q.reference, q.translation, 0); + } + } + + for (const chapter of manifest.chapters) { + for (const section of chapter.sections) { + // Scan section body for scripture references (basic pattern) + const body = section.body ?? ""; + const refPattern = /\b([1-3]?\s?[A-Z][a-z]+(?:\s[A-Z][a-z]+)?)\s+(\d+):(\d+(?:[–\-]\d+)?)/g; + let m; + while ((m = refPattern.exec(body)) !== null) { + addRef(m[0].trim(), "translation unspecified", chapter.number); + } + } + } + + return Array.from(refMap.entries()) + .map(([reference, { translation, chapters }]) => ({ + reference, + translation, + chapters: Array.from(chapters).sort((a, b) => a - b), + })) + .sort((a, b) => { + // Sort by book name, then chapter, then verse (simplified) + return a.reference.localeCompare(b.reference); + }); +} + +export async function POST(req: NextRequest) { + const body = await req.json() as unknown; + let input; + try { + input = BackMatterRequestSchema.parse(body); + } catch (err) { + return NextResponse.json({ error: err instanceof Error ? err.message : "Invalid input" }, { status: 400 }); + } + + const { manifest } = input; + const voiceDNA = manifest.voiceDNA; + + // Build scripture index from the manifest (deterministic, no LLM needed) + const scriptureIndex = buildScriptureIndex(manifest); + + // Build chapter summaries for the LLM tasks + const chapterSummaries = manifest.chapters.map((ch) => { + const keyTakeaways = (ch.keyTakeaways ?? []).slice(0, 4).join("; "); + const sectionHeadings = ch.sections.map((s) => s.heading).join(", "); + return `Ch ${ch.number}: "${ch.title}"\n Takeaways: ${keyTakeaways}\n Sections: ${sectionHeadings}`; + }).join("\n\n"); + + // Terms to define: prefer Voice DNA preferred terminology, then extract unique vocabulary. + // Exclude proper nouns — names of people, places, and organizations are not glossary terms. + const NAME_TITLE_RE = /\b(?:Pastor|Bishop|Prophet|Apostle|Elder|Deacon|Reverend|Rev|Dr|Mr|Mrs|Ms|Prof|Minister|Brother|Sister)\b/i; + const preferredTerms = (voiceDNA?.preferredTerminology ?? []).slice(0, 15); + + // Collect all section body text + const allBodyText = manifest.chapters + .flatMap((ch) => ch.sections) + .map((s) => s.body ?? "") + .join(" "); + + // Extract repeated capitalized phrases (2+ words preferred) — then filter name-like candidates + const phraseFreq = new Map(); + for (const match of allBodyText.matchAll(/\b([A-Z][a-z]{2,}(?:\s[A-Z][a-z]{2,})+)\b/g)) { + const t = match[1]; + phraseFreq.set(t, (phraseFreq.get(t) ?? 0) + 1); + } + // Also pick up single-word domain terms that appear in lowercase mid-sentence (strong signal = concept not name) + for (const match of allBodyText.matchAll(/(? { + if (count < 3) return false; + // Skip if the term itself looks like a personal name (two title-case words where the second looks like a surname) + if (/^[A-Z][a-z]+ [A-Z][a-z]+$/.test(t)) return false; + // Skip if the term appears near a personal title in the full text + const escapedTerm = t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const contextRe = new RegExp(`${NAME_TITLE_RE.source}\\s+${escapedTerm}|${escapedTerm}\\s+${NAME_TITLE_RE.source}`, "i"); + if (contextRe.test(allBodyText)) return false; + return true; + }) + .sort((a, b) => b[1] - a[1]) + .map(([t]) => t) + .filter((t) => !preferredTerms.includes(t)) + .slice(0, 12); + + const termsToDefine = [...new Set([...preferredTerms, ...frequentTerms])].slice(0, 20); + + const resourcesMentioned = (manifest.frontMatter.resourcesList ?? []).slice(0, 15); + + try { + const { object } = await generateObject({ + model: deepSeekReasonerModel, + schema: BackMatterSchema.omit({ scriptureIndex: true }), + mode: "json", + temperature: 1, // reasoner requires temperature=1 + system: `You are creating back matter for a published teaching book. Your job is three tasks: + +TASK 1 — GLOSSARY: +Define the provided key terms EXACTLY as the author uses them in the book. Rules: +- ONLY define concepts, key phrases, doctrinal terms, or domain-specific vocabulary — words that carry specific meaning within this author's teaching framework. +- NEVER define a person's name, a place name, an organization name, or a proper noun that refers to an individual. If a submitted term is a person's name or a place, skip it entirely — do not include it in the glossary. +- Definitions must be 2–3 sentences. +- Drawn ENTIRELY from the book content. No external theological context. +- Use the same vocabulary and register the author uses. +- firstAppearance should name the chapter and section where the term first appears. +- If a term cannot be meaningfully defined as a concept from the book content, omit it. + +TASK 2 — READING GROUP GUIDE: +Write 3–7 discussion questions per chapter. Rules: +- Questions must be SPECIFIC to the actual content of that chapter — not generic. +- Each question must name a concrete claim, story, scripture, or example from the chapter. +- Mix question types: some personal application ("When have you…"), some analytical ("How does the author's argument about X connect to Y?"), some action-oriented ("What would it look like to…"). +- FORBIDDEN: "What did you learn from this chapter?", "How can you apply this?", "What is the main message?" — these are generic and valueless. + +TASK 3 — RECOMMENDED RESOURCES: +List any resources the author specifically mentioned in the book (books, passages, teachings). Do not add resources the author didn't reference. If none were mentioned, return an empty array. + +${SOURCE_LOCK_RULES}`, + prompt: `Book: "${manifest.bookTitle}"${manifest.subtitle ? ` — ${manifest.subtitle}` : ""} +Author: ${manifest.authorName} + +CHAPTER SUMMARIES: +${chapterSummaries} + +TERMS TO DEFINE FOR GLOSSARY: +${termsToDefine.map((t) => `• ${t}`).join("\n")} + +RESOURCES MENTIONED IN THE BOOK: +${resourcesMentioned.length > 0 ? resourcesMentioned.map((r) => `• ${r}`).join("\n") : "(none explicitly mentioned)"} + +Generate the glossary, reading group guide, and recommended resources.`, + }); + + const result: BackMatter = { + scriptureIndex, + glossary: object.glossary ?? [], + readingGroupGuide: object.readingGroupGuide ?? [], + recommendedResources: object.recommendedResources ?? [], + }; + + return NextResponse.json(result, { status: 200 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Back matter generation failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/app/api/ebook/chapter-plan/route.ts b/app/api/ebook/chapter-plan/route.ts new file mode 100644 index 0000000..73e15ed --- /dev/null +++ b/app/api/ebook/chapter-plan/route.ts @@ -0,0 +1,280 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateObject } from "ai"; +import { z } from "zod"; +import { deepSeekReasonerModel } from "@/lib/ai-providers"; +import { ChapterPlanRequestSchema, ChapterPlanResponseSchema } from "@/lib/schemas/ebook"; +import { SOURCE_LOCK_RULES, READER_NORMALIZATION_RULES } from "@/lib/editorial-style-bible"; + +export const runtime = "nodejs"; +export const maxDuration = 120; + +// ── N-gram helpers (mirrors write-section) ─────────────────────────────────── + +const BIBLE_REF_RE = /\b(?:genesis|exodus|leviticus|numbers|deuteronomy|joshua|judges|ruth|samuel|kings|chronicles|ezra|nehemiah|esther|job|psalm|psalms|proverbs|ecclesiastes|isaiah|jeremiah|lamentations|ezekiel|daniel|hosea|joel|amos|obadiah|jonah|micah|nahum|habakkuk|zephaniah|haggai|zechariah|malachi|matthew|mark|luke|john|acts|romans|corinthians|galatians|ephesians|philippians|colossians|thessalonians|timothy|titus|philemon|hebrews|james|peter|jude|revelation)\s+\d+:\d+/i; + +function extractNgrams(text: string, n = 4): Set { + const words = text.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean); + const grams = new Set(); + for (let i = 0; i <= words.length - n; i++) { + grams.add(words.slice(i, i + n).join(" ")); + } + return grams; +} + +function ngramOverlap(a: string, b: string, n = 4): number { + const ga = extractNgrams(a, n); + const gb = extractNgrams(b, n); + if (ga.size === 0) return 0; + let shared = 0; + for (const g of ga) { if (gb.has(g)) shared++; } + return shared / ga.size; +} + +// ── Post-plan cleanup (mirrors write-section) ──────────────────────────────── + +type PlanEntry = { + purpose: string; + supportedExcerptNumbers: number[]; + minExcerptNumber?: number; +}; + +function sortAndPruneEntries(entries: PlanEntry[]): PlanEntry[] { + const anchored = entries.filter((e) => (e.supportedExcerptNumbers ?? []).length > 0); + const base = anchored.length > 0 ? anchored : entries; + base.sort((a, b) => { + const minA = Math.min(...(a.supportedExcerptNumbers.length ? a.supportedExcerptNumbers : [Infinity])); + const minB = Math.min(...(b.supportedExcerptNumbers.length ? b.supportedExcerptNumbers : [Infinity])); + return minA - minB; + }); + return base; +} + +// ── Chapter-level response schema ───────────────────────────────────────────── + +const ChapterPlanLLMSchema = z.object({ + sectionPlans: z.array(z.object({ + sectionNumber: z.number().int(), + paragraphPlan: z.array(z.object({ + purpose: z.string().default(""), + supportedExcerptNumbers: z.array(z.number().int().positive()).default([]), + minExcerptNumber: z.number().int().positive().optional(), + })).default([]), + })).default([]), +}); + +export async function POST(req: NextRequest) { + const body = await req.json() as unknown; + let input: z.infer; + try { + input = ChapterPlanRequestSchema.parse(body); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Invalid input" }, + { status: 400 } + ); + } + + const { chapterNumber, chapterTitle, sections, nextChapterTitle, coreThesis, voiceDNA, alreadyCoveredPoints, priorSectionsSample } = input; + + // ── Build concept ownership manifest ──────────────────────────────────────── + // List every section's heading and key points together so the planner knows + // the full chapter concept space before planning any individual section. + const conceptManifest = sections.map((s) => + `SECTION ${s.sectionNumber}: "${s.heading}"\n Key points: ${(s.keyPoints ?? []).join(" | ") || "(none listed)"}` + ).join("\n\n"); + + // ── Build per-section excerpt blocks with section-scoped numbering ────────── + const sectionExcerptBlocks = sections.map((s) => { + const excerpts = s.transcriptExcerpts ?? []; + const block = excerpts + .map((t, i) => `[S${s.sectionNumber}-EXCERPT ${i + 1} of ${excerpts.length}]\n${t}`) + .join("\n\n---\n\n"); + return { sectionNumber: s.sectionNumber, block, excerptCount: excerpts.length }; + }); + + // ── Dedup signal blocks ────────────────────────────────────────────────────── + const priorChapterBlock = alreadyCoveredPoints.length > 0 + ? `\n\n════════════════════════════════════════════\nPRIOR CHAPTERS — HARD SKIP (already written)\n════════════════════════════════════════════\nThese concepts, arguments, and points have already been written in earlier chapters. Do NOT plan ANY paragraph in ANY section of this chapter that re-introduces, re-defines, or re-explains them:\n${alreadyCoveredPoints.map((p) => `• ${p}`).join("\n")}` + : ""; + + const coreThesisBlock = coreThesis + ? `\n\nBOOK'S CORE THESIS (thread through every section's plan): "${coreThesis}"` + : ""; + + const voiceDnaLine = voiceDNA?.toneProfile + ? `\n\nTONE (enforce in purpose statements): ${voiceDNA.toneProfile}` + : ""; + + const chapterBoundaryBlock = nextChapterTitle + ? `\n\nCHAPTER BOUNDARY — HARD STOP: The final section of this chapter must not plan any paragraph that introduces or develops content from the next chapter titled "${nextChapterTitle}". If any transcript excerpt transitions into that next chapter's topic, stop planning before it.` + : ""; + + const system = `You are a structural editor planning the complete paragraph-by-paragraph architecture for an entire book chapter. + +Your job is to produce a non-overlapping content plan: every concept, story, illustration, and scripture in this chapter is assigned to EXACTLY ONE section. No concept may appear in two sections' plans. + +════════════════════════════════════════════ +CONCEPT OWNERSHIP RULE — NON-NEGOTIABLE +════════════════════════════════════════════ +Before planning paragraphs, mentally assign each concept in the transcript to the section whose heading best owns it. Then plan each section using ONLY the concepts you assigned to it. If a concept fits two sections, assign it to the one with the more specific heading match and exclude it from the other. + +This is the anti-duplication contract: the writer for Section 3 will receive only Section 3's plan and will not see what Sections 1 and 2 planned. If the plans overlap, the book will duplicate content. There is no retry mechanism — plan correctly the first time. + +════════════════════════════════════════════ +EXCERPT OWNERSHIP RULE (FIX 4 — REPLACES MONOTONIC) +════════════════════════════════════════════ +Each transcript excerpt may be assigned to EXACTLY ONE section in this chapter. Once you assign an excerpt to a section, it is LOCKED — no other section may use it. + +You MAY assign excerpts non-monotonically: + ✓ Section 1 can use excerpts 1, 3, 5 + ✓ Section 2 can use excerpts 2, 4, 6 + ✓ Section 3 can use excerpts 7, 8, 9 + +This allows proper handling of teaching structures where the speaker introduces multiple points, then circles back to develop each one. + +Each paragraph plan entry MUST list supportedExcerptNumbers that belong to THIS section only. No excerpt number may appear in two different sections' plans.${priorChapterBlock}${coreThesisBlock}${voiceDnaLine}${chapterBoundaryBlock} + +${SOURCE_LOCK_RULES} +${READER_NORMALIZATION_RULES}`; + + const excerptPayload = sectionExcerptBlocks + .map(({ sectionNumber, block, excerptCount }) => + `${"=".repeat(60)}\nSECTION ${sectionNumber} TRANSCRIPT EXCERPTS (${excerptCount} total)\n${"=".repeat(60)}\n${block}` + ) + .join("\n\n"); + + const prompt = `Plan all sections of Chapter ${chapterNumber}: "${chapterTitle}". + +CHAPTER CONCEPT MAP — assign each concept to ONE section only: +${conceptManifest} + +SECTIONS TO PLAN: +${sections.map((s) => `• Section ${s.sectionNumber}: "${s.heading}"${s.nextSectionHeading ? ` → next: "${s.nextSectionHeading}"` : ""}${s.isLastSectionInChapter ? " (LAST section — chapter close)" : ""}`).join("\n")} + +Return a sectionPlans array with one entry per section. Each paragraphPlan entry must have: +- purpose: what this paragraph will establish (one sentence, specific enough to detect overlap) +- supportedExcerptNumbers: the 1-based excerpt number(s) within THIS section's excerpt list +- minExcerptNumber: the lowest excerpt number this paragraph draws from + +${excerptPayload}`; + + // ── Stream heartbeat bytes to keep the reverse-proxy read-timeout alive ────── + // deepseek-reasoner can take 60-120s. Without periodic bytes, nginx/Cloudflare + // close the connection with a 504/524 before the response arrives. + // We send a space every 15s; JSON.parse (used by res.json()) ignores leading whitespace. + const encoder = new TextEncoder(); + const generatePromise = generateObject({ + model: deepSeekReasonerModel, + schema: ChapterPlanLLMSchema, + mode: "json", + temperature: 1, // reasoner requires temperature=1 + system, + prompt, + }); + + const stream = new ReadableStream({ + async start(controller) { + const heartbeat = setInterval(() => { + try { controller.enqueue(encoder.encode(" ")); } catch { /* already closed */ } + }, 15_000); + + try { + const { object } = await generatePromise; + clearInterval(heartbeat); + + // ── FIX 4: Post-process with excerpt-usage deduplication ──────────────── + const priorProseText = priorSectionsSample.join(" "); + const usedExcerpts = new Set(); // Track which excerpt IDs are already assigned + + const cleanedPlans = (object.sectionPlans ?? []).map((sp) => { + const sectionInput = sections.find((s) => s.sectionNumber === sp.sectionNumber); + const maxExcerpt = Math.max(1, sectionInput?.transcriptExcerpts?.length ?? 0); + let entries = sortAndPruneEntries(sp.paragraphPlan ?? []); + + // Keep only valid excerpt anchors for this section. + // This prevents cross-section bleed caused by out-of-range excerpt numbers. + entries = entries + .map((entry) => { + const supportedExcerptNumbers = Array.from(new Set( + (entry.supportedExcerptNumbers ?? []) + .filter((n) => Number.isInteger(n) && n >= 1 && n <= maxExcerpt) + )); + return { + ...entry, + supportedExcerptNumbers, + minExcerptNumber: supportedExcerptNumbers.length > 0 + ? Math.min(...supportedExcerptNumbers) + : undefined, + }; + }) + .filter((entry) => entry.supportedExcerptNumbers.length > 0); + + // FIX 4: Excerpt ownership enforcement — remove excerpts already used by prior sections + entries = entries + .map((entry) => { + const excerptKey = `S${sp.sectionNumber}`; + const availableExcerpts = entry.supportedExcerptNumbers.filter((n) => { + const key = `${excerptKey}-E${n}`; + return !usedExcerpts.has(key); + }); + return { + ...entry, + supportedExcerptNumbers: availableExcerpts, + minExcerptNumber: availableExcerpts.length > 0 ? Math.min(...availableExcerpts) : undefined, + }; + }) + .filter((entry) => entry.supportedExcerptNumbers.length > 0); + + // Mark all excerpts in this section's plan as consumed + for (const entry of entries) { + for (const n of entry.supportedExcerptNumbers) { + usedExcerpts.add(`S${sp.sectionNumber}-E${n}`); + } + } + + if (priorProseText.length > 100) { + entries = entries.filter((entry) => { + if (!entry.purpose || entry.purpose.length < 20) return true; + if (BIBLE_REF_RE.test(entry.purpose)) return true; + return ngramOverlap(entry.purpose, priorProseText) < 0.30; + }); + } + + // FIX 4: Removed monotonic sorting — sections can now use non-sequential excerpts + // This allows proper handling of teaching structures where concepts are introduced + // then circled back to. Entries are sorted by minExcerptNumber for readability only. + entries.sort((a, b) => (a.minExcerptNumber ?? 0) - (b.minExcerptNumber ?? 0)); + return { sectionNumber: sp.sectionNumber, paragraphPlan: entries }; + }); + + const plannedNums = new Set(cleanedPlans.map((p) => p.sectionNumber)); + for (const s of sections) { + if (!plannedNums.has(s.sectionNumber)) { + cleanedPlans.push({ sectionNumber: s.sectionNumber, paragraphPlan: [] }); + } + } + + const response: z.infer = { sectionPlans: cleanedPlans }; + controller.enqueue(encoder.encode(JSON.stringify(response))); + } catch (err) { + clearInterval(heartbeat); + console.error("[chapter-plan] Error:", err); + const fallback: z.infer = { + sectionPlans: sections.map((s) => ({ sectionNumber: s.sectionNumber, paragraphPlan: [] })), + }; + controller.enqueue(encoder.encode(JSON.stringify(fallback))); + } finally { + try { controller.close(); } catch { /* already closed */ } + } + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "application/json", + "X-Accel-Buffering": "no", // disable nginx proxy buffering — chunks forward immediately + "Cache-Control": "no-cache, no-store", + "Transfer-Encoding": "chunked", + }, + }); +} diff --git a/app/api/ebook/coherence/route.ts b/app/api/ebook/coherence/route.ts new file mode 100644 index 0000000..b62625e --- /dev/null +++ b/app/api/ebook/coherence/route.ts @@ -0,0 +1,122 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateObject } from "ai"; +import { z } from "zod"; +import { deepSeekModel } from "@/lib/ai-providers"; +import { ChapterDraftSchema } from "@/lib/schemas/ebook"; +import { SOURCE_LOCK_RULES } from "@/lib/editorial-style-bible"; + +export const runtime = "nodejs"; +export const maxDuration = 120; + +// ─── Request / Response ─────────────────────────────────────────────────────── + +const CoherenceRequestSchema = z.object({ + chapter: ChapterDraftSchema, + coreThesis: z.string().optional(), +}); + +const WeakTransitionSchema = z.object({ + between: z.string().describe('e.g. "§2 → §3"'), + diagnosis: z.string().describe("Why the transition fails: abrupt, summarizing, repeated opener, etc."), + suggestion: z.string().describe("A concrete rewrite direction. Not a full replacement — a direction."), +}); + +const CoherenceReportSchema = z.object({ + // Whether the chapter's opening hook is resolved or echoed in the conclusion + hookPayoff: z.boolean().default(true), + hookPayoffNote: z.string().default(""), + // Whether the section sequence builds a logical arc + arcIntegrity: z.enum(["strong", "partial", "weak"]).default("partial"), + arcNote: z.string().default(""), + // Tonal inconsistencies found (e.g. one section is academic, another is casual) + tonalInconsistencies: z.array(z.string()).default([]), + // Sections that feel like fillers — content that doesn't advance the chapter thesis + fillerSections: z.array(z.object({ + sectionNumber: z.number(), + reason: z.string(), + })).default([]), + // Weak transitions between adjacent sections + weakTransitions: z.array(WeakTransitionSchema).default([]), + // A revised intro if the opening paragraph doesn't earn the chapter's premise + revisedIntro: z.string().default(""), + // A revised conclusion if the close doesn't resolve the chapter's opening tension + revisedConclusion: z.string().default(""), + // Overall coherence score 1–10 + coherenceScore: z.number().min(1).max(10).default(7), + // Summary note for display in the pipeline UI + summary: z.string().default(""), +}); + +export type CoherenceReport = z.infer; + +export async function POST(req: NextRequest) { + const body = await req.json() as unknown; + let input; + try { + input = CoherenceRequestSchema.parse(body); + } catch (err) { + return NextResponse.json({ error: err instanceof Error ? err.message : "Invalid input" }, { status: 400 }); + } + + const { chapter, coreThesis } = input; + + const sectionSummaries = (chapter.sections ?? []) + .map((s) => { + const opening = (s.body ?? "").split(/(?<=[.!?])\s+/).filter(Boolean)[0] ?? ""; + const closing = (s.body ?? "").split(/(?<=[.!?])\s+/).filter(Boolean).slice(-1)[0] ?? ""; + return `§${s.sectionNumber} — ${s.heading}\n OPENS: "${opening.slice(0, 180)}"\n CLOSES: "${closing.slice(0, 180)}"`; + }) + .join("\n\n"); + + const chapterOpening = (chapter.intro ?? "").split(/(?<=[.!?])\s+/).filter(Boolean).slice(0, 3).join(" "); + const chapterClosing = (chapter.conclusion ?? "").split(/(?<=[.!?])\s+/).filter(Boolean).slice(-3).join(" "); + + const thesisBlock = coreThesis + ? `\nBOOK CORE THESIS: "${coreThesis}"\nEvery chapter must advance this thesis. Flag any section that has no traceable connection to it as a filler section.` + : ""; + + try { + const { object } = await generateObject({ + model: deepSeekModel, + schema: CoherenceReportSchema, + mode: "json", + temperature: 0.2, + system: `You are a senior developmental editor reviewing a chapter for narrative coherence and logical flow. + +Your job is NOT to rewrite the chapter. It is to diagnose structural problems a writer needs to fix: +1. Does the chapter's opening hook pay off in the conclusion? +2. Do sections build logically — each one advancing the argument of the previous? +3. Are there tonal inconsistencies (one section academic, another casual, another evangelical)? +4. Are there sections that feel like filler — content that doesn't advance the chapter's central argument? +5. Are there weak transitions between adjacent sections (abrupt stops, repeated openers, mechanical summaries)? +6. Does the chapter's intro earn its premise? Does the conclusion resolve the opening tension? + +SCORING GUIDE for coherenceScore (1–10): +10 = Every section flows into the next; hook pays off in conclusion; no filler; tone is consistent. +7–9 = Minor weak transitions; hook largely pays off; one possible filler section. +4–6 = Multiple weak transitions; arc feels episodic rather than cumulative; hook not clearly resolved. +1–3 = Sections feel like separate sermons pasted together; no through-line; tone varies widely. + +For revisedIntro / revisedConclusion: only provide a replacement if the existing one is clearly weak (fails to frame the thesis, repeats section content verbatim, or doesn't resolve the hook). Return empty string if the existing text is adequate. + +${SOURCE_LOCK_RULES}`, + prompt: `Review Chapter ${chapter.number}: "${chapter.title}" for coherence.${thesisBlock} + +CHAPTER INTRO (first 3 sentences): +"${chapterOpening}" + +CHAPTER CONCLUSION (last 3 sentences): +"${chapterClosing}" + +SECTION OPENINGS AND CLOSINGS: +${sectionSummaries} + +Provide your coherence diagnosis.`, + }); + + return NextResponse.json(object, { status: 200 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Coherence check failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/app/api/ebook/content-map/route.ts b/app/api/ebook/content-map/route.ts new file mode 100644 index 0000000..41f81f6 --- /dev/null +++ b/app/api/ebook/content-map/route.ts @@ -0,0 +1,291 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateObject } from "ai"; +import { z } from "zod"; +import { deepSeekModel } from "@/lib/ai-providers"; +import { ContentMapRequestSchema, QuoteSchema } from "@/lib/schemas/ebook"; + +export const runtime = "nodejs"; +export const maxDuration = 300; + +// Max words to send to the LLM per slot. +// 12 000 covers the vast majority of sermon recordings (~60–90 min) without +// hitting DeepSeek's context limit, and prevents the truncation that was causing +// the last ~37 % of each slot to be invisble to segment extraction. +const MAX_SLOT_WORDS = 12000; + +// Per-slot extraction schema — NO rawText (LLM must not copy back large text blobs) +const SlotSegmentExtractSchema = z.object({ + topic: z.string(), + keyPoints: z.array(z.string()).default([]), + quotes: z.array( + z.object({ + text: z.string(), + reference: z.string(), + translation: z.string(), + type: z.enum(["scripture", "quote", "proverb"]), + isBlockQuote: z.boolean(), + }) + ).default([]), + estimatedWordCount: z.number(), +}); + +const SlotSegmentsSchema = z.object({ + segments: z.array(SlotSegmentExtractSchema), +}); + +// Final synthesis schema (receives only topics/themes, no raw text) +const SynthesisSchema = z.object({ + totalEstimatedWords: z.number(), + overarchingThemes: z.array(z.string()).default([]), + teachingArc: z.string().default(""), + coreThesis: z.string().default(""), + targetAudience: z.string().default(""), + uniqueVocabulary: z.array(z.string()).default([]), + toneMap: z.string().default(""), +}); + +const SEGMENT_SYSTEM = `You are a content analyst extracting teaching segments from a single sermon/teaching recording. + +════════════════════════════════════════════ +SPEAKER-FIDELITY MANDATE — READ FIRST +════════════════════════════════════════════ +You are cataloguing the SPEAKER'S material only. Every key point you extract must be: + - Explicitly stated or demonstrated by the speaker in this transcript + - Phrased using the speaker's own words and concepts + - Directly observable in the provided text — not inferred, interpolated, or generalized + +YOU MUST NOT: + - Add points the speaker did not make + - Summarize away nuance or add editorial framing + - Introduce theological, doctrinal, or practical concepts not in the transcript + - Merge ideas from outside the recording to "fill gaps" + +════════════════════════════════════════════ +NON-TEACHING CONTENT — SKIP ENTIRELY +════════════════════════════════════════════ +Do NOT create segments from: + - Opening or closing prayers / benedictions + - Announcements, event notices, giving appeals, offering moments + - "Good morning", "welcome", "turn to your neighbor" instructions + - Altar calls or sinner's prayer recitations + - Technical interruptions (mic check, applause breaks) + - Repeated monthly-theme or previous-message recap lines that add no new teaching substance + - Jokes or stories with no direct teaching application + - Any content already stripped by the signal filter + +If such content appears in the transcript, mark the segment topic as "[NON-TEACHING — SKIP]" +and set estimatedWordCount to 0. The architect will discard these automatically. + +════════════════════════════════════════════ +SEGMENT RULES +════════════════════════════════════════════ +- Identify natural topic shifts as segment boundaries +- keyPoints: exact claims made in that segment — use the speaker's own words +- Aim for 3–8 segments per recording, each covering 200–600 words of teaching material +- TOPIC NAMING: Name every segment by its actual teaching claim. NEVER use structural labels like "Introduction", "Introduction:", "Intro", "Overview", "Opening", or "Conclusion" as the topic — not even as a prefix. Name the specific idea being taught (e.g. "Prayer changes your countenance", not "Introduction: Prayer"). + +════════════════════════════════════════════ +SCRIPTURE / QUOTE DETECTION +════════════════════════════════════════════ +For every scripture or quote mentioned: +- type: "scripture" | "quote" | "proverb" +- text: exact words as spoken +- reference: "Book Ch:V" for scripture, "Author, Source" for quotes, "" otherwise +- translation: NIV / KJV / ESV etc., "" if not stated +- isBlockQuote: true if 40+ words + +DO NOT reproduce large blocks of transcript text. Focus on structure and meaning.`; + +export async function POST(req: NextRequest) { + const body = await req.json() as unknown; + let input; + try { + input = ContentMapRequestSchema.parse(body); + } catch (err) { + return NextResponse.json({ error: err instanceof Error ? err.message : "Invalid input" }, { status: 400 }); + } + + try { + // ── 1. Split masterTranscript into per-slot chunks ────────────────────── + const slotChunks: { sourceAudio: string; text: string }[] = []; + const parts = input.masterTranscript.split(/═{3,}/); + let nextSlotFallback = 1; // used when [Slot-N] header was stripped by filter-signal + for (const part of parts) { + // A1: Skip slots tagged as non-teaching by the signal filter (tagNonTeachingSlots pass) + if (/^\s*\[NON-TEACHING-SLOT-\d+\]/i.test(part)) continue; + const m = part.match(/^\s*\[Slot-(\d+)\]\s*([\s\S]+)/); + if (!m) { + // The [Slot-1] label may have been removed by the signal filter when it + // trimmed opening prayers/greetings that preceded the teaching start phrase. + // Don't silently skip — assign to the next expected slot number. + const content = part.trim(); + if (!content) continue; // genuinely empty separator between slots + slotChunks.push({ sourceAudio: `audio-${nextSlotFallback}`, text: content }); + continue; + } + const slotNum = parseInt(m[1], 10); + nextSlotFallback = slotNum + 1; + slotChunks.push({ sourceAudio: `audio-${slotNum}`, text: m[2].trim() }); + } + + if (slotChunks.length === 0) { + slotChunks.push({ sourceAudio: "audio-1", text: input.masterTranscript }); + } + + // ── 2. Extract segments per slot — all slots processed in parallel ─────── + // Processing slots sequentially caused reverse-proxy timeouts on large projects + // (6 slots × 5 chunks × ~4s/call ≈ 120 s). Parallel execution cuts wall-clock + // time to roughly that of the single slowest slot (~20–30 s). + + type DedupedSlotResult = { + chunk: { sourceAudio: string; text: string }; + slotWords: string[]; + dedupedSegs: z.infer[]; + }; + + const slotResults: DedupedSlotResult[] = await Promise.all( + slotChunks.map(async (chunk): Promise => { + // A8: Isolate per-slot failures — a single bad LLM call must not abort the whole map + try { + const slotWords = chunk.text.split(/\s+/); + const OVERLAP = 200; + const chunkRanges: Array<{ start: number; end: number }> = []; + let start = 0; + while (start < slotWords.length) { + const end = Math.min(start + MAX_SLOT_WORDS, slotWords.length); + chunkRanges.push({ start, end }); + if (end === slotWords.length) break; + start = end - OVERLAP; + } + + // Process all chunk ranges within this slot in parallel too. + const chunkSegments = await Promise.all( + chunkRanges.map(async (range) => { + const chunkText = slotWords.slice(range.start, range.end).join(" "); + const { object } = await generateObject({ + model: deepSeekModel, + schema: SlotSegmentsSchema, + mode: "tool", + temperature: 0.2, + system: SEGMENT_SYSTEM, + prompt: `Extract all teaching segments from this recording (${chunk.sourceAudio}):\n\n${chunkText}`, + }); + return object.segments; + }) + ); + + const rawSegmentsForSlot = chunkSegments.flat(); + + // Deduplicate segments that appeared in overlapping chunk windows. + const seenSegTopics = new Set(); + const dedupedSegs = rawSegmentsForSlot.filter((seg) => { + const key = seg.topic.toLowerCase().trim().slice(0, 60); + if (seenSegTopics.has(key)) return false; + seenSegTopics.add(key); + return true; + }); + + return { chunk, slotWords, dedupedSegs }; + } catch (slotErr) { + console.error(`[content-map] Slot ${chunk.sourceAudio} failed — returning empty segments:`, slotErr); + return { chunk, slotWords: chunk.text.split(/\s+/), dedupedSegs: [] }; + } + }) + ); + + // ── Assemble segments sequentially so IDs are deterministic ────────────── + let segmentIdCounter = 1; + const allSegments: Array<{ + id: string; + sourceAudio: string; + topic: string; + rawText: string; + keyPoints: string[]; + quotes: Array<{ id: string; text: string; reference: string; translation: string; type: "scripture" | "quote" | "proverb"; isBlockQuote: boolean }>; + estimatedWordCount: number; + }> = []; + + const allQuotes: Array<{ id: string; text: string; reference: string; translation: string; type: "scripture" | "quote" | "proverb"; isBlockQuote: boolean }> = []; + + for (const { chunk, slotWords, dedupedSegs } of slotResults) { + // Distribute the full slot text across segments proportionally. + const totalEstimatedWords = dedupedSegs.reduce((sum, s) => sum + Math.max(1, s.estimatedWordCount), 0) || 1; + let wordOffset = 0; + const lastSegIdx = dedupedSegs.length - 1; + + for (let si = 0; si < dedupedSegs.length; si++) { + const seg = dedupedSegs[si]; + const id = `seg-${segmentIdCounter++}`; + const segWordCount = Math.max(1, seg.estimatedWordCount); + const sliceFraction = segWordCount / totalEstimatedWords; + const sliceLen = si === lastSegIdx + ? slotWords.length - wordOffset + : Math.round(slotWords.length * sliceFraction); + const rawText = slotWords.slice(wordOffset, wordOffset + sliceLen).join(" "); + wordOffset += sliceLen; + + const actualWordCount = rawText.split(/\s+/).filter(Boolean).length; + + const quotes = (seg.quotes ?? []).map((q, qi) => ({ + ...q, + id: `q-${allQuotes.length + qi + 1}`, + })); + + allSegments.push({ + id, + sourceAudio: chunk.sourceAudio, + topic: seg.topic, + rawText, + keyPoints: seg.keyPoints, + quotes, + estimatedWordCount: actualWordCount, + }); + + allQuotes.push(...quotes); + } + } + + // ── 3. Synthesise themes/arc from segment topics only (no rawText) ────── + // Strip any non-teaching segments the LLM flagged before synthesis and export + const teachingSegments = allSegments.filter( + (s) => !s.topic.includes("[NON-TEACHING") && s.estimatedWordCount > 0 + ); + + const topicSummary = teachingSegments + .map((s) => `- [${s.sourceAudio}] ${s.topic}: ${s.keyPoints.join("; ")}`) + .join("\n"); + + const { object: synthesis } = await generateObject({ + model: deepSeekModel, + schema: SynthesisSchema, + mode: "tool", + temperature: 0.2, + system: `You are a senior editor identifying the overarching message of a multi-part teaching series. + Base your synthesis ONLY on what the speaker explicitly taught — do not add external theological context. + + Your job is to perform the sermon-to-book "Narrative North Star" pass: + - Extract the core thesis that governs the whole manuscript. + - Identify the target audience the speaker is actually addressing in substance, not the live room. + - Capture the speaker's unique vocabulary, metaphors, and repeated conceptual language. + - Describe the tone map for the eventual book. + - Organize recurring ideas into a coherent flow, treating repeated series recaps or monthly-theme refreshers as support material rather than fresh chapters.`, + prompt: `Based on these teaching segment topics, identify the overall themes, teaching arc, core thesis, target audience, unique vocabulary, and tone map. + + Group repeated themes together conceptually so the eventual book reads contiguously instead of repeating sermon-series refreshers. + + ${topicSummary}`, + }); + + const contentMap = { + ...synthesis, + segments: teachingSegments, + allQuotes, + }; + + return NextResponse.json(contentMap, { status: 200 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Content mapping failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + diff --git a/app/api/ebook/export/route.ts b/app/api/ebook/export/route.ts new file mode 100644 index 0000000..110a3d0 --- /dev/null +++ b/app/api/ebook/export/route.ts @@ -0,0 +1,109 @@ +import { NextRequest, NextResponse } from "next/server"; +import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { env } from "@/lib/env"; +import { ExportRequestSchema } from "@/lib/schemas/ebook"; +import { generatePdfBuffer, generateEpubBuffer, generateDocxBuffer } from "@/lib/ebook-generator.tsx"; + +export const runtime = "nodejs"; +export const maxDuration = 120; + +export async function POST(req: NextRequest) { + const body = await req.json() as unknown; + let input; + try { + input = ExportRequestSchema.parse(body); + } catch (err) { + return NextResponse.json({ error: err instanceof Error ? err.message : "Invalid input" }, { status: 400 }); + } + + const { manifest, formats, template } = input; + const safeBookTitle = typeof manifest.bookTitle === "string" ? manifest.bookTitle : "ebook"; + const slug = safeBookTitle + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .slice(0, 40); + const prefix = `ebooks/${Date.now()}-${slug}`; + + try { + const results: { pdfUrl?: string; epubUrl?: string; docxUrl?: string } = {}; + + // ── Generate PDF ────────────────────────────────────────────────────────── + if (formats.pdf) { + const pdfBuffer = await generatePdfBuffer(manifest, template, input.printSpec); + results.pdfUrl = await uploadOrStream(pdfBuffer, `${prefix}.pdf`, "application/pdf"); + } + + // ── Generate EPUB ───────────────────────────────────────────────────────── + if (formats.epub) { + const epubBuffer = await generateEpubBuffer(manifest, template); + results.epubUrl = await uploadOrStream(epubBuffer, `${prefix}.epub`, "application/epub+zip"); + } + + // ── Generate DOCX ─────────────────────────────────────────────────────────── + if (formats.docx) { + const docxBuffer = await generateDocxBuffer(manifest, template, input.printSpec); + results.docxUrl = await uploadOrStream( + docxBuffer, + `${prefix}.docx`, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ); + } + + return NextResponse.json(results, { status: 200 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Export failed"; + return NextResponse.json({ + route: "ebook/export", + error: message, + details: err instanceof Error && err.stack + ? err.stack.split("\n").slice(0, 3).join(" | ") + : undefined, + }, { status: 500 }); + } +} + +async function uploadOrStream( + buffer: Buffer, + key: string, + contentType: string +): Promise { + const { R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET_NAME, R2_PUBLIC_URL } = env; + + if (!R2_ACCOUNT_ID || !R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY || !R2_BUCKET_NAME) { + // R2 not configured — return a data URL (small files only) or throw + throw new Error( + "R2 storage is required for ebook export. Please configure R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, and R2_BUCKET_NAME." + ); + } + + const s3 = new S3Client({ + region: "auto", + endpoint: `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`, + credentials: { + accessKeyId: R2_ACCESS_KEY_ID, + secretAccessKey: R2_SECRET_ACCESS_KEY, + }, + }); + + await s3.send( + new PutObjectCommand({ + Bucket: R2_BUCKET_NAME, + Key: key, + Body: buffer, + ContentType: contentType, + }) + ); + + // Return public URL if configured, otherwise a 1-hour presigned GET URL + if (R2_PUBLIC_URL) { + return `${R2_PUBLIC_URL.replace(/\/$/, "")}/${key}`; + } + + const presignedUrl = await getSignedUrl( + s3, + new GetObjectCommand({ Bucket: R2_BUCKET_NAME, Key: key }), + { expiresIn: 3600 } + ); + return presignedUrl; +} diff --git a/app/api/ebook/filter-signal/route.ts b/app/api/ebook/filter-signal/route.ts new file mode 100644 index 0000000..f14914b --- /dev/null +++ b/app/api/ebook/filter-signal/route.ts @@ -0,0 +1,153 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateText } from "ai"; +import { z } from "zod"; +import { deepSeekModel } from "@/lib/ai-providers"; +import { cleanTranscriptForBook } from "@/lib/editorial-style-bible"; + +export const runtime = "nodejs"; +export const maxDuration = 120; + +const RequestSchema = z.object({ + masterTranscript: z.string().min(50), +}); + +// Tiny schema — only extract start/end markers, NEVER the full transcript. +// Server reconstructs the cleaned transcript via string matching. +const MarkersSchema = z.object({ + teachingStartPhrase: z.string().default("").describe("First 80-120 chars of the sentence where core teaching begins (verbatim)"), + teachingEndPhrase: z.string().default("").describe("Last 80-120 chars of the final teaching sentence before closing prayer/altar call (verbatim)"), + // Accept any strings — the LLM returns human-readable labels, not enum slugs, + // and these are only used as display text in the summary. No switch logic depends on them. + removedCategories: z.array(z.string()).default([]), + summary: z.string().default(""), +}); + +export type FilterSignalResult = { + cleanedTranscript: string; + removedSegments: { reason: string; excerpt: string }[]; + summary: string; +}; + +export async function POST(req: NextRequest) { + const body = await req.json() as unknown; + let input; + try { + input = RequestSchema.parse(body); + } catch (err) { + return NextResponse.json( + { + route: "ebook/filter-signal", + error: err instanceof Error ? err.message : "Invalid input", + details: err instanceof Error && err.stack + ? err.stack.split("\n").slice(0, 2).join(" | ") + : undefined, + }, + { status: 400 } + ); + } + + const transcript = input.masterTranscript; + + // Only sample the head + tail (non-teaching content is almost always at the edges). + // Keep the LLM output tiny — just two phrase markers. + const words = transcript.split(/\s+/); + const headSample = words.slice(0, 1200).join(" "); + const tailSample = words.length > 1200 ? words.slice(-600).join(" ") : ""; + const sample = tailSample + ? `${headSample}\n\n[…middle of transcript omitted…]\n\n${tailSample}` + : headSample; + + try { + const { text } = await generateText({ + model: deepSeekModel, + temperature: 0.1, + system: `You are a content signal filter for a book production pipeline. + +Find where the CORE TEACHING begins and ends in the transcript excerpt. + +NON-TEACHING content (identify and skip): +- Opening/closing prayers and benedictions +- Church announcements and event notices +- Greetings: "Good morning", "how is everyone", banter before teaching +- Greetings and acknowledgements to the room/church family +- Thank-you lines directed to attendees, leaders, choir, workers, or guests +- Repeated monthly-theme/series recap lines that do not add new teaching substance +- Housekeeping: "turn to your neighbor", stand/sit cues, phone reminders +- Altar calls and salvation appeals +- Offering/tithing appeals +- Technical breaks + +TEACHING content (preserve everything else): +- Scripture exposition, Bible references +- Theological and doctrinal points +- Stories and analogies that illustrate a teaching point +- Application, arguments, conclusions + +Return VERBATIM phrases (exact words from the transcript) so the server can locate them. +If teaching starts at the very beginning, set teachingStartPhrase to the first sentence. +If no closing non-teaching is found, set teachingEndPhrase to the last teaching sentence. + +Respond with ONLY a valid JSON object — no markdown, no code blocks, no explanation: +{"teachingStartPhrase":"...","teachingEndPhrase":"...","removedCategories":[],"summary":"..."}`, + prompt: `Identify the teaching start and end markers:\n\n${sample}`, + }); + let _parsed: unknown; + try { + const _jsonMatch = text.match(/\{[\s\S]*\}/); + _parsed = _jsonMatch ? JSON.parse(_jsonMatch[0]) : {}; + } catch { + _parsed = {}; + } + const _result = MarkersSchema.safeParse(_parsed); + const object = _result.success ? _result.data : MarkersSchema.parse({}); + + // Reconstruct cleaned transcript using the markers (string-match, no LLM output of full text) + let cleaned = transcript; + + const start = (object.teachingStartPhrase ?? "").trim(); + if (start.length > 20) { + const idx = transcript.indexOf(start.slice(0, 60)); + if (idx > 10) { + // Re-inject the nearest preceding [Slot-N] header so the content-map + // parser doesn't lose the first slot when greetings/prayers are trimmed. + const before = transcript.slice(0, idx); + const allHeaders = before.match(/\[Slot-\d+\]/g); + const lastHeader = allHeaders ? allHeaders[allHeaders.length - 1] : null; + cleaned = lastHeader + ? `${lastHeader}\n${transcript.slice(idx)}` + : transcript.slice(idx); + } + } + + const end = (object.teachingEndPhrase ?? "").trim(); + if (end.length > 20) { + const searchKey = end.slice(0, 60); + const idx = cleaned.lastIndexOf(searchKey); + if (idx > 0) { + const lineEnd = cleaned.indexOf("\n", idx + searchKey.length); + cleaned = lineEnd > 0 ? cleaned.slice(0, lineEnd).trim() : cleaned; + } + } + + const cleanedTranscript = cleanTranscriptForBook(cleaned || transcript); + const removedSegments = object.removedCategories.map((reason) => ({ reason, excerpt: "" })); + + return NextResponse.json({ + cleanedTranscript, + removedSegments, + summary: object.summary || + (removedSegments.length > 0 ? `Removed: ${object.removedCategories.join(", ")}` : "No non-teaching content detected"), + }, { status: 200 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Signal filter failed"; + return NextResponse.json({ + route: "ebook/filter-signal", + error: message, + details: err instanceof Error && err.stack + ? err.stack.split("\n").slice(0, 3).join(" | ") + : undefined, + }, { status: 500 }); + } +} + + diff --git a/app/api/ebook/format/route.ts b/app/api/ebook/format/route.ts new file mode 100644 index 0000000..b87f8c6 --- /dev/null +++ b/app/api/ebook/format/route.ts @@ -0,0 +1,165 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateObject } from "ai"; +import { z } from "zod"; +import { curatorModel } from "@/lib/ai-providers"; +import { EbookManifestSchema, ChapterDraftSchema } from "@/lib/schemas/ebook"; + +export const runtime = "nodejs"; +export const maxDuration = 120; + +const RequestSchema = z.object({ + manifest: EbookManifestSchema, + instruction: z.string().min(1).max(4000), + chapterNumber: z.union([z.number().int().min(1), z.literal("frontmatter")]), +}); + +// ── Output schemas ──────────────────────────────────────────────────────────── + +const FormatSectionSchema = z.object({ + sectionNumber: z.number(), + body: z.string(), +}); + +const FormatChapterOutputSchema = z.object({ + intro: z.string(), + conclusion: z.string(), + sections: z.array(FormatSectionSchema), + summary: z.string(), +}); + +const FormatFrontMatterOutputSchema = z.object({ + preface: z.string(), + introduction: z.string(), + conclusion: z.string(), + aboutAuthor: z.string().nullable(), + summary: z.string(), +}); + +// ── System prompt ───────────────────────────────────────────────────────────── + +const FORMATTER_SYSTEM = `You are a professional typographic editor for published teaching books. Your sole task is to apply rich markdown formatting to the provided prose. + +ABSOLUTE RULES: +- DO NOT change, add, or remove any words, sentences, or ideas +- PRESERVE the author's voice, vocabulary, and content exactly +- ONLY apply markdown formatting markers +- Return EVERY section that was given to you — do not skip any + +FORMATTING STANDARDS TO APPLY: +1. **Bold** — wrap key terms, core concepts, and must-remember phrases in **double asterisks** +2. *Italics* — wrap book/scripture titles mentioned inline, technical terms on first use, and words used for special emphasis in *single asterisks* +3. > Block quotes — prefix any direct scripture quotation or extended quote (40+ words) with > +4. Lists — if a sentence enumerates 3 or more items separated by commas or semicolons, convert to a markdown bullet list (- item) or numbered list (1. item) as appropriate +5. Paragraphs — ensure each new idea or paragraph is separated by a blank line +6. ### Sub-headings — within section body prose, if a clearly distinct sub-concept is introduced, add a ### Sub-heading before it + +Return the FULL formatted text for every section with all original words intact.`; + +// ── Handler ─────────────────────────────────────────────────────────────────── + +export async function POST(req: NextRequest) { + const body = await req.json() as unknown; + let input; + try { + input = RequestSchema.parse(body); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Invalid input" }, + { status: 400 }, + ); + } + + const { manifest, instruction, chapterNumber } = input; + + try { + // ── Front matter formatting ─────────────────────────────────────────────── + if (chapterNumber === "frontmatter") { + const fm = manifest.frontMatter; + const parts: string[] = []; + if (fm.preface?.trim()) parts.push(`PREFACE:\n${fm.preface}`); + if (fm.introduction?.trim()) parts.push(`INTRODUCTION:\n${fm.introduction}`); + if (fm.conclusion?.trim()) parts.push(`CONCLUSION:\n${fm.conclusion}`); + if (fm.aboutAuthor?.trim()) parts.push(`ABOUT THE AUTHOR:\n${fm.aboutAuthor}`); + + if (parts.length === 0) { + return NextResponse.json({ frontMatter: fm, summary: "No front matter content to format." }); + } + + const { object } = await generateObject({ + model: curatorModel, + schema: FormatFrontMatterOutputSchema, + mode: "tool", + temperature: 0.05, + maxTokens: 10000, + system: FORMATTER_SYSTEM, + prompt: [ + `FORMATTING INSTRUCTION: ${instruction}`, + "", + "FRONT MATTER TO FORMAT:", + ...parts, + ].join("\n\n"), + }); + + const formatted = { + preface: object.preface || fm.preface, + introduction: object.introduction || fm.introduction, + conclusion: object.conclusion || fm.conclusion, + aboutAuthor: object.aboutAuthor ?? fm.aboutAuthor, + resourcesList: fm.resourcesList, + }; + return NextResponse.json({ frontMatter: formatted, summary: object.summary }); + } + + // ── Chapter formatting ──────────────────────────────────────────────────── + const chapter = manifest.chapters.find((c) => c.number === chapterNumber); + if (!chapter) { + return NextResponse.json({ error: `Chapter ${chapterNumber} not found` }, { status: 404 }); + } + + const sectionBlocks = chapter.sections.map( + (s) => `SECTION ${s.sectionNumber}: ${s.heading}\n${s.body ?? ""}`, + ); + + const { object } = await generateObject({ + model: curatorModel, + schema: FormatChapterOutputSchema, + mode: "tool", + temperature: 0.05, + maxTokens: 16000, + system: FORMATTER_SYSTEM, + prompt: [ + `FORMATTING INSTRUCTION: ${instruction}`, + "", + `CHAPTER ${chapter.number}: "${chapter.title}"`, + "", + chapter.intro?.trim() ? `CHAPTER INTRO:\n${chapter.intro}` : null, + ...sectionBlocks, + chapter.conclusion?.trim() ? `CHAPTER CONCLUSION:\n${chapter.conclusion}` : null, + ].filter(Boolean).join("\n\n---\n\n"), + }); + + // Merge formatted content back — preserve all non-body fields (title, wordCount, etc.) + const mergedChapter = ChapterDraftSchema.parse({ + ...chapter, + intro: object.intro || chapter.intro, + conclusion: object.conclusion || chapter.conclusion, + sections: chapter.sections.map((s) => { + const formatted = object.sections.find((fs) => fs.sectionNumber === s.sectionNumber); + // Safety: only apply if the returned body is non-empty and not dramatically shorter + const originalWords = (s.body ?? "").split(/\s+/).filter(Boolean).length; + const returnedWords = (formatted?.body ?? "").split(/\s+/).filter(Boolean).length; + const bodyToUse = + formatted?.body && returnedWords >= originalWords * 0.85 + ? formatted.body + : s.body; + return { ...s, body: bodyToUse }; + }), + }); + + return NextResponse.json({ chapter: mergedChapter, summary: object.summary }); + + } catch (err) { + const message = err instanceof Error ? err.message : "Format operation failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/app/api/ebook/frontmatter/route.ts b/app/api/ebook/frontmatter/route.ts new file mode 100644 index 0000000..1ab9d4b --- /dev/null +++ b/app/api/ebook/frontmatter/route.ts @@ -0,0 +1,115 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateObject } from "ai"; +import { deepSeekReasonerModel } from "@/lib/ai-providers"; +import { FrontMatterRequestSchema, FrontBackMatterSchema } from "@/lib/schemas/ebook"; +import { PREMIUM_BOOK_STYLE_RULES, READER_NORMALIZATION_RULES, SOURCE_LOCK_RULES, stripAudienceLanguage } from "@/lib/editorial-style-bible"; + +export const runtime = "nodejs"; +export const maxDuration = 180; + +// LLM generates introduction + conclusion only — no preface +const IntroConclSchema = FrontBackMatterSchema.omit({ preface: true, scriptureIndex: true }); + +export async function POST(req: NextRequest) { + const body = await req.json() as unknown; + let input; + try { + input = FrontMatterRequestSchema.parse(body); + } catch (err) { + return NextResponse.json({ error: err instanceof Error ? err.message : "Invalid input" }, { status: 400 }); + } + + const transcript = typeof input.masterTranscript === "string" ? input.masterTranscript : ""; + const authorConfig = input.authorConfig; + const authorConfigBlock = (authorConfig?.instructions || authorConfig?.targetAudience) + ? `\n\n════════════════════════════════════════════\nAUTHOR BOOK CONFIGURATION (highest priority)\n════════════════════════════════════════════${authorConfig.targetAudience ? `\nTARGET AUDIENCE: ${authorConfig.targetAudience}` : ""}${authorConfig.instructions ? `\nAUTHOR WRITING INSTRUCTIONS: ${authorConfig.instructions}` : ""}` + : ""; + + try { + const { object } = await generateObject({ + model: deepSeekReasonerModel, + schema: IntroConclSchema, + mode: "json", + temperature: 1, // reasoner requires temperature=1 + system: `You are an editorial assistant writing the introduction and conclusion of a published teaching book. + +ABSOLUTE CONTENT RULE — ZERO FABRICATION: +Every sentence must come verbatim-idea from the provided transcript. You may not add content, context, or ideas not present in the audio/transcript — not even plausible extensions, inferred background, theological context the author "probably" knows, or biographical details you can reasonably assume. If you cannot point to the exact idea in the transcript text below, delete the sentence. Write shorter output rather than pad with invented content. + +════════════════════════════════════════════ +INTRODUCTION +════════════════════════════════════════════ +- Speak in first person as the author introducing the book directly to the reader. +- Focus on the book's purpose, core themes, and invitation to the reader. +- Do not describe the author from a third-person perspective. +- 3–5 paragraphs. Apply all preface guardrails above. + +════════════════════════════════════════════ +BACK MATTER +════════════════════════════════════════════ +- conclusion: Drawn from the closing moments of the teaching, rewritten as a book closing — not an altar call or dismissal. 2–4 paragraphs. +- aboutAuthor: ONLY write if the author spoke about themselves, their background, or their story. Return null if not. +- resourcesList: Books, tools, websites, or resources the author explicitly recommended. Return [] if none mentioned. + +SCRIPTURE & QUOTE FORMATTING: Apply Chicago Manual of Style rules as established in the chapter content. +VOICE ENFORCEMENT: Match the author's tone profile and signature phrases. + +${SOURCE_LOCK_RULES} + +${READER_NORMALIZATION_RULES} + +${PREMIUM_BOOK_STYLE_RULES}${authorConfigBlock}`, + prompt: `Write the front and back matter for this ebook. + +BOOK TITLE: ${input.architecture.bookTitle} +AUTHOR: ${input.architecture.authorName} + +ARCHITECTURE CONTEXT: +- Chapters: ${input.architecture.chapters.map((c) => c.title).join(", ")} +- Front matter notes (opening): ${input.architecture.frontMatterNotes} +- Back matter notes (closing): ${input.architecture.backMatterNotes} + +VOICE DNA: +${JSON.stringify(input.voiceDNA, null, 2)} + +FULL TRANSCRIPT (source of truth — opening & closing only): +${transcript.slice(0, 5000)} + +[… middle omitted …] + +${transcript.slice(-3000)}`, + }); + + return NextResponse.json({ + ...object, + preface: "", + introduction: stripAudienceLanguage(object.introduction ?? ""), + conclusion: stripAudienceLanguage(object.conclusion ?? ""), + aboutAuthor: object.aboutAuthor ? stripAudienceLanguage(object.aboutAuthor) : null, + resourcesList: (object.resourcesList ?? []).map((r) => stripAudienceLanguage(r)), + scriptureIndex: (() => { + const seenRefs = new Set(); + return (input.architecture?.chapters ?? []) + .flatMap((c) => c.quotesInChapter ?? []) + .filter((q) => q.type === "scripture" && q.reference?.trim()) + .sort((a, b) => a.reference.localeCompare(b.reference)) + .reduce((acc, q) => { + const entry = `${q.reference}${q.translation ? ` (${q.translation})` : ""}`; + if (!seenRefs.has(entry)) { seenRefs.add(entry); acc.push(entry); } + return acc; + }, []); + })(), + }, { status: 200 }); + } catch (err) { + const middle = transcript.slice(Math.floor(transcript.length * 0.05), 5200).trim(); + const closing = transcript.slice(-2200).trim(); + return NextResponse.json({ + preface: "", + introduction: stripAudienceLanguage(middle || "Introduction unavailable."), + conclusion: stripAudienceLanguage(closing || "Conclusion unavailable."), + aboutAuthor: null, + resourcesList: [], + scriptureIndex: [], + }, { status: 200 }); + } +} diff --git a/app/api/ebook/heading-review/route.ts b/app/api/ebook/heading-review/route.ts new file mode 100644 index 0000000..f9ac9a8 --- /dev/null +++ b/app/api/ebook/heading-review/route.ts @@ -0,0 +1,120 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateText } from "ai"; +import { z } from "zod"; +import { deepSeekModel } from "@/lib/ai-providers"; +import { BookArchitectureSchema } from "@/lib/schemas/ebook"; + +export const runtime = "nodejs"; +export const maxDuration = 120; + +// ── Input / Output schemas ──────────────────────────────────────────────────── + +const HeadingReviewRequestSchema = z.object({ + architecture: BookArchitectureSchema, +}); + +export type HeadingDiagnostic = { + chapterNumber: number; + chapterTitle: string; + sectionNumber: number; + sectionHeading: string; + issues: Array<"non-parallel" | "generic" | "spoiler" | "reader-unfriendly" | "ok">; + suggestedHeading: string | null; // null when "ok" + explanation: string; +}; + +export type HeadingReviewReport = { + diagnostics: HeadingDiagnostic[]; + overallParallelism: "strong" | "partial" | "weak"; + arcRevealScore: number; // 1–10: do headings collectively reveal the book's journey? + summary: string; + totalIssues: number; +}; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function extractHeadingIndex(architecture: z.infer): string { + const lines: string[] = []; + for (const chapter of architecture.chapters ?? []) { + lines.push(`Chapter ${chapter.number}: "${chapter.title}"`); + for (const section of chapter.sections ?? []) { + lines.push(` §${section.number} "${section.heading}"`); + } + } + return lines.join("\n"); +} + +// ── Route handler ───────────────────────────────────────────────────────────── + +export async function POST(req: NextRequest) { + const body = (await req.json()) as unknown; + let input: z.infer; + try { + input = HeadingReviewRequestSchema.parse(body); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Invalid input" }, + { status: 400 } + ); + } + + const headingIndex = extractHeadingIndex(input.architecture); + + try { + const { text } = await generateText({ + model: deepSeekModel, + maxTokens: 8000, + prompt: `You are a senior developmental editor specializing in nonfiction book architecture and reader experience. + +Review the section headings in this book's table of contents and diagnose quality issues. + +HEADING QUALITY CRITERIA: +1. PARALLEL FORM — All section headings within a chapter (and ideally across the whole book) should follow the same grammatical form: all noun phrases, all gerund phrases, all imperative phrases, or all questions. Mixed forms signal inconsistency. +2. READER APPEAL — Headings should intrigue or orient the reader. Generic headings like "Overview," "Background," "Application," "The Solution" are weak. Headings must be specific to the book's content. +3. ARC REVELATION — When read in sequence, the headings should collectively reveal a journey or progression. A reader scanning the TOC should sense the book's argument developing. +4. NO SPOILERS — Headings should not give away the punchline of the section before the reader has read the opening. +5. LENGTH — Headings should be 2–7 words. More than 10 words is unwieldy. + +ISSUE TYPES: +- "non-parallel": Grammatical form breaks consistency within the chapter or book. +- "generic": Heading could apply to any book on the topic — lacks specific content. +- "spoiler": Heading reveals the conclusion before the reader gets there. +- "reader-unfriendly": Too long, jargon-heavy, or confusing without context. +- "ok": No significant issues. + +BOOK HEADING INDEX: +${headingIndex} + +Return ONLY valid JSON — no markdown fences, no commentary: +{ + "diagnostics": [ + { + "chapterNumber": 1, + "chapterTitle": "...", + "sectionNumber": 1, + "sectionHeading": "...", + "issues": ["non-parallel"|"generic"|"spoiler"|"reader-unfriendly"|"ok"], + "suggestedHeading": "...", + "explanation": "one sentence explaining the issue and why the suggestion improves it" + } + ], + "overallParallelism": "strong"|"partial"|"weak", + "arcRevealScore": 7, + "summary": "2–3 sentence editorial summary of the heading architecture's strengths and weaknesses", + "totalIssues": 4 +}`, + }); + + const match = text.match(/\{[\s\S]*\}/); + if (!match) { + return NextResponse.json({ error: "Malformed LLM response" }, { status: 502 }); + } + const parsed = JSON.parse(match[0]) as HeadingReviewReport; + return NextResponse.json(parsed, { status: 200 }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Heading review failed" }, + { status: 500 } + ); + } +} diff --git a/app/api/ebook/polish/route.ts b/app/api/ebook/polish/route.ts new file mode 100644 index 0000000..b76ea41 --- /dev/null +++ b/app/api/ebook/polish/route.ts @@ -0,0 +1,247 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateText } from "ai"; +import { deepSeekModel } from "@/lib/ai-providers"; +import { z } from "zod"; +import { PolishChapterRequestSchema } from "@/lib/schemas/ebook"; +import { PREMIUM_BOOK_STYLE_RULES, READER_NORMALIZATION_RULES, SOURCE_LOCK_RULES } from "@/lib/editorial-style-bible"; +import { stripAudienceLanguage } from "@/lib/editorial-style-bible"; + +export const runtime = "nodejs"; +export const maxDuration = 300; + +// Slim output — section bodies are already written; LLM only adds framing + takeaways +const PolishOutputSchema = z.object({ + intro: z.string().default(""), + forwardQuestion: z.string().default(""), + keyTakeaways: z.array(z.string()).default([]), + reflectionQuestions: z.array(z.string()).default([]), + epigraph: z.string().default(""), + // Upgrade 5: section boundary transitions + sectionTransitions: z.array(z.object({ + sectionNumber: z.number(), + revisedLastSentence: z.string(), + })).default([]), +}); + +function fallbackPolishOutput(chapter: z.infer["input"]): z.infer { + const sections = chapter.sections ?? []; + const firstBody = sections.map((section) => (section.body ?? "").trim()).find(Boolean) ?? ""; + const lastBody = [...sections].reverse().map((section) => (section.body ?? "").trim()).find(Boolean) ?? ""; + + const takeaways = sections + .flatMap((section) => [section.heading, ...(section.keyTakeaways ?? [])]) + .map((item) => item.trim()) + .filter(Boolean) + .slice(0, 5); + + const reflectionQuestions = takeaways.length > 0 + ? takeaways.slice(0, 3).map((item) => `How does ${item.replace(/[.?!]+$/g, "")} shape the chapter's message?`) + : [ + `What is the main message of chapter ${chapter.number}?`, + `How do the section themes build on each other in chapter ${chapter.number}?`, + `What should the reader carry forward from this chapter?`, + ]; + + // Intro: derive from headings and key points — never copy the body prose. + const headingsSummary = sections + .map((s) => s.heading?.trim()) + .filter(Boolean) + .join(", "); + const fallbackIntro = headingsSummary + ? `This chapter examines: ${headingsSummary}.` + : chapter.title || ""; + + return { + intro: stripAudienceLanguage(fallbackIntro), + forwardQuestion: "", + + keyTakeaways: takeaways.length > 0 ? takeaways.map((item) => stripAudienceLanguage(item)) : [stripAudienceLanguage(chapter.title || "")].filter(Boolean), + reflectionQuestions: reflectionQuestions.map((item) => stripAudienceLanguage(item)).filter(Boolean), + }; +} + +export async function POST(req: NextRequest) { + const body = await req.json() as unknown; + let input; + try { + input = PolishChapterRequestSchema.parse(body); + } catch (err) { + return NextResponse.json({ error: err instanceof Error ? err.message : "Invalid input" }, { status: 400 }); + } + + const { input: chapter } = input; + const authorConfig = input.authorConfig; + const authorConfigBlock = (authorConfig?.instructions || authorConfig?.targetAudience) + ? `\n\nAUTHOR BOOK CONFIGURATION (highest priority):\n${authorConfig.targetAudience ? `TARGET AUDIENCE: ${authorConfig.targetAudience}` : ""}${authorConfig.instructions ? `\nAUTHOR WRITING INSTRUCTIONS: ${authorConfig.instructions}` : ""}` + : ""; + + try { + // Send section headings + key takeaways only — NOT body prose. + // Sending body prose caused the LLM to mirror the section-1 opening verbatim as the intro. + const sectionsSummary = (chapter.sections ?? []) + .map((s) => { + const kp = (s.keyTakeaways ?? []).slice(0, 3).join("; "); + return `Section ${s.sectionNumber} — ${s.heading}${kp ? `: ${kp}` : ""}`; + }) + .join("\n"); + + const totalWordCount = (chapter.sections ?? []).reduce((acc, s) => acc + (s.wordCount ?? 0), 0); + + // Trim VoiceDNA to key fields only to keep the prompt small and response fast + const voiceDNASlim = { + signaturePhrases: (chapter.voiceDNA?.signaturePhrases ?? []).slice(0, 6), + toneProfile: chapter.voiceDNA?.toneProfile ?? "", + preferredTerminology: (chapter.voiceDNA?.preferredTerminology ?? []).slice(0, 6), + avoidWords: (chapter.voiceDNA?.avoidWords ?? []).slice(0, 8), + }; + + const epigraphCandidates = (chapter.quotesInChapter ?? []) + .filter((q) => q.type === "scripture") + .slice(0, 5) + .map((q) => `"${q.text.slice(0, 120)}" \u2014 ${q.reference}${q.translation ? ` (${q.translation})` : ""}`) + .join("\n"); + + const prevChapterBlock = chapter.previousChapterForwardQuestion + ? `\n\nPREVIOUS CHAPTER FORWARD QUESTION (the open question that closed the last chapter — your intro should feel like the answer beginning to form):\n${chapter.previousChapterForwardQuestion.slice(0, 200)}` + : ""; + + // U5: Chapter premise from architect — constrains the intro and premise line + const chapterPremiseBlock = chapter.chapterPremise + ? `\n\nCHAPTER PREMISE (from blueprint — your intro and premise line must serve this north star):\n${chapter.chapterPremise}` + : ""; + + // U7: Series arc bridge — tells the intro what conceptual thread to pick up from the previous chapter + const seriesArcBlock = chapter.seriesArcBridge + ? `\n\nSERIES ARC BRIDGE: The previous chapter's closing thread was: "${chapter.seriesArcBridge}". The intro of this chapter should feel like a natural continuation of that thread — not a restatement, but a forward step.` + : ""; + + // ── Upgrade 5: Section boundary data for transition review ─────────── + // Extract last 2 sentences from each section and first 2 sentences of the next, + // so the LLM can identify and improve weak handoffs between sections. + const sections = chapter.sections ?? []; + const sectionBoundariesBlock = sections.length > 1 + ? `\n\nSECTION BOUNDARY REVIEW:\n${sections.slice(0, -1).map((sec, idx) => { + const nextSec = sections[idx + 1]; + const lastSents = (sec.body ?? "").split(/(?<=[.!?])\s+/).filter(Boolean).slice(-2).join(" "); + const firstSents = (nextSec.body ?? "").split(/(?<=[.!?])\s+/).filter(Boolean).slice(0, 2).join(" "); + return `• Boundary after §${sec.sectionNumber} → §${nextSec.sectionNumber}:\n ENDS: "${lastSents.slice(0, 200)}"\n OPENS: "${firstSents.slice(0, 200)}"`; + }).join("\n")}` + : ""; + + let object: z.infer; + try { + const { text } = await generateText({ + model: deepSeekModel, + temperature: 0.2, + system: `You are an editorial assistant finalizing a chapter of a published teaching book. + +ABSOLUTE CONTENT RULE: Every sentence must come from the provided transcript content. +Do NOT add new ideas, examples, or explanations not present in the transcript. + +EM DASH ABSOLUTE BAN: Never use an em dash (—) in any output. No spaced em dashes ( — ), no unspaced em dashes (—), no double hyphens (--) used as em dashes. Use a comma, colon, or split into two sentences instead. + +HUMANIZATION: Use contractions naturally. Avoid "not just...but", "not merely...but", "indeed,", "certainly,", "ultimately,", "at its core", "in essence", "profoundly", "transformative". Break any run of three parallel-structured sentences. + +Your tasks: +1. EPIGRAPH: From the provided scripture candidates, pick the ONE most resonant opening quote for this chapter. Return it formatted as: "Quote text." — Reference (Translation). If no candidate strongly fits or none are provided, return an empty string. Never invent a quote. +2. INTRO (CONSOLIDATED CHAPTER OPENER): Two sentences — no more, no less. + Sentence 1: ONE bold declarative statement — the north star thesis of this chapter. States what is at stake, what will be proven, or what the reader will discover. Present tense. Direct. Max 20 words. + Sentence 2: ONE provocative question that makes the reader feel the personal stakes and need to read on. Sharp, specific to this chapter's content — not generic. + The two sentences must work as a unit: the first declares, the second destabilizes. Together they are the door into the chapter. + WRONG: "In this chapter, we'll explore what it means to walk in faith. This is an important topic." + RIGHT: "Faith is not the absence of doubt — it is action taken despite it. So why do so many of us pray for more faith instead of just moving?" + CRITICAL: Do NOT copy the opening sentences of Section 1. Do NOT summarize the chapter contents. + CONNECTIVE TISSUE: If a "PREVIOUS CHAPTER FORWARD QUESTION" is provided, sentence 1 should feel like the answer beginning to form. +3. FORWARD QUESTION: ONE sentence — a preemptive question that plants anticipation for where the book goes next. + This is the last thing the reader sees before turning the page. It should feel like an open door, not a closed summary. + It must point forward, not backward. Never restate what the chapter covered. + WRONG: "We've seen how faith requires action." RIGHT: "But what happens when you've done everything right and nothing moves?" + If this is the final chapter, write a question that sends the reader back into life with something unresolved and worth carrying. +4. KEY TAKEAWAYS: 3–6 bullet statements taken VERBATIM or near-verbatim from the chapter content. +5. REFLECTION QUESTIONS: 3–4 questions that are SPECIFIC, PERSONAL, and ACTIONABLE. + REQUIRED: Each question must reference a concrete claim, story, or scripture from this chapter. + FORBIDDEN generic forms: "How does X shape the message?", "What is the main message?", "What should the reader carry forward?", "How can you apply this?". + REQUIRED: Name the specific idea, then ask about its implication in the reader's real life. Example: "Peter says diligence is required, not passive waiting — where in your life are you waiting for God to act when He has already told you to move?" +6. SECTION TRANSITIONS: Review the SECTION BOUNDARY data provided. For each boundary, evaluate whether the current ending sentence of section N creates genuine forward momentum into section N+1. If the handoff feels abrupt, mechanical, or summarizing, write a replacement last sentence for that section. Rules: + • The revised sentence must be drawn from section N's OWN content — never preview section N+1's ideas. + • It must create forward tension via an unresolved question, an open implication, or a logical pull — not a summary. + • If the existing ending is already strong (creates pull, avoids summarizing), return an empty string for that section — do NOT change it. + • Return as sectionTransitions array: [{"sectionNumber": N, "revisedLastSentence": "...or empty string"}]. + +VOICE: Use the author's signature phrases and preferred terminology consistently. Never swap a synonym for variety when the author has a preferred term. Do not use words in the avoidWords list. + +READER NORMALIZATION: +- Remove live-audience language and stage commands. +- Rewrite spoken-room references into reader-facing prose. + +${SOURCE_LOCK_RULES} + +${READER_NORMALIZATION_RULES} + +${PREMIUM_BOOK_STYLE_RULES}${authorConfigBlock} + +Respond with ONLY a valid JSON object — no markdown, no code blocks, no explanation: +{"intro":"...","forwardQuestion":"...","keyTakeaways":["..."],"reflectionQuestions":["..."],"epigraph":"...","sectionTransitions":[{"sectionNumber":1,"revisedLastSentence":"..."}]}`, + prompt: `Finalize this chapter.\n\nCHAPTER ${chapter.number}: ${chapter.title}\n\nVOICE DNA:\n${JSON.stringify(voiceDNASlim)}\n\nSECTION SUMMARIES:\n${sectionsSummary}${epigraphCandidates ? `\n\nSCRIPTURE CANDIDATES FOR EPIGRAPH (pick the most resonant ONE, or return empty string if none fits):\n${epigraphCandidates}` : ""}${prevChapterBlock}${chapterPremiseBlock}${seriesArcBlock}${sectionBoundariesBlock}`, + }); + const _jsonMatch = text.match(/\{[\s\S]*\}/); + object = PolishOutputSchema.parse(_jsonMatch ? JSON.parse(_jsonMatch[0]) : {}); + } catch { + try { + object = fallbackPolishOutput(chapter); + } catch { + object = { intro: "", forwardQuestion: "", keyTakeaways: [], reflectionQuestions: [] }; + } + } + + // Merge: preserve section bodies that were already written + // ── Upgrade 14: Epigraph source credibility flag ───────────────────── + // Flag any epigraph attribution that doesn't match a recognized Bible book pattern. + // This catches hallucinated scripture references or misattributed quotes. + const BIBLE_BOOK_PATTERN = /\b(genesis|exodus|leviticus|numbers|deuteronomy|joshua|judges|ruth|samuel|kings|chronicles|ezra|nehemiah|esther|job|psalms?|proverbs?|ecclesiastes|song of solomon|isaiah|jeremiah|lamentations|ezekiel|daniel|hosea|joel|amos|obadiah|jonah|micah|nahum|habakkuk|zephaniah|haggai|zechariah|malachi|matthew|mark|luke|john|acts|romans|corinthians|galatians|ephesians|philippians|colossians|thessalonians|timothy|titus|philemon|hebrews|james|peter|jude|revelation|gen|ex|lev|num|deut|josh|judg|prov|ps|psa|eccl|isa|jer|lam|ezek|dan|hos|mal|matt|mk|lk|jn|rom|cor|gal|eph|phil|col|thess|tim|tit|heb|jas|rev)\b/i; + const VERSE_REFERENCE_PATTERN = /\d+:\d+/; + let epigraphCredibilityWarning: string | null = null; + if (object.epigraph && object.epigraph.trim().length > 0) { + const epigraphText = object.epigraph; + // Attempt to extract the attribution (after the last — or - or in parens at end) + const attrMatch = epigraphText.match(/[—\-–]\s*(.+?)(\s*\(.*?\))?$/); + const attribution = attrMatch?.[1]?.trim() ?? ""; + if (attribution && !BIBLE_BOOK_PATTERN.test(attribution) && !VERSE_REFERENCE_PATTERN.test(attribution)) { + epigraphCredibilityWarning = `Epigraph attribution "${attribution}" does not match a recognized scripture reference. Verify this source before publishing.`; + console.warn(`[polish] ${epigraphCredibilityWarning}`); + } + } + + // ── Upgrade 5: Apply section transition revisions to section bodies ── + const patchedSections = (chapter.sections ?? []).map((sec) => { + const transition = (object.sectionTransitions ?? []).find( + (t) => t.sectionNumber === sec.sectionNumber && t.revisedLastSentence?.trim() + ); + if (!transition) return sec; + // Replace the last sentence of the section body with the revised one + const sentences = (sec.body ?? "").split(/(?<=[.!?])\s+/).filter(Boolean); + if (sentences.length === 0) return sec; + sentences[sentences.length - 1] = transition.revisedLastSentence.trim(); + return { ...sec, body: sentences.join(" ") }; + }); + const merged = { + ...object, + intro: stripAudienceLanguage(object.intro ?? ""), + forwardQuestion: stripAudienceLanguage(object.forwardQuestion ?? ""), + keyTakeaways: (object.keyTakeaways ?? []).map((t) => stripAudienceLanguage(t)), + reflectionQuestions: (object.reflectionQuestions ?? []).map((q) => stripAudienceLanguage(q)), + epigraph: object.epigraph ?? "", + number: chapter.number, + title: chapter.title, + sections: patchedSections, + totalWordCount, + status: "complete" as const, + ...(epigraphCredibilityWarning ? { epigraphCredibilityWarning } : {}), + }; + + return NextResponse.json(merged, { status: 200 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Chapter polish failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/app/api/ebook/publish/route.ts b/app/api/ebook/publish/route.ts new file mode 100644 index 0000000..b98e32d --- /dev/null +++ b/app/api/ebook/publish/route.ts @@ -0,0 +1,353 @@ +import { NextRequest, NextResponse } from "next/server"; +import { revalidatePath } from "next/cache"; +import { + S3Client, + PutObjectCommand, + GetObjectCommand, + DeleteObjectCommand, +} from "@aws-sdk/client-s3"; +import { env } from "@/lib/env"; +import { EbookManifestSchema } from "@/lib/schemas/ebook"; +import { + PublishedBookEntrySchema, + PublishedCatalogSchema, + CoverAccentSchema, +} from "@/lib/schemas/published-book"; +import type { PublishedCatalog } from "@/lib/schemas/published-book"; +import { z } from "zod"; + +export const runtime = "nodejs"; +export const maxDuration = 30; + +// ── GET /api/ebook/publish — fetch the live published catalog ───────────────── + +export async function GET() { + const { + R2_ACCOUNT_ID, + R2_ACCESS_KEY_ID, + R2_SECRET_ACCESS_KEY, + R2_BUCKET_NAME, + } = env; + + if (!R2_ACCOUNT_ID || !R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY || !R2_BUCKET_NAME) { + return NextResponse.json({ books: [] }, { status: 200 }); + } + + try { + const s3 = makeS3Client(R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY); + const res = await s3.send( + new GetObjectCommand({ Bucket: R2_BUCKET_NAME, Key: "published/index.json" }), + ); + const raw = await res.Body?.transformToString(); + if (!raw) return NextResponse.json({ books: [] }, { status: 200 }); + const parsed = PublishedCatalogSchema.safeParse(JSON.parse(raw)); + return NextResponse.json(parsed.success ? parsed.data : { books: [] }, { status: 200 }); + } catch { + return NextResponse.json({ books: [] }, { status: 200 }); + } +} + +const PublishRequestSchema = z.object({ + manifest: EbookManifestSchema, + coverAccent: CoverAccentSchema.default("amber"), + coverImageUrl: z.string().url().optional().nullable(), + authorImageUrl: z.string().url().optional().nullable(), +}); + +function slugify(title: string, jobId: string): string { + const base = title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 48); + const suffix = jobId.replace(/[^a-z0-9]/gi, "").slice(-6); + return `${base}-${suffix}`; +} + +function buildSynopsis(manifest: z.infer): string { + const candidates = [ + manifest.frontMatter.introduction, + manifest.frontMatter.preface, + ]; + for (const text of candidates) { + if (text && text.length > 60) { + const clean = text.replace(/#{1,3} /g, "").replace(/\*\*/g, "").trim(); + return clean.slice(0, 340).trimEnd() + (clean.length > 340 ? "…" : ""); + } + } + return `${manifest.bookTitle} by ${manifest.authorName}. ${manifest.chapters.length} chapters, ${manifest.totalWordCount.toLocaleString()} words.`; +} + +function makeS3Client(accountId: string, accessKey: string, secretKey: string) { + return new S3Client({ + region: "auto", + endpoint: `https://${accountId}.r2.cloudflarestorage.com`, + credentials: { accessKeyId: accessKey, secretAccessKey: secretKey }, + }); +} + +export async function POST(req: NextRequest) { + const body = await req.json() as unknown; + let input; + try { + input = PublishRequestSchema.parse(body); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Invalid input" }, + { status: 400 }, + ); + } + + const { + R2_ACCOUNT_ID, + R2_ACCESS_KEY_ID, + R2_SECRET_ACCESS_KEY, + R2_BUCKET_NAME, + R2_PUBLIC_URL, + } = env; + + if (!R2_ACCOUNT_ID || !R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY || !R2_BUCKET_NAME) { + return NextResponse.json( + { error: "R2 storage must be configured to publish books. Set R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, and R2_BUCKET_NAME." }, + { status: 503 }, + ); + } + + const { manifest, coverAccent } = input; + // Prefer image URLs passed explicitly; fall back to URLs embedded in the manifest + const coverImageUrl = input.coverImageUrl ?? manifest.coverImageUrl ?? null; + const authorImageUrl = input.authorImageUrl ?? manifest.authorImageUrl ?? null; + const slug = slugify(manifest.bookTitle, manifest.jobId); + const s3 = makeS3Client(R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY); + + try { + // 1. Write the full manifest to R2 (include image URLs so the reader can use them) + const manifestWithImages = { ...manifest, coverImageUrl, authorImageUrl }; + await s3.send( + new PutObjectCommand({ + Bucket: R2_BUCKET_NAME, + Key: `published/${slug}/manifest.json`, + Body: JSON.stringify(manifestWithImages), + ContentType: "application/json", + CacheControl: "public, max-age=60", + }), + ); + + // 2. Build catalog entry + const now = new Date().toISOString(); + const entry = PublishedBookEntrySchema.parse({ + slug, + title: manifest.bookTitle, + subtitle: manifest.subtitle, + authorName: manifest.authorName, + publishedAt: now, + updatedAt: now, + wordCount: manifest.totalWordCount, + chapterCount: manifest.chapters.length, + synopsis: buildSynopsis(manifest), + coverAccent, + template: manifest.selectedTemplate, + coverImageUrl, + authorImageUrl, + }); + + // 3. Read existing catalog (best-effort — index may not yet exist) + let catalog: PublishedCatalog = { updatedAt: now, books: [] }; + try { + const existing = await s3.send( + new GetObjectCommand({ Bucket: R2_BUCKET_NAME, Key: "published/index.json" }), + ); + const raw = await existing.Body?.transformToString(); + if (raw) { + const parsed = PublishedCatalogSchema.safeParse(JSON.parse(raw)); + if (parsed.success) catalog = parsed.data; + } + } catch { + // Index not yet created — start fresh + } + + // 4. Upsert (remove old entry for this slug, prepend new one) + catalog.books = catalog.books.filter((b) => b.slug !== slug); + catalog.books.unshift(entry); + catalog.updatedAt = now; + + // 5. Write updated catalog + await s3.send( + new PutObjectCommand({ + Bucket: R2_BUCKET_NAME, + Key: "published/index.json", + Body: JSON.stringify(catalog), + ContentType: "application/json", + CacheControl: "public, max-age=30", + }), + ); + + const publicUrl = R2_PUBLIC_URL + ? `${R2_PUBLIC_URL.replace(/\/$/, "")}/published/${slug}/manifest.json` + : null; + + revalidatePath("/library"); + + return NextResponse.json({ slug, publicUrl }, { status: 200 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Publish failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +// ── PATCH /api/ebook/publish — update catalog entry metadata without re-publishing ── + +const PatchCatalogRequestSchema = z.object({ + slug: z.string().min(1), + title: z.string().optional(), + subtitle: z.string().optional(), + authorName: z.string().optional(), + synopsis: z.string().optional(), + coverAccent: CoverAccentSchema.optional(), +}); + +export async function PATCH(req: NextRequest) { + let input; + try { + input = PatchCatalogRequestSchema.parse(await req.json() as unknown); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Invalid input" }, + { status: 400 }, + ); + } + + const { R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET_NAME } = env; + if (!R2_ACCOUNT_ID || !R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY || !R2_BUCKET_NAME) { + return NextResponse.json({ error: "R2 storage not configured." }, { status: 503 }); + } + + const { slug, ...fields } = input; + const s3 = makeS3Client(R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY); + const now = new Date().toISOString(); + + try { + // Read current catalog + let catalog: PublishedCatalog = { updatedAt: now, books: [] }; + try { + const existing = await s3.send( + new GetObjectCommand({ Bucket: R2_BUCKET_NAME, Key: "published/index.json" }), + ); + const raw = await existing.Body?.transformToString(); + if (raw) { + const parsed = PublishedCatalogSchema.safeParse(JSON.parse(raw)); + if (parsed.success) catalog = parsed.data; + } + } catch { /* index may not exist yet */ } + + const idx = catalog.books.findIndex((b) => b.slug === slug); + if (idx === -1) { + return NextResponse.json({ error: `Book with slug "${slug}" not found in catalog.` }, { status: 404 }); + } + + // Merge patch + catalog.books[idx] = PublishedBookEntrySchema.parse({ + ...catalog.books[idx], + ...fields, + updatedAt: now, + }); + catalog.updatedAt = now; + + await s3.send( + new PutObjectCommand({ + Bucket: R2_BUCKET_NAME, + Key: "published/index.json", + Body: JSON.stringify(catalog), + ContentType: "application/json", + CacheControl: "public, max-age=30", + }), + ); + + revalidatePath("/library"); + + return NextResponse.json({ slug, updatedAt: now }, { status: 200 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Patch failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +// ── DELETE /api/ebook/publish — remove a book from the library catalog ──────── + +const DeleteRequestSchema = z.object({ slug: z.string().min(1) }); + +export async function DELETE(req: NextRequest) { + let input; + try { + input = DeleteRequestSchema.parse(await req.json() as unknown); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Invalid input" }, + { status: 400 }, + ); + } + + const { + R2_ACCOUNT_ID, + R2_ACCESS_KEY_ID, + R2_SECRET_ACCESS_KEY, + R2_BUCKET_NAME, + } = env; + + if (!R2_ACCOUNT_ID || !R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY || !R2_BUCKET_NAME) { + return NextResponse.json( + { error: "R2 storage must be configured to manage books." }, + { status: 503 }, + ); + } + + const { slug } = input; + const s3 = makeS3Client(R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY); + + try { + // 1. Remove book from catalog index + const now = new Date().toISOString(); + let catalog: PublishedCatalog = { updatedAt: now, books: [] }; + try { + const existing = await s3.send( + new GetObjectCommand({ Bucket: R2_BUCKET_NAME, Key: "published/index.json" }), + ); + const raw = await existing.Body?.transformToString(); + if (raw) { + const parsed = PublishedCatalogSchema.safeParse(JSON.parse(raw)); + if (parsed.success) catalog = parsed.data; + } + } catch { + // Index missing — nothing to remove + } + + catalog.books = catalog.books.filter((b) => b.slug !== slug); + catalog.updatedAt = now; + + await s3.send( + new PutObjectCommand({ + Bucket: R2_BUCKET_NAME, + Key: "published/index.json", + Body: JSON.stringify(catalog), + ContentType: "application/json", + CacheControl: "public, max-age=30", + }), + ); + + // 2. Delete the manifest file from R2 + await s3.send( + new DeleteObjectCommand({ + Bucket: R2_BUCKET_NAME, + Key: `published/${slug}/manifest.json`, + }), + ).catch(() => { /* best-effort — file may not exist */ }); + + // 3. Bust the Next.js ISR cache so the library page reflects the removal immediately + revalidatePath("/library"); + + return NextResponse.json({ slug }, { status: 200 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Delete failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/app/api/ebook/quality-check/route.ts b/app/api/ebook/quality-check/route.ts new file mode 100644 index 0000000..b1f5772 --- /dev/null +++ b/app/api/ebook/quality-check/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { ChapterDraftSchema, ContentMapSchema, FrontBackMatterSchema } from "@/lib/schemas/ebook"; +import { evaluateBookQuality } from "@/lib/ebook-quality"; + +export const runtime = "nodejs"; +export const maxDuration = 30; + +const QualityCheckRequestSchema = z.object({ + chapters: z.array(ChapterDraftSchema), + contentMap: ContentMapSchema, + frontMatter: FrontBackMatterSchema, +}); + +export async function POST(req: NextRequest) { + const body = await req.json() as unknown; + let input; + try { + input = QualityCheckRequestSchema.parse(body); + } catch (err) { + return NextResponse.json({ error: err instanceof Error ? err.message : "Invalid input" }, { status: 400 }); + } + + const report = evaluateBookQuality({ + chapters: input.chapters, + contentMap: input.contentMap, + frontMatter: input.frontMatter, + }); + + return NextResponse.json(report, { status: 200 }); +} diff --git a/app/api/ebook/sanitize/route.ts b/app/api/ebook/sanitize/route.ts new file mode 100644 index 0000000..d094328 --- /dev/null +++ b/app/api/ebook/sanitize/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import glossaryRaw from "@/lib/glossary.json"; + +export const runtime = "nodejs"; +export const maxDuration = 30; + +const GlossaryEntrySchema = z.object({ + wrong: z.string(), + correct: z.string(), +}); + +const RequestSchema = z.object({ + masterTranscript: z.string().min(10), +}); + +const glossary = z.array(GlossaryEntrySchema).parse(glossaryRaw); + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export type SanitizeResult = { + sanitizedTranscript: string; + replacements: { wrong: string; correct: string; count: number }[]; +}; + +export async function POST(req: NextRequest) { + const body = await req.json() as unknown; + let input; + try { + input = RequestSchema.parse(body); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Invalid input" }, + { status: 400 } + ); + } + + let text = input.masterTranscript; + const replacements: SanitizeResult["replacements"] = []; + + for (const entry of glossary) { + const pattern = new RegExp(escapeRegex(entry.wrong), "gi"); + const matches = text.match(pattern); + if (matches && matches.length > 0) { + text = text.replace(pattern, entry.correct); + replacements.push({ wrong: entry.wrong, correct: entry.correct, count: matches.length }); + } + } + + return NextResponse.json( + { sanitizedTranscript: text, replacements } satisfies SanitizeResult, + { status: 200 } + ); +} diff --git a/app/api/ebook/voice-dna/route.ts b/app/api/ebook/voice-dna/route.ts new file mode 100644 index 0000000..9a6ee7c --- /dev/null +++ b/app/api/ebook/voice-dna/route.ts @@ -0,0 +1,188 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateText } from "ai"; +import { deepSeekModel } from "@/lib/ai-providers"; +import { VoiceDNARequestSchema, VoiceDNASchema } from "@/lib/schemas/ebook"; + +export const runtime = "nodejs"; +export const maxDuration = 120; + +export async function POST(req: NextRequest) { + const body = await req.json() as unknown; + let input; + try { + input = VoiceDNARequestSchema.parse(body); + } catch (err) { + return NextResponse.json({ error: err instanceof Error ? err.message : "Invalid input" }, { status: 400 }); + } + + // A7: Distributed 1800-word sample (600 × start/middle/end) — tripled from 900 words + // to improve voice coverage across 6-slot projects where the old "middle" landed near + // a structural break rather than a peak teaching section. + const words = input.masterTranscript.split(/\s+/); + const total = words.length; + const startSample = words.slice(0, 600).join(" "); + const midStart = Math.max(600, Math.floor(total / 2) - 300); + const midSample = words.slice(midStart, midStart + 600).join(" "); + const endSample = words.slice(Math.max(0, total - 600)).join(" "); + const sampleTranscript = [ + "[START]\n" + startSample, + "[MIDDLE]\n" + midSample, + "[END]\n" + endSample, + ].join("\n\n---\n\n"); + + try { + const { text } = await generateText({ + model: deepSeekModel, + temperature: 0.2, + maxTokens: 5120, + system: `You are a master linguist and voice analyst who profiles published authors for professional ghostwriting engagements. +Your task: extract a precise, multi-dimensional Voice DNA from the provided transcript sample. + +CARDINAL RULE: Extract ONLY patterns directly evidenced in this transcript. +Do not invent, infer, or generalize. Every entry must be traceable to actual words present. + +═══════════════════════════════════ +ARRAY SIZE LIMITS — strictly enforced +═══════════════════════════════════ +- signaturePhrases: max 8 (verbatim repeated phrases, min 2 occurrences) +- preferredTerminology: max 10 (domain-specific vocabulary used consistently) +- rhetoricalPatterns: max 6 (teaching devices actually observed) +- avoidWords: max 30 (baseline 22 + up to 8 author-specific) +- vernacularMarkers: max 10 (community idioms that must appear verbatim) +- avoidStructures: max 10 (sentence-level structural patterns the author never uses) + +═══════════════════════════════════ +FIELD DEFINITIONS +═══════════════════════════════════ +signaturePhrases + Exact phrases repeated at least twice. Quote verbatim. + +preferredTerminology + Domain-specific words or concepts this author consistently chooses. + +toneProfile + One concise string capturing the emotional and relational tone. + Example: "pastoral, direct, warm" or "authoritative, scholarly, measured" + +sentencePattern + Must be exactly one of: "short-punchy", "long-explanatory", or "mixed" + +rhetoricalPatterns + Observed teaching devices. Examples: "repeats key point three times", "uses rhetorical questions", "call-and-response structure" + +teachingStyle + How the author opens new topics, builds the argument, and lands the point. + One to three sentences of observed behavior. + +avoidWords + Start with the mandatory AI-cliché baseline below, then append up to 8 words the author demonstrably never uses: + BASELINE (always include ALL 30): ["In conclusion", "delve into", "tapestry", "navigating", "It's important to note", "Furthermore", "Moreover", "In today's fast-paced world", "It is crucial", "It is worth noting", "At the end of the day", "Game-changer", "Paradigm shift", "Deep dive", "Unpack", "Moving forward", "Robust", "Leverage", "Synergy", "It goes without saying", "The truth is,", "The fact of the matter is", "Indeed,", "Certainly,", "Ultimately,", "At its core,", "In essence,", "Simply put,", "profoundly", "transformative", "vibrant", "fostering", "journey (metaphorical)", "not just...but", "not merely...but", "This is not merely"] + +vocabularyLevel + Must be exactly one of: "conversational", "pastoral", "academic", "technical" + Choose the single best match for this author's dominant register. + +pacingFingerprint + One sentence describing their rhythm and momentum pattern. + Example: "slow narrative build followed by rapid-fire doctrinal landing" or "staccato declarative bursts punctuated by extended personal illustration" + +narrativeDevice + How the author structures stories and illustrations. + Example: "opens mid-scene with dramatic detail, then extracts the spiritual principle at the end" + +emotionalArc + The emotional modulation across a typical teaching unit. + Example: "opens with communal challenge, builds doctrinal conviction, releases into personal hope and encouragement" + +vernacularMarkers + Community-specific phrases or idioms that are a signature of this author's culture and must appear verbatim to authenticate voice. + Example: ["Somebody ought to praise Him right there", "Watch this now", "Can I tell you something?"] + If none are present, return an empty array. + +avoidStructures + Sentence-level construction patterns the author never uses. + Example: ["never stacks three consecutive rhetorical questions", "never opens a paragraph with 'The truth is'", "never uses 'not only...but also' framing"] + +openingPattern + How the author launches a new point or section. + Example: "poses a direct question to the audience, then answers it with a scripture anchor" + +closingPattern + How the author lands and seals a point. + Example: "restates the core thesis with a subtle twist, then ends on a concrete imperative or blessing" + +═══════════════════════════════════ +OUTPUT FORMAT +═══════════════════════════════════ +Respond with ONLY a valid JSON object — no markdown fences, no commentary — matching this exact shape: +{ + "signaturePhrases": ["..."], + "preferredTerminology": ["..."], + "toneProfile": "...", + "sentencePattern": "short-punchy" | "long-explanatory" | "mixed", + "rhetoricalPatterns": ["..."], + "teachingStyle": "...", + "avoidWords": ["..."], + "vocabularyLevel": "conversational" | "pastoral" | "academic" | "technical", + "pacingFingerprint": "...", + "narrativeDevice": "...", + "emotionalArc": "...", + "vernacularMarkers": ["..."], + "avoidStructures": ["..."], + "openingPattern": "...", + "closingPattern": "..." +}`, + prompt: `Extract the author's Voice DNA from this transcript sample:\n\n${sampleTranscript}`, + }); + + // Extract the first {...} JSON block — handles leading text, code fences, or truncation artifacts + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (!jsonMatch) throw new Error(`Voice DNA response contained no JSON object. Raw: ${text.slice(0, 200)}`); + + // Attempt to parse; if truncated, close any open brackets and retry once + let raw: Record; + try { + raw = JSON.parse(jsonMatch[0]) as Record; + } catch { + // Truncated JSON — close unclosed arrays and objects and retry + let partial = jsonMatch[0]; + const openArrays = (partial.match(/\[/g) ?? []).length - (partial.match(/\]/g) ?? []).length; + const openObjects = (partial.match(/\{/g) ?? []).length - (partial.match(/\}/g) ?? []).length; + // Remove trailing comma or incomplete token before closing + partial = partial.replace(/,\s*$/, "").replace(/,\s*"[^"]*$/, ""); + partial += "]".repeat(Math.max(0, openArrays)) + "}".repeat(Math.max(0, openObjects)); + raw = JSON.parse(partial) as Record; + } + + // Coerce sentencePattern to a valid enum value + if (typeof raw.sentencePattern === "string") { + const sp = raw.sentencePattern.toLowerCase(); + if (sp.includes("short") || sp.includes("punchy")) raw.sentencePattern = "short-punchy"; + else if (sp.includes("long") || sp.includes("explanatory")) raw.sentencePattern = "long-explanatory"; + else raw.sentencePattern = "mixed"; + } + + // Coerce vocabularyLevel to a valid enum value + if (typeof raw.vocabularyLevel === "string") { + const vl = raw.vocabularyLevel.toLowerCase(); + if (vl.includes("academic")) raw.vocabularyLevel = "academic"; + else if (vl.includes("technical")) raw.vocabularyLevel = "technical"; + else if (vl.includes("pastoral")) raw.vocabularyLevel = "pastoral"; + else raw.vocabularyLevel = "conversational"; + } + + // Hard-cap arrays so an over-generous model can never cause a truncation loop + if (Array.isArray(raw.signaturePhrases)) raw.signaturePhrases = (raw.signaturePhrases as string[]).slice(0, 8); + if (Array.isArray(raw.preferredTerminology)) raw.preferredTerminology = (raw.preferredTerminology as string[]).slice(0, 10); + if (Array.isArray(raw.rhetoricalPatterns)) raw.rhetoricalPatterns = (raw.rhetoricalPatterns as string[]).slice(0, 6); + if (Array.isArray(raw.avoidWords)) raw.avoidWords = (raw.avoidWords as string[]).slice(0, 30); + if (Array.isArray(raw.vernacularMarkers)) raw.vernacularMarkers = (raw.vernacularMarkers as string[]).slice(0, 10); + if (Array.isArray(raw.avoidStructures)) raw.avoidStructures = (raw.avoidStructures as string[]).slice(0, 10); + + const object = VoiceDNASchema.parse(raw); + return NextResponse.json(object, { status: 200 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Voice DNA extraction failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/app/api/ebook/write-chapter/route.ts b/app/api/ebook/write-chapter/route.ts new file mode 100644 index 0000000..4e5a386 --- /dev/null +++ b/app/api/ebook/write-chapter/route.ts @@ -0,0 +1,222 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateObject } from "ai"; +import { z } from "zod"; +import { deepSeekModel } from "@/lib/ai-providers"; +import { WriteChapterRequestSchema, WriteChapterOutputSchema } from "@/lib/schemas/ebook"; +import { SOURCE_LOCK_RULES, READER_NORMALIZATION_RULES, PREMIUM_BOOK_STYLE_RULES, stripAudienceLanguage, cleanTranscriptForBook } from "@/lib/editorial-style-bible"; + +export const runtime = "nodejs"; +export const maxDuration = 300; + +export async function POST(req: NextRequest) { + const body = await req.json() as unknown; + let input; + try { + input = WriteChapterRequestSchema.parse(body); + } catch (err) { + return NextResponse.json({ error: err instanceof Error ? err.message : "Invalid input" }, { status: 400 }); + } + + const { + chapterNumber, chapterTitle, chapterPremise, nextChapterTitle, coreThesis, + primaryTranslation, voiceDNA, authorConfig, sections, + alreadyCoveredPoints, priorSectionsSample, bannedRecaps, + alreadyQuotedRefs, forbiddenVerseTexts, overusedPhrases, + } = input; + + // ── Voice DNA block ──────────────────────────────────────────────────────── + const voiceDnaBlock = voiceDNA + ? `\n\n════════════════════════════════════════════ +VOICE DNA — MUST BE ENFORCED +════════════════════════════════════════════ +Tone: ${voiceDNA.toneProfile} +Sentence pattern: ${voiceDNA.sentencePattern} +Signature phrases (use verbatim where natural): ${(voiceDNA.signaturePhrases ?? []).slice(0, 5).join(" | ")} +Preferred terminology: ${(voiceDNA.preferredTerminology ?? []).slice(0, 8).join(", ")} +Avoid words: ${(voiceDNA.avoidWords ?? []).slice(0, 20).join(", ")}${voiceDNA.openingPattern ? `\nOpening pattern: ${voiceDNA.openingPattern}` : ""}${voiceDNA.closingPattern ? `\nClosing pattern: ${voiceDNA.closingPattern}` : ""}` + : ""; + + const authorConfigBlock = (authorConfig?.instructions || authorConfig?.targetAudience) + ? `\n\n════════════════════════════════════════════ +AUTHOR CONFIGURATION (highest priority) +════════════════════════════════════════════${authorConfig.targetAudience ? `\nTARGET AUDIENCE: ${authorConfig.targetAudience}` : ""}${authorConfig.instructions ? `\nAUTHOR INSTRUCTIONS: ${authorConfig.instructions}` : ""}` + : ""; + + // ── Cross-chapter dedup context ──────────────────────────────────────────── + // FIX 1: Use prose samples (not metadata) for n-gram overlap detection + const priorContextBlock = priorSectionsSample.length > 0 + ? `\n\n════════════════════════════════════════════ +PRIOR CHAPTERS — PROSE SAMPLE (avoid repeating these stories/examples) +════════════════════════════════════════════ +These are actual sentences from prior chapters. Do NOT repeat these stories, examples, or scripture explanations. One-sentence reference maximum: +${priorSectionsSample.slice(0, 20).map((p) => `• ${p.slice(0, 200)}`).join("\n")}` + : ""; + + const bannedRecapsBlock = bannedRecaps.length > 0 + ? `\n\n════════════════════════════════════════════ +BANNED RECAP SENTENCES +════════════════════════════════════════════ +These thesis sentences from prior sections must NOT be paraphrased or echoed: +${bannedRecaps.slice(0, 10).map((r) => `• "${r}"`).join("\n")}` + : ""; + + const quoteDedupBlock = (alreadyQuotedRefs.length + forbiddenVerseTexts.length) > 0 + ? `\n\n════════════════════════════════════════════ +SCRIPTURE DEDUP +════════════════════════════════════════════${alreadyQuotedRefs.length > 0 ? `\nAlready quoted in full — reference only, do NOT reprint: ${alreadyQuotedRefs.join(", ")}` : ""}${forbiddenVerseTexts.length > 0 ? `\nForbidden verse texts (exact text already printed — hard ban): ${forbiddenVerseTexts.slice(0, 5).map((t) => `"${t.slice(0, 60)}…"`).join(" | ")}` : ""}` + : ""; + + // G4: Lexical fingerprint — top overused phrases across the written corpus + const lexicalBlock = overusedPhrases.length > 0 + ? `\n\n════════════════════════════════════════════ +LEXICAL FINGERPRINT — FIND FRESHER LANGUAGE +════════════════════════════════════════════ +These 3-gram constructions are already overused across prior chapters. Avoid them — find different phrasing for the same ideas:\n${overusedPhrases.slice(0, 15).map((p) => `• "${p}"`).join("\n")}` + : ""; + + const translationBlock = primaryTranslation + ? `\n\nPRIMARY TRANSLATION: Default to ${primaryTranslation} for any verse where the speaker did not specify a translation.` + : ""; + + // ── Build section payload ────────────────────────────────────────────────── + const sectionPayload = sections.map((sec, idx) => { + const excerpts = (sec.transcriptExcerpts ?? []) + .map((e) => cleanTranscriptForBook(e).trim()) + .filter(Boolean) + .map((e, i) => `[${i + 1}] ${e.slice(0, 1600)}`) + .join("\n\n"); + const planBlock = (sec.assignedPlan ?? []).length > 0 + ? `\nPARAGRAPH PLAN (follow this sequence):\n${sec.assignedPlan!.map((p, i) => + ` Step ${i + 1}: ${p.purpose}${(p.supportedExcerptNumbers ?? []).length > 0 ? ` [excerpts: ${p.supportedExcerptNumbers.join(", ")}]` : ""}` + ).join("\n")}` + : ""; + const keyPointsText = (sec.keyPoints ?? []).length > 0 + ? `\nKEY POINTS:\n${sec.keyPoints.map((k) => `• ${k}`).join("\n")}` + : ""; + // G5: Include assigned quotes so the LLM knows which scriptures belong in this section + const quotesText = (sec.quotes ?? []).length > 0 + ? `\nASSIGNED QUOTES FOR THIS SECTION:\n${sec.quotes.map((q) => + ` • ${q.reference}${q.translation ? ` (${q.translation})` : ""}: "${q.text.slice(0, 200)}${q.text.length > 200 ? "…" : ""}"` + ).join("\n")}` + : ""; + const lastFlag = sec.isLastSectionInChapter ? " [LAST SECTION — hard chapter boundary: do NOT develop the next chapter's themes]" : ""; + return `══ SECTION ${idx + 1} of ${sections.length}: §${sec.sectionNumber} — "${sec.heading}" (~${sec.targetWordCount ?? 500} words)${lastFlag} ══${keyPointsText}${quotesText}${planBlock}\n\nTRANSCRIPT EXCERPTS:\n${excerpts}`; + }).join("\n\n────────────────────────────────────────────\n\n"); + + // ── System prompt ────────────────────────────────────────────────────────── + const system = `You are an elite ghostwriter writing every section of a single book chapter in one pass. + +# THE CORE ADVANTAGE — USE IT +You are writing ALL ${sections.length} sections of Chapter ${chapterNumber} in a single context window. This means you SEE what you wrote for Section 1 when you write Section 2. Use this aggressively: +• If a concept is fully developed in Section 1, Section 2 gets one-sentence callback at most — zero re-explanation +• Each section OWNS its assigned content. Never develop the same argument, example, story, or illustration twice +• Intra-chapter duplication is a critical error — it signals you are not reading your own prior output + +# SYNTHESIS, NOT TRANSCRIPTION +Extract core insights from the transcript. Reassemble as premium book prose — NOT paraphrased sentences. Every claim must trace to the provided excerpts. Zero fabrication. + +# VOICE AND STYLE +• Active voice, strong verbs, authoritative tone +• NO em dashes (—). Use comma, colon, semicolon, or subordinate clause instead +• Contractions are natural (it's, you're, don't, isn't) +• Vary sentence length: short punch after long explanation; deliberate fragments for emphasis (12 words max) +• No consecutive paragraphs opening with the same word +• BANNED AI clichés: "In conclusion", "delve into", "tapestry", "navigate", "It's important to note", "Furthermore", "Moreover", "transformative", "vibrant", "fostering", "unpack", "ultimately", "at its core", "in essence", "profoundly", "certainly", "indeed", "simply put" + +# PARAGRAPH FORMAT +Each paragraph is a string in a JSON array. ONE idea per paragraph. 3–5 sentences. New point, new scripture quotation, or new example = new array element. NEVER add markdown headings inside paragraph arrays. + +# SECTION BOUNDARIES +Each section is sealed. Do NOT preview the next section's content from within the current one. Presuppose what you just wrote — opening sentences of Section 2+ must not re-introduce concepts already developed. + +# SCRIPTURE RULES +• Short (<40 words): *"verse text"* (Book Chapter:Verse, Translation) inline +• Long (40+ words): markdown blockquote, no quotation marks, — Reference (Translation) at end +• Always complete the TEXT → TRUTH → APPLICATION circuit within 2–3 paragraphs +• No post-quote restatement (next sentence must ADVANCE the argument, not re-explain the quote) +• Anchor controlling verse BEFORE exposition, not after +• Preserve Greek/Hebrew terms exactly as the speaker stated them +• If a translation was not stated, write (translation unspecified) + +# REMOVE FROM OUTPUT — HARD RULE: if any of these appear in output, the book fails QC +• Live-event audience address: "say amen", "somebody say", "turn to your neighbor", "give your neighbor a high five", "can I get an amen", "clap your hands", "stand to your feet", "you may be seated" +• Room/attendance language: "in this room today", "everyone here", "church family", "good morning everyone", "how is everybody", "I'm glad you're here", "welcome to" +• Speaker self-reference banter: "I said that to say this", "let me tell you", "I want to be honest with you", "real quick", "hold on", "wait wait wait" +• Repeated filler and false starts: stutters, "uh", "um", "you know", "I mean", "right right", "okay okay", repeated words ("and and", "the the") +• Church logistics: announcements, event notices, offering/tithing appeals, altar calls, salvation appeals, prayer-line instructions +• Housekeeping cues: phone reminders, stand/sit cues, bathroom breaks, technical pauses +• Transitional banter that has no teaching content: "moving on", "next point", "back to our text", "as I was saying" +• Incomplete or broken sentences that trail off without a point +• Any sentence beginning with a markdown heading symbol (#, ##, ###) +${SOURCE_LOCK_RULES}${voiceDnaBlock}${authorConfigBlock}${priorContextBlock}${bannedRecapsBlock}${quoteDedupBlock}${lexicalBlock}${translationBlock} +${READER_NORMALIZATION_RULES} +${PREMIUM_BOOK_STYLE_RULES}`; + + const coreThesisLine = coreThesis ? `\nCORE BOOK THESIS (thread through every section): ${coreThesis}` : ""; + const premiseLine = chapterPremise ? `\nCHAPTER PREMISE: ${chapterPremise}` : ""; + const nextChapterLine = nextChapterTitle + ? `\nNEXT CHAPTER: "${nextChapterTitle}" — the final section's closing must NOT begin developing its themes` + : ""; + + const prompt = `Write all ${sections.length} sections of Chapter ${chapterNumber}: "${chapterTitle}"${coreThesisLine}${premiseLine}${nextChapterLine} + +Return a JSON object with a "sections" array. Each element: + sectionNumber: integer matching the §N above + paragraphs: string[] — each string is one prose paragraph + claimLedger: { claim: string }[] — one entry per key teaching claim made in this section + +──────────────────────────────────────────── + +${sectionPayload}`; + + // G6: SSE stream with heartbeat — prevents proxy read-timeout on long chapters + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + const ping = setInterval(() => { + try { controller.enqueue(encoder.encode(": ping\n\n")); } catch { /* closed */ } + }, 15_000); + try { + const { object } = await generateObject({ + model: deepSeekModel, + schema: WriteChapterOutputSchema, + mode: "json", + maxTokens: 16_000, // G2: explicit ceiling for full-chapter output + temperature: 0.55, // G1: lower temp for cross-section coherence + system, + prompt, + }); + + // Clean each section's paragraphs — two passes: + // 1. stripAudienceLanguage (deterministic regex) + // 2. Drop heading-prefixed lines and empty results + const cleaned = { + sections: (object.sections ?? []).map((sec) => ({ + ...sec, + paragraphs: (sec.paragraphs ?? []) + .map((p) => stripAudienceLanguage(p.trim())) + .filter(Boolean) + .filter((p) => !(/^#{1,6}\s/.test(p))), + })), + }; + + clearInterval(ping); + controller.enqueue(encoder.encode(`data: ${JSON.stringify(cleaned)}\n\n`)); + } catch (err) { + clearInterval(ping); + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ error: err instanceof Error ? err.message : "Chapter write failed" })}\n\n`)); + } finally { + controller.close(); + } + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + }); +} diff --git a/app/api/ebook/write-section/route.ts b/app/api/ebook/write-section/route.ts new file mode 100644 index 0000000..68fe917 --- /dev/null +++ b/app/api/ebook/write-section/route.ts @@ -0,0 +1,997 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateObject, generateText } from "ai"; +import { z } from "zod"; +import { deepSeekModel } from "@/lib/ai-providers"; +import { WriteSectionRequestSchema } from "@/lib/schemas/ebook"; +import { PREMIUM_BOOK_STYLE_RULES, READER_NORMALIZATION_RULES, SOURCE_LOCK_RULES } from "@/lib/editorial-style-bible"; +import { stripAudienceLanguage } from "@/lib/editorial-style-bible"; + +export const runtime = "nodejs"; +export const maxDuration = 300; + +/** Emergency rewrite fallback — fires when the primary structured call fails or returns + * empty paragraphs. Uses a simple generateText call to produce clean book prose from + * the raw excerpts rather than dumping unedited transcript into the book. */ +async function fallbackSectionBody(input: z.infer["assignment"]): Promise { + const rawExcerpts = input.transcriptExcerpts + .map((e) => e.replace(/\r\n/g, "\n").replace(/\n{3,}/g, "\n\n").replace(/[ \t]{2,}/g, " ").trim()) + .filter(Boolean) + .join("\n\n"); + + if (!rawExcerpts) { + return (input.keyPoints.filter(Boolean).join(" ") || input.heading).trim(); + } + + try { + const { text } = await generateText({ + model: deepSeekModel, + temperature: 0.5, + maxTokens: 1200, + system: `You are a professional book editor. Rewrite the raw spoken transcript below into clean, polished book prose. + +RULES: +- Every idea must come from the transcript — zero fabrication +- Remove all spoken-language artifacts: stutters, false starts ("I mean", "you know", "uh"), repeated words, filler phrases +- Fix broken grammar and incomplete sentences into proper prose +- Remove all live-event language: "look at your neighbor", "say amen", "here in this church" +- Output 3–6 prose paragraphs separated by blank lines — no headings, no markdown +- Write shorter output rather than invent content`, + prompt: `SECTION HEADING: ${input.heading}\n\nRAW TRANSCRIPT:\n${rawExcerpts.slice(0, 4000)}`, + }); + return text.trim() || rawExcerpts; + } catch { + // Last resort: return the raw excerpts stripped of obvious live-event language + return rawExcerpts; + } +} + +function normalizeReaderFacingProse(text: string): string { + return text + .replace(/\b(turn to your neighbor|say amen|clap your hands|lift your hands)\b/gi, "") + .replace(/\b(as you sit here today|in this room today|right here in this place)\b/gi, "") + .replace(/[ \t]{2,}/g, " ") // collapse only horizontal whitespace, never newlines + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +// ── Upgrade 8: Passive voice detector ──────────────────────────────────────── +// Scans finalized prose and returns sentences containing passive constructions +// so they can be logged for visibility (full rewrite is handled by the LLM prompt). +const PASSIVE_PATTERNS = [ + /\b(is|are|was|were|be|been|being)\s+(being\s+)?\w+ed\b/gi, + /\bthere\s+(is|are|was|were)\s+a?\s*\w/gi, + /\bit\s+(is|was)\s+(important|necessary|worth|noted|believed|said|known|thought|understood)/gi, + /\b(we|believers|christians|people)\s+are\s+(called|meant|told|asked|invited|expected)\s+to\b/gi, + /\b(can|should|must|may|might)\s+be\s+(seen|found|noted|observed|understood|considered)/gi, + /\b(god|jesus|paul|peter|david)\s+(is|was)\s+(known|referred|considered|seen|understood)\s+as\b/gi, +]; + +function detectPassiveVoice(text: string): string[] { + const sentences = text.split(/(?<=[.!?])\s+/).filter(Boolean); + const hits: string[] = []; + for (const sentence of sentences) { + if (PASSIVE_PATTERNS.some((re) => { re.lastIndex = 0; return re.test(sentence); })) { + hits.push(sentence.slice(0, 120).trim()); + } + } + return hits; +} + +// ── Upgrade 12: False promise / unfulfilled hook detector ──────────────────── +// Extracts the opening hook/question from the first paragraph and checks whether +// the body actually addresses it (n-gram overlap heuristic). Returns the hook +// string when it appears unfulfilled so it can be logged for editorial review. + +const HOOK_PATTERNS = [ + /^(what|why|how|when|who|where|is|are|was|were|do|does|did|can|could|should|would|will|have|has|had)\b.{10,}[?]/i, + /\b(the question is|here is the thing|consider this|think about|imagine|what if|suppose|ask yourself)\b/i, + /\b(the answer|the key|the secret|the truth|the reason)\s+(is|lies|comes)\b/i, +]; + +function extractOpeningHook(body: string): string | null { + const firstPara = body.split(/\n\n+/)[0]?.trim() ?? ""; + const firstSentences = firstPara.split(/(?<=[.!?])\s+/).slice(0, 3); + for (const sentence of firstSentences) { + if (HOOK_PATTERNS.some((re) => re.test(sentence))) { + return sentence.slice(0, 200).trim(); + } + } + return null; +} + +function ngramTokens(text: string, n: number): Set { + const words = text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((w) => w.length > 3); + const grams = new Set(); + for (let i = 0; i <= words.length - n; i++) { + grams.add(words.slice(i, i + n).join(" ")); + } + return grams; +} + +function hookFulfilled(hook: string, body: string): boolean { + // The body past the first paragraph is where the hook should be addressed + const remainingBody = body.split(/\n\n+/).slice(1).join(" "); + if (!remainingBody || remainingBody.split(/\s+/).length < 20) return true; // too short to judge + const hookGrams = ngramTokens(hook, 3); + if (hookGrams.size === 0) return true; + const bodyGrams = ngramTokens(remainingBody, 3); + let overlap = 0; + for (const g of hookGrams) { + if (bodyGrams.has(g)) overlap++; + } + // At least 15% n-gram overlap means the hook topic appears in the body + return overlap / hookGrams.size >= 0.15; +} + +// ── Seq-A2 post-write reorder ──────────────────────────────────────────────── +// When the LLM writes paragraphs out of the speaker's transcript order, this +// function stable-sorts them back into the correct excerpt sequence. +// Paragraphs with no strong excerpt match inherit the index of their preceding +// matched neighbour so they stay contextually attached to the right argument block. +function reorderParagraphsByExcerptSequence( + paragraphs: string[], + excerpts: string[], + overlapThreshold = 0.08 +): { paragraphs: string[]; reorderedCount: number } { + if (excerpts.length === 0) return { paragraphs, reorderedCount: 0 }; + + // 1. Map each paragraph to its best-matching excerpt index. + const assignments: Array<{ para: string; excerptIdx: number }> = paragraphs.map((para) => { + if (para.split(/\s+/).length < 15) return { para, excerptIdx: -1 }; // short/transitional — defer + let bestMatch = -1; + let bestScore = 0; + for (let ei = 0; ei < excerpts.length; ei++) { + const score = excerptOverlapScore(para, excerpts[ei]); + if (score > bestScore) { bestScore = score; bestMatch = ei; } + } + return { para, excerptIdx: bestScore >= overlapThreshold ? bestMatch : -1 }; + }); + + // 2. Unmatched paragraphs (-1) inherit the excerpt index of their preceding matched + // neighbour so they stay in their contextual position after sorting. + let lastAssigned = 0; + for (let i = 0; i < assignments.length; i++) { + if (assignments[i].excerptIdx >= 0) { + lastAssigned = assignments[i].excerptIdx; + } else { + assignments[i].excerptIdx = lastAssigned; + } + } + + // 3. Check if already in order — skip sort if no fix needed. + let needsSort = false; + for (let i = 1; i < assignments.length; i++) { + if (assignments[i].excerptIdx < assignments[i - 1].excerptIdx) { needsSort = true; break; } + } + if (!needsSort) return { paragraphs, reorderedCount: 0 }; + + // 4. Stable sort by excerpt index (Array.sort is stable in V8 / Node 11+). + const original = assignments.map((a) => a.para); + const sorted = [...assignments].sort((a, b) => a.excerptIdx - b.excerptIdx); + const reorderedCount = sorted.filter((a, i) => a.para !== original[i]).length; + return { paragraphs: sorted.map((a) => a.para), reorderedCount }; +} + +// ── S5: Post-write paragraph length validation ──────────────────────────── +// Detects orphaned long sentences that are not deliberate fragments (≤12 words). +// Merges them with the following paragraph when the next para starts with a +// conjunction-like opener, otherwise logs for visibility. +function repairOrphanParagraphs(paragraphs: string[]): { paragraphs: string[]; orphansFixed: number } { + const CONJUNCTION_OPENERS = /^(and|but|so|because|which|who|whose|although|since|while|however|therefore|thus)\b/i; + const result: string[] = []; + let orphansFixed = 0; + let i = 0; + while (i < paragraphs.length) { + const para = paragraphs[i].trim(); + const sentences = para.split(/(?<=[.!?])\s+/).filter(Boolean); + const wordCount = para.split(/\s+/).filter(Boolean).length; + // A paragraph is an orphaned long sentence if it has exactly 1 sentence and >12 words + const isOrphan = sentences.length === 1 && wordCount > 12; + if (isOrphan && i + 1 < paragraphs.length) { + const next = paragraphs[i + 1].trim(); + if (CONJUNCTION_OPENERS.test(next)) { + // Merge forward: orphan sentence flows directly into the next paragraph + result.push(`${para} ${next}`); + i += 2; + orphansFixed++; + continue; + } + } + // A4: Merge upward when forward merge isn't possible (last paragraph or no conjunction opener) + // Appending to the preceding paragraph keeps the thought in context rather than leaving it dangling. + if (isOrphan && result.length > 0) { + result[result.length - 1] = `${result[result.length - 1]} ${para}`; + i++; + orphansFixed++; + continue; + } + result.push(para); + i++; + } + return { paragraphs: result, orphansFixed }; +} + +// ── Upgrade 2: Server-side n-gram excerpt dedup ──────────────────────────── +// Strips excerpts whose content is substantially covered by already-covered points +// before the LLM ever receives them, removing the root-cause material. + +// Detects any Bible reference in a string (e.g. "John 3:16", "Psalm 23:1-4") +const BIBLE_REF_RE = /\b(?:genesis|exodus|leviticus|numbers|deuteronomy|joshua|judges|ruth|samuel|kings|chronicles|ezra|nehemiah|esther|job|psalm|psalms|proverbs|ecclesiastes|isaiah|jeremiah|lamentations|ezekiel|daniel|hosea|joel|amos|obadiah|jonah|micah|nahum|habakkuk|zephaniah|haggai|zechariah|malachi|matthew|mark|luke|john|acts|romans|corinthians|galatians|ephesians|philippians|colossians|thessalonians|timothy|titus|philemon|hebrews|james|peter|jude|revelation)\s+\d+:\d+|\b(?:gen|exo|lev|num|deut|josh|judg|sam|kgs|chr|ps|prov|eccl|isa|jer|lam|ezek|dan|hos|nah|hab|zeph|mal|matt|mk|lk|jn|rom|cor|gal|eph|phil|col|thess|tim|heb|jas|pet|rev)\s+\d+:\d+/i; + +function containsScripture(text: string): boolean { + return BIBLE_REF_RE.test(text); +} + +// Strip scripture citation tokens before n-gram comparison so verse references +// don't falsely inflate the "already covered" overlap score. +function stripScriptureTokens(text: string): string { + return text + .replace(BIBLE_REF_RE, " ") + .replace(/\b(?:NIV|KJV|ESV|NKJV|NLT|NASB|AMP|MSG)\b/gi, " ") + .replace(/\d+:\d+(?:-\d+)?/g, " ") + .replace(/\s{2,}/g, " ") + .trim(); +} + +function extractNgrams(text: string, n = 4): Set { + const words = text.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean); + const grams = new Set(); + for (let i = 0; i <= words.length - n; i++) { + grams.add(words.slice(i, i + n).join(" ")); + } + return grams; +} + +function excerptOverlapWithCoveredContent(excerpt: string, coveredText: string, n = 4): number { + // Strip scripture tokens so Bible verse citations don't falsely inflate the overlap score + const excerptGrams = extractNgrams(stripScriptureTokens(excerpt), n); + const coveredGrams = extractNgrams(stripScriptureTokens(coveredText), n); + if (excerptGrams.size === 0) return 0; + let shared = 0; + for (const g of excerptGrams) { if (coveredGrams.has(g)) shared++; } + return shared / excerptGrams.size; +} + +// ── Seq-A2: Per-paragraph excerpt-match overlap (used for server-side watermark) +// Scores how strongly a written paragraph draws from a given transcript excerpt +// by 4-gram overlap against the paragraph's own n-gram vocabulary. +function excerptOverlapScore(para: string, excerpt: string, n = 4): number { + const paraGrams = extractNgrams(para, n); + const excGrams = extractNgrams(excerpt, n); + if (paraGrams.size === 0) return 0; + let shared = 0; + for (const g of paraGrams) { if (excGrams.has(g)) shared++; } + return shared / paraGrams.size; +} + +function filterConsumedExcerpts( + excerpts: string[], + alreadyCoveredPoints: string[], + threshold = 0.40 +): { filtered: string[]; removedCount: number } { + if (alreadyCoveredPoints.length === 0) return { filtered: excerpts, removedCount: 0 }; + const coveredText = alreadyCoveredPoints.join(" "); + const filtered: string[] = []; + let removedCount = 0; + for (const excerpt of excerpts) { + const wordCount = excerpt.trim().split(/\s+/).length; + // Short excerpts always pass through + if (wordCount < 40) { filtered.push(excerpt); continue; } + // SCRIPTURE PROTECTION: never drop an excerpt that contains a Bible reference. + // The preacher builds their argument on scripture — filtering scripture-bearing + // excerpts silently removes the theological backbone of the section. + if (containsScripture(excerpt)) { filtered.push(excerpt); continue; } + const overlap = excerptOverlapWithCoveredContent(excerpt, coveredText); + if (overlap >= threshold) { + removedCount++; + } else { + filtered.push(excerpt); + } + } + // Always keep at least one excerpt so the section has source material + return { filtered: filtered.length > 0 ? filtered : excerpts.slice(0, 1), removedCount }; +} + +type ExcerptEntry = { text: string; sourceNumber: number }; + +function filterConsumedExcerptEntries( + entries: ExcerptEntry[], + alreadyCoveredPoints: string[], + threshold = 0.40 +): { filtered: ExcerptEntry[]; removedCount: number } { + if (alreadyCoveredPoints.length === 0) return { filtered: entries, removedCount: 0 }; + const coveredText = alreadyCoveredPoints.join(" "); + const filtered: ExcerptEntry[] = []; + let removedCount = 0; + + for (const entry of entries) { + const excerpt = entry.text; + const wordCount = excerpt.trim().split(/\s+/).length; + if (wordCount < 40) { filtered.push(entry); continue; } + if (containsScripture(excerpt)) { filtered.push(entry); continue; } + const overlap = excerptOverlapWithCoveredContent(excerpt, coveredText); + if (overlap >= threshold) removedCount++; + else filtered.push(entry); + } + + // Always keep at least one excerpt so the section has source material + return { filtered: filtered.length > 0 ? filtered : entries.slice(0, 1), removedCount }; +} + +const EDITORIAL_SYSTEM = `# ROLE AND OBJECTIVE +You are an elite, New York Times-bestselling ghostwriter and developmental editor. Your task is to synthesize raw, unstructured audio transcripts into a highly polished, premium book chapter. + +The final output must read like a professionally published, authoritative text—not a cleaned-up transcript. It must feature high-end editorial styling, a clear narrative arc, and rigorous logical flow. + +# INPUT CONTEXT +You will receive transcribed audio text. Expect the following flaws: +- Non-linear thoughts, tangents, and chronological jumps. +- Redundant points, filler words, and conversational crutches. +- Phonetic transcription errors. + +# STRICT BOUNDARIES & GUARDRAILS +1. SYNTHESIS, NOT TRANSCRIPTION: Do not simply rephrase the text sentence-by-sentence. Extract the core insights, arguments, and stories, then reassemble them into a strong, linear structure. +2. INFORMATION FIDELITY — ZERO FABRICATION: Do not hallucinate data, invent new stories, or inject outside facts. This ban covers plausible extensions, inferred context, and theological background the author "probably" knows. Every sentence must trace to the provided transcript excerpts. If an idea is not in the excerpts, delete it. Write shorter rather than pad with invented content. +3. TONE AND REGISTER: Elevate the speaker's voice. The tone must be authoritative, engaging, and precise. Use active voice and strong verbs. Avoid passive, academic dryness. +4. FORBIDDEN CLICHÉS: You are strictly forbidden from using standard AI transition phrases and clichés, including but not limited to: "In conclusion," "Let's delve into," "A tapestry of," "Navigating the landscape," "It's important to note," "Furthermore," and "In today's fast-paced world." +5. EM DASH ABSOLUTE BAN: Never use an em dash (—) anywhere in the output. No spaced em dashes ( — ), no unspaced em dashes (—), no double hyphens (--) used as em dashes. Rewrite every sentence that would need one using a comma, colon, semicolon, or subordinate clause ("which," "who," "although," "because," "while," "since"). Splitting into two sentences is the last resort — only when both halves stand alone as strong, complete thoughts. +6. HUMANIZATION — ANTI-AI DETECTION (enforce on every paragraph before returning): + - Use contractions naturally (it's, you're, that's, don't, isn't, won't) — they occur in natural prose. + - Avoid "X is not just A; it is B" and "X is not merely A, it is B" sentence frames. + - Break perfect parallel structure. If three items are listed with matching grammar, make one slightly different. + - Never follow a scripture quote with a sentence that explains what the quote means in the same way it just said it. Trust the reader to absorb it. + - Avoid stacking rhetorical questions in consecutive sentences. + - One sentence per paragraph may be a deliberate fragment. For emphasis. That's allowed. + - Never close a paragraph with "This is what it means to..." or "This is why..." followed by a restatement. + - Banned AI-signature words in this output: "indeed," "certainly," "ultimately," "at its core," "in essence," "simply put," "profoundly," "transformative," "vibrant," "fostering," "crucial," "vital" (overused), "journey" (metaphorical use). +7. FORMATTING: Output ONLY as an array of plain prose paragraph strings. NEVER add any markdown heading (##, ###, #, or any heading level) as a paragraph element — the section heading is already displayed by the book layout. Adding a heading inside the paragraphs array creates a duplicate, out-of-place label mid-chapter. Prose paragraphs only. Never use HTML or br tags. +8. SECTION BOUNDARY — ABSOLUTE RULE: Each section is a sealed unit. You MUST NOT preview, introduce, foreshadow, or summarize content that belongs to a future section. This includes any sentence that: + - Names or paraphrases a point the next section will make + - Begins developing an argument that has no transcript support in THIS section's excerpts + - Uses phrases like "We will see…", "As we explore next…", "This leads us to examine…", "In the coming pages…", or any forward reference. + Closing sentences may create forward momentum ONLY through an unresolved question, a tension, or a logical implication drawn entirely from the current section's own content. They must not disclose what the following section contains. + +# SENTENCE STRUCTURE — INDUSTRY EDITORIAL STANDARDS +Apply all of these on every paragraph before finalizing output: + +S1 — FRAGMENT DISCIPLINE: A one-sentence paragraph is a deliberate rhetorical fragment ONLY if the sentence is 12 words or fewer. Any paragraph with a single sentence of 13+ words must be followed by at least one additional sentence that develops, illustrates, or applies the idea. Isolated long sentences read as orphaned thoughts, not emphasis. + +S2 — SYNTACTIC DEPTH (complex sentence requirement): Every paragraph of three or more sentences must contain at least one sentence joined by a subordinating conjunction: "although," "because," "while," "since," "which," "who," "whose," "even though," "as long as," "whenever." All-simple-sentence paragraphs score at a 5th-grade reading level regardless of vocabulary. + +S3 — SAME-OPENER BAN: No three consecutive sentences in the same paragraph may begin with the same word. This is an absolute structural error. Anaphora is intentional repetition; accidental opener repetition is monotony. + +S4 — SENTENCE-LENGTH RATIO: In any paragraph of three or more sentences, the longest sentence must contain at least 2× the words of the shortest sentence. Uniformly medium-length sentences produce a flat, metronomic rhythm that signals machine generation. Deliberate contrast — a short punch after a long explanation — is what makes prose feel alive. + +S6 — PARAGRAPH OPENER VARIATION: The opening word of a paragraph must differ from the opening word of the immediately preceding paragraph. Back-to-back paragraphs that both start with "The," "This," "God," or any proper noun are a structural tell — they reveal that the writer generated a list, not flowing prose. Vary grammatical form at the opening: start one paragraph with a participial phrase, the next with a subordinate clause, the next with a concrete noun. + +# EXECUTION SEQUENCE +Before generating the final output, follow this internal sequence: +1. Analyze the transcript chunk to identify the central thesis. +2. Filter out all conversational redundancies and off-topic tangents. +3. Group related concepts logically so the narrative builds momentum. +4. Draft the text using varied sentence lengths (short punches for emphasis, longer sentences for explanation). +5. Before returning, silently review your draft against all four of these criteria and revise inline: + - RHYTHM: No two consecutive sentences should be the same length. Break monotony with short, punchy sentences after long explanatory ones. + - CLICHÉS: Scan every sentence for robotic phrasing — "It is crucial to remember," "A tapestry of," "Navigating the complexities," "It is worth noting," or any overly neat paragraph-ending summary. Delete or rewrite every instance found. + - SHOW, DON'T TELL: Where the draft states a fact, check whether the transcript contains an example, story, or specific detail that illustrates it instead. If so, use the illustration. + - TONE: Confirm the final prose is authoritative, premium, and sophisticated — never passive, never academic, never motivational-poster flat. + +════════════════════════════════════════════ +VOICE DNA — MUST BE ENFORCED +════════════════════════════════════════════ +The author's Voice DNA is provided. You MUST: +• Use the author's signature phrases exactly as they appear in the Voice DNA +• Maintain the stated tone profile throughout +• Match the sentence pattern described +• Use the author's preferred terminology consistently +• Never use the words in the avoidWords list + +════════════════════════════════════════════ +SCRIPTURE & QUOTE FORMATTING (Chicago Manual of Style + Premium Print Standards) +════════════════════════════════════════════ + +DETECTION RULE — This is the most important formatting rule in this prompt: +Any text enclosed in quotation marks (or reproduced verbatim) that is IMMEDIATELY followed by a Bible book name and chapter:verse citation (e.g. "John 3:16", "Genesis 1:1", "Psalm 23:1–4") is SCRIPTURE. Treat it as scripture regardless of its word count. Do not treat it as ordinary prose or dialogue. + +SCRIPTURE MUST ALWAYS be visually distinct from the speaker's explanatory words. The reader must never have to guess which words are God's Word and which are the author's commentary. + +SHORT SCRIPTURE (under 40 words) WOVEN INTO A SENTENCE: + Integrate inline with quotation marks, followed by the reference in parentheses. Use italic emphasis via markdown: *"verse text"* (Book Chapter:Verse, Translation). + Example: *"For God so loved the world that he gave his one and only Son"* (John 3:16, NIV). + +STANDALONE SHORT SCRIPTURE (under 40 words but quoted as its own statement, not mid-sentence): + Use a markdown blockquote: + > Verse text here. + > — Book Chapter:Verse (Translation) + +LONG SCRIPTURE (40+ words — block quote mandatory): + Begin the blockquote on its own line. No quotation marks around the block. + > For I know the plans I have for you, declares the Lord, + > plans to prosper you and not to harm you, plans to give you + > hope and a future. + > — Jeremiah 29:11 (NIV) + +CHAPTER-OPENING VERSE (epigraph — placed before the body of a chapter or section): + Use a blockquote. Add a blank line after it before the author's prose begins. + > Verse text. + > — Book Chapter:Verse (Translation) + +TRANSLATION RULE: Always include the translation abbreviation in parentheses — KJV, NIV, ESV, NKJV, NLT, NASB, AMP, MSG, etc. If the speaker stated the translation, use it exactly. If no translation was stated, write (translation unspecified). + +NON-SCRIPTURE BLOCK QUOTE (attributed to a person, not the Bible): + Use a blockquote WITHOUT the accent-style attribution format. + > Quote text here. + > — Author Name, Source (if given) + Do NOT use italics for non-scripture block quotes. + +PROVERBS / UNATTRIBUTED SAYINGS: + Use quotation marks only. If no attribution is known, do not fabricate one. + +CRITICAL: Reproduce scripture text EXACTLY as the speaker quoted it. Never paraphrase scripture. Never merge two separate verses into one block unless the speaker quoted them together. + +════════════════════════════════════════════ +SCRIPTURE EDITORIAL STANDARDS (industry rules — enforce on every passage) +════════════════════════════════════════════ + +RULE 1 — ANCHOR BEFORE EXPOSITION (placement discipline): +When a section's central argument depends on a single controlling passage, that passage must appear as a standalone block quote at or near the opening of the section — before the author's explanatory words develop the argument. Do not bury the key verse mid-paragraph as a late proof-text after the argument is already complete. The Word anchors the teaching; the author unpacks what it says. If the transcript introduces the verse after several explanatory paragraphs, restructure so the verse leads and the explanation follows. Exception: when the speaker is building narrative suspense toward a verse, the natural progression may be preserved. + +RULE 2 — NO POST-QUOTE RESTATEMENT (scripture-specific hard ban): +The sentence immediately after a scripture quote must ADVANCE the argument — it must not rephrase, summarize, translate, or explain what the verse just said in different words. "This verse tells us that God loves us" after John 3:16 is always wrong. "What Paul means here is..." after quoting Paul directly is always wrong. The reader has eyes. Land the implication, draw the consequence, or pivot to the application — but never echo back what the text already said. This rule applies to every scripture quotation in every section, no exceptions. + +RULE 3 — PRESERVE LINGUISTIC ANCHORS (Greek/Hebrew term fidelity): +When the speaker provides the original Greek or Hebrew word, its transliteration, or its root meaning, you MUST reproduce that exact term — never paraphrase, generalize, or drop it. These are doctrinal load-bearing details. Place the term adjacent to the verse it annotates. Format: the Greek word *[transliteration]*, meaning "[definition as the speaker stated it]". If the speaker said "the word translated 'prayer' here is proseuchomai, which means to exchange your wish for God's wish," that exact claim must appear in the prose — not a smoothed paraphrase of it. These word studies are often the most memorable teaching moment in the whole chapter; erasing them is a content error, not an editorial improvement. + +RULE 4 — FULL-QUOTE-ONCE, SHORTHAND-AFTER (repetition discipline): +Within the same section, a scripture may only be quoted in full once. Any subsequent reference to the same passage within this section must be shorthand only: "As Jesus said in John 15:5..." or "Returning to James 1:5..." without reprinting the verse text. The forbidden verse texts list (in the dedup block above) already enforces this across sections; this rule extends it within the current section as well. Never reprint a verse text that has already appeared — even if you rephrase the framing. + +RULE 5 — NO UNINVITED BIBLICAL BACKGROUND (source-lock for scripture): +You may not add any of the following unless the speaker explicitly stated it in the transcript: + • Historical setting or date of the original writing + • Cultural or sociological context of the original audience + • Authorial intent, personal biography, or life situation of the Bible author + • Grammatical or syntactical commentary on the original language + • Audience situation of the church/people the letter/book was addressed to (e.g., "Paul wrote to the Corinthians because they were divided over...") + • Any doctrinal position, theological system, or church tradition that "explains" the verse but was not in the transcript +Every word of explanation must trace directly to the transcript. Your training data about biblical texts is not source material. When in doubt, delete the sentence. + +RULE 6 — TEXT → TRUTH → APPLICATION (teaching circuit): +Every scripture quotation must complete a circuit within the same section: + Text: The verse is quoted or cited. + Truth: The speaker's doctrinal or practical claim drawn from the text. + Application: What the reader must believe differently, do, or become as a result. +All three stages must appear within two or three paragraphs of the quotation. If the transcript provides the text and truth but no application material, close the circuit with a reader-facing implication sentence drawn from the transcript's broader argument — not invented content. If the transcript genuinely provides no application, add [application thin] as a note after the section's last paragraph and reduce the word count rather than padding with fabricated application. + +════════════════════════════════════════════ +AUDIENCE & FORMAT +════════════════════════════════════════════ +• Remove crowd cues and stage prompts (e.g., "say amen", "look at your neighbor", applause calls, house-response commands) +• Rewrite direct live-room address ("today I want to tell you", "as you sit here") into book language for an individual reader +• PARAGRAPH DISCIPLINE: You are returning paragraphs as a JSON ARRAY — each array element is exactly one paragraph. ONE idea per paragraph, 3 to 5 sentences. When a new point, scripture quotation, example, or argument begins, it must be a new array element. Never put two paragraphs in one array element. Never split a single paragraph across two elements. +• Target the specified word count based on available content — do not pad to reach it + +${SOURCE_LOCK_RULES} + +${READER_NORMALIZATION_RULES} + +${PREMIUM_BOOK_STYLE_RULES}`; + +export async function POST(req: NextRequest) { + const body = await req.json() as unknown; + let input; + try { + input = WriteSectionRequestSchema.parse(body); + } catch (err) { + return new Response( + JSON.stringify({ error: err instanceof Error ? err.message : "Invalid input" }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + + const { assignment } = input; + const authorConfig = input.authorConfig; + const authorConfigBlock = (authorConfig?.instructions || authorConfig?.targetAudience) + ? `\n\n════════════════════════════════════════════ +AUTHOR BOOK CONFIGURATION (highest priority) +════════════════════════════════════════════${authorConfig.targetAudience ? `\nTARGET AUDIENCE: ${authorConfig.targetAudience}\nWrite at the vocabulary level, cultural register, and depth appropriate for this specific audience. Every example, illustration, and application point must land for this reader.` : ""}${authorConfig.instructions ? `\nAUTHOR WRITING INSTRUCTIONS: ${authorConfig.instructions}\nThese are the author's direct instructions for how the book should read. Honor them on every paragraph. They override any default style preference where they conflict.` : ""}` + : ""; + + // ── Upgrade 2: Readability grade target ───────────────────────────────── + const readabilityBlock = `\n\n════════════════════════════════════════════ +READABILITY TARGET — ENFORCE BEFORE RETURNING +════════════════════════════════════════════ +Target Flesch-Kincaid Grade Level: 9–11. This means: +• Average sentence length of 18–22 words across the section. +• Vocabulary is precise and elevated, but not academic or dense. +• Deliberate length variation: some sentences under 10 words (punch), some over 30 (explanation). Never five consecutive medium-length sentences. +• After drafting, scan for any paragraph where all sentences are approximately the same length — break it with a short punch or a long explanatory sentence.`; + + // ── Upgrade 3: Book thesis threading ──────────────────────────────────── + const coreThesisBlock = assignment.coreThesis + ? `\n\n════════════════════════════════════════════ +BOOK'S CORE THESIS — THREAD THROUGH THIS SECTION +════════════════════════════════════════════ +"${assignment.coreThesis}" +Every section you write must feel like it advances this thesis. Not by repeating it verbatim, but by adding a new dimension, a new piece of evidence, or a new application of it. If a paragraph has no traceable connection to this thesis, it is filler. Readers feel thesis-less sections as "padding" even if they can't name why.` + : ""; + + // ── Upgrade 4: Illustration / story dedup block ────────────────────────── + const usedIllustrationsBlock = (assignment.usedIllustrations ?? []).length > 0 + ? `\n\n════════════════════════════════════════════ +USED STORIES & ILLUSTRATIONS — DO NOT REPEAT +════════════════════════════════════════════ +The following personal stories, illustrations, parables, and named examples have ALREADY appeared in earlier sections of this book. Do NOT retell, re-describe, paraphrase, or re-introduce them as illustrations. If the transcript mentions them, extract ONLY the principle they illustrate — never the narrative wrapper: +${(assignment.usedIllustrations ?? []).map((s) => `• "${s}"`).join("\n")}` + : ""; + + // ── Scripture Amendment 4: Primary translation block ───────────────────── + // Injected into the prompt so the LLM uses the book's dominant translation + // as the default whenever a verse has no explicit translation label. + const primaryTranslationBlock = assignment.primaryTranslation + ? `\n\n════════════════════════════════════════════ +PRIMARY BIBLE TRANSLATION FOR THIS BOOK +════════════════════════════════════════════ +The speaker's dominant Bible translation is: ${assignment.primaryTranslation} +When quoting a verse for which the speaker did not specify a translation, use (${assignment.primaryTranslation}) as the parenthetical label. Never mix translations within the same passage or apply a different default to achieve variety — consistency is correctness here.` + : ""; + + // ── Amendment 1: Coverage Ledger ───────────────────────────────────────── + // Each entry is a section that has ALREADY been written. The LLM must not + // re-establish any insight that is summarised here — reference it at most once. + const coverageLedger = assignment.coverageLedger ?? []; + const coverageLedgerBlock = coverageLedger.length > 0 + ? `\n\n════════════════════════════════════════════ +COVERAGE LEDGER — NEVER RE-EXPLAIN THESE SECTIONS +════════════════════════════════════════════ +Every section below has ALREADY BEEN WRITTEN and delivered to the reader. Do NOT re-introduce, re-define, re-explain, or re-develop the ideas they established — not even in passing. You may presuppose the reader already knows each one. Reference an entry at most once (inline citation only, e.g. "as we saw in [heading]") and only when it directly supports NEW content: +${coverageLedger.map((e) => `• [${e.heading}] — Established: "${e.summary}"`).join("\n")}` + : ""; + + // ── Amendment 4: Banned Recaps ──────────────────────────────────────────── + // These are the exact opening thesis sentences from prior sections. The LLM + // must not rephrase, echo, or restate any of them — not even loosely. + const bannedRecaps = assignment.bannedRecaps ?? []; + const bannedRecapsBlock = bannedRecaps.length > 0 + ? `\n\n════════════════════════════════════════════ +BANNED RECAPS — DO NOT REPHRASE OR RESTATE +════════════════════════════════════════════ +The following sentences are the opening claims of sections already written. You MUST NOT rephrase, echo, paraphrase, or restate any sentence in this list — not in full, not in part, not with synonyms, not as a summary. Write entirely new argumentation: +${bannedRecaps.map((s) => `• "${s}"`).join("\n")}` + : ""; + + // ── Amendment 6: Lexical Fingerprint Exclusion ─────────────────────────── + // The most-repeated 3-grams from all written sections. The LLM should avoid + // these unless quoting scripture or transcript directly. + const overusedPhrases = assignment.overusedPhrases ?? []; + const lexicalFingerprintBlock = overusedPhrases.length > 0 + ? `\n\n════════════════════════════════════════════ +OVERUSED PHRASES — FIND FRESHER LANGUAGE +════════════════════════════════════════════ +The following 3-word phrases appear too frequently across the sections already written. Unless you are quoting scripture or the transcript verbatim, AVOID these phrases. Use semantically equivalent but lexically distinct language: +${overusedPhrases.map((p) => `• "${p}"`).join("\n")}` + : ""; + + // ── Amendment 7: Diminishing Permission Rule ───────────────────────────── + // Section 1 in a chapter may introduce 3 new core concepts. + // Section 2 may introduce 2. Section 3+ may introduce only 1. + // This forces depth over breadth as the chapter builds. + const sectionIdx = assignment.sectionIndexInChapter ?? 0; + const maxNewConcepts = sectionIdx === 0 ? 3 : sectionIdx === 1 ? 2 : 1; + const diminishingPermissionBlock = `\n\n════════════════════════════════════════════ +NEW CONCEPT CAP FOR THIS SECTION +════════════════════════════════════════════ +This is section ${sectionIdx + 1} within its chapter. You MAY introduce at most ${maxNewConcepts} new core concept${maxNewConcepts !== 1 ? "s" : ""} in this section — ideas the reader has NOT encountered yet anywhere in this book. Every additional paragraph must deepen, apply, or illustrate a concept already introduced (either earlier in this chapter, or in this section's own opening). Width is not the goal; depth is. A section that introduces ${maxNewConcepts + 1}+ new concepts will feel scattered and under-developed.`; + + // ── Seq-A3: Argument-turn sequence enforcement block ───────────────────── + const sequenceTurns = assignment.sequenceTurns ?? []; + const sequenceTurnsBlock = sequenceTurns.length > 0 + ? `\n\n════════════════════════════════════════════ +ARGUMENT TURNS — PRESERVE THESE PIVOT POINTS +════════════════════════════════════════════ +The speaker made the following rhetorical pivots at specific points in the transcript. Each turn marks where the argument changes direction. Do NOT merge paragraphs across a turn, and do NOT write the conclusion of a turn before writing its setup: +${sequenceTurns.map((t) => `• ${t}`).join("\n")}` + : ""; + + // ── Seq-A4: Story setup-before-payoff ordering block ───────────────────── + const storyPayoffPairs = assignment.storyPayoffPairs ?? []; + const storyPayoffBlock = storyPayoffPairs.length > 0 + ? `\n\n════════════════════════════════════════════ +STORY SETUP-BEFORE-PAYOFF — ORDERING REQUIRED +════════════════════════════════════════════ +The following story/principle pairs were found in the transcript. You MUST write the narrative setup BEFORE the concluding principle — never reverse the order by stating the lesson first and filling in the backstory afterward: +${storyPayoffPairs.map((p, i) => `${i + 1}. SETUP FIRST: "${p.setup}" → THEN PRINCIPLE: "${p.principle}"`).join("\n")}` + : ""; + + // ── Seq-A5: Scripture position enforcement block ────────────────────────── + const scripturePositions = assignment.scripturePositions ?? []; + const scripturePositionsBlock = scripturePositions.length > 0 + ? `\n\n════════════════════════════════════════════ +SCRIPTURE SEQUENCE POSITIONS — DO NOT MOVE EARLIER +════════════════════════════════════════════ +Each scripture below appears at a specific position in the transcript (by excerpt number). Do NOT use a scripture before you reach the paragraph that corresponds to its excerpt position. The verse belongs where the speaker placed it in their argument — not where it feels rhetorically convenient: +${scripturePositions.map((p) => `• "${p.reference}" — appears in Excerpt ${p.excerptIndex + 1}. Do not use it in paragraphs anchored to earlier excerpts.`).join("\n")}` + : ""; + + // ── Seq-A7: Prior excerpt tail (argument-entry-point) block ────────────── + const priorExcerptTailBlock = assignment.priorExcerptTail + ? `\n\n════════════════════════════════════════════ +ARGUMENT ENTRY POINT — READ BEFORE OPENING PARAGRAPH +════════════════════════════════════════════ +The speaker was mid-argument when this section's excerpts begin. The previous section's transcript ended with: +"${assignment.priorExcerptTail}" +Your opening paragraph must land where that argument was heading — do NOT re-establish the premise that was already set up. Do not reintroduce context; continue forward from it.` + : ""; + + + // FIX 1: Always use prose samples for deduplication (no metadata fallback) + // Prose-vs-prose n-gram overlap gives real signal for detecting duplicate stories/scriptures. + const dedupCorpus = assignment.priorSectionsSample ?? []; + if (dedupCorpus.length === 0) { + console.warn(`[write-section] No prose samples provided for Ch${assignment.chapterNumber} §${assignment.sectionNumber} — dedup will be weak`); + } + const excerptEntries: ExcerptEntry[] = (assignment.transcriptExcerpts ?? []).map((text, idx) => ({ + text, + sourceNumber: idx + 1, + })); + const { filtered: dedupedExcerptEntries, removedCount: excerptRemovedCount } = filterConsumedExcerptEntries( + excerptEntries, + dedupCorpus + ); + let effectiveExcerptEntries = excerptRemovedCount > 0 ? dedupedExcerptEntries : excerptEntries; + + // When chapter-plan is available, enforce its excerpt anchors surgically. + // This prevents section bodies from drifting into prior/adjacent subtitle material. + if ((assignment.assignedPlan ?? []).length > 0) { + const anchored = new Set(); + for (const step of assignment.assignedPlan ?? []) { + for (const n of step.supportedExcerptNumbers ?? []) { + if (Number.isInteger(n) && n > 0) anchored.add(n); + } + } + if (anchored.size > 0) { + const anchoredEntries = effectiveExcerptEntries.filter((e) => anchored.has(e.sourceNumber)); + if (anchoredEntries.length > 0) { + effectiveExcerptEntries = anchoredEntries; + } + } + } + + const effectiveExcerpts = effectiveExcerptEntries.map((e) => e.text); + + // ── Seq-A1: Label each excerpt with its position "of N" so the LLM knows + // the total sequence and cannot pretend later excerpts come first. + const totalExcerpts = assignment.transcriptExcerpts.length; // use original count so numbering is stable + const excerptBlock = effectiveExcerptEntries + .map((e) => `[EXCERPT ${e.sourceNumber} of ${totalExcerpts}]\n${e.text}`) + .join("\n\n---\n\n"); + + const quoteBlock = + assignment.quotes.length > 0 + ? `\nSCRIPTURES / QUOTES IN THIS SECTION:\n${assignment.quotes + .map( + (q) => + `• ${q.type.toUpperCase()}: "${q.text}" — Ref: ${q.reference || "none"} ${q.translation ? `(${q.translation})` : ""} — Block: ${q.isBlockQuote}` + ) + .join("\n")}` + : ""; + + const continuityBlock = assignment.previousSectionEnding + ? `\nSECTION BRIDGE: The previous section ended with this sentence: "${assignment.previousSectionEnding}" — if the opening of this section benefits from it, write ONE brief connecting sentence that picks up the thread naturally. Do NOT repeat, recap, paraphrase, or expand on that ending. One sentence maximum — then move immediately into this section's own content.` + : ""; + + // ── Upgrade 7: Tiered quote dedup — structured hard-ban blocks ────────── + // Tier 1: forbiddenVerseTexts — the EXACT verse texts are listed so the LLM + // cannot accidentally re-print them even with different framing. + // Tier 2: allowedInlineOnly — refs that may only appear as brief inline mentions. + const forbiddenVerseTextsBlock = (assignment.forbiddenVerseTexts ?? []).length > 0 + ? `\n\n════════════════════════════════════════════ +FORBIDDEN VERSE TEXTS — DO NOT PRINT (HARD BAN) +════════════════════════════════════════════ +The following verse texts have ALREADY BEEN REPRODUCED IN FULL in an earlier section of this book. You are ABSOLUTELY FORBIDDEN from printing them again — not one word of the verse, not a paraphrase, not a near-quote. If you reference the scripture at all, use ONLY its citation inline (e.g. "as John 3:16 states"). Never reprint the text: +${(assignment.forbiddenVerseTexts ?? []).map((t) => `• "${t.slice(0, 120)}${t.length > 120 ? "..." : ""}"`).join("\n")}` + : ""; + + const allowedInlineOnlyBlock = (assignment.allowedInlineOnly ?? []).length > 0 + ? `\n\n════════════════════════════════════════════ +SCRIPTURES ALLOWED INLINE ONLY (NO FULL QUOTE) +════════════════════════════════════════════ +The following references have already been quoted in full earlier. You may reference them briefly inline ONLY — never re-print the verse text: +${(assignment.allowedInlineOnly ?? []).map((r) => `• ${r}`).join("\n")}` + : ""; + + const alreadyQuotedBlock = forbiddenVerseTextsBlock + allowedInlineOnlyBlock; + + // coveredBlock is intentionally empty here — the dedup constraint is injected into + // the system prompt (deduplicatedSystem, below) where it carries maximum LLM weight. + const coveredBlock = ""; + + // ── Upgrade 5: Concept ownership map block ────────────────────────────── + // Structured JSON listing which chapter owns each concept so the LLM knows + // what belongs here vs. what belongs to a different chapter. + const conceptOwnershipMap = assignment.conceptOwnershipMap ?? {}; + const foreignConcepts = Object.entries(conceptOwnershipMap) + .filter(([, chNum]) => chNum !== assignment.chapterNumber) + .slice(0, 30); // cap to avoid prompt bloat + const conceptOwnershipBlock = foreignConcepts.length > 0 + ? `\n\n════════════════════════════════════════════ +CONCEPT OWNERSHIP — WRITE ONLY CHAPTER ${assignment.chapterNumber}'S OWN CONTENT +════════════════════════════════════════════ +The following concepts, section headings, and key points are OWNED BY OTHER CHAPTERS. Do NOT develop, introduce, or reference any of them in this section — not even as context-setting: +${foreignConcepts.map(([concept, chNum]) => `• Ch ${chNum} owns: "${concept}"`).join("\n")}` + : ""; + + const nextSectionBlock = assignment.nextSectionHeading + ? `\nFORWARD BRIDGE — STRICT LIMITS: The final sentence of this section may create forward reading momentum, but ONLY through an unresolved question, an open tension, or a logical implication that arises naturally from THIS section's own content. The next section is titled "${assignment.nextSectionHeading}" — use this ONLY as directional context for tone. You MUST NOT: + • Preview, introduce, or summarize any content from that next section + • Name the next section or its heading + • Begin developing any argument not grounded in this section's transcript excerpts + • Use bridge phrases like "Next, we will see…", "In the following section…", "This leads us to explore…" +The closing sentence is a door that swings open — not a trailer for what lies behind it.` + : ""; + + // Chapter-final sections get an explicit hard stop at the chapter boundary. + // This is the #1 cause of cross-chapter content bleed: the transcript excerpt + // contains content that OPENS the next chapter, and the writer keeps going. + const chapterClosingBlock = assignment.isLastSectionInChapter && assignment.nextChapterTitle + ? `\n\nCHAPTER BOUNDARY — HARD STOP (CRITICAL): +This is the FINAL section of Chapter ${assignment.chapterNumber}. The next chapter is titled "${assignment.nextChapterTitle}". + +The transcript excerpt provided to you WILL continue past the chapter boundary. The words belonging to Chapter ${assignment.chapterNumber + 1} are in the excerpt — you must identify where that transition happens and STOP WRITING before you reach it. + +HARD RULES for this section's close: +• DO NOT introduce the opening argument, definition, or thesis of "${assignment.nextChapterTitle}". +• DO NOT quote or paraphrase any scripture or story that will be used to open "${assignment.nextChapterTitle}". +• DO NOT begin developing any concept, key point, or illustration that is not grounded in Chapter ${assignment.chapterNumber}'s own assigned key points. +• The final sentence of this section must bring Chapter ${assignment.chapterNumber} to a natural close — a resolved statement, a challenge, or a final declaration rooted entirely in THIS chapter's own content. +• If the transcript excerpt begins introducing the theme of "${assignment.nextChapterTitle}", stop before that line. Shorter is correct; bleed into the next chapter is a critical error.` + : ""; + + const hookBlock = assignment.sectionNumber === 1 + ? `\nCHAPTER OPENER REQUIREMENT: This is the FIRST section of the chapter. The very first sentence must be a compelling hook — a bold provocative claim, a pointed question, or an immersive specific detail drawn directly from the transcript. Do not open with a general context-setting statement. Drop the reader immediately into the argument.\nHEADING ECHO BAN: The section heading is "${assignment.heading}". The first sentence of the body must NOT restate, echo, paraphrase, or summarise this heading — not even loosely. The heading is already displayed above; repeating it as the first sentence is a critical error. Begin with entirely new content from the transcript.` + : `\nHEADING ECHO BAN: The section heading is "${assignment.heading}". The first sentence of the body must NOT restate, echo, paraphrase, or summarise this heading. The heading is already displayed above. Begin immediately with the argument, scripture, or story from the transcript.`; + + // ── S7: Chapter premise anchor ────────────────────────────────────────── + // First paragraph's opening sentence should echo (not quote) the chapter premise + // so the reader feels immediate orientation within the chapter's thesis. + const chapterPremiseBlock = assignment.chapterPremise + ? `\n\nCHAPTER PREMISE (north star for this chapter):\n"${assignment.chapterPremise}"\nThe opening sentence of the FIRST paragraph of this section should echo the spirit of this premise — not quote it verbatim, but orient the reader toward the same central tension or claim. Subsequent paragraphs should build from it.` + : ""; + + const prompt = `Write the prose for this section of the ebook. Transform the transcript excerpts into polished written prose. + +CHAPTER ${assignment.chapterNumber}: ${assignment.chapterTitle} +SECTION ${assignment.sectionNumber}: ${assignment.heading} +TARGET WORD COUNT: ${assignment.targetWordCount} words (determined by available content — write what the transcript provides, no padding) +${excerptRemovedCount > 0 ? `NOTE: ${excerptRemovedCount} excerpt(s) were pre-filtered as already-covered — write ONLY from the excerpts provided below.` : ""} + +KEY POINTS TO COVER (all from the transcript — include every one): +${assignment.keyPoints.map((kp) => `• ${kp}`).join("\n")} +${quoteBlock} +${continuityBlock} +${coveredBlock} +${nextSectionBlock} +${chapterClosingBlock} +${hookBlock} +${conceptOwnershipBlock} +${chapterPremiseBlock} +${coverageLedgerBlock} +${bannedRecapsBlock} +${lexicalFingerprintBlock} +${diminishingPermissionBlock}${sequenceTurnsBlock}${storyPayoffBlock}${scripturePositionsBlock}${priorExcerptTailBlock} + +TRANSCRIPT EXCERPTS TO WRITE FROM (use ONLY these — excerpt numbers are original and may be non-contiguous after surgical filtering): +${excerptBlock} + +SECTION SCOPE RULE — READ BEFORE WRITING: +Your section is: "${assignment.heading}"${assignment.nextSectionHeading ? `\nThe NEXT section is: "${assignment.nextSectionHeading}"` : ""}${assignment.isLastSectionInChapter && assignment.nextChapterTitle ? `\nThis is the LAST section of Chapter ${assignment.chapterNumber}. The next chapter is "${assignment.nextChapterTitle}". STOP before any content that opens that chapter.` : ""} +Write ONLY content that belongs to THIS section's heading and key points. If any excerpt contains sentences that transition into or introduce the next section's topic, STOP before those sentences. Do not write them. A transcript boundary does not override a section boundary. + +CONTENT COVERAGE REQUIREMENT: Exhaust every distinct key point, story, illustration, and argument that belongs to THIS section's scope. Skip any excerpt content that clearly belongs to the next section or next chapter. Write shorter rather than bleed forward. + +SEQUENCE RULE — ABSOLUTE: Write paragraphs in the EXACT ORDER ideas appear across the excerpts (Excerpt 1 first, then Excerpt 2, etc.). Do NOT reorder. Do NOT restructure into a different arc. The speaker's build-up is intentional — follow it point by point without skipping ahead or circling back. + +NO HEADINGS RULE — ABSOLUTE: Do NOT include any markdown heading (##, ###, #) as a paragraph element. The section heading is already rendered by the book layout. A heading inside the paragraphs array creates a duplicate label mid-chapter. Pure prose paragraphs only. + +Return: +- paragraphs: an array of strings where EACH ELEMENT IS ONE PARAGRAPH of polished prose. Every paragraph is a separate array item. Never put more than one paragraph in a single array element. Do not use \n or \n\n inside any element — each element is exactly one paragraph. +- claimLedger: list of major claims and the excerpt numbers (1-based) that support each claim. +- planSequenceIds: for EACH paragraph in the paragraphs array, provide the 0-based index of the paragraph plan step it fulfills. Must be non-decreasing (paragraph N cannot fulfill a plan step earlier than paragraph N-1's step). + +Now write the section prose:`; + + + const PlanSchema = z.object({ + paragraphPlan: z.array(z.object({ + purpose: z.string().default(""), + supportedExcerptNumbers: z.array(z.number().int().positive()).default([]), + // Seq-A1: minimum excerpt number this paragraph draws from (for monotonicity check) + minExcerptNumber: z.number().int().positive().optional(), + })).default([]), + }); + + const SectionBodySchema = z.object({ + paragraphs: z.array(z.string()).default([]).describe( + "Each element is exactly one paragraph of polished prose. Never embed newlines inside an element. Each paragraph is a standalone array item." + ), + claimLedger: z.array(z.object({ + claim: z.string().default(""), + excerptNumbers: z.array(z.number().int().positive()).default([]), + })).default([]), + // Seq-A6: for each paragraph, the 0-based plan step index it fulfills + planSequenceIds: z.array(z.number().int().nonnegative()).default([]), + }); + + try { + // FIX 2: Require chapter-level plan (no fallback planner) + // The per-section fallback cannot see other sections and creates overlaps. + // Fail visibly so the pipeline can retry the chapter-plan call. + if ((assignment.assignedPlan ?? []).length === 0) { + return NextResponse.json( + { + error: "Chapter-level plan required", + details: `No assignedPlan for Ch${assignment.chapterNumber} §${assignment.sectionNumber}. The chapter-plan step must succeed before write-section can run.`, + }, + { status: 400 } + ); + } + + const paragraphPlan = assignment.assignedPlan!; + console.log(`[write-section] Using chapter-level plan (${paragraphPlan.length} entries) for Ch${assignment.chapterNumber} §${assignment.sectionNumber}`); + + // Build a per-request system prompt: Voice DNA at the top (system-level weight), + // then the dedup prohibition block if any points have already been covered. + const voiceDnaBlock = assignment.voiceDNA + ? (() => { + const dna = assignment.voiceDNA; + const lines: string[] = [ + "\n\n════════════════════════════════════════════", + "AUTHOR VOICE DNA — ENFORCE IN EVERY SENTENCE", + "════════════════════════════════════════════", + "This is the speaker's unique voice fingerprint. Every sentence you write MUST reflect these patterns.", + "", + ]; + if (dna.toneProfile) + lines.push(`TONE: ${dna.toneProfile}`); + if (dna.vocabularyLevel) + lines.push(`VOCABULARY REGISTER: ${dna.vocabularyLevel}`); + if (dna.sentencePattern) + lines.push(`SENTENCE RHYTHM: ${dna.sentencePattern}`); + if (dna.pacingFingerprint) + lines.push(`PACING: ${dna.pacingFingerprint}`); + if (dna.emotionalArc) + lines.push(`EMOTIONAL ARC: ${dna.emotionalArc}`); + if (dna.openingPattern) + lines.push(`HOW TO OPEN A NEW POINT: ${dna.openingPattern}`); + if (dna.closingPattern) + lines.push(`HOW TO CLOSE A POINT: ${dna.closingPattern}`); + if (dna.narrativeDevice) + lines.push(`STORY/ILLUSTRATION STRUCTURE: ${dna.narrativeDevice}`); + if (dna.teachingStyle) + lines.push(`TEACHING STYLE: ${dna.teachingStyle}`); + if ((dna.signaturePhrases ?? []).length > 0) + lines.push(`\nSIGNATURE PHRASES (use naturally, verbatim):\n${dna.signaturePhrases.map((p) => ` • ${p}`).join("\n")}`); + if ((dna.preferredTerminology ?? []).length > 0) + lines.push(`\nPREFERRED TERMINOLOGY (always prefer these terms):\n${dna.preferredTerminology.map((t) => ` • ${t}`).join("\n")}`); + if ((dna.vernacularMarkers ?? []).length > 0) + lines.push(`\nVERNACULAR MARKERS (must appear verbatim to authenticate the voice):\n${dna.vernacularMarkers.map((v) => ` • ${v}`).join("\n")}`); + if ((dna.rhetoricalPatterns ?? []).length > 0) + lines.push(`\nRHETORICAL PATTERNS (replicate these devices):\n${dna.rhetoricalPatterns.map((r) => ` • ${r}`).join("\n")}`); + if ((dna.avoidStructures ?? []).length > 0) + lines.push(`\nFORBIDDEN SENTENCE STRUCTURES (never construct sentences this way):\n${dna.avoidStructures.map((s) => ` • ${s}`).join("\n")}`); + if ((dna.avoidWords ?? []).length > 0) + lines.push(`\nFORBIDDEN WORDS & PHRASES (zero tolerance — not one instance):\n${dna.avoidWords.map((w) => ` • ${w}`).join("\n")}`); + return lines.join("\n"); + })() + : ""; + + // ── Amendment 3: Transitional presupposition language (skip for book's very first section) + const isAbsoluteFirstSection = (assignment.chapterNumber === 1 && sectionIdx === 0); + const transitionalRuleBlock = isAbsoluteFirstSection ? "" : `\n\n════════════════════════════════════════════\nTRANSITIONAL PRESUPPOSITION — STANDING RULE\n════════════════════════════════════════════\nThis section is NOT the first section of the book. Do NOT open as though the reader is encountering these ideas for the first time. The opening paragraph MUST presuppose what came before — use transitional presupposition language such as: "Having seen…", "Building on what we established…", "Since we know…", "With that foundation in place…", "Now that we understand…", "Given what [previous section heading] revealed…". Exception: if this IS the first section of the first chapter (sectionNumber=1, chapterNumber=1), omit this rule.`; + const shortTransitionalRuleBlock = isAbsoluteFirstSection ? "" : `\n\n════════════════════════════════════════════\nTRANSITIONAL PRESUPPOSITION — STANDING RULE\n════════════════════════════════════════════\nIf there are any sections already written before this one (see COVERAGE LEDGER), the opening paragraph MUST presuppose prior content using language like "Having seen…", "Building on…", "Since we established…", "With that foundation in place…". Do not open as though the reader is encountering the book's ideas for the first time.`; + + const deduplicatedSystem = + (assignment.alreadyCoveredPoints ?? []).length > 0 + ? `${EDITORIAL_SYSTEM}${voiceDnaBlock}${authorConfigBlock}${readabilityBlock}${coreThesisBlock}${usedIllustrationsBlock}${primaryTranslationBlock}${alreadyQuotedBlock}\n\n════════════════════════════════════════════\nFIRST-USE OWNERSHIP — STANDING RULE\n════════════════════════════════════════════\nThe first section to introduce a concept, term, or principle OWNS it for the entire book. Later sections REFERENCE what was established — they do not re-develop, re-define, or re-explain it. If a concept was introduced earlier (see COVERAGE LEDGER and PRIOR CONTENT blocks), you may assume the reader already understands it. One sentence of callback is allowed; a full re-explanation is a duplication error.${transitionalRuleBlock}\n\n════════════════════════════════════════════\nPRIOR CONTENT — HARD SKIP (NON-NEGOTIABLE)\n════════════════════════════════════════════\nThe following sections, ideas, claims, and teaching points have ALREADY BEEN WRITTEN in earlier sections of this book. You MUST skip them COMPLETELY — zero sentences, zero phrases, zero acknowledgment. Do not re-introduce, re-explain, re-state, or re-develop ANY of them, even briefly, even in passing, even with different wording. If a transcript excerpt contains these topics, skip that part of the excerpt entirely and write ONLY the new content from the remaining excerpts. Writing even one sentence about an already-covered topic is a critical error:\n${(assignment.alreadyCoveredPoints ?? []).map((p) => `• ${p}`).join("\n")}\n\nSCRIPTURE EXCEPTION — OVERRIDES THE SKIP RULE ABOVE:\nThis hard-skip rule NEVER applies to Bible verses, scripture quotations, or the direct commentary that unpacks them. If the transcript excerpts for THIS section contain a Bible verse or reference, you MUST include it in the prose — even if the same verse or a related one appeared in an earlier section. This author is a preacher; their argument is built verse by verse. Removing a scripture silently breaks the theological foundation of the point. The ONLY restriction is the FORBIDDEN VERSE TEXTS block, which prevents reprinting the exact same verse text verbatim — in that case, cite the reference inline (e.g. "as David declares in Psalm 34:4") without reprinting the full text.` + : `${EDITORIAL_SYSTEM}${voiceDnaBlock}${authorConfigBlock}${readabilityBlock}${coreThesisBlock}${usedIllustrationsBlock}${primaryTranslationBlock}${alreadyQuotedBlock}\n\n════════════════════════════════════════════\nFIRST-USE OWNERSHIP — STANDING RULE\n════════════════════════════════════════════\nThe first section to introduce a concept, term, or principle OWNS it. Later sections reference what was established — never re-develop or re-explain it. Presuppose reader knowledge of all concepts in the COVERAGE LEDGER.${shortTransitionalRuleBlock}`; + + const { object } = await generateObject({ + model: deepSeekModel, + schema: SectionBodySchema, + mode: "json", + temperature: 0.7, + system: deduplicatedSystem, + prompt: `${prompt}\n\nPARAGRAPH PLAN (must follow if provided):\n${JSON.stringify(paragraphPlan)}`, + }); + const rawParagraphs = (object.paragraphs ?? []) + .map((p) => p.trim()) + .filter(Boolean) + // Strip any markdown heading lines the LLM adds despite the ban + .filter((p) => !(/^#{1,6}\s/.test(p))); + const { paragraphs: repairedParagraphs, orphansFixed } = repairOrphanParagraphs(rawParagraphs); + if (orphansFixed > 0) { + console.log(`[write-section] S5: merged ${orphansFixed} orphan paragraph(s) in Ch${assignment.chapterNumber} §${assignment.sectionNumber}`); + } + + // ── Seq-A6: Plan sequence contract check ───────────────────────────── + // The writer declares which plan step each paragraph fulfills via + // planSequenceIds. Verify the array is non-decreasing; log any inversions. + let sequenceBreakCount = 0; + const planSeqIds = object.planSequenceIds ?? []; + for (let i = 1; i < planSeqIds.length; i++) { + if (planSeqIds[i] < planSeqIds[i - 1]) { + sequenceBreakCount++; + console.warn(`[write-section] Seq-A6 plan break: paragraph ${i + 1} fulfills plan step ${planSeqIds[i]} before step ${planSeqIds[i - 1]} in Ch${assignment.chapterNumber} §${assignment.sectionNumber}`); + } + } + + // ── Seq-A2: Sentence-level sequence watermark + auto-reorder ──────────── + // Map each paragraph to its best-matching excerpt by 4-gram overlap and + // verify the indices advance monotonically. When inversions are found, + // reorder the paragraphs so the output always follows the speaker's sequence. + let lastExcerptIdx = -1; + for (let pi = 0; pi < repairedParagraphs.length; pi++) { + const para = repairedParagraphs[pi]; + if (para.split(/\s+/).length < 15) continue; + let bestMatch = -1; + let bestScore = 0; + for (let ei = 0; ei < effectiveExcerpts.length; ei++) { + const score = excerptOverlapScore(para, effectiveExcerpts[ei]); + if (score > bestScore) { bestScore = score; bestMatch = ei; } + } + if (bestMatch >= 0 && bestScore > 0.08) { + if (bestMatch < lastExcerptIdx) { + sequenceBreakCount++; + console.warn(`[write-section] Seq-A2 watermark break: paragraph ${pi + 1} matches excerpt ${bestMatch + 1} but excerpt ${lastExcerptIdx + 1} was already used in Ch${assignment.chapterNumber} §${assignment.sectionNumber}`); + } + lastExcerptIdx = Math.max(lastExcerptIdx, bestMatch); + } + } + + // ── Seq-A2 correction: if any inversions were detected, stable-sort the + // paragraphs back into the speaker's transcript order before joining them. + let finalParagraphs = repairedParagraphs; + if (sequenceBreakCount > 0) { + const { paragraphs: reordered, reorderedCount } = reorderParagraphsByExcerptSequence( + repairedParagraphs, + effectiveExcerpts + ); + if (reorderedCount > 0) { + finalParagraphs = reordered; + console.log(`[write-section] Seq-A2 corrected: reordered ${reorderedCount} paragraph(s) back into transcript sequence in Ch${assignment.chapterNumber} §${assignment.sectionNumber}`); + } + } + + const rawBody = finalParagraphs.join("\n\n") || await fallbackSectionBody(assignment); + const body = stripAudienceLanguage(normalizeReaderFacingProse(rawBody)); + // ── Upgrade 8: Passive voice detection ─────────────────────────────── + const passiveHits = detectPassiveVoice(body); + if (passiveHits.length > 0) { + console.warn(`[write-section] Passive voice: ${passiveHits.length} hit(s) in Ch${assignment.chapterNumber} §${assignment.sectionNumber}:`, passiveHits.slice(0, 3)); + } + // ── Upgrade 12: False promise detector ─────────────────────────────── + const openingHook = extractOpeningHook(body); + let unfullfilledHook: string | null = null; + if (openingHook && !hookFulfilled(openingHook, body)) { + unfullfilledHook = openingHook; + console.warn(`[write-section] Unfulfilled hook in Ch${assignment.chapterNumber} §${assignment.sectionNumber}: "${openingHook.slice(0, 80)}"`); + } + return NextResponse.json({ + body, + claimLedger: object.claimLedger ?? [], + passiveVoiceCount: passiveHits.length, + unfullfilledHook, + sequenceBreakCount, + }, { status: 200 }); + } catch (err) { + const fallbackBody = stripAudienceLanguage(normalizeReaderFacingProse(await fallbackSectionBody(assignment))); + return NextResponse.json({ + body: fallbackBody, + claimLedger: [], + fallback: true, + error: err instanceof Error && err.message.trim() ? err.message : "Section write used transcript fallback", + details: err instanceof Error && err.stack + ? err.stack.split("\n").slice(0, 3).join(" | ") + : undefined, + }, { status: 200 }); + } +} diff --git a/app/api/fetch-youtube-transcript/route.ts b/app/api/fetch-youtube-transcript/route.ts new file mode 100644 index 0000000..ce084c2 --- /dev/null +++ b/app/api/fetch-youtube-transcript/route.ts @@ -0,0 +1,199 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; + +export const runtime = "nodejs"; +export const maxDuration = 30; + +const RequestSchema = z.object({ + url: z.string().min(1), +}); + +function extractVideoId(input: string): string | null { + const patterns = [ + // Standard watch URLs, shared youtu.be links, embeds, shorts + /(?:youtube\.com\/watch\?(?:[^#]*&)?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/, + // Bare 11-char ID + /^([a-zA-Z0-9_-]{11})$/, + ]; + // Strip iOS/Android ?si= and &si= share-tracking params before matching + const clean = input.trim().replace(/[?&]si=[^&]*/g, ""); + for (const p of patterns) { + const m = clean.match(p); + if (m?.[1]) return m[1]; + } + return null; +} + +const WEB_HEADERS = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "Accept-Language": "en-US,en;q=0.9", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", +}; +// Android UA — used when calling InnerTube as ANDROID client +const ANDROID_UA = "com.google.android.youtube/17.36.4 (Linux; U; Android 12; GB) gzip"; + +function parseXml(xml: string): string { + return [...xml.matchAll(/]*>([^<]*)<\/text>/g)] + .map((m) => + (m[1] ?? "") + .replace(/'/g, "'") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/\n/g, " ") + .trim() + ) + .filter(Boolean) + .join(" "); +} + +function parseJson3(data: unknown): string { + type Seg = { utf8?: string }; + type Event = { segs?: Seg[] }; + const events: Event[] = (data as { events?: Event[] })?.events ?? []; + return events + .filter((e) => e.segs) + .flatMap((e) => (e.segs ?? []).map((s) => s.utf8 ?? "")) + .join(" ") + .replace(/\n/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +type CaptionTrack = { baseUrl: string; languageCode: string; kind?: string }; + +/** Fetch a parsed transcript from a caption baseUrl (supports json3 + xml). */ +async function fetchCaptionUrl(baseUrl: string): Promise { + const json3Res = await fetch(baseUrl + "&fmt=json3", { headers: WEB_HEADERS }); + if (json3Res.ok) { + const text = json3Res.status !== 204 ? await json3Res.text() : ""; + if (text && text.length > 10) { + const parsed = parseJson3(JSON.parse(text) as unknown); + if (parsed.length > 50) return parsed; + } + } + // Fall back to XML + const xmlRes = await fetch(baseUrl, { headers: WEB_HEADERS }); + if (!xmlRes.ok) throw new Error(`Caption download failed: HTTP ${xmlRes.status}`); + const xml = await xmlRes.text(); + const parsed = parseXml(xml); + if (!parsed) throw new Error("Transcript parsed but was empty."); + return parsed; +} + +/** Pick best English track from a list, preferring ASR auto-captions. */ +function pickTrack(tracks: CaptionTrack[]): CaptionTrack | undefined { + return ( + tracks.find((t) => t.languageCode.startsWith("en") && t.kind === "asr") ?? + tracks.find((t) => t.languageCode.startsWith("en")) ?? + tracks[0] + ); +} + +async function fetchTranscript(videoId: string): Promise { + // ── Strategy 1: InnerTube ANDROID client ───────────────────────────── + // YouTube's internal player API. The ANDROID client identity bypasses + // bot-detection that blocks plain web scraping from cloud/Codespace IPs. + for (const clientConfig of [ + { clientName: "ANDROID", clientVersion: "17.36.4", androidSdkVersion: 31 }, + { clientName: "WEB_EMBEDDED_PLAYER", clientVersion: "1.20231121.01.00" }, + ]) { + try { + const res = await fetch("https://www.youtube.com/youtubei/v1/player?prettyPrint=false", { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": ANDROID_UA, + "Accept-Language": "en-US,en;q=0.9", + "X-YouTube-Client-Name": "3", + "X-YouTube-Client-Version": clientConfig.clientVersion, + }, + body: JSON.stringify({ + videoId, + context: { + client: { hl: "en", gl: "US", ...clientConfig }, + }, + }), + }); + if (!res.ok) continue; + + const data = (await res.json()) as { + captions?: { playerCaptionsTracklistRenderer?: { captionTracks?: CaptionTrack[] } }; + }; + const tracks = data?.captions?.playerCaptionsTracklistRenderer?.captionTracks ?? []; + const track = pickTrack(tracks); + if (!track?.baseUrl) continue; + + const transcript = await fetchCaptionUrl(track.baseUrl); + if (transcript.length > 50) return transcript; + } catch { /* try next client */ } + } + + // ── Strategy 2: direct timedtext API ───────────────────────────────── + for (const lang of ["en", "en-US", "en-GB"]) { + for (const kind of ["asr", ""]) { + try { + const params = new URLSearchParams({ v: videoId, lang, fmt: "json3", ...(kind ? { kind } : {}) }); + const res = await fetch(`https://www.youtube.com/api/timedtext?${params.toString()}`, { + headers: WEB_HEADERS, + }); + if (!res.ok) continue; + const raw = await res.text(); + if (!raw || raw === "{}" || raw.length < 20) continue; + const parsed = parseJson3(JSON.parse(raw) as unknown); + if (parsed.length > 80) return parsed; + } catch { /* try next */ } + } + } + + // ── Strategy 3: page scrape with consent cookie ─────────────────────── + const pageRes = await fetch(`https://www.youtube.com/watch?v=${videoId}`, { + headers: { ...WEB_HEADERS, Cookie: "CONSENT=YES+cb; YSC=aaaabbbb; VISITOR_INFO1_LIVE=ccccdddd;" }, + }); + + if (!pageRes.ok) { + throw new Error( + `All fetch strategies failed (HTTP ${pageRes.status}). This video's captions are inaccessible from this server. Paste the transcript text directly into the pipeline input field instead.` + ); + } + + const html = await pageRes.text(); + const captionsIdx = html.indexOf('"captionTracks"'); + if (captionsIdx === -1) { + throw new Error( + `YouTube is blocking transcript access from this server IP. Paste the transcript text directly into the pipeline input field (you can copy it from youtube.com/watch?v=${videoId} → ⋯ → Show transcript).` + ); + } + + const segment = html.slice(captionsIdx, captionsIdx + 4000); + const urlMatch = segment.match(/"baseUrl":"([^"]+)"/); + if (!urlMatch) throw new Error("Could not extract caption URL from page."); + + const captionUrl = urlMatch[1].replace(/\\u([0-9a-fA-F]{4})/g, (_, c) => + String.fromCharCode(parseInt(c, 16)) + ); + return fetchCaptionUrl(captionUrl); +} + +export async function POST(req: NextRequest) { + let videoId: string | null = null; + try { + const body = (await req.json()) as unknown; + const { url } = RequestSchema.parse(body); + + videoId = extractVideoId(url.trim()); + if (!videoId) { + return NextResponse.json( + { error: "Could not read the video ID. Paste the full YouTube URL — e.g. https://youtu.be/abc123xyz or https://youtube.com/watch?v=abc123xyz" }, + { status: 400 } + ); + } + + const transcript = await fetchTranscript(videoId); + return NextResponse.json({ transcript, videoId }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to fetch transcript"; + return NextResponse.json({ error: message, ...(videoId ? { videoId } : {}) }, { status: 500 }); + } +} diff --git a/app/api/generate-logic/route.ts b/app/api/generate-logic/route.ts new file mode 100644 index 0000000..1e48095 --- /dev/null +++ b/app/api/generate-logic/route.ts @@ -0,0 +1,50 @@ +import { generateObject } from "ai"; +import { NextRequest, NextResponse } from "next/server"; +import { deepSeekModel } from "@/lib/ai-providers"; +import { + LogicTransformRequestSchema, + LogicTransformResultSchema +} from "@/lib/schemas/blueprint"; + +export const runtime = "nodejs"; + +export async function POST(request: NextRequest) { + try { + const json = await request.json(); + const input = LogicTransformRequestSchema.parse(json); + + const { object } = await generateObject({ + model: deepSeekModel, + schema: LogicTransformResultSchema, + schemaName: "LogicTransformResult", + schemaDescription: "Execution logic graph derived from the blueprint", + mode: "json", + maxTokens: 1_500, + temperature: 0.1, + system: + "You are the Nexus Director structural reasoning engine. Return deterministic, architecture-first outputs that honor constraints and preserve referential integrity. Be concise — do not pad fields.", + prompt: [ + "Transform the workspace blueprint into a strict execution logic graph.", + "- Preserve all workflow dependencies.", + "- Minimize branching complexity.", + "- Identify risks and mitigation actions.", + "- Return data that validates against the provided schema.", + "", + `Objective: ${input.objective}`, + `Constraints: ${input.constraints.join(" | ") || "none"}`, + `Blueprint JSON: ${JSON.stringify(input.blueprint)}` + ].join("\n") + }); + + return NextResponse.json(object, { status: 200 }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + return NextResponse.json( + { + error: "Failed to generate logic structure", + detail: message + }, + { status: 400 } + ); + } +} diff --git a/app/api/generate-ui/route.ts b/app/api/generate-ui/route.ts new file mode 100644 index 0000000..d4a28dd --- /dev/null +++ b/app/api/generate-ui/route.ts @@ -0,0 +1,35 @@ +import { generateObject } from "ai"; +import { NextRequest, NextResponse } from "next/server"; +import { claudeModel } from "@/lib/ai-providers"; +import { UiManifestInputSchema, UiManifestResultSchema } from "@/lib/schemas/ui-manifest"; + +export const runtime = "nodejs"; + +export async function POST(request: NextRequest) { + try { + const json = await request.json(); + const input = UiManifestInputSchema.parse(json); + + const { object } = await generateObject({ + model: claudeModel, + schema: UiManifestResultSchema, + temperature: 0.3, + system: + "You design refined and touch-first UI manifests for premium tablet interfaces.", + prompt: [ + "Create a UI manifest for Nexus Director.", + `Objective: ${input.objective}`, + `Domain: ${input.domain}`, + `Constraints: ${input.constraints.join(" | ") || "none"}` + ].join("\n") + }); + + return NextResponse.json(object, { status: 200 }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + return NextResponse.json( + { error: "Failed to generate UI manifest", detail: message }, + { status: 400 } + ); + } +} diff --git a/app/api/ingest/route.ts b/app/api/ingest/route.ts new file mode 100644 index 0000000..f3c0bd6 --- /dev/null +++ b/app/api/ingest/route.ts @@ -0,0 +1,47 @@ +import { generateObject } from "ai"; +import { NextRequest, NextResponse } from "next/server"; +import { deepSeekReasonerModel } from "@/lib/ai-providers"; +import { IngestInputSchema, IngestResultSchema } from "@/lib/schemas/blueprint"; + +export const runtime = "nodejs"; + +export async function POST(request: NextRequest) { + try { + const json = await request.json(); + const input = IngestInputSchema.parse(json); + + // Trim source — blueprint extraction only needs a representative sample. + // Sending the full transcript wastes tokens and slows the response. + const sourceSample = input.sourceText.length > 10_000 + ? input.sourceText.slice(0, 5_000) + "\n\n[…]\n\n" + input.sourceText.slice(-3_000) + : input.sourceText; + + const { object } = await generateObject({ + model: deepSeekReasonerModel, + schema: IngestResultSchema, + schemaName: "IngestResult", + schemaDescription: "Structured blueprint extracted from source content", + mode: "json", + maxTokens: 4_000, + system: + "You are the Nexus Director Analyst. Extract a concise structured blueprint from the source. Be brief — every field should be the shortest accurate value. Do not pad or invent.", + prompt: [ + "Extract a structured blueprint from this source. Be concise.", + "- workflow: 2–4 steps max, each with a clear label and intent.", + "- assets: list only assets explicitly mentioned in the source.", + "- riskFlags: 1–2 flags only if there are genuine gaps.", + "- Return only data that validates against the schema.", + `Locale: ${input.locale}`, + `Source: ${sourceSample}` + ].join("\n") + }); + + return NextResponse.json(object, { status: 200 }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + return NextResponse.json( + { error: "Failed to ingest source media context", detail: message }, + { status: 400 } + ); + } +} diff --git a/app/api/produce/route.ts b/app/api/produce/route.ts new file mode 100644 index 0000000..00b08e7 --- /dev/null +++ b/app/api/produce/route.ts @@ -0,0 +1,347 @@ +import { NextRequest } from "next/server"; +import { generateObject } from "ai"; +import { z } from "zod"; +import { deepSeekModel, deepSeekReasonerModel } from "@/lib/ai-providers"; +import { ProduceInputSchema, AcademyPackageSchema, AcademyShellSchema } from "@/lib/schemas/academy"; + +export const runtime = "nodejs"; + +export async function POST(req: NextRequest) { + const body = await req.json() as unknown; + let input; + try { + input = ProduceInputSchema.parse(body); + } catch (error) { + const message = error instanceof Error ? error.message : "Invalid request body"; + return new Response(JSON.stringify({ error: message }), { status: 400, headers: { "Content-Type": "application/json" } }); + } + + const encoder = new TextEncoder(); + + // SSE stream — keeps the connection alive with ping comments so reverse + // proxies don't close the socket while DeepSeek generates the large object. + const stream = new ReadableStream({ + async start(controller) { + const ping = setInterval(() => { + try { controller.enqueue(encoder.encode(": ping\n\n")); } catch { /* closed */ } + }, 15_000); + + try { + // Phase 1 source: sample beginning + middle + end of the FULL raw input + // so the curriculum reflects the entire book, not just the opening pages. + // Uses input.rawTranscript (pre-truncation) to capture the full range. + const buildPhase1Source = (text: string | undefined): string => { + if (!text) return ""; + const SAMPLE = 4_500; // chars per sample point (~1,200 tokens) + const len = text.length; + const beginning = text.slice(0, SAMPLE); + const midStart = Math.max(SAMPLE, Math.floor(len / 2) - Math.floor(SAMPLE / 2)); + const middle = len > SAMPLE * 2 ? text.slice(midStart, midStart + SAMPLE) : ""; + const ending = len > SAMPLE ? text.slice(Math.max(0, len - SAMPLE)) : ""; + return [beginning, middle, ending].filter(Boolean).join("\n\n[…]\n\n"); + }; + const phase1Source = buildPhase1Source(input.rawTranscript); + const phase1SourceSection = phase1Source + ? `\n\nSOURCE MATERIAL (sampled: beginning · middle · end of the full document):\n${phase1Source}` + : ""; + + const deliverySection = input.deliveryInstructions + ? `\n\nDELIVERY INSTRUCTIONS:\n${input.deliveryInstructions}` + : ""; + + const basePrompt = JSON.stringify({ + title: input.title, + summary: input.summary, + assets: input.assets, + workflow: input.workflow, + executionPlan: input.executionPlan, + entities: input.entities, + visualDirection: input.visualDirection, + }); + + // ── Scale: calibrate module/theme count to source length ───────────── + // Spoken-word transcripts run ~800 chars/min with Deepgram formatting. + // Text files scale similarly by content density. + const transcriptChars = (input.rawTranscript ?? "").length; + const estMinutes = Math.max(1, Math.round(transcriptChars / 800)); + const scale = estMinutes < 5 + ? { minThemes: 1, maxThemes: 2, minMods: 1, maxMods: 1, maxIdx: 1 } + : estMinutes < 15 + ? { minThemes: 2, maxThemes: 2, minMods: 2, maxMods: 2, maxIdx: 1 } + : estMinutes < 40 + ? { minThemes: 3, maxThemes: 3, minMods: 3, maxMods: 3, maxIdx: 2 } + : { minThemes: 3, maxThemes: 4, minMods: 3, maxMods: 4, maxIdx: 3 }; + + const themeCountLabel = scale.minThemes === scale.maxThemes + ? `exactly ${scale.minThemes}` + : `${scale.minThemes}\u2013${scale.maxThemes}`; + const modCountLabel = scale.minMods === scale.maxMods + ? `exactly ${scale.minMods}` + : `${scale.minMods}\u2013${scale.maxMods}`; + + // ── Phase 0: Content map ──────────────────────────────────────── + // Force DeepSeek to READ and MAP the source's distinct themes BEFORE + // designing the curriculum. Each theme gets direct source passages so + // every module is anchored to real, non-overlapping content. + const ThemeEntrySchema = z.object({ + index: z.number().int().min(0).max(scale.maxIdx), + title: z.string(), + summary: z.string(), + keyPassages: z.array(z.string()).min(1).max(3), + sourceRegion: z.enum(["beginning", "early-middle", "late-middle", "end"]), + }); + const ContentMapSchema = z.object({ + themes: z.array(ThemeEntrySchema).min(scale.minThemes).max(scale.maxThemes), + }); + type ContentMap = z.infer; + + const phase0Source = phase1Source || input.rawTranscript?.slice(0, 12_000) || ""; + let contentMap: ContentMap = { themes: [] }; + if (phase0Source) { + const { object: map } = await generateObject({ + model: deepSeekReasonerModel, + schema: ContentMapSchema, + schemaName: "ContentMap", + schemaDescription: "Distinct major themes found in the source material", + mode: "json", + maxTokens: 1_200, + temperature: 0.1, + system: `You are a source analyst. Read the material and identify ${themeCountLabel} completely DISTINCT major theme${scale.maxThemes === 1 ? "" : "s"} or sections. Cover the FULL arc of the document. + +For each theme: +- index: 0-based (0 = first theme in the source) +- title: 4–7 words, the theme name using the source's own language +- summary: exactly 2 sentences — what the source specifically says about this theme +- keyPassages: 2–3 short verbatim or near-verbatim quotes (10–30 words each) taken directly from the source for this theme +- sourceRegion: where this theme appears in the document: "beginning" | "early-middle" | "late-middle" | "end" + +RULES — non-negotiable: +- Every theme must be DISTINCT — zero conceptual overlap between themes +- keyPassages must be actual text from the source, not paraphrases +- Assign themes across the full document — themes must span beginning to end, not cluster in one region +- Do NOT invent content absent from the source`, + prompt: `SOURCE MATERIAL:\n${phase0Source}`, + }); + contentMap = map; + } + + // Build a content map string for Phase 1 injection + const contentMapSection = contentMap.themes.length > 0 + ? `\n\nCONTENT MAP — your curriculum MUST map exactly to these themes (one theme per module):\n${contentMap.themes.map((t) => + `MODULE ${t.index + 1}: "${t.title}"\n What the source says: ${t.summary}\n Source passages: ${t.keyPassages.map(p => `"${p}"`).join(" | ")}` + ).join("\n\n")}` + : ""; + + // ── Phase 1: Academy shell ───────────────────────────────────────────── + // Generates all metadata, landing page, pricing, SEO, and module outlines + // with lightweight lesson stubs only. Stays well under the 8K output limit. + const { object: shell } = await generateObject({ + model: deepSeekModel, + schema: AcademyShellSchema, + schemaName: "AcademyShell", + schemaDescription: "Academy structure with landing page, pricing, SEO, and lesson outlines — no lesson content", + mode: "json", + maxTokens: 6_000, + temperature: 0.3, + system: `You are the Curator — a world-class educational content architect. Transform source material into an online academy structure. + +OUTPUT ALL ACADEMY FIELDS. TOKEN BUDGET IS TIGHT — be concise in every field. + +FOR THE CURRICULUM: produce ${modCountLabel} module${scale.maxMods === 1 ? "" : "s"} (HARD MAX ${scale.maxMods}). Each module gets exactly 2–3 lesson outlines (HARD MAX 3). Lesson outline = title + type + durationMinutes only. +DO NOT write notes, quiz, keyTakeaways, or actionItems in this phase. + +CURRICULUM RULE — the content map below defines exactly what each module covers: +- Module 1 covers ONLY the content of Theme 1. Module 2 covers ONLY Theme 2. Etc. +- Module title = the theme title (you may rephrase for marketing but must stay on that theme) +- Lesson titles must name SPECIFIC sub-concepts from that theme's source passages ONLY +- Zero topic overlap between any two modules — each module owns its theme exclusively + +FIELD GUIDE: +- academyName: Market-ready title from the core subject matter +- tagline: One punchy line — the student transformation +- targetAudience: One precise sentence +- difficultyLevel: "beginner" | "intermediate" | "advanced" +- totalEstimatedHours: Sum of lesson durations ÷ 60, rounded to 1dp +- certificateTitle: e.g. "Certificate in [Topic]" +- themeVariant: midnight (tech) | amber (business/faith) | emerald (health/nature) | rose (personal dev) | violet (design/code) | solar (beginner/broad) +- layoutVariant: "centered" | "split" | "minimal" + +LANDING PAGE: headline (max 10 words), subheadline (1 sentence), problemStatement (2 sentences), features (4 bullets — specific outcomes only), cta (button text) + +PRICING — exactly 3 tiers: +1. Free — priceUsd: 0, period: "once", 2 bullets +2. Pro — priceUsd: 47–97, period: "monthly", 4 bullets +3. Lifetime — priceUsd: 197–497, period: "once", 5 bullets + +CURRICULUM: moduleTitle, moduleDescription (1–2 sentences), lessonOutlines (max 3 stubs) + +SEO: title (50–60 chars), description (140–155 chars), keywords (6–8) +onboardingSteps: 3–4 steps + +GROUNDING — absolute hard rules: +- Every module title, lesson title, tagline, headline, and feature bullet must be drawn DIRECTLY from the source material or content map below +- Do NOT introduce ANY concept, example, framework, statistic, or claim that does not appear in the source +- If the source does not contain enough material for a field, use a shorter or vaguer value — never pad with invented content +- Paraphrase only to improve readability; never expand beyond what the speaker/author actually said`, + prompt: basePrompt + contentMapSection + phase1SourceSection + deliverySection, + }); + + // ── Phase 2: one generateObject call per module ─────────────────────── + // Splitting by module keeps each call well under DeepSeek's 8K output + // limit. A single all-modules call would exceed it for any course with + // rich notes, causing truncated JSON and a parse failure. + const SingleModuleContentSchema = z.object({ + moduleIndex: z.number().int().min(0), + learningObjectives: z.array(z.string()).min(1).max(5), + keyTerms: z.array(z.object({ + term: z.string(), + definition: z.string(), + })).min(1).max(6), + lessons: z.array(z.object({ + lessonIndex: z.number().int().min(0), + description: z.string(), + notes: z.string(), + keyTakeaways: z.array(z.string()).min(1).max(7), + actionItems: z.array(z.string()).min(1).max(4), + quiz: z.array(z.object({ + q: z.string(), + options: z.array(z.string()).length(4), + correct: z.number().int().min(0).max(3), + })).min(3).max(3), + })), + }); + + // Scale notes length to available source material. Short videos don't + // have enough unique content to fill 500-word lessons without repetition. + const notesWordCount = estMinutes < 5 + ? "150–200 words" + : estMinutes < 15 + ? "200–300 words" + : "350–500 words"; + + const phase2System = `You are the Curator — a world-class educational content writer focused on a SINGLE module. + +PER MODULE output: +- learningObjectives: exactly 3 (Understand/Apply/Identify pattern), drawn from the theme's source passages +- keyTerms: exactly 4 terms that appear in the source, exclusive to this module + +PER LESSON output: +- description: 1 sentence stating the specific learning outcome for THIS lesson +- notes: ${notesWordCount}. Core teaching content — use ONLY what the source says about this specific lesson topic. If the source covers this topic briefly, write briefly — do NOT pad to hit a word count. + Format (adapt section count to actual source depth — omit sections if the source doesn't support them): + 1. Opening paragraph (2–3 sentences): frame the lesson topic grounded in the source. + 2. "## [Section Title]" — core concept block: explain the main idea using specific language from the source. **bold** key terms where introduced. + 3. "## Key Insight" — closing 1–2 sentences: the single most important takeaway from THIS lesson. + Rules: prose only — no bullet lists. **bold** 1–3 terms. Do NOT repeat content from other lessons. +- keyTakeaways: exactly 3 — specific, distinct insights from THIS lesson only +- actionItems: exactly 2 practical steps grounded in the source +- quiz: exactly 3 questions, each with exactly 4 options, correct index 0–3 + +GROUNDING — non-negotiable hard rules: +- Every sentence must be directly traceable to the source material or theme passages provided +- Do NOT introduce concepts, examples, statistics, frameworks, or advice the speaker/author did not state +- Do NOT extrapolate or fill gaps with common knowledge — if the source is silent on a point, omit it +- Paraphrase for clarity only; never add meaning beyond what was said +- Quiz wrong-answer options must be plausible distractors from the source context, not invented facts`; + + const allModuleContents: z.infer[] = []; + // Track key terms defined in earlier modules so subsequent modules don't redefine them. + const definedTerms: Array<{ term: string; definedInModule: number }> = []; + + for (const [mi, mod] of shell.curriculum.entries()) { + const theme = contentMap.themes[mi] ?? contentMap.themes[contentMap.themes.length - 1]; + const modOutline = [ + `MODULE ${mi} "${mod.moduleTitle}": ${mod.moduleDescription}`, + ...mod.lessonOutlines.map((l, li) => ` LESSON ${li}: "${l.title}" (${l.type}, ${l.durationMinutes}min)`), + ].join("\n"); + const themePassage = theme + ? `THEME PASSAGES:\nTheme \u201c${theme.title}\u201d (${theme.sourceRegion}): ${theme.summary}\n Passages: ${theme.keyPassages.map(p => `\u201c${p}\u201d`).join(" | ")}` + : ""; + + // Cross-module dedup: tell each module what terms are already owned by prior modules. + const priorTermsBlock = definedTerms.length > 0 + ? `\nALREADY-DEFINED TERMS \u2014 DO NOT REDEFINE IN THIS MODULE:\nThe following terms were defined and explained in earlier modules. Do NOT add them to keyTerms and do NOT re-explain them in notes \u2014 at most reference them by name:\n${definedTerms.map((t) => ` \u2022 "${t.term}" (Module ${t.definedInModule + 1})`).join("\n")}` + : ""; + + const { object: modContent } = await generateObject({ + model: deepSeekModel, + schema: SingleModuleContentSchema, + schemaName: "ModuleContent", + schemaDescription: "Full lesson content for one academy module", + mode: "json", + maxTokens: 6_000, + temperature: 0.2, + system: phase2System, + prompt: [ + `Write all content for this module (moduleIndex: ${mi}):`, + modOutline, + "", + themePassage, + priorTermsBlock, + "", + phase1Source ? `SOURCE MATERIAL (sampled):\n${phase1Source}` : "", + deliverySection, + ].filter(Boolean).join("\n"), + }); + allModuleContents.push(modContent); + // Register this module's key terms so subsequent modules can avoid redefining them. + for (const kt of modContent.keyTerms ?? []) { + definedTerms.push({ term: kt.term, definedInModule: mi }); + } + } + + // Merge Phase 2 content back onto the Phase 1 shell + const fullCurriculum = shell.curriculum.map((mod, mi) => { + const modContent = allModuleContents[mi] + ?? allModuleContents[allModuleContents.length - 1]; + return { + moduleTitle: mod.moduleTitle, + moduleDescription: mod.moduleDescription, + learningObjectives: modContent.learningObjectives, + keyTerms: modContent.keyTerms, + lessons: mod.lessonOutlines.map((outline, li) => { + const lc = modContent.lessons.find(l => l.lessonIndex === li) + ?? modContent.lessons[li] + ?? modContent.lessons[modContent.lessons.length - 1]; + return { + title: outline.title, + type: outline.type, + durationMinutes: outline.durationMinutes, + description: lc.description, + notes: lc.notes.trim(), + keyTakeaways: lc.keyTakeaways, + actionItems: lc.actionItems, + quiz: lc.quiz, + }; + }), + }; + }); + + // ── Phase 3: Merge and validate ──────────────────────────────────────── + const academy = AcademyPackageSchema.parse({ + ...shell, + curriculum: fullCurriculum, + }); + + clearInterval(ping); + controller.enqueue(encoder.encode(`data: ${JSON.stringify(academy)}\n\n`)); + } catch (error) { + clearInterval(ping); + const message = error instanceof Error ? error.message : "Produce stage failed"; + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ error: message })}\n\n`)); + } finally { + controller.close(); + } + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", // disable nginx buffering + }, + }); +} + diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts new file mode 100644 index 0000000..21ee9d7 --- /dev/null +++ b/app/api/projects/route.ts @@ -0,0 +1,139 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + S3Client, + PutObjectCommand, + GetObjectCommand, + DeleteObjectCommand, + ListObjectsV2Command, +} from "@aws-sdk/client-s3"; +import { env } from "@/lib/env"; +import { z } from "zod"; + +export const runtime = "nodejs"; +export const maxDuration = 30; + +function makeS3(accountId: string, accessKey: string, secretKey: string) { + return new S3Client({ + region: "auto", + endpoint: `https://${accountId}.r2.cloudflarestorage.com`, + credentials: { accessKeyId: accessKey, secretAccessKey: secretKey }, + }); +} + +function r2Ready() { + const { R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET_NAME } = env; + if (!R2_ACCOUNT_ID || !R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY || !R2_BUCKET_NAME) return null; + return { + s3: makeS3(R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY), + bucket: R2_BUCKET_NAME, + }; +} + +// ── GET /api/projects — return all saved ProjectSnapshots from R2 ───────────── + +export async function GET() { + const r2 = r2Ready(); + if (!r2) return NextResponse.json({ projects: [] }); + + try { + // List all objects under projects/ + const list = await r2.s3.send( + new ListObjectsV2Command({ Bucket: r2.bucket, Prefix: "projects/" }), + ); + const keys = (list.Contents ?? []) + .map((o) => o.Key) + .filter((k): k is string => !!k && k.endsWith(".json")); + + if (keys.length === 0) return NextResponse.json({ projects: [] }); + + // Fetch all in parallel + const settled = await Promise.allSettled( + keys.map(async (key) => { + const res = await r2.s3.send(new GetObjectCommand({ Bucket: r2.bucket, Key: key })); + const raw = await res.Body?.transformToString(); + if (!raw) return null; + return JSON.parse(raw) as unknown; + }), + ); + + const projects = settled + .filter((r): r is PromiseFulfilledResult => r.status === "fulfilled" && r.value !== null) + .map((r) => r.value); + + return NextResponse.json({ projects }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to load projects"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +// ── POST /api/projects — upsert a single ProjectSnapshot ───────────────────── + +const UpsertSchema = z.object({ + project: z.object({ id: z.string().min(1) }).passthrough(), +}); + +export async function POST(req: NextRequest) { + const r2 = r2Ready(); + if (!r2) return NextResponse.json({ ok: true }); // no-op when R2 not configured + + let input; + try { + input = UpsertSchema.parse(await req.json() as unknown); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Invalid input" }, + { status: 400 }, + ); + } + + const { project } = input; + try { + await r2.s3.send( + new PutObjectCommand({ + Bucket: r2.bucket, + Key: `projects/${project.id}.json`, + Body: JSON.stringify(project), + ContentType: "application/json", + CacheControl: "private, no-cache", + }), + ); + return NextResponse.json({ ok: true }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Save failed" }, + { status: 500 }, + ); + } +} + +// ── DELETE /api/projects — remove a project from R2 ────────────────────────── + +const DeleteSchema = z.object({ id: z.string().min(1) }); + +export async function DELETE(req: NextRequest) { + const r2 = r2Ready(); + if (!r2) return NextResponse.json({ ok: true }); + + let input; + try { + input = DeleteSchema.parse(await req.json() as unknown); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Invalid input" }, + { status: 400 }, + ); + } + + try { + await r2.s3.send( + new DeleteObjectCommand({ Bucket: r2.bucket, Key: `projects/${input.id}.json` }), + ); + return NextResponse.json({ ok: true }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Delete failed" }, + { status: 500 }, + ); + } +} diff --git a/app/api/r2-presign/route.ts b/app/api/r2-presign/route.ts new file mode 100644 index 0000000..532b8a5 --- /dev/null +++ b/app/api/r2-presign/route.ts @@ -0,0 +1,56 @@ +import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { NextRequest, NextResponse } from "next/server"; +import { env } from "@/lib/env"; +import { z } from "zod"; + +export const runtime = "nodejs"; + +const RequestSchema = z.object({ + filename: z.string().min(1).max(255), + contentType: z.string().min(1), + prefix: z.enum(["videos", "images", "voice-samples"]).default("videos"), +}); + +export async function POST(req: NextRequest) { + const { R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET_NAME, R2_PUBLIC_URL } = env; + + if (!R2_ACCOUNT_ID || !R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY || !R2_BUCKET_NAME) { + return NextResponse.json( + { error: "R2 storage not configured — add R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET_NAME to your environment variables." }, + { status: 503 } + ); + } + + try { + const body = await req.json() as unknown; + const { filename, contentType, prefix } = RequestSchema.parse(body); + + const s3 = new S3Client({ + region: "auto", + endpoint: `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`, + credentials: { + accessKeyId: R2_ACCESS_KEY_ID, + secretAccessKey: R2_SECRET_ACCESS_KEY, + }, + }); + + // Sanitise filename and namespace under the requested prefix + const safe = filename.replace(/[^a-zA-Z0-9._-]/g, "_"); + const key = `${prefix}/${Date.now()}-${safe}`; + + const command = new PutObjectCommand({ + Bucket: R2_BUCKET_NAME, + Key: key, + ContentType: contentType, + }); + + const presignedUrl = await getSignedUrl(s3, command, { expiresIn: 3600 }); + const publicUrl = R2_PUBLIC_URL ? `${R2_PUBLIC_URL.replace(/\/$/, "")}/${key}` : null; + + return NextResponse.json({ presignedUrl, key, publicUrl }); + } catch (error) { + const message = error instanceof Error ? error.message : "Presign failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/app/api/r2-upload/route.ts b/app/api/r2-upload/route.ts new file mode 100644 index 0000000..b883dfc --- /dev/null +++ b/app/api/r2-upload/route.ts @@ -0,0 +1,101 @@ +/** + * POST /api/r2-upload + * + * Server-side file upload to Cloudflare R2. + * Accepts multipart/form-data with: + * file — the audio/video file + * prefix — storage prefix (default: "voice-samples") + * ext — file extension override (derived from file name if omitted) + * + * Returns: { publicUrl: string | null, key: string } + * + * Using server-side upload avoids browser CORS restrictions on presigned PUTs. + */ + +import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; +import { NextRequest, NextResponse } from "next/server"; +import { env } from "@/lib/env"; + +export const runtime = "nodejs"; +export const maxDuration = 60; + +const ALLOWED_PREFIXES = ["voice-samples", "videos", "images"] as const; +const MAX_BYTES = 30 * 1024 * 1024; // 30 MB + +export async function POST(req: NextRequest) { + const { + R2_ACCOUNT_ID, + R2_ACCESS_KEY_ID, + R2_SECRET_ACCESS_KEY, + R2_BUCKET_NAME, + R2_PUBLIC_URL, + } = env; + + if (!R2_ACCOUNT_ID || !R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY || !R2_BUCKET_NAME) { + return NextResponse.json({ error: "R2 storage not configured" }, { status: 503 }); + } + + let formData: FormData; + try { + formData = await req.formData(); + } catch { + return NextResponse.json({ error: "Expected multipart/form-data" }, { status: 400 }); + } + + const file = formData.get("file") as File | null; + const rawPrefix = (formData.get("prefix") as string | null) ?? "voice-samples"; + const prefix = ALLOWED_PREFIXES.includes(rawPrefix as (typeof ALLOWED_PREFIXES)[number]) + ? rawPrefix + : "voice-samples"; + + if (!file || file.size === 0) { + return NextResponse.json({ error: "No file provided" }, { status: 400 }); + } + if (file.size > MAX_BYTES) { + return NextResponse.json( + { error: `File too large (max ${MAX_BYTES / 1024 / 1024} MB)` }, + { status: 413 } + ); + } + + // Derive extension from the File's name, falling back to the form field + const nameExt = file.name.split(".").pop()?.toLowerCase() ?? ""; + const extField = (formData.get("ext") as string | null)?.toLowerCase() ?? ""; + const ext = nameExt || extField || "bin"; + const safeExt = ext.replace(/[^a-z0-9]/g, ""); + + const contentType = file.type || "application/octet-stream"; + const key = `${prefix}/${Date.now()}-sample.${safeExt}`; + + try { + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + const s3 = new S3Client({ + region: "auto", + endpoint: `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`, + credentials: { + accessKeyId: R2_ACCESS_KEY_ID, + secretAccessKey: R2_SECRET_ACCESS_KEY, + }, + }); + + await s3.send( + new PutObjectCommand({ + Bucket: R2_BUCKET_NAME, + Key: key, + Body: buffer, + ContentType: contentType, + }) + ); + + const publicUrl = R2_PUBLIC_URL + ? `${R2_PUBLIC_URL.replace(/\/$/, "")}/${key}` + : null; + + return NextResponse.json({ publicUrl, key }); + } catch (error) { + const message = error instanceof Error ? error.message : "Upload failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/app/api/sermon-assistant/document-extract/route.ts b/app/api/sermon-assistant/document-extract/route.ts new file mode 100644 index 0000000..af10072 --- /dev/null +++ b/app/api/sermon-assistant/document-extract/route.ts @@ -0,0 +1,79 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; + +export const runtime = "nodejs"; + +const FileMetaSchema = z.object({ + name: z.string().min(1), + size: z.number().positive().max(40_000_000), + type: z.string().optional(), +}); + +const SUPPORTED_EXTENSIONS = new Set(["txt", "md", "markdown", "srt", "csv", "tsv", "json", "pdf"]); + +function fileExtension(name: string): string { + return name.split(".").pop()?.toLowerCase() ?? ""; +} + +export async function POST(request: NextRequest) { + try { + const formData = await request.formData(); + const rawFile = formData.get("file"); + if (!(rawFile instanceof File)) { + return NextResponse.json({ error: "Missing upload file" }, { status: 400 }); + } + + const meta = FileMetaSchema.safeParse({ + name: rawFile.name, + size: rawFile.size, + type: rawFile.type, + }); + if (!meta.success) { + return NextResponse.json({ error: "Invalid file metadata" }, { status: 400 }); + } + + const ext = fileExtension(meta.data.name); + if (!SUPPORTED_EXTENSIONS.has(ext)) { + return NextResponse.json({ error: "Unsupported file format" }, { status: 400 }); + } + + let text = ""; + if (ext === "pdf") { + try { + const pdfParse = (await import("pdf-parse")).default; + const buffer = Buffer.from(await rawFile.arrayBuffer()); + const parsed = await pdfParse(buffer); + text = parsed.text ?? ""; + } catch (error) { + return NextResponse.json( + { + error: error instanceof Error + ? `PDF extraction failed: ${error.message}` + : "PDF extraction failed. The file may be image-only, encrypted, or malformed.", + }, + { status: 422 }, + ); + } + } else { + text = await rawFile.text(); + } + + const normalized = text.replace(/\u0000/g, "").trim(); + if (!normalized) { + return NextResponse.json( + { error: ext === "pdf" ? "No readable text extracted from PDF. This PDF may be scanned/image-only and needs OCR support." : "No readable text extracted" }, + { status: 422 }, + ); + } + + const maxChars = 240_000; + const truncated = normalized.length > maxChars; + const capped = truncated ? normalized.slice(0, maxChars) : normalized; + return NextResponse.json({ text: capped, truncated, originalLength: normalized.length }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Document extraction failed" }, + { status: 500 }, + ); + } +} diff --git a/app/api/sermon-assistant/route.ts b/app/api/sermon-assistant/route.ts new file mode 100644 index 0000000..477de21 --- /dev/null +++ b/app/api/sermon-assistant/route.ts @@ -0,0 +1,166 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateText } from "ai"; +import { z } from "zod"; +import { deepSeekReasonerModel } from "@/lib/ai-providers"; + +export const runtime = "nodejs"; +export const maxDuration = 120; + +const RequestSchema = z.discriminatedUnion("action", [ + z.object({ + action: z.literal("outline"), + rawTranscript: z.string().min(1).max(120000), + }), + z.object({ + action: z.literal("command"), + rawTranscript: z.string().min(1).max(120000), + organizedMarkdown: z.string().max(120000).optional().default(""), + command: z.string().min(1).max(2000), + }), +]); + +function outlineSystemPrompt(): string { + return [ + "You are Nexus Sermon Assistant.", + "Transform spoken transcript into a clear, well-organized sermon manuscript outline in Markdown.", + "The user wants organized notes they can preach from and manually edit later.", + "Use this structure whenever source supports it:", + "# Sermon Title", + "## Central Theme", + "## Key Scriptures", + "### Scripture Reference", + "> Full scripture text", + "- Why it matters in the sermon", + "## Opening", + "## Main Movement 1", + "## Main Movement 2", + "## Main Movement 3", + "## Supporting Notes", + "## Invitation / Response", + "## Closing Prayer", + "Keep it faithful to the transcript.", + "If the speaker directly quoted scripture or clearly alluded to a scripture, identify it and include the full verse text when you can do so with high confidence.", + "If a hinted scripture is plausible but not certain, place it under Key Scriptures with a note saying it is a likely reference.", + "Do not invent stories, sermon points, or references not grounded in the transcript.", + "Use bullet points for subpoints, transitions, applications, and supporting notes.", + "Use blockquotes for scripture quotations.", + "Preserve the speaker's language where it is strong, but rewrite into a clean, readable structure.", + "Output only final Markdown.", + ].join("\n"); +} + +function commandSystemPrompt(): string { + return [ + "You are Nexus Sermon Assistant.", + "You receive: transcript, current outline, and a user command.", + "Apply the command precisely and return the complete updated sermon outline in Markdown.", + "Edit only the scope requested by the user.", + "If the user asks for changes to one section, only change that section and keep all other sections intact.", + "Do not delete, condense, or rewrite unrelated sections.", + "Preserve existing headings and order unless the user explicitly asks to move/remove/restructure them.", + "When unsure, prefer minimal diffs over broad rewrites.", + "Keep the outline well organized and preachable.", + "Retain or improve the Key Scriptures section with full verse text for clearly identified scriptures and likely-reference notes for hints/allusions.", + "Preserve unaffected sections.", + "Do not add fabricated details not inferable from source material.", + "Output only final Markdown.", + ].join("\n"); +} + +function countWords(text: string): number { + const tokens = text.trim().match(/\S+/g); + return tokens ? tokens.length : 0; +} + +function countHeadings(text: string): number { + const matches = text.match(/^#{1,6}\s+/gm); + return matches ? matches.length : 0; +} + +function isGlobalRewriteCommand(command: string): boolean { + return /(rewrite\s+(the\s+)?(entire|whole|full)|rewrite\s+all|overhaul|replace\s+everything|summari[sz]e\s+(the\s+)?(entire|whole|full)|condense\s+(the\s+)?(entire|whole|full)|shorten\s+(the\s+)?(entire|whole|full)|trim\s+everything)/i.test(command); +} + +function looksAggressivelyTrimmed(previous: string, next: string, command: string): boolean { + if (isGlobalRewriteCommand(command)) return false; + + const prevWords = countWords(previous); + const nextWords = countWords(next); + if (prevWords < 120) return false; + + const wordRatio = nextWords / Math.max(1, prevWords); + const prevHeadings = countHeadings(previous); + const nextHeadings = countHeadings(next); + const headingRatio = prevHeadings > 0 ? nextHeadings / prevHeadings : 1; + + return wordRatio < 0.7 || headingRatio < 0.6; +} + +export async function POST(req: NextRequest) { + let parsed: z.infer; + try { + parsed = RequestSchema.parse(await req.json() as unknown); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Invalid request" }, + { status: 400 }, + ); + } + + try { + if (parsed.action === "outline") { + const { text } = await generateText({ + model: deepSeekReasonerModel, + temperature: 0.3, + maxTokens: 2800, + system: outlineSystemPrompt(), + prompt: `RAW TRANSCRIPT:\n${parsed.rawTranscript}`, + }); + + return NextResponse.json({ markdown: text.trim() }); + } + + const prompt = [ + `RAW TRANSCRIPT:\n${parsed.rawTranscript}`, + `CURRENT OUTLINE:\n${parsed.organizedMarkdown}`, + `COMMAND:\n${parsed.command}`, + ].join("\n\n"); + + const first = await generateText({ + model: deepSeekReasonerModel, + temperature: 0.25, + maxTokens: 3600, + system: commandSystemPrompt(), + prompt, + }); + + let markdown = first.text.trim(); + + if (looksAggressivelyTrimmed(parsed.organizedMarkdown, markdown, parsed.command)) { + const retry = await generateText({ + model: deepSeekReasonerModel, + temperature: 0.2, + maxTokens: 3800, + system: [ + commandSystemPrompt(), + "CRITICAL: Your previous draft removed too much content.", + "Return the full outline while editing only the requested section(s).", + "Keep all untouched headings and paragraphs from CURRENT OUTLINE.", + ].join("\n"), + prompt, + }); + + const retryMarkdown = retry.text.trim(); + markdown = looksAggressivelyTrimmed(parsed.organizedMarkdown, retryMarkdown, parsed.command) + ? parsed.organizedMarkdown + : retryMarkdown; + } + + return NextResponse.json({ markdown }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Generation failed" }, + { status: 500 }, + ); + } +} \ No newline at end of file diff --git a/app/api/sermon-assistant/scripture-suggest/route.ts b/app/api/sermon-assistant/scripture-suggest/route.ts new file mode 100644 index 0000000..fb12976 --- /dev/null +++ b/app/api/sermon-assistant/scripture-suggest/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateObject } from "ai"; +import { z } from "zod"; +import { deepSeekModel } from "@/lib/ai-providers"; + +export const runtime = "nodejs"; +export const maxDuration = 30; + +const RequestSchema = z.object({ + context: z.string().min(20).max(4000), + existingRefs: z.array(z.string().max(64)).max(50).optional().default([]), +}); + +const SuggestionSchema = z.object({ + suggestions: z.array(z.object({ + ref: z.string().min(3).max(40), + text: z.string().min(6).max(280), + reason: z.string().min(6).max(180), + confidence: z.number().min(0).max(1), + })).max(3), +}); + +export async function POST(req: NextRequest) { + let input: z.infer; + try { + input = RequestSchema.parse(await req.json() as unknown); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Invalid request" }, + { status: 400 }, + ); + } + + try { + const result = await generateObject({ + model: deepSeekModel, + schema: SuggestionSchema, + temperature: 0.2, + system: [ + "You are a Bible reference matcher for sermon prep.", + "Given a paraphrased theology statement, suggest likely scripture references.", + "Return only highly plausible references, not exhaustive lists.", + "Do not suggest refs already in existingRefs.", + "If confidence is low, return an empty suggestions array.", + ].join("\n"), + prompt: [ + `CONTEXT:\n${input.context}`, + `EXISTING_REFS:\n${input.existingRefs.join(", ") || "(none)"}`, + ].join("\n\n"), + }); + + const filtered = result.object.suggestions.filter( + (item) => !input.existingRefs.some((ref) => ref.toLowerCase() === item.ref.toLowerCase()), + ); + + return NextResponse.json({ suggestions: filtered }); + } catch { + return NextResponse.json({ suggestions: [] }); + } +} diff --git a/app/api/sermon-assistant/transcribe-chunk/route.ts b/app/api/sermon-assistant/transcribe-chunk/route.ts new file mode 100644 index 0000000..4e2a389 --- /dev/null +++ b/app/api/sermon-assistant/transcribe-chunk/route.ts @@ -0,0 +1,114 @@ +import { NextRequest, NextResponse } from "next/server"; +import { env } from "@/lib/env"; + +export const runtime = "nodejs"; + +type SpeechLanguage = "auto" | "english" | "spanish" | "french" | "portuguese" | "german" | "swahili" | "twi" | "kikuyu"; + +type DeepgramResponse = { + err_msg?: string; + results?: { + channels?: Array<{ + detected_language?: string; + alternatives?: Array<{ + transcript?: string; + }>; + }>; + }; +}; + +function normalizeSpeechLanguage(value: string | null): SpeechLanguage { + const normalized = (value ?? "auto").toLowerCase(); + const allowed: SpeechLanguage[] = ["auto", "english", "spanish", "french", "portuguese", "german", "swahili", "twi", "kikuyu"]; + return (allowed as string[]).includes(normalized) ? normalized as SpeechLanguage : "auto"; +} + +function buildAttempts(speechLanguage: SpeechLanguage): Array<{ language?: string; detectLanguage: boolean }> { + const codeMap: Record, string[]> = { + english: ["en"], + spanish: ["es"], + french: ["fr"], + portuguese: ["pt"], + german: ["de"], + swahili: ["sw"], + twi: ["ak", "twi"], + kikuyu: ["ki", "kikuyu"], + }; + + if (speechLanguage === "auto") { + return [ + { language: "multi", detectLanguage: true }, + { detectLanguage: true }, + ]; + } + + const manual = codeMap[speechLanguage].map((language) => ({ language, detectLanguage: false })); + return [ + ...manual, + { language: "multi", detectLanguage: true }, + { detectLanguage: true }, + ]; +} + +export async function POST(request: NextRequest) { + if (!env.DEEPGRAM_API_KEY) { + return NextResponse.json({ error: "DEEPGRAM_API_KEY is not configured" }, { status: 503 }); + } + + const formData = await request.formData(); + const audio = formData.get("audio"); + const speechLanguage = normalizeSpeechLanguage(formData.get("speechLanguage")?.toString() ?? null); + + if (!(audio instanceof File)) { + return NextResponse.json({ error: "Missing audio chunk" }, { status: 400 }); + } + + const arrayBuffer = await audio.arrayBuffer(); + if (arrayBuffer.byteLength === 0) { + return NextResponse.json({ transcript: "" }); + } + + let lastError = "Transcription request failed"; + + for (const attempt of buildAttempts(speechLanguage)) { + const params = new URLSearchParams({ + model: "nova-3", + punctuate: "true", + smart_format: "true", + paragraphs: "false", + }); + + if (attempt.language) params.set("language", attempt.language); + if (attempt.detectLanguage) params.set("detect_language", "true"); + + try { + const response = await fetch(`https://api.deepgram.com/v1/listen?${params.toString()}`, { + method: "POST", + headers: { + Authorization: `Token ${env.DEEPGRAM_API_KEY}`, + "Content-Type": audio.type || "audio/webm", + }, + body: arrayBuffer, + }); + + const data = await response.json() as DeepgramResponse; + if (!response.ok) { + lastError = data.err_msg ?? `Deepgram ${response.status}`; + continue; + } + + const channel = data.results?.channels?.[0]; + const transcript = channel?.alternatives?.[0]?.transcript?.trim() ?? ""; + if (!transcript) continue; + + return NextResponse.json({ + transcript, + detectedLanguage: channel?.detected_language ?? null, + }); + } catch (error) { + lastError = error instanceof Error ? error.message : "Transcription request failed"; + } + } + + return NextResponse.json({ error: lastError }, { status: 502 }); +} diff --git a/app/api/sermon-assistant/translate/route.ts b/app/api/sermon-assistant/translate/route.ts new file mode 100644 index 0000000..dd0bf4d --- /dev/null +++ b/app/api/sermon-assistant/translate/route.ts @@ -0,0 +1,221 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { env } from "@/lib/env"; + +export const runtime = "nodejs"; + +const RequestSchema = z.object({ + text: z.string().min(1).max(6000), + lang: z.enum([ + "english", + "spanish", + "french", + "portuguese", + "german", + "swahili", + "twi", + "kikuyu", + "italian", + "dutch", + "arabic", + "hindi", + "russian", + "ukrainian", + "turkish", + "chinese", + "japanese", + "korean", + "amharic", + "yoruba", + "hausa", + ]), + glossary: z.array(z.object({ source: z.string().min(1).max(120), target: z.string().min(1).max(160) })).max(150).optional(), + protectedTerms: z.array(z.string().min(1).max(120)).max(150).optional(), +}); + +type DeepSeekResponse = { + choices?: Array<{ + message?: { + content?: string; + }; + }>; + error?: { + message?: string; + }; +}; + +const LANGUAGE_STYLE_GUIDE: Record["lang"], string> = { + english: "natural modern English", + spanish: "natural modern Spanish", + french: "natural modern French", + portuguese: "natural modern Portuguese", + german: "natural modern German", + swahili: "natural contemporary Swahili", + twi: "natural Akan Twi", + kikuyu: "natural Kikuyu (Gikuyu)", + italian: "natural modern Italian", + dutch: "natural modern Dutch", + arabic: "natural modern Arabic", + hindi: "natural modern Hindi", + russian: "natural modern Russian", + ukrainian: "natural modern Ukrainian", + turkish: "natural modern Turkish", + chinese: "natural modern Chinese (Mandarin)", + japanese: "natural modern Japanese", + korean: "natural modern Korean", + amharic: "natural modern Amharic", + yoruba: "natural modern Yoruba", + hausa: "natural modern Hausa", +}; + +function tokenize(value: string): string[] { + return value + .toLowerCase() + .replace(/[^\p{L}\p{N}\s]/gu, " ") + .split(/\s+/) + .filter((token) => token.length > 1); +} + +function overlapRatio(source: string, output: string): number { + const src = new Set(tokenize(source)); + const out = tokenize(output); + if (src.size === 0 || out.length === 0) return 0; + const matches = out.filter((token) => src.has(token)).length; + return matches / out.length; +} + +function looksLikeTransliteration(source: string, output: string): boolean { + if (!source.trim() || !output.trim()) return false; + if (output.length < 24) return false; + + const normalizedSource = source.toLowerCase().replace(/\s+/g, " ").trim(); + const normalizedOutput = output.toLowerCase().replace(/\s+/g, " ").trim(); + + if (normalizedSource === normalizedOutput) return true; + return overlapRatio(source, output) >= 0.78; +} + +function buildSystemPrompt( + targetLanguageLabel: string, + styleGuide: string, + strictAntiTransliteration: boolean, + glossary: Array<{ source: string; target: string }>, + protectedTerms: string[], +): string { + const base = [ + `You are a highly accurate live translator to ${targetLanguageLabel}.`, + `Return the translation in ${styleGuide}.`, + "Preserve meaning, tone, and intent.", + "Do not summarize.", + "Do not add notes, labels, or quotes.", + "Return ONLY the final translated text.", + ]; + + if (glossary.length > 0) { + const glossaryRules = glossary + .slice(0, 80) + .map((item) => `${item.source} => ${item.target}`) + .join("; "); + base.push(`Terminology glossary (must be enforced when relevant): ${glossaryRules}`); + } + + if (protectedTerms.length > 0) { + const protectedRules = protectedTerms.slice(0, 80).join(", "); + base.push(`Protected terms (never translate or alter): ${protectedRules}`); + } + + if (strictAntiTransliteration) { + base.push("Never transliterate or mimic source pronunciation."); + base.push("Use true semantic translation in the target language."); + base.push("If a term is unclear, translate by context instead of copying source words."); + } + + return base.join(" "); +} + +async function requestTranslation(text: string, systemPrompt: string): Promise<{ translation?: string; status?: number; error?: string }> { + const response = await fetch("https://api.deepseek.com/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${env.DEEPSEEK_API_KEY}`, + }, + body: JSON.stringify({ + model: "deepseek-chat", + temperature: 0, + messages: [ + { + role: "system", + content: systemPrompt, + }, + { + role: "user", + content: text, + }, + ], + }), + }); + + const data = await response.json() as DeepSeekResponse; + if (!response.ok) { + return { + status: response.status, + error: data.error?.message ?? `DeepSeek request failed (${response.status})`, + }; + } + + return { translation: data.choices?.[0]?.message?.content?.trim() }; +} + +export async function POST(request: NextRequest) { + let body: z.infer; + try { + body = RequestSchema.parse(await request.json() as unknown); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Invalid request payload" }, + { status: 400 }, + ); + } + + if (!env.DEEPSEEK_API_KEY) { + return NextResponse.json({ error: "DEEPSEEK_API_KEY is not configured" }, { status: 503 }); + } + + const targetLanguageLabel = body.lang[0].toUpperCase() + body.lang.slice(1); + const styleGuide = LANGUAGE_STYLE_GUIDE[body.lang]; + const glossary = body.glossary ?? []; + const protectedTerms = body.protectedTerms ?? []; + + try { + const firstPass = await requestTranslation( + body.text, + buildSystemPrompt(targetLanguageLabel, styleGuide, false, glossary, protectedTerms), + ); + if (!firstPass.translation) { + return NextResponse.json( + { error: firstPass.error ?? "Translation request failed" }, + { status: firstPass.status ?? 502 }, + ); + } + + let translation = firstPass.translation; + if (looksLikeTransliteration(body.text, translation)) { + const secondPass = await requestTranslation( + body.text, + buildSystemPrompt(targetLanguageLabel, styleGuide, true, glossary, protectedTerms), + ); + + if (secondPass.translation) { + translation = secondPass.translation; + } + } + + return NextResponse.json({ translation }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Translation failed" }, + { status: 500 }, + ); + } +} diff --git a/app/api/transcribe-token/route.ts b/app/api/transcribe-token/route.ts new file mode 100644 index 0000000..c6cc677 --- /dev/null +++ b/app/api/transcribe-token/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from "next/server"; +import { env } from "@/lib/env"; + +export const runtime = "nodejs"; + +export async function GET() { + if (!env.DEEPGRAM_API_KEY) { + return NextResponse.json({ error: "DEEPGRAM_API_KEY not configured" }, { status: 503 }); + } + return NextResponse.json({ apiKey: env.DEEPGRAM_API_KEY }); +} diff --git a/app/api/transcribe/route.ts b/app/api/transcribe/route.ts new file mode 100644 index 0000000..4bb2db4 --- /dev/null +++ b/app/api/transcribe/route.ts @@ -0,0 +1,72 @@ +import { createClient } from "@deepgram/sdk"; +import { NextRequest, NextResponse } from "next/server"; +import { env } from "@/lib/env"; + +export const runtime = "nodejs"; +export const maxDuration = 120; // large files can take time + +export async function POST(request: NextRequest) { + if (!env.DEEPGRAM_API_KEY) { + return NextResponse.json( + { error: "DEEPGRAM_API_KEY not configured" }, + { status: 503 } + ); + } + + try { + const form = await request.formData(); + const file = form.get("file"); + if (!file || typeof file === "string") { + return NextResponse.json({ error: "No file provided" }, { status: 400 }); + } + + const buffer = Buffer.from(await file.arrayBuffer()); + // Map video containers to their audio equivalent so Deepgram accepts them. + // iOS Files app reports MP4 sermon recordings as video/mp4 or video/quicktime. + const VIDEO_TO_AUDIO: Record = { + "video/mp4": "audio/mp4", + "video/quicktime": "audio/mp4", + "video/x-m4v": "audio/mp4", + "video/webm": "audio/webm", + "video/ogg": "audio/ogg", + "video/x-matroska": "audio/webm", + }; + const rawMime = file.type || ""; + const mimeType = VIDEO_TO_AUDIO[rawMime] ?? (rawMime || "audio/mpeg"); + + const deepgram = createClient(env.DEEPGRAM_API_KEY); + + const { result, error } = await deepgram.listen.prerecorded.transcribeFile( + buffer, + { + model: "nova-2", + smart_format: true, + punctuate: true, + paragraphs: true, + utterances: false, + language: "en", + mimetype: mimeType, + } + ); + + if (error) throw new Error(error.message ?? "Deepgram error"); + + const transcript = + result?.results?.channels?.[0]?.alternatives?.[0]?.transcript ?? ""; + + if (!transcript.trim()) { + return NextResponse.json( + { error: "Deepgram returned an empty transcript" }, + { status: 422 } + ); + } + + return NextResponse.json({ transcript }, { status: 200 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json( + { error: "Transcription failed", detail: message }, + { status: 500 } + ); + } +} diff --git a/app/api/voice/clone/finalize/route.ts b/app/api/voice/clone/finalize/route.ts new file mode 100644 index 0000000..87049d8 --- /dev/null +++ b/app/api/voice/clone/finalize/route.ts @@ -0,0 +1,238 @@ +/** + * POST /api/voice/clone/finalize + * + * Called repeatedly by the browser to check on a RunPod clone job. + * When the job completes, uploads the cleaned WAV to R2 and returns voiceId. + * + * Body: { runpodJobId: string } + * Returns: + * { status: "IN_QUEUE" | "IN_PROGRESS" } — still running, poll again + * { status: "COMPLETED", voiceId, durationSec } — done + * { status: "FAILED", error } — fatal error + */ + +import { NextRequest, NextResponse } from "next/server"; +import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; +import { z } from "zod"; +import { env } from "@/lib/env"; +import { toR2PublicUrlOrKey } from "@/lib/r2-storage"; + +export const runtime = "nodejs"; +export const maxDuration = 15; + +const RequestSchema = z.object({ runpodJobId: z.string().min(1) }); + +function makeS3(): S3Client { + return new S3Client({ + region: "auto", + endpoint: `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`, + credentials: { + accessKeyId: env.R2_ACCESS_KEY_ID!, + secretAccessKey: env.R2_SECRET_ACCESS_KEY!, + }, + }); +} + +function getOutputObject(value: unknown): Record | null { + const queue: unknown[] = [value]; + let fallback: Record | null = null; + + while (queue.length > 0) { + const current = queue.shift(); + + if (typeof current === "string") { + const trimmed = current.trim(); + if (trimmed.length > 1000 && /^[A-Za-z0-9+/=\r\n]+$/.test(trimmed)) { + return { wav_base64: trimmed }; + } + try { + queue.push(JSON.parse(trimmed)); + } catch { + // Not JSON, continue searching. + } + continue; + } + + if (Array.isArray(current)) { + queue.push(...current); + continue; + } + + if (!current || typeof current !== "object") continue; + + const obj = current as Record; + if (!fallback) fallback = obj; + + const hasAudioPayload = + typeof obj.wav_base64 === "string" || + typeof obj.audio_base64 === "string" || + typeof obj.error === "string"; + if (hasAudioPayload) return obj; + + if (obj.output !== undefined) queue.push(obj.output); + if (obj.delayOutput !== undefined) queue.push(obj.delayOutput); + if (obj.data !== undefined) queue.push(obj.data); + } + + return fallback; +} + +function extractAudioBase64(value: unknown): string | null { + const queue: unknown[] = [value]; + + while (queue.length > 0) { + const current = queue.shift(); + if (!current) continue; + + if (typeof current === "string") { + const trimmed = current.trim(); + if (trimmed.length > 1000 && /^[A-Za-z0-9+/=\r\n]+$/.test(trimmed)) { + return trimmed; + } + continue; + } + + if (Array.isArray(current)) { + queue.push(...current); + continue; + } + + if (typeof current !== "object") continue; + const obj = current as Record; + + const direct = obj.wav_base64 ?? obj.audio_base64; + if (typeof direct === "string" && direct.length > 0) return direct; + + const nestedAudio = obj.audio; + if (nestedAudio && typeof nestedAudio === "object") { + const nested = nestedAudio as Record; + const nestedDirect = nested.wav_base64 ?? nested.audio_base64 ?? nested.base64; + if (typeof nestedDirect === "string" && nestedDirect.length > 0) return nestedDirect; + } + + if (obj.output !== undefined) queue.push(obj.output); + if (obj.delayOutput !== undefined) queue.push(obj.delayOutput); + if (obj.data !== undefined) queue.push(obj.data); + if (obj.result !== undefined) queue.push(obj.result); + if (obj.response !== undefined) queue.push(obj.response); + } + + return null; +} + +function summarizePayloadShape(value: unknown): string { + if (!value || typeof value !== "object") return "no object payload"; + const top = value as Record; + const keys = Object.keys(top).slice(0, 12); + const details: string[] = [`keys=${keys.join(",") || "none"}`]; + for (const k of ["status", "id", "executionTime", "delayTime"]) { + if (k in top) details.push(`${k}=${String(top[k])}`); + } + return details.join("; "); +} + +function asNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const n = Number(value); + if (Number.isFinite(n)) return n; + } + return null; +} + +export async function POST(req: NextRequest) { + const { + RUNPOD_API_KEY, + RUNPOD_VOICE_ENDPOINT_ID, + RUNPOD_ENDPOINT_ID, + R2_ACCOUNT_ID, + R2_ACCESS_KEY_ID, + R2_SECRET_ACCESS_KEY, + R2_BUCKET_NAME, + } = env; + const endpointId = RUNPOD_VOICE_ENDPOINT_ID ?? RUNPOD_ENDPOINT_ID; + + if (!RUNPOD_API_KEY || !endpointId) { + return NextResponse.json({ status: "FAILED", error: "RUNPOD_API_KEY and RUNPOD_VOICE_ENDPOINT_ID (or RUNPOD_ENDPOINT_ID) must be set" }, { status: 503 }); + } + + let input: z.infer; + try { + const body = await req.json() as unknown; + input = RequestSchema.parse(body); + } catch (err) { + return NextResponse.json({ status: "FAILED", error: err instanceof Error ? err.message : "Invalid request" }, { status: 400 }); + } + + try { + const statusRes = await fetch( + `https://api.runpod.ai/v2/${endpointId}/status/${input.runpodJobId}`, + { headers: { Authorization: `Bearer ${RUNPOD_API_KEY}` } } + ); + + if (!statusRes.ok) { + const body = await statusRes.text(); + return NextResponse.json({ status: "FAILED", error: `RunPod status failed (${statusRes.status}): ${body.slice(0, 400)}` }); + } + + const json = await statusRes.json() as { + status: string; + output?: unknown; + delayOutput?: unknown; + error?: string; + }; + + if (json.status === "IN_QUEUE" || json.status === "IN_PROGRESS") { + return NextResponse.json({ status: json.status }); + } + + if (json.status === "FAILED") { + return NextResponse.json({ status: "FAILED", error: json.error ?? "RunPod job failed" }); + } + + if (json.status !== "COMPLETED") { + return NextResponse.json({ status: json.status }); + } + + // COMPLETED — upload WAV to R2 + const output = getOutputObject(json.output ?? json.delayOutput ?? json); + if (!output) { + return NextResponse.json({ + status: "IN_PROGRESS", + note: `RunPod marked COMPLETED but output is not yet available (${summarizePayloadShape(json)})`, + }); + } + if (typeof output.error === "string" && output.error) { + return NextResponse.json({ status: "FAILED", error: output.error }); + } + + if (!R2_ACCOUNT_ID || !R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY || !R2_BUCKET_NAME) { + return NextResponse.json({ status: "FAILED", error: "R2 storage not configured" }, { status: 503 }); + } + + const wavB64 = extractAudioBase64(output); + if (!wavB64) { + return NextResponse.json({ + status: "IN_PROGRESS", + note: `RunPod output pending audio payload (${summarizePayloadShape(output)})`, + }); + } + const durationSec = asNumber(output.duration_sec ?? output.duration) ?? 0; + + const s3 = makeS3(); + const key = `voices/${Date.now()}-sample.wav`; + await s3.send(new PutObjectCommand({ + Bucket: R2_BUCKET_NAME, + Key: key, + Body: Buffer.from(wavB64, "base64"), + ContentType: "audio/wav", + })); + + const voiceId = toR2PublicUrlOrKey(key); + + return NextResponse.json({ status: "COMPLETED", voiceId, durationSec }); + } catch (err) { + const message = err instanceof Error ? err.message : "Finalize failed"; + return NextResponse.json({ status: "FAILED", error: message }, { status: 500 }); + } +} diff --git a/app/api/voice/clone/route.ts b/app/api/voice/clone/route.ts new file mode 100644 index 0000000..0dc8ef4 --- /dev/null +++ b/app/api/voice/clone/route.ts @@ -0,0 +1,85 @@ +/** + * POST /api/voice/clone + * + * Submits a voice clone job to RunPod and returns immediately with the jobId. + * The client polls /api/voice/clone/finalize to check status and get the voiceId. + * + * Body: { sampleUrl: string, ext?: string } + * Response: { runpodJobId: string } + */ + +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { env } from "@/lib/env"; +import { resolveR2ObjectUrl } from "@/lib/r2-storage"; + +export const runtime = "nodejs"; +export const maxDuration = 15; + +const RequestSchema = z.object({ + sampleUrl: z.string().min(1), + ext: z.string().optional().default("wav"), +}); + +export async function POST(req: NextRequest) { + const { RUNPOD_API_KEY, RUNPOD_VOICE_ENDPOINT_ID, RUNPOD_ENDPOINT_ID } = env; + const endpointId = RUNPOD_VOICE_ENDPOINT_ID ?? RUNPOD_ENDPOINT_ID; + + if (!RUNPOD_API_KEY || !endpointId) { + return NextResponse.json({ error: "RUNPOD_API_KEY and RUNPOD_VOICE_ENDPOINT_ID (or RUNPOD_ENDPOINT_ID) must be set" }, { status: 503 }); + } + + let input: z.infer; + try { + const body = await req.json() as unknown; + input = RequestSchema.parse(body); + } catch (err) { + return NextResponse.json({ error: err instanceof Error ? err.message : "Invalid request" }, { status: 400 }); + } + + try { + const sampleUrl = await resolveR2ObjectUrl(input.sampleUrl); + const safeExt = (input.ext ?? "wav").toLowerCase().replace(/[^a-z0-9]/g, "") || "wav"; + + const sampleRes = await fetch(sampleUrl); + if (!sampleRes.ok) { + const body = await sampleRes.text(); + throw new Error(`Sample fetch failed (${sampleRes.status}): ${body.slice(0, 300)}`); + } + const sampleBuffer = Buffer.from(await sampleRes.arrayBuffer()); + + // RunPod request bodies are capped at 10 MiB. Base64 adds ~33% overhead, + // so keep raw payloads <= 7 MiB on the inline path. + const MAX_INLINE_BYTES = 7 * 1024 * 1024; + const shouldInlineBase64 = sampleBuffer.byteLength <= MAX_INLINE_BYTES; + + const runpodInput = shouldInlineBase64 + ? { + action: "clone", + audio_base64: sampleBuffer.toString("base64"), + ext: safeExt, + } + : { + action: "clone", + // Prefer the original uploaded URL when available to avoid very long + // signed query strings in workers that infer extension from URL text. + audio_url: /^https?:\/\//i.test(input.sampleUrl) ? input.sampleUrl : sampleUrl, + ext: safeExt, + }; + + const submitRes = await fetch(`https://api.runpod.ai/v2/${endpointId}/run`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${RUNPOD_API_KEY}` }, + body: JSON.stringify({ input: runpodInput }), + }); + if (!submitRes.ok) { + const body = await submitRes.text(); + throw new Error(`RunPod submit failed (${submitRes.status}): ${body.slice(0, 400)}`); + } + const { id: runpodJobId } = await submitRes.json() as { id: string }; + return NextResponse.json({ runpodJobId }); + } catch (err) { + const message = err instanceof Error ? err.message : "Submit failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/app/api/voice/library-audio/route.ts b/app/api/voice/library-audio/route.ts new file mode 100644 index 0000000..9542e35 --- /dev/null +++ b/app/api/voice/library-audio/route.ts @@ -0,0 +1,79 @@ +import { NextRequest, NextResponse } from "next/server"; +import { S3Client, HeadObjectCommand } from "@aws-sdk/client-s3"; +import { z } from "zod"; +import { env } from "@/lib/env"; +import { toR2PublicUrlOrKey } from "@/lib/r2-storage"; + +export const runtime = "nodejs"; +export const maxDuration = 15; + +const QuerySchema = z.object({ + chapterId: z.string().min(1).max(120), + slug: z.string().min(1).max(160).optional(), + jobId: z.string().min(1).max(160).optional(), +}); + +function makeS3(): S3Client { + return new S3Client({ + region: "auto", + endpoint: `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`, + credentials: { + accessKeyId: env.R2_ACCESS_KEY_ID!, + secretAccessKey: env.R2_SECRET_ACCESS_KEY!, + }, + }); +} + +function sanitizePart(value: string): string { + return value.replace(/[^a-z0-9-]/gi, "-").toLowerCase(); +} + +export async function GET(req: NextRequest) { + const { + R2_ACCOUNT_ID, + R2_ACCESS_KEY_ID, + R2_SECRET_ACCESS_KEY, + R2_BUCKET_NAME, + } = env; + + if (!R2_ACCOUNT_ID || !R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY || !R2_BUCKET_NAME) { + return NextResponse.json({ audioUrl: null }, { status: 200 }); + } + + let input: z.infer; + try { + input = QuerySchema.parse({ + chapterId: req.nextUrl.searchParams.get("chapterId") ?? "", + slug: req.nextUrl.searchParams.get("slug") ?? undefined, + jobId: req.nextUrl.searchParams.get("jobId") ?? undefined, + }); + } catch (err) { + return NextResponse.json({ error: err instanceof Error ? err.message : "Invalid query" }, { status: 400 }); + } + + const chapterId = sanitizePart(input.chapterId); + const candidates = [input.slug, input.jobId] + .filter((value): value is string => Boolean(value)) + .map((value) => sanitizePart(value)); + + if (candidates.length === 0) { + return NextResponse.json({ audioUrl: null }, { status: 200 }); + } + + const s3 = makeS3(); + + for (const safeSlug of candidates) { + const key = `audio/books/${safeSlug}/${chapterId}.wav`; + try { + await s3.send(new HeadObjectCommand({ + Bucket: R2_BUCKET_NAME, + Key: key, + })); + return NextResponse.json({ audioUrl: toR2PublicUrlOrKey(key) }, { status: 200 }); + } catch { + // Try next candidate. + } + } + + return NextResponse.json({ audioUrl: null }, { status: 200 }); +} diff --git a/app/api/voice/narrate/finalize/route.ts b/app/api/voice/narrate/finalize/route.ts new file mode 100644 index 0000000..f204dfa --- /dev/null +++ b/app/api/voice/narrate/finalize/route.ts @@ -0,0 +1,295 @@ +/** + * POST /api/voice/narrate/finalize + * + * Called repeatedly by the browser to check on a RunPod synthesis job. + * When the job completes, uploads the WAV to R2 and returns the audioUrl. + * + * Body: { runpodJobId: string, chapterId: string, slug: string } + * Returns: + * { status: "IN_QUEUE" | "IN_PROGRESS" } — still running + * { status: "COMPLETED", audioUrl: string, durationSec } — done + * { status: "FAILED", error: string } — fatal + */ + +import { NextRequest, NextResponse } from "next/server"; +import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; +import { z } from "zod"; +import { env } from "@/lib/env"; +import { toR2PublicUrlOrKey } from "@/lib/r2-storage"; + +export const runtime = "nodejs"; +export const maxDuration = 15; + +const RequestSchema = z.object({ + runpodJobId: z.string().min(1), + chapterId: z.string().min(1).max(100), + slug: z.string().min(1).max(100), +}); + +function makeS3(): S3Client { + return new S3Client({ + region: "auto", + endpoint: `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`, + credentials: { + accessKeyId: env.R2_ACCESS_KEY_ID!, + secretAccessKey: env.R2_SECRET_ACCESS_KEY!, + }, + }); +} + +function getOutputObject(value: unknown): Record | null { + const queue: unknown[] = [value]; + let fallback: Record | null = null; + + while (queue.length > 0) { + const current = queue.shift(); + + if (typeof current === "string") { + const trimmed = current.trim(); + if (trimmed.length > 1000 && /^[A-Za-z0-9+/=\r\n]+$/.test(trimmed)) { + return { wav_base64: trimmed }; + } + try { + queue.push(JSON.parse(trimmed)); + } catch { + // Not JSON, continue searching. + } + continue; + } + + if (Array.isArray(current)) { + queue.push(...current); + continue; + } + + if (!current || typeof current !== "object") continue; + + const obj = current as Record; + if (!fallback) fallback = obj; + + const hasAudioPayload = + typeof obj.wav_base64 === "string" || + typeof obj.audio_base64 === "string" || + typeof obj.error === "string"; + if (hasAudioPayload) return obj; + + if (obj.output !== undefined) queue.push(obj.output); + if (obj.delayOutput !== undefined) queue.push(obj.delayOutput); + if (obj.data !== undefined) queue.push(obj.data); + } + + return fallback; +} + +function extractAudioBase64(value: unknown): string | null { + const queue: unknown[] = [value]; + + while (queue.length > 0) { + const current = queue.shift(); + if (!current) continue; + + if (typeof current === "string") { + const trimmed = current.trim(); + if (trimmed.length > 1000 && /^[A-Za-z0-9+/=\r\n]+$/.test(trimmed)) { + return trimmed; + } + continue; + } + + if (Array.isArray(current)) { + queue.push(...current); + continue; + } + + if (typeof current !== "object") continue; + const obj = current as Record; + + const direct = obj.wav_base64 ?? obj.audio_base64; + if (typeof direct === "string" && direct.length > 0) return direct; + + const nestedAudio = obj.audio; + if (nestedAudio && typeof nestedAudio === "object") { + const nested = nestedAudio as Record; + const nestedDirect = nested.wav_base64 ?? nested.audio_base64 ?? nested.base64; + if (typeof nestedDirect === "string" && nestedDirect.length > 0) return nestedDirect; + } + + if (obj.output !== undefined) queue.push(obj.output); + if (obj.delayOutput !== undefined) queue.push(obj.delayOutput); + if (obj.data !== undefined) queue.push(obj.data); + if (obj.result !== undefined) queue.push(obj.result); + if (obj.response !== undefined) queue.push(obj.response); + } + + return null; +} + +function extractAudioUrl(value: unknown): string | null { + const queue: unknown[] = [value]; + + while (queue.length > 0) { + const current = queue.shift(); + if (!current) continue; + + if (typeof current === "string") { + const trimmed = current.trim(); + if (/^https?:\/\//i.test(trimmed) || trimmed.startsWith("audio/books/")) { + return trimmed; + } + continue; + } + + if (Array.isArray(current)) { + queue.push(...current); + continue; + } + + if (typeof current !== "object") continue; + const obj = current as Record; + + const direct = obj.audio_url ?? obj.audioUrl ?? obj.url; + if (typeof direct === "string" && direct.length > 0) return direct; + + if (obj.output !== undefined) queue.push(obj.output); + if (obj.delayOutput !== undefined) queue.push(obj.delayOutput); + if (obj.data !== undefined) queue.push(obj.data); + if (obj.result !== undefined) queue.push(obj.result); + if (obj.response !== undefined) queue.push(obj.response); + } + + return null; +} + +function summarizePayloadShape(value: unknown): string { + if (!value || typeof value !== "object") return "no object payload"; + const top = value as Record; + const keys = Object.keys(top).slice(0, 12); + const details: string[] = [`keys=${keys.join(",") || "none"}`]; + for (const k of ["status", "id", "executionTime", "delayTime"]) { + if (k in top) details.push(`${k}=${String(top[k])}`); + } + return details.join("; "); +} + +function asNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const n = Number(value); + if (Number.isFinite(n)) return n; + } + return null; +} + +export async function POST(req: NextRequest) { + const { + RUNPOD_API_KEY, + RUNPOD_VOICE_ENDPOINT_ID, + RUNPOD_ENDPOINT_ID, + R2_ACCOUNT_ID, + R2_ACCESS_KEY_ID, + R2_SECRET_ACCESS_KEY, + R2_BUCKET_NAME, + } = env; + const endpointId = RUNPOD_VOICE_ENDPOINT_ID ?? RUNPOD_ENDPOINT_ID; + + if (!RUNPOD_API_KEY || !endpointId) { + return NextResponse.json({ status: "FAILED", error: "RUNPOD_API_KEY and RUNPOD_VOICE_ENDPOINT_ID (or RUNPOD_ENDPOINT_ID) must be set" }, { status: 503 }); + } + + let input: z.infer; + try { + const body = await req.json() as unknown; + input = RequestSchema.parse(body); + } catch (err) { + return NextResponse.json({ status: "FAILED", error: err instanceof Error ? err.message : "Invalid request" }, { status: 400 }); + } + + try { + const statusRes = await fetch( + `https://api.runpod.ai/v2/${endpointId}/status/${input.runpodJobId}`, + { headers: { Authorization: `Bearer ${RUNPOD_API_KEY}` } } + ); + + if (!statusRes.ok) { + const body = await statusRes.text(); + return NextResponse.json({ status: "FAILED", error: `RunPod status failed (${statusRes.status}): ${body.slice(0, 400)}` }); + } + + const json = await statusRes.json() as { + status: string; + output?: unknown; + delayOutput?: unknown; + error?: string; + }; + + if (json.status === "IN_QUEUE" || json.status === "IN_PROGRESS") { + return NextResponse.json({ status: json.status }); + } + + if (json.status === "FAILED") { + const rawError = json.error ?? "RunPod synthesis failed"; + const error = /EOF when reading a line/i.test(rawError) + ? "RunPod worker is prompting for interactive input (EOF). Deploy the latest voice worker image so XTTS loads non-interactively." + : rawError; + return NextResponse.json({ status: "FAILED", error }); + } + + if (json.status !== "COMPLETED") { + return NextResponse.json({ status: json.status }); + } + + // COMPLETED — upload WAV to R2 + const output = getOutputObject(json.output ?? json.delayOutput ?? json); + if (!output) { + return NextResponse.json({ + status: "IN_PROGRESS", + note: `RunPod marked COMPLETED but output is not yet available (${summarizePayloadShape(json)})`, + }); + } + if (typeof output.error === "string" && output.error) { + return NextResponse.json({ status: "FAILED", error: output.error }); + } + + const outputAudioUrl = extractAudioUrl(output); + if (outputAudioUrl) { + const durationSec = asNumber(output.duration_sec ?? output.duration) ?? 0; + return NextResponse.json({ + status: "COMPLETED", + audioUrl: outputAudioUrl.startsWith("audio/books/") ? toR2PublicUrlOrKey(outputAudioUrl) : outputAudioUrl, + durationSec, + }); + } + + if (!R2_ACCOUNT_ID || !R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY || !R2_BUCKET_NAME) { + return NextResponse.json({ status: "FAILED", error: "R2 storage not configured" }, { status: 503 }); + } + + const wavB64 = extractAudioBase64(output); + if (!wavB64) { + return NextResponse.json({ + status: "IN_PROGRESS", + note: `RunPod output pending audio payload (${summarizePayloadShape(output)})`, + }); + } + const durationSec = asNumber(output.duration_sec ?? output.duration) ?? 0; + + const s3 = makeS3(); + const safeSlug = input.slug.replace(/[^a-z0-9-]/gi, "-").toLowerCase(); + const safeChapter = input.chapterId.replace(/[^a-z0-9-]/gi, "-").toLowerCase(); + const key = `audio/books/${safeSlug}/${safeChapter}.wav`; + + await s3.send(new PutObjectCommand({ + Bucket: R2_BUCKET_NAME, + Key: key, + Body: Buffer.from(wavB64, "base64"), + ContentType: "audio/wav", + })); + + const audioUrl = toR2PublicUrlOrKey(key); + + return NextResponse.json({ status: "COMPLETED", audioUrl, durationSec }); + } catch (err) { + const message = err instanceof Error ? err.message : "Finalize failed"; + return NextResponse.json({ status: "FAILED", error: message }, { status: 500 }); + } +} diff --git a/app/api/voice/narrate/route.ts b/app/api/voice/narrate/route.ts new file mode 100644 index 0000000..7e5eda6 --- /dev/null +++ b/app/api/voice/narrate/route.ts @@ -0,0 +1,83 @@ +/** + * POST /api/voice/narrate + * + * Submits a chapter synthesis job to RunPod and returns immediately with the jobId. + * The client polls /api/voice/narrate/finalize to check status and get the audioUrl. + * + * Body: { text, voiceId, chapterId, slug, language?, speed? } + * Response: { runpodJobId: string } + */ + +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { env } from "@/lib/env"; +import { resolveR2ObjectUrl } from "@/lib/r2-storage"; + +export const runtime = "nodejs"; +export const maxDuration = 15; + +const RequestSchema = z.object({ + text: z.string().min(1).max(50_000), + voiceId: z.string().min(1, "voiceId is required"), + chapterId: z.string().min(1).max(100), + slug: z.string().min(1).max(100), + language: z.string().default("en"), + speed: z.number().min(0.5).max(2.0).default(1.0), +}); + +export async function POST(req: NextRequest) { + const { RUNPOD_API_KEY, RUNPOD_VOICE_ENDPOINT_ID, RUNPOD_ENDPOINT_ID } = env; + const endpointId = RUNPOD_VOICE_ENDPOINT_ID ?? RUNPOD_ENDPOINT_ID; + + if (!RUNPOD_API_KEY || !endpointId) { + return NextResponse.json({ error: "RUNPOD_API_KEY and RUNPOD_VOICE_ENDPOINT_ID (or RUNPOD_ENDPOINT_ID) must be set" }, { status: 503 }); + } + + let input: z.infer; + try { + const body = await req.json() as unknown; + input = RequestSchema.parse(body); + } catch (err) { + return NextResponse.json({ error: err instanceof Error ? err.message : "Invalid request" }, { status: 400 }); + } + + // Strip markdown so the TTS model reads clean prose, not formatting symbols + const cleanText = input.text + .replace(/^#{1,6}\s+/gm, "") + .replace(/\*\*(.*?)\*\*/g, "$1") + .replace(/\*(.*?)\*/g, "$1") + .replace(/^>\s+/gm, "") + .replace(/^[-*+]\s+/gm, "") + .replace(/\n{3,}/g, "\n\n") + .trim(); + + try { + const speakerWavUrl = await resolveR2ObjectUrl(input.voiceId); + + const submitRes = await fetch(`https://api.runpod.ai/v2/${endpointId}/run`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${RUNPOD_API_KEY}` }, + body: JSON.stringify({ + input: { + action: "synthesize", + text: cleanText, + speaker_wav_url: speakerWavUrl, + language: input.language, + speed: input.speed, + // Pass chapter metadata so finalize can build the R2 key + chapter_id: input.chapterId, + slug: input.slug, + }, + }), + }); + if (!submitRes.ok) { + const body = await submitRes.text(); + throw new Error(`RunPod submit failed (${submitRes.status}): ${body.slice(0, 400)}`); + } + const { id: runpodJobId } = await submitRes.json() as { id: string }; + return NextResponse.json({ runpodJobId }); + } catch (err) { + const message = err instanceof Error ? err.message : "Submit failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/app/components/AssistantPanel.tsx b/app/components/AssistantPanel.tsx new file mode 100644 index 0000000..6b3c0e9 --- /dev/null +++ b/app/components/AssistantPanel.tsx @@ -0,0 +1,629 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { AcademyPackageSchema } from "@/lib/schemas/academy"; +import { SiteConfigSchema } from "@/lib/schemas/site-config"; +import { EbookManifestSchema } from "@/lib/schemas/ebook"; +import type { EbookPipelineSnapshot } from "@/app/components/EbookPipeline"; +import type { AcademyPackage } from "@/lib/schemas/academy"; +import type { SiteConfig } from "@/lib/schemas/site-config"; +import type { EbookManifest } from "@/lib/schemas/ebook"; +import type { ChatMessage } from "@/lib/project-store"; + +type Message = ChatMessage; + +type AssistantPanelProps = { + isOpen: boolean; + onClose: () => void; + academy: AcademyPackage | null; + onUpdate: (academy: AcademyPackage, summary: string) => void; + siteConfig: SiteConfig; + onSiteUpdate: (config: SiteConfig, summary: string) => void; + /** Ebook manifest — when present, enables book production control */ + ebookManifest?: EbookManifest | null; + onEbookUpdate?: (manifest: EbookManifest, summary: string) => void; + ebookPipelineSnapshot?: EbookPipelineSnapshot | null; + /** When a project is loaded, pass its saved messages + a new loadKey to restore chat */ + loadedHistory?: Message[]; + loadKey?: string; + onChatChange?: (msgs: Message[]) => void; +}; + +const IDLE_HINT = "No content loaded yet. Run the pipeline first, then I can help you make changes."; + +// ── Audit report types (mirrored from /api/ebook/audit) ────────────────────── +type AuditConceptDuplicate = { + type: string; title: string; description: string; severity: "minor" | "major"; + locations: Array<{ location: string; excerpt: string }>; + recommendation: string; +}; +type AuditSimilarPair = { locationA: string; locationB: string; similarity: number }; +type AuditRepetition = { phrase: string; count: number; reason: string | null; alternatives: string[] }; +type AuditOverusedWord = { word: string; count: number; frequency: string; alternatives: string[] }; +type BookAuditReport = { + conceptDuplicates: AuditConceptDuplicate[]; + similarPairs: AuditSimilarPair[]; + repetitions: AuditRepetition[]; + overusedWords: AuditOverusedWord[]; + totalConceptDuplicates: number; + totalSimilarPairs: number; + totalRepetitionPhrases: number; + totalOverusedWords: number; +}; + +// ── Intent detectors ───────────────────────────────────────────────────────── +function isAuditIntent(text: string): boolean { + return /\b(audit|full[\s-]?audit|review\s+the\s+book|analyse|analyze|repetit|duplicat|overused\s+words?|similar\s+sections?|quality\s+check|book\s+report|what.{0,12}issues|flag\s+issues|check\s+(?:the\s+)?book|run\s+(?:a\s+)?(?:full\s+)?audit|what.{0,12}(wrong|problem|broken|needs.{0,8}fix)|find\s+(issues|problems|errors|duplicates?)|scripture.{0,12}repeat|same\s+(verse|scripture|passage).{0,16}twice|sounds?\s+redundant|too\s+repetitive|check\s+for\s+(duplicates?|repetition|issues|problems))\b/i.test(text); +} + +function isViewIntent(text: string): boolean { + return /\b(show\s+(?:me\s+)?(?:the\s+)?(?:book|chapters?|contents?|toc|table\s+of\s+contents?|overview|summary|outline|structure|sections?|layout)|view\s+(?:book|chapters?|contents?|structure|outline)|list\s+(?:chapters?|sections?|contents?)|how\s+many\s+(chapters?|sections?)|what.{0,10}(chapters?|sections?|in\s+the\s+book)|book\s+(outline|structure|layout|contents?)|what.{0,10}book\s+look|table\s+of\s+contents?)\b/i.test(text); +} + +// ── Client-side book table of contents ─────────────────────────────────────── +function buildBookToc(manifest: EbookManifest): string { + const lines: string[] = [ + `\u{1F4DA} "${manifest.bookTitle}"`, + ` by ${manifest.authorName}`, + ` ${manifest.chapters.length} chapters \u00b7 ${manifest.totalWordCount.toLocaleString()} words`, + "", + ]; + const fm = manifest.frontMatter; + const fmParts: Array<[string, string | null | undefined]> = [ + ["Preface", fm.preface], + ["Introduction", fm.introduction], + ]; + if (fmParts.some(([, t]) => (t ?? "").trim())) { + lines.push("FRONT MATTER"); + for (const [label, text] of fmParts) { + if ((text ?? "").trim()) lines.push(` ${label} (${text!.trim().split(/\s+/).length.toLocaleString()} words)`); + } + lines.push(""); + } + lines.push("CHAPTERS"); + for (const ch of manifest.chapters) { + lines.push(` Chapter ${ch.number}: ${ch.title} (${(ch.totalWordCount ?? 0).toLocaleString()} words)`); + for (const s of ch.sections) { + lines.push(` ${ch.number}.${s.sectionNumber} ${s.heading} [${(s.wordCount ?? 0).toLocaleString()} w]`); + } + } + const backParts: Array<[string, string | null | undefined]> = [ + ["Conclusion", fm.conclusion], + ["About the Author", fm.aboutAuthor], + ["Resources", fm.resourcesList], + ]; + if (backParts.some(([, t]) => (t ?? "").trim())) { + lines.push(""); + lines.push("BACK MATTER"); + for (const [label, text] of backParts) { + if ((text ?? "").trim()) lines.push(` ${label} (${text!.trim().split(/\s+/).length.toLocaleString()} words)`); + } + } + if (manifest.backMatter) { + const bm = manifest.backMatter; + lines.push(""); + lines.push("GENERATED BACK MATTER"); + if ((bm.glossary?.length ?? 0) > 0) lines.push(` Glossary — ${bm.glossary.length} terms`); + if ((bm.readingGroupGuide?.length ?? 0) > 0) lines.push(` Reading Group Guide — ${bm.readingGroupGuide.length} chapters`); + if ((bm.scriptureIndex?.length ?? 0) > 0) lines.push(` Scripture Index — ${bm.scriptureIndex.length} references`); + if ((bm.recommendedResources?.length ?? 0) > 0) lines.push(` Recommended Resources — ${bm.recommendedResources.length} items`); + } + return lines.join("\n"); +} + +// ── Audit report formatter ──────────────────────────────────────────────────── +function formatAuditReport(r: BookAuditReport): string { + const total = r.totalConceptDuplicates + r.totalSimilarPairs + r.totalRepetitionPhrases; + const lines: string[] = [ + `\u{1F4CA} BOOK AUDIT COMPLETE`, + ` ${total === 0 ? "No significant issues found." : [ + r.totalConceptDuplicates > 0 && `${r.totalConceptDuplicates} concept duplicate${r.totalConceptDuplicates !== 1 ? "s" : ""}`, + r.totalSimilarPairs > 0 && `${r.totalSimilarPairs} similar pair${r.totalSimilarPairs !== 1 ? "s" : ""}`, + r.totalRepetitionPhrases > 0 && `${r.totalRepetitionPhrases} repeated phrase${r.totalRepetitionPhrases !== 1 ? "s" : ""}`, + ].filter(Boolean).join(", ") + " flagged."}`, + "", + ]; + if (r.conceptDuplicates?.length > 0) { + lines.push(`\u{1F504} CONCEPT DUPLICATES (${r.conceptDuplicates.length})`); + lines.push("─".repeat(46)); + for (const d of r.conceptDuplicates) { + lines.push(`\u25B8 [${d.severity.toUpperCase()}] ${d.title}`); + for (const loc of d.locations) lines.push(` \u2192 ${loc.location}`); + lines.push(` Issue: ${d.description}`); + lines.push(` Fix: ${d.recommendation}`); + lines.push(""); + } + } + if (r.similarPairs?.length > 0) { + lines.push(`\u{1F4CC} STRUCTURALLY SIMILAR SECTIONS (${r.similarPairs.length})`); + lines.push("─".repeat(46)); + for (const p of r.similarPairs.slice(0, 8)) { + lines.push(` ${p.locationA} \u2194 ${p.locationB} (${Math.round(p.similarity * 100)}% similar)`); + } + lines.push(""); + } + if (r.repetitions?.length > 0) { + lines.push(`\u{1F501} REPEATED PHRASES (top ${Math.min(r.repetitions.length, 10)})`); + lines.push("─".repeat(46)); + for (const rep of r.repetitions.slice(0, 10)) { + lines.push(` "${rep.phrase}" \u00d7${rep.count}`); + if (rep.reason) lines.push(` \u2192 ${rep.reason}`); + if (rep.alternatives?.length) lines.push(` Alt: ${rep.alternatives.join(", ")}`); + } + lines.push(""); + } + if (r.overusedWords?.length > 0) { + lines.push(`\u{1F4DD} OVERUSED WORDS (${r.overusedWords.length})`); + lines.push("─".repeat(46)); + for (const w of r.overusedWords.slice(0, 8)) { + const alts = w.alternatives?.length ? ` \u2192 ${w.alternatives.join(", ")}` : ""; + lines.push(` "${w.word}" ${w.count}\u00d7 (${w.frequency})${alts}`); + } + lines.push(""); + } + if (total === 0) { + lines.push("\u2705 The book passed the audit with no significant flags."); + } else { + lines.push(`\u{1F4A1} Fix issues by asking me: "Rewrite section 2.1" or "Fix repetition in chapter 3".`); + } + return lines.join("\n"); +} + +export function AssistantPanel({ isOpen, onClose, academy, onUpdate, siteConfig, onSiteUpdate, ebookManifest, onEbookUpdate, ebookPipelineSnapshot, loadedHistory, loadKey, onChatChange }: AssistantPanelProps) { + const [messages, setMessages] = useState([ + { role: "system", content: IDLE_HINT }, + ]); + const [input, setInput] = useState(""); + const [loading, setLoading] = useState(false); + const [showChips, setShowChips] = useState(false); + const scrollRef = useRef(null); + const inputRef = useRef(null); + + // Restore chat history when a project is loaded + useEffect(() => { + if (loadKey && loadedHistory && loadedHistory.length > 0) { + setMessages(loadedHistory); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [loadKey]); + + // Update greeting when academy or ebook first loads (only if no project history was restored) + useEffect(() => { + if (loadKey) return; // project load handles its own history + if (ebookManifest) { + const pipelineStatus = ebookPipelineSnapshot + ? `Pipeline: ${ebookPipelineSnapshot.stage} | Review ready: ${ebookPipelineSnapshot.reviewReady ? "yes" : "no"} | Quality: ${ebookPipelineSnapshot.qualityReport ? `${ebookPipelineSnapshot.qualityReport.score}/100` : "pending"}` + : "Pipeline: connected"; + setMessages([{ + role: "system", + content: `Book loaded: "${ebookManifest.bookTitle}" by ${ebookManifest.authorName} — ${ebookManifest.chapters.length} chapters, ${ebookManifest.totalWordCount.toLocaleString()} words.\n${pipelineStatus}\n\nYou can ask me to change the title, rename chapters, edit section headings, update takeaways, revise the preface, rewrite chapter sections, and make targeted book-wide edits.`, + }]); + } else if (academy) { + const lessonCount = academy.curriculum.flatMap((m) => m.lessons).length; + setMessages([{ + role: "system", + content: `"${academy.academyName}" loaded — ${academy.curriculum.length} modules, ${lessonCount} lessons. Tell me what to change.`, + }]); + } else { + setMessages([{ role: "system", content: IDLE_HINT }]); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [!!academy, !!ebookManifest]); + + // Notify parent whenever messages change so it can persist them + useEffect(() => { + onChatChange?.(messages); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [messages]); + + // Auto-scroll to latest message + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [messages, loading]); + + // Focus input when panel opens + useEffect(() => { + if (isOpen) inputRef.current?.focus(); + }, [isOpen]); + + async function send() { + const text = input.trim(); + if (!text || loading) return; + + const hasContent = academy || ebookManifest; + if (!hasContent) { + setMessages((prev) => [ + ...prev, + { role: "user", content: text }, + { role: "assistant", content: "Run the pipeline first so I have content to edit." }, + ]); + setInput(""); + return; + } + + // ── View book contents (client-side, no loading state) ────────────────── + if (ebookManifest && isViewIntent(text)) { + setMessages((prev) => [...prev, + { role: "user", content: text }, + { role: "assistant", content: buildBookToc(ebookManifest) }, + ]); + setInput(""); + return; + } + + setMessages((prev) => [...prev, { role: "user", content: text }]); + setInput(""); + setLoading(true); + + try { + // ── Book audit ───────────────────────────────────────────────────────── + if (ebookManifest && isAuditIntent(text)) { + const res = await fetch("/api/ebook/audit", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ manifest: ebookManifest }), + }); + const json = await res.json() as BookAuditReport & { error?: string }; + if (!res.ok || json.error) throw new Error(json.error ?? `HTTP ${res.status}`); + setMessages((prev) => [...prev, { role: "assistant", content: formatAuditReport(json) }]); + return; + } + + // ── Route to ebook assistant when a book manifest is loaded ──────────── + if (ebookManifest && onEbookUpdate) { + // Send the last 14 turns (7 exchanges) of user/assistant chat so the AI + // understands follow-up instructions like "make it longer" or "fix that". + const historyForApi = messages + .filter((m) => m.role === "user" || m.role === "assistant") + .slice(-14) + .map((m) => ({ role: m.role as "user" | "assistant", content: m.content })); + const res = await fetch("/api/ebook/assistant", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + manifest: ebookManifest, + instruction: text, + history: historyForApi, + pipeline: ebookPipelineSnapshot ?? undefined, + manifestVersion: (ebookManifest as Record).__version as string | undefined, + }), + }); + const json = await res.json() as { + manifest?: unknown; + summary?: string; + noChanges?: boolean; + error?: string; + code?: string; + needsClarification?: boolean; + clarificationNeeded?: string; + confidence?: "high" | "medium" | "low"; + manifestVersion?: string; + libraryPatch?: { slug: string; title?: string; subtitle?: string; authorName?: string; synopsis?: string; coverAccent?: string }; + }; + if (res.status === 409) { + setMessages((prev) => [...prev, { role: "assistant", content: `⚠️ **Edit conflict** — the book was changed in another tab. Please reload the page and try again.` }]); + return; + } + if (!res.ok || json.error) throw new Error(json.error ?? `HTTP ${res.status}`); + if (json.needsClarification && json.clarificationNeeded) { + setMessages((prev) => [...prev, { role: "assistant", content: `❓ I need a bit more detail before I can make this change:\n\n**${json.clarificationNeeded}**` }]); + return; + } + if (json.noChanges) { + setMessages((prev) => [...prev, { role: "assistant", content: `${json.summary ?? ""} + +⚠️ No manuscript changes were applied. Please rephrase your instruction more specifically — e.g. name the exact chapter or section number you want changed.` }]); + return; + } + // Apply library catalog patch if the assistant updated published metadata + if (json.libraryPatch) { + await fetch("/api/ebook/publish", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(json.libraryPatch), + }).catch(() => { /* best-effort — don't block the manifest update */ }); + } + const parsed = EbookManifestSchema.safeParse(json.manifest); + if (!parsed.success) throw new Error("Invalid ebook manifest returned from assistant"); + // Stash the version token on the manifest object so the next call can send it back + const manifestWithVersion = json.manifestVersion + ? { ...parsed.data, __version: json.manifestVersion } + : parsed.data; + onEbookUpdate(manifestWithVersion as typeof parsed.data, json.summary ?? "Book updated."); + const confidenceNote = json.confidence === "medium" ? " *(medium confidence — review before saving)*" : ""; + setMessages((prev) => [...prev, { role: "assistant", content: (json.summary ?? "Done.") + confidenceNote }]); + return; + } + + // ── Academy assistant (existing path) ────────────────────────────────── + if (!academy) { + setMessages((prev) => [...prev, { role: "assistant", content: "No academy loaded." }]); + return; + } + + const res = await fetch("/api/assistant", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + academy, + instruction: text, + siteConfig, + history: messages.slice(-20).map((m) => ({ role: m.role, content: m.content })), + academyVersion: (academy as Record).__version as string | undefined, + }), + }); + + if (!res.ok) throw new Error(`HTTP ${res.status}`); + + // Read SSE stream — buffer all chunks then find the data: line. + // Parsing chunk-by-chunk causes "Unterminated string" when JSON spans chunks. + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + } + let json: { academy?: unknown; siteConfig?: unknown; summary?: string; error?: string; code?: string; needsClarification?: boolean; clarificationNeeded?: string; confidence?: "high" | "medium" | "low"; academyVersion?: string } | null = null; + for (const line of buffer.split("\n")) { + if (line.startsWith("data: ")) { + json = JSON.parse(line.slice(6)) as typeof json; + break; + } + } + + if (!json) throw new Error("No response from assistant"); + if (json.error && json.code === "VERSION_CONFLICT") { + setMessages((prev) => [...prev, { role: "assistant", content: `⚠️ **Edit conflict** — the academy was changed in another tab. Please reload and try again.` }]); + return; + } + if (json.error) throw new Error(json.error); + if (json.needsClarification && json.clarificationNeeded) { + setMessages((prev) => [...prev, { role: "assistant", content: `❓ I need a bit more detail:\n\n**${json.clarificationNeeded}**` }]); + return; + } + + let changed = false; + + if (json.academy !== undefined) { + const parsed = AcademyPackageSchema.safeParse(json.academy); + if (!parsed.success) throw new Error("Invalid academy returned from assistant"); + onUpdate(parsed.data, json.summary ?? "Changes applied."); + changed = true; + } + + if (json.siteConfig !== undefined) { + const parsed = SiteConfigSchema.safeParse(json.siteConfig); + if (!parsed.success) throw new Error("Invalid site config returned from assistant"); + onSiteUpdate(parsed.data, json.summary ?? "Site updated."); + changed = true; + } + + if (!changed) throw new Error("Assistant returned no changes"); + setMessages((prev) => [ + ...prev, + { role: "assistant", content: json!.summary ?? "Done." }, + ]); + } catch (err) { + const msg = err instanceof Error ? err.message : "Something went wrong"; + setMessages((prev) => [...prev, { role: "assistant", content: `Error: ${msg}` }]); + } finally { + setLoading(false); + } + } + + return ( + <> + {/* Backdrop */} + {isOpen && ( +