mirror of
https://github.com/lllin000/PaperForge.git
synced 2026-07-22 17:00:23 +00:00
340 lines
11 KiB
Python
340 lines
11 KiB
Python
"""Discussion recorder -- writes structured AI-paper Q&A into ai/ workspace directory.
|
|
|
|
Atomic append-only writes for Markdown (canonical). stdlib only.
|
|
|
|
API:
|
|
record_session(vault_path, zotero_key, agent, model, qa_pairs) -> dict
|
|
|
|
CLI:
|
|
python -m paperforge.worker.discussion record <zotero_key> --vault <path>
|
|
--agent <name> --model <model> [--qa-pairs <json>]
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import logging
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import filelock
|
|
|
|
from paperforge.config import paperforge_paths
|
|
from paperforge.worker._utils import slugify_filename
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_SCHEMA_VERSION = "1"
|
|
_ISO_FORMAT = "%Y-%m-%dT%H:%M:%S%z"
|
|
_MD_SEPARATOR = "---"
|
|
|
|
LOCK_TIMEOUT = 10
|
|
"""Seconds to wait for cross-process file lock before raising filelock.Timeout."""
|
|
|
|
_CST = timezone.utc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _now_iso() -> str:
|
|
"""Return current time in ISO 8601 with UTC timezone."""
|
|
return datetime.now(_CST).isoformat()
|
|
|
|
|
|
def _today_str(iso_stamp: str) -> str:
|
|
"""Extract YYYY-MM-DD from an ISO 8601 timestamp."""
|
|
return iso_stamp[:10]
|
|
|
|
|
|
def _find_paper_metadata(vault: Path, zotero_key: str) -> dict | None:
|
|
"""Look up paper metadata from the canonical index.
|
|
|
|
Returns dict with 'domain' and 'title' and 'ai_path' or None if not found.
|
|
"""
|
|
try:
|
|
paths = paperforge_paths(vault)
|
|
except Exception as exc:
|
|
logger.warning("Failed to resolve PaperForge paths: %s", exc)
|
|
return None
|
|
|
|
index_path = paths["paperforge"] / "indexes" / "formal-library.json"
|
|
if not index_path.exists():
|
|
logger.warning("Canonical index does not exist: %s", index_path)
|
|
return None
|
|
|
|
try:
|
|
index = json.loads(index_path.read_text(encoding="utf-8"))
|
|
except (json.JSONDecodeError, OSError) as exc:
|
|
logger.warning("Failed to read canonical index: %s", exc)
|
|
return None
|
|
|
|
items = index.get("items", []) if isinstance(index, dict) else []
|
|
for entry in items:
|
|
if entry.get("zotero_key") == zotero_key:
|
|
return {
|
|
"domain": entry.get("domain", ""),
|
|
"title": entry.get("title", ""),
|
|
"ai_path": entry.get("ai_path", ""),
|
|
}
|
|
return None
|
|
|
|
|
|
def _build_ai_dir(vault: Path, domain: str, key: str, title: str) -> Path:
|
|
"""Construct the ai/ directory path for a paper's workspace.
|
|
|
|
Pattern: {literature_dir}/{domain}/{key} - {title_slug}/ai/
|
|
Uses paperforge_paths for portable path resolution (per D-01).
|
|
"""
|
|
try:
|
|
paths = paperforge_paths(vault)
|
|
lit_root = paths["literature"]
|
|
except Exception:
|
|
# Fallback to default structure if config loading fails
|
|
lit_root = vault / "Resources" / "Literature"
|
|
title_slug = slugify_filename(title) if title else key
|
|
ai_dir = lit_root / domain / f"{key} - {title_slug}" / "ai"
|
|
return ai_dir
|
|
|
|
|
|
def _build_session(
|
|
agent: str,
|
|
model: str,
|
|
zotero_key: str,
|
|
paper_title: str,
|
|
domain: str,
|
|
qa_pairs: list[dict],
|
|
) -> dict:
|
|
"""Build a session object per D-11."""
|
|
return {
|
|
"session_id": str(uuid.uuid4()),
|
|
"agent": agent,
|
|
"model": model,
|
|
"started": _now_iso(),
|
|
"paper_key": zotero_key,
|
|
"paper_title": paper_title,
|
|
"domain": domain,
|
|
"qa_pairs": list(qa_pairs),
|
|
}
|
|
|
|
|
|
def _build_md_header(paper_title: str) -> str:
|
|
return f"# AI Discussion Record: {paper_title}\n\n"
|
|
|
|
|
|
def _build_md_session(session: dict) -> str:
|
|
"""Format a session entry as human-readable Markdown."""
|
|
started = session["started"]
|
|
date_str = _today_str(started)
|
|
agent = session["agent"]
|
|
model = session["model"]
|
|
lines = [f"## {date_str} -- {agent} ({model})\n"]
|
|
for qa in session["qa_pairs"]:
|
|
lines.append(f"**问题:** {qa['question']}")
|
|
lines.append(f"**解答:** {qa['answer']}\n")
|
|
lines.append(f"{_MD_SEPARATOR}\n")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _md_content(existing: str, header: str, session_md: str) -> str:
|
|
"""Append session markdown to existing content or create new."""
|
|
if existing.strip():
|
|
return existing.rstrip("\n") + "\n\n" + session_md
|
|
return header + session_md
|
|
|
|
|
|
def _atomic_write_md(target_path: Path, content: str) -> None:
|
|
"""Atomically write Markdown file via tempfile + os.replace."""
|
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
fd, tmp_path_str = tempfile.mkstemp(
|
|
suffix=".md",
|
|
prefix="discussion_",
|
|
dir=str(target_path.parent),
|
|
)
|
|
tmp_path = Path(tmp_path_str)
|
|
try:
|
|
os.write(fd, content.encode("utf-8"))
|
|
os.close(fd)
|
|
fd = None
|
|
os.replace(tmp_path_str, str(target_path))
|
|
except Exception:
|
|
if fd is not None:
|
|
os.close(fd)
|
|
if tmp_path.exists():
|
|
tmp_path.unlink()
|
|
raise
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def record_session(
|
|
vault_path: Path,
|
|
zotero_key: str,
|
|
agent: str,
|
|
model: str,
|
|
qa_pairs: list[dict],
|
|
) -> dict:
|
|
"""Record an AI-paper discussion session.
|
|
|
|
Creates or appends to ai/discussion.md (canonical) with atomic writes.
|
|
|
|
Args:
|
|
vault_path: Path to the Obsidian vault root.
|
|
zotero_key: Zotero citation key (8 characters).
|
|
agent: Agent name (e.g. "pf-paper").
|
|
model: Model identifier (e.g. "gpt-4").
|
|
qa_pairs: List of dicts with keys: question, answer, source, timestamp.
|
|
|
|
Returns:
|
|
dict with "status": "ok"|"error" and relevant paths or error message.
|
|
"""
|
|
# --- Validation ---
|
|
try:
|
|
vault_path = Path(vault_path).expanduser().resolve()
|
|
except Exception as exc:
|
|
return {"status": "error", "message": f"Invalid vault path: {exc}"}
|
|
|
|
if not vault_path.exists():
|
|
return {"status": "error", "message": f"Vault path does not exist: {vault_path}"}
|
|
if not vault_path.is_dir():
|
|
return {"status": "error", "message": f"Vault path is not a directory: {vault_path}"}
|
|
|
|
if not zotero_key or not isinstance(zotero_key, str):
|
|
return {"status": "error", "message": "zotero_key must be a non-empty string"}
|
|
|
|
if not isinstance(qa_pairs, list):
|
|
return {"status": "error", "message": "qa_pairs must be a list"}
|
|
|
|
# --- Find paper metadata ---
|
|
try:
|
|
meta = _find_paper_metadata(vault_path, zotero_key)
|
|
except Exception as exc:
|
|
logger.warning("Error finding paper metadata: %s", exc)
|
|
return {"status": "error", "message": f"Error finding paper metadata: {exc}"}
|
|
|
|
if meta is None:
|
|
return {"status": "error", "message": f"Paper not found in canonical index: {zotero_key}"}
|
|
|
|
domain = meta["domain"]
|
|
paper_title = meta["title"] or zotero_key
|
|
|
|
# --- Build paths (WS-03: use ai_path from canonical index) ---
|
|
ai_path_str = meta.get("ai_path", "")
|
|
if ai_path_str:
|
|
ai_dir = vault_path / ai_path_str
|
|
else:
|
|
# Fallback: construct from metadata
|
|
ai_dir = _build_ai_dir(vault_path, domain, zotero_key, paper_title)
|
|
|
|
md_path = ai_dir / "discussion.md"
|
|
|
|
# --- Build session object ---
|
|
try:
|
|
session = _build_session(agent, model, zotero_key, paper_title, domain, qa_pairs)
|
|
except Exception as exc:
|
|
return {"status": "error", "message": f"Error building session: {exc}"}
|
|
|
|
# --- Write discussion.md (atomic, locked) ---
|
|
lock_path = md_path.with_suffix(".md.lock")
|
|
lock = filelock.FileLock(lock_path, timeout=LOCK_TIMEOUT)
|
|
try:
|
|
with lock:
|
|
existing_md = ""
|
|
if md_path.exists():
|
|
try:
|
|
existing_md = md_path.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeDecodeError) as exc:
|
|
logger.warning("Could not read existing discussion.md: %s", exc)
|
|
existing_md = ""
|
|
|
|
header = _build_md_header(paper_title)
|
|
session_md = _build_md_session(session)
|
|
content = _md_content(existing_md, header, session_md)
|
|
_atomic_write_md(md_path, content)
|
|
except filelock.Timeout:
|
|
logger.warning("Could not acquire lock for discussion file: %s", lock_path)
|
|
return {"status": "error", "message": "Concurrent access conflict. Please try again."}
|
|
except Exception as exc:
|
|
logger.warning("Failed to write discussion file: %s", exc)
|
|
return {"status": "error", "message": f"Failed to write discussion file: {exc}"}
|
|
|
|
return {"status": "ok", "md_path": str(md_path)}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _build_cli_parser() -> argparse.ArgumentParser:
|
|
"""Build the CLI argument parser."""
|
|
parser = argparse.ArgumentParser(
|
|
prog="python -m paperforge.worker.discussion",
|
|
description="Record AI-paper discussion sessions",
|
|
)
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
|
|
record_p = sub.add_parser("record", help="Record a discussion session")
|
|
record_p.add_argument("zotero_key", help="Zotero citation key")
|
|
record_p.add_argument("--vault", required=True, help="Path to Obsidian vault")
|
|
record_p.add_argument("--agent", required=True, help="Agent name (e.g. pf-paper)")
|
|
record_p.add_argument("--model", required=True, help="Model identifier")
|
|
record_p.add_argument(
|
|
"--qa-pairs",
|
|
default="[]",
|
|
help="JSON array of Q&A pairs",
|
|
)
|
|
return parser
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
"""CLI entry point. Returns exit code."""
|
|
# Ensure UTF-8 output on Windows (console may use cp936/GBK)
|
|
if hasattr(sys.stdout, "reconfigure"):
|
|
try:
|
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
except Exception:
|
|
pass
|
|
|
|
parser = _build_cli_parser()
|
|
args = parser.parse_args(argv)
|
|
|
|
if args.command == "record":
|
|
vault_path = Path(args.vault)
|
|
try:
|
|
qa_pairs = json.loads(args.qa_pairs)
|
|
except json.JSONDecodeError as exc:
|
|
result = {"status": "error", "message": f"Invalid --qa-pairs JSON: {exc}"}
|
|
print(json.dumps(result, ensure_ascii=False))
|
|
return 1
|
|
|
|
result = record_session(
|
|
vault_path=vault_path,
|
|
zotero_key=args.zotero_key,
|
|
agent=args.agent,
|
|
model=args.model,
|
|
qa_pairs=qa_pairs,
|
|
)
|
|
print(json.dumps(result, ensure_ascii=False))
|
|
return 0 if result["status"] == "ok" else 1
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|