Files
group_fqcd_jr/app/service/agent/bootstrap.py
T

427 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import logging
from functools import lru_cache
from typing import Any, cast
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.advisor_allocation_contracts import AssetAllocationQuery
from app.core.config import get_settings
from app.core.errors import RecoverableAgentError
from app.core.fund_contracts import FundQuoteQuery
from app.core.investment_goal_contracts import InvestmentGoalQuery
from app.core.knowledge_contracts import KnowledgeSearchInput
from app.core.nl2sql_contracts import FinancialNL2SQLInput
from app.core.portfolio_analysis_contracts import PortfolioAnalysisQuery
from app.core.product_comparison_contracts import ProductComparisonQuery
from app.core.product_recommendation_contracts import ProductRecommendationQuery
from app.core.risk_contracts import RiskAlertEvidenceQuery, RiskAlertQuery
from app.infrastructure.fund_quote_cache import FundQuoteCache
from app.infrastructure.graph import build_graph_driver
from app.infrastructure.memory_cache import MemoryCacheAdapter
from app.infrastructure.milvus_knowledge_writer import MilvusKnowledgeWriter
from app.infrastructure.vector_memory import VectorMemoryAdapter
from app.service.agent.factory import AgentFactory
from app.service.agent.governance import PlatformGovernance
from app.service.agent.implementations.advisor import AdvisorAgent
from app.service.agent.implementations.customer_service import CustomerServiceAgent
from app.service.agent.implementations.fund_query_demo import FundQueryDemoAgent
from app.service.agent.implementations.platform_probe import (
PROBE_ALT_TOOL,
PROBE_PERMISSION,
PROBE_TOOL,
PlatformProbeAgent,
ProbeEchoArgs,
probe_alt_tool,
probe_echo_tool,
)
from app.service.agent.implementations.risk_agent import RiskAgent
from app.service.agent.offsite_fund_agent import OffsiteFundAgent
from app.service.agent.promotion_material_agent import PromotionMaterialAgent
from app.service.asset_allocation_service import asset_allocation_tool
from app.service.customer_profile_service import (
CustomerProfileQuery,
query_customer_profile_tool,
)
from app.service.financial_nl2sql_service import query_financial_data_tool
from app.service.fund_quote_service import query_fund_quote_tool
from app.service.intent_classifier import IntentClassifier
from app.service.investment_goal_service import investment_goal_query_tool
from app.service.knowledge_search_service import KnowledgeSearchService
from app.service.knowledge_tool import knowledge_search_tool
from app.service.memory_recall_service import MemoryRecallService
from app.service.model_gateway import (
DatabaseModelEndpointResolver,
DatabaseModelGateway,
ModelDispatchService,
ModelEmbeddingService,
ModelGenerationService,
)
from app.service.portfolio_analysis_service import portfolio_analysis_tool
from app.service.product_comparison_service import product_comparison_tool
from app.service.product_recommendation_service import product_recommendation_tool
from app.service.relationship_service import RelationshipService
from app.service.risk_tools import (
get_alert_evidence_tool,
get_risk_overview_tool,
search_risk_alerts_tool,
)
from app.service.runtime_config_service import load_active_intent_configs
from app.service.suitability_service import SuitabilityToolInput, suitability_tool_handler
from app.service.tool_executor import ToolDefinition, ToolExecutor, ToolRegistry
logger = logging.getLogger(__name__)
@lru_cache(maxsize=1)
def get_model_service() -> ModelGenerationService:
"""生产模型装配的唯一入口;业务 Agent 与 Worker 记忆抽取共用同一实例。"""
return ModelGenerationService(ModelDispatchService(DatabaseModelGateway()))
@lru_cache(maxsize=1)
def get_memory_cache_adapter() -> MemoryCacheAdapter | None:
"""Redis 记忆缓存适配器;构造失败返回 None——缓存只是优化层,不得阻塞召回。"""
try:
from redis.asyncio import Redis
settings = get_settings()
client = Redis.from_url(
settings.redis_url,
socket_connect_timeout=settings.redis_connect_timeout_seconds,
socket_timeout=settings.redis_connect_timeout_seconds,
decode_responses=True,
)
return MemoryCacheAdapter(client)
except Exception:
logger.warning("memory cache adapter unavailable; recall runs without cache",
exc_info=True)
return None
@lru_cache(maxsize=1)
def get_fund_quote_cache() -> FundQuoteCache | None:
"""行情短缓存:与记忆召回共用同一个 Redis 适配器。
Redis 不可用时这里仍会返回适配器,但读写异常由 `MemoryCacheAdapter` 内部
吞掉并以 `degraded` 语义返回,行情查询会退化为直连外部数据源而不会阻塞;
适配器构造失败(例如缺少 redis 依赖)则返回 None,效果相同。缓存永远只是
优化层,不得让行情查询因缓存故障失败。
"""
adapter = get_memory_cache_adapter()
if adapter is None:
return None
return FundQuoteCache(adapter)
@lru_cache(maxsize=1)
def get_vector_memory_adapter() -> VectorMemoryAdapter | None:
"""Milvus 适配器;构造失败返回 None(语义通道关闭),不影响结构化召回。"""
try:
from pymilvus import MilvusClient # type: ignore[import-untyped]
settings = get_settings()
client = MilvusClient(uri=settings.milvus_uri, token=settings.milvus_token or None)
return VectorMemoryAdapter(client, settings.milvus_collection)
except Exception:
logger.warning("vector memory adapter unavailable; semantic recall disabled",
exc_info=True)
return None
@lru_cache(maxsize=1)
def get_memory_embedding_service() -> ModelEmbeddingService:
"""文本向量化入口:与文本生成共用同一套端点解析与受控降级。"""
return ModelEmbeddingService(ModelDispatchService(DatabaseModelGateway()))
@lru_cache(maxsize=1)
def get_milvus_knowledge_writer() -> MilvusKnowledgeWriter | None:
"""知识向量**写**适配器(装配入口);`milvus_uri` 缺失时返回 None。
与召回侧的 `get_vector_memory_adapter()` 分离:写路径不与检索进程共用客户端
(读写物理隔离,向量库故障不能从写路径传染到问答主链路)。构造是惰性的
(`MilvusKnowledgeWriter.__init__` 不连 Milvus),所以这里返回实例不代表连接可用;
真连不上时在首次写入抛 `RecoverableAgentError`,由 `OutboxWorker` 退避重试/判死信。
返回 None 的语义是**显式降级**:`WorkerRuntime` 会因此不注册
`knowledge.vector_sync_requested` / `knowledge.vector_delete_requested` 两个 handler,
事件在库里保持 pending(可观测、可重放),并在启动路径留一条 warning —— 绝不静默,
也绝不伪造同步成功。
"""
settings = get_settings()
uri = (settings.milvus_uri or "").strip()
if not uri:
logger.warning(
"milvus_uri not configured; knowledge vector writer disabled and "
"knowledge.vector_sync_requested events will stay pending"
)
return None
return MilvusKnowledgeWriter(uri, settings.milvus_token or "")
async def _embed_text(text: str) -> list[float]:
"""把文本向量化;端点来自发布配置(task_type=embedding),无端点时失败关闭。"""
endpoints = await DatabaseModelEndpointResolver().resolve(
agent_type="memory_recall", task_type="embedding"
)
if not endpoints:
raise RecoverableAgentError("没有可用的 embedding 端点")
execution = await get_memory_embedding_service().embed(endpoints, text)
return execution.vector
@lru_cache(maxsize=1)
def get_knowledge_search_service() -> KnowledgeSearchService:
"""客服知识检索装配:Milvus 客户端 + 向量化入口。
与记忆的语义通道同一取向:Milvus 不可达或缺少 embedding 端点时**不抛异常**,
而是返回 `available=False` 的实例,检索结果标记 `degraded`,由客服 Agent 走
「引导客户致电人工客服」。基础设施故障不该表现成客户可见的错误。
"""
client = None
try:
from pymilvus import MilvusClient
settings = get_settings()
client = MilvusClient(uri=settings.milvus_uri, token=settings.milvus_token or None)
except Exception:
logger.warning("knowledge vector client unavailable; search degrades", exc_info=True)
return KnowledgeSearchService(client, _embed_text)
@lru_cache(maxsize=1)
def get_relationship_service() -> RelationshipService | None:
"""图关系读服务:客户 → 产品/标签/事件 的多跳查询入口。
驱动构造失败时返回 None(图能力关闭),由调用方降级——图库不可用不该阻塞主链路,
与 Milvus 侧"语义通道缺失不影响结构化召回"是同一取向。
注意:本服务**只读**,且关系类型受 `RelationshipService.ALLOWED_RELATIONSHIPS` 白名单约束;
写入走 `GraphProjectionWorker`(由领域事件驱动),这里不提供任意写接口。
"""
driver = build_graph_driver()
if driver is None:
return None
return RelationshipService(driver)
def build_memory_recall_service(session: AsyncSession) -> MemoryRecallService:
"""记忆召回组装:结构化召回始终可用,Redis 缓存与语义通道可用时叠加。
语义通道需要**两件事同时具备**:可达的 Milvus 与可用的 embedding 端点。缺少
embedding 端点时向量化按设计失败关闭,结果标记 `embedding_failed` 并保留结构化
召回——这是配置缺口而非功能缺失,配置端点后语义召回无需改代码即可生效。
"""
vector = get_vector_memory_adapter()
return MemoryRecallService(
session,
vector=vector,
cache=get_memory_cache_adapter(),
embed=_embed_text if vector is not None else None,
)
@lru_cache(maxsize=1)
def get_agent_factory() -> AgentFactory:
"""HTTP 与 Worker 共用的唯一底座依赖组装入口。"""
registry = ToolRegistry()
# 基座验证探针的工具:只回显参数,用于端到端触发 ToolExecutor 的四种拒绝分支。
# 它不查库、不写状态,注册在这里也不会被任何业务 Agent 的白名单引用。
registry.register(ToolDefinition(
name=PROBE_TOOL,
input_model=ProbeEchoArgs,
handler=cast(Any, probe_echo_tool),
required_permission=PROBE_PERMISSION,
allowed_roles=("admin",),
))
# 第二个探针工具,两个用途:
# ① 让"有白名单但不含该工具"的分支可被构造(见 platform_probe 的说明);
# ② 让**工具层**的"角色不能使用工具"分支可被构造 —— 它的角色集合**比 Agent 的更窄**
# (Agent 允许 admin,本工具只允许 risk_operator)。必须更窄才行:Agent 层的
# validate_access 会先按 AgentDefinition.allowed_roles 拦截,两者一致时永远进不到
# 工具层的角色校验。
registry.register(ToolDefinition(
name=PROBE_ALT_TOOL,
input_model=ProbeEchoArgs,
handler=cast(Any, probe_alt_tool),
required_permission=PROBE_PERMISSION,
allowed_roles=("risk_operator",),
))
registry.register(ToolDefinition(
name="check_suitability",
input_model=SuitabilityToolInput,
handler=cast(Any, suitability_tool_handler),
required_permission="suitability:read",
allowed_roles=("customer", "advisor", "operator", "admin"),
))
registry.register(ToolDefinition(
name="query_fund_quote",
input_model=FundQuoteQuery,
handler=cast(Any, query_fund_quote_tool),
required_permission="fund:quote:read",
allowed_roles=("customer", "advisor", "operator", "risk_operator", "admin"),
# C3 数值依据:EastmoneyAdapterFactory 的单代码最坏预算为
# 4.0s(单次) + 0.2s(退避) + 4.0s(重试) = 8.2s,且 FundQuoteService
# 已按代码并发,总耗时与代码数量无关。15s ≈ 8.2s × 1.8,余量覆盖
# DNS/TLS 建连与事件循环调度开销。原值 5s 小于适配器默认单次超时
# (12s)与重试预算,多代码查询必然先撞工具超时。
timeout_seconds=15,
))
registry.register(ToolDefinition(
name="query_customer_profile",
input_model=CustomerProfileQuery,
handler=cast(Any, query_customer_profile_tool),
# 复用既有权限码:`memory:read:self` 表示"只读本人画像",客服 Agent 用它在
# 确定性识别出画像问题后取权威字段;读他人画像需要 `memory:read:customer`,
# 由服务内部按数据范围二次校验(`self` / `own_customers` / `all`)。
required_permission="memory:read:self",
allowed_roles=("customer", "advisor", "operator", "risk_operator", "admin"),
))
registry.register(ToolDefinition(
name="query_financial_data",
input_model=FinancialNL2SQLInput,
handler=cast(Any, query_financial_data_tool),
required_permission="financial:nl2sql:read",
allowed_roles=("advisor", "operator", "admin", "super_admin"),
timeout_seconds=10,
))
registry.register(ToolDefinition(
name="search_knowledge",
input_model=KnowledgeSearchInput,
handler=cast(Any, knowledge_search_tool),
# 复用既有权限码(customer 角色已具备),不新增权限点
required_permission="knowledge:reference:read",
allowed_roles=("customer", "advisor", "operator", "admin"),
# 检索含一次 embedding 调用 + 三次集合检索;embedding 实测 0.42s,
# 10s 覆盖冷启动与 Milvus 抖动,又不至于让客户等太久
timeout_seconds=10,
))
# 兼容一期发布配置和旧客户端的工具名;实现仍复用同一个只读检索处理器。
registry.register(ToolDefinition(
name="query_knowledge",
input_model=KnowledgeSearchInput,
handler=cast(Any, knowledge_search_tool),
required_permission="knowledge:query",
allowed_roles=("visitor", "customer"),
timeout_seconds=10,
))
registry.register(ToolDefinition(
name="search_risk_alerts",
input_model=RiskAlertQuery,
handler=cast(Any, search_risk_alerts_tool),
required_permission="risk:alert:read",
allowed_roles=("risk_operator", "admin"),
))
registry.register(ToolDefinition(
name="get_risk_overview",
input_model=RiskAlertQuery,
handler=cast(Any, get_risk_overview_tool),
required_permission="risk:alert:read",
allowed_roles=("risk_operator", "admin"),
))
registry.register(ToolDefinition(
name="get_alert_evidence",
input_model=RiskAlertEvidenceQuery,
handler=cast(Any, get_alert_evidence_tool),
required_permission="risk:alert:read",
allowed_roles=("risk_operator", "admin"),
))
registry.register(ToolDefinition(
name="query_investment_goal",
input_model=InvestmentGoalQuery,
handler=cast(Any, investment_goal_query_tool),
required_permission="investment-goal:read:self",
allowed_roles=("customer", "advisor", "operator", "admin"),
))
registry.register(ToolDefinition(
name="analyze_portfolio",
input_model=PortfolioAnalysisQuery,
handler=cast(Any, portfolio_analysis_tool),
required_permission="portfolio-analysis:read:self",
allowed_roles=("customer", "advisor", "operator", "admin"),
timeout_seconds=10,
))
registry.register(ToolDefinition(
name="generate_asset_allocation",
input_model=AssetAllocationQuery,
handler=cast(Any, asset_allocation_tool),
required_permission="asset-allocation:generate:self",
allowed_roles=("customer", "advisor", "operator", "admin"),
timeout_seconds=15,
))
registry.register(ToolDefinition(
name="recommend_products",
input_model=ProductRecommendationQuery,
handler=cast(Any, product_recommendation_tool),
required_permission="product-recommendation:generate:self",
allowed_roles=("customer", "advisor", "operator", "admin"),
timeout_seconds=15,
))
registry.register(ToolDefinition(
name="compare_products",
input_model=ProductComparisonQuery,
handler=cast(Any, product_comparison_tool),
required_permission="product-comparison:read:self",
allowed_roles=("customer", "advisor", "operator", "admin"),
timeout_seconds=10,
))
model_service = get_model_service()
endpoint_resolver = DatabaseModelEndpointResolver()
factory = AgentFactory(
# 记忆召回接入统一治理链:Agent 的 recall_memory 走组合召回服务,
# 而不是每个 Agent 自行决定召回方式。
governance=PlatformGovernance(recall_factory=build_memory_recall_service),
model_service=model_service,
tool_executor=ToolExecutor(registry),
intent_classifier=IntentClassifier(
model_service, config_loader=load_active_intent_configs
),
intent_endpoint_resolver=endpoint_resolver,
)
register_business_agents(factory)
return factory
def register_business_agents(factory: AgentFactory) -> None:
"""业务 Agent 的统一注册入口:组员在这里登记自己的一行 `factory.register(...)`。
HTTP 服务与 Worker 共用 `get_agent_factory()` 返回的同一个工厂,因此这里注册
一次即可在两个入口生效。注册只声明"代码允许什么":真正能调用哪些工具,还要
看当前 active 的 `config_release` 里为该 `agent_type:intent` 发布的工具白名单
(两者取交集,缺发布配置时白名单为空、工具失败关闭)。
"""
factory.register(
FundQueryDemoAgent.definition,
lambda _context: FundQueryDemoAgent(FundQueryDemoAgent.definition),
)
# 客服 Agent:只回答能溯源到公司资料的问题,答不了引导客户致电人工客服。
# 它声明了 5 个意图,但真正能调用 search_knowledge 的范围由发布配置逐意图收窄
# (`agent_tools` 里 `customer_service:<intent>`),未发布的意图工具失败关闭。
factory.register(
CustomerServiceAgent.definition,
lambda _context: CustomerServiceAgent(CustomerServiceAgent.definition),
)
factory.register(
RiskAgent.definition,
lambda _context: RiskAgent(RiskAgent.definition),
)
factory.register(
OffsiteFundAgent.definition,
lambda _context: OffsiteFundAgent(OffsiteFundAgent.definition),
)
factory.register(
PromotionMaterialAgent.definition,
lambda _context: PromotionMaterialAgent(PromotionMaterialAgent.definition),
)
# 基座验证探针:只读、无副作用,用于端到端验证工具链路的拒绝行为。
# 角色限定 admin,业务上不对外暴露用途。
factory.register(
PlatformProbeAgent.definition,
lambda _context: PlatformProbeAgent(PlatformProbeAgent.definition),
)
factory.register(
AdvisorAgent.definition,
lambda _context: AdvisorAgent(AdvisorAgent.definition),
)