bench50_v2.py 20.3 KB
import json
import time
import urllib.request

URL = "http://112.82.245.194:41434/v1/chat/completions"
MODEL = "qwen3.6-27b-iq3:latest"

# (utterance, fine-grained expectation for agent judge)
CASES = [
    ("有多少个客户?", "查询"),
    ("必胜客的销售员是谁?", "查询"),
    ("列出所有供应商", "查询"),
    ("查一下这个月的送货单", "查询"),
    ("库存数量最多的前5个物料是什么?", "查询"),
    ("上个月销售额最高的客户是谁?", "查询"),
    ("报价纸盒", "新增"),
    ("给苏州华为报价彩盒1000个", "新增"),
    ("纸盒5000个多少钱?", "新增"),
    ("帮我新建一个客户,名字叫上海创远包装", "新增"),
    ("把必胜客的电话改成13800138000", "修改"),
    ("修改客户地址", "修改"),
    ("删除报价单BJD202607001", "删除"),
    ("作废昨天那张送货单", "删除"),
    ("审核一下报价单BJD202607082", "审核"),
    ("你好", "闲聊"),
    ("谢谢,辛苦了", "闲聊"),
    ("报价", "不清楚"),
    ("纸盒", "不清楚"),
    ("查询报价单有哪些", "查询"),
    ("上海创远包装的地址是什么?", "查询"),
    ("这个月一共开了几张销售订单?", "查询"),
    ("查一下BJD202607001这张报价单", "查询"),
    ("铜版纸的库存还有多少?", "查询"),
    ("有哪些客户是上海的?", "查询"),
    ("最近有没有新的采购订单?", "查询"),
    ("必胜客上个月下了几单?", "查询"),
    ("看看今天的生产任务", "查询"),
    ("应收账款总共多少钱?", "查询"),
    ("供应商里有没有叫金田纸业的?", "查询"),
    ("画册1000本什么价格?", "新增"),
    ("给必胜客做5000个披萨盒报个价", "新增"),
    ("新建一个供应商:杭州新新材料", "新增"),
    ("帮我录一张报价单", "新增"),
    ("添加物料:300g白卡纸", "新增"),
    ("小册子500本多少钱", "新增"),
    ("来一单纸箱报价,规格60*40*30", "新增"),
    ("把上海创远包装的地址改成浦东新区川沙路500号", "修改"),
    ("必胜客的销售员换成王五", "修改"),
    ("供应商金田纸业的电话改一下,改成0571-88881234", "修改"),
    ("帮我改一下那张报价单", "修改"),
    ("把BJD202607001的数量改成8000", "修改"),
    ("把BJD202607001这张报价单作废", "删除"),
    ("那张给必胜客的报价单不要了,作废掉", "删除"),
    ("删掉刚才建错的那个客户", "删除"),
    ("把BJD202607082审核通过", "审核"),
    ("那张报价单审核错了,帮我反审核", "审核"),
    ("送货单DH202607005需要审核一下", "审核"),
    ("你是谁?你能做什么?", "闲聊"),
    ("送货单", "不清楚"),
]

# fine label -> 4-class gate expectation
GATE_MAP = {"查询": "查询", "新增": "新增",
            "修改": "操作已有单据", "删除": "操作已有单据", "审核": "操作已有单据",
            "闲聊": "其他", "不清楚": "其他"}

READ_TOOLS = {"findForms", "readFormData", "lookupRecord"}

# ---------------- 意图门(生产新版逐字复刻:IntentService.SYSTEM) ----------------
INTENT_SYSTEM = (
    "你是印刷/包装 ERP 的意图与实体抽取器。把一句用户话分类为四种意图之一,并抽取句中实体及其业务角色。\n"
    "意图定义:\n"
    "- 查询:查看、统计或查找系统里**已有**的数据/单据。\n"
    "- 新增:要创建一张新单据或一条新记录。行业常识:对定制产品问价格(多少钱/什么价/报个价)"
    "就是要**新建报价单**——价格由系统核价算出,没有现成价;只有给了单号或明确说查已有报价才算查询。\n"
    "- 操作已有单据:对系统里已存在的单据/记录做修改、作废/删除、审核/反审核、复原。"
    "只要动作属于这些,即使还没说具体哪条记录也算本类(缺的信息之后澄清)。\n"
    "- 其他:闲聊、问候、与 ERP 业务无关,或只说名词、没有任何动作、无法判断想做什么。\n"
    "实体角色:客户=购买方的公司/单位名;产品=要生产/加工/报价的物品(如纸盒、彩盒、画册);其余按字面。\n"
    "danju=该意图针对的单据或实体类型:新增时=要新建的单据类型(如问价即 报价),"
    "查询/操作时=要查或要操作的单据/实体(如 客户/销售订单);确实没有才留空。"
    "missing=完成该意图还缺的关键信息。只输出 JSON,不要解释。"
)

