Przeglądaj źródła

Merge branch 'main' of https://git.yishihui.com/howard/Agent

刘文武 2 godzin temu
rodzic
commit
ea0335c676

+ 5 - 4
examples/process_pipeline/prompts/apply_to_grounding.prompt

@@ -29,10 +29,11 @@
 - 只选择你有信心认为与 apply_to_draft 和 capability.body 直接相关的节点;一般每侧不超过 3 项。
 - 不确定时选较粗分类;如果仍然没有信心或 body 中没有直接证据,可以置空数组,不要编造。
 - rationale 用一句话说明为什么该 draft/body 证据落在该节点。
-- 每个 apply_to 条目和 suggest_apply_to 条目都必须包含 body_excerpt 和 body_excerpt_note 字段;这两个字段可以为空字符串。
+- 每个 apply_to 条目和 suggest_apply_to 条目都必须包含 body_excerpt、body_excerpt_note、body_excerpt_type 字段;这三个字段可以为空字符串。
 - 如果填写 body_excerpt,它必须逐字摘自输入 capability.body 的连续原文片段,不允许改写、概括、拼接、补词或翻译。
 - body_excerpt_note 是把原文片段改写成语义完整、能让人看明白的句子:保留 body_excerpt 的核心语义,补足省略的主语/宾语/动作对象,使人不看上下文也能理解这段做法;不是 rationale,也不要解释为什么匹配该节点。
-- 如果 body_excerpt 为空,body_excerpt_note 必须也为空;不允许只填写 body_excerpt_note。
+- body_excerpt_type 表示 body_excerpt 的内容类型,使用简短英文或中文标签,如 prompt、parameter、tool、image、workflow、setting、caption。
+- 如果 body_excerpt 为空,body_excerpt_note 和 body_excerpt_type 必须也为空;不允许只填写 note 或 type。
 
 # suggest_apply_to 规则
 
@@ -68,10 +69,10 @@ suggest_apply_to 用来指出:当前候选路径或已有内容树无法很好
 - 输入中的 body 只用于抽取 body_excerpt 证据,输出中不要回显完整 body。
 
 每个 apply_to 条目格式:
-`{ "category_id": 123, "category_path": "...", "element": null, "rationale": "...", "body_excerpt": "可为空;非空时逐字来自 body 的连续片段", "body_excerpt_note": "可为空;把原文片段改写成语义完整、能让人看明白的句子" }`
+`{ "category_id": 123, "category_path": "...", "element": null, "rationale": "...", "body_excerpt": "可为空;非空时逐字来自 body 的连续片段", "body_excerpt_note": "可为空;把原文片段改写成语义完整、能让人看明白的句子", "body_excerpt_type": "可为空;如 prompt" }`
 
 每个 suggest_apply_to 条目格式:
-`{ "source_type": "实质", "path": "...", "rationale": "...", "body_excerpt": "可为空;非空时逐字来自 body 的连续片段", "body_excerpt_note": "可为空;把原文片段改写成语义完整、能让人看明白的句子" }`
+`{ "source_type": "实质", "path": "...", "rationale": "...", "body_excerpt": "可为空;非空时逐字来自 body 的连续片段", "body_excerpt_note": "可为空;把原文片段改写成语义完整、能让人看明白的句子", "body_excerpt_type": "可为空;如 prompt" }`
 
 # 输出硬规则
 

+ 8 - 4
examples/process_pipeline/prompts/apply_to_grounding_capability.schema.json

@@ -12,7 +12,8 @@
         "category_path",
         "rationale",
         "body_excerpt",
-        "body_excerpt_note"
+        "body_excerpt_note",
+        "body_excerpt_type"
       ],
       "additionalProperties": false,
       "properties": {
@@ -21,7 +22,8 @@
         "element": { "type": ["string", "null"] },
         "rationale": { "type": "string", "minLength": 1 },
         "body_excerpt": { "type": "string" },
-        "body_excerpt_note": { "type": "string" }
+        "body_excerpt_note": { "type": "string" },
+        "body_excerpt_type": { "type": "string" }
       }
     },
     "suggest_apply_to_item": {
@@ -31,7 +33,8 @@
         "path",
         "rationale",
         "body_excerpt",
-        "body_excerpt_note"
+        "body_excerpt_note",
+        "body_excerpt_type"
       ],
       "additionalProperties": false,
       "properties": {
@@ -39,7 +42,8 @@
         "path": { "type": "string", "minLength": 1 },
         "rationale": { "type": "string", "minLength": 1 },
         "body_excerpt": { "type": "string" },
-        "body_excerpt_note": { "type": "string" }
+        "body_excerpt_note": { "type": "string" },
+        "body_excerpt_type": { "type": "string" }
       }
     }
   },

+ 1 - 1
examples/process_pipeline/prompts/extract_workflow.prompt

@@ -44,7 +44,7 @@
 - capability_id:字符串,见上方规则
 - action:{ description, reasoning },见下方 action 字段规则
 - inputs / outputs:结构化接口,见下方规则
-- body:该原子操作在原帖中的描述(可能是对应 step 内容里的子片段);未提及则为 null
+- body:该原子操作在原帖中的描述(可能是对应 step 内容里的子片段);该描述需尽可能细致,做到应有尽有,把每一个细节都记录下来,包括但不限于具体的prompt。未提及则为 null
 - effects:该原子操作产生的可观测效果,数组,每项为结构体(见下方 effects 字段规则)
 - control_target:该操作控制的对象,字符串数组,如 ["人物姿态", "背景风格"];未提及则为 []
 - artifact_type:该操作产出的工件类型,如 "正向提示词"、"蒙版"、"参考图";未提及则为 null

+ 112 - 28
examples/process_pipeline/prompts/extract_workflow.schema.json

@@ -2,12 +2,20 @@
   "$schema": "http://json-schema.org/draft-07/schema#",
   "title": "extract_workflow_output_v7",
   "type": "object",
