@gobing-ai/knowledge-kit 0.0.7 → 0.0.9
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 +21604 -11844
- package/package.json +4 -1
- package/plugins/generations/content-gen/package.json +2 -1
- package/plugins/generations/content-gen/src/index.ts +9 -8
- package/plugins/generations/content-gen/src/storm.ts +12 -4
- package/plugins/generations/dailynews-gen/package.json +17 -0
- package/plugins/generations/dailynews-gen/plugin.json +7 -0
- package/plugins/generations/dailynews-gen/src/index.ts +111 -0
- package/plugins/generations/dailynews-gen/src/script-builder.ts +121 -0
- package/plugins/generations/dailynews-gen/tsconfig.json +4 -0
- package/plugins/generations/voice-gen/package.json +17 -0
- package/plugins/generations/voice-gen/plugin.json +6 -0
- package/plugins/generations/voice-gen/src/concat.ts +218 -0
- package/plugins/generations/voice-gen/src/index.ts +228 -0
- package/plugins/generations/voice-gen/src/mp3.ts +65 -0
- package/plugins/generations/voice-gen/src/voicebox-client.ts +223 -0
- package/plugins/generations/voice-gen/src/voicescript.ts +365 -0
- package/plugins/generations/voice-gen/tsconfig.json +8 -0
- package/plugins/ingestions/aihot-ingest/package.json +17 -0
- package/plugins/ingestions/aihot-ingest/plugin.json +7 -0
- package/plugins/ingestions/aihot-ingest/src/client.ts +185 -0
- package/plugins/ingestions/aihot-ingest/src/index.ts +137 -0
- package/plugins/ingestions/aihot-ingest/src/mapper.ts +42 -0
- package/plugins/ingestions/aihot-ingest/tsconfig.json +4 -0
- package/plugins/ingestions/karakeep-local/package.json +17 -0
- package/plugins/ingestions/karakeep-local/src/index.ts +31 -26
- package/plugins/ingestions/karakeep-local/tsconfig.json +4 -0
- package/plugins/ingestions/web-search/package.json +4 -1
- package/plugins/ingestions/web-search/src/index.ts +139 -16
- package/plugins/kk/README.md +9 -3
- package/plugins/kk/commands/workflow-run.md +100 -28
- package/plugins/kk/config.example.yaml +34 -0
- package/plugins/kk/scripts/render-md.ts +8 -3
- package/plugins/kk/skills/{judge → content-judge}/SKILL.md +13 -14
- package/plugins/kk/skills/{judge → content-judge}/references/workflow-integration.md +13 -11
- package/plugins/kk/skills/itc-generating/SKILL.md +147 -0
- package/plugins/kk/skills/itc-generating/references/generic-craft.md +80 -0
- package/plugins/kk/skills/itc-generating/references/platform-english.md +72 -0
- package/plugins/kk/skills/itc-generating/references/platform-wechat.md +60 -0
- package/plugins/kk/skills/itc-generating/references/skill-authoring.md +62 -0
- package/plugins/kk/skills/storm-research/SKILL.md +10 -3
- package/plugins/kk/workflows/judge-gated-publish-example.yaml +101 -0
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +144 -0
- package/plugins/kk/workflows/kk-ingest-generate-publish.yaml +72 -0
- package/plugins/kk/workflows/kk-itc.yaml +285 -0
- package/plugins/kk/workflows/kk-solo-podcast.yaml +374 -0
- package/plugins/kk/workflows/validate-voicescript.ts +226 -0
- package/plugins/publishings/emdash-pub/package.json +17 -0
- package/plugins/publishings/emdash-pub/plugin.json +7 -0
- package/plugins/publishings/emdash-pub/src/index.ts +450 -0
- package/plugins/publishings/emdash-pub/tsconfig.json +4 -0
- package/plugins/publishings/qiita-pub/package.json +2 -1
- package/plugins/publishings/qiita-pub/src/index.ts +9 -9
- package/plugins/publishings/surfdash-pub/package.json +2 -1
- package/plugins/publishings/surfdash-pub/src/index.ts +17 -12
- package/plugins/publishings/zenn-pub/package.json +2 -1
- package/plugins/publishings/zenn-pub/src/index.ts +11 -11
- package/plugins/kk/agents/judge-compliance.md +0 -37
- package/plugins/kk/agents/judge-tech.md +0 -35
- package/plugins/kk/agents/judge-tone.md +0 -37
- /package/plugins/kk/skills/{judge → content-judge}/references/rubrics.md +0 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import type { Doc } from '@gobing-ai/kk-core';
|
|
2
|
+
|
|
3
|
+
export const VOICEBOX_TEXT_MAX = 50_000;
|
|
4
|
+
export const VOICEBOX_INSTRUCT_MAX = 500;
|
|
5
|
+
export const VOICEBOX_CHUNK_CHARS_MIN = 100;
|
|
6
|
+
export const VOICEBOX_CHUNK_CHARS_MAX = 5_000;
|
|
7
|
+
export const VOICEBOX_CHUNK_CHARS_DEFAULT = 800;
|
|
8
|
+
export const VOICEBOX_CROSSFADE_MS_MIN = 0;
|
|
9
|
+
export const VOICEBOX_CROSSFADE_MS_MAX = 500;
|
|
10
|
+
export const VOICEBOX_CROSSFADE_MS_DEFAULT = 50;
|
|
11
|
+
|
|
12
|
+
export const VOICEBOX_ENGINES = [
|
|
13
|
+
'qwen',
|
|
14
|
+
'qwen_custom_voice',
|
|
15
|
+
'luxtts',
|
|
16
|
+
'chatterbox',
|
|
17
|
+
'chatterbox_turbo',
|
|
18
|
+
'tada',
|
|
19
|
+
'kokoro',
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
export const VOICEBOX_LANGUAGES = [
|
|
23
|
+
'zh',
|
|
24
|
+
'en',
|
|
25
|
+
'ja',
|
|
26
|
+
'ko',
|
|
27
|
+
'de',
|
|
28
|
+
'fr',
|
|
29
|
+
'ru',
|
|
30
|
+
'pt',
|
|
31
|
+
'es',
|
|
32
|
+
'it',
|
|
33
|
+
'he',
|
|
34
|
+
'ar',
|
|
35
|
+
'da',
|
|
36
|
+
'el',
|
|
37
|
+
'fi',
|
|
38
|
+
'hi',
|
|
39
|
+
'ms',
|
|
40
|
+
'nl',
|
|
41
|
+
'no',
|
|
42
|
+
'pl',
|
|
43
|
+
'sv',
|
|
44
|
+
'sw',
|
|
45
|
+
'tr',
|
|
46
|
+
] as const;
|
|
47
|
+
|
|
48
|
+
const ENGINE_SET = new Set<string>(VOICEBOX_ENGINES);
|
|
49
|
+
const LANGUAGE_SET = new Set<string>(VOICEBOX_LANGUAGES);
|
|
50
|
+
|
|
51
|
+
function assertEngine(value: string | undefined, label: string): void {
|
|
52
|
+
if (value !== undefined && !ENGINE_SET.has(value)) {
|
|
53
|
+
throw new Error(`${label} engine must be one of ${VOICEBOX_ENGINES.join('|')} (got "${value}")`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function assertLanguage(value: string | undefined, label: string): void {
|
|
58
|
+
if (value !== undefined && !LANGUAGE_SET.has(value)) {
|
|
59
|
+
throw new Error(`${label} language must be a Voicebox language code (got "${value}")`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface VoiceSpeaker {
|
|
64
|
+
profile: string;
|
|
65
|
+
engine?: string;
|
|
66
|
+
language?: string;
|
|
67
|
+
instruct?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface VoiceSegment {
|
|
71
|
+
speaker?: string;
|
|
72
|
+
profile?: string;
|
|
73
|
+
text: string;
|
|
74
|
+
instruct?: string;
|
|
75
|
+
engine?: string;
|
|
76
|
+
language?: string;
|
|
77
|
+
gap_ms?: number;
|
|
78
|
+
max_chunk_chars?: number;
|
|
79
|
+
crossfade_ms?: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface VoiceScript {
|
|
83
|
+
title?: string;
|
|
84
|
+
language?: string;
|
|
85
|
+
default_engine?: string;
|
|
86
|
+
default_profile?: string;
|
|
87
|
+
max_chunk_chars?: number;
|
|
88
|
+
crossfade_ms?: number;
|
|
89
|
+
speakers?: Record<string, VoiceSpeaker>;
|
|
90
|
+
segments: VoiceSegment[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const VOICESCRIPT_KEYS = new Set(['segments', 'speakers', 'default_profile', 'default_engine']);
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Parse a Doc into a VoiceScript structure.
|
|
97
|
+
* - JSON object starting with '{' -> VoiceScript path (even without segments -> validation fails loud).
|
|
98
|
+
* - Body starting with '[' -> Plain text path (e.g. Chatterbox [laugh] tag).
|
|
99
|
+
* - YAML mapping with any of segments/speakers/default_profile/default_engine -> VoiceScript path.
|
|
100
|
+
* - Otherwise -> Plain text single-segment script.
|
|
101
|
+
*/
|
|
102
|
+
export function parseDocToVoiceScript(doc: Doc, envDefaultProfile?: string): VoiceScript {
|
|
103
|
+
const trimmed = (doc.body || '').trim();
|
|
104
|
+
|
|
105
|
+
if (trimmed.startsWith('{')) {
|
|
106
|
+
let parsed: unknown;
|
|
107
|
+
try {
|
|
108
|
+
parsed = JSON.parse(trimmed);
|
|
109
|
+
} catch {
|
|
110
|
+
throw new Error('Invalid VoiceScript JSON structure: failed to parse JSON object');
|
|
111
|
+
}
|
|
112
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
113
|
+
throw new Error('Invalid VoiceScript JSON structure: root must be a JSON object');
|
|
114
|
+
}
|
|
115
|
+
return parsed as VoiceScript;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (trimmed.startsWith('[')) {
|
|
119
|
+
return {
|
|
120
|
+
segments: [{ text: trimmed }],
|
|
121
|
+
default_profile:
|
|
122
|
+
envDefaultProfile ??
|
|
123
|
+
(typeof doc.metadata?.voiceProfile === 'string' ? doc.metadata.voiceProfile : undefined),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const parsed = Bun.YAML.parse(trimmed);
|
|
129
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
130
|
+
const hasKey = Object.keys(parsed).some((k) => VOICESCRIPT_KEYS.has(k));
|
|
131
|
+
if (hasKey) {
|
|
132
|
+
return parsed as VoiceScript;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
// Fall through to plain text
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
segments: [{ text: trimmed }],
|
|
141
|
+
default_profile:
|
|
142
|
+
envDefaultProfile ??
|
|
143
|
+
(typeof doc.metadata?.voiceProfile === 'string' ? doc.metadata.voiceProfile : undefined),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Merge multiple Doc-level VoiceScripts into a single combined VoiceScript.
|
|
149
|
+
*/
|
|
150
|
+
export function mergeVoiceScripts(scripts: VoiceScript[], docs: Doc[], envDefaultProfile?: string): VoiceScript {
|
|
151
|
+
if (scripts.length === 0) {
|
|
152
|
+
return { segments: [] };
|
|
153
|
+
}
|
|
154
|
+
const firstScript = scripts[0];
|
|
155
|
+
if (scripts.length === 1 && firstScript) {
|
|
156
|
+
return firstScript;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const combinedSpeakers: Record<string, VoiceSpeaker> = {};
|
|
160
|
+
const combinedSegments: VoiceSegment[] = [];
|
|
161
|
+
|
|
162
|
+
let title: string | undefined;
|
|
163
|
+
let defaultProfile: string | undefined = envDefaultProfile;
|
|
164
|
+
let defaultEngine: string | undefined;
|
|
165
|
+
let language: string | undefined;
|
|
166
|
+
let maxChunkChars: number | undefined;
|
|
167
|
+
let crossfadeMs: number | undefined;
|
|
168
|
+
|
|
169
|
+
for (let i = 0; i < scripts.length; i++) {
|
|
170
|
+
const script = scripts[i];
|
|
171
|
+
if (!script) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const doc = docs[i];
|
|
175
|
+
|
|
176
|
+
if (!title) {
|
|
177
|
+
title = script.title || doc?.title;
|
|
178
|
+
}
|
|
179
|
+
if (script.default_profile && defaultProfile === envDefaultProfile) {
|
|
180
|
+
defaultProfile = script.default_profile;
|
|
181
|
+
}
|
|
182
|
+
if (!defaultProfile && typeof doc?.metadata?.voiceProfile === 'string') {
|
|
183
|
+
defaultProfile = doc.metadata.voiceProfile;
|
|
184
|
+
}
|
|
185
|
+
if (!defaultEngine && script.default_engine) {
|
|
186
|
+
defaultEngine = script.default_engine;
|
|
187
|
+
}
|
|
188
|
+
if (!language && script.language) {
|
|
189
|
+
language = script.language;
|
|
190
|
+
}
|
|
191
|
+
if (maxChunkChars === undefined && script.max_chunk_chars !== undefined) {
|
|
192
|
+
maxChunkChars = script.max_chunk_chars;
|
|
193
|
+
}
|
|
194
|
+
if (crossfadeMs === undefined && script.crossfade_ms !== undefined) {
|
|
195
|
+
crossfadeMs = script.crossfade_ms;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (script.speakers) {
|
|
199
|
+
for (const [key, speaker] of Object.entries(script.speakers)) {
|
|
200
|
+
if (key in combinedSpeakers) {
|
|
201
|
+
throw new Error(`Duplicate speaker key across documents: "${key}"`);
|
|
202
|
+
}
|
|
203
|
+
combinedSpeakers[key] = speaker;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (Array.isArray(script.segments)) {
|
|
208
|
+
combinedSegments.push(...script.segments);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const result: VoiceScript = {
|
|
213
|
+
title: title || docs[0]?.title || 'Generated voice',
|
|
214
|
+
segments: combinedSegments,
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
if (Object.keys(combinedSpeakers).length > 0) {
|
|
218
|
+
result.speakers = combinedSpeakers;
|
|
219
|
+
}
|
|
220
|
+
if (defaultProfile) {
|
|
221
|
+
result.default_profile = defaultProfile;
|
|
222
|
+
}
|
|
223
|
+
if (defaultEngine) {
|
|
224
|
+
result.default_engine = defaultEngine;
|
|
225
|
+
}
|
|
226
|
+
if (language) {
|
|
227
|
+
result.language = language;
|
|
228
|
+
}
|
|
229
|
+
if (maxChunkChars !== undefined) {
|
|
230
|
+
result.max_chunk_chars = maxChunkChars;
|
|
231
|
+
}
|
|
232
|
+
if (crossfadeMs !== undefined) {
|
|
233
|
+
result.crossfade_ms = crossfadeMs;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return result;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Validate that a VoiceScript satisfies all Voicebox and plugin constraints.
|
|
241
|
+
*/
|
|
242
|
+
export function validateVoiceScript(script: VoiceScript): void {
|
|
243
|
+
if (!script || typeof script !== 'object') {
|
|
244
|
+
throw new Error('VoiceScript must be an object');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (!Array.isArray(script.segments) || script.segments.length === 0) {
|
|
248
|
+
throw new Error('VoiceScript must have a non-empty segments array');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (script.max_chunk_chars !== undefined) {
|
|
252
|
+
if (
|
|
253
|
+
typeof script.max_chunk_chars !== 'number' ||
|
|
254
|
+
!Number.isInteger(script.max_chunk_chars) ||
|
|
255
|
+
script.max_chunk_chars < VOICEBOX_CHUNK_CHARS_MIN ||
|
|
256
|
+
script.max_chunk_chars > VOICEBOX_CHUNK_CHARS_MAX
|
|
257
|
+
) {
|
|
258
|
+
throw new Error(
|
|
259
|
+
`max_chunk_chars must be between ${VOICEBOX_CHUNK_CHARS_MIN} and ${VOICEBOX_CHUNK_CHARS_MAX}`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (script.crossfade_ms !== undefined) {
|
|
265
|
+
if (
|
|
266
|
+
typeof script.crossfade_ms !== 'number' ||
|
|
267
|
+
!Number.isInteger(script.crossfade_ms) ||
|
|
268
|
+
script.crossfade_ms < VOICEBOX_CROSSFADE_MS_MIN ||
|
|
269
|
+
script.crossfade_ms > VOICEBOX_CROSSFADE_MS_MAX
|
|
270
|
+
) {
|
|
271
|
+
throw new Error(
|
|
272
|
+
`crossfade_ms must be between ${VOICEBOX_CROSSFADE_MS_MIN} and ${VOICEBOX_CROSSFADE_MS_MAX}`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
assertEngine(script.default_engine, 'VoiceScript default_engine');
|
|
278
|
+
assertLanguage(script.language, 'VoiceScript');
|
|
279
|
+
|
|
280
|
+
if (script.speakers) {
|
|
281
|
+
for (const [name, speaker] of Object.entries(script.speakers)) {
|
|
282
|
+
if (!speaker || typeof speaker !== 'object') {
|
|
283
|
+
throw new Error(`Speaker "${name}" definition must be an object`);
|
|
284
|
+
}
|
|
285
|
+
if (!speaker.profile || typeof speaker.profile !== 'string') {
|
|
286
|
+
throw new Error(`Speaker "${name}" must have a profile specified`);
|
|
287
|
+
}
|
|
288
|
+
if (
|
|
289
|
+
speaker.instruct &&
|
|
290
|
+
typeof speaker.instruct === 'string' &&
|
|
291
|
+
speaker.instruct.length > VOICEBOX_INSTRUCT_MAX
|
|
292
|
+
) {
|
|
293
|
+
throw new Error(`Speaker "${name}" instruct exceeds 500 characters (${speaker.instruct.length} chars)`);
|
|
294
|
+
}
|
|
295
|
+
assertEngine(speaker.engine, `Speaker "${name}"`);
|
|
296
|
+
assertLanguage(speaker.language, `Speaker "${name}"`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
for (let idx = 0; idx < script.segments.length; idx++) {
|
|
301
|
+
const segment = script.segments[idx];
|
|
302
|
+
if (!segment || typeof segment !== 'object') {
|
|
303
|
+
throw new Error(`Segment at index ${idx} must be an object`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (typeof segment.text !== 'string' || segment.text.length === 0) {
|
|
307
|
+
throw new Error(`Segment at index ${idx} text must not be empty`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (segment.text.length > VOICEBOX_TEXT_MAX) {
|
|
311
|
+
throw new Error(
|
|
312
|
+
`Segment text exceeds the 50,000-character Voicebox /generate cap (${segment.text.length} chars)`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (
|
|
317
|
+
segment.instruct &&
|
|
318
|
+
typeof segment.instruct === 'string' &&
|
|
319
|
+
segment.instruct.length > VOICEBOX_INSTRUCT_MAX
|
|
320
|
+
) {
|
|
321
|
+
throw new Error(`Segment instruct exceeds 500 characters (${segment.instruct.length} chars)`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (segment.speaker) {
|
|
325
|
+
if (!script.speakers || !(segment.speaker in script.speakers)) {
|
|
326
|
+
throw new Error(`Unknown speaker: "${segment.speaker}"`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (segment.max_chunk_chars !== undefined) {
|
|
331
|
+
if (
|
|
332
|
+
typeof segment.max_chunk_chars !== 'number' ||
|
|
333
|
+
!Number.isInteger(segment.max_chunk_chars) ||
|
|
334
|
+
segment.max_chunk_chars < VOICEBOX_CHUNK_CHARS_MIN ||
|
|
335
|
+
segment.max_chunk_chars > VOICEBOX_CHUNK_CHARS_MAX
|
|
336
|
+
) {
|
|
337
|
+
throw new Error(
|
|
338
|
+
`max_chunk_chars must be between ${VOICEBOX_CHUNK_CHARS_MIN} and ${VOICEBOX_CHUNK_CHARS_MAX}`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (segment.crossfade_ms !== undefined) {
|
|
344
|
+
if (
|
|
345
|
+
typeof segment.crossfade_ms !== 'number' ||
|
|
346
|
+
!Number.isInteger(segment.crossfade_ms) ||
|
|
347
|
+
segment.crossfade_ms < VOICEBOX_CROSSFADE_MS_MIN ||
|
|
348
|
+
segment.crossfade_ms > VOICEBOX_CROSSFADE_MS_MAX
|
|
349
|
+
) {
|
|
350
|
+
throw new Error(
|
|
351
|
+
`crossfade_ms must be between ${VOICEBOX_CROSSFADE_MS_MIN} and ${VOICEBOX_CROSSFADE_MS_MAX}`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (segment.gap_ms !== undefined) {
|
|
357
|
+
if (typeof segment.gap_ms !== 'number' || segment.gap_ms < 0) {
|
|
358
|
+
throw new Error('gap_ms must be non-negative');
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
assertEngine(segment.engine, `Segment at index ${idx}`);
|
|
363
|
+
assertLanguage(segment.language, `Segment at index ${idx}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gobing-ai/aihot-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
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { echoError } from '@gobing-ai/ts-utils';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
export const AIHOT_BASE_URL = 'https://aihot.virxact.com';
|
|
5
|
+
export const DEFAULT_AIHOT_USER_AGENT = 'aihot-ingest/1.0.0';
|
|
6
|
+
|
|
7
|
+
export const AihotItemSchema = z.object({
|
|
8
|
+
id: z.string(),
|
|
9
|
+
title: z.string(),
|
|
10
|
+
originalTitle: z.string().nullable(),
|
|
11
|
+
summary: z.string().nullable(),
|
|
12
|
+
source: z.object({ name: z.string() }),
|
|
13
|
+
links: z.object({
|
|
14
|
+
aihot: z.url(),
|
|
15
|
+
original: z.url(),
|
|
16
|
+
}),
|
|
17
|
+
publishedAt: z.iso.datetime().nullable(),
|
|
18
|
+
discoveredAt: z.iso.datetime(),
|
|
19
|
+
category: z.string().nullable(),
|
|
20
|
+
score: z.number().min(0).max(100).nullable(),
|
|
21
|
+
selected: z.boolean(),
|
|
22
|
+
reason: z.string().nullable().optional(),
|
|
23
|
+
attribution: z
|
|
24
|
+
.object({
|
|
25
|
+
name: z.string(),
|
|
26
|
+
url: z.url(),
|
|
27
|
+
})
|
|
28
|
+
.optional(),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export type AihotItem = z.infer<typeof AihotItemSchema>;
|
|
32
|
+
|
|
33
|
+
export const AihotSnapshotResponseSchema = z.object({
|
|
34
|
+
schemaVersion: z.literal(1),
|
|
35
|
+
asOf: z.iso.datetime(),
|
|
36
|
+
fields: z.literal('default'),
|
|
37
|
+
cursor: z.string(),
|
|
38
|
+
count: z.number().int().nonnegative(),
|
|
39
|
+
hasMore: z.boolean(),
|
|
40
|
+
nextPage: z.string().nullable(),
|
|
41
|
+
items: z.array(AihotItemSchema),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export type AihotSnapshotResponse = z.infer<typeof AihotSnapshotResponseSchema>;
|
|
45
|
+
|
|
46
|
+
const AihotSnapshotEnvelopeSchema = AihotSnapshotResponseSchema.extend({
|
|
47
|
+
items: z.array(z.unknown()),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export const AihotChangeSchema = z.discriminatedUnion('op', [
|
|
51
|
+
z.object({
|
|
52
|
+
op: z.literal('upsert'),
|
|
53
|
+
item: AihotItemSchema,
|
|
54
|
+
changedAt: z.iso.datetime(),
|
|
55
|
+
}),
|
|
56
|
+
z.object({
|
|
57
|
+
op: z.literal('remove'),
|
|
58
|
+
id: z.string(),
|
|
59
|
+
changedAt: z.iso.datetime(),
|
|
60
|
+
}),
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
export const AihotChangesResponseSchema = z.object({
|
|
64
|
+
schemaVersion: z.literal(1),
|
|
65
|
+
fields: z.literal('default'),
|
|
66
|
+
cursor: z.string(),
|
|
67
|
+
count: z.number().int().nonnegative(),
|
|
68
|
+
hasMore: z.boolean(),
|
|
69
|
+
changes: z.array(AihotChangeSchema),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
export type AihotChangesResponse = z.infer<typeof AihotChangesResponseSchema>;
|
|
73
|
+
|
|
74
|
+
const AihotChangesEnvelopeSchema = AihotChangesResponseSchema.extend({
|
|
75
|
+
changes: z.array(z.unknown()),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
export type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
79
|
+
|
|
80
|
+
export interface AihotDeps {
|
|
81
|
+
fetch: FetchLike;
|
|
82
|
+
baseUrl?: string;
|
|
83
|
+
userAgent?: string;
|
|
84
|
+
warn?: (message: string) => void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface FetchSnapshotOptions {
|
|
88
|
+
limit?: number;
|
|
89
|
+
page?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface FetchChangesOptions {
|
|
93
|
+
cursor: string;
|
|
94
|
+
limit?: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export class AihotClient {
|
|
98
|
+
private readonly fetch: FetchLike;
|
|
99
|
+
private readonly baseUrl: string;
|
|
100
|
+
private readonly userAgent: string;
|
|
101
|
+
private readonly warn: (message: string) => void;
|
|
102
|
+
|
|
103
|
+
constructor(deps?: Partial<AihotDeps>) {
|
|
104
|
+
this.fetch = deps?.fetch ?? globalThis.fetch;
|
|
105
|
+
this.baseUrl = (deps?.baseUrl ?? AIHOT_BASE_URL).replace(/\/+$/, '');
|
|
106
|
+
this.userAgent = deps?.userAgent ?? DEFAULT_AIHOT_USER_AGENT;
|
|
107
|
+
this.warn = deps?.warn ?? echoError;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
public async fetchSnapshot(options?: FetchSnapshotOptions): Promise<AihotSnapshotResponse> {
|
|
111
|
+
const url = new URL(`${this.baseUrl}/api/v1/selected/snapshot`);
|
|
112
|
+
if (options?.limit !== undefined) {
|
|
113
|
+
url.searchParams.set('limit', String(options.limit));
|
|
114
|
+
}
|
|
115
|
+
if (options?.page) {
|
|
116
|
+
url.searchParams.set('page', options.page);
|
|
117
|
+
}
|
|
118
|
+
url.searchParams.set('fields', 'default');
|
|
119
|
+
|
|
120
|
+
const res = await this.fetch(url.toString(), {
|
|
121
|
+
method: 'GET',
|
|
122
|
+
headers: {
|
|
123
|
+
Accept: 'application/json',
|
|
124
|
+
'User-Agent': this.userAgent,
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (!res.ok) {
|
|
129
|
+
const errBody = (await res.text()).slice(0, 1000);
|
|
130
|
+
throw new Error(
|
|
131
|
+
`aihot snapshot request failed (HTTP ${res.status} ${res.statusText}): ${errBody || 'Unknown error'}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const response = AihotSnapshotEnvelopeSchema.parse(await res.json());
|
|
136
|
+
return {
|
|
137
|
+
...response,
|
|
138
|
+
items: this.parseRecords(response.items, AihotItemSchema, 'snapshot item'),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
public async fetchChanges(options: FetchChangesOptions): Promise<AihotChangesResponse> {
|
|
143
|
+
const url = new URL(`${this.baseUrl}/api/v1/selected/changes`);
|
|
144
|
+
url.searchParams.set('cursor', options.cursor);
|
|
145
|
+
if (options.limit !== undefined) {
|
|
146
|
+
url.searchParams.set('limit', String(options.limit));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const res = await this.fetch(url.toString(), {
|
|
150
|
+
method: 'GET',
|
|
151
|
+
headers: {
|
|
152
|
+
Accept: 'application/json',
|
|
153
|
+
'User-Agent': this.userAgent,
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (!res.ok) {
|
|
158
|
+
const errBody = (await res.text()).slice(0, 1000);
|
|
159
|
+
throw new Error(
|
|
160
|
+
`aihot changes request failed (HTTP ${res.status} ${res.statusText}): ${errBody || 'Unknown error'}`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const response = AihotChangesEnvelopeSchema.parse(await res.json());
|
|
165
|
+
return {
|
|
166
|
+
...response,
|
|
167
|
+
changes: this.parseRecords(response.changes, AihotChangeSchema, 'change record'),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private parseRecords<T>(records: unknown[], schema: z.ZodType<T>, label: string): T[] {
|
|
172
|
+
return records.flatMap((record, index) => {
|
|
173
|
+
const result = schema.safeParse(record);
|
|
174
|
+
if (result.success) return [result.data];
|
|
175
|
+
|
|
176
|
+
const details = result.error.issues.map((issue) => `${issue.path.join('.') || '<root>'}: ${issue.message}`);
|
|
177
|
+
this.warn(`aihot: skipping invalid ${label} at index ${index} (${details.join('; ')})`);
|
|
178
|
+
return [];
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function createAihotClient(deps?: Partial<AihotDeps>): AihotClient {
|
|
184
|
+
return new AihotClient(deps);
|
|
185
|
+
}
|