novel-writer-cli 0.0.1
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 +103 -0
- package/agents/chapter-writer.md +142 -0
- package/agents/character-weaver.md +117 -0
- package/agents/consistency-auditor.md +85 -0
- package/agents/plot-architect.md +128 -0
- package/agents/quality-judge.md +232 -0
- package/agents/style-analyzer.md +109 -0
- package/agents/style-refiner.md +97 -0
- package/agents/summarizer.md +128 -0
- package/agents/world-builder.md +161 -0
- package/dist/__tests__/character-voice.test.js +445 -0
- package/dist/__tests__/commit-prototype-pollution.test.js +45 -0
- package/dist/__tests__/engagement.test.js +382 -0
- package/dist/__tests__/foreshadow-visibility.test.js +131 -0
- package/dist/__tests__/hook-ledger.test.js +1028 -0
- package/dist/__tests__/naming-lint.test.js +132 -0
- package/dist/__tests__/narrative-health-injection.test.js +359 -0
- package/dist/__tests__/next-step-prejudge-guardrails.test.js +325 -0
- package/dist/__tests__/next-step-title-fix.test.js +153 -0
- package/dist/__tests__/platform-profile.test.js +274 -0
- package/dist/__tests__/promise-ledger.test.js +189 -0
- package/dist/__tests__/readability-lint.test.js +209 -0
- package/dist/__tests__/text-utils.test.js +39 -0
- package/dist/__tests__/title-policy.test.js +147 -0
- package/dist/advance.js +75 -0
- package/dist/character-voice.js +805 -0
- package/dist/checkpoint.js +126 -0
- package/dist/cli.js +563 -0
- package/dist/cliche-lint.js +515 -0
- package/dist/commit.js +1460 -0
- package/dist/consistency-auditor.js +684 -0
- package/dist/engagement.js +687 -0
- package/dist/errors.js +7 -0
- package/dist/fingerprint.js +16 -0
- package/dist/foreshadow-visibility.js +214 -0
- package/dist/fs-utils.js +68 -0
- package/dist/hook-ledger.js +721 -0
- package/dist/hook-policy.js +107 -0
- package/dist/instruction-gates.js +51 -0
- package/dist/instructions.js +406 -0
- package/dist/latest-summary-loader.js +29 -0
- package/dist/lock.js +121 -0
- package/dist/naming-lint.js +531 -0
- package/dist/ner.js +73 -0
- package/dist/next-step.js +408 -0
- package/dist/novel-ask.js +270 -0
- package/dist/output.js +9 -0
- package/dist/platform-constraints.js +518 -0
- package/dist/platform-profile.js +325 -0
- package/dist/prejudge-guardrails.js +370 -0
- package/dist/project.js +40 -0
- package/dist/promise-ledger.js +723 -0
- package/dist/readability-lint.js +555 -0
- package/dist/safe-parse.js +36 -0
- package/dist/safe-path.js +29 -0
- package/dist/scoring-weights.js +290 -0
- package/dist/steps.js +60 -0
- package/dist/text-utils.js +18 -0
- package/dist/title-policy.js +251 -0
- package/dist/type-guards.js +6 -0
- package/dist/validate.js +131 -0
- package/docs/user/README.md +17 -0
- package/docs/user/guardrails.md +179 -0
- package/docs/user/interactive-gates.md +124 -0
- package/docs/user/novel-cli.md +289 -0
- package/docs/user/ops.md +123 -0
- package/docs/user/quick-start.md +97 -0
- package/docs/user/spec-system.md +166 -0
- package/docs/user/storylines.md +144 -0
- package/package.json +48 -0
- package/schemas/README.md +18 -0
- package/schemas/character-voice-drift.schema.json +135 -0
- package/schemas/character-voice-profiles.schema.json +141 -0
- package/schemas/engagement-metrics.schema.json +38 -0
- package/schemas/hook-ledger.schema.json +108 -0
- package/schemas/platform-profile.schema.json +235 -0
- package/schemas/promise-ledger.schema.json +97 -0
- package/scripts/calibrate-quality-judge.sh +91 -0
- package/scripts/compare-regression-runs.sh +86 -0
- package/scripts/lib/_common.py +131 -0
- package/scripts/lib/calibrate_quality_judge.py +312 -0
- package/scripts/lib/compare_regression_runs.py +142 -0
- package/scripts/lib/run_regression.py +621 -0
- package/scripts/lint-blacklist.sh +201 -0
- package/scripts/lint-cliche.sh +370 -0
- package/scripts/lint-readability.sh +404 -0
- package/scripts/query-foreshadow.sh +252 -0
- package/scripts/run-ner.sh +669 -0
- package/scripts/run-regression.sh +122 -0
- package/skills/cli-step/SKILL.md +158 -0
- package/skills/continue/SKILL.md +348 -0
- package/skills/continue/references/context-contracts.md +169 -0
- package/skills/continue/references/continuity-checks.md +187 -0
- package/skills/continue/references/file-protocols.md +64 -0
- package/skills/continue/references/foreshadowing.md +130 -0
- package/skills/continue/references/gate-decision.md +53 -0
- package/skills/continue/references/periodic-maintenance.md +46 -0
- package/skills/novel-writing/SKILL.md +77 -0
- package/skills/novel-writing/references/quality-rubric.md +140 -0
- package/skills/novel-writing/references/style-guide.md +145 -0
- package/skills/start/SKILL.md +458 -0
- package/skills/start/references/quality-review.md +86 -0
- package/skills/start/references/setting-update.md +44 -0
- package/skills/start/references/vol-planning.md +61 -0
- package/skills/start/references/vol-review.md +58 -0
- package/skills/status/SKILL.md +116 -0
- package/skills/status/references/sample-output.md +60 -0
- package/templates/ai-blacklist.json +79 -0
- package/templates/brief-template.md +46 -0
- package/templates/genre-weight-profiles.json +90 -0
- package/templates/novel-ask/example.answer.json +12 -0
- package/templates/novel-ask/example.question.json +51 -0
- package/templates/platform-profile.json +148 -0
- package/templates/style-profile-template.json +58 -0
- package/templates/web-novel-cliche-lint.json +41 -0
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
import { readdir, rename, rm } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { ensureDir, pathExists, readJsonFile, readTextFile, writeJsonFile } from "./fs-utils.js";
|
|
4
|
+
import { loadLatestJsonSummary } from "./latest-summary-loader.js";
|
|
5
|
+
import { runNer } from "./ner.js";
|
|
6
|
+
import { pad2, pad3 } from "./steps.js";
|
|
7
|
+
import { truncateWithEllipsis } from "./text-utils.js";
|
|
8
|
+
import { isPlainObject } from "./type-guards.js";
|
|
9
|
+
function severityRank(v) {
|
|
10
|
+
switch (v) {
|
|
11
|
+
case "high":
|
|
12
|
+
return 0;
|
|
13
|
+
case "medium":
|
|
14
|
+
return 1;
|
|
15
|
+
case "low":
|
|
16
|
+
return 2;
|
|
17
|
+
default:
|
|
18
|
+
return 9;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function confidenceRank(v) {
|
|
22
|
+
switch (v) {
|
|
23
|
+
case "high":
|
|
24
|
+
return 0;
|
|
25
|
+
case "medium":
|
|
26
|
+
return 1;
|
|
27
|
+
case "low":
|
|
28
|
+
return 2;
|
|
29
|
+
default:
|
|
30
|
+
return 9;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function idSafe(s) {
|
|
34
|
+
return s.trim().replace(/\s+/gu, "_").replaceAll("|", "/").replaceAll(":", ":").replaceAll("=", "=");
|
|
35
|
+
}
|
|
36
|
+
function truncateSnippet(snippet, maxLen = 160) {
|
|
37
|
+
const trimmed = snippet.trim();
|
|
38
|
+
return truncateWithEllipsis(trimmed, maxLen);
|
|
39
|
+
}
|
|
40
|
+
function compareStrings(a, b) {
|
|
41
|
+
if (a < b)
|
|
42
|
+
return -1;
|
|
43
|
+
if (a > b)
|
|
44
|
+
return 1;
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
function extractSeason(marker) {
|
|
48
|
+
if (marker.includes("春"))
|
|
49
|
+
return "spring";
|
|
50
|
+
if (marker.includes("夏"))
|
|
51
|
+
return "summer";
|
|
52
|
+
if (marker.includes("秋"))
|
|
53
|
+
return "autumn";
|
|
54
|
+
if (marker.includes("冬"))
|
|
55
|
+
return "winter";
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
function pickPrimaryTimeMarker(ner) {
|
|
59
|
+
let best = null;
|
|
60
|
+
for (const tm of ner.entities.time_markers) {
|
|
61
|
+
const text = tm.text.trim();
|
|
62
|
+
if (text.length === 0)
|
|
63
|
+
continue;
|
|
64
|
+
const rank = confidenceRank(tm.confidence);
|
|
65
|
+
const mention = tm.mentions[0] ?? null;
|
|
66
|
+
const line = mention?.line ?? Number.POSITIVE_INFINITY;
|
|
67
|
+
if (!best) {
|
|
68
|
+
best = { rank, line, text, confidence: tm.confidence, mention };
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (rank !== best.rank) {
|
|
72
|
+
if (rank < best.rank)
|
|
73
|
+
best = { rank, line, text, confidence: tm.confidence, mention };
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (line !== best.line) {
|
|
77
|
+
if (line < best.line)
|
|
78
|
+
best = { rank, line, text, confidence: tm.confidence, mention };
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (text !== best.text && text < best.text)
|
|
82
|
+
best = { rank, line, text, confidence: tm.confidence, mention };
|
|
83
|
+
}
|
|
84
|
+
return best ? { text: best.text, confidence: best.confidence, mention: best.mention } : null;
|
|
85
|
+
}
|
|
86
|
+
async function listVolumeDirs(rootDir) {
|
|
87
|
+
const volsAbs = join(rootDir, "volumes");
|
|
88
|
+
if (!(await pathExists(volsAbs)))
|
|
89
|
+
return [];
|
|
90
|
+
const entries = await readdir(volsAbs, { withFileTypes: true });
|
|
91
|
+
const dirs = entries
|
|
92
|
+
.filter((e) => e.isDirectory())
|
|
93
|
+
.map((e) => e.name)
|
|
94
|
+
.filter((name) => /^vol-\d{2}$/u.test(name))
|
|
95
|
+
.sort(compareStrings);
|
|
96
|
+
return dirs;
|
|
97
|
+
}
|
|
98
|
+
async function findChapterContractRelPath(args) {
|
|
99
|
+
const volumeDirs = await listVolumeDirs(args.rootDir);
|
|
100
|
+
for (const volDir of volumeDirs) {
|
|
101
|
+
const rel = `volumes/${volDir}/chapter-contracts/chapter-${pad3(args.chapter)}.json`;
|
|
102
|
+
if (await pathExists(join(args.rootDir, rel)))
|
|
103
|
+
return rel;
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
async function loadChapterContract(args) {
|
|
108
|
+
const rel = await findChapterContractRelPath({ rootDir: args.rootDir, chapter: args.chapter });
|
|
109
|
+
if (!rel)
|
|
110
|
+
return null;
|
|
111
|
+
try {
|
|
112
|
+
const raw = await readJsonFile(join(args.rootDir, rel));
|
|
113
|
+
if (!isPlainObject(raw))
|
|
114
|
+
return null;
|
|
115
|
+
return raw;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function parseConcurrentState(contract) {
|
|
122
|
+
const storyline_id = typeof contract.storyline_id === "string" ? contract.storyline_id.trim() : null;
|
|
123
|
+
const ctxRaw = contract.storyline_context;
|
|
124
|
+
if (!isPlainObject(ctxRaw))
|
|
125
|
+
return null;
|
|
126
|
+
const ctx = ctxRaw;
|
|
127
|
+
const csRaw = ctx.concurrent_state;
|
|
128
|
+
if (!isPlainObject(csRaw))
|
|
129
|
+
return null;
|
|
130
|
+
const csObj = csRaw;
|
|
131
|
+
const out = {};
|
|
132
|
+
for (const [k, v] of Object.entries(csObj)) {
|
|
133
|
+
if (typeof v !== "string")
|
|
134
|
+
continue;
|
|
135
|
+
const key = k.trim();
|
|
136
|
+
const val = v.trim();
|
|
137
|
+
if (key.length === 0 || val.length === 0)
|
|
138
|
+
continue;
|
|
139
|
+
out[key] = val;
|
|
140
|
+
}
|
|
141
|
+
if (Object.keys(out).length === 0)
|
|
142
|
+
return null;
|
|
143
|
+
return { storyline_id, concurrent_state: out };
|
|
144
|
+
}
|
|
145
|
+
async function getNerForChapter(args) {
|
|
146
|
+
const cached = args.cache.get(args.chapter);
|
|
147
|
+
if (cached)
|
|
148
|
+
return cached;
|
|
149
|
+
const chapterRel = `chapters/chapter-${pad3(args.chapter)}.md`;
|
|
150
|
+
const chapterAbs = join(args.rootDir, chapterRel);
|
|
151
|
+
if (!(await pathExists(chapterAbs))) {
|
|
152
|
+
const entry = { status: "missing", chapterRel, error: "chapter file missing" };
|
|
153
|
+
args.cache.set(args.chapter, entry);
|
|
154
|
+
return entry;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
const ner = await runNer(chapterAbs);
|
|
158
|
+
const entry = { status: "ok", ner, chapterRel };
|
|
159
|
+
args.cache.set(args.chapter, entry);
|
|
160
|
+
return entry;
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
164
|
+
const entry = { status: "failed", chapterRel, error: message };
|
|
165
|
+
args.cache.set(args.chapter, entry);
|
|
166
|
+
return entry;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function sortByLengthThenLexDesc(values) {
|
|
170
|
+
return Array.from(new Set(values))
|
|
171
|
+
.map((s) => s.trim())
|
|
172
|
+
.filter((s) => s.length > 0)
|
|
173
|
+
.sort((a, b) => b.length - a.length || compareStrings(a, b));
|
|
174
|
+
}
|
|
175
|
+
export async function computeContinuityReport(args) {
|
|
176
|
+
const start = args.chapterRange.start;
|
|
177
|
+
const end = args.chapterRange.end;
|
|
178
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 1 || end < start) {
|
|
179
|
+
throw new Error(`Invalid chapterRange: [${String(start)}, ${String(end)}]`);
|
|
180
|
+
}
|
|
181
|
+
if (!Number.isInteger(args.volume) || args.volume < 0) {
|
|
182
|
+
throw new Error(`Invalid volume: ${String(args.volume)}`);
|
|
183
|
+
}
|
|
184
|
+
const generated_at = new Date().toISOString();
|
|
185
|
+
const issues = [];
|
|
186
|
+
const nerCache = new Map();
|
|
187
|
+
let chaptersChecked = 0;
|
|
188
|
+
let chaptersMissing = 0;
|
|
189
|
+
let nerOk = 0;
|
|
190
|
+
let nerFailed = 0;
|
|
191
|
+
let firstNerFailure = null;
|
|
192
|
+
const chapterFacts = [];
|
|
193
|
+
for (let c = start; c <= end; c += 1) {
|
|
194
|
+
const entry = await getNerForChapter({ rootDir: args.rootDir, chapter: c, cache: nerCache });
|
|
195
|
+
if (entry.status === "missing") {
|
|
196
|
+
chaptersMissing += 1;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
chaptersChecked += 1;
|
|
200
|
+
if (entry.status !== "ok") {
|
|
201
|
+
nerFailed += 1;
|
|
202
|
+
if (!firstNerFailure)
|
|
203
|
+
firstNerFailure = entry.error;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
nerOk += 1;
|
|
207
|
+
const ner = entry.ner;
|
|
208
|
+
const time_marker = pickPrimaryTimeMarker(ner);
|
|
209
|
+
const characters = ner.entities.characters
|
|
210
|
+
.map((e) => ({ text: e.text.trim(), mentions: e.mentions }))
|
|
211
|
+
.filter((e) => e.text.length > 0);
|
|
212
|
+
characters.sort((a, b) => compareStrings(a.text, b.text));
|
|
213
|
+
const locations = ner.entities.locations.map((l) => l.text.trim()).filter((s) => s.length > 0);
|
|
214
|
+
chapterFacts.push({ chapter: c, time_marker, characters, locations });
|
|
215
|
+
}
|
|
216
|
+
// Location contradiction: co-occurrence facts within the same primary time marker.
|
|
217
|
+
const locationGroups = new Map();
|
|
218
|
+
for (const ch of chapterFacts) {
|
|
219
|
+
const tm = ch.time_marker;
|
|
220
|
+
if (!tm)
|
|
221
|
+
continue;
|
|
222
|
+
const tmText = tm.text.trim();
|
|
223
|
+
if (tmText.length === 0)
|
|
224
|
+
continue;
|
|
225
|
+
const locTexts = sortByLengthThenLexDesc(ch.locations);
|
|
226
|
+
if (locTexts.length === 0)
|
|
227
|
+
continue;
|
|
228
|
+
for (const char of ch.characters) {
|
|
229
|
+
if (char.mentions.length === 0)
|
|
230
|
+
continue;
|
|
231
|
+
for (const m of char.mentions) {
|
|
232
|
+
const snippet = m.snippet ?? "";
|
|
233
|
+
if (snippet.length === 0)
|
|
234
|
+
continue;
|
|
235
|
+
const loc = locTexts.find((t) => snippet.includes(t));
|
|
236
|
+
if (!loc)
|
|
237
|
+
continue;
|
|
238
|
+
const key = `${char.text}\u0000${tmText}`;
|
|
239
|
+
const group = locationGroups.get(key) ?? { character: char.text, time_marker: tmText, locations: new Map() };
|
|
240
|
+
if (!group.locations.has(loc)) {
|
|
241
|
+
group.locations.set(loc, {
|
|
242
|
+
chapter: ch.chapter,
|
|
243
|
+
line: m.line,
|
|
244
|
+
snippet: truncateSnippet(snippet),
|
|
245
|
+
time_marker_confidence: tm.confidence
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
locationGroups.set(key, group);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
for (const group of locationGroups.values()) {
|
|
253
|
+
if (group.locations.size < 2)
|
|
254
|
+
continue;
|
|
255
|
+
const locList = Array.from(group.locations.keys()).sort(compareStrings);
|
|
256
|
+
const highLocCount = Array.from(group.locations.values()).filter((e) => e.time_marker_confidence === "high").length;
|
|
257
|
+
const isHigh = highLocCount >= 2;
|
|
258
|
+
const severity = isHigh ? "high" : "medium";
|
|
259
|
+
const confidence = isHigh ? "high" : "medium";
|
|
260
|
+
const evidenceList = locList
|
|
261
|
+
.map((loc) => {
|
|
262
|
+
const ev = group.locations.get(loc);
|
|
263
|
+
return { loc, ...ev };
|
|
264
|
+
})
|
|
265
|
+
.slice(0, 5);
|
|
266
|
+
const locId = locList.map(idSafe).join("|");
|
|
267
|
+
const id = `location_contradiction:char=${idSafe(group.character)}:time=${idSafe(group.time_marker)}:loc=${locId}`;
|
|
268
|
+
issues.push({
|
|
269
|
+
id,
|
|
270
|
+
type: "location_contradiction",
|
|
271
|
+
severity,
|
|
272
|
+
confidence,
|
|
273
|
+
entities: {
|
|
274
|
+
characters: [group.character],
|
|
275
|
+
locations: locList,
|
|
276
|
+
time_markers: [group.time_marker],
|
|
277
|
+
storylines: []
|
|
278
|
+
},
|
|
279
|
+
description: "同一 time_marker 下角色位置出现矛盾或疑似瞬移。",
|
|
280
|
+
evidence: evidenceList.map((e) => ({
|
|
281
|
+
chapter: e.chapter,
|
|
282
|
+
source: "chapter",
|
|
283
|
+
line: e.line,
|
|
284
|
+
snippet: e.snippet
|
|
285
|
+
})),
|
|
286
|
+
suggestions: [
|
|
287
|
+
"确认时间标尺是否应推进(例如从'第三年冬末'推进到'翌日清晨')。",
|
|
288
|
+
"若确为跨地移动,补一段赶路/传送的因果说明。"
|
|
289
|
+
]
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
// Timeline contradiction: compare primary time markers referenced via concurrent_state (chNN).
|
|
293
|
+
const seenTimelineIds = new Set();
|
|
294
|
+
for (let c = start; c <= end; c += 1) {
|
|
295
|
+
const contract = await loadChapterContract({ rootDir: args.rootDir, chapter: c });
|
|
296
|
+
if (!contract)
|
|
297
|
+
continue;
|
|
298
|
+
const cs = parseConcurrentState(contract);
|
|
299
|
+
if (!cs)
|
|
300
|
+
continue;
|
|
301
|
+
const currentNer = await getNerForChapter({ rootDir: args.rootDir, chapter: c, cache: nerCache });
|
|
302
|
+
if (currentNer.status !== "ok")
|
|
303
|
+
continue;
|
|
304
|
+
const tmA = pickPrimaryTimeMarker(currentNer.ner);
|
|
305
|
+
if (!tmA || tmA.text.length === 0)
|
|
306
|
+
continue;
|
|
307
|
+
const seasonA = extractSeason(tmA.text);
|
|
308
|
+
if (!seasonA || tmA.confidence !== "high")
|
|
309
|
+
continue;
|
|
310
|
+
const storylineKeys = Object.keys(cs.concurrent_state).sort(compareStrings);
|
|
311
|
+
for (const other of storylineKeys) {
|
|
312
|
+
const summary = cs.concurrent_state[other] ?? "";
|
|
313
|
+
const refs = [];
|
|
314
|
+
const re = /[((]\s*ch\s*(\d+)\s*[))]/giu;
|
|
315
|
+
let m;
|
|
316
|
+
while ((m = re.exec(summary)) !== null) {
|
|
317
|
+
const n = Number.parseInt(m[1] ?? "", 10);
|
|
318
|
+
if (Number.isInteger(n) && n > 0)
|
|
319
|
+
refs.push(n);
|
|
320
|
+
}
|
|
321
|
+
refs.sort((a, b) => a - b);
|
|
322
|
+
for (const refChapter of refs) {
|
|
323
|
+
const refNer = await getNerForChapter({ rootDir: args.rootDir, chapter: refChapter, cache: nerCache });
|
|
324
|
+
if (refNer.status !== "ok")
|
|
325
|
+
continue;
|
|
326
|
+
const tmB = pickPrimaryTimeMarker(refNer.ner);
|
|
327
|
+
if (!tmB || tmB.text.length === 0)
|
|
328
|
+
continue;
|
|
329
|
+
const seasonB = extractSeason(tmB.text);
|
|
330
|
+
if (!seasonB || tmB.confidence !== "high")
|
|
331
|
+
continue;
|
|
332
|
+
if (seasonA === seasonB)
|
|
333
|
+
continue;
|
|
334
|
+
const storylines = [cs.storyline_id, other].filter((s) => typeof s === "string" && s.length > 0).sort(compareStrings);
|
|
335
|
+
const id = `timeline_contradiction:storylines=${storylines.map(idSafe).join("|")}:time=${idSafe(tmA.text)}|${idSafe(tmB.text)}`;
|
|
336
|
+
if (seenTimelineIds.has(id))
|
|
337
|
+
continue;
|
|
338
|
+
seenTimelineIds.add(id);
|
|
339
|
+
const evA = {
|
|
340
|
+
chapter: c,
|
|
341
|
+
source: "chapter",
|
|
342
|
+
line: tmA.mention?.line ?? 0,
|
|
343
|
+
snippet: truncateSnippet(tmA.mention?.snippet ?? tmA.text)
|
|
344
|
+
};
|
|
345
|
+
const evB = {
|
|
346
|
+
chapter: refChapter,
|
|
347
|
+
source: "chapter",
|
|
348
|
+
line: tmB.mention?.line ?? 0,
|
|
349
|
+
snippet: truncateSnippet(tmB.mention?.snippet ?? tmB.text)
|
|
350
|
+
};
|
|
351
|
+
issues.push({
|
|
352
|
+
id,
|
|
353
|
+
type: "timeline_contradiction",
|
|
354
|
+
severity: "high",
|
|
355
|
+
confidence: "high",
|
|
356
|
+
entities: {
|
|
357
|
+
characters: [],
|
|
358
|
+
locations: [],
|
|
359
|
+
time_markers: [tmA.text, tmB.text],
|
|
360
|
+
storylines
|
|
361
|
+
},
|
|
362
|
+
description: "跨故事线并发状态与 time_marker 存在高置信矛盾(可能触发 LS-001)。",
|
|
363
|
+
evidence: [evA, evB],
|
|
364
|
+
suggestions: ["补齐并发线的时空锚点,或调整事件发生顺序。"]
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
// Stable ordering: severity (high→low) → type → id
|
|
370
|
+
issues.sort((a, b) => {
|
|
371
|
+
const sr = severityRank(a.severity) - severityRank(b.severity);
|
|
372
|
+
if (sr !== 0)
|
|
373
|
+
return sr;
|
|
374
|
+
const tr = compareStrings(a.type, b.type);
|
|
375
|
+
if (tr !== 0)
|
|
376
|
+
return tr;
|
|
377
|
+
return compareStrings(a.id, b.id);
|
|
378
|
+
});
|
|
379
|
+
const bySeverity = { high: 0, medium: 0, low: 0 };
|
|
380
|
+
for (const it of issues)
|
|
381
|
+
bySeverity[it.severity] += 1;
|
|
382
|
+
const report = {
|
|
383
|
+
schema_version: 1,
|
|
384
|
+
generated_at,
|
|
385
|
+
scope: args.scope,
|
|
386
|
+
volume: args.volume,
|
|
387
|
+
chapter_range: [start, end],
|
|
388
|
+
issues,
|
|
389
|
+
stats: {
|
|
390
|
+
chapters_checked: chaptersChecked,
|
|
391
|
+
chapters_missing: chaptersMissing,
|
|
392
|
+
issues_total: issues.length,
|
|
393
|
+
issues_by_severity: bySeverity,
|
|
394
|
+
ner_ok: nerOk,
|
|
395
|
+
ner_failed: nerFailed,
|
|
396
|
+
...(firstNerFailure ? { ner_failed_sample: truncateSnippet(firstNerFailure, 200) } : {})
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
return report;
|
|
400
|
+
}
|
|
401
|
+
export async function writeContinuityLogs(args) {
|
|
402
|
+
const dirRel = "logs/continuity";
|
|
403
|
+
const dirAbs = join(args.rootDir, dirRel);
|
|
404
|
+
await ensureDir(dirAbs);
|
|
405
|
+
const [start, end] = args.report.chapter_range;
|
|
406
|
+
const historyRel = `${dirRel}/continuity-report-vol-${pad2(args.report.volume)}-ch${pad3(start)}-ch${pad3(end)}.json`;
|
|
407
|
+
const latestRel = `${dirRel}/latest.json`;
|
|
408
|
+
await writeJsonFile(join(args.rootDir, historyRel), args.report);
|
|
409
|
+
const latestAbs = join(args.rootDir, latestRel);
|
|
410
|
+
const scopeRank = (scope) => (scope === "volume_end" ? 1 : 0);
|
|
411
|
+
const parseLatest = (raw) => {
|
|
412
|
+
if (!isPlainObject(raw))
|
|
413
|
+
return null;
|
|
414
|
+
const obj = raw;
|
|
415
|
+
if (obj.schema_version !== 1)
|
|
416
|
+
return null;
|
|
417
|
+
const range = obj.chapter_range;
|
|
418
|
+
if (!Array.isArray(range) || range.length !== 2)
|
|
419
|
+
return null;
|
|
420
|
+
const a = range[0];
|
|
421
|
+
const b = range[1];
|
|
422
|
+
if (typeof a !== "number" || typeof b !== "number")
|
|
423
|
+
return null;
|
|
424
|
+
if (!Number.isInteger(a) || !Number.isInteger(b) || a < 1 || b < a)
|
|
425
|
+
return null;
|
|
426
|
+
const rawTs = typeof obj.generated_at === "string" ? obj.generated_at : null;
|
|
427
|
+
const generated_at = rawTs && Number.isFinite(Date.parse(rawTs)) ? rawTs : null;
|
|
428
|
+
return { end: b, scope_rank: scopeRank(obj.scope), generated_at };
|
|
429
|
+
};
|
|
430
|
+
const next = { end, scope_rank: scopeRank(args.report.scope), generated_at: args.report.generated_at };
|
|
431
|
+
let shouldWriteLatest = true;
|
|
432
|
+
if (await pathExists(latestAbs)) {
|
|
433
|
+
try {
|
|
434
|
+
const existing = parseLatest(await readJsonFile(latestAbs));
|
|
435
|
+
if (existing) {
|
|
436
|
+
if (existing.end > next.end) {
|
|
437
|
+
shouldWriteLatest = false;
|
|
438
|
+
}
|
|
439
|
+
else if (existing.end === next.end && existing.scope_rank > next.scope_rank) {
|
|
440
|
+
shouldWriteLatest = false;
|
|
441
|
+
}
|
|
442
|
+
else if (existing.end === next.end && existing.scope_rank === next.scope_rank) {
|
|
443
|
+
// If timestamps are comparable, keep the newer one; otherwise, overwrite.
|
|
444
|
+
if (existing.generated_at) {
|
|
445
|
+
const a = Date.parse(existing.generated_at);
|
|
446
|
+
const b = Date.parse(next.generated_at);
|
|
447
|
+
if (Number.isFinite(a) && Number.isFinite(b) && a >= b)
|
|
448
|
+
shouldWriteLatest = false;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
shouldWriteLatest = true;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
if (shouldWriteLatest) {
|
|
458
|
+
// Atomic replace to avoid partial/corrupted JSON on interruption.
|
|
459
|
+
const tmpAbs = join(dirAbs, `.tmp-continuity-latest-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.json`);
|
|
460
|
+
await writeJsonFile(tmpAbs, args.report);
|
|
461
|
+
try {
|
|
462
|
+
// Re-check right before publish to reduce (not eliminate) races without introducing a lock.
|
|
463
|
+
let stillWrite = true;
|
|
464
|
+
if (await pathExists(latestAbs)) {
|
|
465
|
+
try {
|
|
466
|
+
const existing2 = parseLatest(await readJsonFile(latestAbs));
|
|
467
|
+
if (existing2) {
|
|
468
|
+
if (existing2.end > next.end) {
|
|
469
|
+
stillWrite = false;
|
|
470
|
+
}
|
|
471
|
+
else if (existing2.end === next.end && existing2.scope_rank > next.scope_rank) {
|
|
472
|
+
stillWrite = false;
|
|
473
|
+
}
|
|
474
|
+
else if (existing2.end === next.end && existing2.scope_rank === next.scope_rank && existing2.generated_at) {
|
|
475
|
+
const a = Date.parse(existing2.generated_at);
|
|
476
|
+
const b = Date.parse(next.generated_at);
|
|
477
|
+
if (Number.isFinite(a) && Number.isFinite(b) && a >= b)
|
|
478
|
+
stillWrite = false;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
catch {
|
|
483
|
+
stillWrite = true;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (stillWrite)
|
|
487
|
+
await rename(tmpAbs, latestAbs);
|
|
488
|
+
}
|
|
489
|
+
finally {
|
|
490
|
+
await rm(tmpAbs, { force: true }).catch(() => { });
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return { latestRel, historyRel };
|
|
494
|
+
}
|
|
495
|
+
export async function writeVolumeContinuityReport(args) {
|
|
496
|
+
const rel = `volumes/vol-${pad2(args.report.volume)}/continuity-report.json`;
|
|
497
|
+
await writeJsonFile(join(args.rootDir, rel), args.report);
|
|
498
|
+
return { volumeRel: rel };
|
|
499
|
+
}
|
|
500
|
+
export async function loadContinuityLatestSummary(rootDir) {
|
|
501
|
+
return loadLatestJsonSummary({ rootDir, relPath: "logs/continuity/latest.json", summarize: summarizeContinuityForJudge });
|
|
502
|
+
}
|
|
503
|
+
export function summarizeContinuityForJudge(raw) {
|
|
504
|
+
if (!isPlainObject(raw))
|
|
505
|
+
return null;
|
|
506
|
+
const obj = raw;
|
|
507
|
+
if (obj.schema_version !== 1)
|
|
508
|
+
return null;
|
|
509
|
+
const issuesRaw = Array.isArray(obj.issues) ? obj.issues : [];
|
|
510
|
+
const statsRaw = isPlainObject(obj.stats) ? obj.stats : {};
|
|
511
|
+
const issues = [];
|
|
512
|
+
const ls_001_signals = [];
|
|
513
|
+
const MAX_ISSUES = 5;
|
|
514
|
+
const MAX_LS_001_SIGNALS = 5;
|
|
515
|
+
const MAX_DESCRIPTION = 240;
|
|
516
|
+
const MAX_ID = 240;
|
|
517
|
+
const MAX_SUGGESTION = 180;
|
|
518
|
+
const ALLOWED_TYPES = new Set(["timeline_contradiction", "location_contradiction", "character_mapping", "relationship_jump"]);
|
|
519
|
+
const safeString = (v, maxLen) => {
|
|
520
|
+
if (typeof v !== "string")
|
|
521
|
+
return null;
|
|
522
|
+
const trimmed = v.trim();
|
|
523
|
+
if (trimmed.length === 0)
|
|
524
|
+
return null;
|
|
525
|
+
return truncateSnippet(trimmed, maxLen);
|
|
526
|
+
};
|
|
527
|
+
const safeInt = (v) => {
|
|
528
|
+
return typeof v === "number" && Number.isInteger(v) && v >= 0 ? v : null;
|
|
529
|
+
};
|
|
530
|
+
const safeSeverityCounts = (v) => {
|
|
531
|
+
if (!isPlainObject(v))
|
|
532
|
+
return null;
|
|
533
|
+
const o = v;
|
|
534
|
+
const high = safeInt(o.high);
|
|
535
|
+
const medium = safeInt(o.medium);
|
|
536
|
+
const low = safeInt(o.low);
|
|
537
|
+
if (high === null || medium === null || low === null)
|
|
538
|
+
return null;
|
|
539
|
+
return { high, medium, low };
|
|
540
|
+
};
|
|
541
|
+
for (const it of issuesRaw) {
|
|
542
|
+
if (!isPlainObject(it))
|
|
543
|
+
continue;
|
|
544
|
+
const issue = it;
|
|
545
|
+
const type = typeof issue.type === "string" ? issue.type : "";
|
|
546
|
+
const severity = typeof issue.severity === "string" ? issue.severity : "";
|
|
547
|
+
const confidence = typeof issue.confidence === "string" ? issue.confidence : "";
|
|
548
|
+
if (!["high", "medium"].includes(severity))
|
|
549
|
+
continue;
|
|
550
|
+
if (!ALLOWED_TYPES.has(type))
|
|
551
|
+
continue;
|
|
552
|
+
const evidenceRaw = Array.isArray(issue.evidence) ? issue.evidence : [];
|
|
553
|
+
const evidence = evidenceRaw
|
|
554
|
+
.filter((e) => isPlainObject(e))
|
|
555
|
+
.slice(0, 2)
|
|
556
|
+
.map((e) => {
|
|
557
|
+
const eo = e;
|
|
558
|
+
const chapter = typeof eo.chapter === "number" && Number.isInteger(eo.chapter) ? eo.chapter : null;
|
|
559
|
+
const snippet = typeof eo.snippet === "string" ? truncateSnippet(eo.snippet, 120) : null;
|
|
560
|
+
return chapter !== null && snippet !== null ? { chapter, snippet } : null;
|
|
561
|
+
})
|
|
562
|
+
.filter((e) => e !== null);
|
|
563
|
+
const suggestionsRaw = Array.isArray(issue.suggestions) ? issue.suggestions : [];
|
|
564
|
+
const suggestion = safeString(suggestionsRaw[0], MAX_SUGGESTION);
|
|
565
|
+
const id = safeString(issue.id, MAX_ID) ?? "";
|
|
566
|
+
const description = safeString(issue.description, MAX_DESCRIPTION) ?? "";
|
|
567
|
+
const trimmed = {
|
|
568
|
+
id,
|
|
569
|
+
type,
|
|
570
|
+
severity,
|
|
571
|
+
confidence,
|
|
572
|
+
description,
|
|
573
|
+
evidence,
|
|
574
|
+
...(suggestion ? { suggestion } : {})
|
|
575
|
+
};
|
|
576
|
+
issues.push(trimmed);
|
|
577
|
+
if (type === "timeline_contradiction" && confidence === "high") {
|
|
578
|
+
ls_001_signals.push({
|
|
579
|
+
issue_id: id,
|
|
580
|
+
confidence,
|
|
581
|
+
evidence,
|
|
582
|
+
...(suggestion ? { suggestion } : {})
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
issues.sort((a, b) => {
|
|
587
|
+
const as = String(a.severity ?? "");
|
|
588
|
+
const bs = String(b.severity ?? "");
|
|
589
|
+
const sr = severityRank(as) - severityRank(bs);
|
|
590
|
+
if (sr !== 0)
|
|
591
|
+
return sr;
|
|
592
|
+
const tr = compareStrings(String(a.type ?? ""), String(b.type ?? ""));
|
|
593
|
+
if (tr !== 0)
|
|
594
|
+
return tr;
|
|
595
|
+
return compareStrings(String(a.id ?? ""), String(b.id ?? ""));
|
|
596
|
+
});
|
|
597
|
+
ls_001_signals.sort((a, b) => compareStrings(String(a.issue_id ?? ""), String(b.issue_id ?? "")));
|
|
598
|
+
let chapter_range = null;
|
|
599
|
+
if (Array.isArray(obj.chapter_range) && obj.chapter_range.length === 2) {
|
|
600
|
+
const a = obj.chapter_range[0];
|
|
601
|
+
const b = obj.chapter_range[1];
|
|
602
|
+
if (typeof a === "number" && typeof b === "number" && Number.isInteger(a) && Number.isInteger(b) && a > 0 && b >= a) {
|
|
603
|
+
chapter_range = [a, b];
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
const scope = typeof obj.scope === "string" && ["periodic", "volume_end"].includes(obj.scope) ? obj.scope : null;
|
|
607
|
+
const volume = typeof obj.volume === "number" && Number.isInteger(obj.volume) && obj.volume >= 0 ? obj.volume : null;
|
|
608
|
+
const generated_at = typeof obj.generated_at === "string" ? obj.generated_at : null;
|
|
609
|
+
const chaptersChecked = safeInt(statsRaw.chapters_checked) ?? 0;
|
|
610
|
+
const issuesTotal = safeInt(statsRaw.issues_total) ?? 0;
|
|
611
|
+
const issuesBySeverity = safeSeverityCounts(statsRaw.issues_by_severity) ?? { high: 0, medium: 0, low: 0 };
|
|
612
|
+
const chaptersMissing = safeInt(statsRaw.chapters_missing);
|
|
613
|
+
const nerOk = safeInt(statsRaw.ner_ok);
|
|
614
|
+
const nerFailed = safeInt(statsRaw.ner_failed);
|
|
615
|
+
const nerFailedSample = safeString(statsRaw.ner_failed_sample, 160);
|
|
616
|
+
const summary = {
|
|
617
|
+
schema_version: obj.schema_version,
|
|
618
|
+
...(generated_at ? { generated_at } : {}),
|
|
619
|
+
...(scope ? { scope } : {}),
|
|
620
|
+
...(volume !== null ? { volume } : {}),
|
|
621
|
+
chapter_range,
|
|
622
|
+
stats: {
|
|
623
|
+
chapters_checked: chaptersChecked,
|
|
624
|
+
issues_total: issuesTotal,
|
|
625
|
+
issues_by_severity: issuesBySeverity,
|
|
626
|
+
...(chaptersMissing !== null ? { chapters_missing: chaptersMissing } : {}),
|
|
627
|
+
...(nerOk !== null ? { ner_ok: nerOk } : {}),
|
|
628
|
+
...(nerFailed !== null ? { ner_failed: nerFailed } : {}),
|
|
629
|
+
...(nerFailedSample ? { ner_failed_sample: nerFailedSample } : {})
|
|
630
|
+
},
|
|
631
|
+
issues: issues.slice(0, MAX_ISSUES)
|
|
632
|
+
};
|
|
633
|
+
const signals = ls_001_signals.slice(0, MAX_LS_001_SIGNALS);
|
|
634
|
+
if (signals.length > 0)
|
|
635
|
+
summary.ls_001_signals = signals;
|
|
636
|
+
return summary;
|
|
637
|
+
}
|
|
638
|
+
export async function tryParseOutlineChapterRange(args) {
|
|
639
|
+
const outlineRel = `volumes/vol-${pad2(args.volume)}/outline.md`;
|
|
640
|
+
const outlineAbs = join(args.rootDir, outlineRel);
|
|
641
|
+
if (!(await pathExists(outlineAbs)))
|
|
642
|
+
return null;
|
|
643
|
+
const text = await readTextFile(outlineAbs);
|
|
644
|
+
const nums = [];
|
|
645
|
+
const re = /^###\s*第\s*(\d+)\s*章/gu;
|
|
646
|
+
for (const line of text.split(/\r?\n/gu)) {
|
|
647
|
+
const m = re.exec(line);
|
|
648
|
+
re.lastIndex = 0;
|
|
649
|
+
if (!m)
|
|
650
|
+
continue;
|
|
651
|
+
const n = Number.parseInt(m[1] ?? "", 10);
|
|
652
|
+
if (Number.isInteger(n) && n > 0)
|
|
653
|
+
nums.push(n);
|
|
654
|
+
}
|
|
655
|
+
if (nums.length === 0)
|
|
656
|
+
return null;
|
|
657
|
+
nums.sort((a, b) => a - b);
|
|
658
|
+
return { start: nums[0], end: nums[nums.length - 1] };
|
|
659
|
+
}
|
|
660
|
+
export async function tryParseVolumeContractChapterRange(args) {
|
|
661
|
+
const dirRel = `volumes/vol-${pad2(args.volume)}/chapter-contracts`;
|
|
662
|
+
const dirAbs = join(args.rootDir, dirRel);
|
|
663
|
+
if (!(await pathExists(dirAbs)))
|
|
664
|
+
return null;
|
|
665
|
+
const entries = await readdir(dirAbs, { withFileTypes: true });
|
|
666
|
+
const nums = [];
|
|
667
|
+
for (const e of entries) {
|
|
668
|
+
if (!e.isFile())
|
|
669
|
+
continue;
|
|
670
|
+
const m = /^chapter-(\d{3})\.json$/u.exec(e.name);
|
|
671
|
+
if (!m)
|
|
672
|
+
continue;
|
|
673
|
+
const n = Number.parseInt(m[1] ?? "", 10);
|
|
674
|
+
if (Number.isInteger(n) && n > 0)
|
|
675
|
+
nums.push(n);
|
|
676
|
+
}
|
|
677
|
+
if (nums.length === 0)
|
|
678
|
+
return null;
|
|
679
|
+
nums.sort((a, b) => a - b);
|
|
680
|
+
return { start: nums[0], end: nums[nums.length - 1] };
|
|
681
|
+
}
|
|
682
|
+
export async function tryResolveVolumeChapterRange(args) {
|
|
683
|
+
return (await tryParseOutlineChapterRange(args)) ?? (await tryParseVolumeContractChapterRange(args));
|
|
684
|
+
}
|