From 62ac1fb79c57cedacfb2461c6ab5abbe433978bd Mon Sep 17 00:00:00 2001 From: matevip Date: Tue, 14 Apr 2026 18:11:35 +0800 Subject: [PATCH] fix(wiki): route emits metadata only; per-page create + merge in phase B (RFC-012 M2 v2) --- .../wiki/service/WikiProcessingService.java | 198 ++++++++++++------ .../prompts/wiki/create-page-system.txt | 53 +++++ .../prompts/wiki/create-page-user.txt | 26 +++ .../resources/prompts/wiki/route-system.txt | 42 ++-- .../resources/prompts/wiki/route-user.txt | 8 +- 5 files changed, 243 insertions(+), 84 deletions(-) create mode 100644 mateclaw-server/src/main/resources/prompts/wiki/create-page-system.txt create mode 100644 mateclaw-server/src/main/resources/prompts/wiki/create-page-user.txt diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java index 658d5ae9..956c2718 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java @@ -392,32 +392,20 @@ public class WikiProcessingService { return 0; } - String sourceRawIds = "[" + rawId + "]"; int created = 0; int updated = 0; - // 应用 create 列表(直接落库,无需第二轮 LLM) + // ─── 收集 route 输出(仅 metadata,无 content) ─── + List 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 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 写出完整页面。 + *

+ * 输入仅几 KB(chunk 主题片段 + metadata + 已有页索引),输出仅一页 markdown, + * 单次调用稳稳 ≤ 60 秒,避免 nginx 60s 网关。 + *

+ * 兜底:如果 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 文本合并进一个已有页面。 + *

+ * 输入仅几 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 页面 * diff --git a/mateclaw-server/src/main/resources/prompts/wiki/create-page-system.txt b/mateclaw-server/src/main/resources/prompts/wiki/create-page-system.txt new file mode 100644 index 00000000..d0d48eb9 --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/wiki/create-page-system.txt @@ -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 一致或精炼) diff --git a/mateclaw-server/src/main/resources/prompts/wiki/create-page-user.txt b/mateclaw-server/src/main/resources/prompts/wiki/create-page-user.txt new file mode 100644 index 00000000..94d4da36 --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/wiki/create-page-user.txt @@ -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 对应的页面,不要顺便生成其他页面 +- 内容必须基于原始材料中与该主题相关的信息 +- 适当使用 [[页面标题]] 链接到其他相关页面 diff --git a/mateclaw-server/src/main/resources/prompts/wiki/route-system.txt b/mateclaw-server/src/main/resources/prompts/wiki/route-system.txt index 8aaaec4a..0f6769c4 100644 --- a/mateclaw-server/src/main/resources/prompts/wiki/route-system.txt +++ b/mateclaw-server/src/main/resources/prompts/wiki/route-system.txt @@ -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 调用单独展开。 diff --git a/mateclaw-server/src/main/resources/prompts/wiki/route-user.txt b/mateclaw-server/src/main/resources/prompts/wiki/route-user.txt index b2b119e8..b05cbcdf 100644 --- a/mateclaw-server/src/main/resources/prompts/wiki/route-user.txt +++ b/mateclaw-server/src/main/resources/prompts/wiki/route-user.txt @@ -14,8 +14,8 @@ --- -请按 system 中规定的 JSON 格式输出: -- `create`:完全没有对应页面的新概念(含完整内容) -- `update`:已有页面但需根据本材料合并更新的(**仅 slug 列表,不要正文**) +请按 system 中规定的 JSON 格式输出**只含 metadata**的路由结果: +- `create`:完全没有对应页面的新概念,**只给 slug + title + summary,不要写 content** +- `update`:已有页面但需根据本材料合并更新的,**仅 slug 字符串数组** -不要试图自己合并 update 列表里页面的内容 —— 那是下一阶段独立完成的工作。 +不要试图自己生成或合并页面正文 —— 那些都由独立的下阶段 LLM 调用完成。你的输出应该控制在几 KB 以内。