feat(wiki): two-phase digest — route + per-page merge (RFC-012 M2)

This commit is contained in:
matevip 2026-04-14 17:39:50 +08:00
parent a369e8055d
commit 4fb6a83eb6
6 changed files with 283 additions and 5 deletions

View File

@ -78,7 +78,7 @@ public class WikiProperties {
* 是否启用两阶段消化路由 逐页 merge
* <p>
* RFC-012 M2true 时单 chunk LLM 输出量大幅缩减避免 nginx 60s 网关超时
* 默认 false 保持向后兼容M2 实现完成后切到 true
* 默认 trueM2 上线遇问题可在 application.yml mate.wiki.use-two-phase-digest=false 回退到旧行为
*/
private boolean useTwoPhaseDigest = false;
private boolean useTwoPhaseDigest = true;
}

View File

@ -326,8 +326,12 @@ public class WikiProcessingService {
*/
private int processChunk(WikiKnowledgeBaseEntity kb, WikiRawMaterialEntity raw, String textContent,
String existingPagesIndex) {
// RFC-012 M2两阶段消化路由 逐页 merge单次 LLM 调用输出量大幅缩减避免 nginx 60s 网关超时
if (properties.isUseTwoPhaseDigest()) {
return processChunkTwoPhase(kb, raw, textContent, existingPagesIndex);
}
// 加载 prompt 模板
// 旧路径单次调用让 LLM 同时处理新建 + 全量 merge输出爆炸易触发 504
String systemPrompt = PromptLoader.loadPrompt("wiki/digest-system");
String userTemplate = PromptLoader.loadPrompt("wiki/digest-user");
@ -337,17 +341,146 @@ public class WikiProcessingService {
.replace("{raw_title}", raw.getTitle())
.replace("{raw_content}", textContent);
// 调用 LLM带无限重试仅在模型不可用时终止
Prompt prompt = new Prompt(List.of(
new SystemMessage(systemPrompt),
new UserMessage(userPrompt)
));
String llmResponse = callLlmWithResilientRetry(prompt, "chunk of raw=" + raw.getId());
// 解析并持久化页面
return applyLlmResponse(kb.getId(), raw.getId(), llmResponse);
}
/**
* RFC-012 M2 两阶段消化
* <p>
* 阶段 Aroute一次 LLM 调用决定要 create 哪些新页 + update 哪些已有页 slug 列表
* 输入小输出短单次稳定在 30s 内返回
* <p>
* 阶段 Bmerge update 列表里的每个 slug 单独发 LLM 调用输入只塞这一页的现有正文 + 当前
* chunk 文本输出该页 merge 后的完整内容每次调用单页规模远不会触发 nginx 60s 超时
* <p>
* 新建页直接落库merge 页因互不依赖可在当前 chunk virtual thread 内顺序处理chunk 之间
* 已通过 maxParallelChunks Semaphore 拿到了并行度
*
* @return 创建+更新的页面数
*/
private int processChunkTwoPhase(WikiKnowledgeBaseEntity kb, WikiRawMaterialEntity raw,
String textContent, String existingPagesIndex) {
Long kbId = kb.getId();
Long rawId = raw.getId();
String configContent = kb.getConfigContent() != null ? kb.getConfigContent() : "";
String rawTitle = raw.getTitle();
// 阶段 A路由
String routeSystem = PromptLoader.loadPrompt("wiki/route-system");
String routeUserTemplate = PromptLoader.loadPrompt("wiki/route-user");
String routeUser = routeUserTemplate
.replace("{config}", configContent)
.replace("{existing_pages}", existingPagesIndex)
.replace("{raw_title}", rawTitle)
.replace("{raw_content}", textContent);
Prompt routePrompt = new Prompt(List.of(
new SystemMessage(routeSystem),
new UserMessage(routeUser)
));
String routeResponse = callLlmWithResilientRetry(routePrompt, "route chunk of raw=" + rawId);
JsonNode routeJson = parseJsonResponse(routeResponse);
if (routeJson == null) {
log.warn("[Wiki] Route phase: failed to parse JSON for kbId={}, rawId={}, responseLen={}, first200={}",
kbId, rawId, routeResponse != null ? routeResponse.length() : 0,
routeResponse != null ? routeResponse.substring(0, Math.min(200, routeResponse.length())) : "null");
return 0;
}
String sourceRawIds = "[" + rawId + "]";
int created = 0;
int updated = 0;
// 应用 create 列表直接落库无需第二轮 LLM
JsonNode createNode = routeJson.path("create");
if (createNode.isArray()) {
for (JsonNode pageNode : createNode) {
String slug = pageNode.path("slug").asText("");
String title = pageNode.path("title").asText("");
String content = pageNode.path("content").asText("");
String summary = pageNode.path("summary").asText("");
if (slug.isBlank() || title.isBlank()) continue;
WikiPageEntity existing = pageService.getBySlug(kbId, slug);
if (existing != null) {
// LLM 误把已存在 slug 放进 create update 路径兜底
pageService.updatePageByAi(kbId, slug, content, summary, rawId);
updated++;
} else {
pageService.createPage(kbId, slug, title, content, summary, sourceRawIds);
created++;
}
}
}
// 收集 update 列表 slug
List<String> updateSlugs = new ArrayList<>();
JsonNode updateNode = routeJson.path("update");
if (updateNode.isArray()) {
for (JsonNode slugNode : updateNode) {
String slug = slugNode.asText("");
if (!slug.isBlank()) updateSlugs.add(slug);
}
}
log.info("[Wiki] Route phase: kbId={}, rawId={}, planned create={}, planned update={}",
kbId, rawId, createNode.isArray() ? createNode.size() : 0, updateSlugs.size());
// 阶段 B逐页 merge
if (!updateSlugs.isEmpty()) {
String mergeSystem = PromptLoader.loadPrompt("wiki/merge-page-system");
String mergeUserTemplate = PromptLoader.loadPrompt("wiki/merge-page-user");
for (String slug : updateSlugs) {
WikiPageEntity existing = pageService.getBySlug(kbId, slug);
if (existing == null) {
log.warn("[Wiki] Merge phase: slug '{}' planned for update but not found in DB, skipping", slug);
continue;
}
String mergeUser = mergeUserTemplate
.replace("{config}", configContent)
.replace("{page_slug}", existing.getSlug() != null ? existing.getSlug() : slug)
.replace("{page_title}", existing.getTitle() != null ? existing.getTitle() : "")
.replace("{page_last_updated_by}", existing.getLastUpdatedBy() != null ? existing.getLastUpdatedBy() : "ai")
.replace("{page_content}", existing.getContent() != null ? existing.getContent() : "")
.replace("{raw_title}", rawTitle)
.replace("{raw_content}", textContent);
Prompt mergePrompt = new Prompt(List.of(
new SystemMessage(mergeSystem),
new UserMessage(mergeUser)
));
String mergeResponse;
try {
mergeResponse = callLlmWithResilientRetry(mergePrompt,
"merge page slug=" + slug + " of raw=" + rawId);
} catch (RuntimeException e) {
// 单页 merge 失败不影响其他页面 chunk 继续
log.warn("[Wiki] Merge phase: slug '{}' failed: {}", slug, e.getMessage());
continue;
}
JsonNode mergeJson = parseJsonResponse(mergeResponse);
if (mergeJson == null) {
log.warn("[Wiki] Merge phase: slug '{}' returned unparseable JSON, skipping", slug);
continue;
}
String content = mergeJson.path("content").asText("");
String summary = mergeJson.path("summary").asText("");
if (content.isBlank()) {
log.warn("[Wiki] Merge phase: slug '{}' returned blank content, skipping", slug);
continue;
}
pageService.updatePageByAi(kbId, slug, content, summary, rawId);
updated++;
}
}
log.info("[Wiki] Two-phase digest applied: kbId={}, rawId={}, created={}, updated={}",
kbId, rawId, created, updated);
return created + updated;
}
/**
* 解析 LLM 响应并创建/更新 Wiki 页面
*

View File

@ -0,0 +1,47 @@
你是一个知识库 Wiki 单页合并助手。你的唯一任务是:把一段新材料合并进**一个已有的 Wiki 页面**,输出该页面更新后的完整内容。
## 你做什么
- 读懂现有页面的内容结构
- 从新材料里抽取与该页面主题相关的信息
- **合并**新旧信息(不是简单替换、不是追加),保留双向链接
- 输出**这一个页面**更新后的**完整 markdown**
## 你不做什么
- **不要创建其他页面** —— 你只负责一个页面
- **不要输出 diff 或增量** —— 必须输出完整内容
- **不要输出 markdown 代码块包裹** —— 直接输出 JSON
- 不要删除已有页面中仍然有效的信息
## 合并规则
- 新旧信息有冲突 → 在新内容中明确标注矛盾点
- 新材料对该页面无新增信息 → 输出原 content 即可(保持不变)
- 已有页面 lastUpdatedBy=manual → 仍然合并,但优先保留手动编辑的措辞和结构,仅追加新事实
## 链接
- 沿用已有页面里的 [[页面标题]] 双向链接
- 如果新材料引出了对其他已知概念的引用,新增 [[…]] 链接
## 语言
- 跟随原始材料和已有页面的语言
## 输出格式
严格输出 JSON不要 markdown 代码块:
{
"slug": "existing-slug",
"title": "页面标题(可微调)",
"content": "## 标题\n\n摘要...\n\n### 章节...\n\n参见[[相关]]",
"summary": "更新后的一段话摘要"
}
字段说明:
- `slug`:保持与输入一致
- `title`:通常保持不变;只有当新材料明确改变了页面主题时才改
- `content`:完整 markdown包含原内容中仍然有效的部分 + 来自新材料的新增信息
- `summary`:更新后的一段话摘要

View File

@ -0,0 +1,25 @@
## 知识库处理规则
{config}
## 待合并的现有 Wiki 页面
slug`{page_slug}`
标题:{page_title}
最近编辑者:{page_last_updated_by}
### 现有内容
{page_content}
## 新材料
标题:{raw_title}
{raw_content}
---
请把新材料中与本页面主题相关的信息合并进**这一个页面**,输出完整的 JSON按 system 规则)。
- 如果新材料对本页面无新增信息:原 content 原样返回即可。
- 不要输出其他页面,不要 diff不要追加。

View File

@ -0,0 +1,52 @@
你是一个知识库 Wiki 路由助手。你的唯一任务是:阅读一段原始材料,决定**哪些新页面需要创建**、**哪些已有页面需要合并更新**。
## 你做什么
1. **阅读原始材料**,识别其中包含的概念、实体、流程
2. **比对已有页面索引**(仅含 slug + title + summary不含正文
- 材料中出现的概念**已经被某个已有页面充分覆盖** → 放入 `update` 列表(仅写 slug**不要**写正文)
- 材料中出现的概念**没有任何已有页面覆盖** → 放入 `create` 列表,**给出完整页面正文**
3. 不创建浅薄页面(< 3 句实质内容的概念跳过,不进任何列表)
## 你不做什么
- **不要重写或合并已有页面的内容** —— update 列表只写 slug正文合并由后续步骤完成
- **不要为已存在概念在 create 中复制一份**
- 不要输出 markdown 代码块包裹
## 创建新页面(仅 `create` 列表使用)的格式规则
- 使用 Markdown 标题(## / ###)组织
- 使用 [[页面标题]] 双向链接到其他页面(无论是新页面还是已有页面)
- slug 用小写字母 + 连字符
- summary 一段话简短摘要
- content 完整 markdown 正文,开头先一段摘要
## 语言
- 跟随原始材料的语言(中文材料 → 中文输出)
- 术语保持一致
## 输出格式
严格输出 JSON不要包含 markdown 代码块:
{
"create": [
{
"slug": "concept-name",
"title": "概念名称",
"content": "## 概念名称\n\n摘要段落...\n\n### 详细内容\n...\n\n参见[[相关主题]]",
"summary": "一段话摘要"
}
],
"update": [
"existing-slug-1",
"existing-slug-2"
]
}
字段说明:
- `create`:完全新增的页面,给出完整内容
- `update`:已存在但需要根据新材料合并更新的页面,**只列 slug 字符串数组**
- 两个数组都可以为空(材料完全无价值时全空,材料只更新已有页时 create 为空)

View File

@ -0,0 +1,21 @@
## 知识库处理规则
{config}
## 已有 Wiki 页面索引slug + 摘要)
{existing_pages}
## 待消化的原始材料
标题:{raw_title}
{raw_content}
---
请按 system 中规定的 JSON 格式输出:
- `create`:完全没有对应页面的新概念(含完整内容)
- `update`:已有页面但需根据本材料合并更新的(**仅 slug 列表,不要正文**
不要试图自己合并 update 列表里页面的内容 —— 那是下一阶段独立完成的工作。