mirror of
https://github.com/lllin000/PaperForge.git
synced 2026-07-22 06:50:53 +00:00
1893 lines
80 KiB
Python
1893 lines
80 KiB
Python
from __future__ import annotations
|
||
|
||
import html
|
||
import logging
|
||
import os
|
||
import re
|
||
import urllib.parse
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from xml.etree import ElementTree as ET
|
||
|
||
import requests
|
||
|
||
from paperforge.config import load_vault_config, paperforge_paths
|
||
from paperforge.worker._utils import (
|
||
_extract_year,
|
||
lookup_impact_factor,
|
||
read_json,
|
||
read_jsonl,
|
||
slugify_filename,
|
||
write_json,
|
||
write_jsonl,
|
||
yaml_block,
|
||
yaml_list,
|
||
yaml_quote,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def pipeline_paths(vault: Path) -> dict[str, Path]:
|
||
"""Build complete PaperForge path inventory — delegates to shared resolver.
|
||
|
||
Returns paths from paperforge.config.paperforge_paths() plus
|
||
worker-only keys. Preserves all legacy keys for existing callers.
|
||
"""
|
||
shared = paperforge_paths(vault)
|
||
|
||
root = shared["paperforge"]
|
||
control_root = shared["control"]
|
||
|
||
return {
|
||
**shared,
|
||
# Worker-only keys (added on top of shared resolver output)
|
||
"pipeline": root,
|
||
"candidates": root / "candidates" / "candidates.json",
|
||
"candidate_inbox": root / "candidates" / "inbox",
|
||
"candidate_archive": root / "candidates" / "archive",
|
||
"search_tasks": root / "search" / "tasks",
|
||
"search_archive": root / "search" / "archive",
|
||
"search_results": root / "search" / "results",
|
||
"harvest_root": root / "skill-prototypes" / "zotero-review-manuscript-writer",
|
||
"records": control_root / "candidate-records",
|
||
"review": root / "candidates" / "review-latest.md",
|
||
"config": root / "config" / "domain-collections.json",
|
||
"queue": root / "writeback" / "writeback-queue.jsonl",
|
||
"log": root / "writeback" / "writeback-log.jsonl",
|
||
"bridge_config": root / "zotero-bridge" / "bridge-config.json",
|
||
"bridge_config_sample": root / "zotero-bridge" / "bridge-config.sample.json",
|
||
"index": root / "indexes" / "formal-library.json",
|
||
"ocr_queue": root / "ocr" / "ocr-queue.json",
|
||
}
|
||
|
||
|
||
def load_domain_config(paths: dict[str, Path]) -> dict:
|
||
"""Load or create the Lite domain mapping from export JSON files."""
|
||
config_path = paths["config"]
|
||
config = read_json(config_path) if config_path.exists() else {"domains": []}
|
||
domains = config.setdefault("domains", [])
|
||
known_exports = {str(entry.get("export_file", "")) for entry in domains}
|
||
changed = not config_path.exists()
|
||
for export_path in sorted(paths["exports"].glob("*.json")):
|
||
if export_path.name in known_exports:
|
||
continue
|
||
domains.append({"domain": export_path.stem, "export_file": export_path.name, "allowed_collections": []})
|
||
known_exports.add(export_path.name)
|
||
changed = True
|
||
if changed:
|
||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||
write_json(config_path, config)
|
||
return config
|
||
|
||
|
||
def build_collection_lookup(collections: dict) -> dict:
|
||
path_cache = {}
|
||
item_paths = {}
|
||
|
||
def path_for(key: str) -> str:
|
||
if key in path_cache:
|
||
return path_cache[key]
|
||
node = collections.get(key, {})
|
||
parent = node.get("parent") or ""
|
||
name = node.get("name", "")
|
||
parent_path = path_for(parent) if parent else ""
|
||
full_path = f"{parent_path}/{name}" if parent_path else name
|
||
path_cache[key] = full_path
|
||
return full_path
|
||
|
||
for key, node in collections.items():
|
||
full_path = path_for(key)
|
||
for item_id in node.get("items", []):
|
||
item_paths.setdefault(item_id, []).append(full_path)
|
||
return {"path_by_key": path_cache, "paths_by_item_id": item_paths}
|
||
|
||
|
||
def export_collection_paths(export_path: Path) -> list[str]:
|
||
data = read_json(export_path)
|
||
if not isinstance(data, dict):
|
||
return []
|
||
collections = data.get("collections", {})
|
||
if not isinstance(collections, dict):
|
||
return []
|
||
lookup = build_collection_lookup(collections)
|
||
paths = sorted(
|
||
{path for path in lookup.get("path_by_key", {}).values() if str(path or "").strip()},
|
||
key=lambda value: (value.count("/"), value),
|
||
)
|
||
return paths
|
||
|
||
|
||
def load_domain_collection_catalog(paths: dict[str, Path]) -> dict[str, list[str]]:
|
||
config_path = paths["config"]
|
||
config = read_json(config_path) if config_path.exists() else {"domains": []}
|
||
domain_entries = list(config.get("domains", []))
|
||
entry_by_export = {
|
||
entry.get("export_file", ""): dict(entry) for entry in domain_entries if entry.get("export_file")
|
||
}
|
||
export_files = sorted(paths["exports"].glob("*.json"))
|
||
changed = False
|
||
for export_path in export_files:
|
||
entry = entry_by_export.get(export_path.name)
|
||
if not entry:
|
||
entry = {"domain": export_path.stem, "export_file": export_path.name, "allowed_collections": []}
|
||
entry_by_export[export_path.name] = entry
|
||
changed = True
|
||
derived = export_collection_paths(export_path)
|
||
if entry.get("allowed_collections", []) != derived:
|
||
entry["allowed_collections"] = derived
|
||
changed = True
|
||
domains = sorted(entry_by_export.values(), key=lambda entry: entry.get("domain", ""))
|
||
if changed or config.get("domains", []) != domains:
|
||
write_json(config_path, {"domains": domains})
|
||
return {entry.get("domain", ""): entry.get("allowed_collections", []) for entry in domains if entry.get("domain")}
|
||
|
||
|
||
def load_export_inventory(paths: dict[str, Path]) -> dict[str, dict]:
|
||
inventory = {"doi": {}, "pmid": {}, "title": {}}
|
||
for export_path in sorted(paths["exports"].glob("*.json")):
|
||
domain = export_path.stem
|
||
for item in load_export_rows(export_path):
|
||
record = {
|
||
"zotero_key": item.get("key", ""),
|
||
"domain": domain,
|
||
"title": item.get("title", ""),
|
||
"doi": item.get("doi", ""),
|
||
"pmid": item.get("pmid", ""),
|
||
"collections": item.get("collections", []),
|
||
}
|
||
doi = str(record.get("doi", "") or "").strip().lower()
|
||
pmid = str(record.get("pmid", "") or "").strip()
|
||
title = normalize_candidate_title(record.get("title", ""))
|
||
if doi and doi not in inventory["doi"]:
|
||
inventory["doi"][doi] = record
|
||
if pmid and pmid not in inventory["pmid"]:
|
||
inventory["pmid"][pmid] = record
|
||
if title and title not in inventory["title"]:
|
||
inventory["title"][title] = record
|
||
return inventory
|
||
|
||
|
||
def find_existing_library_match(row: dict, inventory: dict[str, dict]) -> dict | None:
|
||
doi = str(row.get("doi", "") or "").strip().lower()
|
||
if doi and doi in inventory["doi"]:
|
||
return inventory["doi"][doi]
|
||
pmid = str(row.get("pmid", "") or "").strip()
|
||
if pmid and pmid in inventory["pmid"]:
|
||
return inventory["pmid"][pmid]
|
||
title = normalize_candidate_title(row.get("title", ""))
|
||
if title and title in inventory["title"]:
|
||
return inventory["title"][title]
|
||
return None
|
||
|
||
|
||
def resolve_collection_choice(domain: str, raw_value: str, catalog: dict[str, list[str]]) -> dict[str, str]:
|
||
text = str(raw_value or "").strip()
|
||
if not text:
|
||
return {"resolved": "", "match": "", "input": ""}
|
||
allowed = [path for path in catalog.get(domain, []) if path]
|
||
if not allowed:
|
||
return {"resolved": "", "match": "no_catalog", "input": text}
|
||
lower_text = text.lower()
|
||
if "/" not in text:
|
||
leaf_matches = [path for path in allowed if path.split("/")[-1].strip().lower() == lower_text]
|
||
leaf_matches = sorted(set(leaf_matches))
|
||
if len(leaf_matches) > 1:
|
||
return {"resolved": "", "match": "ambiguous_leaf", "input": text}
|
||
exact_map = {path: path for path in allowed}
|
||
if text in exact_map:
|
||
return {"resolved": text, "match": "exact", "input": text}
|
||
lower_exact = {path.lower(): path for path in allowed}
|
||
if lower_text in lower_exact:
|
||
return {"resolved": lower_exact[lower_text], "match": "exact_ci", "input": text}
|
||
leaf_matches = [path for path in allowed if path.split("/")[-1].strip().lower() == lower_text]
|
||
if len(leaf_matches) == 1:
|
||
return {"resolved": leaf_matches[0], "match": "leaf", "input": text}
|
||
suffix_matches = [path for path in allowed if path.lower().endswith("/" + lower_text) or path.lower() == lower_text]
|
||
suffix_matches = sorted(set(suffix_matches))
|
||
if len(suffix_matches) == 1:
|
||
return {"resolved": suffix_matches[0], "match": "suffix", "input": text}
|
||
compact = re.sub("\\s+", "", lower_text)
|
||
compact_matches = []
|
||
for path in allowed:
|
||
path_compact = re.sub("\\s+", "", path.lower())
|
||
if path_compact.endswith("/" + compact) or path_compact == compact:
|
||
compact_matches.append(path)
|
||
compact_matches = sorted(set(compact_matches))
|
||
if len(compact_matches) == 1:
|
||
return {"resolved": compact_matches[0], "match": "compact_suffix", "input": text}
|
||
match = "ambiguous" if leaf_matches or suffix_matches or compact_matches else "unresolved"
|
||
return {"resolved": "", "match": match, "input": text}
|
||
|
||
|
||
def apply_candidate_collection_resolution(row: dict, catalog: dict[str, list[str]]) -> dict:
|
||
resolved = dict(row)
|
||
domain = str(resolved.get("domain", "") or "").strip()
|
||
recommended = resolve_collection_choice(domain, resolved.get("recommended_collection", ""), catalog)
|
||
user = resolve_collection_choice(domain, resolved.get("user_collection", ""), catalog)
|
||
resolved["recommended_collection"] = recommended.get("resolved", "")
|
||
resolved["user_collection_resolved"] = user.get("resolved", "")
|
||
if str(resolved.get("user_collection", "") or "").strip():
|
||
resolved["final_collection"] = user.get("resolved", "")
|
||
resolved["collection_resolution"] = (
|
||
f"user_{user.get('match', 'unresolved')}" if user.get("match") else "user_unresolved"
|
||
)
|
||
else:
|
||
resolved["final_collection"] = recommended.get("resolved", "")
|
||
resolved["collection_resolution"] = (
|
||
f"recommended_{recommended.get('match', 'unresolved')}"
|
||
if recommended.get("match")
|
||
else "recommended_unresolved"
|
||
)
|
||
return resolved
|
||
|
||
|
||
def apply_existing_library_match(row: dict, inventory: dict[str, dict]) -> dict:
|
||
resolved = dict(row)
|
||
match = find_existing_library_match(resolved, inventory)
|
||
if not match:
|
||
resolved["existing_zotero_key"] = ""
|
||
resolved["existing_collections"] = []
|
||
resolved["duplicate_hint"] = ""
|
||
return resolved
|
||
resolved["existing_zotero_key"] = str(match.get("zotero_key", "") or "").strip()
|
||
resolved["existing_collections"] = list(match.get("collections", []) or [])
|
||
collections_text = " | ".join(resolved["existing_collections"])
|
||
if collections_text:
|
||
resolved["duplicate_hint"] = f"已存在于 Zotero: {resolved['existing_zotero_key']} ({collections_text})"
|
||
else:
|
||
resolved["duplicate_hint"] = f"已存在于 Zotero: {resolved['existing_zotero_key']}"
|
||
return resolved
|
||
|
||
|
||
def resolve_item_collection_paths(item: dict, collection_lookup: dict) -> list[str]:
|
||
paths = []
|
||
collection_keys = item.get("collections") or []
|
||
if collection_keys:
|
||
for key in collection_keys:
|
||
paths.append(collection_lookup.get("path_by_key", {}).get(key, key))
|
||
item_id = item.get("itemID")
|
||
if item_id is not None:
|
||
paths.extend(collection_lookup.get("paths_by_item_id", {}).get(item_id, []))
|
||
return sorted({path for path in paths if path}, key=lambda value: (-value.count("/"), value))
|
||
|
||
|
||
def obsidian_wikilink_for_pdf(pdf_path: str, vault_dir: Path, zotero_dir: Path | None = None) -> str:
|
||
text = str(pdf_path or "").strip()
|
||
if not text:
|
||
return ""
|
||
# Handle storage: prefix paths by resolving through zotero_dir
|
||
if text.startswith("storage:") and zotero_dir is not None:
|
||
storage_rel = text[len("storage:") :].lstrip("/").lstrip("\\")
|
||
absolute_pdf_path = (zotero_dir / "storage" / storage_rel.replace("/", os.sep)).resolve()
|
||
absolute_str = str(absolute_pdf_path)
|
||
else:
|
||
absolute_str = absolutize_vault_path(vault_dir, text, resolve_junction=True)
|
||
if not absolute_str:
|
||
return ""
|
||
absolute_path = Path(absolute_str)
|
||
try:
|
||
relative = absolute_path.relative_to(vault_dir)
|
||
except ValueError:
|
||
return f"[[{absolute_path.as_posix()}]]"
|
||
return f"[[{relative.as_posix()}]]"
|
||
|
||
|
||
def absolutize_vault_path(vault: Path, path: str, resolve_junction: bool = False) -> str:
|
||
text = str(path or "").strip()
|
||
if not text:
|
||
return ""
|
||
candidate = Path(text)
|
||
result = str(candidate) if candidate.is_absolute() else str((vault / text.replace("/", os.sep)).resolve())
|
||
if resolve_junction:
|
||
from paperforge.pdf_resolver import resolve_junction
|
||
|
||
result = str(resolve_junction(Path(result)))
|
||
return result
|
||
|
||
|
||
def obsidian_wikilink_for_path(vault: Path, path: str) -> str:
|
||
absolute = absolutize_vault_path(vault, path, resolve_junction=True)
|
||
if not absolute:
|
||
return ""
|
||
absolute_path = Path(absolute)
|
||
try:
|
||
relative = absolute_path.relative_to(vault)
|
||
except ValueError:
|
||
return f"[[{absolute_path.as_posix()}]]"
|
||
return f"[[{relative.as_posix()}]]"
|
||
|
||
|
||
def collection_fields(collection_paths: list[str]) -> dict[str, str | list[str]]:
|
||
paths = [path for path in collection_paths if path]
|
||
primary = paths[0] if paths else ""
|
||
if paths:
|
||
primary = sorted(paths, key=lambda value: (value.count("/"), len(value), value), reverse=True)[0]
|
||
tags = []
|
||
seen = set()
|
||
for path in paths:
|
||
for part in [segment.strip() for segment in path.split("/") if segment.strip()]:
|
||
if part not in seen:
|
||
seen.add(part)
|
||
tags.append(part)
|
||
group = primary
|
||
return {"collections": paths, "collection_tags": tags, "collection_group": [group] if group else []}
|
||
|
||
|
||
def extract_authors(item: dict) -> list[str]:
|
||
authors = []
|
||
for creator in item.get("creators", []):
|
||
if creator.get("creatorType") != "author":
|
||
continue
|
||
full_name = " ".join(
|
||
part for part in [creator.get("firstName", ""), creator.get("lastName", "")] if part
|
||
).strip()
|
||
if full_name:
|
||
authors.append(full_name)
|
||
elif creator.get("name"):
|
||
authors.append(creator["name"])
|
||
return authors
|
||
|
||
|
||
def _normalize_attachment_path(path: str, zotero_dir: Path | None = None) -> tuple[str, str, str]:
|
||
"""Normalize a BBT attachment path to a consistent storage: format.
|
||
|
||
Handles three real-world BBT export formats:
|
||
1. Absolute Windows paths: D:\\...\\Zotero\\storage\\8CHARKEY\\filename.pdf
|
||
-> storage:8CHARKEY/filename.pdf
|
||
2. storage: prefix: storage:KEY/filename.pdf -> pass through
|
||
3. Bare relative: KEY/filename.pdf -> storage:KEY/filename.pdf
|
||
|
||
Args:
|
||
path: Raw path from BBT JSON attachment.
|
||
zotero_dir: Optional absolute path to Zotero data directory for
|
||
validating absolute paths.
|
||
|
||
Returns:
|
||
Tuple of (normalized_path, bbt_path_raw, zotero_storage_key).
|
||
normalized_path uses forward slashes and storage: prefix for
|
||
Zotero storage paths. bbt_path_raw preserves the original input
|
||
for debugging. zotero_storage_key is the 8-character Zotero key.
|
||
"""
|
||
raw = str(path or "").strip()
|
||
if not raw:
|
||
return ("", "", "")
|
||
|
||
bbt_path_raw = raw
|
||
|
||
# Format 2: Already has storage: prefix — pass through with slash normalization
|
||
if raw.startswith("storage:"):
|
||
storage_rel = raw[len("storage:") :].lstrip("/").lstrip("\\")
|
||
storage_rel = storage_rel.replace("\\", "/")
|
||
parts = storage_rel.split("/")
|
||
zotero_storage_key = parts[0] if parts else ""
|
||
return (f"storage:{storage_rel}", bbt_path_raw, zotero_storage_key)
|
||
|
||
# Format 1: Absolute Windows path pointing to Zotero storage
|
||
candidate = Path(raw)
|
||
if candidate.is_absolute():
|
||
norm_path = raw.replace("\\", "/")
|
||
# Detect Zotero storage pattern: .../storage/8CHARKEY/...
|
||
if "/storage/" in norm_path:
|
||
parts_after_storage = norm_path.split("/storage/", 1)[1]
|
||
parts = parts_after_storage.split("/")
|
||
if len(parts) >= 2 and len(parts[0]) == 8 and parts[0].isalnum():
|
||
zotero_storage_key = parts[0]
|
||
filename = "/".join(parts[1:])
|
||
return (f"storage:{zotero_storage_key}/{filename}", bbt_path_raw, zotero_storage_key)
|
||
# Absolute path but not in Zotero storage — mark as absolute
|
||
return (f"absolute:{raw}", bbt_path_raw, "")
|
||
|
||
# Format 3: Bare relative path — prepend storage: prefix
|
||
norm = raw.replace("\\", "/")
|
||
parts = norm.split("/")
|
||
zotero_storage_key = parts[0] if parts else ""
|
||
return (f"storage:{norm}", bbt_path_raw, zotero_storage_key)
|
||
|
||
|
||
def _identify_main_pdf(attachments: list[dict]) -> tuple[dict | None, list[dict]]:
|
||
"""Identify the main PDF and supplementary materials from attachments.
|
||
|
||
Uses a hybrid three-priority strategy (Decision D-02):
|
||
1. Primary: attachment.title == "PDF" AND contentType == "application/pdf"
|
||
2. Fallback heuristic: largest file by size (if available), else shortest title
|
||
3. Final fallback: first PDF attachment in the list
|
||
|
||
Args:
|
||
attachments: List of attachment dicts from load_export_rows().
|
||
|
||
Returns:
|
||
Tuple of (main_pdf_attachment, supplementary_attachments).
|
||
main_pdf_attachment may be None if no PDFs found.
|
||
supplementary_attachments is a list of all other PDF attachments.
|
||
"""
|
||
pdf_attachments = [a for a in attachments if isinstance(a, dict) and a.get("contentType") == "application/pdf"]
|
||
|
||
if not pdf_attachments:
|
||
return (None, [])
|
||
|
||
# Priority 1: Title exactly equals "PDF"
|
||
for att in pdf_attachments:
|
||
if att.get("title") == "PDF":
|
||
main = att
|
||
supplementary = [a for a in pdf_attachments if a is not main]
|
||
return (main, supplementary)
|
||
|
||
# Priority 2: Largest file by size (if size field is available and differentiated)
|
||
sized = [(a, a.get("size", 0) or 0) for a in pdf_attachments]
|
||
sized.sort(key=lambda x: x[1], reverse=True)
|
||
if sized and sized[0][1] > 0 and (len(sized) == 1 or sized[0][1] > sized[1][1]):
|
||
main = sized[0][0]
|
||
supplementary = [a for a in pdf_attachments if a is not main]
|
||
return (main, supplementary)
|
||
|
||
# Priority 2b (sizes equal or unavailable): shortest title
|
||
titled = [(a, len(str(a.get("title", "")))) for a in pdf_attachments]
|
||
titled.sort(key=lambda x: x[1])
|
||
main = titled[0][0]
|
||
supplementary = [a for a in pdf_attachments if a is not main]
|
||
return (main, supplementary)
|
||
|
||
|
||
def load_export_rows(path: Path) -> list[dict]:
|
||
data = read_json(path)
|
||
if isinstance(data, list):
|
||
return data
|
||
if isinstance(data, dict) and isinstance(data.get("items"), list):
|
||
collection_lookup = build_collection_lookup(data.get("collections", {}))
|
||
rows = []
|
||
for item in data["items"]:
|
||
if item.get("itemType") in {"attachment", "note", "annotation"}:
|
||
continue
|
||
attachments = []
|
||
for attachment in item.get("attachments", []):
|
||
if not isinstance(attachment, dict):
|
||
continue
|
||
raw_path = attachment.get("path", "")
|
||
normalized_path, bbt_path_raw, zotero_storage_key = _normalize_attachment_path(raw_path)
|
||
# Preserve contentType from BBT if present; fallback to file extension
|
||
content_type = attachment.get("contentType", "")
|
||
if not content_type and str(normalized_path).lower().endswith(".pdf"):
|
||
content_type = "application/pdf"
|
||
attachments.append(
|
||
{
|
||
"path": normalized_path,
|
||
"contentType": content_type,
|
||
"title": attachment.get("title", ""),
|
||
"bbt_path_raw": bbt_path_raw,
|
||
"zotero_storage_key": zotero_storage_key,
|
||
"size": attachment.get("size", 0) or 0,
|
||
}
|
||
)
|
||
main_pdf, supplementary_pdfs = _identify_main_pdf(attachments)
|
||
pdf_path = main_pdf["path"] if main_pdf else ""
|
||
bbt_path_raw = main_pdf["bbt_path_raw"] if main_pdf else ""
|
||
zotero_storage_key = main_pdf["zotero_storage_key"] if main_pdf else ""
|
||
path_error = "not_found" if not main_pdf else ""
|
||
supplementary = [a["path"] for a in supplementary_pdfs] if supplementary_pdfs else []
|
||
attachment_count = len(attachments)
|
||
rows.append(
|
||
{
|
||
"key": item.get("key") or item.get("itemKey", ""),
|
||
"title": item.get("title", ""),
|
||
"authors": extract_authors(item),
|
||
"creators": item.get("creators", []),
|
||
"abstract": item.get("abstractNote", ""),
|
||
"journal": item.get("publicationTitle", ""),
|
||
"extra": item.get("extra", ""),
|
||
"year": _extract_year(item.get("date", "")),
|
||
"date": item.get("date", ""),
|
||
"doi": item.get("DOI", ""),
|
||
"pmid": item.get("PMID", ""),
|
||
"collections": resolve_item_collection_paths(item, collection_lookup),
|
||
"attachments": attachments,
|
||
"pdf_path": pdf_path,
|
||
"supplementary": supplementary,
|
||
"attachment_count": attachment_count,
|
||
"bbt_path_raw": bbt_path_raw,
|
||
"zotero_storage_key": zotero_storage_key,
|
||
"path_error": path_error,
|
||
}
|
||
)
|
||
return rows
|
||
raise ValueError(f"Unsupported export format: {path}")
|
||
|
||
|
||
def compute_final_collection(row: dict) -> str:
|
||
user_raw = str(row.get("user_collection", "") or "").strip()
|
||
user_resolved = str(row.get("user_collection_resolved", "") or "").strip()
|
||
recommended = str(row.get("recommended_collection", "") or "").strip()
|
||
if user_raw:
|
||
return user_resolved
|
||
return recommended
|
||
|
||
|
||
def canonicalize_decision(value: str) -> str:
|
||
text = str(value or "").strip()
|
||
if text in {"", "待查"}:
|
||
return "待定"
|
||
if text in {"排除", "不纳入"}:
|
||
return "不纳入"
|
||
if text == "纳入":
|
||
return "纳入"
|
||
return "待定"
|
||
|
||
|
||
def candidate_markdown(row: dict) -> str:
|
||
row = dict(row)
|
||
row["final_collection"] = compute_final_collection(row)
|
||
row["decision"] = canonicalize_decision(row.get("decision", ""))
|
||
lines = ["---"]
|
||
ordered_keys = [
|
||
"candidate_id",
|
||
"domain",
|
||
"title",
|
||
"authors",
|
||
"year",
|
||
"journal",
|
||
"doi",
|
||
"pmid",
|
||
"source",
|
||
"requester_skill",
|
||
"request_context",
|
||
"abstract_short",
|
||
"decision",
|
||
"recommended_collection",
|
||
"recommend_confidence",
|
||
"recommend_reason",
|
||
"user_collection",
|
||
"user_collection_resolved",
|
||
"final_collection",
|
||
"collection_resolution",
|
||
"duplicate_hint",
|
||
"existing_zotero_key",
|
||
"existing_collections",
|
||
"import_status",
|
||
"note",
|
||
"candidate_source_type",
|
||
"source_zotero_key",
|
||
"cited_ref_number",
|
||
"trigger_sentence",
|
||
"source_context",
|
||
"task_relevance_reason",
|
||
"harvest_priority",
|
||
"raw_reference",
|
||
"status",
|
||
]
|
||
row.setdefault("status", "candidate")
|
||
for key in ordered_keys:
|
||
value = row.get(key, "")
|
||
if isinstance(value, list):
|
||
lines.append(f"{key}:")
|
||
for item in value:
|
||
lines.append(f" - {yaml_quote(item)}")
|
||
elif value == "":
|
||
lines.append(f"{key}:")
|
||
elif "\n" in str(value):
|
||
lines.extend(
|
||
yaml_block(str(value)).copy()
|
||
if key == "abstract"
|
||
else [f"{key}: |-"] + [f" {line}" for line in str(value).splitlines()]
|
||
)
|
||
else:
|
||
lines.append(f"{key}: {yaml_quote(value)}")
|
||
lines.extend(
|
||
[
|
||
"---",
|
||
"",
|
||
f"# {row['candidate_id']}",
|
||
"",
|
||
"候选文献轻量记录,仅用于 Base 决策和 write-back 触发,不是正式文献卡片。",
|
||
"",
|
||
]
|
||
)
|
||
return "\n".join(lines)
|
||
|
||
|
||
def generate_review(candidates: list[dict]) -> str:
|
||
normalized = []
|
||
for row in candidates:
|
||
copy = dict(row)
|
||
copy["decision"] = canonicalize_decision(copy.get("decision", ""))
|
||
normalized.append(copy)
|
||
include = [c for c in normalized if c.get("decision") == "纳入"]
|
||
exclude = [c for c in normalized if c.get("decision") == "不纳入"]
|
||
lines = [
|
||
"# 本轮候选总览",
|
||
"",
|
||
"## 检索背景",
|
||
"",
|
||
f"- 候选数量:{len(normalized)}",
|
||
f"- 建议纳入:{len(include)}",
|
||
f"- 不纳入:{len(exclude)}",
|
||
"",
|
||
"## 总体判断",
|
||
"",
|
||
"- 当前候选池已经按决策状态分层,可直接进入 Base 处理。",
|
||
"",
|
||
"## 推荐优先纳入",
|
||
"",
|
||
]
|
||
if include:
|
||
for row in include:
|
||
lines.extend(
|
||
[
|
||
f"### {row['candidate_id']}",
|
||
"",
|
||
f"- 标题:{row['title']}",
|
||
f"- 推荐分类:`{compute_final_collection(row)}`",
|
||
f"- 理由:{row.get('recommend_reason', '')}",
|
||
"",
|
||
]
|
||
)
|
||
else:
|
||
lines.extend(["- 暂无", ""])
|
||
lines.extend(["## 不纳入", ""])
|
||
if exclude:
|
||
for row in exclude:
|
||
lines.extend(
|
||
[
|
||
f"### {row['candidate_id']}",
|
||
"",
|
||
f"- 标题:{row['title']}",
|
||
f"- 理由:{row.get('recommend_reason', '')}",
|
||
"",
|
||
]
|
||
)
|
||
else:
|
||
lines.extend(["- 暂无", ""])
|
||
lines.extend(["## 下一步", "", "1. 在 Base 中确认决策。", "2. 对纳入项执行 write-back。", "3. 刷新正式索引。", ""])
|
||
return "\n".join(lines)
|
||
|
||
|
||
DEEP_READING_HEADER = "## 🔍 精读"
|
||
|
||
|
||
def extract_preserved_deep_reading(text: str) -> str:
|
||
"""Extract the `## 🔍 精读` section by matching it as a real markdown header.
|
||
|
||
Uses regex to ensure we match `## 🔍 精读` at the start of a line,
|
||
avoiding false positives from prose text that merely mentions the string.
|
||
"""
|
||
if not text:
|
||
return ""
|
||
match = re.search("^## 🔍 精读\\s*$", text, re.MULTILINE)
|
||
if not match:
|
||
return ""
|
||
start = match.start()
|
||
preserved = text[start:].strip()
|
||
return preserved
|
||
|
||
|
||
def has_deep_reading_content(text: str) -> bool:
|
||
"""Return True only if the deep-reading section contains *substantive* content.
|
||
|
||
A scaffold alone (filled with placeholders like '(待补充)') does NOT count.
|
||
We strip out structural lines (section headers, callout headers, empty lists)
|
||
and placeholder text, then require at least one prose sentence or 20 chars
|
||
of actual content.
|
||
"""
|
||
preserved = extract_preserved_deep_reading(text)
|
||
if not preserved:
|
||
return False
|
||
body = preserved.replace(DEEP_READING_HEADER, "").strip()
|
||
if not body:
|
||
return False
|
||
lines = body.splitlines()
|
||
non_placeholder_chars = 0
|
||
has_prose_sentence = False
|
||
for line in lines:
|
||
stripped = line.strip()
|
||
if not stripped:
|
||
continue
|
||
if stripped.startswith("### "):
|
||
continue
|
||
if re.match("^>\\s*\\[!", stripped):
|
||
continue
|
||
if "(待补充)" in stripped:
|
||
continue
|
||
if re.match("^[-*]\\s*$", stripped):
|
||
continue
|
||
non_placeholder_chars += len(stripped)
|
||
if re.search("[\\u4e00-\\u9fff]", stripped) and re.search("[。!?\\.\\!\\?]$", stripped):
|
||
has_prose_sentence = True
|
||
return has_prose_sentence or non_placeholder_chars >= 20
|
||
|
||
|
||
def library_record_markdown(row: dict) -> str:
|
||
lines = [
|
||
"---",
|
||
f"zotero_key: {row.get('zotero_key', '')}",
|
||
f"domain: {row.get('domain', '')}",
|
||
f"title: {yaml_quote(row.get('title', ''))}",
|
||
f"year: {row.get('year', '')}",
|
||
f"doi: {yaml_quote(row.get('doi', ''))}",
|
||
f"date: {yaml_quote(row.get('date', ''))}",
|
||
f"collection_path: {yaml_quote(row.get('collection_path', ''))}",
|
||
f"has_pdf: {('true' if row.get('has_pdf') else 'false')}",
|
||
f"pdf_path: {yaml_quote(row.get('pdf_path', ''))}",
|
||
f"bbt_path_raw: {yaml_quote(row.get('bbt_path_raw', ''))}",
|
||
f"zotero_storage_key: {yaml_quote(row.get('zotero_storage_key', ''))}",
|
||
f"attachment_count: {row.get('attachment_count', 0)}",
|
||
]
|
||
# supplementary as YAML list of wikilinks (already formatted by caller)
|
||
supplementary = row.get("supplementary", [])
|
||
if supplementary:
|
||
lines.append("supplementary:")
|
||
for wikilink in supplementary:
|
||
lines.append(f" - {yaml_quote(wikilink)}")
|
||
else:
|
||
lines.append("supplementary: []")
|
||
# path_error only emitted when there is an actual error
|
||
if row.get("path_error"):
|
||
lines.append(f"path_error: {yaml_quote(row.get('path_error', ''))}")
|
||
lines.extend(
|
||
[
|
||
f"fulltext_md_path: {yaml_quote(row.get('fulltext_md_path', ''))}",
|
||
f"recommend_analyze: {('true' if row.get('recommend_analyze') else 'false')}",
|
||
f"analyze: {('true' if row.get('analyze') else 'false')}",
|
||
f"do_ocr: {('true' if row.get('do_ocr') else 'false')}",
|
||
f"ocr_status: {yaml_quote(row.get('ocr_status', 'pending'))}",
|
||
f"deep_reading_status: {yaml_quote(row.get('deep_reading_status', 'pending'))}",
|
||
f"analysis_note: {yaml_quote(row.get('analysis_note', ''))}",
|
||
]
|
||
)
|
||
lines.extend(yaml_list("collection_group", row.get("collection_group", [])))
|
||
lines.extend(yaml_list("collections", row.get("collections", [])))
|
||
lines.extend(yaml_list("collection_tags", row.get("collection_tags", [])))
|
||
lines.append(f"first_author: {yaml_quote(row.get('first_author', ''))}")
|
||
lines.append(f"journal: {yaml_quote(row.get('journal', ''))}")
|
||
lines.append(f"impact_factor: {yaml_quote(row.get('impact_factor', ''))}")
|
||
lines.extend(
|
||
[
|
||
"---",
|
||
"",
|
||
f"# {row.get('title', '')}",
|
||
"",
|
||
"正式库控制记录。",
|
||
"",
|
||
"- `recommend_analyze` 仅由 `has_pdf=true` 推导。",
|
||
"- `analyze` 控制是否生成正式文献卡片。",
|
||
"- `do_ocr` 控制 OCR 任务。",
|
||
"- `deep_reading_status` 仅两级:`pending`(未精读)/ `done`(已精读)。",
|
||
"",
|
||
]
|
||
)
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _add_missing_frontmatter_fields(existing_content: str, new_fields: dict[str, str]) -> str:
|
||
"""Surgically append missing fields to existing frontmatter without overwriting anything."""
|
||
if not existing_content.startswith("---"):
|
||
return existing_content
|
||
parts = existing_content.split("---", 2)
|
||
if len(parts) < 3:
|
||
return existing_content
|
||
frontmatter = parts[1]
|
||
body = parts[2]
|
||
lines_to_add = []
|
||
for key, value in new_fields.items():
|
||
pattern = "^" + re.escape(key) + "\\s*:"
|
||
if not re.search(pattern, frontmatter, re.MULTILINE):
|
||
lines_to_add.append(f"{key}: {yaml_quote(value)}")
|
||
if not lines_to_add:
|
||
return existing_content
|
||
new_frontmatter = frontmatter.rstrip("\n") + "\n" + "\n".join(lines_to_add) + "\n"
|
||
return f"---{new_frontmatter}---{body}"
|
||
|
||
|
||
def update_frontmatter_field(content: str, key: str, value: str) -> str:
|
||
"""Update an existing frontmatter field value, or add if missing."""
|
||
if not content.startswith("---"):
|
||
return content
|
||
pattern = "^" + re.escape(key) + "\\s*:.*$"
|
||
replacement = f"{key}: {yaml_quote(value)}"
|
||
new_content, count = re.subn(pattern, replacement, content, flags=re.MULTILINE, count=1)
|
||
if count == 0:
|
||
new_content = _add_missing_frontmatter_fields(content, {key: value})
|
||
return new_content
|
||
|
||
|
||
def parse_existing_library_record(path: Path) -> dict:
|
||
if not path.exists():
|
||
return {}
|
||
text = path.read_text(encoding="utf-8")
|
||
result = {}
|
||
for key in ("analyze", "recommend_analyze", "do_ocr"):
|
||
match = re.search(f"^{key}:\\s*(true|false)$", text, re.MULTILINE)
|
||
if match:
|
||
result[key] = match.group(1) == "true"
|
||
for key in ("ocr_status", "analysis_note"):
|
||
match = re.search(f'^{key}:\\s*"?(.*?)"?$', text, re.MULTILINE)
|
||
if match:
|
||
result[key] = match.group(1)
|
||
for key in ("deep_reading_status",):
|
||
match = re.search(f'^{key}:\\s*"?(.*?)"?$', text, re.MULTILINE)
|
||
if match:
|
||
result[key] = match.group(1)
|
||
return result
|
||
|
||
|
||
def load_control_actions(paths: dict[str, Path]) -> dict[str, dict]:
|
||
actions = {}
|
||
if not paths["library_records"].exists():
|
||
return actions
|
||
for record in paths["library_records"].rglob("*.md"):
|
||
text = record.read_text(encoding="utf-8")
|
||
key_match = re.search("^zotero_key:\\s*(.+)$", text, re.MULTILINE)
|
||
if not key_match:
|
||
continue
|
||
zotero_key = key_match.group(1).strip()
|
||
row = parse_existing_library_record(record)
|
||
actions[zotero_key] = {"analyze": row.get("analyze", False), "do_ocr": row.get("do_ocr", False)}
|
||
return actions
|
||
|
||
|
||
def run_selection_sync(vault: Path, verbose: bool = False) -> int:
|
||
from paperforge.worker.base_views import ensure_base_views
|
||
from paperforge.worker.ocr import validate_ocr_meta
|
||
|
||
paths = pipeline_paths(vault)
|
||
config = load_domain_config(paths)
|
||
ensure_base_views(vault, paths, config)
|
||
domain_lookup = {entry["export_file"]: entry["domain"] for entry in config["domains"]}
|
||
written = 0
|
||
updated = 0
|
||
for export_path in sorted(paths["exports"].glob("*.json")):
|
||
domain = domain_lookup.get(export_path.name, export_path.stem)
|
||
for item in load_export_rows(export_path):
|
||
pdf_attachments = [a for a in item.get("attachments", []) if a.get("contentType") == "application/pdf"]
|
||
has_pdf = bool(pdf_attachments)
|
||
raw_pdf_path = pdf_attachments[0].get("path", "") if pdf_attachments else ""
|
||
from paperforge.pdf_resolver import resolve_pdf_path
|
||
|
||
cfg = load_vault_config(vault)
|
||
zotero_dir = vault / cfg.get("system_dir", "99_System") / "Zotero"
|
||
resolved_pdf = resolve_pdf_path(raw_pdf_path, has_pdf, vault, zotero_dir)
|
||
collection_meta = collection_fields(item.get("collections", []))
|
||
record_dir = paths["library_records"] / domain
|
||
record_dir.mkdir(parents=True, exist_ok=True)
|
||
record_path = record_dir / f"{item['key']}.md"
|
||
existing = parse_existing_library_record(record_path)
|
||
meta_path = paths["ocr"] / item["key"] / "meta.json"
|
||
meta = read_json(meta_path) if meta_path.exists() else {}
|
||
validated_ocr_status, validated_error = validate_ocr_meta(paths, meta) if meta else ("pending", "")
|
||
if meta:
|
||
meta["ocr_status"] = validated_ocr_status
|
||
if validated_error:
|
||
meta["error"] = validated_error
|
||
write_json(meta_path, meta)
|
||
note_path = paths["literature"] / domain / f"{item['key']} - {slugify_filename(item['title'])}.md"
|
||
note_text = note_path.read_text(encoding="utf-8") if note_path.exists() else ""
|
||
fulltext_md_path = obsidian_wikilink_for_path(
|
||
vault, meta.get("fulltext_md_path", "") or meta.get("markdown_path", "")
|
||
)
|
||
ocr_status = meta.get("ocr_status", "pending")
|
||
record_ocr_status = "nopdf" if not has_pdf or not resolved_pdf else ocr_status
|
||
creators = item.get("creators", [])
|
||
first_author = ""
|
||
for c in creators:
|
||
if c.get("creatorType") == "author":
|
||
first_author = f"{c.get('firstName', '')} {c.get('lastName', '')}".strip()
|
||
break
|
||
journal = item.get("journal", "")
|
||
extra = item.get("extra", "")
|
||
impact_factor = lookup_impact_factor(journal, extra, vault)
|
||
# Convert supplementary storage: paths to wikilinks
|
||
supplementary_wikilinks = []
|
||
for supp_path in item.get("supplementary", []):
|
||
if supp_path:
|
||
wikilink = obsidian_wikilink_for_pdf(supp_path, vault, zotero_dir)
|
||
if wikilink:
|
||
supplementary_wikilinks.append(wikilink)
|
||
pdf_wikilink = obsidian_wikilink_for_pdf(resolved_pdf, vault, zotero_dir) if resolved_pdf else ""
|
||
content = library_record_markdown(
|
||
{
|
||
"zotero_key": item["key"],
|
||
"domain": domain,
|
||
"title": item.get("title", ""),
|
||
"year": item.get("year", ""),
|
||
"doi": item.get("doi", ""),
|
||
"date": item.get("date", ""),
|
||
"collection_path": " | ".join(item.get("collections", [])),
|
||
"collections": collection_meta.get("collections", []),
|
||
"collection_tags": collection_meta.get("collection_tags", []),
|
||
"collection_group": collection_meta.get("collection_group", []),
|
||
"has_pdf": has_pdf,
|
||
"pdf_path": pdf_wikilink,
|
||
"bbt_path_raw": item.get("bbt_path_raw", ""),
|
||
"zotero_storage_key": item.get("zotero_storage_key", ""),
|
||
"attachment_count": item.get("attachment_count", 0),
|
||
"supplementary": supplementary_wikilinks,
|
||
"path_error": item.get("path_error", ""),
|
||
"recommend_analyze": bool(pdf_attachments),
|
||
"analyze": existing.get("analyze", False),
|
||
"do_ocr": existing.get("do_ocr", False),
|
||
"ocr_status": record_ocr_status,
|
||
"fulltext_md_path": fulltext_md_path,
|
||
"deep_reading_status": "done" if note_text and has_deep_reading_content(note_text) else "pending",
|
||
"analysis_note": existing.get("analysis_note", ""),
|
||
"first_author": first_author,
|
||
"journal": journal,
|
||
"impact_factor": impact_factor,
|
||
}
|
||
)
|
||
if record_path.exists():
|
||
existing_content = record_path.read_text(encoding="utf-8")
|
||
updated_content = _add_missing_frontmatter_fields(
|
||
existing_content,
|
||
{
|
||
"first_author": first_author,
|
||
"journal": journal,
|
||
"impact_factor": impact_factor,
|
||
"bbt_path_raw": item.get("bbt_path_raw", ""),
|
||
"zotero_storage_key": item.get("zotero_storage_key", ""),
|
||
"attachment_count": str(item.get("attachment_count", 0)),
|
||
"path_error": item.get("path_error", ""),
|
||
},
|
||
)
|
||
updated_content = update_frontmatter_field(updated_content, "has_pdf", has_pdf)
|
||
updated_content = update_frontmatter_field(updated_content, "pdf_path", pdf_wikilink)
|
||
updated_content = update_frontmatter_field(updated_content, "ocr_status", record_ocr_status)
|
||
updated_content = update_frontmatter_field(
|
||
updated_content,
|
||
"deep_reading_status",
|
||
"done" if note_text and has_deep_reading_content(note_text) else "pending",
|
||
)
|
||
updated_content = update_frontmatter_field(updated_content, "fulltext_md_path", fulltext_md_path or "")
|
||
if updated_content != existing_content:
|
||
record_path.write_text(updated_content, encoding="utf-8")
|
||
updated += 1
|
||
else:
|
||
written += 1
|
||
record_path.write_text(content, encoding="utf-8")
|
||
print(f"selection-sync: wrote {written} records, updated {updated} records")
|
||
return 0
|
||
|
||
|
||
def load_candidates_by_id(paths: dict[str, Path]) -> dict[str, dict]:
|
||
candidates = read_json(paths["candidates"])
|
||
return {row["candidate_id"]: row for row in candidates}
|
||
|
||
|
||
def save_candidates(paths: dict[str, Path], candidate_map: dict[str, dict]) -> None:
|
||
collection_catalog = load_domain_collection_catalog(paths)
|
||
export_inventory = load_export_inventory(paths)
|
||
rows = []
|
||
for row in candidate_map.values():
|
||
copy = dict(row)
|
||
copy["decision"] = canonicalize_decision(copy.get("decision", ""))
|
||
copy = apply_candidate_collection_resolution(copy, collection_catalog)
|
||
copy = apply_existing_library_match(copy, export_inventory)
|
||
copy["final_collection"] = compute_final_collection(copy)
|
||
rows.append(copy)
|
||
write_json(paths["candidates"], rows)
|
||
|
||
|
||
def writeback_command_for_candidate(row: dict) -> dict | None:
|
||
final_collection = str(row.get("final_collection", "") or "").strip()
|
||
if not final_collection:
|
||
return None
|
||
candidate_id = str(row.get("candidate_id", "") or "").strip()
|
||
if not candidate_id:
|
||
return None
|
||
command = {
|
||
"command_id": f"wb-native-{candidate_id}",
|
||
"status": "queued",
|
||
"source_candidate_id": candidate_id,
|
||
"target_domain": str(row.get("domain", "") or "").strip(),
|
||
"target_collection": final_collection,
|
||
"requested_at": datetime.now(timezone.utc).isoformat(),
|
||
}
|
||
existing_zotero_key = str(row.get("existing_zotero_key", "") or "").strip()
|
||
if existing_zotero_key:
|
||
command.update({"action": "attach_existing_item_to_collection", "existing_zotero_key": existing_zotero_key})
|
||
return command
|
||
doi = str(row.get("doi", "") or "").strip()
|
||
pmid = str(row.get("pmid", "") or "").strip()
|
||
if doi:
|
||
command.update(
|
||
{
|
||
"action": "create_item_from_identifier",
|
||
"identifier_type": "doi",
|
||
"identifier": doi,
|
||
"metadata_fallback": {
|
||
"title": row.get("title", ""),
|
||
"authors": row.get("authors", []),
|
||
"year": str(row.get("year", "") or ""),
|
||
"journal": row.get("journal", ""),
|
||
"doi": doi,
|
||
"pmid": pmid,
|
||
"abstractNote": row.get("abstract_short", ""),
|
||
},
|
||
}
|
||
)
|
||
return command
|
||
if pmid:
|
||
command.update(
|
||
{
|
||
"action": "create_item_from_identifier",
|
||
"identifier_type": "pmid",
|
||
"identifier": pmid,
|
||
"metadata_fallback": {
|
||
"title": row.get("title", ""),
|
||
"authors": row.get("authors", []),
|
||
"year": str(row.get("year", "") or ""),
|
||
"journal": row.get("journal", ""),
|
||
"doi": doi,
|
||
"pmid": pmid,
|
||
"abstractNote": row.get("abstract_short", ""),
|
||
},
|
||
}
|
||
)
|
||
return command
|
||
command.update(
|
||
{
|
||
"action": "create_item_from_metadata",
|
||
"metadata": {
|
||
"title": row.get("title", ""),
|
||
"authors": row.get("authors", []),
|
||
"year": str(row.get("year", "") or ""),
|
||
"journal": row.get("journal", ""),
|
||
"doi": doi,
|
||
"pmid": pmid,
|
||
"abstractNote": row.get("abstract_short", ""),
|
||
},
|
||
}
|
||
)
|
||
return command
|
||
|
||
|
||
def sync_writeback_queue(paths: dict[str, Path], candidate_map: dict[str, dict]) -> tuple[list[dict], int]:
|
||
existing_rows = read_jsonl(paths["queue"])
|
||
existing_by_candidate = {
|
||
str(row.get("source_candidate_id", "") or "").strip(): dict(row)
|
||
for row in existing_rows
|
||
if str(row.get("source_candidate_id", "") or "").strip()
|
||
}
|
||
queue_rows: list[dict] = []
|
||
queued_candidates: set[str] = set()
|
||
created = 0
|
||
for candidate_id, row in candidate_map.items():
|
||
decision = canonicalize_decision(row.get("decision", ""))
|
||
if decision != "纳入":
|
||
continue
|
||
if str(row.get("import_status", "") or "").strip() == "imported":
|
||
continue
|
||
candidate_copy = dict(row)
|
||
candidate_copy["final_collection"] = compute_final_collection(candidate_copy)
|
||
final_collection = str(candidate_copy.get("final_collection", "") or "").strip()
|
||
if not final_collection:
|
||
candidate_copy["import_status"] = "needs_collection_resolution"
|
||
candidate_map[candidate_id] = candidate_copy
|
||
continue
|
||
command = writeback_command_for_candidate(candidate_copy)
|
||
if not command:
|
||
candidate_copy["import_status"] = "blocked"
|
||
candidate_map[candidate_id] = candidate_copy
|
||
continue
|
||
existing = existing_by_candidate.get(candidate_id)
|
||
if existing and str(existing.get("status", "") or "").strip() in {"queued", "running", "processed"}:
|
||
merged = dict(existing)
|
||
merged["target_collection"] = command["target_collection"]
|
||
merged["target_domain"] = command.get("target_domain", merged.get("target_domain", ""))
|
||
if merged.get("status") != "processed":
|
||
merged["requested_at"] = command["requested_at"]
|
||
queue_rows.append(merged)
|
||
else:
|
||
queue_rows.append(command)
|
||
created += 1
|
||
candidate_copy["import_status"] = "queued_for_writeback"
|
||
candidate_map[candidate_id] = candidate_copy
|
||
queued_candidates.add(candidate_id)
|
||
for row in existing_rows:
|
||
candidate_id = str(row.get("source_candidate_id", "") or "").strip()
|
||
status = str(row.get("status", "") or "").strip()
|
||
if candidate_id in queued_candidates:
|
||
continue
|
||
if status == "processed":
|
||
queue_rows.append(row)
|
||
write_jsonl(paths["queue"], queue_rows)
|
||
return (queue_rows, created)
|
||
|
||
|
||
def load_bridge_config(paths: dict[str, Path]) -> dict:
|
||
config_path = paths["bridge_config"]
|
||
if not config_path.exists():
|
||
sample = read_json(paths["bridge_config_sample"])
|
||
write_json(config_path, sample)
|
||
return read_json(config_path)
|
||
|
||
|
||
def apply_writeback_log(paths: dict[str, Path], candidate_map: dict[str, dict]) -> int:
|
||
log_rows = read_jsonl(paths["log"])
|
||
changed = 0
|
||
latest_by_candidate: dict[str, dict] = {}
|
||
for row in log_rows:
|
||
candidate_id = str(row.get("source_candidate_id", "") or "").strip()
|
||
if candidate_id:
|
||
latest_by_candidate[candidate_id] = row
|
||
for candidate_id, log_row in latest_by_candidate.items():
|
||
candidate = dict(candidate_map.get(candidate_id, {}))
|
||
if not candidate:
|
||
continue
|
||
status = str(log_row.get("status", "") or "").strip()
|
||
if status == "success":
|
||
candidate["import_status"] = "imported"
|
||
candidate["zotero_key"] = str(log_row.get("zotero_key", "") or "").strip()
|
||
changed += 1
|
||
elif status == "error":
|
||
candidate["import_status"] = "writeback_error"
|
||
changed += 1
|
||
candidate_map[candidate_id] = candidate
|
||
return changed
|
||
|
||
|
||
def invoke_native_bridge(paths: dict[str, Path], max_commands: int = 5) -> dict:
|
||
config = load_bridge_config(paths)
|
||
base_url = str(config.get("server_base_url", "http://127.0.0.1:23119")).rstrip("/")
|
||
endpoint = str(config.get("process_endpoint", "/literaturePipeline/processQueue"))
|
||
payload = {
|
||
"queuePath": str(paths["queue"]).replace("\\", "/"),
|
||
"logPath": str(paths["log"]).replace("\\", "/"),
|
||
"configPath": str(paths["bridge_config"]).replace("\\", "/"),
|
||
"maxCommands": max_commands,
|
||
}
|
||
response = requests.post(f"{base_url}{endpoint}", json=payload, timeout=30)
|
||
response.raise_for_status()
|
||
result = response.json()
|
||
if not result.get("ok", False):
|
||
raise RuntimeError(result.get("error", "Native bridge returned non-ok result"))
|
||
return result
|
||
|
||
|
||
def normalize_candidate_title(text: str) -> str:
|
||
return re.sub("\\s+", " ", str(text or "").strip().lower())
|
||
|
||
|
||
def candidate_identity_keys(row: dict) -> dict[str, str]:
|
||
doi = str(row.get("doi", "") or "").strip().lower()
|
||
pmid = str(row.get("pmid", "") or "").strip()
|
||
title = normalize_candidate_title(row.get("title", ""))
|
||
return {"doi": doi, "pmid": pmid, "title": title}
|
||
|
||
|
||
def candidate_id_from_payload(row: dict) -> str:
|
||
source = str(row.get("source", "") or "candidate").strip().lower()
|
||
doi = re.sub("[^a-z0-9]+", "-", str(row.get("doi", "") or "").strip().lower()).strip("-")
|
||
pmid = re.sub("[^0-9]+", "", str(row.get("pmid", "") or "").strip())
|
||
fallback = re.sub("[^a-z0-9]+", "-", normalize_candidate_title(row.get("title", ""))).strip("-")[:80]
|
||
suffix = doi or pmid or fallback or datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
|
||
return f"{source}-{suffix}"
|
||
|
||
|
||
def _normalize_candidate_value(value):
|
||
if value is None:
|
||
return ""
|
||
if isinstance(value, list):
|
||
return [str(item).strip() for item in value if str(item).strip()]
|
||
return str(value).strip()
|
||
|
||
|
||
def _authors_from_pubmed(value) -> list[str] | str:
|
||
if isinstance(value, list):
|
||
return [str(item).strip() for item in value if str(item).strip()]
|
||
return str(value or "").strip()
|
||
|
||
|
||
def _authors_from_openalex(value) -> list[str]:
|
||
authors = []
|
||
if isinstance(value, list):
|
||
for item in value:
|
||
if isinstance(item, dict):
|
||
author = item.get("author") or {}
|
||
name = author.get("display_name") or item.get("display_name") or ""
|
||
if name:
|
||
authors.append(str(name).strip())
|
||
elif str(item).strip():
|
||
authors.append(str(item).strip())
|
||
return authors
|
||
|
||
|
||
def _abstract_from_openalex(inverted_index) -> str:
|
||
if not isinstance(inverted_index, dict):
|
||
return ""
|
||
tokens: list[tuple[int, str]] = []
|
||
for word, positions in inverted_index.items():
|
||
if not isinstance(positions, list):
|
||
continue
|
||
for pos in positions:
|
||
try:
|
||
tokens.append((int(pos), str(word)))
|
||
except Exception:
|
||
continue
|
||
if not tokens:
|
||
return ""
|
||
tokens.sort(key=lambda item: item[0])
|
||
return " ".join((word for _, word in tokens))
|
||
|
||
|
||
def _authors_from_arxiv(value) -> list[str]:
|
||
authors = []
|
||
if isinstance(value, list):
|
||
for item in value:
|
||
if isinstance(item, dict):
|
||
name = item.get("name", "") or item.get("author", "")
|
||
if name:
|
||
authors.append(str(name).strip())
|
||
elif str(item).strip():
|
||
authors.append(str(item).strip())
|
||
return authors
|
||
|
||
|
||
def _authors_from_scholar(value) -> list[str]:
|
||
if isinstance(value, list):
|
||
return [str(item).strip() for item in value if str(item).strip()]
|
||
if isinstance(value, str):
|
||
parts = re.split("\\s*,\\s*|\\s+and\\s+", value)
|
||
return [part.strip() for part in parts if part.strip()]
|
||
return []
|
||
|
||
|
||
def adapt_pubmed_candidate(row: dict) -> dict:
|
||
payload = dict(row.get("payload") or {})
|
||
adapted = {
|
||
"candidate_id": row.get("candidate_id") or f"pubmed-{payload.get('pmid') or payload.get('PMID') or ''}",
|
||
"domain": row.get("domain", ""),
|
||
"title": payload.get("title", ""),
|
||
"authors": _authors_from_pubmed(payload.get("authors", [])),
|
||
"year": payload.get("year", ""),
|
||
"journal": payload.get("journal", ""),
|
||
"doi": payload.get("doi", ""),
|
||
"pmid": payload.get("pmid") or payload.get("PMID", ""),
|
||
"source": row.get("source", "") or "pubmed_search",
|
||
"requester_skill": row.get("requester_skill", ""),
|
||
"request_context": row.get("request_context", ""),
|
||
"abstract_short": payload.get("abstract", "") or payload.get("abstract_short", ""),
|
||
"decision": row.get("decision", ""),
|
||
"recommended_collection": row.get("recommended_collection", ""),
|
||
"recommend_confidence": row.get("recommend_confidence", ""),
|
||
"recommend_reason": row.get("recommend_reason", ""),
|
||
"user_collection": row.get("user_collection", ""),
|
||
"final_collection": row.get("final_collection", ""),
|
||
"duplicate_hint": row.get("duplicate_hint", ""),
|
||
"import_status": row.get("import_status", ""),
|
||
"note": row.get("note", ""),
|
||
"candidate_source_type": row.get("candidate_source_type", "") or "external_search",
|
||
"source_context": row.get("source_context", ""),
|
||
"task_relevance_reason": row.get("task_relevance_reason", ""),
|
||
}
|
||
return adapted
|
||
|
||
|
||
def adapt_openalex_candidate(row: dict) -> dict:
|
||
payload = dict(row.get("payload") or {})
|
||
primary_location = payload.get("primary_location") or {}
|
||
source_info = primary_location.get("source") or {}
|
||
openalex_id = str(payload.get("id", "") or "").rstrip("/").split("/")[-1]
|
||
adapted = {
|
||
"candidate_id": row.get("candidate_id") or f"openalex-{openalex_id}",
|
||
"domain": row.get("domain", ""),
|
||
"title": payload.get("display_name", "") or payload.get("title", ""),
|
||
"authors": _authors_from_openalex(payload.get("authorships", [])),
|
||
"year": payload.get("publication_year", "") or payload.get("year", ""),
|
||
"journal": source_info.get("display_name", "") or payload.get("journal", ""),
|
||
"doi": str(payload.get("doi", "") or "").replace("https://doi.org/", ""),
|
||
"pmid": "",
|
||
"source": row.get("source", "") or "openalex_search",
|
||
"requester_skill": row.get("requester_skill", ""),
|
||
"request_context": row.get("request_context", ""),
|
||
"abstract_short": _abstract_from_openalex(payload.get("abstract_inverted_index")),
|
||
"decision": row.get("decision", ""),
|
||
"recommended_collection": row.get("recommended_collection", ""),
|
||
"recommend_confidence": row.get("recommend_confidence", ""),
|
||
"recommend_reason": row.get("recommend_reason", ""),
|
||
"user_collection": row.get("user_collection", ""),
|
||
"final_collection": row.get("final_collection", ""),
|
||
"duplicate_hint": row.get("duplicate_hint", ""),
|
||
"import_status": row.get("import_status", ""),
|
||
"note": row.get("note", ""),
|
||
"candidate_source_type": row.get("candidate_source_type", "") or "external_search",
|
||
"source_context": row.get("source_context", ""),
|
||
"task_relevance_reason": row.get("task_relevance_reason", ""),
|
||
}
|
||
return adapted
|
||
|
||
|
||
def adapt_arxiv_candidate(row: dict) -> dict:
|
||
payload = dict(row.get("payload") or {})
|
||
arxiv_id = str(payload.get("id", "") or payload.get("entry_id", "")).rstrip("/").split("/")[-1]
|
||
adapted = {
|
||
"candidate_id": row.get("candidate_id") or f"arxiv-{arxiv_id}",
|
||
"domain": row.get("domain", ""),
|
||
"title": payload.get("title", ""),
|
||
"authors": _authors_from_arxiv(payload.get("authors", [])),
|
||
"year": str(payload.get("published", "") or "")[:4],
|
||
"journal": payload.get("journal_ref", "") or "arXiv",
|
||
"doi": payload.get("doi", ""),
|
||
"pmid": "",
|
||
"source": row.get("source", "") or "arxiv_search",
|
||
"requester_skill": row.get("requester_skill", ""),
|
||
"request_context": row.get("request_context", ""),
|
||
"abstract_short": payload.get("summary", "") or payload.get("abstract", ""),
|
||
"decision": row.get("decision", ""),
|
||
"recommended_collection": row.get("recommended_collection", ""),
|
||
"recommend_confidence": row.get("recommend_confidence", ""),
|
||
"recommend_reason": row.get("recommend_reason", ""),
|
||
"user_collection": row.get("user_collection", ""),
|
||
"final_collection": row.get("final_collection", ""),
|
||
"duplicate_hint": row.get("duplicate_hint", ""),
|
||
"import_status": row.get("import_status", ""),
|
||
"note": row.get("note", ""),
|
||
"candidate_source_type": row.get("candidate_source_type", "") or "external_search",
|
||
"source_context": row.get("source_context", ""),
|
||
"task_relevance_reason": row.get("task_relevance_reason", ""),
|
||
}
|
||
return adapted
|
||
|
||
|
||
def adapt_google_scholar_candidate(row: dict) -> dict:
|
||
payload = dict(row.get("payload") or {})
|
||
adapted = {
|
||
"candidate_id": row.get("candidate_id")
|
||
or f"google-scholar-{re.sub('[^a-z0-9]+', '-', normalize_candidate_title(payload.get('title', ''))).strip('-')[:80]}",
|
||
"domain": row.get("domain", ""),
|
||
"title": payload.get("title", ""),
|
||
"authors": _authors_from_scholar(payload.get("authors", [])),
|
||
"year": str(payload.get("year", "") or ""),
|
||
"journal": payload.get("journal", "") or payload.get("venue", ""),
|
||
"doi": payload.get("doi", ""),
|
||
"pmid": "",
|
||
"source": row.get("source", "") or "google_scholar_search",
|
||
"requester_skill": row.get("requester_skill", ""),
|
||
"request_context": row.get("request_context", ""),
|
||
"abstract_short": payload.get("abstract", "") or payload.get("snippet", ""),
|
||
"decision": row.get("decision", ""),
|
||
"recommended_collection": row.get("recommended_collection", ""),
|
||
"recommend_confidence": row.get("recommend_confidence", ""),
|
||
"recommend_reason": row.get("recommend_reason", ""),
|
||
"user_collection": row.get("user_collection", ""),
|
||
"final_collection": row.get("final_collection", ""),
|
||
"duplicate_hint": row.get("duplicate_hint", ""),
|
||
"import_status": row.get("import_status", ""),
|
||
"note": row.get("note", ""),
|
||
"candidate_source_type": row.get("candidate_source_type", "") or "external_search",
|
||
"source_context": row.get("source_context", ""),
|
||
"task_relevance_reason": row.get("task_relevance_reason", ""),
|
||
}
|
||
return adapted
|
||
|
||
|
||
def adapt_candidate_event(row: dict) -> dict:
|
||
adapter = str(row.get("adapter", "") or "").strip()
|
||
if adapter == "pubmed_search":
|
||
return adapt_pubmed_candidate(row)
|
||
if adapter == "openalex_search":
|
||
return adapt_openalex_candidate(row)
|
||
if adapter == "arxiv_search":
|
||
return adapt_arxiv_candidate(row)
|
||
if adapter == "google_scholar_search":
|
||
return adapt_google_scholar_candidate(row)
|
||
return dict(row)
|
||
|
||
|
||
def default_user_agent() -> str:
|
||
return "ResearchLiteraturePipeline/1.0 (+local-vault)"
|
||
|
||
|
||
def build_search_event(base_task: dict, source: str, payload: dict) -> dict:
|
||
return {
|
||
"adapter": source,
|
||
"payload": payload,
|
||
"source": source,
|
||
"requester_skill": base_task.get("requester_skill", ""),
|
||
"request_context": base_task.get("request_context", ""),
|
||
"domain": base_task.get("domain", ""),
|
||
"recommended_collection": base_task.get("recommended_collection", ""),
|
||
"recommend_confidence": base_task.get("recommend_confidence", ""),
|
||
"recommend_reason": base_task.get("recommend_reason", "") or f"{source} 检索命中",
|
||
"candidate_source_type": base_task.get("candidate_source_type", "") or "external_search",
|
||
"task_relevance_reason": base_task.get("task_relevance_reason", ""),
|
||
"note": base_task.get("note", ""),
|
||
}
|
||
|
||
|
||
def _pubmed_abstract_and_doi_map(xml_text: str) -> dict[str, dict]:
|
||
root = ET.fromstring(xml_text)
|
||
result = {}
|
||
for article in root.findall(".//PubmedArticle"):
|
||
pmid = (article.findtext(".//MedlineCitation/PMID") or "").strip()
|
||
if not pmid:
|
||
continue
|
||
abstract_parts = []
|
||
for node in article.findall(".//Abstract/AbstractText"):
|
||
label = (node.attrib.get("Label") or "").strip()
|
||
text = " ".join("".join(node.itertext()).split())
|
||
if not text:
|
||
continue
|
||
abstract_parts.append(f"{label}: {text}" if label else text)
|
||
doi = ""
|
||
for id_node in article.findall(".//PubmedData/ArticleIdList/ArticleId"):
|
||
if (id_node.attrib.get("IdType") or "").lower() == "doi":
|
||
doi = "".join(id_node.itertext()).strip()
|
||
if doi:
|
||
break
|
||
if not doi:
|
||
for id_node in article.findall(".//ELocationID"):
|
||
if (id_node.attrib.get("EIdType") or "").lower() == "doi":
|
||
doi = "".join(id_node.itertext()).strip()
|
||
if doi:
|
||
break
|
||
result[pmid] = {"abstract": "\n".join(abstract_parts).strip(), "doi": doi}
|
||
return result
|
||
|
||
|
||
def search_pubmed(task: dict, limit: int) -> tuple[list[dict], dict]:
|
||
query = str(task.get("query", "") or "").strip()
|
||
if not query:
|
||
return ([], {"count": 0, "ids": []})
|
||
base_url = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
|
||
headers = {"User-Agent": os.environ.get("LIT_PIPELINE_USER_AGENT", default_user_agent())}
|
||
common_params = {}
|
||
email = os.environ.get("NCBI_EMAIL", "").strip()
|
||
api_key = os.environ.get("NCBI_API_KEY", "").strip()
|
||
if email:
|
||
common_params["email"] = email
|
||
if api_key:
|
||
common_params["api_key"] = api_key
|
||
esearch = requests.get(
|
||
f"{base_url}/esearch.fcgi",
|
||
params={
|
||
"db": "pubmed",
|
||
"retmode": "json",
|
||
"sort": "relevance",
|
||
"term": query,
|
||
"retmax": limit,
|
||
**common_params,
|
||
},
|
||
headers=headers,
|
||
timeout=60,
|
||
)
|
||
esearch.raise_for_status()
|
||
search_payload = esearch.json().get("esearchresult", {})
|
||
ids = [str(item).strip() for item in search_payload.get("idlist", []) if str(item).strip()]
|
||
if not ids:
|
||
return ([], {"count": 0, "ids": []})
|
||
id_text = ",".join(ids)
|
||
esummary = requests.get(
|
||
f"{base_url}/esummary.fcgi",
|
||
params={"db": "pubmed", "retmode": "json", "id": id_text, **common_params},
|
||
headers=headers,
|
||
timeout=60,
|
||
)
|
||
esummary.raise_for_status()
|
||
summary_payload = esummary.json().get("result", {})
|
||
efetch = requests.get(
|
||
f"{base_url}/efetch.fcgi",
|
||
params={"db": "pubmed", "retmode": "xml", "id": id_text, **common_params},
|
||
headers=headers,
|
||
timeout=90,
|
||
)
|
||
efetch.raise_for_status()
|
||
abstract_map = _pubmed_abstract_and_doi_map(efetch.text)
|
||
rows = []
|
||
for pmid in ids:
|
||
item = summary_payload.get(pmid, {}) or {}
|
||
title = html.unescape(str(item.get("title", "") or "")).strip()
|
||
if not title:
|
||
continue
|
||
article_ids = item.get("articleids", []) or []
|
||
doi = ""
|
||
for article_id in article_ids:
|
||
if str(article_id.get("idtype", "")).lower() == "doi":
|
||
doi = str(article_id.get("value", "")).strip()
|
||
if doi:
|
||
break
|
||
if not doi:
|
||
doi = abstract_map.get(pmid, {}).get("doi", "")
|
||
rows.append(
|
||
{
|
||
"pmid": pmid,
|
||
"title": title,
|
||
"authors": [author.get("name", "") for author in item.get("authors") or [] if author.get("name")],
|
||
"year": _extract_year(str(item.get("pubdate", "") or "")),
|
||
"journal": item.get("fulljournalname", "") or item.get("source", ""),
|
||
"doi": doi,
|
||
"abstract": abstract_map.get(pmid, {}).get("abstract", ""),
|
||
}
|
||
)
|
||
return (rows, {"count": len(rows), "ids": ids})
|
||
|
||
|
||
def search_openalex(task: dict, limit: int) -> tuple[list[dict], dict]:
|
||
query = str(task.get("query", "") or "").strip()
|
||
if not query:
|
||
return ([], {"count": 0})
|
||
headers = {"User-Agent": os.environ.get("LIT_PIPELINE_USER_AGENT", default_user_agent())}
|
||
params = {"search": query, "per-page": limit}
|
||
api_key = os.environ.get("OPENALEX_API_KEY", "").strip()
|
||
if api_key:
|
||
params["api_key"] = api_key
|
||
mailto = os.environ.get("OPENALEX_MAILTO", "").strip()
|
||
if mailto:
|
||
params["mailto"] = mailto
|
||
response = requests.get("https://api.openalex.org/works", params=params, headers=headers, timeout=60)
|
||
response.raise_for_status()
|
||
payload = response.json()
|
||
results = payload.get("results", []) or []
|
||
return (results, {"count": len(results), "meta": payload.get("meta", {})})
|
||
|
||
|
||
def search_arxiv(task: dict, limit: int) -> tuple[list[dict], dict]:
|
||
query = str(task.get("query", "") or "").strip()
|
||
if not query:
|
||
return ([], {"count": 0})
|
||
encoded_query = urllib.parse.quote(f"all:{query}")
|
||
url = f"https://export.arxiv.org/api/query?search_query={encoded_query}&start=0&max_results={limit}&sortBy=relevance&sortOrder=descending"
|
||
headers = {"User-Agent": os.environ.get("LIT_PIPELINE_USER_AGENT", default_user_agent())}
|
||
response = requests.get(url, headers=headers, timeout=60)
|
||
response.raise_for_status()
|
||
ns = {"atom": "http://www.w3.org/2005/Atom", "arxiv": "http://arxiv.org/schemas/atom"}
|
||
root = ET.fromstring(response.text)
|
||
entries = []
|
||
for entry in root.findall("atom:entry", ns):
|
||
entries.append(
|
||
{
|
||
"id": (entry.findtext("atom:id", default="", namespaces=ns) or "").strip(),
|
||
"title": " ".join((entry.findtext("atom:title", default="", namespaces=ns) or "").split()),
|
||
"summary": " ".join((entry.findtext("atom:summary", default="", namespaces=ns) or "").split()),
|
||
"published": (entry.findtext("atom:published", default="", namespaces=ns) or "").strip(),
|
||
"authors": [
|
||
{"name": (node.findtext("atom:name", default="", namespaces=ns) or "").strip()}
|
||
for node in entry.findall("atom:author", ns)
|
||
],
|
||
"doi": (entry.findtext("arxiv:doi", default="", namespaces=ns) or "").strip(),
|
||
"journal_ref": (entry.findtext("arxiv:journal_ref", default="", namespaces=ns) or "").strip(),
|
||
}
|
||
)
|
||
return (entries, {"count": len(entries)})
|
||
|
||
|
||
def _coerce_source_name(value: str) -> str:
|
||
text = str(value or "").strip().lower()
|
||
aliases = {
|
||
"pubmed": "pubmed_search",
|
||
"pubmed_search": "pubmed_search",
|
||
"openalex": "openalex_search",
|
||
"openalex_search": "openalex_search",
|
||
"arxiv": "arxiv_search",
|
||
"arxiv_search": "arxiv_search",
|
||
}
|
||
return aliases.get(text, text)
|
||
|
||
|
||
def run_search_command(vault: Path, args) -> int:
|
||
paths = pipeline_paths(vault)
|
||
paths["search_tasks"].mkdir(parents=True, exist_ok=True)
|
||
task_id = f"search-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
||
sources = args.sources or ["pubmed_search", "openalex_search", "arxiv_search"]
|
||
task = {
|
||
"task_id": task_id,
|
||
"query": args.query,
|
||
"domain": args.domain,
|
||
"recommended_collection": args.recommended_collection or "",
|
||
"requester_skill": args.requester_skill or "",
|
||
"request_context": args.request_context or "",
|
||
"sources": sources,
|
||
"limit": args.limit,
|
||
"recommend_reason": args.recommend_reason or "围绕检索主题补充候选文献",
|
||
"candidate_source_type": "external_search",
|
||
}
|
||
task_path = paths["search_tasks"] / f"{task_id}.json"
|
||
write_json(task_path, task)
|
||
print(f"search: task written -> {task_path}")
|
||
code = run_search_sources(vault)
|
||
if code:
|
||
return code
|
||
if not args.skip_ingest:
|
||
return run_ingest_candidates(vault)
|
||
return 0
|
||
|
||
|
||
def normalize_candidate_payload(row: dict) -> dict:
|
||
normalized = {key: _normalize_candidate_value(value) for key, value in adapt_candidate_event(row).items()}
|
||
normalized["candidate_id"] = str(normalized.get("candidate_id", "") or "").strip() or candidate_id_from_payload(
|
||
normalized
|
||
)
|
||
normalized["title"] = str(normalized.get("title", "") or "").strip()
|
||
normalized["domain"] = str(normalized.get("domain", "") or "").strip()
|
||
normalized["source"] = str(normalized.get("source", "") or "").strip() or "candidate_ingest"
|
||
normalized["candidate_source_type"] = (
|
||
str(normalized.get("candidate_source_type", "") or "").strip() or normalized["source"]
|
||
)
|
||
normalized["decision"] = canonicalize_decision(normalized.get("decision", ""))
|
||
normalized["import_status"] = str(normalized.get("import_status", "") or "").strip() or "pending"
|
||
normalized["recommend_confidence"] = str(normalized.get("recommend_confidence", "") or "").strip() or "0"
|
||
normalized["status"] = "candidate"
|
||
return normalized
|
||
|
||
|
||
def merge_candidate_record(existing: dict | None, incoming: dict) -> dict:
|
||
merged = dict(existing or {})
|
||
preserve_if_existing = {"decision", "user_collection", "note", "import_status"}
|
||
for key, value in incoming.items():
|
||
if existing and key in preserve_if_existing:
|
||
current = merged.get(key, "")
|
||
if str(current).strip():
|
||
continue
|
||
merged[key] = value
|
||
merged["decision"] = canonicalize_decision(merged.get("decision", ""))
|
||
merged["final_collection"] = compute_final_collection(merged)
|
||
return merged
|
||
|
||
|
||
def resolve_existing_candidate(candidate_map: dict[str, dict], incoming: dict) -> tuple[str | None, dict | None]:
|
||
candidate_id = incoming.get("candidate_id", "")
|
||
if candidate_id and candidate_id in candidate_map:
|
||
return (candidate_id, candidate_map[candidate_id])
|
||
incoming_keys = candidate_identity_keys(incoming)
|
||
for existing_id, existing in candidate_map.items():
|
||
existing_keys = candidate_identity_keys(existing)
|
||
if incoming_keys["doi"] and incoming_keys["doi"] == existing_keys["doi"]:
|
||
return (existing_id, existing)
|
||
if incoming_keys["pmid"] and incoming_keys["pmid"] == existing_keys["pmid"]:
|
||
return (existing_id, existing)
|
||
if incoming_keys["title"] and incoming_keys["title"] == existing_keys["title"]:
|
||
return (existing_id, existing)
|
||
return (None, None)
|
||
|
||
|
||
def _harvest_csv_paths(paths: dict[str, Path]) -> list[Path]:
|
||
root = paths["harvest_root"]
|
||
if not root.exists():
|
||
return []
|
||
return sorted(root.rglob("*-05-reference-harvest-candidates.csv"))
|
||
|
||
|
||
def _normalize_harvest_value(value: str) -> str:
|
||
text = str(value or "").strip()
|
||
if text == "<inherit-from-task-context>":
|
||
return ""
|
||
return text
|
||
|
||
|
||
def _normalize_harvest_row(row: dict[str, str]) -> dict:
|
||
normalized = normalize_candidate_payload({key: _normalize_harvest_value(value) for key, value in row.items()})
|
||
normalized["source"] = normalized.get("source", "") or "reference_harvest"
|
||
normalized["candidate_source_type"] = normalized.get("candidate_source_type", "") or "reference_harvest"
|
||
return normalized
|
||
|
||
|
||
def _merge_harvest_candidate(existing: dict | None, incoming: dict) -> dict:
|
||
return merge_candidate_record(existing, incoming)
|
||
|
||
|
||
def next_key(domain: str, export_rows: list[dict]) -> str:
|
||
prefix = "ORTHO" if domain == "骨科" else "SPORT"
|
||
existing = [row.get("key", "") for row in export_rows]
|
||
max_num = 0
|
||
for key in existing:
|
||
if key.startswith(prefix):
|
||
suffix = key[len(prefix) :]
|
||
if suffix.isdigit():
|
||
max_num = max(max_num, int(suffix))
|
||
return f"{prefix}{max_num + 1:03d}"
|
||
|
||
|
||
def frontmatter_note(entry: dict, existing_text: str = "") -> str:
|
||
deep_reading_path = entry.get("deep_reading_md_path", "")
|
||
preserved_deep = extract_preserved_deep_reading(existing_text)
|
||
lines = [
|
||
"---",
|
||
f"title: {yaml_quote(entry['title'])}",
|
||
f"year: {entry.get('year', '')}",
|
||
"type: article",
|
||
f"journal: {yaml_quote(entry.get('journal', ''))}",
|
||
"authors:",
|
||
]
|
||
for author in entry.get("authors", []):
|
||
lines.append(f" - {yaml_quote(author)}")
|
||
lines.extend(
|
||
[
|
||
f"collection_path: {yaml_quote(entry.get('collection_path', ''))}",
|
||
f"domain: {yaml_quote(entry.get('domain', ''))}",
|
||
f"zotero_key: {yaml_quote(entry.get('zotero_key', ''))}",
|
||
f"doi: {yaml_quote(entry.get('doi', ''))}",
|
||
f"pmid: {yaml_quote(entry.get('pmid', ''))}",
|
||
]
|
||
)
|
||
lines.extend(yaml_list("collection_group", entry.get("collection_group", [])))
|
||
lines.extend(yaml_list("collections", entry.get("collections", [])))
|
||
lines.extend(yaml_list("collection_tags", entry.get("collection_tags", [])))
|
||
lines.extend(yaml_block(entry.get("abstract", "")))
|
||
lines.extend(
|
||
[
|
||
f"has_pdf: {('true' if entry.get('has_pdf') else 'false')}",
|
||
f"ocr_status: {yaml_quote(entry.get('ocr_status', 'pending'))}",
|
||
f"ocr_job_id: {yaml_quote(entry.get('ocr_job_id', ''))}",
|
||
f"ocr_md_path: {yaml_quote(entry.get('ocr_md_path', ''))}",
|
||
f"ocr_json_path: {yaml_quote(entry.get('ocr_json_path', ''))}",
|
||
f"deep_reading_status: {yaml_quote(entry.get('deep_reading_status', 'pending'))}",
|
||
f"deep_reading_md_path: {yaml_quote(deep_reading_path)}",
|
||
f"pdf_path: {yaml_quote(entry.get('pdf_path', ''))}",
|
||
"tags:",
|
||
" - 文献阅读",
|
||
f" - {entry.get('domain', '')}",
|
||
"---",
|
||
"",
|
||
f"# {entry['title']}",
|
||
"",
|
||
"## 📄 文献基本信息",
|
||
"",
|
||
f"- Zotero Key: `{entry.get('zotero_key', '')}`",
|
||
f"- Collection: `{entry.get('collection_path', '')}`",
|
||
f"- 作者:{', '.join(entry.get('authors', []))}",
|
||
f"- PDF: {('已检测' if entry.get('has_pdf') else '未检测到')}",
|
||
f"- OCR: {entry.get('ocr_status', 'pending')}",
|
||
f"- 精读: {entry.get('deep_reading_status', 'pending')}",
|
||
"",
|
||
"## 摘要",
|
||
"",
|
||
entry.get("abstract", "") or "暂无摘要",
|
||
"",
|
||
"## 💡 文献内容总结",
|
||
"",
|
||
"- 由 sync worker 自动生成的正式文献卡片。",
|
||
"- 精读笔记(Deep Reading)仅由 /pf-deep 命令维护;sync --index 只保留已有内容,不自动生成。",
|
||
"- 如需精读,请在 Base 中勾选 analyze,OCR 完成后运行 /pf-deep <zotero_key>。",
|
||
"",
|
||
]
|
||
)
|
||
if preserved_deep:
|
||
lines.extend(["", preserved_deep, ""])
|
||
return "\n".join(lines)
|
||
|
||
|
||
def analyze_selected_keys(paths: dict[str, Path]) -> set[str]:
|
||
return {key for key, row in load_control_actions(paths).items() if row.get("analyze")}
|
||
|
||
|
||
def run_index_refresh(vault: Path, verbose: bool = False) -> int:
|
||
from paperforge.worker.base_views import ensure_base_views
|
||
from paperforge.worker.ocr import validate_ocr_meta
|
||
|
||
paths = pipeline_paths(vault)
|
||
config = load_domain_config(paths)
|
||
ensure_base_views(vault, paths, config)
|
||
domain_lookup = {entry["export_file"]: entry["domain"] for entry in config["domains"]}
|
||
|
||
cfg = load_vault_config(vault)
|
||
zotero_dir = vault / cfg.get("system_dir", "99_System") / "Zotero"
|
||
exports = {}
|
||
for export_path in sorted(paths["exports"].glob("*.json")):
|
||
domain = domain_lookup.get(export_path.name, export_path.stem)
|
||
export_rows = load_export_rows(export_path)
|
||
exports[domain] = {row["key"]: row for row in export_rows}
|
||
selected_keys = None
|
||
index_rows = []
|
||
lit_root = paths["literature"]
|
||
for export_path in sorted(paths["exports"].glob("*.json")):
|
||
domain = domain_lookup.get(export_path.name, export_path.stem)
|
||
export_rows = load_export_rows(export_path)
|
||
for item in export_rows:
|
||
key = item["key"]
|
||
if selected_keys is not None and key not in selected_keys:
|
||
continue
|
||
collection_meta = collection_fields(item.get("collections", []))
|
||
pdf_attachments = [a for a in item.get("attachments", []) if a.get("contentType") == "application/pdf"]
|
||
meta_path = paths["ocr"] / key / "meta.json"
|
||
meta = read_json(meta_path) if meta_path.exists() else {}
|
||
if meta:
|
||
validated_ocr_status, validated_error = validate_ocr_meta(paths, meta)
|
||
meta["ocr_status"] = validated_ocr_status
|
||
if validated_error:
|
||
meta["error"] = validated_error
|
||
write_json(meta_path, meta)
|
||
title_slug = slugify_filename(item["title"])
|
||
note_path = lit_root / domain / f"{key} - {title_slug}.md"
|
||
if note_path.parent.exists():
|
||
for stale_note in note_path.parent.glob(f"{key} - *.md"):
|
||
if stale_note != note_path:
|
||
stale_note.unlink()
|
||
entry = {
|
||
"zotero_key": key,
|
||
"domain": domain,
|
||
"title": item["title"],
|
||
"authors": item.get("authors", []),
|
||
"abstract": item.get("abstract", ""),
|
||
"journal": item.get("journal", ""),
|
||
"year": item.get("year", ""),
|
||
"doi": item.get("doi", ""),
|
||
"pmid": item.get("pmid", ""),
|
||
"collection_path": " | ".join(item.get("collections", [])),
|
||
"collections": collection_meta.get("collections", []),
|
||
"collection_tags": collection_meta.get("collection_tags", []),
|
||
"collection_group": collection_meta.get("collection_group", []),
|
||
"has_pdf": bool(pdf_attachments),
|
||
"pdf_path": obsidian_wikilink_for_pdf(pdf_attachments[0]["path"], vault, zotero_dir)
|
||
if pdf_attachments
|
||
else "",
|
||
"ocr_status": meta.get("ocr_status", "pending"),
|
||
"ocr_job_id": meta.get("ocr_job_id", ""),
|
||
"ocr_md_path": obsidian_wikilink_for_path(vault, meta.get("markdown_path", "")),
|
||
"ocr_json_path": meta.get("json_path", ""),
|
||
"deep_reading_status": "done"
|
||
if note_path.exists() and has_deep_reading_content(note_path.read_text(encoding="utf-8"))
|
||
else "pending",
|
||
"note_path": str(note_path.relative_to(vault)).replace("\\", "/"),
|
||
"deep_reading_md_path": str(note_path.relative_to(vault)).replace("\\", "/")
|
||
if note_path.exists() and has_deep_reading_content(note_path.read_text(encoding="utf-8"))
|
||
else "",
|
||
}
|
||
note_path.parent.mkdir(parents=True, exist_ok=True)
|
||
existing_text = note_path.read_text(encoding="utf-8") if note_path.exists() else ""
|
||
note_path.write_text(frontmatter_note(entry, existing_text), encoding="utf-8")
|
||
index_rows.append(entry)
|
||
write_json(paths["index"], index_rows)
|
||
print(f"index-refresh: wrote {len(index_rows)} index rows")
|
||
control_records_dir = paths["library_records"]
|
||
if control_records_dir.exists():
|
||
for domain_dir in control_records_dir.iterdir():
|
||
if not domain_dir.is_dir():
|
||
continue
|
||
domain = domain_dir.name
|
||
domain_export_keys = set(exports.get(domain, {}).keys())
|
||
records_by_title = {}
|
||
records_info = {}
|
||
for record_file in domain_dir.glob("*.md"):
|
||
try:
|
||
content = record_file.read_text(encoding="utf-8")
|
||
title_match = re.search("^title:\\s*[\"\\']?(.+)[\"\\']?\\s*$", content, re.MULTILINE)
|
||
title = title_match.group(1) if title_match else ""
|
||
has_pdf = "has_pdf: true" in content
|
||
normalized = re.sub("[^a-z0-9]", "", title.lower())[:20]
|
||
key = record_file.stem
|
||
records_info[key] = {
|
||
"file": record_file,
|
||
"title": title,
|
||
"has_pdf": has_pdf,
|
||
"normalized": normalized,
|
||
}
|
||
if normalized not in records_by_title:
|
||
records_by_title[normalized] = []
|
||
records_by_title[normalized].append(key)
|
||
except Exception:
|
||
continue
|
||
to_delete = []
|
||
for normalized, keys in records_by_title.items():
|
||
keys_in_export = [k for k in keys if k in domain_export_keys]
|
||
keys_not_in_export = [k for k in keys if k not in domain_export_keys]
|
||
if keys_in_export and keys_not_in_export:
|
||
for k in keys_not_in_export:
|
||
if not records_info[k]["has_pdf"]:
|
||
to_delete.append(k)
|
||
deleted_count = 0
|
||
for key in to_delete:
|
||
try:
|
||
records_info[key]["file"].unlink()
|
||
deleted_count += 1
|
||
except Exception:
|
||
pass
|
||
if deleted_count > 0:
|
||
print(f"index-refresh: cleaned {deleted_count} orphaned records in {domain}")
|
||
return 0
|