Commit 8bd01fbd2f0a3f6528c65de591d3a3cef9789e13

Authored by zichun
1 parent 56c9ae67

bench: freeze bench50_v2 baseline; add trajectory bench (multi-turn/disambiguati…

…on/form-continuation); old-arch baseline 9/12
bench/README.md 0 → 100644
  1 +# 基准(bench)
  2 +
  3 +模型:`qwen3.6-27b-iq3:latest`,Ollama /v1(地址见脚本头部)。运行一律 `uv run --no-project <脚本>`。
  4 +注意:该 Ollama 服务负载下会间歇 502——dt≈0s 的单条失败先当基础设施抖动、单独重跑核实,再考虑改 prompt。
  5 +
  6 +## bench50_v2.py(冻结基线,勿改)
  7 +
  8 +单发话语 → 首步工具选择。50 题。逐字复刻 2026-07-28 生产 prompt(4 类意图门 + 统一 prompt + 6 工具)。
  9 +验收存档 `bench50_v2_baseline.out`:意图门 50/50=100%,agent 严格 38/50=76%、宽松 45/50=90%。
  10 +
  11 +skill-ReAct 落地后:意图门段作废;agent 段对新架构重跑,严格分 ≥76% 才算过闸。
  12 +
  13 +## bench_ext.py(扩展轨迹基准)
  14 +
  15 +多步 ReAct 轨迹模拟(喂回固定工具结果直到模型给最终文字,上限 8 步)+ 多轮对话用例:
  16 +
  17 +| 类别 | 用例 | 测什么 |
  18 +|---|---|---|
  19 +| 消歧 | S1-S5 | 改「审核人」字段=update 不是 examine;撤审=cancelExamine;恢复作废=cancelInvalid |
  20 +| 多轮接续 | M1/M2/M6 | 翻页、代词回指、澄清后接续 |
  21 +| 夹带 | M3 | 回答澄清的同时夹带新查询,两个都要完成 |
  22 +| 长会话回指 | M4 | 30 轮后回指最早提到的实体 |
  23 +| 跨轮表单 | M5 | 弹表单隔 2 轮补字段;「大16开」必须逐字保留 |
  24 +| 提议纪律 | M7 | 提议卡后不重复提议、不声称已执行 |
  25 +
  26 +```
  27 +uv run --no-project bench_ext.py --arch old # 现产线复刻(基线)
  28 +uv run --no-project bench_ext.py --arch new # skill-ReAct(从 src/main/resources/prompts/ 读)
  29 +uv run --no-project bench_ext.py --arch old --only M5
  30 +```
  31 +
  32 +老架构基线存档:`bench_ext_old_baseline.out`。
  33 +
  34 +流式输出中途点【确认】的并发问题由 Java 单测覆盖(事件日志并发 append 不丢事件),不在本目录。
