assistant/tests/test_nlu.py

251 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""意图识别:JSON 解析、相对日期、关键词退路、以及模型不可用时 classify_and_extract 仍然给出可用结果。"""
from __future__ import annotations
import json
from datetime import date, timedelta
import pytest
from backend import nlu
class TestParseJson:
def test_plain_object(self):
assert nlu._parse_json('{"business_id": "a", "confidence": 0.9}') == {"business_id": "a", "confidence": 0.9}
def test_fenced_block(self):
assert nlu._parse_json('```json\n{"business_id": "a"}\n```') == {"business_id": "a"}
def test_object_embedded_in_prose(self):
# 模型偶尔会在 JSON 前后带一句话,取第一个到最后一个花括号之间的内容
assert nlu._parse_json('好的,结果如下:\n{"business_id": "a"}\n以上。') == {"business_id": "a"}
@pytest.mark.parametrize("text", ["", " ", "根本没有 JSON", "{不是合法 JSON}", "[1, 2, 3]"])
def test_unusable_returns_none(self, text):
assert nlu._parse_json(text) is None
def test_top_level_array_rejected(self):
# 只接受对象;数组不是调用方期望的形状
assert nlu._parse_json('[{"business_id": "a"}]') is None
class TestRelativeDate:
@pytest.mark.parametrize("word,offset", [("今天", 0), ("今日", 0), ("明天", 1), ("后天", 2), ("昨天", -1), ("前天", -2)])
def test_relative_words(self, word, offset):
assert nlu._resolve_relative_date(f"帮我填{word}的查课日志") == (date.today() + timedelta(days=offset)).isoformat()
@pytest.mark.parametrize("text,expected", [
("听课时间 2026-09-17", "2026-09-17"),
("听课时间 2026/9/7", "2026-09-07"),
("听课时间 2026年9月7日", "2026-09-07"),
])
def test_absolute_dates_normalised(self, text, expected):
assert nlu._resolve_relative_date(text) == expected
def test_month_day_uses_current_year(self):
assert nlu._resolve_relative_date("9月7日那节课") == f"{date.today().year}-09-07"
def test_no_date_returns_none(self):
assert nlu._resolve_relative_date("帮我看看有什么能办的") is None
class TestPhraseScore:
"""退路打分用最长公共子串,不能要求整段短语原样出现。"""
def test_partial_overlap_counts(self):
# 知识里写「每月查课日志」,用户说「帮我填今天的查课日志」:整段不匹配,公共子串「查课日志」4 个字
assert nlu._phrase_score("每月查课日志", "帮我填今天的查课日志") == 4
def test_single_character_overlap_ignored(self):
assert nlu._phrase_score("辅导员填写谈心谈话记录", "帮我填查课日志") == 0
def test_too_short_phrase_ignored(self):
assert nlu._phrase_score("的", "帮我填今天的查课日志") == 0
def test_no_overlap(self):
assert nlu._phrase_score("请假销假", "今天天气怎么样") == 0
class TestKeywordFallback:
@pytest.mark.parametrize("message", [
"帮我填查课日志",
"帮我填今天的查课日志,班级计科2301班",
"我要填每月查课要求的日志",
"听课记录还好还好",
])
def test_recognises_business_from_varied_phrasings(self, knowledge, message):
# 回归:早先按整段短语匹配,这几句全都落到 generic_explore,模型不可用时等于没有保底
assert nlu._fallback(message, knowledge["businesses"], None)["business_id"] == "counselor_worklog"
def test_matched_business_gets_higher_confidence(self, knowledge):
assert nlu._fallback("帮我填查课日志", knowledge["businesses"], None)["confidence"] == 0.4
def test_unmatched_message_falls_back_to_generic_explore(self, knowledge):
result = nlu._fallback("今天天气怎么样", knowledge["businesses"], None)
assert result["business_id"] == "generic_explore"
assert result["confidence"] == 0.2
def test_generic_explore_does_not_outscore_a_specific_business(self, knowledge):
# generic_explore 的 intent 里有「查找某个入口」「有哪些字段」,不排除它就会抢走具体业务
assert nlu._fallback("帮我查一下课的日志入口", knowledge["businesses"], None)["business_id"] == "counselor_worklog"
def test_fixed_business_id_wins_over_keywords(self, knowledge):
result = nlu._fallback("帮我填查课日志", knowledge["businesses"], "generic_explore")
assert result["business_id"] == "generic_explore"
def test_extracts_class_and_date_without_model(self, knowledge):
result = nlu._fallback("帮我填今天的查课日志,班级计科2301班", knowledge["businesses"], None)
assert result["inputs"]["class_name"] == "计科2301班"
assert result["inputs"]["observe_date"] == date.today().isoformat()
def test_class_label_prefix_stripped(self, knowledge):
# 「班级是计科2301班」里的「班级是」不能被当成班级名的一部分
result = nlu._fallback("班级是计科2301班", knowledge["businesses"], "counselor_worklog")
assert result["inputs"]["class_name"] == "计科2301班"
def test_only_declared_fields_extracted(self, knowledge):
# generic_explore 没声明任何字段,即使原话里有班级也不抽
result = nlu._fallback("帮我看看计科2301班", knowledge["businesses"], "generic_explore")
assert result["inputs"] == {}
class TestClassifyAndExtract:
async def test_uses_model_result_and_filters_undeclared_fields(self, knowledge, monkeypatch):
async def fake_llm(prompt, cfg):
return {
"business_id": "counselor_worklog",
"confidence": 0.95,
"inputs": {"class_name": "计科2301班", "course_name": "高等数学", "不存在的字段": "x"},
}
monkeypatch.setattr(nlu, "get_llm_config", lambda: {"api_key": "k", "model": "m", "base_url": "u"})
monkeypatch.setattr(nlu, "_call_llm", fake_llm)
result = await nlu.classify_and_extract("帮我填查课日志", knowledge["businesses"])
assert result["source"] == "llm"
assert result["business_id"] == "counselor_worklog"
assert result["inputs"] == {"class_name": "计科2301班", "course_name": "高等数学"}
async def test_model_failure_falls_back_to_keywords(self, knowledge, monkeypatch):
async def boom(prompt, cfg):
raise RuntimeError("Error code: 402 - Insufficient Balance")
monkeypatch.setattr(nlu, "get_llm_config", lambda: {"api_key": "k", "model": "m", "base_url": "u"})
monkeypatch.setattr(nlu, "_call_llm", boom)
result = await nlu.classify_and_extract("帮我填今天的查课日志,班级计科2301班", knowledge["businesses"])
assert result["source"] == "fallback"
assert result["business_id"] == "counselor_worklog"
assert result["inputs"]["class_name"] == "计科2301班"
assert "402" in result["error"]
async def test_no_api_key_skips_model_entirely(self, knowledge, monkeypatch):
async def must_not_be_called(prompt, cfg):
raise AssertionError("没有 Key 时不应该调用模型")
monkeypatch.setattr(nlu, "get_llm_config", lambda: {"api_key": "", "model": "m", "base_url": "u"})
monkeypatch.setattr(nlu, "_call_llm", must_not_be_called)
result = await nlu.classify_and_extract("帮我填查课日志", knowledge["businesses"])
assert result["source"] == "fallback"
async def test_unknown_business_id_from_model_is_corrected(self, knowledge, monkeypatch):
async def fake_llm(prompt, cfg):
return {"business_id": "模型编出来的业务", "confidence": 0.9, "inputs": {}}
monkeypatch.setattr(nlu, "get_llm_config", lambda: {"api_key": "k", "model": "m", "base_url": "u"})
monkeypatch.setattr(nlu, "_call_llm", fake_llm)
result = await nlu.classify_and_extract("随便说点什么", knowledge["businesses"])
assert result["business_id"] == "generic_explore"
@pytest.mark.parametrize("value", ["null", "None", "未提及", "", " "])
async def test_placeholder_values_are_dropped(self, knowledge, monkeypatch, value):
async def fake_llm(prompt, cfg):
return {"business_id": "counselor_worklog", "confidence": 0.9, "inputs": {"class_name": value}}
monkeypatch.setattr(nlu, "get_llm_config", lambda: {"api_key": "k", "model": "m", "base_url": "u"})
monkeypatch.setattr(nlu, "_call_llm", fake_llm)
result = await nlu.classify_and_extract("帮我填查课日志", knowledge["businesses"])
assert "class_name" not in result["inputs"]
async def test_confidence_clamped_to_unit_range(self, knowledge, monkeypatch):
async def fake_llm(prompt, cfg):
return {"business_id": "counselor_worklog", "confidence": 9.9, "inputs": {}}
monkeypatch.setattr(nlu, "get_llm_config", lambda: {"api_key": "k", "model": "m", "base_url": "u"})
monkeypatch.setattr(nlu, "_call_llm", fake_llm)
result = await nlu.classify_and_extract("帮我填查课日志", knowledge["businesses"])
assert result["confidence"] == 1.0
async def test_existing_inputs_are_offered_to_the_prompt(self, knowledge, monkeypatch):
seen = {}
async def fake_llm(prompt, cfg):
seen["prompt"] = prompt
return {"business_id": "counselor_worklog", "confidence": 0.9, "inputs": {}}
monkeypatch.setattr(nlu, "get_llm_config", lambda: {"api_key": "k", "model": "m", "base_url": "u"})
monkeypatch.setattr(nlu, "_call_llm", fake_llm)
await nlu.classify_and_extract("补充一下", knowledge["businesses"], existing={"class_name": "计科2301班"})
assert "计科2301班" in seen["prompt"]
class TestCallLlmKeyFallback:
"""主 Key 402 时 _call_llm 必须切到兜底 Key 重发,而不是让任务失败。"""
@staticmethod
def _stub_openai(monkeypatch, keys_seen: list[str]):
import openai
class FakeCompletions:
def __init__(self, key):
self._key = key
async def create(self, **kwargs):
keys_seen.append(self._key)
if self._key == "PRIMARY":
import httpx
raise openai.APIStatusError(
"Error code: 402 - Insufficient Balance",
response=httpx.Response(402, request=httpx.Request("POST", "http://x")),
body=None,
)
return type("R", (), {"choices": [type("C", (), {"message": type("M", (), {"content": '{"ok": 1}'})()})()]})()
class FakeClient:
def __init__(self, api_key, base_url, timeout):
self.chat = type("Chat", (), {"completions": FakeCompletions(api_key)})()
monkeypatch.setattr(openai, "AsyncOpenAI", FakeClient)
async def test_switches_to_fallback_on_402(self, monkeypatch):
from backend.browser import llm_keys
marked = {}
monkeypatch.setattr(llm_keys, "mark_primary_exhausted", lambda reason="": marked.update(hit=True))
monkeypatch.setattr(llm_keys, "clear_primary_exhausted", lambda: None)
keys_seen: list[str] = []
self._stub_openai(monkeypatch, keys_seen)
cfg = {"api_key": "PRIMARY", "primary_api_key": "PRIMARY", "fallback_api_key": "FALLBACK",
"base_url": "http://x", "model": "m"}
assert await nlu._call_llm("hi", cfg) == {"ok": 1}
assert keys_seen == ["PRIMARY", "FALLBACK"]
assert marked.get("hit") is True
async def test_without_fallback_the_402_propagates(self, monkeypatch):
keys_seen: list[str] = []
self._stub_openai(monkeypatch, keys_seen)
cfg = {"api_key": "PRIMARY", "primary_api_key": "PRIMARY", "fallback_api_key": "",
"base_url": "http://x", "model": "m"}
with pytest.raises(Exception, match="402"):
await nlu._call_llm("hi", cfg)
assert keys_seen == ["PRIMARY"]
def test_prompt_lists_every_business_and_required_field(knowledge):
prompt = nlu._build_prompt("帮我填查课日志", knowledge["businesses"], {}, None)
assert "counselor_worklog" in prompt and "generic_explore" in prompt
assert "class_name" in prompt and "observe_date" in prompt
assert json.dumps # 提示词要求模型输出 JSON
assert "JSON" in prompt.upper()