guantao 21 часов назад
Родитель
Сommit
43d722b872

+ 1 - 1
examples/process_pipeline/db_requirements.json

@@ -103,7 +103,7 @@
   "GPT Image 2 vs nanobanana",
   "nano banana",
   "GPT Image 2 评测",
-  "用AI生成真实感人物照片",
+  "用AI生成真实感人物照片,优先agent可用",
   "test",
   "用AI生成人物写真组图",
   "按url搜索到的6条帖子",

+ 102 - 0
examples/process_pipeline/prompts/apply_to_grounding.schema.json

@@ -0,0 +1,102 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "type": "object",
+  "additionalProperties": false,
+  "properties": {
+    "fragments-boundary": {
+      "type": "array",
+      "items": {
+        "type": "object",
+        "additionalProperties": false,
+        "properties": {
+          "fragment_id-ref": {
+            "type": "string"
+          },
+          "apply_to": {
+            "type": "object",
+            "additionalProperties": false,
+            "properties": {
+              "实质": {
+                "type": "array",
+                "minItems": 1,
+                "maxItems": 3,
+                "items": {
+                  "type": "object",
+                  "additionalProperties": false,
+                  "properties": {
+                    "category_id-ref": {
+                      "type": "integer"
+                    },
+                    "category_path-ref": {
+                      "type": "string"
+                    },
+                    "element-ref": {
+                      "type": [
+                        "string",
+                        "null"
+                      ]
+                    },
+                    "rationale": {
+                      "type": "string"
+                    }
+                  },
+                  "required": [
+                    "category_id-ref",
+                    "category_path-ref",
+                    "rationale"
+                  ]
+                }
+              },
+              "形式": {
+                "type": "array",
+                "minItems": 1,
+                "maxItems": 3,
+                "items": {
+                  "type": "object",
+                  "additionalProperties": false,
+                  "properties": {
+                    "category_id-ref": {
+                      "type": "integer"
+                    },
+                    "category_path-ref": {
+                      "type": "string"
+                    },
+                    "element-ref": {
+                      "type": [
+                        "string",
+                        "null"
+                      ]
+                    },
+                    "rationale": {
+                      "type": "string"
+                    }
+                  },
+                  "required": [
+                    "category_id-ref",
+                    "category_path-ref",
+                    "rationale"
+                  ]
+                }
+              }
+            },
+            "required": [
+              "实质",
+              "形式"
+            ]
+          },
+          "ideal_path-ref": {
+            "type": "string"
+          }
+        },
+        "required": [
+          "fragment_id-ref",
+          "apply_to",
+          "ideal_path-ref"
+        ]
+      }
+    }
+  },
+  "required": [
+    "fragments-boundary"
+  ]
+}

+ 14 - 15
examples/process_pipeline/prompts/extract_workflow.prompt

@@ -4,7 +4,7 @@ temperature: 0.1
 
 $system$
 
-你是 AI 图片制作工序沉淀助手。本阶段是 Stage 1:只抽取语义,不做内容树映射。
+你是 AI 图片制作工序沉淀助手。
 
 # 任务概述
 
@@ -21,7 +21,6 @@ $system$
 - 可选步骤也应提取。
 - step 是薄壳:只装结构性元数据(step_id、order、phase、relation、body),不含 capability 字段。
 - 若原帖纯营销、信息密度太低或完全没怎么做,则 skip=true。
-- 不要调用任何工具,不要查树。
 
 # step 字段
 
@@ -48,7 +47,7 @@ $system$
 每个 fragment 包含完整 capability 字段:
 
 - fragment_id:字符串,见上方规则
-- action:{ main_action, mechanism },见下方 action 字段规则
+- action:字符串,见下方 action 字段规则
 - inputs / outputs:结构化接口,见下方规则
 - body:该原子操作在原帖中的描述(可能是 step body 的子片段);未提及则为 null
 - effects:该原子操作产生的可观测效果,数组,每项为结构体(见下方 effects 字段规则)
@@ -61,19 +60,18 @@ $system$
 
 # action 字段
 
-action 写成对象:
+action 是一个字符串。
 
-```json
-{ "main_action": "编辑", "mechanism": "局部重绘" }
-```
+定义:描述信息本身发生了什么变化,独立于变化的意图、使用场景、输入来源和输出去向。该字段必须是一个汉语动词,能单独作谓语;
+
+判断标准:
+- 去掉主语和宾语后,这个词仍然能独立表达一种变化 → 是动作
+- 混入了操作对象 → 不是动作,如"换脸"应写为"替换"
+- 混入了意图或场景 → 不是动作,如"修复划痕"应写为"修复"
 
-- main_action 从以下选择:生成 / 编辑 / 提取 / 组织 / 筛选
-- mechanism 是 main_action 的细分:
-  - 生成:直接生成 / 参考引导 / 一致性保持 / 动画化 / 多模态合成 / 多候选生成
-  - 编辑:局部重绘 / 风格迁移 / 颜色调整 / 蒙板重绘 / 拼接组合 / 裁切扩展
-  - 提取:提示词反推 / 关键帧提取 / 蒙板提取 / 知识库检索 / 特征向量化
-  - 组织:分类入库 / 模板化 / 标签化 / 变量抽象 / 结构抽象
-  - 筛选:抽卡选优 / 评分排序top k / 人工挑选 / 阈值过滤
+
+举例(仅供参考,不限于此):
+生成、替换、融合、提取、修复、增强、分离、筛选、压缩、扩展、插值
 
 # inputs / outputs
 
