Переглянути джерело

feat(llm): 模型三级解析 + github 结构缺失重试

- LLM 模型解析收敛到 core runDocument 一处:--llm-model > LLM_MODEL env > config.llm.model,未配置显式报错(不再静默兜底硬编码模型)
- CLI/web 客户端只透传,默认模型改 model-stable
- 文字模块:github-trending/github-weekly 输出零 github 结构场景时(会导致 cover-only 视频)用结构 nudge 重试一次;结构检查前置于校验
- applyLengthLimits 确定性补齐缺失场景 id;isMissingGithubScenes 抽为独立导出(修复原先被删标记导致的死重试路径)
- 新增 packages/text/test 单测(node:test),7 用例全绿
- 文档/.env.example 同步 LLM_MODEL 说明

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lkatzey 1 місяць тому
батько
коміт
1b8c2fa570

+ 6 - 2
.env.example

@@ -1,6 +1,7 @@
-# Environment variables — secrets and endpoint URLs only.
+# Environment variables — secrets, endpoint URLs, and deployment overrides.
 # Copy this file to .env and fill in values.
-# Model, voice, and other business settings go in config/default.yaml or CLI flags.
+# Business defaults remain in config/default.yaml; deployment-time overrides can
+# be supplied here without rebuilding or replacing the config file.
 #
 # In Docker, docker-compose.yml reads this file and substitutes the values below
 # into the container's environment — you do NOT need a .env inside the image.
@@ -8,6 +9,9 @@
 # --- LLM ---
 OPENAI_API_KEY=
 OPENAI_BASE_URL=
+# Override config/default.yaml `llm.model` for container/Kubernetes deployment.
+# CLI --llm-model takes precedence over this variable.
+# LLM_MODEL=
 
 # --- TTS: OpenAI-compatible (default provider) ---
 OPENAI_TTS_API_KEY=

+ 2 - 1
apps/cli/src/commands/init.ts

