guantao 14 часов назад
Родитель
Сommit
5464057c72

+ 41 - 9
agent/cli/interactive.py

@@ -23,23 +23,55 @@ def check_stdin() -> Optional[str]:
     """
     """
     跨平台非阻塞检查 stdin 输入。
     跨平台非阻塞检查 stdin 输入。
 
 
-    Windows: 使用 msvcrt.kbhit()
-    macOS/Linux: 使用 select.select()
+    支持终端输入和控制文件(用于后台进程控制)。
+
+    优先级:
+    1. 检查控制文件 .agent_control(用于后台进程)
+    2. 检查终端/管道输入
 
 
     Returns:
     Returns:
         'pause' | 'quit' | None
         'pause' | 'quit' | None
     """
     """
-    if sys.platform == 'win32':
-        # Windows: 检查是否有按键按下
-        if msvcrt.kbhit():
-            ch = msvcrt.getwch().lower()
-            if ch == 'p':
+    # 1. 优先检查控制文件(用于后台进程控制)
+    control_file = Path.cwd() / ".agent_control"
+    if control_file.exists():
+        try:
+            cmd = control_file.read_text(encoding='utf-8').strip().lower()
+            control_file.unlink()  # 读取后立即删除
+            if cmd in ('p', 'pause'):
                 return 'pause'
                 return 'pause'
-            if ch == 'q':
+            if cmd in ('q', 'quit'):
                 return 'quit'
                 return 'quit'
+        except Exception:
+            pass
+
+    # 2. 检查终端/管道输入
+    if sys.platform == 'win32':
+        # Windows: 先检查是否是终端
+        if sys.stdin.isatty():
+            # 终端模式:使用 msvcrt
+            if msvcrt.kbhit():
+                ch = msvcrt.getwch().lower()
+                if ch == 'p':
+                    return 'pause'
+                if ch == 'q':
+                    return 'quit'
+        else:
+            # 管道模式:尝试非阻塞读取
+            try:
+                import select
+                ready, _, _ = select.select([sys.stdin], [], [], 0)
+                if ready:
+                    line = sys.stdin.readline().strip().lower()
+                    if line in ('p', 'pause'):
+                        return 'pause'
+                    if line in ('q', 'quit'):
+                        return 'quit'
+            except Exception:
+                pass
         return None
         return None
     else:
     else:
-        # Unix/Mac: 使用 select
+        # Unix/Mac: 使用 select(支持终端和管道)
         import select
         import select
         ready, _, _ = select.select([sys.stdin], [], [], 0)
         ready, _, _ = select.select([sys.stdin], [], [], 0)
         if ready:
         if ready:

Разница между файлами не показана из-за своего большого размера
+ 85 - 45
agent/tools/builtin/feishu/chat_history/chat_关涛.json


+ 892 - 0
agent/tools/builtin/feishu/feishu_agent.py

