@gobing-ai/knowledge-kit 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +796 -176
  3. package/package.json +1 -1
  4. package/plugins/generations/content-gen/dist/index.js +2 -2
  5. package/plugins/generations/core-facts-gen/dist/index.js +2 -2
  6. package/plugins/generations/daily-article-gen/dist/index.js +2 -2
  7. package/plugins/generations/dailynews-gen/dist/index.js +72 -9
  8. package/plugins/generations/dailynews-gen/src/index.ts +23 -1
  9. package/plugins/generations/dailynews-gen/src/script-plan.ts +103 -5
  10. package/plugins/generations/episode-plan-gen/dist/index.js +2 -2
  11. package/plugins/generations/gemini-tts/dist/index.js +2 -2
  12. package/plugins/generations/image-gen/dist/index.js +2 -2
  13. package/plugins/generations/news-report-gen/dist/index.js +2 -2
  14. package/plugins/generations/voice-gen/dist/index.js +2 -2
  15. package/plugins/generations/voxcpm-tts/dist/index.js +2 -2
  16. package/plugins/ingestions/aihot-ingest/dist/index.js +2 -2
  17. package/plugins/ingestions/horizon-ingest/dist/index.js +2 -2
  18. package/plugins/ingestions/last30days-ingest/dist/index.js +2 -2
  19. package/plugins/ingestions/web-search/dist/index.js +2 -2
  20. package/plugins/kk/plugin.json +1 -1
  21. package/plugins/kk/scripts/daily-scheduled-run.ts +53 -5
  22. package/plugins/kk/scripts/daily-stages.ts +476 -12
  23. package/plugins/kk/scripts/kk-workflow-stages.ts +20 -4
  24. package/plugins/kk/skills/content-judge/SKILL.md +5 -4
  25. package/plugins/kk/skills/content-judge/references/rubrics.md +14 -0
  26. package/plugins/kk/skills/news-editorial/SKILL.md +122 -0
  27. package/plugins/kk/skills/news-editorial/references/selection.md +47 -0
  28. package/plugins/kk/skills/news-editorial/references/style-contract.zh.md +99 -0
  29. package/plugins/kk/skills/publish/SKILL.md +3 -3
  30. package/plugins/kk/skills/web-automation/SKILL.md +229 -77
  31. package/plugins/kk/skills/web-automation/references/browser-launch.md +127 -36
  32. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +4 -6
  33. package/plugins/kk/workflows/kk-daily-ai-voice3.yaml +183 -222
  34. package/plugins/kk/workflows/kk-daily-publish3.yaml +16 -8
  35. package/plugins/publishings/bilibili-pub/dist/index.js +5 -3
  36. package/plugins/publishings/bilibili-pub/dist/profile-check.js +3 -1
  37. package/plugins/publishings/cnblogs-pub/dist/index.js +5 -3
  38. package/plugins/publishings/cnblogs-pub/dist/profile-check.js +3 -1
  39. package/plugins/publishings/csdn-pub/dist/index.js +5 -3
  40. package/plugins/publishings/csdn-pub/dist/profile-check.js +5 -3
  41. package/plugins/publishings/emdash-pub/dist/index.js +2 -2
  42. package/plugins/publishings/infoq-pub/dist/index.js +5 -3
  43. package/plugins/publishings/infoq-pub/dist/profile-check.js +5 -3
  44. package/plugins/publishings/jike-pub/dist/index.js +5 -3
  45. package/plugins/publishings/jike-pub/dist/profile-check.js +5 -3
  46. package/plugins/publishings/juejin-pub/dist/index.js +5 -3
  47. package/plugins/publishings/juejin-pub/dist/profile-check.js +5 -3
  48. package/plugins/publishings/linkedin-pub/dist/index.js +5 -3
  49. package/plugins/publishings/linkedin-pub/dist/profile-check.js +5 -3
  50. package/plugins/publishings/medium-pub/dist/index.js +2 -2
  51. package/plugins/publishings/podcast-pub/dist/index.js +147 -4
  52. package/plugins/publishings/podcast-pub/src/index.ts +71 -2
  53. package/plugins/publishings/podcast-pub/src/map.ts +3 -0
  54. package/plugins/publishings/podcast-pub/src/transcript.ts +195 -0
  55. package/plugins/publishings/qiita-pub/dist/index.js +2 -2
  56. package/plugins/publishings/shipinhao-pub/dist/index.js +5 -3
  57. package/plugins/publishings/shipinhao-pub/dist/profile-check.js +3 -1
  58. package/plugins/publishings/sspai-pub/dist/index.js +5 -3
  59. package/plugins/publishings/sspai-pub/dist/profile-check.js +3 -1
  60. package/plugins/publishings/substack-pub/dist/index.js +5 -3
  61. package/plugins/publishings/substack-pub/dist/profile-check.js +5 -3
  62. package/plugins/publishings/surfdash-pub/dist/index.js +2 -2
  63. package/plugins/publishings/threads-pub/dist/index.js +2 -2
  64. package/plugins/publishings/wechatmp-pub/dist/index.js +52 -33
  65. package/plugins/publishings/wechatmp-pub/dist/profile-check.js +5 -3
  66. package/plugins/publishings/wechatmp-pub/src/flow.ts +70 -33
  67. package/plugins/publishings/weibo-pub/dist/index.js +21 -6
  68. package/plugins/publishings/weibo-pub/dist/profile-check.js +5 -3
  69. package/plugins/publishings/weibo-pub/src/index.ts +31 -7
  70. package/plugins/publishings/x-pub/dist/index.js +5 -3
  71. package/plugins/publishings/x-pub/dist/profile-check.js +5 -3
  72. package/plugins/publishings/xhs-pub/dist/index.js +5 -3
  73. package/plugins/publishings/xhs-pub/dist/profile-check.js +5 -3
  74. package/plugins/publishings/xiaoyuzhou-pub/dist/index.js +5 -3
  75. package/plugins/publishings/xiaoyuzhou-pub/dist/profile-check.js +3 -1
  76. package/plugins/publishings/zenn-pub/dist/index.js +2 -2
  77. package/plugins/kk/scripts/daily-voice-style.zh.md +0 -70
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  /** Supervise one scheduled daily run and persist its terminal outcome even after a deadline. */
3
3
  import { spawn, spawnSync } from 'node:child_process';
