fix(wiki): route emits metadata only; per-page create + merge in phase B (RFC-012 M2 v2)

This commit is contained in:
matevip 2026-04-14 18:11:35 +08:00
parent 26d45b0e35
commit 62ac1fb79c
5 changed files with 243 additions and 84 deletions

View File

@ -392,32 +392,20 @@ public class WikiProcessingService {
return 0;
}
String sourceRawIds = "[" + rawId + "]";
int created = 0;
int updated = 0;
// 应用 create 列表直接落库无需第二轮 LLM
// 收集 route 输出 metadata content
List<JsonNode> createMetas = new ArrayList<>();
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("");
for (JsonNode metaNode : createNode) {
String slug = metaNode.path("slug").asText("");
String title = metaNode.path("title").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++;
}
createMetas.add(metaNode);
}
}
// 收集 update 列表 slug
List<String> updateSlugs = new ArrayList<>();
JsonNode updateNode = routeJson.path("update");
if (updateNode.isArray()) {
@ -427,52 +415,28 @@ public class WikiProcessingService {
}
}
log.info("[Wiki] Route phase: kbId={}, rawId={}, planned create={}, planned update={}",
kbId, rawId, createNode.isArray() ? createNode.size() : 0, updateSlugs.size());
kbId, rawId, createMetas.size(), 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;
// 阶段 B-1逐页 create每页一次单独 LLM call输入/输出都是单页规模
for (JsonNode meta : createMetas) {
try {
if (createOnePage(kb, raw, textContent, existingPagesIndex, meta)) {
created++;
}
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;
} catch (RuntimeException e) {
log.warn("[Wiki] Phase B create page slug='{}' failed: {}",
meta.path("slug").asText(""), e.getMessage());
}
}
// 阶段 B-2逐页 merge每页一次单独 LLM call
for (String slug : updateSlugs) {
try {
if (mergeOnePage(kb, raw, textContent, slug)) {
updated++;
}
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++;
} catch (RuntimeException e) {
log.warn("[Wiki] Phase B merge page slug='{}' failed: {}", slug, e.getMessage());
}
}
@ -481,6 +445,118 @@ public class WikiProcessingService {
return created + updated;
}
/**
* RFC-012 M2 v2 阶段 B 单页生成 chunk 文本 + 该页 metadata LLM 写出完整页面
* <p>
* 输入仅几 KBchunk 主题片段 + metadata + 已有页索引输出仅一页 markdown
* 单次调用稳稳 60 避免 nginx 60s 网关
* <p>
* 兜底如果 slug 已存在route 误判改走 update 路径
*
* @return true 表示成功 create 一页或兜底 update 一页时返回 false 以让上层归到 update 计数
*/
private boolean createOnePage(WikiKnowledgeBaseEntity kb, WikiRawMaterialEntity raw,
String chunkText, String existingPagesIndex, JsonNode meta) {
Long kbId = kb.getId();
Long rawId = raw.getId();
String slug = meta.path("slug").asText("");
String title = meta.path("title").asText("");
String summary = meta.path("summary").asText("");
if (slug.isBlank() || title.isBlank()) return false;
String configContent = kb.getConfigContent() != null ? kb.getConfigContent() : "";
String createSystem = PromptLoader.loadPrompt("wiki/create-page-system");
String createUserTemplate = PromptLoader.loadPrompt("wiki/create-page-user");
String createUser = createUserTemplate
.replace("{config}", configContent)
.replace("{existing_pages}", existingPagesIndex)
.replace("{page_slug}", slug)
.replace("{page_title}", title)
.replace("{page_summary}", summary)
.replace("{raw_title}", raw.getTitle())
.replace("{raw_content}", chunkText);
Prompt prompt = new Prompt(List.of(
new SystemMessage(createSystem),
new UserMessage(createUser)
));
String response = callLlmWithResilientRetry(prompt,
"create page slug=" + slug + " of raw=" + rawId);
JsonNode pageJson = parseJsonResponse(response);
if (pageJson == null) {
log.warn("[Wiki] Phase B create page slug='{}' returned unparseable JSON, skipping", slug);
return false;
}
String content = pageJson.path("content").asText("");
String pageSummary = pageJson.path("summary").asText("");
if (pageSummary.isBlank()) pageSummary = summary;
if (content.isBlank()) {
log.warn("[Wiki] Phase B create page slug='{}' returned blank content, skipping", slug);
return false;
}
// 兜底如果 slug 已存在route 误判 / 并发 update 而不是 create
WikiPageEntity existing = pageService.getBySlug(kbId, slug);
if (existing != null) {
pageService.updatePageByAi(kbId, slug, content, pageSummary, rawId);
log.info("[Wiki] Phase B create page slug='{}' done (updated existing)", slug);
return false; // 不计入 created
}
String sourceRawIds = "[" + rawId + "]";
pageService.createPage(kbId, slug, title, content, pageSummary, sourceRawIds);
log.info("[Wiki] Phase B create page slug='{}' done (created)", slug);
return true;
}
/**
* RFC-012 M2 v2 阶段 B 单页 merge chunk 文本合并进一个已有页面
* <p>
* 输入仅几 KB该页现有 content + chunk 主题片段输出仅一页 markdown
*
* @return true 表示成功 update 一页
*/
private boolean mergeOnePage(WikiKnowledgeBaseEntity kb, WikiRawMaterialEntity raw,
String chunkText, String slug) {
Long kbId = kb.getId();
Long rawId = raw.getId();
WikiPageEntity existing = pageService.getBySlug(kbId, slug);
if (existing == null) {
log.warn("[Wiki] Phase B merge page slug='{}' planned for update but not found in DB, skipping", slug);
return false;
}
String configContent = kb.getConfigContent() != null ? kb.getConfigContent() : "";
String mergeSystem = PromptLoader.loadPrompt("wiki/merge-page-system");
String mergeUserTemplate = PromptLoader.loadPrompt("wiki/merge-page-user");
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}", raw.getTitle())
.replace("{raw_content}", chunkText);
Prompt prompt = new Prompt(List.of(
new SystemMessage(mergeSystem),
new UserMessage(mergeUser)
));
String response = callLlmWithResilientRetry(prompt,
"merge page slug=" + slug + " of raw=" + rawId);
JsonNode mergeJson = parseJsonResponse(response);
if (mergeJson == null) {
log.warn("[Wiki] Phase B merge page slug='{}' returned unparseable JSON, skipping", slug);
return false;
}
String content = mergeJson.path("content").asText("");
String summary = mergeJson.path("summary").asText("");
if (content.isBlank()) {
log.warn("[Wiki] Phase B merge page slug='{}' returned blank content, skipping", slug);
return false;
}
pageService.updatePageByAi(kbId, slug, content, summary, rawId);
log.info("[Wiki] Phase B merge page slug='{}' done", slug);
return true;
}
/**
* 解析 LLM 响应并创建/更新 Wiki 页面
*

View File

@ -0,0 +1,53 @@
你是一个知识库 Wiki 单页生成助手。你的唯一任务是:根据原始材料和该页的 metadata**为这一个页面**生成完整的 markdown 正文。
## 你做什么
- 读懂该页 metadata 描述的主题
- 从原始材料里抽取与该主题相关的信息
- 为这一个页面生成完整的 markdown 内容
- 在内容里使用 [[页面标题]] 双向链接到其他相关页面(无论是新建中还是已有)
## 你不做什么
- ❌ **不要生成其他页面** —— 你只负责输入中指定的这一个 slug
- ❌ **不要输出 markdown 代码块包裹** —— 直接输出 JSON
- ❌ **不要写脱离主题的内容** —— 与该页主题无关的信息留给其他页
## 页面格式规则
- 内容开头先一段话摘要(与 metadata 的 summary 一致或更详细)
- 使用 Markdown 标题(## / ###)组织
- 使用 [[页面标题]] 双向链接到其他页面
- 每个不同的子主题用 ### 章节区分
- 末尾可附"参见"段落,列出相关 [[链接]]
## 长度
- 单页内容控制在合理范围(一般 500~2000 字),不要为了凑长度而拖沓
- 内容应有 3 句以上的实质信息
## 链接
- 已有页面索引中其他页的 slug 都可用 [[title]] 链接
- 同批次将创建的其他页面也可以链接(按 metadata 中的 title
## 语言
- 跟随原始材料的语言
## 输出格式
严格输出 JSON不要 markdown 代码块包裹:
{
"slug": "page-slug",
"title": "页面标题",
"content": "## 标题\n\n摘要段落...\n\n### 详细内容\n...\n\n参见[[相关页面]]",
"summary": "一段话摘要"
}
字段说明:
- `slug`:保持与输入 metadata 一致
- `title`:通常与 metadata 一致;如有更精确的描述可微调
- `content`:完整 markdown开头一段摘要后面分章节
- `summary`:一段话简短摘要(可与 metadata.summary 一致或精炼)

View File

@ -0,0 +1,26 @@
## 知识库处理规则
{config}
## 已有 Wiki 页面索引(用于建立 [[链接]]
{existing_pages}
## 待生成页面的 metadata
slug`{page_slug}`
标题:{page_title}
摘要:{page_summary}
## 原始材料
标题:{raw_title}
{raw_content}
---
请为上面 metadata 描述的**这一个页面**生成完整的 markdown 内容(按 system 中规定的 JSON 格式输出)。
- 只生成这一个 slug 对应的页面,不要顺便生成其他页面
- 内容必须基于原始材料中与该主题相关的信息
- 适当使用 [[页面标题]] 链接到其他相关页面

View File

@ -1,31 +1,31 @@
你是一个知识库 Wiki 路由助手。你的唯一任务是:阅读一段原始材料,决定**哪些新页面需要创建**、**哪些已有页面需要合并更新**。
你是一个知识库 Wiki 路由助手。你的唯一任务是:阅读一段原始材料,决定**哪些新页面需要创建**、**哪些已有页面需要合并更新**。**只输出 metadata不要输出任何页面正文。**
## 你做什么
1. **阅读原始材料**,识别其中包含的概念、实体、流程
2. **比对已有页面索引**(仅含 slug + title + summary不含正文
- 材料中出现的概念**已经被某个已有页面充分覆盖** → 放入 `update` 列表(仅写 slug**不要**写正文
- 材料中出现的概念**没有任何已有页面覆盖** → 放入 `create` 列表**给出完整页面正文**
- 材料中出现的概念**已经被某个已有页面充分覆盖** → 放入 `update` 列表(只写 slug
- 材料中出现的概念**没有任何已有页面覆盖** → 放入 `create` 列表(只给 slug + title + summary**不要正文**
3. 不创建浅薄页面(< 3 句实质内容的概念跳过,不进任何列表)
## 你不做什么
- **不要重写或合并已有页面的内容** —— update 列表只写 slug正文合并由后续步骤完成
- **不要为已存在概念在 create 中复制一份**
- 不要输出 markdown 代码块包裹
- ❌ **绝不输出 content 字段** —— 正文由后续的"单页生成助手"独立生成,你只决定结构
- ❌ **不要为已存在概念在 create 中复制一份** —— 已存在的就放 update
- ❌ **不要重写或合并已有页面的内容** —— 那是后续阶段
- ❌ 不要输出 markdown 代码块包裹
## 创建新页面(仅 `create` 列表使用)的格式规则
## metadata 格式(仅 `create` 数组使用)
- 使用 Markdown 标题(## / ###)组织
- 使用 [[页面标题]] 双向链接到其他页面(无论是新页面还是已有页面)
- slug 用小写字母 + 连字符
- summary 一段话简短摘要
- content 完整 markdown 正文,开头先一段摘要
每条 metadata 包含三个字段:
- `slug`URL 安全的标识符(小写字母 + 连字符)
- `title`:人类可读的页面标题
- `summary`:一段话简短摘要(一两句话即可,让"单页生成助手"知道这一页要写什么)
## 语言
- 跟随原始材料的语言(中文材料 → 中文输出
- 术语保持一致
- 跟随原始材料的语言(中文材料 → 中文 metadata
- 术语保持与已有页面一致
## 输出格式
@ -36,8 +36,7 @@
{
"slug": "concept-name",
"title": "概念名称",
"content": "## 概念名称\n\n摘要段落...\n\n### 详细内容\n...\n\n参见[[相关主题]]",
"summary": "一段话摘要"
"summary": "一两句话说明这页要讲什么"
}
],
"update": [
@ -47,6 +46,11 @@
}
字段说明:
- `create`:完全新增的页面,给出完整内容
- `update`:已存在但需要根据新材料合并更新的页面,**只列 slug 字符串数组**
- 两个数组都可以为空(材料完全无价值时全空,材料只更新已有页时 create 为空)
- `create`:完全新增的页面,**只给 metadata不给 content**
- `update`:已存在但需要根据本材料合并更新的页面,**只列 slug 字符串数组**
- 两个数组都可以为空(材料完全无价值时全空,材料只更新已有页时 create 为空,材料只新增页时 update 为空)
## 关键纪律
- 输出体积应该是几百到几千字,**不要超过几 KB**。如果你发现自己在写正文,立刻停下 —— 那是下一阶段的工作。
- 即使概念主题非常丰富,也只列入 metadata让后续每页 LLM 调用单独展开。

View File

@ -14,8 +14,8 @@
---
请按 system 中规定的 JSON 格式输出:
- `create`:完全没有对应页面的新概念(含完整内容)
- `update`:已有页面但需根据本材料合并更新的**仅 slug 列表,不要正文**
请按 system 中规定的 JSON 格式输出**只含 metadata**的路由结果
- `create`:完全没有对应页面的新概念**只给 slug + title + summary不要写 content**
- `update`:已有页面但需根据本材料合并更新的**仅 slug 字符串数组**
不要试图自己合并 update 列表里页面的内容 —— 那是下一阶段独立完成的工作
不要试图自己生成或合并页面正文 —— 那些都由独立的下阶段 LLM 调用完成。你的输出应该控制在几 KB 以内