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

244 lines
8.3 KiB
Python

"""把无障碍树扁平化 + 候选簇喂给 LLM,一次出 商品标题、到手价、归属簇。"""
from __future__ import annotations
import hashlib
import json
import logging
import re
from typing import Optional
from app.llm_client import MOCK_LLM, chat
from app.schemas import ClusterDto, NodeDto
logger = logging.getLogger("shagua.extractor")
PKG_TO_BRAND: dict[str, str] = {
"com.taobao.taobao": "淘宝",
"com.jingdong.app.mall": "京东",
"com.xunmeng.pinduoduo": "拼多多",
"com.ss.android.ugc.aweme": "抖音",
"com.sankuai.meituan": "美团",
"com.sankuai.meituan.takeoutnew": "美团",
"me.ele": "饿了么",
}
MAX_FLAT_CHARS = 12000
SYSTEM_PROMPT = """你是一个商品页结构化提取、商品归簇与市场参考价估算助手。
# 任务
用户会发给你两部分输入:
1. 一段从 Android 无障碍树取出的某个购物 / 外卖 / 团购 App 页面的可见文本与控件信息
2. 用户已有的"商品簇"列表(每个簇用一个代表标题描述)
请你完成三件事:
A. 从页面信息中识别当前商品的「标题」与「到手价」(单位:元,数字)
B. 判断当前商品是否归属于已有簇中的某一个,若是返回该簇 id,若否返回 null(由客户端新建簇)
C. 估算该商品的「市场常见价」(typical_price):主流电商平台常见售价区间的中位值,不含双 11 / 618 等特殊促销价
# 簇匹配规则
两件商品视为「同一簇」,核心商品名一致即可,无视规格、颜色、容量、装数、性别、码数等差异。
例:
- "iPhone 15 Pro 256GB""iPhone 15 Pro 1TB 暮光紫" → 同簇 ✓
- "iPhone 15 Pro""iPhone 14 Pro" → 不同簇 ✗ (型号不同)
- "海尔保温杯 500ml""九阳保温杯 500ml" → 不同簇 ✗ (品牌不同)
- "PaulFrank 卫衣 男款 L""PaulFrank 卫衣 女款 M" → 同簇 ✓ (性别码数算规格)
# 市场常见价估算
- 取主流电商(淘宝/京东/拼多多/抖音电商)常见售价区间的中位值
- 不含双 11、618、年货节、品牌大促等特殊促销价
- 单位:元,可以有小数,必须为正数
- **必须给出一个数字,不允许 null**。即使你对该商品不熟悉,也要根据**品类**(如矿泉水、卫衣、手机、咖啡等)、**品牌**(如有)、**规格**(数量/重量/容量/型号)的常识给出合理估算
- 估算思路:
* 知名品牌 → 该品牌该品类的官方建议零售价或主流电商常见价
* 冷门品牌 → 同品类(品类无关品牌)的市场常见价区间中位值
* 完全陌生 → 参考给定到手价 ±20% 的合理区间
# 输出格式
严格 JSON,无任何额外文字、不要 markdown 代码块、不要解释:
{"title": "完整商品标题", "price": 99.9, "cluster_id": 3, "typical_price": 120.0}
字段说明:
- title:商品标题字符串(最显眼最完整那段,不截断、不拼规格)
- price:到手价数字(优先级:实付到手价 > 优惠后价 > 标价)
- cluster_id:命中已有簇返回其 id (整数);未命中返回 null
- typical_price:市场常见价数字(正数),**必须返回数字,不允许 null**
# 失败处理
页面不是商品/团购/外卖详情页,或无法提取标题/价格时:
{"title": null, "price": null, "cluster_id": null, "typical_price": null}
# 价格细则
到手价选择优先级:实付 > 优惠后 > 单品标价。
"""
def flatten_tree(node: Optional[NodeDto]) -> str:
if node is None:
return ""
lines: list[str] = []
_walk(node, depth=0, out=lines)
text = "\n".join(lines)
if len(text) > MAX_FLAT_CHARS:
text = text[:MAX_FLAT_CHARS] + "\n...(truncated)"
return text
def _walk(node: NodeDto, depth: int, out: list[str]) -> None:
parts: list[str] = []
if node.view_id:
parts.append(f"id={node.view_id}")
if node.text:
parts.append(f'text="{node.text}"')
if node.desc:
parts.append(f'desc="{node.desc}"')
if parts:
out.append(" " * depth + " | ".join(parts))
for child in node.children or []:
_walk(child, depth + 1, out)
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(flat: str) -> str:
"""占坑期 mock(DUOBIBI_MOCK_LLM):按页面文本哈希从预置商品池挑一件,
造 LLM 同格式 JSON(不归簇,由客户端按 cluster 规则新建)。"""
from app.mock_extractor import MOCK_PRODUCTS
h = hashlib.sha256(flat.encode("utf-8")).digest()
p = MOCK_PRODUCTS[h[0] % len(MOCK_PRODUCTS)]
return json.dumps(
{"title": p["title"], "price": p["price"], "cluster_id": None,
"typical_price": p["typical_price"]},
ensure_ascii=False,
)
def extract(
package_name: str,
tree: Optional[NodeDto],
clusters: list[ClusterDto],
) -> dict:
"""返回 {success, title?, price?, source_app, cluster_id?, reason?}。"""
brand = PKG_TO_BRAND.get(package_name, "未知")
if tree is None:
return {"success": False, "reason": "no_tree", "source_app": brand}
flat = flatten_tree(tree)
if not flat.strip():
return {"success": False, "reason": "empty_tree", "source_app": brand}
user_msg = (
f"App: {brand}\n\n"
f"页面信息:\n{flat}\n\n"
f"已有商品簇:\n{_format_clusters(clusters)}"
)
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
]
if MOCK_LLM:
raw = _mock_raw(flat)
else:
try:
raw = chat(messages)
logger.debug("LLM raw output: %s", raw[:500])
except Exception as e:
# LLM 不可用(限流/网络/超时):不 500,返回提取失败让客户端提示重试。
logger.warning("extract LLM call failed: %s", e)
raw = ""
title, price, cluster_id, typical_price = _parse_llm_output(raw)
if title is None or price is None:
return {
"success": False,
"reason": "llm_no_extract",
"source_app": brand,
"raw": raw[:200],
}
# 校验 cluster_id 是不是用户提供过的(防 LLM 编造)
valid_ids = {c.id for c in clusters}
if cluster_id is not None and cluster_id not in valid_ids:
logger.warning("LLM returned unknown cluster_id=%s, treating as new cluster", cluster_id)
cluster_id = None
# typical_price 兜底: prompt 已要求 LLM 必须给数字,但仍可能不听话或给负数。
# 这种极少数情况下用当前价兜底(客户端会显示"持平"),保证字段总有值。
if typical_price is None or typical_price <= 0:
typical_price = price
return {
"success": True,
"title": title,
"price": price,
"source_app": brand,
"cluster_id": cluster_id,
"typical_price": typical_price,
}
def _parse_number(raw) -> Optional[float]:
if isinstance(raw, bool):
return None
if isinstance(raw, (int, float)):
return float(raw)
if isinstance(raw, str):
try:
return float(raw.strip())
except ValueError:
return None
return None
def _parse_llm_output(s: str) -> tuple[Optional[str], Optional[float], Optional[int], Optional[float]]:
s = s.strip()
s = re.sub(r"^```(?:json)?\s*", "", s)
s = re.sub(r"\s*```$", "", s)
data: Optional[dict] = None
try:
data = json.loads(s)
except json.JSONDecodeError:
m = re.search(r'\{[^{}]*"title"\s*:\s*[^{}]+\}', s)
if m:
try:
data = json.loads(m.group(0))
except json.JSONDecodeError:
pass
if not isinstance(data, dict):
return None, None, None, None
title = data.get("title")
title = title.strip() if isinstance(title, str) and title.strip() else None
price = _parse_number(data.get("price"))
typical_price = _parse_number(data.get("typical_price"))
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, price, cluster_id, typical_price