Junjie
8 天以前 1b93474a67aa2323d20630b1bb026713b2bad009
#Agent自动调参
13个文件已添加
16个文件已修改
1个文件已删除
4609 ■■■■■ 已修改文件
src/main/java/com/zy/ai/controller/AutoTuneConsoleController.java 43 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/domain/autotune/AutoTuneApplyResult.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/domain/autotune/AutoTuneControlModeSnapshot.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/domain/autotune/AutoTuneHotPathSegmentItem.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/domain/autotune/AutoTuneRoutePressureRuleSnapshot.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/domain/autotune/AutoTuneRoutePressureSnapshot.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/domain/autotune/AutoTuneSnapshot.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/domain/autotune/AutoTuneStationRuntimeItem.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/domain/autotune/AutoTuneTargetStationRoutePressureItem.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/domain/autotune/AutoTuneTaskRouteSampleItem.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/mcp/tool/AutoTuneMcpTools.java 88 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/service/AutoTuneAgentService.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/service/AutoTuneControlModeService.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/service/RoutePressureSnapshotService.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/service/impl/AutoTuneAgentServiceImpl.java 122 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/service/impl/AutoTuneApplyServiceImpl.java 186 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/service/impl/AutoTuneControlModeServiceImpl.java 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/service/impl/AutoTuneCoordinatorServiceImpl.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/service/impl/AutoTuneSnapshotServiceImpl.java 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/service/impl/RoutePressureSnapshotServiceImpl.java 960 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/utils/AiPromptUtils.java 72 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/utils/AutoTuneWriteBehaviorUtils.java 553 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/sql/20260428_ai_auto_tune_consolidated.sql 299 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/sql/20260428_extend_sys_llm_route_protocol.sql 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/ai/auto_tune.html 793 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/test/java/com/zy/ai/service/AutoTuneApplyServiceImplTest.java 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/test/java/com/zy/ai/service/AutoTuneCoordinatorServiceImplTest.java 281 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/test/java/com/zy/ai/service/impl/AutoTuneSnapshotServiceImplTest.java 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/test/java/com/zy/ai/service/impl/RoutePressureSnapshotServiceImplTest.java 507 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/test/java/com/zy/ai/utils/AutoTuneWriteBehaviorUtilsTest.java 258 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/controller/AutoTuneConsoleController.java
@@ -14,6 +14,7 @@
import com.zy.ai.service.AutoTuneApplyService;
import com.zy.ai.service.AutoTuneCoordinatorService;
import com.zy.ai.service.AutoTuneSnapshotService;
import com.zy.ai.utils.AutoTuneWriteBehaviorUtils;
import com.zy.common.web.BaseController;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
@@ -118,9 +119,13 @@
        item.put("completionTokens", job.getCompletionTokens());
        item.put("totalTokens", job.getTotalTokens());
        List<AiAutoTuneMcpCall> mcpCalls = listMcpCalls(job.getId());
        List<Map<String, Object>> mcpCallSummaries = toMcpCallSummaries(mcpCalls);
        List<Map<String, Object>> changeSummaries = listChangeSummaries(job, mcpCalls);
        AutoTuneWriteBehaviorUtils.addWriteBehavior(item,
                AutoTuneWriteBehaviorUtils.resolveJobWriteBehavior(job, mcpCallSummaries, changeSummaries));
        item.put("mcpCallCount", mcpCalls.size());
        item.put("mcpCalls", toMcpCallSummaries(mcpCalls));
        item.put("changes", listChangeSummaries(mcpCalls));
        item.put("mcpCalls", mcpCallSummaries);
        item.put("changes", changeSummaries);
        return item;
    }
@@ -161,6 +166,8 @@
        item.put("responseJson", mcpCall.getResponseJson());
        item.put("errorMessage", mcpCall.getErrorMessage());
        item.put("createTime", mcpCall.getCreateTime());
        AutoTuneWriteBehaviorUtils.addWriteBehavior(item,
                AutoTuneWriteBehaviorUtils.resolveMcpWriteBehavior(mcpCall));
        return item;
    }
@@ -171,9 +178,10 @@
        return value == 1;
    }
    private List<Map<String, Object>> listChangeSummaries(List<AiAutoTuneMcpCall> mcpCalls) {
    private List<Map<String, Object>> listChangeSummaries(AiAutoTuneJob job, List<AiAutoTuneMcpCall> mcpCalls) {
        List<Map<String, Object>> result = new ArrayList<>();
        List<Long> applyJobIds = collectApplyJobIds(mcpCalls);
        Map<Long, String> ownerTriggerTypes = collectChangeOwnerTriggerTypes(job, mcpCalls);
        List<Long> applyJobIds = new ArrayList<>(ownerTriggerTypes.keySet());
        if (applyJobIds.isEmpty()) {
            return result;
        }
@@ -185,28 +193,41 @@
            return result;
        }
        for (AiAutoTuneChange change : changes) {
            result.add(toChangeSummary(change));
            result.add(toChangeSummary(change, ownerTriggerTypes.get(change.getJobId())));
        }
        return result;
    }
    private List<Long> collectApplyJobIds(List<AiAutoTuneMcpCall> mcpCalls) {
        List<Long> result = new ArrayList<>();
    private Map<Long, String> collectChangeOwnerTriggerTypes(AiAutoTuneJob job, List<AiAutoTuneMcpCall> mcpCalls) {
        LinkedHashMap<Long, String> result = new LinkedHashMap<>();
        if (job != null && job.getId() != null) {
            result.put(job.getId(), job.getTriggerType());
        }
        if (mcpCalls == null || mcpCalls.isEmpty()) {
            return result;
        }
        for (AiAutoTuneMcpCall mcpCall : mcpCalls) {
            Long applyJobId = mcpCall.getApplyJobId();
            if (applyJobId != null && !result.contains(applyJobId)) {
                result.add(applyJobId);
            if (applyJobId == null || result.containsKey(applyJobId)) {
                continue;
            }
            result.put(applyJobId, resolveMcpApplyJobTriggerType(mcpCall));
        }
        return result;
    }
    private Map<String, Object> toChangeSummary(AiAutoTuneChange change) {
    private String resolveMcpApplyJobTriggerType(AiAutoTuneMcpCall mcpCall) {
        if (mcpCall == null || mcpCall.getToolName() == null) {
            return null;
        }
        String toolName = mcpCall.getToolName().toLowerCase();
        return toolName.contains("revert_last_auto_tune_job") || toolName.contains("rollback") ? "rollback" : null;
    }
    private Map<String, Object> toChangeSummary(AiAutoTuneChange change, String ownerTriggerType) {
        LinkedHashMap<String, Object> item = new LinkedHashMap<>();
        item.put("id", change.getId());
        item.put("jobId", change.getJobId());
        item.put("targetType", change.getTargetType());
        item.put("targetId", change.getTargetId());
        item.put("targetKey", change.getTargetKey());
@@ -217,6 +238,8 @@
        item.put("rejectReason", change.getRejectReason());
        item.put("cooldownExpireTime", change.getCooldownExpireTime());
        item.put("createTime", change.getCreateTime());
        AutoTuneWriteBehaviorUtils.addWriteBehavior(item,
                AutoTuneWriteBehaviorUtils.resolveChangeWriteBehavior(change, ownerTriggerType));
        return item;
    }
}
src/main/java/com/zy/ai/domain/autotune/AutoTuneApplyResult.java
@@ -14,6 +14,10 @@
    private Boolean success;
    private Boolean analysisOnly;
    private Boolean noApply;
    private Long jobId;
    private String summary;
src/main/java/com/zy/ai/domain/autotune/AutoTuneControlModeSnapshot.java
New file
@@ -0,0 +1,20 @@
package com.zy.ai.domain.autotune;
import lombok.Data;
import java.io.Serializable;
@Data
public class AutoTuneControlModeSnapshot implements Serializable {
    private static final long serialVersionUID = 1L;
    private Boolean enabled;
    private Boolean analysisOnly;
    private Boolean allowApply;
    private String modeCode;
    private String modeLabel;
}
src/main/java/com/zy/ai/domain/autotune/AutoTuneHotPathSegmentItem.java
New file
@@ -0,0 +1,25 @@
package com.zy.ai.domain.autotune;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
@Data
public class AutoTuneHotPathSegmentItem implements Serializable {
    private static final long serialVersionUID = 1L;
    private String segmentKey;
    private List<Integer> stationIds;
    private Integer passTaskCount;
    private Integer loadingCount;
    private Integer taskHoldingCount;
    private Integer runBlockCount;
    private Integer nonAutoingCount;
    private String pressureLevel;
    private Integer pressureScore;
    private Map<String, Object> pressureFactors;
    private List<Integer> relatedTargetStations;
    private List<Integer> sampleWrkNos;
}
src/main/java/com/zy/ai/domain/autotune/AutoTuneRoutePressureRuleSnapshot.java
New file
@@ -0,0 +1,19 @@
package com.zy.ai.domain.autotune;
import lombok.Data;
import java.io.Serializable;
@Data
public class AutoTuneRoutePressureRuleSnapshot implements Serializable {
    private static final long serialVersionUID = 1L;
    private Integer segmentWindowSize;
    private Integer mediumPercent;
    private Integer highPercent;
    private Integer passWeightPercent;
    private Integer occupiedWeightPercent;
    private Integer blockedWeightPercent;
    private Integer nonAutoingWeightPercent;
    private Integer runBlockWeightPercent;
}
src/main/java/com/zy/ai/domain/autotune/AutoTuneRoutePressureSnapshot.java
New file
@@ -0,0 +1,22 @@
package com.zy.ai.domain.autotune;
import lombok.Data;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
@Data
public class AutoTuneRoutePressureSnapshot implements Serializable {
    private static final long serialVersionUID = 1L;
    private Integer analyzedTaskCount = 0;
    private Integer tracePathCount = 0;
    private Integer estimatedPathCount = 0;
    private Integer pathErrorCount = 0;
    private String confidence;
    private AutoTuneRoutePressureRuleSnapshot routePressureRuleSnapshot;
    private List<AutoTuneTaskRouteSampleItem> taskRouteSamples = new ArrayList<>();
    private List<AutoTuneHotPathSegmentItem> hotPathSegments = new ArrayList<>();
    private List<AutoTuneTargetStationRoutePressureItem> targetStationRoutePressure = new ArrayList<>();
}
src/main/java/com/zy/ai/domain/autotune/AutoTuneSnapshot.java
@@ -23,5 +23,9 @@
    private List<AutoTuneRuleSnapshotItem> ruleSnapshot;
    private AutoTuneRoutePressureSnapshot routePressureSnapshot;
    private AutoTuneControlModeSnapshot controlModeSnapshot;
    private Date snapshotTime;
}
src/main/java/com/zy/ai/domain/autotune/AutoTuneStationRuntimeItem.java
@@ -16,5 +16,7 @@
    private Integer taskNo;
    private Integer runBlock;
    private String ioMode;
}
src/main/java/com/zy/ai/domain/autotune/AutoTuneTargetStationRoutePressureItem.java
New file
@@ -0,0 +1,26 @@
package com.zy.ai.domain.autotune;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
@Data
public class AutoTuneTargetStationRoutePressureItem implements Serializable {
    private static final long serialVersionUID = 1L;
    private Integer targetStationId;
    private Integer blockedTaskCount;
    private Integer routeTaskCount;
    private List<String> mainHotSegments;
    private String pressureLevel;
    private String recommendedDirection;
    private String heuristicDirection;
    private Integer pressureScore;
    private String confidence;
    private Map<String, Object> pressureFactors;
    private List<String> recommendedTargets;
    private String reason;
    private String evidenceText;
}
src/main/java/com/zy/ai/domain/autotune/AutoTuneTaskRouteSampleItem.java
New file
@@ -0,0 +1,22 @@
package com.zy.ai.domain.autotune;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
@Data
public class AutoTuneTaskRouteSampleItem implements Serializable {
    private static final long serialVersionUID = 1L;
    private Integer wrkNo;
    private Long wrkSts;
    private String batch;
    private Integer batchSeq;
    private Integer sourceStaNo;
    private Integer targetStaNo;
    private String pathSource;
    private List<Integer> pathStationIds;
    private Integer pathLength;
    private String pathError;
}
src/main/java/com/zy/ai/mcp/tool/AutoTuneMcpTools.java
@@ -15,6 +15,7 @@
import com.zy.ai.service.AiAutoTuneMcpCallService;
import com.zy.ai.service.AutoTuneApplyService;
import com.zy.ai.service.AutoTuneSnapshotService;
import com.zy.ai.utils.AutoTuneWriteBehaviorUtils;
import lombok.RequiredArgsConstructor;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
@@ -83,10 +84,6 @@
        if (dryRun == null) {
            throw new IllegalArgumentException("dryRun is required. Use dryRun=true first to create a preview token.");
        }
        String fingerprint = buildChangeFingerprint(changes);
        if (Boolean.FALSE.equals(dryRun)) {
            requireMatchingDryRunToken(dryRunToken, fingerprint);
        }
        AutoTuneApplyRequest request = new AutoTuneApplyRequest();
        request.setReason(reason);
@@ -94,8 +91,14 @@
        request.setTriggerType(triggerType);
        request.setDryRun(dryRun);
        request.setChanges(changes);
        String fingerprint = buildChangeFingerprint(changes);
        if (Boolean.FALSE.equals(dryRun)) {
            requireMatchingDryRunToken(dryRunToken, fingerprint);
        }
        AutoTuneApplyResult result = autoTuneApplyService.apply(request);
        if (Boolean.TRUE.equals(dryRun) && isSuccessful(result)) {
        if (Boolean.TRUE.equals(dryRun) && hasApplicableDryRunChanges(result)) {
            result.setDryRunToken(createDryRunToken(fingerprint));
        }
        return result;
@@ -119,9 +122,13 @@
        item.put("rejectCount", job.getRejectCount());
        item.put("errorMessage", job.getErrorMessage());
        List<AiAutoTuneMcpCall> mcpCalls = listMcpCalls(job.getId());
        List<Map<String, Object>> mcpCallSummaries = toMcpCallSummaries(mcpCalls);
        List<Map<String, Object>> changeSummaries = listChangeSummaries(job, mcpCalls);
        AutoTuneWriteBehaviorUtils.addWriteBehavior(item,
                AutoTuneWriteBehaviorUtils.resolveJobWriteBehavior(job, mcpCallSummaries, changeSummaries));
        item.put("mcpCallCount", mcpCalls.size());
        item.put("mcpCalls", toMcpCallSummaries(mcpCalls));
        item.put("changes", listChangeSummaries(mcpCalls));
        item.put("mcpCalls", mcpCallSummaries);
        item.put("changes", changeSummaries);
        return item;
    }
@@ -157,6 +164,8 @@
        item.put("successCount", mcpCall.getSuccessCount());
        item.put("rejectCount", mcpCall.getRejectCount());
        item.put("errorMessage", mcpCall.getErrorMessage());
        AutoTuneWriteBehaviorUtils.addWriteBehavior(item,
                AutoTuneWriteBehaviorUtils.resolveMcpWriteBehavior(mcpCall));
        return item;
    }