4
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
5
  import { dirname, join, resolve } from 'node:path';
6
6
  import { parseArgs } from 'node:util';
7
7
 
@@ -17,8 +17,10 @@ const { values } = parseArgs({
17
17
  'required-channels': { type: 'string', default: 'wechat,locales,cms-resync' },
18
18
  'wechat-live': { type: 'boolean', default: false },
19
19
  workflow: { type: 'string', default: 'kk-daily-ai-voice2' },
20
+ 'generate-only': { type: 'boolean', default: false },
20
21
  window: { type: 'string' },
21
22
  'publish-preset': { type: 'string' },
23
+ 'publish-live-channels': { type: 'string' },
22
24
  },
23
25
  });
24
26
  const timeoutMs = Number(values['timeout-ms']);
@@ -33,13 +35,22 @@ const runId = values['run-id'] ?? `daily-ai-voice-${stamp}-${now.getTime()}`;
33
35
  const workDir = join(resolve(values['works-dir'] ?? join(repo, '.works')), 'kk-daily-ai-voice2', stamp);
34
36
  const receiptPath = resolve(values.receipt ?? join(workDir, 'schedule-receipt.json'));
35
37
  const startedAt = now.toISOString();
38
+ mkdirSync(workDir, { recursive: true });
36
39
  mkdirSync(dirname(receiptPath), { recursive: true });
37
40
  // voice3 shares the voice2 workspace (F08); only the vars and the publish seam differ.
38
41
  const isVoice3 = values.workflow === 'kk-daily-ai-voice3';
42
+ const generateOnly = values['generate-only'] === true;
43
+ if (generateOnly && !isVoice3) {
44
+ console.error('daily-scheduled-run: --generate-only requires --workflow kk-daily-ai-voice3');
45
+ process.exit(2);
46
+ }
39
47
  const vars = isVoice3
40
48
  ? {
41
- auto_publish: 'true',
49
+ auto_publish: generateOnly ? 'false' : 'true',
42
50
  ...(values['publish-preset'] !== undefined ? { publish_preset: values['publish-preset'] } : {}),
51
+ ...(values['publish-live-channels'] !== undefined
52
+ ? { publish_live_channels: values['publish-live-channels'] }
53
+ : {}),
43
54
  ...(values.window ? { window: values.window } : {}),
44
55
  skip_review: 'true',
45
56
  agent: values.agent,
@@ -312,6 +323,37 @@ const missingRequiredChannels = isVoice3
312
323
  const rows = channelCompleteness.filter((row) => row.channel === channel);
313
324
  return rows.length !== 1 || rows[0]?.state !== 'done';
314
325
  });
326
+ /** Generate-only receipts use the same fail-closed bundle admission as posting and validation. */
327
+ function inspectGenerationOnly(): { status: string; reason: string } {
328
+ const inspector = join(repo, 'scripts/daily-ai-news.ts');
329
+ if (!existsSync(inspector)) return { status: 'incomplete', reason: `bundle inspector not found: ${inspector}` };
330
+ // The child is terminal, but our initial receipt still says running. Mark the inspection phase so
331
+ // the shared admission check does not mistake this supervisor for a concurrent active run.
332
+ writeFileSync(receiptPath, JSON.stringify({ runId, startedAt, status: 'inspecting', workDir }, null, 2));
333
+ const probe = spawnSync(process.execPath, [inspector, 'inspect', '--work-dir', workDir, '--run-date', stamp], {
334
+ cwd: repo,
335
+ encoding: 'utf-8',
336
+ timeout: 30000,
337
+ maxBuffer: 1024 * 1024,
338
+ });
339
+ try {
340
+ const parsed = JSON.parse(probe.stdout) as { status?: unknown; reason?: unknown };
341
+ if (typeof parsed.status === 'string') {
342
+ return {
343
+ status: probe.status === 0 && parsed.status === 'complete' ? 'complete' : 'incomplete',
344
+ reason: typeof parsed.reason === 'string' ? parsed.reason : `inspector exited ${probe.status ?? 1}`,
345
+ };
346
+ }
347
+ } catch {}
348
+ return {
349
+ status: 'incomplete',
350
+ reason: probe.stderr.trim() || probe.error?.message || `bundle inspector exited ${probe.status ?? 1}`,
351
+ };
352
+ }
353
+ const generationInspection =
354
+ generateOnly && workflowStatus === 'done' && !runSummary?.startsWith('no-content:')
355
+ ? inspectGenerationOnly()
356
+ : null;
315
357
  // A terminal green workflow can mean "no content" or failed delivery. Make that observable to
316
358
  // the scheduler instead of equating the workflow's process exit with a published edition.
317
359
  // A voice3 outcome classified 'full' already proves every non-dummy row succeeded (0326 R2).