bench/bench50_v2.py 0 → 100644
  1 +import json
  2 +import time
  3 +import urllib.request
  4 +
  5 +URL = "http://112.82.245.194:41434/v1/chat/completions"
  6 +MODEL = "qwen3.6-27b-iq3:latest"
  7 +
  8 +# (utterance, fine-grained expectation for agent judge)
  9 +CASES = [
  10 + ("有多少个客户?", "查询"),
  11 + ("必胜客的销售员是谁?", "查询"),
  12 + ("列出所有供应商", "查询"),
  13 + ("查一下这个月的送货单", "查询"),
  14 + ("库存数量最多的前5个物料是什么?", "查询"),
  15 + ("上个月销售额最高的客户是谁?", "查询"),
  16 + ("报价纸盒", "新增"),
  17 + ("给苏州华为报价彩盒1000个", "新增"),
  18 + ("纸盒5000个多少钱?", "新增"),
  19 + ("帮我新建一个客户,名字叫上海创远包装", "新增"),
  20 + ("把必胜客的电话改成13800138000", "修改"),
  21 + ("修改客户地址", "修改"),
  22 + ("删除报价单BJD202607001", "删除"),
  23 + ("作废昨天那张送货单", "删除"),
  24 + ("审核一下报价单BJD202607082", "审核"),
  25 + ("你好", "闲聊"),
  26 + ("谢谢,辛苦了", "闲聊"),
  27 + ("报价", "不清楚"),
  28 + ("纸盒", "不清楚"),
  29 + ("查询报价单有哪些", "查询"),
  30 + ("上海创远包装的地址是什么?", "查询"),
  31 + ("这个月一共开了几张销售订单?", "查询"),
  32 + ("查一下BJD202607001这张报价单", "查询"),
  33 + ("铜版纸的库存还有多少?", "查询"),
  34 + ("有哪些客户是上海的?", "查询"),
  35 + ("最近有没有新的采购订单?", "查询"),
  36 + ("必胜客上个月下了几单?", "查询"),
  37 + ("看看今天的生产任务", "查询"),
  38 + ("应收账款总共多少钱?", "查询"),
  39 + ("供应商里有没有叫金田纸业的?", "查询"),
  40 + ("画册1000本什么价格?", "新增"),
  41 + ("给必胜客做5000个披萨盒报个价", "新增"),
  42 + ("新建一个供应商:杭州新新材料", "新增"),
  43 + ("帮我录一张报价单", "新增"),
  44 + ("添加物料:300g白卡纸", "新增"),
  45 + ("小册子500本多少钱", "新增"),
  46 + ("来一单纸箱报价,规格60*40*30", "新增"),
  47 + ("把上海创远包装的地址改成浦东新区川沙路500号", "修改"),
  48 + ("必胜客的销售员换成王五", "修改"),
  49 + ("供应商金田纸业的电话改一下,改成0571-88881234", "修改"),
  50 + ("帮我改一下那张报价单", "修改"),
  51 + ("把BJD202607001的数量改成8000", "修改"),
  52 + ("把BJD202607001这张报价单作废", "删除"),
  53 + ("那张给必胜客的报价单不要了,作废掉", "删除"),
  54 + ("删掉刚才建错的那个客户", "删除"),
  55 + ("把BJD202607082审核通过", "审核"),
  56 + ("那张报价单审核错了,帮我反审核", "审核"),
  57 + ("送货单DH202607005需要审核一下", "审核"),
  58 + ("你是谁?你能做什么?", "闲聊"),
  59 + ("送货单", "不清楚"),
  60 +]
  61 +
  62 +# fine label -> 4-class gate expectation
  63 +GATE_MAP = {"查询": "查询", "新增": "新增",
  64 + "修改": "操作已有单据", "删除": "操作已有单据", "审核": "操作已有单据",
  65 + "闲聊": "其他", "不清楚": "其他"}
  66 +
  67 +READ_TOOLS = {"findForms", "readFormData", "lookupRecord"}
  68 +
  69 +# ---------------- 意图门(生产新版逐字复刻:IntentService.SYSTEM) ----------------
  70 +INTENT_SYSTEM = (
  71 + "你是印刷/包装 ERP 的意图与实体抽取器。把一句用户话分类为四种意图之一,并抽取句中实体及其业务角色。\n"
  72 + "意图定义:\n"
  73 + "- 查询:查看、统计或查找系统里**已有**的数据/单据。\n"
  74 + "- 新增:要创建一张新单据或一条新记录。行业常识:对定制产品问价格(多少钱/什么价/报个价)"
  75 + "就是要**新建报价单**——价格由系统核价算出,没有现成价;只有给了单号或明确说查已有报价才算查询。\n"
  76 + "- 操作已有单据:对系统里已存在的单据/记录做修改、作废/删除、审核/反审核、复原。"
  77 + "只要动作属于这些,即使还没说具体哪条记录也算本类(缺的信息之后澄清)。\n"
  78 + "- 其他:闲聊、问候、与 ERP 业务无关,或只说名词、没有任何动作、无法判断想做什么。\n"
  79 + "实体角色:客户=购买方的公司/单位名;产品=要生产/加工/报价的物品(如纸盒、彩盒、画册);其余按字面。\n"
  80 + "danju=该意图针对的单据或实体类型:新增时=要新建的单据类型(如问价即 报价),"
  81 + "查询/操作时=要查或要操作的单据/实体(如 客户/销售订单);确实没有才留空。"
  82 + "missing=完成该意图还缺的关键信息。只输出 JSON,不要解释。"
  83 +)
  84 +
  85 +INTENT_SCHEMA = {
  86 + "type": "object",
  87 + "properties": {
  88 + "intent": {"type": "string",
  89 + "enum": ["查询", "新增", "操作已有单据", "其他"]},
  90 + "danju": {"type": "string"},
  91 + "entities": {"type": "array", "items": {
  92 + "type": "object",
  93 + "properties": {
  94 + "value": {"type": "string"},
  95 + "role": {"type": "string",
  96 + "enum": ["客户", "产品", "物料", "供应商", "数量", "尺寸", "日期", "金额", "其他", "未知"]},
  97 + },
  98 + "required": ["value", "role"],
  99 + }},
  100 + "missing": {"type": "array", "items": {"type": "string"}},
  101 + },
  102 + "required": ["intent", "danju", "entities"],
  103 +}
  104 +
  105 +# ---------------- 统一 agent prompt(生产新版逐字复刻:SystemPromptService.render) ----------------
  106 +DOMAIN_MAP = """- 生产(生产智能体,585 张单据) 上游←[其他,品质,基础资料,报价,销售] 下游→[其他,发外,品质,库存,报价,采购,销售]
  107 +- 销售(销售智能体,325 张单据) 上游←[其他,库存,报价,生产] 下游→[其他,发外,品质,库存,报价,生产]
  108 +- 基础资料(ERP代理人,234 张单据) 下游→[报价,生产]
  109 +- 其他(ERP代理人,134 张单据) 上游←[库存,生产,采购,销售] 下游→[发外,品质,生产,财务,采购,销售]
  110 +- 库存(库存智能体,134 张单据) 上游←[品质,生产,财务,采购,销售] 下游→[其他,发外,品质,销售]
  111 +- 报价(报价智能体,99 张单据) 上游←[基础资料,生产,销售] 下游→[生产,销售]
  112 +- 品质(品质智能体,63 张单据) 上游←[其他,发外,库存,生产,财务,采购,销售] 下游→[发外,库存,生产,采购]
  113 +- 采购(采购智能体,59 张单据) 上游←[其他,品质,生产] 下游→[其他,品质,库存,财务]
  114 +- 发外(发外智能体,51 张单据) 上游←[其他,品质,库存,生产,销售] 下游→[品质]
  115 +- 财务(财务智能体,51 张单据) 上游←[其他,采购] 下游→[品质,库存]
  116 +- 设备(ERP代理人,13 张单据)
  117 +"""
  118 +
  119 +HEADER = ("【硬性要求】必须始终用**简体中文**回答;严禁输出英文/泰文等非中文。**只给最终答复**,"
  120 + "不要复述你在调用哪个工具、不要输出思考过程或过程性旁白。\n\n"
  121 + "你是「小羚羊」,小羚羊印刷 ERP 的智能助手,服务印刷/包装行业的企业用户,帮他们查询和操作 ERP 业务单据。\n")
  122 +
  123 +UNIFIED_PROMPT = HEADER + f"""
  124 +【业务域地图(先据此判断问题属于哪个域、涉及哪些单据)】
  125 +{DOMAIN_MAP}
  126 +【业务常识】
  127 +- 对定制产品问价格(多少钱/什么价/报个价)= 要**新建一张报价单**:价格由系统核价算出,系统里没有现成价。只有给了单号、或明确说查已有报价/统计报价额,才是查询。
  128 +- 业务单据的「删除/取消」= **作废**(proposeWrite action=invalid,可复原),不要物理删除。
  129 +
  130 +【可用工具(只有这些)】
  131 +- findForms(keyword):按关键词把「某类单据/报表」定位到具体表单,拿到 formId 与 moduleId。
  132 +- readFormData(formId, moduleId, keyword?, page?):读某表单的真实数据(每页若干行+总条数)。问数量/概况时 keyword 留空;找某个名称的记录才填 keyword;用户要看下一页时 page 递增。
  133 +- lookupRecord(entityKeyword, recordKeyword):查某实体下某条命名记录的完整信息或某个字段(如某客户的电话/销售员)。问「某记录的某字段」优先用它。
  134 +- collectForm(entityKeyword, knownFieldsJson?):新增字段较多的单据(尤其**报价**)时,弹一张表单让用户一次填齐;把用户已说的信息作为 knownFieldsJson 预填。**新增一律先用它**,用户提交后你再用 proposeWrite(action=create)。
  135 +- proposeWrite(action, entityKeyword, recordKeyword?, fieldChinese?, newValue?, fieldsJson?):**唯一的写工具**(人在环:只生成待确认提议,用户点【确认】才执行)。action:create=新增(fieldsJson);update=改字段(recordKeyword+fieldChinese+newValue);invalid=作废;cancelInvalid=复原;examine=审核;cancelExamine=销审;delete=物理删除(业务单据别用)。它自行定位主表与记录,无需先 findForms。
  136 +- askUser(question, options?):缺关键信息时向用户提**一个**澄清问题(尽量给选项)。
  137 +
  138 +【准则】
  139 +1. 查询:findForms 定位 → readFormData/lookupRecord 读 → 如实汇报。同名多张表单优先选检索结果靠前那张。**绝不编造**表单名、单号或数据——答案里的每个数字都必须来自工具结果。
  140 +2. 写操作一律人在环:只生成待确认提议、**绝不声称已完成/已写入**。新增先 collectForm;proposeWrite 生成提议后就停下、提示用户点【确认】,不要继续调别的工具。
  141 +3. 实体角色不能错:要报价/加工的物品(纸盒/彩盒/画册)是**产品**,购买方公司名才是客户。客户/产品/物料必须是系统里已有的真实记录——找不到就让用户从下拉里选,**绝不新建不存在的客户**。
  142 +4. 信息足够就直接做,不要无谓反问;缺关键参数才 askUser 问**一次**并停下等回答,不要反复追问。回答面向业务人员:用记录名称而不是内部 ID。
  143 +"""
  144 +
  145 +
  146 +def fn(name, desc, props, required):
  147 + return {"type": "function", "function": {
  148 + "name": name, "description": desc,
  149 + "parameters": {"type": "object", "properties": props, "required": required}}}
  150 +
  151 +
  152 +T_FINDFORMS = fn("findForms",
  153 + "按关键词检索 ERP 业务表单目录,返回匹配的表单名、底层数据源、所属菜单id(moduleId) 与表单id(formId)。"
  154 + "当用户提到某类单据或报表、但你不确定具体是哪一张表单时,先用它来定位。最多返回 15 条。",
  155 + {"keyword": {"type": "string", "description": "表单名或业务关键词,例如:报价 / 客户 / 库存 / 送货 / 应收 / 采购订单"}},
  156 + ["keyword"])
  157 +T_READFORM = fn("readFormData",
  158 + "读取指定 ERP 表单的真实业务数据(返回前若干行 + 总条数)。可选 keyword 用于按名称模糊过滤"
  159 + "(如查某个客户 / 物料);可选 page 翻页(从 1 开始,用户要看「下一页/更多」时递增)。"
  160 + "必须先用 findForms 得到目标表单的 formId 与 moduleId。",
  161 + {"formId": {"type": "string", "description": "表单id(findForms 返回的 formId)"},
  162 + "moduleId": {"type": "string", "description": "菜单id(findForms 返回的 moduleId)"},
  163 + "keyword": {"type": "string", "description": "可选:仅当要查找某个具体名称的记录时才填;问数量 / 全部 / 概况时必须留空"},
  164 + "page": {"type": "integer", "description": "可选:页码,从 1 开始;默认 1"}},
  165 + ["formId", "moduleId"])
  166 +T_LOOKUP = fn("lookupRecord",
  167 + "查询某个实体下某条命名记录的**完整信息**(返回该记录的所有可读字段)。"
  168 + "用于「某客户 / 某物料 的详细资料 / 某个具体字段(如联系电话、销售员)」这类精确查询。",
  169 + {"entityKeyword": {"type": "string", "description": "实体类型,如 客户 / 物料 / 供应商"},
  170 + "recordKeyword": {"type": "string", "description": "记录名称关键词,如某个客户名"}},
  171 + ["entityKeyword", "recordKeyword"])
  172 +T_COLLECT = fn("collectForm",
  173 + "在对话里弹出一张 ERP 表单让用户一次性填齐多个字段(而不是逐个追问、更不是自己猜)。用于字段较多的新建/录入场景"
  174 + "(尤其**新建报价**)。entityKeyword = 单据类型(如 报价 / 客户);knownFieldsJson = 用户已说的字段(中文名->值)用于预填。"
  175 + "客户/产品/物料会渲染成下拉让用户从真实数据里选。用户填完提交后,你再用 proposeWrite(action=create) 生成待确认的新增。",
  176 + {"entityKeyword": {"type": "string", "description": "要新建的实体/单据类型,如 报价 / 客户 / 物料"},
  177 + "knownFieldsJson": {"type": "string", "description": "可选:用户已说的字段 JSON(中文名->值),用于预填表单"}},
  178 + ["entityKeyword"])
  179 +T_PROPOSE = fn("proposeWrite",
  180 + "提议一次写操作(人在环:只生成待确认提议、绝不立即执行;用户点【确认】才写入,你绝不能声称已完成)。"
  181 + "用 action 指定动作:create=新增;update=改某字段;invalid=作废(业务单据要“删除/取消”一律用它,可复原);"
  182 + "cancelInvalid=复原/取消作废;examine=审核;cancelExamine=销审/反审核;delete=物理删除(明细行/极少用,业务单据别用)。"
  183 + "按 action 传参:create 用 fieldsJson;update 用 recordKeyword+fieldChinese+newValue;"
  184 + "invalid/cancelInvalid/examine/cancelExamine/delete 用 recordKeyword 定位单据。本工具自行定位主表,无需先 findForms。",
  185 + {"action": {"type": "string", "description": "动作:create|update|invalid|cancelInvalid|examine|cancelExamine|delete"},
  186 + "entityKeyword": {"type": "string", "description": "实体/单据类型,如 报价 / 客户 / 销售订单"},
  187 + "recordKeyword": {"type": "string", "description": "记录名称或单号关键词(create 不需要)"},
  188 + "fieldChinese": {"type": "string", "description": "要改的字段中文名(仅 update)"},
  189 + "newValue": {"type": "string", "description": "新值(仅 update)"},
  190 + "fieldsJson": {"type": "string", "description": "字段 JSON 中文名->值(仅 create)"}},
  191 + ["action", "entityKeyword"])
  192 +T_ASK = fn("askUser",
  193 + "向用户提出一个澄清/消歧的小问题并给出可选项。当用户意图不明确、或需要在几个候选中二选一时使用;"
  194 + "问完即结束本轮、等待用户回答。options 用中文顿号或逗号分隔,可留空表示自由回答。",
  195 + {"question": {"type": "string", "description": "要问用户的问题"},
  196 + "options": {"type": "string", "description": "候选选项,用、或,分隔;没有明确候选时留空"}},
  197 + ["question"])
  198 +
  199 +ALL6 = [T_FINDFORMS, T_READFORM, T_LOOKUP, T_COLLECT, T_PROPOSE, T_ASK]
  200 +
  201 +
  202 +def call(body, timeout=200):
  203 + req = urllib.request.Request(
  204 + URL, data=json.dumps(body).encode(),
  205 + headers={"Content-Type": "application/json", "Authorization": "Bearer ollama"})
  206 + t0 = time.time()
  207 + with urllib.request.urlopen(req, timeout=timeout) as r:
  208 + out = json.loads(r.read())
  209 + return out, time.time() - t0
  210 +
  211 +
  212 +def has_digits(s):
  213 + return any(c.isdigit() for c in s)
  214 +
  215 +
  216 +def judge(exp, tool, args, text):
  217 + """返回 'ok' | 'clarify' | 'fail'。clarify=用文字/askUser 反问(宽松口径算对)。"""
  218 + action = (args or {}).get("action", "")
  219 + asked = tool == "askUser" or (tool is None and text and ("?" in text or "?" in text))
  220 + if exp == "查询":
  221 + if tool in READ_TOOLS:
  222 + return "ok"
  223 + if tool is None and text and not has_digits(text):
  224 + return "ok"
  225 + return "fail"
  226 + if exp == "新增":
  227 + if tool == "collectForm" or (tool == "proposeWrite" and action == "create"):
  228 + return "ok"
  229 + return "clarify" if asked else "fail"
  230 + if exp == "修改":
  231 + if (tool == "proposeWrite" and action == "update") or tool in ("askUser", "lookupRecord"):
  232 + return "ok"
  233 + return "clarify" if asked else "fail"
  234 + if exp == "删除":
  235 + if tool == "proposeWrite" and action in ("invalid", "delete", "cancelInvalid"):
  236 + return "ok"
  237 + return "clarify" if asked else "fail"
  238 + if exp == "审核":
  239 + if tool == "proposeWrite" and action in ("examine", "cancelExamine"):
  240 + return "ok"
  241 + return "clarify" if asked else "fail"
  242 + if exp == "闲聊":
  243 + return "ok" if tool is None else "fail"
  244 + if exp == "不清楚":
  245 + if tool == "askUser" or (tool is None and text):
  246 + return "ok"
  247 + return "fail"
  248 + return "fail"
  249 +
  250 +
  251 +def run_gate():
  252 + print("=" * 20, "意图门 4 类(50题)", "=" * 20)
  253 + ok = 0
  254 + wrong = []
  255 + for utt, fine in CASES:
  256 + exp = GATE_MAP[fine]
  257 + body = {"model": MODEL, "stream": False, "temperature": 0.1, "top_p": 0.9,
  258 + "reasoning_effort": "none",
  259 + "response_format": {"type": "json_schema",
  260 + "json_schema": {"name": "output", "schema": INTENT_SCHEMA}},
  261 + "messages": [{"role": "system", "content": INTENT_SYSTEM},
  262 + {"role": "user", "content": utt}]}
  263 + try:
  264 + resp, dt = call(body)
  265 + j = json.loads(resp["choices"][0]["message"]["content"])
  266 + got = j.get("intent", "?")
  267 + hit = got == exp
  268 + ok += hit
  269 + if not hit:
  270 + wrong.append((utt, exp, got, j.get("danju", "")))
  271 + print(f'{"✓" if hit else "✗"} [{exp}] {utt} → {got}/{j.get("danju","")} ({dt:.1f}s)')
  272 + except Exception as ex:
  273 + wrong.append((utt, exp, "ERROR", str(ex)))
  274 + print(f"✗ [{exp}] {utt} → ERROR {ex}")
  275 + print(f"\n意图门: {ok}/{len(CASES)} = {ok/len(CASES)*100:.0f}%")
  276 + for utt, exp, got, d in wrong:
  277 + print(f" 错例: 「{utt}」 期望{exp} → 得到{got}({d})")
  278 + print()
  279 + return ok
  280 +
  281 +
  282 +def run_agent():
  283 + print("=" * 20, "统一prompt+固定6工具(50题)", "=" * 20)
  284 + strict = 0
  285 + lenient = 0
  286 + per = {}
  287 + fails = []
  288 + for utt, exp in CASES:
  289 + body = {"model": MODEL, "stream": False, "temperature": 0.1, "top_p": 0.9,
  290 + "reasoning_effort": "none", "tools": ALL6,
  291 + "messages": [{"role": "system", "content": UNIFIED_PROMPT},
  292 + {"role": "user", "content": utt}]}
  293 + try:
  294 + resp, dt = call(body)
  295 + msg = resp["choices"][0]["message"]
  296 + tcs = msg.get("tool_calls") or []
  297 + tool, args = None, None
  298 + if tcs:
  299 + tool = tcs[0]["function"]["name"]
  300 + try:
  301 + args = json.loads(tcs[0]["function"].get("arguments") or "{}")
  302 + except Exception:
  303 + args = {}
  304 + text = (msg.get("content") or "").strip()
  305 + v = judge(exp, tool, args, text)
  306 + except Exception as ex:
  307 + v, tool, args, text, dt = "fail", None, None, f"ERROR {ex}", 0
  308 + strict += v == "ok"
  309 + lenient += v in ("ok", "clarify")
  310 + s, l, n = per.get(exp, (0, 0, 0))
  311 + per[exp] = (s + (v == "ok"), l + (v in ("ok", "clarify")), n + 1)
  312 + mark = {"ok": "✓", "clarify": "◐", "fail": "✗"}[v]
  313 + detail = f"tool={tool or '文字'}" + (f" {(args or {}).get('action','')}" if tool == "proposeWrite" else "")
  314 + if v == "fail" and tool is None:
  315 + detail += " 「" + text[:50].replace("\n", " ") + "」"
  316 + if v == "fail":
  317 + fails.append((utt, exp, detail))
  318 + print(f"{mark} [{exp}] {utt} → {detail} ({dt:.1f}s)")
  319 + n = len(CASES)
  320 + print(f"\n统一6工具: 严格 {strict}/{n} = {strict/n*100:.0f}% 宽松 {lenient}/{n} = {lenient/n*100:.0f}%")
  321 + print("按意图分(严格/宽松/总数):", {k: f"{s}/{l}/{c}" for k, (s, l, c) in per.items()})
  322 + for utt, exp, d in fails:
  323 + print(f" 失败: 「{utt}」[{exp}] → {d}")
  324 + print()
  325 + return strict, lenient
  326 +
  327 +
  328 +import sys
  329 +
  330 +if __name__ == "__main__":
  331 + n = len(CASES)
  332 + if "--gate-only" in sys.argv:
  333 + g = run_gate()
  334 + print(f"验收: 意图门100%={'PASS' if g == n else 'FAIL'}")
  335 + else:
  336 + g = run_gate()
  337 + a_s, a_l = run_agent()
  338 + print("=" * 60)
  339 + print(f"总结(50题): 意图门 {g}/{n}={g/n*100:.0f}% | agent 严格{a_s}/{n}={a_s/n*100:.0f}% 宽松{a_l}/{n}={a_l/n*100:.0f}%")
  340 + print(f"验收: 意图门100%={'PASS' if g == n else 'FAIL'} agent严格≥76%={'PASS' if a_s/n >= 0.76 else 'FAIL'}")
