diff --git a/backend/crawler_copy/jd_pc_search/jd_competitor_report.py b/backend/crawler_copy/jd_pc_search/jd_competitor_report.py index f3523b5..8ba6c9a 100644 --- a/backend/crawler_copy/jd_pc_search/jd_competitor_report.py +++ b/backend/crawler_copy/jd_pc_search/jd_competitor_report.py @@ -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, diff --git a/backend/pipeline/jd_runner.py b/backend/pipeline/jd_runner.py index aebe96a..0307c92 100644 --- a/backend/pipeline/jd_runner.py +++ b/backend/pipeline/jd_runner.py @@ -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() diff --git a/backend/pipeline/report_charts.py b/backend/pipeline/report_charts.py index 3d47a01..c46d9b5 100644 --- a/backend/pipeline/report_charts.py +++ b/backend/pipeline/report_charts.py @@ -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 diff --git a/backend/pipeline/views.py b/backend/pipeline/views.py index 06b2154..d610d49 100644 --- a/backend/pipeline/views.py +++ b/backend/pipeline/views.py @@ -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)