Preformu / core /kernel /router.py
Kevinshh's picture
feat: 意图保真(intent-fidelity) + 描述性梳理技能 + 相容性引擎升级; 修复转置宽表解析/CQA对账/澄清交互/功能切换串显; .gitignore 排除专利与机密Demo数据
0e6887b
Raw
History Blame Contribute Delete
13.5 kB
"""路由 Router:依据输入类型与用户意图选择合适的 Skill(需求 1.5)。
合规取向(对应 design.md「3. 路由 Router」):**用规则 + LLM 判断该调哪个 Skill,
但 Skill 选定后即走确定性流水线**,LLM 不参与「要不要算 / 怎么算」(需求 1.5、2)。
三级路由策略:
1. **规则匹配(确定性)**:根据 ``RawInput`` 实际携带的输入类型集合,匹配各
``SkillMeta.input_kinds``。复用现有 ``detect_analysis_type`` 的判定取向
(SMILES+辅料 → 相容性,文件+目标 → 稳定性)。为消解「文件+目标」同时命中
稳定性(``{FILE, TEXT}``)与通用问答(``{TEXT}``)的常见重叠,规则匹配只保留
**最具体(maximal)** 的候选:若候选 A 的 ``input_kinds`` 是候选 B 的真子集且
B 也被命中,则 A 被 B「支配」而剔除。如此「文件+目标」唯一命中稳定性。
2. **LLM 意图兜底**:当规则匹配仍得到多个候选(真正的歧义,互不支配)时,调用
``svc.llm`` 做意图分类,从候选中择一。LLM 不可用或失败时优雅降级到第三级。
3. **can_handle 打分兜底**:仍不确定时取候选中 ``can_handle`` 最高分者;最高分为 0
则返回「需用户手动选择」。
``Router.route`` 返回结构化的 :class:`RouteDecision`,记录所选 Skill、决策方式与
候选列表,便于审计与前端提示。
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Optional
from .skill_base import InputKind, PharmaSkill, RawInput
if TYPE_CHECKING: # pragma: no cover - 仅类型检查
from .registry import SkillRegistry
from .services import Services
from .task_sheet import AnalysisTaskSheet
logger = logging.getLogger(__name__)
#: 意图 → Skill id 映射(与 understanding._INTENT_TO_SKILL 保持一致,需求 7.1)。
INTENT_TO_SKILL = {
"descriptive_summary": "descriptive_summary",
"shelf_life_extrapolation": "stability",
"compatibility": "compatibility",
}
@dataclass
class RouteDecision:
"""路由决策结果。
- ``skill``:选定的 Skill;``None`` 表示无法自动判定,需用户手动选择。
- ``method``:决策方式,``"rule"`` | ``"llm"`` | ``"score"`` | ``"none"``。
- ``candidates``:本次参与决策的候选 Skill id 列表(供审计 / 前端展示)。
- ``needs_user_choice``:是否需要用户手动选择(``skill is None`` 时为真)。
- ``reason``:人类可读的决策理由。
"""
skill: Optional[PharmaSkill]
method: str
candidates: list[str] = field(default_factory=list)
needs_user_choice: bool = False
reason: str = ""
class Router:
"""技能路由器:规则匹配 → LLM 意图兜底 → can_handle 打分兜底。"""
def __init__(self, registry: "SkillRegistry", svc: "Services" | None = None) -> None:
self.registry = registry
self.svc = svc
# ------------------------------------------------------------------
# 公共入口
# ------------------------------------------------------------------
def route(self, raw: RawInput) -> RouteDecision:
"""对给定 ``RawInput`` 选择最合适的 Skill。"""
present = self.detect_input_kinds(raw)
# 一级:规则匹配(仅保留最具体候选)。
rule_candidates = self._rule_match(present)
if len(rule_candidates) == 1:
chosen = rule_candidates[0]
return RouteDecision(
skill=chosen,
method="rule",
candidates=[chosen.meta.id],
reason=f"输入类型 {self._kinds_str(present)} 唯一匹配规则。",
)
# 二级:规则不唯一时,用 LLM 从候选中择一。
if len(rule_candidates) > 1:
chosen = self._llm_pick(raw, rule_candidates)
if chosen is not None:
return RouteDecision(
skill=chosen,
method="llm",
candidates=[s.meta.id for s in rule_candidates],
reason="规则匹配存在多个候选,由 LLM 意图分类择一。",
)
pool = rule_candidates # LLM 不可用 / 失败 → 在候选中打分
else:
pool = self.registry.all() # 规则无候选 → 全量打分兜底
# 三级:can_handle 打分兜底。
best, score = self._best_by_score(raw, pool)
if best is not None and score > 0:
return RouteDecision(
skill=best,
method="score",
candidates=[s.meta.id for s in pool],
reason=f"can_handle 打分择优(最高分 {score:.2f})。",
)
return RouteDecision(
skill=None,
method="none",
candidates=[s.meta.id for s in pool],
needs_user_choice=True,
reason="无法自动判定合适的 Skill,请用户手动选择。",
)
# ------------------------------------------------------------------
# 意图路由(intent-understanding-layer 任务 8)
# ------------------------------------------------------------------
def route_from_sheet(self, sheet: "AnalysisTaskSheet") -> RouteDecision:
"""依据已确认的《分析任务单》路由到 Skill(需求 3.5 / 7.1)。
优先级:
1. 用户编辑过的 ``proposed_skill_id`` 若能解析为已注册 Skill → 采用。
2. 否则按 ``sheet.intent`` 经 :data:`INTENT_TO_SKILL` 映射。
3. 意图为 UNKNOWN / 无映射 / 解析不到 → ``needs_user_choice=True``。
澄清态任务单(``needs_clarification()``)不应进入路由——调用方应先处理澄清;
此处仍稳健地返回 ``needs_user_choice``。
"""
# 澄清态:交回用户选择。
if getattr(sheet, "needs_clarification", lambda: False)():
return RouteDecision(
skill=None, method="none",
needs_user_choice=True,
reason="任务单处于澄清状态,需用户先确认数据类型 / 意图。",
)
# 1) 用户编辑的 proposed_skill_id 优先。
proposed = getattr(sheet, "proposed_skill_id", "") or ""
if proposed:
skill = self.registry.get(proposed)
if skill is not None:
return RouteDecision(
skill=skill, method="sheet",
candidates=[proposed],
reason=f"采用任务单建议 / 用户指定的 Skill:{proposed}。",
)
# 2) 意图映射。
intent_value = getattr(getattr(sheet, "intent", None), "value", None)
mapped_id = INTENT_TO_SKILL.get(intent_value)
if mapped_id:
skill = self.registry.get(mapped_id)
if skill is not None:
return RouteDecision(
skill=skill, method="intent",
candidates=[mapped_id],
reason=f"按意图 {intent_value} 路由到 {mapped_id}。",
)
# 3) 无法判定。
return RouteDecision(
skill=None, method="none",
needs_user_choice=True,
reason="无法依据任务单意图路由,请用户手动选择。",
)
# ------------------------------------------------------------------
# 一级:规则匹配
# ------------------------------------------------------------------
@staticmethod
def detect_input_kinds(raw: RawInput) -> set[InputKind]:
"""从 ``RawInput`` 推断其实际携带的输入类型集合。"""
kinds: set[InputKind] = set()
if raw.files:
kinds.add(InputKind.FILE)
if raw.smiles and raw.smiles.strip():
kinds.add(InputKind.SMILES)
if raw.excipient and raw.excipient.strip():
kinds.add(InputKind.EXCIPIENT)
if raw.goal and raw.goal.strip():
kinds.add(InputKind.TEXT)
return kinds
def _rule_match(self, present: set[InputKind]) -> list[PharmaSkill]:
"""返回声明的 ``input_kinds`` 被 ``present`` 完全覆盖且最具体的候选 Skill。"""
matched = [
s
for s in self.registry.all()
if s.meta.input_kinds and s.meta.input_kinds <= present
]
# 仅保留「最具体」候选:剔除被其它候选真超集支配者,消解输入类型重叠歧义。
maximal: list[PharmaSkill] = []
for skill in matched:
dominated = any(
other is not skill and skill.meta.input_kinds < other.meta.input_kinds
for other in matched
)
if not dominated:
maximal.append(skill)
return maximal
# ------------------------------------------------------------------
# 二级:LLM 意图兜底
# ------------------------------------------------------------------
def _llm_pick(
self, raw: RawInput, candidates: list[PharmaSkill]
) -> Optional[PharmaSkill]:
"""调用 ``svc.llm`` 在候选中做意图分类;不可用 / 失败时返回 ``None``。"""
llm = getattr(self.svc, "llm", None)
if llm is None:
return None
goal = (raw.goal or "").strip()
try:
# 优先使用 LLM 服务的专用意图分类钩子(若提供)。
classify = getattr(llm, "classify_intent", None)
if callable(classify):
chosen_id = classify(goal, [self._meta_brief(s) for s in candidates])
return self._match_id(chosen_id, candidates)
# 退一步:使用通用 complete 接口并解析返回的 id。
complete = getattr(llm, "complete", None)
if callable(complete):
chosen_id = self._classify_via_complete(complete, goal, candidates)
return self._match_id(chosen_id, candidates)
except Exception: # noqa: BLE001 - LLM 兜底失败不得中断路由
logger.warning("LLM 意图兜底失败,降级到 can_handle 打分。", exc_info=True)
return None
return None
@staticmethod
def _classify_via_complete(complete, goal: str, candidates: list[PharmaSkill]):
"""用通用 ``complete(system, user)`` 接口做意图分类,返回所选 id 文本。"""
options = "\n".join(
f"- {s.meta.id}: {s.meta.display_name} —— {s.meta.description}"
for s in candidates
)
system = (
"你是药学分析平台的意图路由器。根据用户目标,从候选技能中选出最合适的一个,"
"只输出该技能的 id,不要任何解释或多余字符。"
)
user = f"用户目标:{goal or '(未提供文字目标)'}\n\n候选技能:\n{options}"
result = complete(system, user, temperature=0.0)
# complete 可能返回字符串或带 .text 字段的结果对象。
text = getattr(result, "text", result)
return str(text).strip() if text is not None else None
@staticmethod
def _match_id(
chosen_id, candidates: list[PharmaSkill]
) -> Optional[PharmaSkill]:
"""把 LLM 返回的 id 文本匹配回候选 Skill;匹配失败返回 ``None``。"""
if not chosen_id:
return None
needle = str(chosen_id).strip()
# 精确匹配优先。
for skill in candidates:
if skill.meta.id == needle:
return skill
# 容错:LLM 输出可能包含额外文字,做包含匹配。
for skill in candidates:
if skill.meta.id and skill.meta.id in needle:
return skill
return None
@staticmethod
def _meta_brief(skill: PharmaSkill) -> dict:
"""构造供 LLM 分类用的候选元数据摘要。"""
meta = skill.meta
return {
"id": meta.id,
"display_name": meta.display_name,
"description": meta.description,
"input_kinds": sorted(k.value for k in meta.input_kinds),
}
# ------------------------------------------------------------------
# 三级:can_handle 打分兜底
# ------------------------------------------------------------------
@staticmethod
def _best_by_score(
raw: RawInput, pool: list[PharmaSkill]
) -> tuple[Optional[PharmaSkill], float]:
"""返回 ``pool`` 中 ``can_handle`` 最高分的 Skill 与其分值。"""
best: Optional[PharmaSkill] = None
best_score = 0.0
for skill in pool:
try:
score = float(skill.can_handle(raw))
except Exception: # noqa: BLE001 - 单个打分异常不得影响整体
logger.debug("Skill %s 的 can_handle 打分异常。", skill.meta.id, exc_info=True)
score = 0.0
if score > best_score:
best, best_score = skill, score
return best, best_score
# ------------------------------------------------------------------
@staticmethod
def _kinds_str(kinds: set[InputKind]) -> str:
return "{" + ", ".join(sorted(k.value for k in kinds)) + "}"
__all__ = ["Router", "RouteDecision"]