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.
This commit is contained in:
倪程伟 2026-06-28 13:07:34 +08:00 committed by GitHub
parent 7be8f81353
commit fbbd1218e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1002 additions and 27 deletions

View File

@ -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<String, Object> 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);

View File

@ -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<String, Object> 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.
*
* <p>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<List<WikiFailureItem>> 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");
}
}
}

View File

@ -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());

View File

@ -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
) {}

View File

@ -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"
* 供前端决定是否显示进度条以及显示"准备中"还是具体进度

View File

@ -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<WikiRawMaterialEntity>
"WHERE id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"AND deleted = 0</script>")
List<RawTitleRef> selectBatchTitles(@Param("ids") Collection<Long> 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<WikiFailureItem> listFailures(@Param("limit") int limit);
}

View File

@ -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));
}
}
}

View File

@ -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<vip.mate.wiki.dto.WikiFailureItem> 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()) {

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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 $$;

View File

@ -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 $$;

View File

@ -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;

View File

@ -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;

View File

@ -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` |

View File

@ -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:

View File

@ -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` |

View File

@ -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 当你有:

View File

@ -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<WikiFailureItem> mine = rawMapper.listFailures(500).stream()
.filter(i -> i.kbId().equals(kb))
.toList();
List<Long> 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());
}
}

View File

@ -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:
* <ul>
* <li>{@link WikiProcessingService#classifyErrorCode} maps exceptions to the
* stable vocabulary, and</li>
* <li>a real failure propagates that code into both the persisted row
* (4-arg {@code updateProcessingStatus}) and the {@code RAW_FAILED}
* SSE payload.</li>
* </ul>
*/
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<String, Object> 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<String, Object> m) -> "EMBEDDING_FAILED".equals(m.get("warningCode"))));
}
}

View File

@ -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<String, Object> m) -> "NO_CONTENT".equals(m.get("errorCode"))));
}
@Test

View File

@ -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<WikiRawMaterialEntity> 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<WikiRawMaterialEntity> 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<WikiRawMaterialEntity> 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());
}
}

View File

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

View File

