mirror of
https://github.com/lllin000/PaperForge.git
synced 2026-07-22 06:50:53 +00:00
383 lines
12 KiB
Python
383 lines
12 KiB
Python
"""Deterministic paper lookup engine.
|
|
|
|
Resolves papers by key, DOI, or structured field search from formal-library.json.
|
|
Natural language queries are handled by the Agent — this module handles only
|
|
deterministic lookups.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
|
|
@dataclass
|
|
class PaperMeta:
|
|
"""Lightweight paper metadata for structured search results."""
|
|
|
|
key: str
|
|
title: str
|
|
domain: str
|
|
year: str = ""
|
|
authors: str = ""
|
|
doi: str = ""
|
|
journal: str = ""
|
|
collection_path: str = ""
|
|
lifecycle: str = ""
|
|
|
|
|
|
@dataclass
|
|
class PaperWorkspace:
|
|
"""Full workspace paths and metadata for a resolved paper."""
|
|
|
|
key: str
|
|
title: str
|
|
domain: str
|
|
formal_note_path: str
|
|
ocr_path: str
|
|
fulltext_path: str
|
|
frontmatter: dict = field(default_factory=dict)
|
|
year: str = ""
|
|
authors: str = ""
|
|
doi: str = ""
|
|
journal: str = ""
|
|
ocr_status: str = ""
|
|
deep_reading_status: str = ""
|
|
analyze: bool = False
|
|
do_ocr: bool = False
|
|
has_pdf: bool = False
|
|
|
|
|
|
class PaperResolver:
|
|
"""Deterministic paper lookup from formal-library.json index."""
|
|
|
|
def __init__(self, vault: Path):
|
|
self._vault = vault
|
|
self._paths: dict[str, Path] = {}
|
|
self._items: list[dict] = []
|
|
self._loaded = False
|
|
|
|
def _ensure_loaded(self):
|
|
if self._loaded:
|
|
return
|
|
try:
|
|
from paperforge.config import paperforge_paths
|
|
|
|
self._paths = paperforge_paths(self._vault)
|
|
except Exception:
|
|
self._paths = {}
|
|
try:
|
|
from paperforge.worker.asset_index import read_index
|
|
|
|
data = read_index(self._vault)
|
|
if isinstance(data, dict):
|
|
self._items = data.get("items", [])
|
|
except Exception:
|
|
self._items = []
|
|
self._loaded = True
|
|
|
|
def resolve_key(self, key: str) -> PaperWorkspace | None:
|
|
"""Exact match on zotero_key."""
|
|
self._ensure_loaded()
|
|
for entry in self._items:
|
|
if entry.get("zotero_key", "").strip().upper() == key.strip().upper():
|
|
return self._build_workspace(entry)
|
|
return None
|
|
|
|
def resolve_doi(self, doi: str) -> PaperWorkspace | None:
|
|
"""Exact match on DOI (case-insensitive, normalized)."""
|
|
self._ensure_loaded()
|
|
normalized = self._normalize_doi(doi)
|
|
for entry in self._items:
|
|
entry_doi = self._normalize_doi(entry.get("doi", ""))
|
|
if entry_doi and entry_doi == normalized:
|
|
return self._build_workspace(entry)
|
|
return None
|
|
|
|
def search(
|
|
self,
|
|
title: str | None = None,
|
|
author: str | None = None,
|
|
year: int | str | None = None,
|
|
domain: str | None = None,
|
|
limit: int = 20,
|
|
) -> list[PaperWorkspace]:
|
|
"""Multi-field search with substring matching, sorted by relevance score.
|
|
|
|
All supplied fields must match (AND logic). Each field contributes to
|
|
a relevance score for ranking.
|
|
"""
|
|
self._ensure_loaded()
|
|
scored: list[tuple[int, PaperWorkspace]] = []
|
|
|
|
title_lower = title.strip().lower() if title else None
|
|
author_lower = author.strip().lower() if author else None
|
|
year_str = str(year).strip() if year is not None else None
|
|
domain_lower = domain.strip().lower() if domain else None
|
|
|
|
for entry in self._items:
|
|
score = 0
|
|
entry_title = (entry.get("title") or "").lower()
|
|
entry_authors = _flatten_authors(entry.get("authors", "")).lower()
|
|
entry_year = str(entry.get("year", ""))
|
|
entry_domain = (entry.get("domain") or "").lower()
|
|
|
|
if title_lower:
|
|
if title_lower == entry_title:
|
|
score += 100
|
|
elif title_lower in entry_title:
|
|
score += 30
|
|
else:
|
|
continue
|
|
if author_lower:
|
|
if author_lower in entry_authors:
|
|
score += 50
|
|
else:
|
|
continue
|
|
if year_str:
|
|
if year_str == entry_year:
|
|
score += 40
|
|
else:
|
|
continue
|
|
if domain_lower:
|
|
if domain_lower == entry_domain:
|
|
score += 20
|
|
elif domain_lower in entry_domain:
|
|
score += 10
|
|
else:
|
|
continue
|
|
|
|
if score > 0 or not any([title_lower, author_lower, year_str, domain_lower]):
|
|
scored.append((score, self._build_workspace(entry)))
|
|
|
|
scored.sort(key=lambda x: x[0], reverse=True)
|
|
return [ws for _, ws in scored[:limit]]
|
|
|
|
def _build_workspace(self, entry: dict) -> PaperWorkspace:
|
|
"""Build a PaperWorkspace from an index entry dict."""
|
|
key = entry.get("zotero_key", "")
|
|
title = entry.get("title", "")
|
|
domain = entry.get("domain", "")
|
|
note_path = entry.get("note_path", "")
|
|
|
|
ocr_base = _resolve_ocr_base(self._paths, key)
|
|
fulltext_path = entry.get("fulltext_path", "")
|
|
if not fulltext_path and ocr_base:
|
|
fulltext_path = str(ocr_base / "fulltext.md")
|
|
|
|
frontmatter = {}
|
|
if note_path:
|
|
try:
|
|
note_full = self._vault / note_path
|
|
if note_full.exists():
|
|
from paperforge.adapters.obsidian_frontmatter import read_frontmatter_dict
|
|
|
|
frontmatter = read_frontmatter_dict(note_full.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
pass
|
|
|
|
authors_raw = entry.get("authors", "")
|
|
if isinstance(authors_raw, list):
|
|
authors_str = "; ".join(authors_raw)
|
|
else:
|
|
authors_str = str(authors_raw)
|
|
|
|
return PaperWorkspace(
|
|
key=key,
|
|
title=title,
|
|
domain=domain,
|
|
formal_note_path=note_path,
|
|
ocr_path=str(ocr_base) if ocr_base else "",
|
|
fulltext_path=fulltext_path,
|
|
frontmatter=frontmatter,
|
|
year=str(entry.get("year", "")),
|
|
authors=authors_str,
|
|
doi=entry.get("doi", ""),
|
|
journal=entry.get("journal", ""),
|
|
ocr_status=entry.get("ocr_status", ""),
|
|
deep_reading_status=entry.get("deep_reading_status", ""),
|
|
analyze=bool(entry.get("analyze", False)),
|
|
do_ocr=bool(entry.get("do_ocr", False)),
|
|
has_pdf=bool(entry.get("has_pdf", False)),
|
|
)
|
|
|
|
@staticmethod
|
|
def _normalize_doi(doi: str) -> str:
|
|
"""Normalize DOI to lowercase, strip URL prefixes."""
|
|
s = doi.strip().lower()
|
|
s = re.sub(r"^https?://(dx\.)?doi\.org/", "", s)
|
|
return s
|
|
|
|
|
|
def _resolve_ocr_base(paths: dict[str, Path], key: str) -> Path | None:
|
|
"""Get the OCR directory for a given zotero key."""
|
|
ocr_dir = paths.get("ocr")
|
|
if ocr_dir and key:
|
|
candidate = ocr_dir / key
|
|
if candidate.exists():
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def _flatten_authors(authors) -> str:
|
|
"""Convert authors (list or str) to a single searchable string."""
|
|
if isinstance(authors, list):
|
|
return "; ".join(authors)
|
|
return str(authors)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI entry point
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _ok_result(command: str, data: dict) -> str:
|
|
"""Produce a PFResult-compatible JSON string to stdout."""
|
|
from paperforge import __version__
|
|
|
|
result = {
|
|
"ok": True,
|
|
"command": command,
|
|
"version": __version__,
|
|
"data": data,
|
|
"error": None,
|
|
}
|
|
return json.dumps(result, ensure_ascii=False, indent=2)
|
|
|
|
|
|
def _err_result(command: str, message: str) -> str:
|
|
from paperforge import __version__
|
|
|
|
result = {
|
|
"ok": False,
|
|
"command": command,
|
|
"version": __version__,
|
|
"data": None,
|
|
"error": {"code": "INTERNAL_ERROR", "message": message},
|
|
}
|
|
return json.dumps(result, ensure_ascii=False, indent=2)
|
|
|
|
|
|
def _workspace_to_dict(ws: PaperWorkspace) -> dict:
|
|
return {
|
|
"key": ws.key,
|
|
"title": ws.title,
|
|
"domain": ws.domain,
|
|
"year": ws.year,
|
|
"authors": ws.authors,
|
|
"doi": ws.doi,
|
|
"journal": ws.journal,
|
|
"formal_note_path": ws.formal_note_path,
|
|
"ocr_path": ws.ocr_path,
|
|
"fulltext_path": ws.fulltext_path,
|
|
"ocr_status": ws.ocr_status,
|
|
"deep_reading_status": ws.deep_reading_status,
|
|
"analyze": ws.analyze,
|
|
"do_ocr": ws.do_ocr,
|
|
"has_pdf": ws.has_pdf,
|
|
}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="PaperForge paper resolver")
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
|
|
rk = sub.add_parser("resolve-key")
|
|
rk.add_argument("key", help="Zotero key (8-char alphanumeric)")
|
|
rk.add_argument("--vault", required=True, help="Path to vault root")
|
|
|
|
rd = sub.add_parser("resolve-doi")
|
|
rd.add_argument("doi", help="DOI string")
|
|
rd.add_argument("--vault", required=True, help="Path to vault root")
|
|
|
|
sr = sub.add_parser("search")
|
|
sr.add_argument("--title", default=None)
|
|
sr.add_argument("--author", default=None)
|
|
sr.add_argument("--year", default=None)
|
|
sr.add_argument("--domain", default=None)
|
|
sr.add_argument("--limit", type=int, default=20)
|
|
sr.add_argument("--vault", required=True, help="Path to vault root")
|
|
|
|
pt = sub.add_parser("paths")
|
|
pt.add_argument("--vault", required=True, help="Path to vault root")
|
|
|
|
args = parser.parse_args()
|
|
|
|
try:
|
|
vault = Path(args.vault).resolve()
|
|
if not vault.exists():
|
|
print(_err_result(args.command, f"Vault not found: {args.vault}"), file=sys.stdout)
|
|
sys.exit(1)
|
|
|
|
resolver = PaperResolver(vault)
|
|
|
|
if args.command == "resolve-key":
|
|
ws = resolver.resolve_key(args.key)
|
|
if ws:
|
|
print(_ok_result("resolve-key", {"match": _workspace_to_dict(ws)}), file=sys.stdout)
|
|
else:
|
|
print(
|
|
json.dumps({
|
|
"ok": True,
|
|
"command": "resolve-key",
|
|
"data": {"match": None, "message": f"No paper found with key: {args.key}"},
|
|
"error": None,
|
|
}, ensure_ascii=False, indent=2),
|
|
file=sys.stdout,
|
|
)
|
|
|
|
elif args.command == "resolve-doi":
|
|
ws = resolver.resolve_doi(args.doi)
|
|
if ws:
|
|
print(_ok_result("resolve-doi", {"match": _workspace_to_dict(ws)}), file=sys.stdout)
|
|
else:
|
|
print(
|
|
json.dumps({
|
|
"ok": True,
|
|
"command": "resolve-doi",
|
|
"data": {"match": None, "message": f"No paper found with DOI: {args.doi}"},
|
|
"error": None,
|
|
}, ensure_ascii=False, indent=2),
|
|
file=sys.stdout,
|
|
)
|
|
|
|
elif args.command == "search":
|
|
results = resolver.search(
|
|
title=args.title,
|
|
author=args.author,
|
|
year=args.year,
|
|
domain=args.domain,
|
|
limit=args.limit,
|
|
)
|
|
print(
|
|
_ok_result(
|
|
"search",
|
|
{
|
|
"matches": [_workspace_to_dict(ws) for ws in results],
|
|
"count": len(results),
|
|
},
|
|
),
|
|
file=sys.stdout,
|
|
)
|
|
|
|
elif args.command == "paths":
|
|
resolver._ensure_loaded()
|
|
paths_data = {
|
|
"vault_root": str(resolver._vault),
|
|
"index_path": str(resolver._paths.get("index", "")),
|
|
"literature_dir": str(resolver._paths.get("literature", "")),
|
|
"ocr_dir": str(resolver._paths.get("ocr", "")),
|
|
}
|
|
print(_ok_result("paths", paths_data), file=sys.stdout)
|
|
|
|
except Exception as exc:
|
|
print(_err_result(args.command, str(exc)), file=sys.stdout)
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|