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
+ ${citationsCardHtml}
`;
renderUsageChart(usage);
renderToolsChart(summary.top_tools);
+ renderCitationsChart(citations);
}
const SYNC_LABELS = {
@@ -1154,6 +1173,58 @@ Admin: Feedback
});
}
+ // Distinct color per series: use the fixed palette, then spread evenly
+ // around the HSL wheel when there are more series than palette entries
+ // (the citation chart can stack 14+ canonical papers).
+ function seriesColor(idx, total) {
+ if (total <= COLORS.length) return COLORS[idx];
+ const hue = Math.round((idx / total) * 360);
+ return `hsl(${hue}, 62%, 48%)`;
+ }
+
+ function renderCitationsChart(citations) {
+ if (citationsChartInstance) { citationsChartInstance.destroy(); citationsChartInstance = null; }
+ const canvas = document.getElementById('citationsChart');
+ if (!canvas || !citations || !citations.by_paper
+ || Object.keys(citations.by_paper).length === 0) return;
+
+ const byPaper = citations.by_paper;
+ const labels = citations.labels || {};
+ // Stacking order follows the configured canonical_dois; any DOI with
+ // citations but no config entry is appended so nothing is dropped.
+ const configured = (citations.canonical_dois || []).filter(d => 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"])