@gobing-ai/knowledge-kit 0.0.28 → 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 (40) 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 +27 -7
  6. package/plugins/generations/dailynews-gen/src/index.ts +29 -1
  7. package/plugins/generations/dailynews-gen/src/script-builder.ts +15 -12
  8. package/plugins/generations/image-gen/assets/fonts/NotoSansSC-Bold.otf +0 -0
  9. package/plugins/generations/image-gen/assets/fonts/NotoSansSC-Regular.otf +0 -0
  10. package/plugins/generations/image-gen/assets/fonts/OFL.txt +92 -0
  11. package/plugins/generations/image-gen/assets/fonts/SOURCE.md +15 -0
  12. package/plugins/generations/image-gen/dist/index.js +591 -15
  13. package/plugins/generations/image-gen/package.json +1 -0
  14. package/plugins/generations/image-gen/presets/formats/daily-news-cover.yaml +2 -2
  15. package/plugins/generations/image-gen/presets/formats/xhs-card.yaml +19 -11
  16. package/plugins/generations/image-gen/presets/styles/xhs-knowledge.yaml +2 -1
  17. package/plugins/generations/image-gen/scripts/compose-e2e-artifact.ts +135 -0
  18. package/plugins/generations/image-gen/src/bytes.ts +28 -0
  19. package/plugins/generations/image-gen/src/compose/card.ts +294 -0
  20. package/plugins/generations/image-gen/src/compose/chromium.ts +147 -0
  21. package/plugins/generations/image-gen/src/compose/cmap.ts +177 -0
  22. package/plugins/generations/image-gen/src/index.ts +167 -9
  23. package/plugins/generations/image-gen/src/job.ts +6 -0
  24. package/plugins/generations/image-gen/src/presets.ts +53 -13
  25. package/plugins/kk/plugin.json +1 -1
  26. package/plugins/kk/scripts/daily-voice-style.zh.md +25 -12
  27. package/plugins/kk/scripts/kk-workflow-stages.ts +268 -9
  28. package/plugins/kk/scripts/topic-boundary.json +105 -0
  29. package/plugins/kk/scripts/validate-voicescript.ts +15 -0
  30. package/plugins/kk/skills/content-safety/SKILL.md +163 -0
  31. package/plugins/kk/skills/content-safety/references/platforms/apple-podcasts.md +36 -0
  32. package/plugins/kk/skills/content-safety/references/platforms/netease-music.md +31 -0
  33. package/plugins/kk/skills/content-safety/references/platforms/wechatmp.md +32 -0
  34. package/plugins/kk/skills/content-safety/references/platforms/xiaoyuzhou.md +35 -0
  35. package/plugins/kk/skills/xhs-images/SKILL.md +11 -7
  36. package/plugins/kk/skills/xhs-images/references/card-layouts.md +4 -4
  37. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +103 -13
  38. package/plugins/publishings/podcast-pub/dist/index.js +4 -0
  39. package/plugins/publishings/podcast-pub/src/index.ts +2 -0
  40. package/plugins/publishings/podcast-pub/src/show-notes.ts +6 -0
@@ -1,33 +1,41 @@
1
- # 每日AI科技漫谈 · 主持风格契约(zh)
1
+ # AI 乱炖 · 主持风格契约(zh)
2
2
 
3
- `kk-daily-ai-voice` 的每一句口播都受这份契约约束(F613 R1)。`script-write` 提示词与
4
- `dailynews-gen` 的校验器读取同一份机器可读块,因此提示词与校验永远一致。
3
+ `kk-daily-ai-voice` 的每一句口播都受这份契约约束(F613 R1;0189 R1 改定位)。`script-write`
4
+ 提示词与 `dailynews-gen` 的校验器读取同一份机器可读块,因此提示词与校验永远一致。
5
+
6
+ ## 栏目定位(0189 R1)
7
+
8
+ 「AI 乱炖」是「咸话咸说」下的 AI 技术分享栏目,副题「工具、论文与实践随聊」。口播是开发者
9
+ 视角的技术分享,不是新闻通报:不播报时事,不以任何代称、绰号或「闲聊」包装政治、外交、
10
+ 军事、公共政策争议或社会公共事件主线的内容——这类条目在选题/QC 阶段就应被排除(0189 R2)。
11
+ 身份与经历必须真实:只讲真实做过的实测与实践,AI 配音参与按真实程度说明。
5
12
 
6
13
  ## 人设与语域
7
14
 