@@ -5,7 +5,8 @@ import { resolve } from "node:path";
 const DEFAULT_CONFIG = `# Pipeline default configuration
 llm:
   baseURL: "https://api.openai.com/v1"
-  model: "gpt-4o"
+  # Or override per-deployment with the LLM_MODEL environment variable.
+  model: "model-stable"
 
 tts:
   provider: "openai-tts"

+ 13 - 2
apps/cli/src/commands/render.ts

@@ -119,7 +119,9 @@ export const renderCommand = new Command("render")
       llm: {
         baseURL: opts.llmBaseUrl || config?.llm?.baseURL,
         apiKey: opts.llmApiKey || config?.llm?.apiKey,
-        model: opts.llmModel || config?.llm?.model || "gpt-4o",
+        // Model resolution (flag > LLM_MODEL env > config) lives in core's
+        // runDocument — clients only forward the pieces.
+        model: config?.llm?.model,
       },
       tts: {
         provider: providerName,
@@ -141,7 +143,15 @@ export const renderCommand = new Command("render")
     const stages = ["text", "audio", "render"];
 
     console.log(`\nPipeline: generating ${template} video for ${platforms.join(", ")}`);
-    console.log(`Input: ${(text ?? "(source)").length} chars${flags.length ? " | Flags: " + flags.join(", ") : ""}\n`);
+    // When --source is used, text is undefined here — the collected content
+    // (markdown fed to the LLM) only materializes inside the text module, so
+    // there is no char count to show yet. Log the source name instead of the
+    // misleading length of a "(source)" placeholder.
+    const inputLabel =
+      text !== undefined
+        ? `${text.length} chars`
+        : `source "${opts.source}" (content collected inside text module)`;
+    console.log(`Input: ${inputLabel}${flags.length ? " | Flags: " + flags.join(", ") : ""}\n`);
 
     // Surface publish resolution so a misconfigured OSS/Feishu is obvious.
     if (noPublish) {
@@ -164,6 +174,7 @@ export const renderCommand = new Command("render")
         skipTts: noTts,
         skipLlm: opts.skipLlm,
         skipPublish: noPublish,
+        llmModel: opts.llmModel,
       },
       runConfig,
       {

+ 3 - 1
apps/web/src/lib/run-render.ts

@@ -66,7 +66,9 @@ export function startRenderJob(params: RenderJobParams): {
 
     const runConfig: DocumentRunConfig = {
       branding: { channelName: config?.branding?.channelName ?? "Pipeline" },
-      llm: { model: config?.llm?.model ?? "glm-5.1" },
+      // Model resolution (env > config) lives in core's runDocument — the
+      // client only forwards the config default.
+      llm: { model: config?.llm?.model },
       tts: {
         provider: ttsProvider,
         voiceId: voiceId || providerConfig?.defaultVoice,

+ 1 - 1
config/default.yaml

@@ -8,7 +8,7 @@ branding:
 llm:
   # baseURL: set via OPENAI_BASE_URL in .env
   # apiKey: set via OPENAI_API_KEY in .env
-  model: "glm-5.1"
+  model: "model-stable"
 
 tts:
   provider: "openai-tts"

+ 2 - 1
docs/DEPLOYMENT.md

@@ -80,13 +80,14 @@ docker compose up --build -d        # 改完代码后重新构建
 
 ## 4. 配置说明
 
-配置分两层:**`.env`(密钥 / 端点)** + **`config/default.yaml`(业务参数)**。CLI 读取顺序:`--config` 参数 → `pipeline.config.yaml` → `config/default.yaml`;密钥统一走环境变量。
+配置分两层:**`.env`(密钥 / 端点 / 部署期覆盖)** + **`config/default.yaml`(业务默认参数)**。CLI 读取顺序:`--config` 参数 → `pipeline.config.yaml` → `config/default.yaml`;密钥统一走环境变量。LLM 模型优先级为:CLI `--llm-model` → `LLM_MODEL` 环境变量 → 配置文件 `llm.model` → 运行时兜底值。这样容器部署可仅注入 `LLM_MODEL` 切换模型,无需修改配置文件或重建镜像。
 
 ### 4.1 环境变量(`.env`)
 
 | 变量 | 必填 | 说明 |
 | --- | --- | --- |
 | `OPENAI_API_KEY` / `OPENAI_BASE_URL` | 是* | LLM(OpenAI 兼容端点:DeepSeek / Qwen / GLM 等)。`*`用结构化 JSON + `--skip-llm` 时可省 |
+| `LLM_MODEL` | 否 | 部署期覆盖 `config/default.yaml` 的 LLM 模型;CLI `--llm-model` 优先级更高 |
 | `OPENAI_TTS_API_KEY` / `OPENAI_TTS_BASE_URL` | 是* | 默认 TTS provider。`*`用 `--no-tts` 时可省 |
 | `OPENAI_TTS_MODEL` | 否 | TTS 模型,默认 `seed-tts-1.1` |
 | `TTS_MAX_RETRIES` | 否 | TTS 请求失败重试次数(网络错误 / 429 / 5xx,指数退避),默认 3;`0` 关闭 |

+ 18 - 2
packages/core/src/document.ts

@@ -30,10 +30,15 @@ export interface DocumentRunInput {
   skipLlm?: boolean;
   /** Skip OSS upload + Feishu notification. */
   skipPublish?: boolean;
+  /** Explicit LLM model override (CLI --llm-model). Highest priority, above
+   *  the LLM_MODEL env and config.llm.model. */
+  llmModel?: string;
 }
 
 export interface DocumentRunConfig {
-  llm: { baseURL?: string; apiKey?: string; model: string };
+  /** Resolved inside runDocument: input.llmModel > LLM_MODEL env > llm.model.
+   *  Throws when none is set — no silent hardcoded fallback. */
+  llm: { baseURL?: string; apiKey?: string; model?: string };
   tts: {
     provider: string;
     voiceId?: string;
@@ -218,6 +223,17 @@ export async function runDocument(
 
   try {
     callbacks?.onStage?.("text");
+    // Resolve the LLM model in ONE place: explicit input override (CLI flag) >
+    // deployment env > config default. Throws when none is set so a misconfigured
+    // deployment fails fast instead of silently using a stale hardcoded model.
+    const llmModel = input.llmModel || process.env.LLM_MODEL || config.llm.model;
+    if (!llmModel) {
+      throw new Error(
+        "LLM model is not configured: set llm.model in the config file, " +
+          "the LLM_MODEL environment variable, or pass --llm-model."
+      );
+    }
+    log.debug(`job ${jobId} llm model=${llmModel}`);
     // generateDocument returns the pure VideoDocument + separate posting metadata.
     const textOut = await generateDocument({
       template: input.template,
@@ -225,7 +241,7 @@ export async function runDocument(
       source: input.source,
       sourceArgs: input.sourceArgs,
       collectorConfig: input.source ? config.collect?.[input.source] : undefined,
-      llm: config.llm,
+      llm: { ...config.llm, model: llmModel },
       skipLlm: input.skipLlm,
     });
     doc = textOut.doc;

+ 32 - 6
packages/text/src/generate.ts

@@ -6,7 +6,13 @@ import {
   type VideoInput,
   type TemplateType,
 } from "@pipeline/shared";
-import { applyLengthLimits, sanitizeVideoInput, JSON_TRUNCATION_NUDGE } from "./postprocess.js";
+import {
+  applyLengthLimits,
+  sanitizeVideoInput,
+  isMissingGithubScenes,
+  JSON_TRUNCATION_NUDGE,
+  GITHUB_STRUCTURE_NUDGE,
+} from "./postprocess.js";
 import type { TextModuleLlmConfig } from "./types.js";
 
 /**
@@ -52,8 +58,13 @@ export async function generateVideoInput(
   let aiParsed: unknown;
   let lastFinish: string | null = null;
   let lastRaw = "";
+  let needGithubRetry = false;
   for (let attempt = 0; attempt < 2 && aiParsed === undefined; attempt++) {
-    const userMessage = attempt === 0 ? text : `${text}${JSON_TRUNCATION_NUDGE}`;
+    const nudges = [
+      attempt > 0 && needGithubRetry ? GITHUB_STRUCTURE_NUDGE : "",
+      attempt > 0 && !needGithubRetry ? JSON_TRUNCATION_NUDGE : "",
+    ].join("");
+    const userMessage = attempt === 0 ? text : `${text}${nudges}`;
     const { content, finishReason } = await client.chat(systemPrompt, userMessage);
     lastFinish = finishReason;
     lastRaw = stripFences(content);
@@ -62,16 +73,31 @@ export async function generateVideoInput(
     } catch {
       // not valid JSON yet — fall through to retry, or to the final error below
     }
+    // Structure check BEFORE validation: github board outputs with zero github
+    // scenes would render cover-only after assembly. If flagged, discard and
+    // retry once with the structure nudge.
+    if (aiParsed !== undefined) {
+      const limited = applyLengthLimits(aiParsed, template);
+      const missingGithub =
+        (template === "github-trending" || template === "github-weekly") &&
+        isMissingGithubScenes(limited);
+      if (missingGithub) {
+        needGithubRetry = true;
+        aiParsed = undefined;
+      } else {
+        aiParsed = limited;
+      }
+    }
   }
   if (aiParsed === undefined) {
     throw new Error(
-      `AI returned invalid JSON after retry (finish_reason=${lastFinish ?? "unknown"}; ` +
-        `likely truncated by the output length limit — raise max_tokens in LLMClient):\n${lastRaw.slice(0, 300)}`
+      needGithubRetry
+        ? `AI output had no github scene structure (github.repo/highlights/intro/review) after retry (finish_reason=${lastFinish ?? "unknown"}):\n${lastRaw.slice(0, 300)}`
+        : `AI returned invalid JSON after retry (finish_reason=${lastFinish ?? "unknown"}; ` +
+          `likely truncated by the output length limit — raise max_tokens in LLMClient):\n${lastRaw.slice(0, 300)}`
     );
   }
 
-  aiParsed = applyLengthLimits(aiParsed, template);
-
   const validationResult = VideoInputSchema.safeParse(aiParsed);
   if (!validationResult.success) {
     throw new Error(`AI output does not match VideoInputSchema: ${validationResult.error.message}`);

+ 29 - 2
packages/text/src/postprocess.ts

@@ -10,10 +10,17 @@ import {
  * output is unaffected by the refactor.
  */
 
-/** Appended on the retry attempt when the first LLM JSON failed to parse. */
+/** Appended on the retry attempt when the first LLM output failed to parse. */
 export const JSON_TRUNCATION_NUDGE =
   "\n\n[重要] 你上一次的 JSON 输出因超出输出长度上限被截断,导致解析失败。请重新输出一份更精简但结构完整的 JSON:适当减少 scenes 数量、缩短每个场景的 narration 与详细描述字段,务必确保整个 JSON(含所有闭合括号)在输出上限内完整结束。";
 
+/** Appended on the retry attempt when the LLM output parsed as JSON but NO scene
+ *  carried the structured github object. github templates render every repo
+ *  scene from its `github` block; assembly drops scenes without one, so such an
+ *  output would silently render a cover-only (~4s) video. */
+export const GITHUB_STRUCTURE_NUDGE =
+  "\n\n[重要] 你上一次的输出中没有任何场景包含必需的 github 结构化对象(github.repo / highlights / intro / review)。请重新输出完整 JSON:github 模板下每个仓库场景都必须包含完整 github 对象(github.repo 从输入的 <!-- repo-meta --> 注释块原样复制,不得改写字段或数值),不要输出缺少 github 对象的概览型场景。";
+
 /**
  * Clip LLM-generated text fields to their schema-enforced maximums before
  * validation. Clips at a sentence/clause boundary when possible so the result
@@ -32,6 +39,20 @@ const isGithubTemplate = (t: string) =>
  *  (prompt asks for 160-230 chars; 260 leaves clip headroom, schema caps at 280). */
 const narrationMax = (t: string) => (t === "github-daily-pick" ? 260 : 200);
 
+/**
+ * True when NO scene carries the structured github object. github board
+ * templates render EVERY content scene from its structured github block
+ * (extension). An LLM output where no scene carries one (e.g. it produced only
+ * overview scenes) would leave the document cover-only after assembly —
+ * checked by the generate layer so it can retry with a structure nudge.
+ */
+export function isMissingGithubScenes(input: unknown): boolean {
+  if (!input || typeof input !== "object") return true;
+  const scenes = (input as any).scenes;
+  if (!Array.isArray(scenes)) return true;
+  return !scenes.some((s: any) => s && typeof s === "object" && s.github && typeof s.github === "object");
+}
+
 export function applyLengthLimits(input: unknown, template: string): unknown {
   if (!input || typeof input !== "object") return input;
   const root = (input as any).scenes && Array.isArray((input as any).scenes)
@@ -39,9 +60,15 @@ export function applyLengthLimits(input: unknown, template: string): unknown {
     : input;
   if (!Array.isArray((root as any).scenes)) return input;
 
-  (root as any).scenes = (root as any).scenes.map((scene: any) => {
+  (root as any).scenes = (root as any).scenes.map((scene: any, index: number) => {
     if (!scene || typeof scene !== "object") return scene;
     const next: any = { ...scene };
+    // Some compatible LLMs omit the mechanically-required scene id even though
+    // the prompt includes it. Keep the contract strict while repairing this
+    // deterministic field before schema validation.
+    if (typeof next.id !== "string" || next.id.trim() === "") {
+      next.id = `scene-${index + 1}`;
+    }
     const narration = isGithubTemplate(template)
       ? stripLeadingGreeting(scene.narration)
       : scene.narration;

+ 68 - 0
packages/text/test/postprocess.test.ts

@@ -0,0 +1,68 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+  applyLengthLimits,
+  isMissingGithubScenes,
+} from "../src/postprocess.ts";
+
+// --- applyLengthLimits: scene id repair ---
+
+test("applyLengthLimits fills missing scene id deterministically", () => {
+  const input = { scenes: [{ narration: "a" }, { id: "", narration: "b" }] };
+  const out = applyLengthLimits(input, "news") as any;
+  assert.equal(out.scenes[0].id, "scene-1");
+  assert.equal(out.scenes[1].id, "scene-2");
+});
+
+test("applyLengthLimits keeps existing scene ids untouched", () => {
+  const input = { scenes: [{ id: "my-scene", narration: "a" }] };
+  const out = applyLengthLimits(input, "news") as any;
+  assert.equal(out.scenes[0].id, "my-scene");
+});
+
+// --- isMissingGithubScenes: github board structure detection ---
+// Regression guard: the detection used to be an object flag that
+// applyLengthLimits deleted before the caller could read it (dead retry path).
+
+test("isMissingGithubScenes: true when no scene carries a github object", () => {
+  const input = {
+    scenes: [
+      { id: "s1", title: "overview", narration: "..." },
+      { id: "s2", title: "another overview", narration: "..." },
+    ],
+  };
+  assert.equal(isMissingGithubScenes(applyLengthLimits(input, "github-trending")), true);
+});
+
+test("isMissingGithubScenes: false when at least one scene has github block", () => {
+  const input = {
+    scenes: [
+      { id: "s1", narration: "...", github: { repo: {}, highlights: "x", intro: "y", review: "z" } },
+      { id: "s2", narration: "..." },
+    ],
+  };
+  assert.equal(isMissingGithubScenes(applyLengthLimits(input, "github-trending")), false);
+});
+
+test("isMissingGithubScenes: true for non-object / missing scenes input", () => {
+  assert.equal(isMissingGithubScenes(undefined), true);
+  assert.equal(isMissingGithubScenes({}), true);
+  assert.equal(isMissingGithubScenes({ scenes: [] }), true);
+});
+
+test("isMissingGithubScenes ignores non-github templates' shape via caller", () => {
+  // Detection itself is template-agnostic; the generate layer gates it on
+  // github-trending/github-weekly only. Here we just pin the pure behavior.
+  const input = { scenes: [{ narration: "plain news scene" }] };
+  assert.equal(isMissingGithubScenes(input), true);
+});
+
+// --- applyLengthLimits must not leak internal markers into output ---
+
+test("applyLengthLimits output contains no internal marker keys", () => {
+  const input = {
+    scenes: [{ narration: "overview only, no github blocks here" }],
+  };
+  const out = applyLengthLimits(input, "github-weekly") as any;
+  assert.equal(Object.hasOwn(out, "__missingGithubScenes"), false);
+});

+ 15 - 0
packages/text/tsconfig.test.json

@@ -0,0 +1,15 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "outDir": "dist",
+    "rootDir": ".",
+    "types": ["node"],
+    "allowImportingTsExtensions": true,
+    "noEmit": true
+  },
+  "include": ["test", "src/postprocess.ts"],
+  "references": [
+    { "path": "../shared" },
+    { "path": "../collect" }
+  ]
+}