guantao 1 gün önce
ebeveyn
işleme
2fc1b68022

+ 234 - 0
agent/llm/claude_code_oauth.py

@@ -0,0 +1,234 @@
+"""
+Claude Code OAuth Provider
+
+通过 claude-agent-sdk 复用 `claude` CLI 的 OAuth 登录态调用 Claude(Max 订阅额度)。
+
+实现方式:使用 `ClaudeSDKClient`(双向 session)+ AsyncIterable[dict] 形式发送
+用户消息。这种模式同时满足:
+  1. 协议正确(client 内部管 stdin 生命周期,不会卡死)
+  2. 支持多模态(content blocks 可带 image 节点)
+
+Auth:依赖 `~/.claude/.credentials.json` 的 OAuth token;如父进程有
+  ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL,会从子进程 env 中剥离,让 CLI
+  回落到 OAuth。父进程 os.environ 不变。
+
+输出契约(与现有 llm_call 一致):
+    {"content": str, "usage": {"input_tokens": int, "output_tokens": int}}
+"""
+
+import logging
+import os
+from typing import Any, Dict, List, Optional, Tuple
+
+logger = logging.getLogger(__name__)
+
+
+def _flatten_messages_to_string(
+    messages: List[Dict[str, Any]],
+) -> Tuple[Optional[str], str]:
+    """
+    把 OpenAI 风格 messages 折叠成 (system_prompt, user_text)。
+
+    - role=system 拼接为 system_prompt
+    - role=user/assistant 的 content 全部拍平为字符串
+    - image_url 类型块降级为 `[图片URL: ...]` 文本占位(模型看到 URL 字符串而非画面)
+
+    使用 string 模式而非 AsyncIterable[dict],是为了走 SDK 中被生产验证的稳定路径。
+    多模态真图传输需要切到 AsyncIterable + Anthropic content block 协议,单独迭代。
+    """
+    system_parts: List[str] = []
+    user_parts: List[str] = []
+
+    for msg in messages:
+        role = msg.get("role")
+        content = msg.get("content")
+
+        if role == "system":
+            if isinstance(content, str):
+                system_parts.append(content)
+            continue
+
+        if isinstance(content, str):
+            user_parts.append(content)
+            continue
+
+        if isinstance(content, list):
+            for block in content:
+                if not isinstance(block, dict):
+                    user_parts.append(str(block))
+                    continue
+                btype = block.get("type")
+                if btype == "text":
+                    user_parts.append(block.get("text", ""))
+                elif btype == "image_url":
+                    url = (block.get("image_url") or {}).get("url", "")
+                    if url:
+                        user_parts.append(f"[图片URL: {url}]")
+                elif btype == "image":
+                    src = block.get("source") or {}
+                    url = src.get("url") or src.get("data", "")[:60]
+                    user_parts.append(f"[图片: {url}]")
+
+    system_prompt = "\n\n".join(system_parts).strip() or None
+    user_text = "\n\n".join(p for p in user_parts if p).strip()
+    return system_prompt, user_text
+
+
+def create_claude_code_oauth_llm_call(model: str = "claude-sonnet-4-5"):
+    """
+    工厂:返回兼容 pipeline llm_call 契约的异步函数(基于 ClaudeSDKClient)。
+
+    返回函数签名:
+        async (messages, model=..., temperature=..., max_tokens=...,
+               response_schema=None, tools=None, **kwargs) -> dict
+    其中 temperature / max_tokens / response_schema / tools 静默忽略
+    (SDK 不透传这些参数,CLI 用自己的默认值)。
+    """
+    from claude_agent_sdk import (
+        AssistantMessage,
+        ClaudeAgentOptions,
+        ClaudeSDKClient,
+        ClaudeSDKError,
+        RateLimitEvent,
+        ResultMessage,
+        TextBlock,
+    )
+
+    # 从子进程 env 中剥离 API key 相关变量,让 CLI 回落到 OAuth;
+    # 父进程 os.environ 不变(其他 LLM provider 仍可用 API key)。
+    _stripped_env = {
+        k: v
+        for k, v in os.environ.items()
+        if k not in ("ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN")
+    }
+    if "ANTHROPIC_API_KEY" in os.environ or "ANTHROPIC_BASE_URL" in os.environ:
+        logger.info(
+            "[claude_code_oauth] Stripping ANTHROPIC_API_KEY/ANTHROPIC_BASE_URL "
+            "from SDK subprocess env so CLI falls back to OAuth credentials."
+        )
+
+    default_model = model
+
+    async def llm_call(
+        messages: List[Dict[str, Any]],
+        model: Optional[str] = None,
+        tools: Optional[List[Dict]] = None,
+        **kwargs: Any,
+    ) -> Dict[str, Any]:
+        actual_model = (model or default_model).split("/")[-1]
+
+        system_prompt, user_text = _flatten_messages_to_string(messages)
+        if not user_text:
+            user_text = " "
+
+        stderr_lines: List[str] = []
+
+        def _capture_stderr(line: str) -> None:
+            if line:
+                stderr_lines.append(line)
+
+        options = ClaudeAgentOptions(
+            model=actual_model,
+            system_prompt=system_prompt,
+            allowed_tools=[],
+            max_turns=1,
+            env=_stripped_env,
+            stderr=_capture_stderr,
+            # 关键:屏蔽 CLI 加载用户级 ~/.claude/ 配置(output_style/skills/plugins 等)
+            # 否则这些会被注入 system prompt,浪费 token + 影响输出格式
+            setting_sources=[],
+        )
+
+        text_parts: List[str] = []
+        usage: Dict[str, Any] = {}
+        is_error = False
+        api_error_status: Optional[int] = None
+        result_subtype: Optional[str] = None
+        result_errors: List[str] = []
+        rate_limit_signal: Optional[str] = None
+
+        def _emit(line: str) -> None:
+            print(f"[claude] {line}", flush=True)
+
+        try:
+            async with ClaudeSDKClient(options=options) as client:
+                await client.query(user_text)
+                async for msg in client.receive_response():
+                    msg_type = type(msg).__name__
+
+                    if isinstance(msg, AssistantMessage):
+                        for block in msg.content:
+                            if hasattr(block, "thinking"):
+                                _emit(f"[think] {block.thinking}")
+                            elif isinstance(block, TextBlock):
+                                _emit(f"[text]  {block.text}")
+                                text_parts.append(block.text)
+                            elif hasattr(block, "name") and hasattr(block, "input"):
+                                _emit(f"[tool_use] {block.name}({block.input})")
+                            else:
+                                _emit(f"[{type(block).__name__}] {block!r}")
+                        if msg.usage and not usage:
+                            usage = dict(msg.usage)
+                    elif isinstance(msg, ResultMessage):
+                        if msg.usage:
+                            usage = dict(msg.usage)
+                        _emit(
+                            f"[result] subtype={msg.subtype} "
+                            f"is_error={msg.is_error} turns={msg.num_turns} "
+                            f"duration={msg.duration_ms}ms "
+                            f"in={msg.usage.get('input_tokens', 0) if msg.usage else 0} "
+                            f"out={msg.usage.get('output_tokens', 0) if msg.usage else 0}"
+                        )
+                        if msg.is_error:
+                            is_error = True
+                            api_error_status = msg.api_error_status
+                            result_subtype = msg.subtype
+                            result_errors = list(msg.errors or [])
+                    elif isinstance(msg, RateLimitEvent):
+                        # RateLimitEvent 是 SDK 定期播报 quota 状态,不等于被限流。
+                        # 只有 rate_limit_info.status != 'allowed' 才算真限流。
+                        info = getattr(msg, "rate_limit_info", None)
+                        info_status = getattr(info, "status", None) if info else None
+                        _emit(f"[rate_limit] status={info_status!r} type={getattr(info, 'rate_limit_type', None)!r}")
+                        if info_status and info_status != "allowed":
+                            rate_limit_signal = f"status={info_status!r}"
+                    else:
+                        # SystemMessage 或其他未知类型
+                        _emit(f"[{msg_type}] {msg!r}")
+        except ClaudeSDKError as e:
+            stderr_tail = "\n".join(stderr_lines[-20:])
+            raise RuntimeError(
+                f"claude_agent_sdk error: {type(e).__name__}: {e}\n"
+                f"--- CLI stderr (last 20 lines) ---\n{stderr_tail}"
+            ) from e
+
+        if rate_limit_signal or api_error_status == 429:
+            raise RuntimeError(
+                "Claude Code OAuth rate-limited (429). "
+                "Max subscription quota may be exhausted in current 5-hour window. "
+                "Run `claude /status` to check remaining."
+            )
+
+        if is_error:
+            stderr_tail = "\n".join(stderr_lines[-20:])
+            errors_str = "; ".join(result_errors) or "(empty errors[])"
+            raise RuntimeError(
+                f"claude_agent_sdk is_error=True "
+                f"subtype={result_subtype!r} status={api_error_status} "
+                f"errors={errors_str}\n"
+                f"--- CLI stderr (last 20 lines) ---\n{stderr_tail}"
+            )
+
+        content = "".join(text_parts)
+
+        normalized_usage = {
+            "input_tokens": int(usage.get("input_tokens", 0) or 0),
+            "output_tokens": int(usage.get("output_tokens", 0) or 0),
+        }
+        for k in ("cache_creation_input_tokens", "cache_read_input_tokens"):
+            if k in usage:
+                normalized_usage[k] = int(usage[k] or 0)
+
+        return {"content": content, "usage": normalized_usage}
+
+    return llm_call

