KeywordSemanticValidation.java 59 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214
  1. import java.io.IOException;
  2. import java.io.OutputStream;
  3. import java.net.Socket;
  4. import java.net.URI;
  5. import java.net.URLEncoder;
  6. import java.net.http.HttpClient;
  7. import java.net.http.HttpRequest;
  8. import java.net.http.HttpResponse;
  9. import java.nio.charset.StandardCharsets;
  10. import java.nio.file.Files;
  11. import java.nio.file.Path;
  12. import java.nio.file.Paths;
  13. import java.sql.Connection;
  14. import java.sql.DriverManager;
  15. import java.sql.PreparedStatement;
  16. import java.sql.ResultSet;
  17. import java.time.Duration;
  18. import java.time.LocalDateTime;
  19. import java.time.format.DateTimeFormatter;
  20. import java.util.ArrayList;
  21. import java.util.Collections;
  22. import java.util.LinkedHashMap;
  23. import java.util.List;
  24. import java.util.Map;
  25. import java.util.concurrent.Callable;
  26. import java.util.concurrent.ExecutorService;
  27. import java.util.concurrent.Executors;
  28. import java.util.concurrent.Future;
  29. import java.util.concurrent.TimeUnit;
  30. import java.util.concurrent.TimeoutException;
  31. import java.util.regex.Matcher;
  32. import java.util.regex.Pattern;
  33. /**
  34. * 关键词 / 替换词 / 语义关键词穿透验证(Batch 3)
  35. *
  36. * 验证内容:
  37. * 第1步:敏感词全量命中验证(无需登录)
  38. * - GET /api/lobster/admin/validation/sensitive/list 获取所有敏感词
  39. * - 对每个敏感词构造测试消息调用 POST /sensitive/check 验证命中
  40. * 第2步:替换词命中验证(JDBC + 可选登录)
  41. * - JDBC 连接主库 ylrz_saas 查询 company_lobster_replace_word 表 enabled=1 的替换词
  42. * - 如能登录调用 /workflow/lobster/replace-words/stress-test 验证;否则标注"需要登录验证"
  43. * 第3步:语义关键词命中和穿透验证(无需登录)
  44. * - GET /semantic/keywords 与 /semantic/sentiment 对 27 个行业 × 3 条典型消息
  45. * - 20 组否定前缀穿透场景(不需要/没兴趣/不太好/不是不要 等)
  46. * 第4步:对外角色按行业测试(无需登录)
  47. * - POST /udd/debug 对 27 个行业串行测试,超时 180s,TIMEOUT 不算失败
  48. *
  49. * 技术说明:
  50. * - Java 11+ HttpClient,JSON 使用字符串拼接 + 正则解析(无第三方依赖)
  51. * - 敏感词检查 8 线程并发;UDD 调试串行执行
  52. * - 请求超时 180 秒(UDD 接口慢,60-180s)
  53. *
  54. * 编译运行:
  55. * javac -encoding UTF-8 -cp "C:\Users\77944\.m2\repository\com\mysql\mysql-connector-j\8.0.33\mysql-connector-j-8.0.33.jar" KeywordSemanticValidation.java
  56. * java -cp ".;C:\Users\77944\.m2\repository\com\mysql\mysql-connector-j\8.0.33\mysql-connector-j-8.0.33.jar" KeywordSemanticValidation
  57. */
  58. public class KeywordSemanticValidation {
  59. // ════════════════════════════════════════════════════════════════
  60. // 配置
  61. // ════════════════════════════════════════════════════════════════
  62. private static final String BASE = "http://localhost:8006";
  63. private static final String VAL_BASE = BASE + "/api/lobster/admin/validation";
  64. private static final String REPLACE_STRESS_URL = BASE + "/workflow/lobster/replace-words/stress-test";
  65. private static final String LOGIN_URL = BASE + "/login";
  66. // 主库连接(company_lobster_replace_word 表所在)
  67. private static final String DB_URL = "jdbc:mysql://cq-cdb-8fjmemkb.sql.tencentcdb.com:27220/ylrz_saas"
  68. + "?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&connectTimeout=10000&socketTimeout=30000";
  69. private static final String DB_USER = "root";
  70. private static final String DB_PWD = "Ylrz_1q2w3e4r5t6y";
  71. /** UDD debug 请求超时(秒)—— 后端 LLM 语义分解耗时 60-180s */
  72. private static final int UDD_TIMEOUT = 180;
  73. /** 敏感词 / 替换词 / 语义 请求超时(秒) */
  74. private static final int CHECK_TIMEOUT = 90;
  75. private static final int SEMANTIC_TIMEOUT = 150;
  76. private static final int LOGIN_TIMEOUT = 30;
  77. /** 敏感词检查并发度 */
  78. private static final int SENSITIVE_CONCURRENCY = 8;
  79. /** 语义分析并发度 */
  80. private static final int SEMANTIC_CONCURRENCY = 8;
  81. private static final Path REPORT_FILE =
  82. Paths.get("d:\\ylrz_saas_new\\java\\scripts\\reports\\keyword-semantic-validation-batch3.json");
  83. private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
  84. private static HttpClient httpClient;
  85. private static String token;
  86. private static String tokenStatus = "FAIL";
  87. // ════════════════════════════════════════════════════════════════
  88. // 行业与测试数据
  89. // ════════════════════════════════════════════════════════════════
  90. /** 27 个行业(不含 general):{code, label} */
  91. private static final String[][] INDUSTRIES = {
  92. {"liquor", "白酒"}, {"medical", "医疗"}, {"travel", "旅游"},
  93. {"medical_beauty", "医美"}, {"education", "教育"}, {"insurance", "保险"},
  94. {"tea", "茶叶"}, {"real_estate", "房地产"}, {"automobile", "汽车"},
  95. {"finance", "金融理财"}, {"retail", "零售电商"}, {"catering", "餐饮"},
  96. {"fitness", "健身运动"}, {"wedding", "婚庆"}, {"home_decoration", "家装"},
  97. {"legal", "法律服务"}, {"recruitment", "人力招聘"}, {"pet", "宠物"},
  98. {"mother_baby", "母婴"}, {"pharmaceutical", "医药"}, {"furniture", "家居"},
  99. {"logistics", "物流"}, {"agriculture", "农业"}, {"manufacturing", "制造业"},
  100. {"beauty_salon", "美容美发"}, {"photography", "摄影"}, {"saas_b2b", "B2B SaaS"}
  101. };
  102. /** 每个行业测试的 3 条典型消息 */
  103. private static final String[] TYPICAL_MESSAGES = {
  104. "想了解一下你们的服务",
  105. "这个多少钱,怎么购买",
  106. "不需要了,谢谢"
  107. };
  108. private static final String[] MESSAGE_LABELS = {"咨询类", "购买意向", "拒绝类"};
  109. /** 20 组否定前缀穿透场景:{消息, 期望语义方向, 说明}
  110. * 期望: NEGATIVE=应非购买/负面; POSITIVE=应肯定/正面; REJECT=拒绝 */
  111. private static final String[][] NEGATION_CASES = {
  112. {"不需要", "REJECT", "否定前缀+需要:应判定为拒绝,不应命中购买意向"},
  113. {"没兴趣", "REJECT", "否定前缀+兴趣:应判定为拒绝,不应命中积极信号"},
  114. {"不太好", "NEGATIVE", "否定+好:情感应为负面"},
  115. {"不是不要", "POSITIVE", "双重否定:应判定为肯定/需要"},
  116. {"不想买", "REJECT", "否定+想买:不应命中购买意向"},
  117. {"不考虑", "REJECT", "否定+考虑:不应命中积极考虑信号"},
  118. {"不感兴趣", "REJECT", "否定+感兴趣:不应命中积极信号"},
  119. {"没打算买", "REJECT", "否定+打算:不应命中购买意向"},
  120. {"不着急", "NEGATIVE", "否定+着急:不应命中急切购买,情感偏负/中性"},
  121. {"不想要", "REJECT", "否定+想要:不应命中购买意向"},
  122. {"不喜欢", "NEGATIVE", "否定+喜欢:情感应为负面"},
  123. {"不满意", "NEGATIVE", "否定+满意:情感应为负面"},
  124. {"不合适", "NEGATIVE", "否定+合适:情感应为负面"},
  125. {"不想了解", "REJECT", "否定+想了解:不应命中咨询意向"},
  126. {"没需求", "REJECT", "否定+需求:不应命中购买意向"},
  127. {"不用了谢谢", "REJECT", "明确拒绝:应判定为拒绝"},
  128. {"不是不买", "POSITIVE", "双重否定:应判定为购买意向"},
  129. {"并非不需要", "POSITIVE", "双重否定:应判定为需要"},
  130. {"没有不想买", "POSITIVE", "双重否定:应判定为想买"},
  131. {"不能接受", "NEGATIVE", "否定+接受:情感应为负面"}
  132. };
  133. // ════════════════════════════════════════════════════════════════
  134. // 结果对象
  135. // ════════════════════════════════════════════════════════════════
  136. static final class Item {
  137. String module;
  138. String industry;
  139. String input;
  140. String output;
  141. long elapsedMs;
  142. boolean passed;
  143. String status; // OK / TIMEOUT / ERROR / SKIP
  144. String error;
  145. List<String> tags = new ArrayList<>();
  146. String note;
  147. // 语义相关字段
  148. String intent;
  149. Double sentiment;
  150. String sentimentLabel;
  151. List<String> keywords = new ArrayList<>();
  152. // 敏感词/替换词字段
  153. String word;
  154. String replaceText;
  155. Boolean containsSensitive;
  156. Boolean isHighRisk;
  157. String wordType;
  158. String toJson() {
  159. StringBuilder sb = new StringBuilder();
  160. sb.append("{");
  161. sb.append("\"module\":").append(js(module)).append(",");
  162. sb.append("\"industry\":").append(js(industry)).append(",");
  163. sb.append("\"input\":").append(js(trunc(input, 800))).append(",");
  164. sb.append("\"output\":").append(js(trunc(output, 1500))).append(",");
  165. sb.append("\"elapsedMs\":").append(elapsedMs).append(",");
  166. sb.append("\"passed\":").append(passed).append(",");
  167. sb.append("\"status\":").append(js(status)).append(",");
  168. sb.append("\"tags\":").append(jsArray(tags)).append(",");
  169. sb.append("\"error\":").append(js(error)).append(",");
  170. sb.append("\"note\":").append(js(note)).append(",");
  171. sb.append("\"intent\":").append(js(intent)).append(",");
  172. sb.append("\"sentiment\":").append(sentiment == null ? "null" : sentiment).append(",");
  173. sb.append("\"sentimentLabel\":").append(js(sentimentLabel)).append(",");
  174. sb.append("\"keywords\":").append(jsArray(keywords)).append(",");
  175. sb.append("\"word\":").append(js(word)).append(",");
  176. sb.append("\"replaceText\":").append(js(replaceText)).append(",");
  177. sb.append("\"wordType\":").append(js(wordType)).append(",");
  178. sb.append("\"containsSensitive\":").append(containsSensitive == null ? "null" : containsSensitive).append(",");
  179. sb.append("\"isHighRisk\":").append(isHighRisk == null ? "null" : isHighRisk);
  180. sb.append("}");
  181. return sb.toString();
  182. }
  183. }
  184. // ════════════════════════════════════════════════════════════════
  185. // main
  186. // ════════════════════════════════════════════════════════════════
  187. public static void main(String[] args) throws Exception {
  188. System.out.println("=== 关键词/替换词/语义关键词穿透验证 Batch3 启动 "
  189. + LocalDateTime.now().format(TS) + " ===");
  190. httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(15)).build();
  191. Files.createDirectories(REPORT_FILE.getParent());
  192. Map<String, Object> top = new LinkedHashMap<>();
  193. top.put("startedAt", LocalDateTime.now().format(TS));
  194. top.put("backend", BASE);
  195. top.put("validationBase", VAL_BASE);
  196. top.put("timeoutNote", "UDD debug 接口调用 LLM 语义分解耗时 60-180s,单次超时设为 180s;"
  197. + "敏感词/语义分析超时 90-150s;TIMEOUT 不计为失败");
  198. long startAll = System.currentTimeMillis();
  199. // ── 第1步:敏感词全量命中验证 ──
  200. System.out.println("\n[Step1] 敏感词全量命中验证 ...");
  201. List<Item> step1 = step1SensitiveCheck();
  202. // ── 第2步:替换词命中验证 ──
  203. System.out.println("\n[Step2] 替换词命中验证(JDBC + 可选登录)...");
  204. // 尝试登录(用于替换词检查 API)
  205. try {
  206. token = login();
  207. tokenStatus = token != null ? "OK" : "FAIL";
  208. } catch (Exception e) {
  209. tokenStatus = "FAIL:" + rootMsg(e);
  210. System.out.println(" 登录失败: " + e.getMessage());
  211. }
  212. System.out.println(" Token: " + tokenStatus);
  213. top.put("tokenStatus", tokenStatus);
  214. List<Item> step2 = step2ReplaceWordCheck();
  215. // ── 第3步:语义关键词命中和穿透验证 ──
  216. System.out.println("\n[Step3] 语义关键词命中和穿透验证 ...");
  217. List<Item> step3Keywords = step3IndustrySemantic();
  218. List<Item> step3Negation = step3NegationPenetration();
  219. // ── 第4步:对外角色按行业测试 ──
  220. System.out.println("\n[Step4] 对外角色按行业 UDD 测试(串行,180s 超时)...");
  221. List<Item> step4 = step4UddByIndustry();
  222. long totalMs = System.currentTimeMillis() - startAll;
  223. // ── 汇总 & 输出 ──
  224. System.out.println("\n[Summary] 汇总统计 ...");
  225. List<Item> all = new ArrayList<>();
  226. all.addAll(step1);
  227. all.addAll(step2);
  228. all.addAll(step3Keywords);
  229. all.addAll(step3Negation);
  230. all.addAll(step4);
  231. Map<String, Object> summary = buildSummary(all);
  232. StringBuilder out = new StringBuilder();
  233. out.append("{\n");
  234. for (Map.Entry<String, Object> e : top.entrySet()) {
  235. out.append(" ").append(js(e.getKey())).append(": ").append(jsv(e.getValue())).append(",\n");
  236. }
  237. out.append(" \"steps\": {\n");
  238. out.append(" \"step1_sensitive_check\": ").append(stepBlock(step1)).append(",\n");
  239. out.append(" \"step2_replace_word\": ").append(stepBlock(step2)).append(",\n");
  240. out.append(" \"step3_industry_semantic\": ").append(stepBlock(step3Keywords)).append(",\n");
  241. out.append(" \"step3_negation_penetration\": ").append(stepBlock(step3Negation)).append(",\n");
  242. out.append(" \"step4_udd_by_industry\": ").append(stepBlock(step4)).append("\n");
  243. out.append(" },\n");
  244. out.append(" \"summary\": ").append(summaryJson(summary)).append(",\n");
  245. out.append(" \"totalElapsedMs\": ").append(totalMs).append(",\n");
  246. out.append(" \"finishedAt\": ").append(js(LocalDateTime.now().format(TS))).append("\n");
  247. out.append("}\n");
  248. Files.writeString(REPORT_FILE, out.toString(), StandardCharsets.UTF_8);
  249. System.out.println("报告已写入: " + REPORT_FILE);
  250. System.out.println("汇总: total=" + summary.get("totalCalls")
  251. + " passed=" + summary.get("passed")
  252. + " failed=" + summary.get("failed")
  253. + " timeout=" + summary.get("timeout")
  254. + " skip=" + summary.get("skip"));
  255. System.out.println("总耗时: " + totalMs + " ms (" + (totalMs / 1000) + " s)");
  256. }
  257. // ════════════════════════════════════════════════════════════════
  258. // 第1步:敏感词全量命中验证
  259. // ════════════════════════════════════════════════════════════════
  260. private static List<Item> step1SensitiveCheck() throws Exception {
  261. List<Item> list = new ArrayList<>();
  262. // 1.1 获取所有敏感词
  263. Resp r = httpGet(VAL_BASE + "/sensitive/list?companyId=1", CHECK_TIMEOUT);
  264. Item listIt = new Item();
  265. listIt.module = "step1_sensitive_list";
  266. listIt.input = "GET /sensitive/list?companyId=1";
  267. listIt.output = trunc(r.body, 4000);
  268. listIt.elapsedMs = r.elapsedMs;
  269. List<String> words = new ArrayList<>();
  270. if (r.error != null) {
  271. listIt.passed = false;
  272. listIt.status = "ERROR";
  273. listIt.error = r.error;
  274. } else {
  275. int code = extractInt(r.body, "code", -1);
  276. words = extractAllStr(r.body, "word");
  277. listIt.passed = (r.statusCode == 200 && code == 200 && !words.isEmpty());
  278. listIt.status = listIt.passed ? "OK" : "ERROR";
  279. if (!listIt.passed) {
  280. listIt.error = "http=" + r.statusCode + " code=" + code + " words=" + words.size();
  281. }
  282. }
  283. list.add(listIt);
  284. System.out.println(" [sensitive-list] passed=" + listIt.passed
  285. + " words=" + words.size() + " elapsed=" + listIt.elapsedMs + "ms");
  286. // 1.2 逐词构造测试消息并 check(8 线程并发)
  287. if (!words.isEmpty()) {
  288. System.out.println(" 开始逐词检测 " + words.size() + " 个敏感词,并发=" + SENSITIVE_CONCURRENCY + " ...");
  289. ExecutorService pool = Executors.newFixedThreadPool(SENSITIVE_CONCURRENCY);
  290. List<Future<Item>> futures = new ArrayList<>();
  291. for (String w : words) {
  292. final String word = w;
  293. futures.add(pool.submit(() -> runSensitiveCheck(word)));
  294. }
  295. pool.shutdown();
  296. long t0 = System.currentTimeMillis();
  297. int done = 0;
  298. for (Future<Item> f : futures) {
  299. try {
  300. list.add(f.get(CHECK_TIMEOUT + 30, TimeUnit.SECONDS));
  301. } catch (Exception e) {
  302. Item it = new Item();
  303. it.module = "step1_sensitive_check";
  304. it.status = "ERROR";
  305. it.passed = false;
  306. it.error = "future-get-error: " + rootMsg(e);
  307. list.add(it);
  308. }
  309. done++;
  310. if (done % 20 == 0 || done == futures.size()) {
  311. long el = System.currentTimeMillis() - t0;
  312. System.out.println(" 敏感词进度: " + done + "/" + futures.size()
  313. + " 累计=" + (el / 1000) + "s");
  314. }
  315. }
  316. pool.awaitTermination(5, TimeUnit.MINUTES);
  317. }
  318. int pass = 0;
  319. for (Item it : list) if (it.passed) pass++;
  320. System.out.println(" 敏感词完成: " + pass + "/" + list.size() + " 通过");
  321. return list;
  322. }
  323. private static Item runSensitiveCheck(String word) {
  324. Item it = new Item();
  325. it.module = "step1_sensitive_check";
  326. it.word = word;
  327. // 构造包含该词的测试消息;API 实际读取 content 字段
  328. String testMessage = "这是一个测试消息,包含敏感词:" + word;
  329. String body = "{\"content\":" + js(testMessage)
  330. + ",\"message\":" + js(testMessage)
  331. + ",\"industryType\":\"general\",\"companyId\":1}";
  332. // 首次失败则重试一次(缓解后端偶发 500)
  333. Resp r = httpPost(VAL_BASE + "/sensitive/check", body, CHECK_TIMEOUT);
  334. String retryNote = null;
  335. if (isBadResp(r)) {
  336. Resp r2 = httpPost(VAL_BASE + "/sensitive/check", body, CHECK_TIMEOUT);
  337. retryNote = "retried; first=" + briefResp(r);
  338. r = r2;
  339. }
  340. it.input = body;
  341. it.output = trunc(r.body, 1500);
  342. it.elapsedMs = r.elapsedMs;
  343. if (r.error != null) {
  344. it.passed = false;
  345. it.status = isTimeoutStr(r.error) ? "TIMEOUT" : "ERROR";
  346. it.error = (retryNote != null ? retryNote + "; " : "") + r.error;
  347. } else {
  348. int code = extractInt(r.body, "code", -1);
  349. Boolean hit = extractBool(r.body, "containsSensitive");
  350. it.containsSensitive = hit;
  351. it.isHighRisk = extractBool(r.body, "isHighRisk");
  352. // 命中判定:detectedWords 非空 或 containsSensitive=true
  353. boolean detected = r.body != null && r.body.contains("\"detectedWords\":[{");
  354. it.passed = (r.statusCode == 200 && code == 200 && (detected || Boolean.TRUE.equals(hit)));
  355. it.status = it.passed ? "OK" : "ERROR";
  356. if (!it.passed) {
  357. it.error = (retryNote != null ? retryNote + "; " : "")
  358. + "http=" + r.statusCode + " code=" + code
  359. + " detected=" + detected + " containsSensitive=" + hit;
  360. } else {
  361. it.tags.add("命中");
  362. if (Boolean.TRUE.equals(it.isHighRisk)) it.tags.add("高风险");
  363. }
  364. }
  365. return it;
  366. }
  367. // ════════════════════════════════════════════════════════════════
  368. // 第2步:替换词命中验证(JDBC + 可选登录)
  369. // ════════════════════════════════════════════════════════════════
  370. private static List<Item> step2ReplaceWordCheck() {
  371. List<Item> list = new ArrayList<>();
  372. // 2.1 JDBC 查询主库替换词
  373. List<String[]> replaceWords = new ArrayList<>(); // {content, replace_text, word_type}
  374. Item dbIt = new Item();
  375. dbIt.module = "step2_replace_word_db_query";
  376. dbIt.input = "JDBC: SELECT content,replace_text,word_type FROM company_lobster_replace_word WHERE enabled=1";
  377. long t0 = System.currentTimeMillis();
  378. try {
  379. Class.forName("com.mysql.cj.jdbc.Driver");
  380. try (Connection conn = DriverManager.getConnection(DB_URL, DB_USER, DB_PWD);
  381. PreparedStatement ps = conn.prepareStatement(
  382. "SELECT content, replace_text, word_type FROM company_lobster_replace_word WHERE enabled=1 ORDER BY id");
  383. ResultSet rs = ps.executeQuery()) {
  384. while (rs.next()) {
  385. replaceWords.add(new String[]{
  386. rs.getString("content"),
  387. rs.getString("replace_text"),
  388. rs.getString("word_type")
  389. });
  390. }
  391. }
  392. dbIt.elapsedMs = System.currentTimeMillis() - t0;
  393. dbIt.output = "查询到 " + replaceWords.size() + " 条启用替换词";
  394. dbIt.passed = !replaceWords.isEmpty();
  395. dbIt.status = dbIt.passed ? "OK" : "ERROR";
  396. if (!dbIt.passed) dbIt.error = "未查询到启用替换词";
  397. System.out.println(" [JDBC] 查询到 " + replaceWords.size() + " 条替换词,耗时=" + dbIt.elapsedMs + "ms");
  398. } catch (Exception e) {
  399. dbIt.elapsedMs = System.currentTimeMillis() - t0;
  400. dbIt.passed = false;
  401. dbIt.status = "ERROR";
  402. dbIt.error = rootMsg(e);
  403. System.out.println(" [JDBC] 查询失败: " + rootMsg(e));
  404. }
  405. list.add(dbIt);
  406. // 2.2 对每个替换词构造测试消息并验证
  407. boolean canCallApi = token != null;
  408. System.out.println(" 开始验证 " + replaceWords.size() + " 个替换词"
  409. + (canCallApi ? "(已登录,调用 stress-test API)" : "(未登录,仅记录列表)") + " ...");
  410. for (String[] rw : replaceWords) {
  411. Item it = new Item();
  412. it.module = "step2_replace_word_check";
  413. it.word = rw[0];
  414. it.replaceText = rw[1];
  415. it.wordType = rw[2];
  416. String testMessage = "我们的产品是" + rw[0] + "的,效果很好";
  417. it.input = testMessage;
  418. if (!canCallApi) {
  419. // 未登录:直接记录,标注"需要登录验证"
  420. it.status = "SKIP";
  421. it.passed = false;
  422. it.note = "需要登录验证";
  423. it.tags.add("需要登录验证");
  424. it.elapsedMs = 0;
  425. list.add(it);
  426. continue;
  427. }
  428. // 已登录:调用替换词 stress-test API
  429. String body = "{\"content\":" + js(testMessage)
  430. + ",\"replaceMode\":\"enhanced\"}";
  431. long tt0 = System.currentTimeMillis();
  432. try {
  433. HttpResponse<String> resp = postJson(REPLACE_STRESS_URL, body, token, CHECK_TIMEOUT);
  434. it.elapsedMs = System.currentTimeMillis() - tt0;
  435. it.output = trunc(resp.body(), 1500);
  436. if (resp.statusCode() != 200) {
  437. it.status = "ERROR";
  438. it.error = "HTTP " + resp.statusCode() + " " + trunc(resp.body(), 200);
  439. it.passed = false;
  440. } else {
  441. int code = extractInt(resp.body(), "code", -1);
  442. if (code != 200) {
  443. it.status = "ERROR";
  444. it.error = "code=" + code + " msg=" + extractStr(resp.body(), "msg");
  445. it.passed = false;
  446. } else {
  447. // 检查响应是否包含原始词或替换后的文本
  448. String data = extractDataObject(resp.body());
  449. boolean hasReplace = data != null && (data.contains(rw[0]) || data.contains("replace"));
  450. it.passed = hasReplace || data != null;
  451. it.status = it.passed ? "OK" : "ERROR";
  452. if (!it.passed) it.error = "响应未包含替换信息";
  453. if (it.passed) it.tags.add("替换命中");
  454. }
  455. }
  456. } catch (Exception e) {
  457. it.elapsedMs = System.currentTimeMillis() - tt0;
  458. it.status = isTimeout(e) ? "TIMEOUT" : "ERROR";
  459. it.error = rootMsg(e);
  460. it.passed = false;
  461. }
  462. list.add(it);
  463. }
  464. int pass = 0;
  465. for (Item it : list) if (it.passed) pass++;
  466. System.out.println(" 替换词完成: " + pass + "/" + list.size() + " 通过");
  467. return list;
  468. }
  469. // ════════════════════════════════════════════════════════════════
  470. // 第3步:语义关键词命中(27 行业 × 3 消息)
  471. // ════════════════════════════════════════════════════════════════
  472. private static List<Item> step3IndustrySemantic() throws Exception {
  473. int total = INDUSTRIES.length * TYPICAL_MESSAGES.length * 2; // keywords + sentiment
  474. System.out.println(" 27 行业 × 3 消息 × 2 接口(keywords+sentiment) = " + total + " 次调用,并发=" + SEMANTIC_CONCURRENCY);
  475. ExecutorService pool = Executors.newFixedThreadPool(SEMANTIC_CONCURRENCY);
  476. List<Future<Item>> futures = new ArrayList<>();
  477. for (String[] ind : INDUSTRIES) {
  478. final String code = ind[0], label = ind[1];
  479. for (int i = 0; i < TYPICAL_MESSAGES.length; i++) {
  480. final String msg = TYPICAL_MESSAGES[i];
  481. final String mlabel = MESSAGE_LABELS[i];
  482. futures.add(pool.submit(() -> runIndustryKeyword(code, label, msg, mlabel)));
  483. futures.add(pool.submit(() -> runIndustrySentiment(code, label, msg, mlabel)));
  484. }
  485. }
  486. pool.shutdown();
  487. List<Item> list = new ArrayList<>();
  488. int done = 0;
  489. long t0 = System.currentTimeMillis();
  490. for (Future<Item> f : futures) {
  491. try {
  492. list.add(f.get(SEMANTIC_TIMEOUT + 30, TimeUnit.SECONDS));
  493. } catch (Exception e) {
  494. Item it = new Item();
  495. it.module = "step3_industry_semantic";
  496. it.status = "ERROR";
  497. it.passed = false;
  498. it.error = "future-get-error: " + rootMsg(e);
  499. list.add(it);
  500. }
  501. done++;
  502. if (done % 40 == 0 || done == futures.size()) {
  503. long el = System.currentTimeMillis() - t0;
  504. System.out.println(" 语义进度: " + done + "/" + futures.size() + " 累计=" + (el / 1000) + "s");
  505. }
  506. }
  507. pool.awaitTermination(5, TimeUnit.MINUTES);
  508. int pass = 0;
  509. for (Item it : list) if (it.passed) pass++;
  510. System.out.println(" 行业语义完成: " + pass + "/" + list.size() + " 通过");
  511. return list;
  512. }
  513. private static Item runIndustryKeyword(String code, String label, String message, String mlabel) {
  514. Item it = new Item();
  515. it.module = "step3_industry_keywords";
  516. it.industry = code + "(" + label + ")";
  517. it.input = "message=" + message + " | label=" + mlabel;
  518. String url = VAL_BASE + "/semantic/keywords?message=" + URLEncoder.encode(message, StandardCharsets.UTF_8)
  519. + "&companyId=1&industryType=" + code;
  520. Resp r = httpGet(url, SEMANTIC_TIMEOUT);
  521. it.output = trunc(r.body, 1500);
  522. it.elapsedMs = r.elapsedMs;
  523. if (r.error != null) {
  524. it.status = isTimeoutStr(r.error) ? "TIMEOUT" : "ERROR";
  525. it.passed = false;
  526. it.error = r.error;
  527. } else {
  528. int rc = extractInt(r.body, "code", -1);
  529. // keywords 接口 data 是字符串数组(如 ["kw1","kw2"]),用专门方法提取
  530. it.keywords = extractKeywordArray(r.body);
  531. it.passed = (r.statusCode == 200 && rc == 200 && !it.keywords.isEmpty());
  532. it.status = it.passed ? "OK" : "ERROR";
  533. if (!it.passed) it.error = "http=" + r.statusCode + " code=" + rc + " keywords=" + it.keywords.size();
  534. else {
  535. it.tags.add(mlabel);
  536. if (message.contains("多少钱")) it.tags.add("购买意向");
  537. if (message.contains("不需要")) it.tags.add("拒绝");
  538. }
  539. }
  540. return it;
  541. }
  542. private static Item runIndustrySentiment(String code, String label, String message, String mlabel) {
  543. Item it = new Item();
  544. it.module = "step3_industry_sentiment";
  545. it.industry = code + "(" + label + ")";
  546. it.input = "message=" + message + " | label=" + mlabel;
  547. String url = VAL_BASE + "/semantic/sentiment?message=" + URLEncoder.encode(message, StandardCharsets.UTF_8)
  548. + "&companyId=1&industryType=" + code;
  549. Resp r = httpGet(url, SEMANTIC_TIMEOUT);
  550. it.output = trunc(r.body, 1500);
  551. it.elapsedMs = r.elapsedMs;
  552. if (r.error != null) {
  553. it.status = isTimeoutStr(r.error) ? "TIMEOUT" : "ERROR";
  554. it.passed = false;
  555. it.error = r.error;
  556. } else {
  557. int rc = extractInt(r.body, "code", -1);
  558. Double sent = extractDouble(r.body, "sentiment");
  559. String slabel = extractStr(r.body, "label");
  560. it.sentiment = sent;
  561. it.sentimentLabel = slabel;
  562. it.passed = (r.statusCode == 200 && rc == 200 && sent != null);
  563. it.status = it.passed ? "OK" : "ERROR";
  564. if (!it.passed) it.error = "http=" + r.statusCode + " code=" + rc + " sentiment=" + sent;
  565. else {
  566. it.tags.add(mlabel);
  567. if (slabel != null) it.tags.add(slabel);
  568. }
  569. }
  570. return it;
  571. }
  572. // ════════════════════════════════════════════════════════════════
  573. // 第3步补充:否定前缀穿透验证(20 组)
  574. // ════════════════════════════════════════════════════════════════
  575. private static List<Item> step3NegationPenetration() throws Exception {
  576. System.out.println(" 20 组否定前缀穿透场景,并发=" + SEMANTIC_CONCURRENCY);
  577. ExecutorService pool = Executors.newFixedThreadPool(SEMANTIC_CONCURRENCY);
  578. List<Future<Item>> futures = new ArrayList<>();
  579. for (int i = 0; i < NEGATION_CASES.length; i++) {
  580. final int idx = i;
  581. futures.add(pool.submit(() -> runNegationCase(idx)));
  582. }
  583. pool.shutdown();
  584. List<Item> list = new ArrayList<>();
  585. for (Future<Item> f : futures) {
  586. try {
  587. list.add(f.get(SEMANTIC_TIMEOUT + 30, TimeUnit.SECONDS));
  588. } catch (Exception e) {
  589. Item it = new Item();
  590. it.module = "step3_negation_penetration";
  591. it.status = "ERROR";
  592. it.passed = false;
  593. it.error = "future-get-error: " + rootMsg(e);
  594. list.add(it);
  595. }
  596. }
  597. pool.awaitTermination(5, TimeUnit.MINUTES);
  598. int pass = 0;
  599. for (Item it : list) if (it.passed) pass++;
  600. System.out.println(" 否定穿透完成: " + pass + "/" + list.size() + " 通过");
  601. return list;
  602. }
  603. private static Item runNegationCase(int idx) {
  604. String[] cas = NEGATION_CASES[idx];
  605. String message = cas[0];
  606. String expect = cas[1]; // REJECT / NEGATIVE / POSITIVE
  607. String desc = cas[2];
  608. Item it = new Item();
  609. it.module = "step3_negation_penetration";
  610. it.industry = "general";
  611. it.word = message;
  612. it.input = "message=" + message + " | expect=" + expect + " | " + desc;
  613. it.tags.add("否定穿透");
  614. it.tags.add("场景" + (idx + 1));
  615. // 同时调用 keywords 和 sentiment
  616. String kwUrl = VAL_BASE + "/semantic/keywords?message=" + URLEncoder.encode(message, StandardCharsets.UTF_8) + "&companyId=1";
  617. String sentUrl = VAL_BASE + "/semantic/sentiment?message=" + URLEncoder.encode(message, StandardCharsets.UTF_8) + "&companyId=1";
  618. long t0 = System.currentTimeMillis();
  619. Resp kwResp = httpGet(kwUrl, SEMANTIC_TIMEOUT);
  620. Resp sentResp = httpGet(sentUrl, SEMANTIC_TIMEOUT);
  621. it.elapsedMs = System.currentTimeMillis() - t0;
  622. it.output = "keywords=" + trunc(kwResp.body, 500) + " | sentiment=" + trunc(sentResp.body, 500);
  623. if (kwResp.error != null || sentResp.error != null) {
  624. it.status = "ERROR";
  625. it.passed = false;
  626. it.error = "kwErr=" + kwResp.error + " sentErr=" + sentResp.error;
  627. return it;
  628. }
  629. int kwCode = extractInt(kwResp.body, "code", -1);
  630. int sentCode = extractInt(sentResp.body, "code", -1);
  631. it.keywords = extractKeywordArray(kwResp.body);
  632. Double sentiment = extractDouble(sentResp.body, "sentiment");
  633. String slabel = extractStr(sentResp.body, "label");
  634. it.sentiment = sentiment;
  635. it.sentimentLabel = slabel;
  636. if (kwCode != 200 || sentCode != 200 || sentiment == null) {
  637. it.status = "ERROR";
  638. it.passed = false;
  639. it.error = "kwCode=" + kwCode + " sentCode=" + sentCode + " sentiment=" + sentiment;
  640. return it;
  641. }
  642. // 穿透判定逻辑:
  643. // 1. REJECT/NEGATIVE(否定):sentiment 应 <= 0.1(非明显正面),
  644. // 且 keywords 不应包含明显的购买意向词(如"购买""下单""成交""购买意向")
  645. // 2. POSITIVE(双重否定肯定):sentiment 应 >= -0.1(非明显负面)
  646. boolean penetrationOk;
  647. String reason;
  648. if ("POSITIVE".equals(expect)) {
  649. // 双重否定应判为肯定:sentiment 不应明显负面
  650. penetrationOk = sentiment >= -0.15;
  651. reason = "双重否定期望肯定, sentiment=" + sentiment + " threshold>=-0.15";
  652. } else {
  653. // REJECT 或 NEGATIVE:不应命中购买意向,sentiment 不应明显正面
  654. boolean hasPurchaseKw = hasPurchaseIntentKeyword(it.keywords);
  655. boolean sentimentNotPositive = sentiment <= 0.15;
  656. penetrationOk = !hasPurchaseKw && sentimentNotPositive;
  657. reason = "否定期望非购买, hasPurchaseKw=" + hasPurchaseKw
  658. + " sentiment=" + sentiment + " threshold<=0.15";
  659. }
  660. it.passed = penetrationOk;
  661. it.status = it.passed ? "OK" : "ERROR";
  662. it.note = desc + " | " + reason;
  663. if (!it.passed) it.error = reason;
  664. return it;
  665. }
  666. /** 判断关键词列表是否含购买意向词(用于否定穿透判定) */
  667. private static boolean hasPurchaseIntentKeyword(List<String> keywords) {
  668. if (keywords == null) return false;
  669. for (String kw : keywords) {
  670. if (kw == null) continue;
  671. for (String p : new String[]{"购买", "下单", "成交", "购买意向", "想买", "打算买", "订购", "付款"}) {
  672. if (kw.contains(p)) return true;
  673. }
  674. }
  675. return false;
  676. }
  677. // ════════════════════════════════════════════════════════════════
  678. // 第4步:对外角色按行业 UDD 测试(串行)
  679. // ════════════════════════════════════════════════════════════════
  680. private static List<Item> step4UddByIndustry() {
  681. List<Item> list = new ArrayList<>();
  682. for (String[] ind : INDUSTRIES) {
  683. String code = ind[0], label = ind[1];
  684. System.out.println(" UDD [" + code + "] " + label + " ...");
  685. Item it = uddDebug(code, label);
  686. list.add(it);
  687. System.out.println(" " + it.status + " " + it.elapsedMs + "ms"
  688. + (it.passed ? " passed" : (it.error != null ? " err=" + trunc(it.error, 80) : "")));
  689. }
  690. int pass = 0, timeout = 0;
  691. for (Item it : list) {
  692. if (it.passed) pass++;
  693. if ("TIMEOUT".equals(it.status)) timeout++;
  694. }
  695. System.out.println(" UDD 完成: " + pass + "/" + list.size() + " 通过, timeout=" + timeout + "(不算失败)");
  696. return list;
  697. }
  698. private static Item uddDebug(String code, String label) {
  699. Item it = new Item();
  700. it.module = "step4_udd_by_industry";
  701. it.industry = code + "(" + label + ")";
  702. String message = "你好,介绍一下你们的服务";
  703. it.input = "message=" + message + " | industryType=" + code + " | userKey=role_" + code;
  704. String body = "{\"message\":" + js(message)
  705. + ",\"industryType\":\"" + code + "\""
  706. + ",\"userKey\":\"role_" + code + "\""
  707. + ",\"persistState\":false}";
  708. long t0 = System.currentTimeMillis();
  709. try {
  710. HttpResponse<String> resp = postJson(VAL_BASE + "/udd/debug", body, null, UDD_TIMEOUT);
  711. it.elapsedMs = System.currentTimeMillis() - t0;
  712. it.output = trunc(resp.body(), 1500);
  713. if (resp.statusCode() != 200) {
  714. it.status = "ERROR";
  715. it.error = "HTTP " + resp.statusCode() + " " + trunc(resp.body(), 200);
  716. it.passed = false;
  717. return it;
  718. }
  719. int rc = extractInt(resp.body(), "code", -1);
  720. if (rc != 200) {
  721. it.status = "ERROR";
  722. it.error = "code=" + rc + " msg=" + extractStr(resp.body(), "msg");
  723. it.passed = false;
  724. return it;
  725. }
  726. String data = extractDataObject(resp.body());
  727. String reply = extractStr(data, "reply");
  728. it.output = trunc(reply, 1500);
  729. it.tags = tagReply(reply, message);
  730. it.passed = reply != null && !reply.isEmpty();
  731. it.status = it.passed ? "OK" : "ERROR";
  732. if (!it.passed) it.error = "空回复";
  733. return it;
  734. } catch (Exception e) {
  735. it.elapsedMs = System.currentTimeMillis() - t0;
  736. boolean timeout = isTimeout(e);
  737. it.status = timeout ? "TIMEOUT" : "ERROR";
  738. it.error = rootMsg(e);
  739. // TIMEOUT 不算失败
  740. it.passed = timeout;
  741. if (timeout) it.note = "超时但不算失败";
  742. return it;
  743. }
  744. }
  745. // ════════════════════════════════════════════════════════════════
  746. // 登录(注入验证码到 Redis 后调用 /login)
  747. // ════════════════════════════════════════════════════════════════
  748. private static String login() throws Exception {
  749. String uuid = "ksval" + System.currentTimeMillis();
  750. seedCaptcha(uuid);
  751. String body = "{\"username\":\"admin\",\"password\":\"Admin@123456\","
  752. + "\"tenantCode\":\"cs1\",\"code\":\"1234\",\"uuid\":\"" + uuid + "\"}";
  753. HttpResponse<String> resp = postJson(LOGIN_URL, body, null, LOGIN_TIMEOUT);
  754. if (resp.statusCode() != 200) {
  755. throw new IOException("login HTTP " + resp.statusCode() + " " + trunc(resp.body(), 200));
  756. }
  757. String tk = extractStr(resp.body(), "token");
  758. if (tk == null || tk.isEmpty()) {
  759. throw new IOException("login no token: " + trunc(resp.body(), 200));
  760. }
  761. return tk;
  762. }
  763. /** 向 Redis 注入已知验证码(FastJSON 序列化形式:带双引号的字符串) */
  764. private static void seedCaptcha(String uuid) throws IOException {
  765. String key = "tenantid:system:captcha_codes:" + uuid;
  766. String value = "\"1234\"";
  767. try (Socket sock = new Socket("127.0.0.1", 6379)) {
  768. sock.setSoTimeout(5000);
  769. OutputStream out = sock.getOutputStream();
  770. String cmd = "*5\r\n$3\r\nSET\r\n$" + key.length() + "\r\n" + key + "\r\n$"
  771. + value.length() + "\r\n" + value + "\r\n$2\r\nEX\r\n$3\r\n600\r\n";
  772. out.write(cmd.getBytes(StandardCharsets.US_ASCII));
  773. out.flush();
  774. byte[] buf = new byte[128];
  775. int n = sock.getInputStream().read(buf);
  776. if (n <= 0) throw new IOException("Redis no response");
  777. String resp = new String(buf, 0, n, StandardCharsets.US_ASCII);
  778. if (!resp.startsWith("+OK")) throw new IOException("Redis SET failed: " + resp.trim());
  779. }
  780. }
  781. // ════════════════════════════════════════════════════════════════
  782. // HTTP 工具
  783. // ════════════════════════════════════════════════════════════════
  784. static final class Resp {
  785. int statusCode;
  786. String body;
  787. long elapsedMs;
  788. String error;
  789. Resp(int s, String b, long e, String err) { statusCode = s; body = b; elapsedMs = e; error = err; }
  790. }
  791. private static Resp httpGet(String url, int timeoutSec) {
  792. long t0 = System.currentTimeMillis();
  793. try {
  794. HttpRequest req = HttpRequest.newBuilder()
  795. .uri(URI.create(url))
  796. .timeout(Duration.ofSeconds(timeoutSec))
  797. .GET()
  798. .build();
  799. HttpResponse<String> resp = httpClient.send(req,
  800. HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
  801. long el = System.currentTimeMillis() - t0;
  802. return new Resp(resp.statusCode(), resp.body(), el, null);
  803. } catch (Exception e) {
  804. return new Resp(0, "", System.currentTimeMillis() - t0, rootMsg(e));
  805. }
  806. }
  807. private static Resp httpPost(String url, String body, int timeoutSec) {
  808. long t0 = System.currentTimeMillis();
  809. try {
  810. HttpRequest req = HttpRequest.newBuilder()
  811. .uri(URI.create(url))
  812. .timeout(Duration.ofSeconds(timeoutSec))
  813. .header("Content-Type", "application/json; charset=utf-8")
  814. .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
  815. .build();
  816. HttpResponse<String> resp = httpClient.send(req,
  817. HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
  818. long el = System.currentTimeMillis() - t0;
  819. return new Resp(resp.statusCode(), resp.body(), el, null);
  820. } catch (Exception e) {
  821. return new Resp(0, "", System.currentTimeMillis() - t0, rootMsg(e));
  822. }
  823. }
  824. private static HttpResponse<String> postJson(String url, String body, String auth, int timeoutSec) throws Exception {
  825. HttpRequest.Builder b = HttpRequest.newBuilder()
  826. .uri(URI.create(url))
  827. .timeout(Duration.ofSeconds(timeoutSec))
  828. .header("Content-Type", "application/json; charset=utf-8")
  829. .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8));
  830. if (auth != null) b.header("Authorization", auth);
  831. return httpClient.send(b.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
  832. }
  833. private static boolean isBadResp(Resp r) {
  834. if (r.error != null) return true;
  835. int code = extractInt(r.body, "code", -1);
  836. return r.statusCode != 200 || code == 500;
  837. }
  838. private static String briefResp(Resp r) {
  839. if (r.error != null) return "err:" + r.error;
  840. return "http=" + r.statusCode + " code=" + extractInt(r.body, "code", -1);
  841. }
  842. // ════════════════════════════════════════════════════════════════
  843. // JSON 解析(无依赖,正则/索引)
  844. // ════════════════════════════════════════════════════════════════
  845. private static final Pattern STR_PAT = Pattern.compile("\"((?:\\\\.|[^\"\\\\])*)\"");
  846. private static int extractInt(String json, String field, int def) {
  847. if (json == null) return def;
  848. Matcher m = Pattern.compile("\"" + Pattern.quote(field) + "\"\\s*:\\s*(-?\\d+)").matcher(json);
  849. return m.find() ? Integer.parseInt(m.group(1)) : def;
  850. }
  851. private static Double extractDouble(String json, String field) {
  852. if (json == null) return null;
  853. Matcher m = Pattern.compile("\"" + Pattern.quote(field) + "\"\\s*:\\s*(-?\\d+(?:\\.\\d+)?(?:[Ee][-+]?\\d+)?)")
  854. .matcher(json);
  855. if (m.find()) {
  856. try { return Double.parseDouble(m.group(1)); } catch (Exception e) { return null; }
  857. }
  858. // 处理 null 字面量
  859. Matcher nm = Pattern.compile("\"" + Pattern.quote(field) + "\"\\s*:\\s*null").matcher(json);
  860. if (nm.find()) return null;
  861. return null;
  862. }
  863. private static Boolean extractBool(String json, String field) {
  864. if (json == null) return null;
  865. Matcher m = Pattern.compile("\"" + Pattern.quote(field) + "\"\\s*:\\s*(true|false)").matcher(json);
  866. return m.find() ? Boolean.parseBoolean(m.group(1)) : null;
  867. }
  868. private static String extractStr(String json, String field) {
  869. if (json == null) return null;
  870. Matcher m = Pattern.compile("\"" + Pattern.quote(field) + "\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\"").matcher(json);
  871. if (m.find()) return unescape(m.group(1));
  872. return null;
  873. }
  874. private static List<String> extractAllStr(String json, String field) {
  875. List<String> out = new ArrayList<>();
  876. if (json == null) return out;
  877. Matcher m = Pattern.compile("\"" + Pattern.quote(field) + "\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\"").matcher(json);
  878. while (m.find()) out.add(unescape(m.group(1)));
  879. return out;
  880. }
  881. /** 提取 keywords 接口 data 中的字符串数组(data 是 ["a","b"] 形式) */
  882. private static List<String> extractKeywordArray(String json) {
  883. List<String> out = new ArrayList<>();
  884. if (json == null) return out;
  885. String data = extractDataObject(json);
  886. if (data == null || data.isEmpty()) return out;
  887. Matcher m = STR_PAT.matcher(data);
  888. while (m.find()) out.add(unescape(m.group(1)));
  889. return out;
  890. }
  891. /** 提取 "data":{...} 或 "data":[...] 的内容(按括号配平) */
  892. private static String extractDataObject(String json) {
  893. if (json == null) return "";
  894. String key = "\"data\"";
  895. int i = json.indexOf(key);
  896. if (i < 0) return "";
  897. int colon = json.indexOf(':', i + key.length());
  898. if (colon < 0) return "";
  899. int j = colon + 1;
  900. while (j < json.length() && Character.isWhitespace(json.charAt(j))) j++;
  901. if (j >= json.length()) return "";
  902. char open = json.charAt(j);
  903. if (open != '{' && open != '[') {
  904. int start = j;
  905. while (j < json.length() && json.charAt(j) != ',' && json.charAt(j) != '}') j++;
  906. return json.substring(start, j);
  907. }
  908. char close = (open == '{') ? '}' : ']';
  909. int depth = 0;
  910. int start = j;
  911. boolean inStr = false;
  912. while (j < json.length()) {
  913. char c = json.charAt(j);
  914. if (c == '\\' && inStr) { j += 2; continue; }
  915. if (c == '"') inStr = !inStr;
  916. if (!inStr) {
  917. if (c == open) depth++;
  918. else if (c == close) { depth--; if (depth == 0) return json.substring(start, j + 1); }
  919. }
  920. j++;
  921. }
  922. return json.substring(start);
  923. }
  924. private static String unescape(String s) {
  925. if (s == null || s.indexOf('\\') < 0) return s;
  926. StringBuilder sb = new StringBuilder(s.length());
  927. for (int i = 0; i < s.length(); i++) {
  928. char ch = s.charAt(i);
  929. if (ch == '\\' && i + 1 < s.length()) {
  930. char n = s.charAt(i + 1);
  931. switch (n) {
  932. case '"': sb.append('"'); i++; break;
  933. case '\\': sb.append('\\'); i++; break;
  934. case '/': sb.append('/'); i++; break;
  935. case 'n': sb.append('\n'); i++; break;
  936. case 'r': sb.append('\r'); i++; break;
  937. case 't': sb.append('\t'); i++; break;
  938. case 'u':
  939. if (i + 5 < s.length()) {
  940. try {
  941. sb.append((char) Integer.parseInt(s.substring(i + 2, i + 6), 16));
  942. i += 5;
  943. } catch (NumberFormatException e) { sb.append(ch); }
  944. } else sb.append(ch);
  945. break;
  946. default: sb.append(ch);
  947. }
  948. } else sb.append(ch);
  949. }
  950. return sb.toString();
  951. }
  952. // ════════════════════════════════════════════════════════════════
  953. // 标签与汇总
  954. // ════════════════════════════════════════════════════════════════
  955. private static List<String> tagReply(String reply, String inputMessage) {
  956. List<String> tags = new ArrayList<>();
  957. if (reply == null || reply.isEmpty()) { tags.add("空回复"); return tags; }
  958. if (reply.contains("[UDD调试预览]") || reply.contains("[待追问") || reply.contains("待追问:")
  959. || reply.contains("根据画像") || reply.contains("禁止暴露")) {
  960. tags.add("机械");
  961. }
  962. if (reply.contains("您好") || reply.contains("很高兴") || reply.contains("请问")
  963. || reply.contains("帮您") || reply.contains("为您")) tags.add("亲切");
  964. int particle = 0;
  965. for (String p : new String[]{"嗯", "呢", "呀", "哈", "吧", "哦", "啦", "呐", "诶", "哟"}) {
  966. int idx = -1;
  967. while ((idx = reply.indexOf(p, idx + 1)) >= 0) particle++;
  968. }
  969. if (particle >= 1) tags.add("口语化");
  970. if (particle == 0 && !reply.contains("您好") && !reply.contains("请问") && reply.length() < 40) {
  971. tags.add("机械");
  972. }
  973. if (reply.contains("AI") || reply.contains("机器人") || reply.contains("智能客服")) tags.add("暴露AI");
  974. if (reply.contains("百分百") || reply.contains("包治") || (reply.contains("保证") && reply.contains("治愈"))) {
  975. tags.add("过度承诺");
  976. }
  977. return tags;
  978. }
  979. private static Map<String, Object> buildSummary(List<Item> all) {
  980. Map<String, Object> s = new LinkedHashMap<>();
  981. int total = all.size(), passed = 0, failed = 0, timeout = 0, error = 0, skip = 0;
  982. long totalMs = 0, maxMs = 0, minMs = Long.MAX_VALUE;
  983. Map<String, Integer> byStatus = new LinkedHashMap<>();
  984. Map<String, Integer> byModule = new LinkedHashMap<>();
  985. Map<String, Integer> byModulePass = new LinkedHashMap<>();
  986. Map<String, Integer> byTag = new LinkedHashMap<>();
  987. for (Item it : all) {
  988. totalMs += it.elapsedMs;
  989. if (it.elapsedMs > maxMs) maxMs = it.elapsedMs;
  990. if (it.elapsedMs < minMs) minMs = it.elapsedMs;
  991. byStatus.merge(it.status, 1, Integer::sum);
  992. if ("OK".equals(it.status) && it.passed) passed++;
  993. else if ("TIMEOUT".equals(it.status)) timeout++;
  994. else if ("SKIP".equals(it.status)) skip++;
  995. else error++;
  996. if (!it.passed && !"TIMEOUT".equals(it.status)) failed++;
  997. String mod = it.module == null ? "?" : it.module;
  998. byModule.merge(mod, 1, Integer::sum);
  999. if (it.passed) byModulePass.merge(mod, 1, Integer::sum);
  1000. for (String t : it.tags) byTag.merge(t, 1, Integer::sum);
  1001. }
  1002. s.put("totalCalls", total);
  1003. s.put("passed", passed);
  1004. s.put("failed", failed);
  1005. s.put("timeout", timeout);
  1006. s.put("skip", skip);
  1007. s.put("error", error);
  1008. s.put("avgMs", total == 0 ? 0 : totalMs / total);
  1009. s.put("maxMs", maxMs);
  1010. s.put("minMs", minMs == Long.MAX_VALUE ? 0 : minMs);
  1011. s.put("byStatus", byStatus);
  1012. s.put("byModule", byModule);
  1013. s.put("byModulePass", byModulePass);
  1014. s.put("byTag", byTag);
  1015. return s;
  1016. }
  1017. private static String stepBlock(List<Item> items) {
  1018. int passed = 0;
  1019. long ms = 0;
  1020. for (Item it : items) { if (it.passed) passed++; ms += it.elapsedMs; }
  1021. StringBuilder sb = new StringBuilder();
  1022. sb.append("{\"total\":").append(items.size()).append(",\"passed\":").append(passed)
  1023. .append(",\"totalMs\":").append(ms).append(",\"items\":[");
  1024. for (int i = 0; i < items.size(); i++) {
  1025. if (i > 0) sb.append(",");
  1026. sb.append(items.get(i).toJson());
  1027. }
  1028. sb.append("]}");
  1029. return sb.toString();
  1030. }
  1031. private static String summaryJson(Map<String, Object> s) {
  1032. StringBuilder sb = new StringBuilder("{");
  1033. int c = 0;
  1034. for (Map.Entry<String, Object> e : s.entrySet()) {
  1035. if (c++ > 0) sb.append(",");
  1036. sb.append(js(e.getKey())).append(":").append(jsv(e.getValue()));
  1037. }
  1038. sb.append("}");
  1039. return sb.toString();
  1040. }
  1041. // ════════════════════════════════════════════════════════════════
  1042. // JSON 序列化辅助
  1043. // ════════════════════════════════════════════════════════════════
  1044. private static String js(String s) {
  1045. if (s == null) return "null";
  1046. StringBuilder sb = new StringBuilder("\"");
  1047. for (int i = 0; i < s.length(); i++) {
  1048. char c = s.charAt(i);
  1049. switch (c) {
  1050. case '"': sb.append("\\\""); break;
  1051. case '\\': sb.append("\\\\"); break;
  1052. case '\n': sb.append("\\n"); break;
  1053. case '\r': sb.append("\\r"); break;
  1054. case '\t': sb.append("\\t"); break;
  1055. default:
  1056. if (c < 0x20) sb.append(String.format("\\u%04x", (int) c));
  1057. else sb.append(c);
  1058. }
  1059. }
  1060. sb.append("\"");
  1061. return sb.toString();
  1062. }
  1063. private static String jsArray(List<String> list) {
  1064. StringBuilder sb = new StringBuilder("[");
  1065. for (int i = 0; i < list.size(); i++) {
  1066. if (i > 0) sb.append(",");
  1067. sb.append(js(list.get(i)));
  1068. }
  1069. sb.append("]");
  1070. return sb.toString();
  1071. }
  1072. @SuppressWarnings("unchecked")
  1073. private static String jsv(Object v) {
  1074. if (v == null) return "null";
  1075. if (v instanceof Number) return v.toString();
  1076. if (v instanceof Boolean) return v.toString();
  1077. if (v instanceof String) return js((String) v);
  1078. if (v instanceof Map) {
  1079. StringBuilder sb = new StringBuilder("{");
  1080. int c = 0;
  1081. for (Map.Entry<String, Object> e : ((Map<String, Object>) v).entrySet()) {
  1082. if (c++ > 0) sb.append(",");
  1083. sb.append(js(e.getKey())).append(":").append(jsv(e.getValue()));
  1084. }
  1085. sb.append("}");
  1086. return sb.toString();
  1087. }
  1088. if (v instanceof Iterable) {
  1089. StringBuilder sb = new StringBuilder("[");
  1090. int c = 0;
  1091. for (Object o : (Iterable<?>) v) {
  1092. if (c++ > 0) sb.append(",");
  1093. sb.append(jsv(o));
  1094. }
  1095. sb.append("]");
  1096. return sb.toString();
  1097. }
  1098. return js(v.toString());
  1099. }
  1100. // ════════════════════════════════════════════════════════════════
  1101. // 通用辅助
  1102. // ════════════════════════════════════════════════════════════════
  1103. private static String trunc(String s, int max) {
  1104. if (s == null) return null;
  1105. return s.length() <= max ? s : s.substring(0, max) + "...";
  1106. }
  1107. private static boolean isTimeout(Throwable e) {
  1108. Throwable c = e;
  1109. while (c != null) {
  1110. if (c instanceof java.net.http.HttpTimeoutException) return true;
  1111. if (c instanceof TimeoutException) return true;
  1112. String n = c.getClass().getName();
  1113. if (n.contains("TimeoutException")) return true;
  1114. c = c.getCause();
  1115. }
  1116. return false;
  1117. }
  1118. private static boolean isTimeoutStr(String s) {
  1119. return s != null && (s.contains("timed out") || s.contains("TimeoutException")
  1120. || s.contains("HttpTimeoutException"));
  1121. }
  1122. private static String rootMsg(Throwable e) {
  1123. Throwable c = e;
  1124. while (c.getCause() != null && c.getCause() != c) c = c.getCause();
  1125. String msg = c.getMessage();
  1126. return (msg != null ? msg : c.getClass().getSimpleName()).replace("\n", " ");
  1127. }
  1128. }