@@ -320,14 +362,20 @@ const status =
320
362
  ? workflowStatus
321
363
  : runSummary?.startsWith('no-content:')
322
364
  ? 'no-content'
323
- : primaryPublishStatus === 'full' && !(!isVoice3 && missingRequiredChannels.length > 0)
324
- ? 'done'
325
- : 'incomplete';
365
+ : generateOnly
366
+ ? generationInspection?.status === 'complete'
367
+ ? 'done'
368
+ : 'incomplete'
369
+ : primaryPublishStatus === 'full' && !(!isVoice3 && missingRequiredChannels.length > 0)
370
+ ? 'done'
371
+ : 'incomplete';
326
372
  const receipt = {
327
373
  runId,
328
374
  startedAt,
329
375
  completedAt: new Date().toISOString(),
330
376
  status,
377
+ generateOnly,
378
+ generationInspection,
331
379
  exitCode: timedOut ? 124 : interrupted ? 143 : exitCode !== 0 ? exitCode : status === 'done' ? 0 : 4,
332
380
  workDir,
333
381
  timeoutMs,
@@ -55,6 +55,7 @@ import { spawnSync } from 'node:child_process';
55
55
  import { createHash } from 'node:crypto';
56
56
  import {
57
57
  copyFileSync,
58
+ cpSync,
58
59
  existsSync,
59
60
  mkdirSync,
60
61
  readdirSync,
@@ -75,6 +76,7 @@ import {
75
76
  coverBrief,
76
77
  coverNormalizeBody,
77
78
  dedupPlanByTitle,
79
+ EDITORIAL_SKILL_DIR,
78
80
  expandHome,
79
81
  fail,
80
82
  failWith,
@@ -91,12 +93,10 @@ import {
91
93
  readJson,
92
94
  readTopicBoundary,
93
95
  requireJson,
94
- requireSidecar,
95
- resolveSidecar,
96
96
  resultPublishStatus,
97
97
  SAFETY_PLATFORM_DECISIONS,
98
98
  SAFETY_REVISE_COUNT,
99
- STYLE_SIDECAR,
99
+ STYLE_CONTRACT,
100
100
  StageFailure,
101
101
  safetyDigest,
102
102
  safetyDraftPaths,
@@ -511,14 +511,24 @@ export function dailyArticle(args: string[]): void {
511
511
  // half-ran (or the json was pruned), and returning here starves every downstream stage
512
512
  // that reads content.json — publish-prep refuses to merge a draft it cannot read. Requiring
513
513
  // both artifacts makes the pair self-healing instead of permanently stuck.
514
- if (isFile(articleMd) && isFile(articleContent)) {
514
+ // The prose body is the generator's INPUT: when it is newer than content.json the pair
515
+ // is stale (an operator or a retry edited the headings after the render), and serving it
516
+ // ships last round's body to surfdash. Re-derive instead of returning.
517
+ const proseBody = join(workDir, '22-article', `${runDate}_06_article-write_body.md`);
518
+ const proseStale =
519
+ isFile(proseBody) &&
520
+ isFile(articleContent) &&
521
+ statSync(proseBody).mtimeMs > statSync(articleContent).mtimeMs;
522
+ if (isFile(articleMd) && isFile(articleContent) && !proseStale) {
515
523
  console.log(`article cached: ${articleMd}`);
516
524
  return;
517
525
  }
526
+ if (proseStale) {
527
+ console.error(`article: stale pair — ${proseBody} is newer than ${articleContent}, re-deriving`);
528
+ }
518
529
  // Agent-written prose body (workflow `article-write` state) replaces the mechanical
519
530
  // per-item render when present — the 3-format split: surfdash gets an article,
520
531
  // not a news list (2026-09-16 feedback).
521
- const proseBody = join(workDir, '22-article', `${runDate}_06_article-write_body.md`);
522
532
  kk(
523
533
  [
524
534
  'executor',
@@ -3999,7 +4009,8 @@ export function dailyScript(args: string[]): void {
3999
4009
  // cwd = the plugin package dir, so a relative workDir would silently miss the
4000
4010
  // plan; a plan file absent at build time reports missing-plan (template fallback).
4001
4011
  DAILYNEWS_SCRIPT_PLAN: resolve(join(workDir, '24-script', `${runDate}_08_script-write_plan.json`)),
4002
- DAILYNEWS_STYLE_FILE: resolve(resolveSidecar('', STYLE_SIDECAR)),
4012
+ DAILYNEWS_STYLE_FILE: resolve(STYLE_CONTRACT),
4013
+ DAILYNEWS_CURATE_EXCLUDED: resolve(join(workDir, '21-plan', `${runDate}_03_curate_excluded.json`)),
4003
4014
  },
4004
4015
  });
4005
4016
  const body = readJson(contentOut)?.body;
@@ -4021,25 +4032,478 @@ export function dailyScript(args: string[]): void {
4021
4032
 
4022
4033
  /** Task 0189 R10 artifacts of the content-safety verdict gate (24-script, step 09). */
4023
4034
  /**
4024
- * F613 R1: snapshot the shipped style contract into the run's work dir. Spur workflows have no
4025
- * workflow-dir var, so the script-write prompt reads this copy — and the snapshot freezes the
4026
- * exact contract the run's plan was written against. Idempotent (an existing snapshot is kept);
4027
- * a missing sidecar is a broken install, so `requireSidecar` fails loud.
4035
+ * F613 R1 / ADR-045: snapshot the shipped kk:news-editorial skill into the run's work dir. Spur
4036
+ * workflows have no workflow-dir var and kk skills are not installed for every executor, so the
4037
+ * writer prompts read this copy by path — and the snapshot freezes the exact policy the run's plan
4038
+ * was written against. `20-editorial/news-editorial/` holds SKILL.md + references; the
4039
+ * `_08_script-write_style.md` copy of the style contract stays the run's audit record.
4040
+ * Idempotent per artifact (an existing snapshot is kept); a missing skill is a broken install.
4028
4041
  */
4029
4042
  export function dailyScriptStyle(args: string[]): void {
4030
4043
  const [workDir = '', runDate = ''] = args;
4044
+ if (!isFile(STYLE_CONTRACT)) {
4045
+ failWith(
4046
+ 1,
4047
+ `news-editorial skill not found — no ${STYLE_CONTRACT}; install or refresh the kk package (bun link in a checkout)`,
4048
+ );
4049
+ }
4050
+ const skillSnapshot = join(workDir, '20-editorial', 'news-editorial');
4051
+ if (isFile(join(skillSnapshot, 'SKILL.md'))) {
4052
+ console.log(`editorial skill cached: ${skillSnapshot}`);
4053
+ } else {
4054
+ sted('cp', skillSnapshot, () => cpSync(EDITORIAL_SKILL_DIR, skillSnapshot, { recursive: true }));
4055
+ console.log(`editorial skill snapshot: ${skillSnapshot}`);
4056
+ }
4031
4057
  const snapshot = join(workDir, '24-script', `${runDate}_08_script-write_style.md`);
4032
4058
  if (isFile(snapshot)) {
4033
4059
  console.log(`script style cached: ${snapshot}`);
4034
4060
  return;
4035
4061
  }
4036
- const sidecar = requireSidecar('', STYLE_SIDECAR, 'daily-voice-style');
4037
4062
  const scriptDir = join(workDir, '24-script');
4038
4063
  sted('mkdir', scriptDir, () => mkdirSync(scriptDir, { recursive: true }));
4039
- sted('cp', snapshot, () => copyFileSync(sidecar, snapshot));
4064
+ sted('cp', snapshot, () => copyFileSync(STYLE_CONTRACT, snapshot));
4040
4065
  console.log(`script style snapshot: ${snapshot}`);
4041
4066
  }
4042
4067
 
4068
+ /**
4069
+ * 0345 R4: dry-run the dailynews-gen validator on the authored script plan so a rejected plan gets
4070
+ * one targeted `script-write-fix` pass instead of silently shipping the template fallback. Writes
4071
+ * `24-script/<date>_08_script-write_errors.json` ([{itemId, rule}]) when the plan is absent or
4072
+ * rejected, and removes it when the plan is accepted. The trial build goes to a scratch path so the
4073
+ * `script` stage's isFile cache is never primed by a check.
4074
+ */
4075
+ export function dailyScriptCheck(args: string[]): void {
4076
+ const [workDir = '', runDate = '', pluginsPath = ''] = args;
4077
+ const planOut = join(workDir, '24-script', `${runDate}_08_script-write_plan.json`);
4078
+ const errorsOut = join(workDir, '24-script', `${runDate}_08_script-write_errors.json`);
4079
+ const writeErrors = (errors: unknown[]) => {
4080
+ sted('write', errorsOut, () => writeJsonText(errorsOut, errors));
4081
+ console.log(`script plan rejected (${errors.length} error(s)): ${errorsOut}`);
4082
+ };
4083
+ if (!isFile(planOut)) {
4084
+ writeErrors([{ itemId: 'episode', rule: 'missing-plan' }]);
4085
+ return;
4086
+ }
4087
+ const trialOut = join(workDir, '24-script', `.${runDate}_08_script-check_content.json`);
4088
+ const planIn = join(workDir, '21-plan', `${runDate}_03_plan_plan.json`);
4089
+ try {
4090
+ kk(['executor', 'run', 'dailynews-gen', '--in', planIn, '--out', trialOut, ...pluginsArgs(pluginsPath)], {
4091
+ env: {
4092
+ DAILYNEWS_LANGUAGE: 'zh',
4093
+ DAILYNEWS_DATE: runDate,
4094
+ DAILYNEWS_SCRIPT_PLAN: resolve(planOut),
4095
+ DAILYNEWS_STYLE_FILE: resolve(STYLE_CONTRACT),
4096
+ DAILYNEWS_CURATE_EXCLUDED: resolve(join(workDir, '21-plan', `${runDate}_03_curate_excluded.json`)),
4097
+ },
4098
+ });
4099
+ const metadata = readJson(trialOut)?.metadata;
4100
+ const source = metadata?.scriptSource;
4101
+ if (source !== 'plan' && source !== 'template') {
4102
+ failWith(1, `script-check: ${trialOut} carries no scriptSource stamp`);
4103
+ }
4104
+ if (source === 'plan') {
4105
+ if (isFile(errorsOut)) unlinkSync(errorsOut);
4106
+ console.log('script plan accepted by the validator');
4107
+ return;
4108
+ }
4109
+ writeErrors(Array.isArray(metadata.scriptPlanErrors) ? metadata.scriptPlanErrors : []);
4110
+ } finally {
4111
+ if (isFile(trialOut)) unlinkSync(trialOut);
4112
+ }
4113
+ }
4114
+
4115
+ /**
4116
+ * 0345 R8: validate the `curate` agent output (`21-plan/<date>_03_plan_plan.json` +
4117
+ * `21-plan/<date>_03_curate_excluded.json`) against the QC candidates
4118
+ * (`21-plan/<date>_04_quality-control-content_candidates.json`). Unwraps a `Content` envelope and
4119
+ * dedups near-duplicate titles in place before `write` reads the plan. Writes
4120
+ * `21-plan/<date>_03_curate_errors.json` (`[{itemId, rule}]`) on failure and removes it on success.
4121
+ * With `--final`, falls back to the untranslated candidates and `[]` exclusions on failure.
4122
+ */
4123
+ export function dailyCurateCheck(args: string[]): void {
4124
+ const [workDir = '', runDate = '', flag = ''] = args;
4125
+ if (!workDir) fail('missing work_dir');
4126
+ if (!runDate) fail('missing run_date');
4127
+ const isFinal = flag === '--final';
4128
+ const candidatesFile = join(workDir, '21-plan', `${runDate}_04_quality-control-content_candidates.json`);
4129
+ const planFile = join(workDir, '21-plan', `${runDate}_03_plan_plan.json`);
4130
+ const excludedFile = join(workDir, '21-plan', `${runDate}_03_curate_excluded.json`);
4131
+ const errFile = join(workDir, '21-plan', `${runDate}_03_curate_errors.json`);
4132
+
4133
+ const candRaw = readJson(candidatesFile);
4134
+ const candidateDocs: Record<string, unknown>[] = Array.isArray(candRaw)
4135
+ ? (candRaw as Record<string, unknown>[])
4136
+ : Array.isArray(metadataOf(candRaw).docs)
4137
+ ? (metadataOf(candRaw).docs as Record<string, unknown>[])
4138
+ : [];
4139
+ const candidateIds = candidateDocs.map((doc) => (typeof doc?.id === 'string' ? doc.id.trim() : '')).filter(Boolean);
4140
+ const candidateIdSet = new Set(candidateIds);
4141
+
4142
+ const handleErrors = (errors: { itemId: string; rule: string }[]): void => {
4143
+ if (isFinal) {
4144
+ const reason = errors.map((e) => `${e.itemId}:${e.rule}`).join(', ');
4145
+ console.error(
4146
+ `curate-check: final check failed (${reason}) — falling back to ${candidateDocs.length} untranslated candidate(s)`,
4147
+ );
4148
+ sted('write', planFile, () => writeJsonText(planFile, candidateDocs));
4149
+ sted('write', excludedFile, () => writeJsonText(excludedFile, []));
4150
+ if (isFile(errFile)) unlinkSync(errFile);
4151
+ return;
4152
+ }
4153
+ sted('write', errFile, () => writeJsonText(errFile, errors));
4154
+ console.log(`curate-check: rejected (${errors.length} error(s)): ${errFile}`);
4155
+ };
4156
+
4157
+ if (!isFile(planFile)) {
4158
+ handleErrors([{ itemId: 'episode', rule: 'missing-plan' }]);
4159
+ return;
4160
+ }
4161
+
4162
+ let planParsed: unknown;
4163
+ try {
4164
+ planParsed = JSON.parse(readFileSync(planFile, 'utf-8'));
4165
+ } catch {
4166
+ handleErrors([{ itemId: 'episode', rule: 'invalid-json' }]);
4167
+ return;
4168
+ }
4169
+
4170
+ let planDocs: Record<string, unknown>[];
4171
+ if (Array.isArray(planParsed)) {
4172
+ planDocs = planParsed as Record<string, unknown>[];
4173
+ } else if (
4174
+ planParsed !== null &&
4175
+ typeof planParsed === 'object' &&
4176
+ Array.isArray(metadataOf(planParsed as Record<string, unknown>).docs)
4177
+ ) {
4178
+ planDocs = metadataOf(planParsed as Record<string, unknown>).docs as Record<string, unknown>[];
4179
+ sted('write', planFile, () => writeJsonText(planFile, planDocs));
4180
+ console.log(`curate-check: unwrapped Content envelope (${planDocs.length} doc(s)) in ${planFile}`);
4181
+ } else {
4182
+ handleErrors([{ itemId: 'episode', rule: 'invalid-json' }]);
4183
+ return;
4184
+ }
4185
+
4186
+ for (const entry of planDocs) {
4187
+ if (entry === null || typeof entry !== 'object' || typeof entry.id !== 'string' || entry.id.trim() === '') {
4188
+ handleErrors([{ itemId: 'episode', rule: 'invalid-json' }]);
4189
+ return;
4190
+ }
4191
+ }
4192
+
4193
+ const excludedItems: { id: string; reason: string }[] = [];
4194
+ if (isFile(excludedFile)) {
4195
+ let exParsed: unknown;
4196
+ try {
4197
+ exParsed = JSON.parse(readFileSync(excludedFile, 'utf-8'));
4198
+ } catch {
4199
+ handleErrors([{ itemId: 'episode', rule: 'invalid-json' }]);
4200
+ return;
4201
+ }
4202
+ if (!Array.isArray(exParsed)) {
4203
+ handleErrors([{ itemId: 'episode', rule: 'invalid-json' }]);
4204
+ return;
4205
+ }
4206
+ for (const entry of exParsed) {
4207
+ if (
4208
+ entry === null ||
4209
+ typeof entry !== 'object' ||
4210
+ typeof (entry as { id?: unknown }).id !== 'string' ||
4211
+ (entry as { id: string }).id.trim() === '' ||
4212
+ typeof (entry as { reason?: unknown }).reason !== 'string' ||
4213
+ (entry as { reason: string }).reason.trim() === ''
4214
+ ) {
4215
+ handleErrors([{ itemId: 'episode', rule: 'invalid-json' }]);
4216
+ return;
4217
+ }
4218
+ excludedItems.push({
4219
+ id: (entry as { id: string }).id.trim(),
4220
+ reason: (entry as { reason: string }).reason.trim(),
4221
+ });
4222
+ }
4223
+ }
4224
+
4225
+ const errors: { itemId: string; rule: string }[] = [];
4226
+ if (planDocs.length === 0) {
4227
+ errors.push({ itemId: 'episode', rule: 'empty-plan' });
4228
+ }
4229
+
4230
+ const keptIds = new Set<string>();
4231
+ for (const doc of planDocs) {
4232
+ const id = (doc.id as string).trim();
4233
+ keptIds.add(id);
4234
+ if (candidateIdSet.size > 0 && !candidateIdSet.has(id)) {
4235
+ errors.push({ itemId: id, rule: 'unknown-id' });
4236
+ }
4237
+ }
4238
+
4239
+ const excludedIds = new Set<string>();
4240
+ for (const ex of excludedItems) {
4241
+ excludedIds.add(ex.id);
4242
+ if (candidateIdSet.size > 0 && !candidateIdSet.has(ex.id)) {
4243
+ errors.push({ itemId: ex.id, rule: 'unknown-id' });
4244
+ }
4245
+ if (keptIds.has(ex.id)) {
4246
+ errors.push({ itemId: ex.id, rule: 'conflict-id' });
4247
+ }
4248
+ }
4249
+
4250
+ for (const cid of candidateIds) {
4251
+ if (!keptIds.has(cid) && !excludedIds.has(cid)) {
4252
+ errors.push({ itemId: cid, rule: 'missing-item' });
4253
+ }
4254
+ }
4255
+
4256
+ if (errors.length > 0) {
4257
+ handleErrors(errors);
4258
+ return;
4259
+ }
4260
+
4261
+ const { kept, dropped } = dedupPlanByTitle(planDocs);
4262
+ if (dropped.length > 0) {
4263
+ sted('write', planFile, () => writeJsonText(planFile, kept));
4264
+ console.error(`curate-check: dropped ${dropped.length} near-duplicate plan item(s): ${dropped.join(', ')}`);
4265
+ for (const droppedId of dropped) {
4266
+ if (!excludedIds.has(droppedId)) {
4267
+ excludedIds.add(droppedId);
4268
+ excludedItems.push({ id: droppedId, reason: '标题近似重复自动去重' });
4269
+ }
4270
+ }
4271
+ sted('write', excludedFile, () => writeJsonText(excludedFile, excludedItems));
4272
+ } else if (!isFile(excludedFile)) {
4273
+ sted('write', excludedFile, () => writeJsonText(excludedFile, excludedItems));
4274
+ }
4275
+
4276
+ if (isFile(errFile)) unlinkSync(errFile);
4277
+ console.log(`curate-check: accepted (${kept.length} kept, ${excludedItems.length} excluded)`);
4278
+ }
4279
+
4280
+ function sourceLabelForDoc(doc: Record<string, unknown>, sourceUri: string): string {
4281
+ const meta = metadataOf(doc);
4282
+ if (typeof meta.source === 'string' && meta.source.trim() !== '') {
4283
+ return meta.source.trim();
4284
+ }
4285
+ try {
4286
+ return new URL(sourceUri).hostname.replace(/^www\./, '') || '原文';
4287
+ } catch {
4288
+ return '原文';
4289
+ }
4290
+ }
4291
+
4292
+ /** Fixed column opening line of the rendered article body: house copy, not editorial judgement (0345). */
4293
+ const EDITORIAL_ARTICLE_OPENING = '这里是由Robin为你带来的咸话咸说之每日AI乱炖。';
4294
+
4295
+ /**
4296
+ * 0345 R10: deterministically render the five sibling editorial artifacts from the master script
4297
+ * plan (`24-script/<date>_08_script-write_plan.json`) and the curated plan
4298
+ * (`21-plan/<date>_03_plan_plan.json`):
4299
+ * 1. `22-article/<date>_06_article-write_body.md`
4300
+ * 2. `23-cover/<date>_07_cover-brief.json`
4301
+ * 3. `25-xhs/<date>_18_xhs_plan.json` (only when `cards_enabled === 'true'`)
4302
+ * 4. `26-social/{x,threads}.md` (`social.short`)
4303
+ * 5. `26-social/{weibo,linkedin}.md` (`social.long`)
4304
+ * Missing master field -> skip that one artifact with a logged reason, write the others.
4305
+ */
4306
+ export function dailyEditorialRender(args: string[]): void {
4307
+ const [workDir = '', runDate = '', cardsEnabled = 'false', cardsRaw = '6'] = args;
4308
+ if (!workDir) fail('missing work_dir');
4309
+ if (!runDate) fail('missing run_date');
4310
+ const cardsCount = Math.max(2, Number.parseInt(cardsRaw, 10) || 6);
4311
+ const planFile = join(workDir, '21-plan', `${runDate}_03_plan_plan.json`);
4312
+ const masterFile = join(workDir, '24-script', `${runDate}_08_script-write_plan.json`);
4313
+
4314
+ const master = readJson(masterFile);
4315
+ if (master === null) {
4316
+ console.log(`editorial-render: no master script plan at ${masterFile} — skipping all renders`);
4317
+ return;
4318
+ }
4319
+
4320
+ const planRaw = readJson(planFile);
4321
+ const planDocs: Record<string, unknown>[] = Array.isArray(planRaw)
4322
+ ? (planRaw as Record<string, unknown>[])
4323
+ : Array.isArray(metadataOf(planRaw).docs)
4324
+ ? (metadataOf(planRaw).docs as Record<string, unknown>[])
4325
+ : [];
4326
+ const docById = new Map<string, Record<string, unknown>>();
4327
+ for (const doc of planDocs) {
4328
+ if (typeof doc?.id === 'string' && doc.id.trim() !== '') {
4329
+ docById.set(doc.id.trim(), doc);
4330
+ }
4331
+ }
4332
+
4333
+ const rawItems = Array.isArray(master.items) ? (master.items as Record<string, unknown>[]) : [];
4334
+ const itemById = new Map<string, Record<string, unknown>>();
4335
+ for (const item of rawItems) {
4336
+ if (item !== null && typeof item === 'object' && typeof item.id === 'string' && item.id.trim() !== '') {
4337
+ itemById.set(item.id.trim(), item);
4338
+ }
4339
+ }
4340
+ const orderedIds =
4341
+ planDocs.length > 0
4342
+ ? planDocs
4343
+ .map((d) => (typeof d.id === 'string' ? d.id.trim() : ''))
4344
+ .filter((id) => id !== '' && itemById.has(id))
4345
+ : [...itemById.keys()];
4346
+
4347
+ // 1. Article body: 22-article/<date>_06_article-write_body.md
4348
+ const outro = typeof master.outro === 'string' ? master.outro.trim() : '';
4349
+ const articleSections: string[] = [];
4350
+ for (const id of orderedIds) {
4351
+ const doc = docById.get(id);
4352
+ const item = itemById.get(id);
4353
+ const title = typeof doc?.title === 'string' ? doc.title.trim() : '';
4354
+ const body = typeof doc?.body === 'string' ? doc.body.trim() : '';
4355
+ const lead = typeof item?.lead === 'string' ? item.lead.trim() : '';
4356
+ const insight = typeof item?.insight === 'string' ? item.insight.trim() : '';
4357
+ if (!title || !body || !lead || !insight) continue;
4358
+ const sourceUri = typeof doc?.sourceUri === 'string' ? doc.sourceUri.trim() : '';
4359
+ const sourceLine = sourceUri ? `[来源:${sourceLabelForDoc(doc ?? {}, sourceUri)}](${sourceUri})` : '';
4360
+ const parts = [`## ${title}`, lead, body, insight, ...(sourceLine ? [sourceLine] : [])];
4361
+ articleSections.push(parts.join('\n\n'));
4362
+ }
4363
+ if (articleSections.length > 0 && outro !== '') {
4364
+ const articleDir = join(workDir, '22-article');
4365
+ sted('mkdir', articleDir, () => mkdirSync(articleDir, { recursive: true }));
4366
+ const articleOut = join(articleDir, `${runDate}_06_article-write_body.md`);
4367
+ const articleText = `${[EDITORIAL_ARTICLE_OPENING, ...articleSections, outro].join('\n\n')}\n`;
4368
+ sted('write', articleOut, () => writeRaw(articleOut, articleText));
4369
+ console.log(`editorial-render: wrote article body (${articleSections.length} item(s)): ${articleOut}`);
4370
+ } else {
4371
+ console.log('editorial-render: skipping article body (missing items/doc fields or outro)');
4372
+ }
4373
+
4374
+ // 2. Cover brief: 23-cover/<date>_07_cover-brief.json
4375
+ const coverSubject = typeof master.cover === 'string' ? master.cover.trim() : '';
4376
+ if (coverSubject !== '') {
4377
+ const coverDir = join(workDir, '23-cover');
4378
+ sted('mkdir', coverDir, () => mkdirSync(coverDir, { recursive: true }));
4379
+ const coverOut = join(coverDir, `${runDate}_07_cover-brief.json`);
4380
+ sted('write', coverOut, () => writeJsonText(coverOut, { subject: coverSubject }));
4381
+ console.log(`editorial-render: wrote cover brief: ${coverOut}`);
4382
+ } else {
4383
+ console.log('editorial-render: skipping cover brief (missing cover in script plan)');
4384
+ }
4385
+
4386
+ // 3. XHS card plan: 25-xhs/<date>_18_xhs_plan.json (only when cards_enabled === 'true')
4387
+ if (cardsEnabled === 'true') {
4388
+ const cardsMeta =
4389
+ master.cards !== null && typeof master.cards === 'object'
4390
+ ? (master.cards as Record<string, unknown>)
4391
+ : null;
4392
+ const cardTitle = typeof cardsMeta?.title === 'string' ? cardsMeta.title.trim() : '';
4393
+ const cardBody = typeof cardsMeta?.body === 'string' ? cardsMeta.body.trim() : '';
4394
+ const contentItems = orderedIds
4395
+ .map((id) => ({ id, item: itemById.get(id), doc: docById.get(id) }))
4396
+ .filter(({ item }) => {
4397
+ const c = item?.card as Record<string, unknown> | undefined;
4398
+ return (
4399
+ c !== null &&
4400
+ typeof c === 'object' &&
4401
+ typeof c.copy === 'string' &&
4402
+ c.copy.trim() !== '' &&
4403
+ Array.isArray(c.points) &&
4404
+ c.points.length >= 1 &&
4405
+ typeof c.subject === 'string' &&
4406
+ c.subject.trim() !== ''
4407
+ );
4408
+ });
4409
+ const maxContent = Math.max(1, cardsCount - 2);
4410
+ const chosenContent = contentItems.slice(0, maxContent);
4411
+ if (cardTitle !== '' && cardBody !== '' && chosenContent.length > 0) {
4412
+ const firstSubject =
4413
+ coverSubject ||
4414
+ ((chosenContent[0]?.item?.card as { subject?: string } | undefined)?.subject ?? '').trim() ||
4415
+ 'Minimalist geometric light sculpture on a dark studio table';
4416
+ const introText =
4417
+ typeof master.intro === 'string' && master.intro.trim() !== '' ? master.intro.trim() : cardTitle;
4418
+ const coverPoints = chosenContent
4419
+ .slice(0, 4)
4420
+ .map(({ item }) => ((item?.card as { copy?: string } | undefined)?.copy || '').trim())
4421
+ .filter(Boolean);
4422
+ const renderedCards: Record<string, unknown>[] = [
4423
+ {
4424
+ role: 'cover',
4425
+ copy: cardTitle.slice(0, 12),
4426
+ points: coverPoints.length >= 2 ? coverPoints : [cardTitle, introText.slice(0, 36)],
4427
+ layout: 'hero',
4428
+ subject: firstSubject,
4429
+ sourcePoint: introText,
4430
+ },
4431
+ ...chosenContent.map(({ id, item, doc }) => {
4432
+ const card = item?.card as { copy: string; points: string[]; subject: string };
4433
+ const lead = typeof item?.lead === 'string' && item.lead.trim() !== '' ? item.lead.trim() : '';
4434
+ const docTitle = typeof doc?.title === 'string' && doc.title.trim() !== '' ? doc.title.trim() : id;
4435
+ return {
4436
+ role: 'content',
4437
+ copy: card.copy.trim(),
4438
+ points: card.points
4439
+ .map((p) => String(p).trim())
4440
+ .filter(Boolean)
4441
+ .slice(0, 4),
4442
+ layout: 'split',
4443
+ subject: card.subject.trim(),
4444
+ sourcePoint: lead || docTitle,
4445
+ itemId: id,
4446
+ };
4447
+ }),
4448
+ ];
4449
+ // Pad content cards if chosenContent.length < maxContent so total length equals cardsCount
4450
+ while (renderedCards.length < cardsCount - 1) {
4451
+ const seed = renderedCards[renderedCards.length - 1] as Record<string, unknown>;
4452
+ renderedCards.push({ ...seed });
4453
+ }
4454
+ const closingPoints = [outro || introText, cardTitle].filter(Boolean);
4455
+ renderedCards.push({
4456
+ role: 'closing',
4457
+ copy: '本期小结',
4458
+ points: closingPoints.length >= 2 ? closingPoints : [outro || cardTitle, '感谢关注每日AI乱炖'],
4459
+ layout: 'summary',
4460
+ subject: firstSubject,
4461
+ sourcePoint: outro || introText,
4462
+ });
4463
+ const xhsDir = join(workDir, '25-xhs');
4464
+ sted('mkdir', xhsDir, () => mkdirSync(xhsDir, { recursive: true }));
4465
+ const xhsOut = join(xhsDir, `${runDate}_18_xhs_plan.json`);
4466
+ sted('write', xhsOut, () =>
4467
+ writeJsonText(xhsOut, {
4468
+ title: cardTitle,
4469
+ body: cardBody,
4470
+ cards: renderedCards,
4471
+ }),
4472
+ );
4473
+ console.log(`editorial-render: wrote xhs card plan (${renderedCards.length} card(s)): ${xhsOut}`);
4474
+ } else {
4475
+ console.log('editorial-render: skipping xhs card plan (missing cards.title/body or items[].card)');
4476
+ }
4477
+ }
4478
+
4479
+ // 4 & 5. Social posts: 26-social/{x,threads}.md and 26-social/{weibo,linkedin}.md
4480
+ const social =
4481
+ master.social !== null && typeof master.social === 'object' ? (master.social as Record<string, unknown>) : null;
4482
+ const socialShort = typeof social?.short === 'string' ? social.short.trim() : '';
4483
+ const socialDir = join(workDir, '26-social');
4484
+ if (socialShort !== '') {
4485
+ sted('mkdir', socialDir, () => mkdirSync(socialDir, { recursive: true }));
4486
+ for (const name of ['x.md', 'threads.md']) {
4487
+ const out = join(socialDir, name);
4488
+ sted('write', out, () => writeRaw(out, `${socialShort}\n`));
4489
+ }
4490
+ console.log('editorial-render: wrote 26-social/{x,threads}.md');
4491
+ } else {
4492
+ console.log('editorial-render: skipping social short posts (missing social.short in script plan)');
4493
+ }
4494
+ const socialLong = typeof social?.long === 'string' ? social.long.trim() : '';
4495
+ if (socialLong !== '') {
4496
+ sted('mkdir', socialDir, () => mkdirSync(socialDir, { recursive: true }));
4497
+ for (const name of ['weibo.md', 'linkedin.md']) {
4498
+ const out = join(socialDir, name);
4499
+ sted('write', out, () => writeRaw(out, `${socialLong}\n`));
4500
+ }
4501
+ console.log('editorial-render: wrote 26-social/{weibo,linkedin}.md');
4502
+ } else {
4503
+ console.log('editorial-render: skipping social long posts (missing social.long in script plan)');
4504
+ }
4505
+ }
4506
+
4043
4507
  export function dailyShowNotes(args: string[]): void {
4044
4508
  const [workDir = '', runDate = '', pluginsPath = '', title = ''] = args;
4045
4509
  const article = join(workDir, '22-article', `${runDate}_06_article_content.json`);