Parcourir la source

feat: 优化可视化界面功能和用户体验

主要改进:
1. 添加搜索结果匹配分数展示
   - 在搜索结果卡片右上角显示匹配分数徽章
   - 点击分数徽章显示详细匹配信息(相同部分、增量部分、分数说明)
   - 展示输入信息(B、A及其Context)帮助理解匹配逻辑

2. 实现搜索结果排序功能
   - 支持三种排序方式:匹配分数、原始顺序、点赞数
   - 默认按匹配分数降序排列
   - 在卡片底部中央显示位置信息(P{page}-{position})

3. 添加发布日期显示
   - 在所有帖子卡片中显示发布时间
   - 位置:描述和底部信息之间

4. 修复内容截断问题
   - 增加折叠区域最大高度限制
   - step-section-content: 5000px -> 20000px
   - step-wrapper-content: 10000px -> 30000px
   - 解决大量搜索结果无法完整显示的问题

5. 优化卡片尺寸和布局
   - 搜索结果卡片: 320px -> 280px
   - 信息帖子卡片: 280px -> 260px
   - 卡片间距: 20px -> 16px

6. 生成唯一容器ID
   - 使用灵感点名称+索引生成唯一ID
   - 修复多个灵感点排序按钮互相干扰的问题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui il y a 2 semaines
Parent
commit
7a24c4f1ac
2 fichiers modifiés avec 861 ajouts et 30 suppressions
  1. 286 0
      README_可视化.md
  2. 575 30
      visualize_inspiration_points.py

+ 286 - 0
README_可视化.md

