合并远程报价模板 AI 解析与超时设置:保留私聊图片和附件询价。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-18 17:47:48 +08:00
co-authored by Cursor
41 changed files with 4972 additions and 438 deletions
@@ -0,0 +1,128 @@
"""
报价模板 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")
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),
}
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}