@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/package.json +1 -1
- package/plugins/generations/daily-article-gen/dist/index.js +27 -4
- package/plugins/generations/daily-article-gen/src/index.ts +34 -3
- package/plugins/generations/dailynews-gen/dist/index.js +42 -19
- package/plugins/generations/dailynews-gen/src/index.ts +29 -1
- package/plugins/generations/dailynews-gen/src/script-builder.ts +33 -21
- package/plugins/generations/episode-plan-gen/dist/index.js +7 -0
- package/plugins/generations/episode-plan-gen/src/index.ts +12 -0
- package/plugins/generations/image-gen/assets/fonts/NotoSansSC-Bold.otf +0 -0
- package/plugins/generations/image-gen/assets/fonts/NotoSansSC-Regular.otf +0 -0
- package/plugins/generations/image-gen/assets/fonts/OFL.txt +92 -0
- package/plugins/generations/image-gen/assets/fonts/SOURCE.md +15 -0
- package/plugins/generations/image-gen/dist/index.js +597 -16
- package/plugins/generations/image-gen/package.json +1 -0
- package/plugins/generations/image-gen/presets/formats/daily-news-cover.yaml +2 -2
- package/plugins/generations/image-gen/presets/formats/xhs-card.yaml +19 -11
- package/plugins/generations/image-gen/presets/styles/xhs-knowledge.yaml +2 -1
- package/plugins/generations/image-gen/scripts/compose-e2e-artifact.ts +135 -0
- package/plugins/generations/image-gen/src/bytes.ts +28 -0
- package/plugins/generations/image-gen/src/compose/card.ts +294 -0
- package/plugins/generations/image-gen/src/compose/chromium.ts +147 -0
- package/plugins/generations/image-gen/src/compose/cmap.ts +177 -0
- package/plugins/generations/image-gen/src/index.ts +167 -9
- package/plugins/generations/image-gen/src/job.ts +6 -0
- package/plugins/generations/image-gen/src/presets.ts +53 -13
- package/plugins/generations/image-gen/src/providers/google.ts +9 -1
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/scripts/daily-voice-style.zh.md +25 -12
- package/plugins/kk/scripts/kk-workflow-stages.ts +344 -11
- package/plugins/kk/scripts/topic-boundary.json +105 -0
- package/plugins/kk/scripts/validate-voicescript.ts +15 -0
- package/plugins/kk/skills/content-safety/SKILL.md +163 -0
- package/plugins/kk/skills/content-safety/references/platforms/apple-podcasts.md +36 -0
- package/plugins/kk/skills/content-safety/references/platforms/netease-music.md +31 -0
- package/plugins/kk/skills/content-safety/references/platforms/wechatmp.md +32 -0
- package/plugins/kk/skills/content-safety/references/platforms/xiaoyuzhou.md +35 -0
- package/plugins/kk/skills/xhs-images/SKILL.md +11 -7
- package/plugins/kk/skills/xhs-images/references/card-layouts.md +4 -4
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +118 -16
- package/plugins/publishings/podcast-pub/dist/index.js +4 -0
- package/plugins/publishings/podcast-pub/src/index.ts +2 -0
- package/plugins/publishings/podcast-pub/src/show-notes.ts +6 -0
- package/plugins/publishings/wechatmp-pub/dist/index.js +272 -115
- package/plugins/publishings/wechatmp-pub/src/index.ts +336 -40
- package/plugins/publishings/wechatmp-pub/src/profile-check.ts +21 -9
- package/plugins/publishings/xhs-pub/dist/index.js +28 -22
- package/plugins/publishings/xhs-pub/src/index.ts +30 -22
package/plugins/kk/plugin.json
CHANGED
|
@@ -1,33 +1,41 @@
|
|
|
1
|
-
#
|
|
1
|
+
# AI 乱炖 · 主持风格契约(zh)
|
|
2
2
|
|
|
3
|
-
`kk-daily-ai-voice` 的每一句口播都受这份契约约束(F613 R1
|
|
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
|
-
-
|
|
23
|
+
- 开场:`大家好,今天是<日期>,欢迎收听「咸话咸说」的「AI 乱炖」栏目,我是<主持人>。`
|
|
17
24
|
- 收尾:`感谢您的收听,我是<主持人>,祝您拥有充实高效的一天,我们下期节目不见不散!`
|
|
18
25
|
|
|
19
26
|
## 段落角色
|
|
20
27
|
|
|
21
|
-
- `intro
|
|
22
|
-
- `lead
|
|
23
|
-
- `insight
|
|
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
|
-
点评、旧模板的 首先/接着/最后
|
|
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.
|
|
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
|
|
1140
|
-
//
|
|
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
|
}
|
|
@@ -1233,7 +1385,9 @@ function dailyPublishXhs(args: string[]): void {
|
|
|
1233
1385
|
* outcome never enters `publish_status` (surfdash + podcast remain the publication contract).
|
|
1234
1386
|
*/
|
|
1235
1387
|
function dailyPublishWechat(args: string[]): void {
|
|
1236
|
-
const [
|
|
1388
|
+
const [workDirRaw = '', runDate = '', pluginsPath = '', live = 'false', collectionArg = ''] = args;
|
|
1389
|
+
// Metadata carries file paths consumed by the plugin child process (cwd = plugin dir) - never emit relatives.
|
|
1390
|
+
const workDir = resolve(workDirRaw);
|
|
1237
1391
|
const article = join(workDir, '22-article', `${runDate}_06_article_content.json`);
|
|
1238
1392
|
const input = join(workDir, '31-publish', `${runDate}_20_publish-wechat_content.json`);
|
|
1239
1393
|
const result = join(workDir, '31-publish', `${runDate}_20_publish-wechat_result.json`);
|
|
@@ -1250,8 +1404,35 @@ function dailyPublishWechat(args: string[]): void {
|
|
|
1250
1404
|
return;
|
|
1251
1405
|
}
|
|
1252
1406
|
const packaged = requireJson(article, 'publish-wechat');
|
|
1407
|
+
// MP draft enrichment (operator preference 2026-09-29): 原创+作者, 赞赏, 合集, 原文链接
|
|
1408
|
+
// (the surfdash post), AI 生成声明, cover. The brand strings are this daily show's constants.
|
|
1409
|
+
const cover = join(workDir, '23-cover', `${runDate}_07_cover_cover.png`);
|
|
1410
|
+
const audio = join(workDir, '30-audio', `${runDate}_11_generate_content.mp3`);
|
|
1411
|
+
const wechatmp: Record<string, unknown> = {
|
|
1412
|
+
original: true,
|
|
1413
|
+
aiGenerated: true,
|
|
1414
|
+
author: '冰原奔狼',
|
|
1415
|
+
collection: collectionArg || 'AI 乱炖',
|
|
1416
|
+
...(isFile(cover) ? { coverPath: cover } : {}),
|
|
1417
|
+
// Audio block at the article top (operator request 2026-09-29): the daily MP3 goes to
|
|
1418
|
+
// the 素材库 under the article title; the plugin reuses an existing same-title material.
|
|
1419
|
+
...(isFile(audio)
|
|
1420
|
+
? {
|
|
1421
|
+
audioPath: audio,
|
|
1422
|
+
audioTitle: typeof packaged.title === 'string' ? packaged.title : '',
|
|
1423
|
+
audioCoverPath: isFile(cover) ? cover : '',
|
|
1424
|
+
}
|
|
1425
|
+
: {}),
|
|
1426
|
+
};
|
|
1427
|
+
// WeChat-only closing line (operator request 2026-09-29): point readers/listeners back to
|
|
1428
|
+
// the show's platforms. Appended to the wechat input body only, never the master article.
|
|
1429
|
+
const outro = '\n\n欲听往期节目,可至小宇宙、Apple Podcast、YouTube 等平台搜索【咸话咸说】。';
|
|
1253
1430
|
sted('write', input, () =>
|
|
1254
|
-
writeJsonText(input, {
|
|
1431
|
+
writeJsonText(input, {
|
|
1432
|
+
...packaged,
|
|
1433
|
+
body: `${typeof packaged.body === 'string' ? packaged.body : ''}${outro}`,
|
|
1434
|
+
metadata: { ...metadataOf(packaged), publishStatus: want, wechatmp },
|
|
1435
|
+
}),
|
|
1255
1436
|
);
|
|
1256
1437
|
// `soft` keeps the WeChat target non-blocking: the plugin still writes its own failed Result
|
|
1257
1438
|
// and prints the failing step (session preflight, editor paste, save control), which is what
|
|
@@ -1304,6 +1485,9 @@ function dailyScript(args: string[]): void {
|
|
|
1304
1485
|
env: {
|
|
1305
1486
|
DAILYNEWS_LANGUAGE: language,
|
|
1306
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,
|
|
1307
1491
|
VOICEBOX_DEFAULT_PROFILE: voiceProfile,
|
|
1308
1492
|
// F613 R4: always passed as absolute paths — spawnPluginEntry runs plugins with
|
|
1309
1493
|
// cwd = the plugin package dir, so a relative workDir would silently miss the
|
|
@@ -1328,6 +1512,91 @@ function dailyScript(args: string[]): void {
|
|
|
1328
1512
|
});
|
|
1329
1513
|
}
|
|
1330
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
|
+
|
|
1331
1600
|
function dailyWrapDocs(args: string[]): void {
|
|
1332
1601
|
const [workDir = '', runDate = '', voiceProfile = ''] = args;
|
|
1333
1602
|
const scriptPath = join(workDir, '24-script', `${runDate}_08_script_voicescript.yaml`);
|
|
@@ -1575,6 +1844,17 @@ function dailyPublishPrep(args: string[]): void {
|
|
|
1575
1844
|
stampId3Tags(audioFile, typeof article.title === 'string' ? article.title : '', runDate, artist, album);
|
|
1576
1845
|
const resolvedCover = resolve(coverPath);
|
|
1577
1846
|
const hasCover = existsSync(resolvedCover);
|
|
1847
|
+
// Daily-show itunes convention: season = year, episode = day-of-year. run_date arrives
|
|
1848
|
+
// dashed (`$(date +%F)`) or digits-only (formatRunDate); map.ts forwards numbers only,
|
|
1849
|
+
// so a malformed run_date omits both tags instead of failing the publish.
|
|
1850
|
+
const digits = runDate.replaceAll(/\D/g, '');
|
|
1851
|
+
const season = Number(digits.slice(0, 4));
|
|
1852
|
+
const month = Number(digits.slice(4, 6));
|
|
1853
|
+
const day = Number(digits.slice(6, 8));
|
|
1854
|
+
const episode =
|
|
1855
|
+
digits.length === 8 && month >= 1 && month <= 12 && day >= 1 && day <= 31
|
|
1856
|
+
? Math.round((Date.UTC(season, month - 1, day) - Date.UTC(season, 0, 1)) / 86_400_000) + 1
|
|
1857
|
+
: 0;
|
|
1578
1858
|
// podcast-pub's documented contract reads metadata.coverImage (map.ts); options.coverPath
|
|
1579
1859
|
// alone was silently dropped by the mapper — episodes published without a cover.
|
|
1580
1860
|
const merged = {
|
|
@@ -1584,6 +1864,7 @@ function dailyPublishPrep(args: string[]): void {
|
|
|
1584
1864
|
audioFile,
|
|
1585
1865
|
runDate,
|
|
1586
1866
|
...(hasCover ? { coverImage: resolvedCover } : {}),
|
|
1867
|
+
...(episode > 0 ? { itunes: { season, episode } } : {}),
|
|
1587
1868
|
},
|
|
1588
1869
|
options: {
|
|
1589
1870
|
audioFile,
|
|
@@ -1591,6 +1872,13 @@ function dailyPublishPrep(args: string[]): void {
|
|
|
1591
1872
|
coverPath: hasCover ? resolvedCover : undefined,
|
|
1592
1873
|
},
|
|
1593
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
|
+
}
|
|
1594
1882
|
sted('write', prepPath, () => writeJsonText(prepPath, merged));
|
|
1595
1883
|
});
|
|
1596
1884
|
}
|
|
@@ -1647,6 +1935,9 @@ function dailyShowNotes(args: string[]): void {
|
|
|
1647
1935
|
PODCAST_PUB_RUN_DATE: runDate,
|
|
1648
1936
|
PODCAST_PUB_BRIEF_MODE: 'true',
|
|
1649
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 合成配音。',
|
|
1650
1941
|
...(isFile(audioContent) ? { PODCAST_PUB_AUDIO_CONTENT: audioContent } : {}),
|
|
1651
1942
|
},
|
|
1652
1943
|
});
|
|
@@ -1765,7 +2056,7 @@ function dailyPublishPartial(args: string[]): void {
|
|
|
1765
2056
|
* Idempotent per post; commits + pushes the surfdash checkout (SURFDASH_AUTO_PUSH=0 keeps commit-only).
|
|
1766
2057
|
*/
|
|
1767
2058
|
function dailyPatchSurfdash(args: string[]): void {
|
|
1768
|
-
const [workDir = '', runDate = '', surfdashRoot = ''] = args;
|
|
2059
|
+
const [workDir = '', runDate = '', surfdashRoot = '', pluginsPath = ''] = args;
|
|
1769
2060
|
if (!surfdashRoot || !existsSync(surfdashRoot)) {
|
|
1770
2061
|
console.log(`patch-surfdash: surfdash root '${surfdashRoot}' not found — skip`);
|
|
1771
2062
|
return;
|
|
@@ -1802,9 +2093,11 @@ function dailyPatchSurfdash(args: string[]): void {
|
|
|
1802
2093
|
const localCover = join(workDir, '23-cover', `${runDate}_07_cover_cover.png`);
|
|
1803
2094
|
const dateIso = `${runDate.slice(0, 4)}-${runDate.slice(4, 6)}-${runDate.slice(6, 8)}`;
|
|
1804
2095
|
const crossLink: Record<string, string> = {
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
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})。`,
|
|
1808
2101
|
};
|
|
1809
2102
|
let changed = 0;
|
|
1810
2103
|
for (const [locale, dir] of [
|
|
@@ -1866,6 +2159,39 @@ function dailyPatchSurfdash(args: string[]): void {
|
|
|
1866
2159
|
changed++;
|
|
1867
2160
|
}
|
|
1868
2161
|
}
|
|
2162
|
+
// 20260929 prod feedback: CMS cards (homepage hero/tag/search) read EmDash rows whose
|
|
2163
|
+
// featured_image is snapshotted at translate-sync time — i.e. the pre-patch
|
|
2164
|
+
// `./assets/cover.png`, which the deployed worker does not serve (404). The patch only
|
|
2165
|
+
// rewrites files, so re-sync the EmDash rows from the patched files through the same
|
|
2166
|
+
// `kk executor` path as translate-sync (its plugin child gets EMDASH_API_TOKEN from kk
|
|
2167
|
+
// config; a direct orchestrator spawn gets 403 from prod dev-bypass). Runs before the
|
|
2168
|
+
// changed===0 early-return so re-runs heal rows an earlier commit left stale
|
|
2169
|
+
// (20260925/20260929). Fixture tests pass no plugins-path and must not shell out.
|
|
2170
|
+
if (pluginsPath === '') {
|
|
2171
|
+
console.log('patch-surfdash: no plugins-path — skip emdash re-sync');
|
|
2172
|
+
} else {
|
|
2173
|
+
const syncIn = join(workDir, '31-publish', `${runDate}_23_patch-surfdash_sync.json`);
|
|
2174
|
+
const syncResult = join(workDir, '31-publish', `${runDate}_23_patch-surfdash_sync-result.json`);
|
|
2175
|
+
sted('write', syncIn, () =>
|
|
2176
|
+
writeJqText(syncIn, { body: '', options: { operation: 'sync', postPath: join(slugDir, 'index.md') } }),
|
|
2177
|
+
);
|
|
2178
|
+
const status = kk(
|
|
2179
|
+
['executor', 'run', 'surfdash-pub', '--in', syncIn, '--out', syncResult, ...pluginsArgs(pluginsPath)],
|
|
2180
|
+
{ unset: NESTED_SPUR_UNSET, soft: true },
|
|
2181
|
+
);
|
|
2182
|
+
const result = isFile(syncResult) ? readJson(syncResult) : null;
|
|
2183
|
+
const ok =
|
|
2184
|
+
status === 0 &&
|
|
2185
|
+
result !== null &&
|
|
2186
|
+
(result.ok === true || (result.targets as Array<{ ok?: boolean }> | undefined)?.[0]?.ok === true);
|
|
2187
|
+
if (!ok) {
|
|
2188
|
+
console.log(
|
|
2189
|
+
`patch-surfdash: WARNING: emdash re-sync failed (exit ${status}) — CMS cards keep the stale cover; fix: kk executor run surfdash-pub --in ${syncIn} --out ${syncResult} --plugins-path ${pluginsPath}`,
|
|
2190
|
+
);
|
|
2191
|
+
} else {
|
|
2192
|
+
console.log('patch-surfdash: emdash rows re-synced from patched files');
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
1869
2195
|
if (changed === 0) {
|
|
1870
2196
|
console.log('patch-surfdash: nothing to change');
|
|
1871
2197
|
return;
|
|
@@ -2035,7 +2361,8 @@ function dailyTranslateSync(args: string[]): void {
|
|
|
2035
2361
|
|
|
2036
2362
|
const [stage, ...rest] = process.argv.slice(2);
|
|
2037
2363
|
|
|
2038
|
-
// Imported as a module (unit tests reach `dedupPlanByTitle`) → exports
|
|
2364
|
+
// Imported as a module (unit tests reach `dedupPlanByTitle` / `applyTopicBoundary`) → exports
|
|
2365
|
+
// only, never a stage run;
|
|
2039
2366
|
// executed as the entrypoint → dispatch.
|
|
2040
2367
|
if (import.meta.main)
|
|
2041
2368
|
switch (stage) {
|
|
@@ -2118,6 +2445,12 @@ if (import.meta.main)
|
|
|
2118
2445
|
case 'daily-script':
|
|
2119
2446
|
dailyScript(rest);
|
|
2120
2447
|
break;
|
|
2448
|
+
case 'daily-safety-prep':
|
|
2449
|
+
dailySafetyPrep(rest);
|
|
2450
|
+
break;
|
|
2451
|
+
case 'daily-safety-gate':
|
|
2452
|
+
dailySafetyGate(rest);
|
|
2453
|
+
break;
|
|
2121
2454
|
case 'daily-wrap-docs':
|
|
2122
2455
|
dailyWrapDocs(rest);
|
|
2123
2456
|
break;
|