@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.
- 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 +27 -7
- package/plugins/generations/dailynews-gen/src/index.ts +29 -1
- package/plugins/generations/dailynews-gen/src/script-builder.ts +15 -12
- 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 +591 -15
- 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/kk/plugin.json +1 -1
- package/plugins/kk/scripts/daily-voice-style.zh.md +25 -12
- package/plugins/kk/scripts/kk-workflow-stages.ts +268 -9
- 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 +103 -13
- 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
|
@@ -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
|
}
|
|
@@ -1260,7 +1412,7 @@ function dailyPublishWechat(args: string[]): void {
|
|
|
1260
1412
|
original: true,
|
|
1261
1413
|
aiGenerated: true,
|
|
1262
1414
|
author: '冰原奔狼',
|
|
1263
|
-
collection: collectionArg || '
|
|
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
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
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
|
|
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
|
|