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",
]