mirror of
https://github.com/primedigitaltech/market-assistant.git
synced 2026-07-21 23:41:39 +08:00
feat(pipeline): 报告文案通俗化与 Markdown 导出可读性优化
- 竞品报告与流水线模板:弱化技术表述,促销与策略相关说明同步调整。 - md_document_export:按标题层级解析 #~######,列表与引用去掉行首符号,PDF 使用表格组件与分级标题样式。 Made-with: Cursor
This commit is contained in:
parent
b7c74f942c
commit
7ed160235b
@ -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,
|
||||
"",
|
||||
|
||||
@ -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 分词 + 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""
|
||||
f""
|
||||
)
|
||||
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="文末调用探针专用 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",
|
||||
|
||||
@ -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``(恢复单次打包调用)。
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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="")
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user