增强 VOC 报告:Dashboard 可视化 + 多维分析 + 机会矩阵

- 重构报告布局:新增固定导航栏、Dashboard KPI 卡片、5 张 ECharts 图表
  (受众饼图、情感柱状图、需求 Top10、负面 Top10、词频雷达图)
- 增强 Prompt 分析深度:新增多维交叉洞察(场景/动机/人群矩阵),
  改进建议扩展为 6 条(短期/中期/长期/差异化梯度)
- 新增机会矩阵 JSON 标记 + 影响力×难度气泡图 + 时间线图 + 策略卡片
- 强制簇名 ≤15 字大白话风格,禁止学术化冗长描述
- 修复词频表重复发送浪费 token 的问题
- audience/pain_point 阶段不再受 10% 小簇过滤规则限制

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
OnesvmWhoops 2026-06-05 12:00:14 +08:00
parent 91e6c47fc0
commit 18a5e38796
13 changed files with 525 additions and 113 deletions

View file

@ -4,7 +4,7 @@ VOC 全流程:合并 → 清洗 → 结构化 → 向量化 →(聚类 ∥
用法::
# 全流程(--industry 默认 Pet Supplies;写入 sqlite 前默认清理旧库,keep-db不清理)
./310py/bin/python main_voc分析.py --input-dir 目录 --product "产品名" --keep-db
./310py/bin/python main_voc分析.py --input-dir '/Users/onesvmwhoops/Cursor_Project/VOC_LLM结构化/cat deterrent indoor ' --product "cat deterrent indoor" --keep-db
--industry "行业名"
# 断点续跑(步骤 4 起可省略 --product,自动读 voc_structured.sqlite)
./310py/bin/python main_voc分析.py --from-step 5

View file

@ -52,7 +52,26 @@
sentiment: Positive
category: Price
- instruction: "示例 4 教学:保留关键成分与症状等核心具体词汇。绝不能将带有具体成分/病症的词泛化提取。例如遇到「chicken flavor」(鸡肉风味)或「joint pain」(关节疼痛)时,必须保留核心修饰词,绝不能错误缩减提取为「flavor」或「pain」。"
- instruction: "示例 4 教学:非英语评论(例如西班牙语)输出必须全英文。禁止保留原文非英文片段,先理解语义再用英文短语表达。"
review: "El producto en sí está muy bien, viene con 2 cristales templados y 2 grips para los joycon. Pero es lo que he leido en algunos comentarios, la funda huele como a tabaco jajaja es raro pero es así"
output:
audience: "self"
pain_points: []
product_feedback:
- aspect: "product"
opinion: "it's fine"
sentiment: Positive
category: Quality
- aspect: "included accessories"
opinion: "comes with 2 tempered glass and 2 grips"
sentiment: Positive
category: Function
- aspect: "case smell"
opinion: "smells like tobacco, strange"
sentiment: Negative
category: Quality
- instruction: "示例 5 教学:保留关键成分与症状等核心具体词汇。绝不能将带有具体成分/病症的词泛化提取。例如遇到「chicken flavor」(鸡肉风味)或「joint pain」(关节疼痛)时,必须保留核心修饰词,绝不能错误缩减提取为「flavor」或「pain」。"
review: "My elderly cat suffers from severe joint pain. But he is very attracted by the chicken flavor of this supplement! It really helps him walk better."
output:
audience: "elderly cat"

View file

@ -1,3 +1,8 @@
## 语言规则(红线):
- 评论输入可能是英语、西班牙语、法语、德语、日语等**任意语言**。
- **所有输出字段值**(audience、pain_points 每一条、aspect、opinion)**必须是自然英文**。
- **禁止**保留原文非英文片段(如 está muy bien、huele como a tabaco、très bon);先理解语义,再用英文短语表达。
## 分析要求:
1. audience (为谁购买):

View file

@ -1,3 +1,3 @@
以下为 {n_keys} 段带前缀的英文评论,每段互相独立。请严格按 system 中的「批量输出格式」返回 JSON,顶层键为 {keys_literal}。
以下为 {n_keys} 段带前缀的用户评论(可能是任意语言),每段互相独立。所有输出字段值必须为英文。请严格按 system 中的「批量输出格式」返回 JSON,顶层键为 {keys_literal}。
{tagged_input}

View file

@ -161,7 +161,8 @@ def _report_marker_kwargs(
"marker_word_zh": markers[0],
"marker_word_category": markers[1],
"marker_cluster_names": markers[2],
"marker_report_html": markers[3],
"marker_opportunity_matrix": markers[3],
"marker_report_html": markers[4],
"wordcloud_top_n": wf.get("wordcloud_top_n", 180),
"word_category_classify_n": wf.get("word_category_classify_n", 180),
"outlier_label_zh": rp.get("outlier_label_zh", "未归类"),
@ -283,7 +284,7 @@ def validate_prompt_files() -> List[str]:
sch = get_schema()
if not word_categories(cfg):
errors.append("config.yaml: word_categories 为空")
if len(report_markers(sch)) != 4:
if len(report_markers(sch)) != 5:
errors.append("schema.yaml: report.markers 须为 4 项")
if not product_feedback_categories(sch):
errors.append("schema.yaml: extraction.product_feedback_categories 为空")

View file

@ -5,4 +5,14 @@
2.分受众分析 — 对 top2 受众分别写(1)用户需求(2)正面(3)负面(4)客观(仅用对应 2a/2b stage)。
- **(二)全部用户需求与产品反馈**:1.全部受众需求(3a)、2.正面、3.负面、4.客观(3b 三档);
每个 <ul> 只收纳对应 stage 的簇;占比=结构化短语数÷a;同 ul 内按短语数降序。正文禁用「观点 / 评价 / 痛点」。
3. **三、改进建议与机会**:保持模版 <h2> 与四条 <ol> 结构;建议须可执行,第 4 条单独写可放大的产品/市场机会;覆盖未满足需求、负面反馈与客观描述中的风险,勿复述本条款文字。
- **(三)多维交叉洞察**(新增):从聚类数据中推断以下维度并进行交叉分析:
1.**使用场景推断** — 从评论关键词和需求簇推断用户的典型使用场景(如日常家用、专业工作、礼品/转售、特定季节等),分析不同场景下的需求差异。
2.**购买动机分析** — 推断驱动购买的核心动机(功能刚需、性价比、品牌信任、替代竞品、尝新等),每个动机引用具体聚类数据支撑。
3.**人群×需求矩阵** — 将 top2 受众与 top 需求/反馈做交叉比对,找出各群体的独有关注点(A群体在意 X 但 B群体不在意)和共性盲区。
每条洞察必须引用具体数据占比或簇名,禁止空泛概括。
3. **三、改进建议与机会**:须严格按模版结构输出六条 <li>。
- 前三条为**短期快赢**建议(0-3月可落地),包括产品本身的微调、包装/说明书优化、Listing与A+页面优化。
- 第四条为**中期优化**(3-6月),涵盖配件组合/套装策略、服务升级(售后/质保/教程内容)、差异化定价与变体策略。
- 第五条为**长期布局**(6-12月),覆盖新品类延伸、场景化产品线、品牌社群与内容生态等前瞻性方向。
- 第六条为**差异化机会**,专门指出竞品未覆盖但本品有潜力占领的空白市场/场景/人群机会。
- 每条建议须关联具体数据洞察(引用对应簇名或占比),不可泛泛而谈。

View file

@ -4,7 +4,7 @@
{err_block}
【修正要点】
- 四个标记必须按顺序出现:{marker_word_zh}、{marker_word_category}、{marker_cluster_names}、{marker_report_html}(JSON 在前,HTML 最后)
- 五个标记必须按顺序出现:{marker_word_zh}、{marker_word_category}、{marker_cluster_names}、{marker_opportunity_matrix}、{marker_report_html}(JSON 在前,HTML 最后)
- JSON 段必须是严格合法 JSON:键与字符串均用英文双引号;例如 "words":["dog","cat"],禁止写成 "words:["dog"] 或 words:[
- 不要用 Markdown 代码围栏包裹 JSON;WORD_CATEGORY_JSON 的键仅限:{cats_literal}
- WORD_CATEGORY_JSON 必须是**一个** JSON 对象,words 仅来自 Top{word_category_classify_n};逐词尽量归类、不强制每类凑满;禁止「其他」键;禁止多段 `{{"某类":[...]}}, "另一类":[...]`

View file

@ -1,4 +1,4 @@
请严格按以下四段标记输出(标记外不要有任何文字;**顺序不可调换**):
请严格按以下五段标记输出(标记外不要有任何文字;**顺序不可调换**):
{marker_word_zh}
一行合法 JSON 对象:Top{wordcloud_top_n} 英文词 -> 中文,如 {{"dog":"狗","cat":"猫"}}。
@ -19,6 +19,18 @@
簇标签 -1 统一命名为「{outlier_label_zh}」。
命名规则:1_audience、3a_pain_global、3b_aspect_opinion_* 写完整业务簇名;
2a/2b 只写子主题(程序拼接受众名);2b 子主题禁止含情感词。
【簇名风格红线】每个簇名最多 15 个中文字,必须用日常口语,让运营人员一眼看懂。
正确示范:「多猫家庭」「喷头漏液」「猫在床上乱尿」「气味刺鼻」「完全无效」
错误示范:「针对床铺沙发等亲密区域的定点排尿抗议行为」「效力根本性不足导致乱尿行为完全无法被中断」
{marker_opportunity_matrix}
一个合法 JSON 数组,恰好 6 个元素,与 REPORT_HTML 中「三、改进建议与机会」的 6 条 <li> 一一对应。
每个元素格式为:
{{"title":"策略标题(≤15字)","phase":"short|mid|long|diff","impact":1-10,"effort":1-10,"data_ref":"引用的核心数据点(如'猫在床上乱尿 15.5%')"}}
- phase 取值:short=短期快赢,mid=中期优化,long=长期布局,diff=差异化机会
- impact=预期业务影响力(10=最大),effort=实施难度(10=最难)
- data_ref 须引用报告中具体的簇名或占比数据
示例:[{{"title":"升级缓释配方","phase":"short","impact":9,"effort":5,"data_ref":"效果仅持续数小时 6.8%"}}, ...]
{marker_report_html}
(最后输出)完整 HTML 文档,结构遵循上文模版;勿用 Markdown 代码围栏包裹。

View file

@ -1,8 +1,11 @@
**输出格式(必须严格遵守):**
1. 只允许输出标准 HTML,不允许 Markdown。
2. {marker_report_html} 内必须是完整 HTML 文档,包含 <html>、<head>、<body>;可保留模版中的 <!-- 映射注释 -->,浏览器不会显示。
3. 允许标签:<h1><h2><h3><h4><p><ul><ol><li><strong>;列表项格式须为 <strong>纯中文簇名</strong> (XX.X%):洞察。
3. 允许标签:<h1><h2><h3><h4><p><ul><ol><li><strong>;列表项格式须为 <strong>纯中文簇名</strong> (XX.X%):洞察。簇名必须控制在 15 字以内,用日常口语概括,禁止学术化长句。
4. 禁止 Markdown(#、**、``` 等)及 <table> 等未列出的标签。
4b. 【文风红线】洞察描述必须直白务实,每句只说一件事。禁止文学性夸张修辞(如"切肤之痛""灾难性崩塌""谋杀式""奇迹心理""令人愉悦"等)。保持客观商业语气。
5. 【红线】正文可见文字中禁止出现 1_audience|2、2a_pain_audience_c2|0、stage=、audience_c 等任何机器标识;仅 CLUSTER_NAMES JSON 键可保留原始 id。
6. 不要输出解释性前后缀;REPORT_HTML 段内只放 HTML 文档本体。
7. 四段标记顺序固定:先三个 JSON 段(WORD_ZH → WORD_CATEGORY → CLUSTER_NAMES),**最后**输出 REPORT_HTML,避免长 HTML 导致 JSON 被截断。
7. 五段标记顺序固定:先四个 JSON 段(WORD_ZH → WORD_CATEGORY → CLUSTER_NAMES → OPPORTUNITY_MATRIX),**最后**输出 REPORT_HTML,避免长 HTML 导致 JSON 被截断。
8. **(三)多维交叉洞察**的三个子板块各至少包含 3 条 <li> 分析;每条 <li> 必须以 <strong>洞察标题</strong> 开头并引用数据占比。
9. **三、改进建议与机会**的 <ol> 必须恰好 6 条 <li>,分别对应短期快赢(3条)、中期优化(1条)、长期布局(1条)、差异化机会(1条),每条以 <strong>策略标题:</strong> 开头。

View file

@ -2,3 +2,17 @@
报告必须专业、可读,正文中绝对禁止出现原始聚类 ID、stage 代码名或簇标签编号。
展示用语统一:用户需求、产品反馈、产品客观描述;禁止观点、评价、痛点等旧称。
你必须严格遵守 HTML 输出规范,禁止 Markdown。
【语言风格——红线要求】
你的读者是业务团队和高管,不是学术论文审稿人。务必:
- 用大白话写作,一句话只传达一个核心信息,避免多层从句嵌套。
- 簇名和标题限制在 15 个中文字以内,用最日常的语言概括核心要点。
正确示范:「猫在床/沙发上乱尿」「产品气味太刺鼻」「喷头漏液」「产品完全无效」
错误示范:「针对床铺沙发等亲密区域的定点排尿抗议行为」「效力根本性不足导致乱尿行为完全无法被中断」
- 描述要具体但不要堆砌修辞——说清"是什么、有多严重、影响谁"即可,删掉所有文学性修饰(如"切肤之痛""灾难性崩塌""谋杀式破坏")。
- 如果原始评论用了生动的英文原词,可以括号引用(如"stopped peeing"),但不要自行发明夸张措辞。
你的核心分析原则:
1. **多维交叉验证**:不仅分析"用户说了什么",还要推断其背后的使用场景、用户人群特征、购买动机,并交叉比对不同维度的数据,发现隐藏洞察。
2. **机会多元化**:改进方案绝不局限于现有产品本身的修改——应覆盖服务升级、配件组合、场景化套装、全新品类延伸、内容与社群运营等多条路径。方案之间要体现差异化梯度(短期快赢 → 中期优化 → 长期布局)。
3. **逻辑递进**:报告整体遵循「数据 → 洞察 → 机会 → 方案」的叙事线,每个结论都能回溯到具体数据支撑。

View file

@ -28,4 +28,5 @@ report:
- "===WORD_ZH_JSON==="
- "===WORD_CATEGORY_JSON==="
- "===CLUSTER_NAMES_JSON==="
- "===OPPORTUNITY_MATRIX_JSON==="
- "===REPORT_HTML==="

View file

@ -119,10 +119,10 @@ CATEGORY_COLORS: Dict[str, str] = {
WORDCLOUD_UNCATEGORIZED_COLOR = "#9ca3af"
TRANSLATE_CHARS_PER_REQUEST = 200_000
TRANSLATE_MAX_OUTPUT_TOKENS = 65536
REPORT_MAX_OUTPUT_TOKENS = 65536
TRANSLATE_MAX_OUTPUT_TOKENS = 200000
REPORT_MAX_OUTPUT_TOKENS = 200000
TRANSLATE_LLM_TIMEOUT_SEC = 600.0
REPORT_LLM_TIMEOUT_SEC = 600.0
REPORT_LLM_TIMEOUT_SEC = 720.0
REPORT_PARSE_MAX_RETRIES = 2
# JSON 段放前、HTML 放后:输出被 max_tokens 截断时优先保留可解析的 JSON
REPORT_MARKERS: Tuple[str, ...] = (
@ -510,10 +510,16 @@ def build_cluster_bundles(
stage_ratio = cnt / stage_total if stage_total else 0.0
ratio = stage_ratio
ratio_global = phrase_cnt / total_reviews if total_reviews else 0.0
skip_filter = (
stage == STAGE_1_AUDIENCE
or stage.startswith(STAGE_2A_PAIN_PREFIX)
or stage == STAGE_3A_PAIN
)
if (
min_cluster_review_ratio is not None
and stage_total > 0
and stage_ratio < min_cluster_review_ratio
and not skip_filter
):
logger.info(
"报告跳过小簇 %s / %s:%s 条评论 (本步骤 %.1f%% < %.0f%%)",
@ -977,10 +983,11 @@ def _build_report_prompt(
word_freq: List[Tuple[str, int]],
top2_audience_clusters: Sequence[int],
) -> Tuple[str, str]:
wf_table = word_freq[:WORD_FREQ_TABLE_N]
wf_classify = word_freq[:WORD_CATEGORY_CLASSIFY_N]
wf_table_lines = "\n".join(f"{w}\t{c}" for w, c in wf_table)
wf_classify_lines = "\n".join(f"{w}\t{c}" for w, c in wf_classify)
n_table = WORD_FREQ_TABLE_N
n_classify = WORD_CATEGORY_CLASSIFY_N
n_max = max(n_table, n_classify)
wf_max = word_freq[:n_max]
wf_lines = "\n".join(f"{w}\t{c}" for w, c in wf_max)
cats_literal = "、".join(WORD_CATEGORIES)
stage_map = _cluster_stage_mapping_guide(
bundles, top2_audience_clusters=top2_audience_clusters
@ -992,16 +999,23 @@ def _build_report_prompt(
top2_audience_clusters=top2_audience_clusters,
)
system = build_report_system()
if n_table == n_classify:
wf_block = (
f"【词频 Top{n_max}(word\\tcount,用于辅助撰写报告 + WORD_CATEGORY_JSON 分类,排名 1-{n_max})】\n"
f"{wf_lines}"
)
else:
wf_block = (
f"【词频 Top{n_max}(word\\tcount,排名 1-{n_max})】\n"
f"前 {n_table} 行用于辅助理解品类与撰写报告;前 {n_classify} 行**仅**用于 WORD_CATEGORY_JSON。\n"
f"{wf_lines}"
)
user = f"""请基于以下用户评论反馈的聚类结果,撰写一份详细的《{product_name}》产品改进建议报告。
行业背景:{industry}
数据清洗后有效评论总数 a = {total_reviews}
【词频 Top{WORD_FREQ_TABLE_N}(word\\tcount,辅助理解品类与撰写报告,排名 1-{WORD_FREQ_TABLE_N})】
{wf_table_lines}
【词频分类词表 Top{WORD_CATEGORY_CLASSIFY_N}(word\\tcount,**仅**用于 WORD_CATEGORY_JSON,排名 1-{WORD_CATEGORY_CLASSIFY_N})】
{wf_classify_lines}
{wf_block}
【聚类流程与报告小节映射】
{stage_map}
@ -1878,6 +1892,92 @@ def _build_word_category_map(category_data: Dict[str, Any]) -> Dict[str, List[st
return dict(by_word)
def _audience_pie_data(bundles: List[ClusterBundle]) -> List[dict]:
"""受众饼图:1_audience 阶段各簇的去重评论数。"""
return [
{"name": b.cluster_title_zh, "value": b.review_count}
for b in bundles
if b.stage == "1_audience"
]
def _sentiment_bar_data(bundles: List[ClusterBundle]) -> Dict[str, int]:
"""情感分布柱状图:3b 阶段按正/负/中汇总结构化短语数。"""
agg: Dict[str, int] = {"positive": 0, "negative": 0, "neutral": 0}
for b in bundles:
for key in agg:
if b.stage == f"3b_aspect_opinion_{key}":
agg[key] += b.phrase_count
return agg
def _pain_top_chart_data(
bundles: List[ClusterBundle], *, top_n: int = 10
) -> List[dict]:
"""全量需求 Top-N 条形图:3a_pain_global 阶段各簇短语数。"""
subset = [
b for b in bundles
if b.stage == STAGE_3A_PAIN and b.cluster_label != -1
]
subset.sort(key=lambda b: -b.phrase_count)
return [
{"name": b.cluster_title_zh, "value": b.phrase_count}
for b in subset[:top_n]
]
def _negative_top_chart_data(
bundles: List[ClusterBundle], *, top_n: int = 10
) -> List[dict]:
"""负面反馈 Top-N 条形图。"""
subset = [
b for b in bundles
if b.stage == STAGE_3B_NEGATIVE and b.cluster_label != -1
]
subset.sort(key=lambda b: -b.phrase_count)
return [
{"name": b.cluster_title_zh, "value": b.phrase_count}
for b in subset[:top_n]
]
def _radar_chart_data(
category_data: Dict[str, Any],
word_freq: List[Tuple[str, int]],
) -> List[dict]:
"""词频分类雷达图:各类别词频总和。"""
count_map = {w.lower(): c for w, c in word_freq}
result: List[dict] = []
for cat in WORD_CATEGORIES:
block = category_data.get(cat, {})
words = block.get("words", []) if isinstance(block, dict) else []
total = sum(count_map.get(str(w).lower(), 0) for w in words)
result.append({"name": cat, "value": total})
return result
def _compute_dashboard_kpis(
total_reviews: int,
bundles: List[ClusterBundle],
) -> Dict[str, Any]:
"""Dashboard 四张 KPI 卡片的数据。"""
audience_count = sum(
1 for b in bundles
if b.stage == "1_audience" and b.cluster_label != -1
)
sent = _sentiment_bar_data(bundles)
total_sent = sum(sent.values()) or 1
cluster_count = sum(1 for b in bundles if b.cluster_label != -1)
return {
"total_reviews": total_reviews,
"audience_count": audience_count,
"positive_ratio": round(sent["positive"] / total_sent * 100, 1),
"negative_ratio": round(sent["negative"] / total_sent * 100, 1),
"neutral_ratio": round(sent["neutral"] / total_sent * 100, 1),
"cluster_count": cluster_count,
}
def _wordcloud_color_for_categories(categories: List[str]) -> str:
if not categories:
return WORDCLOUD_UNCATEGORIZED_COLOR
@ -2163,7 +2263,7 @@ def _render_ai_validation_section(
phrases_panel = _wrap_ai_verify_panel("聚类效果验证", phrases_html)
return (
'<section id="sec-ai-verify">\n'
" <h2>AI分析效果验证</h2>\n"
" <h2>模型中间过程可视化</h2>\n"
' <p class="muted">以下为结构化与聚类结果的抽样展示,点击标题展开查看。</p>\n'
f" {structured_panel}\n"
f" {phrases_panel}\n"
@ -2179,20 +2279,37 @@ def _assemble_html(
phrases_html: str,
structured_html: str,
wordcloud_data: List[dict],
audience_pie_data: List[dict],
sentiment_data: Dict[str, int],
pain_top_data: List[dict],
negative_top_data: List[dict],
radar_data: List[dict],
kpis: Dict[str, Any],
) -> str:
"""按固定顺序机器拼装最终 HTML(不由 LLM 决定版块顺序)。"""
data_json = json.dumps(wordcloud_data, ensure_ascii=False)
"""按固定版块顺序拼装最终 HTML:Dashboard → 洞察报告 → 词频 → 附录。"""
page_title = f"{product_name} · 评论分析报告"
report_body = _strip_report_top_heading(report_html)
report_section = (
'<section id="sec-report">\n'
" <h2>产品改进建议报告</h2>\n"
f" {report_body}\n"
"</section>"
)
ai_verify_section = _render_ai_validation_section(structured_html, phrases_html)
# 顺序固定:改进建议 → 词频 → AI 分析效果验证(内含结构化 / 聚类,可展开)
wc_json = json.dumps(wordcloud_data, ensure_ascii=False)
aud_json = json.dumps(audience_pie_data, ensure_ascii=False)
sent_json = json.dumps(sentiment_data, ensure_ascii=False)
pain_json = json.dumps(pain_top_data, ensure_ascii=False)
neg_json = json.dumps(negative_top_data, ensure_ascii=False)
radar_json = json.dumps(radar_data, ensure_ascii=False)
kpi_reviews = f"{kpis['total_reviews']:,}"
kpi_audiences = kpis["audience_count"]
kpi_pos = kpis["positive_ratio"]
kpi_neg = kpis["negative_ratio"]
body_sections = "\n".join((report_section, wordfreq_html, ai_verify_section))
return f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
@ -2202,9 +2319,48 @@ def _assemble_html(
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/echarts-wordcloud@2/dist/echarts-wordcloud.min.js"></script>
<style>
:root {{ --c-primary: #2563eb; --c-pos: #16a34a; --c-neg: #dc2626; --c-neu: #6366f1;
--c-bg: #f8fafc; --c-border: #e2e8f0; --radius: 12px; }}
*, *::before, *::after {{ box-sizing: border-box; }}
body {{ font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
margin: 0 auto; padding: 24px; line-height: 1.6; color: #222; max-width: 1100px; }}
.muted {{ color: #666; font-size: 0.9em; }}
margin: 0; padding: 0; line-height: 1.6; color: #1e293b; background: #fff; }}
.muted {{ color: #64748b; font-size: 0.9em; }}
/* --- 导航栏 --- */
.report-nav {{ position: sticky; top: 0; z-index: 100; background: rgba(255,255,255,0.92);
backdrop-filter: blur(8px); border-bottom: 1px solid var(--c-border);
display: flex; align-items: center; gap: 0; padding: 0 24px; overflow-x: auto; }}
.report-nav a {{ padding: 14px 18px; font-size: 0.92em; font-weight: 500;
color: #475569; text-decoration: none; white-space: nowrap;
border-bottom: 2px solid transparent; transition: all 0.2s; }}
.report-nav a:hover {{ color: var(--c-primary); }}
.report-nav a.active {{ color: var(--c-primary); border-bottom-color: var(--c-primary); }}
.page-wrap {{ max-width: 1200px; margin: 0 auto; padding: 24px 28px 60px; }}
.page-title {{ font-size: 1.6em; font-weight: 700; margin: 0 0 8px; color: #0f172a; }}
.page-subtitle {{ color: #64748b; margin: 0 0 32px; font-size: 0.95em; }}
/* --- Dashboard --- */
.kpi-grid {{ display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 28px; }}
.kpi-card {{ background: var(--c-bg); border: 1px solid var(--c-border); border-radius: var(--radius);
padding: 20px 18px; text-align: center; }}
.kpi-value {{ font-size: 2em; font-weight: 700; color: #0f172a; line-height: 1.2; }}
.kpi-label {{ font-size: 0.85em; color: #64748b; margin-top: 4px; }}
.kpi-card.pos .kpi-value {{ color: var(--c-pos); }}
.kpi-card.neg .kpi-value {{ color: var(--c-neg); }}
.chart-grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 32px; }}
.chart-box {{ background: var(--c-bg); border: 1px solid var(--c-border);
border-radius: var(--radius); padding: 16px; }}
.chart-box h3 {{ margin: 0 0 8px; font-size: 1em; font-weight: 600; color: #334155; }}
.chart-container {{ width: 100%; height: 360px; }}
.chart-container.tall {{ height: 420px; }}
/* --- 各区块通用 --- */
section {{ margin-top: 40px; }}
section > h2 {{ font-size: 1.3em; border-bottom: 2px solid var(--c-primary);
padding-bottom: 10px; color: #0f172a; }}
/* --- 词频 --- */
#wordcloud-chart {{ width: 100%; height: 520px; margin: 16px 0 24px; }}
.wc-legend {{ display: flex; flex-wrap: wrap; gap: 8px 14px; margin: 8px 0 12px;
font-size: 0.88em; color: #444; }}
@ -2213,27 +2369,15 @@ def _assemble_html(
table {{ border-collapse: collapse; width: 100%; margin: 12px 0; }}
th, td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
th {{ background: #f0f0f0; }}
details {{ margin: 12px 0 24px; padding: 8px; background: #fafafa; border-radius: 6px; }}
#sec-ai-verify {{ margin-top: 40px; }}
details.ai-verify-panel {{ margin: 16px 0; padding: 0; border: 1px solid #e5e7eb;
background: #fff; }}
details.ai-verify-panel > summary {{ padding: 12px 16px; font-size: 1.05em; font-weight: 600;
cursor: pointer; list-style: none; background: #f8fafc; border-radius: 6px 6px 0 0; }}
details.ai-verify-panel > summary::-webkit-details-marker {{ display: none; }}
details.ai-verify-panel > summary::before {{ content: "▸ "; color: #2563eb; }}
details.ai-verify-panel[open] > summary::before {{ content: "▾ "; }}
details.ai-verify-panel[open] > summary {{ border-bottom: 1px solid #e5e7eb; }}
.ai-verify-body {{ padding: 12px 16px 16px; }}
.stage-h {{ margin-top: 32px; border-bottom: 1px solid #eee; padding-bottom: 8px; }}
.wf-categories {{ margin: 24px 0; }}
.wf-cat {{ margin-bottom: 20px; }}
.wf-pager {{ margin: 16px 0; }}
.wf-tab-bar {{ margin-bottom: 8px; }}
.wf-tab {{ margin: 4px 8px 4px 0; padding: 6px 12px; cursor: pointer;
border: 1px solid #ccc; background: #fff; border-radius: 4px; }}
.wf-tab.active {{ background: #2563eb; color: #fff; border-color: #2563eb; }}
.wf-tab.active {{ background: var(--c-primary); color: #fff; border-color: var(--c-primary); }}
.wf-nav {{ margin: 12px 0; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }}
.wf-table-wrap {{ max-height: 520px; overflow: auto; border: 1px solid #e5e7eb; border-radius: 8px; }}
.wf-table-wrap {{ max-height: 520px; overflow: auto; border: 1px solid var(--c-border); border-radius: 8px; }}
.wf-freq-table thead th {{ position: sticky; top: 0; z-index: 1; box-shadow: 0 1px 0 #ddd; }}
.wf-freq-table tbody tr:nth-child(even) {{ background: #fafafa; }}
.wf-freq-table tbody tr:hover {{ background: #f0f7ff; }}
@ -2241,62 +2385,236 @@ def _assemble_html(
.wf-row.wf-visible {{ display: table-row; }}
.wf-count {{ min-width: 120px; }}
.wf-count .wf-bar {{ display: inline-block; height: 10px; margin-right: 8px;
background: linear-gradient(90deg, #93c5fd, #2563eb); border-radius: 2px; vertical-align: middle; }}
background: linear-gradient(90deg, #93c5fd, var(--c-primary)); border-radius: 2px; vertical-align: middle; }}
.wf-en {{ font-family: ui-monospace, monospace; font-size: 0.92em; }}
#sec-wordfreq {{ margin-top: 40px; }}
.struct-sample {{ margin: 24px 0; padding: 16px; border: 1px solid #e5e7eb;
/* --- 附录 --- */
details {{ margin: 12px 0 24px; padding: 8px; background: #fafafa; border-radius: 6px; }}
details.ai-verify-panel {{ margin: 16px 0; padding: 0; border: 1px solid var(--c-border);
background: #fff; }}
details.ai-verify-panel > summary {{ padding: 12px 16px; font-size: 1.05em; font-weight: 600;
cursor: pointer; list-style: none; background: var(--c-bg); border-radius: 6px 6px 0 0; }}
details.ai-verify-panel > summary::-webkit-details-marker {{ display: none; }}
details.ai-verify-panel > summary::before {{ content: "\\25B8 "; color: var(--c-primary); }}
details.ai-verify-panel[open] > summary::before {{ content: "\\25BE "; }}
details.ai-verify-panel[open] > summary {{ border-bottom: 1px solid var(--c-border); }}
.ai-verify-body {{ padding: 12px 16px 16px; }}
.stage-h {{ margin-top: 32px; border-bottom: 1px solid #eee; padding-bottom: 8px; }}
.struct-sample {{ margin: 24px 0; padding: 16px; border: 1px solid var(--c-border);
border-radius: 8px; background: #fafafa; }}
.struct-sample h4 {{ margin: 0 0 12px; }}
.struct-review {{ white-space: pre-wrap; word-break: break-word; margin: 8px 0 16px; }}
.struct-json {{ margin: 0; padding: 12px; background: #fff; border: 1px solid #e5e7eb;
.struct-json {{ margin: 0; padding: 12px; background: #fff; border: 1px solid var(--c-border);
border-radius: 6px; overflow-x: auto; font-size: 0.9em; line-height: 1.5; }}
@media (max-width: 768px) {{
.kpi-grid {{ grid-template-columns: repeat(2, 1fr); }}
.chart-grid {{ grid-template-columns: 1fr; }}
.report-nav {{ padding: 0 12px; }}
.report-nav a {{ padding: 12px 12px; font-size: 0.85em; }}
.page-wrap {{ padding: 16px 14px 40px; }}
}}
</style>
</head>
<body>
<h1>{page_title}</h1>
<nav class="report-nav" id="main-nav">
<a href="#sec-dashboard">数据总览</a>
<a href="#sec-report">用户洞察</a>
<a href="#sec-wordfreq">词频分析</a>
<a href="#sec-ai-verify">附录</a>
</nav>
<div class="page-wrap">
<h1 class="page-title">{page_title}</h1>
<p class="page-subtitle">数据 \\u2192 洞察 \\u2192 机会 \\u2192 方案</p>
<!-- ========== 数据总览 ========== -->
<section id="sec-dashboard">
<h2>数据总览</h2>
<div class="kpi-grid">
<div class="kpi-card">
<div class="kpi-value">{kpi_reviews}</div>
<div class="kpi-label">有效评论数</div>
</div>
<div class="kpi-card">
<div class="kpi-value">{kpi_audiences}</div>
<div class="kpi-label">受众群体</div>
</div>
<div class="kpi-card pos">
<div class="kpi-value">{kpi_pos}%</div>
<div class="kpi-label">正面反馈占比</div>
</div>
<div class="kpi-card neg">
<div class="kpi-value">{kpi_neg}%</div>
<div class="kpi-label">负面反馈占比</div>
</div>
</div>
<div class="chart-grid">
<div class="chart-box">
<h3>受众画像分布</h3>
<div id="audience-chart" class="chart-container"></div>
</div>
<div class="chart-box">
<h3>产品反馈情感分布</h3>
<div id="sentiment-chart" class="chart-container"></div>
</div>
<div class="chart-box">
<h3>用户需求 Top 10</h3>
<div id="pain-top-chart" class="chart-container tall"></div>
</div>
<div class="chart-box">
<h3>负面反馈 Top 10</h3>
<div id="negative-top-chart" class="chart-container tall"></div>
</div>
</div>
<div class="chart-grid">
<div class="chart-box">
<h3>词频分类关注度</h3>
<div id="radar-chart" class="chart-container"></div>
</div>
</div>
</section>
<!-- ========== 用户洞察与行动方案 ========== -->
{body_sections}
<script>
(function() {{
const el = document.getElementById('wordcloud-chart');
if (el && typeof echarts !== 'undefined') {{
const chart = echarts.init(el);
chart.setOption({{
</div><!-- .page-wrap -->
<script>
(function() {{
/* --- 工具函数 --- */
var E = typeof echarts !== 'undefined' ? echarts : null;
function initChart(id, opt) {{
var el = document.getElementById(id);
if (!el || !E) return null;
var c = E.init(el);
c.setOption(opt);
window.addEventListener('resize', function() {{ c.resize(); }});
return c;
}}
/* --- 受众饼图 --- */
var audData = {aud_json};
if (audData.length) {{
initChart('audience-chart', {{
tooltip: {{ trigger: 'item', formatter: '{{b}}: {{c}} 条 ({{d}}%)' }},
color: ['#3b82f6','#f59e0b','#10b981','#8b5cf6','#ef4444','#06b6d4','#ec4899','#84cc16'],
series: [{{
type: 'pie', radius: ['30%','70%'], roseType: 'radius',
itemStyle: {{ borderRadius: 6, borderColor: '#fff', borderWidth: 2 }},
label: {{ formatter: '{{b}}\\n{{d}}%' }},
data: audData
}}]
}});
}}
/* --- 情感柱状图 --- */
var sentRaw = {sent_json};
initChart('sentiment-chart', {{
tooltip: {{ trigger: 'axis' }},
xAxis: {{ type: 'category', data: ['\\u6b63\\u9762\\u53cd\\u9988','\\u8d1f\\u9762\\u53cd\\u9988','\\u5ba2\\u89c2\\u63cf\\u8ff0'],
axisLabel: {{ fontSize: 13 }} }},
yAxis: {{ type: 'value', name: '\\u7ed3\\u6784\\u5316\\u77ed\\u8bed\\u6570' }},
series: [{{
type: 'bar', barWidth: '45%',
data: [
{{ value: sentRaw.positive || 0, itemStyle: {{ color: '#16a34a' }} }},
{{ value: sentRaw.negative || 0, itemStyle: {{ color: '#dc2626' }} }},
{{ value: sentRaw.neutral || 0, itemStyle: {{ color: '#6366f1' }} }}
],
label: {{ show: true, position: 'top', fontWeight: 'bold' }}
}}]
}});
/* --- 需求 Top-N 横向 Bar --- */
var painData = {pain_json};
if (painData.length) {{
initChart('pain-top-chart', {{
tooltip: {{ trigger: 'axis', axisPointer: {{ type: 'shadow' }} }},
grid: {{ left: '35%', right: '8%', top: 10, bottom: 20 }},
yAxis: {{ type: 'category', data: painData.map(function(d){{ return d.name; }}).reverse(),
axisLabel: {{ width: 200, overflow: 'truncate', fontSize: 12 }} }},
xAxis: {{ type: 'value', name: '\\u77ed\\u8bed\\u6570' }},
series: [{{ type: 'bar', data: painData.map(function(d){{ return d.value; }}).reverse(),
itemStyle: {{ color: '#3b82f6', borderRadius: [0,4,4,0] }},
label: {{ show: true, position: 'right' }} }}]
}});
}}
/* --- 负面反馈 Top-N 横向 Bar --- */
var negData = {neg_json};
if (negData.length) {{
initChart('negative-top-chart', {{
tooltip: {{ trigger: 'axis', axisPointer: {{ type: 'shadow' }} }},
grid: {{ left: '35%', right: '8%', top: 10, bottom: 20 }},
yAxis: {{ type: 'category', data: negData.map(function(d){{ return d.name; }}).reverse(),
axisLabel: {{ width: 200, overflow: 'truncate', fontSize: 12 }} }},
xAxis: {{ type: 'value', name: '\\u77ed\\u8bed\\u6570' }},
series: [{{ type: 'bar', data: negData.map(function(d){{ return d.value; }}).reverse(),
itemStyle: {{ color: '#dc2626', borderRadius: [0,4,4,0] }},
label: {{ show: true, position: 'right' }} }}]
}});
}}
/* --- 雷达图 --- */
var radarData = {radar_json};
if (radarData.length) {{
var maxVal = Math.max.apply(null, radarData.map(function(d){{ return d.value; }})) || 1;
initChart('radar-chart', {{
tooltip: {{}},
radar: {{
indicator: radarData.map(function(d) {{
return {{ name: d.name, max: Math.ceil(maxVal * 1.2) }};
}}),
shape: 'circle',
splitArea: {{ areaStyle: {{ color: ['rgba(37,99,235,0.02)','rgba(37,99,235,0.05)'] }} }}
}},
series: [{{
type: 'radar',
data: [{{ value: radarData.map(function(d){{ return d.value; }}),
areaStyle: {{ color: 'rgba(37,99,235,0.15)' }},
lineStyle: {{ color: '#3b82f6', width: 2 }},
itemStyle: {{ color: '#3b82f6' }} }}]
}}]
}});
}}
/* --- 词云 --- */
var wcData = {wc_json};
if (wcData.length) {{
initChart('wordcloud-chart', {{
tooltip: {{
show: true,
formatter: function(p) {{
var d = p.data || {{}};
var rank = d.rank ? ('#' + d.rank + ' ') : '';
var cnt = d.count != null ? d.count : p.value;
var zh = d.zh && d.zh !== d.name ? ('<br/>中文: ' + d.zh) : '';
var zh = d.zh && d.zh !== d.name ? ('<br/>\\u4e2d\\u6587: ' + d.zh) : '';
var cat = (d.categories && d.categories.length)
? ('<br/>分类: ' + d.categories.join('、'))
: (d.category ? ('<br/>分类: ' + d.category) : '<br/>分类: 未分类');
return rank + (d.name || p.name) + zh + cat + '<br/>次数: ' + cnt;
? ('<br/>\\u5206\\u7c7b: ' + d.categories.join('\\u3001'))
: (d.category ? ('<br/>\\u5206\\u7c7b: ' + d.category) : '<br/>\\u5206\\u7c7b: \\u672a\\u5206\\u7c7b');
return rank + (d.name || p.name) + zh + cat + '<br/>\\u6b21\\u6570: ' + cnt;
}}
}},
series: [{{
type: 'wordCloud',
shape: 'circle',
width: '95%',
height: '95%',
type: 'wordCloud', shape: 'circle', width: '95%', height: '95%',
sizeRange: [{WORDCLOUD_SIZE_MIN}, {WORDCLOUD_SIZE_MAX}],
rotationRange: [-15, 15],
rotationStep: 15,
gridSize: 8,
drawOutOfBound: false,
layoutAnimation: true,
rotationRange: [-15, 15], rotationStep: 15, gridSize: 8,
drawOutOfBound: false, layoutAnimation: true,
textStyle: {{ fontFamily: 'system-ui, -apple-system, sans-serif' }},
emphasis: {{ focus: 'self', textStyle: {{ shadowBlur: 6, shadowColor: '#333' }} }},
data: {data_json}
data: wcData
}}]
}});
window.addEventListener('resize', function() {{ chart.resize(); }});
}}
const rows = Array.from(document.querySelectorAll('.wf-row'));
const tabs = Array.from(document.querySelectorAll('.wf-tab'));
const label = document.getElementById('wf-page-label');
const pageCount = tabs.length || 1;
let cur = 1;
/* --- 词频分页 --- */
var rows = Array.from(document.querySelectorAll('.wf-row'));
var tabs = Array.from(document.querySelectorAll('.wf-tab'));
var label = document.getElementById('wf-page-label');
var pageCount = tabs.length || 1;
var cur = 1;
function showPage(n) {{
cur = Math.max(1, Math.min(n, pageCount));
rows.forEach(function(r) {{
@ -2305,20 +2623,35 @@ def _assemble_html(
tabs.forEach(function(t) {{
t.classList.toggle('active', parseInt(t.dataset.page, 10) === cur);
}});
if (label) label.textContent = '第 ' + cur + ' / ' + pageCount + ' 页';
if (label) label.textContent = '\\u7b2c ' + cur + ' / ' + pageCount + ' \\u9875';
}}
tabs.forEach(function(t) {{
t.addEventListener('click', function() {{
showPage(parseInt(t.dataset.page, 10));
}});
t.addEventListener('click', function() {{ showPage(parseInt(t.dataset.page, 10)); }});
}});
var prev = document.getElementById('wf-prev');
var next = document.getElementById('wf-next');
if (prev) prev.addEventListener('click', function() {{ showPage(cur - 1); }});
if (next) next.addEventListener('click', function() {{ showPage(cur + 1); }});
showPage(1);
}})();
</script>
/* --- 导航高亮 --- */
var navLinks = document.querySelectorAll('.report-nav a');
var sectionIds = Array.from(navLinks).map(function(a) {{ return a.getAttribute('href').slice(1); }});
function updateNav() {{
var scrollY = window.scrollY + 80;
var active = sectionIds[0];
sectionIds.forEach(function(id) {{
var el = document.getElementById(id);
if (el && el.offsetTop <= scrollY) active = id;
}});
navLinks.forEach(function(a) {{
a.classList.toggle('active', a.getAttribute('href') === '#' + active);
}});
}}
window.addEventListener('scroll', updateNav);
updateNav();
}})();
</script>
</body>
</html>
"""
@ -2416,6 +2749,14 @@ def generate_report(
cleaned_csv=cleaned_csv,
)
structured_sec = _render_structured_validation_section(struct_samples)
aud_pie = _audience_pie_data(bundles)
sent_bar = _sentiment_bar_data(bundles)
pain_top = _pain_top_chart_data(bundles, top_n=10)
neg_top = _negative_top_chart_data(bundles, top_n=10)
radar = _radar_chart_data(category_data, word_freq)
kpis = _compute_dashboard_kpis(total_reviews, bundles)
html = _assemble_html(
product_name=product_name,
wordfreq_html=wordfreq_sec,
@ -2423,6 +2764,12 @@ def generate_report(
phrases_html=phrases_sec,
structured_html=structured_sec,
wordcloud_data=wc_data,
audience_pie_data=aud_pie,
sentiment_data=sent_bar,
pain_top_data=pain_top,
negative_top_data=neg_top,
radar_data=radar,
kpis=kpis,
)
output_html.parent.mkdir(parents=True, exist_ok=True)
output_html.write_text(html, encoding="utf-8")

View file

@ -78,7 +78,7 @@ BATCH_COUNT_MIN = 1
# 动态分批时单批评论条数上限(避免单请求过大导致输出截断)
DEFAULT_MAX_BATCH_REVIEWS = 100
# Chat 批间并行;与 embedding 共用账号时不宜过高,避免连带 429
STRUCT_DEFAULT_WORKERS = 8
STRUCT_DEFAULT_WORKERS = 20
def _resolve_max_batch_reviews(explicit: int | None = None) -> int: