221 lines
7.2 KiB
Python
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,
|
|
)
|