@whispering233/ai-editor-server 0.0.54 → 0.0.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client-dist/assets/{index-B2op0r9o.css → index-FO9svVbu.css} +1 -1
- package/client-dist/assets/index-ntCd3_tm.js +625 -0
- package/client-dist/index.html +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -4
- package/dist/index.js.map +1 -1
- package/dist/middleware/error.d.ts +1 -1
- package/dist/middleware/error.d.ts.map +1 -1
- package/dist/middleware/error.js +0 -9
- package/dist/middleware/error.js.map +1 -1
- package/dist/middleware/project.d.ts +3 -4
- package/dist/middleware/project.d.ts.map +1 -1
- package/dist/middleware/project.js +2 -40
- package/dist/middleware/project.js.map +1 -1
- package/dist/routes/chat.d.ts.map +1 -1
- package/dist/routes/chat.js +46 -39
- package/dist/routes/chat.js.map +1 -1
- package/dist/routes/project.d.ts +1 -1
- package/dist/routes/project.d.ts.map +1 -1
- package/dist/routes/project.js +4 -6
- package/dist/routes/project.js.map +1 -1
- package/package.json +5 -5
- package/client-dist/assets/index-Bgk8dcAm.js +0 -627
- package/dist/decompose/batching.d.ts +0 -20
- package/dist/decompose/batching.d.ts.map +0 -1
- package/dist/decompose/batching.js +0 -29
- package/dist/decompose/batching.js.map +0 -1
- package/dist/decompose/extract.d.ts +0 -44
- package/dist/decompose/extract.d.ts.map +0 -1
- package/dist/decompose/extract.js +0 -246
- package/dist/decompose/extract.js.map +0 -1
- package/dist/decompose/job.d.ts +0 -91
- package/dist/decompose/job.d.ts.map +0 -1
- package/dist/decompose/job.js +0 -289
- package/dist/decompose/job.js.map +0 -1
- package/dist/decompose/llm.d.ts +0 -68
- package/dist/decompose/llm.d.ts.map +0 -1
- package/dist/decompose/llm.js +0 -181
- package/dist/decompose/llm.js.map +0 -1
- package/dist/decompose/merge-write.d.ts +0 -75
- package/dist/decompose/merge-write.d.ts.map +0 -1
- package/dist/decompose/merge-write.js +0 -670
- package/dist/decompose/merge-write.js.map +0 -1
- package/dist/decompose/merge.d.ts +0 -156
- package/dist/decompose/merge.d.ts.map +0 -1
- package/dist/decompose/merge.js +0 -266
- package/dist/decompose/merge.js.map +0 -1
- package/dist/decompose/report.d.ts +0 -57
- package/dist/decompose/report.d.ts.map +0 -1
- package/dist/decompose/report.js +0 -127
- package/dist/decompose/report.js.map +0 -1
- package/dist/decompose/runner.d.ts +0 -143
- package/dist/decompose/runner.d.ts.map +0 -1
- package/dist/decompose/runner.js +0 -520
- package/dist/decompose/runner.js.map +0 -1
- package/dist/decompose/split.d.ts +0 -89
- package/dist/decompose/split.d.ts.map +0 -1
- package/dist/decompose/split.js +0 -556
- package/dist/decompose/split.js.map +0 -1
- package/dist/routes/decompose.d.ts +0 -26
- package/dist/routes/decompose.d.ts.map +0 -1
- package/dist/routes/decompose.js +0 -453
- package/dist/routes/decompose.js.map +0 -1
|
@@ -1,670 +0,0 @@
|
|
|
1
|
-
// 拆解 S3 落库 + S4 报告写盘(卡 21.7):按 merge.ts 的写入计划把归并产物落进业务表。
|
|
2
|
-
//
|
|
3
|
-
// 契约:docs/design/60-decompose.md §6(归并三层)/ §6.1(`merge_written` 三路比对与重跑幂等)/
|
|
4
|
-
// §6.2(报告落点:一条 `entities(reference)` + 其块文档);docs/api/120-api-decompose.md §rerun。
|
|
5
|
-
// 口径:
|
|
6
|
-
// - **写入计划是唯一写入依据**(create / reuse / keep-user-edited / soft-delete / keep-and-report):
|
|
7
|
-
// 计划外的一律不碰(回收站里的旧行不参与);用户手工建的同名行**复用同一行但不写内容**(不重复建);
|
|
8
|
-
// - **记录面两条**:本 job 清单(软删面)+ 跨轮 baseline = 历史全部 job 的 `merge_written` 并集
|
|
9
|
-
// (同一 id 取最新一次记录)⇒ 续拆时同名实体/关系复用同一行、跨轮未重现的产物不软删、报告更新同一条;
|
|
10
|
-
// - **增量更新**(跨轮复用已存在的行):`mergeEntityData` 的字段口径(description 取更长者 / 数组取并集 /
|
|
11
|
-
// `role` 本轮非空则覆盖 / 其余仅库内为空时填),**无变化则不写**,写了必须刷新清单条目里的 `updated_at`;
|
|
12
|
-
// - **消失行**:实体 → `softDeleteEntity`(进回收站可还原,§6.1);关系 → `deleteRelation` **物理删**——
|
|
13
|
-
// 本仓关系没有回收站路径(relations 软删只作为实体级联的副作用存在,`docs/api/70-api-trash.md` 只服务
|
|
14
|
-
// 实体与大纲节点),与「手动删关系 = 物理删」同口径;产物重现时按新行创建,幂等性不靠旧行;
|
|
15
|
-
// - **章摘要回写只写 `summary`**:章标题的权威来源 = S1 写的大纲节点标题(split 清洗后),
|
|
16
|
-
// 批结果里的 `chapterTitle` 只是模型回声——回写它会覆盖用户改过的标题;
|
|
17
|
-
// - **模型调用只在 `llm.ts`**:S3 一次别名归并(只针对人物)+ S4 一次全书剧情摘要;
|
|
18
|
-
// - 上限与文案常量单一定义并导出;散文与注释只引用常量名,不复述数字。
|
|
19
|
-
import { MAX_ENTITY_LIST_LIMIT, blocksToPlainMd } from "@whispering233/ai-editor-shared";
|
|
20
|
-
import { createEntity, createRelation, deleteRelation, deriveChapterOrder, findOutlineNode, getDocumentTextLengths, getEntity, getRelation, listDecomposeBatches, listDecomposeJobs, listEntities, listRelations, readOutlineFile, softDeleteEntity, updateEntity, updateOutlineNode, upsertDocument, withTransaction, writeMergeWritten, writeOutlineFile, } from "@whispering233/ai-editor-db";
|
|
21
|
-
import { DECOMPOSE_DESCRIPTION_MAX_CHARS } from "./extract.js";
|
|
22
|
-
import { doneBatchResults, paragraphBlocksOf } from "./job.js";
|
|
23
|
-
import { parseModelJson } from "./llm.js";
|
|
24
|
-
import { DECOMPOSE_ALIAS_CANDIDATE_MAX, DECOMPOSE_ALIAS_GROUP_MAX, DECOMPOSE_ALIAS_GROUP_MIN_NAMES, dedupeCandidates, mergeCandidates, normalizeEntityName, planMergeWrite, selectAliasCandidates, validateAliasGroups, } from "./merge.js";
|
|
25
|
-
import { DECOMPOSE_REPORT_ENTITY_TYPE, DECOMPOSE_REPORT_KIND, buildDecomposeReportText, completeReportPlot, decomposeReportName, } from "./report.js";
|
|
26
|
-
/** 归并清单里关系条目的 `type`(关系表无类型列;实体条目用 `entities.type`,报告用 `DECOMPOSE_REPORT_ENTITY_TYPE`) */
|
|
27
|
-
export const DECOMPOSE_MERGE_RELATION_TYPE = "relation";
|
|
28
|
-
/** 别名后缀(§6 第 3 层:别名进 description 的「(又称:X、Y)」,不动 schema) */
|
|
29
|
-
export const DECOMPOSE_ALIAS_NOTE_PREFIX = "(又称:";
|
|
30
|
-
export const DECOMPOSE_ALIAS_NOTE_SEPARATOR = "、";
|
|
31
|
-
/** 实体类别:关系端点**同名跨类型**时的解析优先级(先人物,再设定,末地点) */
|
|
32
|
-
export const DECOMPOSE_ENTITY_KINDS = ["character", "setting", "location"];
|
|
33
|
-
/** 报告在写入计划里的归并身份(与实体/关系身份格式不冲突:实体身份必带类别前缀) */
|
|
34
|
-
const REPORT_PRODUCT_KEY = "report";
|
|
35
|
-
/**
|
|
36
|
-
* S3 别名归并候选(§6 第 2 层):人物候选(第 1 层去重后)+ 出现章数 + 首次出现章 + 短摘要。
|
|
37
|
-
* 短摘要 = 该人物出现章里最完整的一条 `description`(截断)——LLM 只看到这份清单,看不到原文。
|
|
38
|
-
*/
|
|
39
|
-
export function buildAliasCandidates(results) {
|
|
40
|
-
const mentions = aggregateMentions(results);
|
|
41
|
-
return dedupeCandidates(results)
|
|
42
|
-
.entities.filter((entity) => entity.type === "character")
|
|
43
|
-
.map((entity) => ({
|
|
44
|
-
name: entity.name,
|
|
45
|
-
chapterCount: entity.chapters.length,
|
|
46
|
-
firstChapter: entity.chapters[0] ?? 0,
|
|
47
|
-
summary: clipText(mentions.get(entityIdentity(entity.type, entity.name))?.description ?? ""),
|
|
48
|
-
}));
|
|
49
|
-
}
|
|
50
|
-
/** 提及素材聚合(纯函数):`description` 取最长的一条,其余字段取首个非空值(跨章提及并成一行实体) */
|
|
51
|
-
export function aggregateMentions(results) {
|
|
52
|
-
const aggregates = new Map();
|
|
53
|
-
const absorb = (type, entity) => {
|
|
54
|
-
const name = normalizeEntityName(typeof entity.name === "string" ? entity.name : "");
|
|
55
|
-
if (name === "")
|
|
56
|
-
return;
|
|
57
|
-
const key = entityIdentity(type, name);
|
|
58
|
-
const aggregate = aggregates.get(key) ?? { description: "", data: {} };
|
|
59
|
-
const description = typeof entity.description === "string" ? entity.description : "";
|
|
60
|
-
if (description.length > aggregate.description.length)
|
|
61
|
-
aggregate.description = description;
|
|
62
|
-
for (const [field, value] of Object.entries(entity)) {
|
|
63
|
-
if (field === "name" || field === "description" || value === undefined)
|
|
64
|
-
continue;
|
|
65
|
-
if (aggregate.data[field] === undefined)
|
|
66
|
-
aggregate.data[field] = value;
|
|
67
|
-
}
|
|
68
|
-
aggregates.set(key, aggregate);
|
|
69
|
-
};
|
|
70
|
-
for (const result of results) {
|
|
71
|
-
for (const chapter of result.chapters) {
|
|
72
|
-
for (const character of chapter.characters)
|
|
73
|
-
absorb("character", character);
|
|
74
|
-
for (const setting of chapter.settings)
|
|
75
|
-
absorb("setting", setting);
|
|
76
|
-
for (const location of chapter.locations)
|
|
77
|
-
absorb("location", location);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
return aggregates;
|
|
81
|
-
}
|
|
82
|
-
/** 描述 + 「(又称:X、Y)」;描述为空时只留别称(§6 第 3 层) */
|
|
83
|
-
export function appendAliasNote(description, aliases) {
|
|
84
|
-
if (aliases.length === 0)
|
|
85
|
-
return description;
|
|
86
|
-
const note = `${DECOMPOSE_ALIAS_NOTE_PREFIX}${aliases.join(DECOMPOSE_ALIAS_NOTE_SEPARATOR)})`;
|
|
87
|
-
return description === "" ? note : `${description}${note}`;
|
|
88
|
-
}
|
|
89
|
-
/**
|
|
90
|
-
* 章摘要回写大纲(§5):**只写 `summary`**(章标题权威 = S1 写的大纲节点标题)。一次读 + 一次写
|
|
91
|
-
* (逐节点调用会让 N 章 × 原子写 fsync 变成 N 次落盘,同 S1 建档的取舍);摘要与节点现值相同则不触碰该节点。
|
|
92
|
-
* @returns 实际改动的章数
|
|
93
|
-
*/
|
|
94
|
-
export function writeChapterSummaries(project, results, now) {
|
|
95
|
-
const summaryByIndex = new Map();
|
|
96
|
-
for (const result of results) {
|
|
97
|
-
for (const chapter of result.chapters)
|
|
98
|
-
summaryByIndex.set(chapter.chapterIndex, chapter.summary);
|
|
99
|
-
}
|
|
100
|
-
if (summaryByIndex.size === 0)
|
|
101
|
-
return 0;
|
|
102
|
-
const tree = readOutlineFile(project.root);
|
|
103
|
-
let changed = 0;
|
|
104
|
-
for (const entry of deriveChapterOrder(project.root)) {
|
|
105
|
-
const summary = summaryByIndex.get(entry.chapterNumber);
|
|
106
|
-
if (summary === undefined)
|
|
107
|
-
continue;
|
|
108
|
-
const node = findOutlineNode(tree, entry.chapterId);
|
|
109
|
-
if (node === undefined || node.summary === summary)
|
|
110
|
-
continue;
|
|
111
|
-
updateOutlineNode(tree, entry.chapterId, { summary }, now);
|
|
112
|
-
changed++;
|
|
113
|
-
}
|
|
114
|
-
if (changed > 0)
|
|
115
|
-
writeOutlineFile(project.root, tree);
|
|
116
|
-
return changed;
|
|
117
|
-
}
|
|
118
|
-
/** 报告章条目(章序 = 文件位置序;标题取大纲节点、摘要取批结果、长度取正文投影) */
|
|
119
|
-
export function reportChapters(project, results) {
|
|
120
|
-
const order = deriveChapterOrder(project.root);
|
|
121
|
-
const tree = readOutlineFile(project.root);
|
|
122
|
-
const lengths = getDocumentTextLengths(project.db, "chapter", order.map((entry) => entry.chapterId));
|
|
123
|
-
const summaryByIndex = new Map();
|
|
124
|
-
for (const result of results) {
|
|
125
|
-
for (const chapter of result.chapters)
|
|
126
|
-
summaryByIndex.set(chapter.chapterIndex, chapter.summary);
|
|
127
|
-
}
|
|
128
|
-
return order.map((entry) => ({
|
|
129
|
-
index: entry.chapterNumber,
|
|
130
|
-
title: findOutlineNode(tree, entry.chapterId)?.title ?? "",
|
|
131
|
-
summary: summaryByIndex.get(entry.chapterNumber) ?? "",
|
|
132
|
-
length: lengths.get(entry.chapterId) ?? 0,
|
|
133
|
-
}));
|
|
134
|
-
}
|
|
135
|
-
/**
|
|
136
|
-
* S3 + S4 一轮:别名归并调用 → 四步纯管线 → 三路比对 → 落库(实体 / 关系 / 章摘要 / 报告)→ 写 `merge_written`。
|
|
137
|
-
* 幂等由三路比对保证(同一份批结果跑两遍:实体/关系数量不变,报告更新同一条);**跨轮 baseline** =
|
|
138
|
-
* 历史全部 job 的 `merge_written` 并集(§6.1)⇒ 续拆时同名行复用 + 增量更新,跨轮未重现的产物不软删。
|
|
139
|
-
* 调用方(runner 收口路径)负责 job 状态;本模块只写业务数据与清单。
|
|
140
|
-
*/
|
|
141
|
-
export async function runDecomposeMerge(input) {
|
|
142
|
-
const jobs = listDecomposeJobs(input.project.db);
|
|
143
|
-
// 历史 job 全保留(§7.1)⇒ 目标 job 按 id 取,不取「最新一行」(多 job 并存时后者可能不是本轮)
|
|
144
|
-
const job = jobs.find((row) => row.id === input.jobId);
|
|
145
|
-
if (job === undefined)
|
|
146
|
-
throw new Error(`拆解 job 不存在:${input.jobId}`);
|
|
147
|
-
const baseline = mergeBaselineOf(jobs);
|
|
148
|
-
const doneSeqs = listDecomposeBatches(input.project.db, input.jobId)
|
|
149
|
-
.filter((batch) => batch.status === "done")
|
|
150
|
-
.map((batch) => batch.seq);
|
|
151
|
-
const results = doneBatchResults(input.project.db, input.jobId, doneSeqs);
|
|
152
|
-
const aliasGroups = await completeAliasGroups(input.session, results); // S3 第 2 层:全书一次
|
|
153
|
-
const outcome = mergeCandidates(results, aliasGroups); // S3 第 1 层:四步有序纯管线
|
|
154
|
-
const mentions = aggregateMentions(results);
|
|
155
|
-
writeChapterSummaries(input.project, results, input.now);
|
|
156
|
-
const snapshot = readMergeSnapshot(input.project, baseline);
|
|
157
|
-
const reportName = decomposeReportName(input.project.config.name);
|
|
158
|
-
const products = buildMergeProducts({ outcome, mentions, aliasGroups, snapshot, reportName });
|
|
159
|
-
const plan = planMergeWrite([...products.values()].map((product) => ({ key: product.key, id: product.id })), job.merge_written, [...snapshot.rows.values()], [...baseline.values()]);
|
|
160
|
-
const facts = {
|
|
161
|
-
bookName: input.project.config.name,
|
|
162
|
-
...reportFacts({
|
|
163
|
-
outcome,
|
|
164
|
-
mentions,
|
|
165
|
-
aliasGroups,
|
|
166
|
-
chapters: reportChapters(input.project, results),
|
|
167
|
-
batchCount: listDecomposeBatches(input.project.db, input.jobId).length,
|
|
168
|
-
keptUserEdited: keptUserEditedLabels(input.project, plan, job.merge_written),
|
|
169
|
-
}),
|
|
170
|
-
};
|
|
171
|
-
const reportText = buildDecomposeReportText(facts, await completeReportPlot(input.session, facts.chapters));
|
|
172
|
-
// 落库与清单写在同一事务:中途失败(如用户手工建了同一枚关系 ⇒ RELATION_EXISTS)整体回滚,
|
|
173
|
-
// 不留「行已写、清单没记」的半成品——那会让下一轮把同一产物当新产物再创建一遍(幂等破口)
|
|
174
|
-
const entries = withTransaction(input.project.db, () => {
|
|
175
|
-
const writtenEntries = executeMergePlan({
|
|
176
|
-
project: input.project,
|
|
177
|
-
plan,
|
|
178
|
-
products,
|
|
179
|
-
written: job.merge_written,
|
|
180
|
-
entityByName: snapshot.entityByName,
|
|
181
|
-
reportName,
|
|
182
|
-
reportText,
|
|
183
|
-
now: input.now,
|
|
184
|
-
});
|
|
185
|
-
writeMergeWritten(input.project.db, input.jobId, writtenEntries, input.now);
|
|
186
|
-
return writtenEntries;
|
|
187
|
-
});
|
|
188
|
-
const summary = {
|
|
189
|
-
entities: outcome.entities.length,
|
|
190
|
-
relations: outcome.relations.length,
|
|
191
|
-
aliasGroups: aliasGroups.length,
|
|
192
|
-
reportId: entries.find((entry) => entry.type === DECOMPOSE_REPORT_ENTITY_TYPE)?.id ?? "",
|
|
193
|
-
};
|
|
194
|
-
// 过程条目(不参与 LLM 上下文):归并计数与报告落点——批表没有的信息(§8 时间线口径)
|
|
195
|
-
input.session.log({
|
|
196
|
-
kind: "merge_done",
|
|
197
|
-
text: `归并完成:实体 ${summary.entities} / 关系 ${summary.relations} / 别名组 ${summary.aliasGroups}`,
|
|
198
|
-
});
|
|
199
|
-
input.session.log({ kind: "report_done", text: `报告完成:${reportName}(id=${summary.reportId})` });
|
|
200
|
-
return summary;
|
|
201
|
-
}
|
|
202
|
-
// ============ S3 别名归并调用(§6 第 2 层;全书一次) ============
|
|
203
|
-
/** 别名归并调用:LLM 只看候选清单;命中硬校验的组才生效(缺依据的组按保守原则直接丢弃) */
|
|
204
|
-
async function completeAliasGroups(session, results) {
|
|
205
|
-
const all = buildAliasCandidates(results);
|
|
206
|
-
const candidates = selectAliasCandidates(all);
|
|
207
|
-
if (all.length > candidates.length) {
|
|
208
|
-
console.warn(`[decompose] 别名候选超出 ${DECOMPOSE_ALIAS_CANDIDATE_MAX} 条,按提及次数截断后归并`);
|
|
209
|
-
}
|
|
210
|
-
const { text } = await session.complete(aliasPrompt(candidates));
|
|
211
|
-
const proposed = proposedGroupsOf(parseModelJson(text));
|
|
212
|
-
const validation = validateAliasGroups(proposed, candidates);
|
|
213
|
-
if (validation.rejected.length > 0) {
|
|
214
|
-
console.warn(`[decompose] 别名组丢弃:${validation.rejected.map((item) => item.reason).join(";")}`);
|
|
215
|
-
}
|
|
216
|
-
// 只把 accepted 传进 applyAliasGroups(mergeCandidates 不重跑校验;原始组可以发明实体名)
|
|
217
|
-
return proposed.filter((group) => validation.accepted.includes(group));
|
|
218
|
-
}
|
|
219
|
-
/** 别名归并提示词(数值一律由常量插值,不在散文里复述) */
|
|
220
|
-
function aliasPrompt(candidates) {
|
|
221
|
-
return {
|
|
222
|
-
system: [
|
|
223
|
-
"你是小说拆解流水线的别名归并员。输入是全书人物候选清单(规范名 + 出现章数 + 首次出现章 + 短摘要)。",
|
|
224
|
-
"任务:找出**同一人物**的多个名字,输出合并组。输出必须是 JSON(可以放 ```json 围栏),不要写解释文字。",
|
|
225
|
-
"",
|
|
226
|
-
'输出结构:{"groups":[{"canonical":"规范名","aliases":["别名"],"reason":"依据"}]}',
|
|
227
|
-
"",
|
|
228
|
-
"硬性口径:",
|
|
229
|
-
"- canonical 与 aliases 里的每个名字都必须是输入清单里出现过的名字,不得发明新名字;",
|
|
230
|
-
`- 每组名字数(含规范名)在 ${DECOMPOSE_ALIAS_GROUP_MIN_NAMES} 到 ${DECOMPOSE_ALIAS_GROUP_MAX} 之间;`,
|
|
231
|
-
"- 同一个名字不能出现在两组里;",
|
|
232
|
-
"- 只合并能确定的(称号、昵称、称谓、简称等有摘要支撑的同一个人);**不确定就不要合并**,宁可有重复;",
|
|
233
|
-
"- 每组必须有非空 reason(判定依据);没有依据的组会被丢弃;",
|
|
234
|
-
"- 没有可合并的名字就输出 {\"groups\":[]}。",
|
|
235
|
-
].join("\n"),
|
|
236
|
-
user: candidates
|
|
237
|
-
.map((candidate) => `- ${candidate.name}(出现 ${candidate.chapterCount} 章,首见第 ${candidate.firstChapter} 章):${candidate.summary === "" ? "无摘要" : candidate.summary}`)
|
|
238
|
-
.join("\n"),
|
|
239
|
-
};
|
|
240
|
-
}
|
|
241
|
-
/** 解析别名组提案:只收形状完整(规范名 + 别名数组 + 非空依据)的组(§6 保守原则:缺依据直接丢弃) */
|
|
242
|
-
function proposedGroupsOf(raw) {
|
|
243
|
-
const groups = asRecord(raw)?.groups;
|
|
244
|
-
if (!Array.isArray(groups))
|
|
245
|
-
return [];
|
|
246
|
-
const proposals = [];
|
|
247
|
-
for (const item of groups) {
|
|
248
|
-
const record = asRecord(item);
|
|
249
|
-
const canonical = asText(record?.canonical);
|
|
250
|
-
const reason = asText(record?.reason);
|
|
251
|
-
const aliases = Array.isArray(record?.aliases) ? record.aliases.map(asText).filter(isPresent) : [];
|
|
252
|
-
if (canonical === null || reason === null || aliases.length === 0)
|
|
253
|
-
continue;
|
|
254
|
-
proposals.push({ canonical, aliases, reason });
|
|
255
|
-
}
|
|
256
|
-
return proposals;
|
|
257
|
-
}
|
|
258
|
-
/**
|
|
259
|
-
* 跨轮比对面(§6.1):**历史全部 job** 的 `merge_written` 并集——同一 id 取**最新一次**记录
|
|
260
|
-
* (job 按创建时间升序读出 ⇒ 后者覆盖)。本 job 清单另存(它是软删面:只有本 job 写过的行才允许软删)。
|
|
261
|
-
*/
|
|
262
|
-
function mergeBaselineOf(jobs) {
|
|
263
|
-
const baseline = new Map();
|
|
264
|
-
for (const job of jobs)
|
|
265
|
-
for (const entry of job.merge_written)
|
|
266
|
-
baseline.set(entry.id, entry);
|
|
267
|
-
return baseline;
|
|
268
|
-
}
|
|
269
|
-
/**
|
|
270
|
-
* 库内快照(§6.1 三路比对的第三路):实体按 `类型:归一化名`、关系按 `源 id + 靶 id + 类型` 索引;
|
|
271
|
-
* 报告行按 baseline 里 `DECOMPOSE_REPORT_ENTITY_TYPE` 的条目回读(§6.2「跨轮更新同一条」)。
|
|
272
|
-
* **软删行不进快照**(回收站旧行不参与比对:产物重现时按新行创建,不复活旧行)。
|
|
273
|
-
*/
|
|
274
|
-
function readMergeSnapshot(project, baseline) {
|
|
275
|
-
const snapshot = {
|
|
276
|
-
rows: new Map(),
|
|
277
|
-
entityIdOf: new Map(),
|
|
278
|
-
relationIdOf: new Map(),
|
|
279
|
-
entityByName: new Map(),
|
|
280
|
-
nameById: new Map(),
|
|
281
|
-
reportId: null,
|
|
282
|
-
};
|
|
283
|
-
for (const type of DECOMPOSE_ENTITY_KINDS) {
|
|
284
|
-
for (const entity of listAllLiveEntities(project.db, type)) {
|
|
285
|
-
const name = normalizeEntityName(entity.name);
|
|
286
|
-
snapshot.rows.set(entity.id, { id: entity.id, type: entity.type, updated_at: entity.updatedAt, deleted_at: null });
|
|
287
|
-
snapshot.entityIdOf.set(entityIdentity(entity.type, name), entity.id);
|
|
288
|
-
// 同名跨类型(如设定「龙」与人物「龙」)时按 DECOMPOSE_ENTITY_KINDS 顺序取先者:关系候选只有名字,无类型
|
|
289
|
-
if (!snapshot.entityByName.has(name))
|
|
290
|
-
snapshot.entityByName.set(name, { id: entity.id, type: entity.type });
|
|
291
|
-
snapshot.nameById.set(entity.id, name);
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
const { relations } = listRelations(project.db, {}, 1, project.root);
|
|
295
|
-
for (const relation of relations) {
|
|
296
|
-
const row = getRelation(project.db, relation.id, project.root); // 列表投影不带 updated_at,按 id 补齐
|
|
297
|
-
if (row === null)
|
|
298
|
-
continue;
|
|
299
|
-
snapshot.rows.set(row.id, {
|
|
300
|
-
id: row.id,
|
|
301
|
-
type: DECOMPOSE_MERGE_RELATION_TYPE,
|
|
302
|
-
updated_at: row.updated_at,
|
|
303
|
-
deleted_at: null,
|
|
304
|
-
});
|
|
305
|
-
const source = snapshot.nameById.get(row.source_id);
|
|
306
|
-
const target = snapshot.nameById.get(row.target_id);
|
|
307
|
-
// 端点不在三类实体里(大纲节点 / 其他类型实体)⇒ 不是拆解写的关系,不参与比对
|
|
308
|
-
if (source === undefined || target === undefined)
|
|
309
|
-
continue;
|
|
310
|
-
snapshot.relationIdOf.set(relationIdentity(source, target, row.relation_type), row.id);
|
|
311
|
-
}
|
|
312
|
-
const reportId = reportEntryId(baseline.values());
|
|
313
|
-
const report = reportId === null ? null : getEntity(project.db, reportId);
|
|
314
|
-
if (report !== null) {
|
|
315
|
-
snapshot.rows.set(report.id, { id: report.id, type: report.type, updated_at: report.updated_at, deleted_at: null });
|
|
316
|
-
snapshot.reportId = report.id;
|
|
317
|
-
}
|
|
318
|
-
return snapshot;
|
|
319
|
-
}
|
|
320
|
-
/**
|
|
321
|
-
* 报告实体 id(baseline 里类型为报告实体的条目):**最新一次记录胜出**(报告被用户删掉后重跑会另建
|
|
322
|
-
* 新行,那时并集里会有两条 reference 记录;取最旧的一条会每轮重复建行)。从未写过 → null。
|
|
323
|
-
*/
|
|
324
|
-
function reportEntryId(entries) {
|
|
325
|
-
let reportId = null;
|
|
326
|
-
for (const entry of entries)
|
|
327
|
-
if (entry.type === DECOMPOSE_REPORT_ENTITY_TYPE)
|
|
328
|
-
reportId = entry.id;
|
|
329
|
-
return reportId;
|
|
330
|
-
}
|
|
331
|
-
/**
|
|
332
|
-
* 某类型的**全部**活实体(`listEntities` 每页上限 `MAX_ENTITY_LIST_LIMIT` ⇒ 分页取全):
|
|
333
|
-
* 两个调用面都要「全库」,不得只看首页——S3 的同名复用(这里)与起始快照的 `snapshotKnownNames` 全集
|
|
334
|
-
* (`runner.ts` 的 `readStartSnapshot`);两处各写一份必然漂移,故共用本实现。
|
|
335
|
-
*/
|
|
336
|
-
export function listAllLiveEntities(db, type) {
|
|
337
|
-
const entities = [];
|
|
338
|
-
for (let offset = 0;; offset += MAX_ENTITY_LIST_LIMIT) {
|
|
339
|
-
const page = listEntities(db, { type, limit: MAX_ENTITY_LIST_LIMIT, offset });
|
|
340
|
-
entities.push(...page.items);
|
|
341
|
-
if (page.items.length === 0 || entities.length >= page.total)
|
|
342
|
-
return entities;
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
/**
|
|
346
|
-
* 新产物清单(归并身份 → 应写入项):实体 / 关系 / 报告。
|
|
347
|
-
* 关系端点名字解析不到库内实体 → 跳过(防御:悬空关系已被纯管线滤掉,此处只在异常输入下触发)。
|
|
348
|
-
*/
|
|
349
|
-
function buildMergeProducts(input) {
|
|
350
|
-
const products = new Map();
|
|
351
|
-
const aliasesOf = aliasNotesOf(input.aliasGroups);
|
|
352
|
-
for (const candidate of input.outcome.entities) {
|
|
353
|
-
const key = entityIdentity(candidate.type, candidate.name);
|
|
354
|
-
products.set(key, {
|
|
355
|
-
kind: "entity",
|
|
356
|
-
key,
|
|
357
|
-
id: input.snapshot.entityIdOf.get(key) ?? null,
|
|
358
|
-
candidate,
|
|
359
|
-
data: entityDataOf(input.mentions.get(key), aliasesOf.get(candidate.name) ?? []),
|
|
360
|
-
});
|
|
361
|
-
}
|
|
362
|
-
for (const relation of input.outcome.relations) {
|
|
363
|
-
// 端点名字在此**不解析为 id**:本轮新建实体要到计划执行时才有 id(端点解析见 createRow)。
|
|
364
|
-
// 身份只用归一化名字,故能与上一轮写的活关系对上(关系端点只有名字,见 relationIdentity)。
|
|
365
|
-
const source = normalizeEntityName(relation.source);
|
|
366
|
-
const target = normalizeEntityName(relation.target);
|
|
367
|
-
const key = relationIdentity(source, target, relation.type);
|
|
368
|
-
products.set(key, { kind: "relation", key, id: input.snapshot.relationIdOf.get(key) ?? null, relation, source, target });
|
|
369
|
-
}
|
|
370
|
-
products.set(REPORT_PRODUCT_KEY, { kind: "report", key: REPORT_PRODUCT_KEY, id: input.snapshot.reportId });
|
|
371
|
-
return products;
|
|
372
|
-
}
|
|
373
|
-
/** 规范名(归一化)→ 并入该人物的别名(别名组里除规范名以外的名字;只并人物) */
|
|
374
|
-
function aliasNotesOf(groups) {
|
|
375
|
-
const aliasesOf = new Map();
|
|
376
|
-
for (const group of groups) {
|
|
377
|
-
const canonical = normalizeEntityName(group.canonical);
|
|
378
|
-
const aliases = group.aliases.map(normalizeEntityName).filter((name) => name !== "" && name !== canonical);
|
|
379
|
-
aliasesOf.set(canonical, [...new Set([...(aliasesOf.get(canonical) ?? []), ...aliases])]);
|
|
380
|
-
}
|
|
381
|
-
return aliasesOf;
|
|
382
|
-
}
|
|
383
|
-
/** 实体落库 data:首个非空字段 + description(最长的一条,别名并进「(又称:…)」) */
|
|
384
|
-
function entityDataOf(aggregate, noteAliases) {
|
|
385
|
-
const data = { ...(aggregate?.data ?? {}) };
|
|
386
|
-
const description = appendAliasNote(aggregate?.description ?? "", noteAliases);
|
|
387
|
-
if (description !== "")
|
|
388
|
-
data.description = description;
|
|
389
|
-
return data;
|
|
390
|
-
}
|
|
391
|
-
/**
|
|
392
|
-
* 按写入计划落库,返回新的 `merge_written` 清单:
|
|
393
|
-
* - `create` → 建行(实体 / 关系 / 报告),记新行的 `updated_at`;
|
|
394
|
-
* - `reuse` → 复用库内行 + **增量更新**(见 reuseRow);
|
|
395
|
-
* - `keep-user-edited` / `keep-and-report` → **不动**(用户编辑优先 / 分歧保留);清单条目沿用本 job 清单里那条
|
|
396
|
-
* (跨轮条目本就不在本 job 清单里 ⇒ 不认领,不越权把用户行当作本管线所写);
|
|
397
|
-
* - `soft-delete` → 实体软删(回收站可还原)/ 关系物理删,条目从清单移除(旧行不再参与比对)。
|
|
398
|
-
*/
|
|
399
|
-
function executeMergePlan(input) {
|
|
400
|
-
const { project, plan, products, written, now } = input;
|
|
401
|
-
// 名字索引隔离到本轮:随本轮新建实体增长(关系端点解析要用;实体条目在计划里排在关系条目之前)
|
|
402
|
-
const execution = { ...input, entityByName: new Map(input.entityByName) };
|
|
403
|
-
const writtenById = new Map(written.map((entry) => [entry.id, entry]));
|
|
404
|
-
const entries = [];
|
|
405
|
-
for (const item of plan) {
|
|
406
|
-
const previous = item.id === null ? undefined : writtenById.get(item.id);
|
|
407
|
-
if (item.action === "keep-user-edited" || item.action === "keep-and-report") {
|
|
408
|
-
if (previous !== undefined)
|
|
409
|
-
entries.push(previous);
|
|
410
|
-
continue;
|
|
411
|
-
}
|
|
412
|
-
if (item.action === "soft-delete") {
|
|
413
|
-
if (previous !== undefined)
|
|
414
|
-
dropRow(project, previous, now);
|
|
415
|
-
continue;
|
|
416
|
-
}
|
|
417
|
-
const product = item.key === null ? undefined : products.get(item.key);
|
|
418
|
-
if (product === undefined) {
|
|
419
|
-
// 计划与产物同源:缺了即调用方 bug。宁可不写也不写错行,但不静默(§9「删除不静默」同口径)
|
|
420
|
-
console.warn(`[decompose] 写入计划缺产物,跳过:${item.action} ${item.key ?? item.id ?? ""}`);
|
|
421
|
-
continue;
|
|
422
|
-
}
|
|
423
|
-
if (item.action === "reuse") {
|
|
424
|
-
const entry = reuseRow(project, product, execution, previous);
|
|
425
|
-
if (entry !== null)
|
|
426
|
-
entries.push(entry);
|
|
427
|
-
continue;
|
|
428
|
-
}
|
|
429
|
-
const entry = createRow(project, product, execution);
|
|
430
|
-
if (entry !== null)
|
|
431
|
-
entries.push(entry);
|
|
432
|
-
}
|
|
433
|
-
return entries;
|
|
434
|
-
}
|
|
435
|
-
/**
|
|
436
|
-
* `reuse`:库内行是上一轮(本 job 清单 / 跨轮 baseline 记录值一致)写的 ⇒ 复用并**增量更新**(§6 第 3 层)。
|
|
437
|
-
* - 实体:把本轮 data 并进库内现值(口径见 `mergeEntityData`),**无变化则不写**(避免 `updated_at` 抖动);
|
|
438
|
-
* - **写了一定要记新版本戳**:漏了它下一轮会把我们自己的写入误判成「用户编辑过」,从此不再更新该行;
|
|
439
|
-
* - 跨轮行(本 job 清单里没有)**首次写入时认领**:清单自此覆盖它(下一次它被重跑去掉就归软删面管);
|
|
440
|
-
* - 报告:正文每轮重建(重跑后章摘要 / 人物小传可能已变);关系:无可增量字段(复用即不动)。
|
|
441
|
-
* @returns 清单条目(没写且本 job 清单里也没有该行 → null,不凭空认领)
|
|
442
|
-
*/
|
|
443
|
-
function reuseRow(project, product, execution, previous) {
|
|
444
|
-
if (product.id === null)
|
|
445
|
-
return previous ?? null;
|
|
446
|
-
if (product.kind === "report") {
|
|
447
|
-
writeReportDocument(project.db, product.id, execution.reportText, execution.now);
|
|
448
|
-
return previous ?? entityEntry(project.db, product.id);
|
|
449
|
-
}
|
|
450
|
-
if (product.kind === "entity") {
|
|
451
|
-
const current = getEntity(project.db, product.id);
|
|
452
|
-
if (current !== null) {
|
|
453
|
-
const data = mergeEntityData(current.data, product.data);
|
|
454
|
-
if (data !== current.data) {
|
|
455
|
-
const next = updateEntity(project.db, product.id, { data });
|
|
456
|
-
if (next !== null)
|
|
457
|
-
return { id: next.id, type: next.type, updated_at: next.updated_at };
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
return previous ?? null;
|
|
462
|
-
}
|
|
463
|
-
/** 库内实体行的清单条目(报告正文重建后认领它;行不可见 → null) */
|
|
464
|
-
function entityEntry(db, id) {
|
|
465
|
-
const row = getEntity(db, id);
|
|
466
|
-
return row === null ? null : { id: row.id, type: row.type, updated_at: row.updated_at };
|
|
467
|
-
}
|
|
468
|
-
/**
|
|
469
|
-
* 增量更新(§6 第 3 层):把本轮实体 data 并进库内现值。**无变化时返回入参本身**(同一引用)
|
|
470
|
-
* —— 调用方据此判「不写」(`updated_at` 同时是 baseline 比对依据)。
|
|
471
|
-
* - `description`:剥掉「(又称:…)」段后**取更长者**(与 `entityDataOf` 的「最长的一条」同口径),
|
|
472
|
-
* 别名段取**并集**(不丢上一轮别名);
|
|
473
|
-
* - 数组字段(`personality` / `tags` / `rules`):**并集**(库内在前 ⇒ 顺序稳定,重跑幂等);
|
|
474
|
-
* - `role`:本轮**非空则覆盖**(续拆后定位可能变化);
|
|
475
|
-
* - 其余字段(`gender` / `age` / `race` / `alias` / `type` / `motivation` …):**仅库内为空时填**。
|
|
476
|
-
*/
|
|
477
|
-
export function mergeEntityData(current, incoming) {
|
|
478
|
-
let next = null;
|
|
479
|
-
const write = (field, value) => {
|
|
480
|
-
next ??= { ...current };
|
|
481
|
-
next[field] = value;
|
|
482
|
-
};
|
|
483
|
-
for (const [field, value] of Object.entries(incoming)) {
|
|
484
|
-
if (field === "description" || value === undefined)
|
|
485
|
-
continue;
|
|
486
|
-
const existing = current[field];
|
|
487
|
-
if (Array.isArray(existing) && Array.isArray(value)) {
|
|
488
|
-
const union = [...new Set([...existing, ...value])];
|
|
489
|
-
if (union.length !== existing.length)
|
|
490
|
-
write(field, union);
|
|
491
|
-
continue;
|
|
492
|
-
}
|
|
493
|
-
if (field === "role" ? isFilled(value) : !isFilled(existing)) {
|
|
494
|
-
if (existing !== value)
|
|
495
|
-
write(field, value);
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
const previousDescription = textOf(current.description);
|
|
499
|
-
const description = mergeDescription(previousDescription, textOf(incoming.description));
|
|
500
|
-
if (description !== previousDescription)
|
|
501
|
-
write("description", description);
|
|
502
|
-
return next ?? current;
|
|
503
|
-
}
|
|
504
|
-
/** description 的「更长者胜 + 别名并集」(比较基准 = 剥掉别名段后的正文) */
|
|
505
|
-
function mergeDescription(previous, incoming) {
|
|
506
|
-
const before = splitAliasNote(previous);
|
|
507
|
-
const after = splitAliasNote(incoming);
|
|
508
|
-
const base = after.base.length > before.base.length ? after.base : before.base;
|
|
509
|
-
return appendAliasNote(base, [...new Set([...before.aliases, ...after.aliases])]);
|
|
510
|
-
}
|
|
511
|
-
/** 拆开 `appendAliasNote` 拼上的「(又称:…)」段;不是该形态 → 整段当正文(别名取不到) */
|
|
512
|
-
function splitAliasNote(description) {
|
|
513
|
-
const index = description.lastIndexOf(DECOMPOSE_ALIAS_NOTE_PREFIX);
|
|
514
|
-
if (index < 0 || !description.endsWith(")"))
|
|
515
|
-
return { base: description, aliases: [] };
|
|
516
|
-
const aliases = description
|
|
517
|
-
.slice(index + DECOMPOSE_ALIAS_NOTE_PREFIX.length, -1)
|
|
518
|
-
.split(DECOMPOSE_ALIAS_NOTE_SEPARATOR)
|
|
519
|
-
.filter((alias) => alias !== "");
|
|
520
|
-
return { base: description.slice(0, index), aliases };
|
|
521
|
-
}
|
|
522
|
-
/** 非空值(空串 / 空数组 / null / undefined 视同未填)——「库内为空才填」的判据 */
|
|
523
|
-
function isFilled(value) {
|
|
524
|
-
if (value === undefined || value === null)
|
|
525
|
-
return false;
|
|
526
|
-
if (typeof value === "string")
|
|
527
|
-
return value.trim() !== "";
|
|
528
|
-
if (Array.isArray(value))
|
|
529
|
-
return value.length > 0;
|
|
530
|
-
return true;
|
|
531
|
-
}
|
|
532
|
-
function textOf(value) {
|
|
533
|
-
return typeof value === "string" ? value : "";
|
|
534
|
-
}
|
|
535
|
-
/**
|
|
536
|
-
* `create`:建行(实体 / 关系 / 报告 + 报告块文档);关系端点在此解析(本轮新建的实体此时才有 id)。
|
|
537
|
-
* 关系端点解析失败(名字不在落库实体集合内)→ 跳过该关系并返回 null(不写清单:从未落库的条目不该进比对面)。
|
|
538
|
-
*/
|
|
539
|
-
function createRow(project, product, execution) {
|
|
540
|
-
if (product.kind === "entity") {
|
|
541
|
-
const row = createEntity(project.db, { type: product.candidate.type, name: product.candidate.name, data: product.data });
|
|
542
|
-
execution.entityByName.set(normalizeEntityName(row.name), { id: row.id, type: row.type });
|
|
543
|
-
return { id: row.id, type: row.type, updated_at: row.updated_at };
|
|
544
|
-
}
|
|
545
|
-
if (product.kind === "relation") {
|
|
546
|
-
const source = execution.entityByName.get(product.source);
|
|
547
|
-
const target = execution.entityByName.get(product.target);
|
|
548
|
-
if (source === undefined || target === undefined) {
|
|
549
|
-
console.warn(`[decompose] 关系端点未落库,跳过写入:${product.source}→${product.target}(${product.relation.type})`);
|
|
550
|
-
return null;
|
|
551
|
-
}
|
|
552
|
-
const row = createRelation(project.db, {
|
|
553
|
-
sourceType: source.type,
|
|
554
|
-
sourceId: source.id,
|
|
555
|
-
targetType: target.type,
|
|
556
|
-
targetId: target.id,
|
|
557
|
-
relationType: product.relation.type,
|
|
558
|
-
}, project.root);
|
|
559
|
-
return { id: row.id, type: DECOMPOSE_MERGE_RELATION_TYPE, updated_at: row.updated_at };
|
|
560
|
-
}
|
|
561
|
-
const row = createEntity(project.db, {
|
|
562
|
-
type: DECOMPOSE_REPORT_ENTITY_TYPE,
|
|
563
|
-
name: execution.reportName,
|
|
564
|
-
data: { type: DECOMPOSE_REPORT_KIND, tags: [DECOMPOSE_REPORT_KIND] },
|
|
565
|
-
});
|
|
566
|
-
writeReportDocument(project.db, row.id, execution.reportText, execution.now);
|
|
567
|
-
return { id: row.id, type: row.type, updated_at: row.updated_at };
|
|
568
|
-
}
|
|
569
|
-
/**
|
|
570
|
-
* `soft-delete`:消失行。
|
|
571
|
-
* 实体 → 软删(回收站可还原);关系 → 物理删(本仓关系没有回收站路径,见文件头口径)。
|
|
572
|
-
*/
|
|
573
|
-
function dropRow(project, entry, now) {
|
|
574
|
-
if (entry.type === DECOMPOSE_MERGE_RELATION_TYPE)
|
|
575
|
-
deleteRelation(project.db, entry.id);
|
|
576
|
-
else
|
|
577
|
-
softDeleteEntity(project.db, entry.id, now);
|
|
578
|
-
}
|
|
579
|
-
/** 报告正文落块文档(服务端按行拆段落块,不做 markdown 语义转换;投影从块重算) */
|
|
580
|
-
function writeReportDocument(db, reportId, text, now) {
|
|
581
|
-
const blocks = paragraphBlocksOf(text);
|
|
582
|
-
upsertDocument(db, {
|
|
583
|
-
ownerKind: "reference",
|
|
584
|
-
ownerId: reportId,
|
|
585
|
-
content: JSON.stringify(blocks),
|
|
586
|
-
contentText: blocksToPlainMd(blocks),
|
|
587
|
-
now,
|
|
588
|
-
});
|
|
589
|
-
}
|
|
590
|
-
/** 报告事实:全部来自服务端确定性数据(人物小传取落库人物,第 6 段取阈值/悬空过滤的产物) */
|
|
591
|
-
function reportFacts(input) {
|
|
592
|
-
return {
|
|
593
|
-
chapters: input.chapters,
|
|
594
|
-
batchCount: input.batchCount,
|
|
595
|
-
characters: charactersOf(input.outcome.entities, input.mentions),
|
|
596
|
-
chapterLengths: input.chapters.map((chapter) => ({ index: chapter.index, title: chapter.title, length: chapter.length })),
|
|
597
|
-
aliasGroups: input.aliasGroups,
|
|
598
|
-
filteredCharacters: input.outcome.filtered.characters.map((candidate) => candidate.name),
|
|
599
|
-
filteredRelations: input.outcome.filtered.relations.map(relationText),
|
|
600
|
-
keptUserEdited: input.keptUserEdited,
|
|
601
|
-
};
|
|
602
|
-
}
|
|
603
|
-
/** 落库人物小传:`role` + `description` + 出现章范围(数据取提及素材,与实体落库同源) */
|
|
604
|
-
function charactersOf(entities, mentions) {
|
|
605
|
-
return entities
|
|
606
|
-
.filter((entity) => entity.type === "character")
|
|
607
|
-
.map((entity) => {
|
|
608
|
-
const aggregate = mentions.get(entityIdentity(entity.type, entity.name));
|
|
609
|
-
const role = aggregate?.data.role;
|
|
610
|
-
return {
|
|
611
|
-
name: entity.name,
|
|
612
|
-
role: typeof role === "string" ? role : "",
|
|
613
|
-
description: aggregate?.description ?? "",
|
|
614
|
-
chapters: entity.chapters,
|
|
615
|
-
};
|
|
616
|
-
});
|
|
617
|
-
}
|
|
618
|
-
/** `keep-and-report` 的行标签(用户手工编辑过、产物里已消失 ⇒ 保留不动,报告里提示) */
|
|
619
|
-
function keptUserEditedLabels(project, plan, written) {
|
|
620
|
-
const writtenById = new Map(written.map((entry) => [entry.id, entry]));
|
|
621
|
-
return plan
|
|
622
|
-
.filter((item) => item.action === "keep-and-report" && item.id !== null)
|
|
623
|
-
.map((item) => rowLabel(project, writtenById.get(item.id)));
|
|
624
|
-
}
|
|
625
|
-
/** 行标签:实体 = 名字(类别);关系 = 两端名字 + 类型(读不到 → 退回 id,不静默丢行) */
|
|
626
|
-
function rowLabel(project, entry) {
|
|
627
|
-
if (entry === undefined)
|
|
628
|
-
return "";
|
|
629
|
-
if (entry.type === DECOMPOSE_MERGE_RELATION_TYPE) {
|
|
630
|
-
const relation = getRelation(project.db, entry.id, project.root);
|
|
631
|
-
if (relation === null)
|
|
632
|
-
return entry.id;
|
|
633
|
-
const source = getEntity(project.db, relation.source_id)?.name ?? relation.source_id;
|
|
634
|
-
const target = getEntity(project.db, relation.target_id)?.name ?? relation.target_id;
|
|
635
|
-
return `${source}→${target}(${relation.relation_type})`;
|
|
636
|
-
}
|
|
637
|
-
const entity = getEntity(project.db, entry.id);
|
|
638
|
-
return entity === null ? entry.id : `${entity.name}(${entity.type})`;
|
|
639
|
-
}
|
|
640
|
-
// ============ 模块私有小工具 ============
|
|
641
|
-
/** 归并身份:实体 = `类别:归一化名`(与 merge.ts 的写入计划 key 文档同口径) */
|
|
642
|
-
function entityIdentity(type, name) {
|
|
643
|
-
return `${type}:${name}`;
|
|
644
|
-
}
|
|
645
|
-
/** 归并身份:关系 = 两端**归一化名字** + 类型(新产物在端点实体落库前就要能算出身份,见 buildMergeProducts) */
|
|
646
|
-
function relationIdentity(source, target, subject) {
|
|
647
|
-
return `${source}\u0000${target}\u0000${subject}`;
|
|
648
|
-
}
|
|
649
|
-
function relationText(relation) {
|
|
650
|
-
return `${relation.source}→${relation.target}(${relation.type})`;
|
|
651
|
-
}
|
|
652
|
-
/** 超长截断(模型爱加首尾空白;上限 = 抽取层的 description 上限) */
|
|
653
|
-
function clipText(text) {
|
|
654
|
-
const trimmed = text.trim();
|
|
655
|
-
return trimmed.length <= DECOMPOSE_DESCRIPTION_MAX_CHARS ? trimmed : trimmed.slice(0, DECOMPOSE_DESCRIPTION_MAX_CHARS);
|
|
656
|
-
}
|
|
657
|
-
/** 模型输出不可信:非对象一律当缺字段 */
|
|
658
|
-
function asRecord(value) {
|
|
659
|
-
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
660
|
-
}
|
|
661
|
-
/** 非空字符串(trim 后非空)→ 原值;其余当缺字段 */
|
|
662
|
-
function asText(value) {
|
|
663
|
-
if (typeof value !== "string")
|
|
664
|
-
return null;
|
|
665
|
-
return value.trim() === "" ? null : value;
|
|
666
|
-
}
|
|
667
|
-
function isPresent(value) {
|
|
668
|
-
return value !== null;
|
|
669
|
-
}
|
|
670
|
-
//# sourceMappingURL=merge-write.js.map
|