8
- - 口语化普通话,第一人称「我」,像一位冷静、有依据的科技播客主播。
15
+ - 口语化普通话,第一人称「我」,像一位冷静、有依据的 AI 技术分享主播。
9
16
  - 平实、克制、不炒作:不夸大单一消息的意义,不使用惊叹式渲染或营销话术。
10
- - 面向普通听众说明「这件事为什么重要」,而不是堆砌术语。
17
+ - 面向普通听众说明「这件事为什么重要」或「怎么用得上」,而不是堆砌术语。
11
18
 
12
19
  ## 固定开场与收尾
13
20
 
14
21
  开场问候与收尾致谢由 builder 固定输出,计划文本只填充两者之间的内容,节目身份永不漂移:
15
22
 
16
- - 开场:`大家好,今天是<日期>,欢迎收听由<主持人>为您带来的今日AI科技漫谈。`
23
+ - 开场:`大家好,今天是<日期>,欢迎收听「咸话咸说」的「AI 乱炖」栏目,我是<主持人>。`
17
24
  - 收尾:`感谢您的收听,我是<主持人>,祝您拥有充实高效的一天,我们下期节目不见不散!`
18
25
 
19
26
  ## 段落角色
20
27
 
21
- - `intro`:开场导语,串起今天几条主题,接在固定问候之后,不超过 120 字。
22
- - `lead`:一句话口语桥段,引出下一条新闻的【标题】;只做铺垫,不得复述标题本身,不超过 60 字。
23
- - `insight`:只基于这条新闻自身事实的 1–2 句点评,说明它为什么重要;不得挪用其他新闻的话题,不超过 160 字。
28
+ - `intro`:开场导语,串起今天几个主题,接在固定问候之后,不超过 120 字。
29
+ - `lead`:一句话口语桥段,引出下一个主题的【标题】;只做铺垫,不得复述标题本身,不超过 60 字。
30
+ - `insight`:只基于这一条自身可核事实的 1–2 句点评,说明它为什么重要或怎么用得上;不得挪用其他条目的话题,不超过 160 字。
24
31
  - `outro`:简短收束语,接在固定致谢之前,不超过 120 字。
25
32
 
26
33
  ## 机器可读契约
27
34
 
28
35
  `caps` 是各字段的字数上限;`banned` 是任何计划字段都不得出现的短语——包括旧模板的全部罐头
29
- 点评、旧模板的 首先/接着/最后 开头,以及 0133 的研究脚手架标签与对冲语(校验规则
30
- `banned:<短语>`)。
36
+ 点评、旧模板的 首先/接着/最后 开头、0133 的研究脚手架标签与对冲语(校验规则
37
+ `banned:<短语>`),以及 0189 R4 退役的旧栏目名(「每日 AI 资讯」「AI科技漫谈」
38
+ 「AI Daily Briefing」):任何路径都不得让旧新闻定位复活。
31
39
 
32
40
  ```json
33
41
  {
@@ -46,7 +54,12 @@
46
54
  "背景",
47
55
  "潜在影响",
48
56
  "读者宜另行核实",
49
- "仍待验证"
57
+ "仍待验证",
58
+ "每日AI资讯",
59
+ "每日 AI 资讯",
60
+ "今日AI科技漫谈",
61
+ "AI科技漫谈",
62
+ "AI Daily Briefing"
50
63
  ]
51
64
  }
52
65
  ```
@@ -717,6 +717,119 @@ function dailyPlan(args: string[]): void {
717
717
  });
718
718
  }
719
719
 
