tools.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526
  1. """智保通第一阶段 Agent 可调用的确定性业务工具。"""
  2. from typing import Any, Literal, Protocol
  3. from pydantic import BaseModel, ConfigDict, Field
  4. from app.core.errors import AppError
  5. from app.domains.catalog.service import ProductCatalogService
  6. from app.domains.enrollment.service import EnrollmentService
  7. from app.harness.policy import ToolPolicy
  8. from app.harness.schemas import (
  9. AgentAction,
  10. BusinessListBlock,
  11. HarnessToolResult,
  12. MetricItem,
  13. MetricsBlock,
  14. QuoteBlock,
  15. QuoteView,
  16. )
  17. from app.harness.tooling import ToolDefinition, ToolExecutionContext, ToolRegistry
  18. class AttributionMetricsProvider(Protocol):
  19. def performance(self, admin_user_id: str | None = None) -> dict[str, Any]: ...
  20. def order_ids(self, admin_user_id: str | None = None) -> set[str]: ...
  21. class EmptyAttributionMetrics:
  22. def performance(self, admin_user_id: str | None = None) -> dict[str, Any]:
  23. del admin_user_id
  24. return {
  25. "salesperson_count": 0,
  26. "visit_count": 0,
  27. "lead_count": 0,
  28. "order_count": 0,
  29. "premium_cents": 0,
  30. "items": [],
  31. }
  32. def order_ids(self, admin_user_id: str | None = None) -> set[str]:
  33. del admin_user_id
  34. return set()
  35. class ToolArguments(BaseModel):
  36. model_config = ConfigDict(extra="forbid")
  37. class ListProductsArgs(ToolArguments):
  38. category: Literal["MEDICAL", "ACCIDENT"] | None = None
  39. class CalculateQuoteArgs(ToolArguments):
  40. product_code: str = Field(min_length=2, max_length=32)
  41. plan_code: str = Field(min_length=2, max_length=32)
  42. age: int = Field(ge=0, le=120)
  43. region_code: str = Field(default="510100", pattern=r"^\d{6}$")
  44. occupation_code: str = Field(default="GENERAL", min_length=2, max_length=32)
  45. relationship: Literal["SELF", "PARENT", "SPOUSE", "CHILD"]
  46. class PrepareEnrollmentArgs(ToolArguments):
  47. product_code: str = Field(min_length=2, max_length=32)
  48. plan_code: str = Field(min_length=2, max_length=32)
  49. age: int = Field(ge=0, le=120)
  50. relationship: Literal["SELF", "PARENT", "SPOUSE", "CHILD"]
  51. class EmptyArgs(ToolArguments):
  52. pass
  53. class RecentItemsArgs(ToolArguments):
  54. limit: int = Field(default=5, ge=1, le=20)
  55. def build_agent_tool_registry(
  56. catalog: ProductCatalogService,
  57. enrollment: EnrollmentService,
  58. attribution: AttributionMetricsProvider | None = None,
  59. ) -> ToolRegistry:
  60. registry = ToolRegistry()
  61. metrics = attribution or EmptyAttributionMetrics()
  62. def list_products(
  63. context: ToolExecutionContext,
  64. arguments: BaseModel,
  65. ) -> HarnessToolResult:
  66. del context
  67. args = _arguments(arguments, ListProductsArgs)
  68. products = catalog.list_available(category=args.category)
  69. return HarnessToolResult(
  70. summary=f"当前共有{len(products)}款符合条件的在售产品",
  71. data={"items": products, "total": len(products)},
  72. )
  73. def calculate_quote(
  74. context: ToolExecutionContext,
  75. arguments: BaseModel,
  76. ) -> HarnessToolResult:
  77. args = _arguments(arguments, CalculateQuoteArgs)
  78. user = context.h5_user
  79. if user is None:
  80. raise AppError("AGENT_CUSTOMER_REQUIRED", "保费测算需要H5用户身份", 401)
  81. product, plan = _resolve_product_plan(
  82. catalog,
  83. args.product_code,
  84. args.plan_code,
  85. )
  86. try:
  87. quote = enrollment.create_quote(
  88. user,
  89. product_id=str(product["product_id"]),
  90. plan_id=str(plan["id"]),
  91. age=args.age,
  92. region_code=args.region_code,
  93. occupation_code=args.occupation_code,
  94. relationship=args.relationship,
  95. )
  96. except AppError as error:
  97. if error.code != "ELIGIBILITY_REJECTED":
  98. raise
  99. block = QuoteBlock(
  100. quote=QuoteView(
  101. eligible=False,
  102. product_name=str(product["name"]),
  103. plan_name=str(plan["name"]),
  104. reason=error.message,
  105. )
  106. )
  107. return HarnessToolResult(
  108. summary=f"资格校验未通过:{error.message}",
  109. data={
  110. "eligible": False,
  111. "reason_code": (error.details or {}).get("reason_code"),
  112. },
  113. blocks=[block],
  114. )
  115. block = QuoteBlock(
  116. quote=QuoteView(
  117. eligible=True,
  118. quote_id=str(quote["quote_id"]),
  119. product_id=str(quote["product_id"]),
  120. product_name=str(product["name"]),
  121. plan_id=str(quote["plan_id"]),
  122. plan_name=str(plan["name"]),
  123. premium_cents=int(quote["premium_cents"]),
  124. currency=str(quote["currency"]),
  125. expires_at=str(quote["expires_at"]),
  126. )
  127. )
  128. return HarnessToolResult(
  129. summary=(
  130. f"{product['name']}{plan['name']}测算成功,"
  131. f"年保费{int(quote['premium_cents']) / 100:.2f}元"
  132. ),
  133. data=quote,
  134. blocks=[block],
  135. actions=[
  136. AgentAction(
  137. type="open_enrollment",
  138. label="填写投保信息",
  139. payload={
  140. "product_code": args.product_code,
  141. "plan_code": args.plan_code,
  142. "quote_id": quote["quote_id"],
  143. "age": args.age,
  144. "relationship": args.relationship,
  145. },
  146. )
  147. ],
  148. )
  149. def prepare_enrollment(
  150. context: ToolExecutionContext,
  151. arguments: BaseModel,
  152. ) -> HarnessToolResult:
  153. if context.h5_user is None:
  154. raise AppError("AGENT_CUSTOMER_REQUIRED", "投保引导需要H5用户身份", 401)
  155. args = _arguments(arguments, PrepareEnrollmentArgs)
  156. product, plan = _resolve_product_plan(
  157. catalog,
  158. args.product_code,
  159. args.plan_code,
  160. )
  161. action = AgentAction(
  162. type="open_enrollment",
  163. label="立即投保",
  164. payload={
  165. "product_id": product["product_id"],
  166. "product_code": args.product_code,
  167. "plan_id": plan["id"],
  168. "plan_code": args.plan_code,
  169. "age": args.age,
  170. "relationship": args.relationship,
  171. },
  172. )
  173. return HarnessToolResult(
  174. summary="已准备安全投保表单入口",
  175. data={"product": product, "plan": plan},
  176. actions=[action],
  177. )
  178. def list_my_orders(
  179. context: ToolExecutionContext,
  180. arguments: BaseModel,
  181. ) -> HarnessToolResult:
  182. del arguments
  183. user = context.h5_user
  184. if user is None:
  185. raise AppError("AGENT_CUSTOMER_REQUIRED", "订单查询需要H5用户身份", 401)
  186. result = enrollment.list_orders(user.id)
  187. items = [_safe_order_item(item) for item in result["items"]]
  188. return HarnessToolResult(
  189. summary=f"查到{len(items)}笔本人投保订单",
  190. data={"items": items, "total": len(items)},
  191. blocks=[
  192. BusinessListBlock(
  193. title="我的投保订单",
  194. entity="order",
  195. items=items[:5],
  196. total=len(items),
  197. )
  198. ],
  199. actions=[AgentAction(type="open_orders", label="查看我的订单")],
  200. )
  201. def list_my_policies(
  202. context: ToolExecutionContext,
  203. arguments: BaseModel,
  204. ) -> HarnessToolResult:
  205. del arguments
  206. user = context.h5_user
  207. if user is None:
  208. raise AppError("AGENT_CUSTOMER_REQUIRED", "保单查询需要H5用户身份", 401)
  209. result = enrollment.list_policies(user)
  210. items = [_safe_policy_item(item) for item in result["items"]]
  211. return HarnessToolResult(
  212. summary=f"查到{len(items)}张本人保单",
  213. data={"items": items, "total": len(items)},
  214. blocks=[
  215. BusinessListBlock(
  216. title="我的保障",
  217. entity="policy",
  218. items=items[:5],
  219. total=len(items),
  220. )
  221. ],
  222. actions=[AgentAction(type="open_policies", label="查看我的保障")],
  223. )
  224. def overview(
  225. context: ToolExecutionContext,
  226. arguments: BaseModel,
  227. ) -> HarnessToolResult:
  228. del arguments
  229. orders = enrollment.list_orders()
  230. policies = enrollment.list_all_policies()
  231. if context.admin_user and context.admin_user.data_scope == "SELF":
  232. allowed = metrics.order_ids(context.admin_user.id)
  233. orders = _filter_business_result(orders, allowed, "order_id")
  234. policies = _filter_business_result(policies, allowed, "order_id")
  235. active = [item for item in policies["items"] if item["status"] == "ACTIVE"]
  236. premium_cents = sum(int(item["premium_cents"]) for item in active)
  237. items = [
  238. MetricItem(label="订单总量", value=int(orders["total"]), unit="笔"),
  239. MetricItem(label="有效保单", value=len(active), unit="张"),
  240. MetricItem(label="累计保费", value=premium_cents / 100, unit="元"),
  241. ]
  242. return HarnessToolResult(
  243. summary=(
  244. f"当前订单{orders['total']}笔、有效保单{len(active)}张、"
  245. f"累计保费{premium_cents / 100:.2f}元"
  246. ),
  247. data={
  248. "order_count": orders["total"],
  249. "active_policy_count": len(active),
  250. "premium_cents": premium_cents,
  251. },
  252. blocks=[MetricsBlock(title="经营总览", items=items)],
  253. )
  254. def recent_orders(
  255. context: ToolExecutionContext,
  256. arguments: BaseModel,
  257. ) -> HarnessToolResult:
  258. args = _arguments(arguments, RecentItemsArgs)
  259. result = enrollment.list_orders()
  260. if context.admin_user and context.admin_user.data_scope == "SELF":
  261. result = _filter_business_result(
  262. result,
  263. metrics.order_ids(context.admin_user.id),
  264. "order_id",
  265. )
  266. items = [_safe_order_item(item) for item in result["items"][: args.limit]]
  267. return HarnessToolResult(
  268. summary=f"返回最近{len(items)}笔订单",
  269. data={"items": items, "total": result["total"]},
  270. blocks=[
  271. BusinessListBlock(
  272. title="近期订单",
  273. entity="order",
  274. items=items,
  275. total=int(result["total"]),
  276. )
  277. ],
  278. actions=[AgentAction(type="open_admin_orders", label="进入订单中心")],
  279. )
  280. def recent_policies(
  281. context: ToolExecutionContext,
  282. arguments: BaseModel,
  283. ) -> HarnessToolResult:
  284. args = _arguments(arguments, RecentItemsArgs)
  285. result = enrollment.list_all_policies()
  286. if context.admin_user and context.admin_user.data_scope == "SELF":
  287. result = _filter_business_result(
  288. result,
  289. metrics.order_ids(context.admin_user.id),
  290. "order_id",
  291. )
  292. items = [_safe_policy_item(item) for item in result["items"][: args.limit]]
  293. return HarnessToolResult(
  294. summary=f"返回最近{len(items)}张保单",
  295. data={"items": items, "total": result["total"]},
  296. blocks=[
  297. BusinessListBlock(
  298. title="近期保单",
  299. entity="policy",
  300. items=items,
  301. total=int(result["total"]),
  302. )
  303. ],
  304. actions=[AgentAction(type="open_admin_policies", label="进入保单中心")],
  305. )
  306. def attribution_performance(
  307. context: ToolExecutionContext,
  308. arguments: BaseModel,
  309. ) -> HarnessToolResult:
  310. del arguments
  311. admin = context.admin_user
  312. if admin is None:
  313. raise AppError("AGENT_ADMIN_REQUIRED", "推广分析需要后台身份", 401)
  314. owner_id = admin.id if admin.data_scope == "SELF" else None
  315. result = metrics.performance(owner_id)
  316. block = MetricsBlock(
  317. title="推广业绩",
  318. items=[
  319. MetricItem(
  320. label="推广员",
  321. value=int(result["salesperson_count"]),
  322. unit="人",
  323. ),
  324. MetricItem(label="访问", value=int(result["visit_count"]), unit="次"),
  325. MetricItem(label="线索", value=int(result["lead_count"]), unit="人"),
  326. MetricItem(label="归因订单", value=int(result["order_count"]), unit="笔"),
  327. MetricItem(
  328. label="归因保费",
  329. value=int(result["premium_cents"]) / 100,
  330. unit="元",
  331. ),
  332. ],
  333. )
  334. return HarnessToolResult(
  335. summary=(
  336. f"推广访问{result['visit_count']}次、线索{result['lead_count']}人、"
  337. f"归因订单{result['order_count']}笔"
  338. ),
  339. data=result,
  340. blocks=[block],
  341. actions=[AgentAction(type="open_attribution", label="查看推广明细")],
  342. )
  343. definitions = (
  344. ToolDefinition(
  345. name="list_available_products",
  346. description="查询当前真实在售保险产品和计划;推荐产品前必须调用。",
  347. arguments=ListProductsArgs,
  348. policy=ToolPolicy(personas=("customer", "operation"), effect="read"),
  349. handler=list_products,
  350. ),
  351. ToolDefinition(
  352. name="calculate_insurance_quote",
  353. description="按真实产品、年龄、地区、职业和关系执行确定性资格校验与保费测算。",
  354. arguments=CalculateQuoteArgs,
  355. policy=ToolPolicy(personas=("customer",), effect="draft"),
  356. handler=calculate_quote,
  357. ),
  358. ToolDefinition(
  359. name="prepare_enrollment",
  360. description="为已明确的产品和计划生成安全投保表单入口,不采集身份证号。",
  361. arguments=PrepareEnrollmentArgs,
  362. policy=ToolPolicy(personas=("customer",), effect="draft"),
  363. handler=prepare_enrollment,
  364. ),
  365. ToolDefinition(
  366. name="list_my_orders",
  367. description="查询当前登录客户自己的投保订单。",
  368. arguments=EmptyArgs,
  369. policy=ToolPolicy(personas=("customer",), effect="read"),
  370. handler=list_my_orders,
  371. ),
  372. ToolDefinition(
  373. name="list_my_policies",
  374. description="查询当前登录客户自己的电子保单。",
  375. arguments=EmptyArgs,
  376. policy=ToolPolicy(personas=("customer",), effect="read"),
  377. handler=list_my_policies,
  378. ),
  379. ToolDefinition(
  380. name="get_operation_overview",
  381. description="查询订单、有效保单和累计保费经营指标。",
  382. arguments=EmptyArgs,
  383. policy=ToolPolicy(
  384. personas=("operation",),
  385. effect="read",
  386. required_permissions=("dashboard:read",),
  387. ),
  388. handler=overview,
  389. ),
  390. ToolDefinition(
  391. name="list_recent_orders",
  392. description="按当前管理员数据范围查询近期订单,只返回脱敏字段。",
  393. arguments=RecentItemsArgs,
  394. policy=ToolPolicy(
  395. personas=("operation",),
  396. effect="read",
  397. required_permissions=("order:read",),
  398. ),
  399. handler=recent_orders,
  400. ),
  401. ToolDefinition(
  402. name="list_recent_policies",
  403. description="按当前管理员数据范围查询近期保单,只返回脱敏字段。",
  404. arguments=RecentItemsArgs,
  405. policy=ToolPolicy(
  406. personas=("operation",),
  407. effect="read",
  408. required_permissions=("policy:read",),
  409. ),
  410. handler=recent_policies,
  411. ),
  412. ToolDefinition(
  413. name="get_attribution_performance",
  414. description="查询当前管理员数据范围内的推广访问、线索、订单和保费。",
  415. arguments=EmptyArgs,
  416. policy=ToolPolicy(
  417. personas=("operation",),
  418. effect="read",
  419. required_permissions=("attribution:read",),
  420. ),
  421. handler=attribution_performance,
  422. ),
  423. )
  424. for definition in definitions:
  425. registry.register(definition)
  426. return registry
  427. def _arguments(value: BaseModel, expected: type[ToolArguments]) -> Any:
  428. if not isinstance(value, expected):
  429. raise AppError("AGENT_TOOL_ARGUMENTS_INVALID", "工具参数类型错误", 500)
  430. return value
  431. def _resolve_product_plan(
  432. catalog: ProductCatalogService,
  433. product_code: str,
  434. plan_code: str,
  435. ) -> tuple[dict[str, Any], dict[str, Any]]:
  436. product = next(
  437. (item for item in catalog.list_available() if item["product_code"] == product_code),
  438. None,
  439. )
  440. if product is None:
  441. raise AppError("PRODUCT_NOT_AVAILABLE", "产品当前不可投保", 404)
  442. plan = next(
  443. (item for item in product["plans"] if item["code"] == plan_code),
  444. None,
  445. )
  446. if plan is None:
  447. raise AppError("PLAN_NOT_AVAILABLE", "保障计划当前不可投保", 404)
  448. return product, plan
  449. def _safe_order_item(item: dict[str, Any]) -> dict[str, Any]:
  450. return {
  451. "order_id": item["order_id"],
  452. "order_no": item["order_no"],
  453. "product_name": item["product_name"],
  454. "plan_name": item["plan_name"],
  455. "relationship": item["relationship"],
  456. "status": item["status"],
  457. "amount_cents": item["amount_cents"],
  458. "currency": item["currency"],
  459. "policy_no": item["policy_no"],
  460. "created_at": str(item["created_at"]),
  461. }
  462. def _safe_policy_item(item: dict[str, Any]) -> dict[str, Any]:
  463. return {
  464. "policy_id": item["policy_id"],
  465. "policy_no": item["policy_no"],
  466. "order_no": item["order_no"],
  467. "product_name": item["product_name"],
  468. "plan_name": item["plan_name"],
  469. "relationship": item["relationship"],
  470. "status": item["status"],
  471. "premium_cents": item["premium_cents"],
  472. "currency": item["currency"],
  473. "coverage_start": str(item["coverage_start"]),
  474. "coverage_end": str(item["coverage_end"]),
  475. "issued_at": str(item["issued_at"]),
  476. }
  477. def _filter_business_result(
  478. result: dict[str, Any],
  479. allowed_ids: set[str],
  480. id_field: str,
  481. ) -> dict[str, Any]:
  482. items = [item for item in result["items"] if str(item.get(id_field, "")) in allowed_ids]
  483. return {"items": items, "total": len(items)}