mirror of
https://github.com/primedigitaltech/market-assistant.git
synced 2026-07-22 08:01:34 +08:00
fix(report): shop/brand pie totals, matrix price charts, drop LLM §8.5 merge
Made-with: Cursor
This commit is contained in:
parent
2548ba1df5
commit
a1d0fa6686
@ -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,
|
||||
|
||||
@ -22,6 +22,8 @@ def merge_llm_supplement_with_rules_report(llm_md: str, rules_md: str) -> str:
|
||||
|
||||
大模型稿作为 **§8.5** 嵌入在 **第八章末、第九章策略** 之前,与 §8.2~8.4 等具体分析同卷连贯,
|
||||
**不再**插在篇首「## 一、」之前。
|
||||
|
||||
注:API「重新生成报告」已不再调用本函数,避免整篇 LLM 与矩阵/图表口径冲突;保留供脚本或将来显式开关复用。
|
||||
"""
|
||||
body = (rules_md or "").strip()
|
||||
sup = (llm_md or "").strip()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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_runner:run_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)
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user