720
+ /**
721
+ * Topic-boundary sidecar (task 0189 R2). Sibling of this file; node-builtins-only read.
722
+ */
723
+ interface TopicBoundaryRule {
724
+ id: string;
725
+ reason: string;
726
+ keywords: string[];
727
+ }
728
+
729
+ interface TopicBoundarySpec {
730
+ version: number;
731
+ scanChars: number;
732
+ rules: TopicBoundaryRule[];
733
+ dateConflict: { reason: string };
734
+ }
735
+
736
+ interface TopicBoundaryExclusion {
737
+ id: string;
738
+ title: string;
739
+ rule: string;
740
+ reason: string;
741
+ matched: string;
742
+ }
743
+
744
+ function readTopicBoundary(): TopicBoundarySpec {
745
+ const path = join(SCRIPTS_DIR, 'topic-boundary.json');
746
+ let parsed: TopicBoundarySpec;
747
+ try {
748
+ parsed = JSON.parse(readFileSync(path, 'utf-8')) as TopicBoundarySpec;
749
+ } catch (err: unknown) {
750
+ failWith(1, `topic-boundary: spec unreadable at ${path}: ${err instanceof Error ? err.message : String(err)}`);
751
+ }
752
+ if (!Array.isArray(parsed.rules) || parsed.rules.length === 0) {
753
+ failWith(1, `topic-boundary: spec at ${path} carries no rules`);
754
+ }
755
+ return parsed;
756
+ }
757
+
758
+ /**
759
+ * Deterministic topic admission for the AI 乱炖 column (task 0189 R2): an item whose mainline is
760
+ * political/diplomatic-trade, military, public-policy dispute, or a social public event is
761
+ * excluded with its ID, rule and matched keyword recorded — sources stay untouched. A date that
762
+ * contradicts the run (publishedAt after the run date, e.g. an aggregated future-dated draft) is
763
+ * inadmissible as evidence (R3). Matches scan the title plus the first `scanChars` chars of the
764
+ * body — precision first: a body-mention far below the fold does not exclude. Matching is
765
+ * case-insensitive (task 0189 F2: both sides lowercased; zh unaffected) so headline-capitalized
766
+ * "President"/"Pentagon" still hit the lowercase English keywords in the spec.
767
+ */
768
+ export function applyTopicBoundary(
769
+ docs: unknown[],
770
+ runDate: string,
771
+ spec: TopicBoundarySpec,
772
+ ): {
773
+ kept: unknown[];
774
+ excluded: TopicBoundaryExclusion[];
775
+ } {
776
+ const excluded: TopicBoundaryExclusion[] = [];
777
+ const kept: unknown[] = [];
778
+ for (const doc of docs) {
779
+ const record = (doc ?? {}) as Record<string, unknown>;
780
+ const title = typeof record.title === 'string' ? record.title : '';
781
+ const body = typeof record.body === 'string' ? record.body : '';
782
+ const haystack = `${title}\n${body.slice(0, spec.scanChars)}`;
783
+ // Case-insensitive comparison (task 0189 F2): spec keywords are lowercase English; the
784
+ // recorded `matched` stays the spec's keyword verbatim. zh is unaffected by lowercasing.
785
+ const needle = haystack.toLowerCase();
786
+ let exclusion: TopicBoundaryExclusion | null = null;
787
+ for (const rule of spec.rules) {
788
+ // ASCII keywords must start at a word boundary ("selection" is not "election", "patriot"
789
+ // is not "riot"); the tail stays open so plurals/inflections still hit. zh keywords have
790
+ // no word boundaries and keep plain substring matching.
791
+ const matched = rule.keywords.find((keyword) => {
792
+ const lower = keyword.toLowerCase();
793
+ if (lower === '') return false;
794
+ return /^[a-z]/.test(lower)
795
+ ? new RegExp(`\\b${lower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`).test(needle)
796
+ : needle.includes(lower);
797
+ });
798
+ if (matched !== undefined) {
799
+ exclusion = { id: String(record.id ?? ''), title, rule: rule.id, reason: rule.reason, matched };
800
+ break;
801
+ }
802
+ }
803
+ if (exclusion === null) {
804
+ const publishedAt = metadataOf(record).publishedAt;
805
+ const stamp = typeof publishedAt === 'string' ? publishedAt : '';
806
+ const runIso = /^\d{8}$/.test(runDate)
807
+ ? `${runDate.slice(0, 4)}-${runDate.slice(4, 6)}-${runDate.slice(6, 8)}`
808
+ : '';
809
+ if (runIso !== '' && stamp !== '') {
810
+ const published = new Date(stamp);
811
+ if (
812
+ !Number.isNaN(published.getTime()) &&
813
+ // No zone suffix: run_date is a LOCAL day, so its end is local too — a UTC
814
+ // cutoff flags same-evening items west of Greenwich as future-dated.
815
+ published.getTime() > new Date(`${runIso}T23:59:59`).getTime()
816
+ ) {
817
+ exclusion = {
818
+ id: String(record.id ?? ''),
819
+ title,
820
+ rule: 'date-conflict',
821
+ reason: spec.dateConflict.reason,
822
+ matched: stamp,
823
+ };
824
+ }
825
+ }
826
+ }
827
+ if (exclusion !== null) excluded.push(exclusion);
828
+ else kept.push(doc);
829
+ }
830
+ return { kept, excluded };
831
+ }
832
+
720
833
  function dailyQcContent(args: string[]): void {
721
834
  const [
722
835
  workDir = '',
@@ -730,6 +843,7 @@ function dailyQcContent(args: string[]): void {
730
843
  ] = args;
731
844
  const candidates = join(workDir, '21-plan', `${runDate}_04_quality-control-content_candidates.json`);
732
845
  const annotated = join(workDir, '21-plan', `${runDate}_04_quality-control-content_annotated.json`);
846
+ const exclusions = join(workDir, '21-plan', `${runDate}_04_quality-control-content_exclusions.json`);
733
847
  const planContent = join(workDir, '21-plan', `${runDate}_03_plan_content.json`);
734
848
  timed(workDir, 'quality-control-content', () => {
735
849
  if (isFile(candidates)) {
@@ -760,6 +874,32 @@ function dailyQcContent(args: string[]): void {
760
874
  },
761
875
  );
762
876
  });
877
+ // Topic boundary (task 0189 R2) runs on every invocation — re-applying to already-filtered
878
+ // candidates is a no-op, so cached reruns stay idempotent while a crash between the candidates
879
+ // write and the exclusions write still converges on the next run. Zero kept docs leaves the
880
+ // candidates file empty for the workflow's existing done-no-content edge.
881
+ const boundaryDocs = metadataOf(readJson(candidates)).docs;
882
+ if (!Array.isArray(boundaryDocs)) return;
883
+ const { kept, excluded } = applyTopicBoundary(boundaryDocs, runDate, readTopicBoundary());
884
+ if (excluded.length === 0) return;
885
+ sted('write', exclusions, () =>
886
+ writeJsonText(exclusions, {
887
+ version: 1,
888
+ runDate,
889
+ excluded,
890
+ kept: kept.map((doc) => (doc as { id: string }).id),
891
+ }),
892
+ );
893
+ const content = requireJson(candidates, 'topic-boundary');
894
+ const meta = content.metadata;
895
+ if (meta === null || typeof meta !== 'object') {
896
+ failWith(1, 'topic-boundary: candidates content carries no metadata object');
897
+ }
898
+ (meta as Record<string, unknown>).docs = kept;
899
+ sted('write', candidates, () => writeJsonText(candidates, content));
900
+ console.log(
901
+ `topic-boundary: excluded ${excluded.length} item(s): ${excluded.map((entry) => `${entry.id}(${entry.rule})`).join(', ')}`,
902
+ );
763
903
  }
764
904
 
765
905
  /**
@@ -1090,7 +1230,8 @@ function xhsPlan(planPath: string, count: number): { title: string; body: string
1090
1230
  /**
1091
1231
  * E8: build one `image-gen` envelope per planned card, render them sequentially, and package the
1092
1232
  * ordered card manifest + `Content` that `xhs-pub` consumes. Format, style, provider and model
1093
- * are pinned here (never model-authored), so the series cannot drift from its preset. Resume-safe:
1233
+ * are pinned here (never model-authored), so the series cannot drift from its preset. Card copy
1234
+ * is typeset by image-gen's composer; the provider prompt stays text-free (0181). Resume-safe:
1094
1235
  * a card whose recorded artifact is still on disk is not re-rendered — and not re-billed.
1095
1236
  */
1096
1237
  function dailyXhsRender(args: string[]): void {
@@ -1124,7 +1265,6 @@ function dailyXhsRender(args: string[]): void {
1124
1265
  headline: card.copy,
1125
1266
  points: card.points.join('\n'),
1126
1267
  subject: card.subject,
1127
- language: '简体中文',
1128
1268
  index: card.index,
1129
1269
  },
1130
1270
  maxImages: 1,
@@ -1136,8 +1276,10 @@ function dailyXhsRender(args: string[]): void {
1136
1276
  { id: `xhs-card-${card.index}`, title: runDate, body: '', metadata: { imageGen } },
1137
1277
  ]),
1138
1278
  );
