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 0d9b8fb..c01f200 100644 --- a/backend/crawler_copy/jd_pc_search/jd_competitor_report.py +++ b/backend/crawler_copy/jd_pc_search/jd_competitor_report.py @@ -877,7 +877,7 @@ def build_scenario_groups_llm_payload( sku_header: str, title_h: str, ) -> dict[str, Any]: - """供 ``generate_scenario_group_summaries_llm``;计数与 §8.4 场景条形图一致。""" + """供 ``generate_scenario_group_summaries_llm``;计数与 §8.3 图右栏(场景)一致。""" if not feedback_groups: return {} sku_meta: dict[str, tuple[str, str, str]] = {} @@ -1658,15 +1658,10 @@ def _scenario_group_asset_slug(group: str, index: int) -> str: return f"i{index:02d}_{core}" -def _scenario_group_usage_bar_filename(group: str, index: int) -> str: - """场景/用途按细类条形图(横轴为占有效文本比例 %,与报告表格口径一致)。""" +def _focus_scenario_combo_bar_filename(group: str, index: int) -> str: + """关注词 + 使用场景并排条形图(与 ``report_charts.save_combo_focus_scenario_bar`` 同源)。""" slug = _scenario_group_asset_slug(group, index) - return f"chart_usage_scenarios_bar__{slug}.png" - - -def _focus_keywords_group_bar_filename(group: str, index: int) -> str: - slug = _scenario_group_asset_slug(group, index) - return f"chart_focus_keywords_bar__{slug}.png" + return f"chart_focus_and_scenarios_bar__{slug}.png" def _matrix_prices_reviews_chart_filename(group: str, index: int) -> str: @@ -1900,7 +1895,7 @@ def build_competitor_markdown( "- **品牌/店铺集中度(第四章)**:有列表全量时按列表行计店铺与品牌占比;无列表导出时按深入 SKU 合并表估算。", "- **评价主题词**:对评价正文做**预设词表子串计数**,非分词主题模型,适合扫方向,**需抽样人工验证**。", "- **用途/场景**:对每条评价独立判断是否命中预设场景词;一条可计入多个场景,统计的是「提及该场景的评价条数」而非用户数。", - "- **用户画像(第八章)**:正负面粗判含**口语短语**级摘录;关注词与场景**仅按细类**以条形图展示(场景图为**占该细类有效文本比例 %**);见 §8.3~8.4。", + "- **用户画像(第八章)**:正负面粗判含**口语短语**级摘录;关注词与场景**仅按细类**以**同图左右并列**展示(左为关注词命中次数,右为场景占有效文本 **%**);见 §8.3。", "- **细类划分(§5~§8)**:**仅**依据合并表 ``detail_category_path``;该列为空或无法解析出可读细类段的 SKU **不参与**竞品矩阵与按细类评价统计(相关评价条亦**不进入**按细类图表)。", "- **检索结果规模**:来自京东 PC 搜索返回的「结果条数」类指标,表示平台侧申报的匹配数量级,**不等于**动销、库存或独立 SKU 数。", "", @@ -1985,7 +1980,7 @@ def build_competitor_markdown( ) if multi_feedback_cat and (hits or scen_n_texts > 0): exec_bullets.append( - "评价侧写(关注词、用途/场景)已按 **§5 同款细类** 分节,见 **§8.3~8.4**。" + "评价侧写(关注词、用途/场景)已按 **§5 同款细类** 分节,见 **§8.3**(同图并列)。" ) elif hits: top3 = "、".join(f"「{w}」({n})" for w, n in hits.most_common(3)) @@ -2351,8 +2346,7 @@ def build_competitor_markdown( "- **细类划分**:与 **§5 竞品矩阵** 相同,**仅**依据 ``detail_category_path`` 解析为「饼干 / 西式糕点 / …」等(规则见 §5 章首说明)。", "- **归因**:每条评价按其 SKU 对应到深入样本,再映射到该 SKU 所属细类;SKU 不在合并表中的评价单独归入说明性分组;**在合并表中但该 SKU 缺 ``detail_category_path`` 或路径无法解析为可读细类的,该评价不进入按细类统计**(与 §5 排除口径一致)。", "- **正负面粗判(§8.2)**:先以关键词规则与图表做粗分;若任务开启 **llm_comment_sentiment**,可附**大模型对抽样原文的主题归因**(尤其负向「用户在抱怨什么」),与词频条形图互补。", - "- **关注词按细类(§8.3)**:对组内评价正文做子串计数并出条形图;若无逐条正文则用该细类下评价摘要列拼接兜底;与配置关注词及联想扩展同源。", - "- **用途/场景按细类(§8.4)**:对组内每条有效文本独立扫描**本次任务生效的场景词组**(来自报告调参或系统默认),一条可属多场景;条形图横轴为**占该细类有效文本比例 %**(多标签下各比例可相加大于 100%)。", + "- **关注词与使用场景(§8.3)**:对组内评价正文做关注词子串计数(左栏条形图);对每条有效文本独立扫描**本次任务生效的场景词组**(来自报告调参或系统默认),一条可属多场景,右栏为**占该细类有效文本比例 %**(多标签下可相加 **>** 100%)。二者在 **同一张图左右并列**,与 §5 矩阵细类一一对应。", "", "### 8.2 评价正负面粗判(关键词规则)", "", @@ -2420,7 +2414,12 @@ def build_competitor_markdown( lines.append("") lines.extend( [ - "### 8.3 关注词频次(按细类)", + "### 8.3 关注词与使用场景(按细类)", + "", + "每细类一张**左右并列图**(与 ``report_assets/chart_focus_and_scenarios_bar__*.png`` 同源):" + "**左**为配置关注词子串命中次数(同一评价可出现多次,为次数而非去重条数);" + "**右**为预设场景词组命中占该细类有效文本比例 %(一条可属多场景;多柱比例可相加 **>** 100%)。" + "统计均基于评价正文(或兜底预览)子串规则,**不等于**购买动机调研结论。", "", ] ) @@ -2437,54 +2436,33 @@ def build_competitor_markdown( ) lines.append("") hits_g = _group_keyword_hits(cr_g, texts_g, focus_words=focus_words) - if hits_g: - lines.extend( - _embed_chart( - run_dir, - _focus_keywords_group_bar_filename(gname, gi), - f"「{_md_cell(gname, 24)}」细类 · 关注词子串命中次数(条形图;同一评价可出现多次,为次数而非去重条数)", - ) - ) - else: - lines.append("*该细类无命中或无数文本。*") - lines.append("") - - lines.extend( - [ - "### 8.4 用途与使用场景(按细类)", - "", - "每条评价文本(或兜底预览)独立扫描场景词组;若命中某组内**任一**关键词则该组 +1,同一条可计入多组;" - "**不等于**购买动机调研结论,建议结合原句抽样阅读。", - "", - ] - ) - if not feedback_groups: - lines.append("*无评价数据可归组。*") - lines.append("") - else: - for gi, (gname, cr_g, texts_g) in enumerate(feedback_groups): scen_g, scen_ng = _comment_scenario_counts(texts_g, scenario_groups) - lines.append(f"#### {gname}") - lines.append("") + has_focus = any(n > 0 for n in hits_g.values()) if hits_g else False + has_scen = scen_ng > 0 and any(n > 0 for n in scen_g.values()) if scen_ng <= 0: lines.append("*该细类下无可用评价正文。*") lines.append("") continue - lines.append(f"- **有效评价文本条数**:{scen_ng}") - lines.append("") - if scen_g: + if has_focus or has_scen: + cap = ( + f"「{_md_cell(gname, 24)}」细类 · 关注词与使用场景(左:关注词命中次数;右:场景占有效文本 %;" + f"有效文本 **{scen_ng}** 条)" + ) lines.extend( _embed_chart( run_dir, - _scenario_group_usage_bar_filename(gname, gi), - f"「{_md_cell(gname, 24)}」细类 · 场景/用途(条形图:**横轴 = 提及条数 ÷ 本细类有效文本条数**," - f"柱尾标注「条数 · 占比」;有效文本 **{scen_ng}** 条)", + _focus_scenario_combo_bar_filename(gname, gi), + cap, ) ) + else: + lines.append("*该细类无关注词命中且未命中预设场景词组。*") + lines.append("") + if has_scen: for para in _scenario_summary_bullets(scen_g, scen_ng): lines.append(para) lines.append("") - else: + elif scen_ng > 0: lines.append("*未命中预设场景词组。*") lines.append("") @@ -2493,9 +2471,9 @@ def build_competitor_markdown( lines.extend( [ "", - "#### 使用场景要点归纳(大模型,与 §8.4 图表互补)", + "#### 使用场景要点归纳(大模型,与 §8.3 右栏图表互补)", "", - "> **说明**:与 §8.4 **相同**的预设场景词组与子串命中规则;**各场景条数与占比以正文条形图为准**。", + "> **说明**:与 §8.3 **相同**的预设场景词组与子串命中规则;**各场景条数与占比以正文图右栏为准**。", "", _llm_sg, "", @@ -2507,9 +2485,9 @@ def build_competitor_markdown( lines.extend( [ "", - "#### 细类评价与关注词要点归纳(大模型,与 §8.3 图表互补)", + "#### 细类评价与关注词要点归纳(大模型,与 §8.3 左栏图表互补)", "", - "> **说明**:归纳各细类反馈主题与配置关注词命中;**次数与 §8.3 条形图以正文为准**。", + "> **说明**:归纳各细类反馈主题与配置关注词命中;**次数与 §8.3 图左栏以正文为准**。", "", _llm_cg, "", diff --git a/backend/pipeline/jd_runner.py b/backend/pipeline/jd_runner.py index 5c30f96..3318345 100644 --- a/backend/pipeline/jd_runner.py +++ b/backend/pipeline/jd_runner.py @@ -19,7 +19,7 @@ def merge_llm_supplement_with_rules_report(llm_md: str, rules_md: str) -> str: """ **以规则引擎全文为正文**(含 §5 完整竞品矩阵、各章内嵌统计图与表格)。 - 大模型稿作为 **§8.5** 嵌入在 **第八章末、第九章策略** 之前,与 §8.2~8.4 等具体分析同卷连贯, + 大模型稿作为 **§8.5** 嵌入在 **第八章末、第九章策略** 之前,与 §8.2~8.3 等具体分析同卷连贯, **不再**插在篇首「## 一、」之前。 注:API「重新生成报告」已不再调用本函数,避免整篇 LLM 与矩阵/图表口径冲突;保留供脚本或将来显式开关复用。 @@ -33,8 +33,8 @@ def merge_llm_supplement_with_rules_report(llm_md: str, rules_md: str) -> str: marker = "\n---\n\n## 九、策略与机会提示(假设清单,待验证)" insert = ( "\n\n---\n\n" - "### 8.5 大模型深度补充(与 §2~§8.4 定量内容互补)\n\n" - "> **说明**:本段位于**第八章末**;**竞品矩阵、价盘表、统计图与 §8.2~8.4 词频等以正文各节为准**," + "### 8.5 大模型深度补充(与 §2~§8.3 定量内容互补)\n\n" + "> **说明**:本段位于**第八章末**;**竞品矩阵、价盘表、统计图与 §8.2~8.3 等以正文各节为准**," "此处为跨小节语义整合,便于衔接第九章。\n\n" f"{sup.strip()}\n" "\n---\n\n## 九、策略与机会提示(假设清单,待验证)" @@ -49,7 +49,7 @@ def merge_llm_supplement_with_rules_report(llm_md: str, rules_md: str) -> str: if alt in body and marker not in body: return body.replace( alt, - "\n\n---\n\n### 8.5 大模型深度补充(与 §2~§8.4 定量内容互补)\n\n" + "\n\n---\n\n### 8.5 大模型深度补充(与 §2~§8.3 定量内容互补)\n\n" "> **说明**:位于第八章末;**矩阵与图表以正文为准**。\n\n" f"{sup.strip()}\n" + alt, @@ -58,7 +58,7 @@ def merge_llm_supplement_with_rules_report(llm_md: str, rules_md: str) -> str: app = "\n## 附录 A:数据留存说明" if app in body: tail = ( - "\n\n---\n\n### 8.5 大模型深度补充(与 §2~§8.4 定量内容互补)\n\n" + "\n\n---\n\n### 8.5 大模型深度补充(与 §2~§8.3 定量内容互补)\n\n" f"{sup.strip()}\n" ) return body.replace(app, tail + app, 1) diff --git a/backend/pipeline/llm_generate.py b/backend/pipeline/llm_generate.py index 8c2392b..de96702 100644 --- a/backend/pipeline/llm_generate.py +++ b/backend/pipeline/llm_generate.py @@ -41,7 +41,7 @@ REPORT_SYSTEM = """你是业务与产品读者顾问。输入 JSON 含 `keyword` `matrix_overview_for_llm`(按细分类目的 SKU 数与品牌样本)。 你的输出将**嵌入在规则报告第八章末**(作为「### 8.5 …」的正文,系统已加小节标题与说明),**紧接在** -消费者反馈 §8.1~8.4 **之后**、第九章策略**之前**。因此写的是**具体分析型补充**,不是篇首速读块。 +消费者反馈 §8.1~8.3 **之后**、第九章策略**之前**。因此写的是**具体分析型补充**,不是篇首速读块。 所有数字、占比、条数、品牌名、价格区间等**必须严格来自输入 JSON**,禁止编造未在输入中出现的定量结论。 @@ -463,9 +463,9 @@ def generate_comment_group_summaries_llm( SCENARIO_GROUPS_SYSTEM = """你是用户研究与品类顾问。输入为 JSON:``keyword``、``scenario_lexicon``、``groups``。 -``scenario_lexicon`` 列出各场景标签及示例触发子串(与报告 **§8.4** 统计规则一致)。 +``scenario_lexicon`` 列出各场景标签及示例触发子串(与报告 **§8.3** 右栏统计规则一致)。 ``groups`` 每项含 ``group``(与 §5 矩阵一致的细分类目名)、``effective_text_count``(有效评价文本条数)、 -``scenario_distribution``(各预设场景的 ``mention_rows`` 与 ``share_of_effective_texts``;**一条评价可计入多场景**;与 §8.4 条形图同源)、 +``scenario_distribution``(各预设场景的 ``mention_rows`` 与 ``share_of_effective_texts``;**一条评价可计入多场景**;与 §8.3 图右栏同源)、 ``sample_text_snippets``(摘录行常含细类、SKU、品名、店铺等前缀的短引文,已截断)。 统计为**子串命中**,不是语义主题模型。 @@ -479,7 +479,7 @@ SCENARIO_GROUPS_SYSTEM = """你是用户研究与品类顾问。输入为 JSON SCENARIO_GROUPS_USER_PREFIX = ( - "请根据以下 JSON 撰写竞品报告 §8.4 用途与使用场景之后的「使用场景要点归纳」正文(Markdown)。\n\n" + "请根据以下 JSON 撰写竞品报告 §8.3(右栏:使用场景)之后的「使用场景要点归纳」正文(Markdown)。\n\n" ) diff --git a/backend/pipeline/report_charts.py b/backend/pipeline/report_charts.py index c653584..03da04c 100644 --- a/backend/pipeline/report_charts.py +++ b/backend/pipeline/report_charts.py @@ -141,6 +141,15 @@ def _cleanup_obsolete_report_assets(out_dir: Path) -> None: fp.unlink() except OSError: pass + for pat in ( + "chart_focus_keywords_bar__*.png", + "chart_usage_scenarios_bar__*.png", + ): + for fp in out_dir.glob(pat): + try: + fp.unlink() + except OSError: + pass def generate_report_charts(run_dir: Path, brief: dict[str, Any]) -> list[str]: @@ -218,6 +227,93 @@ def generate_report_charts(run_dir: Path, brief: dict[str, Any]) -> list[str]: plt.close(fig) created.append(fname) + def save_combo_focus_scenario_bar( + *, + gname: str, + slug: str, + wl: list[str], + vl: list[float], + gl: list[str], + gv: list[float], + n_texts: int, + ) -> None: + """左:关注词命中次数;右:场景占有效文本 %(与 §8.3 正文同源)。""" + has_l = bool(wl and vl and max(vl) > 0) + has_r = bool(gl and gv and n_texts > 0 and max(gv) > 0) + if not has_l and not has_r: + return + n_l = len(wl) if has_l else 0 + n_r = len(gl) if has_r else 0 + n_max = max(n_l, n_r, 1) + fig_h = max(3.4, min(14.0, 0.38 * n_max + 2.6)) + fig, (ax_l, ax_r) = plt.subplots(1, 2, figsize=(10.8, fig_h)) + ttl = (gname or "").strip()[:22] or "细类" + fig.suptitle( + f"「{ttl}」· 关注词与使用场景(与 §8.3 统计同源)", + fontsize=11, + y=1.02, + ) + if has_l: + y_pos = range(n_l) + ax_l.barh(list(y_pos), vl[:n_l], color="#2563eb", height=0.62) + ax_l.set_yticks(list(y_pos)) + ax_l.set_yticklabels(wl[:n_l], fontsize=8) + ax_l.invert_yaxis() + ax_l.set_xlabel("关注词子串命中次数", fontsize=9) + ax_l.set_title("关注词", fontsize=10, pad=8) + else: + ax_l.text( + 0.5, + 0.5, + "本细类无关注词命中\n或无数文本", + ha="center", + va="center", + transform=ax_l.transAxes, + fontsize=10, + color="#64748b", + ) + ax_l.set_axis_off() + if has_r: + pcts = [100.0 * c / n_texts for c in gv[: len(gl)]] + n_b = len(gl) + y_pos = range(n_b) + bars = ax_r.barh(list(y_pos), pcts, color="#059669", height=0.62) + ax_r.set_yticks(list(y_pos)) + ax_r.set_yticklabels(gl[:n_b], fontsize=8) + ax_r.invert_yaxis() + ax_r.set_xlabel("占有效评价文本比例(%)", fontsize=9) + if pcts: + xmax = max(pcts) * 1.12 + 4.0 + ax_r.set_xlim(0, max(xmax, max(pcts) + 10.0, 24.0)) + else: + ax_r.set_xlim(0, 24.0) + for bar, c, p in zip(bars, gv[:n_b], pcts): + ax_r.text( + min(bar.get_width() + 0.6, ax_r.get_xlim()[1] * 0.97), + bar.get_y() + bar.get_height() / 2, + f"{int(c)}条 · {p:.1f}%", + va="center", + fontsize=8, + ) + ax_r.set_title("使用场景 / 用途", fontsize=10, pad=8) + else: + ax_r.text( + 0.5, + 0.5, + "本细类无场景词命中\n或无数文本", + ha="center", + va="center", + transform=ax_r.transAxes, + fontsize=10, + color="#64748b", + ) + ax_r.set_axis_off() + fig.tight_layout() + path = out_dir / f"chart_focus_and_scenarios_bar__{slug}.png" + fig.savefig(path, dpi=130, bbox_inches="tight") + plt.close(fig) + created.append(path.name) + def save_pie( labels: list[str], values: list[float], @@ -298,6 +394,7 @@ def generate_report_charts(run_dir: Path, brief: dict[str, Any]) -> list[str]: core = "group" return f"i{index:02d}_{core}" + scen_by_slug: dict[str, tuple[list[str], list[float], int]] = {} by_grp = brief.get("usage_scenarios_by_matrix_group") or [] if isinstance(by_grp, list): for item in by_grp: @@ -323,15 +420,10 @@ def generate_report_charts(run_dir: Path, brief: dict[str, Any]) -> list[str]: gpairs.append((lb, float(c))) gpairs = _merge_labeled_counts_tail(gpairs, max_items=14) if gpairs and n_unit > 0: - gl = [p[0] for p in gpairs] - gv = [p[1] for p in gpairs] - title_base = f"「{gname}」· 场景/用途" if gname else "细类 · 场景/用途" - save_bar_h_share_of_text( - gl, - gv, + scen_by_slug[slug] = ( + [p[0] for p in gpairs], + [p[1] for p in gpairs], n_unit, - f"{title_base}(占有效评价文本比例)", - f"chart_usage_scenarios_bar__{slug}.png", ) fb = brief.get("consumer_feedback_by_matrix_group") or [] @@ -360,10 +452,18 @@ def generate_report_charts(run_dir: Path, brief: dict[str, Any]) -> list[str]: vl.append(float(c)) wl = wl[:18] vl = vl[:18] - if not wl: - continue - tkw = f"「{gname}」· 关注词命中次数" if gname else "细类 · 关注词命中次数" - save_bar_h(wl, vl, tkw, f"chart_focus_keywords_bar__{slug}.png", "命中次数") + gl, gv, n_scen = scen_by_slug.get(slug, ([], [], 0)) + n_unit_fb = int(item.get("effective_comment_text_units") or 0) + n_texts = n_scen if n_scen > 0 else n_unit_fb + save_combo_focus_scenario_bar( + gname=gname, + slug=slug, + wl=wl, + vl=vl, + gl=gl, + gv=gv, + n_texts=n_texts, + ) sent = brief.get("comment_sentiment_lexicon") or {} if isinstance(sent, dict): diff --git a/backend/pipeline/run_report_llm_chapters_demo.py b/backend/pipeline/run_report_llm_chapters_demo.py index 4692983..8ebaec8 100644 --- a/backend/pipeline/run_report_llm_chapters_demo.py +++ b/backend/pipeline/run_report_llm_chapters_demo.py @@ -5,7 +5,7 @@ - §6 后:``generate_price_group_summaries_llm`` - §8.2:``generate_comment_sentiment_analysis_llm`` - §8末细类评价:``generate_comment_group_summaries_llm`` -- §8.4 后使用场景:``generate_scenario_group_summaries_llm`` +- §8.3 右栏后使用场景:``generate_scenario_group_summaries_llm`` - §8.5 类全文补充(独立长文):``generate_competitor_report_markdown_llm`` cd backend @@ -244,7 +244,7 @@ def main() -> None: return generate_scenario_group_summaries_llm(pl_sg, keyword=keyword) _run_one( - "§8.4 使用场景归纳(scenario_groups)", + "§8.3 使用场景归纳(scenario_groups)", _sg, live=args.live, preview_chars=args.preview_chars,