Files
AgentLab/backend/app/api/table_imports.py
T
ThearchyHelios 941723b17c 端到端验收修复与指标出具:
- 身份与工具:登录页建第一个管理员(认人接口的 401 说明能不能建);搭建者有只读的人和组名录,选人给候选并核对名字;授权三行带标题;端点请求头名校验(空名、非法名存不进、发不出时说清原因);写入能力在工具页试运行一律演练、不发送,HTTP 状态照实说成败;写记忆不算只读、查询能力只能选同一端点上的只读能力;审批与遮罩的说法和实际规则一致;开登录时不要署名、导入和发布记登录的人;使用者不显示推演等只给搭建者的操作,403 按是否开登录说话;登录页只认一次人
- 流程与实例:图里用到的岗位(派给谁、超时升级给谁、发送确认派给谁)全部派生、没绑人的发布时报错;写外部系统的参数按能力声明的类型收拢后再算哈希和发送,收不成就不发;发起人的实例页只看这一步的状态,处理人才给待办卡;会签进度带登录核实和时间,升级后照实写规则;实例状态写流程的结论(已退回、未确认、等待核实写入结果),核实生效后不再挂失败原因;流程应用首页不派生最近封存运行的结果块,看板没数据时是安静的空态;样例⑩的金额带类型和下限、待办标题带金额,表单上下限前后端一起生效
- 看板与证据:定时任务模板加 ISO 周、上一 ISO 周、本月、上月;出处链穿过原样解析查询结果的整形节点,追不到原始数据的照实说停在哪里,样例⑨改用 cell();应用首页和版本历史都能做成应用;指标块单位不重复;版本列表带退役说明;定时任务页头跟着刷新;删视图要确认、新视图能起名;编辑器属性用中文名、术语带解释
- 指标出具(用户 2026-10-15 拍板):契约只写「指标来自」、上游没有报告撰写节点、成果里没有模型写的话时按指标出具——必需指标齐、指标输入追到原始数据、成果字段里的数回指得上才完整出具;受管级要求每个指标静态地追到原始数据(governed.metric_untraced);出具横幅和应用印章写明按指标
- 受管工作流降为已发布只给管理员(Q8 顺延,服务端同样拦);工作流接口带当前发布版的级别,画布上改过之后也认得出受管工作流
2026-10-15 07:15:37 +08:00

