| """《分析任务单》数据模型(理解层产物,intent-understanding-layer 任务 1)。 |
| |
| 对应 design.md「Components and Interfaces / 1. Data model」。本模块定义理解层 |
| (``UnderstandingLayer``)在正式分析(``compute``)之前产出的**强结构化、可审计、 |
| 可序列化**的中间契约——《分析任务单》(:class:`AnalysisTaskSheet`),以及其内嵌的 |
| 文档画像、提取项与澄清决策类型。 |
| |
| 设计取向(与需求 / 设计一致): |
| |
| - **不含任何分析数值计算**:任务单只承载「文档画像 + 意图 + 来源可定位的提取项 + |
| 缺失/歧义 + 澄清决策」,所有分析数值由后续 ``compute`` 阶段(纯 Python)产出 |
| (需求 1.4、Property 4)。 |
| - **可序列化且往返一致**::meth:`AnalysisTaskSheet.to_dict` / |
| :meth:`AnalysisTaskSheet.from_dict` 互为逆操作(``from_dict(to_dict(x)) == x``, |
| 需求 9.3、Property 9),便于会话状态保存、审计与无 UI / 无 LLM 的单元测试。 |
| - **仅依赖标准库**:纯 dataclass + enum,不触网、无重依赖,便于稳定测试。 |
| |
| 枚举值采用稳定的字符串字面量,作为对外序列化契约的一部分(不可随意更名)。 |
| """ |
|
|
| from __future__ import annotations |
|
|
| from dataclasses import dataclass, field |
| from enum import Enum |
| from typing import Any, Optional |
|
|
|
|
| |
| |
| |
|
|
| class Intent(str, Enum): |
| """用户请求意图分类(需求 3)。 |
| |
| - ``DESCRIPTIVE_SUMMARY``:描述性质量属性梳理(分组/统计/限度符合性,不外推)。 |
| - ``SHELF_LIFE``:货架期外推 / 动力学建模。 |
| - ``UNKNOWN``:无法自信分类 → 进入澄清模式(需求 3.4 / 6.4)。 |
| """ |
|
|
| DESCRIPTIVE_SUMMARY = "descriptive_summary" |
| SHELF_LIFE = "shelf_life_extrapolation" |
| COMPATIBILITY = "compatibility" |
| UNKNOWN = "unknown" |
|
|
|
|
| class SlotState(str, Enum): |
| """意图槽位的**来源态**(意图保真核心元信息)。 |
| |
| 意图是潜变量——系统只能从「用户话语 + 文档上下文」观测它。为确保**用户真实意图 |
| 被采纳**,每个槽位必须显式携带其取值来源,杜绝「系统凭空具象化用户从未表达的意图」: |
| |
| - ``STATED``:用户在指令中**明示**——最高优先级,经确认后锁定,永不被重推断覆盖。 |
| - ``INFERRED``:由文档画像 / 输入信号**语用推断**——可用,但必须向用户**回显来源** |
| 并允许一键改正(推断可以激进,但绝不隐瞒「这是推断」)。 |
| - ``MISSING``:既无明示也无可靠推断——触发**选择性澄清**,绝不填默认值。 |
| |
| 注意:**刻意不设 ``ASSUMED`` 态**。静默默认值(如规格 0.5 / 主 CQA「总杂质」/ |
| 目标时间点 [24,36])是篡改用户意图的暗门,在本保真模型中被禁止——缺失即 ``MISSING``。 |
| """ |
|
|
| STATED = "stated" |
| INFERRED = "inferred" |
| MISSING = "missing" |
|
|
|
|
| class TableType(str, Enum): |
| """文档中检测到的表类型(需求 2.2)。""" |
|
|
| RELATED_SUBSTANCES = "related_substances" |
| ASSAY = "assay" |
| DISSOLUTION = "dissolution" |
| CONTENT_UNIFORMITY = "content_uniformity" |
| PHYSICAL_PROPERTIES = "physical_properties" |
| STABILITY_TIME_SERIES = "stability_time_series" |
| UNKNOWN = "unknown" |
|
|
|
|
| |
| CLARIFY_NO_LLM = "no_llm" |
| CLARIFY_INTENT_UNKNOWN = "intent_unknown" |
| CLARIFY_DATA_INTENT_MISMATCH = "data_intent_mismatch" |
|
|
|
|
| def _coerce_enum(enum_cls, value, default): |
| """把字符串 / 枚举安全归一为 ``enum_cls`` 成员;无法识别回退 ``default``。""" |
| if isinstance(value, enum_cls): |
| return value |
| try: |
| return enum_cls(str(value)) |
| except ValueError: |
| return default |
|
|
|
|
| |
| |
| |
|
|
| @dataclass |
| class DetectedTable: |
| """文档中检测到的单张表(需求 2.1 / 2.2 / 2.3)。""" |
|
|
| table_type: TableType |
| title: str = "" |
| source_document: str = "" |
| is_stability_time_series: bool = False |
| columns: list[str] = field(default_factory=list) |
| sample_rows: list[list[str]] = field(default_factory=list) |
|
|
| def to_dict(self) -> dict: |
| return { |
| "table_type": self.table_type.value, |
| "title": self.title, |
| "source_document": self.source_document, |
| "is_stability_time_series": bool(self.is_stability_time_series), |
| "columns": list(self.columns), |
| "sample_rows": [list(r) for r in self.sample_rows], |
| } |
|
|
| @staticmethod |
| def from_dict(d: dict) -> "DetectedTable": |
| d = d or {} |
| return DetectedTable( |
| table_type=_coerce_enum(TableType, d.get("table_type"), TableType.UNKNOWN), |
| title=str(d.get("title", "")), |
| source_document=str(d.get("source_document", "")), |
| is_stability_time_series=bool(d.get("is_stability_time_series", False)), |
| columns=[str(c) for c in (d.get("columns") or [])], |
| sample_rows=[[str(c) for c in row] for row in (d.get("sample_rows") or [])], |
| ) |
|
|
|
|
| @dataclass |
| class DocumentProfile: |
| """文档画像:检测到的表集合 + 是否含稳定性时序(需求 2)。""" |
|
|
| tables: list[DetectedTable] = field(default_factory=list) |
| has_stability_time_series: bool = False |
| notes: list[str] = field(default_factory=list) |
|
|
| def to_dict(self) -> dict: |
| return { |
| "tables": [t.to_dict() for t in self.tables], |
| "has_stability_time_series": bool(self.has_stability_time_series), |
| "notes": list(self.notes), |
| } |
|
|
| @staticmethod |
| def from_dict(d: dict) -> "DocumentProfile": |
| d = d or {} |
| return DocumentProfile( |
| tables=[DetectedTable.from_dict(t) for t in (d.get("tables") or [])], |
| has_stability_time_series=bool(d.get("has_stability_time_series", False)), |
| notes=[str(n) for n in (d.get("notes") or [])], |
| ) |
|
|
|
|
| |
| |
| |
|
|
| @dataclass |
| class ExtractedItem: |
| """单个提取项,必须可回溯到原文(需求 4)。 |
| |
| - ``source_ref``:可在原文定位的片段 / 单元格内容(回填校验依据,需求 4.1)。 |
| - ``located``:是否在原文成功定位;``False`` 表示被标记缺失、不得带入捏造值 |
| (需求 4.2 / Property 1)。 |
| - ``group``:分组维度(如 ``{"strength": "20μg", "batch": "B1", "attribute": "总杂"}``), |
| 供描述性梳理按规格/批次/属性组织(需求 7.2)。 |
| """ |
|
|
| field: str |
| value: str = "" |
| source_ref: str = "" |
| located: bool = True |
| group: dict = field(default_factory=dict) |
| source: str = "llm" |
|
|
| def to_dict(self) -> dict: |
| return { |
| "field": self.field, |
| "value": self.value, |
| "source_ref": self.source_ref, |
| "located": bool(self.located), |
| "group": dict(self.group), |
| "source": self.source, |
| } |
|
|
| @staticmethod |
| def from_dict(d: dict) -> "ExtractedItem": |
| d = d or {} |
| return ExtractedItem( |
| field=str(d.get("field", "")), |
| value=str(d.get("value", "")), |
| source_ref=str(d.get("source_ref", "")), |
| located=bool(d.get("located", True)), |
| group=dict(d.get("group") or {}), |
| source=str(d.get("source", "llm")), |
| ) |
|
|
|
|
| @dataclass |
| class IntentSlot: |
| """单个**意图槽位**及其保真元信息(意图保真核心载体)。 |
| |
| 把「意图」从一次性扁平分类,升级为「带来源、带置信、可锁定、缺失即问」的可确认 |
| 结构。每个槽位回答三个保真关键问题:这是用户**说的**还是系统**猜的**(``state`` / |
| ``evidence``)?系统有**多确定**(``confidence``)?用户**改得动**吗(``locked``)? |
| |
| - ``name``:槽位名(如 ``primary_cqa`` / ``spec_limit`` / ``target_timepoints``)。 |
| - ``value``:取值;``MISSING`` 态下应为 ``None``(绝不填默认)。 |
| - ``state``:来源态(:class:`SlotState`)。 |
| - ``confidence``:0..1 置信度,驱动选择性澄清与确认策略(STATED≈1.0 / INFERRED 居中 / MISSING=0)。 |
| - ``evidence``:明示 / 推断的原文依据片段(对话接地溯源,供回显)。 |
| - ``locked``:用户确认 / 编辑后置 ``True``——**任何重推断都不得覆盖**(采纳真实意图的硬保证)。 |
| - ``affects_result``:是否影响计算结论,驱动「信息价值」澄清决策(关键槽缺失才必问)。 |
| """ |
|
|
| name: str |
| value: Any = None |
| state: SlotState = SlotState.MISSING |
| confidence: float = 0.0 |
| evidence: str = "" |
| locked: bool = False |
| affects_result: bool = True |
|
|
| def needs_clarification(self) -> bool: |
| """该槽位是否应触发澄清:缺失、且影响结论、且未被用户锁定。""" |
| return ( |
| self.state is SlotState.MISSING |
| and self.affects_result |
| and not self.locked |
| ) |
|
|
| def to_dict(self) -> dict: |
| return { |
| "name": self.name, |
| "value": self.value, |
| "state": self.state.value, |
| "confidence": float(self.confidence), |
| "evidence": self.evidence, |
| "locked": bool(self.locked), |
| "affects_result": bool(self.affects_result), |
| } |
|
|
| @staticmethod |
| def from_dict(d: dict) -> "IntentSlot": |
| d = d or {} |
| return IntentSlot( |
| name=str(d.get("name", "")), |
| value=d.get("value"), |
| state=_coerce_enum(SlotState, d.get("state"), SlotState.MISSING), |
| confidence=float(d.get("confidence", 0.0) or 0.0), |
| evidence=str(d.get("evidence", "")), |
| locked=bool(d.get("locked", False)), |
| affects_result=bool(d.get("affects_result", True)), |
| ) |
|
|
|
|
| @dataclass |
| class ClarificationDecision: |
| """澄清决策(需求 6):是否需要向用户澄清及其原因。""" |
|
|
| needed: bool = False |
| reason_code: str = "" |
| message_key: str = "" |
| options: list[str] = field(default_factory=list) |
|
|
| def to_dict(self) -> dict: |
| return { |
| "needed": bool(self.needed), |
| "reason_code": self.reason_code, |
| "message_key": self.message_key, |
| "options": list(self.options), |
| } |
|
|
| @staticmethod |
| def from_dict(d: dict) -> "ClarificationDecision": |
| d = d or {} |
| return ClarificationDecision( |
| needed=bool(d.get("needed", False)), |
| reason_code=str(d.get("reason_code", "")), |
| message_key=str(d.get("message_key", "")), |
| options=[str(o) for o in (d.get("options") or [])], |
| ) |
|
|
|
|
| |
| |
| |
|
|
| @dataclass |
| class AnalysisTaskSheet: |
| """《分析任务单》:理解层的强结构化产物,经用户确认后驱动分析(需求 5)。 |
| |
| 字段: |
| - ``intent``:分类意图(需求 3.5)。 |
| - ``document_profile``:文档画像(需求 2)。 |
| - ``extracted_items``:来源可定位的提取项(需求 4)。 |
| - ``proposed_skill_id``:建议路由的 Skill id(用户可编辑,需求 5.3 / 8 路由)。 |
| - ``missing_items`` / ``ambiguous_items``:缺失与歧义清单(需求 4.5)。 |
| - ``clarification``:澄清决策;``None`` 表示无需澄清(需求 6)。 |
| - ``source_method``:``"llm"`` | ``"clarification"``,标注任务单来源。 |
| """ |
|
|
| intent: Intent = Intent.UNKNOWN |
| document_profile: DocumentProfile = field(default_factory=DocumentProfile) |
| extracted_items: list[ExtractedItem] = field(default_factory=list) |
| proposed_skill_id: str = "" |
| missing_items: list[str] = field(default_factory=list) |
| ambiguous_items: list[str] = field(default_factory=list) |
| clarification: Optional[ClarificationDecision] = None |
| source_method: str = "llm" |
| |
| |
| intent_slots: list[IntentSlot] = field(default_factory=list) |
|
|
| def needs_clarification(self) -> bool: |
| """是否处于澄清状态(澄清决策存在且 needed=True)。""" |
| return self.clarification is not None and bool(self.clarification.needed) |
|
|
| def slots_needing_clarification(self) -> list[IntentSlot]: |
| """返回应触发澄清的关键意图槽位(缺失、影响结论、未锁定)。 |
| |
| 供选择性澄清(信息价值驱动)使用:只对真正影响结果且不确定的槽位发问, |
| 避免过度澄清。返回空列表表示无需就意图槽位追问。 |
| """ |
| return [s for s in self.intent_slots if s.needs_clarification()] |
|
|
| def get_slot(self, name: str) -> Optional[IntentSlot]: |
| """按名取意图槽位;不存在返回 ``None``。""" |
| for s in self.intent_slots: |
| if s.name == name: |
| return s |
| return None |
|
|
| def to_dict(self) -> dict: |
| return { |
| "intent": self.intent.value, |
| "document_profile": self.document_profile.to_dict(), |
| "extracted_items": [it.to_dict() for it in self.extracted_items], |
| "proposed_skill_id": self.proposed_skill_id, |
| "missing_items": list(self.missing_items), |
| "ambiguous_items": list(self.ambiguous_items), |
| "clarification": self.clarification.to_dict() if self.clarification else None, |
| "source_method": self.source_method, |
| "intent_slots": [s.to_dict() for s in self.intent_slots], |
| } |
|
|
| @staticmethod |
| def from_dict(d: dict) -> "AnalysisTaskSheet": |
| d = d or {} |
| clar = d.get("clarification") |
| return AnalysisTaskSheet( |
| intent=_coerce_enum(Intent, d.get("intent"), Intent.UNKNOWN), |
| document_profile=DocumentProfile.from_dict(d.get("document_profile") or {}), |
| extracted_items=[ExtractedItem.from_dict(it) for it in (d.get("extracted_items") or [])], |
| proposed_skill_id=str(d.get("proposed_skill_id", "")), |
| missing_items=[str(m) for m in (d.get("missing_items") or [])], |
| ambiguous_items=[str(a) for a in (d.get("ambiguous_items") or [])], |
| clarification=ClarificationDecision.from_dict(clar) if clar else None, |
| source_method=str(d.get("source_method", "llm")), |
| intent_slots=[IntentSlot.from_dict(s) for s in (d.get("intent_slots") or [])], |
| ) |
|
|
|
|
| __all__ = [ |
| "Intent", |
| "SlotState", |
| "TableType", |
| "CLARIFY_NO_LLM", |
| "CLARIFY_INTENT_UNKNOWN", |
| "CLARIFY_DATA_INTENT_MISMATCH", |
| "DetectedTable", |
| "DocumentProfile", |
| "ExtractedItem", |
| "IntentSlot", |
| "ClarificationDecision", |
| "AnalysisTaskSheet", |
| ] |
|
|