Spaces:
Sleeping
Sleeping
File size: 13,852 Bytes
2e818da | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 | """Explicit, bounded acquisition of verified open-access project papers."""
from __future__ import annotations
import hashlib
import os
import re
import time
from collections.abc import Mapping
from typing import Any, Literal
from urllib.parse import urljoin, urlparse
from pydantic import BaseModel, ConfigDict
from app.schemas.project import ProjectFile
from app.services.paper_metadata import PaperMetadataService
from app.services.project_activity import ProjectActivityService
from app.services.project_files import project_upload_dir
from app.services.project_service import ProjectService
AcquisitionStatus = Literal["added", "already_present", "unavailable", "failed"]
_PDF_TYPES = {"application/pdf", "application/x-pdf", "application/acrobat", "applications/vnd.pdf"}
_DEFAULT_TRUSTED_OA_HOSTS = {
"arxiv.org", "export.arxiv.org", "aclanthology.org", "openreview.net",
"proceedings.mlr.press", "proceedings.neurips.cc", "papers.nips.cc",
"pmc.ncbi.nlm.nih.gov", "www.ncbi.nlm.nih.gov", "europepmc.org",
"www.ebi.ac.uk", "pdfs.semanticscholar.org",
}
def trusted_oa_hosts() -> set[str]:
extension = {host.strip().lower() for host in os.getenv("PAPER_OA_ALLOWED_HOSTS", "").split(",") if host.strip()}
return _DEFAULT_TRUSTED_OA_HOSTS | extension
def is_acquirable_pdf_url(value: str) -> bool:
parsed = urlparse(str(value or ""))
host = (parsed.hostname or "").lower()
return parsed.scheme == "https" and bool(host) and any(
host == trusted or host.endswith(f".{trusted}") for trusted in trusted_oa_hosts()
)
def sanitize_acquisition_candidate(candidate: Mapping[str, Any]) -> dict[str, Any]:
payload = dict(candidate)
arxiv_id = PaperMetadataService.normalize_arxiv(payload.get("arxiv_id") or "")
if arxiv_id and not payload.get("pdf_url"):
payload["arxiv_id"] = arxiv_id
payload["pdf_url"] = f"https://arxiv.org/pdf/{arxiv_id}.pdf"
payload["is_open_access"] = True
payload["pdf_provider"] = payload.get("pdf_provider") or "arXiv"
can_acquire = bool(payload.get("is_open_access") and is_acquirable_pdf_url(str(payload.get("pdf_url") or "")))
payload["can_acquire"] = can_acquire
if not can_acquire:
payload["is_open_access"] = False
payload["pdf_url"] = ""
return payload
class PaperAcquisitionResult(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
ok: bool
status: AcquisitionStatus
message: str
project: dict[str, Any] | None = None
citation_graph: dict[str, Any] | None = None
partial: bool = False
recovery_required: bool = False
class PaperAcquisitionService:
def __init__(
self,
metadata_service: PaperMetadataService | None = None,
project_service: ProjectService | None = None,
activity_service: ProjectActivityService | None = None,
) -> None:
self.metadata_service = metadata_service or PaperMetadataService()
self.project_service = project_service or ProjectService()
self.activity_service = activity_service or ProjectActivityService()
self.trusted_hosts = trusted_oa_hosts()
self.max_bytes = max(1_048_576, int(os.getenv("PAPER_ACQUISITION_MAX_BYTES", str(50 * 1024 * 1024))))
async def acquire(self, project_id: str, candidate: Mapping[str, Any]) -> PaperAcquisitionResult:
from app.services.citation_graph import CitationGraphService
if not CitationGraphService.is_valid_project_id(project_id):
return self._result(False, "failed", "Invalid project id.")
async with self.project_service.mutation(project_id):
return await self._acquire_locked(project_id, candidate)
async def _acquire_locked(self, project_id: str, candidate: Mapping[str, Any]) -> PaperAcquisitionResult:
from app.services.citation_graph import CitationGraphService
# Resolution and download are inside the project mutation boundary so a
# second add cannot race this operation's eventual reload/dedupe/save.
metadata = await self.metadata_service.resolve(candidate)
if not metadata.is_open_access or not metadata.pdf_url:
return self._result(False, "unavailable", "No downloadable open-access PDF found.")
try:
content = await self._download_pdf(metadata.pdf_url)
except ValueError as exc:
return self._result(False, "unavailable", str(exc))
except Exception as exc: # Network failures are useful but must not crash the UI.
self.activity_service.record(project_id, "paper_acquisition", "Open-access paper download failed", status="warning", metadata={"title": metadata.title, "error": str(exc)})
return self._result(False, "failed", "The open-access PDF could not be downloaded.")
file_id = hashlib.sha256(content).hexdigest()
# Reload immediately before dedupe/update to preserve changes made while
# metadata was cached or before this operation acquired the mutation lock.
project = self.project_service.load(project_id)
if project is None:
return self._result(False, "failed", "Project not found.")
project_payload = self._project_payload(project)
if any(file.file_id == file_id for file in project.files):
graph, graph_error = await self._graph_after_mutation(project_id)
if graph is None:
return self._result(False, "failed", f"Paper is present, but citation refresh failed: {graph_error}", project_payload, partial=True, recovery_required=True)
return self._result(True, "already_present", "This paper is already in the project.", project_payload, graph)
upload_dir = project_upload_dir(project_id)
filename = self._unique_filename(upload_dir, self._safe_filename(metadata.title), file_id)
destination = os.path.join(upload_dir, filename)
old_files = list(project.files)
db = None
try:
# Content stays in memory until both signature and content type have passed.
with open(destination, "xb") as file:
file.write(content)
project.files.append(ProjectFile(filename=filename, file_id=file_id))
project.updated_at = time.time()
self.project_service.save(project)
from app.rag.ingestion import LIBRARY_COLLECTION, ingest_file
from app.websockets.handlers import get_db
db = get_db()
ingest_file(content, filename, LIBRARY_COLLECTION, db=db, skip_if_indexed=False, project_id=project_id)
# Keep the provider-verified identity attached to this exact PDF.
# The canonical evidence parser remains source-of-truth for paper
# content, but its layout-derived title must never replace this.
self.metadata_service.remember_file_metadata(file_id, metadata)
except Exception as exc:
cleanup_errors = self._compensate_ingest(db, project_id, file_id)
self.metadata_service.forget_file_metadata(file_id)
try:
if os.path.exists(destination):
os.remove(destination)
except Exception as cleanup_exc:
cleanup_errors.append(f"file cleanup: {cleanup_exc}")
project.files = old_files
project.updated_at = time.time()
try:
self.project_service.save(project)
except Exception as cleanup_exc:
cleanup_errors.append(f"project registry rollback: {cleanup_exc}")
self.activity_service.record(project_id, "paper_acquisition", "Adding open-access paper failed", status="error", metadata={"title": metadata.title, "error": str(exc), "cleanup_errors": cleanup_errors})
message = "The validated PDF could not be added to this project."
if cleanup_errors:
message += " Storage cleanup needs attention."
return self._result(False, "failed", message, partial=bool(cleanup_errors), recovery_required=bool(cleanup_errors))
self.activity_service.record(project_id, "paper_added", "Added open-access paper", metadata={"title": metadata.title, "filename": filename, "provider": metadata.provider})
graph, graph_error = await self._graph_after_mutation(project_id, force_refresh=True)
if graph is not None:
self.activity_service.record(project_id, "citation_graph_refresh", "Citation map refreshed after paper add")
return self._result(True, "added", "Paper added to the project.", self._project_payload(project), graph)
self.activity_service.record(project_id, "citation_graph_refresh", "Citation map refresh failed after paper add", status="warning", metadata={"error": graph_error})
return self._result(True, "added", "Paper added; citation refresh needs recovery.", self._project_payload(project), None, partial=True, recovery_required=True)
@staticmethod
def _compensate_ingest(db: Any, project_id: str, file_id: str) -> list[str]:
if db is None:
return []
errors: list[str] = []
try:
from app.rag.evidence_ingestion import EvidenceIngestionService
EvidenceIngestionService(db=db).remove_document(project_id, file_id)
except Exception as exc:
errors.append(f"evidence cleanup: {exc}")
return errors
@staticmethod
async def _graph_after_mutation(project_id: str, force_refresh: bool = False) -> tuple[dict[str, Any] | None, str]:
from app.services.citation_graph import CitationGraphService
service = CitationGraphService()
errors: list[str] = []
for attempt in range(2):
try:
graph = await service.refresh(project_id) if force_refresh or attempt else service.load(project_id)
if not graph.nodes or graph.file_fingerprint != service.file_fingerprint(project_id):
graph = await service.refresh(project_id)
return graph.model_dump(), ""
except Exception as exc:
errors.append(str(exc))
force_refresh = True
return None, "; ".join(errors)
async def _download_pdf(self, source_url: str) -> bytes:
import httpx
url = self._validated_url(source_url)
timeout = httpx.Timeout(connect=8.0, read=20.0, write=20.0, pool=8.0)
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client:
for _ in range(6):
async with client.stream("GET", url) as response:
if response.status_code in {301, 302, 303, 307, 308}:
location = response.headers.get("location")
if not location:
raise ValueError("The open-access source returned an invalid redirect.")
url = self._validated_url(urljoin(str(response.url), location))
continue
if response.status_code != 200:
raise ValueError("The open-access source did not provide a downloadable PDF.")
content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
if content_type not in _PDF_TYPES:
raise ValueError("The open-access source did not provide a PDF content type.")
length = response.headers.get("content-length")
if length and int(length) > self.max_bytes:
raise ValueError("The open-access PDF exceeds the download size limit.")
body = bytearray()
async for chunk in response.aiter_bytes():
body.extend(chunk)
if len(body) > self.max_bytes:
raise ValueError("The open-access PDF exceeds the download size limit.")
content = bytes(body)
if not content.startswith(b"%PDF"):
raise ValueError("The open-access source did not provide a valid PDF.")
return content
raise ValueError("The open-access source redirected too many times.")
def _validated_url(self, value: str) -> str:
if not is_acquirable_pdf_url(value):
raise ValueError("No downloadable open-access PDF found.")
return value
def _trusted_host(self, host: str) -> bool:
return any(host == trusted or host.endswith(f".{trusted}") for trusted in self.trusted_hosts)
@staticmethod
def _safe_filename(title: str) -> str:
clean = re.sub(r"[^A-Za-z0-9 ._-]+", "_", title or "open-access-paper").strip(" ._")[:140]
return f"{clean or 'open-access-paper'}.pdf"
@staticmethod
def _unique_filename(upload_dir: str, filename: str, file_id: str) -> str:
stem, ext = os.path.splitext(os.path.basename(filename))
candidate = f"{stem}{ext or '.pdf'}"
if not os.path.exists(os.path.join(upload_dir, candidate)):
return candidate
return f"{stem}-{file_id[:8]}{ext or '.pdf'}"
def _project_payload(self, project: Any) -> dict[str, Any]:
return {**project.model_dump(), "document_id": self.project_service.derive_document_id(project)}
@staticmethod
def _result(ok: bool, status: AcquisitionStatus, message: str, project: dict[str, Any] | None = None, citation_graph: dict[str, Any] | None = None, partial: bool = False, recovery_required: bool = False) -> PaperAcquisitionResult:
return PaperAcquisitionResult(ok=ok, status=status, message=message, project=project, citation_graph=citation_graph, partial=partial, recovery_required=recovery_required)
|