feat(pipeline): add LLM summaries for usage scenarios (§8.4)

Made-with: Cursor
This commit is contained in:
hub-gif 2026-04-14 16:17:46 +08:00
parent d2135e9a6e
commit 5fe4ac4ffe
6 changed files with 364 additions and 3 deletions

View File

@ -869,6 +869,109 @@ def build_comment_groups_llm_payload(
return out
def build_scenario_groups_llm_payload(
*,
feedback_groups: list[tuple[str, list[dict[str, str]], list[str]]],
scenario_groups: tuple[tuple[str, tuple[str, ...]], ...],
merged_rows: list[dict[str, str]],
sku_header: str,
title_h: str,
) -> dict[str, Any]:
"""供 ``generate_scenario_group_summaries_llm``;计数与 §8.4 场景条形图一致。"""
if not feedback_groups:
return {}
sku_meta: dict[str, tuple[str, str, str]] = {}
for row in merged_rows:
sku = _cell(row, sku_header).strip()
if not sku:
continue
gk = _competitor_matrix_group_key(row)
if not gk:
continue
sku_meta[sku] = (
gk,
_cell(row, title_h),
_cell(row, "detail_shop_name") or _cell(row, "店铺名(shopName)"),
)
lexicon = [
{"label": lbl, "trigger_examples": list(trigs[:12])}
for lbl, trigs in scenario_groups
]
groups_out: list[dict[str, Any]] = []
for gname, cr, tu in feedback_groups:
if not tu and not cr:
continue
scen_g, scen_ng = _comment_scenario_counts(tu, scenario_groups)
dist: list[dict[str, Any]] = []
for lbl, n in scen_g.most_common():
if n <= 0:
continue
dist.append(
{
"scenario": lbl,
"mention_rows": int(n),
"share_of_effective_texts": round(
float(n) / float(scen_ng), 4
)
if scen_ng > 0
else 0.0,
}
)
snippets: list[str] = []
for row in cr:
txt = (row.get("tagCommentContent") or "").strip()
if not txt:
continue
if not _text_hits_scenario_triggers(txt, scenario_groups):
continue
sku = _cell(row, "sku").strip()
meta = sku_meta.get(sku)
if meta:
sg, tit, shop = meta
prefix = (
f"【细类:{sg}\uff5cSKU{sku}\uff5c品名:{_md_cell(tit, 60)}\uff5c"
f"店铺:{_md_cell(shop, 28)}"
)
snippets.append(prefix + txt[:300])
else:
snippets.append(
f"【细类:{gname}\uff5cSKU{sku or ''}" + txt[:320]
)
if len(snippets) >= 16:
break
if len(snippets) < 5:
for row in cr:
txt = (row.get("tagCommentContent") or "").strip()
if not txt:
continue
sku = _cell(row, "sku").strip()
meta = sku_meta.get(sku)
if meta:
sg, tit, shop = meta
prefix = (
f"【细类:{sg}\uff5cSKU{sku}\uff5c品名:{_md_cell(tit, 60)}\uff5c"
f"店铺:{_md_cell(shop, 28)}"
)
snippets.append(prefix + txt[:260])
else:
snippets.append(
f"【细类:{gname}\uff5cSKU{sku or ''}" + txt[:280]
)
if len(snippets) >= 10:
break
groups_out.append(
{
"group": gname,
"effective_text_count": int(scen_ng),
"scenario_distribution": dist[:18],
"sample_text_snippets": snippets,
}
)
if not groups_out:
return {}
return {"scenario_lexicon": lexicon, "groups": groups_out}
def build_comment_sentiment_llm_payload(
texts: list[str],
*,
@ -974,6 +1077,17 @@ def _comment_scenario_counts(
return c, n
def _text_hits_scenario_triggers(
text: str,
scenario_groups: tuple[tuple[str, tuple[str, ...]], ...],
) -> bool:
blob = text or ""
for _lbl, triggers in scenario_groups:
if any(t in blob for t in triggers):
return True
return False
def _scenario_summary_bullets(counter: Counter[str], n_texts: int, top_k: int = 5) -> list[str]:
if n_texts <= 0 or not counter:
return []
@ -1657,6 +1771,7 @@ def build_competitor_markdown(
llm_sentiment_section_md: str | None = None,
llm_matrix_section_md: str | None = None,
llm_price_groups_section_md: str | None = None,
llm_scenario_groups_section_md: str | None = None,
llm_comment_groups_section_md: str | None = None,
) -> str:
focus_words, scenario_groups, external_rows = resolve_report_tuning(report_config)
@ -2373,14 +2488,28 @@ def build_competitor_markdown(
lines.append("*未命中预设场景词组。*")
lines.append("")
_llm_sg = (llm_scenario_groups_section_md or "").strip()
if _llm_sg:
lines.extend(
[
"",
"#### 使用场景要点归纳(大模型,与 §8.4 图表互补)",
"",
"> **说明**:与 §8.4 **相同**的预设场景词组与子串命中规则;**各场景条数与占比以正文条形图为准**。",
"",
_llm_sg,
"",
]
)
_llm_cg = (llm_comment_groups_section_md or "").strip()
if _llm_cg:
lines.extend(
[
"",
"#### 细类评价与关注词要点归纳(大模型,与 §8.38.4 图表互补)",
"#### 细类评价与关注词要点归纳(大模型,与 §8.3 图表互补)",
"",
"> **说明**:归纳各细类反馈主题;**命中次数与条形图以正文为准**。",
"> **说明**:归纳各细类反馈主题与配置关注词命中**次数与 §8.3 条形图以正文为准**。",
"",
_llm_cg,
"",

View File

@ -151,6 +151,7 @@ def get_default_report_config() -> dict[str, Any]:
"llm_comment_sentiment": True,
"llm_matrix_group_summaries": True,
"llm_comment_group_summaries": True,
"llm_scenario_group_summaries": True,
"llm_price_group_summaries": True,
"comment_focus_words": list(jcr.COMMENT_FOCUS_WORDS),
"comment_scenario_groups": [
@ -390,9 +391,11 @@ def write_competitor_analysis_for_run_dir(
llm_matrix_md = ""
llm_price_md = ""
llm_scenario_gr_md = ""
llm_comment_gr_md = ""
matrix_llm_rec: dict[str, Any] = {"schema_version": 1, "attempted": False}
price_llm_rec: dict[str, Any] = {"schema_version": 1, "attempted": False}
scenario_gr_llm_rec: dict[str, Any] = {"schema_version": 1, "attempted": False}
comment_gr_llm_rec: dict[str, Any] = {"schema_version": 1, "attempted": False}
sku_h = "SKU(skuId)"
title_h = "标题(wareName)"
@ -402,6 +405,7 @@ def write_competitor_analysis_for_run_dir(
skip_mx = _env_on("MA_SKIP_LLM_MATRIX_GROUP_SUMMARIES")
skip_pr = _env_on("MA_SKIP_LLM_PRICE_GROUP_SUMMARIES")
skip_sg = _env_on("MA_SKIP_LLM_SCENARIO_GROUP_SUMMARIES")
skip_cg = _env_on("MA_SKIP_LLM_COMMENT_GROUP_SUMMARIES")
want_mx = bool(eff_rc.get("llm_matrix_group_summaries")) or _env_on(
"MA_ENABLE_LLM_MATRIX_GROUP_SUMMARIES"
@ -409,6 +413,9 @@ def write_competitor_analysis_for_run_dir(
want_pr = bool(eff_rc.get("llm_price_group_summaries")) or _env_on(
"MA_ENABLE_LLM_PRICE_GROUP_SUMMARIES"
)
want_sg = bool(eff_rc.get("llm_scenario_group_summaries")) or _env_on(
"MA_ENABLE_LLM_SCENARIO_GROUP_SUMMARIES"
)
want_cg = bool(eff_rc.get("llm_comment_group_summaries")) or _env_on(
"MA_ENABLE_LLM_COMMENT_GROUP_SUMMARIES"
)
@ -443,6 +450,40 @@ def write_competitor_analysis_for_run_dir(
elif not want_pr:
price_llm_rec["skipped"] = "not_enabled"
if want_sg and not skip_sg and merged_rows:
_, scenario_tuple, _ = jcr.resolve_report_tuning(eff_rc)
fb_sg = jcr._consumer_feedback_by_matrix_group(
merged_rows=merged_rows,
comment_rows=comment_rows,
sku_header=sku_h,
)
pl_sg = jcr.build_scenario_groups_llm_payload(
feedback_groups=fb_sg,
scenario_groups=scenario_tuple,
merged_rows=merged_rows,
sku_header=sku_h,
title_h=title_h,
)
if pl_sg:
scenario_gr_llm_rec["attempted"] = True
try:
from .llm_generate import generate_scenario_group_summaries_llm
llm_scenario_gr_md = generate_scenario_group_summaries_llm(
pl_sg, keyword=kw
)
scenario_gr_llm_rec["ok"] = True
scenario_gr_llm_rec["chars"] = len(llm_scenario_gr_md)
except Exception as e:
scenario_gr_llm_rec["ok"] = False
scenario_gr_llm_rec["error"] = str(e)
else:
scenario_gr_llm_rec["skipped"] = "empty_scenario_groups_payload"
elif skip_sg:
scenario_gr_llm_rec["skipped"] = "MA_SKIP_LLM_SCENARIO_GROUP_SUMMARIES"
elif not want_sg:
scenario_gr_llm_rec["skipped"] = "not_enabled"
if want_cg and not skip_cg and merged_rows:
fb_cg = jcr._consumer_feedback_by_matrix_group(
merged_rows=merged_rows,
@ -492,6 +533,10 @@ def write_competitor_analysis_for_run_dir(
json.dumps(comment_gr_llm_rec, ensure_ascii=False, indent=2),
encoding="utf-8",
)
(run_dir / "scenario_groups_llm.json").write_text(
json.dumps(scenario_gr_llm_rec, ensure_ascii=False, indent=2),
encoding="utf-8",
)
md = jcr.build_competitor_markdown(
run_dir=run_dir,
@ -504,6 +549,7 @@ def write_competitor_analysis_for_run_dir(
llm_sentiment_section_md=llm_sentiment_md or None,
llm_matrix_section_md=llm_matrix_md or None,
llm_price_groups_section_md=llm_price_md or None,
llm_scenario_groups_section_md=llm_scenario_gr_md or None,
llm_comment_groups_section_md=llm_comment_gr_md or None,
)

View File

@ -462,6 +462,158 @@ def generate_comment_group_summaries_llm(
return _call_llm(COMMENT_GROUPS_SYSTEM, user)
SCENARIO_GROUPS_SYSTEM = """你是用户研究与品类顾问。输入为 JSON``keyword``、``scenario_lexicon``、``groups``。
``scenario_lexicon`` 列出各场景标签及示例触发子串与报告 **§8.4** 统计规则一致
``groups`` 每项含 ``group`` §5 矩阵一致的细分类目名``effective_text_count``有效评价文本条数
``scenario_distribution``各预设场景的 ``mention_rows`` ``share_of_effective_texts``**一条评价可计入多场景** §8.4 条形图同源
``sample_text_snippets``摘录行常含细类SKU品名店铺等前缀的短引文已截断
统计为**子串命中**不是语义主题模型
**为每个细类**输出一小段 Markdown全部 groups 都要写顺序与输入一致
- ``#### `` + 与该条 ``group`` 字段**完全一致**的细类名作为小节标题;
- 每段约 **100220 **归纳该细类用户**自述的使用场景/用途**结构哪些场景标签相对突出多场景叠加是否常见可点到与其他细类的差异**所有条数与占比须与 ``scenario_distribution````effective_text_count`` 一致**禁止编造
- 引用原话时须保留或复述摘录中的店铺/SKU/品名信息勿虚构
- **禁止** Markdown 表格禁止复述全部摘录 ``effective_text_count`` 很小写明样本较少归纳供启发
总输出约 **6003200 **仅输出正文 Markdown不要用代码围栏包裹全文"""
SCENARIO_GROUPS_USER_PREFIX = (
"请根据以下 JSON 撰写竞品报告 §8.4 用途与使用场景之后的「使用场景要点归纳」正文Markdown\n\n"
)
def generate_scenario_group_summaries_llm(
payload: dict[str, Any], *, keyword: str
) -> str:
"""与 ``generate_comment_group_summaries_llm`` 类似:细类多时长 JSON 按档压缩。"""
def _compact_group(
g: dict[str, Any],
*,
dist_n: int,
sn_n: int,
sn_max: int,
) -> dict[str, Any]:
g2: dict[str, Any] = {
"group": g.get("group"),
"effective_text_count": g.get("effective_text_count"),
}
dist = g.get("scenario_distribution")
if isinstance(dist, list):
g2["scenario_distribution"] = []
for x in dist[:dist_n]:
if not isinstance(x, dict):
continue
g2["scenario_distribution"].append(
{
"scenario": x.get("scenario"),
"mention_rows": x.get("mention_rows"),
"share_of_effective_texts": x.get(
"share_of_effective_texts"
),
}
)
else:
g2["scenario_distribution"] = []
sn = g.get("sample_text_snippets")
if isinstance(sn, list):
g2["sample_text_snippets"] = [
str(x)[:sn_max] for x in sn[:sn_n]
]
else:
g2["sample_text_snippets"] = []
return g2
def _compact_lex(raw: Any, *, max_items: int, trig_n: int) -> list[dict[str, Any]]:
if not isinstance(raw, list):
return []
out: list[dict[str, Any]] = []
for item in raw[:max_items]:
if not isinstance(item, dict):
continue
tr = item.get("trigger_examples")
te = (
[str(x)[:48] for x in tr[:trig_n]]
if isinstance(tr, list)
else []
)
out.append({"label": item.get("label"), "trigger_examples": te})
return out
groups_in = [g for g in (payload.get("groups") or []) if isinstance(g, dict)]
ctx = _llm_context_window_size()
budget = ctx - 512 - 256
def _input_ok(system: str, user_p: str) -> bool:
est = _estimated_chat_input_tokens(system, user_p)
return est < 15_500
levels: list[tuple[int, int, int, int, int]] = [
(16, 14, 260, 10, 12),
(14, 12, 220, 8, 10),
(12, 10, 180, 8, 8),
(10, 8, 150, 6, 6),
(8, 6, 120, 5, 5),
(6, 5, 100, 4, 4),
(5, 4, 80, 3, 3),
]
user = ""
chosen = levels[-1]
for level in levels:
chosen = level
dist_n, sn_n, sn_max, lex_n, trig_n = level
trimmed_g = [
_compact_group(g, dist_n=dist_n, sn_n=sn_n, sn_max=sn_max)
for g in groups_in
]
lex_c = _compact_lex(
payload.get("scenario_lexicon"),
max_items=lex_n,
trig_n=trig_n,
)
body = {
"keyword": keyword,
"scenario_lexicon": lex_c,
"groups": trimmed_g,
}
raw = json.dumps(body, ensure_ascii=False)
if len(raw) > 48_000:
raw = raw[:44_000] + "\n\n"
user = SCENARIO_GROUPS_USER_PREFIX + raw
if _input_ok(SCENARIO_GROUPS_SYSTEM, user):
break
else:
tail = "\n\nJSON 已截断以适配上下文;仅依据可见字段撰写。)\n"
dist_n, sn_n, sn_max, lex_n, trig_n = chosen
trimmed_g = [
_compact_group(g, dist_n=dist_n, sn_n=sn_n, sn_max=sn_max)
for g in groups_in
]
lex_c = _compact_lex(
payload.get("scenario_lexicon"),
max_items=lex_n,
trig_n=trig_n,
)
raw = json.dumps(
{
"keyword": keyword,
"scenario_lexicon": lex_c,
"groups": trimmed_g,
},
ensure_ascii=False,
)
room = max(
2000,
int((budget - 800) / 0.55)
- len(SCENARIO_GROUPS_SYSTEM)
- len(SCENARIO_GROUPS_USER_PREFIX)
- len(tail),
)
user = SCENARIO_GROUPS_USER_PREFIX + raw[: max(1500, room)] + tail
return _call_llm(SCENARIO_GROUPS_SYSTEM, user)
PRICE_GROUPS_SYSTEM = """你是定价与渠道顾问。输入为 JSON``keyword`` 与 ``groups``。
每个 group ``group``细分类目名 §5 矩阵§6按细类价盘小节一致``sku_count````price_stats``该细类可解析展示价的 min/max/median/mean/n §6 各细类 Markdown 分位数表同源
``listing_snippets``若干标题标价券后详情价摘录来自合并表字段已截断

View File

@ -5,6 +5,7 @@
- §6 ``generate_price_group_summaries_llm``
- §8.2``generate_comment_sentiment_analysis_llm``
- §8末细类评价``generate_comment_group_summaries_llm``
- §8.4 后使用场景``generate_scenario_group_summaries_llm``
- §8.5 类全文补充独立长文``generate_competitor_report_markdown_llm``
cd backend
@ -123,7 +124,7 @@ def main() -> None:
"--only",
type=str,
default="",
help="逗号分隔子集sentiment,matrix,price,comment_groups,report_supplement",
help="逗号分隔子集sentiment,matrix,price,scenario_groups,comment_groups,report_supplement",
)
parser.add_argument(
"--preview-chars",
@ -142,6 +143,7 @@ def main() -> None:
"sentiment",
"matrix",
"price",
"scenario_groups",
"comment_groups",
"report_supplement",
}
@ -164,6 +166,7 @@ def main() -> None:
generate_competitor_report_markdown_llm,
generate_matrix_group_summaries_llm,
generate_price_group_summaries_llm,
generate_scenario_group_summaries_llm,
)
if "sentiment" in only:
@ -222,6 +225,34 @@ def main() -> None:
if not args.live:
print(f" payload groups={len(pl_pr)}", flush=True)
if "scenario_groups" in only:
_, scen_tuple, _ = jcr.resolve_report_tuning(eff_rc)
fb_s = jcr._consumer_feedback_by_matrix_group(
merged_rows=merged,
comment_rows=comment_rows,
sku_header=sku_h,
)
pl_sg = jcr.build_scenario_groups_llm_payload(
feedback_groups=fb_s,
scenario_groups=scen_tuple,
merged_rows=merged,
sku_header=sku_h,
title_h=title_h,
)
def _sg() -> str:
return generate_scenario_group_summaries_llm(pl_sg, keyword=keyword)
_run_one(
"§8.4 使用场景归纳scenario_groups",
_sg,
live=args.live,
preview_chars=args.preview_chars,
)
if not args.live:
n = len((pl_sg or {}).get("groups") or [])
print(f" payload groups={n}", flush=True)
if "comment_groups" in only:
fb = jcr._consumer_feedback_by_matrix_group(
merged_rows=merged,

View File

@ -20,6 +20,7 @@ _REPORT_CONFIG_ALLOWED_KEYS = frozenset(
"llm_matrix_group_summaries",
"llm_price_group_summaries",
"llm_comment_group_summaries",
"llm_scenario_group_summaries",
"comment_focus_words",
"comment_scenario_groups",
"external_market_table_rows",
@ -44,6 +45,7 @@ def validate_report_config_body(value: dict) -> dict:
"llm_matrix_group_summaries",
"llm_price_group_summaries",
"llm_comment_group_summaries",
"llm_scenario_group_summaries",
):
if k in value and value[k] is not None and not isinstance(value[k], bool):
raise serializers.ValidationError(f"{k} 须为 true 或 false")

View File

@ -20,6 +20,7 @@ const REPORT_CONFIG_PASSTHROUGH_BOOL_KEYS = [
'llm_matrix_group_summaries',
'llm_price_group_summaries',
'llm_comment_group_summaries',
'llm_scenario_group_summaries',
]
/**