移除结构化 audience 字段,强化 voc_业务_2 源评论归因匹配与 Persona 引用展示,更新 README 与流水线默认清理 SQLite。 Co-authored-by: Cursor <cursoragent@cursor.com>
797 lines
34 KiB
Python
797 lines
34 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
步骤 2:主报告构建脚本。读取数据库 → LLM 分析 → 渲染 HTML。
|
||
|
||
用法::
|
||
|
||
# 全流程(config.yaml 留空则 LLM 自动识别产品名和行业)
|
||
../310py/bin/python build_report.py
|
||
|
||
# 指定产品名(覆盖 config.yaml),自定义输出路径
|
||
../310py/bin/python build_report.py --product "Bikini Trimmer" --output output/bikini-trimmer.html
|
||
|
||
# 跳过 LLM 调用(仅渲染模板骨架,用于验证模板和数据管道)
|
||
../310py/bin/python build_report.py --no-llm
|
||
|
||
# 调试:将 LLM 分析结果保存为 JSON(方便人工检查/修正后重新渲染)
|
||
../310py/bin/python build_report.py --save-data
|
||
|
||
依赖:需先运行 run_pipeline.py 产出 SQLite 数据库。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import logging
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List
|
||
|
||
import yaml
|
||
|
||
from data_loader import DataLoader, asin_link_html, build_amazon_url, MarketStats
|
||
from llm_analyzer import (
|
||
discover_personas, discover_themes_both, analyze_kano_jtbd_keywords_parallel,
|
||
analyze_matrix, analyze_all_rootcauses, get_llm_workers, configure_report_llm,
|
||
)
|
||
from report_utils import (
|
||
enrich_theme_keywords, recalc_neg_priorities, compute_persona_pcts,
|
||
assign_persona_clusters, validate_persona_physiological_labels,
|
||
prepare_persona_catalog_for_llm,
|
||
pick_persona_quotes, fix_jtbd_fields, filter_matrix_rows,
|
||
enrich_matrix_scene_evidence, build_matrix_table_rows_html,
|
||
enrich_rootcause_quotes, build_executive_summary, enhanced_market_judgment,
|
||
fix_kano_items, normalize_kano_reverse_items, normalize_persona_dimension,
|
||
normalize_rootcauses,
|
||
build_asin_labels, build_asin_short_codes, build_asin_tables_html,
|
||
build_footer_asin_links, get_layout_config,
|
||
persona_display_meta,
|
||
build_asin_theme_insights, asin_link_with_label, quote_cn_summary,
|
||
sort_personas_by_evidence, sort_rootcauses_by_evidence, jtbd_cell_html,
|
||
build_product_stopwords, build_kano_grid_html, build_neg_theme_summary_note,
|
||
build_neg_theme_table_rows, build_pos_theme_table_rows,
|
||
build_sentiment_keyword_groups, prepare_keyword_display, build_keyword_tables_html,
|
||
build_quote_blocks_html,
|
||
)
|
||
from echarts_builder import (
|
||
get_echarts_script, build_all_charts, calc_theme_freq, calc_per_asin_theme_freq,
|
||
get_chart_layout, build_theme_mini_charts_html,
|
||
)
|
||
|
||
logger = logging.getLogger("voc.build_report")
|
||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||
|
||
_PLACEHOLDER_RE = re.compile(r"\{\{[A-Z0-9_]+\}\}")
|
||
|
||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||
PROJECT_ROOT = SCRIPT_DIR.parent
|
||
CONFIG_FILE = SCRIPT_DIR / "config.yaml"
|
||
TEMPLATE_FILE = SCRIPT_DIR / "template.html"
|
||
|
||
|
||
def product_file_slug(product: str, industry: str = "") -> str:
|
||
"""产品名 → 安全文件名 slug(仅产品名,不含行业/副标题)。"""
|
||
s = (product or "voc-report").strip()
|
||
if industry:
|
||
ind = industry.strip()
|
||
if ind and ind in s:
|
||
for pat in (
|
||
f"({ind})", f"({ind})", f" - {ind}", f" · {ind}",
|
||
f"|{ind}", f"|{ind}", f"/ {ind}",
|
||
):
|
||
if pat in s:
|
||
s = s.split(pat, 1)[0].strip()
|
||
if s.endswith(ind):
|
||
s = s[: -len(ind)].strip(" -/|·()()")
|
||
# 「产品A / 产品B」类双描述只取主产品名(第一段)
|
||
for sep in ("/", "|", "|"):
|
||
if sep in s:
|
||
s = s.split(sep, 1)[0].strip()
|
||
break
|
||
s = s.lower()
|
||
s = re.sub(r'[/\\:*?"<>|]+', "-", s)
|
||
s = re.sub(r"\s+", "-", s)
|
||
s = re.sub(r"-+", "-", s).strip("-")
|
||
return s or "voc-report"
|
||
|
||
|
||
def load_config() -> dict:
|
||
with CONFIG_FILE.open(encoding="utf-8") as f:
|
||
return yaml.safe_load(f) or {}
|
||
|
||
|
||
def finalize_template(html: str) -> str:
|
||
"""检查并清理未替换的 {{PLACEHOLDER}},避免报告页面露出模板标签。"""
|
||
remaining = sorted(set(_PLACEHOLDER_RE.findall(html)))
|
||
if remaining:
|
||
logger.error(
|
||
"报告模板存在未替换占位符 (%s 个): %s — 请更新 render_html 或重新渲染",
|
||
len(remaining), ", ".join(remaining[:12]),
|
||
)
|
||
html = _PLACEHOLDER_RE.sub("", html)
|
||
return html
|
||
|
||
|
||
def build_report_data(loader: DataLoader, cfg: dict) -> Dict[str, Any]:
|
||
"""构建所有报告数据。"""
|
||
configure_report_llm(cfg)
|
||
data: Dict[str, Any] = {}
|
||
llm_workers = get_llm_workers(cfg)
|
||
product = cfg.get("product_name", "主产品")
|
||
industry = cfg.get("industry", "当前品类")
|
||
logger.info("LLM 并发 workers=%s | 报告模式: max thinking", llm_workers)
|
||
|
||
# ── 基础统计 ──
|
||
stats = loader.load_basic_stats()
|
||
data["stats"] = stats
|
||
|
||
# ── 聚类数据 ──
|
||
persona_sample_reviews = int(cfg.get("persona_sample_reviews", 5))
|
||
cluster_data = loader.build_cluster_prompt_data(
|
||
persona_sample_reviews=persona_sample_reviews,
|
||
)
|
||
data["cluster_data"] = cluster_data
|
||
all_clusters = loader.load_cluster_data()
|
||
reviews = loader.load_reviews()
|
||
extractions = loader.load_comment_extractions()
|
||
data["extractions"] = extractions
|
||
persona_quote_max = int(cfg.get("persona_quote_max", 3))
|
||
rootcause_quote_max = int(cfg.get("rootcause_quote_max", 4))
|
||
physio_min = int(cfg.get("persona_physio_min_reviews", 5))
|
||
min_hit_count = int(cfg.get("persona_min_hit_count", 5))
|
||
data["persona_min_hit_count"] = min_hit_count
|
||
prepare_persona_catalog_for_llm(
|
||
cluster_data["persona_cluster_catalog"], all_clusters, reviews,
|
||
min_physio_reviews=physio_min,
|
||
)
|
||
|
||
# ── Persona ──
|
||
personas = discover_personas(cluster_data, product_name=product, industry=industry)
|
||
personas = assign_persona_clusters(personas, all_clusters)
|
||
personas = validate_persona_physiological_labels(
|
||
personas, cluster_data, reviews, all_clusters, min_review_count=physio_min,
|
||
)
|
||
data["personas"] = personas
|
||
|
||
# ── 主题(差评+好评并发) ──
|
||
neg_themes, pos_themes = discover_themes_both(
|
||
cluster_data, stats.neg_review_count, stats.pos_review_count,
|
||
)
|
||
neg_themes = enrich_theme_keywords(neg_themes, cluster_data.get("global_negative", []))
|
||
pos_themes = enrich_theme_keywords(pos_themes, cluster_data.get("global_positive", []))
|
||
data["neg_themes"] = neg_themes
|
||
data["pos_themes"] = pos_themes
|
||
|
||
# ── 主题频次统计(结构化 category+aspect 优先) ──
|
||
neg_freq = calc_theme_freq(neg_themes, reviews, is_neg=True, extractions=extractions)
|
||
pos_freq = calc_theme_freq(pos_themes, reviews, is_neg=False, extractions=extractions)
|
||
per_asin_neg = calc_per_asin_theme_freq(
|
||
neg_themes, reviews, is_neg=True, extractions=extractions,
|
||
)
|
||
per_asin_pos = calc_per_asin_theme_freq(
|
||
pos_themes, reviews, is_neg=False, extractions=extractions,
|
||
)
|
||
neg_themes = recalc_neg_priorities(
|
||
neg_themes, neg_freq, stats.neg_review_count, len(stats.asins), per_asin_neg,
|
||
)
|
||
data["neg_themes"] = neg_themes
|
||
data["neg_freq"] = neg_freq
|
||
data["pos_freq"] = pos_freq
|
||
data["per_asin_neg"] = per_asin_neg
|
||
data["per_asin_pos"] = per_asin_pos
|
||
|
||
# ── Persona 统计与引用 ──
|
||
personas = compute_persona_pcts(personas, reviews, all_clusters)
|
||
personas = sort_personas_by_evidence(personas)
|
||
data["personas"] = personas
|
||
data["persona_quotes"] = pick_persona_quotes(
|
||
personas, reviews, extractions,
|
||
max_quotes=persona_quote_max,
|
||
)
|
||
|
||
# ── KANO + JTBD + 情感关键词(三者并发) ──
|
||
neg_kw_groups = build_sentiment_keyword_groups(neg_themes, reviews, limit=6, is_neg=True)
|
||
pos_kw_groups = build_sentiment_keyword_groups(pos_themes, reviews, limit=6, is_neg=False)
|
||
kano_raw, jtbd_raw, keywords_raw = analyze_kano_jtbd_keywords_parallel(
|
||
neg_themes, pos_themes, personas,
|
||
neg_kw_groups, pos_kw_groups,
|
||
product_name=product,
|
||
)
|
||
kano = normalize_kano_reverse_items(fix_kano_items(kano_raw))
|
||
data["kano"] = kano
|
||
jtbd = fix_jtbd_fields(jtbd_raw, personas)
|
||
data["jtbd"] = jtbd
|
||
data["keywords_llm"] = keywords_raw
|
||
_, _, keywords_neg, keywords_pos = prepare_keyword_display(
|
||
neg_themes, pos_themes, reviews, personas, keywords_raw, limit=6,
|
||
)
|
||
data["keywords_neg"] = keywords_neg
|
||
data["keywords_pos"] = keywords_pos
|
||
data["keywords"] = keywords_neg + keywords_pos
|
||
|
||
# ── 矩阵(依赖 KANO) ──
|
||
matrix = enrich_matrix_scene_evidence(
|
||
analyze_matrix(
|
||
personas, kano, cluster_data, stats.total_reviews,
|
||
market_avg=stats.weighted_avg_rating,
|
||
),
|
||
personas,
|
||
reviews,
|
||
)
|
||
matrix = filter_matrix_rows(
|
||
matrix,
|
||
market_avg=stats.weighted_avg_rating,
|
||
)
|
||
data["matrix"] = matrix
|
||
|
||
# ── 根因分析(各 Persona 并发) ──
|
||
rootcauses = analyze_all_rootcauses(
|
||
personas, neg_themes, all_clusters=all_clusters,
|
||
max_workers=llm_workers,
|
||
product_name=product, industry=industry, min_hit_count=min_hit_count,
|
||
)
|
||
rootcauses = enrich_rootcause_quotes(
|
||
rootcauses, personas, loader, reviews,
|
||
persona_quotes=data["persona_quotes"],
|
||
extractions=extractions,
|
||
max_quotes=rootcause_quote_max,
|
||
)
|
||
rootcauses = normalize_rootcauses(rootcauses, neg_themes)
|
||
rootcauses = sort_rootcauses_by_evidence(rootcauses, personas)
|
||
data["rootcauses"] = rootcauses
|
||
|
||
# ── 市场竞争 + 决策摘要 + ASIN 标签 ──
|
||
asin_labels = build_asin_labels(stats)
|
||
data["asin_labels"] = asin_labels
|
||
data["asin_short_codes"] = build_asin_short_codes(stats)
|
||
market_title, market_desc = enhanced_market_judgment(stats)
|
||
data["market_title"] = market_title
|
||
data["market_desc"] = market_desc
|
||
data["executive_summary"] = build_executive_summary(
|
||
stats, neg_freq, pos_freq, neg_themes, asin_labels=asin_labels,
|
||
pos_themes=pos_themes,
|
||
)
|
||
data["asin_theme_insights"] = build_asin_theme_insights(
|
||
stats, neg_themes, per_asin_neg, asin_labels,
|
||
)
|
||
|
||
return data
|
||
|
||
|
||
def render_html(template_path: Path, data: Dict[str, Any], cfg: dict) -> str:
|
||
"""用数据填充模板并返回完整 HTML。"""
|
||
if not template_path.is_file():
|
||
raise FileNotFoundError(f"模板文件不存在: {template_path}")
|
||
html = template_path.read_text(encoding="utf-8")
|
||
|
||
# ── 简单占位符 ──
|
||
product = cfg.get("product_name", "Product")
|
||
analysis_date = cfg.get("analysis_date", "2026-06-12")
|
||
data_source = cfg.get("data_source", "卖家精灵 realtime CSV")
|
||
version = cfg.get("report_version", "v1")
|
||
|
||
html = html.replace("{{PRODUCT_NAME}}", product)
|
||
html = html.replace("{{ANALYSIS_DATE}}", analysis_date)
|
||
html = html.replace("{{DATA_SOURCE}}", data_source)
|
||
html = html.replace("{{VERSION}}", version)
|
||
|
||
# ── ECharts ──
|
||
echarts_inline = cfg.get("echarts_inline", True)
|
||
html = html.replace("{{ECHARTS_SCRIPT}}", get_echarts_script(inline=echarts_inline))
|
||
|
||
# ── 导航 ASIN 列表 ──
|
||
stats: MarketStats = data["stats"]
|
||
asins = [a.asin for a in stats.asins]
|
||
html = html.replace("{{ASIN_COUNT}}", str(len(asins)))
|
||
|
||
# ── KPI ──
|
||
html = html.replace("{{TOTAL_REVIEWS}}", f"{stats.total_reviews:,}")
|
||
html = html.replace("{{WEIGHTED_AVG}}", f"{stats.weighted_avg_rating}")
|
||
html = html.replace("{{POS_RATE}}", f"{int(stats.pos_rate * 100)}%")
|
||
html = html.replace("{{NEG_RATE}}", f"{int(stats.neg_rate * 100)}%")
|
||
neutral_count = stats.total_reviews - stats.neg_review_count - stats.pos_review_count
|
||
neutral_pct = round(neutral_count / max(stats.total_reviews, 1) * 100)
|
||
html = html.replace("{{NEUTRAL_RATE}}", f"{neutral_pct}%")
|
||
html = html.replace("{{NEUTRAL_COUNT}}", str(neutral_count))
|
||
|
||
# KPI 颜色
|
||
avg_color = "danger" if stats.weighted_avg_rating < 3.5 else ("warn" if stats.weighted_avg_rating <= 4.0 else "success")
|
||
html = html.replace("{{AVG_COLOR}}", avg_color)
|
||
|
||
# ── 市场竞争 callout ──
|
||
callout_type = "danger" if stats.weighted_avg_rating < 3.5 else ("warn" if stats.weighted_avg_rating <= 4.0 else "success")
|
||
html = html.replace("{{MARKET_CALLOUT_TYPE}}", callout_type)
|
||
html = html.replace("{{MARKET_TITLE}}", data["market_title"])
|
||
html = html.replace("{{MARKET_DESC}}", data["market_desc"])
|
||
|
||
# ── 决策摘要 ──
|
||
es = data.get("executive_summary") or {}
|
||
bullets = es.get("bullets", [])
|
||
opps = es.get("opportunities", [])
|
||
conclusion = es.get("conclusion", "")
|
||
insights = es.get("insights", [])
|
||
summary_html = ""
|
||
if conclusion:
|
||
summary_html += (
|
||
f'<div class="callout callout-success" style="margin-bottom:10px;padding:10px 14px">'
|
||
f'<div class="callout-title">产品定义结论</div>'
|
||
f'<p style="font-size:13px;margin:0">{conclusion}</p></div>'
|
||
)
|
||
summary_html += '<ul style="margin:8px 0 0 18px;font-size:13px;color:#444">'
|
||
for b in bullets:
|
||
summary_html += f"<li style=\"margin-bottom:6px\">{b}</li>"
|
||
summary_html += "</ul>"
|
||
if opps:
|
||
summary_html += '<div style="margin-top:12px;font-size:12px;font-weight:600;color:#374151">产品机会清单(按差评频次排序)</div><ol style="margin:6px 0 0 18px;font-size:13px;color:#444">'
|
||
for o in opps:
|
||
summary_html += f"<li style=\"margin-bottom:4px\">{o}</li>"
|
||
summary_html += "</ol>"
|
||
html = html.replace("{{EXEC_SUMMARY_HTML}}", summary_html)
|
||
|
||
if insights:
|
||
ins_html = '<div class="callout callout-warn" style="margin-bottom:18px"><div class="callout-title">关键不对称洞察</div><ul style="margin:6px 0 0 18px;font-size:13px;color:#444">'
|
||
for ins in insights:
|
||
ins_html += f"<li style=\"margin-bottom:4px\">{ins}</li>"
|
||
ins_html += "</ul></div>"
|
||
else:
|
||
ins_html = ""
|
||
html = html.replace("{{INSIGHTS_CALLOUT}}", ins_html)
|
||
|
||
asin_theme_ins = data.get("asin_theme_insights") or []
|
||
if asin_theme_ins:
|
||
ath = '<div class="callout callout-info" style="margin-bottom:14px"><div class="callout-title">ASIN 主题对比结论</div><ul style="margin:6px 0 0 18px;font-size:13px;color:#444">'
|
||
for line in asin_theme_ins:
|
||
ath += f"<li style=\"margin-bottom:4px\">{line}</li>"
|
||
ath += "</ul></div>"
|
||
else:
|
||
ath = ""
|
||
html = html.replace("{{ASIN_THEME_INSIGHTS_HTML}}", ath)
|
||
|
||
asin_labels = data.get("asin_labels") or build_asin_labels(stats)
|
||
asin_short = data.get("asin_short_codes") or build_asin_short_codes(stats)
|
||
layout_cfg = get_layout_config(cfg)
|
||
chart_layout = get_chart_layout(stats, cfg)
|
||
|
||
# ── ASIN 表格(大品类摘要 + 附录)──
|
||
summary_table, appendix_table, table_note = build_asin_tables_html(
|
||
stats, asin_labels, asin_link_html,
|
||
top_n=layout_cfg["asin_table_top_n"],
|
||
large_threshold=layout_cfg["large_asin_threshold"],
|
||
)
|
||
html = html.replace("{{ASIN_TABLE_NOTE}}", table_note)
|
||
html = html.replace("{{ASIN_TABLE_SUMMARY}}", summary_table)
|
||
html = html.replace("{{ASIN_TABLE_APPENDIX}}", appendix_table)
|
||
|
||
# ── 评分分布图布局 ──
|
||
if chart_layout["large_market"]:
|
||
star_note = (
|
||
f"共 {chart_layout['asin_count']} 个竞品:横向堆叠图按评论量排序,"
|
||
f"可在下方滚动查看全部;轴标签为短码(A/B/…)。"
|
||
)
|
||
star_scroll_max = min(720, chart_layout["star_chart_height"])
|
||
else:
|
||
star_note = "按竞品展示 5★–1★ 评论堆叠分布。"
|
||
star_scroll_max = chart_layout["star_chart_height"]
|
||
html = html.replace("{{STAR_CHART_NOTE}}", star_note)
|
||
html = html.replace("{{STAR_CHART_HEIGHT}}", str(chart_layout["star_chart_height"]))
|
||
html = html.replace("{{STAR_SCROLL_MAX}}", str(star_scroll_max))
|
||
if chart_layout.get("show_star_summary"):
|
||
html = html.replace(
|
||
"{{STAR_SUMMARY_HTML}}",
|
||
'<div style="margin-bottom:14px"><h3 style="margin-bottom:6px">评分分布摘要(Top 15 + 其余聚合)</h3>'
|
||
f'<div id="starDistSummaryChart" style="width:100%;height:{chart_layout["star_summary_height"]}px"></div></div>',
|
||
)
|
||
else:
|
||
html = html.replace("{{STAR_SUMMARY_HTML}}", "")
|
||
|
||
neg_top6 = sorted(data["neg_freq"].items(), key=lambda x: x[1], reverse=True)[:6]
|
||
pos_top6 = sorted(data["pos_freq"].items(), key=lambda x: x[1], reverse=True)[:6]
|
||
neg_theme_names = [n for n, _ in neg_top6]
|
||
pos_theme_names = [n for n, _ in pos_top6]
|
||
hm_th = layout_cfg["heatmap_asin_threshold"]
|
||
if chart_layout["use_heatmap"]:
|
||
neg_theme_note = f"热力图:行=主题、列=竞品短码;颜色越深命中越多。下方为各主题 Top ASIN 明细。竞品 >{hm_th},请用滑块横向浏览。"
|
||
pos_theme_note = neg_theme_note.replace("差评", "好评")
|
||
elif chart_layout["large_market"]:
|
||
neg_theme_note = "分组柱:X 轴=竞品短码,图例=6 个差评主题;竞品较多时请拖动下方滑块。"
|
||
pos_theme_note = "分组柱:X 轴=竞品短码,图例=6 个好评主题;竞品较多时请拖动下方滑块。"
|
||
else:
|
||
neg_theme_note = "分组柱:X 轴=竞品,图例=Top 6 差评主题。"
|
||
pos_theme_note = "分组柱:X 轴=竞品,图例=Top 6 好评主题。"
|
||
html = html.replace("{{NEG_THEME_CHART_NOTE}}", neg_theme_note)
|
||
html = html.replace("{{POS_THEME_CHART_NOTE}}", pos_theme_note)
|
||
html = html.replace("{{NEG_THEME_CHART_HEIGHT}}", str(chart_layout["neg_theme_height"]))
|
||
html = html.replace("{{POS_THEME_CHART_HEIGHT}}", str(chart_layout["pos_theme_height"]))
|
||
html = html.replace(
|
||
"{{NEG_THEME_MINI_HTML}}",
|
||
build_theme_mini_charts_html("neg", neg_theme_names, chart_layout["asin_count"], large_threshold=hm_th),
|
||
)
|
||
html = html.replace(
|
||
"{{POS_THEME_MINI_HTML}}",
|
||
build_theme_mini_charts_html("pos", pos_theme_names, chart_layout["asin_count"], large_threshold=hm_th),
|
||
)
|
||
|
||
# 移除旧占位符兼容
|
||
html = html.replace("{{ASIN_TABLE_ROWS}}", "")
|
||
|
||
# ── Persona 卡片 ──
|
||
persona_map = {p.get("name"): p for p in data.get("personas", [])}
|
||
persona_cards = []
|
||
for i, p in enumerate(data["personas"]):
|
||
pq = next((q for q in data["persona_quotes"] if q["persona"] == p["name"]), None)
|
||
quote_items = (pq.get("quotes") or []) if pq else []
|
||
if not quote_items and pq and pq.get("quote"):
|
||
quote_items = [pq]
|
||
quote_html = build_quote_blocks_html(
|
||
quote_items,
|
||
asin_labels,
|
||
empty_msg="暂无命中池内的代表性评论",
|
||
)
|
||
normalize_persona_dimension(p)
|
||
min_hit = int(
|
||
data.get("persona_min_hit_count")
|
||
or cfg.get("persona_min_hit_count", 5)
|
||
)
|
||
meta = persona_display_meta(p, stats.total_reviews, min_hit_count=min_hit)
|
||
persona_cards.append(f"""<div class="persona">
|
||
<div class="p-name">{p.get("name", "?")}</div>
|
||
<div class="p-meta">{meta}</div>
|
||
<div class="p-row"><span class="p-label">核心痛点:</span>{p.get("core_pain", "")}</div>
|
||
<div class="p-row"><span class="p-label">核心需求:</span>{p.get("core_need", "")}</div>
|
||
<div class="p-row"><span class="p-label">购买动机:</span>{p.get("purchase_motivation", "")}</div>
|
||
{quote_html}
|
||
</div>""")
|
||
html = html.replace("{{PERSONA_CARDS}}", "\n".join(persona_cards))
|
||
|
||
# ── 差评主题表格 ──
|
||
neg_total = stats.neg_review_count
|
||
neg_rows_html = build_neg_theme_table_rows(
|
||
data["neg_freq"],
|
||
data["neg_themes"],
|
||
neg_total,
|
||
len(stats.asins),
|
||
data["per_asin_neg"],
|
||
asin_labels,
|
||
)
|
||
html = html.replace("{{NEG_THEME_TABLE_ROWS}}", neg_rows_html)
|
||
|
||
neg_summary_note = build_neg_theme_summary_note(
|
||
stats, data["neg_freq"], data["neg_themes"], data.get("per_asin_neg"),
|
||
)
|
||
html = html.replace("{{NEG_THEME_SUMMARY_NOTE}}", neg_summary_note)
|
||
|
||
# ── 好评主题表格 ──
|
||
pos_total = stats.pos_review_count
|
||
pos_rows_html = build_pos_theme_table_rows(
|
||
data["pos_freq"],
|
||
data.get("pos_themes") or [],
|
||
pos_total,
|
||
data["neg_themes"],
|
||
data["neg_freq"],
|
||
neg_total,
|
||
data.get("kano") or [],
|
||
)
|
||
html = html.replace("{{POS_THEME_TABLE_ROWS}}", pos_rows_html)
|
||
html = html.replace("{{POS_REVIEW_COUNT}}", str(pos_total))
|
||
pos_pct_sum = sum(
|
||
round(c / max(pos_total, 1) * 100) for c in data["pos_freq"].values() if c > 0
|
||
)
|
||
html = html.replace("{{POS_PCT_SUM}}", str(pos_pct_sum))
|
||
|
||
# ── KANO 四象限卡片 ──
|
||
kano_display = normalize_kano_reverse_items(data.get("kano") or [])
|
||
html = html.replace("{{KANO_GRID_HTML}}", build_kano_grid_html(kano_display))
|
||
html = html.replace("{{KANO_TABLE_ROWS}}", "")
|
||
|
||
# ── JTBD 表格 ──
|
||
jtbd_rows = []
|
||
jtbd_items = fix_jtbd_fields(data.get("jtbd") or [], data.get("personas") or [])
|
||
for item in jtbd_items:
|
||
jtbd_rows.append(
|
||
f'<tr><td>{item.get("persona", "?")}</td>'
|
||
f'<td class="jtbd-cell">{jtbd_cell_html(item.get("core_job", ""))}</td>'
|
||
f'<td class="jtbd-cell">{jtbd_cell_html(item.get("functional_motivation", ""))}</td>'
|
||
f'<td class="jtbd-cell">{jtbd_cell_html(item.get("emotional_motivation", ""))}</td>'
|
||
f'<td class="jtbd-cell">{jtbd_cell_html(item.get("social_motivation", ""))}</td>'
|
||
f'<td class="jtbd-cell">{jtbd_cell_html(item.get("trigger", ""))}</td></tr>'
|
||
)
|
||
html = html.replace("{{JTBD_TABLE_ROWS}}", "\n".join(jtbd_rows))
|
||
|
||
# ── 矩阵(Persona 分组卡片)──
|
||
matrix_html = build_matrix_table_rows_html(data["matrix"], persona_map)
|
||
html = html.replace("{{MATRIX_TABLE_ROWS}}", matrix_html)
|
||
html = html.replace("{{MATRIX_HTML}}", "")
|
||
|
||
# ── 根因卡片 ──
|
||
rc_cards = []
|
||
rc_idx = 0
|
||
for i, rc in enumerate(data["rootcauses"]):
|
||
pname = rc.get("persona_name", f"Persona {i}")
|
||
if rc.get("skipped"):
|
||
rc_cards.append(f"""<div class="card collapsed">
|
||
<div class="card-header" onclick="toggleCard(this)">
|
||
<span>👤 {pname} — 核心痛点根因</span>
|
||
<span><span class="pill pill-gray">样本不足</span> <span class="toggle-icon"></span></span>
|
||
</div>
|
||
<div class="card-body">
|
||
<p class="rc-text">{rc.get("skip_reason", "聚类命中不足,未生成根因分析")}</p>
|
||
</div>
|
||
</div>""")
|
||
continue
|
||
causes = rc.get("root_causes", [])
|
||
affected = ", ".join(rc.get("affected_themes", []))
|
||
rc_html_parts = []
|
||
for cause in causes:
|
||
quotes_html = ""
|
||
for q in cause.get("quotes", []):
|
||
asin = q.get("asin", "?")
|
||
if asin == "?" or not re.match(r"^B[A-Z0-9]{9}$", asin):
|
||
continue
|
||
valid_asins = {a.asin for a in stats.asins}
|
||
if asin not in valid_asins:
|
||
continue
|
||
amazon_url = build_amazon_url(asin)
|
||
cn = q.get("cn_summary") or quote_cn_summary(q.get("text", ""))
|
||
alabel = asin_labels.get(asin, asin)
|
||
quotes_html += (
|
||
f'<div class="quote neg">"{q.get("text", "")}" '
|
||
f'— <a href="{amazon_url}" target="_blank" rel="noopener">{alabel}</a>'
|
||
f'<div class="quote-cn">摘要:{cn}</div></div>\n'
|
||
)
|
||
rc_html_parts.append(f"""<div class="rc-section">
|
||
<div class="rc-label">{cause.get("title", "根因")}</div>
|
||
<p class="rc-text">{cause.get("mechanism", "")}</p>
|
||
{quotes_html}
|
||
<div class="rc-label" style="margin-top:4px">→ 产品开发方向</div>
|
||
<p class="rc-text">{cause.get("dev_direction", "")}</p>
|
||
</div>""")
|
||
|
||
collapsed = " collapsed" if rc_idx >= 2 else ""
|
||
rc_idx += 1
|
||
rc_cards.append(f"""<div class="card{collapsed}">
|
||
<div class="card-header" onclick="toggleCard(this)">
|
||
<span>👤 {pname} — 核心痛点根因</span>
|
||
<span><span class="pill pill-warn">{affected}</span> <span class="toggle-icon"></span></span>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="rc-section">
|
||
<div class="rc-label">差评主题归因(该群体命中)</div>
|
||
<p class="rc-text">{affected}</p>
|
||
</div>
|
||
{"".join(rc_html_parts)}
|
||
</div>
|
||
</div>""")
|
||
html = html.replace("{{ROOTCAUSE_CARDS}}", "\n".join(rc_cards))
|
||
|
||
# ── 情感关键词双表 ──
|
||
neg_kw = data.get("keywords_neg")
|
||
pos_kw = data.get("keywords_pos")
|
||
if neg_kw is None or pos_kw is None:
|
||
neg_kw, pos_kw = [], []
|
||
neg_kw_rows, pos_kw_rows = build_keyword_tables_html(neg_kw or [], pos_kw or [])
|
||
html = html.replace("{{KEYWORD_NEG_TABLE_ROWS}}", neg_kw_rows)
|
||
html = html.replace("{{KEYWORD_POS_TABLE_ROWS}}", pos_kw_rows)
|
||
|
||
# ── 图表 JS ──
|
||
charts_js = build_all_charts(
|
||
stats, data["neg_themes"], data["pos_themes"],
|
||
data["neg_freq"], data["pos_freq"],
|
||
data["per_asin_neg"], data["per_asin_pos"],
|
||
asin_labels=asin_labels,
|
||
asin_short_codes=asin_short,
|
||
layout=chart_layout,
|
||
cfg=cfg,
|
||
)
|
||
html = html.replace("{{CHART_JS}}", charts_js)
|
||
|
||
html = html.replace(
|
||
"{{FOOTER_ASIN_LINKS}}",
|
||
build_footer_asin_links(stats, asin_labels),
|
||
)
|
||
|
||
return finalize_template(html)
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="构建 VOC 分析报告 HTML")
|
||
parser.add_argument("--product", help="产品名(覆盖 config.yaml)")
|
||
parser.add_argument("--industry", help="行业名")
|
||
parser.add_argument("--output", default=None, help="输出 HTML 路径")
|
||
parser.add_argument("--no-llm", action="store_true", help="跳过 LLM 调用(仅渲染模板,用于测试)")
|
||
parser.add_argument("--save-data", action="store_true", help="将 LLM 分析结果保存为 JSON")
|
||
parser.add_argument("--render-from-json", help="从已保存的 JSON 渲染 HTML(跳过 LLM)")
|
||
args = parser.parse_args()
|
||
|
||
cfg = load_config()
|
||
if args.product:
|
||
cfg["product_name"] = args.product
|
||
if args.industry:
|
||
cfg["industry"] = args.industry
|
||
|
||
product = cfg.get("product_name", "").strip()
|
||
industry = cfg.get("industry", "").strip()
|
||
|
||
# 如果产品名或行业为空,用 LLM 从原始评论中自动识别
|
||
need_detect = (not product or product == "亚马逊商品" or not industry or industry == "亚马逊电商")
|
||
if need_detect:
|
||
input_dir_raw = cfg.get("input_dir", "")
|
||
input_dir_path_raw = (SCRIPT_DIR / input_dir_raw).resolve() if input_dir_raw else None
|
||
if input_dir_path_raw and input_dir_path_raw.is_dir():
|
||
samples, dir_name = DataLoader.load_raw_review_samples(input_dir_path_raw, max_samples=50)
|
||
if samples:
|
||
from llm_analyzer import detect_product_and_industry
|
||
detected_product, detected_industry = detect_product_and_industry(samples, dir_name)
|
||
if not product or product == "亚马逊商品":
|
||
product = detected_product
|
||
cfg["product_name"] = product
|
||
logger.info("LLM 自动识别产品名: %s", product)
|
||
if not industry or industry == "亚马逊电商":
|
||
industry = detected_industry
|
||
cfg["industry"] = industry
|
||
logger.info("LLM 自动识别行业: %s", industry)
|
||
else:
|
||
product = product or "亚马逊商品"
|
||
industry = industry or "亚马逊电商"
|
||
else:
|
||
product = product or "亚马逊商品"
|
||
industry = industry or "亚马逊电商"
|
||
output_dir = SCRIPT_DIR / cfg.get("output_dir", "./output")
|
||
output_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
slug = product_file_slug(product, industry)
|
||
output_html = Path(args.output) if args.output else (output_dir / f"{slug}.html")
|
||
|
||
logger.info("=== VOC 报告构建 ===")
|
||
logger.info("产品: %s | 行业: %s", product, industry)
|
||
logger.info("项目根目录: %s", PROJECT_ROOT)
|
||
|
||
# 加载数据
|
||
loader = DataLoader(PROJECT_ROOT, product, industry)
|
||
|
||
if args.render_from_json:
|
||
json_path = Path(args.render_from_json)
|
||
if not json_path.is_file():
|
||
slug = product_file_slug(product, industry)
|
||
json_path = output_dir / f"{slug}-analysis-data.json"
|
||
logger.info("从 JSON 渲染: %s", json_path)
|
||
with json_path.open(encoding="utf-8") as f:
|
||
raw = json.load(f)
|
||
stats = loader.load_basic_stats()
|
||
reviews = loader.load_reviews()
|
||
extractions = loader.load_comment_extractions()
|
||
persona_quote_max = int(cfg.get("persona_quote_max", 3))
|
||
rootcause_quote_max = int(cfg.get("rootcause_quote_max", 4))
|
||
all_clusters = loader.load_cluster_data()
|
||
personas = assign_persona_clusters(raw.get("personas", []), all_clusters)
|
||
cluster_data_rr = raw.get("cluster_data") or loader.build_cluster_prompt_data(
|
||
persona_sample_reviews=int(cfg.get("persona_sample_reviews", 5)),
|
||
)
|
||
physio_min = int(cfg.get("persona_physio_min_reviews", 5))
|
||
prepare_persona_catalog_for_llm(
|
||
cluster_data_rr.get("persona_cluster_catalog") or [],
|
||
all_clusters, reviews,
|
||
min_physio_reviews=physio_min,
|
||
)
|
||
personas = validate_persona_physiological_labels(
|
||
personas, cluster_data_rr, reviews, all_clusters, min_review_count=physio_min,
|
||
)
|
||
personas = compute_persona_pcts(personas, reviews, all_clusters)
|
||
personas = sort_personas_by_evidence(personas)
|
||
persona_quotes = pick_persona_quotes(
|
||
personas, reviews, extractions,
|
||
max_quotes=persona_quote_max,
|
||
)
|
||
neg_freq = calc_theme_freq(
|
||
raw.get("neg_themes", []), reviews, is_neg=True, extractions=extractions,
|
||
)
|
||
pos_freq = calc_theme_freq(
|
||
raw.get("pos_themes", []), reviews, is_neg=False, extractions=extractions,
|
||
)
|
||
per_asin_neg = calc_per_asin_theme_freq(
|
||
raw.get("neg_themes", []), reviews, is_neg=True, extractions=extractions,
|
||
)
|
||
per_asin_pos = calc_per_asin_theme_freq(
|
||
raw.get("pos_themes", []), reviews, is_neg=False, extractions=extractions,
|
||
)
|
||
neg_themes = recalc_neg_priorities(
|
||
raw.get("neg_themes", []), neg_freq, stats.neg_review_count, len(stats.asins), per_asin_neg,
|
||
)
|
||
asin_labels = build_asin_labels(stats)
|
||
data = {
|
||
**raw,
|
||
"stats": stats,
|
||
"personas": personas,
|
||
"persona_quotes": persona_quotes,
|
||
"neg_themes": neg_themes,
|
||
"neg_freq": neg_freq,
|
||
"pos_freq": pos_freq,
|
||
"per_asin_neg": per_asin_neg,
|
||
"per_asin_pos": per_asin_pos,
|
||
"asin_labels": asin_labels,
|
||
"asin_short_codes": build_asin_short_codes(stats),
|
||
"executive_summary": build_executive_summary(
|
||
stats, neg_freq, pos_freq, neg_themes, asin_labels=asin_labels,
|
||
pos_themes=raw.get("pos_themes", []),
|
||
),
|
||
"asin_theme_insights": build_asin_theme_insights(
|
||
stats, neg_themes, per_asin_neg, asin_labels,
|
||
),
|
||
}
|
||
if not data.get("keywords_neg") and data.get("neg_themes"):
|
||
_, _, kw_neg, kw_pos = prepare_keyword_display(
|
||
data["neg_themes"],
|
||
data.get("pos_themes") or [],
|
||
reviews,
|
||
personas,
|
||
data.get("keywords_llm") or {},
|
||
limit=6,
|
||
)
|
||
data["keywords_neg"] = kw_neg
|
||
data["keywords_pos"] = kw_pos
|
||
data["keywords"] = kw_neg + kw_pos
|
||
market_title, market_desc = enhanced_market_judgment(stats)
|
||
data["market_title"] = market_title
|
||
data["market_desc"] = market_desc
|
||
data["rootcauses"] = enrich_rootcause_quotes(
|
||
data.get("rootcauses") or [],
|
||
personas,
|
||
loader,
|
||
reviews,
|
||
persona_quotes=persona_quotes,
|
||
extractions=extractions,
|
||
max_quotes=rootcause_quote_max,
|
||
)
|
||
elif args.no_llm:
|
||
logger.warning("--no-llm 模式:跳过 LLM 调用,仅渲染模板")
|
||
# 使用空数据渲染(测试模板)
|
||
data = {
|
||
"stats": loader.load_basic_stats(),
|
||
"personas": [],
|
||
"neg_themes": [], "pos_themes": [],
|
||
"neg_freq": {}, "pos_freq": {},
|
||
"per_asin_neg": {}, "per_asin_pos": {},
|
||
"kano": [], "jtbd": [], "matrix": [], "rootcauses": [],
|
||
"keywords": [], "keywords_neg": [], "keywords_pos": [], "keywords_llm": {},
|
||
"persona_quotes": [],
|
||
"asin_labels": build_asin_labels(loader.load_basic_stats()),
|
||
"asin_short_codes": build_asin_short_codes(loader.load_basic_stats()),
|
||
"asin_theme_insights": [],
|
||
"market_title": "数据待生成", "market_desc": "请运行完整流程",
|
||
"executive_summary": {
|
||
"bullets": ["请运行完整流程生成分析"],
|
||
"opportunities": [],
|
||
"conclusion": "",
|
||
"insights": [],
|
||
},
|
||
}
|
||
else:
|
||
data = build_report_data(loader, cfg)
|
||
|
||
# 保存中间数据(调试用)
|
||
if args.save_data:
|
||
data_json = output_dir / f"{slug}-analysis-data.json"
|
||
with data_json.open("w", encoding="utf-8") as f:
|
||
json.dump(data, f, ensure_ascii=False, indent=2, default=str)
|
||
logger.info("分析数据已保存: %s", data_json)
|
||
|
||
# 渲染 HTML
|
||
logger.info("渲染 HTML...")
|
||
html_content = render_html(TEMPLATE_FILE, data, cfg)
|
||
|
||
output_html.parent.mkdir(parents=True, exist_ok=True)
|
||
output_html.write_text(html_content, encoding="utf-8")
|
||
logger.info("报告已生成: %s (%s KB)", output_html, len(html_content) // 1024)
|
||
|
||
# 如果 ECharts 内联模式且缓存存在,报告大小
|
||
if cfg.get("echarts_inline", True):
|
||
from echarts_builder import CACHE_FILE
|
||
if CACHE_FILE.is_file():
|
||
logger.info("ECharts 已内联(缓存: %s KB)", CACHE_FILE.stat().st_size // 1024)
|
||
|
||
print(f"\n✅ 报告已生成: {output_html}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|