| """目标储存条件选择的回归测试(用户反馈:长期预测被误用加速数据外推)。 |
| |
| 场景还原(Demo2 data.docx):每批含 25℃/60%RH 长期(多点至 36 月)与 40℃/75%RH |
| 加速(仅 3 点至 6 月)两条件。用户指令"预测 25℃,60%RH 条件下 48 个月降解产物"。 |
| |
| 修复前缺陷: |
| - 目标时间点解析把"25℃""60%RH"中的 25/60 误当成月份; |
| - 决策引擎对每个条件都生成预测并按时间点 key 覆盖,最终保留**最后处理的加速条件**, |
| 导致用加速 6 月数据外推 48 月(is_valid=False、点估计虚高)。 |
| |
| 修复后应: |
| - 仅解析出目标时间点 [48]; |
| - 捕获用户目标条件(25℃/长期); |
| - 预测取**长期 25℃** 拟合,48 月落在 ICH 2×(36 月窗)以内 → 有效。 |
| |
| 导入路径由 tests/conftest.py 设置(底座以顶层包导入;skills 为顶层包)。 |
| """ |
|
|
| import pytest |
|
|
| from skills.stability.skill import ( |
| _target_timepoints_from_goal, |
| _target_condition_from_goal, |
| _build_intent_dict, |
| _build_intent_object, |
| _primary_series, |
| ) |
|
|
| GOAL = "请梳理附件稳定性数据并预测各批样品25℃,60%RH条件下48个月时的降解产物" |
|
|
|
|
| def _demo_data(): |
| """Demo2 风格数据:长期 25℃(8 点至 36 月)+ 加速 40℃(3 点至 6 月)。""" |
| return { |
| "batches": [{ |
| "batch_id": "Batch_1", "batch_name": "Batch_1", "batch_type": "target", |
| "conditions": [ |
| {"condition_id": "25C_60RH", "condition_type": "longterm", |
| "timepoints": [0, 3, 6, 9, 12, 18, 24, 36], |
| "cqa_data": [{"cqa_name": "降解产物", "spec_type": "upper", |
| "values": [0.1, 0.1, 0.1, 0.2, 0.2, 0.3, 0.4, 0.6]}]}, |
| {"condition_id": "40C_75RH", "condition_type": "accelerated", |
| "timepoints": [0, 3, 6], |
| "cqa_data": [{"cqa_name": "降解产物", "spec_type": "upper", |
| "values": [0.1, 0.4, 0.8]}]}, |
| ], |
| }], |
| "specification_limit": 0.5, "primary_cqa": "降解产物", "target_timepoints": [48], |
| } |
|
|
|
|
| def test_timepoints_not_polluted_by_temp_humidity(): |
| """'25℃,60%RH...48个月' 只应解析出 [48],不得混入 25 / 60。""" |
| tps = _target_timepoints_from_goal(GOAL) |
| assert tps == [48] |
| assert 25 not in tps and 60 not in tps |
|
|
|
|
| def test_target_condition_parsed_as_longterm_25c(): |
| cond = _target_condition_from_goal(GOAL) |
| assert cond is not None |
| assert cond["temp_c"] == pytest.approx(25.0) |
| assert cond["rh"] == pytest.approx(60.0) |
| assert cond["condition_type"] == "longterm" |
|
|
|
|
| def test_intent_dict_carries_target_condition(): |
| intent = _build_intent_dict(GOAL, _demo_data()) |
| assert intent["target_timepoints"] == [48] |
| assert intent["target_condition"]["condition_type"] == "longterm" |
|
|
|
|
| def test_engine_predicts_from_longterm_not_accelerated(): |
| """决策引擎应使用 25℃ 长期数据预测,48 月落在 ICH 2× 内 → 有效。""" |
| from layers.regulatory_decision_engine import RegulatoryDecisionEngine |
|
|
| data = _demo_data() |
| intent_dict = _build_intent_dict(GOAL, data) |
| data["target_condition"] = intent_dict["target_condition"] |
| intent = _build_intent_object(intent_dict, data) |
|
|
| result = RegulatoryDecisionEngine().execute(intent, data) |
|
|
| |
| assert "25C_60RH" in result.kinetic_fits |
| assert "40C_75RH" in result.kinetic_fits |
|
|
| preds = result.predictions |
| assert "48M" in preds |
| p = preds["48M"] |
| |
| assert p.point_estimate < 1.5, f"应来自长期数据的温和外推,实际 {p.point_estimate}" |
| |
| assert p.is_valid is True |
|
|
| |
| steps = {e["step"]: e for e in result.calculation_trace.entries} |
| assert "select_prediction_condition" in steps |
| assert steps["select_prediction_condition"]["outputs"]["selected_condition"] == "25C_60RH" |
|
|
|
|
| def test_primary_series_uses_longterm_condition(): |
| """建模 / 绘带的主序列也应取长期 25℃ 条件(与预测口径一致)。""" |
| data = _demo_data() |
| intent_dict = _build_intent_dict(GOAL, data) |
| data["target_condition"] = intent_dict["target_condition"] |
|
|
| series = _primary_series(data) |
| assert series is not None |
| |
| assert len(series["times"]) == 8 |
| assert max(series["times"]) == 36 |
|
|
|
|
| def test_no_target_condition_defaults_to_longterm(): |
| """未显式指定条件时,仍应默认用长期条件预测(科学正确的货架期口径)。""" |
| from layers.regulatory_decision_engine import RegulatoryDecisionEngine |
|
|
| data = _demo_data() |
| |
| goal = "预测各批样品48个月的降解产物" |
| intent_dict = _build_intent_dict(goal, data) |
| assert intent_dict.get("target_condition") is None |
| intent = _build_intent_object(intent_dict, data) |
|
|
| result = RegulatoryDecisionEngine().execute(intent, data) |
| steps = {e["step"]: e for e in result.calculation_trace.entries} |
| assert steps["select_prediction_condition"]["outputs"]["selected_condition"] == "25C_60RH" |
|
|
|
|
| |
| |
| |
|
|
| def _multi_batch_demo_data(): |
| """3 批次,各含长期 25℃(8 点至 36 月),降解速率递增以体现批间差异。""" |
| def batch(bid, vals): |
| return { |
| "batch_id": bid, "batch_name": bid, |
| "batch_type": "target" if bid == "Batch_1" else "reference", |
| "conditions": [{ |
| "condition_id": "25C_60RH", "condition_type": "longterm", |
| "timepoints": [0, 3, 6, 9, 12, 18, 24, 36], |
| "cqa_data": [{"cqa_name": "降解产物", "spec_type": "upper", "values": vals}], |
| }], |
| } |
| return { |
| "batches": [ |
| batch("Batch_1", [0.1, 0.1, 0.1, 0.2, 0.2, 0.3, 0.4, 0.6]), |
| batch("Batch_2", [0.1, 0.1, 0.2, 0.2, 0.3, 0.4, 0.5, 0.7]), |
| batch("Batch_3", [0.1, 0.2, 0.2, 0.3, 0.3, 0.4, 0.6, 0.8]), |
| ], |
| "specification_limit": 0.5, "primary_cqa": "降解产物", "target_timepoints": [48], |
| } |
|
|
|
|
| def test_per_batch_predictions_cover_all_batches(): |
| """compute 应为每个批次给出目标时间点预测(呈现批间差异)。""" |
| from kernel.skill_base import ExtractedData |
| from skills.stability.skill import StabilitySkill |
|
|
| data = _multi_batch_demo_data() |
| data["target_condition"] = {"temp_c": 25.0, "rh": 60.0, "condition_type": "longterm"} |
| payload = { |
| "data": data, |
| "intent": _build_intent_dict("预测各批长期48个月降解产物", data), |
| "extraction_method": "manual", |
| } |
| cr = StabilitySkill().compute(ExtractedData(payload=payload, method="manual")) |
|
|
| assert cr.can_proceed |
| pbp = cr.summary.get("per_batch_predictions") |
| assert pbp is not None, "应产出逐批次预测" |
| batches = {r["batch"] for r in pbp["rows"]} |
| assert batches == {"Batch_1", "Batch_2", "Batch_3"}, "三个批次都应有预测" |
| |
| assert pbp["target_timepoints"] == [48] |
| |
| by_batch = {r["batch"]: r["point_estimate"] for r in pbp["rows"] if r["timepoint"] == 48} |
| assert by_batch["Batch_1"] < by_batch["Batch_2"] < by_batch["Batch_3"] |
|
|
|
|
| def test_per_batch_predictions_rendered_in_report(): |
| """report.assemble 应渲染逐批次预测表。""" |
| from services.report_service import ReportService |
| from kernel.skill_base import ReportSections |
|
|
| class _Meta: |
| id = "stability"; display_name = "稳定性预测"; version = "1.0.0" |
|
|
| summary = { |
| "per_batch_predictions": { |
| "cqa": "降解产物", "condition": "25C_60RH", "spec_provided": False, |
| "spec_limit": None, "target_timepoints": [48], |
| "rows": [ |
| {"batch": "Batch_1", "condition": "25C_60RH", "timepoint": 48, |
| "point_estimate": 0.7531, "CI_lower": 0.6394, "CI_upper": 0.8669, |
| "is_valid": True, "R2": 0.9744, "model": "zero-order"}, |
| {"batch": "Batch_2", "condition": "25C_60RH", "timepoint": 48, |
| "point_estimate": 0.9132, "CI_lower": 0.8275, "CI_upper": 0.9989, |
| "is_valid": True, "R2": 0.9897, "model": "zero-order"}, |
| ], |
| }, |
| } |
|
|
| class _Result: |
| def __init__(self): self.summary = summary; self.figures = {} |
|
|
| html = ReportService().assemble(_Meta(), _Result(), ReportSections(sections={}), lang="zh") |
| assert "逐批次预测" in html |
| assert "Batch_1" in html and "Batch_2" in html |
| assert "0.7531" in html and "0.9132" in html |
|
|
|
|
| if __name__ == "__main__": |
| import sys |
| sys.exit(pytest.main([__file__, "-v"])) |
|
|