File size: 6,402 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
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