Files
2026-05-29 13:27:41 +08:00

192 lines
7.1 KiB
Python

"""POST /api/v1/arena-quote —— 多比比"比价擂台"接口。
用户输入一个商品名,后端用 LLM 估出该商品在主流电商各平台的「到手价」
以及「市场常见价」(中位)。客户端把估价铺成"多平台对比表",再用本机
真实记录覆盖对应平台,最低价高亮。
application/json:
{"title": "iPhone 15 Pro 256GB", "platforms": ["淘宝","京东",...](可选)}
响应:
{"title": "...(归一化)", "typical_price": 8299.0,
"quotes": [{"platform":"淘宝","price":8099.0,"note":"..."}, ...],
"lowest_platform": "拼多多"}
兜底:LLM 给了 typical 但缺平台报价 → 围绕 typical 确定性合成,保证表格每行有值;
完全估不出价格 → 422 llm_no_quote。
"""
from __future__ import annotations
import hashlib
import json
import logging
import re
from typing import Optional
from fastapi import APIRouter, HTTPException
from app.llm_client import MOCK_LLM, chat
from app.schemas import ArenaPlatformQuote, ArenaQuoteRequest, ArenaQuoteResponse
logger = logging.getLogger("app.arena")
router = APIRouter(prefix="/api/v1")
# 比价擂台默认对比 4 大主流电商(同一实体商品可跨平台买)。
# 美团 / 饿了么是本地生活 O2O,不进默认对比池,但客户端可显式传入 platforms 覆盖。
DEFAULT_PLATFORMS = ["淘宝", "京东", "拼多多", "抖音"]
SYSTEM_PROMPT = """你是一个电商多平台价格估算助手。
# 任务
用户给你一个商品标题和一组电商平台。请估算该商品在每个平台的「日常到手价」
(实付价,不含双 11 / 618 等大促),以及该商品的「市场常见价」(各平台中位值)。
# 估算原则
- 同一商品在不同平台价格通常有差异:拼多多 / 百亿补贴常偏低,京东自营偏高且稳,
淘宝居中,抖音随直播波动。请给出**有合理区分度**的价格,不要每个平台都一样。
- 不熟悉的商品按品类 / 品牌 / 规格常识估算。完全陌生时给出符合常识的合理估值。
- 所有价格为正数(元),可带小数。
# 输出格式
严格 JSON,无 markdown,无解释:
{"title": "归一化后的商品标题", "typical_price": 8299.0,
"quotes": [{"platform": "淘宝", "price": 8099.0, "note": "一句话(可空)"},
{"platform": "京东", "price": 8299.0, "note": ""}]}
字段:
- title: 整理后的标题(纠错 / 统一规格写法,保留原意)
- typical_price: 市场常见价(正数,必填)
- quotes: 对**给定的每个平台**各给一条;price 正数必填,note 可空
"""
def _extract_json(s: str) -> Optional[dict]:
s = s.strip()
s = re.sub(r"^```(?:json)?\s*", "", s)
s = re.sub(r"\s*```$", "", s)
try:
data = json.loads(s)
except json.JSONDecodeError:
m = re.search(r"\{.*\}", s, re.DOTALL)
if not m:
return None
try:
data = json.loads(m.group(0))
except json.JSONDecodeError:
return None
return data if isinstance(data, dict) else None
def _num(v) -> Optional[float]:
if isinstance(v, bool):
return None
if isinstance(v, (int, float)):
return float(v)
if isinstance(v, str):
try:
return float(v.strip())
except ValueError:
return None
return None
def _synth_price(title: str, platform: str, typical: float) -> float:
"""给 LLM 没给报价的平台合成一个确定性估价(围绕 typical 的 0.93~1.06 倍)。"""
seed = int(hashlib.sha256(f"{title}|{platform}".encode("utf-8")).hexdigest()[:6], 16)
factor = 0.93 + (seed % 14) / 100.0
return round(typical * factor, 2)
def _mock_raw(title: str) -> str:
"""占坑期 mock(DUOBIBI_MOCK_LLM):基于标题确定性造一个市场常见价,
各平台报价交给下游 _synth_price 围绕 typical 合成。返回 LLM 同格式 JSON。"""
seed = int(hashlib.sha256(title.encode("utf-8")).hexdigest()[:8], 16)
typical = round(49 + seed % 9950 + (seed % 100) / 100.0, 2)
return json.dumps({"title": title, "typical_price": typical}, ensure_ascii=False)
@router.post("/arena-quote", response_model=ArenaQuoteResponse)
def arena_quote(req: ArenaQuoteRequest) -> ArenaQuoteResponse:
title_in = req.title.strip()
if not title_in:
raise HTTPException(status_code=422, detail="empty_title")
platforms = [p.strip() for p in (req.platforms or DEFAULT_PLATFORMS) if p and p.strip()]
if not platforms:
platforms = list(DEFAULT_PLATFORMS)
user_msg = f"商品标题: {title_in}\n平台: {', '.join(platforms)}"
if MOCK_LLM:
raw = _mock_raw(title_in)
else:
try:
raw = chat(
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
]
)
logger.debug("arena-quote LLM raw: %s", raw[:400])
except Exception as e:
# LLM 不可用(限流/网络/超时等):降级为空响应,下方走 422 llm_no_quote 而非 500。
logger.warning("arena-quote LLM call failed: %s", e)
raw = ""
data = _extract_json(raw) or {}
title_out = data.get("title")
title_out = title_out.strip() if isinstance(title_out, str) and title_out.strip() else title_in
typical = _num(data.get("typical_price"))
# 解析 quotes,仅保留请求的平台,price 必须 > 0
by_platform: dict[str, ArenaPlatformQuote] = {}
raw_quotes = data.get("quotes")
if isinstance(raw_quotes, list):
for q in raw_quotes:
if not isinstance(q, dict):
continue
plat = q.get("platform")
price = _num(q.get("price"))
if not isinstance(plat, str) or plat.strip() not in platforms:
continue
if price is None or price <= 0:
continue
note = q.get("note")
by_platform[plat.strip()] = ArenaPlatformQuote(
platform=plat.strip(),
price=round(price, 2),
note=note.strip() if isinstance(note, str) else "",
)
# typical 兜底:用已解析报价的中位值
if (typical is None or typical <= 0) and by_platform:
prices = sorted(p.price for p in by_platform.values())
typical = prices[len(prices) // 2]
if typical is None or typical <= 0:
logger.info("arena-quote no usable price for %r", title_in)
raise HTTPException(status_code=422, detail="llm_no_quote")
# 给缺失平台合成估价,保证表格每行都有值
for plat in platforms:
if plat not in by_platform:
by_platform[plat] = ArenaPlatformQuote(
platform=plat,
price=_synth_price(title_out, plat, typical),
note="估算",
)
quotes = [by_platform[p] for p in platforms]
lowest = min(quotes, key=lambda q: q.price).platform if quotes else None
logger.info(
"arena-quote title=%r typical=%.2f platforms=%d lowest=%s",
title_out, typical, len(quotes), lowest,
)
return ArenaQuoteResponse(
title=title_out,
typical_price=round(typical, 2),
quotes=quotes,
lowest_platform=lowest,
)