INTENT_SCHEMA = {
    "type": "object",
    "properties": {
        "intent": {"type": "string",
                   "enum": ["查询", "新增", "操作已有单据", "其他"]},
        "danju": {"type": "string"},
        "entities": {"type": "array", "items": {
            "type": "object",
            "properties": {
                "value": {"type": "string"},
                "role": {"type": "string",
                         "enum": ["客户", "产品", "物料", "供应商", "数量", "尺寸", "日期", "金额", "其他", "未知"]},
            },
            "required": ["value", "role"],
        }},
        "missing": {"type": "array", "items": {"type": "string"}},
    },
    "required": ["intent", "danju", "entities"],
}

# ---------------- 统一 agent prompt(生产新版逐字复刻:SystemPromptService.render) ----------------
DOMAIN_MAP = """- 生产(生产智能体,585 张单据)  上游←[其他,品质,基础资料,报价,销售]  下游→[其他,发外,品质,库存,报价,采购,销售]
- 销售(销售智能体,325 张单据)  上游←[其他,库存,报价,生产]  下游→[其他,发外,品质,库存,报价,生产]
- 基础资料(ERP代理人,234 张单据)  下游→[报价,生产]
- 其他(ERP代理人,134 张单据)  上游←[库存,生产,采购,销售]  下游→[发外,品质,生产,财务,采购,销售]
- 库存(库存智能体,134 张单据)  上游←[品质,生产,财务,采购,销售]  下游→[其他,发外,品质,销售]
- 报价(报价智能体,99 张单据)  上游←[基础资料,生产,销售]  下游→[生产,销售]
- 品质(品质智能体,63 张单据)  上游←[其他,发外,库存,生产,财务,采购,销售]  下游→[发外,库存,生产,采购]
- 采购(采购智能体,59 张单据)  上游←[其他,品质,生产]  下游→[其他,品质,库存,财务]
- 发外(发外智能体,51 张单据)  上游←[其他,品质,库存,生产,销售]  下游→[品质]
- 财务(财务智能体,51 张单据)  上游←[其他,采购]  下游→[品质,库存]
- 设备(ERP代理人,13 张单据)
"""

HEADER = ("【硬性要求】必须始终用**简体中文**回答;严禁输出英文/泰文等非中文。**只给最终答复**,"
          "不要复述你在调用哪个工具、不要输出思考过程或过程性旁白。\n\n"
          "你是「小羚羊」,小羚羊印刷 ERP 的智能助手,服务印刷/包装行业的企业用户,帮他们查询和操作 ERP 业务单据。\n")

