feat(v2): S1c′ 既有上传入口的批量摄取——S1 首次端到端闭环

T00/04 §4.2 要求的"既有材料上传入口扩展":**不建立平行上传 API**。
POST /documents 增加可选 batch_id / item_id 表单字段;不传时行为完全不变。

- api/prescreen.ingest_into_batch:清单核对 + 状态回写,仍走既有
  ProjectAssistantService.upload(),故解析、同哈希复用原件、pending_candidates、
  data_revision、mark_stale 等既有行为一字未改
- 服务端**重算** sha256 与大小,不信任客户端清单;不符则条目 failed + 422
- 返回体保持 **document 形状**(既有前端读 body.id / body.candidates),
  批次状态作为附加字段 import_batch 给出
- 拒绝重复摄入已解析条目(409)、已取消批次(409)、不在清单中的文件(404)

闭环已成立:登记批次 → 上传 → 解析定位 → 冲突检测 → 人工裁定 → 进事实。

测试:test_prescreen_api.py 新增 BatchIngestTests 10 项(含闭环与既有路径回归)。
全量 235 项 OK(225 → 235);scripts/verify.py 9/9 PASS。
真实 app 冒烟 claude_use/T02/smoke_s1.py 15/15 PASS。

**测试隔离缺陷已修**:BatchIngestTests 初版只 patch 了 prescreen 的 service 工厂,
未 patch api.project_assistant.service,导致不传 batch_id 的上传落到**真实 v2 数据库**。
已补 patch,并清空 v2 数据根从干净状态重跑验证。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-29 17:43:57 +08:00
co-authored by Claude Code
parent fcf4d8c948
commit ff7894ae1f
3 changed files with 226 additions and 2 deletions
+63
View File
@@ -22,6 +22,8 @@
from __future__ import annotations
import hashlib
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.exception_handlers import request_validation_exception_handler
from fastapi.exceptions import RequestValidationError
@@ -163,6 +165,67 @@ def _limit(value: int) -> int:
return max(1, min(value, 100))
# ------------------------------------------------- 既有上传入口的批量摄取
def _pick_item(batch: dict, filename: str, item_id: str | None) -> dict:
items = batch["items"]
if item_id:
item = next((i for i in items if i["id"] == item_id), None)
if item is None:
raise KeyError("导入条目不存在")
return item
base = filename.replace("\\", "/").rsplit("/", 1)[-1]
matches = [i for i in items
if i["relative_path"].replace("\\", "/").rsplit("/", 1)[-1] == base]
if not matches:
raise KeyError("该文件不在导入批次清单中")
if len(matches) > 1:
raise ValueError("批次中有多个同名文件,请指定 item_id")
return matches[0]
def ingest_into_batch(project_id: str, filename: str, content: bytes, actor: str,
batch_id: str, item_id: str | None = None):
"""把文件字节摄入**既有**上传链,并回写批次条目(T00/04 §4.2)。
不建立平行上传 API:仍走 `ProjectAssistantService.upload()`,因此解析、
去重(同哈希复用原件)、pending_candidates、data_revision、mark_stale 等
既有行为完全不变。本函数只做"清单核对 + 状态回写"。
服务端**重算**哈希与大小,不信任客户端清单(B-22 的来源可信度)。
"""
with _db() as db:
batch = material_import.load(db, batch_id, project_id)
if batch["status"] == "cancelled":
raise store.Conflict("批次已取消,不能继续摄入文件")
item = _pick_item(batch, filename, item_id)
def fail(code: ErrorCode, message: str):
with _db() as db:
db.execute("BEGIN IMMEDIATE")
material_import.mark_item(db, batch_id, item["id"], "failed", error_code=code)
raise ValueError(message)
if item["status"] == "parsed":
raise store.Conflict("该条目已解析完成,不能重复摄入")
digest = hashlib.sha256(content).hexdigest()
if digest != item["sha256"]:
fail(ErrorCode.INVALID_EVIDENCE, "文件内容与清单声明的 sha256 不一致,请重新选择该文件")
if len(content) != item["size_bytes"]:
fail(ErrorCode.INVALID_EVIDENCE, "文件大小与清单声明不一致,请重新选择该文件")
# 走既有上传链(可能因同哈希而复用既有 document,这是 14 §3 的期望行为)
document = _service().upload(project_id, filename, content, actor)
with _db() as db:
db.execute("BEGIN IMMEDIATE")
batch = material_import.mark_item(db, batch_id, item["id"], "parsed",
document_id=document["id"])
# 返回体保持**document 形状**(既有调用方读 id 与 candidates),
# 批次状态作为附加字段给出,不替换既有契约。
return dict(document, import_batch=batch)
async def validation_handler(request: Request, exc: RequestValidationError):
"""请求体/查询参数校验失败也走统一信封。