@@ -0,0 +1,286 @@
+# 灵感点可视化工具
+
+将灵感点分析数据(包括匹配结果、搜索结果等)生成交互式HTML可视化页面。
+
+---
+
+## 快速开始
+
+```bash
+python visualize_inspiration_points.py
+```
+
+生成的HTML文件位置:
+```
+data/阿里多多酱/out/人设_1110/how/灵感点可视化.html
+```
+
+在浏览器中打开该文件即可查看可视化结果。
+
+---
+
+## 功能特性
+
+### 1. 步骤化展示
+
+- **步骤1**:灵感点匹配灵感分类(基于step1数据)
+  - 显示灵感点与各个灵感要素的匹配关系
+  - 展示匹配分数、相同部分、增量部分、分数说明
+
+- **步骤2**:搜索结果展示(基于search数据)
+  - **2.1**:直接搜索灵感分类
+    - 显示以灵感要素为关键词搜索到的小红书帖子
+    - 展示每个帖子与原始灵感点的匹配分数(基于step4数据)
+
+### 2. 双层匹配分数展示
+
+#### 步骤1分数(灵感点 → 灵感要素)
+- 数据来源:`all_step1_灵感人设匹配_gemini-2.5-pro.json`
+- 显示位置:步骤1匹配项的顶部标题栏
+- 含义:灵感点与某个灵感要素的语义匹配度
+
+#### 步骤4分数(搜索帖子 → 灵感点)
+- 数据来源:`all_step4_搜索结果匹配_gemini-2.5-pro.json`
+- 显示位置:搜索结果帖子卡片的右上角徽章
+- 含义:搜索到的帖子内容与原始灵感点的匹配度
+
+### 3. 交互功能
+
+- **可折叠面板**
+  - 步骤1和步骤2可独立折叠
+  - 匹配项列表可折叠(默认只展开第一个)
+
+- **卡片点击详情**
+  - 点击灵感点卡片:查看完整的灵感点信息
+  - 点击分类卡片:查看该灵感要素下的所有帖子
+  - 点击搜索结果卡片:查看帖子的完整详情
+
+- **分数详情弹窗**
+  - 点击步骤4的匹配分数徽章
+  - 显示:分数、相同部分、增量部分、分数说明
+
+- **图片轮播**
+  - 搜索结果卡片支持多图轮播
+  - 点击左右箭头切换图片
+
+### 4. 智能排序
+
+- 搜索结果按匹配分数降序排列
+- 有匹配分数的帖子优先显示
+- 无匹配分数的帖子排在最后
+
+---
+
+## 数据来源
+
+### 输入文件
+
+| 文件路径 | 用途 | 数据内容 |
+|---------|------|---------|
+| `data/阿里多多酱/out/人设_1110/how/灵感点/[灵感点名称]/all_summary_完整流程_gemini-2.5-pro.json` | 灵感点摘要 | 灵感点的基本信息、描述、维度等 |
+| `data/阿里多多酱/out/人设_1110/how/灵感点/[灵感点名称]/all_step1_灵感人设匹配_gemini-2.5-pro.json` | 步骤1匹配 | 灵感点与灵感要素的匹配结果 |
+| `data/阿里多多酱/out/人设_1110/how/灵感点/[灵感点名称]/search/all_search_*.json` | 搜索结果 | 以灵感要素为关键词搜索的小红书帖子 |
+| `data/阿里多多酱/out/人设_1110/how/灵感点/[灵感点名称]/search/all_step4_搜索结果匹配_gemini-2.5-pro.json` | 步骤4匹配 | 搜索结果帖子与灵感点的匹配分数 |
+| `data/阿里多多酱/out/人设_1110/分类索引_完整.json` | 分类索引 | 灵感分类、目的分类、关键点分类的完整索引 |
+| `data/阿里多多酱/out/人设_1110/点到帖子映射.json` | 点映射 | 灵感点/目的点/关键点到帖子的映射 |
+| `data/阿里多多酱/out/人设_1110/帖子到分类和点映射.json` | 帖子映射 | 帖子ID到分类和点的映射 |
+
+### 输出文件
+
+```
+data/阿里多多酱/out/人设_1110/how/灵感点可视化.html
+```
+
+单个HTML文件包含所有数据和样式,可直接在浏览器中打开。
+
+---
+
+## 数据结构说明
+
+### Step1 数据结构(灵感点 → 灵感要素)
+
+**文件**: `all_step1_灵感人设匹配_gemini-2.5-pro.json`
+
+```json
+{
+  "灵感": "内容植入品牌推广",
+  "匹配结果列表": [
+    {
+      "输入信息": {
+        "B": "内容植入品牌推广",
+        "A": "将品牌/产品信息融入内容",
+        "A_Context": "所属视角: 灵感触发源\n一级分类: 商业合作的要求"
+      },
+      "匹配结果": {
+        "score": 0.9,
+        "score说明": "...",
+        "相同部分": {"内容": "...", "品牌": "..."},
+        "增量部分": {"推广": "..."}
+      },
+      "业务信息": {
+        "灵感": "内容植入品牌推广",
+        "匹配要素": "将品牌/产品信息融入内容"
+      }
+    }
+  ]
+}
+```
+
+**关键字段**:
+- `输入信息.A`: 灵感要素名称(用于展示和搜索)
+- `输入信息.A_Context`: 灵感要素的分类层级
+- `匹配结果.score`: 灵感点与灵感要素的匹配分数
+
+### Step4 数据结构(搜索帖子 → 灵感点)
+
+**文件**: `all_step4_搜索结果匹配_gemini-2.5-pro.json`
+
+```json
+{
+  "输入信息": {
+    "灵感": "内容植入品牌推广",
+    "搜索关键词": "将品牌/产品信息融入内容",
+    "搜索结果数": 20
+  },
+  "匹配结果列表": [
+    {
+      "输入信息": {
+        "B": "内容植入品牌推广",
+        "A": "🔥 Gpt 4o 将品牌符号融入场景,附步骤!",
+        "A_Context": "..."
+      },
+      "匹配结果": {
+        "score": 0.75,
+        "score说明": "...",
+        "相同部分": {"内容植入": "...", "品牌推广": "..."},
+        "增量部分": {"品牌推广": "..."}
+      },
+      "业务信息": {
+        "灵感": "内容植入品牌推广",
+        "channel_content_id": "6842c7d3000000002001f6c4",
+        "title": "🔥 Gpt 4o 将品牌符号融入场景,附步骤!",
+        "likes": 801
+      }
+    }
+  ]
+}
+```
+
+**关键字段**:
+- `输入信息.A`: 帖子标题
+- `匹配结果.score`: 帖子与灵感点的匹配分数
+- `业务信息.channel_content_id`: 帖子ID(用于关联搜索结果)
+
+---
+
+## 重要技术说明
+
+### 变量命名隔离
+
+**问题背景**:步骤1和步骤4使用相同的数据结构(相同部分、增量部分、score等),在同一个循环中处理时容易导致变量覆盖。
+
+**解决方案**:使用不同的变量名前缀
+
+**步骤1变量**(灵感点匹配):
+```python
+score = match_result.get("score", 0)
+score_explain = match_result.get("score说明", "")
+same_parts = match_result.get("相同部分", {})
+increment_parts = match_result.get("增量部分", {})
+```
+
+**步骤4变量**(搜索结果匹配):
+```python
+note_score = score_info["score"]
+note_score_explain = score_info.get("score说明", "")
+note_same_parts = score_info.get("相同部分", {})
+note_increment_parts = score_info.get("增量部分", {})
+```
+
+### None值处理
+
+**问题**:JSON文件中某些字段可能为`null`,Python读取后为`None`,调用`.items()`或`.keys()`会报错。
+
+**解决方案**:使用`or {}`或`or ""`确保默认值
+
+```python
+same_parts = match_result.get("相同部分", {}) or {}
+score_explain = match_result.get("score说明", "") or ""
+```
+
+---
+
+## 版本历史
+
+### v1.1 (2025-11-13)
+- **修复**: 变量命名冲突导致step1分数被step4数据覆盖
+  - 步骤1和步骤4的变量现在完全隔离
+  - step1显示正确的灵感匹配分数
+  - step4显示正确的帖子匹配分数
+- **修复**: None值处理
+  - 添加`or {}`和`or ""`确保安全处理None值
+  - 修复"object of type 'NoneType' has no len()"错误
+
+### v1.0 (2025-11-13)
+- 初始版本
+- 支持步骤1和步骤2的可视化展示
+- 支持搜索结果匹配分数展示
+- 支持交互式卡片和弹窗
+
+---
+
+## 使用场景
+
+### 场景1:分析灵感点匹配效果
+
+打开可视化页面,查看:
+1. 灵感点与哪些灵感要素匹配度最高
+2. 匹配的相同部分和增量部分是什么
+3. 为什么得到这个分数(查看分数说明)
+
+### 场景2:评估搜索结果质量
+
+在步骤2中查看:
+1. 以灵感要素为关键词搜索到的帖子
+2. 哪些帖子与原始灵感点匹配度最高
+3. 为什么这些帖子匹配度高(点击分数查看详情)
+
+### 场景3:发现内容机会
+
+通过可视化页面:
+1. 了解哪些灵感分类缺少内容(匹配分数低)
+2. 查看高分搜索结果,学习内容创作方向
+3. 识别内容创作的相同点和差异点
+
+---
+
+## 常见问题
+
+### Q1: 为什么有些搜索结果没有分数?
+
+**A**: 只有在`all_step4_搜索结果匹配_gemini-2.5-pro.json`文件中的帖子才有匹配分数。如果搜索结果较多,step4可能只对部分帖子进行了匹配分析。
+
+### Q2: step1分数和step4分数有什么区别?
+
+**A**:
+- **step1分数**:灵感点与灵感要素的语义匹配度(概念级别)
+- **step4分数**:实际小红书帖子与灵感点的内容匹配度(内容级别)
+
+### Q3: 为什么有些灵感点没有搜索结果?
+
+**A**: 搜索功能依赖于step1匹配到的灵感要素。如果某个灵感点没有匹配到合适的灵感要素,或者没有执行搜索步骤,就不会有搜索结果。
+
+### Q4: 如何更新可视化?
+
+**A**: 当数据文件更新后,重新运行脚本即可:
+```bash
+python visualize_inspiration_points.py
+```
+
+---
+
+## 相关文档
+
+- [索引构建 README](README_索引构建.md)
+- [搜索模块 README](script/search/README.md)
+- [详情模块 README](script/detail/README.md)

