@gobing-ai/knowledge-kit 0.0.15 → 0.0.17
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 +41 -1
- package/plugins/generations/daily-article-gen/src/index.ts +68 -2
- package/plugins/generations/dailynews-gen/dist/index.js +90 -31
- package/plugins/generations/dailynews-gen/src/script-builder.ts +133 -33
- package/plugins/generations/episode-plan-gen/dist/index.js +4 -2
- package/plugins/generations/episode-plan-gen/src/index.ts +9 -2
- package/plugins/generations/news-report-gen/dist/index.js +48 -2
- package/plugins/generations/news-report-gen/src/index.ts +83 -7
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/pipeline.py +16 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/qc.py +4 -22
- package/plugins/generations/voice-gen/dist/index.js +35 -0
- package/plugins/generations/voice-gen/src/voicescript.ts +53 -0
- package/plugins/kk/commands/workflow-run.md +25 -2
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/scripts/kk-workflow-stages.ts +208 -107
- package/plugins/kk/scripts/validate-voicescript.ts +55 -0
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +20 -15
|
@@ -50,6 +50,10 @@ const VALIDATE_SIDECAR = 'validate-voicescript.ts';
|
|
|
50
50
|
const WRAP_SIDECAR = 'wrap-voicescript-doc.ts';
|
|
51
51
|
const RENDER_SIDECAR = 'render-md.ts';
|
|
52
52
|
const REVISE_COUNT = '.revise-count';
|
|
53
|
+
// Post-translation title-dedup threshold (task 0138 R3). ponytail: the 2026-09-16 Gemini 3.8 Live
|
|
54
|
+
// pair measures 0.417 zh title-only Jaccard, the nearest distinct pair 0.1 (4x margin). Character
|
|
55
|
+
// bigrams need same-language input — pre-translate dedup over the mixed-language blend cannot work.
|
|
56
|
+
const DEDUP_TITLE_THRESHOLD = 0.4;
|
|
53
57
|
|
|
54
58
|
function fail(message: string): never {
|
|
55
59
|
console.error(message);
|
|
@@ -643,6 +647,72 @@ function dailyQcContent(args: string[]): void {
|
|
|
643
647
|
});
|
|
644
648
|
}
|
|
645
649
|
|
|
650
|
+
/**
|
|
651
|
+
* Character-bigram Jaccard over whitespace-normalized, lowercased titles — the twin of
|
|
652
|
+
* episode-plan-gen's `titleSimilarity`. Duplicated on purpose: this file is node-builtins-only
|
|
653
|
+
* (no workspace imports), so it cannot reach that module.
|
|
654
|
+
*/
|
|
655
|
+
function titleSimilarity(a: string, b: string): number {
|
|
656
|
+
const bigrams = (text: string): Set<string> => {
|
|
657
|
+
const normalized = text.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
658
|
+
const out = new Set<string>();
|
|
659
|
+
for (let i = 0; i < normalized.length - 1; i++) out.add(normalized.slice(i, i + 2));
|
|
660
|
+
return out;
|
|
661
|
+
};
|
|
662
|
+
const aGrams = bigrams(a);
|
|
663
|
+
const bGrams = bigrams(b);
|
|
664
|
+
if (aGrams.size === 0 || bGrams.size === 0) return 0;
|
|
665
|
+
let intersection = 0;
|
|
666
|
+
for (const gram of aGrams) if (bGrams.has(gram)) intersection++;
|
|
667
|
+
return intersection / (aGrams.size + bGrams.size - intersection);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Post-translation near-duplicate dedup (task 0138 R3): drop any plan item whose title is a
|
|
672
|
+
* >=DEDUP_TITLE_THRESHOLD bigram match for an EARLIER item, so curated plan order decides the
|
|
673
|
+
* survivor. It runs on the translated plan because cross-language near-dups are invisible before
|
|
674
|
+
* plan-translate (the 2026-09-16 Gemini pair measures 0.208 mixed-language, 0.417 all-zh).
|
|
675
|
+
*/
|
|
676
|
+
export function dedupPlanByTitle<T extends { id?: unknown; title?: unknown }>(
|
|
677
|
+
docs: T[],
|
|
678
|
+
): { kept: T[]; dropped: string[] } {
|
|
679
|
+
const kept: T[] = [];
|
|
680
|
+
const dropped: string[] = [];
|
|
681
|
+
for (const doc of docs) {
|
|
682
|
+
const title = typeof doc.title === 'string' ? doc.title : '';
|
|
683
|
+
const duplicate =
|
|
684
|
+
title !== '' &&
|
|
685
|
+
kept.some((earlier) => {
|
|
686
|
+
const earlierTitle = typeof earlier.title === 'string' ? earlier.title : '';
|
|
687
|
+
return titleSimilarity(earlierTitle, title) >= DEDUP_TITLE_THRESHOLD;
|
|
688
|
+
});
|
|
689
|
+
if (duplicate) {
|
|
690
|
+
dropped.push(typeof doc.id === 'string' && doc.id !== '' ? doc.id : title);
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
kept.push(doc);
|
|
694
|
+
}
|
|
695
|
+
return { kept, dropped };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Run the VoiceScript content validator over a written script and fail the stage when it rejects
|
|
700
|
+
* (task 0139 R3). `spawnSync` rather than `delegate` on purpose: `delegate` exits the process
|
|
701
|
+
* directly, which would skip `timed`'s `finally` and lose the step-timing entry the run report
|
|
702
|
+
* reads. The sidecar's own stderr carries `content check failed: segment <idx> …`; this adds the
|
|
703
|
+
* stage-level naming so the operator sees which artifact was rejected.
|
|
704
|
+
*/
|
|
705
|
+
function assertScriptContent(scriptPath: string): void {
|
|
706
|
+
// `spawnSync` + `failWith`, deliberately NOT `delegate`: delegate is `never`-returning (it calls
|
|
707
|
+
// process.exit), so using it mid-function would abort the calling stage on SUCCESS — dailyWrapDocs
|
|
708
|
+
// would exit before packaging its docs. A clean exit here falls through to the rest of the stage.
|
|
709
|
+
const sidecar = requireSidecar('', VALIDATE_SIDECAR, 'validate_script');
|
|
710
|
+
const runner = spawnSync(process.execPath, [sidecar, '--in', scriptPath], { stdio: 'inherit' });
|
|
711
|
+
if (runner.status !== 0) {
|
|
712
|
+
failWith(runner.status ?? 1, `script content check failed: ${scriptPath} was rejected (see above)`);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
646
716
|
function dailyArticle(args: string[]): void {
|
|
647
717
|
const [workDir = '', runDate = '', pluginsPath = ''] = args;
|
|
648
718
|
const articleMd = join(workDir, '2-article', `${runDate}_06_article_article.md`);
|
|
@@ -650,10 +720,6 @@ function dailyArticle(args: string[]): void {
|
|
|
650
720
|
const planIn = join(workDir, '2-plan', `${runDate}_03_plan_plan.json`);
|
|
651
721
|
const candidates = join(workDir, '2-plan', `${runDate}_04_quality-control-content_candidates.json`);
|
|
652
722
|
timed(workDir, 'article', () => {
|
|
653
|
-
if (isFile(articleMd)) {
|
|
654
|
-
console.log(`article cached: ${articleMd}`);
|
|
655
|
-
return;
|
|
656
|
-
}
|
|
657
723
|
let planUsable = true;
|
|
658
724
|
const raw = isFile(planIn) ? readFileSync(planIn, 'utf-8') : '';
|
|
659
725
|
try {
|
|
@@ -671,6 +737,21 @@ function dailyArticle(args: string[]): void {
|
|
|
671
737
|
}
|
|
672
738
|
}
|
|
673
739
|
if (!planUsable) failWith(1, 'article: no usable plan input');
|
|
740
|
+
// R3 (task 0138): dedup runs BEFORE the cache early-return — the script stage reads this same
|
|
741
|
+
// plan.json and must see one survivor even when the article is already cached. Idempotent:
|
|
742
|
+
// a no-op once the plan is deduped.
|
|
743
|
+
const planParsed: unknown = readJson(planIn);
|
|
744
|
+
if (Array.isArray(planParsed)) {
|
|
745
|
+
const { kept, dropped } = dedupPlanByTitle(planParsed as Record<string, unknown>[]);
|
|
746
|
+
if (dropped.length > 0) {
|
|
747
|
+
sted('write', planIn, () => writeJsonText(planIn, kept));
|
|
748
|
+
console.error(`article: dropped ${dropped.length} near-duplicate plan item(s): ${dropped.join(', ')}`);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
if (isFile(articleMd)) {
|
|
752
|
+
console.log(`article cached: ${articleMd}`);
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
674
755
|
kk(
|
|
675
756
|
[
|
|
676
757
|
'executor',
|
|
@@ -753,6 +834,9 @@ function dailyScript(args: string[]): void {
|
|
|
753
834
|
const body = readJson(contentOut)?.body;
|
|
754
835
|
if (typeof body !== 'string') failWith(1, `script: ${contentOut} has no string body`);
|
|
755
836
|
sted('write', yamlOut, () => writeRaw(yamlOut, body));
|
|
837
|
+
// R3 (task 0139): fresh writes are validated here; the script cache path is covered by
|
|
838
|
+
// wrap-docs, which runs on every path that reaches audio.
|
|
839
|
+
assertScriptContent(yamlOut);
|
|
756
840
|
});
|
|
757
841
|
}
|
|
758
842
|
|
|
@@ -761,6 +845,9 @@ function dailyWrapDocs(args: string[]): void {
|
|
|
761
845
|
const scriptPath = join(workDir, '2-script', `${runDate}_08_script_voicescript.yaml`);
|
|
762
846
|
const docsPath = join(workDir, '3-audio', `${runDate}_10_wrap-docs_docs.json`);
|
|
763
847
|
timed(workDir, 'wrap-docs', () => {
|
|
848
|
+
// R3 (task 0139): the second validator placement — covers a HITL-edited YAML and the
|
|
849
|
+
// script-stage cache path, neither of which re-enters dailyScript's fresh-write branch.
|
|
850
|
+
assertScriptContent(scriptPath);
|
|
764
851
|
const url = new URL(scriptPath, `file://${process.cwd()}/`);
|
|
765
852
|
const body = readFileSync(decodeURIComponent(url.pathname), 'utf-8');
|
|
766
853
|
const profile = voiceProfile || process.env.VOICEBOX_DEFAULT_PROFILE || 'robin-news';
|
|
@@ -1033,6 +1120,8 @@ function dailyRunReport(args: string[]): void {
|
|
|
1033
1120
|
`${runDate}_04_quality-control-content_candidates.rejected.json`,
|
|
1034
1121
|
),
|
|
1035
1122
|
NEWS_REPORT_TIMING_FILE: join(workDir, 'step-timing.json'),
|
|
1123
|
+
// 0139 R5b: the generate stage's QC audit, read best-effort (absent -> section omitted).
|
|
1124
|
+
NEWS_REPORT_QC_FILE: join(workDir, '3-audio', `${runDate}_11_generate_content.json`),
|
|
1036
1125
|
NEWS_REPORT_DATE: runDate,
|
|
1037
1126
|
},
|
|
1038
1127
|
});
|
|
@@ -1118,109 +1207,121 @@ function dailyTranslateSync(args: string[]): void {
|
|
|
1118
1207
|
|
|
1119
1208
|
const [stage, ...rest] = process.argv.slice(2);
|
|
1120
1209
|
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
in: { type: 'string' },
|
|
1143
|
-
out: { type: 'string' },
|
|
1144
|
-
profile: { type: 'string' },
|
|
1145
|
-
});
|
|
1146
|
-
if (!values.in || !values.out) {
|
|
1147
|
-
fail(
|
|
1148
|
-
'usage: kk-workflow-stages.ts wrap [<override>] --in <voicescript.yaml> --out <docs.json> [--profile <name>]',
|
|
1149
|
-
);
|
|
1210
|
+
// Imported as a module (unit tests reach `dedupPlanByTitle`) → exports only, never a stage run;
|
|
1211
|
+
// executed as the entrypoint → dispatch.
|
|
1212
|
+
if (import.meta.main)
|
|
1213
|
+
switch (stage) {
|
|
1214
|
+
case 'prepare-itc':
|
|
1215
|
+
prepareItc(rest);
|
|
1216
|
+
break;
|
|
1217
|
+
case 'prepare-solo':
|
|
1218
|
+
prepareSolo(rest);
|
|
1219
|
+
break;
|
|
1220
|
+
case 'prepare-storm':
|
|
1221
|
+
prepareStorm(rest);
|
|
1222
|
+
break;
|
|
1223
|
+
case 'duration':
|
|
1224
|
+
duration(rest);
|
|
1225
|
+
break;
|
|
1226
|
+
case 'validate': {
|
|
1227
|
+
const { override, values } = parseStage(rest, { in: { type: 'string' } });
|
|
1228
|
+
if (!values.in) fail('usage: kk-workflow-stages.ts validate [<override>] --in <voicescript.yaml>');
|
|
1229
|
+
delegate(requireSidecar(override, VALIDATE_SIDECAR, 'validate_script'), ['--in', values.in]);
|
|
1230
|
+
break;
|
|
1150
1231
|
}
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1232
|
+
case 'wrap': {
|
|
1233
|
+
const { override, values } = parseStage(rest, {
|
|
1234
|
+
in: { type: 'string' },
|
|
1235
|
+
out: { type: 'string' },
|
|
1236
|
+
profile: { type: 'string' },
|
|
1237
|
+
});
|
|
1238
|
+
if (!values.in || !values.out) {
|
|
1239
|
+
fail(
|
|
1240
|
+
'usage: kk-workflow-stages.ts wrap [<override>] --in <voicescript.yaml> --out <docs.json> [--profile <name>]',
|
|
1241
|
+
);
|
|
1242
|
+
}
|
|
1243
|
+
delegate(requireSidecar(override, WRAP_SIDECAR, 'wrap_script'), [
|
|
1244
|
+
values.in,
|
|
1245
|
+
values.out,
|
|
1246
|
+
values.profile ?? '',
|
|
1247
|
+
]);
|
|
1248
|
+
break;
|
|
1249
|
+
}
|
|
1250
|
+
case 'render-storm': {
|
|
1251
|
+
const { override, values } = parseStage(rest, { in: { type: 'string' }, out: { type: 'string' } });
|
|
1252
|
+
if (!values.in || !values.out) {
|
|
1253
|
+
fail('usage: kk-workflow-stages.ts render-storm [<override>] --in <content.json> --out <content.md>');
|
|
1254
|
+
}
|
|
1255
|
+
delegate(requireSidecar(override, RENDER_SIDECAR, 'render_script'), [
|
|
1256
|
+
'--in',
|
|
1257
|
+
values.in,
|
|
1258
|
+
'--out',
|
|
1259
|
+
values.out,
|
|
1260
|
+
]);
|
|
1261
|
+
break;
|
|
1158
1262
|
}
|
|
1159
|
-
|
|
1160
|
-
|
|
1263
|
+
case 'daily-prepare':
|
|
1264
|
+
dailyPrepare(rest);
|
|
1265
|
+
break;
|
|
1266
|
+
case 'daily-collect-facts':
|
|
1267
|
+
dailyCollectFacts(rest);
|
|
1268
|
+
break;
|
|
1269
|
+
case 'daily-plan':
|
|
1270
|
+
dailyPlan(rest);
|
|
1271
|
+
break;
|
|
1272
|
+
case 'daily-qc-content':
|
|
1273
|
+
dailyQcContent(rest);
|
|
1274
|
+
break;
|
|
1275
|
+
case 'daily-article':
|
|
1276
|
+
dailyArticle(rest);
|
|
1277
|
+
break;
|
|
1278
|
+
case 'daily-cover-normalize':
|
|
1279
|
+
dailyCoverNormalize(rest);
|
|
1280
|
+
break;
|
|
1281
|
+
case 'daily-script':
|
|
1282
|
+
dailyScript(rest);
|
|
1283
|
+
break;
|
|
1284
|
+
case 'daily-wrap-docs':
|
|
1285
|
+
dailyWrapDocs(rest);
|
|
1286
|
+
break;
|
|
1287
|
+
case 'daily-generate':
|
|
1288
|
+
dailyGenerate(rest);
|
|
1289
|
+
break;
|
|
1290
|
+
case 'daily-quality-report':
|
|
1291
|
+
dailyQualityReport(rest);
|
|
1292
|
+
break;
|
|
1293
|
+
case 'daily-publish-prep':
|
|
1294
|
+
dailyPublishPrep(rest);
|
|
1295
|
+
break;
|
|
1296
|
+
case 'daily-publish-surfdash':
|
|
1297
|
+
dailyPublishSurfdash(rest);
|
|
1298
|
+
break;
|
|
1299
|
+
case 'daily-show-notes':
|
|
1300
|
+
dailyShowNotes(rest);
|
|
1301
|
+
break;
|
|
1302
|
+
case 'daily-publish-podcast':
|
|
1303
|
+
dailyPublishPodcast(rest);
|
|
1304
|
+
break;
|
|
1305
|
+
case 'daily-publish-classify':
|
|
1306
|
+
dailyPublishClassify(rest);
|
|
1307
|
+
break;
|
|
1308
|
+
case 'daily-publish-partial':
|
|
1309
|
+
dailyPublishPartial(rest);
|
|
1310
|
+
break;
|
|
1311
|
+
case 'daily-run-report':
|
|
1312
|
+
dailyRunReport(rest);
|
|
1313
|
+
break;
|
|
1314
|
+
case 'daily-translate-en':
|
|
1315
|
+
dailyTranslateEn(rest);
|
|
1316
|
+
break;
|
|
1317
|
+
case 'daily-translate-ja':
|
|
1318
|
+
dailyTranslateJa(rest);
|
|
1319
|
+
break;
|
|
1320
|
+
case 'daily-translate-sync':
|
|
1321
|
+
dailyTranslateSync(rest);
|
|
1322
|
+
break;
|
|
1323
|
+
default:
|
|
1324
|
+
fail(
|
|
1325
|
+
`unknown stage: ${stage ?? '(none)'} (expected prepare-itc|prepare-solo|prepare-storm|validate|wrap|duration|render-storm|daily-*)`,
|
|
1326
|
+
);
|
|
1161
1327
|
}
|
|
1162
|
-
case 'daily-prepare':
|
|
1163
|
-
dailyPrepare(rest);
|
|
1164
|
-
break;
|
|
1165
|
-
case 'daily-collect-facts':
|
|
1166
|
-
dailyCollectFacts(rest);
|
|
1167
|
-
break;
|
|
1168
|
-
case 'daily-plan':
|
|
1169
|
-
dailyPlan(rest);
|
|
1170
|
-
break;
|
|
1171
|
-
case 'daily-qc-content':
|
|
1172
|
-
dailyQcContent(rest);
|
|
1173
|
-
break;
|
|
1174
|
-
case 'daily-article':
|
|
1175
|
-
dailyArticle(rest);
|
|
1176
|
-
break;
|
|
1177
|
-
case 'daily-cover-normalize':
|
|
1178
|
-
dailyCoverNormalize(rest);
|
|
1179
|
-
break;
|
|
1180
|
-
case 'daily-script':
|
|
1181
|
-
dailyScript(rest);
|
|
1182
|
-
break;
|
|
1183
|
-
case 'daily-wrap-docs':
|
|
1184
|
-
dailyWrapDocs(rest);
|
|
1185
|
-
break;
|
|
1186
|
-
case 'daily-generate':
|
|
1187
|
-
dailyGenerate(rest);
|
|
1188
|
-
break;
|
|
1189
|
-
case 'daily-quality-report':
|
|
1190
|
-
dailyQualityReport(rest);
|
|
1191
|
-
break;
|
|
1192
|
-
case 'daily-publish-prep':
|
|
1193
|
-
dailyPublishPrep(rest);
|
|
1194
|
-
break;
|
|
1195
|
-
case 'daily-publish-surfdash':
|
|
1196
|
-
dailyPublishSurfdash(rest);
|
|
1197
|
-
break;
|
|
1198
|
-
case 'daily-show-notes':
|
|
1199
|
-
dailyShowNotes(rest);
|
|
1200
|
-
break;
|
|
1201
|
-
case 'daily-publish-podcast':
|
|
1202
|
-
dailyPublishPodcast(rest);
|
|
1203
|
-
break;
|
|
1204
|
-
case 'daily-publish-classify':
|
|
1205
|
-
dailyPublishClassify(rest);
|
|
1206
|
-
break;
|
|
1207
|
-
case 'daily-publish-partial':
|
|
1208
|
-
dailyPublishPartial(rest);
|
|
1209
|
-
break;
|
|
1210
|
-
case 'daily-run-report':
|
|
1211
|
-
dailyRunReport(rest);
|
|
1212
|
-
break;
|
|
1213
|
-
case 'daily-translate-en':
|
|
1214
|
-
dailyTranslateEn(rest);
|
|
1215
|
-
break;
|
|
1216
|
-
case 'daily-translate-ja':
|
|
1217
|
-
dailyTranslateJa(rest);
|
|
1218
|
-
break;
|
|
1219
|
-
case 'daily-translate-sync':
|
|
1220
|
-
dailyTranslateSync(rest);
|
|
1221
|
-
break;
|
|
1222
|
-
default:
|
|
1223
|
-
fail(
|
|
1224
|
-
`unknown stage: ${stage ?? '(none)'} (expected prepare-itc|prepare-solo|prepare-storm|validate|wrap|duration|render-storm|daily-*)`,
|
|
1225
|
-
);
|
|
1226
|
-
}
|
|
@@ -118,6 +118,59 @@ function assertCrossfade(value: number | undefined): void {
|
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
// ─── Content checks (task 0139 R3) ───────────────────────────────────────────
|
|
122
|
+
// Deterministic spoken-copy gates. The workflow wires this validator at the end of the script
|
|
123
|
+
// stage's fresh write and again in wrap-docs (which covers a HITL-edited YAML and the script cache
|
|
124
|
+
// path), so a voicescript that reaches audio carries no title echo, no duplicated segment, no
|
|
125
|
+
// research scaffolding and no colon-mangled URL. Every failure names the segment index and reason.
|
|
126
|
+
|
|
127
|
+
/** Punctuation/space the translator drifts between a title and its body restatement. */
|
|
128
|
+
const CONTENT_ECHO_NOISE_RE = /[,。、::;;!!??…\s「」『』【】]/g;
|
|
129
|
+
/** Research scaffold labels the plan-translate stage must not emit as spoken copy. */
|
|
130
|
+
const CONTENT_SCAFFOLD_LABEL_RE =
|
|
131
|
+
/^(?:\*\*)?[「『]?(?:背景|潜在影响|可做角度|为什么现在值得注意|社区讨论)[」』]?(?:\*\*)?(?:[::,,]|\s|$)/;
|
|
132
|
+
/** `标签,` metadata tails — the plan item's tag line spoken aloud. */
|
|
133
|
+
const CONTENT_TAG_TAIL_RE = /标签[::,,]/;
|
|
134
|
+
/** `wss,//` / `https。//` — the translator replacing a URL colon with a Chinese comma. */
|
|
135
|
+
const CONTENT_URL_MANGLE_RE = /[,。]\/\//;
|
|
136
|
+
|
|
137
|
+
function assertSegmentContent(script: VoiceScript): void {
|
|
138
|
+
const seen = new Map<string, number>();
|
|
139
|
+
for (let idx = 0; idx < script.segments.length; idx++) {
|
|
140
|
+
const text = (script.segments[idx]?.text ?? '').trim();
|
|
141
|
+
if (text === '') continue;
|
|
142
|
+
|
|
143
|
+
const previous = seen.get(text);
|
|
144
|
+
if (previous !== undefined) {
|
|
145
|
+
throw new Error(`content check failed: segment ${idx} duplicates segment ${previous} verbatim`);
|
|
146
|
+
}
|
|
147
|
+
seen.set(text, idx);
|
|
148
|
+
|
|
149
|
+
// Title echo: the 【lead】 content reappearing immediately after the lead, punctuation-drift
|
|
150
|
+
// tolerant — the same comparison `stripLeadingTitleEcho` uses on the producer side.
|
|
151
|
+
const lead = text.match(/^[^【]*【([^】]{4,80})】[,。、\s]*/);
|
|
152
|
+
if (lead !== null) {
|
|
153
|
+
const bracketed = (lead[1] ?? '').replace(CONTENT_ECHO_NOISE_RE, '');
|
|
154
|
+
const rest = text.slice(lead[0].length).replace(CONTENT_ECHO_NOISE_RE, '');
|
|
155
|
+
if (bracketed.length >= 4 && rest.startsWith(bracketed)) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`content check failed: segment ${idx} repeats its 【${lead[1]}】 title in the body text`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (CONTENT_SCAFFOLD_LABEL_RE.test(text)) {
|
|
163
|
+
throw new Error(`content check failed: segment ${idx} opens with a research scaffold label`);
|
|
164
|
+
}
|
|
165
|
+
if (CONTENT_TAG_TAIL_RE.test(text)) {
|
|
166
|
+
throw new Error(`content check failed: segment ${idx} carries a 标签 metadata tail`);
|
|
167
|
+
}
|
|
168
|
+
if (CONTENT_URL_MANGLE_RE.test(text)) {
|
|
169
|
+
throw new Error(`content check failed: segment ${idx} has a colon-mangled URL`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
121
174
|
function validateVoiceScript(script: VoiceScript): void {
|
|
122
175
|
if (!script || typeof script !== 'object') {
|
|
123
176
|
throw new Error('VoiceScript must be an object');
|
|
@@ -173,6 +226,8 @@ function validateVoiceScript(script: VoiceScript): void {
|
|
|
173
226
|
assertEngine(segment.engine, `Segment at index ${idx}`);
|
|
174
227
|
assertLanguage(segment.language, `Segment at index ${idx}`);
|
|
175
228
|
}
|
|
229
|
+
|
|
230
|
+
assertSegmentContent(script);
|
|
176
231
|
}
|
|
177
232
|
|
|
178
233
|
function main(): void {
|
|
@@ -121,12 +121,14 @@ states:
|
|
|
121
121
|
options:
|
|
122
122
|
role: scribe
|
|
123
123
|
agent: ${vars.agent}
|
|
124
|
+
# ADR-115 error tier: keep this prompt <= 1000 chars (0133 R3; 0138 R4a adds the copy discipline).
|
|
124
125
|
input: |
|
|
125
|
-
Read ${vars.work_dir}/2-plan/${vars.run_date}_04_quality-control-content_candidates.json —
|
|
126
|
-
For
|
|
127
|
-
Titles
|
|
128
|
-
|
|
129
|
-
|
|
126
|
+
Read ${vars.work_dir}/2-plan/${vars.run_date}_04_quality-control-content_candidates.json — metadata.docs (Doc[]) is the plan; don't edit it.
|
|
127
|
+
For items with needsTranslation, translate "title" and "body" into natural, concise simplified Chinese.
|
|
128
|
+
Titles read as a news event (subject + action + object) from the body — e.g. "OpenAI 发布 Sora 应用,集成 Sora 2 模型". Keep product/model/company names verbatim, preserve body URLs, use 「」 not ASCII double quotes.
|
|
129
|
+
Copy discipline: reader-facing news copy — no scaffold labels (「可做角度」「为什么现在值得注意」「社区讨论」「背景」「潜在影响」「影响」), no duplicated labels or hedging (「读者宜另行核实」「仍待验证」) — merge their substance into prose or drop.
|
|
130
|
+
Keep other fields (id, sourceUri, mediaType, metadata), technical terms as-is; no trailing commas or comments.
|
|
131
|
+
Output strictly valid JSON — a same-length, same-order array with the same ids — to ${vars.work_dir}/2-plan/${vars.run_date}_03_plan_plan.json.
|
|
130
132
|
expectFile: ${vars.work_dir}/2-plan/${vars.run_date}_03_plan_plan.json
|
|
131
133
|
allowAppend: false
|
|
132
134
|
|
|
@@ -518,27 +520,30 @@ transitions:
|
|
|
518
520
|
guard:
|
|
519
521
|
kind: always
|
|
520
522
|
|
|
523
|
+
# 0140: intentionally NO `qc.passed` conjunct on these two edges (operator decision, ratified
|
|
524
|
+
# 2026-09-17, see the 0140 Q&A). The gate that used to sit here read
|
|
525
|
+
# `jq -r '.metadata.qc.passed // true' <generate content>` — and jq's `//` treats `false` as empty,
|
|
526
|
+
# so it printed "true" for a failing audit and the conjunct passed for every input. It never gated
|
|
527
|
+
# (trace `dogfood-daily-031816` fired quality-control → publish-prep with `passed:false` on disk),
|
|
528
|
+
# so removing it is behaviour-neutral. Policy: degradation-class audits (loudness dips,
|
|
529
|
+
# transcription fidelity) publish warn-only and surface in the run report's ## 音频 QC block;
|
|
530
|
+
# the credibility-damaging class (`repetition_detected`) is hard-failed upstream by 0139 R5a.
|
|
531
|
+
# Do not restore a `qc.passed` guard here without also fixing the `// false`-swallowing expression.
|
|
521
532
|
- from: quality-control
|
|
522
533
|
to: publish-prep
|
|
523
|
-
description: "publish enabled
|
|
534
|
+
description: "publish enabled -> merge article + audio for the publish seam"
|
|
524
535
|
guard:
|
|
525
536
|
kind: shell
|
|
526
537
|
options:
|
|
527
|
-
command: 'test "${vars.publish_enabled}" = "true"
|
|
538
|
+
command: 'test "${vars.publish_enabled}" = "true"'
|
|
528
539
|
|
|
529
540
|
- from: quality-control
|
|
530
541
|
to: done
|
|
531
|
-
description: "publish disabled
|
|
542
|
+
description: "publish disabled -> audio episode complete (no publish)"
|
|
532
543
|
guard:
|
|
533
544
|
kind: shell
|
|
534
545
|
options:
|
|
535
|
-
command: 'test "${vars.publish_enabled}" != "true"
|
|
536
|
-
|
|
537
|
-
- from: quality-control
|
|
538
|
-
to: failed
|
|
539
|
-
description: "Audio QC failed -> pipeline failed"
|
|
540
|
-
guard:
|
|
541
|
-
kind: always
|
|
546
|
+
command: 'test "${vars.publish_enabled}" != "true"'
|
|
542
547
|
|
|
543
548
|
- from: publish-prep
|
|
544
549
|
to: publish-surfdash
|