test: 补充知识处理、检索与 Agent 回归用例

覆盖切块、向量检索、工具调用和历史预算;模型相关用例使用桩。
This commit is contained in:
WangShen007 committed 2026-10-04 20:55:00 +08:00
1 parent 362d79a5c2
commit f4d8019829
123 files changed
+29005

No files matched your search

@@ -0,0 +1,178 @@
"""Tests for the Groq structured-output path and null-tolerant ChatResponse parsing.
Groq can't combine response_format with tools, so the agent uses a `json` tool for
structured output and salvages truncated tool calls. These tests lock in:
- ChatResponse tolerates explicit `null` on non-nullable boolean fields (the models
routinely emit them), instead of discarding the whole response.
- The Groq truncation salvage recovers the lead/end_chat fields from a failed tool call.
"""
import json
from app.models.schemas.chat import ChatResponse, EndChatReasonType
from app.agents.chat_agent import (
_salvage_groq_json_error,
_build_chat_response_from_capture,
_lenient_json_load,
ensure_nonempty_message,
_EMPTY_TURN_FALLBACK,
_salvage_groq_answer_text,
recover_groq_no_capture,
)
class _FakeMsg:
def __init__(self, role, content):
self.role = role
self.content = content
class _FakeResp:
def __init__(self, messages, content=""):
self.messages = messages
self.content = content
class TestGroqNoCaptureRecovery:
"""When Groq ends a turn without calling the `json` tool, its answer text
survives as the last assistant message even though response.content is
empty. Recover it rather than dropping the turn."""
def test_salvage_returns_last_assistant_text(self):
resp = _FakeResp([
_FakeMsg("user", "explain the product"),
_FakeMsg("assistant", ""), # the knowledge tool-call turn
_FakeMsg("tool", "search results..."),
_FakeMsg("assistant", "Charcoal, oat and black come in wide."),
])
assert _salvage_groq_answer_text(resp) == "Charcoal, oat and black come in wide."
def test_salvage_skips_empty_and_non_assistant(self):
resp = _FakeResp([
_FakeMsg("user", "hi"),
_FakeMsg("assistant", " "),
])
assert _salvage_groq_answer_text(resp) is None
def test_recover_uses_salvaged_text(self):
resp = _FakeResp([_FakeMsg("assistant", "Here is the answer.")], content="")
rc = recover_groq_no_capture(resp)
assert rc.message == "Here is the answer."
def test_recover_parses_json_written_as_text(self):
# Model wrote the structured JSON as plain text instead of calling the tool.
resp = _FakeResp([_FakeMsg("assistant", '{"message": "Hello there", "end_chat": true}')])
rc = recover_groq_no_capture(resp)
assert rc.message == "Hello there"
assert rc.end_chat is True
def test_recover_no_text_yields_empty_for_fallback(self):
# Nothing to recover → empty message; ensure_nonempty_message adds the fallback.
resp = _FakeResp([_FakeMsg("assistant", "")], content="")
rc = recover_groq_no_capture(resp)
assert not (rc.message or "").strip()
assert ensure_nonempty_message(rc).message == _EMPTY_TURN_FALLBACK
class TestEnsureNonemptyMessage:
"""A model turn that yields no message and no action (e.g. Groq stopping
after a tool call) must not be dropped — widget_chat only emits when the
message is non-empty, so an empty turn would hang the typing indicator."""
def test_empty_no_action_gets_fallback(self):
assert ensure_nonempty_message(ChatResponse(message="")).message == _EMPTY_TURN_FALLBACK
def test_blank_whitespace_gets_fallback(self):
assert ensure_nonempty_message(ChatResponse(message=" ")).message == _EMPTY_TURN_FALLBACK
def test_real_message_untouched(self):
assert ensure_nonempty_message(ChatResponse(message="Here you go")).message == "Here you go"
def test_empty_with_action_left_alone(self):
# The action's own handler owns the message; don't clobber it.
for rc in (
ChatResponse(message="", transfer_to_human=True),
ChatResponse(message="", end_chat=True),
ChatResponse(message="", request_rating=True),
ChatResponse(message="", create_ticket=True),
ChatResponse(message="", request_lead_capture=True),
):
assert ensure_nonempty_message(rc).message == ""
def test_chat_response_tolerates_explicit_null_booleans():
"""A dict with explicit null on non-nullable bools must still build, with the
real flags (end_chat, request_lead_capture) preserved rather than discarded."""
data = {
"message": "Thanks, we'll be in touch!",
"end_chat": True,
"end_chat_reason": "CUSTOMER_REQUEST",
"request_lead_capture": True,
"lead_email": "sam@example.com",
"lead_consent": True,
"transfer_to_human": None, # non-nullable bool -> must coerce to default
"create_ticket": None, # non-nullable bool -> must coerce to default
"request_contact": None,
"transfer_reason": None,
"lead_name": None,
}
cr = ChatResponse(**data)
assert cr.end_chat is True
assert cr.end_chat_reason == EndChatReasonType.CUSTOMER_REQUEST
assert cr.request_lead_capture is True
assert cr.lead_email == "sam@example.com"
assert cr.lead_consent is True
assert cr.transfer_to_human is False # null -> default
assert cr.create_ticket is False # null -> default
def test_build_chat_response_from_capture_drops_none():
cr = _build_chat_response_from_capture(
{"message": "hi", "end_chat": True, "transfer_to_human": None, "create_ticket": None}
)
assert cr.message == "hi"
assert cr.end_chat is True
assert cr.transfer_to_human is False
def test_salvage_recovers_truncated_groq_tool_call():
"""A Groq tool_use_failed error with a message truncated mid-string still yields
the completed lead fields."""
failed_generation = (
'{"name": "json", "arguments": {'
'"lead_name": "Arun", "lead_email": "yohan@gmail.com", "lead_phone": "7676767676", '
'"lead_consent": true, "request_lead_capture": true, '
'"message": "Thanks for confirming, Arun. Which modules are you most' # truncated
)
body = json.dumps({"error": {"code": "tool_use_failed", "failed_generation": failed_generation}})
class _Exc(Exception):
def __init__(self, msg):
self.message = msg
super().__init__(msg)
salvaged = _salvage_groq_json_error(_Exc(body))
assert salvaged is not None
cr = _build_chat_response_from_capture(salvaged)
assert cr.request_lead_capture is True
assert cr.lead_email == "yohan@gmail.com"
assert cr.lead_name == "Arun"
assert cr.lead_consent is True
def test_salvage_returns_none_for_non_truncation_errors():
"""Rate-limit/auth errors have no failed_generation -> salvage returns None so the
caller re-raises them unchanged."""
class _Exc(Exception):
def __init__(self, msg):
self.message = msg
super().__init__(msg)
body = json.dumps({"error": {"message": "Rate limit reached", "type": "rate_limit_error"}})
assert _salvage_groq_json_error(_Exc(body)) is None
def test_lenient_json_load_handles_complete_and_truncated():
assert _lenient_json_load('{"a": 1, "b": "x"}') == {"a": 1, "b": "x"}
# truncated mid-string -> drops the incomplete trailing key, keeps the rest
out = _lenient_json_load('{"a": "done", "b": "unterminated')
assert out is not None and out.get("a") == "done"