@ -18,6 +18,7 @@ const POLL_INTERVAL_MS = 15_000
const summary = ref<NotificationSummary>({
pendingApprovals: 0,
stuckAgents: 0,
failedWikiJobs: 0,
failedCrons: 0,
downChannels: 0,
downMcps: 0,
@ -57,6 +58,7 @@ async function refresh(): Promise<void> {
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,
}
}

View File

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

View File

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

View File

@ -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.

View File

@ -180,10 +180,16 @@
{{ t('wiki.cancelledHint') }}
</span>
<span
v-else-if="raw.errorMessage && (raw.processingStatus === 'failed' || raw.processingStatus === 'partial')"
class="error-hint" :title="raw.errorMessage"
v-else-if="(raw.errorCode || raw.errorMessage) && (raw.processingStatus === 'failed' || raw.processingStatus === 'partial')"
class="error-hint" :title="raw.errorMessage || friendlyError(raw)"
>
{{ raw.errorMessage }}
{{ friendlyError(raw) }}
</span>
<span
v-if="(raw.warningCode || raw.warningMessage) && raw.processingStatus !== 'failed'"
class="warning-hint" :title="raw.warningMessage || friendlyWarning(raw)"
>
{{ friendlyWarning(raw) }}
</span>
</div>
<div v-if="canManageWiki" class="raw-item-actions">
@ -327,6 +333,30 @@ const workspace = useWorkspaceStore()
const canManageWiki = computed(() => workspace.can('manage:wiki'))
const fileInput = ref<HTMLInputElement | null>(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) */

View File

@ -0,0 +1,97 @@
<template>
<!--
Centralized, cross-KB view of materials needing attention. Renders only for
admins (the endpoint spans every workspace) and only when there is at least
one item, so it stays out of the way on a healthy install.
-->
<section v-if="items.length > 0" class="failure-center mc-surface-card">
<button class="fc-header" @click="collapsed = !collapsed">
<span class="fc-title">
<span class="fc-dot"></span>
{{ t('wiki.failureCenter.title') }}
<span class="fc-count">{{ items.length }}</span>
</span>
<svg class="fc-chevron" :class="{ open: !collapsed }" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<div v-show="!collapsed" class="fc-body">
<div v-for="it in items" :key="it.rawId" class="fc-row" @click="$emit('open', it.kbId)">
<span class="fc-badge" :class="rowKind(it)">{{ t(`wiki.status.${it.processingStatus}`) }}</span>
<div class="fc-main">
<div class="fc-line1">
<span class="fc-raw-title">{{ it.title }}</span>
<span class="fc-kb">· {{ it.kbName }}</span>
</div>
<div class="fc-msg" :title="it.errorMessage || it.warningMessage || ''">{{ friendly(it) }}</div>
</div>
<button class="fc-open" @click.stop="$emit('open', it.kbId)">{{ t('wiki.failureCenter.open') }}</button>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { wikiApi, type WikiFailureItem } from '@/api/index'
defineEmits<{ (e: 'open', kbId: string): void }>()
const { t } = useI18n()
const items = ref<WikiFailureItem[]>([])
const collapsed = ref(false)
// Whether a row is a hard failure vs a non-blocking warning, for badge tone.
function rowKind(it: WikiFailureItem): string {
if (it.processingStatus === 'failed') return 'failed'
if (it.processingStatus === 'partial') return 'partial'
return 'warning'
}
// Localized hint, keyed by the structured code; falls back to the raw text.
function friendly(it: WikiFailureItem): string {
if (it.errorCode) {
const key = `wiki.errorCode.${it.errorCode}`
const msg = t(key)
if (msg !== key) return msg
}
if (it.warningCode) {
const key = `wiki.warningCode.${it.warningCode}`
const msg = t(key)
if (msg !== key) return msg
}
return it.errorMessage || it.warningMessage || t('wiki.errorCode.UNKNOWN')
}
onMounted(async () => {
try {
const res: any = await wikiApi.listFailures(100)
items.value = res.data || res || []
} catch { /* admin-only endpoint; ignore for non-admins */ }
})
</script>
<style scoped>
.failure-center { margin-bottom: 16px; padding: 0; overflow: hidden; }
.fc-header { width: 100%; display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: transparent; border: none; cursor: pointer; color: var(--mc-text-primary); }
.fc-title { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; }
.fc-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--mc-warning, #d98e00); }
.fc-count { font-size: 11px; font-weight: 700; color: var(--mc-warning, #d98e00); background: color-mix(in srgb, var(--mc-warning, #d98e00) 14%, transparent); border-radius: 999px; padding: 1px 8px; }
.fc-chevron { transition: transform 0.15s; color: var(--mc-text-secondary); }
.fc-chevron.open { transform: rotate(180deg); }
.fc-body { border-top: 1px solid var(--mc-border); }
.fc-row { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid var(--mc-border); cursor: pointer; transition: background 0.12s; }
.fc-row:last-child { border-bottom: none; }
.fc-row:hover { background: var(--mc-bg-sunken); }
.fc-badge { flex-shrink: 0; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; padding: 2px 7px; border-radius: 6px; }
.fc-badge.failed { color: var(--mc-danger); background: color-mix(in srgb, var(--mc-danger) 12%, transparent); }
.fc-badge.partial, .fc-badge.warning { color: var(--mc-warning, #d98e00); background: color-mix(in srgb, var(--mc-warning, #d98e00) 14%, transparent); }
.fc-main { flex: 1; min-width: 0; }
.fc-line1 { display: flex; align-items: baseline; gap: 6px; }
.fc-raw-title { font-size: 13px; font-weight: 600; color: var(--mc-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fc-kb { font-size: 11px; color: var(--mc-text-secondary); flex-shrink: 0; }
.fc-msg { font-size: 11px; color: var(--mc-text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fc-open { flex-shrink: 0; font-size: 12px; padding: 4px 12px; border: 1px solid var(--mc-border); border-radius: 8px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); cursor: pointer; }
.fc-open:hover { background: var(--mc-bg-sunken); }
</style>

View File

@ -2,6 +2,10 @@
<div class="mc-page-shell wiki-shell">
<div class="mc-page-frame wiki-frame">
<div class="mc-page-inner wiki-inner">
<WikiFailureCenter
v-if="!store.currentKB && isAdmin"
@open="openFromFailureCenter"
/>
<WikiLibrary
v-if="!store.currentKB"
:kbs="store.knowledgeBases"
@ -40,7 +44,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, watch, onMounted } from 'vue'
import { ref, reactive, watch, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useWikiStore, type WikiKB } from '@/stores/useWikiStore'
@ -49,6 +53,7 @@ import { mcConfirm } from '@/components/common/useConfirm'
import { mcToast } from '@/composables/useMcToast'
import WikiLibrary from './components/WikiLibrary.vue'
import WikiWorkspace from './components/WikiWorkspace.vue'
import WikiFailureCenter from './components/WikiFailureCenter.vue'
const route = useRoute()
const router = useRouter()
@ -56,6 +61,10 @@ const router = useRouter()
const { t } = useI18n()
const store = useWikiStore()
// The cross-KB failure center spans every workspace, so it is admin-only
// mirrors the gate on the backing endpoint.
const isAdmin = computed(() => (localStorage.getItem('role') || 'user') === 'admin')
interface KBStats {
pageCount: number
enrichedPageCount: number
@ -86,6 +95,12 @@ async function enterKB(id: number) {
await store.selectKB(id, 'browse')
}
// The failure center emits a Snowflake kbId as a string keep it a string end
// to end (snowflake-precision-ok) and let the store cast satisfy its signature.
async function openFromFailureCenter(kbId: string) {
await store.selectKB(kbId as unknown as number, 'browse')
}
async function enterKBManage(id: number) {
await store.selectKB(id, 'manage')
}

View File

@ -71,6 +71,13 @@
:collapsed="effectiveCollapsed"
:title="t('notifications.pendingApprovals', { n: pendingApprovals })"
/>
<NavBadge
v-else-if="item.path === '/wiki' && isAdminRole"
:count="failedWikiJobs"
tone="warning"
:collapsed="effectiveCollapsed"
:title="t('notifications.failedWikiJobs', { n: failedWikiJobs })"
/>
</router-link>
</McTooltip>
</div>
@ -281,7 +288,7 @@ function goAutoApproveSettings() {
// Live view) and `/security` (pending approvals) read from a shared 15s poller
// so multiple consumers don't multiply HTTP traffic.
const isAdminRole = computed(() => (localStorage.getItem('role') || 'user') === 'admin')
const { stuckAgents, pendingApprovals } = useNotificationCenter()
const { stuckAgents, pendingApprovals, failedWikiJobs } = useNotificationCenter()
const liveAlertActive = computed(() => isAdminRole.value && stuckAgents.value > 0)
//