@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.
Files changed (77) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +74 -0
  3. package/dist/index.js +46 -100
  4. package/package.json +19 -1
  5. package/plugins/generations/content-gen/dist/index.js +42 -94
  6. package/plugins/generations/core-facts-gen/dist/index.js +51 -100
  7. package/plugins/generations/daily-article-gen/dist/index.js +98 -100
  8. package/plugins/generations/daily-article-gen/src/index.ts +80 -6
  9. package/plugins/generations/dailynews-gen/dist/index.js +144 -130
  10. package/plugins/generations/dailynews-gen/src/script-builder.ts +141 -33
  11. package/plugins/generations/episode-plan-gen/dist/index.js +124 -115
  12. package/plugins/generations/episode-plan-gen/src/index.ts +111 -24
  13. package/plugins/generations/image-gen/dist/index.js +63 -115
  14. package/plugins/generations/news-report-gen/dist/index.js +164 -100
  15. package/plugins/generations/news-report-gen/src/index.ts +199 -7
  16. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/pipeline.py +17 -1
  17. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/qc.py +4 -22
  18. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/voicescript.py +8 -0
  19. package/plugins/generations/voice-gen/dist/index.js +136 -117
  20. package/plugins/generations/voice-gen/src/concat.ts +34 -0
  21. package/plugins/generations/voice-gen/src/index.ts +6 -4
  22. package/plugins/generations/voice-gen/src/voicescript.ts +61 -0
  23. package/plugins/ingestions/aihot-ingest/dist/index.js +47 -97
  24. package/plugins/ingestions/horizon-ingest/dist/index.js +47 -96
  25. package/plugins/ingestions/karakeep-local/dist/index.js +217 -266
  26. package/plugins/ingestions/last30days-ingest/dist/index.js +42 -91
  27. package/plugins/ingestions/web-search/dist/index.js +68 -117
  28. package/plugins/kk/commands/workflow-run.md +25 -2
  29. package/plugins/kk/plugin.json +1 -1
  30. package/plugins/kk/scripts/kk-workflow-stages.ts +522 -163
  31. package/plugins/kk/scripts/validate-voicescript.ts +55 -0
  32. package/plugins/kk/skills/publish/SKILL.md +80 -0
  33. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +125 -120
  34. package/plugins/kk/workflows/leftover-publish-fanout-example.yaml +53 -0
  35. package/plugins/publishings/emdash-pub/dist/index.js +42 -91
  36. package/plugins/publishings/infoq-pub/dist/index.js +21994 -0
  37. package/plugins/publishings/infoq-pub/package.json +17 -0
  38. package/plugins/publishings/infoq-pub/plugin.json +7 -0
  39. package/plugins/publishings/infoq-pub/src/index.ts +123 -0
  40. package/plugins/publishings/infoq-pub/tsconfig.json +4 -0
  41. package/plugins/publishings/juejin-pub/dist/index.js +21994 -0
  42. package/plugins/publishings/juejin-pub/package.json +17 -0
  43. package/plugins/publishings/juejin-pub/plugin.json +7 -0
  44. package/plugins/publishings/juejin-pub/src/index.ts +123 -0
  45. package/plugins/publishings/juejin-pub/tsconfig.json +4 -0
  46. package/plugins/publishings/medium-pub/dist/index.js +22041 -0
  47. package/plugins/publishings/medium-pub/package.json +17 -0
  48. package/plugins/publishings/medium-pub/plugin.json +7 -0
  49. package/plugins/publishings/medium-pub/src/index.ts +215 -0
  50. package/plugins/publishings/medium-pub/tsconfig.json +4 -0
  51. package/plugins/publishings/podcast-pub/dist/index.js +183 -123
  52. package/plugins/publishings/podcast-pub/src/index.ts +47 -3
  53. package/plugins/publishings/podcast-pub/src/show-notes.ts +153 -34
  54. package/plugins/publishings/qiita-pub/dist/index.js +42 -91
  55. package/plugins/publishings/substack-pub/dist/index.js +21994 -0
  56. package/plugins/publishings/substack-pub/package.json +17 -0
  57. package/plugins/publishings/substack-pub/plugin.json +7 -0
  58. package/plugins/publishings/substack-pub/src/index.ts +123 -0
  59. package/plugins/publishings/substack-pub/tsconfig.json +4 -0
  60. package/plugins/publishings/surfdash-pub/dist/index.js +172 -103
  61. package/plugins/publishings/surfdash-pub/src/index.ts +32 -2
  62. package/plugins/publishings/wechatmp-pub/dist/index.js +22119 -0
  63. package/plugins/publishings/wechatmp-pub/package.json +17 -0
  64. package/plugins/publishings/wechatmp-pub/plugin.json +7 -0
  65. package/plugins/publishings/wechatmp-pub/src/index.ts +304 -0
  66. package/plugins/publishings/wechatmp-pub/tsconfig.json +4 -0
  67. package/plugins/publishings/x-pub/dist/index.js +22110 -0
  68. package/plugins/publishings/x-pub/package.json +17 -0
  69. package/plugins/publishings/x-pub/plugin.json +7 -0
  70. package/plugins/publishings/x-pub/src/index.ts +222 -0
  71. package/plugins/publishings/x-pub/tsconfig.json +4 -0
  72. package/plugins/publishings/xhs-pub/dist/index.js +21994 -0
  73. package/plugins/publishings/xhs-pub/package.json +17 -0
  74. package/plugins/publishings/xhs-pub/plugin.json +7 -0
  75. package/plugins/publishings/xhs-pub/src/index.ts +123 -0
  76. package/plugins/publishings/xhs-pub/tsconfig.json +4 -0
  77. 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: { rejected?: { doc: Doc; reasons: string[] }[]; timing?: TimingEntry[] } = {},
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
- /** Read the rejected audit; absent/unreadable/malformed -> [] (never throw). */
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
- return Array.isArray(parsed) ? (parsed as RejectedAudit[]) : [];
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: { rejected?: { doc: Doc; reasons: string[] }[]; timing?: TimingEntry[] },
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 content = docsToContent(docs, dateFromEnv(), { rejected, timing });
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
- max_possible = (
189
- MAX_DURATION_ZH
190
- if language.startswith("zh")
191
- else MAX_DURATION_EN
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