feat(pipeline): 报告文案通俗化与 Markdown 导出可读性优化

- 竞品报告与流水线模板:弱化技术表述,促销与策略相关说明同步调整。

- md_document_export:按标题层级解析 #~######,列表与引用去掉行首符号,PDF 使用表格组件与分级标题样式。

Made-with: Cursor
This commit is contained in:
hub-gif 2026-04-17 10:55:51 +08:00
parent b7c74f942c
commit 7ed160235b
7 changed files with 390 additions and 254 deletions

View File

@ -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.33.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.33.4 无列表侧数据。*"
"*未读到可用的搜索列表导出或文件为空;第三章第三、四节无列表侧数据。*"
)
lines.append("")
lines.extend(["### 3.4 列表端展示价(全导出)", "", "*无列表数据。*", ""])
@ -2466,7 +2390,7 @@ def build_competitor_markdown(
[
"### 3.5 外部市场规模与行业信息(运行配置摘录)",
"",
"以下为本次任务报告调参中维护的**第三方市场摘录**,可与 §3.2 检索规模及 §3.33.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,
"",

View File

@ -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 @@
输出默认写入 ``<run_dir>/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 分词 + sklearnTF-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`` 字段**完全一致**的细类名作为小节标题(勿用 ``##`` 一级标题);每段约 **100260 字**。
- 内容须包含 **12 **概括该细类评论**主要讨论焦点**综合词频与 TF-IDF**不要罗列具体数字** **12 **说明共现词对**暗示**哪些维度常一起出现**非因果** ``lda.topics`` 非空**12 **说明主题粗分侧重点**明确** LDA 无监督****与矩阵细类一一对应 **12 **体现 ``sample_text_snippets`` / ``focus_hit_lines`` 中的**用户语气与关切****转述**为主若必须引用原文**全小节合计** **一处**极短引号内容**40 ****不要**输出 ``细类SKU店铺`` 等长前缀若无可用摘录则写明 **使用场景仅从评论推断** **02 **概括**何时何地何人如何搭配**如早餐加餐控糖人群配牛奶等**只能**依据本细类 ``word_freq``/``tfidf``/``cooccurrence``/``lda`` 与摘录中**已出现或可合理概括**的信息**禁止**套用正式报告场景分组或其它外部场景分类若统计与摘录中**均无**场景线索**一句**写明评论中未体现清晰使用场景即可
- 内容须包含 **12 **概括该细类评论**主要讨论焦点**综合词频与关键词突出度**不要罗列具体数字** **12 **说明共现词对**暗示**哪些维度常一起出现**非因果** ``lda.topics`` 非空**12 **说明主题粗分侧重点**明确**算法无监督****与矩阵细类一一对应 **12 **体现 ``sample_text_snippets`` / ``focus_hit_lines`` 中的**用户语气与关切****转述**为主若必须引用原文**全小节合计** **一处**极短引号内容**40 ****不要**输出 ``细类SKU店铺`` 等长前缀若无可用摘录则写明 **使用场景仅从评论推断** **02 **概括**何时何地何人如何搭配**如早餐加餐控糖人群配牛奶等**只能**依据本细类 ``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 局限小样本细类不可靠
全文末可另起一段 **使用注意**简短点明开放词表统计与人工阅读差异主题归纳局限小样本细类不可靠
总字数约 **8004500 **细类多则偏长仅输出正文 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 路径(默认 <run_dir>/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="文末调用探针专用 LLMPROBE_TEXT_MINING_SYSTEM + 结构化 JSON需 AI_crawler 等)",
help="文末调用补充分析专用 LLMPROBE_TEXT_MINING_SYSTEM + 结构化 JSON需 AI_crawler 等)",
)
ap.add_argument(
"--llm-chunked",
action="store_true",
help="按细类拆分多次调用同一探针提示词,防单次 JSON 过长",
help="按细类拆分多次调用同一补充分析提示词,防单次 JSON 过长",
)
ap.add_argument(
"--no-wordcloud",

View File

@ -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.28.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.28.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``恢复单次打包调用

View File

@ -41,7 +41,7 @@ REPORT_SYSTEM = """你是业务与产品读者顾问。输入 JSON 含 `keyword`
`matrix_overview_for_llm`按细分类目的 SKU 数与品牌样本
你的输出将**嵌入在规则报告第八章末**作为### 8.5 …」的正文,系统已加小节标题与说明),**紧接在**
消费者反馈 §8.18.3 **之后**第九章策略**之前**因此写的是**具体分析型补充**不是篇首速读块
消费者反馈第八章第一至三节**之后**第九章策略**之前**因此写的是**具体分析型补充**不是篇首速读块
所有数字占比条数品牌名价格区间等**必须严格来自输入 JSON**禁止编造未在输入中出现的定量结论
@ -52,17 +52,17 @@ REPORT_SYSTEM = """你是业务与产品读者顾问。输入 JSON 含 `keyword`
- **不要**撰写 Markdown 表格版竞品对比矩阵或罗列 SKU 明细**正文已含矩阵**此处只做分组级语义归纳
- **不要使用** CR1CR3 等集中度缩写作主表述集中度请用第一大店铺/品牌份额前三家合计份额输入中的英文字段名勿照抄进正文请写成中文业务用语
**请输出**仅输出将置于 §8.5 下的正文不要自造### 8.5」标题行):
**请输出**仅输出将置于 8.5 小节 下的正文不要自造### 8.5」标题行):
- **Markdown** **8001500 **
- 建议用 ``####`` 组织:**执行摘要级要点**、**竞争与价盘**、**用户声量与负向事由**(须归纳用户在抱怨什么类型的问题,而非只堆关键词)、**后续可验证动作(假设)**(不写第九章目录或重复策略章内容);
- 若有 `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`` 表示**先按 15 星分桶**无评分行再按关键词``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 字段**完全一致**的细类名作为小节标题(不要使用 ``##`` 一级标题);
- 每段约 **100200 **中文**主体**归纳该细类下**卖点表述共性****配料类型/宣称共性**摘录中无配料则写配料摘录较少**品牌格局**可一句概括仅依据摘录中可见品牌/系列勿编造销量排名
- **价带/价位** ``price_stats.n`` 为大于 0 的整数**仅允许**用该对象里的数值写价带 minmax中位数且须与 ``price_stats`` **完全一致****禁止**价格带未明确未体现具体价位多为中端**与上述数值相矛盾**的表述 n=0 或无可信数值**不要猜测价位**可写一句深入样本可解析数值价不足价盘以 **§6** 表格为准
- **价带/价位** ``price_stats.n`` 为大于 0 的整数**仅允许**用该对象里的数值写价带 minmax中位数且须与 ``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 字段**完全一致**的细类名作为小节标题;
- 每段约 **80200 **中文**只写价盘与价差** ``price_stats`` 概括价带/离散度 minmax中位数相对集中或拉得开 ``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 字段**完全一致**的细类名作为小节标题;
- 每段约 **80220 **中文**只写促销与活动形态**如券后/满减/百亿补贴/新人包邮/限购提示/到手价展示方式 **§6.1** 规则统计可对照的**活动话术密度**印象可一句点出**榜单/腰带**是否常见是否与价格带并存
- **硬性禁止**不要展开配料功效用户画像不要复述 §5 的配料归纳不要编造未在摘录中出现的具体金额或活动规则
- 每段约 **80220 **中文**只写促销与活动形态**如券后/满减/百亿补贴/新人包邮/限购提示/到手价展示方式 **第六章第一节** 规则统计可对照的**活动话术密度**印象可一句点出**榜单曝光**是否常见是否与价格带并存
- **硬性禁止**不要展开配料功效用户画像不要复述第五章 的配料归纳不要编造未在摘录中出现的具体金额或活动规则
- 若该细类 ``rows_with_buyer_promo_text`` 0 且摘录几乎只有标题该细类缺少购买者侧促销摘要从略
总输出约 **5002800 **仅输出正文 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)

View File

@ -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 08, 标题文本) 或 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(

View File

@ -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="")

View File

@ -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