feat(pipeline): 按细类生成评价正/负向主题并写入报告8.3

开启 llm_comment_sentiment 时对每个矩阵细类单独构建情感载荷并调用模型;报告新增 8.3 小节,与探针及细类评论要点归纳并列。补充 jd_report 对 build_comment_sentiment_llm_payload 的导出、comment_sentiment_llm.json schema_version 2 与 groups 记录。前端表单说明与演示脚本注释同步更新。

Made-with: Cursor
This commit is contained in:
hub-gif 2026-04-21 11:07:03 +08:00
parent 396ccd941a
commit d3dec53a8f
6 changed files with 173 additions and 456 deletions

View File

@ -58,21 +58,15 @@ from pipeline.competitor_report.price_promo import ( # noqa: E402
_markdown_price_promotion_section, _markdown_price_promotion_section,
) )
from pipeline.competitor_report.comment_sentiment import ( # noqa: E402 from pipeline.competitor_report.comment_sentiment import ( # noqa: E402
_comment_keyword_hits,
_iter_comment_text_units,
_iter_comment_text_units_and_scores, _iter_comment_text_units_and_scores,
_merge_comment_previews,
_parse_comment_score, _parse_comment_score,
build_comment_sentiment_llm_payload,
) )
from pipeline.competitor_report.llm_group_payloads import ( # noqa: E402 from pipeline.competitor_report.llm_group_payloads import ( # noqa: E402
build_comment_groups_llm_payload, build_comment_groups_llm_payload,
build_matrix_groups_llm_payload, build_matrix_groups_llm_payload,
build_price_groups_llm_payload, build_price_groups_llm_payload,
build_promo_groups_llm_payload, build_promo_groups_llm_payload,
build_scenario_groups_llm_payload,
_comment_scenario_counts,
_group_keyword_hits,
_text_hits_scenario_triggers,
) )
from pipeline.competitor_report.matrix_group import ( # noqa: E402 from pipeline.competitor_report.matrix_group import ( # noqa: E402
_category_mix, _category_mix,
@ -100,13 +94,10 @@ from pipeline.competitor_report.matrix_md import ( # noqa: E402
) )
from pipeline.competitor_report.report_md_helpers import ( # noqa: E402 from pipeline.competitor_report.report_md_helpers import ( # noqa: E402
_embed_chart, _embed_chart,
_focus_scenario_combo_bar_filename,
_lines_4_reading_brand, _lines_4_reading_brand,
_lines_4_reading_shop, _lines_4_reading_shop,
_matrix_prices_sales_chart_filename, _matrix_prices_sales_chart_filename,
_mermaid_pie_focus_keywords,
_scenario_group_asset_slug, _scenario_group_asset_slug,
_scenario_summary_bullets,
_strategy_hints, _strategy_hints,
) )
from pipeline.competitor_report.run_context import ( # noqa: E402 from pipeline.competitor_report.run_context import ( # noqa: E402
@ -117,7 +108,7 @@ from pipeline.competitor_report.run_context import ( # noqa: E402
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 运行配置(按需改这里;与 pipeline.competitor_report.constants 中默认关注词等配合使用 # 运行配置(按需改这里;外部市场表等见 constants
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# KEYWORD京东 PC 搜索词;全量抓取时必填。「仅已有目录」模式下可留空,改从 run_meta / 目录名推断。 # KEYWORD京东 PC 搜索词;全量抓取时必填。「仅已有目录」模式下可留空,改从 run_meta / 目录名推断。
KEYWORD = "低GI" KEYWORD = "低GI"
@ -148,7 +139,7 @@ def build_competitor_markdown(
llm_strategy_opportunities_section_md: str | None = None, llm_strategy_opportunities_section_md: str | None = None,
chapter8_text_mining_probe_section_md: str | None = None, chapter8_text_mining_probe_section_md: str | None = None,
) -> str: ) -> str:
focus_words, scenario_groups, external_rows = resolve_report_tuning(report_config) (external_rows,) = resolve_report_tuning(report_config)
_ch8_probe_sec = (chapter8_text_mining_probe_section_md or "").strip() _ch8_probe_sec = (chapter8_text_mining_probe_section_md or "").strip()
sku_header = MERGED_FIELD_TO_CSV_HEADER["sku_id"] sku_header = MERGED_FIELD_TO_CSV_HEADER["sku_id"]
title_h = MERGED_FIELD_TO_CSV_HEADER["title"] title_h = MERGED_FIELD_TO_CSV_HEADER["title"]
@ -210,23 +201,6 @@ def build_competitor_markdown(
) )
promo_sig = _analyze_price_promotions(promo_rows) promo_sig = _analyze_price_promotions(promo_rows)
hits = _comment_keyword_hits(comment_rows, focus_words)
if not hits:
blob = _merge_comment_previews(merged_rows)
for w in focus_words:
if len(w) < 2:
continue
n = blob.count(w)
if n:
hits[w] += n
comment_texts, comment_scores = _iter_comment_text_units_and_scores(
comment_rows, merged_rows
)
scen_counts, scen_n_texts = _comment_scenario_counts(
comment_texts, scenario_groups
)
feedback_groups = _consumer_feedback_by_matrix_group( feedback_groups = _consumer_feedback_by_matrix_group(
merged_rows=merged_rows, merged_rows=merged_rows,
comment_rows=comment_rows, comment_rows=comment_rows,
@ -286,12 +260,11 @@ def build_competitor_markdown(
"", "",
"- **价格**:自页面「标价 / 券后价 / 详情价」等抽取的**展示价**,含促销与规格差异,**不等于**出厂价或成本。**第六章** 在具备可用的搜索列表导出时,优先以**列表全量**统计;否则使用**已深入 SKU** 的合并数据;**第六章第一节** 归纳标价与券后价差等**列表侧展示价差信号**(不对卖点/腰带字段做预设关键词扫描)。", "- **价格**:自页面「标价 / 券后价 / 详情价」等抽取的**展示价**,含促销与规格差异,**不等于**出厂价或成本。**第六章** 在具备可用的搜索列表导出时,优先以**列表全量**统计;否则使用**已深入 SKU** 的合并数据;**第六章第一节** 归纳标价与券后价差等**列表侧展示价差信号**(不对卖点/腰带字段做预设关键词扫描)。",
"- **品牌/店铺集中度(第四章)**:有列表全量时按列表行计店铺与品牌占比;无列表导出时按深入 SKU 合并表估算。", "- **品牌/店铺集中度(第四章)**:有列表全量时按列表行计店铺与品牌占比;无列表导出时按深入 SKU 合并表估算。",
"- **评价主题词**:对评价正文做**预设词表子串计数**,非分词主题模型,适合扫方向,**需抽样人工验证**。", "- **评价文本****不再**使用「预设关注词 / 预设场景词组」子串计数作为报告或简报主指标;**第八章第二节**以中文分词与开放词表统计(词频、关键词突出度、共现、主题探索等,可选词云及大模型解读)为主,结论须结合抽样原文理解。",
"- **用途/场景**:对每条评价独立判断是否命中预设场景词;一条可计入多个场景,统计的是「提及该场景的评价条数」而非用户数。",
( (
"- **用户画像(第八章)****不再**使用星级子集内**预设口语短语**条形图、正负面扇形图及同口径摘要;**第八章第二节**为分词、词频、共现与主题等**文本补充分析**(可选词云及大模型解读),结论须结合抽样原文理解" "- **用户画像(第八章)****不再**使用星级子集内**预设口语短语**条形图、正负面扇形图及同口径摘要;**第八章第二节**为上述**文本补充分析**(探针)"
if _ch8_probe_sec if _ch8_probe_sec
else "- **用户画像(第八章)**关注词与场景**仅按细类**以**同图左右并列**展示(左为关注词命中次数,右为场景占有效文本 **%**);见**第八章第二节**" else "- **用户画像(第八章)**若未启用探针,第二节仅说明方法并列出按细类评价条数;**请开启** `chapter8_text_mining_probe` 以生成开放词表统计与插图"
), ),
"- **细类划分(第五至第八章)****仅**依据合并表中的**商品详情页类目路径**;该信息缺失或无法读出细类名称的 SKU **不参与**竞品矩阵与按细类评价统计(相关评价条亦**不进入**按细类图表)。", "- **细类划分(第五至第八章)****仅**依据合并表中的**商品详情页类目路径**;该信息缺失或无法读出细类名称的 SKU **不参与**竞品矩阵与按细类评价统计(相关评价条亦**不进入**按细类图表)。",
"- **检索结果规模**:来自京东 PC 搜索返回的「结果条数」类指标,表示平台侧申报的匹配数量级,**不等于**动销、库存或独立 SKU 数。", "- **检索结果规模**:来自京东 PC 搜索返回的「结果条数」类指标,表示平台侧申报的匹配数量级,**不等于**动销、库存或独立 SKU 数。",
@ -384,17 +357,15 @@ def build_competitor_markdown(
exec_bullets.append( exec_bullets.append(
f"列表侧约 **{100.0 * float(sh):.0f}%** 可对齐行呈现「券后/到手」**低于**「标价」,展示价差中位数约 **{float(med):.1f}%****第六章第一节** 活动与话术摘录)。" f"列表侧约 **{100.0 * float(sh):.0f}%** 可对齐行呈现「券后/到手」**低于**「标价」,展示价差中位数约 **{float(med):.1f}%****第六章第一节** 活动与话术摘录)。"
) )
if multi_feedback_cat and (hits or scen_n_texts > 0): if multi_feedback_cat and n_cmt > 0:
exec_bullets.append( if _ch8_probe_sec:
"评价侧写(关注词、用途/场景)已按**第五章同一细类划分**分节,见**第八章第二节**(同图并列)。" exec_bullets.append(
) "评价侧写已按**第五章同一细类划分**呈现,见**第八章第二节**(文本挖掘探针)。"
elif hits: )
top3 = "".join(f"{w}」({n})" for w, n in hits.most_common(3)) else:
exec_bullets.append(f"评价侧写(词频):{top3}") exec_bullets.append(
if scen_n_texts > 0 and scen_counts and not multi_feedback_cat: "评价已按**第五章同一细类**归组;**第八章第二节**定量主题分析依赖探针,可在报告调参中开启 `chapter8_text_mining_probe`。"
top_s = scen_counts.most_common(4) )
frag = "".join(f"{lbl} **{n}** 条" for lbl, n in top_s)
exec_bullets.append(f"用途/场景(评价自述,可多选):{frag}(有效文本 **{scen_n_texts}** 条)。")
if api_rc is not None: if api_rc is not None:
exec_bullets.append( exec_bullets.append(
f"PC 搜索返回的检索结果规模约 **{api_rc:,}**(站内匹配条数量级,见第三章第二节;**不是**零售额或动销统计)。" f"PC 搜索返回的检索结果规模约 **{api_rc:,}**(站内匹配条数量级,见第三章第二节;**不是**零售额或动销统计)。"
@ -750,9 +721,9 @@ def build_competitor_markdown(
"- **细类划分**:与**第五章「竞品矩阵」**相同,**仅**依据合并表中的**商品详情页类目路径**解析为「饼干 / 西式糕点 / …」等(规则见第五章开头说明)。", "- **细类划分**:与**第五章「竞品矩阵」**相同,**仅**依据合并表中的**商品详情页类目路径**解析为「饼干 / 西式糕点 / …」等(规则见第五章开头说明)。",
"- **归因**:每条评价按其 SKU 对应到深入样本,再映射到该 SKU 所属细类SKU 不在合并表中的评价单独归入说明性分组;**在合并表中但该 SKU 缺少类目路径或读不出细类名称的,该评价不进入按细类统计**(与第五章**同一条排除规则**)。", "- **归因**:每条评价按其 SKU 对应到深入样本,再映射到该 SKU 所属细类SKU 不在合并表中的评价单独归入说明性分组;**在合并表中但该 SKU 缺少类目路径或读不出细类名称的,该评价不进入按细类统计**(与第五章**同一条排除规则**)。",
( (
"- **评论文本补充分析(第八章第二节)**:本任务已用中文分词与统计工具做了开放词表分析(词频、关键词突出度、词对共现、主题归纳等,可选词云),**不再**在报告中使用「星级子集内预设口语短语」条形图、正负面扇形图及同口径摘要;**不再**输出原「关注词次数 + 场景占比」左右并列条图。" "- **评论文本补充分析(第八章第二节)**:本任务已用中文分词与统计工具做了开放词表分析(词频、关键词突出度、词对共现、主题归纳等,可选词云),**不再**在报告中使用「星级子集内预设口语短语」条形图、正负面扇形图及同口径摘要;**不再**使用「预设关注词 + 预设场景词组」子串计数与并列条图。"
if _ch8_probe_sec if _ch8_probe_sec
else "- **关注词与使用场景(第八章第二节)**:对组内评价正文做关注词子串计数(左栏条形图);对每条有效文本独立扫描**本次任务生效的场景词组**(来自报告调参或系统默认),一条可属多场景,右栏为**占该细类有效文本比例 %**(多标签下可相加 **>** 100%)。二者在 **同一张图左右并列**,与第五章矩阵细类一一对应" else "- **评论文本补充分析(第八章第二节)**:本任务**未**嵌入探针正文;**不再**输出预设关注词/场景子串统计图。请在 `report_config` 中开启 `chapter8_text_mining_probe` 并重跑以生成开放词表统计与插图"
), ),
"", "",
] ]
@ -769,15 +740,12 @@ def build_competitor_markdown(
] ]
) )
else: else:
# 仅当未嵌入第八章第二节探针补充分析_ch8_probe_sec 为空)时:原「关注词 + 场景」条图与逐细类段落
lines.extend( lines.extend(
[ [
"### 8.2 关注词与使用场景(按细类", "### 8.2 评论文本补充分析(未启用探针时的占位",
"", "",
"每细类一张**左右并列图**(与报告附图文件夹中的 ``chart_focus_and_scenarios_bar__*.png`` 同源):" "> **说明**:本版本**不再**生成「预设关注词 + 预设场景」子串统计与 ``chart_focus_and_scenarios_bar__*.png``。"
"**左**为配置关注词子串命中次数(同一评价可出现多次,为次数而非去重条数);" "开放词表、词频、共现与主题等请开启 ``chapter8_text_mining_probe`` 后重跑。",
"**右**为预设场景词组命中占该细类有效文本比例 %(一条可属多场景;多柱比例可相加 **>** 100%)。"
"统计均基于评价正文(或兜底预览)子串规则,**不等于**购买动机调研结论。",
"", "",
] ]
) )
@ -785,73 +753,44 @@ def build_competitor_markdown(
lines.append("*无评价数据可归组。*") lines.append("*无评价数据可归组。*")
lines.append("") lines.append("")
else: else:
for gi, (gname, cr_g, texts_g) in enumerate(feedback_groups): for _gi, (gname, cr_g, texts_g) in enumerate(feedback_groups):
n_flat = len(cr_g) n_flat = len(cr_g)
lines.append(f"#### {gname}") lines.append(f"#### {gname}")
lines.append("") lines.append("")
lines.append( lines.append(
f"- **本细类逐条评价**{n_flat} 条;**用于统计的有效文本条数**{len(texts_g)}" f"- **本细类逐条评价**{n_flat} 条;**有效文本单元**{len(texts_g)}"
) )
lines.append("") lines.append("")
hits_g = _group_keyword_hits(cr_g, texts_g, focus_words=focus_words)
scen_g, scen_ng = _comment_scenario_counts(texts_g, scenario_groups)
has_focus = any(n > 0 for n in hits_g.values()) if hits_g else False
has_scen = scen_ng > 0 and any(n > 0 for n in scen_g.values())
if scen_ng <= 0:
lines.append("*该细类下无可用评价正文。*")
lines.append("")
continue
if has_focus or has_scen:
cap = (
f"{_md_cell(gname, 24)}」细类 · 关注词与使用场景(左:关注词命中次数;右:场景占有效文本 %"
f"有效文本 **{scen_ng}** 条)"
)
lines.extend(
_embed_chart(
run_dir,
_focus_scenario_combo_bar_filename(gname, gi),
cap,
)
)
else:
lines.append("*该细类无关注词命中且未命中预设场景词组。*")
lines.append("")
if has_scen:
for para in _scenario_summary_bullets(scen_g, scen_ng):
lines.append(para)
lines.append("")
elif scen_ng > 0:
lines.append("*未命中预设场景词组。*")
lines.append("")
_llm_sg = (llm_scenario_groups_section_md or "").strip()
if _llm_sg:
lines.extend(
[
"",
"#### 使用场景要点归纳(大模型,与第八章第二节右栏图表互补)",
"",
"> **说明**:与第八章第二节**相同**的预设场景词组与子串命中规则;**各场景条数与占比以正文图右栏为准**。",
"",
_llm_sg,
"",
]
)
_llm_cg = (llm_comment_groups_section_md or "").strip() _llm_cg = (llm_comment_groups_section_md or "").strip()
if _llm_cg: if _llm_cg:
lines.extend( lines.extend(
[ [
"", "",
"#### 细类评价与关注词要点归纳(大模型,与第八章第二节左栏图表互补", "#### 细类评论要点归纳(大模型,基于评价摘录)",
"", "",
"> **说明**归纳各细类反馈主题与配置关注词命中;**次数与第八章第二节图左栏以正文为准**", "> **说明**:仅依据输入中的评价短摘录与文本单元归纳,**不**依赖预设关注词表或场景触发词统计。",
"", "",
_llm_cg, _llm_cg,
"", "",
] ]
) )
_llm_sent = (llm_sentiment_section_md or "").strip()
if _llm_sent:
lines.extend(
[
"",
"### 8.3 评价正/负向主题(按细类 · 大模型)",
"",
"> **说明**:与 **8.2 评论文本补充分析(探针)** 及 **「细类评论要点归纳(大模型)」** 并列;每段仅使用该细类下评价原文做语境归纳(如正向体验主题、负向评价主题归因)。"
"**不替代**探针的开放词表与专用口径,也不替代「细类评论要点归纳」的写法与载荷。",
"",
_llm_sent,
"",
]
)
lines.extend(["---", "", "## 九、策略与机会提示(假设清单,待验证)", ""]) lines.extend(["---", "", "## 九、策略与机会提示(假设清单,待验证)", ""])
_llm_st = (llm_strategy_opportunities_section_md or "").strip() _llm_st = (llm_strategy_opportunities_section_md or "").strip()
if _llm_st: if _llm_st:
@ -917,7 +856,7 @@ def build_competitor_brief(
""" """
``build_competitor_markdown`` 共用**同一套统计规则**输出可 JSON 序列化的结构化竞品摘要**规则驱动** LLM ``build_competitor_markdown`` 共用**同一套统计规则**输出可 JSON 序列化的结构化竞品摘要**规则驱动** LLM
""" """
focus_words, scenario_groups, _ext = resolve_report_tuning(report_config) (_ext,) = resolve_report_tuning(report_config)
sku_header = MERGED_FIELD_TO_CSV_HEADER["sku_id"] sku_header = MERGED_FIELD_TO_CSV_HEADER["sku_id"]
title_h = MERGED_FIELD_TO_CSV_HEADER["title"] title_h = MERGED_FIELD_TO_CSV_HEADER["title"]
batch = _run_batch_label(run_dir) batch = _run_batch_label(run_dir)
@ -975,22 +914,9 @@ def build_competitor_brief(
) )
price_promotion_signals = _analyze_price_promotions(promo_rows_brief) price_promotion_signals = _analyze_price_promotions(promo_rows_brief)
hits = _comment_keyword_hits(comment_rows, focus_words) comment_texts, _comment_scores = _iter_comment_text_units_and_scores(
if not hits:
blob = _merge_comment_previews(merged_rows)
for w in focus_words:
if len(w) < 2:
continue
n = blob.count(w)
if n:
hits[w] += n
comment_texts, comment_scores = _iter_comment_text_units_and_scores(
comment_rows, merged_rows comment_rows, merged_rows
) )
scen_counts, scen_n_texts = _comment_scenario_counts(
comment_texts, scenario_groups
)
( (
api_rc, api_rc,
@ -1005,10 +931,7 @@ def build_competitor_brief(
hints = _strategy_hints( hints = _strategy_hints(
cr1=cr1_hints, cr1=cr1_hints,
pst=pst, pst=pst,
hits=hits,
n_comments=n_cmt, n_comments=n_cmt,
scen_counts=scen_counts,
scen_n_texts=scen_n_texts,
) )
matrix_groups: list[dict[str, Any]] = [] matrix_groups: list[dict[str, Any]] = []
@ -1047,7 +970,6 @@ def build_competitor_brief(
) )
feedback_by_group: list[dict[str, Any]] = [] feedback_by_group: list[dict[str, Any]] = []
usage_scenarios_by_matrix_group: list[dict[str, Any]] = []
for gi, (gname, cr, tu) in enumerate( for gi, (gname, cr, tu) in enumerate(
_consumer_feedback_by_matrix_group( _consumer_feedback_by_matrix_group(
merged_rows=merged_rows, merged_rows=merged_rows,
@ -1055,8 +977,6 @@ def build_competitor_brief(
sku_header=sku_header, sku_header=sku_header,
) )
): ):
gh = _group_keyword_hits(cr, tu, focus_words=focus_words)
scen_g, scen_n_g = _comment_scenario_counts(tu, scenario_groups)
slug_fb = _scenario_group_asset_slug(gname, gi) slug_fb = _scenario_group_asset_slug(gname, gi)
feedback_by_group.append( feedback_by_group.append(
{ {
@ -1065,43 +985,8 @@ def build_competitor_brief(
"chart_slug": slug_fb, "chart_slug": slug_fb,
"comment_rows": len(cr), "comment_rows": len(cr),
"effective_comment_text_units": len(tu), "effective_comment_text_units": len(tu),
"focus_keyword_hits": [
{"word": w, "count": n} for w, n in gh.most_common(24)
],
"scenarios_top": [
{
"scenario": s,
"count": n,
"share_of_text_units": (
n / scen_n_g if scen_n_g else 0.0
),
}
for s, n in scen_g.most_common(6)
]
if scen_n_g
else [],
} }
) )
if scen_n_g > 0 and scen_g:
usage_scenarios_by_matrix_group.append(
{
"group": gname,
"matrix_group_index": gi,
"chart_slug": slug_fb,
"effective_text_units": scen_n_g,
"scenarios": [
{
"scenario": s,
"count": int(n),
"share_of_text_units": (
float(n) / scen_n_g if scen_n_g else 0.0
),
}
for s, n in scen_g.most_common()
if n > 0
],
}
)
meta_slice: dict[str, Any] = {} meta_slice: dict[str, Any] = {}
if meta: if meta:
@ -1188,26 +1073,15 @@ def build_competitor_brief(
"price_stats_merged_sample": pst_merged, "price_stats_merged_sample": pst_merged,
"price_stats_list_export": pst_list if list_export else {}, "price_stats_list_export": pst_list if list_export else {},
"price_promotion_signals": price_promotion_signals, "price_promotion_signals": price_promotion_signals,
"comment_focus_keywords": [ "comment_focus_keywords": [],
{"word": w, "count": n} for w, n in hits.most_common(24) "usage_scenarios": [],
], "usage_scenarios_denominator": len(comment_texts),
"usage_scenarios": [ "usage_scenarios_by_matrix_group": [],
{
"scenario": lbl,
"count": n,
"share_of_text_units": (
n / scen_n_texts if scen_n_texts else 0.0
),
}
for lbl, n in scen_counts.most_common(16)
],
"usage_scenarios_denominator": scen_n_texts,
"usage_scenarios_by_matrix_group": usage_scenarios_by_matrix_group,
"strategy_hints": hints, "strategy_hints": hints,
"matrix_by_group": matrix_groups, "matrix_by_group": matrix_groups,
"consumer_feedback_by_matrix_group": feedback_by_group, "consumer_feedback_by_matrix_group": feedback_by_group,
"notes": [ "notes": [
"与在线分析报告各章**计数规则**一致;关注词与场景以任务中的分析规则为准(子串命中统计,非深度主题模型)", "与在线分析报告各章**计数规则**一致;**不再**输出预设关注词/场景子串统计,评论侧主题以第八章文本挖掘探针(若启用)为准。",
"价格来自页面展示字段抽取,含促销与规格差异;促销与标价对齐等为启发式摘录,仅供对照。", "价格来自页面展示字段抽取,含促销与规格差异;促销与标价对齐等为启发式摘录,仅供对照。",
"「集中度」中:默认按**列表行**计数(同一 SKU 多页曝光会重复计);`shops_from_list.unique_sku_basis` 为按**去重 SKU** 的对照口径。二者均**不是**销量、库存或全渠道市场份额。", "「集中度」中:默认按**列表行**计数(同一 SKU 多页曝光会重复计);`shops_from_list.unique_sku_basis` 为按**去重 SKU** 的对照口径。二者均**不是**销量、库存或全渠道市场份额。",
], ],

View File

@ -3,9 +3,8 @@
- §5 ``generate_matrix_group_summaries_llm`` - §5 ``generate_matrix_group_summaries_llm``
- §6 ``generate_price_group_summaries_llm````generate_promo_group_summaries_llm`` - §6 ``generate_price_group_summaries_llm````generate_promo_group_summaries_llm``
- 可选默认关闭``generate_comment_sentiment_analysis_llm``已不再嵌入报告正文 - 可选默认关闭``generate_comment_sentiment_analysis_llm``按细类多次调用写入报告 **8.3**与探针 / 评论要点归纳并列
- §8末细类评价``generate_comment_group_summaries_llm`` - §8末细类评价``generate_comment_group_summaries_llm``
- §8.2 关注词/场景路径下右栏后使用场景``generate_scenario_group_summaries_llm``
- §9 策略与机会``generate_strategy_opportunities_llm````build_competitor_brief`` + 可选 ``chapter_llm_narratives`` 与各章归纳对齐 - §9 策略与机会``generate_strategy_opportunities_llm````build_competitor_brief`` + 可选 ``chapter_llm_narratives`` 与各章归纳对齐
- §8.5 类全文补充独立长文``generate_competitor_report_markdown_llm`` - §8.5 类全文补充独立长文``generate_competitor_report_markdown_llm``
@ -132,7 +131,7 @@ def main() -> None:
"--only", "--only",
type=str, type=str,
default="", default="",
help="逗号分隔子集sentiment,matrix,price,promo,strategy_opp,scenario_groups,comment_groups,report_supplement", help="逗号分隔子集sentiment,matrix,price,promo,strategy_opp,comment_groups,report_supplement",
) )
parser.add_argument( parser.add_argument(
"--preview-chars", "--preview-chars",
@ -153,7 +152,6 @@ def main() -> None:
"price", "price",
"promo", "promo",
"strategy_opp", "strategy_opp",
"scenario_groups",
"comment_groups", "comment_groups",
"report_supplement", "report_supplement",
} }
@ -182,8 +180,6 @@ def main() -> None:
generate_promo_group_summaries_llm, generate_promo_group_summaries_llm,
generate_promo_group_summaries_llm_chunked, generate_promo_group_summaries_llm_chunked,
generate_strategy_opportunities_llm, generate_strategy_opportunities_llm,
generate_scenario_group_summaries_llm,
generate_scenario_group_summaries_llm_chunked,
) )
chunk_gr = use_chunked_group_summaries_llm(eff_rc) chunk_gr = use_chunked_group_summaries_llm(eff_rc)
@ -313,41 +309,6 @@ def main() -> None:
flush=True, flush=True,
) )
if "scenario_groups" in only:
_, scen_tuple, _ = jcr.resolve_report_tuning(eff_rc)
fb_s = jcr._consumer_feedback_by_matrix_group(
merged_rows=merged,
comment_rows=comment_rows,
sku_header=sku_h,
)
pl_sg = jcr.build_scenario_groups_llm_payload(
feedback_groups=fb_s,
scenario_groups=scen_tuple,
merged_rows=merged,
sku_header=sku_h,
title_h=title_h,
)
def _sg() -> str:
if chunk_gr:
return generate_scenario_group_summaries_llm_chunked(
pl_sg, keyword=keyword
)
return generate_scenario_group_summaries_llm(pl_sg, keyword=keyword)
_run_one(
"§8.2 路径 · 使用场景归纳scenario_groups",
_sg,
live=args.live,
preview_chars=args.preview_chars,
)
if not args.live:
n = len((pl_sg or {}).get("groups") or [])
print(
f" payload groups={n} chunked_by_matrix={chunk_gr}",
flush=True,
)
if "comment_groups" in only: if "comment_groups" in only:
fb = jcr._consumer_feedback_by_matrix_group( fb = jcr._consumer_feedback_by_matrix_group(
merged_rows=merged, merged_rows=merged,

View File

@ -170,7 +170,6 @@ def get_default_report_config() -> dict[str, Any]:
"llm_comment_sentiment": False, "llm_comment_sentiment": False,
"llm_matrix_group_summaries": True, "llm_matrix_group_summaries": True,
"llm_comment_group_summaries": True, "llm_comment_group_summaries": True,
"llm_scenario_group_summaries": True,
"llm_price_group_summaries": True, "llm_price_group_summaries": True,
"llm_promo_group_summaries": True, "llm_promo_group_summaries": True,
"llm_strategy_opportunities": True, "llm_strategy_opportunities": True,
@ -179,11 +178,6 @@ def get_default_report_config() -> dict[str, Any]:
"chapter8_text_mining_probe_live_llm": True, "chapter8_text_mining_probe_live_llm": True,
"chapter8_text_mining_probe_llm_chunked": True, "chapter8_text_mining_probe_llm_chunked": True,
"chapter8_text_mining_probe_wordcloud": True, "chapter8_text_mining_probe_wordcloud": True,
"comment_focus_words": list(jcr.COMMENT_FOCUS_WORDS),
"comment_scenario_groups": [
{"label": lbl, "triggers": list(trs)}
for lbl, trs in jcr.COMMENT_SCENARIO_GROUPS
],
"external_market_table_rows": [ "external_market_table_rows": [
{"indicator": a, "value_and_scope": b, "source": c, "year": d} {"indicator": a, "value_and_scope": b, "source": c, "year": d}
for a, b, c, d in jcr.EXTERNAL_MARKET_TABLE_ROWS for a, b, c, d in jcr.EXTERNAL_MARKET_TABLE_ROWS
@ -263,10 +257,8 @@ def write_competitor_analysis_for_run_dir(
) )
brief_slice = { brief_slice = {
"keyword": brief_pre.get("keyword"), "keyword": brief_pre.get("keyword"),
"comment_focus_keywords": ( "comment_focus_keywords": [],
brief_pre.get("comment_focus_keywords") or [] "usage_scenarios": [],
)[:20],
"usage_scenarios": (brief_pre.get("usage_scenarios") or [])[:8],
"category_mix_top": (brief_pre.get("category_mix_top") or [])[:6], "category_mix_top": (brief_pre.get("category_mix_top") or [])[:6],
"scope": brief_pre.get("scope"), "scope": brief_pre.get("scope"),
} }
@ -276,13 +268,6 @@ def write_competitor_analysis_for_run_dir(
all_comment_texts=all_tx, all_comment_texts=all_tx,
) )
suggest_record.update(sug) suggest_record.update(sug)
base_words = list(eff_rc.get("comment_focus_words") or [])
for w in sug.get("suggested_focus_keywords") or []:
if isinstance(w, str):
t = w.strip()
if t and t not in base_words:
base_words.append(t)
eff_rc["comment_focus_words"] = base_words[:80]
except Exception as e: except Exception as e:
suggest_record["error"] = str(e) suggest_record["error"] = str(e)
suggest_record["suggested_focus_keywords"] = [] suggest_record["suggested_focus_keywords"] = []
@ -290,60 +275,8 @@ def write_competitor_analysis_for_run_dir(
suggest_record["skipped"] = True suggest_record["skipped"] = True
suggest_record["suggested_focus_keywords"] = [] suggest_record["suggested_focus_keywords"] = []
skip_scen = os.environ.get("MA_SKIP_LLM_SCENARIO_SUGGEST", "").strip().lower() in ( suggest_record["suggested_scenario_groups"] = []
"1", suggest_record["scenario_note"] = "预设场景词组已废弃,不再写入 report_config。"
"true",
"yes",
)
if not skip_scen:
try:
from ..llm.keyword_suggest import suggest_scenario_groups_llm
raw_sg = eff_rc.get("comment_scenario_groups")
if isinstance(raw_sg, list) and raw_sg:
scen_base = [x for x in raw_sg if isinstance(x, dict)]
else:
scen_base = [
{"label": lbl, "triggers": list(trs)}
for lbl, trs in jcr.COMMENT_SCENARIO_GROUPS
]
scen_out = suggest_scenario_groups_llm(
keyword=kw,
existing_groups=scen_base,
all_comment_texts=all_tx,
)
suggest_record["suggested_scenario_groups"] = scen_out.get(
"suggested_scenario_groups"
) or []
suggest_record["scenario_rationale"] = scen_out.get("scenario_rationale") or ""
exist_labels = {
str(x.get("label") or "").strip().lower()
for x in scen_base
if str(x.get("label") or "").strip()
}
merged_scen = list(scen_base)
for g in suggest_record["suggested_scenario_groups"]:
if not isinstance(g, dict):
continue
lab = str(g.get("label") or "").strip()
tr_in = g.get("triggers")
triggers: list[str] = []
if isinstance(tr_in, list):
for t in tr_in[:48]:
s = str(t).strip()
if 2 <= len(s) <= 48:
triggers.append(s)
if not lab or lab.lower() in exist_labels or len(triggers) < 2:
continue
merged_scen.append({"label": lab[:80], "triggers": triggers[:48]})
exist_labels.add(lab.lower())
eff_rc["comment_scenario_groups"] = merged_scen[:40]
except Exception as e:
suggest_record["scenario_error"] = str(e)
suggest_record["suggested_scenario_groups"] = []
else:
suggest_record["scenario_skipped"] = True
suggest_record["suggested_scenario_groups"] = []
suggest_path.write_text( suggest_path.write_text(
json.dumps(suggest_record, ensure_ascii=False, indent=2), json.dumps(suggest_record, ensure_ascii=False, indent=2),
@ -369,8 +302,9 @@ def write_competitor_analysis_for_run_dir(
llm_sentiment_md = "" llm_sentiment_md = ""
sentiment_llm_record: dict[str, Any] = { sentiment_llm_record: dict[str, Any] = {
"schema_version": 1, "schema_version": 2,
"attempted": False, "attempted": False,
"groups": [],
} }
skip_sent = os.environ.get( skip_sent = os.environ.get(
"MA_SKIP_LLM_COMMENT_SENTIMENT", "" "MA_SKIP_LLM_COMMENT_SENTIMENT", ""
@ -382,42 +316,81 @@ def write_competitor_analysis_for_run_dir(
) )
want_sent = bool(eff_rc.get("llm_comment_sentiment")) or env_on want_sent = bool(eff_rc.get("llm_comment_sentiment")) or env_on
if want_sent and not skip_sent: if want_sent and not skip_sent:
comment_units, comment_scores = jcr._iter_comment_text_units_and_scores( feedback_groups_sg = jcr._consumer_feedback_by_matrix_group(
comment_rows, merged_rows merged_rows=merged_rows,
comment_rows=comment_rows,
sku_header=MERGED_FIELD_TO_CSV_HEADER["sku_id"],
) )
if len(comment_units) >= 2: if not feedback_groups_sg:
sentiment_llm_record["attempted"] = True sentiment_llm_record["skipped"] = "no_feedback_groups"
try:
from ..llm.generate import generate_comment_sentiment_analysis_llm
attr_units = jcr._comment_lines_with_product_context(
comment_rows,
merged_rows,
sku_header=MERGED_FIELD_TO_CSV_HEADER["sku_id"],
title_h=MERGED_FIELD_TO_CSV_HEADER["title"],
)
if len(attr_units) != len(comment_units):
attr_units = list(comment_units)
pl = jcr.build_comment_sentiment_llm_payload(
comment_units,
scores=comment_scores,
attributed_texts=attr_units,
max_samples_positive=16,
max_samples_negative=30,
max_samples_mixed=10,
max_chars_per_review=360,
semantic_pool_max=40,
shuffle_seed=kw,
)
pl["keyword"] = kw
llm_sentiment_md = generate_comment_sentiment_analysis_llm(pl)
sentiment_llm_record["ok"] = True
sentiment_llm_record["chars"] = len(llm_sentiment_md)
except Exception as e:
sentiment_llm_record["ok"] = False
sentiment_llm_record["error"] = str(e)
else: else:
sentiment_llm_record["skipped"] = "insufficient_comment_texts" sentiment_llm_record["attempted"] = True
from ..llm.generate import generate_comment_sentiment_analysis_llm
parts_sent: list[str] = []
grp_logs: list[dict[str, Any]] = []
sku_h = MERGED_FIELD_TO_CSV_HEADER["sku_id"]
title_h = MERGED_FIELD_TO_CSV_HEADER["title"]
for gname, cr_g, _tu in feedback_groups_sg:
sub_merged = [
r
for r in merged_rows
if jcr._competitor_matrix_group_key(r) == gname
]
units, scores = jcr._iter_comment_text_units_and_scores(
cr_g, sub_merged
)
if len(units) < 2:
grp_logs.append(
{
"group": gname,
"skipped": "insufficient_comment_texts",
"n_texts": len(units),
}
)
continue
try:
attr_units = jcr._comment_lines_with_product_context(
cr_g,
merged_rows,
sku_header=sku_h,
title_h=title_h,
)
if len(attr_units) != len(units):
attr_units = list(units)
pl = jcr.build_comment_sentiment_llm_payload(
units,
scores=scores,
attributed_texts=attr_units,
max_samples_positive=16,
max_samples_negative=30,
max_samples_mixed=10,
max_chars_per_review=360,
semantic_pool_max=40,
shuffle_seed=f"{kw}|{gname}",
)
pl["keyword"] = kw
pl["matrix_group_focus"] = gname
md_one = generate_comment_sentiment_analysis_llm(pl)
parts_sent.append(f"#### {gname}\n\n{md_one.strip()}\n")
grp_logs.append(
{"group": gname, "ok": True, "chars": len(md_one)}
)
except Exception as e:
grp_logs.append({"group": gname, "ok": False, "error": str(e)})
sentiment_llm_record["groups"] = grp_logs
llm_sentiment_md = "\n".join(parts_sent).strip()
sentiment_llm_record["chars"] = len(llm_sentiment_md)
if llm_sentiment_md:
sentiment_llm_record["ok"] = True
else:
sentiment_llm_record["ok"] = False
if grp_logs and all("skipped" in x for x in grp_logs):
sentiment_llm_record["skipped"] = "insufficient_comment_texts"
elif grp_logs and any("error" in x for x in grp_logs):
sentiment_llm_record["error"] = "all_groups_failed_or_skipped"
else:
sentiment_llm_record["error"] = "no_output"
elif skip_sent: elif skip_sent:
sentiment_llm_record["skipped"] = "MA_SKIP_LLM_COMMENT_SENTIMENT" sentiment_llm_record["skipped"] = "MA_SKIP_LLM_COMMENT_SENTIMENT"
elif not want_sent: elif not want_sent:
@ -431,7 +404,6 @@ def write_competitor_analysis_for_run_dir(
llm_matrix_md = "" llm_matrix_md = ""
llm_price_md = "" llm_price_md = ""
llm_promo_md = "" llm_promo_md = ""
llm_scenario_gr_md = ""
llm_comment_gr_md = "" llm_comment_gr_md = ""
llm_strategy_opp_md = "" llm_strategy_opp_md = ""
matrix_llm_rec: dict[str, Any] = {"schema_version": 1, "attempted": False} matrix_llm_rec: dict[str, Any] = {"schema_version": 1, "attempted": False}
@ -449,7 +421,6 @@ def write_competitor_analysis_for_run_dir(
skip_mx = _env_on("MA_SKIP_LLM_MATRIX_GROUP_SUMMARIES") skip_mx = _env_on("MA_SKIP_LLM_MATRIX_GROUP_SUMMARIES")
skip_pr = _env_on("MA_SKIP_LLM_PRICE_GROUP_SUMMARIES") skip_pr = _env_on("MA_SKIP_LLM_PRICE_GROUP_SUMMARIES")
skip_po = _env_on("MA_SKIP_LLM_PROMO_GROUP_SUMMARIES") skip_po = _env_on("MA_SKIP_LLM_PROMO_GROUP_SUMMARIES")
skip_sg = _env_on("MA_SKIP_LLM_SCENARIO_GROUP_SUMMARIES")
skip_cg = _env_on("MA_SKIP_LLM_COMMENT_GROUP_SUMMARIES") skip_cg = _env_on("MA_SKIP_LLM_COMMENT_GROUP_SUMMARIES")
want_mx = bool(eff_rc.get("llm_matrix_group_summaries")) or _env_on( want_mx = bool(eff_rc.get("llm_matrix_group_summaries")) or _env_on(
"MA_ENABLE_LLM_MATRIX_GROUP_SUMMARIES" "MA_ENABLE_LLM_MATRIX_GROUP_SUMMARIES"
@ -460,9 +431,6 @@ def write_competitor_analysis_for_run_dir(
want_po = bool(eff_rc.get("llm_promo_group_summaries")) or _env_on( want_po = bool(eff_rc.get("llm_promo_group_summaries")) or _env_on(
"MA_ENABLE_LLM_PROMO_GROUP_SUMMARIES" "MA_ENABLE_LLM_PROMO_GROUP_SUMMARIES"
) )
want_sg = bool(eff_rc.get("llm_scenario_group_summaries")) or _env_on(
"MA_ENABLE_LLM_SCENARIO_GROUP_SUMMARIES"
)
want_cg = bool(eff_rc.get("llm_comment_group_summaries")) or _env_on( want_cg = bool(eff_rc.get("llm_comment_group_summaries")) or _env_on(
"MA_ENABLE_LLM_COMMENT_GROUP_SUMMARIES" "MA_ENABLE_LLM_COMMENT_GROUP_SUMMARIES"
) )
@ -512,7 +480,6 @@ def write_competitor_analysis_for_run_dir(
ch8_probe_rec["error"] = str(e) ch8_probe_rec["error"] = str(e)
if use_ch8_probe and chapter8_probe_embed_md: if use_ch8_probe and chapter8_probe_embed_md:
want_sg = False
want_cg = False want_cg = False
chunk_gr = use_chunked_group_summaries_llm(eff_rc) chunk_gr = use_chunked_group_summaries_llm(eff_rc)
@ -625,53 +592,7 @@ def write_competitor_analysis_for_run_dir(
elif not want_po: elif not want_po:
promo_llm_rec["skipped"] = "not_enabled" promo_llm_rec["skipped"] = "not_enabled"
if want_sg and not skip_sg and merged_rows: scenario_gr_llm_rec["skipped"] = "preset_scenario_summaries_removed"
_, scenario_tuple, _ = jcr.resolve_report_tuning(eff_rc)
fb_sg = jcr._consumer_feedback_by_matrix_group(
merged_rows=merged_rows,
comment_rows=comment_rows,
sku_header=sku_h,
)
pl_sg = jcr.build_scenario_groups_llm_payload(
feedback_groups=fb_sg,
scenario_groups=scenario_tuple,
merged_rows=merged_rows,
sku_header=sku_h,
title_h=title_h,
)
if pl_sg:
scenario_gr_llm_rec["attempted"] = True
try:
if chunk_gr:
from ..llm.generate import (
generate_scenario_group_summaries_llm_chunked,
)
llm_scenario_gr_md = generate_scenario_group_summaries_llm_chunked(
pl_sg, keyword=kw
)
else:
from ..llm.generate import generate_scenario_group_summaries_llm
llm_scenario_gr_md = generate_scenario_group_summaries_llm(
pl_sg, keyword=kw
)
scenario_gr_llm_rec["ok"] = True
scenario_gr_llm_rec["chars"] = len(llm_scenario_gr_md)
scenario_gr_llm_rec["chunked_by_matrix"] = chunk_gr
if chunk_gr:
scenario_gr_llm_rec["chunk_count"] = len(
(pl_sg.get("groups") or [])
)
except Exception as e:
scenario_gr_llm_rec["ok"] = False
scenario_gr_llm_rec["error"] = str(e)
else:
scenario_gr_llm_rec["skipped"] = "empty_scenario_groups_payload"
elif skip_sg:
scenario_gr_llm_rec["skipped"] = "MA_SKIP_LLM_SCENARIO_GROUP_SUMMARIES"
elif not want_sg:
scenario_gr_llm_rec["skipped"] = "not_enabled"
if want_cg and not skip_cg and merged_rows: if want_cg and not skip_cg and merged_rows:
fb_cg = jcr._consumer_feedback_by_matrix_group( fb_cg = jcr._consumer_feedback_by_matrix_group(
@ -729,8 +650,6 @@ def write_competitor_analysis_for_run_dir(
_strategy_narratives["sec6_price_group_summaries"] = llm_price_md _strategy_narratives["sec6_price_group_summaries"] = llm_price_md
if (llm_promo_md or "").strip(): if (llm_promo_md or "").strip():
_strategy_narratives["sec6_promo_group_summaries"] = llm_promo_md _strategy_narratives["sec6_promo_group_summaries"] = llm_promo_md
if (llm_scenario_gr_md or "").strip():
_strategy_narratives["sec8_3_scenario_summaries"] = llm_scenario_gr_md
if use_ch8_probe and (chapter8_probe_embed_md or "").strip(): if use_ch8_probe and (chapter8_probe_embed_md or "").strip():
_strategy_narratives["sec8_3_text_mining_probe"] = ( _strategy_narratives["sec8_3_text_mining_probe"] = (
chapter8_probe_embed_md chapter8_probe_embed_md
@ -802,7 +721,7 @@ def write_competitor_analysis_for_run_dir(
llm_matrix_section_md=llm_matrix_md or None, llm_matrix_section_md=llm_matrix_md or None,
llm_price_groups_section_md=llm_price_md or None, llm_price_groups_section_md=llm_price_md or None,
llm_promo_groups_section_md=llm_promo_md or None, llm_promo_groups_section_md=llm_promo_md or None,
llm_scenario_groups_section_md=llm_scenario_gr_md or None, llm_scenario_groups_section_md=None,
llm_comment_groups_section_md=llm_comment_gr_md or None, llm_comment_groups_section_md=llm_comment_gr_md or None,
llm_strategy_opportunities_section_md=llm_strategy_opp_md or None, llm_strategy_opportunities_section_md=llm_strategy_opp_md or None,
chapter8_text_mining_probe_section_md=chapter8_probe_embed_md or None, chapter8_text_mining_probe_section_md=chapter8_probe_embed_md or None,

View File

@ -32,12 +32,19 @@ SENTIMENT_LLM_SYSTEM = """你是电商/食品类用户研究助手。输入 JSON
3. ``#### 混合评价中的典型张力``(可选):同一评价里褒贬并存时,说明在争什么;若无则略写。 3. ``#### 混合评价中的典型张力``(可选):同一评价里褒贬并存时,说明在争什么;若无则略写。
4. ``#### 使用注意``:关键词子串统计的局限、``sample_reviews_semantic_pool`` 与词表归类的差异、抽样截断、非医学结论。 4. ``#### 使用注意``:关键词子串统计的局限、``sample_reviews_semantic_pool`` 与词表归类的差异、抽样截断、非医学结论。
总字数约 **7001600 **简体中文语气客观""" **篇幅** JSON ``matrix_group_focus``单细类范围本节总字数约 **5001200 **勿再按全关键词池写全行业泛化**不含**该字段全量池总字数约 **7001600 **简体中文语气客观"""
def generate_comment_sentiment_analysis_llm(payload: dict[str, Any]) -> str: def generate_comment_sentiment_analysis_llm(payload: dict[str, Any]) -> str:
"""基于 lexicon 统计 + 语义池与按词表归类的抽样生成评价情感归纳段落Markdown**默认不**嵌入竞品报告正文。""" """基于 lexicon 统计 + 语义池与按词表归类的抽样生成评价情感归纳段落Markdown**默认不**嵌入竞品报告正文。"""
p = dict(payload) p = dict(payload)
scope_note = ""
mg = p.get("matrix_group_focus")
if isinstance(mg, str) and mg.strip():
scope_note = (
f"\n\n【范围】以下评价与统计**仅**来自细类「{mg.strip()}」;"
"正向/负向主题须贴合**该细类**语境,勿笼统写成「全关键词下用户普遍…」。\n"
)
raw = json.dumps(p, ensure_ascii=False) raw = json.dumps(p, ensure_ascii=False)
if len(raw) > 88_000: if len(raw) > 88_000:
for k, cap, maxlen in ( for k, cap, maxlen in (
@ -52,7 +59,7 @@ def generate_comment_sentiment_analysis_llm(payload: dict[str, Any]) -> str:
raw = json.dumps(p, ensure_ascii=False) raw = json.dumps(p, ensure_ascii=False)
if len(raw) > 88_000: if len(raw) > 88_000:
raw = raw[:82_000] + "\n\n…(输入过长已截断,请勿编造截断外内容)\n" raw = raw[:82_000] + "\n\n…(输入过长已截断,请勿编造截断外内容)\n"
user = "请根据以下 JSON 按系统说明输出 Markdown\n\n" + raw user = "请根据以下 JSON 按系统说明输出 Markdown" + scope_note + "\n\n" + raw
return call_llm(SENTIMENT_LLM_SYSTEM, user) return call_llm(SENTIMENT_LLM_SYSTEM, user)

View File

@ -0,0 +1,28 @@
"""竞品报告 Markdown第八章情感小节嵌入烟测"""
from __future__ import annotations
import tempfile
from pathlib import Path
from django.test import SimpleTestCase
from pipeline.competitor_report import jd_report as jcr
class JdReportSentimentSectionTests(SimpleTestCase):
def test_chapter_83_embeds_when_sentiment_md_provided(self) -> None:
with tempfile.TemporaryDirectory() as td:
run_dir = Path(td)
(run_dir / "report_assets").mkdir(parents=True)
md = jcr.build_competitor_markdown(
run_dir=run_dir,
keyword="测试词",
merged_rows=[],
search_export_rows=[],
comment_rows=[],
meta=None,
llm_sentiment_section_md="#### 饼干\n\n- 正向\n",
)
self.assertIn("### 8.3 评价正/负向主题(按细类 · 大模型)", md)
self.assertIn("不替代**探针的开放词表", md)
self.assertIn("#### 饼干", md)

View File

@ -1,55 +1,24 @@
<script setup> <script setup>
defineProps({ defineProps({
focusWordRows: { type: Array, required: true },
scenarioGroups: { type: Array, required: true },
marketRows: { type: Array, required: true }, marketRows: { type: Array, required: true },
}) })
defineEmits([ defineEmits(['add-market', 'remove-market'])
'add-focus',
'remove-focus',
'add-scenario',
'remove-scenario',
'add-market',
'remove-market',
])
</script> </script>
<template> <template>
<div> <div>
<div class="rc-section"> <div class="rc-section">
<h4 class="rc-subtitle">1. 评价里要统计的关注词</h4> <h4 class="rc-subtitle">1. 第八章评论分析</h4>
<p class="rc-help">报告会数这些词在评价里出现了多少次适合看大家常提什么例如口感控糖价格等</p>
<div class="rc-rows">
<div v-for="(row, i) in focusWordRows" :key="'f' + i" class="rc-inline">
<input v-model="row.text" type="text" class="rc-input" placeholder="输入一个词,如:控糖" />
<button type="button" class="ma-btn ma-btn-secondary rc-mini" @click="$emit('remove-focus', i)">删除</button>
</div>
</div>
<button type="button" class="ma-btn ma-btn-secondary rc-add" @click="$emit('add-focus')">添加词</button>
</div>
<div class="rc-section">
<h4 class="rc-subtitle">2. 用途 / 场景分组</h4>
<p class="rc-help"> <p class="rc-help">
每一组有一个<strong>场景名称</strong>和若干<strong>触发词</strong>若一条评价里出现了其中任意一个词这条评价就会算进该场景一条评价可以同时属于多个场景触发词请用顿号逗号或换行分开 报告<strong>不再</strong>使用预设关注词 / 预设场景词组子串统计请在报告配置高级 JSON 或接口中维护
<code>chapter8_text_mining_probe</code>
等开关以生成开放词表词频与共现等文本挖掘内容可选
<code>llm_comment_sentiment</code>按矩阵细类分别调用模型在报告<strong>8.3</strong>生成/负向主题归纳与探针及细类评论要点归纳并列互不替代
</p> </p>
<div v-for="(g, i) in scenarioGroups" :key="'s' + i" class="rc-scenario-card">
<label class="rc-label">场景名称</label>
<input v-model="g.label" type="text" class="rc-input" placeholder="如:早餐 / 代餐" />
<label class="rc-label">触发词</label>
<textarea
v-model="g.triggersText"
class="rc-textarea"
rows="2"
placeholder="如:早餐、代餐、早饭(可用顿号或换行分隔)"
/>
<button type="button" class="ma-btn ma-btn-secondary rc-mini" @click="$emit('remove-scenario', i)">删除本组</button>
</div>
<button type="button" class="ma-btn ma-btn-secondary rc-add" @click="$emit('add-scenario')">添加场景组</button>
</div> </div>
<div class="rc-section"> <div class="rc-section">
<h4 class="rc-subtitle">3. 外部市场信息可选</h4> <h4 class="rc-subtitle">2. 外部市场信息可选</h4>
<p class="rc-help">若手边有第三方市场规模增速等摘录可填在表里报告会多一节说明不需要可整表留空</p> <p class="rc-help">若手边有第三方市场规模增速等摘录可填在表里报告会多一节说明不需要可整表留空</p>
<div class="rc-market-wrap"> <div class="rc-market-wrap">
<table class="rc-market"> <table class="rc-market">
@ -107,17 +76,6 @@ defineEmits([
color: #6b7280; color: #6b7280;
line-height: 1.5; line-height: 1.5;
} }
.rc-rows {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.rc-inline {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.rc-input { .rc-input {
flex: 1; flex: 1;
min-width: 140px; min-width: 140px;
@ -128,7 +86,6 @@ defineEmits([
font-size: 0.88rem; font-size: 0.88rem;
box-sizing: border-box; box-sizing: border-box;
} }
/* 表内输入:不占 flex宽度受列约束避免撑进邻列 */
.rc-input.rc-td { .rc-input.rc-td {
flex: none; flex: none;
display: block; display: block;
@ -137,28 +94,6 @@ defineEmits([
max-width: 100%; max-width: 100%;
font-size: 0.8rem; font-size: 0.8rem;
} }
.rc-textarea {
width: 100%;
max-width: 100%;
box-sizing: border-box;
padding: 0.45rem 0.55rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font: inherit;
font-size: 0.88rem;
resize: vertical;
margin: 0.35rem 0 0.5rem;
}
.rc-label {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: #4b5563;
margin-top: 0.35rem;
}
.rc-label:first-of-type {
margin-top: 0;
}
.rc-mini { .rc-mini {
font-size: 0.8rem; font-size: 0.8rem;
padding: 0.3rem 0.55rem; padding: 0.3rem 0.55rem;
@ -168,13 +103,6 @@ defineEmits([
margin-top: 0.5rem; margin-top: 0.5rem;
font-size: 0.85rem; font-size: 0.85rem;
} }
.rc-scenario-card {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 0.75rem 0.85rem;
margin-bottom: 0.65rem;
}
.rc-market-wrap { .rc-market-wrap {
overflow-x: auto; overflow-x: auto;
margin-bottom: 0.35rem; margin-bottom: 0.35rem;