@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.
- package/dist/index.js +451 -152
- package/package.json +1 -1
- package/plugins/generations/content-gen/dist/index.js +22167 -0
- package/plugins/generations/content-gen/plugin.json +1 -1
- package/plugins/generations/content-gen/src/storm.ts +99 -50
- package/plugins/generations/core-facts-gen/dist/index.js +22048 -0
- package/plugins/generations/core-facts-gen/package.json +17 -0
- package/plugins/generations/core-facts-gen/plugin.json +7 -0
- package/plugins/generations/core-facts-gen/src/index.ts +116 -0
- package/plugins/generations/core-facts-gen/tsconfig.json +4 -0
- package/plugins/generations/daily-article-gen/dist/index.js +22022 -0
- package/plugins/generations/daily-article-gen/package.json +17 -0
- package/plugins/generations/daily-article-gen/plugin.json +7 -0
- package/plugins/generations/daily-article-gen/src/index.ts +91 -0
- package/plugins/generations/daily-article-gen/tsconfig.json +4 -0
- package/plugins/generations/dailynews-gen/dist/index.js +22324 -0
- package/plugins/generations/dailynews-gen/plugin.json +1 -1
- package/plugins/generations/dailynews-gen/src/index.ts +11 -0
- package/plugins/generations/dailynews-gen/src/script-builder.ts +1 -1
- package/plugins/generations/episode-plan-gen/dist/index.js +22479 -0
- package/plugins/generations/episode-plan-gen/package.json +17 -0
- package/plugins/generations/episode-plan-gen/plugin.json +7 -0
- package/plugins/generations/episode-plan-gen/src/index.ts +726 -0
- package/plugins/generations/episode-plan-gen/tsconfig.json +4 -0
- package/plugins/generations/image-gen/config.example.yaml +75 -0
- package/plugins/generations/image-gen/dist/index.js +22619 -0
- package/plugins/generations/image-gen/package.json +17 -0
- package/plugins/generations/image-gen/plugin.json +7 -0
- package/plugins/generations/image-gen/presets/formats/cover.yaml +57 -0
- package/plugins/generations/image-gen/presets/formats/free.yaml +46 -0
- package/plugins/generations/image-gen/presets/formats/illustration.yaml +48 -0
- package/plugins/generations/image-gen/presets/styles/clean-webapp-ui.yaml +28 -0
- package/plugins/generations/image-gen/presets/styles/cute.yaml +3 -0
- package/plugins/generations/image-gen/presets/styles/editorial.yaml +3 -0
- package/plugins/generations/image-gen/presets/styles/fresh.yaml +3 -0
- package/plugins/generations/image-gen/presets/styles/minimalist.yaml +3 -0
- package/plugins/generations/image-gen/presets/styles/photorealistic.yaml +3 -0
- package/plugins/generations/image-gen/presets/styles/sketch.yaml +3 -0
- package/plugins/generations/image-gen/presets/styles/technical-diagram.yaml +3 -0
- package/plugins/generations/image-gen/presets/styles/vibrant.yaml +3 -0
- package/plugins/generations/image-gen/presets/styles/warm.yaml +3 -0
- package/plugins/generations/image-gen/src/bytes.ts +19 -0
- package/plugins/generations/image-gen/src/index.ts +319 -0
- package/plugins/generations/image-gen/src/job.ts +143 -0
- package/plugins/generations/image-gen/src/paths.ts +31 -0
- package/plugins/generations/image-gen/src/presets.ts +344 -0
- package/plugins/generations/image-gen/src/providers/seedream.ts +128 -0
- package/plugins/generations/image-gen/src/providers/types.ts +285 -0
- package/plugins/generations/image-gen/tsconfig.json +8 -0
- package/plugins/generations/voice-gen/dist/index.js +23027 -0
- package/plugins/generations/voice-gen/plugin.json +1 -1
- package/plugins/generations/voice-gen/src/index.ts +102 -11
- package/plugins/generations/voice-gen/src/qc.ts +154 -9
- package/plugins/ingestions/aihot-ingest/dist/index.js +22358 -0
- package/plugins/ingestions/aihot-ingest/plugin.json +2 -2
- package/plugins/ingestions/aihot-ingest/src/index.ts +72 -13
- package/plugins/ingestions/aihot-ingest/src/mapper.ts +1 -0
- package/plugins/ingestions/aihot-ingest/src/rss.ts +151 -0
- package/plugins/ingestions/horizon-ingest/dist/index.js +22105 -0
- package/plugins/ingestions/horizon-ingest/package.json +17 -0
- package/plugins/ingestions/horizon-ingest/plugin.json +7 -0
- package/plugins/ingestions/horizon-ingest/src/index.ts +205 -0
- package/plugins/ingestions/horizon-ingest/tsconfig.json +4 -0
- package/plugins/ingestions/karakeep-local/dist/index.js +24204 -0
- package/plugins/ingestions/karakeep-local/plugin.json +1 -1
- package/plugins/ingestions/last30days-ingest/dist/index.js +22050 -0
- package/plugins/ingestions/last30days-ingest/package.json +17 -0
- package/plugins/ingestions/last30days-ingest/plugin.json +7 -0
- package/plugins/ingestions/last30days-ingest/src/index.ts +148 -0
- package/plugins/ingestions/last30days-ingest/tsconfig.json +4 -0
- package/plugins/ingestions/web-search/dist/index.js +24379 -0
- package/plugins/ingestions/web-search/plugin.json +1 -1
- package/plugins/kk/commands/image-generate.md +15 -0
- package/plugins/kk/config.example.yaml +80 -0
- package/plugins/kk/skills/image-authoring/SKILL.md +250 -0
- package/plugins/kk/skills/image-authoring/references/illustration-positions.md +87 -0
- package/plugins/kk/skills/image-authoring/references/migrating-from-wt.md +31 -0
- package/plugins/kk/skills/image-authoring/references/providers.md +52 -0
- package/plugins/kk/skills/image-authoring/references/style-extraction.md +134 -0
- package/plugins/kk/skills/taste-unslop/SKILL.md +12 -6
- package/plugins/kk/skills/taste-unslop/references/pattern-guide.md +128 -48
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +432 -19
- package/plugins/publishings/emdash-pub/dist/index.js +22243 -0
- package/plugins/publishings/emdash-pub/plugin.json +1 -1
- package/plugins/publishings/podcast-pub/dist/index.js +22596 -0
- package/plugins/publishings/podcast-pub/package.json +17 -0
- package/plugins/publishings/podcast-pub/plugin.json +13 -0
- package/plugins/publishings/podcast-pub/src/index.ts +538 -0
- package/plugins/publishings/podcast-pub/src/map.ts +165 -0
- package/plugins/publishings/podcast-pub/src/microfeed-client.ts +196 -0
- package/plugins/publishings/podcast-pub/src/show-notes.ts +132 -0
- package/plugins/publishings/podcast-pub/tsconfig.json +4 -0
- package/plugins/publishings/qiita-pub/dist/index.js +22081 -0
- package/plugins/publishings/qiita-pub/plugin.json +1 -1
- package/plugins/publishings/surfdash-pub/dist/index.js +22233 -0
- package/plugins/publishings/surfdash-pub/plugin.json +1 -1
- package/plugins/publishings/surfdash-pub/src/index.ts +328 -62
- package/plugins/publishings/zenn-pub/dist/index.js +22122 -0
- 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
|
+
}
|