yangxiaohui 2 tygodni temu
rodzic
commit
c6e387c3e5

+ 207 - 0
analyze_inspiration_results.py

@@ -0,0 +1,207 @@
+"""
+分析灵感点匹配结果
+
+从 how/灵感点 目录读取所有灵感的 step1 和 step2 结果,
+按 step1 score 和 step2 score 排序,输出汇总报告
+"""
+import os
+import json
+import argparse
+from pathlib import Path
+from typing import List, Dict
+
+
+def collect_inspiration_results(persona_dir: str) -> List[Dict]:
+    """收集所有灵感的匹配结果
+
+    Args:
+        persona_dir: 人设目录路径
+
+    Returns:
+        结果列表,每项包含灵感名称、step1 结果、step2 结果
+    """
+    inspiration_base_dir = os.path.join(persona_dir, "how", "灵感点")
+
+    if not os.path.exists(inspiration_base_dir):
+        print(f"❌ 目录不存在: {inspiration_base_dir}")
+        return []
+
+    results = []
+
+    # 遍历所有灵感目录
+    for inspiration_name in os.listdir(inspiration_base_dir):
+        inspiration_dir = os.path.join(inspiration_base_dir, inspiration_name)
+
+        # 跳过非目录
+        if not os.path.isdir(inspiration_dir):
+            continue
+
+        # 查找 step1 文件
+        step1_files = list(Path(inspiration_dir).glob("*_step1_*.json"))
+        if not step1_files:
+            continue
+
+        step1_file = str(step1_files[0])
+
+        # 查找 step2 文件
+        step2_files = list(Path(inspiration_dir).glob("*_step2_*.json"))
+        step2_file = str(step2_files[0]) if step2_files else None
+
+        # 读取 step1 结果
+        try:
+            with open(step1_file, 'r', encoding='utf-8') as f:
+                step1_data = json.load(f)
+        except Exception as e:
+            print(f"⚠️  读取 step1 失败: {inspiration_name}, {e}")
+            continue
+
+        # 提取 step1 top1 信息(输入信息 + 完整匹配结果)
+        step1_results = step1_data.get("匹配结果列表", [])
+        if not step1_results:
+            continue
+
+        step1_top1 = step1_results[0]
+        step1_input_info = step1_top1.get("输入信息", {})
+        step1_match_result = step1_top1.get("匹配结果", {})
+        step1_score = step1_match_result.get("score", 0)
+        step1_element = step1_top1.get("业务信息", {}).get("匹配要素", "")
+
+        # 读取 step2 结果(如果存在,包含输入信息 + 完整匹配结果)
+        step2_input_info = None
+        step2_match_result = None
+        step2_score = None
+        step2_word_count = 0
+        if step2_file:
+            try:
+                with open(step2_file, 'r', encoding='utf-8') as f:
+                    step2_data = json.load(f)
+                    step2_input_info = step2_data.get("输入信息", {})
+                    step2_match_result = step2_data.get("匹配结果", {})
+                    step2_score = step2_match_result.get("score", 0)
+                    step2_b_content = step2_input_info.get("B", "")
+                    step2_word_count = len(step2_b_content.split("\n")) if step2_b_content else 0
+            except Exception as e:
+                print(f"⚠️  读取 step2 失败: {inspiration_name}, {e}")
+
+        # 构建结果项(包含输入信息和完整匹配结果)
+        result_item = {
+            "灵感": inspiration_name,
+            "step1": {
+                "输入信息": step1_input_info,
+                "匹配结果": step1_match_result,
+                "匹配要素": step1_element
+            },
+            "step2": {
+                "输入信息": step2_input_info,
+                "匹配结果": step2_match_result,
+                "增量词数量": step2_word_count
+            } if step2_file else None,
+            "文件信息": {
+                "step1": os.path.basename(step1_file),
+                "step2": os.path.basename(step2_file) if step2_file else None
+            }
+        }
+
+        results.append(result_item)
+
+    return results
+
+
+def main():
+    """主函数"""
+    parser = argparse.ArgumentParser(
+        description="分析灵感点匹配结果",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog="""
+使用示例:
+  # 分析默认目录
+  python analyze_inspiration_results.py
+
+  # 指定人设目录
+  python analyze_inspiration_results.py --dir data/阿里多多酱/out/人设_1110
+
+  # 指定输出文件
+  python analyze_inspiration_results.py --output analysis_report.json
+        """
+    )
+
+    parser.add_argument(
+        "--dir",
+        default="data/阿里多多酱/out/人设_1110",
+        help="人设目录路径 (默认: data/阿里多多酱/out/人设_1110)"
+    )
+
+    parser.add_argument(
+        "--output",
+        default=None,
+        help="输出文件路径 (默认: 在人设目录下的 how/灵感匹配分析.json)"
+    )
+
+    args = parser.parse_args()
+    persona_dir = args.dir
+
+    print(f"{'=' * 80}")
+    print(f"灵感点匹配结果分析")
+    print(f"{'=' * 80}")
+    print(f"人设目录: {persona_dir}\n")
+
+    # 收集结果
+    results = collect_inspiration_results(persona_dir)
+
+    if not results:
+        print("❌ 未找到任何灵感结果")
+        return
+
+    print(f"找到 {len(results)} 个灵感的匹配结果\n")
+
+    # 排序:先按 step1 score 降序,再按 step2 score 降序
+    # step2 score 为 None 的排在最后
+    def sort_key(item):
+        step1_score = item["step1"]["匹配结果"].get("score", 0)
+        step2_score = item["step2"]["匹配结果"].get("score", 0) if item["step2"] else -1
+        return (-step1_score, -step2_score)
+
+    results.sort(key=sort_key)
+
+    # 构建输出
+    output_data = {
+        "元数据": {
+            "人设目录": persona_dir,
+            "灵感总数": len(results),
+            "排序规则": "先按 step1 score 降序,再按 step2 score 降序"
+        },
+        "排序结果": results
+    }
+
+    # 输出统计信息
+    has_step2 = sum(1 for r in results if r["step2"] is not None)
+    print(f"统计信息:")
+    print(f"  总灵感数: {len(results)}")
+    print(f"  完成 step2: {has_step2}")
+    print(f"  仅 step1: {len(results) - has_step2}")
+
+    # Top 5 预览
+    print(f"\nTop 5 灵感 (按排序规则):")
+    for i, item in enumerate(results[:5], 1):
+        step1_score = item["step1"]["匹配结果"].get("score", 0)
+        step2_score = item["step2"]["匹配结果"].get("score", 0) if item["step2"] else None
+        step2_info = f", step2: {step2_score:.2f}" if step2_score is not None else ""
+        print(f"  {i}. {item['灵感']}")
+        print(f"     step1: {step1_score:.2f} → {item['step1']['匹配要素']}{step2_info}")
+
+    # 保存结果
+    if args.output:
+        output_file = args.output
+    else:
+        output_file = os.path.join(persona_dir, "how", "灵感匹配分析.json")
+
+    os.makedirs(os.path.dirname(output_file), exist_ok=True)
+
+    with open(output_file, 'w', encoding='utf-8') as f:
+        json.dump(output_data, f, ensure_ascii=False, indent=2)
+
+    print(f"\n完成!分析结果已保存到: {output_file}\n")
+
+
+if __name__ == "__main__":
+    main()

+ 99 - 32
run_inspiration_analysis.py

@@ -10,6 +10,8 @@ import os
 import sys
 import json
 import asyncio
+import random
+import argparse
 
 from agents import trace
 from lib.my_trace import set_trace_smith as set_trace
@@ -73,6 +75,7 @@ async def run_full_analysis(
     persona_dir: str,
     inspiration: str,
     max_tasks: int = None,
+    force: bool = False,
     current_time: str = None,
     log_url: str = None
 ) -> dict:
