feat(v2): T05-a 历史过程时间线(缺时间不编造)

T05 卡片要求"先 timeline,再候选,再编辑/测试/审核"。本批是第一步。

新增 contracts:DecisionEvent(区分 occurred_at 与 recorded_at)、CaseTimeline。
新增 services/history_cases.py:确定性提炼,无模型调用。

两条硬规则的实现:
- **不编造发生时间**(BR-02):occurred_at 只取材料里记载的日期;缺失/占位值
  ("-"、"缺失"、"未知")或无法解析时保持 None 并给缺证提示。recorded_at 必填,
  但它**不表示发生时间**——有测试断言二者不相等
- **不编造经过**(14 §4):只有最终报告或非结构化材料时产出 **0 个事件** 并标
  `partial` + "过程材料不完整",绝不生成虚构的问询顺序

其它:列名靠别名表识别,**未识别的列忽略而非猜测**;未声明事件类型时保守记为
question 而不丢弃该行;事件携带行级 evidence_id 以便回溯原文定位;同项目重复提炼
是同一对象 revision 递增(03 §4);缺日期事件排序时置于末尾,不被打乱成"近期"。

实现中修掉一处自己的问题:build() 初版调了两次 store.put(首次建库 revision 会变 2),
且校验发生在落库之后——非法时间线本可能入库。现改为先校验再单次 put。

测试:新增 test_history_cases.py 18 项(不编造 6 + 提炼 6 + 隔离与版本 6),
含"缺日期为 None 且不等于导入时间""只有最终报告不得生成时间线"等反例。
全量 290 项 OK(272 → 290);verify.py 10/10 PASS。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-29 18:41:50 +08:00
co-authored by Claude Code
parent 8fef00d268
commit 04ef3972a8
3 changed files with 447 additions and 0 deletions
+49
View File
@@ -366,3 +366,52 @@ class DatasetManifest(Contract):
if self.synthetic and self.data_domain != DataDomain.demo:
raise ValueError("合成数据必须为 demo 数据域")
return self
# ---------------------------------------------------------------- 历史过程(T05)
class DecisionEvent(Contract):
"""历史过程中的一次问询/证据/动作/意见/决定(docs/v2/03-detailed-design.md §3)。
时间语义严格区分(BR-02):
- `occurred_at` 是材料里**记载**的发生时间,缺失时保持 None,**不得**用录入时间填充
- `recorded_at` 是录入时间,必填,但**不能**被当作发生时间
"""
id: Identifier
event_type: Literal["question", "evidence", "action", "opinion", "decision"]
occurred_at: AwareDatetime | None = None
recorded_at: AwareDatetime
question: str = Field(default="", max_length=DESC_MAX)
evidence_ids: list[Identifier] = Field(default_factory=list, max_length=EVIDENCE_MAX)
judgment_before: str | None = Field(default=None, max_length=DESC_MAX)
judgment_after: str | None = Field(default=None, max_length=DESC_MAX)
conditions: list[str] = Field(default_factory=list, max_length=EVIDENCE_MAX)
@model_validator(mode="after")
def _check_journal(self) -> DecisionEvent:
# 事件本身必须有可读内容,否则只是空壳
if not (self.question or self.judgment_before or self.judgment_after):
raise ValueError("决策事件必须至少包含问题、判断前或判断后之一")
return self
class CaseTimeline(Contract):
"""历史过程时间线。`partial` 表示过程材料不完整(如只有最终报告)。"""
id: Identifier
project_id: Identifier
data_domain: DataDomain
revision: int = Field(ge=1)
status: Literal["processing", "ready", "partial", "failed"]
created_at: AwareDatetime
events: list[DecisionEvent] = Field(default_factory=list, max_length=500)
warnings: list[str] = Field(default_factory=list, max_length=EVIDENCE_MAX)
@model_validator(mode="after")
def _check_integrity(self) -> CaseTimeline:
ids = [event.id for event in self.events]
if len(set(ids)) != len(ids):
raise ValueError("同一时间线内事件 id 必须唯一")
if self.status == "ready" and any(e.occurred_at is None for e in self.events):
raise ValueError("存在缺发生时间的事件时状态不得为 ready")
return self