discover.py compared the raw OpenAlex cited_id against papers.id (a DOI/arXiv id), so an ingested paper cited by its OpenAlex id was mis-reported as a discovery lead. graph.py already resolved this via papers.openalex_id, but the fix was never propagated — two 'dangling' implementations disagreed. Extract the resolution into one shared constant RESOLVED_CITATIONS_SQL in graph.py; build_citation_graph and all four discover.py queries (discovery_leads / citing_papers / cited_by / cocited_papers) now go through it, so the 'what counts as ingested' rule cannot drift again. Live-DB validated: before, 13 ingested papers leaked into discovery_leads; after, the real discovery_leads() returns 0 ingested papers among its leads. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
82 lines
3.2 KiB
Python
82 lines
3.2 KiB
Python
"""Discovery queries over the citation graph stored in PostgreSQL.
|
|
|
|
Every query resolves ``citations.cited_id`` to a canonical ``papers.id`` through
|
|
the shared :data:`codex.graph.RESOLVED_CITATIONS_SQL`, so this module and the
|
|
citation-graph layer agree on what counts as "ingested". Previously this module
|
|
compared the raw OpenAlex ``cited_id`` against ``papers.id`` (a DOI/arXiv id),
|
|
which mis-reported already-ingested papers as discovery leads (audit C-1).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from codex.db import get_conn
|
|
from codex.graph import RESOLVED_CITATIONS_SQL
|
|
|
|
|
|
def discovery_leads(limit: int = 20) -> list[dict[str, int | str]]:
|
|
"""Return papers referenced by already-ingested papers but not yet collected.
|
|
|
|
Returns list of dicts with keys:
|
|
cited_id (str) — canonical id of the referenced (not-yet-ingested) work
|
|
pull (int) — how many already-ingested papers cite this target
|
|
|
|
Ordered by pull DESC, limited to `limit` rows. References that resolve to a
|
|
paper already in the corpus are excluded — they are not leads.
|
|
"""
|
|
sql = f"""
|
|
WITH resolved AS ({RESOLVED_CITATIONS_SQL})
|
|
SELECT cited_id, count(*) AS pull
|
|
FROM resolved
|
|
WHERE cited_id NOT IN (SELECT id FROM papers)
|
|
GROUP BY cited_id
|
|
ORDER BY pull DESC
|
|
LIMIT %(limit)s
|
|
"""
|
|
with get_conn() as conn:
|
|
rows = conn.execute(sql, {"limit": limit}).fetchall()
|
|
return [{"cited_id": row["cited_id"], "pull": row["pull"]} for row in rows]
|
|
|
|
|
|
def citing_papers(paper_id: str) -> list[str]:
|
|
"""Return IDs of all papers that cite `paper_id` (in-graph reverse citations)."""
|
|
sql = f"""
|
|
WITH resolved AS ({RESOLVED_CITATIONS_SQL})
|
|
SELECT citing_id FROM resolved WHERE cited_id = %(paper_id)s
|
|
"""
|
|
with get_conn() as conn:
|
|
rows = conn.execute(sql, {"paper_id": paper_id}).fetchall()
|
|
return [row["citing_id"] for row in rows]
|
|
|
|
|
|
def cited_by(paper_id: str) -> list[str]:
|
|
"""Return IDs of all papers that `paper_id` cites (forward citations)."""
|
|
sql = f"""
|
|
WITH resolved AS ({RESOLVED_CITATIONS_SQL})
|
|
SELECT cited_id FROM resolved WHERE citing_id = %(paper_id)s
|
|
"""
|
|
with get_conn() as conn:
|
|
rows = conn.execute(sql, {"paper_id": paper_id}).fetchall()
|
|
return [row["cited_id"] for row in rows]
|
|
|
|
|
|
def cocited_papers(paper_id: str, limit: int = 10) -> list[dict[str, int | str]]:
|
|
"""Return papers frequently co-cited with `paper_id`.
|
|
|
|
A paper X is co-cited with `paper_id` if some paper cites both.
|
|
Returns list of dicts: {paper_id: str, co_citations: int}, ordered DESC.
|
|
"""
|
|
sql = f"""
|
|
WITH resolved AS ({RESOLVED_CITATIONS_SQL})
|
|
SELECT c2.cited_id AS paper_id, count(*) AS co_citations
|
|
FROM resolved c1
|
|
JOIN resolved c2 ON c1.citing_id = c2.citing_id
|
|
WHERE c1.cited_id = %(paper_id)s
|
|
AND c2.cited_id != %(paper_id)s
|
|
GROUP BY c2.cited_id
|
|
ORDER BY co_citations DESC
|
|
LIMIT %(limit)s
|
|
"""
|
|
with get_conn() as conn:
|
|
rows = conn.execute(sql, {"paper_id": paper_id, "limit": limit}).fetchall()
|
|
return [{"paper_id": row["paper_id"], "co_citations": row["co_citations"]} for row in rows]
|