@gobing-ai/knowledge-kit 0.0.15 → 0.0.17

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.15",
9
+ "version": "0.0.17",
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.15",
3
+ "version": "0.0.17",
4
4
  "description": "An ingest → create → publish content pipeline CLI (Bun).",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21967,6 +21967,45 @@ function sourceLabelOf(doc2) {
21967
21967
  return "\u6765\u6E90";
21968
21968
  }
21969
21969
  }
21970
+ var SCAFFOLD_LABELS = ["\u53EF\u505A\u89D2\u5EA6", "\u4E3A\u4EC0\u4E48\u73B0\u5728\u503C\u5F97\u6CE8\u610F", "\u793E\u533A\u8BA8\u8BBA", "\u80CC\u666F", "\u6F5C\u5728\u5F71\u54CD", "\u5F71\u54CD"];
21971
+ var HEDGING_MARKERS = ["\u8BFB\u8005\u5B9C\u53E6\u884C\u6838\u5B9E", "\u8BFB\u8005\u6700\u597D", "\u4ECD\u5F85\u9A8C\u8BC1", "\u5C1A\u5F85\u9A8C\u8BC1", "\u9700\u53E6\u884C\u6838\u5B9E"];
21972
+ function scaffoldPrefixLength(text) {
21973
+ const match = text.match(/^(?:#{1,6}\s*)?(?:\*\*)?\s*[\u300C\u300E]?\s*([^\u300D\u300F*\uFF1A:\n]{2,})\s*[\u300D\u300F]?\s*(?:\*\*)?\s*[\uFF1A:]?\s*/);
21974
+ if (match === null || !SCAFFOLD_LABELS.includes((match[1] ?? "").trim()))
21975
+ return 0;
21976
+ return match[0].length;
21977
+ }
21978
+ function collapseScaffoldLabels(text) {
21979
+ let rest = text;
21980
+ for (;; ) {
21981
+ const next = rest.slice(scaffoldPrefixLength(rest));
21982
+ if (next === rest)
21983
+ return rest;
21984
+ rest = next;
21985
+ }
21986
+ }
21987
+ function stripHedging(text) {
21988
+ if (!HEDGING_MARKERS.some((marker) => text.includes(marker)))
21989
+ return text;
21990
+ return text.split(/(?<=\u3002)/).filter((sentence) => !HEDGING_MARKERS.some((marker) => sentence.includes(marker))).join("").trim();
21991
+ }
21992
+ function stripScaffold(body) {
21993
+ const kept = [];
21994
+ for (const line of body.split(`
21995
+ `)) {
21996
+ const trimmed = line.trim();
21997
+ if (trimmed === "") {
21998
+ kept.push(line);
21999
+ continue;
22000
+ }
22001
+ const collapsed = stripHedging(collapseScaffoldLabels(trimmed));
22002
+ if (collapsed === "")
22003
+ continue;
22004
+ kept.push(collapsed === trimmed ? line : collapsed);
22005
+ }
22006
+ return kept.join(`
22007
+ `);
22008
+ }
21970
22009
  function renderDoc(doc2, index) {
21971
22010
  const heading = doc2.title?.trim() || `\u65B0\u95FB ${index + 1}`;
21972
22011
  const link = doc2.sourceUri ? `[\u6765\u6E90\uFF1A${sourceLabelOf(doc2)}](${doc2.sourceUri})` : "";
@@ -21977,7 +22016,7 @@ function renderDoc(doc2, index) {
21977
22016
 
21978
22017
  ${metaLine ? `${metaLine}
21979
22018
 
21980
- ` : ""}${(doc2.body ?? "").trim()}`;
22019
+ ` : ""}${stripScaffold(doc2.body ?? "").trim()}`;
21981
22020
  }
21982
22021
  function renderDailyArticle(docs, date6) {
21983
22022
  const items = docs.map(renderDoc).join(`
@@ -22043,6 +22082,7 @@ if (import.meta.main) {
22043
22082
  process.exit(await main2());
22044
22083
  }
22045
22084
  export {
22085
+ stripScaffold,
22046
22086
  renderDailyArticle,
22047
22087
  processGeneratorIO,
22048
22088
  main2 as main,
@@ -8,7 +8,8 @@ import { echoError } from '@gobing-ai/ts-utils';
8
8
  * daily-article-gen: render a merged `Doc[]` into one readable daily AI-news
9
9
  * article. Mechanical template fill — no LLM, no STORM. Every news item keeps
10
10
  * a human-clickable source link (`[来源](sourceUri)`) so readers can trace
11
- * claims to their origin.
11
+ * claims to their origin. Research scaffolding is stripped before render
12
+ * (task 0138 R4b — see `stripScaffold`).
12
13
  *
13
14
  * Run date: `ARTICLE_DATE` env var (YYYYMMDD or ISO), else today (UTC).
14
15
  * Language-neutral: the Doc bodies already carry the summary language.
@@ -26,6 +27,71 @@ function sourceLabelOf(doc: Doc): string {
26
27
  }
27
28
  }
28
29
 
30
+ /**
31
+ * Research-scaffold labels that must never reach reader copy (task 0138 R4b). The last30days
32
+ * research style emits them as `**「label」**` prefixes; the plan-translate prompt forbids them,
33
+ * but this deterministic pass catches what the agent misses. Grows with sources.
34
+ */
35
+ const SCAFFOLD_LABELS = ['可做角度', '为什么现在值得注意', '社区讨论', '背景', '潜在影响', '影响'];
36
+
37
+ /** Self-referential hedging markers — research voice, not news copy (task 0138 R4b). */
38
+ const HEDGING_MARKERS = ['读者宜另行核实', '读者最好', '仍待验证', '尚待验证', '需另行核实'];
39
+
40
+ /**
41
+ * Length of a leading scaffold label in any shape the research bodies use — `**「背景」**`,
42
+ * `「背景」`, `**背景**`, `### 背景`, `背景:` — or 0 when the line does not open with a known label.
43
+ * The capture is greedy and unbounded on purpose: it must reach the whole label (so `可做` cannot
44
+ * satisfy it where `可做角度` is the real one) and must not impose a length the label list outgrows.
45
+ * The `SCAFFOLD_LABELS` membership check, not the regex, decides.
46
+ */
47
+ function scaffoldPrefixLength(text: string): number {
48
+ const match = text.match(
49
+ /^(?:#{1,6}\s*)?(?:\*\*)?\s*[「『]?\s*([^」』*::\n]{2,})\s*[」』]?\s*(?:\*\*)?\s*[::]?\s*/,
50
+ );
51
+ if (match === null || !SCAFFOLD_LABELS.includes((match[1] ?? '').trim())) return 0;
52
+ return match[0].length;
53
+ }
54
+
55
+ /** Drop every leading scaffold label, keeping the prose after it (`**「可做角度」** 可做角度:X` -> `X`). */
56
+ function collapseScaffoldLabels(text: string): string {
57
+ let rest = text;
58
+ for (;;) {
59
+ const next = rest.slice(scaffoldPrefixLength(rest));
60
+ if (next === rest) return rest;
61
+ rest = next;
62
+ }
63
+ }
64
+
65
+ /** Drop the sentences carrying a hedging marker; a line that is only hedging collapses to ''. */
66
+ function stripHedging(text: string): string {
67
+ if (!HEDGING_MARKERS.some((marker) => text.includes(marker))) return text;
68
+ return text
69
+ .split(/(?<=。)/)
70
+ .filter((sentence) => !HEDGING_MARKERS.some((marker) => sentence.includes(marker)))
71
+ .join('')
72
+ .trim();
73
+ }
74
+
75
+ /**
76
+ * Strip research scaffolding from a Doc body before it reaches reader copy (task 0138 R4b):
77
+ * standalone label lines are dropped, inline label prefixes collapse to their prose, and hedging
78
+ * sentences are removed. A clean body passes through byte-identical.
79
+ */
80
+ export function stripScaffold(body: string): string {
81
+ const kept: string[] = [];
82
+ for (const line of body.split('\n')) {
83
+ const trimmed = line.trim();
84
+ if (trimmed === '') {
85
+ kept.push(line); // paragraph breaks survive untouched
86
+ continue;
87
+ }
88
+ const collapsed = stripHedging(collapseScaffoldLabels(trimmed));
89
+ if (collapsed === '') continue;
90
+ kept.push(collapsed === trimmed ? line : collapsed);
91
+ }
92
+ return kept.join('\n');
93
+ }
94
+
29
95
  /** Render one news item section: heading, source link, and summary body. */
30
96
  function renderDoc(doc: Doc, index: number): string {
31
97
  const heading = doc.title?.trim() || `新闻 ${index + 1}`;
@@ -33,7 +99,7 @@ function renderDoc(doc: Doc, index: number): string {
33
99
  const meta = doc.metadata as Record<string, unknown> | undefined;
34
100
  const publishedAt = typeof meta?.publishedAt === 'string' ? meta.publishedAt.slice(0, 10) : '';
35
101
  const metaLine = [link, publishedAt].filter(Boolean).join(' · ');
36
- return `## ${index + 1}. ${heading}\n\n${metaLine ? `${metaLine}\n\n` : ''}${(doc.body ?? '').trim()}`;
102
+ return `## ${index + 1}. ${heading}\n\n${metaLine ? `${metaLine}\n\n` : ''}${stripScaffold(doc.body ?? '').trim()}`;
37
103
  }
38
104
 
39
105
  /** Render the article markdown for a merged Doc[] (pure — exported for tests). */
@@ -21999,34 +21999,85 @@ function normalizeBroadcastText(raw, isZh) {
21999
21999
  }
22000
22000
  return text;
22001
22001
  }
22002
- function synthesizePersonalUnderstanding(title, body, isZh) {
22002
+ function synthesizePersonalUnderstanding(title, body, isZh, used) {
22003
22003
  const combined = `${title} ${body}`.toLowerCase();
22004
- if (isZh) {
22005
- if (/jakub|szymon|\u9996\u5E2D\u79D1\u5B66\u5BB6|\u521B\u59CB\u56E2\u961F|\u6280\u672F\u9886\u8896|\u9EC4\u91D1\u642D\u6863/i.test(combined)) {
22006
- return "\u5728\u5404\u79CD\u6280\u672F\u5149\u73AF\u80CC\u540E\uFF0C\u6B63\u662F\u8FD9\u6837\u4E13\u6CE8\u800C\u4E0D\u77E5\u75B2\u5026\u7684\u5E95\u5C42\u653B\u575A\u8005\uFF0C\u4E3A\u4ECA\u5929\u6574\u4E2A\u5927\u6A21\u578B\u751F\u6001\u5960\u5B9A\u4E86\u6700\u575A\u5B9E\u7684\u6280\u672F\u57FA\u77F3\u3002";
22007
- }
22008
- if (/\u7B97\u529B|\u5409\u74E6|\u5DE5\u5382|\u7535\u529B|\u82AF\u7247|\u57FA\u7840\u8BBE\u65BD|\u57FA\u5EFA|\u786C\u4EF6|\u80FD\u6E90/i.test(combined)) {
22009
- return "\u8FD9\u5176\u5B9E\u91CA\u653E\u4E86\u4E00\u4E2A\u975E\u5E38\u660E\u786E\u7684\u4FE1\u53F7\uFF1AAI\u7ADE\u4E89\u7684\u4E0B\u534A\u573A\uFF0C\u5DF2\u7ECF\u4E0D\u518D\u53EA\u662F\u5355\u7EAF\u62FC\u6A21\u578B\u53C2\u6570\uFF0C\u66F4\u662F\u7535\u529B\u3001\u80FD\u6E90\u548C\u5168\u6808\u5DE5\u4E1A\u57FA\u7840\u8BBE\u65BD\u7684\u786C\u6838\u8F83\u91CF\u3002";
22010
- }
22011
- if (/sora|\u89C6\u9891|\u751F\u6210|cameo|chatgpt/i.test(combined)) {
22012
- return "\u6211\u4E2A\u4EBA\u89C9\u5F97\uFF0C\u8FD9\u6B21\u6700\u5927\u7684\u770B\u70B9\u662F\u5185\u5BB9\u521B\u4F5C\u95E8\u69DB\u7684\u518D\u6B21\u964D\u4F4E\u3002\u4E0D\u8FC7\u6B63\u5982\u56E2\u961F\u63D0\u5230\u7684\uFF0C\u5982\u4F55\u5E73\u8861\u521B\u4F5C\u81EA\u7531\u4E0E\u9632\u6B62\u4F4E\u8D28\u5185\u5BB9\u6CDB\u6EE5\uFF0C\u4F1A\u662F\u6574\u4E2A\u884C\u4E1A\u63A5\u4E0B\u6765\u6700\u503C\u5F97\u89C2\u5BDF\u7684\u5173\u952E\u8003\u9898\u3002";
22013
- }
22014
- if (/\u878D\u8D44|\u6295\u8D44|\u5E02\u573A|\u5546\u4E1A|\u4F30\u503C|\u5408\u4F5C/i.test(combined)) {
22015
- return "\u8FD9\u9879\u8FDB\u5C55\u53CD\u6620\u51FA\u8D44\u672C\u4E0E\u4EA7\u4E1A\u754C\u6B63\u5728\u52A0\u901F\u5F62\u6210\u5408\u529B\uFF0C\u540E\u7EED\u5B9E\u9645\u4E1A\u52A1\u573A\u666F\u7684\u843D\u5730\u6548\u679C\u975E\u5E38\u503C\u5F97\u6211\u4EEC\u6301\u7EED\u8DDF\u8FDB\u3002";
22016
- }
22017
- return "\u8FD9\u9879\u65B0\u52A8\u6001\u53CD\u6620\u51FA\u6280\u672F\u6F14\u8FDB\u6B63\u5728\u8FDB\u4E00\u6B65\u63D0\u901F\uFF0C\u5BF9\u76F8\u5173\u9886\u57DF\u7684\u5B9E\u9645\u5F71\u54CD\u4E5F\u975E\u5E38\u503C\u5F97\u6211\u4EEC\u6301\u7EED\u5173\u6CE8\u3002";
22004
+ const line = isZh ? zhInsightFor(combined) : enInsightFor(combined);
22005
+ if (used.has(line))
22006
+ return null;
22007
+ used.add(line);
22008
+ return line;
22009
+ }
22010
+ function zhInsightFor(combined) {
22011
+ if (/jakub|szymon|\u9996\u5E2D\u79D1\u5B66\u5BB6|\u521B\u59CB\u56E2\u961F|\u6280\u672F\u9886\u8896|\u9EC4\u91D1\u642D\u6863/i.test(combined)) {
22012
+ return "\u5728\u5404\u79CD\u6280\u672F\u5149\u73AF\u80CC\u540E\uFF0C\u6B63\u662F\u8FD9\u6837\u4E13\u6CE8\u800C\u4E0D\u77E5\u75B2\u5026\u7684\u5E95\u5C42\u653B\u575A\u8005\uFF0C\u4E3A\u4ECA\u5929\u6574\u4E2A\u5927\u6A21\u578B\u751F\u6001\u5960\u5B9A\u4E86\u6700\u575A\u5B9E\u7684\u6280\u672F\u57FA\u77F3\u3002";
22013
+ }
22014
+ if (/\u7B97\u529B|\u5409\u74E6|\u7535\u529B|\u80FD\u6E90|\u6570\u636E\u4E2D\u5FC3|\u57FA\u7840\u8BBE\u65BD|\u57FA\u5EFA/.test(combined)) {
22015
+ return "\u8FD9\u5176\u5B9E\u91CA\u653E\u4E86\u4E00\u4E2A\u975E\u5E38\u660E\u786E\u7684\u4FE1\u53F7\uFF1AAI\u7ADE\u4E89\u7684\u4E0B\u534A\u573A\uFF0C\u5DF2\u7ECF\u4E0D\u518D\u53EA\u662F\u5355\u7EAF\u62FC\u6A21\u578B\u53C2\u6570\uFF0C\u66F4\u662F\u7535\u529B\u3001\u80FD\u6E90\u548C\u5168\u6808\u5DE5\u4E1A\u57FA\u7840\u8BBE\u65BD\u7684\u786C\u6838\u8F83\u91CF\u3002";
22016
+ }
22017
+ if (/\u82AF\u7247|\u786C\u4EF6|\u5904\u7406\u5668|enclave/i.test(combined)) {
22018
+ return "\u771F\u6B63\u88AB\u91CD\u65B0\u5B9A\u4E49\u7684\uFF0C\u662F\u786C\u4EF6\u5C42\u627F\u62C5\u7684\u5B89\u5168\u4E0E\u7B97\u529B\u8D23\u4EFB\uFF1A\u628A\u80FD\u529B\u505A\u8FDB\u7845\u7247\u5C42\u66F4\u53EF\u9760\u3001\u4E5F\u66F4\u96BE\u7ED5\u8FC7\uFF0C\u4F46\u751F\u6001\u9501\u5B9A\u7684\u4EE3\u4EF7\u9700\u8981\u4E00\u8D77\u7B97\u8FDB\u53BB\u3002";
22018
22019
  }
22020
+ if (/sora|\u89C6\u9891|\u751F\u6210\u89C6\u9891|\u89C6\u9891\u751F\u6210|\u6587\u751F\u89C6\u9891|\u5185\u5BB9\u521B\u4F5C|cameo/i.test(combined)) {
22021
+ return "\u6211\u4E2A\u4EBA\u89C9\u5F97\uFF0C\u8FD9\u6B21\u6700\u5927\u7684\u770B\u70B9\u662F\u5185\u5BB9\u521B\u4F5C\u95E8\u69DB\u7684\u518D\u6B21\u964D\u4F4E\u3002\u4E0D\u8FC7\u6B63\u5982\u56E2\u961F\u63D0\u5230\u7684\uFF0C\u5982\u4F55\u5E73\u8861\u521B\u4F5C\u81EA\u7531\u4E0E\u9632\u6B62\u4F4E\u8D28\u5185\u5BB9\u6CDB\u6EE5\uFF0C\u4F1A\u662F\u6574\u4E2A\u884C\u4E1A\u63A5\u4E0B\u6765\u6700\u503C\u5F97\u89C2\u5BDF\u7684\u5173\u952E\u8003\u9898\u3002";
22022
+ }
22023
+ if (/\u878D\u8D44|\u6295\u8D44|\u5E02\u573A|\u5546\u4E1A|\u4F30\u503C|\u5408\u4F5C/.test(combined)) {
22024
+ return "\u8FD9\u9879\u8FDB\u5C55\u53CD\u6620\u51FA\u8D44\u672C\u4E0E\u4EA7\u4E1A\u754C\u6B63\u5728\u52A0\u901F\u5F62\u6210\u5408\u529B\uFF0C\u540E\u7EED\u5B9E\u9645\u4E1A\u52A1\u573A\u666F\u7684\u843D\u5730\u6548\u679C\u975E\u5E38\u503C\u5F97\u6211\u4EEC\u6301\u7EED\u8DDF\u8FDB\u3002";
22025
+ }
22026
+ return "\u8FD9\u9879\u65B0\u52A8\u6001\u53CD\u6620\u51FA\u6280\u672F\u6F14\u8FDB\u6B63\u5728\u8FDB\u4E00\u6B65\u63D0\u901F\uFF0C\u5BF9\u76F8\u5173\u9886\u57DF\u7684\u5B9E\u9645\u5F71\u54CD\u4E5F\u975E\u5E38\u503C\u5F97\u6211\u4EEC\u6301\u7EED\u5173\u6CE8\u3002";
22027
+ }
22028
+ function enInsightFor(combined) {
22019
22029
  if (/jakub|szymon|chief scientist|founding team|technical leader|partnership/i.test(combined)) {
22020
22030
  return "Behind the headlines, it is relentless technical leadership and deep engineering rigor that continue to drive these foundational milestones.";
22021
22031
  }
22022
- if (/compute|gigawatt|factory|power|chip|infrastructure|datacenter/i.test(combined)) {
22032
+ if (/compute|gigawatt|power|energy|datacenter|infrastructure/.test(combined)) {
22023
22033
  return "This signals clearly that the AI frontier is no longer just about algorithms, but a massive contest of power, energy, and full-stack industrial engineering.";
22024
22034
  }
22025
- if (/sora|video|generation|cameo|chatgpt/i.test(combined)) {
22035
+ if (/chip|hardware|processor|enclave/i.test(combined)) {
22036
+ return "What stands out is the hardware layer quietly taking on the security and compute burden: putting the guarantee in silicon is harder to bypass, and it carries its own lock-in cost.";
22037
+ }
22038
+ if (/sora|video|video generation|content creation|cameo/i.test(combined)) {
22026
22039
  return "In my view, the biggest takeaway here is the dramatic lowering of creation barriers, while content quality governance will be the key test ahead.";
22027
22040
  }
22028
22041
  return "This development highlights how rapidly the ecosystem is moving from experimental proofs of concept into large-scale execution.";
22029
22042
  }
22043
+ var MAX_FACT_SEGMENT_CHARS = 300;
22044
+ var TITLE_ECHO_NOISE_RE = /[\uFF0C\u3002\u3001\uFF1A:\uFF1B;!\uFF01?\uFF1F\u2026\s\u300C\u300D\u300E\u300F\u3010\u3011]/g;
22045
+ function stripLeadingTitleEcho(body, title) {
22046
+ const wanted = title.replace(TITLE_ECHO_NOISE_RE, "");
22047
+ if (wanted.length < 4)
22048
+ return body;
22049
+ let matched = "";
22050
+ let end = -1;
22051
+ for (let i = 0;i < body.length && matched.length < wanted.length; i++) {
22052
+ const kept = (body[i] ?? "").replace(TITLE_ECHO_NOISE_RE, "");
22053
+ if (kept === "")
22054
+ continue;
22055
+ matched += kept;
22056
+ if (matched.length >= wanted.length)
22057
+ end = i + 1;
22058
+ }
22059
+ if (end === -1 || matched !== wanted)
22060
+ return body;
22061
+ return body.slice(end).replace(/^[\uFF0C\u3002\u3001\uFF1A:\uFF1B;!\uFF01?\uFF1F\u2026\s\u300C\u300D\u300E\u300F\u3010\u3011]+/, "");
22062
+ }
22063
+ function splitFactText(text, maxChars = MAX_FACT_SEGMENT_CHARS) {
22064
+ if (text.length <= maxChars)
22065
+ return [text];
22066
+ const clauses = text.match(/[^\u3002\uFF01\uFF1F\u2026\uFF1B]+[\u3002\uFF01\uFF1F\u2026\uFF1B]*|[\s\S]/g) ?? [text];
22067
+ const chunks = [];
22068
+ let current = "";
22069
+ for (const clause of clauses) {
22070
+ if (current !== "" && current.length + clause.length > maxChars) {
22071
+ chunks.push(current);
22072
+ current = clause;
22073
+ continue;
22074
+ }
22075
+ current += clause;
22076
+ }
22077
+ if (current !== "")
22078
+ chunks.push(current);
22079
+ return chunks.length > 0 ? chunks : [text];
22080
+ }
22030
22081
  function buildNewsVoiceScript(docs, options) {
22031
22082
  const language = exports_external.enum(["zh", "en"]).parse((options?.language || "zh").toLowerCase());
22032
22083
  const isZh = language === "zh";
@@ -22086,26 +22137,31 @@ function buildNewsVoiceScript(docs, options) {
22086
22137
  gap_ms: 700,
22087
22138
  emotion: "confident, warm"
22088
22139
  });
22140
+ const usedInsights = new Set;
22089
22141
  docs.forEach((doc2, idx) => {
22090
22142
  const isLast = idx === docs.length - 1 && docs.length > 1;
22091
22143
  const cleanTitle = (doc2.title ?? "").trim();
22092
- const cleanBody = (doc2.body ?? "").trim();
22144
+ const cleanBody = stripLeadingTitleEcho((doc2.body ?? "").trim(), cleanTitle);
22093
22145
  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}.`;
22094
22146
  const normalizedLead = normalizeBroadcastText(lead, isZh);
22095
- const normalizedBody = normalizeBroadcastText(cleanBody, isZh);
22096
- const insight = synthesizePersonalUnderstanding(cleanTitle, cleanBody, isZh);
22097
- const normalizedInsight = normalizeBroadcastText(insight, isZh);
22147
+ const normalizedBody = cleanBody === "" ? "" : normalizeBroadcastText(cleanBody, isZh);
22148
+ const insight = synthesizePersonalUnderstanding(cleanTitle, cleanBody, isZh, usedInsights);
22149
+ const normalizedInsight = insight === null ? null : normalizeBroadcastText(insight, isZh);
22098
22150
  const factText = isZh ? `${normalizedLead}${normalizedBody}` : `${normalizedLead} ${normalizedBody}`;
22099
- segments.push({
22100
- text: factText,
22101
- gap_ms: 500,
22102
- emotion: "professional, clear"
22103
- });
22104
- segments.push({
22105
- text: normalizedInsight,
22106
- gap_ms: 800,
22107
- emotion: "thoughtful, personal"
22108
- });
22151
+ for (const chunk of splitFactText(factText)) {
22152
+ segments.push({
22153
+ text: chunk,
22154
+ gap_ms: 500,
22155
+ emotion: "professional, clear"
22156
+ });
22157
+ }
22158
+ if (normalizedInsight !== null) {
22159
+ segments.push({
22160
+ text: normalizedInsight,
22161
+ gap_ms: 800,
22162
+ emotion: "thoughtful, personal"
22163
+ });
22164
+ }
22109
22165
  });
22110
22166
  const outroText = isZh ? `\u597D\u4E86\uFF0C\u4EE5\u4E0A\u5C31\u662F\u4ECA\u5929AI\u79D1\u6280\u6F2B\u8C08\u7684\u5168\u90E8\u5185\u5BB9\u3002\u4E0D\u77E5\u9053\u4ECA\u5929\u8FD9\u51E0\u6761\u6D88\u606F\u6709\u6CA1\u6709\u7ED9\u60A8\u5E26\u6765\u4E00\u4E9B\u65B0\u7684\u542F\u53D1\uFF1F\u611F\u8C22\u60A8\u7684\u6536\u542C\uFF0C\u6211\u662F${hostName}\uFF0C\u795D\u60A8\u62E5\u6709\u5145\u5B9E\u9AD8\u6548\u7684\u4E00\u5929\uFF0C\u6211\u4EEC\u4E0B\u671F\u8282\u76EE\u4E0D\u89C1\u4E0D\u6563\uFF01` : `That wraps up today's AI Daily Briefing. I hope today's highlights gave you some useful takeaways. Thank you for listening. This is ${hostName}, wishing you a productive and wonderful day. Until next time!`;
22111
22167
  segments.push({
@@ -22330,6 +22386,8 @@ if (import.meta.main) {
22330
22386
  }
22331
22387
  export {
22332
22388
  synthesizePersonalUnderstanding,
22389
+ stripLeadingTitleEcho,
22390
+ splitFactText,
22333
22391
  processGeneratorIO,
22334
22392
  normalizeBroadcastText,
22335
22393
  main2 as main,
@@ -22340,5 +22398,6 @@ export {
22340
22398
  VoiceSpeakerSchema,
22341
22399
  VoiceScriptSegmentSchema,
22342
22400
  VoiceScriptSchema,
22401
+ MAX_FACT_SEGMENT_CHARS,
22343
22402
  EffectConfigSchema
22344
22403
  };
@@ -144,39 +144,131 @@ export function normalizeBroadcastText(raw: string, isZh: boolean): string {
144
144
  /**
145
145
  * Synthesizes a natural, concise spoken perspective / takeaway based on the story facts.
146
146
  * Gives the monologue a demonstrative tech podcast talk feel rather than mechanical news reading.
147
+ *
148
+ * `used` is the per-episode set of lines already spoken (task 0139 R2). A colliding item gets
149
+ * `null` — no commentary segment at all — because a repeated line is worse than none and a
150
+ * second-choice line would be a different topic misfire (Q&A item 5).
147
151
  */
148
- export function synthesizePersonalUnderstanding(title: string, body: string, isZh: boolean): string {
152
+ export function synthesizePersonalUnderstanding(
153
+ title: string,
154
+ body: string,
155
+ isZh: boolean,
156
+ used: Set<string>,
157
+ ): string | null {
149
158
  const combined = `${title} ${body}`.toLowerCase();
159
+ const line = isZh ? zhInsightFor(combined) : enInsightFor(combined);
160
+ if (used.has(line)) return null;
161
+ used.add(line);
162
+ return line;
163
+ }
150
164
 
151
- if (isZh) {
152
- if (/jakub|szymon|首席科学家|创始团队|技术领袖|黄金搭档/i.test(combined)) {
153
- return '在各种技术光环背后,正是这样专注而不知疲倦的底层攻坚者,为今天整个大模型生态奠定了最坚实的技术基石。';
154
- }
155
- if (/算力|吉瓦|工厂|电力|芯片|基础设施|基建|硬件|能源/i.test(combined)) {
156
- return '这其实释放了一个非常明确的信号:AI竞争的下半场,已经不再只是单纯拼模型参数,更是电力、能源和全栈工业基础设施的硬核较量。';
157
- }
158
- if (/sora|视频|生成|cameo|chatgpt/i.test(combined)) {
159
- return '我个人觉得,这次最大的看点是内容创作门槛的再次降低。不过正如团队提到的,如何平衡创作自由与防止低质内容泛滥,会是整个行业接下来最值得观察的关键考题。';
160
- }
161
- if (/融资|投资|市场|商业|估值|合作/i.test(combined)) {
162
- return '这项进展反映出资本与产业界正在加速形成合力,后续实际业务场景的落地效果非常值得我们持续跟进。';
163
- }
164
- return '这项新动态反映出技术演进正在进一步提速,对相关领域的实际影响也非常值得我们持续关注。';
165
+ /**
166
+ * Bucket order is first-match-wins, and the buckets are deliberately disjoint (task 0139 R2).
167
+ * The energy line requires energy-side keywords: the pre-fix bucket matched bare
168
+ * `芯片|硬件|工厂`, so it fired after the Nvidia-regulation and Apple Secure-Enclave items on the
169
+ * 2026-09-16 run. Hardware/chip content now has its own line instead.
170
+ */
171
+ function zhInsightFor(combined: string): string {
172
+ if (/jakub|szymon|首席科学家|创始团队|技术领袖|黄金搭档/i.test(combined)) {
173
+ return '在各种技术光环背后,正是这样专注而不知疲倦的底层攻坚者,为今天整个大模型生态奠定了最坚实的技术基石。';
174
+ }
175
+ if (/算力|吉瓦|电力|能源|数据中心|基础设施|基建/.test(combined)) {
176
+ return '这其实释放了一个非常明确的信号:AI竞争的下半场,已经不再只是单纯拼模型参数,更是电力、能源和全栈工业基础设施的硬核较量。';
165
177
  }
178
+ if (/芯片|硬件|处理器|enclave/i.test(combined)) {
179
+ return '真正被重新定义的,是硬件层承担的安全与算力责任:把能力做进硅片层更可靠、也更难绕过,但生态锁定的代价需要一起算进去。';
180
+ }
181
+ // Video/creation only — bare `生成` matched every generative-AI body (Meta and both Gemini
182
+ // items on the 2026-09-16 run) and bare `chatgpt` matched the Meta item, which is what made
183
+ // this line a topic misfire. A product name is not a creation-barrier story.
184
+ if (/sora|视频|生成视频|视频生成|文生视频|内容创作|cameo/i.test(combined)) {
185
+ return '我个人觉得,这次最大的看点是内容创作门槛的再次降低。不过正如团队提到的,如何平衡创作自由与防止低质内容泛滥,会是整个行业接下来最值得观察的关键考题。';
186
+ }
187
+ if (/融资|投资|市场|商业|估值|合作/.test(combined)) {
188
+ return '这项进展反映出资本与产业界正在加速形成合力,后续实际业务场景的落地效果非常值得我们持续跟进。';
189
+ }
190
+ return '这项新动态反映出技术演进正在进一步提速,对相关领域的实际影响也非常值得我们持续关注。';
191
+ }
166
192
 
167
- // English spoken insights
193
+ /** English mirror of {@link zhInsightFor} — same bucket order, same disjointness rule. */
194
+ function enInsightFor(combined: string): string {
168
195
  if (/jakub|szymon|chief scientist|founding team|technical leader|partnership/i.test(combined)) {
169
196
  return 'Behind the headlines, it is relentless technical leadership and deep engineering rigor that continue to drive these foundational milestones.';
170
197
  }
171
- if (/compute|gigawatt|factory|power|chip|infrastructure|datacenter/i.test(combined)) {
198
+ if (/compute|gigawatt|power|energy|datacenter|infrastructure/.test(combined)) {
172
199
  return 'This signals clearly that the AI frontier is no longer just about algorithms, but a massive contest of power, energy, and full-stack industrial engineering.';
173
200
  }
174
- if (/sora|video|generation|cameo|chatgpt/i.test(combined)) {
201
+ if (/chip|hardware|processor|enclave/i.test(combined)) {
202
+ return 'What stands out is the hardware layer quietly taking on the security and compute burden: putting the guarantee in silicon is harder to bypass, and it carries its own lock-in cost.';
203
+ }
204
+ if (/sora|video|video generation|content creation|cameo/i.test(combined)) {
175
205
  return 'In my view, the biggest takeaway here is the dramatic lowering of creation barriers, while content quality governance will be the key test ahead.';
176
206
  }
177
207
  return 'This development highlights how rapidly the ecosystem is moving from experimental proofs of concept into large-scale execution.';
178
208
  }
179
209
 
210
+ /**
211
+ * Longest fact segment the script emits (task 0139 R4).
212
+ * ponytail: observed zh speech rates 3.4–6.1 chars/s put 300 chars at 49–88s per chunk; raise only
213
+ * with measured evidence. 400 would reach ~117s at the slowest observed rate, where whisper
214
+ * transcription fidelity collapsed to 0.25–0.41 on the 2026-09-16 run.
215
+ */
216
+ export const MAX_FACT_SEGMENT_CHARS = 300;
217
+
218
+ /** Punctuation and space the translator drifts between a title and its body restatement. */
219
+ const TITLE_ECHO_NOISE_RE = /[,。、::;;!!??…\s「」『』【】]/g;
220
+
221
+ /**
222
+ * Drop a leading restatement of the item title from its body (task 0139 R1).
223
+ *
224
+ * `plan-translate` derives each title from the body's first sentence, so the echo is always a
225
+ * prefix with at most punctuation drift (`:` → `,`) — the rule is a deterministic significant-
226
+ * character prefix compare, never a fuzzy/LLM similarity match (anti-pattern list). The body is
227
+ * returned untouched whenever the normalized prefix does not equal the normalized title, and a
228
+ * body that is *only* the restatement collapses to the empty string (the lead alone carries it).
229
+ */
230
+ export function stripLeadingTitleEcho(body: string, title: string): string {
231
+ const wanted = title.replace(TITLE_ECHO_NOISE_RE, '');
232
+ if (wanted.length < 4) return body; // a short title would match too much of the body
233
+ let matched = '';
234
+ let end = -1;
235
+ for (let i = 0; i < body.length && matched.length < wanted.length; i++) {
236
+ const kept = (body[i] ?? '').replace(TITLE_ECHO_NOISE_RE, '');
237
+ if (kept === '') continue; // drift punctuation between title and echo
238
+ matched += kept;
239
+ if (matched.length >= wanted.length) end = i + 1;
240
+ }
241
+ if (end === -1 || matched !== wanted) return body;
242
+ return body.slice(end).replace(/^[,。、::;;!!??…\s「」『』【】]+/, '');
243
+ }
244
+
245
+ /**
246
+ * Split a fact text into chunks of at most `maxChars` (task 0139 R4).
247
+ *
248
+ * Greedy packing at clause boundaries: a chunk closes when the next clause would push it past the
249
+ * cap, so `chunks.join('') === text` always holds — the `[\s\S]` alternative is what keeps leading
250
+ * or stray punctuation from being dropped by the match. `;` is a boundary too: a clause-joined
251
+ * sentence would otherwise stay whole and blow past the ~90s budget the cap exists to protect. A
252
+ * single clause longer than the cap is emitted whole — splitting mid-thought is worse than a long
253
+ * segment — and is left to the QC duration audit.
254
+ */
255
+ export function splitFactText(text: string, maxChars: number = MAX_FACT_SEGMENT_CHARS): string[] {
256
+ if (text.length <= maxChars) return [text];
257
+ const clauses = text.match(/[^。!?…;]+[。!?…;]*|[\s\S]/g) ?? [text];
258
+ const chunks: string[] = [];
259
+ let current = '';
260
+ for (const clause of clauses) {
261
+ if (current !== '' && current.length + clause.length > maxChars) {
262
+ chunks.push(current);
263
+ current = clause;
264
+ continue;
265
+ }
266
+ current += clause;
267
+ }
268
+ if (current !== '') chunks.push(current);
269
+ return chunks.length > 0 ? chunks : [text];
270
+ }
271
+
180
272
  export function buildNewsVoiceScript(docs: Doc[], options?: ScriptBuilderOptions): VoiceScript {
181
273
  const language = z.enum(['zh', 'en']).parse((options?.language || 'zh').toLowerCase());
182
274
  const isZh = language === 'zh';
@@ -259,10 +351,13 @@ export function buildNewsVoiceScript(docs: Doc[], options?: ScriptBuilderOptions
259
351
  });
260
352
 
261
353
  // 2. Story segments: demonstrative talk with facts + conversational insight
354
+ const usedInsights = new Set<string>(); // one per episode (task 0139 R2)
262
355
  docs.forEach((doc, idx) => {
263
356
  const isLast = idx === docs.length - 1 && docs.length > 1;
264
357
  const cleanTitle = (doc.title ?? '').trim();
265
- const cleanBody = (doc.body ?? '').trim();
358
+ // R1: drop the title restatement plan-translate prepends to the body, so the lead's
359
+ // 【title】 is the only place the title is spoken for this item.
360
+ const cleanBody = stripLeadingTitleEcho((doc.body ?? '').trim(), cleanTitle);
266
361
 
267
362
  // Conversational lead
268
363
  const lead = isZh
@@ -278,23 +373,28 @@ export function buildNewsVoiceScript(docs: Doc[], options?: ScriptBuilderOptions
278
373
  : `Next, turning our attention to ${cleanTitle}.`;
279
374
 
280
375
  const normalizedLead = normalizeBroadcastText(lead, isZh);
281
- const normalizedBody = normalizeBroadcastText(cleanBody, isZh);
282
- const insight = synthesizePersonalUnderstanding(cleanTitle, cleanBody, isZh);
283
- const normalizedInsight = normalizeBroadcastText(insight, isZh);
376
+ const normalizedBody = cleanBody === '' ? '' : normalizeBroadcastText(cleanBody, isZh);
377
+ const insight = synthesizePersonalUnderstanding(cleanTitle, cleanBody, isZh, usedInsights);
378
+ const normalizedInsight = insight === null ? null : normalizeBroadcastText(insight, isZh);
284
379
 
285
380
  const factText = isZh ? `${normalizedLead}${normalizedBody}` : `${normalizedLead} ${normalizedBody}`;
286
381
 
287
- // Emit facts followed by personal insight commentary
288
- segments.push({
289
- text: factText,
290
- gap_ms: 500, // Natural breathing pause before giving personal perspective
291
- emotion: 'professional, clear',
292
- });
293
- segments.push({
294
- text: normalizedInsight,
295
- gap_ms: 800, // Distinct 800ms pause before the next story
296
- emotion: 'thoughtful, personal',
297
- });
382
+ // R4: facts first, chunked so no single segment runs past ~90s of speech; then the insight
383
+ // only when this episode has not already spoken that line (R2 collision ⇒ no segment).
384
+ for (const chunk of splitFactText(factText)) {
385
+ segments.push({
386
+ text: chunk,
387
+ gap_ms: 500, // Natural breathing pause before giving personal perspective
388
+ emotion: 'professional, clear',
389
+ });
390
+ }
391
+ if (normalizedInsight !== null) {
392
+ segments.push({
393
+ text: normalizedInsight,
394
+ gap_ms: 800, // Distinct 800ms pause before the next story
395
+ emotion: 'thoughtful, personal',
396
+ });
397
+ }
298
398
  });
299
399
 
300
400
  // 3. Outro segment: Warm, conversational wrap-up
@@ -21968,6 +21968,7 @@ var SOURCE_BASE = {
21968
21968
  };
21969
21969
  var DEFAULT_SOURCE_BASE = 5;
21970
21970
  var TITLE_SIMILARITY_THRESHOLD = 0.6;
21971
+ var STUB_BODY_FLOOR = 300;
21971
21972
  var FRESHNESS_DAYS = 7;
21972
21973
  var PER_SOURCE_CAP = 5;
21973
21974
  var NON_NEWS_TAGS = new Set(["geopolitics", "military-conflict", "war"]);
@@ -22166,8 +22167,9 @@ function classifyCategory(doc2) {
22166
22167
  }
22167
22168
  function scoreDimensions(doc2, planDate) {
22168
22169
  const meta3 = asRecord(doc2.metadata);
22169
- let quality = 1 + Math.min(4, Math.round((doc2.body ?? "").length / 1500));
22170
- if (doc2.sourceUri)
22170
+ const bodyLen = (doc2.body ?? "").length;
22171
+ let quality = bodyLen < STUB_BODY_FLOOR ? 1 : 1 + Math.min(4, Math.round(bodyLen / 1500));
22172
+ if (doc2.sourceUri && bodyLen >= STUB_BODY_FLOOR)
22171
22173
  quality = Math.min(5, quality + 1);
22172
22174
  const source = typeof meta3.source === "string" ? meta3.source : "";
22173
22175
  let importance = IMPORTANCE_BASE[source] ?? DEFAULT_IMPORTANCE;
@@ -35,6 +35,11 @@ const SOURCE_BASE: Record<string, number> = {
35
35
  };
36
36
  const DEFAULT_SOURCE_BASE = 5;
37
37
  const TITLE_SIMILARITY_THRESHOLD = 0.6; // character-bigram Jaccard
38
+ // Minimum body length for any substance credit (task 0138 D2). Scoring runs on blend-time bodies:
39
+ // the 2026-09-16 aihot stubs are 234-255 EN chars, substantive items are >=348 — a headline-echo
40
+ // stub must not reach `qc_min_quality=2` on its sourceUri bonus alone.
41
+ // ponytail: re-calibrate with evidence if a source ships legitimate 256-347-char items.
42
+ const STUB_BODY_FLOOR = 300;
38
43
  const FRESHNESS_DAYS = 7; // a daily episode drops items published before this window (dogfood 20260906: aihot served a 2020-2025 blog archive as "selected")
39
44
  const PER_SOURCE_CAP = 5; // one feed can't flood the episode even when all its items score high
40
45
  const NON_NEWS_TAGS = new Set(['geopolitics', 'military-conflict', 'war']); // horizon finance-news tags (dogfood 20260906: Iran oil tankers in an AI episode)
@@ -308,8 +313,10 @@ export function classifyCategory(doc: Doc): Category {
308
313
  */
309
314
  export function scoreDimensions(doc: Doc, planDate: string): DimensionScores {
310
315
  const meta = asRecord(doc.metadata);
311
- let quality = 1 + Math.min(4, Math.round((doc.body ?? '').length / 1500));
312
- if (doc.sourceUri) quality = Math.min(5, quality + 1);
316
+ // The floor short-circuits the sourcedness bonus: "quality 1 regardless of sourceUri" (R2).
317
+ const bodyLen = (doc.body ?? '').length;
318
+ let quality = bodyLen < STUB_BODY_FLOOR ? 1 : 1 + Math.min(4, Math.round(bodyLen / 1500));
319
+ if (doc.sourceUri && bodyLen >= STUB_BODY_FLOOR) quality = Math.min(5, quality + 1);
313
320
 
314
321
  const source = typeof meta.source === 'string' ? meta.source : '';
315
322
  let importance = IMPORTANCE_BASE[source] ?? DEFAULT_IMPORTANCE;