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):
"""请求体/查询参数校验失败也走统一信封。
+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"])))
+152
View File
@@ -3,6 +3,7 @@
只挂 prescreen 路由到独立 app,数据库指向临时文件——不触碰真实库。
"""
import hashlib
import os
import tempfile
import unittest
@@ -320,5 +321,156 @@ class PrescreenApiTests(unittest.TestCase):
self.assertNotIn(banned, source)
class BatchIngestTests(unittest.TestCase):
"""S1c′:既有上传入口的批量摄取(T00/04 §4.2)。
文档端点属于 project_assistant 路由,故这里同时挂两个路由,与 app.py 一致。
"""
def setUp(self):
self.temp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.path = Path(self.temp.name)
self.service = ProjectAssistantService(self.path / "assistant.sqlite3")
self.patches = [
patch.object(api, "_service", lambda: self.service),
patch.dict(os.environ, {"XINGBANG_AUTH_USERS": "", "XINGBANG_REQUIRE_AUTH": "0",
"XINGBANG_LOCAL_MODEL_URL": ""}),
]
for p in self.patches:
p.start()
from fastapi.exceptions import RequestValidationError as RVE
from api import project_assistant as legacy
# 两个路由各自的 service 工厂都要指向临时库——否则 POST /documents
# 会落到真实的 backend/xingbang_data 库上(测试隔离泄漏)。
self.patches.append(patch.object(legacy, "service", lambda: self.service))
for p in self.patches:
p.start()
app = FastAPI()
app.add_exception_handler(RVE, api.validation_handler)
app.include_router(legacy.router)
app.include_router(api.router)
self.client = TestClient(app)
self.base = "/api/xingbang/risk-agent/project-assistant"
self.pre = self.base + "/prescreen"
def tearDown(self):
self.client.close()
for p in reversed(self.patches):
p.stop()
self.temp.cleanup()
def make_batch(self, payload: bytes, name="流水.csv"):
entry = {"relative_path": name, "sha256": hashlib.sha256(payload).hexdigest(),
"size_bytes": len(payload), "mime_type": "text/csv"}
response = self.client.post(f"{self.pre}/projects/{PROJECT}/import-batches",
json={"manifest": [entry]}, headers=HEADERS)
self.assertEqual(response.status_code, 202, response.text)
return response.json()["batch"]
def send(self, batch, payload: bytes, name="流水.csv", item_id=None, batch_id=None):
data = {"batch_id": batch["id"] if batch_id is None else batch_id}
data["item_id"] = item_id if item_id is not None else batch["items"][0]["id"]
return self.client.post(f"{self.base}/projects/{PROJECT}/documents",
files={"file": (name, payload, "text/csv")},
data=data, headers=HEADERS)
def test_ingest_marks_item_parsed_and_links_document(self):
payload = "field,value\nfacility.amount,100\n".encode("utf-8-sig")
batch = self.make_batch(payload)
response = self.send(batch, payload)
self.assertEqual(response.status_code, 201, response.text)
document_id = response.json()["id"]
after = self.client.get(f"{self.pre}/import-batches/{batch['id']}", headers=HEADERS).json()
self.assertEqual(after["status"], "ready")
self.assertEqual(after["items"][0]["status"], "parsed")
self.assertEqual(after["items"][0]["document_id"], document_id)
def test_ingest_by_filename_when_item_id_absent(self):
payload = b"field,value\nfacility.amount,100\n"
batch = self.make_batch(payload)
response = self.client.post(f"{self.base}/projects/{PROJECT}/documents",
files={"file": ("流水.csv", payload, "text/csv")},
data={"batch_id": batch["id"]}, headers=HEADERS)
self.assertEqual(response.status_code, 201, response.text)
after = self.client.get(f"{self.pre}/import-batches/{batch['id']}", headers=HEADERS).json()
self.assertEqual(after["items"][0]["status"], "parsed")
def test_hash_mismatch_marks_failed_and_rejects(self):
declared = b"field,value\nfacility.amount,100\n"
batch = self.make_batch(declared)
tampered = b"field,value\nfacility.amount,999\n"
response = self.send(batch, tampered)
self.assertEqual(response.status_code, 422, response.text)
after = self.client.get(f"{self.pre}/import-batches/{batch['id']}", headers=HEADERS).json()
self.assertEqual(after["items"][0]["status"], "failed")
self.assertEqual(after["items"][0]["error_code"], "INVALID_EVIDENCE")
self.assertIsNone(after["items"][0]["document_id"])
def test_size_mismatch_marks_failed(self):
payload = b"field,value\nfacility.amount,100\n"
entry = {"relative_path": "x.csv", "sha256": hashlib.sha256(payload).hexdigest(),
"size_bytes": len(payload) + 5, "mime_type": "text/csv"}
batch = self.client.post(f"{self.pre}/projects/{PROJECT}/import-batches",
json={"manifest": [entry]}, headers=HEADERS).json()["batch"]
response = self.send(batch, payload, name="x.csv")
self.assertEqual(response.status_code, 422, response.text)
after = self.client.get(f"{self.pre}/import-batches/{batch['id']}", headers=HEADERS).json()
self.assertEqual(after["items"][0]["status"], "failed")
def test_item_not_in_batch_rejected(self):
payload = b"field,value\nfacility.amount,100\n"
batch = self.make_batch(payload)
response = self.send(batch, payload, item_id="item-not-mine")
self.assertEqual(response.status_code, 404, response.text)
def test_filename_not_in_manifest_rejected(self):
payload = b"field,value\nfacility.amount,100\n"
batch = self.make_batch(payload, name="declared.csv")
response = self.client.post(f"{self.base}/projects/{PROJECT}/documents",
files={"file": ("other.csv", payload, "text/csv")},
data={"batch_id": batch["id"]}, headers=HEADERS)
self.assertEqual(response.status_code, 404, response.text)
def test_double_ingest_rejected(self):
payload = b"field,value\nfacility.amount,100\n"
batch = self.make_batch(payload)
self.assertEqual(self.send(batch, payload).status_code, 201)
again = self.send(batch, payload)
self.assertEqual(again.status_code, 409, again.text)
def test_cancelled_batch_refuses_ingest(self):
payload = b"field,value\nfacility.amount,100\n"
batch = self.make_batch(payload)
self.client.post(f"{self.pre}/import-batches/{batch['id']}/cancel",
json={"expected_revision": batch["revision"]}, headers=HEADERS)
response = self.send(batch, payload)
self.assertEqual(response.status_code, 409, response.text)
def test_plain_upload_without_batch_id_is_unchanged(self):
"""既有行为回归:不传 batch_id 时仍走原路径,返回解析候选。"""
payload = b"field,value\nfacility.amount,100\n"
response = self.client.post(f"{self.base}/projects/{PROJECT}/documents",
files={"file": ("plain.csv", payload, "text/csv")},
headers=HEADERS)
self.assertEqual(response.status_code, 201, response.text)
body = response.json()
self.assertIn("candidates", body)
self.assertNotIn("raw_base64", body)
self.assertTrue(body["id"].startswith("doc-"))
def test_end_to_end_batch_closes_the_loop(self):
"""S1 首次闭环:登记批次 → 上传 → 解析定位 → 可确认。"""
payload = "field,value,currency,unit,period,scope\nfacility.amount,100,CNY,万元,2025,合并\n".encode("utf-8-sig")
batch = self.make_batch(payload)
self.assertEqual(self.send(batch, payload).status_code, 201)
final = self.client.get(f"{self.pre}/import-batches/{batch['id']}", headers=HEADERS).json()
self.assertEqual(final["status"], "ready")
document_id = final["items"][0]["document_id"]
detail = self.client.get(f"{self.base}/projects/{PROJECT}/documents/{document_id}",
headers=HEADERS)
self.assertEqual(detail.status_code, 200, detail.text)
self.assertTrue(detail.json()["candidates"], "解析应产出候选字段")
if __name__ == "__main__":
unittest.main()