#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ KB Workflow - Knowledge Base Workflow Orchestrator 知识库工作流编排技能。 核心职责: 1. 接收用户消息,识别触发词和链接 2. 查重(根据URL/文件名) 3. 调用子agent处理各阶段 4. 文件流转(mv) 5. Git版本管理 6. 归档和清理 设计原则: - workflow不读文件内容 - 从文件名提取平台(第一个下划线之前) - 子技能自己定义输入输出格式 - workflow只传递提示词 + 需要的路径信息 """ import os import sys import json import argparse import subprocess import shutil import re from datetime import datetime from pathlib import Path from typing import Dict, List, Optional, Any # ==================== 子技能映射 ==================== LINK_PARSER_AGENT = "kb-link-parser" METADATA_AGENTS = { "bilibili": "kb-bilibili-metadata", "zhihu": "kb-zhihu-metadata", } CONTENT_AGENTS = { "bilibili": "kb-bilibili-content", } OCR_AGENT = "kb-ocr-processor" ANALYZER_AGENT = "kb-deep-analyzer" def extract_platform(filename: str) -> str: """从文件名提取平台(第一个下划线之前)""" match = re.match(r'^([a-zA-Z0-9]+)_(.+)$', filename) if match: return match.group(1) return "unknown" def extract_base_name(filename: str) -> str: """从文件名提取基础名称(去掉扩展名)""" return Path(filename).stem def ensure_directories(base_path: Path) -> Dict[str, Path]: """确保目录存在""" dirs = { "pending": base_path / "pending", "metadata": base_path / "metadata", "content": base_path / "content", "content_downloads": base_path / "content" / "downloads", "content_subtitles": base_path / "content" / "subtitles", "analyzed": base_path / "analyzed", "archive": base_path / "archive", "archive_subscriptions": base_path / "archive" / "subscriptions", } for name, dir_path in dirs.items(): dir_path.mkdir(parents=True, exist_ok=True) return dirs def check_duplicate(base_path: Path, url: str) -> Dict[str, Any]: """查重检查""" metadata_dir = base_path / "metadata" archive_dir = base_path / "archive" # 从URL提取ID bv_match = re.search(r'BV[a-zA-Z0-9]+', url) content_id = bv_match.group() if bv_match else None result = {"duplicate": False, "status": None, "files": []} if metadata_dir.exists(): for f in metadata_dir.iterdir(): if f.is_file() and content_id and content_id in f.name: result["duplicate"] = True result["status"] = "metadata" result["files"].append(str(f)) if archive_dir.exists(): for date_dir in archive_dir.iterdir(): if date_dir.is_dir(): for f in date_dir.iterdir(): if f.is_file() and content_id and content_id in f.name: result["duplicate"] = True result["status"] = "archived" result["files"].append(str(f)) return result def call_subagent(agent_id: str, task: str, timeout: int = 120) -> Dict[str, Any]: """调用子agent(通过 OpenClaw sessions_spawn 或命令行)""" print(f"\n{'='*50}") print(f"调用子技能: {agent_id}") print(f"{'='*50}") # 尝试使用 sessions_spawn(OpenClaw 环境) try: from openclaw_tool import sessions_spawn result = sessions_spawn( agentId=agent_id, task=task, timeoutSeconds=timeout ) print(f"\n子技能返回结果:") print(json.dumps(result, ensure_ascii=False, indent=2)) return result except ImportError: # 回退到命令行调用 pass # 命令行回退:调用子技能的脚本 print(f"使用命令行方式调用: {agent_id}") # 技能目录路径 skills_path = Path(__file__).resolve().parent.parent # 从任务中提取关键信息 import re # 提取URL url_match = re.search(r'URL: (\S+)', task) url = url_match.group(1) if url_match else "" # 提取输出目录 output_match = re.search(r'输出目录: (\S+)', task) output_dir = output_match.group(1) if output_match else "" # 提取输出文件名格式 filename_match = re.search(r'输出文件名格式: (\S+)', task) filename_format = filename_match.group(1) if filename_match else "{platform}_{id}.json" # 提取链接文件路径 link_file_match = re.search(r'链接文件: (\S+)', task) link_file = link_file_match.group(1) if link_file_match else "" # 提取元数据文件路径 meta_file_match = re.search(r'元数据文件: (\S+)', task) meta_file = meta_file_match.group(1) if meta_file_match else "" # kb-link-parser if agent_id == "kb-link-parser": cmd = ['python3', str(skills_path / "kb-link-parser" / "link-parser.py"), '--url', url] try: proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) if proc.returncode == 0: try: data = json.loads(proc.stdout) if data.get('success'): platform = data.get('platform', 'unknown') content_id = data.get('id', 'unknown') ext = '.link.json' filename = f"{platform}_{content_id}{ext}" output_file = Path(output_dir) / filename output_file.parent.mkdir(parents=True, exist_ok=True) with open(output_file, 'w', encoding='utf-8') as f: json.dump(data, f, ensure_ascii=False, indent=2) print(f"保存文件: {output_file}") return {"success": True, "output_file": str(output_file)} else: return {"success": False, "error": data.get('error', 'unknown')} except json.JSONDecodeError: return {"success": False, "error": f"解析失败: {proc.stdout[:200]}"} else: return {"success": False, "error": proc.stderr} except Exception as e: return {"success": False, "error": str(e)} # kb-bilibili-metadata elif agent_id == "kb-bilibili-metadata": # 从链接文件或URL中提取BV号 bv = None if link_file and Path(link_file).exists(): with open(link_file, 'r', encoding='utf-8') as f: link_data = json.load(f) bv = link_data.get('id') if not bv: bv_match = re.search(r'(BV[a-zA-Z0-9]+)', url or link_file) bv = bv_match.group(1) if bv_match else None if not bv: return {"success": False, "error": "无法提取BV号"} # 生成输出文件名 filename = f"bilibili_{bv}.meta.json" output_file = Path(output_dir) / filename output_file.parent.mkdir(parents=True, exist_ok=True) cmd = [ 'python3', str(skills_path / "kb-bilibili-metadata" / "bilibili_metadata.py"), '--id', bv, '--type', 'video', '--output', str(output_file) ] try: proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) if proc.returncode == 0: print(f"保存文件: {output_file}") return {"success": True, "output_file": str(output_file)} else: return {"success": False, "error": proc.stderr} except Exception as e: return {"success": False, "error": str(e)} # kb-bilibili-content elif agent_id == "kb-bilibili-content": bv_match = re.search(r'(BV[a-zA-Z0-9]+)', url or meta_file) bv = bv_match.group(1) if bv_match else None if not bv: return {"success": False, "error": "无法提取BV号"} downloads_dir = Path(output_dir) / "downloads" subtitles_dir = Path(output_dir) / "subtitles" downloads_dir.mkdir(parents=True, exist_ok=True) subtitles_dir.mkdir(parents=True, exist_ok=True) video_file = None subtitle_file = None # 下载视频 cmd_video = [ 'python3', str(skills_path / "kb-bilibili-content" / "bilibili_content.py"), '--id', bv, '--type', 'video', '--output', output_dir ] try: proc = subprocess.run(cmd_video, capture_output=True, text=True, timeout=timeout) if proc.returncode == 0: # 查找生成的视频文件(可能包含BV号) for f in Path(output_dir).iterdir(): if f.is_file() and bv in f.name and f.suffix in ['.flv', '.mp4']: # 重命名为标准格式 new_name = f"bilibili_{bv}{f.suffix}" new_path = downloads_dir / new_name f.rename(new_path) video_file = str(new_path) break except Exception as e: print(f"视频下载警告: {e}") # 下载字幕 cmd_subtitle = [ 'python3', str(skills_path / "kb-bilibili-content" / "bilibili_content.py"), '--id', bv, '--type', 'subtitle', '--output', output_dir ] try: proc = subprocess.run(cmd_subtitle, capture_output=True, text=True, timeout=timeout) if proc.returncode == 0: # 查找生成的字幕文件 for f in Path(output_dir).iterdir(): if f.is_file() and bv in f.name and f.suffix in ['.srt', '.lrc', '.ass']: # 重命名为标准格式 new_name = f"bilibili_{bv}{f.suffix}" new_path = subtitles_dir / new_name f.rename(new_path) subtitle_file = str(new_path) break except Exception as e: print(f"字幕下载警告: {e}") if video_file or subtitle_file: return {"success": True, "output_file": video_file, "subtitles": subtitle_file} else: return {"success": False, "error": "下载未生成任何文件"} # kb-ocr-processor elif agent_id == "kb-ocr-processor": # 查找字幕文件 subtitles_dir = Path(output_dir) / "subtitles" subtitle_file = None if subtitles_dir.exists(): for f in subtitles_dir.iterdir(): if f.suffix in ['.srt', '.lrc']: subtitle_file = str(f) break if not subtitle_file: return {"success": False, "error": "未找到字幕文件"} # 生成输出文件名 text_file = Path(output_dir) / f"{Path(subtitle_file).stem}.txt" cmd = [ 'python3', str(skills_path / "kb-ocr-processor" / "ocr_processor.py"), '--input', subtitle_file, '--output', str(text_file) ] try: proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) if proc.returncode == 0: print(f"保存文件: {text_file}") return {"success": True, "output_file": str(text_file)} else: return {"success": False, "error": proc.stderr} except Exception as e: return {"success": False, "error": str(e)} # kb-deep-analyzer elif agent_id == "kb-deep-analyzer": # 生成输出文件名 meta_name = Path(meta_file).stem if meta_file else "unknown" filename = f"{meta_name}.md" output_file = Path(output_dir) / filename output_file.parent.mkdir(parents=True, exist_ok=True) # 从任务中提取文字内容文件路径 text_file_match = re.search(r'文字内容文件: (\S+)', task) text_content_file = text_file_match.group(1) if text_file_match else "" # 如果没有文字内容文件,使用 metadata 作为 content content_file = meta_file if text_content_file and Path(text_content_file).exists(): # 从文字文件创建 content JSON content_json = { "type": "transcript", "source": text_content_file, "text": Path(text_content_file).read_text(encoding='utf-8')[:10000] if Path(text_content_file).exists() else "" } content_file = Path(output_dir) / f"{meta_name}.content.json" with open(content_file, 'w', encoding='utf-8') as f: json.dump(content_json, f, ensure_ascii=False, indent=2) cmd = [ 'python3', str(skills_path / "kb-deep-analyzer" / "deep_analyzer.py"), '--metadata', meta_file or output_dir, '--content', str(content_file), '--output', str(output_file) ] try: proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) if proc.returncode == 0: print(f"保存文件: {output_file}") return {"success": True, "output_file": str(output_file)} else: return {"success": False, "error": proc.stderr} except Exception as e: return {"success": False, "error": str(e)} return {"success": False, "error": f"暂未实现命令行回退: {agent_id}"} def call_link_parser(url: str, pending_dir: Path) -> Dict[str, Any]: """调用 kb-link-parser""" task = f"""解析以下URL,将结果保存到指定目录: URL: {url} 输出目录: {pending_dir} 输出文件名格式: {{platform}}_{{id}}.link.json 请解析URL中的平台和内容ID,将解析结果保存为JSON文件。 返回结果时告诉我输出文件的完整路径。 只需要解析URL并保存结果,不需要下载内容。""" return call_subagent(LINK_PARSER_AGENT, task, timeout=60) def call_metadata_agent(platform: str, link_file: Path, metadata_dir: Path) -> Dict[str, Any]: """调用对应的元数据获取技能""" agent_id = METADATA_AGENTS.get(platform) if not agent_id: return {"success": False, "error": f"不支持的平台: {platform}"} task = f"""获取链接的元数据: 链接文件: {link_file} 输出目录: {metadata_dir} 请读取链接文件,提取URL,然后获取该链接的元数据(标题、作者、播放量等)。 将元数据保存为JSON文件,文件名格式: {{platform}}_{{id}}.meta.json 返回结果时告诉我输出文件的完整路径。""" return call_subagent(agent_id, task, timeout=120) def call_content_agent(platform: str, meta_file: Path, content_dir: Path) -> Dict[str, Any]: """调用对应的内容获取技能""" agent_id = CONTENT_AGENTS.get(platform) if not agent_id: return {"success": False, "error": f"不支持的平台: {platform}"} task = f"""下载视频内容: 元数据文件: {meta_file} 下载目录: {content_dir} 请读取元数据文件,获取视频信息,然后下载视频和字幕。 视频保存到: content/downloads/ 字幕保存到: content/subtitles/ 文件名格式: {{platform}}_{{id}}.mp4 和 {{platform}}_{{id}}.srt 返回结果时告诉我下载的视频文件和字幕文件的完整路径。""" return call_subagent(agent_id, task, timeout=600) def call_ocr_processor(source_file: Path, content_dir: Path, timeout: int = 1800) -> Dict[str, Any]: """调用 OCR 处理""" task = f"""处理文件,提取文字内容: 源文件: {source_file} 输出目录: {content_dir} 请读取文件,提取文字内容。 输出文件名格式: {{platform}}_{{id}}.txt 返回结果时告诉我输出文件的完整路径。""" return call_subagent(OCR_AGENT, task, timeout=timeout) def call_deep_analyzer(meta_file: Path, text_file: Path, analyzed_dir: Path) -> Dict[str, Any]: """调用深度分析""" task = f"""深度分析内容: 元数据文件: {meta_file} 文字内容文件: {text_file} 输出目录: {analyzed_dir} 请读取元数据和文字内容,进行深度分析。 生成分析报告,保存为Markdown文件。 文件名格式: {{platform}}_{{id}}.md 分析报告应包含: - 一句话总结 - 核心要点 - 时间轴(如适用) - 推荐标签 返回结果时告诉我输出文件的完整路径。""" return call_subagent(ANALYZER_AGENT, task, timeout=180) def archive_and_git(base_path: Path, analyzed_file: Path, git_root: Path, git_user: str, git_email: str, git_remote: str) -> Dict[str, Any]: """归档并提交Git""" result = { "success": False, "archive_path": "", "git_commit": False, "git_push": None, "error": None } # 1. 创建归档目录 date_str = datetime.now().strftime('%Y-%m-%d') date_dir = base_path / "archive" / date_str date_dir.mkdir(parents=True, exist_ok=True) # 2. 复制文件到归档目录 archive_file = date_dir / analyzed_file.name shutil.copy(analyzed_file, archive_file) result["archive_path"] = str(archive_file) # 3. 复制元数据 meta_file = base_path / "metadata" / f"{extract_base_name(analyzed_file.name)}.meta.json" if meta_file.exists(): shutil.copy(meta_file, date_dir / meta_file.name) # 4. Git操作 git_dir = git_root / ".git" if not git_dir.exists(): subprocess.run(['git', 'init'], cwd=str(git_root), capture_output=True) if git_user: subprocess.run(['git', 'config', 'user.name', git_user], cwd=str(git_root)) if git_email: subprocess.run(['git', 'config', 'user.email', git_email], cwd=str(git_root)) if (git_root / ".git").exists(): subprocess.run(['git', 'add', '.'], cwd=str(git_root), capture_output=True) commit_msg = f"Archive: {analyzed_file.name} - {datetime.now().isoformat()}" commit_result = subprocess.run( ['git', 'commit', '-m', commit_msg], cwd=str(git_root), capture_output=True, text=True ) result["git_commit"] = commit_result.returncode == 0 if git_remote and commit_result.returncode == 0: push_result = subprocess.run( ['git', 'push', 'origin', 'master'], cwd=str(git_root), capture_output=True, text=True, timeout=60 ) result["git_push"] = {"success": push_result.returncode == 0} result["success"] = True return result def process_url(url: str, base_path: Path = None) -> Dict[str, Any]: """处理单个URL的完整流程""" if not base_path: base_path = Path.home() / "work" / "knowledge-base" git_root = os.environ.get('KB_GIT_ROOT', str(base_path / 'archive')) git_user = os.environ.get('KB_GIT_USER', '') git_email = os.environ.get('KB_GIT_EMAIL', '') git_remote = os.environ.get('KB_GIT_REMOTE', '') dirs = ensure_directories(base_path) result = { "url": url, "success": False, "stages": [], "final_file": None, "archive": None, "error": None } try: # 查重 print(f"\n{'='*50}") print(f"步骤1: 查重") print(f"{'='*50}") dup_result = check_duplicate(base_path, url) if dup_result["duplicate"]: result["error"] = f"已存在: {dup_result['status']}" result["duplicate"] = True result["existing_files"] = dup_result["files"] return result # kb-link-parser print(f"\n{'='*50}") print(f"步骤2: 链接解析 (kb-link-parser)") print(f"{'='*50}") link_result = call_link_parser(url, dirs["pending"]) if not link_result.get("success"): result["error"] = f"链接解析失败: {link_result.get('error')}" return result link_file = link_result.get("output_file") if not link_file: result["error"] = "链接解析未返回输出文件" return result link_file = Path(link_file) result["stages"].append({"stage": "link_parsing", "file": str(link_file)}) # 提取平台 filename = link_file.name platform = extract_platform(filename) content_id = extract_base_name(filename) print(f"\n平台: {platform}") print(f"内容ID: {content_id}") # 元数据 print(f"\n{'='*50}") print(f"步骤3: 元数据提取 ({METADATA_AGENTS.get(platform, 'unknown')})") print(f"{'='*50}") meta_result = call_metadata_agent(platform, link_file, dirs["metadata"]) if not meta_result.get("success"): result["error"] = f"元数据获取失败: {meta_result.get('error')}" return result meta_file = meta_result.get("output_file") if not meta_file: result["error"] = "元数据获取未返回输出文件" return result meta_file = Path(meta_file) result["stages"].append({"stage": "metadata", "file": str(meta_file)}) # 内容下载 print(f"\n{'='*50}") print(f"步骤4: 内容下载 ({CONTENT_AGENTS.get(platform, 'unknown')})") print(f"{'='*50}") content_result = call_content_agent(platform, meta_file, dirs["content"]) if not content_result.get("success"): result["error"] = f"内容下载失败: {content_result.get('error')}" return result video_file = content_result.get("output_file") subtitle_file = content_result.get("subtitle") or content_result.get("subtitles") result["stages"].append({"stage": "content", "video": video_file, "subtitles": subtitle_file}) # OCR text_file = None print(f"\n{'='*50}") print(f"步骤5: OCR处理 (kb-ocr-processor)") print(f"{'='*50}") # 无论有没有字幕,都尝试OCR处理 ocr_source = subtitle_file if not ocr_source and video_file: ocr_source = video_file if ocr_source: # OCR超时设置为30分钟 ocr_result = call_ocr_processor(Path(ocr_source), dirs["content"], timeout=1800) text_file = ocr_result.get("output_file") if ocr_result.get("success") else None if text_file: result["stages"].append({"stage": "ocr", "file": text_file}) else: print(f"OCR处理未生成文件") else: print(f"无字幕文件,跳过OCR处理") # 深度分析 print(f"\n{'='*50}") print(f"步骤6: 深度分析 (kb-deep-analyzer)") print(f"{'='*50}") analyze_result = call_deep_analyzer( meta_file, Path(text_file) if text_file else meta_file, dirs["analyzed"] ) if not analyze_result.get("success"): result["error"] = f"深度分析失败: {analyze_result.get('error')}" return result analyzed_file = analyze_result.get("output_file") if not analyzed_file: result["error"] = "深度分析未返回输出文件" return result analyzed_file = Path(analyzed_file) result["stages"].append({"stage": "analyzer", "file": str(analyzed_file)}) result["final_file"] = str(analyzed_file) # 归档和Git print(f"\n{'='*50}") print(f"步骤7: 归档和Git") print(f"{'='*50}") archive_result = archive_and_git( base_path, analyzed_file, Path(git_root), git_user, git_email, git_remote ) result["archive"] = archive_result result["success"] = True print(f"\n{'='*50}") print(f"处理完成!") print(f"{'='*50}") except Exception as e: result["error"] = str(e) import traceback traceback.print_exc() return result def main(): """CLI入口""" parser = argparse.ArgumentParser( description='KB Workflow - Knowledge Base Workflow Orchestrator', formatter_class=argparse.RawDescriptionHelpFormatter ) parser.add_argument('--url', type=str, help='要处理的URL') parser.add_argument('--base-path', type=str, help='知识库根目录') args = parser.parse_args() if not args.url: print("请提供URL: --url https://...") sys.exit(1) base_path = Path(args.base_path) if args.base_path else None result = process_url(args.url, base_path) print(f"\n{'='*50}") print("KB Workflow Result") print(f"{'='*50}") print(json.dumps(result, ensure_ascii=False, indent=2, default=str)) sys.exit(0 if result.get('success') else 1) if __name__ == '__main__': main()