@@ -167,8 +176,9 @@
        return value == 1;
    }
    private List<Map<String, Object>> listChangeSummaries(List<AiAutoTuneMcpCall> mcpCalls) {
        List<Long> applyJobIds = collectApplyJobIds(mcpCalls);
    private List<Map<String, Object>> listChangeSummaries(AiAutoTuneJob job, List<AiAutoTuneMcpCall> mcpCalls) {
        Map<Long, String> ownerTriggerTypes = collectChangeOwnerTriggerTypes(job, mcpCalls);
        List<Long> applyJobIds = new ArrayList<>(ownerTriggerTypes.keySet());
        if (applyJobIds.isEmpty()) {
            return new ArrayList<>();
        }
@@ -182,27 +192,40 @@
        List<Map<String, Object>> result = new ArrayList<>();
        for (AiAutoTuneChange change : changes) {
            result.add(toChangeSummary(change));
            result.add(toChangeSummary(change, ownerTriggerTypes.get(change.getJobId())));
        }
        return result;
    }
    private List<Long> collectApplyJobIds(List<AiAutoTuneMcpCall> mcpCalls) {
        List<Long> result = new ArrayList<>();
    private Map<Long, String> collectChangeOwnerTriggerTypes(AiAutoTuneJob job, List<AiAutoTuneMcpCall> mcpCalls) {
        LinkedHashMap<Long, String> result = new LinkedHashMap<>();
        if (job != null && job.getId() != null) {
            result.put(job.getId(), job.getTriggerType());
        }
        if (mcpCalls == null || mcpCalls.isEmpty()) {
            return result;
        }
        for (AiAutoTuneMcpCall mcpCall : mcpCalls) {
            Long applyJobId = mcpCall.getApplyJobId();
            if (applyJobId != null && !result.contains(applyJobId)) {
                result.add(applyJobId);
            if (applyJobId == null || result.containsKey(applyJobId)) {
                continue;
            }
            result.put(applyJobId, resolveMcpApplyJobTriggerType(mcpCall));
        }
        return result;
    }
    private Map<String, Object> toChangeSummary(AiAutoTuneChange change) {
    private String resolveMcpApplyJobTriggerType(AiAutoTuneMcpCall mcpCall) {
        if (mcpCall == null || mcpCall.getToolName() == null) {
            return null;
        }
        String toolName = mcpCall.getToolName().toLowerCase(Locale.ROOT);
        return toolName.contains("revert_last_auto_tune_job") || toolName.contains("rollback") ? "rollback" : null;
    }
    private Map<String, Object> toChangeSummary(AiAutoTuneChange change, String ownerTriggerType) {
        LinkedHashMap<String, Object> item = new LinkedHashMap<>();
        item.put("jobId", change.getJobId());
        item.put("targetType", change.getTargetType());
        item.put("targetId", change.getTargetId());
        item.put("targetKey", change.getTargetKey());
@@ -213,6 +236,8 @@
        item.put("rejectReason", change.getRejectReason());
        item.put("cooldownExpireTime", change.getCooldownExpireTime());
        item.put("createTime", change.getCreateTime());
        AutoTuneWriteBehaviorUtils.addWriteBehavior(item,
                AutoTuneWriteBehaviorUtils.resolveChangeWriteBehavior(change, ownerTriggerType));
        return item;
    }
@@ -260,6 +285,18 @@
        return result != null && Boolean.TRUE.equals(result.getSuccess());
    }
    private boolean hasApplicableDryRunChanges(AutoTuneApplyResult result) {
        if (!isSuccessful(result) || result.getChanges() == null || result.getChanges().isEmpty()) {
            return false;
        }
        for (AiAutoTuneChange change : result.getChanges()) {
            if (change != null && "dry_run".equals(normalizeLower(change.getResultStatus()))) {
                return true;
            }
        }
        return false;
    }
    private String buildChangeFingerprint(List<AutoTuneChangeCommand> changes) {
        List<Map<String, String>> normalizedChanges = new ArrayList<>();
        if (changes != null) {
@@ -267,6 +304,7 @@
                normalizedChanges.add(toNormalizedChange(change));
            }
        }
        validateUniqueChangeTargets(normalizedChanges);
        normalizedChanges.sort(Comparator
                .comparing((Map<String, String> item) -> item.get("targetType"))
                .thenComparing(item -> item.get("targetId"))
@@ -275,6 +313,26 @@
        return JSON.toJSONString(normalizedChanges);
    }
    private void validateUniqueChangeTargets(List<Map<String, String>> normalizedChanges) {
        Map<String, Map<String, String>> uniqueTargets = new LinkedHashMap<>();
        for (Map<String, String> change : normalizedChanges) {
            String targetSignature = buildTargetSignature(change);
            if (uniqueTargets.containsKey(targetSignature)) {
                throw new IllegalArgumentException("Duplicate auto-tune change target in same request: "
                        + "targetType=" + change.get("targetType")
                        + ", targetId=" + change.get("targetId")
                        + ", targetKey=" + change.get("targetKey"));
            }
            uniqueTargets.put(targetSignature, change);
        }
    }
    private String buildTargetSignature(Map<String, String> change) {
        return change.get("targetType") + "\n"
                + change.get("targetId") + "\n"
                + change.get("targetKey");
    }
    private Map<String, String> toNormalizedChange(AutoTuneChangeCommand change) {
        LinkedHashMap<String, String> item = new LinkedHashMap<>();
        String targetType = normalizeLower(change == null ? null : change.getTargetType());
src/main/java/com/zy/ai/service/AutoTuneAgentService.java
@@ -31,6 +31,12 @@
        private Boolean maxRoundsReached;
        private Boolean analysisOnly;
        private Boolean allowApply;
        private String executionMode;
        private Boolean actualApplyCalled;
        private Boolean rollbackCalled;
src/main/java/com/zy/ai/service/AutoTuneControlModeService.java
New file
@@ -0,0 +1,13 @@
package com.zy.ai.service;
import com.zy.ai.domain.autotune.AutoTuneControlModeSnapshot;
public interface AutoTuneControlModeService {
    AutoTuneControlModeSnapshot currentMode();
    default boolean isAnalysisOnly() {
        AutoTuneControlModeSnapshot snapshot = currentMode();
        return snapshot == null || Boolean.TRUE.equals(snapshot.getAnalysisOnly());
    }
}
src/main/java/com/zy/ai/service/RoutePressureSnapshotService.java
New file
@@ -0,0 +1,15 @@
package com.zy.ai.service;
import com.zy.ai.domain.autotune.AutoTuneRoutePressureSnapshot;
import com.zy.ai.domain.autotune.AutoTuneStationRuntimeItem;
import com.zy.ai.domain.autotune.AutoTuneTaskSnapshot;
import com.zy.asrs.entity.WrkMast;
import java.util.List;
public interface RoutePressureSnapshotService {
    AutoTuneRoutePressureSnapshot buildSnapshot(List<WrkMast> activeTasks,
                                                AutoTuneTaskSnapshot taskSnapshot,
                                                List<AutoTuneStationRuntimeItem> stationRuntimeSnapshot);
}
src/main/java/com/zy/ai/service/impl/AutoTuneAgentServiceImpl.java
@@ -3,6 +3,7 @@
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.zy.ai.domain.autotune.AutoTuneApplyResult;
import com.zy.ai.domain.autotune.AutoTuneControlModeSnapshot;
import com.zy.ai.domain.autotune.AutoTuneTriggerType;
import com.zy.ai.entity.AiPromptTemplate;
import com.zy.ai.entity.ChatCompletionRequest;
@@ -11,7 +12,9 @@
import com.zy.ai.mcp.service.SpringAiMcpToolManager;
import com.zy.ai.service.AiPromptTemplateService;
import com.zy.ai.service.AutoTuneAgentService;
import com.zy.ai.service.AutoTuneControlModeService;
import com.zy.ai.service.LlmChatService;
import com.zy.ai.utils.AiPromptUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -46,10 +49,12 @@
    private final LlmChatService llmChatService;
    private final SpringAiMcpToolManager mcpToolManager;
    private final AiPromptTemplateService aiPromptTemplateService;
    private final AutoTuneControlModeService autoTuneControlModeService;
    @Override
    public AutoTuneAgentResult runAutoTune(String triggerType) {
        String normalizedTriggerType = normalizeTriggerType(triggerType);
        AutoTuneControlModeSnapshot controlMode = buildControlModeSnapshot();
        UsageCounter usageCounter = new UsageCounter();
        RunState runState = new RunState();
        boolean maxRoundsReached = false;
@@ -62,7 +67,7 @@
            }
            AiPromptTemplate promptTemplate = aiPromptTemplateService.resolvePublished(AiPromptScene.AUTO_TUNE_DISPATCH.getCode());
            List<ChatCompletionRequest.Message> messages = buildMessages(promptTemplate, normalizedTriggerType);
            List<ChatCompletionRequest.Message> messages = buildMessages(promptTemplate, normalizedTriggerType, controlMode);
            for (int round = 0; round < MAX_TOOL_ROUNDS; round++) {
                ChatCompletionResponse response = llmChatService.chatCompletionOrThrow(messages, TEMPERATURE, MAX_TOKENS, tools);
@@ -74,7 +79,7 @@
                List<ChatCompletionRequest.ToolCall> toolCalls = assistantMessage.getTool_calls();
                if (toolCalls == null || toolCalls.isEmpty()) {
                    return buildResult(runState.isSuccessful(), normalizedTriggerType, summaryBuffer, runState,
                            usageCounter, false);
                            usageCounter, false, controlMode);
                }
                for (ChatCompletionRequest.ToolCall toolCall : toolCalls) {
@@ -83,16 +88,20 @@
                }
            }
            maxRoundsReached = true;
            return buildResult(false, normalizedTriggerType, summaryBuffer, runState, usageCounter, maxRoundsReached);
            return buildResult(false, normalizedTriggerType, summaryBuffer, runState, usageCounter, maxRoundsReached,
                    controlMode);
        } catch (Exception exception) {
            log.error("Auto tune agent stopped with error", exception);
            appendSummary(summaryBuffer, "自动调参 Agent 执行异常: " + exception.getMessage());
            runState.markToolError();
            return buildResult(false, normalizedTriggerType, summaryBuffer, runState, usageCounter, maxRoundsReached);
            return buildResult(false, normalizedTriggerType, summaryBuffer, runState, usageCounter, maxRoundsReached,
                    controlMode);
        }
    }
    private List<ChatCompletionRequest.Message> buildMessages(AiPromptTemplate promptTemplate, String triggerType) {
    private List<ChatCompletionRequest.Message> buildMessages(AiPromptTemplate promptTemplate,
                                                              String triggerType,
                                                              AutoTuneControlModeSnapshot controlMode) {
        List<ChatCompletionRequest.Message> messages = new ArrayList<>();
        ChatCompletionRequest.Message systemMessage = new ChatCompletionRequest.Message();
@@ -102,15 +111,7 @@
        ChatCompletionRequest.Message userMessage = new ChatCompletionRequest.Message();
        userMessage.setRole("user");
        userMessage.setContent("请执行一次后台 WCS 自动调参。triggerType=" + triggerType
                + "。必须先调用 wcs_local_dispatch_get_auto_tune_snapshot 获取事实;如需提交变更,"
                + "必须先 dry-run,再根据 dry-run 结果决定是否实际应用;实际应用时必须带上 dry-run 返回的 dryRunToken。"
                + "禁止在没有收到 wcs_local_dispatch_apply_auto_tune_changes 或 wcs_local_dispatch_revert_last_auto_tune_job 工具返回结果时声称已试算、已应用或已回滚。"
                + "必须检查 taskSnapshot.stationLimitBlockedTasks 和 taskSnapshot.outboundTaskSamples 中的 systemMsg、wrkSts、batchSeq,判断是否存在被上限挡住的早序出库任务。"
                + "所有提交给 wcs_local_dispatch_apply_auto_tune_changes 的 changes 都必须先匹配 snapshot.ruleSnapshot 中对应 targetType/targetKey 的规则。"
                + "每个参数都必须满足 minValue、maxValue 或 dynamicMaxValue、maxStep、cooldownMinutes 和规则 note;找不到规则或无法证明动态上限时禁止提交。"
                + "dry-run 返回 success=false 或 rejectCount>0 时,必须停止实际应用并说明拒绝原因,或重新提交完全合法的 dry-run。"
                + "不要输出自由格式 JSON 供外层解析。");
        userMessage.setContent(AiPromptUtils.buildAutoTuneRuntimeGuard(triggerType, controlMode));
        messages.add(userMessage);
        return messages;
    }
@@ -126,7 +127,9 @@
        return message;
    }
    private Object callMountedTool(ChatCompletionRequest.ToolCall toolCall, RunState runState, String triggerType) {
    private Object callMountedTool(ChatCompletionRequest.ToolCall toolCall,
                                   RunState runState,
                                   String triggerType) {
        String toolName = resolveToolName(toolCall);
        if (!ALLOWED_TOOL_NAMES.contains(toolName)) {
            throw new IllegalArgumentException("Disallowed auto-tune MCP tool: " + toolName);
@@ -138,11 +141,13 @@
            Object output = mcpToolManager.callTool(toolName, arguments);
            runState.markToolSuccess(toolName);
            recordMutationResult(toolName, arguments, output, runState);
            if (TOOL_APPLY_CHANGES.equals(toolName) && isRejectedApplyResult(output)) {
                runState.markApplyRejected();
                Object wrappedOutput = withRejectedApplyInstruction(output);
                runState.addMcpCall(buildMcpCall(toolName, arguments, wrappedOutput, startTimeMillis, null));
                return wrappedOutput;
            if (isRejectedApplyResult(output)) {
                runState.markApplyRejected(resolveApplyError(output));
                if (TOOL_APPLY_CHANGES.equals(toolName)) {
                    Object wrappedOutput = withRejectedApplyInstruction(output);
                    runState.addMcpCall(buildMcpCall(toolName, arguments, wrappedOutput, startTimeMillis, null));
                    return wrappedOutput;
                }
            }
            runState.addMcpCall(buildMcpCall(toolName, arguments, output, startTimeMillis, null));
            return output;
@@ -279,8 +284,10 @@
        if (TOOL_APPLY_CHANGES.equals(toolName)) {
            boolean dryRun = Boolean.TRUE.equals(arguments.getBoolean("dryRun"));
            if (!dryRun) {
                runState.markActualApply();
                runState.addCounts(output);
                if (outputHasSuccessfulChange(output)) {
                    runState.markActualApply();
                }
            } else if (isRejectedApplyResult(output)) {
                runState.addCounts(output);
            }
@@ -290,6 +297,46 @@
            runState.markRollback();
            runState.addCounts(output);
        }
    }
    private boolean outputHasSuccessfulChange(Object output) {
        if (output instanceof AutoTuneApplyResult) {
            AutoTuneApplyResult result = (AutoTuneApplyResult) output;
            if (result.getChanges() != null) {
                return hasSuccessfulChange(result.getChanges());
            }
            return safeCount(result.getSuccessCount()) > 0;
        }
        if (output instanceof Map<?, ?>) {
            Map<?, ?> result = (Map<?, ?>) output;
            if (!isApplyResultShape(result)) {
                return false;
            }
            if (result.containsKey("changes")) {
                return hasSuccessfulChange(result.get("changes"));
            }
            return safeCount(result.get("successCount")) > 0;
        }
        return false;
    }
    private boolean hasSuccessfulChange(Object changes) {
        if (!(changes instanceof List<?>)) {
            return false;
        }
        for (Object change : (List<?>) changes) {
            String resultStatus = null;
            if (change instanceof com.zy.ai.entity.AiAutoTuneChange) {
                resultStatus = ((com.zy.ai.entity.AiAutoTuneChange) change).getResultStatus();
            } else if (change instanceof Map<?, ?>) {
                Object status = ((Map<?, ?>) change).get("resultStatus");
                resultStatus = status == null ? null : String.valueOf(status);
            }
            if (resultStatus != null && "success".equalsIgnoreCase(resultStatus.trim())) {
                return true;
            }
        }
        return false;
    }
    private boolean isRejectedApplyResult(Object output) {
@@ -403,10 +450,14 @@
                                            StringBuilder summaryBuffer,
                                            RunState runState,
                                            UsageCounter usageCounter,
                                            boolean maxRoundsReached) {
                                            boolean maxRoundsReached,
                                            AutoTuneControlModeSnapshot controlMode) {
        AutoTuneAgentResult result = new AutoTuneAgentResult();
        result.setSuccess(success);
        result.setTriggerType(triggerType);
        result.setAnalysisOnly(controlMode.getAnalysisOnly());
        result.setAllowApply(controlMode.getAllowApply());
        result.setExecutionMode(controlMode.getModeCode());
        result.setToolCallCount(runState.getToolCallCount());
        result.setLlmCallCount(usageCounter.getLlmCallCount());
        result.setPromptTokens(usageCounter.getPromptTokens());
@@ -430,6 +481,9 @@
        }
        if (runState.hasApplyRejected()) {
            summary = summary + "\n自动调参 Agent 存在被拒绝的 dry-run/apply 结果,未视为成功调参。";
            if (!isBlank(runState.getFirstRejectReason())) {
                summary = summary + "拒绝原因: " + runState.getFirstRejectReason();
            }
        }
        if (success && !runState.hasActualMutation()) {
            summary = "自动调参 Agent 未调用实际应用或回滚工具,未修改运行参数。"
@@ -438,8 +492,20 @@
        if (maxRoundsReached) {
            summary = summary + "\n自动调参 Agent 达到最大工具调用轮次,已停止。";
        }
        summary = buildModeSummary(controlMode) + (summary.isEmpty() ? "" : "\n" + summary);
        result.setSummary(summary);
        return result;
    }
    private String buildModeSummary(AutoTuneControlModeSnapshot controlMode) {
        return "执行模式: " + controlMode.getModeCode()
                + ",analysisOnly=" + controlMode.getAnalysisOnly()
                + ",allowApply=" + controlMode.getAllowApply()
                + ",modeLabel=" + controlMode.getModeLabel();
    }
    private AutoTuneControlModeSnapshot buildControlModeSnapshot() {
        return autoTuneControlModeService.currentMode();
    }
    private List<Object> filterAllowedTools(List<Object> tools) {
@@ -482,7 +548,7 @@
        return isBlank(triggerType) ? "agent" : triggerType.trim();
    }
    private boolean isBlank(String value) {
    private static boolean isBlank(String value) {
        return value == null || value.trim().isEmpty();
    }
@@ -524,6 +590,7 @@
        private boolean snapshotCalled;
        private boolean toolError;
        private boolean applyRejected;
        private String firstRejectReason;
        private boolean actualApplyCalled;
        private boolean rollbackCalled;
        private int successCount;
@@ -541,8 +608,11 @@
            toolError = true;
        }
        void markApplyRejected() {
        void markApplyRejected(String rejectReason) {
            applyRejected = true;
            if (isBlank(firstRejectReason) && !isBlank(rejectReason)) {
                firstRejectReason = rejectReason;
            }
        }
        void markActualApply() {
@@ -599,6 +669,10 @@
            return applyRejected;
        }
        String getFirstRejectReason() {
            return firstRejectReason;
        }
        boolean isActualApplyCalled() {
            return actualApplyCalled;
        }
src/main/java/com/zy/ai/service/impl/AutoTuneApplyServiceImpl.java
@@ -5,6 +5,7 @@
import com.zy.ai.domain.autotune.AutoTuneApplyRequest;
import com.zy.ai.domain.autotune.AutoTuneApplyResult;
import com.zy.ai.domain.autotune.AutoTuneChangeCommand;
import com.zy.ai.domain.autotune.AutoTuneControlModeSnapshot;
import com.zy.ai.domain.autotune.AutoTuneJobStatus;
import com.zy.ai.domain.autotune.AutoTuneRuleDefinition;
import com.zy.ai.domain.autotune.AutoTuneTargetType;
@@ -14,6 +15,7 @@
import com.zy.ai.service.AiAutoTuneChangeService;
import com.zy.ai.service.AiAutoTuneJobService;
import com.zy.ai.service.AutoTuneApplyService;
import com.zy.ai.service.AutoTuneControlModeService;
import com.zy.asrs.entity.BasCrnp;
import com.zy.asrs.entity.BasDualCrnp;
import com.zy.asrs.entity.BasStation;
@@ -50,6 +52,7 @@
    private static final String PROMPT_SCENE_CODE = "auto_tune_apply";
    private static final long APPLY_LOCK_SECONDS = 120L;
    private static final String APPLY_LOCK_BUSY_REASON = "申请调参锁失败,锁不可用,可能已有任务或 Redis 异常";
    private static final String ANALYSIS_ONLY_REJECT_REASON = "仅分析模式禁止实际应用/回滚,未修改运行参数";
    private static final List<Long> FINAL_WRK_STS_LIST = Arrays.asList(
            WrkStsType.COMPLETE_INBOUND.sts,
            WrkStsType.SETTLE_INBOUND.sts,
@@ -66,6 +69,8 @@
    @Autowired
    private ConfigService configService;
    @Autowired
    private AutoTuneControlModeService autoTuneControlModeService;
    @Autowired
    private BasStationService basStationService;
    @Autowired
    private BasCrnpService basCrnpService;
@@ -81,17 +86,24 @@
    @Override
    public AutoTuneApplyResult apply(AutoTuneApplyRequest request) {
        AutoTuneApplyRequest safeRequest = request == null ? new AutoTuneApplyRequest() : request;
        AutoTuneControlModeSnapshot controlMode = currentControlModeSnapshot();
        boolean dryRun = Boolean.TRUE.equals(safeRequest.getDryRun());
        Date now = new Date();
        AiAutoTuneJob job = createJob(safeRequest, dryRun, now);
        if (dryRun) {
            return applyDryRun(safeRequest, job, now);
            return applyDryRun(safeRequest, job, now, controlMode);
        }
        return applyRealWithLock(safeRequest, job, now);
        if (isAnalysisOnly(controlMode)) {
            return rejectRealApplyForAnalysisOnly(safeRequest, job, now, controlMode);
        }
        return applyRealWithLock(safeRequest, job, now, controlMode);
    }
    private AutoTuneApplyResult applyDryRun(AutoTuneApplyRequest request, AiAutoTuneJob job, Date now) {
    private AutoTuneApplyResult applyDryRun(AutoTuneApplyRequest request,
                                            AiAutoTuneJob job,
                                            Date now,
                                            AutoTuneControlModeSnapshot controlMode) {
        List<ValidatedChange> validatedChanges = validateChanges(request, true, now);
        ApplyPersistenceResult persistenceResult = persistApplyResultInTransaction(
                job,
@@ -101,10 +113,13 @@
                now,
                false
        );
        return buildResult(job, persistenceResult.getAuditChanges(), true);
        return buildResult(job, persistenceResult.getAuditChanges(), true, controlMode);
    }
    private AutoTuneApplyResult applyRealWithLock(AutoTuneApplyRequest request, AiAutoTuneJob job, Date now) {
    private AutoTuneApplyResult applyRealWithLock(AutoTuneApplyRequest request,
                                                  AiAutoTuneJob job,
                                                  Date now,
                                                  AutoTuneControlModeSnapshot controlMode) {
        if (request.getChanges() == null || request.getChanges().isEmpty()) {
            ApplyPersistenceResult persistenceResult = persistApplyResultInTransaction(
                    job,
@@ -114,13 +129,13 @@
                    now,
                    false
            );
            return buildResult(job, persistenceResult.getAuditChanges(), false);
            return buildResult(job, persistenceResult.getAuditChanges(), false, controlMode);
        }
        String lockKey = RedisKeyType.AI_AUTO_TUNE_APPLY_LOCK.key;
        String lockToken = UUID.randomUUID().toString();
        if (!redisUtil.trySetStringIfAbsent(lockKey, lockToken, APPLY_LOCK_SECONDS)) {
            return rejectRealApplyForUnavailableLock(request, job, now, lockKey);
            return rejectRealApplyForUnavailableLock(request, job, now, lockKey, controlMode);
        }
        try {
@@ -138,7 +153,7 @@
                        now,
                        false
                );
                return buildResult(job, persistenceResult.getAuditChanges(), false);
                return buildResult(job, persistenceResult.getAuditChanges(), false, controlMode);
            }
            try {
@@ -151,7 +166,7 @@
                        true
                );
                refreshSystemConfigCacheSafely(persistenceResult);
                return buildResult(job, persistenceResult.getAuditChanges(), false);
                return buildResult(job, persistenceResult.getAuditChanges(), false, controlMode);
            } catch (RuntimeException exception) {
                markWriteFailure(validatedChanges, exception);
                Date failureNow = new Date();
@@ -164,7 +179,7 @@
                        failureNow,
                        false
                );
                return buildResult(failureJob, persistenceResult.getAuditChanges(), false);
                return buildResult(failureJob, persistenceResult.getAuditChanges(), false, controlMode);
            }
        } finally {
            redisUtil.compareAndDelete(lockKey, lockToken);
@@ -174,7 +189,8 @@
    private AutoTuneApplyResult rejectRealApplyForUnavailableLock(AutoTuneApplyRequest request,
                                                                  AiAutoTuneJob job,
                                                                  Date now,
                                                                  String lockKey) {
                                                                  String lockKey,
                                                                  AutoTuneControlModeSnapshot controlMode) {
        boolean lockKeyExists = redisUtil.hasKey(lockKey);
        LOGGER.warn("申请AI自动调参 apply 锁失败,lockKey={}, lockKeyExists={}", lockKey, lockKeyExists);
        List<ValidatedChange> validatedChanges = buildLockBusyChanges(request);
@@ -186,7 +202,18 @@
                now,
                false
        );
        return buildResult(job, persistenceResult.getAuditChanges(), false);
        return buildResult(job, persistenceResult.getAuditChanges(), false, controlMode);
    }
    private AutoTuneApplyResult rejectRealApplyForAnalysisOnly(AutoTuneApplyRequest request,
                                                               AiAutoTuneJob job,
                                                               Date now,
                                                               AutoTuneControlModeSnapshot controlMode) {
        ApplyPersistenceResult persistenceResult = persistAnalysisOnlyApplyRejectionInTransaction(job, request, now);
        AutoTuneApplyResult result = buildResult(job, persistenceResult.getAuditChanges(), false, controlMode);
        result.setAnalysisOnly(true);
        result.setNoApply(true);
        return result;
    }
    private List<ValidatedChange> buildLockBusyChanges(AutoTuneApplyRequest request) {
@@ -204,20 +231,24 @@
    @Override
    public AutoTuneApplyResult rollbackLastSuccessfulJob(String reason) {
        AutoTuneControlModeSnapshot controlMode = currentControlModeSnapshot();
        Date now = new Date();
        AiAutoTuneJob rollbackJob = createRollbackJob(reason, now);
        if (isAnalysisOnly(controlMode)) {
            return rejectRollbackForAnalysisOnly(rollbackJob, now, controlMode);
        }
        String lockKey = RedisKeyType.AI_AUTO_TUNE_APPLY_LOCK.key;
        String lockToken = UUID.randomUUID().toString();
        if (!redisUtil.trySetStringIfAbsent(lockKey, lockToken, APPLY_LOCK_SECONDS)) {
            return rejectRollbackForUnavailableLock(reason, now, lockKey);
            return rejectRollbackForUnavailableLock(reason, now, lockKey, controlMode);
        }
        try {
            List<AiAutoTuneChange> sourceChanges = findLatestSuccessfulChanges();
            if (sourceChanges.isEmpty()) {
                persistNoRollbackSourceJobInTransaction(rollbackJob, now);
                return buildResult(rollbackJob, new ArrayList<>(), false);
                return buildResult(rollbackJob, new ArrayList<>(), false, controlMode);
            }
            try {
                RollbackPersistenceResult persistenceResult = persistRollbackResultInTransaction(
@@ -226,7 +257,7 @@
                        now
                );
                refreshRollbackConfigCacheSafely(persistenceResult);
                return buildResult(rollbackJob, persistenceResult.getRollbackChanges(), false);
                return buildResult(rollbackJob, persistenceResult.getRollbackChanges(), false, controlMode);
            } catch (RuntimeException exception) {
                Date failureNow = new Date();
                AiAutoTuneJob failureJob = createRollbackJob(reason, failureNow);
@@ -236,7 +267,7 @@
                        exception,
                        failureNow
                );
                return buildResult(failureJob, persistenceResult.getRollbackChanges(), false);
                return buildResult(failureJob, persistenceResult.getRollbackChanges(), false, controlMode);
            }
        } finally {
            redisUtil.compareAndDelete(lockKey, lockToken);
@@ -245,7 +276,8 @@
    private AutoTuneApplyResult rejectRollbackForUnavailableLock(String reason,
                                                                 Date now,
                                                                 String lockKey) {
                                                                 String lockKey,
                                                                 AutoTuneControlModeSnapshot controlMode) {
        boolean lockKeyExists = redisUtil.hasKey(lockKey);
        LOGGER.warn("申请AI自动调参 rollback 锁失败,lockKey={}, lockKeyExists={}", lockKey, lockKeyExists);
        AiAutoTuneJob rollbackJob = createRollbackJob(reason, now);
@@ -253,7 +285,20 @@
                rollbackJob,
                now
        );
        return buildResult(rollbackJob, persistenceResult.getRollbackChanges(), false);
        return buildResult(rollbackJob, persistenceResult.getRollbackChanges(), false, controlMode);
    }
    private AutoTuneApplyResult rejectRollbackForAnalysisOnly(AiAutoTuneJob rollbackJob,
                                                              Date now,
                                                              AutoTuneControlModeSnapshot controlMode) {
        RollbackPersistenceResult persistenceResult = persistAnalysisOnlyRollbackRejectionInTransaction(
                rollbackJob,
                now
        );
        AutoTuneApplyResult result = buildResult(rollbackJob, persistenceResult.getRollbackChanges(), false, controlMode);
        result.setAnalysisOnly(true);
        result.setNoApply(true);
        return result;
    }
    private List<ValidatedChange> validateChanges(AutoTuneApplyRequest request, boolean dryRun, Date now) {
@@ -555,6 +600,33 @@
        });
    }
    private ApplyPersistenceResult persistAnalysisOnlyApplyRejectionInTransaction(AiAutoTuneJob job,
                                                                                  AutoTuneApplyRequest request,
                                                                                  Date now) {
        TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
        return transactionTemplate.execute(status -> {
            saveJob(job);
            List<AiAutoTuneChange> auditChanges = buildAnalysisOnlyApplyChanges(job.getId(), request, now);
            saveAuditChanges(auditChanges);
            finishAnalysisOnlyRejectedJob(job, auditChanges, now);
            updateJob(job);
            return new ApplyPersistenceResult(auditChanges, false);
        });
    }
    private RollbackPersistenceResult persistAnalysisOnlyRollbackRejectionInTransaction(AiAutoTuneJob rollbackJob,
                                                                                       Date now) {
        TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
        return transactionTemplate.execute(status -> {
            saveJob(rollbackJob);
            List<AiAutoTuneChange> rollbackChanges = buildAnalysisOnlyRollbackChanges(rollbackJob.getId(), now);
            saveAuditChanges(rollbackChanges);
            finishAnalysisOnlyRejectedJob(rollbackJob, rollbackChanges, now);
            updateJob(rollbackJob);
            return new RollbackPersistenceResult(rollbackChanges, false);
        });
    }
    private RollbackPersistenceResult rollbackChanges(Long rollbackJobId, List<AiAutoTuneChange> sourceChanges, Date now) {
        List<AiAutoTuneChange> rollbackChanges = new ArrayList<>();
        boolean refreshConfigCache = false;
@@ -587,6 +659,58 @@
        rollbackChange.setRejectReason(APPLY_LOCK_BUSY_REASON);
        rollbackChange.setCreateTime(now);
        return List.of(rollbackChange);
    }
    private List<AiAutoTuneChange> buildAnalysisOnlyApplyChanges(Long jobId,
                                                                 AutoTuneApplyRequest request,
                                                                 Date now) {
        List<AiAutoTuneChange> changes = new ArrayList<>();
        List<AutoTuneChangeCommand> commands = request == null ? null : request.getChanges();
        if (commands == null || commands.isEmpty()) {
            changes.add(buildAnalysisOnlyChange(jobId, "analysis_only", "", "apply", null, now));
            return changes;
        }
        for (AutoTuneChangeCommand command : commands) {
            changes.add(buildAnalysisOnlyChange(
                    jobId,
                    normalizeText(command == null ? null : command.getTargetType()),
                    normalizeText(command == null ? null : command.getTargetId()),
                    normalizeText(command == null ? null : command.getTargetKey()),
                    command == null ? null : command.getNewValue(),
                    now
            ));
        }
        return changes;
    }
    private List<AiAutoTuneChange> buildAnalysisOnlyRollbackChanges(Long jobId, Date now) {
        AiAutoTuneChange rollbackChange = buildAnalysisOnlyChange(
                jobId,
                "analysis_only",
                "",
                "rollback",
                null,
                now
        );
        return List.of(rollbackChange);
    }
    private AiAutoTuneChange buildAnalysisOnlyChange(Long jobId,
                                                     String targetType,
                                                     String targetId,
                                                     String targetKey,
                                                     String requestedValue,
                                                     Date now) {
        AiAutoTuneChange change = new AiAutoTuneChange();
        change.setJobId(jobId);
        change.setTargetType(targetType);
        change.setTargetId(targetId);
        change.setTargetKey(targetKey);
        change.setRequestedValue(requestedValue);
        change.setResultStatus(ChangeStatus.REJECTED.getCode());
        change.setRejectReason(ANALYSIS_ONLY_REJECT_REASON);
        change.setCreateTime(now);
        return change;
    }
    private void refreshRollbackConfigCacheSafely(RollbackPersistenceResult persistenceResult) {
@@ -749,6 +873,17 @@
        }
    }
    private void finishAnalysisOnlyRejectedJob(AiAutoTuneJob job, List<AiAutoTuneChange> changes, Date now) {
        int rejectCount = Math.max(1, countRejected(changes));
        job.setFinishTime(now);
        job.setSuccessCount(0);
        job.setRejectCount(rejectCount);
        job.setIntervalAfter(readIntervalMinutes());
        job.setStatus(AutoTuneJobStatus.REJECTED.getCode());
        job.setSummary(ANALYSIS_ONLY_REJECT_REASON);
        job.setErrorMessage(ANALYSIS_ONLY_REJECT_REASON);
    }
    private void finishRollbackJob(AiAutoTuneJob job, List<AiAutoTuneChange> changes, Date now) {
        int successCount = countAccepted(changes);
        int rejectCount = countRejected(changes);
@@ -876,11 +1011,16 @@
        return !AutoTuneTriggerType.ROLLBACK.getCode().equals(job.getTriggerType());
    }
    private AutoTuneApplyResult buildResult(AiAutoTuneJob job, List<AiAutoTuneChange> changes, boolean dryRun) {
    private AutoTuneApplyResult buildResult(AiAutoTuneJob job,
                                            List<AiAutoTuneChange> changes,
                                            boolean dryRun,
                                            AutoTuneControlModeSnapshot controlMode) {
        AutoTuneApplyResult result = new AutoTuneApplyResult();
        result.setDryRun(dryRun);
        result.setSuccess(AutoTuneJobStatus.SUCCESS.getCode().equals(job.getStatus())
                || AutoTuneJobStatus.NO_CHANGE.getCode().equals(job.getStatus()));
        result.setAnalysisOnly(isAnalysisOnly(controlMode));
        result.setNoApply(false);
        result.setJobId(job.getId());
        result.setSummary(job.getSummary());
        result.setSuccessCount(job.getSuccessCount());
@@ -984,6 +1124,14 @@
        }
    }
    private AutoTuneControlModeSnapshot currentControlModeSnapshot() {
        return autoTuneControlModeService.currentMode();
    }
    private boolean isAnalysisOnly(AutoTuneControlModeSnapshot controlMode) {
        return controlMode == null || Boolean.TRUE.equals(controlMode.getAnalysisOnly());
    }
    private String toText(Integer value) {
        return value == null ? null : String.valueOf(value);
    }
src/main/java/com/zy/ai/service/impl/AutoTuneControlModeServiceImpl.java
New file
@@ -0,0 +1,44 @@
package com.zy.ai.service.impl;
import com.zy.ai.domain.autotune.AutoTuneControlModeSnapshot;
import com.zy.ai.service.AutoTuneControlModeService;
import com.zy.system.service.ConfigService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service("autoTuneControlModeService")
@RequiredArgsConstructor
public class AutoTuneControlModeServiceImpl implements AutoTuneControlModeService {
    private static final String CONFIG_AUTO_TUNE_ENABLED = "aiAutoTuneEnabled";
    private static final String CONFIG_ANALYSIS_ONLY = "aiAutoTuneAnalysisOnly";
    private static final String MODE_ANALYSIS_ONLY = "analysis_only";
    private static final String MODE_APPLY_ENABLED = "apply_enabled";
    private final ConfigService configService;
    @Override
    public AutoTuneControlModeSnapshot currentMode() {
        boolean enabled = readConfigBoolean(CONFIG_AUTO_TUNE_ENABLED, false);
        boolean analysisOnly = readConfigBoolean(CONFIG_ANALYSIS_ONLY, true);
        AutoTuneControlModeSnapshot snapshot = new AutoTuneControlModeSnapshot();
        snapshot.setEnabled(enabled);
        snapshot.setAnalysisOnly(analysisOnly);
        snapshot.setAllowApply(!analysisOnly);
        snapshot.setModeCode(analysisOnly ? MODE_ANALYSIS_ONLY : MODE_APPLY_ENABLED);
        snapshot.setModeLabel(analysisOnly ? "仅分析模式" : "允许正式调参");
        return snapshot;
    }
    private boolean readConfigBoolean(String code, boolean defaultValue) {
        String value = configService.getConfigValue(code, defaultValue ? "Y" : "N");
        if (value == null || value.trim().isEmpty()) {
            return defaultValue;
        }
        String normalized = value.trim();
        return "Y".equalsIgnoreCase(normalized)
                || "true".equalsIgnoreCase(normalized)
                || "1".equals(normalized);
    }
}
src/main/java/com/zy/ai/service/impl/AutoTuneCoordinatorServiceImpl.java
@@ -2,6 +2,7 @@
import com.alibaba.fastjson.JSON;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.zy.ai.domain.autotune.AutoTuneControlModeSnapshot;
import com.zy.ai.domain.autotune.AutoTuneJobStatus;
import com.zy.ai.domain.autotune.AutoTuneTriggerType;
import com.zy.ai.entity.AiAutoTuneMcpCall;
@@ -10,6 +11,7 @@
import com.zy.ai.service.AiAutoTuneJobService;
import com.zy.ai.service.AiAutoTuneMcpCallService;
import com.zy.ai.service.AutoTuneAgentService;
import com.zy.ai.service.AutoTuneControlModeService;
import com.zy.ai.service.AutoTuneCoordinatorService;
import com.zy.asrs.entity.WrkMast;
import com.zy.asrs.service.WrkMastService;
@@ -36,9 +38,7 @@
@RequiredArgsConstructor
public class AutoTuneCoordinatorServiceImpl implements AutoTuneCoordinatorService {
    private static final String CONFIG_ENABLED = "aiAutoTuneEnabled";
    private static final String CONFIG_INTERVAL_MINUTES = "aiAutoTuneIntervalMinutes";
    private static final String DEFAULT_ENABLED = "N";
    private static final int DEFAULT_INTERVAL_MINUTES = 10;
    private static final int MIN_INTERVAL_MINUTES = 5;
    private static final int MAX_INTERVAL_MINUTES = 60;
@@ -54,6 +54,7 @@
    );
    private final ConfigService configService;
    private final AutoTuneControlModeService autoTuneControlModeService;
    private final WrkMastService wrkMastService;
    private final AiAutoTuneJobService aiAutoTuneJobService;
    private final AiAutoTuneMcpCallService aiAutoTuneMcpCallService;
@@ -117,14 +118,8 @@
    }
    private boolean isEnabled() {
        String enabled = configService.getConfigValue(CONFIG_ENABLED, DEFAULT_ENABLED);
        if (enabled == null) {
            return false;
        }
        String normalized = enabled.trim();
        return "Y".equalsIgnoreCase(normalized)
                || "true".equalsIgnoreCase(normalized)
                || "1".equals(normalized);
        AutoTuneControlModeSnapshot controlMode = autoTuneControlModeService.currentMode();
        return controlMode != null && Boolean.TRUE.equals(controlMode.getEnabled());
    }
    private int resolveIntervalMinutes() {
@@ -193,6 +188,7 @@
    }
    private AutoTuneAgentService.AutoTuneAgentResult failedAgentResult(String triggerType, Exception exception) {
        AutoTuneControlModeSnapshot controlMode = autoTuneControlModeService.currentMode();
        AutoTuneAgentService.AutoTuneAgentResult result = new AutoTuneAgentService.AutoTuneAgentResult();
        result.setSuccess(false);
        result.setTriggerType(triggerType);
@@ -203,6 +199,9 @@
        result.setCompletionTokens(0L);
        result.setTotalTokens(0L);
        result.setMaxRoundsReached(false);
        result.setAnalysisOnly(controlMode.getAnalysisOnly());
        result.setAllowApply(controlMode.getAllowApply());
        result.setExecutionMode(controlMode.getModeCode());
        result.setActualApplyCalled(false);
        result.setRollbackCalled(false);
        result.setSuccessCount(0);
@@ -395,6 +394,9 @@
    private Map<String, Object> buildRequestSummary(AutoTuneAgentService.AutoTuneAgentResult agentResult) {
        Map<String, Object> request = new LinkedHashMap<>();
        request.put("trigger", agentResult.getTriggerType());
        request.put("analysisOnly", agentResult.getAnalysisOnly());
        request.put("allowApply", agentResult.getAllowApply());
        request.put("executionMode", agentResult.getExecutionMode());
        return request;
    }
@@ -402,6 +404,9 @@
        Map<String, Object> response = new LinkedHashMap<>();
        response.put("success", agentResult.getSuccess());
        response.put("summary", agentResult.getSummary());
        response.put("analysisOnly", agentResult.getAnalysisOnly());
        response.put("allowApply", agentResult.getAllowApply());
        response.put("executionMode", agentResult.getExecutionMode());
        response.put("toolCallCount", agentResult.getToolCallCount());
        response.put("llmCallCount", agentResult.getLlmCallCount());
        response.put("promptTokens", agentResult.getPromptTokens());
src/main/java/com/zy/ai/service/impl/AutoTuneSnapshotServiceImpl.java
@@ -1,15 +1,19 @@
package com.zy.ai.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.zy.ai.domain.autotune.AutoTuneControlModeSnapshot;
import com.zy.ai.domain.autotune.AutoTuneTaskDetailItem;
import com.zy.ai.domain.autotune.AutoTuneParameterSnapshot;
import com.zy.ai.domain.autotune.AutoTuneRoutePressureSnapshot;
import com.zy.ai.domain.autotune.AutoTuneRuleDefinition;
import com.zy.ai.domain.autotune.AutoTuneRuleSnapshotItem;
import com.zy.ai.domain.autotune.AutoTuneSnapshot;
import com.zy.ai.domain.autotune.AutoTuneStationRuntimeItem;
import com.zy.ai.domain.autotune.AutoTuneTaskSnapshot;
import com.zy.ai.service.AutoTuneControlModeService;
import com.zy.ai.service.AutoTuneSnapshotService;
import com.zy.ai.service.FlowTopologySnapshotService;
import com.zy.ai.service.RoutePressureSnapshotService;
import com.zy.asrs.domain.vo.StationCycleCapacityVo;
import com.zy.asrs.domain.vo.StationCycleLoopVo;
import com.zy.asrs.entity.BasCrnp;
@@ -33,6 +37,8 @@
import com.zy.core.model.protocol.StationProtocol;
import com.zy.core.thread.StationThread;
import com.zy.system.service.ConfigService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -50,13 +56,14 @@
@Service("autoTuneSnapshotService")
public class AutoTuneSnapshotServiceImpl implements AutoTuneSnapshotService {
    private static final Logger LOGGER = LoggerFactory.getLogger(AutoTuneSnapshotServiceImpl.class);
    private static final int DEFAULT_CRN_OUT_BATCH_RUNNING_LIMIT = 5;
    private static final int DEFAULT_CONVEYOR_STATION_TASK_LIMIT = 30;
    private static final int DEFAULT_AI_AUTO_TUNE_INTERVAL_MINUTES = 10;
    private static final int OUTBOUND_TASK_SAMPLE_LIMIT = 50;
    private static final int STATION_LIMIT_BLOCKED_TASK_LIMIT = 50;
    private static final int SYSTEM_MESSAGE_LIMIT = 160;
    @Autowired
    private WrkMastService wrkMastService;
@@ -68,6 +75,12 @@
    @Autowired
    private FlowTopologySnapshotService flowTopologySnapshotService;
    @Autowired
    private RoutePressureSnapshotService routePressureSnapshotService;
    @Autowired
    private AutoTuneControlModeService autoTuneControlModeService;
    @Autowired
    private ConfigService configService;
@@ -86,17 +99,30 @@
    @Override
    public AutoTuneSnapshot buildSnapshot() {
        List<WrkMast> activeTasks = loadActiveTasks();
        AutoTuneTaskSnapshot taskSnapshot = buildTaskSnapshot(activeTasks);
        List<AutoTuneStationRuntimeItem> stationRuntimeSnapshot = buildStationRuntimeSnapshot();
        AutoTuneRoutePressureSnapshot routePressureSnapshot = buildRoutePressureSnapshot(
                activeTasks,
                taskSnapshot,
                stationRuntimeSnapshot
        );
        AutoTuneSnapshot snapshot = new AutoTuneSnapshot();
        snapshot.setTaskSnapshot(buildTaskSnapshot());
        snapshot.setTaskSnapshot(taskSnapshot);
        snapshot.setStationRuntimeSnapshot(stationRuntimeSnapshot);
        snapshot.setRoutePressureSnapshot(routePressureSnapshot);
        snapshot.setCycleLoadSnapshot(buildCycleLoadSnapshot());
        snapshot.setFlowTopologySnapshot(flowTopologySnapshotService.buildSnapshot(stationRuntimeSnapshot));
        snapshot.setCurrentParameterSnapshot(buildCurrentParameterSnapshot());
        snapshot.setRuleSnapshot(buildRuleSnapshot());
        snapshot.setControlModeSnapshot(buildControlModeSnapshot());
        snapshot.setSnapshotTime(new Date());
        return snapshot;
    }
    private AutoTuneControlModeSnapshot buildControlModeSnapshot() {
        return autoTuneControlModeService.currentMode();
    }
    List<AutoTuneRuleSnapshotItem> buildRuleSnapshot() {
@@ -118,8 +144,10 @@
    }
    private AutoTuneTaskSnapshot buildTaskSnapshot() {
        List<WrkMast> activeTasks = loadActiveTasks();
        return buildTaskSnapshot(loadActiveTasks());
    }
    private AutoTuneTaskSnapshot buildTaskSnapshot(List<WrkMast> activeTasks) {
        AutoTuneTaskSnapshot snapshot = new AutoTuneTaskSnapshot();
        snapshot.setActiveTaskCount(activeTasks.size());
        snapshot.setStationLimitBlockedTasks(buildStationLimitBlockedTasks(activeTasks));
@@ -131,6 +159,30 @@
        snapshot.setByDualCrn(countByDualCrn(activeTasks));
        snapshot.setByIoType(countByIoType(activeTasks));
        return snapshot;
    }
    private AutoTuneRoutePressureSnapshot buildRoutePressureSnapshot(List<WrkMast> activeTasks,
                                                                     AutoTuneTaskSnapshot taskSnapshot,
                                                                     List<AutoTuneStationRuntimeItem> stationRuntimeSnapshot) {
        if (routePressureSnapshotService == null) {
            return new AutoTuneRoutePressureSnapshot();
        }
        try {
            AutoTuneRoutePressureSnapshot snapshot = routePressureSnapshotService.buildSnapshot(
                    activeTasks,
                    taskSnapshot,
                    stationRuntimeSnapshot
            );
            return snapshot == null ? new AutoTuneRoutePressureSnapshot() : snapshot;
        } catch (Exception exception) {
            LOGGER.warn(
                    "Build auto tune route pressure snapshot failed, fallback to empty snapshot. activeTaskCount={}, stationRuntimeCount={}",
                    safeList(activeTasks).size(),
                    safeList(stationRuntimeSnapshot).size(),
                    exception
            );
            return new AutoTuneRoutePressureSnapshot();
        }
    }
    private List<AutoTuneTaskDetailItem> buildStationLimitBlockedTasks(List<WrkMast> activeTasks) {
@@ -298,6 +350,7 @@
        item.setAutoing(protocol.isAutoing() ? 1 : 0);
        item.setLoading(protocol.isLoading() ? 1 : 0);
        item.setTaskNo(protocol.getTaskNo() == null ? 0 : protocol.getTaskNo());
        item.setRunBlock(protocol.isRunBlock() ? 1 : 0);
        item.setIoMode(protocol.getIoMode() == null ? null : String.valueOf(protocol.getIoMode()));
        return item;
    }
src/main/java/com/zy/ai/service/impl/RoutePressureSnapshotServiceImpl.java
New file
@@ -0,0 +1,960 @@
package com.zy.ai.service.impl;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.zy.ai.domain.autotune.AutoTuneHotPathSegmentItem;
import com.zy.ai.domain.autotune.AutoTuneRoutePressureSnapshot;
import com.zy.ai.domain.autotune.AutoTuneRoutePressureRuleSnapshot;
import com.zy.ai.domain.autotune.AutoTuneStationRuntimeItem;
import com.zy.ai.domain.autotune.AutoTuneTargetStationRoutePressureItem;
import com.zy.ai.domain.autotune.AutoTuneTaskDetailItem;
import com.zy.ai.domain.autotune.AutoTuneTaskRouteSampleItem;
import com.zy.ai.domain.autotune.AutoTuneTaskSnapshot;
import com.zy.ai.service.RoutePressureSnapshotService;
import com.zy.asrs.domain.vo.StationTaskTraceVo;
import com.zy.asrs.entity.WrkMast;
import com.zy.common.model.NavigateNode;
import com.zy.common.utils.NavigateUtils;
import com.zy.core.enums.WrkIoType;
import com.zy.core.enums.WrkStsType;
import com.zy.core.trace.StationTaskTraceRegistry;
import com.zy.core.utils.station.StationOutboundDecisionSupport;
import com.zy.system.service.ConfigService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@Service("routePressureSnapshotService")
public class RoutePressureSnapshotServiceImpl implements RoutePressureSnapshotService {
    private static final int MAX_ANALYZED_TASK_COUNT = 50;
    private static final int DEFAULT_SEGMENT_WINDOW_SIZE = 4;
    private static final int DEFAULT_MEDIUM_PERCENT = 50;
    private static final int DEFAULT_HIGH_PERCENT = 75;
    private static final int DEFAULT_PASS_WEIGHT_PERCENT = 35;
    private static final int DEFAULT_OCCUPIED_WEIGHT_PERCENT = 25;
    private static final int DEFAULT_BLOCKED_WEIGHT_PERCENT = 20;
    private static final int DEFAULT_NON_AUTOING_WEIGHT_PERCENT = 10;
    private static final int DEFAULT_RUN_BLOCK_WEIGHT_PERCENT = 10;
    private static final int MAX_HOT_PATH_SEGMENT_COUNT = 10;
    private static final int MAX_TARGET_MAIN_SEGMENT_COUNT = 3;
    private static final String CONFIG_SEGMENT_WINDOW_SIZE = "aiAutoTuneRoutePressureSegmentWindowSize";
    private static final String CONFIG_MEDIUM_PERCENT = "aiAutoTuneRoutePressureMediumPercent";
    private static final String CONFIG_HIGH_PERCENT = "aiAutoTuneRoutePressureHighPercent";
    private static final String CONFIG_PASS_WEIGHT_PERCENT = "aiAutoTuneRoutePressurePassWeightPercent";
    private static final String CONFIG_OCCUPIED_WEIGHT_PERCENT = "aiAutoTuneRoutePressureOccupiedWeightPercent";
    private static final String CONFIG_BLOCKED_WEIGHT_PERCENT = "aiAutoTuneRoutePressureBlockedWeightPercent";
    private static final String CONFIG_NON_AUTOING_WEIGHT_PERCENT = "aiAutoTuneRoutePressureNonAutoingWeightPercent";
    private static final String CONFIG_RUN_BLOCK_WEIGHT_PERCENT = "aiAutoTuneRoutePressureRunBlockWeightPercent";
    private static final String PATH_SOURCE_TRACE_PENDING = "trace_pending";
    private static final String PATH_SOURCE_TRACE_ISSUED = "trace_issued";
    private static final String PATH_SOURCE_ESTIMATED = "estimated";
    private static final String PATH_SOURCE_MISSING = "missing";
    private static final String PRESSURE_HIGH = "high";
    private static final String PRESSURE_MEDIUM = "medium";
    private static final String PRESSURE_LOW = "low";
    private static final String DIRECTION_INCREASE_CANDIDATE = "increase_candidate";
    private static final String DIRECTION_OBSERVE = "observe";
    private static final String DIRECTION_DECREASE_CANDIDATE = "decrease_candidate";
    private static final String CONFIDENCE_HIGH = "high";
    private static final String CONFIDENCE_MEDIUM = "medium";
    private static final String CONFIDENCE_LOW = "low";
    @Autowired
    private StationTaskTraceRegistry stationTaskTraceRegistry;
    @Autowired
    private NavigateUtils navigateUtils;
    @Autowired
    private StationOutboundDecisionSupport stationOutboundDecisionSupport;
    @Autowired
    private ConfigService configService;
    @Override
    public AutoTuneRoutePressureSnapshot buildSnapshot(List<WrkMast> activeTasks,
                                                       AutoTuneTaskSnapshot taskSnapshot,
                                                       List<AutoTuneStationRuntimeItem> stationRuntimeSnapshot) {
        AutoTuneRoutePressureSnapshot snapshot = new AutoTuneRoutePressureSnapshot();
        AutoTuneRoutePressureRuleSnapshot ruleSnapshot = buildRoutePressureRuleSnapshot();
        List<WrkMast> selectedTasks = selectTasksForAnalysis(activeTasks, taskSnapshot);
        Map<Integer, StationTaskTraceVo> traceMap = buildTraceMap();
        Map<String, EstimatedPathResult> estimatedPathCache = new LinkedHashMap<>();
        List<AutoTuneTaskRouteSampleItem> routeSamples = new ArrayList<>();
        for (WrkMast task : selectedTasks) {
            AutoTuneTaskRouteSampleItem routeSample = buildRouteSample(task, traceMap, estimatedPathCache);
            routeSamples.add(routeSample);
        }
        snapshot.setAnalyzedTaskCount(selectedTasks.size());
        snapshot.setTracePathCount(countByPathSource(routeSamples, PATH_SOURCE_TRACE_PENDING)
                + countByPathSource(routeSamples, PATH_SOURCE_TRACE_ISSUED));
        snapshot.setEstimatedPathCount(countByPathSource(routeSamples, PATH_SOURCE_ESTIMATED));
        snapshot.setPathErrorCount(countByPathSource(routeSamples, PATH_SOURCE_MISSING));
        snapshot.setConfidence(snapshotConfidence(snapshot.getAnalyzedTaskCount(), snapshot.getPathErrorCount()));
        snapshot.setRoutePressureRuleSnapshot(ruleSnapshot);
        snapshot.setTaskRouteSamples(routeSamples);
        Map<Integer, AutoTuneStationRuntimeItem> runtimeMap = buildRuntimeMap(stationRuntimeSnapshot);
        List<AutoTuneHotPathSegmentItem> allHotPathSegments = buildHotPathSegments(
                routeSamples,
                runtimeMap,
                ruleSnapshot
        );
        snapshot.setHotPathSegments(topHotPathSegments(allHotPathSegments));
        snapshot.setTargetStationRoutePressure(buildTargetStationRoutePressure(
                routeSamples,
                allHotPathSegments,
                taskSnapshot,
                ruleSnapshot
        ));
        return snapshot;
    }
    private AutoTuneRoutePressureRuleSnapshot buildRoutePressureRuleSnapshot() {
        AutoTuneRoutePressureRuleSnapshot snapshot = new AutoTuneRoutePressureRuleSnapshot();
        snapshot.setSegmentWindowSize(readIntConfig(
                CONFIG_SEGMENT_WINDOW_SIZE,
                DEFAULT_SEGMENT_WINDOW_SIZE,
                2,
                20
        ));
        snapshot.setMediumPercent(readIntConfig(CONFIG_MEDIUM_PERCENT, DEFAULT_MEDIUM_PERCENT, 1, 100));
        snapshot.setHighPercent(readIntConfig(CONFIG_HIGH_PERCENT, DEFAULT_HIGH_PERCENT, 1, 100));
        if (snapshot.getMediumPercent() > snapshot.getHighPercent()) {
            snapshot.setMediumPercent(DEFAULT_MEDIUM_PERCENT);
            snapshot.setHighPercent(DEFAULT_HIGH_PERCENT);
        }
        snapshot.setPassWeightPercent(readIntConfig(CONFIG_PASS_WEIGHT_PERCENT, DEFAULT_PASS_WEIGHT_PERCENT, 0, 100));
        snapshot.setOccupiedWeightPercent(readIntConfig(
                CONFIG_OCCUPIED_WEIGHT_PERCENT,
                DEFAULT_OCCUPIED_WEIGHT_PERCENT,
                0,
                100
        ));
        snapshot.setBlockedWeightPercent(readIntConfig(
                CONFIG_BLOCKED_WEIGHT_PERCENT,
                DEFAULT_BLOCKED_WEIGHT_PERCENT,
                0,
                100
        ));
        snapshot.setNonAutoingWeightPercent(readIntConfig(
                CONFIG_NON_AUTOING_WEIGHT_PERCENT,
                DEFAULT_NON_AUTOING_WEIGHT_PERCENT,
                0,
                100
        ));
        snapshot.setRunBlockWeightPercent(readIntConfig(
                CONFIG_RUN_BLOCK_WEIGHT_PERCENT,
                DEFAULT_RUN_BLOCK_WEIGHT_PERCENT,
                0,
                100
        ));
        return snapshot;
    }
    private int readIntConfig(String code, int defaultValue, int minValue, int maxValue) {
        if (configService == null) {
            return defaultValue;
        }
        String value = configService.getConfigValue(code, String.valueOf(defaultValue));
        try {
            if (value == null || value.trim().isEmpty()) {
                return defaultValue;
            }
            int parsedValue = Integer.parseInt(value.trim());
            if (parsedValue < minValue || parsedValue > maxValue) {
                return defaultValue;
            }
            return parsedValue;
        } catch (Exception e) {
            return defaultValue;
        }
    }
    private List<WrkMast> selectTasksForAnalysis(List<WrkMast> activeTasks,
                                                 AutoTuneTaskSnapshot taskSnapshot) {
        List<WrkMast> sortedOutboundTasks = sortedOutboundTasks(activeTasks);
        Set<Integer> blockedWrkNos = collectBlockedWrkNos(taskSnapshot);
        LinkedHashMap<Integer, WrkMast> selected = new LinkedHashMap<>();
        for (WrkMast task : sortedOutboundTasks) {
            Integer wrkNo = task.getWrkNo();
            if (wrkNo != null && blockedWrkNos.contains(wrkNo)) {
                selected.put(wrkNo, task);
            }
            if (selected.size() >= MAX_ANALYZED_TASK_COUNT) {
                return new ArrayList<>(selected.values());
            }
        }
        for (WrkMast task : sortedOutboundTasks) {
            Integer wrkNo = task.getWrkNo();
            if (wrkNo != null) {
                selected.putIfAbsent(wrkNo, task);
            }
            if (selected.size() >= MAX_ANALYZED_TASK_COUNT) {
                break;
            }
        }
        return new ArrayList<>(selected.values());
    }
    private Set<Integer> collectBlockedWrkNos(AutoTuneTaskSnapshot taskSnapshot) {
        Set<Integer> blockedWrkNos = new HashSet<>();
        if (taskSnapshot == null || taskSnapshot.getStationLimitBlockedTasks() == null) {
            return blockedWrkNos;
        }
        for (AutoTuneTaskDetailItem blockedTask : taskSnapshot.getStationLimitBlockedTasks()) {
            if (blockedTask != null && blockedTask.getWrkNo() != null) {
                blockedWrkNos.add(blockedTask.getWrkNo());
            }
        }
        return blockedWrkNos;
    }
    private List<WrkMast> sortedOutboundTasks(List<WrkMast> activeTasks) {
        List<WrkMast> sortedTasks = new ArrayList<>();
        if (activeTasks == null) {
            return sortedTasks;
        }
        for (WrkMast task : activeTasks) {
            if (task != null && Objects.equals(task.getIoType(), WrkIoType.OUT.id)) {
                sortedTasks.add(task);
            }
        }
        sortedTasks.sort(Comparator
                .comparing(WrkMast::getBatch, Comparator.nullsLast(String::compareTo))
                .thenComparing(WrkMast::getBatchSeq, Comparator.nullsLast(Integer::compareTo))
                .thenComparing(WrkMast::getWrkNo, Comparator.nullsLast(Integer::compareTo)));
        return sortedTasks;
    }
    private Map<Integer, StationTaskTraceVo> buildTraceMap() {
        Map<Integer, StationTaskTraceVo> traceMap = new LinkedHashMap<>();
        if (stationTaskTraceRegistry == null) {
            return traceMap;
        }
        List<StationTaskTraceVo> traceSnapshots;
        try {
            traceSnapshots = stationTaskTraceRegistry.listPlanningActiveTraceSnapshots();
        } catch (Exception e) {
            return traceMap;
        }
        if (traceSnapshots == null) {
            return traceMap;
        }
        for (StationTaskTraceVo traceSnapshot : traceSnapshots) {
            if (traceSnapshot != null && traceSnapshot.getTaskNo() != null) {
                traceMap.putIfAbsent(traceSnapshot.getTaskNo(), traceSnapshot);
            }
        }
        return traceMap;
    }
    private AutoTuneTaskRouteSampleItem buildRouteSample(WrkMast task,
                                                         Map<Integer, StationTaskTraceVo> traceMap,
                                                         Map<String, EstimatedPathResult> estimatedPathCache) {
        AutoTuneTaskRouteSampleItem item = baseRouteSample(task);
        if (!isRunningStationTask(task)) {
            return buildEstimatedRouteSample(item, task, estimatedPathCache);
        }
        StationTaskTraceVo trace = task == null || task.getWrkNo() == null ? null : traceMap.get(task.getWrkNo());
        if (trace == null) {
            return completeRouteSample(item, PATH_SOURCE_MISSING, Collections.emptyList(),
                    "missing station trace for running station task");
        }
        List<Integer> remainingPath = traceRemainingPath(trace);
        if (!remainingPath.isEmpty()) {
            return completeRouteSample(item, PATH_SOURCE_TRACE_PENDING, remainingPath, null);
        }
        List<Integer> issuedPath = firstNonEmptyDistinct(
                trace == null ? null : trace.getLatestIssuedSegmentPath(),
                trace == null ? null : trace.getIssuedStationIds()
        );
        if (!issuedPath.isEmpty()) {
            return completeRouteSample(item, PATH_SOURCE_TRACE_ISSUED, issuedPath, null);
        }
        return completeRouteSample(item, PATH_SOURCE_MISSING, Collections.emptyList(),
                "missing usable station trace path for running station task");
    }
    private List<Integer> traceRemainingPath(StationTaskTraceVo trace) {
        if (trace == null) {
            return Collections.emptyList();
        }
        List<Integer> pendingPath = distinctPositive(trace.getPendingStationIds());
        Integer currentStationId = trace.getCurrentStationId();
        if (currentStationId == null || currentStationId <= 0) {
            return pendingPath;
        }
        List<Integer> remainingPath = new ArrayList<>();
        remainingPath.add(currentStationId);
        Integer previousStationId = currentStationId;
        for (Integer stationId : pendingPath) {
            if (Objects.equals(stationId, previousStationId)) {
                continue;
            }
            remainingPath.add(stationId);
            previousStationId = stationId;
        }
        return remainingPath;
    }
    private boolean isRunningStationTask(WrkMast task) {
        return task != null && Objects.equals(task.getWrkSts(), WrkStsType.STATION_RUN.sts);
    }
    private AutoTuneTaskRouteSampleItem baseRouteSample(WrkMast task) {
        AutoTuneTaskRouteSampleItem item = new AutoTuneTaskRouteSampleItem();
        if (task == null) {
            return item;
        }
        item.setWrkNo(task.getWrkNo());
        item.setWrkSts(task.getWrkSts());
        item.setBatch(task.getBatch());
        item.setBatchSeq(task.getBatchSeq());
        item.setSourceStaNo(task.getSourceStaNo());
        item.setTargetStaNo(task.getStaNo());
        return item;
    }
    private AutoTuneTaskRouteSampleItem buildEstimatedRouteSample(AutoTuneTaskRouteSampleItem item,
                                                                  WrkMast task,
                                                                  Map<String, EstimatedPathResult> cache) {
        if (task == null || task.getSourceStaNo() == null || task.getStaNo() == null) {
            return completeRouteSample(item, PATH_SOURCE_MISSING, Collections.emptyList(),
                    "missing sourceStaNo or staNo");
        }
        Double pathLenFactor = resolvePathLenFactor(task);
        String cacheKey = buildEstimatedPathCacheKey(task, pathLenFactor);
        EstimatedPathResult pathResult = cache.get(cacheKey);
        if (pathResult == null) {
            pathResult = estimatePath(task, pathLenFactor);
            cache.put(cacheKey, pathResult);
        }
        if (pathResult.pathStationIds.isEmpty()) {
            return completeRouteSample(item, PATH_SOURCE_MISSING, Collections.emptyList(), pathResult.pathError);
        }
        return completeRouteSample(item, PATH_SOURCE_ESTIMATED, pathResult.pathStationIds, null);
    }
    private Double resolvePathLenFactor(WrkMast task) {
        if (stationOutboundDecisionSupport == null) {
            return 0.0d;
        }
        try {
            Double pathLenFactor = stationOutboundDecisionSupport.resolveOutboundPathLenFactor(task);
            return pathLenFactor == null ? 0.0d : pathLenFactor;
        } catch (Exception e) {
            return 0.0d;
        }
    }
    private String buildEstimatedPathCacheKey(WrkMast task, Double pathLenFactor) {
        return task.getWrkNo() + ":" + task.getSourceStaNo() + "->" + task.getStaNo() + ":" + pathLenFactor;
    }
    private EstimatedPathResult estimatePath(WrkMast task, Double pathLenFactor) {
        List<NavigateNode> navigateNodes;
        try {
            navigateNodes = navigateUtils.calcOptimalPathByStationId(
                    task.getSourceStaNo(),
                    task.getStaNo(),
                    task.getWrkNo(),
                    pathLenFactor
            );
        } catch (Exception e) {
            return EstimatedPathResult.error("path estimate exception: " + e.getClass().getSimpleName());
        }
        List<Integer> pathStationIds = stationIdsFromNavigateNodes(navigateNodes);
        if (pathStationIds.isEmpty()) {
            return EstimatedPathResult.error("path estimate failed");
        }
        return EstimatedPathResult.success(pathStationIds);
    }
    private List<Integer> stationIdsFromNavigateNodes(List<NavigateNode> navigateNodes) {
        List<Integer> stationIds = new ArrayList<>();
        if (navigateNodes == null) {
            return stationIds;
        }
        for (NavigateNode navigateNode : navigateNodes) {
            Integer stationId = stationIdFromNavigateNode(navigateNode);
            if (stationId != null && stationId > 0) {
                stationIds.add(stationId);
            }
        }
        return stationIds;
    }
    private Integer stationIdFromNavigateNode(NavigateNode navigateNode) {
        if (navigateNode == null) {
            return null;
        }
        if (navigateNode.getStationId() != null) {
            return navigateNode.getStationId();
        }
        String nodeValue = navigateNode.getNodeValue();
        if (nodeValue == null || nodeValue.trim().isEmpty()) {
            return null;
        }
        try {
            JSONObject value = JSON.parseObject(nodeValue);
            return value == null ? null : value.getInteger("stationId");
        } catch (Exception e) {
            return null;
        }
    }
    @SafeVarargs
    private final List<Integer> firstNonEmptyDistinct(List<Integer>... pathCandidates) {
        if (pathCandidates == null) {
            return Collections.emptyList();
        }
        for (List<Integer> pathCandidate : pathCandidates) {
            List<Integer> pathStationIds = distinctPositive(pathCandidate);
            if (!pathStationIds.isEmpty()) {
                return pathStationIds;
            }
        }
        return Collections.emptyList();
    }
    private List<Integer> distinctPositive(List<Integer> source) {
        List<Integer> result = new ArrayList<>();
        if (source == null) {
            return result;
        }
        Integer previousStationId = null;
        for (Integer stationId : source) {
            if (stationId == null || stationId <= 0 || Objects.equals(stationId, previousStationId)) {
                continue;
            }
            result.add(stationId);
            previousStationId = stationId;
        }
        return result;
    }
    private AutoTuneTaskRouteSampleItem completeRouteSample(AutoTuneTaskRouteSampleItem item,
                                                            String pathSource,
                                                            List<Integer> pathStationIds,
                                                            String pathError) {
        List<Integer> safePathStationIds = pathStationIds == null ? Collections.emptyList() : new ArrayList<>(pathStationIds);
        item.setPathSource(pathSource);
        item.setPathStationIds(safePathStationIds);
        item.setPathLength(safePathStationIds.size());
        item.setPathError(pathError);
        return item;
    }
    private int countByPathSource(List<AutoTuneTaskRouteSampleItem> routeSamples, String pathSource) {
        int count = 0;
        for (AutoTuneTaskRouteSampleItem routeSample : routeSamples) {
            if (routeSample != null && pathSource.equals(routeSample.getPathSource())) {
                count++;
            }
        }
        return count;
    }
    private Map<Integer, AutoTuneStationRuntimeItem> buildRuntimeMap(List<AutoTuneStationRuntimeItem> stationRuntimeSnapshot) {
        Map<Integer, AutoTuneStationRuntimeItem> runtimeMap = new LinkedHashMap<>();
        if (stationRuntimeSnapshot == null) {
            return runtimeMap;
        }
        for (AutoTuneStationRuntimeItem runtimeItem : stationRuntimeSnapshot) {
            if (runtimeItem != null && runtimeItem.getStationId() != null) {
                runtimeMap.putIfAbsent(runtimeItem.getStationId(), runtimeItem);
            }
        }
        return runtimeMap;
    }
    private List<AutoTuneHotPathSegmentItem> buildHotPathSegments(List<AutoTuneTaskRouteSampleItem> routeSamples,
                                                                  Map<Integer, AutoTuneStationRuntimeItem> runtimeMap,
                                                                  AutoTuneRoutePressureRuleSnapshot ruleSnapshot) {
        LinkedHashMap<String, SegmentAggregate> aggregateMap = new LinkedHashMap<>();
        if (routeSamples == null) {
            return Collections.emptyList();
        }
        int validRouteSampleCount = 0;
        for (AutoTuneTaskRouteSampleItem routeSample : routeSamples) {
            if (!hasValidPath(routeSample)) {
                continue;
            }
            validRouteSampleCount++;
            Set<String> sampleSegmentKeys = new HashSet<>();
            List<List<Integer>> routeSegments = splitRouteSegments(routeSample.getPathStationIds(), ruleSnapshot);
            for (List<Integer> routeSegment : routeSegments) {
                String segmentKey = buildSegmentKey(routeSegment);
                if (segmentKey == null || !sampleSegmentKeys.add(segmentKey)) {
                    continue;
                }
                SegmentAggregate aggregate = aggregateMap.computeIfAbsent(
                        segmentKey,
                        key -> new SegmentAggregate(key, routeSegment)
                );
                aggregate.addRouteSample(routeSample);
            }
        }
        List<AutoTuneHotPathSegmentItem> hotPathSegments = new ArrayList<>();
        for (SegmentAggregate aggregate : aggregateMap.values()) {
            hotPathSegments.add(aggregate.toItem(runtimeMap, ruleSnapshot, validRouteSampleCount));
        }
        hotPathSegments.sort(this::compareHotPathSegment);
        return hotPathSegments;
    }
    private boolean hasValidPath(AutoTuneTaskRouteSampleItem routeSample) {
        return routeSample != null
                && routeSample.getTargetStaNo() != null
                && routeSample.getPathStationIds() != null
                && routeSample.getPathStationIds().size() >= 2
                && !PATH_SOURCE_MISSING.equals(routeSample.getPathSource());
    }
    private List<List<Integer>> splitRouteSegments(List<Integer> pathStationIds,
                                                   AutoTuneRoutePressureRuleSnapshot ruleSnapshot) {
        if (pathStationIds == null || pathStationIds.isEmpty()) {
            return Collections.emptyList();
        }
        List<Integer> safePathStationIds = new ArrayList<>(pathStationIds);
        int segmentWindowSize = ruleWindowSize(ruleSnapshot);
        if (safePathStationIds.size() <= segmentWindowSize) {
            return Collections.singletonList(safePathStationIds);
        }
        List<List<Integer>> routeSegments = new ArrayList<>();
        for (int startIndex = 0; startIndex <= safePathStationIds.size() - segmentWindowSize; startIndex++) {
            int endIndex = startIndex + segmentWindowSize;
            routeSegments.add(new ArrayList<>(safePathStationIds.subList(startIndex, endIndex)));
        }
        return routeSegments;
    }
    private int ruleWindowSize(AutoTuneRoutePressureRuleSnapshot ruleSnapshot) {
        Integer segmentWindowSize = ruleSnapshot == null ? null : ruleSnapshot.getSegmentWindowSize();
        return segmentWindowSize == null || segmentWindowSize < 2 ? DEFAULT_SEGMENT_WINDOW_SIZE : segmentWindowSize;
    }
    private String buildSegmentKey(List<Integer> stationIds) {
        if (stationIds == null || stationIds.isEmpty()) {
            return null;
        }
        StringBuilder segmentKey = new StringBuilder();
        for (Integer stationId : stationIds) {
            if (stationId == null) {
                return null;
            }
            if (segmentKey.length() > 0) {
                segmentKey.append("-");
            }
            segmentKey.append(stationId);
        }
        return segmentKey.toString();
    }
    private int compareHotPathSegment(AutoTuneHotPathSegmentItem left, AutoTuneHotPathSegmentItem right) {
        int pressureCompare = Integer.compare(
                pressureRank(right.getPressureLevel()),
                pressureRank(left.getPressureLevel())
        );
        if (pressureCompare != 0) {
            return pressureCompare;
        }
        int scoreCompare = Integer.compare(nullSafe(right.getPressureScore()), nullSafe(left.getPressureScore()));
        if (scoreCompare != 0) {
            return scoreCompare;
        }
        int passCompare = Integer.compare(nullSafe(right.getPassTaskCount()), nullSafe(left.getPassTaskCount()));
        if (passCompare != 0) {
            return passCompare;
        }
        int runBlockCompare = Integer.compare(nullSafe(right.getRunBlockCount()), nullSafe(left.getRunBlockCount()));
        if (runBlockCompare != 0) {
            return runBlockCompare;
        }
        int taskHoldingCompare = Integer.compare(
                nullSafe(right.getTaskHoldingCount()),
                nullSafe(left.getTaskHoldingCount())
        );
        if (taskHoldingCompare != 0) {
            return taskHoldingCompare;
        }
        int loadingCompare = Integer.compare(nullSafe(right.getLoadingCount()), nullSafe(left.getLoadingCount()));
        if (loadingCompare != 0) {
            return loadingCompare;
        }
        return nullSafeString(left.getSegmentKey()).compareTo(nullSafeString(right.getSegmentKey()));
    }
    private int pressureRank(String pressureLevel) {
        if (PRESSURE_HIGH.equals(pressureLevel)) {
            return 3;
        }
        if (PRESSURE_MEDIUM.equals(pressureLevel)) {
            return 2;
        }
        return 1;
    }
    private int nullSafe(Integer value) {
        return value == null ? 0 : value;
    }
    private String nullSafeString(String value) {
        return value == null ? "" : value;
    }
    private List<AutoTuneHotPathSegmentItem> topHotPathSegments(List<AutoTuneHotPathSegmentItem> allHotPathSegments) {
        if (allHotPathSegments == null || allHotPathSegments.isEmpty()) {
            return Collections.emptyList();
        }
        int toIndex = Math.min(MAX_HOT_PATH_SEGMENT_COUNT, allHotPathSegments.size());
        return new ArrayList<>(allHotPathSegments.subList(0, toIndex));
    }
    private List<AutoTuneTargetStationRoutePressureItem> buildTargetStationRoutePressure(
            List<AutoTuneTaskRouteSampleItem> routeSamples,
            List<AutoTuneHotPathSegmentItem> allHotPathSegments,
            AutoTuneTaskSnapshot taskSnapshot,
            AutoTuneRoutePressureRuleSnapshot ruleSnapshot) {
        LinkedHashMap<Integer, TargetPressureAggregate> targetAggregateMap = new LinkedHashMap<>();
        Set<Integer> blockedWrkNos = collectBlockedWrkNos(taskSnapshot);
        if (routeSamples != null) {
            for (AutoTuneTaskRouteSampleItem routeSample : routeSamples) {
                if (!hasValidPath(routeSample)) {
                    continue;
                }
                Integer targetStaNo = routeSample.getTargetStaNo();
                TargetPressureAggregate aggregate = targetAggregateMap.computeIfAbsent(
                        targetStaNo,
                        TargetPressureAggregate::new
                );
                aggregate.routeTaskCount++;
                if (routeSample.getWrkNo() != null && blockedWrkNos.contains(routeSample.getWrkNo())) {
                    aggregate.blockedTaskCount++;
                }
            }
        }
        if (allHotPathSegments != null) {
            for (AutoTuneHotPathSegmentItem hotPathSegment : allHotPathSegments) {
                if (hotPathSegment == null || hotPathSegment.getRelatedTargetStations() == null) {
                    continue;
                }
                for (Integer targetStationId : hotPathSegment.getRelatedTargetStations()) {
                    TargetPressureAggregate aggregate = targetAggregateMap.get(targetStationId);
                    if (aggregate != null) {
                        aggregate.relatedHotSegments.add(hotPathSegment);
                    }
                }
            }
        }
        List<AutoTuneTargetStationRoutePressureItem> targetPressureItems = new ArrayList<>();
        for (TargetPressureAggregate aggregate : targetAggregateMap.values()) {
            targetPressureItems.add(aggregate.toItem(ruleSnapshot));
        }
        targetPressureItems.sort(Comparator.comparing(
                AutoTuneTargetStationRoutePressureItem::getTargetStationId,
                Comparator.nullsLast(Integer::compareTo)
        ));
        return targetPressureItems;
    }
    private int calculateSegmentPressureScore(int passTaskCount,
                                              int taskHoldingCount,
                                              int loadingCount,
                                              int runBlockCount,
                                              int nonAutoingCount,
                                              int segmentStationCount,
                                              int validRouteSampleCount,
                                              AutoTuneRoutePressureRuleSnapshot ruleSnapshot) {
        double passRatio = ratio(passTaskCount, validRouteSampleCount);
        double occupiedRatio = ratio(taskHoldingCount + loadingCount, segmentStationCount);
        double nonAutoingRatio = ratio(nonAutoingCount, segmentStationCount);
        double runBlockRatio = ratio(runBlockCount, segmentStationCount);
        double score = passRatio * nullSafe(ruleSnapshot.getPassWeightPercent())
                + occupiedRatio * nullSafe(ruleSnapshot.getOccupiedWeightPercent())
                + nonAutoingRatio * nullSafe(ruleSnapshot.getNonAutoingWeightPercent())
                + runBlockRatio * nullSafe(ruleSnapshot.getRunBlockWeightPercent());
        return clampPercent((int) Math.round(score));
    }
    private String calculatePressureLevel(int pressureScore, AutoTuneRoutePressureRuleSnapshot ruleSnapshot) {
        if (pressureScore >= nullSafe(ruleSnapshot.getHighPercent())) {
            return PRESSURE_HIGH;
        }
        if (pressureScore >= nullSafe(ruleSnapshot.getMediumPercent())) {
            return PRESSURE_MEDIUM;
        }
        return PRESSURE_LOW;
    }
    private Map<String, Object> segmentPressureFactors(int passTaskCount,
                                                       int taskHoldingCount,
                                                       int loadingCount,
                                                       int runBlockCount,
                                                       int nonAutoingCount,
                                                       int segmentStationCount,
                                                       int validRouteSampleCount) {
        Map<String, Object> factors = new LinkedHashMap<>();
        factors.put("passRatio", percentValue(passTaskCount, validRouteSampleCount));
        factors.put("occupiedRatio", percentValue(taskHoldingCount + loadingCount, segmentStationCount));
        factors.put("nonAutoingRatio", percentValue(nonAutoingCount, segmentStationCount));
        factors.put("runBlockRatio", percentValue(runBlockCount, segmentStationCount));
        return factors;
    }
    private double ratio(int numerator, int denominator) {
        if (numerator <= 0 || denominator <= 0) {
            return 0.0d;
        }
        return Math.min(1.0d, (double) numerator / (double) denominator);
    }
    private Integer percentValue(int numerator, int denominator) {
        return clampPercent((int) Math.round(ratio(numerator, denominator) * 100.0d));
    }
    private int clampPercent(int value) {
        if (value < 0) {
            return 0;
        }
        return Math.min(value, 100);
    }
    private String snapshotConfidence(int analyzedTaskCount, int pathErrorCount) {
        if (analyzedTaskCount >= 10 && pathErrorCount == 0) {
            return CONFIDENCE_HIGH;
        }
        if (analyzedTaskCount >= 3 && pathErrorCount < analyzedTaskCount) {
            return CONFIDENCE_MEDIUM;
        }
        return CONFIDENCE_LOW;
    }
    private String targetConfidence(int routeTaskCount) {
        if (routeTaskCount >= 10) {
            return CONFIDENCE_HIGH;
        }
        if (routeTaskCount >= 3) {
            return CONFIDENCE_MEDIUM;
        }
        return CONFIDENCE_LOW;
    }
    private static class EstimatedPathResult {
        private final List<Integer> pathStationIds;
        private final String pathError;
        private EstimatedPathResult(List<Integer> pathStationIds, String pathError) {
            this.pathStationIds = pathStationIds;
            this.pathError = pathError;
        }
        private static EstimatedPathResult success(List<Integer> pathStationIds) {
            return new EstimatedPathResult(new ArrayList<>(pathStationIds), null);
        }
        private static EstimatedPathResult error(String pathError) {
            return new EstimatedPathResult(Collections.emptyList(), pathError);
        }
    }
    private class SegmentAggregate {
        private final String segmentKey;
        private final List<Integer> stationIds;
        private final Set<Integer> relatedTargetStations = new LinkedHashSet<>();
        private final Set<Integer> sampleWrkNos = new LinkedHashSet<>();
        private int passTaskCount;
        private SegmentAggregate(String segmentKey, List<Integer> stationIds) {
            this.segmentKey = segmentKey;
            this.stationIds = new ArrayList<>(stationIds);
        }
        private void addRouteSample(AutoTuneTaskRouteSampleItem routeSample) {
            passTaskCount++;
            if (routeSample.getTargetStaNo() != null) {
                relatedTargetStations.add(routeSample.getTargetStaNo());
            }
            if (routeSample.getWrkNo() != null) {
                sampleWrkNos.add(routeSample.getWrkNo());
            }
        }
        private AutoTuneHotPathSegmentItem toItem(Map<Integer, AutoTuneStationRuntimeItem> runtimeMap,
                                                 AutoTuneRoutePressureRuleSnapshot ruleSnapshot,
                                                 int validRouteSampleCount) {
            int loadingCount = 0;
            int taskHoldingCount = 0;
            int runBlockCount = 0;
            int nonAutoingCount = 0;
            for (Integer stationId : stationIds) {
                AutoTuneStationRuntimeItem runtimeItem = runtimeMap.get(stationId);
                if (runtimeItem == null) {
                    continue;
                }
                if (Objects.equals(runtimeItem.getLoading(), 1)) {
                    loadingCount++;
                }
                if (runtimeItem.getTaskNo() != null && runtimeItem.getTaskNo() > 0) {
                    taskHoldingCount++;
                }
                if (Objects.equals(runtimeItem.getRunBlock(), 1)) {
                    runBlockCount++;
                }
                if (!Objects.equals(runtimeItem.getAutoing(), 1)) {
                    nonAutoingCount++;
                }
            }
            int pressureScore = calculateSegmentPressureScore(
                    passTaskCount,
                    taskHoldingCount,
                    loadingCount,
                    runBlockCount,
                    nonAutoingCount,
                    stationIds.size(),
                    validRouteSampleCount,
                    ruleSnapshot
            );
            AutoTuneHotPathSegmentItem item = new AutoTuneHotPathSegmentItem();
            item.setSegmentKey(segmentKey);
            item.setStationIds(new ArrayList<>(stationIds));
            item.setPassTaskCount(passTaskCount);
            item.setLoadingCount(loadingCount);
            item.setTaskHoldingCount(taskHoldingCount);
            item.setRunBlockCount(runBlockCount);
            item.setNonAutoingCount(nonAutoingCount);
            item.setPressureScore(pressureScore);
            item.setPressureLevel(calculatePressureLevel(pressureScore, ruleSnapshot));
            item.setPressureFactors(segmentPressureFactors(
                    passTaskCount,
                    taskHoldingCount,
                    loadingCount,
                    runBlockCount,
                    nonAutoingCount,
                    stationIds.size(),
                    validRouteSampleCount
            ));
            item.setRelatedTargetStations(new ArrayList<>(relatedTargetStations));
            item.setSampleWrkNos(new ArrayList<>(sampleWrkNos));
            return item;
        }
    }
    private class TargetPressureAggregate {
        private final Integer targetStationId;
        private final List<AutoTuneHotPathSegmentItem> relatedHotSegments = new ArrayList<>();
        private int blockedTaskCount;
        private int routeTaskCount;
        private TargetPressureAggregate(Integer targetStationId) {
            this.targetStationId = targetStationId;
        }
        private AutoTuneTargetStationRoutePressureItem toItem(AutoTuneRoutePressureRuleSnapshot ruleSnapshot) {
            int pressureScore = targetPressureScore(ruleSnapshot);
            String pressureLevel = calculatePressureLevel(pressureScore, ruleSnapshot);
            String heuristicDirection = heuristicDirection(pressureLevel);
            String evidenceText = evidenceText(pressureLevel, pressureScore, heuristicDirection);
            AutoTuneTargetStationRoutePressureItem item = new AutoTuneTargetStationRoutePressureItem();
            item.setTargetStationId(targetStationId);
            item.setBlockedTaskCount(blockedTaskCount);
            item.setRouteTaskCount(routeTaskCount);
            item.setMainHotSegments(mainHotSegmentKeys());
            item.setPressureLevel(pressureLevel);
            item.setPressureScore(pressureScore);
            item.setConfidence(targetConfidence(routeTaskCount));
            item.setPressureFactors(targetPressureFactors(ruleSnapshot));
            item.setHeuristicDirection(heuristicDirection);
            item.setRecommendedDirection(heuristicDirection);
            item.setRecommendedTargets(Collections.singletonList("station/" + targetStationId + "/outTaskLimit"));
            item.setReason(evidenceText);
            item.setEvidenceText(evidenceText);
            return item;
        }
        private int targetPressureScore(AutoTuneRoutePressureRuleSnapshot ruleSnapshot) {
            int pressureScore = highestSegmentPressureScore();
            int blockedScore = (int) Math.round(
                    ratio(blockedTaskCount, routeTaskCount) * nullSafe(ruleSnapshot.getBlockedWeightPercent())
            );
            return clampPercent(pressureScore + blockedScore);
        }
        private int highestSegmentPressureScore() {
            int pressureScore = 0;
            for (AutoTuneHotPathSegmentItem hotPathSegment : relatedHotSegments) {
                pressureScore = Math.max(pressureScore, nullSafe(hotPathSegment.getPressureScore()));
            }
            return pressureScore;
        }
        private Map<String, Object> targetPressureFactors(AutoTuneRoutePressureRuleSnapshot ruleSnapshot) {
            Map<String, Object> factors = new LinkedHashMap<>();
            factors.put("blockedRatio", percentValue(blockedTaskCount, routeTaskCount));
            factors.put("highestSegmentScore", highestSegmentPressureScore());
            factors.put("blockedScore", (int) Math.round(
                    ratio(blockedTaskCount, routeTaskCount) * nullSafe(ruleSnapshot.getBlockedWeightPercent())
            ));
            return factors;
        }
        private List<String> mainHotSegmentKeys() {
            List<String> segmentKeys = new ArrayList<>();
            for (AutoTuneHotPathSegmentItem hotPathSegment : relatedHotSegments) {
                if (hotPathSegment.getSegmentKey() != null) {
                    segmentKeys.add(hotPathSegment.getSegmentKey());
                }
                if (segmentKeys.size() >= MAX_TARGET_MAIN_SEGMENT_COUNT) {
                    break;
                }
            }
            return segmentKeys;
        }
        private String heuristicDirection(String pressureLevel) {
            if (PRESSURE_HIGH.equals(pressureLevel)) {
                return DIRECTION_DECREASE_CANDIDATE;
            }
            if (blockedTaskCount > 0) {
                return DIRECTION_INCREASE_CANDIDATE;
            }
            return DIRECTION_OBSERVE;
        }
        private String evidenceText(String pressureLevel, int pressureScore, String heuristicDirection) {
            return "目标站" + targetStationId
                    + "路径事实:pressure=" + pressureLevel
                    + ",score=" + pressureScore
                    + ",heuristicDirection=" + heuristicDirection
                    + ",blockedTaskCount=" + blockedTaskCount
                    + ",routeTaskCount=" + routeTaskCount
                    + ",mainHotSegments=" + mainHotSegmentKeys()
                    + "。";
        }
    }
}
src/main/java/com/zy/ai/utils/AiPromptUtils.java
@@ -1,5 +1,6 @@
package com.zy.ai.utils;
import com.zy.ai.domain.autotune.AutoTuneControlModeSnapshot;
import com.zy.ai.enums.AiPromptBlockType;
import com.zy.ai.enums.AiPromptScene;
import org.springframework.stereotype.Component;
@@ -9,11 +10,38 @@
@Component
public class AiPromptUtils {
    private static final String AUTO_TUNE_TOOL_GET_SNAPSHOT = "wcs_local_dispatch_get_auto_tune_snapshot";
    private static final String AUTO_TUNE_TOOL_GET_RECENT_JOBS = "wcs_local_dispatch_get_recent_auto_tune_jobs";
    private static final String AUTO_TUNE_TOOL_APPLY_CHANGES = "wcs_local_dispatch_apply_auto_tune_changes";
    private static final String AUTO_TUNE_TOOL_REVERT_LAST_JOB = "wcs_local_dispatch_revert_last_auto_tune_job";
    private static final String AUTO_TUNE_RULE_SNAPSHOT_INSTRUCTIONS =
            "Step 4 读取调参规则\n" +
                    "- 必须读取 snapshot.ruleSnapshot 中的 minValue、maxValue、maxStep、cooldownMinutes、dynamicMaxValue 和 dynamicMaxSource。\n" +
                    "- 提交给 wcs_local_dispatch_apply_auto_tune_changes 的每个 change 都必须匹配对应 targetType/targetKey 的规则。\n" +
                    "- 所有提交给 " + AUTO_TUNE_TOOL_APPLY_CHANGES + " 的 change 都必须匹配对应 targetType/targetKey 的规则。\n" +
                    "- 每个目标参数的新值必须满足对应 minValue、maxValue 或 dynamicMaxValue、maxStep、cooldownMinutes 和规则 note;找不到规则或无法证明动态上限时禁止提交。";
    public static String buildAutoTuneRuntimeGuard(String triggerType, AutoTuneControlModeSnapshot controlMode) {
        return "请执行一次后台 WCS 自动调参。triggerType=" + safeRuntimeValue(triggerType) + "。\n\n" +
                "==================== 运行时强约束 ====================\n" +
                "- 当前执行模式: modeCode=" + modeValue(controlMode == null ? null : controlMode.getModeCode())
                + ",modeLabel=" + modeValue(controlMode == null ? null : controlMode.getModeLabel())
                + ",enabled=" + modeValue(controlMode == null ? null : controlMode.getEnabled())
                + ",analysisOnly=" + modeValue(controlMode == null ? null : controlMode.getAnalysisOnly())
                + ",allowApply=" + modeValue(controlMode == null ? null : controlMode.getAllowApply()) + "。\n" +
                "- 必须先调用 " + AUTO_TUNE_TOOL_GET_SNAPSHOT + " 获取后端快照,并读取 snapshot.controlModeSnapshot 的 enabled、analysisOnly、allowApply、modeCode、modeLabel。\n" +
                "- 当 snapshot.controlModeSnapshot.analysisOnly=true 或 allowApply=false 时,只允许分析、调用 " + AUTO_TUNE_TOOL_GET_RECENT_JOBS
                + " 和调用 " + AUTO_TUNE_TOOL_APPLY_CHANGES + " dryRun=true 试算;禁止 dryRun=false 正式应用;禁止调用 "
                + AUTO_TUNE_TOOL_REVERT_LAST_JOB + "。\n" +
                "- 如需提交 changes,必须先调用 " + AUTO_TUNE_TOOL_APPLY_CHANGES
                + " dryRun=true;dry-run 返回 success=false、rejectCount>0、changes 为空或 changes[].resultStatus 全部为 no_change 时,不得调用 dryRun=false。\n" +
                "- 只有 analysisOnly=false 且 allowApply=true、dry-run 通过、存在有效变更且返回 dryRunToken 时,才允许通过 "
                + AUTO_TUNE_TOOL_APPLY_CHANGES + " dryRun=false 正式应用;正式应用必须携带该 dryRunToken。\n" +
                "- 没有收到 " + AUTO_TUNE_TOOL_APPLY_CHANGES + " 或 " + AUTO_TUNE_TOOL_REVERT_LAST_JOB
                + " 的工具返回结果时,不得声称已试算、已应用或已回滚;不要输出自由格式 JSON 供外层解析。\n" +
                "- 必须检查 taskSnapshot.stationLimitBlockedTasks 和 taskSnapshot.outboundTaskSamples 中的 systemMsg、wrkSts、batchSeq,判断是否存在被上限挡住的早序出库任务。\n\n" +
                AUTO_TUNE_RULE_SNAPSHOT_INSTRUCTIONS;
    }
    public String getDefaultPrompt(String sceneCode) {
        AiPromptScene scene = AiPromptScene.ofCode(sceneCode);
@@ -134,30 +162,43 @@
                            "- wcs_local_dispatch_get_auto_tune_snapshot:获取当前调度、设备、站点、容量与可写参数快照\n" +
                            "- wcs_local_dispatch_get_recent_auto_tune_jobs:获取近期自动调参任务和变更结果\n" +
                            "- wcs_local_dispatch_apply_auto_tune_changes:提交调参变更,必须先 dry-run 再实际应用\n" +
                            "- wcs_local_dispatch_revert_last_auto_tune_job:仅在明确需要回滚最近一次调参时使用\n\n" +
                            "- wcs_local_dispatch_revert_last_auto_tune_job:仅在运行模式允许且明确需要回滚最近一次调参时使用\n\n" +
                            "禁止调用上述列表之外的工具完成调参。禁止输出自由格式 JSON 让外层解析后调参;所有参数读取、试算、应用和回滚都必须通过 MCP 工具完成。\n\n" +
                            "实际应用前必须先调用 wcs_local_dispatch_apply_auto_tune_changes 执行 dry-run。只有 dry-run 返回允许应用且没有高风险拒绝原因时,才可以再次调用 wcs_local_dispatch_apply_auto_tune_changes 执行实际应用。");
                            "实际应用前必须先调用 wcs_local_dispatch_apply_auto_tune_changes 执行 dry-run。只有 snapshot.controlModeSnapshot 显示 analysisOnly=false 且 allowApply=true,dry-run 返回允许应用且存在有效变更时,才可以再次调用 wcs_local_dispatch_apply_auto_tune_changes 执行实际应用。");
            blocks.put(AiPromptBlockType.OUTPUT_CONTRACT,
                    "==================== 输出要求 ====================\n\n" +
                            "输出必须使用简体中文,并保持审计友好:\n" +
                            "1. 快照摘要:说明本轮依据的关键事实\n" +
                            "2. 调整计划:列出目标参数、原值、建议值和原因\n" +
                            "3. dry-run 结果:说明允许、拒绝或需要人工处理的原因\n" +
                            "4. 实际应用结果:只汇总 MCP 工具返回的应用状态\n" +
                            "4. 实际应用结果:只汇总 MCP 工具返回的应用状态;仅分析模式必须说明“仅分析/未正式应用参数”\n" +
                            "5. 风险与观察点:说明下一轮应重点观察的指标\n\n" +
                            "如果没有足够事实支撑调参,输出“不调整”并说明缺少哪些 MCP 快照事实。");
            blocks.put(AiPromptBlockType.SCENE_PLAYBOOK,
                    "==================== 自动调参规则 ====================\n\n" +
                            "注意:后台 Agent 每轮用户消息还会附加当前执行模式、dryRunToken、no_change 和工具返回边界的运行时强约束;与本 playbook 存在差异时以运行时强约束为准。\n\n" +
                            "Step 1 获取事实\n" +
                            "- 先调用 wcs_local_dispatch_get_auto_tune_snapshot 获取后端快照/MCP facts。\n" +
                            "- 如需判断近期调参影响,再调用 wcs_local_dispatch_get_recent_auto_tune_jobs。\n" +
                            "- 先调用 " + AUTO_TUNE_TOOL_GET_SNAPSHOT + " 获取后端快照/MCP facts。\n" +
                            "- 必须读取 snapshot.controlModeSnapshot,确认 enabled、analysisOnly、allowApply、modeCode、modeLabel。\n" +
                            "- 如需判断近期调参影响,再调用 " + AUTO_TUNE_TOOL_GET_RECENT_JOBS + "。\n" +
                            "- 方向与容量事实必须来自后端快照或 MCP facts,禁止从前端地图推断。\n\n" +
                            "Step 1.1 遵守运行模式\n" +
                            "- 当 controlModeSnapshot.analysisOnly=true 或 allowApply=false 时,只允许分析、获取快照、查询最近任务、调用 dryRun=true 试算;禁止 dryRun=false 实际应用;禁止 rollback;输出必须说明“仅分析/未正式应用参数”。\n" +
                            "- 只有当 controlModeSnapshot.analysisOnly=false 且 allowApply=true,并且 dry-run 通过且存在有效变更时,才允许正式应用。\n\n" +
                            "Step 2 分析站点运行态\n" +
                            "- 运行时站点分析只能使用 autoing、loading、taskNo。\n" +
                            "- 禁止使用 taskWriteIdx 或 taskBufferItems 作为调参依据。\n\n" +
                            "Step 2.1 分析出库任务阻塞\n" +
                            "- 必须检查 taskSnapshot.stationLimitBlockedTasks 和 taskSnapshot.outboundTaskSamples。\n" +
                            "- 如果同一站点/同一批次中,较小 batchSeq 的 NEW_OUTBOUND 任务因 systemMsg 显示“出库任务上限”被挡住,而较大 batchSeq 已进入 STATION_RUN,说明当前上限可能造成出库排队或顺序异常,应优先评估 outTaskLimit、maxOutTask、crnOutBatchRunningLimit 等相关规则允许的上调空间。\n\n" +
                            "Step 2.2 分析路径局部压力\n" +
                            "- 必须读取 routePressureSnapshot.routePressureRuleSnapshot,理解当前仓库使用的百分比阈值和评分权重。\n" +
                            "- 必须读取 routePressureSnapshot.targetStationRoutePressure、hotPathSegments、pressureScore、pressureFactors、confidence 和 evidenceText。\n" +
                            "- heuristicDirection/recommendedDirection 只是后端启发式提示,不是最终调参结论;最终是否调参必须由你结合任务阻塞、路径评分、规则步长、动态上限和冷却独立判断。\n" +
                            "- 禁止仅凭全局环线空闲、总节点空闲或邻接节点空闲上调参数;上调必须有目标站局部路径压力事实支撑。\n" +
                            "- 当 heuristicDirection=increase_candidate 时,仍必须检查 ruleSnapshot、outBufferCapacity、maxStep 和 cooldown,且不得突破动态上限。\n" +
                            "- 当 heuristicDirection=decrease_candidate 时,允许把参数下调到低于当前已执行/已占用数量;该动作只限制后续新增任务,不取消已下发任务。\n" +
                            "- 当 pressureLevel=high、confidence=low 或 pathErrorCount>0 时,禁止激进上调;路径事实不足时必须说明缺少哪些目标站路径事实。\n\n" +
                            "Step 3 限制可写参数\n" +
                            "提交给 MCP/apply-service 的 target_key 必须使用以下键名,不得提交原始数据库列名作为 CRN/双工位堆垛机参数键:\n" +
                            "- crnOutBatchRunningLimit:对应 sys_config.crnOutBatchRunningLimit\n" +
@@ -169,11 +210,13 @@
                            "注意:asr_bas_station.out_buffer_capacity 是人工维护的出库缓存容量,只用于证明 outTaskLimit 可上调上限,Agent 不允许修改该字段;增大 outTaskLimit 时建议值不得超过对应站点 outBufferCapacity。\n\n" +
                            AUTO_TUNE_RULE_SNAPSHOT_INSTRUCTIONS + "\n\n" +
                            "Step 5 提交变更\n" +
                            "- 先通过 wcs_local_dispatch_apply_auto_tune_changes 执行 dry-run。\n" +
                            "- dry-run 通过后才允许通过同一工具实际应用。\n" +
                            "- 先通过 " + AUTO_TUNE_TOOL_APPLY_CHANGES + " 执行 dry-run。\n" +
                            "- dry-run 返回 success=false、rejectCount>0、changes 为空或全部 resultStatus=no_change 时,必须停止实际应用。\n" +
                            "- 只有 controlModeSnapshot.analysisOnly=false 且 allowApply=true,dry-run 通过、存在有效变更并返回 dryRunToken 时,才允许通过同一工具携带 dryRunToken 实际应用。\n" +
                            "- 如果工具返回拒绝、冷却中、存在活动任务风险或参数不在白名单内,必须停止实际应用。\n\n" +
                            "Step 6 回滚边界\n" +
                            "- 只有当最近一次自动调参被 MCP facts 明确证明造成异常,才允许调用 wcs_local_dispatch_revert_last_auto_tune_job。\n" +
                            "- analysisOnly=true 或 allowApply=false 时禁止 rollback。\n" +
                            "- 只有当允许正式应用,且最近一次自动调参被 MCP facts 明确证明造成异常,才允许调用 " + AUTO_TUNE_TOOL_REVERT_LAST_JOB + "。\n" +
                            "- 不得臆测回滚原因。");
            return blocks;
        }
@@ -312,4 +355,15 @@
    private String localTool(String name) {
        return "wcs_local_" + name;
    }
    private static String safeRuntimeValue(String value) {
        if (value == null || value.trim().isEmpty()) {
            return "unknown";
        }
        return value.trim();
    }
    private static String modeValue(Object value) {
        return value == null ? "unknown" : String.valueOf(value);
    }
}
src/main/java/com/zy/ai/utils/AutoTuneWriteBehaviorUtils.java
New file
@@ -0,0 +1,553 @@
package com.zy.ai.utils;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.zy.ai.entity.AiAutoTuneChange;
import com.zy.ai.entity.AiAutoTuneJob;
import com.zy.ai.entity.AiAutoTuneMcpCall;
import java.util.List;
import java.util.Locale;
import java.util.Map;
public final class AutoTuneWriteBehaviorUtils {
    public static final String WRITE_ANALYSIS_ONLY = "analysis_only";
    public static final String WRITE_DRY_RUN = "dry_run";
    public static final String WRITE_APPLY = "apply";
    public static final String WRITE_ROLLBACK = "rollback";
    public static final String WRITE_NO_CHANGE = "no_change";
    public static final String WRITE_READ_ONLY = "read_only";
    public static final String WRITE_FAILED = "failed";
    public static final String WRITE_REJECTED = "rejected";
    public static final String WRITE_UNKNOWN = "unknown";
    private AutoTuneWriteBehaviorUtils() {
    }
    public static void addWriteBehavior(Map<String, Object> item, String writeBehavior) {
        String safeWriteBehavior = isBlank(writeBehavior) ? WRITE_UNKNOWN : writeBehavior;
        item.put("writeBehavior", safeWriteBehavior);
        item.put("writeBehaviorLabel", writeBehaviorLabel(safeWriteBehavior));
    }
    public static String resolveJobWriteBehavior(AiAutoTuneJob job,
                                                 List<Map<String, Object>> mcpCalls,
                                                 List<Map<String, Object>> changes) {
        if (containsWriteBehavior(mcpCalls, WRITE_ANALYSIS_ONLY)
                || containsWriteBehavior(changes, WRITE_ANALYSIS_ONLY)
                || jobSummaryIndicatesAnalysisOnly(job)) {
            return WRITE_ANALYSIS_ONLY;
        }
        if (jobIndicatesSuccessfulRollback(job)
                || containsWriteBehavior(changes, WRITE_ROLLBACK)
                || hasSuccessfulRollbackCall(mcpCalls, changes)) {
            return WRITE_ROLLBACK;
        }
        if (containsWriteBehavior(mcpCalls, WRITE_APPLY) || containsWriteBehavior(changes, WRITE_APPLY)) {
            return WRITE_APPLY;
        }
        if (jobIndicatesStatus(job, WRITE_FAILED)
                || containsWriteBehavior(mcpCalls, WRITE_FAILED)
                || containsWriteBehavior(changes, WRITE_FAILED)) {
            return WRITE_FAILED;
        }
        if (jobIndicatesStatus(job, WRITE_REJECTED)
                || containsWriteBehavior(mcpCalls, WRITE_REJECTED)
                || containsWriteBehavior(changes, WRITE_REJECTED)) {
            return WRITE_REJECTED;
        }
        if (onlyReadOnlyCalls(mcpCalls, changes)) {
            return WRITE_READ_ONLY;
        }
        if (jobIndicatesNoChange(job, mcpCalls, changes)) {
            return WRITE_NO_CHANGE;
        }
        if (containsWriteBehavior(mcpCalls, WRITE_DRY_RUN) || containsWriteBehavior(changes, WRITE_DRY_RUN)) {
            return WRITE_DRY_RUN;
        }
        return WRITE_UNKNOWN;
    }
    public static String resolveMcpWriteBehavior(AiAutoTuneMcpCall mcpCall) {
        if (mcpCall == null) {
            return WRITE_UNKNOWN;
        }
        String toolName = normalizeLower(mcpCall.getToolName());
        if (isRollbackTool(toolName)) {
            return resolveRollbackMcpWriteBehavior(mcpCall);
        }
        String failedBehavior = failedOrRejectedBehavior(mcpCall.getStatus());
        if (isApplyTool(toolName)) {
            Boolean dryRun = toBoolean(mcpCall.getDryRun());
            if (Boolean.TRUE.equals(dryRun)) {
                JSONObject response = parseJsonObject(mcpCall.getResponseJson());
                if (responseIndicatesAnalysisOnly(response) || textIndicatesAnalysisOnly(mcpCall.getErrorMessage())) {
                    return WRITE_ANALYSIS_ONLY;
                }
                return failedBehavior == null ? WRITE_DRY_RUN : failedBehavior;
            }
            if (!Boolean.FALSE.equals(dryRun)) {
                return failedBehavior == null ? WRITE_UNKNOWN : failedBehavior;
            }
            return resolveRealApplyMcpWriteBehavior(mcpCall);
        }
        if (isReadOnlyTool(toolName)) {
            return failedBehavior == null ? WRITE_READ_ONLY : failedBehavior;
        }
        return WRITE_UNKNOWN;
    }
    public static String resolveChangeWriteBehavior(AiAutoTuneChange change) {
        return resolveChangeWriteBehavior(change, null);
    }
    public static String resolveChangeWriteBehavior(AiAutoTuneChange change, String ownerTriggerType) {
        if (change == null) {
            return WRITE_UNKNOWN;
        }
        String resultStatus = normalizeLower(change.getResultStatus());
        if (WRITE_DRY_RUN.equals(resultStatus)) {
            return WRITE_DRY_RUN;
        }
        if ("success".equals(resultStatus)) {
            return isRollbackOwnerTriggerType(ownerTriggerType) ? WRITE_ROLLBACK : WRITE_APPLY;
        }
        if (WRITE_NO_CHANGE.equals(resultStatus)) {
            return WRITE_NO_CHANGE;
        }
        if ("rejected".equals(resultStatus) && textIndicatesAnalysisOnly(change.getRejectReason())) {
            return WRITE_ANALYSIS_ONLY;
        }
        if (WRITE_REJECTED.equals(resultStatus)) {
            return WRITE_REJECTED;
        }
        if (WRITE_FAILED.equals(resultStatus)) {
            return WRITE_FAILED;
        }
        return WRITE_UNKNOWN;
    }
    public static String writeBehaviorLabel(String writeBehavior) {
        if (WRITE_ANALYSIS_ONLY.equals(writeBehavior)) {
            return "仅分析";
        }
        if (WRITE_DRY_RUN.equals(writeBehavior)) {
            return "试算";
        }
        if (WRITE_APPLY.equals(writeBehavior)) {
            return "正式写入";
        }
        if (WRITE_ROLLBACK.equals(writeBehavior)) {
            return "回滚";
        }
        if (WRITE_NO_CHANGE.equals(writeBehavior)) {
            return "无变更";
        }
        if (WRITE_READ_ONLY.equals(writeBehavior)) {
            return "只读";
        }
        if (WRITE_FAILED.equals(writeBehavior)) {
            return "失败";
        }
        if (WRITE_REJECTED.equals(writeBehavior)) {
            return "已拒绝";
        }
        return "未知";
    }
    private static boolean hasSuccessfulRollbackCall(List<Map<String, Object>> mcpCalls,
                                                     List<Map<String, Object>> changes) {
        if (mcpCalls == null || mcpCalls.isEmpty()) {
            return false;
        }
        for (Map<String, Object> mcpCall : mcpCalls) {
            if (!WRITE_ROLLBACK.equals(textValue(mcpCall.get("writeBehavior")))) {
                continue;
            }
            if (isSuccessStatus(mcpCall.get("status")) && rollbackHasAppliedChange(mcpCall, changes)) {
                return true;
            }
        }
        return false;
    }
    private static boolean rollbackHasAppliedChange(Map<String, Object> mcpCall,
                                                    List<Map<String, Object>> changes) {
        JSONObject response = parseJsonObject(textValue(mcpCall.get("responseJson")));
        if (isSuccessStatus(mcpCall.get("status")) && safeCount(mcpCall.get("successCount")) > 0) {
            return true;
        }
        return responseIndicatesSuccessfulChange(response) || changesContainSuccessfulWrite(changes);
    }
    private static boolean containsWriteBehavior(List<Map<String, Object>> items, String writeBehavior) {
        if (items == null || items.isEmpty()) {
            return false;
        }
        for (Map<String, Object> item : items) {
            if (item != null && writeBehavior.equals(textValue(item.get("writeBehavior")))) {
                return true;
            }
        }
        return false;
    }
    private static boolean onlyReadOnlyCalls(List<Map<String, Object>> mcpCalls,
                                             List<Map<String, Object>> changes) {
        if (mcpCalls == null || mcpCalls.isEmpty() || (changes != null && !changes.isEmpty())) {
            return false;
        }
        for (Map<String, Object> mcpCall : mcpCalls) {
            if (!WRITE_READ_ONLY.equals(textValue(mcpCall.get("writeBehavior")))) {
                return false;
            }
        }
        return true;
    }
    private static boolean jobIndicatesNoChange(AiAutoTuneJob job,
                                                List<Map<String, Object>> mcpCalls,
                                                List<Map<String, Object>> changes) {
        if (job != null && WRITE_NO_CHANGE.equals(normalizeLower(job.getStatus()))) {
            return true;
        }
        if (allChangesAreNoChange(changes)) {
            return true;
        }
        return containsWriteBehavior(mcpCalls, WRITE_NO_CHANGE);
    }
    private static boolean jobIndicatesStatus(AiAutoTuneJob job, String status) {
        return job != null && status.equals(normalizeLower(job.getStatus()));
    }
    private static boolean jobIndicatesSuccessfulRollback(AiAutoTuneJob job) {
        if (job == null || !isRollbackOwnerTriggerType(job.getTriggerType())) {
            return false;
        }
        return isSuccessStatus(job.getStatus()) && safeCount(job.getSuccessCount()) > 0;
    }
    private static boolean allChangesAreNoChange(List<Map<String, Object>> changes) {
        if (changes == null || changes.isEmpty()) {
            return false;
        }
        for (Map<String, Object> change : changes) {
            if (!WRITE_NO_CHANGE.equals(textValue(change.get("writeBehavior")))) {
                return false;
            }
        }
        return true;
    }
    private static boolean jobSummaryIndicatesAnalysisOnly(AiAutoTuneJob job) {
        if (job == null) {
            return false;
        }
        return textIndicatesAnalysisOnly(job.getSummary()) || textIndicatesAnalysisOnly(job.getErrorMessage());
    }
    private static String resolveRealApplyMcpWriteBehavior(AiAutoTuneMcpCall mcpCall) {
        JSONObject response = parseJsonObject(mcpCall.getResponseJson());
        if (responseIndicatesAnalysisOnly(response) || textIndicatesAnalysisOnly(mcpCall.getErrorMessage())) {
            return WRITE_ANALYSIS_ONLY;
        }
        if (hasSuccessfulApplyChange(mcpCall, response)) {
            return WRITE_APPLY;
        }
        String failedBehavior = failedOrRejectedBehavior(mcpCall.getStatus());
        if (failedBehavior != null) {
            return failedBehavior;
        }
        if (responseIndicatesNoChange(response)) {
            return WRITE_NO_CHANGE;
        }
        if (!isSuccessStatus(mcpCall.getStatus())) {
            return WRITE_UNKNOWN;
        }
        return WRITE_UNKNOWN;
    }
    private static String resolveRollbackMcpWriteBehavior(AiAutoTuneMcpCall mcpCall) {
        JSONObject response = parseJsonObject(mcpCall.getResponseJson());
        if (responseIndicatesAnalysisOnly(response) || textIndicatesAnalysisOnly(mcpCall.getErrorMessage())) {
            return WRITE_ANALYSIS_ONLY;
        }
        if (hasSuccessfulRollbackChange(mcpCall, response)) {
            return WRITE_ROLLBACK;
        }
        String failedBehavior = failedOrRejectedBehavior(mcpCall.getStatus());
        if (failedBehavior != null) {
            return failedBehavior;
        }
        if (responseIndicatesNoChange(response)) {
            return WRITE_NO_CHANGE;
        }
        return WRITE_UNKNOWN;
    }
    private static boolean hasSuccessfulApplyChange(AiAutoTuneMcpCall mcpCall, JSONObject response) {
        if (responseIndicatesSuccessfulChange(response)) {
            return true;
        }
        if (responseHasChangeDetails(response)) {
            return false;
        }
        return isSuccessStatus(mcpCall.getStatus()) && safeCount(mcpCall.getSuccessCount()) > 0;
    }
    private static boolean hasSuccessfulRollbackChange(AiAutoTuneMcpCall mcpCall, JSONObject response) {
        if (responseIndicatesSuccessfulChange(response)) {
            return true;
        }
        if (responseHasChangeDetails(response)) {
            return false;
        }
        return isSuccessStatus(mcpCall.getStatus()) && safeCount(mcpCall.getSuccessCount()) > 0;
    }
    private static String failedOrRejectedBehavior(Object status) {
        String normalizedStatus = normalizeLower(textValue(status));
        if (WRITE_FAILED.equals(normalizedStatus)) {
            return WRITE_FAILED;
        }
        if (WRITE_REJECTED.equals(normalizedStatus)) {
            return WRITE_REJECTED;
        }
        return null;
    }
    private static boolean isReadOnlyTool(String toolName) {
        return toolName.contains("get_auto_tune_snapshot") || toolName.contains("get_recent_auto_tune_jobs");
    }
    private static boolean isApplyTool(String toolName) {
        return toolName.contains("apply_auto_tune_changes");
    }
    private static boolean isRollbackTool(String toolName) {
        return toolName.contains("revert_last_auto_tune_job") || toolName.contains("rollback");
    }
    private static boolean responseIndicatesAnalysisOnly(JSONObject response) {
        if (response == null) {
            return false;
        }
        if (isTrue(response.get("analysisOnly")) || textIndicatesAnalysisOnly(textValue(response.get("summary")))) {
            return true;
        }
        return changesContainAnalysisOnly(response.get("changes"));
    }
    private static boolean responseIndicatesNoChange(JSONObject response) {
        if (response == null) {
            return false;
        }
        Object changes = response.get("changes");
        if (hasChangeDetails(changes)) {
            return allResponseChangesHaveStatus(changes, WRITE_NO_CHANGE);
        }
        if (isTrue(response.get("noApply"))) {
            return true;
        }
        return isTrue(response.get("success"))
                && safeCount(response.get("successCount")) == 0
                && safeCount(response.get("rejectCount")) == 0;
    }
    private static boolean responseIndicatesSuccessfulChange(JSONObject response) {
        if (response == null) {
            return false;
        }
        Object changes = response.get("changes");
        if (hasChangeDetails(changes)) {
            return responseChangesContainStatus(changes, "success");
        }
        return safeCount(response.get("successCount")) > 0;
    }
    private static boolean responseHasChangeDetails(JSONObject response) {
        return response != null && hasChangeDetails(response.get("changes"));
    }
    private static boolean hasChangeDetails(Object changes) {
        return changes instanceof List<?> && !((List<?>) changes).isEmpty();
    }
    private static boolean changesContainSuccessfulWrite(List<Map<String, Object>> changes) {
        if (changes == null || changes.isEmpty()) {
            return false;
        }
        for (Map<String, Object> change : changes) {
            if (change == null) {
                continue;
            }
            String writeBehavior = textValue(change.get("writeBehavior"));
            if (WRITE_APPLY.equals(writeBehavior) || WRITE_ROLLBACK.equals(writeBehavior)) {
                return true;
            }
            if ("success".equals(normalizeLower(textValue(change.get("resultStatus"))))) {
                return true;
            }
        }
        return false;
    }
    private static boolean changesContainAnalysisOnly(Object changes) {
        if (!(changes instanceof List<?>)) {
            return false;
        }
        for (Object change : (List<?>) changes) {
            if (!(change instanceof Map<?, ?>)) {
                continue;
            }
            Map<?, ?> changeMap = (Map<?, ?>) change;
            if (textIndicatesAnalysisOnly(textValue(changeMap.get("rejectReason")))) {
                return true;
            }
        }
        return false;
    }
    private static boolean allResponseChangesHaveStatus(Object changes, String status) {
        if (!(changes instanceof List<?>)) {
            return false;
        }
        List<?> changeList = (List<?>) changes;
        if (changeList.isEmpty()) {
            return false;
        }
        for (Object change : changeList) {
            if (!(change instanceof Map<?, ?>)) {
                return false;
            }
            Map<?, ?> changeMap = (Map<?, ?>) change;
            if (!status.equals(normalizeLower(textValue(changeMap.get("resultStatus"))))) {
                return false;
            }
        }
        return true;
    }
    private static boolean responseChangesContainStatus(Object changes, String status) {
        if (!(changes instanceof List<?>)) {
            return false;
        }
        for (Object change : (List<?>) changes) {
            if (!(change instanceof Map<?, ?>)) {
                continue;
            }
            Map<?, ?> changeMap = (Map<?, ?>) change;
            if (status.equals(normalizeLower(textValue(changeMap.get("resultStatus"))))) {
                return true;
            }
        }
        return false;
    }
    private static JSONObject parseJsonObject(String json) {
        if (isBlank(json)) {
            return null;
        }
        try {
            return JSON.parseObject(json);
        } catch (RuntimeException ignore) {
            return null;
        }
    }
    private static boolean textIndicatesAnalysisOnly(String text) {
        if (isBlank(text)) {
            return false;
        }
        String normalizedText = text.toLowerCase(Locale.ROOT);
        return normalizedText.contains("仅分析")
                || textContainsPositiveFlag(normalizedText, "analysisonly")
                || textContainsPositiveFlag(normalizedText, "analysis_only")
                || textContainsPositiveFlag(normalizedText, "noapply")
                || textContainsPositiveFlag(normalizedText, "no_apply")
                || normalizedText.contains("禁止实际应用");
    }
    private static boolean textContainsPositiveFlag(String text, String flag) {
        int matchIndex = text.indexOf(flag);
        while (matchIndex >= 0) {
            int valueStart = skipFlagSeparators(text, matchIndex + flag.length());
            if (startsWith(text, valueStart, "false") || startsWith(text, valueStart, "0")) {
                matchIndex = text.indexOf(flag, matchIndex + flag.length());
                continue;
            }
            return true;
        }
        return false;
    }
    private static int skipFlagSeparators(String text, int startIndex) {
        int index = startIndex;
        while (index < text.length()) {
            char character = text.charAt(index);
            if (!Character.isWhitespace(character)
                    && character != '='
                    && character != ':'
                    && character != '"'
                    && character != '\'') {
                break;
            }
            index++;
        }
        return index;
    }
    private static boolean startsWith(String text, int startIndex, String prefix) {
        return startIndex <= text.length() && text.startsWith(prefix, startIndex);
    }
    private static boolean isRollbackOwnerTriggerType(String ownerTriggerType) {
        return WRITE_ROLLBACK.equals(normalizeLower(ownerTriggerType));
    }
    private static Boolean toBoolean(Integer value) {
        if (value == null) {
            return null;
        }
        return value == 1;
    }
    private static boolean isSuccessStatus(Object status) {
        return "success".equals(normalizeLower(textValue(status)));
    }
    private static boolean isTrue(Object value) {
        if (value instanceof Boolean) {
            return Boolean.TRUE.equals(value);
        }
        return "true".equals(normalizeLower(textValue(value))) || "1".equals(textValue(value));
    }
    private static int safeCount(Object value) {
        if (value instanceof Number) {
            return ((Number) value).intValue();
        }
        if (value == null) {
            return 0;
        }
        try {
            return Integer.parseInt(String.valueOf(value));
        } catch (NumberFormatException ignore) {
            return 0;
        }
    }
    private static String normalizeLower(String value) {
        return isBlank(value) ? "" : value.trim().toLowerCase(Locale.ROOT);
    }
    private static String textValue(Object value) {
        return value == null ? "" : String.valueOf(value);
    }
    private static boolean isBlank(String value) {
        return value == null || value.trim().isEmpty();
    }
}
src/main/resources/sql/20260428_ai_auto_tune_consolidated.sql
@@ -1,7 +1,7 @@
-- AI自动调参合并增量脚本
-- 合并范围:2026-04-27 至 2026-04-28 自动调参相关 SQL 变更。
-- 执行说明:在目标库中执行本文件一次即可;脚本按幂等方式编写,已存在对象会跳过或更新。
-- 不包含已废弃的 asr_station_flow_capacity 建表脚本;如历史库存在该表,会迁移 OUT 容量到 asr_bas_station.out_buffer_capacity 后删除旧表。
-- 合并范围:2026-04-27 至 2026-04-28 自动调参与 OpenAI Responses 路由相关 SQL 变更。
-- 执行说明:执行前请自行清理已创建的自动调参表和相关初始化数据;本文件保留自动调参最终结构,并补齐 AI 路由运行必需字段。
-- 不包含已废弃的 asr_station_flow_capacity 建表脚本。
-- 来源脚本:
-- - 20260427_create_ai_auto_tune_tables.sql
-- - 20260427_add_ai_auto_tune_sys_configs.sql
@@ -9,6 +9,7 @@
-- - 20260427_add_ai_auto_tune_console_menu.sql
-- - 20260427_update_auto_tune_prompt_out_buffer_capacity.sql
-- - 20260427_update_auto_tune_prompt_rule_snapshot.sql
-- - 20260428_extend_sys_llm_route_protocol.sql
-- 1. AI自动调参审计表
CREATE TABLE IF NOT EXISTS `sys_ai_auto_tune_job` (
@@ -81,16 +82,9 @@
  KEY `idx_sys_ai_auto_tune_mcp_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI自动调参MCP调用明细表';
ALTER TABLE `sys_ai_auto_tune_job`
  MODIFY COLUMN `summary` MEDIUMTEXT COMMENT '执行摘要',
  MODIFY COLUMN `error_message` MEDIUMTEXT COMMENT '错误信息';
ALTER TABLE `sys_ai_auto_tune_change`
  MODIFY COLUMN `reject_reason` MEDIUMTEXT COMMENT '拒绝原因';
-- 2. AI自动调参系统配置
INSERT INTO sys_config(name, code, value, type, status, select_type)
SELECT 'AI自动调参开关', 'aiAutoTuneEnabled', 'N', 1, 1, 'system'
SELECT 'AI自动调参开关', 'aiAutoTuneEnabled', 'N', 1, 1, 'Agent'
FROM dual
WHERE NOT EXISTS (
    SELECT 1
@@ -99,7 +93,7 @@
);
INSERT INTO sys_config(name, code, value, type, status, select_type)
SELECT 'AI自动调参间隔(分钟)', 'aiAutoTuneIntervalMinutes', '10', 1, 1, 'system'
SELECT 'AI自动调参间隔(分钟)', 'aiAutoTuneIntervalMinutes', '10', 1, 1, 'Agent'
FROM dual
WHERE NOT EXISTS (
    SELECT 1
@@ -108,7 +102,7 @@
);
INSERT INTO sys_config(name, code, value, type, status, select_type)
SELECT 'AI自动调参Prompt日志保留上限', 'aiAutoTunePromptLogLimit', '500', 1, 1, 'system'
SELECT 'AI自动调参Prompt日志保留上限', 'aiAutoTunePromptLogLimit', '500', 1, 1, 'Agent'
FROM dual
WHERE NOT EXISTS (
    SELECT 1
@@ -117,12 +111,80 @@
);
INSERT INTO sys_config(name, code, value, type, status, select_type)
SELECT '输送站出库任务全局上限', 'conveyorStationTaskLimit', '30', 1, 1, 'system'
SELECT 'AI自动调参仅分析模式', 'aiAutoTuneAnalysisOnly', 'Y', 1, 1, 'Agent'
FROM dual
WHERE NOT EXISTS (
    SELECT 1
    FROM sys_config
    WHERE code = 'aiAutoTuneAnalysisOnly'
);
INSERT INTO sys_config(name, code, value, type, status, select_type)
SELECT '输送站出库任务全局上限', 'conveyorStationTaskLimit', '30', 1, 1, 'Agent'
FROM dual
WHERE NOT EXISTS (
    SELECT 1
    FROM sys_config
    WHERE code = 'conveyorStationTaskLimit'
);
INSERT INTO sys_config(name, code, value, type, status, select_type)
SELECT 'AI调参路径压力切段窗口', 'aiAutoTuneRoutePressureSegmentWindowSize', '4', 1, 1, 'Agent'
FROM dual
WHERE NOT EXISTS (SELECT 1 FROM sys_config WHERE code = 'aiAutoTuneRoutePressureSegmentWindowSize');
INSERT INTO sys_config(name, code, value, type, status, select_type)
SELECT 'AI调参路径压力中等阈值(%)', 'aiAutoTuneRoutePressureMediumPercent', '50', 1, 1, 'Agent'
FROM dual
WHERE NOT EXISTS (SELECT 1 FROM sys_config WHERE code = 'aiAutoTuneRoutePressureMediumPercent');
INSERT INTO sys_config(name, code, value, type, status, select_type)
SELECT 'AI调参路径压力高阈值(%)', 'aiAutoTuneRoutePressureHighPercent', '75', 1, 1, 'Agent'
FROM dual
WHERE NOT EXISTS (SELECT 1 FROM sys_config WHERE code = 'aiAutoTuneRoutePressureHighPercent');
INSERT INTO sys_config(name, code, value, type, status, select_type)
SELECT 'AI调参路径任务集中度权重(%)', 'aiAutoTuneRoutePressurePassWeightPercent', '35', 1, 1, 'Agent'
FROM dual
WHERE NOT EXISTS (SELECT 1 FROM sys_config WHERE code = 'aiAutoTuneRoutePressurePassWeightPercent');
INSERT INTO sys_config(name, code, value, type, status, select_type)
SELECT 'AI调参路径占用权重(%)', 'aiAutoTuneRoutePressureOccupiedWeightPercent', '25', 1, 1, 'Agent'
FROM dual
WHERE NOT EXISTS (SELECT 1 FROM sys_config WHERE code = 'aiAutoTuneRoutePressureOccupiedWeightPercent');
INSERT INTO sys_config(name, code, value, type, status, select_type)
SELECT 'AI调参目标站阻塞权重(%)', 'aiAutoTuneRoutePressureBlockedWeightPercent', '20', 1, 1, 'Agent'
FROM dual
WHERE NOT EXISTS (SELECT 1 FROM sys_config WHERE code = 'aiAutoTuneRoutePressureBlockedWeightPercent');
INSERT INTO sys_config(name, code, value, type, status, select_type)
SELECT 'AI调参路径非自动权重(%)', 'aiAutoTuneRoutePressureNonAutoingWeightPercent', '10', 1, 1, 'Agent'
FROM dual
WHERE NOT EXISTS (SELECT 1 FROM sys_config WHERE code = 'aiAutoTuneRoutePressureNonAutoingWeightPercent');
INSERT INTO sys_config(name, code, value, type, status, select_type)
SELECT 'AI调参路径runBlock权重(%)', 'aiAutoTuneRoutePressureRunBlockWeightPercent', '10', 1, 1, 'Agent'
FROM dual
WHERE NOT EXISTS (SELECT 1 FROM sys_config WHERE code = 'aiAutoTuneRoutePressureRunBlockWeightPercent');
UPDATE sys_config
SET select_type = 'Agent'
WHERE code IN (
    'aiAutoTuneAnalysisOnly',
    'aiAutoTuneEnabled',
    'aiAutoTuneIntervalMinutes',
    'aiAutoTunePromptLogLimit',
    'aiAutoTuneRoutePressureBlockedWeightPercent',
    'aiAutoTuneRoutePressureHighPercent',
    'aiAutoTuneRoutePressureMediumPercent',
    'aiAutoTuneRoutePressureNonAutoingWeightPercent',
    'aiAutoTuneRoutePressureOccupiedWeightPercent',
    'aiAutoTuneRoutePressurePassWeightPercent',
    'aiAutoTuneRoutePressureRunBlockWeightPercent',
    'aiAutoTuneRoutePressureSegmentWindowSize',
    'conveyorStationTaskLimit',
    'crnOutBatchRunningLimit'
);
-- 3. asr_bas_station 增加出库缓存容量配置,替代 asr_station_flow_capacity
@@ -144,37 +206,6 @@
PREPARE stmt_out_buffer_capacity FROM @add_out_buffer_capacity_sql;
EXECUTE stmt_out_buffer_capacity;
DEALLOCATE PREPARE stmt_out_buffer_capacity;
SET @station_flow_capacity_exists := (
  SELECT COUNT(1)
  FROM information_schema.TABLES
  WHERE TABLE_SCHEMA = @current_db
    AND TABLE_NAME = 'asr_station_flow_capacity'
);
SET @migrate_station_flow_capacity_sql := IF(
  @station_flow_capacity_exists > 0,
  'UPDATE asr_bas_station station
     JOIN asr_station_flow_capacity capacity
       ON capacity.station_id = station.station_id
      AND capacity.direction_code = ''OUT''
      AND capacity.buffer_capacity IS NOT NULL
      SET station.out_buffer_capacity = capacity.buffer_capacity
    WHERE station.out_buffer_capacity IS NULL',
  'SELECT ''table asr_station_flow_capacity not exists'' '
);
PREPARE stmt_migrate_station_flow_capacity FROM @migrate_station_flow_capacity_sql;
EXECUTE stmt_migrate_station_flow_capacity;
DEALLOCATE PREPARE stmt_migrate_station_flow_capacity;
SET @drop_station_flow_capacity_sql := IF(
  @station_flow_capacity_exists > 0,
  'DROP TABLE asr_station_flow_capacity',
  'SELECT ''table asr_station_flow_capacity already removed'' '
);
PREPARE stmt_drop_station_flow_capacity FROM @drop_station_flow_capacity_sql;
EXECUTE stmt_drop_station_flow_capacity;
DEALLOCATE PREPARE stmt_drop_station_flow_capacity;
-- 4. AI自动调参控制台菜单
-- 执行后请在“角色授权”里给对应角色勾选 AI管理 -> AI自动调参控制台。
@@ -239,53 +270,165 @@
    status = 1
WHERE code = 'ai/auto_tune.html#view' AND level = 3;
-- 5. 历史已发布 Prompt 补丁:outBufferCapacity 约束说明
UPDATE sys_ai_prompt_block block
JOIN sys_ai_prompt_template template ON template.id = block.template_id
SET block.content = CONCAT(
    block.content,
    '\n注意:asr_bas_station.out_buffer_capacity 是人工维护的出库缓存容量,只用于证明 outTaskLimit 可上调上限,Agent 不允许修改该字段;增大 outTaskLimit 时建议值不得超过对应站点 outBufferCapacity。'
)
WHERE template.scene_code = 'wcs_auto_tune_dispatch'
  AND block.block_type = 'scene_playbook'
  AND block.content NOT LIKE '%out_buffer_capacity 是人工维护的出库缓存容量%';
-- 5. OpenAI Responses 兼容路由字段补齐
-- 当前运行库可能已经存在旧版 sys_llm_route 表,必须补齐实体和 Mapper 运行所需字段。
SET @llm_route_provider_type_exists := (
  SELECT COUNT(1)
  FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA = @current_db
    AND TABLE_NAME = 'sys_llm_route'
    AND COLUMN_NAME = 'provider_type'
);
-- 6. 历史已发布 Prompt 补丁:ruleSnapshot 规则说明
-- 追加文本需与 AiPromptUtils 的默认规则说明保持一致。
UPDATE sys_ai_prompt_block block
JOIN sys_ai_prompt_template template ON template.id = block.template_id
SET block.content = CONCAT(
    block.content,
    '\nStep 4 读取调参规则\n- 必须读取 snapshot.ruleSnapshot 中的 minValue、maxValue、maxStep、cooldownMinutes、dynamicMaxValue 和 dynamicMaxSource。\n- 提交给 wcs_local_dispatch_apply_auto_tune_changes 的每个 change 都必须匹配对应 targetType/targetKey 的规则。\n- 每个目标参数的新值必须满足对应 minValue、maxValue 或 dynamicMaxValue、maxStep、cooldownMinutes 和规则 note;找不到规则或无法证明动态上限时禁止提交。'
)
WHERE template.scene_code = 'wcs_auto_tune_dispatch'
  AND block.block_type = 'scene_playbook'
  AND block.content NOT LIKE '%snapshot.ruleSnapshot%';
SET @add_llm_route_provider_type_sql := IF(
  @llm_route_provider_type_exists = 0,
  'ALTER TABLE sys_llm_route ADD COLUMN provider_type VARCHAR(64) NOT NULL DEFAULT ''OPENAI_COMPATIBLE'' COMMENT ''提供商类型: OPENAI_COMPATIBLE/OPENAI/AZURE等'' AFTER name',
  'SELECT ''column provider_type already exists'' '
);
PREPARE stmt_llm_route_alter FROM @add_llm_route_provider_type_sql;
EXECUTE stmt_llm_route_alter;
DEALLOCATE PREPARE stmt_llm_route_alter;
-- 7. 历史已发布 Prompt 补丁:出库任务阻塞明细分析说明
UPDATE sys_ai_prompt_block block
JOIN sys_ai_prompt_template template ON template.id = block.template_id
SET block.content = CONCAT(
    block.content,
    '\nStep 2.1 分析出库任务阻塞\n- 必须检查 taskSnapshot.stationLimitBlockedTasks 和 taskSnapshot.outboundTaskSamples。\n- 如果同一站点/同一批次中,较小 batchSeq 的 NEW_OUTBOUND 任务因 systemMsg 显示“出库任务上限”被挡住,而较大 batchSeq 已进入 STATION_RUN,说明当前上限可能造成出库排队或顺序异常,应优先评估 outTaskLimit、maxOutTask、crnOutBatchRunningLimit 等相关规则允许的上调空间。'
)
WHERE template.scene_code = 'wcs_auto_tune_dispatch'
  AND block.block_type = 'scene_playbook'
  AND block.content NOT LIKE '%taskSnapshot.stationLimitBlockedTasks%';
SET @llm_route_protocol_type_exists := (
  SELECT COUNT(1)
  FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA = @current_db
    AND TABLE_NAME = 'sys_llm_route'
    AND COLUMN_NAME = 'protocol_type'
);
-- 8. 执行后检查
SET @add_llm_route_protocol_type_sql := IF(
  @llm_route_protocol_type_exists = 0,
  'ALTER TABLE sys_llm_route ADD COLUMN protocol_type VARCHAR(64) NOT NULL DEFAULT ''OPENAI_CHAT_COMPLETIONS'' COMMENT ''接口协议: OPENAI_CHAT_COMPLETIONS/OPENAI_RESPONSES'' AFTER provider_type',
  'SELECT ''column protocol_type already exists'' '
);
PREPARE stmt_llm_route_alter FROM @add_llm_route_protocol_type_sql;
EXECUTE stmt_llm_route_alter;
DEALLOCATE PREPARE stmt_llm_route_alter;
SET @llm_route_endpoint_path_exists := (
  SELECT COUNT(1)
  FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA = @current_db
    AND TABLE_NAME = 'sys_llm_route'
    AND COLUMN_NAME = 'endpoint_path'
);
SET @add_llm_route_endpoint_path_sql := IF(
  @llm_route_endpoint_path_exists = 0,
  'ALTER TABLE sys_llm_route ADD COLUMN endpoint_path VARCHAR(255) DEFAULT NULL COMMENT ''接口端点路径,如 /v1/chat/completions 或 /v1/responses'' AFTER base_url',
  'SELECT ''column endpoint_path already exists'' '
);
PREPARE stmt_llm_route_alter FROM @add_llm_route_endpoint_path_sql;
EXECUTE stmt_llm_route_alter;
DEALLOCATE PREPARE stmt_llm_route_alter;
SET @llm_route_auth_type_exists := (
  SELECT COUNT(1)
  FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA = @current_db
    AND TABLE_NAME = 'sys_llm_route'
    AND COLUMN_NAME = 'auth_type'
);
SET @add_llm_route_auth_type_sql := IF(
  @llm_route_auth_type_exists = 0,
  'ALTER TABLE sys_llm_route ADD COLUMN auth_type VARCHAR(32) NOT NULL DEFAULT ''BEARER'' COMMENT ''认证方式: BEARER/API_KEY_HEADER/NONE'' AFTER api_key',
  'SELECT ''column auth_type already exists'' '
);
PREPARE stmt_llm_route_alter FROM @add_llm_route_auth_type_sql;
EXECUTE stmt_llm_route_alter;
DEALLOCATE PREPARE stmt_llm_route_alter;
SET @llm_route_auth_header_name_exists := (
  SELECT COUNT(1)
  FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA = @current_db
    AND TABLE_NAME = 'sys_llm_route'
    AND COLUMN_NAME = 'auth_header_name'
);
SET @add_llm_route_auth_header_name_sql := IF(
  @llm_route_auth_header_name_exists = 0,
  'ALTER TABLE sys_llm_route ADD COLUMN auth_header_name VARCHAR(128) DEFAULT NULL COMMENT ''API_KEY_HEADER 模式下的请求头名称'' AFTER auth_type',
  'SELECT ''column auth_header_name already exists'' '
);
PREPARE stmt_llm_route_alter FROM @add_llm_route_auth_header_name_sql;
EXECUTE stmt_llm_route_alter;
DEALLOCATE PREPARE stmt_llm_route_alter;
SET @llm_route_capabilities_exists := (
  SELECT COUNT(1)
  FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA = @current_db
    AND TABLE_NAME = 'sys_llm_route'
    AND COLUMN_NAME = 'capabilities'
);
SET @add_llm_route_capabilities_sql := IF(
  @llm_route_capabilities_exists = 0,
  'ALTER TABLE sys_llm_route ADD COLUMN capabilities VARCHAR(255) DEFAULT NULL COMMENT ''模型能力标记,逗号分隔,如 tools,reasoning'' AFTER model',
  'SELECT ''column capabilities already exists'' '
);
PREPARE stmt_llm_route_alter FROM @add_llm_route_capabilities_sql;
EXECUTE stmt_llm_route_alter;
DEALLOCATE PREPARE stmt_llm_route_alter;
SET @llm_route_request_options_exists := (
  SELECT COUNT(1)
  FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA = @current_db
    AND TABLE_NAME = 'sys_llm_route'
    AND COLUMN_NAME = 'request_options'
);
SET @add_llm_route_request_options_sql := IF(
  @llm_route_request_options_exists = 0,
  'ALTER TABLE sys_llm_route ADD COLUMN request_options TEXT DEFAULT NULL COMMENT ''接口请求扩展参数JSON'' AFTER capabilities',
  'SELECT ''column request_options already exists'' '
);
PREPARE stmt_llm_route_alter FROM @add_llm_route_request_options_sql;
EXECUTE stmt_llm_route_alter;
DEALLOCATE PREPARE stmt_llm_route_alter;
-- 6. 说明
-- 自动调参 Prompt 最终态由 AiPromptTemplateInitializer 在缺失时按 AiPromptUtils 默认分段初始化,
-- clean 数据库无需 SQL 历史兼容 UPDATE/REPLACE。
-- 7. 执行后检查
SELECT id, name, code, value, type, status, select_type
FROM sys_config
WHERE code IN (
    'aiAutoTuneAnalysisOnly',
    'aiAutoTuneEnabled',
    'aiAutoTuneIntervalMinutes',
    'aiAutoTunePromptLogLimit',
    'conveyorStationTaskLimit'
    'aiAutoTuneRoutePressureBlockedWeightPercent',
    'aiAutoTuneRoutePressureHighPercent',
    'aiAutoTuneRoutePressureMediumPercent',
    'aiAutoTuneRoutePressureNonAutoingWeightPercent',
    'aiAutoTuneRoutePressureOccupiedWeightPercent',
    'aiAutoTuneRoutePressurePassWeightPercent',
    'aiAutoTuneRoutePressureRunBlockWeightPercent',
    'aiAutoTuneRoutePressureSegmentWindowSize',
    'conveyorStationTaskLimit',
    'crnOutBatchRunningLimit'
)
ORDER BY code;
SHOW COLUMNS FROM asr_bas_station LIKE 'out_buffer_capacity';
SHOW COLUMNS FROM sys_llm_route
WHERE Field IN (
  'provider_type',
  'protocol_type',
  'endpoint_path',
  'auth_type',
  'auth_header_name',
  'capabilities',
  'request_options'
);
SELECT id, code, name, resource_id, level, sort, status
FROM sys_resource
WHERE code IN (
src/main/resources/sql/20260428_extend_sys_llm_route_protocol.sql
File was deleted
src/main/webapp/views/ai/auto_tune.html
@@ -62,7 +62,7 @@
    .summary-grid {
      margin-top: 12px;
      display: grid;
      grid-template-columns: repeat(6, minmax(0, 1fr));
      grid-template-columns: repeat(7, minmax(0, 1fr));
      gap: 10px;
    }
    .summary-card {
@@ -94,7 +94,7 @@
    .layout {
      margin-top: 12px;
      display: grid;
      grid-template-columns: minmax(360px, 0.9fr) minmax(520px, 1.4fr);
      grid-template-columns: minmax(460px, 1.05fr) minmax(560px, 1.25fr);
      gap: 12px;
    }
    .panel {
@@ -159,6 +159,21 @@
      font-size: 22px;
      font-weight: 720;
    }
    .route-pressure-metrics {
      display: grid;
      grid-template-columns: repeat(4, minmax(0, 1fr));
      gap: 8px;
      margin-bottom: 12px;
    }
    .route-pressure-section + .route-pressure-section {
      margin-top: 12px;
    }
    .route-pressure-section-title {
      color: #61748a;
      font-size: 12px;
      font-weight: 700;
      margin-bottom: 8px;
    }
    .map-list {
      margin-top: 10px;
      display: grid;
@@ -183,12 +198,21 @@
      color: #8b9ab0;
      font-weight: 500;
    }
    .map-title-row {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 8px;
    }
    .pill-row {
      display: flex;
      flex-wrap: wrap;
      gap: 6px;
      max-height: 120px;
      overflow: auto;
    }
    .pill-row-expanded {
      max-height: 168px;
    }
    .kv-pill {
      border-radius: 999px;
@@ -202,6 +226,79 @@
    .kv-pill .code-name {
      color: #7d8da2;
      margin-left: 3px;
    }
    .insight-stack {
      display: grid;
      gap: 10px;
      margin-top: 10px;
    }
    .wide-map-box {
      min-height: auto;
    }
    .distribution-grid {
      display: grid;
      grid-template-columns: repeat(2, minmax(0, 1fr));
      gap: 10px;
    }
    .distribution-section {
      min-width: 0;
      border-radius: 10px;
      border: 1px solid #edf2f7;
      background: #fff;
      padding: 8px;
    }
    .distribution-label {
      margin-bottom: 6px;
      color: #7a8aa0;
      font-size: 12px;
      font-weight: 700;
    }
    .rule-table-wrap {
      max-height: 260px;
      overflow: auto;
      border: 1px solid #edf2f7;
      border-radius: 10px;
    }
    .rule-table {
      width: 100%;
      border-collapse: separate;
      border-spacing: 0;
      font-size: 12px;
      color: #42566f;
    }
    .rule-table th {
      position: sticky;
      top: 0;
      z-index: 1;
      background: #f7f9fc;
      color: #2e3a4d;
      font-weight: 700;
      text-align: left;
    }
    .rule-table th,
    .rule-table td {
      padding: 8px 10px;
      border-bottom: 1px solid #edf2f7;
      vertical-align: top;
      white-space: nowrap;
    }
    .rule-table tr:last-child td {
      border-bottom: none;
    }
    .rule-table .rule-target-cell {
      min-width: 170px;
      white-space: normal;
    }
    .rule-table .rule-dynamic-cell {
      min-width: 150px;
      white-space: normal;
    }
    .rule-table .code-name {
      display: block;
      margin-top: 2px;
      color: #8b9ab0;
      font-size: 11px;
      word-break: break-all;
    }
    .split-grid {
      display: grid;
@@ -363,6 +460,11 @@
    .job-expand-actions .job-expand-all {
      font-weight: 600;
    }
    .job-expand-all-row {
      display: flex;
      justify-content: flex-end;
      margin: 8px 0 10px;
    }
    .json-dialog-body {
      margin: -8px -6px 0;
    }
@@ -374,7 +476,7 @@
      .layout { grid-template-columns: 1fr; }
    }
    @media (max-width: 760px) {
      .summary-grid, .param-grid, .map-list, .split-grid { grid-template-columns: 1fr; }
      .summary-grid, .param-grid, .route-pressure-metrics, .map-list, .distribution-grid, .split-grid { grid-template-columns: 1fr; }
      .hero-actions { justify-content: flex-start; }
    }
  </style>
@@ -387,17 +489,25 @@
        <div v-html="headerIcon" style="display:flex;"></div>
        <div>
          <div class="main">AI自动调参控制台</div>
          <div class="sub">手动触发 Agent、查看实时快照、审计调参动作和回滚最近成功调参</div>
          <div class="sub">{{ controlModeHeroText }}</div>
        </div>
      </div>
      <div class="hero-actions">
        <el-button type="primary" size="mini" :loading="snapshotLoading" @click="refreshAll">刷新数据</el-button>
        <el-button type="success" size="mini" :loading="agentLoading" @click="triggerManual">手动触发Agent</el-button>
        <el-button type="success" size="mini" :loading="agentLoading" @click="triggerManual">{{ manualTriggerButtonText }}</el-button>
        <el-button size="mini" :loading="schedulerLoading" @click="triggerScheduler">按后台规则触发</el-button>
        <el-button type="danger" plain size="mini" :loading="rollbackLoading" @click="confirmRollback">回滚最近成功调参</el-button>
        <el-button type="danger" plain size="mini" :loading="rollbackLoading"
                   :disabled="isAnalysisOnlyMode"
                   :title="isAnalysisOnlyMode ? '仅分析模式禁止回滚' : ''"
                   @click="confirmRollback">回滚最近成功调参</el-button>
      </div>
    </div>
    <div class="summary-grid">
      <div class="summary-card">
        <div class="k">运行模式</div>
        <div class="v">{{ controlModeDisplay.label }}</div>
        <div class="hint">{{ controlModeDisplay.hint }}</div>
      </div>
      <div class="summary-card">
        <div class="k">活动任务</div>
        <div class="v">{{ taskSnapshot.activeTaskCount || 0 }}</div>
@@ -488,25 +598,61 @@
                <span class="small-muted" v-if="combineTaskLimitEntries(parameterSnapshot.dualCrnMaxOutTask, parameterSnapshot.dualCrnMaxInTask).length === 0">暂无数据</span>
              </div>
            </div>
            <div class="map-box">
          </div>
          <div class="insight-stack">
            <div class="map-box wide-map-box">
              <div class="map-title">任务分布</div>
              <div class="pill-row">
                <span class="kv-pill" v-for="item in mapEntries(taskSnapshot.byTargetStation)" :key="'t_' + item.key">站点{{ item.key }}: {{ item.value }}</span>
                <span class="kv-pill" v-for="item in mapEntries(taskSnapshot.byBatch)" :key="'b_' + item.key">批次{{ item.key }}: {{ item.value }}</span>
                <span class="small-muted" v-if="mapEntries(taskSnapshot.byTargetStation).length === 0 && mapEntries(taskSnapshot.byBatch).length === 0">暂无活动任务</span>
              <div class="distribution-grid">
                <div class="distribution-section">
                  <div class="distribution-label">按目标站点</div>
                  <div class="pill-row pill-row-expanded">
                    <span class="kv-pill" v-for="item in mapEntries(taskSnapshot.byTargetStation)" :key="'t_' + item.key">站点{{ item.key }}: {{ item.value }}</span>
                    <span class="small-muted" v-if="mapEntries(taskSnapshot.byTargetStation).length === 0">暂无数据</span>
                  </div>
                </div>
                <div class="distribution-section">
                  <div class="distribution-label">按任务批次</div>
                  <div class="pill-row pill-row-expanded">
                    <span class="kv-pill" v-for="item in mapEntries(taskSnapshot.byBatch)" :key="'b_' + item.key">批次{{ item.key }}: {{ item.value }}</span>
                    <span class="small-muted" v-if="mapEntries(taskSnapshot.byBatch).length === 0">暂无数据</span>
                  </div>
                </div>
              </div>
            </div>
            <div class="map-box">
              <div class="map-title">
            <div class="map-box wide-map-box">
              <div class="map-title map-title-row">
                <span>调参规则 <span class="code-name">ruleSnapshot</span></span>
                <el-button type="text" size="mini" @click="openJsonDialog('调参规则 ruleSnapshot', ruleSnapshot)">JSON</el-button>
              </div>
              <div class="pill-row">
                <span class="kv-pill" v-for="item in ruleSnapshot" :key="'r_' + item.targetType + '_' + item.targetKey">
                  {{ formatRuleSnapshotText(item) }}
                </span>
                <span class="small-muted" v-if="ruleSnapshot.length === 0">暂无数据</span>
              <div class="rule-table-wrap" v-if="ruleSnapshot.length > 0">
                <table class="rule-table">
                  <thead>
                    <tr>
                      <th>参数</th>
                      <th>范围</th>
                      <th>单次步长</th>
                      <th>冷却</th>
                      <th>动态上限</th>
                    </tr>
                  </thead>
                  <tbody>
                    <tr v-for="item in ruleSnapshot" :key="'r_' + item.targetType + '_' + item.targetKey">
                      <td class="rule-target-cell">
                        {{ targetTypeDisplayName(item.targetType) }} / {{ parameterDisplayName(item.targetKey) }}
                        <span class="code-name">{{ valueOrDash(item.targetType) }}/{{ valueOrDash(item.targetKey) }}</span>
                      </td>
                      <td>{{ valueOrDash(item.minValue) }} ~ {{ valueOrDash(item.maxValue) }}</td>
                      <td>{{ valueOrDash(item.maxStep) }}</td>
                      <td>{{ valueOrDash(item.cooldownMinutes) }} 分钟</td>
                      <td class="rule-dynamic-cell">
                        {{ valueOrDash(item.dynamicMaxValue) }}
                        <span class="code-name" v-if="hasText(item.dynamicMaxSource)">来源: {{ item.dynamicMaxSource }}</span>
                      </td>
                    </tr>
                  </tbody>
                </table>
              </div>
              <span class="small-muted" v-if="ruleSnapshot.length === 0">暂无数据</span>
            </div>
          </div>
        </div>
@@ -533,6 +679,7 @@
              <el-tag size="mini">工具: {{ valueOrDash(agentResult && agentResult.toolCallCount) }}</el-tag>
              <el-tag size="mini">LLM: {{ valueOrDash(agentResult && agentResult.llmCallCount) }}</el-tag>
              <el-tag size="mini">Tokens: {{ valueOrDash(agentResult && agentResult.totalTokens) }}</el-tag>
              <el-tag size="mini" :type="agentExecutionModeTagType">{{ agentExecutionModeText }}</el-tag>
            </div>
            <div class="markdown-body" v-html="renderMarkdownText(agentSummaryText())"></div>
          </div>
@@ -595,6 +742,116 @@
          </div>
        </div>
      </div>
      <div class="panel" style="margin-top:12px;">
        <div class="panel-head">
          <div>
            <div class="panel-title">路径局部压力</div>
            <div class="panel-tip">按目标站与热点路径段输出评分事实,Agent 负责最终调参判断</div>
          </div>
          <div>
            <el-button size="mini" plain @click="openJsonDialog('路径压力规则 routePressureRuleSnapshot', routePressureSnapshot.routePressureRuleSnapshot)">规则</el-button>
            <el-button size="mini" plain @click="openJsonDialog('路径局部压力 routePressureSnapshot', routePressureSnapshot)">JSON</el-button>
          </div>
        </div>
        <div class="panel-body">
          <div class="route-pressure-metrics">
            <div class="param-card">
              <div class="k">分析任务</div>
              <div class="v">{{ valueOrDash(routePressureSnapshot.analyzedTaskCount) }}</div>
            </div>
            <div class="param-card">
              <div class="k">Trace路径</div>
              <div class="v">{{ valueOrDash(routePressureSnapshot.tracePathCount) }}</div>
            </div>
            <div class="param-card">
              <div class="k">预估路径</div>
              <div class="v">{{ valueOrDash(routePressureSnapshot.estimatedPathCount) }}</div>
            </div>
            <div class="param-card">
              <div class="k">路径异常</div>
              <div class="v">{{ valueOrDash(routePressureSnapshot.pathErrorCount) }}</div>
            </div>
          </div>
          <div class="route-pressure-section">
            <div class="route-pressure-section-title">目标站路径压力</div>
            <el-table :data="routePressureSnapshot.targetStationRoutePressure" border stripe height="220" size="mini"
                      v-loading="snapshotLoading"
                      :header-cell-style="{background:'#f7f9fc', color:'#2e3a4d', fontWeight:600}">
              <el-table-column prop="targetStationId" label="目标站" width="80"></el-table-column>
              <el-table-column label="压力" width="80">
                <template slot-scope="scope">
                  <el-tag size="mini" :type="pressureTagType(scope.row.pressureLevel)">
                    {{ valueOrDash(scope.row.pressureLevel) }}
                  </el-tag>
                </template>
              </el-table-column>
              <el-table-column prop="pressureScore" label="评分" width="80"></el-table-column>
              <el-table-column prop="confidence" label="置信度" width="80"></el-table-column>
              <el-table-column label="后端提示" width="130">
                <template slot-scope="scope">
                  <el-tag size="mini" :type="directionTagType(scope.row.heuristicDirection || scope.row.recommendedDirection)">
                    {{ valueOrDash(scope.row.heuristicDirection || scope.row.recommendedDirection) }}
                  </el-tag>
                </template>
              </el-table-column>
              <el-table-column prop="blockedTaskCount" label="阻塞任务" width="80"></el-table-column>
              <el-table-column prop="routeTaskCount" label="路径任务" width="80"></el-table-column>
              <el-table-column label="主热点段" min-width="150">
                <template slot-scope="scope">{{ arrayPreview(scope.row.mainHotSegments) }}</template>
              </el-table-column>
              <el-table-column label="建议目标" min-width="150">
                <template slot-scope="scope">{{ arrayPreview(scope.row.recommendedTargets) }}</template>
              </el-table-column>
              <el-table-column label="事实依据" min-width="260">
                <template slot-scope="scope">
                  <div class="table-text-clamp" :title="scope.row.evidenceText || scope.row.reason || '-'">{{ valueOrDash(scope.row.evidenceText || scope.row.reason) }}</div>
                </template>
              </el-table-column>
            </el-table>
          </div>
          <div class="route-pressure-section">
            <div class="route-pressure-section-title">Top 热点路径段</div>
            <el-table :data="topHotPathSegments" border stripe height="260" size="mini"
                      v-loading="snapshotLoading"
                      :header-cell-style="{background:'#f7f9fc', color:'#2e3a4d', fontWeight:600}">
              <el-table-column prop="segmentKey" label="segmentKey" min-width="150"></el-table-column>
              <el-table-column label="站点序列" min-width="150">
                <template slot-scope="scope">{{ arrayPreview(scope.row.stationIds) }}</template>
              </el-table-column>
              <el-table-column prop="passTaskCount" label="passTaskCount" width="112"></el-table-column>
              <el-table-column prop="pressureScore" label="score" width="80"></el-table-column>
              <el-table-column label="loading" width="80">
                <template slot-scope="scope">{{ routePressureCount(scope.row, 'loading') }}</template>
              </el-table-column>
              <el-table-column label="taskHolding" width="100">
                <template slot-scope="scope">{{ routePressureCount(scope.row, 'taskHolding') }}</template>
              </el-table-column>
              <el-table-column label="runBlock" width="90">
                <template slot-scope="scope">{{ routePressureCount(scope.row, 'runBlock') }}</template>
              </el-table-column>
              <el-table-column label="nonAutoing" width="100">
                <template slot-scope="scope">{{ routePressureCount(scope.row, 'nonAutoing') }}</template>
              </el-table-column>
              <el-table-column label="pressureLevel" width="120">
                <template slot-scope="scope">
                  <el-tag size="mini" :type="pressureTagType(scope.row.pressureLevel)">
                    {{ valueOrDash(scope.row.pressureLevel) }}
                  </el-tag>
                </template>
              </el-table-column>
              <el-table-column label="relatedTargetStations" min-width="150">
                <template slot-scope="scope">{{ arrayPreview(scope.row.relatedTargetStations) }}</template>
              </el-table-column>
              <el-table-column label="sampleWrkNos" min-width="150">
                <template slot-scope="scope">{{ arrayPreview(scope.row.sampleWrkNos) }}</template>
              </el-table-column>
            </el-table>
          </div>
        </div>
      </div>
    </div>
  </div>
@@ -630,12 +887,14 @@
                             @click.stop="openTextDialog('推理摘要 #' + scope.row.id, scope.row.reasoningDigest, true)">推理全文</el-button>
                  <el-button type="text" size="mini" :disabled="!hasText(scope.row.snapshotDigest)"
                             @click.stop="openTextDialog('快照摘要 #' + scope.row.id, scope.row.snapshotDigest, false)">快照全文</el-button>
                  <el-button type="text" size="mini" class="job-expand-all"
                             @click.stop="openJobFullText(scope.row)">查看全部</el-button>
                </div>
              </div>
              <div class="markdown-body compact-summary" style="margin-bottom:10px;"
                   v-html="renderMarkdownText(scope.row.summary || '-')"></div>
              <div class="job-expand-all-row">
                <el-button type="primary" plain size="mini" class="job-expand-all"
                           @click.stop="openJobFullText(scope.row)">查看全部</el-button>
              </div>
              <div class="small-muted" style="margin:8px 0;">MCP调用记录</div>
              <el-table :data="mcpCallList(scope.row)" border size="mini" empty-text="暂无MCP调用记录"
                        style="margin-bottom:10px;"
@@ -647,8 +906,15 @@
                    <el-tag size="mini" :type="statusType(mcpScope.row.status)">{{ mcpScope.row.status || '-' }}</el-tag>
                  </template>
                </el-table-column>
                <el-table-column label="dryRun" width="80">
                  <template slot-scope="mcpScope">{{ formatBooleanLabel(mcpScope.row.dryRun) }}</template>
                <el-table-column label="dryRun" width="100">
                  <template slot-scope="mcpScope">{{ formatMcpDryRunLabel(mcpScope.row) }}</template>
                </el-table-column>
                <el-table-column label="写入行为" width="110">
                  <template slot-scope="mcpScope">
                    <el-tag size="mini" :type="writeBehaviorTagType(mcpWriteBehavior(mcpScope.row))">
                      {{ mcpWriteBehaviorLabel(mcpScope.row) }}
                    </el-tag>
                  </template>
                </el-table-column>
                <el-table-column prop="applyJobId" label="jobId" width="95"></el-table-column>
                <el-table-column label="成功/拒绝" width="95">
@@ -687,7 +953,16 @@
                <el-table-column prop="oldValue" label="原值" width="80"></el-table-column>
                <el-table-column prop="requestedValue" label="请求值" width="80"></el-table-column>
                <el-table-column prop="appliedValue" label="生效值" width="80"></el-table-column>
                <el-table-column prop="resultStatus" label="结果" width="90"></el-table-column>
                <el-table-column label="结果" width="130">
                  <template slot-scope="changeScope">{{ formatResultStatus(changeScope.row.resultStatus) }}</template>
                </el-table-column>
                <el-table-column label="写入行为" width="110">
                  <template slot-scope="changeScope">
                    <el-tag size="mini" :type="writeBehaviorTagType(changeWriteBehavior(changeScope.row))">
                      {{ changeWriteBehaviorLabel(changeScope.row) }}
                    </el-tag>
                  </template>
                </el-table-column>
                <el-table-column label="拒绝原因" min-width="220">
                  <template slot-scope="changeScope">
                    <div class="table-text-clamp"
@@ -716,6 +991,13 @@
        <el-table-column label="MCP" width="76">
          <template slot-scope="scope">
            <el-tag size="mini" type="info">{{ mcpCallCount(scope.row) }}</el-tag>
          </template>
        </el-table-column>
        <el-table-column label="写入行为" width="110">
          <template slot-scope="scope">
            <el-tag size="mini" :type="writeBehaviorTagType(jobWriteBehavior(scope.row))">
              {{ jobWriteBehaviorLabel(scope.row) }}
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="totalTokens" label="Tokens" width="90"></el-table-column>
@@ -794,8 +1076,98 @@
      cycleLoad: function() {
        return this.snapshot.cycleLoadSnapshot || {};
      },
      routePressureSnapshot: function() {
        var source = this.snapshot && this.snapshot.routePressureSnapshot
          ? this.snapshot.routePressureSnapshot
          : {};
        return {
          analyzedTaskCount: source.analyzedTaskCount || 0,
          tracePathCount: source.tracePathCount || 0,
          estimatedPathCount: source.estimatedPathCount || 0,
          pathErrorCount: source.pathErrorCount || 0,
          confidence: source.confidence || '-',
          routePressureRuleSnapshot: source.routePressureRuleSnapshot || {},
          taskRouteSamples: Array.isArray(source.taskRouteSamples) ? source.taskRouteSamples : [],
          hotPathSegments: Array.isArray(source.hotPathSegments) ? source.hotPathSegments : [],
          targetStationRoutePressure: Array.isArray(source.targetStationRoutePressure)
            ? source.targetStationRoutePressure
            : []
        };
      },
      topHotPathSegments: function() {
        var hotPathSegments = this.routePressureSnapshot.hotPathSegments;
        if (!Array.isArray(hotPathSegments)) {
          return [];
        }
        return hotPathSegments.slice(0, 10);
      },
      ruleSnapshot: function() {
        return this.snapshot.ruleSnapshot || [];
      },
      controlModeSnapshot: function() {
        return this.snapshot.controlModeSnapshot || {};
      },
      isAnalysisOnlyMode: function() {
        var controlModeSnapshot = this.controlModeSnapshot;
        return this.isExplicitTrue(controlModeSnapshot.analysisOnly)
          || this.isExplicitFalse(controlModeSnapshot.allowApply);
      },
      controlModeDisplay: function() {
        var controlModeSnapshot = this.controlModeSnapshot;
        var modeLabel = this.hasText(controlModeSnapshot.modeLabel)
          ? String(controlModeSnapshot.modeLabel)
          : '';
        var backgroundHint = this.isExplicitFalse(controlModeSnapshot.enabled) ? '后台关闭/' : '';
        if (this.isAnalysisOnlyMode) {
          return {
            label: '仅分析',
            hint: backgroundHint ? '后台关闭/手动分析不写参数' : '不写参数'
          };
        }
        if (this.isExplicitFalse(controlModeSnapshot.analysisOnly)
          && this.isExplicitTrue(controlModeSnapshot.allowApply)) {
          return {
            label: '正式调参',
            hint: backgroundHint ? '后台关闭/手动允许写参数' : '允许写参数'
          };
        }
        if (modeLabel) {
          return {
            label: modeLabel,
            hint: backgroundHint + '等待模式快照'
          };
        }
        return {
          label: '-',
          hint: backgroundHint + '等待模式快照'
        };
      },
      controlModeHeroText: function() {
        if (this.isExplicitFalse(this.controlModeSnapshot.enabled)) {
          return this.isAnalysisOnlyMode
            ? '后台自动调参已关闭;可手动触发分析、查看快照和审计记录'
            : '后台自动调参已关闭;可手动触发 Agent、查看快照和审计记录';
        }
        if (this.isAnalysisOnlyMode) {
          return '仅分析模式:Agent 只分析、试算和审计,不写运行参数、不执行回滚';
        }
        return '正式调参模式:手动触发 Agent、查看实时快照、审计调参动作和回滚最近成功调参';
      },
      manualTriggerButtonText: function() {
        return this.isAnalysisOnlyMode ? '手动触发分析' : '手动触发Agent';
      },
      agentExecutionModeText: function() {
        if (!this.agentResult) {
          return '模式: 未触发';
        }
        return '模式: ' + this.resolveExecutionModeText(this.agentResult);
      },
      agentExecutionModeTagType: function() {
        if (!this.agentResult) {
          return 'info';
        }
        return this.isResultApplyAllowed(this.agentResult) ? 'success' : 'warning';
      },
      stationBusyCount: function() {
        var count = 0;
@@ -821,6 +1193,32 @@
      }
    },
    methods: {
      isExplicitFalse: function(value) {
        return value === false || value === 'false' || value === 0 || value === '0';
      },
      isExplicitTrue: function(value) {
        return value === true || value === 'true' || value === 1 || value === '1';
      },
      isResultApplyAllowed: function(result) {
        var safeResult = result || {};
        if (this.isExplicitTrue(safeResult.analysisOnly) || this.isExplicitFalse(safeResult.allowApply)) {
          return false;
        }
        if (this.isExplicitTrue(safeResult.allowApply)) {
          return true;
        }
        return !this.isAnalysisOnlyMode;
      },
      resolveExecutionModeText: function(result) {
        var safeResult = result || {};
        if (this.hasText(safeResult.executionMode)) {
          return String(safeResult.executionMode);
        }
        if (!this.isResultApplyAllowed(safeResult)) {
          return '仅分析/不允许正式应用';
        }
        return '正式调参/允许应用';
      },
      authHeaders: function() {
        return { 'token': localStorage.getItem('token') };
      },
@@ -871,7 +1269,11 @@
      },
      triggerManual: function() {
        var self = this;
        self.$confirm('手动触发会立即调用 Agent,并可能通过 MCP 执行 dry-run 与实际调参。是否继续?', '手动触发Agent', {
        var confirmTitle = self.isAnalysisOnlyMode ? '手动触发分析' : '手动触发Agent';
        var confirmMessage = self.isAnalysisOnlyMode
          ? '仅分析模式会立即调用 Agent,只做分析、试算和审计,不会实际应用运行参数,也不会执行回滚。是否继续?'
          : '手动触发会立即调用 Agent,可能先 dry-run 再正式应用运行参数。是否继续?';
        self.$confirm(confirmMessage, confirmTitle, {
          type: 'warning'
        }).then(function() {
          self.agentLoading = true;
@@ -927,6 +1329,10 @@
      },
      confirmRollback: function() {
        var self = this;
        if (self.isAnalysisOnlyMode) {
          self.$message.warning('仅分析模式禁止回滚');
          return;
        }
        self.$prompt('请输入回滚原因。建议写明来自快照或审计记录的异常证据。', '回滚最近成功调参', {
          confirmButtonText: '回滚',
          cancelButtonText: '取消',
@@ -1059,6 +1465,303 @@
        }
        return this.mcpCallList(job).length;
      },
      formatMcpDryRunLabel: function(call) {
        var safeCall = call || {};
        if (safeCall.dryRun === true) {
          return '是';
        }
        if (safeCall.dryRun === false) {
          return '否';
        }
        return '-';
      },
      formatResultStatus: function(resultStatus) {
        var normalizedResultStatus = this.hasText(resultStatus)
          ? String(resultStatus).toLowerCase()
          : '';
        var resultStatusMap = {
          dry_run: '试算通过未写入',
          no_change: '无变更',
          rejected: '被拒绝',
          success: '已生效',
          pending: '待应用'
        };
        return resultStatusMap[normalizedResultStatus] || this.valueOrDash(resultStatus);
      },
      mcpWriteBehavior: function(call) {
        return this.resolveWriteBehavior(call, this.legacyMcpWriteBehavior);
      },
      mcpWriteBehaviorLabel: function(call) {
        return this.resolveWriteBehaviorLabel(call, this.legacyMcpWriteBehavior);
      },
      jobWriteBehavior: function(job) {
        return this.resolveWriteBehavior(job, this.legacyJobWriteBehavior);
      },
      jobWriteBehaviorLabel: function(job) {
        return this.resolveWriteBehaviorLabel(job, this.legacyJobWriteBehavior);
      },
      changeWriteBehavior: function(change) {
        return this.resolveWriteBehavior(change, this.legacyChangeWriteBehavior);
      },
      changeWriteBehaviorLabel: function(change) {
        return this.resolveWriteBehaviorLabel(change, this.legacyChangeWriteBehavior);
      },
      resolveWriteBehavior: function(source, fallback) {
        var safeSource = source || {};
        if (this.hasText(safeSource.writeBehavior)) {
          return this.normalizeWriteBehavior(safeSource.writeBehavior);
        }
        if (typeof fallback === 'function') {
          return this.normalizeWriteBehavior(fallback.call(this, safeSource));
        }
        return 'unknown';
      },
      resolveWriteBehaviorLabel: function(source, fallback) {
        var safeSource = source || {};
        if (this.hasText(safeSource.writeBehaviorLabel)) {
          return String(safeSource.writeBehaviorLabel);
        }
        return this.writeBehaviorLabelFromValue(this.resolveWriteBehavior(safeSource, fallback));
      },
      normalizeWriteBehavior: function(writeBehavior) {
        if (!this.hasText(writeBehavior)) {
          return 'unknown';
        }
        var normalizedBehavior = String(writeBehavior).toLowerCase();
        var behaviorMap = {
          '仅分析': 'analysis_only',
          '试算': 'dry_run',
          '正式写入': 'apply',
          '正式应用': 'apply',
          '回滚': 'rollback',
          '无变更': 'no_change',
          '只读': 'read_only',
          '无工具': 'read_only',
          '失败': 'failed',
          '已拒绝': 'rejected',
          '未知': 'unknown'
        };
        return behaviorMap[writeBehavior] || behaviorMap[normalizedBehavior] || normalizedBehavior;
      },
      writeBehaviorLabelFromValue: function(writeBehavior) {
        var normalizedBehavior = this.normalizeWriteBehavior(writeBehavior);
        var labelMap = {
          analysis_only: '仅分析',
          dry_run: '试算',
          apply: '正式写入',
          rollback: '回滚',
          no_change: '无变更',
          read_only: '只读',
          failed: '失败',
          rejected: '已拒绝',
          unknown: '未知'
        };
        return labelMap[normalizedBehavior] || '未知';
      },
      legacyMcpWriteBehavior: function(call) {
        var safeCall = call || {};
        if (this.isReadOnlyToolName(safeCall.toolName)) {
          return 'read_only';
        }
        if (this.isRollbackToolName(safeCall.toolName)) {
          return this.legacyRollbackCallWriteBehavior(safeCall);
        }
        if (safeCall.dryRun === true) {
          return 'dry_run';
        }
        if (safeCall.dryRun === false && this.isApplyToolName(safeCall.toolName)) {
          return this.legacyApplyCallWriteBehavior(safeCall);
        }
        return 'unknown';
      },
      legacyApplyCallWriteBehavior: function(call) {
        if (this.summaryIndicatesAnalysisOnly(call.errorMessage)) {
          return 'analysis_only';
        }
        if (call.successCount > 0) {
          return 'apply';
        }
        if (call.status === 'success' && call.successCount === 0 && call.rejectCount === 0) {
          return 'no_change';
        }
        if (call.status === 'failed') {
          return 'failed';
        }
        if (call.status === 'rejected' || call.rejectCount > 0) {
          return 'rejected';
        }
        return 'unknown';
      },
      legacyRollbackCallWriteBehavior: function(call) {
        if (this.summaryIndicatesAnalysisOnly(call.errorMessage)) {
          return 'analysis_only';
        }
        if (call.successCount > 0) {
          return 'rollback';
        }
        if (call.status === 'success' && call.successCount === 0 && call.rejectCount === 0) {
          return 'no_change';
        }
        if (call.status === 'failed') {
          return 'failed';
        }
        if (call.status === 'rejected' || call.rejectCount > 0) {
          return 'rejected';
        }
        return 'unknown';
      },
      legacyJobWriteBehavior: function(job) {
        var safeJob = job || {};
        if (safeJob.triggerType === 'rollback') {
          if (safeJob.successCount > 0) {
            return 'rollback';
          }
          if (safeJob.status === 'failed') {
            return 'failed';
          }
          if (safeJob.status === 'rejected' || safeJob.rejectCount > 0) {
            return 'rejected';
          }
          if (safeJob.status === 'no_change' || safeJob.successCount === 0) {
            return 'no_change';
          }
        }
        var mcpCalls = this.mcpCallList(safeJob);
        if (this.containsLegacyWriteBehavior(mcpCalls, this.legacyMcpWriteBehavior, 'rollback')) {
          return 'rollback';
        }
        var changes = Array.isArray(safeJob.changes) ? safeJob.changes : [];
        if (this.containsLegacyWriteBehavior(mcpCalls, this.legacyMcpWriteBehavior, 'apply')
          || this.containsLegacyWriteBehavior(changes, this.legacyChangeWriteBehavior, 'apply')) {
          return 'apply';
        }
        if (this.containsLegacyWriteBehavior(changes, this.legacyChangeWriteBehavior, 'analysis_only')
          || this.summaryIndicatesAnalysisOnly(safeJob.summary)) {
          return 'analysis_only';
        }
        if (this.allLegacyWriteBehavior(mcpCalls, this.legacyMcpWriteBehavior, 'read_only')) {
          return 'read_only';
        }
        if (safeJob.status === 'no_change'
          || this.allLegacyWriteBehavior(changes, this.legacyChangeWriteBehavior, 'no_change')
          || this.containsLegacyWriteBehavior(mcpCalls, this.legacyMcpWriteBehavior, 'no_change')) {
          return 'no_change';
        }
        if (this.containsLegacyWriteBehavior(mcpCalls, this.legacyMcpWriteBehavior, 'dry_run')
          || this.containsLegacyWriteBehavior(changes, this.legacyChangeWriteBehavior, 'dry_run')) {
          return 'dry_run';
        }
        if (safeJob.status === 'failed'
          || this.containsLegacyWriteBehavior(mcpCalls, this.legacyMcpWriteBehavior, 'failed')
          || this.containsLegacyWriteBehavior(changes, this.legacyChangeWriteBehavior, 'failed')) {
          return 'failed';
        }
        if (safeJob.status === 'rejected'
          || this.containsLegacyWriteBehavior(mcpCalls, this.legacyMcpWriteBehavior, 'rejected')
          || this.containsLegacyWriteBehavior(changes, this.legacyChangeWriteBehavior, 'rejected')) {
          return 'rejected';
        }
        return 'unknown';
      },
      legacyChangeWriteBehavior: function(change) {
        var safeChange = change || {};
        var resultStatus = this.hasText(safeChange.resultStatus)
          ? String(safeChange.resultStatus).toLowerCase()
          : '';
        if (resultStatus === 'dry_run') {
          return 'dry_run';
        }
        if (resultStatus === 'success') {
          return 'apply';
        }
        if (resultStatus === 'no_change') {
          return 'no_change';
        }
        if (resultStatus === 'rejected' && this.summaryIndicatesAnalysisOnly(safeChange.rejectReason)) {
          return 'analysis_only';
        }
        if (resultStatus === 'rejected') {
          return 'rejected';
        }
        if (resultStatus === 'failed') {
          return 'failed';
        }
        return 'unknown';
      },
      containsLegacyWriteBehavior: function(items, resolver, expectedBehavior) {
        if (!Array.isArray(items) || items.length === 0) {
          return false;
        }
        for (var itemIndex = 0; itemIndex < items.length; itemIndex++) {
          var itemBehavior = this.normalizeWriteBehavior(resolver.call(this, items[itemIndex]));
          if (itemBehavior === expectedBehavior) {
            return true;
          }
        }
        return false;
      },
      allLegacyWriteBehavior: function(items, resolver, expectedBehavior) {
        if (!Array.isArray(items) || items.length === 0) {
          return false;
        }
        for (var itemIndex = 0; itemIndex < items.length; itemIndex++) {
          var itemBehavior = this.normalizeWriteBehavior(resolver.call(this, items[itemIndex]));
          if (itemBehavior !== expectedBehavior) {
            return false;
          }
        }
        return true;
      },
      isReadOnlyToolName: function(toolName) {
        if (!this.hasText(toolName)) {
          return false;
        }
        var normalizedToolName = String(toolName).toLowerCase();
        return normalizedToolName.indexOf('get_auto_tune_snapshot') >= 0
          || normalizedToolName.indexOf('get_recent_auto_tune_jobs') >= 0;
      },
      isApplyToolName: function(toolName) {
        if (!this.hasText(toolName)) {
          return false;
        }
        return String(toolName).toLowerCase().indexOf('apply_auto_tune_changes') >= 0;
      },
      isRollbackToolName: function(toolName) {
        if (!this.hasText(toolName)) {
          return false;
        }
        var normalizedToolName = String(toolName).toLowerCase();
        return normalizedToolName.indexOf('revert_last_auto_tune_job') >= 0
          || normalizedToolName.indexOf('rollback') >= 0;
      },
      summaryIndicatesAnalysisOnly: function(summary) {
        if (!this.hasText(summary)) {
          return false;
        }
        var summaryText = String(summary).toLowerCase();
        return summaryText.indexOf('仅分析') >= 0
          || summaryText.indexOf('analysisonly=true') >= 0
          || summaryText.indexOf('"analysisonly":true') >= 0
          || summaryText.indexOf('noapply=true') >= 0
          || summaryText.indexOf('"noapply":true') >= 0
          || summaryText.indexOf('禁止实际应用') >= 0;
      },
      writeBehaviorTagType: function(writeBehavior) {
        var normalizedBehavior = this.normalizeWriteBehavior(writeBehavior);
        if (normalizedBehavior === 'apply') {
          return 'success';
        }
        if (normalizedBehavior === 'rollback') {
          return 'danger';
        }
        if (normalizedBehavior === 'failed' || normalizedBehavior === 'rejected') {
          return 'danger';
        }
        if (normalizedBehavior === 'analysis_only' || normalizedBehavior === 'dry_run') {
          return 'warning';
        }
        return 'info';
      },
      formatBooleanLabel: function(value) {
        if (value === true) {
          return '是';
@@ -1081,10 +1784,14 @@
        if (data && data.agentResult) {
          return data.agentResult;
        }
        var controlModeSnapshot = this.controlModeSnapshot;
        return {
          success: false,
          triggerType: defaultTriggerType || '-',
          summary: 'Agent未触发: ' + ((data && data.reason) ? data.reason : '-'),
          analysisOnly: data && data.analysisOnly !== undefined ? data.analysisOnly : controlModeSnapshot.analysisOnly,
          allowApply: data && data.allowApply !== undefined ? data.allowApply : controlModeSnapshot.allowApply,
          executionMode: data && data.executionMode ? data.executionMode : controlModeSnapshot.modeCode,
          toolCallCount: 0,
          llmCallCount: 0,
          promptTokens: 0,
@@ -1099,6 +1806,44 @@
        }
        return value;
      },
      pressureTagType: function(level) {
        if (level === 'high') {
          return 'danger';
        }
        if (level === 'medium') {
          return 'warning';
        }
        if (level === 'low') {
          return 'success';
        }
        return 'info';
      },
      directionTagType: function(direction) {
        if (direction === 'increase' || direction === 'increase_candidate') {
          return 'success';
        }
        if (direction === 'decrease' || direction === 'decrease_candidate') {
          return 'danger';
        }
        if (direction === 'hold' || direction === 'review') {
          return 'warning';
        }
        return 'info';
      },
      arrayPreview: function(value) {
        if (Array.isArray(value)) {
          return value.length > 0 ? value.join(', ') : '-';
        }
        return this.valueOrDash(value);
      },
      routePressureCount: function(row, key) {
        var safeRow = row || {};
        var countKey = key + 'Count';
        if (safeRow[key] !== null && safeRow[key] !== undefined && safeRow[key] !== '') {
          return safeRow[key];
        }
        return this.valueOrDash(safeRow[countKey]);
      },
      mapEntries: function(source) {
        var result = [];
        if (!source) {
src/test/java/com/zy/ai/service/AutoTuneApplyServiceImplTest.java
@@ -7,6 +7,7 @@
import com.zy.ai.entity.AiAutoTuneChange;
import com.zy.ai.entity.AiAutoTuneJob;
import com.zy.ai.service.impl.AutoTuneApplyServiceImpl;
import com.zy.ai.service.impl.AutoTuneControlModeServiceImpl;
import com.zy.asrs.entity.BasCrnp;
import com.zy.asrs.entity.BasDualCrnp;
import com.zy.asrs.entity.BasStation;
@@ -89,6 +90,8 @@
        ReflectionTestUtils.setField(service, "aiAutoTuneJobService", aiAutoTuneJobService);
        ReflectionTestUtils.setField(service, "aiAutoTuneChangeService", aiAutoTuneChangeService);
        ReflectionTestUtils.setField(service, "configService", configService);
        ReflectionTestUtils.setField(service, "autoTuneControlModeService",
                new AutoTuneControlModeServiceImpl(configService));
        ReflectionTestUtils.setField(service, "basStationService", basStationService);
        ReflectionTestUtils.setField(service, "basCrnpService", basCrnpService);
        ReflectionTestUtils.setField(service, "basDualCrnpService", basDualCrnpService);
@@ -111,6 +114,7 @@
        when(aiAutoTuneChangeService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
        when(wrkMastService.count(any(Wrapper.class))).thenReturn(0L);
        when(configService.getConfigValue(eq("aiAutoTuneIntervalMinutes"), any())).thenReturn("10");
        when(configService.getConfigValue("aiAutoTuneAnalysisOnly", "Y")).thenReturn("N");
        when(configService.saveConfigValue(any(), any())).thenReturn(true);
        when(basStationService.update(any(Wrapper.class))).thenReturn(true);
        when(basCrnpService.update(any(Wrapper.class))).thenReturn(true);
@@ -323,6 +327,38 @@
    }
    @Test
    void analysisOnlyRealApplyWritesRejectedAuditAndDoesNotAcquireApplyLock() {
        when(configService.getConfigValue("aiAutoTuneAnalysisOnly", "Y")).thenReturn("Y");
        AutoTuneApplyResult result = service.apply(request(false,
                command("sys_config", null, "conveyorStationTaskLimit", "15")));
        List<AiAutoTuneChange> changes = savedChanges();
        AiAutoTuneJob job = updatedJob();
        assertFalse(result.getSuccess());
        assertTrue(result.getAnalysisOnly());
        assertTrue(result.getNoApply());
        assertEquals("rejected", job.getStatus());
        assertEquals("rejected", changes.get(0).getResultStatus());
        assertTrue(changes.get(0).getRejectReason().contains("仅分析模式禁止实际应用/回滚"));
        verify(redisUtil, never()).trySetStringIfAbsent(anyString(), anyString(), anyLong());
        verify(configService, never()).saveConfigValue(any(), any());
    }
    @Test
    void realApplyResultUsesEntryControlModeSnapshotWhenConfigFlips() {
        when(configService.getConfigValue("aiAutoTuneAnalysisOnly", "Y")).thenReturn("N", "Y");
        when(configService.getOne(any(Wrapper.class))).thenReturn(config("conveyorStationTaskLimit", "10"));
        AutoTuneApplyResult result = service.apply(request(false,
                command("sys_config", null, "conveyorStationTaskLimit", "15")));
        assertTrue(result.getSuccess());
        assertFalse(result.getAnalysisOnly());
        verify(configService, times(1)).getConfigValue("aiAutoTuneAnalysisOnly", "Y");
    }
    @Test
    void acceptedDryRunDoesNotWriteTargetStores() {
        when(configService.getOne(any(Wrapper.class))).thenReturn(config("conveyorStationTaskLimit", "10"));
@@ -338,6 +374,41 @@
    }
    @Test
    void analysisOnlyRollbackWritesRejectedAuditAndDoesNotAcquireApplyLock() {
        when(configService.getConfigValue("aiAutoTuneAnalysisOnly", "Y")).thenReturn("Y");
        AutoTuneApplyResult result = service.rollbackLastSuccessfulJob("manual rollback");
        List<AiAutoTuneChange> changes = savedChanges();
        AiAutoTuneJob job = updatedJob();
        assertFalse(result.getSuccess());
        assertTrue(result.getAnalysisOnly());
        assertTrue(result.getNoApply());
        assertEquals("rejected", job.getStatus());
        assertEquals("rejected", changes.get(0).getResultStatus());
        assertEquals("rollback", changes.get(0).getTargetKey());
        verify(redisUtil, never()).trySetStringIfAbsent(anyString(), anyString(), anyLong());
    }
    @Test
    void rollbackResultUsesEntryControlModeSnapshotWhenConfigFlips() {
        when(configService.getConfigValue("aiAutoTuneAnalysisOnly", "Y")).thenReturn("N", "Y");
        AiAutoTuneJob latestRealJob = job(10L, "manual", "success");
        AiAutoTuneChange configChange = successChange(10L, "sys_config", "", "conveyorStationTaskLimit", "10", "15");
        when(aiAutoTuneChangeService.list(any(Wrapper.class)))
                .thenReturn(List.of(configChange))
                .thenReturn(List.of(configChange));
        when(aiAutoTuneJobService.getById(10L)).thenReturn(latestRealJob);
        when(configService.getOne(any(Wrapper.class))).thenReturn(config("conveyorStationTaskLimit", "15"));
        AutoTuneApplyResult result = service.rollbackLastSuccessfulJob("manual rollback");
        assertTrue(result.getSuccess());
        assertFalse(result.getAnalysisOnly());
        verify(configService, times(1)).getConfigValue("aiAutoTuneAnalysisOnly", "Y");
    }
    @Test
    void applyJobRecordsActiveTasksWhenCountIsPositive() {
        when(configService.getOne(any(Wrapper.class))).thenReturn(config("conveyorStationTaskLimit", "10"));
        when(wrkMastService.count(any(Wrapper.class))).thenReturn(1L);
src/test/java/com/zy/ai/service/AutoTuneCoordinatorServiceImplTest.java
@@ -1,6 +1,7 @@
package com.zy.ai.service;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.zy.ai.domain.autotune.AutoTuneApplyRequest;
import com.zy.ai.domain.autotune.AutoTuneApplyResult;
@@ -17,6 +18,7 @@
import com.zy.ai.mcp.service.SpringAiMcpToolManager;
import com.zy.ai.mcp.tool.AutoTuneMcpTools;
import com.zy.ai.service.impl.AutoTuneAgentServiceImpl;
import com.zy.ai.service.impl.AutoTuneControlModeServiceImpl;
import com.zy.ai.service.impl.AutoTuneCoordinatorServiceImpl;
import com.zy.asrs.service.WrkMastService;
import com.zy.common.utils.RedisUtil;
@@ -44,6 +46,7 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -54,6 +57,7 @@
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@@ -82,6 +86,7 @@
    private AiPromptTemplateService aiPromptTemplateService;
    @Mock
    private ConfigService configService;
    private AutoTuneControlModeService autoTuneControlModeService;
    @Mock
    private WrkMastService wrkMastService;
    @Mock
@@ -93,12 +98,15 @@
    @BeforeEach
    void setUp() {
        autoTuneControlModeService = new AutoTuneControlModeServiceImpl(configService);
        tools = new AutoTuneMcpTools(
                autoTuneSnapshotService,
                autoTuneApplyService,
                aiAutoTuneJobService,
                aiAutoTuneChangeService,
                aiAutoTuneMcpCallService);
        lenient().when(configService.getConfigValue("aiAutoTuneEnabled", "N")).thenReturn("Y");
        lenient().when(configService.getConfigValue("aiAutoTuneAnalysisOnly", "Y")).thenReturn("N");
    }
    @Test
@@ -121,12 +129,19 @@
        job.setSummary("applied");
        job.setSuccessCount(1);
        job.setRejectCount(0);
        AiAutoTuneChange change = new AiAutoTuneChange();
        change.setJobId(70L);
        change.setTargetType("sys_config");
        change.setTargetKey("conveyorStationTaskLimit");
        change.setRequestedValue("12");
        change.setResultStatus("success");
        AiAutoTuneChange agentChange = new AiAutoTuneChange();
        agentChange.setJobId(7L);
        agentChange.setTargetType("station");
        agentChange.setTargetId("101");
        agentChange.setTargetKey("outTaskLimit");
        agentChange.setRequestedValue("3");
        agentChange.setResultStatus("success");
        AiAutoTuneChange applyChange = new AiAutoTuneChange();
        applyChange.setJobId(70L);
        applyChange.setTargetType("sys_config");
        applyChange.setTargetKey("conveyorStationTaskLimit");
        applyChange.setRequestedValue("12");
        applyChange.setResultStatus("success");
        com.zy.ai.entity.AiAutoTuneMcpCall mcpCall = new com.zy.ai.entity.AiAutoTuneMcpCall();
        mcpCall.setAgentJobId(7L);
        mcpCall.setCallSeq(1);
@@ -136,7 +151,10 @@
        when(aiAutoTuneJobService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(job));
        when(aiAutoTuneMcpCallService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(mcpCall));
        when(aiAutoTuneChangeService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(change));
        List<AiAutoTuneChange> auditChanges = new ArrayList<>();
        auditChanges.add(agentChange);
        auditChanges.add(applyChange);
        when(aiAutoTuneChangeService.list(any(Wrapper.class))).thenReturn(auditChanges);
        List<Map<String, Object>> result = tools.getRecentAutoTuneJobs(99);
@@ -145,11 +163,54 @@
        assertFalse(result.get(0).containsKey("reasoningDigest"));
        assertEquals(1, result.get(0).get("mcpCallCount"));
        List<?> changes = (List<?>) result.get(0).get("changes");
        assertEquals(1, changes.size());
        assertEquals(2, changes.size());
        assertEquals(7L, ((Map<?, ?>) changes.get(0)).get("jobId"));
        assertEquals(70L, ((Map<?, ?>) changes.get(1)).get("jobId"));
        ArgumentCaptor<Wrapper<AiAutoTuneJob>> wrapperCaptor = ArgumentCaptor.forClass(Wrapper.class);
        verify(aiAutoTuneJobService).list(wrapperCaptor.capture());
        assertTrue(wrapperCaptor.getValue().getSqlSegment().contains("limit 20"));
        ArgumentCaptor<Wrapper<AiAutoTuneChange>> changeWrapperCaptor = ArgumentCaptor.forClass(Wrapper.class);
        verify(aiAutoTuneChangeService).list(changeWrapperCaptor.capture());
        Wrapper<AiAutoTuneChange> changeWrapper = changeWrapperCaptor.getValue();
        assertTrue(changeWrapper.getSqlSegment().contains("job_id IN"));
        List<Object> changeQueryParams = wrapperParamValues(changeWrapper);
        assertTrue(changeQueryParams.contains(7L));
        assertTrue(changeQueryParams.contains(70L));
    }
    @Test
    void recentJobsMarksRollbackMcpChangesAsRollback() {
        AiAutoTuneJob job = new AiAutoTuneJob();
        job.setId(8L);
        job.setTriggerType("manual");
        job.setStatus("success");
        job.setSuccessCount(1);
        job.setRejectCount(0);
        AiAutoTuneChange rollbackChange = new AiAutoTuneChange();
        rollbackChange.setJobId(80L);
        rollbackChange.setTargetType("sys_config");
        rollbackChange.setTargetKey("conveyorStationTaskLimit");
        rollbackChange.setResultStatus("success");
        com.zy.ai.entity.AiAutoTuneMcpCall mcpCall = new com.zy.ai.entity.AiAutoTuneMcpCall();
        mcpCall.setAgentJobId(8L);
        mcpCall.setCallSeq(1);
        mcpCall.setToolName("wcs_local_dispatch_revert_last_auto_tune_job");
        mcpCall.setStatus("success");
        mcpCall.setApplyJobId(80L);
        mcpCall.setSuccessCount(1);
        when(aiAutoTuneJobService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(job));
        when(aiAutoTuneMcpCallService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(mcpCall));
        when(aiAutoTuneChangeService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(rollbackChange));
        List<Map<String, Object>> result = tools.getRecentAutoTuneJobs(1);
        assertEquals("rollback", result.get(0).get("writeBehavior"));
        List<?> changes = (List<?>) result.get(0).get("changes");
        assertEquals(1, changes.size());
        assertEquals("rollback", ((Map<?, ?>) changes.get(0)).get("writeBehavior"));
    }
    @Test
@@ -157,6 +218,7 @@
        AutoTuneApplyResult expected = new AutoTuneApplyResult();
        expected.setDryRun(true);
        expected.setSuccess(true);
        expected.setChanges(Collections.singletonList(applyResultChange("dry_run")));
        when(autoTuneApplyService.apply(any(AutoTuneApplyRequest.class))).thenReturn(expected);
        AutoTuneChangeCommand command = new AutoTuneChangeCommand();
@@ -203,10 +265,38 @@
    }
    @Test
    void applyToolRejectsDuplicateDryRunTargetsBeforeServiceCall() {
        List<AutoTuneChangeCommand> changes = new ArrayList<>();
        changes.add(change(" sys_config ", "ignored-first", " conveyorStationTaskLimit ", "12"));
        changes.add(change("SYS_CONFIG", "ignored-second", "conveyorStationTaskLimit", "13"));
        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
                () -> tools.applyAutoTuneChanges("duplicate target", 10, "agent", true, null, changes));
        assertTrue(exception.getMessage().contains("Duplicate auto-tune change target"));
        assertTrue(exception.getMessage().contains("targetType=sys_config"));
        assertTrue(exception.getMessage().contains("targetKey=conveyorStationTaskLimit"));
        verify(autoTuneApplyService, never()).apply(any(AutoTuneApplyRequest.class));
    }
    @Test
    void applyToolRejectsRealApplyWithoutDryRunTokenInAnalysisOnlyMode() {
        AutoTuneChangeCommand command = change("sys_config", null, "conveyorStationTaskLimit", "12");
        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
                () -> tools.applyAutoTuneChanges("direct apply", 10, "agent", false, null,
                        Collections.singletonList(command)));
        assertTrue(exception.getMessage().contains("dryRunToken is required"));
        verify(autoTuneApplyService, never()).apply(any(AutoTuneApplyRequest.class));
    }
    @Test
    void applyToolAllowsRealApplyOnlyWithMatchingDryRunToken() {
        AutoTuneApplyResult dryRunResult = new AutoTuneApplyResult();
        dryRunResult.setDryRun(true);
        dryRunResult.setSuccess(true);
        dryRunResult.setChanges(Collections.singletonList(applyResultChange("dry_run")));
        AutoTuneApplyResult applyResult = new AutoTuneApplyResult();
        applyResult.setDryRun(false);
        applyResult.setSuccess(true);
@@ -230,6 +320,7 @@
        AutoTuneApplyResult dryRunResult = new AutoTuneApplyResult();
        dryRunResult.setDryRun(true);
        dryRunResult.setSuccess(true);
        dryRunResult.setChanges(Collections.singletonList(applyResultChange("dry_run")));
        when(autoTuneApplyService.apply(any(AutoTuneApplyRequest.class))).thenReturn(dryRunResult);
        AutoTuneApplyResult preview = tools.applyAutoTuneChanges("preview", 10, "agent", true, null,
@@ -250,6 +341,7 @@
        AutoTuneApplyResult dryRunResult = new AutoTuneApplyResult();
        dryRunResult.setDryRun(true);
        dryRunResult.setSuccess(true);
        dryRunResult.setChanges(Collections.singletonList(applyResultChange("dry_run")));
        when(autoTuneApplyService.apply(any(AutoTuneApplyRequest.class))).thenReturn(dryRunResult);
        List<AutoTuneChangeCommand> changes = Collections.singletonList(
                change("sys_config", null, "conveyorStationTaskLimit", "12"));
@@ -264,8 +356,40 @@
    }
    @Test
    void applyToolDoesNotIssueDryRunTokenWhenAllChangesAreNoChange() {
        AutoTuneApplyResult dryRunResult = new AutoTuneApplyResult();
        dryRunResult.setDryRun(true);
        dryRunResult.setSuccess(true);
        dryRunResult.setSuccessCount(0);
        dryRunResult.setRejectCount(0);
        dryRunResult.setChanges(Collections.singletonList(applyResultChange("no_change")));
        when(autoTuneApplyService.apply(any(AutoTuneApplyRequest.class))).thenReturn(dryRunResult);
        AutoTuneApplyResult preview = tools.applyAutoTuneChanges("preview", 10, "agent", true, null,
                Collections.singletonList(change("sys_config", null, "conveyorStationTaskLimit", "12")));
        assertNull(preview.getDryRunToken());
        verify(autoTuneApplyService, times(1)).apply(any(AutoTuneApplyRequest.class));
    }
    @Test
    void rollbackToolDelegatesToApplyServiceRollback() {
        AutoTuneApplyResult expected = new AutoTuneApplyResult();
        when(autoTuneApplyService.rollbackLastSuccessfulJob("bad result")).thenReturn(expected);
        AutoTuneApplyResult result = tools.revertLastAutoTuneJob("bad result");
        assertSame(expected, result);
        verify(autoTuneApplyService).rollbackLastSuccessfulJob("bad result");
    }
    @Test
    void rollbackToolBlocksRollbackInAnalysisOnlyMode() {
        AutoTuneApplyResult expected = new AutoTuneApplyResult();
        expected.setSuccess(false);
        expected.setAnalysisOnly(true);
        expected.setNoApply(true);
        expected.setRejectCount(1);
        when(autoTuneApplyService.rollbackLastSuccessfulJob("bad result")).thenReturn(expected);
        AutoTuneApplyResult result = tools.revertLastAutoTuneJob("bad result");
@@ -563,7 +687,8 @@
        AutoTuneAgentServiceImpl service = new AutoTuneAgentServiceImpl(
                llmChatService,
                mcpToolManager,
                aiPromptTemplateService);
                aiPromptTemplateService,
                autoTuneControlModeService);
        AiPromptTemplate promptTemplate = new AiPromptTemplate();
        promptTemplate.setContent("system prompt");
        when(aiPromptTemplateService.resolvePublished("wcs_auto_tune_dispatch")).thenReturn(promptTemplate);
@@ -658,6 +783,72 @@
    }
    @Test
    void agentCallsMcpForRealApplyInAnalysisOnlyMode() {
        when(configService.getConfigValue("aiAutoTuneAnalysisOnly", "Y")).thenReturn("Y");
        AutoTuneAgentServiceImpl service = agentService();
        when(mcpToolManager.buildOpenAiTools()).thenReturn(allowedOpenAiTools());
        when(mcpToolManager.callTool(any(), any(JSONObject.class))).thenAnswer(invocation -> {
            String toolName = invocation.getArgument(0);
            if ("wcs_local_dispatch_apply_auto_tune_changes".equals(toolName)) {
                return rejectedAnalysisOnlyResult(false);
            }
            return Collections.singletonMap("ok", true);
        });
        when(llmChatService.chatCompletionOrThrow(any(), anyDouble(), anyInt(), any()))
                .thenReturn(
                        response("snapshot", toolCall("call_1", "wcs_local_dispatch_get_auto_tune_snapshot",
                                "{}"), 10, 5),
                        response("apply", toolCall("call_2", "wcs_local_dispatch_apply_auto_tune_changes",
                                "{\"dryRun\":false,\"changes\":[{\"targetType\":\"sys_config\",\"targetKey\":\"conveyorStationTaskLimit\",\"newValue\":\"12\"}]}"), 10, 5),
                        response("stop", null, 10, 5)
                );
        AutoTuneAgentService.AutoTuneAgentResult result = service.runAutoTune("manual");
        assertFalse(result.getSuccess());
        assertTrue(result.getAnalysisOnly());
        assertFalse(result.getAllowApply());
        assertEquals("analysis_only", result.getExecutionMode());
        assertFalse(result.getActualApplyCalled());
        assertEquals(1, result.getRejectCount());
        assertTrue(result.getSummary().contains("仅分析模式禁止实际应用/回滚"));
        verify(mcpToolManager).callTool(eq("wcs_local_dispatch_get_auto_tune_snapshot"), any(JSONObject.class));
        verify(mcpToolManager).callTool(eq("wcs_local_dispatch_apply_auto_tune_changes"), any(JSONObject.class));
    }
    @Test
    void agentCallsMcpForRollbackInAnalysisOnlyMode() {
        when(configService.getConfigValue("aiAutoTuneAnalysisOnly", "Y")).thenReturn("Y");
        AutoTuneAgentServiceImpl service = agentService();
        when(mcpToolManager.buildOpenAiTools()).thenReturn(allowedOpenAiTools());
        when(mcpToolManager.callTool(any(), any(JSONObject.class))).thenAnswer(invocation -> {
            String toolName = invocation.getArgument(0);
            if ("wcs_local_dispatch_revert_last_auto_tune_job".equals(toolName)) {
                return rejectedAnalysisOnlyResult(false);
            }
            return Collections.singletonMap("ok", true);
        });
        when(llmChatService.chatCompletionOrThrow(any(), anyDouble(), anyInt(), any()))
                .thenReturn(
                        response("snapshot", toolCall("call_1", "wcs_local_dispatch_get_auto_tune_snapshot",
                                "{}"), 10, 5),
                        response("rollback", toolCall("call_2", "wcs_local_dispatch_revert_last_auto_tune_job",
                                "{\"reason\":\"bad result\"}"), 10, 5),
                        response("stop", null, 10, 5)
                );
        AutoTuneAgentService.AutoTuneAgentResult result = service.runAutoTune("manual");
        assertFalse(result.getSuccess());
        assertTrue(result.getAnalysisOnly());
        assertTrue(result.getRollbackCalled());
        assertEquals(1, result.getRejectCount());
        assertTrue(result.getSummary().contains("仅分析模式禁止实际应用/回滚"));
        verify(mcpToolManager).callTool(eq("wcs_local_dispatch_get_auto_tune_snapshot"), any(JSONObject.class));
        verify(mcpToolManager).callTool(eq("wcs_local_dispatch_revert_last_auto_tune_job"), any(JSONObject.class));
    }
    @Test
    void agentMarksSnapshotOnlyRunAsNoActualMutationEvenIfAssistantClaimsApplied() {
        AutoTuneAgentServiceImpl service = agentService();
        when(mcpToolManager.buildOpenAiTools()).thenReturn(allowedOpenAiTools());
@@ -674,7 +865,44 @@
        assertFalse(result.getActualApplyCalled());
        assertFalse(result.getRollbackCalled());
        assertEquals(0, result.getSuccessCount());
        assertTrue(result.getSummary().startsWith("自动调参 Agent 未调用实际应用或回滚工具,未修改运行参数。"));
        assertTrue(result.getSummary().contains("自动调参 Agent 未调用实际应用或回滚工具,未修改运行参数。"));
    }
    @Test
    void agentDoesNotMarkNoChangeRealApplyAsActualApplyWhenSuccessCountIsPositive() {
        AutoTuneAgentServiceImpl service = agentService();
        when(mcpToolManager.buildOpenAiTools()).thenReturn(allowedOpenAiTools());
        when(mcpToolManager.callTool(any(), any(JSONObject.class))).thenAnswer(invocation -> {
            String toolName = invocation.getArgument(0);
            if ("wcs_local_dispatch_apply_auto_tune_changes".equals(toolName)) {
                LinkedHashMap<String, Object> noChange = new LinkedHashMap<>();
                noChange.put("resultStatus", "no_change");
                LinkedHashMap<String, Object> applyOutput = new LinkedHashMap<>();
                applyOutput.put("success", true);
                applyOutput.put("dryRun", false);
                applyOutput.put("successCount", 1);
                applyOutput.put("rejectCount", 0);
                applyOutput.put("changes", Collections.singletonList(noChange));
                return applyOutput;
            }
            return Collections.singletonMap("ok", true);
        });
        when(llmChatService.chatCompletionOrThrow(any(), anyDouble(), anyInt(), any()))
                .thenReturn(
                        response("snapshot", toolCall("call_1", "wcs_local_dispatch_get_auto_tune_snapshot",
                                "{}"), 10, 5),
                        response("apply no change", toolCall("call_2", "wcs_local_dispatch_apply_auto_tune_changes",
                                "{\"dryRun\":false,\"dryRunToken\":\"token-123\",\"changes\":[{\"targetType\":\"sys_config\",\"targetKey\":\"conveyorStationTaskLimit\",\"newValue\":\"12\"}]}"), 10, 5),
                        response("无变更", null, 10, 5)
                );
        AutoTuneAgentService.AutoTuneAgentResult result = service.runAutoTune("manual");
        assertTrue(result.getSuccess());
        assertFalse(result.getActualApplyCalled());
        assertEquals(1, result.getSuccessCount());
        assertEquals(0, result.getRejectCount());
    }
    @Test
@@ -820,12 +1048,17 @@
        AiPromptTemplate promptTemplate = new AiPromptTemplate();
        promptTemplate.setContent("system prompt");
        when(aiPromptTemplateService.resolvePublished("wcs_auto_tune_dispatch")).thenReturn(promptTemplate);
        return new AutoTuneAgentServiceImpl(llmChatService, mcpToolManager, aiPromptTemplateService);
        return new AutoTuneAgentServiceImpl(
                llmChatService,
                mcpToolManager,
                aiPromptTemplateService,
                autoTuneControlModeService);
    }
    private AutoTuneCoordinatorServiceImpl coordinatorService() {
        return new AutoTuneCoordinatorServiceImpl(
                configService,
                autoTuneControlModeService,
                wrkMastService,
                aiAutoTuneJobService,
                aiAutoTuneMcpCallService,
@@ -883,6 +1116,19 @@
        return result;
    }
    private AutoTuneApplyResult rejectedAnalysisOnlyResult(Boolean dryRun) {
        AutoTuneApplyResult result = new AutoTuneApplyResult();
        result.setDryRun(dryRun);
        result.setSuccess(false);
        result.setAnalysisOnly(true);
        result.setNoApply(true);
        result.setSuccessCount(0);
        result.setRejectCount(1);
        result.setSummary("仅分析模式禁止实际应用/回滚,未修改运行参数");
        result.setChanges(new ArrayList<>());
        return result;
    }
    private AutoTuneChangeCommand change(String targetType, String targetId, String targetKey, String newValue) {
        AutoTuneChangeCommand command = new AutoTuneChangeCommand();
        command.setTargetType(targetType);
@@ -892,6 +1138,19 @@
        return command;
    }
    private AiAutoTuneChange applyResultChange(String resultStatus) {
        AiAutoTuneChange change = new AiAutoTuneChange();
        change.setResultStatus(resultStatus);
        return change;
    }
    private List<Object> wrapperParamValues(Wrapper<?> wrapper) {
        if (!(wrapper instanceof AbstractWrapper<?, ?, ?> abstractWrapper)) {
            return Collections.emptyList();
        }
        return new ArrayList<>(abstractWrapper.getParamNameValuePairs().values());
    }
    private List<Object> allowedOpenAiTools() {
        List<Object> tools = new ArrayList<>();
        tools.add(openAiTool("wcs_local_dispatch_get_auto_tune_snapshot"));
src/test/java/com/zy/ai/service/impl/AutoTuneSnapshotServiceImplTest.java
@@ -2,9 +2,14 @@
import com.alibaba.fastjson.JSON;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.zy.ai.domain.autotune.AutoTuneRoutePressureSnapshot;
import com.zy.ai.domain.autotune.AutoTuneRuleSnapshotItem;
import com.zy.ai.domain.autotune.AutoTuneSnapshot;
import com.zy.ai.domain.autotune.AutoTuneStationRuntimeItem;
import com.zy.ai.domain.autotune.AutoTuneTaskDetailItem;
import com.zy.ai.domain.autotune.AutoTuneTaskSnapshot;
import com.zy.ai.service.FlowTopologySnapshotService;
import com.zy.ai.service.RoutePressureSnapshotService;
import com.zy.asrs.entity.BasDevp;
import com.zy.asrs.entity.BasStation;
import com.zy.asrs.entity.WrkMast;
@@ -14,6 +19,8 @@
import com.zy.core.enums.WrkIoType;
import com.zy.core.enums.WrkStsType;
import com.zy.core.model.StationObjModel;
import com.zy.core.model.protocol.StationProtocol;
import com.zy.system.service.ConfigService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
@@ -26,6 +33,7 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
@@ -39,7 +47,11 @@
    @BeforeEach
    void setUp() {
        ConfigService configService = mock(ConfigService.class);
        service = new AutoTuneSnapshotServiceImpl();
        ReflectionTestUtils.setField(service, "configService", configService);
        ReflectionTestUtils.setField(service, "autoTuneControlModeService",
                new AutoTuneControlModeServiceImpl(configService));
    }
    @Test
@@ -147,6 +159,42 @@
    }
    @Test
    void buildSnapshotIncludesRoutePressureSnapshot() {
        WrkMastService wrkMastService = mock(WrkMastService.class);
        FlowTopologySnapshotService flowTopologySnapshotService = mock(FlowTopologySnapshotService.class);
        RoutePressureSnapshotService routePressureSnapshotService = mock(RoutePressureSnapshotService.class);
        AutoTuneRoutePressureSnapshot routePressureSnapshot = new AutoTuneRoutePressureSnapshot();
        List<WrkMast> activeTasks = Collections.singletonList(
                outboundTask(190263, WrkStsType.NEW_OUTBOUND.sts, 101, 1,
                        "BATCH", 8, "目标出库站:101 已达出库任务上限,当前=1,上限=1")
        );
        when(wrkMastService.list(any(Wrapper.class))).thenReturn(activeTasks);
        when(flowTopologySnapshotService.buildSnapshot(any())).thenReturn(Collections.emptyList());
        when(routePressureSnapshotService.buildSnapshot(any(), any(), any())).thenReturn(routePressureSnapshot);
        ReflectionTestUtils.setField(service, "wrkMastService", wrkMastService);
        ReflectionTestUtils.setField(service, "flowTopologySnapshotService", flowTopologySnapshotService);
        ReflectionTestUtils.setField(service, "routePressureSnapshotService", routePressureSnapshotService);
        AutoTuneSnapshot snapshot = service.buildSnapshot();
        assertSame(routePressureSnapshot, snapshot.getRoutePressureSnapshot());
        assertEquals(1, snapshot.getTaskSnapshot().getActiveTaskCount());
        ArgumentCaptor<List<WrkMast>> activeTasksCaptor = ArgumentCaptor.forClass(List.class);
        ArgumentCaptor<AutoTuneTaskSnapshot> taskSnapshotCaptor = ArgumentCaptor.forClass(AutoTuneTaskSnapshot.class);
        ArgumentCaptor<List<AutoTuneStationRuntimeItem>> stationRuntimeCaptor = ArgumentCaptor.forClass(List.class);
        verify(routePressureSnapshotService).buildSnapshot(
                activeTasksCaptor.capture(),
                taskSnapshotCaptor.capture(),
                stationRuntimeCaptor.capture()
        );
        assertSame(activeTasks, activeTasksCaptor.getValue());
        assertSame(snapshot.getTaskSnapshot(), taskSnapshotCaptor.getValue());
        assertSame(snapshot.getStationRuntimeSnapshot(), stationRuntimeCaptor.getValue());
        verify(wrkMastService).list(any(Wrapper.class));
    }
    @Test
    void loadOutStationListOnlyQueriesBasDevpOutStations() {
        BasStationService basStationService = mock(BasStationService.class);
        BasDevpService basDevpService = mock(BasDevpService.class);
@@ -190,6 +238,20 @@
        verify(basStationService, never()).list(any(Wrapper.class));
    }
    @Test
    void toRuntimeItemExposesRunBlockForRoutePressure() {
        StationProtocol protocol = new StationProtocol();
        protocol.setStationId(101);
        protocol.setAutoing(true);
        protocol.setLoading(false);
        protocol.setTaskNo(0);
        protocol.setRunBlock(true);
        AutoTuneStationRuntimeItem item = ReflectionTestUtils.invokeMethod(service, "toRuntimeItem", protocol);
        assertEquals(1, item.getRunBlock());
    }
    private BasStation station(Integer stationId, Integer outTaskLimit) {
        return station(stationId, outTaskLimit, null);
    }
src/test/java/com/zy/ai/service/impl/RoutePressureSnapshotServiceImplTest.java
New file
@@ -0,0 +1,507 @@
package com.zy.ai.service.impl;
import com.zy.ai.domain.autotune.AutoTuneRoutePressureSnapshot;
import com.zy.ai.domain.autotune.AutoTuneHotPathSegmentItem;
import com.zy.ai.domain.autotune.AutoTuneStationRuntimeItem;
import com.zy.ai.domain.autotune.AutoTuneTargetStationRoutePressureItem;
import com.zy.ai.domain.autotune.AutoTuneTaskDetailItem;
import com.zy.ai.domain.autotune.AutoTuneTaskSnapshot;
import com.zy.asrs.domain.vo.StationTaskTraceVo;
import com.zy.asrs.entity.WrkMast;
import com.zy.common.model.NavigateNode;
import com.zy.common.utils.NavigateUtils;
import com.zy.core.enums.WrkIoType;
import com.zy.core.enums.WrkStsType;
import com.zy.core.trace.StationTaskTraceRegistry;
import com.zy.core.utils.station.StationOutboundDecisionSupport;
import com.zy.system.service.ConfigService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class RoutePressureSnapshotServiceImplTest {
    private RoutePressureSnapshotServiceImpl service;
    private StationTaskTraceRegistry traceRegistry;
    private NavigateUtils navigateUtils;
    private StationOutboundDecisionSupport stationOutboundDecisionSupport;
    private ConfigService configService;
    @BeforeEach
    void setUp() {
        service = new RoutePressureSnapshotServiceImpl();
        traceRegistry = mock(StationTaskTraceRegistry.class);
        navigateUtils = mock(NavigateUtils.class);
        stationOutboundDecisionSupport = mock(StationOutboundDecisionSupport.class);
        configService = mock(ConfigService.class);
        ReflectionTestUtils.setField(service, "stationTaskTraceRegistry", traceRegistry);
        ReflectionTestUtils.setField(service, "navigateUtils", navigateUtils);
        ReflectionTestUtils.setField(service, "stationOutboundDecisionSupport", stationOutboundDecisionSupport);
        ReflectionTestUtils.setField(service, "configService", configService);
    }
    @Test
    void buildSnapshotPrefixesCurrentStationBeforePendingTracePath() {
        StationTaskTraceVo trace = new StationTaskTraceVo();
        trace.setTaskNo(190263);
        trace.setCurrentStationId(196);
        trace.setPendingStationIds(Arrays.asList(186, 101));
        when(traceRegistry.listPlanningActiveTraceSnapshots()).thenReturn(Collections.singletonList(trace));
        AutoTuneRoutePressureSnapshot snapshot = service.buildSnapshot(
                Collections.singletonList(outboundTask(190263, 196, 101, 8)),
                taskSnapshotWithBlockedTask(190263),
                runtimeItems()
        );
        assertEquals(1, snapshot.getAnalyzedTaskCount());
        assertEquals(1, snapshot.getTracePathCount());
        assertEquals(0, snapshot.getEstimatedPathCount());
        assertEquals(0, snapshot.getPathErrorCount());
        assertEquals("trace_pending", snapshot.getTaskRouteSamples().get(0).getPathSource());
        assertEquals(Arrays.asList(196, 186, 101), snapshot.getTaskRouteSamples().get(0).getPathStationIds());
        verify(navigateUtils, never()).calcOptimalPathByStationId(any(), any(), any(), any());
    }
    @Test
    void buildSnapshotMarksRunningStationTaskMissingWhenTraceAbsent() {
        when(traceRegistry.listPlanningActiveTraceSnapshots()).thenReturn(Collections.emptyList());
        AutoTuneRoutePressureSnapshot snapshot = service.buildSnapshot(
                Collections.singletonList(outboundTask(190263, 196, 101, 8)),
                emptyTaskSnapshot(),
                runtimeItems()
        );
        assertEquals(1, snapshot.getAnalyzedTaskCount());
        assertEquals(0, snapshot.getTracePathCount());
        assertEquals(0, snapshot.getEstimatedPathCount());
        assertEquals(1, snapshot.getPathErrorCount());
        assertEquals("missing", snapshot.getTaskRouteSamples().get(0).getPathSource());
        assertEquals("missing station trace for running station task",
                snapshot.getTaskRouteSamples().get(0).getPathError());
        verify(navigateUtils, never()).calcOptimalPathByStationId(any(), any(), any(), any());
    }
    @Test
    void buildSnapshotEstimatesPathFromSourceStaNoToTargetStaNoForNonRunningTask() {
        when(traceRegistry.listPlanningActiveTraceSnapshots()).thenReturn(Collections.emptyList());
        when(stationOutboundDecisionSupport.resolveOutboundPathLenFactor(any())).thenReturn(0.0d);
        when(navigateUtils.calcOptimalPathByStationId(eq(196), eq(101), eq(190263), eq(0.0d)))
                .thenReturn(navigateNodes(196, 186, 101));
        AutoTuneRoutePressureSnapshot snapshot = service.buildSnapshot(
                Collections.singletonList(nonRunningOutboundTask(190263, 196, 101, 8)),
                emptyTaskSnapshot(),
                runtimeItems()
        );
        assertEquals(1, snapshot.getAnalyzedTaskCount());
        assertEquals(0, snapshot.getTracePathCount());
        assertEquals(1, snapshot.getEstimatedPathCount());
        assertEquals(0, snapshot.getPathErrorCount());
        assertEquals("estimated", snapshot.getTaskRouteSamples().get(0).getPathSource());
        assertEquals(Arrays.asList(196, 186, 101), snapshot.getTaskRouteSamples().get(0).getPathStationIds());
        verify(navigateUtils).calcOptimalPathByStationId(196, 101, 190263, 0.0d);
    }
    @Test
    void buildSnapshotEstimatesNewOutboundTaskEvenWhenResidualTraceExists() {
        when(traceRegistry.listPlanningActiveTraceSnapshots()).thenReturn(Collections.singletonList(
                trace(190263, 196, 888, 101)
        ));
        when(stationOutboundDecisionSupport.resolveOutboundPathLenFactor(any())).thenReturn(0.0d);
        when(navigateUtils.calcOptimalPathByStationId(eq(196), eq(101), eq(190263), eq(0.0d)))
                .thenReturn(navigateNodes(196, 186, 101));
        AutoTuneRoutePressureSnapshot snapshot = service.buildSnapshot(
                Collections.singletonList(nonRunningOutboundTask(190263, 196, 101, 8)),
                emptyTaskSnapshot(),
                runtimeItems()
        );
        assertEquals(1, snapshot.getAnalyzedTaskCount());
        assertEquals(0, snapshot.getTracePathCount());
        assertEquals(1, snapshot.getEstimatedPathCount());
        assertEquals(0, snapshot.getPathErrorCount());
        assertEquals("estimated", snapshot.getTaskRouteSamples().get(0).getPathSource());
        assertEquals(Arrays.asList(196, 186, 101), snapshot.getTaskRouteSamples().get(0).getPathStationIds());
        verify(navigateUtils).calcOptimalPathByStationId(196, 101, 190263, 0.0d);
    }
    @Test
    void buildSnapshotMarksRunningStationTaskMissingWhenTraceHasNoUsableStationPath() {
        StationTaskTraceVo trace = new StationTaskTraceVo();
        trace.setTaskNo(190263);
        trace.setPendingStationIds(Collections.emptyList());
        trace.setLatestIssuedSegmentPath(Collections.emptyList());
        trace.setIssuedStationIds(Collections.emptyList());
        when(traceRegistry.listPlanningActiveTraceSnapshots()).thenReturn(Collections.singletonList(trace));
        AutoTuneRoutePressureSnapshot snapshot = service.buildSnapshot(
                Collections.singletonList(outboundTask(190263, 196, 101, 8)),
                emptyTaskSnapshot(),
                runtimeItems()
        );
        assertEquals(1, snapshot.getAnalyzedTaskCount());
        assertEquals(0, snapshot.getTracePathCount());
        assertEquals(0, snapshot.getEstimatedPathCount());
        assertEquals(1, snapshot.getPathErrorCount());
        assertEquals("missing", snapshot.getTaskRouteSamples().get(0).getPathSource());
        assertEquals("missing usable station trace path for running station task",
                snapshot.getTaskRouteSamples().get(0).getPathError());
        verify(navigateUtils, never()).calcOptimalPathByStationId(any(), any(), any(), any());
    }
    @Test
    void buildSnapshotDoesNotReuseEstimatedPathAcrossDifferentTaskNos() {
        when(traceRegistry.listPlanningActiveTraceSnapshots()).thenReturn(Collections.emptyList());
        when(stationOutboundDecisionSupport.resolveOutboundPathLenFactor(any())).thenReturn(0.0d);
        when(navigateUtils.calcOptimalPathByStationId(eq(196), eq(101), eq(190263), eq(0.0d)))
                .thenReturn(navigateNodes(196, 186, 101));
        when(navigateUtils.calcOptimalPathByStationId(eq(196), eq(101), eq(190264), eq(0.0d)))
                .thenReturn(navigateNodes(196, 194, 101));
        AutoTuneRoutePressureSnapshot snapshot = service.buildSnapshot(
                Arrays.asList(
                        nonRunningOutboundTask(190263, 196, 101, 8),
                        nonRunningOutboundTask(190264, 196, 101, 9)
                ),
                emptyTaskSnapshot(),
                runtimeItems()
        );
        assertEquals(2, snapshot.getEstimatedPathCount());
        assertEquals(190263, snapshot.getTaskRouteSamples().get(0).getWrkNo());
        assertEquals(Arrays.asList(196, 186, 101), snapshot.getTaskRouteSamples().get(0).getPathStationIds());
        assertEquals(190264, snapshot.getTaskRouteSamples().get(1).getWrkNo());
        assertEquals(Arrays.asList(196, 194, 101), snapshot.getTaskRouteSamples().get(1).getPathStationIds());
        verify(navigateUtils).calcOptimalPathByStationId(196, 101, 190263, 0.0d);
        verify(navigateUtils).calcOptimalPathByStationId(196, 101, 190264, 0.0d);
    }
    @Test
    void buildSnapshotMarksMissingWhenSourceOrTargetStationIsMissing() {
        when(traceRegistry.listPlanningActiveTraceSnapshots()).thenReturn(Collections.emptyList());
        AutoTuneRoutePressureSnapshot snapshot = service.buildSnapshot(
                Collections.singletonList(nonRunningOutboundTask(190263, null, 101, 8)),
                emptyTaskSnapshot(),
                runtimeItems()
        );
        assertEquals(1, snapshot.getAnalyzedTaskCount());
        assertEquals(0, snapshot.getTracePathCount());
        assertEquals(0, snapshot.getEstimatedPathCount());
        assertEquals(1, snapshot.getPathErrorCount());
        assertEquals("missing", snapshot.getTaskRouteSamples().get(0).getPathSource());
        assertTrue(snapshot.getTaskRouteSamples().get(0).getPathError().contains("missing sourceStaNo or staNo"));
        verify(navigateUtils, never()).calcOptimalPathByStationId(any(), any(), any(), any());
    }
    @Test
    void buildSnapshotUsesCurrentStationOnlyWhenCurrentIsTargetAndPendingIsEmpty() {
        StationTaskTraceVo trace = new StationTaskTraceVo();
        trace.setTaskNo(190263);
        trace.setCurrentStationId(101);
        trace.setPendingStationIds(Collections.emptyList());
        trace.setLatestIssuedSegmentPath(Arrays.asList(196, 186, 101));
        trace.setIssuedStationIds(Arrays.asList(196, 186, 101));
        when(traceRegistry.listPlanningActiveTraceSnapshots()).thenReturn(Collections.singletonList(trace));
        AutoTuneRoutePressureSnapshot snapshot = service.buildSnapshot(
                Collections.singletonList(outboundTask(190263, 196, 101, 8)),
                taskSnapshotWithBlockedTask(190263),
                Collections.singletonList(runtimeItem(101, 1, 1, 190263, 1))
        );
        assertEquals(1, snapshot.getTaskRouteSamples().size());
        assertEquals("trace_pending", snapshot.getTaskRouteSamples().get(0).getPathSource());
        assertEquals(Arrays.asList(101), snapshot.getTaskRouteSamples().get(0).getPathStationIds());
        assertEquals(1, snapshot.getTaskRouteSamples().get(0).getPathLength());
        assertEquals(1, snapshot.getTracePathCount());
        assertEquals(0, snapshot.getEstimatedPathCount());
        assertEquals(0, snapshot.getPathErrorCount());
        assertTrue(snapshot.getHotPathSegments().isEmpty());
        assertTrue(snapshot.getTargetStationRoutePressure().isEmpty());
        verify(navigateUtils, never()).calcOptimalPathByStationId(any(), any(), any(), any());
    }
    @Test
    void buildSnapshotKeepsSingleStationSampleOutOfPressureAggregation() {
        when(traceRegistry.listPlanningActiveTraceSnapshots()).thenReturn(Collections.singletonList(
                trace(190263, 101)
        ));
        AutoTuneRoutePressureSnapshot snapshot = service.buildSnapshot(
                Collections.singletonList(outboundTask(190263, 101, 101, 8)),
                taskSnapshotWithBlockedTask(190263),
                Collections.singletonList(runtimeItem(101, 1, 1, 190263, 1))
        );
        assertEquals(1, snapshot.getTaskRouteSamples().size());
        assertEquals(Arrays.asList(101), snapshot.getTaskRouteSamples().get(0).getPathStationIds());
        assertEquals(1, snapshot.getTaskRouteSamples().get(0).getPathLength());
        assertEquals(1, snapshot.getTracePathCount());
        assertTrue(snapshot.getHotPathSegments().isEmpty());
        assertTrue(snapshot.getTargetStationRoutePressure().isEmpty());
    }
    @Test
    void buildSnapshotAggregatesOverlappingRouteSegmentsByPassCountAndTargets() {
        when(traceRegistry.listPlanningActiveTraceSnapshots()).thenReturn(Arrays.asList(
                trace(190263, 1, 2, 3, 4, 5),
                trace(190264, 1, 2, 3, 4, 6),
                trace(190265, 7, 1, 2, 3, 4)
        ));
        AutoTuneRoutePressureSnapshot snapshot = service.buildSnapshot(
                Arrays.asList(
                        outboundTask(190263, 1, 101, 1),
                        outboundTask(190264, 1, 102, 2),
                        outboundTask(190265, 7, 101, 3)
                ),
                emptyTaskSnapshot(),
                runtimeItems()
        );
        AutoTuneHotPathSegmentItem sharedSegment = findHotPathSegment(snapshot, "1-2-3-4");
        assertNotNull(sharedSegment);
        assertEquals(Arrays.asList(1, 2, 3, 4), sharedSegment.getStationIds());
        assertEquals(3, sharedSegment.getPassTaskCount());
        assertEquals(Arrays.asList(101, 102), sharedSegment.getRelatedTargetStations());
        assertEquals(Arrays.asList(190263, 190264, 190265), sharedSegment.getSampleWrkNos());
    }
    @Test
    void buildSnapshotUsesOnlyValidRouteSamplesForSegmentPassRatio() {
        when(traceRegistry.listPlanningActiveTraceSnapshots()).thenReturn(Collections.singletonList(
                trace(190263, 1, 2, 3, 4)
        ));
        AutoTuneRoutePressureSnapshot snapshot = service.buildSnapshot(
                Arrays.asList(
                        outboundTask(190263, 1, 101, 1),
                        outboundTask(190264, 5, 101, 2)
                ),
                emptyTaskSnapshot(),
                runtimeItems()
        );
        AutoTuneHotPathSegmentItem segment = findHotPathSegment(snapshot, "1-2-3-4");
        assertNotNull(segment);
        assertEquals(2, snapshot.getAnalyzedTaskCount());
        assertEquals(1, snapshot.getPathErrorCount());
        assertEquals(1, segment.getPassTaskCount());
        assertEquals(100, segment.getPressureFactors().get("passRatio"));
    }
    @Test
    void buildSnapshotExposesRoutePressureRuleSnapshotFromSysConfig() {
        when(configService.getConfigValue(eq("aiAutoTuneRoutePressureMediumPercent"), eq("50"))).thenReturn("40");
        when(configService.getConfigValue(eq("aiAutoTuneRoutePressureHighPercent"), eq("75"))).thenReturn("70");
        when(configService.getConfigValue(eq("aiAutoTuneRoutePressurePassWeightPercent"), eq("35"))).thenReturn("60");
        AutoTuneRoutePressureSnapshot snapshot = service.buildSnapshot(
                Collections.emptyList(),
                emptyTaskSnapshot(),
                runtimeItems()
        );
        assertNotNull(snapshot.getRoutePressureRuleSnapshot());
        assertEquals(4, snapshot.getRoutePressureRuleSnapshot().getSegmentWindowSize());
        assertEquals(40, snapshot.getRoutePressureRuleSnapshot().getMediumPercent());
        assertEquals(70, snapshot.getRoutePressureRuleSnapshot().getHighPercent());
        assertEquals(60, snapshot.getRoutePressureRuleSnapshot().getPassWeightPercent());
        assertEquals(25, snapshot.getRoutePressureRuleSnapshot().getOccupiedWeightPercent());
    }
    @Test
    void buildSnapshotTreatsRunBlockAsWeightedFactorInsteadOfDirectHighPressure() {
        when(traceRegistry.listPlanningActiveTraceSnapshots()).thenReturn(Collections.singletonList(
                trace(190263, 1, 2, 3, 4)
        ));
        AutoTuneRoutePressureSnapshot snapshot = service.buildSnapshot(
                Collections.singletonList(outboundTask(190263, 1, 101, 1)),
                emptyTaskSnapshot(),
                Collections.singletonList(runtimeItem(2, 1, 0, 0, 1))
        );
        AutoTuneHotPathSegmentItem segment = findHotPathSegment(snapshot, "1-2-3-4");
        assertNotNull(segment);
        assertEquals("low", segment.getPressureLevel());
        assertEquals(38, segment.getPressureScore());
        assertEquals(25, segment.getPressureFactors().get("runBlockRatio"));
    }
    @Test
    void buildSnapshotUsesBlockedTasksAsIncreaseCandidateWhenPressureIsNotHigh() {
        when(traceRegistry.listPlanningActiveTraceSnapshots()).thenReturn(Collections.singletonList(
                trace(190263, 1, 2, 101)
        ));
        AutoTuneRoutePressureSnapshot snapshot = service.buildSnapshot(
                Collections.singletonList(outboundTask(190263, 1, 101, 1)),
                taskSnapshotWithBlockedTask(190263),
                runtimeItems()
        );
        AutoTuneTargetStationRoutePressureItem targetPressure = findTargetPressure(snapshot, 101);
        assertNotNull(targetPressure);
        assertEquals("medium", targetPressure.getPressureLevel());
        assertEquals(55, targetPressure.getPressureScore());
        assertEquals("increase_candidate", targetPressure.getRecommendedDirection());
        assertTrue(targetPressure.getReason().contains("路径事实"));
        assertTrue(targetPressure.getReason().contains("blockedTaskCount=1"));
    }
    @Test
    void buildSnapshotMarksHighPressureWithPercentageScoreAndFactEvidence() {
        List<WrkMast> activeTasks = new ArrayList<>();
        List<StationTaskTraceVo> traces = new ArrayList<>();
        for (int index = 0; index < 10; index++) {
            int wrkNo = 190300 + index;
            activeTasks.add(outboundTask(wrkNo, 1, 101, index));
            traces.add(trace(wrkNo, 1, 2, 3, 4));
        }
        when(traceRegistry.listPlanningActiveTraceSnapshots()).thenReturn(traces);
        AutoTuneRoutePressureSnapshot snapshot = service.buildSnapshot(
                activeTasks,
                emptyTaskSnapshot(),
                Arrays.asList(
                        runtimeItem(1, 0, 1, 190300, 1),
                        runtimeItem(2, 0, 1, 190301, 1),
                        runtimeItem(3, 0, 1, 190302, 1),
                        runtimeItem(4, 0, 1, 190303, 1)
                )
        );
        AutoTuneTargetStationRoutePressureItem targetPressure = findTargetPressure(snapshot, 101);
        assertNotNull(targetPressure);
        assertEquals("high", targetPressure.getPressureLevel());
        assertEquals("decrease_candidate", targetPressure.getRecommendedDirection());
        assertEquals("decrease_candidate", targetPressure.getHeuristicDirection());
        assertTrue(targetPressure.getPressureScore() >= 75);
        assertTrue(targetPressure.getRecommendedTargets().contains("station/101/outTaskLimit"));
        assertTrue(targetPressure.getReason().contains("路径事实"));
        assertTrue(targetPressure.getReason().contains("blockedTaskCount=0"));
        assertTrue(targetPressure.getReason().contains("routeTaskCount=10"));
        assertTrue(targetPressure.getPressureFactors().containsKey("highestSegmentScore"));
    }
    private WrkMast outboundTask(Integer wrkNo, Integer sourceStaNo, Integer targetStaNo, Integer batchSeq) {
        return outboundTask(wrkNo, sourceStaNo, targetStaNo, batchSeq, WrkStsType.STATION_RUN.sts);
    }
    private WrkMast nonRunningOutboundTask(Integer wrkNo,
                                           Integer sourceStaNo,
                                           Integer targetStaNo,
                                           Integer batchSeq) {
        return outboundTask(wrkNo, sourceStaNo, targetStaNo, batchSeq, WrkStsType.NEW_OUTBOUND.sts);
    }
    private WrkMast outboundTask(Integer wrkNo,
                                 Integer sourceStaNo,
                                 Integer targetStaNo,
                                 Integer batchSeq,
                                 long wrkSts) {
        WrkMast task = new WrkMast();
        task.setWrkNo(wrkNo);
        task.setIoType(WrkIoType.OUT.id);
        task.setWrkSts(wrkSts);
        task.setBatch("MANUAL_OUT_20260427181201");
        task.setBatchSeq(batchSeq);
        task.setSourceStaNo(sourceStaNo);
        task.setStaNo(targetStaNo);
        return task;
    }
    private AutoTuneTaskSnapshot taskSnapshotWithBlockedTask(Integer wrkNo) {
        AutoTuneTaskDetailItem blockedTask = new AutoTuneTaskDetailItem();
        blockedTask.setWrkNo(wrkNo);
        AutoTuneTaskSnapshot snapshot = new AutoTuneTaskSnapshot();
        snapshot.setStationLimitBlockedTasks(Collections.singletonList(blockedTask));
        return snapshot;
    }
    private AutoTuneTaskSnapshot emptyTaskSnapshot() {
        AutoTuneTaskSnapshot snapshot = new AutoTuneTaskSnapshot();
        snapshot.setStationLimitBlockedTasks(Collections.emptyList());
        return snapshot;
    }
    private List<AutoTuneStationRuntimeItem> runtimeItems() {
        return Collections.emptyList();
    }
    private AutoTuneStationRuntimeItem runtimeItem(Integer stationId,
                                                   Integer autoing,
                                                   Integer loading,
                                                   Integer taskNo,
                                                   Integer runBlock) {
        AutoTuneStationRuntimeItem runtimeItem = new AutoTuneStationRuntimeItem();
        runtimeItem.setStationId(stationId);
        runtimeItem.setAutoing(autoing);
        runtimeItem.setLoading(loading);
        runtimeItem.setTaskNo(taskNo);
        runtimeItem.setRunBlock(runBlock);
        return runtimeItem;
    }
    private StationTaskTraceVo trace(Integer wrkNo, Integer... pendingStationIds) {
        StationTaskTraceVo trace = new StationTaskTraceVo();
        trace.setTaskNo(wrkNo);
        trace.setPendingStationIds(Arrays.asList(pendingStationIds));
        return trace;
    }
    private AutoTuneHotPathSegmentItem findHotPathSegment(AutoTuneRoutePressureSnapshot snapshot, String segmentKey) {
        return snapshot.getHotPathSegments().stream()
                .filter(segment -> segmentKey.equals(segment.getSegmentKey()))
                .findFirst()
                .orElse(null);
    }
    private AutoTuneTargetStationRoutePressureItem findTargetPressure(AutoTuneRoutePressureSnapshot snapshot,
                                                                      Integer targetStationId) {
        return snapshot.getTargetStationRoutePressure().stream()
                .filter(item -> targetStationId.equals(item.getTargetStationId()))
                .findFirst()
                .orElse(null);
    }
    private List<NavigateNode> navigateNodes(Integer... stationIds) {
        return Arrays.stream(stationIds)
                .map(this::navigateNode)
                .toList();
    }
    private NavigateNode navigateNode(Integer stationId) {
        NavigateNode navigateNode = new NavigateNode();
        navigateNode.setStationId(stationId);
        return navigateNode;
    }
}
src/test/java/com/zy/ai/utils/AutoTuneWriteBehaviorUtilsTest.java
New file
@@ -0,0 +1,258 @@
package com.zy.ai.utils;
import com.zy.ai.entity.AiAutoTuneChange;
import com.zy.ai.entity.AiAutoTuneJob;
import com.zy.ai.entity.AiAutoTuneMcpCall;
import org.junit.jupiter.api.Test;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
class AutoTuneWriteBehaviorUtilsTest {
    @Test
    void resolveMcpCallDistinguishesDryRunAndAnalysisOnlyApply() {
        AiAutoTuneMcpCall dryRunCall = new AiAutoTuneMcpCall();
        dryRunCall.setToolName("wcs_local_dispatch_apply_auto_tune_changes");
        dryRunCall.setDryRun(1);
        AiAutoTuneMcpCall analysisOnlyCall = new AiAutoTuneMcpCall();
        analysisOnlyCall.setToolName("wcs_local_dispatch_apply_auto_tune_changes");
        analysisOnlyCall.setDryRun(0);
        analysisOnlyCall.setStatus("success");
        analysisOnlyCall.setResponseJson("{\"analysisOnly\":true,\"noApply\":true,\"summary\":\"仅分析模式禁止实际应用\"}");
        assertEquals("dry_run", AutoTuneWriteBehaviorUtils.resolveMcpWriteBehavior(dryRunCall));
        assertEquals("analysis_only", AutoTuneWriteBehaviorUtils.resolveMcpWriteBehavior(analysisOnlyCall));
    }
    @Test
    void resolveChangeDistinguishesNoChangeAndRejectedAnalysisOnly() {
        AiAutoTuneChange noChange = new AiAutoTuneChange();
        noChange.setResultStatus("no_change");
        AiAutoTuneChange analysisOnly = new AiAutoTuneChange();
        analysisOnly.setResultStatus("rejected");
        analysisOnly.setRejectReason("仅分析模式禁止实际应用/回滚,未修改运行参数");
        assertEquals("no_change", AutoTuneWriteBehaviorUtils.resolveChangeWriteBehavior(noChange));
        assertEquals("analysis_only", AutoTuneWriteBehaviorUtils.resolveChangeWriteBehavior(analysisOnly));
    }
    @Test
    void resolveMcpCallClassifiesRollbackOnlyWhenSuccessfulChangeExists() {
        AiAutoTuneMcpCall countSuccessCall = rollbackCall("success");
        countSuccessCall.setSuccessCount(1);
        AiAutoTuneMcpCall responseSuccessCall = rollbackCall("failed");
        responseSuccessCall.setResponseJson("{\"changes\":[{\"resultStatus\":\"success\"}]}");
        assertEquals("rollback", AutoTuneWriteBehaviorUtils.resolveMcpWriteBehavior(countSuccessCall));
        assertEquals("rollback", AutoTuneWriteBehaviorUtils.resolveMcpWriteBehavior(responseSuccessCall));
    }
    @Test
    void resolveMcpCallClassifiesRejectedAndFailedRollbackWithoutSuccessfulChange() {
        AiAutoTuneMcpCall rejectedCall = rollbackCall("rejected");
        rejectedCall.setResponseJson("{\"success\":false,\"rejectCount\":1}");
        AiAutoTuneMcpCall failedCall = rollbackCall("failed");
        failedCall.setErrorMessage("rollback write failed");
        assertEquals("rejected", AutoTuneWriteBehaviorUtils.resolveMcpWriteBehavior(rejectedCall));
        assertEquals("failed", AutoTuneWriteBehaviorUtils.resolveMcpWriteBehavior(failedCall));
    }
    @Test
    void resolveMcpCallPrefersAnalysisOnlyAndNoChangeForRollback() {
        AiAutoTuneMcpCall analysisOnlyCall = rollbackCall("rejected");
        analysisOnlyCall.setResponseJson("{\"analysisOnly\":true,\"noApply\":true}");
        AiAutoTuneMcpCall noChangeCall = rollbackCall("success");
        noChangeCall.setResponseJson("{\"success\":true,\"successCount\":0,\"rejectCount\":0}");
        assertEquals("analysis_only", AutoTuneWriteBehaviorUtils.resolveMcpWriteBehavior(analysisOnlyCall));
        assertEquals("no_change", AutoTuneWriteBehaviorUtils.resolveMcpWriteBehavior(noChangeCall));
    }
    @Test
    void resolveMcpCallDoesNotTreatAnalysisOnlyFalseTextAsAnalysisOnly() {
        AiAutoTuneMcpCall applyCall = new AiAutoTuneMcpCall();
        applyCall.setToolName("wcs_local_dispatch_apply_auto_tune_changes");
        applyCall.setDryRun(0);
        applyCall.setStatus("success");
        applyCall.setSuccessCount(1);
        applyCall.setResponseJson("{\"success\":true,\"successCount\":1,\"summary\":\"analysisOnly=false, allowApply=true\"}");
        assertEquals("apply", AutoTuneWriteBehaviorUtils.resolveMcpWriteBehavior(applyCall));
    }
    @Test
    void resolveMcpCallUsesResponseChangeDetailsBeforeSuccessCountForApply() {
        AiAutoTuneMcpCall noChangeCall = applyCall(0, "success");
        noChangeCall.setSuccessCount(1);
        noChangeCall.setResponseJson("{\"success\":true,\"successCount\":1,"
                + "\"changes\":[{\"resultStatus\":\"no_change\"},{\"resultStatus\":\"no_change\"}]}");
        AiAutoTuneMcpCall mixedSuccessCall = applyCall(0, "success");
        mixedSuccessCall.setSuccessCount(0);
        mixedSuccessCall.setResponseJson("{\"success\":true,\"successCount\":0,"
                + "\"changes\":[{\"resultStatus\":\"no_change\"},{\"resultStatus\":\"success\"}]}");
        assertEquals("no_change", AutoTuneWriteBehaviorUtils.resolveMcpWriteBehavior(noChangeCall));
        assertEquals("apply", AutoTuneWriteBehaviorUtils.resolveMcpWriteBehavior(mixedSuccessCall));
    }
    @Test
    void resolveMcpCallPrefersFailedAndRejectedOverDryRunAndNoChange() {
        AiAutoTuneMcpCall failedDryRunCall = applyCall(1, "failed");
        AiAutoTuneMcpCall rejectedNoChangeCall = applyCall(0, "rejected");
        rejectedNoChangeCall.setResponseJson("{\"success\":true,\"successCount\":0,\"rejectCount\":0}");
        assertEquals("failed", AutoTuneWriteBehaviorUtils.resolveMcpWriteBehavior(failedDryRunCall));
        assertEquals("rejected", AutoTuneWriteBehaviorUtils.resolveMcpWriteBehavior(rejectedNoChangeCall));
    }
    @Test
    void resolveChangeUsesOwnerTriggerTypeForRollbackSuccess() {
        AiAutoTuneChange successChange = new AiAutoTuneChange();
        successChange.setResultStatus("success");
        assertEquals("apply", AutoTuneWriteBehaviorUtils.resolveChangeWriteBehavior(successChange));
        assertEquals("rollback", AutoTuneWriteBehaviorUtils.resolveChangeWriteBehavior(successChange, "rollback"));
        assertEquals("apply", AutoTuneWriteBehaviorUtils.resolveChangeWriteBehavior(successChange, "manual"));
    }
    @Test
    void resolveChangeClassifiesRejectedAndFailed() {
        AiAutoTuneChange rejectedChange = new AiAutoTuneChange();
        rejectedChange.setResultStatus("rejected");
        rejectedChange.setRejectReason("超出允许范围");
        AiAutoTuneChange failedChange = new AiAutoTuneChange();
        failedChange.setResultStatus("failed");
        assertEquals("rejected", AutoTuneWriteBehaviorUtils.resolveChangeWriteBehavior(rejectedChange));
        assertEquals("failed", AutoTuneWriteBehaviorUtils.resolveChangeWriteBehavior(failedChange));
    }
    @Test
    void writeBehaviorLabelIncludesRejectedAndFailed() {
        assertEquals("已拒绝", AutoTuneWriteBehaviorUtils.writeBehaviorLabel("rejected"));
        assertEquals("失败", AutoTuneWriteBehaviorUtils.writeBehaviorLabel("failed"));
    }
    @Test
    void resolveJobPrefersReadOnlyWhenOnlySnapshotToolsWereCalled() {
        AiAutoTuneJob job = new AiAutoTuneJob();
        job.setStatus("success");
        Map<String, Object> snapshotCall = new LinkedHashMap<>();
        snapshotCall.put("writeBehavior", "read_only");
        String writeBehavior = AutoTuneWriteBehaviorUtils.resolveJobWriteBehavior(
                job,
                Collections.singletonList(snapshotCall),
                List.of()
        );
        assertEquals("read_only", writeBehavior);
    }
    @Test
    void resolveJobPrefersFailedAndRejectedOverDryRunNoChangeAndReadOnly() {
        AiAutoTuneJob failedDryRunJob = new AiAutoTuneJob();
        failedDryRunJob.setStatus("failed");
        Map<String, Object> dryRunCall = new LinkedHashMap<>();
        dryRunCall.put("writeBehavior", "dry_run");
        AiAutoTuneJob rejectedNoChangeJob = new AiAutoTuneJob();
        rejectedNoChangeJob.setStatus("rejected");
        Map<String, Object> noChange = new LinkedHashMap<>();
        noChange.put("writeBehavior", "no_change");
        AiAutoTuneJob failedReadOnlyJob = new AiAutoTuneJob();
        failedReadOnlyJob.setStatus("failed");
        Map<String, Object> readOnlyCall = new LinkedHashMap<>();
        readOnlyCall.put("writeBehavior", "read_only");
        assertEquals("failed", AutoTuneWriteBehaviorUtils.resolveJobWriteBehavior(
                failedDryRunJob,
                List.of(dryRunCall),
                List.of()
        ));
        assertEquals("rejected", AutoTuneWriteBehaviorUtils.resolveJobWriteBehavior(
                rejectedNoChangeJob,
                List.of(),
                List.of(noChange)
        ));
        assertEquals("failed", AutoTuneWriteBehaviorUtils.resolveJobWriteBehavior(
                failedReadOnlyJob,
                List.of(readOnlyCall),
                List.of()
        ));
    }
    @Test
    void resolveJobClassifiesDirectSuccessfulRollbackWithoutMcpCalls() {
        AiAutoTuneJob job = new AiAutoTuneJob();
        job.setTriggerType("rollback");
        job.setStatus("success");
        job.setSuccessCount(1);
        String writeBehavior = AutoTuneWriteBehaviorUtils.resolveJobWriteBehavior(
                job,
                List.of(),
                List.of()
        );
        assertEquals("rollback", writeBehavior);
    }
    @Test
    void resolveJobPreservesRollbackWhenSomeRollbackChangesFailed() {
        AiAutoTuneJob job = new AiAutoTuneJob();
        job.setTriggerType("rollback");
        job.setStatus("partial_success");
        Map<String, Object> successChange = new LinkedHashMap<>();
        successChange.put("writeBehavior", "rollback");
        successChange.put("resultStatus", "success");
        Map<String, Object> failedChange = new LinkedHashMap<>();
        failedChange.put("writeBehavior", "failed");
        failedChange.put("resultStatus", "failed");
        String writeBehavior = AutoTuneWriteBehaviorUtils.resolveJobWriteBehavior(
                job,
                List.of(),
                List.of(successChange, failedChange)
        );
        assertEquals("rollback", writeBehavior);
    }
    private AiAutoTuneMcpCall applyCall(Integer dryRun, String status) {
        AiAutoTuneMcpCall mcpCall = new AiAutoTuneMcpCall();
        mcpCall.setToolName("wcs_local_dispatch_apply_auto_tune_changes");
        mcpCall.setDryRun(dryRun);
        mcpCall.setStatus(status);
        return mcpCall;
    }
    private AiAutoTuneMcpCall rollbackCall(String status) {
        AiAutoTuneMcpCall mcpCall = new AiAutoTuneMcpCall();
        mcpCall.setToolName("wcs_local_dispatch_revert_last_auto_tune_job");
        mcpCall.setStatus(status);
        mcpCall.setSuccessCount(0);
        return mcpCall;
    }
}