mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
28f2ba973d
commit
e3ddea9a70
@ -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")
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>"}
|
||||
@ -0,0 +1,8 @@
|
||||
Classify the following wiki page.
|
||||
|
||||
Title: {title}
|
||||
|
||||
Summary:
|
||||
{summary}
|
||||
|
||||
Return only: {"page_type": "<type key>"}
|
||||
@ -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) =>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -2212,6 +2212,13 @@ export default {
|
||||
reset: '重置为默认',
|
||||
resetConfirm: '确认重置为内置默认 profile?当前自定义配置会被清除。',
|
||||
},
|
||||
reclassify: {
|
||||
desc: '按当前 profile 重新分类已有页面:仅更新页面分类(pageType),不改动正文。改了 profile(新增/删除类型)后用它把旧页面迁移到新分类。',
|
||||
button: '重新分类已有页面',
|
||||
confirmTitle: '重新分类已有页面',
|
||||
confirmMsg: '将对本知识库每个非系统页面逐一调用大模型重新判定分类,可能耗时并产生费用。系统页面不受影响。确认继续?',
|
||||
started: '已开始重新分类,完成后页面分类会更新。',
|
||||
},
|
||||
layers: {
|
||||
tab: '分层 & 失效',
|
||||
title: '知识分层与失效状态',
|
||||
|
||||
@ -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 })
|
||||
|
||||
Loading…
Reference in New Issue
Block a user