501 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""按配方导入的接口(期 2,WP-5c;P2-SPEC 7.3):暂存、回答问题、改配方、AI 起草、试运行、提交、放弃,
以及「上传新一期」「修改配方」「查看当前配方」。
流程和状态都在 app.data.recipe_imports 里,这里只做三件事:解析请求、取署名、把业务错误转成带机读码的
CodedHTTPException(body 是 {"detail": 中文, "code": 机读码},前端按 code 分支)。
**请求模型里不放会触发 FastAPI 自带 422 的必填项**:它的 body 是 {detail: [...]}、不带 code,前端没法分支。
所以 JSON 请求体一律手工读成 dict(不是 JSON 对象才回 422 body_invalid),各字段由业务代码检查。
路由路径都以 /imports/ 开头,或是 /{source_id}/ 后面跟固定的 reupload、redraft、recipe,与 datasources.py
现有的 /{source_id}、/{source_id}/test|introspect|schema|imports 不重叠;在 main.py 里登记在 datasources
之前,免得 /imports/… 被通配的 {source_id} 先截走。
期 3(WP-5)加了 edits/preview、edits/apply、edits/undo、redraft-rules,PUT recipe 收可选的 origin;只读进程
(没拿到版本存储的守卫)里会写版本存储的入口一律回 503 store_unavailable(第 8 节遗留项 5),body 照样是
{detail, code}:前端把不是 JSON 的 503 当成网络断开。
"""
from __future__ import annotations
import datetime as _dt
import logging
import re
from typing import Any
from fastapi import APIRouter, Depends, File, Form, Header, Request, UploadFile
from fastapi.responses import JSONResponse, Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.coded import CodedHTTPException
from app.api.datasources import _NAME_PATTERN, _out, read_upload
from app.api.deps import current_principal, signer_of
from app.data import recipe_imports, table_versions
from app.data.recipe_imports import EditRequest, ImportRefused
from app.data.recipe_types import Acceptance, AiAvailability, PeriodInput, Recipe, Selection
from app.db.base import get_session
from app.db.models import DataSource, ImportStaging, TableRecipe
router = APIRouter(prefix="/api/datasources", tags=["table-imports"])
logger = logging.getLogger(__name__)
_NAME_INVALID = "名称必须以小写字母开头,只能包含字母、数字和下划线(名称会成为工具名的一部分)"
_SIGNED_MAX = 100
#: 业务上的拒绝:ImportRefused 带着状态和机读码;StoreUnavailable 是只读进程拒写(503)
_REFUSALS = (ImportRefused, table_versions.StoreUnavailable)
def _refused(e: Exception) -> CodedHTTPException:
if isinstance(e, table_versions.StoreUnavailable):
return CodedHTTPException(503, str(e), "store_unavailable")
return CodedHTTPException(e.status, e.message, e.code) # type: ignore[attr-defined]
def _signed(body: dict[str, Any] | None, header: str | None) -> str | None:
"""署名:开了登录是登录的人;关登录时请求体的 signed_by 优先,其次请求头 X-Actor,只原样记下(未认证)。"""
raw = (body or {}).get("signed_by") if isinstance(body, dict) else None
return signer_of(raw, header, _SIGNED_MAX)
def _login_on() -> bool:
return current_principal().mode != "off"
#: JSON 请求体的字节上限和嵌套层数上限(SE-5)。最大的合法配方(200 个标签、几十个分段)也只有一两百 KB、
#: 十来层;更大更深的不是配方,照存会让静态校验跑几十秒、占着解析名额,深层嵌套还会让响应序列化失败
BODY_MAX_BYTES = 1_000_000
BODY_MAX_DEPTH = 32
def _json_depth_over(raw: bytes, limit: int) -> bool:
depth = 0
in_str = esc = False
for ch in raw.decode("utf-8", errors="replace"):
if in_str:
if esc:
esc = False
elif ch == "\\":
esc = True
elif ch == '"':
in_str = False
elif ch == '"':
in_str = True
elif ch in "[{":
depth += 1
if depth > limit:
return True
elif ch in "]}":
depth -= 1
return False
async def _body(request: Request) -> dict[str, Any]:
"""JSON 请求体。空 body 当作 {};超过字节上限回 413 body_too_large;不是 JSON 对象、嵌套过深回 422 body_invalid。"""
length = request.headers.get("content-length")
if length and length.isdigit() and int(length) > BODY_MAX_BYTES:
raise CodedHTTPException(413, f"请求体超过 {BODY_MAX_BYTES // 1_000_000} MB 的上限", "body_too_large")
raw = await request.body()
if len(raw) > BODY_MAX_BYTES:
raise CodedHTTPException(413, f"请求体超过 {BODY_MAX_BYTES // 1_000_000} MB 的上限", "body_too_large")
if not raw.strip():
return {}
if _json_depth_over(raw, BODY_MAX_DEPTH):
raise CodedHTTPException(422, f"请求体的 JSON 嵌套超过 {BODY_MAX_DEPTH} 层", "body_invalid")
try:
data = await request.json()
except (ValueError, RecursionError):
data = None
if not isinstance(data, dict):
raise CodedHTTPException(422, "请求体必须是一个 JSON 对象", "body_invalid")
return data
async def _staging(session: AsyncSession, staging_id: str) -> ImportStaging:
row = await session.get(ImportStaging, staging_id)
if row is None:
raise CodedHTTPException(404, "这次导入不存在,可能已过期并被清理。请重新开始导入", "staging_not_found")
return row
async def _open_staging(session: AsyncSession, staging_id: str) -> ImportStaging:
"""写操作要的暂存区:先让过期的过期(不能指望只有回收时才过期),再要求它未结束。"""
await table_versions.expire_stagings(session)
row = await _staging(session, staging_id)
if not table_versions.staging_is_open(row):
raise CodedHTTPException(409, recipe_imports._CLOSED, "staging_closed")
return row
async def _source(session: AsyncSession, source_id: str) -> DataSource:
row = await session.get(DataSource, source_id)
if row is None:
raise CodedHTTPException(404, "数据源不存在,可能已被删除", "source_not_found")
return row
async def _out_of(session: AsyncSession, staging: ImportStaging) -> dict[str, Any]:
"""StagingOut。提供 AI 起草时才去查模型接入的可用性(不提供时不必多一次查询)。"""
source = await session.get(DataSource, staging.source_id)
pipe = recipe_imports.pipeline()
ai: AiAvailability | None = None
if table_versions.staging_is_open(staging) and recipe_imports.ai_offered(staging):
try:
ai = await pipe.ai_availability(session)
except Exception as e: # noqa: BLE001 - 可用性只是提示,查不到按不可用显示
logger.warning("ai availability failed: %s", type(e).__name__)
ai = AiAvailability(False, reason="暂时无法确认模型接入是否可用。请到「设置 → 模型接入」检查")
return recipe_imports.staging_out(staging, source, ai=ai, units=tuple(pipe.units) or None)
def _unreadable(e: Exception) -> CodedHTTPException:
return CodedHTTPException(400, str(e) or "无法读取这个文件,请确认是未加密的 Excel 文件", "file_unreadable")
# --------------------------------------------------------------------------
# 暂存、查看、放弃
# --------------------------------------------------------------------------
@router.post("/imports/stage", status_code=201)
async def stage(
file: UploadFile | None = File(None),
name: str = Form(""),
description: str = Form(""),
signed_by: str = Form(""),
x_actor: str | None = Header(default=None),
session: AsyncSession = Depends(get_session),
) -> Any:
"""选文件开始一次按配方导入:原件存档、扫描、网格预览、系统发现、规则起草。新名字 kind=first;
同名的简单导入源 kind=switch。"""
from app.data.tabular import UnsupportedTable
if file is None:
raise CodedHTTPException(422, "请选择要导入的文件", "body_invalid")
if not re.match(_NAME_PATTERN, name or ""):
raise CodedHTTPException(400, _NAME_INVALID, "name_invalid")
raw = await read_upload(file)
try:
staging = await recipe_imports.stage_upload(
session, raw=raw, filename=file.filename or name, name=name, description=description or "",
signed_by=_signed({"signed_by": signed_by}, x_actor))
except _REFUSALS as e:
raise _refused(e) from e
except UnsupportedTable as e:
raise _unreadable(e) from e
return JSONResponse(status_code=201, content=await _out_of(session, staging))
@router.get("/imports/{staging_id}")
async def get_staging(staging_id: str, session: AsyncSession = Depends(get_session)) -> Any:
await table_versions.expire_stagings(session)
return await _out_of(session, await _staging(session, staging_id))
@router.delete("/imports/{staging_id}", status_code=204)
async def discard(staging_id: str, session: AsyncSession = Depends(get_session)) -> Response:
staging = await _open_staging(session, staging_id)
try:
await recipe_imports.discard_staging(session, staging)
except _REFUSALS as e:
raise _refused(e) from e
return Response(status_code=204)
# --------------------------------------------------------------------------
# 回答问题、改配方
# --------------------------------------------------------------------------
@router.post("/imports/{staging_id}/answers")
async def answers(staging_id: str, request: Request, session: AsyncSession = Depends(get_session)) -> Any:
body = await _body(request)
given = body.get("answers", {})
if not isinstance(given, dict):
raise CodedHTTPException(422, "回答必须是「问题 → 选项」的对象", "body_invalid")
staging = await _open_staging(session, staging_id)
try:
staging = await recipe_imports.answer(session, staging, given)
except _REFUSALS as e:
raise _refused(e) from e
return await _out_of(session, staging)
@router.put("/imports/{staging_id}/recipe")
async def put_recipe(staging_id: str, request: Request, session: AsyncSession = Depends(get_session)) -> Any:
body = await _body(request)
recipe = body.get("recipe")
if not isinstance(recipe, dict):
raise CodedHTTPException(422, "配方必须是一个 JSON 对象", "body_invalid")
origin = body.get("origin")
if origin not in (None, "rules_redraft"):
raise CodedHTTPException(422, "配方来源只能不填,或填 rules_redraft(采用按规则重新起草的结果)", "body_invalid")
staging = await _open_staging(session, staging_id)
try:
staging = await recipe_imports.put_recipe(session, staging, recipe, origin=origin or "manual")
except _REFUSALS as e:
raise _refused(e) from e
return await _out_of(session, staging)
# --------------------------------------------------------------------------
# 期 3:修复按钮、框选、撤销、按规则重新起草
# --------------------------------------------------------------------------
_EDIT_SHAPE = "请求里要么给修复(fix),要么给框选(selection),二选一"
def _edit_request(body: dict[str, Any]) -> EditRequest:
"""{"fix": {id, option, reason?}} 或 {"selection": {sheet, ref, as, options?}},二选一(9.1)。形状不对回 422
edit_invalid;框选的形状校验交给契约 Selection.from_json(只抛 ValueError),输出时再经 to_json 改回 as。"""
fix, sel = body.get("fix"), body.get("selection")
if (fix is None) == (sel is None):
raise CodedHTTPException(422, _EDIT_SHAPE, "edit_invalid")
if fix is not None:
if (not isinstance(fix, dict) or not isinstance(fix.get("id"), str) or not fix["id"]
or not isinstance(fix.get("option"), str) or not fix["option"]):
raise CodedHTTPException(422, "修复请求要指明是哪条修复建议(id)和选的哪一项(option)", "edit_invalid")
reason = fix.get("reason")
if reason is not None and not isinstance(reason, str):
raise CodedHTTPException(422, "理由必须是一段文字", "edit_invalid")
return EditRequest(kind="fix", fix_id=fix["id"], option=fix["option"], reason=reason)
try:
return EditRequest(kind="selection", selection=Selection.from_json(sel))
except ValueError as e:
raise CodedHTTPException(422, str(e) or "框选的请求格式不对", "edit_invalid") from e
@router.post("/imports/{staging_id}/edits/preview")
async def edits_preview(staging_id: str, request: Request, session: AsyncSession = Depends(get_session)) -> Any:
"""修复或框选的预览(不改工作配方)。seq 原样回传:界面只采用最新一次的回包。"""
body = await _body(request)
req = _edit_request(body)
staging = await _open_staging(session, staging_id)
try:
return await recipe_imports.edit_preview(session, staging, req, seq=body.get("seq"))
except _REFUSALS as e:
raise _refused(e) from e
@router.post("/imports/{staging_id}/edits/apply")
async def edits_apply(staging_id: str, request: Request, x_actor: str | None = Header(default=None),
session: AsyncSession = Depends(get_session)) -> Any:
"""应用预览过的修复或框选:选项和理由与预览时逐字相同,expected_sha256 是预览给的 recipe_sha256_after。"""
body = await _body(request)
req = _edit_request(body)
expected = body.get("expected_sha256")
staging = await _open_staging(session, staging_id)
try:
staging = await recipe_imports.edit_apply(
session, staging, req, expected_sha256=expected if isinstance(expected, str) else None,
signed_by=_signed(body, x_actor))
except _REFUSALS as e:
raise _refused(e) from e
return await _out_of(session, staging)
@router.post("/imports/{staging_id}/edits/undo")
async def edits_undo(staging_id: str, request: Request, session: AsyncSession = Depends(get_session)) -> Any:
await _body(request)
staging = await _open_staging(session, staging_id)
try:
staging = await recipe_imports.edit_undo(session, staging)
except _REFUSALS as e:
raise _refused(e) from e
return await _out_of(session, staging)
@router.post("/imports/{staging_id}/redraft-rules")
async def redraft_rules(staging_id: str, request: Request, session: AsyncSession = Depends(get_session)) -> Any:
"""按规则重新起草(不调用模型、不改工作配方),名字对齐到现行配方;「采用」走 PUT recipe(origin=rules_redraft)。"""
from app.data.tabular import UnsupportedTable
await _body(request)
staging = await _open_staging(session, staging_id)
try:
return await recipe_imports.redraft_rules(session, staging)
except _REFUSALS as e:
raise _refused(e) from e
except UnsupportedTable as e:
raise _unreadable(e) from e
# --------------------------------------------------------------------------
# AI 起草
# --------------------------------------------------------------------------
@router.get("/imports/{staging_id}/draft-ai/preview")
async def draft_ai_preview(staging_id: str, session: AsyncSession = Depends(get_session)) -> Any:
staging = await _open_staging(session, staging_id)
try:
return await recipe_imports.ai_preview(session, staging)
except _REFUSALS as e:
raise _refused(e) from e
@router.post("/imports/{staging_id}/draft-ai")
async def draft_ai(staging_id: str, request: Request, x_actor: str | None = Header(default=None),
session: AsyncSession = Depends(get_session)) -> Any:
body = await _body(request)
sha = body.get("preview_sha256")
staging = await _open_staging(session, staging_id)
try:
staging = await recipe_imports.run_ai_draft(
session, staging, consent=body.get("consent") is True,
preview_sha256=sha if isinstance(sha, str) else None, signed_by=_signed(body, x_actor))
except _REFUSALS as e:
raise _refused(e) from e
return await _out_of(session, staging)
# --------------------------------------------------------------------------
# 试运行、提交
# --------------------------------------------------------------------------
def _context_inputs(raw: Any, signed_by: str | None) -> dict[str, PeriodInput] | None:
"""{"统计期": {"start": "2026-09-01", "end": "2026-09-30"}} → PeriodInput。不合法回 422 context_invalid。"""
if raw is None or raw == {}:
return None
bad = CodedHTTPException(422, "统计期的录入不对:请填写起止日期(YYYY-MM-DD),起点不能晚于终点", "context_invalid")
if not isinstance(raw, dict):
raise bad
out: dict[str, PeriodInput] = {}
for key, value in raw.items():
if key != recipe_imports.PERIOD_KEY or not isinstance(value, dict):
raise bad
try:
start = _dt.date.fromisoformat(str(value.get("start") or ""))
end = _dt.date.fromisoformat(str(value.get("end") or ""))
except ValueError:
raise bad from None
if start > end:
raise bad
who = value.get("signed_by")
# 开了登录时 signed_by 已经是登录的人,这里填的不看(signer_of 同一条)
out[key] = PeriodInput(start=start, end=end,
signed_by=(str(who).strip()[:_SIGNED_MAX]
if isinstance(who, str) and who.strip() and not _login_on() else signed_by))
return out
@router.post("/imports/{staging_id}/trial")
async def trial(staging_id: str, request: Request, x_actor: str | None = Header(default=None),
session: AsyncSession = Depends(get_session)) -> Any:
body = await _body(request)
who = _signed(body, x_actor)
inputs = _context_inputs(body.get("context_inputs"), who)
staging = await _open_staging(session, staging_id)
try:
staging = await recipe_imports.run_trial(session, staging, context_inputs=inputs, signed_by=who)
except _REFUSALS as e:
raise _refused(e) from e
return await _out_of(session, staging)
def _acceptances(raw: Any, who: str | None) -> list[Acceptance]:
if raw is None:
return []
if not isinstance(raw, list):
raise CodedHTTPException(422, "接受理由必须是「核对 → 理由」的列表", "body_invalid")
out = []
for a in raw:
if not isinstance(a, dict) or not isinstance(a.get("check_id"), str):
raise CodedHTTPException(422, "接受理由必须是「核对 → 理由」的列表", "body_invalid")
reason = a.get("reason")
out.append(Acceptance(check_id=a["check_id"], reason=reason if isinstance(reason, str) else "",
signed_by=who))
return out
@router.post("/imports/{staging_id}/commit", status_code=201)
async def commit(staging_id: str, request: Request, x_actor: str | None = Header(default=None),
session: AsyncSession = Depends(get_session)) -> Any:
body = await _body(request)
who = _signed(body, x_actor)
confirmations = body.get("confirmations", [])
if not isinstance(confirmations, list) or not all(isinstance(x, str) for x in confirmations):
raise CodedHTTPException(422, "确认项必须是 id 的列表", "body_invalid")
trial_id = body.get("trial_id")
staging = await _open_staging(session, staging_id)
try:
result = await recipe_imports.commit_staging(
session, staging, trial_id=trial_id if isinstance(trial_id, str) else "",
confirmations=confirmations, acceptances=_acceptances(body.get("acceptances"), who), signed_by=who)
except _REFUSALS as e:
raise _refused(e) from e
source = await session.get(DataSource, result.source_id, populate_existing=True)
return JSONResponse(status_code=201, content={
"source": (await _out(session, source)).model_dump() if source is not None else None,
"import_id": result.import_id, "snapshot_id": result.snapshot_id, "build_id": result.build_id,
"recipe_id": result.recipe_id, "build_reused": result.build_reused, "unchanged": result.unchanged,
# 期 3:同版本数据文件被改过、已用本次的文件恢复;启用后当前版本含几期;直接启用了内容相同的已有版本
"build_restored": result.build_restored, "parts": result.parts, "snapshot_reused": result.snapshot_reused,
})
# --------------------------------------------------------------------------
# 上传新一期、修改配方、当前配方
# --------------------------------------------------------------------------
@router.post("/{source_id}/reupload", status_code=201)
async def reupload(
source_id: str,
file: UploadFile | None = File(None),
signed_by: str = Form(""),
x_actor: str | None = Header(default=None),
session: AsyncSession = Depends(get_session),
) -> Any:
"""上传新一期:按当前配方自动试运行(不调模型),回来时已带回执和差异卡。"""
from app.data.tabular import UnsupportedTable
source = await _source(session, source_id)
if not source.current_recipe_id:
raise CodedHTTPException(409, recipe_imports._NOT_RECIPE, "not_recipe_source")
if file is None:
raise CodedHTTPException(422, "请选择要导入的文件", "body_invalid")
raw = await read_upload(file)
try:
staging = await recipe_imports.reupload(session, source, raw=raw, filename=file.filename or source.name,
signed_by=_signed({"signed_by": signed_by}, x_actor))
except _REFUSALS as e:
raise _refused(e) from e
except UnsupportedTable as e:
raise _unreadable(e) from e
return JSONResponse(status_code=201, content=await _out_of(session, staging))
@router.post("/{source_id}/redraft", status_code=201)
async def redraft(source_id: str, request: Request, x_actor: str | None = Header(default=None),
session: AsyncSession = Depends(get_session)) -> Any:
from app.data.tabular import UnsupportedTable
body = await _body(request)
source = await _source(session, source_id)
try:
staging = await recipe_imports.redraft(session, source, signed_by=_signed(body, x_actor))
except _REFUSALS as e:
raise _refused(e) from e
except UnsupportedTable as e:
raise _unreadable(e) from e
return JSONResponse(status_code=201, content=await _out_of(session, staging))
@router.get("/{source_id}/recipe")
async def get_recipe(source_id: str, session: AsyncSession = Depends(get_session)) -> Any:
source = await _source(session, source_id)
row = await session.get(TableRecipe, source.current_recipe_id) if source.current_recipe_id else None
if row is None:
raise CodedHTTPException(409, recipe_imports._NOT_RECIPE, "not_recipe_source")
try:
full = Recipe.model_validate(row.recipe).model_dump(mode="json")
except Exception: # noqa: BLE001 - 存下的配方都是校验过的;万一读不出,原样给出
full = row.recipe
return {
"recipe_id": row.id, "seq": row.seq, "origin": row.origin, "recipe": full,
"recipe_sha256": row.recipe_sha256, "confirmations": list(row.confirmations or []),
"signed_by": row.signed_by, "activated_at": row.activated_at.isoformat() if row.activated_at else None,
}