@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.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "plugins": [
8
8
  {
9
- "version": "0.0.19",
9
+ "version": "0.0.21",
10
10
  "name": "kk",
11
11
  "source": "./plugins/kk",
12
12
  "description": "knowledge-kit — a set of toolkit for knowledge management"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/knowledge-kit",
3
- "version": "0.0.19",
3
+ "version": "0.0.21",
4
4
  "description": "An ingest → create → publish content pipeline CLI (Bun).",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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({"duration": duration, "transcription": transcription, "loudness_dip": dip})
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://techcrunch.com/category/artificial-intelligence/feed/";
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(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;|&apos;/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 TechCrunch AI) into Doc[]; legacy aihot.virxact.com API behind AIHOT_SOURCE=api"
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, high volume, reachable without auth). */
19
- export const AIHOT_FEED_URL_DEFAULT = 'https://techcrunch.com/category/artificial-intelligence/feed/';
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`, `DURATION` (`8`), `LANGUAGE` (`en`), `SCRIPT_APPROVED` (`true`|`false`),
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`, `outline`,
222
- `force`, `rubric` (`tech-accuracy`), `agent`. The judge verdict lands at `$work_dir/.itc-verdict.json`
223
- (fixed path inside the workflow).
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`
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "kk",
3
- "version": "0.0.19",
3
+ "version": "0.0.21",
4
4
  "description": "knowledge-kit Claude Code plugin: skills, commands, subagents, hooks, and rules"
5
5
  }