Commit 8bd01fbd2f0a3f6528c65de591d3a3cef9789e13
1 parent
56c9ae67
bench: freeze bench50_v2 baseline; add trajectory bench (multi-turn/disambiguati…
…on/form-continuation); old-arch baseline 9/12
Showing
3 changed files
with
745 additions
and
0 deletions
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() |