@gobing-ai/knowledge-kit 0.0.11 → 0.0.13

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 (99) hide show
  1. package/dist/index.js +451 -152
  2. package/package.json +1 -1
  3. package/plugins/generations/content-gen/dist/index.js +22167 -0
  4. package/plugins/generations/content-gen/plugin.json +1 -1
  5. package/plugins/generations/content-gen/src/storm.ts +99 -50
  6. package/plugins/generations/core-facts-gen/dist/index.js +22048 -0
  7. package/plugins/generations/core-facts-gen/package.json +17 -0
  8. package/plugins/generations/core-facts-gen/plugin.json +7 -0
  9. package/plugins/generations/core-facts-gen/src/index.ts +116 -0
  10. package/plugins/generations/core-facts-gen/tsconfig.json +4 -0
  11. package/plugins/generations/daily-article-gen/dist/index.js +22022 -0
  12. package/plugins/generations/daily-article-gen/package.json +17 -0
  13. package/plugins/generations/daily-article-gen/plugin.json +7 -0
  14. package/plugins/generations/daily-article-gen/src/index.ts +91 -0
  15. package/plugins/generations/daily-article-gen/tsconfig.json +4 -0
  16. package/plugins/generations/dailynews-gen/dist/index.js +22324 -0
  17. package/plugins/generations/dailynews-gen/plugin.json +1 -1
  18. package/plugins/generations/dailynews-gen/src/index.ts +11 -0
  19. package/plugins/generations/dailynews-gen/src/script-builder.ts +1 -1
  20. package/plugins/generations/episode-plan-gen/dist/index.js +22479 -0
  21. package/plugins/generations/episode-plan-gen/package.json +17 -0
  22. package/plugins/generations/episode-plan-gen/plugin.json +7 -0
  23. package/plugins/generations/episode-plan-gen/src/index.ts +726 -0
  24. package/plugins/generations/episode-plan-gen/tsconfig.json +4 -0
  25. package/plugins/generations/image-gen/config.example.yaml +75 -0
  26. package/plugins/generations/image-gen/dist/index.js +22619 -0
  27. package/plugins/generations/image-gen/package.json +17 -0
  28. package/plugins/generations/image-gen/plugin.json +7 -0
  29. package/plugins/generations/image-gen/presets/formats/cover.yaml +57 -0
  30. package/plugins/generations/image-gen/presets/formats/free.yaml +46 -0
  31. package/plugins/generations/image-gen/presets/formats/illustration.yaml +48 -0
  32. package/plugins/generations/image-gen/presets/styles/clean-webapp-ui.yaml +28 -0
  33. package/plugins/generations/image-gen/presets/styles/cute.yaml +3 -0
  34. package/plugins/generations/image-gen/presets/styles/editorial.yaml +3 -0
  35. package/plugins/generations/image-gen/presets/styles/fresh.yaml +3 -0
  36. package/plugins/generations/image-gen/presets/styles/minimalist.yaml +3 -0
  37. package/plugins/generations/image-gen/presets/styles/photorealistic.yaml +3 -0
  38. package/plugins/generations/image-gen/presets/styles/sketch.yaml +3 -0
  39. package/plugins/generations/image-gen/presets/styles/technical-diagram.yaml +3 -0
  40. package/plugins/generations/image-gen/presets/styles/vibrant.yaml +3 -0
  41. package/plugins/generations/image-gen/presets/styles/warm.yaml +3 -0
  42. package/plugins/generations/image-gen/src/bytes.ts +19 -0
  43. package/plugins/generations/image-gen/src/index.ts +319 -0
  44. package/plugins/generations/image-gen/src/job.ts +143 -0
  45. package/plugins/generations/image-gen/src/paths.ts +31 -0
  46. package/plugins/generations/image-gen/src/presets.ts +344 -0
  47. package/plugins/generations/image-gen/src/providers/seedream.ts +128 -0
  48. package/plugins/generations/image-gen/src/providers/types.ts +285 -0
  49. package/plugins/generations/image-gen/tsconfig.json +8 -0
  50. package/plugins/generations/voice-gen/dist/index.js +23027 -0
  51. package/plugins/generations/voice-gen/plugin.json +1 -1
  52. package/plugins/generations/voice-gen/src/index.ts +102 -11
  53. package/plugins/generations/voice-gen/src/qc.ts +154 -9
  54. package/plugins/ingestions/aihot-ingest/dist/index.js +22358 -0
  55. package/plugins/ingestions/aihot-ingest/plugin.json +2 -2
  56. package/plugins/ingestions/aihot-ingest/src/index.ts +72 -13
  57. package/plugins/ingestions/aihot-ingest/src/mapper.ts +1 -0
  58. package/plugins/ingestions/aihot-ingest/src/rss.ts +151 -0
  59. package/plugins/ingestions/horizon-ingest/dist/index.js +22105 -0
  60. package/plugins/ingestions/horizon-ingest/package.json +17 -0
  61. package/plugins/ingestions/horizon-ingest/plugin.json +7 -0
  62. package/plugins/ingestions/horizon-ingest/src/index.ts +205 -0
  63. package/plugins/ingestions/horizon-ingest/tsconfig.json +4 -0
  64. package/plugins/ingestions/karakeep-local/dist/index.js +24204 -0
  65. package/plugins/ingestions/karakeep-local/plugin.json +1 -1
  66. package/plugins/ingestions/last30days-ingest/dist/index.js +22050 -0
  67. package/plugins/ingestions/last30days-ingest/package.json +17 -0
  68. package/plugins/ingestions/last30days-ingest/plugin.json +7 -0
  69. package/plugins/ingestions/last30days-ingest/src/index.ts +148 -0
  70. package/plugins/ingestions/last30days-ingest/tsconfig.json +4 -0
  71. package/plugins/ingestions/web-search/dist/index.js +24379 -0
  72. package/plugins/ingestions/web-search/plugin.json +1 -1
  73. package/plugins/kk/commands/image-generate.md +15 -0
  74. package/plugins/kk/config.example.yaml +80 -0
  75. package/plugins/kk/skills/image-authoring/SKILL.md +250 -0
  76. package/plugins/kk/skills/image-authoring/references/illustration-positions.md +87 -0
  77. package/plugins/kk/skills/image-authoring/references/migrating-from-wt.md +31 -0
  78. package/plugins/kk/skills/image-authoring/references/providers.md +52 -0
  79. package/plugins/kk/skills/image-authoring/references/style-extraction.md +134 -0
  80. package/plugins/kk/skills/taste-unslop/SKILL.md +12 -6
  81. package/plugins/kk/skills/taste-unslop/references/pattern-guide.md +128 -48
  82. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +432 -19
  83. package/plugins/publishings/emdash-pub/dist/index.js +22243 -0
  84. package/plugins/publishings/emdash-pub/plugin.json +1 -1
  85. package/plugins/publishings/podcast-pub/dist/index.js +22596 -0
  86. package/plugins/publishings/podcast-pub/package.json +17 -0
  87. package/plugins/publishings/podcast-pub/plugin.json +13 -0
  88. package/plugins/publishings/podcast-pub/src/index.ts +538 -0
  89. package/plugins/publishings/podcast-pub/src/map.ts +165 -0
  90. package/plugins/publishings/podcast-pub/src/microfeed-client.ts +196 -0
  91. package/plugins/publishings/podcast-pub/src/show-notes.ts +132 -0
  92. package/plugins/publishings/podcast-pub/tsconfig.json +4 -0
  93. package/plugins/publishings/qiita-pub/dist/index.js +22081 -0
  94. package/plugins/publishings/qiita-pub/plugin.json +1 -1
  95. package/plugins/publishings/surfdash-pub/dist/index.js +22233 -0
  96. package/plugins/publishings/surfdash-pub/plugin.json +1 -1
  97. package/plugins/publishings/surfdash-pub/src/index.ts +328 -62
  98. package/plugins/publishings/zenn-pub/dist/index.js +22122 -0
  99. package/plugins/publishings/zenn-pub/plugin.json +1 -1
