#!/usr/bin/env python3 """kb-bridge.py —— 原始物料 → 知识页(桥接脚本) 核心功能: 1. 读取原始文件(PDF/CSV/markdown/图片) 2. 调用 Claude API 分析内容 3. 判断 type(doc/decision/conversation) 4. 生成完整 frontmatter(符合 kb-contract.yaml) 5. 保存到 projects/<项目>/docs|decisions|conversations/ 6. 调用 kb-lint-fm.py 校验 用法: # 处理单个文件 python3 tools/kb-bridge.py --input 文件路径 [--project 项目代号] # 批量处理目录 python3 tools/kb-bridge.py --input-dir 目录路径 # 示例 python3 tools/kb-bridge.py --input ../飞书同步/AI工作流.csv --project ai-workflow python3 tools/kb-bridge.py --input ../会议记录/0713会议.pdf 依赖: pip3 install anthropic pyyaml pdfplumber pandas 环境变量: ANTHROPIC_API_KEY - Claude API Key(必需) """ import os import sys import json import hashlib import argparse import subprocess from pathlib import Path from datetime import date try: import yaml except ImportError: print("ERROR: 需要 PyYAML。运行:pip3 install pyyaml", file=sys.stderr) sys.exit(1) try: import anthropic except ImportError: print("ERROR: 需要 anthropic。运行:pip3 install anthropic", file=sys.stderr) sys.exit(1) # 定位库根 ROOT = Path(__file__).parent.parent.resolve() # ========== 文件提取 ========== def extract_content(file_path): """提取文件内容(支持 PDF/CSV/markdown/txt)""" path = Path(file_path) ext = path.suffix.lower() if ext == '.pdf': try: import pdfplumber except ImportError: print("WARN: PDF 提取需要 pdfplumber。运行:pip3 install pdfplumber", file=sys.stderr) return f"[PDF文件:{path.name},无法提取文本]" try: with pdfplumber.open(file_path) as pdf: text = "\n\n".join(page.extract_text() or "" for page in pdf.pages) return text.strip() or "[PDF无文本内容]" except Exception as e: return f"[PDF提取失败:{e}]" elif ext == '.csv': try: import pandas as pd except ImportError: print("WARN: CSV 处理需要 pandas。运行:pip3 install pandas", file=sys.stderr) return f"[CSV文件:{path.name},无法解析]" try: df = pd.read_csv(file_path) # 限制行数,避免太长 if len(df) > 100: preview = df.head(50).to_string() return f"{preview}\n\n... (共{len(df)}行,仅显示前50行)" return df.to_string() except Exception as e: return f"[CSV解析失败:{e}]" elif ext in ['.md', '.txt']: try: return path.read_text(encoding='utf-8') except Exception as e: return f"[文件读取失败:{e}]" else: return f"[不支持的文件类型:{ext}]" # ========== Claude API 分析 ========== def analyze_with_claude(content, file_path): """调用 Claude API 分析文件内容""" api_key = os.environ.get("ANTHROPIC_API_KEY") if not api_key: print("ERROR: 需要设置 ANTHROPIC_API_KEY 环境变量", file=sys.stderr) sys.exit(1) client = anthropic.Anthropic(api_key=api_key) # 限制内容长度(避免超token) content_preview = content[:8000] if len(content) > 8000 else content prompt = f"""分析这个文件,返回 JSON 格式的分析结果。 文件路径:{file_path} 内容(前8000字符): {content_preview} 请判断: 1. **type**(必须是以下之一): - "doc":外部文档、参考资料、飞书文档 - "decision":决策记录、方案选择、重要决定 - "conversation":会议纪要、聊天记录、讨论总结 2. **title**:一句话标题(中文,20字内) 3. **description**:一句话描述(中文,50字内,说清楚这是什么内容) 4. **tags**:3-5个关键词(中文,用于分类和检索) 5. **key_points**:提炼3-5条核心要点(markdown格式,每条1-2句话) 6. **project**:建议的项目代号(英文/拼音,kebab-case,如 ai-workflow、meeting-2024q3) - 如果是会议记录 → meeting-YYYY-qN - 如果是某个主题的文档 → 主题-拼音 - 如果无法判断 → misc 返回格式(纯JSON,不要markdown代码块): {{ "type": "doc", "title": "...", "description": "...", "tags": ["...", "...", "..."], "key_points": "- 要点1\\n- 要点2\\n- 要点3", "project": "..." }} """ try: response = client.messages.create( model="claude-sonnet-3-5-20240620", max_tokens=2000, messages=[{"role": "user", "content": prompt}] ) result_text = response.content[0].text.strip() # 移除可能的 markdown 代码块标记 if result_text.startswith("```"): lines = result_text.split("\n") result_text = "\n".join(lines[1:-1]) return json.loads(result_text) except Exception as e: print(f"ERROR: Claude API 调用失败:{e}", file=sys.stderr) # 返回默认值 return { "type": "doc", "title": Path(file_path).stem, "description": "自动导入的文档", "tags": ["待分类"], "key_points": "[AI分析失败,需要手动补充]", "project": "misc" } # ========== 生成 frontmatter ========== def generate_frontmatter(analysis, source_file): """生成完整 frontmatter(符合 kb-contract.yaml)""" # 判断 source(来源) source_path = Path(source_file).resolve() if "飞书同步" in str(source_path): source = "飞书文档" elif "会议记录" in str(source_path): source = "会议" else: source = "NAS" fm = { "type": analysis["type"], "title": analysis["title"], "description": analysis["description"], "tags": analysis["tags"], "timestamp": str(date.today()), "source": source, "source_link": source_file if source_file.startswith("http") else f"file://{source_path}", "status": "seed", "created": str(date.today()), "ingested": str(date.today()), } return "---\n" + yaml.dump(fm, allow_unicode=True, default_flow_style=False) + "---\n" # ========== 保存知识页 ========== def save_to_kb(content, analysis, source_file, project_override=None): """保存到知识库 projects/""" project = project_override or analysis["project"] # type → 子目录映射 type_dir = { "doc": "docs", "decision": "decisions", "conversation": "conversations" }.get(analysis["type"], "docs") # 项目目录 project_dir = ROOT / "projects" / project # 如果项目不存在,用 kb-init.sh 创建 if not project_dir.exists(): print(f"[创建项目] {project}") result = subprocess.run( ["bash", str(ROOT / "tools" / "kb-init.sh"), project], cwd=ROOT, capture_output=True, text=True ) if result.returncode != 0: print(f"ERROR: kb-init.sh 失败:{result.stderr}", file=sys.stderr) return None # 生成文件名(slug化) def slugify(text): import re # 移除特殊字符,保留中文、字母、数字 text = re.sub(r'[^\w\s一-鿿-]', '', text) # 空格替换为 - text = re.sub(r'[\s]+', '-', text) return text.strip('-')[:50] # 限制长度 filename = slugify(analysis["title"]) + ".md" output_path = project_dir / type_dir / filename # 生成完整内容 frontmatter = generate_frontmatter(analysis, source_file) full_content = f"{frontmatter}\n{analysis['key_points']}\n\n## 原始文件\n\n文件:`{Path(source_file).name}`\n\n" # 保存 output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text(full_content, encoding='utf-8') return output_path # ========== 主函数 ========== def process_file(input_path, project=None): """处理单个文件""" print(f"\n===== 处理文件:{input_path} =====") # 1. 提取内容 print("[1/5] 提取文件内容...") content = extract_content(input_path) if not content or len(content) < 10: print(f"WARN: 文件内容为空或太短,跳过") return None # 2. Claude 分析 print("[2/5] Claude API 分析...") analysis = analyze_with_claude(content, input_path) print(f" - type: {analysis['type']}") print(f" - title: {analysis['title']}") print(f" - project: {analysis['project']}") # 3. 保存到知识库 print("[3/5] 保存到知识库...") output_path = save_to_kb(content, analysis, input_path, project) if not output_path: return None print(f" ✓ 已保存:{output_path.relative_to(ROOT)}") # 4. Lint 校验 print("[4/5] frontmatter 校验...") result = subprocess.run( ["python3", str(ROOT / "tools" / "kb-lint-fm.py"), str(output_path)], cwd=ROOT, capture_output=True, text=True ) if result.returncode == 0: print(" ✓ 校验通过") else: print(f" ✗ 校验失败:\n{result.stdout}") return None # 5. 提示 Git 提交 print("[5/5] 提示:") print(f" git add {output_path.relative_to(ROOT)}") print(f" git commit -m 'Add {analysis['title']}'") print(f" git push") return output_path def main(): parser = argparse.ArgumentParser(description="原始物料 → 知识页") parser.add_argument("--input", help="输入文件路径") parser.add_argument("--input-dir", help="输入目录路径(批量处理)") parser.add_argument("--project", help="指定项目代号(可选,否则 AI 自动判断)") args = parser.parse_args() if not args.input and not args.input_dir: parser.print_help() sys.exit(1) # 单文件处理 if args.input: process_file(args.input, args.project) # 批量处理 elif args.input_dir: input_dir = Path(args.input_dir) files = list(input_dir.glob("*")) print(f"发现 {len(files)} 个文件") for i, file_path in enumerate(files, 1): if file_path.is_file(): print(f"\n[{i}/{len(files)}] {file_path.name}") process_file(str(file_path), args.project) if __name__ == "__main__": main()