test_harness_core.py 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. import json
  2. from datetime import UTC, datetime
  3. import pytest
  4. from zbt.commands.seed import build_seed_manifest
  5. from zbt.core.errors import AppError
  6. from zbt.domains.agent.tools import build_agent_tool_registry
  7. from zbt.domains.catalog.repository import InMemoryCatalogRepository
  8. from zbt.domains.catalog.service import ProductCatalogService
  9. from zbt.domains.enrollment.repository import InMemoryEnrollmentRepository
  10. from zbt.domains.enrollment.service import EnrollmentService
  11. from zbt.domains.identity.models import AdminUser, H5User
  12. from zbt.harness.policy import HarnessPolicyEngine, ToolPolicy
  13. from zbt.harness.registries import PersonaRegistry, PromptRegistry, SkillRegistry
  14. from zbt.harness.schemas import ProductRecommendationsBlock
  15. from zbt.harness.tooling import ToolExecutionContext
  16. NOW = datetime(2026, 7, 26, 8, tzinfo=UTC)
  17. def test_prompt_skill_and_persona_registries_form_a_closed_contract() -> None:
  18. prompts = PromptRegistry()
  19. skills = SkillRegistry()
  20. personas = PersonaRegistry()
  21. assert prompts.ids() == ("customer", "operation")
  22. assert skills.ids() == (
  23. "customer-memory",
  24. "enrollment-guidance",
  25. "insurance-consultation",
  26. "insurance-knowledge",
  27. "operation-insights",
  28. )
  29. customer = personas.get("customer")
  30. operation = personas.get("operation")
  31. assert customer.prompt_id == "customer"
  32. assert "calculate_insurance_quote" in customer.tool_names
  33. assert "search_insurance_knowledge" in customer.tool_names
  34. assert operation.prompt_id == "operation"
  35. assert "get_attribution_performance" in operation.tool_names
  36. assert "search_insurance_knowledge" in operation.tool_names
  37. customer_prompt = prompts.get("customer")
  38. knowledge_skill = skills.get("insurance-knowledge")
  39. assert "不得把宽泛条款扩展到具体场景" in customer_prompt
  40. assert "不得给出或估算免赔额、报销比例" in customer_prompt
  41. assert "宽泛条款不能自动套用到具体情形" in knowledge_skill.instructions
  42. for skill_id in customer.skill_ids + operation.skill_ids:
  43. skill = skills.get(skill_id)
  44. assert skill.instructions
  45. assert skill.source.name == "SKILL.md"
  46. def test_operation_persona_cannot_authorize_a_draft_tool() -> None:
  47. policy = HarnessPolicyEngine()
  48. with pytest.raises(AppError) as error:
  49. policy.authorize(
  50. persona="operation",
  51. allowed_by_persona=("calculate_insurance_quote",),
  52. tool_name="calculate_insurance_quote",
  53. policy=ToolPolicy(personas=("operation",), effect="draft"),
  54. permissions=("*",),
  55. )
  56. assert error.value.code == "AGENT_OPERATION_READ_ONLY"
  57. def test_customer_tools_use_real_catalog_and_deterministic_quote_service() -> None:
  58. catalog_repository = InMemoryCatalogRepository()
  59. manifest = build_seed_manifest()
  60. for product in manifest.products:
  61. catalog_repository.save_product(product)
  62. for version in manifest.versions:
  63. catalog_repository.save_version(version)
  64. catalog = ProductCatalogService(catalog_repository, lambda: NOW)
  65. enrollment = EnrollmentService(
  66. InMemoryEnrollmentRepository(),
  67. catalog,
  68. lambda: NOW,
  69. "x" * 32,
  70. )
  71. registry = build_agent_tool_registry(catalog, enrollment)
  72. user = H5User(
  73. id="01H5USER000000000000000001",
  74. mobile="18800000001",
  75. mobile_masked="188****0001",
  76. display_name="体验用户",
  77. status="ACTIVE",
  78. created_at=NOW,
  79. )
  80. context = ToolExecutionContext(persona="customer", h5_user=user)
  81. tools = registry.build(
  82. names=("list_available_products", "calculate_insurance_quote"),
  83. context=context,
  84. policy_engine=HarnessPolicyEngine(),
  85. )
  86. by_name = {tool.name: tool for tool in tools}
  87. products = json.loads(by_name["list_available_products"].invoke({"category": "MEDICAL"}))
  88. quote = json.loads(
  89. by_name["calculate_insurance_quote"].invoke(
  90. {
  91. "product_code": "MED-SENIOR",
  92. "plan_code": "SENIOR_STANDARD",
  93. "age": 65,
  94. "region_code": "510100",
  95. "occupation_code": "GENERAL",
  96. "relationship": "PARENT",
  97. }
  98. )
  99. )
  100. assert products["data"]["total"] == 3
  101. assert quote["data"]["premium_cents"] == 19900
  102. assert quote["blocks"][0]["type"] == "quote"
  103. assert quote["actions"][0]["type"] == "open_enrollment"
  104. assert [execution.name for execution in context.executions] == [
  105. "list_available_products",
  106. "calculate_insurance_quote",
  107. ]
  108. def test_product_recommendation_contract_accepts_enriched_plan_data() -> None:
  109. catalog_repository = InMemoryCatalogRepository()
  110. manifest = build_seed_manifest()
  111. for product in manifest.products:
  112. catalog_repository.save_product(product)
  113. for version in manifest.versions:
  114. catalog_repository.save_version(version)
  115. catalog = ProductCatalogService(catalog_repository, lambda: NOW)
  116. enrollment = EnrollmentService(
  117. InMemoryEnrollmentRepository(),
  118. catalog,
  119. lambda: NOW,
  120. "x" * 32,
  121. )
  122. registry = build_agent_tool_registry(catalog, enrollment)
  123. context = ToolExecutionContext(
  124. persona="customer",
  125. h5_user=H5User(
  126. id="01H5USER000000000000000001",
  127. mobile="18800000001",
  128. mobile_masked="188****0001",
  129. display_name="体验用户",
  130. status="ACTIVE",
  131. created_at=NOW,
  132. ),
  133. )
  134. tool = registry.build(
  135. names=("list_available_products",),
  136. context=context,
  137. policy_engine=HarnessPolicyEngine(),
  138. )[0]
  139. products = json.loads(tool.invoke({"category": "MEDICAL"}))
  140. product = next(
  141. item
  142. for item in products["data"]["items"]
  143. if item["product_code"] == "MED-SENIOR"
  144. )
  145. recommendation = ProductRecommendationsBlock(
  146. items=[
  147. {
  148. "product_id": product["product_id"],
  149. "product_code": product["product_code"],
  150. "name": product["name"],
  151. "category": product["category"],
  152. "summary": product["summary"],
  153. "product_version_id": product["product_version_id"],
  154. "plans": product["plans"],
  155. }
  156. ],
  157. )
  158. plan = recommendation.items[0].plans[0]
  159. assert plan.premium_cents == 19900
  160. assert plan.coverage_amount_cents == 100_000_000
  161. assert (plan.min_age, plan.max_age) == (50, 75)
  162. def test_operation_analytics_tool_uses_allowlisted_query_and_chart_contract() -> None:
  163. class AnalyticsStub:
  164. def query(
  165. self,
  166. *,
  167. metric: str,
  168. group_by: str,
  169. days: int,
  170. admin_user_id: str | None = None,
  171. ) -> dict[str, object]:
  172. assert metric == "ORDER_COUNT"
  173. assert group_by == "DAY"
  174. assert days == 30
  175. assert admin_user_id is None
  176. return {
  177. "metric": metric,
  178. "metric_name": "订单量",
  179. "unit": "笔",
  180. "group_by": group_by,
  181. "rows": [
  182. {"label": "2026-07-26", "value": 12},
  183. {"label": "2026-07-27", "value": 18},
  184. ],
  185. "total": 30,
  186. "query_plan": {
  187. "source": "analytics",
  188. "fact": "fact_orders",
  189. "aggregation": "COUNT",
  190. "dimension": "dim_date",
  191. "read_only": True,
  192. },
  193. }
  194. catalog = ProductCatalogService(InMemoryCatalogRepository(), lambda: NOW)
  195. enrollment = EnrollmentService(
  196. InMemoryEnrollmentRepository(),
  197. catalog,
  198. lambda: NOW,
  199. "x" * 32,
  200. )
  201. registry = build_agent_tool_registry(
  202. catalog,
  203. enrollment,
  204. analytics=AnalyticsStub(),
  205. )
  206. context = ToolExecutionContext(
  207. persona="operation",
  208. admin_user=AdminUser(
  209. id="01ADMIN0000000000000000001",
  210. username="admin",
  211. password_hash="unused",
  212. display_name="管理员",
  213. status="ACTIVE",
  214. roles=("SUPER_ADMIN",),
  215. permissions=("orders:read", "policies:read"),
  216. data_scope="ALL",
  217. ),
  218. )
  219. tool = registry.build(
  220. names=("query_operation_analytics",),
  221. context=context,
  222. policy_engine=HarnessPolicyEngine(),
  223. )[0]
  224. result = json.loads(
  225. tool.invoke(
  226. {
  227. "metric": "ORDER_COUNT",
  228. "group_by": "DAY",
  229. "days": 30,
  230. }
  231. )
  232. )
  233. assert result["data"]["query_plan"]["read_only"] is True
  234. assert result["data"]["total"] == 30
  235. assert result["blocks"][0]["type"] == "chart"
  236. assert result["blocks"][0]["chart_type"] == "line"
  237. assert result["blocks"][0]["points"][1] == {
  238. "label": "2026-07-27",
  239. "value": 18,
  240. }
  241. def test_operation_tool_permissions_filter_unavailable_tools() -> None:
  242. catalog_repository = InMemoryCatalogRepository()
  243. catalog = ProductCatalogService(catalog_repository, lambda: NOW)
  244. enrollment = EnrollmentService(
  245. InMemoryEnrollmentRepository(),
  246. catalog,
  247. lambda: NOW,
  248. "x" * 32,
  249. )
  250. registry = build_agent_tool_registry(catalog, enrollment)
  251. admin = AdminUser(
  252. id="01ADMIN0000000000000000001",
  253. username="sales_a",
  254. password_hash="unused",
  255. display_name="业务员A",
  256. status="ACTIVE",
  257. roles=("SALESPERSON",),
  258. permissions=("dashboard:read", "attribution:read"),
  259. data_scope="SELF",
  260. )
  261. tools = registry.build(
  262. names=PersonaRegistry().get("operation").tool_names,
  263. context=ToolExecutionContext(persona="operation", admin_user=admin),
  264. policy_engine=HarnessPolicyEngine(),
  265. )
  266. assert {tool.name for tool in tools} == {
  267. "get_operation_overview",
  268. "get_attribution_performance",
  269. "list_available_products",
  270. "search_insurance_knowledge",
  271. }