UNIFIED_PROMPT = HEADER + f"""
【业务域地图(先据此判断问题属于哪个域、涉及哪些单据)】
{DOMAIN_MAP}
【业务常识】
- 对定制产品问价格(多少钱/什么价/报个价)= 要**新建一张报价单**:价格由系统核价算出,系统里没有现成价。只有给了单号、或明确说查已有报价/统计报价额,才是查询。
- 业务单据的「删除/取消」= **作废**(proposeWrite action=invalid,可复原),不要物理删除。

【可用工具(只有这些)】
- findForms(keyword):按关键词把「某类单据/报表」定位到具体表单,拿到 formId 与 moduleId。
- readFormData(formId, moduleId, keyword?, page?):读某表单的真实数据(每页若干行+总条数)。问数量/概况时 keyword 留空;找某个名称的记录才填 keyword;用户要看下一页时 page 递增。
- lookupRecord(entityKeyword, recordKeyword):查某实体下某条命名记录的完整信息或某个字段(如某客户的电话/销售员)。问「某记录的某字段」优先用它。
- collectForm(entityKeyword, knownFieldsJson?):新增字段较多的单据(尤其**报价**)时,弹一张表单让用户一次填齐;把用户已说的信息作为 knownFieldsJson 预填。**新增一律先用它**,用户提交后你再用 proposeWrite(action=create)。
- proposeWrite(action, entityKeyword, recordKeyword?, fieldChinese?, newValue?, fieldsJson?):**唯一的写工具**(人在环:只生成待确认提议,用户点【确认】才执行)。action:create=新增(fieldsJson);update=改字段(recordKeyword+fieldChinese+newValue);invalid=作废;cancelInvalid=复原;examine=审核;cancelExamine=销审;delete=物理删除(业务单据别用)。它自行定位主表与记录,无需先 findForms。
- askUser(question, options?):缺关键信息时向用户提**一个**澄清问题(尽量给选项)。

【准则】
1. 查询:findForms 定位 → readFormData/lookupRecord 读 → 如实汇报。同名多张表单优先选检索结果靠前那张。**绝不编造**表单名、单号或数据——答案里的每个数字都必须来自工具结果。
2. 写操作一律人在环:只生成待确认提议、**绝不声称已完成/已写入**。新增先 collectForm;proposeWrite 生成提议后就停下、提示用户点【确认】,不要继续调别的工具。
3. 实体角色不能错:要报价/加工的物品(纸盒/彩盒/画册)是**产品**,购买方公司名才是客户。客户/产品/物料必须是系统里已有的真实记录——找不到就让用户从下拉里选,**绝不新建不存在的客户**。
4. 信息足够就直接做,不要无谓反问;缺关键参数才 askUser 问**一次**并停下等回答,不要反复追问。回答面向业务人员:用记录名称而不是内部 ID。
"""


def fn(name, desc, props, required):
    return {"type": "function", "function": {
        "name": name, "description": desc,
        "parameters": {"type": "object", "properties": props, "required": required}}}


T_FINDFORMS = fn("findForms",
    "按关键词检索 ERP 业务表单目录,返回匹配的表单名、底层数据源、所属菜单id(moduleId) 与表单id(formId)。"
    "当用户提到某类单据或报表、但你不确定具体是哪一张表单时,先用它来定位。最多返回 15 条。",
    {"keyword": {"type": "string", "description": "表单名或业务关键词,例如:报价 / 客户 / 库存 / 送货 / 应收 / 采购订单"}},
    ["keyword"])
T_READFORM = fn("readFormData",
    "读取指定 ERP 表单的真实业务数据(返回前若干行 + 总条数)。可选 keyword 用于按名称模糊过滤"
    "(如查某个客户 / 物料);可选 page 翻页(从 1 开始,用户要看「下一页/更多」时递增)。"
    "必须先用 findForms 得到目标表单的 formId 与 moduleId。",
    {"formId": {"type": "string", "description": "表单id(findForms 返回的 formId)"},
     "moduleId": {"type": "string", "description": "菜单id(findForms 返回的 moduleId)"},
     "keyword": {"type": "string", "description": "可选:仅当要查找某个具体名称的记录时才填;问数量 / 全部 / 概况时必须留空"},
     "page": {"type": "integer", "description": "可选:页码,从 1 开始;默认 1"}},
    ["formId", "moduleId"])
T_LOOKUP = fn("lookupRecord",
    "查询某个实体下某条命名记录的**完整信息**(返回该记录的所有可读字段)。"
    "用于「某客户 / 某物料 的详细资料 / 某个具体字段(如联系电话、销售员)」这类精确查询。",
    {"entityKeyword": {"type": "string", "description": "实体类型,如 客户 / 物料 / 供应商"},
     "recordKeyword": {"type": "string", "description": "记录名称关键词,如某个客户名"}},
    ["entityKeyword", "recordKeyword"])
