Просмотр исходного кода

业务和可视化功能增加

xueyiming 2 часов назад
Родитель
Сommit
297b347b3d

+ 156 - 24
examples/content_needs_generation/PRD/process.md

@@ -1,54 +1,186 @@
 ## 角色定位
 
-你是一名为老年人服务的内容专家,专注于为50岁及以上老年群体发现和推荐他们最感兴趣的热门话题。
-
+你是一名严格的,为中国50岁以上中老年群体做热点内容筛选的内容专家。你非常严格的判断内容是否“适老”,你需要逐个分析给到的标题信息,根据目标用户画像的特征,剔除所有属于城市年轻群体、中产阶级焦虑、高认知门槛、语义模糊或与老年人生活脱节的“噪音”,保留符合「中国 50 岁以上中老年人」用户画像的内容。
 ## 核心任务
 
-通过使用现有工具和技能,系统性地寻找、筛选和分析老年人最可能喜欢的热门话题,并输出推荐结果。
+通过使用现有工具和技能,系统性地寻找、筛选和分析老年人最可能喜欢的热门话题,并输出推荐结果。在筛选过程中,必须严格按照用户画像和拦截标准进行判断。
+
+## 一、基础定义(严格遵守,不可修改)
+
+### 用户画像:中国50岁以上老年人
+
+1. **认知特点**: 追求"确定性"和"安全感"。偏好简单直白的内容,拒绝烧脑、逻辑复杂或需要推理的内容。拒绝一切"盲盒式"标题(如"这件事千万别做"但没说什么事)。不关注新事物,不关注抽象的宏观经济指标、复杂的金融博弈、枯燥的行政程序
+
+2. **文化背景**: 成长于上世纪50~70年代,传统观念根深蒂固。对网络梗、亚文化、职场黑话、微短剧逻辑不敏感甚至反感。深受儒家文化影响,拥有强烈的孝道观念和集体主义倾向,对投资及房产等与年轻人生活相关度高的信息不感兴趣, 处于"安享期"而非"奋斗期"。关注"保命"(三高、心脏、防骗)而非"塑形"(减肥、发际线);关注"存量财产安全"而非"增量资产博弈"。更倾向于追求身心安宁及正能量内容,不喜欢高强度、信息量大的娱乐内容。
+
+3. **情感需求**: "安逸"、"从容"、"被尊重"。倾向于追求身心安宁、正能量、民族自豪感。反感贩卖焦虑、激烈的矛盾冲突、血腥暴力或过于悲惨的负面新闻。偏好娱乐放松、激发民族自豪感的国家大事、与中国相关的重大国际形式、民生生活、弘扬社会正能量、家庭生活、传统文化、时事政务、同龄人出镜的内容,需要被"认同感"及"群体认同感",偏好接地气、贴近生活、贴近自身的内容,叙事风格
+
+4. **场景偏好**: 菜市场、公园、家庭、医院、老友聚会等怀旧场景。排斥写字楼、夜店、高端滑雪场、极限运动场所。
+
+### 适老品类库(白名单)
+
+只有核心内容属于以下分类,才具备"品类适老系数=1"的基础条件:
+
+*   **国家力量/统一**: 阅兵、基建狂魔、外交胜利、撤侨、领土主权、两岸统一(不含晦涩的地缘政治分析),以非具体正能量人物、非科学、技术为主要内容;反映中国人文、文化、基建、人民生活、国内外对比中强大的内容
+*   **知识科普**:非生活技巧、非科技自然 的 文化、历史、人文、健康等社科知识类科普视
+*   **惠民/民生政策**: 养老金调整、医保报销、现金支付保障、菜篮子物价、天气预警(需具体利民,非枯燥公文)。
+*   **人财诈骗/防骗**: 电诈案例、新型毒品伪装、保健品骗局(极高优先级)。
+*   **老年健康**: 三高管理、心脑血管、养生食疗、长寿知识(**严格剔除**减肥、塑形、医美、脱发焦虑)。
+*   **怀念时光**: 70年代及以前的老照片、老电影切片(非影评)、经典红歌。
+*   **家庭/亲子**: 隔辈亲、孝道故事、家庭邻里互助(**严格剔除**婆媳恶斗、剧烈伦理冲突)。
+*   **传统文化/习俗**: 节气、民俗、戏曲、国学、非遗。
+*   **正能量/社会风气**: 见义勇为、拾金不昧、反腐倡廉、平凡人的善举,以具体中国当代正能量人物为主要描述对象的内容
+*   **惊奇/罕见画面**: 自然奇观、动物趣闻(**严格剔除**血腥、恐怖、猎奇阴暗面)。
+
+## 二、红灯拦截标准(触犯即死,优先级最高)
+
+**不仅要检查负面词汇,更要检查"逻辑不适老",凡触犯以下任意一条,分值直接归零:**
+
+1.  **信源模糊/标题党拦截**:
+    *   标题缺乏具体的**人名、地名、机构名**,无法作为搜索关键词的(如"最爱发钱的老板招工了"、"我看这地儿不错")。
+    *   主语不明、指代不清的(如"注意!这东西不能吃"、"他竟然这样做")。
+    *   个人Vlog式、无公共信息价值的感悟(如"如果我在冬天失业了")。
+
+2.  **生命阶段错位拦截**:
+    *   **职场焦虑**:涉及"月薪、大厂、内卷、求职(非银发专岗)、降薪、裁员、年终奖"。
+    *   **中年健康焦虑**:涉及"减肥、瘦身、代谢变慢、发际线、抗初老、精力管理"。
+    *   **高危活动**:涉及"滑雪、潜水、马拉松、蹦极、赛车"等不适合老年人生理机能的活动(即使是免费票也要拦截)。
+
+3.  **低质娱乐/亚文化拦截**:
+    *   **微短剧/网剧**:标题含"微短剧、网剧、霸总、逆袭、重生"等,此类内容多为虚构浮夸且含诱导付费。
+    *   **网络热梗**:含"破防、yyds、绝绝子、CP感、谐音梗营销",网络热梗段子、恶搞视频、二次元的游戏黑话等
+    *   **专业/小众体育**:NBA交易、欧洲足球战术、球星转会费、网球/斯诺克小众赛事(除非是**国家队/为国争光**)。
+    *   **长辈无关娱乐圈**:年轻流量偶像八卦、饭圈互撕、国外网红动态。
+
+4.  **金融与商业噪音拦截**:
+    *   涉及"板块、指数、美联储、加息、IPO、资产配置、套利"的金融博弈。
+    *   涉及"金银价格剧烈波动分析"(如"K线、点位、抄底"),只保留单纯的实物金价涨跌。
+    *   明显的商业软文、带货广告、陌生APP公测。
+
+5.  **负面与恐慌拦截**:
+    *   过于血腥、暴力、违背伦理的案件细节(如"强奸、分尸、虐待")。
+    *   纯粹贩卖焦虑而无解决方案的内容。
+
+6. **行政公文拦截**:没有具体利民事件的,一律不通过。
+
+7. **宏观自豪感拦截**: 只有国家领导人会面而无具体实惠或震撼性视觉成果(如大型阅兵、卫星发射成功)的,一律不通过。
 
 ## 工作流程
 
