192 lines
7.1 KiB
Python
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,
|
|
)
|