135 lines
5.0 KiB
Python
135 lines
5.0 KiB
Python
"""
|
||
报价模板 AI 映射入队路由。
|
||
|
||
本文件职责:校验 callback token,接收主账单元格清单,写入 recognition Stream 后快回。
|
||
禁止:在本请求线程调千问 / 等完整 mapping;禁止直连 MySQL。
|
||
Worker:recognition 槽认领 kind=quote_template_parse 后跑 mode_sheet_map。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Any, Optional
|
||
|
||
from fastapi import APIRouter, Header, HTTPException, Response
|
||
from pydantic import BaseModel, Field
|
||
|
||
from agent.config import get_settings
|
||
from agent.jobs.recognition import enqueue_recognition
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter(prefix="/internal/quote-template", tags=["quote-template"])
|
||
|
||
|
||
class QuoteTemplateParseBody(BaseModel):
|
||
"""主账提交的模板映射解析任务。"""
|
||
|
||
version_id: str = Field(default="", alias="versionId")
|
||
template_id: str = Field(default="", alias="templateId")
|
||
template_name: str = Field(default="", alias="templateName")
|
||
file_name: str = Field(default="", alias="fileName")
|
||
biz_type: str = Field(default="", alias="bizType")
|
||
transport_mode: str = Field(default="sea", alias="transportMode")
|
||
sheet: str = ""
|
||
cell_inventory: list[Any] = Field(default_factory=list, alias="cellInventory")
|
||
cell_count: int = Field(default=0, alias="cellCount")
|
||
yellow_count: int = Field(default=0, alias="yellowCount")
|
||
truncated: bool = False
|
||
request_id: str = Field(default="", alias="requestId")
|
||
# 未换文件重新解析:true + existingMapping,回写时智能合并(对齐保留、对不上改坐标)
|
||
merge_existing: bool = Field(default=False, alias="mergeExisting")
|
||
existing_mapping: Optional[dict[str, Any]] = Field(default=None, alias="existingMapping")
|
||
|
||
model_config = {"populate_by_name": True}
|
||
|
||
|
||
def _expected_token() -> str:
|
||
s = get_settings()
|
||
return (s.agent_callback_token or s.inquiry_agent_callback_token or "").strip()
|
||
|
||
|
||
def _check_token(authorization: Optional[str], x_agent_token: Optional[str]) -> None:
|
||
expected = _expected_token()
|
||
if not expected:
|
||
if (get_settings().ytd_env or "").lower() == "prod":
|
||
raise HTTPException(status_code=503, detail="callback token 未配置")
|
||
logger.warning("quote-template parse:未配置 callback token,test 允许空校验")
|
||
return
|
||
provided = (x_agent_token or "").strip()
|
||
if not provided and authorization:
|
||
auth = authorization.strip()
|
||
if auth.lower().startswith("bearer "):
|
||
provided = auth[7:].strip()
|
||
else:
|
||
provided = auth
|
||
if provided != expected:
|
||
raise HTTPException(status_code=401, detail="unauthorized")
|
||
|
||
|
||
@router.get("/ping")
|
||
def parse_ping() -> dict[str, str]:
|
||
return {"status": "ok", "channel": "quote-template-parse"}
|
||
|
||
|
||
@router.post("/parse")
|
||
def enqueue_parse(
|
||
body: QuoteTemplateParseBody,
|
||
response: Response,
|
||
authorization: Optional[str] = Header(default=None),
|
||
x_agent_callback_token: Optional[str] = Header(
|
||
default=None, alias="X-Agent-Callback-Token"
|
||
),
|
||
) -> dict[str, Any]:
|
||
"""
|
||
接收主账解析任务:入 recognition 队列后立即 202。
|
||
|
||
不在此调用千问。
|
||
"""
|
||
_check_token(authorization, x_agent_callback_token)
|
||
version_id = (body.version_id or "").strip()
|
||
if not version_id:
|
||
raise HTTPException(status_code=400, detail="versionId 必填")
|
||
inventory = body.cell_inventory or []
|
||
if not inventory:
|
||
raise HTTPException(status_code=400, detail="cellInventory 不能为空")
|
||
|
||
payload = {
|
||
"versionId": version_id,
|
||
"templateId": body.template_id,
|
||
"templateName": body.template_name,
|
||
"fileName": body.file_name,
|
||
"bizType": body.biz_type,
|
||
"transportMode": (body.transport_mode or "sea").strip().lower() or "sea",
|
||
"sheet": body.sheet or "",
|
||
"cellInventory": inventory,
|
||
"cellCount": body.cell_count or len(inventory),
|
||
"yellowCount": body.yellow_count,
|
||
"truncated": bool(body.truncated),
|
||
"mergeExisting": bool(body.merge_existing),
|
||
}
|
||
if body.merge_existing and isinstance(body.existing_mapping, dict):
|
||
payload["existingMapping"] = body.existing_mapping
|
||
result = enqueue_recognition(
|
||
kind="quote_template_parse",
|
||
payload=payload,
|
||
idempotency_key=(body.request_id or "").strip()
|
||
or f"quote-tpl-parse:{version_id}:{len(inventory)}",
|
||
)
|
||
if not result.accepted:
|
||
# 幂等重复:视为已接受,避免主账把「重复」当成失败
|
||
if result.reason == "duplicate_idempotency_key":
|
||
response.status_code = 202
|
||
return {"accepted": True, "entry_id": "", "reason": "duplicate_idempotency_key"}
|
||
response.status_code = 503
|
||
return {"accepted": False, "reason": result.reason or "enqueue_failed"}
|
||
|
||
response.status_code = 202
|
||
logger.info(
|
||
"quote_template.parse.enqueued versionId=%s cells=%s entry=%s",
|
||
version_id,
|
||
len(inventory),
|
||
result.entry_id,
|
||
)
|
||
return {"accepted": True, "entry_id": result.entry_id}
|