Skip to content

Commit 00a32da

Browse files
feat(api): public citations dashboard with cites_doi linkage
Record which canonical DOI each citing paper references and expose a per-year + stacked-by-paper citation feed, opt-in per community. - papers.cites_doi column (CREATE TABLE + _migrate_db ALTER for existing DBs); index created in _migrate_db so init_db stays safe on databases predating the column. - upsert_paper records cites_doi; on conflict COALESCE keeps the first link, so a keyword sync (None) never erases it and a re-sync backfills legacy NULL rows. - sync_citing_papers threads the canonical DOI through _store_papers. - get_citation_stats aggregates total/per_year/by_paper (4-digit-year GLOB guard drops undated rows). - GET /{community_id}/citations gated by public_feeds.citations, returns per_year, stacked by_paper, and canonical_dois from config, with Cache-Control and 503/500 handling matching the FAQ feed. Backfill on deploy: run a full citation re-sync to populate cites_doi on existing rows. Tests: stats aggregation, COALESCE link semantics (backfill/first-wins/ no-clobber), legacy-table migration, endpoint gate/content/cache/503.
1 parent 053db7e commit 00a32da

6 files changed

Lines changed: 516 additions & 6 deletions

File tree

src/api/routers/community.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
from src.assistants.registry import AssistantInfo
3535
from src.core.config.community import WidgetConfig
3636
from src.core.services.litellm_llm import create_openrouter_llm
37-
from src.knowledge.search import FAQResult, list_faq_entries
37+
from src.knowledge.search import FAQResult, get_citation_stats, list_faq_entries
3838
from src.metrics.cost import COST_BLOCK_THRESHOLD, COST_WARN_THRESHOLD, MODEL_PRICING, estimate_cost
3939
from src.metrics.db import (
4040
RequestLogEntry,
@@ -229,6 +229,23 @@ class FAQFeedResponse(BaseModel):
229229
entries: list[FAQEntryResponse] = Field(default_factory=list, description="FAQ entries")
230230

231231

232+
class CitationsFeedResponse(BaseModel):
233+
"""Public citation dashboard data for a community's canonical papers."""
234+
235+
community_id: str = Field(..., description="Community identifier")
236+
total: int = Field(..., description="Total citing papers with a recorded canonical link")
237+
per_year: dict[str, int] = Field(
238+
default_factory=dict, description="Citing-paper count per year across all papers"
239+
)
240+
by_paper: dict[str, dict[str, int]] = Field(
241+
default_factory=dict,
242+
description="Stacked breakdown: canonical DOI -> year -> citing-paper count",
243+
)
244+
canonical_dois: list[str] = Field(
245+
default_factory=list, description="Canonical DOIs tracked for this community"
246+
)
247+
248+
232249
# Matches bare email addresses so they can be stripped from the public feed.
233250
_EMAIL_PATTERN = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
234251

@@ -1621,6 +1638,50 @@ async def community_faq(
16211638
entries=[_faq_result_to_response(e) for e in entries],
16221639
)
16231640

1641+
@router.get("/citations", response_model=CitationsFeedResponse)
1642+
async def community_citations(response: Response) -> CitationsFeedResponse:
1643+
"""Public, read-only citation dashboard for this community.
1644+
1645+
Returns per-year counts of papers citing the community's canonical
1646+
works, plus a stacked breakdown keyed by the cited DOI (the shape
1647+
behind a citations-per-year chart). Disabled by default; a community
1648+
opts in via ``public_feeds.citations: true`` in its config.
1649+
"""
1650+
config = info.community_config
1651+
if config is None or config.public_feeds is None or not config.public_feeds.citations:
1652+
raise HTTPException(
1653+
status_code=404,
1654+
detail="Public citations feed is not enabled for this community.",
1655+
)
1656+
1657+
try:
1658+
stats = get_citation_stats(project=community_id)
1659+
except sqlite3.Error:
1660+
logger.exception("Failed to query citations for community %s", community_id)
1661+
raise HTTPException(
1662+
status_code=503,
1663+
detail="Knowledge database is temporarily unavailable.",
1664+
)
1665+
except Exception:
1666+
logger.exception(
1667+
"Unexpected error serving citations feed for community %s", community_id
1668+
)
1669+
raise HTTPException(
1670+
status_code=500,
1671+
detail="An unexpected error occurred while building the citations feed.",
1672+
)
1673+
1674+
canonical_dois = list(config.citations.dois) if config.citations else []
1675+
1676+
response.headers["Cache-Control"] = "public, max-age=3600"
1677+
return CitationsFeedResponse(
1678+
community_id=community_id,
1679+
total=stats.total,
1680+
per_year=stats.per_year,
1681+
by_paper=stats.by_paper,
1682+
canonical_dois=canonical_dois,
1683+
)
1684+
16241685
return router
16251686

16261687

src/knowledge/db.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,9 @@ def active_mirror_context(mirror_id: str) -> Iterator[None]:
132132
url TEXT NOT NULL,
133133
created_at TEXT,
134134
synced_at TEXT NOT NULL,
135+
-- Canonical DOI this paper cites, when discovered via citation sync.
136+
-- NULL for papers found through keyword search rather than a citation link.
137+
cites_doi TEXT,
135138
UNIQUE(source, external_id)
136139
);
137140
@@ -409,6 +412,8 @@ def active_mirror_context(mirror_id: str) -> Iterator[None]:
409412
CREATE INDEX IF NOT EXISTS idx_github_items_status ON github_items(status);
410413
CREATE INDEX IF NOT EXISTS idx_github_items_type ON github_items(item_type);
411414
CREATE INDEX IF NOT EXISTS idx_papers_source ON papers(source);
415+
-- idx_papers_cites_doi is created in _migrate_db, after the cites_doi column
416+
-- is ensured, so init_db stays safe on databases predating that column.
412417
CREATE INDEX IF NOT EXISTS idx_docstrings_repo ON docstrings(repo);
413418
CREATE INDEX IF NOT EXISTS idx_docstrings_language ON docstrings(language);
414419
CREATE INDEX IF NOT EXISTS idx_messages_list ON mailing_list_messages(list_name);
@@ -507,6 +512,25 @@ def _migrate_db(conn: sqlite3.Connection) -> None:
507512
# Table doesn't exist yet - this is fine, schema will create it
508513
logger.debug("Docstrings table not found during migration (will be created): %s", e)
509514

