File size: 9,302 Bytes
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 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 | """目标储存条件选择的回归测试(用户反馈:长期预测被误用加速数据外推)。
场景还原(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"]
# 长期 k≈0.0146 → 48 月点估计远低于加速误用时的 ~5.68%。
assert p.point_estimate < 1.5, f"应来自长期数据的温和外推,实际 {p.point_estimate}"
# 48 月在长期 36 月窗的 2× 内 → 有效外推。
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
# 长期条件有 8 个点;加速仅 3 点。取到长期则点数为 8。
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"}, "三个批次都应有预测"
# 目标时间点对齐意图(48 月)。
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__": # pragma: no cover
import sys
sys.exit(pytest.main([__file__, "-v"]))
|