1139
- // Sequential on purpose: one provider call per card, and the first failure stops
1140
- // the spend — later cards stay unrendered for a resume run to pick up.
1279
+ // Sequential on purpose: one provider call per card (illustration only —
1280
+ // card copy is typeset by image-gen's composer, never prompted), and the
1281
+ // first failure stops the spend — later cards stay unrendered for a resume
1282
+ // run to pick up.
1141
1283
  kk(['executor', 'run', 'image-gen', '--in', envPath, '--out', out, ...pluginsArgs(pluginsPath)]);
1142
1284
  artifact = firstArtifact(requireJson(out, 'xhs render'));
1143
1285
  }
@@ -1148,6 +1290,15 @@ function dailyXhsRender(args: string[]): void {
1148
1290
  }
1149
1291
  // `image-gen` writes the artifact beside `--out`; the publisher requires absolute paths.
1150
1292
  const imagePath = resolve(recorded);
1293
+ // 0181: a composed card may also carry the text-free illustration layer; keep
1294
+ // its recorded path so the manifest shows both layers.
1295
+ const rawIllustration: unknown = artifact.illustration;
1296
+ const illustrationPath =
1297
+ typeof rawIllustration === 'object' &&
1298
+ rawIllustration !== null &&
1299
+ typeof (rawIllustration as Record<string, unknown>).path === 'string'
1300
+ ? resolve((rawIllustration as Record<string, unknown>).path as string)
1301
+ : undefined;
1151
1302
  sted('write', promptPath, () =>
1152
1303
  writeRaw(promptPath, typeof artifact.prompt === 'string' ? artifact.prompt : ''),
1153
1304
  );
