@gobing-ai/knowledge-kit 0.1.9 → 0.1.11
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/dist/index.js +132 -25
- package/package.json +1 -1
- package/plugins/generations/content-gen/dist/index.js +4 -0
- package/plugins/generations/core-facts-gen/dist/index.js +4 -0
- package/plugins/generations/daily-article-gen/dist/index.js +4 -0
- package/plugins/generations/dailynews-gen/dist/index.js +4 -0
- package/plugins/generations/episode-plan-gen/dist/index.js +102 -1
- package/plugins/generations/episode-plan-gen/src/index.ts +2 -4
- package/plugins/generations/gemini-tts/dist/index.js +4 -0
- package/plugins/generations/image-gen/dist/index.js +4 -0
- package/plugins/generations/news-report-gen/dist/index.js +4 -0
- package/plugins/generations/voice-gen/dist/index.js +4 -0
- package/plugins/generations/voxcpm-tts/dist/index.js +4 -0
- package/plugins/ingestions/aihot-ingest/dist/index.js +4 -0
- package/plugins/ingestions/horizon-ingest/dist/index.js +4 -0
- package/plugins/ingestions/last30days-ingest/dist/index.js +11 -3
- package/plugins/ingestions/last30days-ingest/src/index.ts +14 -3
- package/plugins/ingestions/web-search/dist/index.js +4 -0
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/scripts/daily-scheduled-run.ts +298 -0
- package/plugins/kk/scripts/daily-stages.ts +1083 -49
- package/plugins/kk/scripts/kk-workflow-stages.ts +76 -10
- package/plugins/kk/scripts/safety-artifact-check.ts +235 -0
- package/plugins/kk/scripts/sentences.ts +134 -0
- package/plugins/kk/scripts/series-names.ts +124 -0
- package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +194 -113
- package/plugins/kk/workflows/kk-daily-safety.yaml +160 -36
- package/plugins/publishings/bilibili-pub/dist/index.js +6 -1
- package/plugins/publishings/bilibili-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/cnblogs-pub/dist/index.js +6 -1
- package/plugins/publishings/cnblogs-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/csdn-pub/dist/index.js +6 -1
- package/plugins/publishings/csdn-pub/dist/profile-check.js +6 -1
- package/plugins/publishings/emdash-pub/dist/index.js +4 -0
- package/plugins/publishings/infoq-pub/dist/index.js +6 -1
- package/plugins/publishings/infoq-pub/dist/profile-check.js +6 -1
- package/plugins/publishings/jike-pub/dist/index.js +6 -1
- package/plugins/publishings/jike-pub/dist/profile-check.js +6 -1
- package/plugins/publishings/juejin-pub/dist/index.js +6 -1
- package/plugins/publishings/juejin-pub/dist/profile-check.js +6 -1
- package/plugins/publishings/medium-pub/dist/index.js +4 -0
- package/plugins/publishings/podcast-pub/dist/index.js +151 -16
- package/plugins/publishings/podcast-pub/src/index.ts +62 -5
- package/plugins/publishings/podcast-pub/src/seo.ts +2 -4
- package/plugins/publishings/podcast-pub/src/show-notes.ts +3 -8
- package/plugins/publishings/qiita-pub/dist/index.js +4 -0
- package/plugins/publishings/shipinhao-pub/dist/index.js +6 -1
- package/plugins/publishings/shipinhao-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/sspai-pub/dist/index.js +6 -1
- package/plugins/publishings/sspai-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/substack-pub/dist/index.js +6 -1
- package/plugins/publishings/substack-pub/dist/profile-check.js +6 -1
- package/plugins/publishings/surfdash-pub/dist/index.js +691 -63
- package/plugins/publishings/surfdash-pub/src/digest-check.ts +284 -0
- package/plugins/publishings/surfdash-pub/src/index.ts +656 -84
- package/plugins/publishings/surfdash-pub/src/patch.ts +44 -7
- package/plugins/publishings/threads-pub/dist/index.js +4 -0
- package/plugins/publishings/wechatmp-pub/dist/index.js +937 -98
- package/plugins/publishings/wechatmp-pub/dist/profile-check.js +64 -3
- package/plugins/publishings/wechatmp-pub/src/flow.ts +1125 -85
- package/plugins/publishings/wechatmp-pub/src/index.ts +112 -30
- package/plugins/publishings/wechatmp-pub/src/page.ts +105 -0
- package/plugins/publishings/weibo-pub/dist/index.js +6 -1
- package/plugins/publishings/weibo-pub/dist/profile-check.js +6 -1
- package/plugins/publishings/x-pub/dist/index.js +6 -1
- package/plugins/publishings/x-pub/dist/profile-check.js +6 -1
- package/plugins/publishings/xhs-pub/dist/index.js +6 -1
- package/plugins/publishings/xhs-pub/dist/profile-check.js +6 -1
- package/plugins/publishings/xiaoyuzhou-pub/dist/index.js +6 -1
- package/plugins/publishings/xiaoyuzhou-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/zenn-pub/dist/index.js +4 -0
|
@@ -1263,7 +1263,7 @@ function dailyPublishCompleteness(args: string[]): void {
|
|
|
1263
1263
|
.join('; ');
|
|
1264
1264
|
console.error(
|
|
1265
1265
|
`WARNING: publish completeness — ${completeness.missing.join(', ')} did not complete: ${details}. ` +
|
|
1266
|
-
'
|
|
1266
|
+
'Side-channel and locale rows never enter publish_status; re-run the relevant stages for this work dir (see ' +
|
|
1267
1267
|
`${completenessFile})`,
|
|
1268
1268
|
);
|
|
1269
1269
|
}
|
|
@@ -1278,9 +1278,10 @@ function targetsOf(path: string): Record<string, unknown>[] {
|
|
|
1278
1278
|
}
|
|
1279
1279
|
|
|
1280
1280
|
/**
|
|
1281
|
-
* The publish tail's
|
|
1282
|
-
* enter `publish_status` —
|
|
1283
|
-
*
|
|
1281
|
+
* The publish tail's auxiliary channels (locales, XHS, WeChat) and the surfdash CMS re-sync never
|
|
1282
|
+
* enter `publish_status` — only the primary Surfdash + podcast pair does. Locales now validate
|
|
1283
|
+
* before the primary Surfdash publish; XHS/WeChat remain non-blocking by design. A
|
|
1284
|
+
* tail driven by hand can still skip auxiliary channels and leave a `full` status file behind:
|
|
1284
1285
|
* 20261002 shipped zh-only surfdash with no XHS and no WeChat draft, and nothing in the run's own
|
|
1285
1286
|
* artifacts said so. Record each channel's state beside the status file so the gap is visible.
|
|
1286
1287
|
*/
|
|
@@ -1301,9 +1302,44 @@ function publishCompleteness(
|
|
|
1301
1302
|
detail: 'translate markers absent (en/ja never scaffolded)',
|
|
1302
1303
|
});
|
|
1303
1304
|
} else {
|
|
1304
|
-
const
|
|
1305
|
-
const
|
|
1306
|
-
|
|
1305
|
+
const validationResultFile = at('24_translate-validate_result.json');
|
|
1306
|
+
const legacySyncResultFile = at('24_translate-sync_sync-result.json');
|
|
1307
|
+
const hasValidationResult = isFile(validationResultFile);
|
|
1308
|
+
const validationResult = hasValidationResult ? readJson(validationResultFile) : null;
|
|
1309
|
+
const syncResult = isFile(legacySyncResultFile) ? readJson(legacySyncResultFile) : null;
|
|
1310
|
+
if (hasValidationResult) {
|
|
1311
|
+
const primaryPublished = targetsOf(at('14_publish-surfdash_result.json')).some(
|
|
1312
|
+
(entry) => entry.target === 'surfdash-pub' && entry.ok === true && entry.status === 'published',
|
|
1313
|
+
);
|
|
1314
|
+
if (validationResult?.ok !== true) {
|
|
1315
|
+
rows.push({
|
|
1316
|
+
channel: 'locales',
|
|
1317
|
+
state: 'failed',
|
|
1318
|
+
detail: String(validationResult?.error ?? 'translate-validate receipt is missing or malformed'),
|
|
1319
|
+
});
|
|
1320
|
+
} else if (!primaryPublished) {
|
|
1321
|
+
rows.push({
|
|
1322
|
+
channel: 'locales',
|
|
1323
|
+
state: 'failed',
|
|
1324
|
+
detail: 'local validation passed, but final Surfdash publish did not complete',
|
|
1325
|
+
});
|
|
1326
|
+
} else {
|
|
1327
|
+
const untranslated = ['en', 'ja'].filter((locale) => {
|
|
1328
|
+
const marker = readFileSync(at(`20_translate-localize_${locale}-post-path.txt`), 'utf-8').trim();
|
|
1329
|
+
if (!isFile(marker)) return true;
|
|
1330
|
+
const head = readFileSync(marker, 'utf-8').split('---')[1] ?? '';
|
|
1331
|
+
return /^draft:\s*true\s*$/m.test(head);
|
|
1332
|
+
});
|
|
1333
|
+
rows.push({
|
|
1334
|
+
channel: 'locales',
|
|
1335
|
+
state: untranslated.length === 0 ? 'done' : 'failed',
|
|
1336
|
+
detail:
|
|
1337
|
+
untranslated.length === 0
|
|
1338
|
+
? 'en/ja drafts validated and published with the primary Surfdash sync'
|
|
1339
|
+
: `${untranslated.join(', ')} draft is still a scaffold (draft: true) — not translated, so it stays unpublished`,
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
} else if (syncResult === null) {
|
|
1307
1343
|
rows.push({
|
|
1308
1344
|
channel: 'locales',
|
|
1309
1345
|
state: 'not-run',
|
|
@@ -1415,8 +1451,24 @@ function dailyPatchSurfdash(args: string[]): void {
|
|
|
1415
1451
|
timed(workDir, 'patch-surfdash', () => {
|
|
1416
1452
|
const sdResult = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_result.json`);
|
|
1417
1453
|
const podcastResult = join(workDir, '31-publish', `${runDate}_16_publish-podcast_result.json`);
|
|
1418
|
-
|
|
1419
|
-
|
|
1454
|
+
const resultPath = join(workDir, '31-publish', `${runDate}_23_patch-surfdash_sync-result.json`);
|
|
1455
|
+
const invalidReceipt = (path: string): boolean => {
|
|
1456
|
+
const parsed = readJson(path);
|
|
1457
|
+
return (
|
|
1458
|
+
!isFile(path) ||
|
|
1459
|
+
parsed === null ||
|
|
1460
|
+
!Array.isArray(parsed.targets) ||
|
|
1461
|
+
parsed.targets.length === 0 ||
|
|
1462
|
+
!parsed.targets.every(
|
|
1463
|
+
(target) => target !== null && typeof target === 'object' && !Array.isArray(target),
|
|
1464
|
+
)
|
|
1465
|
+
);
|
|
1466
|
+
};
|
|
1467
|
+
if (invalidReceipt(sdResult) || invalidReceipt(podcastResult)) {
|
|
1468
|
+
const error = 'patch-surfdash: malformed surfdash/podcast publish receipt — nothing to patch';
|
|
1469
|
+
sted('write', resultPath, () => writeJqText(resultPath, { ok: false, target: 'surfdash-pub', error }));
|
|
1470
|
+
console.error(error);
|
|
1471
|
+
return;
|
|
1420
1472
|
}
|
|
1421
1473
|
const targetOf = (path: string): Record<string, unknown> => {
|
|
1422
1474
|
const parsed = readJson(path) as { targets?: Array<Record<string, unknown>> };
|
|
@@ -1433,7 +1485,6 @@ function dailyPatchSurfdash(args: string[]): void {
|
|
|
1433
1485
|
console.error('patch-surfdash: surfdash result carries no postPath — nothing to patch, skip');
|
|
1434
1486
|
return;
|
|
1435
1487
|
}
|
|
1436
|
-
const resultPath = join(workDir, '31-publish', `${runDate}_23_patch-surfdash_sync-result.json`);
|
|
1437
1488
|
if (process.env.KK_SURFDASH_PATCH_OFFLINE === '1') {
|
|
1438
1489
|
// The marker keeps the completeness report's cms-resync row honest in offline runs.
|
|
1439
1490
|
const skipMarker = join(workDir, '31-publish', `${runDate}_23_patch-surfdash_emdash-resync.txt`);
|
|
@@ -1727,6 +1778,11 @@ if (import.meta.main)
|
|
|
1727
1778
|
dailyPublishSurfdash(rest);
|
|
1728
1779
|
break;
|
|
1729
1780
|
}
|
|
1781
|
+
case 'daily-prepare-surfdash': {
|
|
1782
|
+
const { dailyPrepareSurfdash } = await loadDailyStages();
|
|
1783
|
+
dailyPrepareSurfdash(rest);
|
|
1784
|
+
break;
|
|
1785
|
+
}
|
|
1730
1786
|
case 'daily-show-notes': {
|
|
1731
1787
|
const { dailyShowNotes } = await loadDailyStages();
|
|
1732
1788
|
dailyShowNotes(rest);
|
|
@@ -1772,11 +1828,21 @@ if (import.meta.main)
|
|
|
1772
1828
|
dailyTranslateLocalize(rest);
|
|
1773
1829
|
break;
|
|
1774
1830
|
}
|
|
1831
|
+
case 'daily-surfdash-refine': {
|
|
1832
|
+
const { dailySurfdashRefine } = await loadDailyStages();
|
|
1833
|
+
dailySurfdashRefine(rest);
|
|
1834
|
+
break;
|
|
1835
|
+
}
|
|
1775
1836
|
case 'daily-translate-sync': {
|
|
1776
1837
|
const { dailyTranslateSync } = await loadDailyStages();
|
|
1777
1838
|
dailyTranslateSync(rest);
|
|
1778
1839
|
break;
|
|
1779
1840
|
}
|
|
1841
|
+
case 'daily-translate-validate': {
|
|
1842
|
+
const { dailyTranslateValidate } = await loadDailyStages();
|
|
1843
|
+
dailyTranslateValidate(rest);
|
|
1844
|
+
break;
|
|
1845
|
+
}
|
|
1780
1846
|
case 'itc-outline-pick': {
|
|
1781
1847
|
const { itcOutlinePick } = await loadItcStages();
|
|
1782
1848
|
itcOutlinePick(rest);
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
/**
|
|
4
|
+
* Pure safety-workflow transition predicates.
|
|
5
|
+
*
|
|
6
|
+
* The safety state machine cannot put a condition on an individual action. These predicates keep
|
|
7
|
+
* retry routing outside the agent actions: a successful primary artifact is preserved, while a
|
|
8
|
+
* missing or malformed artifact takes the bounded retry edge. The revision predicate also uses the
|
|
9
|
+
* safety request digest rather than Spur's Git working-tree `requireDiff` contract because daily
|
|
10
|
+
* drafts live under the ignored `.works` tree. The publish predicate keeps a real safety escalation
|
|
11
|
+
* out of unattended publication without mistaking a catalog fallback value for one.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
const REPORT_DECISIONS = new Set(['OK', 'RISK', 'BLOCK']);
|
|
18
|
+
const CONFIDENCE = new Set(['HIGH', 'MEDIUM', 'LOW']);
|
|
19
|
+
const FINDING_DISPOSITIONS = new Set(['note', 'fix', 'gap', 'escalate']);
|
|
20
|
+
const FINDING_STATUSES = new Set(['pass', 'fail', 'warn']);
|
|
21
|
+
const COMPLIANCE_CRITERIA = new Set([
|
|
22
|
+
'no-prohibited-claims',
|
|
23
|
+
'disclosure-presence',
|
|
24
|
+
'jurisdictional-rules',
|
|
25
|
+
'data-handling',
|
|
26
|
+
]);
|
|
27
|
+
const VERDICTS = new Set(['PASS', 'FAIL', 'NEEDS_REVISION']);
|
|
28
|
+
|
|
29
|
+
type JsonObject = Record<string, unknown>;
|
|
30
|
+
|
|
31
|
+
function isObject(value: unknown): value is JsonObject {
|
|
32
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readJson(path: string): JsonObject | null {
|
|
36
|
+
try {
|
|
37
|
+
const value: unknown = JSON.parse(readFileSync(path, 'utf8'));
|
|
38
|
+
return isObject(value) ? value : null;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function safetyPath(workDir: string, runDate: string, name: string): string {
|
|
45
|
+
return join(workDir, '24-script', `${runDate}_09_${name}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function nonEmptyString(value: unknown): value is string {
|
|
49
|
+
return typeof value === 'string' && value.trim() !== '';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function stringList(value: unknown): string[] | null {
|
|
53
|
+
if (!Array.isArray(value) || value.some((entry) => !nonEmptyString(entry))) return null;
|
|
54
|
+
return value.map((entry) => String(entry));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sortedUnique(values: string[]): string[] | null {
|
|
58
|
+
const unique = [...new Set(values)];
|
|
59
|
+
return unique.length === values.length ? unique.sort() : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function reportValid(workDir: string, runDate: string): string | null {
|
|
63
|
+
const request = readJson(safetyPath(workDir, runDate, 'safety-review_request.json'));
|
|
64
|
+
const report = readJson(safetyPath(workDir, runDate, 'safety-review_report.json'));
|
|
65
|
+
if (request === null || report === null) return 'review request or report is missing/invalid JSON';
|
|
66
|
+
|
|
67
|
+
const requested = stringList(request.platforms);
|
|
68
|
+
const platforms = report.platforms;
|
|
69
|
+
if (requested === null || !Array.isArray(platforms) || platforms.length === 0) {
|
|
70
|
+
return 'review request platforms or report platforms[] is missing/empty';
|
|
71
|
+
}
|
|
72
|
+
const requestedNames = sortedUnique(requested);
|
|
73
|
+
const actualNames: string[] = [];
|
|
74
|
+
for (const platform of platforms) {
|
|
75
|
+
if (!isObject(platform) || !nonEmptyString(platform.platform)) return 'report platform entry is malformed';
|
|
76
|
+
const name = String(platform.platform);
|
|
77
|
+
if (!REPORT_DECISIONS.has(String(platform.decision))) return `invalid platform decision for ${name}`;
|
|
78
|
+
if (!Array.isArray(platform.findings)) return `findings[] is missing for ${name}`;
|
|
79
|
+
for (const finding of platform.findings) {
|
|
80
|
+
if (!isObject(finding)) return `finding entry is malformed for ${name}`;
|
|
81
|
+
if (finding.disposition !== undefined && !FINDING_DISPOSITIONS.has(String(finding.disposition))) {
|
|
82
|
+
return `invalid finding disposition for ${name}`;
|
|
83
|
+
}
|
|
84
|
+
if (finding.evidenceGap !== undefined && typeof finding.evidenceGap !== 'boolean') {
|
|
85
|
+
return `evidenceGap must be boolean for ${name}`;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
actualNames.push(name);
|
|
89
|
+
}
|
|
90
|
+
const actualNamesSorted = sortedUnique(actualNames);
|
|
91
|
+
if (
|
|
92
|
+
requestedNames === null ||
|
|
93
|
+
actualNamesSorted === null ||
|
|
94
|
+
requestedNames.join('\0') !== actualNamesSorted.join('\0')
|
|
95
|
+
) {
|
|
96
|
+
return 'report platforms do not exactly match the requested platforms';
|
|
97
|
+
}
|
|
98
|
+
if (!CONFIDENCE.has(String(report.confidence))) return 'report confidence is missing/invalid';
|
|
99
|
+
if (typeof report.escalate !== 'boolean') return 'report escalate must be boolean';
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function judgeValid(workDir: string, runDate: string): string | null {
|
|
104
|
+
const verdict = readJson(safetyPath(workDir, runDate, 'safety-judge_verdict.json'));
|
|
105
|
+
if (verdict === null) return 'judge verdict is missing/invalid JSON';
|
|
106
|
+
if (!VERDICTS.has(String(verdict.verdict))) return 'judge verdict value is invalid';
|
|
107
|
+
if (!CONFIDENCE.has(String(verdict.confidence))) return 'judge confidence is missing/invalid';
|
|
108
|
+
if (verdict.rubric !== 'compliance') return 'judge rubric must be compliance';
|
|
109
|
+
if (
|
|
110
|
+
typeof verdict.score !== 'number' ||
|
|
111
|
+
!Number.isFinite(verdict.score) ||
|
|
112
|
+
verdict.score < 0 ||
|
|
113
|
+
verdict.score > 1
|
|
114
|
+
) {
|
|
115
|
+
return 'judge score must be a finite number from 0 through 1';
|
|
116
|
+
}
|
|
117
|
+
if (!nonEmptyString(verdict.summary)) return 'judge summary is missing/empty';
|
|
118
|
+
if (!Array.isArray(verdict.findings) || verdict.findings.length === 0) return 'judge findings[] is missing/empty';
|
|
119
|
+
const criteria = new Set<string>();
|
|
120
|
+
for (const finding of verdict.findings) {
|
|
121
|
+
if (!isObject(finding)) return 'judge finding entry is malformed';
|
|
122
|
+
if (!nonEmptyString(finding.criterion)) return 'judge finding criterion is missing/empty';
|
|
123
|
+
if (!FINDING_STATUSES.has(String(finding.status))) return 'judge finding status is invalid';
|
|
124
|
+
if (!nonEmptyString(finding.detail)) return 'judge finding detail is missing/empty';
|
|
125
|
+
criteria.add(String(finding.criterion));
|
|
126
|
+
}
|
|
127
|
+
if (
|
|
128
|
+
verdict.verdict === 'PASS' &&
|
|
129
|
+
verdict.findings.some((finding) => isObject(finding) && finding.status === 'fail')
|
|
130
|
+
) {
|
|
131
|
+
return 'PASS verdict cannot contain a fail finding';
|
|
132
|
+
}
|
|
133
|
+
for (const criterion of COMPLIANCE_CRITERIA) {
|
|
134
|
+
if (!criteria.has(criterion)) return `judge findings omit compliance criterion ${criterion}`;
|
|
135
|
+
}
|
|
136
|
+
if (!Array.isArray(verdict.feedback) || verdict.feedback.some((entry) => !nonEmptyString(entry))) {
|
|
137
|
+
return 'judge feedback[] is missing or contains an empty/non-string entry';
|
|
138
|
+
}
|
|
139
|
+
if (!nonEmptyString(verdict.content_ref)) return 'judge content_ref is missing/empty';
|
|
140
|
+
if (!nonEmptyString(verdict.evaluated_at) || !/^\d{4}-\d{2}-\d{2}T/.test(verdict.evaluated_at)) {
|
|
141
|
+
return 'judge evaluated_at must be an ISO-8601 timestamp';
|
|
142
|
+
}
|
|
143
|
+
if (!Number.isFinite(Date.parse(verdict.evaluated_at))) return 'judge evaluated_at is not a valid timestamp';
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The catalog decision may fall back to `escalate` even when the gate admitted `pass` or `exclude`.
|
|
149
|
+
* Only the gate's persisted status represents a real content-safety escalation, so unattended mode
|
|
150
|
+
* rejects that status while attended mode leaves it for the caller's pause node.
|
|
151
|
+
*/
|
|
152
|
+
function publishValid(workDir: string, runDate: string, unattendedArg: string): string | null {
|
|
153
|
+
if (unattendedArg !== 'true' && unattendedArg !== 'false') return 'unattended must be true or false';
|
|
154
|
+
const verdictToken = safetyPath(workDir, runDate, 'safety-verdict.status');
|
|
155
|
+
const gateStatusPath = safetyPath(workDir, runDate, 'safety-judge_status.txt');
|
|
156
|
+
let token = '';
|
|
157
|
+
let gateStatus = '';
|
|
158
|
+
try {
|
|
159
|
+
token = readFileSync(verdictToken, 'utf8').trim();
|
|
160
|
+
gateStatus = readFileSync(gateStatusPath, 'utf8').trim();
|
|
161
|
+
} catch {
|
|
162
|
+
return 'safety verdict token or gate status is missing/unreadable';
|
|
163
|
+
}
|
|
164
|
+
if (token !== 'accept') return `safety verdict token is ${token || 'missing'}, not accept`;
|
|
165
|
+
if (!new Set(['pass', 'exclude', 'escalate']).has(gateStatus)) {
|
|
166
|
+
return `gate status ${gateStatus || 'missing'} is not publish-valid`;
|
|
167
|
+
}
|
|
168
|
+
if (unattendedArg === 'true' && gateStatus === 'escalate') {
|
|
169
|
+
return 'unattended run refuses a real safety escalation; attended review is required';
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function draftDigest(workDir: string, runDate: string): string | null {
|
|
175
|
+
const article = join(workDir, '22-article', `${runDate}_06_article_content.json`);
|
|
176
|
+
const script = join(workDir, '24-script', `${runDate}_08_script_voicescript.yaml`);
|
|
177
|
+
try {
|
|
178
|
+
const hash = createHash('sha256');
|
|
179
|
+
hash.update(readFileSync(article)).update('\0');
|
|
180
|
+
hash.update(readFileSync(script)).update('\0');
|
|
181
|
+
return hash.digest('hex');
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function revisionValid(workDir: string, runDate: string): string | null {
|
|
188
|
+
const request = readJson(safetyPath(workDir, runDate, 'safety-review_request.json'));
|
|
189
|
+
const articlePath = join(workDir, '22-article', `${runDate}_06_article_content.json`);
|
|
190
|
+
const scriptPath = join(workDir, '24-script', `${runDate}_08_script_voicescript.yaml`);
|
|
191
|
+
if (request === null || !nonEmptyString(request.digest)) return 'review request digest is missing';
|
|
192
|
+
if (draftDigest(workDir, runDate) === null) return 'revised article or VoiceScript is missing/unreadable';
|
|
193
|
+
if (draftDigest(workDir, runDate) === request.digest) return 'draft digest is unchanged';
|
|
194
|
+
|
|
195
|
+
const article = readJson(articlePath);
|
|
196
|
+
if (article === null || !nonEmptyString(article.title) || !nonEmptyString(article.body)) {
|
|
197
|
+
return 'revised article title/body is missing';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const validator = join(import.meta.dir, 'validate-voicescript.ts');
|
|
201
|
+
const result = spawnSync(process.execPath, [validator, '--in', scriptPath], {
|
|
202
|
+
encoding: 'utf8',
|
|
203
|
+
stdio: 'ignore',
|
|
204
|
+
});
|
|
205
|
+
if (result.status !== 0) return 'revised VoiceScript failed content validation';
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function main(): number {
|
|
210
|
+
const [kind = '', workDir = '', runDate = '', unattended = 'false'] = process.argv.slice(2);
|
|
211
|
+
if (!kind || !workDir || !runDate) {
|
|
212
|
+
console.error(
|
|
213
|
+
'usage: safety-artifact-check.ts <report-valid|judge-valid|revision-valid|publish-valid> <work_dir> <run_date> [unattended]',
|
|
214
|
+
);
|
|
215
|
+
return 2;
|
|
216
|
+
}
|
|
217
|
+
const reason =
|
|
218
|
+
kind === 'report-valid'
|
|
219
|
+
? reportValid(workDir, runDate)
|
|
220
|
+
: kind === 'judge-valid'
|
|
221
|
+
? judgeValid(workDir, runDate)
|
|
222
|
+
: kind === 'revision-valid'
|
|
223
|
+
? revisionValid(workDir, runDate)
|
|
224
|
+
: kind === 'publish-valid'
|
|
225
|
+
? publishValid(workDir, runDate, unattended)
|
|
226
|
+
: 'unknown predicate';
|
|
227
|
+
if (reason !== null) {
|
|
228
|
+
console.error(`safety-artifact-check: ${kind}: ${reason}`);
|
|
229
|
+
return 1;
|
|
230
|
+
}
|
|
231
|
+
console.log(`safety-artifact-check: ${kind}: ok`);
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
process.exitCode = main();
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quote-aware sentence splitting (2026-10-24).
|
|
3
|
+
*
|
|
4
|
+
* NOTE: This file mirrors `packages/kk-core/src/sentences.ts`. It exists as a separate script file
|
|
5
|
+
* because `plugins/kk/scripts/daily-stages.ts` runs from the installed package or from standalone
|
|
6
|
+
* runner invocations with Node/Bun builtins only and cannot import `@gobing-ai/kk-core`.
|
|
7
|
+
* Keep the algorithm and behaviors in sync between both files.
|
|
8
|
+
*
|
|
9
|
+
* Rules:
|
|
10
|
+
* - splits after 。!? (CJK) always, and after `.`/`!`/`?` only when followed by whitespace or end of text
|
|
11
|
+
* (so Character.AI, llama.cpp, LWN.net, whatmodel.sh, claude.dev, v1.2, 3.6% never split);
|
|
12
|
+
* - keeps any run of closing quotes/brackets ( ” ’ 」 』 ) ) " ' ) attached to the preceding sentence;
|
|
13
|
+
* - never splits while inside an open 「」, 『』, “”, () pair (track depth; unbalanced → treat the rest as one sentence);
|
|
14
|
+
* - trims each sentence, drops empties; joining the results with '' (zh) loses no non-whitespace characters.
|
|
15
|
+
* - `{ cjkOnly: true }` splits only on 。!? — the zh/ja briefing paths, where an English body must
|
|
16
|
+
* stay one unit (its first-two-sentences cut would otherwise glue "vtables.It" and split "U.S.").
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const CJK_TERMINATORS = new Set(['。', '!', '?']);
|
|
20
|
+
const LATIN_TERMINATORS = new Set(['.', '!', '?']);
|
|
21
|
+
const ATTACHED_CLOSERS = new Set(['”', '’', '」', '』', ')', ')', '"', "'"]);
|
|
22
|
+
|
|
23
|
+
const OPEN_PAIRS: Record<string, string> = {
|
|
24
|
+
'「': '」',
|
|
25
|
+
'『': '』',
|
|
26
|
+
'“': '”',
|
|
27
|
+
'(': ')',
|
|
28
|
+
'(': ')',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Splitter options; `cjkOnly` drops the Latin `.!?` terminators (zh/ja split only on 。!?). */
|
|
32
|
+
export interface SplitSentencesOptions {
|
|
33
|
+
cjkOnly?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const CLOSE_PAIRS: Record<string, string> = {
|
|
37
|
+
'」': '「',
|
|
38
|
+
'』': '『',
|
|
39
|
+
'”': '“',
|
|
40
|
+
')': '(',
|
|
41
|
+
')': '(',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Splits text into complete sentences while respecting CJK/Latin terminators,
|
|
46
|
+
* attached closing quotes, and nested quote/bracket pairs.
|
|
47
|
+
*/
|
|
48
|
+
export function splitSentences(text: string, options: SplitSentencesOptions = {}): string[] {
|
|
49
|
+
if (!text || typeof text !== 'string') return [];
|
|
50
|
+
const results: string[] = [];
|
|
51
|
+
let sentenceStart = 0;
|
|
52
|
+
let stack: string[] = [];
|
|
53
|
+
let i = 0;
|
|
54
|
+
const isTerminator = (c: string): boolean =>
|
|
55
|
+
CJK_TERMINATORS.has(c) || (options.cjkOnly !== true && LATIN_TERMINATORS.has(c));
|
|
56
|
+
|
|
57
|
+
while (i < text.length) {
|
|
58
|
+
const ch = text[i] as string;
|
|
59
|
+
|
|
60
|
+
if (isTerminator(ch)) {
|
|
61
|
+
let termEnd = i;
|
|
62
|
+
let hasCjkTerm = false;
|
|
63
|
+
let hasLatinTerm = false;
|
|
64
|
+
while (termEnd < text.length) {
|
|
65
|
+
const termChar = text[termEnd];
|
|
66
|
+
if (!termChar || !isTerminator(termChar)) {
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
if (CJK_TERMINATORS.has(termChar)) hasCjkTerm = true;
|
|
70
|
+
if (LATIN_TERMINATORS.has(termChar)) hasLatinTerm = true;
|
|
71
|
+
termEnd++;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let closerEnd = termEnd;
|
|
75
|
+
const tempStack = [...stack];
|
|
76
|
+
while (closerEnd < text.length) {
|
|
77
|
+
const closer = text[closerEnd];
|
|
78
|
+
if (!closer || !ATTACHED_CLOSERS.has(closer)) {
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
const top = tempStack[tempStack.length - 1];
|
|
82
|
+
if (top !== undefined && top === closer) {
|
|
83
|
+
tempStack.pop();
|
|
84
|
+
} else if (CLOSE_PAIRS[closer]) {
|
|
85
|
+
const idx = tempStack.lastIndexOf(closer);
|
|
86
|
+
if (idx >= 0) tempStack.splice(idx, 1);
|
|
87
|
+
}
|
|
88
|
+
closerEnd++;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let isBoundary = false;
|
|
92
|
+
if (tempStack.length === 0) {
|
|
93
|
+
if (hasCjkTerm) {
|
|
94
|
+
isBoundary = true;
|
|
95
|
+
} else if (hasLatinTerm) {
|
|
96
|
+
const nextChar = text[closerEnd];
|
|
97
|
+
if (closerEnd === text.length || (nextChar !== undefined && /\s/.test(nextChar))) {
|
|
98
|
+
isBoundary = true;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (isBoundary) {
|
|
104
|
+
const sentence = text.slice(sentenceStart, closerEnd).trim();
|
|
105
|
+
if (sentence.length > 0) {
|
|
106
|
+
results.push(sentence);
|
|
107
|
+
}
|
|
108
|
+
sentenceStart = closerEnd;
|
|
109
|
+
stack = tempStack;
|
|
110
|
+
i = closerEnd;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const openMatch = OPEN_PAIRS[ch];
|
|
116
|
+
const closeMatch = CLOSE_PAIRS[ch];
|
|
117
|
+
if (openMatch) {
|
|
118
|
+
stack.push(openMatch);
|
|
119
|
+
} else if (closeMatch) {
|
|
120
|
+
const idx = stack.lastIndexOf(ch);
|
|
121
|
+
if (idx >= 0) stack.splice(idx, 1);
|
|
122
|
+
}
|
|
123
|
+
i++;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (sentenceStart < text.length) {
|
|
127
|
+
const tail = text.slice(sentenceStart).trim();
|
|
128
|
+
if (tail.length > 0) {
|
|
129
|
+
results.push(tail);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return results;
|
|
134
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical daily-series naming for the kk-daily-* pipelines (2026-10-23 regression).
|
|
3
|
+
*
|
|
4
|
+
* The 2026-10-23 surfdash run: the translate agent invented locale series names (ja 毎日AIブレンド,
|
|
5
|
+
* en "Daily AI Medley"/"hodgepodge" phrasing, zh-name listen links inside en/ja posts). Series
|
|
6
|
+
* names are product identity, not translation — this module is the pipeline's single definition of
|
|
7
|
+
* the per-locale series/show names plus the deterministic rewrite that repairs the known bad set
|
|
8
|
+
* before the CMS sync (plugins/kk/scripts/daily-stages.ts translate-sync gate).
|
|
9
|
+
*
|
|
10
|
+
* Pure string work only, no I/O. URL segments (http/https) are never touched — a slug or
|
|
11
|
+
* percent-encoded path may legitimately embed any of these tokens. surfdash-pub's cross-link table
|
|
12
|
+
* (plugins/publishings/surfdash-pub/src/patch.ts) cannot import this file (the kk scripts resolve
|
|
13
|
+
* from the installed package with node builtins only), so it repeats the names; tests assert the
|
|
14
|
+
* two tables agree.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The locales a daily post is published in (mirrors surfdash-pub's PATCH_LOCALES). */
|
|
18
|
+
export type DailyLocale = 'zh' | 'en' | 'ja';
|
|
19
|
+
|
|
20
|
+
/** One locale's daily-series naming: the series (episode-column) name and the show it runs under. */
|
|
21
|
+
export interface DailySeriesNames {
|
|
22
|
+
series: string;
|
|
23
|
+
show: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The single per-locale table; en/ja names are fixed identity, the zh column name is the source. */
|
|
27
|
+
export const DAILY_SERIES_NAMES: Record<DailyLocale, DailySeriesNames> = {
|
|
28
|
+
zh: { series: '每日AI乱炖', show: '咸话咸说' },
|
|
29
|
+
en: { series: 'Daily AI Stew', show: 'Salty Talk' },
|
|
30
|
+
ja: { series: 'デイリーAIシチュー', show: '咸話咸説' },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Retired series spellings that must never be emitted in any locale. ごった煮 is deliberately absent:
|
|
35
|
+
* it is ordinary ja vocabulary, so rewriting or failing on it would butcher legitimate prose.
|
|
36
|
+
*/
|
|
37
|
+
const RETIRED_SERIES_NAMES = ['毎日AIブレンド', 'Daily AI Medley', 'Daily AI Briefing'] as const;
|
|
38
|
+
|
|
39
|
+
/** All canonical series tokens — each locale's own name is correct there, wrong-locale elsewhere. */
|
|
40
|
+
const SERIES_TOKENS = ['每日AI乱炖', 'Daily AI Stew', 'デイリーAIシチュー'] as const;
|
|
41
|
+
|
|
42
|
+
const escapeRe = (token: string): string => token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
43
|
+
|
|
44
|
+
/** Latin tokens match case-insensitively with word boundaries; CJK tokens match exactly. */
|
|
45
|
+
const tokenRe = (token: string): RegExp =>
|
|
46
|
+
/^[\x20-\x7E]+$/.test(token) ? new RegExp(`\\b${escapeRe(token)}\\b`, 'gi') : new RegExp(escapeRe(token), 'g');
|
|
47
|
+
|
|
48
|
+
/** Splits markdown into alternating non-URL / URL segments (`split` keeps the capture group). */
|
|
49
|
+
const URL_SPLIT = /(https?:\/\/[^\s<>"')\]]+)/g;
|
|
50
|
+
|
|
51
|
+
function mapOutsideUrls(markdown: string, fn: (text: string) => string): string {
|
|
52
|
+
return markdown
|
|
53
|
+
.split(URL_SPLIT)
|
|
54
|
+
.map((part, index) => (index % 2 === 1 ? part : fn(part)))
|
|
55
|
+
.join('');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The short/spacing zh form (`AI 乱炖`/`AI乱炖`) as a series name; `每日` already prefixing it is correct zh. */
|
|
59
|
+
const SHORT_ZH_FORM = /(?<!每日)AI ?乱炖/g;
|
|
60
|
+
|
|
61
|
+
/** Every retired or wrong-locale series reference in `text`, as written, deduplicated in order. */
|
|
62
|
+
function retiredIn(text: string, locale: DailyLocale): string[] {
|
|
63
|
+
const canonical = DAILY_SERIES_NAMES[locale].series;
|
|
64
|
+
const patterns: RegExp[] = [
|
|
65
|
+
...RETIRED_SERIES_NAMES.map(tokenRe),
|
|
66
|
+
...SERIES_TOKENS.filter((token) => token !== canonical).map(tokenRe),
|
|
67
|
+
SHORT_ZH_FORM,
|
|
68
|
+
/\bhodgepodge\b/gi,
|
|
69
|
+
];
|
|
70
|
+
const found: string[] = [];
|
|
71
|
+
for (const pattern of patterns) {
|
|
72
|
+
for (const match of text.matchAll(pattern)) {
|
|
73
|
+
if (!found.includes(match[0])) found.push(match[0]);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return found;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Retired / wrong-locale series references in `markdown`, as written (URL segments excluded).
|
|
81
|
+
* Detection only — the repair side is {@link normalizeSeriesNames}.
|
|
82
|
+
*/
|
|
83
|
+
export function findRetiredSeriesNames(markdown: string, locale: DailyLocale): string[] {
|
|
84
|
+
const found: string[] = [];
|
|
85
|
+
mapOutsideUrls(markdown, (text) => {
|
|
86
|
+
for (const hit of retiredIn(text, locale)) {
|
|
87
|
+
if (!found.includes(hit)) found.push(hit);
|
|
88
|
+
}
|
|
89
|
+
return text;
|
|
90
|
+
});
|
|
91
|
+
return found;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Rewrite every known retired / wrong-locale series reference to the locale's canonical series
|
|
96
|
+
* name and list what could not be rewritten safely: a bare `hodgepodge` is vocabulary, not a name
|
|
97
|
+
* — rewriting it would butcher prose, so it is reported for hand repair instead. Idempotent: the
|
|
98
|
+
* rewrite only ever produces the locale's own canonical name, which no rule matches.
|
|
99
|
+
*/
|
|
100
|
+
export function normalizeSeriesNames(
|
|
101
|
+
markdown: string,
|
|
102
|
+
locale: DailyLocale,
|
|
103
|
+
): {
|
|
104
|
+
/** The input with every known bad series token rewritten (URL segments untouched). */
|
|
105
|
+
markdown: string;
|
|
106
|
+
/** Retired series references left over that the caller must report or repair by hand. */
|
|
107
|
+
remaining: string[];
|
|
108
|
+
} {
|
|
109
|
+
const canonical = DAILY_SERIES_NAMES[locale].series;
|
|
110
|
+
const next = mapOutsideUrls(markdown, (text) => {
|
|
111
|
+
let out = text;
|
|
112
|
+
if (locale === 'en') {
|
|
113
|
+
// The observed en intro phrase — rewritten as a phrase first so the bare word never
|
|
114
|
+
// produces "daily AI Daily AI Stew".
|
|
115
|
+
out = out.replace(/\byour daily AI hodgepodge\b/gi, 'Your Daily AI Stew');
|
|
116
|
+
out = out.replace(/\bdaily AI hodgepodge\b/gi, 'Daily AI Stew');
|
|
117
|
+
}
|
|
118
|
+
for (const token of [...RETIRED_SERIES_NAMES, ...SERIES_TOKENS.filter((name) => name !== canonical)]) {
|
|
119
|
+
out = out.replace(tokenRe(token), () => canonical);
|
|
120
|
+
}
|
|
121
|
+
return out.replace(SHORT_ZH_FORM, canonical);
|
|
122
|
+
});
|
|
123
|
+
return { markdown: next, remaining: findRetiredSeriesNames(next, locale) };
|
|
124
|
+
}
|