+ 65 - 109
examples/process_pipeline/prompts/extract_workflow.prompt

@@ -1,134 +1,98 @@
----
-temperature: 0.1
----
-
-$system$
-
 你是 AI 图片制作工序沉淀助手。
-
 # 任务概述
-
-从帖子中同时完成两件事:
-1. 识别 workflow steps(按"提交动作"边界划分)
-2. 对每个 step,识别其中的 1+ 原子操作,每个原子操作输出为一个 fragment(带完整 capability 字段)
-
-# 工序提取规则(workflow steps)
-
+从帖子中同时完成两件事:
+1. 识别 workflow steps(按"提交动作"边界划分)
+2. 对每个 step,识别其中的 1+ 原子操作,每个原子操作输出为一个 fragment(带完整 capability 字段)
+# 工序提取规则(workflow steps)
 - 将帖子内容总结为 AI 图片制作工序。
-- 步骤粒度是"做了什么"而非"怎么做"。
-- 以"触发生成 / 处理的动作"为步骤边界,同一次提交前的所有配置(模型选择、参数调整、描述词输入等)合并为一步。
-- 若本质上只有一步,也输出一步,不要返回 workflow=null。
+- 步骤粒度是"做了什么",而非"怎么做"。
+- 以"触发生成 / 处理的动作"为步骤边界,同一次提交前的所有配置(模型选择、参数调整、描述词输入等)合并为一步。
+- 若本质上只有一步,也输出一步,不要返回 workflow=null。
 - 可选步骤也应提取。