bench/bench_ext.py 0 → 100644
  1 +"""扩展基准:多步轨迹 + 多轮对话用例。
  2 +
  3 +bench50_v2.py 只测「单发话语 → 首个工具选择」;本脚本补它测不到的:
  4 +- 多步 ReAct 轨迹(喂回固定工具结果,跑到模型给出最终文字为止)
  5 +- 多轮接续(翻页、代词回指、澄清后接续)
  6 +- 回答夹带新需求
  7 +- 「改审核人」类消歧(改带"审核"字样的字段 ≠ 审核操作)
  8 +- 长会话回指(30 轮后引用最早实体)
  9 +- 跨轮流程接续(弹表单后隔轮补字段,值须逐字保留)
  10 +- 提议卡后的状态纪律(不重复提议、不声称已执行)
  11 +
  12 +用法:
  13 + uv run --no-project bench_ext.py --arch old # 现产线:统一 prompt + 6 工具
  14 + uv run --no-project bench_ext.py --arch new # skill-ReAct(Phase 2 落地后从仓库资源读 prompt/skill)
  15 + uv run --no-project bench_ext.py --arch old --only M5
  16 +"""
  17 +
  18 +import json
  19 +import sys
  20 +import time
  21 +import urllib.request
  22 +
  23 +URL = "http://112.82.245.194:41434/v1/chat/completions"
  24 +MODEL = "qwen3.6-27b-iq3:latest"
  25 +MAX_STEPS = 8
  26 +
  27 +# ---------------- 与 bench50_v2 相同的生产复刻 prompt / 工具定义 ----------------
  28 +from bench50_v2 import UNIFIED_PROMPT, ALL6 # noqa: E402
  29 +
  30 +
  31 +def build_context(arch):
  32 + """返回 (system_prompt, tools)。new 架构落地后从仓库资源文件读取,避免复刻漂移。"""
  33 + if arch == "old":
  34 + return UNIFIED_PROMPT, ALL6
  35 + if arch == "new":
  36 + import pathlib
  37 + root = pathlib.Path(__file__).resolve().parent.parent
  38 + sp = (root / "src/main/resources/prompts/system.txt").read_text(encoding="utf-8")
  39 + tools_json = (root / "src/main/resources/prompts/tools-bench.json").read_text(encoding="utf-8")
  40 + return sp, json.loads(tools_json)
  41 + raise SystemExit(f"未知 arch: {arch}")
  42 +
  43 +
  44 +# ---------------- 固定工具结果(fixtures) ----------------
  45 +CUSTOMERS = {
  46 + "必胜客": {"客户名称": "必胜客", "联系电话": "13912345678", "地址": "上海市静安区南京西路1266号", "销售员": "王五"},
  47 + "上海创远包装": {"客户名称": "上海创远包装", "联系电话": "021-58991234", "地址": "浦东新区川沙路500号", "销售员": "李四"},
  48 + "苏州华为": {"客户名称": "苏州华为", "联系电话": "0512-67771234", "地址": "苏州工业园区", "销售员": "赵六"},
  49 + "杭州大华印务": {"客户名称": "杭州大华印务", "联系电话": "0571-87654321", "地址": "杭州市余杭区", "销售员": "钱七"},
  50 +}
  51 +
  52 +FORMS = {
  53 + "报价": [{"formName": "报价单", "formId": "FQ01", "moduleId": "M-BJ", "source": "quoquotationmaster"}],
  54 + "客户": [{"formName": "客户资料", "formId": "FC01", "moduleId": "M-JC", "source": "custmaster"}],
  55 + "送货": [{"formName": "送货单", "formId": "FD01", "moduleId": "M-XS", "source": "deliverymaster"}],
  56 + "销售": [{"formName": "销售订单", "formId": "FS01", "moduleId": "M-XS", "source": "salesorder"}],
  57 +}
  58 +
  59 +READFORM_PAGES = {
  60 + ("FC01", 1): "共 6 条记录,第 1 页:必胜客、上海创远包装、苏州华为、杭州大华印务",
  61 + ("FC01", 2): "共 6 条记录,第 2 页:宁波天海包装、无锡礼盒厂",
  62 + ("FD01", 1): "共 2 条记录,第 1 页:DH202607005(客户 必胜客)、DH202607008(客户 苏州华为)",
  63 +}
  64 +
  65 +
  66 +def fixture_result(name, args):
  67 + args = args or {}
  68 + if name == "findForms":
  69 + kw = args.get("keyword", "")
  70 + for k, forms in FORMS.items():
  71 + if k in kw:
  72 + return json.dumps(forms, ensure_ascii=False)
  73 + return json.dumps([{"formName": kw + "列表", "formId": "FX99", "moduleId": "M-QT", "source": "misc"}],
  74 + ensure_ascii=False)
  75 + if name == "readFormData":
  76 + page = int(args.get("page") or 1)
  77 + key = (args.get("formId", ""), page)
  78 + if key in READFORM_PAGES:
  79 + return READFORM_PAGES[key]
  80 + kw = args.get("keyword") or ""
  81 + for cname, rec in CUSTOMERS.items():
  82 + if cname in kw:
  83 + return f"共 1 条记录:{json.dumps(rec, ensure_ascii=False)}"
  84 + return f"共 0 条记录(formId={args.get('formId','')} page={page} keyword={kw})"
  85 + if name == "lookupRecord":
  86 + rk = args.get("recordKeyword", "")
  87 + for cname, rec in CUSTOMERS.items():
  88 + if cname in rk or rk in cname:
  89 + return json.dumps(rec, ensure_ascii=False)
  90 + return f"未找到「{rk}」对应的记录"
  91 + if name == "collectForm":
  92 + return (f"[已弹出「{args.get('entityKeyword','')}」表单,预填={args.get('knownFieldsJson') or '{}'}。"
  93 + "等待用户在表单里补齐并点提交,本轮到此为止。]")
  94 + if name == "proposeWrite":
  95 + return (f"[已生成待确认提议 OP-TEST-1:action={args.get('action','')},实体={args.get('entityKeyword','')},"
  96 + f"记录={args.get('recordKeyword','')}。仅提议,未执行;请用户点【确认】。]")
  97 + if name == "askUser":
  98 + return "[问题已发给用户,等待回答,本轮到此为止。]"
  99 + if name == "useSkill":
  100 + return "[skill 文本已载入上下文]"
  101 + return "OK"
  102 +
  103 +
  104 +# ---------------- 轨迹模拟器 ----------------
  105 +def call(body, timeout=200):
  106 + req = urllib.request.Request(
  107 + URL, data=json.dumps(body).encode(),
  108 + headers={"Content-Type": "application/json", "Authorization": "Bearer ollama"})
  109 + with urllib.request.urlopen(req, timeout=timeout) as r:
  110 + return json.loads(r.read())
  111 +
  112 +
  113 +def run_trajectory(system_prompt, tools, history, utterance):
  114 + """history: [(role, text)] 已渲染的往轮。返回 (calls, final_text, steps, err)。"""
  115 + messages = [{"role": "system", "content": system_prompt}]
  116 + for role, text in history:
  117 + messages.append({"role": role, "content": text})
  118 + messages.append({"role": "user", "content": utterance})
  119 + calls = []
  120 + for step in range(MAX_STEPS):
  121 + body = {"model": MODEL, "stream": False, "temperature": 0.1, "top_p": 0.9,
  122 + "reasoning_effort": "none", "tools": tools, "messages": messages}
  123 + try:
  124 + resp = call(body)
  125 + except Exception as ex:
  126 + return calls, None, step, str(ex)
  127 + msg = resp["choices"][0]["message"]
  128 + tcs = msg.get("tool_calls") or []
  129 + if not tcs:
  130 + return calls, (msg.get("content") or "").strip(), step + 1, None
  131 + messages.append({"role": "assistant", "content": msg.get("content") or "",
  132 + "tool_calls": tcs})
  133 + for tc in tcs:
  134 + name = tc["function"]["name"]
  135 + try:
  136 + args = json.loads(tc["function"].get("arguments") or "{}")
  137 + except Exception:
  138 + args = {}
  139 + calls.append((name, args))
  140 + messages.append({"role": "tool", "tool_call_id": tc.get("id", "tc"),
  141 + "content": fixture_result(name, args)})
  142 + return calls, None, MAX_STEPS, "step-cap"
  143 +
  144 +
  145 +# ---------------- 判分辅助 ----------------
  146 +def argstr(args):
  147 + return json.dumps(args or {}, ensure_ascii=False)
  148 +
  149 +
  150 +def find(calls, name, pred=None):
  151 + for n, a in calls:
  152 + if n == name and (pred is None or pred(a)):
  153 + return a
  154 + return None
  155 +
  156 +
  157 +def any_read(calls, needle=None):
  158 + for n, a in calls:
  159 + if n in ("findForms", "readFormData", "lookupRecord"):
  160 + if needle is None or needle in argstr(a):
  161 + return a
  162 + return None
  163 +
  164 +
  165 +def asked(calls, final_text):
  166 + return find(calls, "askUser") is not None or (final_text and ("?" in final_text or "?" in final_text))
  167 +
  168 +
  169 +# ---------------- 用例 ----------------
  170 +# 每条: id, cat, history, utter, judge(calls, text) -> ("PASS"|"PARTIAL"|"FAIL", reason)
  171 +
  172 +def j_shenheren_update(target_val):
  173 + def judge(calls, text):
  174 + p = find(calls, "proposeWrite")
  175 + if p and p.get("action") == "update":
  176 + if target_val in argstr(p):
  177 + return "PASS", ""
  178 + return "PARTIAL", f"update 但参数缺 {target_val}"
  179 + if p and p.get("action") in ("examine", "cancelExamine"):
  180 + return "FAIL", f"误判为审核操作 action={p.get('action')}"
  181 + if asked(calls, text):
  182 + return "PARTIAL", "反问(信息已足够)"
  183 + return "FAIL", "未产生 update 提议"
  184 + return judge
  185 +
  186 +
  187 +def mk(id_, cat, history, utter, judge, desc):
  188 + return {"id": id_, "cat": cat, "history": history, "utter": utter, "judge": judge, "desc": desc}
  189 +
  190 +
  191 +def long_history():
  192 + pairs = [
  193 + ("查一下杭州大华印务的资料", "杭州大华印务:印刷类客户,地址杭州市余杭区,销售员钱七。"),
  194 + ("必胜客上个月下了几单?", "必胜客上个月共 3 张销售订单。"),
  195 + ("铜版纸库存还有多少?", "铜版纸当前库存 12,000 张。"),
  196 + ("上海创远包装的地址?", "浦东新区川沙路500号。"),
  197 + ("这个月开了几张送货单?", "本月共 18 张送货单。"),
  198 + ("苏州华为的销售员是谁?", "赵六。"),
  199 + ("白卡纸多少库存?", "300g 白卡纸库存 8,500 张。"),
  200 + ("最近有新采购订单吗?", "最近一周有 2 张采购订单。"),
  201 + ("应收账款总额?", "当前应收账款合计 156 万元。"),
  202 + ("宁波天海包装是我们客户吗?", "是,客户资料里有宁波天海包装。"),
  203 + ("今天的生产任务?", "今日 4 个生产任务,均在排程中。"),
  204 + ("金田纸业的电话?", "0571-88881234。"),
  205 + ("上季度销售额最高的客户?", "必胜客,合计 89 万元。"),
  206 + ("画册类产品最近报价几张?", "最近一个月 5 张画册报价单。"),
  207 + ("无锡礼盒厂有欠款吗?", "无锡礼盒厂当前无逾期欠款。"),
  208 + ]
  209 + h = []
  210 + for q, a in pairs:
  211 + h.append(("user", q))
  212 + h.append(("assistant", a))
  213 + return h
  214 +
  215 +
  216 +def j_m4(calls, text):
  217 + if any_read(calls, "杭州大华印务"):
  218 + return "PASS", ""
  219 + if "钱七" in (text or ""):
  220 + return "PASS", "从历史正确回忆(未重查)"
  221 + if asked(calls, text):
  222 + return "PARTIAL", "反问是哪个客户(历史里可查)"
  223 + return "FAIL", "既没读 杭州大华印务 也没答对销售员"
  224 +
  225 +
  226 +def j_m5(calls, text):
  227 + c = find(calls, "collectForm")
  228 + if c:
  229 + s = argstr(c)
  230 + if "大16开" in s and "5000" in s:
  231 + return "PASS", ""
  232 + if "16开" in s and "大16开" not in s:
  233 + return "FAIL", "尺寸值被改写(大16开→16开),违反逐字照抄"
  234 + return "PARTIAL", f"collectForm 但预填不全: {s[:80]}"
  235 + p = find(calls, "proposeWrite", lambda a: a.get("action") == "create")
  236 + if p and "大16开" in argstr(p):
  237 + return "PASS", "直接 create 且值逐字"
  238 + return "FAIL", "未回到报价表单流程"
  239 +
  240 +
  241 +def j_m7(calls, text):
  242 + if find(calls, "proposeWrite"):
  243 + return "FAIL", "重复生成提议"
  244 + done_words = [w for w in ("已修改", "已完成", "修改成功") if w in (text or "")]
  245 + if done_words:
  246 + return "FAIL", f"声称已执行: {done_words}"
  247 + if any_read(calls, "必胜客"):
  248 + return "PASS", ""
  249 + return "FAIL", "未查询必胜客地址"
  250 +
  251 +
  252 +def j_m3(calls, text):
  253 + upd = find(calls, "proposeWrite",
  254 + lambda a: a.get("action") == "update" and "13800138000" in argstr(a) and "必胜客" in argstr(a))
  255 + qry = any_read(calls, "上海")
  256 + if upd and qry:
  257 + return "PASS", ""
  258 + if upd:
  259 + return "PARTIAL", "只完成修改提议,丢了夹带的查询"
  260 + if qry:
  261 + return "PARTIAL", "只做了查询,丢了修改"
  262 + return "FAIL", "两个请求都没完成"
  263 +
  264 +
  265 +CASES = [
  266 + # --- 「审核」字样消歧:改带审核字样的字段是 update,不是 examine ---
  267 + mk("S1", "消歧", [], "把BJD202607001的审核人改成张三",
  268 + j_shenheren_update("张三"), "改审核人=update"),
  269 + mk("S2", "消歧", [], "报价单BJD202607082的审核人换成李四",
  270 + j_shenheren_update("李四"), "换审核人=update"),
  271 + mk("S3", "消歧", [],
  272 + "查一下BJD202607001是谁审核的",
  273 + lambda calls, text: ("PASS", "") if any_read(calls) or (text and not any(ch.isdigit() for ch in text))
  274 + else ("FAIL", "既没读也没答"), "查审核人=查询"),
  275 + mk("S4", "消歧", [], "BJD202607001不用审了,帮我撤回来",
  276 + lambda calls, text:
  277 + ("PASS", "") if find(calls, "proposeWrite", lambda a: a.get("action") == "cancelExamine")
  278 + else (("PARTIAL", "反问") if asked(calls, text) else ("FAIL", "未识别为销审")),
  279 + "撤回审核=cancelExamine"),
  280 + # 上下文无候选、记录指代不清 → 反问哪张与直接 cancelInvalid 提议同为正确行为
  281 + mk("S5", "消歧", [], "上次作废的那张送货单帮我恢复一下",
  282 + lambda calls, text:
  283 + ("PASS", "") if find(calls, "proposeWrite", lambda a: a.get("action") == "cancelInvalid")
  284 + or asked(calls, text) else ("FAIL", "未识别为复原"),
  285 + "恢复作废=cancelInvalid或问哪张"),
  286 + # --- 多轮接续 ---
  287 + mk("M1", "多轮接续",
  288 + [("user", "查一下客户列表"),
  289 + ("assistant", "共 6 个客户,第 1 页:必胜客、上海创远包装、苏州华为、杭州大华印务。(表单:客户资料 formId=FC01 moduleId=M-JC)")],
  290 + "下一页",
  291 + lambda calls, text:
  292 + ("PASS", "") if find(calls, "readFormData", lambda a: int(a.get("page") or 1) == 2)
  293 + else ("FAIL", "没有翻到第 2 页"), "翻页接续"),
  294 + mk("M2", "多轮接续",
  295 + [("user", "查一下必胜客的资料"),
  296 + ("assistant", "必胜客:地址 上海市静安区南京西路1266号,销售员 王五。")],
  297 + "他的电话是多少",
  298 + lambda calls, text:
  299 + (("PASS", "") if "13912345678" in (text or "") else ("PARTIAL", "读了但答案未含固定电话"))
  300 + if any_read(calls, "必胜客") else ("FAIL", "未解析代词「他」=必胜客"),
  301 + "代词回指"),
  302 + mk("M6", "多轮接续",
  303 + [("user", "作废那张送货单"),
  304 + ("assistant", "请问是哪一张送货单?候选:DH202607005(必胜客)、DH202607008(苏州华为)")],
  305 + "第一张",
  306 + lambda calls, text:
  307 + ("PASS", "") if find(calls, "proposeWrite",
  308 + lambda a: a.get("action") in ("invalid",) and "DH202607005" in argstr(a))
  309 + else (("PARTIAL", "再次反问") if asked(calls, text) else ("FAIL", "未把「第一张」解析为 DH202607005")),
  310 + "澄清后接续"),
  311 + # --- 夹带新需求 ---
  312 + mk("M3", "夹带", [("user", "把必胜客的电话改一下"), ("assistant", "请问要改成什么号码?")],
  313 + "13800138000,另外查下上海有哪些客户", j_m3, "回答+新查询"),
  314 + # --- 长会话回指 ---
  315 + mk("M4", "长会话回指", long_history(), "最早问的那个客户,帮我查下它的销售员", j_m4, "30 轮后回指最早实体"),
  316 + # --- 跨轮表单接续 ---
  317 + mk("M5", "跨轮表单",
  318 + [("user", "报价纸盒"),
  319 + ("assistant", "[已为您弹出「报价」表单,请填写客户、产品、数量、尺寸等字段后提交。]"),
  320 + ("user", "先查下必胜客的电话"),
  321 + ("assistant", "必胜客电话:13912345678。")],
  322 + "报价那个,尺寸大16开,数量5000", j_m5, "隔轮补字段,值逐字"),
  323 + # --- 提议卡后的状态纪律 ---
  324 + mk("M7", "提议纪律",
  325 + [("user", "把必胜客电话改成13800138000"),
  326 + ("assistant", "[已生成待确认提议 OP123:修改 客户「必胜客」联系电话 → 13800138000],请点击【确认】执行。")],
  327 + "顺便查下它的地址", j_m7, "提议后查询:不重提、不谎称已执行"),
  328 +]
  329 +
  330 +
  331 +def main():
  332 + arch = "old"
  333 + only = None
  334 + argv = sys.argv[1:]
  335 + if "--arch" in argv:
  336 + arch = argv[argv.index("--arch") + 1]
  337 + if "--only" in argv:
  338 + only = argv[argv.index("--only") + 1]
  339 + system_prompt, tools = build_context(arch)
  340 + cases = [c for c in CASES if only is None or c["id"] == only]
  341 + print(f"arch={arch} model={MODEL} cases={len(cases)}")
  342 + results = []
  343 + for c in cases:
  344 + t0 = time.time()
  345 + calls, text, steps, err = run_trajectory(system_prompt, tools, c["history"], c["utter"])
  346 + dt = time.time() - t0
  347 + if err:
  348 + verdict, reason = "FAIL", f"ERROR {err}"
  349 + else:
  350 + verdict, reason = c["judge"](calls, text)
  351 + results.append((c, verdict, reason))
  352 + mark = {"PASS": "✓", "PARTIAL": "◐", "FAIL": "✗"}[verdict]
  353 + traj = " → ".join(n for n, _ in calls) or "文字"
  354 + print(f'{mark} [{c["cat"]}] {c["id"]} {c["desc"]}: {traj}'
  355 + f'{" | " + reason if reason else ""} ({steps}步 {dt:.0f}s)')
  356 + if verdict != "PASS" and text:
  357 + print(f' 最终答复: {text[:100]}')
  358 + n = len(results)
  359 + strict = sum(v == "PASS" for _, v, _ in results)
  360 + lenient = sum(v in ("PASS", "PARTIAL") for _, v, _ in results)
  361 + by_cat = {}
  362 + for c, v, _ in results:
  363 + s, l, t = by_cat.get(c["cat"], (0, 0, 0))
  364 + by_cat[c["cat"]] = (s + (v == "PASS"), l + (v != "FAIL"), t + 1)
  365 + print(f"\n[{arch}] 严格 {strict}/{n} 宽松 {lenient}/{n}")
  366 + for cat, (s, l, t) in by_cat.items():
  367 + print(f" {cat}: 严格 {s}/{t} 宽松 {l}/{t}")
  368 +
  369 +
  370 +if __name__ == "__main__":
  371 + main()