mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 12:27:53 +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();
|
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 ====================
|
// ==================== Directory Scan ====================
|
||||||
|
|
||||||
@RequireWorkspaceRole("member")
|
@RequireWorkspaceRole("member")
|
||||||
|
|||||||
@ -368,6 +368,27 @@ public class WikiPageService {
|
|||||||
.set(WikiPageEntity::getProfileVersion, profileVersion));
|
.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). */
|
/** Set only a page's knowledge layer via a partial update (leaves depends_on untouched). */
|
||||||
public void setKnowledgeLayer(Long pageId, String knowledgeLayer) {
|
public void setKnowledgeLayer(Long pageId, String knowledgeLayer) {
|
||||||
if (pageId == null || knowledgeLayer == null) {
|
if (pageId == null || knowledgeLayer == null) {
|
||||||
|
|||||||
@ -559,6 +559,98 @@ public class WikiProcessingService {
|
|||||||
return pending.size();
|
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>
|
* <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 }),
|
http.post(`/wiki/knowledge-bases/${kbId}/page-type-profile/validate`, { config }),
|
||||||
resetPageTypeProfile: (kbId: string | number) =>
|
resetPageTypeProfile: (kbId: string | number) =>
|
||||||
http.post(`/wiki/knowledge-bases/${kbId}/page-type-profile/reset-default`),
|
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) ----
|
// ---- Agent pageType permissions (REQ-3) ----
|
||||||
listPageTypePermissions: (kbId: string | number, agentId: string | number) =>
|
listPageTypePermissions: (kbId: string | number, agentId: string | number) =>
|
||||||
|
|||||||
@ -2200,6 +2200,13 @@ export default {
|
|||||||
reset: 'Reset to default',
|
reset: 'Reset to default',
|
||||||
resetConfirm: 'Reset to the built-in default profile? Your custom config will be cleared.',
|
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: {
|
layers: {
|
||||||
tab: 'Layers & Stale',
|
tab: 'Layers & Stale',
|
||||||
title: 'Knowledge Layers & Stale State',
|
title: 'Knowledge Layers & Stale State',
|
||||||
|
|||||||
@ -2212,6 +2212,13 @@ export default {
|
|||||||
reset: '重置为默认',
|
reset: '重置为默认',
|
||||||
resetConfirm: '确认重置为内置默认 profile?当前自定义配置会被清除。',
|
resetConfirm: '确认重置为内置默认 profile?当前自定义配置会被清除。',
|
||||||
},
|
},
|
||||||
|
reclassify: {
|
||||||
|
desc: '按当前 profile 重新分类已有页面:仅更新页面分类(pageType),不改动正文。改了 profile(新增/删除类型)后用它把旧页面迁移到新分类。',
|
||||||
|
button: '重新分类已有页面',
|
||||||
|
confirmTitle: '重新分类已有页面',
|
||||||
|
confirmMsg: '将对本知识库每个非系统页面逐一调用大模型重新判定分类,可能耗时并产生费用。系统页面不受影响。确认继续?',
|
||||||
|
started: '已开始重新分类,完成后页面分类会更新。',
|
||||||
|
},
|
||||||
layers: {
|
layers: {
|
||||||
tab: '分层 & 失效',
|
tab: '分层 & 失效',
|
||||||
title: '知识分层与失效状态',
|
title: '知识分层与失效状态',
|
||||||
|
|||||||
@ -32,6 +32,13 @@
|
|||||||
<button class="btn-ghost danger" @click="resetProfile" :disabled="profile.busy">{{ t('wiki.adv.profile.reset') }}</button>
|
<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>
|
<button class="btn-primary" @click="saveProfile" :disabled="profile.busy">{{ t('common.save') }}</button>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
|
|
||||||
<!-- ===================== REQ-2: Layers & Stale ===================== -->
|
<!-- ===================== REQ-2: Layers & Stale ===================== -->
|
||||||
@ -303,6 +310,19 @@ async function resetProfile() {
|
|||||||
await loadProfile()
|
await loadProfile()
|
||||||
} catch (e: any) { mcToast.error(errMsg(e, 'Reset failed')) } finally { profile.busy = false }
|
} 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 ----
|
// ---- REQ-2 Layers & Stale ----
|
||||||
const layers = reactive({ pages: [] as any[], busy: false })
|
const layers = reactive({ pages: [] as any[], busy: false })
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user