+ 575 - 30
visualize_inspiration_points.py

@@ -57,7 +57,7 @@ def load_inspiration_points_data(inspiration_dir: str) -> List[Dict[str, Any]]:
                         except Exception as e:
                             print(f"警告: 读取 {step2_files[0]} 失败: {e}")
 
-                    # 加载搜索结果
+                    # 加载搜索结果和匹配分数
                     search_results = {}
                     search_dir = subdir / "search"
                     if search_dir.exists() and search_dir.is_dir():
@@ -69,11 +69,27 @@ def load_inspiration_points_data(inspiration_dir: str) -> List[Dict[str, Any]]:
                                     # 从JSON内容中读取真实的keyword,而不是从文件名提取
                                     keyword = search_data.get("search_params", {}).get("keyword", "")
                                     if keyword:
-                                        search_results[keyword] = search_data
+                                        # 尝试加载对应的匹配结果文件
+                                        match_file = search_dir / "all_step4_搜索结果匹配_gemini-2.5-pro.json"
+                                        match_data = None
+                                        if match_file.exists():
+                                            try:
+                                                with open(match_file, 'r', encoding='utf-8') as mf:
+                                                    match_data = json.load(mf)
+                                            except Exception as e:
+                                                print(f"警告: 读取匹配文件 {match_file} 失败: {e}")
+
+                                        search_results[keyword] = {
+                                            "search_data": search_data,
+                                            "match_data": match_data
+                                        }
                                     else:
                                         # 如果JSON中没有keyword,则从文件名提取
                                         keyword = search_file.stem.replace("all_search_", "")
-                                        search_results[keyword] = search_data
+                                        search_results[keyword] = {
+                                            "search_data": search_data,
+                                            "match_data": None
+                                        }
                             except Exception as e:
                                 print(f"警告: 读取 {search_file} 失败: {e}")
 
