feat(comments): attribute sentiment LLM samples to shop and SKU

- Add _comment_lines_with_product_context and build_comment_groups_llm_payload
- build_comment_sentiment_llm_payload: lexicon on plain text, samples with
  【细类|SKU|品名|店铺】 prefix; jd_runner passes parallel attributed lines
- Tighten §8.2/§8.5 prompts so negatives tie to shop + product; tests assert 店铺

Made-with: Cursor
This commit is contained in:
hub-gif 2026-04-14 11:04:35 +08:00
parent afe2748129
commit dd5fa8a407
4 changed files with 345 additions and 22 deletions

View File

@ -521,6 +521,84 @@ def _iter_comment_text_units(
return out
def _context_tag_escape(s: str) -> str:
"""避免破坏 ``【细类…】`` 定界符。"""
return (s or "").replace("", "]").replace("", "|").replace("\n", " ").strip()
def _comment_context_prefix(
*,
matrix_group: str,
sku: str,
title: str,
shop: str,
) -> str:
g = _context_tag_escape(matrix_group)[:40]
sk = _context_tag_escape(sku)[:22]
tit = _context_tag_escape(title)[:56]
sh = _context_tag_escape(shop)[:36]
return f"【细类:{g}SKU{sk}|品名:{tit}|店铺:{sh}"
def _merged_by_sku(
merged_rows: list[dict[str, str]], sku_header: str
) -> dict[str, dict[str, str]]:
out: dict[str, dict[str, str]] = {}
for row in merged_rows:
sku = _cell(row, sku_header).strip()
if sku and sku not in out:
out[sku] = row
return out
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]:
"""
:func:`_iter_comment_text_units` **同序同条数**逐条评价仅在正文前加归属头
§8.2 等大模型抽样**情感词表统计**仍须用无头正文 :func:`_iter_comment_text_units`
"""
if not merged_rows:
return list(_iter_comment_text_units(comment_rows, []))
sku_map = _sku_to_matrix_group_map(merged_rows, sku_header)
by_sku = _merged_by_sku(merged_rows, sku_header)
out: list[str] = []
for row in comment_rows:
t = _cell(row, "tagCommentContent")
if not t:
continue
sku = _cell(row, "sku").strip()
g = sku_map.get(sku, "未归类(评价 SKU 无对应深入样本)")
m = by_sku.get(sku) or {}
prefix = _comment_context_prefix(
matrix_group=g,
sku=sku,
title=_cell(m, title_h),
shop=_cell(m, "店铺名(shopName)", "detail_shop_name"),
)
out.append(prefix + t)
if out:
return out
for row in merged_rows:
p = _cell(row, "comment_preview")
if not p:
continue
sku = _cell(row, sku_header).strip()
g = _competitor_matrix_group_key(row)
prefix = _comment_context_prefix(
matrix_group=g,
sku=sku,
title=_cell(row, title_h),
shop=_cell(row, "店铺名(shopName)", "detail_shop_name"),
)
out.append(prefix + p)
return out
_POS_LEX = (
"",
"",
@ -700,39 +778,57 @@ def _comment_sentiment_lexicon(texts: list[str]) -> dict[str, Any]:
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,
max_chars_per_review: int = 300,
max_chars_per_review: int = 360,
) -> dict[str, Any]:
"""
供大模型做正/负向语义归纳附规则统计与**去重后的评价原文抽样** §8.2 词表分桶一致
负向样本默认多于正向便于大模型做具体问题是什么的主题归因而非只复述词频
``attributed_texts`` ``texts`` 须一一对齐前者为带 ``细类SKU品名店铺`` 头的展示串
分桶与 ``comment_sentiment_lexicon`` 仍只基于 ``texts``无头正文避免品名营销词干扰粗判
"""
attrs: list[str]
if attributed_texts is not None and len(attributed_texts) == len(texts):
attrs = list(attributed_texts)
else:
attrs = list(texts)
pos_only_texts: list[str] = []
neg_only_texts: list[str] = []
mixed_texts: list[str] = []
for t in texts:
s = (t or "").strip()
pos_attr_pairs: list[tuple[str, str]] = []
neg_attr_pairs: list[tuple[str, str]] = []
mixed_attr_pairs: list[tuple[str, str]] = []
for plain, attr in zip(texts, attrs):
s = (plain or "").strip()
if not s:
continue
hp = any(k in s for k in _POS_CLASS)
hn = any(k in s for k in _NEG_CLASS)
a = (attr or plain).strip()
if hp and hn:
mixed_texts.append(s)
mixed_attr_pairs.append((s, a))
elif hp:
pos_only_texts.append(s)
pos_attr_pairs.append((s, a))
elif hn:
neg_only_texts.append(s)
neg_attr_pairs.append((s, a))
def _sample(seq: list[str], cap: int) -> list[str]:
def _sample_pairs(pairs: list[tuple[str, str]], cap: int) -> list[str]:
out: list[str] = []
seen: set[str] = set()
for raw in seq:
if raw in seen:
seen_plain: set[str] = set()
for plain, attr in pairs:
if plain in seen_plain:
continue
seen.add(raw)
seen_plain.add(plain)
raw = (attr or plain).strip()
if len(raw) > max_chars_per_review:
out.append(raw[:max_chars_per_review] + "")
else:
@ -750,12 +846,75 @@ def build_comment_sentiment_llm_payload(
"comment_sentiment_lexicon": lex,
"positive_lexeme_hits_top": pos_h_top,
"negative_lexeme_hits_top": neg_h_top,
"sample_reviews_positive_biased": _sample(pos_only_texts, max_samples_positive),
"sample_reviews_negative_biased": _sample(neg_only_texts, max_samples_negative),
"sample_reviews_mixed_tone": _sample(mixed_texts, max_samples_mixed),
"sample_reviews_positive_biased": _sample_pairs(
pos_attr_pairs, max_samples_positive
),
"sample_reviews_negative_biased": _sample_pairs(
neg_attr_pairs, max_samples_negative
),
"sample_reviews_mixed_tone": _sample_pairs(
mixed_attr_pairs, max_samples_mixed
),
"sample_attribution_note": (
"每条样本行前【细类SKU品名店铺】来自合并表与矩阵分组"
"写负向体验时须让读者能对应到「哪家店、哪条 SKU/哪款品名」,勿脱离前缀另编店铺或品名。"
),
}
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]]:
"""
按矩阵细类组装关注词/评价摘录 LLM 输入细类块列表
``sample_text_snippets`` §8.2 抽样一致 ``细类SKU品名店铺`` 前缀
"""
sku_map = _sku_to_matrix_group_map(merged_rows, sku_header) if merged_rows else {}
by_sku = _merged_by_sku(merged_rows, sku_header) if merged_rows else {}
out: list[dict[str, Any]] = []
for gname, cr, tu in feedback_groups:
snippets: list[str] = []
for row in cr:
t = _cell(row, "tagCommentContent")
if not t:
continue
sku = _cell(row, "sku").strip()
g = sku_map.get(sku, gname)
m = by_sku.get(sku) or {}
prefix = _comment_context_prefix(
matrix_group=g,
sku=sku,
title=_cell(m, title_h),
shop=_cell(m, "店铺名(shopName)", "detail_shop_name"),
)
snippets.append(prefix + t)
if len(snippets) >= 8:
break
fh: list[str] = []
blob = "\n".join(tu[:400])
for w in focus_words:
if len(w) < 2:
continue
n = blob.count(w)
if n:
fh.append(f"{w}」×{n}")
out.append(
{
"group": gname,
"comment_flat_rows": len(cr),
"effective_text_lines": len(tu),
"focus_hit_lines": fh[:14],
"sample_text_snippets": snippets,
}
)
return out
def _mermaid_pie_focus_keywords(hits: Counter[str], *, top_k: int = 8) -> str:
"""关注词全局 Top 的 Mermaid pie便于渲染或导出工具识别"""
top = hits.most_common(top_k)

