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

221 lines
7.2 KiB
Python

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