@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.
@@ -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
- switch (stage) {
1122
- case 'prepare-itc':
1123
- prepareItc(rest);
1124
- break;
1125
- case 'prepare-solo':
1126
- prepareSolo(rest);
1127
- break;
1128
- case 'prepare-storm':
1129
- prepareStorm(rest);
1130
- break;
1131
- case 'duration':
1132
- duration(rest);
1133
- break;
1134
- case 'validate': {
1135
- const { override, values } = parseStage(rest, { in: { type: 'string' } });
1136
- if (!values.in) fail('usage: kk-workflow-stages.ts validate [<override>] --in <voicescript.yaml>');
1137
- delegate(requireSidecar(override, VALIDATE_SIDECAR, 'validate_script'), ['--in', values.in]);
1138
- break;
1139
- }
1140
- case 'wrap': {
1141
- const { override, values } = parseStage(rest, {
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
- delegate(requireSidecar(override, WRAP_SIDECAR, 'wrap_script'), [values.in, values.out, values.profile ?? '']);
1152
- break;
1153
- }
1154
- case 'render-storm': {
1155
- const { override, values } = parseStage(rest, { in: { type: 'string' }, out: { type: 'string' } });
1156
- if (!values.in || !values.out) {
1157
- fail('usage: kk-workflow-stages.ts render-storm [<override>] --in <content.json> --out <content.md>');
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
- delegate(requireSidecar(override, RENDER_SIDECAR, 'render_script'), ['--in', values.in, '--out', values.out]);
1160
- break;
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 — its metadata.docs (Doc[]) is the episode plan to translate. Do not edit candidates.json.
126
- For every item with needsTranslation, translate "title" and "body" into natural, concise simplified Chinese.
127
- Titles must read as a news event (subject + action + object), derived from the body — e.g. "OpenAI 发布 Sora 应用,集成 Sora 2 模型". Keep product/model/company names verbatim, preserve URLs inside the body, and use 「」 instead of raw ASCII double quotes inside Chinese text.
128
- Keep every other field untouched (id, sourceUri, mediaType, metadata), keep technical terms in their original form where that is clearer, and produce no trailing commas or comments.
129
- Output strictly valid JSON — a same-length, same-order array carrying the same ids — to ${vars.work_dir}/2-plan/${vars.run_date}_03_plan_plan.json.
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 AND QC passed -> merge article + audio for the publish seam"
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" && test "$(jq -r ''.metadata.qc.passed // true'' "${vars.work_dir}/3-audio/${vars.run_date}_11_generate_content.json")" = "true"'
538
+ command: 'test "${vars.publish_enabled}" = "true"'
528
539
 
529
540
  - from: quality-control
530
541
  to: done
531
- description: "publish disabled AND QC passed -> audio episode complete (no publish)"
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" && test "$(jq -r ''.metadata.qc.passed // true'' "${vars.work_dir}/3-audio/${vars.run_date}_11_generate_content.json")" = "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