@gobing-ai/knowledge-kit 0.0.11 → 0.0.12
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/dist/index.js +363 -138
- package/package.json +1 -1
- package/plugins/generations/content-gen/src/storm.ts +99 -50
- package/plugins/generations/core-facts-gen/package.json +17 -0
- package/plugins/generations/core-facts-gen/plugin.json +7 -0
- package/plugins/generations/core-facts-gen/src/index.ts +116 -0
- package/plugins/generations/core-facts-gen/tsconfig.json +4 -0
- package/plugins/generations/daily-article-gen/package.json +17 -0
- package/plugins/generations/daily-article-gen/plugin.json +7 -0
- package/plugins/generations/daily-article-gen/src/index.ts +91 -0
- package/plugins/generations/daily-article-gen/tsconfig.json +4 -0
- package/plugins/generations/dailynews-gen/src/index.ts +11 -0
- package/plugins/generations/dailynews-gen/src/script-builder.ts +1 -1
- package/plugins/generations/episode-plan-gen/package.json +17 -0
- package/plugins/generations/episode-plan-gen/plugin.json +7 -0
- package/plugins/generations/episode-plan-gen/src/index.ts +726 -0
- package/plugins/generations/episode-plan-gen/tsconfig.json +4 -0
- package/plugins/generations/voice-gen/src/index.ts +102 -11
- package/plugins/generations/voice-gen/src/qc.ts +154 -9
- package/plugins/ingestions/aihot-ingest/plugin.json +1 -1
- package/plugins/ingestions/aihot-ingest/src/index.ts +72 -13
- package/plugins/ingestions/aihot-ingest/src/mapper.ts +1 -0
- package/plugins/ingestions/aihot-ingest/src/rss.ts +151 -0
- package/plugins/ingestions/horizon-ingest/package.json +17 -0
- package/plugins/ingestions/horizon-ingest/plugin.json +7 -0
- package/plugins/ingestions/horizon-ingest/src/index.ts +205 -0
- package/plugins/ingestions/horizon-ingest/tsconfig.json +4 -0
- package/plugins/ingestions/last30days-ingest/package.json +17 -0
- package/plugins/ingestions/last30days-ingest/plugin.json +7 -0
- package/plugins/ingestions/last30days-ingest/src/index.ts +148 -0
- package/plugins/ingestions/last30days-ingest/tsconfig.json +4 -0
- package/plugins/kk/skills/taste-unslop/SKILL.md +12 -6
- package/plugins/kk/skills/taste-unslop/references/pattern-guide.md +128 -48
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +432 -19
- package/plugins/publishings/podcast-pub/package.json +17 -0
- package/plugins/publishings/podcast-pub/plugin.json +7 -0
- package/plugins/publishings/podcast-pub/src/index.ts +538 -0
- package/plugins/publishings/podcast-pub/src/map.ts +165 -0
- package/plugins/publishings/podcast-pub/src/microfeed-client.ts +196 -0
- package/plugins/publishings/podcast-pub/src/show-notes.ts +132 -0
- package/plugins/publishings/podcast-pub/tsconfig.json +4 -0
- package/plugins/publishings/surfdash-pub/src/index.ts +328 -62
|
@@ -5,7 +5,7 @@ import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
|
|
|
5
5
|
import { echoError } from '@gobing-ai/ts-utils';
|
|
6
6
|
import { concatWavs } from './concat';
|
|
7
7
|
import { isMp3Requested, type Mp3Transcoder, transcodeWavToMp3 } from './mp3';
|
|
8
|
-
import { auditVoiceSegments } from './qc';
|
|
8
|
+
import { auditVoiceSegments, detectLoudnessDip, TRANSCRIPTION_FIDELITY_FLOOR, transcriptionFidelity } from './qc';
|
|
9
9
|
import { createVoiceboxClient, type VoiceboxClient, type VoiceboxGenerateBody } from './voicebox-client';
|
|
10
10
|
import {
|
|
11
11
|
mergeVoiceScripts,
|
|
@@ -92,6 +92,9 @@ export async function processGeneratorIO(
|
|
|
92
92
|
speaker?: string;
|
|
93
93
|
emotion?: string;
|
|
94
94
|
duration: number;
|
|
95
|
+
verifyRetries?: number;
|
|
96
|
+
transcription?: string;
|
|
97
|
+
loudnessDip?: boolean;
|
|
95
98
|
}> = [];
|
|
96
99
|
let totalDuration = 0;
|
|
97
100
|
|
|
@@ -115,7 +118,8 @@ export async function processGeneratorIO(
|
|
|
115
118
|
|
|
116
119
|
const resolvedProfile = await client.resolveProfile(profileTarget);
|
|
117
120
|
|
|
118
|
-
const engine =
|
|
121
|
+
const engine =
|
|
122
|
+
segment.engine ?? speakerConfig?.engine ?? combinedScript.default_engine ?? process.env.VOICEBOX_ENGINE;
|
|
119
123
|
const language = segment.language ?? speakerConfig?.language ?? combinedScript.language ?? 'en';
|
|
120
124
|
const rawInstruct = segment.instruct ?? speakerConfig?.instruct;
|
|
121
125
|
const rawEmotion = segment.emotion ?? speakerConfig?.emotion ?? combinedScript.default_emotion;
|
|
@@ -170,28 +174,115 @@ export async function processGeneratorIO(
|
|
|
170
174
|
effects_chain,
|
|
171
175
|
};
|
|
172
176
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
177
|
+
// Timeout retry: a hung/timeout voicebox generation must not kill a multi-hour
|
|
178
|
+
// run (dogfood daily-ai-news-20260903: segment 108/132 timed out at 600s and
|
|
179
|
+
// failed the whole generate state after 140 minutes).
|
|
180
|
+
//
|
|
181
|
+
// Verify retry: qwen with cloned profiles occasionally emits contiguous loop
|
|
182
|
+
// garbage (fidelity 0.00 vs >=0.23 zh ASR floor on real audio — dogfood
|
|
183
|
+
// 2026-09-03). The server's runaway detector only catches gap-shaped failures,
|
|
184
|
+
// so verify per segment via transcription fidelity and regenerate failures with
|
|
185
|
+
// a varied seed and halved chunk size (mirrors the server's own retry policy).
|
|
186
|
+
let wavBytes: Uint8Array | undefined;
|
|
187
|
+
let segDuration = 0;
|
|
188
|
+
let generationId = '';
|
|
189
|
+
let transcription: string | undefined;
|
|
190
|
+
let verifyRetries = 0;
|
|
191
|
+
let loudnessDip = false;
|
|
192
|
+
let lastError: unknown;
|
|
193
|
+
let attemptBody = generateBody;
|
|
194
|
+
for (let verifyAttempt = 0; verifyAttempt <= 2 && wavBytes === undefined; verifyAttempt++) {
|
|
195
|
+
if (verifyAttempt > 0) {
|
|
196
|
+
verifyRetries = verifyAttempt;
|
|
197
|
+
const chunkChars = Math.max(VOICEBOX_CHUNK_CHARS_MIN, Math.floor(attemptBody.max_chunk_chars / 2));
|
|
198
|
+
attemptBody = {
|
|
199
|
+
...generateBody,
|
|
200
|
+
seed: Math.floor(Math.random() * 2 ** 31),
|
|
201
|
+
max_chunk_chars: chunkChars,
|
|
202
|
+
};
|
|
203
|
+
echoError(
|
|
204
|
+
`voice-gen: regenerating segment with seed ${attemptBody.seed} and max_chunk_chars ${chunkChars}`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
let attemptBytes: Uint8Array | undefined;
|
|
208
|
+
for (let attempt = 1; attempt <= 2 && attemptBytes === undefined; attempt++) {
|
|
209
|
+
try {
|
|
210
|
+
const { id } = await client.generate(attemptBody);
|
|
211
|
+
generationId = id;
|
|
212
|
+
// Long qwen segments synthesize slower than the fixed 600s deadline
|
|
213
|
+
// (live 2026-09-04: 171s-audio segment took >600s twice, both completed
|
|
214
|
+
// server-side). Scale the poll deadline with text length: 600s base
|
|
215
|
+
// (or VOICEBOX_TIMEOUT_MS) + 60s per 100 chars, ~2x observed worst case.
|
|
216
|
+
const envTimeout = process.env.VOICEBOX_TIMEOUT_MS
|
|
217
|
+
? parseInt(process.env.VOICEBOX_TIMEOUT_MS, 10)
|
|
218
|
+
: 600000;
|
|
219
|
+
const waitTimeout = envTimeout + Math.ceil(segment.text.length / 100) * 60_000;
|
|
220
|
+
const history = await client.waitUntilDone(id, waitTimeout);
|
|
221
|
+
if (history.status === 'failed') {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`Voicebox generation failed for profile "${resolvedProfile.name}": ${history.error || 'status failed'}`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
attemptBytes = await client.downloadAudio(id);
|
|
227
|
+
segDuration = history.duration ?? 0;
|
|
228
|
+
} catch (err: unknown) {
|
|
229
|
+
lastError = err;
|
|
230
|
+
if (attempt === 1) {
|
|
231
|
+
echoError(
|
|
232
|
+
`voice-gen: segment failed (${err instanceof Error ? err.message : String(err)}); retrying once`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (attemptBytes === undefined) {
|
|
238
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
239
|
+
}
|
|
240
|
+
if (typeof client.transcribe === 'function') {
|
|
241
|
+
try {
|
|
242
|
+
const trans = await client.transcribe(attemptBytes, language);
|
|
243
|
+
const fidelity = transcriptionFidelity(segment.text, trans.text);
|
|
244
|
+
if (fidelity < TRANSCRIPTION_FIDELITY_FLOOR && verifyAttempt < 2) {
|
|
245
|
+
echoError(
|
|
246
|
+
`voice-gen: transcription fidelity ${fidelity.toFixed(2)} below ${TRANSCRIPTION_FIDELITY_FLOOR} (looped/garbage audio): "${trans.text.slice(0, 80)}..."`,
|
|
247
|
+
);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
transcription = trans.text;
|
|
251
|
+
} catch {
|
|
252
|
+
// Transcription unavailable — keep audio; the QC audit backstop decides.
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// Voice dent gate: Qwen3-TTS stochastically collapses loudness mid-utterance on
|
|
256
|
+
// long text; a dip that survives to the last verify attempt is recorded for QC
|
|
257
|
+
// (dogfood 2026-09-08 A/B: same text regenerated clean, so retry usually clears).
|
|
258
|
+
const dip = detectLoudnessDip(attemptBytes);
|
|
259
|
+
loudnessDip = dip.dip;
|
|
260
|
+
if (dip.dip && verifyAttempt < 2) {
|
|
261
|
+
echoError(`voice-gen: loudness dip detected at ${dip.dipAtSec ?? '?'}s — regenerating segment`);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
wavBytes = attemptBytes;
|
|
265
|
+
}
|
|
266
|
+
if (wavBytes === undefined) {
|
|
267
|
+
// Both network attempts and all verify attempts exhausted without audio.
|
|
268
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError ?? 'no audio'));
|
|
179
269
|
}
|
|
180
270
|
|
|
181
|
-
const wavBytes = await client.downloadAudio(id);
|
|
182
271
|
const gap = segment.gap_ms ?? 0;
|
|
183
272
|
|
|
184
273
|
segmentWavs.push(wavBytes);
|
|
185
274
|
segmentGaps.push(gap);
|
|
186
275
|
|
|
187
|
-
const segDuration = history.duration ?? 0;
|
|
188
276
|
totalDuration += segDuration + gap / 1000;
|
|
189
277
|
segmentMetadata.push({
|
|
190
|
-
generationId
|
|
278
|
+
generationId,
|
|
191
279
|
profile: resolvedProfile.name,
|
|
192
280
|
speaker: segment.speaker,
|
|
193
281
|
emotion: rawEmotion,
|
|
194
282
|
duration: segDuration,
|
|
283
|
+
verifyRetries,
|
|
284
|
+
transcription,
|
|
285
|
+
loudnessDip,
|
|
195
286
|
});
|
|
196
287
|
}
|
|
197
288
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ParsedWav, parseWav } from './concat';
|
|
1
2
|
import type { VoiceboxClient } from './voicebox-client';
|
|
2
3
|
import type { VoiceScript, VoiceSegment } from './voicescript';
|
|
3
4
|
|
|
@@ -10,6 +11,8 @@ export interface SegmentQualityAudit {
|
|
|
10
11
|
transcription?: string;
|
|
11
12
|
repetitionDetected: boolean;
|
|
12
13
|
durationAnomaly: boolean;
|
|
14
|
+
/** True when a loudness dip (voice dent) was detected in the final audio and survived regeneration. */
|
|
15
|
+
loudnessDip?: boolean;
|
|
13
16
|
issues: string[];
|
|
14
17
|
passed: boolean;
|
|
15
18
|
}
|
|
@@ -50,6 +53,109 @@ export function detectRepetitions(text: string): boolean {
|
|
|
50
53
|
return false;
|
|
51
54
|
}
|
|
52
55
|
|
|
56
|
+
/** Below this character-bigram coverage, audio is treated as looped/garbage (dogfood 2026-09-03). */
|
|
57
|
+
export const TRANSCRIPTION_FIDELITY_FLOOR = 0.15;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Character-bigram coverage of the source text by the transcription. Whisper zh ASR is
|
|
61
|
+
* lossy on good audio (0.23–0.8 across dogfood runs); looped/garbage TTS audio scores ≤0.05.
|
|
62
|
+
*/
|
|
63
|
+
export function transcriptionFidelity(source: string, transcription: string): number {
|
|
64
|
+
const bigrams = (t: string): Set<string> => {
|
|
65
|
+
const clean = t.replace(/\s+/g, '');
|
|
66
|
+
return new Set(Array.from({ length: Math.max(0, clean.length - 1) }, (_, i) => clean.slice(i, i + 2)));
|
|
67
|
+
};
|
|
68
|
+
const a = bigrams(source);
|
|
69
|
+
const b = bigrams(transcription);
|
|
70
|
+
if (a.size === 0) return 1;
|
|
71
|
+
let inter = 0;
|
|
72
|
+
for (const g of a) if (b.has(g)) inter++;
|
|
73
|
+
return inter / a.size;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A ~1s window whose RMS sits below this fraction of its louder neighbour is a dent.
|
|
78
|
+
* Calibrated on dogfood 2026-09-08: audible Qwen3-TTS collapses measured 0.24–0.37 of
|
|
79
|
+
* the louder neighbour (rms 0.033–0.048); normal quieter phrases sit ≥0.45.
|
|
80
|
+
*/
|
|
81
|
+
export const DIP_RATIO = 0.4;
|
|
82
|
+
|
|
83
|
+
/** A window below this RMS is a real pause (silence), not a dent — dents are quiet-but-not-silent. */
|
|
84
|
+
export const DIP_SILENCE_FLOOR = 0.008;
|
|
85
|
+
|
|
86
|
+
/** Windows above this absolute RMS are normal quieter narration, not collapses (file p25 ≈ 0.08). */
|
|
87
|
+
export const DIP_ABS_CEILING = 0.06;
|
|
88
|
+
|
|
89
|
+
/** Neighbour windows below this RMS carry no speech signal to compare against. */
|
|
90
|
+
export const DIP_NEIGHBOR_FLOOR = 0.02;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Detect a loudness dip (voice dent): a ~1s window of quiet-but-not-silent audio
|
|
94
|
+
* sandwiched between louder speech on both sides. Qwen3-TTS (AR clone, MLX) stochastically
|
|
95
|
+
* collapses loudness mid-utterance on long text (dogfood 2026-09-08 A/B: same text regenerated
|
|
96
|
+
* clean; official docs list "loudness drops" among AR long-form instabilities). The detector
|
|
97
|
+
* returns the first dip position; voice-gen regenerates on it (stochastic → retry usually clears).
|
|
98
|
+
* Returns null when the wav is too short or unparseable.
|
|
99
|
+
*/
|
|
100
|
+
export function detectLoudnessDip(wav: Uint8Array): { dip: boolean; dipAtSec: number | null } {
|
|
101
|
+
let parsed: ParsedWav;
|
|
102
|
+
try {
|
|
103
|
+
parsed = parseWav(wav);
|
|
104
|
+
} catch {
|
|
105
|
+
return { dip: false, dipAtSec: null };
|
|
106
|
+
}
|
|
107
|
+
if (parsed.bitsPerSample !== 16 && parsed.bitsPerSample !== 8) return { dip: false, dipAtSec: null };
|
|
108
|
+
const rate = parsed.sampleRate;
|
|
109
|
+
if (rate <= 0) return { dip: false, dipAtSec: null };
|
|
110
|
+
const data = parsed.dataBytes;
|
|
111
|
+
const bytesPerSample = parsed.bitsPerSample === 8 ? 1 : 2;
|
|
112
|
+
const frameBytes = parsed.numChannels * bytesPerSample;
|
|
113
|
+
const totalFrames = Math.floor(data.length / frameBytes);
|
|
114
|
+
const windowFrames = rate; // 1s windows
|
|
115
|
+
if (totalFrames < windowFrames * 5) return { dip: false, dipAtSec: null };
|
|
116
|
+
|
|
117
|
+
const sampleAt = (frame: number, channel = 0): number => {
|
|
118
|
+
const off = (frame * parsed.numChannels + channel) * bytesPerSample;
|
|
119
|
+
if (bytesPerSample === 1) return ((data[off] ?? 0) - 128) / 128;
|
|
120
|
+
let s = (data[off] ?? 0) | ((data[off + 1] ?? 0) << 8);
|
|
121
|
+
if (s >= 0x8000) s -= 0x10000;
|
|
122
|
+
return s / 32768;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Non-overlapping 1s windows (step = window): a dent spans ~1s → exactly one window,
|
|
126
|
+
// with loud speech before and after. Frame-stepped windows would smear a 1s dent into a
|
|
127
|
+
// 24000-index plateau with equal neighbours, invisible to any adjacent comparison.
|
|
128
|
+
const nWins = Math.floor(totalFrames / windowFrames);
|
|
129
|
+
if (nWins < 7) return { dip: false, dipAtSec: null };
|
|
130
|
+
const windowRms: number[] = [];
|
|
131
|
+
for (let w = 0; w < nWins; w++) {
|
|
132
|
+
const base = w * windowFrames;
|
|
133
|
+
let sum = 0;
|
|
134
|
+
for (let f = base; f < base + windowFrames; f++) {
|
|
135
|
+
const v = sampleAt(f);
|
|
136
|
+
sum += v * v;
|
|
137
|
+
}
|
|
138
|
+
windowRms.push(Math.sqrt(sum / windowFrames));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// A dent = one ~1s window of quiet-but-not-silent audio (rms 0.008..0.06) that is
|
|
142
|
+
// notably quieter than BOTH its immediate 1s neighbours (ratio < 0.4) — an isolated
|
|
143
|
+
// loudness collapse amid otherwise steady speech. Non-overlapping windows keep
|
|
144
|
+
// neighbour semantics exact (a frame-stepped window would smear a 1s dent into a
|
|
145
|
+
// many-index plateau with equal neighbours). Sentence pauses are excluded by the
|
|
146
|
+
// silence floor; normal quieter phrases by the absolute ceiling (calibrated on
|
|
147
|
+
// dogfood 2026-09-08 prod audio: audible dents 0.047/0.042/0.048 vs normal p25 0.080).
|
|
148
|
+
for (let j = 1; j < windowRms.length - 1; j++) {
|
|
149
|
+
const center = windowRms[j] ?? 0;
|
|
150
|
+
if (center <= DIP_SILENCE_FLOOR) continue; // real pause, not a dent
|
|
151
|
+
if (center >= DIP_ABS_CEILING) continue; // normal quieter narration
|
|
152
|
+
const nbr = Math.max(windowRms[j - 1] ?? 0, windowRms[j + 1] ?? 0);
|
|
153
|
+
if (nbr <= DIP_NEIGHBOR_FLOOR) continue;
|
|
154
|
+
if (center < nbr * DIP_RATIO) return { dip: true, dipAtSec: j };
|
|
155
|
+
}
|
|
156
|
+
return { dip: false, dipAtSec: null };
|
|
157
|
+
}
|
|
158
|
+
|
|
53
159
|
/**
|
|
54
160
|
* Compute expected speech duration range given text and language.
|
|
55
161
|
*/
|
|
@@ -76,7 +182,14 @@ export function computeExpectedDurationRange(text: string, language = 'zh'): [nu
|
|
|
76
182
|
export async function auditVoiceSegments(
|
|
77
183
|
script: VoiceScript,
|
|
78
184
|
segmentWavs: Uint8Array[],
|
|
79
|
-
segmentMetadata: Array<{
|
|
185
|
+
segmentMetadata: Array<{
|
|
186
|
+
generationId: string;
|
|
187
|
+
profile: string;
|
|
188
|
+
duration: number;
|
|
189
|
+
transcription?: string;
|
|
190
|
+
verifyRetries?: number;
|
|
191
|
+
loudnessDip?: boolean;
|
|
192
|
+
}>,
|
|
80
193
|
client?: VoiceboxClient,
|
|
81
194
|
): Promise<VoiceQualityReport> {
|
|
82
195
|
const audits: SegmentQualityAudit[] = [];
|
|
@@ -109,22 +222,53 @@ export async function auditVoiceSegments(
|
|
|
109
222
|
let transcriptionText: string | undefined;
|
|
110
223
|
let repetitionDetected = false;
|
|
111
224
|
|
|
112
|
-
if (
|
|
225
|
+
if (meta && typeof meta.transcription === 'string') {
|
|
226
|
+
// Precomputed during generation (verify step) — reuse instead of re-transcribing.
|
|
227
|
+
transcriptionText = meta.transcription;
|
|
228
|
+
} else if (client && wavBytes && typeof client.transcribe === 'function') {
|
|
113
229
|
try {
|
|
114
230
|
const trans = await client.transcribe(wavBytes, lang);
|
|
115
231
|
transcriptionText = trans.text;
|
|
116
|
-
if (detectRepetitions(transcriptionText)) {
|
|
117
|
-
repetitionDetected = true;
|
|
118
|
-
issues.push(
|
|
119
|
-
`Repetition hallucination detected in audio transcription: "${transcriptionText.slice(0, 80)}..."`,
|
|
120
|
-
);
|
|
121
|
-
}
|
|
122
232
|
} catch {
|
|
123
233
|
// Transcription service unavailable or optional
|
|
124
234
|
}
|
|
125
235
|
}
|
|
236
|
+
if (transcriptionText !== undefined && detectRepetitions(transcriptionText)) {
|
|
237
|
+
repetitionDetected = true;
|
|
238
|
+
// Whisper loops over trailing gap/silence and emits phantom repetitions whose
|
|
239
|
+
// audio duration stays normal; a real TTS loop always inflates duration.
|
|
240
|
+
// Fail only when the duration channel corroborates (dogfood 2026-09-03:
|
|
241
|
+
// 15/22 segments flagged with in-range durations = ASR artifacts).
|
|
242
|
+
if (durationAnomaly) {
|
|
243
|
+
issues.push(
|
|
244
|
+
`Repetition hallucination corroborated by duration anomaly: "${transcriptionText.slice(0, 80)}..."`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
let lowFidelity = false;
|
|
250
|
+
if (transcriptionText !== undefined) {
|
|
251
|
+
const fidelity = transcriptionFidelity(segment.text, transcriptionText);
|
|
252
|
+
if (fidelity < TRANSCRIPTION_FIDELITY_FLOOR) {
|
|
253
|
+
lowFidelity = true;
|
|
254
|
+
issues.push(
|
|
255
|
+
`Transcription fidelity ${fidelity.toFixed(2)} below ${TRANSCRIPTION_FIDELITY_FLOOR} — audio diverges from source text: "${transcriptionText.slice(0, 80)}..."`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Loudness dip (voice dent) that survived regeneration — record, don't fail the
|
|
261
|
+
// pipeline: the verify loop already regenerated on it, and a stubborn single dent
|
|
262
|
+
// is better surfaced than a whole-episode failure (dogfood 2026-09-08).
|
|
263
|
+
let loudnessDip = false;
|
|
264
|
+
if (meta?.loudnessDip === true) {
|
|
265
|
+
loudnessDip = true;
|
|
266
|
+
issues.push(
|
|
267
|
+
`Loudness dip persisted after regeneration (voice-gen auto-retried with varied seed; verifyRetries=${meta.verifyRetries ?? 0})`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
126
270
|
|
|
127
|
-
const passed = !durationAnomaly && !repetitionDetected;
|
|
271
|
+
const passed = !durationAnomaly && !(repetitionDetected && durationAnomaly) && !lowFidelity;
|
|
128
272
|
if (!passed) {
|
|
129
273
|
criticalIssues.push(`Segment ${i + 1} (${meta?.profile || 'Unknown'}): ${issues.join('; ')}`);
|
|
130
274
|
}
|
|
@@ -138,6 +282,7 @@ export async function auditVoiceSegments(
|
|
|
138
282
|
transcription: transcriptionText,
|
|
139
283
|
repetitionDetected,
|
|
140
284
|
durationAnomaly,
|
|
285
|
+
loudnessDip,
|
|
141
286
|
issues,
|
|
142
287
|
passed,
|
|
143
288
|
});
|
|
@@ -3,5 +3,5 @@
|
|
|
3
3
|
"kind": "ingestion",
|
|
4
4
|
"entry": "./src/index.ts",
|
|
5
5
|
"version": "1.0.0",
|
|
6
|
-
"description": "Ingests
|
|
6
|
+
"description": "Ingests fresh AI news from an RSS feed (default TechCrunch AI) into Doc[]; legacy aihot.virxact.com API behind AIHOT_SOURCE=api"
|
|
7
7
|
}
|
|
@@ -6,9 +6,11 @@ import { echoError } from '@gobing-ai/ts-utils';
|
|
|
6
6
|
import { z } from 'zod';
|
|
7
7
|
import { type AihotDeps, createAihotClient } from './client';
|
|
8
8
|
import { mapAihotItemsToDocs } from './mapper';
|
|
9
|
+
import { AIHOT_FEED_URL_DEFAULT, fetchRssDocs, type RssFetchLike } from './rss';
|
|
9
10
|
|
|
10
11
|
export * from './client';
|
|
11
12
|
export * from './mapper';
|
|
13
|
+
export * from './rss';
|
|
12
14
|
|
|
13
15
|
export interface IngestionOptions {
|
|
14
16
|
out: string;
|
|
@@ -38,8 +40,26 @@ function parseLimit(value: string): number {
|
|
|
38
40
|
export async function processIngestionIO(
|
|
39
41
|
options: IngestionOptions,
|
|
40
42
|
depsOverride?: Partial<AihotDeps>,
|
|
43
|
+
rssFetchLike: RssFetchLike = fetch,
|
|
41
44
|
): Promise<IngestionResult> {
|
|
42
45
|
const fs = createNodeFileSystem();
|
|
46
|
+
|
|
47
|
+
// RSS is the default source (2026-09-08 dogfood: the aihot.virxact.com API served a
|
|
48
|
+
// 2020–2025 archive — every item failed the 7-day freshness gate; see rss.ts). The API
|
|
49
|
+
// path stays reachable with AIHOT_SOURCE=api for when the CMS carries live curated items.
|
|
50
|
+
const source = process.env.AIHOT_SOURCE?.trim() || 'rss';
|
|
51
|
+
if (source === 'api') {
|
|
52
|
+
return processApiSource(options, fs, depsOverride);
|
|
53
|
+
}
|
|
54
|
+
return processRssSource(options, fs, rssFetchLike);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Legacy aihot.virxact.com API source (cursor/stateFile contract preserved). */
|
|
58
|
+
async function processApiSource(
|
|
59
|
+
options: IngestionOptions,
|
|
60
|
+
fs: ReturnType<typeof createNodeFileSystem>,
|
|
61
|
+
depsOverride?: Partial<AihotDeps>,
|
|
62
|
+
): Promise<IngestionResult> {
|
|
43
63
|
const client = createAihotClient(depsOverride);
|
|
44
64
|
|
|
45
65
|
let effectiveCursor = options.cursor?.trim();
|
|
@@ -68,30 +88,69 @@ export async function processIngestionIO(
|
|
|
68
88
|
}
|
|
69
89
|
|
|
70
90
|
const docs = DocListSchema.parse(mapAihotItemsToDocs(items));
|
|
71
|
-
|
|
72
|
-
const outDir = dirname(options.out);
|
|
73
|
-
if (outDir && outDir !== '.') {
|
|
74
|
-
await fs.ensureDir(outDir);
|
|
75
|
-
}
|
|
76
|
-
await atomicWriteJson(options.out, docs, fs);
|
|
91
|
+
await writeOutput(fs, options, docs);
|
|
77
92
|
|
|
78
93
|
if (options.stateFile) {
|
|
79
|
-
|
|
80
|
-
if (stateDir && stateDir !== '.') {
|
|
81
|
-
await fs.ensureDir(stateDir);
|
|
82
|
-
}
|
|
83
|
-
const statePayload = {
|
|
94
|
+
await writeState(fs, options.stateFile, {
|
|
84
95
|
cursor,
|
|
85
96
|
asOf,
|
|
86
97
|
lastSyncAt: new Date().toISOString(),
|
|
87
98
|
itemCount: docs.length,
|
|
88
|
-
};
|
|
89
|
-
await atomicWriteJson(options.stateFile, statePayload, fs);
|
|
99
|
+
});
|
|
90
100
|
}
|
|
91
101
|
|
|
92
102
|
return { docs, cursor, asOf };
|
|
93
103
|
}
|
|
94
104
|
|
|
105
|
+
/** RSS feed source (default): stateless fetch, no cursor (feed order is newest-first). */
|
|
106
|
+
async function processRssSource(
|
|
107
|
+
options: IngestionOptions,
|
|
108
|
+
fs: ReturnType<typeof createNodeFileSystem>,
|
|
109
|
+
rssFetchLike: RssFetchLike,
|
|
110
|
+
): Promise<IngestionResult> {
|
|
111
|
+
const feedUrl = process.env.AIHOT_FEED_URL?.trim() || AIHOT_FEED_URL_DEFAULT;
|
|
112
|
+
const limit = options.limit ?? 10;
|
|
113
|
+
const { docs, asOf } = await fetchRssDocs(feedUrl, limit, rssFetchLike);
|
|
114
|
+
const validated = DocListSchema.parse(docs);
|
|
115
|
+
await writeOutput(fs, options, validated);
|
|
116
|
+
|
|
117
|
+
if (options.stateFile) {
|
|
118
|
+
await writeState(fs, options.stateFile, {
|
|
119
|
+
source: 'rss',
|
|
120
|
+
feedUrl,
|
|
121
|
+
asOf,
|
|
122
|
+
lastSyncAt: new Date().toISOString(),
|
|
123
|
+
itemCount: validated.length,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { docs: validated, cursor: '', asOf };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function writeOutput(
|
|
131
|
+
fs: ReturnType<typeof createNodeFileSystem>,
|
|
132
|
+
options: IngestionOptions,
|
|
133
|
+
docs: Doc[],
|
|
134
|
+
): Promise<void> {
|
|
135
|
+
const outDir = dirname(options.out);
|
|
136
|
+
if (outDir && outDir !== '.') {
|
|
137
|
+
await fs.ensureDir(outDir);
|
|
138
|
+
}
|
|
139
|
+
await atomicWriteJson(options.out, docs, fs);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function writeState(
|
|
143
|
+
fs: ReturnType<typeof createNodeFileSystem>,
|
|
144
|
+
stateFile: string,
|
|
145
|
+
payload: Record<string, unknown>,
|
|
146
|
+
): Promise<void> {
|
|
147
|
+
const stateDir = dirname(stateFile);
|
|
148
|
+
if (stateDir && stateDir !== '.') {
|
|
149
|
+
await fs.ensureDir(stateDir);
|
|
150
|
+
}
|
|
151
|
+
await atomicWriteJson(stateFile, payload, fs);
|
|
152
|
+
}
|
|
153
|
+
|
|
95
154
|
export async function main(depsOverride?: Partial<AihotDeps>): Promise<number> {
|
|
96
155
|
let values: { in?: string; out?: string; limit?: string; cursor?: string; 'state-file'?: string };
|
|
97
156
|
try {
|
|
@@ -16,6 +16,7 @@ export function mapAihotItemToDoc(item: AihotItem): Doc {
|
|
|
16
16
|
const body = `${item.summary?.trim() || item.title.trim()}\n`;
|
|
17
17
|
|
|
18
18
|
const metadata: Record<string, unknown> = {
|
|
19
|
+
source: 'aihot',
|
|
19
20
|
sourceName: item.source.name,
|
|
20
21
|
publishedAt: item.publishedAt,
|
|
21
22
|
discoveredAt: item.discoveredAt,
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type { Doc } from '@gobing-ai/kk-core';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* RSS/Atom feed fetch + item → Doc mapping for the aihot-ingest RSS source.
|
|
6
|
+
*
|
|
7
|
+
* Why RSS (2026-09-08, dogfood): aihot.virxact.com's API served a 2020–2025 blog
|
|
8
|
+
* archive — all 10 items were dropped every run by the workflow's 7-day freshness
|
|
9
|
+
* gate, so the "curated" aihot source contributed zero usable items while carrying
|
|
10
|
+
* the highest selection tier (SOURCE_BASE aihot=30, importance 4). The RSS source
|
|
11
|
+
* restores aihot as a genuinely fresh feed; the API path stays selectable via
|
|
12
|
+
* AIHOT_SOURCE=api for when the CMS has live curated content.
|
|
13
|
+
*
|
|
14
|
+
* Keeps metadata.source='aihot' so planEpisode's aihot tier (and importance base 4)
|
|
15
|
+
* still applies to feed items.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Default fresh AI-news feed (RSS 2.0, high volume, reachable without auth). */
|
|
19
|
+
export const AIHOT_FEED_URL_DEFAULT = 'https://techcrunch.com/category/artificial-intelligence/feed/';
|
|
20
|
+
|
|
21
|
+
export type RssFetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
22
|
+
|
|
23
|
+
export interface RssEntry {
|
|
24
|
+
title: string;
|
|
25
|
+
link: string;
|
|
26
|
+
publishedAt: string | null; // ISO-8601 or null
|
|
27
|
+
summary: string;
|
|
28
|
+
category: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Strip XML/HTML tags and normalize common entities to plain text. */
|
|
32
|
+
function stripMarkup(raw: string): string {
|
|
33
|
+
return raw
|
|
34
|
+
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
|
|
35
|
+
.replace(/<[^>]+>/g, ' ')
|
|
36
|
+
.replace(/ /g, ' ')
|
|
37
|
+
.replace(/&/g, '&')
|
|
38
|
+
.replace(/</g, '<')
|
|
39
|
+
.replace(/>/g, '>')
|
|
40
|
+
.replace(/"/g, '"')
|
|
41
|
+
.replace(/'|'/g, "'")
|
|
42
|
+
.replace(/\s+/g, ' ')
|
|
43
|
+
.trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** RSS <pubDate> (RFC 822) or Atom <published>/<updated> (ISO-8601) → ISO-8601. */
|
|
47
|
+
function toIsoDate(raw: string | undefined): string | null {
|
|
48
|
+
if (!raw?.trim()) return null;
|
|
49
|
+
const t = Date.parse(raw.trim().replace('UT', 'GMT'));
|
|
50
|
+
return Number.isFinite(t) ? new Date(t).toISOString() : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Extract a single sub-element's text from an element body. */
|
|
54
|
+
function subElement(body: string, tag: string): string | undefined {
|
|
55
|
+
const re = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, 'i');
|
|
56
|
+
const m = body.match(re);
|
|
57
|
+
return m?.[1];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Extract a link: RSS <link>text</link> or Atom <link href="..."/> (first match wins). */
|
|
61
|
+
function subLink(body: string): string | undefined {
|
|
62
|
+
const text = subElement(body, 'link');
|
|
63
|
+
if (text?.trim()) return text.trim();
|
|
64
|
+
const m = body.match(/<link[^>]*\bhref\s*=\s*"([^"]+)"/i);
|
|
65
|
+
return m?.[1];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Block-split RSS <item>… or Atom <entry>… elements and map each to an RssEntry. */
|
|
69
|
+
export function parseFeed(xml: string): RssEntry[] {
|
|
70
|
+
const blocks: string[] = [];
|
|
71
|
+
const itemRe = /<item[\s>][\s\S]*?<\/item>/gi;
|
|
72
|
+
const entryRe = /<entry[\s>][\s\S]*?<\/entry>/gi;
|
|
73
|
+
let m = itemRe.exec(xml);
|
|
74
|
+
while (m !== null) {
|
|
75
|
+
blocks.push(m[0]);
|
|
76
|
+
m = itemRe.exec(xml);
|
|
77
|
+
}
|
|
78
|
+
if (blocks.length === 0) {
|
|
79
|
+
m = entryRe.exec(xml);
|
|
80
|
+
while (m !== null) {
|
|
81
|
+
blocks.push(m[0]);
|
|
82
|
+
m = entryRe.exec(xml);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return blocks.map((b) => {
|
|
86
|
+
const title = stripMarkup(subElement(b, 'title') ?? '');
|
|
87
|
+
const link = subLink(b) ?? '';
|
|
88
|
+
const category = stripMarkup(subElement(b, 'category') ?? '') || null;
|
|
89
|
+
const pubRaw = subElement(b, 'pubDate') ?? subElement(b, 'published') ?? subElement(b, 'updated');
|
|
90
|
+
const summary = stripMarkup(
|
|
91
|
+
subElement(b, 'content:encoded') ?? subElement(b, 'description') ?? subElement(b, 'summary') ?? title,
|
|
92
|
+
);
|
|
93
|
+
return { title, link, publishedAt: toIsoDate(pubRaw), summary, category };
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function computeRssDocId(seed: string): string {
|
|
98
|
+
return createHash('sha256').update(seed).digest('hex').slice(0, 16);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Map a feed entry to the kk Doc shape; source:'aihot' keeps the aihot plan tier. */
|
|
102
|
+
export function mapRssEntryToDoc(entry: RssEntry, feedUrl: string): Doc {
|
|
103
|
+
const id = entry.link ? computeRssDocId(entry.link) : computeRssDocId(`${entry.title}${entry.summary}`);
|
|
104
|
+
let sourceName = 'aihot-feed';
|
|
105
|
+
try {
|
|
106
|
+
sourceName = new URL(feedUrl).hostname;
|
|
107
|
+
} catch {
|
|
108
|
+
// keep 'aihot-feed' fallback
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
id,
|
|
112
|
+
title: entry.title || 'Untitled AI News',
|
|
113
|
+
sourceUri: entry.link || feedUrl,
|
|
114
|
+
body: `${entry.title}\n\n${entry.summary}\n`,
|
|
115
|
+
mediaType: 'text/markdown',
|
|
116
|
+
metadata: {
|
|
117
|
+
source: 'aihot',
|
|
118
|
+
sourceName,
|
|
119
|
+
publishedAt: entry.publishedAt ?? undefined,
|
|
120
|
+
discoveredAt: new Date().toISOString(),
|
|
121
|
+
originalUrl: entry.link || undefined,
|
|
122
|
+
category: entry.category ?? undefined,
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function fetchRssDocs(
|
|
128
|
+
feedUrl: string,
|
|
129
|
+
limit: number,
|
|
130
|
+
fetchLike: RssFetchLike = fetch,
|
|
131
|
+
): Promise<{ docs: Doc[]; asOf: string }> {
|
|
132
|
+
const res = await fetchLike(feedUrl, {
|
|
133
|
+
headers: { 'user-agent': 'aihot-ingest/1.0.0 (RSS source)' },
|
|
134
|
+
});
|
|
135
|
+
if (!res.ok) {
|
|
136
|
+
throw new Error(`aihot-ingest RSS fetch failed: HTTP ${res.status} from ${feedUrl}`);
|
|
137
|
+
}
|
|
138
|
+
const xml = await res.text();
|
|
139
|
+
const entries = parseFeed(xml);
|
|
140
|
+
// Feed order is newest-first for RSS; atom may vary — prefer publication order.
|
|
141
|
+
const seen = new Set<string>();
|
|
142
|
+
const docs: Doc[] = [];
|
|
143
|
+
for (const entry of entries) {
|
|
144
|
+
if (docs.length >= limit) break;
|
|
145
|
+
const key = entry.link || entry.title;
|
|
146
|
+
if (seen.has(key)) continue;
|
|
147
|
+
seen.add(key);
|
|
148
|
+
docs.push(mapRssEntryToDoc(entry, feedUrl));
|
|
149
|
+
}
|
|
150
|
+
return { docs, asOf: new Date().toISOString() };
|
|
151
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gobing-ai/horizon-ingest",
|
|
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
|
+
}
|