mirror of
https://github.com/primedigitaltech/market-assistant.git
synced 2026-07-22 08:01:34 +08:00
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:
parent
afe2748129
commit
dd5fa8a407
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -54,10 +54,10 @@ REPORT_SYSTEM = """你是业务与产品读者顾问。输入 JSON 含 `keyword`
|
||||
- **Markdown**,约 **800~1500 字**;
|
||||
- 建议用 ``####`` 组织:**执行摘要级要点**、**竞争与价盘**、**用户声量与负向事由**(须归纳用户在抱怨什么类型的问题,而非只堆关键词)、**与第九章衔接的策略边界**;
|
||||
- 若有 `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. ``#### 正向体验主题``:3~6 条;每条用一句话概括一类满意点(如口感、甜度、饱腹、性价比、物流),**尽量**在句末用简短「」引用样本中的原话片段佐证(无合适原话则省略引号,勿杜撰)。
|
||||
2. ``#### 负向评价主题归因``:**核心段落**。在「偏负向」与「混合」样本中归纳 **4~8 个具体问题维度**(示例维度,按需选用:口味/难吃/怪味、过甜或寡淡、质地口感、价格与促销、包装破损、物流时效、真伪与效期、与宣传不符、健康/功效疑虑等)。每个维度下用 1~2 条列表项写清「用户具体在抱怨什么」,并**尽量**附上来自 ``sample_reviews_negative_biased`` 或 ``sample_reviews_mixed_tone`` 的「」短引文(引文内**尽量含** ``【细类…】`` 前缀或在同句中点明细类/SKU/品名);若某维度在样本中几乎无依据则不要硬写。
|
||||
2. ``#### 负向评价主题归因``:**核心段落**。在「偏负向」与「混合」样本中归纳 **4~8 个具体问题维度**(示例维度,按需选用:口味/难吃/怪味、过甜或寡淡、质地口感、价格与促销、包装破损、物流时效、真伪与效期、与宣传不符、健康/功效疑虑等)。每个维度下用 1~2 条列表项写清「用户具体在抱怨什么」,并**尽量**附上来自 ``sample_reviews_negative_biased`` 或 ``sample_reviews_mixed_tone`` 的「」短引文(引文内**须含** ``【细类…|…店铺…】`` 前缀,或明确写出与前缀一致的**店铺 + 品名/SKU**);若某维度在样本中几乎无依据则不要硬写。
|
||||
3. ``#### 混合评价中的典型张力``(可选):若 ``sample_reviews_mixed_tone`` 非空,用 2~4 条说明同一条评价里正负并存时在讨论什么(如「认可低糖但嫌口感」);否则写一句「本批混合样本较少,从略」。
|
||||
4. ``#### 使用注意``:1~3 句说明:关键词分桶的局限、抽样与截断、与医学/功效结论无关等。
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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值≤55(1 款)",
|
||||
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("SKU:100", 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("SKU:100", snip)
|
||||
self.assertIn("店铺:", snip)
|
||||
self.assertIn("整体口感还差点意思", snip)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user