| """相容性 Skill(``skills.compatibility.skill``)三层契约的集成测试(任务 18)。 |
| |
| 覆盖需求 8.2 与平台契约: |
| - extract:规范化 SMILES / 辅料、识别官能团、渲染结构图(RDKit 可选→降级无图)。 |
| - compute:**纯 Python** 确定性风险分级(签名无 svc,需求 2.1);数据不足优雅拒绝(8.3)。 |
| - explain:复用 professional_analyzer + 相容性 prompts,LLM 仅写文字、不改数值(2.3)。 |
| - 结构图与风险色块经 ChartService / ReportService 呈现。 |
| |
| 测试自包含:svc.llm 用 stub;RDKit 相关断言用 importorskip 守护;不触网。 |
| 导入路径由 tests/conftest.py 设置(platform 目录与仓库根加入 sys.path)。 |
| """ |
|
|
| from __future__ import annotations |
|
|
| import importlib.util |
| import inspect |
|
|
| import pytest |
|
|
| from kernel.services import Services |
| from kernel.skill_base import ComputeResult, ExtractedData, PharmaSkill, RawInput |
| from skills.compatibility import rules |
| from skills.compatibility.skill import SKILL, CompatibilitySkill |
|
|
| _RDKIT_AVAILABLE = importlib.util.find_spec("rdkit") is not None |
|
|
|
|
| |
| |
| |
|
|
| class _StubLLMResult: |
| def __init__(self, ok: bool, content: str = "") -> None: |
| self.ok = ok |
| self.content = content |
|
|
|
|
| class _StubLLM: |
| """记录调用并返回固定文本的 LLM 桩。""" |
|
|
| def __init__(self, ok: bool = True, content: str = "一、机理分析……\n二、[必须] 控制限度……") -> None: |
| self._ok = ok |
| self._content = content |
| self.calls: list[tuple[str, str]] = [] |
|
|
| def complete(self, system: str, user: str, *, temperature: float = 0.3, **kwargs): |
| self.calls.append((system, user)) |
| return _StubLLMResult(self._ok, self._content if self._ok else "") |
|
|
|
|
| def _svc(llm=None, lang: str = "zh") -> Services: |
| return Services(llm=llm, lang=lang) |
|
|
|
|
| |
| |
| |
|
|
| def test_compute_signature_has_no_service_handle(): |
| """compute(self, data) 的签名不含 svc / llm 句柄(架构级阻断幻觉计算)。""" |
| sig = inspect.signature(CompatibilitySkill.compute) |
| params = list(sig.parameters) |
| assert params == ["self", "data"] |
| assert "svc" not in params and "llm" not in params |
|
|
|
|
| def test_skill_is_pharmaskill_and_registered_instance(): |
| assert isinstance(SKILL, CompatibilitySkill) |
| assert isinstance(SKILL, PharmaSkill) |
| assert SKILL.meta.id == "compatibility" |
| |
| kinds = {k.value for k in SKILL.meta.input_kinds} |
| assert "smiles" in kinds and "excipient" in kinds |
|
|
|
|
| |
| |
| |
|
|
| def test_compute_grades_risk_from_extracted_data(): |
| """compute 对 extract 产物做确定性分级(伯胺 + 乳糖 → 高风险)。""" |
| data = ExtractedData( |
| payload={ |
| "smiles": "NCCO", |
| "api_name": "Test API", |
| "excipients": ["乳糖"], |
| "functional_groups": [{"id": "primary_amine"}], |
| "structure_image": None, |
| }, |
| method="manual", |
| ) |
| result = SKILL.compute(data) |
| assert isinstance(result, ComputeResult) |
| assert result.can_proceed is True |
| assert result.summary["overall_risk"] == rules.RISK_HIGH |
| assert result.summary["n_high"] >= 1 |
| assert result.trace |
|
|
|
|
| def test_compute_refuses_when_no_excipients(): |
| """无辅料时 compute 优雅拒绝(need 8.3)。""" |
| data = ExtractedData(payload={"smiles": "CCO", "excipients": [], "functional_groups": []}) |
| result = SKILL.compute(data) |
| assert result.can_proceed is False |
| assert result.refusal is not None |
| assert "辅料" in result.refusal["reason"] |
|
|
|
|
| def test_compute_injects_structure_figure_when_available(): |
| """extract 渲染出结构图时,compute 把其放入 figures 供报告注入。""" |
| data = ExtractedData( |
| payload={ |
| "smiles": "CCO", |
| "excipients": ["乳糖"], |
| "functional_groups": [{"id": "primary_amine"}], |
| "structure_image": "data:image/png;base64,AAAA", |
| } |
| ) |
| result = SKILL.compute(data) |
| assert "structure" in result.figures |
| assert result.figures["structure"]["image_base64"].startswith("data:image/png") |
|
|
|
|
| def test_compute_is_deterministic(): |
| data = ExtractedData( |
| payload={ |
| "smiles": "NCCO", |
| "excipients": ["乳糖", "硬脂酸镁"], |
| "functional_groups": [{"id": "primary_amine"}, {"id": "ester"}], |
| } |
| ) |
| first = SKILL.compute(data).summary |
| again = SKILL.compute(data).summary |
| assert first == again |
|
|
|
|
| |
| |
| |
|
|
| def _assemble_html(result, sections, lang: str = "zh") -> str: |
| """用 ReportService 组装相容性报告 HTML(矩阵/结构/KPI 由 report_service 渲染)。""" |
| from services.report_service import ReportService |
|
|
| return ReportService().assemble(SKILL.meta, result, sections, lang=lang) |
|
|
|
|
| def test_explain_returns_only_mechanism_and_matrix_rendered_by_report_service(): |
| data = ExtractedData( |
| payload={ |
| "smiles": "NCCO", |
| "api_name": "Test API", |
| "excipients": ["乳糖"], |
| "functional_groups": [{"id": "primary_amine"}], |
| "structure_image": None, |
| } |
| ) |
| result = SKILL.compute(data) |
| llm = _StubLLM(ok=True) |
| sections = SKILL.explain(result, _svc(llm)) |
|
|
| |
| assert len(llm.calls) == 1 |
| |
| assert "机理分析" in sections.sections["mechanism"] |
| |
| assert "risk_matrix" not in sections.sections |
| assert "structure" not in sections.sections |
| |
| assert result.summary["risk_matrix_2d"]["rows"] |
| html = _assemble_html(result, sections, "zh") |
| assert "高风险" in html |
| assert "compat-matrix" in html |
|
|
|
|
| def test_explain_does_not_alter_compute_numbers(): |
| """explain 不得修改 compute 的风险分级(需求 2.3)。""" |
| data = ExtractedData( |
| payload={ |
| "smiles": "NCCO", |
| "excipients": ["乳糖"], |
| "functional_groups": [{"id": "primary_amine"}], |
| } |
| ) |
| result = SKILL.compute(data) |
| overall_before = result.summary["overall_risk"] |
| SKILL.explain(result, _svc(_StubLLM(ok=True))) |
| assert result.summary["overall_risk"] == overall_before |
|
|
|
|
| def test_explain_degrades_gracefully_without_llm(): |
| """svc.llm 为 None 时,explain 用确定性摘要兜底,不崩溃。""" |
| data = ExtractedData( |
| payload={ |
| "smiles": "NCCO", |
| "excipients": ["乳糖"], |
| "functional_groups": [{"id": "primary_amine"}], |
| } |
| ) |
| result = SKILL.compute(data) |
| sections = SKILL.explain(result, _svc(llm=None)) |
| assert "mechanism" in sections.sections |
| |
| assert sections.sections["mechanism"] |
|
|
|
|
| def test_explain_falls_back_when_llm_fails(): |
| data = ExtractedData( |
| payload={"smiles": "NCCO", "excipients": ["乳糖"], "functional_groups": [{"id": "primary_amine"}]} |
| ) |
| result = SKILL.compute(data) |
| sections = SKILL.explain(result, _svc(_StubLLM(ok=False))) |
| assert sections.sections["mechanism"] |
|
|
|
|
| def test_explain_english_language(): |
| data = ExtractedData( |
| payload={"smiles": "NCCO", "excipients": ["乳糖"], "functional_groups": [{"id": "primary_amine"}]} |
| ) |
| result = SKILL.compute(data) |
| sections = SKILL.explain(result, _svc(_StubLLM(ok=True), lang="en")) |
| |
| html = _assemble_html(result, sections, "en") |
| assert "High risk" in html |
|
|
|
|
| |
| |
| |
|
|
| def test_extract_parses_excipients_heuristically(): |
| """无 RDKit 依赖路径:辅料按行解析,payload schema 完整。""" |
| raw = RawInput(smiles="", excipient="乳糖\n硬脂酸镁") |
| data = SKILL.extract(raw, _svc()) |
| assert isinstance(data, ExtractedData) |
| assert "乳糖" in data.payload["excipients"] |
| assert "硬脂酸镁" in data.payload["excipients"] |
| |
| assert "未提供 SMILES" in data.notes |
|
|
|
|
| @pytest.mark.skipif(not _RDKIT_AVAILABLE, reason="RDKit 未安装,跳过结构识别断言") |
| def test_extract_identifies_functional_groups_with_rdkit(): |
| """RDKit 可用时,乙醇胺(NCCO)应识别出伯胺并渲染结构图。""" |
| raw = RawInput(smiles="NCCO", excipient="乳糖") |
| data = SKILL.extract(raw, _svc()) |
| group_ids = { |
| (g.get("id") if isinstance(g, dict) else g) |
| for g in data.payload["functional_groups"] |
| } |
| assert "primary_amine" in group_ids |
| |
| assert data.payload["structure_image"] is None or data.payload["structure_image"].startswith("data:image") |
|
|
|
|
| def test_extract_degrades_when_rdkit_unavailable(monkeypatch): |
| """RDKit 不可用时,extract 跳过结构图与官能团识别但不崩溃。""" |
| |
| class _NoRDKit: |
| is_available = False |
|
|
| skill = CompatibilitySkill(molecule_renderer=_NoRDKit()) |
| raw = RawInput(smiles="NCCO", excipient="乳糖") |
| data = skill.extract(raw, _svc()) |
| assert data.payload["functional_groups"] == [] |
| assert data.payload["structure_image"] is None |
| assert "RDKit 不可用" in data.notes |
|
|
|
|
| |
| |
| |
|
|
| def test_end_to_end_pipeline_smiles_plus_excipient(): |
| raw = RawInput(smiles="NCCO", excipient="乳糖") |
| llm = _StubLLM(ok=True) |
| svc = _svc(llm) |
|
|
| data = SKILL.extract(raw, svc) |
| result = SKILL.compute(data) |
| sections = SKILL.explain(result, svc) |
|
|
| assert result.can_proceed is True |
| assert result.summary["overall_risk"] in ( |
| rules.RISK_NONE, rules.RISK_LOW, rules.RISK_MEDIUM, rules.RISK_HIGH |
| ) |
| |
| assert "mechanism" in sections.sections |
| assert "risk_matrix" not in sections.sections |
|
|
|
|
| def test_can_handle_scoring(): |
| assert SKILL.can_handle(RawInput(smiles="CCO", excipient="乳糖")) == pytest.approx(0.9) |
| assert SKILL.can_handle(RawInput(excipient="乳糖")) == pytest.approx(0.6) |
| assert SKILL.can_handle(RawInput(smiles="CCO")) == pytest.approx(0.4) |
| assert SKILL.can_handle(RawInput()) == pytest.approx(0.0) |
|
|