File size: 6,269 Bytes
19729e9 0e6887b 19729e9 0e6887b 19729e9 0e6887b 19729e9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 | """技能契约 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",
]
|