-  "required": ["skip", "skip_reason", "workflow_groups"],
+  "required": [
+    "skip",
+    "skip_reason",
+    "workflow_groups"
+  ],
   "additionalProperties": false,
   "definitions": {
     "step": {
       "type": "object",
-      "required": ["step_id", "order", "phase"],
+      "required": [
+        "step_id",
+        "order",
+        "phase"
+      ],
       "additionalProperties": false,
       "properties": {
         "step_id": {
@@ -20,13 +28,21 @@
         },
         "phase": {
           "type": "string",
-          "enum": ["非制作", "预处理", "生成", "编辑"]
+          "enum": [
+            "非制作",
+            "预处理",
+            "生成",
+            "编辑"
+          ]
         }
       }
     },
     "workflow": {
       "type": "object",
-      "required": ["workflow_id", "steps"],
+      "required": [
+        "workflow_id",
+        "steps"
+      ],
       "additionalProperties": false,
       "properties": {
         "workflow_id": {
@@ -36,18 +52,33 @@
         "steps": {
           "type": "array",
           "minItems": 1,
-          "items": { "$ref": "#/definitions/step" }
+          "items": {
+            "$ref": "#/definitions/step"
+          }
         }
       }
     },
     "io_item": {
       "type": "object",
-      "required": ["modality", "description", "relation"],
+      "required": [
+        "modality",
+        "description",
+        "relation"
+      ],
       "additionalProperties": false,
       "properties": {
         "modality": {
           "type": "string",
-          "enum": ["文本", "图片", "视频", "音频", "特征点", "参数", "模型", "向量"]
+          "enum": [
+            "文本",
+            "图片",
+            "视频",
+            "音频",
+            "特征点",
+            "参数",
+            "模型",
+            "向量"
+          ]
         },
         "description": {
           "type": "string",
@@ -61,7 +92,12 @@
     },
     "effect": {
       "type": "object",
-      "required": ["statement", "criteria", "judge_method", "negative_examples"],
+      "required": [
+        "statement",
+        "criteria",
+        "judge_method",
+        "negative_examples"
+      ],
       "additionalProperties": false,
       "properties": {
         "statement": {
@@ -74,7 +110,12 @@
         },
         "judge_method": {
           "type": "string",
-          "enum": ["llm", "vlm", "rule", "human"]
+          "enum": [
+            "llm",
+            "vlm",
+            "rule",
+            "human"
+          ]
         },
         "negative_examples": {
           "type": "array",
@@ -88,25 +129,37 @@
     },
     "apply_to_draft": {
       "type": "object",
-      "required": ["实质", "形式"],
+      "required": [
+        "实质",
+        "形式"
+      ],
       "additionalProperties": false,
       "properties": {
         "实质": {
           "type": "array",
-          "items": { "type": "string" }
+          "items": {
+            "type": "string"
+          }
         },
         "形式": {
           "type": "array",
-          "items": { "type": "string" }
+          "items": {
+            "type": "string"
+          }
         }
       }
     },
     "workflow_step_ref": {
       "anyOf": [
-        { "type": "null" },
+        {
+          "type": "null"
+        },
         {
           "type": "object",
-          "required": ["workflow_id", "step_id"],
+          "required": [
+            "workflow_id",
+            "step_id"
+          ],
           "additionalProperties": false,
           "properties": {
             "workflow_id": {
@@ -145,7 +198,10 @@
         },
         "action": {
           "type": "object",
-          "required": ["description", "reasoning"],
+          "required": [
+            "description",
+            "reasoning"
+          ],
           "additionalProperties": false,
           "properties": {
             "description": {
@@ -160,19 +216,28 @@
         },
         "inputs": {
           "type": "array",
-          "items": { "$ref": "#/definitions/io_item" }
+          "items": {
+            "$ref": "#/definitions/io_item"
+          }
         },
         "outputs": {
           "type": "array",
-          "items": { "$ref": "#/definitions/io_item" }
+          "items": {
+            "$ref": "#/definitions/io_item"
+          }
         },
         "body": {
-          "type": ["string", "null"]
+          "type": [
+            "string",
+            "null"
+          ]
         },
         "effects": {
           "type": "array",
           "minItems": 1,
-          "items": { "$ref": "#/definitions/effect" }
+          "items": {
+            "$ref": "#/definitions/effect"
+          }
         },
         "control_target": {
           "type": "array",
@@ -182,14 +247,23 @@
           }
         },
         "artifact_type": {
-          "type": ["string", "null"]
+          "type": [
+            "string",
+            "null"
+          ]
         },
         "tools": {
           "type": "array",
-          "items": { "type": "string" }
+          "items": {
+            "type": "string"
+          }
+        },
+        "apply_to_draft": {
+          "$ref": "#/definitions/apply_to_draft"
+        },
+        "workflow_step_ref": {
+          "$ref": "#/definitions/workflow_step_ref"
         },
-        "apply_to_draft": { "$ref": "#/definitions/apply_to_draft" },
-        "workflow_step_ref": { "$ref": "#/definitions/workflow_step_ref" },
         "is_alternative_to": {
           "type": "array",
           "items": {
@@ -201,18 +275,26 @@
     },
     "workflow_group": {
       "type": "object",
-      "required": ["workflow_id", "workflow", "capability"],
+      "required": [
+        "workflow_id",
+        "workflow",
+        "capability"
+      ],
       "additionalProperties": false,
       "properties": {
         "workflow_id": {
           "type": "string",
           "pattern": "^w[0-9]+$"
         },
-        "workflow": { "$ref": "#/definitions/workflow" },
+        "workflow": {
+          "$ref": "#/definitions/workflow"
+        },
         "capability": {
           "type": "array",
           "minItems": 1,
-          "items": { "$ref": "#/definitions/capability" }
+          "items": {
+            "$ref": "#/definitions/capability"
+          }
         }
       }
     }
@@ -226,7 +308,9 @@
     },
     "workflow_groups": {
       "type": "array",
-      "items": { "$ref": "#/definitions/workflow_group" }
+      "items": {
+        "$ref": "#/definitions/workflow_group"
+      }
     }
   }
-}
+}

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

@@ -218,9 +218,10 @@ def body_excerpts_are_verbatim(apply_to: Any, suggest_apply_to: Any, body: str)
             return False
         excerpt = item.get("body_excerpt")
         note = item.get("body_excerpt_note")
-        if not isinstance(excerpt, str) or not isinstance(note, str):
+        excerpt_type = item.get("body_excerpt_type")
+        if not isinstance(excerpt, str) or not isinstance(note, str) or not isinstance(excerpt_type, str):
             return False
-        if not excerpt.strip() and note.strip():
+        if not excerpt.strip() and (note.strip() or excerpt_type.strip()):
             return False
         if excerpt.strip() and excerpt.strip() not in body:
             return False

+ 145 - 4
examples/process_pipeline/ui/app.js

@@ -39,13 +39,18 @@ if (selectForcePhase && groupPlatforms) {
 
 const jsonStrategy = document.getElementById('json-strategy');
 const jsonBlueprint = document.getElementById('json-blueprint');
+const jsonCapability = document.getElementById('json-capability');
 const jsonSource = document.getElementById('json-source');
 const jsonRaw = document.getElementById('json-raw');
 
-// Modals
 const modalRun = document.getElementById('run-modal');
 const modalLogs = document.getElementById('logs-modal');
 const terminalLogs = document.getElementById('terminal-logs');
+const modalFragDetail = document.getElementById('frag-detail-modal');
+const btnCloseFragDetail = document.getElementById('btn-close-frag-detail');
+if (btnCloseFragDetail) btnCloseFragDetail.onclick = () => modalFragDetail.classList.add('hidden');
+
+window.allFragmentsMap = {};
 
 const PIPELINE_STEPS = [
     { id: 'research', label: '1.1 分布式爬取' },
@@ -845,6 +850,131 @@ function renderCapabilities(capsObj) {
     return html;
 }
 
+function renderFragmentsGrid(fragments) {
+    if (!fragments || fragments.length === 0) return '<p>没有片段数据。</p>';
+    window.allFragmentsMap = {}; // Reset map
+    let html = `<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; padding: 10px;">`;
+    fragments.forEach(f => {
+        let cid = f.capability_id || ('temp_cap_' + Math.random().toString(36).substring(7));
+        window.allFragmentsMap[cid] = f;
+        
+        let tools = f.tools && f.tools.length > 0 ? f.tools.join(', ') : '无工具';
+        let actionDesc = f.action && f.action.description ? f.action.description : (f.capability_id || 'unknown');
+        let bodyText = f.body || f.body_excerpt || f.rationale || '';
+        if (bodyText.length > 180) bodyText = bodyText.substring(0, 180) + '...';
+        
+        let inputs = f.inputs ? f.inputs.map(i => i.modality || '').filter(Boolean).join('+') : '';
+        let outputs = f.outputs ? f.outputs.map(o => o.modality || '').filter(Boolean).join('+') : '';
+        let ioStr = (inputs && outputs) ? `${inputs} -> ${outputs}` : (f.action && f.action.description ? f.action.description : '转换');
+
+        let applyHtml = '';
+        if (f.apply_to) {
+            Object.values(f.apply_to).forEach(arr => {
+                arr.forEach(item => {
+                    const parts = item.category_path ? item.category_path.split('/') : [];
+                    const label = parts.length > 0 ? parts[parts.length - 1] : '标签';
+                    applyHtml += `<span class="frag-apply-pill">${label}</span>`;
+                });
+            });
+        }
+
+        const caseShort = f._caseId ? f._caseId.split('-').pop() : 'case';
+
+        html += `<div class="frag" onclick="openFragDetail('${cid}')" style="cursor:pointer;">
+            <div class="frag-head">
+                <span class="case-badge" onclick="jumpToCase('${f._caseId}'); event.stopPropagation();" style="cursor:pointer" title="点击跳转案例">${caseShort}</span>
+                <span class="frag-badge">${f._workflowId || 'w'}</span>
+                <span style="margin-left: auto; font-weight: 600; color: var(--accent-primary, #3b82f6);">${ioStr}</span>
+            </div>
+            <div class="frag-sig">${actionDesc} [${tools}]</div>
+            <div class="frag-body">${bodyText}</div>
+            ${applyHtml ? `<div class="frag-apply">${applyHtml}</div>` : ''}
+        </div>`;
+    });
+    html += `</div>`;
+    return html;
+}
+
+window.openFragDetail = function(fragId) {
+    const f = window.allFragmentsMap[fragId];
+    if (!f) return;
+    const modal = document.getElementById('frag-detail-modal');
+    const body = document.getElementById('frag-detail-modal-body');
+    
+    let html = `<div style="background:#fff; padding: 20px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">`;
+    
+    let inputs = f.inputs ? f.inputs.map(i => i.modality || '').filter(Boolean).join('+') : '';
+    let outputs = f.outputs ? f.outputs.map(o => o.modality || '').filter(Boolean).join('+') : '';
+    let ioStr = (inputs && outputs) ? `${inputs} → ${outputs}` : (f.action && f.action.description ? f.action.description : '转换');
+
+    html += `<div style="display:flex; gap: 8px; margin-bottom: 16px;">
+        <span class="case-badge">${f._caseId ? f._caseId.split('-').pop() : 'case'}</span>
+        <span class="frag-badge">${f._workflowId || 'w'}</span>
+        <span class="badge-emoji warning">${f.capability_id || 'unknown'}</span>
+    </div>`;
+
+    html += `<h4 style="margin: 0 0 10px 0; color: #334155;">I/O 模态</h4>`;
+    html += `<div style="font-size: 14px; margin-bottom: 8px;"><strong>${ioStr}</strong></div>`;
+    
+    if (f.inputs && f.inputs.length > 0) {
+        html += `<div style="font-size: 13px; color: #64748b; margin-bottom: 4px;">IN <span style="margin-left: 8px;">${f.inputs.map(i => `<span style="background: #e0e7ff; color: #3730a3; padding: 2px 6px; border-radius: 4px; margin-right: 4px;">${i.description || '输入'} [${i.modality || '未知'}]</span>`).join('')}</span></div>`;
+    }
+    if (f.outputs && f.outputs.length > 0) {
+        html += `<div style="font-size: 13px; color: #64748b; margin-bottom: 16px;">OUT <span style="margin-left: 8px;">${f.outputs.map(o => `<span style="background: #fce7f3; color: #9d174d; padding: 2px 6px; border-radius: 4px; margin-right: 4px;">${o.description || '输出'} [${o.modality || '未知'}]</span>`).join('')}</span></div>`;
+    }
+
+    if (f.body || f.body_excerpt || f.rationale) {
+        html += `<h4 style="margin: 20px 0 10px 0; color: #334155;">BODY</h4>`;
+        html += `<div style="background: #f8fafc; padding: 12px; border-left: 4px solid #cbd5e1; border-radius: 4px; font-size: 14px; color: #475569; line-height: 1.6; white-space: pre-wrap;">${f.body || f.body_excerpt || f.rationale}</div>`;
+    }
+
+    if (f.apply_to) {
+        html += `<h4 style="margin: 20px 0 10px 0; color: #334155;">APPLY TO</h4>`;
+        Object.keys(f.apply_to).forEach(k => {
+            f.apply_to[k].forEach(item => {
+                html += `<div style="margin-bottom: 12px;">
+                    <div style="font-size: 13px; font-weight: 600; color: #2563eb; margin-bottom: 4px;">${item.category_path || k}</div>
+                    <div style="font-size: 13px; color: #64748b; padding-left: 10px; border-left: 2px solid #e2e8f0;">${item.rationale || item.body_excerpt || '无推理说明'}</div>
+                </div>`;
+            });
+        });
+    }
+
+    if (f.suggest_apply_to && f.suggest_apply_to.length > 0) {
+        html += `<h4 style="margin: 20px 0 10px 0; color: #334155;">SUGGEST APPLY TO <span style="font-size: 12px; font-weight: normal; color: #8b5cf6;">(建议新增节点)</span></h4>`;
+        f.suggest_apply_to.forEach(item => {
+            html += `<div style="margin-bottom: 12px;">
+                <div style="font-size: 13px; font-weight: 600; color: #8b5cf6; margin-bottom: 4px;">${item.path || '新节点'}</div>
+                <div style="font-size: 13px; color: #64748b; padding-left: 10px; border-left: 2px solid #e2e8f0;">${item.rationale || item.body_excerpt || '无推理说明'}</div>
+            </div>`;
+        });
+    }
+
+    if (f.effects && f.effects.length > 0) {
+        html += `<h4 style="margin: 20px 0 10px 0; color: #334155;">EFFECTS</h4>`;
+        f.effects.forEach((eff, i) => {
+            html += `<div style="background: #f1f5f9; padding: 12px; border-radius: 6px; margin-bottom: 8px;">
+                <div style="font-size: 14px; font-weight: 600; color: #1e293b; margin-bottom: 8px;">#${i} ${eff.statement || 'Effect'}</div>
+                <div style="font-size: 13px; color: #475569; margin-bottom: 4px;"><strong>判定标准:</strong> ${eff.criteria || '无'}</div>
+                <div style="font-size: 13px; color: #475569; margin-bottom: 4px;"><strong>判定方式:</strong> ${eff.judge_method || '未知'}</div>`;
+            if (eff.negative_examples && eff.negative_examples.length > 0) {
+                html += `<div style="font-size: 13px; color: #475569; margin-top: 8px;"><strong>反例:</strong><ul style="margin: 4px 0 0 20px;">${eff.negative_examples.map(ex => `<li>${ex}</li>`).join('')}</ul></div>`;
+            }
+            html += `</div>`;
+        });
+    }
+    
+    html += `<h4 style="margin: 20px 0 10px 0; color: #334155;">其他</h4>`;
+    let tools = f.tools && f.tools.length > 0 ? f.tools.join(', ') : '无';
+    html += `<div style="font-size: 13px; color: #475569; margin-bottom: 4px;"><strong>tools:</strong> ${tools}</div>`;
+    if (f.artifact_type) html += `<div style="font-size: 13px; color: #475569; margin-bottom: 4px;"><strong>artifact_type:</strong> ${f.artifact_type}</div>`;
+    if (f.control_target) html += `<div style="font-size: 13px; color: #475569; margin-bottom: 4px;"><strong>control_target:</strong> ${Array.isArray(f.control_target) ? f.control_target.join(', ') : f.control_target}</div>`;
+    
+    html += `</div>`;
+    body.innerHTML = html;
+    modal.classList.remove('hidden');
+};
+
 function renderBlueprint(bpObj) {
     if (!bpObj || (!bpObj.blueprints && !bpObj.clusters)) {
         return `<div class="data-card" style="border-color: var(--warning)"><div class="card-header"><div class="card-title">⚠️ 未知或非标准格式 (Blueprint)</div></div><div class="card-body" style="font-family: monospace; font-size: 0.85rem; overflow-x: auto;">${renderJSON(bpObj)}</div></div>`;
@@ -1324,6 +1454,15 @@ async function fetchRequirementData(index) {
 
         if (jsonStrategy) jsonStrategy.innerHTML = ''; // Tab removed
 
+        if (jsonCapability) {
+            const reqStr = (index + 1).toString().padStart(3, '0');
+            jsonCapability.innerHTML = `
+                <div id="container-capability" style="height: calc(100vh - 120px); width: 100%;">
+                    <iframe src="/static/viz_fragment.html?req=${reqStr}&v=17" style="width: 100%; height: 100%; border: none; border-radius: 8px; background: var(--bg-primary);"></iframe>
+                </div>
+            `;
+        }
+
         jsonRaw.innerHTML = renderDataOrRaw(rawCasesClone, renderRawCases);
 
         const clusterData = window.dataCache[index].cluster;
@@ -2625,7 +2764,7 @@ window.renderStructuredData = function (items, type, parentItem = null) {
                 if (
                     typeof pathObj !== 'object'
                     || pathObj === null
-                    || !(pathObj.rationale || pathObj.body_excerpt || pathObj.body_excerpt_note || pathObj.category_id)
+                    || !(pathObj.rationale || pathObj.body_excerpt || pathObj.body_excerpt_note || pathObj.body_excerpt_type || pathObj.category_id)
                 ) {
                     return '';
                 }
@@ -2635,18 +2774,20 @@ window.renderStructuredData = function (items, type, parentItem = null) {
                         ${pathObj.rationale ? `<span class="tooltip-rationale">${escapeApplyToText(pathObj.rationale)}</span>` : ''}
                         ${pathObj.body_excerpt ? `<span class="tooltip-body-excerpt">${escapeApplyToText(pathObj.body_excerpt)}</span>` : ''}
                         ${pathObj.body_excerpt_note ? `<span class="tooltip-body-note">${escapeApplyToText(pathObj.body_excerpt_note)}</span>` : ''}
+                        ${pathObj.body_excerpt_type ? `<span class="tooltip-body-note">type: ${escapeApplyToText(pathObj.body_excerpt_type)}</span>` : ''}
                     </div>
                 `;
             };
             const renderEvidence = (pathObj) => {
                 if (typeof pathObj !== 'object' || pathObj === null) return '';
-                if (!('body_excerpt' in pathObj) && !('body_excerpt_note' in pathObj)) return '';
+                if (!('body_excerpt' in pathObj) && !('body_excerpt_note' in pathObj) && !('body_excerpt_type' in pathObj)) return '';
                 const excerpt = pathObj.body_excerpt || '';
                 const note = pathObj.body_excerpt_note || '';
+                const excerptType = pathObj.body_excerpt_type || '';
                 const excerptKey = excerpt ? makeExcerptKey(excerpt) : '';
                 return `<div class="apply-to-evidence">
                     <div class="apply-to-evidence-row">
-                        <span class="apply-to-evidence-label">关联做法</span>
+                        <span class="apply-to-evidence-label">关联做法 <span class="apply-to-evidence-type ${excerptType ? '' : 'empty'}">${excerptType ? escapeApplyToText(excerptType) : 'type: 空'}</span></span>
                         <span class="apply-to-evidence-value apply-to-evidence-note ${note ? '' : 'empty'}" ${excerptKey ? `data-excerpt-key="${excerptKey}"` : ''}>${note ? escapeApplyToText(note) : '空'}</span>
                     </div>
                 </div>`;

+ 16 - 3
examples/process_pipeline/ui/index.html

@@ -186,8 +186,8 @@
                         <div class="content-viewer" id="json-raw">加载中...</div>
                     </div>
 
-                    <div class="tab-content" id="tab-capability" style="height: 100%; padding: 0;">
-                        <iframe src="http://localhost:9999/capabilities/embed" style="width: 100%; height: 100%; border: none; border-radius: 8px;"></iframe>
+                    <div class="tab-content" id="tab-capability">
+                        <div class="content-viewer" id="json-capability">加载中...</div>
                     </div>
 
                     <div class="tab-content" id="tab-blueprint">
@@ -459,7 +459,20 @@
         </div>
     </div>
 
-    <script src="/static/app.js?v=11"></script>
+    <!-- Frag Detail Modal -->
+    <div class="modal-overlay hidden" id="frag-detail-modal">
+        <div class="modal large glass-panel" style="max-width: 900px; height: 85vh; display: flex; flex-direction: column;">
+            <div class="modal-header">
+                <h3>🧩 能力片段详情</h3>
+                <button class="close-btn" id="btn-close-frag-detail">×</button>
+            </div>
+            <div class="modal-body" id="frag-detail-modal-body" style="flex: 1; overflow-y: auto; padding: 1.5rem; background: #fafafa;">
+                <!-- Content dynamically injected here -->
+            </div>
+        </div>
+    </div>
+
+    <script src="/static/app.js?v=17"></script>
 </body>
 
 </html>

+ 2 - 2
examples/process_pipeline/ui/scratchpad.js

@@ -37,9 +37,9 @@ function renderStructuredData(items, type) {
                 const leaf = parts.pop();
                 const prefix = parts.length > 0 ? parts.join('/') + '/' : '';
                 const leafStyle = highlight ? 'background:#eff6ff; color:#2563eb; border:1px solid #bfdbfe;' : '';
-                const evidence = typeof path === 'object' && path !== null && ('body_excerpt' in path || 'body_excerpt_note' in path)
+                const evidence = typeof path === 'object' && path !== null && ('body_excerpt' in path || 'body_excerpt_note' in path || 'body_excerpt_type' in path)
                     ? `<div style="display:flex; flex-direction:column; gap:2px; font-size:0.78rem; color:#64748b; margin-top:4px;">
-                        <div style="display:flex; flex-direction:column; gap:2px;"><strong style="color:#94a3b8;">关联做法</strong><span>${path.body_excerpt_note ? String(path.body_excerpt_note).replace(/</g, '&lt;').replace(/>/g, '&gt;') : '<span style="color:#cbd5e1;font-style:italic;">空</span>'}</span></div>
+                        <div style="display:flex; flex-direction:column; gap:2px;"><strong style="color:#94a3b8;">关联做法 <span style="font-family:monospace; color:#64748b; border:1px solid #cbd5e1; border-radius:999px; padding:0 5px;">${path.body_excerpt_type ? String(path.body_excerpt_type).replace(/</g, '&lt;').replace(/>/g, '&gt;') : 'type: 空'}</span></strong><span>${path.body_excerpt_note ? String(path.body_excerpt_note).replace(/</g, '&lt;').replace(/>/g, '&gt;') : '<span style="color:#cbd5e1;font-style:italic;">空</span>'}</span></div>
                     </div>`
                     : '';
                 return `<span style="display:inline-flex; flex-direction:column; gap:4px;">

+ 93 - 0
examples/process_pipeline/ui/style.css

@@ -1205,6 +1205,23 @@ body {
     font-weight: 600;
 }
 
+.apply-to-evidence-type {
+    display: inline-block;
+    margin-left: 4px;
+    padding: 0 5px;
+    border: 1px solid #cbd5e1;
+    border-radius: 999px;
+    color: #475569;
+    background: #f8fafc;
+    font-weight: 500;
+    font-family: monospace;
+}
+
+.apply-to-evidence-type.empty {
+    color: #cbd5e1;
+    border-color: #e2e8f0;
+}
+
 .apply-to-evidence-value {
     color: #475569;
     word-break: break-word;
@@ -1432,3 +1449,79 @@ body {
     -webkit-box-orient: vertical;
     overflow: hidden;
 }
+
+/* Fragments */
+.frag {
+    padding: 10px 12px;
+    border-radius: 6px;
+    cursor: default;
+    border: 1px solid var(--border-glass, #e2e8f0);
+    background: #f8fafc;
+    transition: all 0.2s;
+    box-shadow: 0 1px 3px rgba(0,0,0,0.05);
+}
+.frag:hover {
+    background: #ffffff;
+    border-color: var(--accent-primary, #3b82f6);
+    box-shadow: 0 4px 12px rgba(59,130,246,0.1);
+    transform: translateY(-2px);
+}
+.frag-head {
+    font-size: 11px;
+    color: #64748b;
+    margin-bottom: 6px;
+    display: flex;
+    gap: 6px;
+    align-items: center;
+}
+.case-badge {
+    background: var(--accent-primary, #3b82f6);
+    color: #fff;
+    padding: 2px 6px;
+    border-radius: 4px;
+    font-weight: 600;
+    font-size: 10px;
+}
+.frag-badge {
+    background: #8b5cf6;
+    color: #fff;
+    padding: 2px 6px;
+    border-radius: 4px;
+    font-weight: 600;
+    font-size: 10px;
+}
+.frag-sig {
+    font-size: 12px;
+    color: #475569;
+    margin-top: 4px;
+    margin-bottom: 6px;
+    font-family: 'Consolas', 'Menlo', monospace;
+    font-weight: 500;
+}
+.frag-body {
+    font-size: 12px;
+    color: #334155;
+    margin-top: 4px;
+    line-height: 1.5;
+    display: -webkit-box;
+    -webkit-line-clamp: 3;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+    background: #f1f5f9;
+    padding: 6px 8px;
+    border-radius: 4px;
+}
+.frag-apply {
+    margin-top: 8px;
+    display: flex;
+    flex-wrap: wrap;
+    gap: 4px;
+}
+.frag-apply-pill {
+    font-size: 10px;
+    background: #e0e7ff;
+    color: #3730a3;
+    padding: 2px 6px;
+    border-radius: 10px;
+    font-weight: 500;
+}

+ 1851 - 0
examples/process_pipeline/ui/viz_fragment.html

@@ -0,0 +1,1851 @@
+<!doctype html>
+<html lang="zh">
+  <head>
+    <meta charset="utf-8" />
+    <title>工序五层筛选</title>
+    <style>
+      :root {
+        --bg: #f8fafc;
+        --panel: #ffffff;
+        --panel2: #f1f5f9;
+        --border: #e2e8f0;
+        --fg: #0f172a;
+        --muted: #64748b;
+        --accent: #3b82f6;
+        --accent2: #8b5cf6;
+        --tag-bg: #e2e8f0;
+        --tag-bg-active: #3b82f6;
+        --tag-fg-active: #fff;
+        --code: #f1f5f9;
+        --warn: #eab308;
+        --suggest: #8b5cf6;
+        --inferred: #94a3b8;
+      }
+      * {
+        box-sizing: border-box;
+      }
+      html,
+      body {
+        margin: 0;
+        height: 100%;
+        background: var(--bg);
+        color: var(--fg);
+        font:
+          13px/1.55 -apple-system,
+          BlinkMacSystemFont,
+          "PingFang SC",
+          "Helvetica Neue",
+          sans-serif;
+      }
+      header {
+        display: flex;
+        align-items: center;
+        gap: 12px;
+        padding: 8px 14px;
+        border-bottom: 1px solid var(--border);
+        background: var(--panel);
+        position: sticky;
+        top: 0;
+        z-index: 5;
+        flex-wrap: wrap;
+      }
+      h1 {
+        font-size: 14px;
+        margin: 0;
+        font-weight: 600;
+      }
+      .stats {
+        color: var(--muted);
+        font-size: 11px;
+      }
+      .clear-all {
+        margin-left: auto;
+        font-size: 11px;
+        color: var(--muted);
+        cursor: pointer;
+        padding: 4px 10px;
+        border: 1px solid var(--border);
+        border-radius: 4px;
+        background: transparent;
+      }
+      .clear-all:hover {
+        color: var(--fg);
+        border-color: var(--accent);
+      }
+      .clear-all.active {
+        color: var(--warn);
+        border-color: var(--warn);
+      }
+      .exit-fs {
+        margin-left: auto;
+        font-size: 11px;
+        color: var(--muted);
+        cursor: pointer;
+        padding: 4px 10px;
+        border: 1px solid var(--border);
+        border-radius: 4px;
+        background: transparent;
+        display: none;
+      }
+      .exit-fs:hover {
+        color: var(--fg);
+        border-color: var(--accent);
+      }
+
+      main {
+        display: grid;
+        grid-template-columns: 160px 360px 220px 320px 1fr;
+        height: 100vh;
+        min-width: 1480px;
+        background: var(--bg);
+      }
+      section {
+        overflow: auto;
+        border-right: 1px solid var(--border);
+        padding: 8px 10px;
+      }
+      section:last-child {
+        border-right: none;
+      }
+      section.fac1 {
+        background: var(--panel);
+      }
+      section.fac2 {
+        background: #f8fafc;
+      }
+      section.fac3 {
+        background: var(--panel);
+      }
+      section.fac4 {
+        background: #f4f4f5;
+      }
+      section.detail {
+        background: var(--panel);
+      }
+
+      .col-title {
+        font-size: 10px;
+        text-transform: uppercase;
+        letter-spacing: 0.6px;
+        color: var(--muted);
+        margin: 2px 4px 8px;
+        display: flex;
+        justify-content: space-between;
+        align-items: baseline;
+      }
+      .col-count {
+        font-size: 10px;
+        color: var(--muted);
+      }
+      .col-clear {
+        font-size: 10px;
+        color: var(--accent);
+        cursor: pointer;
+        display: none;
+      }
+      .col-clear.show {
+        display: inline;
+      }
+
+      .item {
+        padding: 6px 8px;
+        border-radius: 5px;
+        cursor: pointer;
+        margin-bottom: 3px;
+        border: 1px solid transparent;
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+        gap: 6px;
+      }
+      .item:hover:not(.disabled):not(.active) {
+        background: var(--panel2);
+      }
+      .item.active {
+        background: var(--tag-bg-active);
+        color: var(--tag-fg-active);
+        border-color: var(--tag-bg-active);
+      }
+      .item.disabled {
+        opacity: 0.32;
+        cursor: not-allowed;
+      }
+      .item-name {
+        flex: 1;
+        min-width: 0;
+        font-size: 12px;
+        white-space: nowrap;
+        overflow: hidden;
+        text-overflow: ellipsis;
+      }
+      .item-count {
+        font-size: 10px;
+        background: var(--bg);
+        padding: 1px 5px;
+        border-radius: 8px;
+        color: var(--muted);
+        min-width: 20px;
+        text-align: center;
+        flex-shrink: 0;
+      }
+      .item.active .item-count {
+        background: rgba(0, 0, 0, 0.3);
+        color: #fff;
+      }
+      .item-def {
+        display: block;
+        font-size: 10px;
+        color: var(--muted);
+        margin-top: 2px;
+        line-height: 1.4;
+      }
+      .item.active .item-def {
+        color: rgba(255, 255, 255, 0.85);
+      }
+      .item-stack {
+        flex: 1;
+        min-width: 0;
+      }
+
+      /* col-2 tabs */
+      .scope-tabs {
+        display: flex;
+        gap: 4px;
+        margin: 0 0 8px;
+        border-bottom: 1px solid var(--border);
+        padding-bottom: 5px;
+      }
+      .scope-tab {
+        padding: 4px 10px;
+        border-radius: 5px 5px 0 0;
+        cursor: pointer;
+        font-size: 12px;
+        color: var(--muted);
+        background: transparent;
+        border: 1px solid transparent;
+        border-bottom: none;
+        user-select: none;
+      }
+      .scope-tab.active {
+        color: var(--fg);
+        background: var(--panel2);
+        border-color: var(--border);
+      }
+      .scope-tab:hover:not(.active) {
+        color: var(--fg);
+      }
+      .scope-pane {
+        display: none;
+      }
+      .scope-pane.active {
+        display: block;
+      }
+      .scope-meta {
+        font-size: 10px;
+        color: var(--muted);
+        margin: 0 4px 8px;
+        line-height: 1.4;
+      }
+
+      /* facets */
+      .facet-title {
+        margin-top: 10px;
+        padding: 4px 6px;
+        font-size: 11px;
+        color: var(--muted);
+        font-weight: 600;
+        border-bottom: 1px solid var(--border);
+        text-transform: uppercase;
+        letter-spacing: 0.5px;
+      }
+      .facet-title:first-child {
+        margin-top: 0;
+      }
+
+      /* itemsets (pattern tab) */
+      .iset {
+        padding: 8px 10px;
+        border-radius: 5px;
+        cursor: pointer;
+        margin-bottom: 5px;
+        border: 1px solid var(--border);
+        background: var(--panel2);
+      }
+      .iset:hover:not(.disabled):not(.active) {
+        border-color: var(--accent);
+      }
+      .iset.active {
+        border-color: var(--accent);
+        background: #eff6ff;
+        box-shadow: 0 0 0 1px var(--accent) inset;
+      }
+      .iset.disabled {
+        opacity: 0.32;
+        cursor: not-allowed;
+      }
+      .iset-head {
+        display: flex;
+        align-items: center;
+        gap: 6px;
+        margin-bottom: 5px;
+      }
+      .iset-sup {
+        background: var(--accent);
+        color: #fff;
+        padding: 1px 7px;
+        border-radius: 3px;
+        font-weight: 700;
+        font-size: 11px;
+      }
+      .iset-k {
+        background: var(--tag-bg);
+        color: var(--fg);
+        padding: 1px 6px;
+        border-radius: 3px;
+        font-size: 10px;
+        letter-spacing: 0.3px;
+      }
+      .iset-meta {
+        font-size: 10px;
+        color: var(--muted);
+        margin-left: auto;
+      }
+      .iset-paths {
+        display: flex;
+        flex-direction: column;
+        gap: 3px;
+      }
+      .iset-path {
+        display: flex;
+        gap: 5px;
+        font-size: 11px;
+        line-height: 1.4;
+        align-items: flex-start;
+      }
+      .iset-fdot {
+        width: 18px;
+        flex-shrink: 0;
+        font-size: 9px;
+        font-weight: 700;
+        text-align: center;
+        border-radius: 3px;
+        line-height: 16px;
+        height: 16px;
+        letter-spacing: 0;
+        margin-top: 1px;
+      }
+      .iset-fdot.shi {
+        background: #dbeafe;
+        color: #1d4ed8;
+      }
+      .iset-fdot.xing {
+        background: #f3e8ff;
+        color: #7e22ce;
+      }
+      .iset-fdot.both {
+        background: #fef9c3;
+        color: #a16207;
+      }
+      .iset-leaf {
+        font-weight: 600;
+        color: var(--fg);
+        word-break: break-all;
+      }
+      .iset-parent {
+        color: var(--muted);
+        font-size: 10px;
+        margin-top: 1px;
+        word-break: break-all;
+        line-height: 1.3;
+        font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+      }
+
+      /* tree */
+      .tnode {
+        margin-left: 0;
+      }
+      .tnode .tnode {
+        margin-left: 12px;
+        border-left: 1px dashed var(--border);
+        padding-left: 6px;
+      }
+      .tline {
+        display: flex;
+        align-items: center;
+        gap: 4px;
+        padding: 3px 4px;
+        border-radius: 4px;
+        cursor: pointer;
+        font-size: 12px;
+        line-height: 1.35;
+      }
+      .tline:hover:not(.disabled):not(.active) {
+        background: var(--panel2);
+      }
+      .tline.active {
+        background: var(--tag-bg-active);
+        color: var(--tag-fg-active);
+      }
+      .tline.disabled {
+        opacity: 0.32;
+        cursor: not-allowed;
+      }
+      .tline.suggested .tname {
+        color: var(--suggest);
+        font-style: italic;
+      }
+      .tline.suggested.active .tname {
+        color: #fff;
+      }
+      .tline.inferred .tname {
+        color: var(--inferred);
+        font-style: italic;
+      }
+      .tline.inferred.active .tname {
+        color: #fff;
+      }
+      .tcaret {
+        width: 12px;
+        display: inline-block;
+        text-align: center;
+        color: var(--muted);
+        cursor: pointer;
+        user-select: none;
+        flex-shrink: 0;
+        font-size: 10px;
+      }
+      .tcaret.invis {
+        visibility: hidden;
+      }
+      .tname {
+        flex: 1;
+        min-width: 0;
+        white-space: nowrap;
+        overflow: hidden;
+        text-overflow: ellipsis;
+      }
+      .tcount {
+        font-size: 10px;
+        color: var(--muted);
+        background: var(--bg);
+        padding: 1px 5px;
+        border-radius: 8px;
+        flex-shrink: 0;
+      }
+      .tline.active .tcount {
+        background: rgba(0, 0, 0, 0.3);
+        color: #fff;
+      }
+      .tcollapsed > .tnode {
+        display: none;
+      }
+
+      /* fragments list */
+      .frag {
+        padding: 7px 9px;
+        border-radius: 5px;
+        cursor: pointer;
+        margin-bottom: 4px;
+        border: 1px solid var(--border);
+        background: var(--panel);
+      }
+      .frag:hover {
+        background: var(--panel2);
+      }
+      .frag.active {
+        border-color: var(--accent);
+        background: var(--panel2);
+      }
+      .frag-head {
+        font-size: 10px;
+        color: var(--muted);
+        margin-bottom: 3px;
+        display: flex;
+        gap: 6px;
+        align-items: center;
+      }
+      .case-badge {
+        background: var(--accent);
+        color: #fff;
+        padding: 1px 5px;
+        border-radius: 3px;
+        font-weight: 600;
+        font-size: 10px;
+      }
+      .frag-badge {
+        background: var(--accent2);
+        color: #fff;
+        padding: 1px 5px;
+        border-radius: 3px;
+        font-weight: 600;
+        font-size: 10px;
+      }
+      .act-badge {
+        background: var(--warn);
+        color: #000;
+        padding: 1px 5px;
+        border-radius: 3px;
+        font-weight: 600;
+        font-size: 10px;
+      }
+      .frag-sig {
+        font-size: 11px;
+        color: var(--muted);
+        margin-top: 2px;
+        font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+      }
+      .frag-body {
+        font-size: 12px;
+        color: #334155;
+        margin-top: 4px;
+        line-height: 1.45;
+        display: -webkit-box;
+        -webkit-line-clamp: 2;
+        -webkit-box-orient: vertical;
+        overflow: hidden;
+      }
+
+      /* detail */
+      .detail-empty {
+        color: var(--muted);
+        text-align: center;
+        padding: 60px 20px;
+        font-size: 13px;
+      }
+      .detail h2 {
+        font-size: 14px;
+        margin: 0 0 8px;
+        display: flex;
+        gap: 8px;
+        align-items: center;
+        flex-wrap: wrap;
+      }
+      .case-title {
+        font-size: 12px;
+        color: var(--accent);
+        margin: 6px 0;
+        line-height: 1.5;
+      }
+      .case-title a {
+        color: var(--accent);
+        text-decoration: none;
+      }
+      .case-title a:hover {
+        text-decoration: underline;
+      }
+      .detail-section {
+        margin: 12px 0;
+      }
+      .detail-section h3 {
+        font-size: 10px;
+        color: var(--muted);
+        text-transform: uppercase;
+        letter-spacing: 0.6px;
+        margin: 0 0 6px;
+        font-weight: 600;
+      }
+      .body-text {
+        font-size: 12px;
+        line-height: 1.6;
+        color: #334155;
+        background: var(--code);
+        padding: 8px 10px;
+        border-radius: 5px;
+        white-space: pre-wrap;
+      }
+      .effect-card {
+        background: var(--code);
+        border-radius: 5px;
+        padding: 7px 9px;
+        margin-bottom: 5px;
+        border-left: 3px solid var(--accent2);
+        font-size: 12px;
+      }
+      .effect-stmt {
+        font-weight: 600;
+        margin-bottom: 4px;
+      }
+      .effect-meta {
+        color: var(--muted);
+        font-size: 11px;
+        margin-top: 3px;
+      }
+      .pill {
+        display: inline-block;
+        padding: 1px 6px;
+        background: var(--tag-bg);
+        border-radius: 8px;
+        font-size: 11px;
+        margin-right: 3px;
+        margin-bottom: 3px;
+      }
+      .pill.in {
+        background: #dbeafe;
+        color: #1e3a8a;
+      }
+      .pill.out {
+        background: #f3e8ff;
+        color: #581c87;
+      }
+      .pill.cfg {
+        background: #fef9c3;
+        color: #854d0e;
+      }
+      .io-row {
+        margin-bottom: 4px;
+        font-size: 11px;
+      }
+      .io-row .lbl {
+        color: var(--muted);
+        font-size: 10px;
+        margin-right: 6px;
+        text-transform: uppercase;
+      }
+      .pathline {
+        font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+        font-size: 11px;
+        color: var(--accent);
+        margin-bottom: 2px;
+        word-break: break-all;
+      }
+      .pathline.suggest {
+        color: var(--suggest);
+      }
+      .rationale {
+        color: var(--muted);
+        font-size: 11px;
+        margin-bottom: 6px;
+        margin-left: 4px;
+        line-height: 1.45;
+      }
+      .json-raw {
+        font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+        font-size: 10px;
+        background: var(--code);
+        padding: 8px;
+        border-radius: 5px;
+        white-space: pre-wrap;
+        color: var(--fg);
+        max-height: 240px;
+        overflow: auto;
+      }
+      details summary {
+        cursor: pointer;
+        color: var(--muted);
+        font-size: 10px;
+        margin: 6px 0;
+        user-select: none;
+      }
+      details summary:hover {
+        color: var(--fg);
+      }
+
+      .empty-msg {
+        color: var(--muted);
+        text-align: center;
+        padding: 30px 10px;
+        font-size: 11px;
+        font-style: italic;
+      }
+
+      /* modality side filter (column 3 internal) */
+      .mod-filter {
+        margin: 0 4px 8px;
+        padding: 6px 6px 4px;
+        background: var(--code);
+        border: 1px solid var(--border);
+        border-radius: 5px;
+      }
+      .mod-filter-row {
+        display: flex;
+        align-items: center;
+        gap: 6px;
+        margin-bottom: 4px;
+      }
+      .mod-filter-row:last-child {
+        margin-bottom: 0;
+      }
+      .mod-filter-lbl {
+        font-size: 10px;
+        color: var(--muted);
+        min-width: 42px;
+        flex-shrink: 0;
+      }
+      .mod-chips {
+        display: flex;
+        gap: 4px;
+        flex-wrap: wrap;
+      }
+      .mod-chip {
+        font-size: 11px;
+        padding: 1px 7px;
+        background: var(--tag-bg);
+        border-radius: 8px;
+        cursor: pointer;
+        color: var(--fg);
+        user-select: none;
+        border: 1px solid transparent;
+      }
+      .mod-chip:hover:not(.disabled) {
+        background: var(--panel2);
+        border-color: var(--border);
+      }
+      .mod-chip.active {
+        background: var(--tag-bg-active);
+        color: var(--tag-fg-active);
+      }
+      .mod-chip.disabled {
+        opacity: 0.32;
+        cursor: not-allowed;
+      }
+
+      /* selection summary chip in header */
+      .filter-chip {
+        font-size: 11px;
+        padding: 3px 8px;
+        background: var(--tag-bg);
+        border-radius: 10px;
+        color: var(--fg);
+        display: inline-flex;
+        align-items: center;
+        gap: 6px;
+      }
+      .filter-chip .x {
+        color: var(--muted);
+        cursor: pointer;
+        font-weight: bold;
+      }
+      .filter-chip .x:hover {
+        color: var(--warn);
+      }
+      .filter-chip .lbl {
+        color: var(--muted);
+        font-size: 9px;
+        text-transform: uppercase;
+      }
+    </style>
+  </head>
+  <body>
+    <header>
+      <h1>工序五层筛选</h1>
+      <span class="stats" id="stats"></span>
+      <span id="chips"></span>
+      <button class="exit-fs" id="exitFs">退出全屏</button>
+      <button class="clear-all" id="clearAll">清除全部筛选</button>
+    </header>
+
+    <main>
+      <section class="fac1" id="actCol">
+        <div class="col-title">
+          <span>① 动作</span><span class="col-count" id="actCnt"></span>
+        </div>
+        <div id="actList"></div>
+      </section>
+      <section class="fac2" id="scopeCol">
+        <div class="col-title">
+          <span>② 作用域</span>
+          <span class="col-count" id="scopeStat"></span>
+        </div>
+        <div class="scope-tabs">
+          <div class="scope-tab" data-mode="node" id="tabNode">
+            node · 内容树
+          </div>
+          <div class="scope-tab" data-mode="pattern" id="tabPattern">
+            pattern · 频繁项集
+          </div>
+          <span
+            class="col-clear"
+            id="scopeClear"
+            style="margin-left: auto; align-self: center"
+            >清除</span
+          >
+        </div>
+
+        <div class="scope-pane" id="paneNode">
+          <div class="facet-title">实质</div>
+          <div id="shizhiTree"></div>
+          <div class="facet-title">形式</div>
+          <div id="xingshiTree"></div>
+        </div>
+
+        <div class="scope-pane" id="panePattern">
+          <div class="scope-meta" id="isParams"></div>
+          <div id="isList"></div>
+        </div>
+      </section>
+      <section class="fac3" id="modCol">
+        <div class="col-title">
+          <span>③ 输入 → 输出模态</span
+          ><span class="col-clear" id="modClear">清除</span>
+        </div>
+        <div class="mod-filter">
+          <div class="mod-filter-row">
+            <span class="mod-filter-lbl">输入含</span>
+            <span class="mod-chips" id="modInChips"></span>
+          </div>
+          <div class="mod-filter-row">
+            <span class="mod-filter-lbl">输出含</span>
+            <span class="mod-chips" id="modOutChips"></span>
+          </div>
+        </div>
+        <div id="modList"></div>
+      </section>
+      <section class="fac4" id="fragCol">
+        <div class="col-title">
+          <span id="fragColTitle">④ Fragments</span
+          ><span class="col-count" id="fragCnt"></span>
+        </div>
+        <div id="fragList"></div>
+      </section>
+      <section class="detail" id="detailCol">
+        <div class="col-title"><span>⑤ 来源工序详情</span></div>
+        <div id="detailBody">
+          <div class="detail-empty">点击左侧 fragment 查看详情</div>
+        </div>
+      </section>
+    </main>
+
+    
+    <script>
+      
+      let data = null;
+      let SIDE_MODS = null;
+      let shiDesc = null;
+      let xingDesc = null;
+
+      const urlParams = new URLSearchParams(window.location.search);
+      const reqIdx = urlParams.get('req') || '108';
+
+      Promise.all([
+        fetch(`/output/${reqIdx}/case.json`).then(r => r.json()).catch(() => null),
+        fetch(`/output/${reqIdx}/fragment.json`).then(r => r.json()).catch(() => null)
+      ]).then(([caseData, fragData]) => {
+        if (!caseData) {
+            throw new Error("case.json is required but not found");
+        }
+
+        if (fragData && fragData.fragments && fragData.subtree) {
+            data = fragData;
+        } else {
+            console.warn("fragment.json missing or invalid. Building data dynamically from case.json apply_to/apply_to_draft...");
+            data = buildDataFromCase(caseData);
+        }
+
+        const casesMap = {};
+        if (caseData && caseData.cases) {
+          caseData.cases.forEach(c => casesMap[c.index] = c);
+        }
+        data.fragments.forEach(f => {
+          if (casesMap[f.case_index]) {
+            f.case_title = casesMap[f.case_index].title;
+            f.case_url = casesMap[f.case_index].url;
+            f.case_cover = casesMap[f.case_index].cover;
+            f.case_body = casesMap[f.case_index].body;
+            f.case_images = casesMap[f.case_index].images;
+          }
+        });
+        initApp();
+      }).catch(err => {
+        document.body.innerHTML = `<div style="padding: 40px; text-align: center; color: var(--muted); font-size: 14px;">
+          <h2>⚠️ 缺少必需的数据文件</h2>
+          <p>无法加载 /output/${reqIdx}/case.json,或数据解析失败。</p>
+          <p>请确保流水线已运行完毕并生成了 case.json。</p>
+        </div>`;
+      });
+
+      function buildDataFromCase(caseData) {
+        let frags = [];
+        caseData.cases.forEach(c => {
+           let groups = c.workflow_groups || [];
+           groups.forEach(g => {
+             let caps = g.capability || [];
+             caps.forEach((cap, idx) => {
+               let apply = cap.apply_to || cap.apply_to_draft || {};
+               let apply_shizhi = [];
+               let apply_xingshi = [];
+               
+               if (apply["实质"]) apply_shizhi = apply["实质"].map(s => typeof s === 'string' ? {category_path: s} : {category_path: s.category_path || s.path || s});
+               if (apply["形式"]) apply_xingshi = apply["形式"].map(s => typeof s === 'string' ? {category_path: s} : {category_path: s.category_path || s.path || s});
+
+               let inMod = cap.inputs ? cap.inputs.map(i=>i.modality).join('+') : '∅';
+               let outMod = cap.outputs ? cap.outputs.map(i=>i.modality).join('+') : '∅';
+               let sig = `${inMod} → ${outMod}`;
+
+               frags.push({
+                 ...cap,
+                 case_index: c.index,
+                 fragment_id: cap.capability_id || `${g.workflow_id}_${idx}`,
+                 action: cap.action ? cap.action.description : '未知',
+                 apply_shizhi,
+                 apply_xingshi,
+                 modality_signature: sig,
+                 original_cap: cap 
+               });
+             });
+           });
+        });
+
+        let actMap = new Map();
+        frags.forEach(f => actMap.set(f.action, (actMap.get(f.action) || 0) + 1));
+        let actions = Array.from(actMap.entries()).map(([verb, count]) => ({verb, count}));
+        actions.sort((a,b)=>b.count-a.count);
+
+        let modMap = new Map();
+        frags.forEach(f => modMap.set(f.modality_signature, (modMap.get(f.modality_signature) || 0) + 1));
+        let modalities = Array.from(modMap.entries()).map(([sig, count]) => ({sig, count}));
+        modalities.sort((a,b)=>b.count-a.count);
+
+        function buildTree(pathList) {
+          let rootNodes = [];
+          let nodeMap = {};
+          pathList.forEach(p => {
+             if (!p) return;
+             let parts = p.split('/').filter(x => x);
+             let currPath = '';
+             let currentLevelList = rootNodes;
+             parts.forEach(part => {
+               currPath += '/' + part;
+               if (!nodeMap[currPath]) {
+                 let newNode = { path: currPath, name: part, children: [] };
+                 nodeMap[currPath] = newNode;
+                 currentLevelList.push(newNode);
+               }
+               currentLevelList = nodeMap[currPath].children;
+             });
+          });
+          return rootNodes;
+        }
+
+        let shizhiPaths = new Set();
+        let xingshiPaths = new Set();
+        frags.forEach(f => {
+           f.apply_shizhi.forEach(s => s.category_path && shizhiPaths.add(s.category_path));
+           f.apply_xingshi.forEach(s => s.category_path && xingshiPaths.add(s.category_path));
+        });
+
+        return {
+          fragments: frags,
+          subtree: {
+            shizhi: buildTree(Array.from(shizhiPaths)),
+            xingshi: buildTree(Array.from(xingshiPaths))
+          },
+          actions,
+          modalities,
+          itemsets: [],
+          itemsetsParams: { min_support: 0, max_k: 0, include_ancestors: false },
+          stats: { fragments: frags.length, fragments_skipped: 0 }
+        };
+      }
+
+      function initApp() {
+
+
+      // State of selections (cascading filters)
+      const state = {
+        action: null, // verb string
+        scopeMode: "node", // 'node' or 'pattern' — which sub-filter is active
+        shizhiPath: null, // path (node mode)
+        xingshiPath: null, // path (node mode)
+        itemsetIdx: null, // index into data.itemsets (pattern mode)
+        modSig: null,
+        modInFilter: new Set(),
+        modOutFilter: new Set(),
+        fragKey: null,
+        collapsed: new Set(),
+      };
+
+      // Parse a modality signature into input/output sets
+      function sigParts(sig) {
+        const [inS, outS] = sig.split(" → ");
+        const parse = (s) =>
+          new Set(s.split("+").filter((x) => x && x !== "∅"));
+        return { in: parse(inS), out: parse(outS) };
+      }
+      // All non-config modalities seen in any signature (for the chip palette)
+      const SIDE_MODS = (() => {
+        const inSet = new Set(),
+          outSet = new Set();
+        for (const m of data.modalities) {
+          const p = sigParts(m.sig);
+          for (const x of p.in) inSet.add(x);
+          for (const x of p.out) outSet.add(x);
+        }
+        return { in: [...inSet].sort(), out: [...outSet].sort() };
+      })();
+
+      function $(id) {
+        return document.getElementById(id);
+      }
+      function el(tag, attrs, ...kids) {
+        const e = document.createElement(tag);
+        if (attrs)
+          for (const k in attrs) {
+            if (k === "class") e.className = attrs[k];
+            else if (k === "onClick") e.addEventListener("click", attrs[k]);
+            else if (k.startsWith("data-")) e.setAttribute(k, attrs[k]);
+            else if (k === "title") e.title = attrs[k];
+            else e[k] = attrs[k];
+          }
+        for (const kid of kids) {
+          if (kid == null || kid === false) continue;
+          if (typeof kid === "string")
+            e.appendChild(document.createTextNode(kid));
+          else e.appendChild(kid);
+        }
+        return e;
+      }
+
+      const fragKey = (f) => f.case_index + ":" + f.fragment_id;
+
+      // build path -> all descendant paths map for tree filtering
+      function buildDescMap(roots) {
+        const map = new Map();
+        function walk(node) {
+          const all = [node.path];
+          for (const c of node.children || []) {
+            const sub = walk(c);
+            for (const p of sub) all.push(p);
+          }
+          map.set(node.path, all);
+          return all;
+        }
+        for (const r of roots) walk(r);
+        return map;
+      }
+      const shiDesc = buildDescMap(data.subtree.shizhi || []);
+      const xingDesc = buildDescMap(data.subtree.xingshi || []);
+
+      function fragMatchesPath(f, key, selPath, descMap) {
+        if (!selPath) return true;
+        const allowed = descMap.get(selPath) || [selPath];
+        const allowedSet = new Set(allowed);
+        const list = key === "shizhi" ? f.apply_shizhi : f.apply_xingshi;
+        for (const e of list || []) {
+          if (allowedSet.has(e.category_path)) return true;
+        }
+        return false;
+      }
+
+      // Pattern mode helpers
+      function fragPathSet(f) {
+        const s = new Set();
+        for (const e of f.apply_shizhi || [])
+          if (e.category_path) s.add(e.category_path);
+        for (const e of f.apply_xingshi || [])
+          if (e.category_path) s.add(e.category_path);
+        return s;
+      }
+      const fragPaths = new Map();
+      for (const f of data.fragments)
+        fragPaths.set(f.case_index + ":" + f.fragment_id, fragPathSet(f));
+      function fragMatchesItemset(f, itemset) {
+        if (!itemset) return true;
+        const ps = fragPaths.get(f.case_index + ":" + f.fragment_id);
+        for (const p of itemset.items) if (!ps.has(p)) return false;
+        return true;
+      }
+
+      // Active scope filter only counts when we're on the matching tab.
+      function scopeFilterActive() {
+        if (state.scopeMode === "node")
+          return !!(state.shizhiPath || state.xingshiPath);
+        if (state.scopeMode === "pattern") return state.itemsetIdx != null;
+        return false;
+      }
+
+      // Compute filtered fragments given the current state, optionally excluding one filter dim
+      function applyFilters(except) {
+        return data.fragments.filter((f) => {
+          if (except !== "action" && state.action && f.action !== state.action)
+            return false;
+          if (except !== "scope" && scopeFilterActive()) {
+            if (state.scopeMode === "node") {
+              if (
+                state.shizhiPath &&
+                !fragMatchesPath(f, "shizhi", state.shizhiPath, shiDesc)
+              )
+                return false;
+              if (
+                state.xingshiPath &&
+                !fragMatchesPath(f, "xingshi", state.xingshiPath, xingDesc)
+              )
+                return false;
+            } else if (state.scopeMode === "pattern") {
+              const sel = data.itemsets[state.itemsetIdx];
+              if (sel && !fragMatchesItemset(f, sel)) return false;
+            }
+          }
+          if (
+            except !== "mod" &&
+            state.modSig &&
+            f.modality_signature !== state.modSig
+          )
+            return false;
+          return true;
+        });
+      }
+
+      // === RENDERERS ===
+
+      function renderActions() {
+        const baseFiltered = applyFilters("action");
+        const counts = {};
+        for (const f of baseFiltered)
+          counts[f.action] = (counts[f.action] || 0) + 1;
+        const wrap = $("actList");
+        wrap.innerHTML = "";
+        for (const a of data.actions) {
+          const c = counts[a.verb] || 0;
+          const node = el("div", {
+            class:
+              "item" +
+              (state.action === a.verb ? " active" : "") +
+              (c === 0 && state.action !== a.verb ? " disabled" : ""),
+            onClick: () => {
+              if (c === 0 && state.action !== a.verb) return;
+              state.action = state.action === a.verb ? null : a.verb;
+              renderAll();
+            },
+          });
+          const stack = el(
+            "div",
+            { class: "item-stack" },
+            el("div", { class: "item-name" }, a.verb),
+            el("div", { class: "item-def" }, a.definition),
+          );
+          node.appendChild(stack);
+          node.appendChild(el("span", { class: "item-count" }, String(c)));
+          wrap.appendChild(node);
+        }
+        $("actCnt").textContent = "共 " + data.actions.length + " 个";
+      }
+
+      function renderTreeFacet(roots, key, mountId, selPath, descMap) {
+        const wrap = $(mountId);
+        wrap.innerHTML = "";
+        const baseFiltered = applyFilters("scope"); // exclude scope filters so this column shows what could be selected
+        // Compute hits: each fragment puts its 实质/形式 leaf paths in hit set
+        const hitCounts = new Map();
+        for (const f of baseFiltered) {
+          const list = key === "shizhi" ? f.apply_shizhi : f.apply_xingshi;
+          const seen = new Set();
+          for (const e of list || []) {
+            // walk up the path adding to ancestor counts so subtree counts roll up
+            const parts = (e.category_path || "").split("/").filter(Boolean);
+            for (let i = 1; i <= parts.length; i++) {
+              const p = "/" + parts.slice(0, i).join("/");
+              if (seen.has(p)) continue;
+              seen.add(p);
+            }
+          }
+          for (const p of seen) hitCounts.set(p, (hitCounts.get(p) || 0) + 1);
+        }
+
+        function renderNode(node, parent) {
+          const c = hitCounts.get(node.path) || 0;
+          const collapsed = state.collapsed.has(node.path);
+          const hasChildren = (node.children || []).length > 0;
+          const tnode = el("div", {
+            class: "tnode" + (collapsed ? " tcollapsed" : ""),
+          });
+          const cls = ["tline"];
+          if (selPath === node.path) cls.push("active");
+          if (c === 0 && selPath !== node.path) cls.push("disabled");
+          if (node.is_suggested && !node.is_hit) cls.push("suggested");
+          if (node.is_inferred) cls.push("inferred");
+          const line = el("div", {
+            class: cls.join(" "),
+            title:
+              node.path + (node.description ? "\n\n" + node.description : ""),
+          });
+          const caret = el(
+            "span",
+            {
+              class: "tcaret" + (hasChildren ? "" : " invis"),
+              onClick: (ev) => {
+                ev.stopPropagation();
+                if (state.collapsed.has(node.path))
+                  state.collapsed.delete(node.path);
+                else state.collapsed.add(node.path);
+                renderTrees();
+              },
+            },
+            hasChildren ? (collapsed ? "▶" : "▼") : "·",
+          );
+          line.appendChild(caret);
+          let label = node.name;
+          if (node.is_suggested && !node.is_hit) label += " ✦";
+          if (node.is_inferred) label += " (推断)";
+          line.appendChild(el("span", { class: "tname" }, label));
+          line.appendChild(el("span", { class: "tcount" }, String(c)));
+          line.addEventListener("click", () => {
+            if (c === 0 && selPath !== node.path) return;
+            const k = key === "shizhi" ? "shizhiPath" : "xingshiPath";
+            state[k] = state[k] === node.path ? null : node.path;
+            renderAll();
+          });
+          tnode.appendChild(line);
+          for (const ch of node.children || []) renderNode(ch, tnode);
+          parent.appendChild(tnode);
+        }
+        for (const r of roots) renderNode(r, wrap);
+      }
+
+      function renderTrees() {
+        renderTreeFacet(
+          data.subtree.shizhi || [],
+          "shizhi",
+          "shizhiTree",
+          state.shizhiPath,
+          shiDesc,
+        );
+        renderTreeFacet(
+          data.subtree.xingshi || [],
+          "xingshi",
+          "xingshiTree",
+          state.xingshiPath,
+          xingDesc,
+        );
+      }
+
+      function pathLeafAndParent(p) {
+        const parts = p.split("/").filter(Boolean);
+        if (!parts.length) return { leaf: p, parent: "" };
+        return {
+          leaf: parts[parts.length - 1],
+          parent: "/" + parts.slice(0, -1).join("/"),
+        };
+      }
+
+      function renderItemsets() {
+        const wrap = $("isList");
+        wrap.innerHTML = "";
+        $("isParams").textContent =
+          `${data.itemsets.length} closed · min_support=${data.itemsetsParams.min_support}, k≤${data.itemsetsParams.max_k}, ${data.itemsetsParams.include_ancestors ? "+ancestors" : "leaf-only"}`;
+
+        const baseFiltered = applyFilters("scope");
+        data.itemsets.forEach((iset, idx) => {
+          const supportNow = baseFiltered.filter((f) =>
+            fragMatchesItemset(f, iset),
+          ).length;
+          const isActive = state.itemsetIdx === idx;
+          const cls = ["iset"];
+          if (isActive) cls.push("active");
+          if (supportNow === 0 && !isActive) cls.push("disabled");
+          const node = el("div", {
+            class: cls.join(" "),
+            onClick: () => {
+              if (supportNow === 0 && !isActive) return;
+              state.itemsetIdx = isActive ? null : idx;
+              renderAll();
+            },
+          });
+          const head = el(
+            "div",
+            { class: "iset-head" },
+            el(
+              "span",
+              { class: "iset-sup", title: "当前筛选下的支持度" },
+              "×" + supportNow,
+            ),
+            el("span", { class: "iset-k" }, "k=" + iset.size),
+            el("span", { class: "iset-meta" }, "原始 sup " + iset.support),
+          );
+          node.appendChild(head);
+          const paths = el("div", { class: "iset-paths" });
+          for (const p of iset.items) {
+            const facet = data.pathToFacet[p] || "?";
+            const dot =
+              facet === "实质"
+                ? el("span", { class: "iset-fdot shi", title: "实质" }, "实")
+                : facet === "形式"
+                  ? el("span", { class: "iset-fdot xing", title: "形式" }, "形")
+                  : el(
+                      "span",
+                      { class: "iset-fdot both", title: "两侧都有" },
+                      "双",
+                    );
+            const lp = pathLeafAndParent(p);
+            const row = el(
+              "div",
+              { class: "iset-path", title: p },
+              dot,
+              el(
+                "div",
+                null,
+                el("span", { class: "iset-leaf" }, lp.leaf),
+                el("div", { class: "iset-parent" }, lp.parent),
+              ),
+            );
+            paths.appendChild(row);
+          }
+          node.appendChild(paths);
+          wrap.appendChild(node);
+        });
+      }
+
+      function renderScope() {
+        // tab visuals
+        $("tabNode").classList.toggle("active", state.scopeMode === "node");
+        $("tabPattern").classList.toggle(
+          "active",
+          state.scopeMode === "pattern",
+        );
+        $("paneNode").classList.toggle("active", state.scopeMode === "node");
+        $("panePattern").classList.toggle(
+          "active",
+          state.scopeMode === "pattern",
+        );
+        // status
+        if (state.scopeMode === "node") {
+          $("scopeStat").textContent = "路径树(双 facet)";
+        } else {
+          $("scopeStat").textContent = `${data.itemsets.length} 项集`;
+        }
+        $("scopeClear").classList.toggle("show", scopeFilterActive());
+        // body
+        if (state.scopeMode === "node") renderTrees();
+        else renderItemsets();
+      }
+
+      function sigPassesChipFilter(sig) {
+        const p = sigParts(sig);
+        for (const m of state.modInFilter) if (!p.in.has(m)) return false;
+        for (const m of state.modOutFilter) if (!p.out.has(m)) return false;
+        return true;
+      }
+
+      function renderModalityChips() {
+        const renderSide = (mountId, palette, selectedSet, side) => {
+          const wrap = $(mountId);
+          wrap.innerHTML = "";
+          // Counts: how many sigs would remain if THIS chip were toggled on (alongside the other already-selected chips on this side)
+          for (const m of palette) {
+            const trial = new Set(selectedSet);
+            if (!trial.has(m)) trial.add(m);
+            let c = 0;
+            for (const sig of data.modalities) {
+              const p = sigParts(sig.sig);
+              const inOK =
+                side === "in"
+                  ? [...trial].every((x) => p.in.has(x)) &&
+                    [...state.modOutFilter].every((x) => p.out.has(x))
+                  : [...state.modInFilter].every((x) => p.in.has(x)) &&
+                    [...trial].every((x) => p.out.has(x));
+              if (inOK) c++;
+            }
+            const isActive = selectedSet.has(m);
+            const chip = el(
+              "span",
+              {
+                class:
+                  "mod-chip" +
+                  (isActive ? " active" : "") +
+                  (c === 0 && !isActive ? " disabled" : ""),
+                title: m + (c === 0 && !isActive ? " (无匹配)" : ""),
+                onClick: () => {
+                  if (c === 0 && !isActive) return;
+                  if (selectedSet.has(m)) selectedSet.delete(m);
+                  else selectedSet.add(m);
+                  // if the currently selected modSig no longer matches, drop it
+                  if (state.modSig && !sigPassesChipFilter(state.modSig))
+                    state.modSig = null;
+                  renderAll();
+                },
+              },
+              m,
+            );
+            wrap.appendChild(chip);
+          }
+        };
+        renderSide("modInChips", SIDE_MODS.in, state.modInFilter, "in");
+        renderSide("modOutChips", SIDE_MODS.out, state.modOutFilter, "out");
+      }
+
+      function renderModalities() {
+        const baseFiltered = applyFilters("mod");
+        const counts = {};
+        for (const f of baseFiltered)
+          counts[f.modality_signature] =
+            (counts[f.modality_signature] || 0) + 1;
+        renderModalityChips();
+        const wrap = $("modList");
+        wrap.innerHTML = "";
+        const visible = data.modalities.filter((m) =>
+          sigPassesChipFilter(m.sig),
+        );
+        const sorted = [...visible].sort(
+          (a, b) => (counts[b.sig] || 0) - (counts[a.sig] || 0),
+        );
+        if (sorted.length === 0) {
+          wrap.appendChild(
+            el("div", { class: "empty-msg" }, "当前 chip 筛选下没有匹配签名"),
+          );
+        }
+        for (const m of sorted) {
+          const c = counts[m.sig] || 0;
+          const node = el(
+            "div",
+            {
+              class:
+                "item" +
+                (state.modSig === m.sig ? " active" : "") +
+                (c === 0 && state.modSig !== m.sig ? " disabled" : ""),
+              onClick: () => {
+                if (c === 0 && state.modSig !== m.sig) return;
+                state.modSig = state.modSig === m.sig ? null : m.sig;
+                renderAll();
+              },
+            },
+            el("span", { class: "item-name" }, m.sig),
+            el("span", { class: "item-count" }, String(c)),
+          );
+          wrap.appendChild(node);
+        }
+        $("modClear").classList.toggle(
+          "show",
+          !!state.modSig ||
+            state.modInFilter.size > 0 ||
+            state.modOutFilter.size > 0,
+        );
+      }
+
+      function renderFragments() {
+        const filtered = applyFilters(null);
+        const wrap = $("fragList");
+        wrap.innerHTML = "";
+        $("fragCnt").textContent =
+          filtered.length + " / " + data.fragments.length;
+        $("fragColTitle").textContent =
+          "④ Fragments · " +
+          (state.scopeMode === "pattern" ? "分子能力" : "原子能力");
+        if (filtered.length === 0) {
+          wrap.appendChild(
+            el("div", { class: "empty-msg" }, "当前筛选下没有命中的 fragment"),
+          );
+          return;
+        }
+        for (const f of filtered) {
+          const k = fragKey(f);
+          const head = el(
+            "div",
+            { class: "frag-head" },
+            el("span", { class: "case-badge" }, "案例" + f.case_index),
+            el("span", { class: "frag-badge" }, f.fragment_id),
+            el("span", { class: "act-badge" }, f.action),
+          );
+          const sig = el("div", { class: "frag-sig" }, f.modality_signature);
+          const body = el("div", { class: "frag-body" }, f.body || "");
+          const node = el(
+            "div",
+            {
+              class: "frag" + (state.fragKey === k ? " active" : ""),
+              onClick: () => {
+                state.fragKey = k;
+                renderFragments();
+                renderDetail();
+              },
+            },
+            head,
+            sig,
+            body,
+          );
+          wrap.appendChild(node);
+        }
+      }
+
+      function renderDetail() {
+        const wrap = $("detailBody");
+        wrap.innerHTML = "";
+        const f = data.fragments.find((x) => fragKey(x) === state.fragKey);
+        if (!f) {
+          wrap.appendChild(
+            el("div", { class: "detail-empty" }, "点击左侧 fragment 查看详情"),
+          );
+          return;
+        }
+
+        // Header
+        wrap.appendChild(
+          el(
+            "h2",
+            null,
+            el("span", { class: "case-badge" }, "案例 " + f.case_index),
+            el("span", { class: "frag-badge" }, f.fragment_id),
+            el("span", { class: "act-badge" }, f.action),
+            f.workflow_step_ref && f.workflow_step_ref.step_id
+              ? el(
+                  "span",
+                  { class: "pill" },
+                  "step " + f.workflow_step_ref.step_id,
+                )
+              : null,
+          ),
+        );
+        if (f.case_title) {
+          const det = el("details", { style: "margin: 8px 0; padding-bottom: 8px; border-bottom: 1px solid var(--border);" });
+          const sum = el("summary", { class: "case-title", style: "cursor: pointer; margin: 0; outline: none; font-weight: bold; color: var(--accent); padding: 6px 8px; background: var(--panel2); border-radius: 4px; font-size: 11px;" }, "📄 源: " + f.case_title);
+          det.appendChild(sum);
+          
+          const caseContent = el("div", { style: "padding: 10px; background: var(--code); border-radius: 5px; margin-top: 4px; border: 1px solid var(--border);" });
+          
+          const imgsToRender = f.case_images && f.case_images.length ? f.case_images : (f.case_cover ? [f.case_cover] : []);
+          if (imgsToRender.length > 0) {
+            const imgContainer = el("div", { style: "display: flex; gap: 8px; overflow-x: auto; margin-bottom: 8px; padding-bottom: 4px;" });
+            for (const imgSrc of imgsToRender) {
+              const img = el("img", { src: imgSrc, style: "height: 160px; border-radius: 5px; object-fit: contain; background: var(--panel2); border: 1px solid var(--border);" });
+              imgContainer.appendChild(img);
+            }
+            caseContent.appendChild(imgContainer);
+          }
+          
+          if (f.case_body) {
+            const body = el("div", { style: "font-size: 12px; line-height: 1.6; color: var(--fg); white-space: pre-wrap; margin-bottom: 10px; max-height: 250px; overflow-y: auto; padding-right: 4px;" }, f.case_body);
+            caseContent.appendChild(body);
+          }
+          
+          if (f.case_url) {
+            const link = el("a", { href: f.case_url, target: "_blank", rel: "noopener", style: "font-size: 11px; color: var(--accent); display: inline-block; background: var(--panel2); padding: 4px 8px; border-radius: 4px; text-decoration: none;" }, "🔗 访问原始链接");
+            caseContent.appendChild(link);
+          }
+          
+          det.appendChild(caseContent);
+          wrap.appendChild(det);
+        }
+
+        // I/O modalities
+        const ioSec = el(
+          "div",
+          { class: "detail-section" },
+          el("h3", null, "I/O 模态"),
+        );
+        ioSec.appendChild(
+          el(
+            "div",
+            { class: "frag-sig", style: "font-size:13px;margin-bottom:6px" },
+            f.modality_signature,
+          ),
+        );
+        const ioRow = (lbl, arr, kind) => {
+          const row = el(
+            "div",
+            { class: "io-row" },
+            el("span", { class: "lbl" }, lbl),
+          );
+          if (!arr || !arr.length) {
+            row.appendChild(
+              el("span", { style: "color:var(--muted)" }, "(无)"),
+            );
+            return row;
+          }
+          for (const x of arr) {
+            const isCfg = x.modality === "模型" || x.modality === "参数";
+            const txt =
+              (x.description || "") +
+              (x.modality ? "[" + x.modality + "]" : "") +
+              (x.relation ? " " + x.relation : "");
+            row.appendChild(
+              el("span", { class: "pill " + (isCfg ? "cfg" : kind) }, txt),
+            );
+          }
+          return row;
+        };
+        ioSec.appendChild(ioRow("IN", f.inputs, "in"));
+        ioSec.appendChild(ioRow("OUT", f.outputs, "out"));
+        wrap.appendChild(ioSec);
+
+        // Body
+        if (f.body) {
+          wrap.appendChild(
+            el(
+              "div",
+              { class: "detail-section" },
+              el("h3", null, "Body"),
+              el("div", { class: "body-text" }, f.body),
+            ),
+          );
+        }
+
+        // apply_to (实质 + 形式) — highlight rule depends on scope mode
+        let hotPaths = new Set();
+        if (state.scopeMode === "pattern" && state.itemsetIdx != null) {
+          const sel = data.itemsets[state.itemsetIdx];
+          if (sel) for (const p of sel.items) hotPaths.add(p);
+        }
+        const renderApply = (label, arr) => {
+          const sec = el(
+            "div",
+            { class: "detail-section" },
+            el("h3", null, "Apply to · " + label),
+          );
+          if (!arr || !arr.length) {
+            sec.appendChild(
+              el("div", { class: "empty-msg", style: "padding:8px" }, "无"),
+            );
+            return sec;
+          }
+          for (const e of arr) {
+            let isHighlighted = false;
+            if (state.scopeMode === "node") {
+              isHighlighted =
+                (label === "实质" &&
+                  state.shizhiPath &&
+                  (shiDesc.get(state.shizhiPath) || []).includes(
+                    e.category_path,
+                  )) ||
+                (label === "形式" &&
+                  state.xingshiPath &&
+                  (xingDesc.get(state.xingshiPath) || []).includes(
+                    e.category_path,
+                  ));
+            } else {
+              isHighlighted = hotPaths.has(e.category_path);
+            }
+            sec.appendChild(
+              el(
+                "div",
+                {
+                  class: "pathline",
+                  style: isHighlighted ? "color:#f1e05a" : "",
+                },
+                e.category_path,
+              ),
+            );
+            if (e.rationale)
+              sec.appendChild(
+                el("div", { class: "rationale" }, "— " + e.rationale),
+              );
+          }
+          return sec;
+        };
+        wrap.appendChild(renderApply("实质", f.apply_shizhi));
+        wrap.appendChild(renderApply("形式", f.apply_xingshi));
+
+        // suggest_apply_to
+        if (f.suggest_apply_to && f.suggest_apply_to.length) {
+          const sec = el(
+            "div",
+            { class: "detail-section" },
+            el("h3", null, "Suggest Apply to ✦(建议新增节点)")
+          );
+          let arr = Array.isArray(f.suggest_apply_to) ? f.suggest_apply_to : [f.suggest_apply_to];
+          for (const s of arr) {
+            let p = typeof s === 'string' ? s : (s.path || s.category_path || JSON.stringify(s));
+            sec.appendChild(el("div", { class: "pathline suggest" }, p));
+            if (s && typeof s === 'object' && s.rationale) {
+              sec.appendChild(el("div", { class: "rationale" }, "— " + s.rationale));
+            }
+          }
+          wrap.appendChild(sec);
+        }
+
+        // effects
+        if (f.effects && f.effects.length) {
+          const sec = el(
+            "div",
+            { class: "detail-section" },
+            el("h3", null, "Effects"),
+          );
+          f.effects.forEach((e, i) => {
+            const card = el("div", { class: "effect-card" });
+            card.appendChild(
+              el(
+                "div",
+                { class: "effect-stmt" },
+                "#" + i + " " + (e.statement || "-"),
+              ),
+            );
+            if (e.criteria)
+              card.appendChild(
+                el("div", { class: "effect-meta" }, "判定标准:" + e.criteria),
+              );
+            if (e.judge_method)
+              card.appendChild(
+                el(
+                  "div",
+                  { class: "effect-meta" },
+                  "判定方式:" + e.judge_method,
+                ),
+              );
+            if (e.negative_examples && e.negative_examples.length) {
+              const ne = el("div", { class: "effect-meta" }, "反例:");
+              for (const n of e.negative_examples)
+                ne.appendChild(
+                  el("div", { style: "margin-left:8px" }, "· " + n),
+                );
+              card.appendChild(ne);
+            }
+            sec.appendChild(card);
+          });
+          wrap.appendChild(sec);
+        }
+
+        // misc
+        const miscRows = [];
+        miscRows.push(["tools", (f.tools && f.tools.length) ? f.tools.join(", ") : "-"]);
+        miscRows.push(["artifact_type", f.artifact_type ? f.artifact_type : "-"]);
+        miscRows.push(["control_target", (f.control_target && f.control_target.length) ? f.control_target.join(", ") : "-"]);
+        miscRows.push(["alt_to", (f.is_alternative_to && f.is_alternative_to.length) ? f.is_alternative_to.join(", ") : "-"]);
+        if (miscRows.length) {
+          const sec = el(
+            "div",
+            { class: "detail-section" },
+            el("h3", null, "其他"),
+          );
+          for (const [k2, v2] of miscRows) {
+            const row = el(
+              "div",
+              { style: "font-size:11px;margin-bottom:3px" },
+              el("span", { style: "color:var(--muted);margin-right:8px" }, k2),
+              el("span", null, v2),
+            );
+            sec.appendChild(row);
+          }
+          wrap.appendChild(sec);
+        }
+
+        // raw fold-out
+        wrap.appendChild(
+          el(
+            "details",
+            null,
+            el("summary", null, "查看原始 fragment JSON"),
+            el("pre", { class: "json-raw" }, JSON.stringify(f, null, 2)),
+          ),
+        );
+      }
+
+      function renderChips() {
+        const c = $("chips");
+        c.innerHTML = "";
+        let any = false;
+        const mk = (lbl, val, onX) => {
+          any = true;
+          c.appendChild(
+            el(
+              "span",
+              { class: "filter-chip" },
+              el("span", { class: "lbl" }, lbl),
+              el("span", null, val),
+              el(
+                "span",
+                {
+                  class: "x",
+                  onClick: () => {
+                    onX();
+                    renderAll();
+                  },
+                },
+                "×",
+              ),
+            ),
+          );
+        };
+        if (state.action)
+          mk("动作", state.action, () => {
+            state.action = null;
+          });
+        if (state.scopeMode === "node") {
+          if (state.shizhiPath)
+            mk(
+              "实质",
+              state.shizhiPath.split("/").pop() || state.shizhiPath,
+              () => {
+                state.shizhiPath = null;
+              },
+            );
+          if (state.xingshiPath)
+            mk(
+              "形式",
+              state.xingshiPath.split("/").pop() || state.xingshiPath,
+              () => {
+                state.xingshiPath = null;
+              },
+            );
+        } else if (state.scopeMode === "pattern" && state.itemsetIdx != null) {
+          const sel = data.itemsets[state.itemsetIdx];
+          const desc = sel.items.map((p) => p.split("/").pop()).join(" + ");
+          mk("项集 k=" + sel.size, desc, () => {
+            state.itemsetIdx = null;
+          });
+        }
+        if (state.modSig)
+          mk("模态", state.modSig, () => {
+            state.modSig = null;
+          });
+        $("clearAll").classList.toggle("active", any);
+      }
+
+      function renderStats() {
+        $("stats").textContent =
+          `${data.stats.fragments} fragments · ${data.actions.length} 动作 · ${data.modalities.length} 模态组合${data.stats.fragments_skipped ? ` · 已跳过 ${data.stats.fragments_skipped} 个空模态片段` : ""}`;
+      }
+
+      function renderAll() {
+        renderActions();
+        renderScope();
+        renderModalities();
+        renderFragments();
+        renderDetail();
+        renderChips();
+      }
+
+      $("clearAll").addEventListener("click", () => {
+        state.action = null;
+        state.shizhiPath = null;
+        state.xingshiPath = null;
+        state.itemsetIdx = null;
+        state.modSig = null;
+        state.modInFilter.clear();
+        state.modOutFilter.clear();
+        renderAll();
+      });
+      $("scopeClear").addEventListener("click", () => {
+        // clear only the current tab's selection
+        if (state.scopeMode === "node") {
+          state.shizhiPath = null;
+          state.xingshiPath = null;
+        } else {
+          state.itemsetIdx = null;
+        }
+        renderAll();
+      });
+      $("tabNode").addEventListener("click", () => {
+        state.scopeMode = "node";
+        renderAll();
+      });
+      $("tabPattern").addEventListener("click", () => {
+        state.scopeMode = "pattern";
+        renderAll();
+      });
+      $("modClear").addEventListener("click", () => {
+        state.modSig = null;
+        state.modInFilter.clear();
+        state.modOutFilter.clear();
+        renderAll();
+      });
+
+      renderStats();
+      renderAll();
+      }
+
+      document.getElementById('exitFs').addEventListener('click', () => {
+        window.parent.postMessage({ type: 'EXIT_FULLSCREEN' }, '*');
+      });
+
+      window.addEventListener('message', (e) => {
+        if (e.data && e.data.type === 'SET_FULLSCREEN') {
+          const btn = document.getElementById('exitFs');
+          const clearAll = document.getElementById('clearAll');
+          if (e.data.isFullscreen) {
+            btn.style.display = 'inline-block';
+            clearAll.style.marginLeft = '8px';
+          } else {
+            btn.style.display = 'none';
+            clearAll.style.marginLeft = 'auto';
+          }
+        }
+      });
+    </script>
+  </body>
+</html>