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
+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()