fix(report): use detail_category_path only for matrix and fine-category stats

Made-with: Cursor
This commit is contained in:
hub-gif 2026-04-14 17:25:54 +08:00
parent e73f1ffe8d
commit c6560bd12b
2 changed files with 220 additions and 234 deletions

View File

@ -192,12 +192,7 @@ def _cell(row: dict[str, str], *keys: str) -> str:
return ""
# 合并表列名lean 仅有搜索侧「类目(...)」full 或历史文件可能含商详「detail_category_path」。勿用内部键 leaf_categoryCSV 中不存在)。
_MERGED_CATEGORY_KEYS: tuple[str, ...] = (
"detail_category_path",
"leaf_category",
"类目(leafCategory,cid3Name,catid)",
)
_DETAIL_CATEGORY_PATH_KEY = "detail_category_path"
_K_CAT_COL = "类目(leafCategory,cid3Name,catid)"
_K_PROP_COL = "规格属性(propertyList,color,catid,shortName)"
@ -207,17 +202,9 @@ def _shortname_from_prop(prop: str) -> str:
return m.group(1).strip()[:120] if m else ""
def _category_cell(row: dict[str, str]) -> str:
c = _cell(row, *_MERGED_CATEGORY_KEYS)
if c:
return c
prop = _cell(row, _K_PROP_COL)
sn = _shortname_from_prop(prop)
if re.search(r"类目[:]\s*\d+", prop):
if sn:
return sn
return ""
return ""
def _detail_category_path_cell(row: dict[str, str]) -> str:
"""细类矩阵与按细类评价统计仅以该列为准;空则视为商详类目不完整。"""
return str(row.get(_DETAIL_CATEGORY_PATH_KEY) or "").strip()
def _search_export_catid_to_shortname_map(rows: list[dict[str, str]]) -> dict[str, str]:
@ -710,8 +697,11 @@ def _comment_lines_with_product_context(
sku = _cell(row, sku_header).strip()
if not sku:
continue
gk = _competitor_matrix_group_key(row)
if not gk:
continue
sku_meta[sku] = (
_competitor_matrix_group_key(row),
gk,
_cell(row, title_h),
_cell(row, "detail_shop_name") or _cell(row, "店铺名(shopName)"),
)
@ -828,8 +818,11 @@ def build_comment_groups_llm_payload(
sku = _cell(row, sku_header).strip()
if not sku:
continue
gk = _competitor_matrix_group_key(row)
if not gk:
continue
sku_meta[sku] = (
_competitor_matrix_group_key(row),
gk,
_cell(row, title_h),
_cell(row, "detail_shop_name") or _cell(row, "店铺名(shopName)"),
)
@ -1006,8 +999,11 @@ def _sku_to_matrix_group_map(
m: dict[str, str] = {}
for row in merged_rows:
sku = _cell(row, sku_header).strip()
if sku:
m[sku] = _competitor_matrix_group_key(row)
if not sku:
continue
gk = _competitor_matrix_group_key(row)
if gk:
m[sku] = gk
return m
@ -1079,11 +1075,22 @@ def _consumer_feedback_by_matrix_group(
]
sku_map = _sku_to_matrix_group_map(merged_rows, sku_header)
merged_by_sku: dict[str, dict[str, str]] = {}
for row in merged_rows:
s = _cell(row, sku_header).strip()
if s:
merged_by_sku[s] = row
by_g: dict[str, list[dict[str, str]]] = {}
for row in comment_rows:
sku = _cell(row, "sku").strip()
g = sku_map.get(sku, "未归类(评价 SKU 无对应深入样本)")
by_g.setdefault(g, []).append(row)
g = sku_map.get(sku)
if g:
by_g.setdefault(g, []).append(row)
continue
if sku and sku in merged_by_sku:
# 深入样本存在但缺 detail_category_path或路径无法解析为可读细类不参与按细类分析
continue
by_g.setdefault("未归类(评价 SKU 无对应深入样本)", []).append(row)
out: list[tuple[str, list[dict[str, str]], list[str]]] = []
used: set[str] = set()
@ -1258,73 +1265,6 @@ def _search_list_proxies(rows: list[dict[str, str]]) -> dict[str, Any]:
}
def _category_mix(rows: list[dict[str, str]]) -> list[tuple[str, int]]:
cats: list[str] = []
for r in rows:
c = _category_cell(r)
if c:
cats.append(c.split(">")[0].strip() if ">" in c else c[:80])
return Counter(cats).most_common(8)
def _category_mix_search_export(rows: list[dict[str, str]]) -> list[tuple[str, int]]:
"""PC 搜索导出:优先可读类目名;纯数字 ID 时用「简称」聚合,避免展示无意义类目码。"""
id_names = _search_export_catid_to_shortname_map(rows)
labels: list[str] = []
for r in rows:
c = _cell(r, _K_CAT_COL).strip()
p = _cell(r, _K_PROP_COL)
if c and not c.isdigit():
labels.append(c[:80])
continue
if c.isdigit():
labels.append(
id_names.get(c)
or _shortname_from_prop(p)
or "未解析类目(列表仅有内部编码且无简称)"
)
continue
m = re.search(r"类目[:]\s*(\d+)", p)
if m:
cid = m.group(1)
labels.append(
id_names.get(cid)
or _shortname_from_prop(p)
or "未解析类目(列表仅有内部编码且无简称)"
)
else:
sn = _shortname_from_prop(p)
if sn:
labels.append(sn)
return Counter(labels).most_common(12)
def _structure_shops(rows: list[dict[str, str]], *, list_export: bool) -> list[str]:
if list_export:
return [_cell(r, "店铺名(shopName)") for r in rows if _cell(r, "店铺名(shopName)")]
out: list[str] = []
for r in rows:
s = _cell(r, "detail_shop_name") or _cell(r, "店铺名(shopName)")
if s:
out.append(s)
return out
def _structure_brands(rows: list[dict[str, str]], *, list_export: bool) -> list[str]:
if list_export:
k = "店铺信息标题(shopInfoTitle,brandName)"
return [_cell(r, k) for r in rows if _cell(r, k)]
return [_cell(r, "detail_brand") for r in rows if _cell(r, "detail_brand")]
def _structure_category_mix(
rows: list[dict[str, str]], *, list_export: bool
) -> list[tuple[str, int]]:
if list_export:
return _category_mix_search_export(rows)
return _category_mix(rows)
def _category_token_meaningless(seg: str) -> bool:
"""纯数字类目 ID、空串或疑似内部编码的段不宜直接作为矩阵分组展示名。"""
t = (seg or "").strip()
@ -1369,29 +1309,28 @@ def _matrix_display_segment_from_parts(parts: list[str]) -> str | None:
return None
def _matrix_group_label_from_path(path: str) -> str:
"""由 ``detail_category_path`` 文本解析细类展示名;空或无可读段则返回空串。"""
t = (path or "").strip()
if not t:
return ""
parts = [p.strip() for p in t.replace("", ">").split(">") if p.strip()]
if not parts:
return ""
key = _matrix_display_segment_from_parts(parts)
return (key[:80] if key else "")
def _matrix_group_label_from_detail_path(row: dict[str, str]) -> str:
return _matrix_group_label_from_path(_detail_category_path_cell(row))
def _competitor_matrix_group_key(row: dict[str, str]) -> str:
"""
竞品矩阵分组使饼干面条等同细类同表§5 / §8 / 统计图共用
优先商详类目路径路径段仅为内部编码时退化为规格属性简称避免一品一类
竞品矩阵分组§5 / §8 / 统计图共用
****依据 ``detail_category_path``列为空或路径段均为无意义编码时不参与矩阵返回空串
"""
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 "未归类(无类目路径)"
key = _matrix_display_segment_from_parts(parts)
if key:
return key[:80]
if sn:
return sn[:80]
return "未归类(类目仅为内部编码)"
return _matrix_group_label_from_detail_path(row)
def _merged_rows_grouped_for_matrix(
@ -1400,6 +1339,8 @@ def _merged_rows_grouped_for_matrix(
buckets: dict[str, list[dict[str, str]]] = {}
for row in merged_rows:
k = _competitor_matrix_group_key(row)
if not k:
continue
buckets.setdefault(k, []).append(row)
def sort_key(item: tuple[str, list[dict[str, str]]]) -> tuple[int, int, str]:
@ -1410,6 +1351,52 @@ def _merged_rows_grouped_for_matrix(
return sorted(buckets.items(), key=sort_key)
def _category_mix(rows: list[dict[str, str]]) -> list[tuple[str, int]]:
"""深入合并表:仅统计具备可读细类标签的 ``detail_category_path``。"""
labels: list[str] = []
for r in rows:
k = _matrix_group_label_from_detail_path(r)
if k:
labels.append(k)
return Counter(labels).most_common(8)
def _category_mix_search_export(rows: list[dict[str, str]]) -> list[tuple[str, int]]:
"""列表导出:仅当行上存在 ``detail_category_path`` 时纳入(与 §5 口径一致)。"""
labels: list[str] = []
for r in rows:
k = _matrix_group_label_from_detail_path(r)
if k:
labels.append(k)
return Counter(labels).most_common(12)
def _structure_shops(rows: list[dict[str, str]], *, list_export: bool) -> list[str]:
if list_export:
return [_cell(r, "店铺名(shopName)") for r in rows if _cell(r, "店铺名(shopName)")]
out: list[str] = []
for r in rows:
s = _cell(r, "detail_shop_name") or _cell(r, "店铺名(shopName)")
if s:
out.append(s)
return out
def _structure_brands(rows: list[dict[str, str]], *, list_export: bool) -> list[str]:
if list_export:
k = "店铺信息标题(shopInfoTitle,brandName)"
return [_cell(r, k) for r in rows if _cell(r, k)]
return [_cell(r, "detail_brand") for r in rows if _cell(r, "detail_brand")]
def _structure_category_mix(
rows: list[dict[str, str]], *, list_export: bool
) -> list[tuple[str, int]]:
if list_export:
return _category_mix_search_export(rows)
return _category_mix(rows)
def _is_ingredient_url_blob(s: str) -> bool:
"""详情主图 URL 串(分号分隔)或单列以 http 开头。"""
t = (s or "").strip()
@ -1475,7 +1462,7 @@ def _competitor_matrix_md_line(
rank = _md_cell(
_cell(row, "榜单类文案(标签/腰带/标题数组中的榜、TOP 等)"), 28
)
cat = _md_cell(_category_cell(row), 24)
cat = _md_cell(_detail_category_path_cell(row), 24)
ing = _matrix_ingredients_cell(row)
cc = _md_cell(_cell(row, "评价量(commentFuzzy)"), 10)
prev = _md_cell(_cell(row, "comment_preview"), 72)
@ -1637,11 +1624,11 @@ def _lines_4_reading_category(
"",
"**数据解读(规则摘要)**",
"",
f"- 列表侧可读类目/简称共 **{len(cm_structure)}** 种取值,合计 **{total}** 行"
f"- 具备 ``detail_category_path`` 且可解析为细类标签的结构行共 **{total}** 行,对应 **{len(cm_structure)}** 种细类取值(与 §5 同源)"
f"其中「{_md_cell(top_lbl, 40)}」行数最多,约占 **{100 * share:.1f}%**。",
"- 若头部占比极高说明当前关键词下货架被少数品类定义跨品类机会需结合商详矩阵§5再核对。",
"- 若头部类占比极高说明当前关键词下货架被少数品类定义跨品类机会需结合商详矩阵§5再核对。",
"",
"| 类目/简称Top 5 | 列表行数 | 占本章结构样本 |",
"| 细类标签Top 5 | 结构行数 | 占本章有效行 |",
"| --- | ---: | ---: |",
]
for lbl, cnt in cm_structure[:5]:
@ -1672,6 +1659,8 @@ def build_competitor_markdown(
batch = _run_batch_label(run_dir)
n_sku = len(merged_rows)
n_cmt = len(comment_rows)
n_sku_pathed = sum(1 for r in merged_rows if _detail_category_path_cell(r))
n_sku_matrix = sum(1 for r in merged_rows if _competitor_matrix_group_key(r))
list_export = len(search_export_rows) > 0
structure_rows = search_export_rows if list_export else merged_rows
@ -1763,6 +1752,14 @@ def build_competitor_markdown(
f"- **搜索关键词**:「{keyword}",
f"- **分析对象**:流水线拉取的 **{n_sku}** 个 SKU搜索排序靠前子样本非全站普查",
]
if n_sku:
n_sku_nop = n_sku - n_sku_pathed
n_sku_unparsed = n_sku_pathed - n_sku_matrix
lines.append(
f"- **细类分析口径****{n_sku_matrix}** 个 SKU 具备可参与 **§5§8** 的商详 "
f"``detail_category_path``(且路径可解析为可读细类);另有 **{n_sku_nop}** 个缺该字段、"
f"**{n_sku_unparsed}** 个有路径但无可读细类段,**未纳入**细类矩阵与按细类评价统计。"
)
if meta:
lines.append(
f"- **搜索列表页**:逻辑第 **{meta.get('page_start')}** 页至第 **{meta.get('page_to')}** 页;"
@ -1783,6 +1780,7 @@ def build_competitor_markdown(
"- **评价主题词**:对评价正文做**预设词表子串计数**,非分词主题模型,适合扫方向,**需抽样人工验证**。",
"- **用途/场景**:对每条评价独立判断是否命中预设场景词;一条可计入多个场景,统计的是「提及该场景的评价条数」而非用户数。",
"- **用户画像(第八章)**:正负面粗判含**口语短语**级摘录;关注词与场景**仅按细类**以条形图展示(场景图为**占该细类有效文本比例 %**);见 §8.38.4。",
"- **细类划分§5§8****仅**依据合并表 ``detail_category_path``;该列为空或无法解析出可读细类段的 SKU **不参与**竞品矩阵与按细类评价统计(相关评价条亦**不进入**按细类图表)。",
"- **各章衔接(可选)**:若任务配置 ``llm_section_bridges``(或部署侧环境变量启用),则在「## 一」至「## 九」各章二级标题后插入大模型撰写的**衔接分析**段落,便于阅读过渡;**定量结论仍以正文表格与摘要 JSON 为准**。",
"- **检索结果规模**:来自京东 PC 搜索返回的「结果条数」类指标,表示平台侧申报的匹配数量级,**不等于**动销、库存或独立 SKU 数。",
"",
@ -2084,7 +2082,7 @@ def build_competitor_markdown(
_embed_chart(
run_dir,
"chart_category_mix_pie.png",
"类目/可读名称分布(扇形图;已用列表「简称」替代裸类目码",
"细类标签分布(扇形图;自 ``detail_category_path`` 解析,与 §5 口径一致",
)
)
lines.extend(_lines_4_reading_category(cm_structure))
@ -2092,7 +2090,9 @@ def build_competitor_markdown(
"*完整类目行数见结构化摘要 ``category_mix_top``。*"
)
else:
lines.append("*无类目列或无法解析。*")
lines.append(
"*结构样本中无带 ``detail_category_path`` 的可解析细类行,本小节不展示扇形图;细类分布以 §5 为准。*"
)
lines.append("")
lines.extend(
@ -2101,11 +2101,11 @@ def build_competitor_markdown(
"",
"## 五、竞品对比矩阵(按细分类目分组)",
"",
"优先按商详**类目路径**列分组**三级路径**取中间一段(如 … > **饼干** > 粗粮饼干),"
"**四级及以上**取倒数第二段(如 … > **面条** > 挂面)。若该列为空,退化为搜索列表中的类目或规格属性;仍无则「未归类」。全量合并模式下另有更多商详字段可供核对",
"分组**仅**使用合并表列 ``detail_category_path``(商详类目路径)**三级路径**取中间一段(如 … > **饼干** > 粗粮饼干),"
"**四级及以上**取倒数第二段(如 … > **面条** > 挂面)。**该列为空**或路径段均为内部编码、**无法解析出可读细类**的 SKU **不进入**本矩阵,亦**不参与**第八章按细类的评价统计",
"",
"维度说明:**产品**(标题/规格)、**价格**(列表展示)、**渠道**(京东店铺)、**推广**(卖点/榜单文案)、"
"**类目**、**配料表**(见下)、**声量**(评价量与摘要)。",
"**类目路径**``detail_category_path``、**配料表**(见下)、**声量**(评价量与摘要)。",
"",
"**配料表**:优先使用配料正文列(开启配料视觉解析时为识别出的文字);"
"仅有详情长图链接时列内会提示;若商详参数含「配料/配料表:」则摘录该段。"
@ -2114,12 +2114,18 @@ def build_competitor_markdown(
]
)
matrix_header = [
"| SKU | 产品(标题) | 品牌 | 标价 | 详情价 | 渠道(店铺) | 推广(卖点) | 榜单/标签 | 类目 | 配料表 | 评价量(搜索) | 消费者反馈摘要 |",
"| SKU | 产品(标题) | 品牌 | 标价 | 详情价 | 渠道(店铺) | 推广(卖点) | 榜单/标签 | 类目路径(商详) | 配料表 | 评价量(搜索) | 消费者反馈摘要 |",
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
]
grouped_matrix = _merged_rows_grouped_for_matrix(merged_rows)
if not grouped_matrix:
lines.append("*无合并表 SKU。*")
if merged_rows:
lines.append(
"*深入合并表有条目,但均无可用 ``detail_category_path``(或路径无法解析为可读细类),故无法生成细类矩阵;"
"§5§8 中依赖矩阵的按细类统计相应为空。请核对商详抓取与合并字段。*"
)
else:
lines.append("*无合并表 SKU。*")
lines.append("")
for gname, grows in grouped_matrix:
lines.append(f"### {gname}**{len(grows)}** 款)")
@ -2238,8 +2244,8 @@ def build_competitor_markdown(
"",
"### 8.1 方法",
"",
"- **细类划分**:与 **§5 竞品矩阵** 相同,依据商详类目路径解析为「饼干 / 西式糕点 / …」等(规则见 §5 章首说明)。",
"- **归因**:每条评价按其 SKU 对应到深入样本,再映射到该 SKU 所属细类SKU 不在合并表中的评价单独归入说明性分组",
"- **细类划分**:与 **§5 竞品矩阵** 相同,**仅**依据 ``detail_category_path`` 解析为「饼干 / 西式糕点 / …」等(规则见 §5 章首说明)。",
"- **归因**:每条评价按其 SKU 对应到深入样本,再映射到该 SKU 所属细类SKU 不在合并表中的评价单独归入说明性分组**在合并表中但该 SKU 缺 ``detail_category_path`` 或路径无法解析为可读细类的,该评价不进入按细类统计**(与 §5 排除口径一致)",
"- **正负面粗判§8.2**:先以关键词规则与图表做粗分;若任务开启 **llm_comment_sentiment**,可附**大模型对抽样原文的主题归因**(尤其负向「用户在抱怨什么」),与词频条形图互补。",
"- **关注词按细类§8.3**:对组内评价正文做子串计数并出条形图;若无逐条正文则用该细类下评价摘要列拼接兜底;与配置关注词及联想扩展同源。",
"- **用途/场景按细类§8.4**:对组内每条有效文本独立扫描**本次任务生效的场景词组**(来自报告调参或系统默认),一条可属多场景;条形图横轴为**占该细类有效文本比例 %**(多标签下各比例可相加大于 100%)。",
@ -2560,7 +2566,7 @@ def build_competitor_brief(
"shop": _cell(
row, "店铺名(shopName)", "detail_shop_name"
),
"category": _category_cell(row),
"category": _detail_category_path_cell(row),
"selling_point": _cell(row, "卖点(sellingPoint)")[:240],
"comment_fuzzy": _cell(row, "评价量(commentFuzzy)"),
}

View File

@ -8,6 +8,9 @@ from pathlib import Path
from django.conf import settings
from django.test import SimpleTestCase
from pipeline.csv_schema import infer_total_sales_from_sales_floor
from pipeline.report_charts import _cn_volume_int
class BuildCompetitorBriefTests(SimpleTestCase):
def test_empty_merged_json_safe(self) -> None:
@ -33,7 +36,6 @@ 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
@ -67,110 +69,38 @@ 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:
"""类目列误入商品标题时,勿当作「饼干」式细类名。"""
def test_matrix_groups_require_detail_category_path(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
),
"长尾店铺应合并到「其他」尾桶",
sku_h = "SKU(skuId)"
merged = [
{
sku_h: "111",
"detail_category_path": "食品饮料 > 休闲食品 > 饼干 > 粗粮饼干",
"标题(wareName)": "A",
},
{sku_h: "222", "标题(wareName)": "B"},
]
groups = jcr._merged_rows_grouped_for_matrix(merged)
self.assertEqual(len(groups), 1)
self.assertEqual(len(groups[0][1]), 1)
self.assertEqual(groups[0][1][0][sku_h], "111")
smap = jcr._sku_to_matrix_group_map(merged, sku_h)
self.assertEqual(smap.get("111"), "饼干")
self.assertNotIn("222", smap)
fb = jcr._consumer_feedback_by_matrix_group(
merged_rows=merged,
comment_rows=[
{"sku": "222", "tagCommentContent": "缺路径仍不应进细类桶"},
{"sku": "111", "tagCommentContent": "有路径进细类"},
],
sku_header=sku_h,
)
counts = {g: len(cr) for g, cr, _ in fb}
self.assertEqual(counts.get("饼干"), 1)
def test_comment_lines_with_product_context_prefix(self) -> None:
"""评价抽样须带细类/SKU/品名前缀,便于归因。"""
@ -188,9 +118,9 @@ class BuildCompetitorBriefTests(SimpleTestCase):
"detail_brand": "B",
"detail_price_final": "29",
"detail_shop_name": "",
"detail_category_path": "a>饼干",
"detail_category_path": "休闲食品 > 饼干 > 粗粮饼干",
"detail_product_attributes": "x",
}
},
]
comments = [{"sku": "100", "tagCommentContent": "整体口感还差点意思"}]
lines = jcr._comment_lines_with_product_context(
@ -203,20 +133,70 @@ class BuildCompetitorBriefTests(SimpleTestCase):
self.assertIn("店铺:", lines[0])
self.assertIn("整体口感还差点意思", lines[0])
def test_scenario_groups_llm_payload_matches_section_8_4_counts(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
sku_h = "SKU(skuId)"
merged = [
{
sku_h: "111",
"detail_category_path": "食品饮料 > 休闲食品 > 饼干 > 粗粮饼干",
"标题(wareName)": "A饼",
"detail_shop_name": "店甲",
},
]
scen = (("早餐/代餐", ("早餐",)),)
fb = jcr._consumer_feedback_by_matrix_group(
merged_rows=merged,
comment_rows=comments,
comment_rows=[
{"sku": "111", "tagCommentContent": "早上当早餐吃还不错"},
],
sku_header=sku_h,
)
pl = jcr.build_comment_groups_llm_payload(
pl = jcr.build_scenario_groups_llm_payload(
feedback_groups=fb,
focus_words=("口感",),
scenario_groups=scen,
merged_rows=merged,
sku_header=sku_h,
title_h=title_h,
title_h="标题(wareName)",
)
self.assertTrue(pl)
snip = (pl[0].get("sample_text_snippets") or [""])[0]
self.assertIn("SKU100", snip)
self.assertIn("店铺:", snip)
self.assertIn("整体口感还差点意思", snip)
self.assertIn("groups", pl)
self.assertIn("scenario_lexicon", pl)
g0 = pl["groups"][0]
self.assertEqual(g0["group"], "饼干")
self.assertEqual(g0["effective_text_count"], 1)
self.assertEqual(g0["scenario_distribution"][0]["mention_rows"], 1)
self.assertEqual(
g0["scenario_distribution"][0]["scenario"], "早餐/代餐"
)
def test_cn_volume_int_parses_total_sales_trailer(self) -> None:
self.assertEqual(
_cn_volume_int("已售50万+ | good:99%好评"), 500_000
)
self.assertEqual(_cn_volume_int("2.5亿件"), 250_000_000)
def test_infer_total_sales_from_sales_floor(self) -> None:
self.assertEqual(
infer_total_sales_from_sales_floor("good:99%好评 | 已售50万+"),
"已售50万+",
)
self.assertEqual(infer_total_sales_from_sales_floor(""), "")
def test_mix_top_remainder_sums_to_all_rows(self) -> None:
"""饼图与 §4 表同源mix_top 各 count 之和须等于含名行数。"""
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
names = [f"{i}" for i in range(30)]
mix = jcr._counter_mix_top_rows_with_remainder(
names, top_n=24, remainder_label="(其余店铺)"
)
self.assertEqual(sum(v for _, v in mix), 30)
self.assertEqual(mix[-1][0], "(其余店铺)")
self.assertEqual(mix[-1][1], 6)