import logging import re from collections.abc import Callable from typing import Protocol from sqlalchemy import select, text from sqlalchemy.ext.asyncio import AsyncSession from app.core.contracts import ( AgentDefinition, AgentResult, RecalledMemory, RequestContext, ResolvedAgentConfig, ) from app.core.errors import ForbiddenAgentError, RecoverableAgentError from app.infrastructure.db import SessionFactory from app.model.configuration import ConfigRelease from app.service.memory_recall_service import MemoryRecallService from app.service.runtime_config_service import RuntimeConfigService logger = logging.getLogger(__name__) # 组装层注入的召回服务工厂:允许带 Redis 缓存与向量库,缺省退化为纯结构化召回。 RecallServiceFactory = Callable[[AsyncSession], MemoryRecallService] class AgentGovernance(Protocol): async def resolve( self, definition: AgentDefinition, context: RequestContext ) -> ResolvedAgentConfig: ... async def recall(self, context: RequestContext) -> tuple[RecalledMemory, ...]: ... async def review( self, result: AgentResult, context: RequestContext, config: ResolvedAgentConfig, memories: tuple[RecalledMemory, ...], ) -> AgentResult: ... class PlatformGovernance: """Request-scoped snapshots; no AsyncSession or customer state retained on the singleton.""" def __init__(self, recall_factory: RecallServiceFactory | None = None) -> None: # 组装层可注入带缓存/向量库的召回服务;缺省退化为纯结构化召回, # 依赖缺失或故障只降级,不让记忆功能整体不可用。 self._recall_factory = recall_factory async def resolve( self, definition: AgentDefinition, context: RequestContext ) -> ResolvedAgentConfig: async with SessionFactory() as session: release = await session.scalar(select(ConfigRelease).where( ConfigRelease.status == "active")) tools: dict[str, tuple[str, ...]] = {} if release is not None: service = RuntimeConfigService(session) for intent in definition.supported_intents: configured = await service.allowed_tools( release.id, definition.agent_type, intent) tools[intent] = tuple(sorted(set(configured) & set(definition.allowed_tools))) rows = (await session.execute(text(""" SELECT match_type, word_pattern, applicable_agents FROM agent_negative_word WHERE status='active' AND reviewer_id IS NOT NULL AND reviewed_at IS NOT NULL """))).mappings().all() rules: list[tuple[str, str]] = [] import json for row in rows: agents = row["applicable_agents"] if isinstance(agents, str): agents = json.loads(agents) if agents and definition.agent_type not in agents: continue if row["match_type"] not in {"exact", "contains"}: # Python re has no execution timeout: never run unchecked admin regex. raise RecoverableAgentError("禁止表达正则规则需要受限匹配器") rules.append((str(row["match_type"]), str(row["word_pattern"]))) return ResolvedAgentConfig( config_version=release.release_no if release else f"code:{definition.version}", release_id=release.id if release else None, prompt_version="none", model_endpoint="", allowed_tools=(), allowed_tools_by_intent=tools, negative_rules=tuple(rules), ) async def recall(self, context: RequestContext) -> tuple[RecalledMemory, ...]: async with SessionFactory() as session: service = ( self._recall_factory(session) if self._recall_factory is not None else MemoryRecallService(session) ) customer_id = int(context.user_id) result = await service.recall(customer_id) if result.degraded: logger.warning("memory recall degraded customer_id=%s reasons=%s", customer_id, ",".join(result.degraded_reasons)) return tuple( RecalledMemory(memory_uuid=item.memory_uuid, customer_id=str(customer_id), content=item.content) for item in result.items ) async def review( self, result: AgentResult, context: RequestContext, config: ResolvedAgentConfig, memories: tuple[RecalledMemory, ...], ) -> AgentResult: return review_output(result, context, config, memories) def review_output( result: AgentResult, context: RequestContext, config: ResolvedAgentConfig, memories: tuple[RecalledMemory, ...], ) -> AgentResult: content = result.result issued_tools = {f"{context.trace_id}:{record.tool_name}" for record in content.tool_calls if record.status == "succeeded"} known = {memory.memory_uuid for memory in memories if memory.customer_id == context.user_id} for reference in content.source_references: valid = ((reference.source_type == "memory" and reference.source_id in known) or (reference.source_type == "tool" and reference.source_id in issued_tools)) if not valid: raise ForbiddenAgentError("引用未来自本次已授权召回结果") output = content.text hard_patterns = ("保证收益", "稳赚不赔", "保本保收益", "已为您下单", "已替您交易") blocked = any(word in output for word in hard_patterns) for kind, pattern in config.negative_rules: blocked |= (output == pattern if kind == "exact" else pattern in output) if blocked: content = content.model_copy(update={ "text": "该内容需要人工核实。基金投资存在风险,本系统不代客交易。", "source_references": (), "transfer_required": True, "transfer_reason": "compliance_review_required", }) else: # Apply to text and citation titles, not only to the displayed answer. def redact(value: str) -> str: value = re.sub(r"(?