2026-04-21 12:50:17 +00:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
"""Interactive installer for the Literature Workflow."""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
|
import sys
|
|
|
|
|
|
import shutil
|
|
|
|
|
|
import subprocess
|
|
|
|
|
|
import platform
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from typing import Optional
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Colors:
|
|
|
|
|
|
HEADER = '\033[95m'
|
|
|
|
|
|
OKBLUE = '\033[94m'
|
|
|
|
|
|
OKCYAN = '\033[96m'
|
|
|
|
|
|
OKGREEN = '\033[92m'
|
|
|
|
|
|
WARNING = '\033[93m'
|
|
|
|
|
|
FAIL = '\033[91m'
|
|
|
|
|
|
ENDC = '\033[0m'
|
|
|
|
|
|
BOLD = '\033[1m'
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 13:12:12 +00:00
|
|
|
|
# Agent platform configurations
|
|
|
|
|
|
AGENT_CONFIGS = {
|
|
|
|
|
|
"opencode": {
|
|
|
|
|
|
"name": "OpenCode",
|
|
|
|
|
|
"skill_dir": ".opencode/skills",
|
|
|
|
|
|
"config_file": None,
|
|
|
|
|
|
},
|
|
|
|
|
|
"claude": {
|
|
|
|
|
|
"name": "Claude Code",
|
|
|
|
|
|
"skill_dir": ".claude/skills",
|
|
|
|
|
|
"config_file": ".claude/skills.json",
|
|
|
|
|
|
},
|
|
|
|
|
|
"cursor": {
|
|
|
|
|
|
"name": "Cursor",
|
|
|
|
|
|
"skill_dir": ".cursor/skills",
|
|
|
|
|
|
"config_file": ".cursor/settings.json",
|
|
|
|
|
|
},
|
|
|
|
|
|
"windsurf": {
|
|
|
|
|
|
"name": "Windsurf",
|
|
|
|
|
|
"skill_dir": ".windsurf/skills",
|
|
|
|
|
|
"config_file": None,
|
|
|
|
|
|
},
|
|
|
|
|
|
"github_copilot": {
|
|
|
|
|
|
"name": "GitHub Copilot",
|
|
|
|
|
|
"skill_dir": ".github/skills",
|
|
|
|
|
|
"config_file": ".github/copilot-instructions.md",
|
|
|
|
|
|
},
|
|
|
|
|
|
"cline": {
|
|
|
|
|
|
"name": "Cline",
|
|
|
|
|
|
"skill_dir": ".clinerules/skills",
|
|
|
|
|
|
"config_file": ".clinerules",
|
|
|
|
|
|
},
|
|
|
|
|
|
"augment": {
|
|
|
|
|
|
"name": "Augment",
|
|
|
|
|
|
"skill_dir": ".augment/skills",
|
|
|
|
|
|
"config_file": None,
|
|
|
|
|
|
},
|
|
|
|
|
|
"trae": {
|
|
|
|
|
|
"name": "Trae",
|
|
|
|
|
|
"skill_dir": ".trae/skills",
|
|
|
|
|
|
"config_file": None,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def select_agent() -> tuple[str, dict]:
|
|
|
|
|
|
"""Ask user to select their AI agent platform."""
|
|
|
|
|
|
print_header("Step 0: Agent Platform Selection")
|
|
|
|
|
|
print("Which AI agent do you use with this vault?")
|
|
|
|
|
|
print("(This determines where skill files and configurations are placed)\n")
|
|
|
|
|
|
|
|
|
|
|
|
agents = list(AGENT_CONFIGS.items())
|
|
|
|
|
|
for i, (key, cfg) in enumerate(agents, 1):
|
|
|
|
|
|
print(f" {i}. {cfg['name']} ({key})")
|
|
|
|
|
|
print(f" {len(agents) + 1}. Other (custom)")
|
|
|
|
|
|
|
|
|
|
|
|
choice = ask("Select agent", default="1")
|
|
|
|
|
|
try:
|
|
|
|
|
|
idx = int(choice) - 1
|
|
|
|
|
|
if 0 <= idx < len(agents):
|
|
|
|
|
|
return agents[idx]
|
|
|
|
|
|
elif idx == len(agents):
|
|
|
|
|
|
# Custom agent
|
|
|
|
|
|
custom_name = ask("Enter agent name")
|
|
|
|
|
|
custom_dir = ask("Enter skill directory (relative to vault)", default=".custom/skills")
|
|
|
|
|
|
return "custom", {
|
|
|
|
|
|
"name": custom_name,
|
|
|
|
|
|
"skill_dir": custom_dir,
|
|
|
|
|
|
"config_file": None,
|
|
|
|
|
|
}
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
# Default to OpenCode
|
|
|
|
|
|
print_warning("Invalid choice, defaulting to OpenCode")
|
|
|
|
|
|
return "opencode", AGENT_CONFIGS["opencode"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def configure_vault_paths(vault_path: Path) -> dict:
|
2026-04-21 14:10:40 +00:00
|
|
|
|
"""Ask user for PaperForge-specific directory structure preferences."""
|
2026-04-21 13:12:12 +00:00
|
|
|
|
print_header("Step 0.5: Vault Directory Configuration")
|
2026-04-21 14:10:40 +00:00
|
|
|
|
print("Configure PaperForge directory structure (press Enter to accept defaults):\n")
|
|
|
|
|
|
print("Note: 00_Inbox, 04_Archives, 05_Bases, 06_AI_Wiki are your personal PARA folders.")
|
|
|
|
|
|
print(" PaperForge will NOT create them.\n")
|
2026-04-21 13:12:12 +00:00
|
|
|
|
|
|
|
|
|
|
paths = {
|
|
|
|
|
|
"system_dir": ask("System folder name", default="99_System"),
|
|
|
|
|
|
"resources_dir": ask("Resources folder name", default="03_Resources"),
|
2026-04-21 14:10:40 +00:00
|
|
|
|
"literature_dir": ask("Literature subfolder (for generated notes)", default="Literature"),
|
2026-04-21 13:12:12 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# Build derived paths
|
2026-04-22 14:10:16 +00:00
|
|
|
|
paths["pipeline_path"] = f"{paths['system_dir']}/PaperForge"
|
2026-04-22 15:20:49 +00:00
|
|
|
|
paths["literature_path"] = f"{paths['resources_dir']}/{paths['literature_dir']}"
|
2026-04-21 14:10:40 +00:00
|
|
|
|
|
|
|
|
|
|
return paths
|
2026-04-21 13:12:12 +00:00
|
|
|
|
|
|
|
|
|
|
return paths
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 12:50:17 +00:00
|
|
|
|
def print_header(text: str) -> None:
|
|
|
|
|
|
print(f"\n{Colors.HEADER}{'='*60}{Colors.ENDC}")
|
|
|
|
|
|
print(f"{Colors.BOLD}{text}{Colors.ENDC}")
|
|
|
|
|
|
print(f"{Colors.HEADER}{'='*60}{Colors.ENDC}\n")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def print_success(text: str) -> None:
|
|
|
|
|
|
print(f"{Colors.OKGREEN}[OK]{Colors.ENDC} {text}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def print_warning(text: str) -> None:
|
|
|
|
|
|
print(f"{Colors.WARNING}[WARN]{Colors.ENDC} {text}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def print_error(text: str) -> None:
|
|
|
|
|
|
print(f"{Colors.FAIL}[ERROR]{Colors.ENDC} {text}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def ask(question: str, default: Optional[str] = None) -> str:
|
|
|
|
|
|
"""Ask user a question with optional default."""
|
|
|
|
|
|
if default:
|
|
|
|
|
|
prompt = f"{question} [{default}]: "
|
|
|
|
|
|
else:
|
|
|
|
|
|
prompt = f"{question}: "
|
|
|
|
|
|
|
|
|
|
|
|
answer = input(prompt).strip()
|
|
|
|
|
|
if not answer and default:
|
|
|
|
|
|
return default
|
|
|
|
|
|
return answer
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def ask_yes_no(question: str, default: bool = False) -> bool:
|
|
|
|
|
|
"""Ask a yes/no question."""
|
|
|
|
|
|
suffix = " [Y/n]: " if default else " [y/N]: "
|
|
|
|
|
|
answer = input(f"{question}{suffix}").strip().lower()
|
|
|
|
|
|
if not answer:
|
|
|
|
|
|
return default
|
|
|
|
|
|
return answer in ('y', 'yes', 'true', '1')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def detect_zotero_path() -> Optional[Path]:
|
|
|
|
|
|
"""Auto-detect Zotero data directory."""
|
|
|
|
|
|
system = platform.system()
|
|
|
|
|
|
|
|
|
|
|
|
if system == "Windows":
|
|
|
|
|
|
# Check common locations
|
|
|
|
|
|
home = Path.home()
|
|
|
|
|
|
candidates = [
|
|
|
|
|
|
home / "Zotero",
|
|
|
|
|
|
home / "AppData" / "Roaming" / "Zotero" / "Zotero",
|
|
|
|
|
|
Path("C:/Users") / os.environ.get("USERNAME", "") / "Zotero",
|
|
|
|
|
|
]
|
|
|
|
|
|
elif system == "Darwin": # macOS
|
|
|
|
|
|
home = Path.home()
|
|
|
|
|
|
candidates = [
|
|
|
|
|
|
home / "Zotero",
|
|
|
|
|
|
home / "Library" / "Application Support" / "Zotero",
|
|
|
|
|
|
]
|
|
|
|
|
|
else: # Linux
|
|
|
|
|
|
home = Path.home()
|
|
|
|
|
|
candidates = [
|
|
|
|
|
|
home / "Zotero",
|
|
|
|
|
|
home / ".zotero",
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
for candidate in candidates:
|
|
|
|
|
|
if candidate.exists() and (candidate / "zotero.sqlite").exists():
|
|
|
|
|
|
return candidate
|
|
|
|
|
|
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def create_junction(source: Path, target: Path) -> bool:
|
|
|
|
|
|
"""Create junction/symlink from source to target."""
|
|
|
|
|
|
system = platform.system()
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
if system == "Windows":
|
|
|
|
|
|
subprocess.run(
|
|
|
|
|
|
["cmd", "/c", "mklink", "/J", str(source), str(target)],
|
|
|
|
|
|
check=True,
|
|
|
|
|
|
capture_output=True,
|
|
|
|
|
|
shell=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
source.symlink_to(target, target_is_directory=True)
|
|
|
|
|
|
return True
|
|
|
|
|
|
except (subprocess.CalledProcessError, OSError) as e:
|
|
|
|
|
|
print_error(f"Failed to create junction: {e}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def check_python_deps() -> list[str]:
|
|
|
|
|
|
"""Check if required Python packages are installed."""
|
|
|
|
|
|
required = ["requests", "pymupdf", "PIL", "pytest"]
|
|
|
|
|
|
missing = []
|
|
|
|
|
|
|
|
|
|
|
|
for package in required:
|
|
|
|
|
|
try:
|
|
|
|
|
|
__import__(package.lower().replace("pil", "PIL"))
|
|
|
|
|
|
except ImportError:
|
|
|
|
|
|
missing.append(package)
|
|
|
|
|
|
|
|
|
|
|
|
return missing
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def install_deps(deps: list[str]) -> bool:
|
|
|
|
|
|
"""Install missing Python dependencies."""
|
|
|
|
|
|
if not deps:
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
print(f"Installing dependencies: {', '.join(deps)}")
|
|
|
|
|
|
try:
|
|
|
|
|
|
subprocess.run(
|
|
|
|
|
|
[sys.executable, "-m", "pip", "install"] + deps,
|
|
|
|
|
|
check=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
return True
|
|
|
|
|
|
except subprocess.CalledProcessError as e:
|
|
|
|
|
|
print_error(f"Failed to install dependencies: {e}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 13:12:12 +00:00
|
|
|
|
def create_directory_structure(vault_path: Path, paths: dict) -> None:
|
2026-04-21 14:10:40 +00:00
|
|
|
|
"""Create PaperForge directory structure with configurable paths."""
|
2026-04-21 12:50:17 +00:00
|
|
|
|
dirs = [
|
2026-04-21 14:10:40 +00:00
|
|
|
|
# Core pipeline directories
|
2026-04-21 13:12:12 +00:00
|
|
|
|
f"{paths['pipeline_path']}/ocr",
|
|
|
|
|
|
f"{paths['pipeline_path']}/worker/scripts",
|
2026-04-21 14:10:40 +00:00
|
|
|
|
f"{paths['pipeline_path']}/worker/tests",
|
|
|
|
|
|
f"{paths['pipeline_path']}/indexes",
|
|
|
|
|
|
f"{paths['pipeline_path']}/exports",
|
|
|
|
|
|
|
|
|
|
|
|
# Zotero junction point
|
|
|
|
|
|
f"{paths['system_dir']}/Zotero",
|
|
|
|
|
|
|
2026-04-21 14:44:09 +00:00
|
|
|
|
# Library records (for selection_sync state)
|
2026-04-21 14:10:40 +00:00
|
|
|
|
f"03_Resources/LiteratureControl/library-records",
|
2026-04-21 12:50:17 +00:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
for d in dirs:
|
|
|
|
|
|
(vault_path / d).mkdir(parents=True, exist_ok=True)
|
2026-04-21 14:44:09 +00:00
|
|
|
|
print_success(f"Created: {d}")
|
2026-04-21 12:50:17 +00:00
|
|
|
|
|
2026-04-21 13:12:12 +00:00
|
|
|
|
print_success(f"Directory structure created ({len(dirs)} folders)")
|
2026-04-21 12:50:17 +00:00
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 14:10:40 +00:00
|
|
|
|
def create_env_file(vault_path: Path, config: dict, paths: dict) -> None:
|
2026-04-21 12:50:17 +00:00
|
|
|
|
"""Create .env configuration file."""
|
|
|
|
|
|
env_path = vault_path / ".env"
|
|
|
|
|
|
|
|
|
|
|
|
lines = [
|
2026-04-21 14:10:40 +00:00
|
|
|
|
"# PaperForge Configuration",
|
2026-04-21 12:50:17 +00:00
|
|
|
|
f"ZOTERO_DATA_DIR={config['zotero_path']}",
|
|
|
|
|
|
f"ZOTERO_STORAGE_DIR={config.get('storage_path', config['zotero_path'])}",
|
2026-04-22 16:57:48 +00:00
|
|
|
|
f"PADDLEOCR_API_TOKEN={config['ocr_api_key']}",
|
|
|
|
|
|
"PADDLEOCR_JOB_URL=https://paddleocr.aistudio-app.com/api/v2/ocr/jobs",
|
2026-04-21 12:50:17 +00:00
|
|
|
|
"",
|
2026-04-21 14:10:40 +00:00
|
|
|
|
"# Agent Configuration",
|
|
|
|
|
|
f"PAPERFORGE_AGENT={config['agent']}",
|
|
|
|
|
|
f"PAPERFORGE_AGENT_NAME={config['agent_name']}",
|
|
|
|
|
|
f"PAPERFORGE_SKILL_DIR={config['skill_dir']}",
|
|
|
|
|
|
"",
|
|
|
|
|
|
"# Path Configuration",
|
|
|
|
|
|
f"PAPERFORGE_SYSTEM_DIR={paths['system_dir']}",
|
|
|
|
|
|
f"PAPERFORGE_PIPELINE_PATH={paths['pipeline_path']}",
|
2026-04-22 15:20:49 +00:00
|
|
|
|
f"PAPERFORGE_RESOURCES_PATH={paths['literature_path']}",
|
2026-04-21 14:10:40 +00:00
|
|
|
|
f"PAPERFORGE_VAULT_PATH={vault_path}",
|
2026-04-21 12:50:17 +00:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
env_path.write_text("\n".join(lines), encoding="utf-8")
|
|
|
|
|
|
print_success(f"Configuration saved to {env_path}")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 13:12:12 +00:00
|
|
|
|
def create_agents_md(vault_path: Path, config: dict, paths: dict, agent_config: dict) -> None:
|
2026-04-22 09:39:17 +00:00
|
|
|
|
"""Generate AGENTS.md with user-configured paths embedded.
|
|
|
|
|
|
|
|
|
|
|
|
This creates a complete Lite-version workflow guide that is personalized
|
|
|
|
|
|
to the user's chosen directory structure, not a static template.
|
|
|
|
|
|
"""
|
2026-04-21 12:50:17 +00:00
|
|
|
|
agents_path = vault_path / "AGENTS.md"
|
|
|
|
|
|
|
|
|
|
|
|
if agents_path.exists():
|
|
|
|
|
|
if not ask_yes_no("AGENTS.md already exists. Overwrite?", default=False):
|
|
|
|
|
|
print_warning("Skipping AGENTS.md creation")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-04-22 09:39:17 +00:00
|
|
|
|
# Build the personalized AGENTS.md content
|
|
|
|
|
|
# All paths come from the user's configuration (paths dict)
|
|
|
|
|
|
content = f"""# PaperForge Lite - Agent Guide
|
|
|
|
|
|
|
|
|
|
|
|
> 本文档面向 **安装完成后的新用户** 和 **AI Agent**。
|
|
|
|
|
|
> 安装步骤见 [INSTALLATION.md](docs/INSTALLATION.md)。
|
|
|
|
|
|
> 本 Vault 使用自定义目录结构生成,路径以实际配置为准。
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 0. 安装后检查清单(第一次使用前必做)
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
[ ] Zotero 已安装 + Better BibTeX 插件已启用
|
|
|
|
|
|
[ ] Better BibTeX 已配置自动导出 JSON(见下方配置)
|
|
|
|
|
|
[ ] Obsidian 已打开当前 Vault
|
|
|
|
|
|
[ ] Python 依赖已安装 (pip install requests pymupdf pillow)
|
|
|
|
|
|
[ ] PaddleOCR API Key 已配置(在 .env 中)
|
|
|
|
|
|
[ ] 目录结构已创建(setup.py 会自动完成)
|
|
|
|
|
|
[ ] Zotero 数据目录已链接到 {paths['system_dir']}/Zotero
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### Better BibTeX 自动导出配置
|
|
|
|
|
|
|
|
|
|
|
|
1. Zotero → Edit → Preferences → Better BibTeX
|
|
|
|
|
|
2. 勾选 **"Keep updated"**(自动导出)
|
|
|
|
|
|
3. 选择导出格式:**Better BibLaTeX** 或 **Better BibTeX**
|
|
|
|
|
|
4. 导出路径设置为:`{vault_path}/{paths['pipeline_path']}/exports/library.json`
|
|
|
|
|
|
5. 点击 OK,JSON 文件会自动生成并保持同步
|
|
|
|
|
|
|
|
|
|
|
|
---
|
2026-04-21 12:50:17 +00:00
|
|
|
|
|
2026-04-22 09:39:17 +00:00
|
|
|
|
## 1. 核心架构(Lite 版)
|
2026-04-21 12:50:17 +00:00
|
|
|
|
|
2026-04-22 09:39:17 +00:00
|
|
|
|
PaperForge Lite 采用 **两层设计**:
|
2026-04-21 12:50:17 +00:00
|
|
|
|
|
2026-04-22 09:39:17 +00:00
|
|
|
|
| 层级 | 组件 | 触发方式 | 作用 |
|
|
|
|
|
|
|------|------|----------|------|
|
|
|
|
|
|
| **Worker 层** | `literature_pipeline.py`(4 个 workers) | Python CLI | 后台自动化 |
|
|
|
|
|
|
| **Agent 层** | `/LD-deep`, `/LD-paper` 命令 | 用户手动触发 | 交互式精读 |
|
2026-04-21 12:50:17 +00:00
|
|
|
|
|
2026-04-22 09:39:17 +00:00
|
|
|
|
**关键区别**:
|
|
|
|
|
|
- **Worker 只做机械劳动**(检测新文献、生成笔记、OCR)
|
|
|
|
|
|
- **Agent 只做深度思考**(精读、分析、写作)
|
|
|
|
|
|
- Worker 不会自动触发 Agent,Agent 不会自动触发 Worker
|
2026-04-21 14:10:40 +00:00
|
|
|
|
|
2026-04-22 09:39:17 +00:00
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 2. 完整数据流
|
2026-04-21 12:50:17 +00:00
|
|
|
|
|
2026-04-21 13:12:12 +00:00
|
|
|
|
```
|
2026-04-22 09:39:17 +00:00
|
|
|
|
Zotero 添加文献
|
|
|
|
|
|
↓ Better BibTeX 自动导出 JSON
|
|
|
|
|
|
{paths['pipeline_path']}/exports/library.json
|
|
|
|
|
|
↓ 运行 selection-sync
|
|
|
|
|
|
{paths['resources_dir']}/LiteratureControl/library-records/<domain>/<key>.md
|
|
|
|
|
|
↓ 运行 index-refresh
|
|
|
|
|
|
{paths['literature_path']}/<domain>/<key> - <Title>.md(正式笔记)
|
|
|
|
|
|
↓ 用户在 library-record 中设置 do_ocr: true
|
|
|
|
|
|
运行 ocr → {paths['pipeline_path']}/ocr/<key>/
|
|
|
|
|
|
↓ 用户在 library-record 中设置 analyze: true
|
|
|
|
|
|
运行 deep-reading(查看队列,确认就绪)
|
|
|
|
|
|
↓ 用户执行 Agent 命令
|
|
|
|
|
|
/LD-deep <zotero_key>
|
|
|
|
|
|
↓ Agent 生成
|
|
|
|
|
|
正式笔记中新增 ## 🔍 精读 区域
|
2026-04-21 13:12:12 +00:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-04-22 09:39:17 +00:00
|
|
|
|
---
|
2026-04-21 14:10:40 +00:00
|
|
|
|
|
2026-04-22 09:39:17 +00:00
|
|
|
|
## 3. 目录结构(本 Vault 配置)
|
2026-04-21 12:50:17 +00:00
|
|
|
|
|
2026-04-22 09:39:17 +00:00
|
|
|
|
```
|
|
|
|
|
|
{vault_path}/
|
|
|
|
|
|
├── {paths['resources_dir']}/
|
|
|
|
|
|
│ ├── {paths['literature_dir']}/ ← 正式文献笔记(index-refresh 生成)
|
|
|
|
|
|
│ │ ├── 骨科/
|
|
|
|
|
|
│ │ ├── 运动医学/
|
|
|
|
|
|
│ │ └── ...(你的分类)
|
|
|
|
|
|
│ └── LiteratureControl/ ← 状态跟踪
|
|
|
|
|
|
│ └── library-records/ ← selection-sync 输出
|
|
|
|
|
|
│ ├── 骨科/
|
|
|
|
|
|
│ │ └── ABCDEFG.md ← 单条文献状态记录
|
|
|
|
|
|
│ └── 运动医学/
|
|
|
|
|
|
│ └── HIJKLMN.md
|
|
|
|
|
|
│
|
|
|
|
|
|
├── {paths['system_dir']}/
|
2026-04-22 14:10:16 +00:00
|
|
|
|
│ ├── PaperForge/
|
2026-04-22 09:39:17 +00:00
|
|
|
|
│ │ ├── exports/ ← Better BibTeX 自动导出的 JSON
|
|
|
|
|
|
│ │ │ └── library.json
|
|
|
|
|
|
│ │ ├── ocr/ ← OCR 结果(每个文献一个子目录)
|
|
|
|
|
|
│ │ │ └── ABCDEFG/ ← Zotero key 作为目录名
|
|
|
|
|
|
│ │ │ ├── fulltext.md ← OCR 提取的全文
|
|
|
|
|
|
│ │ │ ├── images/ ← 图表切割图片
|
|
|
|
|
|
│ │ │ ├── meta.json ← OCR 元数据(含 ocr_status)
|
|
|
|
|
|
│ │ │ └── figure-map.json ← 图表索引(自动创建)
|
|
|
|
|
|
│ │ └── worker/scripts/
|
|
|
|
|
|
│ │ └── literature_pipeline.py ← 核心脚本
|
|
|
|
|
|
│ └── Zotero/ ← Junction/Symlink 到 Zotero 数据目录
|
|
|
|
|
|
│
|
|
|
|
|
|
├── {agent_config['skill_dir']}/ ← {agent_config['name']} Skill 目录
|
|
|
|
|
|
│ └── literature-qa/ ← 深度阅读 Skill
|
|
|
|
|
|
│ ├── scripts/
|
|
|
|
|
|
│ │ └── ld_deep.py ← /LD-deep 核心脚本
|
|
|
|
|
|
│ ├── prompt_deep_subagent.md ← Agent 精读提示词
|
|
|
|
|
|
│ └── chart-reading/ ← 14 种图表阅读指南
|
|
|
|
|
|
│
|
|
|
|
|
|
├── .env ← API Key 等敏感配置
|
|
|
|
|
|
└── AGENTS.md ← 本文件
|
|
|
|
|
|
```
|
2026-04-21 14:10:40 +00:00
|
|
|
|
|
2026-04-22 09:39:17 +00:00
|
|
|
|
### 各目录作用速查
|
2026-04-21 14:10:40 +00:00
|
|
|
|
|
2026-04-22 09:39:17 +00:00
|
|
|
|
| 目录 | 内容 | 谁生成/修改 |
|
|
|
|
|
|
|------|------|------------|
|
|
|
|
|
|
| `{paths['literature_path']}/` | 正式文献笔记(含 frontmatter + 精读内容) | index-refresh 生成,Agent 写入精读 |
|
|
|
|
|
|
| `{paths['resources_dir']}/LiteratureControl/library-records/` | 文献状态跟踪(analyze, ocr_status 等) | selection-sync 生成,用户修改状态 |
|
|
|
|
|
|
| `{paths['pipeline_path']}/exports/` | Better BibTeX JSON 导出 | Zotero 自动导出 |
|
|
|
|
|
|
| `{paths['pipeline_path']}/ocr/` | OCR 全文 + 图表切割 | ocr worker 生成 |
|
|
|
|
|
|
| `{paths['system_dir']}/Zotero/` | Zotero 数据目录的链接 | 安装时手动创建 junction |
|
2026-04-21 14:10:40 +00:00
|
|
|
|
|
2026-04-22 09:39:17 +00:00
|
|
|
|
---
|
2026-04-21 12:50:17 +00:00
|
|
|
|
|
2026-04-22 09:39:17 +00:00
|
|
|
|
## 4. 核心 Workers(Lite 版,4 个)
|
|
|
|
|
|
|
|
|
|
|
|
### selection-sync
|
|
|
|
|
|
- **作用**:检测 Zotero 中的新条目,创建 library-records
|
|
|
|
|
|
- **运行时机**:添加新文献到 Zotero 后
|
|
|
|
|
|
- **输出**:`{paths['resources_dir']}/LiteratureControl/library-records/<domain>/<key>.md`
|
|
|
|
|
|
- **示例**:
|
|
|
|
|
|
```bash
|
|
|
|
|
|
python {paths['pipeline_path']}/worker/scripts/literature_pipeline.py \\
|
|
|
|
|
|
--vault "{vault_path}" selection-sync
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### index-refresh
|
|
|
|
|
|
- **作用**:基于 library-records 生成正式文献笔记
|
|
|
|
|
|
- **运行时机**:selection-sync 之后,或需要更新笔记格式时
|
|
|
|
|
|
- **输出**:`{paths['literature_path']}/<domain>/<key> - <Title>.md`
|
|
|
|
|
|
- **说明**:会读取 Better BibTeX JSON 提取元数据,生成带 frontmatter 的 Obsidian 笔记
|
|
|
|
|
|
|
|
|
|
|
|
### ocr
|
|
|
|
|
|
- **作用**:将 PDF 上传到 PaddleOCR API,提取全文文本和图表
|
|
|
|
|
|
- **触发条件**:library-record 中 `do_ocr: true`
|
|
|
|
|
|
- **输出**:`{paths['pipeline_path']}/ocr/<key>/` 目录
|
|
|
|
|
|
- `fulltext.md`:提取的全文(含 `<!-- page N -->` 分页标记)
|
|
|
|
|
|
- `images/`:自动切割的图表图片
|
|
|
|
|
|
- `meta.json`:OCR 状态(`ocr_status: done/pending/processing/failed`)
|
|
|
|
|
|
- `figure-map.json`:图表索引(后续自动生成)
|
|
|
|
|
|
- **注意**:OCR 是异步的,大文件可能需要几分钟
|
|
|
|
|
|
|
|
|
|
|
|
### deep-reading
|
|
|
|
|
|
- **作用**:扫描所有 library-records,列出 `analyze=true` 且 OCR 完成的文献
|
|
|
|
|
|
- **运行时机**:用户想看看哪些文献可以开始精读了
|
|
|
|
|
|
- **输出**:控制台表格,显示队列状态
|
|
|
|
|
|
- **重要**:这只是**查看队列**,不会自动触发 Agent 精读
|
2026-04-21 12:50:17 +00:00
|
|
|
|
|
2026-04-22 09:39:17 +00:00
|
|
|
|
---
|
2026-04-21 12:50:17 +00:00
|
|
|
|
|
2026-04-22 09:39:17 +00:00
|
|
|
|
## 5. Agent 命令(用户手动触发)
|
2026-04-21 12:50:17 +00:00
|
|
|
|
|
2026-04-22 09:39:17 +00:00
|
|
|
|
| 命令 | 用途 | 前置条件 |
|
|
|
|
|
|
|------|------|----------|
|
|
|
|
|
|
| `/LD-deep <zotero_key>` | 完整 Keshav 三阶段精读 | OCR 完成 (`ocr_status: done`) |
|
|
|
|
|
|
| `/LD-paper <zotero_key>` | 快速摘要(无 OCR 要求) | 有正式笔记即可 |
|
|
|
|
|
|
|
|
|
|
|
|
### /LD-deep 执行流程
|
|
|
|
|
|
|
|
|
|
|
|
1. **prepare 阶段**(自动):
|
|
|
|
|
|
- 查找 library-record(确认 `analyze: true`)
|
|
|
|
|
|
- 检查 OCR 状态
|
|
|
|
|
|
- 读取 formal note
|
|
|
|
|
|
- 生成 figure-map.json(图表索引)
|
|
|
|
|
|
- 生成 chart-type-map.json(图表类型识别)
|
|
|
|
|
|
- 在正式笔记中插入 `## 🔍 精读` 骨架
|
|
|
|
|
|
|
|
|
|
|
|
2. **精读阶段**(Agent 执行):
|
|
|
|
|
|
- Pass 1: 概览(5-10 分钟快速扫描)
|
|
|
|
|
|
- Pass 2: 精读还原(逐图逐表分析)
|
|
|
|
|
|
- Pass 3: 深度理解(批判性评估 + 迁移思考)
|
|
|
|
|
|
|
|
|
|
|
|
3. **验证阶段**(自动):
|
|
|
|
|
|
- 检查 callout 间距
|
|
|
|
|
|
- 检查必要 section 是否完整
|
|
|
|
|
|
- 检查 figure/table embed 是否存在
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 6. Frontmatter 字段参考
|
|
|
|
|
|
|
|
|
|
|
|
### Library Record(`{paths['resources_dir']}/LiteratureControl/library-records/<domain>/<key>.md`)
|
|
|
|
|
|
|
|
|
|
|
|
这是**用户控制工作流的核心**。每个文献对应一个 record 文件:
|
2026-04-21 12:50:17 +00:00
|
|
|
|
|
|
|
|
|
|
```yaml
|
|
|
|
|
|
---
|
2026-04-22 09:39:17 +00:00
|
|
|
|
zotero_key: "ABCDEFG" # Zotero citation key(自动生成)
|
|
|
|
|
|
domain: "骨科" # 分类领域(对应 Zotero 收藏夹)
|
|
|
|
|
|
title: "论文标题"
|
|
|
|
|
|
year: 2024
|
|
|
|
|
|
doi: "10.xxxx/xxxxx"
|
|
|
|
|
|
collection_path: "子分类" # Zotero 子收藏夹路径
|
|
|
|
|
|
has_pdf: true # 是否有 PDF 附件(自动生成)
|
|
|
|
|
|
pdf_path: "{paths['system_dir']}/Zotero/..." # PDF 相对路径(自动生成)
|
|
|
|
|
|
fulltext_md_path: "{paths['pipeline_path']}/ocr/..."
|
|
|
|
|
|
recommend_analyze: true # 系统推荐精读(有 PDF 时自动设为 true)
|
|
|
|
|
|
analyze: false # 【用户控制】是否生成精读?设为 true 触发
|
|
|
|
|
|
do_ocr: true # 【用户控制】是否运行 OCR?设为 true 触发
|
|
|
|
|
|
ocr_status: "done" # OCR 状态(pending/processing/done/failed)
|
|
|
|
|
|
deep_reading_status: "pending" # 精读状态(pending/done)
|
|
|
|
|
|
analysis_note: "" # 预留字段
|
|
|
|
|
|
---
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**用户操作方式**:
|
|
|
|
|
|
- 在 Obsidian 中打开 library-record 文件
|
|
|
|
|
|
- 修改 `analyze: false` → `analyze: true` 标记要精读的文献
|
|
|
|
|
|
- 修改 `do_ocr: false` → `do_ocr: true` 触发 OCR
|
|
|
|
|
|
- 或使用 Obsidian Base 视图批量操作
|
|
|
|
|
|
|
|
|
|
|
|
### Formal Note(`{paths['literature_path']}/<domain>/<key> - <Title>.md`)
|
|
|
|
|
|
|
|
|
|
|
|
这是最终产出的笔记,包含元数据 + 精读内容:
|
|
|
|
|
|
|
|
|
|
|
|
```yaml
|
|
|
|
|
|
---
|
|
|
|
|
|
title: "论文标题"
|
|
|
|
|
|
year: 2024
|
|
|
|
|
|
type: "journal"
|
|
|
|
|
|
journal: "Journal Name"
|
|
|
|
|
|
impact_factor: 5.2
|
|
|
|
|
|
category: "骨科"
|
2026-04-21 12:50:17 +00:00
|
|
|
|
tags:
|
|
|
|
|
|
- 文献阅读
|
2026-04-22 09:39:17 +00:00
|
|
|
|
- 子分类
|
|
|
|
|
|
keywords: ["keyword1", "keyword2"]
|
|
|
|
|
|
pdf_link: "{paths['system_dir']}/Zotero/..."
|
2026-04-21 12:50:17 +00:00
|
|
|
|
---
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-04-22 09:39:17 +00:00
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 7. 第一次使用指南(手把手)
|
2026-04-21 12:50:17 +00:00
|
|
|
|
|
2026-04-22 09:39:17 +00:00
|
|
|
|
### Step 1: 确认 Zotero 有文献
|
2026-04-21 12:50:17 +00:00
|
|
|
|
|
2026-04-22 09:39:17 +00:00
|
|
|
|
确保 Zotero 中已有至少一篇带 PDF 的文献,且 Better BibTeX 已导出 JSON。
|
|
|
|
|
|
|
|
|
|
|
|
### Step 2: 运行 selection-sync
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
# 在 Vault 根目录执行
|
|
|
|
|
|
python {paths['pipeline_path']}/worker/scripts/literature_pipeline.py \\
|
|
|
|
|
|
--vault "{vault_path}" selection-sync
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
预期输出:
|
|
|
|
|
|
```
|
|
|
|
|
|
[INFO] Found 5 new items
|
|
|
|
|
|
[INFO] Created library-records/骨科/XXXXXXX.md
|
|
|
|
|
|
...
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### Step 3: 运行 index-refresh
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
python {paths['pipeline_path']}/worker/scripts/literature_pipeline.py \\
|
|
|
|
|
|
--vault "{vault_path}" index-refresh
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
预期输出:
|
|
|
|
|
|
```
|
|
|
|
|
|
[INFO] Generated 5 formal notes
|
|
|
|
|
|
[INFO] Output: {paths['literature_path']}/骨科/XXXXXXX - Title.md
|
|
|
|
|
|
...
|
|
|
|
|
|
```
|
2026-04-21 12:50:17 +00:00
|
|
|
|
|
2026-04-22 09:39:17 +00:00
|
|
|
|
### Step 4: 标记要精读的文献
|
2026-04-21 12:50:17 +00:00
|
|
|
|
|
2026-04-22 09:39:17 +00:00
|
|
|
|
在 Obsidian 中:
|
|
|
|
|
|
1. 打开 `{paths['resources_dir']}/LiteratureControl/library-records/骨科/XXXXXXX.md`
|
|
|
|
|
|
2. 将 `do_ocr: false` 改为 `do_ocr: true`
|
|
|
|
|
|
3. 将 `analyze: false` 改为 `analyze: true`
|
|
|
|
|
|
4. 保存文件
|
|
|
|
|
|
|
|
|
|
|
|
### Step 5: 运行 OCR
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
python {paths['pipeline_path']}/worker/scripts/literature_pipeline.py \\
|
|
|
|
|
|
--vault "{vault_path}" ocr
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
等待完成(可能需要几分钟)。
|
|
|
|
|
|
|
|
|
|
|
|
### Step 6: 检查 OCR 状态
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
python {paths['pipeline_path']}/worker/scripts/literature_pipeline.py \\
|
|
|
|
|
|
--vault "{vault_path}" deep-reading
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
预期输出:
|
|
|
|
|
|
```
|
|
|
|
|
|
## 就绪 (1 篇) — OCR 完成
|
|
|
|
|
|
- `XXXXXXX` | 骨科 | 论文标题
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### Step 7: 执行精读
|
|
|
|
|
|
|
|
|
|
|
|
在 {agent_config['name']} Agent 中输入:
|
|
|
|
|
|
```
|
|
|
|
|
|
/LD-deep XXXXXXX
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Agent 会自动:
|
|
|
|
|
|
1. 准备精读骨架(prepare)
|
|
|
|
|
|
2. 逐阶段填写精读内容
|
|
|
|
|
|
3. 验证结构完整性
|
|
|
|
|
|
|
|
|
|
|
|
### Step 8: 查看结果
|
|
|
|
|
|
|
|
|
|
|
|
在 Obsidian 中打开正式笔记,找到 `## 🔍 精读` 区域,精读已完成。
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 8. 常用命令速查
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
# 检测 Zotero 新条目
|
|
|
|
|
|
python {paths['pipeline_path']}/worker/scripts/literature_pipeline.py \\
|
|
|
|
|
|
--vault "{vault_path}" selection-sync
|
|
|
|
|
|
|
|
|
|
|
|
# 生成/更新正式笔记
|
|
|
|
|
|
python {paths['pipeline_path']}/worker/scripts/literature_pipeline.py \\
|
|
|
|
|
|
--vault "{vault_path}" index-refresh
|
|
|
|
|
|
|
|
|
|
|
|
# 运行 OCR(处理 do_ocr=true 的文献)
|
|
|
|
|
|
python {paths['pipeline_path']}/worker/scripts/literature_pipeline.py \\
|
|
|
|
|
|
--vault "{vault_path}" ocr
|
|
|
|
|
|
|
|
|
|
|
|
# 查看精读队列
|
|
|
|
|
|
python {paths['pipeline_path']}/worker/scripts/literature_pipeline.py \\
|
|
|
|
|
|
--vault "{vault_path}" deep-reading
|
|
|
|
|
|
|
|
|
|
|
|
# 查看整体状态
|
|
|
|
|
|
python {paths['pipeline_path']}/worker/scripts/literature_pipeline.py \\
|
|
|
|
|
|
--vault "{vault_path}" status
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### Agent 命令
|
|
|
|
|
|
```
|
|
|
|
|
|
/LD-deep <zotero_key> # 完整三阶段精读
|
|
|
|
|
|
/LD-paper <zotero_key> # 快速摘要
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 9. 常见问题
|
|
|
|
|
|
|
|
|
|
|
|
### Q: 运行 selection-sync 后没有生成 library-records?
|
|
|
|
|
|
- 检查 Better BibTeX JSON 导出路径是否正确:`{paths['pipeline_path']}/exports/library.json`
|
|
|
|
|
|
- 检查 JSON 文件是否包含文献数据
|
|
|
|
|
|
- 确认 Zotero 中该文献有 citation key
|
|
|
|
|
|
|
|
|
|
|
|
### Q: OCR 一直显示 pending?
|
|
|
|
|
|
- 检查 PaddleOCR API Key 是否配置正确(`.env` 文件)
|
|
|
|
|
|
- 检查网络连接
|
|
|
|
|
|
- 查看 `{paths['pipeline_path']}/ocr/<key>/meta.json` 中的错误信息
|
|
|
|
|
|
|
|
|
|
|
|
### Q: /LD-deep 提示 OCR 未完成?
|
|
|
|
|
|
- 确认 library-record 中 `ocr_status: done`
|
|
|
|
|
|
- 如 OCR 失败,可重新设置 `do_ocr: true` 再运行 ocr worker
|
|
|
|
|
|
|
|
|
|
|
|
### Q: Base 视图中 pdf_path 显示为绝对路径?
|
|
|
|
|
|
- 这是 Obsidian 渲染问题,数据本身是相对路径
|
|
|
|
|
|
- 不影响功能,可忽略
|
|
|
|
|
|
|
|
|
|
|
|
### Q: 可以批量操作吗?
|
|
|
|
|
|
- 可以。使用 Obsidian Base 视图批量修改 `do_ocr` 和 `analyze` 字段
|
|
|
|
|
|
- 或使用脚本批量修改 library-records 中的 frontmatter
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 10. 升级与维护
|
|
|
|
|
|
|
|
|
|
|
|
### 更新 PaperForge 代码
|
|
|
|
|
|
```bash
|
|
|
|
|
|
cd {vault_path}
|
|
|
|
|
|
# 如果你有 git 跟踪 PaperForge
|
|
|
|
|
|
git pull origin main
|
|
|
|
|
|
|
|
|
|
|
|
# 或手动复制更新文件
|
|
|
|
|
|
cp -r 新下载的scripts/* {paths['pipeline_path']}/worker/scripts/
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 备份注意事项
|
|
|
|
|
|
- `{paths['resources_dir']}/` 和 `{paths['pipeline_path']}/ocr/` 包含你的数据,需备份
|
|
|
|
|
|
- `.env` 包含 API Key,不要提交到 git
|
|
|
|
|
|
- `{paths['pipeline_path']}/exports/` 可重新生成(由 Zotero 自动导出)
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 配置摘要
|
|
|
|
|
|
|
|
|
|
|
|
| 配置项 | 值 |
|
|
|
|
|
|
|--------|-----|
|
|
|
|
|
|
| Vault 路径 | `{vault_path}` |
|
|
|
|
|
|
| System 目录 | `{paths['system_dir']}` |
|
|
|
|
|
|
| Pipeline 路径 | `{paths['pipeline_path']}` |
|
|
|
|
|
|
| 文献目录 | `{paths['literature_path']}` |
|
|
|
|
|
|
| Agent 平台 | {agent_config['name']} ({config['agent']}) |
|
|
|
|
|
|
| Skill 目录 | `{agent_config['skill_dir']}` |
|
|
|
|
|
|
| Zotero 路径 | {config['zotero_path']} |
|
|
|
|
|
|
|
|
|
|
|
|
---
|
2026-04-21 12:50:17 +00:00
|
|
|
|
|
2026-04-22 09:39:17 +00:00
|
|
|
|
*PaperForge Lite | 本文件由 setup.py 根据你的自定义配置自动生成*
|
|
|
|
|
|
*生成时间:{platform.system()} | 请勿手动编辑路径,重装时会被覆盖*
|
2026-04-21 12:50:17 +00:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
agents_path.write_text(content, encoding="utf-8")
|
2026-04-22 09:39:17 +00:00
|
|
|
|
print_success(f"AGENTS.md created at {agents_path} ({len(content)} chars, fully customized)")
|
2026-04-21 12:50:17 +00:00
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 15:44:45 +00:00
|
|
|
|
def safe_copy(src: Path, dst: Path, backup: bool = True) -> bool:
|
|
|
|
|
|
"""Safely copy file, never overwrite without consent.
|
|
|
|
|
|
|
|
|
|
|
|
Returns True if copied, False if skipped.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if dst.exists():
|
|
|
|
|
|
if backup:
|
|
|
|
|
|
backup_path = dst.with_suffix(dst.suffix + ".backup")
|
|
|
|
|
|
shutil.copy2(dst, backup_path)
|
|
|
|
|
|
print(f"{Colors.YELLOW}[BACKUP]{Colors.ENDC} Existing file saved to {backup_path.name}")
|
|
|
|
|
|
|
|
|
|
|
|
print(f"{Colors.YELLOW}[SKIP]{Colors.ENDC} File exists: {dst}")
|
|
|
|
|
|
print(f" To update, manually delete or rename the existing file.")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
shutil.copy2(src, dst)
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 13:12:12 +00:00
|
|
|
|
def deploy_workflow_scripts(vault_path: Path, agent_key: str, agent_config: dict, paths: dict) -> bool:
|
2026-04-21 12:59:14 +00:00
|
|
|
|
"""Deploy workflow scripts from repo to vault.
|
|
|
|
|
|
|
|
|
|
|
|
This copies the core pipeline code from the repository into the user's vault,
|
|
|
|
|
|
ensuring the latest scripts are available while keeping private data (.env, API keys) separate.
|
|
|
|
|
|
"""
|
|
|
|
|
|
print_header("Step 4.5: Deploying Workflow Scripts")
|
|
|
|
|
|
|
|
|
|
|
|
# Determine repo root (where this script is located)
|
|
|
|
|
|
repo_root = Path(__file__).resolve().parent.parent
|
|
|
|
|
|
|
2026-04-21 13:12:12 +00:00
|
|
|
|
# Get agent-specific skill directory
|
|
|
|
|
|
skill_dir = agent_config.get("skill_dir", ".opencode/skills")
|
|
|
|
|
|
|
2026-04-21 12:59:14 +00:00
|
|
|
|
# Files to deploy: (source_relative_path, dest_relative_path)
|
|
|
|
|
|
deployments = [
|
|
|
|
|
|
# OCR pipeline worker
|
2026-04-21 14:17:45 +00:00
|
|
|
|
(f"pipeline/worker/scripts/literature_pipeline.py",
|
2026-04-21 13:12:12 +00:00
|
|
|
|
f"{paths['pipeline_path']}/worker/scripts/literature_pipeline.py"),
|
2026-04-21 12:59:14 +00:00
|
|
|
|
|
2026-04-21 13:12:12 +00:00
|
|
|
|
# Deep reading scripts (into agent-specific skill dir)
|
2026-04-21 14:17:45 +00:00
|
|
|
|
(f"skills/literature-qa/scripts/ld_deep.py",
|
2026-04-21 13:12:12 +00:00
|
|
|
|
f"{skill_dir}/literature-qa/scripts/ld_deep.py"),
|
2026-04-21 12:59:14 +00:00
|
|
|
|
|
2026-04-21 13:12:12 +00:00
|
|
|
|
# Subagent prompt (into agent-specific skill dir)
|
2026-04-21 14:17:45 +00:00
|
|
|
|
(f"skills/literature-qa/prompt_deep_subagent.md",
|
2026-04-21 13:12:12 +00:00
|
|
|
|
f"{skill_dir}/literature-qa/prompt_deep_subagent.md"),
|
2026-04-21 12:59:14 +00:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
success_count = 0
|
|
|
|
|
|
fail_count = 0
|
|
|
|
|
|
|
|
|
|
|
|
for src_rel, dst_rel in deployments:
|
|
|
|
|
|
src_path = repo_root / src_rel
|
|
|
|
|
|
dst_path = vault_path / dst_rel
|
|
|
|
|
|
|
|
|
|
|
|
if not src_path.exists():
|
|
|
|
|
|
print_warning(f"Source file not found (skipping): {src_rel}")
|
|
|
|
|
|
fail_count += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
2026-04-21 15:44:45 +00:00
|
|
|
|
if safe_copy(src_path, dst_path):
|
|
|
|
|
|
print_success(f"Deployed: {dst_rel}")
|
|
|
|
|
|
success_count += 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
fail_count += 1
|
2026-04-21 12:59:14 +00:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print_error(f"Failed to deploy {src_rel}: {e}")
|
|
|
|
|
|
fail_count += 1
|
|
|
|
|
|
|
2026-04-21 15:00:50 +00:00
|
|
|
|
# Deploy chart reading guides (into literature-qa skill as references)
|
|
|
|
|
|
chart_guide_src = repo_root / "skills/literature-qa/chart-reading"
|
|
|
|
|
|
chart_guide_dst = vault_path / skill_dir / "literature-qa/chart-reading"
|
2026-04-21 12:59:14 +00:00
|
|
|
|
|
|
|
|
|
|
if chart_guide_src.exists() and chart_guide_src.is_dir():
|
|
|
|
|
|
chart_files = list(chart_guide_src.glob("*.md"))
|
|
|
|
|
|
if chart_files:
|
|
|
|
|
|
chart_guide_dst.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
for chart_file in chart_files:
|
|
|
|
|
|
try:
|
|
|
|
|
|
dst_file = chart_guide_dst / chart_file.name
|
2026-04-21 15:44:45 +00:00
|
|
|
|
if safe_copy(chart_file, dst_file):
|
|
|
|
|
|
success_count += 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
fail_count += 1
|
2026-04-21 12:59:14 +00:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print_error(f"Failed to deploy chart guide {chart_file.name}: {e}")
|
|
|
|
|
|
fail_count += 1
|
|
|
|
|
|
print_success(f"Deployed {len(chart_files)} chart reading guides")
|
|
|
|
|
|
|
2026-04-21 15:40:07 +00:00
|
|
|
|
# Deploy agent commands
|
|
|
|
|
|
command_src = repo_root / "command"
|
|
|
|
|
|
command_dst = vault_path / ".opencode/command"
|
|
|
|
|
|
|
|
|
|
|
|
if command_src.exists() and command_src.is_dir():
|
|
|
|
|
|
command_files = list(command_src.glob("*.md"))
|
|
|
|
|
|
if command_files:
|
|
|
|
|
|
command_dst.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
for cmd_file in command_files:
|
|
|
|
|
|
try:
|
|
|
|
|
|
dst_file = command_dst / cmd_file.name
|
2026-04-21 15:44:45 +00:00
|
|
|
|
if safe_copy(cmd_file, dst_file):
|
|
|
|
|
|
success_count += 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
fail_count += 1
|
2026-04-21 15:40:07 +00:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print_error(f"Failed to deploy command {cmd_file.name}: {e}")
|
|
|
|
|
|
fail_count += 1
|
|
|
|
|
|
print_success(f"Deployed {len(command_files)} agent commands")
|
|
|
|
|
|
|
2026-04-21 12:59:14 +00:00
|
|
|
|
print(f"\nDeployment summary: {success_count} succeeded, {fail_count} failed")
|
|
|
|
|
|
return fail_count == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 13:12:12 +00:00
|
|
|
|
def validate_setup(vault_path: Path, config: dict, paths: dict) -> list[str]:
|
2026-04-21 12:50:17 +00:00
|
|
|
|
"""Validate the setup and return issues."""
|
|
|
|
|
|
issues = []
|
|
|
|
|
|
|
|
|
|
|
|
# Check Zotero SQLite
|
|
|
|
|
|
zotero_db = Path(config['zotero_path']) / "zotero.sqlite"
|
|
|
|
|
|
if not zotero_db.exists():
|
|
|
|
|
|
issues.append(f"Zotero database not found: {zotero_db}")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print_success("Zotero database accessible")
|
|
|
|
|
|
|
|
|
|
|
|
# Check directory structure
|
|
|
|
|
|
required_dirs = [
|
2026-04-21 13:12:12 +00:00
|
|
|
|
f"{paths['pipeline_path']}/ocr",
|
|
|
|
|
|
f"{paths['pipeline_path']}/worker/scripts",
|
2026-04-21 14:10:40 +00:00
|
|
|
|
f"{paths['pipeline_path']}/indexes",
|
|
|
|
|
|
f"03_Resources/LiteratureControl/library-records",
|
2026-04-21 12:50:17 +00:00
|
|
|
|
]
|
|
|
|
|
|
for d in required_dirs:
|
|
|
|
|
|
if not (vault_path / d).exists():
|
|
|
|
|
|
issues.append(f"Missing directory: {d}")
|
|
|
|
|
|
|
|
|
|
|
|
if not issues:
|
|
|
|
|
|
print_success("Directory structure correct")
|
|
|
|
|
|
|
|
|
|
|
|
# Check AGENTS.md
|
|
|
|
|
|
if not (vault_path / "AGENTS.md").exists():
|
|
|
|
|
|
issues.append("AGENTS.md missing")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print_success("AGENTS.md exists")
|
|
|
|
|
|
|
|
|
|
|
|
# Check .env
|
|
|
|
|
|
if not (vault_path / ".env").exists():
|
|
|
|
|
|
issues.append(".env configuration missing")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print_success("Configuration file exists")
|
|
|
|
|
|
|
|
|
|
|
|
return issues
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> int:
|
|
|
|
|
|
"""Main installer entry point."""
|
2026-04-21 13:14:25 +00:00
|
|
|
|
# Show welcome screen
|
|
|
|
|
|
try:
|
|
|
|
|
|
from welcome import show_welcome, show_install_menu
|
|
|
|
|
|
show_welcome()
|
|
|
|
|
|
show_install_menu()
|
|
|
|
|
|
except ImportError:
|
|
|
|
|
|
print_header("Literature Workflow Installer")
|
|
|
|
|
|
|
2026-04-21 15:44:45 +00:00
|
|
|
|
print("This script will help you configure the literature research pipeline.")
|
|
|
|
|
|
print(f"\n{Colors.BRIGHT_YELLOW}{Colors.BOLD}[SAFETY NOTICE]{Colors.ENDC}")
|
|
|
|
|
|
print(f" - This installer will NOT modify your Zotero database or existing notes")
|
|
|
|
|
|
print(f" - Existing files will be backed up (.backup) before any change")
|
|
|
|
|
|
print(f" - You can safely re-run this script; it will skip existing files\n")
|
2026-04-21 12:50:17 +00:00
|
|
|
|
|
2026-04-21 13:12:12 +00:00
|
|
|
|
# Step 0: Select agent platform
|
|
|
|
|
|
agent_key, agent_config = select_agent()
|
|
|
|
|
|
print_success(f"Selected agent: {agent_config['name']}")
|
|
|
|
|
|
|
|
|
|
|
|
# Step 0.5: Configure vault paths
|
|
|
|
|
|
print_header("Step 0.5: Vault Configuration")
|
2026-04-21 12:50:17 +00:00
|
|
|
|
vault_path_str = ask(
|
|
|
|
|
|
"Where is your Obsidian vault located?",
|
|
|
|
|
|
default=str(Path.cwd()),
|
|
|
|
|
|
)
|
|
|
|
|
|
vault_path = Path(vault_path_str).resolve()
|
|
|
|
|
|
|
|
|
|
|
|
if not vault_path.exists():
|
|
|
|
|
|
print_error(f"Vault path does not exist: {vault_path}")
|
|
|
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
|
|
print_success(f"Using vault: {vault_path}")
|
|
|
|
|
|
|
2026-04-21 13:12:12 +00:00
|
|
|
|
# Configure directory structure
|
|
|
|
|
|
paths = configure_vault_paths(vault_path)
|
|
|
|
|
|
print_success("Directory structure configured")
|
|
|
|
|
|
|
2026-04-21 12:50:17 +00:00
|
|
|
|
# Step 1: Check Python deps
|
|
|
|
|
|
print_header("Step 1: Checking Python Dependencies")
|
|
|
|
|
|
missing_deps = check_python_deps()
|
|
|
|
|
|
if missing_deps:
|
|
|
|
|
|
print_warning(f"Missing packages: {', '.join(missing_deps)}")
|
|
|
|
|
|
if ask_yes_no("Install now?", default=True):
|
|
|
|
|
|
if not install_deps(missing_deps):
|
|
|
|
|
|
return 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
print_error("Required packages must be installed to continue")
|
|
|
|
|
|
return 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
print_success("All dependencies installed")
|
|
|
|
|
|
|
|
|
|
|
|
# Step 2: Detect/Ask for Zotero path
|
|
|
|
|
|
print_header("Step 2: Zotero Configuration")
|
|
|
|
|
|
detected_zotero = detect_zotero_path()
|
|
|
|
|
|
|
|
|
|
|
|
if detected_zotero:
|
|
|
|
|
|
print_success(f"Detected Zotero at: {detected_zotero}")
|
|
|
|
|
|
if ask_yes_no("Use this path?", default=True):
|
|
|
|
|
|
zotero_path = detected_zotero
|
|
|
|
|
|
else:
|
|
|
|
|
|
zotero_path = Path(ask("Enter Zotero data directory:"))
|
|
|
|
|
|
else:
|
|
|
|
|
|
print_warning("Could not auto-detect Zotero")
|
|
|
|
|
|
zotero_path = Path(ask("Enter Zotero data directory (contains zotero.sqlite):"))
|
|
|
|
|
|
|
|
|
|
|
|
if not (zotero_path / "zotero.sqlite").exists():
|
|
|
|
|
|
print_error(f"zotero.sqlite not found in {zotero_path}")
|
|
|
|
|
|
print("Please ensure Zotero is installed and the path is correct.")
|
|
|
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
|
|
# Step 3: Storage path
|
|
|
|
|
|
storage_path = zotero_path
|
|
|
|
|
|
if ask_yes_no("Is your Zotero storage directory in a different location?", default=False):
|
|
|
|
|
|
storage_path = Path(ask("Enter Zotero storage directory:"))
|
|
|
|
|
|
|
|
|
|
|
|
# Step 4: OCR API Key
|
|
|
|
|
|
print_header("Step 3: OCR Configuration")
|
|
|
|
|
|
ocr_api_key = ask("Enter your PaddleOCR API key:")
|
|
|
|
|
|
if not ocr_api_key:
|
|
|
|
|
|
print_warning("No API key provided. OCR features will not work.")
|
|
|
|
|
|
|
|
|
|
|
|
# Step 5: Create directories
|
|
|
|
|
|
print_header("Step 4: Creating Directory Structure")
|
2026-04-21 13:12:12 +00:00
|
|
|
|
create_directory_structure(vault_path, paths)
|
2026-04-21 12:50:17 +00:00
|
|
|
|
|
2026-04-21 13:12:12 +00:00
|
|
|
|
# Step 6: Deploy workflow scripts
|
|
|
|
|
|
deploy_workflow_scripts(vault_path, agent_key, agent_config, paths)
|
2026-04-21 12:59:14 +00:00
|
|
|
|
|
2026-04-21 13:12:12 +00:00
|
|
|
|
# Step 7: Create junction or config
|
2026-04-21 12:50:17 +00:00
|
|
|
|
print_header("Step 5: Configuring Zotero Integration")
|
2026-04-21 13:12:12 +00:00
|
|
|
|
zotero_link = vault_path / paths["system_dir"] / "Zotero"
|
2026-04-21 12:50:17 +00:00
|
|
|
|
|
|
|
|
|
|
if zotero_link.exists() or zotero_link.is_symlink():
|
|
|
|
|
|
print_warning("Zotero link already exists")
|
|
|
|
|
|
else:
|
|
|
|
|
|
if create_junction(zotero_link, zotero_path):
|
|
|
|
|
|
print_success("Zotero junction created")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print_warning("Failed to create junction, will use config file instead")
|
|
|
|
|
|
|
2026-04-21 13:12:12 +00:00
|
|
|
|
# Step 8: Save configuration
|
2026-04-21 12:50:17 +00:00
|
|
|
|
print_header("Step 6: Saving Configuration")
|
|
|
|
|
|
config = {
|
|
|
|
|
|
"zotero_path": str(zotero_path),
|
|
|
|
|
|
"storage_path": str(storage_path),
|
|
|
|
|
|
"ocr_api_key": ocr_api_key,
|
2026-04-21 13:12:12 +00:00
|
|
|
|
"agent": agent_key,
|
|
|
|
|
|
"agent_name": agent_config["name"],
|
|
|
|
|
|
"skill_dir": agent_config["skill_dir"],
|
2026-04-21 12:50:17 +00:00
|
|
|
|
}
|
2026-04-21 14:10:40 +00:00
|
|
|
|
create_env_file(vault_path, config, paths)
|
2026-04-21 13:12:12 +00:00
|
|
|
|
create_agents_md(vault_path, config, paths, agent_config)
|
2026-04-21 12:50:17 +00:00
|
|
|
|
|
2026-04-21 13:12:12 +00:00
|
|
|
|
# Step 9: Validation
|
2026-04-21 12:50:17 +00:00
|
|
|
|
print_header("Step 7: Validating Setup")
|
2026-04-21 13:12:12 +00:00
|
|
|
|
issues = validate_setup(vault_path, config, paths)
|
2026-04-21 12:50:17 +00:00
|
|
|
|
|
|
|
|
|
|
if issues:
|
|
|
|
|
|
print_error("\nValidation failed with the following issues:")
|
|
|
|
|
|
for issue in issues:
|
|
|
|
|
|
print(f" - {issue}")
|
|
|
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
|
|
print_header("Installation Complete!")
|
|
|
|
|
|
print(f"""
|
|
|
|
|
|
{Colors.OKGREEN}Your literature workflow is ready to use!{Colors.ENDC}
|
|
|
|
|
|
|
2026-04-21 13:12:12 +00:00
|
|
|
|
Configuration Summary:
|
|
|
|
|
|
- Agent: {agent_config['name']}
|
|
|
|
|
|
- Skill Directory: {agent_config['skill_dir']}
|
|
|
|
|
|
- System Folder: {paths['system_dir']}
|
|
|
|
|
|
- Literature Path: {paths['literature_path']}
|
|
|
|
|
|
|
2026-04-21 12:50:17 +00:00
|
|
|
|
Next steps:
|
|
|
|
|
|
1. Open Obsidian and ensure your vault is loaded
|
|
|
|
|
|
2. Index your library: Run the index-refresh worker
|
|
|
|
|
|
3. Queue papers for analysis in the Base system
|
|
|
|
|
|
4. Run OCR on queued papers
|
|
|
|
|
|
5. Start deep reading with /LD-deep <zotero_key>
|
|
|
|
|
|
|
|
|
|
|
|
For detailed usage, see the documentation in docs/.
|
|
|
|
|
|
""")
|
|
|
|
|
|
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
try:
|
|
|
|
|
|
sys.exit(main())
|
|
|
|
|
|
except KeyboardInterrupt:
|
|
|
|
|
|
print("\n\nInstallation cancelled by user.")
|
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print_error(f"Unexpected error: {e}")
|
|
|
|
|
|
sys.exit(1)
|