"""技能契约 PharmaSkill 与三层数据载体类型。 本模块定义「底座 + 可插拔 Skill」平台的核心**类型契约**,用类型签名把 三层职责锁死(需求 1.4、2.1): - Layer 1 ``extract``:可用 LLM,仅产出结构化数据(``ExtractedData``)。 - Layer 2 ``compute``:**纯 Python**,方法签名不接收任何服务 / LLM 句柄, 从源头杜绝幻觉计算(需求 2.1 的硬保证)。 - Layer 3 ``explain``:LLM 仅将只读计算结果转写为专业文本,禁止任何数值计算。 数据载体一律采用 ``dataclass``,确保三层之间传递的是结构化只读对象。 设计要点(对应 design.md「1. 技能契约」):``compute(self, data)`` **不传 ``svc``**, 任何 Skill 作者即便想在计算阶段调用 LLM,也没有句柄可用;审计追踪通过返回 ``ComputeResult.trace`` 由底座统一落库,而非在计算内部调用服务。 """ from __future__ import annotations from abc import ABC, abstractmethod from dataclasses import dataclass, field from enum import Enum from typing import TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover - 仅供类型检查,避免运行期循环依赖 # ``Services`` 容器由后续任务(kernel/services.py)实现。 # 此处以前向引用方式标注,使本契约模块可独立导入。 from .services import Services class InputKind(str, Enum): """Skill 声明可处理的输入类型,供底座路由匹配(需求 1.5)。""" FILE = "file" SMILES = "smiles" EXCIPIENT = "excipient" TEXT = "text" @dataclass class SkillMeta: """Skill 元数据:身份、展示与路由信息(需求 1.4)。""" id: str display_name: str description: str version: str input_kinds: set[InputKind] icon: str = "💊" order: int = 100 # 导航排序 #: 是否走「理解层 → 任务单 → 用户确认」两阶段流程。 #: 文档驱动、意图可能模糊的分析(稳定性 / 描述性梳理)应为 True(默认); #: 输入签名确定、用户已显式选定的技能(通用问答 / 相容性)应设为 False, #: 直接走固定流水线(extract→compute→explain),避免被意图澄清误拦。 two_phase: bool = True @dataclass class RawInput: """底座收集的原始输入(未结构化)。""" smiles: str = "" excipient: str = "" goal: str = "" files: list = field(default_factory=list) extra: dict = field(default_factory=dict) @dataclass class ExtractedData: """Layer1 输出:结构化数据。 ``payload`` 由各 Skill 定义其 schema;``method`` 标注提取方式。 """ payload: dict notes: str = "" method: str = "" # "llm" | "heuristic" | "manual" @dataclass class ComputeResult: """Layer2 输出:只读计算结果 + 图表数据 + 审计追踪。 - ``summary``:核心计算结论(k 值、R²、外推预测值、货架期等)。 - ``figures``:``chart_service`` 可渲染的结构化图表数据。 - ``trace``:计算追踪条目,由底座统一落审计库(需求 2 / 9.5)。 - ``can_proceed`` / ``refusal``:数据不足等情形的优雅拒绝(需求 8.3)。 """ summary: dict figures: dict = field(default_factory=dict) trace: list = field(default_factory=list) can_proceed: bool = True refusal: dict | None = None @dataclass class ReportSections: """Layer3 输出:各段落文本(HTML 片段)。""" sections: dict html: str = "" class PharmaSkill(ABC): """统一技能契约。 每个分析功能作为实现本契约的自包含 Skill 存在。底座固定流水线为 ``extract → compute → explain → report``。 **关键架构约束(需求 2.1)**:``compute(self, data)`` 的签名**不含** ``svc`` 等服务 / LLM 句柄——这是从类型层面阻断「大模型幻觉计算」的硬保证。 """ #: Skill 元数据。子类必须以类属性或实例属性形式提供。 meta: SkillMeta @abstractmethod def render_inputs(self, st_ctx) -> RawInput: """在 Streamlit 中渲染本 Skill 的输入面板,返回 ``RawInput``。""" @abstractmethod def extract(self, raw: RawInput, svc: "Services") -> ExtractedData: """Layer1:可用 ``svc.llm`` 做提取,仅产出结构化数据。""" @abstractmethod def compute(self, data: ExtractedData) -> ComputeResult: """Layer2:纯 Python。 注意签名无 ``svc``——禁止 LLM 介入计算。任何数值结论必须由确定性 Python 计算得出,审计追踪通过返回 ``ComputeResult.trace`` 暴露给底座。 """ @abstractmethod def explain(self, result: ComputeResult, svc: "Services") -> ReportSections: """Layer3:用 ``svc.llm`` 将只读结果转写为文字,禁止改数值。""" def can_handle(self, raw: RawInput) -> float: """返回 0..1 的可处理置信度,供 Router 兜底打分(可选覆写)。""" return 0.0 def _t(self, key: str, default: str = "") -> str: """取本 Skill 输入面板文案:优先底座注入的绑定翻译器(随语言切换), 否则回退到 ``default``(中文兜底)。 底座在渲染前通过 ``app._safe_render_inputs`` 注入 ``self._i18n_t`` (一个接收局部键、返回本地化文案的可调用对象,需求 3 / 17.4)。无注入 (如无 GUI 的纯逻辑单测)时,返回 ``default`` 以保持原有行为,不抛异常。 """ translator = getattr(self, "_i18n_t", None) if callable(translator): try: text = translator(key) # 绑定翻译器在 key 缺失时会回退为 key 本身;此时改用 default 兜底。 if text and text != key and not str(text).endswith(f".{key}"): return text except Exception: # noqa: BLE001 - 取用失败回退 default pass return default __all__ = [ "InputKind", "SkillMeta", "RawInput", "ExtractedData", "ComputeResult", "ReportSections", "PharmaSkill", ]