diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java index 98e21576..d67906bf 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java @@ -274,6 +274,23 @@ public class WikiController { return R.ok(); } + @RequireWorkspaceRole("admin") + @Operation(summary = "按当前 pageType profile 重新分类已有页面(异步,不改内容)") + @PostMapping("/knowledge-bases/{id}/reclassify") + public R> reclassifyKB(@PathVariable Long id, + @RequestBody(required = false) Map body, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(id, workspaceId); + Long modelId = null; + if (body != null && body.get("modelId") != null) { + modelId = Long.valueOf(String.valueOf(body.get("modelId"))); + } + int queued = processingService.reclassifyKB(id, modelId); + Map out = new LinkedHashMap<>(); + out.put("queued", queued); + return R.ok(out); + } + // ==================== Directory Scan ==================== @RequireWorkspaceRole("member") diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java index 9645c0ba..7f085eac 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java @@ -368,6 +368,27 @@ public class WikiPageService { .set(WikiPageEntity::getProfileVersion, profileVersion)); } + /** + * Reclassify a page in place: set only its pageType (and, when supplied, + * its knowledge layer) via a partial update. Content / summary / links are + * never touched, so this is safe to run as a bulk backfill after a KB's + * pageType profile changes. {@code pageType} is stored lowercase; a null / + * blank pageType is ignored. A null layer is left untouched. + */ + public void updatePageType(Long pageId, String pageType, String knowledgeLayer) { + if (pageId == null || pageType == null || pageType.isBlank()) { + return; + } + com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper w = + new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper() + .eq(WikiPageEntity::getId, pageId) + .set(WikiPageEntity::getPageType, pageType.toLowerCase()); + if (knowledgeLayer != null && !knowledgeLayer.isBlank()) { + w.set(WikiPageEntity::getKnowledgeLayer, knowledgeLayer); + } + pageMapper.update(null, w); + } + /** Set only a page's knowledge layer via a partial update (leaves depends_on untouched). */ public void setKnowledgeLayer(Long pageId, String knowledgeLayer) { if (pageId == null || knowledgeLayer == null) { 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 ab1ef38f..78565c63 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 @@ -559,6 +559,98 @@ public class WikiProcessingService { return pending.size(); } + /** + * Re-classify every non-system page in a KB against its current pageType + * profile, without touching page content. Used after a profile edit so + * existing pages migrate into newly-added types instead of staying frozen + * on whatever type the original ingest assigned. Per page this runs one + * lightweight classify-only LLM call (title + summary in, a single + * page_type out), normalises the answer through the profile, and writes + * pageType + knowledge layer via a partial update. + * + *