@@ -1160,6 +1311,7 @@ function dailyXhsRender(args: string[]): void {
1160
1311
  layout: card.layout,
1161
1312
  promptPath,
1162
1313
  imagePath,
1314
+ illustrationPath,
1163
1315
  status: 'rendered',
1164
1316
  });
1165
1317
  }
@@ -1260,7 +1412,7 @@ function dailyPublishWechat(args: string[]): void {
1260
1412
  original: true,
1261
1413
  aiGenerated: true,
1262
1414
  author: '冰原奔狼',
1263
- collection: collectionArg || '每日AI资讯',
1415
+ collection: collectionArg || 'AI 乱炖',
1264
1416
  ...(isFile(cover) ? { coverPath: cover } : {}),
1265
1417
  // Audio block at the article top (operator request 2026-09-29): the daily MP3 goes to
1266
1418
  // the 素材库 under the article title; the plugin reuses an existing same-title material.
@@ -1333,6 +1485,9 @@ function dailyScript(args: string[]): void {
1333
1485
  env: {
1334
1486
  DAILYNEWS_LANGUAGE: language,
1335
1487
  DAILYNEWS_TITLE: title,
1488
+ // Task 0189: the broadcast greeting carries the episode's run date, not the render
1489
+ // date — a backfilled/retried run must not greet "today".
1490
+ DAILYNEWS_DATE: runDate,
1336
1491
  VOICEBOX_DEFAULT_PROFILE: voiceProfile,
1337
1492
  // F613 R4: always passed as absolute paths — spawnPluginEntry runs plugins with
1338
1493
  // cwd = the plugin package dir, so a relative workDir would silently miss the
@@ -1357,6 +1512,91 @@ function dailyScript(args: string[]): void {
1357
1512
  });
1358
1513
  }
1359
1514
 
1515
+ /** Task 0189 R10 artifacts of the content-safety verdict gate (24-script, step 09). */
1516
+ function safetyPath(workDir: string, runDate: string, name: string): string {
1517
+ return join(workDir, '24-script', `${runDate}_09_${name}`);
1518
+ }
1519
+
1520
+ /**
1521
+ * Digest of the exact drafts under review (article Content + VoiceScript). A verdict is only valid
1522
+ * for the bytes it was issued on: the gate and publish-prep both recompute this, so an edit after
1523
+ * PASS — including a HITL edit at `review` — invalidates the stamp instead of riding on it.
1524
+ */
1525
+ function safetyDigest(workDir: string, runDate: string): string {
1526
+ const hash = createHash('sha256');
1527
+ for (const path of [
1528
+ join(workDir, '22-article', `${runDate}_06_article_content.json`),
1529
+ join(workDir, '24-script', `${runDate}_08_script_voicescript.yaml`),
1530
+ ]) {
1531
+ if (!isFile(path)) failWith(1, `safety-review: required draft not found: ${path}`);
1532
+ hash.update(readFileSync(path)).update('\0');
1533
+ }
1534
+ return hash.digest('hex');
1535
+ }
1536
+
1537
+ /**
1538
+ * Open a content-safety review of the current drafts: drop every artifact of an earlier review
1539
+ * (a retry must be re-reviewed on its final version) and write the request the two agent.run
1540
+ * steps read. The program passes context only — platform policy lives in the kk:content-safety
1541
+ * skill's reference files, never here.
1542
+ */
1543
+ function dailySafetyPrep(args: string[]): void {
1544
+ const [workDir = '', runDate = '', platforms = ''] = args;
1545
+ timed(workDir, 'safety-review', () => {
1546
+ const digest = safetyDigest(workDir, runDate);
1547
+ for (const name of [
1548
+ 'safety-review_report.json',
1549
+ 'safety-judge_verdict.json',
1550
+ 'safety-judge_status.txt',
1551
+ 'safety-judge_stamp.json',
1552
+ ]) {
1553
+ const stale = safetyPath(workDir, runDate, name);
1554
+ if (isFile(stale)) unlinkSync(stale);
1555
+ }
1556
+ const request = safetyPath(workDir, runDate, 'safety-review_request.json');
1557
+ const targets = platforms
1558
+ .split(',')
1559
+ .map((platform) => platform.trim())
1560
+ .filter(Boolean);
1561
+ sted('write', request, () => writeJsonText(request, { digest, platforms: targets, mode: 'detect-only' }));
1562
+ });
1563
+ }
1564
+
1565
+ /**
1566
+ * Consume the content-safety report and the content-judge verdict into pass|escalate|block (exit 0
1567
+ * in all three — routing is transition-owned). Fail-closed: only an explicit PASS verdict on the
1568
+ * unchanged drafts, with a well-formed report carrying no BLOCK, unblocks; a RISK decision or
1569
+ * `escalate: true` forces the human review regardless of the verdict score.
1570
+ */
1571
+ function dailySafetyGate(args: string[]): void {
1572
+ const [workDir = '', runDate = ''] = args;
1573
+ const statusFile = safetyPath(workDir, runDate, 'safety-judge_status.txt');
1574
+ const stampFile = safetyPath(workDir, runDate, 'safety-judge_stamp.json');
1575
+ timed(workDir, 'safety-judge', () => {
1576
+ const request = readJson(safetyPath(workDir, runDate, 'safety-review_request.json'));
1577
+ const report = readJson(safetyPath(workDir, runDate, 'safety-review_report.json'));
1578
+ const verdict = readJson(safetyPath(workDir, runDate, 'safety-judge_verdict.json'));
1579
+ const platforms = Array.isArray(report?.platforms) ? (report.platforms as Record<string, unknown>[]) : [];
1580
+ const decisions = platforms.map((platform) => platform?.decision);
1581
+ const digest = safetyDigest(workDir, runDate);
1582
+ let status = 'block';
1583
+ let reason = 'verdict PASS on unchanged drafts, no BLOCK/RISK';
1584
+ if (request?.digest !== digest) reason = 'drafts changed after the review was requested';
1585
+ else if (decisions.length === 0 || decisions.some((d) => d !== 'OK' && d !== 'RISK' && d !== 'BLOCK'))
1586
+ reason = 'content-safety report missing or malformed';
1587
+ else if (decisions.includes('BLOCK')) reason = 'content-safety report carries a BLOCK decision';
1588
+ else if (verdict?.verdict !== 'PASS')
1589
+ reason = `content-judge verdict is ${typeof verdict?.verdict === 'string' ? verdict.verdict : 'missing or unreadable'}`;
1590
+ else if (decisions.includes('RISK') || report?.escalate === true) {
1591
+ status = 'escalate';
1592
+ reason = 'RISK decision or escalation — human review required';
1593
+ } else status = 'pass';
1594
+ if (status !== 'block') sted('write', stampFile, () => writeJsonText(stampFile, { digest, status }));
1595
+ sted('write', statusFile, () => writeRaw(statusFile, status));
1596
+ console.log(`safety: status=${status} (${reason}); routing is transition-owned`);
1597
+ });
1598
+ }
1599
+
1360
1600
  function dailyWrapDocs(args: string[]): void {
1361
1601
  const [workDir = '', runDate = '', voiceProfile = ''] = args;
1362
1602
  const scriptPath = join(workDir, '24-script', `${runDate}_08_script_voicescript.yaml`);
@@ -1632,6 +1872,13 @@ function dailyPublishPrep(args: string[]): void {
1632
1872
  coverPath: hasCover ? resolvedCover : undefined,
1633
1873
  },
1634
1874
  };
1875
+ // Task 0189 R10: the last program seam before anything is dispatched. No stamp, or a
1876
+ // stamp issued on different bytes (edited after PASS, or an older draft), never publishes.
1877
+ const stamp = readJson(safetyPath(workDir, runDate, 'safety-judge_stamp.json'));
1878
+ if (stamp === null) failWith(1, 'publish-prep: no content-safety verdict stamp for this run — not publishing');
1879
+ if (stamp.digest !== safetyDigest(workDir, runDate)) {
1880
+ failWith(1, 'publish-prep: drafts changed after the content-safety verdict — re-run the review');
1881
+ }
1635
1882
  sted('write', prepPath, () => writeJsonText(prepPath, merged));
1636
1883
  });
