dsh-novel-write-engine 0.2.5 → 0.2.6
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/CHANGELOG.md +22 -0
- package/README.md +21 -27
- package/briefs/P3.md +2 -0
- package/briefs/P6.md +1 -1
- package/briefs/P8.md +1 -1
- package/lib/client.js +134 -17
- package/lib/index.d.ts +176 -2
- package/lib/index.js +872 -252
- package/lib/types/client/dto.d.ts +13 -0
- package/lib/types/client/viewmodel.d.ts +24 -1
- package/lib/types/constants.d.ts +1 -1
- package/lib/types/panel-contract.d.ts +125 -0
- package/lib/types/types.d.ts +15 -1
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -14,7 +14,7 @@ const PLUGIN_NAME = "dsh-novel-write-engine";
|
|
|
14
14
|
const PANEL_ID = "novel-workshop";
|
|
15
15
|
const PANEL_ROUTE = "/api/novel-write-engine/panel";
|
|
16
16
|
const SKILL_NAME = "novel-write-engine";
|
|
17
|
-
const REMOTE_VERSION =
|
|
17
|
+
const REMOTE_VERSION = 6;
|
|
18
18
|
/** The panel is a recent-history view; the wire payload and UI use one cap. */
|
|
19
19
|
const PANEL_CHAPTER_LIMIT = 30;
|
|
20
20
|
/** Project-relative locations. Every write goes through these constants. */
|
|
@@ -523,9 +523,61 @@ function applyExclusions(dimensions, exclusions) {
|
|
|
523
523
|
function splitParagraphs(text) {
|
|
524
524
|
return text.split(/\n+/).map((line) => line.trim()).filter((line) => line !== "");
|
|
525
525
|
}
|
|
526
|
+
const SENTENCE_TERMINATOR = /[。!?!?]+|…+|\.(?=\s|$)/g;
|
|
527
|
+
const SENTENCE_CLOSERS = /* @__PURE__ */ new Set([
|
|
528
|
+
"」",
|
|
529
|
+
"』",
|
|
530
|
+
"”",
|
|
531
|
+
"\"",
|
|
532
|
+
"’",
|
|
533
|
+
"'",
|
|
534
|
+
")",
|
|
535
|
+
")",
|
|
536
|
+
"】",
|
|
537
|
+
"]"
|
|
538
|
+
]);
|
|
539
|
+
/**
|
|
540
|
+
* Sentence boundaries have one owner. This offset-preserving variant is used
|
|
541
|
+
* by semantic evidence and any caller that must map a quote back to a
|
|
542
|
+
* paragraph; `splitSentences` is the string-only convenience view of it.
|
|
543
|
+
*/
|
|
544
|
+
function splitSentenceSpans(text) {
|
|
545
|
+
const spans = [];
|
|
546
|
+
let paragraph = 1;
|
|
547
|
+
let lineStart = 0;
|
|
548
|
+
while (lineStart <= text.length) {
|
|
549
|
+
const newline = text.indexOf("\n", lineStart);
|
|
550
|
+
const lineEnd = newline === -1 ? text.length : newline;
|
|
551
|
+
const line = text.slice(lineStart, lineEnd);
|
|
552
|
+
if (line.trim() !== "") {
|
|
553
|
+
let cursor = 0;
|
|
554
|
+
while (cursor < line.length) {
|
|
555
|
+
SENTENCE_TERMINATOR.lastIndex = cursor;
|
|
556
|
+
const match = SENTENCE_TERMINATOR.exec(line);
|
|
557
|
+
let rawEnd = match === null ? line.length : match.index + match[0].length;
|
|
558
|
+
while (rawEnd < line.length && SENTENCE_CLOSERS.has(line[rawEnd])) rawEnd += 1;
|
|
559
|
+
let start = cursor;
|
|
560
|
+
while (start < rawEnd && /\s/.test(line[start])) start += 1;
|
|
561
|
+
const body = line.slice(start, rawEnd).trimEnd();
|
|
562
|
+
const end = start + body.length;
|
|
563
|
+
if (body !== "") spans.push({
|
|
564
|
+
start: lineStart + start,
|
|
565
|
+
end: lineStart + end,
|
|
566
|
+
paragraph,
|
|
567
|
+
text: body
|
|
568
|
+
});
|
|
569
|
+
cursor = rawEnd;
|
|
570
|
+
}
|
|
571
|
+
paragraph += 1;
|
|
572
|
+
}
|
|
573
|
+
if (newline === -1) break;
|
|
574
|
+
lineStart = newline + 1;
|
|
575
|
+
}
|
|
576
|
+
return spans;
|
|
577
|
+
}
|
|
526
578
|
/** Sentence splitting uses Chinese and English terminators; empty runs collapse. */
|
|
527
579
|
function splitSentences(text) {
|
|
528
|
-
return text.
|
|
580
|
+
return splitSentenceSpans(text).map((span) => span.text.replace(/([。!?!?]+|…+|\.)$/u, "").replace(/[」』”"'’))】\]]+$/u, "").trim()).filter((sentence) => sentence !== "");
|
|
529
581
|
}
|
|
530
582
|
/** Character length of each sentence, in document order. */
|
|
531
583
|
function sentenceLengths(sentences) {
|
|
@@ -627,6 +679,30 @@ function similarity(a, b) {
|
|
|
627
679
|
function textSimilarity(a, b) {
|
|
628
680
|
return similarity(simhash(a), simhash(b));
|
|
629
681
|
}
|
|
682
|
+
/** Character-bigram Dice similarity; unlike SimHash this has a ~0 baseline for unrelated short text. */
|
|
683
|
+
function bigramSimilarity(a, b) {
|
|
684
|
+
const grams = (text) => {
|
|
685
|
+
const chars = Array.from(text.replace(/\s+/g, ""));
|
|
686
|
+
if (chars.length < 2) return chars.length === 0 ? [] : [chars[0]];
|
|
687
|
+
const result = [];
|
|
688
|
+
for (let index = 0; index + 1 < chars.length; index += 1) result.push(chars[index] + chars[index + 1]);
|
|
689
|
+
return result;
|
|
690
|
+
};
|
|
691
|
+
const left = grams(a);
|
|
692
|
+
const right = grams(b);
|
|
693
|
+
if (left.length === 0 || right.length === 0) return 0;
|
|
694
|
+
const counts = /* @__PURE__ */ new Map();
|
|
695
|
+
for (const gram of left) counts.set(gram, (counts.get(gram) ?? 0) + 1);
|
|
696
|
+
let intersection = 0;
|
|
697
|
+
for (const gram of right) {
|
|
698
|
+
const count = counts.get(gram) ?? 0;
|
|
699
|
+
if (count > 0) {
|
|
700
|
+
counts.set(gram, count - 1);
|
|
701
|
+
intersection += 1;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
return 2 * intersection / (left.length + right.length);
|
|
705
|
+
}
|
|
630
706
|
// src/domain/transitions.ts
|
|
631
707
|
/**
|
|
632
708
|
* The single definition of allowed progress. The engine queries this table
|
|
@@ -1968,31 +2044,31 @@ const SCHEMAS = {
|
|
|
1968
2044
|
themes: req("array")
|
|
1969
2045
|
}
|
|
1970
2046
|
};
|
|
1971
|
-
const invalid$
|
|
2047
|
+
const invalid$2 = (kind, detail) => {
|
|
1972
2048
|
throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `invalid ${kind} document: ${detail}`);
|
|
1973
2049
|
};
|
|
1974
2050
|
const objectAt = (kind, value, path) => {
|
|
1975
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) return invalid$
|
|
2051
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return invalid$2(kind, `${path} must be an object`);
|
|
1976
2052
|
return value;
|
|
1977
2053
|
};
|
|
1978
2054
|
const listAt = (kind, value, path) => {
|
|
1979
|
-
if (!Array.isArray(value)) return invalid$
|
|
2055
|
+
if (!Array.isArray(value)) return invalid$2(kind, `${path} must be an array`);
|
|
1980
2056
|
return value;
|
|
1981
2057
|
};
|
|
1982
2058
|
const stringAt = (kind, value, path) => {
|
|
1983
|
-
if (typeof value !== "string") return invalid$
|
|
2059
|
+
if (typeof value !== "string") return invalid$2(kind, `${path} must be a string`);
|
|
1984
2060
|
return value;
|
|
1985
2061
|
};
|
|
1986
2062
|
const numberAt = (kind, value, path) => {
|
|
1987
|
-
if (typeof value !== "number" || !Number.isFinite(value)) return invalid$
|
|
2063
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return invalid$2(kind, `${path} must be a number`);
|
|
1988
2064
|
return value;
|
|
1989
2065
|
};
|
|
1990
2066
|
const integerAt = (kind, value, path) => {
|
|
1991
|
-
if (typeof value !== "number" || !Number.isInteger(value)) return invalid$
|
|
2067
|
+
if (typeof value !== "number" || !Number.isInteger(value)) return invalid$2(kind, `${path} must be an integer`);
|
|
1992
2068
|
return value;
|
|
1993
2069
|
};
|
|
1994
2070
|
const booleanAt = (kind, value, path) => {
|
|
1995
|
-
if (typeof value !== "boolean") return invalid$
|
|
2071
|
+
if (typeof value !== "boolean") return invalid$2(kind, `${path} must be a boolean`);
|
|
1996
2072
|
return value;
|
|
1997
2073
|
};
|
|
1998
2074
|
const optionalString = (kind, value, path) => {
|
|
@@ -2015,10 +2091,19 @@ const duplicateNames = (kind, rows, path, nameOf) => {
|
|
|
2015
2091
|
for (const row of rows) {
|
|
2016
2092
|
const name = nameOf(row);
|
|
2017
2093
|
if (typeof name !== "string" || name === "") continue;
|
|
2018
|
-
if (seen.has(name)) invalid$
|
|
2094
|
+
if (seen.has(name)) invalid$2(kind, `${path} contains duplicate name "${name}"`);
|
|
2019
2095
|
seen.add(name);
|
|
2020
2096
|
}
|
|
2021
2097
|
};
|
|
2098
|
+
const validateAnchorList = (kind, value, path) => {
|
|
2099
|
+
if (value === void 0) return;
|
|
2100
|
+
const anchors = listAt(kind, value, path);
|
|
2101
|
+
if (anchors.length > 8) invalid$2(kind, `${path} may contain at most 8 anchors`);
|
|
2102
|
+
for (const [index, anchor] of anchors.entries()) {
|
|
2103
|
+
const text = stringAt(kind, anchor, `${path}[${index}]`).trim();
|
|
2104
|
+
if (text.length < 2 || Array.from(text).length > 120) invalid$2(kind, `${path}[${index}] must contain 2..120 characters`);
|
|
2105
|
+
}
|
|
2106
|
+
};
|
|
2022
2107
|
const validateSummary = (kind, value, path) => {
|
|
2023
2108
|
const summary = objectAt(kind, value, path);
|
|
2024
2109
|
for (const key of [
|
|
@@ -2033,6 +2118,18 @@ const validateSummary = (kind, value, path) => {
|
|
|
2033
2118
|
"resolved"
|
|
2034
2119
|
]) listAt(kind, foreshadows[key], `${path}.foreshadows.${key}`);
|
|
2035
2120
|
objectAt(kind, summary.new_elements, `${path}.new_elements`);
|
|
2121
|
+
if (summary.new_element_anchors !== void 0) {
|
|
2122
|
+
const anchorMap = objectAt(kind, summary.new_element_anchors, `${path}.new_element_anchors`);
|
|
2123
|
+
for (const group of Object.keys(anchorMap)) {
|
|
2124
|
+
if (![
|
|
2125
|
+
"locations",
|
|
2126
|
+
"abilities",
|
|
2127
|
+
"artifacts"
|
|
2128
|
+
].includes(group)) invalid$2(kind, `${path}.new_element_anchors has unknown group "${group}"`);
|
|
2129
|
+
const entries = objectAt(kind, anchorMap[group], `${path}.new_element_anchors.${group}`);
|
|
2130
|
+
for (const [name, anchors] of Object.entries(entries)) validateAnchorList(kind, anchors, `${path}.new_element_anchors.${group}.${name}`);
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2036
2133
|
listAt(kind, summary.resource_changes, `${path}.resource_changes`);
|
|
2037
2134
|
stringAt(kind, summary.opening, `${path}.opening`);
|
|
2038
2135
|
const continuity = objectAt(kind, summary.continuity, `${path}.continuity`);
|
|
@@ -2047,7 +2144,7 @@ const validateNested = (kind, record) => {
|
|
|
2047
2144
|
const targets = objectAt(kind, record.targets, "targets");
|
|
2048
2145
|
for (const key of ["words_per_chapter", "dialogue_ratio"]) {
|
|
2049
2146
|
const pair = listAt(kind, targets[key], `targets.${key}`);
|
|
2050
|
-
if (pair.length !== 2) invalid$
|
|
2147
|
+
if (pair.length !== 2) invalid$2(kind, `targets.${key} must contain two numbers`);
|
|
2051
2148
|
for (const [index, item] of pair.entries()) numberAt(kind, item, `targets.${key}[${index}]`);
|
|
2052
2149
|
}
|
|
2053
2150
|
if (targets.total_chapters !== null) integerAt(kind, targets.total_chapters, "targets.total_chapters");
|
|
@@ -2123,13 +2220,14 @@ const validateNested = (kind, record) => {
|
|
|
2123
2220
|
stringAt(kind, row.layout, `${path}.layout`);
|
|
2124
2221
|
numberAt(kind, row.capacity, `${path}.capacity`);
|
|
2125
2222
|
listAt(kind, row.changes, `${path}.changes`);
|
|
2223
|
+
validateAnchorList(kind, row.anchors, `${path}.anchors`);
|
|
2126
2224
|
});
|
|
2127
2225
|
for (const key of [
|
|
2128
2226
|
"abilities",
|
|
2129
2227
|
"artifacts",
|
|
2130
2228
|
"factions",
|
|
2131
2229
|
"rules"
|
|
2132
|
-
]) listAt(kind, record[key], key);
|
|
2230
|
+
]) for (const [index, row] of listAt(kind, record[key], key).entries()) if (typeof row === "object" && row !== null && !Array.isArray(row)) validateAnchorList(kind, row.anchors, `${key}[${index}].anchors`);
|
|
2133
2231
|
{
|
|
2134
2232
|
const names = [];
|
|
2135
2233
|
for (const key of [
|
|
@@ -2142,7 +2240,7 @@ const validateNested = (kind, record) => {
|
|
|
2142
2240
|
const rows = Array.isArray(record[key]) ? record[key] : [];
|
|
2143
2241
|
for (const row of rows) if (typeof row.name === "string") names.push(row.name);
|
|
2144
2242
|
}
|
|
2145
|
-
if (new Set(names).size !== names.length) invalid$
|
|
2243
|
+
if (new Set(names).size !== names.length) invalid$2(kind, "world entities must have unique names");
|
|
2146
2244
|
}
|
|
2147
2245
|
return;
|
|
2148
2246
|
case "locks": {
|
|
@@ -2213,7 +2311,7 @@ const validateNested = (kind, record) => {
|
|
|
2213
2311
|
validateRecords(kind, semantic.items, "semantic.items", (row, path) => {
|
|
2214
2312
|
stringAt(kind, row.id, `${path}.id`);
|
|
2215
2313
|
const verdict = stringAt(kind, row.verdict, `${path}.verdict`);
|
|
2216
|
-
if (verdict !== "pass" && verdict !== "fail") invalid$
|
|
2314
|
+
if (verdict !== "pass" && verdict !== "fail") invalid$2(kind, `${path}.verdict must be pass|fail`);
|
|
2217
2315
|
stringAt(kind, row.evidence, `${path}.evidence`);
|
|
2218
2316
|
if (row.note !== void 0) stringAt(kind, row.note, `${path}.note`);
|
|
2219
2317
|
});
|
|
@@ -2262,11 +2360,11 @@ const validateNested = (kind, record) => {
|
|
|
2262
2360
|
"notes",
|
|
2263
2361
|
"status"
|
|
2264
2362
|
]) stringAt(kind, row[key], `${path}.${key}`);
|
|
2265
|
-
if (!SCENE_TYPES.includes(row.scene_type)) invalid$
|
|
2266
|
-
if (row.status !== "planned" && row.status !== "archived") invalid$
|
|
2363
|
+
if (!SCENE_TYPES.includes(row.scene_type)) invalid$2(kind, `${path}.scene_type must be one of ${SCENE_TYPES.join(", ")}`);
|
|
2364
|
+
if (row.status !== "planned" && row.status !== "archived") invalid$2(kind, `${path}.status must be planned|archived`);
|
|
2267
2365
|
for (const [index, name] of listAt(kind, row.cast, `${path}.cast`).entries()) stringAt(kind, name, `${path}.cast[${index}]`);
|
|
2268
2366
|
const target = listAt(kind, row.target_words, `${path}.target_words`);
|
|
2269
|
-
if (target.length !== 2) invalid$
|
|
2367
|
+
if (target.length !== 2) invalid$2(kind, `${path}.target_words must contain two integers`);
|
|
2270
2368
|
for (const [index, item] of target.entries()) integerAt(kind, item, `${path}.target_words[${index}]`);
|
|
2271
2369
|
nullableString(kind, row.archived_at, `${path}.archived_at`);
|
|
2272
2370
|
nullableNumber(kind, row.word_count, `${path}.word_count`);
|
|
@@ -2323,9 +2421,9 @@ const validateNested = (kind, record) => {
|
|
|
2323
2421
|
validateRecords(kind, record.issues, "issues", (row, path) => {
|
|
2324
2422
|
stringAt(kind, row.id, `${path}.id`);
|
|
2325
2423
|
const severity = stringAt(kind, row.severity, `${path}.severity`);
|
|
2326
|
-
if (severity !== "blocker" && severity !== "advisory") invalid$
|
|
2424
|
+
if (severity !== "blocker" && severity !== "advisory") invalid$2(kind, `${path}.severity must be blocker|advisory`);
|
|
2327
2425
|
const status = stringAt(kind, row.status, `${path}.status`);
|
|
2328
|
-
if (status !== "open" && status !== "fixed" && status !== "waived") invalid$
|
|
2426
|
+
if (status !== "open" && status !== "fixed" && status !== "waived") invalid$2(kind, `${path}.status must be open|fixed|waived`);
|
|
2329
2427
|
for (const field of [
|
|
2330
2428
|
"dimension",
|
|
2331
2429
|
"location",
|
|
@@ -2358,26 +2456,26 @@ const matchesType = (value, field) => {
|
|
|
2358
2456
|
* user upgrades instead of the engine rewriting their data.
|
|
2359
2457
|
*/
|
|
2360
2458
|
function narrow(kind, value) {
|
|
2361
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) invalid$
|
|
2459
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) invalid$2(kind, "not an object");
|
|
2362
2460
|
const record = value;
|
|
2363
2461
|
const version = record.schema_version;
|
|
2364
|
-
if (typeof version !== "number" || !Number.isInteger(version)) return invalid$
|
|
2462
|
+
if (typeof version !== "number" || !Number.isInteger(version)) return invalid$2(kind, "missing schema_version");
|
|
2365
2463
|
if (version > 1) throw new EngineFaultError(ERROR_CODES.SCHEMA_TOO_NEW, `refusing ${kind}: schema_version ${version} is newer than supported 1`, "upgrade the plugin; the file was left untouched");
|
|
2366
|
-
if (version !== 1) invalid$
|
|
2367
|
-
if (!Object.hasOwn(SCHEMAS, kind)) invalid$
|
|
2464
|
+
if (version !== 1) invalid$2(kind, `unsupported schema_version ${version}`);
|
|
2465
|
+
if (!Object.hasOwn(SCHEMAS, kind)) invalid$2(kind, `unknown document kind "${String(kind)}"`);
|
|
2368
2466
|
const schema = SCHEMAS[kind];
|
|
2369
2467
|
for (const key of Object.keys(record)) {
|
|
2370
2468
|
if (key === "schema_version") continue;
|
|
2371
|
-
if (!Object.hasOwn(schema, key)) invalid$
|
|
2469
|
+
if (!Object.hasOwn(schema, key)) invalid$2(kind, `unknown key "${key}"`);
|
|
2372
2470
|
}
|
|
2373
2471
|
for (const [key, field] of Object.entries(schema)) {
|
|
2374
2472
|
const fieldValue = record[key];
|
|
2375
2473
|
if (fieldValue === void 0) {
|
|
2376
|
-
if (field.required === true) invalid$
|
|
2474
|
+
if (field.required === true) invalid$2(kind, `missing required key "${key}"`);
|
|
2377
2475
|
continue;
|
|
2378
2476
|
}
|
|
2379
|
-
if (!matchesType(fieldValue, field)) invalid$
|
|
2380
|
-
if (field.enum !== void 0 && !field.enum.includes(fieldValue)) invalid$
|
|
2477
|
+
if (!matchesType(fieldValue, field)) invalid$2(kind, `"${key}" must be ${field.type}`);
|
|
2478
|
+
if (field.enum !== void 0 && !field.enum.includes(fieldValue)) invalid$2(kind, `"${key}" must be one of ${field.enum.join(", ")}`);
|
|
2381
2479
|
}
|
|
2382
2480
|
validateNested(kind, record);
|
|
2383
2481
|
return record;
|
|
@@ -3944,7 +4042,7 @@ const wordPair = (value, where) => {
|
|
|
3944
4042
|
if (!Number.isInteger(min) || !Number.isInteger(max) || min <= 0 || max < min) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} must be [min, max] positive integers with min <= max`);
|
|
3945
4043
|
return [min, max];
|
|
3946
4044
|
};
|
|
3947
|
-
const isRecord$
|
|
4045
|
+
const isRecord$3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3948
4046
|
/** Remove a leading 第N章 prefix; stored titles are bodies. */
|
|
3949
4047
|
function stripChapterPrefix(title) {
|
|
3950
4048
|
return title.replace(LENIENT_TITLE_PREFIX$1, "").trim();
|
|
@@ -4046,7 +4144,7 @@ const BOOK_PATCH_KEYS = /* @__PURE__ */ new Set([
|
|
|
4046
4144
|
/** Apply a partial book patch; omitted fields keep their existing value. */
|
|
4047
4145
|
function mergeBookPlan(existing, patch) {
|
|
4048
4146
|
if (patch === void 0) return { ...existing };
|
|
4049
|
-
if (!isRecord$
|
|
4147
|
+
if (!isRecord$3(patch)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "book must be an object");
|
|
4050
4148
|
for (const key of Object.keys(patch)) if (!BOOK_PATCH_KEYS.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `book has unknown field "${key}"`, `allowed: ${[...BOOK_PATCH_KEYS].join(", ")}`);
|
|
4051
4149
|
const result = { ...existing };
|
|
4052
4150
|
for (const key of [
|
|
@@ -4074,7 +4172,7 @@ function normalizeVolumes(input, options = { totalChapters: null }) {
|
|
|
4074
4172
|
if (!Array.isArray(input)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "volumes must be an array");
|
|
4075
4173
|
if (input.length > LIMITS.maxOutlineVolumes) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes contains ${input.length} items; limit is ${LIMITS.maxOutlineVolumes}`);
|
|
4076
4174
|
const volumes = input.map((raw, index) => {
|
|
4077
|
-
if (!isRecord$
|
|
4175
|
+
if (!isRecord$3(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes[${index}] must be an object`);
|
|
4078
4176
|
const start = positiveInteger$1(raw.start_chapter, `volumes[${index}].start_chapter`);
|
|
4079
4177
|
const end = positiveInteger$1(raw.end_chapter, `volumes[${index}].end_chapter`);
|
|
4080
4178
|
if (end < start) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes[${index}] end_chapter ${end} is before start_chapter ${start}`);
|
|
@@ -4191,7 +4289,7 @@ function normalizeOutlineChapters(input, options) {
|
|
|
4191
4289
|
const archived = new Map(options.archived.map((record) => [record.num, record]));
|
|
4192
4290
|
const normalized = /* @__PURE__ */ new Map();
|
|
4193
4291
|
for (const [index, raw] of input.entries()) {
|
|
4194
|
-
if (!isRecord$
|
|
4292
|
+
if (!isRecord$3(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapters[${index}] must be an object`);
|
|
4195
4293
|
const num = positiveInteger$1(raw.num, `chapters[${index}].num`);
|
|
4196
4294
|
if (options.totalChapters !== null && num > options.totalChapters) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} exceeds total_chapters ${options.totalChapters}`, "raise total_chapters with novel_blueprint before planning a longer book");
|
|
4197
4295
|
if (normalized.has(num)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} appears more than once`);
|
|
@@ -4265,7 +4363,7 @@ function normalizeOutlineChapters(input, options) {
|
|
|
4265
4363
|
}
|
|
4266
4364
|
/** Validate a document loaded from disk. Throws a fault, not an input error. */
|
|
4267
4365
|
function validateOutlineDocument(value, options) {
|
|
4268
|
-
if (!isRecord$
|
|
4366
|
+
if (!isRecord$3(value)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "outline/outline.yaml must be a mapping");
|
|
4269
4367
|
try {
|
|
4270
4368
|
if (value.schema_version !== 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline/outline.yaml schema_version must be 1, got ${String(value.schema_version)}`);
|
|
4271
4369
|
const allowedTopLevel = /* @__PURE__ */ new Set([
|
|
@@ -4277,7 +4375,7 @@ function validateOutlineDocument(value, options) {
|
|
|
4277
4375
|
]);
|
|
4278
4376
|
for (const key of Object.keys(value)) if (!allowedTopLevel.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline/outline.yaml has unknown key "${key}"`);
|
|
4279
4377
|
if (typeof value.updated_at !== "string" || value.updated_at.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "outline/outline.yaml updated_at must be a string");
|
|
4280
|
-
if (!isRecord$
|
|
4378
|
+
if (!isRecord$3(value.book)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "outline/outline.yaml book must be a mapping");
|
|
4281
4379
|
const allowedBook = /* @__PURE__ */ new Set([
|
|
4282
4380
|
"premise",
|
|
4283
4381
|
"logline",
|
|
@@ -4301,7 +4399,7 @@ function validateOutlineDocument(value, options) {
|
|
|
4301
4399
|
const chapters = [];
|
|
4302
4400
|
const seen = /* @__PURE__ */ new Set();
|
|
4303
4401
|
for (const [index, raw] of value.chapters.entries()) {
|
|
4304
|
-
if (!isRecord$
|
|
4402
|
+
if (!isRecord$3(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapters[${index}] must be an object`);
|
|
4305
4403
|
const num = positiveInteger$1(raw.num, `chapters[${index}].num`);
|
|
4306
4404
|
if (options.totalChapters !== null && num > options.totalChapters) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} exceeds total_chapters ${options.totalChapters}`);
|
|
4307
4405
|
if (seen.has(num)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} appears more than once`);
|
|
@@ -4367,7 +4465,7 @@ function mergeWorldbook(existing, patch, now) {
|
|
|
4367
4465
|
...existing,
|
|
4368
4466
|
updated_at: now
|
|
4369
4467
|
};
|
|
4370
|
-
if (!isRecord$
|
|
4468
|
+
if (!isRecord$3(patch)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "worldbook must be an object");
|
|
4371
4469
|
const result = {
|
|
4372
4470
|
...existing,
|
|
4373
4471
|
updated_at: now,
|
|
@@ -4387,7 +4485,7 @@ function mergeWorldbook(existing, patch, now) {
|
|
|
4387
4485
|
if (!Array.isArray(value)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${key} must be an array`);
|
|
4388
4486
|
if (value.length > LIMITS.maxWorldbookEntries) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${key} contains ${value.length} items; limit is ${LIMITS.maxWorldbookEntries}`);
|
|
4389
4487
|
return value.map((raw, index) => {
|
|
4390
|
-
if (!isRecord$
|
|
4488
|
+
if (!isRecord$3(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${key}[${index}] must be an object`);
|
|
4391
4489
|
return raw;
|
|
4392
4490
|
});
|
|
4393
4491
|
};
|
|
@@ -4467,7 +4565,7 @@ function mergeWorldbook(existing, patch, now) {
|
|
|
4467
4565
|
}
|
|
4468
4566
|
/** Validate a worldbook loaded from disk. */
|
|
4469
4567
|
function validateWorldbookDocument(value, now) {
|
|
4470
|
-
if (!isRecord$
|
|
4568
|
+
if (!isRecord$3(value)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "outline/worldbook.yaml must be a mapping");
|
|
4471
4569
|
try {
|
|
4472
4570
|
if (value.schema_version !== 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline/worldbook.yaml schema_version must be 1, got ${String(value.schema_version)}`);
|
|
4473
4571
|
const allowedTopLevel = /* @__PURE__ */ new Set([
|
|
@@ -4878,7 +4976,7 @@ function parseOutlineData(content, format) {
|
|
|
4878
4976
|
} catch (error) {
|
|
4879
4977
|
throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline ${format} is invalid: ${error instanceof Error ? error.message : String(error)}`);
|
|
4880
4978
|
}
|
|
4881
|
-
if (!isRecord$
|
|
4979
|
+
if (!isRecord$3(parsed)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline ${format} must be a mapping`);
|
|
4882
4980
|
if (parsed.schema_version !== void 0 && parsed.schema_version !== 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline ${format} schema_version must be 1, got ${String(parsed.schema_version)}`);
|
|
4883
4981
|
const warnings = [];
|
|
4884
4982
|
const allowed = /* @__PURE__ */ new Set([
|
|
@@ -4909,19 +5007,19 @@ function parseOutlineInput(content, format) {
|
|
|
4909
5007
|
};
|
|
4910
5008
|
}
|
|
4911
5009
|
/** One-line Markdown field; a newline would turn into an unknown import line. */
|
|
4912
|
-
const oneLine = (value) => value.replace(/\s+/g, " ").trim();
|
|
5010
|
+
const oneLine$1 = (value) => value.replace(/\s+/g, " ").trim();
|
|
4913
5011
|
/** Export helper: a human-readable outline in the import template's shape. */
|
|
4914
5012
|
function renderOutlineMarkdown(project, document, chapters) {
|
|
4915
|
-
const parts = [`# ${oneLine(project.title)}`, ""];
|
|
4916
|
-
if (document.book.logline !== "") parts.push(`> ${oneLine(document.book.logline)}`, "");
|
|
5013
|
+
const parts = [`# ${oneLine$1(project.title)}`, ""];
|
|
5014
|
+
if (document.book.logline !== "") parts.push(`> ${oneLine$1(document.book.logline)}`, "");
|
|
4917
5015
|
const fields = [
|
|
4918
|
-
["前提", oneLine(document.book.premise)],
|
|
4919
|
-
["主题", document.book.themes.map(oneLine).join("、")],
|
|
4920
|
-
["基调", oneLine(document.book.tone)],
|
|
4921
|
-
["视角", oneLine(document.book.pov)],
|
|
4922
|
-
["读者", oneLine(document.book.audience)],
|
|
4923
|
-
["卖点", document.book.selling_points.map(oneLine).join("、")],
|
|
4924
|
-
["结局", oneLine(document.book.ending)],
|
|
5016
|
+
["前提", oneLine$1(document.book.premise)],
|
|
5017
|
+
["主题", document.book.themes.map(oneLine$1).join("、")],
|
|
5018
|
+
["基调", oneLine$1(document.book.tone)],
|
|
5019
|
+
["视角", oneLine$1(document.book.pov)],
|
|
5020
|
+
["读者", oneLine$1(document.book.audience)],
|
|
5021
|
+
["卖点", document.book.selling_points.map(oneLine$1).join("、")],
|
|
5022
|
+
["结局", oneLine$1(document.book.ending)],
|
|
4925
5023
|
["严格", document.book.strict ? "true" : "false"]
|
|
4926
5024
|
];
|
|
4927
5025
|
for (const [key, value] of fields) if (value !== "") parts.push(`${key}:${value}`);
|
|
@@ -4930,12 +5028,12 @@ function renderOutlineMarkdown(project, document, chapters) {
|
|
|
4930
5028
|
const outside = document.chapters.filter((card) => !document.volumes.some((volume) => card.num >= volume.start_chapter && card.num <= volume.end_chapter));
|
|
4931
5029
|
for (const card of outside) parts.push(...renderCard(card, archived.get(card.num)));
|
|
4932
5030
|
for (const volume of document.volumes) {
|
|
4933
|
-
parts.push(`## ${oneLine(volume.title)}(第${volume.start_chapter}-${volume.end_chapter}章)`);
|
|
4934
|
-
parts.push(`编号:${oneLine(volume.id)}`);
|
|
5031
|
+
parts.push(`## ${oneLine$1(volume.title)}(第${volume.start_chapter}-${volume.end_chapter}章)`);
|
|
5032
|
+
parts.push(`编号:${oneLine$1(volume.id)}`);
|
|
4935
5033
|
parts.push(`状态:${volume.status}`);
|
|
4936
|
-
if (volume.goal !== "") parts.push(`目标:${oneLine(volume.goal)}`);
|
|
4937
|
-
if (volume.arc !== "") parts.push(`弧线:${oneLine(volume.arc)}`);
|
|
4938
|
-
if (volume.climax !== "") parts.push(`高潮:${oneLine(volume.climax)}`);
|
|
5034
|
+
if (volume.goal !== "") parts.push(`目标:${oneLine$1(volume.goal)}`);
|
|
5035
|
+
if (volume.arc !== "") parts.push(`弧线:${oneLine$1(volume.arc)}`);
|
|
5036
|
+
if (volume.climax !== "") parts.push(`高潮:${oneLine$1(volume.climax)}`);
|
|
4939
5037
|
parts.push("");
|
|
4940
5038
|
const cards = document.chapters.filter((card) => card.num >= volume.start_chapter && card.num <= volume.end_chapter);
|
|
4941
5039
|
for (const card of cards) parts.push(...renderCard(card, archived.get(card.num)));
|
|
@@ -4944,15 +5042,15 @@ function renderOutlineMarkdown(project, document, chapters) {
|
|
|
4944
5042
|
}
|
|
4945
5043
|
const renderCard = (card, record) => {
|
|
4946
5044
|
const lines = [
|
|
4947
|
-
`### 第${card.num}章 ${oneLine(card.title)}`,
|
|
5045
|
+
`### 第${card.num}章 ${oneLine$1(card.title)}`,
|
|
4948
5046
|
`场景:${card.scene_type}`,
|
|
4949
5047
|
`字数:${card.target_words[0]}-${card.target_words[1]}`,
|
|
4950
|
-
`角色:${card.cast.map(oneLine).join("、")}`,
|
|
4951
|
-
`目标:${oneLine(card.goal)}`,
|
|
4952
|
-
`冲突:${oneLine(card.conflict)}`,
|
|
4953
|
-
`钩子:${oneLine(card.hook)}`
|
|
5048
|
+
`角色:${card.cast.map(oneLine$1).join("、")}`,
|
|
5049
|
+
`目标:${oneLine$1(card.goal)}`,
|
|
5050
|
+
`冲突:${oneLine$1(card.conflict)}`,
|
|
5051
|
+
`钩子:${oneLine$1(card.hook)}`
|
|
4954
5052
|
];
|
|
4955
|
-
if (card.notes !== "") lines.push(`备注:${oneLine(card.notes)}`);
|
|
5053
|
+
if (card.notes !== "") lines.push(`备注:${oneLine$1(card.notes)}`);
|
|
4956
5054
|
if (card.status === "archived" && record !== void 0) lines.push(`归档:${record.word_count} 字 / ${record.score} 分`);
|
|
4957
5055
|
lines.push("");
|
|
4958
5056
|
return lines;
|
|
@@ -4964,20 +5062,20 @@ function renderWorldbookMarkdown(project, document) {
|
|
|
4964
5062
|
if (lines.length === 0) return;
|
|
4965
5063
|
parts.push(`## ${title}`, "", ...lines, "");
|
|
4966
5064
|
};
|
|
4967
|
-
if (document.premise !== "") section("一句话", [oneLine(document.premise)]);
|
|
4968
|
-
if (document.setting !== "") section("世界设定", [oneLine(document.setting)]);
|
|
4969
|
-
if (document.themes.length > 0) section("主题", [document.themes.map(oneLine).join("、")]);
|
|
4970
|
-
section("规则", document.rules.map((entry) => `- **${oneLine(entry.name)}**:${oneLine(entry.description)}`));
|
|
4971
|
-
section("势力", document.factions.map((entry) => entry.goal === "" ? `- **${oneLine(entry.name)}**:${oneLine(entry.description)}` : `- **${oneLine(entry.name)}**:${oneLine(entry.description)}(目标:${oneLine(entry.goal)})`));
|
|
4972
|
-
section("地点", document.locations.map((entry) => `- **${oneLine(entry.name)}**:${oneLine(entry.description)}${entry.layout === "" ? "" : `;布局:${oneLine(entry.layout)}`}${entry.capacity === 0 ? "" : `;容量:${entry.capacity}`}`));
|
|
4973
|
-
section("角色", document.characters.map((entry) => `- **${oneLine(entry.name)}**(${oneLine(entry.role)}):${oneLine(entry.description)}${entry.arc === "" ? "" : `;弧线:${oneLine(entry.arc)}`}`));
|
|
4974
|
-
section("术语", document.glossary.map((entry) => `- **${oneLine(entry.term)}**:${oneLine(entry.definition)}`));
|
|
4975
|
-
section("前史", document.backstory.map((entry) => `- ${oneLine(entry.time)}:${oneLine(entry.event)}`));
|
|
4976
|
-
section("数值事实", (document.facts ?? []).map((fact) => `- **${oneLine(fact.alias)}**:${fact.value}${oneLine(fact.unit)}${fact.tolerance === void 0 ? "" : `(容差 ${fact.tolerance})`}${fact.note === void 0 || fact.note === "" ? "" : `;${oneLine(fact.note)}`}`));
|
|
5065
|
+
if (document.premise !== "") section("一句话", [oneLine$1(document.premise)]);
|
|
5066
|
+
if (document.setting !== "") section("世界设定", [oneLine$1(document.setting)]);
|
|
5067
|
+
if (document.themes.length > 0) section("主题", [document.themes.map(oneLine$1).join("、")]);
|
|
5068
|
+
section("规则", document.rules.map((entry) => `- **${oneLine$1(entry.name)}**:${oneLine$1(entry.description)}`));
|
|
5069
|
+
section("势力", document.factions.map((entry) => entry.goal === "" ? `- **${oneLine$1(entry.name)}**:${oneLine$1(entry.description)}` : `- **${oneLine$1(entry.name)}**:${oneLine$1(entry.description)}(目标:${oneLine$1(entry.goal)})`));
|
|
5070
|
+
section("地点", document.locations.map((entry) => `- **${oneLine$1(entry.name)}**:${oneLine$1(entry.description)}${entry.layout === "" ? "" : `;布局:${oneLine$1(entry.layout)}`}${entry.capacity === 0 ? "" : `;容量:${entry.capacity}`}`));
|
|
5071
|
+
section("角色", document.characters.map((entry) => `- **${oneLine$1(entry.name)}**(${oneLine$1(entry.role)}):${oneLine$1(entry.description)}${entry.arc === "" ? "" : `;弧线:${oneLine$1(entry.arc)}`}`));
|
|
5072
|
+
section("术语", document.glossary.map((entry) => `- **${oneLine$1(entry.term)}**:${oneLine$1(entry.definition)}`));
|
|
5073
|
+
section("前史", document.backstory.map((entry) => `- ${oneLine$1(entry.time)}:${oneLine$1(entry.event)}`));
|
|
5074
|
+
section("数值事实", (document.facts ?? []).map((fact) => `- **${oneLine$1(fact.alias)}**:${fact.value}${oneLine$1(fact.unit)}${fact.tolerance === void 0 ? "" : `(容差 ${fact.tolerance})`}${fact.note === void 0 || fact.note === "" ? "" : `;${oneLine$1(fact.note)}`}`));
|
|
4977
5075
|
return parts.join("\n");
|
|
4978
5076
|
}
|
|
4979
5077
|
// src/engine/outline-merge.ts
|
|
4980
|
-
const isRecord$
|
|
5078
|
+
const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4981
5079
|
const text$1 = (value, where) => {
|
|
4982
5080
|
if (typeof value !== "string" || value.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} must be a non-empty string`);
|
|
4983
5081
|
return value.trim();
|
|
@@ -5012,7 +5110,7 @@ const submittedNumbers = (input) => {
|
|
|
5012
5110
|
const result = /* @__PURE__ */ new Set();
|
|
5013
5111
|
if (!Array.isArray(input)) return result;
|
|
5014
5112
|
for (const raw of input) {
|
|
5015
|
-
if (!isRecord$
|
|
5113
|
+
if (!isRecord$2(raw) || !Number.isInteger(raw.num) || raw.num < 1) continue;
|
|
5016
5114
|
result.add(raw.num);
|
|
5017
5115
|
}
|
|
5018
5116
|
return result;
|
|
@@ -5051,7 +5149,7 @@ const mergeVolumes = (options) => {
|
|
|
5051
5149
|
};
|
|
5052
5150
|
const submitted = /* @__PURE__ */ new Set();
|
|
5053
5151
|
for (const [index, raw] of (options.volumes ?? []).entries()) {
|
|
5054
|
-
if (!isRecord$
|
|
5152
|
+
if (!isRecord$2(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes[${index}] must be an object`);
|
|
5055
5153
|
const title = text$1(raw.title, `volumes[${index}].title`);
|
|
5056
5154
|
if (submitted.has(title)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes contains duplicate title "${title}"`);
|
|
5057
5155
|
submitted.add(title);
|
|
@@ -5205,6 +5303,120 @@ const mergeOutline = (options) => {
|
|
|
5205
5303
|
volumes_removed: volumeChanges.removed
|
|
5206
5304
|
};
|
|
5207
5305
|
};
|
|
5306
|
+
// src/engine/entity-anchors.ts
|
|
5307
|
+
/** The three `summary.new_elements` groups that may carry anchors. */
|
|
5308
|
+
const NEW_ELEMENT_GROUPS = [
|
|
5309
|
+
"locations",
|
|
5310
|
+
"abilities",
|
|
5311
|
+
"artifacts"
|
|
5312
|
+
];
|
|
5313
|
+
const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5314
|
+
const invalid$1 = (detail) => {
|
|
5315
|
+
throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, detail, "new_element_anchors maps each new entity name to 1..8 verbatim anchors of 2..120 characters");
|
|
5316
|
+
};
|
|
5317
|
+
const emptyAnchorMap = () => ({
|
|
5318
|
+
locations: {},
|
|
5319
|
+
abilities: {},
|
|
5320
|
+
artifacts: {}
|
|
5321
|
+
});
|
|
5322
|
+
/** Trim, de-duplicate and validate one explicit anchor list. */
|
|
5323
|
+
function normalizeAnchors(value, where) {
|
|
5324
|
+
if (!Array.isArray(value)) invalid$1(`${where} must be an array of strings`);
|
|
5325
|
+
const list = value;
|
|
5326
|
+
const result = [];
|
|
5327
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5328
|
+
for (const [index, raw] of list.entries()) {
|
|
5329
|
+
if (typeof raw !== "string") invalid$1(`${where}[${index}] must be a string`);
|
|
5330
|
+
const anchor = raw.trim();
|
|
5331
|
+
const length = Array.from(anchor).length;
|
|
5332
|
+
if (length < 2 || length > 120) invalid$1(`${where}[${index}] must contain 2..120 characters`);
|
|
5333
|
+
if (seen.has(anchor)) continue;
|
|
5334
|
+
seen.add(anchor);
|
|
5335
|
+
result.push(anchor);
|
|
5336
|
+
}
|
|
5337
|
+
if (result.length > 8) invalid$1(`${where} contains ${result.length} unique anchors; limit is 8`);
|
|
5338
|
+
return result;
|
|
5339
|
+
}
|
|
5340
|
+
/**
|
|
5341
|
+
* Parse the optional chapter-level anchor map. Keys must name an entity in the
|
|
5342
|
+
* matching `new_elements` group; an unknown key is a schema error, not a
|
|
5343
|
+
* silently ignored instruction.
|
|
5344
|
+
*/
|
|
5345
|
+
function normalizeNewElementAnchors(value, namesByGroup) {
|
|
5346
|
+
const result = emptyAnchorMap();
|
|
5347
|
+
if (value === void 0) return result;
|
|
5348
|
+
if (!isRecord$1(value)) invalid$1("new_element_anchors must be an object");
|
|
5349
|
+
const record = value;
|
|
5350
|
+
for (const key of Object.keys(record)) if (!NEW_ELEMENT_GROUPS.includes(key)) invalid$1(`new_element_anchors has unknown group "${key}"`);
|
|
5351
|
+
for (const group of NEW_ELEMENT_GROUPS) {
|
|
5352
|
+
const rawGroup = record[group];
|
|
5353
|
+
if (rawGroup === void 0) continue;
|
|
5354
|
+
if (!isRecord$1(rawGroup)) invalid$1(`new_element_anchors.${group} must be an object`);
|
|
5355
|
+
const groupRecord = rawGroup;
|
|
5356
|
+
for (const [name, rawAnchors] of Object.entries(groupRecord)) {
|
|
5357
|
+
if (!namesByGroup[group].includes(name)) invalid$1(`new_element_anchors.${group} names "${name}", but new_elements.${group} has no such entity`);
|
|
5358
|
+
const anchors = normalizeAnchors(rawAnchors, `new_element_anchors.${group}.${name}`);
|
|
5359
|
+
if (anchors.length > 0) result[group][name] = anchors;
|
|
5360
|
+
}
|
|
5361
|
+
}
|
|
5362
|
+
return result;
|
|
5363
|
+
}
|
|
5364
|
+
/** Read the declared new-element names from a summary without trusting its shape. */
|
|
5365
|
+
const newElementNames = (summary) => {
|
|
5366
|
+
const record = isRecord$1(summary) ? summary : {};
|
|
5367
|
+
const elements = isRecord$1(record.new_elements) ? record.new_elements : {};
|
|
5368
|
+
const names = (group) => Array.isArray(elements[group]) ? elements[group].filter((name) => typeof name === "string") : [];
|
|
5369
|
+
return {
|
|
5370
|
+
locations: names("locations"),
|
|
5371
|
+
abilities: names("abilities"),
|
|
5372
|
+
artifacts: names("artifacts")
|
|
5373
|
+
};
|
|
5374
|
+
};
|
|
5375
|
+
/** Normalize anchors for one summary; returns an empty map when omitted. */
|
|
5376
|
+
function anchorsFromSummary(summary) {
|
|
5377
|
+
if (!isRecord$1(summary)) return emptyAnchorMap();
|
|
5378
|
+
return normalizeNewElementAnchors(summary.new_element_anchors, newElementNames(summary));
|
|
5379
|
+
}
|
|
5380
|
+
function anchorsForGroup(anchors, group, name) {
|
|
5381
|
+
return anchors[group][name] ?? [];
|
|
5382
|
+
}
|
|
5383
|
+
/**
|
|
5384
|
+
* The single name/anchor matcher used by PG-05 and state-delta. Explicit
|
|
5385
|
+
* anchors win; only an entity with no anchors falls back to its readable name.
|
|
5386
|
+
*/
|
|
5387
|
+
function matchEntityText(text, name, anchors = []) {
|
|
5388
|
+
const attempted = anchors.length > 0 ? [...anchors] : [name];
|
|
5389
|
+
const matched = attempted.find((anchor) => anchor !== "" && text.includes(anchor)) ?? null;
|
|
5390
|
+
return {
|
|
5391
|
+
ok: matched !== null,
|
|
5392
|
+
matched,
|
|
5393
|
+
attempted,
|
|
5394
|
+
usedFallback: anchors.length === 0
|
|
5395
|
+
};
|
|
5396
|
+
}
|
|
5397
|
+
function entityMatchHint(name, anchors) {
|
|
5398
|
+
if (anchors.length === 0) return `provide summary.new_element_anchors for "${name}", or write the name verbatim in the manuscript`;
|
|
5399
|
+
return `none of the anchors for "${name}" appears verbatim (${anchors.join(" / ")}); add a matching anchor or use a scene name that is actually in the manuscript`;
|
|
5400
|
+
}
|
|
5401
|
+
/** Persist anchors on a world row without overwriting old anchors that are not repeated. */
|
|
5402
|
+
function mergeEntityAnchors(existing, incoming) {
|
|
5403
|
+
const old = Array.isArray(existing) ? existing.filter((anchor) => typeof anchor === "string") : [];
|
|
5404
|
+
const merged = [];
|
|
5405
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5406
|
+
for (const anchor of [...old, ...incoming]) {
|
|
5407
|
+
const value = anchor.trim();
|
|
5408
|
+
if (value === "" || seen.has(value)) continue;
|
|
5409
|
+
seen.add(value);
|
|
5410
|
+
merged.push(value);
|
|
5411
|
+
if (merged.length === 8) break;
|
|
5412
|
+
}
|
|
5413
|
+
return merged;
|
|
5414
|
+
}
|
|
5415
|
+
/** A shape-safe name reader for world rows; `[null]` must never crash a gate. */
|
|
5416
|
+
function worldEntityName(row) {
|
|
5417
|
+
if (!isRecord$1(row) || typeof row.name !== "string" || row.name.trim() === "") return null;
|
|
5418
|
+
return row.name.trim();
|
|
5419
|
+
}
|
|
5208
5420
|
// src/engine/state-delta.ts
|
|
5209
5421
|
const SECTIONS = [
|
|
5210
5422
|
"characters",
|
|
@@ -5336,6 +5548,12 @@ function applyStateDelta(input) {
|
|
|
5336
5548
|
"advanced",
|
|
5337
5549
|
"resolved"
|
|
5338
5550
|
]) if (!Array.isArray(summary.foreshadows[key])) fail$1(`summary.foreshadows.${key} must be an array`);
|
|
5551
|
+
let anchors;
|
|
5552
|
+
try {
|
|
5553
|
+
anchors = anchorsFromSummary(summary);
|
|
5554
|
+
} catch (error) {
|
|
5555
|
+
fail$1(error instanceof Error ? error.message : "summary.new_element_anchors is invalid");
|
|
5556
|
+
}
|
|
5339
5557
|
const characters = structuredClone(input.characters);
|
|
5340
5558
|
const world = structuredClone(input.world);
|
|
5341
5559
|
const locks = structuredClone(input.locks);
|
|
@@ -5472,12 +5690,23 @@ function applyStateDelta(input) {
|
|
|
5472
5690
|
if (summary.new_elements.artifacts.includes(name)) return "artifact";
|
|
5473
5691
|
return null;
|
|
5474
5692
|
};
|
|
5693
|
+
const anchorsForKind = (kind, name) => {
|
|
5694
|
+
if (kind === "location") return anchorsForGroup(anchors, "locations", name);
|
|
5695
|
+
if (kind === "ability") return anchorsForGroup(anchors, "abilities", name);
|
|
5696
|
+
if (kind === "artifact") return anchorsForGroup(anchors, "artifacts", name);
|
|
5697
|
+
return [];
|
|
5698
|
+
};
|
|
5699
|
+
const requireText = (name, kind, context) => {
|
|
5700
|
+
const candidates = anchorsForKind(kind, name);
|
|
5701
|
+
if (!matchEntityText(input.text, name, candidates).ok) fail$1(`${context} ${entityMatchHint(name, candidates)}`);
|
|
5702
|
+
return candidates;
|
|
5703
|
+
};
|
|
5475
5704
|
for (const [kind, names] of [
|
|
5476
5705
|
["location", summary.new_elements.locations],
|
|
5477
5706
|
["ability", summary.new_elements.abilities],
|
|
5478
5707
|
["artifact", summary.new_elements.artifacts]
|
|
5479
5708
|
]) for (const name of names) {
|
|
5480
|
-
|
|
5709
|
+
requireText(name, kind, `summary lists new ${kind} "${name}" but the manuscript never mentions it;`);
|
|
5481
5710
|
if (!Object.hasOwn(delta.world, name)) fail$1(`summary lists new ${kind} "${name}" but world.${name} is not declared`);
|
|
5482
5711
|
const declared = delta.world[name];
|
|
5483
5712
|
if (declared.kind !== void 0 && declared.kind !== kind) fail$1(`world.${name} is declared as ${declared.kind} but the summary lists it as ${kind}`);
|
|
@@ -5496,6 +5725,7 @@ function applyStateDelta(input) {
|
|
|
5496
5725
|
const expected = expectedKind(name);
|
|
5497
5726
|
if (expected !== null && declared.kind !== void 0 && declared.kind !== expected) fail$1(`world.${name} is declared as ${declared.kind} but the summary lists it as ${expected}`);
|
|
5498
5727
|
const existing = findWorld(name);
|
|
5728
|
+
let entityAnchors = anchorsForKind(declared.kind ?? expected, name);
|
|
5499
5729
|
let action = declared.action;
|
|
5500
5730
|
if (action === "created" && existing !== null) {
|
|
5501
5731
|
if (declared.kind !== void 0 && declared.kind !== existing.kind) fail$1(`world.${name} is declared as ${declared.kind} but the ledger has ${existing.kind}; kind cannot change`);
|
|
@@ -5503,12 +5733,13 @@ function applyStateDelta(input) {
|
|
|
5503
5733
|
action = "updated";
|
|
5504
5734
|
warnReclass(name, "created", "updated", "existing-created-as-updated");
|
|
5505
5735
|
} else if (action === "updated" && existing === null) {
|
|
5506
|
-
|
|
5507
|
-
if (
|
|
5736
|
+
const kind = declared.kind ?? expected;
|
|
5737
|
+
if (kind === null) fail$1(`world.${name} does not exist; declare kind (location|ability|artifact|faction|rule) or list it in summary.new_elements`);
|
|
5738
|
+
entityAnchors = requireText(name, kind, `world entity "${name}" is new but does not appear in the manuscript;`);
|
|
5508
5739
|
action = "created";
|
|
5509
5740
|
warnReclass(name, "updated", "created", "missing-updated-as-created");
|
|
5510
5741
|
} else if (action === "no_change" && existing === null && expected !== null) {
|
|
5511
|
-
|
|
5742
|
+
entityAnchors = requireText(name, expected, `world entity "${name}" is new but does not appear in the manuscript;`);
|
|
5512
5743
|
action = "created";
|
|
5513
5744
|
warnReclass(name, "no_change", "created", "missing-no-change-as-created");
|
|
5514
5745
|
}
|
|
@@ -5522,9 +5753,9 @@ function applyStateDelta(input) {
|
|
|
5522
5753
|
if (action === "created") {
|
|
5523
5754
|
if (name.trim() === "") fail$1("a created world entity has an empty name");
|
|
5524
5755
|
if (existing !== null) fail$1(`world entity "${name}" already exists; use updated or no_change`);
|
|
5525
|
-
if (!input.text.includes(name)) fail$1(`world entity "${name}" is created but does not appear in the manuscript`);
|
|
5526
5756
|
const kind = declared.kind ?? expected;
|
|
5527
5757
|
if (kind === null) fail$1(`world.${name} must declare kind (location|ability|artifact|faction|rule)`);
|
|
5758
|
+
entityAnchors = requireText(name, kind, `world entity "${name}" is created but does not appear in the manuscript;`);
|
|
5528
5759
|
if (![
|
|
5529
5760
|
"location",
|
|
5530
5761
|
"ability",
|
|
@@ -5540,13 +5771,15 @@ function applyStateDelta(input) {
|
|
|
5540
5771
|
description: declared.description ?? "",
|
|
5541
5772
|
layout: declared.layout ?? "",
|
|
5542
5773
|
capacity: declared.capacity ?? 0,
|
|
5774
|
+
...entityAnchors.length === 0 ? {} : { anchors: entityAnchors },
|
|
5543
5775
|
changes: []
|
|
5544
5776
|
});
|
|
5545
5777
|
else world[kind === "ability" ? "abilities" : kind === "artifact" ? "artifacts" : kind === "faction" ? "factions" : "rules"].push({
|
|
5546
5778
|
...declared.detail ?? {},
|
|
5547
5779
|
name,
|
|
5548
5780
|
first_appearance: chapter,
|
|
5549
|
-
description: declared.description ?? ""
|
|
5781
|
+
description: declared.description ?? "",
|
|
5782
|
+
...entityAnchors.length === 0 ? {} : { anchors: entityAnchors }
|
|
5550
5783
|
});
|
|
5551
5784
|
continue;
|
|
5552
5785
|
}
|
|
@@ -5558,6 +5791,7 @@ function applyStateDelta(input) {
|
|
|
5558
5791
|
if (declared.description !== void 0) location.description = declared.description;
|
|
5559
5792
|
if (declared.layout !== void 0) location.layout = declared.layout;
|
|
5560
5793
|
if (declared.capacity !== void 0) location.capacity = declared.capacity;
|
|
5794
|
+
if (entityAnchors.length > 0) location.anchors = mergeEntityAnchors(location.anchors, entityAnchors);
|
|
5561
5795
|
location.changes.push({
|
|
5562
5796
|
chapter,
|
|
5563
5797
|
change: declared.reason ?? declared.description ?? "updated",
|
|
@@ -5566,6 +5800,7 @@ function applyStateDelta(input) {
|
|
|
5566
5800
|
} else if (isRecord(existing.value)) {
|
|
5567
5801
|
if (declared.description !== void 0 && "description" in existing.value) existing.value.description = declared.description;
|
|
5568
5802
|
if (declared.detail !== void 0) Object.assign(existing.value, declared.detail);
|
|
5803
|
+
if (entityAnchors.length > 0) existing.value.anchors = mergeEntityAnchors(existing.value.anchors, entityAnchors);
|
|
5569
5804
|
}
|
|
5570
5805
|
}
|
|
5571
5806
|
}
|
|
@@ -5775,6 +6010,30 @@ const median = (values) => {
|
|
|
5775
6010
|
const middle = Math.floor(sorted.length / 2);
|
|
5776
6011
|
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle];
|
|
5777
6012
|
};
|
|
6013
|
+
/** Persisted anchors exposed by the report; top-level caps keep the JSON small. */
|
|
6014
|
+
const worldAnchors = (world) => {
|
|
6015
|
+
if (world === void 0) return [];
|
|
6016
|
+
const groups = [
|
|
6017
|
+
["location", world.locations],
|
|
6018
|
+
["ability", world.abilities],
|
|
6019
|
+
["artifact", world.artifacts],
|
|
6020
|
+
["faction", world.factions],
|
|
6021
|
+
["rule", world.rules]
|
|
6022
|
+
];
|
|
6023
|
+
const out = [];
|
|
6024
|
+
for (const [kind, entries] of groups) for (const entry of entries) {
|
|
6025
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
6026
|
+
const record = entry;
|
|
6027
|
+
if (typeof record.name !== "string" || !Array.isArray(record.anchors)) continue;
|
|
6028
|
+
const anchors = record.anchors.filter((anchor) => typeof anchor === "string").slice(0, 8);
|
|
6029
|
+
if (anchors.length > 0) out.push({
|
|
6030
|
+
name: record.name,
|
|
6031
|
+
kind,
|
|
6032
|
+
anchors
|
|
6033
|
+
});
|
|
6034
|
+
}
|
|
6035
|
+
return out.slice(0, 10);
|
|
6036
|
+
};
|
|
5778
6037
|
/** Build the whole-book report from one committed snapshot. No IO. */
|
|
5779
6038
|
function buildBookReport(input) {
|
|
5780
6039
|
const records = [...input.chapters].sort((left, right) => left.num - right.num);
|
|
@@ -5890,6 +6149,7 @@ function buildBookReport(input) {
|
|
|
5890
6149
|
};
|
|
5891
6150
|
});
|
|
5892
6151
|
if (records.some((record) => record.rounds === void 0 || record.archived_at === void 0)) warnings.push("部分旧章缺少归档 effort 元数据;报告已按事务日志回填");
|
|
6152
|
+
const persistedAnchors = worldAnchors(input.world);
|
|
5893
6153
|
return {
|
|
5894
6154
|
progress: {
|
|
5895
6155
|
archived: records.length,
|
|
@@ -5917,7 +6177,10 @@ function buildBookReport(input) {
|
|
|
5917
6177
|
volumes: volumeCounts,
|
|
5918
6178
|
drift
|
|
5919
6179
|
},
|
|
5920
|
-
worldbook:
|
|
6180
|
+
worldbook: {
|
|
6181
|
+
...worldbookCounts(input.worldbook),
|
|
6182
|
+
...persistedAnchors.length === 0 ? {} : { anchors: persistedAnchors }
|
|
6183
|
+
},
|
|
5921
6184
|
quality: {
|
|
5922
6185
|
average: Math.round(qualityAverage * 10) / 10,
|
|
5923
6186
|
median: Math.round(median(scores) * 10) / 10,
|
|
@@ -6319,6 +6582,173 @@ function searchSnapshot(input) {
|
|
|
6319
6582
|
results
|
|
6320
6583
|
};
|
|
6321
6584
|
}
|
|
6585
|
+
const oneLine = (value) => value.replace(/\s+/g, " ").trim();
|
|
6586
|
+
const ledgerWorldEntries = (world, kind) => {
|
|
6587
|
+
const rows = kind === "location" ? world.locations : kind === "faction" ? world.factions : world.rules;
|
|
6588
|
+
const entries = [];
|
|
6589
|
+
for (const row of rows) {
|
|
6590
|
+
if (typeof row !== "object" || row === null) continue;
|
|
6591
|
+
const record = row;
|
|
6592
|
+
if (typeof record.name !== "string" || record.name.trim() === "") continue;
|
|
6593
|
+
const anchors = Array.isArray(record.anchors) ? record.anchors.filter((anchor) => typeof anchor === "string") : [];
|
|
6594
|
+
entries.push({
|
|
6595
|
+
name: record.name,
|
|
6596
|
+
description: typeof record.description === "string" ? record.description : "",
|
|
6597
|
+
...anchors.length === 0 ? {} : { anchors }
|
|
6598
|
+
});
|
|
6599
|
+
}
|
|
6600
|
+
return entries;
|
|
6601
|
+
};
|
|
6602
|
+
const nameDescription = (entries, world, kind = "location") => {
|
|
6603
|
+
const merged = /* @__PURE__ */ new Map();
|
|
6604
|
+
for (const entry of [...entries, ...ledgerWorldEntries(world, kind)]) {
|
|
6605
|
+
const name = oneLine(entry.name);
|
|
6606
|
+
if (name === "") continue;
|
|
6607
|
+
const existing = merged.get(name);
|
|
6608
|
+
if (existing === void 0) {
|
|
6609
|
+
merged.set(name, {
|
|
6610
|
+
name,
|
|
6611
|
+
description: oneLine(entry.description),
|
|
6612
|
+
...entry.anchors === void 0 || entry.anchors.length === 0 ? {} : { anchors: [...entry.anchors] }
|
|
6613
|
+
});
|
|
6614
|
+
continue;
|
|
6615
|
+
}
|
|
6616
|
+
if (existing.anchors === void 0 && entry.anchors !== void 0 && entry.anchors.length > 0) existing.anchors = [...entry.anchors];
|
|
6617
|
+
}
|
|
6618
|
+
return [...merged.values()].slice(0, 5);
|
|
6619
|
+
};
|
|
6620
|
+
/**
|
|
6621
|
+
* Build the v6 panel book view from the canonical `BookReport`. This function
|
|
6622
|
+
* only truncates and renames; it never recomputes a quality/pacing aggregate.
|
|
6623
|
+
*/
|
|
6624
|
+
function buildPanelBookData(input) {
|
|
6625
|
+
const report = input.report;
|
|
6626
|
+
const remainingBasis = report.progress.target === null ? "none" : report.progress.archived === 0 ? "minimum_target" : "actual_average";
|
|
6627
|
+
const remaining = remainingBasis === "actual_average" ? report.progress.remaining_words_average : remainingBasis === "minimum_target" ? report.progress.remaining_words_min : null;
|
|
6628
|
+
const card = input.outline.chapters.find((candidate) => candidate.num === input.currentChapter);
|
|
6629
|
+
const currentCard = card === void 0 ? null : {
|
|
6630
|
+
num: card.num,
|
|
6631
|
+
title: card.title,
|
|
6632
|
+
scene_type: card.scene_type,
|
|
6633
|
+
goal: card.goal,
|
|
6634
|
+
conflict: card.conflict,
|
|
6635
|
+
hook: card.hook,
|
|
6636
|
+
cast: [...card.cast],
|
|
6637
|
+
target_words: [card.target_words[0], card.target_words[1]],
|
|
6638
|
+
status: card.status
|
|
6639
|
+
};
|
|
6640
|
+
const quality = report.quality;
|
|
6641
|
+
const counts = {
|
|
6642
|
+
present: report.worldbook.present,
|
|
6643
|
+
characters: report.worldbook.characters,
|
|
6644
|
+
locations: report.worldbook.locations,
|
|
6645
|
+
factions: report.worldbook.factions,
|
|
6646
|
+
rules: report.worldbook.rules,
|
|
6647
|
+
glossary: report.worldbook.glossary,
|
|
6648
|
+
backstory: report.worldbook.backstory,
|
|
6649
|
+
...report.worldbook.facts === void 0 ? {} : { facts: report.worldbook.facts }
|
|
6650
|
+
};
|
|
6651
|
+
return {
|
|
6652
|
+
identity: {
|
|
6653
|
+
author: input.project.author,
|
|
6654
|
+
description: input.project.description,
|
|
6655
|
+
created_at: input.project.created_at,
|
|
6656
|
+
targets: {
|
|
6657
|
+
words_per_chapter: [input.project.targets.words_per_chapter[0], input.project.targets.words_per_chapter[1]],
|
|
6658
|
+
total_chapters: input.project.targets.total_chapters,
|
|
6659
|
+
dialogue_ratio: [input.project.targets.dialogue_ratio[0], input.project.targets.dialogue_ratio[1]]
|
|
6660
|
+
}
|
|
6661
|
+
},
|
|
6662
|
+
blueprint: {
|
|
6663
|
+
premise: input.outline.book.premise,
|
|
6664
|
+
logline: input.outline.book.logline,
|
|
6665
|
+
themes: [...input.outline.book.themes],
|
|
6666
|
+
tone: input.outline.book.tone,
|
|
6667
|
+
pov: input.outline.book.pov,
|
|
6668
|
+
audience: input.outline.book.audience,
|
|
6669
|
+
selling_points: [...input.outline.book.selling_points],
|
|
6670
|
+
ending: input.outline.book.ending
|
|
6671
|
+
},
|
|
6672
|
+
progress: {
|
|
6673
|
+
archived: report.progress.archived,
|
|
6674
|
+
target: report.progress.target,
|
|
6675
|
+
words_archived: report.progress.words,
|
|
6676
|
+
words_with_draft: report.progress.words + input.draftWords,
|
|
6677
|
+
average_words: report.progress.average_words,
|
|
6678
|
+
remaining_words: remaining,
|
|
6679
|
+
remaining_words_basis: remainingBasis,
|
|
6680
|
+
percent: report.progress.percent
|
|
6681
|
+
},
|
|
6682
|
+
current: {
|
|
6683
|
+
card: currentCard,
|
|
6684
|
+
chapter: input.currentChapter,
|
|
6685
|
+
stage: input.stage,
|
|
6686
|
+
rounds: { ...input.rounds },
|
|
6687
|
+
manuscript_hash: input.manuscriptHash,
|
|
6688
|
+
metrics: input.metrics
|
|
6689
|
+
},
|
|
6690
|
+
worldbook: {
|
|
6691
|
+
counts,
|
|
6692
|
+
locations: nameDescription(input.worldbook.locations, input.world),
|
|
6693
|
+
factions: nameDescription(input.worldbook.factions, input.world, "faction"),
|
|
6694
|
+
rules: nameDescription(input.worldbook.rules, input.world, "rule"),
|
|
6695
|
+
facts: (input.worldbook.facts ?? []).slice(0, 5).map((fact) => ({
|
|
6696
|
+
alias: fact.alias,
|
|
6697
|
+
value: fact.value,
|
|
6698
|
+
unit: fact.unit
|
|
6699
|
+
}))
|
|
6700
|
+
},
|
|
6701
|
+
cast: {
|
|
6702
|
+
total: report.cast.total,
|
|
6703
|
+
top: report.cast.top.slice(0, 5).map((entry) => ({ ...entry }))
|
|
6704
|
+
},
|
|
6705
|
+
relationships: {
|
|
6706
|
+
total: report.relationships.total,
|
|
6707
|
+
types: { ...report.relationships.types }
|
|
6708
|
+
},
|
|
6709
|
+
foreshadows: {
|
|
6710
|
+
...report.foreshadows,
|
|
6711
|
+
oldest: report.foreshadows.oldest.slice(0, 5).map((entry) => ({ ...entry }))
|
|
6712
|
+
},
|
|
6713
|
+
timeline: { ...report.timeline },
|
|
6714
|
+
outline: { drift: report.outline.drift.slice(0, 10).map((entry) => ({ ...entry })) },
|
|
6715
|
+
quality: {
|
|
6716
|
+
average: quality.average,
|
|
6717
|
+
median: quality.median,
|
|
6718
|
+
min: quality.min,
|
|
6719
|
+
max: quality.max,
|
|
6720
|
+
last_ten_average: input.lastTenAverage,
|
|
6721
|
+
trend: quality.trend,
|
|
6722
|
+
trend_basis: quality.trend_basis,
|
|
6723
|
+
samples: quality.samples,
|
|
6724
|
+
attempts: quality.attempts,
|
|
6725
|
+
dimensions: quality.dimensions.slice(0, 15).map((entry) => ({ ...entry }))
|
|
6726
|
+
},
|
|
6727
|
+
pacing: {
|
|
6728
|
+
scene_types: { ...report.pacing.scene_types },
|
|
6729
|
+
hook_strength: { ...report.pacing.hook_strength },
|
|
6730
|
+
plot_advancement: { ...report.pacing.plot_advancement },
|
|
6731
|
+
longest: report.pacing.longest === null ? null : { ...report.pacing.longest },
|
|
6732
|
+
shortest: report.pacing.shortest === null ? null : { ...report.pacing.shortest }
|
|
6733
|
+
},
|
|
6734
|
+
effort: {
|
|
6735
|
+
...report.effort,
|
|
6736
|
+
chapters: report.effort.chapters.slice(-30).map((entry) => ({ ...entry }))
|
|
6737
|
+
},
|
|
6738
|
+
health: {
|
|
6739
|
+
state_errors: input.stateErrors.slice(0, 10).map((entry) => ({ ...entry })),
|
|
6740
|
+
outline: {
|
|
6741
|
+
present: input.outlinePresent,
|
|
6742
|
+
error: input.outlineError
|
|
6743
|
+
},
|
|
6744
|
+
worldbook: {
|
|
6745
|
+
present: input.worldbookPresent,
|
|
6746
|
+
error: input.worldbookError
|
|
6747
|
+
},
|
|
6748
|
+
pending: input.pending === null ? null : { ...input.pending }
|
|
6749
|
+
}
|
|
6750
|
+
};
|
|
6751
|
+
}
|
|
6322
6752
|
// src/engine/history.ts
|
|
6323
6753
|
const PATTERN = /^ch(?<chapter>\d{3,})\.r(?<revision>\d+)\.(?<suffix>review\.yaml|check\.yaml|chapter\.yaml|md)$/;
|
|
6324
6754
|
const kindOf = (directory, suffix) => {
|
|
@@ -7100,6 +7530,14 @@ const SEMANTIC_CHECK_IDS = [
|
|
|
7100
7530
|
"L2-VOICE",
|
|
7101
7531
|
"L2-POV"
|
|
7102
7532
|
];
|
|
7533
|
+
/** A longer evidence interval must share at least this share of the shorter quote. */
|
|
7534
|
+
const SEMANTIC_OVERLAP_RATIO = .5;
|
|
7535
|
+
const conflictSummary = (itemId, conflict) => {
|
|
7536
|
+
const pair = `${itemId}↔${conflict.other_id}`;
|
|
7537
|
+
if (conflict.kind === "same_sentence") return `${pair} same sentence${conflict.sentence === void 0 ? "" : ` at paragraph ${conflict.sentence.paragraph} sentence ${conflict.sentence.id}`}`;
|
|
7538
|
+
if (conflict.kind === "duplicate") return `${pair} exact duplicate`;
|
|
7539
|
+
return `${pair} overlaps ${conflict.overlap_chars ?? 0} chars${conflict.overlap_ratio === void 0 ? "" : ` (${Math.round(conflict.overlap_ratio * 100)}%)`}${conflict.paragraphs === void 0 || conflict.paragraphs.length === 0 ? "" : ` at paragraph ${conflict.paragraphs.join("/")}`}`;
|
|
7540
|
+
};
|
|
7103
7541
|
/**
|
|
7104
7542
|
* A caller supplied evidence that cannot be evaluated as a content judgement.
|
|
7105
7543
|
* It must never be persisted as a failed check or consume a content round.
|
|
@@ -7108,16 +7546,26 @@ var CheckSubmissionError = class extends EngineInputError {
|
|
|
7108
7546
|
items;
|
|
7109
7547
|
passed;
|
|
7110
7548
|
failed;
|
|
7549
|
+
conflicts;
|
|
7550
|
+
blocking;
|
|
7111
7551
|
rounds_consumed = 0;
|
|
7112
7552
|
failure_class = "submission";
|
|
7113
|
-
constructor(items) {
|
|
7553
|
+
constructor(items, blocking) {
|
|
7114
7554
|
const passed = items.filter((item) => item.state === "pass").length;
|
|
7115
7555
|
const failed = items.filter((item) => item.state !== "pass" && item.state !== "fail").length;
|
|
7116
7556
|
const first = items.find((item) => item.state !== "pass" && item.state !== "fail");
|
|
7117
|
-
|
|
7557
|
+
const conflicts = items.flatMap((item) => item.conflicts ?? []);
|
|
7558
|
+
const summaries = items.flatMap((item) => (item.conflicts ?? []).map((conflict) => conflictSummary(item.id, conflict))).slice(0, 5);
|
|
7559
|
+
super(ERROR_CODES.CHECK_SUBMISSION_INVALID, `semantic submission is invalid: ${first?.id ?? "semantic"} ${first?.state ?? "invalid"}${first?.detail === void 0 ? "" : ` (${first.detail})`}${summaries.length === 0 ? "" : `; conflicts: ${summaries.join("; ")}`}; ${passed} passed, ${failed} failed; no check was written and no round was consumed`, "resubmit novel_check with all five semantic items; use distinct quotes from different sentences and paragraphs, and never reuse a sentence for two dimensions");
|
|
7118
7560
|
this.items = items;
|
|
7119
7561
|
this.passed = passed;
|
|
7120
7562
|
this.failed = failed;
|
|
7563
|
+
this.conflicts = conflicts;
|
|
7564
|
+
this.blocking = blocking ?? items.filter((item) => submissionState(item.state)).map((item) => ({
|
|
7565
|
+
code: codeForState(item.state),
|
|
7566
|
+
scope: "check",
|
|
7567
|
+
detail: detailFor(item)
|
|
7568
|
+
}));
|
|
7121
7569
|
}
|
|
7122
7570
|
};
|
|
7123
7571
|
/** Extract the quoted part of an evidence string, tolerating "第 N 段:" labels. */
|
|
@@ -7129,75 +7577,18 @@ function evidenceQuote(value) {
|
|
|
7129
7577
|
candidate = candidate.replace(/^[「『“"']/, "").replace(/[」』”"']$/, "").trim();
|
|
7130
7578
|
return candidate;
|
|
7131
7579
|
}
|
|
7132
|
-
const CLOSING_PUNCTUATION = /* @__PURE__ */ new Set([
|
|
7133
|
-
"」",
|
|
7134
|
-
"』",
|
|
7135
|
-
"”",
|
|
7136
|
-
"\"",
|
|
7137
|
-
"’",
|
|
7138
|
-
"'",
|
|
7139
|
-
")",
|
|
7140
|
-
")",
|
|
7141
|
-
"】",
|
|
7142
|
-
"]"
|
|
7143
|
-
]);
|
|
7144
|
-
/**
|
|
7145
|
-
* Split text into sentence spans while treating every non-empty line as a
|
|
7146
|
-
* paragraph boundary. A quote may cover multiple sentences or paragraphs; the
|
|
7147
|
-
* spans are only used to prove that no two evidence items reuse the same
|
|
7148
|
-
* passage. Trailing closing quotes belong to the sentence they terminate.
|
|
7149
|
-
*/
|
|
7150
|
-
function sentenceSpans(text) {
|
|
7151
|
-
const spans = [];
|
|
7152
|
-
let paragraph = 1;
|
|
7153
|
-
let lineStart = 0;
|
|
7154
|
-
let sentenceStart = 0;
|
|
7155
|
-
for (let index = 0; index < text.length; index += 1) {
|
|
7156
|
-
const char = text[index];
|
|
7157
|
-
if (char === "\n") {
|
|
7158
|
-
if (text.slice(lineStart, index).trim() !== "") {
|
|
7159
|
-
if (index > sentenceStart) spans.push({
|
|
7160
|
-
start: sentenceStart,
|
|
7161
|
-
end: index,
|
|
7162
|
-
paragraph
|
|
7163
|
-
});
|
|
7164
|
-
paragraph += 1;
|
|
7165
|
-
}
|
|
7166
|
-
lineStart = index + 1;
|
|
7167
|
-
sentenceStart = index + 1;
|
|
7168
|
-
continue;
|
|
7169
|
-
}
|
|
7170
|
-
if ("。!?!?".includes(char)) {
|
|
7171
|
-
let end = index + 1;
|
|
7172
|
-
while (end < text.length && CLOSING_PUNCTUATION.has(text[end])) end += 1;
|
|
7173
|
-
spans.push({
|
|
7174
|
-
start: sentenceStart,
|
|
7175
|
-
end,
|
|
7176
|
-
paragraph
|
|
7177
|
-
});
|
|
7178
|
-
sentenceStart = end;
|
|
7179
|
-
index = end - 1;
|
|
7180
|
-
}
|
|
7181
|
-
}
|
|
7182
|
-
if (sentenceStart < text.length) spans.push({
|
|
7183
|
-
start: sentenceStart,
|
|
7184
|
-
end: text.length,
|
|
7185
|
-
paragraph
|
|
7186
|
-
});
|
|
7187
|
-
return spans;
|
|
7188
|
-
}
|
|
7189
7580
|
function nearestParagraph(text, quote) {
|
|
7190
7581
|
const paragraphs = splitParagraphs(text);
|
|
7191
7582
|
let best = null;
|
|
7192
7583
|
for (const [index, paragraph] of paragraphs.entries()) {
|
|
7193
|
-
const score =
|
|
7584
|
+
const score = bigramSimilarity(paragraph, quote);
|
|
7194
7585
|
if (best === null || score > best.score) best = {
|
|
7195
7586
|
index,
|
|
7196
7587
|
paragraph,
|
|
7197
7588
|
score
|
|
7198
7589
|
};
|
|
7199
7590
|
}
|
|
7200
|
-
if (best === null) return null;
|
|
7591
|
+
if (best === null || best.score < .5) return null;
|
|
7201
7592
|
return {
|
|
7202
7593
|
paragraph: best.index + 1,
|
|
7203
7594
|
quote: Array.from(best.paragraph).slice(0, 120).join(""),
|
|
@@ -7230,18 +7621,41 @@ const detailFor = (result) => {
|
|
|
7230
7621
|
default: return `${result.id} is invalid`;
|
|
7231
7622
|
}
|
|
7232
7623
|
};
|
|
7624
|
+
const sentenceIdsFor = (sentences, start, end) => sentences.map((sentence, index) => ({
|
|
7625
|
+
sentence,
|
|
7626
|
+
index
|
|
7627
|
+
})).filter(({ sentence }) => start < sentence.end && sentence.start < end).map(({ index }) => index);
|
|
7628
|
+
const paragraphsFor = (sentences, start, end) => {
|
|
7629
|
+
const values = /* @__PURE__ */ new Set();
|
|
7630
|
+
for (const sentence of sentences) if (start < sentence.end && sentence.start < end) values.add(sentence.paragraph);
|
|
7631
|
+
return [...values].sort((left, right) => left - right);
|
|
7632
|
+
};
|
|
7633
|
+
const intervalOverlap = (start, end, other) => {
|
|
7634
|
+
const overlapStart = Math.max(start, other.start);
|
|
7635
|
+
const overlapEnd = Math.min(end, other.end);
|
|
7636
|
+
const overlapChars = Math.max(0, overlapEnd - overlapStart);
|
|
7637
|
+
const minLength = Math.min(end - start, other.end - other.start);
|
|
7638
|
+
return {
|
|
7639
|
+
start: overlapStart,
|
|
7640
|
+
end: overlapEnd,
|
|
7641
|
+
chars: overlapChars,
|
|
7642
|
+
ratio: minLength === 0 ? 0 : overlapChars / minLength,
|
|
7643
|
+
contains: start <= other.start && end >= other.end || other.start <= start && other.end >= end
|
|
7644
|
+
};
|
|
7645
|
+
};
|
|
7233
7646
|
/**
|
|
7234
|
-
*
|
|
7647
|
+
* Evaluate all five L2 semantic items.
|
|
7235
7648
|
*
|
|
7236
|
-
*
|
|
7237
|
-
*
|
|
7238
|
-
*
|
|
7239
|
-
*
|
|
7649
|
+
* Sentence boundaries come from the domain owner. A failure never consumes an
|
|
7650
|
+
* interval or a sentence, so one bad quote cannot cascade into the next item.
|
|
7651
|
+
* Exact duplicate, same-sentence reuse and meaningful interval overlap are
|
|
7652
|
+
* distinct, pair-level conflicts; merely sharing a few words in different
|
|
7653
|
+
* sentences is not a conflict.
|
|
7240
7654
|
*/
|
|
7241
7655
|
function evaluateSemantic(text, input) {
|
|
7242
7656
|
const items = [];
|
|
7243
|
-
const
|
|
7244
|
-
const sentences =
|
|
7657
|
+
const accepted = [];
|
|
7658
|
+
const sentences = splitSentenceSpans(text);
|
|
7245
7659
|
const usedSentences = /* @__PURE__ */ new Map();
|
|
7246
7660
|
const seenIds = /* @__PURE__ */ new Set();
|
|
7247
7661
|
const acceptedQuotes = /* @__PURE__ */ new Map();
|
|
@@ -7306,13 +7720,26 @@ function evaluateSemantic(text, input) {
|
|
|
7306
7720
|
});
|
|
7307
7721
|
continue;
|
|
7308
7722
|
}
|
|
7723
|
+
if (!SEMANTIC_CHECK_IDS.includes(id)) {
|
|
7724
|
+
items.push({
|
|
7725
|
+
id,
|
|
7726
|
+
state: "invalid",
|
|
7727
|
+
detail: `${id} is not one of the required P6 semantic checks`
|
|
7728
|
+
});
|
|
7729
|
+
continue;
|
|
7730
|
+
}
|
|
7309
7731
|
const duplicate = acceptedQuotes.get(quote);
|
|
7310
7732
|
if (duplicate !== void 0) {
|
|
7733
|
+
const conflict = {
|
|
7734
|
+
kind: "duplicate",
|
|
7735
|
+
other_id: duplicate
|
|
7736
|
+
};
|
|
7311
7737
|
items.push({
|
|
7312
7738
|
id,
|
|
7313
7739
|
state: "duplicate",
|
|
7314
7740
|
quote,
|
|
7315
7741
|
conflict_with: duplicate,
|
|
7742
|
+
conflicts: [conflict],
|
|
7316
7743
|
detail: `${id} repeats ${duplicate}'s quote exactly`
|
|
7317
7744
|
});
|
|
7318
7745
|
continue;
|
|
@@ -7334,63 +7761,79 @@ function evaluateSemantic(text, input) {
|
|
|
7334
7761
|
});
|
|
7335
7762
|
continue;
|
|
7336
7763
|
}
|
|
7337
|
-
let
|
|
7338
|
-
|
|
7339
|
-
let sameSentenceWith;
|
|
7340
|
-
let boundary = false;
|
|
7764
|
+
let acceptedSpan = null;
|
|
7765
|
+
const conflicts = [];
|
|
7341
7766
|
for (const start of occurrences) {
|
|
7342
7767
|
const end = start + quote.length;
|
|
7343
|
-
const
|
|
7344
|
-
if (
|
|
7345
|
-
|
|
7346
|
-
|
|
7768
|
+
const sentenceIds = sentenceIdsFor(sentences, start, end);
|
|
7769
|
+
if (sentenceIds.length === 0) continue;
|
|
7770
|
+
let rejected = false;
|
|
7771
|
+
for (const other of accepted) {
|
|
7772
|
+
const overlap = intervalOverlap(start, end, other);
|
|
7773
|
+
const minLength = Math.min(quote.length, other.quote.length);
|
|
7774
|
+
const threshold = Math.max(8, Math.ceil(SEMANTIC_OVERLAP_RATIO * minLength));
|
|
7775
|
+
if (overlap.chars > 0 && (overlap.contains || overlap.chars >= threshold)) {
|
|
7776
|
+
conflicts.push({
|
|
7777
|
+
kind: "interval_overlap",
|
|
7778
|
+
other_id: other.id,
|
|
7779
|
+
overlap_text: Array.from(text.slice(overlap.start, overlap.end)).slice(0, 120).join(""),
|
|
7780
|
+
overlap_chars: overlap.chars,
|
|
7781
|
+
overlap_ratio: Math.round(overlap.ratio * 1e3) / 1e3,
|
|
7782
|
+
paragraphs: paragraphsFor(sentences, overlap.start, overlap.end)
|
|
7783
|
+
});
|
|
7784
|
+
rejected = true;
|
|
7785
|
+
}
|
|
7347
7786
|
}
|
|
7348
|
-
|
|
7349
|
-
|
|
7350
|
-
|
|
7351
|
-
|
|
7352
|
-
|
|
7353
|
-
|
|
7354
|
-
|
|
7787
|
+
if (!rejected) for (const sentenceId of sentenceIds) {
|
|
7788
|
+
const owner = usedSentences.get(sentenceId);
|
|
7789
|
+
if (owner !== void 0) {
|
|
7790
|
+
const sentence = sentences[sentenceId];
|
|
7791
|
+
conflicts.push({
|
|
7792
|
+
kind: "same_sentence",
|
|
7793
|
+
other_id: owner,
|
|
7794
|
+
sentence: {
|
|
7795
|
+
id: sentenceId + 1,
|
|
7796
|
+
text: sentence.text,
|
|
7797
|
+
paragraph: sentence.paragraph
|
|
7798
|
+
}
|
|
7799
|
+
});
|
|
7800
|
+
rejected = true;
|
|
7801
|
+
}
|
|
7355
7802
|
}
|
|
7356
|
-
|
|
7357
|
-
|
|
7358
|
-
|
|
7359
|
-
|
|
7803
|
+
if (!rejected) {
|
|
7804
|
+
acceptedSpan = {
|
|
7805
|
+
start,
|
|
7806
|
+
end,
|
|
7807
|
+
id,
|
|
7808
|
+
quote,
|
|
7809
|
+
sentenceIds
|
|
7810
|
+
};
|
|
7811
|
+
break;
|
|
7360
7812
|
}
|
|
7361
|
-
accepted = {
|
|
7362
|
-
start,
|
|
7363
|
-
end,
|
|
7364
|
-
id
|
|
7365
|
-
};
|
|
7366
|
-
for (const { sentenceIndex } of covered) usedSentences.set(sentenceIndex, id);
|
|
7367
|
-
break;
|
|
7368
7813
|
}
|
|
7369
|
-
if (
|
|
7370
|
-
|
|
7371
|
-
|
|
7372
|
-
|
|
7373
|
-
|
|
7374
|
-
|
|
7375
|
-
|
|
7376
|
-
|
|
7377
|
-
|
|
7378
|
-
|
|
7379
|
-
|
|
7380
|
-
|
|
7381
|
-
conflict_with: sameSentenceWith,
|
|
7382
|
-
detail: `${id} reuses a sentence already cited by ${sameSentenceWith}`
|
|
7383
|
-
});
|
|
7384
|
-
else items.push({
|
|
7814
|
+
if (acceptedSpan === null) {
|
|
7815
|
+
const unique = /* @__PURE__ */ new Map();
|
|
7816
|
+
for (const conflict of conflicts) {
|
|
7817
|
+
const key = `${conflict.kind}:${conflict.other_id}:${conflict.overlap_chars ?? ""}:${conflict.sentence?.id ?? ""}`;
|
|
7818
|
+
unique.set(key, conflict);
|
|
7819
|
+
}
|
|
7820
|
+
const all = [...unique.values()];
|
|
7821
|
+
const overlap = all.find((conflict) => conflict.kind === "interval_overlap");
|
|
7822
|
+
const same = all.find((conflict) => conflict.kind === "same_sentence");
|
|
7823
|
+
const chosen = overlap ?? same;
|
|
7824
|
+
const state = overlap !== void 0 ? "overlap" : same !== void 0 ? "same_sentence" : "boundary";
|
|
7825
|
+
items.push({
|
|
7385
7826
|
id,
|
|
7386
|
-
state
|
|
7827
|
+
state,
|
|
7387
7828
|
quote,
|
|
7388
|
-
...
|
|
7389
|
-
|
|
7829
|
+
...chosen === void 0 ? {} : { conflict_with: chosen.other_id },
|
|
7830
|
+
...all.length === 0 ? {} : { conflicts: all },
|
|
7831
|
+
detail: chosen === void 0 ? `${id} cannot be mapped onto a sentence boundary` : `${id} ${chosen.kind} with ${chosen.other_id}`
|
|
7390
7832
|
});
|
|
7391
7833
|
continue;
|
|
7392
7834
|
}
|
|
7393
|
-
|
|
7835
|
+
accepted.push(acceptedSpan);
|
|
7836
|
+
for (const sentenceId of acceptedSpan.sentenceIds) usedSentences.set(sentenceId, id);
|
|
7394
7837
|
acceptedQuotes.set(quote, id);
|
|
7395
7838
|
items.push({
|
|
7396
7839
|
id,
|
|
@@ -7404,24 +7847,29 @@ function evaluateSemantic(text, input) {
|
|
|
7404
7847
|
state: "missing",
|
|
7405
7848
|
detail: `${id} is required by the P6 task book`
|
|
7406
7849
|
});
|
|
7407
|
-
|
|
7408
|
-
id,
|
|
7409
|
-
state: "invalid",
|
|
7410
|
-
detail: `${id} is not one of the required P6 semantic checks`
|
|
7411
|
-
});
|
|
7850
|
+
const conflicts = items.flatMap((item) => item.conflicts ?? []);
|
|
7412
7851
|
const findings = items.filter((item) => item.state === "fail").map((item) => ({
|
|
7413
7852
|
code: "SEMANTIC_CHECK_FAILED",
|
|
7414
7853
|
scope: "check",
|
|
7415
7854
|
detail: item.detail ?? `${item.id}: semantic check failed`
|
|
7416
7855
|
}));
|
|
7417
7856
|
const submissionItems = items.filter((item) => submissionState(item.state));
|
|
7418
|
-
const blocking = [
|
|
7419
|
-
|
|
7420
|
-
|
|
7421
|
-
|
|
7422
|
-
|
|
7857
|
+
const blocking = [
|
|
7858
|
+
...submissionItems.map((item) => ({
|
|
7859
|
+
code: codeForState(item.state),
|
|
7860
|
+
scope: "check",
|
|
7861
|
+
detail: detailFor(item)
|
|
7862
|
+
})),
|
|
7863
|
+
...submissionItems.flatMap((item) => (item.conflicts ?? []).slice(0, 5).map((conflict) => ({
|
|
7864
|
+
code: codeForState(item.state),
|
|
7865
|
+
scope: "check",
|
|
7866
|
+
detail: `${item.id}: ${conflictSummary(item.id, conflict)}`
|
|
7867
|
+
}))),
|
|
7868
|
+
...findings
|
|
7869
|
+
];
|
|
7423
7870
|
return {
|
|
7424
7871
|
items,
|
|
7872
|
+
conflicts,
|
|
7425
7873
|
findings,
|
|
7426
7874
|
blocking,
|
|
7427
7875
|
submission_failed: submissionItems.length > 0,
|
|
@@ -7432,7 +7880,7 @@ function evaluateSemantic(text, input) {
|
|
|
7432
7880
|
/** Throw a typed submission error when shape/evidence errors exist. */
|
|
7433
7881
|
function validateSemanticSubmission(text, input) {
|
|
7434
7882
|
const evaluation = evaluateSemantic(text, input);
|
|
7435
|
-
if (evaluation.submission_failed) throw new CheckSubmissionError(evaluation.items);
|
|
7883
|
+
if (evaluation.submission_failed) throw new CheckSubmissionError(evaluation.items, evaluation.blocking);
|
|
7436
7884
|
return evaluation;
|
|
7437
7885
|
}
|
|
7438
7886
|
// src/engine/gates.ts
|
|
@@ -7537,23 +7985,32 @@ const summarySchema = (input) => {
|
|
|
7537
7985
|
}
|
|
7538
7986
|
const newElements = summary.new_elements;
|
|
7539
7987
|
if (typeof newElements !== "object" || newElements === null) problems.push("new_elements must be an object");
|
|
7540
|
-
else
|
|
7541
|
-
|
|
7542
|
-
|
|
7543
|
-
|
|
7544
|
-
|
|
7545
|
-
|
|
7546
|
-
|
|
7547
|
-
|
|
7548
|
-
|
|
7549
|
-
|
|
7550
|
-
|
|
7551
|
-
|
|
7552
|
-
|
|
7553
|
-
|
|
7554
|
-
|
|
7555
|
-
|
|
7556
|
-
|
|
7988
|
+
else {
|
|
7989
|
+
let anchorMap = null;
|
|
7990
|
+
try {
|
|
7991
|
+
anchorMap = anchorsFromSummary(summary);
|
|
7992
|
+
} catch (error) {
|
|
7993
|
+
problems.push(error instanceof Error ? error.message : "new_element_anchors is invalid");
|
|
7994
|
+
}
|
|
7995
|
+
for (const key of [
|
|
7996
|
+
"locations",
|
|
7997
|
+
"abilities",
|
|
7998
|
+
"artifacts"
|
|
7999
|
+
]) {
|
|
8000
|
+
if (!textArray(newElements[key])) problems.push(`new_elements.${key} must be a string array`);
|
|
8001
|
+
for (const name of textArray(newElements[key]) ? newElements[key] : []) {
|
|
8002
|
+
const anchors = anchorMap === null ? [] : anchorsForGroup(anchorMap, key, name);
|
|
8003
|
+
if (!matchEntityText(input.text, name, anchors).ok) problems.push(`new_elements.${key} lists "${name}", but ${entityMatchHint(name, anchors)}`);
|
|
8004
|
+
if (input.operation !== "archiveChapter") {
|
|
8005
|
+
const ledger = input.evidence.world;
|
|
8006
|
+
if (ledger !== null && [
|
|
8007
|
+
ledger.locations,
|
|
8008
|
+
ledger.abilities,
|
|
8009
|
+
ledger.artifacts,
|
|
8010
|
+
ledger.factions,
|
|
8011
|
+
ledger.rules
|
|
8012
|
+
].some((rows) => rows.some((row) => worldEntityName(row) === name))) problems.push(`new_elements.${key} lists "${name}", but state/world.yaml already has it; remove it or use updated/no_change`);
|
|
8013
|
+
}
|
|
7557
8014
|
}
|
|
7558
8015
|
}
|
|
7559
8016
|
}
|
|
@@ -7676,12 +8133,18 @@ const issuesCleared = (input) => {
|
|
|
7676
8133
|
}
|
|
7677
8134
|
return problems.length === 0 ? [] : [block("PG-08", problems.join("; "), "mark every blocker fixed and quote the revised text as evidence")];
|
|
7678
8135
|
};
|
|
7679
|
-
/** Paragraph index (1-based)
|
|
8136
|
+
/** Paragraph index (1-based) nearest the reference when the quote repeats, or -1. */
|
|
7680
8137
|
function actualParagraph(text, reference) {
|
|
7681
8138
|
if (typeof reference !== "object" || reference === null || typeof reference.quote !== "string") return -1;
|
|
7682
8139
|
const quote = reference.quote.trim();
|
|
7683
8140
|
if (Array.from(quote).length < LIMITS.minQuoteChars) return -1;
|
|
7684
|
-
|
|
8141
|
+
const matches = splitParagraphs(text).map((paragraph, index) => ({
|
|
8142
|
+
paragraph,
|
|
8143
|
+
index
|
|
8144
|
+
})).filter(({ paragraph }) => paragraph.includes(quote));
|
|
8145
|
+
if (matches.length === 0) return -1;
|
|
8146
|
+
const target = Number.isInteger(reference.paragraph) && reference.paragraph > 0 ? reference.paragraph : 1;
|
|
8147
|
+
return matches.reduce((best, match) => Math.abs(match.index + 1 - target) < Math.abs(best.index + 1 - target) ? match : best).index + 1;
|
|
7685
8148
|
}
|
|
7686
8149
|
/** Verify one reading-proof reference against the manuscript. */
|
|
7687
8150
|
function verifyReference(text, reference) {
|
|
@@ -7690,9 +8153,13 @@ function verifyReference(text, reference) {
|
|
|
7690
8153
|
if (!Number.isInteger(reference.paragraph) || reference.paragraph < 1) return "reading proof paragraph must be a positive integer";
|
|
7691
8154
|
const quote = reference.quote.trim();
|
|
7692
8155
|
if (Array.from(quote).length < LIMITS.minQuoteChars) return `quote "${reference.quote}" is shorter than ${LIMITS.minQuoteChars} characters`;
|
|
7693
|
-
const
|
|
7694
|
-
|
|
7695
|
-
|
|
8156
|
+
const matches = splitParagraphs(text).map((paragraph, index) => ({
|
|
8157
|
+
paragraph,
|
|
8158
|
+
index
|
|
8159
|
+
})).filter(({ paragraph }) => paragraph.includes(quote));
|
|
8160
|
+
if (matches.length === 0) return `quote "${quote}" does not appear in the text`;
|
|
8161
|
+
const nearest = matches.reduce((best, match) => Math.abs(match.index + 1 - reference.paragraph) < Math.abs(best.index + 1 - reference.paragraph) ? match : best);
|
|
8162
|
+
if (Math.abs(nearest.index + 1 - reference.paragraph) > 1) return `quote "${quote}" is at paragraph ${nearest.index + 1}, not ${reference.paragraph}`;
|
|
7696
8163
|
return null;
|
|
7697
8164
|
}
|
|
7698
8165
|
/** PG-09: three verifiable quotes from three distinct paragraphs. */
|
|
@@ -7945,12 +8412,21 @@ function projectChapter(record) {
|
|
|
7945
8412
|
score: record.score,
|
|
7946
8413
|
stage: record.status === "reviewed" ? "done" : "P3",
|
|
7947
8414
|
status: record.status === "reviewed" ? "passed" : "draft",
|
|
7948
|
-
details: record.summary.events.join(";").slice(0, 240)
|
|
8415
|
+
details: record.summary.events.join(";").slice(0, 240),
|
|
8416
|
+
sceneType: record.scene_type,
|
|
8417
|
+
dialogueRatio: record.dialogue_ratio,
|
|
8418
|
+
hookStrength: record.summary.hook_strength,
|
|
8419
|
+
finalCheck: record.final_check ?? null,
|
|
8420
|
+
reviews: record.rounds?.review ?? 0,
|
|
8421
|
+
revisions: record.rounds?.revise ?? 0,
|
|
8422
|
+
checks: record.rounds?.check ?? 0,
|
|
8423
|
+
rollbacks: record.rounds?.rollbacks ?? 0,
|
|
8424
|
+
archivedAt: record.archived_at ?? null
|
|
7949
8425
|
};
|
|
7950
8426
|
}
|
|
7951
8427
|
function projectSummary(snapshot) {
|
|
7952
8428
|
return {
|
|
7953
|
-
schemaVersion:
|
|
8429
|
+
schemaVersion: 6,
|
|
7954
8430
|
panelId: PANEL_ID,
|
|
7955
8431
|
root: snapshot.root,
|
|
7956
8432
|
title: snapshot.title,
|
|
@@ -7972,6 +8448,8 @@ function projectSummary(snapshot) {
|
|
|
7972
8448
|
...snapshot.book === void 0 ? {} : { book: snapshot.book },
|
|
7973
8449
|
...snapshot.quality === void 0 ? {} : { quality: snapshot.quality },
|
|
7974
8450
|
...snapshot.health === void 0 ? {} : { health: snapshot.health },
|
|
8451
|
+
...snapshot.bookData === void 0 ? {} : { bookData: snapshot.bookData },
|
|
8452
|
+
...snapshot.pending === void 0 ? {} : { pending: snapshot.pending },
|
|
7975
8453
|
...snapshot.activity === void 0 ? {} : { activity: snapshot.activity },
|
|
7976
8454
|
...snapshot.advisories === void 0 ? {} : { advisories: snapshot.advisories },
|
|
7977
8455
|
...snapshot.acknowledgedAdvisories === void 0 ? {} : { acknowledgedAdvisories: snapshot.acknowledgedAdvisories },
|
|
@@ -7997,14 +8475,14 @@ function projectChapters(snapshot) {
|
|
|
7997
8475
|
details: draft.details
|
|
7998
8476
|
});
|
|
7999
8477
|
return {
|
|
8000
|
-
schemaVersion:
|
|
8478
|
+
schemaVersion: 6,
|
|
8001
8479
|
panelId: PANEL_ID,
|
|
8002
8480
|
rows
|
|
8003
8481
|
};
|
|
8004
8482
|
}
|
|
8005
8483
|
function projectGates(snapshot) {
|
|
8006
8484
|
return {
|
|
8007
|
-
schemaVersion:
|
|
8485
|
+
schemaVersion: 6,
|
|
8008
8486
|
panelId: PANEL_ID,
|
|
8009
8487
|
stage: snapshot.stage,
|
|
8010
8488
|
blocking: snapshot.blocking,
|
|
@@ -8406,6 +8884,8 @@ var EngineService = class {
|
|
|
8406
8884
|
exportArtifactEpoch = /* @__PURE__ */ new Map();
|
|
8407
8885
|
/** Status, panel and gates ask for the same stage context repeatedly. */
|
|
8408
8886
|
briefContextCache = /* @__PURE__ */ new WeakMap();
|
|
8887
|
+
/** The 15s panel poll reuses the heavy book projection until revision/damage changes. */
|
|
8888
|
+
panelCache = /* @__PURE__ */ new Map();
|
|
8409
8889
|
briefs;
|
|
8410
8890
|
now;
|
|
8411
8891
|
metadataMutex = new KeyedMutex();
|
|
@@ -9418,14 +9898,51 @@ var EngineService = class {
|
|
|
9418
9898
|
if (typeof input.txid !== "string" || !/^[0-9a-f-]{36}$/i.test(input.txid)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "restore txid must be a transaction UUID");
|
|
9419
9899
|
if (input.chapter !== void 0) this.assertChapterParameter(input.chapter);
|
|
9420
9900
|
if (input.paths !== void 0 && (!Array.isArray(input.paths) || input.paths.length === 0)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "restore paths must be a non-empty array");
|
|
9901
|
+
const pipelinePath = joinRel(STATE_DIR, STATE_FILES.pipeline);
|
|
9902
|
+
const pipelineOnly = input.paths?.length === 1 && input.paths[0] === pipelinePath;
|
|
9421
9903
|
return this.mutate({
|
|
9422
9904
|
root: input.root,
|
|
9423
9905
|
op: "restoreBackup",
|
|
9424
9906
|
chapter: input.chapter,
|
|
9425
9907
|
reason: `restore backup ${input.txid}`,
|
|
9908
|
+
...pipelineOnly ? {
|
|
9909
|
+
allowPipelineRecovery: true,
|
|
9910
|
+
repairPaths: [pipelinePath]
|
|
9911
|
+
} : {},
|
|
9426
9912
|
run: async (snapshot, context) => {
|
|
9427
9913
|
const manifest = await readBackupManifest(context.store, input.txid);
|
|
9428
9914
|
if (manifest === null) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} has no readable manifest`, "list backups with novel_state {kind:\"backups\"}; only marked engine backups can be restored");
|
|
9915
|
+
if (pipelineOnly) {
|
|
9916
|
+
if (input.chapter !== void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "restoring state/pipeline.yaml does not accept a chapter", "omit chapter when paths is exactly [state/pipeline.yaml]");
|
|
9917
|
+
if (!manifest.files.includes(pipelinePath)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} does not contain ${pipelinePath}`, "choose a backup created by a stage operation or amend that contains the pipeline");
|
|
9918
|
+
if (snapshot.pipelineError === null) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "state/pipeline.yaml is readable; refusing a blind pipeline restore", "use the normal stage operation, or repair a genuinely unreadable pipeline");
|
|
9919
|
+
const text = await context.store.readText(`backups/${input.txid}/${pipelinePath}`);
|
|
9920
|
+
if (text === null) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} does not contain ${pipelinePath}`, "choose another backup with a pipeline pre-image");
|
|
9921
|
+
let backupPipeline;
|
|
9922
|
+
try {
|
|
9923
|
+
backupPipeline = narrow("pipeline", parse(text));
|
|
9924
|
+
} catch (error) {
|
|
9925
|
+
throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} contains an invalid pipeline: ${errorMessage(error)}`, "choose a different backup or repair the file manually");
|
|
9926
|
+
}
|
|
9927
|
+
const nextPipeline = {
|
|
9928
|
+
...backupPipeline,
|
|
9929
|
+
revision: context.revision,
|
|
9930
|
+
last_action: action("novel_restore", this.now(), shortDigest(input.txid))
|
|
9931
|
+
};
|
|
9932
|
+
return {
|
|
9933
|
+
files: [{
|
|
9934
|
+
path: pipelinePath,
|
|
9935
|
+
text: yaml(nextPipeline)
|
|
9936
|
+
}],
|
|
9937
|
+
backup: true,
|
|
9938
|
+
data: {
|
|
9939
|
+
txid: input.txid,
|
|
9940
|
+
chapter: nextPipeline.chapter,
|
|
9941
|
+
restored: [pipelinePath]
|
|
9942
|
+
},
|
|
9943
|
+
warnings: []
|
|
9944
|
+
};
|
|
9945
|
+
}
|
|
9429
9946
|
if (input.paths?.length === 1 && input.paths[0] === "outline/outline.yaml") {
|
|
9430
9947
|
this.assertArchiveIndexReadable(snapshot, "novel_restore");
|
|
9431
9948
|
if (input.chapter !== void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "restoring outline/outline.yaml does not accept a chapter", "omit chapter when paths is exactly [outline/outline.yaml]");
|
|
@@ -9441,6 +9958,8 @@ var EngineService = class {
|
|
|
9441
9958
|
} catch (error) {
|
|
9442
9959
|
throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} contains an invalid outline: ${errorMessage(error)}`, "restore a different backup or repair the file manually");
|
|
9443
9960
|
}
|
|
9961
|
+
assertArchivedCardVolumes(snapshot.chapters.chapters, snapshot.outline.chapters, snapshot.outline.volumes, document.volumes, "novel_restore");
|
|
9962
|
+
assertArchivedCardsInVolumes(snapshot.chapters.chapters, document.chapters, document.volumes, "novel_restore");
|
|
9444
9963
|
const restoredCards = new Map(document.chapters.map((card) => [card.num, card]));
|
|
9445
9964
|
const archivedNums = new Set(snapshot.chapters.chapters.map((record) => record.num));
|
|
9446
9965
|
for (const record of snapshot.chapters.chapters) {
|
|
@@ -10239,7 +10758,7 @@ var EngineService = class {
|
|
|
10239
10758
|
severity: "error",
|
|
10240
10759
|
title: "状态文档不可读",
|
|
10241
10760
|
detail: `${error.code}: ${error.message}`,
|
|
10242
|
-
hint: `repair ${error.path} with novel_amend`
|
|
10761
|
+
hint: error.code === "PIPELINE_UNREADABLE" ? "restore a valid pipeline backup with novel_restore { paths: [\"state/pipeline.yaml\"], txid }" : `repair ${error.path} with novel_amend`
|
|
10243
10762
|
});
|
|
10244
10763
|
add({
|
|
10245
10764
|
id: "outline",
|
|
@@ -10517,6 +11036,7 @@ var EngineService = class {
|
|
|
10517
11036
|
worldbook: snapshot.worldbook,
|
|
10518
11037
|
worldbookPresent: snapshot.worldbookPresent,
|
|
10519
11038
|
worldbookError: snapshot.worldbookError,
|
|
11039
|
+
world: snapshot.world,
|
|
10520
11040
|
chapters: snapshot.chapters.chapters,
|
|
10521
11041
|
characters: snapshot.characters,
|
|
10522
11042
|
timeline: snapshot.timeline,
|
|
@@ -12888,15 +13408,18 @@ var EngineService = class {
|
|
|
12888
13408
|
const target = snapshot.project.targets.total_chapters;
|
|
12889
13409
|
const finished = snapshot.pipeline.stage === "done" && target !== null && archived.length >= target;
|
|
12890
13410
|
const draftWords = snapshot.text === null ? 0 : countWordMetrics(snapshot.text).total_word_count;
|
|
12891
|
-
const journal = await readJournal(this.storeFor(snapshot.root));
|
|
12892
13411
|
const pending = await inspectPending(this.storeFor(snapshot.root));
|
|
13412
|
+
const stateErrorCount = this.stateErrors(snapshot).length;
|
|
13413
|
+
const panelCacheKey = `${snapshot.root}:${snapshot.pipeline.revision}:${pending.state}:${stateErrorCount}:${snapshot.outlineError ?? ""}:${snapshot.worldbookError ?? ""}:${JSON.stringify(snapshot.briefs)}`;
|
|
13414
|
+
const cachedPanel = this.panelCache.get(snapshot.root);
|
|
13415
|
+
if (cachedPanel?.key === panelCacheKey) return cachedPanel.snapshot;
|
|
13416
|
+
const journal = await readJournal(this.storeFor(snapshot.root));
|
|
12893
13417
|
const journalBlocking = await this.journalBlocking(snapshot);
|
|
12894
13418
|
const outline = outlineSummary(snapshot.outline, archived.length, target, archived.map((record) => record.num));
|
|
12895
13419
|
const counts = worldbookCounts(snapshot.worldbook);
|
|
12896
13420
|
const scores = archived.slice(-10).map((chapter) => chapter.score);
|
|
12897
13421
|
const blocking = [...this.effectiveBlocking(snapshot), ...journalBlocking];
|
|
12898
13422
|
const healthBlocking = blocking.length;
|
|
12899
|
-
const stateErrorCount = this.stateErrors(snapshot).length;
|
|
12900
13423
|
const book = {
|
|
12901
13424
|
premise: snapshot.outline.book.premise,
|
|
12902
13425
|
logline: snapshot.outline.book.logline,
|
|
@@ -12940,11 +13463,16 @@ var EngineService = class {
|
|
|
12940
13463
|
const store = this.storeFor(snapshot.root);
|
|
12941
13464
|
const reviewNames = new Set(await store.list(REVIEW_DIR));
|
|
12942
13465
|
const sampleChapters = /* @__PURE__ */ new Set();
|
|
13466
|
+
const currentReviews = [];
|
|
12943
13467
|
for (const record of archived) {
|
|
12944
13468
|
const name = reviewFile(record.num).slice(`${REVIEW_DIR}/`.length);
|
|
12945
13469
|
if (!reviewNames.has(name)) continue;
|
|
12946
13470
|
try {
|
|
12947
|
-
|
|
13471
|
+
const review = await store.readDoc(reviewFile(record.num), "review");
|
|
13472
|
+
if (review !== null) {
|
|
13473
|
+
sampleChapters.add(record.num);
|
|
13474
|
+
currentReviews.push(review);
|
|
13475
|
+
}
|
|
12948
13476
|
} catch {}
|
|
12949
13477
|
}
|
|
12950
13478
|
const samples = sampleChapters.size;
|
|
@@ -12975,15 +13503,66 @@ var EngineService = class {
|
|
|
12975
13503
|
chapter: row.chapter,
|
|
12976
13504
|
status: row.status
|
|
12977
13505
|
}));
|
|
13506
|
+
const draftMetrics = snapshot.text === null ? null : countWordMetrics(snapshot.text);
|
|
13507
|
+
const bookData = buildPanelBookData({
|
|
13508
|
+
report: buildBookReport({
|
|
13509
|
+
project: snapshot.project,
|
|
13510
|
+
outline: snapshot.outline,
|
|
13511
|
+
outlinePresent: snapshot.outlinePresent,
|
|
13512
|
+
outlineError: snapshot.outlineError,
|
|
13513
|
+
worldbook: snapshot.worldbook,
|
|
13514
|
+
worldbookPresent: snapshot.worldbookPresent,
|
|
13515
|
+
worldbookError: snapshot.worldbookError,
|
|
13516
|
+
world: snapshot.world,
|
|
13517
|
+
chapters: archived,
|
|
13518
|
+
characters: snapshot.characters,
|
|
13519
|
+
timeline: snapshot.timeline,
|
|
13520
|
+
foreshadows: snapshot.foreshadows,
|
|
13521
|
+
relationships: snapshot.relationships,
|
|
13522
|
+
reviews: currentReviews,
|
|
13523
|
+
activity: journal.rows.slice(-8),
|
|
13524
|
+
journal: journal.rows,
|
|
13525
|
+
reviewAttempts: attempts,
|
|
13526
|
+
finalChecks: Object.fromEntries(archived.filter((record) => record.final_check === "pass" || record.final_check === "fail").map((record) => [record.num, record.final_check])),
|
|
13527
|
+
targetChapters: target,
|
|
13528
|
+
minScore: this.config.quality.minScore,
|
|
13529
|
+
foreshadowDormantAfter: this.rulepack.crosschapter.foreshadow_dormant_after
|
|
13530
|
+
}),
|
|
13531
|
+
project: snapshot.project,
|
|
13532
|
+
worldbook: snapshot.worldbook,
|
|
13533
|
+
world: snapshot.world,
|
|
13534
|
+
outline: snapshot.outline,
|
|
13535
|
+
outlinePresent: snapshot.outlinePresent,
|
|
13536
|
+
worldbookPresent: snapshot.worldbookPresent,
|
|
13537
|
+
currentChapter: snapshot.pipeline.chapter,
|
|
13538
|
+
stage: snapshot.pipeline.stage,
|
|
13539
|
+
rounds: snapshot.pipeline.rounds,
|
|
13540
|
+
draftWords,
|
|
13541
|
+
lastTenAverage: scores.length === 0 ? 0 : scores.reduce((total, score) => total + score, 0) / scores.length,
|
|
13542
|
+
manuscriptHash: snapshot.pipeline.draft?.content_hash ?? (snapshot.text === null ? null : sha256(snapshot.text)),
|
|
13543
|
+
metrics: draftMetrics,
|
|
13544
|
+
stateErrors: this.stateErrors(snapshot).map((error) => ({
|
|
13545
|
+
path: error.path,
|
|
13546
|
+
code: error.code,
|
|
13547
|
+
message: error.message
|
|
13548
|
+
})),
|
|
13549
|
+
pending: pending.state === "none" ? null : {
|
|
13550
|
+
state: pending.state,
|
|
13551
|
+
txid: pending.txid ?? "",
|
|
13552
|
+
detail: pending.detail ?? ""
|
|
13553
|
+
},
|
|
13554
|
+
outlineError: snapshot.outlineError,
|
|
13555
|
+
worldbookError: snapshot.worldbookError
|
|
13556
|
+
});
|
|
12978
13557
|
const draft = archivedCurrent || snapshot.pipeline.stage === "done" ? null : {
|
|
12979
13558
|
num: snapshot.pipeline.chapter,
|
|
12980
13559
|
title: stripTitlePrefix(snapshot.plan?.title ?? "") || `第${snapshot.pipeline.chapter}章`,
|
|
12981
13560
|
wordCount: draftWords,
|
|
12982
13561
|
score: snapshot.review?.total ?? 0,
|
|
12983
13562
|
status: blocking.length > 0 ? "blocked" : "draft",
|
|
12984
|
-
details: (snapshot.
|
|
13563
|
+
details: (snapshot.pipeline.draft?.summary?.events ?? []).join(";").slice(0, 240)
|
|
12985
13564
|
};
|
|
12986
|
-
|
|
13565
|
+
const result = {
|
|
12987
13566
|
root: snapshot.root,
|
|
12988
13567
|
title: snapshot.project.title,
|
|
12989
13568
|
genre: snapshot.project.genre,
|
|
@@ -13014,11 +13593,13 @@ var EngineService = class {
|
|
|
13014
13593
|
averageScore: archived.length === 0 ? 0 : archived.reduce((total, chapter) => total + chapter.score, 0) / archived.length,
|
|
13015
13594
|
phases: phaseStrip(snapshot.pipeline.stage, finished),
|
|
13016
13595
|
book,
|
|
13596
|
+
bookData,
|
|
13017
13597
|
quality,
|
|
13018
13598
|
health: {
|
|
13019
|
-
ok: healthBlocking === 0 && stateErrorCount === 0,
|
|
13599
|
+
ok: healthBlocking === 0 && stateErrorCount === 0 && pending.state === "none",
|
|
13020
13600
|
blocking: healthBlocking,
|
|
13021
|
-
stateErrors: stateErrorCount
|
|
13601
|
+
stateErrors: stateErrorCount,
|
|
13602
|
+
pending: pending.state !== "none"
|
|
13022
13603
|
},
|
|
13023
13604
|
activity,
|
|
13024
13605
|
...advisories.length === 0 ? {} : { advisories },
|
|
@@ -13030,6 +13611,15 @@ var EngineService = class {
|
|
|
13030
13611
|
detail: pending.detail ?? ""
|
|
13031
13612
|
} }
|
|
13032
13613
|
};
|
|
13614
|
+
this.panelCache.set(snapshot.root, {
|
|
13615
|
+
key: panelCacheKey,
|
|
13616
|
+
snapshot: result
|
|
13617
|
+
});
|
|
13618
|
+
if (this.panelCache.size > 16) {
|
|
13619
|
+
const oldest = this.panelCache.keys().next().value;
|
|
13620
|
+
if (oldest !== void 0) this.panelCache.delete(oldest);
|
|
13621
|
+
}
|
|
13622
|
+
return result;
|
|
13033
13623
|
}
|
|
13034
13624
|
/**
|
|
13035
13625
|
* The archive stage can be left by a direct revision (the 0.2.3 workflow) or
|
|
@@ -13324,7 +13914,7 @@ var EngineService = class {
|
|
|
13324
13914
|
code: "PG-13",
|
|
13325
13915
|
scope: "outline",
|
|
13326
13916
|
detail: `chapter ${snapshot.pipeline.chapter} has no outline card while strict mode is on`,
|
|
13327
|
-
hint: "call novel_outline to add the card
|
|
13917
|
+
hint: "call novel_outline to add the missing card; do not turn strict mode off just to pass this gate"
|
|
13328
13918
|
};
|
|
13329
13919
|
}
|
|
13330
13920
|
const plan = snapshot.plan;
|
|
@@ -13376,6 +13966,11 @@ var EngineService = class {
|
|
|
13376
13966
|
code: "PROJECT_UNREADABLE",
|
|
13377
13967
|
message: snapshot.projectError
|
|
13378
13968
|
});
|
|
13969
|
+
if (snapshot.pipelineError !== null) errors.push({
|
|
13970
|
+
path: joinRel(STATE_DIR, STATE_FILES.pipeline),
|
|
13971
|
+
code: "PIPELINE_UNREADABLE",
|
|
13972
|
+
message: snapshot.pipelineError
|
|
13973
|
+
});
|
|
13379
13974
|
const chapter = snapshot.pipeline.chapter;
|
|
13380
13975
|
if (snapshot.worldPlanError !== null) errors.push({
|
|
13381
13976
|
path: planWorldFile(chapter),
|
|
@@ -13411,6 +14006,11 @@ var EngineService = class {
|
|
|
13411
14006
|
}
|
|
13412
14007
|
/** The earliest rebuild point for the current state damage. */
|
|
13413
14008
|
recoveryFor(snapshot) {
|
|
14009
|
+
if (snapshot.pipelineError !== null) return {
|
|
14010
|
+
tool: "novel_restore",
|
|
14011
|
+
args: { paths: [joinRel(STATE_DIR, STATE_FILES.pipeline)] },
|
|
14012
|
+
reason: `PIPELINE_UNREADABLE: ${snapshot.pipelineError}; choose a backup with a pipeline pre-image and pass its txid`
|
|
14013
|
+
};
|
|
13414
14014
|
const chapter = snapshot.pipeline.chapter;
|
|
13415
14015
|
const errors = this.stateErrors(snapshot);
|
|
13416
14016
|
if (snapshot.projectError !== null) return {
|
|
@@ -13759,7 +14359,7 @@ var EngineService = class {
|
|
|
13759
14359
|
summary: extras.summary ?? snapshot.pipeline.draft?.summary ?? snapshot.plan?.summary ?? null,
|
|
13760
14360
|
summaryProvided: extras.summaryProvided,
|
|
13761
14361
|
summaryOnly: extras.summaryOnly,
|
|
13762
|
-
summaryOnlyRevision: extras.summaryOnlyRevision ?? snapshot.pipeline.last_action?.op === "novel_revise_summary",
|
|
14362
|
+
summaryOnlyRevision: extras.summaryOnlyRevision ?? (operation === "runCheck" && snapshot.pipeline.stage === "P6" && snapshot.pipeline.last_action?.op === "novel_revise_summary"),
|
|
13763
14363
|
review: extras.review ?? snapshot.review,
|
|
13764
14364
|
check: extras.check ?? snapshot.check,
|
|
13765
14365
|
semantic: extras.semantic ?? [],
|
|
@@ -13886,8 +14486,22 @@ var EngineService = class {
|
|
|
13886
14486
|
actualTitle: project.title
|
|
13887
14487
|
});
|
|
13888
14488
|
}
|
|
13889
|
-
const
|
|
13890
|
-
|
|
14489
|
+
const pipelinePath = joinRel(STATE_DIR, STATE_FILES.pipeline);
|
|
14490
|
+
let pipeline = null;
|
|
14491
|
+
let pipelineError = null;
|
|
14492
|
+
try {
|
|
14493
|
+
pipeline = await store.readDoc(pipelinePath, "pipeline");
|
|
14494
|
+
if (pipeline === null) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "state/pipeline.yaml is missing");
|
|
14495
|
+
} catch (error) {
|
|
14496
|
+
pipelineError = errorMessage(error);
|
|
14497
|
+
const backup = await this.latestBackup(store, pipelinePath);
|
|
14498
|
+
if (backup === null) throw error;
|
|
14499
|
+
try {
|
|
14500
|
+
pipeline = narrow("pipeline", parse(backup));
|
|
14501
|
+
} catch {
|
|
14502
|
+
throw error;
|
|
14503
|
+
}
|
|
14504
|
+
}
|
|
13891
14505
|
let briefs = pipeline.briefs;
|
|
13892
14506
|
const briefsRaw = await store.readText(BRIEF_RECEIPTS_FILE);
|
|
13893
14507
|
if (briefsRaw !== null) {
|
|
@@ -14026,10 +14640,12 @@ var EngineService = class {
|
|
|
14026
14640
|
if (planRaw === null && planError === null && cycleRank >= STAGE_RANK.P3) planError = `${planChapterFile(chapter)} is missing`;
|
|
14027
14641
|
if (review === null && reviewError === null && cycleRank >= STAGE_RANK.P5) reviewError = `${reviewFile(chapter)} is missing`;
|
|
14028
14642
|
if (check === null && checkError === null && pipeline.stage === "ready_to_archive") checkError = `${checkFile(chapter)} is missing`;
|
|
14029
|
-
|
|
14030
|
-
|
|
14031
|
-
if (
|
|
14032
|
-
|
|
14643
|
+
if (pipelineError === null) {
|
|
14644
|
+
const latest = await store.readDoc(joinRel(STATE_DIR, STATE_FILES.pipeline), "pipeline");
|
|
14645
|
+
if (latest === null || latest.revision !== pipeline.revision) {
|
|
14646
|
+
if (retries > 0) return this.loadAt(selection, retries - 1, allowLock);
|
|
14647
|
+
throw new EngineFaultError(ERROR_CODES.TXN_ABORTED, "the project changed while its snapshot was being read", "retry the read; if it keeps moving, wait for the writer to finish");
|
|
14648
|
+
}
|
|
14033
14649
|
}
|
|
14034
14650
|
if (!allowLock && await this.lockIsLive(store)) {
|
|
14035
14651
|
if (retries > 0) return this.loadAt(selection, retries - 1, false);
|
|
@@ -14040,6 +14656,7 @@ var EngineService = class {
|
|
|
14040
14656
|
selection,
|
|
14041
14657
|
project,
|
|
14042
14658
|
projectError,
|
|
14659
|
+
pipelineError,
|
|
14043
14660
|
pipeline,
|
|
14044
14661
|
briefs,
|
|
14045
14662
|
chapters,
|
|
@@ -14194,6 +14811,7 @@ var EngineService = class {
|
|
|
14194
14811
|
}
|
|
14195
14812
|
await assertJournalConsistent(store, options.repairPaths ?? []);
|
|
14196
14813
|
const snapshot = await this.loadAt(selection, 1, true);
|
|
14814
|
+
if (snapshot.pipelineError !== null && options.allowPipelineRecovery !== true) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `state/pipeline.yaml is unreadable: ${snapshot.pipelineError}`, "restore a valid pipeline backup with novel_restore { paths: [\"state/pipeline.yaml\"], txid: ... }; other writes are blocked until then");
|
|
14197
14815
|
if (selection.source === "session" && selection.created_at !== void 0 && selection.created_at !== "" && snapshot.projectError !== null) throw identityMismatchError({
|
|
14198
14816
|
selection,
|
|
14199
14817
|
actualTitle: snapshot.project.title
|
|
@@ -14242,7 +14860,8 @@ var EngineService = class {
|
|
|
14242
14860
|
},
|
|
14243
14861
|
...error instanceof EngineError ? { code: error.code } : {},
|
|
14244
14862
|
...error instanceof EngineError && error.hint !== void 0 ? { hint: error.hint } : {},
|
|
14245
|
-
...error instanceof EngineBlockedError ? { blocking: error.blocking } : {}
|
|
14863
|
+
...error instanceof EngineBlockedError ? { blocking: error.blocking } : {},
|
|
14864
|
+
...error instanceof CheckSubmissionError ? { blocking: error.blocking.slice(0, 20) } : {}
|
|
14246
14865
|
}).catch(() => void 0);
|
|
14247
14866
|
throw error;
|
|
14248
14867
|
}
|
|
@@ -14309,6 +14928,7 @@ var EngineService = class {
|
|
|
14309
14928
|
selection,
|
|
14310
14929
|
project: { ...ENGINE_PROJECT_INITIAL },
|
|
14311
14930
|
projectError: null,
|
|
14931
|
+
pipelineError: null,
|
|
14312
14932
|
pipeline: emptyPipeline({
|
|
14313
14933
|
revision: 1,
|
|
14314
14934
|
chapter: 1,
|
|
@@ -16006,7 +16626,7 @@ function createNovelTools(engine, options = {}) {
|
|
|
16006
16626
|
}),
|
|
16007
16627
|
defineTool({
|
|
16008
16628
|
name: "novel_restore",
|
|
16009
|
-
description: "Restore an active chapter file,
|
|
16629
|
+
description: "Restore an active chapter file, outline/outline.yaml, or a damaged state/pipeline.yaml from a marked backup transaction. Archived chapters are refused; use novel_reopen first. Every restore is itself journaled and backed up.",
|
|
16010
16630
|
parameters: {
|
|
16011
16631
|
txid: {
|
|
16012
16632
|
type: "string",
|
|
@@ -16016,7 +16636,7 @@ function createNovelTools(engine, options = {}) {
|
|
|
16016
16636
|
paths: {
|
|
16017
16637
|
type: "array",
|
|
16018
16638
|
items: { type: "string" },
|
|
16019
|
-
description: arraySemanticsDescription("replace", "Project-relative chapter files,
|
|
16639
|
+
description: arraySemanticsDescription("replace", "Project-relative chapter files, exactly [outline/outline.yaml], or exactly [state/pipeline.yaml] when the pipeline is unreadable; omitted restores every restorable chapter file the manifest records.")
|
|
16020
16640
|
},
|
|
16021
16641
|
project_root: root
|
|
16022
16642
|
},
|
|
@@ -16511,7 +17131,7 @@ function createSkill() {
|
|
|
16511
17131
|
async function panelPayload(engine, root) {
|
|
16512
17132
|
return {
|
|
16513
17133
|
...await engine.panelBundle(root === void 0 ? {} : { root }),
|
|
16514
|
-
schemaVersion:
|
|
17134
|
+
schemaVersion: 6
|
|
16515
17135
|
};
|
|
16516
17136
|
}
|
|
16517
17137
|
const queryRoot = (url) => {
|
|
@@ -16561,7 +17181,7 @@ function panelHandler(engine, guard = denyAll) {
|
|
|
16561
17181
|
response.setHeader("content-type", "application/json; charset=utf-8");
|
|
16562
17182
|
response.setHeader("cache-control", "no-store");
|
|
16563
17183
|
response.end(JSON.stringify({
|
|
16564
|
-
schemaVersion:
|
|
17184
|
+
schemaVersion: 6,
|
|
16565
17185
|
projects: [],
|
|
16566
17186
|
selection: {
|
|
16567
17187
|
root: null,
|
|
@@ -16576,7 +17196,7 @@ function panelHandler(engine, guard = denyAll) {
|
|
|
16576
17196
|
response.setHeader("content-type", "application/json; charset=utf-8");
|
|
16577
17197
|
response.setHeader("cache-control", "no-store");
|
|
16578
17198
|
response.end(JSON.stringify({
|
|
16579
|
-
schemaVersion:
|
|
17199
|
+
schemaVersion: 6,
|
|
16580
17200
|
projects: [],
|
|
16581
17201
|
selection: {
|
|
16582
17202
|
root: null,
|