@ -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 = 60 0.0
REPORT_LLM_TIMEOUT_SEC = 72 0.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 : 24 px ; 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 ( 8 px ) ; border - bottom : 1 px solid var ( - - c - border ) ;
display : flex ; align - items : center ; gap : 0 ; padding : 0 24 px ; overflow - x : auto ; } }
. report - nav a { { padding : 14 px 18 px ; font - size : 0.92 em ; font - weight : 500 ;
color : #475569; text-decoration: none; white-space: nowrap;
border - bottom : 2 px solid transparent ; transition : all 0.2 s ; } }
. 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 : 1200 px ; margin : 0 auto ; padding : 24 px 28 px 60 px ; } }
. page - title { { font - size : 1.6 em ; font - weight : 700 ; margin : 0 0 8 px ; color : #0f172a; }}
. page - subtitle { { color : #64748b; margin: 0 0 32px; font-size: 0.95em; }}
/ * - - - Dashboard - - - * /
. kpi - grid { { display : grid ; grid - template - columns : repeat ( 4 , 1 fr ) ; gap : 16 px ; margin - bottom : 28 px ; } }
. kpi - card { { background : var ( - - c - bg ) ; border : 1 px solid var ( - - c - border ) ; border - radius : var ( - - radius ) ;
padding : 20 px 18 px ; text - align : center ; } }
. kpi - value { { font - size : 2 em ; font - weight : 700 ; color : #0f172a; line-height: 1.2; }}
. kpi - label { { font - size : 0.85 em ; 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 : 1 fr 1 fr ; gap : 20 px ; margin - bottom : 32 px ; } }
. chart - box { { background : var ( - - c - bg ) ; border : 1 px solid var ( - - c - border ) ;
border - radius : var ( - - radius ) ; padding : 16 px ; } }
. chart - box h3 { { margin : 0 0 8 px ; font - size : 1 em ; font - weight : 600 ; color : #334155; }}
. chart - container { { width : 100 % ; height : 360 px ; } }
. chart - container . tall { { height : 420 px ; } }
/ * - - - 各区块通用 - - - * /
section { { margin - top : 40 px ; } }
section > h2 { { font - size : 1.3 em ; border - bottom : 2 px solid var ( - - c - primary ) ;
padding - bottom : 10 px ; color : #0f172a; }}
/ * - - - 词频 - - - * /
#wordcloud-chart {{ width: 100%; height: 520px; margin: 16px 0 24px; }}
. wc - legend { { display : flex ; flex - wrap : wrap ; gap : 8 px 14 px ; margin : 8 px 0 12 px ;
font - size : 0.88 em ; color : #444; }}
@ -2213,27 +2369,15 @@ def _assemble_html(
table { { border - collapse : collapse ; width : 100 % ; margin : 12 px 0 ; } }
th , td { { border : 1 px solid #ddd; padding: 8px; text-align: left; }}
th { { background : #f0f0f0; }}
details { { margin : 12 px 0 24 px ; padding : 8 px ; background : #fafafa; border-radius: 6px; }}
#sec-ai-verify {{ margin-top: 40px; }}
details . ai - verify - panel { { margin : 16 px 0 ; padding : 0 ; border : 1 px solid #e5e7eb;
background : #fff; }}
details . ai - verify - panel > summary { { padding : 12 px 16 px ; font - size : 1.05 em ; 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 : 1 px solid #e5e7eb; }}
. ai - verify - body { { padding : 12 px 16 px 16 px ; } }
. stage - h { { margin - top : 32 px ; border - bottom : 1 px solid #eee; padding-bottom: 8px; }}
. wf - categories { { margin : 24 px 0 ; } }
. wf - cat { { margin - bottom : 20 px ; } }
. wf - pager { { margin : 16 px 0 ; } }
. wf - tab - bar { { margin - bottom : 8 px ; } }
. wf - tab { { margin : 4 px 8 px 4 px 0 ; padding : 6 px 12 px ; cursor : pointer ;
border : 1 px 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 : 12 px 0 ; display : flex ; align - items : center ; gap : 12 px ; flex - wrap : wrap ; } }
. wf - table - wrap { { max - height : 520 px ; overflow : auto ; border : 1 px solid #e5e7eb; border-radius: 8px; } }
. wf - table - wrap { { max - height : 520 px ; overflow : auto ; border : 1 px solid var ( - - c - border ) ; border - radius : 8 px ; } }
. wf - freq - table thead th { { position : sticky ; top : 0 ; z - index : 1 ; box - shadow : 0 1 px 0 #ddd; }}
. wf - freq - table tbody tr : nth - child ( even ) { { background : #fafafa; }}
. wf - freq - table tbody tr : hover { { background : #f0f7ff; }}
@ -2241,84 +2385,273 @@ def _assemble_html(
. wf - row . wf - visible { { display : table - row ; } }
. wf - count { { min - width : 120 px ; } }
. wf - count . wf - bar { { display : inline - block ; height : 10 px ; margin - right : 8 px ;
background : linear - gradient ( 90 deg , #93c5fd, #2563eb ); border-radius: 2px; vertical-align: middle; }}
background : linear - gradient ( 90 deg , #93c5fd, var(--c-primary) ); border-radius: 2px; vertical-align: middle; }}
. wf - en { { font - family : ui - monospace , monospace ; font - size : 0.92 em ; } }
#sec-wordfreq {{ margin-top: 40px; }}
. struct - sample { { margin : 24 px 0 ; padding : 16 px ; border : 1 px solid #e5e7eb;
/ * - - - 附录 - - - * /
details { { margin : 12 px 0 24 px ; padding : 8 px ; background : #fafafa; border-radius: 6px; }}
details . ai - verify - panel { { margin : 16 px 0 ; padding : 0 ; border : 1 px solid var ( - - c - border ) ;
background : #fff; }}
details . ai - verify - panel > summary { { padding : 12 px 16 px ; font - size : 1.05 em ; font - weight : 600 ;
cursor : pointer ; list - style : none ; background : var ( - - c - bg ) ; border - radius : 6 px 6 px 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 : 1 px solid var ( - - c - border ) ; } }
. ai - verify - body { { padding : 12 px 16 px 16 px ; } }
. stage - h { { margin - top : 32 px ; border - bottom : 1 px solid #eee; padding-bottom: 8px; }}
. struct - sample { { margin : 24 px 0 ; padding : 16 px ; border : 1 px solid var ( - - c - border ) ;
border - radius : 8 px ; background : #fafafa; }}
. struct - sample h4 { { margin : 0 0 12 px ; } }
. struct - review { { white - space : pre - wrap ; word - break : break - word ; margin : 8 px 0 16 px ; } }
. struct - json { { margin : 0 ; padding : 12 px ; background : #fff; border: 1px solid #e5e7eb;
. struct - json { { margin : 0 ; padding : 12 px ; background : #fff; border: 1px solid var(--c-border) ;
border - radius : 6 px ; overflow - x : auto ; font - size : 0.9 em ; line - height : 1.5 ; } }
@media ( max - width : 768 px ) { {
. kpi - grid { { grid - template - columns : repeat ( 2 , 1 fr ) ; } }
. chart - grid { { grid - template - columns : 1 fr ; } }
. report - nav { { padding : 0 12 px ; } }
. report - nav a { { padding : 12 px 12 px ; font - size : 0.85 em ; } }
. page - wrap { { padding : 16 px 14 px 40 px ; } }
} }
< / 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 ( { {
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 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 ;
} }
} } ,
series : [ { {
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 ,
textStyle : { { fontFamily : ' system-ui, -apple-system, sans-serif ' } } ,
emphasis : { { focus : ' self ' , textStyle : { { shadowBlur : 6 , shadowColor : ' #333 ' } } } } ,
data : { data_json }
} } ]
} } ) ;
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 ;
function showPage ( n ) { {
cur = Math . max ( 1 , Math . min ( n , pageCount ) ) ;
rows . forEach ( function ( r ) { {
r . classList . toggle ( ' wf-visible ' , parseInt ( r . dataset . page , 10 ) == = cur ) ;
} } ) ;
tabs . forEach ( function ( t ) { {
t . classList . toggle ( ' active ' , parseInt ( t . dataset . page , 10 ) == = cur ) ;
} } ) ;
if ( label ) label . textContent = ' 第 ' + cur + ' / ' + pageCount + ' 页 ' ;
} }
tabs . forEach ( function ( t ) { {
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 >
< / 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/> \\ u4e2d \\ u6587: ' + d . zh ) : ' ' ;
var cat = ( d . categories & & d . categories . length )
? ( ' <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 % ' ,
sizeRange : [ { WORDCLOUD_SIZE_MIN } , { WORDCLOUD_SIZE_MAX } ] ,
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 : wcData
} } ]
} } ) ;
} }
/ * - - - 词频分页 - - - * /
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 ) { {
r . classList . toggle ( ' wf-visible ' , parseInt ( r . dataset . page , 10 ) == = cur ) ;
} } ) ;
tabs . forEach ( function ( t ) { {
t . classList . toggle ( ' active ' , parseInt ( t . dataset . page , 10 ) == = cur ) ;
} } ) ;
if ( label ) label . textContent = ' \\ u7b2c ' + cur + ' / ' + pageCount + ' \\ u9875 ' ;
} }
tabs . forEach ( function ( t ) { {
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 ) ;
/ * - - - 导航高亮 - - - * /
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 " )