File size: 15,824 Bytes
0e6887b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
"""《分析任务单》数据模型(理解层产物,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"


#: 澄清原因码(需求 6):无 LLM / 意图未知 / 数据与意图不匹配。
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 = ""          # 多文档聚合:记录来源文件名(需求 2.2)
    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"   # "llm"(模型提取)| "user_edited"(用户手动修改/新增)

    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 = ""              # CLARIFY_* 之一
    message_key: str = ""             # i18n key(界面提示文案)
    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"
    #: 意图槽位(保真核心):带来源态 / 置信度 / 锁定的可确认意图维度(需求 3 / 5)。
    #: 向后兼容默认空列表——旧任务单与未启用槽位的技能不受影响。
    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",
]