@@ -0,0 +1,892 @@
+"""
+飞书实时对话 Agent
+
+通过飞书 WebSocket 监听消息,调用 Qwen LLM 生成回复,实现实时对话。
+支持工具调用:浏览目录、读取文件、执行 bash 命令。
+
+用法:
+    python -m agent.tools.builtin.feishu.feishu_agent
+
+环境变量:
+    FEISHU_APP_ID / FEISHU_APP_SECRET: 飞书应用凭证
+    QWEN_API_KEY: 通义千问 API Key
+    QWEN_BASE_URL: 通义千问 API 地址(可选,默认阿里云)
+    FEISHU_AGENT_MODEL: 模型名称(默认 qwen-plus)
+    FEISHU_AGENT_SYSTEM_PROMPT: 自定义 system prompt(可选)
+"""
+
+import os
+import sys
+import json
+import asyncio
+import logging
+import subprocess
+import threading
+import base64
+import zipfile
+from typing import Dict, List, Any, Optional
+from collections import defaultdict
+
+PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
+if PROJECT_ROOT not in sys.path:
+    sys.path.insert(0, PROJECT_ROOT)
+
+from agent.tools.builtin.feishu.feishu_client import FeishuClient, FeishuMessageEvent, FeishuDomain, ReceiveIdType
+from agent.tools.builtin.feishu.chat import (
+    FEISHU_APP_ID,
+    FEISHU_APP_SECRET,
+    get_contact_by_id,
+    load_chat_history,
+    save_chat_history,
+)
+from agent.llm.qwen import qwen_llm_call
+
+logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(name)s] %(levelname)s %(message)s')
+logger = logging.getLogger("FeishuAgent")
+
+# ===== 配置 =====
+
+MODEL = os.getenv("FEISHU_AGENT_MODEL", "qwen3.5-397b-a17b")
+MAX_HISTORY = 50
+MAX_TOOL_ROUNDS = 10  # 工具调用最大循环次数
+ALLOWED_CONTACTS = {"关涛"}
+
+DEFAULT_SYSTEM_PROMPT = """你是一个友好、有帮助的 AI 助手,正在通过飞书和用户对话。
+你可以使用工具来浏览本地目录、读取文件内容、执行 bash 命令。
+请用简洁清晰的中文回复。如果用户使用其他语言,请用对应语言回复。"""
+
+SYSTEM_PROMPT = os.getenv("FEISHU_AGENT_SYSTEM_PROMPT", DEFAULT_SYSTEM_PROMPT)
+
+# ===== 工具定义 =====
+
+TOOLS = [
+    {
+        "type": "function",
+        "function": {
+            "name": "list_directory",
+            "description": "列出指定目录下的文件和子目录",
+            "parameters": {
+                "type": "object",
+                "properties": {
+                    "path": {"type": "string", "description": "目录路径,默认当前目录"}
+                },
+                "required": []
+            }
+        }
+    },
+    {
+        "type": "function",
+        "function": {
+            "name": "read_file",
+            "description": "读取指定文件的内容",
+            "parameters": {
+                "type": "object",
+                "properties": {
+                    "path": {"type": "string", "description": "文件路径"},
+                    "max_lines": {"type": "integer", "description": "最多读取行数,默认200"}
+                },
+                "required": ["path"]
+            }
+        }
+    },
+    {
+        "type": "function",
+        "function": {
+            "name": "run_bash",
+            "description": "执行 bash/shell 命令并返回输出",
+            "parameters": {
+                "type": "object",
+                "properties": {
+                    "command": {"type": "string", "description": "要执行的命令"}
+                },
+                "required": ["command"]
+            }
+        }
+    },
+    {
+        "type": "function",
+        "function": {
+            "name": "send_image",
+            "description": "发送图片到飞书",
+            "parameters": {
+                "type": "object",
+                "properties": {
+                    "path": {"type": "string", "description": "本地图片文件路径"}
+                },
+                "required": ["path"]
+            }
+        }
+    },
+    {
+        "type": "function",
+        "function": {
+            "name": "send_file",
+            "description": "发送文件到飞书",
+            "parameters": {
+                "type": "object",
+                "properties": {
+                    "path": {"type": "string", "description": "本地文件路径"}
+                },
+                "required": ["path"]
+            }
+        }
+    },
+    {
+        "type": "function",
+        "function": {
+            "name": "zip_and_send",
+            "description": "将本地目录打包成 zip 并发送到飞书",
+            "parameters": {
+                "type": "object",
+                "properties": {
+                    "path": {"type": "string", "description": "要打包的目录路径"}
+                },
+                "required": ["path"]
+            }
+        }
+    },
+    {
+        "type": "function",
+        "function": {
+            "name": "run_background",
+            "description": "在后台启动一个长期运行的命令(如服务器、agent),返回进程 ID。不会等待命令结束。",
+            "parameters": {
+                "type": "object",
+                "properties": {
+                    "command": {"type": "string", "description": "要执行的命令"},
+                    "name": {"type": "string", "description": "给这个后台进程起个名字,方便后续管理"}
+                },
+                "required": ["command"]
+            }
+        }
+    },
+    {
+        "type": "function",
+        "function": {
+            "name": "stop_process",
+            "description": "停止一个后台运行的进程",
+            "parameters": {
+                "type": "object",
+                "properties": {
+                    "pid": {"type": "integer", "description": "进程 ID"}
+                },
+                "required": ["pid"]
+            }
+        }
+    },
+    {
+        "type": "function",
+        "function": {
+            "name": "list_processes",
+            "description": "列出所有通过 run_background 启动的后台进程及其状态",
+            "parameters": {
+                "type": "object",
+                "properties": {},
+                "required": []
+            }
+        }
+    },
+    {
+        "type": "function",
+        "function": {
+            "name": "get_process_output",
+            "description": "获取后台进程的最新输出日志",
+            "parameters": {
+                "type": "object",
+                "properties": {
+                    "pid": {"type": "integer", "description": "进程 ID"},
+                    "tail": {"type": "integer", "description": "获取最后 N 行,默认50"}
+                },
+                "required": ["pid"]
+            }
+        }
+    },
+    {
+        "type": "function",
+        "function": {
+            "name": "send_input",
+            "description": "向后台进程发送输入(如交互式命令)",
+            "parameters": {
+                "type": "object",
+                "properties": {
+                    "pid": {"type": "integer", "description": "进程 ID"},
+                    "text": {"type": "string", "description": "要发送的文本"}
+                },
+                "required": ["pid", "text"]
+            }
+        }
+    }
+]
+
+
+# ===== 工具执行 =====
+
+# 全局变量:保存当前会话的 chat_id,供工具使用
+_current_chat_id = None
+
+# 后台进程管理
+_background_processes: Dict[int, Dict[str, Any]] = {}  # pid → {proc, name, command, output_lines, started_at}
+
+
+def execute_tool(name: str, arguments: Dict[str, Any]) -> str:
+    """执行工具调用,返回结果字符串"""
+    try:
+        if name == "list_directory":
+            return _tool_list_directory(arguments.get("path", "."))
+        elif name == "read_file":
+            return _tool_read_file(arguments["path"], arguments.get("max_lines", 200))
+        elif name == "run_bash":
+            return _tool_run_bash(arguments["command"])
+        elif name == "send_image":
+            return _tool_send_image(arguments["path"])
+        elif name == "send_file":
+            return _tool_send_file(arguments["path"])
+        elif name == "zip_and_send":
+            return _tool_zip_and_send(arguments["path"])
+        elif name == "run_background":
+            return _tool_run_background(arguments["command"], arguments.get("name", ""))
+        elif name == "stop_process":
+            return _tool_stop_process(arguments["pid"])
+        elif name == "list_processes":
+            return _tool_list_processes()
+        elif name == "get_process_output":
+            return _tool_get_process_output(arguments["pid"], arguments.get("tail", 50))
+        elif name == "send_input":
+            return _tool_send_input(arguments["pid"], arguments["text"])
+        else:
+            return f"未知工具: {name}"
+    except Exception as e:
+        return f"工具执行出错: {type(e).__name__}: {e}"
+
+
+def _tool_list_directory(path: str) -> str:
+    path = os.path.abspath(path)
+    if not os.path.isdir(path):
+        return f"目录不存在: {path}"
+    entries = []
+    for name in sorted(os.listdir(path)):
+        full = os.path.join(path, name)
+        suffix = "/" if os.path.isdir(full) else ""
+        entries.append(f"  {name}{suffix}")
+    header = f"目录: {path} ({len(entries)} 项)\n"
+    return header + "\n".join(entries) if entries else header + "  (空目录)"
+
+
+def _tool_read_file(path: str, max_lines: int = 200) -> str:
+    path = os.path.abspath(path)
+    if not os.path.isfile(path):
+        return f"文件不存在: {path}"
+    try:
+        with open(path, "r", encoding="utf-8", errors="replace") as f:
+            lines = []
+            for i, line in enumerate(f):
+                if i >= max_lines:
+                    lines.append(f"\n... (截断,共读取 {max_lines} 行)")
+                    break
+                lines.append(line.rstrip())
+        return "\n".join(lines)
+    except Exception as e:
+        return f"读取失败: {e}"
+
+
+def _tool_run_bash(command: str) -> str:
+    # 将项目 .venv 的 Scripts/bin 目录加到 PATH 最前面,确保 python 指向虚拟环境
+    env = os.environ.copy()
+    venv_scripts = os.path.join(PROJECT_ROOT, ".venv", "Scripts")  # Windows
+    if not os.path.isdir(venv_scripts):
+        venv_scripts = os.path.join(PROJECT_ROOT, ".venv", "bin")  # Linux/Mac
+    env["PATH"] = venv_scripts + os.pathsep + env.get("PATH", "")
+    env["VIRTUAL_ENV"] = os.path.join(PROJECT_ROOT, ".venv")
+    env["PYTHONIOENCODING"] = "utf-8"
+
+    try:
+        proc = subprocess.Popen(
+            command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+            text=True, cwd=PROJECT_ROOT, env=env, encoding="utf-8", errors="replace",
+        )
+        try:
+            stdout, stderr = proc.communicate(timeout=30)
+        except subprocess.TimeoutExpired:
+            # 超时:杀掉整个进程树(Windows 需要 taskkill)
+            try:
+                if sys.platform == "win32":
+                    subprocess.run(
+                        f"taskkill /F /T /PID {proc.pid}",
+                        shell=True, capture_output=True, timeout=5,
+                    )
+                else:
+                    import signal
+                    os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
+            except Exception:
+                proc.kill()
+            proc.wait(timeout=5)
+            return "命令执行超时(30秒),已终止进程"
+
+        output = ""
+        if stdout:
+            output += stdout
+        if stderr:
+            output += ("\n--- stderr ---\n" + stderr) if output else stderr
+        if proc.returncode != 0:
+            output += f"\n(exit code: {proc.returncode})"
+        return output.strip() or "(无输出)"
+    except Exception as e:
+        return f"执行失败: {e}"
+
+
+def _tool_send_image(path: str) -> str:
+    """发送图片到飞书"""
+    from agent.tools.builtin.feishu.chat import FEISHU_APP_ID, FEISHU_APP_SECRET
+    from agent.tools.builtin.feishu.feishu_client import FeishuClient, FeishuDomain, ReceiveIdType
+
+    if not _current_chat_id:
+        return "错误:无法获取当前会话 ID"
+
+    path = os.path.abspath(path)
+    if not os.path.isfile(path):
+        return f"文件不存在: {path}"
+
+    try:
+        client = FeishuClient(app_id=FEISHU_APP_ID, app_secret=FEISHU_APP_SECRET, domain=FeishuDomain.FEISHU)
+        client.send_image(to=_current_chat_id, image=path)
+        return f"图片已发送: {os.path.basename(path)}"
+    except Exception as e:
+        return f"发送图片失败: {e}"
+
+
+def _tool_send_file(path: str) -> str:
+    """发送文件到飞书"""
+    from agent.tools.builtin.feishu.chat import FEISHU_APP_ID, FEISHU_APP_SECRET
+    from agent.tools.builtin.feishu.feishu_client import FeishuClient, FeishuDomain
+
+    if not _current_chat_id:
+        return "错误:无法获取当前会话 ID"
+
+    path = os.path.abspath(path)
+    if not os.path.isfile(path):
+        return f"文件不存在: {path}"
+
+    try:
+        client = FeishuClient(app_id=FEISHU_APP_ID, app_secret=FEISHU_APP_SECRET, domain=FeishuDomain.FEISHU)
+        client.send_file(to=_current_chat_id, file=path, file_name=os.path.basename(path))
+        return f"文件已发送: {os.path.basename(path)}"
+    except Exception as e:
+        return f"发送文件失败: {e}"
+
+
+def _tool_zip_and_send(path: str) -> str:
+    """打包目录并发送到飞书"""
+    from agent.tools.builtin.feishu.chat import FEISHU_APP_ID, FEISHU_APP_SECRET
+    from agent.tools.builtin.feishu.feishu_client import FeishuClient, FeishuDomain
+
+    if not _current_chat_id:
+        return "错误:无法获取当前会话 ID"
+
+    path = os.path.abspath(path)
+    if not os.path.isdir(path):
+        return f"目录不存在: {path}"
+
+    try:
+        # 创建临时 zip 文件
+        zip_name = os.path.basename(path.rstrip(os.sep)) + ".zip"
+        zip_path = os.path.join(PROJECT_ROOT, zip_name)
+
+        with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
+            for root, dirs, files in os.walk(path):
+                for file in files:
+                    file_path = os.path.join(root, file)
+                    arcname = os.path.relpath(file_path, path)
+                    zipf.write(file_path, arcname)
+
+        client = FeishuClient(app_id=FEISHU_APP_ID, app_secret=FEISHU_APP_SECRET, domain=FeishuDomain.FEISHU)
+        client.send_file(to=_current_chat_id, file=zip_path, file_name=zip_name)
+
+        # 删除临时文件
+        os.remove(zip_path)
+
+        return f"目录已打包并发送: {zip_name}"
+    except Exception as e:
+        return f"打包发送失败: {e}"
+
+
+# ===== 后台进程管理 =====
+
+def _output_reader(proc: subprocess.Popen, pid: int):
+    """后台线程:持续读取进程输出并存入缓冲区"""
+    try:
+        for line in iter(proc.stdout.readline, ''):
+            if pid not in _background_processes:
+                break
+            _background_processes[pid]["output_lines"].append(line.rstrip())
+            # 只保留最近 500 行
+            if len(_background_processes[pid]["output_lines"]) > 500:
+                _background_processes[pid]["output_lines"] = _background_processes[pid]["output_lines"][-500:]
+    except Exception:
+        pass
+
+    # stderr 也读
+    try:
+        for line in iter(proc.stderr.readline, ''):
+            if pid not in _background_processes:
+                break
+            _background_processes[pid]["output_lines"].append(f"[stderr] {line.rstrip()}")
+            if len(_background_processes[pid]["output_lines"]) > 500:
+                _background_processes[pid]["output_lines"] = _background_processes[pid]["output_lines"][-500:]
+    except Exception:
+        pass
+
+
+def _tool_run_background(command: str, name: str = "") -> str:
+    """后台启动命令"""
+    env = os.environ.copy()
+    venv_scripts = os.path.join(PROJECT_ROOT, ".venv", "Scripts")
+    if not os.path.isdir(venv_scripts):
+        venv_scripts = os.path.join(PROJECT_ROOT, ".venv", "bin")
+    env["PATH"] = venv_scripts + os.pathsep + env.get("PATH", "")
+    env["VIRTUAL_ENV"] = os.path.join(PROJECT_ROOT, ".venv")
+    env["PYTHONIOENCODING"] = "utf-8"
+    env["PYTHONUNBUFFERED"] = "1"  # 强制 Python 无缓冲输出
+    env["PYTHONIOENCODING"] = "utf-8"  # 强制 Python IO 编码为 utf-8
+
+    try:
+        proc = subprocess.Popen(
+            command, shell=True,
+            stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+            text=True, cwd=PROJECT_ROOT, env=env, encoding="utf-8", errors="replace",
+            bufsize=1,  # 行缓冲
+        )
+
+        from datetime import datetime
+        _background_processes[proc.pid] = {
+            "proc": proc,
+            "name": name or command[:40],
+            "command": command,
+            "output_lines": [],
+            "started_at": datetime.now().strftime("%H:%M:%S"),
+        }
+
+        # 启动输出读取线程
+        t = threading.Thread(target=_output_reader, args=(proc, proc.pid), daemon=True)
+        t.start()
+
+        return f"后台进程已启动: PID={proc.pid}, name={name or command[:40]}"
+    except Exception as e:
+        return f"启动失败: {e}"
+
+
+def _tool_stop_process(pid: int) -> str:
+    """停止后台进程"""
+    info = _background_processes.get(pid)
+    if not info:
+        return f"未找到 PID={pid} 的后台进程"
+
+    proc = info["proc"]
+    name = info["name"]
+    try:
+        if sys.platform == "win32":
+            subprocess.run(f"taskkill /F /T /PID {pid}", shell=True, capture_output=True, timeout=5)
+        else:
+            import signal
+            os.killpg(os.getpgid(pid), signal.SIGKILL)
+    except Exception:
+        proc.kill()
+
+    try:
+        proc.wait(timeout=5)
+    except Exception:
+        pass
+
+    _background_processes.pop(pid, None)
+    return f"已停止进程: PID={pid} ({name})"
+
+
+def _tool_list_processes() -> str:
+    """列出所有后台进程"""
+    if not _background_processes:
+        return "当前没有后台进程"
+
+    lines = []
+    for pid, info in _background_processes.items():
+        proc = info["proc"]
+        status = "运行中" if proc.poll() is None else f"已退出(code={proc.returncode})"
+        lines.append(f"  PID={pid} | {status} | {info['name']} | 启动于 {info['started_at']}")
+    return f"后台进程 ({len(lines)} 个):\n" + "\n".join(lines)
+
+
+def _tool_get_process_output(pid: int, tail: int = 50) -> str:
+    """获取后台进程的最新输出"""
+    info = _background_processes.get(pid)
+    if not info:
+        return f"未找到 PID={pid} 的后台进程"
+
+    output_lines = info["output_lines"]
+    proc = info["proc"]
+    status = "运行中" if proc.poll() is None else f"已退出(code={proc.returncode})"
+
+    if not output_lines:
+        return f"PID={pid} ({info['name']}) [{status}]: 暂无输出"
+
+    recent = output_lines[-tail:]
+    header = f"PID={pid} ({info['name']}) [{status}] 最近 {len(recent)} 行:\n"
+    return header + "\n".join(recent)
+
+
+def _tool_send_input(pid: int, text: str) -> str:
+    """向后台进程发送输入(通过控制文件)"""
+    info = _background_processes.get(pid)
+    if not info:
+        return f"未找到 PID={pid} 的后台进程"
+
+    proc = info["proc"]
+    if proc.poll() is not None:
+        return f"进程已退出,无法发送输入"
+
+    try:
+        # 使用控制文件方式(更可靠)
+        control_file = os.path.join(PROJECT_ROOT, ".agent_control")
+        with open(control_file, "w", encoding="utf-8") as f:
+            f.write(text.strip())
+        return f"已向 PID={pid} 发送控制指令: {text.strip()}"
+    except Exception as e:
+        return f"发送输入失败: {e}"
+        return f"发送输入失败: {e}"
+
+
+class FeishuAgent:
+    """飞书实时对话 Agent"""
+
+    def __init__(self):
+        self.client = FeishuClient(
+            app_id=FEISHU_APP_ID,
+            app_secret=FEISHU_APP_SECRET,
+            domain=FeishuDomain.FEISHU,
+        )
+        self.conversations: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
+        self._history_loaded: set = set()
+        self.loop = asyncio.new_event_loop()
+        self._loop_thread = threading.Thread(target=self._run_loop, daemon=True)
+        self._loop_thread.start()
+
+    def _run_loop(self):
+        asyncio.set_event_loop(self.loop)
+        self.loop.run_forever()
+
+    def _get_conversation_key(self, event: FeishuMessageEvent) -> str:
+        if event.chat_type and event.chat_type.value == "p2p":
+            return event.sender_open_id
+        return event.chat_id
+
+    def _build_messages(self, conv_key: str) -> List[Dict[str, Any]]:
+        messages = [{"role": "system", "content": SYSTEM_PROMPT}]
+        conv = self.conversations[conv_key]
+
+        # 验证并清理 tool_calls:确保每个 assistant+tool_calls 后面都有对应的 tool response
+        clean = []
+        i = 0
+        while i < len(conv):
+            msg = conv[i]
+            if msg.get("role") == "assistant" and msg.get("tool_calls"):
+                # 收集这个 assistant 消息的所有 tool_call_id
+                expected_ids = {tc.get("id") for tc in msg.get("tool_calls", [])}
+                # 检查后续消息是否有对应的 tool response
+                j = i + 1
+                found_ids = set()
+                while j < len(conv) and conv[j].get("role") == "tool":
+                    found_ids.add(conv[j].get("tool_call_id"))
+                    j += 1
+
+                # 只有所有 tool_call_id 都有对应 response 才保留
+                if expected_ids == found_ids:
+                    clean.append(msg)
+                    # 添加对应的 tool response
+                    for k in range(i + 1, j):
+                        clean.append(conv[k])
+                    i = j
+                else:
+                    # 不完整,跳过这组
+                    i = j
+            elif msg.get("role") == "tool":
+                # 孤立的 tool response,跳过
+                i += 1
+            else:
+                clean.append(msg)
+                i += 1
+
+        messages.extend(clean)
+        return messages
+
+    def _append_message(self, conv_key: str, role: str, content: Any, **extra):
+        msg = {"role": role, "content": content, **extra}
+        self.conversations[conv_key].append(msg)
+        if len(self.conversations[conv_key]) > MAX_HISTORY:
+            self.conversations[conv_key] = self.conversations[conv_key][-MAX_HISTORY:]
+
+    def _extract_content(self, event: FeishuMessageEvent) -> Optional[Any]:
+        """从飞书消息中提取内容(文本/图片/文件),返回 OpenAI 多模态格式"""
+        if event.content_type == "text":
+            try:
+                parsed = json.loads(event.content)
+                return parsed.get("text", event.content)
+            except (json.JSONDecodeError, TypeError):
+                return event.content
+
+        elif event.content_type == "image":
+            # 下载图片并转成 base64
+            try:
+                content_dict = json.loads(event.content)
+                image_key = content_dict.get("image_key")
+                if image_key and event.message_id:
+                    img_bytes = self.client.download_message_resource(
+                        message_id=event.message_id,
+                        file_key=image_key,
+                        resource_type="image"
+                    )
+                    b64_str = base64.b64encode(img_bytes).decode('utf-8')
+                    # 返回 OpenAI 多模态格式
+                    return [
+                        {"type": "text", "text": "[用户发送了一张图片]"},
+                        {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64_str}"}}
+                    ]
+            except Exception as e:
+                logger.error(f"下载图片失败: {e}")
+                return "[图片下载失败]"
+
+        elif event.content_type == "file":
+            # 下载文件到本地
+            try:
+                content_dict = json.loads(event.content)
+                file_key = content_dict.get("file_key")
+                file_name = content_dict.get("file_name", "unknown_file")
+                if file_key and event.message_id:
+                    file_bytes = self.client.download_message_resource(
+                        message_id=event.message_id,
+                        file_key=file_key,
+                        resource_type="file"
+                    )
+                    # 保存到项目根目录的 downloads 文件夹
+                    download_dir = os.path.join(PROJECT_ROOT, "downloads")
+                    os.makedirs(download_dir, exist_ok=True)
+                    save_path = os.path.join(download_dir, file_name)
+                    with open(save_path, "wb") as f:
+                        f.write(file_bytes)
+                    return f"[用户发送了文件: {file_name},已保存到 {save_path}]"
+            except Exception as e:
+                logger.error(f"下载文件失败: {e}")
+                return "[文件下载失败]"
+
+        return None
+
+    def _load_history_from_disk(self, contact_name: str, conv_key: str):
+        if conv_key in self._history_loaded:
+            return
+        self._history_loaded.add(conv_key)
+        history = load_chat_history(contact_name)
+        for msg in history[-MAX_HISTORY:]:
+            role = msg.get("role", "user")
+            content = msg.get("content", "")
+            if isinstance(content, list):
+                text_parts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"]
+                content = "\n".join(text_parts)
+            if isinstance(content, str) and content.strip():
+                self.conversations[conv_key].append({"role": role, "content": content})
+
+    def _save_to_disk(self, contact_name: str, conv_key: str):
+        # 保存完整对话历史,包括工具调用和结果
+        saveable = []
+        for m in self.conversations[conv_key]:
+            role = m.get("role")
+            content = m.get("content")
+
+            # 保存所有消息类型
+            if role in ("user", "assistant", "tool"):
+                msg = {"role": role}
+
+                # 处理 content
+                if isinstance(content, str):
+                    msg["content"] = content
+                elif isinstance(content, list):
+                    # 多模态消息:提取文本部分保存
+                    text_parts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"]
+                    if text_parts:
+                        msg["content"] = "\n".join(text_parts)
+                    else:
+                        msg["content"] = "[多模态内容]"
+                else:
+                    msg["content"] = str(content) if content else ""
+
+                # 保存 tool_calls(如果有)
+                if "tool_calls" in m:
+                    msg["tool_calls"] = m["tool_calls"]
+
+                # 保存 tool_call_id(如果有)
+                if "tool_call_id" in m:
+                    msg["tool_call_id"] = m["tool_call_id"]
+
+                saveable.append(msg)
+
+        save_chat_history(contact_name, saveable)
+
+    def handle_message(self, event: FeishuMessageEvent):
+        global _current_chat_id
+        contact = get_contact_by_id(event.sender_open_id) or get_contact_by_id(event.chat_id)
+        if not contact:
+            logger.debug(f"忽略非白名单消息: {event.sender_open_id}")
+            return
+
+        sender_name = contact.get("name", "未知")
+        if sender_name not in ALLOWED_CONTACTS:
+            logger.debug(f"忽略非白名单联系人: {sender_name}")
+            return
+
+        user_content = self._extract_content(event)
+        if not user_content:
+            logger.info(f"[{sender_name}] 发送了不支持的消息类型,跳过")
+            return
+
+        # 记录消息类型
+        if isinstance(user_content, str):
+            logger.info(f"收到 [{sender_name}]: {user_content[:80]}")
+        else:
+            logger.info(f"收到 [{sender_name}]: [多模态消息]")
+
+        conv_key = self._get_conversation_key(event)
+        _current_chat_id = event.chat_id  # 设置全局变量供工具使用
+
+        self._load_history_from_disk(sender_name, conv_key)
+        self._append_message(conv_key, "user", user_content)
+        self._save_to_disk(sender_name, conv_key)
+
+        future = asyncio.run_coroutine_threadsafe(
+            self._generate_and_reply(conv_key, event, sender_name),
+            self.loop,
+        )
+        future.add_done_callback(self._on_reply_done)
+
+    async def _generate_and_reply(self, conv_key: str, event: FeishuMessageEvent, sender_name: str):
+        """调用 Qwen LLM,支持多轮工具调用循环"""
+        try:
+            last_tool_calls = []  # 记录最近的工具调用,用于检测循环
+            for round_i in range(MAX_TOOL_ROUNDS):
+                messages = self._build_messages(conv_key)
+
+                result = await qwen_llm_call(
+                    messages=messages,
+                    model=MODEL,
+                    tools=TOOLS,
+                    temperature=0.7,
+                    max_tokens=4096,
+                )
+
+                tool_calls = result.get("tool_calls")
+
+                if not tool_calls:
+                    # 没有工具调用,直接回复
+                    reply_text = result.get("content", "").strip()
+                    if not reply_text:
+                        reply_text = "(抱歉,我暂时无法生成回复)"
+
+                    self._append_message(conv_key, "assistant", reply_text)
+                    self._save_to_disk(sender_name, conv_key)
+
+                    self.client.send_message(
+                        to=event.chat_id,
+                        text=reply_text,
+                        receive_id_type=ReceiveIdType.CHAT_ID,
+                    )
+
+                    tokens_in = result.get("prompt_tokens", 0)
+                    tokens_out = result.get("completion_tokens", 0)
+                    cost = result.get("cost", 0)
+                    logger.info(
+                        f"回复 [{sender_name}]: {reply_text[:80]}... "
+                        f"(tokens: {tokens_in}+{tokens_out}, cost: ¥{cost:.4f})"
+                    )
+                    return
+
+                # 检测循环:如果连续 2 次调用相同工具(仅比较工具名),强制退出
+                current_call_names = [tc.get("function", {}).get("name") for tc in tool_calls]
+                last_tool_calls.append(current_call_names)
+                if len(last_tool_calls) >= 2 and last_tool_calls[-1] == last_tool_calls[-2]:
+                    logger.warning(f"[{sender_name}] 检测到工具调用循环: {current_call_names}")
+                    self._append_message(conv_key, "assistant", "(检测到重复调用,已停止)")
+                    self._save_to_disk(sender_name, conv_key)
+                    self.client.send_message(
+                        to=event.chat_id,
+                        text="⚠️ 检测到重复调用相同工具,已停止。请换一个方式或直接告诉我结果。",
+                        receive_id_type=ReceiveIdType.CHAT_ID,
+                    )
+                    return
+
+                # 有工具调用:追加 assistant 消息(含 tool_calls),然后执行
+                assistant_msg = {"role": "assistant", "content": result.get("content", "") or ""}
+                assistant_msg["tool_calls"] = tool_calls
+                self.conversations[conv_key].append(assistant_msg)
+
+                for tc in tool_calls:
+                    func = tc.get("function", {})
+                    tool_name = func.get("name", "")
+                    try:
+                        tool_args = json.loads(func.get("arguments", "{}"))
+                    except json.JSONDecodeError:
+                        tool_args = {}
+
+                    logger.info(f"[{sender_name}] 调用工具: {tool_name}({tool_args})")
+                    # 在线程池中执行工具,避免阻塞 event loop
+                    loop = asyncio.get_event_loop()
+                    tool_result = await loop.run_in_executor(
+                        None, execute_tool, tool_name, tool_args
+                    )
+
+                    # 截断过长的工具结果
+                    if len(tool_result) > 4000:
+                        tool_result = tool_result[:4000] + "\n... (结果已截断)"
+
+                    self.conversations[conv_key].append({
+                        "role": "tool",
+                        "tool_call_id": tc.get("id", ""),
+                        "content": tool_result,
+                    })
+
+            # 超过最大轮次
+            self._append_message(conv_key, "assistant", "(工具调用轮次过多,已停止)")
+            self._save_to_disk(sender_name, conv_key)
+            self.client.send_message(
+                to=event.chat_id,
+                text="⚠️ 工具调用轮次过多,已停止",
+                receive_id_type=ReceiveIdType.CHAT_ID,
+            )
+
+        except Exception as e:
+            logger.error(f"生成回复失败: {e}", exc_info=True)
+            try:
+                self.client.send_message(
+                    to=event.chat_id,
+                    text=f"⚠️ 生成回复时出错: {type(e).__name__}",
+                    receive_id_type=ReceiveIdType.CHAT_ID,
+                )
+            except Exception:
+                logger.error("发送错误消息也失败了", exc_info=True)
+
+    def _on_reply_done(self, future):
+        exc = future.exception()
+        if exc:
+            logger.error(f"回复任务异常: {exc}", exc_info=exc)
+
+    def start(self):
+        logger.info(f"启动飞书对话 Agent (model={MODEL})")
+        logger.info("等待飞书消息... 按 Ctrl+C 退出")
+
+        try:
+            self.client.start_websocket(
+                on_message=self.handle_message,
+                blocking=True,
+            )
+        except KeyboardInterrupt:
+            logger.info("Agent 已停止")
+        finally:
+            self.loop.call_soon_threadsafe(self.loop.stop)
+
+
+if __name__ == "__main__":
+    agent = FeishuAgent()
+    agent.start()

