opencode-wiki-historian 0.2.0
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/LICENSE +21 -0
- package/README.md +381 -0
- package/dist/chronology.d.ts +36 -0
- package/dist/chronology.js +67 -0
- package/dist/config.d.ts +112 -0
- package/dist/config.js +158 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +136 -0
- package/dist/jsonc.d.ts +17 -0
- package/dist/jsonc.js +131 -0
- package/dist/map.d.ts +58 -0
- package/dist/map.js +196 -0
- package/dist/migrate-apply.d.ts +40 -0
- package/dist/migrate-apply.js +144 -0
- package/dist/migrate-score.d.ts +29 -0
- package/dist/migrate-score.js +267 -0
- package/dist/migrate-store.d.ts +52 -0
- package/dist/migrate-store.js +77 -0
- package/dist/migrate.d.ts +65 -0
- package/dist/migrate.js +111 -0
- package/dist/templates/genres.d.ts +65 -0
- package/dist/templates/genres.js +228 -0
- package/dist/templates/skeletons.d.ts +48 -0
- package/dist/templates/skeletons.js +558 -0
- package/dist/tools/create.d.ts +9 -0
- package/dist/tools/create.js +77 -0
- package/dist/tools/local.d.ts +10 -0
- package/dist/tools/local.js +107 -0
- package/dist/tools/mutate.d.ts +11 -0
- package/dist/tools/mutate.js +157 -0
- package/dist/tools/read.d.ts +9 -0
- package/dist/tools/read.js +104 -0
- package/dist/tools/shared.d.ts +52 -0
- package/dist/tools/shared.js +87 -0
- package/dist/tools/write.d.ts +10 -0
- package/dist/tools/write.js +148 -0
- package/dist/tools.d.ts +23 -0
- package/dist/tools.js +43 -0
- package/dist/translate.d.ts +44 -0
- package/dist/translate.js +207 -0
- package/dist/wiki/assets.d.ts +42 -0
- package/dist/wiki/assets.js +91 -0
- package/dist/wiki/client.d.ts +67 -0
- package/dist/wiki/client.js +221 -0
- package/dist/wiki/locale.d.ts +66 -0
- package/dist/wiki/locale.js +154 -0
- package/dist/wiki/pages.d.ts +7 -0
- package/dist/wiki/pages.js +7 -0
- package/dist/wiki/pages.read.d.ts +114 -0
- package/dist/wiki/pages.read.js +114 -0
- package/dist/wiki/pages.write.d.ts +109 -0
- package/dist/wiki/pages.write.js +201 -0
- package/package.json +36 -0
- package/skills/historian/SKILL.md +294 -0
- package/skills/historian/references/adapting-your-own-wiki.md +53 -0
- package/skills/historian/references/genres.md +160 -0
- package/skills/historian/references/rules.md +30 -0
- package/skills/historian/references/style.md +84 -0
- package/skills/historian/references/wikijs-guide.md +87 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migrate persistence (plan todo 14): pre-image backup files + the
|
|
3
|
+
* path-level checkpoint. Both are plain JSON under the plan's 工件根
|
|
4
|
+
* (results/ in the plugin repo; ~/.config/opencode in the home dir), with
|
|
5
|
+
* tolerant reads — a corrupt/missing file degrades to an empty state,
|
|
6
|
+
* never a crash (the pre-image backup is the restore program of record).
|
|
7
|
+
*/
|
|
8
|
+
import type { Locale } from './wiki/pages.read.js';
|
|
9
|
+
/** Write-side field set of one pre-migration page; null = locale absent.
|
|
10
|
+
* publishStartDate/publishEndDate ride along so a replay restore via
|
|
11
|
+
* updatePage (full RMW) reproduces the exact write-side contract. */
|
|
12
|
+
export interface PreImageEntry {
|
|
13
|
+
readonly content: string;
|
|
14
|
+
readonly title: string;
|
|
15
|
+
readonly description: string;
|
|
16
|
+
readonly tags: readonly string[];
|
|
17
|
+
readonly isPublished: boolean;
|
|
18
|
+
readonly publishStartDate: string;
|
|
19
|
+
readonly publishEndDate: string;
|
|
20
|
+
}
|
|
21
|
+
export type PreImagePair = Readonly<Record<Locale, PreImageEntry | null>>;
|
|
22
|
+
export interface BackupFile {
|
|
23
|
+
readonly section: string;
|
|
24
|
+
readonly createdAt: string;
|
|
25
|
+
readonly paths: Readonly<Record<string, PreImagePair>>;
|
|
26
|
+
}
|
|
27
|
+
export declare function sectionOf(path: string): string;
|
|
28
|
+
export declare function dateKey(d: Date): string;
|
|
29
|
+
export declare function defaultResultsDir(): string;
|
|
30
|
+
/** Plan verbatim filename: results/pilot-backup-<section>-<date>.json */
|
|
31
|
+
export declare function backupFileFor(section: string, date: string, resultsDir: string): string;
|
|
32
|
+
export declare function readBackup(file: string): BackupFile;
|
|
33
|
+
/** Atomic write: tmp file + rename, so a crash never leaves a truncated
|
|
34
|
+
* backup (the restore program of record must itself be restorable). */
|
|
35
|
+
export declare function writeBackup(file: string, backup: BackupFile): void;
|
|
36
|
+
export interface CheckpointEntry {
|
|
37
|
+
readonly contentHash: string;
|
|
38
|
+
readonly zhHash: string | null;
|
|
39
|
+
readonly appliedAt: string;
|
|
40
|
+
readonly genre: string;
|
|
41
|
+
}
|
|
42
|
+
export interface CheckpointFile {
|
|
43
|
+
readonly version: 1;
|
|
44
|
+
readonly paths: Readonly<Record<string, CheckpointEntry>>;
|
|
45
|
+
}
|
|
46
|
+
export declare function checkpointPath(homeDir: string): string;
|
|
47
|
+
export declare function readCheckpoint(homeDir: string): CheckpointFile;
|
|
48
|
+
export declare function writeCheckpoint(homeDir: string, cp: CheckpointFile): void;
|
|
49
|
+
/** Content hash = sha256 over whitespace-normalized text: byte-identical
|
|
50
|
+
* content with cosmetic whitespace drift still latches the checkpoint. */
|
|
51
|
+
export declare function hashText(text: string): string;
|
|
52
|
+
export declare function normalizeForHash(s: string): string;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migrate persistence (plan todo 14): pre-image backup files + the
|
|
3
|
+
* path-level checkpoint. Both are plain JSON under the plan's 工件根
|
|
4
|
+
* (results/ in the plugin repo; ~/.config/opencode in the home dir), with
|
|
5
|
+
* tolerant reads — a corrupt/missing file degrades to an empty state,
|
|
6
|
+
* never a crash (the pre-image backup is the restore program of record).
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from 'node:fs';
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { isRecord } from './jsonc.js';
|
|
13
|
+
export function sectionOf(path) {
|
|
14
|
+
return path.split('/')[0];
|
|
15
|
+
}
|
|
16
|
+
export function dateKey(d) {
|
|
17
|
+
const y = d.getFullYear();
|
|
18
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
19
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
20
|
+
return `${y}-${m}-${day}`;
|
|
21
|
+
}
|
|
22
|
+
export function defaultResultsDir() {
|
|
23
|
+
return fileURLToPath(new URL('../results/', import.meta.url));
|
|
24
|
+
}
|
|
25
|
+
/** Plan verbatim filename: results/pilot-backup-<section>-<date>.json */
|
|
26
|
+
export function backupFileFor(section, date, resultsDir) {
|
|
27
|
+
return join(resultsDir, `pilot-backup-${section}-${date}.json`);
|
|
28
|
+
}
|
|
29
|
+
export function readBackup(file) {
|
|
30
|
+
const parsed = readJsonTolerant(file, null);
|
|
31
|
+
if (!isRecord(parsed) || !isRecord(parsed.paths)) {
|
|
32
|
+
return { section: '', createdAt: '', paths: {} };
|
|
33
|
+
}
|
|
34
|
+
return { section: String(parsed.section ?? ''), createdAt: String(parsed.createdAt ?? ''), paths: parsed.paths };
|
|
35
|
+
}
|
|
36
|
+
/** Atomic write: tmp file + rename, so a crash never leaves a truncated
|
|
37
|
+
* backup (the restore program of record must itself be restorable). */
|
|
38
|
+
export function writeBackup(file, backup) {
|
|
39
|
+
writeJsonAtomic(file, backup);
|
|
40
|
+
}
|
|
41
|
+
export function checkpointPath(homeDir) {
|
|
42
|
+
return join(homeDir, '.config', 'opencode', 'historian-migrate.json');
|
|
43
|
+
}
|
|
44
|
+
export function readCheckpoint(homeDir) {
|
|
45
|
+
const parsed = readJsonTolerant(checkpointPath(homeDir), null);
|
|
46
|
+
if (!isRecord(parsed) || !isRecord(parsed.paths)) {
|
|
47
|
+
return { version: 1, paths: {} };
|
|
48
|
+
}
|
|
49
|
+
return { version: 1, paths: parsed.paths };
|
|
50
|
+
}
|
|
51
|
+
export function writeCheckpoint(homeDir, cp) {
|
|
52
|
+
writeJsonAtomic(checkpointPath(homeDir), cp);
|
|
53
|
+
}
|
|
54
|
+
// --- Hashing -----------------------------------------------------------------
|
|
55
|
+
/** Content hash = sha256 over whitespace-normalized text: byte-identical
|
|
56
|
+
* content with cosmetic whitespace drift still latches the checkpoint. */
|
|
57
|
+
export function hashText(text) {
|
|
58
|
+
return createHash('sha256').update(normalizeForHash(text)).digest('hex');
|
|
59
|
+
}
|
|
60
|
+
export function normalizeForHash(s) {
|
|
61
|
+
return s.trim().replace(/\s+/g, ' ');
|
|
62
|
+
}
|
|
63
|
+
// --- fs helpers --------------------------------------------------------------
|
|
64
|
+
function readJsonTolerant(file, fallback) {
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return fallback;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function writeJsonAtomic(file, data) {
|
|
73
|
+
fs.mkdirSync(dirname(file), { recursive: true });
|
|
74
|
+
const tmp = `${file}.tmp`;
|
|
75
|
+
fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
|
76
|
+
fs.renameSync(tmp, file);
|
|
77
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration engine — reformat side (plan todo 14): LLM-driven genre restyle.
|
|
3
|
+
* The dry-run reads the page (en, else zh), classifies the genre (explicit
|
|
4
|
+
* arg wins), reformats via the translate engine's Anthropic-compatible call
|
|
5
|
+
* path (SAME-LANGUAGE restyle — the twin is translated at apply time, never
|
|
6
|
+
* here), and scores the 10-item checklist deterministically on the DRAFT
|
|
7
|
+
* (items 9-10 are 'deferred' pre-apply; N/A per kind contract).
|
|
8
|
+
*
|
|
9
|
+
* No LLM access never crashes: every failure surfaces as a structured
|
|
10
|
+
* {ok:false, error} with the TranslateError/PageNotFoundError taxonomy.
|
|
11
|
+
* Apply lives in migrate-apply.ts (this module stays under the LOC ceiling).
|
|
12
|
+
*/
|
|
13
|
+
import type { GqlClient } from './wiki/client.js';
|
|
14
|
+
import type { HistorianOptions } from './config.js';
|
|
15
|
+
import { type Confidence, type Genre, type GenreLang } from './templates/genres.js';
|
|
16
|
+
import { type ChecklistVerdict } from './migrate-score.js';
|
|
17
|
+
import { type Locale, type TranslateFn } from './wiki/pages.read.js';
|
|
18
|
+
export interface MigrateDeps {
|
|
19
|
+
readonly client: GqlClient;
|
|
20
|
+
readonly options: HistorianOptions;
|
|
21
|
+
readonly translate?: TranslateFn;
|
|
22
|
+
readonly fetchImpl?: typeof fetch;
|
|
23
|
+
readonly homeDir: string;
|
|
24
|
+
readonly resultsDir?: string;
|
|
25
|
+
readonly now?: () => Date;
|
|
26
|
+
}
|
|
27
|
+
export interface ReformatArgs {
|
|
28
|
+
readonly path: string;
|
|
29
|
+
readonly genre?: Genre;
|
|
30
|
+
/** Pilot revise-loop seam (todo 15): corrective hints appended into the
|
|
31
|
+
* restyle user prompt when a previous draft failed checklist items. */
|
|
32
|
+
readonly reviseHints?: readonly string[];
|
|
33
|
+
}
|
|
34
|
+
export type ReformatOutcome = {
|
|
35
|
+
ok: true;
|
|
36
|
+
draft: string;
|
|
37
|
+
genre: Genre;
|
|
38
|
+
confidence: Confidence | 'explicit';
|
|
39
|
+
signals: readonly string[];
|
|
40
|
+
checklistResults: readonly ChecklistVerdict[];
|
|
41
|
+
urls: {
|
|
42
|
+
en: string;
|
|
43
|
+
zh: string;
|
|
44
|
+
};
|
|
45
|
+
alreadyConforms: boolean;
|
|
46
|
+
missingTwin: boolean;
|
|
47
|
+
sourceLocale: Locale;
|
|
48
|
+
sourceContent: string;
|
|
49
|
+
sourceTitle: string;
|
|
50
|
+
} | {
|
|
51
|
+
ok: false;
|
|
52
|
+
error: Error;
|
|
53
|
+
};
|
|
54
|
+
/** Locale-aware URL pair for a server-reported path (mirrors tools/shared
|
|
55
|
+
* reportUrls; the engine must not import the tools layer). */
|
|
56
|
+
export declare function urlsOf(baseUrl: string, path: string, locale: Locale): {
|
|
57
|
+
en: string;
|
|
58
|
+
zh: string;
|
|
59
|
+
};
|
|
60
|
+
export declare function reformatSystemFor(lang: GenreLang): string;
|
|
61
|
+
export declare function reformatPromptFor(genre: Genre, lang: GenreLang, original: string): string;
|
|
62
|
+
/** Dry-run engine: read → classify (explicit genre wins) → LLM restyle →
|
|
63
|
+
* deterministic checklist on the draft → conformance signal vs stored
|
|
64
|
+
* content. Never writes (the tool, not this module, owns the envelope). */
|
|
65
|
+
export declare function reformatPageDraft(deps: MigrateDeps, args: ReformatArgs): Promise<ReformatOutcome>;
|
package/dist/migrate.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration engine — reformat side (plan todo 14): LLM-driven genre restyle.
|
|
3
|
+
* The dry-run reads the page (en, else zh), classifies the genre (explicit
|
|
4
|
+
* arg wins), reformats via the translate engine's Anthropic-compatible call
|
|
5
|
+
* path (SAME-LANGUAGE restyle — the twin is translated at apply time, never
|
|
6
|
+
* here), and scores the 10-item checklist deterministically on the DRAFT
|
|
7
|
+
* (items 9-10 are 'deferred' pre-apply; N/A per kind contract).
|
|
8
|
+
*
|
|
9
|
+
* No LLM access never crashes: every failure surfaces as a structured
|
|
10
|
+
* {ok:false, error} with the TranslateError/PageNotFoundError taxonomy.
|
|
11
|
+
* Apply lives in migrate-apply.ts (this module stays under the LOC ceiling).
|
|
12
|
+
*/
|
|
13
|
+
import { callMessages } from './translate.js';
|
|
14
|
+
import { classifyGenre, genreSkeleton } from './templates/genres.js';
|
|
15
|
+
import { scoreChecklist, contentSimilar } from './migrate-score.js';
|
|
16
|
+
import { PageNotFoundError } from './wiki/pages.js';
|
|
17
|
+
import { readPage } from './wiki/pages.read.js';
|
|
18
|
+
import { assertLocalePair, twinOf, PathValidationError } from './wiki/locale.js';
|
|
19
|
+
/** Locale-aware URL pair for a server-reported path (mirrors tools/shared
|
|
20
|
+
* reportUrls; the engine must not import the tools layer). */
|
|
21
|
+
export function urlsOf(baseUrl, path, locale) {
|
|
22
|
+
const raw = (l) => `${baseUrl.replace(/\/+$/, '')}/${l}/${path}`;
|
|
23
|
+
try {
|
|
24
|
+
const pair = assertLocalePair(path, locale, baseUrl);
|
|
25
|
+
return locale === 'en' ? { en: pair.url, zh: pair.twinUrl } : { en: pair.twinUrl, zh: pair.url };
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
if (!(err instanceof PathValidationError))
|
|
29
|
+
throw err;
|
|
30
|
+
return { en: raw('en'), zh: raw('zh') };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// --- Reformat prompt (rules.md items 1-8, embedded with machine-readable
|
|
34
|
+
// markers; the LLM restyles, scoring stays deterministic code) -----------
|
|
35
|
+
const RULES = [
|
|
36
|
+
'R1 结论先行: the lead states the conclusion/action first.',
|
|
37
|
+
'R2 一页一问: the page answers exactly one question.',
|
|
38
|
+
'R3 导言占比: lead ≈10-15% of the body, one sentence per major section.',
|
|
39
|
+
'R4 句长约束: zh sentences ≤20 chars; en sentences ≤25 words.',
|
|
40
|
+
'R5 表格判据: ≥3-field structured data → table; pairs → description list.',
|
|
41
|
+
'R6 来源列: comparison/timeline tables carry a source column.',
|
|
42
|
+
'R7 时间线三列: event timelines are exactly 时间|事件|来源.',
|
|
43
|
+
'R8 行动项五要素: action items use the five columns 类型|负责人|期限|验证|状态.',
|
|
44
|
+
];
|
|
45
|
+
export function reformatSystemFor(lang) {
|
|
46
|
+
return [
|
|
47
|
+
`RESTYLE: ${lang}->${lang}`,
|
|
48
|
+
'You restructure an existing wiki page into the target genre skeleton.',
|
|
49
|
+
'RULES:',
|
|
50
|
+
...RULES,
|
|
51
|
+
'PRESERVE every fact, link, and code block verbatim; restructure only.',
|
|
52
|
+
'Return the full page as Markdown. Do NOT wrap the output in code fences and do not add commentary outside the Markdown.',
|
|
53
|
+
].join('\n');
|
|
54
|
+
}
|
|
55
|
+
export function reformatPromptFor(genre, lang, original) {
|
|
56
|
+
return [
|
|
57
|
+
`TARGET GENRE SKELETON (${genre}, ${lang}):`,
|
|
58
|
+
genreSkeleton(genre, lang),
|
|
59
|
+
'',
|
|
60
|
+
'ORIGINAL CONTENT TO RESTRUCTURE:',
|
|
61
|
+
original,
|
|
62
|
+
].join('\n');
|
|
63
|
+
}
|
|
64
|
+
/** Append pilot revise-loop hints to the restyle user prompt (todo 15 seam);
|
|
65
|
+
* absent hints leave the prompt byte-identical to the pre-seam form. */
|
|
66
|
+
function appendReviseHints(prompt, hints) {
|
|
67
|
+
if (hints === undefined || hints.length === 0)
|
|
68
|
+
return prompt;
|
|
69
|
+
return [
|
|
70
|
+
prompt,
|
|
71
|
+
'',
|
|
72
|
+
'REVISE HINTS (the restructure MUST satisfy every hint):',
|
|
73
|
+
...hints.map((h) => `- ${h}`),
|
|
74
|
+
].join('\n');
|
|
75
|
+
}
|
|
76
|
+
// --- reformatPageDraft ------------------------------------------------------
|
|
77
|
+
/** Dry-run engine: read → classify (explicit genre wins) → LLM restyle →
|
|
78
|
+
* deterministic checklist on the draft → conformance signal vs stored
|
|
79
|
+
* content. Never writes (the tool, not this module, owns the envelope). */
|
|
80
|
+
export async function reformatPageDraft(deps, args) {
|
|
81
|
+
const en = await readPage(deps.client, args.path, 'en');
|
|
82
|
+
const source = en ?? (await readPage(deps.client, args.path, 'zh'));
|
|
83
|
+
if (source === null) {
|
|
84
|
+
return { ok: false, error: new PageNotFoundError(`page '${args.path}' does not exist (checked en and zh locales)`) };
|
|
85
|
+
}
|
|
86
|
+
const classified = classifyGenre({ title: source.title, body: source.content });
|
|
87
|
+
const genre = args.genre ?? classified.genre;
|
|
88
|
+
const user = appendReviseHints(reformatPromptFor(genre, source.locale, source.content), args.reviseHints);
|
|
89
|
+
let draft;
|
|
90
|
+
try {
|
|
91
|
+
draft = await callMessages(deps.options, { fetchImpl: deps.fetchImpl }, { system: reformatSystemFor(source.locale), user });
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
return { ok: false, error: err instanceof Error ? err : new Error(String(err)) };
|
|
95
|
+
}
|
|
96
|
+
const twin = await readPage(deps.client, args.path, twinOf(source.locale));
|
|
97
|
+
return {
|
|
98
|
+
ok: true,
|
|
99
|
+
draft,
|
|
100
|
+
genre,
|
|
101
|
+
confidence: args.genre !== undefined ? 'explicit' : classified.confidence,
|
|
102
|
+
signals: args.genre !== undefined ? [] : classified.signals,
|
|
103
|
+
checklistResults: scoreChecklist(genre, draft),
|
|
104
|
+
urls: urlsOf(deps.options.baseUrl, source.path, source.locale),
|
|
105
|
+
alreadyConforms: contentSimilar(draft, source.content),
|
|
106
|
+
missingTwin: twin === null,
|
|
107
|
+
sourceLocale: source.locale,
|
|
108
|
+
sourceContent: source.content,
|
|
109
|
+
sourceTitle: source.title,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page-genre system: bilingual G1–G5 templates, deterministic genre
|
|
3
|
+
* classification, and the 10-item self-review gate (plan todo 9).
|
|
4
|
+
*
|
|
5
|
+
* The five genres (digest "Genre templates", cross-cultural-wiki-writing):
|
|
6
|
+
* G1 事件/复盘页 — incident postmortem (summary → metadata → background →
|
|
7
|
+
* timeline → impact → root cause → remediation → action
|
|
8
|
+
* items → lessons → appendix)
|
|
9
|
+
* G2 对比/选型页 — comparison / selection (conclusion first → dimensions →
|
|
10
|
+
* object overview → comparison table → methodology →
|
|
11
|
+
* recommendation)
|
|
12
|
+
* G3 清单/参考页 — inventory / reference (scope statement → structure
|
|
13
|
+
* mirror → entry table → maintenance note)
|
|
14
|
+
* G4 概念/原理页 — concept / explanation (definition+rationale →
|
|
15
|
+
* importance-ordered aspects → how it works →
|
|
16
|
+
* attribution/opinion)
|
|
17
|
+
* G5 现状卡/账本页 — current-state ledger (status block → deployed
|
|
18
|
+
* components table with per-row last-verified dates →
|
|
19
|
+
* integration → invalidation policy → agent-executable
|
|
20
|
+
* verification commands → append-only change log)
|
|
21
|
+
*
|
|
22
|
+
* Every skeleton embeds the harness rubric dimension-C anatomy (status block,
|
|
23
|
+
* one-line scope, Related Pages tail) and wiki.js expression pieces only —
|
|
24
|
+
* see skeletons.ts for the raw strings. Consumers (tools.ts, todo 14/15 pilot)
|
|
25
|
+
* import everything from this barrel.
|
|
26
|
+
*/
|
|
27
|
+
export { G1_EN, G1_ZH, G2_EN, G2_ZH, G3_EN, G3_ZH, G4_EN, G4_ZH, G5_EN, G5_ZH, } from './skeletons.js';
|
|
28
|
+
export type Genre = 'G1' | 'G2' | 'G3' | 'G4' | 'G5';
|
|
29
|
+
export type GenreLang = 'en' | 'zh';
|
|
30
|
+
export type Confidence = 'high' | 'medium' | 'low';
|
|
31
|
+
export declare const GENRES: readonly Genre[];
|
|
32
|
+
export interface ClassifyInput {
|
|
33
|
+
readonly title: string;
|
|
34
|
+
readonly body: string;
|
|
35
|
+
}
|
|
36
|
+
export interface ClassifyResult {
|
|
37
|
+
readonly genre: Genre;
|
|
38
|
+
readonly confidence: Confidence;
|
|
39
|
+
/** The keyword cues that were matched (deterministic order, no duplicates). */
|
|
40
|
+
readonly signals: readonly string[];
|
|
41
|
+
}
|
|
42
|
+
/** One gate item of the 10-item self-review checklist (plan todo 9). */
|
|
43
|
+
export interface ChecklistItem {
|
|
44
|
+
/** 1..10 — items are always returned as the full contiguous gate. */
|
|
45
|
+
readonly id: number;
|
|
46
|
+
readonly label: string;
|
|
47
|
+
/** kind='genre-specific' items are scored N/A=PASS when the page's genre is
|
|
48
|
+
* not in `appliesTo` (pilot scoring, todo 14/15). */
|
|
49
|
+
readonly kind: 'content' | 'post-write' | 'genre-specific';
|
|
50
|
+
readonly appliesTo: readonly Genre[] | 'all';
|
|
51
|
+
}
|
|
52
|
+
/** Full markdown skeleton for a genre × language pair. Pure string data —
|
|
53
|
+
* the author copies it, replaces placeholders, and fills the commented slots. */
|
|
54
|
+
export declare function genreSkeleton(genre: Genre, lang: GenreLang): string;
|
|
55
|
+
/** Deterministic keyword scoring. No randomness, no state:
|
|
56
|
+
* - each genre scores Σ(title hits × 3 + body hits × 1) over its vocabulary;
|
|
57
|
+
* - body is truncated to the first 200_000 chars (guard), latin keywords are
|
|
58
|
+
* word-boundary matched;
|
|
59
|
+
* - all-zero scores → G4/low (garbage fallback);
|
|
60
|
+
* - a tie between the two top scores (both > 0) → G4/medium (generalist
|
|
61
|
+
* genre wins the ambiguous case);
|
|
62
|
+
* - otherwise the top genre wins; confidence = margin ≥ 3 → high, else medium.
|
|
63
|
+
* `signals` names the matched cues (used by the pilot engine, todo 14/15). */
|
|
64
|
+
export declare function classifyGenre(input: ClassifyInput): ClassifyResult;
|
|
65
|
+
export declare function selfReviewChecklist(genre: Genre): readonly ChecklistItem[];
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page-genre system: bilingual G1–G5 templates, deterministic genre
|
|
3
|
+
* classification, and the 10-item self-review gate (plan todo 9).
|
|
4
|
+
*
|
|
5
|
+
* The five genres (digest "Genre templates", cross-cultural-wiki-writing):
|
|
6
|
+
* G1 事件/复盘页 — incident postmortem (summary → metadata → background →
|
|
7
|
+
* timeline → impact → root cause → remediation → action
|
|
8
|
+
* items → lessons → appendix)
|
|
9
|
+
* G2 对比/选型页 — comparison / selection (conclusion first → dimensions →
|
|
10
|
+
* object overview → comparison table → methodology →
|
|
11
|
+
* recommendation)
|
|
12
|
+
* G3 清单/参考页 — inventory / reference (scope statement → structure
|
|
13
|
+
* mirror → entry table → maintenance note)
|
|
14
|
+
* G4 概念/原理页 — concept / explanation (definition+rationale →
|
|
15
|
+
* importance-ordered aspects → how it works →
|
|
16
|
+
* attribution/opinion)
|
|
17
|
+
* G5 现状卡/账本页 — current-state ledger (status block → deployed
|
|
18
|
+
* components table with per-row last-verified dates →
|
|
19
|
+
* integration → invalidation policy → agent-executable
|
|
20
|
+
* verification commands → append-only change log)
|
|
21
|
+
*
|
|
22
|
+
* Every skeleton embeds the harness rubric dimension-C anatomy (status block,
|
|
23
|
+
* one-line scope, Related Pages tail) and wiki.js expression pieces only —
|
|
24
|
+
* see skeletons.ts for the raw strings. Consumers (tools.ts, todo 14/15 pilot)
|
|
25
|
+
* import everything from this barrel.
|
|
26
|
+
*/
|
|
27
|
+
import { G1_EN, G1_ZH, G2_EN, G2_ZH, G3_EN, G3_ZH, G4_EN, G4_ZH, G5_EN, G5_ZH, } from './skeletons.js';
|
|
28
|
+
// Contract re-exports: raw skeleton data stays reachable through the barrel
|
|
29
|
+
// (todo 13 renders these double-checked strings into skill references).
|
|
30
|
+
export { G1_EN, G1_ZH, G2_EN, G2_ZH, G3_EN, G3_ZH, G4_EN, G4_ZH, G5_EN, G5_ZH, } from './skeletons.js';
|
|
31
|
+
export const GENRES = ['G1', 'G2', 'G3', 'G4', 'G5'];
|
|
32
|
+
// --- genreSkeleton ----------------------------------------------------------
|
|
33
|
+
const SKELETONS = {
|
|
34
|
+
G1: { en: G1_EN, zh: G1_ZH },
|
|
35
|
+
G2: { en: G2_EN, zh: G2_ZH },
|
|
36
|
+
G3: { en: G3_EN, zh: G3_ZH },
|
|
37
|
+
G4: { en: G4_EN, zh: G4_ZH },
|
|
38
|
+
G5: { en: G5_EN, zh: G5_ZH },
|
|
39
|
+
};
|
|
40
|
+
/** Full markdown skeleton for a genre × language pair. Pure string data —
|
|
41
|
+
* the author copies it, replaces placeholders, and fills the commented slots. */
|
|
42
|
+
export function genreSkeleton(genre, lang) {
|
|
43
|
+
return SKELETONS[genre][lang];
|
|
44
|
+
}
|
|
45
|
+
// --- classifyGenre ----------------------------------------------------------
|
|
46
|
+
/** Title hits weigh TITLE_WEIGHT× a body hit: a title naming the genre (e.g.
|
|
47
|
+
* "Postmortem") is the strongest signal; body cues still move the needle. */
|
|
48
|
+
const TITLE_WEIGHT = 3;
|
|
49
|
+
const BODY_WEIGHT = 1;
|
|
50
|
+
/** Never scan more than this many chars of body (truncation guard). */
|
|
51
|
+
const BODY_TRUNCATE_CHARS = 200_000;
|
|
52
|
+
/** Keyword vocabulary, one set per genre. Latin cues are matched on word
|
|
53
|
+
* boundaries (case-insensitive) so "vs" never fires inside "moves"/"versus".
|
|
54
|
+
* Keep this table in sync with skills references when it changes. */
|
|
55
|
+
const GENRE_KEYWORDS = {
|
|
56
|
+
G1: ['故障', '复盘', '事故', 'incident', 'postmortem', 'outage'],
|
|
57
|
+
G2: ['对比', '选型', 'vs', 'versus', 'compare', 'benchmark', 'alternatives'],
|
|
58
|
+
G3: ['清单', '列表', 'inventory', 'checklist', 'catalog', '命令速查'],
|
|
59
|
+
G4: ['原理', '为什么', 'how it works', '概念', '机制'],
|
|
60
|
+
// Deployed-state cues only — bare 部署/版本 would collide with G3 "部署清单"
|
|
61
|
+
// and general changelog talk, regressing existing corpus classifications.
|
|
62
|
+
G5: ['现状卡', '当前状态', '已部署', '部署物', '现役', '上线', '端口', '上次核实', '失效策略', '验证命令', 'current state', 'deployed', 'last verified', 'running now'],
|
|
63
|
+
};
|
|
64
|
+
function escapeRegex(s) {
|
|
65
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
66
|
+
}
|
|
67
|
+
/** Occurrence count of one keyword in a haystack: word-boundary regex for
|
|
68
|
+
* latin cues, plain substring scan for CJK (non-overlapping). */
|
|
69
|
+
function countMatches(haystack, keyword) {
|
|
70
|
+
if (/[A-Za-z]/.test(keyword)) {
|
|
71
|
+
return (haystack.match(new RegExp(`\\b${escapeRegex(keyword)}\\b`, 'gi')) ?? []).length;
|
|
72
|
+
}
|
|
73
|
+
let count = 0;
|
|
74
|
+
let idx = haystack.indexOf(keyword);
|
|
75
|
+
while (idx !== -1) {
|
|
76
|
+
count += 1;
|
|
77
|
+
idx = haystack.indexOf(keyword, idx + keyword.length);
|
|
78
|
+
}
|
|
79
|
+
return count;
|
|
80
|
+
}
|
|
81
|
+
/** Deterministic keyword scoring. No randomness, no state:
|
|
82
|
+
* - each genre scores Σ(title hits × 3 + body hits × 1) over its vocabulary;
|
|
83
|
+
* - body is truncated to the first 200_000 chars (guard), latin keywords are
|
|
84
|
+
* word-boundary matched;
|
|
85
|
+
* - all-zero scores → G4/low (garbage fallback);
|
|
86
|
+
* - a tie between the two top scores (both > 0) → G4/medium (generalist
|
|
87
|
+
* genre wins the ambiguous case);
|
|
88
|
+
* - otherwise the top genre wins; confidence = margin ≥ 3 → high, else medium.
|
|
89
|
+
* `signals` names the matched cues (used by the pilot engine, todo 14/15). */
|
|
90
|
+
export function classifyGenre(input) {
|
|
91
|
+
const title = input.title.toLowerCase();
|
|
92
|
+
const rawBody = input.body;
|
|
93
|
+
const body = (rawBody.length > BODY_TRUNCATE_CHARS ? rawBody.slice(0, BODY_TRUNCATE_CHARS) : rawBody).toLowerCase();
|
|
94
|
+
const scores = new Map();
|
|
95
|
+
const signals = [];
|
|
96
|
+
for (const genre of GENRES) {
|
|
97
|
+
let score = 0;
|
|
98
|
+
for (const keyword of GENRE_KEYWORDS[genre]) {
|
|
99
|
+
const titleHits = countMatches(title, keyword);
|
|
100
|
+
const bodyHits = countMatches(body, keyword);
|
|
101
|
+
if (titleHits > 0 || bodyHits > 0)
|
|
102
|
+
signals.push(keyword);
|
|
103
|
+
score += titleHits * TITLE_WEIGHT + bodyHits * BODY_WEIGHT;
|
|
104
|
+
}
|
|
105
|
+
scores.set(genre, score);
|
|
106
|
+
}
|
|
107
|
+
const [top, second] = [...GENRES].sort((a, b) => (scores.get(b) ?? 0) - (scores.get(a) ?? 0));
|
|
108
|
+
const topScore = scores.get(top) ?? 0;
|
|
109
|
+
const secondScore = scores.get(second) ?? 0;
|
|
110
|
+
if (topScore === 0)
|
|
111
|
+
return { genre: 'G4', confidence: 'low', signals: [] };
|
|
112
|
+
const margin = topScore - secondScore;
|
|
113
|
+
if (margin === 0) {
|
|
114
|
+
// Ambiguous: two genres scored equally — the generalist G4 wins the tie.
|
|
115
|
+
return { genre: 'G4', confidence: 'medium', signals };
|
|
116
|
+
}
|
|
117
|
+
return { genre: top, confidence: margin >= TITLE_WEIGHT ? 'high' : 'medium', signals };
|
|
118
|
+
}
|
|
119
|
+
// --- selfReviewChecklist ----------------------------------------------------
|
|
120
|
+
/**
|
|
121
|
+
* The 10-item self-review gate (plan todo 9). The FULL gate is always
|
|
122
|
+
* returned (ids 1..10 contiguous); the pilot engine (todo 15) applies items
|
|
123
|
+
* via the kind/appliesTo contract:
|
|
124
|
+
* - content items (1–3, 7, 8) — scored on the rewritten draft (dry-run);
|
|
125
|
+
* - genre-specific items (4–6) — N/A=PASS when the page's genre is not in
|
|
126
|
+
* appliesTo (G2 pages only have a source column; only G1 pages have
|
|
127
|
+
* timeline sources and the action-item five essentials; G5 pages swap in
|
|
128
|
+
* the ledger variants: per-row last-verified, verification commands, no
|
|
129
|
+
* narrative prose);
|
|
130
|
+
* - post-write items (9, 10) — scored after apply + written into the
|
|
131
|
+
* pilot report (never scored pre-write, where they are FAIL by
|
|
132
|
+
* construction).
|
|
133
|
+
* `genre` is validated (unknown values throw) so callers cannot silently
|
|
134
|
+
* score against a partial gate.
|
|
135
|
+
*/
|
|
136
|
+
/** G5 swaps the genre-specific items 4–6 for ledger gates; everything else
|
|
137
|
+
* (1–3, 7–10) is shared with the base gate. */
|
|
138
|
+
const G5_CHECKLIST_VARIANTS = {
|
|
139
|
+
4: {
|
|
140
|
+
id: 4,
|
|
141
|
+
label: '部署物清单每行带「上次核实于/Last verified」列,缺日期即过期(ledger component rows carry a last-verified date)',
|
|
142
|
+
kind: 'genre-specific',
|
|
143
|
+
appliesTo: ['G5'],
|
|
144
|
+
},
|
|
145
|
+
5: {
|
|
146
|
+
id: 5,
|
|
147
|
+
label: '验证方法节存在:每个组件对应一条 agent 可直接执行的复核命令(verification section: an executable re-check command per component)',
|
|
148
|
+
kind: 'genre-specific',
|
|
149
|
+
appliesTo: ['G5'],
|
|
150
|
+
},
|
|
151
|
+
6: {
|
|
152
|
+
id: 6,
|
|
153
|
+
label: '无叙事正文:现状卡 = 状态块 + 表格,叙述历史链向 G1 事件页(table+status page: no narrative prose bodies; history links to G1 pages)',
|
|
154
|
+
kind: 'genre-specific',
|
|
155
|
+
appliesTo: ['G5'],
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
export function selfReviewChecklist(genre) {
|
|
159
|
+
if (!GENRES.includes(genre)) {
|
|
160
|
+
throw new Error(`unknown genre: ${genre}`);
|
|
161
|
+
}
|
|
162
|
+
const items = [
|
|
163
|
+
{
|
|
164
|
+
id: 1,
|
|
165
|
+
label: '导言占比 10–15%:导言 ≈ 正文的 10–15%,每个重要小节在导言至少占一句(lead ≈10–15% of body; every major section ≥1 sentence in lead)',
|
|
166
|
+
kind: 'content',
|
|
167
|
+
appliesTo: 'all',
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
id: 2,
|
|
171
|
+
label: '句长上限:中文句 ≤20 字、英文句 ≤25 词(sentence cap: zh ≤20 chars, en ≤25 words)',
|
|
172
|
+
kind: 'content',
|
|
173
|
+
appliesTo: 'all',
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
id: 3,
|
|
177
|
+
label: '表格判据:≥3 字段的结构化枚举入表,成对数据用描述列表(≥3 fields → table; pairs → description list)',
|
|
178
|
+
kind: 'content',
|
|
179
|
+
appliesTo: 'all',
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
id: 4,
|
|
183
|
+
label: '对比表/枚举表每行有来源列,行序固定、无合并单元格(comparison/enumeration table has a source column)',
|
|
184
|
+
kind: 'genre-specific',
|
|
185
|
+
appliesTo: ['G2'],
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
id: 5,
|
|
189
|
+
label: '时间线每行有来源列,仅日志可证事实(timeline rows carry sources; log-verifiable facts only)',
|
|
190
|
+
kind: 'genre-specific',
|
|
191
|
+
appliesTo: ['G1'],
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
id: 6,
|
|
195
|
+
label: '行动项五要素 = 类型|负责人|期限|验证|状态 五列,措施是行内容(action items: five essentials as columns)',
|
|
196
|
+
kind: 'genre-specific',
|
|
197
|
+
appliesTo: ['G1'],
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
id: 7,
|
|
201
|
+
label: '无杂项筐:除 参见/附录 之外没有 "其他/杂项" 类 catch-all 小节(no catch-all sections outside See-Also/Appendix)',
|
|
202
|
+
kind: 'content',
|
|
203
|
+
appliesTo: 'all',
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
id: 8,
|
|
207
|
+
label: '无溢美词:领先/强大/灵活/高效 等 bare claim 改事实或删除(no unbacked praise: 领先/强大/灵活/高效 without evidence)',
|
|
208
|
+
kind: 'content',
|
|
209
|
+
appliesTo: 'all',
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
id: 9,
|
|
213
|
+
label: '双语 URL 已回报:报告含 /en/ 与 /zh/ 两个可访问 URL(post-write: report carries both /en/ and /zh/ URLs)',
|
|
214
|
+
kind: 'post-write',
|
|
215
|
+
appliesTo: 'all',
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
id: 10,
|
|
219
|
+
label: '孪生页已建 或 zh_status:pending 已记录并在报告中声明(twin created OR zh_status pending recorded and declared in report)',
|
|
220
|
+
kind: 'post-write',
|
|
221
|
+
appliesTo: 'all',
|
|
222
|
+
},
|
|
223
|
+
];
|
|
224
|
+
if (genre === 'G5') {
|
|
225
|
+
return items.map((item) => G5_CHECKLIST_VARIANTS[item.id] ?? item);
|
|
226
|
+
}
|
|
227
|
+
return items;
|
|
228
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bilingual G1–G4 page skeletons (template data).
|
|
3
|
+
*
|
|
4
|
+
* Provenance: `.omo/research/cross-cultural-wiki-writing-digest.md` (Genre
|
|
5
|
+
* templates + SYN-1..20) and `.omo/research/wikijs-2x-capabilities-digest.md`
|
|
6
|
+
* (expression syntax), vendored at `docs/research/*`.
|
|
7
|
+
*
|
|
8
|
+
* Contract of every skeleton constant:
|
|
9
|
+
* - Rubric dimension C anatomy: H1 placeholder → status line
|
|
10
|
+
* `**状态/Status**: Active <!-- or Historical/Superseded --> · **日期/Date**: YYYY-MM-DD`
|
|
11
|
+
* → one-line scope (`This page answers:` / `本页回答:`) → tail
|
|
12
|
+
* `Related Pages`/`相关页面` section with a real-link hint (SYN-9 fixed tail).
|
|
13
|
+
* - wiki.js 2.x expression pieces only: blockquote admonitions
|
|
14
|
+
* (`> …` + `{.is-info}`), `{.dense}` tables, `[^1]` footnotes.
|
|
15
|
+
* - FORBIDDEN: `{{toc}}`, `:::` containers, YAML frontmatter (`---` at pos 0) —
|
|
16
|
+
* wiki.js does not support them (they render as body text or v-pre escapes).
|
|
17
|
+
* - Language-native: zh skeletons use 中文节标题 and zh author guidance,
|
|
18
|
+
* en skeletons English; the bilingual pairs correspond section-for-section.
|
|
19
|
+
* - Inline `<!-- … -->` comments carry author guidance per section (what to
|
|
20
|
+
* write, length caps); placeholders are marked 占位/placeholder and must be
|
|
21
|
+
* replaced by the author before publishing.
|
|
22
|
+
*
|
|
23
|
+
* // allow: SIZE_OK — pure template data, one constant per (genre, lang);
|
|
24
|
+
* split across files would buy nothing (each pair is a single narrative).
|
|
25
|
+
*/
|
|
26
|
+
/** G1 — 事件/复盘页 (incident postmortem), zh. */
|
|
27
|
+
export declare const G1_ZH: string;
|
|
28
|
+
/** G1 — incident postmortem, en (section-for-section twin of G1_ZH). */
|
|
29
|
+
export declare const G1_EN: string;
|
|
30
|
+
/** G2 — 对比/选型页 (comparison / selection), zh. */
|
|
31
|
+
export declare const G2_ZH: string;
|
|
32
|
+
/** G2 — comparison / selection, en (section-for-section twin of G2_ZH). */
|
|
33
|
+
export declare const G2_EN: string;
|
|
34
|
+
/** G3 — 清单/参考页 (inventory / reference), zh. */
|
|
35
|
+
export declare const G3_ZH: string;
|
|
36
|
+
/** G3 — inventory / reference, en (section-for-section twin of G3_ZH). */
|
|
37
|
+
export declare const G3_EN: string;
|
|
38
|
+
/** G4 — 概念/原理解析页 (concept / explanation), zh. */
|
|
39
|
+
export declare const G4_ZH: string;
|
|
40
|
+
/** G4 — concept / explanation, en (section-for-section twin of G4_ZH). */
|
|
41
|
+
export declare const G4_EN: string;
|
|
42
|
+
/** G5 — 现状卡/部署现状账本页 (current-state ledger), zh. Status + tables only:
|
|
43
|
+
* one authoritative snapshot of what is deployed/running NOW, per-row
|
|
44
|
+
* last-verified dates, agent-executable re-check commands. Narrative history
|
|
45
|
+
* belongs to G1 event pages, linked from 变更记录. */
|
|
46
|
+
export declare const G5_ZH: string;
|
|
47
|
+
/** G5 — current-state ledger, en (section-for-section twin of G5_ZH). */
|
|
48
|
+
export declare const G5_EN: string;
|