Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 74 additions & 3 deletions dashboard/osa/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,7 @@ <h2>Admin Access</h2>
let toolsChartInstance = null;
let adminTokenChartInstance = null;
let adminCostChartInstance = null;
let citationsChartInstance = null;

const COLORS = [
'#2563eb', '#1e3a5f', '#059669', '#d97706', '#dc2626',
Expand Down Expand Up @@ -858,11 +859,12 @@ <h2>Communities</h2>
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);
Expand All @@ -872,8 +874,10 @@ <h2>Communities</h2>
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);
Expand All @@ -885,7 +889,7 @@ <h2>Communities</h2>
}
}

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] || {};
Expand Down Expand Up @@ -917,6 +921,19 @@ <h2>Communities</h2>
: '';
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 ? `
<div class="card">
<h2>Publication Citations</h2>
<p style="color:#64748b; font-size:0.9rem; margin-bottom:0.75rem;">
${Number(citations.total || 0).toLocaleString()} papers citing this community's canonical works, by year.
</p>
<div class="chart-container" style="height:360px;"><canvas id="citationsChart"></canvas></div>
</div>` : '';

app.className = '';
app.innerHTML = `
<div class="card">
Expand Down Expand Up @@ -974,10 +991,12 @@ <h3 style="color:#1e3a5f;margin:1.5rem 0 1rem;font-size:1rem;">Admin: Feedback</
<div id="adminFeedback"><div class="loading">Loading feedback...</div></div>
</div>
</div>
${citationsCardHtml}
`;

renderUsageChart(usage);
renderToolsChart(summary.top_tools);
renderCitationsChart(citations);
}

const SYNC_LABELS = {
Expand Down Expand Up @@ -1154,6 +1173,58 @@ <h3 style="color:#1e3a5f;margin:1.5rem 0 1rem;font-size:1rem;">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));
Expand Down
6 changes: 6 additions & 0 deletions src/api/routers/community.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down
21 changes: 21 additions & 0 deletions src/assistants/bids/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions src/assistants/eeglab/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 31 additions & 0 deletions src/core/config/community.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
11 changes: 11 additions & 0 deletions tests/test_api/test_citations_feed.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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

Expand Down
12 changes: 12 additions & 0 deletions tests/test_api/test_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions tests/test_core/test_config/test_community.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
Loading