+### 工具使用说明
+
+- **数据来源工具**(用于获取选题):
+  *   `hot_rank_search`:获取热榜内容(热榜来源)
+  *   `browser`:基于日期搜索话题(日期来源)
+
 ### 1. 理解老年人群体特点
-- 关注健康养生、家庭亲情、生活智慧、历史文化等老年人普遍关心的领域
-- 考虑老年人的阅读习惯、信息接收方式和兴趣偏好
-- 优先选择正面、实用、易懂的内容主题
+- 严格按照【一、基础定义】中的用户画像进行判断
+- 参考适老品类库(白名单)确定内容是否属于适老品类
+- 优先选择符合老年人价值观和需求的话
 
-### 2. 使用工具获取热门话题
+### 2. 使用工具获取热门话题(热榜来源)
 - 使用 `hot_rank_search` 工具获取当前热门话题数据
 - 根据"最热"或"最新"排序方式获取热榜内容
 - 可以多次调用工具获取更全面的数据
-- **注意**:热门榜单搜索是必需步骤,但不能单独作为依据,必须与日期搜索结果结合后再做最终推荐判断
+- **注意**:热榜搜索是独立的数据来源,需要单独进行筛选和输出
 
-### 3. 基于日期搜索话题
+### 3. 基于日期搜索话题(日期来源)
 - **日期范围**:获取当前日期,并计算前后7天的日期范围(前7天至后7天,共15天)
 - **搜索策略**:
-  - 使用当前日期搜索当天的热门话题和内容
-  - 使用前7天的日期逐一搜索历史热门话题,了解近期趋势和持续性话题
-  - 使用后7天的日期搜索即将到来的话题(如节日、纪念日、重要事件等)
+  - 使用当前日期和后7天的日期搜索即将到来的话题(如节日、纪念日、重要事件等)
   - 通过多日期搜索,获取更全面的话题覆盖范围,避免遗漏重要内容
 - **执行方式**:
-  - 系统性地遍历前后7天的日期范围
+  - 系统性地遍历当天日期和后7天的日期范围
   - 对重要的日期调用 `browser` 工具进行搜索
   - 记录和整理不同日期搜索到的热门话题
+- **注意**:日期搜索是独立的数据来源,需要单独进行筛选和输出
 
 ### 4. 筛选和评估话题
-- 分析每个热门话题与老年人兴趣的匹配度
-- 评估话题的正面性、实用性和可理解性
-- 优先选择符合老年人价值观和需求的话题
-- 在做出最终推荐前,必须将“热门榜单搜索结果”和“日期范围搜索结果”进行对比和融合,避免只依赖其中任意一种来源
+
+**重要**:热榜来源和日期来源是两种独立的数据来源,需要分别进行筛选和评估,分别输出结果,不要融合。
+
+对每个话题(无论来自热榜还是日期搜索)进行严格的筛选和评分,按照以下步骤执行:
+
+#### 第一步:红灯熔断审查
+检查标题是否触犯【二、红灯拦截标准】。
+*   **若触犯任意一条**:最终综合得分直接为 0.00,判定为"不通过",reason中明确指出触犯了哪条红灯。
+*   **若未触犯**:进入第二步。
+
+#### 第二步:用户画像维度打分(0.00 - 1.00)
+请根据【一、基础定义】中的用户画像对以下四个维度进行最严格的独立打分,如果信息模糊则全部用最低分值判断:
+1.  **S1 认知特点 (权重 20%)**:对应用户画像进行判断(模糊不清/专业术语/黑话=0分)
+2.  **S2 文化背景 (权重 30%)**:对应用户画像进行判断(个人主义/挑战传统/崇洋媚外=0分)
+3.  **S3 情感需求 (权重 30%)**:对应用户画像进行判断(焦虑/恐惧/悲惨/激烈冲突=0分)
+4.  **S4 场景偏好 (权重 20%)**:对应用户画像进行判断(CBD/夜店/极限运动/国外陌生场景=0分)
+
+#### 第三步:短板效应与画像分计算
+*   **公式**:
+    IF (S1 < 0.6 OR S2 < 0.6 OR S3 < 0.6):
+        Persona_Score = Min(S1, S2, S3)  (任意核心维度不及格,则整体不及格)
+    ELSE:
+        Persona_Score = (S1 * 0.2) + (S2 * 0.3) + (S3 * 0.3) + (S4 * 0.2)
+
+#### 第四步:最终判定
+*   **品类系数 K**:
+    *   内容核心属于白名单品类:K = 1
+    *   内容核心不属于白名单(如科技、游戏、职场、二次元等):K = 0
+*   **最终综合得分** = Persona_Score * K
 
 ### 5. 输出推荐结果
