"""外卖比价业务透传端点。 把客户端的 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_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: async with httpx.AsyncClient(timeout=timeout) as client: resp = await client.post( url, content=raw, headers={"Content-Type": "application/json"} ) 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("/price/step", summary="外卖比价 Phase 2 步进 (透传到 pricebot)") async def price_step(request: Request) -> dict[str, Any]: return await _passthrough(request, "/api/price/step")