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
+11 -2
View File
@@ -1,7 +1,7 @@
"""Unified project API. Every resource read/write derives scope from its project."""
import base64
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
from fastapi import APIRouter, Depends, Form, HTTPException, Request, UploadFile, File
from fastapi.responses import Response
from pydantic import BaseModel, Field, ConfigDict
from api.risk_agent_access import authorize, check_project
@@ -221,7 +221,9 @@ def legacy(project_id: str, kind: str, legacy_id: str, request: Request):
@router.post("/projects/{project_id}/documents", status_code=201)
async def upload(project_id: str, request: Request, file: UploadFile = File(...)):
async def upload(project_id: str, request: Request, file: UploadFile = File(...),
batch_id: str | None = Form(default=None),
item_id: str | None = Form(default=None)):
guard(request, project_id)
from services.assistant_materials import LIMIT
try:
@@ -230,6 +232,13 @@ async def upload(project_id: str, request: Request, file: UploadFile = File(...)
await file.close()
# Parsing is synchronous CPU work; do not block the ASGI event loop.
from starlette.concurrency import run_in_threadpool
# S1:传入 batch_id 时走批量清单核对与状态回写(api/prescreen.ingest_into_batch)。
# 不传时行为与既有完全一致——前端与既有调用方都不受影响。
if batch_id:
from api.prescreen import ingest_into_batch
return await run_in_threadpool(lambda: translate(lambda: ingest_into_batch(
project_id, file.filename or "material", content,
request.state.identity["id"], batch_id, item_id)))
return await run_in_threadpool(lambda: translate(lambda: service().upload(project_id, file.filename or "material", content, request.state.identity["id"])))