@@ -82,6 +85,7 @@ async def run_full_analysis(
         persona_dir: 人设目录路径
         inspiration: 灵感点文本
         max_tasks: step1 最大任务数(None 表示不限制)
+        force: 是否强制重新执行(跳过文件存在检查)
         current_time: 当前时间戳
         log_url: 日志链接
 
@@ -107,8 +111,8 @@ async def run_full_analysis(
     ]
 
     try:
-        # 调用 step1 的 main 函数
-        await step1_inspiration_match.main(current_time, log_url)
+        # 调用 step1 的 main 函数(通过参数传递 force)
+        await step1_inspiration_match.main(current_time, log_url, force=force)
     finally:
         # 恢复原始参数
         sys.argv = original_argv
@@ -147,8 +151,8 @@ async def run_full_analysis(
     ]
 
     try:
-        # 调用 step2 的 main 函数
-        await step2_incremental_match.main(current_time, log_url)
+        # 调用 step2 的 main 函数(通过参数传递 force)
+        await step2_incremental_match.main(current_time, log_url, force=force)
     finally:
         # 恢复原始参数
         sys.argv = original_argv
@@ -214,20 +218,68 @@ async def run_full_analysis(
     }
 
 
-async def main(current_time: str, log_url: str):
+async def main():
     """主函数"""
     # 解析命令行参数
-    persona_dir = sys.argv[1] if len(sys.argv) > 1 else "data/阿里多多酱/out/人设_1110"
+    parser = argparse.ArgumentParser(
+        description="灵感分析主流程 (Step1 + Step2)",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog="""
+使用示例:
+  # 处理第1个灵感
+  python run_inspiration_analysis.py --dir data/阿里多多酱/out/人设_1110 --count 1
+
+  # 随机处理5个灵感
+  python run_inspiration_analysis.py --count 5 --shuffle
+
+  # 处理所有灵感,强制重新执行
+  python run_inspiration_analysis.py --count all --force
+
+  # 处理前10个灵感,step1只处理前20个任务
+  python run_inspiration_analysis.py --count 10 --max-tasks 20
+        """
+    )
+
+    parser.add_argument(
+        "--dir",
+        default="data/阿里多多酱/out/人设_1110",
+        help="人设目录路径 (默认: data/阿里多多酱/out/人设_1110)"
+    )
+
+    parser.add_argument(
+        "--count",
+        default="1",
+        help="处理的灵感数量,可以是数字或 'all' (默认: 1)"
+    )
 
-    # 第二个参数:灵感数量限制,默认为 1(处理第一个灵感)
-    # 可以是数字(如 1, 5, 10)或 "all"(所有灵感)
-    inspiration_count_arg = sys.argv[2] if len(sys.argv) > 2 else "1"
+    parser.add_argument(
+        "--max-tasks",
+        type=str,
+        default="all",
+        help="Step1 处理的最大任务数,可以是数字或 'all' (默认: all)"
+    )
+
+    parser.add_argument(
+        "--force",
+        action="store_true",
+        help="强制重新执行,覆盖已存在的文件"
+    )
 
-    # 第三个参数:step1 任务数限制,默认为 None(所有任务)
-    max_tasks = None if len(sys.argv) > 3 and sys.argv[3] == "all" else (
-        int(sys.argv[3]) if len(sys.argv) > 3 else None
+    parser.add_argument(
+        "--shuffle",
+        action="store_true",
+        help="随机选择灵感,而不是按顺序"
     )
 
+    args = parser.parse_args()
+
+    persona_dir = args.dir
+    force = args.force
+    shuffle = args.shuffle
+
+    # 处理 max_tasks
+    max_tasks = None if args.max_tasks == "all" else int(args.max_tasks)
+
     print(f"{'=' * 80}")
     print(f"灵感分析主流程 (Step1 + Step2)")
     print(f"{'=' * 80}")
@@ -237,18 +289,31 @@ async def main(current_time: str, log_url: str):
     inspiration_list = load_inspiration_list(persona_dir)
 
     # 确定要处理的灵感数量
-    if inspiration_count_arg == "all":
+    if args.count == "all":
         inspiration_count = len(inspiration_list)
         print(f"处理灵感: 全部 ({inspiration_count} 个)")
     else:
-        inspiration_count = int(inspiration_count_arg)
+        inspiration_count = int(args.count)
         print(f"处理灵感: 前 {inspiration_count} 个")
 
     if max_tasks:
         print(f"Step1 任务数限制: {max_tasks}")
 
+    if force:
+        print(f"强制模式: 重新执行所有步骤")
+
+    if shuffle:
+        print(f"随机模式: 随机选择灵感")
+
     # 选择要处理的灵感列表
-    inspirations_to_process = inspiration_list[:inspiration_count]
+    if shuffle:
+        # 随机打乱灵感列表后选择
+        shuffled_list = inspiration_list.copy()
+        random.shuffle(shuffled_list)
+        inspirations_to_process = shuffled_list[:inspiration_count]
+    else:
+        # 按顺序选择前 N 个
+        inspirations_to_process = inspiration_list[:inspiration_count]
 
     print(f"\n将处理以下灵感:")
     for i, insp in enumerate(inspirations_to_process, 1):
@@ -258,18 +323,27 @@ async def main(current_time: str, log_url: str):
     results = []
     for i, inspiration in enumerate(inspirations_to_process, 1):
         print(f"\n{'#' * 80}")
-        print(f"处理第 {i}/{len(inspirations_to_process)} 个灵感")
+        print(f"处理第 {i}/{len(inspirations_to_process)} 个灵感: {inspiration}")
         print(f"{'#' * 80}")
 
-        result = await run_full_analysis(
-            persona_dir=persona_dir,
-            inspiration=inspiration,
-            max_tasks=max_tasks,
-            current_time=current_time,
-            log_url=log_url
-        )
+        # 为每个灵感创建独立的 trace
+        insp_time, insp_log_url = set_trace()
+
+        with trace(f"灵感分析: {inspiration}"):
+            result = await run_full_analysis(
+                persona_dir=persona_dir,
+                inspiration=inspiration,
+                max_tasks=max_tasks,
+                force=force,
+                current_time=insp_time,
+                log_url=insp_log_url
+            )
+
         results.append(result)
 
+        if insp_log_url:
+            print(f"本次 Trace: {insp_log_url}")
+
     # 输出最终汇总
     print(f"\n{'=' * 80}")
     print(f"批量处理完成")
@@ -282,14 +356,7 @@ async def main(current_time: str, log_url: str):
         status_icon = "✓" if result["status"] == "success" else "✗"
         print(f"  {status_icon} [{i}] {insp}")
 
-    if log_url:
-        print(f"\nTrace: {log_url}")
-
 
 if __name__ == "__main__":
-    # 设置 trace
-    current_time, log_url = set_trace()
-
-    # 使用 trace 上下文包裹整个执行流程
-    with trace("灵感分析完整流程 (Step1+Step2)"):
-        asyncio.run(main(current_time, log_url))
+    # 主流程不设置 trace,由每个灵感独立设置
+    asyncio.run(main())

+ 323 - 0
run_step3_from_analysis.py

@@ -0,0 +1,323 @@
+"""
+基于灵感匹配分析结果,批量执行 Step3 生成新灵感
+
+从灵感匹配分析.json中筛选符合条件的灵感(step1 score 在指定范围内),
+然后对每个灵感的 top1 匹配结果执行 step3,生成新的灵感点
+"""
+import os
+import sys
+import json
+import asyncio
+import argparse
+
+from agents import trace
+from lib.my_trace import set_trace_smith as set_trace
+from lib.data_loader import load_persona_data
+import step3_generate_inspirations
+
+
+def filter_inspirations_by_score(
+    analysis_file: str,
+    min_score: float = 0.5,
+    max_score: float = 0.8
+) -> list:
+    """从分析文件中筛选符合条件的灵感
+
+    Args:
+        analysis_file: 灵感匹配分析.json 文件路径
+        min_score: step1 score 最小值(含)
+        max_score: step1 score 最大值(含)
+
+    Returns:
+        筛选后的灵感列表
+    """
+    with open(analysis_file, 'r', encoding='utf-8') as f:
+        analysis_data = json.load(f)
+
+    results = analysis_data.get("排序结果", [])
+
+    filtered = []
+    for item in results:
+        step1_score = item["step1"]["匹配结果"].get("score", 0)
+        if min_score <= step1_score <= max_score:
+            filtered.append(item["灵感"])
+
+    return filtered
+
+
+async def run_step3_for_inspiration(
+    persona_dir: str,
+    inspiration: str,
+    persona_data: dict,
+    force: bool = False
+) -> dict:
+    """为单个灵感执行 step3
+
+    Args:
+        persona_dir: 人设目录
+        inspiration: 灵感名称
+        persona_data: 人设数据
+        force: 是否强制重新执行
+
+    Returns:
+        执行结果字典
+    """
+    print(f"\n{'=' * 80}")
+    print(f"处理灵感: {inspiration}")
+    print(f"{'=' * 80}\n")
+
+    # 查找 step1 结果文件
+    model_name = "google/gemini-2.5-pro"
+    step1_file = step3_generate_inspirations.find_step1_file(
+        persona_dir, inspiration, model_name
+    )
+
+    # 读取 step1 结果
+    with open(step1_file, 'r', encoding='utf-8') as f:
+        step1_data = json.load(f)
+
+    step1_results = step1_data.get("匹配结果列表", [])
+    if not step1_results:
+        print("❌ step1 结果为空")
+        return {
+            "灵感": inspiration,
+            "status": "step1_empty",
+            "output_file": None
+        }
+
+    # 获取 top1
+    step1_top1 = step1_results[0]
+
+    # 构建输出文件路径
+    output_dir = os.path.join(persona_dir, "how", "灵感点", inspiration)
+    model_name_short = model_name.replace("google/", "").replace("/", "_")
+    step1_filename = os.path.basename(step1_file)
+    step1_basename = os.path.splitext(step1_filename)[0]
+    scope_prefix = step1_basename.split("_")[0]
+    output_filename = f"{scope_prefix}_step3_top1_生成灵感_{model_name_short}.json"
+    output_file = os.path.join(output_dir, output_filename)
+
+    # 检查文件是否已存在
+    if not force and os.path.exists(output_file):
+        print(f"✓ 输出文件已存在,跳过: {output_file}")
+        return {
+            "灵感": inspiration,
+            "status": "skipped",
+            "output_file": output_file
+        }
+
+    # 创建独立的 trace
+    current_time, log_url = set_trace()
+
+    try:
+        with trace(f"Step3: {inspiration}"):
+            # 执行 step3
+            output = await step3_generate_inspirations.process_step3_generate_inspirations(
+                step1_top1=step1_top1,
+                persona_data=persona_data,
+                current_time=current_time,
+                log_url=log_url
+            )
+
+            # 添加元数据
+            output["元数据"]["step1_匹配索引"] = 1
+
+            # 保存结果
+            os.makedirs(output_dir, exist_ok=True)
+            with open(output_file, 'w', encoding='utf-8') as f:
+                json.dump(output, f, ensure_ascii=False, indent=2)
+
+            # 输出预览
+            generated = output.get("生成结果", {})
+            inspirations = generated.get("灵感点列表", [])
+            print(f"✓ 生成了 {len(inspirations)} 个灵感点")
+            if log_url:
+                print(f"  Trace: {log_url}")
+
+            return {
+                "灵感": inspiration,
+                "status": "success",
+                "output_file": output_file,
+                "生成数量": len(inspirations)
+            }
+
+    except Exception as e:
+        print(f"❌ 执行失败: {e}")
+        return {
+            "灵感": inspiration,
+            "status": "error",
+            "output_file": None,
+            "error": str(e)
+        }
+
+
+async def main():
+    """主函数"""
+    parser = argparse.ArgumentParser(
+        description="基于灵感匹配分析结果,批量执行 Step3",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog="""
+使用示例:
+  # 测试:只处理第1个符合条件的灵感
+  python run_step3_from_analysis.py --count 1
+
+  # 使用默认参数(step1 score 在 [0.5, 0.8] 区间)
+  python run_step3_from_analysis.py
+
+  # 指定 score 范围
+  python run_step3_from_analysis.py --min-score 0.6 --max-score 0.9
+
+  # 强制重新执行,处理前3个
+  python run_step3_from_analysis.py --force --count 3
+
+  # 指定人设目录
+  python run_step3_from_analysis.py --dir data/阿里多多酱/out/人设_1110
+        """
+    )
+
+    parser.add_argument(
+        "--dir",
+        default="data/阿里多多酱/out/人设_1110",
+        help="人设目录路径 (默认: data/阿里多多酱/out/人设_1110)"
+    )
+
+    parser.add_argument(
+        "--analysis-file",
+        default=None,
+        help="灵感匹配分析文件路径 (默认: {dir}/how/灵感匹配分析.json)"
+    )
+
+    parser.add_argument(
+        "--min-score",
+        type=float,
+        default=0.5,
+        help="step1 score 最小值(含)(默认: 0.5)"
+    )
+
+    parser.add_argument(
+        "--max-score",
+        type=float,
+        default=0.8,
+        help="step1 score 最大值(含)(默认: 0.8)"
+    )
+
+    parser.add_argument(
+        "--force",
+        action="store_true",
+        help="强制重新执行,覆盖已存在的文件"
+    )
+
+    parser.add_argument(
+        "--count",
+        type=int,
+        default=1,
+        help="处理的灵感数量限制(默认: 1)"
+    )
+
+    args = parser.parse_args()
+
+    persona_dir = args.dir
+    min_score = args.min_score
+    max_score = args.max_score
+    force = args.force
+    count_limit = args.count
+
+    # 确定分析文件路径
+    if args.analysis_file:
+        analysis_file = args.analysis_file
+    else:
+        analysis_file = os.path.join(persona_dir, "how", "灵感匹配分析.json")
+
+    print(f"{'=' * 80}")
+    print(f"基于灵感匹配分析,批量执行 Step3")
+    print(f"{'=' * 80}")
+    print(f"人设目录: {persona_dir}")
+    print(f"分析文件: {analysis_file}")
+    print(f"Score 范围: [{min_score}, {max_score}]")
+    if count_limit:
+        print(f"数量限制: 处理前 {count_limit} 个")
+    if force:
+        print(f"强制模式: 重新执行所有步骤")
+    print()
+
+    # 检查分析文件是否存在
+    if not os.path.exists(analysis_file):
+        print(f"❌ 分析文件不存在: {analysis_file}")
+        print(f"请先运行 analyze_inspiration_results.py 生成分析文件")
+        sys.exit(1)
+
+    # 筛选灵感
+    filtered_inspirations = filter_inspirations_by_score(
+        analysis_file, min_score, max_score
+    )
+
+    if not filtered_inspirations:
+        print(f"❌ 没有找到符合条件的灵感(step1 score 在 [{min_score}, {max_score}] 范围内)")
+        sys.exit(0)
+
+    # 应用数量限制
+    if count_limit and count_limit < len(filtered_inspirations):
+        filtered_inspirations = filtered_inspirations[:count_limit]
+        print(f"找到 {len(filtered_inspirations)} 个符合条件的灵感(已应用数量限制):\n")
+    else:
+        print(f"找到 {len(filtered_inspirations)} 个符合条件的灵感:\n")
+
+    for i, insp in enumerate(filtered_inspirations, 1):
+        print(f"  {i}. {insp}")
+    print()
+
+    # 加载人设数据(只需要加载一次)
+    persona_data = load_persona_data(persona_dir)
+
+    # 批量执行 step3
+    results = []
+    for i, inspiration in enumerate(filtered_inspirations, 1):
+        print(f"\n{'#' * 80}")
+        print(f"处理第 {i}/{len(filtered_inspirations)} 个")
+        print(f"{'#' * 80}")
+
+        result = await run_step3_for_inspiration(
+            persona_dir=persona_dir,
+            inspiration=inspiration,
+            persona_data=persona_data,
+            force=force
+        )
+
+        results.append(result)
+
+    # 输出最终汇总
+    print(f"\n{'=' * 80}")
+    print(f"批量处理完成")
+    print(f"{'=' * 80}\n")
+
+    success_count = sum(1 for r in results if r["status"] == "success")
+    skipped_count = sum(1 for r in results if r["status"] == "skipped")
+    error_count = sum(1 for r in results if r["status"] == "error")
+
+    print(f"统计:")
+    print(f"  总数: {len(results)}")
+    print(f"  成功: {success_count}")
+    print(f"  跳过: {skipped_count}")
+    print(f"  失败: {error_count}")
+
+    print(f"\n详细结果:")
+    for i, result in enumerate(results, 1):
+        status_icon = {
+            "success": "✓",
+            "skipped": "○",
+            "error": "✗",
+            "step1_empty": "⚠"
+        }.get(result["status"], "?")
+
+        status_text = {
+            "success": f"成功,生成 {result.get('生成数量', 0)} 个",
+            "skipped": "已存在",
+            "error": f"失败: {result.get('error', '')}",
+            "step1_empty": "step1 结果为空"
+        }.get(result["status"], result["status"])
+
+        print(f"  {status_icon} [{i}] {result['灵感']} - {status_text}")
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 20 - 14
step1_inspiration_match.py

@@ -209,12 +209,13 @@ async def process_inspiration_match(
 
 
 # ========== 主函数 ==========
-async def main(current_time: str = None, log_url: str = None):
+async def main(current_time: str = None, log_url: str = None, force: bool = False):
     """主函数:负责参数解析、文件读取、结果保存
 
     Args:
         current_time: 当前时间戳(从外部传入)
         log_url: 日志链接(从外部传入)
+        force: 是否强制重新执行(跳过已存在文件检查)
     """
     # 解析命令行参数
     # 第一个参数:人设文件夹路径(默认值)
@@ -231,11 +232,29 @@ async def main(current_time: str = None, log_url: str = None):
         int(sys.argv[3]) if len(sys.argv) > 3 else None
     )
 
+    # 第四个参数:force(如果从命令行调用且有该参数,则覆盖函数参数)
+    if len(sys.argv) > 4 and sys.argv[4] == "force":
+        force = True
+
     # 加载数据(使用辅助函数,失败时自动退出)
     persona_data = load_persona_data(persona_dir)
     inspiration_list = load_inspiration_list(persona_dir)
     test_inspiration = select_inspiration(inspiration_arg, inspiration_list)
 
+    # 构建输出文件路径
+    output_dir = os.path.join(persona_dir, "how", "灵感点", test_inspiration)
+    model_name_short = MODEL_NAME.replace("google/", "").replace("/", "_")
+    step_name_cn = "灵感人设匹配"
+    scope_prefix = f"top{max_tasks}" if max_tasks is not None else "all"
+    output_filename = f"{scope_prefix}_step1_{step_name_cn}_{model_name_short}.json"
+    output_file = os.path.join(output_dir, output_filename)
+
+    # 检查文件是否已存在
+    if not force and os.path.exists(output_file):
+        print(f"\n✓ 输出文件已存在,跳过执行: {output_file}")
+        print(f"提示: 如需重新执行,请添加 'force' 参数\n")
+        return
+
     # 执行核心业务逻辑
     output = await process_inspiration_match(
         persona_data=persona_data,
@@ -246,21 +265,8 @@ async def main(current_time: str = None, log_url: str = None):
         log_url=log_url
     )
 
-    # 保存结果文件
-    # 路径格式:how/灵感点/[灵感点名称]/[top_n?]_[步骤名称中文]_[模型名称].json
-    output_dir = os.path.join(persona_dir, "how", "灵感点", test_inspiration)
-
-    # 提取模型名称
-    model_name_short = MODEL_NAME.replace("google/", "").replace("/", "_")
-    step_name_cn = "灵感人设匹配"
-
-    # 构建文件名:范围标识(all 或 top_n)+ step1 + 步骤名称中文 + 模型名称
-    scope_prefix = f"top{max_tasks}" if max_tasks is not None else "all"
-    output_filename = f"{scope_prefix}_step1_{step_name_cn}_{model_name_short}.json"
-
     # 确保目录存在
     os.makedirs(output_dir, exist_ok=True)
-    output_file = os.path.join(output_dir, output_filename)
 
     # 保存结果
     with open(output_file, 'w', encoding='utf-8') as f:

+ 27 - 12
step2_incremental_match.py

@@ -195,12 +195,22 @@ async def process_step2_incremental_match(
     }
 
 
-async def main(current_time: str, log_url: str):
-    """主函数"""
+async def main(current_time: str, log_url: str, force: bool = False):
+    """主函数
+
+    Args:
+        current_time: 当前时间戳
+        log_url: 日志链接
+        force: 是否强制重新执行(跳过已存在文件检查)
+    """
     # 解析命令行参数
     persona_dir = sys.argv[1] if len(sys.argv) > 1 else "data/阿里多多酱/out/人设_1110"
     inspiration_arg = sys.argv[2] if len(sys.argv) > 2 else "0"
 
+    # 第三个参数:force(如果从命令行调用且有该参数,则覆盖函数参数)
+    if len(sys.argv) > 3 and sys.argv[3] == "force":
+        force = True
+
     print(f"{'=' * 80}")
     print(f"Step2: 增量词在人设中的匹配分析(Top1)")
     print(f"{'=' * 80}")
@@ -219,6 +229,20 @@ async def main(current_time: str, log_url: str):
 
     print(f"Step1 输入文件: {step1_file}")
 
+    # 构建输出文件路径
+    output_dir = os.path.join(persona_dir, "how", "灵感点", test_inspiration)
+    model_name_short = MODEL_NAME.replace("google/", "").replace("/", "_")
+    scope_prefix = step1_basename.split("_")[0]  # 提取 "all" 或 "top10" 等
+    result_index = 0  # 使用第 1 个匹配结果(top1)
+    output_filename = f"{scope_prefix}_step2_top{result_index + 1}_增量词匹配_{model_name_short}.json"
+    output_file = os.path.join(output_dir, output_filename)
+
+    # 检查文件是否已存在
+    if not force and os.path.exists(output_file):
+        print(f"\n✓ 输出文件已存在,跳过执行: {output_file}")
+        print(f"提示: 如需重新执行,请添加 'force' 参数\n")
+        return
+
     with open(step1_file, 'r', encoding='utf-8') as f:
         step1_data = json.load(f)
 
@@ -231,8 +255,7 @@ async def main(current_time: str, log_url: str):
 
     print(f"灵感: {actual_inspiration}")
 
-    # 默认处理 top1,后续可以支持指定第几个
-    result_index = 0  # 使用第 1 个匹配结果(top1)
+    # 默认处理 top1
     selected_result = step1_results[result_index]
     print(f"处理第 {result_index + 1} 个匹配结果(Top{result_index + 1})\n")
 
@@ -249,15 +272,7 @@ async def main(current_time: str, log_url: str):
     output["元数据"]["step1_匹配索引"] = result_index + 1
 
     # 保存结果
-    output_dir = os.path.join(persona_dir, "how", "灵感点", test_inspiration)
-    model_name_short = MODEL_NAME.replace("google/", "").replace("/", "_")
-
-    # 提取 step1 的范围标识(all 或 top10 等)
-    scope_prefix = step1_basename.split("_")[0]  # 提取 "all" 或 "top10" 等
-    output_filename = f"{scope_prefix}_step2_top{result_index + 1}_增量词匹配_{model_name_short}.json"
-
     os.makedirs(output_dir, exist_ok=True)
-    output_file = os.path.join(output_dir, output_filename)
 
     with open(output_file, 'w', encoding='utf-8') as f:
         json.dump(output, f, ensure_ascii=False, indent=2)

+ 163 - 51
step3_generate_inspirations.py

@@ -23,40 +23,46 @@ MODEL_NAME = "google/gemini-2.5-pro"
 # ========== System Prompt ==========
 GENERATE_INSPIRATIONS_PROMPT = """
 # 任务
-基于给定的人设要素(锚点),分析和生成可能的灵感点。
+基于给定的人设体系和锚点要素,分析和生成可能的灵感点。
 
 ## 输入说明
 
-- **<人设要素></人设要素>**: 作为锚点的人设要素(一级或二级分类)
+- **<人设体系></人设体系>**: 完整的人设系统,用于理解整体风格和定位
+- **<锚点要素></锚点要素>**: 作为锚点的人设要素(一级或二级分类)
+- **<要素定义></要素定义>**: 该要素在人设中的完整定义(如果有)
 - **<要素上下文></要素上下文>**: 该要素的上下文信息(所属视角、一级分类等)
-- **<参考灵感></参考灵感>**: 一个已匹配到该要素的灵感点示例(可选)
 
 ## 分析方法
 
-### 核心原则:基于要素特征发散灵感
+### 核心原则:基于人设特征和要素定位发散灵感
 
-从人设要素的核心特征出发,思考可能触发该要素的各种灵感来源。
+1. 先理解整个人设体系的风格、调性和定位
+2. 深入理解锚点要素在人设中的含义和作用
+3. 基于人设特征,推理可能触发该要素的灵感来源
 
 ### 分析步骤
 
-1. **理解要素核心**
-   - 分析人设要素的核心特征
-   - 理解该要素代表的内容类型或表达方式
-   - 结合上下文理解要素的定位
+1. **理解人设体系**
+   - 分析人设的整体风格和内容定位
+   - 理解灵感点、目的点、关键点的组织方式
+   - 把握人设的表达特色和价值取向
 
-2. **参考已有灵感**(如果提供)
-   - 分析参考灵感如何触发该要素
-   - 识别灵感的关键特征
+2. **理解锚点要素**
+   - 结合要素定义,理解该要素的核心含义
+   - 分析该要素在人设体系中的层级位置
+   - 理解该要素代表的内容类型或表达方式
 
-3. **发散思考**
-   - 从不同角度思考可能的灵感来源
+3. **发散灵感思考**
+   - 基于人设整体风格,思考什么样的灵感会触发该要素
    - 考虑不同的场景、话题、情感、事件等
-   - 保持与要素核心特征的相关性
+   - 确保生成的灵感符合人设的调性和定位
+   - 灵感应该多样化,但都要能映射到该要素
 
 4. **生成灵感点列表**
-   - 每个灵感点应该简洁明确
+   - 每个灵感点应该简洁明确(一句话)
    - 灵感点之间应有一定的多样性
    - 灵感点应该能够触发该人设要素
+   - 纯粹基于人设和要素特征推理
 
 ---
 
@@ -64,18 +70,22 @@ GENERATE_INSPIRATIONS_PROMPT = """
 
 ```json
 {
+  "人设理解": {
+    "整体风格": "简要描述人设的整体风格和定位(1-2句话)",
+    "内容调性": "该人设的内容调性和表达特点"
+  },
   "要素分析": {
-    "核心特征": "简要描述该要素的核心特征(1-2句话)",
-    "适用场景": "该要素适用的内容场景或表达方式"
+    "核心特征": "结合人设和要素定义,描述该要素的核心特征(1-2句话)",
+    "适用场景": "该要素在这个人设中适用的内容场景"
   },
   "灵感点列表": [
     {
       "灵感点": "具体的灵感点描述",
-      "说明": "为什么这个灵感可能触发该要素"
+      "说明": "为什么这个灵感可能触发该要素(结合人设特征说明)"
     },
     {
       "灵感点": "具体的灵感点描述",
-      "说明": "为什么这个灵感可能触发该要素"
+      "说明": "为什么这个灵感可能触发该要素(结合人设特征说明)"
     }
   ]
 }
@@ -84,12 +94,14 @@ GENERATE_INSPIRATIONS_PROMPT = """
 **输出要求**:
 1. 必须严格按照上述JSON格式输出
 2. 所有字段都必须填写
-3. **要素分析**:包含核心特征和适用场景
-4. **灵感点列表**:生成 5-10 个灵感点
-5. 每个灵感点包含:
+3. **人设理解**:体现对整个人设体系的把握
+4. **要素分析**:结合人设和要素定义进行分析
+5. **灵感点列表**:生成 5-10 个灵感点
+6. 每个灵感点包含:
    - **灵感点**:简洁的灵感描述(一句话)
-   - **说明**:解释为什么这个灵感可能触发该要素(1-2句话)
-6. 灵感点应该多样化,覆盖不同角度和场景
+   - **说明**:解释为什么这个灵感可能触发该要素,要结合人设特征(1-2句话)
+7. 灵感点应该多样化,覆盖不同角度和场景
+8. 纯粹基于人设和要素特征进行推理,不依赖外部参考
 """.strip()
 
 
@@ -138,6 +150,10 @@ def parse_generate_response(response_content: str) -> dict:
     except Exception as e:
         print(f"解析响应失败: {e}")
         return {
+            "人设理解": {
+                "整体风格": "解析失败",
+                "内容调性": "解析失败"
+            },
             "要素分析": {
                 "核心特征": "解析失败",
                 "适用场景": "解析失败"
@@ -146,6 +162,78 @@ def parse_generate_response(response_content: str) -> dict:
         }
 
 
+def format_persona_system(persona_data: dict) -> str:
+    """格式化完整人设系统为文本
+
+    Args:
+        persona_data: 人设数据
+
+    Returns:
+        格式化的人设系统文本
+    """
+    lines = ["# 人设系统"]
+
+    # 处理三个部分:灵感点列表、目的点、关键点列表
+    for section_key, section_title in [
+        ("灵感点列表", "【灵感点】灵感的来源和性质"),
+        ("目的点", "【目的点】创作的目的和价值导向"),
+        ("关键点列表", "【关键点】内容的核心主体和表达方式")
+    ]:
+        section_data = persona_data.get(section_key, [])
+        if not section_data:
+            continue
+
+        lines.append(f"\n## {section_title}\n")
+
+        for perspective in section_data:
+            perspective_name = perspective.get("视角名称", "")
+            lines.append(f"\n### 视角:{perspective_name}")
+
+            for pattern in perspective.get("模式列表", []):
+                pattern_name = pattern.get("分类名称", "")
+                pattern_def = pattern.get("核心定义", "")
+                lines.append(f"\n  【一级】{pattern_name}")
+                if pattern_def:
+                    lines.append(f"    定义:{pattern_def}")
+
+                # 二级细分
+                for sub in pattern.get("二级细分", []):
+                    sub_name = sub.get("分类名称", "")
+                    sub_def = sub.get("分类定义", "")
+                    lines.append(f"    【二级】{sub_name}:{sub_def}")
+
+    return "\n".join(lines)
+
+
+def find_element_definition(persona_data: dict, element_name: str) -> str:
+    """从人设数据中查找要素的定义
+
+    Args:
+        persona_data: 人设数据
+        element_name: 要素名称
+
+    Returns:
+        要素定义文本,如果未找到则返回空字符串
+    """
+    # 在灵感点列表中查找
+    for section_key in ["灵感点列表", "目的点", "关键点列表"]:
+        section_data = persona_data.get(section_key, [])
+        for perspective in section_data:
+            for pattern in perspective.get("模式列表", []):
+                # 检查一级分类
+                if pattern.get("分类名称", "") == element_name:
+                    definition = pattern.get("核心定义", "")
+                    if definition:
+                        return definition
+
+                # 检查二级分类
+                for sub in pattern.get("二级细分", []):
+                    if sub.get("分类名称", "") == element_name:
+                        return sub.get("分类定义", "")
+
+    return ""
+
+
 def find_step1_file(persona_dir: str, inspiration: str, model_name: str) -> str:
     """查找 step1 输出文件
 
@@ -175,7 +263,7 @@ def find_step1_file(persona_dir: str, inspiration: str, model_name: str) -> str:
 
 async def process_step3_generate_inspirations(
     step1_top1: dict,
-    reference_inspiration: str,
+    persona_data: dict,
     current_time: str = None,
     log_url: str = None
 ) -> dict:
@@ -183,7 +271,7 @@ async def process_step3_generate_inspirations(
 
     Args:
         step1_top1: step1 的 top1 匹配结果
-        reference_inspiration: 参考灵感(step1 输入的灵感)
+        persona_data: 完整的人设数据
         current_time: 当前时间戳
         log_url: trace URL
 
@@ -197,27 +285,37 @@ async def process_step3_generate_inspirations(
     matched_element = business_info.get("匹配要素", "")
     element_context = input_info.get("A_Context", "")
 
+    # 格式化人设系统
+    persona_system_text = format_persona_system(persona_data)
+
+    # 查找要素定义
+    element_definition = find_element_definition(persona_data, matched_element)
+
     print(f"\n开始灵感生成分析")
     print(f"锚点要素: {matched_element}")
-    print(f"参考灵感: {reference_inspiration}")
+    print(f"要素定义: {element_definition if element_definition else '(未找到定义)'}")
     print(f"模型: {MODEL_NAME}\n")
 
-    # 构建任务描述
+    # 构建任务描述(包含完整人设系统、锚点要素、要素定义、要素上下文)
     task_description = f"""## 本次分析任务
 
-<人设要素>
+<人设体系>
+{persona_system_text}
+</人设体系>
+
+<锚点要素>
 {matched_element}
-</人设要素>
+</锚点要素>
+
+<要素定义>
+{element_definition if element_definition else '无'}
+</要素定义>
 
 <要素上下文>
 {element_context}
 </要素上下文>
 
-<参考灵感>
-{reference_inspiration}
-</参考灵感>
-
-请基于上述人设要素作为锚点,分析并生成可能的灵感点列表,严格按照系统提示中的 JSON 格式输出结果。"""
+请基于上述完整的人设体系和锚点要素,深入理解人设的整体风格和该要素的定位,推理并生成可能的灵感点列表,严格按照系统提示中的 JSON 格式输出结果。"""
 
     # 构造消息
     messages = [{
@@ -235,7 +333,6 @@ async def process_step3_generate_inspirations(
         name=f"Step3: 灵感生成 - {matched_element}",
         data={
             "锚点要素": matched_element,
-            "参考灵感": reference_inspiration,
             "模型": MODEL_NAME,
             "步骤": "基于要素生成灵感点"
         }
@@ -259,20 +356,30 @@ async def process_step3_generate_inspirations(
         },
         "锚点信息": {
             "人设要素": matched_element,
-            "要素上下文": element_context,
-            "参考灵感": reference_inspiration
+            "要素定义": element_definition if element_definition else "无",
+            "要素上下文": element_context
         },
         "step1_结果": step1_top1,
         "生成结果": parsed_result
     }
 
 
-async def main(current_time: str, log_url: str):
-    """主函数"""
+async def main(current_time: str, log_url: str, force: bool = False):
+    """主函数
+
+    Args:
+        current_time: 当前时间戳
+        log_url: 日志链接
+        force: 是否强制重新执行(跳过已存在文件检查)
+    """
     # 解析命令行参数
     persona_dir = sys.argv[1] if len(sys.argv) > 1 else "data/阿里多多酱/out/人设_1110"
     inspiration_arg = sys.argv[2] if len(sys.argv) > 2 else "0"
 
+    # 第三个参数:force(如果从命令行调用且有该参数,则覆盖函数参数)
+    if len(sys.argv) > 3 and sys.argv[3] == "force":
+        force = True
+
     print(f"{'=' * 80}")
     print(f"Step3: 基于匹配节点生成灵感点")
     print(f"{'=' * 80}")
@@ -307,6 +414,20 @@ async def main(current_time: str, log_url: str):
 
     print(f"Step1 输入文件: {step1_file}")
 
+    # 构建输出文件路径
+    output_dir = os.path.join(persona_dir, "how", "灵感点", test_inspiration)
+    model_name_short = MODEL_NAME.replace("google/", "").replace("/", "_")
+    scope_prefix = step1_basename.split("_")[0]
+    result_index = 0
+    output_filename = f"{scope_prefix}_step3_top{result_index + 1}_生成灵感_{model_name_short}.json"
+    output_file = os.path.join(output_dir, output_filename)
+
+    # 检查文件是否已存在
+    if not force and os.path.exists(output_file):
+        print(f"\n✓ 输出文件已存在,跳过执行: {output_file}")
+        print(f"提示: 如需重新执行,请添加 'force' 参数\n")
+        return
+
     with open(step1_file, 'r', encoding='utf-8') as f:
         step1_data = json.load(f)
 
@@ -320,14 +441,13 @@ async def main(current_time: str, log_url: str):
     print(f"灵感: {actual_inspiration}")
 
     # 默认处理 top1
-    result_index = 0
     selected_result = step1_results[result_index]
     print(f"处理第 {result_index + 1} 个匹配结果(Top{result_index + 1})\n")
 
     # 执行核心业务逻辑
     output = await process_step3_generate_inspirations(
         step1_top1=selected_result,
-        reference_inspiration=actual_inspiration,
+        persona_data=persona_data,
         current_time=current_time,
         log_url=log_url
     )
@@ -336,15 +456,7 @@ async def main(current_time: str, log_url: str):
     output["元数据"]["step1_匹配索引"] = result_index + 1
 
     # 保存结果
-    output_dir = os.path.join(persona_dir, "how", "灵感点", test_inspiration)
-    model_name_short = MODEL_NAME.replace("google/", "").replace("/", "_")
-
-    # 提取 step1 的范围标识(all 或 top10 等)
-    scope_prefix = step1_basename.split("_")[0]
-    output_filename = f"{scope_prefix}_step3_top{result_index + 1}_生成灵感_{model_name_short}.json"
-
     os.makedirs(output_dir, exist_ok=True)
-    output_file = os.path.join(output_dir, output_filename)
 
     with open(output_file, 'w', encoding='utf-8') as f:
         json.dump(output, f, ensure_ascii=False, indent=2)

+ 2068 - 0
visualize_inspiration_points.py

@@ -0,0 +1,2068 @@
+"""
+灵感点分析结果可视化脚本
+
+读取 how/灵感点 目录下的分析结果,结合作者历史帖子详情,生成可视化HTML页面
+"""
+
+import json
+from pathlib import Path
+from typing import Dict, Any, List, Optional
+from datetime import datetime
+import html as html_module
+
+
+def load_inspiration_points_data(inspiration_dir: str) -> List[Dict[str, Any]]:
+    """
+    加载所有灵感点的分析结果
+
+    Args:
+        inspiration_dir: 灵感点目录路径
+
+    Returns:
+        灵感点分析结果列表
+    """
+    inspiration_path = Path(inspiration_dir)
+    results = []
+
+    # 遍历所有子目录
+    for subdir in inspiration_path.iterdir():
+        if subdir.is_dir():
+            # 查找 all_summary 文件
+            summary_files = list(subdir.glob("all_summary_*.json"))
+            if summary_files:
+                summary_file = summary_files[0]
+                try:
+                    with open(summary_file, 'r', encoding='utf-8') as f:
+                        data = json.load(f)
+
+                    # 加载完整的 step1 和 step2 数据
+                    step1_data = None
+                    step2_data = None
+
+                    # 直接从当前子目录查找 step1 和 step2 文件
+                    step1_files = list(subdir.glob("all_step1_*.json"))
+                    step2_files = list(subdir.glob("all_step2_*.json"))
+
+                    if step1_files:
+                        try:
+                            with open(step1_files[0], 'r', encoding='utf-8') as f:
+                                step1_data = json.load(f)
+                        except Exception as e:
+                            print(f"警告: 读取 {step1_files[0]} 失败: {e}")
+
+                    if step2_files:
+                        try:
+                            with open(step2_files[0], 'r', encoding='utf-8') as f:
+                                step2_data = json.load(f)
+                        except Exception as e:
+                            print(f"警告: 读取 {step2_files[0]} 失败: {e}")
+
+                    results.append({
+                        "summary": data,
+                        "step1": step1_data,
+                        "step2": step2_data,
+                        "inspiration_name": subdir.name
+                    })
+                except Exception as e:
+                    print(f"警告: 读取 {summary_file} 失败: {e}")
+
+    return results
+
+
+def load_posts_data(posts_dir: str) -> Dict[str, Dict[str, Any]]:
+    """
+    加载所有帖子详情数据
+
+    Args:
+        posts_dir: 帖子目录路径
+
+    Returns:
+        帖子ID到帖子详情的映射
+    """
+    posts_path = Path(posts_dir)
+    posts_map = {}
+
+    for post_file in posts_path.glob("*.json"):
+        try:
+            with open(post_file, 'r', encoding='utf-8') as f:
+                post_data = json.load(f)
+                post_id = post_data.get("channel_content_id")
+                if post_id:
+                    posts_map[post_id] = post_data
+        except Exception as e:
+            print(f"警告: 读取 {post_file} 失败: {e}")
+
+    return posts_map
+
+
+def generate_inspiration_card_html(inspiration_data: Dict[str, Any]) -> str:
+    """
+    生成单个灵感点的卡片HTML
+
+    Args:
+        inspiration_data: 灵感点数据
+
+    Returns:
+        HTML字符串
+    """
+    summary = inspiration_data.get("summary", {})
+    step1 = inspiration_data.get("step1", {})
+    step2 = inspiration_data.get("step2", {})
+    inspiration_name = inspiration_data.get("inspiration_name", "未知灵感")
+
+    # 提取关键指标
+    metrics = summary.get("关键指标", {})
+    step1_score = metrics.get("step1_top1_score", 0)
+    step2_score = metrics.get("step2_score", 0)
+    step1_match_element = metrics.get("step1_top1_匹配要素", "")
+    step2_increment_count = metrics.get("step2_增量词数量", 0)
+
+    # 确定卡片颜色(基于Step1分数)
+    if step1_score >= 0.7:
+        border_color = "#10b981"
+        step1_color = "#10b981"
+    elif step1_score >= 0.5:
+        border_color = "#f59e0b"
+        step1_color = "#f59e0b"
+    elif step1_score >= 0.3:
+        border_color = "#3b82f6"
+        step1_color = "#3b82f6"
+    else:
+        border_color = "#ef4444"
+        step1_color = "#ef4444"
+
+    # Step2颜色
+    if step2_score >= 0.7:
+        step2_color = "#10b981"
+    elif step2_score >= 0.5:
+        step2_color = "#f59e0b"
+    elif step2_score >= 0.3:
+        step2_color = "#3b82f6"
+    else:
+        step2_color = "#ef4444"
+
+    # 转义HTML
+    inspiration_name_escaped = html_module.escape(inspiration_name)
+    step1_match_element_escaped = html_module.escape(step1_match_element)
+
+    # 获取Step1匹配结果(简要展示)
+    step1_matches = step1.get("匹配结果列表", []) if step1 else []
+    step1_match_preview = ""
+    if step1_matches:
+        top_match = step1_matches[0]
+        # 从新的数据结构中提取信息
+        input_info = top_match.get("输入信息", {})
+        match_result = top_match.get("匹配结果", {})
+        element_name = input_info.get("A", "")
+        match_score = match_result.get("score", 0)
+        same_parts = match_result.get("相同部分", {})
+        increment_parts = match_result.get("增量部分", {})
+
+        # 生成相同部分和增量部分的HTML
+        parts_html = ""
+        if same_parts:
+            same_items = [f"{html_module.escape(k)}" for k in same_parts.keys()]
+            parts_html += f'<div class="preview-parts same"><strong>相同:</strong> {", ".join(same_items)}</div>'
+
+        if increment_parts:
+            inc_items = [f"{html_module.escape(k)}" for k in increment_parts.keys()]
+            parts_html += f'<div class="preview-parts increment"><strong>增量:</strong> {", ".join(inc_items)}</div>'
+
+        step1_match_preview = f'''
+        <div class="match-preview">
+            <div class="match-preview-header">🎯 Step1 Top1匹配</div>
+            <div class="match-preview-content">
+                <span class="match-preview-name">{html_module.escape(element_name)}</span>
+                <span class="match-preview-score" style="color: {step1_color};">{match_score:.2f}</span>
+            </div>
+            {parts_html}
+        </div>
+        '''
+
+    # 获取Step2匹配结果(简要展示)
+    step2_match_preview = ""
+    if step2:
+        input_info = step2.get("输入信息", {})
+        match_result = step2.get("匹配结果", {})
+        increment_word = input_info.get("B", "")
+        match_score = match_result.get("score", 0)
+        same_parts = match_result.get("相同部分", {})
+        increment_parts = match_result.get("增量部分", {})
+
+        # 只有当增量词不为空时才显示
+        if increment_word.strip():
+            # 生成相同部分和增量部分的HTML
+            parts_html = ""
+            if same_parts:
+                same_items = [f"{html_module.escape(k)}" for k in same_parts.keys()]
+                parts_html += f'<div class="preview-parts same"><strong>相同:</strong> {", ".join(same_items)}</div>'
+
+            if increment_parts:
+                inc_items = [f"{html_module.escape(k)}" for k in increment_parts.keys()]
+                parts_html += f'<div class="preview-parts increment"><strong>增量:</strong> {", ".join(inc_items)}</div>'
+
+            step2_match_preview = f'''
+            <div class="match-preview">
+                <div class="match-preview-header">➕ Step2 Top1增量词</div>
+                <div class="match-preview-content">
+                    <span class="match-preview-name">{html_module.escape(increment_word)}</span>
+                    <span class="match-preview-score" style="color: {step2_color};">{match_score:.2f}</span>
+                </div>
+                {parts_html}
+            </div>
+            '''
+
+    # 准备详细数据用于弹窗
+    detail_data_json = json.dumps(inspiration_data, ensure_ascii=False)
+    detail_data_json_escaped = html_module.escape(detail_data_json)
+
+    # 生成详细HTML并进行HTML转义
+    detail_html = generate_detail_html(inspiration_data)
+    detail_html_escaped = html_module.escape(detail_html)
+
+    html = f'''
+    <div class="inspiration-card" style="border-left-color: {border_color};"
+         data-inspiration-name="{inspiration_name_escaped}"
+         data-detail="{detail_data_json_escaped}"
+         data-detail-html="{detail_html_escaped}"
+         data-step1-score="{step1_score}"
+         data-step2-score="{step2_score}"
+         onclick="showInspirationDetail(this)">
+        <div class="card-header">
+            <h3 class="inspiration-name">{inspiration_name_escaped}</h3>
+        </div>
+
+        <div class="score-section">
+            <div class="score-item">
+                <div class="score-label">Step1分数</div>
+                <div class="score-value" style="color: {step1_color};">{step1_score:.3f}</div>
+            </div>
+            <div class="score-divider"></div>
+            <div class="score-item">
+                <div class="score-label">Step2分数</div>
+                <div class="score-value" style="color: {step2_color};">{step2_score:.3f}</div>
+            </div>
+        </div>
+
+        {step1_match_preview}
+        {step2_match_preview}
+
+        <div class="metrics-section">
+            <div class="metric-item">
+                <span class="metric-icon">📊</span>
+                <span class="metric-label">增量词数:</span>
+                <span class="metric-value">{step2_increment_count}</span>
+            </div>
+        </div>
+
+        <div class="click-hint">点击查看详情 →</div>
+    </div>
+    '''
+
+    return html
+
+
+def generate_detail_html(inspiration_data: Dict[str, Any]) -> str:
+    """
+    生成灵感点的详细信息HTML
+
+    Args:
+        inspiration_data: 灵感点数据
+
+    Returns:
+        详细信息的HTML字符串
+    """
+    import html as html_module
+
+    summary = inspiration_data.get("summary", {})
+    step1 = inspiration_data.get("step1", {})
+    step2 = inspiration_data.get("step2", {})
+    inspiration_name = inspiration_data.get("inspiration_name", "未知灵感")
+
+    content = f'''
+        <div class="modal-header">
+            <h2 class="modal-title">{html_module.escape(inspiration_name)}</h2>
+        </div>
+    '''
+
+    # 获取元数据,用于后面的日志链接
+    metadata = summary.get("元数据", {})
+
+    # Step1 详细信息
+    if step1 and step1.get("灵感"):
+        inspiration = step1.get("灵感", "")
+        matches = step1.get("匹配结果列表", [])
+
+        content += f'''
+            <div class="modal-section">
+                <h3>🎯 Step1: 灵感人设匹配</h3>
+                <div class="step-content">
+                    <div class="step-field">
+                        <span class="step-field-label">灵感内容:</span>
+                        <span class="step-field-value">{html_module.escape(inspiration)}</span>
+                    </div>
+        '''
+
+        # 显示匹配结果(只显示Top1)
+        if matches:
+            content += f'''
+                <div class="step-field">
+                    <span class="step-field-label">Top1匹配结果:</span>
+                    <div class="matches-list">
+            '''
+
+            for index, match in enumerate(matches[:1]):
+                input_info = match.get("输入信息", {})
+                match_result = match.get("匹配结果", {})
+
+                element_a = input_info.get("A", "")
+                context_a = input_info.get("A_Context", "")
+                score = match_result.get("score", 0)
+                score_explain = match_result.get("score说明", "")
+                same_parts = match_result.get("相同部分", {})
+                increment_parts = match_result.get("增量部分", {})
+
+                content += f'''
+                    <div class="match-item">
+                        <div class="match-header">
+                            <span class="match-element-name">{html_module.escape(element_a)}</span>
+                            <span class="match-score">{score:.2f}</span>
+                        </div>
+                '''
+
+                if context_a:
+                    content += f'<div class="match-context"><strong>📍 所属分类:</strong> {html_module.escape(context_a).replace(chr(10), "<br>")}</div>'
+
+                if score_explain:
+                    content += f'<div class="match-explain"><strong>💡 分数说明:</strong> {html_module.escape(score_explain)}</div>'
+
+                # 相同部分
+                if same_parts:
+                    content += '''
+                        <div class="match-parts same-parts">
+                            <div class="parts-header">✅ 相同部分</div>
+                            <div class="parts-content">
+                    '''
+                    for key, value in same_parts.items():
+                        content += f'''
+                            <div class="part-item">
+                                <span class="part-key">{html_module.escape(key)}:</span>
+                                <span class="part-value">{html_module.escape(value)}</span>
+                            </div>
+                        '''
+                    content += '''
+                            </div>
+                        </div>
+                    '''
+
+                # 增量部分
+                if increment_parts:
+                    content += '''
+                        <div class="match-parts increment-parts">
+                            <div class="parts-header">➕ 增量部分</div>
+                            <div class="parts-content">
+                    '''
+                    for key, value in increment_parts.items():
+                        content += f'''
+                            <div class="part-item">
+                                <span class="part-key">{html_module.escape(key)}:</span>
+                                <span class="part-value">{html_module.escape(value)}</span>
+                            </div>
+                        '''
+                    content += '''
+                            </div>
+                        </div>
+                    '''
+
+                content += '''
+                    </div>
+                '''
+
+            content += '''
+                    </div>
+                </div>
+            '''
+
+        content += '''
+                </div>
+            </div>
+        '''
+
+    # Step2 详细信息
+    if step2 and step2.get("灵感"):
+        input_info = step2.get("输入信息", {})
+        match_result = step2.get("匹配结果", {})
+
+        increment_word = input_info.get("B", "")
+        b_context = input_info.get("B_Context", "")
+        score = match_result.get("score", 0)
+        score_explain = match_result.get("score说明", "")
+        same_parts = match_result.get("相同部分", {})
+        increment_parts = match_result.get("增量部分", {})
+
+        content += '''
+            <div class="modal-section">
+                <h3>➕ Step2: 增量词匹配</h3>
+                <div class="step-content">
+        '''
+
+        if increment_word.strip():
+            content += f'''
+                <div class="step-field">
+                    <span class="step-field-label">增量词:</span>
+                    <span class="step-field-value">{html_module.escape(increment_word)}</span>
+                </div>
+            '''
+
+            if b_context:
+                content += f'''
+                    <div class="increment-context">
+                        <strong>📌 增量词来源:</strong> {html_module.escape(b_context)}
+                    </div>
+                '''
+
+            content += f'''
+                <div class="increment-item">
+                    <div class="increment-header">
+                        <span class="increment-words">分数</span>
+                        <span class="increment-score">{score:.2f}</span>
+                    </div>
+            '''
+
+            if score_explain:
+                content += f'''
+                    <div class="match-explain">
+                        <strong>💡 分数说明:</strong> {html_module.escape(score_explain)}
+                    </div>
+                '''
+
+            # 相同部分
+            if same_parts:
+                content += '''
+                    <div class="match-parts same-parts">
+                        <div class="parts-header">✅ 相同部分</div>
+                        <div class="parts-content">
+                '''
+                for key, value in same_parts.items():
+                    content += f'''
+                        <div class="part-item">
+                            <span class="part-key">{html_module.escape(key)}:</span>
+                            <span class="part-value">{html_module.escape(value)}</span>
+                        </div>
+                    '''
+                content += '''
+                        </div>
+                    </div>
+                '''
+
+            # 增量部分
+            if increment_parts:
+                content += '''
+                    <div class="match-parts increment-parts">
+                        <div class="parts-header">➕ 增量部分</div>
+                        <div class="parts-content">
+                '''
+                for key, value in increment_parts.items():
+                    content += f'''
+                        <div class="part-item">
+                            <span class="part-key">{html_module.escape(key)}:</span>
+                            <span class="part-value">{html_module.escape(value)}</span>
+                        </div>
+                    '''
+                content += '''
+                        </div>
+                    </div>
+                '''
+
+            content += '''
+                </div>
+            '''
+        else:
+            content += '''
+                <div class="empty-state">暂无增量词匹配结果</div>
+            '''
+
+        content += '''
+                </div>
+            </div>
+        '''
+
+    # 日志链接
+    if metadata.get("log_url"):
+        content += f'''
+            <div class="modal-link">
+                <a href="{metadata["log_url"]}" target="_blank" class="modal-link-btn">
+                    🔗 查看详细日志
+                </a>
+            </div>
+        '''
+
+    return content
+
+
+def generate_detail_modal_content_js() -> str:
+    """
+    生成详情弹窗内容的JavaScript函数
+
+    Returns:
+        JavaScript代码字符串
+    """
+    return '''
+    // Tab切换功能
+    function switchTab(event, tabId) {
+        // 移除所有tab的active状态
+        const tabButtons = document.querySelectorAll('.tab-button');
+        tabButtons.forEach(button => {
+            button.classList.remove('active');
+        });
+
+        // 隐藏所有tab内容
+        const tabContents = document.querySelectorAll('.tab-content');
+        tabContents.forEach(content => {
+            content.classList.remove('active');
+        });
+
+        // 激活当前tab
+        event.currentTarget.classList.add('active');
+        document.getElementById(tabId).classList.add('active');
+    }
+
+    function showInspirationDetail(element) {
+        const detailHtml = element.dataset.detailHtml;
+        const modal = document.getElementById('detailModal');
+        const modalBody = document.getElementById('modalBody');
+
+        modalBody.innerHTML = detailHtml;
+        modal.classList.add('active');
+        document.body.style.overflow = 'hidden';
+    }
+
+    function closeModal() {
+        const modal = document.getElementById('detailModal');
+        modal.classList.remove('active');
+        document.body.style.overflow = '';
+    }
+
+    function closeModalOnOverlay(event) {
+        if (event.target.id === 'detailModal') {
+            closeModal();
+        }
+    }
+
+    // ESC键关闭Modal
+    document.addEventListener('keydown', function(event) {
+        if (event.key === 'Escape') {
+            closeModal();
+        }
+    });
+
+    // 搜索和过滤功能
+    function filterInspirations() {
+        const searchInput = document.getElementById('searchInput').value.toLowerCase();
+        const sortSelect = document.getElementById('sortSelect').value;
+        const cards = document.querySelectorAll('.inspiration-card');
+
+        let visibleCards = Array.from(cards);
+
+        // 搜索过滤
+        visibleCards.forEach(card => {
+            const name = card.dataset.inspirationName.toLowerCase();
+            if (name.includes(searchInput)) {
+                card.style.display = '';
+            } else {
+                card.style.display = 'none';
+            }
+        });
+
+        // 获取可见的卡片
+        visibleCards = Array.from(cards).filter(card => card.style.display !== 'none');
+
+        // 排序
+        if (sortSelect === 'step1-desc' || sortSelect === 'step1-asc') {
+            visibleCards.sort((a, b) => {
+                const step1A = parseFloat(a.dataset.step1Score) || 0;
+                const step1B = parseFloat(b.dataset.step1Score) || 0;
+                const step2A = parseFloat(a.dataset.step2Score) || 0;
+                const step2B = parseFloat(b.dataset.step2Score) || 0;
+
+                if (sortSelect === 'step1-desc') {
+                    return step1B !== step1A ? step1B - step1A : step2B - step2A;
+                } else {
+                    return step1A !== step1B ? step1A - step1B : step2A - step2B;
+                }
+            });
+        } else if (sortSelect === 'step2-desc' || sortSelect === 'step2-asc') {
+            visibleCards.sort((a, b) => {
+                const step2A = parseFloat(a.dataset.step2Score) || 0;
+                const step2B = parseFloat(b.dataset.step2Score) || 0;
+                const step1A = parseFloat(a.dataset.step1Score) || 0;
+                const step1B = parseFloat(b.dataset.step1Score) || 0;
+
+                if (sortSelect === 'step2-desc') {
+                    return step2B !== step2A ? step2B - step2A : step1B - step1A;
+                } else {
+                    return step2A !== step2B ? step2A - step2B : step1A - step1B;
+                }
+            });
+        } else if (sortSelect === 'name-asc' || sortSelect === 'name-desc') {
+            visibleCards.sort((a, b) => {
+                const nameA = a.dataset.inspirationName;
+                const nameB = b.dataset.inspirationName;
+                return sortSelect === 'name-asc' ? nameA.localeCompare(nameB) : nameB.localeCompare(nameA);
+            });
+        }
+
+        // 重新排列卡片
+        const container = document.querySelector('.inspirations-grid');
+        visibleCards.forEach(card => {
+            container.appendChild(card);
+        });
+
+        // 更新统计
+        updateStats();
+    }
+
+    function updateStats() {
+        const cards = document.querySelectorAll('.inspiration-card');
+        const visibleCards = Array.from(cards).filter(card => card.style.display !== 'none');
+
+        document.getElementById('totalCount').textContent = visibleCards.length;
+
+        let step1ExcellentCount = 0;
+        let step1GoodCount = 0;
+        let step1NormalCount = 0;
+        let step1NeedOptCount = 0;
+        let step2ExcellentCount = 0;
+        let step2GoodCount = 0;
+        let step2NormalCount = 0;
+        let step2NeedOptCount = 0;
+        let totalStep1Score = 0;
+        let totalStep2Score = 0;
+
+        visibleCards.forEach(card => {
+            const step1Score = parseFloat(card.dataset.step1Score) || 0;
+            const step2Score = parseFloat(card.dataset.step2Score) || 0;
+
+            totalStep1Score += step1Score;
+            totalStep2Score += step2Score;
+
+            // Step1 统计
+            if (step1Score >= 0.7) step1ExcellentCount++;
+            else if (step1Score >= 0.5) step1GoodCount++;
+            else if (step1Score >= 0.3) step1NormalCount++;
+            else step1NeedOptCount++;
+
+            // Step2 统计
+            if (step2Score >= 0.7) step2ExcellentCount++;
+            else if (step2Score >= 0.5) step2GoodCount++;
+            else if (step2Score >= 0.3) step2NormalCount++;
+            else step2NeedOptCount++;
+        });
+
+        document.getElementById('step1ExcellentCount').textContent = step1ExcellentCount;
+        document.getElementById('step1GoodCount').textContent = step1GoodCount;
+        document.getElementById('step1NormalCount').textContent = step1NormalCount;
+        document.getElementById('step1NeedOptCount').textContent = step1NeedOptCount;
+
+        document.getElementById('step2ExcellentCount').textContent = step2ExcellentCount;
+        document.getElementById('step2GoodCount').textContent = step2GoodCount;
+        document.getElementById('step2NormalCount').textContent = step2NormalCount;
+        document.getElementById('step2NeedOptCount').textContent = step2NeedOptCount;
+
+        const avgStep1Score = visibleCards.length > 0 ? (totalStep1Score / visibleCards.length).toFixed(3) : '0.000';
+        const avgStep2Score = visibleCards.length > 0 ? (totalStep2Score / visibleCards.length).toFixed(3) : '0.000';
+        document.getElementById('avgStep1Score').textContent = avgStep1Score;
+        document.getElementById('avgStep2Score').textContent = avgStep2Score;
+    }
+    '''
+
+
+def generate_persona_structure_html(persona_data: Dict[str, Any]) -> str:
+    """
+    生成人设结构的树状HTML
+
+    Args:
+        persona_data: 人设数据
+
+    Returns:
+        人设结构的HTML字符串
+    """
+    if not persona_data:
+        return '<div class="empty-state">暂无人设数据</div>'
+
+    inspiration_list = persona_data.get("灵感点列表", [])
+    if not inspiration_list:
+        return '<div class="empty-state">暂无灵感点列表数据</div>'
+
+    html_parts = ['<div class="tree">']
+
+    for perspective_idx, perspective in enumerate(inspiration_list):
+        perspective_name = perspective.get("视角名称", "未知视角")
+        perspective_desc = perspective.get("视角描述", "")
+        pattern_list = perspective.get("模式列表", [])
+
+        # 一级节点:视角
+        html_parts.append(f'''
+            <ul>
+                <li>
+                    <div class="tree-node level-1">
+                        <span class="node-icon">📁</span>
+                        <span class="node-name">{html_module.escape(perspective_name)}</span>
+                        <span class="node-count">{len(pattern_list)}个分类</span>
+                    </div>
+        ''')
+
+        if perspective_desc:
+            html_parts.append(f'''
+                    <div class="node-desc">{html_module.escape(perspective_desc)}</div>
+            ''')
+
+        # 二级节点:分类
+        if pattern_list:
+            html_parts.append('<ul>')
+            for pattern in pattern_list:
+                category_name = pattern.get("分类名称", "未知分类")
+                core_definition = pattern.get("核心定义", "")
+                subcategories = pattern.get("二级细分", [])
+                total_posts = sum(len(sub.get("帖子ID列表", [])) for sub in subcategories)
+
+                html_parts.append(f'''
+                    <li>
+                        <div class="tree-node level-2">
+                            <span class="node-icon">📂</span>
+                            <span class="node-name">{html_module.escape(category_name)}</span>
+                            <span class="node-count">{total_posts}个帖子</span>
+                        </div>
+                ''')
+
+                if core_definition:
+                    html_parts.append(f'''
+                        <div class="node-desc">{html_module.escape(core_definition)}</div>
+                    ''')
+
+                # 三级节点:细分
+                if subcategories:
+                    html_parts.append('<ul>')
+                    for subcategory in subcategories:
+                        sub_name = subcategory.get("分类名称", "未知细分")
+                        sub_definition = subcategory.get("分类定义", "")
+                        post_ids = subcategory.get("帖子ID列表", [])
+
+                        html_parts.append(f'''
+                            <li>
+                                <div class="tree-node level-3">
+                                    <span class="node-icon">📄</span>
+                                    <span class="node-name">{html_module.escape(sub_name)}</span>
+                                    <span class="node-count">{len(post_ids)}个帖子</span>
+                                </div>
+                        ''')
+
+                        if sub_definition:
+                            html_parts.append(f'''
+                                <div class="node-desc">{html_module.escape(sub_definition)}</div>
+                            ''')
+
+                        if post_ids:
+                            html_parts.append(f'''
+                                <div class="node-posts">
+                                    <span class="posts-label">📋 帖子ID:</span>
+                                    <span class="posts-ids">{", ".join([html_module.escape(str(pid)) for pid in post_ids[:5]])}</span>
+                                    {f'<span class="posts-more">... 等{len(post_ids)}个</span>' if len(post_ids) > 5 else ''}
+                                </div>
+                            ''')
+
+                        html_parts.append('</li>')
+                    html_parts.append('</ul>')
+
+                html_parts.append('</li>')
+            html_parts.append('</ul>')
+
+        html_parts.append('</li>')
+        html_parts.append('</ul>')
+
+    html_parts.append('</div>')
+    return ''.join(html_parts)
+
+
+def generate_html(
+    inspirations_data: List[Dict[str, Any]],
+    posts_map: Dict[str, Dict[str, Any]],
+    persona_data: Dict[str, Any],
+    output_path: str
+) -> str:
+    """
+    生成完整的可视化HTML
+
+    Args:
+        inspirations_data: 灵感点数据列表
+        posts_map: 帖子数据映射
+        persona_data: 人设数据
+        output_path: 输出文件路径
+
+    Returns:
+        输出文件路径
+    """
+    timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+    # 统计信息
+    total_count = len(inspirations_data)
+
+    # Step1 统计
+    step1_excellent_count = sum(1 for d in inspirations_data
+                               if d["summary"].get("关键指标", {}).get("step1_top1_score", 0) >= 0.7)
+    step1_good_count = sum(1 for d in inspirations_data
+                          if 0.5 <= d["summary"].get("关键指标", {}).get("step1_top1_score", 0) < 0.7)
+    step1_normal_count = sum(1 for d in inspirations_data
+                            if 0.3 <= d["summary"].get("关键指标", {}).get("step1_top1_score", 0) < 0.5)
+    step1_need_opt_count = sum(1 for d in inspirations_data
+                              if d["summary"].get("关键指标", {}).get("step1_top1_score", 0) < 0.3)
+
+    # Step2 统计
+    step2_excellent_count = sum(1 for d in inspirations_data
+                               if d["summary"].get("关键指标", {}).get("step2_score", 0) >= 0.7)
+    step2_good_count = sum(1 for d in inspirations_data
+                          if 0.5 <= d["summary"].get("关键指标", {}).get("step2_score", 0) < 0.7)
+    step2_normal_count = sum(1 for d in inspirations_data
+                            if 0.3 <= d["summary"].get("关键指标", {}).get("step2_score", 0) < 0.5)
+    step2_need_opt_count = sum(1 for d in inspirations_data
+                              if d["summary"].get("关键指标", {}).get("step2_score", 0) < 0.3)
+
+    # 平均分数
+    total_step1_score = sum(d["summary"].get("关键指标", {}).get("step1_top1_score", 0)
+                           for d in inspirations_data)
+    total_step2_score = sum(d["summary"].get("关键指标", {}).get("step2_score", 0)
+                           for d in inspirations_data)
+    avg_step1_score = total_step1_score / total_count if total_count > 0 else 0
+    avg_step2_score = total_step2_score / total_count if total_count > 0 else 0
+
+    # 按Step1分数排序(Step2作为次要排序)
+    inspirations_data_sorted = sorted(
+        inspirations_data,
+        key=lambda x: (x["summary"].get("关键指标", {}).get("step1_top1_score", 0),
+                      x["summary"].get("关键指标", {}).get("step2_score", 0)),
+        reverse=True
+    )
+
+    # 生成卡片HTML
+    cards_html = [generate_inspiration_card_html(data) for data in inspirations_data_sorted]
+    cards_html_str = '\n'.join(cards_html)
+
+    # 生成人设结构HTML
+    persona_structure_html = generate_persona_structure_html(persona_data)
+
+    # 生成JavaScript
+    detail_modal_js = generate_detail_modal_content_js()
+
+    # 完整HTML
+    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>灵感点分析可视化</title>
+    <style>
+        * {{
+            margin: 0;
+            padding: 0;
+            box-sizing: border-box;
+        }}
+
+        body {{
+            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            color: #333;
+            line-height: 1.6;
+            min-height: 100vh;
+            padding: 20px;
+        }}
+
+        .container {{
+            max-width: 1600px;
+            margin: 0 auto;
+        }}
+
+        .header {{
+            background: white;
+            padding: 40px;
+            border-radius: 16px;
+            margin-bottom: 30px;
+            box-shadow: 0 10px 40px rgba(0,0,0,0.2);
+        }}
+
+        .header h1 {{
+            font-size: 42px;
+            margin-bottom: 10px;
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            -webkit-background-clip: text;
+            -webkit-text-fill-color: transparent;
+            font-weight: 800;
+        }}
+
+        .header-subtitle {{
+            font-size: 16px;
+            color: #6b7280;
+            margin-bottom: 30px;
+        }}
+
+        .stats-overview {{
+            display: grid;
+            grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+            gap: 20px;
+            margin-top: 25px;
+        }}
+
+        .stat-box {{
+            background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
+            padding: 20px;
+            border-radius: 12px;
+            text-align: center;
+            transition: transform 0.3s ease;
+        }}
+
+        .stat-box:hover {{
+            transform: translateY(-5px);
+        }}
+
+        .stat-label {{
+            font-size: 13px;
+            color: #6b7280;
+            margin-bottom: 8px;
+            font-weight: 600;
+        }}
+
+        .stat-value {{
+            font-size: 32px;
+            font-weight: 700;
+            color: #1a1a1a;
+        }}
+
+        .stat-box.excellent .stat-value {{
+            color: #10b981;
+        }}
+
+        .stat-box.good .stat-value {{
+            color: #f59e0b;
+        }}
+
+        .stat-box.normal .stat-value {{
+            color: #3b82f6;
+        }}
+
+        .stat-box.need-opt .stat-value {{
+            color: #ef4444;
+        }}
+
+        .controls-section {{
+            background: #f9fafb;
+            padding: 25px;
+            border-radius: 12px;
+            margin-bottom: 30px;
+            display: flex;
+            gap: 20px;
+            flex-wrap: wrap;
+            align-items: center;
+        }}
+
+        .search-box {{
+            flex: 1;
+            min-width: 250px;
+        }}
+
+        .search-input {{
+            width: 100%;
+            padding: 12px 20px;
+            border: 2px solid #e5e7eb;
+            border-radius: 10px;
+            font-size: 15px;
+            transition: all 0.3s;
+        }}
+
+        .search-input:focus {{
+            outline: none;
+            border-color: #667eea;
+            box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
+        }}
+
+        .sort-box {{
+            display: flex;
+            align-items: center;
+            gap: 12px;
+        }}
+
+        .sort-label {{
+            font-size: 14px;
+            font-weight: 600;
+            color: #374151;
+        }}
+
+        .sort-select {{
+            padding: 10px 16px;
+            border: 2px solid #e5e7eb;
+            border-radius: 10px;
+            font-size: 14px;
+            background: white;
+            cursor: pointer;
+            transition: all 0.3s;
+        }}
+
+        .sort-select:focus {{
+            outline: none;
+            border-color: #667eea;
+        }}
+
+        .inspirations-section {{
+            padding: 0;
+        }}
+
+        .inspirations-grid {{
+            display: grid;
+            grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
+            gap: 25px;
+        }}
+
+        .inspiration-card {{
+            background: white;
+            border-radius: 14px;
+            padding: 25px;
+            border-left: 6px solid #10b981;
+            cursor: pointer;
+            transition: all 0.3s ease;
+            box-shadow: 0 4px 12px rgba(0,0,0,0.08);
+            position: relative;
+        }}
+
+        .inspiration-card:hover {{
+            transform: translateY(-8px);
+            box-shadow: 0 12px 30px rgba(102, 126, 234, 0.2);
+        }}
+
+        .card-header {{
+            display: flex;
+            justify-content: space-between;
+            align-items: flex-start;
+            margin-bottom: 20px;
+            gap: 12px;
+        }}
+
+        .inspiration-name {{
+            font-size: 19px;
+            font-weight: 700;
+            color: #1a1a1a;
+            line-height: 1.4;
+            flex: 1;
+        }}
+
+        .grade-badge {{
+            background: #10b981;
+            color: white;
+            padding: 6px 14px;
+            border-radius: 20px;
+            font-size: 12px;
+            font-weight: 700;
+            white-space: nowrap;
+        }}
+
+        .score-section {{
+            display: flex;
+            align-items: center;
+            gap: 25px;
+            margin-bottom: 20px;
+            padding: 20px;
+            background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
+            border-radius: 12px;
+        }}
+
+        .score-item {{
+            display: flex;
+            flex-direction: column;
+            align-items: center;
+            gap: 8px;
+            flex: 1;
+        }}
+
+        .main-score {{
+            display: flex;
+            flex-direction: column;
+            align-items: center;
+            gap: 8px;
+        }}
+
+        .score-circle {{
+            width: 90px;
+            height: 90px;
+            border-radius: 50%;
+            border: 6px solid #10b981;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            background: white;
+        }}
+
+        .score-value {{
+            font-size: 26px;
+            font-weight: 800;
+            color: #10b981;
+        }}
+
+        .score-label {{
+            font-size: 12px;
+            color: #6b7280;
+            font-weight: 600;
+        }}
+
+        .sub-scores {{
+            flex: 1;
+            display: flex;
+            flex-direction: column;
+            gap: 12px;
+        }}
+
+        .sub-score-item {{
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+            padding: 10px 15px;
+            background: white;
+            border-radius: 8px;
+        }}
+
+        .sub-score-label {{
+            font-size: 13px;
+            color: #6b7280;
+            font-weight: 600;
+        }}
+
+        .sub-score-value {{
+            font-size: 18px;
+            font-weight: 700;
+            color: #2563eb;
+        }}
+
+        .metrics-section {{
+            display: flex;
+            flex-direction: column;
+            gap: 10px;
+            margin-bottom: 15px;
+        }}
+
+        .metric-item {{
+            display: flex;
+            align-items: center;
+            gap: 8px;
+            font-size: 13px;
+            color: #4b5563;
+        }}
+
+        .metric-icon {{
+            font-size: 16px;
+        }}
+
+        .metric-label {{
+            font-weight: 600;
+        }}
+
+        .metric-value {{
+            color: #1f2937;
+        }}
+
+        .match-preview {{
+            background: #f9fafb;
+            padding: 12px;
+            border-radius: 8px;
+            margin-bottom: 10px;
+            border-left: 3px solid #8b5cf6;
+        }}
+
+        .match-preview-header {{
+            font-size: 12px;
+            font-weight: 600;
+            color: #6b7280;
+            margin-bottom: 6px;
+        }}
+
+        .match-preview-content {{
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+        }}
+
+        .match-preview-name {{
+            font-size: 13px;
+            color: #1f2937;
+            flex: 1;
+        }}
+
+        .match-preview-score {{
+            font-size: 16px;
+            font-weight: 700;
+        }}
+
+        .preview-parts {{
+            margin-top: 8px;
+            padding: 8px 10px;
+            border-radius: 6px;
+            font-size: 12px;
+            line-height: 1.6;
+        }}
+
+        .preview-parts.same {{
+            background: #f0fdf4;
+            color: #15803d;
+            border-left: 3px solid #10b981;
+        }}
+
+        .preview-parts.increment {{
+            background: #fff7ed;
+            color: #92400e;
+            border-left: 3px solid #f59e0b;
+            margin-top: 6px;
+        }}
+
+        .preview-parts strong {{
+            font-weight: 700;
+            margin-right: 6px;
+        }}
+
+        .score-divider {{
+            width: 1px;
+            height: 40px;
+            background: #e5e7eb;
+        }}
+
+        .click-hint {{
+            position: absolute;
+            bottom: 15px;
+            right: 15px;
+            font-size: 12px;
+            color: #8b5cf6;
+            font-weight: 700;
+            opacity: 0;
+            transition: opacity 0.3s ease;
+            background: rgba(139, 92, 246, 0.1);
+            padding: 6px 12px;
+            border-radius: 8px;
+        }}
+
+        .inspiration-card:hover .click-hint {{
+            opacity: 1;
+        }}
+
+        /* Modal样式 */
+        .modal-overlay {{
+            display: none;
+            position: fixed;
+            top: 0;
+            left: 0;
+            right: 0;
+            bottom: 0;
+            background: rgba(0, 0, 0, 0.8);
+            z-index: 1000;
+            align-items: center;
+            justify-content: center;
+            padding: 20px;
+            overflow-y: auto;
+        }}
+
+        .modal-overlay.active {{
+            display: flex;
+        }}
+
+        .modal-content {{
+            background: white;
+            border-radius: 16px;
+            max-width: 1200px;
+            width: 100%;
+            max-height: 90vh;
+            overflow-y: auto;
+            position: relative;
+        }}
+
+        .modal-close {{
+            position: sticky;
+            top: 0;
+            right: 0;
+            background: white;
+            border: none;
+            font-size: 32px;
+            color: #6b7280;
+            cursor: pointer;
+            padding: 15px 20px;
+            z-index: 10;
+            text-align: right;
+            border-bottom: 1px solid #e5e7eb;
+        }}
+
+        .modal-close:hover {{
+            color: #1f2937;
+        }}
+
+        .modal-body {{
+            padding: 30px;
+        }}
+
+        .modal-header {{
+            margin-bottom: 25px;
+            padding-bottom: 20px;
+            border-bottom: 2px solid #e5e7eb;
+        }}
+
+        .modal-title {{
+            font-size: 28px;
+            font-weight: 800;
+            color: #1a1a1a;
+        }}
+
+        .modal-section {{
+            margin-bottom: 30px;
+        }}
+
+        .modal-section h3 {{
+            font-size: 20px;
+            font-weight: 700;
+            color: #374151;
+            margin-bottom: 15px;
+            padding-bottom: 10px;
+            border-bottom: 2px solid #f3f4f6;
+        }}
+
+        .info-grid {{
+            display: grid;
+            grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+            gap: 15px;
+        }}
+
+        .info-item {{
+            background: #f9fafb;
+            padding: 12px 16px;
+            border-radius: 8px;
+            border-left: 3px solid #8b5cf6;
+        }}
+
+        .info-label {{
+            font-weight: 600;
+            color: #6b7280;
+            font-size: 13px;
+            margin-right: 8px;
+        }}
+
+        .info-value {{
+            color: #1f2937;
+            font-size: 14px;
+        }}
+
+        .metrics-grid {{
+            display: grid;
+            grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+            gap: 15px;
+        }}
+
+        .metric-box {{
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            padding: 20px;
+            border-radius: 12px;
+            text-align: center;
+            color: white;
+        }}
+
+        .metric-box.wide {{
+            grid-column: span 2;
+        }}
+
+        .metric-box-label {{
+            font-size: 13px;
+            opacity: 0.9;
+            margin-bottom: 8px;
+            font-weight: 600;
+        }}
+
+        .metric-box-value {{
+            font-size: 28px;
+            font-weight: 700;
+        }}
+
+        .metric-box-value.small {{
+            font-size: 16px;
+        }}
+
+        .step-content {{
+            background: #f9fafb;
+            padding: 20px;
+            border-radius: 12px;
+        }}
+
+        .step-field {{
+            margin-bottom: 20px;
+        }}
+
+        .step-field-label {{
+            font-weight: 700;
+            color: #374151;
+            font-size: 14px;
+            margin-bottom: 8px;
+            display: block;
+        }}
+
+        .step-field-value {{
+            color: #1f2937;
+            font-size: 15px;
+            line-height: 1.7;
+        }}
+
+        .matches-list {{
+            display: flex;
+            flex-direction: column;
+            gap: 15px;
+            margin-top: 10px;
+        }}
+
+        .match-item {{
+            background: white;
+            padding: 18px;
+            border-radius: 10px;
+            border-left: 5px solid #3b82f6;
+        }}
+
+        .match-item.top1 {{
+            border-left-color: #fbbf24;
+            background: linear-gradient(135deg, #fef3c7 0%, #fde68a 50%, white 100%);
+        }}
+
+        .match-item.top2 {{
+            border-left-color: #c0c0c0;
+            background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 50%, white 100%);
+        }}
+
+        .match-item.top3 {{
+            border-left-color: #cd7f32;
+            background: linear-gradient(135deg, #fef3c7 0%, #fed7aa 50%, white 100%);
+        }}
+
+        .match-header {{
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+            margin-bottom: 12px;
+            gap: 10px;
+        }}
+
+        .match-rank {{
+            font-size: 18px;
+            font-weight: 800;
+            color: #6b7280;
+        }}
+
+        .match-element-name {{
+            flex: 1;
+            font-size: 16px;
+            font-weight: 700;
+            color: #1f2937;
+        }}
+
+        .match-score {{
+            font-size: 22px;
+            font-weight: 800;
+            color: #2563eb;
+            background: white;
+            padding: 6px 14px;
+            border-radius: 8px;
+        }}
+
+        .match-detail {{
+            background: rgba(255, 255, 255, 0.7);
+            padding: 10px;
+            border-radius: 6px;
+            margin-bottom: 10px;
+            font-size: 13px;
+            color: #4b5563;
+        }}
+
+        .match-reason {{
+            color: #1f2937;
+            font-size: 14px;
+            line-height: 1.7;
+        }}
+
+        .increment-matches {{
+            display: flex;
+            flex-direction: column;
+            gap: 12px;
+            margin-top: 10px;
+        }}
+
+        .increment-item {{
+            background: white;
+            padding: 15px;
+            border-radius: 8px;
+            border-left: 4px solid #10b981;
+        }}
+
+        .increment-header {{
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+            margin-bottom: 10px;
+        }}
+
+        .increment-words {{
+            font-weight: 700;
+            color: #1f2937;
+            font-size: 15px;
+        }}
+
+        .increment-score {{
+            font-size: 20px;
+            font-weight: 800;
+            color: #10b981;
+        }}
+
+        .increment-reason {{
+            color: #4b5563;
+            font-size: 13px;
+            line-height: 1.6;
+        }}
+
+        .empty-state {{
+            text-align: center;
+            padding: 40px;
+            color: #9ca3af;
+            font-size: 14px;
+        }}
+
+        .modal-link {{
+            margin-top: 25px;
+            padding-top: 20px;
+            border-top: 2px solid #e5e7eb;
+            text-align: center;
+        }}
+
+        .modal-link-btn {{
+            display: inline-flex;
+            align-items: center;
+            gap: 10px;
+            padding: 12px 24px;
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            color: white;
+            text-decoration: none;
+            border-radius: 10px;
+            font-size: 15px;
+            font-weight: 600;
+            transition: all 0.3s;
+        }}
+
+        .modal-link-btn:hover {{
+            transform: translateY(-2px);
+            box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
+        }}
+
+        .timestamp {{
+            text-align: center;
+            color: white;
+            font-size: 13px;
+            margin-top: 30px;
+            opacity: 0.8;
+        }}
+
+        .match-context {{
+            background: #f3f4f6;
+            padding: 8px 12px;
+            border-radius: 6px;
+            margin: 8px 0;
+            font-size: 12px;
+            color: #6b7280;
+            line-height: 1.6;
+        }}
+
+        .match-explain {{
+            background: #fef3c7;
+            padding: 10px 12px;
+            border-radius: 6px;
+            margin: 10px 0;
+            font-size: 13px;
+            color: #92400e;
+            line-height: 1.7;
+            border-left: 3px solid #f59e0b;
+        }}
+
+        .match-parts {{
+            margin: 12px 0;
+            border-radius: 8px;
+            overflow: hidden;
+        }}
+
+        .match-parts.same-parts {{
+            background: #f0fdf4;
+            border: 2px solid #10b981;
+        }}
+
+        .match-parts.increment-parts {{
+            background: #fff7ed;
+            border: 2px solid #f59e0b;
+        }}
+
+        .parts-header {{
+            font-weight: 700;
+            padding: 10px 12px;
+            font-size: 13px;
+        }}
+
+        .same-parts .parts-header {{
+            background: #dcfce7;
+            color: #15803d;
+        }}
+
+        .increment-parts .parts-header {{
+            background: #fed7aa;
+            color: #92400e;
+        }}
+
+        .parts-content {{
+            padding: 8px 12px;
+        }}
+
+        .part-item {{
+            padding: 6px 0;
+            border-bottom: 1px solid rgba(0,0,0,0.05);
+            font-size: 13px;
+            line-height: 1.6;
+        }}
+
+        .part-item:last-child {{
+            border-bottom: none;
+        }}
+
+        .part-key {{
+            font-weight: 600;
+            color: #374151;
+            margin-right: 6px;
+        }}
+
+        .part-value {{
+            color: #1f2937;
+        }}
+
+        .increment-context {{
+            background: #fef3c7;
+            padding: 10px 12px;
+            border-radius: 6px;
+            margin: 10px 0;
+            font-size: 12px;
+            color: #92400e;
+            line-height: 1.6;
+            border-left: 3px solid #f59e0b;
+        }}
+
+        /* Tab样式 */
+        .tabs-nav {{
+            background: white;
+            padding: 0 30px;
+            border-radius: 16px 16px 0 0;
+            margin-bottom: 0;
+            box-shadow: 0 4px 20px rgba(0,0,0,0.1);
+            display: flex;
+            gap: 10px;
+        }}
+
+        .tab-button {{
+            padding: 15px 30px;
+            border: none;
+            background: transparent;
+            color: #6b7280;
+            font-size: 15px;
+            font-weight: 600;
+            cursor: pointer;
+            border-bottom: 3px solid transparent;
+            transition: all 0.3s;
+        }}
+
+        .tab-button:hover {{
+            color: #667eea;
+            background: rgba(102, 126, 234, 0.05);
+        }}
+
+        .tab-button.active {{
+            color: #667eea;
+            border-bottom-color: #667eea;
+            background: rgba(102, 126, 234, 0.05);
+        }}
+
+        .tab-content {{
+            display: none;
+            background: white;
+            padding: 30px;
+            border-radius: 0 0 16px 16px;
+            box-shadow: 0 10px 40px rgba(0,0,0,0.15);
+        }}
+
+        .tab-content.active {{
+            display: block;
+        }}
+
+        /* 人设结构样式 */
+        .persona-structure-section h2 {{
+            font-size: 28px;
+            font-weight: 700;
+            margin-bottom: 25px;
+            color: #1a1a1a;
+        }}
+
+        /* 树状图样式 */
+        .tree {{
+            font-size: 14px;
+        }}
+
+        .tree ul {{
+            padding-left: 30px;
+            list-style: none;
+            position: relative;
+        }}
+
+        .tree ul ul {{
+            padding-left: 40px;
+        }}
+
+        .tree li {{
+            position: relative;
+            padding: 8px 0;
+        }}
+
+        .tree li::before {{
+            content: "";
+            position: absolute;
+            top: 0;
+            left: -20px;
+            border-left: 2px solid #d1d5db;
+            border-bottom: 2px solid #d1d5db;
+            width: 20px;
+            height: 20px;
+        }}
+
+        .tree li::after {{
+            content: "";
+            position: absolute;
+            top: 20px;
+            left: -20px;
+            border-left: 2px solid #d1d5db;
+            height: 100%;
+        }}
+
+        .tree li:last-child::after {{
+            display: none;
+        }}
+
+        .tree > ul > li::before,
+        .tree > ul > li::after {{
+            display: none;
+        }}
+
+        .tree-node {{
+            display: inline-flex;
+            align-items: center;
+            gap: 10px;
+            padding: 12px 16px;
+            border-radius: 8px;
+            transition: all 0.3s;
+            margin-bottom: 8px;
+        }}
+
+        .tree-node:hover {{
+            transform: translateX(4px);
+            box-shadow: 0 4px 12px rgba(0,0,0,0.1);
+        }}
+
+        .tree-node.level-1 {{
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            color: white;
+            font-size: 18px;
+            font-weight: 700;
+            padding: 16px 20px;
+        }}
+
+        .tree-node.level-2 {{
+            background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
+            color: #1e40af;
+            font-size: 16px;
+            font-weight: 600;
+            border: 2px solid #3b82f6;
+        }}
+
+        .tree-node.level-3 {{
+            background: white;
+            color: #374151;
+            font-size: 14px;
+            font-weight: 500;
+            border: 1px solid #e5e7eb;
+        }}
+
+        .node-icon {{
+            font-size: 20px;
+        }}
+
+        .tree-node.level-1 .node-icon {{
+            font-size: 24px;
+        }}
+
+        .node-name {{
+            flex: 1;
+        }}
+
+        .node-count {{
+            background: rgba(255, 255, 255, 0.3);
+            padding: 4px 12px;
+            border-radius: 12px;
+            font-size: 12px;
+            font-weight: 600;
+        }}
+
+        .tree-node.level-1 .node-count {{
+            background: rgba(255, 255, 255, 0.4);
+        }}
+
+        .tree-node.level-2 .node-count {{
+            background: #bfdbfe;
+            color: #1e3a8a;
+        }}
+
+        .tree-node.level-3 .node-count {{
+            background: #dcfce7;
+            color: #166534;
+        }}
+
+        .node-desc {{
+            margin: 8px 0 8px 50px;
+            padding: 12px 16px;
+            background: #fffbeb;
+            border-left: 3px solid #f59e0b;
+            border-radius: 6px;
+            color: #92400e;
+            font-size: 13px;
+            line-height: 1.7;
+        }}
+
+        .node-posts {{
+            margin: 8px 0 8px 50px;
+            padding: 12px 16px;
+            background: #f0fdf4;
+            border-left: 3px solid #10b981;
+            border-radius: 6px;
+            font-size: 12px;
+            line-height: 1.8;
+        }}
+
+        .posts-label {{
+            font-weight: 600;
+            color: #15803d;
+            margin-right: 8px;
+        }}
+
+        .posts-ids {{
+            color: #166534;
+            word-break: break-all;
+        }}
+
+        .posts-more {{
+            color: #059669;
+            font-weight: 600;
+            margin-left: 8px;
+        }}
+
+        @media (max-width: 768px) {{
+            .inspirations-grid {{
+                grid-template-columns: 1fr;
+            }}
+
+            .header h1 {{
+                font-size: 32px;
+            }}
+
+            .stats-overview {{
+                grid-template-columns: repeat(2, 1fr);
+            }}
+        }}
+    </style>
+</head>
+<body>
+    <div class="container">
+        <div class="header">
+            <h1>💡 灵感点分析可视化</h1>
+            <div class="header-subtitle">基于HOW人设的灵感点匹配分析结果</div>
+
+            <div class="stats-overview">
+                <div class="stat-box">
+                    <div class="stat-label">分析总数</div>
+                    <div class="stat-value" id="totalCount">{total_count}</div>
+                </div>
+                <div class="stat-box excellent">
+                    <div class="stat-label">Step1优秀 (≥0.7)</div>
+                    <div class="stat-value" id="step1ExcellentCount">{step1_excellent_count}</div>
+                </div>
+                <div class="stat-box good">
+                    <div class="stat-label">Step1良好 (0.5-0.7)</div>
+                    <div class="stat-value" id="step1GoodCount">{step1_good_count}</div>
+                </div>
+                <div class="stat-box normal">
+                    <div class="stat-label">Step1一般 (0.3-0.5)</div>
+                    <div class="stat-value" id="step1NormalCount">{step1_normal_count}</div>
+                </div>
+                <div class="stat-box need-opt">
+                    <div class="stat-label">Step1待优化 (<0.3)</div>
+                    <div class="stat-value" id="step1NeedOptCount">{step1_need_opt_count}</div>
+                </div>
+                <div class="stat-box excellent">
+                    <div class="stat-label">Step2优秀 (≥0.7)</div>
+                    <div class="stat-value" id="step2ExcellentCount">{step2_excellent_count}</div>
+                </div>
+                <div class="stat-box good">
+                    <div class="stat-label">Step2良好 (0.5-0.7)</div>
+                    <div class="stat-value" id="step2GoodCount">{step2_good_count}</div>
+                </div>
+                <div class="stat-box normal">
+                    <div class="stat-label">Step2一般 (0.3-0.5)</div>
+                    <div class="stat-value" id="step2NormalCount">{step2_normal_count}</div>
+                </div>
+                <div class="stat-box need-opt">
+                    <div class="stat-label">Step2待优化 (<0.3)</div>
+                    <div class="stat-value" id="step2NeedOptCount">{step2_need_opt_count}</div>
+                </div>
+                <div class="stat-box">
+                    <div class="stat-label">Step1平均分</div>
+                    <div class="stat-value" id="avgStep1Score">{avg_step1_score:.3f}</div>
+                </div>
+                <div class="stat-box">
+                    <div class="stat-label">Step2平均分</div>
+                    <div class="stat-value" id="avgStep2Score">{avg_step2_score:.3f}</div>
+                </div>
+            </div>
+        </div>
+
+        <div class="tabs-nav">
+            <button class="tab-button active" onclick="switchTab(event, 'tab-inspirations')">
+                灵感点分析
+            </button>
+            <button class="tab-button" onclick="switchTab(event, 'tab-persona')">
+                人设结构
+            </button>
+        </div>
+
+        <div id="tab-inspirations" class="tab-content active">
+            <div class="controls-section">
+                <div class="search-box">
+                    <input type="text"
+                           id="searchInput"
+                           class="search-input"
+                           placeholder="🔍 搜索灵感点名称..."
+                           oninput="filterInspirations()">
+                </div>
+
+                <div class="sort-box">
+                    <span class="sort-label">排序方式:</span>
+                    <select id="sortSelect" class="sort-select" onchange="filterInspirations()">
+                        <option value="step1-desc">Step1分数从高到低</option>
+                        <option value="step1-asc">Step1分数从低到高</option>
+                        <option value="step2-desc">Step2分数从高到低</option>
+                        <option value="step2-asc">Step2分数从低到高</option>
+                        <option value="name-asc">名称A-Z</option>
+                        <option value="name-desc">名称Z-A</option>
+                    </select>
+                </div>
+            </div>
+
+            <div class="inspirations-section">
+                <div class="inspirations-grid">
+                    {cards_html_str}
+                </div>
+            </div>
+        </div>
+
+        <div id="tab-persona" class="tab-content">
+            <div class="persona-structure-section">
+                <h2>📚 人设结构</h2>
+                {persona_structure_html}
+            </div>
+        </div>
+
+        <div class="timestamp">生成时间: {timestamp}</div>
+
+        <!-- Modal -->
+        <div id="detailModal" class="modal-overlay" onclick="closeModalOnOverlay(event)">
+            <div class="modal-content">
+                <button class="modal-close" onclick="closeModal()">&times;</button>
+                <div class="modal-body" id="modalBody">
+                    <!-- Content will be inserted here -->
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <script>
+    {detail_modal_js}
+    </script>
+</body>
+</html>'''
+
+    # 写入文件
+    output_file = Path(output_path)
+    output_file.parent.mkdir(parents=True, exist_ok=True)
+
+    with open(output_file, 'w', encoding='utf-8') as f:
+        f.write(html_content)
+
+    return str(output_file.absolute())
+
+
+def load_persona_data(persona_path: str) -> Dict[str, Any]:
+    """
+    加载人设数据
+
+    Args:
+        persona_path: 人设JSON文件路径
+
+    Returns:
+        人设数据字典
+    """
+    try:
+        with open(persona_path, 'r', encoding='utf-8') as f:
+            return json.load(f)
+    except Exception as e:
+        print(f"警告: 读取人设文件失败: {e}")
+        return {}
+
+
+def main():
+    """主函数"""
+    import sys
+
+    # 配置路径
+    inspiration_dir = "/Users/semsevens/Desktop/workspace/aaa/dev_3/data/阿里多多酱/out/人设_1110/how/灵感点"
+    posts_dir = "/Users/semsevens/Desktop/workspace/aaa/dev_3/data/阿里多多酱/作者历史帖子"
+    persona_path = "/Users/semsevens/Desktop/workspace/aaa/dev_3/data/阿里多多酱/out/人设_1110/人设.json"
+    output_path = "/Users/semsevens/Desktop/workspace/aaa/dev_3/data/阿里多多酱/out/人设_1110/how/灵感点可视化.html"
+
+    print("=" * 60)
+    print("灵感点分析可视化脚本")
+    print("=" * 60)
+
+    # 加载数据
+    print("\n📂 正在加载灵感点数据...")
+    inspirations_data = load_inspiration_points_data(inspiration_dir)
+    print(f"✅ 成功加载 {len(inspirations_data)} 个灵感点")
+
+    print("\n📂 正在加载帖子数据...")
+    posts_map = load_posts_data(posts_dir)
+    print(f"✅ 成功加载 {len(posts_map)} 个帖子")
+
+    print("\n📂 正在加载人设数据...")
+    persona_data = load_persona_data(persona_path)
+    print(f"✅ 成功加载人设数据")
+
+    # 生成HTML
+    print("\n🎨 正在生成可视化HTML...")
+    result_path = generate_html(inspirations_data, posts_map, persona_data, output_path)
+
+    print(f"\n✅ 可视化文件已生成!")
+    print(f"📄 文件路径: {result_path}")
+    print(f"\n💡 在浏览器中打开该文件即可查看可视化结果")
+    print("=" * 60)
+
+
+if __name__ == "__main__":
+    main()

+ 1358 - 0
visualize_inspiration_points_backup.py

@@ -0,0 +1,1358 @@
+"""
+灵感点分析结果可视化脚本
+
+读取 how/灵感点 目录下的分析结果,结合作者历史帖子详情,生成可视化HTML页面
+"""
+
+import json
+from pathlib import Path
+from typing import Dict, Any, List, Optional
+from datetime import datetime
+import html as html_module
+
+
+def load_inspiration_points_data(inspiration_dir: str) -> List[Dict[str, Any]]:
+    """
+    加载所有灵感点的分析结果
+
+    Args:
+        inspiration_dir: 灵感点目录路径
+
+    Returns:
+        灵感点分析结果列表
+    """
+    inspiration_path = Path(inspiration_dir)
+    results = []
+
+    # 遍历所有子目录
+    for subdir in inspiration_path.iterdir():
+        if subdir.is_dir():
+            # 查找 all_summary 文件
+            summary_files = list(subdir.glob("all_summary_*.json"))
+            if summary_files:
+                summary_file = summary_files[0]
+                try:
+                    with open(summary_file, 'r', encoding='utf-8') as f:
+                        data = json.load(f)
+
+                    # 加载完整的 step1 和 step2 数据
+                    step1_data = None
+                    step2_data = None
+
+                    if "文件路径" in data:
+                        step1_path = data["文件路径"].get("step1")
+                        step2_path = data["文件路径"].get("step2")
+
+                        if step1_path:
+                            step1_full_path = Path(step1_path)
+                            if not step1_full_path.is_absolute():
+                                step1_full_path = inspiration_path.parent.parent.parent.parent / step1_path
+                            if step1_full_path.exists():
+                                with open(step1_full_path, 'r', encoding='utf-8') as f:
+                                    step1_data = json.load(f)
+
+                        if step2_path:
+                            step2_full_path = Path(step2_path)
+                            if not step2_full_path.is_absolute():
+                                step2_full_path = inspiration_path.parent.parent.parent.parent / step2_path
+                            if step2_full_path.exists():
+                                with open(step2_full_path, 'r', encoding='utf-8') as f:
+                                    step2_data = json.load(f)
+
+                    results.append({
+                        "summary": data,
+                        "step1": step1_data,
+                        "step2": step2_data,
+                        "inspiration_name": subdir.name
+                    })
+                except Exception as e:
+                    print(f"警告: 读取 {summary_file} 失败: {e}")
+
+    return results
+
+
+def load_posts_data(posts_dir: str) -> Dict[str, Dict[str, Any]]:
+    """
+    加载所有帖子详情数据
+
+    Args:
+        posts_dir: 帖子目录路径
+
+    Returns:
+        帖子ID到帖子详情的映射
+    """
+    posts_path = Path(posts_dir)
+    posts_map = {}
+
+    for post_file in posts_path.glob("*.json"):
+        try:
+            with open(post_file, 'r', encoding='utf-8') as f:
+                post_data = json.load(f)
+                post_id = post_data.get("channel_content_id")
+                if post_id:
+                    posts_map[post_id] = post_data
+        except Exception as e:
+            print(f"警告: 读取 {post_file} 失败: {e}")
+
+    return posts_map
+
+
+def generate_inspiration_card_html(inspiration_data: Dict[str, Any]) -> str:
+    """
+    生成单个灵感点的卡片HTML
+
+    Args:
+        inspiration_data: 灵感点数据
+
+    Returns:
+        HTML字符串
+    """
+    summary = inspiration_data.get("summary", {})
+    step1 = inspiration_data.get("step1", {})
+    step2 = inspiration_data.get("step2", {})
+    inspiration_name = inspiration_data.get("inspiration_name", "未知灵感")
+
+    # 提取关键指标
+    metrics = summary.get("关键指标", {})
+    step1_score = metrics.get("step1_top1_score", 0)
+    step2_score = metrics.get("step2_score", 0)
+    step1_match_element = metrics.get("step1_top1_匹配要素", "")
+    step2_increment_count = metrics.get("step2_增量词数量", 0)
+
+    # 确定卡片颜色(基于Step1分数)
+    if step1_score >= 0.7:
+        border_color = "#10b981"
+        step1_color = "#10b981"
+    elif step1_score >= 0.5:
+        border_color = "#f59e0b"
+        step1_color = "#f59e0b"
+    elif step1_score >= 0.3:
+        border_color = "#3b82f6"
+        step1_color = "#3b82f6"
+    else:
+        border_color = "#ef4444"
+        step1_color = "#ef4444"
+
+    # Step2颜色
+    if step2_score >= 0.7:
+        step2_color = "#10b981"
+    elif step2_score >= 0.5:
+        step2_color = "#f59e0b"
+    elif step2_score >= 0.3:
+        step2_color = "#3b82f6"
+    else:
+        step2_color = "#ef4444"
+
+    # 转义HTML
+    inspiration_name_escaped = html_module.escape(inspiration_name)
+    step1_match_element_escaped = html_module.escape(step1_match_element)
+
+    # 获取Step1匹配结果(简要展示)
+    step1_matches = step1.get("匹配结果", []) if step1 else []
+    step1_match_preview = ""
+    if step1_matches:
+        top_match = step1_matches[0]
+        element_name = top_match.get("要素", {}).get("名称", "")
+        match_score = top_match.get("分数", 0)
+        step1_match_preview = f'''
+        <div class="match-preview">
+            <div class="match-preview-header">🎯 Step1 Top1匹配</div>
+            <div class="match-preview-content">
+                <span class="match-preview-name">{html_module.escape(element_name)}</span>
+                <span class="match-preview-score" style="color: {step1_color};">{match_score:.2f}</span>
+            </div>
+        </div>
+        '''
+
+    # 获取Step2匹配结果(简要展示)
+    step2_matches = step2.get("匹配结果", []) if step2 else []
+    step2_match_preview = ""
+    if step2_matches:
+        top_match = step2_matches[0]
+        words = top_match.get("增量词", [])
+        match_score = top_match.get("分数", 0)
+        step2_match_preview = f'''
+        <div class="match-preview">
+            <div class="match-preview-header">➕ Step2 Top1增量词</div>
+            <div class="match-preview-content">
+                <span class="match-preview-name">{html_module.escape(", ".join(words))}</span>
+                <span class="match-preview-score" style="color: {step2_color};">{match_score:.2f}</span>
+            </div>
+        </div>
+        '''
+
+    # 准备详细数据用于弹窗
+    detail_data_json = json.dumps(inspiration_data, ensure_ascii=False)
+    detail_data_json_escaped = html_module.escape(detail_data_json)
+
+    html = f'''
+    <div class="inspiration-card" style="border-left-color: {border_color};"
+         data-inspiration-name="{inspiration_name_escaped}"
+         data-detail="{detail_data_json_escaped}"
+         data-step1-score="{step1_score}"
+         data-step2-score="{step2_score}"
+         onclick="showInspirationDetail(this)">
+        <div class="card-header">
+            <h3 class="inspiration-name">{inspiration_name_escaped}</h3>
+        </div>
+
+        <div class="score-section">
+            <div class="score-item">
+                <div class="score-label">Step1分数</div>
+                <div class="score-value" style="color: {step1_color};">{step1_score:.3f}</div>
+            </div>
+            <div class="score-divider"></div>
+            <div class="score-item">
+                <div class="score-label">Step2分数</div>
+                <div class="score-value" style="color: {step2_color};">{step2_score:.3f}</div>
+            </div>
+        </div>
+
+        {step1_match_preview}
+        {step2_match_preview}
+
+        <div class="metrics-section">
+            <div class="metric-item">
+                <span class="metric-icon">📊</span>
+                <span class="metric-label">增量词数:</span>
+                <span class="metric-value">{step2_increment_count}</span>
+            </div>
+        </div>
+
+        <div class="click-hint">点击查看详情 →</div>
+    </div>
+    '''
+
+    return html
+
+
+def generate_detail_modal_content_js() -> str:
+    """
+    生成详情弹窗内容的JavaScript函数
+
+    Returns:
+        JavaScript代码字符串
+    """
+    return '''
+    function showInspirationDetail(element) {
+        const inspirationName = element.dataset.inspirationName;
+        const detailStr = element.dataset.detail;
+
+        let detail;
+        try {
+            detail = JSON.parse(detailStr);
+        } catch(e) {
+            console.error('解析数据失败:', e);
+            return;
+        }
+
+        const modal = document.getElementById('detailModal');
+        const modalBody = document.getElementById('modalBody');
+
+        const summary = detail.summary || {};
+        const step1 = detail.step1 || {};
+        const step2 = detail.step2 || {};
+        const metrics = summary.关键指标 || {};
+
+        // 构建Modal内容
+        let content = `
+            <div class="modal-header">
+                <h2 class="modal-title">${inspirationName}</h2>
+            </div>
+        `;
+
+        // 元数据信息
+        const metadata = summary.元数据 || {};
+        if (metadata.current_time || metadata.流程) {
+            content += `
+                <div class="modal-section">
+                    <h3>📋 分析信息</h3>
+                    <div class="info-grid">
+                        ${metadata.current_time ? `<div class="info-item"><span class="info-label">分析时间:</span> <span class="info-value">${metadata.current_time}</span></div>` : ''}
+                        ${metadata.流程 ? `<div class="info-item"><span class="info-label">分析流程:</span> <span class="info-value">${metadata.流程}</span></div>` : ''}
+                        ${metadata.step1_model ? `<div class="info-item"><span class="info-label">Step1模型:</span> <span class="info-value">${metadata.step1_model}</span></div>` : ''}
+                        ${metadata.step2_model ? `<div class="info-item"><span class="info-label">Step2模型:</span> <span class="info-value">${metadata.step2_model}</span></div>` : ''}
+                    </div>
+                </div>
+            `;
+        }
+
+        // 关键指标
+        content += `
+            <div class="modal-section">
+                <h3>📊 关键指标</h3>
+                <div class="metrics-grid">
+                    <div class="metric-box">
+                        <div class="metric-box-label">Step1得分</div>
+                        <div class="metric-box-value">${metrics.step1_top1_score || 0}</div>
+                    </div>
+                    <div class="metric-box">
+                        <div class="metric-box-label">Step2得分</div>
+                        <div class="metric-box-value">${metrics.step2_score || 0}</div>
+                    </div>
+                    <div class="metric-box">
+                        <div class="metric-box-label">增量词数量</div>
+                        <div class="metric-box-value">${metrics.step2_增量词数量 || 0}</div>
+                    </div>
+                    <div class="metric-box wide">
+                        <div class="metric-box-label">匹配要素</div>
+                        <div class="metric-box-value small">${metrics.step1_top1_匹配要素 || '无'}</div>
+                    </div>
+                </div>
+            </div>
+        `;
+
+        // Step1 详细信息
+        if (step1 && step1.灵感) {
+            const inspiration = step1.灵感 || '';
+            const persona = step1.人设 || {};
+            const matches = step1.匹配结果 || [];
+
+            content += `
+                <div class="modal-section">
+                    <h3>🎯 Step1: 灵感人设匹配</h3>
+                    <div class="step-content">
+                        <div class="step-field">
+                            <span class="step-field-label">灵感内容:</span>
+                            <span class="step-field-value">${inspiration}</span>
+                        </div>
+            `;
+
+            // 显示匹配结果(Top3)
+            if (matches.length > 0) {
+                content += `
+                    <div class="step-field">
+                        <span class="step-field-label">匹配结果 (Top ${Math.min(3, matches.length)}):</span>
+                        <div class="matches-list">
+                `;
+
+                matches.slice(0, 3).forEach((match, index) => {
+                    const element = match.要素 || {};
+                    const score = match.分数 || 0;
+                    const reason = match.原因 || '';
+                    const colorClass = index === 0 ? 'top1' : (index === 1 ? 'top2' : 'top3');
+
+                    content += `
+                        <div class="match-item ${colorClass}">
+                            <div class="match-header">
+                                <span class="match-rank">#${index + 1}</span>
+                                <span class="match-element-name">${element.名称 || '未知要素'}</span>
+                                <span class="match-score">${score.toFixed(2)}</span>
+                            </div>
+                            ${element.定义 ? `<div class="match-detail"><strong>定义:</strong> ${element.定义}</div>` : ''}
+                            <div class="match-reason">${reason}</div>
+                        </div>
+                    `;
+                });
+
+                content += `
+                        </div>
+                    </div>
+                `;
+            }
+
+            content += `
+                    </div>
+                </div>
+            `;
+        }
+
+        // Step2 详细信息
+        if (step2 && step2.灵感) {
+            const step2Matches = step2.匹配结果 || [];
+
+            content += `
+                <div class="modal-section">
+                    <h3>➕ Step2: 增量词匹配</h3>
+                    <div class="step-content">
+            `;
+
+            if (step2Matches.length > 0) {
+                content += `
+                    <div class="step-field">
+                        <span class="step-field-label">增量词匹配结果:</span>
+                        <div class="increment-matches">
+                `;
+
+                step2Matches.forEach((match, index) => {
+                    const words = match.增量词 || [];
+                    const score = match.分数 || 0;
+                    const reason = match.原因 || '';
+
+                    content += `
+                        <div class="increment-item">
+                            <div class="increment-header">
+                                <span class="increment-words">${words.join(', ')}</span>
+                                <span class="increment-score">${score.toFixed(2)}</span>
+                            </div>
+                            <div class="increment-reason">${reason}</div>
+                        </div>
+                    `;
+                });
+
+                content += `
+                        </div>
+                    </div>
+                `;
+            } else {
+                content += `
+                    <div class="empty-state">暂无增量词匹配结果</div>
+                `;
+            }
+
+            content += `
+                    </div>
+                </div>
+            `;
+        }
+
+        // 日志链接
+        if (metadata.log_url) {
+            content += `
+                <div class="modal-link">
+                    <a href="${metadata.log_url}" target="_blank" class="modal-link-btn">
+                        🔗 查看详细日志
+                    </a>
+                </div>
+            `;
+        }
+
+        modalBody.innerHTML = content;
+        modal.classList.add('active');
+        document.body.style.overflow = 'hidden';
+    }
+
+    function closeModal() {
+        const modal = document.getElementById('detailModal');
+        modal.classList.remove('active');
+        document.body.style.overflow = '';
+    }
+
+    function closeModalOnOverlay(event) {
+        if (event.target.id === 'detailModal') {
+            closeModal();
+        }
+    }
+
+    // ESC键关闭Modal
+    document.addEventListener('keydown', function(event) {
+        if (event.key === 'Escape') {
+            closeModal();
+        }
+    });
+
+    // 搜索和过滤功能
+    function filterInspirations() {
+        const searchInput = document.getElementById('searchInput').value.toLowerCase();
+        const sortSelect = document.getElementById('sortSelect').value;
+        const cards = document.querySelectorAll('.inspiration-card');
+
+        let visibleCards = Array.from(cards);
+
+        // 搜索过滤
+        visibleCards.forEach(card => {
+            const name = card.dataset.inspirationName.toLowerCase();
+            if (name.includes(searchInput)) {
+                card.style.display = '';
+            } else {
+                card.style.display = 'none';
+            }
+        });
+
+        // 获取可见的卡片
+        visibleCards = Array.from(cards).filter(card => card.style.display !== 'none');
+
+        // 排序
+        if (sortSelect === 'score-desc' || sortSelect === 'score-asc') {
+            visibleCards.sort((a, b) => {
+                const detailA = JSON.parse(a.dataset.detail);
+                const detailB = JSON.parse(b.dataset.detail);
+                const scoreA = ((detailA.summary.关键指标.step1_top1_score || 0) + (detailA.summary.关键指标.step2_score || 0)) / 2;
+                const scoreB = ((detailB.summary.关键指标.step1_top1_score || 0) + (detailB.summary.关键指标.step2_score || 0)) / 2;
+                return sortSelect === 'score-desc' ? scoreB - scoreA : scoreA - scoreB;
+            });
+        } else if (sortSelect === 'name-asc' || sortSelect === 'name-desc') {
+            visibleCards.sort((a, b) => {
+                const nameA = a.dataset.inspirationName;
+                const nameB = b.dataset.inspirationName;
+                return sortSelect === 'name-asc' ? nameA.localeCompare(nameB) : nameB.localeCompare(nameA);
+            });
+        }
+
+        // 重新排列卡片
+        const container = document.querySelector('.inspirations-grid');
+        visibleCards.forEach(card => {
+            container.appendChild(card);
+        });
+
+        // 更新统计
+        updateStats();
+    }
+
+    function updateStats() {
+        const cards = document.querySelectorAll('.inspiration-card');
+        const visibleCards = Array.from(cards).filter(card => card.style.display !== 'none');
+
+        document.getElementById('totalCount').textContent = visibleCards.length;
+
+        let excellentCount = 0;
+        let goodCount = 0;
+        let normalCount = 0;
+        let needOptCount = 0;
+        let totalScore = 0;
+
+        visibleCards.forEach(card => {
+            const detail = JSON.parse(card.dataset.detail);
+            const metrics = detail.summary.关键指标;
+            const score = ((metrics.step1_top1_score || 0) + (metrics.step2_score || 0)) / 2 * 100;
+            totalScore += score;
+
+            if (score >= 70) excellentCount++;
+            else if (score >= 50) goodCount++;
+            else if (score >= 30) normalCount++;
+            else needOptCount++;
+        });
+
+        document.getElementById('excellentCount').textContent = excellentCount;
+        document.getElementById('goodCount').textContent = goodCount;
+        document.getElementById('normalCount').textContent = normalCount;
+        document.getElementById('needOptCount').textContent = needOptCount;
+
+        const avgScore = visibleCards.length > 0 ? (totalScore / visibleCards.length).toFixed(1) : 0;
+        document.getElementById('avgScore').textContent = avgScore;
+    }
+    '''
+
+
+def generate_html(
+    inspirations_data: List[Dict[str, Any]],
+    posts_map: Dict[str, Dict[str, Any]],
+    output_path: str
+) -> str:
+    """
+    生成完整的可视化HTML
+
+    Args:
+        inspirations_data: 灵感点数据列表
+        posts_map: 帖子数据映射
+        output_path: 输出文件路径
+
+    Returns:
+        输出文件路径
+    """
+    timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+    # 统计信息
+    total_count = len(inspirations_data)
+    excellent_count = sum(1 for d in inspirations_data
+                         if ((d["summary"].get("关键指标", {}).get("step1_top1_score", 0) +
+                              d["summary"].get("关键指标", {}).get("step2_score", 0)) / 2 * 100) >= 70)
+    good_count = sum(1 for d in inspirations_data
+                    if 50 <= ((d["summary"].get("关键指标", {}).get("step1_top1_score", 0) +
+                               d["summary"].get("关键指标", {}).get("step2_score", 0)) / 2 * 100) < 70)
+    normal_count = sum(1 for d in inspirations_data
+                      if 30 <= ((d["summary"].get("关键指标", {}).get("step1_top1_score", 0) +
+                                 d["summary"].get("关键指标", {}).get("step2_score", 0)) / 2 * 100) < 50)
+    need_opt_count = sum(1 for d in inspirations_data
+                        if ((d["summary"].get("关键指标", {}).get("step1_top1_score", 0) +
+                             d["summary"].get("关键指标", {}).get("step2_score", 0)) / 2 * 100) < 30)
+
+    total_score = sum((d["summary"].get("关键指标", {}).get("step1_top1_score", 0) +
+                       d["summary"].get("关键指标", {}).get("step2_score", 0)) / 2 * 100
+                     for d in inspirations_data)
+    avg_score = total_score / total_count if total_count > 0 else 0
+
+    # 按综合分数排序
+    inspirations_data_sorted = sorted(
+        inspirations_data,
+        key=lambda x: (x["summary"].get("关键指标", {}).get("step1_top1_score", 0) +
+                      x["summary"].get("关键指标", {}).get("step2_score", 0)) / 2,
+        reverse=True
+    )
+
+    # 生成卡片HTML
+    cards_html = [generate_inspiration_card_html(data) for data in inspirations_data_sorted]
+    cards_html_str = '\n'.join(cards_html)
+
+    # 生成JavaScript
+    detail_modal_js = generate_detail_modal_content_js()
+
+    # 完整HTML
+    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>灵感点分析可视化</title>
+    <style>
+        * {{
+            margin: 0;
+            padding: 0;
+            box-sizing: border-box;
+        }}
+
+        body {{
+            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            color: #333;
+            line-height: 1.6;
+            min-height: 100vh;
+            padding: 20px;
+        }}
+
+        .container {{
+            max-width: 1600px;
+            margin: 0 auto;
+        }}
+
+        .header {{
+            background: white;
+            padding: 40px;
+            border-radius: 16px;
+            margin-bottom: 30px;
+            box-shadow: 0 10px 40px rgba(0,0,0,0.2);
+        }}
+
+        .header h1 {{
+            font-size: 42px;
+            margin-bottom: 10px;
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            -webkit-background-clip: text;
+            -webkit-text-fill-color: transparent;
+            font-weight: 800;
+        }}
+
+        .header-subtitle {{
+            font-size: 16px;
+            color: #6b7280;
+            margin-bottom: 30px;
+        }}
+
+        .stats-overview {{
+            display: grid;
+            grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+            gap: 20px;
+            margin-top: 25px;
+        }}
+
+        .stat-box {{
+            background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
+            padding: 20px;
+            border-radius: 12px;
+            text-align: center;
+            transition: transform 0.3s ease;
+        }}
+
+        .stat-box:hover {{
+            transform: translateY(-5px);
+        }}
+
+        .stat-label {{
+            font-size: 13px;
+            color: #6b7280;
+            margin-bottom: 8px;
+            font-weight: 600;
+        }}
+
+        .stat-value {{
+            font-size: 32px;
+            font-weight: 700;
+            color: #1a1a1a;
+        }}
+
+        .stat-box.excellent .stat-value {{
+            color: #10b981;
+        }}
+
+        .stat-box.good .stat-value {{
+            color: #f59e0b;
+        }}
+
+        .stat-box.normal .stat-value {{
+            color: #3b82f6;
+        }}
+
+        .stat-box.need-opt .stat-value {{
+            color: #ef4444;
+        }}
+
+        .controls-section {{
+            background: white;
+            padding: 25px;
+            border-radius: 16px;
+            margin-bottom: 30px;
+            box-shadow: 0 4px 20px rgba(0,0,0,0.1);
+            display: flex;
+            gap: 20px;
+            flex-wrap: wrap;
+            align-items: center;
+        }}
+
+        .search-box {{
+            flex: 1;
+            min-width: 250px;
+        }}
+
+        .search-input {{
+            width: 100%;
+            padding: 12px 20px;
+            border: 2px solid #e5e7eb;
+            border-radius: 10px;
+            font-size: 15px;
+            transition: all 0.3s;
+        }}
+
+        .search-input:focus {{
+            outline: none;
+            border-color: #667eea;
+            box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
+        }}
+
+        .sort-box {{
+            display: flex;
+            align-items: center;
+            gap: 12px;
+        }}
+
+        .sort-label {{
+            font-size: 14px;
+            font-weight: 600;
+            color: #374151;
+        }}
+
+        .sort-select {{
+            padding: 10px 16px;
+            border: 2px solid #e5e7eb;
+            border-radius: 10px;
+            font-size: 14px;
+            background: white;
+            cursor: pointer;
+            transition: all 0.3s;
+        }}
+
+        .sort-select:focus {{
+            outline: none;
+            border-color: #667eea;
+        }}
+
+        .inspirations-section {{
+            background: white;
+            padding: 30px;
+            border-radius: 16px;
+            box-shadow: 0 10px 40px rgba(0,0,0,0.15);
+        }}
+
+        .inspirations-grid {{
+            display: grid;
+            grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
+            gap: 25px;
+        }}
+
+        .inspiration-card {{
+            background: white;
+            border-radius: 14px;
+            padding: 25px;
+            border-left: 6px solid #10b981;
+            cursor: pointer;
+            transition: all 0.3s ease;
+            box-shadow: 0 4px 12px rgba(0,0,0,0.08);
+            position: relative;
+        }}
+
+        .inspiration-card:hover {{
+            transform: translateY(-8px);
+            box-shadow: 0 12px 30px rgba(102, 126, 234, 0.2);
+        }}
+
+        .card-header {{
+            display: flex;
+            justify-content: space-between;
+            align-items: flex-start;
+            margin-bottom: 20px;
+            gap: 12px;
+        }}
+
+        .inspiration-name {{
+            font-size: 19px;
+            font-weight: 700;
+            color: #1a1a1a;
+            line-height: 1.4;
+            flex: 1;
+        }}
+
+        .grade-badge {{
+            background: #10b981;
+            color: white;
+            padding: 6px 14px;
+            border-radius: 20px;
+            font-size: 12px;
+            font-weight: 700;
+            white-space: nowrap;
+        }}
+
+        .score-section {{
+            display: flex;
+            align-items: center;
+            gap: 25px;
+            margin-bottom: 20px;
+            padding: 20px;
+            background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
+            border-radius: 12px;
+        }}
+
+        .main-score {{
+            display: flex;
+            flex-direction: column;
+            align-items: center;
+            gap: 8px;
+        }}
+
+        .score-circle {{
+            width: 90px;
+            height: 90px;
+            border-radius: 50%;
+            border: 6px solid #10b981;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            background: white;
+        }}
+
+        .score-value {{
+            font-size: 26px;
+            font-weight: 800;
+            color: #10b981;
+        }}
+
+        .score-label {{
+            font-size: 12px;
+            color: #6b7280;
+            font-weight: 600;
+        }}
+
+        .sub-scores {{
+            flex: 1;
+            display: flex;
+            flex-direction: column;
+            gap: 12px;
+        }}
+
+        .sub-score-item {{
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+            padding: 10px 15px;
+            background: white;
+            border-radius: 8px;
+        }}
+
+        .sub-score-label {{
+            font-size: 13px;
+            color: #6b7280;
+            font-weight: 600;
+        }}
+
+        .sub-score-value {{
+            font-size: 18px;
+            font-weight: 700;
+            color: #2563eb;
+        }}
+
+        .metrics-section {{
+            display: flex;
+            flex-direction: column;
+            gap: 10px;
+            margin-bottom: 15px;
+        }}
+
+        .metric-item {{
+            display: flex;
+            align-items: center;
+            gap: 8px;
+            font-size: 13px;
+            color: #4b5563;
+        }}
+
+        .metric-icon {{
+            font-size: 16px;
+        }}
+
+        .metric-label {{
+            font-weight: 600;
+        }}
+
+        .metric-value {{
+            color: #1f2937;
+        }}
+
+        .click-hint {{
+            position: absolute;
+            bottom: 15px;
+            right: 15px;
+            font-size: 12px;
+            color: #8b5cf6;
+            font-weight: 700;
+            opacity: 0;
+            transition: opacity 0.3s ease;
+            background: rgba(139, 92, 246, 0.1);
+            padding: 6px 12px;
+            border-radius: 8px;
+        }}
+
+        .inspiration-card:hover .click-hint {{
+            opacity: 1;
+        }}
+
+        /* Modal样式 */
+        .modal-overlay {{
+            display: none;
+            position: fixed;
+            top: 0;
+            left: 0;
+            right: 0;
+            bottom: 0;
+            background: rgba(0, 0, 0, 0.8);
+            z-index: 1000;
+            align-items: center;
+            justify-content: center;
+            padding: 20px;
+            overflow-y: auto;
+        }}
+
+        .modal-overlay.active {{
+            display: flex;
+        }}
+
+        .modal-content {{
+            background: white;
+            border-radius: 16px;
+            max-width: 1200px;
+            width: 100%;
+            max-height: 90vh;
+            overflow-y: auto;
+            position: relative;
+        }}
+
+        .modal-close {{
+            position: sticky;
+            top: 0;
+            right: 0;
+            background: white;
+            border: none;
+            font-size: 32px;
+            color: #6b7280;
+            cursor: pointer;
+            padding: 15px 20px;
+            z-index: 10;
+            text-align: right;
+            border-bottom: 1px solid #e5e7eb;
+        }}
+
+        .modal-close:hover {{
+            color: #1f2937;
+        }}
+
+        .modal-body {{
+            padding: 30px;
+        }}
+
+        .modal-header {{
+            margin-bottom: 25px;
+            padding-bottom: 20px;
+            border-bottom: 2px solid #e5e7eb;
+        }}
+
+        .modal-title {{
+            font-size: 28px;
+            font-weight: 800;
+            color: #1a1a1a;
+        }}
+
+        .modal-section {{
+            margin-bottom: 30px;
+        }}
+
+        .modal-section h3 {{
+            font-size: 20px;
+            font-weight: 700;
+            color: #374151;
+            margin-bottom: 15px;
+            padding-bottom: 10px;
+            border-bottom: 2px solid #f3f4f6;
+        }}
+
+        .info-grid {{
+            display: grid;
+            grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+            gap: 15px;
+        }}
+
+        .info-item {{
+            background: #f9fafb;
+            padding: 12px 16px;
+            border-radius: 8px;
+            border-left: 3px solid #8b5cf6;
+        }}
+
+        .info-label {{
+            font-weight: 600;
+            color: #6b7280;
+            font-size: 13px;
+            margin-right: 8px;
+        }}
+
+        .info-value {{
+            color: #1f2937;
+            font-size: 14px;
+        }}
+
+        .metrics-grid {{
+            display: grid;
+            grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+            gap: 15px;
+        }}
+
+        .metric-box {{
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            padding: 20px;
+            border-radius: 12px;
+            text-align: center;
+            color: white;
+        }}
+
+        .metric-box.wide {{
+            grid-column: span 2;
+        }}
+
+        .metric-box-label {{
+            font-size: 13px;
+            opacity: 0.9;
+            margin-bottom: 8px;
+            font-weight: 600;
+        }}
+
+        .metric-box-value {{
+            font-size: 28px;
+            font-weight: 700;
+        }}
+
+        .metric-box-value.small {{
+            font-size: 16px;
+        }}
+
+        .step-content {{
+            background: #f9fafb;
+            padding: 20px;
+            border-radius: 12px;
+        }}
+
+        .step-field {{
+            margin-bottom: 20px;
+        }}
+
+        .step-field-label {{
+            font-weight: 700;
+            color: #374151;
+            font-size: 14px;
+            margin-bottom: 8px;
+            display: block;
+        }}
+
+        .step-field-value {{
+            color: #1f2937;
+            font-size: 15px;
+            line-height: 1.7;
+        }}
+
+        .matches-list {{
+            display: flex;
+            flex-direction: column;
+            gap: 15px;
+            margin-top: 10px;
+        }}
+
+        .match-item {{
+            background: white;
+            padding: 18px;
+            border-radius: 10px;
+            border-left: 5px solid #3b82f6;
+        }}
+
+        .match-item.top1 {{
+            border-left-color: #fbbf24;
+            background: linear-gradient(135deg, #fef3c7 0%, #fde68a 50%, white 100%);
+        }}
+
+        .match-item.top2 {{
+            border-left-color: #c0c0c0;
+            background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 50%, white 100%);
+        }}
+
+        .match-item.top3 {{
+            border-left-color: #cd7f32;
+            background: linear-gradient(135deg, #fef3c7 0%, #fed7aa 50%, white 100%);
+        }}
+
+        .match-header {{
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+            margin-bottom: 12px;
+            gap: 10px;
+        }}
+
+        .match-rank {{
+            font-size: 18px;
+            font-weight: 800;
+            color: #6b7280;
+        }}
+
+        .match-element-name {{
+            flex: 1;
+            font-size: 16px;
+            font-weight: 700;
+            color: #1f2937;
+        }}
+
+        .match-score {{
+            font-size: 22px;
+            font-weight: 800;
+            color: #2563eb;
+            background: white;
+            padding: 6px 14px;
+            border-radius: 8px;
+        }}
+
+        .match-detail {{
+            background: rgba(255, 255, 255, 0.7);
+            padding: 10px;
+            border-radius: 6px;
+            margin-bottom: 10px;
+            font-size: 13px;
+            color: #4b5563;
+        }}
+
+        .match-reason {{
+            color: #1f2937;
+            font-size: 14px;
+            line-height: 1.7;
+        }}
+
+        .increment-matches {{
+            display: flex;
+            flex-direction: column;
+            gap: 12px;
+            margin-top: 10px;
+        }}
+
+        .increment-item {{
+            background: white;
+            padding: 15px;
+            border-radius: 8px;
+            border-left: 4px solid #10b981;
+        }}
+
+        .increment-header {{
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+            margin-bottom: 10px;
+        }}
+
+        .increment-words {{
+            font-weight: 700;
+            color: #1f2937;
+            font-size: 15px;
+        }}
+
+        .increment-score {{
+            font-size: 20px;
+            font-weight: 800;
+            color: #10b981;
+        }}
+
+        .increment-reason {{
+            color: #4b5563;
+            font-size: 13px;
+            line-height: 1.6;
+        }}
+
+        .empty-state {{
+            text-align: center;
+            padding: 40px;
+            color: #9ca3af;
+            font-size: 14px;
+        }}
+
+        .modal-link {{
+            margin-top: 25px;
+            padding-top: 20px;
+            border-top: 2px solid #e5e7eb;
+            text-align: center;
+        }}
+
+        .modal-link-btn {{
+            display: inline-flex;
+            align-items: center;
+            gap: 10px;
+            padding: 12px 24px;
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            color: white;
+            text-decoration: none;
+            border-radius: 10px;
+            font-size: 15px;
+            font-weight: 600;
+            transition: all 0.3s;
+        }}
+
+        .modal-link-btn:hover {{
+            transform: translateY(-2px);
+            box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
+        }}
+
+        .timestamp {{
+            text-align: center;
+            color: white;
+            font-size: 13px;
+            margin-top: 30px;
+            opacity: 0.8;
+        }}
+
+        @media (max-width: 768px) {{
+            .inspirations-grid {{
+                grid-template-columns: 1fr;
+            }}
+
+            .header h1 {{
+                font-size: 32px;
+            }}
+
+            .stats-overview {{
+                grid-template-columns: repeat(2, 1fr);
+            }}
+        }}
+    </style>
+</head>
+<body>
+    <div class="container">
+        <div class="header">
+            <h1>💡 灵感点分析可视化</h1>
+            <div class="header-subtitle">基于HOW人设的灵感点匹配分析结果</div>
+
+            <div class="stats-overview">
+                <div class="stat-box">
+                    <div class="stat-label">分析总数</div>
+                    <div class="stat-value" id="totalCount">{total_count}</div>
+                </div>
+                <div class="stat-box excellent">
+                    <div class="stat-label">优秀 (≥70)</div>
+                    <div class="stat-value" id="excellentCount">{excellent_count}</div>
+                </div>
+                <div class="stat-box good">
+                    <div class="stat-label">良好 (50-70)</div>
+                    <div class="stat-value" id="goodCount">{good_count}</div>
+                </div>
+                <div class="stat-box normal">
+                    <div class="stat-label">一般 (30-50)</div>
+                    <div class="stat-value" id="normalCount">{normal_count}</div>
+                </div>
+                <div class="stat-box need-opt">
+                    <div class="stat-label">待优化 (<30)</div>
+                    <div class="stat-value" id="needOptCount">{need_opt_count}</div>
+                </div>
+                <div class="stat-box">
+                    <div class="stat-label">平均分数</div>
+                    <div class="stat-value" id="avgScore">{avg_score:.1f}</div>
+                </div>
+            </div>
+        </div>
+
+        <div class="controls-section">
+            <div class="search-box">
+                <input type="text"
+                       id="searchInput"
+                       class="search-input"
+                       placeholder="🔍 搜索灵感点名称..."
+                       oninput="filterInspirations()">
+            </div>
+
+            <div class="sort-box">
+                <span class="sort-label">排序方式:</span>
+                <select id="sortSelect" class="sort-select" onchange="filterInspirations()">
+                    <option value="score-desc">分数从高到低</option>
+                    <option value="score-asc">分数从低到高</option>
+                    <option value="name-asc">名称A-Z</option>
+                    <option value="name-desc">名称Z-A</option>
+                </select>
+            </div>
+        </div>
+
+        <div class="inspirations-section">
+            <div class="inspirations-grid">
+                {cards_html_str}
+            </div>
+        </div>
+
+        <div class="timestamp">生成时间: {timestamp}</div>
+
+        <!-- Modal -->
+        <div id="detailModal" class="modal-overlay" onclick="closeModalOnOverlay(event)">
+            <div class="modal-content">
+                <button class="modal-close" onclick="closeModal()">&times;</button>
+                <div class="modal-body" id="modalBody">
+                    <!-- Content will be inserted here -->
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <script>
+    {detail_modal_js}
+    </script>
+</body>
+</html>'''
+
+    # 写入文件
+    output_file = Path(output_path)
+    output_file.parent.mkdir(parents=True, exist_ok=True)
+
+    with open(output_file, 'w', encoding='utf-8') as f:
+        f.write(html_content)
+
+    return str(output_file.absolute())
+
+
+def main():
+    """主函数"""
+    import sys
+
+    # 配置路径
+    inspiration_dir = "/Users/semsevens/Desktop/workspace/aaa/dev_3/data/阿里多多酱/out/人设_1110/how/灵感点"
+    posts_dir = "/Users/semsevens/Desktop/workspace/aaa/dev_3/data/阿里多多酱/作者历史帖子"
+    output_path = "/Users/semsevens/Desktop/workspace/aaa/dev_3/data/阿里多多酱/out/人设_1110/how/灵感点可视化.html"
+
+    print("=" * 60)
+    print("灵感点分析可视化脚本")
+    print("=" * 60)
+
+    # 加载数据
+    print("\n📂 正在加载灵感点数据...")
+    inspirations_data = load_inspiration_points_data(inspiration_dir)
+    print(f"✅ 成功加载 {len(inspirations_data)} 个灵感点")
+
+    print("\n📂 正在加载帖子数据...")
+    posts_map = load_posts_data(posts_dir)
+    print(f"✅ 成功加载 {len(posts_map)} 个帖子")
+
+    # 生成HTML
+    print("\n🎨 正在生成可视化HTML...")
+    result_path = generate_html(inspirations_data, posts_map, output_path)
+
+    print(f"\n✅ 可视化文件已生成!")
+    print(f"📄 文件路径: {result_path}")
+    print(f"\n💡 在浏览器中打开该文件即可查看可视化结果")
+    print("=" * 60)
+
+
+if __name__ == "__main__":
+    main()