增强 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:
parent
91e6c47fc0
commit
18a5e38796
13 changed files with 525 additions and 113 deletions
|
|
@ -4,7 +4,7 @@ VOC 全流程:合并 → 清洗 → 结构化 → 向量化 →(聚类 ∥
|
||||||
用法::
|
用法::
|
||||||
|
|
||||||
# 全流程(--industry 默认 Pet Supplies;写入 sqlite 前默认清理旧库,keep-db不清理)
|
# 全流程(--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 "行业名"
|
--industry "行业名"
|
||||||
# 断点续跑(步骤 4 起可省略 --product,自动读 voc_structured.sqlite)
|
# 断点续跑(步骤 4 起可省略 --product,自动读 voc_structured.sqlite)
|
||||||
./310py/bin/python main_voc分析.py --from-step 5
|
./310py/bin/python main_voc分析.py --from-step 5
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,26 @@
|
||||||
sentiment: Positive
|
sentiment: Positive
|
||||||
category: Price
|
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."
|
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:
|
output:
|
||||||
audience: "elderly cat"
|
audience: "elderly cat"
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,8 @@
|
||||||
|
## 语言规则(红线):
|
||||||
|
- 评论输入可能是英语、西班牙语、法语、德语、日语等**任意语言**。
|
||||||
|
- **所有输出字段值**(audience、pain_points 每一条、aspect、opinion)**必须是自然英文**。
|
||||||
|
- **禁止**保留原文非英文片段(如 está muy bien、huele como a tabaco、très bon);先理解语义,再用英文短语表达。
|
||||||
|
|
||||||
## 分析要求:
|
## 分析要求:
|
||||||
|
|
||||||
1. audience (为谁购买):
|
1. audience (为谁购买):
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
以下为 {n_keys} 段带前缀的英文评论,每段互相独立。请严格按 system 中的「批量输出格式」返回 JSON,顶层键为 {keys_literal}。
|
以下为 {n_keys} 段带前缀的用户评论(可能是任意语言),每段互相独立。所有输出字段值必须为英文。请严格按 system 中的「批量输出格式」返回 JSON,顶层键为 {keys_literal}。
|
||||||
|
|
||||||
{tagged_input}
|
{tagged_input}
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,8 @@ def _report_marker_kwargs(
|
||||||
"marker_word_zh": markers[0],
|
"marker_word_zh": markers[0],
|
||||||
"marker_word_category": markers[1],
|
"marker_word_category": markers[1],
|
||||||
"marker_cluster_names": markers[2],
|
"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),
|
"wordcloud_top_n": wf.get("wordcloud_top_n", 180),
|
||||||
"word_category_classify_n": wf.get("word_category_classify_n", 180),
|
"word_category_classify_n": wf.get("word_category_classify_n", 180),
|
||||||
"outlier_label_zh": rp.get("outlier_label_zh", "未归类"),
|
"outlier_label_zh": rp.get("outlier_label_zh", "未归类"),
|
||||||
|
|
@ -283,7 +284,7 @@ def validate_prompt_files() -> List[str]:
|
||||||
sch = get_schema()
|
sch = get_schema()
|
||||||
if not word_categories(cfg):
|
if not word_categories(cfg):
|
||||||
errors.append("config.yaml: word_categories 为空")
|
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 项")
|
errors.append("schema.yaml: report.markers 须为 4 项")
|
||||||
if not product_feedback_categories(sch):
|
if not product_feedback_categories(sch):
|
||||||
errors.append("schema.yaml: extraction.product_feedback_categories 为空")
|
errors.append("schema.yaml: extraction.product_feedback_categories 为空")
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,14 @@
|
||||||
2.分受众分析 — 对 top2 受众分别写(1)用户需求(2)正面(3)负面(4)客观(仅用对应 2a/2b stage)。
|
2.分受众分析 — 对 top2 受众分别写(1)用户需求(2)正面(3)负面(4)客观(仅用对应 2a/2b stage)。
|
||||||
- **(二)全部用户需求与产品反馈**:1.全部受众需求(3a)、2.正面、3.负面、4.客观(3b 三档);
|
- **(二)全部用户需求与产品反馈**:1.全部受众需求(3a)、2.正面、3.负面、4.客观(3b 三档);
|
||||||
每个 <ul> 只收纳对应 stage 的簇;占比=结构化短语数÷a;同 ul 内按短语数降序。正文禁用「观点 / 评价 / 痛点」。
|
每个 <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月),覆盖新品类延伸、场景化产品线、品牌社群与内容生态等前瞻性方向。
|
||||||
|
- 第六条为**差异化机会**,专门指出竞品未覆盖但本品有潜力占领的空白市场/场景/人群机会。
|
||||||
|
- 每条建议须关联具体数据洞察(引用对应簇名或占比),不可泛泛而谈。
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
{err_block}
|
{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:[
|
- JSON 段必须是严格合法 JSON:键与字符串均用英文双引号;例如 "words":["dog","cat"],禁止写成 "words:["dog"] 或 words:[
|
||||||
- 不要用 Markdown 代码围栏包裹 JSON;WORD_CATEGORY_JSON 的键仅限:{cats_literal}
|
- 不要用 Markdown 代码围栏包裹 JSON;WORD_CATEGORY_JSON 的键仅限:{cats_literal}
|
||||||
- WORD_CATEGORY_JSON 必须是**一个** JSON 对象,words 仅来自 Top{word_category_classify_n};逐词尽量归类、不强制每类凑满;禁止「其他」键;禁止多段 `{{"某类":[...]}}, "另一类":[...]`
|
- WORD_CATEGORY_JSON 必须是**一个** JSON 对象,words 仅来自 Top{word_category_classify_n};逐词尽量归类、不强制每类凑满;禁止「其他」键;禁止多段 `{{"某类":[...]}}, "另一类":[...]`
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
请严格按以下四段标记输出(标记外不要有任何文字;**顺序不可调换**):
|
请严格按以下五段标记输出(标记外不要有任何文字;**顺序不可调换**):
|
||||||
|
|
||||||
{marker_word_zh}
|
{marker_word_zh}
|
||||||
一行合法 JSON 对象:Top{wordcloud_top_n} 英文词 -> 中文,如 {{"dog":"狗","cat":"猫"}}。
|
一行合法 JSON 对象:Top{wordcloud_top_n} 英文词 -> 中文,如 {{"dog":"狗","cat":"猫"}}。
|
||||||
|
|
@ -19,6 +19,18 @@
|
||||||
簇标签 -1 统一命名为「{outlier_label_zh}」。
|
簇标签 -1 统一命名为「{outlier_label_zh}」。
|
||||||
命名规则:1_audience、3a_pain_global、3b_aspect_opinion_* 写完整业务簇名;
|
命名规则:1_audience、3a_pain_global、3b_aspect_opinion_* 写完整业务簇名;
|
||||||
2a/2b 只写子主题(程序拼接受众名);2b 子主题禁止含情感词。
|
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}
|
{marker_report_html}
|
||||||
(最后输出)完整 HTML 文档,结构遵循上文模版;勿用 Markdown 代码围栏包裹。
|
(最后输出)完整 HTML 文档,结构遵循上文模版;勿用 Markdown 代码围栏包裹。
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
**输出格式(必须严格遵守):**
|
**输出格式(必须严格遵守):**
|
||||||
1. 只允许输出标准 HTML,不允许 Markdown。
|
1. 只允许输出标准 HTML,不允许 Markdown。
|
||||||
2. {marker_report_html} 内必须是完整 HTML 文档,包含 <html>、<head>、<body>;可保留模版中的 <!-- 映射注释 -->,浏览器不会显示。
|
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> 等未列出的标签。
|
4. 禁止 Markdown(#、**、``` 等)及 <table> 等未列出的标签。
|
||||||
|
4b. 【文风红线】洞察描述必须直白务实,每句只说一件事。禁止文学性夸张修辞(如"切肤之痛""灾难性崩塌""谋杀式""奇迹心理""令人愉悦"等)。保持客观商业语气。
|
||||||
5. 【红线】正文可见文字中禁止出现 1_audience|2、2a_pain_audience_c2|0、stage=、audience_c 等任何机器标识;仅 CLUSTER_NAMES JSON 键可保留原始 id。
|
5. 【红线】正文可见文字中禁止出现 1_audience|2、2a_pain_audience_c2|0、stage=、audience_c 等任何机器标识;仅 CLUSTER_NAMES JSON 键可保留原始 id。
|
||||||
6. 不要输出解释性前后缀;REPORT_HTML 段内只放 HTML 文档本体。
|
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> 开头。
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,17 @@
|
||||||
报告必须专业、可读,正文中绝对禁止出现原始聚类 ID、stage 代码名或簇标签编号。
|
报告必须专业、可读,正文中绝对禁止出现原始聚类 ID、stage 代码名或簇标签编号。
|
||||||
展示用语统一:用户需求、产品反馈、产品客观描述;禁止观点、评价、痛点等旧称。
|
展示用语统一:用户需求、产品反馈、产品客观描述;禁止观点、评价、痛点等旧称。
|
||||||
你必须严格遵守 HTML 输出规范,禁止 Markdown。
|
你必须严格遵守 HTML 输出规范,禁止 Markdown。
|
||||||
|
|
||||||
|
【语言风格——红线要求】
|
||||||
|
你的读者是业务团队和高管,不是学术论文审稿人。务必:
|
||||||
|
- 用大白话写作,一句话只传达一个核心信息,避免多层从句嵌套。
|
||||||
|
- 簇名和标题限制在 15 个中文字以内,用最日常的语言概括核心要点。
|
||||||
|
正确示范:「猫在床/沙发上乱尿」「产品气味太刺鼻」「喷头漏液」「产品完全无效」
|
||||||
|
错误示范:「针对床铺沙发等亲密区域的定点排尿抗议行为」「效力根本性不足导致乱尿行为完全无法被中断」
|
||||||
|
- 描述要具体但不要堆砌修辞——说清"是什么、有多严重、影响谁"即可,删掉所有文学性修饰(如"切肤之痛""灾难性崩塌""谋杀式破坏")。
|
||||||
|
- 如果原始评论用了生动的英文原词,可以括号引用(如"stopped peeing"),但不要自行发明夸张措辞。
|
||||||
|
|
||||||
|
你的核心分析原则:
|
||||||
|
1. **多维交叉验证**:不仅分析"用户说了什么",还要推断其背后的使用场景、用户人群特征、购买动机,并交叉比对不同维度的数据,发现隐藏洞察。
|
||||||
|
2. **机会多元化**:改进方案绝不局限于现有产品本身的修改——应覆盖服务升级、配件组合、场景化套装、全新品类延伸、内容与社群运营等多条路径。方案之间要体现差异化梯度(短期快赢 → 中期优化 → 长期布局)。
|
||||||
|
3. **逻辑递进**:报告整体遵循「数据 → 洞察 → 机会 → 方案」的叙事线,每个结论都能回溯到具体数据支撑。
|
||||||
|
|
|
||||||
|
|
@ -28,4 +28,5 @@ report:
|
||||||
- "===WORD_ZH_JSON==="
|
- "===WORD_ZH_JSON==="
|
||||||
- "===WORD_CATEGORY_JSON==="
|
- "===WORD_CATEGORY_JSON==="
|
||||||
- "===CLUSTER_NAMES_JSON==="
|
- "===CLUSTER_NAMES_JSON==="
|
||||||
|
- "===OPPORTUNITY_MATRIX_JSON==="
|
||||||
- "===REPORT_HTML==="
|
- "===REPORT_HTML==="
|
||||||
|
|
|
||||||
487
voc_report.py
487
voc_report.py
|
|
@ -119,10 +119,10 @@ CATEGORY_COLORS: Dict[str, str] = {
|
||||||
WORDCLOUD_UNCATEGORIZED_COLOR = "#9ca3af"
|
WORDCLOUD_UNCATEGORIZED_COLOR = "#9ca3af"
|
||||||
|
|
||||||
TRANSLATE_CHARS_PER_REQUEST = 200_000
|
TRANSLATE_CHARS_PER_REQUEST = 200_000
|
||||||
TRANSLATE_MAX_OUTPUT_TOKENS = 65536
|
TRANSLATE_MAX_OUTPUT_TOKENS = 200000
|
||||||
REPORT_MAX_OUTPUT_TOKENS = 65536
|
REPORT_MAX_OUTPUT_TOKENS = 200000
|
||||||
TRANSLATE_LLM_TIMEOUT_SEC = 600.0
|
TRANSLATE_LLM_TIMEOUT_SEC = 600.0
|
||||||
REPORT_LLM_TIMEOUT_SEC = 600.0
|
REPORT_LLM_TIMEOUT_SEC = 720.0
|
||||||
REPORT_PARSE_MAX_RETRIES = 2
|
REPORT_PARSE_MAX_RETRIES = 2
|
||||||
# JSON 段放前、HTML 放后:输出被 max_tokens 截断时优先保留可解析的 JSON
|
# JSON 段放前、HTML 放后:输出被 max_tokens 截断时优先保留可解析的 JSON
|
||||||
REPORT_MARKERS: Tuple[str, ...] = (
|
REPORT_MARKERS: Tuple[str, ...] = (
|
||||||
|
|
@ -510,10 +510,16 @@ def build_cluster_bundles(
|
||||||
stage_ratio = cnt / stage_total if stage_total else 0.0
|
stage_ratio = cnt / stage_total if stage_total else 0.0
|
||||||
ratio = stage_ratio
|
ratio = stage_ratio
|
||||||
ratio_global = phrase_cnt / total_reviews if total_reviews else 0.0
|
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 (
|
if (
|
||||||
min_cluster_review_ratio is not None
|
min_cluster_review_ratio is not None
|
||||||
and stage_total > 0
|
and stage_total > 0
|
||||||
and stage_ratio < min_cluster_review_ratio
|
and stage_ratio < min_cluster_review_ratio
|
||||||
|
and not skip_filter
|
||||||
):
|
):
|
||||||
logger.info(
|
logger.info(
|
||||||
"报告跳过小簇 %s / %s:%s 条评论 (本步骤 %.1f%% < %.0f%%)",
|
"报告跳过小簇 %s / %s:%s 条评论 (本步骤 %.1f%% < %.0f%%)",
|
||||||
|
|
@ -977,10 +983,11 @@ def _build_report_prompt(
|
||||||
word_freq: List[Tuple[str, int]],
|
word_freq: List[Tuple[str, int]],
|
||||||
top2_audience_clusters: Sequence[int],
|
top2_audience_clusters: Sequence[int],
|
||||||
) -> Tuple[str, str]:
|
) -> Tuple[str, str]:
|
||||||
wf_table = word_freq[:WORD_FREQ_TABLE_N]
|
n_table = WORD_FREQ_TABLE_N
|
||||||
wf_classify = word_freq[:WORD_CATEGORY_CLASSIFY_N]
|
n_classify = WORD_CATEGORY_CLASSIFY_N
|
||||||
wf_table_lines = "\n".join(f"{w}\t{c}" for w, c in wf_table)
|
n_max = max(n_table, n_classify)
|
||||||
wf_classify_lines = "\n".join(f"{w}\t{c}" for w, c in wf_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)
|
cats_literal = "、".join(WORD_CATEGORIES)
|
||||||
stage_map = _cluster_stage_mapping_guide(
|
stage_map = _cluster_stage_mapping_guide(
|
||||||
bundles, top2_audience_clusters=top2_audience_clusters
|
bundles, top2_audience_clusters=top2_audience_clusters
|
||||||
|
|
@ -992,16 +999,23 @@ def _build_report_prompt(
|
||||||
top2_audience_clusters=top2_audience_clusters,
|
top2_audience_clusters=top2_audience_clusters,
|
||||||
)
|
)
|
||||||
system = build_report_system()
|
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}》产品改进建议报告。
|
user = f"""请基于以下用户评论反馈的聚类结果,撰写一份详细的《{product_name}》产品改进建议报告。
|
||||||
|
|
||||||
行业背景:{industry}
|
行业背景:{industry}
|
||||||
数据清洗后有效评论总数 a = {total_reviews}
|
数据清洗后有效评论总数 a = {total_reviews}
|
||||||
|
|
||||||
【词频 Top{WORD_FREQ_TABLE_N}(word\\tcount,辅助理解品类与撰写报告,排名 1-{WORD_FREQ_TABLE_N})】
|
{wf_block}
|
||||||
{wf_table_lines}
|
|
||||||
|
|
||||||
【词频分类词表 Top{WORD_CATEGORY_CLASSIFY_N}(word\\tcount,**仅**用于 WORD_CATEGORY_JSON,排名 1-{WORD_CATEGORY_CLASSIFY_N})】
|
|
||||||
{wf_classify_lines}
|
|
||||||
|
|
||||||
【聚类流程与报告小节映射】
|
【聚类流程与报告小节映射】
|
||||||
{stage_map}
|
{stage_map}
|
||||||
|
|
@ -1878,6 +1892,92 @@ def _build_word_category_map(category_data: Dict[str, Any]) -> Dict[str, List[st
|
||||||
return dict(by_word)
|
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:
|
def _wordcloud_color_for_categories(categories: List[str]) -> str:
|
||||||
if not categories:
|
if not categories:
|
||||||
return WORDCLOUD_UNCATEGORIZED_COLOR
|
return WORDCLOUD_UNCATEGORIZED_COLOR
|
||||||
|
|
@ -2163,7 +2263,7 @@ def _render_ai_validation_section(
|
||||||
phrases_panel = _wrap_ai_verify_panel("聚类效果验证", phrases_html)
|
phrases_panel = _wrap_ai_verify_panel("聚类效果验证", phrases_html)
|
||||||
return (
|
return (
|
||||||
'<section id="sec-ai-verify">\n'
|
'<section id="sec-ai-verify">\n'
|
||||||
" <h2>AI分析效果验证</h2>\n"
|
" <h2>模型中间过程可视化</h2>\n"
|
||||||
' <p class="muted">以下为结构化与聚类结果的抽样展示,点击标题展开查看。</p>\n'
|
' <p class="muted">以下为结构化与聚类结果的抽样展示,点击标题展开查看。</p>\n'
|
||||||
f" {structured_panel}\n"
|
f" {structured_panel}\n"
|
||||||
f" {phrases_panel}\n"
|
f" {phrases_panel}\n"
|
||||||
|
|
@ -2179,20 +2279,37 @@ def _assemble_html(
|
||||||
phrases_html: str,
|
phrases_html: str,
|
||||||
structured_html: str,
|
structured_html: str,
|
||||||
wordcloud_data: List[dict],
|
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:
|
) -> str:
|
||||||
"""按固定顺序机器拼装最终 HTML(不由 LLM 决定版块顺序)。"""
|
"""按固定版块顺序拼装最终 HTML:Dashboard → 洞察报告 → 词频 → 附录。"""
|
||||||
data_json = json.dumps(wordcloud_data, ensure_ascii=False)
|
|
||||||
page_title = f"{product_name} · 评论分析报告"
|
page_title = f"{product_name} · 评论分析报告"
|
||||||
report_body = _strip_report_top_heading(report_html)
|
report_body = _strip_report_top_heading(report_html)
|
||||||
report_section = (
|
report_section = (
|
||||||
'<section id="sec-report">\n'
|
'<section id="sec-report">\n'
|
||||||
" <h2>产品改进建议报告</h2>\n"
|
|
||||||
f" {report_body}\n"
|
f" {report_body}\n"
|
||||||
"</section>"
|
"</section>"
|
||||||
)
|
)
|
||||||
ai_verify_section = _render_ai_validation_section(structured_html, phrases_html)
|
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))
|
body_sections = "\n".join((report_section, wordfreq_html, ai_verify_section))
|
||||||
|
|
||||||
return f"""<!DOCTYPE html>
|
return f"""<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<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@5/dist/echarts.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/echarts-wordcloud@2/dist/echarts-wordcloud.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/echarts-wordcloud@2/dist/echarts-wordcloud.min.js"></script>
|
||||||
<style>
|
<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;
|
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; }}
|
margin: 0; padding: 0; line-height: 1.6; color: #1e293b; background: #fff; }}
|
||||||
.muted {{ color: #666; font-size: 0.9em; }}
|
.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; }}
|
#wordcloud-chart {{ width: 100%; height: 520px; margin: 16px 0 24px; }}
|
||||||
.wc-legend {{ display: flex; flex-wrap: wrap; gap: 8px 14px; margin: 8px 0 12px;
|
.wc-legend {{ display: flex; flex-wrap: wrap; gap: 8px 14px; margin: 8px 0 12px;
|
||||||
font-size: 0.88em; color: #444; }}
|
font-size: 0.88em; color: #444; }}
|
||||||
|
|
@ -2213,27 +2369,15 @@ def _assemble_html(
|
||||||
table {{ border-collapse: collapse; width: 100%; margin: 12px 0; }}
|
table {{ border-collapse: collapse; width: 100%; margin: 12px 0; }}
|
||||||
th, td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
|
th, td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
|
||||||
th {{ background: #f0f0f0; }}
|
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-categories {{ margin: 24px 0; }}
|
||||||
.wf-cat {{ margin-bottom: 20px; }}
|
.wf-cat {{ margin-bottom: 20px; }}
|
||||||
.wf-pager {{ margin: 16px 0; }}
|
.wf-pager {{ margin: 16px 0; }}
|
||||||
.wf-tab-bar {{ margin-bottom: 8px; }}
|
.wf-tab-bar {{ margin-bottom: 8px; }}
|
||||||
.wf-tab {{ margin: 4px 8px 4px 0; padding: 6px 12px; cursor: pointer;
|
.wf-tab {{ margin: 4px 8px 4px 0; padding: 6px 12px; cursor: pointer;
|
||||||
border: 1px solid #ccc; background: #fff; border-radius: 4px; }}
|
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-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 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:nth-child(even) {{ background: #fafafa; }}
|
||||||
.wf-freq-table tbody tr:hover {{ background: #f0f7ff; }}
|
.wf-freq-table tbody tr:hover {{ background: #f0f7ff; }}
|
||||||
|
|
@ -2241,62 +2385,236 @@ def _assemble_html(
|
||||||
.wf-row.wf-visible {{ display: table-row; }}
|
.wf-row.wf-visible {{ display: table-row; }}
|
||||||
.wf-count {{ min-width: 120px; }}
|
.wf-count {{ min-width: 120px; }}
|
||||||
.wf-count .wf-bar {{ display: inline-block; height: 10px; margin-right: 8px;
|
.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; }}
|
.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; }}
|
border-radius: 8px; background: #fafafa; }}
|
||||||
.struct-sample h4 {{ margin: 0 0 12px; }}
|
.struct-sample h4 {{ margin: 0 0 12px; }}
|
||||||
.struct-review {{ white-space: pre-wrap; word-break: break-word; margin: 8px 0 16px; }}
|
.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; }}
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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}
|
{body_sections}
|
||||||
<script>
|
|
||||||
(function() {{
|
</div><!-- .page-wrap -->
|
||||||
const el = document.getElementById('wordcloud-chart');
|
|
||||||
if (el && typeof echarts !== 'undefined') {{
|
<script>
|
||||||
const chart = echarts.init(el);
|
(function() {{
|
||||||
chart.setOption({{
|
/* --- 工具函数 --- */
|
||||||
|
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: {{
|
tooltip: {{
|
||||||
show: true,
|
show: true,
|
||||||
formatter: function(p) {{
|
formatter: function(p) {{
|
||||||
var d = p.data || {{}};
|
var d = p.data || {{}};
|
||||||
var rank = d.rank ? ('#' + d.rank + ' ') : '';
|
var rank = d.rank ? ('#' + d.rank + ' ') : '';
|
||||||
var cnt = d.count != null ? d.count : p.value;
|
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)
|
var cat = (d.categories && d.categories.length)
|
||||||
? ('<br/>分类: ' + d.categories.join('、'))
|
? ('<br/>\\u5206\\u7c7b: ' + d.categories.join('\\u3001'))
|
||||||
: (d.category ? ('<br/>分类: ' + d.category) : '<br/>分类: 未分类');
|
: (d.category ? ('<br/>\\u5206\\u7c7b: ' + d.category) : '<br/>\\u5206\\u7c7b: \\u672a\\u5206\\u7c7b');
|
||||||
return rank + (d.name || p.name) + zh + cat + '<br/>次数: ' + cnt;
|
return rank + (d.name || p.name) + zh + cat + '<br/>\\u6b21\\u6570: ' + cnt;
|
||||||
}}
|
}}
|
||||||
}},
|
}},
|
||||||
series: [{{
|
series: [{{
|
||||||
type: 'wordCloud',
|
type: 'wordCloud', shape: 'circle', width: '95%', height: '95%',
|
||||||
shape: 'circle',
|
|
||||||
width: '95%',
|
|
||||||
height: '95%',
|
|
||||||
sizeRange: [{WORDCLOUD_SIZE_MIN}, {WORDCLOUD_SIZE_MAX}],
|
sizeRange: [{WORDCLOUD_SIZE_MIN}, {WORDCLOUD_SIZE_MAX}],
|
||||||
rotationRange: [-15, 15],
|
rotationRange: [-15, 15], rotationStep: 15, gridSize: 8,
|
||||||
rotationStep: 15,
|
drawOutOfBound: false, layoutAnimation: true,
|
||||||
gridSize: 8,
|
|
||||||
drawOutOfBound: false,
|
|
||||||
layoutAnimation: true,
|
|
||||||
textStyle: {{ fontFamily: 'system-ui, -apple-system, sans-serif' }},
|
textStyle: {{ fontFamily: 'system-ui, -apple-system, sans-serif' }},
|
||||||
emphasis: {{ focus: 'self', textStyle: {{ shadowBlur: 6, shadowColor: '#333' }} }},
|
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');
|
var rows = Array.from(document.querySelectorAll('.wf-row'));
|
||||||
const pageCount = tabs.length || 1;
|
var tabs = Array.from(document.querySelectorAll('.wf-tab'));
|
||||||
let cur = 1;
|
var label = document.getElementById('wf-page-label');
|
||||||
|
var pageCount = tabs.length || 1;
|
||||||
|
var cur = 1;
|
||||||
function showPage(n) {{
|
function showPage(n) {{
|
||||||
cur = Math.max(1, Math.min(n, pageCount));
|
cur = Math.max(1, Math.min(n, pageCount));
|
||||||
rows.forEach(function(r) {{
|
rows.forEach(function(r) {{
|
||||||
|
|
@ -2305,20 +2623,35 @@ def _assemble_html(
|
||||||
tabs.forEach(function(t) {{
|
tabs.forEach(function(t) {{
|
||||||
t.classList.toggle('active', parseInt(t.dataset.page, 10) === cur);
|
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) {{
|
tabs.forEach(function(t) {{
|
||||||
t.addEventListener('click', function() {{
|
t.addEventListener('click', function() {{ showPage(parseInt(t.dataset.page, 10)); }});
|
||||||
showPage(parseInt(t.dataset.page, 10));
|
|
||||||
}});
|
|
||||||
}});
|
}});
|
||||||
var prev = document.getElementById('wf-prev');
|
var prev = document.getElementById('wf-prev');
|
||||||
var next = document.getElementById('wf-next');
|
var next = document.getElementById('wf-next');
|
||||||
if (prev) prev.addEventListener('click', function() {{ showPage(cur - 1); }});
|
if (prev) prev.addEventListener('click', function() {{ showPage(cur - 1); }});
|
||||||
if (next) next.addEventListener('click', function() {{ showPage(cur + 1); }});
|
if (next) next.addEventListener('click', function() {{ showPage(cur + 1); }});
|
||||||
showPage(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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|
@ -2416,6 +2749,14 @@ def generate_report(
|
||||||
cleaned_csv=cleaned_csv,
|
cleaned_csv=cleaned_csv,
|
||||||
)
|
)
|
||||||
structured_sec = _render_structured_validation_section(struct_samples)
|
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(
|
html = _assemble_html(
|
||||||
product_name=product_name,
|
product_name=product_name,
|
||||||
wordfreq_html=wordfreq_sec,
|
wordfreq_html=wordfreq_sec,
|
||||||
|
|
@ -2423,6 +2764,12 @@ def generate_report(
|
||||||
phrases_html=phrases_sec,
|
phrases_html=phrases_sec,
|
||||||
structured_html=structured_sec,
|
structured_html=structured_sec,
|
||||||
wordcloud_data=wc_data,
|
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.parent.mkdir(parents=True, exist_ok=True)
|
||||||
output_html.write_text(html, encoding="utf-8")
|
output_html.write_text(html, encoding="utf-8")
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ BATCH_COUNT_MIN = 1
|
||||||
# 动态分批时单批评论条数上限(避免单请求过大导致输出截断)
|
# 动态分批时单批评论条数上限(避免单请求过大导致输出截断)
|
||||||
DEFAULT_MAX_BATCH_REVIEWS = 100
|
DEFAULT_MAX_BATCH_REVIEWS = 100
|
||||||
# Chat 批间并行;与 embedding 共用账号时不宜过高,避免连带 429
|
# Chat 批间并行;与 embedding 共用账号时不宜过高,避免连带 429
|
||||||
STRUCT_DEFAULT_WORKERS = 8
|
STRUCT_DEFAULT_WORKERS = 20
|
||||||
|
|
||||||
|
|
||||||
def _resolve_max_batch_reviews(explicit: int | None = None) -> int:
|
def _resolve_max_batch_reviews(explicit: int | None = None) -> int:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue