@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/dist/index.js +120 -26
- package/package.json +1 -1
- package/plugins/generations/content-gen/dist/index.js +22187 -0
- package/plugins/generations/content-gen/plugin.json +1 -1
- package/plugins/generations/core-facts-gen/dist/index.js +22068 -0
- package/plugins/generations/core-facts-gen/plugin.json +1 -1
- package/plugins/generations/daily-article-gen/dist/index.js +22050 -0
- package/plugins/generations/daily-article-gen/plugin.json +1 -1
- package/plugins/generations/daily-article-gen/src/index.ts +13 -1
- package/plugins/generations/dailynews-gen/dist/index.js +22344 -0
- package/plugins/generations/dailynews-gen/plugin.json +1 -1
- package/plugins/generations/episode-plan-gen/dist/index.js +22503 -0
- package/plugins/generations/episode-plan-gen/plugin.json +1 -1
- package/plugins/generations/episode-plan-gen/src/index.ts +11 -0
- package/plugins/generations/image-gen/config.example.yaml +75 -0
- package/plugins/generations/image-gen/dist/index.js +24862 -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/agnes.ts +110 -0
- package/plugins/generations/image-gen/src/providers/azure.ts +153 -0
- package/plugins/generations/image-gen/src/providers/codex-cli.ts +170 -0
- package/plugins/generations/image-gen/src/providers/dashscope.ts +485 -0
- package/plugins/generations/image-gen/src/providers/google.ts +268 -0
- package/plugins/generations/image-gen/src/providers/huggingface.ts +59 -0
- package/plugins/generations/image-gen/src/providers/jimeng.ts +259 -0
- package/plugins/generations/image-gen/src/providers/minimax.ts +171 -0
- package/plugins/generations/image-gen/src/providers/openai.ts +319 -0
- package/plugins/generations/image-gen/src/providers/openrouter.ts +257 -0
- package/plugins/generations/image-gen/src/providers/refs.ts +24 -0
- package/plugins/generations/image-gen/src/providers/replicate.ts +279 -0
- package/plugins/generations/image-gen/src/providers/seedream.ts +128 -0
- package/plugins/generations/image-gen/src/providers/types.ts +286 -0
- package/plugins/generations/image-gen/src/providers/zai.ts +237 -0
- package/plugins/generations/image-gen/tsconfig.json +8 -0
- package/plugins/generations/news-report-gen/dist/index.js +22193 -0
- package/plugins/generations/news-report-gen/package.json +17 -0
- package/plugins/generations/news-report-gen/plugin.json +7 -0
- package/plugins/generations/news-report-gen/src/index.ts +308 -0
- package/plugins/generations/news-report-gen/tsconfig.json +4 -0
- package/plugins/generations/omni-voice-gen/Makefile +14 -0
- package/plugins/generations/omni-voice-gen/README.md +112 -0
- package/plugins/generations/omni-voice-gen/bin/omni-voice-gen +2 -0
- package/plugins/generations/omni-voice-gen/dist/omni-voice-gen-prr8skpb. +2 -0
- package/plugins/generations/omni-voice-gen/dist/omni-voice-gen.js +6 -0
- package/plugins/generations/omni-voice-gen/plugin.json +6 -0
- package/plugins/generations/omni-voice-gen/profiles.json +12 -0
- package/plugins/generations/omni-voice-gen/pyproject.toml +25 -0
- package/plugins/generations/omni-voice-gen/scripts/coverage_gate.py +74 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/__init__.py +1 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/__main__.py +39 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/audio.py +190 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/backend.py +150 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/contract.py +76 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/mp3.py +60 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/pipeline.py +289 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/profiles.py +100 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/qc.py +234 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/voicescript.py +352 -0
- package/plugins/generations/omni-voice-gen/uv.lock +3510 -0
- package/plugins/generations/voice-gen/dist/index.js +23055 -0
- package/plugins/generations/voice-gen/plugin.json +1 -1
- package/plugins/generations/voice-gen/src/index.ts +16 -1
- package/plugins/generations/voice-gen/src/voicebox-client.ts +3 -1
- package/plugins/ingestions/aihot-ingest/dist/index.js +22378 -0
- package/plugins/ingestions/aihot-ingest/plugin.json +1 -1
- package/plugins/ingestions/horizon-ingest/dist/index.js +22125 -0
- package/plugins/ingestions/horizon-ingest/plugin.json +1 -1
- 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 +22070 -0
- package/plugins/ingestions/last30days-ingest/plugin.json +1 -1
- package/plugins/ingestions/web-search/dist/index.js +24399 -0
- package/plugins/ingestions/web-search/plugin.json +1 -1
- package/plugins/kk/commands/image-extract.md +40 -0
- package/plugins/kk/commands/image-generate.md +32 -0
- package/plugins/kk/config.example.yaml +80 -0
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/skills/image-authoring/SKILL.md +257 -0
- package/plugins/kk/skills/image-authoring/references/format-drafting.md +57 -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 +139 -0
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +130 -30
- package/plugins/publishings/emdash-pub/dist/index.js +22263 -0
- package/plugins/publishings/emdash-pub/plugin.json +1 -1
- package/plugins/publishings/podcast-pub/dist/index.js +22650 -0
- package/plugins/publishings/podcast-pub/plugin.json +8 -2
- package/plugins/publishings/podcast-pub/src/index.ts +18 -2
- package/plugins/publishings/podcast-pub/src/show-notes.ts +56 -9
- package/plugins/publishings/qiita-pub/dist/index.js +22101 -0
- package/plugins/publishings/qiita-pub/plugin.json +1 -1
- package/plugins/publishings/surfdash-pub/dist/index.js +22323 -0
- package/plugins/publishings/surfdash-pub/plugin.json +1 -1
- package/plugins/publishings/surfdash-pub/src/index.ts +109 -9
- package/plugins/publishings/zenn-pub/dist/index.js +22142 -0
- package/plugins/publishings/zenn-pub/plugin.json +1 -1
- package/plugins/sp/scripts/batch-preflight.mjs +346 -0
- package/plugins/sp/scripts/batch-preflight.ts +459 -0
- package/plugins/sp/scripts/daily-summary/daily-summary.mjs +615 -0
- package/plugins/sp/scripts/daily-summary/daily-summary.ts +846 -0
- package/plugins/sp/scripts/daily-summary/logger.ts +28 -0
- package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +223 -0
- package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +367 -0
- package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +132 -0
- package/plugins/sp/scripts/dogfood-testing/validate-report.ts +169 -0
- package/plugins/sp/scripts/feature-dev-precheck.mjs +171 -0
- package/plugins/sp/scripts/feature-dev-precheck.ts +238 -0
- package/plugins/sp/scripts/feature-sync-bounded.mjs +285 -0
- package/plugins/sp/scripts/feature-sync-bounded.ts +478 -0
- package/plugins/sp/scripts/history-anatomy-cache.mjs +902 -0
- package/plugins/sp/scripts/history-anatomy-cache.ts +1028 -0
- package/plugins/sp/scripts/idea-handoff.mjs +22 -0
- package/plugins/sp/scripts/idea-handoff.ts +44 -0
- package/plugins/sp/scripts/inline-pipeline-parity-check.ts +185 -0
- package/plugins/sp/scripts/inline-run-setup.ts +198 -0
- package/plugins/sp/scripts/pr-reviewing.mjs +769 -0
- package/plugins/sp/scripts/pr-reviewing.ts +925 -0
- package/plugins/sp/scripts/quality-gate.mjs +179 -0
- package/plugins/sp/scripts/quality-gate.ts +217 -0
- package/plugins/sp/scripts/script-contract-check.ts +319 -0
- package/plugins/sp/scripts/stage-registry-adapter.ts +1533 -0
- package/plugins/sp/scripts/surface-drift-inventory.ts +929 -0
- package/plugins/sp/scripts/task-evidence-precheck.ts +181 -0
- package/plugins/sp/scripts/task-size-precheck.ts +175 -0
- package/plugins/sp/scripts/transition-shim-check.ts +238 -0
- package/plugins/sp/scripts/validate-commands.ts +689 -0
- package/plugins/sp/scripts/validate-flag-contracts.ts +878 -0
- package/plugins/sp/scripts/verify-answer-lint.ts +530 -0
- package/plugins/sp/scripts/workflow-step-profile.mjs +316 -0
- package/plugins/sp/scripts/workflow-step-profile.ts +456 -0
- package/plugins/sp/scripts/wrapup-steps.mjs +373 -0
- 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,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,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())
|