feat(wiki): reclassify existing pages against the current pageType profile

Add a backfill path so pages created before a KB's pageType profile changed
can be migrated into newly-added types. A per-page classify-only LLM call
(title + summary in, single page_type out) is normalised through the profile
and written back via a partial update that never touches page content.

Exposed as POST /knowledge-bases/{id}/reclassify (admin) and a "re-classify
existing pages" action in the Wiki advanced panel.
This commit is contained in:
倪程伟 2026-06-08 17:09:52 +08:00 committed by matevip
parent 28f2ba973d
commit e3ddea9a70
9 changed files with 188 additions and 0 deletions

View File

@ -274,6 +274,23 @@ public class WikiController {
return R.ok();
}
@RequireWorkspaceRole("admin")
@Operation(summary = "按当前 pageType profile 重新分类已有页面(异步,不改内容)")
@PostMapping("/knowledge-bases/{id}/reclassify")
public R<Map<String, Object>> reclassifyKB(@PathVariable Long id,
@RequestBody(required = false) Map<String, Object> 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<String, Object> out = new LinkedHashMap<>();
out.put("queued", queued);
return R.ok(out);
}
// ==================== Directory Scan ====================
@RequireWorkspaceRole("member")

View File

@ -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<WikiPageEntity> w =
new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<WikiPageEntity>()
.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) {

View File

@ -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.
*
* <p>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<WikiPageEntity> 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;
}
/**
* 处理知识库中所有待处理的原始材料
* <p>

View File

@ -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": "<one of the allowed type keys>"}

View File

@ -0,0 +1,8 @@
Classify the following wiki page.
Title: {title}
Summary:
{summary}
Return only: {"page_type": "<type key>"}

View File

@ -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) =>

View File

@ -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',

View File

@ -2212,6 +2212,13 @@ export default {
reset: '重置为默认',
resetConfirm: '确认重置为内置默认 profile当前自定义配置会被清除。',
},
reclassify: {
desc: '按当前 profile 重新分类已有页面仅更新页面分类pageType不改动正文。改了 profile新增/删除类型)后用它把旧页面迁移到新分类。',
button: '重新分类已有页面',
confirmTitle: '重新分类已有页面',
confirmMsg: '将对本知识库每个非系统页面逐一调用大模型重新判定分类,可能耗时并产生费用。系统页面不受影响。确认继续?',
started: '已开始重新分类,完成后页面分类会更新。',
},
layers: {
tab: '分层 & 失效',
title: '知识分层与失效状态',

View File

@ -32,6 +32,13 @@
<button class="btn-ghost danger" @click="resetProfile" :disabled="profile.busy">{{ t('wiki.adv.profile.reset') }}</button>
<button class="btn-primary" @click="saveProfile" :disabled="profile.busy">{{ t('common.save') }}</button>
</div>
<div class="reclassify-box">
<p class="adv-desc">{{ t('wiki.adv.reclassify.desc') }}</p>
<button class="btn-ghost" @click="reclassify" :disabled="profile.busy">
{{ t('wiki.adv.reclassify.button') }}
</button>
</div>
</section>
<!-- ===================== REQ-2: Layers & Stale ===================== -->
@ -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 })