b7b958ed58
coupon/compare 透传原先每请求 async with httpx.AsyncClient(...) 新建: 每次重建 SSL 上下文(加载 certifi CA)实测 ~1s+,而 pricebot 是纯 HTTP 透传根本用不到 TLS;且 trust_env 默认 True 会把 http://localhost:8000 经进程代理(Clash)再绕几秒。 抽 app/core/pricebot_client.py 共享单例:trust_env=False 直连,lifespan 启动预热(SSL 一次性成本付在启动)、关停 aclose;timeout 下放到 .post() 保留 coupon 30s / compare 60s 差异。keep-alive 复用 TCP,每帧降到个位数 ms。 --------- Co-authored-by: guke <guke@autohome.com.cn> Reviewed-on: #87 Co-authored-by: guke <guke@wonderable.ai> Co-committed-by: guke <guke@wonderable.ai>
128 lines
5.4 KiB
Python
128 lines
5.4 KiB
Python
"""外卖比价业务透传端点。
|
|
|
|
把客户端的 POST /api/v1/intent/recognize 和 /api/v1/price/step 请求原样转发给
|
|
pricebot-backend 的 /api/intent/recognize 和 /api/price/step。MVP 阶段**不鉴权**
|
|
(同 coupon/step,见 docs/待办与技术债.md P1:device_id 透传区分设备,待补 JWT +
|
|
device_id↔user_id 绑定后才能做用户级画像)。
|
|
|
|
- Phase 1 /intent/recognize:从源平台(淘宝闪购/美团/京东外卖)购物车页识别店名+菜品+
|
|
价格,一次性。
|
|
- Phase 2 /price/step:多轮循环,在目标平台复现订单读到手价,直到 done。
|
|
|
|
真正的目标驱动比价逻辑在 pricebot-backend(GoalEngine,另一个 repo),本接口只是透传壳。
|
|
电商(ecom)那两个端点 food MVP 暂不需要,以后放开 scene 时再加 /ecom/intent/recognize
|
|
和 /ecom/step 两行即可。
|
|
|
|
pricebot 协议文档:
|
|
pricebot-backend/docs/main/02_api_protocol.md
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from typing import Any
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, HTTPException, Request, status
|
|
|
|
from app.core.config import settings
|
|
from app.core.pricebot_client import get_pricebot_client
|
|
from app.core.pricebot_router import pick_pricebot
|
|
|
|
logger = logging.getLogger("shagua.compare")
|
|
|
|
router = APIRouter(prefix="/api/v1", tags=["compare"])
|
|
|
|
|
|
async def _passthrough(request: Request, upstream_path: str) -> dict[str, Any]:
|
|
"""把请求体原样转发给 pricebot-backend 的 {upstream_path}。
|
|
|
|
跟 coupon_step 同款透传壳:不鉴权、不做 schema 校验,仅读 device_id/trace_id/step
|
|
打日志。比价单帧是大上下文 / 逐帧 LLM,超时用 PRICEBOT_COMPARE_TIMEOUT_SEC(60s,
|
|
比领券的 30s 长)。
|
|
"""
|
|
# 读原始字节,避免"反序列化→再序列化"的双重 JSON(省 ~一半透传 CPU,让 app-server
|
|
# 单 worker 也扛得住高并发)。只 json.loads 一次拿 trace_id 做亲和 + 打日志,转发时
|
|
# 直接发原始 bytes(content=raw),不重新 dumps。
|
|
raw = await request.body()
|
|
try:
|
|
meta = json.loads(raw)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=400, detail=f"invalid json body: {e}") from e
|
|
if not isinstance(meta, dict):
|
|
meta = {}
|
|
|
|
# 按 trace_id 一致性 hash 选 pricebot 实例(同一比价的所有帧落同一进程,内存维护 state)
|
|
base = pick_pricebot(meta.get("trace_id"))
|
|
url = f"{base.rstrip('/')}{upstream_path}"
|
|
timeout = settings.PRICEBOT_COMPARE_TIMEOUT_SEC
|
|
|
|
logger.info(
|
|
"compare %s device_id=%s trace_id=%s step=%s",
|
|
upstream_path,
|
|
meta.get("device_id"),
|
|
meta.get("trace_id"),
|
|
meta.get("step"),
|
|
)
|
|
|
|
try:
|
|
client = get_pricebot_client()
|
|
resp = await client.post(
|
|
url, content=raw, headers={"Content-Type": "application/json"}, timeout=timeout
|
|
)
|
|
except httpx.RequestError as e:
|
|
logger.error("[pricebot] request failed: %s", e)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=f"pricebot upstream unreachable: {e}",
|
|
) from e
|
|
|
|
if resp.status_code >= 500:
|
|
logger.error(
|
|
"[pricebot] 5xx status=%d body=%s",
|
|
resp.status_code,
|
|
resp.text[:500],
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=f"pricebot upstream returned {resp.status_code}",
|
|
)
|
|
|
|
return resp.json()
|
|
|
|
|
|
@router.post("/intent/recognize", summary="外卖比价 Phase 1 意图识别 (透传到 pricebot)")
|
|
async def intent_recognize(request: Request) -> dict[str, Any]:
|
|
return await _passthrough(request, "/api/intent/recognize")
|
|
|
|
|
|
@router.post("/intent/step", summary="外卖比价 Phase 1 多帧意图识别 (透传到 pricebot, 仅淘宝源)")
|
|
async def intent_step(request: Request) -> dict[str, Any]:
|
|
# 多帧版意图识别(展开+滚动采集→提取): 循环调用直到 done(done 帧顶层带
|
|
# result+calibration)。目前仅淘宝源走这条, 其它源走上面单次 /intent/recognize。
|
|
return await _passthrough(request, "/api/intent/step")
|
|
|
|
|
|
@router.post(
|
|
"/intent/precoupon/step",
|
|
summary="外卖比价 Phase 0 意图识别前先用券 (透传到 pricebot, 仅美团源)",
|
|
)
|
|
async def intent_precoupon_step(request: Request) -> dict[str, Any]:
|
|
# 美团源平台『意图识别前先用券』多帧循环: 客户端在调 /intent/recognize 之前先循环
|
|
# 调本端点到 done(continue=false)。订单页底部有『点击使用X红包』就自动选最大免费券
|
|
# 用上, 已用券/无券则首帧秒过。与 /intent/step 同属 intent 域, 复用同一透传壳。
|
|
return await _passthrough(request, "/api/intent/precoupon/step")
|
|
|
|
|
|
@router.post("/price/step", summary="外卖比价 Phase 2 步进 (透传到 pricebot)")
|
|
async def price_step(request: Request) -> dict[str, Any]:
|
|
return await _passthrough(request, "/api/price/step")
|
|
|
|
|
|
@router.post("/trace/finalize", summary="比价 trace 收尾上云 (透传到 pricebot, 终止/未识别拿 trace_url)")
|
|
async def trace_finalize(request: Request) -> dict[str, Any]:
|
|
# 用户终止 / Phase1 未识别没走到 done 帧, pricebot 没上云也没回传 trace_url。客户端收尾时
|
|
# 打这个, _passthrough 按 trace_id 一致性 hash 落到处理这条 trace 的同一 pricebot 进程
|
|
# (dir_cache 在那, 才能算对 trace 目录), 由后者打包上云返回 {trace_url}。
|
|
return await _passthrough(request, "/api/trace/finalize")
|