"""统一意图分类出口,业务 Agent 不得自行解析模型分类结果。 运行期可选的配置来源是 `agent_intent_config` 中 `status='active'` 的行:装载后只用于 **增强分类提示与置信度阈值**,不改变任何既有契约——仍然是严格 JSON 输出、格式违约与 未声明意图一律失败关闭、意图必须落在 `AgentDefinition.supported_intents` 内、 `needs_clarification = confidence < 阈值`。 """ import json from collections.abc import Awaitable, Callable from typing import Any, Protocol from pydantic import BaseModel, ConfigDict, Field, ValidationError from app.core.contracts import IntentResult from app.core.errors import RecoverableAgentError, ValidationAgentError from app.service.model_gateway import ModelGenerationService class IntentEndpointResolver(Protocol): async def resolve(self, *, agent_type: str, task_type: str) -> list[Any]: ... class IntentConfigEntry(BaseModel): """`agent_intent_config` 生效行在分类链路上的投影(只带分类用得到的字段)。""" model_config = ConfigDict(frozen=True) intent_code: str = Field(min_length=1, max_length=64) intent_name: str = Field(default="", max_length=128) description: str | None = Field(default=None, max_length=500) examples: tuple[str, ...] = () classifier_instruction: str | None = None confidence_threshold: float = Field(default=0.6, ge=0, le=1) # 装载器按 `agent_type` 读取当前生效的意图配置;无配置时返回空元组。 IntentConfigLoader = Callable[[str], Awaitable[tuple[IntentConfigEntry, ...]]] class _IntentPayload(BaseModel): model_config = ConfigDict(extra="forbid") intent: str = Field(min_length=1, max_length=64) confidence: float = Field(ge=0, le=1) class IntentClassifier: """使用已路由模型输出严格 JSON,并绑定 Agent 声明的意图集合。""" def __init__( self, model_service: ModelGenerationService, *, threshold: float = 0.65, config_loader: IntentConfigLoader | None = None, ) -> None: if not 0 <= threshold <= 1: raise ValueError("intent threshold must be between 0 and 1") self.model_service = model_service self.threshold = threshold self._config_loader = config_loader @property def config_loader(self) -> IntentConfigLoader | None: """暴露装载器便于装配自检(生产装配必须注入,测试可缺省)。""" return self._config_loader async def classify( self, *, message: str, supported_intents: tuple[str, ...], endpoints: list[Any], agent_type: str | None = None, ) -> IntentResult: if not message.strip(): raise ValidationAgentError("待分类消息不能为空") if not supported_intents: raise ValidationAgentError("Agent 未声明支持的意图") # 只认 AgentDefinition 声明过的意图:配置里多出来的意图既不进提示,也不放宽校验。 loaded = await self._load(agent_type) configured = tuple( entry for entry in loaded if entry.intent_code in supported_intents ) prompt = self._build_prompt(message, supported_intents, configured) execution = await self.model_service.generate(endpoints, prompt) payload = self._parse(execution.text) if payload.intent not in supported_intents: raise ValidationAgentError("模型返回了未声明的意图") return IntentResult( intent=payload.intent, confidence=payload.confidence, needs_clarification=payload.confidence < self._threshold_for(payload.intent, configured), ) async def _load(self, agent_type: str | None) -> tuple[IntentConfigEntry, ...]: if self._config_loader is None or agent_type is None: return () return await self._config_loader(agent_type) def _threshold_for(self, intent: str, configured: tuple[IntentConfigEntry, ...]) -> float: """阈值语义不变(confidence < 阈值 → 需澄清),阈值取值优先生效配置。""" for entry in configured: if entry.intent_code == intent: return entry.confidence_threshold return self.threshold @staticmethod def _build_prompt( message: str, supported_intents: tuple[str, ...], configured: tuple[IntentConfigEntry, ...], ) -> str: lines = [ "请仅输出 JSON,不要 Markdown。字段必须为 intent 和 confidence。", f"intent 只能从 {list(supported_intents)!r} 中选择,confidence 为 0 到 1 的数字。", ] if configured: lines.append("平台配置的意图说明:") for entry in configured: detail = f"- {entry.intent_code}" if entry.intent_name: detail += f"({entry.intent_name})" if entry.description: detail += f":{entry.description}" if entry.examples: detail += f";示例:{' / '.join(entry.examples)}" lines.append(detail) if entry.classifier_instruction: lines.append(f" 分类要求:{entry.classifier_instruction}") lines.append(f"用户消息:{message}") return "\n".join(lines) @staticmethod def _parse(text: str) -> _IntentPayload: candidate = text.strip() if candidate.startswith("```"): candidate = candidate.removeprefix("```") candidate = candidate.removeprefix("json").removesuffix("```").strip() try: value = json.loads(candidate) return _IntentPayload.model_validate(value) except (json.JSONDecodeError, ValidationError, TypeError) as exc: raise RecoverableAgentError("模型意图输出不是有效 JSON") from exc