From a0054e725267ed544258ddd4447e11420a6c2efc Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Tue, 9 Jun 2026 17:15:31 -0700 Subject: [PATCH 1/4] feat(citations): expose per-DOI labels in the citations feed Add an optional paper_labels (DOI -> label) field to CitationConfig with DOI-key normalization matching dois, and return a labels map from GET /{community_id}/citations so consumers can show human-readable series names instead of bare DOIs. --- src/api/routers/community.py | 6 ++++++ src/core/config/community.py | 19 +++++++++++++++++++ tests/test_api/test_citations_feed.py | 9 +++++++++ tests/test_core/test_config/test_community.py | 18 ++++++++++++++++++ 4 files changed, 52 insertions(+) diff --git a/src/api/routers/community.py b/src/api/routers/community.py index 37040b1..c165fb6 100644 --- a/src/api/routers/community.py +++ b/src/api/routers/community.py @@ -244,6 +244,10 @@ class CitationsFeedResponse(BaseModel): canonical_dois: list[str] = Field( default_factory=list, description="Canonical DOIs tracked for this community" ) + labels: dict[str, str] = Field( + default_factory=dict, + description="Human-readable labels per canonical DOI (DOI -> label), when configured", + ) # Matches bare email addresses so they can be stripped from the public feed. @@ -1672,6 +1676,7 @@ async def community_citations(response: Response) -> CitationsFeedResponse: ) canonical_dois = list(config.citations.dois) if config.citations else [] + labels = dict(config.citations.paper_labels) if config.citations else {} response.headers["Cache-Control"] = "public, max-age=3600" return CitationsFeedResponse( @@ -1680,6 +1685,7 @@ async def community_citations(response: Response) -> CitationsFeedResponse: per_year=stats.per_year, by_paper=stats.by_paper, canonical_dois=canonical_dois, + labels=labels, ) return router diff --git a/src/core/config/community.py b/src/core/config/community.py index 24bd3a2..294646d 100644 --- a/src/core/config/community.py +++ b/src/core/config/community.py @@ -243,6 +243,25 @@ class CitationConfig(BaseModel): OpenAlex anonymously. Communities opt in explicitly, and their prompt should tell the agent to ask the user before running it.""" + paper_labels: dict[str, str] = Field(default_factory=dict) + """Optional human-readable labels for canonical DOIs (DOI -> short label). + + Used to label the stacked series in the public citations dashboard + (e.g. '10.1038/s41597-019-0104-8' -> 'EEG-BIDS (Pernet 2019)'). Keys are + normalized like ``dois`` so they match the stored ``cites_doi`` values. + DOIs without a label fall back to the bare DOI in consumers.""" + + @field_validator("paper_labels") + @classmethod + def validate_paper_labels(cls, v: dict[str, str]) -> dict[str, str]: + """Normalize DOI keys (strip doi.org prefixes) to match stored DOIs.""" + normalized: dict[str, str] = {} + for doi, label in v.items(): + clean_doi = re.sub(r"^(https?://)?(dx\.)?doi\.org/", "", doi.strip()) + if clean_doi: + normalized[clean_doi] = label + return normalized + @field_validator("queries") @classmethod def validate_queries(cls, v: list[str]) -> list[str]: diff --git a/tests/test_api/test_citations_feed.py b/tests/test_api/test_citations_feed.py index bbf0e6b..4aeda47 100644 --- a/tests/test_api/test_citations_feed.py +++ b/tests/test_api/test_citations_feed.py @@ -170,6 +170,14 @@ def test_cache_control_header(self, client, citations_db): resp = client.get(f"/{COMMUNITY_ID}/citations") assert resp.headers["Cache-Control"] == "public, max-age=3600" + def test_labels_from_config(self, client, citations_db): + with patch("src.knowledge.db.get_db_path", return_value=citations_db): + resp = client.get(f"/{COMMUNITY_ID}/citations") + labels = resp.json()["labels"] + # eeglab config defines human-readable labels for its canonical DOIs. + assert labels.get(DOI_A) == "EEGLAB (Delorme 2004)" + assert labels.get(DOI_B) == "ICLabel (Pion-Tonachini 2019)" + class TestCitationsFeedNoConfig: """Feed enabled for a community without a citations config block.""" @@ -181,6 +189,7 @@ def test_canonical_dois_empty_when_no_citations_config(self, client, citations_d body = resp.json() assert resp.status_code == 200 assert body["canonical_dois"] == [] + assert body["labels"] == {} # Stats still come from the DB regardless of config presence. assert body["total"] == 4 diff --git a/tests/test_core/test_config/test_community.py b/tests/test_core/test_config/test_community.py index ab5f8f8..6dda976 100644 --- a/tests/test_core/test_config/test_community.py +++ b/tests/test_core/test_config/test_community.py @@ -189,6 +189,24 @@ def test_deduplicates_dois(self) -> None: assert "10.1234/example" in config.dois assert "10.5678/other" in config.dois + def test_paper_labels_default_empty(self) -> None: + """paper_labels defaults to an empty dict.""" + assert CitationConfig().paper_labels == {} + + def test_paper_labels_keys_normalized(self) -> None: + """DOI keys in paper_labels are normalized like dois so they match.""" + config = CitationConfig( + dois=["10.1234/example"], + paper_labels={ + "https://doi.org/10.1234/example": "Example (Author 2020)", + "10.5678/other": "Other (Author 2021)", + }, + ) + assert config.paper_labels["10.1234/example"] == "Example (Author 2020)" + assert config.paper_labels["10.5678/other"] == "Other (Author 2021)" + for key in config.paper_labels: + assert not key.startswith("http") + def test_deduplicates_queries(self) -> None: """Should deduplicate queries.""" config = CitationConfig(queries=["query 1", "query 1", "query 2"]) From d5a89dbb8198144bb4c80d791d8b700a1e727d4c Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Tue, 9 Jun 2026 17:15:31 -0700 Subject: [PATCH 2/4] feat(bids,eeglab): enable public citations feed with paper labels - bids: turn on public_feeds.citations (FAQ stays off; no FAQ pipeline) and add labels for all 14 canonical BIDS papers. - eeglab: add the Lab Streaming Layer paper (Kothe 2025, 10.1162/IMAG.a.136) and short labels for all canonical DOIs. --- src/assistants/bids/config.yaml | 21 +++++++++++++++++++++ src/assistants/eeglab/config.yaml | 7 +++++++ 2 files changed, 28 insertions(+) diff --git a/src/assistants/bids/config.yaml b/src/assistants/bids/config.yaml index 17cb046..924a660 100644 --- a/src/assistants/bids/config.yaml +++ b/src/assistants/bids/config.yaml @@ -574,6 +574,27 @@ citations: - "10.1038/s41597-025-05543-2" # MRS-BIDS (Bouchard et al., 2025) # Related ecosystem - "10.1371/journal.pcbi.1005209" # BIDS Apps (Gorgolewski et al., 2017) + # Short labels for the public citations dashboard (stacked series legend) + paper_labels: + "10.1038/sdata.2016.44": "BIDS (Gorgolewski 2016)" + "10.1038/s41597-019-0104-8": "EEG-BIDS (Pernet 2019)" + "10.1038/s41597-019-0105-7": "iEEG-BIDS (Holdgraf 2019)" + "10.1038/sdata.2018.110": "MEG-BIDS (Niso 2018)" + "10.1038/s41597-022-01164-1": "PET-BIDS (Norgaard 2021)" + "10.1177/0271678X20905433": "PET guidelines (Knudsen 2020)" + "10.1093/gigascience/giaa104": "Genetics-BIDS (Moreau 2020)" + "10.3389/fnins.2022.871228": "Microscopy-BIDS (Bourget 2022)" + "10.1038/s41597-022-01571-4": "qMRI-BIDS (Karakuzu 2022)" + "10.1038/s41597-022-01615-9": "ASL-BIDS (Clement 2022)" + "10.1038/s41597-024-04136-9": "NIRS-BIDS (Luke 2025)" + "10.1038/s41597-024-03559-8": "Motion-BIDS (Jeung 2024)" + "10.1038/s41597-025-05543-2": "MRS-BIDS (Bouchard 2025)" + "10.1371/journal.pcbi.1005209": "BIDS Apps (Gorgolewski 2017)" + +# Expose the citation dashboard as a public, read-only JSON feed +# (GET /bids/citations). FAQ feed stays off: BIDS has no FAQ pipeline configured. +public_feeds: + citations: true # Discourse forums discourse: diff --git a/src/assistants/eeglab/config.yaml b/src/assistants/eeglab/config.yaml index 7bec8a5..c57b51e 100644 --- a/src/assistants/eeglab/config.yaml +++ b/src/assistants/eeglab/config.yaml @@ -426,6 +426,13 @@ citations: - "10.1016/j.jneumeth.2003.10.009" # EEGLAB: an open source toolbox (Delorme & Makeig, 2004) - "10.1016/j.neuroimage.2019.05.026" # ICLabel: automated EEG IC classification (Pion-Tonachini et al., 2019) - "10.3389/fninf.2015.00016" # PREP: standardized preprocessing (Bigdely-Shamlo et al., 2015) + - "10.1162/IMAG.a.136" # The lab streaming layer for synchronized multimodal recording (Kothe et al., 2025) + # Short labels for the public citations dashboard (stacked series legend) + paper_labels: + "10.1016/j.jneumeth.2003.10.009": "EEGLAB (Delorme 2004)" + "10.1016/j.neuroimage.2019.05.026": "ICLabel (Pion-Tonachini 2019)" + "10.3389/fninf.2015.00016": "PREP (Bigdely-Shamlo 2015)" + "10.1162/IMAG.a.136": "LSL (Kothe 2025)" # Expose generated FAQ entries and citation stats as public, read-only JSON feeds # (GET /eeglab/faq and GET /eeglab/citations). Off by default platform-wide. From b47bad8399280b44302d61a434b265a8ecb3f913 Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Tue, 9 Jun 2026 17:15:31 -0700 Subject: [PATCH 3/4] feat(dashboard): stacked publication-citations-by-year chart Add a Publication Citations card to the community view that renders a stacked-by-canonical-paper bar chart from GET /{community_id}/citations, using configured labels for the legend and an HSL fallback palette for communities with many tracked papers (e.g. BIDS). Shown only when the community exposes the feed and has citation data. --- dashboard/osa/index.html | 77 ++++++++++++++++++++++++++++++-- tests/test_api/test_dashboard.py | 12 +++++ 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/dashboard/osa/index.html b/dashboard/osa/index.html index 991725e..1902117 100644 --- a/dashboard/osa/index.html +++ b/dashboard/osa/index.html @@ -697,6 +697,7 @@

