| """路由 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: |
| from .registry import SkillRegistry |
| from .services import Services |
| from .task_sheet import AnalysisTaskSheet |
|
|
| logger = logging.getLogger(__name__) |
|
|
| |
| 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)} 唯一匹配规则。", |
| ) |
|
|
| |
| 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 |
| else: |
| pool = self.registry.all() |
|
|
| |
| 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,请用户手动选择。", |
| ) |
|
|
| |
| |
| |
| 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="任务单处于澄清状态,需用户先确认数据类型 / 意图。", |
| ) |
|
|
| |
| 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}。", |
| ) |
|
|
| |
| 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}。", |
| ) |
|
|
| |
| 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 |
|
|
| |
| |
| |
| 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: |
| |
| 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 = 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: |
| 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) |
| |
| 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 |
| |
| 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), |
| } |
|
|
| |
| |
| |
| @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: |
| 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"] |
|
|