This repository has been archived on 2026-08-07. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
openclaw/skills/kb-workflow/kb_workflow.py
T

735 lines
25 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
KB Workflow - Knowledge Base Workflow Orchestrator
知识库工作流辅助工具函数。
此模块提供查重、文件操作、Git 管理、状态管理等工具函数,
供主代理在执行 SKILL.md 中定义的 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, Tuple
# ==================== 状态机定义 ====================
# 工作流步骤顺序
WORKFLOW_STEPS = [
"init",
"link_parse",
"metadata",
"content",
"whisper", # 可选步骤
"analyze",
"archive",
"done"
]
# 每个步骤的必需输出字段
STEP_OUTPUT_FIELDS = {
"init": [],
"link_parse": ["platform", "content_type", "content_id"],
"metadata": ["title"],
"content": ["filepath"],
"whisper": ["output_files"],
"analyze": ["output_file"],
"archive": ["archive_path"],
"done": []
}
# 步骤描述(用于汇报)
STEP_DESCRIPTIONS = {
"init": "初始化",
"link_parse": "链接解析",
"metadata": "元数据提取",
"content": "内容下载",
"whisper": "语音识别",
"analyze": "深度分析",
"archive": "归档",
"done": "完成"
}
# ==================== 子技能映射 ====================
METADATA_AGENTS = {
"bilibili": "kb-bilibili-metadata",
"zhihu": "kb-zhihu-metadata",
}
CONTENT_AGENTS = {
"bilibili": "kb-bilibili-content",
"zhihu": "kb-zhihu-content",
}
# ==================== 内容类型判断 ====================
# 需要下载视频/字幕的内容类型
VIDEO_CONTENT_TYPES = {"video", "zvideo"}
# 需要提取文字的内容类型
TEXT_CONTENT_TYPES = {"article", "zhuanlan", "question", "answer"}
# 需要下载图片的内容类型
IMAGE_CONTENT_TYPES = {"note"}
def is_video_content(content_type: str) -> bool:
"""判断是否为视频内容类型"""
return content_type in VIDEO_CONTENT_TYPES
def is_text_content(content_type: str) -> bool:
"""判断是否为文字内容类型(专栏、文章、问答等)"""
return content_type in TEXT_CONTENT_TYPES
def is_image_content(content_type: str) -> bool:
"""判断是否为图片内容类型(小红书等)"""
return content_type in IMAGE_CONTENT_TYPES
def get_content_handler(content_type: str) -> str:
"""
获取内容处理方式
Returns:
"video" - 需要下载视频/字幕
"text" - 需要提取文字
"image" - 需要下载图片
"unknown" - 未知类型
"""
if is_video_content(content_type):
return "video"
elif is_text_content(content_type):
return "text"
elif is_image_content(content_type):
return "image"
else:
return "unknown"
# ==================== 工具函数 ====================
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 get_knowledge_base_path() -> Path:
"""获取知识库根目录"""
base_path_str = os.environ.get('KB_BASE_PATH', '')
if base_path_str:
return Path(base_path_str).expanduser()
return Path.home() / "work" / "knowledge-base"
def get_archive_path() -> Path:
"""获取归档目录(AI分析文档存放位置)"""
archive_path_str = os.environ.get('KB_ARCHIVE_PATH', '')
if archive_path_str:
return Path(archive_path_str).expanduser()
# 默认:~/work/zq/30.资源/AI分析
return Path.home() / "work" / "zq" / "30.资源" / "AI分析"
def get_git_root() -> Path:
"""获取 Git 仓库根目录(用于归档提交)"""
git_root_str = os.environ.get('KB_GIT_ROOT', '')
if git_root_str:
return Path(git_root_str).expanduser()
# 默认:~/work/zq
return Path.home() / "work" / "zq"
def ensure_directories(base_path: Path = None) -> Dict[str, Path]:
"""确保目录存在"""
if base_path is None:
base_path = get_knowledge_base_path()
dirs = {
"base": base_path,
"pending": base_path / "pending",
"metadata": base_path / "metadata",
"content": base_path / "content",
"content_downloads": base_path / "content" / "downloads",
"analyzed": base_path / "analyzed",
"archive": base_path / "archive",
"archive_subscriptions": base_path / "archive" / "subscriptions",
}
for name, dir_path in dirs.items():
if isinstance(dir_path, Path):
dir_path.mkdir(parents=True, exist_ok=True)
return dirs
def check_duplicate(base_path: Path = None, url: str = None, platform: str = None, content_id: str = None) -> Dict[str, Any]:
"""查重检查"""
if base_path is None:
base_path = get_knowledge_base_path()
metadata_dir = base_path / "metadata"
archive_dir = base_path / "archive"
# 从URL提取ID
if url and not content_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 archive_and_git(
analyzed_file: str,
base_path: Path = None,
git_root: Path = None,
git_user: str = None,
git_email: str = None,
git_remote: str = None
) -> Dict[str, Any]:
"""归档并提交Git"""
if base_path is None:
base_path = get_knowledge_base_path()
if git_root is None:
git_root = get_git_root()
if git_user is None:
git_user = os.environ.get('KB_GIT_USER', '')
if git_email is None:
git_email = os.environ.get('KB_GIT_EMAIL', '')
if git_remote is None:
git_remote = os.environ.get('KB_GIT_REMOTE', '')
result = {
"success": False,
"archive_path": "",
"git_commit": False,
"git_push": None,
"error": None
}
try:
# 1. 检查并初始化 Git 仓库
git_dir = git_root / ".git"
if not git_dir.exists():
# 初始化仓库
subprocess.run(['git', 'init'], cwd=str(git_root), capture_output=True)
subprocess.run(['git', 'config', 'user.name', git_user], cwd=str(git_root))
subprocess.run(['git', 'config', 'user.email', git_email], cwd=str(git_root))
# 关联远端仓库
if git_remote:
subprocess.run(['git', 'remote', 'add', 'origin', git_remote], cwd=str(git_root), capture_output=True)
# 拉取远端分支
subprocess.run(['git', 'fetch', 'origin'], cwd=str(git_root), capture_output=True)
# 设置 main 分支并拉取
subprocess.run(['git', 'branch', '--set-upstream-to=origin/main', 'main'], cwd=str(git_root), capture_output=True)
subprocess.run(['git', 'pull', 'origin', 'main', '--rebase'], cwd=str(git_root), capture_output=True, timeout=120)
else:
# 已有仓库,检查远端是否配置
if git_remote:
remote_check = subprocess.run(['git', 'remote', '-v'], cwd=str(git_root), capture_output=True, text=True)
if 'origin' not in remote_check.stdout:
subprocess.run(['git', 'remote', 'add', 'origin', git_remote], cwd=str(git_root), capture_output=True)
# 拉取远端最新代码
subprocess.run(['git', 'fetch', 'origin'], cwd=str(git_root), capture_output=True)
# 尝试拉取 main 分支,如果失败则尝试 master
pull_result = subprocess.run(['git', 'pull', 'origin', 'main', '--rebase'], cwd=str(git_root), capture_output=True, timeout=120)
if pull_result.returncode != 0:
subprocess.run(['git', 'pull', 'origin', 'master', '--rebase'], cwd=str(git_root), capture_output=True, timeout=120)
# 2. 创建归档目录
date_str = datetime.now().strftime('%Y-%m-%d')
archive_base = get_archive_path()
date_dir = archive_base / date_str
date_dir.mkdir(parents=True, exist_ok=True)
# 3. 复制分析报告
src_file = Path(analyzed_file)
archive_file = date_dir / src_file.name
shutil.copy(src_file, archive_file)
result["archive_path"] = str(archive_file)
# 4. 复制元数据
meta_file = base_path / "metadata" / f"{src_file.stem}.meta.json"
if meta_file.exists():
shutil.copy(meta_file, date_dir / meta_file.name)
# 5. Git 提交并推送
if (git_root / ".git").exists():
# 添加所有变更
subprocess.run(['git', 'add', '.'], cwd=str(git_root), capture_output=True)
# 检查是否有变更需要提交
status_result = subprocess.run(['git', 'status', '--porcelain'], cwd=str(git_root), capture_output=True, text=True)
if status_result.stdout.strip():
commit_msg = f"Archive: {src_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', 'main'],
cwd=str(git_root), capture_output=True, text=True, timeout=60
)
if push_result.returncode != 0:
# 如果 main 分支推送失败,尝试 master
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, "output": push_result.stdout}
result["success"] = True
except Exception as e:
result["error"] = str(e)
return result
# ==================== 状态管理 ====================
def get_state_file_path(url: str, base_path: Path = None) -> Path:
"""获取状态文件路径"""
if base_path is None:
base_path = get_knowledge_base_path()
pending_dir = base_path / "pending"
pending_dir.mkdir(parents=True, exist_ok=True)
# 从 URL 提取标识符
# 尝试提取 BV 号或其他 ID
bv_match = re.search(r'BV[a-zA-Z0-9]+', url)
if bv_match:
content_id = bv_match.group()
platform = "bilibili"
else:
# 尝试提取知乎 ID
zhihu_match = re.search(r'zhihu\.com/(?:question|zvideo|p)/(\d+)', url)
if zhihu_match:
content_id = zhihu_match.group(1)
platform = "zhihu"
else:
# 使用 URL hash 作为标识
import hashlib
content_id = hashlib.md5(url.encode()).hexdigest()[:12]
platform = "unknown"
return pending_dir / f"{platform}_{content_id}.state.json"
def init_state(url: str, base_path: Path = None) -> Dict[str, Any]:
"""初始化工作流状态"""
state_file = get_state_file_path(url, base_path)
# 如果状态文件已存在,返回现有状态
if state_file.exists():
with open(state_file, 'r', encoding='utf-8') as f:
return json.load(f)
# 创建新状态
now = datetime.now().isoformat()
state = {
"url": url,
"platform": None,
"content_id": None,
"content_type": None,
"current_step": "init",
"completed_steps": [],
"step_results": {},
"errors": [],
"created_at": now,
"updated_at": now
}
with open(state_file, 'w', encoding='utf-8') as f:
json.dump(state, f, ensure_ascii=False, indent=2)
return state
def get_state(url: str, base_path: Path = None) -> Dict[str, Any]:
"""获取当前工作流状态"""
state_file = get_state_file_path(url, base_path)
if not state_file.exists():
return {
"success": False,
"error": "状态文件不存在",
"url": url,
"state_file": str(state_file)
}
with open(state_file, 'r', encoding='utf-8') as f:
state = json.load(f)
return {
"success": True,
"state": state,
"state_file": str(state_file)
}
def advance_step(
url: str,
step_name: str,
result: Dict[str, Any] = None,
base_path: Path = None
) -> Dict[str, Any]:
"""推进到下一步并记录结果"""
state_file = get_state_file_path(url, base_path)
if not state_file.exists():
return {
"success": False,
"error": "状态文件不存在,请先执行 --init-state"
}
with open(state_file, 'r', encoding='utf-8') as f:
state = json.load(f)
# 验证步骤名称
if step_name not in WORKFLOW_STEPS:
return {
"success": False,
"error": f"无效的步骤名称: {step_name}",
"valid_steps": WORKFLOW_STEPS
}
# 获取当前步骤索引
try:
current_idx = WORKFLOW_STEPS.index(state["current_step"])
new_idx = WORKFLOW_STEPS.index(step_name)
except ValueError:
return {
"success": False,
"error": f"步骤索引错误"
}
# 更新状态
if step_name != state["current_step"]:
# 标记当前步骤为完成
if state["current_step"] not in state["completed_steps"]:
state["completed_steps"].append(state["current_step"])
state["current_step"] = step_name
state["updated_at"] = datetime.now().isoformat()
# 记录步骤结果
if result:
state["step_results"][step_name] = result
# 提取平台和 ID 信息(从 link_parse 步骤)
if step_name == "link_parse" and result.get("success"):
state["platform"] = result.get("platform")
state["content_id"] = result.get("content_id")
state["content_type"] = result.get("type")
# 保存状态
with open(state_file, 'w', encoding='utf-8') as f:
json.dump(state, f, ensure_ascii=False, indent=2)
return {
"success": True,
"current_step": step_name,
"completed_steps": state["completed_steps"],
"state_file": str(state_file)
}
def record_error(
url: str,
error_msg: str,
step: str = None,
base_path: Path = None
) -> Dict[str, Any]:
"""记录错误"""
state_file = get_state_file_path(url, base_path)
if not state_file.exists():
return {
"success": False,
"error": "状态文件不存在,请先执行 --init-state"
}
with open(state_file, 'r', encoding='utf-8') as f:
state = json.load(f)
# 记录错误
error_entry = {
"step": step or state["current_step"],
"error": error_msg,
"timestamp": datetime.now().isoformat()
}
state["errors"].append(error_entry)
state["updated_at"] = datetime.now().isoformat()
# 保存状态
with open(state_file, 'w', encoding='utf-8') as f:
json.dump(state, f, ensure_ascii=False, indent=2)
return {
"success": True,
"error_recorded": error_entry,
"total_errors": len(state["errors"])
}
def get_resume_info(url: str, base_path: Path = None) -> Dict[str, Any]:
"""获取恢复执行的指令"""
state_file = get_state_file_path(url, base_path)
if not state_file.exists():
return {
"success": False,
"error": "状态文件不存在",
"suggestion": "请使用 --init-state 初始化工作流"
}
with open(state_file, 'r', encoding='utf-8') as f:
state = json.load(f)
current_step = state["current_step"]
step_desc = STEP_DESCRIPTIONS.get(current_step, current_step)
# 检查是否有错误
if state["errors"]:
last_error = state["errors"][-1]
return {
"success": True,
"status": "error",
"current_step": current_step,
"step_description": step_desc,
"last_error": last_error,
"completed_steps": state["completed_steps"],
"suggestion": f"请先解决 {last_error['step']} 步骤的错误: {last_error['error']}"
}
# 检查是否已完成
if current_step == "done":
return {
"success": True,
"status": "completed",
"current_step": "done",
"step_description": "已完成",
"completed_steps": state["completed_steps"],
"step_results": state["step_results"]
}
# 返回下一步指令
return {
"success": True,
"status": "in_progress",
"current_step": current_step,
"step_description": step_desc,
"completed_steps": state["completed_steps"],
"next_action": f"继续执行步骤: {step_desc}",
"platform": state.get("platform"),
"content_id": state.get("content_id"),
"content_type": state.get("content_type"),
"step_results": state.get("step_results", {})
}
def generate_summary(url: str, stages: List[Dict], final_file: str, archive_result: Dict = None) -> str:
"""生成处理完成汇报"""
# 提取视频标题(如果有)
title_match = re.search(r'com/video/([^?]+)', url)
title = title_match.group(1) if title_match else "未知"
summary = f"""
✅ **处理完成!**
**视频:** {title}
**处理阶段:**
"""
for stage in stages:
stage_name = stage.get('stage', 'unknown')
emoji = "✓" if stage.get('success') else "○"
summary += f"{emoji} {stage_name}\n"
if final_file:
summary += f"\n**最终文件:** `{final_file}`"
if archive_result:
if archive_result.get('git_commit'):
summary += f"\n**Git:** 已提交"
if archive_result.get('git_push', {}).get('success'):
summary += " ✓"
elif archive_result.get('git_push'):
summary += " ✗"
return summary
# ==================== CLI ====================
def main():
"""CLI入口 - 用于命令行测试"""
parser = argparse.ArgumentParser(
description='KB Workflow - Knowledge Base Workflow Orchestrator',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
使用示例:
# 查重
python kb_workflow.py --check --url "https://www.bilibili.com/video/BVxxx"
# 归档
python kb_workflow.py --archive-file "/path/to/analyzed.md"
# 状态管理
python kb_workflow.py --init-state --url "https://..."
python kb_workflow.py --get-state --url "https://..."
python kb_workflow.py --advance-step --url "https://..." --step "metadata" --result '{"success":true}'
python kb_workflow.py --record-error --url "https://..." --error-msg "403 Forbidden"
python kb_workflow.py --resume --url "https://..."
"""
)
# 基础参数
parser.add_argument('--url', type=str, help='URL 地址')
parser.add_argument('--base-path', type=str, help='知识库根目录')
# 查重和归档
parser.add_argument('--check', action='store_true', help='查重')
parser.add_argument('--archive-file', type=str, help='归档的文件路径')
# 目录路径查询
parser.add_argument('--pending', action='store_true', help='获取 URL 解析目录')
parser.add_argument('--metadata', action='store_true', help='获取元数据目录')
parser.add_argument('--content', action='store_true', help='获取内容目录')
parser.add_argument('--content_downloads', action='store_true', help='获取内容下载目录')
parser.add_argument('--analyzed', action='store_true', help='获取深度分析目录')
parser.add_argument('--archive_dir', action='store_true', help='获取归档目录')
parser.add_argument('--archive_subscriptions', action='store_true', help='获取订阅信息目录')
parser.add_argument('--extract_platform', type=str, help='从文件名提取平台名称')
# 状态管理
parser.add_argument('--init-state', action='store_true', help='初始化工作流状态')
parser.add_argument('--get-state', action='store_true', help='获取当前状态')
parser.add_argument('--advance-step', action='store_true', help='推进到下一步')
parser.add_argument('--step', type=str, help='步骤名称')
parser.add_argument('--result', type=str, help='步骤结果(JSON 字符串)')
parser.add_argument('--record-error', action='store_true', help='记录错误')
parser.add_argument('--error-msg', type=str, help='错误信息')
parser.add_argument('--resume', action='store_true', help='获取恢复执行信息')
args = parser.parse_args()
base_path = Path(args.base_path) if args.base_path else None
dirs = ensure_directories(base_path)
# 查重
if args.check and args.url:
result = check_duplicate(base_path, args.url)
print(json.dumps(result, ensure_ascii=False, indent=2))
# 归档
elif args.archive_file:
result = archive_and_git(args.archive_file, base_path)
print(json.dumps(result, ensure_ascii=False, indent=2))
# 目录路径查询
elif args.pending:
print(json.dumps({"path": str(dirs["pending"])}, ensure_ascii=False, indent=2))
elif args.metadata:
print(json.dumps({"path": str(dirs["metadata"])}, ensure_ascii=False, indent=2))
elif args.content:
print(json.dumps({"path": str(dirs["content"])}, ensure_ascii=False, indent=2))
elif args.content_downloads:
print(json.dumps({"path": str(dirs["content_downloads"])}, ensure_ascii=False, indent=2))
elif args.analyzed:
print(json.dumps({"path": str(dirs["analyzed"])}, ensure_ascii=False, indent=2))
elif args.archive_dir:
print(json.dumps({"path": str(get_archive_path())}, ensure_ascii=False, indent=2))
elif args.archive_subscriptions:
print(json.dumps({"path": str(dirs["archive_subscriptions"])}, ensure_ascii=False, indent=2))
elif args.extract_platform:
platform = extract_platform(args.extract_platform)
print(json.dumps({"platform": platform}, ensure_ascii=False, indent=2))
elif args.base_path:
print(json.dumps({"path": str(dirs["base"])}, ensure_ascii=False, indent=2))
# 状态管理
elif args.init_state and args.url:
result = init_state(args.url, base_path)
print(json.dumps({"success": True, "state": result}, ensure_ascii=False, indent=2))
elif args.get_state and args.url:
result = get_state(args.url, base_path)
print(json.dumps(result, ensure_ascii=False, indent=2))
elif args.advance_step and args.url and args.step:
result_data = None
if args.result:
try:
result_data = json.loads(args.result)
except json.JSONDecodeError:
result_data = {"raw": args.result}
result = advance_step(args.url, args.step, result_data, base_path)
print(json.dumps(result, ensure_ascii=False, indent=2))
elif args.record_error and args.url and args.error_msg:
result = record_error(args.url, args.error_msg, args.step, base_path)
print(json.dumps(result, ensure_ascii=False, indent=2))
elif args.resume and args.url:
result = get_resume_info(args.url, base_path)
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
parser.print_help()
if __name__ == '__main__':
main()
if __name__ == '__main__':
main()