@gobing-ai/knowledge-kit 0.0.13 → 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 (109) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +20 -0
  3. package/package.json +1 -1
  4. package/plugins/generations/content-gen/dist/index.js +20 -0
  5. package/plugins/generations/core-facts-gen/dist/index.js +20 -0
  6. package/plugins/generations/daily-article-gen/dist/index.js +29 -1
  7. package/plugins/generations/daily-article-gen/src/index.ts +13 -1
  8. package/plugins/generations/dailynews-gen/dist/index.js +20 -0
  9. package/plugins/generations/episode-plan-gen/dist/index.js +24 -0
  10. package/plugins/generations/episode-plan-gen/src/index.ts +11 -0
  11. package/plugins/generations/image-gen/dist/index.js +2296 -53
  12. package/plugins/generations/image-gen/src/providers/agnes.ts +110 -0
  13. package/plugins/generations/image-gen/src/providers/azure.ts +153 -0
  14. package/plugins/generations/image-gen/src/providers/codex-cli.ts +170 -0
  15. package/plugins/generations/image-gen/src/providers/dashscope.ts +485 -0
  16. package/plugins/generations/image-gen/src/providers/google.ts +268 -0
  17. package/plugins/generations/image-gen/src/providers/huggingface.ts +59 -0
  18. package/plugins/generations/image-gen/src/providers/jimeng.ts +259 -0
  19. package/plugins/generations/image-gen/src/providers/minimax.ts +171 -0
  20. package/plugins/generations/image-gen/src/providers/openai.ts +319 -0
  21. package/plugins/generations/image-gen/src/providers/openrouter.ts +257 -0
  22. package/plugins/generations/image-gen/src/providers/refs.ts +24 -0
  23. package/plugins/generations/image-gen/src/providers/replicate.ts +279 -0
  24. package/plugins/generations/image-gen/src/providers/seedream.ts +1 -1
  25. package/plugins/generations/image-gen/src/providers/types.ts +52 -51
  26. package/plugins/generations/image-gen/src/providers/zai.ts +237 -0
  27. package/plugins/generations/news-report-gen/dist/index.js +22193 -0
  28. package/plugins/generations/news-report-gen/package.json +17 -0
  29. package/plugins/generations/news-report-gen/plugin.json +7 -0
  30. package/plugins/generations/news-report-gen/src/index.ts +308 -0
  31. package/plugins/generations/news-report-gen/tsconfig.json +4 -0
  32. package/plugins/generations/omni-voice-gen/Makefile +14 -0
  33. package/plugins/generations/omni-voice-gen/README.md +112 -0
  34. package/plugins/generations/omni-voice-gen/bin/omni-voice-gen +2 -0
  35. package/plugins/generations/omni-voice-gen/dist/omni-voice-gen-prr8skpb. +2 -0
  36. package/plugins/generations/omni-voice-gen/dist/omni-voice-gen.js +6 -0
  37. package/plugins/generations/omni-voice-gen/plugin.json +6 -0
  38. package/plugins/generations/omni-voice-gen/profiles.json +12 -0
  39. package/plugins/generations/omni-voice-gen/pyproject.toml +25 -0
  40. package/plugins/generations/omni-voice-gen/scripts/coverage_gate.py +74 -0
  41. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/__init__.py +1 -0
  42. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/__main__.py +39 -0
  43. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/audio.py +190 -0
  44. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/backend.py +150 -0
  45. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/contract.py +76 -0
  46. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/mp3.py +60 -0
  47. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/pipeline.py +289 -0
  48. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/profiles.py +100 -0
  49. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/qc.py +234 -0
  50. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/voicescript.py +352 -0
  51. package/plugins/generations/omni-voice-gen/uv.lock +3510 -0
  52. package/plugins/generations/voice-gen/dist/index.js +30 -2
  53. package/plugins/generations/voice-gen/src/index.ts +16 -1
  54. package/plugins/generations/voice-gen/src/voicebox-client.ts +3 -1
  55. package/plugins/ingestions/aihot-ingest/dist/index.js +20 -0
  56. package/plugins/ingestions/horizon-ingest/dist/index.js +20 -0
  57. package/plugins/ingestions/last30days-ingest/dist/index.js +20 -0
  58. package/plugins/ingestions/web-search/dist/index.js +20 -0
  59. package/plugins/kk/commands/image-extract.md +40 -0
  60. package/plugins/kk/commands/image-generate.md +18 -1
  61. package/plugins/kk/plugin.json +1 -1
  62. package/plugins/kk/skills/image-authoring/SKILL.md +10 -3
  63. package/plugins/kk/skills/image-authoring/references/format-drafting.md +57 -0
  64. package/plugins/kk/skills/image-authoring/references/style-extraction.md +14 -9
  65. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +130 -30
  66. package/plugins/publishings/emdash-pub/dist/index.js +20 -0
  67. package/plugins/publishings/podcast-pub/dist/index.js +57 -3
  68. package/plugins/publishings/podcast-pub/src/index.ts +18 -2
  69. package/plugins/publishings/podcast-pub/src/show-notes.ts +56 -9
  70. package/plugins/publishings/qiita-pub/dist/index.js +20 -0
  71. package/plugins/publishings/surfdash-pub/dist/index.js +96 -6
  72. package/plugins/publishings/surfdash-pub/src/index.ts +109 -9
  73. package/plugins/publishings/zenn-pub/dist/index.js +20 -0
  74. package/plugins/sp/scripts/batch-preflight.mjs +346 -0
  75. package/plugins/sp/scripts/batch-preflight.ts +459 -0
  76. package/plugins/sp/scripts/daily-summary/daily-summary.mjs +615 -0
  77. package/plugins/sp/scripts/daily-summary/daily-summary.ts +846 -0
  78. package/plugins/sp/scripts/daily-summary/logger.ts +28 -0
  79. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +223 -0
  80. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +367 -0
  81. package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +132 -0
  82. package/plugins/sp/scripts/dogfood-testing/validate-report.ts +169 -0
  83. package/plugins/sp/scripts/feature-dev-precheck.mjs +171 -0
  84. package/plugins/sp/scripts/feature-dev-precheck.ts +238 -0
  85. package/plugins/sp/scripts/feature-sync-bounded.mjs +285 -0
  86. package/plugins/sp/scripts/feature-sync-bounded.ts +478 -0
  87. package/plugins/sp/scripts/history-anatomy-cache.mjs +902 -0
  88. package/plugins/sp/scripts/history-anatomy-cache.ts +1028 -0
  89. package/plugins/sp/scripts/idea-handoff.mjs +22 -0
  90. package/plugins/sp/scripts/idea-handoff.ts +44 -0
  91. package/plugins/sp/scripts/inline-pipeline-parity-check.ts +185 -0
  92. package/plugins/sp/scripts/inline-run-setup.ts +198 -0
  93. package/plugins/sp/scripts/pr-reviewing.mjs +769 -0
  94. package/plugins/sp/scripts/pr-reviewing.ts +925 -0
  95. package/plugins/sp/scripts/quality-gate.mjs +179 -0
  96. package/plugins/sp/scripts/quality-gate.ts +217 -0
  97. package/plugins/sp/scripts/script-contract-check.ts +319 -0
  98. package/plugins/sp/scripts/stage-registry-adapter.ts +1533 -0
  99. package/plugins/sp/scripts/surface-drift-inventory.ts +929 -0
  100. package/plugins/sp/scripts/task-evidence-precheck.ts +181 -0
  101. package/plugins/sp/scripts/task-size-precheck.ts +175 -0
  102. package/plugins/sp/scripts/transition-shim-check.ts +238 -0
  103. package/plugins/sp/scripts/validate-commands.ts +689 -0
  104. package/plugins/sp/scripts/validate-flag-contracts.ts +878 -0
  105. package/plugins/sp/scripts/verify-answer-lint.ts +530 -0
  106. package/plugins/sp/scripts/workflow-step-profile.mjs +316 -0
  107. package/plugins/sp/scripts/workflow-step-profile.ts +456 -0
  108. package/plugins/sp/scripts/wrapup-steps.mjs +373 -0
  109. package/plugins/sp/scripts/wrapup-steps.ts +466 -0