-- step 是薄壳:只装结构性元数据(step_id、order、phase、relation、body),不含 capability 字段。
-- 若原帖纯营销、信息密度太低或完全没怎么做,则 skip=true。
-
+- step 是薄壳:只装结构性元数据(step_id、order、phase、relation、body),不含 capability 字段。
+- 若原帖纯营销、信息密度太低或完全没怎么做,则 skip=true。
 # step 字段
-
-每个 step 包含:
-
-- step_id:格式为 "s{order}",如 "s1"、"s2"
-- order:步骤序号,整数
-- phase:该步骤所属阶段,取值为「非制作」/「预处理」/「生成」/「编辑」之一
-- relation:该步骤输出的去向,格式同 inputs/outputs 的 relation 字段,如 "[去向.最终成品]"、"[去向.s2I]"
-- body:具体做法,包含 prompt 写法、参数配置、操作细节等;从帖子原文中提取,未提及则为 null
-
+每个 step 包含:
+- step_id:格式为 "s{order}",如 "s1"、"s2"
+- order:步骤序号,整数
+- phase:该步骤所属阶段,取值为「非制作」/「预处理」/「生成」/「编辑」之一
+- relation:该步骤输出的去向,格式同 inputs/outputs 的 relation 字段,如 "[去向.最终成品]"、"[去向.s2I]"
+- body:具体做法,包含 prompt 写法、参数配置、操作细节等;从帖子原文中提取,未提及则为 null
 # fragment 提取规则
-
-- 每个 step 的 body 中识别 1+ 原子操作,每个原子操作输出为一个 fragment。
-- 同一 step 内的不同方案(如"用 MJ 生成 / 用 SD 生成")互为 alternative:
-  - 每种方案单独输出一个 fragment,各自填写完整字段(inputs、action、outputs、tools 等均可不同)
+- 每个 step 的 body 中识别 1+ 原子操作,每个原子操作输出为一个 fragment。
+- 同一 step 内的不同方案(如"用 MJ 生成 / 用 SD 生成")互为 alternative:
+  - 每种方案单独输出一个 fragment,各自填写完整字段(inputs、action、outputs、tools 等均可不同)
   - 在 is_alternative_to 中互相标注对方的 fragment_id
-- 帖子中没有 workflow 上下文的能力提及 → fragmentworkflow_step_ref = null。
+- 帖子中没有 workflow 上下文的能力提及 → fragment,workflow_step_ref = null。
 - 不跨 step 合并 fragments。
-- fragment_id 格式:步内原子操作用 "f_{step_id}_{i}"(如 "f_s1_0"、"f_s1_1"),standalone 用 "f_standalone_{i}"。
-
+- fragment_id 格式:步内原子操作用 "f_{step_id}_{i}"(如 "f_s1_0"、"f_s1_1"),standalone 用 "f_standalone_{i}"。
 # fragment 字段
