mirror of
https://github.com/primedigitaltech/market-assistant.git
synced 2026-07-21 23:41:39 +08:00
feat(pipeline): 策略生成按矩阵分组收窄 brief 输入
新增 strategy_matrix_group / strategy_matrix_group_index 请求参数;收窄后价盘与集中度按分组内 SKU 重算;摘要 GET 与策略 POST 响应附带 matrix_groups。 Made-with: Cursor
This commit is contained in:
parent
911ad203db
commit
6143ad6180
304
backend/pipeline/reporting/brief_strategy_scope.py
Normal file
304
backend/pipeline/reporting/brief_strategy_scope.py
Normal file
@ -0,0 +1,304 @@
|
||||
"""按矩阵分组(细类)收窄 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",
|
||||
]
|
||||
@ -461,3 +461,15 @@ class StrategyDraftRequestSerializer(serializers.Serializer):
|
||||
default="rules",
|
||||
required=False,
|
||||
)
|
||||
strategy_matrix_group_index = serializers.IntegerField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
min_value=0,
|
||||
)
|
||||
strategy_matrix_group = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
default="",
|
||||
max_length=200,
|
||||
trim_whitespace=True,
|
||||
)
|
||||
|
||||
126
backend/pipeline/tests/test_brief_strategy_scope.py
Normal file
126
backend/pipeline/tests/test_brief_strategy_scope.py
Normal file
@ -0,0 +1,126 @@
|
||||
"""brief 按矩阵分组收窄。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from pipeline.reporting.brief_strategy_scope import (
|
||||
filter_brief_for_strategy_matrix_group,
|
||||
list_matrix_groups_for_api,
|
||||
resolve_strategy_matrix_group_index,
|
||||
)
|
||||
|
||||
|
||||
class BriefStrategyScopeTests(SimpleTestCase):
|
||||
def _sample_brief(self) -> dict:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"keyword": "低GI",
|
||||
"matrix_by_group": [
|
||||
{
|
||||
"group": "饮料",
|
||||
"sku_count": 1,
|
||||
"skus": [
|
||||
{
|
||||
"brand": "A",
|
||||
"shop": "S1",
|
||||
"category": "水饮 > 茶",
|
||||
"list_price_show": "10",
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"group": "饼干",
|
||||
"sku_count": 2,
|
||||
"skus": [
|
||||
{
|
||||
"brand": "B",
|
||||
"shop": "S2",
|
||||
"category": "休闲食品 > 饼干 > 粗粮饼干",
|
||||
"list_price_show": "20",
|
||||
},
|
||||
{
|
||||
"brand": "B",
|
||||
"shop": "S2",
|
||||
"category": "休闲食品 > 饼干 > 苏打饼干",
|
||||
"list_price_show": "22",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"consumer_feedback_by_matrix_group": [
|
||||
{
|
||||
"group": "饮料",
|
||||
"comment_rows": 5,
|
||||
"effective_comment_text_units": 5,
|
||||
"focus_keyword_hits": [{"word": "甜", "count": 2}],
|
||||
"scenarios_top": [
|
||||
{
|
||||
"scenario": "解渴",
|
||||
"count": 2,
|
||||
"share_of_text_units": 0.4,
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"group": "饼干",
|
||||
"comment_rows": 8,
|
||||
"effective_comment_text_units": 8,
|
||||
"focus_keyword_hits": [{"word": "脆", "count": 3}],
|
||||
"scenarios_top": [
|
||||
{
|
||||
"scenario": "早餐",
|
||||
"count": 4,
|
||||
"share_of_text_units": 0.5,
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
"usage_scenarios_by_matrix_group": [
|
||||
{"group": "饮料", "scenarios": []},
|
||||
{"group": "饼干", "scenarios": [{"scenario": "早餐", "count": 4}]},
|
||||
],
|
||||
"notes": ["原注"],
|
||||
}
|
||||
|
||||
def test_list_matrix_groups(self) -> None:
|
||||
b = self._sample_brief()
|
||||
m = list_matrix_groups_for_api(b)
|
||||
self.assertEqual(len(m), 2)
|
||||
self.assertEqual(m[1]["group"], "饼干")
|
||||
self.assertEqual(m[1]["index"], 1)
|
||||
|
||||
def test_resolve_by_label(self) -> None:
|
||||
b = self._sample_brief()
|
||||
idx, err = resolve_strategy_matrix_group_index(
|
||||
b, matrix_group_label="饼干"
|
||||
)
|
||||
self.assertIsNone(err)
|
||||
self.assertEqual(idx, 1)
|
||||
|
||||
def test_resolve_by_index(self) -> None:
|
||||
b = self._sample_brief()
|
||||
idx, err = resolve_strategy_matrix_group_index(
|
||||
b, matrix_group_index=1
|
||||
)
|
||||
self.assertIsNone(err)
|
||||
self.assertEqual(idx, 1)
|
||||
|
||||
def test_resolve_mismatch(self) -> None:
|
||||
b = self._sample_brief()
|
||||
idx, err = resolve_strategy_matrix_group_index(
|
||||
b, matrix_group_index=0, matrix_group_label="饼干"
|
||||
)
|
||||
self.assertIsNotNone(err)
|
||||
self.assertIsNone(idx)
|
||||
|
||||
def test_filter_keeps_only_group(self) -> None:
|
||||
b = self._sample_brief()
|
||||
out = filter_brief_for_strategy_matrix_group(b, matrix_group_index=1)
|
||||
self.assertEqual(len(out["matrix_by_group"]), 1)
|
||||
self.assertEqual(out["matrix_by_group"][0]["group"], "饼干")
|
||||
self.assertEqual(len(out["matrix_by_group"][0]["skus"]), 2)
|
||||
self.assertEqual(len(out["consumer_feedback_by_matrix_group"]), 1)
|
||||
self.assertEqual(out["comment_focus_keywords"][0]["word"], "脆")
|
||||
self.assertEqual(out["price_stats_source"], "strategy_scope_matrix_group_skus")
|
||||
self.assertIn("strategy_scope_applied", out)
|
||||
self.assertEqual(out["strategy_scope_applied"]["group"], "饼干")
|
||||
@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
from typing import Any
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
@ -21,6 +22,11 @@ from ..jd.runner import (
|
||||
from ..llm.generate import generate_strategy_draft_markdown_llm
|
||||
from ..models import JobStatus, PipelineJob
|
||||
from ..reporting.brief_pack import build_brief_pack_zip_bytes
|
||||
from ..reporting.brief_strategy_scope import (
|
||||
filter_brief_for_strategy_matrix_group,
|
||||
list_matrix_groups_for_api,
|
||||
resolve_strategy_matrix_group_index,
|
||||
)
|
||||
from ..reporting.md_document_export import markdown_to_docx_bytes, markdown_to_pdf_bytes
|
||||
from ..reporting.report_strategy_excerpt import load_report_strategy_excerpt
|
||||
from ..reporting.strategy_draft import build_strategy_draft_markdown
|
||||
@ -57,6 +63,9 @@ class JobCompetitorBriefView(APIView):
|
||||
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except ValueError as e:
|
||||
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if isinstance(data, dict):
|
||||
data = dict(data)
|
||||
data["matrix_groups"] = list_matrix_groups_for_api(data)
|
||||
return Response(data)
|
||||
|
||||
|
||||
@ -155,6 +164,26 @@ class JobStrategyDraftView(APIView):
|
||||
except ValueError as e:
|
||||
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
matrix_groups = list_matrix_groups_for_api(brief)
|
||||
sg_idx = vd.get("strategy_matrix_group_index")
|
||||
sg_lbl = (vd.get("strategy_matrix_group") or "").strip()
|
||||
scope_idx, scope_err = resolve_strategy_matrix_group_index(
|
||||
brief,
|
||||
matrix_group_index=sg_idx,
|
||||
matrix_group_label=sg_lbl or None,
|
||||
)
|
||||
if scope_err:
|
||||
return Response({"detail": scope_err}, status=status.HTTP_400_BAD_REQUEST)
|
||||
strategy_scope_applied: dict[str, Any] | None = None
|
||||
if scope_idx is not None:
|
||||
brief = filter_brief_for_strategy_matrix_group(
|
||||
brief, matrix_group_index=scope_idx
|
||||
)
|
||||
raw_sa = brief.get("strategy_scope_applied")
|
||||
strategy_scope_applied = (
|
||||
raw_sa if isinstance(raw_sa, dict) else None
|
||||
)
|
||||
|
||||
gen_at = timezone.now().isoformat()
|
||||
generator = (vd.get("generator") or "rules").strip()
|
||||
excerpt_src = "none"
|
||||
@ -204,6 +233,8 @@ class JobStrategyDraftView(APIView):
|
||||
"markdown": md,
|
||||
"report_strategy_excerpt_source": excerpt_src,
|
||||
"report_strategy_excerpt_chars": len(report_excerpt or ""),
|
||||
"matrix_groups": matrix_groups,
|
||||
"strategy_scope_applied": strategy_scope_applied,
|
||||
}
|
||||
return Response(body)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user