"""意图保真:意图槽位(IntentSlot)派生与不变量测试。 覆盖「确保用户真实意图被采纳」的核心保真规则: - 用户明示 → STATED;仅可推断 → INFERRED;缺失 / 歧义 → MISSING(**绝不填默认**)。 - 选择性澄清:仅对「影响结论且不确定且未锁定」的槽位追问。 - 用户锁定后不再被澄清(采纳真实意图的硬保证)。 - 序列化往返一致(含 intent_slots)。 """ from __future__ import annotations from kernel.task_sheet import ( AnalysisTaskSheet, ExtractedItem, Intent, IntentSlot, SlotState, ) from kernel.understanding import ( SLOT_PRIMARY_CQA, SLOT_SPEC_LIMIT, SLOT_TARGET_TIMEPOINTS, build_intent_slots, ) def _slot(slots, name): return next((s for s in slots if s.name == name), None) # --------------------------------------------------------------------------- # build_intent_slots:来源态判定 # --------------------------------------------------------------------------- def test_explicit_goal_yields_stated_slots(): """用户明示主 CQA 与目标时间点 → STATED,置信 1.0。""" slots = build_intent_slots( goal="请把总杂质外推到36个月", intent=Intent.SHELF_LIFE, extracted_items=[] ) tp = _slot(slots, SLOT_TARGET_TIMEPOINTS) cqa = _slot(slots, SLOT_PRIMARY_CQA) assert tp.state is SlotState.STATED and tp.value == [36] and tp.confidence == 1.0 assert cqa.state is SlotState.STATED and cqa.value == "总杂" def test_missing_slots_have_no_default_values(): """指令与文档都未给出关键槽位 → MISSING 且 value 为 None(禁止 0.5 / 总杂质 / [24,36])。""" slots = build_intent_slots( goal="预测一下稳定性", intent=Intent.SHELF_LIFE, extracted_items=[] ) tp = _slot(slots, SLOT_TARGET_TIMEPOINTS) cqa = _slot(slots, SLOT_PRIMARY_CQA) assert tp.state is SlotState.MISSING and tp.value is None assert cqa.state is SlotState.MISSING and cqa.value is None # 关键:绝无 ASSUMED 态,也无任何被悄悄填入的默认值。 assert all(s.state is not SlotState.MISSING or s.value is None for s in slots) def test_single_attribute_infers_cqa(): """文档仅含单一属性 → 主 CQA 推断为 INFERRED(带来源 evidence)。""" items = [ExtractedItem(field="总杂", value="0.5", group={"attribute": "总杂"})] slots = build_intent_slots( goal="外推到24个月", intent=Intent.SHELF_LIFE, extracted_items=items ) cqa = _slot(slots, SLOT_PRIMARY_CQA) assert cqa.state is SlotState.INFERRED assert cqa.value == "总杂" assert cqa.evidence def test_multiple_attributes_are_ambiguous_not_guessed(): """文档含多个属性且指令未指定 → 主 CQA 视为歧义 → MISSING(不臆断择一)。""" items = [ ExtractedItem(field="总杂", value="0.5", group={"attribute": "总杂"}), ExtractedItem(field="含量", value="99.5", group={"attribute": "含量"}), ] slots = build_intent_slots( goal="外推到24个月", intent=Intent.SHELF_LIFE, extracted_items=items ) cqa = _slot(slots, SLOT_PRIMARY_CQA) assert cqa.state is SlotState.MISSING and cqa.value is None def test_spec_limit_inferred_from_items(): items = [ExtractedItem(field="spec_limit", value="总杂≤2.0%", source_ref="限度 总杂≤2.0%")] slots = build_intent_slots( goal="把总杂外推到36个月", intent=Intent.SHELF_LIFE, extracted_items=items ) spec = _slot(slots, SLOT_SPEC_LIMIT) assert spec.state is SlotState.INFERRED and spec.value == "总杂≤2.0%" def test_non_shelf_life_intent_yields_no_slots(): """相容性 / 未知意图暂不强加槽位,保持向后兼容(描述性梳理见下方专项测试)。""" assert build_intent_slots("相容性评估", Intent.COMPATIBILITY, []) == [] assert build_intent_slots("随便问问", Intent.UNKNOWN, []) == [] # --------------------------------------------------------------------------- # 选择性澄清(信息价值驱动) + 锁定粘性 # --------------------------------------------------------------------------- def test_selective_clarification_targets_only_result_affecting_missing(): """缺失的目标时间点 / 主 CQA(影响结论)应被追问;缺失规格(可降级)不强制追问。""" slots = build_intent_slots("预测稳定性", Intent.SHELF_LIFE, []) sheet = AnalysisTaskSheet(intent=Intent.SHELF_LIFE, intent_slots=slots) names = {s.name for s in sheet.slots_needing_clarification()} assert SLOT_TARGET_TIMEPOINTS in names assert SLOT_PRIMARY_CQA in names assert SLOT_SPEC_LIMIT not in names # affects_result=False → 不过度澄清 def test_locked_slot_is_not_reclarified(): """用户锁定的缺失槽位不再触发澄清(采纳真实意图,模型不得推翻用户决定)。""" s = IntentSlot(name=SLOT_TARGET_TIMEPOINTS, value=None, state=SlotState.MISSING, affects_result=True, locked=True) assert s.needs_clarification() is False def test_stated_slot_does_not_need_clarification(): s = IntentSlot(name=SLOT_PRIMARY_CQA, value="总杂", state=SlotState.STATED, confidence=1.0, affects_result=True) assert s.needs_clarification() is False # --------------------------------------------------------------------------- # 序列化往返(含 intent_slots) # --------------------------------------------------------------------------- def test_intent_slot_roundtrip(): s = IntentSlot(name=SLOT_TARGET_TIMEPOINTS, value=[24, 36], state=SlotState.STATED, confidence=1.0, evidence="36个月", locked=True, affects_result=True) assert IntentSlot.from_dict(s.to_dict()) == s def test_sheet_with_slots_roundtrip(): slots = build_intent_slots("把总杂外推到36个月", Intent.SHELF_LIFE, []) sheet = AnalysisTaskSheet(intent=Intent.SHELF_LIFE, intent_slots=slots, proposed_skill_id="stability") assert AnalysisTaskSheet.from_dict(sheet.to_dict()) == sheet def test_get_slot_helper(): slots = build_intent_slots("外推到36个月", Intent.SHELF_LIFE, []) sheet = AnalysisTaskSheet(intent=Intent.SHELF_LIFE, intent_slots=slots) assert sheet.get_slot(SLOT_TARGET_TIMEPOINTS) is not None assert sheet.get_slot("nonexistent") is None # --------------------------------------------------------------------------- # 描述性梳理意图保真推广(需求 15) # --------------------------------------------------------------------------- from kernel.understanding import SLOT_TARGET_ATTRIBUTES # noqa: E402 def test_descriptive_target_attributes_stated_when_goal_names_them(): """指令点名质量属性 → target_attributes 为 STATED,值为属性列表。""" slots = build_intent_slots("梳理总杂和含量", Intent.DESCRIPTIVE_SUMMARY, []) s = next(s for s in slots if s.name == SLOT_TARGET_ATTRIBUTES) assert s.state is SlotState.STATED assert "总杂" in s.value and "含量" in s.value # 描述性梳理目标属性不强制澄清(未指定时默认梳理全部)。 assert s.affects_result is False def test_descriptive_target_attributes_missing_when_unspecified(): """未点名属性 → MISSING 且 value 为 None(不臆造默认),但不强制澄清。""" slots = build_intent_slots("帮我梳理一下检测数据", Intent.DESCRIPTIVE_SUMMARY, []) s = next(s for s in slots if s.name == SLOT_TARGET_ATTRIBUTES) assert s.state is SlotState.MISSING assert s.value is None assert s.affects_result is False # affects_result=False → 不进入选择性澄清(默认梳理全部)。 sheet = AnalysisTaskSheet(intent=Intent.DESCRIPTIVE_SUMMARY, intent_slots=slots) assert sheet.slots_needing_clarification() == [] def test_descriptive_skill_filters_to_confirmed_attributes(): """描述性技能尊重已确认/锁定的 target_attributes,按属性过滤观测(用户说了算)。""" from kernel.skill_base import RawInput from kernel.services import Services from skills.descriptive_summary.skill import SKILL task_sheet = { "extracted_items": [ {"field": "总杂", "value": "0.30", "group": {"strength": "20μg", "attribute": "总杂"}}, {"field": "含量", "value": "99.5", "group": {"strength": "20μg", "attribute": "含量"}}, {"field": "水分", "value": "3.1", "group": {"strength": "20μg", "attribute": "水分"}}, ], "intent_slots": [ {"name": SLOT_TARGET_ATTRIBUTES, "value": ["总杂"], "state": "stated", "locked": True}, ], } raw = RawInput(goal="梳理总杂", extra={"task_sheet": task_sheet}) data = SKILL.extract(raw, Services()) attrs = {o["attribute"] for o in data.payload["observations"]} assert attrs == {"总杂"} # 仅保留用户确认的属性 assert "filtered to user-confirmed attributes" in data.notes def test_descriptive_skill_no_confirmation_keeps_all(): """未确认 target_attributes → 梳理全部观测(向后兼容、行为零变化)。""" from kernel.skill_base import RawInput from kernel.services import Services from skills.descriptive_summary.skill import SKILL task_sheet = { "extracted_items": [ {"field": "总杂", "value": "0.30", "group": {"attribute": "总杂"}}, {"field": "含量", "value": "99.5", "group": {"attribute": "含量"}}, ], "intent_slots": [ {"name": SLOT_TARGET_ATTRIBUTES, "value": None, "state": "missing", "locked": False}, ], } raw = RawInput(goal="梳理数据", extra={"task_sheet": task_sheet}) data = SKILL.extract(raw, Services()) attrs = {o["attribute"] for o in data.payload["observations"]} assert attrs == {"总杂", "含量"} # 全部保留