"""POST /api/v1/track-price —— 价保哨兵的价格查询接口(LLM mock 实现)。 ## 场景 用户在 App 内告知"我在 X 平台 ¥XXX 买了 Y 商品",客户端每天后台调一次此接口 查"该商品当前在该平台的合理价"。客户端按 `savings > 0` 弹"该申请价保了"通知。 ## 为什么是 mock + LLM 占坑期没有真实平台价 API。爬虫法规风险大。OCR 用户主动截图比较被动 — 不适合 "每天自动监控"场景。 折中方案:LLM 估"该商品在该平台的日常常见价"(锚价),再用哈希抖动模拟"今天降价 / 持平 / 涨价"。抖动是**确定性的**(seed = title+platform+date),同一商品同一天 结果一致,跨天才会变化,不会让用户看到"通知反复抖动"。 未来切真 API 时客户端 0 改动(接口签名/响应固定)。 ## 抖动设计 seed = sha256(title + platform + YYYY-MM-DD).hexdigest()[:8] h = int(seed, 16) % 100 - h ∈ [0, 60) → 降价 3-8%, trend="down" (60% 概率,让通知活跃) - h ∈ [60, 90) → 持平 ±2%, trend="flat" - h ∈ [90, 100)→ 涨价 3-8%, trend="up" 60% 降价概率是策略选择:demo 体验里用户经常能看到"该申请价保了"通知, 功能"动"得多。真实降价频率比这低很多,但 mock 阶段优先体验。 """ from __future__ import annotations import hashlib import json import logging import re import time from datetime import datetime, timezone from typing import Optional from fastapi import APIRouter, HTTPException from app.llm_client import MOCK_LLM, chat from app.schemas import TrackPriceRequest, TrackPriceResponse logger = logging.getLogger("shagua.protect") router = APIRouter(prefix="/api/v1") SYSTEM_PROMPT = """你是一个商品当前价格估算助手。 # 输入 用户会告诉你: - 平台: 例如 京东 / 淘宝 / 拼多多 / 抖音 - 商品标题: 商品完整名称 - 用户购买价: 参考用,不一定是当前价 - 购买距今天数: 例如 7天 # 任务 估算该商品在该平台目前的**日常常见售价**(不含特殊大促),作为"基础价"输出。 # 估算思路 - 考虑该平台的常态价位区间(例:京东自营常比拼多多百亿补贴高 5-10%) - 考虑时间维度:新品 3 个月内可能略涨,旧品逐渐降 - 考虑大促节奏:618 / 双11 前后日常价会下浮 - 不熟悉的商品 → 根据品类 + 品牌常识估算合理日常价 - 完全陌生 → 取用户购买价的 ±5% 区间合理值 # 输出格式 严格 JSON,无 markdown,无解释,无任何其他文字: {"base_price": 数字, "reasoning": "一句话"} 字段: - base_price: 商品在该平台的日常常见价数字,**必须正数,不允许 null** - reasoning: 一句话理由(给排查 LLM 行为用,客户端不展示) """ def _parse_llm_output(s: str) -> tuple[Optional[float], str]: """从 LLM 输出剥出 base_price + reasoning。返回 (base_price | None, reasoning)。""" 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, "" try: data = json.loads(m.group(0)) except json.JSONDecodeError: return None, "" if not isinstance(data, dict): return None, "" bp_raw = data.get("base_price") if isinstance(bp_raw, bool): base_price = None elif isinstance(bp_raw, (int, float)): base_price = float(bp_raw) elif isinstance(bp_raw, str): try: base_price = float(bp_raw.strip()) except ValueError: base_price = None else: base_price = None reasoning = data.get("reasoning") if not isinstance(reasoning, str): reasoning = "" return base_price, reasoning def _compute_jitter(title: str, platform: str, today_str: str) -> tuple[float, str]: """确定性抖动 — 同 title+platform+date 结果一致,跨天才变。 返回 (factor, trend),factor 是乘到 base_price 上的系数。 """ seed_str = f"{title}|{platform}|{today_str}" digest = hashlib.sha256(seed_str.encode("utf-8")).hexdigest() h = int(digest[:8], 16) % 100 if h < 60: # 降价 3-8%。在 [0, 60) 内线性插值到 [0.92, 0.97] factor = 0.92 + (h / 60.0) * 0.05 trend = "down" elif h < 90: # 持平 ±2%。在 [60, 90) 内线性插值到 [0.98, 1.02] factor = 0.98 + ((h - 60) / 30.0) * 0.04 trend = "flat" else: # 涨价 3-8%。在 [90, 100) 内线性插值到 [1.03, 1.08] factor = 1.03 + ((h - 90) / 10.0) * 0.05 trend = "up" return factor, trend @router.post("/track-price", response_model=TrackPriceResponse) def track_price(req: TrackPriceRequest) -> TrackPriceResponse: title = req.product_title.strip() platform = req.platform.strip() if not title or not platform: raise HTTPException(status_code=422, detail="empty_title_or_platform") if req.purchase_price <= 0: raise HTTPException(status_code=422, detail="invalid_purchase_price") # 计算"购买距今多少天",给 LLM 当上下文 now_ts = int(time.time()) days_since = max(0, (now_ts - req.purchase_at) // 86400) user_msg = ( f"平台: {platform}\n" f"商品标题: {title}\n" f"用户购买价: ¥{req.purchase_price:.2f}\n" f"购买距今: {days_since}天" ) if MOCK_LLM: # mock:跳过 LLM,下方 base_price 回退到 purchase_price 锚 + 确定性抖动。 raw = "" else: try: raw = chat( messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_msg}, ] ) logger.debug("track-price LLM raw: %s", raw[:300]) except Exception as e: # LLM 不可用(限流/网络/超时):不 500,用 purchase_price 作锚。 logger.warning("track-price LLM call failed, using purchase_price anchor: %s", e) raw = "" base_price, reasoning = _parse_llm_output(raw) # base_price 兜底:LLM 不听话或给 <=0,用 purchase_price 作为锚 # (occurence 极少;占坑期 mock 不能让接口因 LLM 抽风就 500) if base_price is None or base_price <= 0: logger.warning( "LLM did not return valid base_price for %r, fallback to purchase_price", title, ) base_price = req.purchase_price # 抖动 — seed = title + platform + 服务器当前日期(UTC,日切清晰) today_str = datetime.now(timezone.utc).strftime("%Y-%m-%d") factor, trend = _compute_jitter(title, platform, today_str) current_price = round(base_price * factor, 2) savings = round(req.purchase_price - current_price, 2) logger.info( "track-price: platform=%s title=%r purchase=¥%.2f base=¥%.2f current=¥%.2f" " trend=%s savings=¥%.2f reasoning=%r", platform, title, req.purchase_price, base_price, current_price, trend, savings, reasoning[:80], ) return TrackPriceResponse( current_price=current_price, base_price=base_price, trend=trend, savings=savings, checked_at=now_ts, )