diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
index 5476857b..db166183 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
@@ -132,6 +132,7 @@ public class AgentGraphBuilder {
private final vip.mate.i18n.I18nService i18nService;
private final vip.mate.llm.failover.ProviderHealthTracker providerHealthTracker;
private final vip.mate.llm.chatmodel.ProviderChatModelFactory chatModelFactory;
+ private final vip.mate.llm.failover.AvailableProviderPool providerPool;
/**
* 根据 AgentEntity 构建完整的 Agent 实例
@@ -189,12 +190,12 @@ public class AgentGraphBuilder {
BaseAgent agent;
boolean toolCallingEnabled;
if ("plan_execute".equals(entity.getAgentType())) {
- agent = buildPlanExecuteAgent(toolSet, runtimeModel, maxIter);
+ agent = buildPlanExecuteAgent(toolSet, runtimeModel, maxIter, entity.getId());
toolCallingEnabled = true;
log.info("Built StateGraph Plan-Execute agent: {} (maxIterations={}, tools={}, protocol={})",
entity.getName(), maxIter, toolSet.size(), protocol.getId());
} else {
- agent = buildReActAgent(toolSet, runtimeModel, maxIter);
+ agent = buildReActAgent(toolSet, runtimeModel, maxIter, entity.getId());
// StateGraph 路径下工具调用由 ActionNode 控制,始终启用
toolCallingEnabled = true;
log.info("Built StateGraph ReAct agent: {} (maxIterations={}, tools={}, protocol={})",
@@ -236,34 +237,51 @@ public class AgentGraphBuilder {
// ==================== Agent 构建方法 ====================
StateGraphReActAgent buildReActAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel, int maxIter) {
+ return buildReActAgent(toolSet, runtimeModel, maxIter, null);
+ }
+
+ StateGraphReActAgent buildReActAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel,
+ int maxIter, Long agentId) {
ChatModel chatModel = buildRuntimeChatModel(runtimeModel);
ChatClient chatClient = ChatClient.create(chatModel);
String reasoningEffort = resolveReasoningEffortForModel(runtimeModel);
- CompiledGraph compiledGraph = buildReActGraph(toolSet, chatModel, maxIter, reasoningEffort, runtimeModel);
+ CompiledGraph compiledGraph = buildReActGraph(toolSet, chatModel, maxIter, reasoningEffort, runtimeModel, agentId);
return new StateGraphReActAgent(chatClient, conversationService, compiledGraph,
chatModel, conversationWindowManager);
}
StateGraphPlanExecuteAgent buildPlanExecuteAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel, int maxIter) {
+ return buildPlanExecuteAgent(toolSet, runtimeModel, maxIter, null);
+ }
+
+ StateGraphPlanExecuteAgent buildPlanExecuteAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel,
+ int maxIter, Long agentId) {
ChatModel chatModel = buildRuntimeChatModel(runtimeModel);
ChatClient chatClient = ChatClient.create(chatModel);
String reasoningEffort = resolveReasoningEffortForModel(runtimeModel);
- CompiledGraph graph = buildPlanExecuteGraph(toolSet, chatModel, maxIter, reasoningEffort, runtimeModel);
+ CompiledGraph graph = buildPlanExecuteGraph(toolSet, chatModel, maxIter, reasoningEffort, runtimeModel, agentId);
return new StateGraphPlanExecuteAgent(chatClient, conversationService, graph, planningService,
chatModel, conversationWindowManager);
}
CompiledGraph buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, String reasoningEffort) {
- return buildPlanExecuteGraph(toolSet, chatModel, maxIterations, reasoningEffort, null);
+ return buildPlanExecuteGraph(toolSet, chatModel, maxIterations, reasoningEffort, null, null);
}
CompiledGraph buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
String reasoningEffort, ModelConfigEntity primaryModelConfig) {
+ return buildPlanExecuteGraph(toolSet, chatModel, maxIterations, reasoningEffort, primaryModelConfig, null);
+ }
+
+ CompiledGraph buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
+ String reasoningEffort, ModelConfigEntity primaryModelConfig,
+ Long agentId) {
try {
- List fallbackChain = buildFallbackChain(primaryModelConfig);
+ List fallbackChain = buildFallbackChain(primaryModelConfig, agentId);
NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper(
streamTracker, fallbackChain, llmCacheMetricsAggregator, providerHealthTracker,
- primaryModelConfig != null ? primaryModelConfig.getProvider() : null);
+ primaryModelConfig != null ? primaryModelConfig.getProvider() : null,
+ providerPool);
ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, toolGuardService, approvalService, streamTracker, toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry);
PlanGenerationNode planGenerationNode = new PlanGenerationNode(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet);
StepExecutionNode stepExecutionNode = new StepExecutionNode(chatModel, toolSet, executor, planningService, streamTracker, reasoningEffort, streamingHelper, conversationWindowManager);
@@ -355,16 +373,23 @@ public class AgentGraphBuilder {
}
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, String reasoningEffort) {
- return buildReActGraph(toolSet, chatModel, maxIterations, reasoningEffort, null);
+ return buildReActGraph(toolSet, chatModel, maxIterations, reasoningEffort, null, null);
}
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
String reasoningEffort, ModelConfigEntity primaryModelConfig) {
+ return buildReActGraph(toolSet, chatModel, maxIterations, reasoningEffort, primaryModelConfig, null);
+ }
+
+ CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
+ String reasoningEffort, ModelConfigEntity primaryModelConfig,
+ Long agentId) {
try {
- List fallbackChain = buildFallbackChain(primaryModelConfig);
+ List fallbackChain = buildFallbackChain(primaryModelConfig, agentId);
NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper(
streamTracker, fallbackChain, llmCacheMetricsAggregator, providerHealthTracker,
- primaryModelConfig != null ? primaryModelConfig.getProvider() : null);
+ primaryModelConfig != null ? primaryModelConfig.getProvider() : null,
+ providerPool);
ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, toolGuardService, approvalService, streamTracker, toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry);
ReasoningNode reasoningNode = new ReasoningNode(chatModel, toolSet, reasoningEffort, streamingHelper, conversationWindowManager, streamTracker, 0, wikiContextService);
ActionNode actionNode = new ActionNode(executor, streamTracker);
@@ -512,7 +537,7 @@ public class AgentGraphBuilder {
}
/**
- * build the full multi-provider failover chain for a primary
+ * RFC-009: build the full multi-provider failover chain for a primary
* model. Providers are read from {@code mate_model_provider} ordered by
* {@code fallback_priority ASC} (positive values only), each resolved to
* its default {@link ModelConfigEntity} and turned into a {@link ChatModel}
@@ -526,13 +551,27 @@ public class AgentGraphBuilder {
*
The primary model is excluded from the chain when its provider +
* model name matches a chain entry. Previously only reference equality
* was checked, which meant a DashScope-primary deployment ended up with
- * {@code null} fallback — the case this design targets.
+ * {@code null} fallback — exactly the case RFC-009 targets.
*
* @param primaryModelConfig the {@code ModelConfigEntity} used to build
* the primary model; used to identity-filter the chain
* @return ordered, possibly-empty list of fallback {@link ChatModel}s
*/
List buildFallbackChain(ModelConfigEntity primaryModelConfig) {
+ return buildFallbackChain(primaryModelConfig, null);
+ }
+
+ /**
+ * RFC-009 PR-3 overload: when {@code agentId} is non-null, the agent's
+ * {@code mate_agent_provider_preference} rows bias the chain order — listed
+ * providers come first in their declared {@code sort_order}, then the
+ * remaining providers fall in by global {@code fallback_priority} ascending,
+ * tie-broken by provider id alphabetically. {@code null} agentId keeps the
+ * pre-PR-3 ordering (pure global priority) — that's the path for legacy
+ * callers and tests.
+ */
+ List buildFallbackChain(ModelConfigEntity primaryModelConfig,
+ Long agentId) {
List providers;
try {
providers = modelProviderService.listFallbackChain();
@@ -547,8 +586,29 @@ public class AgentGraphBuilder {
String primaryProviderId = primaryModelConfig != null ? primaryModelConfig.getProvider() : null;
String primaryModelName = primaryModelConfig != null ? primaryModelConfig.getModelName() : null;
+ // RFC-009 PR-3: bias by agent preferences (if any). Listed providers win
+ // their declared order; everything else keeps the global priority order.
+ List preferred = agentId == null
+ ? java.util.Collections.emptyList()
+ : agentBindingService.getPreferredProviderIds(agentId);
+ if (!preferred.isEmpty()) {
+ providers = reorderByPreferences(providers, preferred);
+ log.debug("[LlmFailover] agent={} preferences={} -> chain head reordered", agentId, preferred);
+ }
+
List chain = new ArrayList<>();
for (ModelProviderEntity p : providers) {
+ // RFC-009 Phase 4: skip providers known-bad at build time. This is
+ // a perf optimization (one fewer ChatModel to construct + one
+ // fewer round-trip on the chain walk); the runtime walker in
+ // NodeStreamingChatHelper re-checks pool membership per request,
+ // so a provider that re-enters the pool later still gets used
+ // (the graph is rebuilt on ModelConfigChangedEvent).
+ if (providerPool != null && !providerPool.contains(p.getProviderId())) {
+ log.debug("[LlmFailover] skipping provider {} — not in available pool",
+ p.getProviderId());
+ continue;
+ }
ModelConfigEntity fallbackConfig;
try {
fallbackConfig = modelConfigService.getDefaultModelByProvider(p.getProviderId());
@@ -584,6 +644,36 @@ public class AgentGraphBuilder {
return chain;
}
+ /**
+ * Reorder a provider list by an agent's preference list. Listed provider
+ * ids come first in their preference order; any provider not in the
+ * preference list keeps its original position relative to other unlisted
+ * providers (stable partition). Preference entries that don't match any
+ * actual provider are silently dropped.
+ */
+ /** Package-private for unit testing — see {@code AgentGraphBuilderPreferenceTest}. */
+ static List reorderByPreferences(List providers,
+ List preferredOrder) {
+ Map byId = new java.util.LinkedHashMap<>();
+ for (ModelProviderEntity p : providers) {
+ byId.put(p.getProviderId(), p);
+ }
+ List reordered = new ArrayList<>(providers.size());
+ Set placed = new java.util.HashSet<>();
+ for (String prefId : preferredOrder) {
+ ModelProviderEntity p = byId.get(prefId);
+ if (p != null && placed.add(prefId)) {
+ reordered.add(p);
+ }
+ }
+ for (ModelProviderEntity p : providers) {
+ if (placed.add(p.getProviderId())) {
+ reordered.add(p);
+ }
+ }
+ return reordered;
+ }
+
/**
* @deprecated use {@link #buildFallbackChain(ModelConfigEntity)} — the
* single-fallback variant cannot represent an ordered chain and only
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/controller/AgentBindingController.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/controller/AgentBindingController.java
index 39b28594..4e0a96f5 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/binding/controller/AgentBindingController.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/controller/AgentBindingController.java
@@ -5,6 +5,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import vip.mate.agent.AgentService;
+import vip.mate.agent.binding.model.AgentProviderPreference;
import vip.mate.agent.binding.model.AgentSkillBinding;
import vip.mate.agent.binding.model.AgentToolBinding;
import vip.mate.agent.binding.service.AgentBindingService;
@@ -102,6 +103,33 @@ public class AgentBindingController {
return R.ok();
}
+ // ==================== Provider Preferences (RFC-009 PR-3) ====================
+
+ @Operation(summary = "获取 Agent 的偏好 Provider 顺序")
+ @GetMapping("/provider-preferences")
+ @RequireWorkspaceRole("viewer")
+ public R> listProviderPreferences(
+ @PathVariable Long agentId,
+ @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
+ verifyAgentWorkspace(agentId, workspaceId);
+ return R.ok(bindingService.listProviderPreferences(agentId));
+ }
+
+ @Operation(summary = "批量设置 Agent 的偏好 Provider 顺序(替换模式)")
+ @PutMapping("/provider-preferences")
+ @RequireWorkspaceRole("member")
+ public R setProviderPreferences(
+ @PathVariable Long agentId,
+ @RequestBody List providerIds,
+ @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
+ verifyAgentWorkspace(agentId, workspaceId);
+ bindingService.setProviderPreferences(agentId, providerIds);
+ agentService.invalidateAgentCache(agentId);
+ auditEventService.record("UPDATE", "AGENT_PROVIDER_PREF", String.valueOf(agentId),
+ "providers=" + providerIds.size(), null);
+ return R.ok();
+ }
+
// ==================== Workspace Verification ====================
private void verifyAgentWorkspace(Long agentId, Long headerWorkspaceId) {
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentProviderPreference.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentProviderPreference.java
new file mode 100644
index 00000000..81eeebe7
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentProviderPreference.java
@@ -0,0 +1,44 @@
+package vip.mate.agent.binding.model;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * RFC-009 Phase 4 PR-3 — agent → preferred provider routing hint.
+ *
+ *
An agent with zero rows here uses the global fallback chain order
+ * (no behavior change from pre-PR-3 deployments). When rows exist,
+ * {@code AgentGraphBuilder.buildFallbackChain} sorts those provider ids
+ * to the front by ascending {@code sortOrder}; non-listed providers
+ * follow in their global priority order.
+ *
+ *
Pool/cooldown gating still applies: a preferred provider that is
+ * HARD-removed or cooling down is still skipped by the runtime walker.
+ */
+@Data
+@TableName("mate_agent_provider_preference")
+public class AgentProviderPreference {
+
+ @TableId(type = IdType.ASSIGN_ID)
+ private Long id;
+
+ private Long agentId;
+
+ /** Provider id (matches {@code mate_model_provider.provider_id}). */
+ private String providerId;
+
+ /** Lower wins. Two rows with the same value tie-break on provider_id alphabetically. */
+ private Integer sortOrder;
+
+ private Boolean enabled;
+
+ @TableField(fill = FieldFill.INSERT)
+ private LocalDateTime createTime;
+
+ @TableField(fill = FieldFill.INSERT_UPDATE)
+ private LocalDateTime updateTime;
+
+ private Integer deleted;
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/repository/AgentProviderPreferenceMapper.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/repository/AgentProviderPreferenceMapper.java
new file mode 100644
index 00000000..8e6cac4c
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/repository/AgentProviderPreferenceMapper.java
@@ -0,0 +1,9 @@
+package vip.mate.agent.binding.repository;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+import vip.mate.agent.binding.model.AgentProviderPreference;
+
+@Mapper
+public interface AgentProviderPreferenceMapper extends BaseMapper {
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java
index d5fab91c..f0c3532c 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java
@@ -4,8 +4,10 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
+import vip.mate.agent.binding.model.AgentProviderPreference;
import vip.mate.agent.binding.model.AgentSkillBinding;
import vip.mate.agent.binding.model.AgentToolBinding;
+import vip.mate.agent.binding.repository.AgentProviderPreferenceMapper;
import vip.mate.agent.binding.repository.AgentSkillBindingMapper;
import vip.mate.agent.binding.repository.AgentToolBindingMapper;
@@ -30,6 +32,7 @@ public class AgentBindingService {
private final AgentSkillBindingMapper skillBindingMapper;
private final AgentToolBindingMapper toolBindingMapper;
+ private final AgentProviderPreferenceMapper providerPreferenceMapper;
// ==================== Skill Bindings ====================
@@ -167,4 +170,52 @@ public class AgentBindingService {
}
}
}
+
+ // ==================== Provider Preferences (RFC-009 PR-3) ====================
+
+ /** Raw rows for the agent edit form. Sorted by sort_order ascending. */
+ public List listProviderPreferences(Long agentId) {
+ return providerPreferenceMapper.selectList(
+ new LambdaQueryWrapper()
+ .eq(AgentProviderPreference::getAgentId, agentId)
+ .orderByAsc(AgentProviderPreference::getSortOrder));
+ }
+
+ /**
+ * Ordered list of provider ids the agent prefers, lowest sort_order
+ * first. Disabled rows are filtered out. Empty list means "no
+ * preference — fall back to the global chain order".
+ *
+ *
Used by {@code AgentGraphBuilder.buildFallbackChain} to bias the
+ * fallback chain order per agent.
+ */
+ public List getPreferredProviderIds(Long agentId) {
+ if (agentId == null) return Collections.emptyList();
+ return listProviderPreferences(agentId).stream()
+ .filter(p -> Boolean.TRUE.equals(p.getEnabled()))
+ .map(AgentProviderPreference::getProviderId)
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * Replace the full preference list for an agent. {@code providerIds}
+ * is the new ordered preference (index 0 = highest preference).
+ * Empty / null list clears all preferences for the agent.
+ */
+ public void setProviderPreferences(Long agentId, List providerIds) {
+ providerPreferenceMapper.delete(
+ new LambdaQueryWrapper()
+ .eq(AgentProviderPreference::getAgentId, agentId));
+ if (providerIds == null) return;
+ int order = 0;
+ for (String providerId : providerIds) {
+ if (providerId == null || providerId.isBlank()) continue;
+ AgentProviderPreference row = new AgentProviderPreference();
+ row.setAgentId(agentId);
+ row.setProviderId(providerId.trim());
+ row.setSortOrder(order++);
+ row.setEnabled(true);
+ providerPreferenceMapper.insert(row);
+ }
+ }
}
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java
index a9cfe105..97716f09 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java
@@ -47,7 +47,7 @@ public class NodeStreamingChatHelper {
/**
* Ordered fallback chain tried after the primary model exhausts retries.
* Each entry is attempted once (no retry); the first successful response
- * wins. Empty list disables fallover entirely.
+ * wins. Empty list disables fallover entirely. See RFC-009.
*
*
Stored as {@link vip.mate.llm.failover.FallbackEntry} (providerId +
* ChatModel) so the chain walker can consult {@link vip.mate.llm.failover.ProviderHealthTracker}
@@ -71,17 +71,26 @@ public class NodeStreamingChatHelper {
*/
private final String primaryProviderId;
+ /**
+ * RFC-009 Phase 4: membership gate for usable providers. A provider is
+ * removed from the pool on HARD errors (AUTH_ERROR / BILLING /
+ * MODEL_NOT_FOUND) so subsequent requests skip it entirely without
+ * burning a round-trip. {@code null} disables the gate (legacy callers,
+ * tests) — every provider then counts as in-pool (fail-open).
+ */
+ private final vip.mate.llm.failover.AvailableProviderPool providerPool;
+
public NodeStreamingChatHelper(ChatStreamTracker streamTracker) {
- this(streamTracker, List.of(), null, null, null);
+ this(streamTracker, List.of(), null, null, null, null);
}
/**
* @deprecated use the full constructor — a single fallback cannot
- * express the ordered multi-provider chain
+ * express the ordered multi-provider chain from RFC-009.
*/
@Deprecated
public NodeStreamingChatHelper(ChatStreamTracker streamTracker, ChatModel fallbackModel) {
- this(streamTracker, wrap(fallbackModel), null, null, null);
+ this(streamTracker, wrap(fallbackModel), null, null, null, null);
}
/**
@@ -90,7 +99,7 @@ public class NodeStreamingChatHelper {
@Deprecated
public NodeStreamingChatHelper(ChatStreamTracker streamTracker, ChatModel fallbackModel,
vip.mate.llm.cache.LlmCacheMetricsAggregator cacheMetrics) {
- this(streamTracker, wrap(fallbackModel), cacheMetrics, null, null);
+ this(streamTracker, wrap(fallbackModel), cacheMetrics, null, null, null);
}
/**
@@ -100,7 +109,7 @@ public class NodeStreamingChatHelper {
public NodeStreamingChatHelper(ChatStreamTracker streamTracker,
List fallbackChain,
vip.mate.llm.cache.LlmCacheMetricsAggregator cacheMetrics) {
- this(streamTracker, fallbackChain, cacheMetrics, null, null);
+ this(streamTracker, fallbackChain, cacheMetrics, null, null, null);
}
/**
@@ -112,19 +121,41 @@ public class NodeStreamingChatHelper {
List fallbackChain,
vip.mate.llm.cache.LlmCacheMetricsAggregator cacheMetrics,
vip.mate.llm.failover.ProviderHealthTracker healthTracker) {
- this(streamTracker, fallbackChain, cacheMetrics, healthTracker, null);
+ this(streamTracker, fallbackChain, cacheMetrics, healthTracker, null, null);
}
+ /**
+ * Constructor that wires health tracker + primary provider id but leaves
+ * the {@link vip.mate.llm.failover.AvailableProviderPool} disabled. Kept
+ * so existing tests (e.g. {@code NodeStreamingChatHelperFailoverTest})
+ * compile unchanged — they don't exercise the pool gate.
+ */
public NodeStreamingChatHelper(ChatStreamTracker streamTracker,
List fallbackChain,
vip.mate.llm.cache.LlmCacheMetricsAggregator cacheMetrics,
vip.mate.llm.failover.ProviderHealthTracker healthTracker,
String primaryProviderId) {
+ this(streamTracker, fallbackChain, cacheMetrics, healthTracker, primaryProviderId, null);
+ }
+
+ /**
+ * Full constructor — preferred for production wiring. The
+ * {@link vip.mate.llm.failover.AvailableProviderPool} hookup gates both
+ * the primary short-circuit and the fallback walker; passing {@code null}
+ * runs in fail-open mode (every provider counted as in-pool).
+ */
+ public NodeStreamingChatHelper(ChatStreamTracker streamTracker,
+ List fallbackChain,
+ vip.mate.llm.cache.LlmCacheMetricsAggregator cacheMetrics,
+ vip.mate.llm.failover.ProviderHealthTracker healthTracker,
+ String primaryProviderId,
+ vip.mate.llm.failover.AvailableProviderPool providerPool) {
this.streamTracker = streamTracker;
this.fallbackChain = fallbackChain == null ? List.of() : List.copyOf(fallbackChain);
this.cacheMetrics = cacheMetrics;
this.healthTracker = healthTracker;
this.primaryProviderId = primaryProviderId;
+ this.providerPool = providerPool;
}
private static List wrap(ChatModel m) {
@@ -144,6 +175,47 @@ public class NodeStreamingChatHelper {
else healthTracker.recordFailure(primaryProviderId);
}
+ /**
+ * RFC-009 Phase 4 — map an {@link ErrorType} to the matching pool
+ * {@link vip.mate.llm.failover.AvailableProviderPool.RemovalSource} for
+ * HARD failures (AUTH / BILLING / MODEL_NOT_FOUND). Returns {@code null}
+ * for SOFT errors and benign types — those keep the provider in-pool and
+ * are handled by {@link vip.mate.llm.failover.ProviderHealthTracker}'s
+ * cooldown instead.
+ */
+ private static vip.mate.llm.failover.AvailableProviderPool.RemovalSource hardRemovalSource(ErrorType type) {
+ if (type == null) return null;
+ return switch (type) {
+ case AUTH_ERROR -> vip.mate.llm.failover.AvailableProviderPool.RemovalSource.AUTH_ERROR;
+ case BILLING -> vip.mate.llm.failover.AvailableProviderPool.RemovalSource.BILLING;
+ case MODEL_NOT_FOUND -> vip.mate.llm.failover.AvailableProviderPool.RemovalSource.MODEL_NOT_FOUND;
+ default -> null;
+ };
+ }
+
+ /** Convenience: pool-aware membership check. Null pool means fail-open (everyone in). */
+ private boolean inPool(String providerId) {
+ return providerPool == null || providerId == null || providerPool.contains(providerId);
+ }
+
+ /**
+ * Remove the given provider from the pool if {@code errorType} is HARD
+ * (AUTH_ERROR / BILLING / MODEL_NOT_FOUND). No-op when the pool is
+ * disabled, the provider id is unknown, or the error is SOFT.
+ */
+ private void removeFromPool(String providerId, ErrorType errorType, String message) {
+ if (providerPool == null || providerId == null) return;
+ var source = hardRemovalSource(errorType);
+ if (source == null) return;
+ providerPool.remove(providerId, source, message != null ? message : errorType.name());
+ }
+
+ /** Defensively re-affirm pool membership after a successful call. Idempotent + cheap. */
+ private void addToPool(String providerId) {
+ if (providerPool == null || providerId == null) return;
+ providerPool.add(providerId);
+ }
+
/**
* 流式调用 LLM 并实时广播增量内容
*
@@ -235,7 +307,7 @@ public class NodeStreamingChatHelper {
|| msg.contains("thinking block")) {
return ErrorType.THINKING_BLOCK_ERROR;
}
- // BILLING — payment / quota exhausted. Distinct from AUTH because
+ // RFC-009 P3.2: BILLING — payment / quota exhausted. Distinct from AUTH because
// a different provider may have credits, so we should fall back instead of
// terminating the call. Both OpenAI ("insufficient_quota") and Anthropic
// ("credit balance is too low") use these phrases in 402-class responses.
@@ -246,7 +318,7 @@ public class NodeStreamingChatHelper {
|| msg.contains("quota exceeded") || msg.contains("Quota exceeded")) {
return ErrorType.BILLING;
}
- // MODEL_NOT_FOUND — provider rejects the requested model id.
+ // RFC-009 P3.2: MODEL_NOT_FOUND — provider rejects the requested model id.
// Includes DashScope's "[InvalidParameter] url error, please check url"
// (https://help.aliyun.com/zh/model-studio/error-code#error-url) which despite
// the wording is the provider rejecting an unknown/unsupported model id on
@@ -300,20 +372,23 @@ public class NodeStreamingChatHelper {
throw new CancellationException("Stream stopped by user");
}
- // if the primary's provider is in cooldown (3+ recent
- // consecutive failures within the cooldown window), skip the 5-retry
- // primary loop entirely and head straight to the fallback chain.
- // Without this short-circuit a degraded primary forces every LLM
- // call in the conversation to wait through the full backoff.
- boolean primarySkipped = primaryProviderId != null
+ // RFC-009 P3.1 + Phase 4: short-circuit the primary retry loop in two cases.
+ // (a) primary is in cooldown (P3.3) — soft, transient
+ // (b) primary was HARD-removed from the pool (Phase 4) — auth/billing/missing model
+ // Either way, retrying the same model wastes seconds; head straight to fallback.
+ boolean primaryInCooldown = primaryProviderId != null
&& healthTracker != null
&& healthTracker.isInCooldown(primaryProviderId);
+ boolean primaryOutOfPool = primaryProviderId != null && !inPool(primaryProviderId);
+ boolean primarySkipped = primaryInCooldown || primaryOutOfPool;
if (primarySkipped) {
- log.warn("[{}] Primary provider={} is in cooldown — skipping straight to fallback chain",
- phase, primaryProviderId);
+ String reason = primaryOutOfPool ? "removed from pool" : "in cooldown";
+ log.warn("[{}] Primary provider={} {} — skipping straight to fallback chain",
+ phase, primaryProviderId, reason);
if (broadcast) {
broadcastDelta(conversationId, "warning",
- buildDeltaJson("主模型暂时不可用(冷却中),直接尝试备选模型..."));
+ buildDeltaJson("主模型暂时不可用(" + (primaryOutOfPool ? "已下线" : "冷却中")
+ + "),直接尝试备选模型..."));
}
}
@@ -334,9 +409,10 @@ public class NodeStreamingChatHelper {
if (lastResult.errorType() == ErrorType.AUTH_ERROR) {
log.warn("[{}] Primary auth failed — skipping same-model retries, handing off to fallback chain", phase);
recordPrimary(false);
+ removeFromPool(primaryProviderId, ErrorType.AUTH_ERROR, lastResult.errorMessage());
break;
}
- // BILLING / MODEL_NOT_FOUND — provider-side hard failures
+ // RFC-009 P3.2: BILLING / MODEL_NOT_FOUND — provider-side hard failures
// that won't change on retry. Skip to fallback chain (a different
// provider may have credits, or the model name may be valid there).
if (lastResult.errorType() == ErrorType.BILLING
@@ -344,6 +420,7 @@ public class NodeStreamingChatHelper {
log.warn("[{}] Primary error={} — skipping same-model retries, handing off to fallback chain",
phase, lastResult.errorType());
recordPrimary(false);
+ removeFromPool(primaryProviderId, lastResult.errorType(), lastResult.errorMessage());
break;
}
// CLIENT_ERROR (400 Bad Request): 不重试(参数/格式错误重试也不会变)
@@ -359,7 +436,7 @@ public class NodeStreamingChatHelper {
if (lastResult.errorType() == ErrorType.THINKING_BLOCK_ERROR) {
return lastResult; // 已经重试过了
}
- // EMPTY_RESPONSE — break the primary-retry loop and fall through to
+ // RFC-009: EMPTY_RESPONSE — break the primary-retry loop and fall through to
// the fallback chain. Retrying the same model that returned nothing is rarely
// productive; a different provider has a better chance of succeeding.
if (lastResult.errorType() == ErrorType.EMPTY_RESPONSE) {
@@ -370,6 +447,7 @@ public class NodeStreamingChatHelper {
// 成功
if (lastResult.errorMessage() == null || lastResult.errorType() == ErrorType.NONE) {
recordPrimary(true);
+ addToPool(primaryProviderId);
return lastResult;
}
// Any other non-null errored result with a classified type that doStreamCall
@@ -390,12 +468,21 @@ public class NodeStreamingChatHelper {
// Each fallback gets a single shot (no retry); first successful result wins.
// Same-instance entries (e.g., primary accidentally included in the chain)
// are skipped so we don't re-try the exact model that just failed.
- // Providers in cooldown are also skipped so a known-bad
+ // Providers in cooldown (RFC-009 P3.3) are also skipped so a known-bad
// provider doesn't add latency to every conversation turn.
for (int i = 0; i < fallbackChain.size(); i++) {
vip.mate.llm.failover.FallbackEntry entry = fallbackChain.get(i);
ChatModel fallback = entry.chatModel();
if (fallback == chatModel) continue;
+ // RFC-009 Phase 4 — pool gate (the real runtime fence). A provider
+ // HARD-removed earlier (or by another conversation) must not even
+ // be attempted here. Build-time filtering is best-effort; this is
+ // the one that matters when pool state changes mid-conversation.
+ if (!inPool(entry.providerId())) {
+ log.info("[{}] Skipping fallback {}/{} provider={} — not in pool",
+ phase, i + 1, fallbackChain.size(), entry.providerId());
+ continue;
+ }
if (healthTracker != null && healthTracker.isInCooldown(entry.providerId())) {
log.info("[{}] Skipping fallback {}/{} provider={} — in cooldown",
phase, i + 1, fallbackChain.size(), entry.providerId());
@@ -417,10 +504,15 @@ public class NodeStreamingChatHelper {
&& fallbackResult.errorType() == ErrorType.NONE
&& fallbackResult.errorMessage() == null) {
if (healthTracker != null) healthTracker.recordSuccess(entry.providerId());
+ addToPool(entry.providerId());
return fallbackResult;
}
if (healthTracker != null) healthTracker.recordFailure(entry.providerId());
if (fallbackResult != null) {
+ // RFC-009 Phase 4: HARD errors evict from the pool so later
+ // walks skip this provider outright. SOFT errors keep it
+ // in-pool and let the tracker's cooldown absorb the blip.
+ removeFromPool(entry.providerId(), fallbackResult.errorType(), fallbackResult.errorMessage());
lastResult = fallbackResult; // remember most recent to report if the whole chain fails
}
}
@@ -438,7 +530,7 @@ public class NodeStreamingChatHelper {
boolean broadcast, int attempt) {
if (attempt > 0) {
long delay = Math.min(BACKOFF_BASE_MS * (1L << (attempt - 1)), BACKOFF_CAP_MS);
- // 加入 jitter 防止雷群效应(a comparable reference runtime 风格)
+ // 加入 jitter 防止雷群效应(Hermes 风格)
delay += ThreadLocalRandom.current().nextLong(0, Math.max(1, delay / 2));
delay = Math.min(delay, BACKOFF_CAP_MS);
log.warn("[{}] Retry attempt {}/{} after {}ms for conversation {}",
@@ -661,7 +753,7 @@ public class NodeStreamingChatHelper {
// warning 已在 dispose 时广播,无需重复
}
- // guard against silent empty responses. Some providers return
+ // RFC-009: guard against silent empty responses. Some providers return
// HTTP 200 with an empty body under soft-failure conditions (rate-limit
// capacity, context filter, upstream overload). Treat this as a failure
// signal so streamCallInternal can hand off to the fallback chain.
@@ -931,14 +1023,14 @@ public class NodeStreamingChatHelper {
/** Thinking 块错误(旧消息中的 thinking block 不可修改)— 可剥离后单次重试 */
THINKING_BLOCK_ERROR,
/**
- * LLM returned no content, no thinking, and no tool calls.
+ * RFC-009: LLM returned no content, no thinking, and no tool calls.
* Treated as a soft failure — skip same-model retries and hand off to
* the fallback chain directly. Typical cause: upstream rate-limit
* rejection that comes back as HTTP 200 with empty body.
*/
EMPTY_RESPONSE,
/**
- * payment / billing failure (HTTP 402, "insufficient_quota",
+ * RFC-009 P3.2: payment / billing failure (HTTP 402, "insufficient_quota",
* "credit balance is too low", etc.). Distinct from {@link #AUTH_ERROR}
* because the right response is to switch provider (a different
* provider may have credits) rather than just terminate. Skips same-model
@@ -946,7 +1038,7 @@ public class NodeStreamingChatHelper {
*/
BILLING,
/**
- * requested model id not recognized by the provider
+ * RFC-009 P3.2: requested model id not recognized by the provider
* (HTTP 404, "Model not exist", "model_not_found", DashScope's
* "url error"). Same handling as {@link #BILLING} — heads straight
* to the fallback chain instead of looping retries against a model
diff --git a/mateclaw-server/src/main/java/vip/mate/llm/controller/ProviderPoolController.java b/mateclaw-server/src/main/java/vip/mate/llm/controller/ProviderPoolController.java
new file mode 100644
index 00000000..11be60d4
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/llm/controller/ProviderPoolController.java
@@ -0,0 +1,120 @@
+package vip.mate.llm.controller;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+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.RestController;
+import vip.mate.common.result.R;
+import vip.mate.llm.failover.AvailableProviderPool;
+import vip.mate.llm.failover.AvailableProviderPool.RemovalReason;
+import vip.mate.llm.failover.ProbeResult;
+import vip.mate.llm.failover.ProviderHealthTracker;
+import vip.mate.llm.failover.ProviderHealthTracker.ProviderHealthSnapshot;
+import vip.mate.llm.failover.ProviderInitProbe;
+import vip.mate.llm.model.ProviderInfoDTO;
+import vip.mate.llm.service.ModelProviderService;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * RFC-009 Phase 4 — read-only diagnostic endpoint for the provider pool.
+ *
+ *
- *
- * @author MateClaw Team
+ * Three search modes: keyword (DB LIKE), semantic (chunk vectors),
+ * hybrid (RRF fusion). RFC-032 adds: N+1 fix, two-phase keyword search,
+ * relation boost, snippet extraction, and PageSearchResult DTO.
*/
@Slf4j
@Service
-@RequiredArgsConstructor
public class HybridRetriever {
private final WikiPageService pageService;
private final WikiChunkService chunkService;
private final WikiEmbeddingService embeddingService;
private final WikiProperties properties;
+ private final WikiPageMapper pageMapper;
+
+ @Autowired(required = false)
+ private WikiRelationService relationService;
+
+ private static final double RELATION_BOOST = 0.15;
+
+ public HybridRetriever(WikiPageService pageService,
+ WikiChunkService chunkService,
+ WikiEmbeddingService embeddingService,
+ WikiProperties properties,
+ WikiPageMapper pageMapper) {
+ this.pageService = pageService;
+ this.chunkService = chunkService;
+ this.embeddingService = embeddingService;
+ this.properties = properties;
+ this.pageMapper = pageMapper;
+ }
public enum Mode { KEYWORD, SEMANTIC, HYBRID }
/**
- * 搜索结果(页面级)
+ * Legacy page hit record (kept for backward compatibility).
*/
public record PageHit(Long pageId, String slug, String title, String summary, double score) {}
/**
- * 搜索结果(chunk 级,语义搜索专用)
+ * Chunk-level search result (semantic search).
*/
public record ChunkHit(Long chunkId, Long rawId, String snippet, float score) {}
/**
- * 执行混合搜索,返回页面级结果
+ * RFC-032: Enhanced search returning PageSearchResult with snippet and matchedBy metadata.
*/
- public List searchPages(Long kbId, String query, String modeStr, int topK) {
+ public List search(Long kbId, String query, String modeStr, int topK) {
Mode mode = parseMode(modeStr);
List semantic = List.of();
@@ -60,9 +78,7 @@ public class HybridRetriever {
keyword = keywordSearch(kbId, query, topK * 3);
}
- // 如果 semantic 不可用(no embedding model),回退到 keyword
if (mode == Mode.SEMANTIC && semantic.isEmpty()) {
- log.debug("[HybridRetriever] Semantic unavailable, falling back to keyword");
keyword = keywordSearch(kbId, query, topK * 3);
}
@@ -75,21 +91,50 @@ public class HybridRetriever {
fused = rrfFuse(semantic, keyword, 60);
}
- // 取 topK,装配 PageHit
- return fused.stream()
- .limit(topK)
- .map(ri -> {
- WikiPageEntity page = pageService.getById(ri.pageId);
- if (page == null) return null;
- return new PageHit(ri.pageId, page.getSlug(), page.getTitle(),
- page.getSummary(), ri.score);
- })
- .filter(Objects::nonNull)
- .toList();
+ // RFC-032: Relation boost (1-hop expansion on top-3 seeds)
+ fused = applyRelationBoost(fused, kbId, topK);
+
+ // Batch-fetch page info (N+1 fix)
+ List topIds = fused.stream().limit(topK).map(ri -> ri.pageId).toList();
+ if (topIds.isEmpty()) return List.of();
+
+ Map liteMap = pageMapper.selectBatchLite(topIds)
+ .stream().collect(Collectors.toMap(WikiPageLite::id, p -> p));
+
+ // Build result with snippets
+ List results = new ArrayList<>();
+ for (RankedItem ri : fused.stream().limit(topK).toList()) {
+ WikiPageLite lite = liteMap.get(ri.pageId);
+ if (lite == null) continue;
+
+ String snippet = null;
+ if (!ri.matchedBy.contains("relation_boost")) {
+ String content = pageMapper.selectContentById(ri.pageId);
+ if (content != null) {
+ snippet = SnippetExtractor.extract(content, query);
+ }
+ }
+
+ String reason = buildReason(lite, ri.matchedBy, query);
+ results.add(new PageSearchResult(
+ lite.slug(), lite.title(), lite.summary(),
+ snippet != null ? snippet : lite.summary(),
+ ri.matchedBy, reason, ri.score));
+ }
+ return results;
}
/**
- * chunk 级语义搜索(Agent 直接拿 chunk 片段作为证据)
+ * Legacy searchPages — returns PageHit for backward compatibility.
+ */
+ public List searchPages(Long kbId, String query, String modeStr, int topK) {
+ return search(kbId, query, modeStr, topK).stream()
+ .map(r -> new PageHit(null, r.slug(), r.title(), r.summary(), r.score()))
+ .toList();
+ }
+
+ /**
+ * Chunk-level semantic search.
*/
public List searchChunks(Long kbId, String query, int topK) {
if (!embeddingService.isAvailable()) return List.of();
@@ -114,9 +159,9 @@ public class HybridRetriever {
.toList();
}
- // ==================== 内部方法 ====================
+ // ==================== Internal methods ====================
- /** 语义搜索:chunk cosine → 聚合到 page(同页多 chunk 取最高分) */
+ /** Semantic search: chunk cosine → aggregate to page level */
private List semanticSearch(Long kbId, String query, int limit) {
float[] queryVec = embeddingService.embedQuery(kbId, query);
if (queryVec == null) return List.of();
@@ -124,25 +169,19 @@ public class HybridRetriever {
List allChunks = chunkService.listByKbId(kbId);
if (allChunks.isEmpty()) return List.of();
- // chunk → score, 然后 需要映射到 page。
- // 当前没有 chunk → page 的直接关联(chunk 只有 rawId)。
- // 走 rawId → 找该 rawId 对应的所有 page(source_raw_ids 含该 rawId)
- // 这是个近似:一个 rawId 可能产出多个 page,都算命中。
Map chunkScores = new HashMap<>();
for (WikiChunkEntity chunk : allChunks) {
- if (chunk.getEmbedding() == null) continue;
+ if (chunk.getEmbedding() == null || chunk.getRawId() == null) continue;
float[] vec = WikiEmbeddingService.bytesToFloats(chunk.getEmbedding());
float score = WikiEmbeddingService.cosine(queryVec, vec);
- chunkScores.merge(chunk.getRawId(), score, Math::max); // rawId 级聚合
+ chunkScores.merge(chunk.getRawId(), score, Math::max);
}
- // rawId → page IDs
List allPages = pageService.listByKbId(kbId);
Map pageScores = new HashMap<>();
for (WikiPageEntity page : allPages) {
String rawIds = page.getSourceRawIds();
if (rawIds == null) continue;
- // 解析 "[1,2,3]" 格式
for (String rawIdStr : rawIds.replaceAll("[\\[\\]\\s]", "").split(",")) {
try {
long rawId = Long.parseLong(rawIdStr.trim());
@@ -157,32 +196,108 @@ public class HybridRetriever {
return pageScores.entrySet().stream()
.sorted(Map.Entry.comparingByValue().reversed())
.limit(limit)
- .map(e -> new RankedItem(e.getKey(), e.getValue()))
+ .map(e -> new RankedItem(e.getKey(), e.getValue(), List.of("semantic")))
.toList();
}
- /** 关键词搜索:走现有 DB LIKE */
+ /**
+ * RFC-032: Two-phase keyword search — fast path (title+summary) first,
+ * full content search only if needed to fill topK.
+ */
private List keywordSearch(Long kbId, String query, int limit) {
- List results = pageService.searchPages(kbId, query);
+ String kw = "%" + query.toLowerCase()
+ .replace("\\", "\\\\")
+ .replace("%", "\\%")
+ .replace("_", "\\_") + "%";
+
+ // Phase 1: fast path (title + summary only)
+ List fastIds = pageMapper.searchFastIds(kbId, kw, limit);
+
List ranked = new ArrayList<>();
- for (int i = 0; i < Math.min(results.size(), limit); i++) {
- // LIKE 无分数,用倒序排名作为伪分数
- ranked.add(new RankedItem(results.get(i).getId(), 1.0 / (i + 1)));
+ for (int i = 0; i < fastIds.size(); i++) {
+ ranked.add(new RankedItem(fastIds.get(i), 1.0 / (i + 1), List.of("title")));
}
+
+ if (fastIds.size() >= limit) return ranked;
+
+ // Phase 2: full content search (supplement)
+ List contentIds = pageMapper.searchContentIds(kbId, kw, fastIds, limit - fastIds.size());
+ for (int i = 0; i < contentIds.size(); i++) {
+ ranked.add(new RankedItem(contentIds.get(i),
+ 1.0 / (fastIds.size() + i + 1), List.of("content")));
+ }
+
return ranked;
}
- /** RRF 融合:score = Σ 1/(k + rank_i) */
+ /** RRF fusion: score = Σ 1/(k + rank_i) */
private List rrfFuse(List a, List b, int k) {
Map fused = new HashMap<>();
- for (int i = 0; i < a.size(); i++) fused.merge(a.get(i).pageId, 1.0 / (k + i + 1), Double::sum);
- for (int i = 0; i < b.size(); i++) fused.merge(b.get(i).pageId, 1.0 / (k + i + 1), Double::sum);
+ Map> matchedByMap = new HashMap<>();
+
+ for (int i = 0; i < a.size(); i++) {
+ fused.merge(a.get(i).pageId, 1.0 / (k + i + 1), Double::sum);
+ matchedByMap.computeIfAbsent(a.get(i).pageId, x -> new ArrayList<>()).addAll(a.get(i).matchedBy);
+ }
+ for (int i = 0; i < b.size(); i++) {
+ fused.merge(b.get(i).pageId, 1.0 / (k + i + 1), Double::sum);
+ matchedByMap.computeIfAbsent(b.get(i).pageId, x -> new ArrayList<>()).addAll(b.get(i).matchedBy);
+ }
+
return fused.entrySet().stream()
.sorted(Map.Entry.comparingByValue().reversed())
- .map(e -> new RankedItem(e.getKey(), e.getValue()))
+ .map(e -> new RankedItem(e.getKey(), e.getValue(),
+ matchedByMap.getOrDefault(e.getKey(), List.of()).stream().distinct().toList()))
.toList();
}
+ /**
+ * RFC-032: 1-hop relation boost on top-3 seed pages.
+ */
+ private List applyRelationBoost(List hits, Long kbId, int topK) {
+ if (relationService == null || hits.isEmpty()) return hits;
+
+ List seedIds = hits.stream().limit(3).map(h -> h.pageId).toList();
+ Map boostMap = new HashMap<>();
+
+ for (Long seedId : seedIds) {
+ List seedLites = pageMapper.selectBatchLite(List.of(seedId));
+ if (seedLites.isEmpty()) continue;
+ WikiPageLite seed = seedLites.get(0);
+ try {
+ relationService.relatedPages(kbId, seed.slug(), 3)
+ .forEach(r -> {
+ // Find the page ID from slug
+ WikiPageEntity relPage = pageService.getBySlug(kbId, r.slug());
+ if (relPage != null) {
+ boostMap.merge(relPage.getId(), RELATION_BOOST, Double::sum);
+ }
+ });
+ } catch (Exception e) {
+ log.debug("[HybridRetriever] Relation boost failed for seed {}: {}", seed.slug(), e.getMessage());
+ }
+ }
+
+ Set existingIds = hits.stream().map(h -> h.pageId).collect(Collectors.toSet());
+ boostMap.keySet().removeAll(existingIds);
+
+ if (boostMap.isEmpty()) return hits;
+
+ List expanded = new ArrayList<>(hits);
+ boostMap.forEach((pid, score) -> expanded.add(
+ new RankedItem(pid, score, List.of("relation_boost"))));
+ return expanded;
+ }
+
+ private String buildReason(WikiPageLite lite, List matchedBy, String query) {
+ if (matchedBy.contains("relation_boost")) return "Structurally related to top search results";
+ if (matchedBy.contains("title") && matchedBy.contains("semantic")) return "Title and semantic match";
+ if (matchedBy.contains("title")) return "Title match";
+ if (matchedBy.contains("semantic")) return "Semantic similarity";
+ if (matchedBy.contains("content")) return "Content match";
+ return "Keyword match";
+ }
+
private Mode parseMode(String mode) {
if (mode == null || mode.isBlank()) {
String defaultMode = properties.getSearchDefaultMode();
@@ -199,5 +314,5 @@ public class HybridRetriever {
};
}
- private record RankedItem(Long pageId, double score) {}
+ private record RankedItem(Long pageId, double score, List matchedBy) {}
}
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiCitationService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiCitationService.java
new file mode 100644
index 00000000..d2560f89
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiCitationService.java
@@ -0,0 +1,74 @@
+package vip.mate.wiki.service;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+import vip.mate.wiki.model.WikiChunkEntity;
+import vip.mate.wiki.model.WikiPageCitationEntity;
+import vip.mate.wiki.model.WikiPageEntity;
+import vip.mate.wiki.repository.WikiPageCitationMapper;
+import vip.mate.wiki.repository.WikiPageMapper;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * RFC-029: Builds citation records linking pages to their source chunks.
+ * Called asynchronously after page creation or update to avoid blocking
+ * the main processing pipeline.
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class WikiCitationService {
+
+ private final WikiPageMapper pageMapper;
+ private final WikiChunkService chunkService;
+ private final WikiPageCitationMapper citationMapper;
+ private final ObjectMapper objectMapper;
+
+ @Async
+ public void buildCitationsAsync(Long pageId, Long kbId) {
+ buildCitations(pageId, kbId);
+ }
+
+ /**
+ * Rebuild all citation records for a page based on its sourceRawIds.
+ * Soft-deletes existing citations first, then creates new ones for
+ * all chunks belonging to the page's source raw materials.
+ */
+ public void buildCitations(Long pageId, Long kbId) {
+ WikiPageEntity page = pageMapper.selectById(pageId);
+ if (page == null) return;
+
+ List rawIds = parseRawIds(page.getSourceRawIds());
+ citationMapper.softDeleteByPageId(pageId);
+
+ for (Long rawId : rawIds) {
+ List chunks = chunkService.listByRawId(rawId);
+ for (WikiChunkEntity chunk : chunks) {
+ WikiPageCitationEntity citation = new WikiPageCitationEntity();
+ citation.setPageId(pageId);
+ citation.setChunkId(chunk.getId());
+ citation.setConfidence(BigDecimal.ONE);
+ citation.setCreatedBy("system");
+ citationMapper.insert(citation);
+ }
+ }
+
+ log.debug("[WikiCitation] Built citations for pageId={}, kbId={}", pageId, kbId);
+ }
+
+ private List parseRawIds(String json) {
+ if (json == null || json.isBlank()) return List.of();
+ try {
+ return objectMapper.readValue(json, new TypeReference<>() {});
+ } catch (Exception e) {
+ log.warn("[WikiCitation] Failed to parse sourceRawIds: {}", e.getMessage());
+ return List.of();
+ }
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContextService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContextService.java
index 819b5825..ca2826e4 100644
--- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContextService.java
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContextService.java
@@ -4,18 +4,17 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.wiki.WikiProperties;
+import vip.mate.wiki.dto.PageSearchResult;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
import vip.mate.wiki.model.WikiPageEntity;
-import java.util.ArrayList;
import java.util.List;
/**
- * Wiki 上下文服务
+ * Wiki context service — builds context for agent conversation injection.
*
- * 为 Agent 对话构建 Wiki 知识库上下文,注入到系统提示词中。
- *
- * @author MateClaw Team
+ * RFC-032: buildRelevantContext now delegates to HybridRetriever instead
+ * of using a custom keyword matching algorithm.
*/
@Slf4j
@Service
@@ -24,17 +23,14 @@ public class WikiContextService {
private final WikiKnowledgeBaseService kbService;
private final WikiPageService pageService;
+ private final HybridRetriever hybridRetriever;
private final WikiProperties properties;
/**
- * 构建与用户消息相关的 Wiki 上下文(任务前知识注入)
+ * Build relevant wiki context for the current user message.
*
- * 从用户消息中提取关键词,匹配 Wiki 页面的标题和摘要,
- * 注入 top-3 相关页面的完整内容到 system prompt 中。
- *
- * @param agentId Agent ID
- * @param userMessage 用户当前消息
- * @return 相关 Wiki 页面内容,如果没有匹配则返回空字符串
+ * RFC-032: Uses HybridRetriever for consistent search quality,
+ * returns snippet + reason instead of just summary.
*/
public String buildRelevantContext(Long agentId, String userMessage) {
if (!properties.isEnabled() || userMessage == null || userMessage.isBlank()) {
@@ -46,72 +42,46 @@ public class WikiContextService {
return "";
}
- // 从用户消息中提取关键词(简单分词:按非字母数字中文分割,过滤短词)
- String[] keywords = userMessage.toLowerCase()
- .replaceAll("[^a-z0-9\\u4e00-\\u9fff]+", " ")
- .trim()
- .split("\\s+");
- if (keywords.length == 0 || (keywords.length == 1 && keywords[0].isBlank())) {
+ Long kbId = kbs.get(0).getId();
+ List hits = hybridRetriever.search(kbId, userMessage, "hybrid", 5);
+ if (hits.isEmpty()) {
return "";
}
- // 使用缓存的 listSummaries(不加载 content),按关键词评分
- record ScoredPage(WikiPageEntity page, int score) {}
- List scored = new ArrayList<>();
-
- for (WikiKnowledgeBaseEntity kb : kbs) {
- List pages = pageService.listSummaries(kb.getId()); // 走缓存
- for (WikiPageEntity page : pages) {
- String titleLower = page.getTitle() != null ? page.getTitle().toLowerCase() : "";
- String summaryLower = page.getSummary() != null ? page.getSummary().toLowerCase() : "";
- int score = 0;
- for (String kw : keywords) {
- if (kw.length() < 2) continue;
- if (titleLower.contains(kw)) score += 3;
- if (summaryLower.contains(kw)) score += 1;
- }
- if (score > 0) {
- scored.add(new ScoredPage(page, score));
- }
- }
- }
-
- if (scored.isEmpty()) {
- return "";
- }
-
- // 取 top-5 最相关页面,只注入摘要(不注入全文),受 token 预算限制
- scored.sort((a, b) -> Integer.compare(b.score, a.score));
- int topN = Math.min(5, scored.size());
- int maxChars = properties.getMaxContextChars();
- int totalChars = 0;
-
- StringBuilder sb = new StringBuilder();
- sb.append("\n");
+ StringBuilder sb = new StringBuilder("\n");
sb.append("[Relevant wiki pages for this query. Use wiki_read_page(slug) for full content.]\n\n");
- for (int i = 0; i < topN; i++) {
- WikiPageEntity page = scored.get(i).page;
- String line = "- **" + page.getTitle() + "** (`" + page.getSlug() + "`)";
- if (page.getSummary() != null && !page.getSummary().isBlank()) {
- line += " — " + page.getSummary();
- }
- line += "\n";
- if (totalChars + line.length() > maxChars) {
+ int totalChars = 0;
+ int maxChars = properties.getMaxContextChars();
+
+ for (PageSearchResult hit : hits) {
+ String entry = buildContextEntry(hit);
+ if (totalChars + entry.length() > maxChars) {
sb.append("- ... (use wiki_search_pages for more)\n");
break;
}
- sb.append(line);
- totalChars += line.length();
+ sb.append(entry);
+ totalChars += entry.length();
}
sb.append("");
return sb.toString();
}
+ private String buildContextEntry(PageSearchResult hit) {
+ StringBuilder entry = new StringBuilder();
+ entry.append("- **[[").append(hit.slug()).append("]]** ").append(hit.title()).append("\n");
+ String excerpt = hit.snippet() != null ? hit.snippet() : hit.summary();
+ if (excerpt != null) {
+ entry.append(" ").append(excerpt).append("\n");
+ }
+ if (hit.reason() != null && !hit.reason().isBlank()) {
+ entry.append(" Relevance: ").append(hit.reason()).append("\n");
+ }
+ entry.append("\n");
+ return entry.toString();
+ }
+
/**
- * 构建指定 Agent 关联的 Wiki 上下文
- *
- * @param agentId Agent ID
- * @return Wiki 上下文字符串,如果没有关联知识库或页面则返回空字符串
+ * Build full wiki context for agent system prompt.
*/
public String buildWikiContext(Long agentId) {
if (!properties.isEnabled()) {
@@ -140,8 +110,6 @@ public class WikiContextService {
}
sb.append(" (").append(pages.size()).append(" pages)\n\n");
- // 小 KB(≤20 页)保留 summary(成本低且是唯一的语义线索)
- // 大 KB(>20 页)紧凑模式(slug + title)
boolean compact = pages.size() > 20;
for (WikiPageEntity page : pages) {
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLinkEnrichmentService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLinkEnrichmentService.java
new file mode 100644
index 00000000..1cf12c6b
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLinkEnrichmentService.java
@@ -0,0 +1,120 @@
+package vip.mate.wiki.service;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.chat.messages.SystemMessage;
+import org.springframework.ai.chat.messages.UserMessage;
+import org.springframework.ai.chat.model.ChatModel;
+import org.springframework.ai.chat.model.ChatResponse;
+import org.springframework.ai.chat.prompt.Prompt;
+import org.springframework.stereotype.Service;
+import vip.mate.wiki.WikiProperties;
+import vip.mate.wiki.job.WikiModelRoutingService;
+import vip.mate.wiki.model.WikiPageEntity;
+
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Semaphore;
+import java.util.stream.Collectors;
+
+/**
+ * RFC-031: Lightweight wikilink enrichment service.
+ * Adds [[slug]] cross-references to page content without modifying
+ * the actual text. Corresponds to llm_wiki's enrich-wikilinks.ts.
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class WikiLinkEnrichmentService {
+
+ private final WikiPageService pageService;
+ private final WikiModelRoutingService routingService;
+ private final WikiProperties wikiProperties;
+
+ private static final ExecutorService WIKI_EXECUTOR =
+ Executors.newVirtualThreadPerTaskExecutor();
+
+ /**
+ * Enrich a single page with [[wikilinks]].
+ */
+ public void enrichPage(Long pageId, Long modelId) {
+ WikiPageEntity page = pageService.getById(pageId);
+ if (page == null || page.getContent() == null) return;
+
+ String index = buildIndexPrompt(page.getKbId());
+ ChatModel chatModel = routingService.buildChatModel(modelId);
+ String enriched = callEnrichLlm(chatModel, page.getContent(), index);
+
+ if (enriched != null
+ && enriched.length() >= page.getContent().length() * wikiProperties.getWikilinkMinContentRatio()) {
+ page.setContent(enriched);
+ page.setOutgoingLinks(pageService.extractLinksAsJson(enriched));
+ pageService.updateById(page);
+ }
+ }
+
+ /**
+ * Batch-enrich all pages in a KB (e.g. after initial ingest).
+ */
+ public void enrichAllPages(Long kbId, Long modelId) {
+ List pages = pageService.listByKbIdWithContent(kbId);
+ String index = buildIndexPrompt(kbId);
+ Semaphore sem = new Semaphore(wikiProperties.getMaxParallelPhaseBPages());
+
+ for (WikiPageEntity page : pages) {
+ sem.acquireUninterruptibly();
+ WIKI_EXECUTOR.submit(() -> {
+ try {
+ enrichPageWithIndex(page, modelId, index);
+ } finally {
+ sem.release();
+ }
+ });
+ }
+ }
+
+ private void enrichPageWithIndex(WikiPageEntity page, Long modelId, String index) {
+ ChatModel chatModel = routingService.buildChatModel(modelId);
+ String enriched = callEnrichLlm(chatModel, page.getContent(), index);
+ if (enriched != null
+ && enriched.length() >= page.getContent().length() * wikiProperties.getWikilinkMinContentRatio()) {
+ page.setContent(enriched);
+ page.setOutgoingLinks(pageService.extractLinksAsJson(enriched));
+ pageService.updateById(page);
+ }
+ }
+
+ private String callEnrichLlm(ChatModel model, String content, String index) {
+ String systemPrompt = """
+ You are a wiki cross-referencing assistant.
+ Your ONLY job: add [[slug]] markers around entity and concept names that appear in the wiki index.
+ Rules:
+ - Do NOT change any content, rewrite sentences, or add new text.
+ - Do NOT modify YAML frontmatter.
+ - Only wrap existing words/phrases with [[ and ]].
+ - Use the exact slug from the wiki index (not the title).
+ - Return the COMPLETE page text with [[wikilinks]] added.
+ """;
+ String userPrompt = "Wiki Index (slug → title):\n" + index + "\n\nPage content:\n" + content;
+
+ try {
+ ChatResponse response = model.call(
+ new Prompt(List.of(
+ new SystemMessage(systemPrompt),
+ new UserMessage(userPrompt)
+ ))
+ );
+ return response.getResult().getOutput().getText();
+ } catch (Exception e) {
+ log.warn("[WikiEnrich] Failed to enrich page: {}", e.getMessage());
+ return null;
+ }
+ }
+
+ private String buildIndexPrompt(Long kbId) {
+ return pageService.listSummaries(kbId).stream()
+ .map(p -> p.getSlug() + " → " + p.getTitle())
+ .collect(Collectors.joining("\n"));
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java
index 30f68252..4d842068 100644
--- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java
@@ -168,6 +168,17 @@ public class WikiPageService {
return pageMapper.selectById(id);
}
+ /**
+ * Direct update by entity (used by enrichment service).
+ */
+ @Transactional
+ public void updateById(WikiPageEntity entity) {
+ pageMapper.updateById(entity);
+ if (entity.getKbId() != null) {
+ evictSummaryCache(entity.getKbId());
+ }
+ }
+
/**
* 创建新 Wiki 页面
*/
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java
index a5c5af2e..bfcec07a 100644
--- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java
@@ -53,8 +53,13 @@ public class WikiProcessingService {
private final AgentGraphBuilder agentGraphBuilder;
private final ObjectMapper objectMapper;
private final WikiProgressBus progressBus;
+ private final WikiCitationService citationService;
- /** 并行 chunk / 材料处理执行器(JDK 21 虚拟线程);Listener 跨包需要引用,故 public */
+ @org.springframework.beans.factory.annotation.Autowired(required = false)
+ @org.springframework.context.annotation.Lazy
+ private vip.mate.wiki.job.WikiProcessingJobService wikiJobService;
+
+ /** Parallel chunk / material processing executor (JDK 21 virtual threads) */
public static final ExecutorService WIKI_EXECUTOR = Executors.newVirtualThreadPerTaskExecutor();
/**
@@ -132,6 +137,15 @@ public class WikiProcessingService {
kbService.updateStatus(kb.getId(), "processing");
+ // RFC-030 §9.1: create a processing job record before starting
+ if (wikiJobService != null) {
+ try {
+ wikiJobService.createHeavyIngest(kb.getId(), rawId);
+ } catch (Exception e) {
+ log.warn("[Wiki] Failed to create heavy ingest job record for raw={}: {}", rawId, e.getMessage());
+ }
+ }
+
// RFC-012 M2 v2 UI v2:为本次 raw 处理创建共享进度计数器(多 chunk 共享,避免 race)
progressCounters.put(rawId, new ProgressCounter());
rawService.updateProgress(rawId, "route", 0, 0); // UI 立即看到 indeterminate 滑条
@@ -777,8 +791,10 @@ public class WikiProcessingService {
}
String sourceRawIds = "[" + rawId + "]";
try {
- pageService.createPage(kbId, slug, title, content, pageSummary, sourceRawIds);
+ WikiPageEntity created = pageService.createPage(kbId, slug, title, content, pageSummary, sourceRawIds);
log.info("[Wiki] Phase B create page slug='{}' done (created)", slug);
+ // RFC-029: async citation build
+ citationService.buildCitationsAsync(created.getId(), kbId);
return true;
} catch (org.springframework.dao.DuplicateKeyException e) {
// 兜底 2:select-then-create 在并发下不是原子操作。当 N 个 chunk 同时
@@ -846,8 +862,12 @@ public class WikiProcessingService {
log.warn("[Wiki] Phase B merge page slug='{}' returned blank content, skipping", slug);
return false;
}
- pageService.updatePageByAi(kbId, slug, content, summary, rawId);
+ WikiPageEntity updated = pageService.updatePageByAi(kbId, slug, content, summary, rawId);
log.info("[Wiki] Phase B merge page slug='{}' done", slug);
+ // RFC-029: async citation rebuild
+ if (updated != null) {
+ citationService.buildCitationsAsync(updated.getId(), kbId);
+ }
return true;
}
@@ -1129,11 +1149,129 @@ public class WikiProcessingService {
return cls + ": " + trimmed;
}
- /** 瞬时错误的内部标记异常,确保空响应也能走重试路径 */
+ /** Transient error marker to route empty responses through the retry path */
private static class TransientLlmException extends RuntimeException {
TransientLlmException(String msg) { super(msg); }
}
+ // ==================== RFC-030: Error classification ====================
+
+ /**
+ * Classify an exception into an error code aligned with RFC-009 ErrorType.
+ *
+ * @return error code string: AUTH_ERROR, BILLING, MODEL_NOT_FOUND,
+ * RATE_LIMIT, SERVER_ERROR, TIMEOUT, CONTENT_FILTER, UNKNOWN
+ */
+ public String classifyErrorCode(Throwable t) {
+ Throwable cur = t;
+ int depth = 0;
+ while (cur != null && depth < 8) {
+ String className = cur.getClass().getSimpleName();
+ if ("UnknownHostException".equals(className)
+ || "SSLHandshakeException".equals(className)) {
+ return "AUTH_ERROR";
+ }
+ String msg = cur.getMessage();
+ if (msg != null) {
+ String m = msg.toLowerCase();
+ if (m.contains("401") || m.contains("unauthorized") || m.contains("403")
+ || m.contains("forbidden") || m.contains("invalid api key")
+ || m.contains("invalid_api_key") || m.contains("authentication")) {
+ return "AUTH_ERROR";
+ }
+ if (m.contains("quota") || m.contains("insufficient_quota") || m.contains("billing")) {
+ return "BILLING";
+ }
+ if (m.contains("model not found") || m.contains("model_not_found")) {
+ return "MODEL_NOT_FOUND";
+ }
+ if (m.contains("429") || m.contains("rate_limit") || m.contains("too many requests")) {
+ return "RATE_LIMIT";
+ }
+ if (m.contains("content_filter") || m.contains("content filter")
+ || m.contains("data_inspection_failed")) {
+ return "CONTENT_FILTER";
+ }
+ if (m.contains("timeout") || m.contains("timed out")) {
+ return "TIMEOUT";
+ }
+ if (m.contains("500") || m.contains("502") || m.contains("503") || m.contains("504")) {
+ return "SERVER_ERROR";
+ }
+ }
+ cur = cur.getCause();
+ depth++;
+ }
+ return "UNKNOWN";
+ }
+
+ // ==================== RFC-031: Methods for template delegation ====================
+
+ /**
+ * Repair a single page by regenerating its content.
+ * Used by LocalRepairTemplate.
+ */
+ public void repairSinglePage(Long targetPageId, Long modelId) {
+ WikiPageEntity page = pageService.getById(targetPageId);
+ if (page == null) {
+ log.warn("[Wiki] repairSinglePage: page not found: {}", targetPageId);
+ return;
+ }
+
+ WikiKnowledgeBaseEntity kb = kbService.getById(page.getKbId());
+ if (kb == null) return;
+
+ // Find source raw material
+ List rawIds = parseSourceRawIds(page.getSourceRawIds());
+ if (rawIds.isEmpty()) {
+ log.warn("[Wiki] repairSinglePage: no source raw IDs for page {}", targetPageId);
+ return;
+ }
+
+ WikiRawMaterialEntity raw = rawService.getById(rawIds.get(0));
+ if (raw == null) return;
+
+ String textContent = rawService.getTextContent(raw);
+ if (textContent == null || textContent.isBlank()) return;
+
+ // Use existing two-phase single-page create logic
+ String existingPagesIndex = buildExistingPagesIndex(kb.getId());
+ String configContent = kb.getConfigContent() != null ? kb.getConfigContent() : "";
+ String createSystem = PromptLoader.loadPrompt("wiki/create-page-system");
+ String createUserTemplate = PromptLoader.loadPrompt("wiki/create-page-user");
+ String createUser = createUserTemplate
+ .replace("{config}", configContent)
+ .replace("{existing_pages}", existingPagesIndex)
+ .replace("{page_slug}", page.getSlug())
+ .replace("{page_title}", page.getTitle())
+ .replace("{page_summary}", page.getSummary() != null ? page.getSummary() : "")
+ .replace("{raw_title}", raw.getTitle())
+ .replace("{raw_content}", textContent);
+ Prompt prompt = new Prompt(List.of(
+ new SystemMessage(createSystem),
+ new UserMessage(createUser)
+ ));
+ String response = callLlmWithResilientRetry(prompt, "repair page=" + page.getSlug());
+ com.fasterxml.jackson.databind.JsonNode pageJson = parseJsonResponse(response);
+ if (pageJson == null) return;
+
+ String content = pageJson.path("content").asText("");
+ String summary = pageJson.path("summary").asText("");
+ if (!content.isBlank()) {
+ pageService.updatePageByAi(kb.getId(), page.getSlug(), content, summary, rawIds.get(0));
+ log.info("[Wiki] Repaired page: {} (kbId={})", page.getSlug(), kb.getId());
+ }
+ }
+
+ private List parseSourceRawIds(String json) {
+ if (json == null || json.isBlank()) return List.of();
+ try {
+ return objectMapper.readValue(json, new com.fasterxml.jackson.core.type.TypeReference>() {});
+ } catch (Exception e) {
+ return List.of();
+ }
+ }
+
private JsonNode parseJsonResponse(String response) {
if (response == null || response.isBlank()) return null;
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRelationService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRelationService.java
new file mode 100644
index 00000000..6b0e4a7d
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRelationService.java
@@ -0,0 +1,122 @@
+package vip.mate.wiki.service;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import vip.mate.wiki.dto.*;
+import vip.mate.wiki.model.WikiPageEntity;
+import vip.mate.wiki.relation.RelationSignalStrategy;
+import vip.mate.wiki.repository.WikiPageCitationMapper;
+import vip.mate.wiki.repository.WikiPageMapper;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * RFC-029: Wiki relation service — computes multi-signal structural
+ * relevance between pages using all registered {@link RelationSignalStrategy} beans.
+ */
+@Slf4j
+@Service
+public class WikiRelationService {
+
+ private final List signals;
+ private final WikiPageMapper pageMapper;
+ private final WikiPageService pageService;
+ private final WikiPageCitationMapper citationMapper;
+
+ public WikiRelationService(List signals,
+ WikiPageMapper pageMapper,
+ WikiPageService pageService,
+ WikiPageCitationMapper citationMapper) {
+ this.signals = signals;
+ this.pageMapper = pageMapper;
+ this.pageService = pageService;
+ this.citationMapper = citationMapper;
+ }
+
+ /**
+ * Find pages related to a seed page, ranked by multi-signal score.
+ */
+ public List relatedPages(Long kbId, String seedSlug, int topK) {
+ WikiPageEntity seed = pageService.getBySlug(kbId, seedSlug);
+ if (seed == null) return List.of();
+
+ Map totalScores = new HashMap<>();
+ Map> signalHits = new HashMap<>();
+
+ for (RelationSignalStrategy signal : signals) {
+ try {
+ signal.score(seed.getId(), kbId).forEach((pid, s) -> {
+ totalScores.merge(pid, s, Double::sum);
+ signalHits.computeIfAbsent(pid, k -> new ArrayList<>()).add(signal.signalName());
+ });
+ } catch (Exception e) {
+ log.warn("[WikiRelation] Signal '{}' failed for seed={}: {}",
+ signal.signalName(), seedSlug, e.getMessage());
+ }
+ }
+
+ List topIds = totalScores.entrySet().stream()
+ .sorted(Map.Entry.comparingByValue().reversed())
+ .limit(topK)
+ .map(Map.Entry::getKey)
+ .toList();
+
+ if (topIds.isEmpty()) return List.of();
+ Map liteMap = pageMapper.selectBatchLite(topIds)
+ .stream().collect(Collectors.toMap(WikiPageLite::id, l -> l));
+
+ return topIds.stream()
+ .filter(liteMap::containsKey)
+ .map(pid -> new RelatedPageResult(
+ liteMap.get(pid).slug(),
+ liteMap.get(pid).title(),
+ liteMap.get(pid).summary(),
+ totalScores.get(pid),
+ signalHits.getOrDefault(pid, List.of())))
+ .toList();
+ }
+
+ /**
+ * Explain the relation between two pages with a per-signal breakdown.
+ */
+ public RelationExplanation explain(Long kbId, String slugA, String slugB) {
+ WikiPageEntity a = pageService.getBySlug(kbId, slugA);
+ WikiPageEntity b = pageService.getBySlug(kbId, slugB);
+ if (a == null || b == null) return RelationExplanation.notFound();
+
+ List breakdown = new ArrayList<>();
+ double total = 0;
+ for (RelationSignalStrategy signal : signals) {
+ try {
+ Double score = signal.score(a.getId(), kbId).get(b.getId());
+ if (score != null && score > 0) {
+ breakdown.add(new SignalScore(signal.signalName(), signal.weight(), score));
+ total += score;
+ }
+ } catch (Exception e) {
+ log.warn("[WikiRelation] Signal '{}' failed for explain {}<->{}: {}",
+ signal.signalName(), slugA, slugB, e.getMessage());
+ }
+ }
+ return new RelationExplanation(slugA, slugB, total, breakdown);
+ }
+
+ /**
+ * Find all pages derived from a given raw material.
+ */
+ public List pagesByRawId(Long rawId) {
+ List pageIds = citationMapper.listPageIdsByRawId(rawId);
+ if (pageIds.isEmpty()) return List.of();
+ return pageMapper.selectBatchLite(pageIds);
+ }
+
+ /**
+ * Find all pages that cite a given chunk.
+ */
+ public List pagesByChunkId(Long chunkId) {
+ List pageIds = citationMapper.listPageIdsByChunkId(chunkId);
+ if (pageIds.isEmpty()) return List.of();
+ return pageMapper.selectBatchLite(pageIds);
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java b/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java
index bf680716..4341adfa 100644
--- a/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java
@@ -5,47 +5,79 @@ import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
-import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.ApplicationEventPublisher;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
+import vip.mate.wiki.dto.*;
+import vip.mate.wiki.job.WikiProcessingJobService;
+import vip.mate.wiki.job.event.WikiJobCreatedEvent;
+import vip.mate.wiki.job.model.WikiProcessingJobEntity;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
import vip.mate.wiki.model.WikiPageEntity;
import vip.mate.wiki.model.WikiRawMaterialEntity;
-import vip.mate.wiki.service.HybridRetriever;
-import vip.mate.wiki.service.WikiKnowledgeBaseService;
-import vip.mate.wiki.service.WikiPageService;
-import vip.mate.wiki.service.WikiRawMaterialService;
+import vip.mate.wiki.repository.WikiRawMaterialMapper;
+import vip.mate.wiki.service.*;
-import java.util.List;
+import java.util.*;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
/**
- * Wiki 知识库工具
+ * Wiki knowledge base tools for agent conversations.
*
- * 供 Agent 在对话中按需读取 Wiki 页面内容。
- * kbId 通过 agentId 自动解析,LLM 无需传递。
+ * All tools auto-resolve kbId from agentId; LLM never needs to pass it directly.
*
* @author MateClaw Team
*/
@Slf4j
@Component
-@RequiredArgsConstructor
public class WikiTool {
private final WikiPageService pageService;
private final WikiKnowledgeBaseService kbService;
private final WikiRawMaterialService rawService;
private final HybridRetriever hybridRetriever;
+ private final ObjectMapper objectMapper;
+
+ @Autowired(required = false)
+ private WikiRelationService relationService;
+
+ @Autowired(required = false)
+ private WikiProcessingJobService jobService;
+
+ @Autowired(required = false)
+ private ApplicationEventPublisher eventPublisher;
+
+ @Autowired(required = false)
+ private WikiRawMaterialMapper rawMaterialMapper;
+
+ public WikiTool(WikiPageService pageService,
+ WikiKnowledgeBaseService kbService,
+ WikiRawMaterialService rawService,
+ HybridRetriever hybridRetriever,
+ ObjectMapper objectMapper) {
+ this.pageService = pageService;
+ this.kbService = kbService;
+ this.rawService = rawService;
+ this.hybridRetriever = hybridRetriever;
+ this.objectMapper = objectMapper;
+ }
+
+ // ==================== RFC-032: Enhanced wiki_read_page ====================
@Tool(description = """
- 读取 Wiki 知识库中指定页面的完整内容。
- 当系统提示词中的 Wiki 页面摘要不够详细时,使用此工具获取完整内容。
- 返回 Markdown 格式的页面内容,包含 [[双向链接]] 和来源原始文件信息。
+ Read a wiki page. Use maxChars to limit size (recommended: 3000-6000 for most tasks).
+ Use sectionHeading to read only one section by its heading text.
""")
public String wiki_read_page(
- @ToolParam(description = "当前 Agent 的 ID") Long agentId,
- @ToolParam(description = "页面标识符 (slug)") String slug) {
+ @ToolParam(description = "Agent ID") Long agentId,
+ @ToolParam(description = "Page slug") String slug,
+ @ToolParam(description = "Max characters to return (null = full page)", required = false) Integer maxChars,
+ @ToolParam(description = "Section heading to extract (null = all sections)", required = false) String sectionHeading) {
if (slug == null || slug.isBlank()) {
return error("slug is required");
@@ -61,38 +93,66 @@ public class WikiTool {
return error("Page not found: " + slug);
}
- // Agent 引用追踪
pageService.trackReference(kbId, slug);
+ String content = page.getContent();
+
+ if (sectionHeading != null && !sectionHeading.isBlank()) {
+ content = extractSection(content, sectionHeading);
+ }
+ if (maxChars != null && maxChars > 0) {
+ content = applyMaxChars(content, maxChars);
+ }
+
JSONObject result = JSONUtil.createObj()
.set("title", page.getTitle())
.set("slug", page.getSlug())
.set("version", page.getVersion())
.set("lastUpdatedBy", page.getLastUpdatedBy())
- .set("content", page.getContent())
+ .set("content", content)
.set("sourceFiles", resolveSourceFiles(page.getSourceRawIds()));
return result.toString();
}
+ // ==================== RFC-032: Enhanced wiki_list_pages ====================
+
@Tool(description = """
- 列出 Wiki 知识库中的所有页面。
- 返回页面列表,包含标题、slug 和摘要。
+ List wiki pages. Add query to filter by title keyword (max 30 results).
+ Without query returns all pages (use only for small KBs).
""")
public String wiki_list_pages(
- @ToolParam(description = "当前 Agent 的 ID") Long agentId) {
+ @ToolParam(description = "Agent ID") Long agentId,
+ @ToolParam(description = "Title keyword filter (optional)", required = false) String query) {
Long kbId = resolveKbId(agentId);
if (kbId == null) {
return error("No wiki knowledge base found for this agent");
}
- List pages = pageService.listSummaries(kbId);
+ List pages;
+ if (query != null && !query.isBlank()) {
+ List ids = pageService.searchPages(kbId, query).stream()
+ .map(WikiPageEntity::getId).limit(30).toList();
+ if (ids.isEmpty()) {
+ pages = List.of();
+ } else {
+ pages = pageService.listSummaries(kbId).stream()
+ .filter(p -> ids.stream().anyMatch(id -> Objects.equals(id, p.getId())))
+ .map(p -> new WikiPageLite(p.getId(), p.getSlug(), p.getTitle(), p.getSummary()))
+ .toList();
+ }
+ } else {
+ pages = pageService.listSummaries(kbId).stream()
+ .map(p -> new WikiPageLite(p.getId(), p.getSlug(), p.getTitle(), p.getSummary()))
+ .toList();
+ }
+
JSONArray arr = new JSONArray();
- for (WikiPageEntity page : pages) {
+ for (WikiPageLite page : pages) {
arr.add(JSONUtil.createObj()
- .set("title", page.getTitle())
- .set("slug", page.getSlug())
- .set("summary", page.getSummary()));
+ .set("slug", page.slug())
+ .set("title", page.title())
+ .set("summary", page.summary()));
}
return JSONUtil.createObj()
@@ -102,15 +162,17 @@ public class WikiTool {
.toString();
}
+ // ==================== RFC-032: Enhanced wiki_search_pages ====================
+
@Tool(description = """
- 在 Wiki 知识库中搜索页面。
- 支持三种模式:keyword(关键词匹配)、semantic(语义向量相似度)、hybrid(两者融合,默认)。
- 返回匹配的页面列表及其来源文件。
+ Search wiki pages. Returns snippet so you can judge relevance without reading the full page.
+ Default topK=5 is sufficient for most queries.
""")
public String wiki_search_pages(
- @ToolParam(description = "当前 Agent 的 ID") Long agentId,
- @ToolParam(description = "搜索关键词或自然语言问题") String query,
- @ToolParam(description = "搜索模式:keyword | semantic | hybrid(默认 hybrid)", required = false) String mode) {
+ @ToolParam(description = "Agent ID") Long agentId,
+ @ToolParam(description = "Search query") String query,
+ @ToolParam(description = "Mode: keyword|semantic|hybrid (default: hybrid)", required = false) String mode,
+ @ToolParam(description = "Max results (default 5, max 20)", required = false) Integer topK) {
if (query == null || query.isBlank()) {
return error("query is required");
@@ -121,41 +183,44 @@ public class WikiTool {
return error("No wiki knowledge base found for this agent");
}
- // RFC-011:走混合检索
- List hits = hybridRetriever.searchPages(kbId, query, mode, 20);
+ int k = (topK != null && topK > 0) ? Math.min(topK, 20) : 5;
+ List results = hybridRetriever.search(kbId, query, mode, k);
- // Agent 引用追踪
- for (HybridRetriever.PageHit h : hits) {
- pageService.trackReference(kbId, h.slug());
+ for (PageSearchResult r : results) {
+ pageService.trackReference(kbId, r.slug());
}
JSONArray arr = new JSONArray();
- for (HybridRetriever.PageHit hit : hits) {
+ for (PageSearchResult r : results) {
arr.add(JSONUtil.createObj()
- .set("title", hit.title())
- .set("slug", hit.slug())
- .set("summary", hit.summary())
- .set("score", String.format("%.4f", hit.score())));
+ .set("slug", r.slug())
+ .set("title", r.title())
+ .set("snippet", r.snippet() != null ? r.snippet() : r.summary())
+ .set("matchedBy", r.matchedBy())
+ .set("reason", r.reason() != null ? r.reason() : "")
+ .set("score", String.format("%.4f", r.score())));
}
return JSONUtil.createObj()
.set("kbId", kbId)
.set("query", query)
.set("mode", mode != null ? mode : "hybrid")
- .set("matchCount", hits.size())
+ .set("matchCount", results.size())
.set("pages", arr)
.toString();
}
+ // ==================== RFC-032: N+1 fixed wiki_semantic_search ====================
+
@Tool(description = """
- 在 Wiki 知识库中进行 chunk 级语义搜索。
- 返回与查询语义最接近的原始文本片段(chunk),包含相似度分数。
- 当 wiki_search_pages 返回的页面摘要不够具体时,使用此工具获取精确的源文本证据。
+ Chunk-level semantic search in the wiki knowledge base.
+ Returns raw text fragments closest to the query with similarity scores.
+ Use when wiki_search_pages results are not specific enough.
""")
public String wiki_semantic_search(
- @ToolParam(description = "当前 Agent 的 ID") Long agentId,
- @ToolParam(description = "自然语言查询") String query,
- @ToolParam(description = "返回条数(默认 5)", required = false) Integer topK) {
+ @ToolParam(description = "Agent ID") Long agentId,
+ @ToolParam(description = "Natural language query") String query,
+ @ToolParam(description = "Max results (default 5)", required = false) Integer topK) {
if (query == null || query.isBlank()) {
return error("query is required");
@@ -178,13 +243,21 @@ public class WikiTool {
.toString();
}
+ // RFC-032: Batch-fetch raw titles (N+1 fix)
+ Set rawIds = hits.stream().map(HybridRetriever.ChunkHit::rawId).collect(Collectors.toSet());
+ Map rawTitles;
+ if (rawMaterialMapper != null && !rawIds.isEmpty()) {
+ rawTitles = rawMaterialMapper.selectBatchTitles(rawIds)
+ .stream().collect(Collectors.toMap(RawTitleRef::id, RawTitleRef::title));
+ } else {
+ rawTitles = Map.of();
+ }
+
JSONArray arr = new JSONArray();
for (HybridRetriever.ChunkHit hit : hits) {
- // 解析 raw material 标题
- WikiRawMaterialEntity raw = rawService.getById(hit.rawId());
arr.add(JSONUtil.createObj()
.set("chunkId", hit.chunkId())
- .set("rawTitle", raw != null ? raw.getTitle() : "unknown")
+ .set("rawTitle", rawTitles.getOrDefault(hit.rawId(), "unknown"))
.set("snippet", hit.snippet())
.set("score", String.format("%.4f", hit.score())));
}
@@ -198,13 +271,12 @@ public class WikiTool {
}
@Tool(description = """
- 追溯 Wiki 页面的来源原始文件。
- 查询指定页面是由哪些原始文档生成的,返回文件名、类型、路径等信息。
- 用于回答"这个内容出自哪篇文档"类的问题。
+ Trace the source raw materials for a wiki page.
+ Returns file names, types, and paths of the original documents.
""")
public String wiki_trace_source(
- @ToolParam(description = "当前 Agent 的 ID") Long agentId,
- @ToolParam(description = "页面标识符 (slug)") String slug) {
+ @ToolParam(description = "Agent ID") Long agentId,
+ @ToolParam(description = "Page slug") String slug) {
if (slug == null || slug.isBlank()) {
return error("slug is required");
@@ -228,14 +300,13 @@ public class WikiTool {
}
@Tool(description = """
- 在 Wiki 知识库中创建新页面。
- 用于保存任务执行结果、分析报告、会议纪要等有价值的信息。
- 内容使用 Markdown 格式。页面标识符 (slug) 会从标题自动生成。
+ Create a new wiki page. Used to save task results, analysis reports, etc.
+ Content should be Markdown. Slug is auto-generated from title.
""")
public String wiki_create_page(
- @ToolParam(description = "当前 Agent 的 ID") Long agentId,
- @ToolParam(description = "页面标题") String title,
- @ToolParam(description = "页面内容 (Markdown 格式)") String content) {
+ @ToolParam(description = "Agent ID") Long agentId,
+ @ToolParam(description = "Page title") String title,
+ @ToolParam(description = "Page content (Markdown)") String content) {
if (title == null || title.isBlank()) {
return error("title is required");
@@ -249,7 +320,6 @@ public class WikiTool {
return error("No wiki knowledge base found for this agent. Create one first.");
}
- // 从标题生成 slug
String slug = title.toLowerCase()
.replaceAll("[^a-z0-9\\u4e00-\\u9fff]+", "-")
.replaceAll("^-|-$", "");
@@ -257,17 +327,13 @@ public class WikiTool {
slug = "page-" + System.currentTimeMillis();
}
- // 检查 slug 是否已存在
WikiPageEntity existing = pageService.getBySlug(kbId, slug);
if (existing != null) {
slug = slug + "-" + System.currentTimeMillis() % 10000;
}
- // 生成摘要(取前 200 字符)
String summary = content.length() > 200 ? content.substring(0, 200) + "..." : content;
-
WikiPageEntity page = pageService.createPage(kbId, slug, title, content, summary, null);
-
log.info("[WikiTool] Created page: {} (slug={}, kbId={})", title, slug, kbId);
return JSONUtil.createObj()
@@ -280,12 +346,11 @@ public class WikiTool {
}
@Tool(description = """
- 删除一个 AI 生成的 Wiki 页面。无法删除人工维护的页面(lastUpdatedBy = 'manual')。
- 用于清理过时、冗余或不准确的 Wiki 页面。
+ Delete an AI-generated wiki page. Cannot delete manually curated pages.
""")
public String wiki_delete_page(
- @ToolParam(description = "当前 Agent 的 ID") Long agentId,
- @ToolParam(description = "要删除的页面 slug") String slug) {
+ @ToolParam(description = "Agent ID") Long agentId,
+ @ToolParam(description = "Page slug to delete") String slug) {
if (slug == null || slug.isBlank()) {
return error("slug is required");
@@ -301,7 +366,6 @@ public class WikiTool {
return error("Page not found: " + slug);
}
- // 安全保护:禁止删除人工维护的页面
if ("manual".equals(page.getLastUpdatedBy())) {
return error("Cannot delete manually curated page: " + page.getTitle() + ". Please manage via admin UI.");
}
@@ -317,24 +381,104 @@ public class WikiTool {
.toString();
}
- /**
- * 通过 agentId 自动解析关联的知识库 ID
- *
- * 查找逻辑:Agent 专属 KB + 公共 KB(agent_id IS NULL),取第一个。
- */
+ // ==================== RFC-029: Relation tools ====================
+
+ @Tool(description = """
+ Find pages structurally related to a given page (shared sources, links,
+ semantic similarity). More reliable than keyword search for discovering connected knowledge.
+ """)
+ public String wiki_related_pages(
+ @ToolParam(description = "Agent ID") Long agentId,
+ @ToolParam(description = "Page slug") String slug,
+ @ToolParam(description = "Max results (default 5, max 10)", required = false) Integer topK) {
+
+ Long kbId = resolveKbId(agentId);
+ if (kbId == null) return error("No wiki knowledge base found for this agent");
+ if (relationService == null) return error("Relation service not available");
+
+ int k = (topK != null && topK > 0) ? Math.min(topK, 10) : 5;
+ List results = relationService.relatedPages(kbId, slug, k);
+
+ JSONArray arr = new JSONArray();
+ for (RelatedPageResult r : results) {
+ arr.add(JSONUtil.createObj()
+ .set("slug", r.slug())
+ .set("title", r.title())
+ .set("score", String.format("%.2f", r.score()))
+ .set("signals", r.signals()));
+ }
+
+ return JSONUtil.createObj()
+ .set("slug", slug)
+ .set("relatedCount", results.size())
+ .set("pages", arr)
+ .toString();
+ }
+
+ @Tool(description = """
+ Explain why two wiki pages are related. Returns signal breakdown with scores.
+ """)
+ public String wiki_explain_relation(
+ @ToolParam(description = "Agent ID") Long agentId,
+ @ToolParam(description = "First page slug") String slugA,
+ @ToolParam(description = "Second page slug") String slugB) {
+
+ Long kbId = resolveKbId(agentId);
+ if (kbId == null) return error("No wiki knowledge base found for this agent");
+ if (relationService == null) return error("Relation service not available");
+
+ RelationExplanation ex = relationService.explain(kbId, slugA, slugB);
+ if (ex.breakdown().isEmpty()) return slugA + " and " + slugB + " have no detected relation.";
+
+ StringBuilder sb = new StringBuilder("Relation score: ")
+ .append(String.format("%.2f", ex.totalScore())).append("\n");
+ ex.breakdown().forEach(s -> sb.append(" ").append(s.signal())
+ .append(": ").append(String.format("%.2f", s.score())).append("\n"));
+ return sb.toString();
+ }
+
+ // ==================== RFC-031: Enrichment tool ====================
+
+ @Tool(description = """
+ Trigger lightweight wikilink enrichment for a specific page.
+ Does NOT regenerate content — only adds [[wikilink]] cross-references.
+ """)
+ public String wiki_enrich_page(
+ @ToolParam(description = "Agent ID") Long agentId,
+ @ToolParam(description = "Page slug") String slug) {
+
+ Long kbId = resolveKbId(agentId);
+ if (kbId == null) return error("No wiki knowledge base found for this agent");
+ if (jobService == null || eventPublisher == null) return error("Job service not available");
+
+ WikiPageEntity page = pageService.getBySlug(kbId, slug);
+ if (page == null) return error("Page not found: " + slug);
+
+ Long rawId = 0L;
+ try {
+ List rawIds = objectMapper.readValue(
+ page.getSourceRawIds() != null ? page.getSourceRawIds() : "[]",
+ new TypeReference>() {});
+ if (!rawIds.isEmpty()) rawId = rawIds.get(0);
+ } catch (Exception ignored) {}
+
+ WikiProcessingJobEntity job = jobService.createLightEnrich(kbId, rawId);
+ eventPublisher.publishEvent(new WikiJobCreatedEvent(job.getId()));
+ return "Wikilink enrichment queued for: " + slug;
+ }
+
+ // ==================== Helpers ====================
+
private Long resolveKbId(Long agentId) {
List kbs = kbService.listByAgentId(agentId);
return kbs.isEmpty() ? null : kbs.get(0).getId();
}
- /**
- * 将 sourceRawIds JSON 数组解析为原始文件信息列表
- */
private JSONArray resolveSourceFiles(String sourceRawIdsJson) {
JSONArray result = new JSONArray();
if (sourceRawIdsJson == null || sourceRawIdsJson.isBlank()) return result;
try {
- List rawIds = new ObjectMapper().readValue(sourceRawIdsJson, new TypeReference>() {});
+ List rawIds = objectMapper.readValue(sourceRawIdsJson, new TypeReference>() {});
for (Long rawId : rawIds) {
WikiRawMaterialEntity raw = rawService.getById(rawId);
if (raw != null) {
@@ -351,6 +495,30 @@ public class WikiTool {
return result;
}
+ /**
+ * RFC-032: Extract a section from markdown content by heading text.
+ */
+ private String extractSection(String content, String heading) {
+ if (content == null) return "";
+ String escaped = Pattern.quote(heading.trim());
+ Pattern p = Pattern.compile(
+ "(?m)^(#{1,3})\\s+" + escaped + "\\b.*?(?=^#{1,3}\\s|\\Z)",
+ Pattern.DOTALL | Pattern.MULTILINE
+ );
+ Matcher m = p.matcher(content);
+ return m.find() ? m.group().strip() : content;
+ }
+
+ /**
+ * RFC-032: Truncate content with a helpful message.
+ */
+ private String applyMaxChars(String text, int maxChars) {
+ if (text == null || text.length() <= maxChars) return text;
+ return text.substring(0, maxChars)
+ + "\n\n[Content truncated at " + maxChars + " chars. "
+ + "Use sectionHeading param to read a specific section.]";
+ }
+
private String error(String message) {
return JSONUtil.createObj().set("error", message).toString();
}
diff --git a/mateclaw-server/src/main/resources/application.yml b/mateclaw-server/src/main/resources/application.yml
index 7863d8ec..a80c2875 100644
--- a/mateclaw-server/src/main/resources/application.yml
+++ b/mateclaw-server/src/main/resources/application.yml
@@ -133,7 +133,7 @@ mateclaw:
enabled: true # 自适应降级(连续 miss 后短路到 NoOp,冷却后恢复)
miss-threshold: 5
cool-down-ms: 60000
- # per-provider health tracker for the multi-model failover chain.
+ # RFC-009 P3.3: per-provider health tracker for the multi-model failover chain.
# When a provider hits failure-threshold consecutive failures it enters a cooldown
# window during which the chain walker skips it, avoiding repeated 5-retry stalls
# against a known-broken provider on every conversation turn.
@@ -160,7 +160,7 @@ mate:
per-category:
shell: 120
web: 30
- # RFC-008 tool-result three-layer budget (per-result spill + per-turn aggregate budget).
+ # RFC-008 Phase 3: tool-result three-layer budget (per-result spill + per-turn aggregate budget).
# Layer 1 (per-tool cap) lives inside individual tools; Layer 2 spills oversized
# single results to disk; Layer 3 enforces an aggregate cap on the combined
# response size of one tool turn. The full output is preserved on disk and
diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V21__provider_fallback_priority.sql b/mateclaw-server/src/main/resources/db/migration/h2/V21__provider_fallback_priority.sql
index 9ef9d51e..9e87813c 100644
--- a/mateclaw-server/src/main/resources/db/migration/h2/V21__provider_fallback_priority.sql
+++ b/mateclaw-server/src/main/resources/db/migration/h2/V21__provider_fallback_priority.sql
@@ -1,4 +1,4 @@
--- ordered multi-provider fallback chain
+-- RFC-009 Phase 1: ordered multi-provider fallback chain
--
-- `fallback_priority` defines the order in which a provider is tried after the
-- primary model exhausts retries:
diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V23__wiki_relation_model.sql b/mateclaw-server/src/main/resources/db/migration/h2/V23__wiki_relation_model.sql
new file mode 100644
index 00000000..a68e5a4a
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/h2/V23__wiki_relation_model.sql
@@ -0,0 +1,16 @@
+CREATE TABLE IF NOT EXISTS mate_wiki_page_citation (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ page_id BIGINT NOT NULL,
+ chunk_id BIGINT NOT NULL,
+ paragraph_idx INT NOT NULL DEFAULT 0,
+ anchor_text VARCHAR(512),
+ confidence DECIMAL(4,3) NOT NULL DEFAULT 1.000,
+ created_by VARCHAR(32) NOT NULL DEFAULT 'system',
+ create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ deleted TINYINT NOT NULL DEFAULT 0
+);
+CREATE INDEX IF NOT EXISTS idx_wpc_page ON mate_wiki_page_citation (page_id);
+CREATE INDEX IF NOT EXISTS idx_wpc_chunk ON mate_wiki_page_citation (chunk_id);
+
+ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS page_type VARCHAR(32) NOT NULL DEFAULT 'concept';
+ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS purpose_hint TEXT;
diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V24__wiki_processing_job.sql b/mateclaw-server/src/main/resources/db/migration/h2/V24__wiki_processing_job.sql
new file mode 100644
index 00000000..4630a74a
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/h2/V24__wiki_processing_job.sql
@@ -0,0 +1,25 @@
+CREATE TABLE IF NOT EXISTS mate_wiki_processing_job (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ kb_id BIGINT NOT NULL,
+ raw_id BIGINT NOT NULL,
+ job_type VARCHAR(32) NOT NULL DEFAULT 'heavy_ingest',
+ stage VARCHAR(64) NOT NULL DEFAULT 'queued',
+ status VARCHAR(32) NOT NULL DEFAULT 'queued',
+ primary_model_id BIGINT,
+ current_model_id BIGINT,
+ fallback_chain_json TEXT,
+ retry_count INT NOT NULL DEFAULT 0,
+ max_retries INT NOT NULL DEFAULT 3,
+ error_code VARCHAR(64),
+ error_message TEXT,
+ resume_from_stage VARCHAR(64),
+ meta_json TEXT,
+ started_at DATETIME(3),
+ finished_at DATETIME(3),
+ create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ deleted TINYINT NOT NULL DEFAULT 0
+);
+CREATE INDEX IF NOT EXISTS idx_wpj_raw ON mate_wiki_processing_job (raw_id);
+CREATE INDEX IF NOT EXISTS idx_wpj_status ON mate_wiki_processing_job (status);
+CREATE INDEX IF NOT EXISTS idx_wpj_kb ON mate_wiki_processing_job (kb_id, status);
diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V25__agent_provider_preference.sql b/mateclaw-server/src/main/resources/db/migration/h2/V25__agent_provider_preference.sql
new file mode 100644
index 00000000..985f99d2
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/h2/V25__agent_provider_preference.sql
@@ -0,0 +1,26 @@
+-- RFC-009 Phase 4 PR-3: per-agent provider preferences
+--
+-- Lets each agent declare an ordered list of preferred provider ids. Empty
+-- table for an agent (no rows) means "use the global fallback chain order"
+-- — fully backwards compatible with pre-PR-3 behavior. When rows exist,
+-- listed providers are tried in ascending sort_order before any non-listed
+-- provider is considered.
+--
+-- This is purely a routing hint. The runtime walker still gates each entry
+-- through AvailableProviderPool / ProviderHealthTracker — a preferred
+-- provider that is HARD-removed or in cooldown is still skipped.
+
+CREATE TABLE IF NOT EXISTS mate_agent_provider_preference (
+ id BIGINT NOT NULL PRIMARY KEY,
+ agent_id BIGINT NOT NULL,
+ provider_id VARCHAR(128) NOT NULL,
+ sort_order INT NOT NULL DEFAULT 0,
+ enabled BOOLEAN NOT NULL DEFAULT TRUE,
+ create_time DATETIME NOT NULL,
+ update_time DATETIME NOT NULL,
+ deleted INT NOT NULL DEFAULT 0
+);
+CREATE UNIQUE INDEX IF NOT EXISTS uk_agent_provider
+ ON mate_agent_provider_preference(agent_id, provider_id);
+CREATE INDEX IF NOT EXISTS idx_agent_provider_order
+ ON mate_agent_provider_preference(agent_id, sort_order);
diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V21__provider_fallback_priority.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V21__provider_fallback_priority.sql
index 0a336863..ddf09921 100644
--- a/mateclaw-server/src/main/resources/db/migration/mysql/V21__provider_fallback_priority.sql
+++ b/mateclaw-server/src/main/resources/db/migration/mysql/V21__provider_fallback_priority.sql
@@ -1,4 +1,4 @@
--- ordered multi-provider fallback chain
+-- RFC-009 Phase 1: ordered multi-provider fallback chain
--
-- `fallback_priority` defines the order in which a provider is tried after the
-- primary model exhausts retries:
diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V23__wiki_relation_model.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V23__wiki_relation_model.sql
new file mode 100644
index 00000000..be190cca
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/mysql/V23__wiki_relation_model.sql
@@ -0,0 +1,29 @@
+CREATE TABLE IF NOT EXISTS mate_wiki_page_citation (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ page_id BIGINT NOT NULL,
+ chunk_id BIGINT NOT NULL,
+ paragraph_idx INT NOT NULL DEFAULT 0,
+ anchor_text VARCHAR(512),
+ confidence DECIMAL(4,3) NOT NULL DEFAULT 1.000,
+ created_by VARCHAR(32) NOT NULL DEFAULT 'system',
+ create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ deleted TINYINT NOT NULL DEFAULT 0,
+ INDEX idx_wpc_page (page_id),
+ INDEX idx_wpc_chunk (chunk_id)
+);
+
+SET @c1 = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_page'
+ AND COLUMN_NAME = 'page_type');
+SET @s1 = IF(@c1 = 0,
+ 'ALTER TABLE mate_wiki_page ADD COLUMN page_type VARCHAR(32) NOT NULL DEFAULT ''concept''',
+ 'SELECT 1');
+PREPARE p1 FROM @s1; EXECUTE p1; DEALLOCATE PREPARE p1;
+
+SET @c2 = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_page'
+ AND COLUMN_NAME = 'purpose_hint');
+SET @s2 = IF(@c2 = 0,
+ 'ALTER TABLE mate_wiki_page ADD COLUMN purpose_hint TEXT',
+ 'SELECT 1');
+PREPARE p2 FROM @s2; EXECUTE p2; DEALLOCATE PREPARE p2;
diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V24__wiki_processing_job.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V24__wiki_processing_job.sql
new file mode 100644
index 00000000..db18e4a9
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/mysql/V24__wiki_processing_job.sql
@@ -0,0 +1,25 @@
+CREATE TABLE IF NOT EXISTS mate_wiki_processing_job (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ kb_id BIGINT NOT NULL,
+ raw_id BIGINT NOT NULL,
+ job_type VARCHAR(32) NOT NULL DEFAULT 'heavy_ingest',
+ stage VARCHAR(64) NOT NULL DEFAULT 'queued',
+ status VARCHAR(32) NOT NULL DEFAULT 'queued',
+ primary_model_id BIGINT,
+ current_model_id BIGINT,
+ fallback_chain_json TEXT,
+ retry_count INT NOT NULL DEFAULT 0,
+ max_retries INT NOT NULL DEFAULT 3,
+ error_code VARCHAR(64),
+ error_message TEXT,
+ resume_from_stage VARCHAR(64),
+ meta_json TEXT,
+ started_at DATETIME(3),
+ finished_at DATETIME(3),
+ create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
+ deleted TINYINT NOT NULL DEFAULT 0,
+ INDEX idx_wpj_raw (raw_id),
+ INDEX idx_wpj_status (status),
+ INDEX idx_wpj_kb (kb_id, status)
+);
diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V25__agent_provider_preference.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V25__agent_provider_preference.sql
new file mode 100644
index 00000000..941f6e2d
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/mysql/V25__agent_provider_preference.sql
@@ -0,0 +1,24 @@
+-- RFC-009 Phase 4 PR-3: per-agent provider preferences
+--
+-- Lets each agent declare an ordered list of preferred provider ids. Empty
+-- table for an agent (no rows) means "use the global fallback chain order"
+-- — fully backwards compatible with pre-PR-3 behavior. When rows exist,
+-- listed providers are tried in ascending sort_order before any non-listed
+-- provider is considered.
+--
+-- This is purely a routing hint. The runtime walker still gates each entry
+-- through AvailableProviderPool / ProviderHealthTracker — a preferred
+-- provider that is HARD-removed or in cooldown is still skipped.
+
+CREATE TABLE IF NOT EXISTS mate_agent_provider_preference (
+ id BIGINT NOT NULL PRIMARY KEY,
+ agent_id BIGINT NOT NULL,
+ provider_id VARCHAR(128) NOT NULL,
+ sort_order INT NOT NULL DEFAULT 0,
+ enabled TINYINT(1) NOT NULL DEFAULT 1,
+ create_time DATETIME NOT NULL,
+ update_time DATETIME NOT NULL,
+ deleted INT NOT NULL DEFAULT 0,
+ UNIQUE KEY uk_agent_provider (agent_id, provider_id),
+ KEY idx_agent_provider_order (agent_id, sort_order)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/AgentGraphBuilderPreferenceTest.java b/mateclaw-server/src/test/java/vip/mate/agent/AgentGraphBuilderPreferenceTest.java
new file mode 100644
index 00000000..c2c10749
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/AgentGraphBuilderPreferenceTest.java
@@ -0,0 +1,78 @@
+package vip.mate.agent;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import vip.mate.llm.model.ModelProviderEntity;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * RFC-009 PR-3 — verifies the agent-preference reorder used by
+ * {@link AgentGraphBuilder#buildFallbackChain}: listed providers move to the
+ * front in their declared order; unlisted providers keep their original
+ * relative order; missing/duplicate preferences are ignored gracefully.
+ */
+class AgentGraphBuilderPreferenceTest {
+
+ private static ModelProviderEntity p(String id) {
+ ModelProviderEntity p = new ModelProviderEntity();
+ p.setProviderId(id);
+ return p;
+ }
+
+ private static List ids(List ps) {
+ return ps.stream().map(ModelProviderEntity::getProviderId).toList();
+ }
+
+ @Test
+ @DisplayName("Empty preferences: original order preserved")
+ void noPreferences() {
+ var input = List.of(p("openai"), p("anthropic"), p("dashscope"));
+ var out = AgentGraphBuilder.reorderByPreferences(input, List.of());
+ assertEquals(List.of("openai", "anthropic", "dashscope"), ids(out));
+ }
+
+ @Test
+ @DisplayName("Single preference: preferred provider moves to front, rest follow original order")
+ void singlePreferenceFront() {
+ var input = List.of(p("openai"), p("anthropic"), p("dashscope"));
+ var out = AgentGraphBuilder.reorderByPreferences(input, List.of("dashscope"));
+ assertEquals(List.of("dashscope", "openai", "anthropic"), ids(out));
+ }
+
+ @Test
+ @DisplayName("Multiple preferences: preferred order matches declaration, rest stable")
+ void multiplePreferencesOrder() {
+ var input = List.of(p("openai"), p("anthropic"), p("dashscope"), p("kimi"));
+ var out = AgentGraphBuilder.reorderByPreferences(input, List.of("kimi", "anthropic"));
+ // kimi → anthropic → (rest in original order: openai, dashscope)
+ assertEquals(List.of("kimi", "anthropic", "openai", "dashscope"), ids(out));
+ }
+
+ @Test
+ @DisplayName("Preference references unknown provider: silently skipped")
+ void preferenceReferencesUnknown() {
+ var input = List.of(p("openai"), p("anthropic"));
+ var out = AgentGraphBuilder.reorderByPreferences(input, List.of("ghost", "anthropic"));
+ assertEquals(List.of("anthropic", "openai"), ids(out));
+ }
+
+ @Test
+ @DisplayName("Duplicate preferences: each provider appears at most once")
+ void duplicatePreferencesDeduped() {
+ var input = List.of(p("openai"), p("anthropic"));
+ var out = AgentGraphBuilder.reorderByPreferences(input, List.of("openai", "openai", "anthropic"));
+ assertEquals(List.of("openai", "anthropic"), ids(out));
+ }
+
+ @Test
+ @DisplayName("All providers preferred: input pure-reordered, no drops")
+ void allProvidersPreferred() {
+ var input = List.of(p("openai"), p("anthropic"), p("dashscope"));
+ var out = AgentGraphBuilder.reorderByPreferences(input,
+ List.of("dashscope", "openai", "anthropic"));
+ assertEquals(List.of("dashscope", "openai", "anthropic"), ids(out));
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/ErrorClassificationTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/ErrorClassificationTest.java
index 58ead87e..5227d185 100644
--- a/mateclaw-server/src/test/java/vip/mate/agent/graph/ErrorClassificationTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/ErrorClassificationTest.java
@@ -8,7 +8,7 @@ import java.lang.reflect.Method;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
- * classification tests for the new error types
+ * RFC-009 P3.2: classification tests for the new error types
* ({@link NodeStreamingChatHelper.ErrorType#BILLING},
* {@link NodeStreamingChatHelper.ErrorType#MODEL_NOT_FOUND}).
*
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperFailoverTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperFailoverTest.java
index 3d322669..226fa2c6 100644
--- a/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperFailoverTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperFailoverTest.java
@@ -153,7 +153,7 @@ class NodeStreamingChatHelperFailoverTest {
// ============================================================
@Test
- @DisplayName("C4 (regression): primary BILLING still triggers fallback (unchanged P3.2)")
+ @DisplayName("C4 (regression): primary BILLING still triggers fallback (unchanged from RFC-009 P3.2)")
void billingStillFallsBack() {
ChatModel primary = errorModel(new RuntimeException("402 Payment Required: insufficient_quota"));
ChatModel fallback = successModel("recovered via fallback");
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperFallbackChainTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperFallbackChainTest.java
index 1765f33b..21107fca 100644
--- a/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperFallbackChainTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperFallbackChainTest.java
@@ -13,7 +13,7 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
/**
- * smoke tests for the multi-model fallback chain wiring on
+ * RFC-009: smoke tests for the multi-model fallback chain wiring on
* {@link NodeStreamingChatHelper}.
*
*
Full streaming-flow integration (ChatModel.stream / Flux mocking) is left
@@ -88,7 +88,7 @@ class NodeStreamingChatHelperFallbackChainTest {
}
@Test
- @DisplayName("EMPTY_RESPONSE / BILLING / MODEL_NOT_FOUND error types exist ")
+ @DisplayName("EMPTY_RESPONSE / BILLING / MODEL_NOT_FOUND error types exist (RFC-009 fallback triggers)")
void fallbackTriggerErrorTypesExist() {
// Compile-time safety net: these enum constants the streaming pipeline relies on
// must not be renamed or removed without breaking the fallback contract.
@@ -98,7 +98,7 @@ class NodeStreamingChatHelperFallbackChainTest {
}
@Test
- @DisplayName("primary providerId is stored when supplied via the full constructor")
+ @DisplayName("RFC-009 P3.1: primary providerId is stored when supplied via the full constructor")
void primaryProviderIdStored() throws Exception {
NodeStreamingChatHelper helper = new NodeStreamingChatHelper(
streamTracker, List.of(), null, null, "openai");
@@ -110,7 +110,7 @@ class NodeStreamingChatHelperFallbackChainTest {
}
@Test
- @DisplayName("legacy constructors leave primaryProviderId null (tracking disabled)")
+ @DisplayName("RFC-009 P3.1: legacy constructors leave primaryProviderId null (tracking disabled)")
void primaryProviderIdNullForLegacyConstructors() throws Exception {
NodeStreamingChatHelper helper = new NodeStreamingChatHelper(streamTracker);
Field f = NodeStreamingChatHelper.class.getDeclaredField("primaryProviderId");
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperPoolTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperPoolTest.java
new file mode 100644
index 00000000..f5ef3903
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperPoolTest.java
@@ -0,0 +1,269 @@
+package vip.mate.agent.graph;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.ai.chat.messages.AssistantMessage;
+import org.springframework.ai.chat.messages.UserMessage;
+import org.springframework.ai.chat.metadata.ChatGenerationMetadata;
+import org.springframework.ai.chat.model.ChatModel;
+import org.springframework.ai.chat.model.ChatResponse;
+import org.springframework.ai.chat.model.Generation;
+import org.springframework.ai.chat.prompt.Prompt;
+import reactor.core.publisher.Flux;
+import vip.mate.channel.web.ChatStreamTracker;
+import vip.mate.llm.failover.AvailableProviderPool;
+import vip.mate.llm.failover.AvailableProviderPool.RemovalSource;
+import vip.mate.llm.failover.FallbackEntry;
+import vip.mate.llm.failover.ProviderHealthProperties;
+import vip.mate.llm.failover.ProviderHealthTracker;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * RFC-009 Phase 4 — verifies the three pool hooks wired into
+ * {@link NodeStreamingChatHelper}:
+ *
+ *
Primary short-circuit when its provider id is not in the pool —
+ * primary is never even called, fallback runs first.
+ *
Walker head filter — out-of-pool fallback entries are skipped.
+ *
HARD error → {@code pool.remove}; SOFT error → pool unchanged.
+ *
+ *
+ *
Pool state must remain consistent across these three behaviors so a
+ * single misconfigured provider can't pollute every conversation turn.
+ */
+class NodeStreamingChatHelperPoolTest {
+
+ private ChatStreamTracker streamTracker;
+ private ProviderHealthTracker healthTracker;
+ private AvailableProviderPool pool;
+
+ @BeforeEach
+ void setUp() {
+ streamTracker = mock(ChatStreamTracker.class);
+ when(streamTracker.isStopRequested(any())).thenReturn(false);
+ healthTracker = new ProviderHealthTracker(new ProviderHealthProperties());
+ pool = new AvailableProviderPool();
+ }
+
+ private static ChatModel successModel(String text) {
+ ChatModel m = mock(ChatModel.class);
+ Generation gen = new Generation(new AssistantMessage(text), ChatGenerationMetadata.NULL);
+ ChatResponse resp = mock(ChatResponse.class);
+ when(resp.getResults()).thenReturn(List.of(gen));
+ when(resp.getResult()).thenReturn(gen);
+ when(resp.getMetadata()).thenReturn(null);
+ when(m.stream(any(Prompt.class))).thenReturn(Flux.just(resp));
+ return m;
+ }
+
+ private static ChatModel errorModel(Throwable err) {
+ ChatModel m = mock(ChatModel.class);
+ when(m.stream(any(Prompt.class))).thenReturn(Flux.error(err));
+ return m;
+ }
+
+ /** Stream a single chunk with empty text and no tool calls — triggers EMPTY_RESPONSE (SOFT). */
+ private static ChatModel emptyResponseModel() {
+ ChatModel m = mock(ChatModel.class);
+ Generation gen = new Generation(new AssistantMessage(""), ChatGenerationMetadata.NULL);
+ ChatResponse resp = mock(ChatResponse.class);
+ when(resp.getResults()).thenReturn(List.of(gen));
+ when(resp.getResult()).thenReturn(gen);
+ when(resp.getMetadata()).thenReturn(null);
+ when(m.stream(any(Prompt.class))).thenReturn(Flux.just(resp));
+ return m;
+ }
+
+ private NodeStreamingChatHelper helper(List chain, String primary) {
+ return new NodeStreamingChatHelper(streamTracker, chain, null, healthTracker, primary, pool);
+ }
+
+ private static Prompt smallPrompt() {
+ return new Prompt(List.of(new UserMessage("hi")));
+ }
+
+ // ============================================================
+ // Hook 1: primary out-of-pool short-circuits the retry loop
+ // ============================================================
+
+ @Test
+ @DisplayName("Primary not in pool: skipped without being called, fallback wins")
+ void primaryOutOfPoolShortCircuits() {
+ // openai is HARD-removed from pool before the call
+ pool.add("dashscope");
+ pool.remove("openai", RemovalSource.AUTH_ERROR, "stale 401");
+
+ ChatModel primary = successModel("primary should never be called");
+ ChatModel fallback = successModel("fallback wins");
+ var helper = helper(List.of(new FallbackEntry("dashscope", fallback)), "openai");
+
+ var result = helper.streamCall(primary, smallPrompt(), "conv-h1", "reasoning");
+
+ assertEquals("fallback wins", result.text());
+ verify(primary, never()).stream(any(Prompt.class));
+ verify(fallback, times(1)).stream(any(Prompt.class));
+ }
+
+ // ============================================================
+ // Hook 2: walker skips out-of-pool fallback entries
+ // ============================================================
+
+ @Test
+ @DisplayName("Walker skips out-of-pool fallback and lands on the next eligible one")
+ void walkerSkipsOutOfPoolFallback() {
+ pool.add("openai"); // primary
+ pool.remove("anthropic", RemovalSource.BILLING, "402"); // first fallback dead
+ pool.add("dashscope"); // second fallback alive
+
+ // Use AUTH_ERROR (HARD) on primary — triggers the immediate break-to-walker
+ // path. Picking SERVER_ERROR would burn 5 retries (~110s) and then exit
+ // without ever hitting the walker, which is unrelated to the property
+ // under test here.
+ ChatModel primary = errorModel(new RuntimeException("401 Unauthorized"));
+ ChatModel fbAnthropic = successModel("should be skipped");
+ ChatModel fbDashscope = successModel("dashscope wins");
+ var helper = helper(List.of(
+ new FallbackEntry("anthropic", fbAnthropic),
+ new FallbackEntry("dashscope", fbDashscope)), "openai");
+
+ var result = helper.streamCall(primary, smallPrompt(), "conv-h2", "reasoning");
+
+ assertEquals("dashscope wins", result.text());
+ verify(fbAnthropic, never()).stream(any(Prompt.class));
+ verify(fbDashscope, times(1)).stream(any(Prompt.class));
+ }
+
+ // ============================================================
+ // Hook 3a: primary HARD error evicts from pool
+ // ============================================================
+
+ @Test
+ @DisplayName("Primary AUTH_ERROR HARD-removes openai from pool with AUTH_ERROR source")
+ void primaryAuthErrorEvictsFromPool() {
+ pool.add("openai");
+ pool.add("dashscope");
+
+ ChatModel primary = errorModel(new RuntimeException("401 Unauthorized: bad key"));
+ ChatModel fallback = successModel("recovered");
+ var helper = helper(List.of(new FallbackEntry("dashscope", fallback)), "openai");
+
+ helper.streamCall(primary, smallPrompt(), "conv-h3a", "reasoning");
+
+ assertFalse(pool.contains("openai"), "openai must be removed from pool after AUTH_ERROR");
+ var reason = pool.snapshot().get("openai");
+ assertNotNull(reason);
+ assertEquals(RemovalSource.AUTH_ERROR, reason.source());
+ assertTrue(pool.contains("dashscope"), "successful fallback stays in pool");
+ }
+
+ @Test
+ @DisplayName("Primary BILLING HARD-removes with BILLING source (distinct from AUTH)")
+ void primaryBillingEvictsWithBillingSource() {
+ pool.add("openai");
+ pool.add("dashscope");
+
+ ChatModel primary = errorModel(new RuntimeException("402 Payment Required: insufficient_quota"));
+ ChatModel fallback = successModel("ok");
+ var helper = helper(List.of(new FallbackEntry("dashscope", fallback)), "openai");
+
+ helper.streamCall(primary, smallPrompt(), "conv-h3b", "reasoning");
+
+ assertFalse(pool.contains("openai"));
+ assertEquals(RemovalSource.BILLING, pool.snapshot().get("openai").source());
+ }
+
+ @Test
+ @DisplayName("Primary MODEL_NOT_FOUND HARD-removes with MODEL_NOT_FOUND source")
+ void primaryModelNotFoundEvictsWithCorrectSource() {
+ pool.add("openai");
+ pool.add("dashscope");
+
+ ChatModel primary = errorModel(new RuntimeException("404 model_not_found: gpt-99"));
+ ChatModel fallback = successModel("ok");
+ var helper = helper(List.of(new FallbackEntry("dashscope", fallback)), "openai");
+
+ helper.streamCall(primary, smallPrompt(), "conv-h3c", "reasoning");
+
+ assertFalse(pool.contains("openai"));
+ assertEquals(RemovalSource.MODEL_NOT_FOUND, pool.snapshot().get("openai").source());
+ }
+
+ // ============================================================
+ // Hook 3b: SOFT errors do NOT evict from pool
+ // ============================================================
+
+ @Test
+ @DisplayName("Primary EMPTY_RESPONSE (SOFT) keeps provider in pool, only records failure")
+ void primarySoftErrorKeepsInPool() {
+ pool.add("openai");
+ pool.add("dashscope");
+
+ // EMPTY_RESPONSE is SOFT and breaks straight to fallback (no 5x retry)
+ // — keeps the test fast while still exercising the SOFT path.
+ ChatModel primary = emptyResponseModel();
+ ChatModel fallback = successModel("ok");
+ var helper = helper(List.of(new FallbackEntry("dashscope", fallback)), "openai");
+
+ helper.streamCall(primary, smallPrompt(), "conv-h3d", "reasoning");
+
+ assertTrue(pool.contains("openai"),
+ "SOFT errors must NOT evict — health tracker cooldown handles transient blips");
+ assertTrue(healthTracker.snapshot().get("openai").consecutiveFailures() > 0,
+ "SOFT failure must still be recorded by the health tracker");
+ }
+
+ // ============================================================
+ // Hook 3c: fallback HARD errors also evict
+ // ============================================================
+
+ @Test
+ @DisplayName("Fallback AUTH_ERROR evicts the fallback provider and walker continues")
+ void fallbackHardErrorEvictsFallback() {
+ pool.add("openai");
+ pool.add("anthropic");
+ pool.add("dashscope");
+
+ // Use AUTH on primary so we reach the walker without burning 5 retries.
+ // The behavior under test is fallback eviction, not the primary path.
+ ChatModel primary = errorModel(new RuntimeException("401 Unauthorized: openai key"));
+ ChatModel fbBad = errorModel(new RuntimeException("401 Unauthorized: anthropic key"));
+ ChatModel fbGood = successModel("dashscope ok");
+ var helper = helper(List.of(
+ new FallbackEntry("anthropic", fbBad),
+ new FallbackEntry("dashscope", fbGood)), "openai");
+
+ var result = helper.streamCall(primary, smallPrompt(), "conv-h3e", "reasoning");
+
+ assertEquals("dashscope ok", result.text());
+ assertFalse(pool.contains("anthropic"), "fallback that failed AUTH must be evicted");
+ assertEquals(RemovalSource.AUTH_ERROR, pool.snapshot().get("anthropic").source());
+ assertTrue(pool.contains("dashscope"));
+ }
+
+ // ============================================================
+ // Sanity: fail-open mode (null pool) — old call sites unchanged
+ // ============================================================
+
+ @Test
+ @DisplayName("Null pool: helper behaves as before (no NPE, no skipping)")
+ void nullPoolFailOpen() {
+ ChatModel primary = errorModel(new RuntimeException("401 Unauthorized"));
+ ChatModel fallback = successModel("ok");
+ // 5-arg constructor — no pool wired
+ var helper = new NodeStreamingChatHelper(streamTracker,
+ List.of(new FallbackEntry("dashscope", fallback)), null, healthTracker, "openai");
+
+ var result = helper.streamCall(primary, smallPrompt(), "conv-failopen", "reasoning");
+
+ assertEquals("ok", result.text());
+ // No pool to inspect — just confirm we didn't crash and fallback ran.
+ verify(primary, times(1)).stream(any(Prompt.class));
+ verify(fallback, times(1)).stream(any(Prompt.class));
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/llm/failover/ProviderHealthTrackerTest.java b/mateclaw-server/src/test/java/vip/mate/llm/failover/ProviderHealthTrackerTest.java
index 8908c8bd..97d3b478 100644
--- a/mateclaw-server/src/test/java/vip/mate/llm/failover/ProviderHealthTrackerTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/llm/failover/ProviderHealthTrackerTest.java
@@ -7,7 +7,7 @@ import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
- * per-provider failure-count + cooldown logic.
+ * RFC-009 P3.3: per-provider failure-count + cooldown logic.
*/
class ProviderHealthTrackerTest {
diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts
index f2f83169..56426cfb 100644
--- a/mateclaw-ui/src/api/index.ts
+++ b/mateclaw-ui/src/api/index.ts
@@ -282,6 +282,33 @@ export const modelApi = {
http.post('/models/embedding/default', { modelId }),
}
+// ==================== Provider Pool (RFC-009 Phase 4) ====================
+export interface ProviderPoolEntry {
+ providerId: string
+ providerName: string
+ inPool: boolean
+ removalSource: string | null
+ removalMessage: string | null
+ removedAtMs: number | null
+ inCooldown: boolean
+ cooldownRemainingMs: number
+ consecutiveFailures: number
+}
+
+export interface ReprobeResult {
+ providerId: string
+ success: boolean
+ latencyMs: number
+ errorMessage: string | null
+ inPool: boolean
+}
+
+export const providerPoolApi = {
+ snapshot: () => http.get('/llm/provider-pool'),
+ reprobe: (providerId: string) =>
+ http.post(`/llm/provider-pool/${encodeURIComponent(providerId)}/reprobe`),
+}
+
// ==================== OAuth ====================
export const oauthApi = {
authorize: () => http.get('/oauth/openai/authorize'),
@@ -407,6 +434,30 @@ export const wikiApi = {
// Processing
processKB: (kbId: number) => http.post(`/wiki/knowledge-bases/${kbId}/process`),
getProcessingStatus: (kbId: number) => http.get(`/wiki/knowledge-bases/${kbId}/processing-status`),
+
+ // RFC-029: Relations
+ getRelatedPages: (kbId: number, slug: string, topK = 5) =>
+ http.get(`/wiki/kb/${kbId}/pages/${encodeURIComponent(slug)}/related`, { params: { topK } }),
+ explainRelation: (kbId: number, slugA: string, slugB: string) =>
+ http.get(`/wiki/kb/${kbId}/pages/${encodeURIComponent(slugA)}/relation/${encodeURIComponent(slugB)}`),
+ getPageCitations: (kbId: number, pageId: number) =>
+ http.get(`/wiki/kb/${kbId}/pages/${pageId}/citations`),
+
+ // RFC-030: Jobs
+ getWikiJobs: (kbId: number, rawId: number) =>
+ http.get(`/wiki/kb/${kbId}/jobs`, { params: { rawId } }),
+ getKBStats: (kbId: number) =>
+ http.get(`/wiki/kb/${kbId}/stats`),
+
+ // RFC-031: Enrichment & Repair
+ enrichPage: (kbId: number, slug: string) =>
+ http.post(`/wiki/kb/${kbId}/pages/${encodeURIComponent(slug)}/enrich`),
+ repairPage: (kbId: number, slug: string) =>
+ http.post(`/wiki/kb/${kbId}/pages/${encodeURIComponent(slug)}/repair`),
+
+ // RFC-032: Search preview
+ searchPreview: (kbId: number, data: { query: string; mode?: string; topK?: number }) =>
+ http.post(`/wiki/kb/${kbId}/search-preview`, data),
}
// ==================== Workspace (Team) ====================
@@ -433,6 +484,11 @@ export const agentBindingApi = {
unbindSkill: (agentId: string | number, skillId: number) => http.delete(`/agents/${agentId}/skills/${skillId}`),
listTools: (agentId: string | number) => http.get(`/agents/${agentId}/tools`),
setTools: (agentId: string | number, toolNames: string[]) => http.put(`/agents/${agentId}/tools`, toolNames),
+ // RFC-009 PR-3: per-agent provider preference order. Empty list = use global chain order.
+ listProviderPreferences: (agentId: string | number) =>
+ http.get(`/agents/${agentId}/provider-preferences`),
+ setProviderPreferences: (agentId: string | number, providerIds: string[]) =>
+ http.put(`/agents/${agentId}/provider-preferences`, providerIds),
}
// ==================== Dashboard ====================
diff --git a/mateclaw-ui/src/composables/useWikiJobPoller.ts b/mateclaw-ui/src/composables/useWikiJobPoller.ts
new file mode 100644
index 00000000..a33f8a4c
--- /dev/null
+++ b/mateclaw-ui/src/composables/useWikiJobPoller.ts
@@ -0,0 +1,51 @@
+import { ref, onMounted, onUnmounted, type Ref } from 'vue'
+import { wikiApi } from '@/api/index'
+
+export interface WikiProcessingJob {
+ id: number
+ kbId: number
+ rawId: number
+ jobType: string
+ stage: string
+ status: string
+ primaryModelId: number | null
+ currentModelId: number | null
+ currentModelName?: string
+ errorCode: string | null
+ errorMessage: string | null
+ retryCount: number
+ startedAt: string | null
+ finishedAt: string | null
+ done?: number
+ total?: number
+}
+
+/**
+ * RFC-033: Polls the latest processing job for a given raw material.
+ * Stops polling when the job reaches a terminal status.
+ */
+export function useWikiJobPoller(kbId: Ref, rawId: Ref) {
+ const job = ref(null)
+ let timer: ReturnType | null = null
+
+ const poll = async () => {
+ if (!kbId.value || !rawId.value) return
+ try {
+ const jobs: any = await wikiApi.getWikiJobs(kbId.value, rawId.value)
+ const list = jobs.data || jobs || []
+ job.value = list[0] ?? null
+ if (job.value && (job.value.status === 'running' || job.value.status === 'queued')) {
+ timer = setTimeout(poll, 3000)
+ }
+ } catch {
+ job.value = null
+ }
+ }
+
+ onMounted(poll)
+ onUnmounted(() => {
+ if (timer) clearTimeout(timer)
+ })
+
+ return { job, refresh: poll }
+}
diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts
index 3c809588..d08101c3 100644
--- a/mateclaw-ui/src/i18n/locales/en-US.ts
+++ b/mateclaw-ui/src/i18n/locales/en-US.ts
@@ -332,9 +332,25 @@ export default {
protocolGemini: 'Gemini Native',
protocolDashScope: 'DashScope Native',
advancedHint: 'Use this for generation options such as temperature, max_tokens, and top_p.',
- fallbackPriorityHint: 'Failover order after the primary model fails: 0 = excluded, 1 = first in line, 2 = second, and so on. Providers sharing the same value are tried in alphabetical order of their ID.',
- fallbackBadge: 'Fallback #{priority}',
- fallbackBadgeTitle: 'Position in the multi-model failover chain — lower numbers are tried first',
+ fallbackPriorityHint: 'Pool try-order (lower wins): 0 = excluded, 1 = first in line, 2 = second, and so on. Providers sharing the same value are tried in alphabetical order of their ID.',
+ fallbackBadge: 'Preferred #{priority}',
+ fallbackBadgeTitle: 'Position in the available pool — lower numbers are tried first',
+ // RFC-009 Phase 4: provider pool status badges
+ poolBadgeInPool: 'In Pool',
+ poolBadgeInPoolTitle: 'Passed startup probe; participates in failover',
+ poolBadgeRemoved: 'Removed',
+ poolBadgeRemovedTitle: 'Removed from pool ({source}): {message}',
+ poolBadgeCooldown: 'Cooling Down',
+ poolBadgeCooldownTitle: 'Recent failures; recovers automatically in {seconds}s',
+ poolSourceAuthError: 'API key invalid',
+ poolSourceBilling: 'Billing/quota issue',
+ poolSourceModelNotFound: 'Model not found',
+ poolSourceInitProbe: 'Startup probe failed',
+ poolSourceManual: 'Manually removed',
+ poolReprobe: 'Reprobe',
+ poolReprobing: 'Probing...',
+ poolReprobeOk: 'Probe passed; back in the available pool',
+ poolReprobeFail: 'Probe failed: {error}',
searchHint: 'When enabled, the LLM will use its built-in search engine to retrieve real-time information (DashScope/Kimi/OpenAI supported).',
searchStrategyDefault: 'Default',
oauthTitle: 'OpenAI OAuth Login',
@@ -352,7 +368,7 @@ export default {
apiKeyPrefix: 'API Key Prefix',
protocol: 'Protocol',
generateKwargs: 'Generate Kwargs (JSON)',
- fallbackPriority: 'Failover Priority',
+ fallbackPriority: 'Pool Try-Order',
enableSearch: 'Built-in Search',
searchStrategy: 'Search Strategy',
modelId: 'Model ID',
@@ -611,6 +627,7 @@ export default {
basic: 'Basic',
skills: 'Skills',
tools: 'Tools',
+ providers: 'Providers',
context: 'Context',
},
columns: {
@@ -683,8 +700,11 @@ export default {
binding: {
skillsHint: 'Select skills this agent can use. Leave empty to use all enabled skills.',
toolsHint: 'Select tools this agent can use. Leave empty to use all enabled tools.',
+ providersHint: 'Preferred provider order for this agent (lower index tried first). Leave empty to use the global available-pool order. Cooling-down or pool-removed providers are still skipped automatically.',
+ providersAddHint: 'Click a provider below to add it to the preference list:',
noSkills: 'No skills available',
noTools: 'No tools available',
+ noProviderPreferences: 'No preferences set — the agent uses the global fallback chain order.',
contextHint: 'Manage context files (e.g. AGENT.md) that define this agent\'s behavior, knowledge, and instructions.',
goToContext: 'Edit Context Files',
},
@@ -1233,6 +1253,64 @@ export default {
progress: {
preparing: 'Preparing…',
},
+ jobStage: {
+ queued: 'Queued',
+ routing: 'Routing',
+ phase_a_running: 'Analyzing',
+ phase_a_done: 'Analyzed',
+ phase_b_running: 'Generating',
+ enriching: 'Linking',
+ embedding: 'Embedding',
+ completed: 'Completed',
+ failed: 'Failed',
+ cancelled: 'Cancelled',
+ },
+ jobInfo: {
+ currentModel: 'Model',
+ fallbackActive: 'Fallback active',
+ elapsedTime: 'Elapsed',
+ pagesProgress: '{done}/{total} pages',
+ },
+ page: {
+ type: {
+ entity: 'Entity',
+ concept: 'Concept',
+ source: 'Source',
+ synthesis: 'Synthesis',
+ },
+ enriched: 'Linked',
+ notEnriched: 'Not linked',
+ enrich: 'Add cross-links',
+ repair: 'Repair page',
+ citations: '{count} source chunks',
+ noCitations: 'No source citations found',
+ },
+ relation: {
+ shared_chunk: 'Shared chunk',
+ shared_raw: 'Shared source',
+ direct_link: 'Direct link',
+ semantic_near: 'Semantic',
+ relatedPages: 'Related Pages',
+ explainRelation: 'Explain relation',
+ },
+ configPanel: {
+ modelStrategy: 'Model Strategy',
+ stepModel: {
+ route: 'Analysis / Routing',
+ create_page: 'Page Creation',
+ merge_page: 'Page Merge',
+ enrich: 'Link Annotation',
+ summary: 'Summary Update',
+ },
+ fallbackModels: 'Fallback Models',
+ searchPreview: 'Search Preview',
+ searchPreviewPlaceholder: 'Enter a user message to preview agent search results…',
+ searchPreviewRun: 'Test',
+ },
+ stats: {
+ enrichedRatio: '{enriched}/{total} linked',
+ failedJobs: '{count} failed',
+ },
},
cronJobs: {
kicker: 'Automation',
diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts
index 51f02dc9..f674ba62 100644
--- a/mateclaw-ui/src/i18n/locales/zh-CN.ts
+++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts
@@ -322,9 +322,25 @@ export default {
protocolGemini: 'Gemini 原生',
protocolDashScope: 'DashScope 原生',
advancedHint: '用于补充 temperature、max_tokens、top_p 等生成参数。',
- fallbackPriorityHint: '主模型失败后的失败切换顺序:0 = 不参与;1 = 第一顺位;2 = 第二顺位,依此类推。多个 provider 共用同一数字时按 ID 字典序。',
- fallbackBadge: '兜底 #{priority}',
- fallbackBadgeTitle: '该 provider 在多模型失败切换链中,数字越小越先尝试',
+ fallbackPriorityHint: '池内尝试顺序(数字越小越先):0 = 不参与;1 = 第一顺位;2 = 第二顺位,依此类推。多个 provider 共用同一数字时按 ID 字典序。',
+ fallbackBadge: '偏好 #{priority}',
+ fallbackBadgeTitle: '可用池内的尝试顺序,数字越小越先尝试',
+ // RFC-009 Phase 4: provider pool status badges
+ poolBadgeInPool: '可用',
+ poolBadgeInPoolTitle: '已通过启动体检,参与失败切换',
+ poolBadgeRemoved: '已下线',
+ poolBadgeRemovedTitle: '已从可用池移除({source}):{message}',
+ poolBadgeCooldown: '冷却中',
+ poolBadgeCooldownTitle: '近期连续失败,{seconds} 秒后自动恢复',
+ poolSourceAuthError: 'API Key 失效',
+ poolSourceBilling: '余额/计费问题',
+ poolSourceModelNotFound: '模型不存在',
+ poolSourceInitProbe: '启动体检失败',
+ poolSourceManual: '手动移除',
+ poolReprobe: '重新检测',
+ poolReprobing: '检测中...',
+ poolReprobeOk: '检测通过,已重新加入可用池',
+ poolReprobeFail: '检测失败:{error}',
searchHint: '开启后,大模型将在回答时自动调用内置搜索引擎获取实时信息(DashScope/Kimi/OpenAI 支持)。',
searchStrategyDefault: '默认',
oauthTitle: 'OpenAI OAuth 登录',
@@ -342,7 +358,7 @@ export default {
apiKeyPrefix: 'API Key 前缀',
protocol: '协议',
generateKwargs: 'Generate Kwargs (JSON)',
- fallbackPriority: '失败切换优先级',
+ fallbackPriority: '池内尝试顺序',
enableSearch: '内置搜索',
searchStrategy: '搜索策略',
modelId: '模型 ID',
@@ -611,6 +627,7 @@ export default {
basic: '基本信息',
skills: '技能',
tools: '工具',
+ providers: '偏好 Provider',
context: '上下文',
},
columns: {
@@ -683,8 +700,11 @@ export default {
binding: {
skillsHint: '选择此智能体可使用的技能。留空则使用所有已启用的技能。',
toolsHint: '选择此智能体可使用的工具。留空则使用所有已启用的工具。',
+ providersHint: '此智能体优先使用的 Provider 顺序(数字越小越先尝试)。留空则按全局可用池顺序回退。Provider 进入冷却或被移出池时仍会被自动跳过。',
+ providersAddHint: '点击下方 Provider 加入偏好列表:',
noSkills: '暂无可用技能',
noTools: '暂无可用工具',
+ noProviderPreferences: '尚未配置偏好顺序,将按全局回退链顺序使用。',
contextHint: '管理此智能体的上下文文件(如 AGENT.md),定义智能体的行为、知识和指令。',
goToContext: '前往编辑上下文',
},
@@ -1243,6 +1263,64 @@ export default {
progress: {
preparing: '准备中…',
},
+ jobStage: {
+ queued: '排队中',
+ routing: '选择模型',
+ phase_a_running: '分析中',
+ phase_a_done: '分析完成',
+ phase_b_running: '生成页面',
+ enriching: '补充链接',
+ embedding: '向量化',
+ completed: '已完成',
+ failed: '处理失败',
+ cancelled: '已取消',
+ },
+ jobInfo: {
+ currentModel: '当前模型',
+ fallbackActive: '已启用备用模型',
+ elapsedTime: '已用时',
+ pagesProgress: '已生成 {done}/{total} 页',
+ },
+ page: {
+ type: {
+ entity: '实体',
+ concept: '概念',
+ source: '来源',
+ synthesis: '综合',
+ },
+ enriched: '已链接',
+ notEnriched: '未链接',
+ enrich: '添加交叉链接',
+ repair: '修复页面',
+ citations: '来源 {count} 个 chunk',
+ noCitations: '未找到来源引用',
+ },
+ relation: {
+ shared_chunk: '共享片段',
+ shared_raw: '共享来源',
+ direct_link: '直接引用',
+ semantic_near: '语义相近',
+ relatedPages: '相关页面',
+ explainRelation: '查看关联原因',
+ },
+ configPanel: {
+ modelStrategy: '模型策略',
+ stepModel: {
+ route: '分析/路由',
+ create_page: '页面生成',
+ merge_page: '页面合并',
+ enrich: '链接标注',
+ summary: '摘要更新',
+ },
+ fallbackModels: '备选模型',
+ searchPreview: '检索测试',
+ searchPreviewPlaceholder: '输入用户消息,预览 Agent 检索结果…',
+ searchPreviewRun: '测试',
+ },
+ stats: {
+ enrichedRatio: '{enriched}/{total} 已链接',
+ failedJobs: '{count} 个失败',
+ },
},
cronJobs: {
kicker: '自动执行',
diff --git a/mateclaw-ui/src/views/Agents.vue b/mateclaw-ui/src/views/Agents.vue
index 7eb80611..c048f18c 100644
--- a/mateclaw-ui/src/views/Agents.vue
+++ b/mateclaw-ui/src/views/Agents.vue
@@ -162,6 +162,10 @@
{{ t('agents.tabs.tools', 'Tools') }}
{{ selectedToolNames.length }}
+
@@ -260,6 +264,38 @@
+
+
+