@@ -0,0 +1,726 @@
1
+ import { dirname } from 'node:path';
2
+ import { parseArgs } from 'node:util';
3
+ import { type Content, ContentSchema, type Doc, DocListSchema } from '@gobing-ai/kk-core';
4
+ import { atomicWriteJson, createNodeFileSystem, readJsonFile } from '@gobing-ai/ts-runtime';
5
+ import { echoError } from '@gobing-ai/ts-utils';
6
+
7
+ /**
8
+ * episode-plan-gen: plan the daily episode from the blended `Doc[]`. Annotate
9
+ * every doc first (MECE `metadata.category` + four 0–5 `metadata.scores` QC
10
+ * dimensions — deterministic, no LLM), then mechanically select: score each doc
11
+ * (source weight + normalized relevance + log-scaled engagement), dedup by
12
+ * normalized URL then by title bigram similarity, keep the top N, and flag
13
+ * English items for the workflow's translation step. Output is a Content whose `metadata.docs` carries the
14
+ * selected `Doc[]`; the workflow extracts that array for downstream steps.
15
+ *
16
+ * Env: `EPISODE_PLAN_MAX_ITEMS` (default 20), `EPISODE_PLAN_DATE` (YYYYMMDD or
17
+ * ISO, else today UTC). `--mode filter` (0094) adds the quality-controller gate:
18
+ * annotated `Doc[]` in, category-allowlist + inclusive per-dimension minimums
19
+ * out — survivors to `--out`, rejection audit to `<out>.rejected.json`; env
20
+ * `QC_CATEGORIES` (default 'ai-news,ai-industry'),
21
+ * `QC_MIN_QUALITY/IMPORTANCE/URGENCY/IMPACT` (defaults 2/3/2/1 — 0099 recalibration);
22
+ * invalid values exit 1 (fail loud, no clamping).
23
+ */
24
+
25
+ /** Tunable scoring knobs (kept here so the shape is greppable, not config-filed). */
26
+ const SOURCE_BASE: Record<string, number> = {
27
+ aihot: 30, // human-curated feed — outranks everything at equal evidence
28
+ rss: 12, // horizon-ranked feed items
29
+ hackernews: 10,
30
+ youtube: 8,
31
+ github: 6,
32
+ reddit: 5,
33
+ grounding: 5,
34
+ polymarket: 4,
35
+ };
36
+ const DEFAULT_SOURCE_BASE = 5;
37
+ const TITLE_SIMILARITY_THRESHOLD = 0.6; // character-bigram Jaccard
38
+ const FRESHNESS_DAYS = 7; // a daily episode drops items published before this window (dogfood 20260906: aihot served a 2020-2025 blog archive as "selected")
39
+ const PER_SOURCE_CAP = 5; // one feed can't flood the episode even when all its items score high
40
+ const NON_NEWS_TAGS = new Set(['geopolitics', 'military-conflict', 'war']); // horizon finance-news tags (dogfood 20260906: Iran oil tankers in an AI episode)
41
+
42
+ /**
43
+ * MECE category taxonomy for the annotate pass (v1 — tune later). classifyCategory
44
+ * resolves first match wins: (1) a metadata.tags hit in TAG_CATEGORY, (2) a source
45
+ * in NON_AI_SOURCES (default-deny — skips the keyword scan), (3) keyword signals in
46
+ * the title + first 200 chars of the body, (4) default `non-ai`.
47
+ */
48
+ export type Category = 'ai-news' | 'ai-technical' | 'ai-research' | 'ai-industry' | 'non-ai';
49
+
50
+ /** The four QC dimensions written to `metadata.scores`; each an integer 0–5 (v1 rubric — tune later). */
51
+ export interface DimensionScores {
52
+ quality: number;
53
+ importance: number;
54
+ urgency: number;
55
+ impact: number;
56
+ }
57
+
58
+ /**
59
+ * tag (lowercased) → category; checked before any source or keyword signal. The
60
+ * generic-AI / major-lab entries are the 0099 expansion (0098 dogfood r7: items tagged
61
+ * OpenAI/AI/LLM fell through to `non-ai` under the v1 map).
62
+ */
63
+ const TAG_CATEGORY: Record<string, Category> = {
64
+ 'ai-news': 'ai-news',
65
+ ai: 'ai-news',
66
+ 'artificial-intelligence': 'ai-news',
67
+ 'generative-ai': 'ai-news',
68
+ genai: 'ai-news',
69
+ llm: 'ai-news',
70
+ llms: 'ai-news',
71
+ openai: 'ai-news',
72
+ anthropic: 'ai-news',
73
+ deepmind: 'ai-news',
74
+ 'google-deepmind': 'ai-news',
75
+ 'meta-ai': 'ai-news',
76
+ mistral: 'ai-news',
77
+ xai: 'ai-news',
78
+ 'coding-agents': 'ai-news',
79
+ 'coding agent': 'ai-news',
80
+ research: 'ai-research',
81
+ alignment: 'ai-research',
82
+ 'research-paper': 'ai-research',
83
+ paper: 'ai-research',
84
+ tutorial: 'ai-technical',
85
+ guide: 'ai-technical',
86
+ geopolitics: 'non-ai',
87
+ 'military-conflict': 'non-ai',
88
+ war: 'non-ai',
89
+ };
90
+
91
+ /** Sources whose items are by default not AI content — the keyword scan is skipped for them. */
92
+ const NON_AI_SOURCES = new Set(['polymarket']);
93
+
94
+ /**
95
+ * Title/body keyword signals, scanned in array order (junk → technical → news → industry);
96
+ * first hit wins. The junk group is the 0099 audit: ingest template artifacts — error logs,
97
+ * checklist boilerplate — used to absorb AI keywords riding along in the boilerplate (0098
98
+ * dogfood r7: a yt-dlp HTTP-403 error post classified INTO ai-industry via the word "policy"
99
+ * in its "NO AI / NO LLM POLICY" checklist), so they now land `non-ai` instead.
100
+ */
101
+ const CATEGORY_KEYWORDS: ReadonlyArray<{ category: Category; words: string[] }> = [
102
+ {
103
+ // Ingest junk: error-log titles and template checklists. Phrases are tight enough
104
+ // to never fire on genuine coverage (verified against the r6/r7 blended corpora).
105
+ category: 'non-ai',
106
+ words: ['error:', 'http error', 'unable to download'],
107
+ },
108
+ {
109
+ // Technical vocabulary: APIs, tutorials, benchmarks, architecture, inference.
110
+ category: 'ai-technical',
111
+ words: ['api', 'tutorial', 'benchmark', 'architecture', 'sdk', 'cli', 'quantization', 'inference', 'fine-tun'],
112
+ },
113
+ {
114
+ // Launch/release/announce verbs + product names.
115
+ category: 'ai-news',
116
+ words: [
117
+ 'launch',
118
+ 'release',
119
+ 'announce',
120
+ 'unveil',
121
+ 'debuts',
122
+ 'ships',
123
+ '发布',
124
+ '上线',
125
+ '推出',
126
+ '开源',
127
+ 'sora',
128
+ 'gpt',
129
+ 'claude',
130
+ 'gemini',
131
+ 'llama',
132
+ 'grok',
133
+ 'copilot',
134
+ ],
135
+ },
136
+ {
137
+ // Company/funding/policy signals.
138
+ category: 'ai-industry',
139
+ words: [
140
+ 'funding',
141
+ 'raises',
142
+ 'acquisition',
143
+ 'acquires',
144
+ 'ipo',
145
+ 'valuation',
146
+ 'policy',
147
+ 'regulation',
148
+ 'lawsuit',
149
+ '融资',
150
+ '收购',
151
+ '法案',
152
+ '监管',
153
+ ],
154
+ },
155
+ ];
156
+
157
+ /** Source-tier base for the importance dimension (derived from SOURCE_BASE tiers: aihot → 4, rss/hackernews → 3, tail → 2). */
158
+ const IMPORTANCE_BASE: Record<string, number> = { aihot: 4, rss: 3, hackernews: 3 };
159
+ const DEFAULT_IMPORTANCE = 2;
160
+
161
+ export interface PlanOptions {
162
+ maxItems: number;
163
+ date?: string; // episode date (YYYY-MM-DD); freshness is judged against it, default today
164
+ }
165
+
166
+ /** Best-effort publication timestamp from mixed source metadata (aihot: publishedAt/discoveredAt; horizon: date). */
167
+ function docTimestamp(doc: Doc): number | null {
168
+ const meta = asRecord(doc.metadata);
169
+ for (const key of ['publishedAt', 'discoveredAt']) {
170
+ const value = meta[key];
171
+ if (typeof value === 'string' && value.trim()) {
172
+ const t = Date.parse(value);
173
+ if (Number.isFinite(t)) return t;
174
+ }
175
+ }
176
+ const date = meta.date;
177
+ if (typeof date === 'string' && /^\d{4}-\d{2}-\d{2}/.test(date.trim())) {
178
+ return Date.parse(date.trim().slice(0, 10));
179
+ }
180
+ return null;
181
+ }
182
+
183
+ /** Freshness + non-news gates applied before scoring/dedup (archived or off-topic items never enter the pool). */
184
+ function passesGates(doc: Doc, planDate: string): boolean {
185
+ const meta = asRecord(doc.metadata);
186
+ const tags = Array.isArray(meta.tags) ? meta.tags.filter((t): t is string => typeof t === 'string') : [];
187
+ if (tags.some((tag) => NON_NEWS_TAGS.has(tag.toLowerCase()))) return false;
188
+ const published = docTimestamp(doc);
189
+ if (published === null) return true; // undated sources (hackernews/reddit last30days) are curated same-day by construction
190
+ return published >= Date.parse(planDate) - FRESHNESS_DAYS * 86_400_000;
191
+ }
192
+
193
+ /** Strip `⭐️ x.y/10` ratings and `### [title](url)` heading copies from a body. */
194
+ export function cleanBody(body: string): string {
195
+ return body
196
+ .replace(/⭐️\s*[\d.]+\s*\/\s*10/g, '')
197
+ .replace(/^#{1,6}\s*\[[^\]]*\]\([^)]*\)\s*$/gm, '')
198
+ .replace(/\n{3,}/g, '\n\n')
199
+ .trim();
200
+ }
201
+
202
+ /** Canonical URL key: drop query, fragment, trailing slash, `www.`/`amp.` prefixes. */
203
+ export function normalizeUrl(raw: string | undefined): string | null {
204
+ if (!raw) return null;
205
+ let url: URL;
206
+ try {
207
+ url = new URL(raw);
208
+ } catch {
209
+ return null;
210
+ }
211
+ const host = url.hostname.replace(/^(www|amp)\./, '');
212
+ const path = url.pathname.replace(/\/+$/, '');
213
+ return `${url.protocol}//${host}${path}`;
214
+ }
215
+
216
+ /** ASCII ratio of a string; > 0.5 on the title marks an English item. */
217
+ function asciiRatio(text: string): number {
218
+ const chars = [...text].filter((c) => !/\s/.test(c));
219
+ if (chars.length === 0) return 0;
220
+ return chars.filter((c) => c.charCodeAt(0) < 128).length / chars.length;
221
+ }
222
+
223
+ /** Character-bigram set for Jaccard similarity. */
224
+ function bigrams(text: string): Set<string> {
225
+ const s = text.replace(/\s+/g, ' ').trim().toLowerCase();
226
+ const out = new Set<string>();
227
+ for (let i = 0; i < s.length - 1; i++) out.add(s.slice(i, i + 2));
228
+ return out;
229
+ }
230
+
231
+ export function titleSimilarity(a: string, b: string): number {
232
+ const A = bigrams(a);
233
+ const B = bigrams(b);
234
+ if (A.size === 0 || B.size === 0) return 0;
235
+ let inter = 0;
236
+ for (const g of A) if (B.has(g)) inter++;
237
+ return inter / (A.size + B.size - inter);
238
+ }
239
+
240
+ function asRecord(value: unknown): Record<string, unknown> {
241
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
242
+ ? (value as Record<string, unknown>)
243
+ : {};
244
+ }
245
+
246
+ function toNumber(value: unknown): number | undefined {
247
+ const n = typeof value === 'string' ? Number.parseFloat(value) : typeof value === 'number' ? value : Number.NaN;
248
+ return Number.isFinite(n) ? n : undefined;
249
+ }
250
+
251
+ /** Largest engagement number across mixed units (points/comments/stars/...); 0 when empty. */
252
+ function maxEngagement(engagement: Record<string, unknown>): number {
253
+ let max = 0;
254
+ for (const v of Object.values(engagement)) {
255
+ const n = toNumber(v);
256
+ if (n !== undefined && n > max) max = n;
257
+ }
258
+ return max;
259
+ }
260
+
261
+ /** Engagement magnitude → 0..10 via log10 scaling (mixed units across sources). */
262
+ function engagementScore(engagement: Record<string, unknown>): number {
263
+ const max = maxEngagement(engagement);
264
+ if (max <= 0) return 0;
265
+ return Math.min(10, Math.log10(max));
266
+ }
267
+
268
+ /**
269
+ * Latin keywords match on a whole-word boundary plus common inflections
270
+ * (`launch` still hits `launches`/`launched`/`launching` and `fine-tun` hits
271
+ * `fine-tuning`, but short keywords never prefix-over-match — `cli` does not
272
+ * hit `client`); CJK keywords match as substrings.
273
+ */
274
+ function matchesKeyword(haystack: string, word: string): boolean {
275
+ if (word.charCodeAt(0) > 0x2e80) return haystack.includes(word);
276
+ return new RegExp(`\\b${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:e?s|ed|d|ing)?\\b`, 'i').test(haystack);
277
+ }
278
+
279
+ /**
280
+ * MECE category of a doc (v1 — tune later), first match wins: tag map →
281
+ * non-AI source deny → title + body-head keyword scan → `non-ai`.
282
+ * Deterministic, no LLM.
283
+ */
284
+ export function classifyCategory(doc: Doc): Category {
285
+ const meta = asRecord(doc.metadata);
286
+ const tags = Array.isArray(meta.tags) ? meta.tags.filter((t): t is string => typeof t === 'string') : [];
287
+ for (const tag of tags) {
288
+ const hit = TAG_CATEGORY[tag.toLowerCase()];
289
+ if (hit) return hit;
290
+ }
291
+ if (NON_AI_SOURCES.has(typeof meta.source === 'string' ? meta.source : '')) return 'non-ai';
292
+ const title = (doc.title ?? '').toLowerCase();
293
+ const bodyHead = (doc.body ?? '').slice(0, 200).toLowerCase();
294
+ for (const { category, words } of CATEGORY_KEYWORDS) {
295
+ if (words.some((w) => matchesKeyword(title, w) || matchesKeyword(bodyHead, w))) return category;
296
+ }
297
+ return 'non-ai';
298
+ }
299
+
300
+ /**
301
+ * Four QC dimensions (v1 — tune later), integers 0–5. Purity contract: a pure
302
+ * function of (doc, planDate) — `planDate` is REQUIRED and the caller passes the
303
+ * resolved plan date (never the wall clock), so the same input always yields the
304
+ * same output (R3 determinism). quality from body length + sourcedness,
305
+ * importance from source tier + relevance, urgency from publication date vs
306
+ * `planDate`, impact from engagement magnitude. Ranking still uses planScore;
307
+ * these feed QC thresholds (0094 filter mode).
308
+ */
309
+ export function scoreDimensions(doc: Doc, planDate: string): DimensionScores {
310
+ const meta = asRecord(doc.metadata);
311
+ let quality = 1 + Math.min(4, Math.round((doc.body ?? '').length / 1500));
312
+ if (doc.sourceUri) quality = Math.min(5, quality + 1);
313
+
314
+ const source = typeof meta.source === 'string' ? meta.source : '';
315
+ let importance = IMPORTANCE_BASE[source] ?? DEFAULT_IMPORTANCE;
316
+ const relevance = toNumber(meta.relevance_score);
317
+ if (relevance !== undefined && relevance >= 0.8) importance = Math.min(5, importance + 1);
318
+
319
+ const published = docTimestamp(doc);
320
+ let urgency = 3; // undated → curated same-day sources
321
+ if (published !== null) {
322
+ const dayMs = 86_400_000;
323
+ const planDay = Math.floor(Date.parse(planDate) / dayMs);
324
+ const ageDays = planDay - Math.floor(published / dayMs);
325
+ urgency = ageDays <= 0 ? 5 : ageDays <= 2 ? 4 : ageDays <= 7 ? 3 : 1;
326
+ }
327
+
328
+ const max = maxEngagement(asRecord(meta.engagement));
329
+ const impact = max > 1000 ? 5 : max > 100 ? 4 : max > 10 ? 3 : max > 1 ? 2 : 1;
330
+
331
+ return { quality, importance, urgency, impact };
332
+ }
333
+
334
+ /** Composite 0..100-ish score; ordering matters more than absolute value. */
335
+ export function planScore(doc: Doc): number {
336
+ const meta = asRecord(doc.metadata);
337
+ const source = typeof meta.source === 'string' ? meta.source : '';
338
+ let score = SOURCE_BASE[source] ?? DEFAULT_SOURCE_BASE;
339
+
340
+ const relevance = toNumber(meta.relevance_score) ?? toNumber(meta.score);
341
+ if (relevance !== undefined) {
342
+ // horizon `score` is 0..10; last30days `relevance_score` is 0..1.
343
+ score += relevance <= 1 ? relevance * 10 : Math.min(relevance * 10, 40) / 4;
344
+ }
345
+ const engagement = engagementScore(asRecord(meta.engagement));
346
+ score += engagement * 1.5;
347
+ return Math.round(score * 10) / 10;
348
+ }
349
+
350
+ /** Winner of a merge keeps a trace of what it absorbed (source + URL). */
351
+ interface MergedFrom {
352
+ source: string;
353
+ title: string;
354
+ url: string | null;
355
+ }
356
+
357
+ interface ScoredDoc {
358
+ doc: Doc;
359
+ score: number;
360
+ mergedFrom: MergedFrom[];
361
+ }
362
+
363
+ /** Full plan pipeline: clean → annotate (category + 4-dim QC scores) → gate (freshness/non-news) → score → URL-dedup → title-dedup → per-source cap → top N. */
364
+ export function planEpisode(docs: Doc[], options: PlanOptions): Doc[] {
365
+ const planDate = options.date ?? new Date().toISOString().slice(0, 10);
366
+ // URL dedup happens on the original sourceUri (first occurrence wins ties,
367
+ // resolved below by score after the map).
368
+ const byUrl = new Map<string, ScoredDoc>();
369
+ const scored: ScoredDoc[] = [];
370
+ for (const doc of docs) {
371
+ // Annotate before any gate — understanding precedes selection; annotate drops nothing.
372
+ const cleaned: Doc = {
373
+ ...doc,
374
+ body: cleanBody(doc.body ?? ''),
375
+ metadata: {
376
+ ...asRecord(doc.metadata),
377
+ category: classifyCategory(doc),
378
+ scores: scoreDimensions(doc, planDate),
379
+ },
380
+ };
381
+ if (!passesGates(cleaned, planDate)) continue;
382
+ const record = { doc: cleaned, score: planScore(cleaned), mergedFrom: [] as MergedFrom[] };
383
+ const urlKey = normalizeUrl(doc.sourceUri);
384
+ if (urlKey) {
385
+ const existing = byUrl.get(urlKey);
386
+ if (existing) {
387
+ // Higher score wins; the loser is recorded in mergedFrom.
388
+ const loser = record.score > existing.score ? existing : record;
389
+ const winner = record.score > existing.score ? record : existing;
390
+ winner.mergedFrom.push({
391
+ source: sourceOf(loser.doc),
392
+ title: loser.doc.title ?? '',
393
+ url: loser.doc.sourceUri ?? null,
394
+ });
395
+ if (winner !== existing) {
396
+ existing.doc = winner.doc;
397
+ existing.score = winner.score;
398
+ existing.mergedFrom = winner.mergedFrom;
399
+ }
400
+ continue;
401
+ }
402
+ byUrl.set(urlKey, record);
403
+ }
404
+ scored.push(record);
405
+ }
406
+
407
+ // Title-similarity dedup across distinct URLs: keep the higher-scoring doc.
408
+ const absorbed = new Set<ScoredDoc>();
409
+ for (let i = 0; i < scored.length; i++) {
410
+ for (let j = i + 1; j < scored.length; j++) {
411
+ const a = scored[i];
412
+ const b = scored[j];
413
+ if (!a || !b || absorbed.has(a) || absorbed.has(b)) continue;
414
+ const titleA = a.doc.title ?? '';
415
+ const titleB = b.doc.title ?? '';
416
+ if (!titleA || !titleB) continue;
417
+ if (titleSimilarity(titleA, titleB) < TITLE_SIMILARITY_THRESHOLD) continue;
418
+ const loser = a.score < b.score ? a : b;
419
+ const winner = a.score < b.score ? b : a;
420
+ winner.mergedFrom.push({
421
+ source: sourceOf(loser.doc),
422
+ title: loser.doc.title ?? '',
423
+ url: loser.doc.sourceUri ?? null,
424
+ });
425
+ absorbed.add(loser);
426
+ }
427
+ }
428
+
429
+ const candidates = scored.filter((r) => !absorbed.has(r)).sort((a, b) => b.score - a.score);
430
+ // Per-source cap, applied in score order: one feed's flood stops at PER_SOURCE_CAP.
431
+ const perSource = new Map<string, number>();
432
+ const selected: ScoredDoc[] = [];
433
+ for (const record of candidates) {
434
+ const source = sourceOf(record.doc);
435
+ const count = perSource.get(source) ?? 0;
436
+ if (count >= PER_SOURCE_CAP) continue;
437
+ perSource.set(source, count + 1);
438
+ selected.push(record);
439
+ if (selected.length >= Math.max(1, options.maxItems)) break;
440
+ }
441
+
442
+ return selected.map((r) => {
443
+ const meta = asRecord(r.doc.metadata);
444
+ const ascii = asciiRatio(r.doc.title ?? '');
445
+ return {
446
+ ...r.doc,
447
+ metadata: {
448
+ ...meta,
449
+ planScore: r.score,
450
+ ...(ascii > 0.5 ? { needsTranslation: true } : {}),
451
+ ...(r.mergedFrom.length > 0 ? { mergedFrom: r.mergedFrom } : {}),
452
+ },
453
+ } satisfies Doc;
454
+ });
455
+ }
456
+
457
+ function sourceOf(doc: Doc): string {
458
+ const meta = asRecord(doc.metadata);
459
+ return typeof meta.source === 'string' ? meta.source : 'unknown';
460
+ }
461
+
462
+ /** Human-readable plan markdown (the Content body; not consumed downstream). */
463
+ export function renderPlanMarkdown(docs: Doc[], date: string): string {
464
+ const items = docs
465
+ .map((doc, i) => {
466
+ const meta = asRecord(doc.metadata);
467
+ const parts = [
468
+ `## ${i + 1}. ${doc.title ?? `条目 ${i + 1}`}`,
469
+ `${meta.planScore !== undefined ? `planScore: ${String(meta.planScore)}\n` : ''}${doc.body ?? ''}`,
470
+ ];
471
+ return parts.join('\n\n');
472
+ })
473
+ .join('\n\n');
474
+ return `# 每日 AI 播客选稿计划 — ${date}\n\n本期共 ${docs.length} 条(评分/去重/标记后精选)。\n\n${items}\n`;
475
+ }
476
+
477
+ /** Build the plan Content; `metadata.docs` is the contract with the workflow. */
478
+ export function docsToContent(docs: Doc[], date: string): Content {
479
+ const planned = planEpisode(docs, { maxItems: maxItemsFromEnv(), date });
480
+ return planContent(planned, date);
481
+ }
482
+
483
+ /** Shared ContentSchema wrapper (plan + filter modes); `metadata.docs: []` stays a valid exit-0 Content. */
484
+ function planContent(docs: Doc[], date: string): Content {
485
+ return ContentSchema.parse({
486
+ title: `每日 AI 播客选稿计划 — ${date}`,
487
+ body: renderPlanMarkdown(docs, date),
488
+ format: 'markdown',
489
+ metadata: {
490
+ generator: 'kk:episode-plan',
491
+ date,
492
+ doc_count: docs.length,
493
+ docs,
494
+ },
495
+ });
496
+ }
497
+
498
+ function maxItemsFromEnv(): number {
499
+ const raw = process.env.EPISODE_PLAN_MAX_ITEMS;
500
+ if (raw === undefined || raw.trim() === '') return 20;
501
+ const n = Number.parseInt(raw, 10);
502
+ return Number.isFinite(n) && n > 0 ? n : 20;
503
+ }
504
+
505
+ /** Plan date shared by both modes: `EPISODE_PLAN_DATE` (YYYYMMDD or ISO) else today UTC. */
506
+ function resolvePlanDate(): string {
507
+ const raw = process.env.EPISODE_PLAN_DATE ?? new Date().toISOString().slice(0, 10);
508
+ return raw.length === 8 ? `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}` : raw;
509
+ }
510
+
511
+ // ---------------------------------------------------------------------------
512
+ // Quality-controller filter mode (0094) — annotated Doc[] in, auditable
513
+ // kept/rejected split out (ADR-019 D3: QC is a visible, threshold-driven node).
514
+ // ---------------------------------------------------------------------------
515
+
516
+ /** Run modes: `plan` (default — the gated selection pipeline, unchanged) or `filter` (the QC gate). */
517
+ export type GeneratorMode = 'plan' | 'filter';
518
+
519
+ /** The closed MECE taxonomy (the values `classifyCategory` emits) — filter allowlists validate against it. */
520
+ const MECE_CATEGORIES: readonly Category[] = ['ai-news', 'ai-technical', 'ai-research', 'ai-industry', 'non-ai'];
521
+
522
+ /** The four QC dimensions, in reason-reporting order. */
523
+ const QC_DIMENSIONS = ['quality', 'importance', 'urgency', 'impact'] as const;
524
+ type QCDimension = (typeof QC_DIMENSIONS)[number];
525
+
526
+ /** Quality-controller filter configuration; every minimum is inclusive (score == min passes). */
527
+ export interface FilterConfig {
528
+ /** Category allowlist — a doc survives only when `metadata.category` is one of these. */
529
+ categories: Category[];
530
+ minQuality: number;
531
+ minImportance: number;
532
+ minUrgency: number;
533
+ minImpact: number;
534
+ }
535
+
536
+ /** A doc the filter dropped, with every failed check (`category:not-in-allowlist`, `quality:2<3`, …). */
537
+ export interface RejectedDoc {
538
+ doc: Doc;
539
+ reasons: string[];
540
+ }
541
+
542
+ /** Single source of filter defaults — standalone CLI and workflow stay in lockstep (no divergence). */
543
+ export const DEFAULT_FILTER_CONFIG: FilterConfig = {
544
+ categories: ['ai-news', 'ai-industry'],
545
+ // 0098 dogfood calibration (r7, owner-approved 0099): the shipped 3/3/2/2 rejected 20/20
546
+ // live candidates — the two strongest ai-news items died solely on `impact:1<2`, and
547
+ // discussion-tier last30days content scores quality 2. quality 3→2, impact 2→1
548
+ // (importance/urgency unchanged). The workflow's QC_* pins (kk-daily-ai-voice.yaml)
549
+ // move in lockstep — env wins over these defaults, so a half-move is silently wrong.
550
+ minQuality: 2,
551
+ minImportance: 3,
552
+ minUrgency: 2,
553
+ minImpact: 1,
554
+ };
555
+
556
+ /** One QC_MIN_* threshold: an integer 0–5 or absent (→ fallback); anything else fails loud — never clamped. */
557
+ function parseFilterThreshold(raw: string | undefined, name: string, fallback: number): number {
558
+ if (raw === undefined || raw.trim() === '') return fallback;
559
+ const value = raw.trim();
560
+ if (!/^\d+$/.test(value)) throw new Error(`${name}='${raw}' is not an integer in 0–5 (fail loud, no clamping)`);
561
+ const parsed = Number.parseInt(value, 10);
562
+ if (parsed < 0 || parsed > 5) throw new Error(`${name}=${parsed} is outside 0–5 (fail loud, no clamping)`);
563
+ return parsed;
564
+ }
565
+
566
+ /** QC_CATEGORIES: a comma list of MECE categories; unknown entries fail loud (no silent drops). */
567
+ function parseFilterCategories(raw: string | undefined): Category[] {
568
+ if (raw === undefined || raw.trim() === '') return [...DEFAULT_FILTER_CONFIG.categories];
569
+ const entries = raw.split(',').map((entry) => entry.trim());
570
+ for (const entry of entries) {
571
+ if (!MECE_CATEGORIES.includes(entry as Category)) {
572
+ throw new Error(
573
+ `QC_CATEGORIES entry '${entry}' is not a MECE category (valid: ${MECE_CATEGORIES.join(', ')})`,
574
+ );
575
+ }
576
+ }
577
+ return entries as Category[];
578
+ }
579
+
580
+ /** Filter env in one place: QC_CATEGORIES + QC_MIN_{QUALITY,IMPORTANCE,URGENCY,IMPACT}; defaults from DEFAULT_FILTER_CONFIG. */
581
+ export function filterConfigFromEnv(env: Record<string, string | undefined> = process.env): FilterConfig {
582
+ return {
583
+ categories: parseFilterCategories(env.QC_CATEGORIES),
584
+ minQuality: parseFilterThreshold(env.QC_MIN_QUALITY, 'QC_MIN_QUALITY', DEFAULT_FILTER_CONFIG.minQuality),
585
+ minImportance: parseFilterThreshold(
586
+ env.QC_MIN_IMPORTANCE,
587
+ 'QC_MIN_IMPORTANCE',
588
+ DEFAULT_FILTER_CONFIG.minImportance,
589
+ ),
590
+ minUrgency: parseFilterThreshold(env.QC_MIN_URGENCY, 'QC_MIN_URGENCY', DEFAULT_FILTER_CONFIG.minUrgency),
591
+ minImpact: parseFilterThreshold(env.QC_MIN_IMPACT, 'QC_MIN_IMPACT', DEFAULT_FILTER_CONFIG.minImpact),
592
+ };
593
+ }
594
+
595
+ /** Read 0093's annotations off a filter input doc; fail loud naming the doc id — never silently pass unannotated docs. */
596
+ function requireAnnotation(doc: Doc): { category: Category; scores: Record<QCDimension, number> } {
597
+ const meta = asRecord(doc.metadata);
598
+ const category = meta.category;
599
+ if (typeof category !== 'string' || !MECE_CATEGORIES.includes(category as Category)) {
600
+ throw new Error(`doc '${doc.id}' has missing/invalid metadata.category — run the annotate pass first`);
601
+ }
602
+ const raw = asRecord(meta.scores);
603
+ const scores = {} as Record<QCDimension, number>;
604
+ for (const dim of QC_DIMENSIONS) {
605
+ const value = raw[dim];
606
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 5) {
607
+ throw new Error(`doc '${doc.id}' has missing/invalid metadata.scores.${dim} — run the annotate pass first`);
608
+ }
609
+ scores[dim] = value;
610
+ }
611
+ return { category: category as Category, scores };
612
+ }
613
+
614
+ /**
615
+ * Quality-controller filter (0094): an annotated `Doc[]` in, a kept/rejected split out. A doc
616
+ * survives iff `metadata.category` is in the allowlist AND every dimension score is ≥ its inclusive
617
+ * minimum (score == min passes). Rejection reasons accumulate every failed check in a fixed order —
618
+ * `category:not-in-allowlist`, then `quality:2<3` / `importance:…` / `urgency:…` / `impact:…`.
619
+ * Pure: docs are consumed read-only (no re-annotation, no mutation, kept docs returned as-is);
620
+ * a missing or invalid annotation throws naming the doc id (main() → exit 1).
621
+ */
622
+ export function filterAnnotated(docs: Doc[], config: FilterConfig): { kept: Doc[]; rejected: RejectedDoc[] } {
623
+ const kept: Doc[] = [];
624
+ const rejected: RejectedDoc[] = [];
625
+ const minimums: Record<QCDimension, number> = {
626
+ quality: config.minQuality,
627
+ importance: config.minImportance,
628
+ urgency: config.minUrgency,
629
+ impact: config.minImpact,
630
+ };
631
+ for (const doc of docs) {
632
+ const { category, scores } = requireAnnotation(doc);
633
+ const reasons: string[] = [];
634
+ if (!config.categories.includes(category)) reasons.push('category:not-in-allowlist');
635
+ for (const dim of QC_DIMENSIONS) {
636
+ if (scores[dim] < minimums[dim]) reasons.push(`${dim}:${scores[dim]}<${minimums[dim]}`);
637
+ }
638
+ if (reasons.length > 0) rejected.push({ doc, reasons });
639
+ else kept.push(doc);
640
+ }
641
+ return { kept, rejected };
642
+ }
643
+
644
+ /** Sibling rejected-audit path: `<out>.json` → `<out>.rejected.json` (candidates.json → candidates.rejected.json). */
645
+ export function rejectedOutPath(outputPath: string): string {
646
+ return outputPath.endsWith('.json')
647
+ ? `${outputPath.slice(0, -'.json'.length)}.rejected.json`
648
+ : `${outputPath}.rejected.json`;
649
+ }
650
+
651
+ /** The plugin's single atomic write seam (plan Content and the rejected audit both ride it). */
652
+ async function atomicWrite(path: string, value: unknown): Promise<void> {
653
+ const fs = createNodeFileSystem();
654
+ const outDir = dirname(path);
655
+ if (outDir && outDir !== '.') await fs.ensureDir(outDir);
656
+ await atomicWriteJson(path, value, fs);
657
+ }
658
+
659
+ export async function processGeneratorIO(
660
+ inputPath: string,
661
+ outputPath: string,
662
+ mode: GeneratorMode = 'plan',
663
+ ): Promise<Content> {
664
+ return mode === 'filter' ? processFilterIO(inputPath, outputPath) : processPlanIO(inputPath, outputPath);
665
+ }
666
+
667
+ /** Plan mode (default, behavior unchanged): blended `Doc[]` → gated/annotated plan Content. */
668
+ async function processPlanIO(inputPath: string, outputPath: string): Promise<Content> {
669
+ const docs = DocListSchema.parse(await readJsonFile(inputPath));
670
+ const content = docsToContent(docs, resolvePlanDate());
671
+ await atomicWrite(outputPath, content);
672
+ return content;
673
+ }
674
+
675
+ /**
676
+ * Filter mode (0094): annotated `Doc[]` → QC-gated Content at `--out` plus the rejection audit at
677
+ * `<out>.rejected.json` (`{ rejected, config, total }`, total = input doc count). Zero survivors is
678
+ * a valid exit-0 outcome — the workflow routes `metadata.docs | length == 0` to done-no-content,
679
+ * never a failure.
680
+ */
681
+ async function processFilterIO(inputPath: string, outputPath: string): Promise<Content> {
682
+ const config = filterConfigFromEnv();
683
+ const docs = DocListSchema.parse(await readJsonFile(inputPath));
684
+ const { kept, rejected } = filterAnnotated(docs, config);
685
+ const content = planContent(kept, resolvePlanDate());
686
+ await atomicWrite(outputPath, content);
687
+ await atomicWrite(rejectedOutPath(outputPath), { rejected, config, total: docs.length });
688
+ return content;
689
+ }
690
+
691
+ export async function main(): Promise<number> {
692
+ let values: { in?: string; out?: string; mode?: string };
693
+ try {
694
+ ({ values } = parseArgs({
695
+ options: { in: { type: 'string' }, out: { type: 'string' }, mode: { type: 'string' } },
696
+ }));
697
+ } catch (err: unknown) {
698
+ echoError(`episode-plan-gen failed: ${err instanceof Error ? err.message : String(err)}`);
699
+ return 1;
700
+ }
701
+ if (!values.in || !values.out) {
702
+ echoError('episode-plan-gen failed: Missing required arguments: --in <docs.json> --out <content.json>');
703
+ return 1;
704
+ }
705
+ // Mode precedence (0097 kk-seam wiring): explicit --mode flag > EPISODE_PLAN_MODE env > 'plan'.
706
+ // The env rung exists because `kk executor run` spawns plugin entries with --in/--out only
707
+ // (ADR-006, apps/cli/src/invoke.ts) and cannot forward extra CLI args.
708
+ const mode = values.mode ?? process.env.EPISODE_PLAN_MODE ?? 'plan';
709
+ if (mode !== 'plan' && mode !== 'filter') {
710
+ echoError(
711
+ `episode-plan-gen failed: unknown mode '${mode}' (from --mode flag or EPISODE_PLAN_MODE env; valid: plan, filter)`,
712
+ );
713
+ return 1;
714
+ }
715
+ try {
716
+ await processGeneratorIO(values.in, values.out, mode);
717
+ return 0;
718
+ } catch (err: unknown) {
719
+ echoError(`episode-plan-gen failed: ${err instanceof Error ? err.message : String(err)}`);
720
+ return 1;
721
+ }
722
+ }
723
+
724
+ if (import.meta.main) {
725
+ process.exit(await main());
726
+ }