Files

124 lines
5.7 KiB
Python

import re
from typing import Protocol
from sqlalchemy import select, text
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_service import MemoryService
from app.service.runtime_config_service import RuntimeConfigService
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."""
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:
rows = await MemoryService(session).recall(int(context.user_id))
return tuple(RecalledMemory(memory_uuid=row.memory_uuid,
customer_id=str(row.customer_id), content=row.content)
for row in rows)
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"(?<!\d)1[3-9]\d{9}(?!\d)", "[手机号已脱敏]", value)
return re.sub(r"(?<!\d)\d{15,19}[Xx]?(?!\d)", "[敏感号码已脱敏]", value)
content = content.model_copy(update={
"text": redact(output),
"source_references": tuple(ref.model_copy(update={
"title": redact(ref.title) if ref.title else None,
}) for ref in content.source_references),
})
return result.model_copy(update={"result": content})