View File

@ -365,12 +365,21 @@ def write_competitor_analysis_for_run_dir(
try:
from .llm_generate import generate_comment_sentiment_analysis_llm
attr_units = jcr._comment_lines_with_product_context(
comment_rows,
merged_rows,
sku_header="SKU(skuId)",
title_h="标题(wareName)",
)
if len(attr_units) != len(comment_units):
attr_units = list(comment_units)
pl = jcr.build_comment_sentiment_llm_payload(
comment_units,
attributed_texts=attr_units,
max_samples_positive=16,
max_samples_negative=30,
max_samples_mixed=10,
max_chars_per_review=300,
max_chars_per_review=360,
)
pl["keyword"] = kw
llm_sentiment_md = generate_comment_sentiment_analysis_llm(pl)

View File

@ -54,10 +54,10 @@ REPORT_SYSTEM = """你是业务与产品读者顾问。输入 JSON 含 `keyword`
- **Markdown** **8001500 **
- 建议用 ``####`` 组织:**执行摘要级要点**、**竞争与价盘**、**用户声量与负向事由**(须归纳用户在抱怨什么类型的问题,而非只堆关键词)、**与第九章衔接的策略边界**
- 若有 `comment_sentiment_lexicon`概括正/负向粗判局限**负向**写清事由类型口感价格物流等
- **归因与引语硬性**`consumer_feedback_by_matrix_group` `comment_sentiment_lexicon` 等均为** SKU/跨店铺的关键词子串或条数统计****不能**据此推断某一店铺某一单品的结论
- **禁止**部分用户反馈口感偏硬发粘**带引号的具体体验原话**除非输入 JSON **同一段可引用的原文**已出现该措辞本段输入通常不含评价全文故默认**不要**使用引号式举例
- 若写口感包装物流价格等维度须用**维度级聚合级**表述点明口径在已合并的评价文本中物流价格类关键词命中较多为全样本子串计数不区分具体 SKU可结合 `matrix_overview_for_llm` 的细类名谈**结构分布**勿把词频偷换成用户点名某款商品的叙事
- 正文前节§8.2已有带归属的抽样解读本段**只可概括其结论层级****不要**重复杜撰新的引文
- **归因与引语硬性**`consumer_feedback_by_matrix_group` `comment_sentiment_lexicon` 等均为** SKU/跨店铺的关键词子串或条数统计****不能**单独据此推断某一店铺某一单品的结论
- **具体体验句式含口感包装等**须以正文 **§8.2** 中带 ``细类SKU品名店铺`` 前缀的抽样为准本段**不要**新增无前缀无店铺/品名/SKU 指向的引语
- 若写口感包装物流价格等**聚合**维度点明口径在已合并的评价文本中物流价格类关键词命中较多为全样本子串计数可结合 `matrix_overview_for_llm` 谈细类结构**可一句**引导读者 §8.2 按店铺/品名的负向举例
- §8.2 已归纳带店铺与 SKU 的负向主题本段**只做执行摘要级收束**勿重复编造新引文
- 语气专业中文缺失项写本段未提供该项而非猜测"""
REPORT_USER_PREFIX = """请根据以下 JSON 撰写上文所述 §8.5 嵌入段落Markdown 正文,勿加 ### 8.5 标题)。\n\n"""
@ -79,18 +79,18 @@ SENTIMENT_LLM_SYSTEM = """你是电商/食品类用户研究助手。输入 JSON
- ``comment_sentiment_lexicon``关键词规则下的条数与短语命中粗判非深度学习
- ``positive_lexeme_hits_top`` / ``negative_lexeme_hits_top``短语级命中摘要与条形图同源
- ``sample_reviews_*``按同一规则从评价中抽样的短文已截断**仅可依据这些原文与 lexicon 数字归纳**
每条样本通常以 ``细类SKU品名`` 开头表示该句评价对应的 **§5 矩阵细类****具体 SKU/商品标题**写归纳与引用短引文时**保留或复述该归属**例如先点明饼干类某 SKU再引口感原话**禁止**把多条样本混成用户普遍却不交代是哪类产
每条样本通常以 ``细类SKU品名店铺`` 开头表示该句评价对应的 **§5 矩阵细类****具体 SKU/商品标题****店铺**写归纳与引用短引文时**让读者能回答哪家店哪条 SKU哪款品名**或保留该前缀或在同一句内用店铺名 + 品名/SKU复述一致信息**禁止**把多条样本混成用户普遍却不交代是哪一店哪一
**硬性要求**
- **仅输出 Markdown 正文**不要用 ``` 围栏包裹全文
- **不要编造**样本中未出现的具体事实品牌价格医学功效
- 条数占比等**定量表述须与** ``comment_sentiment_lexicon`` **一致**勿与样本矛盾
- 若某具体措辞口感偏硬****出现在任一 ``sample_reviews_*`` 字符串**禁止**用引号写出该句或暗示为直接引语仅可写口感相关抱怨在样本/词表中较集中等聚合表述
- 若某具体措辞口感偏硬****出现在任一 ``sample_reviews_*`` 字符串含前缀后的正文**禁止**用引号写出该句或暗示为直接引语仅可写口感相关抱怨在样本/词表中较集中等聚合表述
- **不要**只复述某词出现 N 词频条形图已在报告正文你的价值是**语义层归纳**用户在说什么不满/满意的具体事由是什么
**建议结构**使用四级标题 ``####``
1. ``#### 正向体验主题``36 条;每条用一句话概括一类满意点(如口感、甜度、饱腹、性价比、物流),**尽量**在句末用简短「」引用样本中的原话片段佐证(无合适原话则省略引号,勿杜撰)。
2. ``#### 负向评价主题归因``**核心段落**。在「偏负向」与「混合」样本中归纳 **48 个具体问题维度**(示例维度,按需选用:口味/难吃/怪味、过甜或寡淡、质地口感、价格与促销、包装破损、物流时效、真伪与效期、与宣传不符、健康/功效疑虑等)。每个维度下用 12 条列表项写清「用户具体在抱怨什么」,并**尽量**附上来自 ``sample_reviews_negative_biased`` 或 ``sample_reviews_mixed_tone`` 的「」短引文(引文内**尽量含** ``【细类…】`` 前缀或在同句中点明细类/SKU/品名);若某维度在样本中几乎无依据则不要硬写。
2. ``#### 负向评价主题归因``**核心段落**。在「偏负向」与「混合」样本中归纳 **48 个具体问题维度**(示例维度,按需选用:口味/难吃/怪味、过甜或寡淡、质地口感、价格与促销、包装破损、物流时效、真伪与效期、与宣传不符、健康/功效疑虑等)。每个维度下用 12 条列表项写清「用户具体在抱怨什么」,并**尽量**附上来自 ``sample_reviews_negative_biased`` 或 ``sample_reviews_mixed_tone`` 的「」短引文(引文内**须含** ``【细类…|…店铺…】`` 前缀,或明确写出与前缀一致的**店铺 + 品名/SKU**);若某维度在样本中几乎无依据则不要硬写。
3. ``#### 混合评价中的典型张力``(可选):若 ``sample_reviews_mixed_tone`` 非空,用 24 条说明同一条评价里正负并存时在讨论什么(如「认可低糖但嫌口感」);否则写一句「本批混合样本较少,从略」。
4. ``#### 使用注意``13 句说明:关键词分桶的局限、抽样与截断、与医学/功效结论无关等。
@ -104,9 +104,9 @@ def generate_comment_sentiment_analysis_llm(payload: dict[str, Any]) -> str:
if len(raw) > 88_000:
# 超长时优先压缩正向与混合,保留更多负向样本以利主题归因
for k, cap, maxlen in (
("sample_reviews_positive_biased", 8, 140),
("sample_reviews_mixed_tone", 6, 140),
("sample_reviews_negative_biased", 18, 160),
("sample_reviews_positive_biased", 8, 200),
("sample_reviews_mixed_tone", 6, 200),
("sample_reviews_negative_biased", 18, 220),
):
lst = p.get(k)
if isinstance(lst, list):

View File

@ -33,6 +33,7 @@ class BuildCompetitorBriefTests(SimpleTestCase):
self.assertEqual(out["scope"]["merged_sku_count"], 0)
self.assertIsInstance(out["strategy_hints"], list)
self.assertEqual(out["matrix_by_group"], [])
self.assertTrue(out.get("matrix_compact_section"))
self.assertIn("comment_sentiment_lexicon", out)
self.assertEqual(out["comment_sentiment_lexicon"].get("text_units"), 0)
import json
@ -65,3 +66,157 @@ class BuildCompetitorBriefTests(SimpleTestCase):
words = {x["word"] for x in out["comment_focus_keywords"]}
self.assertIn("自定义词阿尔法", words)
def test_matrix_group_key_product_like_title_not_used_as_group(self) -> None:
"""类目列误入商品标题时,勿当作「饼干」式细类名。"""
root = Path(settings.CRAWLER_JD_ROOT).resolve()
if str(root) not in sys.path:
sys.path.insert(0, str(root))
import jd_competitor_report as jcr # noqa: WPS433
cat = "类目(leafCategory,cid3Name,catid)"
prop = "规格属性(propertyList,color,catid,shortName)"
title_row = {
cat: "南纳香低gi大米10斤 GI值≤551 款)",
prop: "",
}
self.assertEqual(
jcr._competitor_matrix_group_key(title_row, catid_short={}),
jcr._MATRIX_GROUP_LIST_PRODUCTLIKE_FALLBACK,
)
title_with_sn = {
cat: "南纳香低gi大米10斤 GI值≤55",
prop: "简称: 大米",
}
self.assertEqual(
jcr._competitor_matrix_group_key(title_with_sn, catid_short={}),
"大米",
)
low_gi_noodle = {cat: "低GI面条", prop: ""}
self.assertEqual(
jcr._competitor_matrix_group_key(low_gi_noodle, catid_short={}),
"低GI面条",
)
def test_detail_empty_rows_excluded_from_matrix_groups(self) -> None:
"""商详五项全空时不进入矩阵分组(避免仅凭列表类目硬分)。"""
root = Path(settings.CRAWLER_JD_ROOT).resolve()
if str(root) not in sys.path:
sys.path.insert(0, str(root))
import jd_competitor_report as jcr # noqa: WPS433
cat = "类目(leafCategory,cid3Name,catid)"
prop = "规格属性(propertyList,color,catid,shortName)"
sku = "SKU(skuId)"
title = "标题(wareName)"
fail_row = {
sku: "111",
title: "仅列表有标题",
cat: "99999",
prop: "",
"detail_brand": "",
"detail_price_final": "",
"detail_shop_name": "",
"detail_category_path": "",
"detail_product_attributes": "",
}
ok_row = {
sku: "222",
title: "有商详",
cat: "饼干",
prop: "",
"detail_brand": "某品牌",
"detail_price_final": "19.9",
"detail_shop_name": "某店",
"detail_category_path": "",
"detail_product_attributes": "配料:小麦粉",
}
self.assertFalse(jcr._merged_row_has_detail_for_matrix(fail_row))
self.assertTrue(jcr._merged_row_has_detail_for_matrix(ok_row))
grouped = jcr._merged_rows_grouped_for_matrix([fail_row, ok_row])
self.assertEqual(len(grouped), 1)
self.assertEqual(grouped[0][1][0][sku], "222")
m = jcr._sku_to_matrix_group_map([fail_row, ok_row], sku)
self.assertEqual(m.get("111"), jcr._MATRIX_SKU_DETAIL_FAILED_BUCKET)
self.assertNotEqual(m.get("222"), jcr._MATRIX_SKU_DETAIL_FAILED_BUCKET)
def test_list_shop_mix_top_counts_sum_to_shop_rows(self) -> None:
"""Top-N 截断时须带尾桶,否则饼图分母小于含店铺名行数、与 §4.2 表格不一致。"""
root = Path(settings.CRAWLER_JD_ROOT).resolve()
if str(root) not in sys.path:
sys.path.insert(0, str(root))
import jd_competitor_report as jcr # noqa: WPS433
shop_k = "店铺名(shopName)"
rows: list[dict[str, str]] = []
rows.append({shop_k: "头部店", "SKU(skuId)": "1"})
for i in range(50):
rows.append({shop_k: f"小店{i}", "SKU(skuId)": str(i + 2)})
mix = jcr._label_count_dicts_top_n_plus_other(
jcr._structure_shops(rows, list_export=True),
top_n=24,
other_label="其他Top24 以外店铺行数合计)",
)
self.assertEqual(sum(int(x["count"]) for x in mix), 51)
self.assertTrue(
any(
(x.get("label") or "").startswith("其他Top24")
for x in mix
),
"长尾店铺应合并到「其他」尾桶",
)
def test_comment_lines_with_product_context_prefix(self) -> None:
"""评价抽样须带细类/SKU/品名前缀,便于归因。"""
root = Path(settings.CRAWLER_JD_ROOT).resolve()
if str(root) not in sys.path:
sys.path.insert(0, str(root))
import jd_competitor_report as jcr # noqa: WPS433
sku_h = "SKU(skuId)"
title_h = "标题(wareName)"
merged = [
{
sku_h: "100",
title_h: "低GI全麦饼干1kg",
"detail_brand": "B",
"detail_price_final": "29",
"detail_shop_name": "",
"detail_category_path": "a>饼干",
"detail_product_attributes": "x",
}
]
comments = [{"sku": "100", "tagCommentContent": "整体口感还差点意思"}]
lines = jcr._comment_lines_with_product_context(
comments, merged, sku_header=sku_h, title_h=title_h
)
self.assertEqual(len(lines), 1)
self.assertIn("【细类:", lines[0])
self.assertIn("SKU100", lines[0])
self.assertIn("品名:", lines[0])
self.assertIn("店铺:", lines[0])
self.assertIn("整体口感还差点意思", lines[0])
fb = jcr._consumer_feedback_by_matrix_group(
merged_rows=merged,
comment_rows=comments,
sku_header=sku_h,
)
pl = jcr.build_comment_groups_llm_payload(
feedback_groups=fb,
focus_words=("口感",),
merged_rows=merged,
sku_header=sku_h,
title_h=title_h,
)
self.assertTrue(pl)
snip = (pl[0].get("sample_text_snippets") or [""])[0]
self.assertIn("SKU100", snip)
self.assertIn("店铺:", snip)
self.assertIn("整体口感还差点意思", snip)