test(saa): 003-U1 验收门工具化——SaaFullGraphE2eTest n≥10 + minSuccessRate 硬门 + token 成本会计
把 SaaFullGraphE2eTest 从「内置 5 条 / 只断≥1 跑通」改造成可作 flip 前置硬门的客观量测工具 (plan 2026-06-18-003 / 003-U1;该路代码 001-U1~U4 已在 dev/2.0.0 落地,本单元只工具化验收门): - brief:内置扩 5→12 条多品类(偏 realtime/physics/tap-targets/clickable)+ -Dsaa.e2e.briefFile 外部文件(行式,去空白/跳空行/跳 # 注释,解析空则回退内置);-Dsaa.e2e.n 取前 K(未设=全量)。 - 成功率门:-Dsaa.e2e.minSuccessRate 可配硬断言;默认不设=仅存在性(保 003-U1「先实测真基线再收敛」、 不破普通构建),显式 0.8 即 flip(003-U2)前置硬门。 - 成本会计:从回调 trace best-effort 抽 prompt+completion token + 升 stage2 计数作成本证据; -Dsaa.e2e.maxTokensPerGame 仅软警告不硬阻断(遵创始人「质量优先、允许临时破价、记账对账」)。 ¥折算 blocked on F3(SaaGraphDispatcher cost 整段省略,§6.3)→ 不造假¥。 - 纯逻辑抽 8 静态方法 + 新增模型无关单测 SaaFullGraphE2eGateLogicTest(覆盖 happy+边界)。 验证:mvn test-compile 全 reactor BUILD SUCCESS;SaaFullGraphE2eGateLogicTest 12/12 过; e2e 无 -Dsaa.e2e=1 仍 Skipped(门控完好)。质量收敛到≥80% 须 mini-desktop 权威量测(blocked,非本单元)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9b280d668e
commit
730b399dd6
@ -0,0 +1,171 @@
|
||||
package com.wanxiang.huijing.game.module.aigc.saa;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* SaaFullGraphE2eGateLogicTest —— 003-U1 验收门工具化的<b>纯逻辑模型无关单测</b>。
|
||||
*
|
||||
* <p><b>为何独立成测</b>:{@link SaaFullGraphE2eTest} 主体 {@code @EnabledIfSystemProperty(saa.e2e=1)}(需 mini-desktop+模型+九门,
|
||||
* 普通构建跳过、不可验)。但其新增的「brief 解析 / 取前 N / 门槛解析 / 成功率门 / token·模型档抽取」全是<b>纯函数</b>,
|
||||
* 与模型无关——抽出后此测在普通 {@code mvn test} 即跑,<b>无需 LLM / NEWAPI_KEY / -Dsaa.e2e</b>,作工具化逻辑的客观回归门。
|
||||
*
|
||||
* <p>覆盖:happy + 边界(空/null/越界/非法)+ 等价类,对齐 ce-work「Test Scenario Completeness」。
|
||||
*
|
||||
* @author 造梦AI(003-U1 验收门工具化)
|
||||
*/
|
||||
class SaaFullGraphE2eGateLogicTest {
|
||||
|
||||
// ─────────────────────────── parseBriefLines:行式解析(去空白 / 跳空行 / 跳 # 注释) ───────────────────────────
|
||||
|
||||
@Test
|
||||
@DisplayName("parseBriefLines:去首尾空白 + 跳空行 + 跳 # 注释 + 保留正文")
|
||||
void parseBriefLines_basic() {
|
||||
List<String> raw = Arrays.asList(
|
||||
"# 这是注释,应跳过",
|
||||
" 打砖块:挡板接球消砖。 ", // 含首尾空白,应 trim
|
||||
"", // 空行,跳过
|
||||
" ", // 纯空白行,跳过
|
||||
"贪吃蛇:吃食物变长。",
|
||||
"#另一条注释",
|
||||
"接金币:接住金币。");
|
||||
List<String> out = SaaFullGraphE2eTest.parseBriefLines(raw);
|
||||
assertEquals(3, out.size(), "应只剩 3 条正文");
|
||||
assertEquals("打砖块:挡板接球消砖。", out.get(0), "首尾空白应被 trim");
|
||||
assertEquals("贪吃蛇:吃食物变长。", out.get(1));
|
||||
assertEquals("接金币:接住金币。", out.get(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("parseBriefLines:null 入参 → 空列表(不抛)")
|
||||
void parseBriefLines_null() {
|
||||
assertTrue(SaaFullGraphE2eTest.parseBriefLines(null).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("parseBriefLines:含 null 行 + CRLF 尾 \\r 被 trim 掉")
|
||||
void parseBriefLines_nullLineAndCrlf() {
|
||||
List<String> raw = Arrays.asList("有效行\r", null, "另一行");
|
||||
List<String> out = SaaFullGraphE2eTest.parseBriefLines(raw);
|
||||
assertEquals(2, out.size());
|
||||
assertEquals("有效行", out.get(0), "CRLF 的 \\r 应被 trim 掉(跨平台 brief 文件)");
|
||||
}
|
||||
|
||||
// ─────────────────────────── clampToN:取前 N(缺=全量 / clamp [1,size]) ───────────────────────────
|
||||
|
||||
@Test
|
||||
@DisplayName("clampToN:n=null → 全量")
|
||||
void clampToN_nullN_full() {
|
||||
List<String> src = Arrays.asList("a", "b", "c");
|
||||
assertEquals(3, SaaFullGraphE2eTest.clampToN(src, null).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("clampToN:n 越上界 → 收到 size;n<1 → 收到 1;n 居中 → n")
|
||||
void clampToN_clamp() {
|
||||
List<String> src = Arrays.asList("a", "b", "c");
|
||||
assertEquals(3, SaaFullGraphE2eTest.clampToN(src, 99).size(), "n>size → size");
|
||||
assertEquals(1, SaaFullGraphE2eTest.clampToN(src, 0).size(), "n<1 → 1");
|
||||
assertEquals(1, SaaFullGraphE2eTest.clampToN(src, -5).size(), "负 n → 1");
|
||||
assertEquals(2, SaaFullGraphE2eTest.clampToN(src, 2).size(), "居中 → n");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("clampToN:null / 空列表 → 安全返回空")
|
||||
void clampToN_nullOrEmpty() {
|
||||
assertTrue(SaaFullGraphE2eTest.clampToN(null, 5).isEmpty());
|
||||
assertTrue(SaaFullGraphE2eTest.clampToN(new ArrayList<>(), 5).isEmpty());
|
||||
}
|
||||
|
||||
// ─────────────────────────── parseRate:门槛解析(null/非法→0;clamp [0,1]) ───────────────────────────
|
||||
|
||||
@Test
|
||||
@DisplayName("parseRate:null/空/非法 → 0.0(不设硬门,仅存在性)")
|
||||
void parseRate_offCases() {
|
||||
assertEquals(0.0, SaaFullGraphE2eTest.parseRate(null));
|
||||
assertEquals(0.0, SaaFullGraphE2eTest.parseRate(""));
|
||||
assertEquals(0.0, SaaFullGraphE2eTest.parseRate(" "));
|
||||
assertEquals(0.0, SaaFullGraphE2eTest.parseRate("abc"), "非法值按不设门处理,不抛");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("parseRate:合法值原样;越界 clamp 到 [0,1];带空白可解析")
|
||||
void parseRate_validAndClamp() {
|
||||
assertEquals(0.8, SaaFullGraphE2eTest.parseRate("0.8"));
|
||||
assertEquals(0.5, SaaFullGraphE2eTest.parseRate(" 0.5 "), "首尾空白应 trim");
|
||||
assertEquals(1.0, SaaFullGraphE2eTest.parseRate("1.5"), "上界 clamp 到 1.0");
|
||||
assertEquals(0.0, SaaFullGraphE2eTest.parseRate("-0.2"), "负值 clamp 到 0.0");
|
||||
}
|
||||
|
||||
// ─────────────────────────── passesGate:成功率硬门 ───────────────────────────
|
||||
|
||||
@Test
|
||||
@DisplayName("passesGate:达门(≥,含正好等于)/ 不达门 / 无样本 / 门槛0")
|
||||
void passesGate_cases() {
|
||||
assertTrue(SaaFullGraphE2eTest.passesGate(8, 10, 0.8), "8/10=0.8 正好等于门槛 → 过(>=)");
|
||||
assertTrue(SaaFullGraphE2eTest.passesGate(9, 10, 0.8), "9/10 > 0.8 → 过");
|
||||
assertFalse(SaaFullGraphE2eTest.passesGate(7, 10, 0.8), "7/10 < 0.8 → 不过");
|
||||
assertTrue(SaaFullGraphE2eTest.passesGate(10, 10, 1.0), "满分对 100% 门 → 过");
|
||||
assertFalse(SaaFullGraphE2eTest.passesGate(1, 0, 0.8), "total<=0 无样本 → 不过(防 0 除)");
|
||||
assertTrue(SaaFullGraphE2eTest.passesGate(0, 10, 0.0), "门槛 0 → 恒过(存在性另管)");
|
||||
}
|
||||
|
||||
// ─────────────────────────── extractTotalTokens / extractModelTier:成本会计抽取 ───────────────────────────
|
||||
|
||||
@Test
|
||||
@DisplayName("extractTotalTokens:tokens.{prompt,completion} 求和(Number/Long/数字串)")
|
||||
void extractTotalTokens_sum() {
|
||||
Map<String, Object> trace = new LinkedHashMap<>();
|
||||
Map<String, Object> tokens = new LinkedHashMap<>();
|
||||
tokens.put("prompt", 1200L); // dispatcher 落 Long
|
||||
tokens.put("completion", 800); // 兼容 Integer
|
||||
trace.put("tokens", tokens);
|
||||
assertEquals(2000L, SaaFullGraphE2eTest.extractTotalTokens(trace));
|
||||
|
||||
// 数字串容错
|
||||
Map<String, Object> strTokens = new HashMap<>();
|
||||
strTokens.put("prompt", "300");
|
||||
strTokens.put("completion", "20");
|
||||
Map<String, Object> trace2 = new HashMap<>();
|
||||
trace2.put("tokens", strTokens);
|
||||
assertEquals(320L, SaaFullGraphE2eTest.extractTotalTokens(trace2));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("extractTotalTokens:缺 prompt → 仅 completion;trace=null / 无 tokens / tokens 非 Map → -1(无数据)")
|
||||
void extractTotalTokens_missing() {
|
||||
Map<String, Object> onlyCompletion = new HashMap<>();
|
||||
onlyCompletion.put("completion", 50L);
|
||||
Map<String, Object> trace = new HashMap<>();
|
||||
trace.put("tokens", onlyCompletion);
|
||||
assertEquals(50L, SaaFullGraphE2eTest.extractTotalTokens(trace), "缺 prompt → 按 0 + completion");
|
||||
|
||||
assertEquals(-1L, SaaFullGraphE2eTest.extractTotalTokens(null), "trace=null → -1");
|
||||
assertEquals(-1L, SaaFullGraphE2eTest.extractTotalTokens(new HashMap<>()), "无 tokens 键 → -1");
|
||||
Map<String, Object> badTokens = new HashMap<>();
|
||||
badTokens.put("tokens", "not-a-map");
|
||||
assertEquals(-1L, SaaFullGraphE2eTest.extractTotalTokens(badTokens), "tokens 非 Map → -1");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("extractModelTier:有 stage2 → stage2;缺 / trace=null → stage1(未升档)")
|
||||
void extractModelTier_cases() {
|
||||
Map<String, Object> stage2 = new HashMap<>();
|
||||
stage2.put("modelTier", "stage2");
|
||||
assertEquals("stage2", SaaFullGraphE2eTest.extractModelTier(stage2));
|
||||
|
||||
assertEquals("stage1", SaaFullGraphE2eTest.extractModelTier(new HashMap<>()), "缺 modelTier → stage1");
|
||||
assertEquals("stage1", SaaFullGraphE2eTest.extractModelTier(null), "trace=null → stage1");
|
||||
}
|
||||
}
|
||||
@ -44,21 +44,30 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
* <ol>
|
||||
* <li>门控 {@code -Dsaa.e2e=1}(无机/无 key 时不跑,避免误判 fail);</li>
|
||||
* <li>{@code saaCheckpointEnabled=false} + DataSource 传 null → <b>零 DB 依赖</b>;</li>
|
||||
* <li>N 默认 5,可 {@code -Dsaa.e2e.n=K} 覆盖(串行单条含修复轮约 5~15 分钟,N=5 需约 30~75 分钟,按 mvn timeout 量裁);</li>
|
||||
* <li>brief 源:默认内置多品类(≥10 条),或 {@code -Dsaa.e2e.briefFile=<路径>} 外部文件
|
||||
* (行式:去首尾空白、跳空行与 {@code #} 注释行;解析为空则回退内置);可 {@code -Dsaa.e2e.n=K} 取前 K 条
|
||||
* (串行单条含修复轮约 5~15 分钟,N=10 需约 50~150 分钟,按 mvn timeout 量裁);</li>
|
||||
* <li>等待<b>总</b>超时 = {@code perBriefBudget(默认 900s) × N},可 {@code -Dsaa.e2e.perBriefSec} 调;到点仍未集齐则
|
||||
* best-effort 收尾(已回调的照常计,未回调的计「(未回调)」),<b>不一条卡死全挂</b>;</li>
|
||||
* <li>成功率作证据打印,<b>不硬断 80%</b>——存在性断言放宽到「至少 1 条真全图 e2e 跑通」(首证存在性,成功率留人判)。</li>
|
||||
* <li><b>成功率门(003-U1)</b>:成功率始终作证据打印 + <b>存在性下限</b>「≥1 条跑通」始终断言;
|
||||
* <b>flip 前置硬门</b>仅当显式 {@code -Dsaa.e2e.minSuccessRate=0.8} 设定时才硬断成功率≥门槛
|
||||
* (默认不设=基线量测模式,保 003-U1「先实测真基线再收敛」,不破普通构建);</li>
|
||||
* <li><b>成本会计(003-U1·token 代理)</b>:汇总每条 prompt+completion token + 升 stage2(强档救场)计数作成本证据;
|
||||
* {@code -Dsaa.e2e.maxTokensPerGame=N} 设上限时仅<b>软警告</b>不硬阻断(遵创始人裁决「质量优先、允许临时破价、记账对账」)。
|
||||
* <b>注</b>:SAA 路只有 token 计量,折¥是 follow-up F3({@code SaaGraphDispatcher} cost 整段省略,§6.3)→ 此处<b>不造假¥</b>。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p><b>运行(mini-desktop,game-runtime 有 node+esbuild+Chrome、端口 4320/9222 空)</b>:
|
||||
* <pre>
|
||||
* // 基线量测(仅打印成功率 + 存在性断言;003-U1 第一步「实测真基线」):
|
||||
* NEWAPI_KEY=sk-... timeout 6000 mvn -pl ...aigc-server test \
|
||||
* -Dtest=SaaFullGraphE2eTest -Dsaa.e2e=1 \
|
||||
* -Dtest=SaaFullGraphE2eTest -Dsaa.e2e=1 -Dsaa.e2e.n=10 \
|
||||
* -DGAME_RUNTIME_DIR=/root/game-staging/repo/game-runtime \
|
||||
* -Dsurefire.failIfNoSpecifiedTests=false
|
||||
* // flip 前置硬门(成功率须≥80%,否则测试 fail):追加 -Dsaa.e2e.minSuccessRate=0.8
|
||||
* </pre>
|
||||
*
|
||||
* @author 造梦AI(U7 集成顶石)
|
||||
* @author 造梦AI(U7 集成顶石;003-U1 验收门工具化)
|
||||
*/
|
||||
class SaaFullGraphE2eTest {
|
||||
|
||||
@ -77,15 +86,23 @@ class SaaFullGraphE2eTest {
|
||||
Long.getLong("saa.e2e.perBriefSec", 900L);
|
||||
|
||||
/**
|
||||
* N=5 条多样一句话 brief(覆盖不同玩法品类,避免单品类偶然过/不过)。可 {@code -Dsaa.e2e.n=K} 取前 K 条。
|
||||
* 全部一次性投递,后台真玩门 Semaphore(1) 串行逐条跑。
|
||||
* 内置 12 条多样一句话 brief(覆盖不同玩法品类,避免单品类偶然过/不过;003-U1 扩到 ≥10 以作产线门量测)。
|
||||
* 偏向运行时/harness 支持的品类(realtime/physics/tap-targets/clickable)。可 {@code -Dsaa.e2e.n=K} 取前 K 条,
|
||||
* 或 {@code -Dsaa.e2e.briefFile=<路径>} 整组改用外部文件。全部一次性投递,后台真玩门 Semaphore(1) 串行逐条跑。
|
||||
*/
|
||||
private static final List<String> ALL_BRIEFS = List.of(
|
||||
"打砖块:挡板接球把上方砖块全部消除即胜,掉球则负。",
|
||||
"贪吃蛇:操控小蛇吃食物变长,撞到自己身体或墙壁则游戏结束。",
|
||||
"接金币:左右移动篮子接住从天上掉落的金币,漏接太多就失败。",
|
||||
"躲障碍:角色不断前进,点击跳跃躲开迎面而来的障碍物,撞到就结束。",
|
||||
"点击消除:点击三个或以上相连的同色方块把它们消掉,消满目标分数过关。"
|
||||
"点击消除:点击三个或以上相连的同色方块把它们消掉,消满目标分数过关。",
|
||||
"太空射击:飞船左右移动并发射子弹击落持续下降的敌机,被敌机撞到则失败。",
|
||||
"打地鼠:地鼠随机从洞口冒头,点击敲中得分,限时结束时分数越高越好。",
|
||||
"节奏敲击:方块下落到底部判定线的瞬间点击得分,连续错过三次则结束。",
|
||||
"平台跳跃:左右移动并跳跃,踩着悬空平台抵达终点旗帜,掉出屏幕底部则失败。",
|
||||
"太空躲陨石:上下左右移动飞船躲避飞来的陨石,坚持到计时归零即通关。",
|
||||
"弹力打靶:点击屏幕给小球施加方向,把场上所有靶子撞掉即过关。",
|
||||
"泡泡射手:瞄准并发射泡泡,三个同色相连则消除,清空全部泡泡即胜。"
|
||||
);
|
||||
|
||||
/** 单条 brief 的 e2e 结果证据(回调按 traceId 精确归位)。 */
|
||||
@ -99,6 +116,8 @@ class SaaFullGraphE2eTest {
|
||||
volatile boolean dispatched = false; // dispatch 投递握手是否 true
|
||||
volatile long callbackAtMs = 0; // 回调命中时刻(相对 run 起点;推单条墙钟用)
|
||||
volatile String failDetail = ""; // 失败时从 verdict.json/giveup-dump.json 摘的真因
|
||||
volatile long totalTokens = -1; // 成本会计:prompt+completion token(成本代理);-1=回调未带 trace token
|
||||
volatile String modelTier = "stage1"; // 终态模型档(stage1/stage2);stage2=救场升强档(成本↑,关注质量-成本权衡)
|
||||
|
||||
BriefResult(int idx, String brief, String traceId) {
|
||||
this.idx = idx;
|
||||
@ -136,9 +155,18 @@ class SaaFullGraphE2eTest {
|
||||
r.engineBundleLen = reqVO.getEngineBundle() == null ? 0 : reqVO.getEngineBundle().length();
|
||||
r.failureReason = reqVO.getFailureReason() == null ? "" : reqVO.getFailureReason();
|
||||
r.callbackAtMs = System.currentTimeMillis() - runStartMs;
|
||||
// 成本会计(003-U1):从 trace best-effort 抽 token 总数 + 模型档(缺/异常保持默认 -1/stage1,不阻断归位)。
|
||||
Object traceObj = reqVO.getTrace();
|
||||
if (traceObj instanceof Map) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> tr = (Map<String, Object>) traceObj;
|
||||
r.totalTokens = extractTotalTokens(tr);
|
||||
r.modelTier = extractModelTier(tr);
|
||||
}
|
||||
System.out.println("[callback] #" + r.idx + " traceId=" + traceId
|
||||
+ " status=" + r.status + " engineBundleLen=" + r.engineBundleLen
|
||||
+ " reason=" + r.failureReason + " @+" + (r.callbackAtMs / 1000) + "s");
|
||||
+ " reason=" + r.failureReason + " tokens=" + r.totalTokens + " tier=" + r.modelTier
|
||||
+ " @+" + (r.callbackAtMs / 1000) + "s");
|
||||
} else {
|
||||
// 不应发生(traceId 必在 map 内):留痕,仍倒数避免 latch 永挂。
|
||||
System.out.println("[callback] 未知 traceId=" + traceId + "(不在 map 内,忽略归位仍倒数)");
|
||||
@ -163,10 +191,12 @@ class SaaFullGraphE2eTest {
|
||||
assertTrue(Files.exists(runtimeRoot.resolve("node_modules/esbuild")),
|
||||
"缺 node_modules/esbuild(build 节点依赖),请在 game-runtime 跑 npm i -D esbuild");
|
||||
|
||||
// N:默认 5,可 -Dsaa.e2e.n=K 取前 K 条(串行耗时大,调小便于在短 mvn timeout 内跑通若干条)。
|
||||
int n = Integer.getInteger("saa.e2e.n", ALL_BRIEFS.size());
|
||||
n = Math.max(1, Math.min(n, ALL_BRIEFS.size()));
|
||||
List<String> briefs = ALL_BRIEFS.subList(0, n);
|
||||
// brief 源(003-U1):-Dsaa.e2e.briefFile 给定且可读→外部文件(行式),否则内置 12 条;
|
||||
// 再按 -Dsaa.e2e.n=K 取前 K 条(未设=全量;串行耗时大,调小便于在短 mvn timeout 内跑通若干条)。
|
||||
List<String> sourceBriefs = resolveSourceBriefs();
|
||||
List<String> briefs = clampToN(sourceBriefs, Integer.getInteger("saa.e2e.n"));
|
||||
int n = briefs.size();
|
||||
System.out.println("[briefs] 本次投递 n=" + n + " 条(源 " + sourceBriefs.size() + " 条)");
|
||||
|
||||
// ── 1) 构造真 AigcExecutorProperties(@Data 即 setter 全有)──
|
||||
// checkpoint 关 + DataSource 传 null → 零 DB 依赖;dispatcher=saa 走进程内全图派发。
|
||||
@ -250,8 +280,10 @@ class SaaFullGraphE2eTest {
|
||||
System.out.println("------------------------------------------------------");
|
||||
for (BriefResult r : results) {
|
||||
System.out.println(String.format(
|
||||
"#%d status=%-26s bundleLen=%-7d callback@+%-5s reason=%s",
|
||||
"#%d status=%-26s bundleLen=%-7d tokens=%-7s tier=%-6s callback@+%-5s reason=%s",
|
||||
r.idx, r.status, r.engineBundleLen,
|
||||
(r.totalTokens >= 0 ? String.valueOf(r.totalTokens) : "-"),
|
||||
r.modelTier,
|
||||
(r.callbackAtMs > 0 ? (r.callbackAtMs / 1000) + "s" : "-"),
|
||||
r.succeeded() ? "-" : (r.failureReason.isEmpty() ? "(无)" : r.failureReason)));
|
||||
System.out.println(" brief: " + r.brief);
|
||||
@ -259,12 +291,56 @@ class SaaFullGraphE2eTest {
|
||||
System.out.println(" fail : " + r.failDetail);
|
||||
}
|
||||
}
|
||||
// ── 成本会计(003-U1·token 代理,非¥;折¥=follow-up F3,SAA 路 cost 省略)──
|
||||
long totalTok = 0;
|
||||
int tokCounted = 0;
|
||||
int stage2Count = 0;
|
||||
for (BriefResult r : results) {
|
||||
if (r.totalTokens >= 0) {
|
||||
totalTok += r.totalTokens;
|
||||
tokCounted++;
|
||||
}
|
||||
if ("stage2".equals(r.modelTier)) {
|
||||
stage2Count++;
|
||||
}
|
||||
}
|
||||
System.out.println("------------------------------------------------------");
|
||||
System.out.println("成本会计(token 代理,非¥;折¥=follow-up F3):");
|
||||
System.out.println(" 总 token = " + totalTok + "(有数据 " + tokCounted + "/" + n + " 条)"
|
||||
+ (tokCounted > 0 ? " 均 " + (totalTok / tokCounted) + " token/款" : ""));
|
||||
System.out.println(" 升 stage2(强档救场)= " + stage2Count + "/" + n + " 条"
|
||||
+ (stage2Count > 0 ? "(强档↑成本,关注质量-成本权衡 KTD5)" : ""));
|
||||
// -Dsaa.e2e.maxTokensPerGame=N:仅软警告超预算条目,不硬阻断(遵创始人「质量优先、允许临时破价、记账对账」)。
|
||||
Long maxTok = Long.getLong("saa.e2e.maxTokensPerGame");
|
||||
if (maxTok != null) {
|
||||
for (BriefResult r : results) {
|
||||
if (r.totalTokens > maxTok) {
|
||||
System.out.println(" [warn] #" + r.idx + " token " + r.totalTokens
|
||||
+ " 超预算 " + maxTok + "(软警告,不阻断——质量优先允许临时破价,见 KTD5)");
|
||||
}
|
||||
}
|
||||
}
|
||||
System.out.println("######################################################");
|
||||
|
||||
// ── 7) 存在性断言(首证):至少 1 条真全图 e2e 跑通;成功率作证据,不硬断 80%(留人判)──
|
||||
// ── 7) 断言(003-U1 验收门)──
|
||||
// 7a) 存在性下限(首证 / 基线量测模式):至少 1 条跑通,始终生效。
|
||||
assertTrue(succeeded >= 1,
|
||||
"至少 1 条 SAA 真全图 e2e 应跑通(实得 " + succeeded + "/" + briefs.size()
|
||||
"至少 1 条 SAA 真全图 e2e 应跑通(实得 " + succeeded + "/" + n
|
||||
+ ");若全 0:检查 new-api 可达/九门 harness/端口 4320-9222 占用/图终态——见上方逐条 failDetail");
|
||||
// 7b) flip 前置硬门:仅当显式 -Dsaa.e2e.minSuccessRate 设定时才硬断成功率≥门槛(默认不设=仅存在性,保「先实测真基线」)。
|
||||
double minSuccessRate = parseRate(System.getProperty("saa.e2e.minSuccessRate"));
|
||||
if (minSuccessRate > 0.0) {
|
||||
assertTrue(passesGate((int) succeeded, n, minSuccessRate),
|
||||
String.format("成功率 %.1f%%(%d/%d)< flip 前置门槛 %.1f%% —— 硬门未过,不可切默认产线(003-U2);"
|
||||
+ "据根因分析按主杠杆(升模型档 + ReAct)继续收敛",
|
||||
100.0 * succeeded / n, succeeded, n, minSuccessRate * 100));
|
||||
System.out.println("[gate] flip 前置硬门通过:成功率 "
|
||||
+ String.format("%.1f%%", 100.0 * succeeded / n) + " ≥ 门槛 "
|
||||
+ String.format("%.1f%%", minSuccessRate * 100));
|
||||
} else {
|
||||
System.out.println("[gate] 未设 -Dsaa.e2e.minSuccessRate:仅存在性断言(基线量测模式);"
|
||||
+ "flip 前置硬门请加 -Dsaa.e2e.minSuccessRate=0.8");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -319,4 +395,160 @@ class SaaFullGraphE2eTest {
|
||||
return "(读 failDetail 异常:" + e.getMessage() + ")";
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────── 003-U1 验收门工具化:纯逻辑静态方法(模型无关,可单测,见 SaaFullGraphE2eGateLogicTest) ─────────────────
|
||||
|
||||
/**
|
||||
* 决定本次 brief 列表来源:{@code -Dsaa.e2e.briefFile} 给定且可读 → 外部文件(经 {@link #parseBriefLines} 解析),
|
||||
* 否则用内置 {@link #ALL_BRIEFS}。外部文件解析为空或读取失败 → warn + 回退内置(不抛、不致测试无 brief 可投)。
|
||||
*
|
||||
* @return 源 brief 列表(外部文件 or 内置;恒非空)
|
||||
*/
|
||||
private static List<String> resolveSourceBriefs() {
|
||||
String briefFile = System.getProperty("saa.e2e.briefFile");
|
||||
if (briefFile != null && !briefFile.isBlank()) {
|
||||
Path p = Paths.get(briefFile.trim());
|
||||
try {
|
||||
List<String> parsed = parseBriefLines(Files.readAllLines(p));
|
||||
if (!parsed.isEmpty()) {
|
||||
System.out.println("[briefs] 外部文件 " + p + " 载入 " + parsed.size() + " 条");
|
||||
return parsed;
|
||||
}
|
||||
System.out.println("[briefs][warn] 外部文件 " + p + " 解析后为空,回退内置 " + ALL_BRIEFS.size() + " 条");
|
||||
} catch (Exception e) {
|
||||
System.out.println("[briefs][warn] 读外部文件失败(" + e.getMessage() + "),回退内置 " + ALL_BRIEFS.size() + " 条");
|
||||
}
|
||||
}
|
||||
return ALL_BRIEFS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析外部 brief 文件行(纯函数):逐行去首尾空白,跳过空行与 {@code #} 注释行;其余原样保留。
|
||||
*
|
||||
* @param rawLines 原始行(null 视作空)
|
||||
* @return 清洗后的 brief 列表(恒非空对象,可能 0 元素)
|
||||
*/
|
||||
static List<String> parseBriefLines(List<String> rawLines) {
|
||||
List<String> out = new ArrayList<>();
|
||||
if (rawLines == null) {
|
||||
return out;
|
||||
}
|
||||
for (String line : rawLines) {
|
||||
if (line == null) {
|
||||
continue;
|
||||
}
|
||||
String t = line.trim();
|
||||
if (t.isEmpty() || t.startsWith("#")) {
|
||||
continue; // 跳空行 / # 注释行
|
||||
}
|
||||
out.add(t);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取前 N 条(纯函数):{@code nOrNull} 缺(null)→ 全量;否则 clamp 到 {@code [1, size]}。空列表安全返回。
|
||||
*
|
||||
* @param briefs 源列表
|
||||
* @param nOrNull -Dsaa.e2e.n(未设为 null)
|
||||
* @return 前 N 条子列表
|
||||
*/
|
||||
static List<String> clampToN(List<String> briefs, Integer nOrNull) {
|
||||
if (briefs == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
if (briefs.isEmpty()) {
|
||||
return briefs;
|
||||
}
|
||||
int n = (nOrNull == null) ? briefs.size() : nOrNull;
|
||||
n = Math.max(1, Math.min(n, briefs.size()));
|
||||
return briefs.subList(0, n);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析成功率门槛(纯函数):null/空/非法 → 0.0(=不设硬门,仅存在性断言);否则 clamp 到 {@code [0,1]}。
|
||||
*
|
||||
* @param raw -Dsaa.e2e.minSuccessRate 原始串
|
||||
* @return 门槛 [0,1]
|
||||
*/
|
||||
static double parseRate(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return 0.0;
|
||||
}
|
||||
try {
|
||||
double v = Double.parseDouble(raw.trim());
|
||||
if (v < 0) {
|
||||
return 0.0;
|
||||
}
|
||||
return Math.min(v, 1.0);
|
||||
} catch (NumberFormatException e) {
|
||||
return 0.0; // 非法值按「不设门」处理(基线量测模式),不致测试构造期抛
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 成功率硬门判定(纯函数):{@code total<=0} → false(无样本不算过门);否则 {@code succeeded/total >= minRate}。
|
||||
*
|
||||
* @param succeeded 跑通条数
|
||||
* @param total 总投递条数
|
||||
* @param minRate 门槛 [0,1]
|
||||
* @return 是否达门
|
||||
*/
|
||||
static boolean passesGate(int succeeded, int total, double minRate) {
|
||||
if (total <= 0) {
|
||||
return false;
|
||||
}
|
||||
return ((double) succeeded / total) >= minRate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从回调 {@code trace} map 抽 token 总数(纯函数):{@code trace.tokens.{prompt,completion}} 之和。
|
||||
* SAA 路恒落 tokens(缺省 0);trace 为 null / 无 tokens 子对象 → 返 {@code -1}(表示「无数据」,区别于真 0)。
|
||||
*
|
||||
* @param trace 回调 trace({@link DifyCallbackReqVO#getTrace()})
|
||||
* @return prompt+completion token 总数,无数据返 -1
|
||||
*/
|
||||
static long extractTotalTokens(Map<String, Object> trace) {
|
||||
if (trace == null) {
|
||||
return -1;
|
||||
}
|
||||
Object tk = trace.get("tokens");
|
||||
if (!(tk instanceof Map)) {
|
||||
return -1;
|
||||
}
|
||||
Map<?, ?> tokens = (Map<?, ?>) tk;
|
||||
return asLong(tokens.get("prompt")) + asLong(tokens.get("completion"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 从回调 {@code trace} map 抽模型档(纯函数):{@code trace.modelTier}("stage1"/"stage2")。
|
||||
* dispatcher 仅「真升档态」才落 modelTier → 缺(含 trace=null)即 stage1(未升档)。
|
||||
*
|
||||
* @param trace 回调 trace
|
||||
* @return "stage1" 或 "stage2"
|
||||
*/
|
||||
static String extractModelTier(Map<String, Object> trace) {
|
||||
if (trace == null) {
|
||||
return "stage1";
|
||||
}
|
||||
Object mt = trace.get("modelTier");
|
||||
return mt == null ? "stage1" : String.valueOf(mt);
|
||||
}
|
||||
|
||||
/**
|
||||
* 宽松转 long(trace token 取值专用):Number → longValue;数字串 → parse;其余/null → 0。
|
||||
*/
|
||||
private static long asLong(Object o) {
|
||||
if (o instanceof Number) {
|
||||
return ((Number) o).longValue();
|
||||
}
|
||||
if (o instanceof String) {
|
||||
try {
|
||||
return Long.parseLong(((String) o).trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user