@@ -139,6 +155,7 @@ def generate_post_card_html(post: Dict[str, Any], note_id_prefix: str = "info-po
     link = post.get("link", "")
     author = post.get("channel_account_name", "")
     post_id = post.get("channel_content_id", "")
+    publish_time = post.get("publish_time", "")
 
     # 生成唯一的note_id
     note_id = f"{note_id_prefix}-{random.randint(10000, 99999)}"
@@ -236,12 +253,18 @@ def generate_post_card_html(post: Dict[str, Any], note_id_prefix: str = "info-po
         if points_sections:
             points_html = f'<div class="note-points">{"".join(points_sections)}</div>'
 
+    # 生成发布日期HTML
+    publish_date_html = ""
+    if publish_time:
+        publish_date_html = f'<div class="note-publish-date">📅 {html_module.escape(publish_time)}</div>'
+
     card_html = f'''
     <div class="search-note-item" data-note-data='{note_data_json_escaped}' onclick="showNoteDetail(this)">
         {images_html}
         <div class="note-content">
             <div class="note-title">{html_module.escape(title) if title else "无标题"}</div>
             <div class="note-desc">{html_module.escape(desc) if desc else "暂无描述"}</div>
+            {publish_date_html}
             <div class="note-footer">
                 <div class="note-author">@{html_module.escape(author) if author else "匿名"}</div>
                 <div class="note-stats">
@@ -312,8 +335,8 @@ def generate_inspiration_card_html(
         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("增量部分", {})
+        same_parts = match_result.get("相同部分", {}) or {}
+        increment_parts = match_result.get("增量部分", {}) or {}
 
         # 生成相同部分和增量部分的HTML
         parts_html = ""
@@ -389,9 +412,13 @@ def generate_inspiration_card_html(
             element_name = input_info.get("A", "")
             context = 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("增量部分", {})
+            score_explain = match_result.get("score说明", "") or ""
+            same_parts = match_result.get("相同部分", {}) or {}
+            increment_parts = match_result.get("增量部分", {}) or {}
+
+            # 为搜索结果容器生成唯一ID
+            safe_insp_name = ''.join(c if c.isalnum() else '_' for c in inspiration_name)
+            unique_match_id = f"{safe_insp_name}-match-{idx}"
 
             # 解析层级
             hierarchy = []
@@ -434,11 +461,49 @@ def generate_inspiration_card_html(
             # 生成搜索结果HTML(网格展示,图片轮播)
             search_html = ""
             if element_name in search_results:
-                search_data = search_results[element_name]
+                result_obj = search_results[element_name]
+                search_data = result_obj.get("search_data", {})
+                match_data = result_obj.get("match_data", None)
+
                 search_params = search_data.get("search_params", {})
                 notes = search_data.get("notes", [])
                 notes_count = len(notes)
 
+                # 构建匹配分数字典 {channel_content_id: match_info}
+                match_scores = {}
+                if match_data and "匹配结果列表" in match_data:
+                    for match_item in match_data["匹配结果列表"]:
+                        business_info = match_item.get("业务信息", {})
+                        input_info = match_item.get("输入信息", {})
+                        content_id = business_info.get("channel_content_id", "")
+                        if content_id:
+                            match_scores[content_id] = {
+                                "score": match_item.get("匹配结果", {}).get("score", 0),
+                                "score说明": match_item.get("匹配结果", {}).get("score说明", "") or "",
+                                "相同部分": match_item.get("匹配结果", {}).get("相同部分", {}) or {},
+                                "增量部分": match_item.get("匹配结果", {}).get("增量部分", {}) or {},
+                                "输入B": input_info.get("B", "") or "",
+                                "输入A": input_info.get("A", "") or "",
+                                "B_Context": input_info.get("B_Context", "") or "",
+                                "A_Context": input_info.get("A_Context", "") or ""
+                            }
+
+                # 为notes添加匹配分数、原始索引,并准备排序
+                notes_with_scores = []
+                for original_idx, note in enumerate(notes):
+                    note_id = note.get("channel_content_id", "")
+                    score_info = match_scores.get(note_id, None)
+                    notes_with_scores.append({
+                        "note": note,
+                        "score_info": score_info,
+                        "original_index": original_idx,  # 原始搜索结果位置(0-based)
+                        "page": (original_idx // 20) + 1,  # 第几页(假设每页20条)
+                        "position_in_page": (original_idx % 20) + 1  # 页内位置
+                    })
+
+                # 默认按分数降序排序(没有分数的放到最后)
+                notes_with_scores.sort(key=lambda x: x["score_info"]["score"] if x["score_info"] else -1, reverse=True)
+
                 # 生成搜索参数HTML
                 search_params_html = ""
                 if search_params:
@@ -471,19 +536,35 @@ def generate_inspiration_card_html(
                     </div>
                     '''
 
-                # 生成搜索结果统计HTML
+                # 生成搜索结果统计HTML(带排序按钮)
                 search_summary_html = f'''
                 <div class="search-summary-section">
-                    <div class="search-summary-title">📊 搜索结果</div>
-                    <div class="search-summary-content">
-                        共找到 <span class="search-result-count">{notes_count}</span> 条相关内容
+                    <div class="search-summary-header">
+                        <div class="search-summary-left">
+                            <div class="search-summary-title">📊 搜索结果</div>
+                            <div class="search-summary-content">
+                                共找到 <span class="search-result-count">{notes_count}</span> 条相关内容
+                            </div>
+                        </div>
+                        <div class="search-sort-buttons">
+                            <span class="search-sort-label">排序:</span>
+                            <button class="search-sort-btn active" data-sort="score" onclick="sortSearchResults(this, '{unique_match_id}-search-results')">匹配分数</button>
+                            <button class="search-sort-btn" data-sort="original" onclick="sortSearchResults(this, '{unique_match_id}-search-results')">原始顺序</button>
+                            <button class="search-sort-btn" data-sort="likes" onclick="sortSearchResults(this, '{unique_match_id}-search-results')">点赞数</button>
+                        </div>
                     </div>
                 </div>
                 '''
 
                 if notes_count > 0:
                     notes_items = ""
-                    for note_idx, note in enumerate(notes):  # 显示所有结果
+                    for note_idx, item in enumerate(notes_with_scores):  # 使用包含元数据的列表
+                        note = item["note"]
+                        score_info = item["score_info"]
+                        original_index = item["original_index"]
+                        page = item["page"]
+                        position_in_page = item["position_in_page"]
+
                         title = note.get("title", "")
                         desc = note.get("desc", "")
                         link = note.get("link", "")
@@ -491,6 +572,8 @@ def generate_inspiration_card_html(
                         like_count = note.get("like_count", 0)
                         comment_count = note.get("comment_count", 0)
                         images = note.get("images", [])
+                        content_id = note.get("channel_content_id", "")
+                        publish_time = note.get("publish_time", "")
 
                         note_id = f"note-{idx}-{note_idx}"
 
@@ -544,14 +627,64 @@ def generate_inspiration_card_html(
                         note_data_json = json.dumps(note_data, ensure_ascii=False)
                         note_data_escaped = html_module.escape(note_data_json)
 
+                        # 生成匹配分数HTML
+                        score_badge_html = ""
+                        score_detail_html = ""
+                        if score_info:
+                            note_score = score_info["score"]
+                            note_score_explain = score_info.get("score说明", "") or ""
+                            note_same_parts = score_info.get("相同部分", {}) or {}
+                            note_increment_parts = score_info.get("增量部分", {}) or {}
+                            input_b = score_info.get("输入B", "") or ""
+                            input_a = score_info.get("输入A", "") or ""
+                            b_context = score_info.get("B_Context", "") or ""
+                            a_context = score_info.get("A_Context", "") or ""
+
+                            # 分数详情JSON
+                            score_detail_data = {
+                                "score": note_score,
+                                "score说明": note_score_explain,
+                                "相同部分": note_same_parts,
+                                "增量部分": note_increment_parts,
+                                "输入B": input_b,
+                                "输入A": input_a,
+                                "B_Context": b_context,
+                                "A_Context": a_context
+                            }
+                            score_detail_json = json.dumps(score_detail_data, ensure_ascii=False)
+                            score_detail_escaped = html_module.escape(score_detail_json)
+
+                            score_badge_html = f'''
+                            <div class="note-score-badge" onclick="event.stopPropagation(); showScoreDetail(this)" data-score-detail='{score_detail_escaped}'>
+                                <span class="score-label">匹配分数</span>
+                                <span class="score-value">{note_score:.2f}</span>
+                            </div>
+                            '''
+
+                        # 生成发布日期HTML
+                        publish_date_html = ""
+                        if publish_time:
+                            publish_date_html = f'<div class="note-publish-date">📅 {html_module.escape(publish_time)}</div>'
+
+                        # 计算匹配分数(用于排序)
+                        sort_score = score_info["score"] if score_info else -1
+
                         notes_items += f'''
-                        <div class="search-note-item" data-note-data='{note_data_escaped}' onclick="showNoteDetail(this)">
+                        <div class="search-note-item"
+                             data-note-data='{note_data_escaped}'
+                             data-original-index="{original_index}"
+                             data-score="{sort_score}"
+                             data-likes="{like_count}"
+                             onclick="showNoteDetail(this)">
+                            {score_badge_html}
                             {images_html}
                             <div class="note-content">
                                 <div class="note-title">{html_module.escape(title) if title else "无标题"}</div>
                                 <div class="note-desc">{html_module.escape(desc) if desc else "暂无描述"}</div>
+                                {publish_date_html}
                                 <div class="note-footer">
                                     <div class="note-author">@{html_module.escape(author)}</div>
+                                    <div class="note-position">P{page}-{position_in_page}</div>
                                     <div class="note-stats">
                                         <span>👍 {like_count}</span>
                                         <span>💬 {comment_count}</span>
@@ -565,7 +698,7 @@ def generate_inspiration_card_html(
                     <div class="search-results-section">
                         {search_params_html}
                         {search_summary_html}
-                        <div class="search-notes-list">
+                        <div class="search-notes-list" id="{unique_match_id}-search-results">
                             {notes_items}
                         </div>
                     </div>
@@ -603,7 +736,7 @@ def generate_inspiration_card_html(
                     # 生成帖子卡片
                     insp_posts_html = ""
                     for post in insp_posts[:6]:  # 最多显示6个
-                        insp_posts_html += generate_post_card_html(post, f"match-{idx}-insp-post", post_to_mapping_data)
+                        insp_posts_html += generate_post_card_html(post, f"{unique_match_id}-insp-post", post_to_mapping_data)
 
                     insp_detail_html = f'''
                     <div class="info-detail-column">
@@ -629,7 +762,7 @@ def generate_inspiration_card_html(
                     # 生成帖子卡片
                     cat_posts_html = ""
                     for post in cat_posts[:6]:  # 最多显示6个
-                        cat_posts_html += generate_post_card_html(post, f"match-{idx}-cat-post", post_to_mapping_data)
+                        cat_posts_html += generate_post_card_html(post, f"{unique_match_id}-cat-post", post_to_mapping_data)
 
                     cat_detail_html = f'''
                     <div class="info-detail-column">
@@ -664,7 +797,7 @@ def generate_inspiration_card_html(
                     <div class="step-toggle">▼</div>
                 </div>
                 <div class="step-wrapper-content">
-                    <div class="match-analysis-section" id="match-{idx}-step1" data-step-name="灵感点匹配灵感分类">
+                    <div class="match-analysis-section" id="{unique_match_id}-step1" data-step-name="灵感点匹配灵感分类">
                         <div class="match-parts-container">
                             <div class="match-parts-column">
                                 {same_parts_html}
@@ -692,7 +825,7 @@ def generate_inspiration_card_html(
                         <div class="step-toggle">▼</div>
                     </div>
                     <div class="step-wrapper-content">
-                        <div class="step-section expanded" data-step="2" id="match-{idx}-step2" data-step-name="灵感分类搜索">
+                        <div class="step-section expanded" data-step="2" id="{unique_match_id}-step2" data-step-name="灵感分类搜索">
                             <div class="step-section-header" onclick="toggleStep(this)">
                                 <div class="step-section-title">
                                     <span class="step-sub-number">2.1</span>
@@ -712,7 +845,7 @@ def generate_inspiration_card_html(
             safe_element_id = ''.join(c if c.isalnum() or c in '_-' else '_' for c in element_name)
 
             matches_html += f'''
-            <div class="match-item{expanded_class}" data-index="{idx}" id="match-{idx}" data-match-name="{html_module.escape(element_name)}">
+            <div class="match-item{expanded_class}" data-index="{idx}" id="{unique_match_id}" data-match-name="{html_module.escape(element_name)}">
                 <div class="match-main-header" onclick="toggleMainMatch(this)">
                     <div class="match-header-row">
                         <div class="match-header-left">
@@ -826,9 +959,9 @@ def generate_detail_html(inspiration_data: Dict[str, Any]) -> str:
                 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("增量部分", {})
+                score_explain = match_result.get("score说明", "") or ""
+                same_parts = match_result.get("相同部分", {}) or {}
+                increment_parts = match_result.get("增量部分", {}) or {}
 
                 content += f'''
                     <div class="match-item">
@@ -1087,10 +1220,169 @@ def generate_detail_modal_content_js() -> str:
         }
     }
 
+    // 显示分数详情
+    function showScoreDetail(element) {
+        const scoreDetailStr = element.dataset.scoreDetail;
+        if (!scoreDetailStr) return;
+
+        try {
+            const scoreData = JSON.parse(scoreDetailStr);
+            const modal = document.getElementById('scoreDetailModal');
+            const modalBody = document.getElementById('scoreModalBody');
+
+            // 生成相同部分HTML
+            let samePartsHTML = '';
+            if (scoreData.相同部分 && Object.keys(scoreData.相同部分).length > 0) {
+                const sameItems = Object.entries(scoreData.相同部分).map(([key, value]) =>
+                    `<div class="score-part-item"><span class="score-part-key">${key}:</span><span class="score-part-value">${value}</span></div>`
+                ).join('');
+                samePartsHTML = `
+                    <div class="score-parts same-parts">
+                        <div class="score-parts-title">✅ 相同部分</div>
+                        ${sameItems}
+                    </div>
+                `;
+            }
+
+            // 生成增量部分HTML
+            let incrementPartsHTML = '';
+            if (scoreData.增量部分 && Object.keys(scoreData.增量部分).length > 0) {
+                const incItems = Object.entries(scoreData.增量部分).map(([key, value]) =>
+                    `<div class="score-part-item"><span class="score-part-key">${key}:</span><span class="score-part-value">${value}</span></div>`
+                ).join('');
+                incrementPartsHTML = `
+                    <div class="score-parts increment-parts">
+                        <div class="score-parts-title">➕ 增量部分</div>
+                        ${incItems}
+                    </div>
+                `;
+            }
+
+            // 生成分数说明HTML
+            let explainHTML = '';
+            if (scoreData.score说明) {
+                explainHTML = `
+                    <div class="score-detail-explain">
+                        <div class="score-explain-title">💡 分数说明</div>
+                        <div class="score-explain-text">${scoreData.score说明}</div>
+                    </div>
+                `;
+            }
+
+            // 生成输入信息HTML
+            let inputInfoHTML = '';
+            if (scoreData.输入B || scoreData.输入A) {
+                inputInfoHTML = `
+                    <div class="score-input-info">
+                        <div class="score-input-title">📝 输入信息</div>
+                        ${scoreData.输入B ? `
+                            <div class="score-input-item">
+                                <div class="score-input-label">输入B(灵感点):</div>
+                                <div class="score-input-value">${scoreData.输入B}</div>
+                            </div>
+                        ` : ''}
+                        ${scoreData.B_Context ? `
+                            <div class="score-input-item">
+                                <div class="score-input-label">B_Context:</div>
+                                <div class="score-input-value">${scoreData.B_Context}</div>
+                            </div>
+                        ` : ''}
+                        ${scoreData.输入A ? `
+                            <div class="score-input-item">
+                                <div class="score-input-label">输入A(帖子标题):</div>
+                                <div class="score-input-value">${scoreData.输入A}</div>
+                            </div>
+                        ` : ''}
+                        ${scoreData.A_Context ? `
+                            <div class="score-input-item">
+                                <div class="score-input-label">A_Context(帖子内容摘要):</div>
+                                <div class="score-input-value">${scoreData.A_Context}</div>
+                            </div>
+                        ` : ''}
+                    </div>
+                `;
+            }
+
+            modalBody.innerHTML = `
+                <div class="score-detail-container">
+                    <h2 class="score-detail-title">匹配分数详情</h2>
+                    <div class="score-detail-score">
+                        <span class="score-detail-label">匹配分数:</span>
+                        <span class="score-detail-value">${scoreData.score.toFixed(2)}</span>
+                    </div>
+                    ${inputInfoHTML}
+                    ${explainHTML}
+                    <div class="score-parts-container">
+                        ${samePartsHTML}
+                        ${incrementPartsHTML}
+                    </div>
+                </div>
+            `;
+
+            modal.classList.add('active');
+            document.body.style.overflow = 'hidden';
+        } catch (e) {
+            console.error('Failed to parse score detail:', e);
+        }
+    }
+
+    // 关闭分数详情
+    function closeScoreDetail() {
+        const modal = document.getElementById('scoreDetailModal');
+        if (modal) {
+            modal.classList.remove('active');
+            document.body.style.overflow = '';
+        }
+    }
+
+    // 点击Modal背景关闭分数详情
+    function closeScoreDetailModal(event) {
+        if (event.target.id === 'scoreDetailModal') {
+            closeScoreDetail();
+        }
+    }
+
+    // 搜索结果排序
+    function sortSearchResults(button, containerId) {
+        const sortType = button.dataset.sort;
+        const container = document.getElementById(containerId);
+        if (!container) return;
+
+        // 更新按钮状态
+        const allButtons = button.parentElement.querySelectorAll('.search-sort-btn');
+        allButtons.forEach(btn => btn.classList.remove('active'));
+        button.classList.add('active');
+
+        // 获取所有搜索结果卡片
+        const items = Array.from(container.querySelectorAll('.search-note-item'));
+
+        // 根据排序类型排序
+        items.sort((a, b) => {
+            if (sortType === 'score') {
+                const scoreA = parseFloat(a.dataset.score) || -1;
+                const scoreB = parseFloat(b.dataset.score) || -1;
+                return scoreB - scoreA; // 降序
+            } else if (sortType === 'original') {
+                const indexA = parseInt(a.dataset.originalIndex) || 0;
+                const indexB = parseInt(b.dataset.originalIndex) || 0;
+                return indexA - indexB; // 升序
+            } else if (sortType === 'likes') {
+                const likesA = parseInt(a.dataset.likes) || 0;
+                const likesB = parseInt(b.dataset.likes) || 0;
+                return likesB - likesA; // 降序
+            }
+            return 0;
+        });
+
+        // 重新排列DOM
+        items.forEach(item => container.appendChild(item));
+    }
+
     // ESC键关闭详情
     document.addEventListener('keydown', function(event) {
         if (event.key === 'Escape') {
             closeNoteDetail();
+            closeScoreDetail();
         }
     });
 
@@ -2129,8 +2421,8 @@ def generate_html(
         }}
 
         .info-posts-grid .search-note-item {{
-            flex: 0 0 280px;
-            min-width: 280px;
+            flex: 0 0 260px;
+            min-width: 260px;
         }}
 
         .matches-list {{
@@ -2309,7 +2601,7 @@ def generate_html(
         }}
 
         .step-section-wrapper.expanded .step-wrapper-content {{
-            max-height: 10000px;
+            max-height: 30000px;
         }}
 
         .step-number-badge {{
@@ -2501,7 +2793,7 @@ def generate_html(
         }}
 
         .step-section.expanded .step-section-content {{
-            max-height: 5000px;
+            max-height: 20000px;
         }}
 
         .match-content {{
@@ -2653,6 +2945,17 @@ def generate_html(
             margin-bottom: 20px;
         }}
 
+        .search-summary-header {{
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+            gap: 20px;
+        }}
+
+        .search-summary-left {{
+            flex: 1;
+        }}
+
         .search-summary-title {{
             font-size: 15px;
             font-weight: 700;
@@ -2672,10 +2975,51 @@ def generate_html(
             margin: 0 4px;
         }}
 
+        .search-sort-buttons {{
+            display: flex;
+            align-items: center;
+            gap: 8px;
+        }}
+
+        .search-sort-label {{
+            font-size: 13px;
+            color: #047857;
+            font-weight: 600;
+        }}
+
+        .search-sort-btn {{
+            background: white;
+            border: 1px solid #a7f3d0;
+            color: #065f46;
+            padding: 6px 12px;
+            border-radius: 6px;
+            font-size: 12px;
+            font-weight: 600;
+            cursor: pointer;
+            transition: all 0.2s;
+        }}
+
+        .search-sort-btn:hover {{
+            background: #d1fae5;
+            border-color: #6ee7b7;
+        }}
+
+        .search-sort-btn.active {{
+            background: #059669;
+            color: white;
+            border-color: #059669;
+        }}
+
+        .note-position {{
+            font-size: 11px;
+            color: #9ca3af;
+            font-weight: 600;
+        }}
+
         .search-notes-list {{
             display: grid;
-            grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
-            gap: 20px;
+            grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+            gap: 16px;
         }}
 
         .search-note-item {{
@@ -2685,6 +3029,7 @@ def generate_html(
             box-shadow: 0 2px 8px rgba(0,0,0,0.08);
             border: 1px solid #e5e7eb;
             transition: all 0.3s;
+            position: relative;
         }}
 
         .search-note-item:hover {{
@@ -2692,6 +3037,41 @@ def generate_html(
             border-color: #667eea;
         }}
 
+        .note-score-badge {{
+            position: absolute;
+            top: 12px;
+            right: 12px;
+            background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+            padding: 8px 14px;
+            border-radius: 8px;
+            display: flex;
+            flex-direction: column;
+            align-items: center;
+            gap: 2px;
+            cursor: pointer;
+            z-index: 5;
+            box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
+            transition: all 0.3s;
+        }}
+
+        .note-score-badge:hover {{
+            transform: scale(1.05);
+            box-shadow: 0 4px 12px rgba(99, 102, 241, 0.5);
+        }}
+
+        .note-score-badge .score-label {{
+            font-size: 10px;
+            color: rgba(255, 255, 255, 0.9);
+            text-transform: uppercase;
+            letter-spacing: 0.5px;
+        }}
+
+        .note-score-badge .score-value {{
+            font-size: 18px;
+            font-weight: 700;
+            color: white;
+        }}
+
         .note-image-carousel {{
             position: relative;
             width: 100%;
@@ -2813,6 +3193,15 @@ def generate_html(
             -webkit-box-orient: vertical;
         }}
 
+        .note-publish-date {{
+            font-size: 12px;
+            color: #9ca3af;
+            margin-bottom: 12px;
+            display: flex;
+            align-items: center;
+            gap: 4px;
+        }}
+
         .note-footer {{
             display: flex;
             justify-content: space-between;
@@ -3159,6 +3548,152 @@ def generate_html(
             padding: 30px;
         }}
 
+        /* 分数详情Modal样式 */
+        .score-detail-content {{
+            max-width: 800px;
+        }}
+
+        .score-detail-container {{
+            padding: 10px;
+        }}
+
+        .score-detail-title {{
+            font-size: 24px;
+            font-weight: 700;
+            color: #1f2937;
+            margin-bottom: 20px;
+            padding-bottom: 15px;
+            border-bottom: 2px solid #e5e7eb;
+        }}
+
+        .score-detail-score {{
+            background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+            padding: 20px;
+            border-radius: 12px;
+            margin-bottom: 20px;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            gap: 10px;
+        }}
+
+        .score-detail-label {{
+            font-size: 16px;
+            color: rgba(255, 255, 255, 0.9);
+            font-weight: 600;
+        }}
+
+        .score-detail-value {{
+            font-size: 32px;
+            font-weight: 700;
+            color: white;
+        }}
+
+        .score-detail-explain {{
+            background: #fffbeb;
+            padding: 16px 20px;
+            border-radius: 8px;
+            margin-bottom: 20px;
+            border-left: 3px solid #f59e0b;
+        }}
+
+        .score-input-info {{
+            background: #f0f9ff;
+            padding: 16px 20px;
+            border-radius: 8px;
+            margin-bottom: 20px;
+            border-left: 3px solid #3b82f6;
+        }}
+
+        .score-input-title {{
+            font-weight: 700;
+            font-size: 14px;
+            margin-bottom: 12px;
+            color: #1e40af;
+            text-transform: uppercase;
+            letter-spacing: 0.5px;
+        }}
+
+        .score-input-item {{
+            margin-bottom: 12px;
+            padding-bottom: 12px;
+            border-bottom: 1px solid #bfdbfe;
+        }}
+
+        .score-input-item:last-child {{
+            margin-bottom: 0;
+            padding-bottom: 0;
+            border-bottom: none;
+        }}
+
+        .score-input-label {{
+            font-weight: 600;
+            font-size: 13px;
+            color: #1e40af;
+            margin-bottom: 4px;
+        }}
+
+        .score-input-value {{
+            font-size: 14px;
+            color: #374151;
+            line-height: 1.6;
+            white-space: pre-wrap;
+            word-break: break-word;
+        }}
+
+        .score-parts-container {{
+            display: grid;
+            grid-template-columns: 1fr 1fr;
+            gap: 20px;
+        }}
+
+        .score-parts {{
+            background: #f9fafb;
+            padding: 16px;
+            border-radius: 8px;
+            border: 1px solid #e5e7eb;
+        }}
+
+        .score-parts.same-parts {{
+            background: #ecfdf5;
+            border-color: #10b981;
+        }}
+
+        .score-parts.increment-parts {{
+            background: #fef3c7;
+            border-color: #f59e0b;
+        }}
+
+        .score-parts-title {{
+            font-weight: 700;
+            font-size: 14px;
+            margin-bottom: 16px;
+            color: #374151;
+            text-transform: uppercase;
+            letter-spacing: 0.5px;
+        }}
+
+        .score-part-item {{
+            padding: 10px 0;
+            margin-bottom: 8px;
+            font-size: 14px;
+            border-bottom: 1px solid #e5e7eb;
+        }}
+
+        .score-part-item:last-child {{
+            border-bottom: none;
+        }}
+
+        .score-part-key {{
+            font-weight: 500;
+            color: #374151;
+            margin-right: 8px;
+        }}
+
+        .score-part-value {{
+            color: #6b7280;
+        }}
+
         .modal-header {{
             margin-bottom: 25px;
             padding-bottom: 20px;
@@ -3562,6 +4097,16 @@ def generate_html(
                 </div>
             </div>
         </div>
+
+        <!-- Score Detail Modal -->
+        <div id="scoreDetailModal" class="modal-overlay" onclick="closeScoreDetailModal(event)">
+            <div class="modal-content score-detail-content">
+                <button class="modal-close" onclick="closeScoreDetail()">&times;</button>
+                <div class="modal-body" id="scoreModalBody">
+                    <!-- Score detail content will be inserted here -->
+                </div>
+            </div>
+        </div>
     </div>
 
     <script>