T_COLLECT = fn("collectForm",
    "在对话里弹出一张 ERP 表单让用户一次性填齐多个字段(而不是逐个追问、更不是自己猜)。用于字段较多的新建/录入场景"
    "(尤其**新建报价**)。entityKeyword = 单据类型(如 报价 / 客户);knownFieldsJson = 用户已说的字段(中文名->值)用于预填。"
    "客户/产品/物料会渲染成下拉让用户从真实数据里选。用户填完提交后,你再用 proposeWrite(action=create) 生成待确认的新增。",
    {"entityKeyword": {"type": "string", "description": "要新建的实体/单据类型,如 报价 / 客户 / 物料"},
     "knownFieldsJson": {"type": "string", "description": "可选:用户已说的字段 JSON(中文名->值),用于预填表单"}},
    ["entityKeyword"])
T_PROPOSE = fn("proposeWrite",
    "提议一次写操作(人在环:只生成待确认提议、绝不立即执行;用户点【确认】才写入,你绝不能声称已完成)。"
    "用 action 指定动作:create=新增;update=改某字段;invalid=作废(业务单据要“删除/取消”一律用它,可复原);"
    "cancelInvalid=复原/取消作废;examine=审核;cancelExamine=销审/反审核;delete=物理删除(明细行/极少用,业务单据别用)。"
    "按 action 传参:create 用 fieldsJson;update 用 recordKeyword+fieldChinese+newValue;"
    "invalid/cancelInvalid/examine/cancelExamine/delete 用 recordKeyword 定位单据。本工具自行定位主表,无需先 findForms。",
    {"action": {"type": "string", "description": "动作:create|update|invalid|cancelInvalid|examine|cancelExamine|delete"},
     "entityKeyword": {"type": "string", "description": "实体/单据类型,如 报价 / 客户 / 销售订单"},
     "recordKeyword": {"type": "string", "description": "记录名称或单号关键词(create 不需要)"},
     "fieldChinese": {"type": "string", "description": "要改的字段中文名(仅 update)"},
     "newValue": {"type": "string", "description": "新值(仅 update)"},
     "fieldsJson": {"type": "string", "description": "字段 JSON 中文名->值(仅 create)"}},
    ["action", "entityKeyword"])
T_ASK = fn("askUser",
    "向用户提出一个澄清/消歧的小问题并给出可选项。当用户意图不明确、或需要在几个候选中二选一时使用;"
    "问完即结束本轮、等待用户回答。options 用中文顿号或逗号分隔,可留空表示自由回答。",
    {"question": {"type": "string", "description": "要问用户的问题"},
     "options": {"type": "string", "description": "候选选项,用、或,分隔;没有明确候选时留空"}},
    ["question"])

ALL6 = [T_FINDFORMS, T_READFORM, T_LOOKUP, T_COLLECT, T_PROPOSE, T_ASK]


def call(body, timeout=200):
    req = urllib.request.Request(
        URL, data=json.dumps(body).encode(),
        headers={"Content-Type": "application/json", "Authorization": "Bearer ollama"})
    t0 = time.time()
    with urllib.request.urlopen(req, timeout=timeout) as r:
        out = json.loads(r.read())
    return out, time.time() - t0


def has_digits(s):
    return any(c.isdigit() for c in s)


def judge(exp, tool, args, text):
    """返回 'ok' | 'clarify' | 'fail'。clarify=用文字/askUser 反问(宽松口径算对)。"""
    action = (args or {}).get("action", "")
    asked = tool == "askUser" or (tool is None and text and ("?" in text or "?" in text))
    if exp == "查询":
        if tool in READ_TOOLS:
            return "ok"
        if tool is None and text and not has_digits(text):
            return "ok"
        return "fail"
    if exp == "新增":
        if tool == "collectForm" or (tool == "proposeWrite" and action == "create"):
            return "ok"
        return "clarify" if asked else "fail"
    if exp == "修改":
        if (tool == "proposeWrite" and action == "update") or tool in ("askUser", "lookupRecord"):
            return "ok"
        return "clarify" if asked else "fail"
    if exp == "删除":
        if tool == "proposeWrite" and action in ("invalid", "delete", "cancelInvalid"):
            return "ok"
        return "clarify" if asked else "fail"
    if exp == "审核":
        if tool == "proposeWrite" and action in ("examine", "cancelExamine"):
            return "ok"
        return "clarify" if asked else "fail"
    if exp == "闲聊":
        return "ok" if tool is None else "fail"
    if exp == "不清楚":
        if tool == "askUser" or (tool is None and text):
            return "ok"
        return "fail"
    return "fail"