+ 100 - 0
examples/plan/xiaocong/analysis.json

@@ -0,0 +1,100 @@
+{
+  "category": {
+    "name": "AI 知识科普笔记风格海报",
+    "traits": [
+      "拟物化笔记本场景呈现(螺旋笔记本框架、边缘纹理、阴影)",
+      "结构化排版与导视(居中堆叠、虚线分隔、层级列表)",
+      "语义化色彩高亮(荧光笔触、彩色字体标记关键术语)",
+      "手绘装饰与卡通图标(星星、花朵、笑脸等涂鸦元素)",
+      "知识可视化图表(将抽象算法逻辑具象化为可视矩阵)",
+      "拟真纸张纹理(带褶皱纹理的信纸、横线笔记本纸张)"
+    ],
+    "ai_challenges": [
+      "纸张纹理的真实感还原(褶皱、横线、虚线边框的自然度)",
+      "手绘装饰的自然笔触感(避免过于规整的 AI 生成痕迹)",
+      "文字与背景的融合度(荧光笔触效果、彩色字体的边缘处理)",
+      "立体装饰元素的透视一致性(回形针的立体感、阴影方向)",
+      "知识图表的数据准确性与视觉美观的平衡"
+    ],
+    "reasoning": "基于 index.md 明确这是'AI 知识科普笔记风格海报的解构数据',制作亮点.md 定义了 6 组视觉亮点聚类(3 实质 +3 形式),制作点.md 按权重排序了 3 个核心元素。该品类的核心特征是将数字化知识内容包装在物理笔记本的视觉容器中,通过拟物化设计增强阅读代入感。"
+  },
+  "highlight": [
+    {
+      "name": "拟真纸张与笔记本实体",
+      "description": "必须高度还原带褶皱纹理的信纸和螺旋活页笔记本的真实触感,包括纸张的横线纹理、边缘虚线边框、螺旋装订孔的细节,以及纸张因使用产生的自然褶皱和阴影变化。",
+      "reasoning": "这是实质亮点中权重最高的物理基础元素,构成画面的视觉容器。如果纸张纹理过于平滑或规则,会失去'手写笔记'的生活气息,导致整体风格失真。"
+    },
+    {
+      "name": "核心英文单词的视觉突出",
+      "description": "页面中心的大号英文单词必须位于彩色背景块上,字体颜色突出、尺寸明显大于正文,形成视觉焦点。背景块的颜色需与整体色彩体系协调且具有足够的对比度。",
+      "reasoning": "制作点中权重 87.3 的最高优先级元素,是每张图的核心信息载体。如果文字不够突出或背景块融合度差,会导致信息层级混乱,读者无法快速捕捉核心概念。"
+    },
+    {
+      "name": "语义化色彩高亮体系",
+      "description": "模拟学生笔记的荧光笔触和彩色字体,对关键术语进行视觉强调。色彩使用需建立清晰的信息层级(如定义用黄色、公式用蓝色、重点用粉色),且高亮边缘需有自然的笔触过渡。",
+      "reasoning": "形式亮点中的信息导视核心,权重体现在制作点的背景元素(59.2)中。如果色彩高亮过于均匀或缺乏层次,会失去笔记的真实感,降低信息传达效率。"
+    },
+    {
+      "name": "手绘装饰与卡通图标的自然分布",
+      "description": "星星、花朵、笑脸及各类卡通小图标需以手绘涂鸦风格散布在画面中,笔触需有自然的不规则感,分布需疏密有致,中和科技主题的枯燥感但不干扰主要内容阅读。",
+      "reasoning": "实质亮点中的氛围调节元素,是区分'模板化设计'与'真实笔记'的关键。如果装饰元素过于规整或分布均匀,会暴露 AI 生成的痕迹。"
+    },
+    {
+      "name": "知识可视化图表的准确性",
+      "description": "将抽象算法逻辑(如 Embedding 的分词到向量转化)具象化为可视矩阵数据,图表结构需准确反映技术逻辑,同时保持与整体笔记风格的视觉一致性(手写标注、彩色箭头等)。",
+      "reasoning": "实质亮点中的教育解释核心,是内容专业性的体现。如果图表结构错误或视觉风格割裂,会同时损害内容可信度和整体美观度。"
+    },
+    {
+      "name": "拟物化场景的透视一致性",
+      "description": "螺旋笔记本框架、回形针装饰、纸张阴影需保持统一的透视角度和光源方向。回形针的立体感需通过高光和阴影正确呈现,螺旋装订孔需符合透视变形规律。",
+      "reasoning": "形式亮点中的沉浸感基础,影响整体画面的真实度。如果透视不一致(如回形针阴影方向与纸张阴影矛盾),会产生视觉违和感。"
+    }
+  ],
+  "baseline": [
+    {
+      "name": "纸张纹理的基本质感",
+      "description": "背景必须呈现笔记本纸张的基本特征:横线纹理、虚线边框、适当的纸张颜色(米白或淡黄)。不能是纯色背景或过于光滑的数码质感。",
+      "why_critical": "这是笔记风格的物理基础,做不好会直接导致'一眼假'。如果背景是纯色或纹理过于规则,会失去手写笔记的生活气息,整体风格崩塌。",
+      "reasoning": "制作点中'背景'元素权重 59.2,且在 3 张图中都有出现(段落 2.1、3.1、4.1)。index.md 明确将'拟真纸张与笔记本实体'列为首要实质亮点。"
+    },
+    {
+      "name": "核心文字的清晰可读性",
+      "description": "中心英文单词必须清晰可读,字体大小明显大于正文,与背景色块有足够对比度。不能出现文字模糊、颜色融合或尺寸不足的问题。",
+      "why_critical": "这是信息传达的核心载体,权重 87.3 为最高。如果文字不清晰或不够突出,读者无法快速理解核心概念,内容失去教育价值。",
+      "reasoning": "制作点中'核心英文单词'权重最高(87.3),且在 4 张图中都有出现。这是内容功能性的底线要求。"
+    },
+    {
+      "name": "回形针装饰的基本形态",
+      "description": "页面边缘的蓝色回形针需呈现基本立体形态,有明确的金属质感和阴影。不能是扁平色块或形态扭曲。",
+      "why_critical": "这是增强拟物感的关键装饰元素,权重 48.7。如果回形针过于扁平或形态错误,会破坏整体场景的真实感,暴露 AI 生成痕迹。",
+      "reasoning": "制作点中'回形针装饰'在 3 张图中出现(段落 2.2.1、3.2.1、4.2.1),是拟物化场景的重要组成部分。"
+    },
+    {
+      "name": "排版结构的基本秩序",
+      "description": "内容需呈现居中堆叠、虚线分隔、层级列表的基本排版秩序。不能出现元素杂乱堆砌、层级混乱或对齐不一致的问题。",
+      "why_critical": "这是信息可读性的基础,对应'结构化排版与导视'亮点。如果排版混乱,会降低阅读体验,损害知识传达效率。",
+      "reasoning": "制作亮点中明确将'结构化排版与导视'列为形式亮点,index.md 提到'严谨清晰的视觉秩序'是该风格的核心特征。"
+    },
+    {
+      "name": "色彩体系的基本协调",
+      "description": "整体色彩需保持协调,荧光笔触和彩色字体需建立基本层次。不能出现色彩冲突、饱和度过高或层次混乱的问题。",
+      "why_critical": "这是视觉美观的底线,对应'语义化色彩高亮'亮点。如果色彩混乱,会产生视觉疲劳,降低内容专业感。",
+      "reasoning": "制作亮点中'语义化色彩高亮'被定义为建立信息层级的形式手段,是笔记风格的重要特征。"
+    }
+  ],
+  "requirement_summary": [
+    "【品类定位】AI 知识科普笔记风格海报,需将数字化知识内容包装在物理笔记本的视觉容器中",
+    "【核心亮点 1】拟真纸张纹理:横线、虚线边框、褶皱阴影的自然还原",
+    "【核心亮点 2】核心英文单词:大号字体 + 彩色背景块,形成视觉焦点(权重 87.3)",
+    "【核心亮点 3】语义化色彩高亮:荧光笔触 + 彩色字体建立信息层级",
+    "【核心亮点 4】手绘装饰:星星、花朵、笑脸等涂鸦的自然分布和笔触感",
+    "【核心亮点 5】知识可视化图表:准确反映技术逻辑且保持手写风格",
+    "【核心亮点 6】拟物化透视:螺旋框架、回形针、阴影的透视一致性",
+    "【下限 1】纸张基本质感:不能是纯色或光滑数码背景",
+    "【下限 2】文字清晰可读:核心单词必须突出且清晰",
+    "【下限 3】回形针立体感:不能是扁平色块",
+    "【下限 4】排版秩序:居中堆叠、虚线分隔、层级清晰",
+    "【下限 5】色彩协调:避免冲突和层次混乱",
+    "【AI 挑战】纸张纹理真实感、手绘笔触自然度、文字背景融合度、立体装饰透视一致性、图表准确性与美观平衡"
+  ]
+}

