feat(aigc): D11 质量评分参数外置进配置集(W-CFG-EXT E1-E3)
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled

背景/目的:D11 就绪评分的四维权重与效率预算阈值原本硬编码在 ReadinessScorer
里,放量校准要改代码换 jar;本提交把它们收进阶段二配置集治理,改一版即生效、可
回滚、可归因(SoT §10「校准走配置、不在现场拍脑袋」的配置载体,不改任何命题)。

范围/做法(E1-E3):
- E1 content_json 加第三块 readiness(十字段,值=ReadinessScorer 常量)+ 档位枚举
  加跨档特例值 READINESS(无 Nacos dataId、进程内消费);codec 加 readiness 值域
  sanity(四维权重和≈1.0±0.001/各∈[0,1]/预算>0/repairs_good≤ok/子项分∈[0,1])
  与 routeA/routeB 互斥;NO_ROUTE 判据扩为 !hasA&&!hasB&&!hasReadiness(空串/{}
  仍红)。
- E2 新 ReadinessConfigProvider(读激活版 readiness、短 TTL 缓存、逐字段回落、读失
  败不抛)+ GenConfigService.getActiveVersionBySetKey(按 uk_set_key 定位、读不到返
  null);ReadinessScorer 从 provider 取权重阈值、硬编码常量降为回落默认(一个不删)
  、@Autowired(required=false) 可选注入不破坏既有 new ReadinessScorer() 单测;顺修
  :19/:151 陈旧「预算<=0.15」注释(实际 0.6)。
- E3 激活编排一行不改:readiness-only 经 codec 产空载荷,现有 dispatch/reconcile
  在空载荷下天然短路成「值域 sanity + 账本收口、无投影、恒一致」。

建模:按计划倾向用枚举值 READINESS(§7 待拍点,创始人未明拍,可 revise 为与 tier
正交的 kind 维度)。契约先落(content_json.readiness schema/javadoc、
AigcConfigTierEnum、GenConfigService 接口、NO_ROUTE 错误码文案)再改实现。

验证:整 game-module-aigc-server 单测 385/0/0(7 skipped=需真基建 RealIT);api
经 -am 从源码重建绿;既有反射测试(CheapWorkerResultOutContractTest)与 score==95
锚点(SaaGraphDispatcherTraceTest)未破坏。真 Spring 上下文 wiring + 真后端 e2e
排部署窗口。

边界:W-CFG-KB(知识件外置)不在本提交(gated 别的依赖);admin 端 readiness 数字
字段编辑表单为 follow-up(本提交只做后端)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-05 18:06:05 -07:00
parent 8200aa08d0
commit 8724501f32
14 changed files with 930 additions and 29 deletions

View File