Admin Access

let toolsChartInstance = null; let adminTokenChartInstance = null; let adminCostChartInstance = null; + let citationsChartInstance = null; const COLORS = [ '#2563eb', '#1e3a5f', '#059669', '#d97706', '#dc2626', @@ -858,11 +859,12 @@

Communities

document.title = `${safeName.toUpperCase()} - OSA Dashboard`; try { - const [summaryResp, usageResp, syncResp, healthResp] = await Promise.all([ + const [summaryResp, usageResp, syncResp, healthResp, citationsResp] = await Promise.all([ fetch(`${API_BASE}/${encodeURIComponent(communityId)}/metrics/public`), fetch(`${API_BASE}/${encodeURIComponent(communityId)}/metrics/public/usage?period=${activePeriod}`), fetch(`${API_BASE}/sync/status?community_id=${encodeURIComponent(communityId)}`).catch(err => { console.warn('Sync status fetch failed (non-critical):', err.message); return null; }), fetch(`${API_BASE}/sync/health?community_id=${encodeURIComponent(communityId)}`).catch(err => { console.warn('Health check fetch failed (non-critical):', err.message); return null; }), + fetch(`${API_BASE}/${encodeURIComponent(communityId)}/citations`).catch(err => { console.warn('Citations fetch failed (non-critical):', err.message); return null; }), ]); const failedStatus = !summaryResp.ok ? summaryResp.status : (!usageResp.ok ? usageResp.status : null); @@ -872,8 +874,10 @@

Communities

const usage = await usageResp.json(); const sync = syncResp && syncResp.ok ? await syncResp.json() : null; const health = healthResp && healthResp.ok ? await healthResp.json() : null; + // Citations feed is opt-in per community; a 404 just means it is off. + const citations = citationsResp && citationsResp.ok ? await citationsResp.json() : null; - renderCommunityView(summary, usage, sync, health, communityId); + renderCommunityView(summary, usage, sync, health, citations, communityId); document.getElementById('adminCard').style.display = ''; if (adminKey) loadAdminData(communityId); @@ -885,7 +889,7 @@

Communities

} } - function renderCommunityView(summary, usage, sync, health, communityId) { + function renderCommunityView(summary, usage, sync, health, citations, communityId) { const app = document.getElementById('app'); const safeName = escapeHtml(communityId); const meta = communityMeta[communityId] || {}; @@ -917,6 +921,19 @@

Communities

: ''; const links = linkHtml(meta.links, 'community-detail-links'); + // Publication citations card: shown only when the community exposes the + // citations feed and at least one canonical paper has citations. + const hasCitations = citations && citations.by_paper + && Object.keys(citations.by_paper).length > 0; + const citationsCardHtml = hasCitations ? ` +
+

Publication Citations

+

+ ${Number(citations.total || 0).toLocaleString()} papers citing this community's canonical works, by year. +

+
+
` : ''; + app.className = ''; app.innerHTML = `
@@ -974,10 +991,12 @@

Admin: Feedback
Loading feedback...

+ ${citationsCardHtml} `; renderUsageChart(usage); renderToolsChart(summary.top_tools); + renderCitationsChart(citations); } const SYNC_LABELS = { @@ -1154,6 +1173,58 @@

Admin: Feedback byPaper[d]); + const extras = Object.keys(byPaper).filter(d => !configured.includes(d)); + const dois = configured.concat(extras); + + // Union of all years present, sorted ascending for the x-axis. + const yearsSet = new Set(); + dois.forEach(d => Object.keys(byPaper[d]).forEach(y => yearsSet.add(y))); + const years = Array.from(yearsSet).sort(); + + const datasets = dois.map((doi, idx) => ({ + label: labels[doi] || doi, + data: years.map(y => byPaper[doi][y] || 0), + backgroundColor: seriesColor(idx, dois.length), + borderWidth: 0, + })); + + citationsChartInstance = new Chart(canvas, { + type: 'bar', + data: { labels: years, datasets }, + options: { + responsive: true, maintainAspectRatio: false, + plugins: { + legend: { position: 'bottom', labels: { boxWidth: 12, font: { size: 11 } } }, + tooltip: { mode: 'index' }, + }, + scales: { + x: { stacked: true }, + y: { stacked: true, beginAtZero: true, ticks: { precision: 0 } }, + }, + } + }); + } + function changePeriod(period, communityId) { activePeriod = period; loadCommunityView(decodeURIComponent(communityId)); diff --git a/tests/test_api/test_dashboard.py b/tests/test_api/test_dashboard.py index cc14100..ba52901 100644 --- a/tests/test_api/test_dashboard.py +++ b/tests/test_api/test_dashboard.py @@ -77,6 +77,18 @@ def test_has_period_toggle(self) -> None: assert "weekly" in content assert "monthly" in content + def test_references_citations_api(self) -> None: + content = DASHBOARD_HTML_PATH.read_text() + # Community view fetches the public citations feed. + assert "/citations" in content + + def test_has_citations_chart(self) -> None: + content = DASHBOARD_HTML_PATH.read_text() + assert "renderCitationsChart" in content + assert "citationsChart" in content + # Uses the configured labels for the stacked series legend. + assert "citations.labels" in content + def test_api_base_configurable(self) -> None: content = DASHBOARD_HTML_PATH.read_text() # Should support ?api= query param or window.OSA_API_BASE override From 5522416e4bdd6136230f6ace14ae0cd55c1b8d4c Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Tue, 9 Jun 2026 17:20:30 -0700 Subject: [PATCH 4/4] fix: address PR review on citation labels + chart - validate_paper_labels now rejects malformed DOI keys (same format check as dois) so a typo fails at config load instead of silently dropping a label; explicit last-wins dedup documented. - dashboard: null citationsChartInstance after destroy; sort years numerically. - tests: invalid-key raises, doi.org-prefix normalization, dedup last-wins, and the LSL mixed-case DOI label round-trip. --- dashboard/osa/index.html | 4 ++-- src/core/config/community.py | 18 +++++++++++++++--- tests/test_api/test_citations_feed.py | 2 ++ tests/test_core/test_config/test_community.py | 18 ++++++++++++++++++ 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/dashboard/osa/index.html b/dashboard/osa/index.html index 1902117..57b4c30 100644 --- a/dashboard/osa/index.html +++ b/dashboard/osa/index.html @@ -1183,7 +1183,7 @@

Admin: FeedbackAdmin: Feedback Object.keys(byPaper[d]).forEach(y => yearsSet.add(y))); - const years = Array.from(yearsSet).sort(); + const years = Array.from(yearsSet).sort((a, b) => Number(a) - Number(b)); const datasets = dois.map((doi, idx) => ({ label: labels[doi] || doi, diff --git a/src/core/config/community.py b/src/core/config/community.py index 294646d..e9db7e6 100644 --- a/src/core/config/community.py +++ b/src/core/config/community.py @@ -254,12 +254,24 @@ class CitationConfig(BaseModel): @field_validator("paper_labels") @classmethod def validate_paper_labels(cls, v: dict[str, str]) -> dict[str, str]: - """Normalize DOI keys (strip doi.org prefixes) to match stored DOIs.""" + """Normalize and validate DOI keys so labels line up with stored DOIs. + + Applies the same prefix-stripping and format check as ``dois`` so a + mistyped key fails loudly at config load instead of silently producing + a label that never matches a citation bucket. If two keys normalize to + the same DOI, the last one wins (mirrors ``dois`` dedup behavior). + """ + doi_pattern = re.compile(r"^10\.\d{4,}/[^\s]+$") normalized: dict[str, str] = {} for doi, label in v.items(): clean_doi = re.sub(r"^(https?://)?(dx\.)?doi\.org/", "", doi.strip()) - if clean_doi: - normalized[clean_doi] = label + if not clean_doi: + continue + if not doi_pattern.match(clean_doi): + raise ValueError( + f"Invalid DOI key in paper_labels (expected '10.xxxx/yyyy'): {doi}" + ) + normalized[clean_doi] = label return normalized @field_validator("queries") diff --git a/tests/test_api/test_citations_feed.py b/tests/test_api/test_citations_feed.py index 4aeda47..596f6e5 100644 --- a/tests/test_api/test_citations_feed.py +++ b/tests/test_api/test_citations_feed.py @@ -177,6 +177,8 @@ def test_labels_from_config(self, client, citations_db): # eeglab config defines human-readable labels for its canonical DOIs. assert labels.get(DOI_A) == "EEGLAB (Delorme 2004)" assert labels.get(DOI_B) == "ICLabel (Pion-Tonachini 2019)" + # Mixed-case DOI suffix survives the config -> endpoint round-trip. + assert labels.get("10.1162/IMAG.a.136") == "LSL (Kothe 2025)" class TestCitationsFeedNoConfig: diff --git a/tests/test_core/test_config/test_community.py b/tests/test_core/test_config/test_community.py index 6dda976..de11eb1 100644 --- a/tests/test_core/test_config/test_community.py +++ b/tests/test_core/test_config/test_community.py @@ -199,13 +199,31 @@ def test_paper_labels_keys_normalized(self) -> None: dois=["10.1234/example"], paper_labels={ "https://doi.org/10.1234/example": "Example (Author 2020)", + "doi.org/10.9012/paper": "Paper (Author 2019)", "10.5678/other": "Other (Author 2021)", }, ) assert config.paper_labels["10.1234/example"] == "Example (Author 2020)" + assert config.paper_labels["10.9012/paper"] == "Paper (Author 2019)" assert config.paper_labels["10.5678/other"] == "Other (Author 2021)" for key in config.paper_labels: assert not key.startswith("http") + assert not key.startswith("doi.org") + + def test_paper_labels_rejects_invalid_doi_key(self) -> None: + """A malformed DOI key fails loudly rather than silently dropping the label.""" + with pytest.raises(ValidationError, match="Invalid DOI key in paper_labels"): + CitationConfig(paper_labels={"not-a-doi": "Label"}) + + def test_paper_labels_dedup_last_wins(self) -> None: + """Two keys that normalize to the same DOI collapse to one (last wins).""" + config = CitationConfig( + paper_labels={ + "https://doi.org/10.1234/x": "Label A", + "10.1234/x": "Label B", + } + ) + assert config.paper_labels == {"10.1234/x": "Label B"} def test_deduplicates_queries(self) -> None: """Should deduplicate queries."""