fix(pipeline): 区分店铺列表行占比与去重SKU占比,避免误导

- 列表导出增加 unique_sku_basis(每 SKU 计 1 次),与按行计的集中度并列写入 brief 与执行摘要。
- 第四章解读与扇图说明强调「列表可见度」非市占;8.5 大模型提示禁止将行占比写成 SKU 款数占比或自营占比。
- 分析页摘要兼容 first_share 并展示去重 SKU 口径。

Made-with: Cursor
This commit is contained in:
hub-gif 2026-04-17 17:19:20 +08:00
parent f09a46a79e
commit ed28e00e74
6 changed files with 139 additions and 20 deletions

View File

@ -91,6 +91,7 @@ from pipeline.competitor_report.list_mix import ( # noqa: E402
_brand_cr,
_counter_mix_top_rows_with_remainder,
_search_list_proxies,
_shop_concentration_by_unique_sku,
_structure_brands,
_structure_names_for_pie_counter,
_structure_shops,
@ -167,6 +168,11 @@ def build_competitor_markdown(
shops_for_cr = _structure_names_for_pie_counter(shops_s)
brands_for_cr = _structure_names_for_pie_counter(brands_s)
cr1_shop, cr3_shop, top_shop_s, _ = _brand_cr(shops_for_cr)
shop_unique_sku_basis = (
_shop_concentration_by_unique_sku(search_export_rows)
if list_export
else None
)
cr1_list_brand, cr3_list_brand, top_list_brand, _ = _brand_cr(brands_for_cr)
# §4.3 类目分布:深入合并表(与 §5 竞品矩阵同一细类划分,非搜索列表行)
cm_structure = _category_mix(merged_rows, top_k=12)
@ -320,12 +326,21 @@ def build_competitor_markdown(
if cr3_shop is not None:
exec_bullets.append(
f"竞争结构({src},第四章):**店铺** 第一大店铺份额 ≈ **{100 * cr1_shop:.1f}%**(「{top_shop_s}」),"
f"前三店铺合计份额 ≈ **{100 * cr3_shop:.1f}%**按列表行计,同一 SKU 多行会重复计)。"
f"前三店铺合计份额 ≈ **{100 * cr3_shop:.1f}%****按列表行计**,同一 SKU 多行会重复计**勿与「去重 SKU 款数占比」或市占混淆**)。"
)
else:
exec_bullets.append(
f"竞争结构({src},第四章):**店铺** 第一大店铺份额 ≈ **{100 * cr1_shop:.1f}%**(「{top_shop_s})。"
f"竞争结构({src},第四章):**店铺** 第一大店铺份额 ≈ **{100 * cr1_shop:.1f}%**(「{top_shop_s}**按列表行计**)。"
)
if shop_unique_sku_basis and shop_unique_sku_basis.get("n_unique_skus"):
u = shop_unique_sku_basis
fs = u.get("first_share")
lab = (u.get("top_label") or "").strip()
if fs is not None and lab:
exec_bullets.append(
f"**对照(按去重 SKU**:同批列表共 **{u['n_unique_skus']}** 个去重 SKU第一大店铺「{lab}"
f"约占 **{100 * float(fs):.1f}%**(每 SKU 计 1 次;**不是**全渠道销量或市占)。"
)
elif not list_export and cr1_deep is not None and top_brand_deep:
if cr3_deep is not None:
exec_bullets.append(
@ -548,7 +563,7 @@ def build_competitor_markdown(
_embed_chart(
run_dir,
"chart_shop_rows_pie.png",
"店铺列表曝光占比(扇形图;按整理后的店铺名计数,与结构化摘要中的店铺占比统计一致"
"店铺列表曝光占比(扇形图;**按列表行计**的店铺名计数,与结构化摘要一致;若与「按去重 SKU」口径差异大见下文规则解读"
"长尾并入「(其余店铺)」;扇形内再合并为「其他」)",
)
)
@ -559,6 +574,7 @@ def build_competitor_markdown(
top=top_shop_s or "",
shop_rows_n=shop_rows_n,
n_structure=n_structure,
unique_sku_basis=shop_unique_sku_basis,
)
)
lines.append(
@ -1019,6 +1035,11 @@ def build_competitor_brief(
shops_for_cr = _structure_names_for_pie_counter(shops_s)
brands_for_cr = _structure_names_for_pie_counter(brands_s)
cr1_shop, cr3_shop, top_shop_s, top_shop_share = _brand_cr(shops_for_cr)
shop_unique_sku_basis = (
_shop_concentration_by_unique_sku(search_export_rows)
if list_export
else None
)
cr1_list_brand, cr3_list_brand, top_list_brand, _ = _brand_cr(brands_for_cr)
cm_structure = _category_mix(merged_rows, top_k=12)
min_brand_rows = max(5, int(0.02 * n_structure)) if n_structure else 5
@ -1238,6 +1259,7 @@ def build_competitor_brief(
"top_three_combined_share": cr3_shop,
"top_label": top_shop_s,
"top_share_pct": top_shop_share,
"unique_sku_basis": shop_unique_sku_basis,
},
"list_brand_field": list_brand_block,
"detail_brand_among_merged": {
@ -1294,7 +1316,7 @@ def build_competitor_brief(
"与在线分析报告各章**计数规则**一致;关注词与场景以任务中的分析规则为准(子串命中统计,非深度主题模型)。",
"价格来自页面展示字段抽取,含促销与规格差异;促销与标价对齐等为启发式摘录,仅供对照。",
"评价语气为关键词粗判,非深度学习情感模型。",
"「集中度」中:最大一家占比、前三名合计占比为小数(如 0.12 表示约 12%),对应列表或深入样本中的相关行",
"「集中度」中:默认按**列表行**计数(同一 SKU 多页曝光会重复计);`shops_from_list.unique_sku_basis` 为按**去重 SKU** 的对照口径。二者均**不是**销量、库存或全渠道市场份额",
],
}
return _sanitize_json_numbers(out)

View File

@ -99,6 +99,42 @@ def _search_list_proxies(rows: list[dict[str, str]]) -> dict[str, Any]:
}
def _shop_concentration_by_unique_sku(
rows: list[dict[str, str]],
) -> dict[str, Any] | None:
"""
列表导出专用 **去重 SKU** 归属店铺后计集中度每个 SKU 1 店铺名取该 SKU **首次出现** 的列表行
``_structure_names_for_pie_counter(_structure_shops(...))`` 按列表行计可形成对照
同一 SKU 在多页重复曝光时行占比会高于去重 SKU 占比**不是**销量库存或全渠道市场份额
"""
if not rows:
return None
sku_k = JD_SEARCH_CSV_HEADERS["sku_id"]
shop_k = JD_SEARCH_CSV_HEADERS["shop_name"]
sku_to_shop: dict[str, str] = {}
for r in rows:
sid = _cell(r, sku_k)
sh = _cell(r, shop_k, _LEGACY_SHOP_NAME_KEY)
if not sid or not sh:
continue
sid = sid.strip()
sh = sh.strip()
if not sid or not sh:
continue
if sid not in sku_to_shop:
sku_to_shop[sid] = sh
if not sku_to_shop:
return None
c1, c3, top, _ = _brand_cr(list(sku_to_shop.values()))
return {
"first_share": c1,
"top_three_combined_share": c3,
"top_label": top,
"n_unique_skus": len(sku_to_shop),
}
def _structure_shops(rows: list[dict[str, str]], *, list_export: bool) -> list[str]:
if list_export:
return [
@ -132,6 +168,7 @@ __all__ = [
"_brand_cr",
"_counter_mix_top_rows_with_remainder",
"_search_list_proxies",
"_shop_concentration_by_unique_sku",
"_structure_brands",
"_structure_names_for_pie_counter",
"_structure_shops",

View File

@ -167,6 +167,7 @@ def _lines_4_reading_shop(
top: str,
shop_rows_n: int,
n_structure: int,
unique_sku_basis: dict[str, Any] | None = None,
) -> list[str]:
if not shop_rows_n:
return []
@ -178,13 +179,26 @@ def _lines_4_reading_shop(
]
if cr1 is not None and (top or "").strip():
lines.append(
f"- 第一大店铺「{_md_cell(top.strip(), 40)}」约占 **{100 * cr1:.1f}%**"
"该指标刻画的是**列表可见度**而非销量,适合用于判断货架被哪些店铺占据"
f"- 第一大店铺「{_md_cell(top.strip(), 40)}」约占 **{100 * cr1:.1f}%****按列表行计**,同一 SKU 多行重复曝光会重复计)"
"该指标刻画的是**列表可见度**而非销量或全渠道市占"
)
if cr3 is not None:
lines.append(
f"- 前三店铺合计约 **{100 * cr3:.1f}%**;若集中度高,可考虑从店铺矩阵、旗舰店/专营店布局等角度拆解竞争。"
)
if isinstance(unique_sku_basis, dict) and unique_sku_basis.get("n_unique_skus"):
u1 = unique_sku_basis.get("first_share")
u3 = unique_sku_basis.get("top_three_combined_share")
utop = (unique_sku_basis.get("top_label") or "").strip()
nuk = unique_sku_basis.get("n_unique_skus")
if isinstance(u1, (int, float)) and utop:
lines.append(
f"- **按去重 SKU 计**(列表内共 **{nuk}** 个 SKU每个计 1 次;店铺取该 SKU 首次出现行):"
f"第一大店铺「{_md_cell(utop, 40)}」约占 **{100 * float(u1):.1f}%**。"
"若与上行「按行计」差异大,通常因同一 SKU 在多页重复出现;**勿将行占比误称为「SKU 款数占比」或「市场份额」**。"
)
if isinstance(u3, (int, float)):
lines.append(f"- 前三店铺合计(按去重 SKU约 **{100 * float(u3):.1f}%**。")
lines.append("")
return lines

View File

@ -21,6 +21,7 @@ REPORT_SYSTEM = """你是业务与产品读者顾问。输入 JSON 含 `keyword`
- **不要**输出完整报告目录或复述研究范围与方法长章
- **不要**撰写 Markdown 表格版竞品对比矩阵或罗列 SKU 明细**正文已含矩阵**此处只做分组级语义归纳
- **不要使用** CR1CR3 等集中度缩写作主表述集中度请用第一大店铺/品牌份额前三家合计份额输入中的英文字段名勿照抄进正文请写成中文业务用语
- **店铺集中度** `competitor_brief.concentration.shops_from_list` `unique_sku_basis`须区分 **按列表行计** **按去重 SKU ****禁止**列表行曝光占比写成SKU 款数占比自营 SKU 占比市场份额
**请输出**仅输出将置于 8.5 小节 下的正文不要自造### 8.5」标题行):
- **Markdown** **8001500 **

View File

@ -84,11 +84,24 @@ def markdown_summary_from_brief(brief: dict[str, Any]) -> str:
[
"## 店铺集中度(列表)",
"",
f"- **第一大店铺份额**{_pct(concentration_first_share(shops))}(第一店铺:{shops.get('top_label') or ''}",
f"- **前三店铺合计份额**{_pct(concentration_top_three_share(shops))}",
f"- **第一大店铺份额(按列表行)**{_pct(concentration_first_share(shops))}(第一店铺:{shops.get('top_label') or ''}",
f"- **前三店铺合计份额(按列表行)**{_pct(concentration_top_three_share(shops))}",
"",
]
)
usb = shops.get("unique_sku_basis")
if isinstance(usb, dict) and usb.get("n_unique_skus"):
u1 = usb.get("first_share")
u3 = usb.get("top_three_combined_share")
if u1 is not None:
lines.extend(
[
f"- **按去重 SKU 计**:共 **{_num(usb.get('n_unique_skus'))}** 个 SKU"
f"第一大店铺 **{_pct(u1)}**(「{usb.get('top_label') or ''}」)",
f"- **前三店铺合计(按去重 SKU**{_pct(u3)}",
"",
]
)
dbrand = conc.get("detail_brand_among_merged") or {}
if concentration_first_share(dbrand) is not None or dbrand.get("top_label"):
lines.extend(

View File

@ -11,6 +11,15 @@ function pctShare(x) {
return `${(n * 100).toFixed(1)}%`
}
/** 集中度块:新键 first_share / top_three_combined_share旧键 cr1 / cr3 */
function concShare(block, key) {
if (!block || typeof block !== 'object') return null
const v0 = key === 'first' ? block.first_share : block.top_three_combined_share
if (v0 != null && v0 !== '') return v0
const legacy = key === 'first' ? block.cr1 : block.cr3
return legacy != null && legacy !== '' ? legacy : null
}
function briefHumanSummary(j) {
const rows = []
if (!j || typeof j !== 'object') return rows
@ -31,46 +40,69 @@ function briefHumanSummary(j) {
if (conc && typeof conc === 'object') {
const shops = conc.shops_from_list
if (shops && typeof shops === 'object') {
if (shops.top_label && shops.cr1 != null) {
const cr1 = concShare(shops, 'first')
const cr3 = concShare(shops, 'top3')
if (shops.top_label && cr1 != null) {
rows.push({
label: '第一大店铺(占列表行比例)',
value: `${pctShare(shops.cr1)} · ${shops.top_label}`,
value: `${pctShare(cr1)} · ${shops.top_label}`,
})
}
if (shops.cr3 != null) {
if (cr3 != null) {
rows.push({
label: '前三大店铺合计(占列表行比例)',
value: pctShare(shops.cr3),
value: pctShare(cr3),
})
}
const usb = shops.unique_sku_basis
if (usb && typeof usb === 'object' && usb.n_unique_skus != null) {
const u1 = concShare(usb, 'first')
const u3 = concShare(usb, 'top3')
if (usb.top_label && u1 != null) {
rows.push({
label: '第一大店铺(占去重 SKU 比例)',
value: `${pctShare(u1)} · ${usb.top_label} · 共 ${usb.n_unique_skus} 个 SKU`,
})
}
if (u3 != null) {
rows.push({
label: '前三大店铺合计(占去重 SKU',
value: pctShare(u3),
})
}
}
}
const lb = conc.list_brand_field
if (lb && typeof lb === 'object') {
if (lb.top_label && lb.cr1 != null) {
const l1 = concShare(lb, 'first')
const l3 = concShare(lb, 'top3')
if (lb.top_label && l1 != null) {
rows.push({
label: '第一大品牌(列表侧,按行)',
value: `${pctShare(lb.cr1)} · ${lb.top_label}`,
value: `${pctShare(l1)} · ${lb.top_label}`,
})
}
if (lb.cr3 != null) {
if (l3 != null) {
rows.push({
label: '前三大品牌合计(列表侧)',
value: pctShare(lb.cr3),
value: pctShare(l3),
})
}
}
const db = conc.detail_brand_among_merged
if (db && typeof db === 'object') {
if (db.top_label && db.cr1 != null) {
const d1 = concShare(db, 'first')
const d3 = concShare(db, 'top3')
if (db.top_label && d1 != null) {
rows.push({
label: '第一大品牌(深入样本)',
value: `${pctShare(db.cr1)} · ${db.top_label}`,
value: `${pctShare(d1)} · ${db.top_label}`,
})
}
if (db.cr3 != null) {
if (d3 != null) {
rows.push({
label: '前三大品牌合计(深入样本)',
value: pctShare(db.cr3),
value: pctShare(d3),
})
}
}