-
-每个 fragment 包含完整 capability 字段:
-
-- fragment_id:字符串,见上方规则
-- action:字符串,见下方 action 字段规则
-- inputs / outputs:结构化接口,见下方规则
-- body:该原子操作在原帖中的描述(可能是 step body 的子片段);未提及则为 null
-- effects:该原子操作产生的可观测效果,数组,每项为结构体(见下方 effects 字段规则)
-- control_target:该操作控制的对象,字符串数组,如 ["人物姿态", "背景风格"];未提及则为 []
-- artifact_type:该操作产出的工件类型,如 "正向提示词"、"蒙版"、"参考图";未提及则为 null
-- tools:使用的工具或平台,数组;未提及则为 []
-- apply_to_draft:{ 实质: [...], 形式: [...] },只写自然语言短语
-- workflow_step_ref:{ workflow_id, step_id } 或 null(standalone fragment)
-- is_alternative_to:同一 step 内互为可选方案的其他 fragment_id 数组,无则为 []
-
+每个 fragment 包含完整 capability 字段:
+- fragment_id:字符串,见上方规则
+- action:字符串,见下方 action 字段规则
+- inputs / outputs:结构化接口,见下方规则
+- body:该原子操作在原帖中的描述(可能是 step body 的子片段);未提及则为 null
+- effects:该原子操作产生的可观测效果,数组,每项为结构体(见下方 effects 字段规则)
+- control_target:该操作控制的对象,字符串数组,如 ["人物姿态", "背景风格"];未提及则为 []
+- artifact_type:该操作产出的工件类型,如 "正向提示词"、"蒙版"、"参考图";未提及则为 null
+- tools:使用的工具或平台,数组;未提及则为 []
+- apply_to_draft:{ 实质: [...], 形式: [...] },只写自然语言短语
+- workflow_step_ref:{ workflow_id, step_id } 或 null(standalone fragment)
+- is_alternative_to:同一 step 内互为可选方案的其他 fragment_id 数组,无则为 []
 # action 字段
-
 action 是一个字符串。
-
-定义:描述信息本身发生了什么变化,独立于变化的意图、使用场景、输入来源和输出去向。该字段必须是一个汉语动词,能单独作谓语;
-
-判断标准:
-- 去掉主语和宾语后,这个词仍然能独立表达一种变化 → 是动作
-- 混入了操作对象 → 不是动作,如"换脸"应写为"替换"
-- 混入了意图或场景 → 不是动作,如"修复划痕"应写为"修复"
-
-
-举例(仅供参考,不限于此):
-生成、替换、融合、提取、修复、增强、分离、筛选、压缩、扩展、插值
-
+定义:动作是:输入到输出之间,客观发生的信息变化;需要包含动词
+距离来说:
+一个人脸上有一颗痣,你用 AI 把它去掉。
+以意图描述:美化、精修
+以场景描述:祛痘、磨皮
+以信息变化描述:修复(用周围信息填补某个区域)
+判断标准:
+- 去掉主语和宾语后,这个词仍然能独立表达一种变化 → 是动作
+- 混入了操作对象 → 不是动作,如"换脸"应写为"替换"
+- 混入了意图或场景 → 不是动作,如"修复划痕"应写为"修复"
+举例(仅供参考,不限于此):
+生成、替换、融合、提取、局部修复、风格迁移
 # inputs / outputs
-
 ```json
 {
   "modality": "文本",
-  "description": "该项在当前步骤中实际起到的作用用简短名词短语表达",
+  "description": "该项在当前步骤中实际起到的作用,用简短名词短语表达",
   "relation": "来源或去向"
 }
 ```
-
-- modality 是数据形态:文本 / 图片 / 视频 / 音频 / 特征点 / 参数 / 模型 / 向量
+- modality 是数据形态:文本 / 图片 / 视频 / 音频 / 特征点 / 参数 / 模型 / 向量
 - 同一次提交给模型的所有文字描述统一合并为一个输入项
-- relation 格式:[来源.1O]、[去向.2I]、[来源.原始输入]、[去向.最终成品]
-
+- relation 格式:[来源.1O]、[去向.2I]、[来源.原始输入]、[去向.最终成品]
 # effects 字段
-
-每个 effect 写成结构体:
-
+每个 effect 写成结构体:
 ```json
 {
   "statement": "实现XXX",
-  "criteria": "判断该效果是否达成的具体标准一句话描述",
+  "criteria": "判断该效果是否达成的具体标准,一句话描述",
   "judge_method": "vlm",
   "negative_examples": ["反例描述1"]
 }
 ```