1637
1884
  }
@@ -1688,6 +1935,9 @@ function dailyShowNotes(args: string[]): void {
1688
1935
  PODCAST_PUB_RUN_DATE: runDate,
1689
1936
  PODCAST_PUB_BRIEF_MODE: 'true',
1690
1937
  PODCAST_PUB_ARTICLE_POST_PATH: postPath,
1938
+ // Task 0189 R1 / Apple §1.11: AI-voice disclosure in the episode metadata, not only
1939
+ // in the audio sign-off and the article footer.
1940
+ PODCAST_PUB_FOOTER_NOTE: '本期文字由 AI 辅助整理,音频为 AI 合成配音。',
1691
1941
  ...(isFile(audioContent) ? { PODCAST_PUB_AUDIO_CONTENT: audioContent } : {}),
1692
1942
  },
1693
1943
  });
@@ -1843,9 +2093,11 @@ function dailyPatchSurfdash(args: string[]): void {
1843
2093
  const localCover = join(workDir, '23-cover', `${runDate}_07_cover_cover.png`);
1844
2094
  const dateIso = `${runDate.slice(0, 4)}-${runDate.slice(4, 6)}-${runDate.slice(6, 8)}`;
1845
2095
  const crossLink: Record<string, string> = {
1846
- zh: `🎧 本期已同步制作为播客节目,[点击收听《每日 AI 资讯 · ${dateIso}》](${showNotesUrl})。`,
1847
- en: `🎧 This episode is also available as a podcast: [listen to the Daily AI Briefing · ${dateIso}](${showNotesUrl}).`,
1848
- ja: `🎧 本エピソードはポッドキャストでも配信中:[Daily AI Briefing · ${dateIso} を聴く](${showNotesUrl})。`,
2096
+ // Task 0189 R4: cross-links carry the column's episode-title pattern, not the retired
2097
+ // news-bulletin name (the VoiceScript episode title is episodeTitle(lead, date)).
2098
+ zh: `🎧 本期已同步制作为播客节目,[点击收听《AI 乱炖 · ${dateIso}》](${showNotesUrl})。`,
2099
+ en: `🎧 This episode is also available as a podcast: [listen to AI 乱炖 · ${dateIso}](${showNotesUrl}).`,
2100
+ ja: `🎧 本エピソードはポッドキャストでも配信中:[AI 乱炖 · ${dateIso} を聴く](${showNotesUrl})。`,
1849
2101
  };
1850
2102
  let changed = 0;
1851
2103
  for (const [locale, dir] of [
@@ -2109,7 +2361,8 @@ function dailyTranslateSync(args: string[]): void {
2109
2361
 
2110
2362
  const [stage, ...rest] = process.argv.slice(2);
2111
2363
 
2112
- // Imported as a module (unit tests reach `dedupPlanByTitle`) → exports only, never a stage run;
2364
+ // Imported as a module (unit tests reach `dedupPlanByTitle` / `applyTopicBoundary`) → exports
2365
+ // only, never a stage run;
2113
2366
  // executed as the entrypoint → dispatch.
2114
2367
  if (import.meta.main)
2115
2368
  switch (stage) {
@@ -2192,6 +2445,12 @@ if (import.meta.main)
2192
2445
  case 'daily-script':
2193
2446
  dailyScript(rest);
2194
2447
  break;
2448
+ case 'daily-safety-prep':
2449
+ dailySafetyPrep(rest);
2450
+ break;
2451
+ case 'daily-safety-gate':
2452
+ dailySafetyGate(rest);
2453
+ break;
2195
2454
  case 'daily-wrap-docs':
2196
2455
  dailyWrapDocs(rest);
2197
2456
  break;
@@ -0,0 +1,105 @@
1
+ {
2
+ "version": 1,
3
+ "note": "Task 0189 R2 topic admission for the AI 乱炖 column. Excludes items whose mainline is political/diplomatic-trade, military, public-policy dispute, or a social public event; records every exclusion with id, rule, reason and the matched keyword. Sources are never deleted or modified. Scan covers the title plus the first scanChars chars of the body. Precision first: these keywords are a floor, not a sieve — a rare false negative is caught by the script-write/article-write topic-boundary prompts and the content-safety skill review, and a suspected false positive is restored by hand with a recorded reason (manual review), never silently.",
4
+ "scanChars": 200,
5
+ "rules": [
6
+ {
7
+ "id": "political-diplomatic",
8
+ "reason": "政治/外交主线:涉及政治人物、政府机构、选举、外交或双边关系的条目,不属于 AI 乱炖的技术分享范围(0189 R2)。",
9
+ "keywords": [
10
+ "领导人",
11
+ "主席访",
12
+ "总书记",
13
+ "国务院总理",
14
+ "外交部",
15
+ "大使馆",
16
+ "国事访问",
17
+ "政治局",
18
+ "中南海",
19
+ "总统",
20
+ "白宫",
21
+ "国会",
22
+ "议会",
23
+ "选举",
24
+ "台海",
25
+ "两岸关系",
26
+ "中美关系",
27
+ "president",
28
+ "parliament",
29
+ "election",
30
+ "diplomat"
31
+ ]
32
+ },
33
+ {
34
+ "id": "trade-controls",
35
+ "reason": "外交/贸易管制主线:制裁、关税、出口管制、实体清单等政府间管制动作属于公共事务,不是技术实践(0189 R2;涉管制的技术影响可在人工复审后改写为纯技术内容)。",
36
+ "keywords": [
37
+ "制裁",
38
+ "关税",
39
+ "出口管制",
40
+ "实体清单",
41
+ "贸易战",
42
+ "禁令",
43
+ "制裁清单",
44
+ "sanctions",
45
+ "tariff",
46
+ "export control",
47
+ "trade war",
48
+ "entity list"
49
+ ]
50
+ },
51
+ {
52
+ "id": "military",
53
+ "reason": "军事主线:军队、武器、作战相关的条目一律排除(0189 R2)。",
54
+ "keywords": [
55
+ "军队",
56
+ "军事",
57
+ "解放军",
58
+ "武器",
59
+ "导弹",
60
+ "无人机袭击",
61
+ "战场",
62
+ "military",
63
+ "pentagon",
64
+ "missile"
65
+ ]
66
+ },
67
+ {
68
+ "id": "policy-dispute",
69
+ "reason": "公共政策争议主线:立法、听证、反垄断等政策博弈是公共事务主线,不是技术内容(0189 R2;纯技术子话题需人工复审并重写后才可使用)。",
70
+ "keywords": [
71
+ "立法",
72
+ "听证",
73
+ "政策争议",
74
+ "反垄断",
75
+ "监管机构",
76
+ "监管新规",
77
+ "法案",
78
+ "regulation",
79
+ "antitrust",
80
+ "legislation",
81
+ "lawsuit"
82
+ ]
83
+ },
84
+ {
85
+ "id": "social-event",
86
+ "reason": "社会公共事件主线:事故、灾害、冲突现场等社会事件的报道不属于技术分享(0189 R2)。",
87
+ "keywords": [
88
+ "社会事件",
89
+ "踩踏",
90
+ "骚乱",
91
+ "抗议",
92
+ "示威",
93
+ "罢工",
94
+ "遇难",
95
+ "伤亡",
96
+ "protest",
97
+ "riot",
98
+ "casualties"
99
+ ]
100
+ }
101
+ ],
102
+ "dateConflict": {
103
+ "reason": "日期自相矛盾:条目 publishedAt 晚于本期运行日期(如聚合源携带的未来日期草稿),依据 0189 R3 不得作为事实依据。"
104
+ }
105
+ }
@@ -136,6 +136,16 @@ const CONTENT_SCAFFOLD_LABEL_RE =
136
136
  const CONTENT_TAG_TAIL_RE = /标签[::,,][^\n。!?…]*$/;
137
137
  /** `wss,//` / `https。//` — the translator replacing a URL colon with a Chinese comma. */
138
138
  const CONTENT_URL_MANGLE_RE = /[,。]\/\//;
139
+ /** Retired pre-0189 positioning names (task 0189 R4): no production path — fresh build, cached
140
+ * re-render, or HITL-edited script — may speak an old news-bulletin column name. Exact
141
+ * substrings; brand-consistency gate, not a platform sensitive-word table. */
142
+ const RETIRED_POSITIONING_NAMES = [
143
+ '每日AI资讯',
144
+ '每日 AI 资讯',
145
+ '今日AI科技漫谈',
146
+ 'AI科技漫谈',
147
+ 'AI Daily Briefing',
148
+ ] as const;
139
149
 
140
150
  function assertSegmentContent(script: VoiceScript): void {
141
151
  const seen = new Map<string, number>();
@@ -171,6 +181,11 @@ function assertSegmentContent(script: VoiceScript): void {
171
181
  if (CONTENT_URL_MANGLE_RE.test(text)) {
172
182
  throw new Error(`content check failed: segment ${idx} has a colon-mangled URL`);
173
183
  }
184
+ for (const name of RETIRED_POSITIONING_NAMES) {
185
+ if (text.includes(name)) {
186
+ throw new Error(`content check failed: segment ${idx} carries retired positioning name "${name}"`);
187
+ }
188
+ }
174
189
  }
175
190
  }
176
191