190 lines
6.5 KiB
Python
190 lines
6.5 KiB
Python
"""POST /api/v1/quick-quote —— Android 端主动比价查询接口。
|
|
|
|
application/json:
|
|
{"title": "iPhone 15 Pro Max 1TB", "clusters": [{"id":1,"title":"..."}, ...]}
|
|
|
|
使用场景:用户在 App 内"快速比价"输入框输入商品名查询 — 不需要先去
|
|
购物 App 浮窗记账,直接得到"市场常见价 + 是否命中已记过的同款"。
|
|
|
|
返回:
|
|
{"title": "...", "typical_price": 13999.0, "cluster_id": 1 | null}
|
|
|
|
LLM 行为:
|
|
- title 标准化 (修正错别字 / 统一规格表达)
|
|
- typical_price 必须给数字 (与 /parse 一致的兜底逻辑)
|
|
- cluster_id 按现有 簇匹配规则 判断
|
|
"""
|
|
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 ClusterDto, QuickQuoteRequest, QuickQuoteResponse
|
|
|
|
logger = logging.getLogger("shagua.quick_quote")
|
|
|
|
router = APIRouter(prefix="/api/v1")
|
|
|
|
|
|
SYSTEM_PROMPT = """你是商品归簇与市场参考价估算助手。
|
|
|
|
# 任务
|
|
|
|
用户会发给你两部分输入:
|
|
1. 一个商品标题字符串
|
|
2. 用户已有的"商品簇"列表(每个簇用一个代表标题描述)
|
|
|
|
请你完成两件事:
|
|
A. 估算该商品的「市场常见价」(typical_price):主流电商平台常见售价区间
|
|
的中位值,不含双 11 / 618 等特殊促销价
|
|
B. 判断该商品是否归属于已有簇中的某一个,若是返回该簇 id,若否返回 null
|
|
|
|
# 簇匹配规则
|
|
|
|
两件商品视为「同一簇」,核心商品名一致即可,无视规格、颜色、容量、装数、
|
|
性别、码数等差异。
|
|
|
|
例:
|
|
- "iPhone 15 Pro 256GB" ↔ "iPhone 15 Pro 1TB 暮光紫" → 同簇 ✓
|
|
- "iPhone 15 Pro" ↔ "iPhone 14 Pro" → 不同簇 ✗ (型号不同)
|
|
- "海尔保温杯 500ml" ↔ "九阳保温杯 500ml" → 不同簇 ✗ (品牌不同)
|
|
|
|
# 市场常见价估算
|
|
|
|
- 取主流电商(淘宝/京东/拼多多/抖音电商)常见售价区间的中位值
|
|
- 不含双 11、618、年货节、品牌大促等特殊促销价
|
|
- 单位:元,可以有小数,必须为正数
|
|
- **必须给出一个数字,不允许 null**。即使你不熟悉,也要根据品类、品牌、
|
|
规格的常识给出合理估算
|
|
- 估算思路:
|
|
* 知名品牌 → 该品牌该品类的官方建议零售价或主流电商常见价
|
|
* 冷门品牌 → 同品类的市场常见价区间中位值
|
|
* 完全陌生 → 给出符合常识的合理估值
|
|
|
|
# 输出格式
|
|
|
|
严格 JSON,无任何额外文字、不要 markdown 代码块、不要解释:
|
|
|
|
{"title": "标准化后的商品标题", "typical_price": 99.9, "cluster_id": 3}
|
|
|
|
字段说明:
|
|
- title:整理后的标题(可纠错 / 统一规格写法,但保留原意)
|
|
- typical_price:市场常见价数字(正数),**必须返回数字,不允许 null**
|
|
- cluster_id:命中已有簇返回其 id (整数);未命中返回 null
|
|
"""
|
|
|
|
|
|
def _format_clusters(clusters: list[ClusterDto]) -> str:
|
|
if not clusters:
|
|
return "(无,这是用户记录的第一件商品)"
|
|
return "\n".join(f"- id={c.id}: {c.title}" for c in clusters)
|
|
|
|
|
|
def _mock_raw(title: str) -> str:
|
|
"""占坑期 mock(DUOBIBI_MOCK_LLM):基于标题确定性造市场常见价,不归簇(由客户端新建)。"""
|
|
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, "cluster_id": None}, ensure_ascii=False)
|
|
|
|
|
|
def _parse_llm_output(s: str) -> tuple[Optional[str], Optional[float], Optional[int]]:
|
|
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)
|
|
if not m:
|
|
return None, None, None
|
|
try:
|
|
data = json.loads(m.group(0))
|
|
except json.JSONDecodeError:
|
|
return None, None, None
|
|
if not isinstance(data, dict):
|
|
return None, None, None
|
|
|
|
title = data.get("title")
|
|
title = title.strip() if isinstance(title, str) and title.strip() else None
|
|
|
|
tp_raw = data.get("typical_price")
|
|
if isinstance(tp_raw, bool):
|
|
typical_price = None
|
|
elif isinstance(tp_raw, (int, float)):
|
|
typical_price = float(tp_raw)
|
|
elif isinstance(tp_raw, str):
|
|
try:
|
|
typical_price = float(tp_raw.strip())
|
|
except ValueError:
|
|
typical_price = None
|
|
else:
|
|
typical_price = None
|
|
|
|
cid_raw = data.get("cluster_id")
|
|
if isinstance(cid_raw, bool):
|
|
cluster_id = None
|
|
elif isinstance(cid_raw, int):
|
|
cluster_id = cid_raw
|
|
elif isinstance(cid_raw, float) and cid_raw.is_integer():
|
|
cluster_id = int(cid_raw)
|
|
else:
|
|
cluster_id = None
|
|
|
|
return title, typical_price, cluster_id
|
|
|
|
|
|
@router.post("/quick-quote", response_model=QuickQuoteResponse)
|
|
def quick_quote(req: QuickQuoteRequest) -> QuickQuoteResponse:
|
|
title_in = req.title.strip()
|
|
if not title_in:
|
|
raise HTTPException(status_code=422, detail="empty_title")
|
|
|
|
user_msg = (
|
|
f"商品标题: {title_in}\n\n"
|
|
f"已有商品簇:\n{_format_clusters(req.clusters)}"
|
|
)
|
|
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("quick-quote LLM raw: %s", raw[:300])
|
|
except Exception as e:
|
|
# LLM 不可用:不 500,下方 typical_price 走兜底。
|
|
logger.warning("quick-quote LLM call failed: %s", e)
|
|
raw = ""
|
|
|
|
title_out, typical_price, cluster_id = _parse_llm_output(raw)
|
|
|
|
# title 兜底:LLM 没整理就用原值
|
|
if title_out is None:
|
|
title_out = title_in
|
|
|
|
# cluster_id 校验:防 LLM 编造
|
|
valid_ids = {c.id for c in req.clusters}
|
|
if cluster_id is not None and cluster_id not in valid_ids:
|
|
logger.warning("LLM returned unknown cluster_id=%s, treating as null", cluster_id)
|
|
cluster_id = None
|
|
|
|
# typical_price 兜底:LLM 不听话或给 <=0,用一个保守占位(实际极少触发)
|
|
if typical_price is None or typical_price <= 0:
|
|
logger.warning("LLM did not return valid typical_price, fallback to 0.0")
|
|
typical_price = 0.0
|
|
|
|
return QuickQuoteResponse(
|
|
title=title_out,
|
|
typical_price=typical_price,
|
|
cluster_id=cluster_id,
|
|
)
|