刘立冬 3 týždňov pred
rodič
commit
b005c0b644
2 zmenil súbory, kde vykonal 987 pridanie a 0 odobranie
  1. 1 0
      llm_evaluator.py
  2. 986 0
      visualize_stage5_results_v3.py

+ 1 - 0
llm_evaluator.py

@@ -224,6 +224,7 @@ class LLMEvaluator:
                     if search_word:  # 确保有搜索词
                         all_results.append({
                             "search_word": search_word,
+                            "source_word": item.get("source_word", ""),
                             "score": item.get("score", 0.0),
                             "reasoning": item.get("reasoning", ""),
                             "original_feature": original_feature

+ 986 - 0
visualize_stage5_results_v3.py

@@ -0,0 +1,986 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+Stage5搜索结果可视化工具 V3
+支持三层树形结构: Original Feature → Base Word → Search Terms
+"""
+
+import json
+import os
+import subprocess
+from datetime import datetime
+from typing import List, Dict, Any
+
+
+def load_data(json_path: str) -> List[Dict[str, Any]]:
+    """加载JSON数据"""
+    with open(json_path, 'r', encoding='utf-8') as f:
+        return json.load(f)
+
+
+def calculate_statistics(data: List[Dict[str, Any]]) -> Dict[str, Any]:
+    """计算统计数据"""
+    total_features = len(data)
+    total_base_words = 0
+    total_search_words = 0
+    total_notes = 0
+    video_count = 0
+    normal_count = 0
+
+    for feature in data:
+        # 支持分组结构
+        grouped_results = feature.get('组合评估结果_分组', [])
+
+        if grouped_results:
+            # 新结构:分组结果
+            total_base_words += len(grouped_results)
+            for group in grouped_results:
+                search_words = group.get('top10_searches', [])
+                total_search_words += len(search_words)
+
+                for search_item in search_words:
+                    search_result = search_item.get('search_result')
+                    if search_result is None:
+                        continue  # 跳过搜索失败的项
+                    notes = search_result.get('data', {}).get('data', [])
+                    total_notes += len(notes)
+
+                    for note in notes:
+                        note_type = note.get('note_card', {}).get('type', '')
+                        if note_type == 'video':
+                            video_count += 1
+                        else:
+                            normal_count += 1
+        else:
+            # 兼容旧结构
+            search_results = feature.get('组合评估结果', [])
+            total_search_words += len(search_results)
+
+            for search_item in search_results:
+                search_result = search_item.get('search_result')
+                if search_result is None:
+                    continue  # 跳过搜索失败的项
+                notes = search_result.get('data', {}).get('data', [])
+                total_notes += len(notes)
+
+                for note in notes:
+                    note_type = note.get('note_card', {}).get('type', '')
+                    if note_type == 'video':
+                        video_count += 1
+                    else:
+                        normal_count += 1
+
+    return {
+        'total_features': total_features,
+        'total_base_words': total_base_words,
+        'total_search_words': total_search_words,
+        'total_notes': total_notes,
+        'video_count': video_count,
+        'normal_count': normal_count,
+        'video_percentage': round(video_count / total_notes * 100, 1) if total_notes > 0 else 0,
+        'normal_percentage': round(normal_count / total_notes * 100, 1) if total_notes > 0 else 0
+    }
+
+
+def generate_html(data: List[Dict[str, Any]], stats: Dict[str, Any], output_path: str):
+    """生成HTML可视化页面"""
+
+    # 准备数据JSON(用于JavaScript)
+    data_json = json.dumps(data, ensure_ascii=False, indent=2)
+
+    html_content = f'''<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Stage5 搜索结果可视化 (三层结构)</title>
+    <style>
+        * {{
+            margin: 0;
+            padding: 0;
+            box-sizing: border-box;
+        }}
+
+        body {{
+            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+            background: #f5f7fa;
+            color: #333;
+        }}
+
+        /* 顶部统计面板 */
+        .stats-panel {{
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            color: white;
+            padding: 20px;
+            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
+        }}
+
+        .stats-container {{
+            max-width: 1400px;
+            margin: 0 auto;
+            display: flex;
+            justify-content: space-around;
+            flex-wrap: wrap;
+            gap: 20px;
+        }}
+
+        .stat-item {{
+            text-align: center;
+        }}
+
+        .stat-value {{
+            font-size: 32px;
+            font-weight: bold;
+            margin-bottom: 5px;
+        }}
+
+        .stat-label {{
+            font-size: 14px;
+            opacity: 0.9;
+        }}
+
+        /* 主容器 */
+        .main-container {{
+            display: flex;
+            max-width: 1400px;
+            margin: 20px auto;
+            gap: 20px;
+            padding: 0 20px;
+        }}
+
+        /* 左侧三层树导航 */
+        .left-sidebar {{
+            width: 35%;
+            background: white;
+            border-radius: 8px;
+            box-shadow: 0 2px 8px rgba(0,0,0,0.1);
+            overflow-y: auto;
+            max-height: calc(100vh - 160px);
+        }}
+
+        /* Level 1: Original Feature */
+        .feature-group {{
+            border-bottom: 1px solid #e5e7eb;
+        }}
+
+        .feature-header {{
+            padding: 15px 20px;
+            background: #f9fafb;
+            cursor: pointer;
+            user-select: none;
+            transition: all 0.2s;
+            display: flex;
+            align-items: center;
+            gap: 10px;
+        }}
+
+        .feature-header:hover {{
+            background: #f3f4f6;
+        }}
+
+        .feature-header.active {{
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            color: white;
+        }}
+
+        .expand-icon {{
+            font-size: 12px;
+            color: #6b7280;
+            transition: transform 0.2s;
+            min-width: 12px;
+        }}
+
+        .feature-header.active .expand-icon {{
+            color: white;
+            transform: rotate(90deg);
+        }}
+
+        .feature-title {{
+            font-size: 17px;
+            font-weight: 600;
+            margin-bottom: 5px;
+            flex: 1;
+        }}
+
+        .feature-meta {{
+            font-size: 12px;
+            color: #6b7280;
+        }}
+
+        .feature-header.active .feature-meta {{
+            color: rgba(255,255,255,0.8);
+        }}
+
+        /* Level 2: Base Words */
+        .base-words-list {{
+            display: none;
+            background: #fafafa;
+        }}
+
+        .base-words-list.expanded {{
+            display: block;
+        }}
+
+        .base-word-item {{
+            padding: 10px 20px 10px 40px;
+            cursor: pointer;
+            border-left: 3px solid transparent;
+            transition: all 0.2s;
+            background: white;
+            border-bottom: 1px solid #f0f0f0;
+            display: flex;
+            align-items: center;
+            gap: 8px;
+            position: relative;
+        }}
+
+        .base-word-item::before {{
+            content: '├─';
+            position: absolute;
+            left: 20px;
+            color: #d1d5db;
+            font-size: 14px;
+        }}
+
+        .base-word-item:hover {{
+            background: #fef9f3;
+            border-left-color: #f59e0b;
+        }}
+
+        .base-word-item.active {{
+            background: #fef3c7;
+            border-left-color: #f59e0b;
+        }}
+
+        .base-expand-icon {{
+            font-size: 10px;
+            color: #9ca3af;
+            transition: transform 0.2s;
+            min-width: 10px;
+        }}
+
+        .base-word-item.active .base-expand-icon {{
+            transform: rotate(90deg);
+        }}
+
+        .base-word-text {{
+            font-size: 14px;
+            font-weight: 600;
+            color: #374151;
+            flex: 1;
+        }}
+
+        .base-word-similarity {{
+            display: inline-block;
+            padding: 2px 8px;
+            border-radius: 12px;
+            font-size: 11px;
+            font-weight: 600;
+            background: #fef3c7;
+            color: #92400e;
+            margin-left: 8px;
+        }}
+
+        /* Level 3: Search Terms */
+        .search-terms-list {{
+            display: none;
+            background: #f9fafb;
+        }}
+
+        .search-terms-list.expanded {{
+            display: block;
+        }}
+
+        .search-term-item {{
+            padding: 8px 20px 8px 70px;
+            cursor: pointer;
+            border-left: 3px solid transparent;
+            transition: all 0.2s;
+            position: relative;
+        }}
+
+        .search-term-item::before {{
+            content: '└─';
+            position: absolute;
+            left: 52px;
+            color: #d1d5db;
+            font-size: 12px;
+        }}
+
+        .search-term-item:hover {{
+            background: #f3f4f6;
+            border-left-color: #667eea;
+        }}
+
+        .search-term-item.active {{
+            background: #ede9fe;
+            border-left-color: #7c3aed;
+        }}
+
+        .search-term-text {{
+            font-size: 13px;
+            color: #374151;
+            margin-bottom: 3px;
+            display: flex;
+            align-items: center;
+            gap: 6px;
+        }}
+
+        .search-term-score {{
+            display: inline-block;
+            padding: 2px 6px;
+            border-radius: 10px;
+            font-size: 10px;
+            font-weight: 600;
+            margin-left: 6px;
+        }}
+
+        .score-high {{
+            background: #d1fae5;
+            color: #065f46;
+        }}
+
+        .score-medium {{
+            background: #fef3c7;
+            color: #92400e;
+        }}
+
+        .score-low {{
+            background: #fee2e2;
+            color: #991b1b;
+        }}
+
+        .search-term-reasoning {{
+            font-size: 11px;
+            color: #6b7280;
+            margin-top: 2px;
+            display: -webkit-box;
+            -webkit-line-clamp: 1;
+            -webkit-box-orient: vertical;
+            overflow: hidden;
+        }}
+
+        .search-term-source {{
+            font-size: 10px;
+            color: #9ca3af;
+            margin-top: 2px;
+            font-style: italic;
+        }}
+
+        .source-word-inline {{
+            font-size: 10px;
+            color: #b0b5ba;
+            margin-left: 4px;
+            font-weight: normal;
+        }}
+
+        /* 右侧结果区 */
+        .right-content {{
+            flex: 1;
+            overflow-y: auto;
+            max-height: calc(100vh - 160px);
+        }}
+
+        .result-block {{
+            background: white;
+            border-radius: 8px;
+            box-shadow: 0 2px 8px rgba(0,0,0,0.1);
+            margin-bottom: 20px;
+            padding: 20px;
+        }}
+
+        .result-header {{
+            margin-bottom: 20px;
+            padding-bottom: 15px;
+            border-bottom: 2px solid #e5e7eb;
+        }}
+
+        .result-title {{
+            font-size: 18px;
+            font-weight: 600;
+            color: #111827;
+            margin-bottom: 10px;
+        }}
+
+        .result-stats {{
+            display: flex;
+            gap: 15px;
+            font-size: 13px;
+            color: #6b7280;
+        }}
+
+        .stat-badge {{
+            background: #f3f4f6;
+            padding: 4px 10px;
+            border-radius: 4px;
+        }}
+
+        /* 帖子网格 */
+        .notes-grid {{
+            display: grid;
+            grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+            gap: 20px;
+        }}
+
+        .note-card {{
+            border: 1px solid #e5e7eb;
+            border-radius: 8px;
+            overflow: hidden;
+            cursor: pointer;
+            transition: all 0.3s;
+            background: white;
+        }}
+
+        .note-card:hover {{
+            transform: translateY(-4px);
+            box-shadow: 0 10px 25px rgba(0,0,0,0.15);
+        }}
+
+        /* 图片轮播 */
+        .image-carousel {{
+            position: relative;
+            width: 100%;
+            height: 364px;
+            background: #f3f4f6;
+            overflow: hidden;
+        }}
+
+        .carousel-images {{
+            display: flex;
+            height: 100%;
+            transition: transform 0.3s ease;
+        }}
+
+        .carousel-image {{
+            min-width: 100%;
+            height: 100%;
+            object-fit: cover;
+        }}
+
+        .carousel-btn {{
+            position: absolute;
+            top: 50%;
+            transform: translateY(-50%);
+            background: rgba(0,0,0,0.5);
+            color: white;
+            border: none;
+            width: 32px;
+            height: 32px;
+            border-radius: 50%;
+            cursor: pointer;
+            font-size: 16px;
+            display: none;
+            align-items: center;
+            justify-content: center;
+            z-index: 10;
+        }}
+
+        .carousel-btn:hover {{
+            background: rgba(0,0,0,0.7);
+        }}
+
+        .carousel-btn.prev {{ left: 8px; }}
+        .carousel-btn.next {{ right: 8px; }}
+
+        .note-card:hover .carousel-btn {{
+            display: flex;
+        }}
+
+        .carousel-indicators {{
+            position: absolute;
+            bottom: 10px;
+            left: 50%;
+            transform: translateX(-50%);
+            display: flex;
+            gap: 6px;
+            z-index: 10;
+        }}
+
+        .dot {{
+            width: 8px;
+            height: 8px;
+            border-radius: 50%;
+            background: rgba(255,255,255,0.5);
+            cursor: pointer;
+            transition: all 0.2s;
+        }}
+
+        .dot.active {{
+            background: white;
+            width: 24px;
+            border-radius: 4px;
+        }}
+
+        .image-counter {{
+            position: absolute;
+            top: 10px;
+            right: 10px;
+            background: rgba(0,0,0,0.6);
+            color: white;
+            padding: 4px 8px;
+            border-radius: 4px;
+            font-size: 12px;
+            z-index: 10;
+        }}
+
+        /* 帖子信息 */
+        .note-info {{
+            padding: 12px;
+        }}
+
+        .note-title {{
+            font-size: 14px;
+            font-weight: 500;
+            color: #111827;
+            margin-bottom: 8px;
+            display: -webkit-box;
+            -webkit-line-clamp: 2;
+            -webkit-box-orient: vertical;
+            overflow: hidden;
+        }}
+
+        .note-meta {{
+            display: flex;
+            justify-content: space-between;
+            font-size: 12px;
+            color: #6b7280;
+        }}
+
+        .note-type {{
+            padding: 3px 8px;
+            border-radius: 4px;
+            font-weight: 500;
+        }}
+
+        .type-video {{
+            background: #dbeafe;
+            color: #1e40af;
+        }}
+
+        .type-normal {{
+            background: #d1fae5;
+            color: #065f46;
+        }}
+
+        ::-webkit-scrollbar {{
+            width: 8px;
+        }}
+
+        ::-webkit-scrollbar-track {{
+            background: #f1f1f1;
+        }}
+
+        ::-webkit-scrollbar-thumb {{
+            background: #888;
+            border-radius: 4px;
+        }}
+
+        ::-webkit-scrollbar-thumb:hover {{
+            background: #555;
+        }}
+
+        /* 图片画廊模态框 */
+        .gallery-modal {{
+            display: none;
+            position: fixed;
+            top: 0;
+            left: 0;
+            width: 100%;
+            height: 100%;
+            background: rgba(0,0,0,0.95);
+            z-index: 9999;
+            overflow-y: auto;
+        }}
+
+        .gallery-modal.show {{
+            display: block;
+        }}
+
+        .gallery-close {{
+            position: fixed;
+            top: 20px;
+            right: 20px;
+            color: white;
+            font-size: 40px;
+            cursor: pointer;
+            z-index: 10000;
+            background: rgba(0,0,0,0.5);
+            width: 50px;
+            height: 50px;
+            border-radius: 50%;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            transition: all 0.2s;
+        }}
+
+        .gallery-close:hover {{
+            background: rgba(255,255,255,0.2);
+        }}
+
+        .gallery-grid {{
+            display: grid;
+            grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
+            gap: 20px;
+            padding: 80px 40px 40px 40px;
+            max-width: 1600px;
+            margin: 0 auto;
+        }}
+
+        .gallery-image {{
+            width: 100%;
+            height: auto;
+            object-fit: contain;
+            border-radius: 8px;
+        }}
+    </style>
+</head>
+<body>
+    <!-- 统计面板 -->
+    <div class="stats-panel">
+        <div class="stats-container">
+            <div class="stat-item">
+                <div class="stat-value">📊 {stats['total_features']}</div>
+                <div class="stat-label">原始特征数</div>
+            </div>
+            <div class="stat-item">
+                <div class="stat-value">🎯 {stats['total_base_words']}</div>
+                <div class="stat-label">基础词数</div>
+            </div>
+            <div class="stat-item">
+                <div class="stat-value">🔍 {stats['total_search_words']}</div>
+                <div class="stat-label">搜索词数</div>
+            </div>
+            <div class="stat-item">
+                <div class="stat-value">📝 {stats['total_notes']}</div>
+                <div class="stat-label">帖子总数</div>
+            </div>
+            <div class="stat-item">
+                <div class="stat-value">🎬 {stats['video_count']}</div>
+                <div class="stat-label">视频 ({stats['video_percentage']}%)</div>
+            </div>
+            <div class="stat-item">
+                <div class="stat-value">📷 {stats['normal_count']}</div>
+                <div class="stat-label">图文 ({stats['normal_percentage']}%)</div>
+            </div>
+        </div>
+    </div>
+
+    <!-- 主容器 -->
+    <div class="main-container">
+        <!-- 左侧三层树导航 -->
+        <div class="left-sidebar" id="leftSidebar"></div>
+
+        <!-- 右侧结果区 -->
+        <div class="right-content" id="rightContent"></div>
+    </div>
+
+    <script>
+        const data = {data_json};
+
+        // 渲染左侧三层树导航
+        function renderLeftSidebar() {{
+            const sidebar = document.getElementById('leftSidebar');
+            let html = '';
+
+            data.forEach((feature, featureIdx) => {{
+                const groupedResults = feature['组合评估结果_分组'] || [];
+                const totalSearchWords = groupedResults.reduce((sum, g) => sum + (g.top10_searches || []).length, 0);
+
+                // Level 1: Original Feature
+                html += `
+                    <div class="feature-group">
+                        <div class="feature-header" onclick="toggleFeature(${{featureIdx}})" id="feature-header-${{featureIdx}}">
+                            <span class="expand-icon">▶</span>
+                            <div class="feature-title">📊 ${{feature['原始特征名称']}}</div>
+                        </div>
+                        <div class="base-words-list" id="base-words-${{featureIdx}}">
+                `;
+
+                // Level 2: Base Words
+                groupedResults.forEach((group, groupIdx) => {{
+                    const baseWord = group.base_word || '';
+                    const baseSimilarity = group.base_word_similarity || 0;
+                    const searchTerms = group.top10_searches || [];
+
+                    html += `
+                        <div class="base-word-item" onclick="event.stopPropagation(); toggleBaseWord(${{featureIdx}}, ${{groupIdx}})" id="base-word-${{featureIdx}}-${{groupIdx}}">
+                            <span class="base-expand-icon">▶</span>
+                            <div class="base-word-text">
+                                🎯 ${{baseWord}}
+                            </div>
+                        </div>
+                        <div class="search-terms-list" id="search-terms-${{featureIdx}}-${{groupIdx}}">
+                    `;
+
+                    // Level 3: Search Terms
+                    searchTerms.forEach((term, termIdx) => {{
+                        const score = term.score || 0;
+                        const scoreClass = score >= 0.9 ? 'score-high' : score >= 0.7 ? 'score-medium' : 'score-low';
+                        const blockId = `block-${{featureIdx}}-${{groupIdx}}-${{termIdx}}`;
+
+                        html += `
+                            <div class="search-term-item" onclick="scrollToBlock('${{blockId}}')"
+                                 id="term-${{featureIdx}}-${{groupIdx}}-${{termIdx}}"
+                                 data-block-id="${{blockId}}">
+                                <div class="search-term-text">
+                                    🔍 ${{term.search_word}} ${{term.source_word ? `<span class="source-word-inline">来源: ${{term.source_word}}</span>` : ''}}
+                                </div>
+                                <div class="search-term-reasoning" title="${{term.reasoning || ''}}">
+                                    ${{term.reasoning || ''}}
+                                </div>
+                            </div>
+                        `;
+                    }});
+
+                    html += `</div>`;
+                }});
+
+                html += `
+                        </div>
+                    </div>
+                `;
+            }});
+
+            sidebar.innerHTML = html;
+        }}
+
+        // 渲染右侧结果区
+        function renderRightContent() {{
+            const content = document.getElementById('rightContent');
+            let html = '';
+
+            data.forEach((feature, featureIdx) => {{
+                const groupedResults = feature['组合评估结果_分组'] || [];
+
+                groupedResults.forEach((group, groupIdx) => {{
+                    const searchTerms = group.top10_searches || [];
+
+                    searchTerms.forEach((term, termIdx) => {{
+                        const blockId = `block-${{featureIdx}}-${{groupIdx}}-${{termIdx}}`;
+                        const searchResult = term.search_result || {{}};
+                        const notes = searchResult.data?.data || [];
+
+                        const videoCount = notes.filter(n => n.note_card?.type === 'video').length;
+                        const normalCount = notes.length - videoCount;
+
+                        html += `
+                            <div class="result-block" id="${{blockId}}">
+                                <div class="result-header">
+                                    <div class="result-title">${{term.search_word}}</div>
+                                    ${{term.source_word ? `<div style="font-size: 12px; color: #9ca3af; font-style: italic; margin-top: 5px;">来源词: ${{term.source_word}}</div>` : ''}}
+                                    <div class="result-stats">
+                                        <span class="stat-badge">📝 ${{notes.length}} 条帖子</span>
+                                        <span class="stat-badge">🎬 ${{videoCount}} 视频</span>
+                                        <span class="stat-badge">📷 ${{normalCount}} 图文</span>
+                                    </div>
+                                </div>
+                                <div class="notes-grid">
+                                    ${{notes.map((note, noteIdx) => renderNoteCard(note, featureIdx, groupIdx, termIdx, noteIdx)).join('')}}
+                                </div>
+                            </div>
+                        `;
+                    }});
+                }});
+            }});
+
+            content.innerHTML = html;
+        }}
+
+        // 渲染单个帖子卡片
+        function renderNoteCard(note, featureIdx, groupIdx, termIdx, noteIdx) {{
+            const card = note.note_card || {{}};
+            const images = card.image_list || [];
+            const title = card.display_title || '无标题';
+            const noteType = card.type || 'normal';
+            const noteId = note.id || '';
+
+            const carouselId = `carousel-${{featureIdx}}-${{groupIdx}}-${{termIdx}}-${{noteIdx}}`;
+
+            return `
+                <div class="note-card" onclick="event.stopPropagation(); showImageGallery('${{carouselId}}')">
+                    <div class="image-carousel" id="${{carouselId}}">
+                        <div class="carousel-images">
+                            ${{images.map(img => `<img class="carousel-image" src="${{img}}" loading="lazy">`).join('')}}
+                        </div>
+                        ${{images.length > 1 ? `
+                            <button class="carousel-btn prev" onclick="event.stopPropagation(); changeImage('${{carouselId}}', -1)">←</button>
+                            <button class="carousel-btn next" onclick="event.stopPropagation(); changeImage('${{carouselId}}', 1)">→</button>
+                            <div class="carousel-indicators">
+                                ${{images.map((_, i) => `<span class="dot ${{i === 0 ? 'active' : ''}}" onclick="event.stopPropagation(); goToImage('${{carouselId}}', ${{i}})"></span>`).join('')}}
+                            </div>
+                            <span class="image-counter">1/${{images.length}}</span>
+                        ` : ''}}
+                    </div>
+                    <div class="note-info">
+                        <div class="note-title">${{title}}</div>
+                        <div class="note-meta">
+                            <span class="note-type type-${{noteType}}">
+                                ${{noteType === 'video' ? '🎬 视频' : '📷 图文'}}
+                            </span>
+                        </div>
+                    </div>
+                </div>
+            `;
+        }}
+
+        // 图片轮播逻辑
+        const carouselStates = {{}};
+
+        function changeImage(carouselId, direction) {{
+            if (!carouselStates[carouselId]) carouselStates[carouselId] = {{ currentIndex: 0 }};
+
+            const carousel = document.getElementById(carouselId);
+            const imagesContainer = carousel.querySelector('.carousel-images');
+            const images = carousel.querySelectorAll('.carousel-image');
+            const dots = carousel.querySelectorAll('.dot');
+            const counter = carousel.querySelector('.image-counter');
+
+            let newIndex = carouselStates[carouselId].currentIndex + direction;
+            if (newIndex < 0) newIndex = images.length - 1;
+            if (newIndex >= images.length) newIndex = 0;
+
+            carouselStates[carouselId].currentIndex = newIndex;
+            imagesContainer.style.transform = `translateX(-${{newIndex * 100}}%)`;
+
+            dots.forEach((dot, i) => dot.classList.toggle('active', i === newIndex));
+            if (counter) counter.textContent = `${{newIndex + 1}}/${{images.length}}`;
+        }}
+
+        function goToImage(carouselId, index) {{
+            if (!carouselStates[carouselId]) carouselStates[carouselId] = {{ currentIndex: 0 }};
+
+            const carousel = document.getElementById(carouselId);
+            const imagesContainer = carousel.querySelector('.carousel-images');
+            const dots = carousel.querySelectorAll('.dot');
+            const counter = carousel.querySelector('.image-counter');
+
+            carouselStates[carouselId].currentIndex = index;
+            imagesContainer.style.transform = `translateX(-${{index * 100}}%)`;
+
+            dots.forEach((dot, i) => dot.classList.toggle('active', i === index));
+            if (counter) counter.textContent = `${{index + 1}}/${{dots.length}}`;
+        }}
+
+        // 展开/折叠特征组
+        function toggleFeature(featureIdx) {{
+            const baseWordsList = document.getElementById(`base-words-${{featureIdx}}`);
+            const featureHeader = document.getElementById(`feature-header-${{featureIdx}}`);
+
+            baseWordsList.classList.toggle('expanded');
+            featureHeader.classList.toggle('active');
+        }}
+
+        // 展开/折叠base_word
+        function toggleBaseWord(featureIdx, groupIdx) {{
+            const termsList = document.getElementById(`search-terms-${{featureIdx}}-${{groupIdx}}`);
+            const baseWordItem = document.getElementById(`base-word-${{featureIdx}}-${{groupIdx}}`);
+
+            termsList.classList.toggle('expanded');
+            baseWordItem.classList.toggle('active');
+        }}
+
+        // 滚动到指定结果块
+        function scrollToBlock(blockId) {{
+            const block = document.getElementById(blockId);
+            if (block) {{
+                block.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
+
+                document.querySelectorAll('.search-term-item').forEach(item => item.classList.remove('active'));
+                document.querySelectorAll(`[data-block-id="${{blockId}}"]`).forEach(item => item.classList.add('active'));
+            }}
+        }}
+
+        // 打开小红书帖子
+        function openNote(noteId) {{
+            if (noteId) window.open(`https://www.xiaohongshu.com/explore/${{noteId}}`, '_blank');
+        }}
+
+        // 显示图片画廊
+        function showImageGallery(carouselId) {{
+            const carousel = document.getElementById(carouselId);
+            if (!carousel) return;
+
+            const images = Array.from(carousel.querySelectorAll('.carousel-image')).map(img => img.src);
+
+            const modal = document.getElementById('galleryModal');
+            const grid = document.getElementById('galleryGrid');
+
+            grid.innerHTML = images.map(img => `<img class="gallery-image" src="${{img}}" loading="lazy">`).join('');
+            modal.classList.add('show');
+        }}
+
+        // 关闭图片画廊
+        function closeGallery() {{
+            const modal = document.getElementById('galleryModal');
+            modal.classList.remove('show');
+        }}
+
+        // 初始化
+        document.addEventListener('DOMContentLoaded', () => {{
+            renderLeftSidebar();
+            renderRightContent();
+
+            // 默认全部折叠,用户可以点击展开
+            // 不再自动展开第一个特征
+        }});
+    </script>
+
+    <!-- 图片画廊模态框 -->
+    <div class="gallery-modal" id="galleryModal">
+        <div class="gallery-close" onclick="closeGallery()">×</div>
+        <div class="gallery-grid" id="galleryGrid"></div>
+    </div>
+</body>
+</html>
+'''
+
+    # 写入文件
+    with open(output_path, 'w', encoding='utf-8') as f:
+        f.write(html_content)
+
+
+def main():
+    """主函数"""
+    # 配置路径
+    script_dir = os.path.dirname(os.path.abspath(__file__))
+    json_path = os.path.join(script_dir, 'output_v2', 'stage5_with_search_results.json')
+    output_dir = os.path.join(script_dir, 'visualization')
+    os.makedirs(output_dir, exist_ok=True)
+
+    timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
+    output_path = os.path.join(output_dir, f'stage5_3level_{timestamp}.html')
+
+    # 加载数据
+    print(f"📖 加载数据: {json_path}")
+    data = load_data(json_path)
+    print(f"✓ 加载了 {len(data)} 个原始特征")
+
+    # 计算统计
+    print("📊 计算统计数据...")
+    stats = calculate_statistics(data)
+    print(f"✓ 统计完成:")
+    print(f"  - 原始特征: {stats['total_features']}")
+    print(f"  - 基础词: {stats['total_base_words']}")
+    print(f"  - 搜索词: {stats['total_search_words']}")
+    print(f"  - 帖子总数: {stats['total_notes']}")
+    print(f"  - 视频: {stats['video_count']} ({stats['video_percentage']}%)")
+    print(f"  - 图文: {stats['normal_count']} ({stats['normal_percentage']}%)")
+
+    # 生成HTML
+    print(f"\n🎨 生成三层树形可视化...")
+    generate_html(data, stats, output_path)
+    print(f"✓ 生成完成: {output_path}")
+
+    # 自动在浏览器中打开
+    print(f"\n🌐 正在浏览器中打开...")
+    try:
+        subprocess.run(['open', output_path], check=True)
+        print(f"✓ 已在浏览器中打开")
+    except Exception as e:
+        print(f"⚠ 自动打开失败: {e}")
+        print(f"   请手动打开: file://{output_path}")
+
+
+if __name__ == '__main__':
+    main()