-
-- statement:以"实现"开头,描述该操作产生的可观测效果
-- criteria:判断标准,具体、可操作,描述"什么情况下算达成"
-- judge_method:判断方式,从以下选择:
-  - `llm`:纯文本推理可判断
-  - `vlm`:需要看图才能判断
-  - `rule`:可用规则/代码判断(如分辨率、文件大小)
-  - `human`:需要人工主观判断
-- negative_examples:反例列表,描述"什么情况下算没达成";无明显反例则为 []
-
-每个 fragment 必须有 effects,至少一项。
-
+- statement:以"实现"开头,描述该操作产生的可观测效果
+- criteria:判断标准,具体、可操作,描述"什么情况下算达成"
+- judge_method:判断方式,从以下选择:
+  - `llm`:纯文本推理可判断
+  - `vlm`:需要看图才能判断
+  - `rule`:可用规则/代码判断(如分辨率、文件大小)
+  - `human`:需要人工主观判断
+- negative_examples:反例列表,描述"什么情况下算没达成";无明显反例则为 []
+每个 fragment 必须有 effects,至少一项。
 # apply_to_draft 字段
-
-- 本阶段严禁生成 apply_to,只生成 apply_to_draft。
-- apply_to_draft.实质 写内容关于什么:主体、题材、场景、情境等。
-- apply_to_draft.形式 写内容怎么呈现:镜头、构图、光线、叙事、排版、质感等。
-
+- 本阶段严禁生成 apply_to,只生成 apply_to_draft。
+- apply_to_draft.实质 写内容关于什么:主体、题材、场景、情境等。
+- apply_to_draft.形式 写内容怎么呈现:镜头、构图、光线、叙事、排版、质感等。
 {interface_vocab}
-
 $user$
-
-# 输入:原帖
-
+# 输入:原帖
 ---
-
 ## %context%
-
 # 输出 JSON 形状
-
 ```json
 {
   "skip": false,
@@ -139,8 +103,6 @@ $user$
       {
         "step_id": "s1",
         "order": 1,
-        "phase": "生成",
-        "relation": "[去向.最终成品]",
         "body": "string | null"
       }
     ]
@@ -175,18 +137,12 @@ $user$
       "control_target": [],
       "artifact_type": null,
       "tools": [],
-      "apply_to_draft": { "实质": ["..."], "形式": ["..."] },
-      "workflow_step_ref": { "workflow_id": null, "step_id": "s1" },
-      "is_alternative_to": []
     }
   ]
 }
 ```
-
 # 输出硬规则
-
-- 只输出最终严格 JSON,不要 Markdown 代码块。
+- 只输出最终严格 JSON,不要 Markdown 代码块。
 - 不要任何前言、解释、标题。
-- 字符串值内禁止出现 ASCII 双引号;需要引号请用中文书名号。
-- effects 的每项都必须以"实现"开头。
-
+- 字符串值内禁止出现 ASCII 双引号;需要引号请用中文书名号。
+- effects 的每项都必须以"实现"开头。

+ 54 - 132
examples/process_pipeline/prompts/extract_workflow.schema.json