+ 97 - 0
examples/production/examples/production/xiaocong/analysis.json

@@ -0,0 +1,97 @@
+{
+  "category": {
+    "name": "AI 知识科普笔记风格海报",
+    "traits": [
+      "拟物化笔记本纸张背景(带横线、褶皱、虚线边框)",
+      "结构化排版(居中堆叠、虚线分隔、层级列表)",
+      "手绘装饰元素(星星、花朵、笑脸、卡通图标)",
+      "语义化色彩高亮(荧光笔触、彩色字体标记关键术语)",
+      "知识可视化图表(将抽象算法逻辑具象化为矩阵数据)",
+      "手写风格字体与印刷体混用"
+    ],
+    "ai_challenges": [
+      "纸张纹理的真实感还原(褶皱、脏污感、横线间距一致性)",
+      "手绘元素的自然笔触感(避免过于规整的 AI 生成痕迹)",
+      "文字排版的精确对齐(多语言混排、字号层级、居中对齐)",
+      "色彩高亮的语义一致性(荧光笔触边缘模糊效果、颜色透明度)",
+      "拟物化装饰的立体感(回形针光影、螺旋装订孔洞细节)",
+      "表格与图表的结构化生成(行列对齐、填充色均匀、边框清晰)"
+    ],
+    "reasoning": "基于 index.md 和制作亮点.md 的分析,该品类核心特征是将数字化 AI 知识内容包装在实体笔记本的视觉容器中,通过拟物化背景、手绘装饰、结构化排版三大支柱构建'手记'风格。制作点.md 显示核心英文单词(权重 87.3)、背景(59.2)、回形针装饰(48.7)是最关键的三个元素,任何一项还原不到位都会导致'一眼假'。"
+  },
+  "highlight": [
+    {
+      "name": "拟真纸张纹理与笔记本实体感",
+      "description": "米白色纸张背景(RGB: 245, 245, 235)带有横向浅灰色细线,表面有不规则褶皱和轻微脏污感;螺旋活页笔记本框架具有边缘纹理、圆孔、阴影处理,呈现真实触感的书写介质",
+      "reasoning": "这是画面的物理基础,属于实质亮点。制作点中'背景'权重 59.2,是第二大核心元素。纸张纹理的真实感直接决定整体沉浸感,褶皱和脏污感的自然度是 AI 生成的难点,做不好会显得过于干净平整而失去手记的真实感。"
+    },
+    {
+      "name": "核心英文单词的视觉层级",
+      "description": "页面中心位置的大号英文单词(如'Embedding',约 100pt),采用粗体无衬线字体,颜色突出(蓝色 RGB: 60, 140, 220 或黑色#000000),位于彩色背景块或白色纸张中心",
+      "reasoning": "制作点中权重 87.3,是第一大核心元素。作为知识主题的视觉锚点,需要在字号、颜色、位置上形成绝对主导地位。AI 生成时容易出现字号不够突出、颜色对比度不足、位置偏离中心等问题,导致主题识别度下降。"
+    },
+    {
+      "name": "手绘装饰与卡通图标的自然笔触",
+      "description": "星星(五角星、四角星)、花朵(四瓣圆形花瓣 + 圆形花蕊)、笑脸等手绘涂鸦元素散布在画面四角,具有不规则边缘和手绘质感,颜色柔和(粉色 RGB: 255, 180, 200 等)",
+      "reasoning": "属于实质亮点,功能是中和科技主题的枯燥感。手绘元素的关键在于'不完美'的自然感,AI 生成容易过于规整对称,失去手绘的随意性和温度感。需要在形状变化、边缘模糊度、颜色透明度上模拟真实手绘效果。"
+    },
+    {
+      "name": "结构化排版与视觉导视系统",
+      "description": "居中堆叠的标题层级(主标题→副标题→核心词→语录)、虚线分隔线、层级列表、左对齐正文、居中对齐表格,构建严谨清晰的视觉秩序",
+      "reasoning": "属于形式亮点,功能是将复杂信息拆解为易消化模块。AI 生成时容易出现对齐偏差、间距不一致、层级混乱等问题,导致阅读体验下降。需要精确控制各元素的坐标位置和尺寸比例。"
+    },
+    {
+      "name": "语义化色彩高亮与荧光笔触",
+      "description": "模拟学生笔记习惯,用荧光笔触(粉色涂抹区域,边缘模糊)、彩色字体(蓝色关键词、浅灰色辅助词)对关键术语进行视觉强调,建立清晰的信息层级",
+      "reasoning": "属于形式亮点,功能是引导读者快速捕捉核心知识点。荧光笔触的关键在于边缘模糊效果和半透明叠加感,AI 生成容易出现边缘过于锐利或颜色饱和度过高的问题,失去真实荧光笔的质感。"
+    },
+    {
+      "name": "拟物化回形针装饰的立体感",
+      "description": "页面边缘的蓝色或黄色回形针形状装饰(固定在纸张顶部或侧边),具有金属光泽、阴影投射、弯曲弧度的立体感,模拟真实回形针夹住纸张的效果",
+      "reasoning": "制作点中权重 48.7,是第三大核心元素。回形针的立体感依赖于光影处理(高光、阴影、反射),AI 生成容易出现平面化、光影方向不一致、弯曲弧度不自然的问题,导致装饰元素显得虚假。"
+    }
+  ],
+  "baseline": [
+    {
+      "name": "背景纸张纹理的一致性",
+      "description": "横线间距均匀、颜色浅淡(浅灰色)、与纸张底色对比度适中;褶皱分布自然、不重复;虚线边框连续、圆孔大小一致",
+      "why_critical": "背景是画面的基础载体,横线间距不一致或褶皱过于规律会立即暴露 AI 生成痕迹,导致'一眼假'。横线颜色过深会干扰文字阅读,过浅则失去笔记本特征。",
+      "reasoning": "制作点中'背景'权重 59.2,且制作亮点中'拟真纸张与笔记本实体'和'拟物化笔记场景呈现'两个聚类都直接依赖背景质量。img_1、img_2、img_3、img_4 的制作表都显示背景是第一个子段落,是其他元素的承载基础。"
+    },
+    {
+      "name": "文字排版的精确对齐与层级",
+      "description": "主标题居中、副标题居中、核心词居中、语录居中;字号层级清晰(100pt→90pt→30pt→25pt);行间距、段间距一致;多语言混排时基线对齐",
+      "why_critical": "结构化排版是该品类的核心形式特征,对齐偏差或层级混乱会破坏视觉秩序,导致信息传达效率下降。AI 生成容易出现文字偏移、字号比例失调、行距不一致的问题。",
+      "reasoning": "制作亮点中'结构化排版与导视'聚类强调'严谨而清晰的视觉秩序',img_2 制作表显示文字排版的 combined_score 为 0.602,是较高的评分项。4 张图都采用居中堆叠的排版结构,这是品类的标志性特征。"
+    },
+    {
+      "name": "核心英文单词的视觉主导地位",
+      "description": "字号最大(约 100pt)、颜色对比度高(蓝色或黑色)、位置居中、无遮挡;与背景形成清晰的图底关系",
+      "why_critical": "制作点中权重 87.3,是第一大核心元素。如果核心词不够突出,会导致主题识别度下降,观众无法快速抓住知识主题。AI 生成容易出现字号不够大、颜色对比度不足、位置偏离的问题。",
+      "reasoning": "制作点.md 明确显示'核心英文单词'权重 87.3,远高于其他元素。4 张图的制作表都包含'核心英文单词'子段落(段落 X.2.2),且文字字号评分都较高,说明这是必须精准还原的上限点。"
+    },
+    {
+      "name": "手绘装饰元素的自然不规则性",
+      "description": "星星、花朵等形状有轻微变形、边缘不完全平滑、颜色有深浅变化;分布位置看似随意但有视觉平衡;不与其他元素重叠遮挡",
+      "why_critical": "手绘装饰的功能是增加温度感和活泼感,如果过于规整对称会失去手绘的灵魂,显得机械呆板。AI 生成容易追求完美对称,反而暴露生成痕迹。",
+      "reasoning": "制作亮点中'手绘装饰与卡通图标'聚类强调'轻松活泼的风格',img_1 制作表显示图案形状的 combined_score 为 0.49,相对较低,说明这是容易出问题的环节。4 张图都有手绘装饰元素,是品类的标志性特征。"
+    },
+    {
+      "name": "色彩高亮的半透明叠加效果",
+      "description": "荧光笔触颜色半透明(可看到下方文字或纹理)、边缘模糊渐变、颜色饱和度适中(不过于鲜艳);彩色字体与背景对比度适中",
+      "why_critical": "荧光笔触的关键在于模拟真实荧光笔的半透明叠加效果,如果颜色完全不透明或边缘过于锐利,会显得虚假。AI 生成容易出现颜色饱和度过高、边缘清晰的问题。",
+      "reasoning": "制作亮点中'语义化色彩高亮'聚类强调'模拟学生笔记时的色彩标记习惯',img_1 制作表显示涂抹颜色的 combined_score 为 0.406,涂抹位置的 combined_score 为 0.42,都相对较低,说明这是需要特别注意的下限点。"
+    }
+  ],
+  "requirement_summary": [
+    "生成米白色纸张背景(RGB: 245, 245, 235),带有横向浅灰色细线、不规则褶皱、轻微脏污感,虚线边框和圆孔(螺旋笔记本特征)",
+    "在页面中心位置生成大号英文单词(约 100pt),采用粗体无衬线字体,颜色为蓝色(RGB: 60, 140, 220)或黑色(#000000),形成视觉主导地位",
+    "在画面四角添加手绘装饰元素(星星、花朵、笑脸),形状有轻微变形、边缘不完全平滑、颜色柔和(粉色 RGB: 255, 180, 200 等),分布看似随意但有视觉平衡",
+    "采用居中堆叠的排版结构:主标题→副标题→核心词→语录,字号层级清晰(100pt→90pt→30pt→25pt),行间距、段间距一致",
+    "对关键术语使用荧光笔触高亮(粉色涂抹区域,边缘模糊,半透明叠加效果)和彩色字体标记(蓝色关键词、浅灰色辅助词)",
+    "在页面边缘添加回形针装饰(蓝色或黄色),具有金属光泽、阴影投射、弯曲弧度的立体感,模拟真实回形针夹住纸张的效果",
+    "生成知识可视化图表(如 Embedding 矩阵),表格行列对齐、填充色均匀(浅绿色#90EE90、浅紫色#E6E6FA、白色#FFFFFF)、边框清晰",
+    "确保多语言混排时基线对齐,中文采用手写风格粗体字体,英文采用粗体无衬线字体或手写风格衬线字体",
+    "整体色调以米白色、蓝色、黄色为主,色彩搭配和谐,避免过于鲜艳或对比度过高的颜色组合"
+  ]
+}

