feat(aigc): D11 质量评分参数外置进配置集(W-CFG-EXT E1-E3)
背景/目的: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:
parent
8200aa08d0
commit
8724501f32
@ -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;
|
||||
|
||||
@ -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, "配置激活下发失败(已判未生效并补偿回上一激活版,可重试):{}");
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 给「是否变更 + 两侧字节数」,结构化旋钮拍平成点路径逐项比对给增/删/改。
|
||||
*
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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";
|
||||
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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、不发任何一路。 */
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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 不误报。 */
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user