mirror of
https://github.com/primedigitaltech/market-assistant.git
synced 2026-07-21 23:41:39 +08:00
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:
parent
396ccd941a
commit
d3dec53a8f
@ -58,21 +58,15 @@ from pipeline.competitor_report.price_promo import ( # noqa: E402
|
||||
_markdown_price_promotion_section,
|
||||
)
|
||||
from pipeline.competitor_report.comment_sentiment import ( # noqa: E402
|
||||
_comment_keyword_hits,
|
||||
_iter_comment_text_units,
|
||||
_iter_comment_text_units_and_scores,
|
||||
_merge_comment_previews,
|
||||
_parse_comment_score,
|
||||
build_comment_sentiment_llm_payload,
|
||||
)
|
||||
from pipeline.competitor_report.llm_group_payloads import ( # noqa: E402
|
||||
build_comment_groups_llm_payload,
|
||||
build_matrix_groups_llm_payload,
|
||||
build_price_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
|
||||
_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
|
||||
_embed_chart,
|
||||
_focus_scenario_combo_bar_filename,
|
||||
_lines_4_reading_brand,
|
||||
_lines_4_reading_shop,
|
||||
_matrix_prices_sales_chart_filename,
|
||||
_mermaid_pie_focus_keywords,
|
||||
_scenario_group_asset_slug,
|
||||
_scenario_summary_bullets,
|
||||
_strategy_hints,
|
||||
)
|
||||
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 = "低GI"
|
||||
@ -148,7 +139,7 @@ def build_competitor_markdown(
|
||||
llm_strategy_opportunities_section_md: str | None = None,
|
||||
chapter8_text_mining_probe_section_md: str | None = None,
|
||||
) -> 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()
|
||||
sku_header = MERGED_FIELD_TO_CSV_HEADER["sku_id"]
|
||||
title_h = MERGED_FIELD_TO_CSV_HEADER["title"]
|
||||
@ -210,23 +201,6 @@ def build_competitor_markdown(
|
||||
)
|
||||
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(
|
||||
merged_rows=merged_rows,
|
||||
comment_rows=comment_rows,
|
||||
@ -286,12 +260,11 @@ def build_competitor_markdown(
|
||||
"",
|
||||
"- **价格**:自页面「标价 / 券后价 / 详情价」等抽取的**展示价**,含促销与规格差异,**不等于**出厂价或成本。**第六章** 在具备可用的搜索列表导出时,优先以**列表全量**统计;否则使用**已深入 SKU** 的合并数据;**第六章第一节** 归纳标价与券后价差等**列表侧展示价差信号**(不对卖点/腰带字段做预设关键词扫描)。",
|
||||
"- **品牌/店铺集中度(第四章)**:有列表全量时按列表行计店铺与品牌占比;无列表导出时按深入 SKU 合并表估算。",
|
||||
"- **评价主题词**:对评价正文做**预设词表子串计数**,非分词主题模型,适合扫方向,**需抽样人工验证**。",
|
||||
"- **用途/场景**:对每条评价独立判断是否命中预设场景词;一条可计入多个场景,统计的是「提及该场景的评价条数」而非用户数。",
|
||||
"- **评价文本**:**不再**使用「预设关注词 / 预设场景词组」子串计数作为报告或简报主指标;**第八章第二节**以中文分词与开放词表统计(词频、关键词突出度、共现、主题探索等,可选词云及大模型解读)为主,结论须结合抽样原文理解。",
|
||||
(
|
||||
"- **用户画像(第八章)**:**不再**使用星级子集内**预设口语短语**条形图、正负面扇形图及同口径摘要;**第八章第二节**为分词、词频、共现与主题等**文本补充分析**(可选词云及大模型解读),结论须结合抽样原文理解。"
|
||||
"- **用户画像(第八章)**:**不再**使用星级子集内**预设口语短语**条形图、正负面扇形图及同口径摘要;**第八章第二节**为上述**文本补充分析**(探针)。"
|
||||
if _ch8_probe_sec
|
||||
else "- **用户画像(第八章)**:关注词与场景**仅按细类**以**同图左右并列**展示(左为关注词命中次数,右为场景占有效文本 **%**);见**第八章第二节**。"
|
||||
else "- **用户画像(第八章)**:若未启用探针,第二节仅说明方法并列出按细类评价条数;**请开启** `chapter8_text_mining_probe` 以生成开放词表统计与插图。"
|
||||
),
|
||||
"- **细类划分(第五至第八章)**:**仅**依据合并表中的**商品详情页类目路径**;该信息缺失或无法读出细类名称的 SKU **不参与**竞品矩阵与按细类评价统计(相关评价条亦**不进入**按细类图表)。",
|
||||
"- **检索结果规模**:来自京东 PC 搜索返回的「结果条数」类指标,表示平台侧申报的匹配数量级,**不等于**动销、库存或独立 SKU 数。",
|
||||
@ -384,17 +357,15 @@ def build_competitor_markdown(
|
||||
exec_bullets.append(
|
||||
f"列表侧约 **{100.0 * float(sh):.0f}%** 可对齐行呈现「券后/到手」**低于**「标价」,展示价差中位数约 **{float(med):.1f}%**(**第六章第一节** 活动与话术摘录)。"
|
||||
)
|
||||
if multi_feedback_cat and (hits or scen_n_texts > 0):
|
||||
exec_bullets.append(
|
||||
"评价侧写(关注词、用途/场景)已按**第五章同一细类划分**分节,见**第八章第二节**(同图并列)。"
|
||||
)
|
||||
elif hits:
|
||||
top3 = "、".join(f"「{w}」({n})" for w, n in hits.most_common(3))
|
||||
exec_bullets.append(f"评价侧写(词频):{top3}。")
|
||||
if scen_n_texts > 0 and scen_counts and not multi_feedback_cat:
|
||||
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 multi_feedback_cat and n_cmt > 0:
|
||||
if _ch8_probe_sec:
|
||||
exec_bullets.append(
|
||||
"评价侧写已按**第五章同一细类划分**呈现,见**第八章第二节**(文本挖掘探针)。"
|
||||
)
|
||||
else:
|
||||
exec_bullets.append(
|
||||
"评价已按**第五章同一细类**归组;**第八章第二节**定量主题分析依赖探针,可在报告调参中开启 `chapter8_text_mining_probe`。"
|
||||
)
|
||||
if api_rc is not None:
|
||||
exec_bullets.append(
|
||||
f"PC 搜索返回的检索结果规模约 **{api_rc:,}**(站内匹配条数量级,见第三章第二节;**不是**零售额或动销统计)。"
|
||||
@ -750,9 +721,9 @@ def build_competitor_markdown(
|
||||
"- **细类划分**:与**第五章「竞品矩阵」**相同,**仅**依据合并表中的**商品详情页类目路径**解析为「饼干 / 西式糕点 / …」等(规则见第五章开头说明)。",
|
||||
"- **归因**:每条评价按其 SKU 对应到深入样本,再映射到该 SKU 所属细类;SKU 不在合并表中的评价单独归入说明性分组;**在合并表中但该 SKU 缺少类目路径或读不出细类名称的,该评价不进入按细类统计**(与第五章**同一条排除规则**)。",
|
||||
(
|
||||
"- **评论文本补充分析(第八章第二节)**:本任务已用中文分词与统计工具做了开放词表分析(词频、关键词突出度、词对共现、主题归纳等,可选词云),**不再**在报告中使用「星级子集内预设口语短语」条形图、正负面扇形图及同口径摘要;**不再**输出原「关注词次数 + 场景占比」左右并列条图。"
|
||||
"- **评论文本补充分析(第八章第二节)**:本任务已用中文分词与统计工具做了开放词表分析(词频、关键词突出度、词对共现、主题归纳等,可选词云),**不再**在报告中使用「星级子集内预设口语短语」条形图、正负面扇形图及同口径摘要;**不再**使用「预设关注词 + 预设场景词组」子串计数与并列条图。"
|
||||
if _ch8_probe_sec
|
||||
else "- **关注词与使用场景(第八章第二节)**:对组内评价正文做关注词子串计数(左栏条形图);对每条有效文本独立扫描**本次任务生效的场景词组**(来自报告调参或系统默认),一条可属多场景,右栏为**占该细类有效文本比例 %**(多标签下可相加 **>** 100%)。二者在 **同一张图左右并列**,与第五章矩阵细类一一对应。"
|
||||
else "- **评论文本补充分析(第八章第二节)**:本任务**未**嵌入探针正文;**不再**输出预设关注词/场景子串统计图。请在 `report_config` 中开启 `chapter8_text_mining_probe` 并重跑以生成开放词表统计与插图。"
|
||||
),
|
||||
"",
|
||||
]
|
||||
@ -769,15 +740,12 @@ def build_competitor_markdown(
|
||||
]
|
||||
)
|
||||
else:
|
||||
# 仅当未嵌入第八章第二节探针补充分析(_ch8_probe_sec 为空)时:原「关注词 + 场景」条图与逐细类段落
|
||||
lines.extend(
|
||||
[
|
||||
"### 8.2 关注词与使用场景(按细类)",
|
||||
"### 8.2 评论文本补充分析(未启用探针时的占位)",
|
||||
"",
|
||||
"每细类一张**左右并列图**(与报告附图文件夹中的 ``chart_focus_and_scenarios_bar__*.png`` 同源):"
|
||||
"**左**为配置关注词子串命中次数(同一评价可出现多次,为次数而非去重条数);"
|
||||
"**右**为预设场景词组命中占该细类有效文本比例 %(一条可属多场景;多柱比例可相加 **>** 100%)。"
|
||||
"统计均基于评价正文(或兜底预览)子串规则,**不等于**购买动机调研结论。",
|
||||
"> **说明**:本版本**不再**生成「预设关注词 + 预设场景」子串统计与 ``chart_focus_and_scenarios_bar__*.png``。"
|
||||
"开放词表、词频、共现与主题等请开启 ``chapter8_text_mining_probe`` 后重跑。",
|
||||
"",
|
||||
]
|
||||
)
|
||||
@ -785,73 +753,44 @@ def build_competitor_markdown(
|
||||
lines.append("*无评价数据可归组。*")
|
||||
lines.append("")
|
||||
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)
|
||||
lines.append(f"#### {gname}")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"- **本细类逐条评价**:{n_flat} 条;**用于统计的有效文本条数**:{len(texts_g)}。"
|
||||
f"- **本细类逐条评价**:{n_flat} 条;**有效文本单元**:{len(texts_g)}。"
|
||||
)
|
||||
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()
|
||||
if _llm_cg:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"#### 细类评价与关注词要点归纳(大模型,与第八章第二节左栏图表互补)",
|
||||
"#### 细类评论要点归纳(大模型,基于评价摘录)",
|
||||
"",
|
||||
"> **说明**:归纳各细类反馈主题与配置关注词命中;**次数与第八章第二节图左栏以正文为准**。",
|
||||
"> **说明**:仅依据输入中的评价短摘录与文本单元归纳,**不**依赖预设关注词表或场景触发词统计。",
|
||||
"",
|
||||
_llm_cg,
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
_llm_sent = (llm_sentiment_section_md or "").strip()
|
||||
if _llm_sent:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"### 8.3 评价正/负向主题(按细类 · 大模型)",
|
||||
"",
|
||||
"> **说明**:与 **8.2 评论文本补充分析(探针)** 及 **「细类评论要点归纳(大模型)」** 并列;每段仅使用该细类下评价原文做语境归纳(如正向体验主题、负向评价主题归因)。"
|
||||
"**不替代**探针的开放词表与专用口径,也不替代「细类评论要点归纳」的写法与载荷。",
|
||||
"",
|
||||
_llm_sent,
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
lines.extend(["---", "", "## 九、策略与机会提示(假设清单,待验证)", ""])
|
||||
_llm_st = (llm_strategy_opportunities_section_md or "").strip()
|
||||
if _llm_st:
|
||||
@ -917,7 +856,7 @@ def build_competitor_brief(
|
||||
"""
|
||||
与 ``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"]
|
||||
title_h = MERGED_FIELD_TO_CSV_HEADER["title"]
|
||||
batch = _run_batch_label(run_dir)
|
||||
@ -975,22 +914,9 @@ def build_competitor_brief(
|
||||
)
|
||||
price_promotion_signals = _analyze_price_promotions(promo_rows_brief)
|
||||
|
||||
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_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
|
||||
)
|
||||
|
||||
(
|
||||
api_rc,
|
||||
@ -1005,10 +931,7 @@ def build_competitor_brief(
|
||||
hints = _strategy_hints(
|
||||
cr1=cr1_hints,
|
||||
pst=pst,
|
||||
hits=hits,
|
||||
n_comments=n_cmt,
|
||||
scen_counts=scen_counts,
|
||||
scen_n_texts=scen_n_texts,
|
||||
)
|
||||
|
||||
matrix_groups: list[dict[str, Any]] = []
|
||||
@ -1047,7 +970,6 @@ def build_competitor_brief(
|
||||
)
|
||||
|
||||
feedback_by_group: list[dict[str, Any]] = []
|
||||
usage_scenarios_by_matrix_group: list[dict[str, Any]] = []
|
||||
for gi, (gname, cr, tu) in enumerate(
|
||||
_consumer_feedback_by_matrix_group(
|
||||
merged_rows=merged_rows,
|
||||
@ -1055,8 +977,6 @@ def build_competitor_brief(
|
||||
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)
|
||||
feedback_by_group.append(
|
||||
{
|
||||
@ -1065,43 +985,8 @@ def build_competitor_brief(
|
||||
"chart_slug": slug_fb,
|
||||
"comment_rows": len(cr),
|
||||
"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] = {}
|
||||
if meta:
|
||||
@ -1188,26 +1073,15 @@ def build_competitor_brief(
|
||||
"price_stats_merged_sample": pst_merged,
|
||||
"price_stats_list_export": pst_list if list_export else {},
|
||||
"price_promotion_signals": price_promotion_signals,
|
||||
"comment_focus_keywords": [
|
||||
{"word": w, "count": n} for w, n in hits.most_common(24)
|
||||
],
|
||||
"usage_scenarios": [
|
||||
{
|
||||
"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,
|
||||
"comment_focus_keywords": [],
|
||||
"usage_scenarios": [],
|
||||
"usage_scenarios_denominator": len(comment_texts),
|
||||
"usage_scenarios_by_matrix_group": [],
|
||||
"strategy_hints": hints,
|
||||
"matrix_by_group": matrix_groups,
|
||||
"consumer_feedback_by_matrix_group": feedback_by_group,
|
||||
"notes": [
|
||||
"与在线分析报告各章**计数规则**一致;关注词与场景以任务中的分析规则为准(子串命中统计,非深度主题模型)。",
|
||||
"与在线分析报告各章**计数规则**一致;**不再**输出预设关注词/场景子串统计,评论侧主题以第八章文本挖掘探针(若启用)为准。",
|
||||
"价格来自页面展示字段抽取,含促销与规格差异;促销与标价对齐等为启发式摘录,仅供对照。",
|
||||
"「集中度」中:默认按**列表行**计数(同一 SKU 多页曝光会重复计);`shops_from_list.unique_sku_basis` 为按**去重 SKU** 的对照口径。二者均**不是**销量、库存或全渠道市场份额。",
|
||||
],
|
||||
|
||||
@ -3,9 +3,8 @@
|
||||
|
||||
- §5 后:``generate_matrix_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.2 关注词/场景路径下右栏后使用场景:``generate_scenario_group_summaries_llm``
|
||||
- §9 策略与机会:``generate_strategy_opportunities_llm``(``build_competitor_brief`` + 可选 ``chapter_llm_narratives`` 与各章归纳对齐)
|
||||
- §8.5 类全文补充(独立长文):``generate_competitor_report_markdown_llm``
|
||||
|
||||
@ -132,7 +131,7 @@ def main() -> None:
|
||||
"--only",
|
||||
type=str,
|
||||
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(
|
||||
"--preview-chars",
|
||||
@ -153,7 +152,6 @@ def main() -> None:
|
||||
"price",
|
||||
"promo",
|
||||
"strategy_opp",
|
||||
"scenario_groups",
|
||||
"comment_groups",
|
||||
"report_supplement",
|
||||
}
|
||||
@ -182,8 +180,6 @@ def main() -> None:
|
||||
generate_promo_group_summaries_llm,
|
||||
generate_promo_group_summaries_llm_chunked,
|
||||
generate_strategy_opportunities_llm,
|
||||
generate_scenario_group_summaries_llm,
|
||||
generate_scenario_group_summaries_llm_chunked,
|
||||
)
|
||||
|
||||
chunk_gr = use_chunked_group_summaries_llm(eff_rc)
|
||||
@ -313,41 +309,6 @@ def main() -> None:
|
||||
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:
|
||||
fb = jcr._consumer_feedback_by_matrix_group(
|
||||
merged_rows=merged,
|
||||
|
||||
@ -170,7 +170,6 @@ def get_default_report_config() -> dict[str, Any]:
|
||||
"llm_comment_sentiment": False,
|
||||
"llm_matrix_group_summaries": True,
|
||||
"llm_comment_group_summaries": True,
|
||||
"llm_scenario_group_summaries": True,
|
||||
"llm_price_group_summaries": True,
|
||||
"llm_promo_group_summaries": 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_llm_chunked": 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": [
|
||||
{"indicator": a, "value_and_scope": b, "source": c, "year": d}
|
||||
for a, b, c, d in jcr.EXTERNAL_MARKET_TABLE_ROWS
|
||||
@ -263,10 +257,8 @@ def write_competitor_analysis_for_run_dir(
|
||||
)
|
||||
brief_slice = {
|
||||
"keyword": brief_pre.get("keyword"),
|
||||
"comment_focus_keywords": (
|
||||
brief_pre.get("comment_focus_keywords") or []
|
||||
)[:20],
|
||||
"usage_scenarios": (brief_pre.get("usage_scenarios") or [])[:8],
|
||||
"comment_focus_keywords": [],
|
||||
"usage_scenarios": [],
|
||||
"category_mix_top": (brief_pre.get("category_mix_top") or [])[:6],
|
||||
"scope": brief_pre.get("scope"),
|
||||
}
|
||||
@ -276,13 +268,6 @@ def write_competitor_analysis_for_run_dir(
|
||||
all_comment_texts=all_tx,
|
||||
)
|
||||
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:
|
||||
suggest_record["error"] = str(e)
|
||||
suggest_record["suggested_focus_keywords"] = []
|
||||
@ -290,60 +275,8 @@ def write_competitor_analysis_for_run_dir(
|
||||
suggest_record["skipped"] = True
|
||||
suggest_record["suggested_focus_keywords"] = []
|
||||
|
||||
skip_scen = os.environ.get("MA_SKIP_LLM_SCENARIO_SUGGEST", "").strip().lower() in (
|
||||
"1",
|
||||
"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_record["suggested_scenario_groups"] = []
|
||||
suggest_record["scenario_note"] = "预设场景词组已废弃,不再写入 report_config。"
|
||||
|
||||
suggest_path.write_text(
|
||||
json.dumps(suggest_record, ensure_ascii=False, indent=2),
|
||||
@ -369,8 +302,9 @@ def write_competitor_analysis_for_run_dir(
|
||||
|
||||
llm_sentiment_md = ""
|
||||
sentiment_llm_record: dict[str, Any] = {
|
||||
"schema_version": 1,
|
||||
"schema_version": 2,
|
||||
"attempted": False,
|
||||
"groups": [],
|
||||
}
|
||||
skip_sent = os.environ.get(
|
||||
"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
|
||||
if want_sent and not skip_sent:
|
||||
comment_units, comment_scores = jcr._iter_comment_text_units_and_scores(
|
||||
comment_rows, merged_rows
|
||||
feedback_groups_sg = jcr._consumer_feedback_by_matrix_group(
|
||||
merged_rows=merged_rows,
|
||||
comment_rows=comment_rows,
|
||||
sku_header=MERGED_FIELD_TO_CSV_HEADER["sku_id"],
|
||||
)
|
||||
if len(comment_units) >= 2:
|
||||
sentiment_llm_record["attempted"] = True
|
||||
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)
|
||||
if not feedback_groups_sg:
|
||||
sentiment_llm_record["skipped"] = "no_feedback_groups"
|
||||
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:
|
||||
sentiment_llm_record["skipped"] = "MA_SKIP_LLM_COMMENT_SENTIMENT"
|
||||
elif not want_sent:
|
||||
@ -431,7 +404,6 @@ def write_competitor_analysis_for_run_dir(
|
||||
llm_matrix_md = ""
|
||||
llm_price_md = ""
|
||||
llm_promo_md = ""
|
||||
llm_scenario_gr_md = ""
|
||||
llm_comment_gr_md = ""
|
||||
llm_strategy_opp_md = ""
|
||||
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_pr = _env_on("MA_SKIP_LLM_PRICE_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")
|
||||
want_mx = bool(eff_rc.get("llm_matrix_group_summaries")) or _env_on(
|
||||
"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(
|
||||
"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(
|
||||
"MA_ENABLE_LLM_COMMENT_GROUP_SUMMARIES"
|
||||
)
|
||||
@ -512,7 +480,6 @@ def write_competitor_analysis_for_run_dir(
|
||||
ch8_probe_rec["error"] = str(e)
|
||||
|
||||
if use_ch8_probe and chapter8_probe_embed_md:
|
||||
want_sg = False
|
||||
want_cg = False
|
||||
|
||||
chunk_gr = use_chunked_group_summaries_llm(eff_rc)
|
||||
@ -625,53 +592,7 @@ def write_competitor_analysis_for_run_dir(
|
||||
elif not want_po:
|
||||
promo_llm_rec["skipped"] = "not_enabled"
|
||||
|
||||
if want_sg and not skip_sg and merged_rows:
|
||||
_, 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"
|
||||
scenario_gr_llm_rec["skipped"] = "preset_scenario_summaries_removed"
|
||||
|
||||
if want_cg and not skip_cg and merged_rows:
|
||||
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
|
||||
if (llm_promo_md or "").strip():
|
||||
_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():
|
||||
_strategy_narratives["sec8_3_text_mining_probe"] = (
|
||||
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_price_groups_section_md=llm_price_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_strategy_opportunities_section_md=llm_strategy_opp_md or None,
|
||||
chapter8_text_mining_probe_section_md=chapter8_probe_embed_md or None,
|
||||
|
||||
@ -32,12 +32,19 @@ SENTIMENT_LLM_SYSTEM = """你是电商/食品类用户研究助手。输入 JSON
|
||||
3. ``#### 混合评价中的典型张力``(可选):同一评价里褒贬并存时,说明在争什么;若无则略写。
|
||||
4. ``#### 使用注意``:关键词子串统计的局限、``sample_reviews_semantic_pool`` 与词表归类的差异、抽样截断、非医学结论。
|
||||
|
||||
总字数约 **700~1600 字**,简体中文,语气客观。"""
|
||||
**篇幅**:若 JSON 含 ``matrix_group_focus``(单细类范围),本节总字数约 **500~1200 字**,勿再按全关键词池写「全行业泛化」;若**不含**该字段(全量池),总字数约 **700~1600 字**。简体中文,语气客观。"""
|
||||
|
||||
|
||||
def generate_comment_sentiment_analysis_llm(payload: dict[str, Any]) -> str:
|
||||
"""基于 lexicon 统计 + 语义池与按词表归类的抽样,生成评价情感归纳段落(Markdown);**默认不**嵌入竞品报告正文。"""
|
||||
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)
|
||||
if len(raw) > 88_000:
|
||||
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)
|
||||
if len(raw) > 88_000:
|
||||
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)
|
||||
|
||||
|
||||
|
||||
28
backend/pipeline/tests/test_jd_report_sentiment_section.py
Normal file
28
backend/pipeline/tests/test_jd_report_sentiment_section.py
Normal 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)
|
||||
@ -1,55 +1,24 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
focusWordRows: { type: Array, required: true },
|
||||
scenarioGroups: { type: Array, required: true },
|
||||
marketRows: { type: Array, required: true },
|
||||
})
|
||||
defineEmits([
|
||||
'add-focus',
|
||||
'remove-focus',
|
||||
'add-scenario',
|
||||
'remove-scenario',
|
||||
'add-market',
|
||||
'remove-market',
|
||||
])
|
||||
defineEmits(['add-market', 'remove-market'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="rc-section">
|
||||
<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>
|
||||
<h4 class="rc-subtitle">1. 第八章评论分析</h4>
|
||||
<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>
|
||||
<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 class="rc-section">
|
||||
<h4 class="rc-subtitle">3. 外部市场信息(可选)</h4>
|
||||
<h4 class="rc-subtitle">2. 外部市场信息(可选)</h4>
|
||||
<p class="rc-help">若手边有第三方市场规模、增速等摘录,可填在表里,报告会多一节说明;不需要可整表留空。</p>
|
||||
<div class="rc-market-wrap">
|
||||
<table class="rc-market">
|
||||
@ -107,17 +76,6 @@ defineEmits([
|
||||
color: #6b7280;
|
||||
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 {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
@ -128,7 +86,6 @@ defineEmits([
|
||||
font-size: 0.88rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* 表内输入:不占 flex,宽度受列约束,避免撑进邻列 */
|
||||
.rc-input.rc-td {
|
||||
flex: none;
|
||||
display: block;
|
||||
@ -137,28 +94,6 @@ defineEmits([
|
||||
max-width: 100%;
|
||||
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 {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
@ -168,13 +103,6 @@ defineEmits([
|
||||
margin-top: 0.5rem;
|
||||
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 {
|
||||
overflow-x: auto;
|
||||
margin-bottom: 0.35rem;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user