@gobing-ai/knowledge-kit 0.0.12 → 0.0.14

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 (152) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +120 -26
  3. package/package.json +1 -1
  4. package/plugins/generations/content-gen/dist/index.js +22187 -0
  5. package/plugins/generations/content-gen/plugin.json +1 -1
  6. package/plugins/generations/core-facts-gen/dist/index.js +22068 -0
  7. package/plugins/generations/core-facts-gen/plugin.json +1 -1
  8. package/plugins/generations/daily-article-gen/dist/index.js +22050 -0
  9. package/plugins/generations/daily-article-gen/plugin.json +1 -1
  10. package/plugins/generations/daily-article-gen/src/index.ts +13 -1
  11. package/plugins/generations/dailynews-gen/dist/index.js +22344 -0
  12. package/plugins/generations/dailynews-gen/plugin.json +1 -1
  13. package/plugins/generations/episode-plan-gen/dist/index.js +22503 -0
  14. package/plugins/generations/episode-plan-gen/plugin.json +1 -1
  15. package/plugins/generations/episode-plan-gen/src/index.ts +11 -0
  16. package/plugins/generations/image-gen/config.example.yaml +75 -0
  17. package/plugins/generations/image-gen/dist/index.js +24862 -0
  18. package/plugins/generations/image-gen/package.json +17 -0
  19. package/plugins/generations/image-gen/plugin.json +7 -0
  20. package/plugins/generations/image-gen/presets/formats/cover.yaml +57 -0
  21. package/plugins/generations/image-gen/presets/formats/free.yaml +46 -0
  22. package/plugins/generations/image-gen/presets/formats/illustration.yaml +48 -0
  23. package/plugins/generations/image-gen/presets/styles/clean-webapp-ui.yaml +28 -0
  24. package/plugins/generations/image-gen/presets/styles/cute.yaml +3 -0
  25. package/plugins/generations/image-gen/presets/styles/editorial.yaml +3 -0
  26. package/plugins/generations/image-gen/presets/styles/fresh.yaml +3 -0
  27. package/plugins/generations/image-gen/presets/styles/minimalist.yaml +3 -0
  28. package/plugins/generations/image-gen/presets/styles/photorealistic.yaml +3 -0
  29. package/plugins/generations/image-gen/presets/styles/sketch.yaml +3 -0
  30. package/plugins/generations/image-gen/presets/styles/technical-diagram.yaml +3 -0
  31. package/plugins/generations/image-gen/presets/styles/vibrant.yaml +3 -0
  32. package/plugins/generations/image-gen/presets/styles/warm.yaml +3 -0
  33. package/plugins/generations/image-gen/src/bytes.ts +19 -0
  34. package/plugins/generations/image-gen/src/index.ts +319 -0
  35. package/plugins/generations/image-gen/src/job.ts +143 -0
  36. package/plugins/generations/image-gen/src/paths.ts +31 -0
  37. package/plugins/generations/image-gen/src/presets.ts +344 -0
  38. package/plugins/generations/image-gen/src/providers/agnes.ts +110 -0
  39. package/plugins/generations/image-gen/src/providers/azure.ts +153 -0
  40. package/plugins/generations/image-gen/src/providers/codex-cli.ts +170 -0
  41. package/plugins/generations/image-gen/src/providers/dashscope.ts +485 -0
  42. package/plugins/generations/image-gen/src/providers/google.ts +268 -0
  43. package/plugins/generations/image-gen/src/providers/huggingface.ts +59 -0
  44. package/plugins/generations/image-gen/src/providers/jimeng.ts +259 -0
  45. package/plugins/generations/image-gen/src/providers/minimax.ts +171 -0
  46. package/plugins/generations/image-gen/src/providers/openai.ts +319 -0
  47. package/plugins/generations/image-gen/src/providers/openrouter.ts +257 -0
  48. package/plugins/generations/image-gen/src/providers/refs.ts +24 -0
  49. package/plugins/generations/image-gen/src/providers/replicate.ts +279 -0
  50. package/plugins/generations/image-gen/src/providers/seedream.ts +128 -0
  51. package/plugins/generations/image-gen/src/providers/types.ts +286 -0
  52. package/plugins/generations/image-gen/src/providers/zai.ts +237 -0
  53. package/plugins/generations/image-gen/tsconfig.json +8 -0
  54. package/plugins/generations/news-report-gen/dist/index.js +22193 -0
  55. package/plugins/generations/news-report-gen/package.json +17 -0
  56. package/plugins/generations/news-report-gen/plugin.json +7 -0
  57. package/plugins/generations/news-report-gen/src/index.ts +308 -0
  58. package/plugins/generations/news-report-gen/tsconfig.json +4 -0
  59. package/plugins/generations/omni-voice-gen/Makefile +14 -0
  60. package/plugins/generations/omni-voice-gen/README.md +112 -0
  61. package/plugins/generations/omni-voice-gen/bin/omni-voice-gen +2 -0
  62. package/plugins/generations/omni-voice-gen/dist/omni-voice-gen-prr8skpb. +2 -0
  63. package/plugins/generations/omni-voice-gen/dist/omni-voice-gen.js +6 -0
  64. package/plugins/generations/omni-voice-gen/plugin.json +6 -0
  65. package/plugins/generations/omni-voice-gen/profiles.json +12 -0
  66. package/plugins/generations/omni-voice-gen/pyproject.toml +25 -0
  67. package/plugins/generations/omni-voice-gen/scripts/coverage_gate.py +74 -0
  68. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/__init__.py +1 -0
  69. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/__main__.py +39 -0
  70. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/audio.py +190 -0
  71. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/backend.py +150 -0
  72. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/contract.py +76 -0
  73. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/mp3.py +60 -0
  74. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/pipeline.py +289 -0
  75. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/profiles.py +100 -0
  76. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/qc.py +234 -0
  77. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/voicescript.py +352 -0
  78. package/plugins/generations/omni-voice-gen/uv.lock +3510 -0
  79. package/plugins/generations/voice-gen/dist/index.js +23055 -0
  80. package/plugins/generations/voice-gen/plugin.json +1 -1
  81. package/plugins/generations/voice-gen/src/index.ts +16 -1
  82. package/plugins/generations/voice-gen/src/voicebox-client.ts +3 -1
  83. package/plugins/ingestions/aihot-ingest/dist/index.js +22378 -0
  84. package/plugins/ingestions/aihot-ingest/plugin.json +1 -1
  85. package/plugins/ingestions/horizon-ingest/dist/index.js +22125 -0
  86. package/plugins/ingestions/horizon-ingest/plugin.json +1 -1
  87. package/plugins/ingestions/karakeep-local/dist/index.js +24204 -0
  88. package/plugins/ingestions/karakeep-local/plugin.json +1 -1
  89. package/plugins/ingestions/last30days-ingest/dist/index.js +22070 -0
  90. package/plugins/ingestions/last30days-ingest/plugin.json +1 -1
  91. package/plugins/ingestions/web-search/dist/index.js +24399 -0
  92. package/plugins/ingestions/web-search/plugin.json +1 -1
  93. package/plugins/kk/commands/image-extract.md +40 -0
  94. package/plugins/kk/commands/image-generate.md +32 -0
  95. package/plugins/kk/config.example.yaml +80 -0
  96. package/plugins/kk/plugin.json +1 -1
  97. package/plugins/kk/skills/image-authoring/SKILL.md +257 -0
  98. package/plugins/kk/skills/image-authoring/references/format-drafting.md +57 -0
  99. package/plugins/kk/skills/image-authoring/references/illustration-positions.md +87 -0
  100. package/plugins/kk/skills/image-authoring/references/migrating-from-wt.md +31 -0
  101. package/plugins/kk/skills/image-authoring/references/providers.md +52 -0
  102. package/plugins/kk/skills/image-authoring/references/style-extraction.md +139 -0
  103. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +130 -30
  104. package/plugins/publishings/emdash-pub/dist/index.js +22263 -0
  105. package/plugins/publishings/emdash-pub/plugin.json +1 -1
  106. package/plugins/publishings/podcast-pub/dist/index.js +22650 -0
  107. package/plugins/publishings/podcast-pub/plugin.json +8 -2
  108. package/plugins/publishings/podcast-pub/src/index.ts +18 -2
  109. package/plugins/publishings/podcast-pub/src/show-notes.ts +56 -9
  110. package/plugins/publishings/qiita-pub/dist/index.js +22101 -0
  111. package/plugins/publishings/qiita-pub/plugin.json +1 -1
  112. package/plugins/publishings/surfdash-pub/dist/index.js +22323 -0
  113. package/plugins/publishings/surfdash-pub/plugin.json +1 -1
  114. package/plugins/publishings/surfdash-pub/src/index.ts +109 -9
  115. package/plugins/publishings/zenn-pub/dist/index.js +22142 -0
  116. package/plugins/publishings/zenn-pub/plugin.json +1 -1
  117. package/plugins/sp/scripts/batch-preflight.mjs +346 -0
  118. package/plugins/sp/scripts/batch-preflight.ts +459 -0
  119. package/plugins/sp/scripts/daily-summary/daily-summary.mjs +615 -0
  120. package/plugins/sp/scripts/daily-summary/daily-summary.ts +846 -0
  121. package/plugins/sp/scripts/daily-summary/logger.ts +28 -0
  122. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +223 -0
  123. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +367 -0
  124. package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +132 -0
  125. package/plugins/sp/scripts/dogfood-testing/validate-report.ts +169 -0
  126. package/plugins/sp/scripts/feature-dev-precheck.mjs +171 -0
  127. package/plugins/sp/scripts/feature-dev-precheck.ts +238 -0
  128. package/plugins/sp/scripts/feature-sync-bounded.mjs +285 -0
  129. package/plugins/sp/scripts/feature-sync-bounded.ts +478 -0
  130. package/plugins/sp/scripts/history-anatomy-cache.mjs +902 -0
  131. package/plugins/sp/scripts/history-anatomy-cache.ts +1028 -0
  132. package/plugins/sp/scripts/idea-handoff.mjs +22 -0
  133. package/plugins/sp/scripts/idea-handoff.ts +44 -0
  134. package/plugins/sp/scripts/inline-pipeline-parity-check.ts +185 -0
  135. package/plugins/sp/scripts/inline-run-setup.ts +198 -0
  136. package/plugins/sp/scripts/pr-reviewing.mjs +769 -0
  137. package/plugins/sp/scripts/pr-reviewing.ts +925 -0
  138. package/plugins/sp/scripts/quality-gate.mjs +179 -0
  139. package/plugins/sp/scripts/quality-gate.ts +217 -0
  140. package/plugins/sp/scripts/script-contract-check.ts +319 -0
  141. package/plugins/sp/scripts/stage-registry-adapter.ts +1533 -0
  142. package/plugins/sp/scripts/surface-drift-inventory.ts +929 -0
  143. package/plugins/sp/scripts/task-evidence-precheck.ts +181 -0
  144. package/plugins/sp/scripts/task-size-precheck.ts +175 -0
  145. package/plugins/sp/scripts/transition-shim-check.ts +238 -0
  146. package/plugins/sp/scripts/validate-commands.ts +689 -0
  147. package/plugins/sp/scripts/validate-flag-contracts.ts +878 -0
  148. package/plugins/sp/scripts/verify-answer-lint.ts +530 -0
  149. package/plugins/sp/scripts/workflow-step-profile.mjs +316 -0
  150. package/plugins/sp/scripts/workflow-step-profile.ts +456 -0
  151. package/plugins/sp/scripts/wrapup-steps.mjs +373 -0
  152. package/plugins/sp/scripts/wrapup-steps.ts +466 -0
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@gobing-ai/news-report-gen",
3
+ "type": "module",
4
+ "private": true,
5
+ "scripts": {
6
+ "typecheck": "tsc --noEmit"
7
+ },
8
+ "dependencies": {
9
+ "@gobing-ai/kk-core": "workspace:*",
10
+ "@gobing-ai/ts-runtime": "catalog:",
11
+ "@gobing-ai/ts-utils": "catalog:",
12
+ "zod": "4.4.3"
13
+ },
14
+ "devDependencies": {
15
+ "@types/bun": "1.3.14"
16
+ }
17
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "news-report-gen",
3
+ "kind": "generator",
4
+ "entry": "./dist/index.js",
5
+ "version": "1.0.0",
6
+ "description": "Assembles the per-run report from existing run artifacts: kept-candidate QC scores + per-dimension min/max/avg aggregates + threshold-rejected counts (rejected audit via NEWS_REPORT_REJECTED_FILE) + per-step wall-clock timing (step-timing.jsonl JSONL via NEWS_REPORT_TIMING_FILE). Output-only — no publish side effect (task 0117)."
7
+ }
@@ -0,0 +1,308 @@
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
+ * news-report-gen: assemble the per-run report from existing run artifacts —
9
+ * kept-candidate QC scores, per-dimension min/max/avg aggregates, threshold-
10
+ * rejected counts, and per-step wall-clock timing. Output-only Content (task
11
+ * 0117): no publish side effect, no engine data channel (# ponytail: step
12
+ * boundary timing stitched at report time; upgrade path = engine-side ledger
13
+ * if drift ever matters).
14
+ *
15
+ * Inputs:
16
+ * - `--in` Doc[] — kept candidates (`2-plan/episode-plan.json`, built by the
17
+ * workflow from the filter Content's metadata.docs). Scores live in
18
+ * `metadata.scores` (quality/importance/urgency/impact 0-5).
19
+ * - `NEWS_REPORT_REJECTED_FILE` — the filter's rejected audit path
20
+ * (`2-plan/candidates.rejected.json`, RejectedDoc[] of {doc, reasons});
21
+ * absent or unreadable -> reject counts render as 0, never throw.
22
+ * - `NEWS_REPORT_TIMING_FILE` — `<work_dir>/step-timing.json` JSONL of
23
+ * {step, startedAt, endedAt} appended by the workflow shell steps; missing
24
+ * or unreadable -> the timing section is omitted, never throw.
25
+ * - Threshold consistency: `QC_MIN_*` envs — the exact names the workflow
26
+ * already pins (must move in lockstep with episode-plan-gen's
27
+ * DEFAULT_FILTER_CONFIG), so one grep shows both sides.
28
+ * - `NEWS_REPORT_DATE` (YYYYMMDD or ISO), else today (UTC).
29
+ */
30
+
31
+ const QC_DIMENSIONS = ['quality', 'importance', 'urgency', 'impact'] as const;
32
+ type QCDimension = (typeof QC_DIMENSIONS)[number];
33
+
34
+ const QC_MIN_ENVS: Record<QCDimension, string> = {
35
+ quality: 'QC_MIN_QUALITY',
36
+ importance: 'QC_MIN_IMPORTANCE',
37
+ urgency: 'QC_MIN_URGENCY',
38
+ impact: 'QC_MIN_IMPACT',
39
+ };
40
+
41
+ interface TimingEntry {
42
+ step: string;
43
+ startedAt: string;
44
+ endedAt: string;
45
+ }
46
+
47
+ function scoreOf(doc: Doc, dim: QCDimension): number | null {
48
+ const meta = doc.metadata as Record<string, unknown> | undefined;
49
+ const value = (meta?.scores as Record<string, unknown> | undefined)?.[dim];
50
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
51
+ }
52
+
53
+ /** Per-dimension min/max/avg/count over the kept candidates' scores. */
54
+ export function scoreAggregates(
55
+ docs: Doc[],
56
+ ): Record<QCDimension, { min: number | null; max: number | null; avg: number | null; count: number }> {
57
+ const out = {} as Record<
58
+ QCDimension,
59
+ { min: number | null; max: number | null; avg: number | null; count: number }
60
+ >;
61
+ for (const dim of QC_DIMENSIONS) {
62
+ const values = docs.map((d) => scoreOf(d, dim)).filter((v): v is number => v !== null);
63
+ const count = values.length;
64
+ out[dim] =
65
+ count === 0
66
+ ? { min: null, max: null, avg: null, count: 0 }
67
+ : {
68
+ min: Math.min(...values),
69
+ max: Math.max(...values),
70
+ avg: Math.round((values.reduce((a, b) => a + b, 0) / count) * 10) / 10,
71
+ count,
72
+ };
73
+ }
74
+ return out;
75
+ }
76
+
77
+ /** Hostname of a URL-ish value; unparseable/absent → '—'. */
78
+ function domainOf(value: unknown): string {
79
+ if (typeof value !== 'string' || value.trim() === '') return '—';
80
+ try {
81
+ return new URL(value).hostname || '—';
82
+ } catch {
83
+ return '—';
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Per-source item counts with domain sub-counts from candidate metadata
89
+ * (`source`/`sourceName`, falling back to the URL hostname).
90
+ */
91
+ export function sourceBreakdown(
92
+ docs: Doc[],
93
+ ): { source: string; items: number; domains: { domain: string; items: number }[] }[] {
94
+ const bySource = new Map<string, Map<string, number>>();
95
+ for (const doc of docs) {
96
+ const meta = doc.metadata as Record<string, unknown> | undefined;
97
+ const source = typeof meta?.source === 'string' && meta.source.trim() !== '' ? meta.source : '未知';
98
+ const domain =
99
+ typeof meta?.sourceName === 'string' && meta.sourceName.trim() !== ''
100
+ ? meta.sourceName
101
+ : domainOf(meta?.originalUrl ?? doc.sourceUri);
102
+ const domains = bySource.get(source) ?? new Map<string, number>();
103
+ domains.set(domain, (domains.get(domain) ?? 0) + 1);
104
+ bySource.set(source, domains);
105
+ }
106
+ return [...bySource.entries()]
107
+ .map(([source, domains]) => ({
108
+ source,
109
+ items: [...domains.values()].reduce((sum, count) => sum + count, 0),
110
+ domains: [...domains.entries()]
111
+ .map(([domain, count]) => ({ domain, items: count }))
112
+ .sort((a, b) => b.items - a.items || a.domain.localeCompare(b.domain)),
113
+ }))
114
+ .sort((a, b) => b.items - a.items || a.source.localeCompare(b.source));
115
+ }
116
+
117
+ /** Rejected-count per threshold dimension from the filter's rejection reasons (`dim:<score><<min>`). */
118
+ export function rejectedCounts(
119
+ rejected: { doc: Doc; reasons: string[] }[],
120
+ ): Record<QCDimension, number> & { category: number } {
121
+ const out = { category: 0 } as Record<QCDimension, number> & { category: number };
122
+ for (const dim of QC_DIMENSIONS) out[dim] = 0;
123
+ for (const entry of rejected) {
124
+ if (entry.reasons.includes('category:not-in-allowlist')) out.category += 1;
125
+ for (const dim of QC_DIMENSIONS) {
126
+ if (entry.reasons.some((r) => r.startsWith(`${dim}:`))) out[dim] += 1;
127
+ }
128
+ }
129
+ return out;
130
+ }
131
+
132
+ export function minimumsFromEnv(): Record<QCDimension, number | null> {
133
+ const out = {} as Record<QCDimension, number | null>;
134
+ for (const dim of QC_DIMENSIONS) {
135
+ const raw = process.env[QC_MIN_ENVS[dim]];
136
+ const n = raw === undefined || raw.trim() === '' ? Number.NaN : Number.parseInt(raw, 10);
137
+ out[dim] = Number.isFinite(n) ? n : null;
138
+ }
139
+ return out;
140
+ }
141
+
142
+ interface RejectedAudit {
143
+ doc: Doc;
144
+ reasons: string[];
145
+ }
146
+
147
+ /** Parse the timing JSONL; unreadable/missing -> [] (never throw). Last entry per step wins. */
148
+ export async function readTiming(path: string | undefined): Promise<TimingEntry[]> {
149
+ if (!path || path.trim() === '') return [];
150
+ let raw: string;
151
+ try {
152
+ raw = await Bun.file(path).text();
153
+ } catch {
154
+ return [];
155
+ }
156
+ const last = new Map<string, TimingEntry>();
157
+ for (const line of raw.split('\n')) {
158
+ const trimmed = line.trim();
159
+ if (trimmed === '') continue;
160
+ try {
161
+ const entry = JSON.parse(trimmed) as Partial<TimingEntry>;
162
+ if (typeof entry.step !== 'string' || entry.step.trim() === '') continue;
163
+ if (typeof entry.startedAt !== 'string' || typeof entry.endedAt !== 'string') continue;
164
+ last.set(entry.step, { step: entry.step, startedAt: entry.startedAt, endedAt: entry.endedAt });
165
+ } catch {
166
+ // skip malformed JSONL line (# ponytail: report-side tolerance, stamping owns the format)
167
+ }
168
+ }
169
+ return [...last.values()];
170
+ }
171
+
172
+ function durationMs(entry: TimingEntry): number | null {
173
+ const start = Date.parse(entry.startedAt);
174
+ const end = Date.parse(entry.endedAt);
175
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
176
+ return Math.max(0, end - start);
177
+ }
178
+
179
+ /** Render the report markdown for a kept-candidate Doc[] (pure — exported for tests). */
180
+ export function renderReportMarkdown(
181
+ docs: Doc[],
182
+ date: string,
183
+ options: { rejected?: { doc: Doc; reasons: string[] }[]; timing?: TimingEntry[] } = {},
184
+ ): string {
185
+ const lines: string[] = [`# 每日 AI 语音运行报告 — ${date}`, ''];
186
+ lines.push('## 选稿评分(保留候选)', '');
187
+ if (docs.length === 0) {
188
+ lines.push('本期无保留候选。', '');
189
+ } else {
190
+ lines.push('| # | 候选 | quality | importance | urgency | impact |', '| --- | --- | --- | --- | --- | --- |');
191
+ docs.forEach((doc, i) => {
192
+ const cells = QC_DIMENSIONS.map((dim) => scoreOf(doc, dim) ?? '—');
193
+ lines.push(`| ${i + 1} | ${(doc.title ?? doc.id ?? '').replace(/\|/g, '\\|')} | ${cells.join(' | ')} |`);
194
+ });
195
+ lines.push('');
196
+ const agg = scoreAggregates(docs);
197
+ lines.push('**维度聚合**(min / max / avg / 样本数)', '');
198
+ lines.push('| 维度 | min | max | avg | count |', '| --- | --- | --- | --- | --- |');
199
+ for (const dim of QC_DIMENSIONS) {
200
+ const a = agg[dim];
201
+ lines.push(`| ${dim} | ${a.min ?? '—'} | ${a.max ?? '—'} | ${a.avg ?? '—'} | ${a.count} |`);
202
+ }
203
+ lines.push('');
204
+ lines.push('## 来源分布', '');
205
+ lines.push('| 来源 | 条数 | 域名 |', '| --- | --- | --- |');
206
+ for (const group of sourceBreakdown(docs)) {
207
+ const domainCell = group.domains.map((d) => `${d.domain.replace(/\|/g, '\\|')} (${d.items})`).join(', ');
208
+ lines.push(`| ${group.source} | ${group.items} | ${domainCell || '—'} |`);
209
+ }
210
+ lines.push('');
211
+ }
212
+
213
+ const mins = minimumsFromEnv();
214
+ const counts = rejectedCounts(options.rejected ?? []);
215
+ lines.push('## 阈值拒绝计数', '');
216
+ lines.push('| 检查 | 阈值 | 拒绝数 |', '| --- | --- | --- |');
217
+ for (const dim of QC_DIMENSIONS) {
218
+ const min = mins[dim];
219
+ lines.push(`| ${dim} | ${min === null ? '—' : min} | ${counts[dim]} |`);
220
+ }
221
+ lines.push(`| category allowlist | — | ${counts.category} |`);
222
+ lines.push('');
223
+
224
+ const timing = options.timing ?? [];
225
+ if (timing.length > 0) {
226
+ lines.push('## 步耗时', '');
227
+ lines.push('| 步骤 | startedAt | endedAt | 耗时 |', '| --- | --- | --- | --- |');
228
+ let totalMs = 0;
229
+ let totalKnown = true;
230
+ for (const entry of timing) {
231
+ const ms = durationMs(entry);
232
+ if (ms === null) totalKnown = false;
233
+ else totalMs += ms;
234
+ lines.push(`| ${entry.step} | ${entry.startedAt} | ${entry.endedAt} | ${ms === null ? '—' : `${ms} ms`} |`);
235
+ }
236
+ lines.push('');
237
+ if (totalKnown) lines.push(`**总耗时**: ${totalMs} ms(各步相加)`, '');
238
+ }
239
+
240
+ return lines.join('\n');
241
+ }
242
+
243
+ /** Read the rejected audit; absent/unreadable/malformed -> [] (never throw). */
244
+ export async function readRejectedAudit(path: string | undefined): Promise<RejectedAudit[]> {
245
+ if (!path || path.trim() === '') return [];
246
+ try {
247
+ const parsed = JSON.parse(await Bun.file(path).text());
248
+ return Array.isArray(parsed) ? (parsed as RejectedAudit[]) : [];
249
+ } catch {
250
+ return [];
251
+ }
252
+ }
253
+
254
+ /** Build the report Content from a kept-candidate Doc[]. */
255
+ export function docsToContent(
256
+ docs: Doc[],
257
+ date: string,
258
+ options: { rejected?: { doc: Doc; reasons: string[] }[]; timing?: TimingEntry[] },
259
+ ): Content {
260
+ return ContentSchema.parse({
261
+ title: `每日 AI 语音运行报告 — ${date}`,
262
+ body: renderReportMarkdown(docs, date, options),
263
+ format: 'markdown',
264
+ metadata: { generator: 'kk:news-report', date, doc_count: docs.length },
265
+ });
266
+ }
267
+
268
+ function dateFromEnv(): string {
269
+ const raw = process.env.NEWS_REPORT_DATE ?? new Date().toISOString().slice(0, 10);
270
+ return raw.length === 8 ? `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}` : raw;
271
+ }
272
+
273
+ export async function processGeneratorIO(inputPath: string, outputPath: string): Promise<Content> {
274
+ const docs = DocListSchema.parse(await readJsonFile(inputPath));
275
+ const rejected = await readRejectedAudit(process.env.NEWS_REPORT_REJECTED_FILE);
276
+ const timing = await readTiming(process.env.NEWS_REPORT_TIMING_FILE);
277
+ const content = docsToContent(docs, dateFromEnv(), { rejected, timing });
278
+ const fs = createNodeFileSystem();
279
+ const outDir = dirname(outputPath);
280
+ if (outDir && outDir !== '.') await fs.ensureDir(outDir);
281
+ await atomicWriteJson(outputPath, content, fs);
282
+ return content;
283
+ }
284
+
285
+ export async function main(): Promise<number> {
286
+ let values: { in?: string; out?: string };
287
+ try {
288
+ ({ values } = parseArgs({ options: { in: { type: 'string' }, out: { type: 'string' } } }));
289
+ } catch (err: unknown) {
290
+ echoError(`news-report-gen failed: ${err instanceof Error ? err.message : String(err)}`);
291
+ return 1;
292
+ }
293
+ if (!values.in || !values.out) {
294
+ echoError('news-report-gen failed: Missing required arguments: --in <docs.json> --out <content.json>');
295
+ return 1;
296
+ }
297
+ try {
298
+ await processGeneratorIO(values.in, values.out);
299
+ return 0;
300
+ } catch (err: unknown) {
301
+ echoError(`news-report-gen failed: ${err instanceof Error ? err.message : String(err)}`);
302
+ return 1;
303
+ }
304
+ }
305
+
306
+ if (import.meta.main) {
307
+ process.exit(await main());
308
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../../tooling/typescript/base.json",
3
+ "include": ["src", "tests"]
4
+ }
@@ -0,0 +1,14 @@
1
+ .PHONY: sync test coverage lint
2
+
3
+ sync:
4
+ uv sync
5
+
6
+ test:
7
+ uv run pytest -q
8
+
9
+ coverage:
10
+ uv run pytest --cov=omni_voice_gen -q
11
+ uv run python scripts/coverage_gate.py
12
+
13
+ lint:
14
+ uv run ruff check src tests scripts
@@ -0,0 +1,112 @@
1
+ # omni-voice-gen
2
+
3
+ OmniVoice-backed voice generator plugin for [knowledge-kit](../../../README.md).
4
+ Drop-in replacement for `voice-gen` — same `kk executor run` contract (`--in Doc[] --out Content`),
5
+ same audio metadata shape, same QC pipeline. Swap the backend in `kk-daily-ai-voice` with:
6
+
7
+ ```bash
8
+ spur workflow run plugins/kk/workflows/kk-daily-ai-voice.yaml \
9
+ --vars '{"voice_generator":"omni-voice-gen"}' # other vars per your kk config
10
+ ```
11
+
12
+ The default `voice_generator` is `voice-gen` (production behavior unchanged; swap is operator opt-in).
13
+
14
+ ## Setup
15
+
16
+ ### 1. Install dependencies
17
+
18
+ ```bash
19
+ make sync # runs `uv sync` — creates .venv, installs omnivoice from PyPI + dev deps
20
+ ```
21
+
22
+ > **First-run note:** `omnivoice` pulls `k2-fsa/OmniVoice` from Hugging Face on first generate
23
+ > (~2–4 GB depending on device). The Whisper ASR model used for transcription-fidelity QC loads
24
+ > lazily on the first QC transcription (`OMNIVOICE_ASR_MODEL` overrides it). Subsequent runs
25
+ > use the HF cache. `OMNIVOICE_MODEL` overrides the model id or points to a local checkout.
26
+ > GPU (`cuda`) is auto-selected when available; MPS (Apple Silicon) and CPU are fallbacks.
27
+ > Set `OMNIVOICE_DEVICE` to override (e.g. `cpu` for CI).
28
+
29
+ ### 2. Verify
30
+
31
+ ```bash
32
+ make test # pytest (FakeBackend — no model load, no network)
33
+ make coverage # pytest + per-file line/function >= 90% gate
34
+ make lint # ruff check
35
+ ```
36
+
37
+ ## Profile registry
38
+
39
+ Profiles map a name to one of three voice-specification kinds. The registry lives at
40
+ `profiles.json` (override with `OMNIVOICE_PROFILE_REGISTRY`).
41
+
42
+ ### Kinds
43
+
44
+ | Kind | JSON shape | When to use |
45
+ |------|-----------|-------------|
46
+ | **Clone** | `{ "ref_audio": "<path>", "ref_text": "<transcript>" }` | 3–10 s reference clip + its transcript |
47
+ | **Saved prompt** | `{ "prompt": "<path>.pt" }` | Pre-computed clone prompt via `create_voice_clone_prompt` |
48
+ | **Instruct** | `{ "instruct": "<description>" }` | Text description of the desired voice style |
49
+
50
+ ### Creating a profile (clone example)
51
+
52
+ 1. Record a 3–10 second WAV clip of the target voice.
53
+ 2. Transcribe it (the exact words spoken in the clip).
54
+ 3. Add an entry to `profiles.json`:
55
+
56
+ ```json
57
+ {
58
+ "robin-news": {
59
+ "ref_audio": "/path/to/robin-ref.wav",
60
+ "ref_text": "This is Robin, bringing you today's AI news."
61
+ }
62
+ }
63
+ ```
64
+
65
+ > **Never commit private audio paths or real operator clips.** The shipped `profiles.json` contains
66
+ > only documented placeholder entries. Copy and edit locally; the file is operator-private.
67
+
68
+ ### Resolution order
69
+
70
+ Segment `profile` → speaker `profile` → script `default_profile` → `VOICEBOX_DEFAULT_PROFILE` env
71
+ → doc `metadata.voiceProfile`. Unknown name → exit 1 naming the profile.
72
+
73
+ Relative `ref_audio` / `prompt` paths in registry entries resolve against the process working
74
+ directory (they are passed to the backend unmodified) — prefer absolute paths.
75
+
76
+ ## Environment variables
77
+
78
+ | Variable | Required | Default | Description |
79
+ |----------|----------|---------|-------------|
80
+ | `VOICEBOX_DEFAULT_PROFILE` | no | — | Fallback profile name (parity with voice-gen) |
81
+ | `VOICE_GEN_MP3` | no | off | `true`/`1`/`yes`/`on` → ffmpeg sibling MP3; missing ffmpeg → exit 1 |
82
+ | `VOICE_GEN_MAX_RUN_MS` | no | unlimited | Total render budget (ms), checked between segments |
83
+ | `VOICE_GEN_FAIL_ON_QC` | no | off | `true` + failing QC → exit 1 |
84
+ | `OMNIVOICE_MODEL` | no | `k2-fsa/OmniVoice` | HF model id or local path |
85
+ | `OMNIVOICE_ASR_MODEL` | no | omnivoice default Whisper | ASR model for transcription-fidelity QC (loads lazily on first QC) |
86
+ | `OMNIVOICE_DEVICE` | no | auto (`cuda`→`mps`→`cpu`) | Torch device |
87
+ | `OMNIVOICE_SPEED` | no | `1.0` | Speaking-rate factor |
88
+ | `OMNIVOICE_PROFILE_REGISTRY` | no | `<plugin>/profiles.json` | Profile registry path override |
89
+
90
+ **Not honored** (Voicebox-transport knobs): `VOICEBOX_URL`, `VOICEBOX_TIMEOUT_MS`,
91
+ `VOICEBOX_POLL_MS`, `VOICEBOX_MAX_CHUNK_CHARS`, `VOICEBOX_CROSSFADE_MS`. The render budget is
92
+ `VOICE_GEN_MAX_RUN_MS`.
93
+
94
+ ## Make targets
95
+
96
+ | Target | Description |
97
+ |--------|-------------|
98
+ | `make sync` | `uv sync` — install/update deps into `.venv` |
99
+ | `make test` | `uv run pytest -q` |
100
+ | `make coverage` | pytest + `scripts/coverage_gate.py` (per-file line/function >= 90%) |
101
+ | `make lint` | `uv run ruff check src tests scripts` |
102
+
103
+ ## Usage with kk-solo-podcast
104
+
105
+ `kk-solo-podcast.yaml` currently uses `voice-gen` directly. It can adopt the same `voice_generator`
106
+ var pattern later — out of F611 scope (one-line change when ready).
107
+
108
+ ## See also
109
+
110
+ - [Design spec](../../../docs/design/omni-voice-gen.md) — decisions, interfaces, invariants
111
+ - [voice-gen](../voice-gen/) — the Voicebox-backed sibling this plugin replaces
112
+ - [kk-daily-ai-voice.yaml](../../kk/workflows/kk-daily-ai-voice.yaml) — the workflow that selects the generator
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bash
2
+ exec uv run --project "$(cd "$(dirname "$0")/.." && pwd)" python -m omni_voice_gen "$@"
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bash
2
+ exec uv run --project "$(cd "$(dirname "$0")/.." && pwd)" python -m omni_voice_gen "$@"
@@ -0,0 +1,6 @@
1
+ // @bun
2
+ // ../../plugins/generations/omni-voice-gen/bin/omni-voice-gen
3
+ var omni_voice_gen_default = "./omni-voice-gen-prr8skpb.";
4
+ export {
5
+ omni_voice_gen_default as default
6
+ };
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "omni-voice-gen",
3
+ "kind": "generator",
4
+ "entry": "./dist/index.js",
5
+ "version": "1.0.0"
6
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "sample-clone": {
3
+ "ref_audio": "voices/sample-ref.wav",
4
+ "ref_text": "Replace with the transcript of your reference clip."
5
+ },
6
+ "sample-prompt": {
7
+ "prompt": "voices/sample-prompt.pt"
8
+ },
9
+ "sample-narrator": {
10
+ "instruct": "Speak in a calm, measured narrator voice."
11
+ }
12
+ }
@@ -0,0 +1,25 @@
1
+ [project]
2
+ name = "omni-voice-gen"
3
+ version = "1.0.0"
4
+ description = "OmniVoice-backed voice generator plugin for knowledge-kit"
5
+ requires-python = ">=3.10"
6
+ dependencies = [
7
+ "omnivoice",
8
+ "pyyaml",
9
+ "pydantic",
10
+ ]
11
+
12
+ [dependency-groups]
13
+ dev = [
14
+ "pytest",
15
+ "pytest-cov",
16
+ "coverage",
17
+ "ruff",
18
+ ]
19
+
20
+ [build-system]
21
+ requires = ["hatchling"]
22
+ build-backend = "hatchling.build"
23
+
24
+ [tool.hatch.build.targets.wheel]
25
+ packages = ["src/omni_voice_gen"]
@@ -0,0 +1,74 @@
1
+ """Per-file coverage gate: line AND function coverage >= 90% over the .coverage data.
2
+
3
+ Function coverage has no native coverage.py metric, so the gate derives it: a
4
+ function counts as covered when its `def` line executed AND at least one body
5
+ line executed (def/body line sets from ast; measured lines from the coverage.py
6
+ API). Line coverage uses coverage.py's own per-file analysis. Exit 1 listing
7
+ every offender below threshold.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import ast
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ THRESHOLD = 0.90
17
+
18
+
19
+ def function_map(path: Path) -> list[tuple[int, set[int]]]:
20
+ """(def_lineno, {body linenos}) for every function/method in a source file."""
21
+ tree = ast.parse(path.read_text(encoding="utf-8"))
22
+ out: list[tuple[int, set[int]]] = []
23
+ for node in ast.walk(tree):
24
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
25
+ body = {child.lineno for child in ast.walk(node) if hasattr(child, "lineno") and child.lineno != node.lineno}
26
+ out.append((node.lineno, body))
27
+ return out
28
+
29
+
30
+ def main() -> int:
31
+ from coverage import Coverage
32
+
33
+ cov = Coverage(data_file=".coverage")
34
+ cov.load()
35
+ measured = cov.get_data().measured_files()
36
+ if not measured:
37
+ print("coverage_gate: no coverage data found; run `make coverage` first", file=sys.stderr)
38
+ return 1
39
+
40
+ offenders: list[str] = []
41
+ for measured_path in sorted(measured):
42
+ try:
43
+ rel = Path(measured_path).relative_to(Path.cwd())
44
+ except ValueError:
45
+ continue
46
+ # Gate scope: package sources only (src/omni_voice_gen/<module>.py).
47
+ if len(rel.parts) < 3 or rel.parts[-3:-1] != ("src", "omni_voice_gen"):
48
+ continue
49
+ executed = set(cov.get_data().lines(measured_path) or [])
50
+ _, statements, _, missing, _ = cov.analysis2(str(rel))
51
+ if not statements:
52
+ continue
53
+ line_pct = (len(statements) - len(missing)) / len(statements)
54
+
55
+ funcs = function_map(rel)
56
+ func_pct = 1.0
57
+ if funcs:
58
+ covered = sum(1 for def_line, body in funcs if def_line in executed and body & executed)
59
+ func_pct = covered / len(funcs)
60
+
61
+ if line_pct < THRESHOLD or func_pct < THRESHOLD:
62
+ offenders.append(f"{rel}: line {line_pct:.1%}, function {func_pct:.1%} (both must be >= 90%)")
63
+
64
+ if offenders:
65
+ print("coverage_gate: FAIL (threshold 90% line and function, per file)", file=sys.stderr)
66
+ for line in offenders:
67
+ print(f" {line}", file=sys.stderr)
68
+ return 1
69
+ print("coverage_gate: PASS (line and function coverage >= 90% per file)")
70
+ return 0
71
+
72
+
73
+ if __name__ == "__main__":
74
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ """omni-voice-gen: OmniVoice-backed voice generator plugin (generator kind)."""
@@ -0,0 +1,39 @@
1
+ """CLI entry (kk executor spawn seam): --in Doc[] JSON -> --out Content JSON.
2
+
3
+ Exit codes mirror voice-gen main(): 0 success; 1 on any failure with
4
+ `omni-voice-gen failed: <msg>` on stderr. IO, cleanup, and the empty-input notice live in
5
+ pipeline.run; torch/omnivoice import only inside OmnivoiceBackend methods, so the
6
+ empty-input notice path never loads the model stack.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import sys
13
+
14
+
15
+ def _fail(msg: str) -> int:
16
+ print(f"omni-voice-gen failed: {msg}", file=sys.stderr)
17
+ return 1
18
+
19
+
20
+ def main(argv: list[str] | None = None) -> int:
21
+ parser = argparse.ArgumentParser(prog="omni-voice-gen")
22
+ parser.add_argument("--in", dest="in_path")
23
+ parser.add_argument("--out", dest="out_path")
24
+ args = parser.parse_args(argv)
25
+
26
+ if not args.in_path or not args.out_path:
27
+ return _fail("Missing required arguments: --in and --out")
28
+
29
+ try:
30
+ from .pipeline import run # lazy: argument errors never import the render stack
31
+
32
+ run(args.in_path, args.out_path)
33
+ except Exception as exc: # noqa: BLE001 — every failure maps onto the single fail-loud exit (voice-gen main parity)
34
+ return _fail(str(exc))
35
+ return 0
36
+
37
+
38
+ if __name__ == "__main__": # pragma: no cover — `python -m` entry guard
39
+ sys.exit(main())