Files
qiaoqichaoandClaude Code 04ef3972a8 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>
2026-09-29 18:41:50 +08:00

206 lines
8.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""T05-a 历史过程时间线:从历史材料提炼决策事件。
依据:docs/v2/14-experience-loop.md §4(加工步骤与"过程不完整")、§3(来源定位);
BR-02 / E-02 / AC-02。
两条硬规则:
1. **不编造经过**:只有最终报告、没有结构化问询/决定记录时,产出 **0 个事件** 并标
`partial` + 明确提示,绝不生成虚构的问询顺序(14 §4 明文)。
2. **不编造时间**:`occurred_at` 只来自材料里**记载**的日期;缺失或无法解析时保持
None 并给缺证提示。**导入时间绝不冒充发生时间**(14 §3 / BR-02)。
提取是确定性的(别名匹配 + 结构化行),不做模型调用。
"""
from __future__ import annotations
import re
from datetime import datetime, timezone
from contracts.prescreen import CaseTimeline
from services import assistant_store as store
KIND = "case_timeline"
# 列名别名。未识别的列一律忽略——**不猜**列含义。
COLUMN_ALIASES = {
"occurred_at": {"date", "日期", "发生时间", "时间", "问询日期", "会议日期", "决定日期"},
"event_type": {"event_type", "类型", "事件类型", "环节", "过程类型"},
"question": {"question", "问题", "问询", "问询事项", "事项", "核查问题"},
"judgment_before": {"judgment_before", "判断前", "原判断", "初审意见"},
"judgment_after": {"judgment_after", "判断后", "新判断", "复审意见"},
"conditions": {"conditions", "条件", "附加条件", "批注条件"},
"evidence": {"evidence", "依据", "证据", "材料", "来源"},
}
EVENT_TYPE_ALIASES = {
"question": "question", "问询": "question", "问题": "question",
"evidence": "evidence", "证据": "evidence", "材料": "evidence",
"action": "action", "动作": "action", "补件": "action",
"opinion": "opinion", "意见": "opinion", "判断": "opinion",
"decision": "decision", "决定": "decision", "结论": "decision", "会议": "decision",
}
DATE_FORMATS = ("%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d", "%Y年%m月%d日",
"%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M:%S", "%Y%m%d")
MISSING_DATE_NOTE = "未标注发生时间,按缺证处理(不推断日期)"
INCOMPLETE_NOTE = ("过程材料不完整:未发现结构化的问询/决定记录,"
"仅能提炼有来源的结论,不生成虚构经过")
def _normalize(text) -> str:
return str(text or "").strip().lower().replace(" ", "")
def _parse_date(value) -> datetime | None:
"""只接受材料里明确写出的日期;无法解析即 None(不猜测)。"""
raw = str(value or "").strip()
if not raw or raw in ("-", "无", "null", "未知", "缺失"):
return None
for fmt in DATE_FORMATS:
try:
parsed = datetime.strptime(raw, fmt)
except ValueError:
continue
return parsed.replace(tzinfo=timezone.utc)
try:
parsed = datetime.fromisoformat(raw)
except ValueError:
return None
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
def _header_map(chunks: list[dict]):
"""找到表头行及其列映射。未识别的列不猜。"""
for position, chunk in enumerate(chunks):
cells = chunk.get("cells")
if not cells:
continue
mapping: dict[str, int] = {}
for index, cell in enumerate(cells):
key = _normalize(cell)
for field, aliases in COLUMN_ALIASES.items():
if key in aliases and field not in mapping:
mapping[field] = index
# 至少要能定位"问什么"或"何时",否则不视为表头
if "question" in mapping or "occurred_at" in mapping:
return position, mapping
return None, {}
def _cell(cells: list, mapping: dict, field: str) -> str:
index = mapping.get(field)
if index is None or index >= len(cells):
return ""
return str(cells[index] or "").strip()
def _split_conditions(text: str) -> list[str]:
return [item.strip() for item in re.split(r"[;;,,、]", text or "") if item.strip()]
def events_from_document(document: dict) -> tuple[list[dict], list[str]]:
"""从单个文档提炼事件。返回 (events, warnings)。**不落库**。"""
chunks = document.get("chunks") or []
head, mapping = _header_map(chunks)
if head is None:
# 非结构化材料(txt/pdf/docx 正文或纯报告)→ 不产出事件,交调用方标注不完整
return [], []
events, warnings = [], []
for chunk in chunks[head + 1:]:
cells = chunk.get("cells")
if not cells or not any(str(cell or "").strip() for cell in cells):
continue
occurred = _parse_date(_cell(cells, mapping, "occurred_at"))
if occurred is None and "occurred_at" in mapping:
raw = _cell(cells, mapping, "occurred_at")
warnings.append(f"{chunk['locator']}:{MISSING_DATE_NOTE}"
+ (f"(原文:{raw})" if raw else "(该列留空)"))
raw_type = _cell(cells, mapping, "event_type")
event_type = EVENT_TYPE_ALIASES.get(_normalize(raw_type))
if event_type is None:
# 未声明类型时按最保守的"问询"记;不因为缺类型而丢弃这一行
event_type = "question"
question = _cell(cells, mapping, "question")
before = _cell(cells, mapping, "judgment_before") or None
after = _cell(cells, mapping, "judgment_after") or None
if not (question or before or after):
continue
events.append({
"id": store.uid("event"),
"event_type": event_type,
"occurred_at": occurred.isoformat() if occurred else None,
# 录入时间必填,但**只表示我们何时录入**,不当作发生时间
"recorded_at": store.now(),
"question": question,
"evidence_ids": [chunk["evidence_id"]] if chunk.get("evidence_id") else [],
"judgment_before": before,
"judgment_after": after,
"conditions": _split_conditions(_cell(cells, mapping, "conditions")),
})
return events, warnings
def build(db, project_id: str, data_domain: str) -> dict:
"""重新提炼该项目的时间线。同项目重复提炼 → 同 id、revision 递增(03 §4)。"""
documents = store.listing(db, "document", project_id)
events: list[dict] = []
warnings: list[str] = []
structured_documents = 0
for document in documents:
found, notes = events_from_document(document)
warnings.extend(notes)
if found:
structured_documents += 1
events.extend(found)
events.sort(key=lambda item: (item["occurred_at"] is None, item["occurred_at"] or "", item["id"]))
if not events:
warnings.append(INCOMPLETE_NOTE)
status = "partial"
elif any(event["occurred_at"] is None for event in events):
# 设计 §4:部分缺日期为 partial
status = "partial"
else:
status = "ready"
body = {
# 每项目一条时间线;稳定 id 使"重新提炼"表现为 revision 递增而不是新对象
"id": f"timeline-{project_id}",
"project_id": project_id,
"data_domain": data_domain,
"status": status,
"created_at": store.now(),
"events": events,
"warnings": warnings,
}
# 先按契约校验再落库:revision 由 store.put() 维护,这里给的 1 只是为了让
# 校验通过(CaseTimeline.revision 必填),put() 会写入真实值。
# 一次 put,不重复写;不合法的时间线绝不入库。
validated = CaseTimeline(**dict(body, revision=1)).model_dump(mode="json")
stored = store.put(db, KIND, validated)
store.event(db, project_id, "case_timeline.built", "system",
{"timeline_id": stored["id"], "events": len(events),
"structured_documents": structured_documents, "status": status})
return stored
def load(db, project_id: str) -> dict:
body = store.get(db, KIND, f"timeline-{project_id}")
if body["project_id"] != project_id:
raise KeyError("时间线不属于该项目")
return body
def undated_events(timeline: dict) -> list[dict]:
"""缺发生时间的事件——界面必须显式提示缺证,不得静默排序成"近期"(BR-02)。"""
return [event for event in timeline["events"] if event["occurred_at"] is None]