@gobing-ai/knowledge-kit 0.0.19 → 0.0.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/package.json +1 -1
- package/plugins/generations/dailynews-gen/dist/index.js +9 -1
- package/plugins/generations/dailynews-gen/src/script-builder.ts +19 -1
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/pipeline.py +13 -5
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/qc.py +23 -15
- package/plugins/ingestions/aihot-ingest/dist/index.js +1 -1
- package/plugins/ingestions/aihot-ingest/plugin.json +1 -1
- package/plugins/ingestions/aihot-ingest/src/rss.ts +12 -2
- package/plugins/kk/commands/workflow-run.md +20 -6
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/scripts/itc-stages.ts +563 -0
- package/plugins/kk/scripts/kk-workflow-stages.ts +93 -14
- package/plugins/kk/skills/article-adapt/SKILL.md +85 -0
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +26 -16
- package/plugins/kk/workflows/kk-itc.yaml +461 -12
package/package.json
CHANGED
|
@@ -22010,6 +22010,13 @@ function stripLeadingTitleEcho(body, title) {
|
|
|
22010
22010
|
return body;
|
|
22011
22011
|
return body.slice(end).replace(/^[\uFF0C\u3002\u3001\uFF1A:\uFF1B;!\uFF01?\uFF1F\u2026\s\u300C\u300D\u300E\u300F\u3010\u3011]+/, "");
|
|
22012
22012
|
}
|
|
22013
|
+
var TAG_METADATA_LINE_RE = /^\s*\**\s*\u6807\u7B7E\s*\**\s*[\uFF1A:\uFF0C,]/;
|
|
22014
|
+
function stripTagMetadataLines(body) {
|
|
22015
|
+
const kept = body.split(`
|
|
22016
|
+
`).filter((line) => !TAG_METADATA_LINE_RE.test(line)).join(`
|
|
22017
|
+
`);
|
|
22018
|
+
return kept === body ? body : kept.trim();
|
|
22019
|
+
}
|
|
22013
22020
|
function splitFactText(text, maxChars = MAX_FACT_SEGMENT_CHARS) {
|
|
22014
22021
|
if (text.length <= maxChars)
|
|
22015
22022
|
return [text];
|
|
@@ -22091,7 +22098,7 @@ function buildNewsVoiceScript(docs, options) {
|
|
|
22091
22098
|
docs.forEach((doc2, idx) => {
|
|
22092
22099
|
const isLast = idx === docs.length - 1 && docs.length > 1;
|
|
22093
22100
|
const cleanTitle = (doc2.title ?? "").trim();
|
|
22094
|
-
const cleanBody = stripLeadingTitleEcho((doc2.body ?? "").trim(), cleanTitle);
|
|
22101
|
+
const cleanBody = stripLeadingTitleEcho(stripTagMetadataLines((doc2.body ?? "").trim()), cleanTitle);
|
|
22095
22102
|
const lead = isZh ? idx === 0 ? `\u9996\u5148\u6765\u804A\u804A\u5927\u5BB6\u975E\u5E38\u5173\u6CE8\u7684\u3010${cleanTitle}\u3011\u3002` : isLast ? `\u6700\u540E\uFF0C\u6765\u770B\u770B\u4ECA\u5929\u7684\u6700\u540E\u4E00\u6761\u8D44\u8BAF\uFF0C\u3010${cleanTitle}\u3011\u3002` : `\u63A5\u7740\u6211\u4EEC\u628A\u76EE\u5149\u8F6C\u5411\u53E6\u4E00\u6761\u91CD\u8981\u8FDB\u5C55\uFF0C\u3010${cleanTitle}\u3011\u3002` : idx === 0 ? `First up today, let's look at ${cleanTitle}.` : isLast ? `And finally today, let's wrap up with ${cleanTitle}.` : `Next, turning our attention to ${cleanTitle}.`;
|
|
22096
22103
|
const normalizedLead = normalizeBroadcastText(lead, isZh);
|
|
22097
22104
|
const normalizedBody = cleanBody === "" ? "" : normalizeBroadcastText(cleanBody, isZh);
|
|
@@ -22341,6 +22348,7 @@ if (import.meta.main) {
|
|
|
22341
22348
|
}
|
|
22342
22349
|
export {
|
|
22343
22350
|
synthesizePersonalUnderstanding,
|
|
22351
|
+
stripTagMetadataLines,
|
|
22344
22352
|
stripLeadingTitleEcho,
|
|
22345
22353
|
splitFactText,
|
|
22346
22354
|
processGeneratorIO,
|
|
@@ -245,6 +245,24 @@ export function stripLeadingTitleEcho(body: string, title: string): string {
|
|
|
245
245
|
return body.slice(end).replace(/^[,。、::;;!!??…\s「」『』【】]+/, '');
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Drop `**标签**: …` metadata lines from a plan doc body (0139 R3).
|
|
250
|
+
*
|
|
251
|
+
* `plan-translate` keeps the research note's trailing tag line in the body it hands over; spoken
|
|
252
|
+
* aloud it reads as a `标签,…` tail the script validator rejects. The line is article metadata,
|
|
253
|
+
* not copy — remove whole lines that open with the 标签 label + separator; anything else is left
|
|
254
|
+
* for the validator to fail loud on.
|
|
255
|
+
*/
|
|
256
|
+
const TAG_METADATA_LINE_RE = /^\s*\**\s*标签\s*\**\s*[::,,]/;
|
|
257
|
+
|
|
258
|
+
export function stripTagMetadataLines(body: string): string {
|
|
259
|
+
const kept = body
|
|
260
|
+
.split('\n')
|
|
261
|
+
.filter((line) => !TAG_METADATA_LINE_RE.test(line))
|
|
262
|
+
.join('\n');
|
|
263
|
+
return kept === body ? body : kept.trim();
|
|
264
|
+
}
|
|
265
|
+
|
|
248
266
|
/**
|
|
249
267
|
* Split a fact text into chunks of at most `maxChars` (task 0139 R4).
|
|
250
268
|
*
|
|
@@ -360,7 +378,7 @@ export function buildNewsVoiceScript(docs: Doc[], options?: ScriptBuilderOptions
|
|
|
360
378
|
const cleanTitle = (doc.title ?? '').trim();
|
|
361
379
|
// R1: drop the title restatement plan-translate prepends to the body, so the lead's
|
|
362
380
|
// 【title】 is the only place the title is spoken for this item.
|
|
363
|
-
const cleanBody = stripLeadingTitleEcho((doc.body ?? '').trim(), cleanTitle);
|
|
381
|
+
const cleanBody = stripLeadingTitleEcho(stripTagMetadataLines((doc.body ?? '').trim()), cleanTitle);
|
|
364
382
|
|
|
365
383
|
// Conversational lead
|
|
366
384
|
const lead = isZh
|
|
@@ -133,8 +133,8 @@ def _transcribe(backend: Backend, wav: bytes, language: str) -> str | None:
|
|
|
133
133
|
return None
|
|
134
134
|
|
|
135
135
|
|
|
136
|
-
def _render_verified(backend: Backend, spec: SegmentSpec) -> tuple[bytes, float, int, str | None, bool]:
|
|
137
|
-
"""Spec §5 verify-retry -> (wav, duration, verify_retries, transcription, loudness_dip)."""
|
|
136
|
+
def _render_verified(backend: Backend, spec: SegmentSpec) -> tuple[bytes, float, int, str | None, bool, float | None]:
|
|
137
|
+
"""Spec §5 verify-retry -> (wav, duration, verify_retries, transcription, loudness_dip, dip_at)."""
|
|
138
138
|
for attempt in range(MAX_VERIFY_RETRIES + 1):
|
|
139
139
|
final = attempt == MAX_VERIFY_RETRIES # the final attempt is always accepted
|
|
140
140
|
if attempt:
|
|
@@ -154,7 +154,7 @@ def _render_verified(backend: Backend, spec: SegmentSpec) -> tuple[bytes, float,
|
|
|
154
154
|
if dip and not final:
|
|
155
155
|
_log(f"loudness dip detected at {dip_at}s — regenerating segment")
|
|
156
156
|
continue
|
|
157
|
-
return wav, duration, attempt, transcription, dip
|
|
157
|
+
return wav, duration, attempt, transcription, dip, dip_at
|
|
158
158
|
raise AssertionError("unreachable: the final verify attempt always returns") # pragma: no cover
|
|
159
159
|
|
|
160
160
|
|
|
@@ -212,7 +212,7 @@ def _render(
|
|
|
212
212
|
"seed": None if segment.seed is None else int(segment.seed),
|
|
213
213
|
"speed": None if segment.speed is None else float(segment.speed),
|
|
214
214
|
}
|
|
215
|
-
wav, duration, retries, transcription, dip = _render_verified(backend, spec)
|
|
215
|
+
wav, duration, retries, transcription, dip, dip_at = _render_verified(backend, spec)
|
|
216
216
|
|
|
217
217
|
gap_ms = segment.gap_ms or 0
|
|
218
218
|
wavs.append(wav)
|
|
@@ -233,7 +233,15 @@ def _render(
|
|
|
233
233
|
}
|
|
234
234
|
)
|
|
235
235
|
)
|
|
236
|
-
audit_meta.append(
|
|
236
|
+
audit_meta.append(
|
|
237
|
+
{
|
|
238
|
+
"duration": duration,
|
|
239
|
+
"transcription": transcription,
|
|
240
|
+
"loudness_dip": dip,
|
|
241
|
+
"dip_at": dip_at,
|
|
242
|
+
"verify_retries": retries,
|
|
243
|
+
}
|
|
244
|
+
)
|
|
237
245
|
retry_note = f" ({retries} verify {'retry' if retries == 1 else 'retries'})" if retries else ""
|
|
238
246
|
_log(f"segment {index + 1}/{total} done in {monotonic() - segment_started:.1f}s{retry_note}")
|
|
239
247
|
|
|
@@ -48,6 +48,7 @@ class SegmentQualityAudit:
|
|
|
48
48
|
repetition_detected: bool
|
|
49
49
|
duration_anomaly: bool
|
|
50
50
|
fidelity_score: float | None
|
|
51
|
+
verify_retries: int = 0
|
|
51
52
|
issues: list[str] = field(default_factory=list)
|
|
52
53
|
|
|
53
54
|
|
|
@@ -158,21 +159,6 @@ def audit_voice_segments(
|
|
|
158
159
|
if fidelity < TRANSCRIPTION_FIDELITY_FLOOR:
|
|
159
160
|
issues.append(f"transcription_fidelity_low:{fidelity:.2f}")
|
|
160
161
|
|
|
161
|
-
# Repetition
|
|
162
|
-
repetition = detect_repetitions(text) or (
|
|
163
|
-
detect_repetitions(transcription) if transcription is not None else False
|
|
164
|
-
)
|
|
165
|
-
if repetition:
|
|
166
|
-
issues.append("repetition_detected")
|
|
167
|
-
|
|
168
|
-
# Loudness dip: reuse the verify-phase result when present; only run the windowed
|
|
169
|
-
# scan when the verify phase did not (meta.loudness_dip absent).
|
|
170
|
-
dip, dip_at = (
|
|
171
|
-
(bool(meta["loudness_dip"]), None) if "loudness_dip" in meta else detect_loudness_dip(wav)
|
|
172
|
-
)
|
|
173
|
-
if dip:
|
|
174
|
-
issues.append(f"loudness_dip:{f'{dip_at}s' if dip_at is not None else 'unknown'}")
|
|
175
|
-
|
|
176
162
|
# Duration anomaly
|
|
177
163
|
ratio_anomaly = duration > MAX_DURATION_RATIO * max_sec if max_sec > 0 else False
|
|
178
164
|
abs_anomaly = duration > max_sec + MAX_ABS_DIFF_SEC if max_sec > 0 else False
|
|
@@ -187,6 +173,27 @@ def audit_voice_segments(
|
|
|
187
173
|
f"duration_anomaly: expected {min_sec:.2f}s-{max_sec:.2f}s, got {duration:.2f}s"
|
|
188
174
|
)
|
|
189
175
|
|
|
176
|
+
# Repetition — transcription channel only, corroborated by duration anomaly (voice-gen
|
|
177
|
+
# qc.ts parity): the battery is an ASR/TTS-artifact detector, not a source-text lint.
|
|
178
|
+
# Source strings like version numbers ("1.1.1.") are legitimate copy and speak fine
|
|
179
|
+
# (dogfood 2026-09-18: a version string in the source tripped the word-run regex and
|
|
180
|
+
# condemned a 70-minute render that no regeneration could fix). A real TTS loop always
|
|
181
|
+
# inflates duration; a Whisper phantom loop over trailing silence does not.
|
|
182
|
+
repetition = detect_repetitions(transcription) if transcription is not None else False
|
|
183
|
+
if repetition and duration_anomaly:
|
|
184
|
+
# Bare token: pipeline.py's R5a gate matches issues by exact membership.
|
|
185
|
+
issues.append("repetition_detected")
|
|
186
|
+
|
|
187
|
+
# Loudness dip: reuse the verify-phase result when present; only run the windowed
|
|
188
|
+
# scan when the verify phase did not (meta.loudness_dip absent).
|
|
189
|
+
dip, dip_at = (
|
|
190
|
+
(bool(meta["loudness_dip"]), meta.get("dip_at"))
|
|
191
|
+
if "loudness_dip" in meta
|
|
192
|
+
else detect_loudness_dip(wav)
|
|
193
|
+
)
|
|
194
|
+
if dip:
|
|
195
|
+
issues.append(f"loudness_dip:{f'{dip_at}s' if dip_at is not None else 'unknown'}")
|
|
196
|
+
|
|
190
197
|
audits.append(
|
|
191
198
|
SegmentQualityAudit(
|
|
192
199
|
segment_index=index,
|
|
@@ -198,6 +205,7 @@ def audit_voice_segments(
|
|
|
198
205
|
repetition_detected=repetition,
|
|
199
206
|
duration_anomaly=duration_anomaly,
|
|
200
207
|
fidelity_score=fidelity,
|
|
208
|
+
verify_retries=int(meta.get("verify_retries", 0) or 0),
|
|
201
209
|
issues=issues,
|
|
202
210
|
)
|
|
203
211
|
)
|
|
@@ -22086,7 +22086,7 @@ function mapAihotItemsToDocs(items) {
|
|
|
22086
22086
|
|
|
22087
22087
|
// ../../plugins/ingestions/aihot-ingest/src/rss.ts
|
|
22088
22088
|
import { createHash as createHash2 } from "crypto";
|
|
22089
|
-
var AIHOT_FEED_URL_DEFAULT = "https://
|
|
22089
|
+
var AIHOT_FEED_URL_DEFAULT = "https://arstechnica.com/ai/feed/";
|
|
22090
22090
|
function stripMarkup(raw) {
|
|
22091
22091
|
return raw.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1").replace(/<[^>]+>/g, " ").replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'|'/g, "'").replace(/\s+/g, " ").trim();
|
|
22092
22092
|
}
|
|
@@ -3,5 +3,5 @@
|
|
|
3
3
|
"kind": "ingestion",
|
|
4
4
|
"entry": "./dist/index.js",
|
|
5
5
|
"version": "1.0.0",
|
|
6
|
-
"description": "Ingests fresh AI news from an RSS feed (default
|
|
6
|
+
"description": "Ingests fresh AI news from an RSS feed (default Ars Technica AI, full text) into Doc[]; legacy aihot.virxact.com API behind AIHOT_SOURCE=api"
|
|
7
7
|
}
|
|
@@ -15,8 +15,18 @@ import type { Doc } from '@gobing-ai/kk-core';
|
|
|
15
15
|
* still applies to feed items.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
/** Default fresh AI-news feed (RSS 2.0,
|
|
19
|
-
|
|
18
|
+
/** Default fresh AI-news feed (RSS 2.0, full text, reachable without auth).
|
|
19
|
+
*
|
|
20
|
+
* Why Ars Technica (2026-09-20, dogfood): the previous default (TechCrunch AI
|
|
21
|
+
* category feed) stopped shipping `content:encoded` — items now carry only
|
|
22
|
+
* 86–236-char `description` summaries. Every such body lands below the planner's
|
|
23
|
+
* STUB_BODY_FLOOR (300 chars), scores quality 1, and is auto-rejected at
|
|
24
|
+
* `qc_min_quality=2`, so the aihot leg contributed ~0 usable items (the
|
|
25
|
+
* 2026-09-20 episode collapsed to 3 items / 5m33s). The Ars Technica AI feed
|
|
26
|
+
* ships full `content:encoded` (≈900–1800 chars) on every item. The parser
|
|
27
|
+
* already prefers `content:encoded`, so only the URL changes.
|
|
28
|
+
*/
|
|
29
|
+
export const AIHOT_FEED_URL_DEFAULT = 'https://arstechnica.com/ai/feed/';
|
|
20
30
|
|
|
21
31
|
export type RssFetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
22
32
|
|
|
@@ -35,7 +35,10 @@ Parse per the frozen rule:
|
|
|
35
35
|
`--fixture` → force `fixture: "true"`.
|
|
36
36
|
- Profile `kk-itc`: `--dir <path>` (default `$works_dir/kk-itc/<kebab>`), `--playbook generic|english|wechat` (default `generic`),
|
|
37
37
|
`--research` (`true`|`false`, default `false`), `--judge` (`true`|`false`, default `false`),
|
|
38
|
-
`--outline <a|b|c>` (default empty), `--writer itc-generating|topic` (default `itc-generating`)
|
|
38
|
+
`--outline <a|b|c>` (default empty), `--writer itc-generating|topic` (default `itc-generating`),
|
|
39
|
+
`--auto`, `--unslop true|false` (default `false`), `--max-revisions <N>` (default `2`),
|
|
40
|
+
`--cover`, `--inline-images`, `--targets <a,b>` (default empty), `--live`,
|
|
41
|
+
`--source-locale en|zh|ja` (default `en`; `--playbook wechat` without it implies `zh`).
|
|
39
42
|
`--fixture` passed with `kk-itc` or `kk-solo-podcast` → exit 1 (`fixture is storm-only`).
|
|
40
43
|
- Profile `kk-solo-podcast`: `--dir <path>` (default `$works_dir/kk-solo-podcast/<date>`), `--outline <a|b|c>`
|
|
41
44
|
(default empty), `--duration <min>` (default `8`), `--language <code>` (default `en`),
|
|
@@ -48,7 +51,9 @@ Parse per the frozen rule:
|
|
|
48
51
|
|
|
49
52
|
Resolve these before step 2: `NAME`, `TOPIC`, `INPUT_FILE` (empty in sentence mode), `FIXTURE`
|
|
50
53
|
(`true`|`false`), `FORCE` (`true`|`false`), `DIR`, `PLAYBOOK`, `RESEARCH`, `JUDGE`, `OUTLINE`,
|
|
51
|
-
`WRITER`, `
|
|
54
|
+
`WRITER`, `AUTO` (`true`|`false`), `UNSLOP` (`true`|`false`), `MAX_REVISIONS` (`2`), `COVER`
|
|
55
|
+
(`true`|`false`), `INLINE_IMAGES` (`true`|`false`), `TARGETS`, `LIVE` (`true`|`false`),
|
|
56
|
+
`SOURCE_LOCALE` (`en`), `DURATION` (`8`), `LANGUAGE` (`en`), `SCRIPT_APPROVED` (`true`|`false`),
|
|
52
57
|
`VOICE_PROFILE` (from `VOICEBOX_DEFAULT_PROFILE`, default empty).
|
|
53
58
|
|
|
54
59
|
## 2. Resolve the config
|
|
@@ -185,6 +190,14 @@ mkdir -p "$work_dir"
|
|
|
185
190
|
`work_dir="${DIR:-$works_dir/kk-itc/$kebab}"`. Empty derived `kebab` → exit 1. An explicit
|
|
186
191
|
`--dir` still wins, and the default never writes into the caller's cwd.
|
|
187
192
|
|
|
193
|
+
Binding (0153): `--auto` binds `auto=true` and defaults `research`, `judge` and `unslop` to
|
|
194
|
+
`true` unless the operator passed them explicitly. `--live` is never implied — `publish_live`
|
|
195
|
+
stays `false` unless `--live` is passed explicitly (ADR-022). `--playbook wechat` without
|
|
196
|
+
`--source-locale` binds `source_locale=zh`; an explicit `--source-locale` always wins.
|
|
197
|
+
`--targets <a,b>` binds `publish_targets`; `--cover` / `--inline-images` bind
|
|
198
|
+
`cover_enabled` / `inline_images` (`false` unless passed); `--max-revisions` binds
|
|
199
|
+
`max_revisions` (default `2`).
|
|
200
|
+
|
|
188
201
|
### Profile `kk-solo-podcast`
|
|
189
202
|
|
|
190
203
|
`date` = today's date in `YYYY-MM-DD` (`$(date +%Y-%m-%d)`).
|
|
@@ -218,13 +231,14 @@ spur workflow run "$dest" --vars \
|
|
|
218
231
|
|
|
219
232
|
### Profile `kk-itc`
|
|
220
233
|
|
|
221
|
-
Bind vars: `topic`, `input_file`, `work_dir`, `writer`, `playbook`, `research`, `judge`, `
|
|
222
|
-
`force`, `rubric` (`tech-accuracy`), `agent
|
|
223
|
-
|
|
234
|
+
Bind vars: `topic`, `input_file`, `work_dir`, `writer`, `playbook`, `research`, `judge`, `auto`,
|
|
235
|
+
`unslop`, `max_revisions`, `outline`, `force`, `rubric` (`tech-accuracy`), `agent`,
|
|
236
|
+
`cover_enabled`, `inline_images`, `publish_targets`, `publish_live`, `source_locale`. The judge
|
|
237
|
+
verdict lands at `$work_dir/.itc-verdict.json` (fixed path inside the workflow).
|
|
224
238
|
|
|
225
239
|
```bash
|
|
226
240
|
spur workflow run "$dest" --vars \
|
|
227
|
-
"{\"topic\":\"$TOPIC\",\"input_file\":\"$INPUT_FILE\",\"work_dir\":\"$work_dir\",\"writer\":\"$WRITER\",\"playbook\":\"$PLAYBOOK\",\"research\":\"$RESEARCH\",\"judge\":\"$JUDGE\",\"outline\":\"$OUTLINE\",\"force\":\"$FORCE\",\"rubric\":\"tech-accuracy\",\"agent\":\"$AGENT\"}"
|
|
241
|
+
"{\"topic\":\"$TOPIC\",\"input_file\":\"$INPUT_FILE\",\"work_dir\":\"$work_dir\",\"writer\":\"$WRITER\",\"playbook\":\"$PLAYBOOK\",\"research\":\"$RESEARCH\",\"judge\":\"$JUDGE\",\"auto\":\"$AUTO\",\"unslop\":\"$UNSLOP\",\"max_revisions\":\"$MAX_REVISIONS\",\"outline\":\"$OUTLINE\",\"force\":\"$FORCE\",\"rubric\":\"tech-accuracy\",\"agent\":\"$AGENT\",\"cover_enabled\":\"$COVER\",\"inline_images\":\"$INLINE_IMAGES\",\"publish_targets\":\"$TARGETS\",\"publish_live\":\"$LIVE\",\"source_locale\":\"$SOURCE_LOCALE\"}"
|
|
228
242
|
```
|
|
229
243
|
|
|
230
244
|
### Profile `kk-solo-podcast`
|
package/plugins/kk/plugin.json
CHANGED