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>
196 lines
7.9 KiB
Python
196 lines
7.9 KiB
Python
"""S1 材料导入服务:文件集合清单的生命周期。
|
||
|
||
职责 owner(docs/v2/03-detailed-design.md §9.2「材料导入服务」):
|
||
集合清单、逐项状态与重试、批次状态派生。
|
||
|
||
边界(不得越界):
|
||
- **不建立平行上传 API**:文件字节仍走既有
|
||
`POST /projects/{project_id}/documents` → `ProjectAssistantService.upload()`
|
||
→ `assistant_materials.material()`。本模块只登记清单与回写解析结果。
|
||
- **不创建平行持久化入口**:复用 `assistant_objects`(kind=`import_batch`)。
|
||
items 是批次内的值对象(契约中 `ImportItem` 无 `revision`),故不另立
|
||
`import_item` kind;批次 revision 由 `put()` 维护。
|
||
- **不做真实性核验**:`SourceLocator` 能定位不等于材料真实可信(01 §1)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from services import assistant_store as store
|
||
from contracts.prescreen import ErrorCode, MAX_BATCH_BYTES, MAX_BATCH_FILES
|
||
|
||
KIND = "import_batch"
|
||
IDEMPOTENCY_OPERATION = "import_batch"
|
||
|
||
# 解析实现版本:冻结进批次,使"用哪版解析器得到这个结果"可追溯(BR-22)。
|
||
# 解析逻辑在 assistant_materials.parse(),其行为变更时必须同步提升此版本。
|
||
PARSER_VERSION = "assistant_materials.parse@1"
|
||
|
||
_STATUS_ORDER = ("queued", "parsing", "parsed", "failed")
|
||
|
||
|
||
def _derive_status(items: list[dict]) -> str:
|
||
"""由逐项状态派生批次状态。
|
||
|
||
ready = 全部 parsed
|
||
failed = 全部 failed
|
||
partial = 既有 parsed 又有 failed(含 queued/parsing 混入的中间态)
|
||
"""
|
||
statuses = {item["status"] for item in items}
|
||
if statuses == {"parsed"}:
|
||
return "ready"
|
||
if statuses == {"failed"}:
|
||
return "failed"
|
||
if "failed" in statuses and "parsed" in statuses:
|
||
return "partial"
|
||
return "parsing"
|
||
|
||
|
||
def _validate_manifest(manifest: list[dict]) -> list[dict]:
|
||
from pydantic import ValidationError
|
||
from contracts.prescreen import ImportItem
|
||
|
||
if not manifest:
|
||
raise ValueError("材料集合为空;请选择至少一个文件")
|
||
if len(manifest) > MAX_BATCH_FILES:
|
||
raise ValueError(f"单批最多 {MAX_BATCH_FILES} 个文件;请拆分后重试")
|
||
|
||
items: list[dict] = []
|
||
seen_sha: set[str] = set()
|
||
for index, entry in enumerate(manifest):
|
||
try:
|
||
parsed = ImportItem(**entry)
|
||
except ValidationError as exc:
|
||
# 逐项错误沿用 E-01/E-16 的文案口径,指明是第几项
|
||
first = exc.errors()[0]
|
||
where = ".".join(str(p) for p in first["loc"]) or "输入"
|
||
raise ValueError(f"第 {index + 1} 个文件不合法({where}):{first['msg']}") from exc
|
||
if parsed.sha256 in seen_sha:
|
||
raise ValueError(f"第 {index + 1} 个文件与同批其他条目哈希重复:{parsed.relative_path}")
|
||
seen_sha.add(parsed.sha256)
|
||
items.append(parsed.model_dump(mode="json"))
|
||
|
||
total = sum(item["size_bytes"] for item in items)
|
||
if total > MAX_BATCH_BYTES:
|
||
raise ValueError(f"单批总量 {total} 超过上限 {MAX_BATCH_BYTES} 字节;请拆分后重试")
|
||
return items
|
||
|
||
|
||
def create(db, project_id, data_domain, manifest, actor, idempotency_key="", parser_version=PARSER_VERSION):
|
||
"""登记一次文件集合导入。返回 ImportBatch(status=queued)。
|
||
|
||
幂等:同一 actor+project+key 且载荷相同 → 返回原批次;载荷不同 → E-09。
|
||
"""
|
||
request_hash = store.digest({"project": project_id, "domain": data_domain, "manifest": manifest})
|
||
if idempotency_key:
|
||
prior = store.idempotent_lookup(db, actor, IDEMPOTENCY_OPERATION, idempotency_key, request_hash)
|
||
if prior:
|
||
return store.get(db, KIND, prior)
|
||
|
||
items = _validate_manifest(manifest)
|
||
batch = {
|
||
"id": store.uid("import"),
|
||
"project_id": project_id,
|
||
"data_domain": data_domain,
|
||
"status": "queued",
|
||
"created_at": store.now(),
|
||
"created_by": actor,
|
||
"parser_version": parser_version,
|
||
"items": items,
|
||
"total_bytes": sum(item["size_bytes"] for item in items),
|
||
"warnings": [],
|
||
}
|
||
batch = store.put(db, KIND, batch) # revision=1
|
||
if idempotency_key:
|
||
store.idempotent_remember(db, actor, IDEMPOTENCY_OPERATION, idempotency_key, request_hash, batch["id"])
|
||
store.event(db, project_id, "import_batch.created", actor,
|
||
{"batch_id": batch["id"], "files": len(items), "total_bytes": batch["total_bytes"]})
|
||
return batch
|
||
|
||
|
||
def load(db, batch_id, project_id=None):
|
||
batch = store.get(db, KIND, batch_id)
|
||
if project_id is not None and batch["project_id"] != project_id:
|
||
# 不存在与无访问权统一对外 404(03 §2),故这里也走 KeyError
|
||
raise KeyError("导入批次不存在")
|
||
return batch
|
||
|
||
|
||
def listing(db, project_id, limit=20):
|
||
rows = store.listing(db, KIND, project_id)
|
||
return {"items": rows[:limit], "next_cursor": None if len(rows) <= limit else str(limit)}
|
||
|
||
|
||
def mark_item(db, batch_id, item_id, status, document_id=None, error_code=None, warning=None):
|
||
"""由既有上传链回写单条解析结果。
|
||
|
||
`parsed` 必须带 document_id;`failed` 必须带 error_code —— 与契约同规则,
|
||
避免出现"状态说成功但无法定位原件"的条目(AC-23)。
|
||
"""
|
||
if status not in _STATUS_ORDER:
|
||
raise ValueError(f"未知条目状态:{status}")
|
||
if status == "failed" and not error_code:
|
||
raise ValueError("failed 的条目必须给出 error_code")
|
||
batch = store.get(db, KIND, batch_id)
|
||
item = next((i for i in batch["items"] if i["id"] == item_id), None)
|
||
if item is None:
|
||
raise KeyError("导入条目不存在")
|
||
if batch["status"] == "cancelled":
|
||
# 取消后忽略迟到结果(03 §9.5),不写回
|
||
return batch
|
||
item["status"] = status
|
||
if document_id is not None:
|
||
item["document_id"] = document_id
|
||
item["error_code"] = error_code if status == "failed" else None
|
||
if warning:
|
||
batch["warnings"].append(f"{item['relative_path']}: {warning}")
|
||
batch["status"] = _derive_status(batch["items"])
|
||
return store.put(db, KIND, batch)
|
||
|
||
|
||
def retry(db, batch_id, item_ids, actor, expected):
|
||
"""仅把失败项重置为 queued;已 parsed 的条目不重解析、不重写(AC-23 反例)。"""
|
||
batch = store.get(db, KIND, batch_id)
|
||
if batch["status"] == "cancelled":
|
||
raise store.Conflict("批次已取消,不能重试")
|
||
if batch["status"] not in ("partial", "failed"):
|
||
raise ValueError("只有部分失败或全部失败的批次可以重试")
|
||
|
||
targets = set(item_ids)
|
||
unknown = targets - {i["id"] for i in batch["items"]}
|
||
if unknown:
|
||
raise KeyError(f"导入条目不存在:{sorted(unknown)}")
|
||
|
||
reset = []
|
||
for item in batch["items"]:
|
||
if item["id"] not in targets:
|
||
continue
|
||
if item["status"] != "failed":
|
||
raise ValueError(f"条目 {item['relative_path']} 不是失败项,不能重试")
|
||
item["status"] = "queued"
|
||
item["error_code"] = None
|
||
reset.append(item["id"])
|
||
|
||
batch["status"] = _derive_status(batch["items"])
|
||
batch = store.put(db, KIND, batch, expected=expected)
|
||
store.event(db, batch["project_id"], "import_batch.retried", actor,
|
||
{"batch_id": batch_id, "items": reset})
|
||
return batch
|
||
|
||
|
||
def cancel(db, batch_id, actor, expected):
|
||
batch = store.get(db, KIND, batch_id)
|
||
if batch["status"] in ("ready", "partial", "failed", "cancelled"):
|
||
raise store.Conflict("批次已结束,不能取消")
|
||
batch["status"] = "cancelled"
|
||
batch = store.put(db, KIND, batch, expected=expected)
|
||
store.event(db, batch["project_id"], "import_batch.cancelled", actor, {"batch_id": batch_id})
|
||
return batch
|
||
|
||
|
||
# 供 S1c 接口层把服务端异常映射为错误码(03 §2 的统一信封)
|
||
ERROR_BY_EXCEPTION = {
|
||
"KeyError": ErrorCode.NOT_FOUND,
|
||
"Conflict": ErrorCode.REVISION_CONFLICT,
|
||
"ValueError": ErrorCode.IMPORT_BATCH_INVALID,
|
||
}
|