515+
# Migration: Add cites_doi column to papers table (added 2026-06-09).
516+
# The index lives here (not in SCHEMA_SQL) so executescript never references
517+
# cites_doi on a database created before the column existed.
518+
try:
519+
cursor = conn.execute("PRAGMA table_info(papers)")
520+
columns = [row[1] for row in cursor.fetchall()]
521+
522+
if columns: # papers table exists
523+
if "cites_doi" not in columns:
524+
logger.info("Migrating papers table: adding cites_doi column")
525+
conn.execute("ALTER TABLE papers ADD COLUMN cites_doi TEXT")
526+
logger.info("Migration complete: cites_doi column added to papers")
527+
# Ensure the index exists for both new and migrated databases.
528+
conn.execute("CREATE INDEX IF NOT EXISTS idx_papers_cites_doi ON papers(cites_doi)")
529+
conn.commit()
530+
except sqlite3.OperationalError as e:
531+
# Table doesn't exist yet - this is fine, schema will create it
532+
logger.debug("Papers table not found during migration (will be created): %s", e)
533+
510534

511535
def init_db(project: str = "hed") -> None:
512536
"""Initialize database schema for a project.
@@ -586,6 +610,7 @@ def upsert_paper(
586610
first_message: str | None,
587611
url: str,
588612
created_at: str | None,
613+
cites_doi: str | None = None,
589614
) -> None:
590615
"""Insert or update a paper.
591616
@@ -597,6 +622,11 @@ def upsert_paper(
597622
first_message: Abstract (limited to ~2000 chars)
598623
url: URL to the paper (DOI or source URL)
599624
created_at: Publication date (ISO 8601 or year string)
625+
cites_doi: Canonical DOI this paper cites, when known from a citation
626+
sync. ``None`` for keyword-search results. On conflict the first
627+
recorded link is kept (COALESCE), so a later keyword sync passing
628+
``None`` never erases an existing citation link, and a re-sync
629+
backfills the link onto rows stored before this column existed.
600630
"""
601631
# Limit first_message size
602632
if first_message and len(first_message) > 2000:
@@ -605,14 +635,15 @@ def upsert_paper(
605635
conn.execute(
606636
"""
607637
INSERT INTO papers (source, external_id, title, first_message,
608-
status, url, created_at, synced_at)
609-
VALUES (?, ?, ?, ?, 'published', ?, ?, ?)
638+
status, url, created_at, synced_at, cites_doi)
639+
VALUES (?, ?, ?, ?, 'published', ?, ?, ?, ?)
610640
ON CONFLICT(source, external_id) DO UPDATE SET
611641
title=excluded.title,
612642
first_message=excluded.first_message,
613-
synced_at=excluded.synced_at
643+
synced_at=excluded.synced_at,
644+
cites_doi=COALESCE(papers.cites_doi, excluded.cites_doi)
614645
""",
615-
(source, external_id, title, first_message, url, created_at, _now_iso()),
646+
(source, external_id, title, first_message, url, created_at, _now_iso(), cites_doi),
616647
)
617648

618649

src/knowledge/papers_sync.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ def _store_papers(
158158
project: str,
159159
*,
160160
force_source: str | None = None,
161+
cites_doi: str | None = None,
161162
) -> dict[str, int]:
162163
"""Upsert opencite papers into the knowledge DB, returning counts by source.
163164
@@ -167,6 +168,8 @@ def _store_papers(
167168
force_source: When set (a single-source sync), record this OSA source
168169
label using its native identifier; falls back to the priority
169170
mapping if that identifier is missing.
171+
cites_doi: Canonical DOI these papers cite, recorded on each row when
172+
storing the results of a citation sync. ``None`` for keyword search.
170173
"""
171174
counts: dict[str, int] = {}
172175
with get_connection(project) as conn:
@@ -193,6 +196,7 @@ def _store_papers(
193196
first_message=paper.abstract or None,
194197
url=_paper_url(paper),
195198
created_at=paper.publication_date or (str(paper.year) if paper.year else None),
199+
cites_doi=cites_doi,
196200
)
197201
counts[source] = counts.get(source, 0) + 1
198202
conn.commit()
@@ -420,7 +424,7 @@ def sync_citing_papers(
420424
total = 0
421425
for doi, papers in cited:
422426
try:
423-
counts = _store_papers(papers, project)
427+
counts = _store_papers(papers, project, cites_doi=doi)
424428
count = sum(counts.values())
425429
update_sync_metadata("papers", f"citing_{doi}", count, project)
426430
logger.info("Synced %d papers citing %s", count, doi)

src/knowledge/search.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,77 @@ def search_github_items(
376376
return results
377377

378378

379+
@dataclass
380+
class CitationStats:
381+
"""Aggregated citation counts for a community's canonical papers."""
382+
383+
total: int
384+
"""Total citing papers with a recorded canonical link and a valid year."""
385+
386+
per_year: dict[str, int]
387+
"""Citing-paper count per publication year, summed across canonical DOIs."""
388+
389+
by_paper: dict[str, dict[str, int]]
390+
"""Per canonical DOI: a mapping of publication year to citing-paper count."""
391+
392+
393+
def get_citation_stats(project: str = "eeglab") -> CitationStats:
394+
"""Aggregate citation counts for the public citations dashboard.
395+
396+
Counts papers that cite a community's canonical DOIs (``papers.cites_doi``
397+
is set), grouped by the citing paper's publication year. The year is the
398+
leading four digits of ``created_at`` (ISO date or bare year); rows whose
399+
``created_at`` is missing or not a four-digit year are skipped so a bad
400+
date never lands in a bogus year bucket.
401+
402+
Args:
403+
project: Community ID for database isolation. Defaults to 'eeglab'.
404+
405+
Returns:
406+
CitationStats with the overall ``total``, ``per_year`` totals, and the
407+
stacked ``by_paper`` breakdown (canonical DOI -> year -> count). Years
408+
are sorted ascending in every mapping.
409+
"""
410+
sql = """
411+
SELECT cites_doi, substr(created_at, 1, 4) AS yr, COUNT(*) AS cnt
412+
FROM papers
413+
WHERE cites_doi IS NOT NULL
414+
AND created_at IS NOT NULL
415+
AND substr(created_at, 1, 4) GLOB '[0-9][0-9][0-9][0-9]'
416+
GROUP BY cites_doi, yr
417+
"""
418+
419+
per_year: dict[str, int] = {}
420+
by_paper: dict[str, dict[str, int]] = {}
421+
total = 0
422+
try:
423+
with get_connection(project) as conn:
424+
for row in conn.execute(sql):
425+
doi = row["cites_doi"]
426+
year = row["yr"]
427+
count = row["cnt"]
428+
per_year[year] = per_year.get(year, 0) + count
429+
by_paper.setdefault(doi, {})[year] = count
430+
total += count
431+
except sqlite3.OperationalError as e:
432+
logger.error(
433+
"Database operational error computing citation stats: %s",
434+
e,
435+
exc_info=True,
436+
extra={"project": project},
437+
)
438+
raise
439+
except sqlite3.Error as e:
440+
logger.warning("Database error computing citation stats (project=%s): %s", project, e)
441+
raise
442+
443+
return CitationStats(
444+
total=total,
445+
per_year=dict(sorted(per_year.items())),
446+
by_paper={doi: dict(sorted(years.items())) for doi, years in by_paper.items()},
447+
)
448+
449+
379450
def search_papers(
380451
query: str,
381452
project: str = "hed",

0 commit comments

Comments
 (0)