@@ -1,12 +1,12 @@
 {
   "$schema": "http://json-schema.org/draft-07/schema#",
-  "title": "extract_workflow_output_v7",
+  "title": "extract_workflow_output_v8",
   "type": "object",
   "required": [
     "skip",
     "skip_reason",
     "workflow",
-    "fragments-boundary"
+    "capability-boundary"
   ],
   "properties": {
     "skip": {
@@ -16,99 +16,79 @@
       "type": "string"
     },
     "workflow": {
-      "anyOf": [
-        {
-          "type": "null"
+      "type": "object",
+      "required": [
+        "workflow_id-ref",
+        "steps-boundary"
+      ],
+      "properties": {
+        "workflow_id-ref": {
+          "type": [
+            "string",
+            "null"
+          ]
         },
-        {
-          "type": "object",
-          "required": [
-            "workflow_id-ref",
-            "steps-boundary"
-          ],
-          "properties": {
-            "workflow_id-ref": {
-              "type": [
-                "string",
-                "null"
-              ]
-            },
-            "steps-boundary": {
-              "type": "array",
-              "minItems": 1,
-              "items": {
-                "type": "object",
-                "required": [
-                  "step_id-ref",
-                  "order",
-                  "phase",
-                  "relation",
-                  "body"
-                ],
-                "properties": {
-                  "step_id-ref": {
-                    "type": "string",
-                    "pattern": "^s[0-9]+$"
-                  },
-                  "order": {
-                    "type": "integer",
-                    "minimum": 1
-                  },
-                  "phase": {
-                    "type": "string",
-                    "enum": [
-                      "非制作",
-                      "预处理",
-                      "生成",
-                      "编辑"
-                    ]
-                  },
-                  "relation": {
-                    "type": "string",
-                    "minLength": 1
-                  },
-                  "body": {
-                    "type": [
-                      "string",
-                      "null"
-                    ]
-                  }
-                },
-                "additionalProperties": false
+        "steps-boundary": {
+          "type": "array",
+          "minItems": 1,
+          "items": {
+            "type": "object",
+            "required": [
+              "step_id-ref",
+              "order",
+              "body"
+            ],
+            "properties": {
+              "step_id-ref": {
+                "type": "string",
+                "pattern": "^s[0-9]+$"
+              },
+              "order": {
+                "type": "integer",
+                "minimum": 1
+              },
+              "body": {
+                "type": [
+                  "string",
+                  "null"
+                ]
               }
-            }
-          },
-          "additionalProperties": false
+            },
+            "additionalProperties": false
+          }
         }
-      ]
+      },
+      "additionalProperties": false
     },
-    "fragments-boundary": {
+    "capability-boundary": {
       "type": "array",
       "items": {
         "type": "object",
         "required": [
-          "fragment_id-ref",
+          "capability_id-ref",
           "action",
+          "reasoning",
           "inputs-boundary",
           "outputs-boundary",
           "body",
           "effects-boundary",
           "control_target",
           "artifact_type",
-          "tools-boundary",
-          "apply_to_draft-boundary",
-          "workflow_step_ref",
-          "is_alternative_to-boundary"
+          "tools-boundary"
         ],
         "properties": {
-          "fragment_id-ref": {
+          "capability_id-ref": {
             "type": "string",
-            "pattern": "^(f_s[0-9]+_[0-9]+|f_standalone_[0-9]+)$"
+            "pattern": "^(c_s[0-9]+_[0-9]+|c_standalone_[0-9]+)$"
           },
           "action": {
             "type": "string",
             "minLength": 1
           },
+          "reasoning": {
+            "type": "string",
+            "minLength": 1
+          },
           "inputs-boundary": {
             "type": "array",
             "items": {
@@ -138,7 +118,7 @@
                 },
                 "relation": {
                   "type": "string",
-                  "minLength": 1
+                  "pattern": "^\\[(来源|去向)\\.[^\\]]+\\]$"
                 }
               },
               "additionalProperties": false
@@ -173,7 +153,7 @@
                 },
                 "relation": {
                   "type": "string",
-                  "minLength": 1
+                  "pattern": "^\\[(来源|去向)\\.[^\\]]+\\]$"
                 }
               },
               "additionalProperties": false
@@ -244,64 +224,6 @@
               "type": "string",
               "minLength": 1
             }
-          },
-          "apply_to_draft-boundary": {
-            "type": "object",
-            "required": [
-              "实质-boundary",
-              "形式-boundary"
-            ],
-            "properties": {
-              "实质-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": "object",
-                "required": [
-                  "workflow_id-ref",
-                  "step_id-ref"
-                ],
-                "properties": {
-                  "workflow_id-ref": {
-                    "type": [
-                      "string",
-                      "null"
-                    ]
-                  },
-                  "step_id-ref": {
-                    "type": "string",
-                    "pattern": "^s[0-9]+$"
-                  }
-                },
-                "additionalProperties": false
-              }
-            ]
-          },
-          "is_alternative_to-boundary": {
-            "type": "array",
-            "items": {
-              "type": "string",
-              "pattern": "^(f_s[0-9]+_[0-9]+|f_standalone_[0-9]+)$"
-            }
           }
         },
         "additionalProperties": false

+ 7 - 2
examples/process_pipeline/run_pipeline.py

@@ -768,12 +768,17 @@ async def main():
         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'))}")
         claude_llm_call = create_claude_llm_call(model=claude_model)
+        # workflow 单独走 Claude Agent SDK(OAuth / Max 订阅)
+        from agent.llm.claude_code_oauth import create_claude_code_oauth_llm_call
+        workflow_llm_call = create_claude_code_oauth_llm_call(model=claude_model)
+        print(f"✅ Workflow extraction will use Claude Agent SDK (OAuth/Max subscription)")
     else:
         # 使用 OpenRouter 代理的 GPT-5.4(支持结构化输出 strict mode)
         claude_model = "openai/gpt-5.4"
         print(f"✅ Using OpenRouter with model: {claude_model}")
         from agent.llm.openrouter import create_openrouter_llm_call
         claude_llm_call = create_openrouter_llm_call(model=claude_model)
+        workflow_llm_call = claude_llm_call  # 没开 SDK 时与默认一致
     
     runner_qwen = AgentRunner(
         trace_store=store,
@@ -876,7 +881,7 @@ async def main():
                 from examples.process_pipeline.script.extract_workflow import extract_workflow
                 result = await extract_workflow(
                     case_file_to_use,
-                    claude_llm_call, model=claude_model
+                    workflow_llm_call, model=claude_model
                 )
 
                 # 如果使用了临时文件,需要合并回原始 case.json
@@ -1260,7 +1265,7 @@ async def main():
 
                     workflow_stats = await extract_workflow(
                         case_file,
-                        claude_llm_call,
+                        workflow_llm_call,
                         model=claude_model,
                         max_concurrent=3
                     )

+ 158 - 0
examples/process_pipeline/script/clean_capabilities.py

@@ -0,0 +1,158 @@
+"""
+清洗脚本:移除 case.json 中的 capabilities 数组
+
+背景:capability 概念已被 fragment 取代,case.json 里遗留的 capabilities 字段需要清除。
+
+用法:
+    # 清洗指定需求目录的 case.json
+    python -m examples.process_pipeline.script.clean_capabilities --index 108
+
+    # 预览(不实际修改文件)
+    python -m examples.process_pipeline.script.clean_capabilities --index 108 --dry-run
+
+    # 不备份(默认会写 .bak)
+    python -m examples.process_pipeline.script.clean_capabilities --index 108 --no-backup
+
+    # 批量清洗所有 output 目录
+    python -m examples.process_pipeline.script.clean_capabilities --all
+"""
+
+import argparse
+import json
+import shutil
+import sys
+from pathlib import Path
+from typing import Any, Dict, List, Tuple
+
+# Windows 控制台 UTF-8 输出
+if sys.platform == "win32":
+    sys.stdout.reconfigure(encoding="utf-8")
+    sys.stderr.reconfigure(encoding="utf-8")
+
+PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent
+OUTPUT_DIR = Path(__file__).resolve().parent.parent / "output"
+
+
+def clean_case_file(
+    case_file: Path,
+    dry_run: bool = False,
+    backup: bool = True,
+) -> Dict[str, int]:
+    """
+    清洗单个 case.json 文件中的 capabilities 数组。
+
+    Returns:
+        stats dict: {"total_cases": int, "with_capabilities": int, "without": int}
+    """
+    if not case_file.exists():
+        raise FileNotFoundError(f"Case file not found: {case_file}")
+
+    with open(case_file, "r", encoding="utf-8") as f:
+        data = json.load(f)
+
+    cases = data.get("cases", [])
+    with_cap_count = 0
+
+    for case in cases:
+        if "capabilities" in case:
+            with_cap_count += 1
+            if not dry_run:
+                del case["capabilities"]
+
+    without_count = len(cases) - with_cap_count
+
+    if dry_run:
+        return {
+            "total_cases": len(cases),
+            "with_capabilities": with_cap_count,
+            "without": without_count,
+            "action": "dry_run",
+        }
+
+    if with_cap_count > 0:
+        if backup:
+            backup_file = case_file.with_suffix(".json.bak")
+            shutil.copy2(case_file, backup_file)
+
+        with open(case_file, "w", encoding="utf-8") as f:
+            json.dump(data, f, ensure_ascii=False, indent=2)
+
+    return {
+        "total_cases": len(cases),
+        "with_capabilities": with_cap_count,
+        "without": without_count,
+        "action": "cleaned" if with_cap_count > 0 else "no_change",
+    }
+
+
+def main():
+    parser = argparse.ArgumentParser(description="移除 case.json 中的 capabilities 数组")
+    group = parser.add_mutually_exclusive_group(required=True)
+    group.add_argument("--index", type=str, help="需求目录索引(如 108 或 108,109,110)")
+    group.add_argument("--all", action="store_true", help="清洗 output 下所有需求目录")
+
+    parser.add_argument("--dry-run", action="store_true", help="只预览,不修改文件")
+    parser.add_argument("--no-backup", action="store_true", help="不创建 .bak 备份文件")
+    args = parser.parse_args()
+
+    backup = not args.no_backup
+
+    # 确定要处理的目录列表
+    target_dirs: List[Path] = []
+    if args.all:
+        for d in sorted(OUTPUT_DIR.iterdir()):
+            if d.is_dir() and d.name.isdigit():
+                case_file = d / "case.json"
+                if case_file.exists():
+                    target_dirs.append(d)
+    else:
+        for idx_str in args.index.split(","):
+            idx_str = idx_str.strip()
+            # 支持 "108" 或 "108/raw_cases" 格式,统一取目录名
+            target_dir = OUTPUT_DIR / idx_str.zfill(3) if len(idx_str) <= 3 else OUTPUT_DIR / idx_str
+            if not target_dir.exists():
+                print(f"⚠️  目录不存在: {target_dir}")
+                continue
+            target_dirs.append(target_dir)
+
+    if not target_dirs:
+        print("❌ 没有找到任何可处理的目录")
+        sys.exit(1)
+
+    print(f"{'[Dry Run] ' if args.dry_run else ''}处理 {len(target_dirs)} 个目录 (backup={'on' if backup else 'off'})")
+    print("=" * 60)
+
+    total_cleaned = 0
+    total_cases = 0
+    for d in target_dirs:
+        case_file = d / "case.json"
+        if not case_file.exists():
+            print(f"  [{d.name}] ⏭️  case.json 不存在")
+            continue
+
+        try:
+            stats = clean_case_file(case_file, dry_run=args.dry_run, backup=backup)
+        except Exception as e:
+            print(f"  [{d.name}] ❌ 错误: {type(e).__name__}: {e}")
+            continue
+
+        total_cases += stats["total_cases"]
+        total_cleaned += stats["with_capabilities"]
+
+        icon = "🔍" if args.dry_run else ("✅" if stats["action"] == "cleaned" else "⏭️ ")
+        print(
+            f"  [{d.name}] {icon} "
+            f"{stats['action']}: {stats['with_capabilities']}/{stats['total_cases']} cases 含 capabilities"
+        )
+
+    print("=" * 60)
+    if args.dry_run:
+        print(f"📊 [Dry Run Summary] 共 {total_cases} 个 case,{total_cleaned} 个含 capabilities(未修改)")
+    else:
+        print(f"📊 [Summary] 共处理 {total_cases} 个 case,清除了 {total_cleaned} 个 capabilities 数组")
+        if backup and total_cleaned > 0:
+            print(f"   💾 原始文件已备份为 case.json.bak")
+
+
+if __name__ == "__main__":
+    main()

+ 27 - 0
scratch/check_case35.py

@@ -0,0 +1,27 @@
+import json
+
+with open(r"C:\Users\11304\gitlab\cybertogether\Agent\examples\process_pipeline\output\112\case.json", "r", encoding="utf-8") as f:
+    data = json.load(f)
+
+case_35 = next((c for c in data.get("cases", []) if c.get("index") == 35), None)
+if not case_35:
+    print("case 35 NOT FOUND")
+else:
+    wf = case_35.get("workflow")
+    fr = case_35.get("fragments") or []
+    print("title:", (case_35.get("title") or "")[:50])
+    print("workflow exists:", wf is not None)
+    if wf:
+        steps = wf.get("steps", [])
+        print("steps count:", len(steps))
+        for s in steps:
+            order = s.get("order")
+            phase = s.get("phase")
+            body_present = bool(s.get("body"))
+            print(f"  step {order}: phase={phase} body_present={body_present}")
+    print("fragments count:", len(fr))
+    for f in fr:
+        fid = f.get("fragment_id")
+        action = f.get("action")
+        ref = (f.get("workflow_step_ref") or {}).get("step_id")
+        print(f"  {fid}: action={action} ref={ref}")

+ 41 - 0
scratch/test_claude_sdk.py

@@ -0,0 +1,41 @@
+"""
+最小化的 Claude Agent SDK 调用示例。
+
+前置条件:
+  1) pip install claude-agent-sdk
+  2) 终端里已经跑过 `claude` 并登录(OAuth token 已存在 ~/.claude/)
+  3) 不要设 ANTHROPIC_API_KEY 环境变量,否则会走 API 计费而不是订阅额度
+
+运行:
+  python scratch/test_claude_sdk.py
+"""
+
+import anyio
+import os
+
+from claude_agent_sdk import query, ClaudeAgentOptions, AssistantMessage, TextBlock
+
+
+async def main() -> None:
+    if os.environ.get("ANTHROPIC_API_KEY"):
+        print("[warn] 检测到 ANTHROPIC_API_KEY,将走 API 计费而非订阅额度。")
+        print("       想用终端登录态请先 `Remove-Item Env:ANTHROPIC_API_KEY`。\n")
+
+    options = ClaudeAgentOptions(
+        system_prompt="You are a concise assistant. Reply in Chinese.",
+        max_turns=1,
+        allowed_tools=[],
+    )
+
+    prompt = "用一句话告诉我:1+1 等于几?"
+    print(f">>> 提问: {prompt}\n")
+
+    async for message in query(prompt=prompt, options=options):
+        if isinstance(message, AssistantMessage):
+            for block in message.content:
+                if isinstance(block, TextBlock):
+                    print(f"<<< 回答: {block.text}")
+
+
+if __name__ == "__main__":
+    anyio.run(main)