From fbbd1218e8902a9af8d6fbae7bd4f9b57741a799 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=80=AA=E7=A8=8B=E4=BC=9F?= Date: Sun, 28 Jun 2026 13:07:34 +0800 Subject: [PATCH] feat(wiki): KB processing-failure visibility (error-code chain + silent sub-step alerts + cross-KB failure center) Propagates structured error codes through the KB processing pipeline, surfaces silent sub-step warnings as a non-failure warning state, and adds a cross-KB failure center for aggregated visibility. --- .../notification/NotificationController.java | 8 + .../wiki/controller/WikiAdminController.java | 39 +++++ .../mate/wiki/controller/WikiController.java | 3 + .../vip/mate/wiki/dto/WikiFailureItem.java | 23 +++ .../wiki/model/WikiRawMaterialEntity.java | 24 ++- .../repository/WikiRawMaterialMapper.java | 32 ++++ .../wiki/service/WikiProcessingService.java | 59 ++++++-- .../wiki/service/WikiRawMaterialService.java | 61 +++++++- .../vip/mate/wiki/sse/WikiProgressBus.java | 2 + .../h2/V162__wiki_raw_material_error_code.sql | 9 ++ .../h2/V163__wiki_raw_material_warning.sql | 10 ++ .../V162__wiki_raw_material_error_code.sql | 17 +++ .../V163__wiki_raw_material_warning.sql | 27 ++++ .../V162__wiki_raw_material_error_code.sql | 12 ++ .../mysql/V163__wiki_raw_material_warning.sql | 16 ++ .../src/main/resources/docs/en/api.md | 1 + .../src/main/resources/docs/en/wiki.md | 41 +++++- .../src/main/resources/docs/zh/api.md | 1 + .../src/main/resources/docs/zh/wiki.md | 41 +++++- .../WikiRawMaterialFailuresMapperE2ETest.java | 95 ++++++++++++ .../WikiProcessingServiceErrorCodeTest.java | 139 ++++++++++++++++++ .../WikiProcessingServiceLazyTest.java | 6 +- .../WikiRawMaterialFailureStateTest.java | 100 +++++++++++++ mateclaw-ui/src/api/index.ts | 20 +++ .../src/composables/useNotificationCenter.ts | 3 + mateclaw-ui/src/i18n/locales/en-US.ts | 25 ++++ mateclaw-ui/src/i18n/locales/zh-CN.ts | 25 ++++ mateclaw-ui/src/stores/useWikiStore.ts | 8 + .../Wiki/components/RawMaterialPanel.vue | 59 +++++++- .../Wiki/components/WikiFailureCenter.vue | 97 ++++++++++++ mateclaw-ui/src/views/Wiki/index.vue | 17 ++- mateclaw-ui/src/views/layout/MainLayout.vue | 9 +- 32 files changed, 1002 insertions(+), 27 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/wiki/dto/WikiFailureItem.java create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V162__wiki_raw_material_error_code.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V163__wiki_raw_material_warning.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/kingbase/V162__wiki_raw_material_error_code.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/kingbase/V163__wiki_raw_material_warning.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V162__wiki_raw_material_error_code.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V163__wiki_raw_material_warning.sql create mode 100644 mateclaw-server/src/test/java/vip/mate/wiki/repository/WikiRawMaterialFailuresMapperE2ETest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingServiceErrorCodeTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/wiki/service/WikiRawMaterialFailureStateTest.java create mode 100644 mateclaw-ui/src/views/Wiki/components/WikiFailureCenter.vue diff --git a/mateclaw-server/src/main/java/vip/mate/notification/NotificationController.java b/mateclaw-server/src/main/java/vip/mate/notification/NotificationController.java index 1f1396ae..4b031e11 100644 --- a/mateclaw-server/src/main/java/vip/mate/notification/NotificationController.java +++ b/mateclaw-server/src/main/java/vip/mate/notification/NotificationController.java @@ -13,6 +13,7 @@ import vip.mate.agent.runtime.AgentRuntimeAggregator; import vip.mate.approval.ApprovalWorkflowService; import vip.mate.common.result.R; import vip.mate.exception.MateClawException; +import vip.mate.wiki.service.WikiRawMaterialService; import java.util.LinkedHashMap; import java.util.Map; @@ -36,6 +37,7 @@ public class NotificationController { private final ApprovalWorkflowService approvalWorkflowService; private final AgentRuntimeAggregator agentRuntimeAggregator; + private final WikiRawMaterialService wikiRawMaterialService; @Operation(summary = "Aggregated counts for the sidebar attention badges") @GetMapping("/summary") @@ -49,10 +51,16 @@ public class NotificationController { int stuckAgents = admin ? agentRuntimeAggregator.snapshot().summary().stuck() : 0; + // Cross-KB Wiki ingest failures/degradations — admin-only, mirroring + // stuckAgents (the list view it links to spans every workspace). + int failedWikiJobs = admin + ? (int) Math.min(Integer.MAX_VALUE, wikiRawMaterialService.countFailures()) + : 0; Map payload = new LinkedHashMap<>(); payload.put("pendingApprovals", pendingApprovals); payload.put("stuckAgents", stuckAgents); + payload.put("failedWikiJobs", failedWikiJobs); // Reserved fields — wire shape stays stable so the frontend doesn't // need a fan-out when these get real semantics later. payload.put("failedCrons", 0); diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiAdminController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiAdminController.java index f836025e..edaea65b 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiAdminController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiAdminController.java @@ -6,17 +6,25 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import vip.mate.common.result.R; +import vip.mate.exception.MateClawException; +import vip.mate.wiki.dto.WikiFailureItem; import vip.mate.wiki.job.WikiChunkTokenBackfillJob; import vip.mate.wiki.service.WikiOverviewService; import vip.mate.wiki.service.WikiPageService; +import vip.mate.wiki.service.WikiRawMaterialService; import vip.mate.wiki.service.WikiScaffoldService; import java.util.HashMap; +import java.util.List; import java.util.Map; import vip.mate.workspace.core.annotation.RequireWorkspaceRole; @@ -37,6 +45,7 @@ public class WikiAdminController { private final WikiScaffoldService scaffoldService; private final WikiPageService pageService; + private final WikiRawMaterialService rawService; /** Optional so the controller can boot in environments where the rebuilder isn't wired (e.g. minimal tests). */ @Autowired(required = false) @@ -100,4 +109,34 @@ public class WikiAdminController { Map report = pageService.mergeDuplicateTitles(kbId, dryRun, concatenate); return ResponseEntity.ok(report); } + + /** + * Centralized, cross-knowledge-base list of materials needing operator + * attention (failed / partial / completed-but-degraded). Lets an admin + * triage background ingest problems without opening each KB in turn — + * the count behind the sidebar attention badge resolves here. + * + *

Platform-admin only: it deliberately spans every workspace, so it is + * gated on {@code ROLE_ADMIN} rather than a per-workspace role. + */ + @Operation(summary = "跨知识库列出需要关注的处理失败/降级材料(管理员)") + @GetMapping("/failures") + public R> listFailures( + @RequestParam(defaultValue = "100") int limit, + Authentication auth) { + requireAdmin(auth); + return R.ok(rawService.listFailures(limit)); + } + + private void requireAdmin(Authentication auth) { + if (auth == null) { + throw new MateClawException(401, "authentication required"); + } + boolean admin = auth.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .anyMatch("ROLE_ADMIN"::equals); + if (!admin) { + throw new MateClawException(403, "admin only"); + } + } } 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 86ee37a6..e4c1947b 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 @@ -524,7 +524,10 @@ public class WikiController { item.put("title", raw.getTitle()); item.put("sourceType", raw.getSourceType()); item.put("processingStatus", raw.getProcessingStatus()); + item.put("errorCode", raw.getErrorCode()); item.put("errorMessage", raw.getErrorMessage()); + item.put("warningCode", raw.getWarningCode()); + item.put("warningMessage", raw.getWarningMessage()); item.put("progressPhase", raw.getProgressPhase()); item.put("progressDone", raw.getProgressDone()); item.put("progressTotal", raw.getProgressTotal()); diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/dto/WikiFailureItem.java b/mateclaw-server/src/main/java/vip/mate/wiki/dto/WikiFailureItem.java new file mode 100644 index 00000000..340124dc --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/dto/WikiFailureItem.java @@ -0,0 +1,23 @@ +package vip.mate.wiki.dto; + +import java.time.LocalDateTime; + +/** + * Cross-KB projection of a raw material that needs operator attention — + * failed, partial, or completed-but-degraded (a warning was recorded). Powers + * the centralized Wiki failure list so operators can triage background + * processing problems without opening each knowledge base in turn. + */ +public record WikiFailureItem( + Long rawId, + Long kbId, + String kbName, + Long workspaceId, + String title, + String processingStatus, + String errorCode, + String errorMessage, + String warningCode, + String warningMessage, + LocalDateTime updateTime +) {} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiRawMaterialEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiRawMaterialEntity.java index f623ccdf..26dcbdc0 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiRawMaterialEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiRawMaterialEntity.java @@ -64,9 +64,31 @@ public class WikiRawMaterialEntity { /** 上次成功处理时的 content_hash,用于重处理时的短路判断 */ private String lastProcessedHash; - /** 错误信息 */ + /** 错误信息(原始异常文本,供排查使用) */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String errorMessage; + /** + * 结构化错误码,与 {@code WikiProcessingService#classifyErrorCode} 同一词表 + * (AUTH_ERROR / BILLING / MODEL_NOT_FOUND / RATE_LIMIT / TIMEOUT / + * SERVER_ERROR / CONTENT_FILTER / NO_CONTENT / EMPTY_RESULT / UNKNOWN)。 + * 供前端做本地化的友好提示;null 表示无错误。 + */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private String errorCode; + + /** + * 非阻断告警码:材料整体处理成功(completed/partial),但某个异步子步骤 + * (向量化 embedding / 实体图抽取)失败导致功能降级(如无法语义检索)。 + * 与 {@link #errorCode} 同一友好提示机制;null 表示无告警。 + */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private String warningCode; + + /** 告警原始文本(供排查),与 {@link #warningCode} 配套。 */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private String warningMessage; + /** * RFC-012 M2 v2 UI:当前处理阶段(null 未开始 / "route" / "phase-b" / "done")。 * 供前端决定是否显示进度条以及显示"准备中"还是具体进度。 diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiRawMaterialMapper.java b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiRawMaterialMapper.java index 777df692..5e744ba4 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiRawMaterialMapper.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiRawMaterialMapper.java @@ -5,6 +5,7 @@ import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import vip.mate.wiki.dto.RawTitleRef; +import vip.mate.wiki.dto.WikiFailureItem; import vip.mate.wiki.model.WikiRawMaterialEntity; import java.util.Collection; @@ -25,4 +26,35 @@ public interface WikiRawMaterialMapper extends BaseMapper "WHERE id IN #{id} " + "AND deleted = 0") List selectBatchTitles(@Param("ids") Collection ids); + + /** + * Predicate shared by the count + list of materials needing operator + * attention: hard failure, partial (rerunnable), or completed-but-degraded + * (an async sub-step recorded a warning). + */ + String NEEDS_ATTENTION = + "r.deleted = 0 AND (r.processing_status IN ('failed','partial') OR r.warning_code IS NOT NULL)"; + + /** Count of attention-needing raw materials across all knowledge bases. */ + @Select("SELECT COUNT(*) FROM mate_wiki_raw_material r " + + "JOIN mate_wiki_knowledge_base k ON k.id = r.kb_id AND k.deleted = 0 " + + "WHERE " + NEEDS_ATTENTION) + long countFailures(); + + /** + * Cross-KB list of attention-needing raw materials, newest first. Joined to + * the knowledge base for the display name + workspace so the UI can route to + * the owning KB without a second round-trip. + */ + @Select("SELECT r.id AS rawId, r.kb_id AS kbId, k.name AS kbName, k.workspace_id AS workspaceId, " + + "r.title AS title, r.processing_status AS processingStatus, " + + "r.error_code AS errorCode, r.error_message AS errorMessage, " + + "r.warning_code AS warningCode, r.warning_message AS warningMessage, " + + "r.update_time AS updateTime " + + "FROM mate_wiki_raw_material r " + + "JOIN mate_wiki_knowledge_base k ON k.id = r.kb_id AND k.deleted = 0 " + + "WHERE " + NEEDS_ATTENTION + " " + + "ORDER BY r.update_time DESC " + + "LIMIT #{limit}") + List listFailures(@Param("limit") int limit); } 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 dafc22a9..7b93612d 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 @@ -318,8 +318,10 @@ public class WikiProcessingService { // Phase 1: 获取文本内容 String textContent = rawService.getTextContent(raw); if (textContent == null || textContent.isBlank()) { - rawService.updateProcessingStatus(rawId, "failed", "No text content available"); + rawService.updateProcessingStatus(rawId, "failed", "NO_CONTENT", "No text content available"); kbService.updateStatus(kb.getId(), "active"); + progressBus.broadcast(kb.getId(), WikiProgressBus.EVENT_RAW_FAILED, + Map.of("rawId", rawId, "error", "No text content available", "errorCode", "NO_CONTENT")); return; } @@ -382,6 +384,10 @@ public class WikiProcessingService { String finalStatus; String finalDetail = null; + // Structured failure code (null unless finalStatus becomes "failed"), + // carried into both the persisted row and the RAW_FAILED SSE event so + // the UI can localize the failure instead of echoing raw English text. + String finalErrorCode = null; // Cancellation takes precedence over the normal terminal-state logic: // chunks that observed the cancel flag returned early as "failed", but // those aren't real failures — the user asked to stop. Surface that @@ -408,9 +414,10 @@ public class WikiProcessingService { log.info("[Wiki] Eager produced 0 pages but {} chunks indexed; marking partial for raw={}", totalChunks, rawId); } else { - rawService.updateProcessingStatus(rawId, "failed", "No pages generated from LLM response"); - finalStatus = "failed"; + finalErrorCode = "EMPTY_RESULT"; finalDetail = "No pages generated from LLM response"; + rawService.updateProcessingStatus(rawId, "failed", finalErrorCode, finalDetail); + finalStatus = "failed"; } } else if (failedChunks > 0 || failedPages > 0) { // 部分成功:chunk 整体失败 或 chunk 内有 page 失败 @@ -444,7 +451,9 @@ public class WikiProcessingService { // RFC-012 M3:广播终态 if ("failed".equals(finalStatus)) { progressBus.broadcast(kb.getId(), WikiProgressBus.EVENT_RAW_FAILED, - Map.of("rawId", rawId, "error", finalDetail == null ? "" : finalDetail)); + Map.of("rawId", rawId, + "error", finalDetail == null ? "" : finalDetail, + "errorCode", finalErrorCode == null ? "UNKNOWN" : finalErrorCode)); } else { progressBus.broadcast(kb.getId(), WikiProgressBus.EVENT_RAW_COMPLETED, Map.of( @@ -517,6 +526,7 @@ public class WikiProcessingService { // every pending chunk and produce more "all chunks failed" noise. if (totalChunks > 0 && !"cancelled".equals(finalStatus)) { final Long fKbId = kb.getId(); + final Long fRawId = rawId; WIKI_EXECUTOR.submit(() -> { try { int embedded = embeddingService.embedMissingChunks(fKbId); @@ -529,8 +539,10 @@ public class WikiProcessingService { // emit a calmer notice here instead of a generic failure log. log.warn("[Wiki] Async embedding aborted by circuit-breaker for kbId={}: {}", fKbId, ex.getMessage()); + surfaceWarning(fKbId, fRawId, "EMBEDDING_FAILED", ex.getMessage()); } catch (Exception ex) { log.warn("[Wiki] Async embedding failed for kbId={}: {}", fKbId, ex.getMessage()); + surfaceWarning(fKbId, fRawId, "EMBEDDING_FAILED", ex.getMessage()); } }); } @@ -551,6 +563,7 @@ public class WikiProcessingService { } } catch (Exception ex) { log.warn("[Wiki] Async entity extraction failed for kbId={}: {}", fKbId, ex.getMessage()); + surfaceWarning(fKbId, fRawId, "ENTITY_EXTRACTION_FAILED", ex.getMessage()); } }); } @@ -562,6 +575,7 @@ public class WikiProcessingService { // checkpoint rejected between chunks). boolean cancelled = rawService.isCancelRequested(rawId); String terminalStatus = cancelled ? "cancelled" : "failed"; + String errorCode = cancelled ? null : classifyErrorCode(e); String detail = cancelled ? "Cancelled by user (interrupted: " + (e.getMessage() == null ? "unknown" : e.getMessage()) + ")" : e.getMessage(); @@ -570,7 +584,7 @@ public class WikiProcessingService { } else { log.error("[Wiki] Processing failed for raw={}: {}", rawId, e.getMessage(), e); } - rawService.updateProcessingStatus(rawId, terminalStatus, detail); + rawService.updateProcessingStatus(rawId, terminalStatus, errorCode, detail); kbService.updateStatus(kb.getId(), "active"); if (wikiJobService != null && jobId != null) { try { @@ -587,7 +601,9 @@ public class WikiProcessingService { Map.of("rawId", rawId, "status", "cancelled")); } else { progressBus.broadcast(kb.getId(), WikiProgressBus.EVENT_RAW_FAILED, - Map.of("rawId", rawId, "error", e.getMessage() == null ? "unknown" : e.getMessage())); + Map.of("rawId", rawId, + "error", e.getMessage() == null ? "unknown" : e.getMessage(), + "errorCode", errorCode == null ? "UNKNOWN" : errorCode)); } } finally { // RFC-012 M2 v2 UI v2:写入最终进度并清理共享计数器 @@ -2705,6 +2721,24 @@ public class WikiProcessingService { TransientLlmException(String msg) { super(msg); } } + /** + * Persist a non-blocking warning on a completed material whose async sub-step + * (embedding / entity extraction) failed, and push it live so the UI can flag + * the degradation without a reload. Best-effort: a warning must never escalate + * into a pipeline failure, so any bookkeeping error here is swallowed. + */ + private void surfaceWarning(Long kbId, Long rawId, String warningCode, String warningMessage) { + try { + rawService.recordWarning(rawId, warningCode, warningMessage); + progressBus.broadcast(kbId, WikiProgressBus.EVENT_RAW_WARNING, + Map.of("rawId", rawId, + "warningCode", warningCode, + "warning", warningMessage == null ? "" : warningMessage)); + } catch (Exception ex) { + log.warn("[Wiki] Failed to record warning for raw={}: {}", rawId, ex.getMessage()); + } + } + // ==================== RFC-030: Error classification ==================== /** @@ -2975,10 +3009,10 @@ public class WikiProcessingService { try { String textContent = rawService.getTextContent(raw); if (textContent == null || textContent.isBlank()) { - rawService.updateProcessingStatus(rawId, "failed", "No text content available"); + rawService.updateProcessingStatus(rawId, "failed", "NO_CONTENT", "No text content available"); kbService.updateStatus(kbId, "active"); progressBus.broadcast(kbId, WikiProgressBus.EVENT_RAW_FAILED, - Map.of("rawId", rawId, "error", "No text content available")); + Map.of("rawId", rawId, "error", "No text content available", "errorCode", "NO_CONTENT")); return; } @@ -3010,6 +3044,7 @@ public class WikiProcessingService { // Async embedding — mirror the eager path so a slow embedding model // does not block the raw from reaching completed. final Long fKbId = kbId; + final Long fRawId = rawId; WIKI_EXECUTOR.submit(() -> { try { int embedded = embeddingService.embedMissingChunks(fKbId); @@ -3019,8 +3054,10 @@ public class WikiProcessingService { } catch (WikiEmbeddingProviderFailingException ex) { log.warn("[Wiki] Lazy async embedding aborted by circuit-breaker for kbId={}: {}", fKbId, ex.getMessage()); + surfaceWarning(fKbId, fRawId, "EMBEDDING_FAILED", ex.getMessage()); } catch (Exception ex) { log.warn("[Wiki] Lazy async embedding failed for kbId={}: {}", fKbId, ex.getMessage()); + surfaceWarning(fKbId, fRawId, "EMBEDDING_FAILED", ex.getMessage()); } }); @@ -3058,11 +3095,13 @@ public class WikiProcessingService { rawId, kbId, totalChunks); } catch (Exception e) { log.error("[Wiki] Lazy processing failed for raw={}: {}", rawId, e.getMessage(), e); - rawService.updateProcessingStatus(rawId, "failed", e.getMessage()); + String errorCode = classifyErrorCode(e); + rawService.updateProcessingStatus(rawId, "failed", errorCode, e.getMessage()); kbService.updateStatus(kbId, "active"); progressBus.broadcast(kbId, WikiProgressBus.EVENT_RAW_FAILED, Map.of("rawId", rawId, - "error", e.getMessage() == null ? "unknown" : e.getMessage())); + "error", e.getMessage() == null ? "unknown" : e.getMessage(), + "errorCode", errorCode == null ? "UNKNOWN" : errorCode)); } } } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java index e60ad96d..57e2ccc8 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java @@ -152,7 +152,7 @@ public class WikiRawMaterialService { entity.setFileSize((long) content.getBytes(StandardCharsets.UTF_8).length); entity.setSourcePath(sourcePath); entity.setProcessingStatus("pending"); - entity.setErrorMessage(null); + clearFailureState(entity); entity.setExtractedText(null); entity.setProgressPhase(null); entity.setProgressDone(0); @@ -227,7 +227,7 @@ public class WikiRawMaterialService { entity.setFileSize(fileSize); entity.setSourcePath(sourcePath); entity.setProcessingStatus("pending"); - entity.setErrorMessage(null); + clearFailureState(entity); entity.setExtractedText(null); entity.setProgressPhase(null); entity.setProgressDone(0); @@ -411,7 +411,7 @@ public class WikiRawMaterialService { return false; } entity.setProcessingStatus("processing"); - entity.setErrorMessage(null); + clearFailureState(entity); // RFC-012 M2 v2 UI:新一轮处理开始,清掉上次遗留的进度显示 entity.setProgressPhase(null); entity.setProgressTotal(0); @@ -478,11 +478,60 @@ public class WikiRawMaterialService { rawMapper.updateById(entity); } - @Transactional public void updateProcessingStatus(Long id, String status, String errorMessage) { + updateProcessingStatus(id, status, null, errorMessage); + } + + /** + * Reset all failure/warning surfacing fields to a clean slate for a fresh + * run. Required because {@code errorCode}/{@code errorMessage}/{@code warning*} + * all carry {@code FieldStrategy.ALWAYS}: a row loaded then re-saved would + * otherwise re-persist its stale values. + */ + private static void clearFailureState(WikiRawMaterialEntity e) { + e.setErrorCode(null); + e.setErrorMessage(null); + e.setWarningCode(null); + e.setWarningMessage(null); + } + + /** + * Record a non-blocking warning on a material that finished processing but + * had an async sub-step (embedding / entity extraction) fail. Does not touch + * {@code processingStatus} — the material is still usable, just degraded. + */ + @Transactional + public void recordWarning(Long id, String warningCode, String warningMessage) { + WikiRawMaterialEntity entity = rawMapper.selectById(id); + if (entity == null) return; + entity.setWarningCode(warningCode); + entity.setWarningMessage(warningMessage); + rawMapper.updateById(entity); + } + + /** Cross-KB count of materials needing operator attention (failed/partial/degraded). */ + public long countFailures() { + return rawMapper.countFailures(); + } + + /** Cross-KB list of materials needing operator attention, newest first (capped). */ + public java.util.List listFailures(int limit) { + return rawMapper.listFailures(Math.max(1, Math.min(limit, 500))); + } + + /** + * Terminal/intermediate status transition that also records a structured + * {@code errorCode} (see {@code WikiProcessingService#classifyErrorCode}). + * Both error fields carry {@link com.baomidou.mybatisplus.annotation.FieldStrategy#ALWAYS} + * on the entity, so a success transition with {@code null} code/message + * clears any stale failure left from a prior run. + */ + @Transactional + public void updateProcessingStatus(Long id, String status, String errorCode, String errorMessage) { WikiRawMaterialEntity entity = rawMapper.selectById(id); if (entity == null) return; entity.setProcessingStatus(status); + entity.setErrorCode(errorCode); entity.setErrorMessage(errorMessage); if ("completed".equals(status)) { entity.setLastProcessedAt(java.time.LocalDateTime.now()); @@ -539,7 +588,7 @@ public class WikiRawMaterialService { } boolean wasPartial = "partial".equals(entity.getProcessingStatus()); entity.setProcessingStatus("pending"); - entity.setErrorMessage(null); + clearFailureState(entity); rawMapper.updateById(entity); if (wasPartial) { @@ -798,7 +847,7 @@ public class WikiRawMaterialService { raw.setProgressPhase(null); raw.setProgressTotal(0); raw.setProgressDone(0); - raw.setErrorMessage(null); + clearFailureState(raw); rawMapper.updateById(raw); if (properties.isAutoProcessOnUpload()) { diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/sse/WikiProgressBus.java b/mateclaw-server/src/main/java/vip/mate/wiki/sse/WikiProgressBus.java index 55ed27ee..1a23b139 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/sse/WikiProgressBus.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/sse/WikiProgressBus.java @@ -38,6 +38,8 @@ public class WikiProgressBus { public static final String EVENT_CHUNK_DONE = "chunk.done"; public static final String EVENT_RAW_COMPLETED = "raw.completed"; public static final String EVENT_RAW_FAILED = "raw.failed"; + /** Non-blocking warning on an otherwise-completed material (async sub-step failed). */ + public static final String EVENT_RAW_WARNING = "raw.warning"; public static final String EVENT_HEARTBEAT = "heartbeat"; private final ObjectMapper objectMapper; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V162__wiki_raw_material_error_code.sql b/mateclaw-server/src/main/resources/db/migration/h2/V162__wiki_raw_material_error_code.sql new file mode 100644 index 00000000..8c634412 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V162__wiki_raw_material_error_code.sql @@ -0,0 +1,9 @@ +-- V162: structured error_code on wiki raw material. +-- The processing pipeline already classifies failures into a stable vocabulary +-- (AUTH_ERROR / BILLING / MODEL_NOT_FOUND / RATE_LIMIT / TIMEOUT / SERVER_ERROR / +-- CONTENT_FILTER / UNKNOWN, see WikiProcessingService#classifyErrorCode) but only +-- the free-text error_message reached the raw_material row — so the frontend could +-- not localize the failure into a user-friendly hint. Persisting the code lets the +-- UI render a friendly i18n message and keep the raw message as a collapsible detail. +-- Nullable: NULL = no error (or a legacy failure recorded before this column existed). +ALTER TABLE mate_wiki_raw_material ADD COLUMN IF NOT EXISTS error_code VARCHAR(64) DEFAULT NULL; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V163__wiki_raw_material_warning.sql b/mateclaw-server/src/main/resources/db/migration/h2/V163__wiki_raw_material_warning.sql new file mode 100644 index 00000000..9203ac0b --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V163__wiki_raw_material_warning.sql @@ -0,0 +1,10 @@ +-- V163: non-blocking warning surface on wiki raw material. +-- Some ingest sub-steps run async *after* the material is already marked +-- completed/partial — embedding (semantic search) and entity-graph extraction. +-- When they fail the material stays "completed" but is silently degraded +-- (e.g. not searchable), and previously the only trace was a server log line. +-- These columns let such a failure show as a non-blocking warning on an +-- otherwise-successful row. Mirrors the error_code/error_message pair so the +-- UI can render a localized friendly hint; NULL = no warning. +ALTER TABLE mate_wiki_raw_material ADD COLUMN IF NOT EXISTS warning_code VARCHAR(64) DEFAULT NULL; +ALTER TABLE mate_wiki_raw_material ADD COLUMN IF NOT EXISTS warning_message VARCHAR(512) DEFAULT NULL; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V162__wiki_raw_material_error_code.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V162__wiki_raw_material_error_code.sql new file mode 100644 index 00000000..4c28c19d --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V162__wiki_raw_material_error_code.sql @@ -0,0 +1,17 @@ +-- V162: structured error_code on wiki raw material. +-- The processing pipeline already classifies failures into a stable vocabulary +-- (AUTH_ERROR / BILLING / MODEL_NOT_FOUND / RATE_LIMIT / TIMEOUT / SERVER_ERROR / +-- CONTENT_FILTER / UNKNOWN, see WikiProcessingService#classifyErrorCode) but only +-- the free-text error_message reached the raw_material row — so the frontend could +-- not localize the failure into a user-friendly hint. Persisting the code lets the +-- UI render a friendly i18n message and keep the raw message as a collapsible detail. +-- Nullable: NULL = no error (or a legacy failure recorded before this column existed). +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_raw_material' AND column_name = 'error_code' + ) THEN + ALTER TABLE mate_wiki_raw_material ADD COLUMN error_code VARCHAR(64) DEFAULT NULL; + END IF; +END $$; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V163__wiki_raw_material_warning.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V163__wiki_raw_material_warning.sql new file mode 100644 index 00000000..5cad5523 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V163__wiki_raw_material_warning.sql @@ -0,0 +1,27 @@ +-- V163: non-blocking warning surface on wiki raw material. +-- Some ingest sub-steps run async *after* the material is already marked +-- completed/partial — embedding (semantic search) and entity-graph extraction. +-- When they fail the material stays "completed" but is silently degraded +-- (e.g. not searchable), and previously the only trace was a server log line. +-- These columns let such a failure show as a non-blocking warning on an +-- otherwise-successful row. Mirrors the error_code/error_message pair so the +-- UI can render a localized friendly hint; NULL = no warning. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_raw_material' AND column_name = 'warning_code' + ) THEN + ALTER TABLE mate_wiki_raw_material ADD COLUMN warning_code VARCHAR(64) DEFAULT NULL; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'mate_wiki_raw_material' AND column_name = 'warning_message' + ) THEN + ALTER TABLE mate_wiki_raw_material ADD COLUMN warning_message VARCHAR(512) DEFAULT NULL; + END IF; +END $$; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V162__wiki_raw_material_error_code.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V162__wiki_raw_material_error_code.sql new file mode 100644 index 00000000..d3a9df5c --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V162__wiki_raw_material_error_code.sql @@ -0,0 +1,12 @@ +-- V162: structured error_code on wiki raw material. +-- The processing pipeline already classifies failures into a stable vocabulary +-- (AUTH_ERROR / BILLING / MODEL_NOT_FOUND / RATE_LIMIT / TIMEOUT / SERVER_ERROR / +-- CONTENT_FILTER / UNKNOWN, see WikiProcessingService#classifyErrorCode) but only +-- the free-text error_message reached the raw_material row — so the frontend could +-- not localize the failure into a user-friendly hint. Persisting the code lets the +-- UI render a friendly i18n message and keep the raw message as a collapsible detail. +-- Nullable: NULL = no error (or a legacy failure recorded before this column existed). +-- MySQL lacks `ADD COLUMN IF NOT EXISTS`; use INFORMATION_SCHEMA guard instead. +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_raw_material' AND COLUMN_NAME = 'error_code'); +SET @s := IF(@c = 0, 'ALTER TABLE mate_wiki_raw_material ADD COLUMN error_code VARCHAR(64) DEFAULT NULL', 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V163__wiki_raw_material_warning.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V163__wiki_raw_material_warning.sql new file mode 100644 index 00000000..1f6b886f --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V163__wiki_raw_material_warning.sql @@ -0,0 +1,16 @@ +-- V163: non-blocking warning surface on wiki raw material. +-- Some ingest sub-steps run async *after* the material is already marked +-- completed/partial — embedding (semantic search) and entity-graph extraction. +-- When they fail the material stays "completed" but is silently degraded +-- (e.g. not searchable), and previously the only trace was a server log line. +-- These columns let such a failure show as a non-blocking warning on an +-- otherwise-successful row. Mirrors the error_code/error_message pair so the +-- UI can render a localized friendly hint; NULL = no warning. +-- MySQL lacks `ADD COLUMN IF NOT EXISTS`; use INFORMATION_SCHEMA guard instead. +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_raw_material' AND COLUMN_NAME = 'warning_code'); +SET @s := IF(@c = 0, 'ALTER TABLE mate_wiki_raw_material ADD COLUMN warning_code VARCHAR(64) DEFAULT NULL', 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_raw_material' AND COLUMN_NAME = 'warning_message'); +SET @s := IF(@c = 0, 'ALTER TABLE mate_wiki_raw_material ADD COLUMN warning_message VARCHAR(512) DEFAULT NULL', 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/mateclaw-server/src/main/resources/docs/en/api.md b/mateclaw-server/src/main/resources/docs/en/api.md index 70746b09..f65379a4 100644 --- a/mateclaw-server/src/main/resources/docs/en/api.md +++ b/mateclaw-server/src/main/resources/docs/en/api.md @@ -768,6 +768,7 @@ Total routes extracted: 406. | Method | Path | Purpose / handler | |---|---|---| | `POST` | `/api/v1/wiki/admin/backfill-tokens` | `Force-run the token-count backfill batch now` | +| `GET` | `/api/v1/wiki/admin/failures` | `Cross-KB list of materials needing attention (failed/partial/degraded) — admin` | | `POST` | `/api/v1/wiki/admin/kb/{kbId}/rebuild-overview` | `Ensure overview/log scaffold + rebuild overview stats now` | | `GET` | `/api/v1/wiki/chunks/{chunkId}/pages` | `Pages By Chunk Id` | | `DELETE` | `/api/v1/wiki/hot-cache/{kbId}` | `Soft-delete the hot cache row` | diff --git a/mateclaw-server/src/main/resources/docs/en/wiki.md b/mateclaw-server/src/main/resources/docs/en/wiki.md index bf2e44c6..32c30033 100644 --- a/mateclaw-server/src/main/resources/docs/en/wiki.md +++ b/mateclaw-server/src/main/resources/docs/en/wiki.md @@ -672,7 +672,7 @@ Core tables (see feature sections for the complete list): | Table | Purpose | |---|---| | `mate_wiki_knowledge_base` | One row per KB. Owner, name, description, config JSON (`ingestMode`, `wikiDefaultModelId`, `stepModels`, `entityExtractionEnabled`, `entityTypes`, fallback chain). | -| `mate_wiki_raw_material` | One row per upload. Status, byte hash, source path, last successfully-processed hash. | +| `mate_wiki_raw_material` | One row per upload. Status, byte hash, source path, last successfully-processed hash; structured `error_code` + `error_message` on failure, and `warning_code` + `warning_message` when completed-but-degraded. | | `mate_wiki_page` | One row per generated page. Title, summary, body, `source_raw_ids` (provenance), `page_type`, `locked`, version, plus `embedding` / `embedding_model` / `embedding_text_version` so transformation synthesis pages enter semantic search directly. | | `mate_wiki_chunk` | One row per chunk. content + hash + offsets + embedding, plus `page_number`, `header_breadcrumb`, `source_section`, `token_count`. | | `mate_wiki_relation` | Cached page-to-page edges (shared chunks, shared raws, direct links, semantic neighbors) used to power the 1-hop retrieval boost and the related-pages tool. | @@ -697,6 +697,7 @@ For when you don't want to wait for the cron / event hooks to catch up: |---|---| | `POST /api/v1/wiki/admin/kb/{kbId}/rebuild-overview` | Force-rewrite the overview marker region from current stats. | | `POST /api/v1/wiki/admin/backfill-tokens` | Run one batch of the token-count backfill now; returns `pendingBefore` / `pendingAfter` / `filledThisBatch`. | +| `GET /api/v1/wiki/admin/failures?limit=100` | Cross-KB list of materials needing attention (failed / partial / warning); see "Failure visibility" below (platform admin). | The `mate.wiki` block in `application.yml` controls global knobs (chunk size, parallelism, auto-process-on-upload). Per-KB knobs (ingest mode, step models, fallback chain) live inside the KB's `configContent` JSON and are edited through the config UI. @@ -704,6 +705,44 @@ The `mate.wiki` block in `application.yml` controls global knobs (chunk size, pa --- +## Failure visibility + +Ingest is mostly async background work, so failures used to be visible only in the server log. They are now **structured onto the raw material and pushed live to the UI**. + +### Structured error codes + +When a raw material fails, alongside the raw text (`error_message`) it records a **structured `error_code`**: + +`AUTH_ERROR` / `BILLING` / `MODEL_NOT_FOUND` / `RATE_LIMIT` / `TIMEOUT` / `SERVER_ERROR` (5xx) / `CONTENT_FILTER` / `NO_CONTENT` (no extractable text) / `EMPTY_RESULT` (model produced no pages) / `UNKNOWN`. + +The UI renders a localized friendly hint from the code (e.g. "Model authentication failed — check the provider key") and keeps the raw exception as a hover detail. Both columns are cleared on a successful reprocess. + +### Non-blocking warnings + +Some sub-steps run async **after** the material is already completed — embedding and entity-graph extraction. Their failure does not affect the pages, but it degrades the material (most notably: a failed embedding means the material is not semantically searchable yet). Instead of only logging, these record a non-blocking `warning_code` (`EMBEDDING_FAILED` / `ENTITY_EXTRACTION_FAILED`) + `warning_message`; the material stays "completed" but carries a ⚠ marker. + +### Progress SSE events + +The KB progress stream `GET /api/v1/wiki/knowledge-bases/{kbId}/progress` (SSE) emits: + +| Event | When | Key fields | +|---|---|---| +| `raw.started` | a material starts processing | `rawId` | +| `route.done` / `chunk.done` | stage progress | `rawId` + progress counters | +| `raw.completed` | material finished (incl. partial) | `rawId` / `status` / `totalPages` | +| `raw.failed` | material failed | `rawId` / `error` / `errorCode` | +| `raw.warning` | completed but an async sub-step failed | `rawId` / `warning` / `warningCode` | + +### Cross-KB failure center (admin) + +Instead of opening each KB in turn, an admin sees everything needing attention (failed / partial / warning) in one place: + +- `GET /api/v1/wiki/admin/failures?limit=100` — lists across **all** knowledge bases with KB name, status, error/warning code, and time (platform admin `ROLE_ADMIN`, spans every workspace). +- The notification summary `GET /api/v1/notifications/summary` gains a `failedWikiJobs` count, driving the attention badge on the sidebar Wiki item. +- The frontend Wiki library view shows a collapsible failure center at the top with one-click open into the owning KB. + +--- + ## When to use it Reach for a Wiki KB when you have: diff --git a/mateclaw-server/src/main/resources/docs/zh/api.md b/mateclaw-server/src/main/resources/docs/zh/api.md index 1c81926c..ac313a2b 100644 --- a/mateclaw-server/src/main/resources/docs/zh/api.md +++ b/mateclaw-server/src/main/resources/docs/zh/api.md @@ -768,6 +768,7 @@ curl -X PUT "http://localhost:18088/api/v1/auth/users/1/password?oldPassword=adm | 方法 | 路径 | 用途 / handler | |---|---|---| | `POST` | `/api/v1/wiki/admin/backfill-tokens` | `Force-run the token-count backfill batch now` | +| `GET` | `/api/v1/wiki/admin/failures` | `跨知识库列出需要关注的处理失败/降级材料(管理员)` | | `POST` | `/api/v1/wiki/admin/kb/{kbId}/rebuild-overview` | `Ensure overview/log scaffold + rebuild overview stats now` | | `GET` | `/api/v1/wiki/chunks/{chunkId}/pages` | `Pages By Chunk Id` | | `DELETE` | `/api/v1/wiki/hot-cache/{kbId}` | `Soft-delete the hot cache row` | diff --git a/mateclaw-server/src/main/resources/docs/zh/wiki.md b/mateclaw-server/src/main/resources/docs/zh/wiki.md index 3122a145..0dc29fd1 100644 --- a/mateclaw-server/src/main/resources/docs/zh/wiki.md +++ b/mateclaw-server/src/main/resources/docs/zh/wiki.md @@ -627,7 +627,7 @@ stepModels[step] → wikiDefaultModelId → 系统默认模型 | 表名 | 用途 | |------|------| | `mate_wiki_knowledge_base` | 每个 KB 一行。owner、名字、描述、配置 JSON(含 `ingestMode` / `wikiDefaultModelId` / `stepModels` / `entityExtractionEnabled` / `entityTypes` 等)。 | -| `mate_wiki_raw_material` | 每份上传一行。状态、byte hash、来源路径、上次成功处理时的 hash。 | +| `mate_wiki_raw_material` | 每份上传一行。状态、byte hash、来源路径、上次成功处理时的 hash;失败时的结构化 `error_code` + `error_message`,已完成但降级时的 `warning_code` + `warning_message`。 | | `mate_wiki_page` | 每个生成页面一行。标题、摘要、正文、`source_raw_ids`(回指原文)、`page_type`、`locked`、版本号,外加 `embedding` / `embedding_model` / `embedding_text_version` 让 synthesis 页直接进语义搜索。 | | `mate_wiki_chunk` | 每个 chunk 一行。content + hash + 偏移 + embedding,外加 `page_number` / `header_breadcrumb` / `source_section` / `token_count`。 | | `mate_wiki_relation` | 缓存的页对页边(共享 chunk / 共享原文 / 直接链接 / 语义近邻),用于检索时的 1 跳关系 boost 和关联推荐工具。 | @@ -652,6 +652,7 @@ stepModels[step] → wikiDefaultModelId → 系统默认模型 |---|---| | `POST /api/v1/wiki/admin/kb/{kbId}/rebuild-overview` | 立即按当前数据重写 overview marker 区域 | | `POST /api/v1/wiki/admin/backfill-tokens` | 立即跑一批 token_count 回填,返回 `pendingBefore/pendingAfter/filledThisBatch` | +| `GET /api/v1/wiki/admin/failures?limit=100` | 跨知识库列出需要关注的材料(failed / partial / 带告警),见下方"处理失败的可见性"(平台管理员) | `application.yml` 的 `mate.wiki` 配置块控制切块大小、并发度、auto-process 等全局参数;具体到每个 KB 的入库模式 / 模型策略 / 备选模型链,写在 KB 的 `configContent` JSON 里——前端配置页直接编辑。 @@ -659,6 +660,44 @@ stepModels[step] → wikiDefaultModelId → 系统默认模型 --- +## 处理失败的可见性 + +后台消化大多是异步任务,过去出错往往只能去服务端日志看。现在错误会**结构化地落到原始材料上、并实时推到前端**。 + +### 结构化错误码 + +每条 raw material 失败时,除原始错误文本(`error_message`)外还记一个**结构化错误码** `error_code`: + +`AUTH_ERROR`(鉴权失败)/ `BILLING`(额度/计费)/ `MODEL_NOT_FOUND` / `RATE_LIMIT`(限流)/ `TIMEOUT` / `SERVER_ERROR`(5xx)/ `CONTENT_FILTER`(安全策略拦截)/ `NO_CONTENT`(提取不到文本)/ `EMPTY_RESULT`(模型没产出页面)/ `UNKNOWN`。 + +前端据此显示本地化友好提示(如"模型鉴权失败,请检查供应商密钥"),原始异常串折叠为 hover 详情。重新处理成功后这两列自动清空。 + +### 非阻断告警 + +有些子步骤在材料**已完成之后**才异步跑——向量化(embedding)、实体图谱抽取。它们失败不影响页面本身,但会让材料**降级**(最典型:向量化失败 → 该材料暂时无法被语义检索)。这类失败不再只写日志,而是记一个非阻断告警 `warning_code`(`EMBEDDING_FAILED` / `ENTITY_EXTRACTION_FAILED`)+ `warning_message`,材料仍是"完成"但带一个 ⚠ 标记。 + +### 进度 SSE 事件 + +KB 进度流 `GET /api/v1/wiki/knowledge-bases/{kbId}/progress`(SSE)推送: + +| 事件 | 何时 | 关键字段 | +|---|---|---| +| `raw.started` | 开始处理一条材料 | `rawId` | +| `route.done` / `chunk.done` | 阶段进度 | `rawId` + 进度计数 | +| `raw.completed` | 材料完成(含 partial) | `rawId` / `status` / `totalPages` | +| `raw.failed` | 材料失败 | `rawId` / `error` / `errorCode` | +| `raw.warning` | 已完成但某异步子步骤失败 | `rawId` / `warning` / `warningCode` | + +### 跨知识库失败中心(管理员) + +不用逐个 KB 翻,管理员可在一处看全部需要关注的材料(failed / partial / 带告警): + +- `GET /api/v1/wiki/admin/failures?limit=100` —— 跨**所有**知识库列出,含 KB 名、状态、错误/告警码、时间(平台管理员 `ROLE_ADMIN`,跨 workspace)。 +- 通知摘要 `GET /api/v1/notifications/summary` 新增 `failedWikiJobs` 计数,驱动侧边栏 Wiki 入口的关注徽标。 +- 前端 Wiki 库视图顶部有一个可折叠的"失败中心",一键进入对应 KB。 + +--- + ## 什么时候该用它 用 Wiki KB 当你有: diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/repository/WikiRawMaterialFailuresMapperE2ETest.java b/mateclaw-server/src/test/java/vip/mate/wiki/repository/WikiRawMaterialFailuresMapperE2ETest.java new file mode 100644 index 00000000..f9287f8b --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/repository/WikiRawMaterialFailuresMapperE2ETest.java @@ -0,0 +1,95 @@ +package vip.mate.wiki.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import vip.mate.wiki.dto.WikiFailureItem; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; +import vip.mate.wiki.model.WikiRawMaterialEntity; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Validates the centralized failure queries against H2: the NEEDS_ATTENTION + * predicate must capture failed / partial / warning rows and exclude clean + * completed and pending ones, and the list must join the KB display name. + */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.NONE, + properties = { + "spring.flyway.enabled=true", + "spring.flyway.locations=classpath:db/migration/h2", + "mateclaw.feature-flag.refresh-ms=999999" + } +) +class WikiRawMaterialFailuresMapperE2ETest { + + @Autowired + private WikiRawMaterialMapper rawMapper; + @Autowired + private WikiKnowledgeBaseMapper kbMapper; + + private static final java.util.concurrent.atomic.AtomicLong SEQ = + new java.util.concurrent.atomic.AtomicLong(System.nanoTime()); + + private long newKb(String name) { + WikiKnowledgeBaseEntity kb = new WikiKnowledgeBaseEntity(); + long id = SEQ.incrementAndGet(); + kb.setId(id); + kb.setName(name); + kb.setStatus("active"); + kb.setWorkspaceId(1L); + kb.setCreateTime(LocalDateTime.now()); + kb.setUpdateTime(LocalDateTime.now()); + kb.setDeleted(0); + kbMapper.insert(kb); + return id; + } + + private long raw(long kbId, String status, String errorCode, String warningCode) { + WikiRawMaterialEntity r = new WikiRawMaterialEntity(); + long id = SEQ.incrementAndGet(); + r.setId(id); + r.setKbId(kbId); + r.setTitle("raw-" + id); + r.setSourceType("text"); + r.setProcessingStatus(status); + r.setErrorCode(errorCode); + r.setWarningCode(warningCode); + r.setCreateTime(LocalDateTime.now()); + r.setUpdateTime(LocalDateTime.now()); + r.setDeleted(0); + rawMapper.insert(r); + return id; + } + + @Test + void needsAttentionPredicateCapturesTheRightRows() { + long kb = newKb("KB-Failures"); + long failed = raw(kb, "failed", "AUTH_ERROR", null); + long partial = raw(kb, "partial", null, null); + long degraded = raw(kb, "completed", null, "EMBEDDING_FAILED"); + long clean = raw(kb, "completed", null, null); + long pending = raw(kb, "pending", null, null); + + long countBefore = rawMapper.countFailures(); + assertTrue(countBefore >= 3, "count should include the 3 attention-needing rows"); + + List mine = rawMapper.listFailures(500).stream() + .filter(i -> i.kbId().equals(kb)) + .toList(); + + List ids = mine.stream().map(WikiFailureItem::rawId).toList(); + assertTrue(ids.contains(failed), "failed row must surface"); + assertTrue(ids.contains(partial), "partial row must surface"); + assertTrue(ids.contains(degraded), "degraded (warning) row must surface"); + assertFalse(ids.contains(clean), "clean completed row must not surface"); + assertFalse(ids.contains(pending), "pending row must not surface"); + + // Join carries the KB display name through to the projection. + assertEquals("KB-Failures", mine.get(0).kbName()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingServiceErrorCodeTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingServiceErrorCodeTest.java new file mode 100644 index 00000000..f11a960d --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingServiceErrorCodeTest.java @@ -0,0 +1,139 @@ +package vip.mate.wiki.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.agent.AgentGraphBuilder; +import vip.mate.llm.service.ModelConfigService; +import vip.mate.wiki.WikiProperties; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; +import vip.mate.wiki.model.WikiRawMaterialEntity; +import vip.mate.wiki.sse.WikiProgressBus; + +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Covers the structured error-code path added so the frontend can localize + * Wiki processing failures instead of echoing raw exception text: + *

    + *
  • {@link WikiProcessingService#classifyErrorCode} maps exceptions to the + * stable vocabulary, and
  • + *
  • a real failure propagates that code into both the persisted row + * (4-arg {@code updateProcessingStatus}) and the {@code RAW_FAILED} + * SSE payload.
  • + *
+ */ +class WikiProcessingServiceErrorCodeTest { + + private WikiKnowledgeBaseService kbService; + private WikiRawMaterialService rawService; + private WikiChunkService chunkService; + private WikiEmbeddingService embeddingService; + private WikiProgressBus progressBus; + private WikiProcessingService service; + + private static final Long KB_ID = 7L; + private static final Long RAW_ID = 99L; + + @BeforeEach + void setUp() { + kbService = mock(WikiKnowledgeBaseService.class); + rawService = mock(WikiRawMaterialService.class); + chunkService = mock(WikiChunkService.class); + embeddingService = mock(WikiEmbeddingService.class); + progressBus = mock(WikiProgressBus.class); + ObjectMapper om = new ObjectMapper(); + service = new WikiProcessingService( + kbService, rawService, mock(WikiPageService.class), chunkService, + embeddingService, new WikiLinkService(om), + new WikiProperties(), mock(ModelConfigService.class), + mock(AgentGraphBuilder.class), om, progressBus, + mock(WikiCitationService.class), + mock(org.springframework.context.ApplicationEventPublisher.class), + mock(WikiEntityExtractionService.class)); + } + + @Test + @DisplayName("classifyErrorCode maps the stable failure vocabulary") + void classifyErrorCode_mapsVocabulary() { + assertEquals("AUTH_ERROR", service.classifyErrorCode(new RuntimeException("401 Unauthorized"))); + assertEquals("AUTH_ERROR", service.classifyErrorCode(new RuntimeException("invalid api key"))); + assertEquals("BILLING", service.classifyErrorCode(new RuntimeException("insufficient_quota"))); + assertEquals("MODEL_NOT_FOUND", service.classifyErrorCode(new RuntimeException("model not found"))); + assertEquals("RATE_LIMIT", service.classifyErrorCode(new RuntimeException("429 too many requests"))); + assertEquals("TIMEOUT", service.classifyErrorCode(new RuntimeException("Read timed out"))); + assertEquals("SERVER_ERROR", service.classifyErrorCode(new RuntimeException("503 Service Unavailable"))); + assertEquals("CONTENT_FILTER", service.classifyErrorCode(new RuntimeException("data_inspection_failed"))); + assertEquals("UNKNOWN", service.classifyErrorCode(new RuntimeException("something odd"))); + // Unwraps nested causes. + assertEquals("AUTH_ERROR", + service.classifyErrorCode(new RuntimeException("wrap", new IllegalStateException("403 forbidden")))); + } + + @Test + @DisplayName("lazy failure persists the classified code and includes it in RAW_FAILED") + void lazyFailure_propagatesErrorCode() { + WikiRawMaterialEntity raw = new WikiRawMaterialEntity(); + raw.setId(RAW_ID); + raw.setKbId(KB_ID); + raw.setProcessingStatus("pending"); + WikiKnowledgeBaseEntity kb = new WikiKnowledgeBaseEntity(); + kb.setId(KB_ID); + kb.setConfigContent("{\"ingestMode\":\"lazy\"}"); + + when(rawService.claimForProcessing(RAW_ID)).thenReturn(true); + when(rawService.getById(RAW_ID)).thenReturn(raw); + when(rawService.getTextContent(raw)).thenReturn("Some real document text. ".repeat(20)); + when(kbService.getById(KB_ID)).thenReturn(kb); + // Chunk persistence blows up with an auth-shaped error. + doThrow(new RuntimeException("401 Unauthorized")) + .when(chunkService).persistChunks(eq(KB_ID), eq(RAW_ID), any(), any()); + + service.processRawMaterial(RAW_ID); + + verify(rawService).updateProcessingStatus(eq(RAW_ID), eq("failed"), eq("AUTH_ERROR"), eq("401 Unauthorized")); + verify(progressBus).broadcast(eq(KB_ID), eq(WikiProgressBus.EVENT_RAW_FAILED), + argThat((Map m) -> "AUTH_ERROR".equals(m.get("errorCode")) + && "401 Unauthorized".equals(m.get("error")))); + } + + @Test + @DisplayName("async embedding failure surfaces a non-blocking warning, not a failed status") + void embeddingFailure_surfacesWarning() throws InterruptedException { + WikiRawMaterialEntity raw = new WikiRawMaterialEntity(); + raw.setId(RAW_ID); + raw.setKbId(KB_ID); + raw.setProcessingStatus("pending"); + WikiKnowledgeBaseEntity kb = new WikiKnowledgeBaseEntity(); + kb.setId(KB_ID); + kb.setConfigContent("{\"ingestMode\":\"lazy\"}"); + + when(rawService.claimForProcessing(RAW_ID)).thenReturn(true); + when(rawService.getById(RAW_ID)).thenReturn(raw); + when(rawService.getTextContent(raw)).thenReturn("Some real document text. ".repeat(20)); + when(kbService.getById(KB_ID)).thenReturn(kb); + + // The async embedding sweep fails; recordWarning fires from that thread, + // so latch on it to make the assertion deterministic. + CountDownLatch warned = new CountDownLatch(1); + when(embeddingService.embedMissingChunks(KB_ID)).thenThrow(new RuntimeException("embed boom")); + doAnswer(inv -> { warned.countDown(); return null; }) + .when(rawService).recordWarning(eq(RAW_ID), eq("EMBEDDING_FAILED"), any()); + + service.processRawMaterial(RAW_ID); + + assertTrue(warned.await(5, TimeUnit.SECONDS), "recordWarning should have been invoked"); + // Material itself completed — the warning must not have flipped it to failed. + verify(rawService, never()).updateProcessingStatus(eq(RAW_ID), eq("failed"), any(), any()); + verify(progressBus).broadcast(eq(KB_ID), eq(WikiProgressBus.EVENT_RAW_WARNING), + argThat((Map m) -> "EMBEDDING_FAILED".equals(m.get("warningCode")))); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingServiceLazyTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingServiceLazyTest.java index 075407fc..c0d41cf9 100644 --- a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingServiceLazyTest.java +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingServiceLazyTest.java @@ -195,8 +195,10 @@ class WikiProcessingServiceLazyTest { service.processRawMaterial(RAW_ID); verify(chunkService, never()).persistChunks(anyLong(), anyLong(), anyList(), anyList()); - verify(rawService).updateProcessingStatus(eq(RAW_ID), eq("failed"), eq("No text content available")); - verify(progressBus).broadcast(eq(KB_ID), eq(WikiProgressBus.EVENT_RAW_FAILED), any()); + verify(rawService).updateProcessingStatus(eq(RAW_ID), eq("failed"), eq("NO_CONTENT"), eq("No text content available")); + // RAW_FAILED now carries the structured errorCode alongside the message. + verify(progressBus).broadcast(eq(KB_ID), eq(WikiProgressBus.EVENT_RAW_FAILED), + argThat((Map m) -> "NO_CONTENT".equals(m.get("errorCode")))); } @Test diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiRawMaterialFailureStateTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiRawMaterialFailureStateTest.java new file mode 100644 index 00000000..1249f37c --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiRawMaterialFailureStateTest.java @@ -0,0 +1,100 @@ +package vip.mate.wiki.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.context.ApplicationEventPublisher; +import vip.mate.system.featureflag.FeatureFlagService; +import vip.mate.tool.builtin.DocumentExtractTool; +import vip.mate.tool.image.vision.ImageVisionService; +import vip.mate.wiki.WikiProperties; +import vip.mate.wiki.model.WikiRawMaterialEntity; +import vip.mate.wiki.repository.WikiRawMaterialMapper; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Covers the structured error-code + non-blocking warning state on + * {@link WikiRawMaterialService}: the 4-arg status update persists the code, + * {@code recordWarning} flags a degraded-but-completed row without changing its + * status, and {@code claimForProcessing} wipes any stale failure/warning so a + * re-run starts clean (required because these columns are FieldStrategy.ALWAYS). + */ +class WikiRawMaterialFailureStateTest { + + private WikiRawMaterialMapper rawMapper; + private WikiRawMaterialService service; + + private static final Long ID = 99L; + + @BeforeEach + void setUp() { + rawMapper = mock(WikiRawMaterialMapper.class); + WikiProperties props = new WikiProperties(); + props.setAutoProcessOnUpload(false); + service = new WikiRawMaterialService(rawMapper, mock(WikiKnowledgeBaseService.class), props, + mock(ApplicationEventPublisher.class), mock(DocumentExtractTool.class), + mock(WikiChunkService.class), mock(ImageVisionService.class), + mock(PdfImageExtractor.class), mock(FeatureFlagService.class)); + } + + private WikiRawMaterialEntity row(String status) { + WikiRawMaterialEntity e = new WikiRawMaterialEntity(); + e.setId(ID); + e.setProcessingStatus(status); + e.setCancelRequested(Boolean.FALSE); + return e; + } + + @Test + @DisplayName("updateProcessingStatus(4-arg) persists the structured error code") + void updateStatus_persistsErrorCode() { + when(rawMapper.selectById(ID)).thenReturn(row("processing")); + + service.updateProcessingStatus(ID, "failed", "AUTH_ERROR", "401 Unauthorized"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WikiRawMaterialEntity.class); + verify(rawMapper).updateById(captor.capture()); + assertEquals("AUTH_ERROR", captor.getValue().getErrorCode()); + assertEquals("401 Unauthorized", captor.getValue().getErrorMessage()); + assertEquals("failed", captor.getValue().getProcessingStatus()); + } + + @Test + @DisplayName("recordWarning flags a degraded row without touching its status") + void recordWarning_persistsWithoutStatusChange() { + when(rawMapper.selectById(ID)).thenReturn(row("completed")); + + service.recordWarning(ID, "EMBEDDING_FAILED", "circuit breaker open"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WikiRawMaterialEntity.class); + verify(rawMapper).updateById(captor.capture()); + assertEquals("EMBEDDING_FAILED", captor.getValue().getWarningCode()); + assertEquals("circuit breaker open", captor.getValue().getWarningMessage()); + assertEquals("completed", captor.getValue().getProcessingStatus()); + } + + @Test + @DisplayName("claimForProcessing wipes stale error + warning state for a clean re-run") + void claim_clearsFailureState() { + WikiRawMaterialEntity stale = row("pending"); + stale.setErrorCode("AUTH_ERROR"); + stale.setErrorMessage("old error"); + stale.setWarningCode("EMBEDDING_FAILED"); + stale.setWarningMessage("old warning"); + when(rawMapper.selectById(ID)).thenReturn(stale); + + assertTrue(service.claimForProcessing(ID)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WikiRawMaterialEntity.class); + verify(rawMapper).updateById(captor.capture()); + WikiRawMaterialEntity persisted = captor.getValue(); + assertNull(persisted.getErrorCode()); + assertNull(persisted.getErrorMessage()); + assertNull(persisted.getWarningCode()); + assertNull(persisted.getWarningMessage()); + assertEquals("processing", persisted.getProcessingStatus()); + } +} diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index ab60b2c2..158b7144 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -390,6 +390,7 @@ export const liveApi = { export interface NotificationSummary { pendingApprovals: number stuckAgents: number + failedWikiJobs: number failedCrons: number downChannels: number downMcps: number @@ -782,6 +783,22 @@ export const cronJobApi = { } // ==================== Wiki Knowledge Base ==================== +// One row in the cross-KB failure center. ids are strings (global Long→String +// Jackson config) to avoid Snowflake precision loss. +export interface WikiFailureItem { + rawId: string + kbId: string + kbName: string + workspaceId: string | null + title: string + processingStatus: string + errorCode: string | null + errorMessage: string | null + warningCode: string | null + warningMessage: string | null + updateTime: string | null +} + export const wikiApi = { // Knowledge Base listKBs: () => http.get('/wiki/knowledge-bases'), @@ -802,6 +819,9 @@ export const wikiApi = { http.put(`/wiki/knowledge-bases/${id}/source-directory`, { path }), scanDirectory: (id: number) => http.post(`/wiki/knowledge-bases/${id}/scan`), + // Centralized cross-KB failure center (admin only) + listFailures: (limit = 100) => http.get<{ data: WikiFailureItem[] }>(`/wiki/admin/failures?limit=${limit}`), + // Raw Materials listRaw: (kbId: number) => http.get(`/wiki/knowledge-bases/${kbId}/raw`), addRawText: (kbId: number, data: { title: string; content: string }) => diff --git a/mateclaw-ui/src/composables/useNotificationCenter.ts b/mateclaw-ui/src/composables/useNotificationCenter.ts index 2ce7e2e5..d31789fc 100644 --- a/mateclaw-ui/src/composables/useNotificationCenter.ts +++ b/mateclaw-ui/src/composables/useNotificationCenter.ts @@ -18,6 +18,7 @@ const POLL_INTERVAL_MS = 15_000 const summary = ref({ pendingApprovals: 0, stuckAgents: 0, + failedWikiJobs: 0, failedCrons: 0, downChannels: 0, downMcps: 0, @@ -57,6 +58,7 @@ async function refresh(): Promise { summary.value = { pendingApprovals: toCount(raw.pendingApprovals), stuckAgents: toCount(raw.stuckAgents), + failedWikiJobs: toCount(raw.failedWikiJobs), failedCrons: toCount(raw.failedCrons), downChannels: toCount(raw.downChannels), downMcps: toCount(raw.downMcps), @@ -97,6 +99,7 @@ export function useNotificationCenter() { summary: computed(() => summary.value), pendingApprovals: computed(() => summary.value.pendingApprovals), stuckAgents: computed(() => summary.value.stuckAgents), + failedWikiJobs: computed(() => summary.value.failedWikiJobs), refresh, } } diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index cf6bf4c2..a232d9c0 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -514,6 +514,7 @@ export default { }, notifications: { pendingApprovals: '{n} tool call(s) pending approval', + failedWikiJobs: '{n} knowledge base material(s) failed or degraded', }, live: { kicker: 'Live', @@ -2507,6 +2508,30 @@ export default { cancelled: 'CANCELLED', cancelling: 'CANCELLING…', }, + // Friendly, localized hints keyed by the backend's structured error code. + // The raw exception text is kept as the hover tooltip for troubleshooting. + errorCode: { + AUTH_ERROR: 'Model authentication failed — check that the provider API key is correct and valid.', + BILLING: 'Provider quota exhausted or billing error — check your account balance.', + MODEL_NOT_FOUND: 'The selected model is missing or unavailable — switch models in the knowledge base settings.', + RATE_LIMIT: 'Rate-limited by the provider — please retry in a moment.', + TIMEOUT: 'The model timed out — retry later or switch to a faster model.', + SERVER_ERROR: 'The provider is temporarily unavailable (5xx) — please retry later.', + CONTENT_FILTER: 'Content was blocked by the model safety filter — adjust the material and retry.', + NO_CONTENT: 'No text could be extracted from this material — check that the file is not empty or corrupt.', + EMPTY_RESULT: 'The model generated no pages — reprocess to retry.', + UNKNOWN: 'Processing failed — see details or the server logs.', + }, + // Non-blocking warnings: the material processed but an async sub-step failed. + warningCode: { + EMBEDDING_FAILED: 'Embedding failed — this material is not semantically searchable yet; check the embedding model and reprocess.', + ENTITY_EXTRACTION_FAILED: 'Entity-graph extraction failed — the knowledge graph may be incomplete; reprocess later.', + UNKNOWN: 'Some background processing did not finish — see details.', + }, + failureCenter: { + title: 'Knowledge base processing issues', + open: 'Open', + }, progress: { preparing: 'Preparing…', uploading: 'Uploading {pct}%', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index fcefa1b8..afd405bc 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -2051,6 +2051,7 @@ export default { }, notifications: { pendingApprovals: '{n} 个工具调用等待审批', + failedWikiJobs: '{n} 个知识库材料处理失败或降级', }, live: { kicker: '现场', @@ -2519,6 +2520,30 @@ export default { cancelled: '已取消', cancelling: '正在取消…', }, + // Friendly, localized hints keyed by the backend's structured error code. + // The raw exception text is kept as the hover tooltip for troubleshooting. + errorCode: { + AUTH_ERROR: '模型鉴权失败,请检查供应商的 API Key 是否正确、有效', + BILLING: '供应商额度不足或计费异常,请检查账户余额', + MODEL_NOT_FOUND: '所选模型不存在或不可用,请在知识库设置中更换模型', + RATE_LIMIT: '请求过于频繁,已被供应商限流,请稍后重试', + TIMEOUT: '模型响应超时,请稍后重试或更换更快的模型', + SERVER_ERROR: '供应商服务暂时不可用(5xx),请稍后重试', + CONTENT_FILTER: '内容被模型安全策略拦截,请调整材料内容后重试', + NO_CONTENT: '未能从该材料中提取到文本内容,请检查文件是否为空或损坏', + EMPTY_RESULT: '模型未生成任何页面,可重新处理以重试', + UNKNOWN: '处理失败,请查看详情或后台日志', + }, + // Non-blocking warnings: the material processed but an async sub-step failed. + warningCode: { + EMBEDDING_FAILED: '向量化失败,该材料暂时无法被语义检索,请检查 embedding 模型后重新处理', + ENTITY_EXTRACTION_FAILED: '实体图谱抽取失败,知识图谱可能不完整,可稍后重新处理', + UNKNOWN: '部分后台处理未完成,请查看详情', + }, + failureCenter: { + title: '知识库处理异常', + open: '打开', + }, progress: { preparing: '准备中…', uploading: '上传 {pct}%', diff --git a/mateclaw-ui/src/stores/useWikiStore.ts b/mateclaw-ui/src/stores/useWikiStore.ts index 68f9f951..30ddd0e3 100644 --- a/mateclaw-ui/src/stores/useWikiStore.ts +++ b/mateclaw-ui/src/stores/useWikiStore.ts @@ -25,6 +25,14 @@ export interface WikiRawMaterial { processingStatus: string lastProcessedAt: string | null errorMessage: string | null + // Structured failure code (AUTH_ERROR / BILLING / MODEL_NOT_FOUND / RATE_LIMIT / + // TIMEOUT / SERVER_ERROR / CONTENT_FILTER / NO_CONTENT / EMPTY_RESULT / UNKNOWN); + // drives the localized friendly hint. null when there is no error. + errorCode: string | null + // Non-blocking warning: the material processed but an async sub-step + // (embedding / entity extraction) failed, degrading it. null when clean. + warningCode: string | null + warningMessage: string | null createTime: string // Two-stage ingestion progress: backend writes total after routing and // increments done as each generated page finishes. diff --git a/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue b/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue index dbbf9bb0..2700443f 100644 --- a/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue +++ b/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue @@ -180,10 +180,16 @@ {{ t('wiki.cancelledHint') }} - {{ raw.errorMessage }} + {{ friendlyError(raw) }} + + + ⚠ {{ friendlyWarning(raw) }}
@@ -327,6 +333,30 @@ const workspace = useWorkspaceStore() const canManageWiki = computed(() => workspace.can('manage:wiki')) const fileInput = ref(null) +// Map a structured backend errorCode to a localized, user-friendly hint. +// Falls back to the raw backend message, then to a generic failure label, so +// the user always sees something meaningful — never a blank "failed" badge. +function friendlyError(raw: { errorCode?: string | null; errorMessage?: string | null }): string { + const code = raw.errorCode + if (code) { + const key = `wiki.errorCode.${code}` + const msg = t(key) + if (msg !== key) return msg + } + return raw.errorMessage || t('wiki.errorCode.UNKNOWN') +} + +// Same idea for the non-blocking warning surface (degraded-but-usable rows). +function friendlyWarning(raw: { warningCode?: string | null; warningMessage?: string | null }): string { + const code = raw.warningCode + if (code) { + const key = `wiki.warningCode.${code}` + const msg = t(key) + if (msg !== key) return msg + } + return raw.warningMessage || t('wiki.warningCode.UNKNOWN') +} + // While raw materials are active, subscribe to the backend SSE progress stream. // A slower polling fallback keeps the UI in sync if SSE reconnects or misses a // terminal event. The database remains the source of truth. @@ -390,12 +420,32 @@ function openSse(kbId: number) { try { const data = JSON.parse(ev.data) const raw = store.rawMaterials.find(r => r.id === data.rawId) - if (raw) raw.processingStatus = 'failed' + if (raw) { + raw.processingStatus = 'failed' + // Surface the failure immediately from the event payload instead of + // waiting for the refresh round-trip — and never drop it: a null + // message would otherwise leave the user with a blank "failed" badge. + if (typeof data.error === 'string') raw.errorMessage = data.error + if (typeof data.errorCode === 'string') raw.errorCode = data.errorCode + } // Clear stale job entry delete rawJobs[data.rawId] if (store.currentKB) void store.refreshCurrentKB() } catch { /* ignore */ } }) + es.addEventListener('raw.warning', (ev: MessageEvent) => { + try { + const data = JSON.parse(ev.data) + const raw = store.rawMaterials.find(r => r.id === data.rawId) + // A warning lands async after the material already completed, so the + // refresh round-trip on raw.completed has already happened — apply it + // live here, otherwise it would only appear on the next manual reload. + if (raw) { + if (typeof data.warning === 'string') raw.warningMessage = data.warning + if (typeof data.warningCode === 'string') raw.warningCode = data.warningCode + } + } catch { /* ignore */ } + }) es.onerror = () => { // Browser EventSource auto-reconnects; just log // console.debug('Wiki SSE error/reconnect', kbId) @@ -855,6 +905,7 @@ async function handleScanDir() { .raw-item-meta { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } .raw-item-actions { display: flex; gap: 4px; flex-shrink: 0; } .error-hint { font-size: 11px; color: var(--mc-danger); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.warning-hint { font-size: 11px; color: var(--mc-warning, #d98e00); max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .page-count-chip { display: inline-flex; align-items: center; gap: 3px; font-size: 11px; font-weight: 500; color: var(--mc-text-secondary); background: var(--mc-bg-sunken); border-radius: 9999px; padding: 2px 7px; } /* Two-phase digest progress bar (RFC-012 M2 v2 UI) */ diff --git a/mateclaw-ui/src/views/Wiki/components/WikiFailureCenter.vue b/mateclaw-ui/src/views/Wiki/components/WikiFailureCenter.vue new file mode 100644 index 00000000..a5dded70 --- /dev/null +++ b/mateclaw-ui/src/views/Wiki/components/WikiFailureCenter.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/mateclaw-ui/src/views/Wiki/index.vue b/mateclaw-ui/src/views/Wiki/index.vue index ffa92a5d..190258e0 100644 --- a/mateclaw-ui/src/views/Wiki/index.vue +++ b/mateclaw-ui/src/views/Wiki/index.vue @@ -2,6 +2,10 @@
+