feat(v2): S1 存储层——材料导入批次与事实冲突检测

T02 第二个子卡。复用 assistant_objects(新 kind),**未新建表**;旧库结构不变。

新增服务:
- services/material_import.py 批次生命周期:create/load/listing/mark_item/
  retry/cancel;状态由逐项状态派生(ready/partial/failed/parsing)
- services/fact_conflict.py 冲突检测/阻断/裁定;FactConflictUnresolved(E-17)

解决的**真实缺口**(BR-23 / AC-24):既有 apply_candidate 对 values/facts 是
后写覆盖,sources 虽累积多来源但无人检测分歧、无人阻断。本层在**新路径**前置
检测:冲突时证据仍入 sources(BR-22 留痕)但值不采用(BR-23),已确认值不被覆盖。

增量改动(均为可加性,未改既有语义与调用方行为):
- assistant_store.py:新增 idempotent_lookup / idempotent_remember 共享 helper。
  既有 enqueue 与 create_report 的内联实现**未改写**。用 INSERT OR REPLACE 而非
  裸 INSERT:重试路径上裸 INSERT 会抛 sqlite3.IntegrityError(非 Conflict,
  translate() 无法映射为 409)。
- assistant_materials.apply_candidate 新增 adopt=True 形参。adopt=False 只记证据
  不改值;既有调用方不传参,行为完全不变(有回归测试)。

设计决定:
- T00/04 曾提 3 个 kind;实现时发现契约中 ImportItem 无 revision 且内嵌于批次,
  items 是值对象,故收敛为 2 个 kind(import_batch / fact_conflict)
- E-17 有两个面:确认动作被拒 = 422;运行级结果 = HTTP 200 且冲突存于
  degradations。契约映射与注释均已写明,并有测试锁定

测试:新增 test_prescreen_storage.py(33 项)。全量 196 项 OK(124 → 196)。
scripts/verify.py 9/9 PASS(exit 0)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-29 14:49:03 +08:00
co-authored by Claude Code
parent d8a7d3858c
commit c59fff68d3
7 changed files with 892 additions and 6 deletions
+9 -3
View File
@@ -22,6 +22,7 @@ from contracts.prescreen import (
ImportItem,
Ref,
S1_ERROR_HTTP,
S1_ERROR_RETRYABLE,
SourceLocator,
)
@@ -298,9 +299,14 @@ class ErrorContractTests(unittest.TestCase):
self.assertEqual(S1_ERROR_HTTP[ErrorCode.IDEMPOTENCY_CONFLICT], 409)
self.assertEqual(S1_ERROR_HTTP[ErrorCode.IMPORT_BATCH_INVALID], 422)
def test_e17_is_not_an_http_error(self):
# 01 §5 末:运行级错误 HTTP 200,只表示结果可读
self.assertEqual(S1_ERROR_HTTP[ErrorCode.FACT_CONFLICT_UNRESOLVED], 200)
def test_e17_confirm_refusal_is_422(self):
# E-17 有两个面:确认动作被拒 = 422(客户端须先裁定);
# 运行级结果 = HTTP 200 且冲突保存在 degradations(那个面不走错误信封)。
self.assertEqual(S1_ERROR_HTTP[ErrorCode.FACT_CONFLICT_UNRESOLVED], 422)
def test_all_codes_are_non_retryable(self):
# 03 §5:403/422 不自动重试;409 需客户端改载荷或刷新
self.assertFalse(any(S1_ERROR_RETRYABLE.values()))
def test_envelope_shape(self):
body = ErrorResponse.model_validate({"error": {