@cloud411716/fancy-webnovel 2.2.0 → 3.0.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/client.js +201 -15
- package/cordis.patch.yml +5 -0
- package/docs/douyin-plugin.md +118 -0
- package/docs/material-library/README.md +404 -0
- package/docs/material-library/import.js +437 -0
- package/docs/material-library/prompt.md +370 -0
- package/docs/material-library/queries.surql +94 -0
- package/docs/material-library/sample.jsonl +14 -0
- package/docs/material-library/schema.surql +46 -0
- package/docs/mcp-profile.example.yml +24 -0
- package/docs/story-graph-design.md +602 -0
- package/docs/story-graph-validation.md +81 -0
- package/docs/workspace-mcp.md +284 -0
- package/index.js +11 -7
- package/lib/atomic-write.js +109 -0
- package/lib/chapter-order.js +100 -0
- package/lib/confirmation.js +80 -0
- package/lib/db-init.js +123 -0
- package/lib/deployed-state.js +11 -11
- package/lib/direction-labels.js +265 -0
- package/lib/error-codes.js +31 -31
- package/lib/infra.js +29 -4
- package/lib/material-db.js +149 -0
- package/lib/path-guard.js +150 -0
- package/lib/projects.js +400 -0
- package/lib/scraper-utils.js +140 -0
- package/lib/surreal-server.js +338 -0
- package/lib/topic-decision.js +860 -0
- package/lib/topic-fields.js +251 -0
- package/lib/topic-schema.js +225 -0
- package/lib/workspace.js +501 -0
- package/package.json +29 -4
- package/plugins/fancy-analyze/analyze-definitions.md +519 -0
- package/plugins/fancy-analyze/index.js +1128 -77
- package/plugins/fancy-analyze/ingest-coverage.js +107 -0
- package/plugins/fancy-analyze/schema-analyze.md +355 -107
- package/plugins/fancy-analyze/scripts/append-summary-chapter.js +612 -0
- package/plugins/fancy-analyze/summary-progress.js +71 -7
- package/plugins/fancy-analyze/templates.js +437 -139
- package/plugins/fancy-bootstrap/index.js +276 -131
- package/plugins/fancy-bootstrap/scripts/init.js +59 -25
- package/plugins/fancy-bootstrap/templates.js +35 -18
- package/plugins/fancy-db/index.js +172 -0
- package/plugins/fancy-douyin/deploy.js +134 -0
- package/plugins/fancy-douyin/index.js +551 -0
- package/plugins/fancy-douyin/prompts/01-analysis-prompt.md +84 -0
- package/plugins/fancy-douyin/scripts/crawl-note.cjs +194 -0
- package/plugins/fancy-douyin/scripts/lib.cjs +197 -0
- package/plugins/fancy-douyin/scripts/scan-account.cjs +128 -0
- package/plugins/fancy-douyin/scripts/start-chrome.cjs +203 -0
- package/plugins/fancy-douyin/state.js +197 -0
- package/plugins/fancy-douyin/templates.js +127 -0
- package/plugins/fancy-export/exporter.js +805 -0
- package/plugins/fancy-export/index.js +63 -0
- package/plugins/fancy-import/import-driver.js +29 -6
- package/plugins/fancy-import/index.js +13 -5
- package/plugins/fancy-import/prompts/01-import-prompt.md +39 -14
- package/plugins/fancy-import/templates.js +17 -8
- package/plugins/fancy-material/exporter.js +153 -0
- package/plugins/fancy-material/genres.js +256 -0
- package/plugins/fancy-material/index.js +835 -0
- package/plugins/fancy-material/inventory.js +143 -0
- package/plugins/fancy-material/kinds.js +313 -0
- package/plugins/fancy-material/queue.js +55 -0
- package/plugins/fancy-material/scripts/query.js +254 -0
- package/plugins/fancy-material/templates.js +258 -0
- package/plugins/fancy-material/tool-deploy.js +138 -0
- package/plugins/fancy-material/validate.js +149 -0
- package/plugins/fancy-project/index.js +133 -0
- package/plugins/fancy-scan/existing-block.js +100 -0
- package/plugins/fancy-scan/index.js +410 -21
- package/plugins/fancy-scan/prompts/01-analysis-prompt.md +302 -0
- package/plugins/fancy-scan/scripts/append-block.js +725 -0
- package/plugins/fancy-scan/scripts/report-validate.js +824 -0
- package/plugins/fancy-scan/scripts/run-scan.js +169 -22
- package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +145 -16
- package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +179 -49
- package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +38 -20
- package/plugins/fancy-scan/templates/blocks.js +155 -0
- package/plugins/fancy-scan/templates/platforms.js +372 -0
- package/plugins/fancy-scan/templates/report-schema.js +90 -0
- package/plugins/fancy-scan/templates.js +545 -146
- package/plugins/fancy-scan/tool-deploy.js +335 -0
- package/plugins/fancy-scan/wait-turn.js +46 -0
- package/plugins/fancy-story-builder/db/README.md +106 -0
- package/plugins/fancy-story-builder/db/client.js +228 -0
- package/plugins/fancy-story-builder/db/commands.js +145 -0
- package/plugins/fancy-story-builder/db/ids.js +39 -0
- package/plugins/fancy-story-builder/db/import.js +798 -0
- package/plugins/fancy-story-builder/db/project.js +299 -0
- package/plugins/fancy-story-builder/db/query.js +361 -0
- package/plugins/fancy-story-builder/db/schema.js +417 -0
- package/plugins/fancy-story-builder/db/stage-sync.js +238 -0
- package/plugins/fancy-story-builder/flesh-output.js +19 -6
- package/plugins/fancy-story-builder/index.js +4105 -396
- package/plugins/fancy-story-builder/patch-driver.js +390 -0
- package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +29 -19
- package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +72 -10
- package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +40 -5
- package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +37 -0
- package/plugins/fancy-story-builder/purpose-template.md +42 -66
- package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -56
- package/plugins/fancy-story-builder/references/genre-profiles.json +3514 -0
- package/plugins/fancy-story-builder/references/glossary.md +15 -6
- package/plugins/fancy-story-builder/references/index.md +97 -6
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +1 -1
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +25 -7
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +5 -1
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +6 -2
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +15 -5
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +1 -1
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +2 -1
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +1 -1
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +19 -1
- package/plugins/fancy-story-builder/schema-setting.json +1100 -0
- package/plugins/fancy-story-builder/schema-setting.md +201 -293
- package/plugins/fancy-story-builder/scripts/brief-validate.js +164 -0
- package/plugins/fancy-story-builder/scripts/content-quality.js +4 -1
- package/plugins/fancy-story-builder/scripts/flesh-validate.js +135 -12
- package/plugins/fancy-story-builder/scripts/graph-utils.js +146 -47
- package/plugins/fancy-story-builder/scripts/reference-log.js +110 -0
- package/plugins/fancy-story-builder/scripts/rhythm-validate.js +100 -4
- package/plugins/fancy-story-builder/scripts/schema-pages.js +2668 -0
- package/plugins/fancy-story-builder/scripts/semantic-lint.js +5 -1
- package/plugins/fancy-story-builder/scripts/skeleton-validate.js +158 -17
- package/plugins/fancy-story-builder/scripts/stage-batches.js +36 -2
- package/plugins/fancy-story-builder/scripts/stage-contract.js +12 -0
- package/plugins/fancy-story-builder/scripts/story-artifacts.js +933 -0
- package/plugins/fancy-story-builder/scripts/upstream-query.js +1 -1
- package/plugins/fancy-story-builder/scripts/v2/baseline.js +112 -0
- package/plugins/fancy-story-builder/scripts/v2/cast.js +517 -0
- package/plugins/fancy-story-builder/scripts/v2/context.js +434 -0
- package/plugins/fancy-story-builder/scripts/v2/contracts.js +493 -0
- package/plugins/fancy-story-builder/scripts/v2/core.js +419 -0
- package/plugins/fancy-story-builder/scripts/v2/engine.js +1068 -0
- package/plugins/fancy-story-builder/scripts/v2/export-markdown.js +794 -0
- package/plugins/fancy-story-builder/scripts/v2/genre.js +195 -0
- package/plugins/fancy-story-builder/scripts/v2/knowledge.js +321 -0
- package/plugins/fancy-story-builder/scripts/v2/macro.js +409 -0
- package/plugins/fancy-story-builder/scripts/v2/manifest.js +538 -0
- package/plugins/fancy-story-builder/scripts/v2/migrate.js +137 -0
- package/plugins/fancy-story-builder/scripts/v2/prompts.js +1335 -0
- package/plugins/fancy-story-builder/scripts/v2/rhythm.js +618 -0
- package/plugins/fancy-story-builder/scripts/v2/scale.js +843 -0
- package/plugins/fancy-story-builder/scripts/v2/snapshot.js +121 -0
- package/plugins/fancy-story-builder/scripts/v2/stages.js +273 -0
- package/plugins/fancy-story-builder/scripts/v2/writing.js +216 -0
- package/plugins/fancy-story-builder/storydb/README.md +271 -0
- package/plugins/fancy-story-builder/storydb/cli.js +458 -0
- package/plugins/fancy-story-builder/storydb/client.js +351 -0
- package/plugins/fancy-story-builder/storydb/compile.js +452 -0
- package/plugins/fancy-story-builder/storydb/context.js +288 -0
- package/plugins/fancy-story-builder/storydb/definitions.js +699 -0
- package/plugins/fancy-story-builder/storydb/driver.js +106 -0
- package/plugins/fancy-story-builder/storydb/dsh-tools.js +313 -0
- package/plugins/fancy-story-builder/storydb/export.js +175 -0
- package/plugins/fancy-story-builder/storydb/ids.js +31 -0
- package/plugins/fancy-story-builder/storydb/impact.js +170 -0
- package/plugins/fancy-story-builder/storydb/import.js +559 -0
- package/plugins/fancy-story-builder/storydb/mcp.js +99 -0
- package/plugins/fancy-story-builder/storydb/prompts.js +952 -0
- package/plugins/fancy-story-builder/storydb/stage-command.js +379 -0
- package/plugins/fancy-story-builder/storydb/stage.js +426 -0
- package/plugins/fancy-story-builder/storydb/store.js +891 -0
- package/plugins/fancy-story-builder/storydb/tools.js +592 -0
- package/plugins/fancy-story-builder/storydb/validation.js +183 -0
- package/plugins/fancy-story-builder/templates/v2/10-scale/scale-template.json +144 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/emotion-template.json +15 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/endings-template.json +16 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/lines-template.json +28 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/roles-template.json +10 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/story-template.json +19 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/volumes-template.json +24 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/world-template.json +24 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/characters-template.json +49 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/climaxes-template.json +14 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/conflicts-template.json +30 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/factions-template.json +18 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/foreshadows-template.json +29 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/information-template.json +29 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/items-template.json +13 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/locations-template.json +12 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/payoffs-template.json +16 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/relations-template.json +44 -0
- package/plugins/fancy-story-builder/templates/v2/40-rhythm/chapter-map-template.json +81 -0
- package/plugins/fancy-story-builder/templates/v2/40-rhythm/outline-template.json +162 -0
- package/plugins/fancy-story-builder/templates/v2/50-writing/ledger-template.json +46 -0
- package/plugins/fancy-story-builder/templates/v2/60-deslop/report-template.json +10 -0
- package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//346/227/266/345/272/217/346/230/240/345/260/204/346/250/241/346/235/277.json +267 -44
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//344/270/226/347/225/214/350/247/202/350/247/204/345/210/231/346/250/241/346/235/277.json +121 -19
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/263/351/224/256/351/201/223/345/205/267/346/250/241/346/235/277.json +129 -21
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/266/344/273/226/345/256/236/344/275/223/346/250/241/346/235/277.json +62 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/206/262/347/252/201/346/250/241/346/235/277.json +76 -17
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/211/247/346/203/205/346/250/241/346/235/277.json +56 -8
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/212/277/345/212/233/346/250/241/346/235/277.json +63 -13
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/234/260/347/202/271/346/250/241/346/235/277.json +34 -6
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//347/273/223/345/261/200/346/250/241/346/235/277.json +81 -18
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//350/247/222/350/211/262/346/250/241/346/235/277.json +202 -42
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/205/263/347/263/273/346/250/241/346/235/277.json +160 -27
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/256/236/344/275/223/346/250/241/346/235/277.json +217 -41
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//346/241/206/346/236/266/346/250/241/346/235/277.json +215 -41
- package/plugins/fancy-story-builder/templates.js +272 -74
- package/plugins/fancy-story-builder/wiki-references.js +429 -93
- package/plugins/fancy-story-builder/writing-driver.js +336 -9
- package/plugins/fancy-topic/index.js +414 -61
- package/plugins/fancy-topic/prompts/01-exploration-prompt.md +115 -0
- package/plugins/fancy-topic/scripts/save-suggestion.js +219 -0
- package/plugins/fancy-topic/scripts/select-topic.js +106 -63
- package/plugins/fancy-topic/templates.js +225 -122
- package/plugins/fancy-topic/tool-deploy.js +200 -0
- package/lib/safe-rm.js +0 -94
|
@@ -16,18 +16,23 @@
|
|
|
16
16
|
* 不依赖 phase:这个 skill 可以独立于整条流水线使用
|
|
17
17
|
* (用户在写正文之前、中途、之后都可以调用来分析参考爆款)
|
|
18
18
|
*/
|
|
19
|
-
import { existsSync, statSync, readdirSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, unlinkSync, renameSync } from 'fs';
|
|
19
|
+
import { existsSync, statSync, lstatSync, chmodSync, readdirSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, unlinkSync, renameSync } from 'fs';
|
|
20
20
|
import { join, dirname, basename } from 'path';
|
|
21
21
|
import { fileURLToPath } from 'url';
|
|
22
|
+
import { atomicWriteFile } from '#atomic-write';
|
|
22
23
|
import { askUserQuestion, spawnScript } from '#infra';
|
|
23
24
|
import { notify, llmFollowup } from '#infra';
|
|
24
25
|
import { describeFetchError, llmWikiError, readJsonSafe } from '#llm-wiki';
|
|
25
26
|
import { readDeployedState, DEPLOYED_STATUS } from '#deployed-state';
|
|
27
|
+
import { resolveWorkspace } from '#workspace';
|
|
28
|
+
import { sortByChapter, chapterNumber } from '#chapter-order';
|
|
29
|
+
import { findMissingIngestOutputs } from './ingest-coverage.js';
|
|
26
30
|
import { analyze } from './templates.js';
|
|
27
31
|
import {
|
|
28
32
|
scanSummaryProgress,
|
|
29
33
|
dirsToDispatch,
|
|
30
|
-
|
|
34
|
+
listSummaryDirs,
|
|
35
|
+
LEGACY_STRUCTURE_DIRS,
|
|
31
36
|
} from './summary-progress.js';
|
|
32
37
|
import { normalizeCommandInput, matchSubcommand } from './command-input.js';
|
|
33
38
|
|
|
@@ -35,7 +40,104 @@ export const inject = ['commands', 'userQuestions'];
|
|
|
35
40
|
|
|
36
41
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
37
42
|
const SPLIT_SCRIPT = join(__dirname, 'scripts', 'split_source.js');
|
|
43
|
+
// 登记脚本:把"追加技法内容 + 登记章节"做成幂等原子的操作。
|
|
44
|
+
// 让模型手工编辑 YAML 头部很容易把已登记的清单冲掉 —— 头部是唯一事实源,
|
|
45
|
+
// 冲掉了下次 summary 会把老章节全部重做一遍。
|
|
46
|
+
//
|
|
47
|
+
// **脚本不在插件目录里执行** —— 插件目录在 node_modules 下(部署后位置
|
|
48
|
+
// 还不一样),让模型去那里跑文件既别扭又易变。派发提示词前把它复制到
|
|
49
|
+
// 项目里的 .fancy-tools/,提示词里给项目内的路径。
|
|
50
|
+
const REGISTER_SCRIPT = join(__dirname, 'scripts', 'append-summary-chapter.js');
|
|
51
|
+
const REGISTER_TOOL_DIR = '.fancy-tools';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 确保项目里有最新版登记脚本,返回它的绝对路径(失败返回 null)。
|
|
55
|
+
*
|
|
56
|
+
* 内容比对后才写 —— 每次 summary 都覆盖一次的话会白白改 mtime,
|
|
57
|
+
* 也会让"这个文件是不是被人手改过"变得看不出来。
|
|
58
|
+
*
|
|
59
|
+
* @param {string} projectRoot
|
|
60
|
+
* @returns {string|null}
|
|
61
|
+
*/
|
|
62
|
+
export function ensureRegisterScript(projectRoot) {
|
|
63
|
+
try {
|
|
64
|
+
if (!projectRoot || typeof projectRoot !== 'string') return null;
|
|
65
|
+
if (!existsSync(projectRoot) || !statSync(projectRoot).isDirectory()) return null;
|
|
66
|
+
if (!existsSync(REGISTER_SCRIPT)) return null;
|
|
67
|
+
|
|
68
|
+
const destDir = join(projectRoot, REGISTER_TOOL_DIR);
|
|
69
|
+
let dirStat = null;
|
|
70
|
+
try { dirStat = lstatSync(destDir); } catch { /* 不存在 */ }
|
|
71
|
+
if (dirStat) {
|
|
72
|
+
// .fancy-tools 绝不能是符号链接 —— 否则脚本会被写到项目外。
|
|
73
|
+
if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) {
|
|
74
|
+
console.error('[fancy-analyze] .fancy-tools 不是真实目录,拒绝部署登记脚本');
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
mkdirSync(destDir);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const dest = join(destDir, 'append-summary-chapter.js');
|
|
82
|
+
let destStat = null;
|
|
83
|
+
try { destStat = lstatSync(dest); } catch { /* 不存在 */ }
|
|
84
|
+
if (destStat && (destStat.isSymbolicLink() || !destStat.isFile())) {
|
|
85
|
+
console.error('[fancy-analyze] 登记脚本目标不是普通文件,拒绝覆盖:' + dest);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const srcContent = readFileSync(REGISTER_SCRIPT, 'utf-8');
|
|
90
|
+
if (destStat) {
|
|
91
|
+
const same = readFileSync(dest, 'utf-8') === srcContent;
|
|
92
|
+
if (!same) atomicWriteFile(dest, srcContent, { mode: 0o444 });
|
|
93
|
+
// 内容没变也要去掉写权限(老项目里可能是 0644)。
|
|
94
|
+
chmodSync(dest, 0o444);
|
|
95
|
+
return dest;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
atomicWriteFile(dest, srcContent, { mode: 0o444 });
|
|
99
|
+
return dest;
|
|
100
|
+
} catch (err) {
|
|
101
|
+
console.error('[fancy-analyze] 准备登记脚本失败:' + (err && err.message ? err.message : String(err)));
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
38
105
|
const SCHEMA_SOURCE = join(__dirname, 'schema-analyze.md');
|
|
106
|
+
const DEFINITIONS_SOURCE = join(__dirname, 'analyze-definitions.md');
|
|
107
|
+
const DEFINITIONS_FILENAME = '分析定义.md';
|
|
108
|
+
|
|
109
|
+
/** 按章写作技法(与 schema-analyze.md 的 13 项保持一致)。 */
|
|
110
|
+
const CHAPTER_WRITING_TYPES = [
|
|
111
|
+
'叙事视角',
|
|
112
|
+
'章节结构',
|
|
113
|
+
'节奏韵律',
|
|
114
|
+
'对话技巧',
|
|
115
|
+
'描写技法',
|
|
116
|
+
'情节机制',
|
|
117
|
+
'开头钩子',
|
|
118
|
+
'结尾收束',
|
|
119
|
+
'句式变化',
|
|
120
|
+
'段落布局',
|
|
121
|
+
'场景转换',
|
|
122
|
+
'情绪渲染',
|
|
123
|
+
'张力构建',
|
|
124
|
+
];
|
|
125
|
+
/** 只做称呼/关系称谓、通常不该单独建角色页的名字。 */
|
|
126
|
+
const RELATION_ONLY_ENTITY_RE = /^(?:哥哥|姐姐|弟弟|妹妹|父亲|母亲|爸爸|妈妈|爷爷|奶奶|外公|外婆|师父|师傅|老师|老板|同学|朋友|儿子|女儿)$/u;
|
|
127
|
+
/** 按章建页的类型集合(13 写作技法 + 章节功能设计)。 */
|
|
128
|
+
const CHAPTER_PAGE_TYPES = new Set([...CHAPTER_WRITING_TYPES, '章节功能设计']);
|
|
129
|
+
|
|
130
|
+
/** 结构设计页类型(与 schema-analyze.md 的 8 项保持一致)。 */
|
|
131
|
+
const ENTITY_STRUCTURE_TYPES = [
|
|
132
|
+
'人物关系设计',
|
|
133
|
+
'势力结构',
|
|
134
|
+
'世界观设计',
|
|
135
|
+
'冲突设计',
|
|
136
|
+
'主线支线结构',
|
|
137
|
+
'分卷节奏',
|
|
138
|
+
'伏笔布局',
|
|
139
|
+
'角色弧光设计',
|
|
140
|
+
];
|
|
39
141
|
|
|
40
142
|
/** resummary 备份旧总结文件的目录(放在 对标/ 下,不在 wiki 树里,免得被当成技法目录)。 */
|
|
41
143
|
const SUMMARY_BACKUP_DIRNAME = '.summary-backup';
|
|
@@ -52,11 +154,16 @@ function backupStamp(ts) {
|
|
|
52
154
|
}
|
|
53
155
|
|
|
54
156
|
function getProjectRoot(session) {
|
|
55
|
-
//
|
|
56
|
-
// 宿主若恰好在某个含 .fancy-deployed 的目录里启动,会对错误的项目执行部署与写盘。
|
|
57
|
-
// 此时返回 null,由调用方给出明确报错。
|
|
157
|
+
// Analysis is shared at the workspace level: <workspace>/对标.
|
|
58
158
|
const cwd = session.header?.cwd;
|
|
59
|
-
|
|
159
|
+
if (!cwd) return null;
|
|
160
|
+
try {
|
|
161
|
+
return resolveWorkspace({ cwd, required: true });
|
|
162
|
+
} catch {
|
|
163
|
+
// Fall back to the session cwd so the caller can report "not initialized"
|
|
164
|
+
// with the right path instead of "no project root".
|
|
165
|
+
return cwd;
|
|
166
|
+
}
|
|
60
167
|
}
|
|
61
168
|
|
|
62
169
|
/**
|
|
@@ -180,6 +287,68 @@ async function queryIngestStatus(baseUrl, token, projectId) {
|
|
|
180
287
|
return callLlmWikiApi(baseUrl, token, `/projects/${projectId}/ingest/querystatus`, 'GET', null);
|
|
181
288
|
}
|
|
182
289
|
|
|
290
|
+
/**
|
|
291
|
+
* summary / resummary 的前置门禁:ingest 必须真的完成。
|
|
292
|
+
*
|
|
293
|
+
* 只看本地 `对标/wiki` 存在是不够的:ingest 还在排队时,wiki 目录可能已经
|
|
294
|
+
* 有一半页面,summary 会把“还没落盘的章节”当成“没有输入”或永远等不到。
|
|
295
|
+
*
|
|
296
|
+
* 这里用 llm_wiki 的 /ingest/querystatus:
|
|
297
|
+
* - missingFiles 非空 → 还有源文件没进 wiki;
|
|
298
|
+
* - queue.pending / processing > 0 → 队列还在跑;
|
|
299
|
+
* - queue.failed > 0 → 有失败任务,需要 /fancy-analyze fix。
|
|
300
|
+
* 任一条成立都不让 summary 开工。
|
|
301
|
+
*
|
|
302
|
+
* @returns {{ok:boolean, text?:string}}
|
|
303
|
+
*/
|
|
304
|
+
async function checkIngestReadyForSummary(projectRoot, opts = {}) {
|
|
305
|
+
const command = opts.command || 'summary';
|
|
306
|
+
const { config, errorText } = loadDeployedConfig(projectRoot);
|
|
307
|
+
if (errorText) return { ok: false, text: errorText };
|
|
308
|
+
const referenceId = config && typeof config.reference_id === 'string' ? config.reference_id.trim() : '';
|
|
309
|
+
if (!referenceId) {
|
|
310
|
+
return { ok: false, text: analyze.notify({ type: 'no_reference_id' }) };
|
|
311
|
+
}
|
|
312
|
+
const llmWikiUrl = (config.llm_wiki_url || '').replace(/\/$/, '');
|
|
313
|
+
const llmWikiToken = config.llm_wiki_token || '';
|
|
314
|
+
if (!llmWikiUrl || !llmWikiToken) {
|
|
315
|
+
return { ok: false, text: analyze.notify({ type: 'llm_wiki_not_configured' }) };
|
|
316
|
+
}
|
|
317
|
+
let result;
|
|
318
|
+
try {
|
|
319
|
+
result = await queryIngestStatus(llmWikiUrl, llmWikiToken, referenceId);
|
|
320
|
+
} catch (err) {
|
|
321
|
+
return { ok: false, text: analyze.notify({
|
|
322
|
+
type: 'status_query_failed',
|
|
323
|
+
err: describeFetchError(err),
|
|
324
|
+
}) };
|
|
325
|
+
}
|
|
326
|
+
const apiErr = llmWikiError(result);
|
|
327
|
+
if (apiErr) {
|
|
328
|
+
return { ok: false, text: analyze.notify({ type: 'status_query_failed', err: apiErr }) };
|
|
329
|
+
}
|
|
330
|
+
const data = result.data || {};
|
|
331
|
+
// 和 status 用同一套排序:服务端返回 missingFiles 的顺序是随机的,
|
|
332
|
+
// summary / summary-status / resummary 三个入口都走这里,统一按书名+章号排。
|
|
333
|
+
const missingFiles = sortByChapter(Array.isArray(data.missingFiles) ? data.missingFiles : []);
|
|
334
|
+
const queue = data.queue && typeof data.queue === 'object' ? data.queue : {};
|
|
335
|
+
const pending = Number(queue.pending) || 0;
|
|
336
|
+
const processing = Number(queue.processing) || 0;
|
|
337
|
+
const failed = Number(queue.failed) || 0;
|
|
338
|
+
if (missingFiles.length || pending || processing || failed) {
|
|
339
|
+
return {
|
|
340
|
+
ok: false,
|
|
341
|
+
text: analyze.notify({
|
|
342
|
+
type: 'summary_ingest_not_ready',
|
|
343
|
+
command,
|
|
344
|
+
missingFiles,
|
|
345
|
+
queue: { ...queue, pending, processing, failed },
|
|
346
|
+
}),
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
return { ok: true };
|
|
350
|
+
}
|
|
351
|
+
|
|
183
352
|
/**
|
|
184
353
|
* 在 参考资料/ 下查找匹配的源文件
|
|
185
354
|
*
|
|
@@ -207,17 +376,24 @@ function findSourceFile(refDir, bookName) {
|
|
|
207
376
|
const targetBase = bookName.trim().split(/[\\/]/).pop();
|
|
208
377
|
const target = stripExt(targetBase);
|
|
209
378
|
|
|
210
|
-
const stat =
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
379
|
+
const stat = (() => {
|
|
380
|
+
try { return statSync(refDir); } catch { return null; }
|
|
381
|
+
})();
|
|
382
|
+
if (!stat) return { error: 'dir_missing' };
|
|
383
|
+
let entries = [];
|
|
384
|
+
try {
|
|
385
|
+
entries = stat.isDirectory() ? readdirSync(refDir) : [];
|
|
386
|
+
} catch {
|
|
387
|
+
entries = [];
|
|
388
|
+
}
|
|
214
389
|
|
|
215
390
|
// 情况:参考资料/<书名>.txt(文件)
|
|
216
391
|
const directMatches = [];
|
|
217
392
|
for (const name of entries) {
|
|
218
393
|
if (stripExt(name) === target) {
|
|
219
394
|
const full = join(refDir, name);
|
|
220
|
-
|
|
395
|
+
let st = null;
|
|
396
|
+
try { st = statSync(full); } catch { continue; }
|
|
221
397
|
if (st.isFile()) {
|
|
222
398
|
directMatches.push({ path: full, kind: 'file' });
|
|
223
399
|
}
|
|
@@ -238,6 +414,181 @@ function findSourceFile(refDir, bookName) {
|
|
|
238
414
|
return { error: 'not_found', matches: allCandidates.map(c => c.name) };
|
|
239
415
|
}
|
|
240
416
|
|
|
417
|
+
/**
|
|
418
|
+
* 从来源页里解析 `### 列表` / `### 结构设计分析` 的「| 名称 | 有/无 |」表格。
|
|
419
|
+
*/
|
|
420
|
+
function parseHasTable(content, heading) {
|
|
421
|
+
const headingIndex = content.indexOf(heading);
|
|
422
|
+
if (headingIndex === -1) return {};
|
|
423
|
+
const after = content.substring(headingIndex);
|
|
424
|
+
const nextHeading = after.match(/\n#{1,3}\s+\S/);
|
|
425
|
+
const tableEnd = nextHeading ? after.indexOf(nextHeading[0]) : after.length;
|
|
426
|
+
const tableContent = after.substring(0, tableEnd);
|
|
427
|
+
const out = {};
|
|
428
|
+
for (const line of tableContent.split('\n')) {
|
|
429
|
+
const row = line.match(/^\|\s*(.+?)\s*\|\s*(有|无)\s*\|/);
|
|
430
|
+
if (row) out[row[1].trim()] = row[2] === '有';
|
|
431
|
+
}
|
|
432
|
+
return out;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* 只保留当前 schema 定义的按章类型。
|
|
437
|
+
* 旧来源页的「### 列表」里还有 伏笔回收/世界观构建/角色弧光 ——
|
|
438
|
+
* 它们是按章页面,新版不会再生成对应页面。不过滤的话 status 会一直
|
|
439
|
+
* 报这三类缺失、fix 重传后又永远补不上。
|
|
440
|
+
*/
|
|
441
|
+
function filterChapterTypes(table) {
|
|
442
|
+
return Object.fromEntries(
|
|
443
|
+
Object.entries(table).filter(([type]) => CHAPTER_PAGE_TYPES.has(type)));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* 从来源页的「### 跨章节结构实体」表里解析实体行。
|
|
448
|
+
* 表头:结构类型 | 实体/线索 | 页面文件 | 本章动作 | 首次章节 | 本次章节
|
|
449
|
+
*/
|
|
450
|
+
function parseEntityRows(content, bookName, chapterKey) {
|
|
451
|
+
const heading = '### 跨章节结构实体';
|
|
452
|
+
const headingIndex = content.indexOf(heading);
|
|
453
|
+
if (headingIndex === -1) return [];
|
|
454
|
+
const after = content.substring(headingIndex);
|
|
455
|
+
const nextHeading = after.match(/\n#{1,3}\s+\S/);
|
|
456
|
+
const tableEnd = nextHeading ? after.indexOf(nextHeading[0]) : after.length;
|
|
457
|
+
const lines = after.substring(0, tableEnd).split('\n');
|
|
458
|
+
const rows = [];
|
|
459
|
+
for (const line of lines) {
|
|
460
|
+
if (!line.trim().startsWith('|')) continue;
|
|
461
|
+
const cells = line.split('|').slice(1, -1).map((cell) => cell.trim());
|
|
462
|
+
if (cells.length < 6) continue;
|
|
463
|
+
const [type, entity, rawPageFile, action, firstChapter, currentChapter] = cells;
|
|
464
|
+
if (!ENTITY_STRUCTURE_TYPES.includes(type)) continue;
|
|
465
|
+
// 模型经常漏掉 .md、加反引号、或者写成 wiki/类型/文件名 的完整路径。
|
|
466
|
+
// 这里统一成裸文件名;带“第X章”的仍保留,交给下面的 invalidPageFile 报错。
|
|
467
|
+
let pageFile = String(rawPageFile || '')
|
|
468
|
+
.trim()
|
|
469
|
+
.replace(/^`+|`+$/g, '')
|
|
470
|
+
.replace(/^\[\[|\]\]$/g, '')
|
|
471
|
+
.trim();
|
|
472
|
+
if (pageFile.includes('/')) pageFile = pageFile.split('/').pop();
|
|
473
|
+
if (pageFile && pageFile !== '无' && pageFile !== '-' && !pageFile.endsWith('.md')) {
|
|
474
|
+
pageFile += '.md';
|
|
475
|
+
}
|
|
476
|
+
if (!pageFile || !pageFile.endsWith('.md')) continue;
|
|
477
|
+
rows.push({
|
|
478
|
+
bookName,
|
|
479
|
+
chapterKey,
|
|
480
|
+
type,
|
|
481
|
+
entity,
|
|
482
|
+
pageFile,
|
|
483
|
+
action,
|
|
484
|
+
// 跨章节实体页的文件名必须稳定:带“第X章”说明模型又按章建页了。
|
|
485
|
+
invalidPageFile: /第\d+章/.test(pageFile),
|
|
486
|
+
firstChapter: firstChapter && firstChapter !== '-' ? firstChapter : chapterKey,
|
|
487
|
+
currentChapter: currentChapter && currentChapter !== '-' ? currentChapter : chapterKey,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
return rows;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* 读实体页 frontmatter 里的 tags(支持行内数组和块列表)。
|
|
495
|
+
* 角色弧光页常把正式姓名/别名写在 tags 里,例如老班页的 tags 含“刘安国”。
|
|
496
|
+
*/
|
|
497
|
+
function parseFrontmatterTags(content) {
|
|
498
|
+
const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(String(content || ''));
|
|
499
|
+
if (!fm) return [];
|
|
500
|
+
const lines = fm[1].split(/\r?\n/);
|
|
501
|
+
const tags = [];
|
|
502
|
+
let inTags = false;
|
|
503
|
+
for (const line of lines) {
|
|
504
|
+
const m = /^tags\s*:\s*(.*)$/.exec(line.trim());
|
|
505
|
+
if (m) {
|
|
506
|
+
const raw = m[1].trim();
|
|
507
|
+
if (raw.startsWith('[') && raw.endsWith(']')) {
|
|
508
|
+
for (const item of raw.slice(1, -1).split(',')) {
|
|
509
|
+
const tag = item.trim().replace(/^["']|["']$/g, '');
|
|
510
|
+
if (tag) tags.push(tag);
|
|
511
|
+
}
|
|
512
|
+
inTags = false;
|
|
513
|
+
} else if (raw) {
|
|
514
|
+
tags.push(raw.replace(/^["']|["']$/g, ''));
|
|
515
|
+
inTags = false;
|
|
516
|
+
} else {
|
|
517
|
+
inTags = true;
|
|
518
|
+
}
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
if (!inTags) continue;
|
|
522
|
+
const item = /^\s*-\s+(.+)$/.exec(line);
|
|
523
|
+
if (item) {
|
|
524
|
+
const tag = item[1].trim().replace(/^["']|["']$/g, '');
|
|
525
|
+
if (tag) tags.push(tag);
|
|
526
|
+
} else if (line.trim()) {
|
|
527
|
+
inTags = false;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return tags;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** 从实体页正文提取“正式姓名为X / 本名X / 又名X”这类别名。 */
|
|
534
|
+
function parseAliasesFromContent(content) {
|
|
535
|
+
const out = [];
|
|
536
|
+
const re = /(?:正式姓名|本名|真名|原名|又名|化名|姓名)\s*[为是::]\s*([\u4e00-\u9fa5]{2,4})/g;
|
|
537
|
+
let match;
|
|
538
|
+
while ((match = re.exec(String(content || ''))) !== null) {
|
|
539
|
+
if (match[1]) out.push(match[1]);
|
|
540
|
+
}
|
|
541
|
+
return out;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function hasChapterHeading(content, chapter) {
|
|
545
|
+
if (!chapter) return true;
|
|
546
|
+
const escaped = escapeRegExp(chapter);
|
|
547
|
+
// 允许 `### 第5章`、`## 第5章 转折`、`### 第5章:转折` 等写法;
|
|
548
|
+
// 但 `第1章` 不能匹配 `第10章`(后面紧跟数字时不放行)。
|
|
549
|
+
return new RegExp(`^#{2,3}\\s*${escaped}(?:\\s|$|[::((—-])`, 'm').test(content);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* fix 重新 ingest 时带给 LLM 的修复指令。
|
|
554
|
+
*
|
|
555
|
+
* 只重传文件不够:模型可能再次漏掉同样的实体页/结构表。把这次
|
|
556
|
+
* 修复要重点检查的项目直接写进 ingest 任务,让分析/生成两个阶段都看到。
|
|
557
|
+
*/
|
|
558
|
+
function buildFixInstruction(bookName, chapter, pageInfo = null) {
|
|
559
|
+
const existing = Array.isArray(pageInfo?.existing) ? pageInfo.existing : [];
|
|
560
|
+
const missing = Array.isArray(pageInfo?.missing) ? pageInfo.missing : [];
|
|
561
|
+
const list = (items) => items.slice(0, 40).map((item) => `- ${item}`).join('\n');
|
|
562
|
+
|
|
563
|
+
const lines = [`这是《${bookName}》${chapter} 的修复性重新 ingest。`, ''];
|
|
564
|
+
|
|
565
|
+
if (existing.length > 0) {
|
|
566
|
+
lines.push('上次已经生成并保留的页面(本次不要重写):');
|
|
567
|
+
lines.push(list(existing));
|
|
568
|
+
lines.push('');
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
lines.push('本次需要补的页面/小节:');
|
|
572
|
+
if (missing.length > 0) {
|
|
573
|
+
lines.push(list(missing));
|
|
574
|
+
} else {
|
|
575
|
+
// 正常不该走到这里;如果真没有 pageInfo,也只能给出本章级的明确动作,
|
|
576
|
+
// 不能再让模型去“猜 status 的缺失清单”。
|
|
577
|
+
lines.push('- 本章没有可枚举的页面级缺失;请重新核对来源页的「### 列表」与「### 跨章节结构实体」表,并按 schema 补齐。');
|
|
578
|
+
}
|
|
579
|
+
lines.push('');
|
|
580
|
+
|
|
581
|
+
lines.push('要求:');
|
|
582
|
+
lines.push('1. 只输出本次缺失页面/小节对应的 FILE 块;已经存在的页面不要重新生成;');
|
|
583
|
+
lines.push('2. 如果缺失的是实体页的 ### 第X章 小节,输出该实体页并只追加本章小节,页面合并会保留旧内容;');
|
|
584
|
+
lines.push('3. 如果缺失的是来源页或整章产物,按 schema 正常生成;');
|
|
585
|
+
lines.push('4. 已有实体页只追加本章小节,不得按章新建,不得换名新建;');
|
|
586
|
+
lines.push('5. 先读 对标/分析定义.md,按其中的术语定义判断,不要自创定义;');
|
|
587
|
+
lines.push('6. 14 种按章类型(13 种写作技法 + 章节功能设计)逐项判断“有/无”,标“有”的必须创建对应按章页面;');
|
|
588
|
+
lines.push('7. 本名/正式名优先;称呼、绰号、简称、集团/公司后缀不要另建实体。');
|
|
589
|
+
return lines.join('\n');
|
|
590
|
+
}
|
|
591
|
+
|
|
241
592
|
/**
|
|
242
593
|
* 拷贝 schema 文件到目标目录
|
|
243
594
|
*/
|
|
@@ -245,13 +596,108 @@ function copySchemaFile(targetDir) {
|
|
|
245
596
|
const targetPath = join(targetDir, 'schema.md');
|
|
246
597
|
mkdirSync(targetDir, { recursive: true });
|
|
247
598
|
copyFileSync(SCHEMA_SOURCE, targetPath);
|
|
599
|
+
// 术语定义是 schema 的配套权威文件,必须和 schema 一起部署。
|
|
600
|
+
copyFileSync(DEFINITIONS_SOURCE, join(targetDir, DEFINITIONS_FILENAME));
|
|
248
601
|
return targetPath;
|
|
249
602
|
}
|
|
250
603
|
|
|
604
|
+
/** 读项目里的 对标/schema.md;不存在或读不了返回 null。 */
|
|
605
|
+
function readAnalyzeSchema(projectRoot) {
|
|
606
|
+
const schemaPath = join(projectRoot, '对标', 'schema.md');
|
|
607
|
+
if (!existsSync(schemaPath)) return null;
|
|
608
|
+
try {
|
|
609
|
+
return readFileSync(schemaPath, 'utf-8');
|
|
610
|
+
} catch {
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* 判断 对标/schema.md 是否是上一版(按章结构设计)。
|
|
617
|
+
* 旧版没有「### 跨章节结构实体」,且带「### 结构设计分析」。
|
|
618
|
+
* 旧 schema 会让 fix 继续生成按章的 伏笔回收/世界观构建/角色弧光 页面,
|
|
619
|
+
* 所以 fix 前必须先把它换成新版。
|
|
620
|
+
*/
|
|
621
|
+
function isLegacyAnalyzeSchema(content) {
|
|
622
|
+
if (!content) return true;
|
|
623
|
+
if (content.includes('### 结构设计分析')) return true;
|
|
624
|
+
if (!content.includes('### 跨章节结构实体')) return true;
|
|
625
|
+
if (!content.includes('世界观设计') || !content.includes('角色弧光设计')) return true;
|
|
626
|
+
return false;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* fix 是否要把 对标/schema.md 刷新成插件当前版本。
|
|
631
|
+
*
|
|
632
|
+
* 不能只看“是不是旧版”:schema 里新加的强制核对清单、命名规则也要
|
|
633
|
+
* 能同步到已经初始化的项目,否则模型看不到最新约束。
|
|
634
|
+
*/
|
|
635
|
+
/**
|
|
636
|
+
* 对标/raw/sources 下是否有任何拆书分片(递归,忽略隐藏目录)。
|
|
637
|
+
*
|
|
638
|
+
* status / fix 的第一步:连源文件都没有,说明还没跑过 /fancy-analyze <书名>,
|
|
639
|
+
* 这时去检查 schema、覆盖、.retry 全是无意义的噪音。
|
|
640
|
+
*
|
|
641
|
+
* @param {string} dir
|
|
642
|
+
* @returns {boolean}
|
|
643
|
+
*/
|
|
644
|
+
function hasAnySourceFiles(dir) {
|
|
645
|
+
let entries;
|
|
646
|
+
try {
|
|
647
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
648
|
+
} catch {
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
for (const entry of entries) {
|
|
652
|
+
if (entry.name.startsWith('.')) continue;
|
|
653
|
+
if (entry.isFile()) return true;
|
|
654
|
+
if (entry.isDirectory() && hasAnySourceFiles(join(dir, entry.name))) return true;
|
|
655
|
+
}
|
|
656
|
+
return false;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* 对标/wiki 下是否已有任何拆解产物(旧项目可能清掉了 raw 分片)。
|
|
661
|
+
*
|
|
662
|
+
* 只看子目录里的 .md:wiki 根目录的 index.md / log.md / overview.md 是
|
|
663
|
+
* llm_wiki 建项目时生成的元数据,不能拿来证明“已经拆解过”。
|
|
664
|
+
*/
|
|
665
|
+
function hasAnyAnalyzeOutput(dir, depth = 0) {
|
|
666
|
+
let entries;
|
|
667
|
+
try {
|
|
668
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
669
|
+
} catch {
|
|
670
|
+
return false;
|
|
671
|
+
}
|
|
672
|
+
for (const entry of entries) {
|
|
673
|
+
if (entry.name.startsWith('.')) continue;
|
|
674
|
+
if (entry.isDirectory() && hasAnyAnalyzeOutput(join(dir, entry.name), depth + 1)) return true;
|
|
675
|
+
if (depth > 0 && entry.isFile() && entry.name.endsWith('.md')) return true;
|
|
676
|
+
}
|
|
677
|
+
return false;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function schemaNeedsRefresh(projectRoot) {
|
|
681
|
+
const current = readAnalyzeSchema(projectRoot);
|
|
682
|
+
if (!current) return true;
|
|
683
|
+
try {
|
|
684
|
+
const latest = readFileSync(SCHEMA_SOURCE, 'utf-8');
|
|
685
|
+
if (current.trim() !== latest.trim()) return true;
|
|
686
|
+
|
|
687
|
+
const defPath = join(projectRoot, '对标', DEFINITIONS_FILENAME);
|
|
688
|
+
if (!existsSync(defPath)) return true;
|
|
689
|
+
const currentDef = readFileSync(defPath, 'utf-8');
|
|
690
|
+
const latestDef = readFileSync(DEFINITIONS_SOURCE, 'utf-8');
|
|
691
|
+
return currentDef.trim() !== latestDef.trim();
|
|
692
|
+
} catch {
|
|
693
|
+
return isLegacyAnalyzeSchema(current);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
251
697
|
export async function apply(ctx) {
|
|
252
698
|
ctx.commands.register({
|
|
253
699
|
name: 'fancy-analyze',
|
|
254
|
-
description: '🔍 拆解爆款小说(参考对标) ( usage: /fancy-analyze [书名] | status | fix | summary | summary-status | resummary [技法名] )',
|
|
700
|
+
description: '🔍 拆解爆款小说(参考对标) ( usage: /fancy-analyze [书名] | status | fix | summary | summary-status | resummary [技法名] | help )',
|
|
255
701
|
input: {
|
|
256
702
|
hint: '.',
|
|
257
703
|
},
|
|
@@ -259,11 +705,15 @@ export async function apply(ctx) {
|
|
|
259
705
|
const session = invocation.agent.session;
|
|
260
706
|
const { signal } = invocation;
|
|
261
707
|
|
|
708
|
+
if ((invocation.rawInput || '').trim().toLowerCase() === 'help') {
|
|
709
|
+
return notify(session, 'success', analyze.notify({ type: 'help' }));
|
|
710
|
+
}
|
|
711
|
+
|
|
262
712
|
const projectRoot = getProjectRoot(session);
|
|
263
713
|
if (!projectRoot) {
|
|
264
714
|
return notify(session, 'error', analyze.notify({
|
|
265
715
|
type: 'no_project_root',
|
|
266
|
-
err: '
|
|
716
|
+
err: '会话未提供当前工作目录'
|
|
267
717
|
}));
|
|
268
718
|
}
|
|
269
719
|
|
|
@@ -321,30 +771,73 @@ export async function apply(ctx) {
|
|
|
321
771
|
return notify(session, 'error', analyze.notify({ type: 'no_reference_id' }));
|
|
322
772
|
}
|
|
323
773
|
|
|
774
|
+
const rawSourcesDir = join(projectRoot, '对标', 'raw', 'sources');
|
|
775
|
+
const hasSources = hasAnySourceFiles(rawSourcesDir);
|
|
776
|
+
const hasOutputs = hasAnyAnalyzeOutput(join(projectRoot, '对标', 'wiki'));
|
|
777
|
+
if (!hasSources && !hasOutputs) {
|
|
778
|
+
return notify(session, 'error', analyze.notify({ type: 'fix_not_analyzed' }));
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// fix 是迁移旧项目的主要入口:先把 对标/schema.md 换成新版,
|
|
782
|
+
// 否则重新 ingest 会继续按旧 schema 生成按章结构页。
|
|
783
|
+
// 这一步放在 .retry 之前:即使没有缺失章节,用户也能用 fix 完成
|
|
784
|
+
// schema 迁移,而不会被“没有需要重试的项目”提前挡回去。
|
|
785
|
+
let schemaUpdated = false;
|
|
786
|
+
try {
|
|
787
|
+
if (schemaNeedsRefresh(projectRoot)) {
|
|
788
|
+
copySchemaFile(join(projectRoot, '对标'));
|
|
789
|
+
schemaUpdated = true;
|
|
790
|
+
}
|
|
791
|
+
} catch (err) {
|
|
792
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
793
|
+
return notify(session, 'error',
|
|
794
|
+
`❌ 无法更新 对标/schema.md:${msg}\n\n` +
|
|
795
|
+
'续跑 fix 仍会按旧 schema 生成按章结构页,所以本次没有提交 ingest。\n' +
|
|
796
|
+
'请确认 对标/ 可写后重试。');
|
|
797
|
+
}
|
|
798
|
+
const schemaNote = schemaUpdated
|
|
799
|
+
? '## 已更新对标 schema\n对标/schema.md 已更新为新版(写作技法 + 跨章节实体页)。\n\n'
|
|
800
|
+
: '';
|
|
801
|
+
const withSchemaNote = (text) => schemaNote ? schemaNote + text : text;
|
|
802
|
+
|
|
324
803
|
// 读取 .retry 文件
|
|
325
804
|
const retryFilePath = join(projectRoot, '.retry');
|
|
326
805
|
if (!existsSync(retryFilePath)) {
|
|
327
|
-
return notify(session, 'error',
|
|
328
|
-
1. 如果已执行过 fancy-analyze status
|
|
329
|
-
2.
|
|
806
|
+
return notify(session, 'error', withSchemaNote(`未找到 .retry 文件。
|
|
807
|
+
1. 如果已执行过 /fancy-analyze status,说明已成功提取,可以继续执行 /fancy-analyze summary;
|
|
808
|
+
2. 如果尚未执行过 /fancy-analyze status,请先执行该命令。`));
|
|
330
809
|
}
|
|
331
810
|
|
|
332
811
|
let retryData;
|
|
333
812
|
try {
|
|
334
813
|
retryData = JSON.parse(readFileSync(retryFilePath, 'utf-8'));
|
|
335
814
|
} catch {
|
|
336
|
-
return notify(session, 'error', '.retry
|
|
815
|
+
return notify(session, 'error', withSchemaNote('.retry 文件格式错误,请检查文件内容后重试。'));
|
|
337
816
|
}
|
|
338
817
|
|
|
339
818
|
if (!retryData || !Array.isArray(retryData.items) || retryData.items.length === 0) {
|
|
340
|
-
return notify(session, 'success', '
|
|
819
|
+
return notify(session, 'success', withSchemaNote('当前没有需要重试的项目。'));
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// status 额外写出的“已有页面 / 缺失页面”清单。旧项目没有这个文件时,
|
|
823
|
+
// fix 自动退回原来的通用修复指令。
|
|
824
|
+
let retryPageInfo = {};
|
|
825
|
+
try {
|
|
826
|
+
const pagesPath = join(projectRoot, '.retry-pages.json');
|
|
827
|
+
if (existsSync(pagesPath)) {
|
|
828
|
+
const parsed = JSON.parse(readFileSync(pagesPath, 'utf-8'));
|
|
829
|
+
if (parsed && typeof parsed === 'object') retryPageInfo = parsed;
|
|
830
|
+
}
|
|
831
|
+
} catch {
|
|
832
|
+
retryPageInfo = {};
|
|
341
833
|
}
|
|
342
834
|
|
|
343
|
-
const rawSourcesDir = join(projectRoot, '对标', 'raw', 'sources');
|
|
344
835
|
const results = [];
|
|
345
836
|
const errors = [];
|
|
346
837
|
|
|
347
|
-
|
|
838
|
+
// .retry 是 status 写的(现在有序),但老文件可能是乱序的 ——
|
|
839
|
+
// 补传顺序跟着章号走,跟 ingest 的正常流程保持一致。
|
|
840
|
+
for (const item of sortByChapter(retryData.items, (it) => it && it.bookName)) {
|
|
348
841
|
const { bookName, chapter } = item;
|
|
349
842
|
// 定位文件:书名/ 目录下查找文件名包含"第X章"的文件
|
|
350
843
|
const bookDir = join(rawSourcesDir, bookName);
|
|
@@ -353,7 +846,7 @@ export async function apply(ctx) {
|
|
|
353
846
|
continue;
|
|
354
847
|
}
|
|
355
848
|
|
|
356
|
-
let
|
|
849
|
+
let matchedFiles = [];
|
|
357
850
|
try {
|
|
358
851
|
const files = readdirSync(bookDir);
|
|
359
852
|
// 查找文件名中包含"第X章"的文件(如:第8章-心好痛.txt)
|
|
@@ -361,21 +854,42 @@ export async function apply(ctx) {
|
|
|
361
854
|
// 直接拼进正则的话,一旦内容里出现正则元字符就会抛 SyntaxError,
|
|
362
855
|
// 而且 "." 未转义会让"第1章"匹配上"第1章X"这种文件名。
|
|
363
856
|
const pattern = new RegExp(`${escapeRegExp(chapter)}.*\\.txt$`);
|
|
364
|
-
|
|
365
|
-
|
|
857
|
+
//
|
|
858
|
+
// **要全部匹配到的分片,不能只取第一个** ——
|
|
859
|
+
// 超长章会被 split_source.js 二次切分成
|
|
860
|
+
// 第7章-武科五大关-1.txt / 第7章-武科五大关-2.txt
|
|
861
|
+
// 两个文件名都匹配同一个正则,而 files.find() 只返回其中一个,
|
|
862
|
+
// 另一个分片永远补不上:服务端那一章的产物始终是残缺的
|
|
863
|
+
// (第一次 ingest 是把整章的分片一起传的,服务端会合并成
|
|
864
|
+
// 一章的产物;只补一半反而会把完整产物覆盖成半章)。
|
|
865
|
+
//
|
|
866
|
+
// 排序用自然序,保证「第-10」排在「第-2」后面,
|
|
867
|
+
// 补传顺序与初次 ingest 一致。
|
|
868
|
+
matchedFiles = files
|
|
869
|
+
.filter(f => pattern.test(f))
|
|
870
|
+
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
871
|
+
if (matchedFiles.length === 0) {
|
|
366
872
|
errors.push(`未找到文件:${bookName} 下包含 ${chapter} 的文件`);
|
|
367
873
|
continue;
|
|
368
874
|
}
|
|
369
875
|
|
|
370
876
|
// 清除 ingest-cache 中对应条目的 hash
|
|
877
|
+
// (一次读、一次改、一次写:多个分片共用一份 cache,
|
|
878
|
+
// 循环里反复读写会把前一次的结果覆盖掉)
|
|
371
879
|
const ingestCachePath = join(projectRoot, '对标', '.llm-wiki', 'ingest-cache.json');
|
|
372
880
|
if (existsSync(ingestCachePath)) {
|
|
373
881
|
try {
|
|
374
882
|
const cacheContent = readFileSync(ingestCachePath, 'utf-8');
|
|
375
883
|
const cacheData = JSON.parse(cacheContent);
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
884
|
+
let cacheChanged = false;
|
|
885
|
+
for (const f of matchedFiles) {
|
|
886
|
+
const cacheKey = `${bookName}/${f}`;
|
|
887
|
+
if (cacheData.entries && cacheData.entries[cacheKey]) {
|
|
888
|
+
cacheData.entries[cacheKey].hash = '';
|
|
889
|
+
cacheChanged = true;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
if (cacheChanged) {
|
|
379
893
|
writeFileSync(ingestCachePath, JSON.stringify(cacheData, null, 2), 'utf-8');
|
|
380
894
|
}
|
|
381
895
|
} catch {
|
|
@@ -383,7 +897,9 @@ export async function apply(ctx) {
|
|
|
383
897
|
}
|
|
384
898
|
}
|
|
385
899
|
|
|
386
|
-
|
|
900
|
+
// 整章的分片一起传 —— 与初次 ingest 的语义一致
|
|
901
|
+
// (初次是 split_files 全部一次传给服务端)。
|
|
902
|
+
const filePaths = matchedFiles.map(f => `raw/sources/${bookName}/${f}`);
|
|
387
903
|
// 必须检查返回:ingest 返回 4xx/5xx 时 llm_wiki 根本没收到文件,
|
|
388
904
|
// 之前这里不检查,命令照报"## 重新 ingest 成功"(实测:两个 POST
|
|
389
905
|
// 都返回 500,用户看到的仍是"成功")。
|
|
@@ -392,14 +908,23 @@ export async function apply(ctx) {
|
|
|
392
908
|
llmWikiToken,
|
|
393
909
|
`/projects/${referenceId}/sources/ingest`,
|
|
394
910
|
'POST',
|
|
395
|
-
{
|
|
911
|
+
{
|
|
912
|
+
paths: filePaths,
|
|
913
|
+
instruction: buildFixInstruction(
|
|
914
|
+
bookName,
|
|
915
|
+
chapter,
|
|
916
|
+
retryPageInfo[`${bookName}::${chapter}`] || null,
|
|
917
|
+
),
|
|
918
|
+
}
|
|
396
919
|
);
|
|
397
920
|
const apiErr = llmWikiError(ingestResult);
|
|
398
921
|
if (apiErr) {
|
|
399
|
-
errors.push(`${bookName}/${
|
|
922
|
+
errors.push(`${bookName}/${matchedFiles.join('、')} - ${apiErr}`);
|
|
400
923
|
continue;
|
|
401
924
|
}
|
|
402
|
-
|
|
925
|
+
for (const f of matchedFiles) {
|
|
926
|
+
results.push(`${bookName}/${f}`);
|
|
927
|
+
}
|
|
403
928
|
} catch (err) {
|
|
404
929
|
errors.push(`${bookName}/${chapter} - ${describeFetchError(err)}`);
|
|
405
930
|
}
|
|
@@ -421,7 +946,7 @@ export async function apply(ctx) {
|
|
|
421
946
|
const headline = (errors.length > 0 && results.length === 0)
|
|
422
947
|
? '重新 ingest 失败:\n\n'
|
|
423
948
|
: '';
|
|
424
|
-
const finalMessage = headline + message + '\n\n请稍后执行 /fancy-analyze status 查看结果,如果仍有缺失需再次执行 /fancy-analyze fix';
|
|
949
|
+
const finalMessage = headline + schemaNote + message + '\n\n请稍后执行 /fancy-analyze status 查看结果,如果仍有缺失需再次执行 /fancy-analyze fix';
|
|
425
950
|
return notify(session, results.length > 0 ? 'success' : 'error', finalMessage);
|
|
426
951
|
}
|
|
427
952
|
|
|
@@ -444,12 +969,31 @@ export async function apply(ctx) {
|
|
|
444
969
|
return notify(session, 'error', analyze.notify({ type: 'no_reference_id' }));
|
|
445
970
|
}
|
|
446
971
|
|
|
972
|
+
const rawSourcesDir = join(projectRoot, '对标', 'raw', 'sources');
|
|
973
|
+
const hasSources = hasAnySourceFiles(rawSourcesDir);
|
|
974
|
+
const hasOutputs = hasAnyAnalyzeOutput(join(projectRoot, '对标', 'wiki'));
|
|
975
|
+
if (!hasSources && !hasOutputs) {
|
|
976
|
+
return notify(session, 'error', analyze.notify({ type: 'status_not_analyzed' }));
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
const schemaOutdated = isLegacyAnalyzeSchema(readAnalyzeSchema(projectRoot));
|
|
980
|
+
|
|
447
981
|
// 检查来源目录下的文件,从"### 列表"表格中获取【有/无】信息
|
|
448
982
|
// 同时读取"来源"和"来源摘要"两个目录
|
|
449
983
|
// 映射格式:{ "书名": { "第X章": { "技法名": true/false, ... }, ... } }
|
|
450
984
|
const bookChapterTechHas = {};
|
|
451
985
|
// 记录缺少列表的章节
|
|
452
|
-
|
|
986
|
+
// 用 let:下面会按(书名, 章号)重排 —— readdirSync 返回的是目录项
|
|
987
|
+
// 哈希序(ext4 上表现为第7章排在第4章前面),不是字典序。
|
|
988
|
+
let missingListReports = [];
|
|
989
|
+
let structuralTableSeen = false;
|
|
990
|
+
let legacyStructuralTableSeen = false;
|
|
991
|
+
const entityRows = [];
|
|
992
|
+
const legacySourceChapters = [];
|
|
993
|
+
|
|
994
|
+
// "来源"和"来源摘要"两个目录里已有的 .md 文件名。
|
|
995
|
+
// 用来回答:"raw/sources 里的这个分片,拆解产物落地了吗?"
|
|
996
|
+
const sourceFileNames = new Set();
|
|
453
997
|
|
|
454
998
|
const sourceDirs = ['来源', '来源摘要'];
|
|
455
999
|
for (const sourceDir of sourceDirs) {
|
|
@@ -460,6 +1004,7 @@ export async function apply(ctx) {
|
|
|
460
1004
|
const sourceFiles = readdirSync(duiBiaoWikiDir);
|
|
461
1005
|
for (const file of sourceFiles) {
|
|
462
1006
|
if (!file.endsWith('.md')) continue;
|
|
1007
|
+
sourceFileNames.add(file);
|
|
463
1008
|
// 提取书名和第X章(如:全球高武-第10章-气血检查.md → 书名:全球高武, 第X章:第10章)
|
|
464
1009
|
// 格式:书名-第X章-xxx.md 或 书名-技法名-第X章.md
|
|
465
1010
|
const fullMatch = file.match(/^(.+?)-(第\d+章)-/);
|
|
@@ -473,6 +1018,14 @@ export async function apply(ctx) {
|
|
|
473
1018
|
|
|
474
1019
|
const content = readFileSync(join(duiBiaoWikiDir, file), 'utf-8');
|
|
475
1020
|
|
|
1021
|
+
// 来源页没有新版结构表 → 这一章还是旧 schema 的产物。
|
|
1022
|
+
// 只认 来源/,不认 来源摘要/:摘要页本来就可能没有技法表。
|
|
1023
|
+
const hasNewStructureTables = content.includes('### 跨章节结构实体')
|
|
1024
|
+
|| content.includes('### 章节级结构设计');
|
|
1025
|
+
if (sourceDir === '来源' && !hasNewStructureTables) {
|
|
1026
|
+
legacySourceChapters.push({ bookName, chapter: chapterKey, file });
|
|
1027
|
+
}
|
|
1028
|
+
|
|
476
1029
|
// 查找"### 列表"三级标题的位置
|
|
477
1030
|
const listHeadingIndex = content.indexOf('### 列表');
|
|
478
1031
|
if (listHeadingIndex === -1) {
|
|
@@ -481,29 +1034,19 @@ export async function apply(ctx) {
|
|
|
481
1034
|
continue;
|
|
482
1035
|
}
|
|
483
1036
|
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
const nextHeadingMatch = afterList.match(/\n#{1,3}\s+\S/);
|
|
487
|
-
const tableEndIndex = nextHeadingMatch
|
|
488
|
-
? afterList.indexOf(nextHeadingMatch[0])
|
|
489
|
-
: afterList.length;
|
|
490
|
-
const tableContent = afterList.substring(0, tableEndIndex);
|
|
491
|
-
|
|
492
|
-
// 解析 markdown 表格
|
|
493
|
-
const tableLines = tableContent.split('\n');
|
|
494
|
-
const techHas = {}; // { "世界观构建": true, "伏笔回收": false, ... }
|
|
495
|
-
|
|
496
|
-
for (const line of tableLines) {
|
|
497
|
-
// 匹配表格行:| 技法名 | 有/无 |
|
|
498
|
-
const rowMatch = line.match(/^\|\s*(.+?)\s*\|\s*(有|无)\s*\|/);
|
|
499
|
-
if (rowMatch) {
|
|
500
|
-
const techName = rowMatch[1].trim();
|
|
501
|
-
const hasValue = rowMatch[2] === '有';
|
|
502
|
-
techHas[techName] = hasValue;
|
|
503
|
-
}
|
|
1037
|
+
if (content.includes('### 跨章节结构实体') || content.includes('### 章节级结构设计')) {
|
|
1038
|
+
structuralTableSeen = true;
|
|
504
1039
|
}
|
|
505
|
-
|
|
1040
|
+
// 上一版 schema 的按章结构设计表;只用于提示迁移,不参与检查。
|
|
1041
|
+
if (content.includes('### 结构设计分析')) legacyStructuralTableSeen = true;
|
|
1042
|
+
// 按章类型:写作技法 + 章节功能设计。
|
|
1043
|
+
// 过滤旧类型,避免 status 追着已废弃的按章页面不放。
|
|
1044
|
+
const techHas = {
|
|
1045
|
+
...filterChapterTypes(parseHasTable(content, '### 列表')),
|
|
1046
|
+
...filterChapterTypes(parseHasTable(content, '### 章节级结构设计')),
|
|
1047
|
+
};
|
|
506
1048
|
bookChapterTechHas[bookName][chapterKey] = techHas;
|
|
1049
|
+
entityRows.push(...parseEntityRows(content, bookName, chapterKey));
|
|
507
1050
|
}
|
|
508
1051
|
} catch {
|
|
509
1052
|
// 忽略读取错误
|
|
@@ -513,6 +1056,7 @@ export async function apply(ctx) {
|
|
|
513
1056
|
// 检查各技法目录下是否有对应的文件
|
|
514
1057
|
// 映射格式:{ "书名": { "技法名": ["第X章", "第Y章", ...], ... }, ... }
|
|
515
1058
|
const bookTechChapters = {};
|
|
1059
|
+
const legacyStructureDirs = new Map();
|
|
516
1060
|
|
|
517
1061
|
const duiBiaoWikiRoot = join(projectRoot, '对标', 'wiki');
|
|
518
1062
|
if (existsSync(duiBiaoWikiRoot)) {
|
|
@@ -522,9 +1066,20 @@ export async function apply(ctx) {
|
|
|
522
1066
|
if (dir === '来源' || dir === '来源摘要') continue;
|
|
523
1067
|
|
|
524
1068
|
const dirPath = join(duiBiaoWikiRoot, dir);
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
1069
|
+
let dirStat = null;
|
|
1070
|
+
try { dirStat = statSync(dirPath); } catch { continue; }
|
|
1071
|
+
if (!dirStat.isDirectory()) continue;
|
|
1072
|
+
|
|
1073
|
+
let files = [];
|
|
1074
|
+
try { files = readdirSync(dirPath); } catch { continue; }
|
|
1075
|
+
|
|
1076
|
+
// 旧版按章结构页:只统计并提示迁移,不当作新版技法/实体页。
|
|
1077
|
+
if (LEGACY_STRUCTURE_DIRS.includes(dir)) {
|
|
1078
|
+
const pageCount = files.filter(
|
|
1079
|
+
(file) => file.endsWith('.md') && !file.endsWith('-总结.md')).length;
|
|
1080
|
+
legacyStructureDirs.set(dir, pageCount);
|
|
1081
|
+
continue;
|
|
1082
|
+
}
|
|
528
1083
|
for (const file of files) {
|
|
529
1084
|
if (!file.endsWith('.md')) continue;
|
|
530
1085
|
// 提取书名和第X章(如:全球高武-节奏韵律-第10章.md → 书名:全球高武, 第X章:第10章)
|
|
@@ -546,11 +1101,79 @@ export async function apply(ctx) {
|
|
|
546
1101
|
}
|
|
547
1102
|
}
|
|
548
1103
|
|
|
1104
|
+
// 跨章节实体页:按“类型 + 文件名”索引,并读取正文用于检查章节小节。
|
|
1105
|
+
const entityPageContents = new Map();
|
|
1106
|
+
const legacyEntityChapterPages = new Map();
|
|
1107
|
+
for (const type of ENTITY_STRUCTURE_TYPES) {
|
|
1108
|
+
const dirPath = join(duiBiaoWikiRoot, type);
|
|
1109
|
+
if (!existsSync(dirPath)) continue;
|
|
1110
|
+
let files = [];
|
|
1111
|
+
try { files = readdirSync(dirPath); } catch { continue; }
|
|
1112
|
+
for (const file of files) {
|
|
1113
|
+
if (!file.endsWith('.md')) continue;
|
|
1114
|
+
if (file.endsWith('-总结.md')) continue;
|
|
1115
|
+
// 实体目录里的按章文件是上一版 schema 的产物;不把它当成
|
|
1116
|
+
// 新版实体页,否则 status 会误以为实体页已经存在。
|
|
1117
|
+
if (/第\d+章/.test(file)) {
|
|
1118
|
+
const list = legacyEntityChapterPages.get(type) || [];
|
|
1119
|
+
list.push(file);
|
|
1120
|
+
legacyEntityChapterPages.set(type, list);
|
|
1121
|
+
continue;
|
|
1122
|
+
}
|
|
1123
|
+
try {
|
|
1124
|
+
entityPageContents.set(
|
|
1125
|
+
`${type}::${file}`,
|
|
1126
|
+
readFileSync(join(dirPath, file), 'utf-8'),
|
|
1127
|
+
);
|
|
1128
|
+
} catch {
|
|
1129
|
+
// 读不到就当缺失,下面会报出来
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// 章节列表按(书名, 章号)排 —— 遍历用的是 readdirSync 的返回顺序
|
|
1135
|
+
missingListReports = sortByChapter(missingListReports);
|
|
1136
|
+
|
|
1137
|
+
// ---- 拆解产物缺失检查 ----
|
|
1138
|
+
//
|
|
1139
|
+
// 前两项检查都是"文件在 来源/ 里存在"的前提下做的:
|
|
1140
|
+
// · "列表未找到" —— 文件在,但内容里没有 ### 列表 段;
|
|
1141
|
+
// · "缺失检测" —— 文件在,列表说某技法"有",但技法文件没落地。
|
|
1142
|
+
//
|
|
1143
|
+
// 但还有一种更靠前的情况压根没被检查到:**分片在 raw/sources/ 里,
|
|
1144
|
+
// 而 来源/ 和 来源摘要/ 里连对应的章节文件都没有** —— 这一章
|
|
1145
|
+
// 根本没被拆解,或者拆了但没落盘。
|
|
1146
|
+
//
|
|
1147
|
+
// 服务端的 missingFiles 只反映它自己的队列状态,拆解"跑完了"
|
|
1148
|
+
// 但产物没落盘这种情况它不知道,所以要本地查这一份。
|
|
1149
|
+
const missingIngestOutputs = findMissingIngestOutputs({
|
|
1150
|
+
rawSourcesDir,
|
|
1151
|
+
sourceFileNames,
|
|
1152
|
+
});
|
|
1153
|
+
const missingIngestReports = sortByChapter(
|
|
1154
|
+
missingIngestOutputs.map(o => o.report));
|
|
1155
|
+
const missingIngestItems = missingIngestOutputs
|
|
1156
|
+
.filter(o => o.chapter)
|
|
1157
|
+
.map(o => ({ bookName: o.bookName, chapter: o.chapter }));
|
|
1158
|
+
|
|
549
1159
|
// 比对结果:找出"标记为有、但技法目录里没有对应文件"的章节。
|
|
550
1160
|
// 之前同一段逻辑算了两遍(一次出报告、一次出 .retry),
|
|
551
1161
|
// 两份循环还得保持一致,改一处漏一处就会让报告和 .retry 对不上。
|
|
552
|
-
|
|
1162
|
+
let missingReports = [];
|
|
553
1163
|
const retryItems = [];
|
|
1164
|
+
const retryReasons = new Map();
|
|
1165
|
+
const addRetry = (bookName, chapter, reason) => {
|
|
1166
|
+
if (!bookName || !chapter) return;
|
|
1167
|
+
const key = bookName + '::' + chapter;
|
|
1168
|
+
if (reason) {
|
|
1169
|
+
const set = retryReasons.get(key) || new Set();
|
|
1170
|
+
set.add(reason);
|
|
1171
|
+
retryReasons.set(key, set);
|
|
1172
|
+
}
|
|
1173
|
+
const exists = retryItems.some(
|
|
1174
|
+
(item) => item.bookName === bookName && item.chapter === chapter);
|
|
1175
|
+
if (!exists) retryItems.push({ bookName, chapter });
|
|
1176
|
+
};
|
|
554
1177
|
for (const [bookName, chapterTechHas] of Object.entries(bookChapterTechHas)) {
|
|
555
1178
|
for (const [chapter, techHas] of Object.entries(chapterTechHas)) {
|
|
556
1179
|
const missingTechs = [];
|
|
@@ -565,10 +1188,134 @@ export async function apply(ctx) {
|
|
|
565
1188
|
}
|
|
566
1189
|
if (missingTechs.length > 0) {
|
|
567
1190
|
missingReports.push(`${bookName}${chapter}的ingest有缺失,缺失的为${missingTechs.join('、')}`);
|
|
568
|
-
|
|
1191
|
+
addRetry(bookName, chapter, '按章技法页缺失:' + missingTechs.join('、'));
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
// 跨章节实体页检查:页面不存在 → 重 ingest 首次章节;
|
|
1196
|
+
// 页面存在但没有本次章节小节 → 重 ingest 本次章节。
|
|
1197
|
+
const entityMissingReports = [];
|
|
1198
|
+
const entitySectionGaps = new Map();
|
|
1199
|
+
const pushRetry = (bookName, chapter, reason) => addRetry(bookName, chapter, reason);
|
|
1200
|
+
// 同一实体可能在多章被引用;按“类型 + 页面文件”分组后再报,
|
|
1201
|
+
// 否则一个缺失页面会按章重复几十行,真正的缺失反而被淹没。
|
|
1202
|
+
const entityGroups = new Map();
|
|
1203
|
+
for (const row of entityRows) {
|
|
1204
|
+
const chapter = row.currentChapter || row.chapterKey;
|
|
1205
|
+
const key = `${row.bookName}::${row.type}::${row.pageFile}`;
|
|
1206
|
+
let group = entityGroups.get(key);
|
|
1207
|
+
if (!group) {
|
|
1208
|
+
group = {
|
|
1209
|
+
bookName: row.bookName,
|
|
1210
|
+
type: row.type,
|
|
1211
|
+
pageFile: row.pageFile,
|
|
1212
|
+
invalidPageFile: row.invalidPageFile,
|
|
1213
|
+
entities: new Set(),
|
|
1214
|
+
chapters: new Set(),
|
|
1215
|
+
};
|
|
1216
|
+
entityGroups.set(key, group);
|
|
1217
|
+
}
|
|
1218
|
+
group.entities.add(row.entity);
|
|
1219
|
+
if (chapter) group.chapters.add(chapter);
|
|
1220
|
+
if (row.invalidPageFile) group.invalidPageFile = true;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
for (const group of entityGroups.values()) {
|
|
1224
|
+
const chapters = [...group.chapters].sort(
|
|
1225
|
+
(a, b) => (chapterNumber(a) ?? Number.MAX_SAFE_INTEGER) -
|
|
1226
|
+
(chapterNumber(b) ?? Number.MAX_SAFE_INTEGER));
|
|
1227
|
+
const firstChapter = chapters[0] || null;
|
|
1228
|
+
const entityLabel = [...group.entities].join('、');
|
|
1229
|
+
if (group.invalidPageFile) {
|
|
1230
|
+
entityMissingReports.push(
|
|
1231
|
+
`${group.bookName}的${group.type}实体「${entityLabel}」页面文件名 ${group.pageFile} 带章节号;跨章节实体页必须用稳定实体名,不能按章建页`);
|
|
1232
|
+
for (const chapter of chapters) pushRetry(group.bookName, chapter, '实体页文件名带章节号:' + group.pageFile);
|
|
1233
|
+
continue;
|
|
1234
|
+
}
|
|
1235
|
+
const content = entityPageContents.get(`${group.type}::${group.pageFile}`);
|
|
1236
|
+
if (content === undefined) {
|
|
1237
|
+
entityMissingReports.push(
|
|
1238
|
+
`${group.bookName}的${group.type}实体「${entityLabel}」缺少页面 ${group.pageFile}(首次出现 ${firstChapter || '未知'};涉及 ${chapters.join('、') || '未知章节'})`);
|
|
1239
|
+
for (const chapter of chapters) pushRetry(group.bookName, chapter, '实体页整页缺失:' + group.pageFile);
|
|
1240
|
+
continue;
|
|
1241
|
+
}
|
|
1242
|
+
for (const chapter of chapters) {
|
|
1243
|
+
if (hasChapterHeading(content, chapter)) continue;
|
|
1244
|
+
entityMissingReports.push(
|
|
1245
|
+
`${group.bookName}的${group.type}实体「${entityLabel}」页面 ${group.pageFile} 缺少 ${chapter} 小节`);
|
|
1246
|
+
entitySectionGaps.set(
|
|
1247
|
+
group.type, (entitySectionGaps.get(group.type) || 0) + 1);
|
|
1248
|
+
pushRetry(group.bookName, chapter, '实体页缺少 ' + chapter + ' 小节:' + group.pageFile);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// 角色弧光覆盖的启发式检查:人物关系页的两端通常就是角色。
|
|
1253
|
+
// 缺页的角色所在关系章节进 .retry,fix 重传后才会按新版 schema
|
|
1254
|
+
// 为这些角色补角色弧光页。
|
|
1255
|
+
let missingArcs = [];
|
|
1256
|
+
const missingArcChapters = new Map();
|
|
1257
|
+
{
|
|
1258
|
+
const arcNames = new Set();
|
|
1259
|
+
for (const row of entityRows) {
|
|
1260
|
+
if (row.type !== '角色弧光设计') continue;
|
|
1261
|
+
const entity = String(row.entity || '').trim();
|
|
1262
|
+
if (entity) arcNames.add(entity);
|
|
1263
|
+
// 弧光页可能用“老班”建档,但正式姓名“刘安国”写在 tags 或正文里。
|
|
1264
|
+
// 只看 entity 会把这种别名的关系端点误报成“缺角色弧光页”。
|
|
1265
|
+
const content = entityPageContents.get(`${row.type}::${row.pageFile}`) || '';
|
|
1266
|
+
for (const tag of parseFrontmatterTags(content)) arcNames.add(tag);
|
|
1267
|
+
for (const alias of parseAliasesFromContent(content)) arcNames.add(alias);
|
|
1268
|
+
}
|
|
1269
|
+
const relationCharacterNames = new Set();
|
|
1270
|
+
const nonCharacterName = /(父母|家人|兄弟|姐妹|同学|老师|全班|众人|人群|集团|公司|大学|学院|医院|银行|协会|联盟|家族|国家|班级|政府|部门|学校|武大|一中)/u;
|
|
1271
|
+
for (const row of entityRows) {
|
|
1272
|
+
if (row.type !== '人物关系设计') continue;
|
|
1273
|
+
for (const part of String(row.entity || '').split(/[-—–与、,,]/u)) {
|
|
1274
|
+
const name = part.trim();
|
|
1275
|
+
if (name.length < 2 || name.length > 4) continue;
|
|
1276
|
+
if (nonCharacterName.test(name)) continue;
|
|
1277
|
+
relationCharacterNames.add(name);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
missingArcs = [...relationCharacterNames].filter((name) => !arcNames.has(name));
|
|
1281
|
+
for (const name of missingArcs) {
|
|
1282
|
+
const byBook = new Map();
|
|
1283
|
+
for (const row of entityRows) {
|
|
1284
|
+
if (row.type !== '人物关系设计') continue;
|
|
1285
|
+
const parts = String(row.entity || '')
|
|
1286
|
+
.split(/[-—–与、,,]/u)
|
|
1287
|
+
.map((part) => part.trim());
|
|
1288
|
+
if (!parts.includes(name)) continue;
|
|
1289
|
+
const chapter = row.currentChapter || row.chapterKey;
|
|
1290
|
+
if (!chapter) continue;
|
|
1291
|
+
if (!byBook.has(row.bookName)) byBook.set(row.bookName, new Set());
|
|
1292
|
+
byBook.get(row.bookName).add(chapter);
|
|
1293
|
+
}
|
|
1294
|
+
for (const [bookName, chapters] of byBook) {
|
|
1295
|
+
missingArcChapters.set(bookName + '::' + name, chapters);
|
|
1296
|
+
for (const chapter of chapters) {
|
|
1297
|
+
pushRetry(bookName, chapter, '角色弧光页整页缺失:' + name);
|
|
1298
|
+
}
|
|
569
1299
|
}
|
|
570
1300
|
}
|
|
571
1301
|
}
|
|
1302
|
+
|
|
1303
|
+
// 旧 schema 的来源页:重传这一章,才能生成「### 跨章节结构实体」
|
|
1304
|
+
// 表和后续实体页。没有这一步,旧项目永远停留在按章结构页。
|
|
1305
|
+
for (const item of legacySourceChapters) {
|
|
1306
|
+
pushRetry(item.bookName, item.chapter, '来源页需要重生成跨章节结构实体表');
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// 实体目录里已经存在按章文件:重传对应章节,让 ingest 先把它们
|
|
1310
|
+
// 合并成稳定实体页,再补本章内容。
|
|
1311
|
+
for (const files of legacyEntityChapterPages.values()) {
|
|
1312
|
+
for (const file of files) {
|
|
1313
|
+
const match = file.match(/^(.+?)-.*?第(\d+)章/);
|
|
1314
|
+
if (!match) continue;
|
|
1315
|
+
pushRetry(match[1], `第${match[2]}章`, '按章命名的实体页需要合并:' + file);
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
572
1319
|
// 添加缺少列表的章节到 retry
|
|
573
1320
|
for (const report of missingListReports) {
|
|
574
1321
|
const match = report.match(/^(.+?)(第\d+章)的列表未找到/);
|
|
@@ -577,17 +1324,117 @@ export async function apply(ctx) {
|
|
|
577
1324
|
const chapter = match[2];
|
|
578
1325
|
const exists = retryItems.some(item => item.bookName === bookName && item.chapter === chapter);
|
|
579
1326
|
if (!exists) {
|
|
580
|
-
|
|
1327
|
+
addRetry(bookName, chapter, '来源页缺少「### 列表」表');
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
// 拆解产物缺失的章节同样进 retry —— 补传这一章才能把产物补出来
|
|
1332
|
+
for (const item of missingIngestItems) {
|
|
1333
|
+
const exists = retryItems.some(
|
|
1334
|
+
r => r.bookName === item.bookName && r.chapter === item.chapter);
|
|
1335
|
+
if (!exists) {
|
|
1336
|
+
addRetry(item.bookName, item.chapter, '整章拆解产物缺失');
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
// 章节列表按(书名, 章号)排
|
|
1340
|
+
missingReports = sortByChapter(missingReports);
|
|
1341
|
+
|
|
1342
|
+
// 给 fix 准备“已有哪些页面 / 本次缺哪些页面”。
|
|
1343
|
+
// 写在单独的 .retry-pages.json,避免改变 .retry 的既有结构。
|
|
1344
|
+
const buildRetryPageInfo = (bookName, chapter) => {
|
|
1345
|
+
const existing = new Set();
|
|
1346
|
+
const missing = new Set();
|
|
1347
|
+
|
|
1348
|
+
const sourcePrefix = `${bookName}-${chapter}-`;
|
|
1349
|
+
const sourcePages = [...sourceFileNames].filter((file) => file.startsWith(sourcePrefix));
|
|
1350
|
+
for (const file of sourcePages) {
|
|
1351
|
+
const sourceDir = existsSync(join(projectRoot, '对标', 'wiki', '来源', file))
|
|
1352
|
+
? '来源'
|
|
1353
|
+
: existsSync(join(projectRoot, '对标', 'wiki', '来源摘要', file))
|
|
1354
|
+
? '来源摘要'
|
|
1355
|
+
: '来源';
|
|
1356
|
+
existing.add(`wiki/${sourceDir}/${file}`);
|
|
1357
|
+
}
|
|
1358
|
+
if (sourcePages.length === 0) {
|
|
1359
|
+
missing.add(`wiki/来源/${bookName}-${chapter}-*.md(来源页)`);
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
const techHas = bookChapterTechHas[bookName]?.[chapter] || {};
|
|
1363
|
+
for (const [tech, hasValue] of Object.entries(techHas)) {
|
|
1364
|
+
if (hasValue !== true) continue;
|
|
1365
|
+
const pagePath = `wiki/${tech}/${bookName}-${tech}-${chapter}.md`;
|
|
1366
|
+
if (bookTechChapters[bookName]?.[tech]?.includes(chapter)) {
|
|
1367
|
+
existing.add(pagePath);
|
|
1368
|
+
} else {
|
|
1369
|
+
missing.add(pagePath);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
const rows = entityRows.filter(
|
|
1374
|
+
(row) => row.bookName === bookName &&
|
|
1375
|
+
(row.currentChapter || row.chapterKey) === chapter);
|
|
1376
|
+
for (const row of rows) {
|
|
1377
|
+
if (row.invalidPageFile) {
|
|
1378
|
+
missing.add(`wiki/${row.type}/${row.pageFile}(文件名带章节号,必须改成稳定实体名)`);
|
|
1379
|
+
continue;
|
|
1380
|
+
}
|
|
1381
|
+
const pagePath = `wiki/${row.type}/${row.pageFile}`;
|
|
1382
|
+
const content = entityPageContents.get(`${row.type}::${row.pageFile}`);
|
|
1383
|
+
if (content === undefined) {
|
|
1384
|
+
missing.add(`${pagePath}(实体页整页缺失)`);
|
|
1385
|
+
} else if (!hasChapterHeading(content, chapter)) {
|
|
1386
|
+
missing.add(`${pagePath}(缺少 ${chapter} 小节)`);
|
|
1387
|
+
} else {
|
|
1388
|
+
existing.add(pagePath);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
// 角色弧光缺页:必须把具体页面写进 .retry-pages.json,
|
|
1393
|
+
// 否则 fix 的提示词没有可列出的页面,只能让模型去猜。
|
|
1394
|
+
for (const [key, chapters] of missingArcChapters) {
|
|
1395
|
+
if (!chapters.has(chapter)) continue;
|
|
1396
|
+
const sep = key.indexOf('::');
|
|
1397
|
+
const arcBook = key.slice(0, sep);
|
|
1398
|
+
const arcName = key.slice(sep + 2);
|
|
1399
|
+
if (arcBook !== bookName || !arcName) continue;
|
|
1400
|
+
missing.add(`wiki/角色弧光设计/${bookName}-角色-${arcName}.md(整页缺失)`);
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
// 其他无法枚举成页面路径的缺失原因(来源页缺列表、旧 schema 等)
|
|
1404
|
+
// 也要明确写出来,不能让 fix 去猜。
|
|
1405
|
+
if (missing.size === 0) {
|
|
1406
|
+
const reasons = retryReasons.get(bookName + '::' + chapter);
|
|
1407
|
+
if (reasons) {
|
|
1408
|
+
for (const reason of reasons) missing.add(reason);
|
|
581
1409
|
}
|
|
582
1410
|
}
|
|
1411
|
+
|
|
1412
|
+
if (sourcePages.length === 0 && rows.length === 0 &&
|
|
1413
|
+
!Object.values(techHas).some((value) => value === true)) {
|
|
1414
|
+
missing.add('整章拆解产物(来源页 + 按章页面 + 实体页)');
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
return { existing: [...existing], missing: [...missing] };
|
|
1418
|
+
};
|
|
1419
|
+
|
|
1420
|
+
const retryPageInfo = {};
|
|
1421
|
+
for (const item of retryItems) {
|
|
1422
|
+
retryPageInfo[`${item.bookName}::${item.chapter}`] =
|
|
1423
|
+
buildRetryPageInfo(item.bookName, item.chapter);
|
|
583
1424
|
}
|
|
1425
|
+
|
|
1426
|
+
// .retry 里的 items 同样排序:它是按缺失检测的遍历顺序 push 的,
|
|
1427
|
+
// 乱序会让 /fancy-analyze fix 的补传顺序跟着乱。
|
|
1428
|
+
const sortedRetryItems = sortByChapter(retryItems, (it) => it && it.bookName);
|
|
1429
|
+
|
|
584
1430
|
// 根据当前检查结果覆盖 .retry 文件
|
|
585
1431
|
// 写失败必须说出来:写不进去的话,后面 /fancy-analyze fix 只会报
|
|
586
1432
|
// "没有找到 .retry 文件",用户完全不知道真正的失败原因在这里。
|
|
587
1433
|
let retryWriteError = null;
|
|
1434
|
+
let retryPagesWriteError = null;
|
|
588
1435
|
try {
|
|
589
|
-
if (
|
|
590
|
-
writeFileSync(join(projectRoot, '.retry'), JSON.stringify({ items:
|
|
1436
|
+
if (sortedRetryItems.length > 0) {
|
|
1437
|
+
writeFileSync(join(projectRoot, '.retry'), JSON.stringify({ items: sortedRetryItems }, null, 2), 'utf-8');
|
|
591
1438
|
} else {
|
|
592
1439
|
// 没有缺失项时删除 .retry 文件
|
|
593
1440
|
const retryFilePath = join(projectRoot, '.retry');
|
|
@@ -599,6 +1446,17 @@ export async function apply(ctx) {
|
|
|
599
1446
|
retryWriteError = err instanceof Error ? err.message : String(err);
|
|
600
1447
|
}
|
|
601
1448
|
|
|
1449
|
+
try {
|
|
1450
|
+
const pagesPath = join(projectRoot, '.retry-pages.json');
|
|
1451
|
+
if (sortedRetryItems.length > 0) {
|
|
1452
|
+
writeFileSync(pagesPath, JSON.stringify(retryPageInfo, null, 2), 'utf-8');
|
|
1453
|
+
} else if (existsSync(pagesPath)) {
|
|
1454
|
+
unlinkSync(pagesPath);
|
|
1455
|
+
}
|
|
1456
|
+
} catch (err) {
|
|
1457
|
+
retryPagesWriteError = err instanceof Error ? err.message : String(err);
|
|
1458
|
+
}
|
|
1459
|
+
|
|
602
1460
|
try {
|
|
603
1461
|
const result = await queryIngestStatus(llmWikiUrl, llmWikiToken, referenceId);
|
|
604
1462
|
|
|
@@ -612,26 +1470,164 @@ export async function apply(ctx) {
|
|
|
612
1470
|
}));
|
|
613
1471
|
}
|
|
614
1472
|
|
|
615
|
-
const missingFiles = result.data.missingFiles || [];
|
|
1473
|
+
const missingFiles = sortByChapter(result.data.missingFiles || []);
|
|
616
1474
|
const queue = result.data.queue || {};
|
|
617
1475
|
|
|
618
|
-
|
|
1476
|
+
const entityStats = ENTITY_STRUCTURE_TYPES.map((type) => {
|
|
1477
|
+
const pages = new Set(
|
|
1478
|
+
entityRows.filter((row) => row.type === type).map((row) => row.pageFile));
|
|
1479
|
+
const missing = [...pages].filter(
|
|
1480
|
+
(file) => !entityPageContents.has(`${type}::${file}`)).length;
|
|
1481
|
+
return {
|
|
1482
|
+
type,
|
|
1483
|
+
entities: pages.size,
|
|
1484
|
+
missing,
|
|
1485
|
+
sectionGaps: entitySectionGaps.get(type) || 0,
|
|
1486
|
+
};
|
|
1487
|
+
});
|
|
1488
|
+
const coverageLines = [];
|
|
1489
|
+
if (entityStats.some((s) => s.entities > 0)) {
|
|
1490
|
+
coverageLines.push('## 跨章节实体覆盖');
|
|
1491
|
+
for (const s of entityStats) {
|
|
1492
|
+
const arcGap = s.type === '角色弧光设计' ? missingArcs.length : 0;
|
|
1493
|
+
if (s.entities === 0) {
|
|
1494
|
+
coverageLines.push(`- ℹ️ ${s.type}:0 个(本书未引用,不算缺失)`);
|
|
1495
|
+
} else if (s.missing > 0 || s.sectionGaps > 0 || arcGap > 0) {
|
|
1496
|
+
const parts = [`${s.entities} 个实体页`];
|
|
1497
|
+
if (s.missing > 0) parts.push(`缺 ${s.missing} 个`);
|
|
1498
|
+
if (s.sectionGaps > 0) parts.push(`另有 ${s.sectionGaps} 个章节小节缺失`);
|
|
1499
|
+
if (arcGap > 0) parts.push(`另有 ${arcGap} 个角色缺页`);
|
|
1500
|
+
coverageLines.push(`- ❌ ${s.type}:${parts.join(',')}`);
|
|
1501
|
+
} else {
|
|
1502
|
+
coverageLines.push(`- ✅ ${s.type}:${s.entities} 个实体页`);
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
const totalMissingPages = entityStats.reduce((n, s) => n + s.missing, 0);
|
|
1506
|
+
const hasLegacyStructureFiles = legacyStructureDirs.size > 0 || legacyEntityChapterPages.size > 0;
|
|
1507
|
+
coverageLines.push('');
|
|
1508
|
+
if (hasLegacyStructureFiles) {
|
|
1509
|
+
coverageLines.push('⚠️ 结构设计覆盖未完成:还有旧版/按章命名的实体文件需要迁移。');
|
|
1510
|
+
} else if (missingArcs.length > 0) {
|
|
1511
|
+
coverageLines.push(`❌ 角色弧光覆盖不完整:${missingArcs.length} 个角色缺角色弧光页。`);
|
|
1512
|
+
} else if (entityMissingReports.length === 0 && totalMissingPages === 0) {
|
|
1513
|
+
coverageLines.push('✅ 结构设计覆盖完整,未发现缺失。');
|
|
1514
|
+
}
|
|
1515
|
+
coverageLines.push('');
|
|
1516
|
+
}
|
|
1517
|
+
if (legacyStructureDirs.size > 0) {
|
|
1518
|
+
coverageLines.push('## 旧版结构页(不参与新实体检查)');
|
|
1519
|
+
for (const [name, pageCount] of legacyStructureDirs) {
|
|
1520
|
+
coverageLines.push(`- ⚠️ ${name}:${pageCount} 个按章页面`);
|
|
1521
|
+
}
|
|
1522
|
+
coverageLines.push(' ⚠️ 新版改用跨章节实体页:伏笔回收→伏笔布局、世界观构建/世界观规则设计→世界观设计、角色弧光→角色弧光设计。');
|
|
1523
|
+
coverageLines.push(' ⚠️ 重新 ingest 对应章节后由新 schema 生成实体页;旧页面不会自动迁移,可确认后手动删除。');
|
|
1524
|
+
coverageLines.push('');
|
|
1525
|
+
}
|
|
1526
|
+
if (legacyEntityChapterPages.size > 0) {
|
|
1527
|
+
coverageLines.push('## 按章命名的实体页(不符合跨章节规则)');
|
|
1528
|
+
for (const [type, files] of legacyEntityChapterPages) {
|
|
1529
|
+
coverageLines.push(`- ❌ ${type}:${files.length} 个带章节号的文件`);
|
|
1530
|
+
}
|
|
1531
|
+
coverageLines.push(' ❌ 实体页文件名不应带 `第X章`;/fancy-analyze fix 会在重新 ingest 时自动合并为稳定实体页。');
|
|
1532
|
+
coverageLines.push('');
|
|
1533
|
+
}
|
|
1534
|
+
// 去掉最后一个空行,同时保证各覆盖小节之间恰好一个空行。
|
|
1535
|
+
while (coverageLines.length > 0 && coverageLines[coverageLines.length - 1] === '') {
|
|
1536
|
+
coverageLines.pop();
|
|
1537
|
+
}
|
|
1538
|
+
let structuralCoverage;
|
|
1539
|
+
if (coverageLines.length > 0) {
|
|
1540
|
+
structuralCoverage = coverageLines.join('\n');
|
|
1541
|
+
} else if (structuralTableSeen) {
|
|
1542
|
+
structuralCoverage = '## 跨章节实体覆盖\nℹ️ 结构设计表存在,但还没有记录任何跨章节实体。';
|
|
1543
|
+
} else if (legacyStructuralTableSeen) {
|
|
1544
|
+
structuralCoverage = '## 跨章节实体覆盖\n⚠️ 检测到旧版「### 结构设计分析」表(按章结构页)。新版改为「### 跨章节结构实体」;重新 ingest 后才会生成跨章节实体页。';
|
|
1545
|
+
} else {
|
|
1546
|
+
structuralCoverage = '## 跨章节实体覆盖\nℹ️ 未发现结构设计分析表(可能是旧版拆解结果;重新 ingest 后才会出现跨章节实体页)。';
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
const problemLines = [];
|
|
1550
|
+
if (schemaOutdated) {
|
|
1551
|
+
problemLines.push('## 对标 schema 版本');
|
|
1552
|
+
problemLines.push('⚠️ 检测到 对标/schema.md 是旧版(按章结构设计)。');
|
|
1553
|
+
problemLines.push('⚠️ /fancy-analyze fix 会先更新为新版 schema,再补传缺失章节;否则重新 ingest 仍会生成伏笔回收/世界观构建/角色弧光这些按章页面。');
|
|
1554
|
+
}
|
|
619
1555
|
if (retryWriteError) {
|
|
620
|
-
|
|
621
|
-
|
|
1556
|
+
problemLines.push('## .retry 写入失败');
|
|
1557
|
+
problemLines.push(`❌ 写入 .retry 文件失败:${retryWriteError}`);
|
|
1558
|
+
problemLines.push('❌ 缺失清单没能保存,/fancy-analyze fix 将无法工作。');
|
|
1559
|
+
}
|
|
1560
|
+
if (retryPagesWriteError) {
|
|
1561
|
+
problemLines.push('## .retry-pages 写入失败');
|
|
1562
|
+
problemLines.push(`⚠️ 写入 .retry-pages.json 失败:${retryPagesWriteError}`);
|
|
1563
|
+
problemLines.push('⚠️ fix 将退回通用修复指令,无法列出“已有页面/缺失页面”。');
|
|
622
1564
|
}
|
|
623
1565
|
if (missingReports.length > 0) {
|
|
624
|
-
|
|
1566
|
+
problemLines.push('## 缺失检测');
|
|
1567
|
+
for (const report of missingReports) problemLines.push(`❌ ${report}`);
|
|
625
1568
|
}
|
|
626
1569
|
if (missingListReports.length > 0) {
|
|
627
|
-
|
|
1570
|
+
problemLines.push('## 列表未找到');
|
|
1571
|
+
for (const report of missingListReports) problemLines.push(`❌ ${report}`);
|
|
1572
|
+
}
|
|
1573
|
+
if (missingIngestReports.length > 0) {
|
|
1574
|
+
problemLines.push('## 拆解产物缺失');
|
|
1575
|
+
for (const report of missingIngestReports) problemLines.push(`❌ ${report}`);
|
|
1576
|
+
}
|
|
1577
|
+
if (entityMissingReports.length > 0) {
|
|
1578
|
+
problemLines.push('## 结构设计实体缺失');
|
|
1579
|
+
for (const report of entityMissingReports) problemLines.push(`❌ ${report}`);
|
|
1580
|
+
}
|
|
1581
|
+
const aliasReports = new Map();
|
|
1582
|
+
for (const row of entityRows) {
|
|
1583
|
+
if (!RELATION_ONLY_ENTITY_RE.test(String(row.entity || '').trim())) continue;
|
|
1584
|
+
aliasReports.set(`${row.type}::${row.pageFile}`, row);
|
|
1585
|
+
}
|
|
1586
|
+
if (aliasReports.size > 0) {
|
|
1587
|
+
problemLines.push('## 疑似称呼实体');
|
|
1588
|
+
for (const row of aliasReports.values()) {
|
|
1589
|
+
problemLines.push(`⚠️ ${row.type}「${row.entity}」可能是某个角色的称呼,不应单独建页;请合并到本名页`);
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
if (missingArcs.length > 0) {
|
|
1594
|
+
problemLines.push('## 角色弧光覆盖');
|
|
1595
|
+
problemLines.push(`⚠️ 以下角色出现在人物关系里,但没有角色弧光页:${missingArcs.join('、')}`);
|
|
1596
|
+
problemLines.push('⚠️ 新版 schema 要求每个有名角色都有角色弧光页;/fancy-analyze fix 重传相关章节后会补。');
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
if (legacyStructureDirs.size > 0 || legacyEntityChapterPages.size > 0) {
|
|
1600
|
+
problemLines.push('## 结构设计文件需要迁移');
|
|
1601
|
+
problemLines.push('⚠️ 检测到旧版结构页或按章命名的实体页,详见下方覆盖统计;/fancy-analyze fix 会合并/重传。');
|
|
1602
|
+
}
|
|
1603
|
+
if (legacySourceChapters.length > 0) {
|
|
1604
|
+
const byBook = new Map();
|
|
1605
|
+
for (const item of legacySourceChapters) {
|
|
1606
|
+
if (!byBook.has(item.bookName)) byBook.set(item.bookName, new Set());
|
|
1607
|
+
byBook.get(item.bookName).add(item.chapter);
|
|
1608
|
+
}
|
|
1609
|
+
const lines = [];
|
|
1610
|
+
let total = 0;
|
|
1611
|
+
for (const [book, chapters] of byBook) {
|
|
1612
|
+
const sorted = [...chapters].sort(
|
|
1613
|
+
(a, b) => a.localeCompare(b, 'zh-Hans-CN', { numeric: true }));
|
|
1614
|
+
const preview = sorted.slice(0, 5).join('、') +
|
|
1615
|
+
(sorted.length > 5 ? ` 等 ${sorted.length} 章` : '');
|
|
1616
|
+
lines.push(`⚠️ ${book}:${preview}`);
|
|
1617
|
+
total += sorted.length;
|
|
1618
|
+
}
|
|
1619
|
+
problemLines.push('## 旧 schema 章节');
|
|
1620
|
+
problemLines.push(...lines);
|
|
1621
|
+
problemLines.push(`⚠️ 共 ${total} 章缺少「### 跨章节结构实体」表。`);
|
|
1622
|
+
problemLines.push('⚠️ /fancy-analyze fix 会先更新 schema,再用新版重传这些章节。');
|
|
628
1623
|
}
|
|
629
1624
|
|
|
630
1625
|
return notify(session, 'success', analyze.notify({
|
|
631
1626
|
type: 'status_result',
|
|
632
1627
|
missingFiles,
|
|
633
1628
|
queue,
|
|
634
|
-
extraMessage:
|
|
1629
|
+
extraMessage: problemLines.join('\n').replace(/\n(?=## )/g, '\n\n'),
|
|
1630
|
+
coverageMessage: structuralCoverage,
|
|
635
1631
|
}));
|
|
636
1632
|
} catch (err) {
|
|
637
1633
|
const errMsg = describeFetchError(err);
|
|
@@ -642,12 +1638,19 @@ export async function apply(ctx) {
|
|
|
642
1638
|
}
|
|
643
1639
|
}
|
|
644
1640
|
|
|
645
|
-
// 2.3 summary-status
|
|
1641
|
+
// 2.3 summary-status:查技法总结进度。
|
|
646
1642
|
//
|
|
647
|
-
//
|
|
648
|
-
//
|
|
649
|
-
//
|
|
1643
|
+
// 和 summary / resummary 一样先确认 ingest 完成:ingest 还没结束时,
|
|
1644
|
+
// wiki 里可能只有一部分来源页,此时显示"进度"会把用户引向错误结论。
|
|
1645
|
+
// 进度本身仍然是本地文件系统事实,但门禁必须先过。
|
|
650
1646
|
if (isSummaryStatus) {
|
|
1647
|
+
if (signal?.aborted) {
|
|
1648
|
+
return notify(session, 'error', analyze.notify({ type: 'cancelled' }));
|
|
1649
|
+
}
|
|
1650
|
+
const ingestReady = await checkIngestReadyForSummary(projectRoot, { command: 'summary-status' });
|
|
1651
|
+
if (!ingestReady.ok) {
|
|
1652
|
+
return notify(session, 'error', ingestReady.text);
|
|
1653
|
+
}
|
|
651
1654
|
const wikiRoot = join(projectRoot, '对标', 'wiki');
|
|
652
1655
|
if (!existsSync(wikiRoot)) {
|
|
653
1656
|
return notify(session, 'error', '❌ "对标/wiki" 目录不存在');
|
|
@@ -669,12 +1672,19 @@ export async function apply(ctx) {
|
|
|
669
1672
|
// 新写的和旧的混在一起,文件越滚越胖。移走是最干脆的"重来"。
|
|
670
1673
|
// 备份而不是删除:旧总结可能还有参考价值。
|
|
671
1674
|
if (isResummary) {
|
|
1675
|
+
if (signal?.aborted) {
|
|
1676
|
+
return notify(session, 'error', analyze.notify({ type: 'cancelled' }));
|
|
1677
|
+
}
|
|
1678
|
+
const ingestReady = await checkIngestReadyForSummary(projectRoot, { command: 'resummary' });
|
|
1679
|
+
if (!ingestReady.ok) {
|
|
1680
|
+
return notify(session, 'error', ingestReady.text);
|
|
1681
|
+
}
|
|
672
1682
|
const wikiRoot = join(projectRoot, '对标', 'wiki');
|
|
673
1683
|
if (!existsSync(wikiRoot)) {
|
|
674
1684
|
return notify(session, 'error', '❌ "对标/wiki" 目录不存在');
|
|
675
1685
|
}
|
|
676
1686
|
|
|
677
|
-
const allDirs =
|
|
1687
|
+
const allDirs = listSummaryDirs(wikiRoot);
|
|
678
1688
|
const targets = resummaryTarget
|
|
679
1689
|
? allDirs.filter((name) => name === resummaryTarget)
|
|
680
1690
|
: allDirs;
|
|
@@ -682,8 +1692,8 @@ export async function apply(ctx) {
|
|
|
682
1692
|
if (resummaryTarget && targets.length === 0) {
|
|
683
1693
|
const known = allDirs.join('、');
|
|
684
1694
|
return notify(session, 'error',
|
|
685
|
-
`❌
|
|
686
|
-
(known ?
|
|
1695
|
+
`❌ 未找到名为「${resummaryTarget}」的总结目录` +
|
|
1696
|
+
(known ? `。可用目录:${known}` : '(对标/wiki 下尚无可用总结目录)'));
|
|
687
1697
|
}
|
|
688
1698
|
|
|
689
1699
|
const stamp = backupStamp(Date.now());
|
|
@@ -709,6 +1719,7 @@ export async function apply(ctx) {
|
|
|
709
1719
|
// 提示词同样只给目录:章节名让模型自己去目录里找。
|
|
710
1720
|
const tasks = dirsToDispatch(progress).map((p) => ({
|
|
711
1721
|
name: p.name,
|
|
1722
|
+
scope: p.scope,
|
|
712
1723
|
sourceCount: p.sourceCount,
|
|
713
1724
|
pendingCount: p.pendingFiles.length,
|
|
714
1725
|
}));
|
|
@@ -716,7 +1727,23 @@ export async function apply(ctx) {
|
|
|
716
1727
|
|
|
717
1728
|
let dispatched = 0;
|
|
718
1729
|
if (tasks.length > 0) {
|
|
719
|
-
|
|
1730
|
+
// 脚本复制到项目里再让模型调用 —— 不在 node_modules 里跑
|
|
1731
|
+
const registerTool = ensureRegisterScript(projectRoot);
|
|
1732
|
+
if (!registerTool) {
|
|
1733
|
+
return notify(session, 'error', analyze.notify({
|
|
1734
|
+
type: 'register_script_failed',
|
|
1735
|
+
projectRoot,
|
|
1736
|
+
}));
|
|
1737
|
+
}
|
|
1738
|
+
const prompt = analyze.generateSummarySystemPrompt({
|
|
1739
|
+
tasks,
|
|
1740
|
+
remainingChapters,
|
|
1741
|
+
projectRoot,
|
|
1742
|
+
registerScript: registerTool,
|
|
1743
|
+
});
|
|
1744
|
+
if (signal?.aborted) {
|
|
1745
|
+
return notify(session, 'error', analyze.notify({ type: 'cancelled' }));
|
|
1746
|
+
}
|
|
720
1747
|
const follow = await llmFollowup(invocation, prompt);
|
|
721
1748
|
if (!follow || follow.ok !== true) {
|
|
722
1749
|
return notify(session, 'error',
|
|
@@ -744,6 +1771,13 @@ export async function apply(ctx) {
|
|
|
744
1771
|
// 模型一个字都还没写 —— 那是假成功。
|
|
745
1772
|
if (isSummary) {
|
|
746
1773
|
try {
|
|
1774
|
+
if (signal?.aborted) {
|
|
1775
|
+
return notify(session, 'error', analyze.notify({ type: 'cancelled' }));
|
|
1776
|
+
}
|
|
1777
|
+
const ingestReady = await checkIngestReadyForSummary(projectRoot, { command: 'summary' });
|
|
1778
|
+
if (!ingestReady.ok) {
|
|
1779
|
+
return notify(session, 'error', ingestReady.text);
|
|
1780
|
+
}
|
|
747
1781
|
const wikiRoot = join(projectRoot, '对标', 'wiki');
|
|
748
1782
|
if (!existsSync(wikiRoot)) {
|
|
749
1783
|
return notify(session, 'error', '❌ "对标/wiki" 目录不存在');
|
|
@@ -767,15 +1801,32 @@ export async function apply(ctx) {
|
|
|
767
1801
|
// 每个目录里写清楚"怎么判断哪些章节还没总结",模型自己去目录里找。
|
|
768
1802
|
const tasks = toDispatch.map((p) => ({
|
|
769
1803
|
name: p.name,
|
|
1804
|
+
scope: p.scope,
|
|
770
1805
|
sourceCount: p.sourceCount,
|
|
771
1806
|
pendingCount: p.pendingFiles.length,
|
|
772
1807
|
}));
|
|
773
1808
|
const remainingChapters = toDispatch.reduce((n, p) => n + p.pendingFiles.length, 0);
|
|
774
1809
|
|
|
775
1810
|
// 生成提示词
|
|
776
|
-
|
|
1811
|
+
// 脚本复制到项目里再让模型调用 —— 不在 node_modules 里跑
|
|
1812
|
+
const registerTool = ensureRegisterScript(projectRoot);
|
|
1813
|
+
if (!registerTool) {
|
|
1814
|
+
return notify(session, 'error', analyze.notify({
|
|
1815
|
+
type: 'register_script_failed',
|
|
1816
|
+
projectRoot,
|
|
1817
|
+
}));
|
|
1818
|
+
}
|
|
1819
|
+
const prompt = analyze.generateSummarySystemPrompt({
|
|
1820
|
+
tasks,
|
|
1821
|
+
remainingChapters,
|
|
1822
|
+
projectRoot,
|
|
1823
|
+
registerScript: registerTool,
|
|
1824
|
+
});
|
|
777
1825
|
|
|
778
1826
|
// 调用 LLM 进行总结
|
|
1827
|
+
if (signal?.aborted) {
|
|
1828
|
+
return notify(session, 'error', analyze.notify({ type: 'cancelled' }));
|
|
1829
|
+
}
|
|
779
1830
|
const follow = await llmFollowup(invocation, prompt);
|
|
780
1831
|
// llmFollowup 内部已捕获异常并返回 { ok, error },必须检查返回值,
|
|
781
1832
|
// 否则投递失败也会报"技法总结完成"
|