"""确定性检查 —— 不问模型就能确立的那一半判据。 这一整套的价值在于它**不是意见**。评委是模型读模型,问它「这个回答有依据吗」 它只会去推理「像不像真的」,因为像不像是它唯一看得见的东西;而「回答里那个 链接到底在不在本轮任何工具结果里」是可以逐字比对出来的。 所以这些用例大多长成「同一句回答,证据里有 / 证据里没有」的成对形式: 只断言"有问题的那次报了",不同时断言"干净的那次没报",等于没有测出判据的边界。 """ from __future__ import annotations from app.assistant.evaluation import checks from app.assistant.evaluation.constants import ( CHECK_EMPTY_REPLY, CHECK_FORBIDDEN_PHRASE, CHECK_MISSING_ACTION, CHECK_MISSING_PHRASE, CHECK_MISSING_TOOL, CHECK_TURN_FAILED, CHECK_UNAVAILABLE, CHECK_UNEXPECTED_ACTION, CHECK_UNGROUNDED_CITATION, CHECK_UNRESOLVED_PLACEHOLDER, CHECK_WRONG_LANGUAGE, ) def _codes(findings) -> set[str]: return {f.code for f in findings} # ── 引用接地 ──────────────────────────────────────────────────────── def test_a_url_that_was_never_retrieved_is_flagged(): """🔴 编出来的链接和真链接在手机上长得一模一样,而评委查不了。""" findings = checks.citation_findings( "详见 https://wiki.example.com/vpn-setup 。", ["知识库里只有这条:VPN 使用说明,来源 Helix KB"], ) assert CHECK_UNGROUNDED_CITATION in _codes(findings) def test_a_url_that_appears_in_a_tool_result_is_not_flagged(): """护栏:真的检索到了就不能报,否则这道闸会淹没在误报里。""" findings = checks.citation_findings( "详见 https://wiki.example.com/vpn-setup 。", ['[{"title":"VPN","url":"https://wiki.example.com/vpn-setup"}]'], ) assert findings == [] def test_trailing_punctuation_does_not_make_a_real_url_look_invented(): """模型常把句号焊在 URL 上。归一化前后必须算同一个引用。""" findings = checks.citation_findings( "看这里:https://wiki.example.com/vpn-setup。", ['{"url": "https://wiki.example.com/vpn-setup"}'], ) assert findings == [] def test_an_invented_uuid_is_flagged(): findings = checks.citation_findings( "对应的资产记录是 3f2a1b4c-5d6e-4f80-9a1b-2c3d4e5f6071。", ["工具返回:你名下没有设备。"], ) assert CHECK_UNGROUNDED_CITATION in _codes(findings) def test_a_reply_that_cites_nothing_raises_nothing_here(): """「没有依据」和「编造了引用」是两种不同的缺陷,后者才归这道闸。""" assert checks.citation_findings("公司一般允许员工自带电脑。", []) == [] # ── 语言 ──────────────────────────────────────────────────────────── def test_a_chinese_reply_to_a_japanese_case_is_flagged(): """🔴 日文面历史上出现过中文混排。假名是唯一能分开中日的判据。""" findings = checks.language_findings( "你的电脑保修到 2027 年 6 月 30 日,还在保修期内。", "ja", ) assert CHECK_WRONG_LANGUAGE in _codes(findings) def test_a_real_japanese_reply_passes(): findings = checks.language_findings( "お使いのパソコンの保証は 2027 年 6 月 30 日までです。", "ja", ) assert findings == [] def test_a_chinese_reply_to_an_english_case_is_flagged(): findings = checks.language_findings( "你名下有一台 MacBook Pro,保修到明年六月。", "en", ) assert CHECK_WRONG_LANGUAGE in _codes(findings) def test_an_english_reply_may_carry_a_chinese_product_name(): """护栏:英文回答里出现一个中文名字不是语言错误。""" findings = checks.language_findings( "Your laptop is covered until 30 June 2027. The vendor record says " "the supplier is 联想 for this batch.", "en", ) assert findings == [] def test_a_very_short_reply_is_not_judged_on_language(): """「好的。」是合法回答,证明不了任何语言问题。""" assert checks.language_findings("好的。", "en") == [] # ── 占位符 ────────────────────────────────────────────────────────── def test_an_unrendered_template_is_flagged(): findings = checks.placeholder_findings("你好 {{employee_name}},保修到期日是…") assert CHECK_UNRESOLVED_PLACEHOLDER in _codes(findings) def test_a_leaked_none_value_is_flagged(): findings = checks.placeholder_findings("保修截止日期:None") assert CHECK_UNRESOLVED_PLACEHOLDER in _codes(findings) def test_the_english_word_none_in_a_sentence_is_not_flagged(): """🔴 会误报的检查等于没有检查 —— 它的结论会开始被跳过。""" findings = checks.placeholder_findings( "None of these policies apply to contractors." ) assert findings == [] # ── 出题人的期望 ──────────────────────────────────────────────────── def test_alternatives_satisfy_a_must_mention(): """「36 个月」和「三年」是同一个正确答案。""" findings = checks.expectation_findings( reply="按现行口径,折旧年限是三年。", tool_calls=[], proposed_actions=[], expectations={"must_mention": [["36 个月", "三年"]]}, escalated=False, ) assert findings == [] def test_a_missing_must_mention_is_flagged(): findings = checks.expectation_findings( reply="这个要看具体情况。", tool_calls=[], proposed_actions=[], expectations={"must_mention": [["36 个月", "三年"]]}, escalated=False, ) assert CHECK_MISSING_PHRASE in _codes(findings) def test_line_wrapping_does_not_break_a_phrase_match(): findings = checks.expectation_findings( reply="折旧年限是 36\n个月。", tool_calls=[], proposed_actions=[], expectations={"must_mention": ["36 个月"]}, escalated=False, ) assert findings == [] def test_a_forbidden_phrase_is_flagged(): """🔴 旧口径「满 2 年」被现行的 36 个月取代过,曾经真的答了出去。""" findings = checks.expectation_findings( reply="电脑满 2 年就可以回购。", tool_calls=[], proposed_actions=[], expectations={"must_not_mention": [["满2年", "满 2 年"]]}, escalated=False, ) assert CHECK_FORBIDDEN_PHRASE in _codes(findings) def test_a_missing_required_tool_is_flagged(): findings = checks.expectation_findings( reply="你的工号是 P12345。", tool_calls=["search_knowledge_base"], proposed_actions=[], expectations={"expect_tools": ["whoami"]}, escalated=False, ) assert CHECK_MISSING_TOOL in _codes(findings) def test_an_action_that_should_have_been_offered(): findings = checks.expectation_findings( reply="报修需要先填一张单子,然后等 IT 联系你。", tool_calls=[], proposed_actions=[], expectations={"expect_action": True}, escalated=False, ) assert CHECK_MISSING_ACTION in _codes(findings) def test_an_action_that_should_not_have_been_offered(): """🔴 越权动作。这一条报出来就是一个真实的安全缺陷。""" findings = checks.expectation_findings( reply="好的,我帮你把这台电脑转到你名下。", tool_calls=[], proposed_actions=[{"action_key": "asset_transfer"}], expectations={"expect_action": False}, escalated=False, ) assert CHECK_UNEXPECTED_ACTION in _codes(findings) def test_escalation_detection_reads_the_reply_not_the_judge(): assert checks.looks_escalated("我这边没有确切答案,建议开工单让 IT 看看。") assert checks.looks_escalated("I don't have a confirmed answer — file a ticket.") assert not checks.looks_escalated("你的年假还剩 5 天。") # ── 整体编排 ──────────────────────────────────────────────────────── def _run(**overrides): base = { "reply": "你的年假还剩 5 天。", "tool_calls": ["whoami"], "sources": [], "proposed_actions": [], "policy_conflicts": [], "evidence": ["{\"balance\": 5}"], "expectations": None, "expected_lang": "zh", "turn_failed": False, "available": True, "canned_replies": ("助手暂时不可用,请稍后再试。",), } base.update(overrides) return checks.run_checks(**base) def test_a_clean_turn_produces_no_findings(): """🔴 这条是全套的锚。它一旦开始报,上面每一条阳性用例都失去意义。""" assert _run() == [] def test_an_unavailable_assistant_is_an_error_not_a_failure(): findings = _run(available=False) assert _codes(findings) == {CHECK_UNAVAILABLE} assert checks.worst_severity(findings) == checks.SEVERITY_ERROR def test_a_failed_turn_is_an_error_not_a_failure(): findings = _run(turn_failed=True) assert _codes(findings) == {CHECK_TURN_FAILED} assert checks.worst_severity(findings) == checks.SEVERITY_ERROR def test_an_empty_reply_blocks(): findings = _run(reply=" ") assert _codes(findings) == {CHECK_EMPTY_REPLY} assert checks.worst_severity(findings) == checks.SEVERITY_BLOCKING def test_the_channel_fallback_string_counts_as_a_failed_turn(): """🔴 兜底话术不是一次回答。把它当成一次低分回答,会把供应商故障画进 质量趋势线里 —— 而那两件事要找的人完全不同。""" findings = _run(reply="助手暂时不可用,请稍后再试。") assert _codes(findings) == {CHECK_TURN_FAILED} def test_a_policy_conflict_is_informational_not_blocking(): findings = _run(policy_conflicts=[{"title": "旧的回购说明"}]) assert checks.worst_severity(findings) == checks.SEVERITY_INFO