+ 91 - 0
examples/production/xiaocong/analysis.json

@@ -0,0 +1,91 @@
+{
+  "category": {
+    "name": "AI 知识科普笔记风格海报",
+    "traits": [
+      "拟物化笔记本/活页夹视觉框架",
+      "结构化信息分层排版",
+      "手绘装饰元素中和科技主题",
+      "语义化色彩高亮关键术语",
+      "知识可视化图表辅助理解"
+    ],
+    "ai_challenges": [
+      "纸张纹理和褶皱的真实感还原",
+      "螺旋活页夹的立体感和阴影处理",
+      "手绘涂鸦的自然笔触感",
+      "文字与背景的正确层级关系",
+      "回形针等金属装饰的光泽和立体感"
+    ],
+    "reasoning": "基于 index.md 和制作亮点.md 的分析,该品类核心特征是将数字化 AI 知识内容包装在实体笔记本的视觉框架中,通过拟物化设计增强阅读代入感。6 组亮点聚类中 3 组为实质(纸张实体、手绘装饰、可视化图表),3 组为形式(拟物化场景、结构化排版、语义化色彩),体现了'物理载体 + 信息设计'的双重特征。"
+  },
+  "highlight": [
+    {
+      "name": "拟真纸张与笔记本实体",
+      "description": "带褶皱纹理的信纸和螺旋活页笔记本,真实触感的书写介质,包括米白色纸张纹理、横向浅灰色细线、不规则褶皱和轻微脏污感",
+      "reasoning": "这是画面的物理基础,属于实质亮点。制作点中'背景'权重 59.2,直接依赖此亮点的还原质量。做不好会导致画面失去'手记'的真实感,一眼看出是纯数字生成。"
+    },
+    {
+      "name": "拟物化笔记场景呈现",
+      "description": "模仿实体螺旋笔记本的构图框架、边缘纹理和阴影处理,包括蓝色活页夹边缘、纸张边缘虚线、圆孔打孔、螺旋环结构",
+      "reasoning": "这是形式亮点,构建沉浸式'手记'视觉风格的关键。4 张图中 img_2/3/4 均采用螺旋笔记本框架,是系列一致性的核心特征。"
+    },
+    {
+      "name": "核心英文单词视觉焦点",
+      "description": "页面中心的大号英文单词(如'Embedding'),位于彩色背景块上,采用粗体无衬线字体,字号约 90-100pt,颜色突出(蓝色或黑色)",
+      "reasoning": "这是制作点中权重最高(87.3)的元素,是每张图的视觉焦点。4 张图的制作表均包含'核心英文单词'段落,且评分均为最高(0.665-0.672)。"
+    },
+    {
+      "name": "回形针装饰",
+      "description": "页面顶部边缘的黄色/蓝色回形针形状装饰,用于固定页面,具有立体感和金属光泽",
+      "reasoning": "制作点中权重 48.7,出现在 img_2/3/4 中。虽权重不是最高,但属于'一眼假'风险点——回形针的立体感和金属质感难以用 AI 直接生成,需要特殊处理。"
+    },
+    {
+      "name": "结构化排版与导视",
+      "description": "居中堆叠、虚线分隔、层级列表,严谨清晰的视觉秩序,包括标题左对齐带序号和卡通表情、正文左对齐分段落、表格居中对齐",
+      "reasoning": "形式亮点,支撑知识内容的可读性。img_2/3/4 的内容区均采用此排版,是信息传达效率的保障。"
+    },
+    {
+      "name": "手绘装饰与卡通图标",
+      "description": "星星、花朵、笑脸及各类卡通小图标等手绘涂鸦元素,分布在画面四角或标题旁,颜色鲜艳(粉色、黄色、绿色)",
+      "reasoning": "实质亮点,中和科技主题枯燥感。img_1 中大量使用(左上角星星、右上角椭圆边框、左下角花朵、右下角四角星),img_2/3/4 中也有卡通表情点缀。"
+    }
+  ],
+  "baseline": [
+    {
+      "name": "背景纸张纹理真实感",
+      "description": "米白色纸张底色(RGB: 245, 245, 235)、横向浅灰色细线、不规则褶皱和轻微脏污感,边缘虚线边框",
+      "why_critical": "做不好会导致画面失去'手记'的真实感,变成纯数字海报。纸张纹理是拟物化设计的基础,褶皱和脏污感是区分 AI 生成和真实拍摄的关键细节。",
+      "reasoning": "制作点中'背景'权重 59.2,4 张图的制作表均将'背景'作为第一子段落。img_1 的背景纹理评分 0.63,img_2/3/4 的背景颜色/纹理评分在 0.42-0.64 之间,是基础但易出错的元素。"
+    },
+    {
+      "name": "文字与背景层级关系",
+      "description": "文字清晰可读,与背景有足够对比度;核心英文单词位于彩色背景块上时有正确的遮挡和融合效果",
+      "why_critical": "做不好会导致文字模糊、边缘锯齿或与背景混为一体,直接影响信息传达。AI 生成图像常出现文字变形、笔画错误的问题。",
+      "reasoning": "4 张图的制作表中,文字相关形式参数(文字内容、颜色、字号、字体、排版)占比最高。核心英文单词的评分均为最高(0.665-0.672),说明这是质量判断的关键指标。"
+    },
+    {
+      "name": "回形针立体感和金属质感",
+      "description": "回形针具有正确的三维形态、金属光泽和阴影投射,与纸张有正确的前后层级关系",
+      "why_critical": "做不好会显得扁平、塑料感或像贴纸,破坏拟物化效果。金属材质的光泽和反射是 AI 生成的难点。",
+      "reasoning": "制作点中权重 48.7,但 img_2/3/4 的制作表中回形针段落评分极低(0.154-0.175),说明这是当前还原的薄弱环节,需要重点关注。"
+    },
+    {
+      "name": "螺旋活页夹结构准确性",
+      "description": "蓝色活页夹边缘的波浪形结构、三个半圆形环状线条、纸张左侧的三个圆形打孔,位置和比例准确",
+      "why_critical": "做不好会导致结构变形、比例失调,破坏拟物化场景的可信度。螺旋环的透视和阴影处理是技术难点。",
+      "reasoning": "img_2/3/4 均采用螺旋笔记本框架,背景形状和线条形状的形式参数详细描述了这个结构。这是系列一致性的核心,做不好会导致风格不统一。"
+    }
+  ],
+  "requirement_summary": [
+    "【品类特征】AI 知识科普笔记风格海报,需同时还原拟物化物理载体(纸张、活页夹)和信息设计(排版、色彩高亮)双重特征",
+    "【上限点 1】核心英文单词:大号粗体无衬线字体(90-100pt),位于画面中心,颜色突出(蓝色 RGB: 60,140,220 或黑色),评分需达到 0.66+",
+    "【上限点 2】拟真纸张纹理:米白色底色(RGB: 245,245,235),横向浅灰色细线,不规则褶皱和轻微脏污感,边缘虚线边框",
+    "【上限点 3】拟物化笔记本框架:蓝色活页夹边缘(#87CEEB),波浪形结构,三个圆形打孔,螺旋环立体感和阴影",
+    "【上限点 4】回形针装饰:黄色/蓝色,金属光泽和立体感,正确的前后层级关系,需解决当前评分低(0.15-0.17)的问题",
+    "【上限点 5】手绘装饰元素:星星、花朵、笑脸、卡通图标,分布在画面四角或标题旁,颜色鲜艳,自然笔触感",
+    "【上限点 6】结构化排版:标题左对齐带序号和卡通表情,正文左对齐分段落,虚线分隔,表格居中对齐",
+    "【下限点 1】背景纸张纹理真实感:避免纯数字海报感,褶皱和脏污感是关键细节",
+    "【下限点 2】文字与背景层级关系:文字清晰可读,无变形、无锯齿,核心单词与背景块正确融合",
+    "【下限点 3】回形针立体感:避免扁平、塑料感,需呈现金属光泽和正确阴影",
+    "【下限点 4】螺旋活页夹结构:结构不变形、比例准确,螺旋环透视和阴影处理正确"
+  ]
+}

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