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:
@@ -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):
|
||||
"""请求体/查询参数校验失败也走统一信封。
|
||||
|
||||
|
||||
@@ -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"])))
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user