study-buddy / app /routers /citation_graph.py
GitHub Actions
deploy d092bea3608b7a29952f16357fda39b7a29e399b
2e818da
Raw
History Blame Contribute Delete
6.4 kB
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