@gobing-ai/knowledge-kit 0.0.27 → 0.0.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/package.json +1 -1
  3. package/plugins/generations/daily-article-gen/dist/index.js +27 -4
  4. package/plugins/generations/daily-article-gen/src/index.ts +34 -3
  5. package/plugins/generations/dailynews-gen/dist/index.js +42 -19
  6. package/plugins/generations/dailynews-gen/src/index.ts +29 -1
  7. package/plugins/generations/dailynews-gen/src/script-builder.ts +33 -21
  8. package/plugins/generations/episode-plan-gen/dist/index.js +7 -0
  9. package/plugins/generations/episode-plan-gen/src/index.ts +12 -0
  10. package/plugins/generations/image-gen/assets/fonts/NotoSansSC-Bold.otf +0 -0
  11. package/plugins/generations/image-gen/assets/fonts/NotoSansSC-Regular.otf +0 -0
  12. package/plugins/generations/image-gen/assets/fonts/OFL.txt +92 -0
  13. package/plugins/generations/image-gen/assets/fonts/SOURCE.md +15 -0
  14. package/plugins/generations/image-gen/dist/index.js +597 -16
  15. package/plugins/generations/image-gen/package.json +1 -0
  16. package/plugins/generations/image-gen/presets/formats/daily-news-cover.yaml +2 -2
  17. package/plugins/generations/image-gen/presets/formats/xhs-card.yaml +19 -11
  18. package/plugins/generations/image-gen/presets/styles/xhs-knowledge.yaml +2 -1
  19. package/plugins/generations/image-gen/scripts/compose-e2e-artifact.ts +135 -0
  20. package/plugins/generations/image-gen/src/bytes.ts +28 -0
  21. package/plugins/generations/image-gen/src/compose/card.ts +294 -0
  22. package/plugins/generations/image-gen/src/compose/chromium.ts +147 -0
  23. package/plugins/generations/image-gen/src/compose/cmap.ts +177 -0
  24. package/plugins/generations/image-gen/src/index.ts +167 -9
  25. package/plugins/generations/image-gen/src/job.ts +6 -0
  26. package/plugins/generations/image-gen/src/presets.ts +53 -13
  27. package/plugins/generations/image-gen/src/providers/google.ts +9 -1
  28. package/plugins/kk/plugin.json +1 -1
  29. package/plugins/kk/scripts/daily-voice-style.zh.md +25 -12
  30. package/plugins/kk/scripts/kk-workflow-stages.ts +344 -11
  31. package/plugins/kk/scripts/topic-boundary.json +105 -0
  32. package/plugins/kk/scripts/validate-voicescript.ts +15 -0
  33. package/plugins/kk/skills/content-safety/SKILL.md +163 -0
  34. package/plugins/kk/skills/content-safety/references/platforms/apple-podcasts.md +36 -0
  35. package/plugins/kk/skills/content-safety/references/platforms/netease-music.md +31 -0
  36. package/plugins/kk/skills/content-safety/references/platforms/wechatmp.md +32 -0
  37. package/plugins/kk/skills/content-safety/references/platforms/xiaoyuzhou.md +35 -0
  38. package/plugins/kk/skills/xhs-images/SKILL.md +11 -7
  39. package/plugins/kk/skills/xhs-images/references/card-layouts.md +4 -4
  40. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +118 -16
  41. package/plugins/publishings/podcast-pub/dist/index.js +4 -0
  42. package/plugins/publishings/podcast-pub/src/index.ts +2 -0
  43. package/plugins/publishings/podcast-pub/src/show-notes.ts +6 -0
  44. package/plugins/publishings/wechatmp-pub/dist/index.js +272 -115
  45. package/plugins/publishings/wechatmp-pub/src/index.ts +336 -40
  46. package/plugins/publishings/wechatmp-pub/src/profile-check.ts +21 -9
  47. package/plugins/publishings/xhs-pub/dist/index.js +28 -22
  48. package/plugins/publishings/xhs-pub/src/index.ts +30 -22
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "plugins": [
8
8
  {
9
- "version": "0.0.27",
9
+ "version": "0.0.29",
10
10
  "name": "kk",
11
11
  "source": "./plugins/kk",
12
12
  "description": "knowledge-kit — a set of toolkit for knowledge management"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/knowledge-kit",
3
- "version": "0.0.27",
3
+ "version": "0.0.29",
4
4
  "description": "An ingest → create → publish content pipeline CLI (Bun).",
5
5
  "repository": {
6
6
  "type": "git",
@@ -22035,13 +22035,19 @@ ${metaLine ? `${metaLine}
22035
22035
 
22036
22036
  ` : ""}${stripScaffold(doc2.body ?? "").trim()}`;
22037
22037
  }
22038
+ function episodeTitle(docs, date6) {
22039
+ const lead = docs[0]?.title?.trim() ?? "";
22040
+ const theme = lead !== "" ? lead : "\u5DE5\u5177\u3001\u8BBA\u6587\u4E0E\u5B9E\u8DF5";
22041
+ return `AI \u4E71\u7096\uFF5C${theme} \xB7 ${date6}`;
22042
+ }
22038
22043
  function renderDailyArticle(docs, date6) {
22039
22044
  const items = docs.map(renderDoc).join(`
22040
22045
 
22041
22046
  `);
22042
- return `# \u6BCF\u65E5 AI \u8D44\u8BAF \u2014 ${date6}
22047
+ const intro = docs.length > 0 ? `\u300CAI \u4E71\u7096\u300D\u662F\u300C\u54B8\u8BDD\u54B8\u8BF4\u300D\u7684 AI \u6280\u672F\u5206\u4EAB\u680F\u76EE\uFF1A\u5DE5\u5177\u3001\u8BBA\u6587\u4E0E\u5B9E\u8DF5\u968F\u804A\u3002\u672C\u671F\u5171 ${docs.length} \u6761\u5185\u5BB9\uFF0C\u5747\u9644\u53EF\u70B9\u51FB\u6765\u6E90\u94FE\u63A5\u3002` : "\u300CAI \u4E71\u7096\u300D\u662F\u300C\u54B8\u8BDD\u54B8\u8BF4\u300D\u7684 AI \u6280\u672F\u5206\u4EAB\u680F\u76EE\uFF1A\u5DE5\u5177\u3001\u8BBA\u6587\u4E0E\u5B9E\u8DF5\u968F\u804A\u3002\u672C\u671F\u6CA1\u6709\u8DB3\u591F\u624E\u5B9E\u7684\u53EF\u804A\u5185\u5BB9\u3002";
22048
+ return `# ${episodeTitle(docs, date6)}
22043
22049
 
22044
- \u672C\u671F\u5171 ${docs.length} \u6761\u65B0\u95FB\uFF0C\u5747\u9644\u53EF\u70B9\u51FB\u6765\u6E90\u94FE\u63A5\u3002
22050
+ ${intro}
22045
22051
 
22046
22052
  ${items}
22047
22053
  `;
@@ -22054,10 +22060,26 @@ function tagsFor(docs) {
22054
22060
  });
22055
22061
  return [AI_NEWS_TAG, ...existing.filter((t) => t !== AI_NEWS_TAG)];
22056
22062
  }
22063
+ var LEADING_H1_RE = /^#(?!#)\s*[^\n]+\n+/.source;
22057
22064
  function docsToContent(docs, date6, bodyOverride) {
22065
+ const title = episodeTitle(docs, date6);
22066
+ let body = renderDailyArticle(docs, date6);
22067
+ if (bodyOverride) {
22068
+ const stripped = stripScaffold(bodyOverride.trim()).trim().replace(new RegExp(LEADING_H1_RE), "");
22069
+ body = `# ${title}
22070
+
22071
+ ${stripped}
22072
+ `;
22073
+ }
22074
+ body = `${body.trimEnd()}
22075
+
22076
+ ---
22077
+
22078
+ *\u6587\u5B57\u7531 AI \u8F85\u52A9\u6574\u7406\uFF0C\u97F3\u9891\u4E3A AI \u5408\u6210\u914D\u97F3\u3002*
22079
+ `;
22058
22080
  return ContentSchema.parse({
22059
- title: `\u6BCF\u65E5 AI \u8D44\u8BAF \u2014 ${date6}`,
22060
- body: bodyOverride ? stripScaffold(bodyOverride).trim() : renderDailyArticle(docs, date6),
22081
+ title,
22082
+ body,
22061
22083
  format: "markdown",
22062
22084
  references: docs.filter((d) => d.sourceUri).map((d) => ({ url: d.sourceUri, title: d.title, cite: d.id })),
22063
22085
  metadata: { generator: "kk:daily-article", date: date6, doc_count: docs.length, tags: tagsFor(docs) }
@@ -22110,5 +22132,6 @@ export {
22110
22132
  renderDailyArticle,
22111
22133
  processGeneratorIO,
22112
22134
  main2 as main,
22135
+ episodeTitle,
22113
22136
  docsToContent
22114
22137
  };
@@ -102,10 +102,27 @@ function renderDoc(doc: Doc, index: number): string {
102
102
  return `## ${index + 1}. ${heading}\n\n${metaLine ? `${metaLine}\n\n` : ''}${stripScaffold(doc.body ?? '').trim()}`;
103
103
  }
104
104
 
105
+ /**
106
+ * Episode title for the 「AI 乱炖」 column (task 0189 R1/R4): `AI 乱炖|<本期实际技术主题> · YYYY-MM-DD`.
107
+ * The deterministic proxy for 本期实际技术主题 is the lead plan item's title — the same doc the
108
+ * cover brief and XHS cards already treat as the episode's lead story. Pure function so the
109
+ * agent-written and template article paths, ID3, RSS and show notes all derive one identical
110
+ * title; never re-introduces the retired news-bulletin positioning (task 0189 R4).
111
+ */
112
+ export function episodeTitle(docs: Doc[], date: string): string {
113
+ const lead = docs[0]?.title?.trim() ?? '';
114
+ const theme = lead !== '' ? lead : '工具、论文与实践';
115
+ return `AI 乱炖|${theme} · ${date}`;
116
+ }
117
+
105
118
  /** Render the article markdown for a merged Doc[] (pure — exported for tests). */
106
119
  export function renderDailyArticle(docs: Doc[], date: string): string {
107
120
  const items = docs.map(renderDoc).join('\n\n');
108
- return `# 每日 AI 资讯 — ${date}\n\n本期共 ${docs.length} 条新闻,均附可点击来源链接。\n\n${items}\n`;
121
+ const intro =
122
+ docs.length > 0
123
+ ? `「AI 乱炖」是「咸话咸说」的 AI 技术分享栏目:工具、论文与实践随聊。本期共 ${docs.length} 条内容,均附可点击来源链接。`
124
+ : '「AI 乱炖」是「咸话咸说」的 AI 技术分享栏目:工具、论文与实践随聊。本期没有足够扎实的可聊内容。';
125
+ return `# ${episodeTitle(docs, date)}\n\n${intro}\n\n${items}\n`;
109
126
  }
110
127
 
111
128
  /** Single-source daily tag: stamped on every Content this generator emits (task 0119). */
@@ -120,11 +137,25 @@ function tagsFor(docs: Doc[]): string[] {
120
137
  return [AI_NEWS_TAG, ...existing.filter((t) => t !== AI_NEWS_TAG)];
121
138
  }
122
139
 
140
+ /** A leading H1 line — agent bodies used to carry their own title; the canonical title is computed (task 0189 R4). */
141
+ const LEADING_H1_RE = /^#(?!#)\s*[^\n]+\n+/.source;
142
+
123
143
  /** Build the article Content from a merged Doc[]; `bodyOverride` replaces the mechanical render (agent-written prose), still scaffold-stripped as a safety net. */
124
144
  export function docsToContent(docs: Doc[], date: string, bodyOverride?: string): Content {
145
+ const title = episodeTitle(docs, date);
146
+ let body = renderDailyArticle(docs, date);
147
+ if (bodyOverride) {
148
+ // The canonical episode title is computed from the lead item; a leading H1 the agent may
149
+ // still have written is replaced (not duplicated) so body and metadata agree on every path.
150
+ const stripped = stripScaffold(bodyOverride.trim()).trim().replace(new RegExp(LEADING_H1_RE), '');
151
+ body = `# ${title}\n\n${stripped}\n`;
152
+ }
153
+ // Task 0189 R1: the AI-production note rides on every article path (mechanical and agent
154
+ // prose). Podcast show notes are rendered separately by podcast-pub and do not inherit it.
155
+ body = `${body.trimEnd()}\n\n---\n\n*文字由 AI 辅助整理,音频为 AI 合成配音。*\n`;
125
156
  return ContentSchema.parse({
126
- title: `每日 AI 资讯 — ${date}`,
127
- body: bodyOverride ? stripScaffold(bodyOverride).trim() : renderDailyArticle(docs, date),
157
+ title,
158
+ body,
128
159
  format: 'markdown',
129
160
  references: docs.filter((d) => d.sourceUri).map((d) => ({ url: d.sourceUri, title: d.title, cite: d.id })),
130
161
  metadata: { generator: 'kk:daily-article', date, doc_count: docs.length, tags: tagsFor(docs) },
@@ -22064,19 +22064,22 @@ function stripLeadingTitleEcho(body, title) {
22064
22064
  const wanted = title.replace(TITLE_ECHO_NOISE_RE, "");
22065
22065
  if (wanted.length < 4)
22066
22066
  return body;
22067
- let matched = "";
22068
- let end = -1;
22069
- for (let i = 0;i < body.length && matched.length < wanted.length; i++) {
22070
- const kept = (body[i] ?? "").replace(TITLE_ECHO_NOISE_RE, "");
22071
- if (kept === "")
22072
- continue;
22073
- matched += kept;
22074
- if (matched.length >= wanted.length)
22075
- end = i + 1;
22067
+ let text = body;
22068
+ for (;; ) {
22069
+ let matched = "";
22070
+ let end = -1;
22071
+ for (let i = 0;i < text.length && matched.length < wanted.length; i++) {
22072
+ const kept = (text[i] ?? "").replace(TITLE_ECHO_NOISE_RE, "");
22073
+ if (kept === "")
22074
+ continue;
22075
+ matched += kept;
22076
+ if (matched.length >= wanted.length)
22077
+ end = i + 1;
22078
+ }
22079
+ if (end === -1 || matched !== wanted)
22080
+ return text;
22081
+ text = text.slice(end).replace(/^[\uFF0C\u3002\u3001\uFF1A:\uFF1B;!\uFF01?\uFF1F\u2026\s\u300C\u300D\u300E\u300F\u3010\u3011]+/, "");
22076
22082
  }
22077
- if (end === -1 || matched !== wanted)
22078
- return body;
22079
- return body.slice(end).replace(/^[\uFF0C\u3002\u3001\uFF1A:\uFF1B;!\uFF01?\uFF1F\u2026\s\u300C\u300D\u300E\u300F\u3010\u3011]+/, "");
22080
22083
  }
22081
22084
  var TAG_METADATA_LINE_RE = /^\s*\**\s*\u6807\u7B7E\s*\**\s*[\uFF1A:\uFF0C,]/;
22082
22085
  function stripTagMetadataLines(body) {
@@ -22113,10 +22116,10 @@ function buildNewsVoiceScript(docs, options) {
22113
22116
  const broadcastDate = options?.date ?? new Date;
22114
22117
  const dateStr = formatBroadcastDate(broadcastDate, language);
22115
22118
  if (docs.length === 0) {
22116
- const title2 = options?.title || (isZh ? "\u4ECA\u65E5AI\u79D1\u6280\u6F2B\u8C08\uFF08\u6682\u65E0\u66F4\u65B0\uFF09" : "AI Daily Briefing (No Updates)");
22119
+ const title2 = options?.title || (isZh ? "AI \u4E71\u7096\uFF08\u6682\u65E0\u66F4\u65B0\uFF09" : "AI \u4E71\u7096 (No Updates)");
22117
22120
  const segments2 = isZh ? [
22118
22121
  {
22119
- text: `\u5927\u5BB6\u597D\uFF0C\u4ECA\u5929\u662F${dateStr}\u3002\u6B22\u8FCE\u6536\u542C\u7531${voiceProfile}\u4E3A\u60A8\u5E26\u6765\u7684\u4ECA\u65E5AI\u79D1\u6280\u6F2B\u8C08\u3002\u4ECA\u5929\u79D1\u6280\u5708\u6682\u65E0\u6700\u65B0\u7684\u7CBE\u9009\u52A8\u6001\u3002`,
22122
+ text: `\u5927\u5BB6\u597D\uFF0C\u4ECA\u5929\u662F${dateStr}\u3002\u6B22\u8FCE\u6536\u542C\u300C\u54B8\u8BDD\u54B8\u8BF4\u300D\u7684\u300CAI \u4E71\u7096\u300D\u680F\u76EE\uFF0C\u6211\u662F${voiceProfile}\u3002\u4ECA\u5929\u6CA1\u6709\u8DB3\u591F\u624E\u5B9E\u7684\u53EF\u804A\u5185\u5BB9\u3002`,
22120
22123
  gap_ms: 400,
22121
22124
  emotion: "calm"
22122
22125
  },
@@ -22127,7 +22130,7 @@ function buildNewsVoiceScript(docs, options) {
22127
22130
  }
22128
22131
  ] : [
22129
22132
  {
22130
- text: `Hello and welcome. Today is ${dateStr}. This is ${voiceProfile} with your AI Daily Briefing. There are no new featured stories today.`,
22133
+ text: `Hello and welcome. Today is ${dateStr}. This is ${voiceProfile} with AI \u4E71\u7096, our chat about AI tools, papers, and hands-on practice. There is nothing solid enough to share today.`,
22131
22134
  gap_ms: 400,
22132
22135
  emotion: "calm"
22133
22136
  },
@@ -22148,7 +22151,7 @@ function buildNewsVoiceScript(docs, options) {
22148
22151
  segments: segments2
22149
22152
  });
22150
22153
  }
22151
- const title = options?.title || (isZh ? `\u4ECA\u65E5AI\u79D1\u6280\u6F2B\u8C08\uFF08\u5171${docs.length}\u6761\uFF09` : `AI Daily Briefing (${docs.length} stories)`);
22154
+ const title = options?.title || (isZh ? `AI \u4E71\u7096\uFF08\u5171${docs.length}\u6761\uFF09` : `AI \u4E71\u7096 (${docs.length} stories)`);
22152
22155
  const segments = [];
22153
22156
  let hostName = options?.speakers?.host?.name || voiceProfile.replace(/[-_]?(news|voice|bot|profile)$/i, "") || voiceProfile;
22154
22157
  if (hostName.length > 0) {
@@ -22158,7 +22161,7 @@ function buildNewsVoiceScript(docs, options) {
22158
22161
  const planItems = new Map((scriptPlan?.items ?? []).map((item) => [item.id, item]));
22159
22162
  const countText = isZh ? `${formatChineseCount(docs.length)}\u6761` : `${docs.length}`;
22160
22163
  const titlesSummary = docs.slice(0, 3).map((d) => (d.title ?? "").trim()).filter(Boolean).join("\u3001");
22161
- const introText = isZh ? scriptPlan ? `\u5927\u5BB6\u597D\uFF0C\u4ECA\u5929\u662F${dateStr}\uFF0C\u6B22\u8FCE\u6536\u542C\u7531${hostName}\u4E3A\u60A8\u5E26\u6765\u7684\u4ECA\u65E5AI\u79D1\u6280\u6F2B\u8C08\u3002${scriptPlan.intro}` : `\u5927\u5BB6\u597D\uFF0C\u4ECA\u5929\u662F${dateStr}\uFF0C\u6B22\u8FCE\u6536\u542C\u7531${hostName}\u4E3A\u60A8\u5E26\u6765\u7684\u4ECA\u65E5AI\u79D1\u6280\u6F2B\u8C08\u3002\u4ECA\u5929\u79D1\u6280\u5708\u6709\u51E0\u6761\u975E\u5E38\u6709\u770B\u70B9\u7684\u5927\u6D88\u606F\uFF0C\u6211\u7CBE\u9009\u4E86${countText}\u6700\u503C\u5F97\u5173\u6CE8\u7684\u91CD\u70B9\u52A8\u6001\u6765\u548C\u5927\u5BB6\u804A\u4E00\u804A${titlesSummary ? `\uFF0C\u5305\u62EC${titlesSummary}` : ""}\u3002\u8BDD\u4E0D\u591A\u8BF4\uFF0C\u6211\u4EEC\u9A6C\u4E0A\u8FDB\u5165\u4ECA\u5929\u7684\u6B63\u9898\u3002` : `Hello and welcome. Today is ${dateStr}. This is ${hostName} with your AI Daily Briefing. In today's episode, we have ${countText} fascinating stories from the AI world. Let's dive straight in.`;
22164
+ const introText = isZh ? scriptPlan ? `\u5927\u5BB6\u597D\uFF0C\u4ECA\u5929\u662F${dateStr}\uFF0C\u6B22\u8FCE\u6536\u542C\u300C\u54B8\u8BDD\u54B8\u8BF4\u300D\u7684\u300CAI \u4E71\u7096\u300D\u680F\u76EE\uFF0C\u6211\u662F${hostName}\u3002${scriptPlan.intro}` : `\u5927\u5BB6\u597D\uFF0C\u4ECA\u5929\u662F${dateStr}\uFF0C\u6B22\u8FCE\u6536\u542C\u300C\u54B8\u8BDD\u54B8\u8BF4\u300D\u7684\u300CAI \u4E71\u7096\u300D\u680F\u76EE\uFF0C\u6211\u662F${hostName}\u3002\u4ECA\u5929\u6211\u4ECE AI \u5DE5\u5177\u3001\u8BBA\u6587\u548C\u5B9E\u8DF5\u4E2D\u6574\u7406\u4E86${countText}\u503C\u5F97\u804A\u7684\u5185\u5BB9${titlesSummary ? `\uFF0C\u5305\u62EC${titlesSummary}` : ""}\uFF0C\u54B1\u4EEC\u4E00\u6761\u6761\u6765\u804A\u3002` : `Hello and welcome. Today is ${dateStr}. This is ${hostName} with AI \u4E71\u7096, where we talk about AI tools, papers, and hands-on practice. In today's episode, we have ${countText} stories. Let's dive straight in.`;
22162
22165
  segments.push({
22163
22166
  text: normalizeBroadcastText(introText, isZh),
22164
22167
  gap_ms: 700,
@@ -22193,7 +22196,7 @@ function buildNewsVoiceScript(docs, options) {
22193
22196
  });
22194
22197
  }
22195
22198
  });
22196
- const outroText = isZh ? scriptPlan ? `${scriptPlan.outro}\u611F\u8C22\u60A8\u7684\u6536\u542C\uFF0C\u6211\u662F${hostName}\uFF0C\u795D\u60A8\u62E5\u6709\u5145\u5B9E\u9AD8\u6548\u7684\u4E00\u5929\uFF0C\u6211\u4EEC\u4E0B\u671F\u8282\u76EE\u4E0D\u89C1\u4E0D\u6563\uFF01` : `\u597D\u4E86\uFF0C\u4EE5\u4E0A\u5C31\u662F\u4ECA\u5929AI\u79D1\u6280\u6F2B\u8C08\u7684\u5168\u90E8\u5185\u5BB9\u3002\u4E0D\u77E5\u9053\u4ECA\u5929\u8FD9\u51E0\u6761\u6D88\u606F\u6709\u6CA1\u6709\u7ED9\u60A8\u5E26\u6765\u4E00\u4E9B\u65B0\u7684\u542F\u53D1\uFF1F\u611F\u8C22\u60A8\u7684\u6536\u542C\uFF0C\u6211\u662F${hostName}\uFF0C\u795D\u60A8\u62E5\u6709\u5145\u5B9E\u9AD8\u6548\u7684\u4E00\u5929\uFF0C\u6211\u4EEC\u4E0B\u671F\u8282\u76EE\u4E0D\u89C1\u4E0D\u6563\uFF01` : `That wraps up today's AI Daily Briefing. I hope today's highlights gave you some useful takeaways. Thank you for listening. This is ${hostName}, wishing you a productive and wonderful day. Until next time!`;
22199
+ const outroText = isZh ? scriptPlan ? `${scriptPlan.outro}\u672C\u671F\u97F3\u9891\u4E3A AI \u5408\u6210\u914D\u97F3\u3002\u611F\u8C22\u60A8\u7684\u6536\u542C\uFF0C\u6211\u662F${hostName}\uFF0C\u795D\u60A8\u62E5\u6709\u5145\u5B9E\u9AD8\u6548\u7684\u4E00\u5929\uFF0C\u6211\u4EEC\u4E0B\u671F\u8282\u76EE\u4E0D\u89C1\u4E0D\u6563\uFF01` : `\u597D\u4E86\uFF0C\u672C\u671FAI\u4E71\u7096\u5C31\u804A\u5230\u8FD9\u91CC\u3002\u5E0C\u671B\u4ECA\u5929\u8FD9\u4E9B\u5DE5\u5177\u3001\u8BBA\u6587\u548C\u5B9E\u8DF5\u7684\u7ECF\u9A8C\u5BF9\u60A8\u6709\u542F\u53D1\u3002\u672C\u671F\u97F3\u9891\u4E3A AI \u5408\u6210\u914D\u97F3\u3002\u611F\u8C22\u60A8\u7684\u6536\u542C\uFF0C\u6211\u662F${hostName}\uFF0C\u795D\u60A8\u62E5\u6709\u5145\u5B9E\u9AD8\u6548\u7684\u4E00\u5929\uFF0C\u6211\u4EEC\u4E0B\u671F\u8282\u76EE\u4E0D\u89C1\u4E0D\u6563\uFF01` : `That wraps up this episode of AI \u4E71\u7096. I hope today's takeaways were useful. The audio in this episode is AI-synthesized. Thank you for listening. This is ${hostName}, wishing you a productive and wonderful day. Until next time!`;
22197
22200
  segments.push({
22198
22201
  text: normalizeBroadcastText(outroText, isZh),
22199
22202
  gap_ms: 300,
@@ -22453,6 +22456,24 @@ async function resolveScriptPlan(fs, docs) {
22453
22456
  }
22454
22457
  return { scriptPlan: validated.plan, scriptSource: "plan", scriptPlanErrors: [] };
22455
22458
  }
22459
+ function broadcastDateFromEnv(raw) {
22460
+ if (!raw)
22461
+ return;
22462
+ const match = raw.match(/^(\d{4})-?(\d{2})-?(\d{2})$/);
22463
+ if (match === null) {
22464
+ echoError(`dailynews-gen: ignoring malformed DAILYNEWS_DATE "${raw}" (expected YYYYMMDD or YYYY-MM-DD)`);
22465
+ return;
22466
+ }
22467
+ const year = Number(match[1]);
22468
+ const month = Number(match[2]) - 1;
22469
+ const day = Number(match[3]);
22470
+ const date6 = new Date(year, month, day);
22471
+ if (date6.getFullYear() !== year || date6.getMonth() !== month || date6.getDate() !== day) {
22472
+ echoError(`dailynews-gen: ignoring out-of-range DAILYNEWS_DATE "${raw}"`);
22473
+ return;
22474
+ }
22475
+ return date6;
22476
+ }
22456
22477
  async function processGeneratorIO(inputPath, outputPath, options) {
22457
22478
  const fs = createNodeFileSystem();
22458
22479
  let docs;
@@ -22472,7 +22493,8 @@ async function processGeneratorIO(inputPath, outputPath, options) {
22472
22493
  }
22473
22494
  const isZh = (options?.language || "zh").toLowerCase() === "zh";
22474
22495
  const planOutcome = isZh ? await resolveScriptPlan(fs, docs) : { scriptSource: "template", scriptPlanErrors: [] };
22475
- const script = buildNewsVoiceScript(docs, { ...options, scriptPlan: planOutcome.scriptPlan });
22496
+ const date6 = options?.date ?? broadcastDateFromEnv(process.env.DAILYNEWS_DATE);
22497
+ const script = buildNewsVoiceScript(docs, { ...options, date: date6, scriptPlan: planOutcome.scriptPlan });
22476
22498
  const yamlBody = formatVoiceScriptToYaml(script);
22477
22499
  const content = {
22478
22500
  title: script.title,
@@ -22553,6 +22575,7 @@ export {
22553
22575
  formatChineseCount,
22554
22576
  formatBroadcastDate,
22555
22577
  buildNewsVoiceScript,
22578
+ broadcastDateFromEnv,
22556
22579
  VoiceSpeakerSchema,
22557
22580
  VoiceScriptSegmentSchema,
22558
22581
  VoiceScriptSchema,
@@ -66,6 +66,32 @@ async function resolveScriptPlan(fs: FileSystem, docs: Doc[]): Promise<ScriptPla
66
66
  return { scriptPlan: validated.plan, scriptSource: 'plan', scriptPlanErrors: [] };
67
67
  }
68
68
 
69
+ /**
70
+ * `DAILYNEWS_DATE` (YYYYMMDD or `YYYY-MM-DD`) → a local-midnight Date. Task 0189: the broadcast
71
+ * greeting must carry the episode's run date, not the render date — a 9/29 episode rebuilt on 9/30
72
+ * must not greet "今天是9月30日". Parsed as local calendar parts, never UTC (`new Date('2026-09-29')`
73
+ * is UTC midnight and renders the previous day west of UTC). Absent → undefined; garbled or
74
+ * out-of-range (the Date constructor rolls over, e.g. month 13) → undefined with a warn so a
75
+ * caller bug surfaces instead of silently broadcasting the wrong day.
76
+ */
77
+ export function broadcastDateFromEnv(raw: string | undefined): Date | undefined {
78
+ if (!raw) return undefined;
79
+ const match = raw.match(/^(\d{4})-?(\d{2})-?(\d{2})$/);
80
+ if (match === null) {
81
+ echoError(`dailynews-gen: ignoring malformed DAILYNEWS_DATE "${raw}" (expected YYYYMMDD or YYYY-MM-DD)`);
82
+ return undefined;
83
+ }
84
+ const year = Number(match[1]);
85
+ const month = Number(match[2]) - 1;
86
+ const day = Number(match[3]);
87
+ const date = new Date(year, month, day);
88
+ if (date.getFullYear() !== year || date.getMonth() !== month || date.getDate() !== day) {
89
+ echoError(`dailynews-gen: ignoring out-of-range DAILYNEWS_DATE "${raw}"`);
90
+ return undefined;
91
+ }
92
+ return date;
93
+ }
94
+
69
95
  export async function processGeneratorIO(
70
96
  inputPath: string,
71
97
  outputPath: string,
@@ -101,7 +127,9 @@ export async function processGeneratorIO(
101
127
  const planOutcome = isZh
102
128
  ? await resolveScriptPlan(fs, docs)
103
129
  : { scriptSource: 'template' as const, scriptPlanErrors: [] as ScriptPlanError[] };
104
- const script = buildNewsVoiceScript(docs, { ...options, scriptPlan: planOutcome.scriptPlan });
130
+ // Explicit option wins; else DAILYNEWS_DATE (workflow backfill/retry); else the builder's now.
131
+ const date = options?.date ?? broadcastDateFromEnv(process.env.DAILYNEWS_DATE);
132
+ const script = buildNewsVoiceScript(docs, { ...options, date, scriptPlan: planOutcome.scriptPlan });
105
133
  const yamlBody = formatVoiceScriptToYaml(script);
106
134
 
107
135
  const content: Content = {
@@ -232,20 +232,29 @@ const TITLE_ECHO_NOISE_RE = /[,。、::;;!!??…\s「」『』【】]/
232
232
  * character prefix compare, never a fuzzy/LLM similarity match (anti-pattern list). The body is
233
233
  * returned untouched whenever the normalized prefix does not equal the normalized title, and a
234
234
  * body that is *only* the restatement collapses to the empty string (the lead alone carries it).
235
+ *
236
+ * 20260929 dogfood: plan bodies can restate the title MORE THAN once — a bare first line plus a
237
+ * restated line carrying a parenthetical extension (`title(来源标注日期为…)`). A single pass
238
+ * strips only the first, so the second becomes the new head and the VoiceScript validator rejects
239
+ * the segment. Keep stripping until the head no longer echoes; every accepted pass strictly
240
+ * shortens the body, so the loop terminates.
235
241
  */
236
242
  export function stripLeadingTitleEcho(body: string, title: string): string {
237
243
  const wanted = title.replace(TITLE_ECHO_NOISE_RE, '');
238
244
  if (wanted.length < 4) return body; // a short title would match too much of the body
239
- let matched = '';
240
- let end = -1;
241
- for (let i = 0; i < body.length && matched.length < wanted.length; i++) {
242
- const kept = (body[i] ?? '').replace(TITLE_ECHO_NOISE_RE, '');
243
- if (kept === '') continue; // drift punctuation between title and echo
244
- matched += kept;
245
- if (matched.length >= wanted.length) end = i + 1;
245
+ let text = body;
246
+ for (;;) {
247
+ let matched = '';
248
+ let end = -1;
249
+ for (let i = 0; i < text.length && matched.length < wanted.length; i++) {
250
+ const kept = (text[i] ?? '').replace(TITLE_ECHO_NOISE_RE, '');
251
+ if (kept === '') continue; // drift punctuation between title and echo
252
+ matched += kept;
253
+ if (matched.length >= wanted.length) end = i + 1;
254
+ }
255
+ if (end === -1 || matched !== wanted) return text;
256
+ text = text.slice(end).replace(/^[,。、::;;!!??…\s「」『』【】]+/, '');
246
257
  }
247
- if (end === -1 || matched !== wanted) return body;
248
- return body.slice(end).replace(/^[,。、::;;!!??…\s「」『』【】]+/, '');
249
258
  }
250
259
 
251
260
  /**
@@ -304,11 +313,13 @@ export function buildNewsVoiceScript(docs: Doc[], options?: ScriptBuilderOptions
304
313
  const dateStr = formatBroadcastDate(broadcastDate, language);
305
314
 
306
315
  if (docs.length === 0) {
307
- const title = options?.title || (isZh ? '今日AI科技漫谈(暂无更新)' : 'AI Daily Briefing (No Updates)');
316
+ const title = options?.title || (isZh ? 'AI 乱炖(暂无更新)' : 'AI 乱炖 (No Updates)');
308
317
  const segments: VoiceScriptSegment[] = isZh
309
318
  ? [
310
319
  {
311
- text: `大家好,今天是${dateStr}。欢迎收听由${voiceProfile}为您带来的今日AI科技漫谈。今天科技圈暂无最新的精选动态。`,
320
+ // Task 0189 R1/R4: honest no-content episode — the copy names the show
321
+ // (咸话咸说) and the column (AI 乱炖), and never claims a news digest happened.
322
+ text: `大家好,今天是${dateStr}。欢迎收听「咸话咸说」的「AI 乱炖」栏目,我是${voiceProfile}。今天没有足够扎实的可聊内容。`,
312
323
  gap_ms: 400,
313
324
  emotion: 'calm',
314
325
  },
@@ -320,7 +331,7 @@ export function buildNewsVoiceScript(docs: Doc[], options?: ScriptBuilderOptions
320
331
  ]
321
332
  : [
322
333
  {
323
- text: `Hello and welcome. Today is ${dateStr}. This is ${voiceProfile} with your AI Daily Briefing. There are no new featured stories today.`,
334
+ text: `Hello and welcome. Today is ${dateStr}. This is ${voiceProfile} with AI 乱炖, our chat about AI tools, papers, and hands-on practice. There is nothing solid enough to share today.`,
324
335
  gap_ms: 400,
325
336
  emotion: 'calm',
326
337
  },
@@ -343,9 +354,7 @@ export function buildNewsVoiceScript(docs: Doc[], options?: ScriptBuilderOptions
343
354
  });
344
355
  }
345
356
 
346
- const title =
347
- options?.title ||
348
- (isZh ? `今日AI科技漫谈(共${docs.length}条)` : `AI Daily Briefing (${docs.length} stories)`);
357
+ const title = options?.title || (isZh ? `AI 乱炖(共${docs.length}条)` : `AI 乱炖 (${docs.length} stories)`);
349
358
  const segments: VoiceScriptSegment[] = [];
350
359
 
351
360
  let hostName =
@@ -373,9 +382,10 @@ export function buildNewsVoiceScript(docs: Doc[], options?: ScriptBuilderOptions
373
382
  ? scriptPlan
374
383
  ? // F613: fixed greeting stays builder-owned; the plan only fills the slot between
375
384
  // greeting and first story (the template tail 从「今天科技圈有几条…正题。」 is replaced).
376
- `大家好,今天是${dateStr},欢迎收听由${hostName}为您带来的今日AI科技漫谈。${scriptPlan.intro}`
377
- : `大家好,今天是${dateStr},欢迎收听由${hostName}为您带来的今日AI科技漫谈。今天科技圈有几条非常有看点的大消息,我精选了${countText}最值得关注的重点动态来和大家聊一聊${titlesSummary ? `,包括${titlesSummary}` : ''}。话不多说,我们马上进入今天的正题。`
378
- : `Hello and welcome. Today is ${dateStr}. This is ${hostName} with your AI Daily Briefing. In today's episode, we have ${countText} fascinating stories from the AI world. Let's dive straight in.`;
385
+ // Task 0189 R1: the greeting names the show (咸话咸说) and the column (AI 乱炖).
386
+ `大家好,今天是${dateStr},欢迎收听「咸话咸说」的「AI 乱炖」栏目,我是${hostName}。${scriptPlan.intro}`
387
+ : `大家好,今天是${dateStr},欢迎收听「咸话咸说」的「AI 乱炖」栏目,我是${hostName}。今天我从 AI 工具、论文和实践中整理了${countText}值得聊的内容${titlesSummary ? `,包括${titlesSummary}` : ''},咱们一条条来聊。`
388
+ : `Hello and welcome. Today is ${dateStr}. This is ${hostName} with AI 乱炖, where we talk about AI tools, papers, and hands-on practice. In today's episode, we have ${countText} stories. Let's dive straight in.`;
379
389
 
380
390
  segments.push({
381
391
  text: normalizeBroadcastText(introText, isZh),
@@ -440,11 +450,13 @@ export function buildNewsVoiceScript(docs: Doc[], options?: ScriptBuilderOptions
440
450
 
441
451
  // 3. Outro segment: Warm, conversational wrap-up. F613: the plan outro replaces the template
442
452
  // wrap-up head; the fixed sign-off stays builder-owned.
453
+ // Task 0189 R1: the AI-voice disclosure is part of the builder-owned sign-off (not the plan),
454
+ // so it is spoken in every episode — Apple Podcasts guideline 1.11 requires it in the content.
443
455
  const outroText = isZh
444
456
  ? scriptPlan
445
- ? `${scriptPlan.outro}感谢您的收听,我是${hostName},祝您拥有充实高效的一天,我们下期节目不见不散!`
446
- : `好了,以上就是今天AI科技漫谈的全部内容。不知道今天这几条消息有没有给您带来一些新的启发?感谢您的收听,我是${hostName},祝您拥有充实高效的一天,我们下期节目不见不散!`
447
- : `That wraps up today's AI Daily Briefing. I hope today's highlights gave you some useful takeaways. Thank you for listening. This is ${hostName}, wishing you a productive and wonderful day. Until next time!`;
457
+ ? `${scriptPlan.outro}本期音频为 AI 合成配音。感谢您的收听,我是${hostName},祝您拥有充实高效的一天,我们下期节目不见不散!`
458
+ : `好了,本期AI乱炖就聊到这里。希望今天这些工具、论文和实践的经验对您有启发。本期音频为 AI 合成配音。感谢您的收听,我是${hostName},祝您拥有充实高效的一天,我们下期节目不见不散!`
459
+ : `That wraps up this episode of AI 乱炖. I hope today's takeaways were useful. The audio in this episode is AI-synthesized. Thank you for listening. This is ${hostName}, wishing you a productive and wonderful day. Until next time!`;
448
460
 
449
461
  segments.push({
450
462
  text: normalizeBroadcastText(outroText, isZh),
@@ -22101,7 +22101,14 @@ function docTimestamp(doc2) {
22101
22101
  }
22102
22102
  return null;
22103
22103
  }
22104
+ var CHINA_POLITICS_RE = /\u4E60\u8FD1\u5E73|\u674E\u514B\u5F3A|\u674E\u5F3A|\u515A\u4E2D\u592E|\u653F\u6CBB\u5C40|\u4E2D\u5357\u6D77|\u653F\u5E9C\u5DE5\u4F5C\u62A5\u544A|\u56FD\u53F0\u529E|\u5916\u4EA4\u90E8\u53D1\u8A00\u4EBA|\u4E24\u4F1A/;
22105
+ function isChinaPolitics(doc2) {
22106
+ return CHINA_POLITICS_RE.test(`${doc2.title ?? ""}
22107
+ ${(doc2.body ?? "").slice(0, 2000)}`);
22108
+ }
22104
22109
  function passesGates(doc2, planDate) {
22110
+ if (isChinaPolitics(doc2))
22111
+ return false;
22105
22112
  const meta3 = asRecord(doc2.metadata);
22106
22113
  const tags = Array.isArray(meta3.tags) ? meta3.tags.filter((t) => typeof t === "string") : [];
22107
22114
  if (tags.some((tag) => NON_NEWS_TAGS.has(tag.toLowerCase())))
@@ -227,7 +227,19 @@ function docTimestamp(doc: Doc): number | null {
227
227
  }
228
228
 
229
229
  /** Freshness + non-news gates applied before scoring/dedup (archived or off-topic items never enter the pool). */
230
+ /**
231
+ * Platform-safety gate (operator rule 2026-09-29): China domestic politics stories never enter
232
+ * the plan. Keyword heuristic — high-precision markers + top-leader names only; widen after
233
+ * reviewing false negatives in the audit trail rather than pre-emptively (overblocking kills
234
+ * legitimate AI-industry coverage).
235
+ */
236
+ const CHINA_POLITICS_RE = /习近平|李克强|李强|党中央|政治局|中南海|政府工作报告|国台办|外交部发言人|两会/;
237
+ function isChinaPolitics(doc: Doc): boolean {
238
+ return CHINA_POLITICS_RE.test(`${doc.title ?? ''}\n${(doc.body ?? '').slice(0, 2000)}`);
239
+ }
240
+
230
241
  function passesGates(doc: Doc, planDate: string): boolean {
242
+ if (isChinaPolitics(doc)) return false;
231
243
  const meta = asRecord(doc.metadata);
232
244
  const tags = Array.isArray(meta.tags) ? meta.tags.filter((t): t is string => typeof t === 'string') : [];
233
245
  if (tags.some((tag) => NON_NEWS_TAGS.has(tag.toLowerCase()))) return false;
@@ -0,0 +1,92 @@
1
+ This Font Software is licensed under the SIL Open Font License,
2
+ Version 1.1.
3
+
4
+ This license is copied below, and is also available with a FAQ at:
5
+ http://scripts.sil.org/OFL
6
+
7
+ -----------------------------------------------------------
8
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
9
+ -----------------------------------------------------------
10
+
11
+ PREAMBLE
12
+ The goals of the Open Font License (OFL) are to stimulate worldwide
13
+ development of collaborative font projects, to support the font
14
+ creation efforts of academic and linguistic communities, and to
15
+ provide a free and open framework in which fonts may be shared and
16
+ improved in partnership with others.
17
+
18
+ The OFL allows the licensed fonts to be used, studied, modified and
19
+ redistributed freely as long as they are not sold by themselves. The
20
+ fonts, including any derivative works, can be bundled, embedded,
21
+ redistributed and/or sold with any software provided that any reserved
22
+ names are not used by derivative works. The fonts and derivatives,
23
+ however, cannot be released under any other type of license. The
24
+ requirement for fonts to remain under this license does not apply to
25
+ any document created using the fonts or their derivatives.
26
+
27
+ DEFINITIONS
28
+ "Font Software" refers to the set of files released by the Copyright
29
+ Holder(s) under this license and clearly marked as such. This may
30
+ include source files, build scripts and documentation.
31
+
32
+ "Reserved Font Name" refers to any names specified as such after the
33
+ copyright statement(s).
34
+
35
+ "Original Version" refers to the collection of Font Software
36
+ components as distributed by the Copyright Holder(s).
37
+
38
+ "Modified Version" refers to any derivative made by adding to,
39
+ deleting, or substituting -- in part or in whole -- any of the
40
+ components of the Original Version, by changing formats or by porting
41
+ the Font Software to a new environment.
42
+
43
+ "Author" refers to any designer, engineer, programmer, technical
44
+ writer or other person who contributed to the Font Software.
45
+
46
+ PERMISSION & CONDITIONS
47
+ Permission is hereby granted, free of charge, to any person obtaining
48
+ a copy of the Font Software, to use, study, copy, merge, embed,
49
+ modify, redistribute, and sell modified and unmodified copies of the
50
+ Font Software, subject to the following conditions:
51
+
52
+ 1) Neither the Font Software nor any of its individual components, in
53
+ Original or Modified Versions, may be sold by itself.
54
+
55
+ 2) Original or Modified Versions of the Font Software may be bundled,
56
+ redistributed and/or sold with any software, provided that each copy
57
+ contains the above copyright notice and this license. These can be
58
+ included either as stand-alone text files, human-readable headers or
59
+ in the appropriate machine-readable metadata fields within text or
60
+ binary files as long as those fields can be easily viewed by the user.
61
+
62
+ 3) No Modified Version of the Font Software may use the Reserved Font
63
+ Name(s) unless explicit written permission is granted by the
64
+ corresponding Copyright Holder. This restriction only applies to the
65
+ primary font name as presented to the users.
66
+
67
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
68
+ Software shall not be used to promote, endorse or advertise any
69
+ Modified Version, except to acknowledge the contribution(s) of the
70
+ Copyright Holder(s) and the Author(s) or with their explicit written
71
+ permission.
72
+
73
+ 5) The Font Software, modified or unmodified, in part or in whole,
74
+ must be distributed entirely under this license, and must not be
75
+ distributed under any other license. The requirement for fonts to
76
+ remain under this license does not apply to any document created using
77
+ the Font Software.
78
+
79
+ TERMINATION
80
+ This license becomes null and void if any of the above conditions are
81
+ not met.
82
+
83
+ DISCLAIMER
84
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
85
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
86
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
87
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
88
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
89
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
90
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
91
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
92
+ OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,15 @@
1
+ # Noto Sans SC — provenance
2
+
3
+ Shipped static SubsetOTF/SC fonts used by the `compose` text-card composer (task 0180, ADR-028).
4
+ Output is reproducible on every install because these bytes ship with the plugin — no runtime
5
+ download and no system-font lookup.
6
+
7
+ - **Upstream:** https://github.com/notofonts/noto-cjk (Noto Sans CJK SC, static SubsetOTF)
8
+ - **Version:** tag `Sans2.004` (https://github.com/notofonts/noto-cjk/releases/tag/Sans2.004)
9
+ - **Licence:** SIL Open Font License 1.1 (`OFL.txt`, from the repo root `LICENSE` at the same tag)
10
+
11
+ | File | Upstream path at `Sans2.004` | SHA-256 |
12
+ | --- | --- | --- |
13
+ | `NotoSansSC-Regular.otf` | `Sans/SubsetOTF/SC/NotoSansSC-Regular.otf` | `faa6c9df652116dde789d351359f3d7e5d2285a2b2a1f04a2d7244df706d5ea9` |
14
+ | `NotoSansSC-Bold.otf` | `Sans/SubsetOTF/SC/NotoSansSC-Bold.otf` | `c6cb5a93abaa9edc8ee7463b7ebb7f42d618d40e6ed2f7a5371c97b0b64767c0` |
15
+ | `OFL.txt` | `LICENSE` | `6a73f9541c2de74158c0e7cf6b0a58ef774f5a780bf191f2d7ec9cc53efe2bf2` |