kernel.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. """统一 Agent 内核:同一编排能力承载客户与运营两种人格。"""
  2. from dataclasses import dataclass
  3. from typing import Any, cast
  4. from uuid import UUID, uuid4
  5. from langchain.agents import create_agent
  6. from langchain_core.messages import AIMessage
  7. from langchain_core.runnables import RunnableConfig
  8. from langsmith import Client, tracing_context
  9. from app.core.config import Settings
  10. from app.domains.identity.models import AdminUser, H5User
  11. from app.harness.model_gateway import ModelGateway
  12. from app.harness.policy import HarnessPolicyEngine
  13. from app.harness.registries import PersonaRegistry, PromptRegistry, SkillRegistry
  14. from app.harness.schemas import (
  15. AgentAction,
  16. AgentReply,
  17. AgentStructuredOutput,
  18. ProductRecommendationsBlock,
  19. ProductView,
  20. )
  21. from app.harness.tooling import ToolExecutionContext, ToolRegistry
  22. @dataclass(frozen=True)
  23. class ConversationTurn:
  24. role: str
  25. content: str
  26. @dataclass(frozen=True)
  27. class AgentInvocation:
  28. persona: str
  29. message: str
  30. h5_user: H5User | None = None
  31. admin_user: AdminUser | None = None
  32. history: tuple[ConversationTurn, ...] = ()
  33. class AgentKernel:
  34. def __init__(
  35. self,
  36. *,
  37. settings: Settings,
  38. model_gateway: ModelGateway,
  39. tools: ToolRegistry,
  40. prompts: PromptRegistry | None = None,
  41. skills: SkillRegistry | None = None,
  42. personas: PersonaRegistry | None = None,
  43. policy: HarnessPolicyEngine | None = None,
  44. ) -> None:
  45. self._settings = settings
  46. self._model_gateway = model_gateway
  47. self._tools = tools
  48. self._prompts = prompts or PromptRegistry()
  49. self._skills = skills or SkillRegistry()
  50. self._personas = personas or PersonaRegistry()
  51. self._policy = policy or HarnessPolicyEngine()
  52. self._langsmith_client = (
  53. Client(
  54. api_url=settings.langsmith_endpoint,
  55. api_key=settings.langsmith_api_key,
  56. )
  57. if settings.langsmith_tracing and settings.langsmith_api_key
  58. else None
  59. )
  60. def reply(self, invocation: AgentInvocation) -> AgentReply:
  61. persona = self._personas.get(invocation.persona)
  62. system_prompt = self._compose_prompt(persona.prompt_id, persona.skill_ids)
  63. context = ToolExecutionContext(
  64. persona=persona.id,
  65. h5_user=invocation.h5_user,
  66. admin_user=invocation.admin_user,
  67. )
  68. tools = self._tools.build(
  69. names=persona.tool_names,
  70. context=context,
  71. policy_engine=self._policy,
  72. )
  73. agent = create_agent(
  74. model=self._model_gateway.chat_model(persona.id),
  75. tools=tools,
  76. system_prompt=system_prompt,
  77. name=f"zhibaotong-{persona.id}",
  78. )
  79. trace_id = uuid4()
  80. messages = [
  81. {"role": turn.role, "content": turn.content} for turn in invocation.history[-12:]
  82. ]
  83. messages.append({"role": "user", "content": invocation.message})
  84. with tracing_context(
  85. project_name=self._settings.langsmith_project,
  86. enabled=self._langsmith_client is not None,
  87. client=self._langsmith_client,
  88. tags=["zhibaotong", "s1", persona.id],
  89. metadata={
  90. "persona": persona.id,
  91. "channel": ("customer_h5" if persona.id == "customer" else "operation_admin"),
  92. "principal_id": context.principal_id,
  93. },
  94. ):
  95. run_config: RunnableConfig = {"run_id": trace_id}
  96. result = agent.invoke(
  97. cast(Any, {"messages": messages}),
  98. config=run_config,
  99. )
  100. structured = self._structured_output(
  101. cast(dict[str, Any], result),
  102. context,
  103. )
  104. blocks = [block for execution in context.executions for block in execution.result.blocks]
  105. recommendation = self._recommendation_block(
  106. context,
  107. structured.selected_product_codes,
  108. )
  109. if recommendation is not None:
  110. blocks.insert(0, recommendation)
  111. actions = [
  112. action for execution in context.executions for action in execution.result.actions
  113. ]
  114. if structured.suggested_action is not None:
  115. actions.append(structured.suggested_action)
  116. unique_actions = _deduplicate_actions(actions)
  117. return AgentReply(
  118. text=structured.message,
  119. cards=[block.model_dump(mode="json") for block in blocks],
  120. trace_id=str(trace_id),
  121. trace_url=self._trace_url(trace_id),
  122. actions=[action.model_dump(mode="json") for action in unique_actions],
  123. invoked_tools=tuple(execution.name for execution in context.executions),
  124. )
  125. def _compose_prompt(
  126. self,
  127. prompt_id: str,
  128. skill_ids: tuple[str, ...],
  129. ) -> str:
  130. prompt = self._prompts.get(prompt_id)
  131. skill_sections = []
  132. for skill_id in skill_ids:
  133. skill = self._skills.get(skill_id)
  134. skill_sections.append(f"## Skill:{skill.name}\n\n{skill.instructions}")
  135. return f"{prompt}\n\n# 已加载技能\n\n" + "\n\n".join(skill_sections)
  136. @staticmethod
  137. def _structured_output(
  138. result: dict[str, Any],
  139. context: ToolExecutionContext,
  140. ) -> AgentStructuredOutput:
  141. messages = result.get("messages", [])
  142. final = messages[-1] if messages else None
  143. text = (
  144. str(final.content)
  145. if isinstance(final, AIMessage)
  146. else "暂时无法生成有效回答,请稍后重试。"
  147. )
  148. selected_codes: list[str] = []
  149. for execution in context.executions:
  150. if execution.name != "list_available_products":
  151. continue
  152. raw_items = execution.result.data.get("items", [])
  153. if not isinstance(raw_items, list):
  154. continue
  155. for item in raw_items:
  156. if not isinstance(item, dict):
  157. continue
  158. code = str(item.get("product_code", ""))
  159. name = str(item.get("name", ""))
  160. if code and (code in text or name in text):
  161. selected_codes.append(code)
  162. return AgentStructuredOutput(
  163. message=text,
  164. selected_product_codes=list(dict.fromkeys(selected_codes))[:2],
  165. )
  166. @staticmethod
  167. def _recommendation_block(
  168. context: ToolExecutionContext,
  169. selected_codes: list[str],
  170. ) -> ProductRecommendationsBlock | None:
  171. if not selected_codes:
  172. return None
  173. product_items: list[dict[str, Any]] = []
  174. for execution in context.executions:
  175. if execution.name != "list_available_products":
  176. continue
  177. raw_items = execution.result.data.get("items", [])
  178. if isinstance(raw_items, list):
  179. product_items.extend(item for item in raw_items if isinstance(item, dict))
  180. selected = [
  181. item
  182. for code in selected_codes
  183. for item in product_items
  184. if item.get("product_code") == code
  185. ][:2]
  186. if not selected:
  187. return None
  188. views = [
  189. ProductView.model_validate(
  190. {
  191. "product_id": item["product_id"],
  192. "product_code": item["product_code"],
  193. "name": item["name"],
  194. "category": item["category"],
  195. "summary": item["summary"],
  196. "product_version_id": item["product_version_id"],
  197. "plans": item["plans"],
  198. }
  199. )
  200. for item in selected
  201. ]
  202. return ProductRecommendationsBlock(items=views)
  203. def _trace_url(self, trace_id: UUID) -> str | None:
  204. if self._langsmith_client is None:
  205. return None
  206. try:
  207. run = self._langsmith_client.read_run(trace_id)
  208. return self._langsmith_client.get_run_url(
  209. run=run,
  210. project_name=self._settings.langsmith_project,
  211. )
  212. except Exception:
  213. # Trace 上传可能有短暂延迟,运行详情接口会再次尝试解析。
  214. return None
  215. def trace_url(self, trace_id: str) -> str | None:
  216. try:
  217. return self._trace_url(UUID(trace_id))
  218. except ValueError:
  219. return None
  220. def _deduplicate_actions(actions: list[AgentAction]) -> list[AgentAction]:
  221. unique: list[AgentAction] = []
  222. seen: set[tuple[str, str]] = set()
  223. for action in actions:
  224. key = (action.type, str(sorted(action.payload.items())))
  225. if key not in seen:
  226. seen.add(key)
  227. unique.append(action)
  228. return unique