@@ -0,0 +1,190 @@
1
+ """WAV parse/concat with gaps + loudness-dip detector — Python port of voice-gen concat.ts
2
+ and the detectLoudnessDip half of qc.ts (spec layout: dip detection lives here, not qc.py).
3
+
4
+ D8.1 delta (bug fix, not a port): gaps_ms[i] is silence inserted BEFORE segment i. The
5
+ voice-gen `gapsMs[0] === 0 ? gapsMs[i] : gapsMs[i - 1]` special case is not replicated.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import struct
11
+ from dataclasses import dataclass
12
+
13
+ # Dip thresholds — identical to voice-gen qc.ts (D7).
14
+ DIP_RATIO = 0.4
15
+ DIP_SILENCE_FLOOR = 0.008
16
+ DIP_ABS_CEILING = 0.06
17
+ DIP_NEIGHBOR_FLOOR = 0.02
18
+
19
+
20
+ @dataclass
21
+ class ParsedWav:
22
+ audio_format: int
23
+ num_channels: int
24
+ sample_rate: int
25
+ byte_rate: int
26
+ block_align: int
27
+ bits_per_sample: int
28
+ data_bytes: bytes
29
+
30
+
31
+ def parse_wav(buffer: bytes) -> ParsedWav:
32
+ """Parse a RIFF WAVE buffer into its format fields and raw audio data."""
33
+ if len(buffer) < 44:
34
+ raise ValueError("Invalid WAV buffer: buffer too short")
35
+
36
+ riff_tag = buffer[0:4].decode("latin-1")
37
+ wave_tag = buffer[8:12].decode("latin-1")
38
+ if riff_tag != "RIFF" or wave_tag != "WAVE":
39
+ raise ValueError(f"Invalid WAV buffer: expected RIFF/WAVE header, got {riff_tag}/{wave_tag}")
40
+
41
+ offset = 12
42
+ format_info: dict[str, int] | None = None
43
+ data_bytes: bytes | None = None
44
+
45
+ while offset + 8 <= len(buffer):
46
+ chunk_id = buffer[offset : offset + 4].decode("latin-1")
47
+ (chunk_size,) = struct.unpack_from("<I", buffer, offset + 4)
48
+ chunk_data_offset = offset + 8
49
+
50
+ if chunk_id == "fmt ":
51
+ if chunk_size < 16:
52
+ raise ValueError("Invalid WAV fmt chunk size")
53
+ audio_format, num_channels, sample_rate, byte_rate, block_align, bits_per_sample = struct.unpack_from(
54
+ "<HHIIHH", buffer, chunk_data_offset
55
+ )
56
+ format_info = {
57
+ "audio_format": audio_format,
58
+ "num_channels": num_channels,
59
+ "sample_rate": sample_rate,
60
+ "byte_rate": byte_rate,
61
+ "block_align": block_align,
62
+ "bits_per_sample": bits_per_sample,
63
+ }
64
+ elif chunk_id == "data":
65
+ end = min(chunk_data_offset + chunk_size, len(buffer))
66
+ data_bytes = buffer[chunk_data_offset:end]
67
+
68
+ # Advance to next chunk (aligned to 2 bytes)
69
+ offset = chunk_data_offset + chunk_size + (1 if chunk_size % 2 == 1 else 0)
70
+
71
+ if format_info is None:
72
+ raise ValueError('Invalid WAV: missing "fmt " chunk')
73
+ if data_bytes is None:
74
+ raise ValueError('Invalid WAV: missing "data" chunk')
75
+
76
+ return ParsedWav(**format_info, data_bytes=data_bytes)
77
+
78
+
79
+ def concat_wavs(wav_buffers: list[bytes], gaps_ms: list[int] | None = None) -> bytes:
80
+ """Concatenate WAV buffers in order; gaps_ms[i] is silence inserted before segment i (D8.1)."""
81
+ if not wav_buffers:
82
+ return b""
83
+
84
+ gaps = gaps_ms or []
85
+ parsed_wavs = [parse_wav(buf) for buf in wav_buffers]
86
+ first = parsed_wavs[0]
87
+
88
+ # Verify format compatibility
89
+ for i in range(1, len(parsed_wavs)):
90
+ current = parsed_wavs[i]
91
+ if current.sample_rate != first.sample_rate:
92
+ raise ValueError(
93
+ f"WAV sample rate mismatch: expected {first.sample_rate} Hz, got {current.sample_rate} Hz"
94
+ )
95
+ if (
96
+ current.audio_format != first.audio_format
97
+ or current.num_channels != first.num_channels
98
+ or current.bits_per_sample != first.bits_per_sample
99
+ ):
100
+ raise ValueError(
101
+ f"WAV format mismatch between segment 0 and segment {i}: format {first.audio_format} vs "
102
+ f"{current.audio_format}, channels {first.num_channels} vs {current.num_channels}, bits "
103
+ f"{first.bits_per_sample} vs {current.bits_per_sample}"
104
+ )
105
+
106
+ # Compute total combined data length
107
+ total_data_length = 0
108
+ pieces: list[bytes] = []
109
+
110
+ for i, wav in enumerate(parsed_wavs):
111
+ if i > 0:
112
+ gap = gaps[i] if i < len(gaps) else 0
113
+ if gap > 0:
114
+ num_gap_bytes = int(first.sample_rate * (gap / 1000)) * first.block_align
115
+ if num_gap_bytes > 0:
116
+ pieces.append(bytes(num_gap_bytes))
117
+ total_data_length += num_gap_bytes
118
+ pieces.append(wav.data_bytes)
119
+ total_data_length += len(wav.data_bytes)
120
+
121
+ # Build the consolidated WAV buffer
122
+ header = struct.pack(
123
+ "<4sI4s4sIHHIIHH4sI",
124
+ b"RIFF",
125
+ 36 + total_data_length,
126
+ b"WAVE",
127
+ b"fmt ",
128
+ 16,
129
+ first.audio_format,
130
+ first.num_channels,
131
+ first.sample_rate,
132
+ first.byte_rate,
133
+ first.block_align,
134
+ first.bits_per_sample,
135
+ b"data",
136
+ total_data_length,
137
+ )
138
+ return header + b"".join(pieces)
139
+
140
+
141
+ def detect_loudness_dip(wav: bytes) -> tuple[bool, float | None]:
142
+ """Detect a 'collapsed-volume' window: center RMS dips vs both neighbors.
143
+
144
+ Returns (dip, dip_at_sec). Any parse failure -> (False, None): a dip check must
145
+ never crash the run (voice-gen parity).
146
+ """
147
+ try:
148
+ parsed = parse_wav(wav)
149
+ if parsed.bits_per_sample != 16 or parsed.num_channels < 1 or len(parsed.data_bytes) < 2:
150
+ return False, None
151
+
152
+ data = parsed.data_bytes
153
+ total_frames = len(data) // parsed.block_align
154
+ window_frames = parsed.sample_rate # 1s windows
155
+ if total_frames < window_frames * 5:
156
+ return False, None
157
+ n_wins = total_frames // window_frames
158
+ if n_wins < 7:
159
+ return False, None
160
+
161
+ bytes_per_frame = parsed.block_align
162
+ bytes_per_window = window_frames * bytes_per_frame
163
+
164
+ def window_rms(win_idx: int) -> float:
165
+ start = win_idx * bytes_per_window
166
+ sum_sq = 0.0
167
+ count = 0
168
+ for off in range(start, start + bytes_per_window, bytes_per_frame):
169
+ (sample,) = struct.unpack_from("<h", data, off)
170
+ value = sample / 32768.0
171
+ sum_sq += value * value
172
+ count += 1
173
+ return (sum_sq / count) ** 0.5 if count else 0.0
174
+
175
+ rms = [window_rms(i) for i in range(n_wins)]
176
+ for j in range(1, n_wins - 1):
177
+ prev_rms = rms[j - 1]
178
+ center = rms[j]
179
+ next_rms = rms[j + 1]
180
+ nbr = max(prev_rms, next_rms)
181
+ if (
182
+ center > DIP_SILENCE_FLOOR
183
+ and center < DIP_ABS_CEILING
184
+ and nbr > DIP_NEIGHBOR_FLOOR
185
+ and center < nbr * DIP_RATIO
186
+ ):
187
+ return True, round(j + 0.5, 2)
188
+ return False, None
189
+ except Exception: # noqa: BLE001 — a dip check must never crash the run (voice-gen parity)
190
+ return False, None
@@ -0,0 +1,150 @@
1
+ """Backend seam (D6): one in-process OmniVoice load renders every segment.
2
+
3
+ This is the only torch/omnivoice-importing module, behind the Backend Protocol so every
4
+ other module and all tests run without a GPU/model. torch/omnivoice imports stay lazy inside
5
+ methods; FakeBackend replays canned data and never touches the model stack. WAV codec is
6
+ stdlib `wave` only (float32 [-1,1] -> int16 via np.clip; 24kHz mono both directions).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import io
12
+ import os
13
+ import wave
14
+ from typing import Any, Protocol, TypedDict
15
+
16
+ import numpy as np
17
+
18
+ from .profiles import ProfileEntry
19
+
20
+ DEFAULT_MODEL = "k2-fsa/OmniVoice"
21
+ SAMPLE_RATE = 24000 # OmniVoice output rate; ASR transcribe is fixed at 24000 (task R4)
22
+
23
+
24
+ class SegmentSpec(TypedDict):
25
+ text: str
26
+ language: str
27
+ profile: ProfileEntry
28
+ instruct: str | None
29
+ seed: int | None
30
+ speed: float | None
31
+
32
+
33
+ class Backend(Protocol):
34
+ # Concrete `raise` bodies (not `...`): coverage.py excludes ellipsis lines from
35
+ # measurement, which would leave these permanently "uncovered" for the D2 gate.
36
+ def load(self) -> None:
37
+ raise NotImplementedError
38
+
39
+ def generate(self, spec: SegmentSpec) -> tuple[bytes, float]: # wav bytes, duration sec
40
+ raise NotImplementedError
41
+
42
+ def transcribe(self, wav: bytes, language: str) -> str: # lazy whisper; may raise
43
+ raise NotImplementedError
44
+
45
+
46
+ def _float_to_wav(audio: np.ndarray, sample_rate: int) -> bytes:
47
+ pcm = (np.clip(audio, -1.0, 1.0) * 32767).astype(np.int16)
48
+ buf = io.BytesIO()
49
+ with wave.open(buf, "wb") as wf:
50
+ wf.setnchannels(1)
51
+ wf.setsampwidth(2)
52
+ wf.setframerate(sample_rate)
53
+ wf.writeframes(pcm.tobytes())
54
+ return buf.getvalue()
55
+
56
+
57
+ def _wav_to_float(wav: bytes) -> np.ndarray:
58
+ with wave.open(io.BytesIO(wav), "rb") as wf:
59
+ frames = wf.readframes(wf.getnframes())
60
+ return np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0
61
+
62
+
63
+ class FakeBackend:
64
+ """Canned test backend: replays wavs/durations/transcriptions by call order."""
65
+
66
+ def __init__(self, wavs: list[bytes], durations: list[float], transcriptions: dict[int, str]) -> None:
67
+ self._wavs = wavs
68
+ self._durations = durations
69
+ self._transcriptions = transcriptions
70
+ self.calls: list[SegmentSpec] = []
71
+ self.loaded = False
72
+ self._transcribe_count = 0
73
+
74
+ def load(self) -> None:
75
+ self.loaded = True
76
+
77
+ def generate(self, spec: SegmentSpec) -> tuple[bytes, float]:
78
+ self.calls.append(spec)
79
+ idx = len(self.calls) - 1
80
+ return self._wavs[idx], self._durations[idx]
81
+
82
+ def transcribe(self, wav: bytes, language: str) -> str:
83
+ text = self._transcriptions.get(self._transcribe_count, "")
84
+ self._transcribe_count += 1
85
+ return text
86
+
87
+
88
+ def _default_device() -> str:
89
+ import torch
90
+
91
+ if torch.cuda.is_available():
92
+ return "cuda"
93
+ if torch.backends.mps.is_available():
94
+ return "mps"
95
+ return "cpu"
96
+
97
+
98
+ class OmnivoiceBackend:
99
+ """Real backend: single `OmniVoice.from_pretrained` per instance; ASR lazy (D6/D7)."""
100
+
101
+ def __init__(self, model: str | None = None, device: str | None = None, speed: float | None = None) -> None:
102
+ self._model_id = model or os.environ.get("OMNIVOICE_MODEL", DEFAULT_MODEL)
103
+ self._device = device or os.environ.get("OMNIVOICE_DEVICE")
104
+ self._speed = speed if speed is not None else float(os.environ.get("OMNIVOICE_SPEED", "1.0"))
105
+ self._asr_model = os.environ.get("OMNIVOICE_ASR_MODEL") # None -> omnivoice default whisper
106
+ self._model: Any = None
107
+ self._asr_loaded = False
108
+
109
+ def load(self) -> None:
110
+ if self._model is not None:
111
+ return
112
+ from omnivoice import OmniVoice
113
+
114
+ self._model = OmniVoice.from_pretrained(self._model_id, device_map=self._device or _default_device())
115
+
116
+ def generate(self, spec: SegmentSpec) -> tuple[bytes, float]:
117
+ if self._model is None:
118
+ raise RuntimeError("OmnivoiceBackend.load() must be called before generate()")
119
+ profile = spec["profile"]
120
+ kwargs: dict[str, Any] = {
121
+ "text": spec["text"],
122
+ "language": spec["language"], # straight through — OmniVoice validates (task R5)
123
+ "speed": spec["speed"] if spec["speed"] is not None else self._speed,
124
+ "normalize_text": False,
125
+ }
126
+ if profile.kind == "clone":
127
+ kwargs["ref_audio"] = profile.ref_audio
128
+ kwargs["ref_text"] = profile.ref_text
129
+ elif profile.kind == "prompt":
130
+ from omnivoice import VoiceClonePrompt
131
+
132
+ assert profile.prompt is not None # guaranteed by registry validation
133
+ kwargs["voice_clone_prompt"] = VoiceClonePrompt.load(profile.prompt)
134
+ else: # instruct — spec.instruct wins over the profile instruct
135
+ kwargs["instruct"] = spec["instruct"] if spec["instruct"] is not None else profile.instruct
136
+ if spec["seed"] is not None:
137
+ import torch
138
+
139
+ torch.manual_seed(spec["seed"])
140
+ audios = self._model.generate(**kwargs)
141
+ audio = audios[0]
142
+ return _float_to_wav(audio, SAMPLE_RATE), len(audio) / SAMPLE_RATE
143
+
144
+ def transcribe(self, wav: bytes, language: str) -> str:
145
+ if self._model is None:
146
+ raise RuntimeError("OmnivoiceBackend.load() must be called before transcribe()")
147
+ if not self._asr_loaded:
148
+ self._model.load_asr_model(model_name=self._asr_model)
149
+ self._asr_loaded = True
150
+ return str(self._model.transcribe((_wav_to_float(wav), SAMPLE_RATE)))
@@ -0,0 +1,76 @@
1
+ """Pydantic contract mirroring @gobing-ai/kk-core Doc/Content (packages/kk-core/src/schemas.ts).
2
+
3
+ Doc requires only `id` + `body`; Content requires `body` with `format` defaulting to
4
+ "markdown". Unknown fields are ignored (zod-strip parity). Every violation surfaces as
5
+ ValueError so __main__ maps it onto the single fail-loud exit path.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from typing import Any
12
+
13
+ from pydantic import BaseModel, ValidationError
14
+
15
+
16
+ class Reference(BaseModel):
17
+ """Optional citation entry inside Content.references."""
18
+
19
+ url: str | None = None
20
+ title: str | None = None
21
+ cite: str | None = None
22
+
23
+
24
+ class Doc(BaseModel):
25
+ """One ingested document; `body` is the primary text/markdown payload."""
26
+
27
+ id: str
28
+ body: str
29
+ title: str | None = None
30
+ sourceUri: str | None = None
31
+ mediaType: str | None = None
32
+ metadata: dict[str, Any] | None = None
33
+
34
+
35
+ class Content(BaseModel):
36
+ """Generator output validated by the kk executor's ContentSchema."""
37
+
38
+ body: str
39
+ title: str | None = None
40
+ format: str = "markdown"
41
+ outline: str | None = None
42
+ references: list[Reference] | None = None
43
+ metadata: dict[str, Any] | None = None
44
+
45
+
46
+ NOTICE_CONTENT = Content(
47
+ title="Notice",
48
+ body="# Notice\nNo documents provided for generation.",
49
+ format="markdown",
50
+ references=[],
51
+ )
52
+
53
+
54
+ def parse_doc_list(raw: str) -> list[Doc]:
55
+ """Parse a Doc[] JSON string; raise ValueError carrying the reason on any violation."""
56
+ try:
57
+ value = json.loads(raw)
58
+ except json.JSONDecodeError as exc:
59
+ raise ValueError(f"input is not valid JSON: {exc.msg} at line {exc.lineno} column {exc.colno}") from exc
60
+ if not isinstance(value, list):
61
+ # ValueError, not TypeError: every parse failure must funnel into the single
62
+ # `Invalid DocList input:` exit path (task R4 contract).
63
+ raise ValueError( # noqa: TRY004
64
+ f"input must be a JSON array of Doc objects, got {type(value).__name__}"
65
+ )
66
+ try:
67
+ return [Doc.model_validate(item) for item in value]
68
+ except ValidationError as exc:
69
+ first = exc.errors()[0]
70
+ loc = ".".join(str(part) for part in first.get("loc", ())) or "root"
71
+ raise ValueError(f"Doc validation failed at [{loc}]: {first['msg']}") from exc
72
+
73
+
74
+ def content_to_json(content: Content) -> str:
75
+ """2-space-indent JSON with nulls dropped — voice-gen JSON.stringify output parity."""
76
+ return json.dumps(content.model_dump(exclude_none=True), ensure_ascii=False, indent=2)
@@ -0,0 +1,60 @@
1
+ """VOICE_GEN_MP3 truthiness + ffmpeg transcode — Python port of voice-gen mp3.ts.
2
+
3
+ Same flags (`-y -hide_banner -loglevel error -i <wav> -codec:a libmp3lame -qscale:a 2`),
4
+ same fail-loud contract: missing input, missing ffmpeg, non-zero exit, or no output file
5
+ all raise with the voice-gen message text.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import subprocess
12
+ from collections.abc import Mapping
13
+
14
+ MP3_TRUTHY = frozenset({"1", "true", "yes", "on"})
15
+
16
+
17
+ def is_mp3_requested(env: Mapping[str, str] | None = None) -> bool:
18
+ """True when the operator asked for a sibling MP3 (VOICE_GEN_MP3=true|1|yes|on)."""
19
+ raw = (os.environ if env is None else env).get("VOICE_GEN_MP3")
20
+ if raw is None or raw == "":
21
+ return False
22
+ return raw.strip().lower() in MP3_TRUTHY
23
+
24
+
25
+ def transcode_wav_to_mp3(wav_path: str, mp3_path: str, ffmpeg_bin: str = "ffmpeg") -> None:
26
+ """WAV → MP3 via ffmpeg + libmp3lame (VBR -qscale:a 2). Fail-loud on any error."""
27
+ if not os.path.exists(wav_path):
28
+ raise RuntimeError(f"ffmpeg mp3 transcode failed: WAV input not found: {wav_path}")
29
+
30
+ try:
31
+ proc = subprocess.run(
32
+ [
33
+ ffmpeg_bin,
34
+ "-y",
35
+ "-hide_banner",
36
+ "-loglevel",
37
+ "error",
38
+ "-i",
39
+ wav_path,
40
+ "-codec:a",
41
+ "libmp3lame",
42
+ "-qscale:a",
43
+ "2",
44
+ mp3_path,
45
+ ],
46
+ capture_output=True,
47
+ text=True,
48
+ check=False,
49
+ )
50
+ except FileNotFoundError as err:
51
+ raise RuntimeError(
52
+ f"ffmpeg is required for MP3 output (VOICE_GEN_MP3=true) but was not found on PATH ({err})"
53
+ ) from err
54
+
55
+ if proc.returncode != 0:
56
+ detail = proc.stderr.strip() or proc.stdout.strip()
57
+ raise RuntimeError(f"ffmpeg mp3 transcode failed (exit {proc.returncode}): {detail or 'no stderr'}")
58
+
59
+ if not os.path.exists(mp3_path):
60
+ raise RuntimeError(f"ffmpeg mp3 transcode produced no file: {mp3_path}")