-- 按照指定的JSON格式输出推荐的热门话题
-- 为每个话题提供清晰的推荐理由
-- 理由应说明该话题为什么适合老年人群体
+
+仅输出 JSON 格式,无多余解释。必须分别输出热榜来源和日期来源的结果,格式如下:
+
+```json
+{
+  "热榜来源": [
+    {
+      "标题内容": "原标题",
+      "最终综合得分": 0.00,
+      "画像维度得分": {
+        "S1_认知": 0.00,
+        "S2_文化": 0.00,
+        "S3_情感": 0.00,
+        "S4_场景": 0.00
+      },
+      "用户画像匹配分(Persona_Score)": 0.00,
+      "品类系数(K)": 1或0,
+      "匹配品类": "最匹配的白名单分类或'无'",
+      "reason": "简要说明理由,若拦截需指出具体原因(如:信源模糊、生命阶段错位、红灯拦截等)"
+    }
+  ],
+  "日期来源": [
+    {
+      "标题内容": "原标题",
+      "最终综合得分": 0.00,
+      "画像维度得分": {
+        "S1_认知": 0.00,
+        "S2_文化": 0.00,
+        "S3_情感": 0.00,
+        "S4_场景": 0.00
+      },
+      "用户画像匹配分(Persona_Score)": 0.00,
+      "品类系数(K)": 1或0,
+      "匹配品类": "最匹配的白名单分类或'无'",
+      "reason": "简要说明理由,若拦截需指出具体原因(如:信源模糊、生命阶段错位、红灯拦截等)"
+    }
+  ]
+}
+```
 
 ## 执行要求
 
 - 在调用工具前,先说明调用原因和参数生成逻辑
-- **组合搜索要求**:
+- **独立搜索要求**:
   - 必须同时完成基于热榜的搜索(如使用 `hot_rank_search`)和基于日期范围的搜索(如结合 `browser` 或其他工具)
   - 不允许只完成其中一种搜索就直接输出推荐结果
-  - 在思考和输出中,需要明确说明:两类搜索分别得到的关键信息,以及它们是如何被综合之后才形成最终推荐结论的
+  - **重要**:热榜来源和日期来源是两种独立的数据来源,需要分别进行筛选和评估,分别输出结果,不要融合
+  - 在思考和输出中,需要明确说明:两类搜索分别得到的关键信息,并分别展示各自的筛选结果
+- **严格筛选要求**:
+  - 对每个话题(无论来自热榜还是日期搜索)必须执行完整的四步筛选流程(红灯熔断审查 → 用户画像维度打分 → 短板效应计算 → 最终判定)
+  - 必须严格按照【二、红灯拦截标准】进行拦截,触犯任意一条即判定为不通过
+  - 必须严格按照【一、基础定义】中的用户画像进行评分,信息模糊时使用最低分值
+  - 必须明确标注每个话题的匹配品类(白名单分类)或标注为"无"
 - 展示思考过程,包括如何筛选和评估话题
-- 确保输出的每个话题都有充分的推荐理由
-- 基于实际数据进行分析,不自行联想或添加不存在的信息
+- 确保输出的每个话题都有充分的推荐理由,若被拦截需明确指出具体原因
+- 基于实际数据进行分析,不自行联想或添加不存在的信息
+- 输出格式必须严格按照JSON格式,分别输出"热榜来源"和"日期来源"两个数组,每个数组包含筛选后的话题列表

+ 0 - 4
examples/content_needs_generation/content_needs_generation.prompt

@@ -13,7 +13,3 @@ $system$
 {process}
 
 
-$user$
-
-输出信息
-{output}

+ 50 - 16
examples/content_needs_generation/process_messages.py

