mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
7be8f81353
commit
fbbd1218e8
@ -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);
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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
|
||||
) {}
|
||||
@ -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")。
|
||||
* 供前端决定是否显示进度条以及显示"准备中"还是具体进度。
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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 $$;
|
||||
@ -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 $$;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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` |
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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` |
|
||||
|
||||
@ -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 当你有:
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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"))));
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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 }) =>
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}%',
|
||||
|
||||
@ -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}%',
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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) */
|
||||
|
||||
97
mateclaw-ui/src/views/Wiki/components/WikiFailureCenter.vue
Normal file
97
mateclaw-ui/src/views/Wiki/components/WikiFailureCenter.vue
Normal 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>
|
||||
@ -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')
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
// 移动端状态
|
||||
|
||||
Loading…
Reference in New Issue
Block a user