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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user