def run_gate():
    print("=" * 20, "意图门 4 类(50题)", "=" * 20)
    ok = 0
    wrong = []
    for utt, fine in CASES:
        exp = GATE_MAP[fine]
        body = {"model": MODEL, "stream": False, "temperature": 0.1, "top_p": 0.9,
                "reasoning_effort": "none",
                "response_format": {"type": "json_schema",
                                    "json_schema": {"name": "output", "schema": INTENT_SCHEMA}},
                "messages": [{"role": "system", "content": INTENT_SYSTEM},
                             {"role": "user", "content": utt}]}
        try:
            resp, dt = call(body)
            j = json.loads(resp["choices"][0]["message"]["content"])
            got = j.get("intent", "?")
            hit = got == exp
            ok += hit
            if not hit:
                wrong.append((utt, exp, got, j.get("danju", "")))
            print(f'{"✓" if hit else "✗"} [{exp}] {utt}  →  {got}/{j.get("danju","")}  ({dt:.1f}s)')
        except Exception as ex:
            wrong.append((utt, exp, "ERROR", str(ex)))
            print(f"✗ [{exp}] {utt}  →  ERROR {ex}")
    print(f"\n意图门: {ok}/{len(CASES)} = {ok/len(CASES)*100:.0f}%")
    for utt, exp, got, d in wrong:
        print(f"  错例: 「{utt}」 期望{exp} → 得到{got}({d})")
    print()
    return ok


def run_agent():
    print("=" * 20, "统一prompt+固定6工具(50题)", "=" * 20)
    strict = 0
    lenient = 0
    per = {}
    fails = []
    for utt, exp in CASES:
        body = {"model": MODEL, "stream": False, "temperature": 0.1, "top_p": 0.9,
                "reasoning_effort": "none", "tools": ALL6,
                "messages": [{"role": "system", "content": UNIFIED_PROMPT},
                             {"role": "user", "content": utt}]}
        try:
            resp, dt = call(body)
            msg = resp["choices"][0]["message"]
            tcs = msg.get("tool_calls") or []
            tool, args = None, None
            if tcs:
                tool = tcs[0]["function"]["name"]
                try:
                    args = json.loads(tcs[0]["function"].get("arguments") or "{}")
                except Exception:
                    args = {}
            text = (msg.get("content") or "").strip()
            v = judge(exp, tool, args, text)
        except Exception as ex:
            v, tool, args, text, dt = "fail", None, None, f"ERROR {ex}", 0
        strict += v == "ok"
        lenient += v in ("ok", "clarify")
        s, l, n = per.get(exp, (0, 0, 0))
        per[exp] = (s + (v == "ok"), l + (v in ("ok", "clarify")), n + 1)
        mark = {"ok": "✓", "clarify": "◐", "fail": "✗"}[v]
        detail = f"tool={tool or '文字'}" + (f" {(args or {}).get('action','')}" if tool == "proposeWrite" else "")
        if v == "fail" and tool is None:
            detail += " 「" + text[:50].replace("\n", " ") + "」"
        if v == "fail":
            fails.append((utt, exp, detail))
        print(f"{mark} [{exp}] {utt}  →  {detail}  ({dt:.1f}s)")
    n = len(CASES)
    print(f"\n统一6工具: 严格 {strict}/{n} = {strict/n*100:.0f}%   宽松 {lenient}/{n} = {lenient/n*100:.0f}%")
    print("按意图分(严格/宽松/总数):", {k: f"{s}/{l}/{c}" for k, (s, l, c) in per.items()})
    for utt, exp, d in fails:
        print(f"  失败: 「{utt}」[{exp}] → {d}")
    print()
    return strict, lenient


import sys

if __name__ == "__main__":
    n = len(CASES)
    if "--gate-only" in sys.argv:
        g = run_gate()
        print(f"验收: 意图门100%={'PASS' if g == n else 'FAIL'}")
    else:
        g = run_gate()
        a_s, a_l = run_agent()
        print("=" * 60)
        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}%")
        print(f"验收: 意图门100%={'PASS' if g == n else 'FAIL'}  agent严格≥76%={'PASS' if a_s/n >= 0.76 else 'FAIL'}")