market-assistant/backend/pipeline/llm_keyword_suggest.py
hub-gif 1deea3af35 feat(pipeline): LLM-extend usage scenario groups from comments
Add suggest_scenario_groups_llm: single call with excerpt corpus + existing
label/triggers, parse scenarios JSON, merge into comment_scenario_groups
(up to 40) before build_competitor_brief. Record in keyword_suggest_llm.json
(schema_version 3). Skip with MA_SKIP_LLM_SCENARIO_SUGGEST. Update tests
and analysis view hint.

Made-with: Cursor
2026-04-14 10:36:28 +08:00

286 lines
9.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""在报告生成前:基于评价正文调用大模型,联想补充**关注词**与**使用场景触发组**(写入 effective_report_config"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
from typing import Any
from django.conf import settings
MAX_CHUNK_CHARS = 24_000
MAX_CHUNKS = 12
# 场景联想单次送入模型的评价摘录上限(字符);过大易顶上下文
SCENARIO_CORPUS_MAX_CHARS = 18_000
_CHUNK_SYSTEM = """你是电商评价挖掘助手。输入 JSON 含 keyword、excerpt_index、excerpts一段用户评价正文合集
任务:从 excerpts 中抽取值得纳入「关注词/卖点监测」的**中文短语**212 字为主,可为词组)。
硬性规则:
- 仅输出一段 JSON{"phrases": ["短语1", ...]},短语共 620 条。
- 不要医疗功效、治愈、降血糖承诺;不要完整复制整句评价。
- 不要输出与常见停用词无信息量的单字。
- 不要输出 JSON 以外的文字。"""
def _ensure_ai_crawler_path() -> None:
root = Path(settings.CRAWLER_JD_ROOT).resolve()
if not root.is_dir():
raise FileNotFoundError(f"爬虫副本目录不存在: {root}")
rs = str(root)
if rs not in sys.path:
sys.path.insert(0, rs)
def _call_llm(system_prompt: str, user_prompt: str) -> str:
_ensure_ai_crawler_path()
import AI_crawler as ac # noqa: WPS433
return ac.chat_completion_text(
system_prompt=system_prompt,
user_prompt=user_prompt,
)
def _chunk_comment_texts(texts: list[str]) -> list[str]:
"""将全量评价划为若干段,控制单段字符量与最大段数。"""
parts: list[str] = []
cur: list[str] = []
cur_len = 0
for t in texts:
s = (t or "").strip()
if not s:
continue
extra = len(s) + 1
if cur and cur_len + extra > MAX_CHUNK_CHARS:
parts.append("\n".join(cur))
cur = []
cur_len = 0
cur.append(s)
cur_len += extra
if cur:
parts.append("\n".join(cur))
if not parts:
return []
if len(parts) <= MAX_CHUNKS:
return parts
idxs = sorted(
{min(int(i * len(parts) / MAX_CHUNKS), len(parts) - 1) for i in range(MAX_CHUNKS)}
)
return [parts[i] for i in idxs]
def _parse_phrases_object(raw: str) -> list[str]:
t = raw.strip()
t = re.sub(r"^```(?:json)?\s*", "", t)
t = re.sub(r"\s*```$", "", t)
try:
obj = json.loads(t)
if isinstance(obj, dict) and isinstance(obj.get("phrases"), list):
return [str(x).strip() for x in obj["phrases"] if str(x).strip()]
except json.JSONDecodeError:
pass
m = re.search(r"\{[\s\S]*\}", t)
if m:
try:
obj = json.loads(m.group(0))
if isinstance(obj, dict) and isinstance(obj.get("phrases"), list):
return [str(x).strip() for x in obj["phrases"] if str(x).strip()]
except json.JSONDecodeError:
pass
return []
_SCENARIO_SYSTEM = """你是电商用户研究助手。输入 JSON 含:
- ``keyword``:监测词;
- ``existing_scenarios``:数组,每项为 ``{"label": "展示名", "triggers": ["子串1", ...]}``。统计时若评价正文**包含任一 trigger 子串**,则计入该 label与宿主系统规则一致
- ``excerpts``:多条用户评价正文摘录(已截断拼接)。
任务:在**不重复** ``existing_scenarios`` 中已有 ``label``(逐字比较,勿改写字)的前提下,从 excerpts 归纳 **412 条**新的「用途/场景」监测组,覆盖评论里**明显出现但未被现有组覆盖**的消费情境(如「下午茶」「露营」「宿舍」等,须确有文本依据)。
硬性规则:
- **仅输出**一段 JSON``{"scenarios": [{"label": "展示名", "triggers": ["子串1", "子串2", ...]}, ...]}``
- 每条 ``label`` 216 字;每组 ``triggers`` 310 条,每条 trigger 为 **212 字中文**子串,用于**子串命中**计数;
- 不要医疗功效、治愈、降血糖承诺;不要与 existing 的 label 同名或仅差空格;
- 不要输出 JSON 以外的文字。"""
def _sample_corpus_for_scenarios(texts: list[str], *, max_chars: int) -> str:
"""取评价正文前部拼接至 max_chars供单次场景联想。"""
parts: list[str] = []
n = 0
for t in texts:
s = (t or "").strip()
if not s:
continue
extra = len(s) + 1
if n + extra > max_chars:
remain = max_chars - n - 1
if remain > 40:
parts.append(s[:remain])
break
parts.append(s)
n += extra
return "\n".join(parts)
def _parse_scenarios_object(raw: str) -> list[dict[str, Any]]:
t = (raw or "").strip()
t = re.sub(r"^```(?:json)?\s*", "", t, flags=re.IGNORECASE)
t = re.sub(r"\s*```$", "", t)
try:
obj = json.loads(t)
except json.JSONDecodeError:
obj = None
if not isinstance(obj, dict):
m = re.search(r"\{[\s\S]*\}", t)
if not m:
return []
try:
obj = json.loads(m.group(0))
except json.JSONDecodeError:
return []
arr = obj.get("scenarios")
if not isinstance(arr, list):
return []
out: list[dict[str, Any]] = []
for item in arr:
if not isinstance(item, dict):
continue
label = str(item.get("label") or "").strip()[:80]
tr_raw = item.get("triggers")
triggers: list[str] = []
if isinstance(tr_raw, list):
seen_t: set[str] = set()
for x in tr_raw[:24]:
s = str(x).strip()
if len(s) < 2 or len(s) > 24:
continue
if s in seen_t:
continue
seen_t.add(s)
triggers.append(s)
if label and len(triggers) >= 1:
out.append({"label": label, "triggers": triggers[:12]})
return out
def suggest_scenario_groups_llm(
*,
keyword: str,
existing_groups: list[dict[str, Any]],
all_comment_texts: list[str],
) -> dict[str, Any]:
"""
单次调用模型,基于评价摘录扩展 ``comment_scenario_groups`` 形态的新组label + triggers
"""
if not all_comment_texts:
return {
"suggested_scenario_groups": [],
"scenario_rationale": "无评价正文可分析。",
}
existing_compact: list[dict[str, Any]] = []
for g in (existing_groups or [])[:36]:
if not isinstance(g, dict):
continue
lab = str(g.get("label") or "").strip()
tr = g.get("triggers")
ts: list[str] = []
if isinstance(tr, list):
for x in tr[:16]:
s = str(x).strip()
if s:
ts.append(s[:48])
if lab and ts:
existing_compact.append({"label": lab[:80], "triggers": ts})
excerpts = _sample_corpus_for_scenarios(
all_comment_texts, max_chars=SCENARIO_CORPUS_MAX_CHARS
)
payload = {
"keyword": keyword,
"existing_scenarios": existing_compact,
"excerpts": excerpts,
}
raw = _call_llm(_SCENARIO_SYSTEM, json.dumps(payload, ensure_ascii=False))
scenarios = _parse_scenarios_object(raw)
return {
"suggested_scenario_groups": scenarios[:14],
"scenario_rationale": (
f"基于约 {len(excerpts)} 字评价摘录单次调用模型;"
f"{len(existing_compact)} 组既有场景之外补充 {len(scenarios[:14])} 组候选。"
),
}
def suggest_focus_keywords_from_all_comments(
*,
keyword: str,
brief_slice: dict[str, Any],
all_comment_texts: list[str],
) -> dict[str, Any]:
"""
读取全量评价(在服务端分块),每块调用模型抽取短语,合并去重后返回 ``suggested_focus_keywords``。
"""
if not all_comment_texts:
return {
"suggested_focus_keywords": [],
"rationale": "无评价正文可分析。",
"chunks_processed": 0,
"total_comment_texts": 0,
}
existing: set[str] = set()
for x in brief_slice.get("comment_focus_keywords") or []:
if isinstance(x, dict):
w = str(x.get("word") or "").strip()
if w:
existing.add(w)
chunks = _chunk_comment_texts(all_comment_texts)
collected: list[str] = []
for i, ch in enumerate(chunks):
payload = {
"keyword": keyword,
"excerpt_index": i + 1,
"excerpts": ch,
}
raw = _call_llm(_CHUNK_SYSTEM, json.dumps(payload, ensure_ascii=False))
collected.extend(_parse_phrases_object(raw))
seen: set[str] = set()
merged: list[str] = []
for p in collected:
t = p.strip()
if len(t) < 2 or len(t) > 24:
continue
if t in seen or t in existing:
continue
seen.add(t)
merged.append(t)
out_kw = merged[:22]
return {
"suggested_focus_keywords": out_kw,
"rationale": (
f"基于全量 {len(all_comment_texts)} 条评价文本,分 {len(chunks)} 段调用模型抽取短语并去重;"
f"已排除与当前关注词统计表完全相同的词。"
),
"chunks_processed": len(chunks),
"total_comment_texts": len(all_comment_texts),
}
# 兼容旧接口名(若仍有调用)
def suggest_comment_keywords_llm(
*,
keyword: str,
brief_slice: dict[str, Any],
comment_samples: list[str],
) -> dict[str, Any]:
return suggest_focus_keywords_from_all_comments(
keyword=keyword,
brief_slice=brief_slice,
all_comment_texts=list(comment_samples),
)