diff --git a/dashboard/osa/index.html b/dashboard/osa/index.html index 991725e..57b4c30 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((a, b) => Number(a) - Number(b)); + + 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/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/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. diff --git a/src/core/config/community.py b/src/core/config/community.py index 24bd3a2..e9db7e6 100644 --- a/src/core/config/community.py +++ b/src/core/config/community.py @@ -243,6 +243,37 @@ 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 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 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") @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..596f6e5 100644 --- a/tests/test_api/test_citations_feed.py +++ b/tests/test_api/test_citations_feed.py @@ -170,6 +170,16 @@ 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)" + # Mixed-case DOI suffix survives the config -> endpoint round-trip. + assert labels.get("10.1162/IMAG.a.136") == "LSL (Kothe 2025)" + class TestCitationsFeedNoConfig: """Feed enabled for a community without a citations config block.""" @@ -181,6 +191,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_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 diff --git a/tests/test_core/test_config/test_community.py b/tests/test_core/test_config/test_community.py index ab5f8f8..de11eb1 100644 --- a/tests/test_core/test_config/test_community.py +++ b/tests/test_core/test_config/test_community.py @@ -189,6 +189,42 @@ 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)", + "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.""" config = CitationConfig(queries=["query 1", "query 1", "query 2"])