Răsfoiți Sursa

github-trending: top6 选取、精简封面口播、移除 coverTags、LLM 截断容错

- parse: 内容场景按 todayStars 降序取 top6(视频只介绍涨星最多的 6 个);封面口播改为承接语「下面进入项目详解。」(不再念日期/数量);停止产出 coverTags
- prompt: 数据源规则按今日涨星降序选 6 个;口播恢复 ≤200 字;补 todayStars 形状与 Today: +N 兜底;移除 coverTags
- LLM: max_tokens 8192→16384,chat() 返回 finishReason;解析失败重试一次(追加精简指令),错误信息带 finish_reason

Co-Authored-By: Claude <noreply@anthropic.com>
lkatzey 5 zile în urmă
părinte
comite
fd24f38169

+ 49 - 21
packages/core/src/stages/parse.ts

@@ -11,6 +11,13 @@ import {
 import { getTimezone } from "@pipeline/shared/node";
 import type { ParsedContent, Scene, VideoInput } from "@pipeline/shared";
 
+/** Appended on the retry attempt when the first LLM JSON failed to parse.
+ *  With response_format=json_object a parse failure almost always means the
+ *  response was truncated by the output length limit (finish_reason="length").
+ *  This nudges the model to produce a shorter, fully-closed JSON that fits. */
+const JSON_TRUNCATION_NUDGE =
+  "\n\n[重要] 你上一次的 JSON 输出因超出输出长度上限被截断,导致解析失败。请重新输出一份更精简但结构完整的 JSON:适当减少 scenes 数量、缩短每个场景的 narration 与详细描述字段,务必确保整个 JSON(含所有闭合括号)在输出上限内完整结束。";
+
 export interface ParseStageConfig {
   llm: {
     baseURL?: string;
@@ -182,25 +189,39 @@ export async function parseText(
       template as "news" | "knowledge" | "opinion" | "marketing" | "github-trending",
       config.source
     );
-    const rawResponse = await client.chat(systemPrompt, text);
 
-    // Strip markdown code block wrapper if present (```json ... ```)
-    const cleaned = rawResponse
-      .replace(/^```(?:json)?\s*\n?/i, "")
-      .replace(/\n?```\s*$/i, "")
-      .trim();
+    // Strip markdown code block wrapper if present (```json ... ```).
+    const stripFences = (s: string) =>
+      s.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, "").trim();
 
+    // With response_format=json_object the model emits valid JSON syntax, so a
+    // parse failure almost always means the response was truncated by the
+    // output length limit (finish_reason="length") — common for github-trending
+    // when the trending list is large. Retry once with a conciseness nudge so
+    // the (shorter) JSON completes within the budget.
     let aiParsed: unknown;
-    try {
-      aiParsed = JSON.parse(cleaned);
-    } catch {
+    let lastFinish: string | null = null;
+    let lastRaw = "";
+    for (let attempt = 0; attempt < 2 && aiParsed === undefined; attempt++) {
+      const userMessage = attempt === 0 ? text : `${text}${JSON_TRUNCATION_NUDGE}`;
+      const { content, finishReason } = await client.chat(systemPrompt, userMessage);
+      lastFinish = finishReason;
+      lastRaw = stripFences(content);
+      try {
+        aiParsed = JSON.parse(lastRaw);
+      } catch {
+        // not valid JSON yet — fall through to retry, or to the final error below
+      }
+    }
+    if (aiParsed === undefined) {
       throw new Error(
-        `AI returned invalid JSON: ${cleaned.slice(0, 200)}`
+        `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)}`
       );
     }
 
     // LLMs routinely overshoot the documented character budgets (narration
-    // ≤200, github highlights/intro/review ≤30/60/30). Clip in place before
+    // ≤200; github highlights/intro/review ≤30/200/30). Clip in place before
     // schema validation so the pipeline tolerates drift instead of hard-failing.
     aiParsed = applyLengthLimits(aiParsed, template);
 
@@ -253,15 +274,23 @@ export async function parseText(
         ...(coverInput.imageQuery ? [{ query: coverInput.imageQuery }] : []),
       ]
     : (firstSceneCoverImage ? [firstSceneCoverImage] : []);
-  // For github-trending the cover doubles as the opening narration screen —
-  // give it a fixed masthead line (greeting + date + show name + today's
-  // selection count) so the TTS stage produces audio and the compose stage
-  // sets the cover duration to match. It also carries the LLM-produced
-  // coverTags + trendSummary, rendered on the title screen by the template.
-  // Other templates keep the original behaviour (1s silent cover).
-  const gtRepoCount = videoInput.scenes.filter(s => s.github).length;
+  // For github-trending the cover doubles as the opening narration screen. The
+  // narration is a short bridge into the repo details — the date and repo count
+  // are shown on screen (subtitle pill + the cards), NOT read aloud. It also
+  // carries the LLM-produced trendSummary (rendered as a subtitle line by the
+  // template); the repo list itself is rendered from the content scenes. Other
+  // templates keep the original behaviour (1s silent cover).
+  // github-trending covers only the TOP 6 repos by today's star gain (matching
+  // the cover preview), played in descending-gain order so playback follows the
+  // cover ranking. Other templates keep all input scenes.
+  const contentInputs = template === "github-trending"
+    ? videoInput.scenes
+        .filter(s => s.github)
+        .sort((a, b) => (b.github!.repo.todayStars ?? 0) - (a.github!.repo.todayStars ?? 0))
+        .slice(0, 6)
+    : videoInput.scenes;
   const coverNarration = template === "github-trending"
-    ? `大家好,今天是${formatChineseDate(new Date(), getTimezone())},欢迎收看 GitHub 每日热榜。今天为大家精选了 ${gtRepoCount} 个值得关注的优质开源项目。`
+    ? "下面进入项目详解。"
     : "";
   scenes.push({
     id: "cover",
@@ -272,13 +301,12 @@ export async function parseText(
     keyframes: coverKeyframes,
     images: coverImages,
     backgroundQuery: coverInput?.imageQuery,
-    coverTags: template === "github-trending" ? videoInput.coverTags : undefined,
     trendSummary: template === "github-trending" ? videoInput.trendSummary : undefined,
     duration: 1,
   });
 
   // Content scenes
-  for (const s of videoInput.scenes) {
+  for (const s of contentInputs) {
     scenes.push({
       id: s.id,
       index: sceneIndex++,

+ 1 - 1
packages/shared/src/index.ts

@@ -13,5 +13,5 @@ export type {
   JobStatus,
 } from "./constants.js";
 export { LLMClient, getParsePrompt, detectInputFormat } from "./llm/index.js";
-export type { LLMClientConfig, InputFormat, DetectResult } from "./llm/index.js";
+export type { LLMClientConfig, LLMResult, InputFormat, DetectResult } from "./llm/index.js";
 export { stripUnsupportedGlyphs, clipToLength, formatCount, isoDateString, formatChineseDate, DEFAULT_TIMEZONE } from "./utils/index.js";

+ 18 - 10
packages/shared/src/llm/client.ts

@@ -6,6 +6,14 @@ export interface LLMClientConfig {
   model: string;
 }
 
+export interface LLMResult {
+  content: string;
+  // Provider finish reason: "stop" (complete), "length" (truncated by
+  // max_tokens — yields incomplete JSON), "content_filter", etc. The parse
+  // stage uses this to retry truncated github-trending output.
+  finishReason: string | null;
+}
+
 export class LLMClient {
   private client: OpenAI;
   private model: string;
@@ -18,7 +26,7 @@ export class LLMClient {
     this.model = config.model;
   }
 
-  async chat(systemPrompt: string, userMessage: string): Promise<string> {
+  async chat(systemPrompt: string, userMessage: string): Promise<LLMResult> {
     const response = await this.client.chat.completions.create({
       model: this.model,
       messages: [
@@ -27,17 +35,17 @@ export class LLMClient {
       ],
       temperature: 0.3,
       response_format: { type: "json_object" },
-      // The github-trending template produces ~5 repos × ~600 chars of
-      // structured JSON each (repo metadata + narration + intro + highlights
-      // + review + image refs) — that's ~6000 tokens before counting JSON
-      // overhead. Provider defaults (often 1024–4096) truncate the response
-      // mid-scene and break JSON parsing. 8192 leaves headroom for up to
-      // ~10 repos; raise if needed.
-      max_tokens: 8192,
+      // github-trending with a large trending list (many repos × verbose
+      // intro/narration) can exceed 8k output tokens and get truncated mid-JSON,
+      // breaking parsing. 16384 gives headroom. (The gateway clamps rather than
+      // errors on an oversized value, and the parse stage additionally retries
+      // once with a conciseness nudge if finish_reason="length".)
+      max_tokens: 16384,
     });
 
-    const content = response.choices[0]?.message?.content;
+    const choice = response.choices[0];
+    const content = choice?.message?.content;
     if (!content) throw new Error("LLM returned empty response");
-    return content;
+    return { content, finishReason: choice?.finish_reason ?? null };
   }
 }

+ 1 - 1
packages/shared/src/llm/index.ts

@@ -1,5 +1,5 @@
 export { LLMClient } from "./client.js";
-export type { LLMClientConfig } from "./client.js";
+export type { LLMClientConfig, LLMResult } from "./client.js";
 export { getParsePrompt } from "./prompts/parse-text.js";
 export { detectInputFormat } from "./detect-input.js";
 export type { InputFormat, DetectResult } from "./detect-input.js";

+ 6 - 8
packages/shared/src/llm/prompts/parse-text.ts

@@ -10,7 +10,6 @@ The output JSON must follow this exact schema:
 {
   "title": "string — video title, concise and catchy",
   "summary": "string — one-sentence summary",
-  "coverTags": "array of strings — OPTIONAL, github-trending only. ≤5 short theme tags (2-6 chars each) naming the main tech areas/themes across the selected repos. Omit for other templates.",
   "trendSummary": "string — OPTIONAL, github-trending only. One short sentence (≤30 chars) synthesizing the day's overall trend. Omit for other templates.",
   "scenes": [
     {
@@ -22,7 +21,7 @@ The output JSON must follow this exact schema:
         { "type": "text|highlight|image|icon|transition", "content": "string" }
       ],
       "images": "array — optional, image references. Entries may use { path } for local files, { url } for remote URLs, or { repoSocialPreview: \"owner/name\" } for GitHub repo social preview cards (only used by github-trending template).",
-      "github": "object — optional, only for github-trending template. Shape: { repo: { owner, name, fullName, language, languageColor, stars, forks, license }, highlights, intro, review }. See template-specific rules.",
+      "github": "object — optional, only for github-trending template. Shape: { repo: { owner, name, fullName, language, languageColor, stars, forks, license, todayStars }, highlights, intro, review }. See template-specific rules.",
       "durationHint": 10,
       "backgroundQuery": "string — describe the ideal background image",
       "layoutHint": "full-text|split|centered|bullet-list"
@@ -95,15 +94,14 @@ Template: GitHub Trending Showcase (GitHub 每日热榜)
 - Do not create overview, summary, or trend-overview SCENES. The opening title screen (cover) is built by the pipeline; it does not need a scene. Instead, convey the day's overall theme via the two top-level cover metadata fields below. Each repo scene MUST focus on its own repo.
 - The output for this template relies on structured metadata embedded in the input. Treat that metadata as authoritative — DO NOT paraphrase or invent values.
 
-COVER METADATA (github-trending only — emit these two TOP-LEVEL fields alongside title/summary; omit them for every other template):
-- coverTags: ≤5 short theme tags (2-6 Chinese chars, or a short English term). Each tag names the main technical AREA or THEME a selected repo belongs to — e.g. "代码智能", "时序模型", "视频编辑", "情报看板", "生成式AI", "AI Agent", "Rust 运行时". NOT programming languages and NOT repo names. Prefer one tag per selected repo; if two repos share a theme, merge them and pick a distinct area so the set still spans up to 5 areas. Plain CJK/ASCII only — no emoji, no decorative symbols.
-- trendSummary: one short sentence (≤30 chars) synthesizing the overall trend across today's repos — what theme is hot or emerging. Must read as an editorial insight, NOT a restatement of the tags or a generic filler. Example: "AI Agent 与代码智能工具持续升温". Plain CJK/ASCII only.
+COVER METADATA (github-trending only — emit this TOP-LEVEL field alongside title/summary; omit it for every other template):
+- trendSummary: one short sentence (≤30 chars) synthesizing the overall trend across today's repos — what theme is hot or emerging. Must read as an editorial insight, NOT a restatement of the repo list or a generic filler. Example: "AI Agent 与代码智能工具持续升温". Plain CJK/ASCII only.
 
 PER-REPO SCENE STRUCTURE (mandatory for every repo scene):
 
 displayText: the repo's fullName (e.g. "facebook/react"). Nothing else.
 
-github.repo: COPY the contents of the corresponding "<!-- repo-meta: ... -->" block from the input VERBATIM into scene.github.repo. Do not drop fields, do not rename keys, do not reformat numbers (keep stars as an integer). If license is empty string, keep it as empty string — the template will skip the license tag.
+github.repo: COPY the contents of the corresponding "<!-- repo-meta: ... -->" block from the input VERBATIM into scene.github.repo. Do not drop fields, do not rename keys, do not reformat numbers (keep stars as an integer). If license is empty string, keep it as empty string — the template will skip the license tag. If the repo-meta block omits todayStars but the repo's text contains a "Today: +N" line, extract the integer N and set github.repo.todayStars to it.
 
 images: array of EXACTLY TWO entries, copied from the corresponding "<!-- repo-images: ... -->" block:
   1. { "repoSocialPreview": "owner/name" }  — DO NOT expand this into a URL. The assets stage resolves it.
@@ -118,7 +116,7 @@ github.review (点评推荐): one short recommendation, ≤ 30 Chinese character
 
 narration (口播): ≤ 200 characters. Cover what the project does (deeper than intro), why it is notable today (timing / trend / adoption), and one distinctive characteristic a developer cares about. Pack specifics — numbers, comparisons, concrete use cases. Do NOT include a language bullet unless language is core to the value; do NOT read out the GitHub URL.
 
-GREETING RULE (overrides the base prompt's first-scene greeting rule): the cover scene for this template already opens with "大家好,今天是...". Therefore EVERY content scene's narration — including the first repo scene — MUST NOT begin with any of the following:
+GREETING RULE (overrides the base prompt's first-scene greeting rule): the cover scene for this template already opens with a short factual masthead line ("…,今日精选 N 个热榜项目。") that frames the day. Therefore EVERY content scene's narration — including the first repo scene — MUST NOT begin with any of the following:
 - A greeting: "大家好", "各位", "哈喽", "嗨", time-of-day greetings.
 - A show-opener that references the day, show, or format: "今天是GitHub热榜速览", "今天为大家带来...", "今天给大家介绍...", "本期节目...", "本周热榜...", "欢迎收看...".
 - Any meta lead-in. The narration must start DIRECTLY with project content (e.g. "首先要聊的是 React...", "接下来这个项目...", "React 是 Facebook 出品的声明式 UI 库...").
@@ -174,7 +172,7 @@ For multi-project sources, EACH project scene follows this same structure indepe
     "github-trending": `
 Data source: GitHub Trending (热门仓库列表)
 - The input is a list of multiple trending repositories. Treat it as a roundup.
-- Aim for 3-6 repos; if the input has more, pick the most notable ones (highest stars, biggest daily growth, or most novel).
+- Select EXACTLY the 6 repos with the highest today's star gain (github.repo.todayStars, mirrored by the 'Today: +N' line), in descending order. Produce one scene per repo in that order so the video follows the same ranking shown on the cover. If the input has fewer than 6 repos, use all of them.
 - One scene per repo. EVERY repo scene MUST follow the per-project scene structure below (headline + mandatory 功能简介 / GitHub 地址 / 使用场景 cards + optional value-add card + detailed narration).
 - Connect repos with a short narrative thread between scenes if there's a shared theme (e.g. "今天 AI Agent 持续火热"), but each project scene stands on its own.
 - Tone: pragmatic, factual, no hype. The audience is developers.