@@ -50,11 +50,8 @@ def format_message(msg: Dict[str, Any], messages: List[Dict[str, Any]]) -> Dict[
     result = {
         "sequence": msg.get('sequence'),
         "role": msg.get('role'),
-        "message_id": msg.get('message_id'),
         "parent_sequence": msg.get('parent_sequence'),
         "status": msg.get('status'),
-        "goal_id": msg.get('goal_id'),
-        "created_at": msg.get('created_at'),
     }
 
     # 处理content
@@ -66,9 +63,15 @@ def format_message(msg: Dict[str, Any], messages: List[Dict[str, Any]]) -> Dict[
         result["text"] = content.get('text', '')
         result["content"] = content
 
-    # 处理description
-    if msg.get('description'):
-        result["description"] = msg.get('description')
+    # 生成title:取text的前60个字符加省略号
+    text = result.get('text', '')
+    if text:
+        if len(text) > 60:
+            result["title"] = text[:60] + "..."
+        else:
+            result["title"] = text
+    else:
+        result["title"] = ""
 
     # 处理tokens信息
     if msg.get('tokens') is not None:
@@ -115,17 +118,47 @@ def format_message(msg: Dict[str, Any], messages: List[Dict[str, Any]]) -> Dict[
                                                                                              dict) else tool_result.get(
                             'content'),
                         "status": tool_result.get('status'),
-                        "created_at": tool_result.get('created_at'),
                     }
 
                 result["children"].append(tool_node)
 
-    # 如果是tool消息,添加工具相关信息
+    # 如果title为空(text不存在),尝试从children列表的最后一个对象的result.result中获取
+    if not result.get("title") or result["title"].strip() == "":
+        children = result.get("children", [])
+        if children:
+            last_child = children[-1]
+            if last_child.get("result") and last_child["result"].get("result"):
+                result_text = str(last_child["result"]["result"])
+                if result_text:
+                    # 优先匹配 "Summary:" 后面的字符
+                    summary_match = None
+                    if "Summary:" in result_text:
+                        # 查找 "Summary:" 后面的内容
+                        summary_index = result_text.find("Summary:")
+                        if summary_index != -1:
+                            summary_text = result_text[summary_index + len("Summary:"):].strip()
+                            # 取到换行符或前60个字符
+                            if "\n" in summary_text:
+                                summary_text = summary_text.split("\n")[0].strip()
+                            if summary_text:
+                                if len(summary_text) > 60:
+                                    summary_match = summary_text[:60] + "..."
+                                else:
+                                    summary_match = summary_text
+                    
+                    if summary_match:
+                        result["title"] = summary_match
+                    else:
+                        # 如果不存在Summary,则从result.result中获取前60个字符
+                        if len(result_text) > 60:
+                            result["title"] = result_text[:60] + "..."
+                        else:
+                            result["title"] = result_text
+
+    # tool消息不单独创建记录,结果已经放在assistant消息的children中
+    # 如果是tool消息,返回None,后续会被过滤掉
     if msg.get('role') == 'tool':
-        result["tool_call_id"] = msg.get('tool_call_id')
-        if isinstance(content, dict):
-            result["tool_name"] = content.get('tool_name')
-            result["tool_result"] = content.get('result')
+        return None
 
     return result
 
@@ -145,11 +178,12 @@ def process_messages(messages_dir: str, output_path: str):
     messages = load_all_messages(str(messages_dir_path))
     print(f"共读取 {len(messages)} 条消息")
 
-    # 格式化所有消息
+    # 格式化所有消息,过滤掉tool消息(结果已放在assistant的children中)
     structured_messages = []
     for msg in messages:
         formatted = format_message(msg, messages)
-        structured_messages.append(formatted)
+        if formatted is not None:  # 过滤掉tool消息(返回None)
+            structured_messages.append(formatted)
 
     # 确保输出目录存在
     output_file_path.parent.mkdir(parents=True, exist_ok=True)
@@ -171,8 +205,8 @@ def process_messages(messages_dir: str, output_path: str):
 if __name__ == "__main__":
     # 使用定义的变量
     try:
-        input = ''
-        output = ''
+        input = '/Users/shimeng/Desktop/py/Agent/examples/content_needs_generation/.trace/96983ec5-ce2a-46f8-9bbf-e8d65e06f569/messages'
+        output = '/Users/shimeng/Desktop/py/Agent/examples/content_needs_generation/.trace/96983ec5-ce2a-46f8-9bbf-e8d65e06f569/output.json'
         process_messages(input, output)
     except Exception as e:
         print(f"错误: {e}")

+ 5 - 2
examples/content_needs_generation/run.py

@@ -235,11 +235,14 @@ async def main():
     from agent.tools import get_tool_registry
     tool_registry = get_tool_registry()
     registered_tools = list(tool_registry._tools.keys())
-    custom_tools = [t for t in registered_tools if "hot_rank" in t.lower()]
+    custom_tools = [
+        t for t in registered_tools 
+        if "hot_rank" in t.lower() or "content_deconstruction" in t.lower() or "query_content" in t.lower()
+    ]
     if custom_tools:
         print(f"✓ 已注册自定义工具: {custom_tools}")
     else:
-        print(f"⚠️  警告: 未找到自定义工具 'hot_rank_search'")
+        print(f"⚠️  警告: 未找到自定义工具")
         print(f"   已注册的工具: {registered_tools[:10]}...")  # 只显示前10个
 
     store = FileSystemTraceStore(base_path=TRACE_STORE_PATH)

+ 4 - 1
examples/content_needs_generation/tool/__init__.py

@@ -3,5 +3,8 @@ content_needs_generation 示例的自定义工具
 """
 
 from examples.content_needs_generation.tool.hot_rank_search import hot_rank_search
+from examples.content_needs_generation.tool.content_deconstruction_search import (
+    query_content_deconstruction_by_keywords,
+)
 
-__all__ = ["hot_rank_search"]
+__all__ = ["hot_rank_search", "query_content_deconstruction_by_keywords"]

Разница между файлами не показана из-за своего большого размера
+ 249 - 0
examples/content_needs_generation/trace_visualization.html


+ 791 - 0
examples/content_needs_generation/visualize_trace.py

@@ -0,0 +1,791 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+生成JSON跟踪文件的可视化HTML页面
+"""
+
+import json
+import os
+from pathlib import Path
+
+
+def load_json_data(json_path):
+    """加载JSON数据"""
+    with open(json_path, 'r', encoding='utf-8') as f:
+        return json.load(f)
+
+
+def calculate_grid_layout(nodes, node_width=200, node_height=120, horizontal_spacing=250, vertical_spacing=150, margin=50, screen_width=1600):
+    """
+    计算网格布局的节点位置
+    从左到右,到右边后向下再向左,呈蛇形排列,所有节点对齐到网格
+    """
+    # 按sequence排序
+    sorted_nodes = sorted(nodes, key=lambda x: x.get('sequence', 0))
+    
+    positions = {}
+    grid_positions = {}  # 存储每个节点在网格中的行列位置
+    col = 0
+    row = 0
+    direction = 1  # 1表示向右,-1表示向左
+    
+    # 计算每行可以放置的节点数
+    available_width = screen_width - 2 * margin
+    nodes_per_row = max(1, int(available_width / (node_width + horizontal_spacing)))
+    
+    for node in sorted_nodes:
+        seq = node.get('sequence', 0)
+        
+        # 检查是否需要换行
+        if direction == 1 and col >= nodes_per_row:
+            # 向右超出,向下移动,开始向左
+            row += 1
+            col = nodes_per_row - 1
+            direction = -1
+        elif direction == -1 and col < 0:
+            # 向左超出,向下移动,开始向右
+            row += 1
+            col = 0
+            direction = 1
+        
+        # 记录网格位置
+        grid_positions[seq] = {'row': row, 'col': col}
+        
+        # 计算实际像素位置(对齐到网格)
+        x = margin + col * (node_width + horizontal_spacing)
+        y = margin + row * (node_height + vertical_spacing)
+        
+        positions[seq] = {
+            'x': x,
+            'y': y,
+            'width': node_width,
+            'height': node_height,
+            'row': row,
+            'col': col
+        }
+        
+        # 移动到下一个网格位置
+        col += direction
+    
+    # 计算最大尺寸
+    max_col = max([pos['col'] for pos in grid_positions.values()]) if grid_positions else 0
+    max_row = max([pos['row'] for pos in grid_positions.values()]) if grid_positions else 0
+    max_x = margin + (max_col + 1) * (node_width + horizontal_spacing)
+    max_y = margin + (max_row + 1) * (node_height + vertical_spacing)
+    
+    return positions, grid_positions, max_x, max_y
+
+
+def generate_html(json_data, output_path):
+    """生成HTML可视化页面"""
+    
+    # 提取所有节点
+    nodes = []
+    node_map = {}
+    
+    for item in json_data:
+        seq = item.get('sequence')
+        if seq is not None:
+            nodes.append(item)
+            node_map[seq] = item
+    
+    # 计算网格布局
+    positions, grid_positions, max_width, max_height = calculate_grid_layout(nodes)
+    
+    # 计算连线信息
+    def calculate_connection(from_seq, to_seq, from_pos, to_pos):
+        """计算两个节点之间的连线方向和起止点"""
+        from_row, from_col = from_pos.get('row', 0), from_pos.get('col', 0)
+        to_row, to_col = to_pos.get('row', 0), to_pos.get('col', 0)
+        
+        # 判断方向
+        if to_col > from_col:
+            direction = 'right'  # 下一个节点在右侧
+        elif to_row > from_row:
+            direction = 'down'  # 下一个节点在下侧
+        elif to_col < from_col:
+            direction = 'left'  # 下一个节点在左侧
+        else:
+            direction = 'down'  # 默认向下
+        
+        # 计算起止点(节点的最近边)
+        from_x = from_pos['x']
+        from_y = from_pos['y']
+        from_w = from_pos['width']
+        from_h = from_pos['height']
+        
+        to_x = to_pos['x']
+        to_y = to_pos['y']
+        to_w = to_pos['width']
+        to_h = to_pos['height']
+        
+        if direction == 'right':
+            # 从右侧边中点连接到左侧边中点
+            start_x = from_x + from_w
+            start_y = from_y + from_h / 2
+            end_x = to_x
+            end_y = to_y + to_h / 2
+        elif direction == 'down':
+            # 从下侧边中点连接到上侧边中点
+            start_x = from_x + from_w / 2
+            start_y = from_y + from_h
+            end_x = to_x + to_w / 2
+            end_y = to_y
+        elif direction == 'left':
+            # 从左侧边中点连接到右侧边中点
+            start_x = from_x
+            start_y = from_y + from_h / 2
+            end_x = to_x + to_w
+            end_y = to_y + to_h / 2
+        
+        return {
+            'direction': direction,
+            'start_x': start_x,
+            'start_y': start_y,
+            'end_x': end_x,
+            'end_y': end_y
+        }
+    
+    # 生成连线数据(按照sequence顺序连接相邻节点)
+    connections = []
+    sorted_sequences = sorted([node.get('sequence') for node in nodes if node.get('sequence') is not None])
+    
+    for i in range(len(sorted_sequences) - 1):
+        from_seq = sorted_sequences[i]
+        to_seq = sorted_sequences[i + 1]
+        if from_seq in positions and to_seq in positions:
+            conn = calculate_connection(from_seq, to_seq, positions[from_seq], positions[to_seq])
+            conn['from'] = from_seq
+            conn['to'] = to_seq
+            connections.append(conn)
+    
+    # 准备传递给JavaScript的数据(简化节点数据,避免循环引用)
+    nodes_js = []
+    for node in nodes:
+        node_js = {
+            'sequence': node.get('sequence'),
+            'role': node.get('role', 'unknown'),
+            'parent_sequence': node.get('parent_sequence'),
+            'status': node.get('status', 'unknown'),
+            'title': node.get('title', '无标题'),
+            'text': node.get('text', ''),
+            'tokens': node.get('tokens', 0)
+        }
+        # 处理content字段
+        content = node.get('content')
+        if content:
+            if isinstance(content, str):
+                node_js['content'] = content
+            else:
+                node_js['content'] = json.dumps(content, ensure_ascii=False, indent=2)
+        
+        # 处理children字段
+        children = node.get('children')
+        if children:
+            node_js['children'] = json.dumps(children, ensure_ascii=False, indent=2)
+        
+        nodes_js.append(node_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', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            min-height: 100vh;
+            padding: 20px;
+            overflow: auto;
+        }}
+        
+        .container {{
+            position: relative;
+            width: 100%;
+            min-width: {max_width}px;
+            min-height: {max_height}px;
+            background: rgba(255, 255, 255, 0.95);
+            border-radius: 12px;
+            box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
+            padding: 20px;
+        }}
+        
+        .node {{
+            position: absolute;
+            width: 200px;
+            height: 120px;
+            background: linear-gradient(135deg, #6366f1 0%, #7c3aed 100%);
+            border-radius: 8px;
+            box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
+            cursor: pointer;
+            transition: all 0.3s ease;
+            display: flex;
+            flex-direction: column;
+            justify-content: center;
+            align-items: center;
+            padding: 12px;
+            border: 2px solid rgba(255, 255, 255, 0.3);
+        }}
+        
+        .node:hover {{
+            transform: translateY(-5px) scale(1.05);
+            box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
+            z-index: 100;
+        }}
+        
+        .node.system {{
+            background: linear-gradient(135deg, #a855f7 0%, #be185d 100%);
+        }}
+        
+        .node.user {{
+            background: linear-gradient(135deg, #3b82f6 0%, #0284c7 100%);
+        }}
+        
+        .node.assistant {{
+            background: linear-gradient(135deg, #10b981 0%, #059669 100%);
+        }}
+        
+        .node-title {{
+            color: white;
+            font-size: 13px;
+            font-weight: 600;
+            text-align: center;
+            line-height: 1.4;
+            overflow: hidden;
+            text-overflow: ellipsis;
+            display: -webkit-box;
+            -webkit-line-clamp: 4;
+            -webkit-box-orient: vertical;
+            word-break: break-word;
+        }}
+        
+        .node-sequence {{
+            position: absolute;
+            top: 5px;
+            left: 8px;
+            color: rgba(255, 255, 255, 0.8);
+            font-size: 11px;
+            font-weight: bold;
+        }}
+        
+        .arrow {{
+            position: absolute;
+            stroke: #667eea;
+            stroke-width: 2;
+            fill: none;
+            marker-end: url(#arrowhead);
+            opacity: 0.6;
+            transition: opacity 0.3s ease;
+        }}
+        
+        .arrow:hover {{
+            opacity: 1;
+            stroke-width: 3;
+        }}
+        
+        .tooltip {{
+            position: fixed;
+            background: rgba(0, 0, 0, 0.9);
+            color: white;
+            padding: 12px 16px;
+            border-radius: 6px;
+            font-size: 13px;
+            max-width: 400px;
+            z-index: 1000;
+            pointer-events: none;
+            box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
+            display: none;
+            line-height: 1.6;
+            word-break: break-word;
+        }}
+        
+        .modal {{
+            display: none;
+            position: fixed;
+            top: 0;
+            left: 0;
+            width: 100%;
+            height: 100%;
+            background: rgba(0, 0, 0, 0.7);
+            z-index: 2000;
+            justify-content: center;
+            align-items: center;
+        }}
+        
+        .modal-content {{
+            background: white;
+            border-radius: 12px;
+            padding: 30px;
+            width: 75vw;
+            max-width: 75vw;
+            max-height: 80vh;
+            overflow-y: auto;
+            box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
+            position: relative;
+        }}
+        
+        .modal-close {{
+            position: absolute;
+            top: 15px;
+            right: 20px;
+            font-size: 28px;
+            cursor: pointer;
+            color: #999;
+            transition: color 0.3s ease;
+        }}
+        
+        .modal-close:hover {{
+            color: #333;
+        }}
+        
+        .modal-header {{
+            margin-bottom: 20px;
+            padding-bottom: 15px;
+            border-bottom: 2px solid #eee;
+        }}
+        
+        .modal-title {{
+            font-size: 20px;
+            font-weight: bold;
+            color: #333;
+            margin-bottom: 10px;
+        }}
+        
+        .modal-info {{
+            font-size: 13px;
+            color: #666;
+        }}
+        
+        .modal-body {{
+            font-size: 14px;
+            line-height: 2.2;
+            color: #444;
+        }}
+        
+        .modal-section {{
+            margin-bottom: 28px;
+        }}
+        
+        .modal-section-title {{
+            font-weight: bold;
+            color: #667eea;
+            margin-bottom: 14px;
+            font-size: 16px;
+        }}
+        
+        .modal-section-content {{
+            background: #f8f9fa;
+            padding: 18px;
+            border-radius: 6px;
+            white-space: pre-wrap;
+            word-break: break-word;
+            line-height: 1.9;
+        }}
+        
+        .stats {{
+            position: fixed;
+            top: 20px;
+            right: 20px;
+            background: rgba(255, 255, 255, 0.95);
+            padding: 15px 20px;
+            border-radius: 8px;
+            box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
+            font-size: 14px;
+            z-index: 100;
+        }}
+        
+        .stats-item {{
+            margin: 5px 0;
+            color: #333;
+        }}
+    </style>
+</head>
+<body>
+    <div class="stats">
+        <div class="stats-item"><strong>总节点数:</strong> {len(nodes)}</div>
+        <div class="stats-item"><strong>系统节点:</strong> <span id="system-count">0</span></div>
+        <div class="stats-item"><strong>用户节点:</strong> <span id="user-count">0</span></div>
+        <div class="stats-item"><strong>助手节点:</strong> <span id="assistant-count">0</span></div>
+    </div>
+    
+    <div class="container" id="container">
+        <svg style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none;">
+            <defs>
+                <marker id="arrowhead" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
+                    <polygon points="0 0, 10 3, 0 6" fill="#667eea" />
+                </marker>
+            </defs>
+        </svg>
+    </div>
+    
+    <div class="tooltip" id="tooltip"></div>
+    
+    <div class="modal" id="modal">
+        <div class="modal-content">
+            <span class="modal-close" onclick="closeModal()">&times;</span>
+            <div id="modal-body"></div>
+        </div>
+    </div>
+    
+    <script type="application/json" id="nodes-data">{json.dumps(nodes_js, ensure_ascii=False)}</script>
+    <script type="application/json" id="positions-data">{json.dumps(positions, ensure_ascii=False)}</script>
+    <script type="application/json" id="connections-data">{json.dumps(connections, ensure_ascii=False)}</script>
+    <script>
+        const nodes = JSON.parse(document.getElementById('nodes-data').textContent);
+        const positions = JSON.parse(document.getElementById('positions-data').textContent);
+        const connections = JSON.parse(document.getElementById('connections-data').textContent);
+        
+        // 统计节点类型
+        let systemCount = 0, userCount = 0, assistantCount = 0;
+        
+        // 创建节点
+        const container = document.getElementById('container');
+        const svg = container.querySelector('svg');
+        
+        nodes.forEach(node => {{
+            const seq = node.sequence;
+            const pos = positions[seq];
+            if (!pos) return;
+            
+            const role = node.role || 'unknown';
+            if (role === 'system') systemCount++;
+            else if (role === 'user') userCount++;
+            else if (role === 'assistant') assistantCount++;
+            
+            // 创建节点元素
+            const nodeEl = document.createElement('div');
+            nodeEl.className = `node ${{role}}`;
+            nodeEl.style.left = pos.x + 'px';
+            nodeEl.style.top = pos.y + 'px';
+            nodeEl.setAttribute('data-sequence', seq);
+            
+            const sequenceEl = document.createElement('div');
+            sequenceEl.className = 'node-sequence';
+            sequenceEl.textContent = `#${{seq}}`;
+            
+            const titleEl = document.createElement('div');
+            titleEl.className = 'node-title';
+            titleEl.textContent = node.title || '无标题';
+            
+            nodeEl.appendChild(sequenceEl);
+            nodeEl.appendChild(titleEl);
+            container.appendChild(nodeEl);
+            
+            // 添加事件监听
+            nodeEl.addEventListener('mouseenter', (e) => {{
+                showTooltip(e, node.text || node.title || '无内容');
+            }});
+            
+            nodeEl.addEventListener('mouseleave', () => {{
+                hideTooltip();
+            }});
+            
+            nodeEl.addEventListener('click', () => {{
+                showModal(node);
+            }});
+            
+        }});
+        
+        // 创建连线(在节点创建完成后)
+        connections.forEach(conn => {{
+            const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'line');
+            arrow.setAttribute('x1', conn.start_x);
+            arrow.setAttribute('y1', conn.start_y);
+            arrow.setAttribute('x2', conn.end_x);
+            arrow.setAttribute('y2', conn.end_y);
+            arrow.setAttribute('class', 'arrow');
+            arrow.setAttribute('data-direction', conn.direction);
+            svg.appendChild(arrow);
+        }});
+        
+        // 更新统计
+        document.getElementById('system-count').textContent = systemCount;
+        document.getElementById('user-count').textContent = userCount;
+        document.getElementById('assistant-count').textContent = assistantCount;
+        
+        // 工具提示
+        const tooltip = document.getElementById('tooltip');
+        
+        function showTooltip(event, text) {{
+            if (!text || text.trim() === '') return;
+            tooltip.textContent = text;
+            tooltip.style.display = 'block';
+            updateTooltipPosition(event);
+        }}
+        
+        function hideTooltip() {{
+            tooltip.style.display = 'none';
+        }}
+        
+        function updateTooltipPosition(event) {{
+            const x = event.clientX + 10;
+            const y = event.clientY + 10;
+            tooltip.style.left = x + 'px';
+            tooltip.style.top = y + 'px';
+        }}
+        
+        document.addEventListener('mousemove', (e) => {{
+            if (tooltip.style.display === 'block') {{
+                updateTooltipPosition(e);
+            }}
+        }});
+        
+        // 模态框
+        const modal = document.getElementById('modal');
+        const modalBody = document.getElementById('modal-body');
+        
+        function formatText(text) {{
+            if (!text) return '';
+            // 将转义字符转换为实际字符,并处理换行
+            return String(text)
+                .replace(/\\\\n/g, '\\n')
+                .replace(/\\\\t/g, '\\t')
+                .replace(/\\\\"/g, '"')
+                .replace(/\\\\'/g, "'")
+                .replace(/\\\\\\\\/g, '\\\\');
+        }}
+        
+        function showModal(node) {{
+            let html = `
+                <div class="modal-header">
+                    <div class="modal-title">节点 #${{node.sequence}}</div>
+                </div>
+            `;
+            
+            if (node.content) {{
+                let contentStr = '';
+                try {{
+                    // 尝试解析JSON字符串
+                    const contentObj = JSON.parse(node.content);
+                    
+                    // 优先显示text内容
+                    if (contentObj.text) {{
+                        contentStr = contentObj.text;
+                    }}
+                    
+                    // 然后显示其他内容
+                    if (contentObj.tool_calls && Array.isArray(contentObj.tool_calls)) {{
+                        if (contentStr) contentStr += '\\n\\n---\\n\\n';
+                        contentObj.tool_calls.forEach((call, idx) => {{
+                            if (idx > 0) contentStr += '\\n\\n';
+                            contentStr += '工具 ' + (idx + 1) + ': ' + (call.function?.name || '未知工具');
+                            if (call.function?.arguments) {{
+                                try {{
+                                    const args = JSON.parse(call.function.arguments);
+                                    contentStr += '\\n参数:\\n' + JSON.stringify(args, null, 2);
+                                }} catch (e) {{
+                                    contentStr += '\\n参数: ' + call.function.arguments;
+                                }}
+                            }}
+                        }});
+                    }} else {{
+                        // 如果不是tool_calls格式,显示其他字段(text已优先显示)
+                        const otherFields = {{}};
+                        Object.keys(contentObj).forEach(key => {{
+                            if (key !== 'text') {{
+                                otherFields[key] = contentObj[key];
+                            }}
+                        }});
+                        if (Object.keys(otherFields).length > 0) {{
+                            if (contentStr) contentStr += '\\n\\n---\\n\\n';
+                            contentStr += JSON.stringify(otherFields, null, 2);
+                        }}
+                    }}
+                }} catch (e) {{
+                    // 如果不是JSON,直接显示字符串
+                    contentStr = node.content;
+                }}
+                if (contentStr) {{
+                    html += `
+                        <div class="modal-section">
+                            <div class="modal-section-title">完整内容</div>
+                            <div class="modal-section-content">${{escapeHtml(formatText(contentStr))}}</div>
+                        </div>
+                    `;
+                }}
+            }}
+            
+            if (node.children) {{
+                let childrenStr = '';
+                try {{
+                    const children = JSON.parse(node.children);
+                    
+                    // 处理数组格式
+                    if (Array.isArray(children) && children.length > 0) {{
+                        children.forEach((child, idx) => {{
+                            childrenStr += '\\n[' + (idx + 1) + '] ';
+                            
+                            // 动态显示所有字段
+                            const fields = [];
+                            
+                            // 常见字段按顺序显示(移除 type 和 id)
+                            if (child.tool_name !== undefined) {{
+                                fields.push('工具名称: ' + child.tool_name);
+                            }}
+                            if (child.name !== undefined) {{
+                                fields.push('名称: ' + child.name);
+                            }}
+                            
+                            // 参数相关字段
+                            if (child.arguments !== undefined) {{
+                                if (typeof child.arguments === 'object' && child.arguments !== null) {{
+                                    fields.push('参数: ' + JSON.stringify(child.arguments, null, 2));
+                                }} else {{
+                                    fields.push('参数: ' + child.arguments);
+                                }}
+                            }}
+                            if (child.raw_arguments !== undefined) {{
+                                fields.push('原始参数: ' + child.raw_arguments);
+                            }}
+                            
+                            // 结果相关字段
+                            if (child.result !== undefined) {{
+                                if (typeof child.result === 'object' && child.result !== null) {{
+                                    fields.push('结果: ' + JSON.stringify(child.result, null, 2));
+                                }} else {{
+                                    fields.push('结果: ' + child.result);
+                                }}
+                            }}
+                            if (child.response !== undefined) {{
+                                if (typeof child.response === 'object' && child.response !== null) {{
+                                    fields.push('响应: ' + JSON.stringify(child.response, null, 2));
+                                }} else {{
+                                    fields.push('响应: ' + child.response);
+                                }}
+                            }}
+                            
+                            // 状态相关字段
+                            if (child.status !== undefined) {{
+                                fields.push('状态: ' + child.status);
+                            }}
+                            if (child.sequence !== undefined) {{
+                                fields.push('序列号: ' + child.sequence);
+                            }}
+                            
+                            // 显示所有字段
+                            childrenStr += fields.join('\\n');
+                            
+                            // 如果有其他未处理的字段,也显示出来(排除不需要的字段)
+                            const knownFields = ['type', 'tool_name', 'tool_call_id', 'name', 'id', 
+                                                'arguments', 'raw_arguments', 'result', 'response', 
+                                                'status', 'sequence', 'tokens', 'prompt_tokens', 
+                                                'completion_tokens', 'cost'];
+                            const otherFields = Object.keys(child).filter(key => !knownFields.includes(key));
+                            if (otherFields.length > 0) {{
+                                childrenStr += '\\n其他字段:';
+                                otherFields.forEach(key => {{
+                                    const value = child[key];
+                                    if (typeof value === 'object' && value !== null) {{
+                                        childrenStr += '\\n  ' + key + ': ' + JSON.stringify(value, null, 2);
+                                    }} else {{
+                                        childrenStr += '\\n  ' + key + ': ' + value;
+                                    }}
+                                }});
+                            }}
+                            
+                            childrenStr += '\\n\\n---\\n';
+                        }});
+                    }} 
+                    // 处理对象格式(单个child)
+                    else if (typeof children === 'object' && children !== null && !Array.isArray(children)) {{
+                        // 过滤掉不需要的字段
+                        const filtered = {{}};
+                        Object.keys(children).forEach(key => {{
+                            if (!['type', 'id', 'tool_call_id', 'tokens', 'prompt_tokens', 'completion_tokens', 'cost'].includes(key)) {{
+                                filtered[key] = children[key];
+                            }}
+                        }});
+                        childrenStr = JSON.stringify(filtered, null, 2);
+                    }}
+                    // 处理其他格式
+                    else {{
+                        childrenStr = JSON.stringify(children, null, 2);
+                    }}
+                }} catch (e) {{
+                    // 如果解析失败,直接显示原始字符串
+                    childrenStr = node.children;
+                }}
+                
+                html += `
+                    <div class="modal-section">
+                        <div class="modal-section-title">子节点 (Children)</div>
+                        <div class="modal-section-content">${{escapeHtml(formatText(childrenStr))}}</div>
+                    </div>
+                `;
+            }}
+            
+            modalBody.innerHTML = html;
+            modal.style.display = 'flex';
+        }}
+        
+        function closeModal() {{
+            modal.style.display = 'none';
+        }}
+        
+        function escapeHtml(text) {{
+            const div = document.createElement('div');
+            div.textContent = text;
+            return div.innerHTML;
+        }}
+        
+        // 点击模态框外部关闭
+        modal.addEventListener('click', (e) => {{
+            if (e.target === modal) {{
+                closeModal();
+            }}
+        }});
+        
+        // ESC键关闭模态框
+        document.addEventListener('keydown', (e) => {{
+            if (e.key === 'Escape') {{
+                closeModal();
+            }}
+        }});
+    </script>
+</body>
+</html>"""
+    
+    # 写入文件
+    with open(output_path, 'w', encoding='utf-8') as f:
+        f.write(html_content)
+    
+    print(f"✅ 可视化页面已生成: {output_path}")
+
+
+def main():
+    """主函数"""
+    # 获取脚本所在目录
+    script_dir = Path(__file__).parent
+    
+    # JSON文件路径
+    json_path = script_dir / '.trace' / '96983ec5-ce2a-46f8-9bbf-e8d65e06f569' / 'output.json'
+    
+    # 输出HTML文件路径
+    output_path = script_dir / 'trace_visualization.html'
+    
+    if not json_path.exists():
+        print(f"❌ 错误: 找不到JSON文件: {json_path}")
+        return
+    
+    print(f"📖 正在读取JSON文件: {json_path}")
+    json_data = load_json_data(json_path)
+    
+    print(f"📊 找到 {len(json_data)} 个节点")
+    print(f"🎨 正在生成可视化页面...")
+    
+    generate_html(json_data, output_path)
+    
+    print(f"\n✨ 完成! 请在浏览器中打开: {output_path}")
+
+
+if __name__ == '__main__':
+    main()

Некоторые файлы не были показаны из-за большого количества измененных файлов