"""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, )