@@ -150,7 +148,7 @@ $user$
   "fragments": [
     {
       "fragment_id": "f_s1_0",
-      "action": { "main_action": "生成", "mechanism": "直接生成" },
+      "action": "直接生成",
       "inputs": [
         {
           "modality": "文本",
@@ -191,3 +189,4 @@ $user$
 - 不要任何前言、解释、标题。
 - 字符串值内禁止出现 ASCII 双引号;需要引号请用中文书名号。
 - effects 的每项都必须以"实现"开头。
+

+ 244 - 67
examples/process_pipeline/prompts/extract_workflow.schema.json

@@ -1,135 +1,312 @@
 {
   "$schema": "http://json-schema.org/draft-07/schema#",
-  "title": "extract_workflow_output_v6",
+  "title": "extract_workflow_output_v7",
   "type": "object",
-  "required": ["skip", "skip_reason", "workflow", "fragments"],
+  "required": [
+    "skip",
+    "skip_reason",
+    "workflow",
+    "fragments-boundary"
+  ],
   "properties": {
-    "skip": { "type": "boolean" },
-    "skip_reason": { "type": "string" },
+    "skip": {
+      "type": "boolean"
+    },
+    "skip_reason": {
+      "type": "string"
+    },
     "workflow": {
       "anyOf": [
-        { "type": "null" },
+        {
+          "type": "null"
+        },
         {
           "type": "object",
-          "required": ["steps"],
+          "required": [
+            "workflow_id-ref",
+            "steps-boundary"
+          ],
           "properties": {
-            "workflow_id": { "type": ["string", "null"] },
-            "steps": {
+            "workflow_id-ref": {
+              "type": [
+                "string",
+                "null"
+              ]
+            },
+            "steps-boundary": {
               "type": "array",
               "minItems": 1,
               "items": {
                 "type": "object",
-                "required": ["step_id", "order", "phase", "relation", "body"],
+                "required": [
+                  "step_id-ref",
+                  "order",
+                  "phase",
+                  "relation",
+                  "body"
+                ],
                 "properties": {
-                  "step_id": { "type": "string", "pattern": "^s[0-9]+$" },
-                  "order": { "type": "integer", "minimum": 1 },
+                  "step_id-ref": {
+                    "type": "string",
+                    "pattern": "^s[0-9]+$"
+                  },
+                  "order": {
+                    "type": "integer",
+                    "minimum": 1
+                  },
                   "phase": {
                     "type": "string",
-                    "enum": ["非制作", "预处理", "生成", "编辑"]
+                    "enum": [
+                      "非制作",
+                      "预处理",
+                      "生成",
+                      "编辑"
+                    ]
                   },
-                  "relation": { "type": "string", "minLength": 1 },
-                  "body": { "type": ["string", "null"] }
-                }
+                  "relation": {
+                    "type": "string",
+                    "minLength": 1
+                  },
+                  "body": {
+                    "type": [
+                      "string",
+                      "null"
+                    ]
+                  }
+                },
+                "additionalProperties": false
               }
             }
-          }
+          },
+          "additionalProperties": false
         }
       ]
     },
-    "fragments": {
+    "fragments-boundary": {
       "type": "array",
       "items": {
         "type": "object",
         "required": [
-          "fragment_id", "action", "inputs", "outputs",
-          "body", "effects", "control_target", "artifact_type",
-          "tools", "apply_to_draft",
-          "workflow_step_ref", "is_alternative_to"
+          "fragment_id-ref",
+          "action",
+          "inputs-boundary",
+          "outputs-boundary",
+          "body",
+          "effects-boundary",
+          "control_target",
+          "artifact_type",
+          "tools-boundary",
+          "apply_to_draft-boundary",
+          "workflow_step_ref",
+          "is_alternative_to-boundary"
         ],
         "properties": {
-          "fragment_id": { "type": "string", "minLength": 1 },
+          "fragment_id-ref": {
+            "type": "string",
+            "pattern": "^(f_s[0-9]+_[0-9]+|f_standalone_[0-9]+)$"
+          },
           "action": {
-            "type": "object",
-            "required": ["main_action", "mechanism"],
-            "properties": {
-              "main_action": { "type": "string", "minLength": 1 },
-              "mechanism": { "type": "string", "minLength": 1 }
-            }
+            "type": "string",
+            "minLength": 1
           },
-          "inputs": {
+          "inputs-boundary": {
             "type": "array",
             "items": {
               "type": "object",
-              "required": ["modality", "description", "relation"],
+              "required": [
+                "modality",
+                "description",
+                "relation"
+              ],
               "properties": {
-                "modality": { "type": "string", "minLength": 1 },
-                "description": { "type": "string", "minLength": 1 },
-                "relation": { "type": "string", "minLength": 1 }
-              }
+                "modality": {
+                  "type": "string",
+                  "enum": [
+                    "文本",
+                    "图片",
+                    "视频",
+                    "音频",
+                    "特征点",
+                    "参数",
+                    "模型",
+                    "向量"
+                  ]
+                },
+                "description": {
+                  "type": "string",
+                  "minLength": 1
+                },
+                "relation": {
+                  "type": "string",
+                  "minLength": 1
+                }
+              },
+              "additionalProperties": false
             }
           },
-          "outputs": {
+          "outputs-boundary": {
             "type": "array",
             "items": {
               "type": "object",
-              "required": ["modality", "description", "relation"],
+              "required": [
+                "modality",
+                "description",
+                "relation"
+              ],
               "properties": {
-                "modality": { "type": "string", "minLength": 1 },
-                "description": { "type": "string", "minLength": 1 },
-                "relation": { "type": "string", "minLength": 1 }
-              }
+                "modality": {
+                  "type": "string",
+                  "enum": [
+                    "文本",
+                    "图片",
+                    "视频",
+                    "音频",
+                    "特征点",
+                    "参数",
+                    "模型",
+                    "向量"
+                  ]
+                },
+                "description": {
+                  "type": "string",
+                  "minLength": 1
+                },
+                "relation": {
+                  "type": "string",
+                  "minLength": 1
+                }
+              },
+              "additionalProperties": false
             }
           },
-          "body": { "type": ["string", "null"] },
-          "effects": {
+          "body": {
+            "type": [
+              "string",
+              "null"
+            ]
+          },
+          "effects-boundary": {
             "type": "array",
+            "minItems": 1,
             "items": {
               "type": "object",
-              "required": ["statement", "criteria", "judge_method", "negative_examples"],
+              "required": [
+                "statement",
+                "criteria",
+                "judge_method",
+                "negative_examples-boundary"
+              ],
               "properties": {
-                "statement": { "type": "string", "pattern": "^实现" },
-                "criteria": { "type": "string", "minLength": 1 },
-                "judge_method": { "type": "string", "enum": ["llm", "vlm", "rule", "human"] },
-                "negative_examples": { "type": "array", "items": { "type": "string", "minLength": 1 }, "default": [] }
-              }
+                "statement": {
+                  "type": "string",
+                  "pattern": "^实现"
+                },
+                "criteria": {
+                  "type": "string",
+                  "minLength": 1
+                },
+                "judge_method": {
+                  "type": "string",
+                  "enum": [
+                    "llm",
+                    "vlm",
+                    "rule",
+                    "human"
+                  ]
+                },
+                "negative_examples-boundary": {
+                  "type": "array",
+                  "items": {
+                    "type": "string",
+                    "minLength": 1
+                  }
+                }
+              },
+              "additionalProperties": false
             }
           },
           "control_target": {
             "type": "array",
-            "items": { "type": "string", "minLength": 1 }
+            "items": {
+              "type": "string",
+              "minLength": 1
+            }
           },
-          "artifact_type": { "type": ["string", "null"] },
-          "tools": {
+          "artifact_type": {
+            "type": [
+              "string",
+              "null"
+            ]
+          },
+          "tools-boundary": {
             "type": "array",
-            "items": { "type": "string" }
+            "items": {
+              "type": "string",
+              "minLength": 1
+            }
           },
-          "apply_to_draft": {
+          "apply_to_draft-boundary": {
             "type": "object",
-            "required": ["实质", "形式"],
+            "required": [
+              "实质-boundary",
+              "形式-boundary"
+            ],
             "properties": {
-              "实质": { "type": "array", "items": { "type": "string" } },
-              "形式": { "type": "array", "items": { "type": "string" } }
-            }
+              "实质-boundary": {
+                "type": "array",
+                "items": {
+                  "type": "string",
+                  "minLength": 1
+                }
+              },
+              "形式-boundary": {
+                "type": "array",
+                "items": {
+                  "type": "string",
+                  "minLength": 1
+                }
+              }
+            },
+            "additionalProperties": false
           },
           "workflow_step_ref": {
             "anyOf": [
-              { "type": "null" },
+              {
+                "type": "null"
+              },
               {
                 "type": "object",
-                "required": ["step_id"],
+                "required": [
+                  "workflow_id-ref",
+                  "step_id-ref"
+                ],
                 "properties": {
-                  "workflow_id": { "type": "string" },
-                  "step_id": { "type": "string", "pattern": "^s[0-9]+$" }
-                }
+                  "workflow_id-ref": {
+                    "type": [
+                      "string",
+                      "null"
+                    ]
+                  },
+                  "step_id-ref": {
+                    "type": "string",
+                    "pattern": "^s[0-9]+$"
+                  }
+                },
+                "additionalProperties": false
               }
             ]
           },
-          "is_alternative_to": {
+          "is_alternative_to-boundary": {
             "type": "array",
-            "items": { "type": "string" }
+            "items": {
+              "type": "string",
+              "pattern": "^(f_s[0-9]+_[0-9]+|f_standalone_[0-9]+)$"
+            }
           }
-        }
+        },
+        "additionalProperties": false
       }
     }
-  }
-}
+  },
+  "additionalProperties": false
+}

+ 5 - 5
examples/process_pipeline/run_pipeline.py

@@ -387,8 +387,8 @@ async def run_anthropic_sdk_task(prompt_name: str, kwargs: dict, task_name: str,
                 # 去除前缀(兼容比如 openrouter 传入的名字)
                 clean_model = model_name.split("/")[-1] if "/" in model_name else model_name
                 
-                # 这里专门将实际请求映射到其可用的特殊别名 claude-sonnet-4-5 
-                target_model = "claude-sonnet-4-5" if "claude" in clean_model else clean_model
+                # 这里专门将实际请求映射到其可用的特殊别名 claude-sonnet-4-6 
+                target_model = "claude-sonnet-4-6" if "claude" in clean_model else clean_model
 
                 response = await client.messages.create(
                     model=target_model,
@@ -465,7 +465,7 @@ async def run_anthropic_sdk_task(prompt_name: str, kwargs: dict, task_name: str,
                 "content": f"【系统强制指令】你的任务阶段已完成分析,但尚未将最终结果写入目标文件。请立刻调用 write_json (或 write_file) 工具,将你的成果数据直接写入到绝对路径 `{out_file}`,务必立刻执行写入动作!"
             })
             try:
-                target_model = "claude-sonnet-4-5" if "claude" in clean_model else clean_model
+                target_model = "claude-sonnet-4-6" if "claude" in clean_model else clean_model
                 rec_response = await client.messages.create(
                     model=target_model,
                     max_tokens=4096,
@@ -763,7 +763,7 @@ async def main():
     # 根据 --use-claude-sdk 参数选择 LLM 提供商
     if args.use_claude_sdk:
         # 使用 Claude SDK (CLAUDE_CODE_KEY/URL 或 ANTHROPIC_API_KEY/BASE_URL)
-        claude_model = "claude-sonnet-4-5"
+        claude_model = "claude-sonnet-4-6"
         print(f"✅ Using Claude SDK with model: {claude_model}")
         print(f"   API Key: {os.getenv('CLAUDE_CODE_KEY', 'N/A')[:20]}...")
         print(f"   Base URL: {os.getenv('CLAUDE_CODE_URL', os.getenv('ANTHROPIC_BASE_URL', 'https://api.anthropic.com'))}")
@@ -818,7 +818,7 @@ async def main():
                 single_is_single = args.restart_mode.startswith("single_")
                 phase1_tasks = []
                 for p in single_platforms:
-                    task_desc = f"渠道:{p.upper()}。核心需求:{requirement}。目标:至少收集 {TARGET_QUALIFIED_CASES} 条高质量案例(评分>=70、正文充实)。"
+                    task_desc = f"渠道:{p.upper()}。核心需求:{requirement}。目标:至少收集 {TARGET_QUALIFIED_CASES} 条高质量案例(评分>=80、正文充实)。"
                     out_file = str(raw_cases_dir / f"case_{p}.json")
                     kwargs = {
                         "task": task_desc,

+ 3 - 2
examples/process_pipeline/script/generate_case.py

@@ -182,9 +182,9 @@ async def normalize_source_item(
     post = source_item.get("post", {})
     case_id = source_item.get("case_id", f"{platform}_{source_item.get('channel_content_id', '')}")
 
-    title = post.get("title", "")
-    author = _extract_author(post, platform)
     body = _extract_body(post, platform)
+    title = post.get("title") or source_item.get("title") or post.get("desc") or (body[:30] + "..." if body else "") or case_id
+    author = _extract_author(post, platform)
     url = _extract_url(post, platform) or source_item.get("source_url", "")
 
     # 收集反馈数据(兼容不同平台,没有的字段填 None)
@@ -236,6 +236,7 @@ async def normalize_source_item(
                 print(f"    ☁️  [{idx+1}/{len(raw_images)}] 上传完成")
         except Exception as e:
             print(f"    ⚠ [{idx+1}/{len(raw_images)}] 图片处理失败: {str(e)[:60]}")
+            images.append(img_url)
 
     # 兜底:对 body 里的外链图片也替换为 CDN
     try:

+ 173 - 0
examples/process_pipeline/server.py

@@ -65,6 +65,10 @@ class PromptRequest(BaseModel):
 class ImportExternalRequest(BaseModel):
     case_id: str
 
+class CaseFilterRequest(BaseModel):
+    case_id: str
+    reason: Optional[str] = "manual_delete"
+
 @app.post("/api/requirements/{index}/import_source_ex")
 def import_source_ex(index: int, req: ImportExternalRequest):
     idx_str = f"{(index+1):03d}"
@@ -227,6 +231,174 @@ def update_requirement(index: int, req: RequirementUpdateRequest):
         
     return {"status": "success", "requirement": req.requirement}
 
+@app.post("/api/requirements/{index}/cases/filter")
+def filter_case(index: int, req: CaseFilterRequest):
+    idx_str = f"{(index+1):03d}"
+    dir_path = OUTPUT_DIR / idx_str / "raw_cases"
+    source_path = dir_path / "source.json"
+    filtered_path = dir_path / "filtered_cases.json"
+    
+    if not source_path.exists():
+        raise HTTPException(status_code=404, detail="source.json not found")
+        
+    with open(source_path, "r", encoding="utf-8") as f:
+        source_data = json.load(f)
+        
+    target_case = None
+    new_sources = []
+    for c in source_data.get("sources", []):
+        c_id = c.get("case_id") or (c.get("_raw", {}).get("case_id")) or (c.get("post", {}).get("channel_content_id"))
+        if c_id == req.case_id:
+            target_case = c
+        else:
+            new_sources.append(c)
+            
+    if not target_case:
+        raise HTTPException(status_code=404, detail="Case not found in source.json")
+        
+    source_data["sources"] = new_sources
+    source_data["total"] = len(new_sources)
+    with open(source_path, "w", encoding="utf-8") as f:
+        json.dump(source_data, f, ensure_ascii=False, indent=2)
+        
+    filtered_data = {"total": 0, "by_reason": {}}
+    if filtered_path.exists():
+        with open(filtered_path, "r", encoding="utf-8") as f:
+            filtered_data = json.load(f)
+            
+    reason = req.reason or "manual_delete"
+    if reason not in filtered_data["by_reason"]:
+        filtered_data["by_reason"][reason] = {"total": 0, "sources": []}
+        
+    # Copy and add filter_reason
+    target_case["filter_reason"] = reason
+    filtered_data["by_reason"][reason]["sources"].append(target_case)
+    filtered_data["by_reason"][reason]["total"] = len(filtered_data["by_reason"][reason]["sources"])
+    
+    total_filtered = sum(r.get("total", 0) for r in filtered_data["by_reason"].values())
+    filtered_data["total"] = total_filtered
+    
+    with open(filtered_path, "w", encoding="utf-8") as f:
+        json.dump(filtered_data, f, ensure_ascii=False, indent=2)
+        
+    # Also attempt to remove from case.json if it exists
+    case_path = dir_path.parent / "case.json"
+    if case_path.exists():
+        with open(case_path, "r", encoding="utf-8") as f:
+            case_data = json.load(f)
+        if "cases" in case_data:
+            original_len = len(case_data["cases"])
+            case_data["cases"] = [c for c in case_data["cases"] if c.get("case_id") != req.case_id and c.get("_raw", {}).get("case_id") != req.case_id]
+            if len(case_data["cases"]) != original_len:
+                with open(case_path, "w", encoding="utf-8") as f:
+                    json.dump(case_data, f, ensure_ascii=False, indent=2)
+        
+    return {"status": "success"}
+
+@app.post("/api/requirements/{index}/cases/restore")
+def restore_case(index: int, req: CaseFilterRequest):
+    idx_str = f"{(index+1):03d}"
+    dir_path = OUTPUT_DIR / idx_str / "raw_cases"
+    source_path = dir_path / "source.json"
+    filtered_path = dir_path / "filtered_cases.json"
+    
+    if not filtered_path.exists():
+        raise HTTPException(status_code=404, detail="filtered_cases.json not found")
+        
+    with open(filtered_path, "r", encoding="utf-8") as f:
+        filtered_data = json.load(f)
+        
+    target_case = None
+    # Find and remove
+    for reason, reason_obj in filtered_data.get("by_reason", {}).items():
+        new_sources = []
+        for c in reason_obj.get("sources", []):
+            c_id = c.get("case_id") or (c.get("_raw", {}).get("case_id")) or (c.get("post", {}).get("channel_content_id"))
+            if c_id == req.case_id:
+                target_case = c
+            else:
+                new_sources.append(c)
+        if target_case:
+            reason_obj["sources"] = new_sources
+            reason_obj["total"] = len(new_sources)
+            break
+            
+    if not target_case:
+        raise HTTPException(status_code=404, detail="Case not found in filtered_cases.json")
+        
+    total_filtered = sum(r.get("total", 0) for r in filtered_data.get("by_reason", {}).values())
+    filtered_data["total"] = total_filtered
+    
+    with open(filtered_path, "w", encoding="utf-8") as f:
+        json.dump(filtered_data, f, ensure_ascii=False, indent=2)
+        
+    source_data = {"total": 0, "sources": []}
+    if source_path.exists():
+        with open(source_path, "r", encoding="utf-8") as f:
+            source_data = json.load(f)
+            
+    # Clean up filter_reason
+    target_case.pop("filter_reason", None)
+    
+    source_data["sources"].append(target_case)
+    source_data["total"] = len(source_data["sources"])
+    
+    with open(source_path, "w", encoding="utf-8") as f:
+        json.dump(source_data, f, ensure_ascii=False, indent=2)
+        
+    # Note: We do not automatically inject it back into case.json because case.json 
+    # requires format normalization (which generate_case.py does). The user must rerun step 1.6.
+        
+    return {"status": "success"}
+
+@app.post("/api/requirements/{index}/upload_cluster")
+async def upload_cluster(index: int, file: UploadFile = File(...)):
+    idx_str = f"{(index+1):03d}"
+    dir_path = OUTPUT_DIR / idx_str
+    dir_path.mkdir(parents=True, exist_ok=True)
+    cluster_path = dir_path / "cluster.json"
+    
+    content = await file.read()
+    try:
+        json_data = json.loads(content)
+        
+        # Load existing data or start new list
+        existing_data = []
+        if cluster_path.exists():
+            with open(cluster_path, "r", encoding="utf-8") as f:
+                try:
+                    existing = json.load(f)
+                    if isinstance(existing, list):
+                        existing_data = existing
+                    else:
+                        existing_data = [existing] # Wrap old format
+                except:
+                    pass
+                    
+        existing_data.append(json_data)
+        
+        with open(cluster_path, "w", encoding="utf-8") as f:
+            json.dump(existing_data, f, ensure_ascii=False, indent=2)
+            
+        return {"status": "success"}
+    except Exception as e:
+        raise HTTPException(status_code=400, detail=f"Invalid JSON: {str(e)}")
+
+@app.post("/api/requirements/{index}/save_cluster")
+async def save_cluster(index: int, request: Request):
+    idx_str = f"{(index+1):03d}"
+    dir_path = OUTPUT_DIR / idx_str
+    dir_path.mkdir(parents=True, exist_ok=True)
+    cluster_path = dir_path / "cluster.json"
+    
+    try:
+        data = await request.json()
+        with open(cluster_path, "w", encoding="utf-8") as f:
+            json.dump(data, f, ensure_ascii=False, indent=2)
+        return {"status": "success"}
+    except Exception as e:
+        raise HTTPException(status_code=400, detail=f"Failed to save JSON: {str(e)}")
+
 @app.post("/api/requirements")
 def add_requirement(req: RequirementUpdateRequest):
     if not DB_PATH.exists():
@@ -268,6 +440,7 @@ def get_requirement_data(index: int):
         "strategy": safe_load_json(dir_path / "strategy.json"),
         "blueprint": safe_load_json(dir_path / "process.json") or safe_load_json(dir_path / "blueprint.json"),
         "blueprint_temp": safe_load_json(dir_path / "blueprint_temp.json"),
+        "cluster": safe_load_json(dir_path / "cluster.json"),
         "capabilities": safe_load_json(dir_path / "capabilities.json") or safe_load_json(dir_path / "capabilities_extracted.json"),
         "capabilities_temp": safe_load_json(dir_path / "capabilities_temp.json"),
         "raw_cases": {

+ 339 - 48
examples/process_pipeline/ui/app.js

@@ -177,14 +177,19 @@ function renderRawCases(rawCasesObj) {
     const detailedCaseObj = rawCasesObj['case'] || rawCasesObj['case_detailed'];
     if (detailedCaseObj) {
         const cd = detailedCaseObj;
-        let calcWorkflow = 0;
-        let calcCapabilities = 0;
+        let uniqueCases = new Set();
+        let uniqueWorkflow = new Set();
+        let uniqueCapabilities = new Set();
+
         if (cd.cases) {
             cd.cases.forEach(c => {
                 const cId = c.case_id || (c._raw && c._raw.case_id);
                 const cUrl = c.source_url || c.url;
-                if (c.workflow) calcWorkflow++;
-                if (c.capabilities && c.capabilities.length > 0) calcCapabilities++;
+                const uniqueKey = cId || cUrl || Math.random().toString();
+                uniqueCases.add(uniqueKey);
+                
+                if (c.workflow) uniqueWorkflow.add(uniqueKey);
+                if (c.capabilities && c.capabilities.length > 0) uniqueCapabilities.add(uniqueKey);
                 
                 if (cId) {
                     if (c.workflow) detailMap[cId] = { ...detailMap[cId], workflow: c.workflow };
@@ -196,13 +201,15 @@ function renderRawCases(rawCasesObj) {
                 }
             });
         }
-        if (cd.total !== undefined) {
-            const displayWorkflowSuccess = cd.workflow_success !== undefined && cd.workflow_success !== null ? cd.workflow_success : (cd.success !== undefined && cd.success !== null ? cd.success : calcWorkflow);
-            const displayCapabilitiesSuccess = cd.capabilities_success !== undefined && cd.capabilities_success !== null ? cd.capabilities_success : calcCapabilities;
-            
+        
+        const displayTotal = uniqueCases.size > 0 ? uniqueCases.size : (cd.total !== undefined ? cd.total : 0);
+        const displayWorkflowSuccess = uniqueCases.size > 0 ? uniqueWorkflow.size : (cd.workflow_success !== undefined && cd.workflow_success !== null ? cd.workflow_success : (cd.success !== undefined && cd.success !== null ? cd.success : 0));
+        const displayCapabilitiesSuccess = uniqueCases.size > 0 ? uniqueCapabilities.size : (cd.capabilities_success !== undefined && cd.capabilities_success !== null ? cd.capabilities_success : 0);
+        
+        if (cd.total !== undefined || uniqueCases.size > 0) {
             totalStatsHtml = `<div style="display:flex; gap:1rem; margin-bottom:1rem; padding:0.5rem 1rem; background:rgba(0, 0, 0, 0.03); border-radius:8px; align-items:center;">
                 <div style="flex:1; text-align:center; display:flex; flex-direction:column; gap:2px;">
-                    <span style="font-size:1.3rem; color:var(--text-main); font-weight:bold; line-height:1;">${cd.total}</span>
+                    <span style="font-size:1.3rem; color:var(--text-main); font-weight:bold; line-height:1;">${displayTotal}</span>
                     <span style="color:var(--text-muted); font-size:0.75rem;">未被过滤的帖子总数</span>
                 </div>
                 <div style="flex:1; text-align:center; display:flex; flex-direction:column; gap:2px;">
@@ -266,6 +273,8 @@ function renderRawCases(rawCasesObj) {
         let groupedHtml = {};
         const getGroupKey = (c, p) => (p === 'filtered_cases' && c.filter_reason) ? `🚫 过滤原因: ${c.filter_reason}` : 'default';
         
+        let allCases = [];
+        
         pList.forEach(p => {
             if (!rawCasesObj[p]) return;
             if (rawCasesObj[p].error) {
@@ -305,9 +314,28 @@ function renderRawCases(rawCasesObj) {
                     </div>`;
                     return;
                 }
-                
                 cases.forEach((c, idx) => {
-                    totalCases++;
+                    allCases.push({ c: c, p: p, originalIdx: idx });
+                });
+            }
+        });
+        
+        allCases.sort((aObj, bObj) => {
+            const getScore = (item) => {
+                const iId = item.case_id || (item._raw && item._raw.case_id) || (item.post && item.post.channel_content_id);
+                const iUrl = item.source_url || item.url || (item.post && item.post.link);
+                const mapped = sourceMap[iId] || sourceMap[iUrl] || (item._raw && sourceMap[item._raw.case_id]) || item;
+                return mapped.evaluation && mapped.evaluation.quality ? (mapped.evaluation.quality.overall_score || 0) : 0;
+            };
+            return getScore(bObj.c) - getScore(aObj.c);
+        });
+        
+        allCases.forEach(itemObj => {
+            const c = itemObj.c;
+            const p = itemObj.p;
+            const idx = itemObj.originalIdx;
+            
+            totalCases++;
                     const cId = c.case_id || (c._raw && c._raw.case_id) || (c.post && c.post.channel_content_id) || `temp_${p}_${idx}`;
                     const cUrl = c.source_url || c.url || (c.post && c.post.link) || '';
                     
@@ -330,7 +358,7 @@ function renderRawCases(rawCasesObj) {
                         const coverImgUrl = allImages.length > 0 ? `/output/${reqId}/raw_cases/images/${cId}/00.jpg` : '';
                         const fallbackImgUrl = allImages.length > 0 ? allImages[0] : '';
                         
-                        const title = post.title || c.title || post.desc || '无标题';
+                        const title = post.title || c.title || post.desc || (post.body_text ? post.body_text.substring(0, 30) + '...' : '') || cId || '无标题';
                         const author = post.channel_account_name || s.author || '-';
                         const likes = post.like_count !== undefined ? post.like_count : (post.likes !== undefined ? post.likes : '-');
                         const snippetStr = (post.body_text || post.body || '').substring(0, 100);
@@ -338,6 +366,13 @@ function renderRawCases(rawCasesObj) {
                         
                         const platBadge = p.startsWith('case_') ? `<span class="tag" style="position:absolute; top:8px; left:8px; background:rgba(0,0,0,0.6); color:#fff; padding:2px 6px; border-radius:4px; font-size:0.7em; z-index:2; backdrop-filter: blur(4px); border: 1px solid rgba(255,255,255,0.2);">${p.replace('case_', '').toUpperCase()}</span>` : '';
                         
+                        const score = s.evaluation && s.evaluation.quality ? s.evaluation.quality.overall_score : null;
+                        let scoreBadge = '';
+                        if (score !== null && score !== undefined) {
+                            let color = score >= 80 ? '#10b981' : (score >= 60 ? '#f59e0b' : '#ef4444');
+                            scoreBadge = `<div style="position:absolute; top:8px; right:8px; background:rgba(0,0,0,0.7); color:${color}; font-weight:bold; padding:2px 8px; border-radius:12px; font-size:0.8em; z-index:2; backdrop-filter: blur(4px); border: 1px solid rgba(255,255,255,0.2);">⭐️ ${score}</div>`;
+                        }
+                        
                         let actionBtn = '';
                         if (p === 'source_ex') {
                             const isImported = !!sourceMap[cId] || !!sourceMap[cUrl];
@@ -350,6 +385,7 @@ function renderRawCases(rawCasesObj) {
                         
                         groupedHtml[groupKey] += `<div class="masonry-card" style="position:relative;" onclick="openCaseDetail('${p}', ${idx})">
                             ${platBadge}
+                            ${scoreBadge}
                             ${actionBtn}
                             ${allImages.length > 0 ? `<img class="cover-img" src="${coverImgUrl}" onerror="this.onerror=null; this.src='${fallbackImgUrl}';">` : ''}
                             <div class="masonry-card-info">
@@ -367,9 +403,6 @@ function renderRawCases(rawCasesObj) {
                         </div>`;
                     }
                 });
-            }
-        });
-        
         if (totalCases === 0 && pList.length > 0 && !paneHtml.includes('解析失败') && !paneHtml.includes('未进行')) {
             paneHtml += `<div style="padding:1rem; color:var(--text-muted); text-align:center;">暂无数据</div>`;
         } else {
@@ -437,18 +470,126 @@ window.importAllExternalCases = async function() {
     }
 };
 
-window.selectSubTab = function(p) {
+function selectSubTab(tabName) {
     document.querySelectorAll('#json-raw .sub-tab-btn').forEach(btn => {
         btn.classList.remove('active');
-        if(btn.textContent === p.replace('case_', '').toUpperCase()) btn.classList.add('active');
     });
+    const activeBtn = document.querySelector(`#json-raw .sub-tab-btn[data-target="sub-tab-${tabName}"]`);
+    if (activeBtn) activeBtn.classList.add('active');
+    
     document.querySelectorAll('#json-raw .sub-tab-pane').forEach(pane => {
         pane.classList.add('hidden');
     });
-    const target = document.getElementById(`sub-tab-${p}`);
-    if (target) target.classList.remove('hidden');
+    document.getElementById(`sub-tab-${tabName}`).classList.remove('hidden');
+}
+
+window.deleteClusterItems = async function(indices) {
+    if (currentSelectedIndex === null) return;
+    const data = window.dataCache[currentSelectedIndex]?.cluster;
+    if (!data) return;
+    
+    let newData = Array.isArray(data) ? [...data] : {...data};
+    let count = 0;
+    
+    // Sort indices descending to avoid shifting issues if it's an array
+    indices.sort((a,b) => b-a);
+    
+    if (Array.isArray(newData)) {
+        indices.forEach(i => { newData.splice(i, 1); count++; });
+    } else {
+        const keys = Object.keys(newData);
+        indices.forEach(i => { delete newData[keys[i]]; count++; });
+    }
+    
+    if (!confirm(`确定要删除选中的 ${count} 个项目吗?`)) return;
+    
+    try {
+        const res = await fetch(`/api/requirements/${currentSelectedIndex}/save_cluster`, {
+            method: 'POST',
+            body: JSON.stringify(newData),
+            headers: {'Content-Type': 'application/json'}
+        });
+        if (res.ok) {
+            fetchRequirementData(currentSelectedIndex);
+        } else {
+            alert('删除失败');
+        }
+    } catch (e) {
+        console.error(e);
+        alert('删除失败');
+    }
+};
+
+window.deleteSingleCluster = function(idx) {
+    deleteClusterItems([idx]);
+};
+
+window.deleteSelectedClusters = function() {
+    const checkboxes = document.querySelectorAll('.cluster-checkbox:checked');
+    const indices = Array.from(checkboxes).map(cb => parseInt(cb.dataset.idx));
+    if (indices.length === 0) {
+        alert('请先选择要删除的项目');
+        return;
+    }
+    deleteClusterItems(indices);
 };
 
+window.clearAllClusters = async function() {
+    if (currentSelectedIndex === null) return;
+    if (!confirm('确定要清空全部聚类结果吗?')) return;
+    try {
+        const res = await fetch(`/api/requirements/${currentSelectedIndex}/save_cluster`, {
+            method: 'POST',
+            body: JSON.stringify(Array.isArray(window.dataCache[currentSelectedIndex]?.cluster) ? [] : {}),
+            headers: {'Content-Type': 'application/json'}
+        });
+        if (res.ok) {
+            fetchRequirementData(currentSelectedIndex);
+        } else {
+            alert('清空失败');
+        }
+    } catch (e) {
+        console.error(e);
+        alert('清空失败');
+    }
+};
+
+function renderClusterDeletable(clusterData) {
+    if (!clusterData || (Array.isArray(clusterData) && clusterData.length === 0) || (typeof clusterData === 'object' && Object.keys(clusterData).length === 0)) {
+        return `<div style="color:var(--text-muted); padding:2rem; text-align:center;">暂无聚类结果数据,请导入 JSON 文件</div>`;
+    }
+    
+    let html = `<div style="margin-bottom: 1rem; display: flex; justify-content: space-between; align-items: center;">
+        <button class="btn btn-danger btn-small" onclick="deleteSelectedClusters()" style="background-color: var(--danger); border-color: var(--danger); color: white;">🗑️ 删除选中项</button>
+        <button class="btn btn-secondary btn-small" onclick="clearAllClusters()">🧹 清空全部</button>
+    </div>`;
+    
+    html += `<div style="display:flex; flex-direction:column; gap:1rem;">`;
+    
+    const items = Array.isArray(clusterData) ? clusterData : Object.entries(clusterData).map(([k,v]) => ({key: k, value: v}));
+    
+    items.forEach((item, idx) => {
+        const displayData = Array.isArray(clusterData) ? item : item.value;
+        const displayKey = Array.isArray(clusterData) ? `导入记录 #${idx + 1}` : `Key: ${item.key}`;
+        
+        html += `<div style="background: rgba(0,0,0,0.02); border: 1px solid var(--border-glass); border-radius: 8px; padding: 1rem;">
+            <div style="display:flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; border-bottom: 1px solid rgba(0,0,0,0.05); padding-bottom: 0.5rem;">
+                <label style="display:flex; align-items:center; gap:8px; cursor:pointer; font-weight:bold; color:var(--text-main);">
+                    <input type="checkbox" class="cluster-checkbox" data-idx="${idx}" style="cursor:pointer; width:16px; height:16px;">
+                    ${displayKey}
+                </label>
+                <button class="btn btn-secondary btn-small" onclick="deleteSingleCluster(${idx})" style="color:var(--danger); border-color:rgba(239, 68, 68, 0.2); background:rgba(239, 68, 68, 0.05);">❌ 删除此项</button>
+            </div>
+            <div style="background: #1e1e1e; color: #d4d4d4; padding: 1rem; border-radius: 6px; font-family: monospace; font-size: 0.85rem; overflow-x: auto; max-height: 400px;">
+                ${renderJSON(displayData)}
+            </div>
+        </div>`;
+    });
+    
+    html += `</div>`;
+    return html;
+}
+
 window.selectGenericSubTab = function(prefix, targetId) {
     const parentContainer = document.getElementById(`container-${prefix}`);
     if (!parentContainer) return;
@@ -947,9 +1088,24 @@ function renderAggregatedPerCaseData(cases, type) {
     let hasData = false;
     let displayIndex = 1;
     
-    cases.forEach((c, idx) => {
+    // Sort cases by score
+    const sortedCases = [...cases].sort((a, b) => {
+        const aId = a.case_id || (a._raw && a._raw.case_id);
+        const bId = b.case_id || (b._raw && b._raw.case_id);
+        const sourceMap = window._currentRawCasesContext ? window._currentRawCasesContext.sourceMap || {} : {};
+        const aMapped = sourceMap[aId] || {};
+        const bMapped = sourceMap[bId] || {};
+        const aScore = aMapped.evaluation && aMapped.evaluation.quality ? (aMapped.evaluation.quality.overall_score || 0) : 0;
+        const bScore = bMapped.evaluation && bMapped.evaluation.quality ? (bMapped.evaluation.quality.overall_score || 0) : 0;
+        return bScore - aScore;
+    });
+
+    sortedCases.forEach((c, idx) => {
         const cId = c.case_id || (c._raw && c._raw.case_id) || `temp_${idx}`;
-        const title = (c.post && c.post.title) || c.title || cId || `案例 ${idx + 1}`;
+        const sourceMap = window._currentRawCasesContext ? window._currentRawCasesContext.sourceMap || {} : {};
+        const mappedS = sourceMap[cId] || {};
+        const postObj = mappedS.post || c.post || c || {};
+        const title = postObj.title || c.title || postObj.desc || (postObj.body_text ? postObj.body_text.substring(0, 30) + '...' : '') || cId || `案例 ${idx + 1}`;
         
         hasData = true; // Always render if there is a case, so the user can click Rerun.
         let items = null;
@@ -970,6 +1126,13 @@ function renderAggregatedPerCaseData(cases, type) {
             publishStrHtml = `<span style="font-size: 0.75em; font-weight: normal; color: #94a3b8; background: #f1f5f9; border: 1px solid #e2e8f0; padding: 2px 8px; border-radius: 12px; margin-left: 8px;">发布于: ${dateStr}</span>`;
         }
         
+        const score = mappedS.evaluation && mappedS.evaluation.quality ? mappedS.evaluation.quality.overall_score : null;
+        let scoreBadge = '';
+        if (score !== null && score !== undefined) {
+            let color = score >= 80 ? '#10b981' : (score >= 60 ? '#f59e0b' : '#ef4444');
+            scoreBadge = `<span style="font-size: 0.85em; font-weight: bold; color: #fff; background: ${color}; padding: 2px 8px; border-radius: 12px; margin-left: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">⭐️ ${score}</span>`;
+        }
+        
         const typeLabel = type === 'workflow' ? '工序' : '能力';
         
         // Add to sidebar
@@ -991,19 +1154,25 @@ function renderAggregatedPerCaseData(cases, type) {
         contentHtml += `<h3 style="margin: 0; padding: 1rem; color: var(--text-main); font-size: 1.2rem; display: flex; align-items: center; gap: 10px; user-select: none; flex: 1;">`;
         contentHtml += `<span class="case-arrow" style="font-size: 0.8em; color: var(--text-muted); width: 16px; display: inline-block;">▶</span>`;
         contentHtml += `<span style="color: #64748b; font-size: 1.1rem; font-weight: bold; font-family: monospace;">#${idx + 1}</span>`;
-        contentHtml += `<span>${title.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</span>${publishStrHtml}</h3>`;
+        contentHtml += `<span>${title.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</span>${scoreBadge}${publishStrHtml}</h3>`;
         contentHtml += btnHtml;
         contentHtml += `</div>`;
         
         // Collapsible Post Info
-        const post = c.post || c || {};
+        const post = mappedS.post || c.post || c || {};
+        
         const images = post.images || [];
+        const xImages = post.image_url_list || [];
+        const ytThumbnails = post.thumbnails && post.thumbnails.length > 0 ? [post.thumbnails[post.thumbnails.length - 1].url] : [];
+        const allImages = [...images, ...xImages.map(img => img.image_url), ...ytThumbnails].filter(Boolean);
+
         const bodyText = post.body_text || post.body || post.desc || '';
         let mediaHtml = '';
-        if (images.length > 0) {
+        if (allImages.length > 0) {
             mediaHtml = `<div style="display:flex; gap:8px; overflow-x:auto; margin-bottom: 1rem; padding-bottom: 8px;">`;
-            images.forEach(img => {
-                mediaHtml += `<img src="${img}" style="height: 150px; border-radius: 6px; object-fit: cover; flex-shrink: 0;">`;
+            allImages.forEach((img, i) => {
+                const coverImgUrl = `/output/${window._currentRawCasesContext.reqId}/raw_cases/images/${cId}/${i.toString().padStart(2, '0')}.jpg`;
+                mediaHtml += `<img src="${coverImgUrl}" onerror="this.onerror=null; this.src='${img}';" style="height: 150px; border-radius: 6px; object-fit: cover; flex-shrink: 0;">`;
             });
             mediaHtml += `</div>`;
         }
@@ -1043,6 +1212,9 @@ async function fetchRequirementData(index) {
         const res = await fetch(`/api/requirements/${index}/data`);
         const data = await res.json();
         
+        window.dataCache = window.dataCache || {};
+        window.dataCache[index] = data;
+        
         let rawCasesClone = null;
         let casesList = [];
         if (data.raw_cases) {
@@ -1054,11 +1226,61 @@ async function fetchRequirementData(index) {
         }
         
         if (jsonStrategy) jsonStrategy.innerHTML = ''; // Tab removed
-        jsonBlueprint.innerHTML = renderAggregatedPerCaseData(casesList, 'workflow');
-        jsonCaps.innerHTML = renderAggregatedPerCaseData(casesList, 'capabilities');
         
         jsonRaw.innerHTML = renderDataOrRaw(rawCasesClone, renderRawCases);
         
+        const clusterData = window.dataCache[index].cluster;
+        
+        const oldActiveWorkflowTab = jsonBlueprint.querySelector('.sub-tab-btn.active')?.dataset?.target || 'sub-tab-workflow-cluster';
+        
+        const workflowCasesHtml = renderAggregatedPerCaseData(casesList, 'workflow');
+        let bpHtml = `<div id="container-workflow">`;
+        bpHtml += `<div class="sub-tabs-container" style="margin-bottom: 1rem; border-bottom: 1px solid var(--border-glass); padding-bottom: 8px; display: flex; gap: 8px;">`;
+        bpHtml += `<button class="sub-tab-btn ${oldActiveWorkflowTab === 'sub-tab-workflow-cases' ? 'active' : ''}" data-target="sub-tab-workflow-cases" onclick="selectGenericSubTab('workflow', 'sub-tab-workflow-cases')">📊 案例解析页</button>`;
+        bpHtml += `<button class="sub-tab-btn ${oldActiveWorkflowTab === 'sub-tab-workflow-cluster' ? 'active' : ''}" data-target="sub-tab-workflow-cluster" onclick="selectGenericSubTab('workflow', 'sub-tab-workflow-cluster')">🧩 聚类结果 (Cluster)</button>`;
+        bpHtml += `</div>`;
+        
+        bpHtml += `<div id="sub-tab-workflow-cases" class="sub-tab-pane ${oldActiveWorkflowTab === 'sub-tab-workflow-cases' ? '' : 'hidden'}">${workflowCasesHtml}</div>`;
+        bpHtml += `<div id="sub-tab-workflow-cluster" class="sub-tab-pane ${oldActiveWorkflowTab === 'sub-tab-workflow-cluster' ? '' : 'hidden'}">
+            <div style="margin-bottom: 1.5rem; padding: 1.2rem; background: rgba(0,0,0,0.02); border-radius: 8px; display: flex; justify-content: space-between; align-items: center; border: 1px dashed rgba(0,0,0,0.1);">
+                <span style="color: var(--text-muted); font-size: 0.9em;">导入本地生成的 JSON (如 process.json 等) 将保存为 cluster.json 并支持单项删除与多选清除。</span>
+                <div>
+                    <input type="file" id="input-upload-cluster" accept=".json" style="display:none;">
+                    <button class="btn btn-primary btn-small" onclick="document.getElementById('input-upload-cluster').click()">📥 导入 JSON</button>
+                </div>
+            </div>
+            <div id="cluster-preview-content">${renderClusterDeletable(clusterData)}</div>
+        </div>`;
+        bpHtml += `</div>`;
+        jsonBlueprint.innerHTML = bpHtml;
+        
+        const clusterFileInput = document.getElementById('input-upload-cluster');
+        if (clusterFileInput) {
+            clusterFileInput.onchange = async (e) => {
+                const file = e.target.files[0];
+                if (!file) return;
+                
+                const formData = new FormData();
+                formData.append('file', file);
+                
+                try {
+                    const res = await fetch(`/api/requirements/${index}/upload_cluster`, {
+                        method: 'POST',
+                        body: formData
+                    });
+                    if (res.ok) {
+                        fetchRequirementData(index);
+                    } else {
+                        alert('上传失败');
+                    }
+                } catch (err) {
+                    console.error(err);
+                    alert('上传出错');
+                }
+            };
+        }
+        jsonCaps.innerHTML = renderAggregatedPerCaseData(casesList, 'capabilities');
+        
         const btnUpload = document.getElementById('btn-upload-source-ex');
         const fileInput = document.getElementById('input-upload-source-ex');
         if (btnUpload && fileInput) {
@@ -1138,6 +1360,7 @@ async function pollStatus() {
                 }
             });
             renderTaskList(requirements);
+            updateStats();
         }
 
     } catch (e) {
@@ -1160,6 +1383,7 @@ function renderTaskList(list) {
         if (req.status === 'running') statusMarker = '🚀';
         else if (req.status === 'completed') statusMarker = '✅';
         else if (req.status === 'partial') statusMarker = '⚠️';
+        else if (req.status === 'failed') statusMarker = '❌';
         else statusMarker = '⏳';
         option.textContent = `${statusMarker} [#${req.id}] ${req.requirement} (Cases: ${req.raw_cases_count})`;
         if (currentSelectedIndex === req.index) {
@@ -1531,7 +1755,6 @@ function setupEventListeners() {
             const req = requirements.find(r => r.index === currentSelectedIndex);
             if (req) {
                 req.requirement = newText;
-                elDetailTitle.textContent = newText;
                 renderTaskList(requirements);
             }
 
@@ -2034,8 +2257,19 @@ window.renderSingleCaseDetail = function(idx) {
     const commentCount = post.comment_count !== undefined ? post.comment_count : (post.comments !== undefined ? post.comments : '-');
     const shareCount = post.share_count !== undefined ? post.share_count : (post.shares !== undefined ? post.shares : '-');
 
+    const isFiltered = (c._actualPlatform === 'filtered_cases');
+    let filterActionHtml = '';
+    if (isFiltered) {
+        filterActionHtml = `<button onclick="window.toggleCaseFilter('${cId}', true)" style="padding: 6px 12px; background: #3b82f6; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.9em; font-weight: bold; margin-left: auto;">↩️ 恢复至总库</button>`;
+    } else {
+        filterActionHtml = `<button onclick="window.toggleCaseFilter('${cId}', false)" style="padding: 6px 12px; background: #ef4444; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.9em; font-weight: bold; margin-left: auto;">🗑️ 移至被过滤</button>`;
+    }
+
     const headerHtml = `
-        <h2 style="margin: 0 0 0.5rem 0; font-size: 1.4em; color: var(--text-main);">${title}</h2>
+        <div style="display:flex; justify-content: space-between; align-items: flex-start; gap: 1rem;">
+            <h2 style="margin: 0 0 0.5rem 0; font-size: 1.4em; color: var(--text-main); flex: 1;">${title}</h2>
+            ${filterActionHtml}
+        </div>
         <div style="display: flex; gap: 12px; margin-bottom: 0.8rem;">
             ${workflowUrl ? `<a href="${workflowUrl}" target="_blank" style="color: var(--accent-primary); text-decoration: none; font-size: 0.9em;">原文 ↗</a>` : ''}
             <span style="color: var(--text-muted); font-size: 0.9em;">平台: ${platformName}</span>
@@ -2230,17 +2464,25 @@ window.renderStructuredData = function(items, type, parentItem = null) {
         const hasValidIO = (arr) => Array.isArray(arr) && arr.length > 0 && (arr[0].role || arr[0].description);
         if (hasValidIO(item.inputs) || hasValidIO(item.outputs) || (item.steps && item.steps.length > 0)) {
             let actionStr = '';
-            if (item.action && item.action.main_action) {
+            if (item.action && typeof item.action === 'string') {
+                actionStr = item.action;
+            } else if (item.action && item.action.main_action) {
                 actionStr = item.action.main_action;
+            } else if (item.body) {
+                actionStr = item.body.length > 20 ? item.body.substring(0, 20) + '...' : item.body;
             } else if (item.method && !item.method.includes('[')) {
                 actionStr = item.method;
             } else if (item.steps && Array.isArray(item.steps)) {
-                const hasAnyValidIO = item.steps.some(s => hasValidIO(s.inputs) || hasValidIO(s.outputs));
+                const hasAnyValidIO = item.steps.some(s => hasValidIO(s.inputs) || hasValidIO(s.outputs) || s.body);
                 if (hasAnyValidIO) {
                     actionStr = item.steps.map(s => {
+                        if (s.action && typeof s.action === 'string') {
+                            return s.action;
+                        }
                         if (s.action && s.action.main_action) {
                             return s.action.mechanism ? `[${s.action.main_action}] ${s.action.mechanism}` : s.action.main_action;
                         }
+                        if (s.body) return s.body.length > 20 ? s.body.substring(0, 20) + '...' : s.body;
                         if (s.method) return s.method;
                         if (s.phase) return s.phase;
                         return '未知';
@@ -2258,10 +2500,17 @@ window.renderStructuredData = function(items, type, parentItem = null) {
         } else {
             const escapeHtml = (s) => String(s).replace(/</g, '&lt;').replace(/>/g, '&gt;');
             title = escapeHtml(item.method || item.name || '');
+            if (!title && item.action && typeof item.action === 'string') {
+                title = escapeHtml(item.action);
+            }
             if (!title && item.action && item.action.main_action) {
                 const actText = item.action.mechanism ? `[${item.action.main_action}] ${item.action.mechanism}` : item.action.main_action;
                 title = escapeHtml(actText);
             }
+            if (!title && item.body) {
+                const actText = item.body.length > 50 ? item.body.substring(0, 50) + '...' : item.body;
+                title = escapeHtml(actText);
+            }
             if (!title) {
                 title = escapeHtml(type === 'workflow' ? '工作流' : `节点 ${idx + 1}`);
             }
@@ -2601,10 +2850,16 @@ window.renderStructuredData = function(items, type, parentItem = null) {
             const minWidth = 1250;
             const renderAction = (src) => {
                 if (!src) return '-';
+                if (src.action && typeof src.action === 'string') {
+                    return `<span style="font-weight: 600; color: #0f172a; font-size: 1.05em;">${escapeHtml(src.action)}</span>`;
+                }
                 if (src.action && src.action.main_action) {
-                    const mainAction = escapeHtml(src.action.main_action);
-                    const mechanism = src.action.mechanism ? escapeHtml(src.action.mechanism) : '';
-                    return `<span class="data-type-badge" style="background:#e0e7ff;color:#3730a3;font-weight:normal;margin-right:6px;margin-bottom:2px;display:inline-block;">${mainAction}</span>${mechanism}`;
+                    const mainAction = `<span style="font-weight: 600; color: #0f172a; font-size: 1.05em;">${escapeHtml(src.action.main_action)}</span>`;
+                    const mechanism = src.action.mechanism ? ` <span style="color: #64748b; font-size: 0.9em;">(${escapeHtml(src.action.mechanism)})</span>` : '';
+                    return `${mainAction}${mechanism}`;
+                }
+                if (src.body) {
+                    return `<div class="fragment-text">${escapeHtml(src.body)}</div>`;
                 }
                 if (src.method) return escapeHtml(src.method);
                 if (src.description) return escapeHtml(src.description);
@@ -2616,24 +2871,26 @@ window.renderStructuredData = function(items, type, parentItem = null) {
             };
             const renderEffects = (effects) => {
                 if (!effects || !Array.isArray(effects) || effects.length === 0) return '-';
+                const renderKeyTag = (keyText) => `<span style="display:inline-block; padding: 2px 6px; background: #f1f5f9; color: #475569; border-radius: 4px; font-size: 0.85em; font-weight: 500; margin-right: 6px; border: 1px solid #e2e8f0; vertical-align: middle; white-space: nowrap;">${keyText}</span>`;
                 return `<div style="display:flex; flex-direction:column; gap:8px;">${effects.map(effect => {
                     if (typeof effect === 'string') {
-                        return `<div class="effect-item"><span class="data-type-badge effect-index">效果</span><span>${escapeHtml(effect)}</span></div>`;
+                        return `<div class="effect-item"><div style="margin-bottom: 2px; display: flex; align-items: flex-start; line-height: 1.6;">${renderKeyTag('效果')}<span style="flex:1;">${escapeHtml(effect)}</span></div></div>`;
                     }
                     if (typeof effect !== 'object' || effect === null) return '';
                     const statement = effect.statement ? escapeHtml(effect.statement) : '效果';
                     const criteria = effect.criteria ? escapeHtml(effect.criteria) : '';
                     const judgeMethod = effect.judge_method ? escapeHtml(effect.judge_method) : '';
                     const negativeExamples = Array.isArray(effect.negative_examples) && effect.negative_examples.length > 0
-                        ? effect.negative_examples.slice(0, 2).map(ex => `<span class="effect-negative">${escapeHtml(ex)}</span>`).join('')
+                        ? `<div style="display:flex; flex-direction:column; gap:4px; flex:1;">` + 
+                          effect.negative_examples.map(ex => `<span style="background: #f8fafc; color: #64748b; padding: 2px 8px; border-radius: 4px; font-size: 0.9em; border: 1px solid #cbd5e1; display: inline-block; word-break: break-word;">${escapeHtml(ex)}</span>`).join('') + 
+                          `</div>`
                         : '';
                     return `<div class="effect-item">
-                        <div class="effect-content">
-                            <div>${judgeMethod ? `<span class="data-type-badge effect-method">${judgeMethod}</span>` : ''}<span class="effect-statement">${statement}</span></div>
-                            ${criteria ? `<div class="effect-criteria">${criteria}</div>` : ''}
-                            ${negativeExamples ? `<div class="effect-meta">
-                                ${negativeExamples}
-                            </div>` : ''}
+                        <div class="effect-content" style="font-size: 0.95em; line-height: 1.6;">
+                            <div style="font-weight: 600; margin-bottom: 6px; color: #0f172a; font-size: 1.05em;">${statement}</div>
+                            ${criteria ? `<div style="margin-bottom: 4px; display: flex; align-items: flex-start;">${renderKeyTag('判断标准')}<span style="flex:1; color: #334155;">${criteria}</span></div>` : ''}
+                            ${judgeMethod ? `<div style="margin-bottom: 4px; display: flex; align-items: flex-start;">${renderKeyTag('评判方式')}<span style="flex:1; color: #334155;">${judgeMethod}</span></div>` : ''}
+                            ${negativeExamples ? `<div style="display: flex; align-items: flex-start;">${renderKeyTag('负面示例')}${negativeExamples}</div>` : ''}
                         </div>
                     </div>`;
                 }).join('')}</div>`;
@@ -2658,13 +2915,13 @@ window.renderStructuredData = function(items, type, parentItem = null) {
                         <span class="row-expand-icon">▶</span>
                         ${fragment && fragment.fragment_id ? `<span style="display:inline-block; color:#94a3b8; font-size:0.85em; font-weight:400;">${escapeHtml(fragment.fragment_id)}</span>` : '-'}
                     </td>
-                    <td class="fragment-cell">${renderAction(fragment)}</td>
                     <td class="fragment-cell">${fragment && fragment.inputs && fragment.inputs.length > 0 ? renderDataObjList(fragment.inputs) : '-'}</td>
+                    <td class="fragment-cell"><div class="fragment-clamp">${renderAction(fragment)}</div></td>
                     <td class="fragment-cell">${fragment && fragment.outputs && fragment.outputs.length > 0 ? renderDataObjList(fragment.outputs) : '-'}</td>
                     <td class="fragment-cell"><div class="fragment-clamp">${fragment ? renderEffects(fragment.effects) : '-'}</div></td>
+                    <td class="fragment-cell"><div class="fragment-clamp fragment-text" style="color: #475569;">${fragment && fragment.body ? escapeHtml(fragment.body) : '-'}</div></td>
                     <td class="fragment-cell" style="font-size:0.9em;"><div class="fragment-clamp">${applyTo ? renderApplyToVal(applyTo) : '-'}</div></td>
                     <td class="fragment-cell"><div class="fragment-clamp">${fragment ? renderTools(fragment.tools) : '-'}</div></td>
-                    <td class="fragment-cell"><div class="fragment-clamp fragment-text">${fragment && fragment.body ? escapeHtml(fragment.body) : '-'}</div></td>
                 `;
             };
 
@@ -2699,13 +2956,13 @@ window.renderStructuredData = function(items, type, parentItem = null) {
                                 <th style="padding: 12px 10px; width: 60px;">序号</th>
                                 <th style="padding: 12px 10px; width: 90px;">阶段</th>
                                 <th style="padding: 12px 8px; width: 90px;"></th>
-                                <th style="padding: 12px 10px; width: 160px;">动作</th>
                                 <th style="padding: 12px 10px; width: 180px;">输入</th>
+                                <th style="padding: 12px 10px; width: 140px;">动作/做法</th>
                                 <th style="padding: 12px 10px; width: 180px;">输出</th>
-                                <th style="padding: 12px 10px; width: 220px;">效果</th>
+                                <th style="padding: 12px 10px; width: 360px;">效果</th>
+                                <th style="padding: 12px 10px; width: 180px;">用法</th>
                                 <th style="padding: 12px 10px; width: 260px;">作用域</th>
                                 <th style="padding: 12px 10px; width: 130px;">工具</th>
-                                <th style="padding: 12px 10px; width: 280px;">做法</th>
                             </tr>
                         </thead>
                         <tbody>`;
@@ -2879,4 +3136,38 @@ window.triggerSingleCaseRerun = async function(step, caseIndex) {
     }
 };
 
+window.toggleCaseFilter = async function(caseId, isRestore) {
+    if (currentSelectedIndex === null) return;
+    const reqIndex = currentSelectedIndex;
+    
+    let reason = "manual_delete";
+    if (!isRestore) {
+        reason = prompt("请输入移除原因 (默认: manual_delete):", "manual_delete");
+        if (reason === null) return; // Cancelled
+        if (!reason.trim()) reason = "manual_delete";
+    }
+
+    try {
+        const action = isRestore ? 'restore' : 'filter';
+        const res = await fetch(`/api/requirements/${reqIndex}/cases/${action}`, {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify({ case_id: caseId, reason: reason })
+        });
+
+        if (!res.ok) {
+            const err = await res.json();
+            alert('操作失败: ' + (err.detail || '未知错误'));
+            return;
+        }
+
+        // Close modal and refresh data
+        document.getElementById('case-detail-modal').classList.add('hidden');
+        document.getElementById('btn-refresh-data').click();
+    } catch (e) {
+        console.error(e);
+        alert('操作失败');
+    }
+};
+
 init();

+ 2 - 2
frontend/react-template/src/components/MainContent/MainContent.tsx

@@ -136,9 +136,9 @@ export const MainContent: FC<MainContentProps> = ({
             style={{ width: 400 }}
             placeholder="选择 Trace"
             optionList={traceList.map((t) => {
-              const taskDesc = t.task && t.task.length > 20 ? `${t.task.slice(0, 20)}...` : t.task;
+              const taskDesc = t.task && t.task.length > 50 ? `${t.task.slice(0, 50)}...` : t.task;
               return {
-                label: taskDesc ? `${t.trace_id} - ${taskDesc}` : t.trace_id,
+                label: taskDesc || t.trace_id,
                 value: t.trace_id,
               };
             })}