From 7ed160235b13bd8a9399a72b2e91d00276d10f41 Mon Sep 17 00:00:00 2001 From: hub-gif <2487812171@qq.com> Date: Fri, 17 Apr 2026 10:55:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(pipeline):=20=E6=8A=A5=E5=91=8A=E6=96=87?= =?UTF-8?q?=E6=A1=88=E9=80=9A=E4=BF=97=E5=8C=96=E4=B8=8E=20Markdown=20?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E5=8F=AF=E8=AF=BB=E6=80=A7=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 竞品报告与流水线模板:弱化技术表述,促销与策略相关说明同步调整。 - md_document_export:按标题层级解析 #~######,列表与引用去掉行首符号,PDF 使用表格组件与分级标题样式。 Made-with: Cursor --- .../jd_pc_search/jd_competitor_report.py | 214 ++++++----------- .../demos/chapter8_text_mining_probe.py | 80 +++--- backend/pipeline/jd/runner.py | 14 +- backend/pipeline/llm/generate.py | 105 +++++--- .../pipeline/reporting/md_document_export.py | 227 +++++++++++++++--- backend/pipeline/views.py | 2 +- backend/requirements.txt | 2 +- 7 files changed, 390 insertions(+), 254 deletions(-) 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 e4bb5b1..edf464f 100644 --- a/backend/crawler_copy/jd_pc_search/jd_competitor_report.py +++ b/backend/crawler_copy/jd_pc_search/jd_competitor_report.py @@ -2,7 +2,7 @@ """ 关键词 → 调用 ``jd_keyword_pipeline`` 全链路采集 → 生成 **标准化竞品分析报告**(Markdown)。 -报告结构对齐常见竞品分析框架:研究范围与方法、执行摘要、**整体市场观察(列表可见度 proxy)**、 +报告结构对齐常见竞品分析框架:研究范围与方法、执行摘要、**整体市场观察(列表可见度参考)**、 市场与竞争结构、**按细分类目分组的竞品对比矩阵**、价格分析(含规则化价差/活动信号与可选 **细类价盘·促销** 大模型归纳)、**按细分类目的消费者反馈与用户画像**、**策略与机会提示**(以大模型归纳为主,可选)与附录;并明确数据边界。 若运行配置中提供了外部市场规模摘录(``EXTERNAL_MARKET_TABLE_ROWS``),则追加对应表格小节;否则不输出占位行。 @@ -337,43 +337,10 @@ def _collect_prices(rows: list[dict[str, str]]) -> list[float]: return out -# 列表/合并表中「卖点、腰带」常见活动话术子串(行级命中,非严谨 NLP) -_PROMO_SUBSTRINGS_IN_COPY: tuple[str, ...] = ( - "满减", - "秒杀", - "限时", - "优惠券", - "领券", - "券后", - "红包", - "京豆", - "百亿补贴", - "包邮", - "赠品", - "买赠", - "第二件", - "第2件", - "直降", - "特价", - "促销", - "套装", - "任选", - "到手价", - "补贴", - "聚划算", - "预售", - "定金", - "返现", - "折扣", - "加购", - "下单立减", -) - - def _analyze_price_promotions(rows: list[dict[str, str]]) -> dict[str, Any]: """ - 从列表或合并行中归纳「标价 vs 券后/到手」及卖点/腰带中的活动话术信号, - 供 §6.1 与结构化摘要使用(按**页面展示价**字段归纳,非结算实付)。 + 从列表或合并行中归纳「标价 vs 券后/到手」等价差信号, + 供第六章第一节与结构化摘要使用(按**页面展示价**字段归纳,非结算实付)。 """ n = len(rows) with_jd = with_cp = with_both = 0 @@ -415,21 +382,6 @@ def _analyze_price_promotions(rows: list[dict[str, str]]) -> dict[str, Any]: if _cell(r, _RANK_TAGLINE_KEY, _LEGACY_RANK_TAGLINE_KEY).strip() ) - kw_row_hits: dict[str, int] = {} - for kw in _PROMO_SUBSTRINGS_IN_COPY: - c = 0 - for row in rows: - blob = ( - _cell(row, _SELLING_POINT_KEY, _LEGACY_SELLING_POINT_KEY) - + " " - + _cell(row, _RANK_TAGLINE_KEY, _LEGACY_RANK_TAGLINE_KEY) - ) - if kw in blob: - c += 1 - if c: - kw_row_hits[kw] = c - top_promos = sorted(kw_row_hits.items(), key=lambda x: -x[1])[:14] - median_pct = statistics.median(pct_offs) if pct_offs else None mean_pct = statistics.mean(pct_offs) if pct_offs else None share_below = ( @@ -448,14 +400,12 @@ def _analyze_price_promotions(rows: list[dict[str, str]]) -> dict[str, Any]: "rows_original_price_above_list_price": ori_above_list, "rows_selling_point_nonempty": selling_nonempty, "rows_rank_tagline_nonempty": rank_nonempty, - "promo_keyword_row_hits_top": [ - {"keyword": k, "rows": v} for k, v in top_promos - ], + "promo_keyword_row_hits_top": [], } def _markdown_price_promotion_section(p: dict[str, Any]) -> list[str]: - """§6.1 优惠活动与价差信号(Markdown 行列表)。""" + """第六章第一节:优惠活动与价差信号(Markdown 行列表)。""" lines: list[str] = [ "### 6.1 优惠活动与价差信号(页面展示摘录)", "", @@ -466,7 +416,7 @@ def _markdown_price_promotion_section(p: dict[str, Any]) -> list[str]: wb = int(p.get("rows_with_both_list_and_coupon") or 0) if wb <= 0: lines.append( - "- **标价与券后价可对齐比较**的有效行不足,本节仅摘录卖点/腰带中的活动话术(若有)。" + "- **标价与券后价可对齐比较**的有效行不足,本节以展示价与券后/到手字段的可得信息为主。" ) lines.append("") else: @@ -493,7 +443,7 @@ def _markdown_price_promotion_section(p: dict[str, Any]) -> list[str]: ) elif cb > 0: lines.append( - "- **价差**:存在「券后低于标价」样本,但条数较少,未给出稳健分位数;建议结合 §5 单品对照。" + "- **价差**:存在「券后低于标价」样本,但条数较少,未给出稳健分位数;建议结合第五章矩阵中的单品对照。" ) lines.append("") oa = int(p.get("rows_original_price_above_list_price") or 0) @@ -502,33 +452,10 @@ def _markdown_price_promotion_section(p: dict[str, Any]) -> list[str]: f"- **划线原价高于当前标价** 的行约 **{oa}** 条(常见「划线价 + 当前价」促销陈列,具体以页面为准)。" ) lines.append("") - sn = int(p.get("rows_selling_point_nonempty") or 0) - rn = int(p.get("rows_rank_tagline_nonempty") or 0) - nr = int(p.get("row_count") or 0) - if nr > 0: - lines.append( - f"- **卖点字段非空**:**{sn}** / **{nr}** 行;**榜单/腰带类文案非空**:**{rn}** / **{nr}** 行(用于观察申报活动与心智标签)。" - ) - lines.append("") - top = p.get("promo_keyword_row_hits_top") or [] - if isinstance(top, list) and top: - lines.append("- **活动话术在列表行中的出现面**(卖点+腰带合并扫描预设子串;**同一行可含多词**,为行级命中次数):") - parts = [] - for it in top[:10]: - if isinstance(it, dict): - k = it.get("keyword") or "" - v = it.get("rows") - if k and v is not None: - parts.append(f"「{k}」**{int(v)}** 行") - if parts: - lines.append(" - " + ";".join(parts) + "。") - lines.append( - " - **解读**:高频词反映列表侧**主推活动类型**(如满减、百亿补贴、赠品);与 §6 分布表结合看「低价来自常态价还是大促价带」。" - ) - else: - lines.append( - "- **活动话术**:未在卖点/腰带字段中命中预设促销子串(可能字段为空或话术与词表不一致)。" - ) + lines.append( + "- **说明**:本节**不对**列表「卖点/腰带」等字段做预设促销关键词行级统计;" + "活动形态归纳以第六章「细类促销与活动要点归纳」为准(若已生成)。" + ) lines.append("") return lines @@ -1029,7 +956,7 @@ def build_price_groups_llm_payload( def _promo_snippet_for_llm(row: dict[str, str], title_h: str) -> str: - """单条 SKU:合并表中的「促销摘要 / 榜单排名 / 列表卖点与腰带」摘录,供促销 LLM 用。""" + """单条 SKU:合并表「促销摘要」「榜单排名」「榜单类文案」摘录,供促销 LLM 用(不含列表卖点/腰带列,避免固定词表匹配的粗口径)。""" title = _md_cell(_cell(row, title_h), 56) promo = _cell( row, @@ -1041,7 +968,6 @@ def _promo_snippet_for_llm(row: dict[str, str], title_h: str) -> str: MERGED_FIELD_TO_CSV_HEADER["buyer_ranking_line"], "buyer_ranking_line", ) - sp = _cell(row, _SELLING_POINT_KEY, _LEGACY_SELLING_POINT_KEY) belt = _cell(row, _RANK_TAGLINE_KEY, _LEGACY_RANK_TAGLINE_KEY) parts: list[str] = [title] if promo.strip(): @@ -1052,8 +978,6 @@ def _promo_snippet_for_llm(row: dict[str, str], title_h: str) -> str: parts.append( f"{MERGED_FIELD_TO_CSV_HEADER['buyer_ranking_line']}:{_md_cell(br, 120)}" ) - if sp.strip(): - parts.append(f"{JD_SEARCH_CSV_HEADERS['selling_point']}:{_md_cell(sp, 100)}") if belt.strip(): parts.append( f"{JD_SEARCH_CSV_HEADERS['hot_list_rank']}:{_md_cell(belt, 80)}" @@ -2245,15 +2169,15 @@ def build_competitor_markdown( "### 1.1 研究范围", "", f"- **搜索关键词**:「{keyword}」", - f"- **分析对象**:流水线拉取的 **{n_sku}** 个 SKU(搜索排序靠前子样本,非全站普查)。", + f"- **分析对象**:本次采集流程选取的 **{n_sku}** 个 SKU(搜索排序靠前子样本,非全站普查)。", ] if n_sku: n_sku_nop = n_sku - n_sku_pathed n_sku_unparsed = n_sku_pathed - n_sku_matrix lines.append( - f"- **细类分析范围**:**{n_sku_matrix}** 个 SKU 具备可参与 **§5~§8** 的商详 " - f"``detail_category_path``(且路径可解析为可读细类);另有 **{n_sku_nop}** 个缺该字段、" - f"**{n_sku_unparsed}** 个有路径但无可读细类段,**未纳入**细类矩阵与按细类评价统计。" + f"- **细类分析范围**:**{n_sku_matrix}** 个 SKU 具备参与**第五至第八章**分析所需的**商品详情页类目路径**" + f"(且能读出常见细类名称,如饼干、挂面等);另有 **{n_sku_nop}** 个商品缺少该信息、" + f"**{n_sku_unparsed}** 个虽有路径但读不出细类名称,**未纳入**细类矩阵与按细类的评价统计。" ) if meta: lines.append( @@ -2270,16 +2194,16 @@ def build_competitor_markdown( "", "### 1.3 方法说明(指标含义)", "", - "- **价格**:自页面「标价 / 券后价 / 详情价」等抽取的**展示价**,含促销与规格差异,**不等于**出厂价或成本。**第六章** 在具备可用的搜索列表导出时,优先以**列表全量**统计;否则使用**已深入 SKU** 的合并数据;**§6.1** 归纳标价与券后价差及卖点/腰带中的**活动话术信号**。", + "- **价格**:自页面「标价 / 券后价 / 详情价」等抽取的**展示价**,含促销与规格差异,**不等于**出厂价或成本。**第六章** 在具备可用的搜索列表导出时,优先以**列表全量**统计;否则使用**已深入 SKU** 的合并数据;**第六章第一节** 归纳标价与券后价差等**列表侧展示价差信号**(不对卖点/腰带字段做预设关键词扫描)。", "- **品牌/店铺集中度(第四章)**:有列表全量时按列表行计店铺与品牌占比;无列表导出时按深入 SKU 合并表估算。", "- **评价主题词**:对评价正文做**预设词表子串计数**,非分词主题模型,适合扫方向,**需抽样人工验证**。", "- **用途/场景**:对每条评价独立判断是否命中预设场景词;一条可计入多个场景,统计的是「提及该场景的评价条数」而非用户数。", ( - "- **用户画像(第八章)**:正负面粗判含**口语短语**级摘录;§8.3 为 **jieba + TF-IDF / 共现 / LDA** 文本挖掘探针(与 §8.2 条形图口径不同、互补),可选词云与探针专用大模型归纳。" + "- **用户画像(第八章)**:正负面粗判含**口语短语**级摘录;**第八章第三节**另用分词、词频与主题模型等对评论做**补充分析**(与**第八章第二节**条形图口径不同、互为补充),可选词云并由大模型归纳要点。" if _ch8_probe_sec - else "- **用户画像(第八章)**:正负面粗判含**口语短语**级摘录;关注词与场景**仅按细类**以**同图左右并列**展示(左为关注词命中次数,右为场景占有效文本 **%**);见 §8.3。" + else "- **用户画像(第八章)**:正负面粗判含**口语短语**级摘录;关注词与场景**仅按细类**以**同图左右并列**展示(左为关注词命中次数,右为场景占有效文本 **%**);见**第八章第三节**。" ), - "- **细类划分(§5~§8)**:**仅**依据合并表 ``detail_category_path``;该列为空或无法解析出可读细类段的 SKU **不参与**竞品矩阵与按细类评价统计(相关评价条亦**不进入**按细类图表)。", + "- **细类划分(第五至第八章)**:**仅**依据合并表中的**商品详情页类目路径**;该信息缺失或无法读出细类名称的 SKU **不参与**竞品矩阵与按细类评价统计(相关评价条亦**不进入**按细类图表)。", "- **检索结果规模**:来自京东 PC 搜索返回的「结果条数」类指标,表示平台侧申报的匹配数量级,**不等于**动销、库存或独立 SKU 数。", "", "### 1.4 主要局限", @@ -2288,7 +2212,7 @@ def build_competitor_markdown( "- 样本量由本次抓取上限与搜索页数决定,**结论外推需谨慎**。", "- 详情配料与宣称以页面展示为准,**与真实配方可能不一致**(合规与实测另议)。", ( - "- **行业零售额、TAM、CAGR 等**:无法从本批次数据推导;本报告已纳入任务中配置的第三方摘录,见 **§3.5**。" + "- **行业零售额、TAM、CAGR 等**:无法从本批次数据推导;本报告已纳入任务中配置的第三方摘录,见 **第三章第五节**。" if has_external_market else "- **行业零售额、TAM、CAGR 等**:无法从本批次数据推导;本报告未纳入外部摘录(可在任务报告调参中维护市场信息表)。" ), @@ -2308,22 +2232,22 @@ def build_competitor_markdown( src = f"列表全量 **{n_structure}** 行" if cr3_shop is not None: exec_bullets.append( - f"竞争结构({src},§4):**店铺** 第一大店铺份额 ≈ **{100 * cr1_shop:.1f}%**(「{top_shop_s}」)," + f"竞争结构({src},第四章):**店铺** 第一大店铺份额 ≈ **{100 * cr1_shop:.1f}%**(「{top_shop_s}」)," f"前三店铺合计份额 ≈ **{100 * cr3_shop:.1f}%**(按列表行计,同一 SKU 多行会重复计)。" ) else: exec_bullets.append( - f"竞争结构({src},§4):**店铺** 第一大店铺份额 ≈ **{100 * cr1_shop:.1f}%**(「{top_shop_s}」)。" + f"竞争结构({src},第四章):**店铺** 第一大店铺份额 ≈ **{100 * cr1_shop:.1f}%**(「{top_shop_s}」)。" ) elif not list_export and cr1_deep is not None and top_brand_deep: if cr3_deep is not None: exec_bullets.append( - f"竞争结构(无列表导出,§4 用深入合并表):**品牌** 第一大品牌份额 ≈ **{100 * cr1_deep:.1f}%**(「{top_brand_deep}」)," + f"竞争结构(无列表导出,第四章用深入合并表):**品牌** 第一大品牌份额 ≈ **{100 * cr1_deep:.1f}%**(「{top_brand_deep}」)," f"前三品牌合计份额 ≈ **{100 * cr3_deep:.1f}%**。" ) else: exec_bullets.append( - f"竞争结构(无列表导出,§4 用深入合并表):**品牌** 第一大品牌份额 ≈ **{100 * cr1_deep:.1f}%**(「{top_brand_deep}」)。" + f"竞争结构(无列表导出,第四章用深入合并表):**品牌** 第一大品牌份额 ≈ **{100 * cr1_deep:.1f}%**(「{top_brand_deep}」)。" ) if ( list_export @@ -2342,7 +2266,7 @@ def build_competitor_markdown( ) elif list_export and cr1_deep is not None and top_brand_deep and not brands_for_cr: exec_bullets.append( - f"列表导出缺少品牌标题字段,**深入 {n_sku} SKU** 商详品牌第一大品牌份额 ≈ **{100 * cr1_deep:.1f}%**(「{top_brand_deep}」),供与 §5 矩阵对照。" + f"列表导出缺少品牌标题字段,**深入 {n_sku} SKU** 商详品牌第一大品牌份额 ≈ **{100 * cr1_deep:.1f}%**(「{top_brand_deep}」),供与第五章矩阵对照。" ) if pst: price_src_short = ( @@ -2359,11 +2283,11 @@ def build_competitor_markdown( med = promo_sig.get("median_discount_pct_when_coupon_below") if wb >= 3 and isinstance(sh, (int, float)) and sh >= 0.08 and med is not None: exec_bullets.append( - f"列表侧约 **{100.0 * float(sh):.0f}%** 可对齐行呈现「券后/到手」**低于**「标价」,展示价差中位数约 **{float(med):.1f}%**(**§6.1** 活动与话术摘录)。" + f"列表侧约 **{100.0 * float(sh):.0f}%** 可对齐行呈现「券后/到手」**低于**「标价」,展示价差中位数约 **{float(med):.1f}%**(**第六章第一节** 活动与话术摘录)。" ) if multi_feedback_cat and (hits or scen_n_texts > 0): exec_bullets.append( - "评价侧写(关注词、用途/场景)已按 **§5 同款细类** 分节,见 **§8.3**(同图并列)。" + "评价侧写(关注词、用途/场景)已按**第五章同一细类划分**分节,见**第八章第三节**(同图并列)。" ) elif hits: top3 = "、".join(f"「{w}」({n})" for w, n in hits.most_common(3)) @@ -2374,7 +2298,7 @@ def build_competitor_markdown( exec_bullets.append(f"用途/场景(评价自述,可多选):{frag}(有效文本 **{scen_n_texts}** 条)。") if api_rc is not None: exec_bullets.append( - f"PC 搜索返回的检索结果规模约 **{api_rc:,}**(站内匹配条数量级,见 §3.2;**不是**零售额或动销统计)。" + f"PC 搜索返回的检索结果规模约 **{api_rc:,}**(站内匹配条数量级,见第三章第二节;**不是**零售额或动销统计)。" ) for b in exec_bullets: lines.append(f"- {b}") @@ -2382,13 +2306,13 @@ def build_competitor_markdown( lines.append("- 当前批次可汇总要点较少(以正文各节实际输出为准)。") proxy = _search_list_proxies(search_export_rows) if search_export_rows else {} - lines.extend(["", "---", "", "## 三、整体市场观察(渠道可见度 proxy · 非官方规模)", ""]) + lines.extend(["", "---", "", "## 三、整体市场观察(渠道可见度参考,非官方市场规模)", ""]) lines.extend( [ "### 3.1 与「市场规模」的区别", "", "- **官方/行业市场规模**(如全国零售额、品类增速、渗透率)通常来自 **Euromonitor、行业协会、上市公司年报、券商研报** 等;**不能**用京东搜索返回条数或 SKU 数直接等同。", - "- **§3.2** 使用搜索接口返回的**检索结果规模**字段;**§3.3~3.4** 描述本次导出的列表行、去重 SKU/店铺及列表价,用作 **proxy(参照)**,外推全市场需谨慎。", + "- **第三章第二节** 使用搜索接口返回的**结果条数**;**第三章第三、四节** 描述本次导出的列表行、去重 SKU/店铺及列表价,仅作**参照**,外推全市场需谨慎。", "", "### 3.2 接口返回的检索规模", "", @@ -2435,7 +2359,7 @@ def build_competitor_markdown( [ f"- **列表导出行数**:**{proxy['total_rows']}** 行。", f"- **去重 SKU 数**:**{proxy['unique_skus']}**;**去重店铺数**:**{proxy['unique_shops']}**;{span_txt}。", - f"- **列表中去重叶子类目代码/片段数**(粗略):**{proxy['unique_leaf_cats']}**(同一关键词下品类宽度 proxy)。", + f"- **列表中去重叶子类目代码/片段数**(粗略):**{proxy['unique_leaf_cats']}**(同一关键词下品类宽度的参考)。", "", ] ) @@ -2456,7 +2380,7 @@ def build_competitor_markdown( lines.append("") else: lines.append( - "*未读到可用的搜索列表导出或文件为空;§3.3~3.4 无列表侧数据。*" + "*未读到可用的搜索列表导出或文件为空;第三章第三、四节无列表侧数据。*" ) lines.append("") lines.extend(["### 3.4 列表端展示价(全导出)", "", "*无列表数据。*", ""]) @@ -2466,7 +2390,7 @@ def build_competitor_markdown( [ "### 3.5 外部市场规模与行业信息(运行配置摘录)", "", - "以下为本次任务报告调参中维护的**第三方市场摘录**,可与 §3.2 检索规模及 §3.3~3.4 列表参照对照使用;**指标含义与真实性以原出处为准**。", + "以下为本次任务报告调参中维护的**第三方市场摘录**,可与第三章第二节检索规模及第三章第三、四节列表参照对照使用;**指标含义与真实性以原出处为准**。", "", "| 指标 | 数值与说明 | 来源 | 年份 |", "| --- | --- | --- | --- |", @@ -2486,7 +2410,7 @@ def build_competitor_markdown( lines.extend(["", "---", "", ch4_heading, ""]) if list_export: lines.append( - f"基于**搜索列表导出**共 **{n_structure}** 行,与 §3.3 一致;" + f"基于**搜索列表导出**共 **{n_structure}** 行,与第三章第三节一致;" f"集中度按**列表行**计数(同一 SKU 多次曝光则重复计)。" ) else: @@ -2504,7 +2428,7 @@ def build_competitor_markdown( _embed_chart( run_dir, "chart_brand_rows_pie.png", - "品牌列表曝光占比(扇形图;按 strip 后品牌名计数、与结构化摘要 ``list_brand_mix_top`` 同源;" + "品牌列表曝光占比(扇形图;按整理后的品牌名计数,与结构化摘要中的品牌占比统计一致;" "长尾并入「(其余品牌)」;扇形内再合并为「其他」)", ) ) @@ -2523,8 +2447,8 @@ def build_competitor_markdown( elif list_export: lines.append( f"*列表导出中店铺/品牌标题有效 **{brand_rows_n}** 条," - f"低于建议阈值(≥{min_brand_rows}),品牌集中度未展开。**店铺结构见 §4.2**;" - f"商详品牌在 **§5**。*" + f"低于建议阈值(≥{min_brand_rows}),品牌集中度未展开。**店铺结构见第四章第二节**;" + f"商详品牌在**第五章**。*" ) else: lines.append("*深入子样本无可用品牌字段。*") @@ -2537,7 +2461,7 @@ def build_competitor_markdown( _embed_chart( run_dir, "chart_shop_rows_pie.png", - "店铺列表曝光占比(扇形图;按 strip 后店铺名计数、与结构化摘要 ``list_shop_mix_top`` 同源;" + "店铺列表曝光占比(扇形图;按整理后的店铺名计数,与结构化摘要中的店铺占比统计一致;" "长尾并入「(其余店铺)」;扇形内再合并为「其他」)", ) ) @@ -2557,14 +2481,14 @@ def build_competitor_markdown( lines.append("*无店铺字段。*") lines.append("") - lines.extend(["### 4.3 细分类目分布(深入合并表 · 与 §5 矩阵同一细类划分)", ""]) + lines.extend(["### 4.3 细分类目分布(深入合并表 · 与第五章矩阵同一细类划分)", ""]) if cm_structure and n_sku_matrix > 0: lines.extend( _embed_chart( run_dir, "chart_category_mix_pie.png", - "细类标签分布(扇形图;合并表 ``detail_category_path``,与 §5 同源;" - "Top 12 以外的细类在数据层并入「(其余细类)」;扇形图内再合并为「其他」)", + "细类标签分布(扇形图;依据合并表中的商品详情页类目路径,与第五章一致;" + "Top 12 以外的细类在统计时并入「(其余细类)」;扇形图内再合并为「其他」)", ) ) lines.append( @@ -2572,7 +2496,7 @@ def build_competitor_markdown( ) else: lines.append( - "*深入合并表中无具备可解析 ``detail_category_path`` 细类标签的 SKU,本小节不展示扇形图;请核对商详抓取与合并字段。*" + "*深入合并表中无具备可解析商品详情页类目路径的 SKU,本小节不展示扇形图;请核对商详抓取与合并字段。*" ) lines.append("") @@ -2582,11 +2506,11 @@ def build_competitor_markdown( "", "## 五、竞品对比矩阵(按细分类目分组)", "", - "分组**仅**使用合并表列 ``detail_category_path``(商详类目路径):**三级路径**取中间一段(如 … > **饼干** > 粗粮饼干)," - "**四级及以上**取倒数第二段(如 … > **面条** > 挂面)。**该列为空**或路径段均为内部编码、**无法解析出可读细类**的 SKU **不进入**本矩阵,亦**不参与**第八章按细类的评价统计。", + "分组**仅**依据合并表中的**商品详情页类目路径**(京东商详中的类目层级):**三级路径**取中间一段(如 … > **饼干** > 粗粮饼干)," + "**四级及以上**取倒数第二段(如 … > **面条** > 挂面)。**路径缺失**或各段均为内部编码、**读不出常见细类名称**的 SKU **不进入**本矩阵,亦**不参与**第八章按细类的评价统计。", "", - "**读图方式**:每个细类下为**并列横向条形图**(左:**展示价**(元);右:**销量**(搜索列表 ``totalSales`` 文案解析件数,如「已售50万+」计为 **50 万**))," - "纵轴为**产品标题**(与 ``report_assets/chart_matrix_prices_sales__*.png`` 同源)。**SKU、店铺、配料与评价摘要等明细不列入正文**,见本批次 ``keyword_pipeline_merged.csv``。", + "**读图方式**:每个细类下为**并列横向条形图**(左:**展示价**(元);右:**销量**(来自搜索列表页「已售」等销量文案,如「已售50万+」计为 **50 万**))," + "纵轴为**产品标题**(与本节各附图一致)。**SKU、店铺、配料与评价摘要等明细不列入正文**,详见本批次导出的合并数据表。", "", ] ) @@ -2594,8 +2518,8 @@ def build_competitor_markdown( if not grouped_matrix: if merged_rows: lines.append( - "*深入合并表有条目,但均无可用 ``detail_category_path``(或路径无法解析为可读细类),故无法生成细类矩阵;" - "§5~§8 中依赖矩阵的按细类统计相应为空。请核对商详抓取与合并字段。*" + "*深入合并表有条目,但均无可用商品详情页类目路径(或路径无法解析为可读细类),故无法生成细类矩阵;" + "第五至第八章中依赖矩阵的按细类统计相应为空。请核对商详抓取与合并字段。*" ) else: lines.append("*无合并表 SKU。*") @@ -2608,7 +2532,7 @@ def build_competitor_markdown( _embed_chart( run_dir, mx_chart, - f"「{_md_cell(gname, 20)}」· 展示价与销量(totalSales 解析);纵轴为产品标题。", + f"「{_md_cell(gname, 20)}」· 展示价与销量(页面「已售」销量文案);纵轴为产品标题。", ) ) if not (run_dir / "report_assets" / mx_chart).is_file(): @@ -2625,7 +2549,7 @@ def build_competitor_markdown( "", "#### 细类要点归纳(大模型,与上文条形图互补)", "", - "> **说明**:与 §5 相同的细类划分下归纳卖点与配料共性;**具体 SKU、价格与条形图以正文为准**,SKU级明细见合并表 CSV。", + "> **说明**:与第五章相同的细类划分下归纳卖点与配料共性;**具体 SKU、价格与条形图以正文为准**,SKU 级明细见合并表 CSV。", "", _llm_mx, "", @@ -2692,7 +2616,7 @@ def build_competitor_markdown( lines.extend( [ "", - "#### 细类价盘要点归纳(大模型,与 §6 量化表互补)", + "#### 细类价盘要点归纳(大模型,与第六章量化表互补)", "", "> **说明**:侧重价带与标价/券后关系的可读叙述;**数值以正文分位数表为准**。", "", @@ -2706,10 +2630,10 @@ def build_competitor_markdown( lines.extend( [ "", - "#### 细类促销与活动要点归纳(大模型,与 §6.1 及价盘互补)", + "#### 细类促销与活动要点归纳(大模型,与第六章第一节及价盘互补)", "", - "> **说明**:依据合并表「促销摘要」及列表卖点/腰带、榜单类文案等**页面展示摘录**;" - "归纳券/补贴/新人/榜单曝光等活动形态,**不**替代 §5 的配料/宣称归纳。**具体以页面与 CSV 为准**。", + "> **说明**:依据合并表「促销摘要」及榜单相关字段(如「榜单排名」「榜单类文案」)等**页面展示摘录**;" + "不采用列表「卖点/腰带」类字段作归纳依据(多为固定词表匹配,口径偏粗)。归纳券/补贴/新人/榜单曝光等活动形态,**不**替代第五章的配料/宣称归纳。**具体以页面与 CSV 为准**。", "", _llm_po, "", @@ -2729,18 +2653,18 @@ def build_competitor_markdown( "", "### 8.1 方法", "", - "- **细类划分**:与 **§5 竞品矩阵** 相同,**仅**依据 ``detail_category_path`` 解析为「饼干 / 西式糕点 / …」等(规则见 §5 章首说明)。", - "- **归因**:每条评价按其 SKU 对应到深入样本,再映射到该 SKU 所属细类;SKU 不在合并表中的评价单独归入说明性分组;**在合并表中但该 SKU 缺 ``detail_category_path`` 或路径无法解析为可读细类的,该评价不进入按细类统计**(与 §5 **同一条排除规则**)。", - "- **正负面粗判(§8.2)**:若评价含有效「评分」列则**先按星级**粗分正负与中评,再在对应子集内统计口语短语;无评分时仍按关键词子串;若任务开启 **llm_comment_sentiment**,可附**大模型对抽样原文的主题归因**,与条形图互补。", + "- **细类划分**:与**第五章「竞品矩阵」**相同,**仅**依据合并表中的**商品详情页类目路径**解析为「饼干 / 西式糕点 / …」等(规则见第五章开头说明)。", + "- **归因**:每条评价按其 SKU 对应到深入样本,再映射到该 SKU 所属细类;SKU 不在合并表中的评价单独归入说明性分组;**在合并表中但该 SKU 缺少类目路径或读不出细类名称的,该评价不进入按细类统计**(与第五章**同一条排除规则**)。", + "- **正负面粗判(第八章第二节)**:若评价含有效「评分」列则**先按星级**粗分正负与中评,再在对应子集内统计口语短语;无评分时仍按关键词子串;若任务开启**大模型评价情感分析**,可附**大模型对抽样原文的主题归因**,与条形图互补。", ( - "- **文本挖掘探索(§8.3)**:本任务已启用 **jieba + sklearn** 的开放词表分析(词频 / TF-IDF / 共现 / LDA,可选词云),与 §8.2 规则词表条形图**不同**、**互补**;**不再**输出原「关注词次数 + 场景占比」左右并列条图。" + "- **文本补充分析(第八章第三节)**:本任务已用中文分词与统计工具做了开放词表分析(词频、关键词突出度、词对共现、主题归纳等,可选词云),与**第八章第二节**规则词表条形图**不同**、**互补**;**不再**输出原「关注词次数 + 场景占比」左右并列条图。" if _ch8_probe_sec - else "- **关注词与使用场景(§8.3)**:对组内评价正文做关注词子串计数(左栏条形图);对每条有效文本独立扫描**本次任务生效的场景词组**(来自报告调参或系统默认),一条可属多场景,右栏为**占该细类有效文本比例 %**(多标签下可相加 **>** 100%)。二者在 **同一张图左右并列**,与 §5 矩阵细类一一对应。" + else "- **关注词与使用场景(第八章第三节)**:对组内评价正文做关注词子串计数(左栏条形图);对每条有效文本独立扫描**本次任务生效的场景词组**(来自报告调参或系统默认),一条可属多场景,右栏为**占该细类有效文本比例 %**(多标签下可相加 **>** 100%)。二者在 **同一张图左右并列**,与第五章矩阵细类一一对应。" ), "", _sec82_title, "", - f"- **有效文本条数**:{sentiment_lex.get('text_units', 0)}(与 §8.1 **归因规则**一致)。", + f"- **有效文本条数**:{sentiment_lex.get('text_units', 0)}(与第八章第一节**归因规则**一致)。", ] if _sm_score: _sec82_block.append( @@ -2835,21 +2759,21 @@ def build_competitor_markdown( if _ch8_probe_sec: lines.extend( [ - "### 8.3 文本挖掘探针(jieba / TF-IDF / 共现 / LDA)", + "### 8.3 评论文本补充分析(词频、关键词与共现、主题归纳)", "", - "> **说明**:与 §8.2 口语短语条形图(规则词表)**口径不同**、**互补**;**不**再输出本章原「关注词 + 场景」左右并列条图;插图路径相对于本批次目录下 ``report_assets/``。", + "> **说明**:与**第八章第二节**口语短语条形图(规则词表)**口径不同**、**互补**;**不再**输出本章原「关注词 + 场景」左右并列条图;插图位于本批次报告附图文件夹中。", "", _ch8_probe_sec, "", ] ) else: - # 仅当未嵌入文本挖掘探针(_ch8_probe_sec 为空)时:原「关注词 + 场景」条图与逐细类段落 + # 仅当未嵌入第八章第三节补充分析(_ch8_probe_sec 为空)时:原「关注词 + 场景」条图与逐细类段落 lines.extend( [ "### 8.3 关注词与使用场景(按细类)", "", - "每细类一张**左右并列图**(与 ``report_assets/chart_focus_and_scenarios_bar__*.png`` 同源):" + "每细类一张**左右并列图**(与报告附图文件夹中的 ``chart_focus_and_scenarios_bar__*.png`` 同源):" "**左**为配置关注词子串命中次数(同一评价可出现多次,为次数而非去重条数);" "**右**为预设场景词组命中占该细类有效文本比例 %(一条可属多场景;多柱比例可相加 **>** 100%)。" "统计均基于评价正文(或兜底预览)子串规则,**不等于**购买动机调研结论。", @@ -2904,9 +2828,9 @@ def build_competitor_markdown( lines.extend( [ "", - "#### 使用场景要点归纳(大模型,与 §8.3 右栏图表互补)", + "#### 使用场景要点归纳(大模型,与第八章第三节右栏图表互补)", "", - "> **说明**:与 §8.3 **相同**的预设场景词组与子串命中规则;**各场景条数与占比以正文图右栏为准**。", + "> **说明**:与第八章第三节**相同**的预设场景词组与子串命中规则;**各场景条数与占比以正文图右栏为准**。", "", _llm_sg, "", @@ -2918,9 +2842,9 @@ def build_competitor_markdown( lines.extend( [ "", - "#### 细类评价与关注词要点归纳(大模型,与 §8.3 左栏图表互补)", + "#### 细类评价与关注词要点归纳(大模型,与第八章第三节左栏图表互补)", "", - "> **说明**:归纳各细类反馈主题与配置关注词命中;**次数与 §8.3 图左栏以正文为准**。", + "> **说明**:归纳各细类反馈主题与配置关注词命中;**次数与第八章第三节图左栏以正文为准**。", "", _llm_cg, "", diff --git a/backend/pipeline/demos/chapter8_text_mining_probe.py b/backend/pipeline/demos/chapter8_text_mining_probe.py index e70460f..05ee841 100644 --- a/backend/pipeline/demos/chapter8_text_mining_probe.py +++ b/backend/pipeline/demos/chapter8_text_mining_probe.py @@ -1,8 +1,8 @@ """ -第八章「文本挖掘探针」独立脚本(**不修改**主报告代码)。 +第八章「评论文本补充分析」独立脚本(**不修改**主报告核心逻辑)。 -流程(按细类分组):清洗(jieba 分词 + 停用词)→ **词云图(可选)** → 词频 / TF-IDF → 共现对 → LDA 主题 -→ 规则化叙事小结 → 文末可选 **探针专用 LLM**(结构化 JSON + ``PROBE_TEXT_MINING_SYSTEM`` + ``_call_llm``;**非** ``COMMENT_GROUPS_SYSTEM``、**非** §8.2 情感)。 +流程(按细类分组):清洗(中文分词 + 停用词)→ **词云图(可选)** → 词频 / 关键词突出度 → 词对共现 → 主题归纳 +→ 规则化叙事小结 → 文末可选 **专用 LLM**(结构化 JSON + ``PROBE_TEXT_MINING_SYSTEM`` + ``_call_llm``;**非** ``COMMENT_GROUPS_SYSTEM``、**非**第八章第二节情感)。 依赖(请自行安装):: @@ -17,7 +17,7 @@ 输出:默认写入 ``/chapter8_text_mining_probe.md``。 -嵌入竞品报告:流水线默认开启(``get_default_report_config`` 中 ``chapter8_text_mining_probe``: true);若任务显式关闭则为 false。开启时会生成本稿并调用 ``markdown_embed_body_for_competitor_report`` 写入 ``competitor_analysis.md`` 的 **§8.3**,替代原「关注词 + 场景」条图及对应两段大模型;**§8.2 与「大模型深入解读(主题归因…)」保留**。 +嵌入竞品报告:流水线默认开启(``get_default_report_config`` 中 ``chapter8_text_mining_probe``: true);若任务显式关闭则为 false。开启时会生成本稿并调用 ``markdown_embed_body_for_competitor_report`` 写入 ``competitor_analysis.md`` 的 **第八章第三节**,替代原「关注词 + 场景」条图及对应两段大模型;**第八章第二节与「大模型深入解读(主题归因…)」保留**。 """ from __future__ import annotations @@ -91,30 +91,30 @@ _STOP_BASIC: frozenset[str] = frozenset( """.split() ) -# --- 文本挖掘探针 · 专用 LLM(与正式报告 ``COMMENT_GROUPS_SYSTEM`` / §8.2 均不同)--- +# --- 评论文本补充分析 · 专用 LLM(与正式报告 ``COMMENT_GROUPS_SYSTEM`` / 第八章第二节均不同)--- PROBE_TEXT_MINING_SYSTEM = """你是用户研究与文本挖掘方向的助手。 -输入 JSON 为「第八章文本挖掘探针」的**专用**结果(``schema_version``=1),**不是**正式竞品报告里的关注词规则统计、也不是 §8.2 情感 Lexicon。其中的数字与词表来自 **jieba 分词 + sklearn(TF-IDF / 共现 / LDA)**,与业务侧子串计数**口径不同**。 +输入 JSON 为「第八章第三节评论文本补充分析」的**专用**结果(``schema_version``=1),**不是**正式竞品报告里的关注词规则统计、也不是第八章第二节情感 Lexicon。其中的数字与词表来自**中文分词 + 统计工具**(词频、关键词突出度、共现、主题归纳),与业务侧子串计数**口径不同**。 每个 ``groups`` 元素含: - ``probe_status``:``ok`` 表示该细类已完成分词与统计;``skipped`` 表示样本过少等未下钻。 - ``word_freq_top`` / ``tfidf_top`` / ``cooccurrence_top``:统计型特征(开放词表)。 -- ``lda``:无监督主题词;**仅为探索**,同一词可出现在多主题,**禁止**当作严格品类或固定标签。 -- ``focus_hit_lines`` / ``sample_text_snippets``:与正式管线摘取方式**类似**,仅用于**对照语境**;若与 TF-IDF 焦点冲突,以**整句原文**为准,并在段末或「使用注意」中可点明「统计与语义可能不一致」。 +- ``lda``:无监督自动归纳的主题词;**仅为探索**,同一词可出现在多主题,**禁止**当作严格品类或固定标签。 +- ``focus_hit_lines`` / ``sample_text_snippets``:与正式管线摘取方式**类似**,仅用于**对照语境**;若与关键词突出度焦点冲突,以**整句原文**为准,并在段末或「使用注意」中可点明「统计与语义可能不一致」。 **任务**:对 ``groups`` 中**每一项**输出对应 Markdown(**顺序与输入一致**): - 对 ``probe_status == "ok"``:以 ``#### `` + 与该条 ``group`` 字段**完全一致**的细类名作为小节标题(勿用 ``##`` 一级标题);每段约 **100~260 字**。 -- 内容须包含:①用 **1~2 句**概括该细类评论**主要讨论焦点**(综合词频与 TF-IDF,**不要罗列具体数字**);② **1~2 句**说明共现词对**暗示**哪些维度常一起出现(**非因果**);③若 ``lda.topics`` 非空,**1~2 句**说明主题粗分侧重点,并**明确** LDA 无监督、**不**与矩阵细类一一对应;④用 **1~2 句**体现 ``sample_text_snippets`` / ``focus_hit_lines`` 中的**用户语气与关切**(以**转述**为主);若必须引用原文,**全小节合计**仅 **一处**极短引号内容(**≤40 字**,**不要**输出 ``【细类…SKU…店铺…】`` 等长前缀);若无可用摘录则写明;⑤ **使用场景(仅从评论推断)**:用 **0~2 句**概括**何时、何地、何人、如何搭配**等(如早餐、加餐、控糖人群、配牛奶等)——**只能**依据本细类 ``word_freq``/``tfidf``/``cooccurrence``/``lda`` 与摘录中**已出现或可合理概括**的信息;**禁止**套用正式报告「场景分组」或其它外部场景分类;若统计与摘录中**均无**场景线索,**一句**写明「评论中未体现清晰使用场景」即可。 +- 内容须包含:①用 **1~2 句**概括该细类评论**主要讨论焦点**(综合词频与关键词突出度,**不要罗列具体数字**);② **1~2 句**说明共现词对**暗示**哪些维度常一起出现(**非因果**);③若 ``lda.topics`` 非空,**1~2 句**说明主题粗分侧重点,并**明确**算法无监督、**不**与矩阵细类一一对应;④用 **1~2 句**体现 ``sample_text_snippets`` / ``focus_hit_lines`` 中的**用户语气与关切**(以**转述**为主);若必须引用原文,**全小节合计**仅 **一处**极短引号内容(**≤40 字**,**不要**输出 ``【细类…SKU…店铺…】`` 等长前缀);若无可用摘录则写明;⑤ **使用场景(仅从评论推断)**:用 **0~2 句**概括**何时、何地、何人、如何搭配**等(如早餐、加餐、控糖人群、配牛奶等)——**只能**依据本细类 ``word_freq``/``tfidf``/``cooccurrence``/``lda`` 与摘录中**已出现或可合理概括**的信息;**禁止**套用正式报告「场景分组」或其它外部场景分类;若统计与摘录中**均无**场景线索,**一句**写明「评论中未体现清晰使用场景」即可。 - 对 ``probe_status == "skipped"``:该小节仅 **一句**说明原因。 -**禁止**:编造数据中未出现的品牌、价格、医学功效或疗效承诺;不要把 ``keyword`` 监测词写进「用户原话」;不要输出 Markdown 表格;不要声称本段与「正式报告 §8 末」完全同源——本任务为**探针解读**。**禁止**把输入里的 ``sample_text_snippets`` / ``focus_hit_lines`` **逐条罗列**、**多条整段复制**到输出(那不是归纳,是重复贴评论)。 +**禁止**:编造数据中未出现的品牌、价格、医学功效或疗效承诺;不要把 ``keyword`` 监测词写进「用户原话」;不要输出 Markdown 表格;不要声称本段与「正式报告第八章末」完全同源——本任务为**补充分析解读**。**禁止**把输入里的 ``sample_text_snippets`` / ``focus_hit_lines`` **逐条罗列**、**多条整段复制**到输出(那不是归纳,是重复贴评论)。 -全文末可另起一段 **「使用注意」**(简短):点明开放词表统计与人工阅读差异、LDA 局限、小样本细类不可靠。 +全文末可另起一段 **「使用注意」**(简短):点明开放词表统计与人工阅读差异、主题归纳局限、小样本细类不可靠。 总字数约 **800~4500 字**(细类多则偏长)。仅输出正文 Markdown,不要用代码围栏包裹全文。""" PROBE_TEXT_MINING_USER_PREFIX = ( - "请根据以下 JSON 撰写「文本挖掘探针」解读正文(Markdown)。\n\n" + "请根据以下 JSON 撰写「评论文本补充分析」解读正文(Markdown)。\n\n" ) @@ -350,7 +350,7 @@ def _narrative_stub( ) + "。", "", - "**TF-IDF 加权 Top**(相对区分度):" + "**关键词突出度 Top**(相对区分度):" + ( "、".join(f"「{w}」({s:.3f})" for w, s in tfidf_top[:12]) if tfidf_top @@ -368,10 +368,10 @@ def _narrative_stub( "", ] if lda_note: - lines.append(f"*LDA:{lda_note}*") + lines.append(f"*主题归纳:{lda_note}*") lines.append("") elif lda_topics: - lines.append("**LDA 主题(无监督,仅作探索)**:") + lines.append("**自动归纳的主题(无监督,仅作探索)**:") for i, words in enumerate(lda_topics): lines.append(f"- 主题 {i + 1}:{'、'.join(words)}") lines.append("") @@ -423,7 +423,7 @@ def _merge_snippets_from_comment_groups( comment_rows: list[dict[str, str]], run_dir: Path, ) -> None: - """把正式 ``build_comment_groups_llm_payload`` 中的摘录并入探针行(原地修改)。""" + """把正式 ``build_comment_groups_llm_payload`` 中的摘录并入补充分析行(原地修改)。""" sku_h = MERGED_FIELD_TO_CSV_HEADER["sku_id"] title_h = MERGED_FIELD_TO_CSV_HEADER["title"] fb = jcr._consumer_feedback_by_matrix_group( @@ -461,9 +461,9 @@ def _run_probe_text_mining_llm( *, chunked: bool, ) -> str: - """探针专用:``PROBE_TEXT_MINING_SYSTEM`` + 结构化 JSON;可选按细类拆分调用。""" + """补充分析专用:``PROBE_TEXT_MINING_SYSTEM`` + 结构化 JSON;可选按细类拆分调用。""" if not payload.get("groups"): - return "> **探针 LLM 解读**:无分组数据,跳过。" + return "> **补充分析 LLM 解读**:无分组数据,跳过。" try: if not chunked: p = _truncate_probe_payload(payload) @@ -487,7 +487,7 @@ def _run_probe_text_mining_llm( if g.get("probe_status") != "ok": parts.append( f"#### {gname}\n\n" - f"*(未做探针:{g.get('reason', '')})*" + f"*(评论量不足,未做词云与主题归纳:{g.get('reason', '')})*" ) continue mini = { @@ -507,13 +507,13 @@ def _run_probe_text_mining_llm( ) return "\n\n---\n\n".join(parts) except Exception as e: - return f"> **探针 LLM 解读**调用失败:{e}" + return f"> **补充分析 LLM 解读**调用失败:{e}" def _ensure_probe_dependencies() -> None: if not _PROBE_TEXT_MINING_DEPS_OK: raise ImportError( - "第八章文本挖掘探针依赖未安装,请在 backend 环境下执行:" + "第八章评论文本补充分析依赖未安装,请在 backend 环境下执行:" "pip install jieba scikit-learn numpy wordcloud\n" f"原始错误: {_PROBE_TEXT_MINING_IMPORT_ERROR}" ) @@ -542,7 +542,7 @@ def build_markdown( ) lines: list[str] = [ - "# 八、消费者反馈与用户画像(文本挖掘探针 · 实验稿)", + "# 八、消费者反馈与用户画像(评论文本补充分析 · 实验稿)", "", f"- **运行目录**:`{run_dir}`", f"- **监测词(run_meta)**:{kw or '—'}", @@ -550,10 +550,9 @@ def build_markdown( "", "## 8.0 说明", "", - "本稿为**独立探针**,流程参考「清洗 → 词云(可选)→ 词频/TF-IDF → 共现 → LDA → 叙事小结」;" - "文末可选 **探针专用 LLM 解读**(独立 JSON + ``PROBE_TEXT_MINING_SYSTEM``,**非**正式 ``COMMENT_GROUPS_SYSTEM`` / §8.2)。" - "与线上一致的部分:**细类划分与 SKU 归因**复用 ``jd_competitor_report._consumer_feedback_by_matrix_group``;" - "其余为 **jieba + sklearn** 的开放词表分析,**不替代**正式报告中的规则统计。", + "本稿为**独立补充分析**,流程为:清洗评论 → 词云(可选)→ 词频与关键词 → 词对共现 → 主题归纳 → 文字小结;" + "文末可选用**专用提示词**由大模型解读(与第八章第二节所用口径不同)。" + "**细类划分与 SKU 归因**与主报告一致;其余为中文分词与统计工具做的开放词表分析,**不替代**正式报告中的规则统计。", "", "---", "", @@ -591,7 +590,7 @@ def build_markdown( [ f"## {gname}", "", - f"*本细类有效文本 {n_raw} 条,低于 ``--min-texts``={min_texts},跳过。*", + f"*本细类有效评论仅 {n_raw} 条,低于分析所需最少条数({min_texts} 条),故未生成词云与主题图。*", "", "---", "", @@ -668,7 +667,7 @@ def build_markdown( ) if not err_wc: lines.append( - f"![词云(本分词词频权重;探针)](report_assets/{fn})" + f"![词云(按词频权重)](report_assets/{fn})" ) lines.append("") wc_n += 1 @@ -696,8 +695,8 @@ def build_markdown( "schema_version": 1, "keyword": kw, "probe_note": ( - "jieba 分词 + 停用词;TF-IDF/共现/LDA 为 sklearn;" - "与正式报告的关注词规则统计、§8.2 情感口径均不同;" + "中文分词 + 停用词;关键词突出度/共现/主题归纳为统计库;" + "与正式报告的关注词规则统计、第八章第二节情感口径均不同;" "评价摘录字段合并自 build_comment_groups_llm_payload,供语境对照;" "「使用场景」若出现,须仅能从本 JSON 内统计与摘录推断,不接入正式场景分组。" ), @@ -709,11 +708,10 @@ def build_markdown( "", "---", "", - "## 探针归纳(大模型 · 文本挖掘专用)", + "## 评论文本归纳(大模型 · 专用口径)", "", - "> 输入为探针 JSON(``schema_version``=1):每细类含 **word_freq / tfidf / cooccurrence / lda** 及合并后的 **focus_hit_lines、sample_text_snippets**(供语境,**非**要求逐条复述);" - "归纳中的**使用场景**仅能从上述字段推断,**不**等同正式「场景分组」章节;" - "系统提示为脚本内 ``PROBE_TEXT_MINING_SYSTEM``,**不是** ``COMMENT_GROUPS_SYSTEM``。", + "> 输入为按细类整理后的词频、关键词、共现与主题等统计结果,以及少量原文摘录(供理解语境,**不是**要求逐条复述);" + "归纳中的**使用场景**仅能从上述统计推断,**不等同**于正式的「场景分组」章节。", "", ] ) @@ -721,7 +719,7 @@ def build_markdown( lines.append(_run_probe_text_mining_llm(llm_payload, chunked=llm_chunked)) else: lines.append( - "> **探针 LLM 解读**:未启用。请使用 ``--live-llm``(需 ``AI_crawler`` 等可用);" + "> **补充分析 LLM 解读**:未启用。请使用 ``--live-llm``(需 ``AI_crawler`` 等可用);" "细类很多、单次 JSON 易超长时可加 ``--llm-chunked``(按细类多次调用后拼接)。" ) @@ -732,7 +730,7 @@ def build_markdown( def markdown_embed_body_for_competitor_report(full_probe_md: str) -> str: """ - 将独立探针稿转为可嵌入 ``build_competitor_markdown`` 的 **§8.3 正文**(不含 ``### 8.3`` 标题行): + 将独立补充分析稿转为可嵌入 ``build_competitor_markdown`` 的 **第八章第三节正文**(不含 ``### 8.3`` 标题行): 从 ``## 8.0 说明`` 起至文末,并把 ``## …`` 降为 ``#### …``,避免与宿主 ``## 八、`` 冲突。 """ lines = (full_probe_md or "").splitlines() @@ -766,7 +764,7 @@ def main() -> None: file=sys.stderr, ) sys.exit(1) - ap = argparse.ArgumentParser(description="第八章文本挖掘探针(独立脚本)") + ap = argparse.ArgumentParser(description="第八章评论文本补充分析(独立脚本)") ap.add_argument( "--run-dir", type=Path, @@ -780,19 +778,19 @@ def main() -> None: help="输出 Markdown 路径(默认 /chapter8_text_mining_probe.md)", ) ap.add_argument("--min-texts", type=int, default=8, help="细类最少评论条数才分析") - ap.add_argument("--lda-topics", type=int, default=4, help="LDA 主题数上限(会按样本量裁剪)") - ap.add_argument("--top-k-words", type=int, default=30, help="词频/TF-IDF 展示长度") + ap.add_argument("--lda-topics", type=int, default=4, help="自动主题归纳条数上限(会按样本量裁剪)") + ap.add_argument("--top-k-words", type=int, default=30, help="词频/关键词突出度展示长度") ap.add_argument("--cooc-vocab", type=int, default=80, help="共现矩阵保留的高频词数") ap.add_argument("--cooc-pairs", type=int, default=25, help="输出词对数量") ap.add_argument( "--live-llm", action="store_true", - help="文末调用探针专用 LLM(PROBE_TEXT_MINING_SYSTEM + 结构化 JSON;需 AI_crawler 等)", + help="文末调用补充分析专用 LLM(PROBE_TEXT_MINING_SYSTEM + 结构化 JSON;需 AI_crawler 等)", ) ap.add_argument( "--llm-chunked", action="store_true", - help="按细类拆分多次调用同一探针提示词,防单次 JSON 过长", + help="按细类拆分多次调用同一补充分析提示词,防单次 JSON 过长", ) ap.add_argument( "--no-wordcloud", diff --git a/backend/pipeline/jd/runner.py b/backend/pipeline/jd/runner.py index 40382f8..a127f1f 100644 --- a/backend/pipeline/jd/runner.py +++ b/backend/pipeline/jd/runner.py @@ -18,9 +18,9 @@ from ..models import PipelineJob def merge_llm_supplement_with_rules_report(llm_md: str, rules_md: str) -> str: """ - **以规则引擎全文为正文**(含 §5 完整竞品矩阵、各章内嵌统计图与表格)。 + **以规则引擎全文为正文**(含第五章完整竞品矩阵、各章内嵌统计图与表格)。 - 大模型稿作为 **§8.5** 嵌入在 **第八章末、第九章策略** 之前,与 §8.2~8.3 等具体分析同卷连贯, + 大模型稿作为 **8.5 小节**嵌入在 **第八章末、第九章策略** 之前,与第八章第二、三节等具体分析同卷连贯, **不再**插在篇首「## 一、」之前。 注:API「重新生成报告」已不再调用本函数,避免整篇 LLM 与矩阵/图表**数据含义**冲突;保留供脚本或将来显式开关复用。 @@ -34,8 +34,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.3 定量内容互补)\n\n" - "> **说明**:本段位于**第八章末**;**竞品矩阵、价盘表、统计图与 §8.2~8.3 等以正文各节为准**," + "### 8.5 大模型深度补充(与第二章至第八章第三节正文中的定量内容互补)\n\n" + "> **说明**:本段位于**第八章末**;**竞品矩阵、价盘表、统计图与第八章第二、三节等各节正文以相应章节为准**," "此处为跨小节语义整合,便于衔接第九章。\n\n" f"{sup.strip()}\n" "\n---\n\n## 九、策略与机会提示(假设清单,待验证)" @@ -50,7 +50,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.3 定量内容互补)\n\n" + "\n\n---\n\n### 8.5 大模型深度补充(与第二章至第八章第三节正文中的定量内容互补)\n\n" "> **说明**:位于第八章末;**矩阵与图表以正文为准**。\n\n" f"{sup.strip()}\n" + alt, @@ -59,7 +59,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.3 定量内容互补)\n\n" + "\n\n---\n\n### 8.5 大模型深度补充(与第二章至第八章第三节正文中的定量内容互补)\n\n" f"{sup.strip()}\n" ) return body.replace(app, tail + app, 1) @@ -147,7 +147,7 @@ def _jd_crawler_modules(): def use_chunked_group_summaries_llm(report_config: dict[str, Any] | None) -> bool: """ - 是否按矩阵细类**拆分** §5/§6/§8 等 group 归纳的 LLM 请求(默认开启)。 + 是否按矩阵细类**拆分**第五/六/八章等 group 归纳的 LLM 请求(默认开启)。 关闭方式:``report_config`` 中 ``llm_group_summaries_chunk_by_matrix``: false, 或环境变量 ``MA_LLM_GROUP_SUMMARIES_BULK=1``(恢复单次打包调用)。 diff --git a/backend/pipeline/llm/generate.py b/backend/pipeline/llm/generate.py index f46f09e..058830e 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.3 **之后**、第九章策略**之前**。因此写的是**具体分析型补充**,不是篇首速读块。 +消费者反馈(第八章第一至三节)**之后**、第九章策略**之前**。因此写的是**具体分析型补充**,不是篇首速读块。 所有数字、占比、条数、品牌名、价格区间等**必须严格来自输入 JSON**,禁止编造未在输入中出现的定量结论。 @@ -52,17 +52,17 @@ REPORT_SYSTEM = """你是业务与产品读者顾问。输入 JSON 含 `keyword` - **不要**撰写 Markdown 表格版「竞品对比矩阵」或罗列 SKU 明细——**正文已含矩阵**,此处只做分组级语义归纳; - **不要使用** CR1、CR3 等集中度缩写作主表述;集中度请用「第一大店铺/品牌份额」「前三家合计份额」;输入中的英文字段名勿照抄进正文,请写成中文业务用语。 -**请输出**(仅输出将置于 §8.5 下的正文,不要自造「### 8.5」标题行): +**请输出**(仅输出将置于 8.5 小节 下的正文,不要自造「### 8.5」标题行): - **Markdown**,约 **800~1500 字**; - 建议用 ``####`` 组织:**执行摘要级要点**、**竞争与价盘**、**用户声量与负向事由**(须归纳用户在抱怨什么类型的问题,而非只堆关键词)、**后续可验证动作(假设)**(不写第九章目录或重复策略章内容); - 若有 `comment_sentiment_lexicon`,概括正/负向粗判局限;**负向**写清事由类型(口感、价格、物流等); - **归因与引语(硬性)**:`consumer_feedback_by_matrix_group` 与 `comment_sentiment_lexicon` 等均为**跨 SKU/跨店铺的关键词子串或条数统计**,**不能**单独据此推断「某一店铺某一单品」的结论。 - - **具体体验句式(含口感、包装等)**须以正文 **§8.2** 中带 ``【细类|SKU|品名|店铺】`` 前缀的抽样为准;本段**不要**新增无前缀、无店铺/品名/SKU 指向的「」引语。 - - 若写口感、包装、物流、价格等**聚合**维度,须**写明统计范围**(如「在已合并的评价文本中,『物流』『价格』类关键词命中较多,为全样本子串计数」);可结合 `matrix_overview_for_llm` 谈细类结构;**可一句**引导读者「见 §8.2 按店铺/品名的负向举例」。 - - 若 §8.2 已归纳带店铺与 SKU 的负向主题,本段**只做执行摘要级收束**,勿重复编造新引文。 + - **具体体验句式(含口感、包装等)**须以正文 **第八章第二节** 中带 ``【细类|SKU|品名|店铺】`` 前缀的抽样为准;本段**不要**新增无前缀、无店铺/品名/SKU 指向的「」引语。 + - 若写口感、包装、物流、价格等**聚合**维度,须**写明统计范围**(如「在已合并的评价文本中,『物流』『价格』类关键词命中较多,为全样本子串计数」);可结合 `matrix_overview_for_llm` 谈细类结构;**可一句**引导读者「见第八章第二节按店铺/品名的负向举例」。 + - 若 第八章第二节 已归纳带店铺与 SKU 的负向主题,本段**只做执行摘要级收束**,勿重复编造新引文。 - 语气专业、中文;某类信息在输入中缺失时**一句带过数据缺口**即可,**禁止**输出「本段未提供该项」等套话占位。""" -REPORT_USER_PREFIX = """请根据以下 JSON 撰写上文所述 §8.5 嵌入段落(Markdown 正文,勿加 ### 8.5 标题)。\n\n""" +REPORT_USER_PREFIX = """请根据以下 JSON 撰写上文所述 8.5 小节 嵌入段落(Markdown 正文,勿加 ### 8.5 标题)。\n\n""" def _estimated_chat_input_tokens(system_prompt: str, user_prompt: str) -> int: @@ -119,7 +119,7 @@ SENTIMENT_LLM_SYSTEM = """你是电商/食品类用户研究助手。输入 JSON - ``sentiment_bucket_method``:``score_then_lexeme`` 表示**先按 1~5 星分桶**(无评分行再按关键词);``keyword_substring_heuristic`` 表示**仅关键词**分桶;与条形图一致。``sample_reviews_positive_biased`` / ``negative`` / ``mixed_tone`` 按该规则**机械归类**的抽样,**可能与整句真实褒贬不一致**(例如「软硬适中」曾被误归负向)。 - **``sample_reviews_semantic_pool``**(若有):本批评价经去重后的**随机/洗牌抽样**(来自全部有效条,不限于某一象限)。**归纳正/负向体验、引用「」短引文时,优先以此池与上述各列表中的原文为准,自行结合语境理解**:转折、对比(如「没那么甜」「软硬适中」)、先抑后扬/先扬后抑整句态度;**不得以子串是否命中负面词来断言该句为抱怨**。 -每条样本通常以 ``【细类:…|SKU:…|品名:…|店铺:…】`` 开头,表示 **§5 细类、SKU、品名、店铺**;写归纳与「」引文时须能还原「哪家店、哪条 SKU、哪款品名」,或保留前缀,**禁止**无指代地写「用户普遍…」。 +每条样本通常以 ``【细类:…|SKU:…|品名:…|店铺:…】`` 开头,表示 **第五章细类、SKU、品名、店铺**;写归纳与「」引文时须能还原「哪家店、哪条 SKU、哪款品名」,或保留前缀,**禁止**无指代地写「用户普遍…」。 **硬性要求**: - **仅输出 Markdown 正文**(不要用 ``` 围栏包裹全文); @@ -140,7 +140,7 @@ SENTIMENT_LLM_SYSTEM = """你是电商/食品类用户研究助手。输入 JSON def generate_comment_sentiment_analysis_llm(payload: dict[str, Any]) -> str: - """基于 lexicon 统计 + 语义池与按词表归类的抽样,生成 §8.2 大模型解读段落(Markdown)。""" + """基于 lexicon 统计 + 语义池与按词表归类的抽样,生成 第八章第二节 大模型解读段落(Markdown)。""" p = dict(payload) raw = json.dumps(p, ensure_ascii=False) if len(raw) > 88_000: @@ -316,13 +316,13 @@ def generate_strategy_draft_markdown_llm( MATRIX_GROUPS_SYSTEM = """你是竞品分析顾问。输入为 JSON:``keyword`` 与 ``groups`` 数组。 -每个 group 含 ``group``(细分类目名)、``sku_count``、``price_stats``(该细类深入合并行可解析展示价的 min/max/median/mean/n,与 **§6「按细类价盘」** 分位数表同源;无则 n=0 或缺字段)、 +每个 group 含 ``group``(细分类目名)、``sku_count``、``price_stats``(该细类深入合并行可解析展示价的 min/max/median/mean/n,与 **第六章「按细类价盘」** 分位数表同源;无则 n=0 或缺字段)、 ``lines``(该细类下若干 SKU 的标题/卖点/配料**摘录**,均来自页面抓取拼接,可能截断)。 请**为每个细类**输出一小段 Markdown(全部 groups 都要写,顺序与输入一致): - 以 ``#### `` + 与该 group 字段**完全一致**的细类名作为小节标题(不要使用 ``##`` 一级标题); - 每段约 **100~200 字**中文:**主体**归纳该细类下**卖点表述共性**、**配料类型/宣称共性**(摘录中无配料则写「配料摘录较少」);**品牌格局**可一句概括(仅依据摘录中可见品牌/系列,勿编造销量排名); -- **价带/价位**:若 ``price_stats.n`` 为大于 0 的整数,**仅允许**用该对象里的数值写价带(如 min~max、中位数),且须与 ``price_stats`` **完全一致**,**禁止**写「价格带未明确」「未体现具体价位」「多为中端」等**与上述数值相矛盾**的表述;若 n=0 或无可信数值,**不要猜测价位**,可写一句「深入样本可解析数值价不足,价盘以 **§6** 表格为准」; +- **价带/价位**:若 ``price_stats.n`` 为大于 0 的整数,**仅允许**用该对象里的数值写价带(如 min~max、中位数),且须与 ``price_stats`` **完全一致**,**禁止**写「价格带未明确」「未体现具体价位」「多为中端」等**与上述数值相矛盾**的表述;若 n=0 或无可信数值,**不要猜测价位**,可写一句「深入样本可解析数值价不足,价盘以 **第六章** 表格为准」; - **禁止**输出 Markdown 表格、禁止逐条复述 SKU 明细表;勿编造功效、认证; - 若 ``lines`` 很少,明确写「样本较少,归纳供启发」。 @@ -359,8 +359,8 @@ def generate_matrix_group_summaries_llm( COMMENT_GROUPS_SYSTEM = """你是用户研究与品类顾问。输入为 JSON:``keyword`` 与 ``groups``。 -每个 group 含 ``group``(与 §5 矩阵一致的细分类目名)、``comment_flat_rows``、``effective_text_lines``、 -``focus_hit_lines``(关注词子串命中摘要,与 §8.3 同源)、``sample_text_snippets``(评价短摘录,已截断)。 +每个 group 含 ``group``(与 第五章矩阵一致的细分类目名)、``comment_flat_rows``、``effective_text_lines``、 +``focus_hit_lines``(关注词子串命中摘要,与 第八章第三节 同源)、``sample_text_snippets``(评价短摘录,已截断)。 摘录行通常以 ``【细类:…|SKU:…|品名:…|店铺:…】`` 开头:细类可与本 group 名对照,**品名/SKU/店铺**表示该句具体出自哪条链接;归纳时若引用原话,**须交代是「哪家店、哪条 SKU、哪款品名」上的反馈**,勿只写「有用户说口感差」而不指代产品。 关注词命中为子串统计,可能与句意不一致;**请以整句语义**判断褒贬(如「软硬适中」「没那么甜」常为满意表述,不得据此写成质地问题)。 @@ -472,9 +472,9 @@ def generate_comment_group_summaries_llm( SCENARIO_GROUPS_SYSTEM = """你是用户研究与品类顾问。输入为 JSON:``keyword``、``scenario_lexicon``、``groups``。 -``scenario_lexicon`` 列出各场景标签及示例触发子串(与报告 **§8.3** 右栏统计规则一致)。 -``groups`` 每项含 ``group``(与 §5 矩阵一致的细分类目名)、``effective_text_count``(有效评价文本条数)、 -``scenario_distribution``(各预设场景的 ``mention_rows`` 与 ``share_of_effective_texts``;**一条评价可计入多场景**;与 §8.3 图右栏同源)、 +``scenario_lexicon`` 列出各场景标签及示例触发子串(与报告 **第八章第三节** 右栏统计规则一致)。 +``groups`` 每项含 ``group``(与 第五章矩阵一致的细分类目名)、``effective_text_count``(有效评价文本条数)、 +``scenario_distribution``(各预设场景的 ``mention_rows`` 与 ``share_of_effective_texts``;**一条评价可计入多场景**;与 第八章第三节 图右栏同源)、 ``sample_text_snippets``(摘录行常含细类、SKU、品名、店铺等前缀的短引文,已截断)。 统计为**子串命中**,不是语义主题模型。 @@ -488,7 +488,7 @@ SCENARIO_GROUPS_SYSTEM = """你是用户研究与品类顾问。输入为 JSON SCENARIO_GROUPS_USER_PREFIX = ( - "请根据以下 JSON 撰写竞品报告 §8.3(右栏:使用场景)之后的「使用场景要点归纳」正文(Markdown)。\n\n" + "请根据以下 JSON 撰写竞品报告 第八章第三节(右栏:使用场景)之后的「使用场景要点归纳」正文(Markdown)。\n\n" ) @@ -624,13 +624,13 @@ def generate_scenario_group_summaries_llm( PRICE_GROUPS_SYSTEM = """你是定价与渠道顾问。输入为 JSON:``keyword`` 与 ``groups``。 -每个 group 含 ``group``(细分类目名,与 §5 矩阵、§6「按细类价盘」小节一致)、``sku_count``、``price_stats``(该细类可解析展示价的 min/max/median/mean/n,与 §6 各细类 Markdown 分位数表同源)、 +每个 group 含 ``group``(细分类目名,与 第五章矩阵、第六章「按细类价盘」小节一致)、``sku_count``、``price_stats``(该细类可解析展示价的 min/max/median/mean/n,与第六章各细类 Markdown 分位数表同源)、 ``listing_snippets``(若干「标题|标价|券后|详情价」摘录,来自合并表字段,已截断)。 请**为每个细类**输出一小段 Markdown(全部 groups 都要写,顺序与输入一致): - 以 ``#### `` + 与该 group 字段**完全一致**的细类名作为小节标题; - 每段约 **80~200 字**中文,**只写价盘与价差**:用 ``price_stats`` 概括价带/离散度(如 min~max、中位数、相对集中或拉得开);用 ``listing_snippets`` 归纳**标价 vs 券后 vs 详情价**是否常一致、是否常见券后低于标价、价差幅度的大致印象;**可一句**联系标题里**显式出现的规格数字**(如克重、件数)解释**价高/价差大是否可能来自大规格或组合装**——仅当摘录里确有数字时写,勿展开成宣称解读; -- **硬性禁止**(本章不是卖点章):不要列举或归纳「0 蔗糖 / 低 GI / 全麦 / 代餐 / 孕妇 / 控糖」等**营销宣称或场景关键词**;不要写配料、功效、品牌叙事、用户画像;这些若出现应留给报告 **§5 细类要点归纳**。 +- **硬性禁止**(本章不是卖点章):不要列举或归纳「0 蔗糖 / 低 GI / 全麦 / 代餐 / 孕妇 / 控糖」等**营销宣称或场景关键词**;不要写配料、功效、品牌叙事、用户画像;这些若出现应留给报告 **第五章细类要点归纳**。 - **禁止** Markdown 表格、禁止罗列全部 SKU;勿编造未出现的到手价、销量排名; - 若 ``price_stats`` 中 n=0 或缺失,写「该细类无可解析数值价,从略」。 @@ -668,13 +668,13 @@ def generate_price_group_summaries_llm( PROMO_GROUPS_SYSTEM = """你是电商促销与价盘顾问。输入为 JSON:``keyword`` 与 ``groups``。 -每个 group 含 ``group``(细分类目名,与 §5 矩阵、§6 一致)、``sku_count``、``rows_with_buyer_promo_text``(该细类合并表中「促销摘要」非空行数)、 -``promo_snippets``(若干条摘录:标题 + 促销摘要/购买者侧榜单/列表卖点与腰带等,已截断)。 +每个 group 含 ``group``(细分类目名,与第五章矩阵、第六章一致)、``sku_count``、``rows_with_buyer_promo_text``(该细类合并表中「促销摘要」非空行数)、 +``promo_snippets``(若干条摘录:标题 + 促销摘要/榜单排名/榜单类文案等,已截断;**不含**列表卖点/腰带列)。 请**为每个细类**输出一小段 Markdown(全部 groups 都要写,顺序与输入一致): - 以 ``#### `` + 与该 group 字段**完全一致**的细类名作为小节标题; -- 每段约 **80~220 字**中文,**只写促销与活动形态**:如券后/满减/百亿补贴/新人包邮/限购提示/「到手价」展示方式、与 **§6.1** 规则统计可对照的**活动话术密度**印象;可一句点出**榜单/腰带**是否常见、是否与价格带并存; -- **硬性禁止**:不要展开配料、功效、用户画像;不要复述 §5 的配料归纳;不要编造未在摘录中出现的具体金额或活动规则; +- 每段约 **80~220 字**中文,**只写促销与活动形态**:如券后/满减/百亿补贴/新人包邮/限购提示/「到手价」展示方式、与 **第六章第一节** 规则统计可对照的**活动话术密度**印象;可一句点出**榜单曝光**是否常见、是否与价格带并存; +- **硬性禁止**:不要展开配料、功效、用户画像;不要复述第五章 的配料归纳;不要编造未在摘录中出现的具体金额或活动规则; - 若该细类 ``rows_with_buyer_promo_text`` 为 0 且摘录几乎只有标题,写「该细类缺少购买者侧促销摘要,从略」。 总输出约 **500~2800 字**。仅输出正文 Markdown,不要用代码围栏包裹全文。""" @@ -682,7 +682,7 @@ PROMO_GROUPS_SYSTEM = """你是电商促销与价盘顾问。输入为 JSON:`` PROMO_GROUPS_USER_PREFIX = ( "请根据以下 JSON 撰写竞品报告第六章「细类促销与活动要点归纳」正文(Markdown)。" - "依据 ``promo_snippets`` 中的促销摘要与列表文案,**不写**价带分位数(留给上一小节)。\n\n" + "依据 ``promo_snippets`` 中的促销摘要与榜单相关摘录,**不写**价带分位数(留给上一小节)。\n\n" ) @@ -785,7 +785,7 @@ def generate_scenario_group_summaries_llm_chunked( return _join_chunked_group_markdown(parts) -STRATEGY_OPPORTUNITIES_SYSTEM = """你是 B 端市场与增长顾问。输入 JSON 含 ``keyword``、``competitor_brief``(与本任务规则报告同源的结构化摘要,可能经裁剪,并含 ``matrix_overview_for_llm``),以及可选 ``prior_chapter_llm_narratives``(本报告 **§5~§8** 已生成的大模型归纳节选,与正文**同源**)。 +STRATEGY_OPPORTUNITIES_SYSTEM = """你是 B 端市场与增长顾问。输入 JSON 含 ``keyword``、``competitor_brief``(与本任务规则报告同源的结构化摘要,可能经裁剪,并含 ``matrix_overview_for_llm``),以及可选 ``prior_chapter_llm_narratives``(本报告 **第五至第八章** 已生成的大模型归纳节选,与正文**同源**)。 请输出 **Markdown 正文**(不要用 ``` 围栏包裹),将**直接嵌入**宿主文档中**已存在章节标题之下**的小节,读者已知当前处于「策略与机会」相关章节。 @@ -793,7 +793,7 @@ STRATEGY_OPPORTUNITIES_SYSTEM = """你是 B 端市场与增长顾问。输入 JS - **定性主题**(各细类讨论焦点、正负向体验、场景与关注词归纳、配料/卖点叙事、促销形态描述等)须与 ``prior_chapter_llm_narratives`` 中已出现的表述**方向一致**,**禁止**另写一套与节选**明显矛盾**的品类判断、品牌举例或用户痛点主题。 - **定量与可核验事实**(价带分位数、店铺/品牌占比、条数、评论统计字段等)**以** ``competitor_brief`` **为准**;若节选与 brief 数字冲突,**采纳 brief**,且勿复述与数字冲突的节选句。 - 若某键未出现在 ``prior_chapter_llm_narratives`` 或内容为空,则该维度**不得**编造与可能存在的报告其他章冲突的细节;仅依据 ``competitor_brief`` 或明确写「输入中未体现」。 -- **转化与体验**小节:正负向体验线索须**优先呼应** ``sec8_2_sentiment_theme_attribution`` 与 **§8.3 侧**节选(``sec8_3_comment_focus_summaries`` 或 ``sec8_3_text_mining_probe``,视何者存在);**禁止**将节选未提及的具体抱怨/品类问题写成**主要结论**;可写「假设:待结合业务验证」。 +- **转化与体验**小节:正负向体验线索须**优先呼应** ``sec8_2_sentiment_theme_attribution`` 与 **第八章第三节 侧**节选(``sec8_3_comment_focus_summaries`` 或 ``sec8_3_text_mining_probe``,视何者存在);**禁止**将节选未提及的具体抱怨/品类问题写成**主要结论**;可写「假设:待结合业务验证」。 **标题与措辞(硬性)**: - **禁止**在正文开头或任何位置重复宿主已有章名/小节名,包括但不限于:「第九章」「第9章」「九、」「策略与机会提示」「策略与机会建议」「策略与机会」等;**不要**自造 ``##`` 一级标题; @@ -803,7 +803,7 @@ STRATEGY_OPPORTUNITIES_SYSTEM = """你是 B 端市场与增长顾问。输入 JS - **数字与事实**:价格分位数、集中度份额、条数、占比等**只能**来自 ``competitor_brief`` 中已有字段;**禁止编造**未出现的品牌销量、具体 GMV、未给出的到手价; - **语气**:分节给出**可操作的假设性建议**(定价区间思路、应对齐的差异化观测点、应规避的风险、促销与机制设计线索、转化与详情页/评价侧改进方向),每条建议用「假设:」「待验证:」等标明不确定性; - **结构**:至少使用 ``####`` 组织以下主题(可合并子条,但须覆盖):**定价与价带**、**差异化与应对齐的优势**、**风险与避免项**、**促销与活动机制**、**转化与体验**; -- **促销与活动机制(硬性)**:该节**必须优先依据** ``competitor_brief.price_promotion_signals``(若存在),并与 ``prior_chapter_llm_narratives.sec6_promo_group_summaries``(若有)**不矛盾**:对 ``promo_keyword_row_hits_top`` 中**已出现**的活动话术类型逐类给出**假设性**机制建议。**禁止**编造具体满减门槛、红包面额、补贴比例;**禁止**在输入中完全未出现任何列表侧活动话术或价差信号时,仍写一大段具体「要做满减发红包」而无「输入中未捕获此类信号」的说明。 +- **促销与活动机制(硬性)**:该节**必须优先依据** ``competitor_brief.price_promotion_signals``(券后/标价、价差等,若存在),并与 ``prior_chapter_llm_narratives.sec6_promo_group_summaries``(若有)**不矛盾**,给出**假设性**机制建议。**禁止**编造具体满减门槛、红包面额、补贴比例;**禁止**在输入中完全未出现任何列表侧价差或促销归纳信号时,仍写一大段具体「要做满减发红包」而无「输入中未捕获此类信号」的说明。 - **转化与体验(硬性)**:须**同时**写清正向与负向;**禁止**使用「占比均超过 130 次」等**语义不通或混用次数/占比**的表述;数字表述须与 ``competitor_brief`` 一致。 - **禁止**:不要写完整报告目录;不要复述「研究范围与方法」;不要使用 CR1/CR3 缩写(用「第一大……份额」「前三家合计」);不要输出与输入矛盾的价带描述。 @@ -812,7 +812,7 @@ STRATEGY_OPPORTUNITIES_SYSTEM = """你是 B 端市场与增长顾问。输入 JS STRATEGY_OPPORTUNITIES_USER_PREFIX = ( "请根据以下 JSON 撰写策略归纳正文(Markdown)。" - "``competitor_brief`` 为结构化摘要;若含 ``prior_chapter_llm_narratives``,则为 §5~§8 大模型归纳节选,须与策略正文对齐。" + "``competitor_brief`` 为结构化摘要;若含 ``prior_chapter_llm_narratives``,则为 第五至第八章 大模型归纳节选,须与策略正文对齐。" "宿主报告已含章节标题,**勿在输出中写第九章或「策略与机会」类标题**。\n\n" ) @@ -852,6 +852,32 @@ def _strategy_prompt_fits_context(system: str, user: str) -> bool: return est < ctx - buf - 256 +def _strategy_completion_avail_tokens(system: str, user: str) -> int: + """ + 与 ``AI_crawler.chat_completion_text`` 中 ``avail = context_window - input_est - buf`` 一致, + 即本次调用实际可用于 **completion** 的上限(随后还会与 ``max_tokens`` 取 min)。 + 若该值过小,长文会在句中被截断(例如「转化与体验」末段不完整)。 + """ + est = _estimate_chat_input_tokens(system, user) + ctx = _llm_context_window_limit() + buf = 256 + return ctx - est - buf + + +def _min_strategy_completion_tokens() -> int: + raw = (os.environ.get("MA_STRATEGY_MIN_COMPLETION_TOKENS") or "2048").strip() + try: + return max(256, int(raw)) + except ValueError: + return 2048 + + +def _strategy_prompt_ok_for_call(system: str, user: str, *, min_completion_tokens: int) -> bool: + return _strategy_prompt_fits_context( + system, user + ) and _strategy_completion_avail_tokens(system, user) >= min_completion_tokens + + def generate_strategy_opportunities_llm( brief: dict[str, Any], *, @@ -861,7 +887,7 @@ def generate_strategy_opportunities_llm( """ 基于 ``build_competitor_brief`` 全量摘要,生成策略与机会小节正文(不含章名,由宿主 Markdown 加标题)。 - ``chapter_llm_narratives`` 为与本报告 §5~§8 同源的大模型正文节选,键名稳定(见 runner 传入),用于与策略段严格对齐。 + ``chapter_llm_narratives`` 为与本报告 第五至第八章 同源的大模型正文节选,键名稳定(见 runner 传入),用于与策略段严格对齐。 """ narr_in = { k: v @@ -873,6 +899,10 @@ def generate_strategy_opportunities_llm( def _user_from_payload(p: dict[str, Any]) -> str: return STRATEGY_OPPORTUNITIES_USER_PREFIX + json.dumps(p, ensure_ascii=False) + min_comp = _min_strategy_completion_tokens() + # 极长输入时仅「未超限」仍会把 completion 压到几百 token,正文会在小节末被截断;须同时满足最小输出预算。 + min_comp_relaxed = max(256, min_comp // 2) + # 按「字符上限」递减尝试;最终以 token 估算为准(与 AI_crawler 一致),避免 JSON 仍超长导致整段失败。 for cap_brief, cap_narr in ( (48_000, 2_800), @@ -882,6 +912,10 @@ def generate_strategy_opportunities_llm( (26_000, 950), (22_000, 700), (18_000, 500), + (16_000, 400), + (14_000, 320), + (12_000, 260), + (10_000, 200), ): compact = compact_brief_for_llm(brief, max_chars=cap_brief) narratives = { @@ -894,18 +928,25 @@ def generate_strategy_opportunities_llm( if narratives: payload["prior_chapter_llm_narratives"] = narratives user = _user_from_payload(payload) - if _strategy_prompt_fits_context(sys_prompt, user): + if _strategy_prompt_ok_for_call(sys_prompt, user, min_completion_tokens=min_comp): return _call_llm(sys_prompt, user) # 去掉各章节选,仅保留 brief - for cap_brief in (40_000, 32_000, 26_000, 20_000, 16_000): + for cap_brief in (40_000, 32_000, 26_000, 20_000, 16_000, 14_000, 12_000, 10_000): compact = compact_brief_for_llm(brief, max_chars=cap_brief) payload = {"keyword": keyword, "competitor_brief": compact} user = _user_from_payload(payload) - if _strategy_prompt_fits_context(sys_prompt, user): + if _strategy_prompt_ok_for_call(sys_prompt, user, min_completion_tokens=min_comp): return _call_llm(sys_prompt, user) - compact = compact_brief_for_llm(brief, max_chars=14_000) + for cap_brief in (14_000, 12_000, 10_000, 8_000): + compact = compact_brief_for_llm(brief, max_chars=cap_brief) + payload = {"keyword": keyword, "competitor_brief": compact} + user = _user_from_payload(payload) + if _strategy_prompt_ok_for_call(sys_prompt, user, min_completion_tokens=min_comp_relaxed): + return _call_llm(sys_prompt, user) + + compact = compact_brief_for_llm(brief, max_chars=8_000) payload = {"keyword": keyword, "competitor_brief": compact} user = _user_from_payload(payload) return _call_llm(sys_prompt, user) diff --git a/backend/pipeline/reporting/md_document_export.py b/backend/pipeline/reporting/md_document_export.py index 249f0c3..1e408da 100644 --- a/backend/pipeline/reporting/md_document_export.py +++ b/backend/pipeline/reporting/md_document_export.py @@ -23,9 +23,26 @@ def _is_table_sep(line: str) -> bool: return bool(inner) and all(p in ("", "---", ":---", "---:", ":---:") for p in t.split("|")) +_RE_HEADING = re.compile(r"^(#{1,6})\s+(.+)$") +_RE_UL = re.compile(r"^\s*[-*+]\s+(.+)$") +_RE_OL = re.compile(r"^\s*(\d+)\.\s+(.+)$") +_RE_BLOCKQUOTE = re.compile(r"^\s*>\s?(.*)$") +_RE_HR = re.compile(r"^\s*(?:[-*_]\s*){3,}\s*$") + _img_line = re.compile(r"^!\[([^\]]*)\]\(([^)]+)\)\s*$") +def _match_heading(line: str) -> tuple[int, str] | None: + """返回 (docx level 0–8, 标题文本) 或 None。""" + m = _RE_HEADING.match(line.strip()) + if not m: + return None + depth = len(m.group(1)) + title = _strip_inline_md(m.group(2).strip()) + level = min(max(depth - 1, 0), 8) + return (level, title) + + def markdown_to_docx_bytes(md: str, *, asset_root: Path | None = None) -> bytes: from docx import Document from docx.enum.text import WD_PARAGRAPH_ALIGNMENT @@ -39,6 +56,20 @@ def markdown_to_docx_bytes(md: str, *, asset_root: Path | None = None) -> bytes: except Exception: pass + def _add_list_bullet(text: str) -> None: + t = _strip_inline_md(text) + try: + doc.add_paragraph(t, style="List Bullet") + except KeyError: + doc.add_paragraph("• " + t) + + def _add_list_number(text: str) -> None: + t = _strip_inline_md(text) + try: + doc.add_paragraph(t, style="List Number") + except KeyError: + doc.add_paragraph(t) + lines = (md or "").replace("\r\n", "\n").split("\n") i = 0 in_fence = False @@ -62,22 +93,18 @@ def markdown_to_docx_bytes(md: str, *, asset_root: Path | None = None) -> bytes: doc.add_paragraph("") i += 1 continue - if line.startswith("# "): - doc.add_heading(_strip_inline_md(line[2:].strip()), level=0) + + if _RE_HR.match(line): + doc.add_paragraph("") i += 1 continue - if line.startswith("## "): - doc.add_heading(_strip_inline_md(line[3:].strip()), level=1) - i += 1 - continue - if line.startswith("### "): - doc.add_heading(_strip_inline_md(line[4:].strip()), level=2) - i += 1 - continue - if line.startswith("#### "): - doc.add_heading(_strip_inline_md(line[5:].strip()), level=3) + + hm = _match_heading(line) + if hm is not None: + doc.add_heading(hm[1], level=hm[0]) i += 1 continue + mimg = _img_line.match(line.strip()) if mimg and asset_root is not None: rel = mimg.group(2).strip() @@ -92,6 +119,7 @@ def markdown_to_docx_bytes(md: str, *, asset_root: Path | None = None) -> bytes: doc.add_picture(str(img_path), width=Inches(5.9)) i += 1 continue + if line.strip().startswith("|"): rows: list[list[str]] = [] while i < len(lines) and lines[i].strip().startswith("|"): @@ -112,6 +140,28 @@ def markdown_to_docx_bytes(md: str, *, asset_root: Path | None = None) -> bytes: tbl.rows[ri].cells[ci].text = cell continue + mu = _RE_UL.match(line) + if mu: + _add_list_bullet(mu.group(1)) + i += 1 + continue + + mo = _RE_OL.match(line) + if mo: + _add_list_number(mo.group(2)) + i += 1 + continue + + mq = _RE_BLOCKQUOTE.match(line) + if mq: + inner = mq.group(1).strip() + if inner: + p = doc.add_paragraph() + p.paragraph_format.left_indent = Inches(0.25) + p.add_run(_strip_inline_md(inner)) + i += 1 + continue + p = doc.add_paragraph() p.alignment = WD_PARAGRAPH_ALIGNMENT.LEFT text = _strip_inline_md(line) @@ -171,13 +221,14 @@ def _pdf_flowable_image(img_path: Path, *, max_w: float, max_h: float) -> Any: def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes: - """简易纯文本流式 PDF;需本机 .ttf 中文字体或环境变量 MA_PDF_FONT。""" + """简易 PDF;需本机 .ttf 中文字体或环境变量 MA_PDF_FONT。""" + from reportlab.lib import colors from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet from reportlab.lib.units import cm from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont - from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer + from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle font_name = "MaExportCJK" registered = False @@ -226,22 +277,89 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes: leading=17, spaceAfter=6, ) + h3s = ParagraphStyle( + name="H3CJK", + parent=body, + fontSize=12, + leading=16, + spaceAfter=5, + ) + h4s = ParagraphStyle( + name="H4CJK", + parent=body, + fontSize=11, + leading=15, + spaceAfter=4, + ) + h56s = ParagraphStyle( + name="H56CJK", + parent=body, + fontSize=10.5, + leading=14, + spaceAfter=3, + ) + quote_style = ParagraphStyle( + name="QuoteCJK", + parent=body, + leftIndent=14, + fontSize=9.5, + textColor=colors.HexColor("#444444"), + ) + bullet_body = ParagraphStyle( + name="BulletBodyCJK", + parent=body, + leftIndent=18, + bulletIndent=8, + firstLineIndent=0, + ) story: list[Any] = [] lines = (md or "").replace("\r\n", "\n").split("\n") + i = 0 in_fence = False - for raw in lines: + + def _para_cell(s: str, style: Any) -> Paragraph: + return Paragraph(xml_escape(_strip_inline_md(s)), style) + + while i < len(lines): + raw = lines[i] if raw.strip().startswith("```"): in_fence = not in_fence + i += 1 continue s = raw.rstrip() if in_fence: story.append(Paragraph(xml_escape(s or " "), body)) story.append(Spacer(1, 0.1 * cm)) + i += 1 continue if not s.strip(): story.append(Spacer(1, 0.15 * cm)) + i += 1 continue + + if _RE_HR.match(s): + story.append(Spacer(1, 0.2 * cm)) + i += 1 + continue + + hm = _match_heading(s) + if hm is not None: + level, title = hm + title_esc = xml_escape(title) + if level == 0: + story.append(Paragraph(title_esc, h1s)) + elif level == 1: + story.append(Paragraph(title_esc, h2s)) + elif level == 2: + story.append(Paragraph(title_esc, h3s)) + elif level == 3: + story.append(Paragraph(title_esc, h4s)) + else: + story.append(Paragraph(title_esc, h56s)) + i += 1 + continue + mimg = _img_line.match(s.strip()) if mimg and asset_root is not None: rel = mimg.group(2).strip() @@ -250,28 +368,83 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes: try: img_path.relative_to(asset_root.resolve()) except ValueError: + i += 1 continue if img_path.is_file(): - # 版面可用高度需小于正文框(A4 减边距后约 24.6cm),否则 ReportLab 报 LayoutError story.append( _pdf_flowable_image( img_path, max_w=13 * cm, max_h=24 * cm ) ) story.append(Spacer(1, 0.2 * cm)) + i += 1 continue + + if s.strip().startswith("|"): + rows: list[list[str]] = [] + while i < len(lines) and lines[i].strip().startswith("|"): + row_line = lines[i].strip() + if _is_table_sep(row_line): + i += 1 + continue + cells = [c.strip() for c in row_line.strip("|").split("|")] + rows.append([_strip_inline_md(c) for c in cells]) + i += 1 + if rows: + max_cols = max(len(r) for r in rows) + pad_rows = [r + [""] * (max_cols - len(r)) for r in rows] + usable_w = 13 * cm + col_w = usable_w / float(max_cols) + data: list[list[Any]] = [] + for row in pad_rows: + data.append( + [_para_cell(c, body) for c in row] + ) + t = Table(data, colWidths=[col_w] * max_cols) + t.setStyle( + TableStyle( + [ + ("GRID", (0, 0), (-1, -1), 0.5, colors.grey), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("LEFTPADDING", (0, 0), (-1, -1), 4), + ("RIGHTPADDING", (0, 0), (-1, -1), 4), + ("TOPPADDING", (0, 0), (-1, -1), 3), + ("BOTTOMPADDING", (0, 0), (-1, -1), 3), + ] + ) + ) + story.append(t) + story.append(Spacer(1, 0.15 * cm)) + continue + + mu = _RE_UL.match(s) + if mu: + txt = xml_escape(_strip_inline_md(mu.group(1))) + story.append(Paragraph(f"• {txt}", bullet_body)) + i += 1 + continue + + mo = _RE_OL.match(s) + if mo: + n, rest = mo.group(1), mo.group(2) + txt = xml_escape(_strip_inline_md(rest)) + story.append(Paragraph(f"{n}. {txt}", bullet_body)) + i += 1 + continue + + mq = _RE_BLOCKQUOTE.match(s) + if mq: + inner = mq.group(1).strip() + if inner: + story.append( + Paragraph(xml_escape(_strip_inline_md(inner)), quote_style) + ) + i += 1 + continue + plain = _strip_inline_md(s) - text = xml_escape(plain) - if s.startswith("# "): - story.append(Paragraph(xml_escape(plain[2:]), h1s)) - elif s.startswith("## "): - story.append(Paragraph(xml_escape(plain[3:]), h2s)) - elif s.startswith("### "): - story.append(Paragraph(xml_escape(plain[4:]), body)) - elif s.strip().startswith("|"): - story.append(Paragraph(text.replace("|", " │ "), body)) - else: - story.append(Paragraph(text, body)) + story.append(Paragraph(xml_escape(plain), body)) + i += 1 buf = BytesIO() doc = SimpleDocTemplate( diff --git a/backend/pipeline/views.py b/backend/pipeline/views.py index 746de94..46fc01c 100644 --- a/backend/pipeline/views.py +++ b/backend/pipeline/views.py @@ -757,7 +757,7 @@ def _read_page_params(request) -> tuple[int, int]: def _report_group_options_for_job(job: PipelineJob) -> list[str]: - """类目选项:与 §5 矩阵一致,来自合并表 ``detail_category_path`` 解析。""" + """类目选项:与第五章矩阵一致,来自合并表商品详情页类目路径解析。""" qs = ( JdJobMergedRow.objects.filter(job=job) .exclude(matrix_group_label="") diff --git a/backend/requirements.txt b/backend/requirements.txt index 2c5325a..327a605 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -9,7 +9,7 @@ reportlab>=4.0 matplotlib>=3.8 playwright>=1.40 -# 第八章文本挖掘探针(默认写入竞品报告 §8.3 时需安装) +# 第八章评论文本补充分析(默认写入竞品报告第八章第三节时需安装) jieba>=0.42 scikit-learn>=1.4 numpy>=1.26