Runs asynchronously on {@link #WIKI_EXECUTOR}; returns the number of + * pages queued. Progress + completion are broadcast on {@link WikiProgressBus} + * so the UI can surface it the same way it does ingest progress. + * + * @param kbId target KB + * @param modelId optional explicit model; {@code null} uses the KB's routed + * CREATE_PAGE model (falling back to the system default) + * @return number of pages queued for reclassification + */ + public int reclassifyKB(Long kbId, Long modelId) { + if (kbId == null) { + throw new IllegalArgumentException("kbId is required"); + } + if (pageTypeProfileService == null) { + throw new IllegalStateException("pageType profile service unavailable"); + } + List pages = pageService.listByKbId(kbId).stream() + .filter(p -> !"system".equalsIgnoreCase(String.valueOf(p.getPageType()))) + .toList(); + if (pages.isEmpty()) { + return 0; + } + + // Resolve the classifying model once up front. An explicit modelId wins; + // otherwise route as a CREATE_PAGE step, falling back to the default. + final ChatModel chatModel; + if (modelId != null && modelRoutingService != null) { + chatModel = modelRoutingService.buildChatModel(modelId); + } else { + chatModel = resolveChatModel(kbId, vip.mate.wiki.job.WikiJobStep.CREATE_PAGE).chatModel; + } + + final String systemPrompt = PromptLoader.loadPrompt("wiki/classify-page-system") + .replace("{allowed_page_types}", pageTypeProfileService.describeForPrompt(kbId)); + final String userTemplate = PromptLoader.loadPrompt("wiki/classify-page-user"); + final int total = pages.size(); + + WIKI_EXECUTOR.submit(() -> { + int done = 0; + int changed = 0; + for (WikiPageEntity page : pages) { + done++; + try { + String summary = page.getSummary() == null ? "" : page.getSummary(); + String userPrompt = userTemplate + .replace("{title}", page.getTitle() == null ? "" : page.getTitle()) + .replace("{summary}", summary); + ChatResponse resp = chatModel.call(new Prompt(List.of( + new SystemMessage(systemPrompt), new UserMessage(userPrompt)))); + String text = (resp == null || resp.getResult() == null + || resp.getResult().getOutput() == null) + ? null : resp.getResult().getOutput().getText(); + String proposed = null; + JsonNode json = parseJsonResponse(text); + if (json != null) { + proposed = json.path("page_type").asText(""); + } + // Normalise through the profile: an unknown / blank answer + // downgrades to the profile fallback, never null. + String newType = pageTypeProfileService.normalizePageType(kbId, proposed); + if (newType != null && !newType.isBlank() + && !newType.equalsIgnoreCase(String.valueOf(page.getPageType()))) { + String layer = pageTypeProfileService.resolveLayer(kbId, newType); + pageService.updatePageType(page.getId(), newType, layer); + changed++; + } + } catch (Exception e) { + log.warn("[Wiki] reclassify failed pageId={} kbId={}: {}", + page.getId(), kbId, e.getMessage()); + } finally { + progressBus.broadcast(kbId, WikiProgressBus.EVENT_CHUNK_DONE, + java.util.Map.of("kind", "reclassify", "done", done, "total", total)); + } + } + log.info("[Wiki] reclassifyKB done kbId={} pages={} changed={}", kbId, total, changed); + progressBus.broadcast(kbId, WikiProgressBus.EVENT_RAW_COMPLETED, + java.util.Map.of("kind", "reclassify", "done", total, "total", total, "changed", changed)); + }); + + log.info("[Wiki] reclassifyKB queued {} page(s) for kbId={} (modelId={})", total, kbId, modelId); + return total; + } + /** * 处理知识库中所有待处理的原始材料 *

diff --git a/mateclaw-server/src/main/resources/prompts/wiki/classify-page-system.txt b/mateclaw-server/src/main/resources/prompts/wiki/classify-page-system.txt new file mode 100644 index 00000000..36a5153e --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/wiki/classify-page-system.txt @@ -0,0 +1,14 @@ +You are a knowledge-base page classifier. Your only job is to assign an +existing wiki page to exactly ONE page type from the allowed list below. + +Allowed page types for this knowledge base: +{allowed_page_types} + +Rules: +- Pick the single best-fitting type for the page based on its title and summary. +- You MUST choose a type from the allowed list. Do not invent new types. +- If nothing fits well, choose the most general / fallback type available. +- Do NOT rewrite, summarize, translate or otherwise change the page content. + +Respond with a single minified JSON object and nothing else: +{"page_type": ""} diff --git a/mateclaw-server/src/main/resources/prompts/wiki/classify-page-user.txt b/mateclaw-server/src/main/resources/prompts/wiki/classify-page-user.txt new file mode 100644 index 00000000..3b193c22 --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/wiki/classify-page-user.txt @@ -0,0 +1,8 @@ +Classify the following wiki page. + +Title: {title} + +Summary: +{summary} + +Return only: {"page_type": ""} diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 222028f7..74fb0b2a 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -923,6 +923,8 @@ export const wikiApi = { http.post(`/wiki/knowledge-bases/${kbId}/page-type-profile/validate`, { config }), resetPageTypeProfile: (kbId: string | number) => http.post(`/wiki/knowledge-bases/${kbId}/page-type-profile/reset-default`), + reclassifyKB: (kbId: string | number, modelId?: number | null) => + http.post(`/wiki/knowledge-bases/${kbId}/reclassify`, modelId != null ? { modelId } : {}), // ---- Agent pageType permissions (REQ-3) ---- listPageTypePermissions: (kbId: string | number, agentId: string | number) => diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 820ce98d..7171fcad 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -2200,6 +2200,13 @@ export default { reset: 'Reset to default', resetConfirm: 'Reset to the built-in default profile? Your custom config will be cleared.', }, + reclassify: { + desc: 'Re-classify existing pages against the current profile: only the page type is updated, the body is untouched. Use it after editing the profile (adding/removing types) to migrate old pages to the new categories.', + button: 'Re-classify existing pages', + confirmTitle: 'Re-classify existing pages', + confirmMsg: 'Each non-system page in this knowledge base will be sent to the LLM one by one to re-determine its category. This may take time and incur costs. System pages are unaffected. Continue?', + started: 'Re-classification started; page categories will update once it finishes.', + }, layers: { tab: 'Layers & Stale', title: 'Knowledge Layers & Stale State', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index fe610224..7db288a5 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -2212,6 +2212,13 @@ export default { reset: '重置为默认', resetConfirm: '确认重置为内置默认 profile?当前自定义配置会被清除。', }, + reclassify: { + desc: '按当前 profile 重新分类已有页面:仅更新页面分类(pageType),不改动正文。改了 profile(新增/删除类型)后用它把旧页面迁移到新分类。', + button: '重新分类已有页面', + confirmTitle: '重新分类已有页面', + confirmMsg: '将对本知识库每个非系统页面逐一调用大模型重新判定分类,可能耗时并产生费用。系统页面不受影响。确认继续?', + started: '已开始重新分类,完成后页面分类会更新。', + }, layers: { tab: '分层 & 失效', title: '知识分层与失效状态', diff --git a/mateclaw-ui/src/views/Wiki/components/WikiAdvancedPanel.vue b/mateclaw-ui/src/views/Wiki/components/WikiAdvancedPanel.vue index a871f857..8469c43b 100644 --- a/mateclaw-ui/src/views/Wiki/components/WikiAdvancedPanel.vue +++ b/mateclaw-ui/src/views/Wiki/components/WikiAdvancedPanel.vue @@ -32,6 +32,13 @@ + +

+

{{ t('wiki.adv.reclassify.desc') }}

+ +
@@ -303,6 +310,19 @@ async function resetProfile() { await loadProfile() } catch (e: any) { mcToast.error(errMsg(e, 'Reset failed')) } finally { profile.busy = false } } +async function reclassify() { + if (!kbId.value) return + if (!(await mcConfirm({ + title: t('wiki.adv.reclassify.confirmTitle'), + message: t('wiki.adv.reclassify.confirmMsg'), + tone: 'danger', + }))) return + profile.busy = true + try { + await wikiApi.reclassifyKB(kbId.value) + mcToast.success(t('wiki.adv.reclassify.started')) + } catch (e: any) { mcToast.error(errMsg(e, 'Reclassify failed')) } finally { profile.busy = false } +} // ---- REQ-2 Layers & Stale ---- const layers = reactive({ pages: [] as any[], busy: false })