diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaFullGraphE2eGateLogicTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaFullGraphE2eGateLogicTest.java new file mode 100644 index 00000000..af1a2530 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaFullGraphE2eGateLogicTest.java @@ -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 验收门工具化的纯逻辑模型无关单测。 + * + *

为何独立成测:{@link SaaFullGraphE2eTest} 主体 {@code @EnabledIfSystemProperty(saa.e2e=1)}(需 mini-desktop+模型+九门, + * 普通构建跳过、不可验)。但其新增的「brief 解析 / 取前 N / 门槛解析 / 成功率门 / token·模型档抽取」全是纯函数, + * 与模型无关——抽出后此测在普通 {@code mvn test} 即跑,无需 LLM / NEWAPI_KEY / -Dsaa.e2e,作工具化逻辑的客观回归门。 + * + *

覆盖:happy + 边界(空/null/越界/非法)+ 等价类,对齐 ce-work「Test Scenario Completeness」。 + * + * @author 造梦AI(003-U1 验收门工具化) + */ +class SaaFullGraphE2eGateLogicTest { + + // ─────────────────────────── parseBriefLines:行式解析(去空白 / 跳空行 / 跳 # 注释) ─────────────────────────── + + @Test + @DisplayName("parseBriefLines:去首尾空白 + 跳空行 + 跳 # 注释 + 保留正文") + void parseBriefLines_basic() { + List raw = Arrays.asList( + "# 这是注释,应跳过", + " 打砖块:挡板接球消砖。 ", // 含首尾空白,应 trim + "", // 空行,跳过 + " ", // 纯空白行,跳过 + "贪吃蛇:吃食物变长。", + "#另一条注释", + "接金币:接住金币。"); + List 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 raw = Arrays.asList("有效行\r", null, "另一行"); + List 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 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 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 trace = new LinkedHashMap<>(); + Map tokens = new LinkedHashMap<>(); + tokens.put("prompt", 1200L); // dispatcher 落 Long + tokens.put("completion", 800); // 兼容 Integer + trace.put("tokens", tokens); + assertEquals(2000L, SaaFullGraphE2eTest.extractTotalTokens(trace)); + + // 数字串容错 + Map strTokens = new HashMap<>(); + strTokens.put("prompt", "300"); + strTokens.put("completion", "20"); + Map 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 onlyCompletion = new HashMap<>(); + onlyCompletion.put("completion", 50L); + Map 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 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 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"); + } +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaFullGraphE2eTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaFullGraphE2eTest.java index 8d28e5cd..00a39469 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaFullGraphE2eTest.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaFullGraphE2eTest.java @@ -44,21 +44,30 @@ import static org.junit.jupiter.api.Assertions.assertTrue; *

    *
  1. 门控 {@code -Dsaa.e2e=1}(无机/无 key 时不跑,避免误判 fail);
  2. *
  3. {@code saaCheckpointEnabled=false} + DataSource 传 null → 零 DB 依赖
  4. - *
  5. N 默认 5,可 {@code -Dsaa.e2e.n=K} 覆盖(串行单条含修复轮约 5~15 分钟,N=5 需约 30~75 分钟,按 mvn timeout 量裁);
  6. + *
  7. brief 源:默认内置多品类(≥10 条),或 {@code -Dsaa.e2e.briefFile=<路径>} 外部文件 + * (行式:去首尾空白、跳空行与 {@code #} 注释行;解析为空则回退内置);可 {@code -Dsaa.e2e.n=K} 取前 K 条 + * (串行单条含修复轮约 5~15 分钟,N=10 需约 50~150 分钟,按 mvn timeout 量裁);
  8. *
  9. 等待超时 = {@code perBriefBudget(默认 900s) × N},可 {@code -Dsaa.e2e.perBriefSec} 调;到点仍未集齐则 * best-effort 收尾(已回调的照常计,未回调的计「(未回调)」),不一条卡死全挂
  10. - *
  11. 成功率作证据打印,不硬断 80%——存在性断言放宽到「至少 1 条真全图 e2e 跑通」(首证存在性,成功率留人判)。
  12. + *
  13. 成功率门(003-U1):成功率始终作证据打印 + 存在性下限「≥1 条跑通」始终断言; + * flip 前置硬门仅当显式 {@code -Dsaa.e2e.minSuccessRate=0.8} 设定时才硬断成功率≥门槛 + * (默认不设=基线量测模式,保 003-U1「先实测真基线再收敛」,不破普通构建);
  14. + *
  15. 成本会计(003-U1·token 代理):汇总每条 prompt+completion token + 升 stage2(强档救场)计数作成本证据; + * {@code -Dsaa.e2e.maxTokensPerGame=N} 设上限时仅软警告不硬阻断(遵创始人裁决「质量优先、允许临时破价、记账对账」)。 + * :SAA 路只有 token 计量,折¥是 follow-up F3({@code SaaGraphDispatcher} cost 整段省略,§6.3)→ 此处不造假¥
  16. *
* *

运行(mini-desktop,game-runtime 有 node+esbuild+Chrome、端口 4320/9222 空): *

+ *   // 基线量测(仅打印成功率 + 存在性断言;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
  * 
* - * @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 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 tr = (Map) 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 briefs = ALL_BRIEFS.subList(0, n); + // brief 源(003-U1):-Dsaa.e2e.briefFile 给定且可读→外部文件(行式),否则内置 12 条; + // 再按 -Dsaa.e2e.n=K 取前 K 条(未设=全量;串行耗时大,调小便于在短 mvn timeout 内跑通若干条)。 + List sourceBriefs = resolveSourceBriefs(); + List 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 resolveSourceBriefs() { + String briefFile = System.getProperty("saa.e2e.briefFile"); + if (briefFile != null && !briefFile.isBlank()) { + Path p = Paths.get(briefFile.trim()); + try { + List 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 parseBriefLines(List rawLines) { + List 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 clampToN(List 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 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 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; + } }