test_agent_knowledge_citations.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. import json
  2. from datetime import UTC, datetime
  3. from typing import Any
  4. from langchain_core.language_models.chat_models import BaseChatModel
  5. from langchain_core.messages import AIMessage
  6. from langchain_core.outputs import ChatGeneration, ChatResult
  7. from pydantic import Field
  8. from zbt.core.config import Settings
  9. from zbt.domains.agent.tools import build_agent_tool_registry
  10. from zbt.domains.catalog.repository import InMemoryCatalogRepository
  11. from zbt.domains.catalog.service import ProductCatalogService
  12. from zbt.domains.enrollment.repository import InMemoryEnrollmentRepository
  13. from zbt.domains.enrollment.service import EnrollmentService
  14. from zbt.domains.identity.models import H5User
  15. from zbt.domains.knowledge.index import InMemoryKnowledgeIndex
  16. from zbt.domains.knowledge.repository import InMemoryKnowledgeRepository
  17. from zbt.domains.knowledge.service import KnowledgeService
  18. from zbt.harness.kernel import (
  19. AgentInvocation,
  20. AgentKernel,
  21. _infer_cited_chunk_ids,
  22. )
  23. NOW = datetime(2026, 7, 28, 10, tzinfo=UTC)
  24. class CitationAwareTestModel(BaseChatModel):
  25. cited_chunk_ids: list[str] = Field(default_factory=list)
  26. invocation_count: int = 0
  27. structured_tool_name: str | None = None
  28. @property
  29. def _llm_type(self) -> str:
  30. return "citation-aware-test-model"
  31. def bind_tools(
  32. self,
  33. tools: Any,
  34. *,
  35. tool_choice: str | None = None,
  36. **kwargs: Any,
  37. ) -> Any:
  38. del tool_choice, kwargs
  39. names = [_tool_name(tool) for tool in tools]
  40. self.structured_tool_name = next(
  41. (name for name in names if name == "AgentStructuredOutput"),
  42. None,
  43. )
  44. return self
  45. def _generate(
  46. self,
  47. messages: Any,
  48. stop: list[str] | None = None,
  49. run_manager: Any = None,
  50. **kwargs: Any,
  51. ) -> ChatResult:
  52. del messages, stop, run_manager, kwargs
  53. self.invocation_count += 1
  54. if self.invocation_count == 1:
  55. message = AIMessage(
  56. content="",
  57. tool_calls=[
  58. {
  59. "name": "search_insurance_knowledge",
  60. "args": {
  61. "query": "安心医疗险是否报销海外整形手术,比例是多少?",
  62. "limit": 5,
  63. },
  64. "id": "knowledge-search-1",
  65. }
  66. ],
  67. )
  68. elif self.structured_tool_name is not None:
  69. message = AIMessage(
  70. content="",
  71. tool_calls=[
  72. {
  73. "name": self.structured_tool_name,
  74. "args": {
  75. "message": (
  76. "当前知识库未明确海外整形手术是否属于保障责任,"
  77. "也未明确具体报销比例,无法确定。"
  78. ),
  79. "selected_product_codes": [],
  80. "suggested_action": None,
  81. "cited_knowledge_chunk_ids": self.cited_chunk_ids,
  82. },
  83. "id": "structured-answer-1",
  84. }
  85. ],
  86. )
  87. else:
  88. marker = json.dumps(self.cited_chunk_ids)
  89. message = AIMessage(
  90. content=(
  91. "当前知识库未明确海外整形手术是否属于保障责任,"
  92. "也未明确具体报销比例,无法确定。\n\n"
  93. f"<!-- ZBT_CITATIONS: {marker} -->"
  94. )
  95. )
  96. return ChatResult(generations=[ChatGeneration(message=message)])
  97. class CitationAwareTestGateway:
  98. def __init__(self, model: CitationAwareTestModel) -> None:
  99. self._model = model
  100. def chat_model(self, persona: str) -> BaseChatModel:
  101. assert persona == "customer"
  102. return self._model
  103. def test_unknown_answer_does_not_expose_retrieval_candidates_as_citations() -> None:
  104. kernel, user, _, _ = _kernel(cited_chunk_ids=[])
  105. reply = kernel.reply(
  106. AgentInvocation(
  107. persona="customer",
  108. message="安心医疗险是否可以报销海外整形手术?具体报销比例是多少?",
  109. h5_user=user,
  110. )
  111. )
  112. assert reply.invoked_tools == ("search_insurance_knowledge",)
  113. assert "无法确定" in reply.text
  114. assert "ZBT_CITATIONS" not in reply.text
  115. assert not any(card["type"] == "knowledge_sources" for card in reply.cards)
  116. def test_answer_only_exposes_selected_candidates_as_citations() -> None:
  117. kernel, user, chunk_ids, model = _kernel(cited_chunk_ids=[])
  118. model.cited_chunk_ids = [
  119. chunk_ids[0],
  120. chunk_ids[1],
  121. "not-returned-by-the-knowledge-tool",
  122. chunk_ids[0],
  123. ]
  124. reply = kernel.reply(
  125. AgentInvocation(
  126. persona="customer",
  127. message="非医学必需的美容项目是否属于保障责任?",
  128. h5_user=user,
  129. )
  130. )
  131. source_cards = [
  132. card for card in reply.cards if card["type"] == "knowledge_sources"
  133. ]
  134. assert len(source_cards) == 1
  135. assert len(source_cards[0]["items"]) == 1
  136. source = source_cards[0]["items"][0]
  137. assert source["excerpts"] == [
  138. "非医学必需的美容项目不属于本产品保障责任。",
  139. "具体报销比例以电子保单对应的保障计划为准。",
  140. ]
  141. def test_exact_quoted_knowledge_can_recover_missing_model_citation_marker() -> None:
  142. text = (
  143. "依据《安心医疗险保险条款》(版本 v1):"
  144. "疾病医疗责任的等待期为30天。"
  145. )
  146. executions = [
  147. {
  148. "chunk_id": "doc-1:1:1",
  149. "title": "安心医疗险保险条款",
  150. "source_name": "安心医疗险保险条款.md",
  151. "content": "疾病医疗责任的等待期为30天。因意外伤害不受等待期限制。",
  152. },
  153. {
  154. "chunk_id": "doc-1:1:2",
  155. "title": "安心医疗险保险条款",
  156. "source_name": "安心医疗险保险条款.md",
  157. "content": "具体责任以电子保单对应的保障计划为准。",
  158. },
  159. ]
  160. assert _infer_cited_chunk_ids(text, executions) == ["doc-1:1:1"]
  161. def _kernel(
  162. *,
  163. cited_chunk_ids: list[str],
  164. ) -> tuple[AgentKernel, H5User, list[str], CitationAwareTestModel]:
  165. knowledge = KnowledgeService(
  166. InMemoryKnowledgeRepository(),
  167. lambda: NOW,
  168. InMemoryKnowledgeIndex(),
  169. )
  170. document = knowledge.create_document(
  171. title="安心医疗险保险条款",
  172. document_type="INSURANCE_TERMS",
  173. source_name="安心医疗险保险条款.md",
  174. product_code="MED-BASIC",
  175. content=(
  176. "非医学必需的美容项目不属于本产品保障责任。\n\n"
  177. "具体报销比例以电子保单对应的保障计划为准。"
  178. ),
  179. created_by="admin-01",
  180. )
  181. knowledge.index_document(document["document_id"])
  182. knowledge.test_search(document["document_id"], "美容项目是否保障")
  183. knowledge.publish_document(document["document_id"])
  184. chunk_ids = [
  185. f"{document['document_id']}:1:1",
  186. f"{document['document_id']}:1:2",
  187. ]
  188. catalog = ProductCatalogService(InMemoryCatalogRepository(), lambda: NOW)
  189. enrollment = EnrollmentService(
  190. InMemoryEnrollmentRepository(),
  191. catalog,
  192. lambda: NOW,
  193. "x" * 32,
  194. )
  195. tools = build_agent_tool_registry(catalog, enrollment, knowledge=knowledge)
  196. user = H5User(
  197. id="01H5USER000000000000000001",
  198. mobile="18800000001",
  199. mobile_masked="188****0001",
  200. display_name="测试用户",
  201. status="ACTIVE",
  202. created_at=NOW,
  203. )
  204. settings = Settings(
  205. app_env="test",
  206. jwt_access_secret="a" * 32,
  207. jwt_refresh_secret="b" * 32,
  208. field_encryption_key="c" * 32,
  209. langsmith_tracing=False,
  210. )
  211. model = CitationAwareTestModel(cited_chunk_ids=cited_chunk_ids)
  212. return (
  213. AgentKernel(
  214. settings=settings,
  215. model_gateway=CitationAwareTestGateway(model),
  216. tools=tools,
  217. ),
  218. user,
  219. chunk_ids,
  220. model,
  221. )
  222. def _tool_name(tool: Any) -> str:
  223. if hasattr(tool, "name"):
  224. return str(tool.name)
  225. if isinstance(tool, dict):
  226. function = tool.get("function", {})
  227. if isinstance(function, dict):
  228. return str(function.get("name", ""))
  229. return ""