@gobing-ai/knowledge-kit 0.0.16 → 0.0.18
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/README.md +74 -0
- package/dist/index.js +46 -100
- package/package.json +19 -1
- package/plugins/generations/content-gen/dist/index.js +42 -94
- package/plugins/generations/core-facts-gen/dist/index.js +51 -100
- package/plugins/generations/daily-article-gen/dist/index.js +98 -100
- package/plugins/generations/daily-article-gen/src/index.ts +80 -6
- package/plugins/generations/dailynews-gen/dist/index.js +144 -130
- package/plugins/generations/dailynews-gen/src/script-builder.ts +141 -33
- package/plugins/generations/episode-plan-gen/dist/index.js +124 -115
- package/plugins/generations/episode-plan-gen/src/index.ts +111 -24
- package/plugins/generations/image-gen/dist/index.js +63 -115
- package/plugins/generations/news-report-gen/dist/index.js +164 -100
- package/plugins/generations/news-report-gen/src/index.ts +199 -7
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/pipeline.py +17 -1
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/qc.py +4 -22
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/voicescript.py +8 -0
- package/plugins/generations/voice-gen/dist/index.js +136 -117
- package/plugins/generations/voice-gen/src/concat.ts +34 -0
- package/plugins/generations/voice-gen/src/index.ts +6 -4
- package/plugins/generations/voice-gen/src/voicescript.ts +61 -0
- package/plugins/ingestions/aihot-ingest/dist/index.js +47 -97
- package/plugins/ingestions/horizon-ingest/dist/index.js +47 -96
- package/plugins/ingestions/karakeep-local/dist/index.js +217 -266
- package/plugins/ingestions/last30days-ingest/dist/index.js +42 -91
- package/plugins/ingestions/web-search/dist/index.js +68 -117
- package/plugins/kk/commands/workflow-run.md +25 -2
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/scripts/kk-workflow-stages.ts +522 -163
- package/plugins/kk/scripts/validate-voicescript.ts +55 -0
- package/plugins/kk/skills/publish/SKILL.md +80 -0
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +125 -120
- package/plugins/kk/workflows/leftover-publish-fanout-example.yaml +53 -0
- package/plugins/publishings/emdash-pub/dist/index.js +42 -91
- package/plugins/publishings/infoq-pub/dist/index.js +21994 -0
- package/plugins/publishings/infoq-pub/package.json +17 -0
- package/plugins/publishings/infoq-pub/plugin.json +7 -0
- package/plugins/publishings/infoq-pub/src/index.ts +123 -0
- package/plugins/publishings/infoq-pub/tsconfig.json +4 -0
- package/plugins/publishings/juejin-pub/dist/index.js +21994 -0
- package/plugins/publishings/juejin-pub/package.json +17 -0
- package/plugins/publishings/juejin-pub/plugin.json +7 -0
- package/plugins/publishings/juejin-pub/src/index.ts +123 -0
- package/plugins/publishings/juejin-pub/tsconfig.json +4 -0
- package/plugins/publishings/medium-pub/dist/index.js +22041 -0
- package/plugins/publishings/medium-pub/package.json +17 -0
- package/plugins/publishings/medium-pub/plugin.json +7 -0
- package/plugins/publishings/medium-pub/src/index.ts +215 -0
- package/plugins/publishings/medium-pub/tsconfig.json +4 -0
- package/plugins/publishings/podcast-pub/dist/index.js +183 -123
- package/plugins/publishings/podcast-pub/src/index.ts +47 -3
- package/plugins/publishings/podcast-pub/src/show-notes.ts +153 -34
- package/plugins/publishings/qiita-pub/dist/index.js +42 -91
- package/plugins/publishings/substack-pub/dist/index.js +21994 -0
- package/plugins/publishings/substack-pub/package.json +17 -0
- package/plugins/publishings/substack-pub/plugin.json +7 -0
- package/plugins/publishings/substack-pub/src/index.ts +123 -0
- package/plugins/publishings/substack-pub/tsconfig.json +4 -0
- package/plugins/publishings/surfdash-pub/dist/index.js +172 -103
- package/plugins/publishings/surfdash-pub/src/index.ts +32 -2
- package/plugins/publishings/wechatmp-pub/dist/index.js +22119 -0
- package/plugins/publishings/wechatmp-pub/package.json +17 -0
- package/plugins/publishings/wechatmp-pub/plugin.json +7 -0
- package/plugins/publishings/wechatmp-pub/src/index.ts +304 -0
- package/plugins/publishings/wechatmp-pub/tsconfig.json +4 -0
- package/plugins/publishings/x-pub/dist/index.js +22110 -0
- package/plugins/publishings/x-pub/package.json +17 -0
- package/plugins/publishings/x-pub/plugin.json +7 -0
- package/plugins/publishings/x-pub/src/index.ts +222 -0
- package/plugins/publishings/x-pub/tsconfig.json +4 -0
- package/plugins/publishings/xhs-pub/dist/index.js +21994 -0
- package/plugins/publishings/xhs-pub/package.json +17 -0
- package/plugins/publishings/xhs-pub/plugin.json +7 -0
- package/plugins/publishings/xhs-pub/src/index.ts +123 -0
- package/plugins/publishings/xhs-pub/tsconfig.json +4 -0
- package/plugins/publishings/zenn-pub/dist/index.js +42 -91
|
@@ -17,7 +17,12 @@ import { echoError } from '@gobing-ai/ts-utils';
|
|
|
17
17
|
* workflow from the filter Content's metadata.docs). Scores live in
|
|
18
18
|
* `metadata.scores` (quality/importance/urgency/impact 0-5).
|
|
19
19
|
* - `NEWS_REPORT_REJECTED_FILE` — the filter's rejected audit path
|
|
20
|
-
* (`2-plan/candidates.rejected.json`, RejectedDoc[] of {doc, reasons})
|
|
20
|
+
* (`2-plan/candidates.rejected.json`, RejectedDoc[] of {doc, reasons})
|
|
21
|
+
* - `NEWS_REPORT_BLENDED_FILE` — the collect stage's blended fan-in output
|
|
22
|
+
* (`1-ingest/<date>_02_collect_blended.json`); absent or unreadable -> the
|
|
23
|
+
* 语料构成 section is omitted, never throw.
|
|
24
|
+
* - `NEWS_REPORT_QC_FILE` — the generate stage's QC audit path
|
|
25
|
+
* (`3-audio/<date>_11_generate_content.json`); absent -> the 音频 QC block is omitted;
|
|
21
26
|
* absent or unreadable -> reject counts render as 0, never throw.
|
|
22
27
|
* - `NEWS_REPORT_TIMING_FILE` — `<work_dir>/step-timing.json` JSONL of
|
|
23
28
|
* {step, startedAt, endedAt} appended by the workflow shell steps; missing
|
|
@@ -144,6 +149,51 @@ interface RejectedAudit {
|
|
|
144
149
|
reasons: string[];
|
|
145
150
|
}
|
|
146
151
|
|
|
152
|
+
/** Generate-stage audio QC summary rendered into the run report (task 0139 R5b). */
|
|
153
|
+
export interface QcSummary {
|
|
154
|
+
passed: boolean;
|
|
155
|
+
overallScore: number;
|
|
156
|
+
/** Per-issue-type counts, most frequent first; `duration_anomaly: <detail>` counts as its type. */
|
|
157
|
+
issues: { type: string; count: number }[];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Read the generate stage's QC audit (`<run_date>_11_generate_content.json`) (task 0139 R5b).
|
|
162
|
+
*
|
|
163
|
+
* Best-effort by contract: an absent, unreadable, or malformed file returns `null` and the report
|
|
164
|
+
* omits the section — a run report must never fail a run. Issue types are the text before the first
|
|
165
|
+
* `:` so a detailed `duration_anomaly: expected …` rolls up under one row.
|
|
166
|
+
*/
|
|
167
|
+
export async function readQcSummary(path: string | undefined): Promise<QcSummary | null> {
|
|
168
|
+
if (!path || path.trim() === '') return null;
|
|
169
|
+
try {
|
|
170
|
+
const parsed = JSON.parse(await Bun.file(path).text()) as { metadata?: { qc?: unknown } } | null;
|
|
171
|
+
const qc = parsed?.metadata?.qc;
|
|
172
|
+
if (qc === null || typeof qc !== 'object') return null;
|
|
173
|
+
const record = qc as { passed?: unknown; overallScore?: unknown; segmentAudits?: unknown };
|
|
174
|
+
const counts = new Map<string, number>();
|
|
175
|
+
const audits = Array.isArray(record.segmentAudits) ? record.segmentAudits : [];
|
|
176
|
+
for (const audit of audits) {
|
|
177
|
+
const issues = (audit as { issues?: unknown } | null)?.issues;
|
|
178
|
+
if (!Array.isArray(issues)) continue;
|
|
179
|
+
for (const issue of issues) {
|
|
180
|
+
if (typeof issue !== 'string') continue;
|
|
181
|
+
const type = issue.split(':')[0]?.trim() || issue;
|
|
182
|
+
counts.set(type, (counts.get(type) ?? 0) + 1);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
passed: record.passed === true,
|
|
187
|
+
overallScore: typeof record.overallScore === 'number' ? record.overallScore : 0,
|
|
188
|
+
issues: [...counts.entries()]
|
|
189
|
+
.map(([type, count]) => ({ type, count }))
|
|
190
|
+
.sort((a, b) => b.count - a.count || a.type.localeCompare(b.type)),
|
|
191
|
+
};
|
|
192
|
+
} catch {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
147
197
|
/** Parse the timing JSONL; unreadable/missing -> [] (never throw). Last entry per step wins. */
|
|
148
198
|
export async function readTiming(path: string | undefined): Promise<TimingEntry[]> {
|
|
149
199
|
if (!path || path.trim() === '') return [];
|
|
@@ -180,7 +230,13 @@ function durationMs(entry: TimingEntry): number | null {
|
|
|
180
230
|
export function renderReportMarkdown(
|
|
181
231
|
docs: Doc[],
|
|
182
232
|
date: string,
|
|
183
|
-
options: {
|
|
233
|
+
options: {
|
|
234
|
+
rejected?: { doc: Doc; reasons: string[] }[];
|
|
235
|
+
timing?: TimingEntry[];
|
|
236
|
+
qc?: QcSummary | null;
|
|
237
|
+
planAudit?: PlanAuditRow[] | null;
|
|
238
|
+
blended?: BlendedDoc[] | null;
|
|
239
|
+
} = {},
|
|
184
240
|
): string {
|
|
185
241
|
const lines: string[] = [`# 每日 AI 语音运行报告 — ${date}`, ''];
|
|
186
242
|
lines.push('## 选稿评分(保留候选)', '');
|
|
@@ -221,6 +277,52 @@ export function renderReportMarkdown(
|
|
|
221
277
|
lines.push(`| category allowlist | — | ${counts.category} |`);
|
|
222
278
|
lines.push('');
|
|
223
279
|
|
|
280
|
+
const blended = options.blended ?? null;
|
|
281
|
+
if (blended !== null && blended.length > 0) {
|
|
282
|
+
// 20260917 funnel: selection can only be judged against what arrived — the blended
|
|
283
|
+
// input's source/category composition exposes the uncategorized mass that plan
|
|
284
|
+
// under-ranks (e.g. 47 of 52 docs with no category -> 2 selected).
|
|
285
|
+
const comp = corpusComposition(blended);
|
|
286
|
+
lines.push(`## 语料构成(fan-in 输入,共 ${comp.total} 条)`, '');
|
|
287
|
+
lines.push('| 来源 | 条数 |', '| --- | --- |');
|
|
288
|
+
for (const g of comp.sources) lines.push(`| ${g.key} | ${g.count} |`);
|
|
289
|
+
lines.push('');
|
|
290
|
+
lines.push('| 类别 | 条数 |', '| --- | --- |');
|
|
291
|
+
for (const g of comp.categories) lines.push(`| ${g.key} | ${g.count} |`);
|
|
292
|
+
lines.push('');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const planAudit = options.planAudit ?? null;
|
|
296
|
+
if (planAudit !== null && planAudit.length > 0) {
|
|
297
|
+
// 20260917 funnel: the report only showed the 2 survivors — every fetched title, its
|
|
298
|
+
// scores and its elimination reason now render here (selection is auditable end to end).
|
|
299
|
+
lines.push(`## 选稿漏斗(全量审计,共 ${planAudit.length} 条)`, '');
|
|
300
|
+
lines.push(
|
|
301
|
+
'| 来源 | 标题 | 类别 | q | i | u | im | score | 去向 |',
|
|
302
|
+
'| --- | --- | --- | --- | --- | --- | --- | --- | --- |',
|
|
303
|
+
);
|
|
304
|
+
for (const row of planAudit) {
|
|
305
|
+
const s = row.scores ?? {};
|
|
306
|
+
const title = (row.title || row.id).replace(/\|/g, '\\|').slice(0, 60);
|
|
307
|
+
lines.push(
|
|
308
|
+
`| ${row.source} | ${title} | ${row.category} | ${s.quality ?? '—'} | ${s.importance ?? '—'} | ${s.urgency ?? '—'} | ${s.impact ?? '—'} | ${row.planScore ?? '—'} | ${row.disposition} |`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
lines.push('');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const qc = options.qc ?? null;
|
|
315
|
+
if (qc !== null) {
|
|
316
|
+
// 0139 R5b: the audio QC verdict is otherwise invisible in the report — this run published
|
|
317
|
+
// an episode whose audit said `passed:false`, and nothing but the artifact recorded it.
|
|
318
|
+
lines.push('## 音频 QC', '');
|
|
319
|
+
lines.push('| 指标 | 值 |', '| --- | --- |');
|
|
320
|
+
lines.push(`| 综合评分 | ${qc.overallScore} |`);
|
|
321
|
+
lines.push(`| 通过 | ${qc.passed ? '是' : '否'} |`);
|
|
322
|
+
for (const issue of qc.issues) lines.push(`| ${issue.type} | ${issue.count} |`);
|
|
323
|
+
lines.push('');
|
|
324
|
+
}
|
|
325
|
+
|
|
224
326
|
const timing = options.timing ?? [];
|
|
225
327
|
if (timing.length > 0) {
|
|
226
328
|
lines.push('## 步耗时', '');
|
|
@@ -240,22 +342,109 @@ export function renderReportMarkdown(
|
|
|
240
342
|
return lines.join('\n');
|
|
241
343
|
}
|
|
242
344
|
|
|
243
|
-
/**
|
|
345
|
+
/**
|
|
346
|
+
* Read the rejected audit; absent/unreadable/malformed -> [] (never throw).
|
|
347
|
+
*
|
|
348
|
+
* Two writer shapes: a bare `RejectedAudit[]` (older producers) and episode-plan-gen's
|
|
349
|
+
* `{ rejected, config, total }` envelope (`processFilterIO` — the live one). Reading only the
|
|
350
|
+
* array shape reported 0 rejections for every real run (task 0138 D1).
|
|
351
|
+
*/
|
|
244
352
|
export async function readRejectedAudit(path: string | undefined): Promise<RejectedAudit[]> {
|
|
245
353
|
if (!path || path.trim() === '') return [];
|
|
246
354
|
try {
|
|
247
|
-
const parsed = JSON.parse(await Bun.file(path).text());
|
|
248
|
-
|
|
355
|
+
const parsed: unknown = JSON.parse(await Bun.file(path).text());
|
|
356
|
+
if (Array.isArray(parsed)) return parsed as RejectedAudit[];
|
|
357
|
+
const rejected = (parsed as { rejected?: unknown } | null)?.rejected;
|
|
358
|
+
return Array.isArray(rejected) ? (rejected as RejectedAudit[]) : [];
|
|
249
359
|
} catch {
|
|
250
360
|
return [];
|
|
251
361
|
}
|
|
252
362
|
}
|
|
253
363
|
|
|
364
|
+
/** One row of episode-plan-gen's `<out>.audit.json` selection trail (plan mode). */
|
|
365
|
+
export interface PlanAuditRow {
|
|
366
|
+
id: string;
|
|
367
|
+
source: string;
|
|
368
|
+
title: string;
|
|
369
|
+
category: string;
|
|
370
|
+
scores: Record<string, number>;
|
|
371
|
+
planScore: number | null;
|
|
372
|
+
disposition: 'gate' | 'url-merge' | 'title-merge' | 'cap' | 'topN' | 'selected';
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Read the plan-mode audit envelope `{ total, rows }`; absent/unreadable/malformed -> null (never throw). */
|
|
376
|
+
export async function readPlanAudit(path: string | undefined): Promise<PlanAuditRow[] | null> {
|
|
377
|
+
if (!path || path.trim() === '') return null;
|
|
378
|
+
try {
|
|
379
|
+
const parsed: unknown = JSON.parse(await Bun.file(path).text());
|
|
380
|
+
const rows = (parsed as { rows?: unknown } | null)?.rows;
|
|
381
|
+
return Array.isArray(rows) ? (rows as PlanAuditRow[]) : null;
|
|
382
|
+
} catch {
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Minimal shape the 语料构成 section needs from a blended fan-in doc (tolerant on purpose). */
|
|
388
|
+
export interface BlendedDoc {
|
|
389
|
+
title?: unknown;
|
|
390
|
+
id?: unknown;
|
|
391
|
+
source?: unknown;
|
|
392
|
+
category?: unknown;
|
|
393
|
+
metadata?: { source?: unknown; category?: unknown };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export interface CorpusCount {
|
|
397
|
+
key: string;
|
|
398
|
+
count: number;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Read the blended fan-in input; absent/unreadable/malformed/not-an-array -> null (never throw). */
|
|
402
|
+
export async function readBlendedCorpus(path: string | undefined): Promise<BlendedDoc[] | null> {
|
|
403
|
+
if (!path || path.trim() === '') return null;
|
|
404
|
+
try {
|
|
405
|
+
const parsed: unknown = JSON.parse(await Bun.file(path).text());
|
|
406
|
+
const docs = Array.isArray(parsed) ? parsed : (parsed as { docs?: unknown } | null)?.docs;
|
|
407
|
+
return Array.isArray(docs) ? (docs as BlendedDoc[]) : null;
|
|
408
|
+
} catch {
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Tally blended docs by source and by category (missing fields bucket as (无)), most frequent first. */
|
|
414
|
+
export function corpusComposition(docs: BlendedDoc[]): {
|
|
415
|
+
total: number;
|
|
416
|
+
sources: CorpusCount[];
|
|
417
|
+
categories: CorpusCount[];
|
|
418
|
+
} {
|
|
419
|
+
const tally = (pick: (d: BlendedDoc) => unknown): CorpusCount[] => {
|
|
420
|
+
const counts = new Map<string, number>();
|
|
421
|
+
for (const d of docs) {
|
|
422
|
+
const raw = pick(d);
|
|
423
|
+
const key = typeof raw === 'string' && raw.trim() !== '' ? raw : '(无)';
|
|
424
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
425
|
+
}
|
|
426
|
+
return [...counts.entries()]
|
|
427
|
+
.map(([key, count]) => ({ key, count }))
|
|
428
|
+
.sort((a, b) => b.count - a.count || (a.key < b.key ? -1 : 1));
|
|
429
|
+
};
|
|
430
|
+
return {
|
|
431
|
+
total: docs.length,
|
|
432
|
+
sources: tally((d) => d.metadata?.source ?? d.source),
|
|
433
|
+
categories: tally((d) => d.metadata?.category ?? d.category),
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
254
437
|
/** Build the report Content from a kept-candidate Doc[]. */
|
|
255
438
|
export function docsToContent(
|
|
256
439
|
docs: Doc[],
|
|
257
440
|
date: string,
|
|
258
|
-
options: {
|
|
441
|
+
options: {
|
|
442
|
+
rejected?: { doc: Doc; reasons: string[] }[];
|
|
443
|
+
timing?: TimingEntry[];
|
|
444
|
+
qc?: QcSummary | null;
|
|
445
|
+
planAudit?: PlanAuditRow[] | null;
|
|
446
|
+
blended?: BlendedDoc[] | null;
|
|
447
|
+
},
|
|
259
448
|
): Content {
|
|
260
449
|
return ContentSchema.parse({
|
|
261
450
|
title: `每日 AI 语音运行报告 — ${date}`,
|
|
@@ -274,7 +463,10 @@ export async function processGeneratorIO(inputPath: string, outputPath: string):
|
|
|
274
463
|
const docs = DocListSchema.parse(await readJsonFile(inputPath));
|
|
275
464
|
const rejected = await readRejectedAudit(process.env.NEWS_REPORT_REJECTED_FILE);
|
|
276
465
|
const timing = await readTiming(process.env.NEWS_REPORT_TIMING_FILE);
|
|
277
|
-
const
|
|
466
|
+
const qc = await readQcSummary(process.env.NEWS_REPORT_QC_FILE);
|
|
467
|
+
const planAudit = await readPlanAudit(process.env.NEWS_REPORT_PLAN_AUDIT_FILE);
|
|
468
|
+
const blended = await readBlendedCorpus(process.env.NEWS_REPORT_BLENDED_FILE);
|
|
469
|
+
const content = docsToContent(docs, dateFromEnv(), { rejected, timing, qc, planAudit, blended });
|
|
278
470
|
const fs = createNodeFileSystem();
|
|
279
471
|
const outDir = dirname(outputPath);
|
|
280
472
|
if (outDir && outDir !== '.') await fs.ensureDir(outDir);
|
|
@@ -210,7 +210,7 @@ def _render(
|
|
|
210
210
|
"profile": resolve_profile(profile, registry),
|
|
211
211
|
"instruct": segment_instruct,
|
|
212
212
|
"seed": None if segment.seed is None else int(segment.seed),
|
|
213
|
-
"speed": None,
|
|
213
|
+
"speed": None if segment.speed is None else float(segment.speed),
|
|
214
214
|
}
|
|
215
215
|
wav, duration, retries, transcription, dip = _render_verified(backend, spec)
|
|
216
216
|
|
|
@@ -303,3 +303,19 @@ def run(
|
|
|
303
303
|
except BaseException:
|
|
304
304
|
_delete(out_path, audio_path, mp3_path)
|
|
305
305
|
raise
|
|
306
|
+
|
|
307
|
+
# R5a (task 0139): repetition is the one QC issue the verify loop cannot fix and the one that
|
|
308
|
+
# damages credibility on air, so it fails the run — but only AFTER content.json is on disk, and
|
|
309
|
+
# outside the delete-on-failure try above, so the operator can inspect the audit that condemned
|
|
310
|
+
# it. The audio and MP3 stay too: this is a review stop, not a corrupt-artifact cleanup. Recovery
|
|
311
|
+
# is HITL YAML inspection plus a re-run; Q&A item 3 ships this with no override knob.
|
|
312
|
+
repeated = [
|
|
313
|
+
audit.get("segmentIndex")
|
|
314
|
+
for audit in content.metadata.get("qc", {}).get("segmentAudits", [])
|
|
315
|
+
if "repetition_detected" in audit.get("issues", [])
|
|
316
|
+
]
|
|
317
|
+
if repeated:
|
|
318
|
+
indexes = ", ".join(str(index) for index in repeated)
|
|
319
|
+
raise RuntimeError(
|
|
320
|
+
f"segment(s) {indexes} carry repetition_detected — content.json written for inspection"
|
|
321
|
+
)
|
|
@@ -24,13 +24,6 @@ MAX_RATE_ZH = 15.0
|
|
|
24
24
|
MAX_RATE_EN = 30.0
|
|
25
25
|
MAX_RATE_OTHER = 25.0
|
|
26
26
|
|
|
27
|
-
MAX_DURATION_ZH = 15.0
|
|
28
|
-
MAX_DURATION_EN = 30.0
|
|
29
|
-
MAX_DURATION_OTHER = 20.0
|
|
30
|
-
|
|
31
|
-
MAX_DURATION_ZH_DIVISOR = 15.0
|
|
32
|
-
MAX_DURATION_EN_DIVISOR = 20.0
|
|
33
|
-
MAX_DURATION_OTHER_DIVISOR = 15.0
|
|
34
27
|
|
|
35
28
|
RE_CHAR_RUN = re.compile(r"(.)\1{2,}", re.DOTALL)
|
|
36
29
|
RE_WORD_RUN = re.compile(r"(.{2,3}?)\1{2,}", re.DOTALL)
|
|
@@ -185,21 +178,10 @@ def audit_voice_segments(
|
|
|
185
178
|
abs_anomaly = duration > max_sec + MAX_ABS_DIFF_SEC if max_sec > 0 else False
|
|
186
179
|
max_rate = MAX_RATE_ZH if language.startswith("zh") else MAX_RATE_EN if language.startswith("en") else MAX_RATE_OTHER
|
|
187
180
|
rate_anomaly = duration > 0 and (len(text) / duration) > max_rate
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if language.startswith("en")
|
|
193
|
-
else MAX_DURATION_OTHER
|
|
194
|
-
) + len(text) / (
|
|
195
|
-
MAX_DURATION_ZH_DIVISOR
|
|
196
|
-
if language.startswith("zh")
|
|
197
|
-
else MAX_DURATION_EN_DIVISOR
|
|
198
|
-
if language.startswith("en")
|
|
199
|
-
else MAX_DURATION_OTHER_DIVISOR
|
|
200
|
-
)
|
|
201
|
-
abs_ceiling_anomaly = duration > max_possible
|
|
202
|
-
duration_anomaly = ratio_anomaly or abs_anomaly or rate_anomaly or abs_ceiling_anomaly
|
|
181
|
+
# The former abs-ceiling term (duration > MAX_DURATION_<lang> + len(text)/<divisor>)
|
|
182
|
+
# compared against a fastest-speech floor, so any segment over ~39 chars flagged even
|
|
183
|
+
# when inside its expected range; genuinely overlong audio stays covered by ratio/abs/rate.
|
|
184
|
+
duration_anomaly = ratio_anomaly or abs_anomaly or rate_anomaly
|
|
203
185
|
if duration_anomaly:
|
|
204
186
|
issues.append(
|
|
205
187
|
f"duration_anomaly: expected {min_sec:.2f}s-{max_sec:.2f}s, got {duration:.2f}s"
|
|
@@ -70,6 +70,7 @@ class VoiceSegment:
|
|
|
70
70
|
engine: Any = None
|
|
71
71
|
language: Any = None
|
|
72
72
|
gap_ms: Any = None
|
|
73
|
+
speed: Any = None
|
|
73
74
|
max_chunk_chars: Any = None
|
|
74
75
|
crossfade_ms: Any = None
|
|
75
76
|
personality: Any = None
|
|
@@ -334,6 +335,13 @@ def validate_voice_script(script: VoiceScript, sink: WarnSink) -> None:
|
|
|
334
335
|
):
|
|
335
336
|
raise ValueError("gap_ms must be non-negative")
|
|
336
337
|
|
|
338
|
+
if segment.speed is not None and (
|
|
339
|
+
isinstance(segment.speed, bool)
|
|
340
|
+
or not isinstance(segment.speed, (int, float))
|
|
341
|
+
or not 0.5 <= float(segment.speed) <= 2.0
|
|
342
|
+
):
|
|
343
|
+
raise ValueError("speed must be between 0.5 and 2.0")
|
|
344
|
+
|
|
337
345
|
if segment.seed is not None and (not _is_int(segment.seed) or segment.seed < 0):
|
|
338
346
|
raise ValueError("seed must be a non-negative integer")
|
|
339
347
|
|