fix(report): shop/brand pie totals, matrix price charts, drop LLM §8.5 merge

Made-with: Cursor
This commit is contained in:
hub-gif 2026-04-14 13:16:29 +08:00
parent 2548ba1df5
commit a1d0fa6686
4 changed files with 156 additions and 63 deletions

View File

@ -281,6 +281,27 @@ def _collect_prices(rows: list[dict[str, str]]) -> list[float]:
return out
def _label_count_dicts_top_n_plus_other(
labels: list[str], *, top_n: int, other_label: str
) -> list[dict[str, Any]]:
"""
Top-N 标签计数 + 一条其他汇总剩余行数使 ``count`` 之和等于非空标签行总数
§4 集中度表扇形图分母一致否则仅 ``most_common(N)`` 会丢掉长尾店铺/品牌行
"""
cleaned = [(x or "").strip() for x in labels if (x or "").strip()]
if not cleaned:
return []
cnt = Counter(cleaned)
total_rows = len(cleaned)
mc = cnt.most_common(top_n)
out: list[dict[str, Any]] = [{"label": k, "count": int(v)} for k, v in mc]
covered = sum(v for _, v in mc)
rest = total_rows - covered
if rest > 0:
out.append({"label": other_label, "count": int(rest)})
return out
_JD_LIST_PRICE_KEY = "标价(jdPrice,jdPriceText,realPrice)"
_COUPON_SHOW_PRICE_KEY = (
"券后到手价(couponPrice,subsidyPrice,finalPrice.estimatedPrice,priceShow)"
@ -2013,7 +2034,7 @@ def build_competitor_markdown(
_embed_chart(
run_dir,
"chart_brand_rows_pie.png",
"品牌列表曝光占比(扇形图Top 段合并为「其他」;与上表集中度同源",
"品牌列表曝光占比(扇形图;与上表「含品牌字段行数」同源,长尾已并入「其他」尾桶",
)
)
lines.extend(
@ -2055,7 +2076,7 @@ def build_competitor_markdown(
_embed_chart(
run_dir,
"chart_shop_rows_pie.png",
"店铺列表曝光占比(扇形图;与上表同源)",
"店铺列表曝光占比(扇形图;与上表「含店铺名的行数」同源,长尾已并入「其他」尾桶",
)
)
lines.extend(
@ -2117,7 +2138,7 @@ def build_competitor_markdown(
if not grouped_matrix:
lines.append("*无合并表 SKU。*")
lines.append("")
for gname, grows in grouped_matrix:
for gi, (gname, grows) in enumerate(grouped_matrix):
lines.append(f"### {gname}**{len(grows)}** 款)")
lines.append("")
lines.extend(matrix_header)
@ -2129,6 +2150,15 @@ def build_competitor_markdown(
)
)
lines.append("")
slug_mx = _scenario_group_asset_slug(gname, gi)
lines.extend(
_embed_chart(
run_dir,
f"chart_matrix_prices_reviews__{slug_mx}.png",
f"{_md_cell(gname, 24)}」细类 · **展示价与评价量**(条形图;与上表同源:"
f"价取 detail_price_final→标价→券后 优先可解析数值;评价量为搜索侧「评价量」字段摘录)",
)
)
_mx_llm = (llm_matrix_section_md or "").strip()
if _mx_llm:
@ -2684,18 +2714,16 @@ def build_competitor_brief(
"category_mix_top": [
{"label": lbl, "count": cnt} for lbl, cnt in cm_structure
],
"list_brand_mix_top": [
{"label": k, "count": v}
for k, v in Counter(
b for b in brands_s if (b or "").strip()
).most_common(24)
],
"list_shop_mix_top": [
{"label": k, "count": v}
for k, v in Counter(
s for s in shops_s if (s or "").strip()
).most_common(24)
],
"list_brand_mix_top": _label_count_dicts_top_n_plus_other(
brands_s,
top_n=24,
other_label="其他Top24 以外品牌行数合计)",
),
"list_shop_mix_top": _label_count_dicts_top_n_plus_other(
shops_s,
top_n=24,
other_label="其他Top24 以外店铺行数合计)",
),
"price_stats": pst,
"price_stats_source": price_stats_source,
"price_stats_merged_sample": pst_merged,

View File

@ -22,6 +22,8 @@ def merge_llm_supplement_with_rules_report(llm_md: str, rules_md: str) -> str:
大模型稿作为 **§8.5** 嵌入在 **第八章末第九章策略** 之前 §8.28.4 等具体分析同卷连贯
**不再**插在篇首## 一、」之前。
API重新生成报告已不再调用本函数避免整篇 LLM 与矩阵/图表口径冲突保留供脚本或将来显式开关复用
"""
body = (rules_md or "").strip()
sup = (llm_md or "").strip()

View File

@ -2,6 +2,7 @@
from __future__ import annotations
import math
import os
import re
from pathlib import Path
@ -63,6 +64,36 @@ def _merge_labeled_counts_tail(
return head
def _float_price_from_cell(s: str) -> float | None:
t = (s or "").strip().replace(",", "").replace("", "")
if not t:
return None
m = re.search(r"(\d+(?:\.\d+)?)", t)
if not m:
return None
try:
v = float(m.group(1))
except ValueError:
return None
if 0 < v < 1_000_000:
return v
return None
def _reviews_volume_int(s: str) -> int:
"""与矩阵「评价量」列同源:从搜索侧模糊文案中抽取整数(含「万」)。"""
t = (s or "").strip().replace(",", "").replace("", "")
if not t:
return 0
m = re.search(r"(\d+(?:\.\d+)?)\s*万", t)
if m:
return int(round(float(m.group(1)) * 10_000))
m2 = re.search(r"(\d+)", t)
if m2:
return int(m2.group(1))
return 0
def _merge_tail_as_other(
labels: list[str], values: list[float], *, max_slices: int
) -> tuple[list[str], list[float]]:
@ -377,4 +408,78 @@ def generate_report_charts(run_dir: Path, brief: dict[str, Any]) -> list[str]:
"条数",
)
matrix_groups = brief.get("matrix_by_group") or []
if isinstance(matrix_groups, list):
for gi, block in enumerate(matrix_groups):
if not isinstance(block, dict):
continue
gname = str(block.get("group") or "").strip()
skus = block.get("skus") or []
if not isinstance(skus, list) or not skus:
continue
slug = scenario_group_asset_slug(gname, gi)
rows_data: list[tuple[str, float | None, int]] = []
for s in skus:
if not isinstance(s, dict):
continue
sku = str(s.get("sku_id") or "").strip()
title = str(s.get("title") or "").strip()
if sku:
label = sku if len(sku) <= 20 else sku[:18] + ""
else:
label = title if len(title) <= 16 else title[:14] + ""
if not label:
label = "?"
p: float | None = None
for k in (
"detail_price_final",
"list_price_show",
"coupon_or_detail_price",
):
p = _float_price_from_cell(str(s.get(k) or ""))
if p is not None:
break
rev = _reviews_volume_int(str(s.get("comment_fuzzy") or ""))
rows_data.append((label, p, rev))
rows_data.sort(key=lambda x: x[0])
if not rows_data:
continue
if not any(
(pr is not None and pr > 0) or rv > 0
for _, pr, rv in rows_data
):
continue
n = len(rows_data)
labels_mx = [x[0] for x in rows_data]
prices_mx = [x[1] for x in rows_data]
reviews_mx = [x[2] for x in rows_data]
y_pos = list(range(n))
fig_h = max(3.4, min(14.0, 0.38 * n + 2.4))
fig, (ax_l, ax_r) = plt.subplots(
1, 2, figsize=(10.6, fig_h), sharey=True
)
for yi, pr in enumerate(prices_mx):
if pr is not None and pr > 0 and math.isfinite(pr):
ax_l.barh(yi, pr, height=0.62, color="#2563eb")
ax_l.set_yticks(y_pos)
ax_l.set_yticklabels(labels_mx, fontsize=8)
ax_l.invert_yaxis()
ax_l.set_xlabel("展示价(元)", fontsize=9)
ax_l.set_title("展示价", fontsize=10, pad=8)
ax_r.barh(y_pos, reviews_mx, height=0.62, color="#059669")
ax_r.set_xlabel("评价量(搜索侧)", fontsize=9)
ax_r.set_title("评价量 / 声量", fontsize=10, pad=8)
ax_r.tick_params(axis="y", left=False, labelleft=False)
ttl = gname[:22] if gname else "细类"
fig.suptitle(
f"{ttl}」· 竞品矩阵:价格与评价量(与 §5 表同源)",
fontsize=11,
y=1.01,
)
fig.tight_layout()
out_mx = out_dir / f"chart_matrix_prices_reviews__{slug}.png"
fig.savefig(out_mx, dpi=130, bbox_inches="tight")
plt.close(fig)
created.append(out_mx.name)
return created

View File

@ -36,14 +36,9 @@ from .ingest import ingest_job_full
from .jd_runner import (
build_competitor_brief_for_job,
get_default_report_config,
merge_llm_supplement_with_rules_report,
regenerate_competitor_report,
write_competitor_analysis_markdown,
)
from .llm_generate import (
generate_competitor_report_markdown_llm,
generate_strategy_draft_markdown_llm,
)
from .llm_generate import generate_strategy_draft_markdown_llm
from .md_document_export import markdown_to_docx_bytes, markdown_to_pdf_bytes
from .models import (
JdJobCommentRow,
@ -344,48 +339,11 @@ class JobRegenerateReportView(APIView):
except ValueError as e:
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
if generator == "llm":
try:
rules_md = (
Path(job.run_dir) / "competitor_analysis.md"
).read_text(encoding="utf-8")
brief = build_competitor_brief_for_job(
job.run_dir, job.keyword, report_config=rc
)
md = generate_competitor_report_markdown_llm(brief, job.keyword)
md = merge_llm_supplement_with_rules_report(md, rules_md)
write_competitor_analysis_markdown(job.run_dir, md)
except FileNotFoundError as e:
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
except ValueError as e:
msg = str(e)
logger.warning(
"regenerate-report LLM ValueError job_id=%s: %s", pk, msg
)
# AI_crawler缺密钥/网关、提示词过长jd_runnerrun_dir 越界等
if "run_dir 不在京东数据目录下" in msg:
return Response(
{"detail": msg},
status=status.HTTP_400_BAD_REQUEST,
)
if "请设置环境变量" in msg:
return Response(
{"detail": msg + "(运行 Django 的终端需能读取到该环境变量)"},
status=status.HTTP_400_BAD_REQUEST,
)
if "提示词过长" in msg or "上下文上限" in msg:
return Response(
{"detail": msg},
status=status.HTTP_400_BAD_REQUEST,
)
return Response(
{"detail": msg},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
except requests.RequestException as e:
return Response(
{"detail": f"大模型网关错误:{e}"},
status=status.HTTP_502_BAD_GATEWAY,
)
logger.info(
"regenerate-report job_id=%s: generator=llm 已忽略;"
"规则正文(矩阵与统计图)为唯一输出,不再并入整篇大模型 §8.5。",
pk,
)
return Response(PipelineJobSerializer(job).data)