from fastapi import APIRouter, HTTPException from pydantic import BaseModel, ConfigDict, Field from app.services.citation_graph import CitationGraph, CitationGraphService from app.services.citation_exploration import ( CitationExplorationDelta, CitationExplorationResponse, CitationExplorationService, ) from app.services.citation_graph import CitationGraphNode from app.services.paper_acquisition import PaperAcquisitionResult, PaperAcquisitionService from app.services.project_activity import ProjectActivityService router = APIRouter(prefix="/api/citation-graph", tags=["citation-graph"]) _activity = ProjectActivityService() class RefreshCitationGraphRequest(BaseModel): model_config = ConfigDict(extra="forbid", strict=True) project_id: str = Field(min_length=1, max_length=128) class AddReferenceToProjectRequest(BaseModel): model_config = ConfigDict(extra="forbid", strict=True) project_id: str = Field(min_length=1, max_length=128) source_node_id: str = Field(min_length=1, max_length=200) candidate_id: str = Field(min_length=64, max_length=64, pattern=r"^[0-9a-f]{64}$") class ExploreCitationRequest(BaseModel): model_config = ConfigDict(extra="forbid", strict=True) project_id: str = Field(min_length=1, max_length=128) candidate_id: str = Field(min_length=64, max_length=64, pattern=r"^[0-9a-f]{64}$") class EnrichCitationEdgesRequest(BaseModel): model_config = ConfigDict(extra="forbid", strict=True) project_id: str = Field(min_length=1, max_length=128) neighborhood_key: str = Field(min_length=1, max_length=256) class ExploreAuthorRequest(ExploreCitationRequest): author_index: int = Field(ge=0, le=200) def _require_valid_project_id(project_id: str) -> None: if not CitationGraphService.is_valid_project_id(project_id): raise HTTPException(400, "Invalid project id") @router.get("", response_model=CitationGraph) async def get_citation_graph(project_id: str): _require_valid_project_id(project_id) service = CitationGraphService() graph = service.load(project_id) project = service.project_service.load(project_id) if project is not None and project.files and ( not graph.nodes or graph.file_fingerprint != service.file_fingerprint(project_id) or not service.metadata_is_fresh(graph) ): return await service.refresh(project_id) return graph @router.post("/refresh", response_model=CitationGraph) async def refresh_citation_graph(req: RefreshCitationGraphRequest): _require_valid_project_id(req.project_id) _activity.record(req.project_id, "citation_graph_refresh", "Citation map refreshing", status="running") graph = await CitationGraphService().refresh(req.project_id) _activity.record( req.project_id, "citation_graph_refresh", "Citation map refreshed", metadata={"nodes": len(graph.nodes), "edges": len(graph.edges)}, ) return graph @router.post("/references/add-to-project", response_model=PaperAcquisitionResult) async def add_reference_to_project(req: AddReferenceToProjectRequest): """Acquire only a candidate already persisted in this project's citation graph.""" _require_valid_project_id(req.project_id) graph = CitationGraphService().load(req.project_id) source = graph.nodes_by_id.get(req.source_node_id) if source is None or source.status != "owned": raise HTTPException(404, "Source paper not found") references = source.metadata.get("verified_unmatched_references") or [] candidate = next( (item for item in references if isinstance(item, dict) and item.get("candidate_id") == req.candidate_id), None, ) if candidate is None: raise HTTPException(404, "Verified reference not found") return await PaperAcquisitionService().acquire(req.project_id, candidate) @router.post("/explore", response_model=CitationExplorationResponse) async def explore_citation(req: ExploreCitationRequest): """Open a transient paper-centred graph from a server-verified candidate.""" _require_valid_project_id(req.project_id) try: return await CitationExplorationService().explore(req.project_id, req.candidate_id) except LookupError as exc: raise HTTPException(404, str(exc)) from exc except RuntimeError as exc: raise HTTPException(502, str(exc)) from exc @router.post("/explore/enrich-edges", response_model=CitationExplorationDelta) async def enrich_explored_edges(req: EnrichCitationEdgesRequest): """Lazily add verified visible-paper citation edges to a transient graph.""" _require_valid_project_id(req.project_id) try: return await CitationExplorationService().enrich_edges(req.project_id, req.neighborhood_key) except LookupError as exc: raise HTTPException(404, str(exc)) from exc @router.post("/explore/details", response_model=CitationGraphNode) async def explored_paper_details(req: ExploreCitationRequest): """Resolve and cache rich metadata for one server-known paper.""" _require_valid_project_id(req.project_id) try: return await CitationExplorationService().details(req.project_id, req.candidate_id) except LookupError as exc: raise HTTPException(404, str(exc)) from exc except RuntimeError as exc: raise HTTPException(502, str(exc)) from exc @router.post("/explore/authors", response_model=CitationExplorationResponse) async def explore_author(req: ExploreAuthorRequest): """Build the selected paper author's cached top-ten citation graph.""" _require_valid_project_id(req.project_id) try: return await CitationExplorationService().explore_author( req.project_id, req.candidate_id, req.author_index ) except LookupError as exc: raise HTTPException(404, str(exc)) from exc except RuntimeError as exc: raise HTTPException(502, str(exc)) from exc @router.post("/explore/download", response_model=PaperAcquisitionResult) async def download_explored_paper(req: ExploreCitationRequest): """Download a candidate from the trusted exploration cache into the project.""" _require_valid_project_id(req.project_id) try: return await CitationExplorationService().download(req.project_id, req.candidate_id) except LookupError as exc: raise HTTPException(404, str(exc)) from exc