market-assistant/backend/pipeline/reporting/brief_strategy_scope.py
hub-gif 6143ad6180 feat(pipeline): 策略生成按矩阵分组收窄 brief 输入
新增 strategy_matrix_group / strategy_matrix_group_index 请求参数;收窄后价盘与集中度按分组内 SKU 重算;摘要 GET 与策略 POST 响应附带 matrix_groups。

Made-with: Cursor
2026-04-20 17:45:15 +08:00

305 lines
9.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""按矩阵分组(细类)收窄 competitor brief使策略生成输入仅含所选分组数据。"""
from __future__ import annotations
import copy
from collections import Counter
from typing import Any
from pipeline.competitor_report.csv_io import _collect_prices
from pipeline.competitor_report.price_stats import _price_stats_extended
def list_matrix_groups_for_api(brief: dict[str, Any]) -> list[dict[str, Any]]:
"""供前端下拉:矩阵分组名称与索引(与 ``matrix_by_group`` 顺序一致)。"""
mg = brief.get("matrix_by_group")
if not isinstance(mg, list):
return []
out: list[dict[str, Any]] = []
for i, g in enumerate(mg):
if not isinstance(g, dict):
continue
name = (g.get("group") or "").strip() or ""
skus = g.get("skus") if isinstance(g.get("skus"), list) else []
out.append(
{
"index": i,
"group": name,
"sku_count": int(g.get("sku_count") or len(skus)),
}
)
return out
def resolve_strategy_matrix_group_index(
brief: dict[str, Any],
*,
matrix_group_index: int | None = None,
matrix_group_label: str | None = None,
) -> tuple[int | None, str | None]:
"""
解析请求中的矩阵分组。
返回 ``(index, error)``
- ``index is None`` 且 ``error is None``:未指定收窄(使用完整 brief
- ``index`` 为 ``int``:有效分组下标;
- ``error`` 非空:参数与 brief 不一致。
"""
mg = brief.get("matrix_by_group")
if not isinstance(mg, list) or not mg:
if matrix_group_index is not None or (
matrix_group_label and str(matrix_group_label).strip()
):
return None, "当前任务 brief 中无 matrix_by_group无法按细类收窄"
return None, None
label = (matrix_group_label or "").strip()
has_idx = matrix_group_index is not None
has_lbl = bool(label)
if not has_idx and not has_lbl:
return None, None
if has_idx:
idx = int(matrix_group_index)
if idx < 0 or idx >= len(mg):
return None, f"strategy_matrix_group_index 须在 0{len(mg) - 1} 之间"
if has_lbl and not has_idx:
for i, g in enumerate(mg):
if not isinstance(g, dict):
continue
if (g.get("group") or "").strip() == label:
return i, None
return None, f"未找到矩阵分组「{label}"
assert has_idx
idx = int(matrix_group_index)
g0 = mg[idx]
gname = (g0.get("group") or "").strip() if isinstance(g0, dict) else ""
if has_lbl and gname != label:
return None, (
f"strategy_matrix_group_index={idx} 对应分组「{gname}」,"
f"与 strategy_matrix_group「{label}」不一致"
)
return idx, None
def _sku_rows_for_prices(skus: list[dict[str, Any]]) -> list[dict[str, str]]:
out: list[dict[str, str]] = []
for s in skus:
if not isinstance(s, dict):
continue
out.append({k: str(v) if v is not None else "" for k, v in s.items()})
return out
def _mix_top_rows(
skus: list[dict[str, Any]], key: str, *, top_n: int = 24
) -> list[dict[str, Any]]:
c: Counter[str] = Counter()
for s in skus:
if not isinstance(s, dict):
continue
v = (s.get(key) or "").strip() or "(未标注)"
c[v] += 1
rows: list[dict[str, Any]] = []
for lab, cnt in c.most_common(top_n):
rows.append({"label": lab, "count": cnt})
remainder = sum(cnt for _, cnt in c.most_common()[top_n:])
if remainder > 0:
rows.append({"label": "(其余)", "count": remainder})
return rows
def _category_mix_from_skus(skus: list[dict[str, Any]]) -> list[dict[str, Any]]:
c: Counter[str] = Counter()
for s in skus:
if not isinstance(s, dict):
continue
cat = (s.get("category") or "").strip()
if not cat:
continue
parts = [p.strip() for p in cat.split(">") if p.strip()]
leaf = parts[-1] if parts else cat
c[leaf] += 1
return [{"label": k, "count": v} for k, v in c.most_common(24)]
def _concentration_brand_shop_from_skus(skus: list[dict[str, Any]]) -> dict[str, Any]:
"""用矩阵内 SKU 粗算集中度(与全站列表口径不同,仅供分组内对照)。"""
brands = [
(s.get("brand") or "").strip()
for s in skus
if isinstance(s, dict) and (s.get("brand") or "").strip()
]
shops = [
(s.get("shop") or "").strip()
for s in skus
if isinstance(s, dict) and (s.get("shop") or "").strip()
]
def _block(labels: list[str]) -> dict[str, Any]:
if not labels:
return {
"first_share": 0.0,
"top_three_combined_share": 0.0,
"top_label": "",
"top_share_pct": "0%",
}
ct = Counter(labels)
n = len(labels)
top_lab, top_n = ct.most_common(1)[0]
top3 = sum(x for _, x in ct.most_common(3))
return {
"first_share": top_n / n,
"top_three_combined_share": top3 / n,
"top_label": top_lab,
"top_share_pct": f"{100.0 * top_n / n:.1f}%",
}
return {
"shops_from_list": _block(shops),
"list_brand_field": None,
"detail_brand_among_merged": _block(brands),
}
def filter_brief_for_strategy_matrix_group(
brief: dict[str, Any],
*,
matrix_group_index: int,
) -> dict[str, Any]:
"""
深拷贝 ``brief``,仅保留 ``matrix_by_group[matrix_group_index]`` 及其对齐的
``consumer_feedback_by_matrix_group`` / ``usage_scenarios_by_matrix_group``
并重算与样本相关的价盘、类目与集中度(避免仍混入全关键词列表口径)。
"""
b = copy.deepcopy(brief)
mg = b.get("matrix_by_group")
if not isinstance(mg, list) or matrix_group_index < 0 or matrix_group_index >= len(
mg
):
return b
chosen = mg[matrix_group_index]
if not isinstance(chosen, dict):
return b
gname = (chosen.get("group") or "").strip() or ""
skus = chosen.get("skus") if isinstance(chosen.get("skus"), list) else []
n_skus = len(skus)
b["matrix_by_group"] = [
{
"group": gname,
"sku_count": int(chosen.get("sku_count") or n_skus),
"skus": skus,
}
]
def _pick_by_group(
items: Any,
) -> list[dict[str, Any]]:
if not isinstance(items, list):
return []
out: list[dict[str, Any]] = []
for it in items:
if not isinstance(it, dict):
continue
if (it.get("group") or "").strip() == gname:
out.append(dict(it))
break
gi = it.get("matrix_group_index")
if gi is not None and int(gi) == matrix_group_index:
out.append(dict(it))
break
return out
fb = b.get("consumer_feedback_by_matrix_group")
fb_one = _pick_by_group(fb)
b["consumer_feedback_by_matrix_group"] = fb_one
ub = b.get("usage_scenarios_by_matrix_group")
b["usage_scenarios_by_matrix_group"] = _pick_by_group(ub)
if fb_one:
f0 = fb_one[0]
b["comment_focus_keywords"] = list(f0.get("focus_keyword_hits") or [])
scenarios_top = f0.get("scenarios_top") or []
b["usage_scenarios"] = list(scenarios_top)
denom = int(f0.get("effective_comment_text_units") or 0)
if denom <= 0:
denom = int(f0.get("comment_rows") or 0)
b["usage_scenarios_denominator"] = denom
else:
b["comment_focus_keywords"] = []
b["usage_scenarios"] = []
b["usage_scenarios_denominator"] = 0
rows_for_price = _sku_rows_for_prices(
[s for s in skus if isinstance(s, dict)]
)
prices = _collect_prices(rows_for_price)
pst_merged = _price_stats_extended(prices)
b["price_stats_merged_sample"] = pst_merged
b["price_stats"] = dict(pst_merged) if pst_merged else {}
b["price_stats_source"] = "strategy_scope_matrix_group_skus"
b["price_stats_list_export"] = {}
b["category_mix_top"] = _category_mix_from_skus(
[s for s in skus if isinstance(s, dict)]
)
b["list_brand_mix_top"] = _mix_top_rows(
[s for s in skus if isinstance(s, dict)], "brand"
)
b["list_shop_mix_top"] = _mix_top_rows(
[s for s in skus if isinstance(s, dict)], "shop"
)
b["concentration"] = _concentration_brand_shop_from_skus(
[s for s in skus if isinstance(s, dict)]
)
sc = b.get("scope")
if isinstance(sc, dict):
sc2 = dict(sc)
sc2["merged_sku_count"] = n_skus
if fb_one:
sc2["comment_flat_rows"] = int(fb_one[0].get("comment_rows") or 0)
b["scope"] = sc2
b["strategy_scope_applied"] = {
"matrix_group_index": matrix_group_index,
"group": gname,
"original_matrix_group_count": len(mg),
}
notes = b.get("notes")
extra = (
"策略生成已按矩阵分组收窄:下文统计与矩阵仅针对「"
+ gname
+ "」内 SKU与全关键词搜索列表、全样本评价总量不同口径。"
)
if isinstance(notes, list):
b["notes"] = [extra] + [n for n in notes if isinstance(n, str)]
else:
b["notes"] = [extra]
b["price_promotion_signals"] = []
b["strategy_hints"] = []
b["list_visibility_proxy"] = {
"total_rows": n_skus,
"unique_skus": n_skus,
"_strategy_scope_note": "矩阵所选分组内 SKU 数,非全关键词列表导出口径。",
}
return b
__all__ = [
"filter_brief_for_strategy_matrix_group",
"list_matrix_groups_for_api",
"resolve_strategy_matrix_group_index",
]