@ -23,7 +23,16 @@ public enum AigcConfigTierEnum {
/** 便宜档:AI 轻参与的高质轻量游戏(cheap-worker,MiniMax-M3 路,软停线 cheap_rmb_hard_limit=¥10) */
CHEAP("cheap", "便宜档"),
/** 富游戏档:AI 深参与的自治富游戏(tier2 gen-worker,Phaser,软停线 rmb_hard_limit=¥50) */
TIER2("tier2", "富游戏档");
TIER2("tier2", "富游戏档"),
/**
* 质量评分档(W-CFG-EXT · 跨档特例):承载 D11 就绪评分参数(四维权重 + 效率预算阈值 + 稳定性两级阈值
* + 中性子项分),同时给便宜档/富游戏档的游戏打分,不属任何生成档。无 Nacos dataId、无路 A/路 B 外投影——
* 消费方 {@link com.wanxiang.huijing.game.module.aigc.service.task.ReadinessScorer} 就在 game-cloud、与配置集账本
* 同库同进程,由 ReadinessConfigProvider 进程内读激活版本行的 content_json.readiness;激活退化成「值域 sanity +
* 账本指针前移」的纯账本收口。故其配置集内容与 routeA/routeB 互斥(codec 激活前置校验),也不参与按 cheap/tier2
* 拼 Nacos dataId 的下发逻辑。
*/
READINESS("readiness", "质量评分");
/** 档位码(落库 tier 列) */
private final String tier;

View File

@ -91,7 +91,7 @@ public interface ErrorCodeConstants {
/** 值域 sanity 校验不通过:负预算/空模型名/阈值出域/max_tokens≤thinking/chat_model_config 不完整等(设计 §3.6,激活前置,拒绝不发任何一路)。占位符带明细。 */
ErrorCode AIGC_CONFIG_ACTIVATE_SANITY_FAILED = new ErrorCode(1_101_004_009, "配置值域校验不通过:{}");
/** 激活内容缺路由:content_json 未声明可激活的 routeA/routeB(无落点可下发)。 */
ErrorCode AIGC_CONFIG_ACTIVATE_NO_ROUTE = new ErrorCode(1_101_004_010, "配置内容未声明任何可激活的下发路由(routeA/routeB 均缺)");
ErrorCode AIGC_CONFIG_ACTIVATE_NO_ROUTE = new ErrorCode(1_101_004_010, "配置内容未声明任何可激活的下发路由(routeA/routeB/readiness 均缺)");
/** 双路下发失败:某路 PATCH/publish 失败 → 整批判未生效、已把成功路补偿重推回上一激活版、指针不前移、可重试(设计 §3.6)。占位符带明细。 */
ErrorCode AIGC_CONFIG_ACTIVATE_DISPATCH_FAILED = new ErrorCode(1_101_004_011, "配置激活下发失败(已判未生效并补偿回上一激活版,可重试):{}");

View File

@ -10,8 +10,10 @@ package com.wanxiang.huijing.game.module.aigc.service.config;
* <li><b>路 B</b>({@link #pathBJson},可空):publish 进 Nacos 生效 dataId 的 {@code area.key} 扁平 JSON(预算/门阈值)。</li>
* </ul>
*
* <p>两路都可空(但不能都空——那样没有可激活的落点):纯参数档可能只有路 B,纯 prompt/模型档可能只有路 A。是否下发某路
* 由该路是否为空决定({@link #hasPathA()} / {@link #hasPathB()})。
* <p>两路可各自为空:纯参数档可能只有路 B,纯 prompt/模型档可能只有路 A。是否下发某路由该路是否为空决定
* ({@link #hasPathA()} / {@link #hasPathB()})。<b>两路皆空 = 空载荷</b>:这是 W-CFG-EXT 质量评分配置集的合法形态
* (readiness 块进程内消费、无外投影),激活时现有 dispatch/reconcile 天然短路成纯账本收口;生成配置集(routeA/routeB)
* 则至少一路非空(codec 对既无路由又无 readiness 的空内容抛 NO_ROUTE)。
*
* @param pathA 路 A 载荷(PATCH /agent+/session 的目标与请求体);null = 本版本不下发路 A
* @param pathBJson 路 B 载荷(area.key 扁平 JSON 文本,整内容 publish);null = 本版本不下发路 B

View File

@ -44,6 +44,23 @@ import static com.wanxiang.huijing.framework.common.exception.util.ServiceExcept
* 预算为正且落合理区间、超时有下限、system_prompt 非空。deeper 的「type 与 credential type 匹配」由 Service PATCH 侧
* {@code _ensure_credential_exists} 运行时兜(凭据在 Service 存储、game-cloud 侧看不到),失败即路 A 下发失败→补偿。
*
* <p><b>第三块 readiness(W-CFG-EXT · D11 质量评分参数,与 routeA/routeB 互斥)</b>:除按下发路由分的 routeA/routeB 外,
* content_json 可含一个平级的 {@code readiness} 块,承载 D11 就绪评分的十个标量(四维权重 + 中性分 + 效率预算阈值
* + 稳定性两级阈值与两级分)。它是<b>跨档、进程内消费、无外投影</b>的特例——不下发路 A/路 B,由 game-cloud 进程内的
* {@code ReadinessConfigProvider} 直读激活版本行。故本编解码器对只含 readiness 的配置集产出<b>空载荷</b>
* (pathA=pathB=null),现有 dispatch/reconcile 在空载荷下天然短路成「值域 sanity + 账本收口、无投影、恒一致」。
* 字段名与默认起步值 = {@link com.wanxiang.huijing.game.module.aigc.service.task.ReadinessScorer} 的回落常量,契约:
* <pre>
* { "readiness": {
* "playability_weight": 0.50, "first_play_weight": 0.25,
* "stability_weight": 0.15, "efficiency_weight": 0.10,
* "neutral_score": 0.5, "efficiency_budget_rmb": 0.6,
* "stability_repairs_good_max": 0, "stability_repairs_ok_max": 2,
* "stability_ok_score": 0.7, "stability_bad_score": 0.4 } }
* </pre>
* 激活前置 readiness 值域 sanity(不过即拒):四维权重各 ∈[0,1] 且和 ≈1.0(±0.001)、中性/稳定性两级分 ∈[0,1]、
* 效率预算 > 0、修复轮 good_max ≤ ok_max;并与 routeA/routeB 互斥(readiness 存在时禁二者,反之亦然)。
*
* @author 绘境AI
*/
@Slf4j
@ -70,18 +87,96 @@ public final class GenConfigContentCodec {
JsonNode root = parseRoot(contentJson);
JsonNode routeA = root.get("routeA");
JsonNode routeB = root.get("routeB");
JsonNode readiness = root.get(ReadinessConfigKeys.BLOCK);
boolean hasA = routeA != null && routeA.isObject();
boolean hasB = routeB != null && routeB.isObject() && routeB.size() > 0;
if (!hasA && !hasB) {
boolean hasReadiness = readiness != null && readiness.isObject();
if (!hasA && !hasB && !hasReadiness) {
// 无任何可下发路由:没有落点(设计 §4 步骤3 交付以「双路下发」为核心,至少一路)
throw exception(AIGC_CONFIG_ACTIVATE_NO_ROUTE);
}
// 互斥(W-CFG-EXT):质量评分块是跨档、进程内消费、无外投影,不与生成下发路(routeA/routeB)共存于同一配置集。
// 若一个质量评分配置集误混 routeB,激活会按 tier=readiness 拼出伪 dataId(resolveDataId 不认档但仍拼)真 publish;
// 误混 routeA 则 resolveServiceUrl 返 null 下发失败。故激活前置拒,让 enum-value 建模对既有按档下发逻辑安全。
if (hasReadiness && (hasA || hasB)) {
throw sanity("readiness 块与 routeA/routeB 互斥:质量评分配置集不下发 prompt/模型/预算/门阈值,不得混配生成路");
}
if (hasReadiness) {
// 质量评分配置集:只做值域 sanity,不产任何外投影载荷。空载荷(pathA=pathB=null)→ 现有 dispatch/reconcile
// 天然短路成「sanity + 账本收口、无投影、恒一致」(激活编排一行不改,见 GenConfigActivationServiceImpl.dispatch)。
validateReadiness(readiness);
return new GenConfigActivationPayload(null, null);
}
GenConfigActivationPayload.PathA pathA = hasA ? buildPathA(prompt, routeA) : null;
String pathBJson = hasB ? buildPathB(routeB) : null;
return new GenConfigActivationPayload(pathA, pathBJson);
}
// ==================== readiness:D11 质量评分参数 值域 sanity(W-CFG-EXT) ====================
/**
* 校验 content_json.readiness 块值域(激活前置,不过即拒):四维权重各 ∈[0,1] 且和 ≈1.0(容差 ±0.001)、
* 中性分与稳定性两级分 ∈[0,1]、效率预算 > 0、修复轮 good_max ≤ ok_max。字段全须齐(缺即拒)——一版完整的 D11
* 参数集才准激活;运行期个别字段缺失的逐字段回落是 ReadinessConfigProvider 对陈旧/异常激活版的防御,不放宽本处
* 「新激活须完整」的闸(两个时刻:新激活须完整过闸,已激活版遇 schema 增长靠 provider 逐字段回落存活)。
* 字段值域与默认起步值语义见 ReadinessScorer 各回落常量。
*/
private void validateReadiness(JsonNode r) {
double wPlay = requireUnit(r, ReadinessConfigKeys.PLAYABILITY_WEIGHT);
double wFirst = requireUnit(r, ReadinessConfigKeys.FIRST_PLAY_WEIGHT);
double wStab = requireUnit(r, ReadinessConfigKeys.STABILITY_WEIGHT);
double wEff = requireUnit(r, ReadinessConfigKeys.EFFICIENCY_WEIGHT);
double sum = wPlay + wFirst + wStab + wEff;
if (Math.abs(sum - 1.0) > 0.001) {
throw sanity(String.format("readiness 四维权重和=%.4f 偏离 1.0(容差 ±0.001)", sum));
}
// 中性分 + 稳定性两级分:∈[0,1]
requireUnit(r, ReadinessConfigKeys.NEUTRAL_SCORE);
requireUnit(r, ReadinessConfigKeys.STABILITY_OK_SCORE);
requireUnit(r, ReadinessConfigKeys.STABILITY_BAD_SCORE);
// 效率维预算阈值:须为正
double budget = requireNumber(r, ReadinessConfigKeys.EFFICIENCY_BUDGET_RMB);
if (budget <= 0) {
throw sanity("readiness." + ReadinessConfigKeys.EFFICIENCY_BUDGET_RMB + "=" + budget + " 须为正");
}
// 稳定性修复轮阈值:good_max ≤ ok_max(整数)
int goodMax = requireInt(r, ReadinessConfigKeys.STABILITY_REPAIRS_GOOD_MAX);
int okMax = requireInt(r, ReadinessConfigKeys.STABILITY_REPAIRS_OK_MAX);
if (goodMax > okMax) {
throw sanity(String.format("readiness.%s(%d) 须 <= %s(%d)",
ReadinessConfigKeys.STABILITY_REPAIRS_GOOD_MAX, goodMax,
ReadinessConfigKeys.STABILITY_REPAIRS_OK_MAX, okMax));
}
}
/** 取必填数值字段(缺失/非数值即 sanity 拒)。 */
private double requireNumber(JsonNode r, String field) {
JsonNode v = r.get(field);
if (v == null || !v.isNumber()) {
throw sanity("readiness." + field + " 缺失或非数值");
}
return v.asDouble();
}
/** 取必填单位区间 [0,1] 字段(缺失/非数值/越界即拒)。 */
private double requireUnit(JsonNode r, String field) {
double v = requireNumber(r, field);
if (v < 0 || v > 1) {
throw sanity("readiness." + field + "=" + v + " 越界 [0,1]");
}
return v;
}
/** 取必填整数字段(缺失/非整数即拒;浮点如 2.5 也拒——修复轮阈值是整数)。 */
private int requireInt(JsonNode r, String field) {
JsonNode v = r.get(field);
if (v == null || !v.isIntegralNumber()) {
throw sanity("readiness." + field + " 缺失或非整数");
}
return v.asInt();
}
// ==================== 路 A:/agent + /session 请求体组装 + 值域校验 ====================
private GenConfigActivationPayload.PathA buildPathA(String prompt, JsonNode routeA) {

View File

@ -96,6 +96,22 @@ public interface GenConfigService {
*/
GenConfigVersionDO getVersion(Long configSetId, Long versionId);
/**
* 按业务键取「当前激活版本行」(供进程内消费方直读激活配置,如 D11 质量评分参数 W-CFG-EXT)。
*
* <p>与 {@link #getVersion} 的差别:getVersion 按 versionId 精确取任一历史版、不存在即抛;本方法按配置集业务键
* 定位其当前激活版(active_version_id 指向的那一行),且<b>读不到不抛、返回 null</b>——配置集不存在、从未激活
* (active_version_id 为 NULL)、或激活版行缺失,都返 null,交调用方回落默认(消费侧非阻断:D11 打分读不到激活版
* 就用硬编码默认,绝不因配置缺失打挂回调)。
*
* <p>按 setKey(而非 tier)定位:uk_set_key 保证至多一条,寻址无歧义;跨档的质量评分配置集是单例(约定业务键
* {@link ReadinessConfigKeys#READINESS_SET_KEY}),按 tier 取则一档可有多集、有歧义,故走业务键。
*
* @param setKey 配置集业务键
* @return 当前激活版本行 DO;配置集不存在/从未激活/激活版缺失 → null
*/
GenConfigVersionDO getActiveVersionBySetKey(String setKey);
/**
* 两版 diff(应用层做):prompt 给「是否变更 + 两侧字节数」,结构化旋钮拍平成点路径逐项比对给增/删/改。
*

View File

@ -210,6 +210,18 @@ public class GenConfigServiceImpl implements GenConfigService {
return version;
}
@Override
public GenConfigVersionDO getActiveVersionBySetKey(String setKey) {
// 按业务键定位配置集(uk_set_key 至多一条);不存在 → null(消费侧回落,不抛)
GenConfigSetDO set = configSetMapper.selectBySetKey(setKey);
if (set == null || set.getActiveVersionId() == null) {
// 配置集未建 / 从未激活(active_version_id 为 NULL)→ 无当前激活版
return null;
}
// 取当前激活版指针指向的版本行(理论上必归属本集;缺失则 null,交调用方回落)
return configVersionMapper.selectByIdAndSetId(set.getActiveVersionId(), set.getId());
}
@Override
public GenConfigVersionDiffRespVO diffVersions(Long configSetId, Long versionAId, Long versionBId) {
GenConfigVersionDO a = getVersion(configSetId, versionAId);

View File

@ -0,0 +1,57 @@
package com.wanxiang.huijing.game.module.aigc.service.config;
/**
* D11 质量评分配置集(W-CFG-EXT)的内容契约常量:content_json 里 {@code readiness} 块的块名与十个字段名,
* 外加质量评分配置集的约定业务键。
*
* <p><b>为何单独成类</b>:这批字段名是「配置集内容(doc)」与「读取消费(code)」之间的唯一约定——
* {@link GenConfigContentCodec} 激活前置的 readiness 值域 sanity 与 {@code ReadinessConfigProvider} 的逐字段解析
* 都引用它,把字段名收进一处避免两边各写一份字符串字面量、漂移无人对账。字段值(默认起步值)不在这里,
* 在 {@link com.wanxiang.huijing.game.module.aigc.service.task.ReadinessScorer} 的回落默认常量里——那里才是
* 「热源缺失时用哪个数」的权威,本类只管「用哪个键名读」。
*
* <p>字段值域与默认值语义见 {@code ReadinessScorer} 各常量的 Javadoc;字段名采直白 snake_case,与打分器四维/
* 稳定性两级/中性分/效率预算一一对应。
*
* @author 绘境AI(W-CFG-EXT · D11 参数外置)
*/
public final class ReadinessConfigKeys {
private ReadinessConfigKeys() {
}
/** content_json 里承载 D11 参数的块名(平级于 routeA/routeB,互斥)。 */
public static final String BLOCK = "readiness";
/**
* 质量评分配置集的约定业务键(set_key)。跨档单例:全租户内质量评分配置集用这一个业务键,
* {@code ReadinessConfigProvider} 据它定位当前激活版(uk_set_key 保证至多一条,寻址无歧义)。
* 运营在配置中心新建质量评分配置集时须用此业务键,否则 provider 找不到激活版、打分器回落硬编码默认。
*/
public static final String READINESS_SET_KEY = "readiness-default";
// ---- 四维权重(和≈1.0)----
/** 可玩性权重。 */
public static final String PLAYABILITY_WEIGHT = "playability_weight";
/** 首局权重。 */
public static final String FIRST_PLAY_WEIGHT = "first_play_weight";
/** 稳定性权重。 */
public static final String STABILITY_WEIGHT = "stability_weight";
/** 效率权重。 */
public static final String EFFICIENCY_WEIGHT = "efficiency_weight";
// ---- 子项参数 ----
/** 数据缺失时的中性子项分。 */
public static final String NEUTRAL_SCORE = "neutral_score";
/** 效率维预算阈值(成本 ≤ 此值算满分)。 */
public static final String EFFICIENCY_BUDGET_RMB = "efficiency_budget_rmb";
/** 稳定性:修复轮 ≤ 此值算满分(1.0)。 */
public static final String STABILITY_REPAIRS_GOOD_MAX = "stability_repairs_good_max";
/** 稳定性:修复轮 ≤ 此值算次优(ok 分),超则劣(bad 分)。 */
public static final String STABILITY_REPAIRS_OK_MAX = "stability_repairs_ok_max";
/** 稳定性次优分(good < 修复轮 ≤ ok 时取值)。 */
public static final String STABILITY_OK_SCORE = "stability_ok_score";
/** 稳定性劣分(修复轮 > ok 时取值)。 */
public static final String STABILITY_BAD_SCORE = "stability_bad_score";
}

View File

@ -0,0 +1,142 @@
package com.wanxiang.huijing.game.module.aigc.service.config;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.wanxiang.huijing.game.module.aigc.dal.dataobject.config.GenConfigVersionDO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* D11 质量评分参数的进程内读取面(W-CFG-EXT):把「当前激活的质量评分配置集」version 行里的 content_json.readiness
* 解析成 {@link ReadinessView},供 {@link com.wanxiang.huijing.game.module.aigc.service.task.ReadinessScorer} 打分时取用。
*
* <p><b>为何进程内直读账本、不走投影</b>:D11 参数的消费方就是同库同进程的打分器,不像 prompt/模型(路 A PATCH Service)
* 或预算/门阈值(路 B publish Nacos)要投影到外部系统。所以这里直接读 MySQL 账本的激活版本行,是配置控制面阶段二
* 「消费两路」之外补齐的第三条消费口(game-cloud 进程内读激活版)。
*
* <p><b>短 TTL 缓存,不接激活失效信号</b>:打分发生在回调落库这条本就 DB-heavy 的路上,一次缓存读可忽略;用短 TTL
* 失效(而非让激活链发进程内失效信号)把配置中心与打分器解耦——激活一版权重后,最迟一个 TTL 内下一次打分即读到新值,
* 不必把 provider 耦合进激活编排。TTL 很短,「改一版即生效」的体感仍在秒级。
*
* <p><b>永不抛、逐字段回落</b>:未建质量评分配置集 / 未激活 / DB 读异常 / content_json 非法 / 某字段缺失或类型不符——
* 任一情形都不抛异常打挂回调,退化成「该字段用打分器硬编码默认」。整份读不到 → {@link ReadinessView#empty()}
* (全字段回落);读到但缺某字段 → 仅该字段为 null(仅它回落)。回落方向永远收敛到硬编码默认,保证「配置集出任何
* 问题,打分退化成现状、绝不更差」。
*
* @author 绘境AI(W-CFG-EXT · D11 参数外置)
*/
@Slf4j
@Component
public class ReadinessConfigProvider {
/** content_json 解析器(无共享可变状态,线程安全)。 */
private static final ObjectMapper JSON = new ObjectMapper();
/** 缓存短 TTL 默认值(毫秒):激活一版后最迟这么久下一次打分读到新值。 */
private static final long DEFAULT_TTL_MS = 5_000L;
@Resource
private GenConfigService genConfigService;
/** 缓存 TTL(毫秒);测试可经包内构造器设 0 强制每次重读。 */
private final long ttlMs;
/** 缓存视图(volatile 读写;短窗内并发多读一次刷新只多几次 DB,可接受,不加锁保简单)。 */
private volatile ReadinessView cachedView;
/** 缓存过期时刻(epoch millis);now ≥ 它则重读。 */
private volatile long cacheExpireAtMs;
/** Spring 用:默认短 TTL。 */
public ReadinessConfigProvider() {
this.ttlMs = DEFAULT_TTL_MS;
}
/** 测试用(包可见):自定义 TTL(设 0 = 每次重读,便于验解析/回落而不受缓存干扰);genConfigService 由测试注入。 */
ReadinessConfigProvider(GenConfigService genConfigService, long ttlMs) {
this.genConfigService = genConfigService;
this.ttlMs = ttlMs;
}
/**
* 取当前激活质量评分配置集的 D11 参数视图(短 TTL 缓存;缺/失败逐字段回落)。
*
* @return 只读视图(永不为 null;读不到则 {@link ReadinessView#empty()})
*/
public ReadinessView get() {
long now = System.currentTimeMillis();
ReadinessView cached = cachedView;
if (cached != null && now < cacheExpireAtMs) {
return cached;
}
ReadinessView fresh = load();
cachedView = fresh;
cacheExpireAtMs = now + ttlMs;
return fresh;
}
/** 读激活版 + 解析成视图;任何异常/缺失都收敛到全回落(empty),绝不抛。 */
private ReadinessView load() {
try {
GenConfigVersionDO version =
genConfigService.getActiveVersionBySetKey(ReadinessConfigKeys.READINESS_SET_KEY);
if (version == null) {
// 未建质量评分配置集 / 未激活过 → 全字段回落硬编码默认
return ReadinessView.empty();
}
JsonNode readiness = extractReadiness(version.getContentJson());
if (readiness == null) {
log.warn("[readiness-config] 激活版无 content_json.readiness 块,全回落硬编码默认 versionId={}", version.getId());
return ReadinessView.empty();
}
return parse(readiness);
} catch (Exception e) {
// DB 读异常等:退化成全回落,不打挂回调(非阻断哲学)
log.warn("[readiness-config] 读激活质量评分配置集失败,全回落硬编码默认", e);
return ReadinessView.empty();
}
}
/** 从 content_json 取 readiness 块(非法 JSON / 无该块 → null)。 */
private JsonNode extractReadiness(String contentJson) {
if (contentJson == null || contentJson.isBlank()) {
return null;
}
try {
JsonNode root = JSON.readTree(contentJson);
JsonNode readiness = root == null ? null : root.get(ReadinessConfigKeys.BLOCK);
return (readiness != null && readiness.isObject()) ? readiness : null;
} catch (Exception e) {
log.warn("[readiness-config] content_json 非法 JSON,全回落 len={}", contentJson.length());
return null;
}
}
/** 逐字段解析成视图:字段缺失/类型不符 → 该字段 null(仅它回落),不影响其余字段。 */
private ReadinessView parse(JsonNode r) {
return new ReadinessView(
doubleOrNull(r, ReadinessConfigKeys.PLAYABILITY_WEIGHT),
doubleOrNull(r, ReadinessConfigKeys.FIRST_PLAY_WEIGHT),
doubleOrNull(r, ReadinessConfigKeys.STABILITY_WEIGHT),
doubleOrNull(r, ReadinessConfigKeys.EFFICIENCY_WEIGHT),
doubleOrNull(r, ReadinessConfigKeys.NEUTRAL_SCORE),
doubleOrNull(r, ReadinessConfigKeys.EFFICIENCY_BUDGET_RMB),
intOrNull(r, ReadinessConfigKeys.STABILITY_REPAIRS_GOOD_MAX),
intOrNull(r, ReadinessConfigKeys.STABILITY_REPAIRS_OK_MAX),
doubleOrNull(r, ReadinessConfigKeys.STABILITY_OK_SCORE),
doubleOrNull(r, ReadinessConfigKeys.STABILITY_BAD_SCORE));
}
/** 取数值字段;缺失或非数值 → null(该字段回落)。 */
private static Double doubleOrNull(JsonNode node, String field) {
JsonNode v = node.get(field);
return (v != null && v.isNumber()) ? v.asDouble() : null;
}
/** 取整数字段;缺失或非整数(含浮点)→ null(该字段回落)。 */
private static Integer intOrNull(JsonNode node, String field) {
JsonNode v = node.get(field);
return (v != null && v.isIntegralNumber()) ? v.asInt() : null;
}
}

View File

@ -0,0 +1,92 @@
package com.wanxiang.huijing.game.module.aigc.service.config;
/**
* D11 质量评分参数的只读视图(W-CFG-EXT):当前激活质量评分配置集里 content_json.readiness 各字段的进程内快照。
*
* <p><b>逐字段回落语义</b>:每个字段可空——{@code null} 表示激活版里没有该字段(或未激活/读取异常整份为空)。
* 取值 getter 都要求传入一个回落默认(打分器的硬编码常量),字段非空取激活版值、字段空取回落默认。
* 这样「某个字段缺失只回落那一个,不因一个字段缺就整份弃用激活版」在一处兑现,调用方(打分器)只管把自己的
* 常量作为回落默认传进来。{@link #empty()} 是全空视图,任何字段都取回落默认——等价于「无激活版、全用硬编码」,
* 用于 provider 缺席(如未接配置中心的单测)或读取失败。
*
* <p><b>只读、无副作用</b>:构造后不可变;provider 短 TTL 缓存的就是本视图实例,多线程打分共享读安全。
*
* @author 绘境AI(W-CFG-EXT · D11 参数外置)
*/
public final class ReadinessView {
// 四维权重(null=激活版缺该字段→回落)
private final Double playabilityWeight;
private final Double firstPlayWeight;
private final Double stabilityWeight;
private final Double efficiencyWeight;
// 子项参数
private final Double neutralScore;
private final Double efficiencyBudgetRmb;
private final Integer stabilityRepairsGoodMax;
private final Integer stabilityRepairsOkMax;
private final Double stabilityOkScore;
private final Double stabilityBadScore;
public ReadinessView(Double playabilityWeight, Double firstPlayWeight, Double stabilityWeight,
Double efficiencyWeight, Double neutralScore, Double efficiencyBudgetRmb,
Integer stabilityRepairsGoodMax, Integer stabilityRepairsOkMax,
Double stabilityOkScore, Double stabilityBadScore) {
this.playabilityWeight = playabilityWeight;
this.firstPlayWeight = firstPlayWeight;
this.stabilityWeight = stabilityWeight;
this.efficiencyWeight = efficiencyWeight;
this.neutralScore = neutralScore;
this.efficiencyBudgetRmb = efficiencyBudgetRmb;
this.stabilityRepairsGoodMax = stabilityRepairsGoodMax;
this.stabilityRepairsOkMax = stabilityRepairsOkMax;
this.stabilityOkScore = stabilityOkScore;
this.stabilityBadScore = stabilityBadScore;
}
/** 全空视图:任何字段取回落默认(等价「无激活版、全用硬编码常量」)。 */
public static ReadinessView empty() {
return new ReadinessView(null, null, null, null, null, null, null, null, null, null);
}
public double playabilityWeight(double fallback) {
return playabilityWeight != null ? playabilityWeight : fallback;
}
public double firstPlayWeight(double fallback) {
return firstPlayWeight != null ? firstPlayWeight : fallback;
}
public double stabilityWeight(double fallback) {
return stabilityWeight != null ? stabilityWeight : fallback;
}
public double efficiencyWeight(double fallback) {
return efficiencyWeight != null ? efficiencyWeight : fallback;
}
public double neutralScore(double fallback) {
return neutralScore != null ? neutralScore : fallback;
}
public double efficiencyBudgetRmb(double fallback) {
return efficiencyBudgetRmb != null ? efficiencyBudgetRmb : fallback;
}
public int stabilityRepairsGoodMax(int fallback) {
return stabilityRepairsGoodMax != null ? stabilityRepairsGoodMax : fallback;
}
public int stabilityRepairsOkMax(int fallback) {
return stabilityRepairsOkMax != null ? stabilityRepairsOkMax : fallback;
}
public double stabilityOkScore(double fallback) {
return stabilityOkScore != null ? stabilityOkScore : fallback;
}
public double stabilityBadScore(double fallback) {
return stabilityBadScore != null ? stabilityBadScore : fallback;
}
}

View File

@ -1,6 +1,9 @@
package com.wanxiang.huijing.game.module.aigc.service.task;
import com.wanxiang.huijing.game.module.aigc.service.config.ReadinessConfigProvider;
import com.wanxiang.huijing.game.module.aigc.service.config.ReadinessView;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.Map;
@ -16,7 +19,7 @@ import java.util.Map;
* - playability 占半壁(九门全过 = 就绪基线):trace.pass==true → 1.0;否则 0(九门是硬地板)。
* - firstPlay(首局):trace.gatespec 有 driver 且 sevenGateVerdict.guards.H_progress==true → 1.0;否则 0.5。
* - stability(稳定性):repairs==0 → 1.0;repairs<=2 → 0.7;否则 0.4(修复轮越少越就绪)。
* - efficiency(效率):cost.totalRmb 在预算内(<=0.15) → 1.0;超则线性降到 0(成本可控度)。
* - efficiency(效率):cost.totalRmb 在预算阈值内(起步 ¥0.6,W-CFG-EXT 可热配) → 1.0;超则线性降到 0(成本可控度)。
* - 任一子项数据缺失(如 cost 取价失败)→ 该子项取中性 0.5,不让缺数据把分打到 0(非阻断哲学)。
*
* 【权重占位(执行版 §9-2 待校准)】playability 0.50 / firstPlay 0.25 / stability 0.15 / efficiency 0.10——
@ -43,12 +46,35 @@ public class ReadinessScorer {
/**
* 效率子项的预算阈值(成本 totalRmb <= 此值算满分,超则线性降)。
* 起步值 ¥0.6(创始人 2026-07-04 决策5「按推荐」——原 0.15 与便宜档真实成本 ¥0.4–1.2 错配、
* 效率维恒 0 零区分力,取便宜档成本中位纠正)。当前为硬编码常量:改要动代码 + 换 jar;
* 待随 D11 就绪评分参数(本阈值 + 四维权重)外置进配置中心(决策6「进配置集」涟漪),届时
* 本常量降为热源缺失时的回落默认、真值由激活版本行下发。
* 效率维恒 0 零区分力,取便宜档成本中位纠正)。已随 D11 就绪评分参数(本阈值 + 四维权重)外置进配置中心
* (W-CFG-EXT,决策6「进配置集」涟漪):本常量降为热源缺失时的回落默认,真值由激活的质量评分配置集版本行
* (content_json.readiness.efficiency_budget_rmb)经 ReadinessConfigProvider 下发,改一版即生效、不换 jar。
*/
private static final double BUDGET_RMB = 0.6;
/** 稳定性:修复轮 <= 此值算满分 1.0(回落默认,原内联字面量 r<=0)。 */
private static final int REPAIRS_GOOD_MAX = 0;
/** 稳定性:修复轮 <= 此值算次优 ok 分,超则劣 bad 分(回落默认,原内联字面量 r<=2)。 */
private static final int REPAIRS_OK_MAX = 2;
/** 稳定性次优分(good < 修复轮 <= ok 时取值;回落默认,原内联字面量 0.7)。 */
private static final double STABILITY_OK_SCORE = 0.7;
/** 稳定性劣分(修复轮 > ok 时取值;回落默认,原内联字面量 0.4)。 */
private static final double STABILITY_BAD_SCORE = 0.4;
/**
* D11 质量评分参数的进程内读取面(W-CFG-EXT):打分时取当前激活质量评分配置集的四维权重与阈值,读不到逐字段
* 回落到上面的 static final 常量。<b>可选注入</b>(required=false):Spring 上下文里由 {@link ReadinessConfigProvider}
* bean 注入;单测 {@code new ReadinessScorer()} 时为 null → {@link #view()} 返回 {@link ReadinessView#empty()} →
* 全字段回落硬编码常量(等价外置前行为,故既有单测不受影响)。
*/
@Autowired(required = false)
private ReadinessConfigProvider configProvider;
/** 取当前 D11 参数视图:provider 缺席(单测)/为空 → 全回落硬编码常量。 */
private ReadinessView view() {
return configProvider != null ? configProvider.get() : ReadinessView.empty();
}
/**
* 读 trace 算就绪度分(0-100 整数)
*
@ -61,14 +87,16 @@ public class ReadinessScorer {
return null;
}
try {
double playability = scorePlayability(trace);
double firstPlay = scoreFirstPlay(trace);
double stability = scoreStability(trace);
double efficiency = scoreEfficiency(trace);
double weighted = W_PLAYABILITY * playability
+ W_FIRST_PLAY * firstPlay
+ W_STABILITY * stability
+ W_EFFICIENCY * efficiency;
// 一次解析当前激活的 D11 参数视图(短 TTL 缓存);读不到逐字段回落硬编码常量(W-CFG-EXT)
ReadinessView v = view();
double playability = scorePlayability(trace, v);
double firstPlay = scoreFirstPlay(trace, v);
double stability = scoreStability(trace, v);
double efficiency = scoreEfficiency(trace, v);
double weighted = v.playabilityWeight(W_PLAYABILITY) * playability
+ v.firstPlayWeight(W_FIRST_PLAY) * firstPlay
+ v.stabilityWeight(W_STABILITY) * stability
+ v.efficiencyWeight(W_EFFICIENCY) * efficiency;
// 夹取 [0,1] 后映射 0-100(防权重/子项越界,稳健兜底)
int score = (int) Math.round(100.0 * clamp01(weighted));
return Math.max(0, Math.min(100, score));
@ -81,9 +109,13 @@ public class ReadinessScorer {
/** 可玩性子项:trace.pass==true → 1.0;明确 false → 0;缺失 → 中性 0.5。 */
private double scorePlayability(Map<String, Object> trace) {
return scorePlayability(trace, view());
}
private double scorePlayability(Map<String, Object> trace, ReadinessView v) {
Object pass = trace.get("pass");
if (pass == null) {
return NEUTRAL;
return v.neutralScore(NEUTRAL);
}
return asBool(pass) ? 1.0 : 0.0;
}
@ -98,8 +130,12 @@ public class ReadinessScorer {
* firstPlay 永卡中性 0.5、永不到 1.0(HTTP/SAA 两路同病)。修后:H_progress 是 Map 时读其 {@code pass} 布尔字段
* (真实形态),同时<b>保留对裸 bool 的向后兼容</b>(老格式/测试夹具)。
*/
@SuppressWarnings("unchecked")
private double scoreFirstPlay(Map<String, Object> trace) {
return scoreFirstPlay(trace, view());
}
@SuppressWarnings("unchecked")
private double scoreFirstPlay(Map<String, Object> trace, ReadinessView v) {
Object gatespec = trace.get("gatespec");
boolean hasDriver = gatespec instanceof Map && ((Map<String, Object>) gatespec).get("driver") != null;
Object verdict = trace.get("sevenGateVerdict");
@ -111,7 +147,7 @@ public class ReadinessScorer {
hProgress = guardPass(((Map<String, Object>) guards).get("H_progress"));
}
}
return (hasDriver && hProgress) ? 1.0 : NEUTRAL;
return (hasDriver && hProgress) ? 1.0 : v.neutralScore(NEUTRAL);
}
/**
@ -136,37 +172,50 @@ public class ReadinessScorer {
/** 稳定性子项:repairs==0 → 1.0;<=2 → 0.7;否则 0.4;缺失 → 中性 0.5。 */
private double scoreStability(Map<String, Object> trace) {
return scoreStability(trace, view());
}
private double scoreStability(Map<String, Object> trace, ReadinessView v) {
Object repairs = trace.get("repairs");
if (!(repairs instanceof Number)) {
return NEUTRAL;
return v.neutralScore(NEUTRAL);
}
int r = ((Number) repairs).intValue();
if (r <= 0) {
if (r <= v.stabilityRepairsGoodMax(REPAIRS_GOOD_MAX)) {
return 1.0;
}
return r <= 2 ? 0.7 : 0.4;
return r <= v.stabilityRepairsOkMax(REPAIRS_OK_MAX)
? v.stabilityOkScore(STABILITY_OK_SCORE)
: v.stabilityBadScore(STABILITY_BAD_SCORE);
}
/**
* 效率子项:cost.totalRmb <= 预算(0.15) → 1.0;超则线性降到 0(totalRmb>=2×预算 时 0);
* cost 缺失/取价失败(无 totalRmb)→ 中性 0.5(worker new-api 取价失败时 cost 段降级,不应把分打到 0)。
* 效率子项:cost.totalRmb <= 预算阈值 → 1.0;超则线性降到 0(totalRmb>=2×预算 时 0);
* cost 缺失/取价失败(无 totalRmb)→ 中性分(worker new-api 取价失败时 cost 段降级,不应把分打到 0)。
* 预算阈值取激活的质量评分配置集 {@code efficiency_budget_rmb}(W-CFG-EXT),读不到回落 {@link #BUDGET_RMB}(起步 ¥0.6)。
*/
@SuppressWarnings("unchecked")
private double scoreEfficiency(Map<String, Object> trace) {
return scoreEfficiency(trace, view());
}
@SuppressWarnings("unchecked")
private double scoreEfficiency(Map<String, Object> trace, ReadinessView v) {
double budget = v.efficiencyBudgetRmb(BUDGET_RMB);
Object cost = trace.get("cost");
if (!(cost instanceof Map)) {
return NEUTRAL;
return v.neutralScore(NEUTRAL);
}
Object totalRmb = ((Map<String, Object>) cost).get("totalRmb");
if (!(totalRmb instanceof Number)) {
return NEUTRAL; // 取价失败降级(cost 只剩 error+byModel)→ 中性
return v.neutralScore(NEUTRAL); // 取价失败降级(cost 只剩 error+byModel)→ 中性
}
double rmb = ((Number) totalRmb).doubleValue();
if (rmb <= BUDGET_RMB) {
if (rmb <= budget) {
return 1.0;
}
// 线性降:预算→2×预算 区间从 1.0 降到 0,超 2×预算 钳到 0
double ratio = (rmb - BUDGET_RMB) / BUDGET_RMB;
double ratio = (rmb - budget) / budget;
return clamp01(1.0 - ratio);
}

View File

@ -58,6 +58,13 @@ class GenConfigActivationServiceImplTest extends BaseMockitoUnitTest {
+ "\"routeB\":{\"budget.rmb_hard_limit\":40.0}}";
/** 值域非法内容(负预算)。 */
private static final String BAD_CONTENT = "{\"routeB\":{\"budget.rmb_hard_limit\":-5.0}}";
/** 质量评分配置集内容(readiness-only,W-CFG-EXT):codec 产空载荷、激活天然短路成纯账本收口。 */
private static final String READINESS_CONTENT =
"{\"readiness\":{\"playability_weight\":0.50,\"first_play_weight\":0.25,"
+ "\"stability_weight\":0.15,\"efficiency_weight\":0.10,"
+ "\"neutral_score\":0.5,\"efficiency_budget_rmb\":0.6,"
+ "\"stability_repairs_good_max\":0,\"stability_repairs_ok_max\":2,"
+ "\"stability_ok_score\":0.7,\"stability_bad_score\":0.4}}";
@BeforeEach
void setUp() {
@ -208,6 +215,30 @@ class GenConfigActivationServiceImplTest extends BaseMockitoUnitTest {
verify(genConfigService, never()).abortActivating(any(), any());
}
// ==================== 质量评分配置集(readiness-only,W-CFG-EXT E3):空载荷天然短路 ====================
/**
* E3:质量评分配置集(readiness-only)激活 → codec 产空载荷 → 现有 dispatch 天然短路:
* 账本正常收口(markVersionActivated),但两处投影全程零触碰(Agent Service/Redis 与 Nacos dataId 都不写)——纯账本收口、无外投影。
* 激活编排一行未改(复用现成 dispatch/收口),本例坐实空载荷短路成立。
*/
@Test
void testActivate_readinessOnly_pureLedgerNoDispatch() {
stubSet(1L, AigcConfigStatusEnum.PENDING_REVIEW, null, "readiness"); // 跨档质量评分档
stubVersion(1L, 3000L, null, READINESS_CONTENT); // 无 prompt、只有 readiness 块
when(genConfigService.beginActivating(1L, 3000L))
.thenReturn(new GenConfigActivationSnapshot(AigcConfigStatusEnum.PENDING_REVIEW.getStatus(), null));
service.activate(1L, 3000L);
// 纯账本收口:转 ACTIVE + 前移指针(markVersionActivated),不失败复位
verify(genConfigService).markVersionActivated(1L, 3000L);
verify(genConfigService, never()).abortActivating(any(), any());
// 两处投影零写入:Agent Service(路A/Redis)与 Nacos(路B/dataId)全程不被触碰
verify(agentClient, never()).patch(anyString(), any());
verify(nacosClient, never()).publish(anyString(), any());
}
// ==================== 前置拒绝:不写账本、不下发 ====================
/** 值域非法(负预算)→ 激活前置拒:不落 ACTIVATING、不发任何一路。 */

View File

@ -201,4 +201,108 @@ class GenConfigContentCodecTest {
assertTrue(p.hasPathB());
}
// ==================== readiness:D11 质量评分配置集(W-CFG-EXT) ====================
/** 合法 readiness 起步值(十字段,值 = ReadinessScorer 常量)。 */
private static final String VALID_READINESS =
"{\"readiness\":{\"playability_weight\":0.50,\"first_play_weight\":0.25,"
+ "\"stability_weight\":0.15,\"efficiency_weight\":0.10,"
+ "\"neutral_score\":0.5,\"efficiency_budget_rmb\":0.6,"
+ "\"stability_repairs_good_max\":0,\"stability_repairs_ok_max\":2,"
+ "\"stability_ok_score\":0.7,\"stability_bad_score\":0.4}}";
/** 只有 readiness 块(起步值合法)→ 空载荷(两路皆空):现有 dispatch/reconcile 天然短路成纯账本收口。 */
@Test
void testBuild_readinessOnly_emptyPayload() {
GenConfigActivationPayload p = codec().build(null, VALID_READINESS);
assertFalse(p.hasPathA(), "质量评分配置集不下发路 A");
assertFalse(p.hasPathB(), "质量评分配置集不下发路 B");
assertNull(p.pathA());
assertNull(p.pathBJson());
}
/** readiness 四维权重和≠1.0(如 0.5/0.5/0.5/0.5)→ sanity 拒。 */
@Test
void testReadinessSanity_weightSumNotOne() {
String c = "{\"readiness\":{\"playability_weight\":0.5,\"first_play_weight\":0.5,"
+ "\"stability_weight\":0.5,\"efficiency_weight\":0.5,"
+ "\"neutral_score\":0.5,\"efficiency_budget_rmb\":0.6,"
+ "\"stability_repairs_good_max\":0,\"stability_repairs_ok_max\":2,"
+ "\"stability_ok_score\":0.7,\"stability_bad_score\":0.4}}";
assertEquals(AIGC_CONFIG_ACTIVATE_SANITY_FAILED.getCode(), sanityOf(null, c).getCode());
}
/** readiness 单权重越界 [0,1](如 1.5)→ sanity 拒。 */
@Test
void testReadinessSanity_weightOutOfUnit() {
String c = "{\"readiness\":{\"playability_weight\":1.5,\"first_play_weight\":-0.5,"
+ "\"stability_weight\":0.0,\"efficiency_weight\":0.0,"
+ "\"neutral_score\":0.5,\"efficiency_budget_rmb\":0.6,"
+ "\"stability_repairs_good_max\":0,\"stability_repairs_ok_max\":2,"
+ "\"stability_ok_score\":0.7,\"stability_bad_score\":0.4}}";
assertEquals(AIGC_CONFIG_ACTIVATE_SANITY_FAILED.getCode(), sanityOf(null, c).getCode());
}
/** readiness 预算非正(0)→ sanity 拒。 */
@Test
void testReadinessSanity_budgetNotPositive() {
String c = VALID_READINESS.replace("\"efficiency_budget_rmb\":0.6", "\"efficiency_budget_rmb\":0.0");
assertEquals(AIGC_CONFIG_ACTIVATE_SANITY_FAILED.getCode(), sanityOf(null, c).getCode());
}
/** readiness 修复轮 good_max > ok_max → sanity 拒。 */
@Test
void testReadinessSanity_repairsGoodGtOk() {
String c = VALID_READINESS
.replace("\"stability_repairs_good_max\":0", "\"stability_repairs_good_max\":5")
.replace("\"stability_repairs_ok_max\":2", "\"stability_repairs_ok_max\":2");
assertEquals(AIGC_CONFIG_ACTIVATE_SANITY_FAILED.getCode(), sanityOf(null, c).getCode());
}
/** readiness 子项分越界 [0,1](stability_ok_score=1.7)→ sanity 拒。 */
@Test
void testReadinessSanity_subScoreOutOfUnit() {
String c = VALID_READINESS.replace("\"stability_ok_score\":0.7", "\"stability_ok_score\":1.7");
assertEquals(AIGC_CONFIG_ACTIVATE_SANITY_FAILED.getCode(), sanityOf(null, c).getCode());
}
/** readiness 缺字段(缺 efficiency_budget_rmb)→ sanity 拒(新激活须完整;运行期缺字段回落是 provider 的事)。 */
@Test
void testReadinessSanity_missingField() {
String c = VALID_READINESS.replace(",\"efficiency_budget_rmb\":0.6", "");
assertEquals(AIGC_CONFIG_ACTIVATE_SANITY_FAILED.getCode(), sanityOf(null, c).getCode());
}
/** readiness 修复轮阈值非整数(2.5)→ sanity 拒。 */
@Test
void testReadinessSanity_repairsNotInteger() {
String c = VALID_READINESS.replace("\"stability_repairs_ok_max\":2", "\"stability_repairs_ok_max\":2.5");
assertEquals(AIGC_CONFIG_ACTIVATE_SANITY_FAILED.getCode(), sanityOf(null, c).getCode());
}
/** 互斥:readiness 与 routeB 共存 → sanity 拒(质量评分块不得混配生成下发路)。 */
@Test
void testReadiness_mutexWithRouteB() {
String c = "{\"readiness\":{\"playability_weight\":0.50,\"first_play_weight\":0.25,"
+ "\"stability_weight\":0.15,\"efficiency_weight\":0.10,"
+ "\"neutral_score\":0.5,\"efficiency_budget_rmb\":0.6,"
+ "\"stability_repairs_good_max\":0,\"stability_repairs_ok_max\":2,"
+ "\"stability_ok_score\":0.7,\"stability_bad_score\":0.4},"
+ "\"routeB\":{\"budget.cheap_rmb_hard_limit\":10.0}}";
assertEquals(AIGC_CONFIG_ACTIVATE_SANITY_FAILED.getCode(), sanityOf(null, c).getCode());
}
/** 互斥:readiness 与 routeA 共存 → sanity 拒。 */
@Test
void testReadiness_mutexWithRouteA() {
String c = "{\"readiness\":{\"playability_weight\":0.50,\"first_play_weight\":0.25,"
+ "\"stability_weight\":0.15,\"efficiency_weight\":0.10,"
+ "\"neutral_score\":0.5,\"efficiency_budget_rmb\":0.6,"
+ "\"stability_repairs_good_max\":0,\"stability_repairs_ok_max\":2,"
+ "\"stability_ok_score\":0.7,\"stability_bad_score\":0.4},"
+ "\"routeA\":{\"agentId\":\"a1\",\"sessionId\":\"s1\",\"session\":{\"chat_model_config\":"
+ "{\"type\":\"t\",\"credential_id\":\"c\",\"model\":\"m\",\"parameters\":{}}}}}";
assertEquals(AIGC_CONFIG_ACTIVATE_SANITY_FAILED.getCode(), sanityOf("sys", c).getCode());
}
}

View File

@ -60,6 +60,13 @@ class GenConfigReconcileServiceImplTest extends BaseMockitoUnitTest {
private static final String DATAID = "gen-hot-params-cheap";
private static final String PB_IN_SYNC = "{\"budget.cheap_rmb_hard_limit\":10.0}";
private static final String PB_DRIFT = "{\"budget.cheap_rmb_hard_limit\":999.0}";
/** 质量评分配置集内容(readiness-only,W-CFG-EXT):空载荷、无投影可对账。 */
private static final String READINESS_CONTENT =
"{\"readiness\":{\"playability_weight\":0.50,\"first_play_weight\":0.25,"
+ "\"stability_weight\":0.15,\"efficiency_weight\":0.10,"
+ "\"neutral_score\":0.5,\"efficiency_budget_rmb\":0.6,"
+ "\"stability_repairs_good_max\":0,\"stability_repairs_ok_max\":2,"
+ "\"stability_ok_score\":0.7,\"stability_bad_score\":0.4}}";
@BeforeEach
void setUp() {
@ -257,6 +264,33 @@ class GenConfigReconcileServiceImplTest extends BaseMockitoUnitTest {
verify(nacosClient, never()).probeDataId(anyString());
}
/**
* E3:质量评分配置集(readiness-only)对账 → codec 产空载荷 → 两路 applicable=false → IN_SYNC、零漂移,
* 且两路投影零探测(无路A/路B 可对账)。坐实「漂移对账对质量评分配置集报无投影、无漂移」。
*/
@Test
void testReconcile_readinessOnly_inSyncNoProjection() {
GenConfigSetDO set = new GenConfigSetDO();
set.setId(1L);
set.setTier("readiness"); // 跨档质量评分档
set.setStatus(AigcConfigStatusEnum.ACTIVE.getStatus());
set.setActiveVersionId(300L);
when(genConfigService.getConfigSet(1L)).thenReturn(set);
GenConfigVersionDO v = new GenConfigVersionDO();
v.setId(300L);
v.setConfigSetId(1L);
v.setContentJson(READINESS_CONTENT); // 只有 readiness 块 → 空载荷
when(genConfigService.getVersion(1L, 300L)).thenReturn(v);
GenConfigDriftReportRespVO report = service.reconcile(1L);
assertEquals("IN_SYNC", report.getVerdict(), "质量评分配置集无投影 → 恒一致");
assertTrue(report.getDriftItems().isEmpty(), "无投影、零漂移项");
// 两路投影零探测(无路A/路B 可对账)
verify(agentClient, never()).probeAgentSystemPrompt(anyString(), anyString());
verify(nacosClient, never()).probeDataId(anyString());
}
// ==================== 最终一致容忍窗 ====================
/** 路B 首读旧值(漂移)、窗内再读已收敛 → 容忍窗吸收传播延迟、IN_SYNC 不误报。 */

View File

@ -0,0 +1,258 @@
package com.wanxiang.huijing.game.module.aigc.service.config;
import com.wanxiang.huijing.game.module.aigc.dal.dataobject.config.GenConfigVersionDO;
import com.wanxiang.huijing.game.module.aigc.service.task.ReadinessScorer;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link ReadinessConfigProvider} 与 {@link ReadinessScorer} 外置读线的单元测试(W-CFG-EXT):把守
* ①未激活/读失败逐字段回落硬编码默认、②读到激活版按字段生效、③单字段缺失只回落该字段、
* ④起步值 = ReadinessScorer 常量的 doc↔code 对账机器门、⑤短 TTL 缓存。纯 Mockito,不依赖 DB/Spring。
*
* @author 绘境AI(W-CFG-EXT · D11 参数外置)
*/
class ReadinessConfigProviderTest {
/** 起步值 content_json(字面量 = 契约文档里的起步值;doc↔code 断言据此对齐 ReadinessScorer 常量)。 */
private static final String STARTER_CONTENT =
"{\"readiness\":{\"playability_weight\":0.50,\"first_play_weight\":0.25,"
+ "\"stability_weight\":0.15,\"efficiency_weight\":0.10,"
+ "\"neutral_score\":0.5,\"efficiency_budget_rmb\":0.6,"
+ "\"stability_repairs_good_max\":0,\"stability_repairs_ok_max\":2,"
+ "\"stability_ok_score\":0.7,\"stability_bad_score\":0.4}}";
private static final double SENTINEL = -999.0;
private static final int INT_SENTINEL = -999;
private GenConfigVersionDO versionWith(String contentJson) {
GenConfigVersionDO v = new GenConfigVersionDO();
v.setId(1L);
v.setContentJson(contentJson);
return v;
}
/** ttl=0 的 provider(每次重读,验解析/回落不受缓存干扰)。 */
private ReadinessConfigProvider providerOver(GenConfigService svc) {
return new ReadinessConfigProvider(svc, 0L);
}
// ==================== ① 未激活 / 读失败 → 全回落 ====================
/** 未建/未激活质量评分配置集(getActiveVersionBySetKey 返 null)→ 全字段回落硬编码默认(sentinel 全部原样返回)。 */
@Test
void get_noActiveVersion_allFallback() {
GenConfigService svc = mock(GenConfigService.class);
when(svc.getActiveVersionBySetKey(eq(ReadinessConfigKeys.READINESS_SET_KEY))).thenReturn(null);
ReadinessView v = providerOver(svc).get();
assertEquals(SENTINEL, v.playabilityWeight(SENTINEL), 1e-9, "未激活应回落(返回 sentinel)");
assertEquals(SENTINEL, v.efficiencyBudgetRmb(SENTINEL), 1e-9);
assertEquals(INT_SENTINEL, v.stabilityRepairsOkMax(INT_SENTINEL));
}
/** DB 读抛异常 → 不抛穿、退化成全回落(非阻断哲学)。 */
@Test
void get_dbThrows_allFallback() {
GenConfigService svc = mock(GenConfigService.class);
when(svc.getActiveVersionBySetKey(eq(ReadinessConfigKeys.READINESS_SET_KEY)))
.thenThrow(new RuntimeException("DB 不可达"));
ReadinessView v = providerOver(svc).get();
assertEquals(SENTINEL, v.playabilityWeight(SENTINEL), 1e-9, "读异常应回落、不抛");
}
/** 激活版无 content_json.readiness 块(如生成配置集误挂)→ 全回落。 */
@Test
void get_noReadinessBlock_allFallback() {
GenConfigService svc = mock(GenConfigService.class);
when(svc.getActiveVersionBySetKey(eq(ReadinessConfigKeys.READINESS_SET_KEY)))
.thenReturn(versionWith("{\"routeB\":{\"budget.cheap_rmb_hard_limit\":10.0}}"));
ReadinessView v = providerOver(svc).get();
assertEquals(SENTINEL, v.stabilityOkScore(SENTINEL), 1e-9);
}
/** content_json 非法 JSON → 全回落、不抛。 */
@Test
void get_invalidJson_allFallback() {
GenConfigService svc = mock(GenConfigService.class);
when(svc.getActiveVersionBySetKey(eq(ReadinessConfigKeys.READINESS_SET_KEY)))
.thenReturn(versionWith("{不是合法json"));
ReadinessView v = providerOver(svc).get();
assertEquals(SENTINEL, v.neutralScore(SENTINEL), 1e-9);
}
// ==================== ② 读到激活版 → 按字段生效 ====================
/** 激活版给非默认权重 → view 逐字段返回激活值(非回落)。 */
@Test
void get_activeVersion_fieldsTakeEffect() {
String content = "{\"readiness\":{\"playability_weight\":0.60,\"first_play_weight\":0.20,"
+ "\"stability_weight\":0.10,\"efficiency_weight\":0.10,"
+ "\"neutral_score\":0.4,\"efficiency_budget_rmb\":1.2,"
+ "\"stability_repairs_good_max\":1,\"stability_repairs_ok_max\":3,"
+ "\"stability_ok_score\":0.8,\"stability_bad_score\":0.3}}";
GenConfigService svc = mock(GenConfigService.class);
when(svc.getActiveVersionBySetKey(eq(ReadinessConfigKeys.READINESS_SET_KEY)))
.thenReturn(versionWith(content));
ReadinessView v = providerOver(svc).get();
assertEquals(0.60, v.playabilityWeight(SENTINEL), 1e-9);
assertEquals(1.2, v.efficiencyBudgetRmb(SENTINEL), 1e-9);
assertEquals(1, v.stabilityRepairsGoodMax(INT_SENTINEL));
assertEquals(3, v.stabilityRepairsOkMax(INT_SENTINEL));
assertEquals(0.8, v.stabilityOkScore(SENTINEL), 1e-9);
}
// ==================== ③ 单字段缺失 → 只回落该字段 ====================
/** 激活版缺 efficiency_budget_rmb:只该字段回落 sentinel,其余仍取激活版值。 */
@Test
void get_missingOneField_onlyThatFieldFallsBack() {
String content = STARTER_CONTENT.replace(",\"efficiency_budget_rmb\":0.6", "");
GenConfigService svc = mock(GenConfigService.class);
when(svc.getActiveVersionBySetKey(eq(ReadinessConfigKeys.READINESS_SET_KEY)))
.thenReturn(versionWith(content));
ReadinessView v = providerOver(svc).get();
assertEquals(SENTINEL, v.efficiencyBudgetRmb(SENTINEL), 1e-9, "缺失字段回落 sentinel");
assertEquals(0.50, v.playabilityWeight(SENTINEL), 1e-9, "其余字段仍取激活版值、不受牵连");
assertEquals(2, v.stabilityRepairsOkMax(INT_SENTINEL), "其余字段仍取激活版值");
}
// ==================== ④ doc↔code:起步值 = ReadinessScorer 常量(机器门) ====================
/**
* 契约文档里的起步值(STARTER_CONTENT 字面量)逐项 = ReadinessScorer 的回落常量。
* 谁改了打分器常量却没同步契约起步值(或反之),此断言即红——把「字段名与默认值 = ReadinessScorer 常量」钉成机器门。
*/
@Test
void docCode_starterValues_equalScorerConstants() throws Exception {
GenConfigService svc = mock(GenConfigService.class);
when(svc.getActiveVersionBySetKey(eq(ReadinessConfigKeys.READINESS_SET_KEY)))
.thenReturn(versionWith(STARTER_CONTENT));
ReadinessView v = providerOver(svc).get();
assertEquals(constD("W_PLAYABILITY"), v.playabilityWeight(SENTINEL), 1e-9);
assertEquals(constD("W_FIRST_PLAY"), v.firstPlayWeight(SENTINEL), 1e-9);
assertEquals(constD("W_STABILITY"), v.stabilityWeight(SENTINEL), 1e-9);
assertEquals(constD("W_EFFICIENCY"), v.efficiencyWeight(SENTINEL), 1e-9);
assertEquals(constD("NEUTRAL"), v.neutralScore(SENTINEL), 1e-9);
assertEquals(constD("BUDGET_RMB"), v.efficiencyBudgetRmb(SENTINEL), 1e-9);
assertEquals(constI("REPAIRS_GOOD_MAX"), v.stabilityRepairsGoodMax(INT_SENTINEL));
assertEquals(constI("REPAIRS_OK_MAX"), v.stabilityRepairsOkMax(INT_SENTINEL));
assertEquals(constD("STABILITY_OK_SCORE"), v.stabilityOkScore(SENTINEL), 1e-9);
assertEquals(constD("STABILITY_BAD_SCORE"), v.stabilityBadScore(SENTINEL), 1e-9);
}
/**
* 端到端等价(行为版 doc↔code):起步值激活版喂进带 provider 的打分器,与无 provider(纯常量)打分器对同一 trace 同分。
* 若起步值 ≠ 常量,两者分数会偏离 → 红。
*/
@Test
void docCode_starterConfig_scoresSameAsHardcoded() {
GenConfigService svc = mock(GenConfigService.class);
when(svc.getActiveVersionBySetKey(eq(ReadinessConfigKeys.READINESS_SET_KEY)))
.thenReturn(versionWith(STARTER_CONTENT));
ReadinessScorer withProvider = new ReadinessScorer();
ReflectionTestUtils.setField(withProvider, "configProvider", providerOver(svc));
ReadinessScorer hardcoded = new ReadinessScorer(); // provider=null → 全回落常量
Map<String, Object> trace = fullTrace();
assertEquals(hardcoded.score(trace), withProvider.score(trace),
"起步值激活版与硬编码常量对同一 trace 应同分(起步值=常量)");
}
/** 端到端生效:激活版把 playability 权重从 0.50 抬到 0.90,同一 trace 就绪分随之变化(不重编 jar)。 */
@Test
void endToEnd_activatedWeightChangesScore() {
// 提高可玩性权重、压其余到和为 1.0;对 playability=1.0 而其余非满的 trace,抬 playability 权重会改变合成分
String content = "{\"readiness\":{\"playability_weight\":0.90,\"first_play_weight\":0.04,"
+ "\"stability_weight\":0.03,\"efficiency_weight\":0.03,"
+ "\"neutral_score\":0.5,\"efficiency_budget_rmb\":0.6,"
+ "\"stability_repairs_good_max\":0,\"stability_repairs_ok_max\":2,"
+ "\"stability_ok_score\":0.7,\"stability_bad_score\":0.4}}";
GenConfigService svc = mock(GenConfigService.class);
when(svc.getActiveVersionBySetKey(eq(ReadinessConfigKeys.READINESS_SET_KEY)))
.thenReturn(versionWith(content));
ReadinessScorer withProvider = new ReadinessScorer();
ReflectionTestUtils.setField(withProvider, "configProvider", providerOver(svc));
ReadinessScorer hardcoded = new ReadinessScorer();
// trace:playability=1.0(pass=true)、firstPlay=0.5(无 H 门)、stability=1.0(repairs=0)、efficiency 中性 0.5
Map<String, Object> trace = new HashMap<>();
trace.put("pass", true);
trace.put("repairs", 0);
// 不给 gatespec/verdict → firstPlay 走中性;不给 cost → efficiency 中性
assertNotEquals(hardcoded.score(trace), withProvider.score(trace),
"抬高 playability 权重后同一 trace 就绪分应变化");
}
// ==================== ⑤ 短 TTL 缓存 ====================
/** TTL 内多次 get 只读一次 DB(缓存命中)。 */
@Test
void get_withinTtl_cachesOneDbRead() {
GenConfigService svc = mock(GenConfigService.class);
when(svc.getActiveVersionBySetKey(eq(ReadinessConfigKeys.READINESS_SET_KEY)))
.thenReturn(versionWith(STARTER_CONTENT));
ReadinessConfigProvider provider = new ReadinessConfigProvider(svc, 60_000L); // 长 TTL
provider.get();
provider.get();
provider.get();
verify(svc, times(1)).getActiveVersionBySetKey(eq(ReadinessConfigKeys.READINESS_SET_KEY));
}
// ==================== 工具 ====================
/** 反射读 ReadinessScorer 的 private static final double 常量。 */
private static double constD(String name) throws Exception {
Field f = ReadinessScorer.class.getDeclaredField(name);
f.setAccessible(true);
return f.getDouble(null);
}
/** 反射读 ReadinessScorer 的 private static final int 常量。 */
private static int constI(String name) throws Exception {
Field f = ReadinessScorer.class.getDeclaredField(name);
f.setAccessible(true);
return f.getInt(null);
}
/** 九门全过的满信号 trace(playability/firstPlay/stability=1.0、efficiency 满分)。 */
private static Map<String, Object> fullTrace() {
Map<String, Object> trace = new HashMap<>();
trace.put("pass", true);
trace.put("repairs", 0);
Map<String, Object> gatespec = new HashMap<>();
gatespec.put("driver", "click");
trace.put("gatespec", gatespec);
Map<String, Object> hProgress = new HashMap<>();
hProgress.put("pass", true);
Map<String, Object> guards = new HashMap<>();
guards.put("H_progress", hProgress);
Map<String, Object> verdict = new HashMap<>();
verdict.put("guards", guards);
trace.put("sevenGateVerdict", verdict);
Map<String, Object> cost = new HashMap<>();
cost.put("totalRmb", 0.3); // <= budget 0.6 → efficiency 1.0
trace.put("cost", cost);
return trace;
}
}