feat(report): wire LLM group summaries, improve matrix labels and chart Y-axis

Made-with: Cursor
This commit is contained in:
hub-gif 2026-04-14 14:53:24 +08:00
parent 11b9c24334
commit f7a4754e56
2 changed files with 302 additions and 20 deletions

View File

@ -697,9 +697,189 @@ def _comment_sentiment_lexicon(texts: list[str]) -> dict[str, Any]:
}
def _comment_lines_with_product_context(
comment_rows: list[dict[str, str]],
merged_rows: list[dict[str, str]],
*,
sku_header: str,
title_h: str,
) -> list[str]:
"""与 ``comment_rows`` 顺序对齐:带细类/SKU/品名/店铺前缀,供 §8.2 大模型抽样。"""
sku_meta: dict[str, tuple[str, str, str]] = {}
for row in merged_rows:
sku = _cell(row, sku_header).strip()
if not sku:
continue
sku_meta[sku] = (
_competitor_matrix_group_key(row),
_cell(row, title_h),
_cell(row, "detail_shop_name") or _cell(row, "店铺名(shopName)"),
)
out: list[str] = []
for cr in comment_rows:
txt = (cr.get("tagCommentContent") or "").strip()
if not txt:
continue
sku = _cell(cr, "sku").strip()
meta = sku_meta.get(sku)
if meta:
gname, tit, shop = meta
prefix = (
f"【细类:{gname}SKU{sku}|品名:{_md_cell(tit, 80)}"
f"店铺:{_md_cell(shop, 40)}"
)
out.append(prefix + txt)
else:
out.append(txt)
return out
def _matrix_excerpt_line_for_llm(row: dict[str, str], title_h: str) -> str:
title = _md_cell(_cell(row, title_h), 100)
sp = _md_cell(_cell(row, "卖点(sellingPoint)"), 120)
ing_raw = _ingredients_from_product_attributes(_cell(row, "detail_product_attributes"))
ing = _md_cell(_ingredients_single_line(ing_raw), 100) if ing_raw else ""
chunks: list[str] = []
if title:
chunks.append(title)
if sp:
chunks.append(f"卖点:{sp}")
if ing:
chunks.append(f"配料:{ing}")
return "".join(chunks) if chunks else "(无标题摘录)"
def _listing_price_snippet_for_llm(row: dict[str, str], title_h: str) -> str:
title = _md_cell(_cell(row, title_h), 72)
lp = _cell(row, "标价(jdPrice,jdPriceText,realPrice)")
cp = _cell(
row,
"券后到手价(couponPrice,subsidyPrice,finalPrice.estimatedPrice,priceShow)",
)
dp = _cell(row, "detail_price_final")
return f"{title}|标价:{lp}|券后:{cp}|详情价:{dp}"
def build_matrix_groups_llm_payload(
merged_rows: list[dict[str, str]],
*,
title_h: str,
sku_header: str = "",
) -> list[dict[str, Any]]:
"""供 ``generate_matrix_group_summaries_llm``:与 §5 细类划分一致。"""
_ = sku_header
if not merged_rows:
return []
out: list[dict[str, Any]] = []
for gname, grows in _merged_rows_grouped_for_matrix(merged_rows):
prices = _collect_prices(grows)
pst = _price_stats_extended(prices) if prices else {"n": 0}
lines = [_matrix_excerpt_line_for_llm(r, title_h) for r in grows[:24]]
out.append(
{
"group": gname,
"sku_count": len(grows),
"price_stats": pst,
"lines": lines,
}
)
return out
def build_price_groups_llm_payload(
merged_rows: list[dict[str, str]],
*,
title_h: str,
sku_header: str = "",
) -> list[dict[str, Any]]:
"""供 ``generate_price_group_summaries_llm``。"""
_ = sku_header
if not merged_rows:
return []
out: list[dict[str, Any]] = []
for gname, grows in _merged_rows_grouped_for_matrix(merged_rows):
prices = _collect_prices(grows)
pst = _price_stats_extended(prices) if prices else {"n": 0}
snippets = [_listing_price_snippet_for_llm(r, title_h) for r in grows[:16]]
out.append(
{
"group": gname,
"sku_count": len(grows),
"price_stats": pst,
"listing_snippets": snippets,
}
)
return out
def build_comment_groups_llm_payload(
*,
feedback_groups: list[tuple[str, list[dict[str, str]], list[str]]],
focus_words: tuple[str, ...],
merged_rows: list[dict[str, str]],
sku_header: str,
title_h: str,
) -> list[dict[str, Any]]:
"""供 ``generate_comment_group_summaries_llm``。"""
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
sku_meta[sku] = (
_competitor_matrix_group_key(row),
_cell(row, title_h),
_cell(row, "detail_shop_name") or _cell(row, "店铺名(shopName)"),
)
out: list[dict[str, Any]] = []
for gname, cr, tu in feedback_groups:
if not tu and not cr:
continue
gh = _group_keyword_hits(cr, tu, focus_words=focus_words)
focus_hit_lines = [
f"{w}{n}" for w, n in gh.most_common(14) if n > 0
]
snippets: list[str] = []
for row in cr[:48]:
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}SKU{sku}|品名:{_md_cell(tit, 60)}"
f"店铺:{_md_cell(shop, 28)}"
)
snippets.append(prefix + txt[:300])
else:
snippets.append(
f"【细类:{gname}SKU{sku or ''}" + txt[:320]
)
if len(snippets) >= 16:
break
eff = tu[:28]
if len(tu) > 28:
eff = list(eff) + [f"…共 {len(tu)} 条有效文本,此处截断"]
out.append(
{
"group": gname,
"comment_flat_rows": f"评价行 {len(cr)};有效文本单元 {len(tu)}",
"effective_text_lines": eff,
"focus_hit_lines": focus_hit_lines,
"sample_text_snippets": snippets,
}
)
return out
def build_comment_sentiment_llm_payload(
texts: list[str],
*,
attributed_texts: list[str] | None = None,
max_samples_positive: int = 16,
max_samples_negative: int = 30,
max_samples_mixed: int = 10,
@ -713,18 +893,27 @@ def build_comment_sentiment_llm_payload(
pos_only_texts: list[str] = []
neg_only_texts: list[str] = []
mixed_texts: list[str] = []
for t in texts:
use_attr = (
attributed_texts is not None
and len(attributed_texts) == len(texts)
)
for i, t in enumerate(texts):
s = (t or "").strip()
if not s:
continue
disp = (
(attributed_texts[i] or s).strip()
if use_attr
else s
)
hp = any(k in s for k in _POS_CLASS)
hn = any(k in s for k in _NEG_CLASS)
if hp and hn:
mixed_texts.append(s)
mixed_texts.append(disp)
elif hp:
pos_only_texts.append(s)
pos_only_texts.append(disp)
elif hn:
neg_only_texts.append(s)
neg_only_texts.append(disp)
def _sample(seq: list[str], cap: int) -> list[str]:
out: list[str] = []
@ -1136,26 +1325,73 @@ def _structure_category_mix(
return _category_mix(rows)
def _category_token_meaningless(seg: str) -> bool:
"""纯数字类目 ID、空串或疑似内部编码的段不宜直接作为矩阵分组展示名。"""
t = (seg or "").strip()
if not t:
return True
if t.isdigit():
return True
if len(t) >= 14 and re.fullmatch(r"[A-Za-z0-9_\-]+", t):
return True
return False
def _matrix_display_segment_from_parts(parts: list[str]) -> str | None:
"""
与历史逻辑一致的主选段若该段无意义则自右向左找第一段可读文本
避免仅类目码或中间段为数字 ID 时整组成品名式乱桶
"""
if not parts:
return None
if len(parts) >= 4:
preferred = parts[-2]
elif len(parts) >= 3:
preferred = parts[1]
elif len(parts) >= 2:
preferred = parts[1]
else:
preferred = parts[0]
order: list[str] = []
if preferred:
order.append(preferred)
if len(parts) >= 2:
order.append(parts[-2])
order.append(parts[-1])
order.extend(reversed(parts))
seen: set[str] = set()
for cand in order:
if not cand or cand in seen:
continue
seen.add(cand)
if not _category_token_meaningless(cand):
return cand.strip()
return None
def _competitor_matrix_group_key(row: dict[str, str]) -> str:
"""
竞品矩阵分组使饼干面条等同细类同表
- 路径 4 取倒数第二段 > 面条 > 挂面 面条
- 路径 3 取中间段 休闲食品 > 饼干 > 粗粮饼干 饼干
- 路径 2 取第二段1 取该段
竞品矩阵分组使饼干面条等同细类同表§5 / §8 / 统计图共用
优先商详类目路径路径段仅为内部编码时退化为规格属性简称避免一品一类
"""
prop = _cell(row, _K_PROP_COL)
sn = _shortname_from_prop(prop)
c = _category_cell(row)
if not c:
if sn:
return sn[:80]
return "未归类(无类目路径)"
parts = [p.strip() for p in c.replace("", ">").split(">") if p.strip()]
if not parts:
if sn:
return sn[:80]
return "未归类(无类目路径)"
if len(parts) >= 4:
return parts[-2]
if len(parts) >= 3:
return parts[1]
if len(parts) >= 2:
return parts[1]
return parts[0]
key = _matrix_display_segment_from_parts(parts)
if key:
return key[:80]
if sn:
return sn[:80]
return "未归类(类目仅为内部编码)"
def _merged_rows_grouped_for_matrix(
@ -1426,6 +1662,9 @@ def build_competitor_markdown(
meta: dict[str, Any] | None,
report_config: dict[str, Any] | None = None,
llm_sentiment_section_md: str | None = None,
llm_matrix_section_md: str | None = None,
llm_price_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)
sku_header = "SKU(skuId)"
@ -1895,6 +2134,20 @@ def build_competitor_markdown(
)
lines.append("")
_llm_mx = (llm_matrix_section_md or "").strip()
if _llm_mx:
lines.extend(
[
"",
"#### 细类要点归纳(大模型,与上表矩阵互补)",
"",
"> **说明**:与 §5 相同的细类划分下归纳卖点与配料共性;**具体 SKU、价格与表格列以正文为准**。",
"",
_llm_mx,
"",
]
)
ch6_price_title = (
"## 六、价格分析PC 搜索列表全量)"
if list_export and pst_list.get("n", 0) > 0
@ -1950,6 +2203,20 @@ def build_competitor_markdown(
lines.extend(_markdown_price_promotion_section(promo_sig))
lines.append("")
_llm_pr = (llm_price_groups_section_md or "").strip()
if _llm_pr:
lines.extend(
[
"",
"#### 细类价盘要点归纳(大模型,与 §6 量化表互补)",
"",
"> **说明**:侧重价带与标价/券后关系的可读叙述;**数值以正文分位数表为准**。",
"",
_llm_pr,
"",
]
)
attrs: list[str] = []
for row in merged_rows:
a = _cell(row, "detail_product_attributes")
@ -2111,6 +2378,20 @@ def build_competitor_markdown(
lines.append("*未命中预设场景词组。*")
lines.append("")
_llm_cg = (llm_comment_groups_section_md or "").strip()
if _llm_cg:
lines.extend(
[
"",
"#### 细类评价与关注词要点归纳(大模型,与 §8.38.4 图表互补)",
"",
"> **说明**:归纳各细类反馈主题;**命中次数与条形图以正文为准**。",
"",
_llm_cg,
"",
]
)
lines.extend(
[
"---",

View File

@ -422,13 +422,14 @@ def generate_report_charts(run_dir: Path, brief: dict[str, Any]) -> list[str]:
for s in skus:
if not isinstance(s, dict):
continue
sku = str(s.get("sku_id") or "").strip()
title = str(s.get("title") or "").strip()
if sku:
label = sku if len(sku) <= 20 else sku[:18] + ""
sku = str(s.get("sku_id") or "").strip()
# 与 §5 矩阵「产品」列一致:纵轴优先品名,无标题时再退化为 SKU
if title:
label = title if len(title) <= 30 else title[:28] + ""
elif sku:
label = sku if len(sku) <= 22 else sku[:20] + ""
else:
label = title if len(title) <= 16 else title[:14] + ""
if not label:
label = "?"
p: float | None = None
for k in (