From f0d9c6ee52016fbbc320009d764a834e62c2a5e0 Mon Sep 17 00:00:00 2001
From: Seyed Yahya Shirazi
Date: Thu, 14 May 2026 11:29:48 -0700
Subject: [PATCH 01/39] Bump version to 0.8.4.dev0
Manual recovery: sync-develop.yml could not run because develop was
auto-deleted after the v0.8.3 release PR merged (repo had
delete_branch_on_merge=true). Reproducing what that workflow would
have done: develop = main + patch dev0 bump.
---
src/version.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/version.py b/src/version.py
index d79d2d1..f689bfc 100644
--- a/src/version.py
+++ b/src/version.py
@@ -1,7 +1,7 @@
"""Version information for OSA."""
-__version__ = "0.8.3"
-__version_info__ = (0, 8, 3)
+__version__ = "0.8.4.dev0"
+__version_info__ = (0, 8, 4, "dev")
def get_version() -> str:
From 0d50dc50cc99ed06fd4606db3f4724c16ec02e30 Mon Sep 17 00:00:00 2001
From: "Seyed (Yahya) Shirazi"
Date: Sat, 16 May 2026 10:24:19 -0700
Subject: [PATCH 02/39] feat: document versioned widget URL and SRI hashes for
secure embedding (#279)
* feat: document versioned widget URL and SRI hashes for secure embedding
Add security-sensitive embedding section to the widget demo page showing
the versioned jsDelivr URL and SRI hash approach as an alternative to the
default (always-latest) URL. The default remains the recommended approach;
the versioned option is clearly labeled for supply-chain-secure workflows.
Also add scripts/widget-sri.py to generate SHA-384 SRI hashes for any
release, useful for maintainers publishing release notes.
Closes #268
Co-authored-by: Seyed (Yahya) Shirazi
* Enhance release notes with widget SRI hash
Append widget SRI hash to release notes and update embedding instructions.
---------
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Seyed (Yahya) Shirazi
---
.github/workflows/release.yml | 32 ++++++++++++++++++
frontend/index.html | 22 +++++++++++++
scripts/widget-sri.py | 61 +++++++++++++++++++++++++++++++++++
3 files changed, 115 insertions(+)
create mode 100644 scripts/widget-sri.py
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a6332f7..758c515 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -58,6 +58,38 @@ jobs:
EOF
echo "Generated release notes for v${{ steps.version.outputs.version }}"
+ - name: Append widget SRI hash to release notes
+ run: |
+ VERSION="${{ steps.version.outputs.version }}"
+ HASH=$(python3 -c "
+ import hashlib, base64
+ with open('frontend/osa-chat-widget.js', 'rb') as f:
+ content = f.read()
+ print('sha384-' + base64.b64encode(hashlib.sha384(content).digest()).decode())
+ ")
+ python3 - <
+ \`\`\`
+
+ **Versioned + SRI** (for supply-chain-secure environments):
+ \`\`\`html
+
+ \`\`\`
+ > Pin to this exact release. Update the tag when upgrading.
+ """
+ pathlib.Path('/tmp/release_notes.md').write_text(notes + section)
+ PYEOF
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
diff --git a/frontend/index.html b/frontend/index.html
index 3f76f44..da81881 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -391,6 +391,17 @@
Integration
});
</script>
The widget auto-configures the API endpoint based on the communityId.
+
+
Security-sensitive environments (supply-chain policies, SRI required): pin to a specific release using a versioned jsDelivr URL. The integrity hash for each release is published in the GitHub releases.
+
<!-- Replace vX.Y.Z and the integrity hash from the GitHub release notes -->
+<script src="https://cdn.jsdelivr.net/gh/OpenScience-Collective/osa@vX.Y.Z/frontend/osa-chat-widget.js"
+ integrity="sha384-..."
+ crossorigin="anonymous"
+ defer></script>
+<script>
+ OSAChatWidget.setConfig({ communityId: 'hed' });
+</script>
+
Note: you must update the version tag manually when upgrading.
@@ -479,6 +490,17 @@
Add to Your Site
// widgetInstructions: 'Focus on topics relevant to this page.'
});
</script>
+
+
Security-sensitive environments (supply-chain policies, SRI required): use a versioned URL pinned to a specific release. The integrity hash for each release is published in the GitHub releases.
+
<!-- Replace vX.Y.Z and the integrity hash from the GitHub release notes -->
+<script src="https://cdn.jsdelivr.net/gh/OpenScience-Collective/osa@vX.Y.Z/frontend/osa-chat-widget.js"
+ integrity="sha384-..."
+ crossorigin="anonymous"
+ defer></script>
+<script>
+ OSAChatWidget.setConfig({ communityId: '${communityId}' });
+</script>
+
Note: you must update the version tag manually when upgrading.
diff --git a/scripts/widget-sri.py b/scripts/widget-sri.py
new file mode 100644
index 0000000..29b913c
--- /dev/null
+++ b/scripts/widget-sri.py
@@ -0,0 +1,61 @@
+#!/usr/bin/env python3
+"""Generate the SRI hash for the OSA chat widget.
+
+Usage:
+ # Hash the local file (useful during development / CI before a release is tagged)
+ python scripts/widget-sri.py
+
+ # Hash a specific release from jsDelivr (useful after tagging)
+ python scripts/widget-sri.py v0.8.3
+
+The script prints the sha384 integrity value and a ready-to-paste embed snippet.
+"""
+
+import base64
+import hashlib
+import sys
+import urllib.request
+from pathlib import Path
+
+
+def sri(content: bytes) -> str:
+ return "sha384-" + base64.b64encode(hashlib.sha384(content).digest()).decode()
+
+
+def main() -> None:
+ if len(sys.argv) > 1:
+ tag = sys.argv[1] if sys.argv[1].startswith("v") else f"v{sys.argv[1]}"
+ url = (
+ f"https://cdn.jsdelivr.net/gh/OpenScience-Collective/osa@{tag}"
+ "/frontend/osa-chat-widget.js"
+ )
+ print(f"Fetching {url} …", flush=True)
+ with urllib.request.urlopen(url) as resp: # noqa: S310 (trusted CDN URL)
+ content = resp.read()
+ label = f"jsDelivr {tag}"
+ else:
+ widget_path = Path(__file__).parent.parent / "frontend" / "osa-chat-widget.js"
+ if not widget_path.exists():
+ print(f"Error: {widget_path} not found.", file=sys.stderr)
+ print("Pass a version tag to fetch from jsDelivr instead:", file=sys.stderr)
+ print(" python scripts/widget-sri.py v0.8.3", file=sys.stderr)
+ sys.exit(1)
+ content = widget_path.read_bytes()
+ tag = None
+ label = f"local ({widget_path.name})"
+
+ hash_value = sri(content)
+ print(f"\nSRI hash ({label}):")
+ print(f" integrity=\"{hash_value}\"")
+
+ if tag:
+ print(f"\nVersioned embed snippet for {tag}:")
+ print(f"""\
+ """)
+
+
+if __name__ == "__main__":
+ main()
From 7fc024ea12ba047b6ff1bcc0af70f2a6ce80fdd7 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Sat, 16 May 2026 17:24:30 +0000
Subject: [PATCH 03/39] Bump version to 0.8.4.dev1
---
src/version.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/version.py b/src/version.py
index f689bfc..77e2e53 100644
--- a/src/version.py
+++ b/src/version.py
@@ -1,7 +1,7 @@
"""Version information for OSA."""
-__version__ = "0.8.4.dev0"
-__version_info__ = (0, 8, 4, "dev")
+__version__ = "0.8.4.dev1"
+__version_info__ = (0, 8, 4, "dev1")
def get_version() -> str:
From f19ed085ce0c9d85f8ec5f4d90c279604653aa3c Mon Sep 17 00:00:00 2001
From: Granville Matheson
Date: Tue, 2 Jun 2026 22:16:01 +0200
Subject: [PATCH 04/39] feat: add petfit readthedocs documentation to
openneuropet assistant (#281)
---
src/assistants/openneuropet/config.yaml | 73 +++++++++++++++++++++++--
1 file changed, 69 insertions(+), 4 deletions(-)
diff --git a/src/assistants/openneuropet/config.yaml b/src/assistants/openneuropet/config.yaml
index 6cc93e9..fa22a38 100644
--- a/src/assistants/openneuropet/config.yaml
+++ b/src/assistants/openneuropet/config.yaml
@@ -133,11 +133,76 @@ documentation:
source_url: https://raw.githubusercontent.com/nipreps/petprep/main/docs/faq.rst
category: analysis
description: Frequently asked questions about PETPrep usage, data quality requirements, and preprocessing decisions.
- - title: petfit documentation
- url: https://github.com/mathesong/petfit
- source_url: https://raw.githubusercontent.com/mathesong/petfit/main/README.md
+ - title: petfit overview
+ url: https://petfit.readthedocs.io/en/latest/
+ source_url: https://raw.githubusercontent.com/mathesong/petfit/main/docs/index.md
category: analysis
- description: Petfit for regional kinetic modelling (based on kinfitr).
+ description: PETFit is a BIDS App for fitting kinetic models to PET time activity curve (TAC) data, producing parameter estimates and QC reports. Supports interactive (GUI) and automatic (CLI) modes.
+ - title: petfit installation
+ url: https://petfit.readthedocs.io/en/latest/installation.html
+ source_url: https://raw.githubusercontent.com/mathesong/petfit/main/docs/installation.md
+ category: analysis
+ description: Installing PETFit via Docker (recommended), Apptainer, or the R package.
+ - title: petfit quick start
+ url: https://petfit.readthedocs.io/en/latest/quickstart.html
+ source_url: https://raw.githubusercontent.com/mathesong/petfit/main/docs/quickstart.md
+ category: analysis
+ description: Two-step PETFit workflow - region definition (once per dataset) then kinetic modelling - run interactively or from the command line.
+ - title: petfit usage guide
+ url: https://petfit.readthedocs.io/en/latest/usage/index.html
+ source_url: https://raw.githubusercontent.com/mathesong/petfit/main/docs/usage/index.md
+ category: analysis
+ description: Overview of the region-definition and kinetic-modelling stages and the three Shiny apps (region definition, plasma input, reference tissue).
+ - title: petfit region definition
+ url: https://petfit.readthedocs.io/en/latest/usage/region-definition.html
+ source_url: https://raw.githubusercontent.com/mathesong/petfit/main/docs/usage/region-definition.md
+ category: analysis
+ description: First step of a PETFit workflow - combining individual brain regions from PET preprocessing derivatives into analysis-ready TACs via the petfit_regions.tsv file.
+ - title: petfit plasma input modelling
+ url: https://petfit.readthedocs.io/en/latest/usage/modelling-plasma.html
+ source_url: https://raw.githubusercontent.com/mathesong/petfit/main/docs/usage/modelling-plasma.md
+ category: analysis
+ description: Configuring and running invasive kinetic models that require an arterial blood input function (raw _blood.tsv or processed _inputfunction.tsv data).
+ - title: petfit reference tissue modelling
+ url: https://petfit.readthedocs.io/en/latest/usage/modelling-reference.html
+ source_url: https://raw.githubusercontent.com/mathesong/petfit/main/docs/usage/modelling-reference.md
+ category: analysis
+ description: Configuring and running non-invasive kinetic models that use a reference brain region instead of arterial blood data.
+ - title: petfit supported models
+ url: https://petfit.readthedocs.io/en/latest/models.html
+ source_url: https://raw.githubusercontent.com/mathesong/petfit/main/docs/models.md
+ category: analysis
+ description: Kinetic models available in PETFit (via kinfitr) - plasma input models (1TCM, 2TCM, Logan, etc.) and reference tissue models.
+ - title: petfit reports
+ url: https://petfit.readthedocs.io/en/latest/usage/reports.html
+ source_url: https://raw.githubusercontent.com/mathesong/petfit/main/docs/usage/reports.md
+ category: analysis
+ description: Parameterised HTML quality-control reports generated for every analysis step, with interactive plots, data summaries, and diagnostics.
+ - title: petfit outputs
+ url: https://petfit.readthedocs.io/en/latest/outputs.html
+ source_url: https://raw.githubusercontent.com/mathesong/petfit/main/docs/outputs.md
+ category: analysis
+ description: PETFit output files following BIDS derivatives conventions in derivatives/petfit/, including parameter estimates and HTML reports.
+ - title: petfit folder structure
+ url: https://petfit.readthedocs.io/en/latest/usage/folder-structure.html
+ source_url: https://raw.githubusercontent.com/mathesong/petfit/main/docs/usage/folder-structure.md
+ category: analysis
+ description: How PETFit organises its outputs in a structured hierarchy within the BIDS derivatives directory.
+ - title: petfit Docker usage
+ url: https://petfit.readthedocs.io/en/latest/containers/docker.html
+ source_url: https://raw.githubusercontent.com/mathesong/petfit/main/docs/containers/docker.md
+ category: analysis
+ description: Advanced Docker options and CLI reference for running PETFit container images.
+ - title: petfit Apptainer usage
+ url: https://petfit.readthedocs.io/en/latest/containers/apptainer.html
+ source_url: https://raw.githubusercontent.com/mathesong/petfit/main/docs/containers/apptainer.md
+ category: analysis
+ description: Running PETFit with Apptainer (formerly Singularity), the standard container runtime on HPC clusters.
+ - title: petfit R API reference
+ url: https://petfit.readthedocs.io/en/latest/api.html
+ source_url: https://raw.githubusercontent.com/mathesong/petfit/main/docs/api.md
+ category: analysis
+ description: Reference for PETFit's main R functions for launching apps and running pipelines (e.g. petfit_interactive()).
- title: PetSurfer Wiki
url: https://surfer.nmr.mgh.harvard.edu/fswiki/PetSurfer
source_url: https://surfer.nmr.mgh.harvard.edu/fswiki/PetSurfer
From c2967a6c3e5255ed5f264095dfb14f14585e4447 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Tue, 2 Jun 2026 20:16:17 +0000
Subject: [PATCH 05/39] Bump version to 0.8.4.dev2
---
src/version.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/version.py b/src/version.py
index 77e2e53..d7db3dc 100644
--- a/src/version.py
+++ b/src/version.py
@@ -1,7 +1,7 @@
"""Version information for OSA."""
-__version__ = "0.8.4.dev1"
-__version_info__ = (0, 8, 4, "dev1")
+__version__ = "0.8.4.dev2"
+__version_info__ = (0, 8, 4, "dev2")
def get_version() -> str:
From 8d13bf520a33427be076918521692872ecd9c8d1 Mon Sep 17 00:00:00 2001
From: "Seyed (Yahya) Shirazi"
Date: Thu, 4 Jun 2026 19:35:30 -0700
Subject: [PATCH 06/39] feat: per-response and general widget feedback (#284)
* feat: per-response and general widget feedback
Widget: thumbs up/down under each assistant reply (sentiment only,
one-vote-lock, keyed to a new per-response request_id) plus a
Send-feedback footer link opening a free-text modal for general feedback.
Backend: anonymous POST /feedback (worker already proxies to it) writing
to a new feedback_log table in the existing metrics.db (auto-created via
CREATE TABLE IF NOT EXISTS; rides the osa-data volume). request_id is now
exposed on the chat/ask done event and ChatResponse/AskResponse. page_url
is scheme-validated to prevent stored XSS in the admin view.
Admin: GET /metrics/feedback (per-community scoped key, strictly confined)
and a Feedback panel on the status dashboard.
Tested: 23 new tests (db/queries + endpoint scoping); ruff clean.
Closes #282
* refactor: address PR #284 review findings
- FeedbackEntry: narrow to Literal types + __post_init__ enforcing the
per-type invariants at the storage layer (last checkpoint before SQLite);
add CHECK constraints + a UNIQUE index on feedback_id to the schema.
- feedback.py: move sentiment/comment normalization into a mode=before
validator (drop object.__setattr__); remove the unreachable manual
whitelist checks and the dead try/except (write_feedback never raises).
- write_feedback: acquire the connection inside the try so it honors its
never-raises contract on a connect failure.
- Widget: submitResponseFeedback now awaits the POST and rolls back the
optimistic vote (with an error) if it fails, instead of silently dropping.
- Tests: replace the mock-based fixture with a DATA_DIR env fixture; add
__post_init__ invariant tests, write-failure counter + CRITICAL escalation,
comments_only/offset/limit-floor coverage, data:/ftp page_url rejection,
general-sentiment-stripping, request_id round-trip, cross-community admin
aggregation; fix the no-op test_limit_clamped.
- Docs/comments: db module docstring, warning SSE event, request_id wording,
widget/dashboard comment and error-message fixes.
---
dashboard/osa/index.html | 202 ++++++++++++++
frontend/osa-chat-widget.js | 300 +++++++++++++++++++-
src/api/main.py | 6 +
src/api/routers/__init__.py | 2 +
src/api/routers/community.py | 38 ++-
src/api/routers/feedback.py | 145 ++++++++++
src/api/routers/metrics.py | 43 +++
src/metrics/db.py | 137 ++++++++-
src/metrics/queries.py | 116 ++++++++
tests/test_api/test_feedback_endpoint.py | 339 +++++++++++++++++++++++
tests/test_metrics/test_feedback_db.py | 320 +++++++++++++++++++++
11 files changed, 1638 insertions(+), 10 deletions(-)
create mode 100644 src/api/routers/feedback.py
create mode 100644 tests/test_api/test_feedback_endpoint.py
create mode 100644 tests/test_metrics/test_feedback_db.py
diff --git a/dashboard/osa/index.html b/dashboard/osa/index.html
index 122882c..991725e 100644
--- a/dashboard/osa/index.html
+++ b/dashboard/osa/index.html
@@ -416,6 +416,50 @@
.admin-status.success { color: #059669; }
.admin-status.error { color: #dc2626; }
+ /* Feedback panel */
+ .feedback-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-bottom: 0.75rem;
+ font-size: 0.82rem;
+ color: #64748b;
+ }
+ .feedback-toolbar label { display: inline-flex; align-items: center; gap: 0.4rem; cursor: pointer; }
+ .feedback-table-wrap {
+ max-height: 320px;
+ overflow-y: auto;
+ border: 1px solid #e2e8f0;
+ border-radius: 8px;
+ }
+ .feedback-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.82rem;
+ }
+ .feedback-table th, .feedback-table td {
+ text-align: left;
+ padding: 0.5rem 0.7rem;
+ border-bottom: 1px solid #e2e8f0;
+ vertical-align: top;
+ }
+ .feedback-table th {
+ position: sticky;
+ top: 0;
+ background: #f8fafc;
+ color: #475569;
+ font-weight: 600;
+ font-size: 0.72rem;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ }
+ .feedback-table tr:last-child td { border-bottom: none; }
+ .feedback-table td a { color: #2563eb; text-decoration: none; }
+ .feedback-table td a:hover { text-decoration: underline; }
+ .feedback-sentiment-up { color: #059669; font-weight: 600; }
+ .feedback-sentiment-down { color: #dc2626; font-weight: 600; }
+ .feedback-empty { color: #64748b; padding: 1rem; font-size: 0.85rem; }
+
/* Loading / error */
.loading { text-align: center; color: #64748b; padding: 2rem; }
.loading::after {
@@ -508,6 +552,14 @@
.admin-btn { background: #7ba3d4; color: #0d1117; }
.admin-btn:hover { background: #93b8de; }
.loading { color: #8a9bb5; }
+ .feedback-toolbar { color: #8a9bb5; }
+ .feedback-table-wrap { border-color: #30363d; }
+ .feedback-table th, .feedback-table td { border-color: #30363d; }
+ .feedback-table th { background: #1c2128; color: #8a9bb5; }
+ .feedback-table td a { color: #7ba3d4; }
+ .feedback-sentiment-up { color: #4ade80; }
+ .feedback-sentiment-down { color: #f87171; }
+ .feedback-empty { color: #8a9bb5; }
.error-msg { color: #f87171; background: #3b1219; border-color: #5c1d2a; }
.site-footer { color: #6b7b92; border-color: #30363d; }
.site-footer a { color: #7ba3d4; }
@@ -918,6 +970,8 @@
';
+ } else {
+ const rows = entries.map(e => {
+ const time = escapeHtml(formatFeedbackTime(e.timestamp));
+ const type = escapeHtml(e.feedback_type || '—');
+ let sentiment = '';
+ if (e.sentiment === 'up') sentiment = '▲ up';
+ else if (e.sentiment === 'down') sentiment = '▼ down';
+ const comment = e.comment ? escapeHtml(e.comment) : '—';
+ // Defense in depth: only render a clickable link for http(s) URLs
+ // (the backend already enforces this on write).
+ const isHttpUrl = typeof e.page_url === 'string' && /^https?:\/\//i.test(e.page_url);
+ const page = isHttpUrl
+ ? `link`
+ : '';
+ return `
+
${time}
+
${type}
+
${sentiment}
+
${comment}
+
${page}
+
`;
+ }).join('');
+ tableHtml = `
+
+
+
+
+
Time
+
Type
+
Sentiment
+
Comment
+
Page
+
+
+ ${rows}
+
+
`;
+ }
+
+ container.innerHTML = summaryHtml + toolbarHtml + tableHtml;
+ }
+
function renderAdminCharts(data) {
if (adminTokenChartInstance) adminTokenChartInstance.destroy();
const tokenCanvas = document.getElementById('adminTokenChart');
diff --git a/frontend/osa-chat-widget.js b/frontend/osa-chat-widget.js
index 1df9f59..0f8ae9a 100644
--- a/frontend/osa-chat-widget.js
+++ b/frontend/osa-chat-widget.js
@@ -118,7 +118,9 @@
copy: '',
check: '',
popout: '',
- settings: ''
+ settings: '',
+ thumbUp: '',
+ thumbDown: ''
};
// CSS Styles
@@ -569,6 +571,64 @@
gap: 8px;
}
+ .osa-message-feedback {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ margin-top: 6px;
+ }
+
+ .osa-feedback-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 26px;
+ height: 26px;
+ padding: 0;
+ border: none;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--osa-text-light);
+ cursor: pointer;
+ transition: background 0.15s ease, color 0.15s ease;
+ }
+
+ .osa-feedback-btn svg {
+ width: 15px;
+ height: 15px;
+ }
+
+ .osa-feedback-btn:hover {
+ background: var(--osa-assistant-bg);
+ color: var(--osa-text);
+ }
+
+ .osa-feedback-up.selected {
+ color: #16a34a;
+ }
+
+ .osa-feedback-down.selected {
+ color: #dc2626;
+ }
+
+ .osa-message-feedback.recorded .osa-feedback-btn {
+ cursor: default;
+ }
+
+ .osa-message-feedback.recorded .osa-feedback-btn:not(.selected) {
+ opacity: 0.3;
+ }
+
+ .osa-message-feedback.recorded .osa-feedback-btn:hover {
+ background: transparent;
+ }
+
+ .osa-feedback-thanks {
+ font-size: 12px;
+ color: var(--osa-text-light);
+ margin-left: 4px;
+ }
+
.osa-suggestions {
padding: 12px 16px;
border-top: 1px solid var(--osa-border);
@@ -810,6 +870,38 @@
text-decoration: underline;
}
+ .osa-combined-footer .osa-footer-powered .osa-feedback-link {
+ background: none;
+ border: none;
+ padding: 0;
+ font: inherit;
+ color: var(--osa-text-light);
+ cursor: pointer;
+ text-decoration: none;
+ }
+
+ .osa-combined-footer .osa-footer-powered .osa-feedback-link:hover {
+ color: var(--osa-primary);
+ text-decoration: underline;
+ }
+
+ .osa-combined-footer .osa-footer-powered .osa-footer-sep {
+ margin: 0 4px;
+ color: var(--osa-border);
+ }
+
+ .osa-feedback-textarea {
+ resize: vertical;
+ min-height: 80px;
+ font-family: inherit;
+ }
+
+ .osa-feedback-modal-thanks {
+ padding: 16px 20px;
+ color: #16a34a;
+ font-weight: 600;
+ }
+
/* Settings modal - contained within chat window to avoid z-index conflicts
and ensure modal is properly scoped to the widget's stacking context */
.osa-settings-overlay {
@@ -1766,6 +1858,117 @@
closeSettings(container);
}
+ // --- Feedback -----------------------------------------------------------
+
+ // Low-level POST to the feedback endpoint. In production the request goes
+ // through the Cloudflare Worker proxy; in development CONFIG.apiEndpoint
+ // points directly to the backend. Best-effort: never throws to the caller.
+ async function postFeedback(payload) {
+ try {
+ const response = await fetch(`${CONFIG.apiEndpoint}/feedback`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ community_id: CONFIG.communityId, ...payload }),
+ });
+ if (!response.ok) {
+ console.warn('[OSA] Feedback submission returned', response.status);
+ return false;
+ }
+ return true;
+ } catch (e) {
+ console.error('[OSA] Failed to submit feedback:', e);
+ return false;
+ }
+ }
+
+ // Record a thumbs up/down on a specific assistant reply. One vote per reply
+ // per browser session (stored in localStorage): once recorded, the choice is
+ // locked in this browser to keep counts honest. The button updates optimistically
+ // for responsiveness, but if the POST fails the vote is rolled back so the user
+ // is not misled into thinking it was saved.
+ async function submitResponseFeedback(container, msgIndex, sentiment) {
+ const msg = messages[msgIndex];
+ if (!msg || msg.role !== 'assistant') return;
+ if (msg.feedback) return; // already voted on this reply
+ if (sentiment !== 'up' && sentiment !== 'down') return;
+
+ // Optimistic update.
+ msg.feedback = sentiment;
+ renderMessages(container);
+
+ const ok = await postFeedback({
+ feedback_type: 'response',
+ sentiment,
+ request_id: msg.requestId || null,
+ session_id: sessionId || null,
+ message_index: msgIndex,
+ });
+
+ if (!ok) {
+ // Roll back so the vote can be retried and the user knows it did not save.
+ delete msg.feedback;
+ renderMessages(container);
+ showError(container, 'Could not send feedback. Please try again.');
+ return;
+ }
+
+ try {
+ saveHistory();
+ } catch (e) {
+ console.error('[OSA] Failed to persist feedback locally:', e);
+ }
+ }
+
+ // Open the general (free-text) feedback modal
+ function openFeedback(container) {
+ const overlay = container.querySelector('.osa-feedback-overlay');
+ if (!overlay) return;
+ const textarea = container.querySelector('#osa-feedback-text');
+ const thanks = container.querySelector('.osa-feedback-modal-thanks');
+ const form = container.querySelector('.osa-feedback-modal-form');
+ if (textarea) textarea.value = '';
+ if (thanks) thanks.style.display = 'none';
+ if (form) form.style.display = 'block';
+ overlay.classList.add('open');
+ if (textarea) textarea.focus();
+ }
+
+ function closeFeedback(container) {
+ const overlay = container.querySelector('.osa-feedback-overlay');
+ if (overlay) overlay.classList.remove('open');
+ }
+
+ // Send free-text general feedback (not tied to a single reply)
+ async function submitGeneralFeedback(container) {
+ const textarea = container.querySelector('#osa-feedback-text');
+ const comment = textarea ? textarea.value.trim() : '';
+ if (!comment) {
+ showError(container, 'Please enter some feedback first.');
+ return;
+ }
+ if (comment.length > 5000) {
+ showError(container, 'Feedback is too long (5000 character max).');
+ return;
+ }
+
+ const ok = await postFeedback({
+ feedback_type: 'general',
+ comment,
+ session_id: sessionId || null,
+ page_url: (typeof window !== 'undefined' && window.location) ? window.location.href : null,
+ });
+
+ if (ok) {
+ const thanks = container.querySelector('.osa-feedback-modal-thanks');
+ const form = container.querySelector('.osa-feedback-modal-form');
+ if (form) form.style.display = 'none';
+ if (thanks) thanks.style.display = 'block';
+ setTimeout(() => closeFeedback(container), 1500);
+ } else {
+ showError(container, 'Could not send feedback. Please try again later.');
+ }
+ }
+
// Check backend health status
async function checkBackendStatus() {
const statusDot = document.querySelector('.osa-status-dot');
@@ -1965,6 +2168,8 @@
@@ -2027,6 +2232,44 @@
+
+
+
+
+
Send feedback
+
+
+
+
+
+
+
+ Shared with the ${escapeHtml(CONFIG.title.replace(' Assistant', ''))} community maintainers. Please do not include personal information.
+
+
+
+
+ Thanks for your feedback!
+
+
+
`;
@@ -2056,12 +2299,26 @@
? ``
: '';
+ // Per-response feedback (thumbs up/down) for assistant replies, but not
+ // the canned opening greeting (index 0). Records sentiment only; free-text
+ // feedback lives in the "Send feedback" link in the footer.
+ const showFeedback = msg.role === 'assistant' && msgIndex > 0;
+ const fb = msg.feedback;
+ const feedbackRow = showFeedback
+ ? `
+
+
+ Thanks!
+
`
+ : '';
+
msgEl.innerHTML = `
${escapeHtml(label)}
${copyBtn}
${content}
+ ${feedbackRow}
`;
messagesEl.appendChild(msgEl);
});
@@ -2093,6 +2350,17 @@
});
});
+ // Per-response thumbs up/down buttons
+ messagesEl.querySelectorAll('.osa-message-feedback .osa-feedback-btn[data-feedback]').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const row = btn.closest('.osa-message-feedback');
+ const msgIndex = parseInt(row.getAttribute('data-msg-index'), 10);
+ const sentiment = btn.getAttribute('data-feedback');
+ submitResponseFeedback(container, msgIndex, sentiment);
+ });
+ });
+
if (isLoading) {
const loadingEl = document.createElement('div');
loadingEl.className = 'osa-loading';
@@ -2236,7 +2504,9 @@
// Log tool completion
console.log('[OSA] Tool completed:', event.name);
} else if (event.event === 'session') {
- // Capture session ID early (sent at stream start)
+ // Capture session ID early (sent at stream start). request_id is
+ // intentionally NOT sent here; it arrives on the 'done' event so it
+ // only attaches to a reply that completed successfully.
if (event.session_id && typeof event.session_id === 'string') {
sessionId = event.session_id;
}
@@ -2251,6 +2521,9 @@
if (event.session_id && typeof event.session_id === 'string') {
sessionId = event.session_id;
}
+ if (event.request_id && typeof event.request_id === 'string') {
+ messages[messageIndex].requestId = event.request_id;
+ }
messages[messageIndex].content = accumulatedContent;
renderMessages(container);
try {
@@ -2473,7 +2746,11 @@
if (!answer) {
throw new Error('Invalid response from server');
}
- messages.push({ role: 'assistant', content: answer });
+ const assistantMsg = { role: 'assistant', content: answer };
+ if (data && typeof data.request_id === 'string') {
+ assistantMsg.requestId = data.request_id;
+ }
+ messages.push(assistantMsg);
try {
saveHistory();
} catch (saveError) {
@@ -2852,6 +3129,23 @@
}
});
+ // Feedback link + modal event listeners
+ const feedbackLink = container.querySelector('.osa-feedback-link');
+ const feedbackOverlay = container.querySelector('.osa-feedback-overlay');
+ const feedbackCloseBtn = container.querySelector('.osa-feedback-close-btn');
+ const feedbackCancelBtn = container.querySelector('.osa-feedback-cancel-btn');
+ const feedbackSendBtn = container.querySelector('.osa-feedback-send-btn');
+
+ feedbackLink?.addEventListener('click', () => openFeedback(container));
+ feedbackCloseBtn?.addEventListener('click', () => closeFeedback(container));
+ feedbackCancelBtn?.addEventListener('click', () => closeFeedback(container));
+ feedbackSendBtn?.addEventListener('click', () => submitGeneralFeedback(container));
+ feedbackOverlay?.addEventListener('click', (e) => {
+ if (e.target === feedbackOverlay) {
+ closeFeedback(container);
+ }
+ });
+
// Show/hide custom model input based on selection
modelSelect?.addEventListener('change', (e) => {
if (customModelField) {
diff --git a/src/api/main.py b/src/api/main.py
index 3d486c6..f248c3b 100644
--- a/src/api/main.py
+++ b/src/api/main.py
@@ -17,6 +17,7 @@
from src.api.routers import (
communities_router,
create_community_router,
+ feedback_router,
metrics_public_router,
metrics_router,
mirrors_router,
@@ -272,6 +273,9 @@ def register_routes(app: FastAPI) -> None:
# Communities metadata endpoint (public, for widget config)
app.include_router(communities_router)
+ # Feedback endpoint (public; anonymous widget users submit thumbs/comments)
+ app.include_router(feedback_router)
+
# Health check router
app.include_router(health_router)
@@ -323,7 +327,9 @@ async def root() -> dict[str, Any]:
endpoints["POST /sync/trigger"] = "Trigger sync (requires API key)"
endpoints["GET /metrics/overview"] = "Metrics overview (requires admin key)"
endpoints["GET /metrics/tokens"] = "Token breakdown (requires admin key)"
+ endpoints["GET /metrics/feedback"] = "User feedback (requires admin key)"
endpoints["GET /metrics/public/overview"] = "Public metrics overview"
+ endpoints["POST /feedback"] = "Submit user feedback (thumbs up/down or comment)"
endpoints["GET /health"] = "Health check"
return {
diff --git a/src/api/routers/__init__.py b/src/api/routers/__init__.py
index df2c667..d343526 100644
--- a/src/api/routers/__init__.py
+++ b/src/api/routers/__init__.py
@@ -2,6 +2,7 @@
from src.api.routers.communities import router as communities_router
from src.api.routers.community import create_community_router
+from src.api.routers.feedback import router as feedback_router
from src.api.routers.metrics import router as metrics_router
from src.api.routers.metrics_public import router as metrics_public_router
from src.api.routers.mirrors import router as mirrors_router
@@ -10,6 +11,7 @@
__all__ = [
"communities_router",
"create_community_router",
+ "feedback_router",
"metrics_public_router",
"metrics_router",
"mirrors_router",
diff --git a/src/api/routers/community.py b/src/api/routers/community.py
index 3e16e5a..0fc6426 100644
--- a/src/api/routers/community.py
+++ b/src/api/routers/community.py
@@ -145,6 +145,10 @@ class ChatResponse(BaseModel):
tool_calls: list[ToolCallInfo] = Field(
default_factory=list, description="Tools called during response generation"
)
+ request_id: str | None = Field(
+ default=None,
+ description="Per-request identifier the widget can attach to feedback",
+ )
class AskResponse(BaseModel):
@@ -154,6 +158,10 @@ class AskResponse(BaseModel):
tool_calls: list[ToolCallInfo] = Field(
default_factory=list, description="Tools called during response generation"
)
+ request_id: str | None = Field(
+ default=None,
+ description="Per-request identifier the widget can attach to feedback",
+ )
class SessionInfo(BaseModel):
@@ -1048,7 +1056,11 @@ async def ask(
ar = _extract_agent_result(result)
_set_metrics_on_request(http_request, awm, ar)
- return AskResponse(answer=ar.response_content, tool_calls=ar.tool_calls_info)
+ return AskResponse(
+ answer=ar.response_content,
+ tool_calls=ar.tool_calls_info,
+ request_id=getattr(http_request.state, "request_id", None),
+ )
except HTTPException:
raise
@@ -1155,6 +1167,7 @@ async def chat(
session_id=session.session_id,
message=ChatMessage(role="assistant", content=ar.response_content),
tool_calls=ar.tool_calls_info,
+ request_id=getattr(http_request.state, "request_id", None),
)
except ValueError as e:
@@ -1595,7 +1608,7 @@ async def _stream_ask_response(
data: {"event": "content", "content": "text chunk"}
data: {"event": "tool_start", "name": "tool_name", "input": {...}}
data: {"event": "tool_end", "name": "tool_name", "output": {...}}
- data: {"event": "done"}
+ data: {"event": "done", "request_id": "..."}
data: {"event": "error", "message": "error text"}
"""
start_time = time.monotonic()
@@ -1604,6 +1617,9 @@ async def _stream_ask_response(
total_input_tokens = 0
total_output_tokens = 0
+ # Per-request id (set by metrics middleware) so the widget can attach feedback.
+ request_id = getattr(http_request.state, "request_id", None) if http_request else None
+
try:
awm = create_community_assistant(
community_id,
@@ -1658,7 +1674,7 @@ async def _stream_ask_response(
}
yield f"data: {json.dumps(sse_event)}\n\n"
- sse_event = {"event": "done"}
+ sse_event = {"event": "done", "request_id": request_id}
yield f"data: {json.dumps(sse_event)}\n\n"
# Log metrics at end of streaming
@@ -1767,7 +1783,8 @@ async def _stream_chat_response(
data: {"event": "tool_start", "name": "tool_name", "input": {...}}
data: {"event": "tool_end", "name": "tool_name", "output": {...}}
data: {"event": "session", "session_id": "..."} (sent first)
- data: {"event": "done", "session_id": "..."}
+ data: {"event": "warning", "message": "..."} (optional, before done)
+ data: {"event": "done", "session_id": "...", "request_id": "..."}
data: {"event": "error", "message": "error text"}
"""
start_time = time.monotonic()
@@ -1776,6 +1793,13 @@ async def _stream_chat_response(
total_input_tokens = 0
total_output_tokens = 0
+ # The metrics middleware assigns a per-request UUID; expose it only on the
+ # final `done` event (below) so the widget attaches it only to a reply that
+ # completed normally. Error paths yield an `error` event instead and never
+ # reach `done`, so a partially-streamed or fully-errored reply carries no
+ # request_id. This also joins per-response feedback back to request_log.
+ request_id = getattr(http_request.state, "request_id", None) if http_request else None
+
# Send session_id immediately so the client captures it even if the
# stream is truncated by a proxy timeout.
sse_event = {"event": "session", "session_id": session.session_id}
@@ -1859,7 +1883,11 @@ async def _stream_chat_response(
}
yield f"data: {json.dumps(sse_event)}\n\n"
- sse_event = {"event": "done", "session_id": session.session_id}
+ sse_event = {
+ "event": "done",
+ "session_id": session.session_id,
+ "request_id": request_id,
+ }
yield f"data: {json.dumps(sse_event)}\n\n"
# Log metrics at end of streaming
diff --git a/src/api/routers/feedback.py b/src/api/routers/feedback.py
new file mode 100644
index 0000000..392f4e0
--- /dev/null
+++ b/src/api/routers/feedback.py
@@ -0,0 +1,145 @@
+"""User feedback API endpoint.
+
+Receives anonymous feedback from the chat widget (per-response thumbs up/down
+and free-text general feedback) and persists it to the metrics database for
+community admins to review on the status dashboard.
+
+This is a top-level (non community-prefixed) route because the widget posts to
+the Cloudflare Worker's global ``POST /feedback`` proxy, which forwards here;
+the community is carried in the request body, not the path.
+"""
+
+import logging
+import uuid
+from typing import Annotated, Any, Literal
+
+from fastapi import APIRouter, Header, HTTPException
+from pydantic import BaseModel, Field, field_validator, model_validator
+
+from src.api.routers.community import _is_authorized_origin
+from src.assistants import registry
+from src.metrics.db import FeedbackEntry, now_iso, write_feedback
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(tags=["Feedback"])
+
+_MAX_COMMENT_LEN = 5000
+
+
+class FeedbackRequest(BaseModel):
+ """Body for ``POST /feedback`` submitted by the widget.
+
+ Two shapes share this model:
+
+ * ``feedback_type="response"`` -- a thumbs up/down on one reply. ``sentiment``
+ is required; ``request_id`` / ``message_index`` link it to the answer.
+ * ``feedback_type="general"`` -- free-text feedback. ``comment`` is required;
+ ``sentiment`` is ignored.
+ """
+
+ community_id: str = Field(..., min_length=1, max_length=64)
+ feedback_type: Literal["response", "general"] = "response"
+ sentiment: Literal["up", "down"] | None = None
+ request_id: str | None = Field(default=None, max_length=128)
+ session_id: str | None = Field(default=None, max_length=128)
+ message_index: int | None = Field(default=None, ge=0)
+ comment: str | None = Field(default=None, max_length=_MAX_COMMENT_LEN)
+ page_url: str | None = Field(default=None, max_length=2048)
+
+ @field_validator("page_url")
+ @classmethod
+ def _validate_page_url_scheme(cls, url: str | None) -> str | None:
+ """Only accept http(s) URLs.
+
+ This endpoint is anonymous, and page_url is later rendered as a clickable
+ link in the admin dashboard. Rejecting non-http(s) schemes prevents a
+ stored 'javascript:'/'data:' link from becoming XSS against an admin.
+ """
+ if url is None:
+ return url
+ if not url.startswith(("http://", "https://")):
+ raise ValueError("page_url must start with http:// or https://")
+ return url
+
+ @model_validator(mode="before")
+ @classmethod
+ def _normalize(cls, data: Any) -> Any:
+ """Normalize the raw input before field validation.
+
+ Runs on the raw dict so the after-validator can be a pure guard: drop
+ any sentiment on general feedback (it is meaningless there) and collapse
+ a whitespace-only comment to None so the DB stays clean.
+ """
+ if not isinstance(data, dict):
+ return data
+ if data.get("feedback_type") == "general":
+ data = {**data, "sentiment": None}
+ comment = data.get("comment")
+ if isinstance(comment, str) and not comment.strip():
+ data = {**data, "comment": None}
+ return data
+
+ @model_validator(mode="after")
+ def _check_shape(self) -> "FeedbackRequest":
+ """Enforce the per-type required fields (pure guard, no mutation)."""
+ if self.feedback_type == "response" and self.sentiment is None:
+ raise ValueError("response feedback requires a sentiment ('up' or 'down')")
+ if self.feedback_type == "general" and not (self.comment and self.comment.strip()):
+ raise ValueError("general feedback requires a non-empty comment")
+ return self
+
+
+class FeedbackResponse(BaseModel):
+ """Acknowledgement returned to the widget."""
+
+ status: Literal["ok"] = "ok"
+ feedback_id: str
+
+
+@router.post("/feedback", response_model=FeedbackResponse)
+async def submit_feedback(
+ body: FeedbackRequest,
+ origin: Annotated[str | None, Header()] = None,
+) -> FeedbackResponse:
+ """Record a piece of user feedback.
+
+ Anonymous by design: widget users carry no API key. The community must be a
+ registered one. The Origin header is checked softly -- an unrecognized or
+ absent origin is logged (CLI, mobile, proxies strip it) but does not reject
+ the submission, mirroring how the chat endpoints degrade gracefully.
+
+ Abuse/DoS defense: the public path to this endpoint is the Cloudflare Worker
+ (`POST /feedback` -> handleFeedback -> rateLimitOrReject), which rate-limits
+ per client before proxying here. There is intentionally no in-process limiter;
+ if this endpoint is ever exposed without the Worker in front, add one.
+ """
+ info = registry.get(body.community_id)
+ if info is None:
+ raise HTTPException(status_code=404, detail=f"Unknown community: {body.community_id}")
+
+ if not _is_authorized_origin(origin, body.community_id):
+ logger.info(
+ "Feedback from unrecognized origin %r for community %s (accepted)",
+ origin,
+ body.community_id,
+ )
+
+ entry = FeedbackEntry(
+ feedback_id=str(uuid.uuid4()),
+ timestamp=now_iso(),
+ community_id=body.community_id,
+ feedback_type=body.feedback_type,
+ sentiment=body.sentiment,
+ request_id=body.request_id,
+ session_id=body.session_id,
+ message_index=body.message_index,
+ comment=body.comment,
+ page_url=body.page_url,
+ )
+
+ # write_feedback is best-effort: it logs and swallows storage errors (and
+ # escalates after repeated failures) rather than failing the user's request,
+ # so the widget always receives a clean acknowledgement.
+ write_feedback(entry)
+ return FeedbackResponse(feedback_id=entry.feedback_id)
diff --git a/src/api/routers/metrics.py b/src/api/routers/metrics.py
index 467c28c..a022d28 100644
--- a/src/api/routers/metrics.py
+++ b/src/api/routers/metrics.py
@@ -14,6 +14,8 @@
from src.metrics.db import metrics_connection
from src.metrics.queries import (
get_community_summary,
+ get_feedback_entries,
+ get_feedback_summary,
get_overview,
get_quality_summary,
get_token_breakdown,
@@ -71,6 +73,47 @@ async def token_breakdown(
)
+@router.get("/feedback")
+async def feedback(
+ auth: RequireScopedAuth,
+ community_id: str | None = Query(default=None, description="Filter by community"),
+ limit: int = Query(default=100, ge=1, le=500, description="Max comment rows to return"),
+ offset: int = Query(default=0, ge=0, description="Rows to skip (pagination)"),
+ comments_only: bool = Query(
+ default=False, description="Return only entries that carry a free-text comment"
+ ),
+) -> dict[str, Any]:
+ """Get user feedback (thumbs up/down counts and free-text comments).
+
+ Global admin keys can filter by any community (or see all). Per-community
+ keys are automatically scoped to their own community.
+ """
+ # Community-scoped keys always filter to their own community
+ effective_community = community_id
+ if auth.role == "community":
+ effective_community = auth.community_id
+
+ try:
+ with metrics_connection() as conn:
+ return {
+ "community_id": effective_community,
+ "summary": get_feedback_summary(conn, community_id=effective_community),
+ "entries": get_feedback_entries(
+ conn,
+ community_id=effective_community,
+ limit=limit,
+ offset=offset,
+ with_comment_only=comments_only,
+ ),
+ }
+ except sqlite3.Error:
+ logger.exception("Failed to query metrics database for feedback")
+ raise HTTPException(
+ status_code=503,
+ detail="Metrics database is temporarily unavailable.",
+ )
+
+
@router.get("/quality")
async def quality_overview(auth: RequireScopedAuth) -> dict[str, Any]:
"""Get quality metrics overview.
diff --git a/src/metrics/db.py b/src/metrics/db.py
index 35b6af5..9e2b621 100644
--- a/src/metrics/db.py
+++ b/src/metrics/db.py
@@ -1,7 +1,7 @@
"""Metrics storage layer using SQLite with WAL mode.
-Single SQLite database at {data_dir}/metrics.db stores all request logs.
-WAL mode enables concurrent reads during writes.
+Single SQLite database at {data_dir}/metrics.db stores request logs and user
+feedback. WAL mode enables concurrent reads during writes.
"""
import json
@@ -12,6 +12,7 @@
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
+from typing import Literal
from langchain_core.messages import AIMessage, BaseMessage
@@ -20,6 +21,14 @@
# Track consecutive log_request failures for escalation
_log_request_failures: int = 0
+# Track consecutive write_feedback failures for escalation
+_write_feedback_failures: int = 0
+
+# Allowed feedback type / sentiment values. These mirror the SQLite CHECK
+# constraints below and the FeedbackEntry / FeedbackRequest invariants.
+FEEDBACK_TYPES = ("response", "general")
+FEEDBACK_SENTIMENTS = ("up", "down")
+
METRICS_SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS request_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -49,6 +58,27 @@
ON request_log(timestamp);
CREATE INDEX IF NOT EXISTS idx_request_log_community_timestamp
ON request_log(community_id, timestamp);
+
+CREATE TABLE IF NOT EXISTS feedback_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ feedback_id TEXT NOT NULL,
+ timestamp TEXT NOT NULL,
+ community_id TEXT NOT NULL,
+ feedback_type TEXT NOT NULL CHECK (feedback_type IN ('response', 'general')),
+ sentiment TEXT CHECK (sentiment IN ('up', 'down') OR sentiment IS NULL),
+ request_id TEXT,
+ session_id TEXT,
+ message_index INTEGER,
+ comment TEXT,
+ page_url TEXT
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS idx_feedback_log_feedback_id
+ ON feedback_log(feedback_id);
+CREATE INDEX IF NOT EXISTS idx_feedback_log_community
+ ON feedback_log(community_id);
+CREATE INDEX IF NOT EXISTS idx_feedback_log_community_timestamp
+ ON feedback_log(community_id, timestamp);
"""
# Columns added after initial schema; ALTER TABLE for existing databases
@@ -83,6 +113,48 @@ class RequestLogEntry:
langfuse_trace_id: str | None = None
+@dataclass
+class FeedbackEntry:
+ """A single user-feedback entry for the metrics database.
+
+ Two flavors share one table:
+
+ * ``feedback_type="response"`` -- a thumbs up/down on a specific assistant
+ reply. ``sentiment`` is required; ``request_id`` (and/or ``message_index``)
+ links it back to the answer it rates.
+ * ``feedback_type="general"`` -- free-text feedback not tied to a single
+ reply. ``sentiment`` is typically ``None`` and ``comment`` carries the text.
+ """
+
+ feedback_id: str
+ timestamp: str
+ community_id: str
+ feedback_type: Literal["response", "general"]
+ sentiment: Literal["up", "down"] | None = None
+ request_id: str | None = None
+ session_id: str | None = None
+ message_index: int | None = None
+ comment: str | None = None
+ page_url: str | None = None
+
+ def __post_init__(self) -> None:
+ """Enforce the storage-layer invariants.
+
+ ``write_feedback`` is a public function callable outside the API router,
+ so this is the last checkpoint before a row reaches SQLite. Reject the
+ illegal shapes the satisfaction-rate query would otherwise be corrupted
+ by (a 'response' with no sentiment, or a 'general' carrying one).
+ """
+ if self.feedback_type not in FEEDBACK_TYPES:
+ raise ValueError(f"feedback_type must be one of {FEEDBACK_TYPES!r}")
+ if self.sentiment is not None and self.sentiment not in FEEDBACK_SENTIMENTS:
+ raise ValueError(f"sentiment must be one of {FEEDBACK_SENTIMENTS!r} or None")
+ if self.feedback_type == "response" and self.sentiment is None:
+ raise ValueError("response feedback requires a sentiment")
+ if self.feedback_type == "general" and self.sentiment is not None:
+ raise ValueError("general feedback must not carry a sentiment")
+
+
def get_metrics_db_path() -> Path:
"""Return path to the metrics SQLite database.
@@ -230,6 +302,67 @@ def log_request(entry: RequestLogEntry, db_path: Path | None = None) -> None:
conn.close()
+def write_feedback(entry: FeedbackEntry, db_path: Path | None = None) -> None:
+ """Insert a feedback entry into the database.
+
+ Mirrors :func:`log_request`: best-effort, never raises to the caller, and
+ escalates to a CRITICAL log after repeated failures so a broken disk or DB
+ surfaces in monitoring instead of silently dropping feedback.
+
+ Args:
+ entry: The feedback entry to insert.
+ db_path: Optional path override (for testing).
+ """
+ global _write_feedback_failures
+ conn: sqlite3.Connection | None = None
+ try:
+ # Acquire the connection inside the try so a connect failure (e.g. the
+ # data dir vanished) is handled here too and the function honors its
+ # "never raises" contract.
+ conn = get_metrics_connection(db_path)
+ conn.execute(
+ """
+ INSERT INTO feedback_log (
+ feedback_id, timestamp, community_id, feedback_type, sentiment,
+ request_id, session_id, message_index, comment, page_url
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ entry.feedback_id,
+ entry.timestamp,
+ entry.community_id,
+ entry.feedback_type,
+ entry.sentiment,
+ entry.request_id,
+ entry.session_id,
+ entry.message_index,
+ entry.comment,
+ entry.page_url,
+ ),
+ )
+ conn.commit()
+ except sqlite3.Error:
+ _write_feedback_failures += 1
+ logger.exception(
+ "Failed to write feedback %s (community=%s, type=%s) [failure #%d]",
+ entry.feedback_id,
+ entry.community_id,
+ entry.feedback_type,
+ _write_feedback_failures,
+ )
+ if _write_feedback_failures >= 10:
+ logger.critical(
+ "Feedback DB write has failed %d times. "
+ "Possible disk/database issue requiring investigation.",
+ _write_feedback_failures,
+ )
+ else:
+ _write_feedback_failures = 0
+ finally:
+ if conn is not None:
+ conn.close()
+
+
def extract_token_usage(result: dict) -> tuple[int, int, int]:
"""Extract token usage from agent result messages.
diff --git a/src/metrics/queries.py b/src/metrics/queries.py
index 8c2d7be..fae34b8 100644
--- a/src/metrics/queries.py
+++ b/src/metrics/queries.py
@@ -593,6 +593,122 @@ def get_quality_summary(community_id: str, conn: sqlite3.Connection) -> dict[str
}
+# ---------------------------------------------------------------------------
+# Feedback queries
+# ---------------------------------------------------------------------------
+
+
+def get_feedback_summary(
+ conn: sqlite3.Connection,
+ community_id: str | None = None,
+) -> dict[str, Any]:
+ """Get aggregate feedback counts.
+
+ Args:
+ conn: SQLite connection (with row_factory=sqlite3.Row).
+ community_id: Filter to a single community, or None for all communities.
+
+ Returns:
+ Dict with thumbs_up, thumbs_down, response_total, general_total,
+ comment_total, and a satisfaction_rate (up / (up + down)).
+ """
+ where = ""
+ params: tuple = ()
+ if community_id:
+ where = "WHERE community_id = ?"
+ params = (community_id,)
+
+ row = conn.execute(
+ f"""
+ SELECT
+ COUNT(CASE WHEN sentiment = 'up' THEN 1 END) as thumbs_up,
+ COUNT(CASE WHEN sentiment = 'down' THEN 1 END) as thumbs_down,
+ COUNT(CASE WHEN feedback_type = 'response' THEN 1 END) as response_total,
+ COUNT(CASE WHEN feedback_type = 'general' THEN 1 END) as general_total,
+ COUNT(CASE WHEN comment IS NOT NULL AND TRIM(comment) != '' THEN 1 END)
+ as comment_total
+ FROM feedback_log
+ {where}
+ """,
+ params,
+ ).fetchone()
+
+ up = row["thumbs_up"]
+ down = row["thumbs_down"]
+ rated = up + down
+
+ return {
+ "community_id": community_id,
+ "thumbs_up": up,
+ "thumbs_down": down,
+ "response_total": row["response_total"],
+ "general_total": row["general_total"],
+ "comment_total": row["comment_total"],
+ "satisfaction_rate": round(up / rated, 4) if rated > 0 else None,
+ }
+
+
+def get_feedback_entries(
+ conn: sqlite3.Connection,
+ community_id: str | None = None,
+ limit: int = 100,
+ offset: int = 0,
+ with_comment_only: bool = False,
+) -> list[dict[str, Any]]:
+ """Get individual feedback rows, most recent first.
+
+ Args:
+ conn: SQLite connection.
+ community_id: Filter to a single community, or None for all communities.
+ limit: Maximum number of rows to return (clamped 1..500).
+ offset: Number of rows to skip (for pagination).
+ with_comment_only: If True, return only rows that carry a free-text comment.
+
+ Returns:
+ List of dicts with timestamp, community_id, feedback_type, sentiment,
+ comment, request_id, session_id, message_index, page_url.
+ """
+ limit = max(1, min(int(limit), 500))
+ offset = max(0, int(offset))
+
+ clauses: list[str] = []
+ params: list[Any] = []
+ if community_id:
+ clauses.append("community_id = ?")
+ params.append(community_id)
+ if with_comment_only:
+ clauses.append("comment IS NOT NULL AND TRIM(comment) != ''")
+ where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
+
+ rows = conn.execute(
+ f"""
+ SELECT
+ timestamp, community_id, feedback_type, sentiment,
+ comment, request_id, session_id, message_index, page_url
+ FROM feedback_log
+ {where}
+ ORDER BY timestamp DESC, id DESC
+ LIMIT ? OFFSET ?
+ """,
+ (*params, limit, offset),
+ ).fetchall()
+
+ return [
+ {
+ "timestamp": r["timestamp"],
+ "community_id": r["community_id"],
+ "feedback_type": r["feedback_type"],
+ "sentiment": r["sentiment"],
+ "comment": r["comment"],
+ "request_id": r["request_id"],
+ "session_id": r["session_id"],
+ "message_index": r["message_index"],
+ "page_url": r["page_url"],
+ }
+ for r in rows
+ ]
+
+
def _percentile(sorted_values: list[float], pct: float) -> float | None:
"""Compute percentile from a sorted list of values.
diff --git a/tests/test_api/test_feedback_endpoint.py b/tests/test_api/test_feedback_endpoint.py
new file mode 100644
index 0000000..b307cf8
--- /dev/null
+++ b/tests/test_api/test_feedback_endpoint.py
@@ -0,0 +1,339 @@
+"""Tests for the feedback API: POST /feedback and GET /metrics/feedback."""
+
+import os
+
+import pytest
+from fastapi.testclient import TestClient
+
+from src.api.main import app
+from src.metrics.db import get_metrics_connection, init_metrics_db
+from src.metrics.queries import get_feedback_summary
+
+ADMIN_KEY = "test-feedback-admin-key"
+HED_KEY = "hed-community-key"
+
+
+@pytest.fixture
+def feedback_db(tmp_path):
+ """Isolated, empty metrics DB via DATA_DIR (real path resolution, no mocks).
+
+ get_metrics_db_path() reads DATA_DIR, so pointing it at tmp_path redirects
+ every metrics code path (writes, reads, middleware) to a throwaway DB.
+ """
+ os.environ["DATA_DIR"] = str(tmp_path)
+ init_metrics_db()
+ yield tmp_path / "metrics.db"
+ del os.environ["DATA_DIR"]
+
+
+@pytest.fixture
+def scoped_auth_env():
+ """Admin key + per-community key for hed."""
+ from src.api.config import get_settings
+
+ os.environ["API_KEYS"] = ADMIN_KEY
+ os.environ["REQUIRE_API_AUTH"] = "true"
+ os.environ["COMMUNITY_ADMIN_KEYS"] = f"hed:{HED_KEY}"
+ get_settings.cache_clear()
+ yield
+ del os.environ["API_KEYS"]
+ del os.environ["REQUIRE_API_AUTH"]
+ del os.environ["COMMUNITY_ADMIN_KEYS"]
+ get_settings.cache_clear()
+
+
+@pytest.fixture
+def client():
+ return TestClient(app)
+
+
+@pytest.mark.usefixtures("feedback_db")
+class TestSubmitFeedback:
+ """POST /feedback (anonymous, no auth)."""
+
+ def test_thumbs_up(self, client):
+ resp = client.post(
+ "/feedback",
+ json={
+ "community_id": "hed",
+ "feedback_type": "response",
+ "sentiment": "up",
+ "request_id": "req-1",
+ "session_id": "sess-1",
+ "message_index": 1,
+ },
+ )
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["status"] == "ok"
+ assert body["feedback_id"]
+
+ def test_thumbs_down_with_comment(self, client):
+ resp = client.post(
+ "/feedback",
+ json={
+ "community_id": "hed",
+ "feedback_type": "response",
+ "sentiment": "down",
+ "comment": "missed the BIDS sidecar question",
+ },
+ )
+ assert resp.status_code == 200
+
+ def test_general_feedback(self, client):
+ resp = client.post(
+ "/feedback",
+ json={
+ "community_id": "hed",
+ "feedback_type": "general",
+ "comment": "Please add more examples.",
+ },
+ )
+ assert resp.status_code == 200
+
+ def test_unknown_community_rejected(self, client):
+ resp = client.post(
+ "/feedback",
+ json={
+ "community_id": "not-a-real-community",
+ "feedback_type": "response",
+ "sentiment": "up",
+ },
+ )
+ assert resp.status_code == 404
+
+ def test_response_without_sentiment_rejected(self, client):
+ resp = client.post(
+ "/feedback",
+ json={"community_id": "hed", "feedback_type": "response"},
+ )
+ assert resp.status_code == 422
+
+ def test_general_without_comment_rejected(self, client):
+ resp = client.post(
+ "/feedback",
+ json={"community_id": "hed", "feedback_type": "general"},
+ )
+ assert resp.status_code == 422
+
+ def test_oversized_comment_rejected(self, client):
+ resp = client.post(
+ "/feedback",
+ json={
+ "community_id": "hed",
+ "feedback_type": "general",
+ "comment": "x" * 6000,
+ },
+ )
+ assert resp.status_code == 422
+
+ @pytest.mark.parametrize(
+ "bad_url",
+ [
+ "javascript:alert(1)",
+ "data:text/html,",
+ "ftp://evil.example/x",
+ ],
+ )
+ def test_non_http_page_url_rejected(self, client, bad_url):
+ # page_url is rendered as a link in the admin dashboard; only http(s)
+ # schemes are allowed to prevent stored XSS against admins.
+ resp = client.post(
+ "/feedback",
+ json={
+ "community_id": "hed",
+ "feedback_type": "response",
+ "sentiment": "up",
+ "page_url": bad_url,
+ },
+ )
+ assert resp.status_code == 422
+
+ def test_general_sentiment_is_stripped(self, feedback_db, client):
+ # A general payload that smuggles a sentiment must be stored with NULL
+ # sentiment so it never counts as a thumbs-down.
+ resp = client.post(
+ "/feedback",
+ json={
+ "community_id": "hed",
+ "feedback_type": "general",
+ "sentiment": "down",
+ "comment": "smuggled sentiment",
+ },
+ )
+ assert resp.status_code == 200
+ conn = get_metrics_connection(feedback_db)
+ try:
+ row = conn.execute(
+ "SELECT sentiment FROM feedback_log WHERE feedback_type='general'"
+ ).fetchone()
+ summary = get_feedback_summary(conn, community_id="hed")
+ finally:
+ conn.close()
+ assert row["sentiment"] is None
+ assert summary["thumbs_down"] == 0
+
+ def test_http_page_url_accepted(self, client):
+ resp = client.post(
+ "/feedback",
+ json={
+ "community_id": "hed",
+ "feedback_type": "response",
+ "sentiment": "up",
+ "page_url": "https://hedtags.org/page",
+ },
+ )
+ assert resp.status_code == 200
+
+ def test_persisted_and_readable(self, feedback_db, client):
+ client.post(
+ "/feedback",
+ json={"community_id": "hed", "feedback_type": "response", "sentiment": "up"},
+ )
+ conn = get_metrics_connection(feedback_db)
+ try:
+ summary = get_feedback_summary(conn, community_id="hed")
+ finally:
+ conn.close()
+ assert summary["thumbs_up"] == 1
+
+ def test_request_id_round_trips(self, feedback_db, client):
+ # The widget keys a vote on the request_id from the chat `done` event;
+ # the stored row must carry it so feedback joins back to request_log.
+ client.post(
+ "/feedback",
+ json={
+ "community_id": "hed",
+ "feedback_type": "response",
+ "sentiment": "down",
+ "request_id": "req-xyz-123",
+ "message_index": 4,
+ },
+ )
+ conn = get_metrics_connection(feedback_db)
+ try:
+ row = conn.execute("SELECT request_id, message_index FROM feedback_log").fetchone()
+ finally:
+ conn.close()
+ assert row["request_id"] == "req-xyz-123"
+ assert row["message_index"] == 4
+
+
+@pytest.mark.usefixtures("feedback_db", "scoped_auth_env")
+class TestReadFeedback:
+ """GET /metrics/feedback (scoped admin auth)."""
+
+ def _seed(self, client):
+ client.post(
+ "/feedback",
+ json={"community_id": "hed", "feedback_type": "response", "sentiment": "up"},
+ )
+ client.post(
+ "/feedback",
+ json={
+ "community_id": "eeglab",
+ "feedback_type": "response",
+ "sentiment": "down",
+ },
+ )
+
+ def test_admin_sees_all(self, client):
+ self._seed(client)
+ resp = client.get("/metrics/feedback", headers={"X-API-Key": ADMIN_KEY})
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["summary"]["thumbs_up"] == 1
+ assert data["summary"]["thumbs_down"] == 1
+
+ def test_admin_aggregates_across_communities(self, client):
+ # Two up votes in different communities must sum, proving the admin view
+ # is not silently scoped to one community.
+ client.post(
+ "/feedback",
+ json={"community_id": "hed", "feedback_type": "response", "sentiment": "up"},
+ )
+ client.post(
+ "/feedback",
+ json={"community_id": "eeglab", "feedback_type": "response", "sentiment": "up"},
+ )
+ resp = client.get("/metrics/feedback", headers={"X-API-Key": ADMIN_KEY})
+ assert resp.json()["summary"]["thumbs_up"] == 2
+
+ def test_comments_only_filter(self, client):
+ client.post(
+ "/feedback",
+ json={"community_id": "hed", "feedback_type": "response", "sentiment": "up"},
+ )
+ client.post(
+ "/feedback",
+ json={"community_id": "hed", "feedback_type": "general", "comment": "great tool"},
+ )
+ resp = client.get(
+ "/metrics/feedback",
+ params={"comments_only": "true"},
+ headers={"X-API-Key": HED_KEY},
+ )
+ entries = resp.json()["entries"]
+ assert len(entries) == 1
+ assert entries[0]["comment"] == "great tool"
+
+ def test_limit_above_max_rejected(self, client):
+ resp = client.get(
+ "/metrics/feedback",
+ params={"limit": 9999},
+ headers={"X-API-Key": HED_KEY},
+ )
+ assert resp.status_code == 422
+
+ def test_offset_pagination(self, client):
+ for i in range(3):
+ client.post(
+ "/feedback",
+ json={
+ "community_id": "hed",
+ "feedback_type": "general",
+ "comment": f"note {i}",
+ },
+ )
+ page1 = client.get(
+ "/metrics/feedback",
+ params={"limit": 2, "offset": 0},
+ headers={"X-API-Key": HED_KEY},
+ ).json()["entries"]
+ page2 = client.get(
+ "/metrics/feedback",
+ params={"limit": 2, "offset": 2},
+ headers={"X-API-Key": HED_KEY},
+ ).json()["entries"]
+ assert len(page1) == 2
+ assert len(page2) == 1
+ # No overlap between pages
+ seen = {e["comment"] for e in page1} | {e["comment"] for e in page2}
+ assert seen == {"note 0", "note 1", "note 2"}
+
+ def test_community_key_scoped_to_own(self, client):
+ self._seed(client)
+ resp = client.get("/metrics/feedback", headers={"X-API-Key": HED_KEY})
+ assert resp.status_code == 200
+ data = resp.json()
+ # hed key must only see hed's up vote, not eeglab's down vote
+ assert data["community_id"] == "hed"
+ assert data["summary"]["thumbs_up"] == 1
+ assert data["summary"]["thumbs_down"] == 0
+ assert all(e["community_id"] == "hed" for e in data["entries"])
+
+ def test_community_key_cannot_override_filter(self, client):
+ self._seed(client)
+ # hed key asks for eeglab; must still be scoped to hed
+ resp = client.get(
+ "/metrics/feedback",
+ params={"community_id": "eeglab"},
+ headers={"X-API-Key": HED_KEY},
+ )
+ data = resp.json()
+ assert data["community_id"] == "hed"
+ assert data["summary"]["thumbs_down"] == 0
+
+ def test_requires_auth(self, client):
+ resp = client.get("/metrics/feedback")
+ assert resp.status_code == 401
diff --git a/tests/test_metrics/test_feedback_db.py b/tests/test_metrics/test_feedback_db.py
new file mode 100644
index 0000000..515c246
--- /dev/null
+++ b/tests/test_metrics/test_feedback_db.py
@@ -0,0 +1,320 @@
+"""Tests for the feedback storage layer (feedback_log table + queries)."""
+
+import logging
+
+import pytest
+
+import src.metrics.db as db_module
+from src.metrics.db import (
+ FeedbackEntry,
+ get_metrics_connection,
+ init_metrics_db,
+ now_iso,
+ write_feedback,
+)
+from src.metrics.queries import get_feedback_entries, get_feedback_summary
+
+
+@pytest.fixture
+def reset_feedback_counter():
+ """Reset the module-global failure counter around tests that exercise it."""
+ db_module._write_feedback_failures = 0
+ yield
+ db_module._write_feedback_failures = 0
+
+
+@pytest.fixture
+def feedback_db(tmp_path):
+ """Create a temporary metrics database with feedback rows."""
+ db_path = tmp_path / "metrics.db"
+ init_metrics_db(db_path)
+
+ entries = [
+ FeedbackEntry(
+ feedback_id="f1",
+ timestamp="2025-01-15T10:00:00+00:00",
+ community_id="hed",
+ feedback_type="response",
+ sentiment="up",
+ request_id="r1",
+ session_id="s1",
+ message_index=0,
+ ),
+ FeedbackEntry(
+ feedback_id="f2",
+ timestamp="2025-01-15T11:00:00+00:00",
+ community_id="hed",
+ feedback_type="response",
+ sentiment="down",
+ request_id="r2",
+ session_id="s1",
+ message_index=2,
+ comment="answer was wrong about epochs",
+ ),
+ FeedbackEntry(
+ feedback_id="f3",
+ timestamp="2025-01-15T12:00:00+00:00",
+ community_id="hed",
+ feedback_type="general",
+ sentiment=None,
+ comment="love this assistant",
+ page_url="https://hedtags.org",
+ ),
+ # A different community, to prove scoping
+ FeedbackEntry(
+ feedback_id="f4",
+ timestamp="2025-01-15T13:00:00+00:00",
+ community_id="eeglab",
+ feedback_type="response",
+ sentiment="up",
+ ),
+ ]
+ for e in entries:
+ write_feedback(e, db_path=db_path)
+
+ return db_path
+
+
+class TestFeedbackTable:
+ """Schema creation for feedback_log."""
+
+ def test_table_created(self, tmp_path):
+ db_path = tmp_path / "metrics.db"
+ init_metrics_db(db_path)
+ conn = get_metrics_connection(db_path)
+ try:
+ tables = conn.execute(
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='feedback_log'"
+ ).fetchall()
+ assert len(tables) == 1
+ finally:
+ conn.close()
+
+ def test_table_added_to_existing_db(self, tmp_path):
+ """A pre-existing DB without feedback_log gets the table on re-init.
+
+ This proves the CREATE TABLE IF NOT EXISTS path auto-provisions the
+ new table on already-deployed databases without a bespoke migration.
+ Simulated by initializing the full schema, dropping feedback_log to
+ recreate an "old" DB, then re-running init.
+ """
+ db_path = tmp_path / "metrics.db"
+ init_metrics_db(db_path)
+
+ conn = get_metrics_connection(db_path)
+ conn.execute("DROP TABLE feedback_log")
+ conn.commit()
+ conn.close()
+
+ # Re-init (mirrors a deploy bringing the new schema).
+ init_metrics_db(db_path)
+
+ conn = get_metrics_connection(db_path)
+ try:
+ tables = conn.execute(
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='feedback_log'"
+ ).fetchall()
+ assert len(tables) == 1
+ finally:
+ conn.close()
+
+
+class TestWriteFeedback:
+ """write_feedback() persistence."""
+
+ def test_round_trip(self, tmp_path):
+ db_path = tmp_path / "metrics.db"
+ init_metrics_db(db_path)
+ write_feedback(
+ FeedbackEntry(
+ feedback_id="x1",
+ timestamp=now_iso(),
+ community_id="hed",
+ feedback_type="response",
+ sentiment="up",
+ request_id="req-abc",
+ ),
+ db_path=db_path,
+ )
+ conn = get_metrics_connection(db_path)
+ try:
+ row = conn.execute("SELECT * FROM feedback_log WHERE feedback_id = 'x1'").fetchone()
+ assert row["sentiment"] == "up"
+ assert row["request_id"] == "req-abc"
+ assert row["feedback_type"] == "response"
+ finally:
+ conn.close()
+
+ @pytest.mark.usefixtures("reset_feedback_counter")
+ def test_failure_counter_increments_and_resets(self, tmp_path):
+ # A UNIQUE feedback_id collision makes the second write fail at INSERT.
+ # write_feedback must swallow it, bump the counter, then reset on success.
+ db_path = tmp_path / "metrics.db"
+ init_metrics_db(db_path)
+ dup = FeedbackEntry(
+ feedback_id="dup",
+ timestamp=now_iso(),
+ community_id="hed",
+ feedback_type="response",
+ sentiment="up",
+ )
+ write_feedback(dup, db_path=db_path)
+ assert db_module._write_feedback_failures == 0
+ write_feedback(dup, db_path=db_path) # duplicate -> swallowed failure
+ assert db_module._write_feedback_failures == 1
+ write_feedback(
+ FeedbackEntry(
+ feedback_id="ok2",
+ timestamp=now_iso(),
+ community_id="hed",
+ feedback_type="response",
+ sentiment="up",
+ ),
+ db_path=db_path,
+ )
+ assert db_module._write_feedback_failures == 0
+
+ @pytest.mark.usefixtures("reset_feedback_counter")
+ def test_failure_escalates_to_critical(self, tmp_path, caplog):
+ db_path = tmp_path / "metrics.db"
+ init_metrics_db(db_path)
+ dup = FeedbackEntry(
+ feedback_id="d",
+ timestamp=now_iso(),
+ community_id="hed",
+ feedback_type="response",
+ sentiment="up",
+ )
+ write_feedback(dup, db_path=db_path) # first succeeds
+ with caplog.at_level(logging.CRITICAL):
+ for _ in range(10):
+ write_feedback(dup, db_path=db_path) # all collide
+ assert any(r.levelno == logging.CRITICAL for r in caplog.records)
+
+
+class TestFeedbackEntryInvariants:
+ """FeedbackEntry.__post_init__ rejects illegal shapes at the storage layer."""
+
+ def test_response_requires_sentiment(self):
+ with pytest.raises(ValueError, match="response feedback requires a sentiment"):
+ FeedbackEntry(
+ feedback_id="a",
+ timestamp=now_iso(),
+ community_id="hed",
+ feedback_type="response",
+ sentiment=None,
+ )
+
+ def test_general_must_not_carry_sentiment(self):
+ with pytest.raises(ValueError, match="general feedback must not carry a sentiment"):
+ FeedbackEntry(
+ feedback_id="b",
+ timestamp=now_iso(),
+ community_id="hed",
+ feedback_type="general",
+ sentiment="up",
+ comment="x",
+ )
+
+ def test_bad_feedback_type_rejected(self):
+ with pytest.raises(ValueError, match="feedback_type must be"):
+ FeedbackEntry(
+ feedback_id="c",
+ timestamp=now_iso(),
+ community_id="hed",
+ feedback_type="bogus",
+ )
+
+ def test_bad_sentiment_rejected(self):
+ with pytest.raises(ValueError, match="sentiment must be"):
+ FeedbackEntry(
+ feedback_id="d",
+ timestamp=now_iso(),
+ community_id="hed",
+ feedback_type="response",
+ sentiment="meh",
+ )
+
+
+class TestFeedbackSummary:
+ """get_feedback_summary() aggregation."""
+
+ def test_counts_for_community(self, feedback_db):
+ conn = get_metrics_connection(feedback_db)
+ try:
+ summary = get_feedback_summary(conn, community_id="hed")
+ finally:
+ conn.close()
+ assert summary["thumbs_up"] == 1
+ assert summary["thumbs_down"] == 1
+ assert summary["response_total"] == 2
+ assert summary["general_total"] == 1
+ assert summary["comment_total"] == 2 # f2 (note) + f3 (general)
+ assert summary["satisfaction_rate"] == 0.5
+
+ def test_counts_all_communities(self, feedback_db):
+ conn = get_metrics_connection(feedback_db)
+ try:
+ summary = get_feedback_summary(conn, community_id=None)
+ finally:
+ conn.close()
+ assert summary["thumbs_up"] == 2 # hed f1 + eeglab f4
+ assert summary["thumbs_down"] == 1
+
+ def test_satisfaction_rate_none_when_no_ratings(self, tmp_path):
+ db_path = tmp_path / "metrics.db"
+ init_metrics_db(db_path)
+ conn = get_metrics_connection(db_path)
+ try:
+ summary = get_feedback_summary(conn, community_id="hed")
+ finally:
+ conn.close()
+ assert summary["satisfaction_rate"] is None
+
+
+class TestFeedbackEntries:
+ """get_feedback_entries() listing."""
+
+ def test_scoped_to_community(self, feedback_db):
+ conn = get_metrics_connection(feedback_db)
+ try:
+ rows = get_feedback_entries(conn, community_id="hed")
+ finally:
+ conn.close()
+ assert len(rows) == 3
+ assert all(r["community_id"] == "hed" for r in rows)
+ # Most recent first
+ assert rows[0]["timestamp"] >= rows[-1]["timestamp"]
+
+ def test_comments_only_filter(self, feedback_db):
+ conn = get_metrics_connection(feedback_db)
+ try:
+ rows = get_feedback_entries(conn, community_id="hed", with_comment_only=True)
+ finally:
+ conn.close()
+ assert len(rows) == 2
+ assert all(r["comment"] for r in rows)
+
+ def test_limit_floor_clamped(self, feedback_db):
+ # limit=0 must be clamped up to 1 (without clamping, SQL LIMIT 0 would
+ # return zero rows). Asserting a non-empty result tests the clamp itself.
+ conn = get_metrics_connection(feedback_db)
+ try:
+ rows = get_feedback_entries(conn, community_id="hed", limit=0)
+ finally:
+ conn.close()
+ assert len(rows) == 1
+
+ def test_offset_pagination(self, feedback_db):
+ # hed has 3 entries; page through them with limit=2.
+ conn = get_metrics_connection(feedback_db)
+ try:
+ page1 = get_feedback_entries(conn, community_id="hed", limit=2, offset=0)
+ page2 = get_feedback_entries(conn, community_id="hed", limit=2, offset=2)
+ finally:
+ conn.close()
+ assert len(page1) == 2
+ assert len(page2) == 1
+ stamps = {r["timestamp"] for r in page1} | {r["timestamp"] for r in page2}
+ # No overlap, full coverage of the 3 hed rows.
+ assert len(stamps) == 3
From 29405b2b3b0f672e560d27b6266cbb7cf7d46084 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Fri, 5 Jun 2026 02:35:42 +0000
Subject: [PATCH 07/39] Bump version to 0.8.4.dev3
---
src/version.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/version.py b/src/version.py
index d7db3dc..8deae8a 100644
--- a/src/version.py
+++ b/src/version.py
@@ -1,7 +1,7 @@
"""Version information for OSA."""
-__version__ = "0.8.4.dev2"
-__version_info__ = (0, 8, 4, "dev2")
+__version__ = "0.8.4.dev3"
+__version_info__ = (0, 8, 4, "dev3")
def get_version() -> str:
From b296ebffb396c7f6dc8e9ae06a01841406da8d96 Mon Sep 17 00:00:00 2001
From: "Seyed (Yahya) Shirazi"
Date: Thu, 4 Jun 2026 19:39:19 -0700
Subject: [PATCH 08/39] feat: community-admin scoped PR auto-merge (#285)
* feat: community-admin scoped PR auto-merge
New workflow approves + squash auto-merges a PR only when the author is in
the target community's maintainers list (read from the BASE branch), the
diff touches only src/assistants//**, and the PR does not edit the
maintainers field. Uses a dedicated GitHub App token (not the CI PAT);
pull_request_target with base-only checkout never runs PR-head code;
required CI checks still gate the merge. Community-id is whitelisted before
interpolation. Includes a setup/trust-model doc.
Validated: YAML + every embedded bash step (bash -n).
Closes #283
* fix: address PR #285 security review
- permissions: add pull-requests:read (the eligibility step reads the PR
file list with github.token; with explicit permissions the unspecified
scope is 'none', which would 403 every PR).
- Scope bypass fix: read the changed-file list from the paginated
/pulls/{n}/files API and check BOTH .filename and .previous_filename.
gh pr diff --name-only omitted a rename's OLD path (letting a file be
moved from outside the community dir into it) and truncated at 300 files.
- Injection hardening: pass user.login and head.sha via env vars instead of
interpolating ${{ }} into the run-step shell body.
- A config.yaml deleted/renamed-away is now caught (its old path appears in
the file list, the head-config fetch 404s, and the PR is held for review).
- Checkout: drop fetch-depth:0 (only BASE_SHA is needed); fix the comment.
- Comment/doc accuracy: GITHUB_TOKEN approval restriction wording, the
what-it-does step ordering (maintainers-field check before author check),
and the setup-incomplete behavior wording.
---
.context/community-admin-merge.md | 166 ++++++++
.../workflows/community-admin-pr-merge.yml | 386 ++++++++++++++++++
CLAUDE.md | 1 +
3 files changed, 553 insertions(+)
create mode 100644 .context/community-admin-merge.md
create mode 100644 .github/workflows/community-admin-pr-merge.yml
diff --git a/.context/community-admin-merge.md b/.context/community-admin-merge.md
new file mode 100644
index 0000000..5310f92
--- /dev/null
+++ b/.context/community-admin-merge.md
@@ -0,0 +1,166 @@
+# Community Admin Auto-Merge
+
+Lets a community maintainer auto-merge a pull request (PR) that touches ONLY
+their own community's directory, without waiting on a human reviewer. The normal
+required status checks (ruff + tests) still gate the actual merge, so this only
+removes the human-approval step, not the CI gate.
+
+Workflow file: `.github/workflows/community-admin-pr-merge.yml`
+
+## What it does
+
+When a PR is opened, synchronized, reopened, or marked ready-for-review against
+`develop` or `main`, the workflow:
+
+1. Reads the PR's changed-file list via the GitHub API.
+2. Confirms every changed path is under a single `src/assistants//` tree
+ (one community, nothing outside it).
+3. Reads the `maintainers:` list from the BASE branch copy of
+ `src/assistants//config.yaml`.
+4. Confirms the PR does NOT change the `maintainers:` field itself (a covert
+ admin-list change is rejected before the author is even considered).
+5. Confirms the PR author is in that list (case-insensitive).
+6. If all checks pass: approves the PR and enables a SQUASH auto-merge using a
+ dedicated GitHub App token. The merge fires only after required checks pass.
+
+If any check fails, or anything is ambiguous, the workflow no-ops (logs a notice
+and exits cleanly). The PR then follows the normal human-review path. Default
+posture is "do not merge".
+
+## Trust model
+
+- Source of truth for authorization is the `maintainers:` list in the
+ community's `config.yaml`, read from the BASE branch (the already-merged,
+ trusted code), NEVER from the PR head. This prevents a PR from granting itself
+ merge rights by adding its author to the list in the same change.
+- Scope is path-restricted to exactly one `src/assistants//` directory.
+ A PR that touches two communities, or touches anything outside a community
+ directory (including `src/version.py`, CI, top-level files), is ineligible.
+- Edits to the `maintainers:` field are excluded from auto-merge. Changing who
+ holds admin power always requires human review. The workflow detects this by
+ parsing the base vs head copies of `config.yaml` and comparing the normalized
+ maintainers lists.
+- The actual merge still requires all branch-protection required checks to be
+ green. Auto-merge only removes the manual-approval gate.
+- The workflow uses `pull_request_target` so it has access to secrets even for
+ fork PRs, but it NEVER checks out or executes PR-head code. The only checkout
+ is the trusted base branch; the PR diff is read through the API only. The head
+ copy of `config.yaml` is fetched and parsed solely to compare the maintainers
+ field; it is never executed.
+
+## Token model
+
+All write actions (approve + merge + confirmation comment) use a SHORT-LIVED
+installation token minted from a dedicated GitHub App via
+`actions/create-github-app-token@v1`. This App is independent from the
+`CI_ADMIN_TOKEN` personal access token (PAT) used by the version-automation
+workflows. The workflow's own `GITHUB_TOKEN` is kept at `contents: read` only,
+so the workflow has no standing write power; every write is attributable to the
+App.
+
+## One-time setup (human)
+
+These steps are done once by a repo admin. Until they are complete, the workflow
+evaluates every PR but skips the write steps (mint, approve, merge) for
+ineligible PRs. If an *eligible* PR is opened before the secrets exist, the
+token-mint step fails with an error (the PR is not merged). Finish all steps
+before relying on it.
+
+### 1. Create the GitHub App
+
+GitHub -> Settings (your org `OpenScience-Collective`) -> Developer settings ->
+GitHub Apps -> New GitHub App.
+
+- GitHub App name: e.g. `OSA Community Auto-Merge`.
+- Homepage URL: the repo URL is fine (`https://github.com/OpenScience-Collective/osa`).
+- Webhook: UNCHECK "Active". No webhook is needed; the Action invokes the App by
+ minting a token, the App does not need to receive events.
+- Repository permissions (set ONLY these; leave everything else "No access"):
+ - Pull requests: Read and write (needed to approve and to enable auto-merge)
+ - Contents: Read and write (needed for the squash merge to write the commit)
+ - Metadata: Read-only (mandatory; GitHub sets this automatically)
+- Organization permissions: none.
+- Account permissions: none.
+- "Where can this GitHub App be installed?": Only on this account.
+
+Create the App. Note the App ID shown on the App's settings page.
+
+### 2. Install the App on this repo
+
+From the App's settings page -> Install App -> install it on
+`OpenScience-Collective`, and restrict it to the `osa` repository only
+("Only select repositories" -> `osa`).
+
+### 3. Generate a private key
+
+On the App's settings page -> "Private keys" -> "Generate a private key". This
+downloads a `.pem` file. Keep it secret; you will paste its full contents into a
+repo secret in the next step. You cannot retrieve it again later, only generate
+a new one.
+
+### 4. Add the two repo secrets
+
+Repo -> Settings -> Secrets and variables -> Actions -> New repository secret:
+
+- `COMMUNITY_MERGE_APP_ID` = the numeric App ID from step 1.
+- `COMMUNITY_MERGE_APP_PRIVATE_KEY` = the full contents of the `.pem` file from
+ step 3 (including the `-----BEGIN ... KEY-----` / `-----END ... KEY-----`
+ lines).
+
+### 5. Enable "Allow auto-merge" on the repo
+
+The workflow uses `gh pr merge --auto`, which requires the repository-level
+auto-merge feature to be turned on. Repo -> Settings -> General -> "Pull
+Requests" section -> check "Allow auto-merge". Without this, the merge step
+fails (the PR will still be approved, but it will not merge automatically).
+
+### 6. Let the App merge past branch protection
+
+Branch protection on `develop` and `main` is configured in the GitHub UI (not in
+the repo). Make sure the App is allowed to merge:
+
+- Required status checks must stay ON (ruff + tests). The App does NOT bypass
+ these; auto-merge waits for them. Do not add the App to any
+ "allow specified actors to bypass required pull requests / status checks"
+ allowlist; it should merge through the same gate everyone else uses.
+- If a branch has "Restrict who can push to matching branches" (a push/merge
+ allowlist) enabled, add the GitHub App to that allowlist so its merge is
+ permitted. If that restriction is not enabled, no action is needed.
+- "Require approvals" can stay on; the App's approval satisfies it. If you use
+ "Dismiss stale approvals", note that a new push will dismiss the App's
+ approval and the workflow will re-approve on the next `synchronize` event.
+
+## How a community admin uses it
+
+1. Open a PR against `develop` (or `main`) that changes only files inside your
+ community directory, `src/assistants//` (config.yaml, tools.py,
+ prompts, logo, etc.).
+2. Make sure you are a listed maintainer in that directory's `config.yaml`.
+3. Do not change the `maintainers:` field in the same PR; that needs human
+ review.
+4. The workflow approves the PR and enables squash auto-merge. The PR merges
+ automatically once ruff and the test suite pass. A confirmation comment is
+ posted (and updated in place on later pushes, not duplicated).
+
+If your PR is not eligible (touches more than one community, touches files
+outside your directory, changes the maintainers list, or you are not a listed
+maintainer), the workflow simply does nothing and the PR follows the normal
+review process.
+
+## Security notes and limitations
+
+- The `maintainers:` list is hand-maintained in each `config.yaml`. There is no
+ sync with GitHub Teams or org membership; whoever is listed there is trusted to
+ merge changes to that community's directory.
+- Adding or removing a maintainer is intentionally excluded from auto-merge and
+ always requires a human-reviewed PR.
+- A brand-new community (no `config.yaml` on the base branch yet) cannot be
+ auto-merged, because there is no trusted maintainers list to authorize
+ against. The first PR introducing a community needs human review.
+- A `config.yaml` that fails to parse (malformed YAML, missing or empty
+ maintainers, non-string entries) is treated as "not eligible".
+- Auto-merging community changes into `main` will NOT cut a release. Releases
+ are triggered by changes to `src/version.py`, and community PRs are
+ path-restricted so they never touch it.
+- The workflow uses `pull_request_target` but never runs PR-head code; see the
+ Trust model section and the comment block at the top of the workflow file.
diff --git a/.github/workflows/community-admin-pr-merge.yml b/.github/workflows/community-admin-pr-merge.yml
new file mode 100644
index 0000000..89e6d79
--- /dev/null
+++ b/.github/workflows/community-admin-pr-merge.yml
@@ -0,0 +1,386 @@
+name: Community Admin PR Auto-Merge
+
+# Lets a community maintainer auto-merge a PR that ONLY touches their own
+# community's directory (src/assistants//**). When eligible, this workflow
+# approves the PR and enables squash auto-merge so the normal required status
+# checks (ruff + tests) still gate the actual merge.
+#
+# -----------------------------------------------------------------------------
+# Trigger choice: pull_request_target (NOT pull_request)
+# -----------------------------------------------------------------------------
+# We need to approve + merge, which requires a privileged token. On a plain
+# `pull_request` event from a fork, secrets are NOT available, so we could not
+# mint the GitHub App token. `pull_request_target` runs in the context of the
+# BASE repo and therefore HAS access to secrets even for fork PRs.
+#
+# The well-known danger of pull_request_target is that it can be tricked into
+# executing attacker-controlled PR-head code with secrets in scope. We avoid
+# that entirely:
+# - We NEVER check out the PR head. The only checkout is the BASE branch
+# (the trusted, already-reviewed code), and we never run anything from the
+# PR. The PR diff is read via the GitHub API only.
+# - Authorization (the maintainers list) is read from the BASE branch version
+# of config.yaml, never from the PR head, so a PR cannot grant itself power.
+# - If the PR's diff touches the `maintainers:` field of any config.yaml, the
+# PR is ineligible and goes to human review.
+# Given these constraints, pull_request_target is the correct and safe choice.
+#
+# -----------------------------------------------------------------------------
+# Token model
+# -----------------------------------------------------------------------------
+# GITHUB_TOKEN cannot approve a PR (GitHub prohibits a workflow's own token from
+# submitting a review on the PR that triggered it) and, more importantly, we
+# deliberately keep its permissions minimal (read-only) so this workflow has no
+# standing write power. All write actions (approve +
+# merge) are performed with a SHORT-LIVED installation token minted from a
+# dedicated GitHub App via actions/create-github-app-token. The App is the only
+# identity allowed to merge here, and it is independent from the CI_ADMIN_TOKEN
+# PAT used by the version-automation workflows. Required secrets:
+# - COMMUNITY_MERGE_APP_ID
+# - COMMUNITY_MERGE_APP_PRIVATE_KEY
+# See .context/community-admin-merge.md for the one-time App setup.
+
+on:
+ pull_request_target:
+ types: [opened, synchronize, reopened, ready_for_review]
+ branches: [develop, main]
+
+# Minimal standing permissions. contents:read for the base checkout; the
+# eligibility step reads the PR's file list with github.token, which needs
+# pull-requests:read. Every WRITE (approve, merge, comment) happens through the
+# GitHub App installation token minted below, NOT through GITHUB_TOKEN.
+permissions:
+ contents: read
+ pull-requests: read
+
+# One in-flight evaluation per PR; cancel stale runs on rapid synchronize.
+concurrency:
+ group: community-admin-merge-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+jobs:
+ evaluate-and-merge:
+ # Skip drafts outright; they are not ready and ready_for_review will fire
+ # the real evaluation when the author marks the PR ready.
+ if: github.event.pull_request.draft == false
+ runs-on: ubuntu-latest
+ steps:
+ # Checkout the BASE branch ONLY. This is trusted, already-merged code.
+ # We never check out github.event.pull_request.head; the PR diff is read
+ # via the API in later steps. We only ever `git show` BASE_SHA:config.yaml,
+ # and `ref: base.sha` guarantees that commit is present locally.
+ - name: Checkout base branch (trusted)
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.pull_request.base.sha }}
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+
+ - name: Install dependencies
+ run: pip install pyyaml
+
+ # ----------------------------------------------------------------------
+ # Eligibility evaluation. This step writes ONE output: `eligible`
+ # (true/false) plus `community` and `reason` for logging/comments. It
+ # performs NO writes and uses only the read-only github.token to list the
+ # PR's changed files. Default posture is NOT eligible: any ambiguity,
+ # parse failure, or unexpected condition results in eligible=false.
+ # ----------------------------------------------------------------------
+ - name: Evaluate eligibility
+ id: eval
+ # All PR-controlled values are passed via env (never interpolated into
+ # the script body) so a crafted login/sha/ref cannot inject shell.
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ AUTHOR_LOGIN: ${{ github.event.pull_request.user.login }}
+ REPO: ${{ github.repository }}
+ run: |
+ set -euo pipefail
+
+ # Helper: emit outputs and exit "not eligible" cleanly (no-op, exit 0).
+ not_eligible() {
+ echo "::notice::Not eligible for auto-merge: $1"
+ {
+ echo "eligible=false"
+ echo "community="
+ echo "reason=$1"
+ } >> "$GITHUB_OUTPUT"
+ exit 0
+ }
+
+ # 1. Get the PR's changed files from the GitHub API (metadata only; we
+ # never merge or execute PR-head code). We use the paginated
+ # /pulls/{n}/files endpoint, NOT `gh pr diff --name-only`, for two
+ # reasons that are load-bearing for security:
+ # - It emits BOTH `.filename` (new path) and `.previous_filename`
+ # (a rename's OLD path). Checking both prevents a rename that
+ # moves a file FROM outside the community dir INTO it (the old
+ # path would be out of scope) from slipping the scope check.
+ # - `--paginate` returns ALL files; `gh pr diff` truncates large
+ # PRs at 300 files, which could hide out-of-scope files past the
+ # cutoff.
+ mapfile -t CHANGED < <(
+ gh api --paginate "/repos/$REPO/pulls/$PR_NUMBER/files" \
+ -q '.[] | (.filename, (.previous_filename // empty))' | sort -u
+ )
+
+ if [ "${#CHANGED[@]}" -eq 0 ]; then
+ not_eligible "PR changes zero files"
+ fi
+
+ echo "Changed files:"
+ printf ' %s\n' "${CHANGED[@]}"
+
+ # 2. Every changed path must match ^src/assistants//... and all
+ # paths must resolve to the SAME single community id. A PR spanning
+ # multiple communities, or touching anything outside a community
+ # dir, is not eligible.
+ COMMUNITY=""
+ for f in "${CHANGED[@]}"; do
+ # Defense in depth: git reports normalized repo-relative paths, but
+ # reject anything containing a `..` segment or a leading slash just
+ # in case, so a path can never escape the community subtree.
+ if [[ "$f" == *"/../"* || "$f" == "../"* || "$f" == *"/.." || "$f" == /* ]]; then
+ not_eligible "path contains a parent-directory or absolute segment: $f"
+ fi
+
+ # Strict regex: literal prefix, one path segment (no slash, no `..`)
+ # as the id, then a slash and at least one more character (a real
+ # file under the dir, including files at the community root).
+ if [[ "$f" =~ ^src/assistants/([^/]+)/.+$ ]]; then
+ id="${BASH_REMATCH[1]}"
+ else
+ not_eligible "path outside a single community dir: $f"
+ fi
+
+ if [ -z "$COMMUNITY" ]; then
+ COMMUNITY="$id"
+ elif [ "$COMMUNITY" != "$id" ]; then
+ not_eligible "PR spans multiple communities ($COMMUNITY, $id)"
+ fi
+ done
+
+ if [ -z "$COMMUNITY" ]; then
+ not_eligible "could not determine a single community id"
+ fi
+
+ # Defense in depth: the path regex allows any non-slash characters in
+ # the id. Constrain it to a safe identifier (lowercase letters, digits,
+ # hyphen, underscore) before it is interpolated into git refs / paths,
+ # so a crafted path can never carry shell or ref metacharacters here.
+ if ! [[ "$COMMUNITY" =~ ^[a-z0-9_-]+$ ]]; then
+ not_eligible "community id has unexpected characters: $COMMUNITY"
+ fi
+ echo "Target community: $COMMUNITY"
+
+ CONFIG_PATH="src/assistants/$COMMUNITY/config.yaml"
+
+ # 3. The BASE branch must actually define this community with a
+ # config.yaml. If the base has no such config (e.g. a brand-new
+ # community introduced by this very PR), there is no trusted
+ # maintainers list to authorize against -> human review.
+ if ! git cat-file -e "$BASE_SHA:$CONFIG_PATH" 2>/dev/null; then
+ not_eligible "no $CONFIG_PATH on base branch (new community needs human review)"
+ fi
+
+ # 4. SECURITY: read the maintainers list ONLY from the BASE branch
+ # version of config.yaml. Never from the PR head. A parse failure
+ # (malformed YAML, missing/empty maintainers) means not eligible.
+ git show "$BASE_SHA:$CONFIG_PATH" > /tmp/base_config.yaml || \
+ not_eligible "could not read base config.yaml"
+
+ # Extract a normalized (lowercased, sorted, newline-joined) maintainers
+ # list. Exit code 2 from the helper = parse/shape failure.
+ python - "$COMMUNITY" <<'PY' > /tmp/base_maintainers.txt || PARSE_RC=$?
+ import sys, yaml
+ community = sys.argv[1]
+ try:
+ with open("/tmp/base_config.yaml") as fh:
+ data = yaml.safe_load(fh)
+ except Exception as exc: # noqa: BLE001 - any parse error => ineligible
+ sys.stderr.write(f"base config.yaml failed to parse: {exc}\n")
+ sys.exit(2)
+ if not isinstance(data, dict):
+ sys.stderr.write("base config.yaml is not a mapping\n")
+ sys.exit(2)
+ maint = data.get("maintainers")
+ if not isinstance(maint, list) or not maint:
+ sys.stderr.write("base config.yaml has no non-empty maintainers list\n")
+ sys.exit(2)
+ names = []
+ for m in maint:
+ if not isinstance(m, str) or not m.strip():
+ sys.stderr.write("maintainers entry is not a non-empty string\n")
+ sys.exit(2)
+ names.append(m.strip().lower())
+ # Normalized, de-duplicated, sorted for stable comparison.
+ for n in sorted(set(names)):
+ print(n)
+ PY
+ PARSE_RC=${PARSE_RC:-0}
+ if [ "$PARSE_RC" -ne 0 ]; then
+ not_eligible "base config.yaml maintainers could not be parsed"
+ fi
+
+ # 5. SECURITY: if config.yaml is among the changed files, ensure the
+ # PR does NOT modify the maintainers field. Changing who holds
+ # admin power must go through human review. Compare the normalized
+ # maintainers list of base vs head of config.yaml.
+ if printf '%s\n' "${CHANGED[@]}" | grep -qxF "$CONFIG_PATH"; then
+ echo "config.yaml is modified; verifying maintainers field is unchanged"
+
+ # Fetch the HEAD version of config.yaml via the API (raw contents at
+ # the PR head sha, from $HEAD_SHA in env). We parse it ONLY to compare
+ # the maintainers field; we never execute it. A failure to fetch/parse
+ # the head config => not eligible (conservative default). This also
+ # catches a config.yaml that the PR deleted or renamed away (the fetch
+ # 404s), so admin-list removal cannot ride through.
+ if ! gh api \
+ -H "Accept: application/vnd.github.raw+json" \
+ "/repos/$REPO/contents/$CONFIG_PATH?ref=$HEAD_SHA" \
+ > /tmp/head_config.yaml 2>/dev/null; then
+ not_eligible "could not read head config.yaml for maintainers diff"
+ fi
+
+ python - "$COMMUNITY" <<'PY' > /tmp/head_maintainers.txt || HEAD_RC=$?
+ import sys, yaml
+ try:
+ with open("/tmp/head_config.yaml") as fh:
+ data = yaml.safe_load(fh)
+ except Exception as exc: # noqa: BLE001 - any parse error => ineligible
+ sys.stderr.write(f"head config.yaml failed to parse: {exc}\n")
+ sys.exit(2)
+ if not isinstance(data, dict):
+ sys.stderr.write("head config.yaml is not a mapping\n")
+ sys.exit(2)
+ maint = data.get("maintainers")
+ if not isinstance(maint, list):
+ sys.stderr.write("head config.yaml has no maintainers list\n")
+ sys.exit(2)
+ names = []
+ for m in maint:
+ if not isinstance(m, str) or not m.strip():
+ sys.stderr.write("head maintainers entry is not a non-empty string\n")
+ sys.exit(2)
+ names.append(m.strip().lower())
+ for n in sorted(set(names)):
+ print(n)
+ PY
+ HEAD_RC=${HEAD_RC:-0}
+ if [ "$HEAD_RC" -ne 0 ]; then
+ not_eligible "head config.yaml maintainers could not be parsed"
+ fi
+
+ if ! diff -q /tmp/base_maintainers.txt /tmp/head_maintainers.txt >/dev/null; then
+ not_eligible "PR modifies the maintainers field (needs human review)"
+ fi
+ echo "maintainers field unchanged; OK to proceed"
+ fi
+
+ # 6. Verify the PR author is in the BASE-branch maintainers list.
+ # GitHub usernames are case-insensitive, so compare lowercased.
+ # AUTHOR_LOGIN comes from env (never interpolated into the script).
+ AUTHOR="$(printf '%s' "$AUTHOR_LOGIN" | tr '[:upper:]' '[:lower:]')"
+ echo "PR author (normalized): $AUTHOR"
+ if ! grep -qxF "$AUTHOR" /tmp/base_maintainers.txt; then
+ not_eligible "author '$AUTHOR' is not a maintainer of '$COMMUNITY'"
+ fi
+
+ # All checks passed.
+ echo "::notice::PR #$PR_NUMBER is eligible for auto-merge (community=$COMMUNITY, author=$AUTHOR)"
+ {
+ echo "eligible=true"
+ echo "community=$COMMUNITY"
+ echo "reason=author is a maintainer; changes scoped to $COMMUNITY"
+ } >> "$GITHUB_OUTPUT"
+
+ # ----------------------------------------------------------------------
+ # Mint the GitHub App installation token. Only reached when eligible.
+ # Short-lived; scoped to this repo by the App installation.
+ # ----------------------------------------------------------------------
+ - name: Mint GitHub App token
+ if: steps.eval.outputs.eligible == 'true'
+ id: app_token
+ uses: actions/create-github-app-token@v1
+ with:
+ app-id: ${{ secrets.COMMUNITY_MERGE_APP_ID }}
+ private-key: ${{ secrets.COMMUNITY_MERGE_APP_PRIVATE_KEY }}
+
+ # Approve the PR using the App token. A clear, attributable review body.
+ - name: Approve PR
+ if: steps.eval.outputs.eligible == 'true'
+ env:
+ GH_TOKEN: ${{ steps.app_token.outputs.token }}
+ REPO: ${{ github.repository }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ COMMUNITY: ${{ steps.eval.outputs.community }}
+ run: |
+ set -euo pipefail
+ gh api \
+ --method POST \
+ -H "Accept: application/vnd.github+json" \
+ "/repos/$REPO/pulls/$PR_NUMBER/reviews" \
+ -f event=APPROVE \
+ -f body="Auto-approved by community-admin auto-merge: changes are scoped to the **$COMMUNITY** community directory and the author is a listed maintainer. Required status checks still gate the merge."
+
+ # Enable squash auto-merge with the App token. Using --auto means the
+ # merge only happens once all required checks (ruff + tests) pass, so we
+ # never bypass CI. If the PR is already mergeable, GitHub merges
+ # immediately; otherwise it waits.
+ - name: Enable squash auto-merge
+ if: steps.eval.outputs.eligible == 'true'
+ env:
+ GH_TOKEN: ${{ steps.app_token.outputs.token }}
+ REPO: ${{ github.repository }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ run: |
+ set -euo pipefail
+ gh pr merge "$PR_NUMBER" --repo "$REPO" --auto --squash
+
+ # Post (find-or-update) a confirmation comment so we do not spam a new
+ # comment on every synchronize event. Uses the App token's identity.
+ - name: Comment confirmation
+ if: steps.eval.outputs.eligible == 'true'
+ continue-on-error: true
+ uses: actions/github-script@v7
+ with:
+ github-token: ${{ steps.app_token.outputs.token }}
+ script: |
+ const community = process.env.COMMUNITY;
+ const marker = '';
+ const body = `${marker}\n` +
+ `Auto-merge enabled for the **${community}** community.\n\n` +
+ `This PR only touches \`src/assistants/${community}/\` and was opened by a ` +
+ `listed maintainer, so it has been approved and queued for **squash** auto-merge. ` +
+ `It will merge automatically once all required checks (ruff + tests) pass.`;
+
+ const { data: comments } = await github.rest.issues.listComments({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.payload.pull_request.number,
+ });
+ const existing = comments.find(c => c.body && c.body.includes(marker));
+ if (existing) {
+ await github.rest.issues.updateComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: existing.id,
+ body,
+ });
+ } else {
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.payload.pull_request.number,
+ body,
+ });
+ }
+ env:
+ COMMUNITY: ${{ steps.eval.outputs.community }}
diff --git a/CLAUDE.md b/CLAUDE.md
index 472865b..f8be9aa 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -162,6 +162,7 @@ src/
- **.context/yaml_registry.md** - YAML-based community config (internal notes)
- **.context/community_onboarding_review.md** - Onboarding gap analysis
- **.context/local-testing-guide.md** - Quick local testing reference
+- **.context/community-admin-merge.md** - Maintainer auto-merge for community-scoped PRs (setup + trust model)
- **Existing configs to reference**: `src/assistants/hed/config.yaml`, `src/assistants/eeglab/config.yaml`
### Tool System
From efaf5bfb28d0894659a72c4b33e43bcf86ec0010 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Fri, 5 Jun 2026 02:39:29 +0000
Subject: [PATCH 09/39] Bump version to 0.8.4.dev4
---
src/version.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/version.py b/src/version.py
index 8deae8a..a9ce623 100644
--- a/src/version.py
+++ b/src/version.py
@@ -1,7 +1,7 @@
"""Version information for OSA."""
-__version__ = "0.8.4.dev3"
-__version_info__ = (0, 8, 4, "dev3")
+__version__ = "0.8.4.dev4"
+__version_info__ = (0, 8, 4, "dev4")
def get_version() -> str:
From 399f35731af1d457d619c18d43f4394f047914aa Mon Sep 17 00:00:00 2001
From: "Seyed (Yahya) Shirazi"
Date: Thu, 4 Jun 2026 21:20:45 -0700
Subject: [PATCH 10/39] feat: optional 'what went wrong' box on thumbs-down
(#287)
* feat: optional 'what went wrong' box on thumbs-down
Thumbs-down now reveals a small inline comment box (What went wrong?
optional) with Send/Skip under that reply, instead of only Thanks!.
Thumbs-up is unchanged (one-click count).
The down-vote is deferred until the user sends or skips, and is flushed
(committed bare) when they send a new message, reset, or close the widget,
so leaving the box open never drops the vote. The optional comment rides on
the same single response/down row (linked by request_id) -- no double count,
no backend change. Optimistic with rollback + error on POST failure.
Closes #286
* fix: address PR #287 review (confirm-then-commit feedback)
- commitResponseFeedback now confirms before committing: feedbackCommitted
(the 'Thanks!' state) is set only AFTER the POST succeeds, so a failure
never leaves a false success in the UI or in localStorage.
- A flush on send/reset/close is best-effort (interactive=false): it never
writes errors into a tearing-down or hidden UI; a pending down stays pending
and retries on the next flush. Interactive Send/Skip/up surface failures and
keep the box (with the typed comment) for retry.
- saveHistory strips transient flags (_feedbackCommitting/_feedbackJustOpened/
feedbackDraft) and never persists an unconfirmed vote.
---
frontend/osa-chat-widget.js | 218 +++++++++++++++++++++++++++++++-----
1 file changed, 192 insertions(+), 26 deletions(-)
diff --git a/frontend/osa-chat-widget.js b/frontend/osa-chat-widget.js
index 0f8ae9a..1b1d5c5 100644
--- a/frontend/osa-chat-widget.js
+++ b/frontend/osa-chat-widget.js
@@ -574,6 +574,7 @@
.osa-message-feedback {
display: flex;
align-items: center;
+ flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
@@ -629,6 +630,63 @@
margin-left: 4px;
}
+ .osa-feedback-comment {
+ flex-basis: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin-top: 4px;
+ }
+
+ .osa-feedback-comment-input {
+ width: 100%;
+ box-sizing: border-box;
+ resize: vertical;
+ min-height: 44px;
+ padding: 6px 8px;
+ font: inherit;
+ font-size: 13px;
+ color: var(--osa-text);
+ background: var(--osa-bg);
+ border: 1px solid var(--osa-border);
+ border-radius: 6px;
+ }
+
+ .osa-feedback-comment-input:focus {
+ outline: none;
+ border-color: var(--osa-primary);
+ }
+
+ .osa-feedback-comment-actions {
+ display: flex;
+ gap: 8px;
+ justify-content: flex-end;
+ }
+
+ .osa-feedback-comment-actions button {
+ font: inherit;
+ font-size: 12px;
+ padding: 4px 10px;
+ border-radius: 6px;
+ cursor: pointer;
+ border: 1px solid var(--osa-border);
+ }
+
+ .osa-feedback-skip {
+ background: transparent;
+ color: var(--osa-text-light);
+ }
+
+ .osa-feedback-send {
+ background: var(--osa-primary);
+ color: #fff;
+ border-color: var(--osa-primary);
+ }
+
+ .osa-feedback-send:hover {
+ background: var(--osa-primary-dark);
+ }
+
.osa-suggestions {
padding: 12px 16px;
border-top: 1px solid var(--osa-border);
@@ -1385,7 +1443,15 @@
}
try {
- const data = JSON.stringify({ messages, sessionId });
+ // Persist only durable feedback state: drop transient flags and the
+ // in-progress draft, and never persist a vote that has not been confirmed
+ // by the server (so a reload can't show a false "recorded" state).
+ const persistable = messages.map((m) => {
+ const { _feedbackCommitting, _feedbackJustOpened, feedbackDraft, ...rest } = m;
+ if (rest.feedback && !rest.feedbackCommitted) delete rest.feedback;
+ return rest;
+ });
+ const data = JSON.stringify({ messages: persistable, sessionId });
localStorage.setItem(CONFIG.storageKey, data);
saveErrorShown = false;
} catch (e) {
@@ -1881,42 +1947,91 @@
}
}
- // Record a thumbs up/down on a specific assistant reply. One vote per reply
- // per browser session (stored in localStorage): once recorded, the choice is
- // locked in this browser to keep counts honest. The button updates optimistically
- // for responsiveness, but if the POST fails the vote is rolled back so the user
- // is not misled into thinking it was saved.
- async function submitResponseFeedback(container, msgIndex, sentiment) {
+ // Select a thumbs up/down on a specific assistant reply. One vote per reply
+ // per browser session (stored in localStorage). Thumbs-up commits immediately;
+ // thumbs-down reveals an optional "what went wrong?" box and commits when the
+ // user sends/skips (or when the vote is flushed on send/reset/close).
+ function submitResponseFeedback(container, msgIndex, sentiment) {
const msg = messages[msgIndex];
if (!msg || msg.role !== 'assistant') return;
if (msg.feedback) return; // already voted on this reply
if (sentiment !== 'up' && sentiment !== 'down') return;
- // Optimistic update.
msg.feedback = sentiment;
- renderMessages(container);
+ if (sentiment === 'down') {
+ // Defer the post; reveal the optional comment box first.
+ msg._feedbackJustOpened = true;
+ renderMessages(container);
+ } else {
+ renderMessages(container); // show the selection immediately
+ commitResponseFeedback(container, msgIndex, { interactive: true });
+ }
+ }
+
+ // Post a per-response vote (with the optional down-vote comment) exactly once.
+ // Confirm-then-commit: the "Thanks!" / committed state is only shown AFTER the
+ // POST succeeds, so a failure never leaves a false success (in the UI or in
+ // localStorage). interactive=true (Send/Skip/up click) surfaces failures so the
+ // user can retry; interactive=false (a flush on send/reset/close) is best-effort
+ // and never writes to the UI, since the conversation may be mid-teardown.
+ async function commitResponseFeedback(container, msgIndex, { interactive = false } = {}) {
+ const msg = messages[msgIndex];
+ if (!msg || !msg.feedback) return;
+ if (msg.feedbackCommitted || msg._feedbackCommitting) return;
+ msg._feedbackCommitting = true;
+
+ const sentiment = msg.feedback;
+ const comment = (msg.feedbackDraft || '').trim();
const ok = await postFeedback({
feedback_type: 'response',
sentiment,
+ comment: comment || null,
request_id: msg.requestId || null,
session_id: sessionId || null,
message_index: msgIndex,
});
+ msg._feedbackCommitting = false;
- if (!ok) {
- // Roll back so the vote can be retried and the user knows it did not save.
- delete msg.feedback;
+ if (ok) {
+ msg.feedbackCommitted = true;
+ delete msg.feedbackDraft;
+ delete msg._feedbackJustOpened;
+ // Reveal "Thanks!" (and replace any open box). Safe in every path: harmless
+ // on a hidden window, and a no-op for a reply already removed by a reset.
renderMessages(container);
- showError(container, 'Could not send feedback. Please try again.');
+ try {
+ saveHistory();
+ } catch (e) {
+ console.error('[OSA] Failed to persist feedback locally:', e);
+ }
return;
}
- try {
- saveHistory();
- } catch (e) {
- console.error('[OSA] Failed to persist feedback locally:', e);
+ // Failed. An up-vote reverts to unvoted; a down-vote keeps its pending box
+ // (and the typed comment) so it can be retried on the next Send or flush.
+ if (sentiment === 'up') delete msg.feedback;
+ if (!interactive) {
+ // Best-effort flush during teardown: do not touch the (possibly hidden or
+ // already-reset) UI. A pending down stays pending and retries next flush.
+ console.warn('[OSA] Feedback flush did not send; will retry on next attempt.');
+ return;
}
+ if (sentiment === 'down') msg._feedbackJustOpened = true; // refocus the box
+ renderMessages(container);
+ showError(container, 'Could not send feedback. Please try again.');
+ }
+
+ // Commit any pending (down) vote whose comment box is still open, so leaving
+ // it open and then sending/resetting/closing never silently drops the vote.
+ // Best-effort (interactive=false): failures are not surfaced into a tearing-down UI.
+ function flushPendingResponseFeedback(container) {
+ messages.forEach((msg, idx) => {
+ if (msg && msg.role === 'assistant' && msg.feedback
+ && !msg.feedbackCommitted && !msg._feedbackCommitting) {
+ commitResponseFeedback(container, idx, { interactive: false });
+ }
+ });
}
// Open the general (free-text) feedback modal
@@ -2300,17 +2415,34 @@
: '';
// Per-response feedback (thumbs up/down) for assistant replies, but not
- // the canned opening greeting (index 0). Records sentiment only; free-text
- // feedback lives in the "Send feedback" link in the footer.
+ // the canned opening greeting (index 0). Up is a one-click count; down
+ // reveals an optional "what went wrong?" box before it is committed.
const showFeedback = msg.role === 'assistant' && msgIndex > 0;
const fb = msg.feedback;
- const feedbackRow = showFeedback
- ? `
@@ -2361,6 +2493,33 @@
});
});
+ // Optional comment box for a pending thumbs-down
+ messagesEl.querySelectorAll('.osa-message-feedback .osa-feedback-comment').forEach(box => {
+ const row = box.closest('.osa-message-feedback');
+ const msgIndex = parseInt(row.getAttribute('data-msg-index'), 10);
+ const textarea = box.querySelector('.osa-feedback-comment-input');
+ if (textarea) {
+ textarea.addEventListener('input', () => {
+ if (messages[msgIndex]) messages[msgIndex].feedbackDraft = textarea.value;
+ });
+ // Focus once, right after the box first appears (not on every re-render).
+ if (messages[msgIndex] && messages[msgIndex]._feedbackJustOpened) {
+ messages[msgIndex]._feedbackJustOpened = false;
+ textarea.focus();
+ }
+ }
+ box.querySelector('.osa-feedback-send')?.addEventListener('click', (e) => {
+ e.stopPropagation();
+ if (textarea && messages[msgIndex]) messages[msgIndex].feedbackDraft = textarea.value;
+ commitResponseFeedback(container, msgIndex, { interactive: true });
+ });
+ box.querySelector('.osa-feedback-skip')?.addEventListener('click', (e) => {
+ e.stopPropagation();
+ if (messages[msgIndex]) messages[msgIndex].feedbackDraft = '';
+ commitResponseFeedback(container, msgIndex, { interactive: true });
+ });
+ });
+
if (isLoading) {
const loadingEl = document.createElement('div');
loadingEl.className = 'osa-loading';
@@ -2630,6 +2789,9 @@
async function sendMessage(container, question) {
if (isLoading || !question.trim()) return;
+ // Commit any open thumbs-down comment box before the conversation moves on.
+ flushPendingResponseFeedback(container);
+
isLoading = true;
// Track message indices to avoid corruption on error
@@ -2859,6 +3021,8 @@
// Reset chat
function resetChat(container) {
if (messages.length <= 1 || isLoading) return;
+ // Commit any open thumbs-down comment before the history is cleared.
+ flushPendingResponseFeedback(container);
messages = [{ role: 'assistant', content: CONFIG.initialMessage }];
sessionId = null; // Clear session to start fresh on next message
try {
@@ -2886,6 +3050,8 @@
// Hide tooltip when chat opens
if (tooltip) tooltip.classList.remove('visible');
} else {
+ // Commit any open thumbs-down comment box on close.
+ flushPendingResponseFeedback(container);
chatWindow.classList.remove('open');
container.classList.remove('chat-open');
button.innerHTML = ICONS.chat;
From 1d6e613cfd56442235d0b0e0a171d9c7c0fb375d Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Fri, 5 Jun 2026 04:21:00 +0000
Subject: [PATCH 11/39] Bump version to 0.8.4.dev5
---
src/version.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/version.py b/src/version.py
index a9ce623..6ca4244 100644
--- a/src/version.py
+++ b/src/version.py
@@ -1,7 +1,7 @@
"""Version information for OSA."""
-__version__ = "0.8.4.dev4"
-__version_info__ = (0, 8, 4, "dev4")
+__version__ = "0.8.4.dev5"
+__version_info__ = (0, 8, 4, "dev5")
def get_version() -> str:
From 52a0273e4af578cfbb475619108a674a4509a0ff Mon Sep 17 00:00:00 2001
From: "Seyed (Yahya) Shirazi"
Date: Thu, 4 Jun 2026 21:36:00 -0700
Subject: [PATCH 12/39] fix(widget): move Send feedback to the disclaimer line
(#289)
The footer line ('Send feedback . Powered by OSA vX') overflowed the
window next to the page-URL toggle. Shorten the default disclaimer to
'This is an AI assistant and may make mistakes.' and move the Send
feedback link to the end of that line (flex space-between). The footer
keeps only 'Powered by OSA vX'.
Closes #288
---
frontend/osa-chat-widget.js | 56 ++++++++++++++++++++-----------------
1 file changed, 30 insertions(+), 26 deletions(-)
diff --git a/frontend/osa-chat-widget.js b/frontend/osa-chat-widget.js
index 1b1d5c5..7065e82 100644
--- a/frontend/osa-chat-widget.js
+++ b/frontend/osa-chat-widget.js
@@ -53,7 +53,7 @@
pageContextLabel: 'Share page URL to help answer questions',
// AI disclaimer shown above the footer
disclaimerEnabled: true,
- disclaimerText: 'This is a multi-agent AI assistant and may make mistakes. Please verify responses.',
+ disclaimerText: 'This is an AI assistant and may make mistakes.',
disclaimerColor: '#9a3412',
disclaimerBackground: '#fff7ed',
// Fullscreen mode (for pop-out windows)
@@ -866,14 +866,37 @@
}
.osa-ai-disclaimer {
- padding: 4px 16px;
+ padding: 5px 16px;
font-size: 10px;
- text-align: center;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
border-top: 1px solid var(--osa-border);
color: var(--osa-disclaimer-color, #9a3412);
background: var(--osa-disclaimer-bg, #fff7ed);
}
+ .osa-ai-disclaimer .osa-disclaimer-text {
+ text-align: left;
+ }
+
+ .osa-ai-disclaimer .osa-feedback-link {
+ background: none;
+ border: none;
+ padding: 0;
+ font: inherit;
+ color: inherit;
+ cursor: pointer;
+ text-decoration: underline;
+ white-space: nowrap;
+ flex-shrink: 0;
+ }
+
+ .osa-ai-disclaimer .osa-feedback-link:hover {
+ opacity: 0.75;
+ }
+
.osa-combined-footer {
padding: 6px 16px;
font-size: 11px;
@@ -928,26 +951,6 @@
text-decoration: underline;
}
- .osa-combined-footer .osa-footer-powered .osa-feedback-link {
- background: none;
- border: none;
- padding: 0;
- font: inherit;
- color: var(--osa-text-light);
- cursor: pointer;
- text-decoration: none;
- }
-
- .osa-combined-footer .osa-footer-powered .osa-feedback-link:hover {
- color: var(--osa-primary);
- text-decoration: underline;
- }
-
- .osa-combined-footer .osa-footer-powered .osa-footer-sep {
- margin: 0 4px;
- color: var(--osa-border);
- }
-
.osa-feedback-textarea {
resize: vertical;
min-height: 80px;
@@ -2276,15 +2279,16 @@
${ICONS.send}
-
${escapeHtml(CONFIG.disclaimerText || '')}
+
+ ${escapeHtml(CONFIG.disclaimerText || '')}
+
+
From b92a1d00ff79666806169efddeeaaf5d32d06dcb Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Fri, 5 Jun 2026 04:36:12 +0000
Subject: [PATCH 13/39] Bump version to 0.8.4.dev6
---
src/version.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/version.py b/src/version.py
index 6ca4244..7648250 100644
--- a/src/version.py
+++ b/src/version.py
@@ -1,7 +1,7 @@
"""Version information for OSA."""
-__version__ = "0.8.4.dev5"
-__version_info__ = (0, 8, 4, "dev5")
+__version__ = "0.8.4.dev6"
+__version_info__ = (0, 8, 4, "dev6")
def get_version() -> str:
From e061c3c2f8520cd415df781b5cdb4054f4b2c5c8 Mon Sep 17 00:00:00 2001
From: "Seyed (Yahya) Shirazi"
Date: Thu, 4 Jun 2026 22:09:49 -0700
Subject: [PATCH 14/39] fix(ci): pick community-admin merge method by target
branch (#291)
The community-admin auto-merge always used --squash, but the protect-main
ruleset only allows merge-commits, so a community PR to main would fail at
the merge step. Choose the method by base ref: --squash into develop
(feature-branch convention) and --merge into main (release convention).
Updates the confirmation comment, header comment, and setup doc to match.
Closes #290
---
.context/community-admin-merge.md | 12 +++++----
.../workflows/community-admin-pr-merge.yml | 27 ++++++++++++-------
2 files changed, 25 insertions(+), 14 deletions(-)
diff --git a/.context/community-admin-merge.md b/.context/community-admin-merge.md
index 5310f92..0395034 100644
--- a/.context/community-admin-merge.md
+++ b/.context/community-admin-merge.md
@@ -20,8 +20,9 @@ When a PR is opened, synchronized, reopened, or marked ready-for-review against
4. Confirms the PR does NOT change the `maintainers:` field itself (a covert
admin-list change is rejected before the author is even considered).
5. Confirms the PR author is in that list (case-insensitive).
-6. If all checks pass: approves the PR and enables a SQUASH auto-merge using a
- dedicated GitHub App token. The merge fires only after required checks pass.
+6. If all checks pass: approves the PR and enables auto-merge using a dedicated
+ GitHub App token (squash into `develop`, merge-commit into `main`, matching
+ the branch rulesets). The merge fires only after required checks pass.
If any check fails, or anything is ambiguous, the workflow no-ops (logs a notice
and exits cleanly). The PR then follows the normal human-review path. Default
@@ -77,7 +78,7 @@ GitHub Apps -> New GitHub App.
minting a token, the App does not need to receive events.
- Repository permissions (set ONLY these; leave everything else "No access"):
- Pull requests: Read and write (needed to approve and to enable auto-merge)
- - Contents: Read and write (needed for the squash merge to write the commit)
+ - Contents: Read and write (needed for the merge to write the commit)
- Metadata: Read-only (mandatory; GitHub sets this automatically)
- Organization permissions: none.
- Account permissions: none.
@@ -138,8 +139,9 @@ the repo). Make sure the App is allowed to merge:
2. Make sure you are a listed maintainer in that directory's `config.yaml`.
3. Do not change the `maintainers:` field in the same PR; that needs human
review.
-4. The workflow approves the PR and enables squash auto-merge. The PR merges
- automatically once ruff and the test suite pass. A confirmation comment is
+4. The workflow approves the PR and enables auto-merge (squash into develop,
+ merge-commit into main). The PR merges automatically once ruff and the test
+ suite pass. A confirmation comment is
posted (and updated in place on later pushes, not duplicated).
If your PR is not eligible (touches more than one community, touches files
diff --git a/.github/workflows/community-admin-pr-merge.yml b/.github/workflows/community-admin-pr-merge.yml
index 89e6d79..4aeac6f 100644
--- a/.github/workflows/community-admin-pr-merge.yml
+++ b/.github/workflows/community-admin-pr-merge.yml
@@ -2,8 +2,8 @@ name: Community Admin PR Auto-Merge
# Lets a community maintainer auto-merge a PR that ONLY touches their own
# community's directory (src/assistants//**). When eligible, this workflow
-# approves the PR and enables squash auto-merge so the normal required status
-# checks (ruff + tests) still gate the actual merge.
+# approves the PR and enables auto-merge (squash into develop, merge-commit into
+# main) so the normal required status checks (ruff + tests) still gate the merge.
#
# -----------------------------------------------------------------------------
# Trigger choice: pull_request_target (NOT pull_request)
@@ -330,19 +330,28 @@ jobs:
-f event=APPROVE \
-f body="Auto-approved by community-admin auto-merge: changes are scoped to the **$COMMUNITY** community directory and the author is a listed maintainer. Required status checks still gate the merge."
- # Enable squash auto-merge with the App token. Using --auto means the
- # merge only happens once all required checks (ruff + tests) pass, so we
- # never bypass CI. If the PR is already mergeable, GitHub merges
- # immediately; otherwise it waits.
- - name: Enable squash auto-merge
+ # Enable auto-merge with the App token. --auto means the merge happens only
+ # once all required checks (ruff + tests) pass, so we never bypass CI. The
+ # merge method is chosen by target branch to satisfy the branch rulesets:
+ # develop allows squash (feature-branch convention); main is merge-commit
+ # only (the develop->main release convention). Using --squash on a main PR
+ # would be rejected by the protect-main ruleset.
+ - name: Enable auto-merge
if: steps.eval.outputs.eligible == 'true'
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
+ BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
set -euo pipefail
- gh pr merge "$PR_NUMBER" --repo "$REPO" --auto --squash
+ if [ "$BASE_REF" = "main" ]; then
+ METHOD="--merge"
+ else
+ METHOD="--squash"
+ fi
+ echo "Merging PR #$PR_NUMBER into $BASE_REF with $METHOD"
+ gh pr merge "$PR_NUMBER" --repo "$REPO" --auto "$METHOD"
# Post (find-or-update) a confirmation comment so we do not spam a new
# comment on every synchronize event. Uses the App token's identity.
@@ -358,7 +367,7 @@ jobs:
const body = `${marker}\n` +
`Auto-merge enabled for the **${community}** community.\n\n` +
`This PR only touches \`src/assistants/${community}/\` and was opened by a ` +
- `listed maintainer, so it has been approved and queued for **squash** auto-merge. ` +
+ `listed maintainer, so it has been approved and queued for auto-merge. ` +
`It will merge automatically once all required checks (ruff + tests) pass.`;
const { data: comments } = await github.rest.issues.listComments({
From cc203ca00af6d7c2d1b61e49fbd97fff68789f91 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Fri, 5 Jun 2026 05:10:00 +0000
Subject: [PATCH 15/39] Bump version to 0.8.4.dev7
---
src/version.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/version.py b/src/version.py
index 7648250..4969a6a 100644
--- a/src/version.py
+++ b/src/version.py
@@ -1,7 +1,7 @@
"""Version information for OSA."""
-__version__ = "0.8.4.dev6"
-__version_info__ = (0, 8, 4, "dev6")
+__version__ = "0.8.4.dev7"
+__version_info__ = (0, 8, 4, "dev7")
def get_version() -> str:
From 336feba787b58be2b36506badf10dd02a12fd227 Mon Sep 17 00:00:00 2001
From: "Seyed (Yahya) Shirazi"
Date: Thu, 4 Jun 2026 22:25:05 -0700
Subject: [PATCH 16/39] chore: add neuromechanist as maintainer of HED, EEGLAB,
NEMAR (#293)
Grants the OSC owner scoped community-admin merge rights on these three
communities and enables end-to-end testing of the auto-merge flow.
Closes #292
---
src/assistants/eeglab/config.yaml | 1 +
src/assistants/hed/config.yaml | 1 +
src/assistants/nemar/config.yaml | 1 +
3 files changed, 3 insertions(+)
diff --git a/src/assistants/eeglab/config.yaml b/src/assistants/eeglab/config.yaml
index 7d147ff..62708c4 100644
--- a/src/assistants/eeglab/config.yaml
+++ b/src/assistants/eeglab/config.yaml
@@ -35,6 +35,7 @@ cors_origins:
# Used for scoped admin access and budget alert @mentions
maintainers:
- arnodelorme
+ - neuromechanist
# Budget limits for cost management
budget:
diff --git a/src/assistants/hed/config.yaml b/src/assistants/hed/config.yaml
index 84a41f9..5ae8b89 100644
--- a/src/assistants/hed/config.yaml
+++ b/src/assistants/hed/config.yaml
@@ -33,6 +33,7 @@ openrouter_api_key_env_var: "OPENROUTER_API_KEY_HED"
maintainers:
- VisLab
- yarikoptic
+ - neuromechanist
# Budget limits for cost management
budget:
diff --git a/src/assistants/nemar/config.yaml b/src/assistants/nemar/config.yaml
index 59df1a0..575316f 100644
--- a/src/assistants/nemar/config.yaml
+++ b/src/assistants/nemar/config.yaml
@@ -33,6 +33,7 @@ cors_origins:
# Community maintainers (GitHub usernames)
maintainers:
- arnodelorme
+ - neuromechanist
# Budget limits for cost management
budget:
From b6ee763f3d15a9bfe2c327455580afa269c84bdb Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Fri, 5 Jun 2026 05:25:19 +0000
Subject: [PATCH 17/39] Bump version to 0.8.4.dev8
---
src/version.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/version.py b/src/version.py
index 4969a6a..e2f1555 100644
--- a/src/version.py
+++ b/src/version.py
@@ -1,7 +1,7 @@
"""Version information for OSA."""
-__version__ = "0.8.4.dev7"
-__version_info__ = (0, 8, 4, "dev7")
+__version__ = "0.8.4.dev8"
+__version_info__ = (0, 8, 4, "dev8")
def get_version() -> str:
From 19876522d5b919715b233530a33e1b0f28630ccf Mon Sep 17 00:00:00 2001
From: "Seyed (Yahya) Shirazi"
Date: Thu, 4 Jun 2026 22:42:35 -0700
Subject: [PATCH 18/39] docs(hed): note community-admin scoped auto-merge for
maintainers (#296)
---
src/assistants/hed/config.yaml | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/src/assistants/hed/config.yaml b/src/assistants/hed/config.yaml
index 5ae8b89..c7d6562 100644
--- a/src/assistants/hed/config.yaml
+++ b/src/assistants/hed/config.yaml
@@ -462,3 +462,7 @@ extensions:
- validate_hed_string
- suggest_hed_tags
- get_hed_schema_versions
+
+# Community maintainers listed above may open scoped PRs (touching only
+# this directory) that auto-approve and auto-merge via the community-admin
+# merge workflow.
From a34604c819357c27bf620afd549bf01f9602ecb7 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Fri, 5 Jun 2026 05:42:46 +0000
Subject: [PATCH 19/39] Bump version to 0.8.4.dev9
---
src/version.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/version.py b/src/version.py
index e2f1555..0741f2c 100644
--- a/src/version.py
+++ b/src/version.py
@@ -1,7 +1,7 @@
"""Version information for OSA."""
-__version__ = "0.8.4.dev8"
-__version_info__ = (0, 8, 4, "dev8")
+__version__ = "0.8.4.dev9"
+__version_info__ = (0, 8, 4, "dev9")
def get_version() -> str:
From 5730628e340c2c1b7ccb5e9096f4813d59bd0bd0 Mon Sep 17 00:00:00 2001
From: "Seyed (Yahya) Shirazi"
Date: Thu, 4 Jun 2026 23:16:32 -0700
Subject: [PATCH 20/39] feat(ci): merge community PRs into develop on a
maintainer's LGTM/merge comment (#298)
* feat(ci): merge community PRs into develop on a maintainer's LGTM/merge comment
Replace auto-merge-on-open with an explicit comment command: a community
maintainer merges a scoped PR into develop by commenting both 'LGTM' and
'merge'. Triggers on issue_comment; authorizes the COMMENTER against the
target community's base-branch maintainers; squash-merges pinned to the
commented head (--match-head-commit) so a later push needs a fresh comment.
Develop only -- an OSA admin merges develop to main. All prior guards keep:
single-community path scope, maintainers-field-edit exclusion, community-id
whitelist, never run PR-head code, App token for writes. Docs rewritten.
Closes #297
* harden(ci): guard negated 'merge' comments + pin pyyaml
Address PR #298 security review: reject comments that say 'do not merge'/
'don't merge' even if they also contain LGTM+merge; pin pyyaml==6.0.2 in the
privileged eval step.
---
.context/community-admin-merge.md | 227 +++++------
.../workflows/community-admin-pr-merge.yml | 364 ++++++++----------
CLAUDE.md | 2 +-
3 files changed, 269 insertions(+), 324 deletions(-)
diff --git a/.context/community-admin-merge.md b/.context/community-admin-merge.md
index 0395034..282959f 100644
--- a/.context/community-admin-merge.md
+++ b/.context/community-admin-merge.md
@@ -1,53 +1,81 @@
-# Community Admin Auto-Merge
+# Community Maintainer Merge Command
-Lets a community maintainer auto-merge a pull request (PR) that touches ONLY
-their own community's directory, without waiting on a human reviewer. The normal
+Lets a community maintainer merge a pull request (PR) into `develop` that touches
+ONLY their own community's directory, by leaving an explicit comment. Nothing
+merges on open; the maintainer comments when they are ready to ship. The normal
required status checks (ruff + tests) still gate the actual merge, so this only
removes the human-approval step, not the CI gate.
+**Scope is `develop` only.** Releases (`develop -> main`) are done by an OSA admin,
+so `main` never diverges from the integrated `develop` branch. A community PR that
+targets `main` is ignored by this workflow.
+
Workflow file: `.github/workflows/community-admin-pr-merge.yml`
+## How a community maintainer uses it
+
+1. Open a PR against **`develop`** that changes only files inside your community
+ directory, `src/assistants//` (`config.yaml`, `tools.py`, prompts,
+ `logo.svg`, etc.). It does not have to be authored by you.
+2. When you (a listed maintainer of that community) are ready to merge it, post a
+ PR comment containing both keywords **`LGTM`** and **`merge`**, for example:
+
+ > LGTM, please merge
+
+ Casing does not matter (`lgtm`/`LGTM`), and any phrasing works as long as both
+ words appear.
+3. The bot approves the PR and enables **squash** auto-merge. The PR merges
+ automatically once ruff and the tests pass. A confirmation comment is posted
+ (and updated in place, not duplicated).
+
+That's it. If the PR is not eligible (see below), the comment simply does
+nothing and the PR follows the normal review process.
+
## What it does
-When a PR is opened, synchronized, reopened, or marked ready-for-review against
-`develop` or `main`, the workflow:
+When a comment is posted on a PR, the workflow:
-1. Reads the PR's changed-file list via the GitHub API.
-2. Confirms every changed path is under a single `src/assistants//` tree
- (one community, nothing outside it).
-3. Reads the `maintainers:` list from the BASE branch copy of
+1. Checks the comment contains both `LGTM` and `merge` (case-insensitive). If not,
+ it stops immediately.
+2. Reads the PR via the GitHub API and confirms it is **open**, not a draft, and
+ targets **`develop`**.
+3. Confirms every changed path (including a rename's old path) is under a single
+ `src/assistants//` tree (one community, nothing outside it).
+4. Reads the `maintainers:` list from the **base-branch** copy of
`src/assistants//config.yaml`.
-4. Confirms the PR does NOT change the `maintainers:` field itself (a covert
- admin-list change is rejected before the author is even considered).
-5. Confirms the PR author is in that list (case-insensitive).
-6. If all checks pass: approves the PR and enables auto-merge using a dedicated
- GitHub App token (squash into `develop`, merge-commit into `main`, matching
- the branch rulesets). The merge fires only after required checks pass.
+5. Confirms the PR does NOT change the `maintainers:` field itself (a covert
+ admin-list change is excluded and needs human review).
+6. Confirms the **commenter** is in that maintainers list (case-insensitive).
+7. If all checks pass: approves the PR and enables squash auto-merge using a
+ dedicated GitHub App token, **pinned to the exact commit the comment was made
+ on**. The merge fires only after required checks pass.
If any check fails, or anything is ambiguous, the workflow no-ops (logs a notice
-and exits cleanly). The PR then follows the normal human-review path. Default
-posture is "do not merge".
+and exits cleanly). Default posture is "do not merge".
## Trust model
-- Source of truth for authorization is the `maintainers:` list in the
- community's `config.yaml`, read from the BASE branch (the already-merged,
- trusted code), NEVER from the PR head. This prevents a PR from granting itself
- merge rights by adding its author to the list in the same change.
-- Scope is path-restricted to exactly one `src/assistants//` directory.
- A PR that touches two communities, or touches anything outside a community
- directory (including `src/version.py`, CI, top-level files), is ineligible.
-- Edits to the `maintainers:` field are excluded from auto-merge. Changing who
- holds admin power always requires human review. The workflow detects this by
- parsing the base vs head copies of `config.yaml` and comparing the normalized
- maintainers lists.
-- The actual merge still requires all branch-protection required checks to be
- green. Auto-merge only removes the manual-approval gate.
-- The workflow uses `pull_request_target` so it has access to secrets even for
- fork PRs, but it NEVER checks out or executes PR-head code. The only checkout
- is the trusted base branch; the PR diff is read through the API only. The head
- copy of `config.yaml` is fetched and parsed solely to compare the maintainers
- field; it is never executed.
+- **Authorization is the commenter**, who must be in the `maintainers:` list of
+ the target community's `config.yaml`, read from the **base branch** (the
+ already-merged, trusted code), NEVER from the PR head. This prevents a PR from
+ granting itself merge rights by adding someone to the list in the same change.
+- **Scope** is path-restricted to exactly one `src/assistants//` directory.
+ A PR that touches two communities, or anything outside a community directory
+ (including `src/version.py`, CI, top-level files), is ineligible.
+- **Maintainers-field edits are excluded.** Changing who holds merge power always
+ requires human review. The workflow parses the base vs head copies of
+ `config.yaml` and compares the normalized maintainers lists.
+- **No approve-then-push bypass.** The merge is pinned to the head commit the
+ command was made on (`gh pr merge --match-head-commit`). If new commits are
+ pushed after the comment, the pinned auto-merge is cancelled and a fresh
+ `LGTM ... merge` comment is required. (The `protect-dev` ruleset does not
+ dismiss stale reviews on push, so the workflow enforces this itself.)
+- **CI still gates.** The merge waits for all required checks (`--auto`); the App
+ does not bypass them.
+- **`issue_comment` is privileged** (it runs in the base-repo context with
+ secrets, like `pull_request_target`), so the workflow NEVER checks out or runs
+ PR-head code. Everything is read through the API; the head copy of `config.yaml`
+ is fetched only to compare the maintainers field, never executed.
## Token model
@@ -55,114 +83,95 @@ All write actions (approve + merge + confirmation comment) use a SHORT-LIVED
installation token minted from a dedicated GitHub App via
`actions/create-github-app-token@v1`. This App is independent from the
`CI_ADMIN_TOKEN` personal access token (PAT) used by the version-automation
-workflows. The workflow's own `GITHUB_TOKEN` is kept at `contents: read` only,
-so the workflow has no standing write power; every write is attributable to the
-App.
+workflows. The workflow's own `GITHUB_TOKEN` is kept read-only, so the workflow
+has no standing write power; every write is attributable to the App.
## One-time setup (human)
-These steps are done once by a repo admin. Until they are complete, the workflow
-evaluates every PR but skips the write steps (mint, approve, merge) for
-ineligible PRs. If an *eligible* PR is opened before the secrets exist, the
-token-mint step fails with an error (the PR is not merged). Finish all steps
-before relying on it.
+These steps are done once by a repo admin. Until they are complete, an
+authorized `LGTM ... merge` comment will fail at the token-mint step (the PR is
+not merged). Finish all steps before relying on it.
### 1. Create the GitHub App
-GitHub -> Settings (your org `OpenScience-Collective`) -> Developer settings ->
+GitHub -> Settings (org `OpenScience-Collective`) -> Developer settings ->
GitHub Apps -> New GitHub App.
- GitHub App name: e.g. `OSA Community Auto-Merge`.
- Homepage URL: the repo URL is fine (`https://github.com/OpenScience-Collective/osa`).
-- Webhook: UNCHECK "Active". No webhook is needed; the Action invokes the App by
- minting a token, the App does not need to receive events.
+- Webhook: UNCHECK "Active". No webhook is needed; the Action mints a token, the
+ App does not receive events.
- Repository permissions (set ONLY these; leave everything else "No access"):
- Pull requests: Read and write (needed to approve and to enable auto-merge)
- - Contents: Read and write (needed for the merge to write the commit)
+ - Contents: Read and write (needed for the squash merge to write the commit)
- Metadata: Read-only (mandatory; GitHub sets this automatically)
-- Organization permissions: none.
-- Account permissions: none.
+- Organization permissions: none. Account permissions: none.
- "Where can this GitHub App be installed?": Only on this account.
Create the App. Note the App ID shown on the App's settings page.
### 2. Install the App on this repo
-From the App's settings page -> Install App -> install it on
-`OpenScience-Collective`, and restrict it to the `osa` repository only
+From the App's settings page -> Install App -> install on
+`OpenScience-Collective`, restricted to the `osa` repository only
("Only select repositories" -> `osa`).
### 3. Generate a private key
On the App's settings page -> "Private keys" -> "Generate a private key". This
-downloads a `.pem` file. Keep it secret; you will paste its full contents into a
-repo secret in the next step. You cannot retrieve it again later, only generate
-a new one.
+downloads a `.pem` file. Keep it secret. You cannot retrieve it again later, only
+generate a new one.
### 4. Add the two repo secrets
Repo -> Settings -> Secrets and variables -> Actions -> New repository secret:
- `COMMUNITY_MERGE_APP_ID` = the numeric App ID from step 1.
-- `COMMUNITY_MERGE_APP_PRIVATE_KEY` = the full contents of the `.pem` file from
- step 3 (including the `-----BEGIN ... KEY-----` / `-----END ... KEY-----`
- lines).
+- `COMMUNITY_MERGE_APP_PRIVATE_KEY` = the full contents of the `.pem` file
+ (including the `-----BEGIN ... KEY-----` / `-----END ... KEY-----` lines).
+
+CLI alternative for the secrets:
+
+```bash
+gh secret set COMMUNITY_MERGE_APP_ID --repo OpenScience-Collective/osa --body ""
+gh secret set COMMUNITY_MERGE_APP_PRIVATE_KEY --repo OpenScience-Collective/osa < /path/to/key.pem
+```
### 5. Enable "Allow auto-merge" on the repo
The workflow uses `gh pr merge --auto`, which requires the repository-level
-auto-merge feature to be turned on. Repo -> Settings -> General -> "Pull
-Requests" section -> check "Allow auto-merge". Without this, the merge step
-fails (the PR will still be approved, but it will not merge automatically).
-
-### 6. Let the App merge past branch protection
-
-Branch protection on `develop` and `main` is configured in the GitHub UI (not in
-the repo). Make sure the App is allowed to merge:
-
-- Required status checks must stay ON (ruff + tests). The App does NOT bypass
- these; auto-merge waits for them. Do not add the App to any
- "allow specified actors to bypass required pull requests / status checks"
- allowlist; it should merge through the same gate everyone else uses.
-- If a branch has "Restrict who can push to matching branches" (a push/merge
- allowlist) enabled, add the GitHub App to that allowlist so its merge is
- permitted. If that restriction is not enabled, no action is needed.
-- "Require approvals" can stay on; the App's approval satisfies it. If you use
- "Dismiss stale approvals", note that a new push will dismiss the App's
- approval and the workflow will re-approve on the next `synchronize` event.
-
-## How a community admin uses it
-
-1. Open a PR against `develop` (or `main`) that changes only files inside your
- community directory, `src/assistants//` (config.yaml, tools.py,
- prompts, logo, etc.).
-2. Make sure you are a listed maintainer in that directory's `config.yaml`.
-3. Do not change the `maintainers:` field in the same PR; that needs human
- review.
-4. The workflow approves the PR and enables auto-merge (squash into develop,
- merge-commit into main). The PR merges automatically once ruff and the test
- suite pass. A confirmation comment is
- posted (and updated in place on later pushes, not duplicated).
-
-If your PR is not eligible (touches more than one community, touches files
-outside your directory, changes the maintainers list, or you are not a listed
-maintainer), the workflow simply does nothing and the PR follows the normal
-review process.
+auto-merge feature. Repo -> Settings -> General -> "Pull Requests" -> check
+"Allow auto-merge".
+
+### 6. Registration: the workflow must live on the default branch (`main`)
+
+GitHub discovers/registers workflows from the **default branch** only. Because
+this workflow triggers on `issue_comment`, the workflow file must be present on
+`main` for comments to fire it. Keep `.github/workflows/community-admin-pr-merge.yml`
+in sync on both `develop` and `main`.
+
+### 7. Branch protection / rulesets
+
+`develop` is protected by the `protect-dev` ruleset (required checks `Lint` +
+`Test (3.12)`, 0 required reviews). The App merges *through* this gate (it waits
+for the checks via `--auto`); it does NOT need to be added to any bypass list. If
+you later enable "restrict who can push/merge" on `develop`, add the App to that
+allowlist.
## Security notes and limitations
-- The `maintainers:` list is hand-maintained in each `config.yaml`. There is no
- sync with GitHub Teams or org membership; whoever is listed there is trusted to
- merge changes to that community's directory.
-- Adding or removing a maintainer is intentionally excluded from auto-merge and
- always requires a human-reviewed PR.
-- A brand-new community (no `config.yaml` on the base branch yet) cannot be
- auto-merged, because there is no trusted maintainers list to authorize
- against. The first PR introducing a community needs human review.
-- A `config.yaml` that fails to parse (malformed YAML, missing or empty
- maintainers, non-string entries) is treated as "not eligible".
-- Auto-merging community changes into `main` will NOT cut a release. Releases
- are triggered by changes to `src/version.py`, and community PRs are
- path-restricted so they never touch it.
-- The workflow uses `pull_request_target` but never runs PR-head code; see the
- Trust model section and the comment block at the top of the workflow file.
+- The `maintainers:` list is hand-maintained in each `config.yaml`; there is no
+ sync with GitHub Teams or org membership. Whoever is listed is trusted to merge
+ changes to that community's directory.
+- The merge is **pinned to the commented commit**. A push after the `LGTM ... merge`
+ comment cancels the pending merge; a maintainer must re-comment. This is the
+ guard against getting an LGTM on a clean diff and then pushing changes.
+- Adding/removing a maintainer, introducing a brand-new community (no base
+ `config.yaml` yet), or a `config.yaml` that fails to parse are all "not
+ eligible" and need a human-reviewed PR.
+- A maintainer may comment on their **own** PR (GitHub allows self-comments, even
+ though it disallows self-approval), so a maintainer's own scoped PR is merged
+ when they comment the command.
+- Community changes never reach `main` automatically; an OSA admin merges
+ `develop -> main` as part of a release. Community PRs are path-restricted and
+ never touch `src/version.py`, so they never cut a release on their own.
diff --git a/.github/workflows/community-admin-pr-merge.yml b/.github/workflows/community-admin-pr-merge.yml
index 4aeac6f..8e96c31 100644
--- a/.github/workflows/community-admin-pr-merge.yml
+++ b/.github/workflows/community-admin-pr-merge.yml
@@ -1,206 +1,174 @@
-name: Community Admin PR Auto-Merge
+name: Community Maintainer Merge Command
-# Lets a community maintainer auto-merge a PR that ONLY touches their own
-# community's directory (src/assistants//**). When eligible, this workflow
-# approves the PR and enables auto-merge (squash into develop, merge-commit into
-# main) so the normal required status checks (ruff + tests) still gate the merge.
+# A community maintainer merges a PR INTO develop that touches ONLY their own
+# community's directory (src/assistants//**) by posting a comment that
+# contains BOTH "LGTM" and "merge" (e.g. "LGTM, please merge"). Nothing merges on
+# open -- the comment is the deliberate, explicit signal. (Chosen over GitHub
+# "Approve", which reviewers give during review without intending to ship now.)
+#
+# Scope is develop only. Releases (develop -> main) are done by an OSA admin, so
+# main never diverges from the integrated develop branch.
#
# -----------------------------------------------------------------------------
-# Trigger choice: pull_request_target (NOT pull_request)
+# Trigger + security model
# -----------------------------------------------------------------------------
-# We need to approve + merge, which requires a privileged token. On a plain
-# `pull_request` event from a fork, secrets are NOT available, so we could not
-# mint the GitHub App token. `pull_request_target` runs in the context of the
-# BASE repo and therefore HAS access to secrets even for fork PRs.
-#
-# The well-known danger of pull_request_target is that it can be tricked into
-# executing attacker-controlled PR-head code with secrets in scope. We avoid
-# that entirely:
-# - We NEVER check out the PR head. The only checkout is the BASE branch
-# (the trusted, already-reviewed code), and we never run anything from the
-# PR. The PR diff is read via the GitHub API only.
+# issue_comment runs in the BASE-repo context and HAS access to secrets (so we
+# can mint the GitHub App token), even for comments on fork PRs. It is therefore
+# as privileged as pull_request_target, and we treat it the same way:
+# - We NEVER check out or run PR-head code. Everything is read via the API.
# - Authorization (the maintainers list) is read from the BASE branch version
-# of config.yaml, never from the PR head, so a PR cannot grant itself power.
-# - If the PR's diff touches the `maintainers:` field of any config.yaml, the
-# PR is ineligible and goes to human review.
-# Given these constraints, pull_request_target is the correct and safe choice.
+# of config.yaml at the PR's base sha, never from the PR head, so a PR cannot
+# grant itself power.
+# - The PR must touch ONLY src/assistants//** (rename old paths
+# checked too) and must NOT edit the maintainers field; the community id is
+# whitelisted before it is used.
+# - The COMMENTER must be a maintainer of that community.
+# - The merge is pinned to the exact head commit the comment was made on
+# (--match-head-commit), so a push AFTER the comment requires a fresh
+# "LGTM ... merge". (protect-dev keeps stale approvals, so we pin ourselves.)
+# - --auto means the merge waits for required checks; we never bypass CI.
#
-# -----------------------------------------------------------------------------
-# Token model
-# -----------------------------------------------------------------------------
-# GITHUB_TOKEN cannot approve a PR (GitHub prohibits a workflow's own token from
-# submitting a review on the PR that triggered it) and, more importantly, we
-# deliberately keep its permissions minimal (read-only) so this workflow has no
-# standing write power. All write actions (approve +
-# merge) are performed with a SHORT-LIVED installation token minted from a
-# dedicated GitHub App via actions/create-github-app-token. The App is the only
-# identity allowed to merge here, and it is independent from the CI_ADMIN_TOKEN
-# PAT used by the version-automation workflows. Required secrets:
-# - COMMUNITY_MERGE_APP_ID
-# - COMMUNITY_MERGE_APP_PRIVATE_KEY
-# See .context/community-admin-merge.md for the one-time App setup.
+# Token model: all writes (approve, merge, comment) use a SHORT-LIVED GitHub App
+# installation token (secrets COMMUNITY_MERGE_APP_ID / COMMUNITY_MERGE_APP_PRIVATE_KEY),
+# minted only when a command is authorized. GITHUB_TOKEN stays read-only.
+# See .context/community-admin-merge.md for the one-time App setup + usage.
on:
- pull_request_target:
- types: [opened, synchronize, reopened, ready_for_review]
- branches: [develop, main]
+ issue_comment:
+ types: [created]
-# Minimal standing permissions. contents:read for the base checkout; the
-# eligibility step reads the PR's file list with github.token, which needs
-# pull-requests:read. Every WRITE (approve, merge, comment) happens through the
-# GitHub App installation token minted below, NOT through GITHUB_TOKEN.
+# Minimal standing permissions; the eligibility step reads PR data with
+# github.token (needs pull-requests:read). Every WRITE happens through the App
+# installation token minted below, NOT through GITHUB_TOKEN.
permissions:
contents: read
pull-requests: read
-# One in-flight evaluation per PR; cancel stale runs on rapid synchronize.
concurrency:
- group: community-admin-merge-${{ github.event.pull_request.number }}
- cancel-in-progress: true
+ group: community-maintainer-merge-${{ github.event.issue.number }}
+ cancel-in-progress: false
jobs:
- evaluate-and-merge:
- # Skip drafts outright; they are not ready and ready_for_review will fire
- # the real evaluation when the author marks the PR ready.
- if: github.event.pull_request.draft == false
+ merge-on-command:
+ # Only comments on pull requests (issue_comment also fires on plain issues).
+ if: github.event.issue.pull_request != null
runs-on: ubuntu-latest
steps:
- # Checkout the BASE branch ONLY. This is trusted, already-merged code.
- # We never check out github.event.pull_request.head; the PR diff is read
- # via the API in later steps. We only ever `git show` BASE_SHA:config.yaml,
- # and `ref: base.sha` guarantees that commit is present locally.
- - name: Checkout base branch (trusted)
- uses: actions/checkout@v4
- with:
- ref: ${{ github.event.pull_request.base.sha }}
- persist-credentials: false
+ # Cheap first gate: does the comment carry the "LGTM ... merge" command?
+ # Case-insensitive, both keywords required. Skips the rest (and the Python
+ # setup) for ordinary comments. COMMENT_BODY via env (never interpolated).
+ - name: Detect command
+ id: cmd
+ env:
+ COMMENT_BODY: ${{ github.event.comment.body }}
+ run: |
+ set -euo pipefail
+ body="$(printf '%s' "$COMMENT_BODY" | tr '[:upper:]' '[:lower:]')"
+ # Guard the most explicit negations so "LGTM, but do not merge yet"
+ # does not fire. (Bounded anyway: maintainer-only, CI-gated, cancellable.)
+ negated=false
+ case "$body" in
+ *"do not merge"*|*"don't merge"*|*"dont merge"*) negated=true ;;
+ esac
+ if [[ "$body" == *lgtm* && "$body" == *merge* && "$negated" == false ]]; then
+ echo "command=true" >> "$GITHUB_OUTPUT"
+ echo "Detected LGTM/merge command."
+ else
+ echo "command=false" >> "$GITHUB_OUTPUT"
+ echo "No actionable LGTM/merge command (keywords absent or negated); nothing to do."
+ fi
- name: Set up Python
+ if: steps.cmd.outputs.command == 'true'
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
- run: pip install pyyaml
+ if: steps.cmd.outputs.command == 'true'
+ # Pinned: this step parses YAML in a privileged context (App token is
+ # minted only in a later step, but pin anyway for supply-chain hygiene).
+ run: pip install "pyyaml==6.0.2"
# ----------------------------------------------------------------------
- # Eligibility evaluation. This step writes ONE output: `eligible`
- # (true/false) plus `community` and `reason` for logging/comments. It
- # performs NO writes and uses only the read-only github.token to list the
- # PR's changed files. Default posture is NOT eligible: any ambiguity,
- # parse failure, or unexpected condition results in eligible=false.
+ # Eligibility. Writes outputs: eligible (true/false), community, head_sha,
+ # method, commenter. Performs NO writes; uses only the read-only
+ # github.token. Default posture is NOT eligible.
# ----------------------------------------------------------------------
- name: Evaluate eligibility
id: eval
- # All PR-controlled values are passed via env (never interpolated into
- # the script body) so a crafted login/sha/ref cannot inject shell.
+ if: steps.cmd.outputs.command == 'true'
env:
GH_TOKEN: ${{ github.token }}
- PR_NUMBER: ${{ github.event.pull_request.number }}
- BASE_SHA: ${{ github.event.pull_request.base.sha }}
- HEAD_SHA: ${{ github.event.pull_request.head.sha }}
- AUTHOR_LOGIN: ${{ github.event.pull_request.user.login }}
REPO: ${{ github.repository }}
+ PR_NUMBER: ${{ github.event.issue.number }}
+ COMMENTER_LOGIN: ${{ github.event.comment.user.login }}
run: |
set -euo pipefail
- # Helper: emit outputs and exit "not eligible" cleanly (no-op, exit 0).
not_eligible() {
- echo "::notice::Not eligible for auto-merge: $1"
- {
- echo "eligible=false"
- echo "community="
- echo "reason=$1"
- } >> "$GITHUB_OUTPUT"
+ echo "::notice::Not merging: $1"
+ { echo "eligible=false"; echo "community="; } >> "$GITHUB_OUTPUT"
exit 0
}
- # 1. Get the PR's changed files from the GitHub API (metadata only; we
- # never merge or execute PR-head code). We use the paginated
- # /pulls/{n}/files endpoint, NOT `gh pr diff --name-only`, for two
- # reasons that are load-bearing for security:
- # - It emits BOTH `.filename` (new path) and `.previous_filename`
- # (a rename's OLD path). Checking both prevents a rename that
- # moves a file FROM outside the community dir INTO it (the old
- # path would be out of scope) from slipping the scope check.
- # - `--paginate` returns ALL files; `gh pr diff` truncates large
- # PRs at 300 files, which could hide out-of-scope files past the
- # cutoff.
+ # 1. Fetch the PR (data only; we never run its code).
+ PR_JSON="$(gh api "/repos/$REPO/pulls/$PR_NUMBER")"
+ BASE_REF="$(jq -r '.base.ref' <<<"$PR_JSON")"
+ BASE_SHA="$(jq -r '.base.sha' <<<"$PR_JSON")"
+ HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")"
+ STATE="$(jq -r '.state' <<<"$PR_JSON")"
+ IS_DRAFT="$(jq -r '.draft' <<<"$PR_JSON")"
+
+ [ "$STATE" = "open" ] || not_eligible "PR is not open ($STATE)"
+ [ "$IS_DRAFT" = "false" ] || not_eligible "PR is a draft"
+ # Community maintainers may merge into develop ONLY. Releases to main
+ # (develop -> main) are done by an OSA admin, so a community PR targeting
+ # main is never merged here (a main PR can also be stale vs develop).
+ [ "$BASE_REF" = "develop" ] || \
+ not_eligible "base branch '$BASE_REF' is not develop (community PRs target develop; an OSA admin merges develop to main)"
+
+ # 2. Changed files via the paginated /files API (both new and rename-old
+ # paths). Every path must be under ONE src/assistants// tree.
mapfile -t CHANGED < <(
gh api --paginate "/repos/$REPO/pulls/$PR_NUMBER/files" \
-q '.[] | (.filename, (.previous_filename // empty))' | sort -u
)
+ [ "${#CHANGED[@]}" -gt 0 ] || not_eligible "PR changes zero files"
+ echo "Changed files:"; printf ' %s\n' "${CHANGED[@]}"
- if [ "${#CHANGED[@]}" -eq 0 ]; then
- not_eligible "PR changes zero files"
- fi
-
- echo "Changed files:"
- printf ' %s\n' "${CHANGED[@]}"
-
- # 2. Every changed path must match ^src/assistants//... and all
- # paths must resolve to the SAME single community id. A PR spanning
- # multiple communities, or touching anything outside a community
- # dir, is not eligible.
COMMUNITY=""
for f in "${CHANGED[@]}"; do
- # Defense in depth: git reports normalized repo-relative paths, but
- # reject anything containing a `..` segment or a leading slash just
- # in case, so a path can never escape the community subtree.
if [[ "$f" == *"/../"* || "$f" == "../"* || "$f" == *"/.." || "$f" == /* ]]; then
not_eligible "path contains a parent-directory or absolute segment: $f"
fi
-
- # Strict regex: literal prefix, one path segment (no slash, no `..`)
- # as the id, then a slash and at least one more character (a real
- # file under the dir, including files at the community root).
if [[ "$f" =~ ^src/assistants/([^/]+)/.+$ ]]; then
id="${BASH_REMATCH[1]}"
else
not_eligible "path outside a single community dir: $f"
fi
-
if [ -z "$COMMUNITY" ]; then
COMMUNITY="$id"
elif [ "$COMMUNITY" != "$id" ]; then
not_eligible "PR spans multiple communities ($COMMUNITY, $id)"
fi
done
-
- if [ -z "$COMMUNITY" ]; then
- not_eligible "could not determine a single community id"
- fi
-
- # Defense in depth: the path regex allows any non-slash characters in
- # the id. Constrain it to a safe identifier (lowercase letters, digits,
- # hyphen, underscore) before it is interpolated into git refs / paths,
- # so a crafted path can never carry shell or ref metacharacters here.
+ [ -n "$COMMUNITY" ] || not_eligible "could not determine a single community id"
if ! [[ "$COMMUNITY" =~ ^[a-z0-9_-]+$ ]]; then
not_eligible "community id has unexpected characters: $COMMUNITY"
fi
echo "Target community: $COMMUNITY"
-
CONFIG_PATH="src/assistants/$COMMUNITY/config.yaml"
- # 3. The BASE branch must actually define this community with a
- # config.yaml. If the base has no such config (e.g. a brand-new
- # community introduced by this very PR), there is no trusted
- # maintainers list to authorize against -> human review.
- if ! git cat-file -e "$BASE_SHA:$CONFIG_PATH" 2>/dev/null; then
+ # 3. Read the maintainers list ONLY from the BASE config (base sha), via
+ # the API. Never the PR head. Missing/unparseable -> not eligible.
+ if ! gh api -H "Accept: application/vnd.github.raw+json" \
+ "/repos/$REPO/contents/$CONFIG_PATH?ref=$BASE_SHA" \
+ > /tmp/base_config.yaml 2>/dev/null; then
not_eligible "no $CONFIG_PATH on base branch (new community needs human review)"
fi
-
- # 4. SECURITY: read the maintainers list ONLY from the BASE branch
- # version of config.yaml. Never from the PR head. A parse failure
- # (malformed YAML, missing/empty maintainers) means not eligible.
- git show "$BASE_SHA:$CONFIG_PATH" > /tmp/base_config.yaml || \
- not_eligible "could not read base config.yaml"
-
- # Extract a normalized (lowercased, sorted, newline-joined) maintainers
- # list. Exit code 2 from the helper = parse/shape failure.
- python - "$COMMUNITY" <<'PY' > /tmp/base_maintainers.txt || PARSE_RC=$?
+ python - <<'PY' > /tmp/base_maintainers.txt || PARSE_RC=$?
import sys, yaml
- community = sys.argv[1]
try:
with open("/tmp/base_config.yaml") as fh:
data = yaml.safe_load(fh)
@@ -220,36 +188,23 @@ jobs:
sys.stderr.write("maintainers entry is not a non-empty string\n")
sys.exit(2)
names.append(m.strip().lower())
- # Normalized, de-duplicated, sorted for stable comparison.
for n in sorted(set(names)):
print(n)
PY
PARSE_RC=${PARSE_RC:-0}
- if [ "$PARSE_RC" -ne 0 ]; then
- not_eligible "base config.yaml maintainers could not be parsed"
- fi
+ [ "$PARSE_RC" -eq 0 ] || not_eligible "base config.yaml maintainers could not be parsed"
- # 5. SECURITY: if config.yaml is among the changed files, ensure the
- # PR does NOT modify the maintainers field. Changing who holds
- # admin power must go through human review. Compare the normalized
- # maintainers list of base vs head of config.yaml.
+ # 4. SECURITY: if config.yaml is changed, the maintainers field must be
+ # unchanged (changing who holds power needs human review). Compare
+ # the normalized base vs head maintainers list.
if printf '%s\n' "${CHANGED[@]}" | grep -qxF "$CONFIG_PATH"; then
- echo "config.yaml is modified; verifying maintainers field is unchanged"
-
- # Fetch the HEAD version of config.yaml via the API (raw contents at
- # the PR head sha, from $HEAD_SHA in env). We parse it ONLY to compare
- # the maintainers field; we never execute it. A failure to fetch/parse
- # the head config => not eligible (conservative default). This also
- # catches a config.yaml that the PR deleted or renamed away (the fetch
- # 404s), so admin-list removal cannot ride through.
- if ! gh api \
- -H "Accept: application/vnd.github.raw+json" \
+ echo "config.yaml changed; verifying the maintainers field is unchanged"
+ if ! gh api -H "Accept: application/vnd.github.raw+json" \
"/repos/$REPO/contents/$CONFIG_PATH?ref=$HEAD_SHA" \
> /tmp/head_config.yaml 2>/dev/null; then
- not_eligible "could not read head config.yaml for maintainers diff"
+ not_eligible "could not read head config.yaml (delete/rename needs human review)"
fi
-
- python - "$COMMUNITY" <<'PY' > /tmp/head_maintainers.txt || HEAD_RC=$?
+ python - <<'PY' > /tmp/head_maintainers.txt || HEAD_RC=$?
import sys, yaml
try:
with open("/tmp/head_config.yaml") as fh:
@@ -274,37 +229,30 @@ jobs:
print(n)
PY
HEAD_RC=${HEAD_RC:-0}
- if [ "$HEAD_RC" -ne 0 ]; then
- not_eligible "head config.yaml maintainers could not be parsed"
- fi
-
+ [ "$HEAD_RC" -eq 0 ] || not_eligible "head config.yaml maintainers could not be parsed"
if ! diff -q /tmp/base_maintainers.txt /tmp/head_maintainers.txt >/dev/null; then
not_eligible "PR modifies the maintainers field (needs human review)"
fi
echo "maintainers field unchanged; OK to proceed"
fi
- # 6. Verify the PR author is in the BASE-branch maintainers list.
- # GitHub usernames are case-insensitive, so compare lowercased.
- # AUTHOR_LOGIN comes from env (never interpolated into the script).
- AUTHOR="$(printf '%s' "$AUTHOR_LOGIN" | tr '[:upper:]' '[:lower:]')"
- echo "PR author (normalized): $AUTHOR"
- if ! grep -qxF "$AUTHOR" /tmp/base_maintainers.txt; then
- not_eligible "author '$AUTHOR' is not a maintainer of '$COMMUNITY'"
+ # 5. The COMMENTER must be a maintainer of this community (base list).
+ # GitHub usernames are case-insensitive; compare lowercased.
+ COMMENTER="$(printf '%s' "$COMMENTER_LOGIN" | tr '[:upper:]' '[:lower:]')"
+ echo "Commenter (normalized): $COMMENTER"
+ if ! grep -qxF "$COMMENTER" /tmp/base_maintainers.txt; then
+ not_eligible "commenter '$COMMENTER' is not a maintainer of '$COMMUNITY'"
fi
- # All checks passed.
- echo "::notice::PR #$PR_NUMBER is eligible for auto-merge (community=$COMMUNITY, author=$AUTHOR)"
+ echo "::notice::Merging PR #$PR_NUMBER ($COMMUNITY) into develop on @$COMMENTER command; head $HEAD_SHA"
{
echo "eligible=true"
echo "community=$COMMUNITY"
- echo "reason=author is a maintainer; changes scoped to $COMMUNITY"
+ echo "head_sha=$HEAD_SHA"
+ echo "commenter=$COMMENTER"
} >> "$GITHUB_OUTPUT"
- # ----------------------------------------------------------------------
- # Mint the GitHub App installation token. Only reached when eligible.
- # Short-lived; scoped to this repo by the App installation.
- # ----------------------------------------------------------------------
+ # Mint the GitHub App installation token. Only reached when authorized.
- name: Mint GitHub App token
if: steps.eval.outputs.eligible == 'true'
id: app_token
@@ -313,83 +261,71 @@ jobs:
app-id: ${{ secrets.COMMUNITY_MERGE_APP_ID }}
private-key: ${{ secrets.COMMUNITY_MERGE_APP_PRIVATE_KEY }}
- # Approve the PR using the App token. A clear, attributable review body.
+ # Record an approval (audit trail) attributing the merge to the commenter.
- name: Approve PR
if: steps.eval.outputs.eligible == 'true'
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
REPO: ${{ github.repository }}
- PR_NUMBER: ${{ github.event.pull_request.number }}
+ PR_NUMBER: ${{ github.event.issue.number }}
COMMUNITY: ${{ steps.eval.outputs.community }}
+ COMMENTER: ${{ steps.eval.outputs.commenter }}
run: |
set -euo pipefail
- gh api \
- --method POST \
- -H "Accept: application/vnd.github+json" \
+ gh api --method POST -H "Accept: application/vnd.github+json" \
"/repos/$REPO/pulls/$PR_NUMBER/reviews" \
-f event=APPROVE \
- -f body="Auto-approved by community-admin auto-merge: changes are scoped to the **$COMMUNITY** community directory and the author is a listed maintainer. Required status checks still gate the merge."
+ -f body="Merge approved on @$COMMENTER's LGTM/merge command. Changes are scoped to the **$COMMUNITY** community directory; required status checks still gate the merge."
- # Enable auto-merge with the App token. --auto means the merge happens only
- # once all required checks (ruff + tests) pass, so we never bypass CI. The
- # merge method is chosen by target branch to satisfy the branch rulesets:
- # develop allows squash (feature-branch convention); main is merge-commit
- # only (the develop->main release convention). Using --squash on a main PR
- # would be rejected by the protect-main ruleset.
- - name: Enable auto-merge
+ # Enable squash auto-merge into develop, PINNED to the head the command was
+ # made on. --auto waits for required checks; --match-head-commit means a
+ # push after the command cancels this and requires a fresh "LGTM ... merge".
+ - name: Merge on command (pinned to the commented head)
if: steps.eval.outputs.eligible == 'true'
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
REPO: ${{ github.repository }}
- PR_NUMBER: ${{ github.event.pull_request.number }}
- BASE_REF: ${{ github.event.pull_request.base.ref }}
+ PR_NUMBER: ${{ github.event.issue.number }}
+ HEAD_SHA: ${{ steps.eval.outputs.head_sha }}
run: |
set -euo pipefail
- if [ "$BASE_REF" = "main" ]; then
- METHOD="--merge"
- else
- METHOD="--squash"
- fi
- echo "Merging PR #$PR_NUMBER into $BASE_REF with $METHOD"
- gh pr merge "$PR_NUMBER" --repo "$REPO" --auto "$METHOD"
+ echo "Squash-merging PR #$PR_NUMBER into develop, pinned to $HEAD_SHA"
+ gh pr merge "$PR_NUMBER" --repo "$REPO" --auto --squash --match-head-commit "$HEAD_SHA"
- # Post (find-or-update) a confirmation comment so we do not spam a new
- # comment on every synchronize event. Uses the App token's identity.
- - name: Comment confirmation
+ # Find-or-update a single confirmation comment (no spam on repeat commands).
+ - name: Comment result
if: steps.eval.outputs.eligible == 'true'
continue-on-error: true
uses: actions/github-script@v7
+ env:
+ COMMUNITY: ${{ steps.eval.outputs.community }}
+ COMMENTER: ${{ steps.eval.outputs.commenter }}
with:
github-token: ${{ steps.app_token.outputs.token }}
script: |
const community = process.env.COMMUNITY;
- const marker = '';
+ const commenter = process.env.COMMENTER;
+ const marker = '';
const body = `${marker}\n` +
- `Auto-merge enabled for the **${community}** community.\n\n` +
- `This PR only touches \`src/assistants/${community}/\` and was opened by a ` +
- `listed maintainer, so it has been approved and queued for auto-merge. ` +
- `It will merge automatically once all required checks (ruff + tests) pass.`;
-
+ `Queued for merge on @${commenter}'s **LGTM / merge** command.\n\n` +
+ `This PR only touches \`src/assistants/${community}/\`, so it has been ` +
+ `approved and will merge automatically once all required checks pass. ` +
+ `The merge is pinned to the current commit; pushing new commits cancels ` +
+ `it and needs a fresh \`LGTM ... merge\` comment.`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
- issue_number: context.payload.pull_request.number,
+ issue_number: context.payload.issue.number,
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- comment_id: existing.id,
- body,
+ owner: context.repo.owner, repo: context.repo.repo,
+ comment_id: existing.id, body,
});
} else {
await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: context.payload.pull_request.number,
- body,
+ owner: context.repo.owner, repo: context.repo.repo,
+ issue_number: context.payload.issue.number, body,
});
}
- env:
- COMMUNITY: ${{ steps.eval.outputs.community }}
diff --git a/CLAUDE.md b/CLAUDE.md
index f8be9aa..3ec24c1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -162,7 +162,7 @@ src/
- **.context/yaml_registry.md** - YAML-based community config (internal notes)
- **.context/community_onboarding_review.md** - Onboarding gap analysis
- **.context/local-testing-guide.md** - Quick local testing reference
-- **.context/community-admin-merge.md** - Maintainer auto-merge for community-scoped PRs (setup + trust model)
+- **.context/community-admin-merge.md** - Community maintainer "LGTM/merge" comment command to merge community-scoped PRs into develop (setup + trust model)
- **Existing configs to reference**: `src/assistants/hed/config.yaml`, `src/assistants/eeglab/config.yaml`
### Tool System
From 268cb62ef5b6550ac4459c500e967e0beb7f5f41 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Fri, 5 Jun 2026 06:16:46 +0000
Subject: [PATCH 21/39] Bump version to 0.8.4.dev10
---
src/version.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/version.py b/src/version.py
index 0741f2c..bac355e 100644
--- a/src/version.py
+++ b/src/version.py
@@ -1,7 +1,7 @@
"""Version information for OSA."""
-__version__ = "0.8.4.dev9"
-__version_info__ = (0, 8, 4, "dev9")
+__version__ = "0.8.4.dev10"
+__version_info__ = (0, 8, 4, "dev10")
def get_version() -> str:
From 9e2adc94aff3806b14355f1c5699a64e3e395a3f Mon Sep 17 00:00:00 2001
From: "Seyed (Yahya) Shirazi"
Date: Thu, 4 Jun 2026 23:24:56 -0700
Subject: [PATCH 22/39] test(hed): verify LGTM/merge comment command (#301)
---
src/assistants/hed/config.yaml | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/assistants/hed/config.yaml b/src/assistants/hed/config.yaml
index c7d6562..718b6f0 100644
--- a/src/assistants/hed/config.yaml
+++ b/src/assistants/hed/config.yaml
@@ -466,3 +466,4 @@ extensions:
# Community maintainers listed above may open scoped PRs (touching only
# this directory) that auto-approve and auto-merge via the community-admin
# merge workflow.
+# (verifying the LGTM/merge comment command)
From 8aa4b55f8ce0203fa75c81e6683d51f10f975b6c Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Fri, 5 Jun 2026 06:25:09 +0000
Subject: [PATCH 23/39] Bump version to 0.8.4.dev11
---
src/version.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/version.py b/src/version.py
index bac355e..9b4e956 100644
--- a/src/version.py
+++ b/src/version.py
@@ -1,7 +1,7 @@
"""Version information for OSA."""
-__version__ = "0.8.4.dev10"
-__version_info__ = (0, 8, 4, "dev10")
+__version__ = "0.8.4.dev11"
+__version_info__ = (0, 8, 4, "dev11")
def get_version() -> str:
From ffccf1fa2105208feb6be00b2c4e1f4422f5bf85 Mon Sep 17 00:00:00 2001
From: "Seyed (Yahya) Shirazi"
Date: Thu, 4 Jun 2026 23:36:01 -0700
Subject: [PATCH 24/39] fix(ci): ignore bot comments in the community merge
command (#303)
The App's confirmation comment contains 'LGTM / merge' and re-triggered the
issue_comment workflow (which then no-op'd: bot is not a maintainer). Skip
comments where comment.user.type == 'Bot' so the App's own comment no longer
spawns an extra run.
Closes #302
---
.github/workflows/community-admin-pr-merge.yml | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/community-admin-pr-merge.yml b/.github/workflows/community-admin-pr-merge.yml
index 8e96c31..48b5a58 100644
--- a/.github/workflows/community-admin-pr-merge.yml
+++ b/.github/workflows/community-admin-pr-merge.yml
@@ -50,8 +50,12 @@ concurrency:
jobs:
merge-on-command:
- # Only comments on pull requests (issue_comment also fires on plain issues).
- if: github.event.issue.pull_request != null
+ # Only human comments on pull requests. issue_comment also fires on plain
+ # issues (filtered out), and on the App's own confirmation comment (which
+ # mentions "LGTM / merge") -- skipping Bot authors stops that self-trigger.
+ if: >-
+ github.event.issue.pull_request != null &&
+ github.event.comment.user.type != 'Bot'
runs-on: ubuntu-latest
steps:
# Cheap first gate: does the comment carry the "LGTM ... merge" command?
From 72199ba1d08cbb245eca0f41555e521860181135 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Fri, 5 Jun 2026 06:36:14 +0000
Subject: [PATCH 25/39] Bump version to 0.8.4.dev12
---
src/version.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/version.py b/src/version.py
index 9b4e956..921c81f 100644
--- a/src/version.py
+++ b/src/version.py
@@ -1,7 +1,7 @@
"""Version information for OSA."""
-__version__ = "0.8.4.dev11"
-__version_info__ = (0, 8, 4, "dev11")
+__version__ = "0.8.4.dev12"
+__version_info__ = (0, 8, 4, "dev12")
def get_version() -> str:
From 073d2608a4772fe79b5d974eb84d4c080be20a31 Mon Sep 17 00:00:00 2001
From: "Seyed (Yahya) Shirazi"
Date: Fri, 5 Jun 2026 00:45:30 -0700
Subject: [PATCH 26/39] fix(search): match multi-word FTS queries (#306)
* fix(search): match multi-word FTS queries
Replace blanket phrase-wrapping in _sanitize_fts5_query with tokenize ->
drop stopwords/operators -> quote each term -> OR. Multi-word queries
(every query the agent sends for papers/discussions/FAQ/docstrings) were
exact-phrase matched and returned nothing despite populated databases.
Still injection-safe (each term individually quoted); results ordered by
existing BM25 rank. Affects all 6 knowledge-search call sites.
Closes #305
* test(search): harden operator test; keep list/use terms
Address PR review:
- strengthen test_sanitize_fts5_operators to assert every term is
individually quoted (no bare operator can reach MATCH)
- drop 'list' and 'use' from stopwords (meaningful EEGLAB/MATLAB nouns)
so multi-word queries like 'list channels' don't silently lose a term
* test(bep): use non-matching query for no-results case
BEP keyword search is OR-based and rank-ordered after the FTS fix, so the
old phrase-only no-match query ('...data type...') now matches BEPs that
mention 'data'. Use a genuinely non-matching query to keep the
no-results path covered.
---
src/knowledge/search.py | 108 +++++++++++++++++++++++++---
tests/test_knowledge/test_search.py | 74 ++++++++++++++++---
tests/test_tools/test_bep_tool.py | 5 +-
3 files changed, 165 insertions(+), 22 deletions(-)
diff --git a/src/knowledge/search.py b/src/knowledge/search.py
index 1130644..61563f2 100644
--- a/src/knowledge/search.py
+++ b/src/knowledge/search.py
@@ -98,26 +98,112 @@ def _titles_are_similar(
return similarity >= threshold
+# Common English words that add noise to keyword search without improving
+# relevance. Deliberately short and domain-agnostic so we never strip a
+# meaningful term (acronyms, function names, identifiers are all kept).
+_FTS_STOPWORDS = frozenset(
+ {
+ "a",
+ "an",
+ "and",
+ "any",
+ "are",
+ "as",
+ "about",
+ "at",
+ "be",
+ "but",
+ "by",
+ "can",
+ "did",
+ "do",
+ "does",
+ "for",
+ "from",
+ "give",
+ "has",
+ "have",
+ "how",
+ "i",
+ "in",
+ "into",
+ "is",
+ "it",
+ "its",
+ "me",
+ "my",
+ "no",
+ "not",
+ "of",
+ "on",
+ "or",
+ "paper",
+ "papers",
+ "please",
+ "research",
+ "search",
+ "show",
+ "some",
+ "tell",
+ "that",
+ "the",
+ "their",
+ "them",
+ "there",
+ "these",
+ "this",
+ "to",
+ "using",
+ "want",
+ "was",
+ "we",
+ "what",
+ "when",
+ "where",
+ "which",
+ "who",
+ "why",
+ "will",
+ "with",
+ "would",
+ "you",
+ "your",
+ }
+)
+
+
def _sanitize_fts5_query(query: str) -> str:
- """Sanitize user input for safe FTS5 queries.
+ """Build a safe, forgiving FTS5 MATCH expression from raw user input.
- IMPORTANT: This function wraps ALL input in quotes, converting queries to
- exact phrase searches. This prevents FTS5 operator injection but also
- disables legitimate FTS5 features (AND/OR/NOT, wildcards, NEAR, etc.).
+ Splits the query into individual terms, drops noise words and any FTS5
+ operator characters, quotes each remaining term (so reserved words like
+ AND/OR/NEAR and punctuation cannot inject operators), and ORs them
+ together. Callers order results by BM25 ``rank``, so documents matching
+ the most (and rarest) terms surface first.
- For a production system with advanced search needs, consider implementing
- proper query parsing instead of blanket phrase conversion.
+ This replaces the previous behaviour of wrapping the whole query in quotes,
+ which forced an exact consecutive-phrase match and caused multi-word
+ natural-language queries to return nothing.
Args:
query: Raw user input
Returns:
- Sanitized query safe for FTS5 MATCH (as a phrase search)
+ A MATCH expression safe from FTS5 injection. Falls back to a quoted
+ phrase of the raw input when no meaningful terms remain (e.g. a query
+ made entirely of stopwords or symbols), preserving safe behaviour
+ instead of producing an empty MATCH.
"""
- # Escape internal double quotes by doubling them
- escaped = query.replace('"', '""')
- # Wrap in quotes to treat entire input as phrase search
- return f'"{escaped}"'
+ # Tokens: words, numbers, and identifier-style names (pop_loadset, clean_rawdata).
+ # The regex strips all FTS5 operator characters (quotes, *, :, (), -, etc.).
+ tokens = re.findall(r"[A-Za-z0-9_]+", query.lower())
+ terms = [t for t in tokens if t not in _FTS_STOPWORDS]
+ if not terms:
+ # Nothing meaningful left: fall back to a safe phrase match of raw input.
+ escaped = query.replace('"', '""')
+ return f'"{escaped}"'
+ # Quote each term individually to neutralize operators, then OR them.
+ return " OR ".join(f'"{t}"' for t in terms)
@dataclass
diff --git a/tests/test_knowledge/test_search.py b/tests/test_knowledge/test_search.py
index 2460bbb..396c017 100644
--- a/tests/test_knowledge/test_search.py
+++ b/tests/test_knowledge/test_search.py
@@ -3,6 +3,7 @@
These tests use a temporary database populated with test data.
"""
+import re
from pathlib import Path
from unittest.mock import patch
@@ -171,6 +172,28 @@ def test_filter_by_source(self, populated_db: Path):
assert all(r.source == "openalex" for r in openalex_results)
assert all(r.source == "semanticscholar" for r in s2_results)
+ def test_multiword_query_matches_non_adjacent_terms(self, populated_db: Path):
+ """Regression (issue #305): natural-language queries must match papers
+ even when the terms are not adjacent in the document.
+
+ Previously the whole query was phrase-wrapped, so multi-word questions
+ returned zero papers despite a populated database. "annotation" and
+ "neuroimaging" both appear in a paper but never consecutively in this
+ order, so the old phrase match returned nothing.
+ """
+ with patch("src.knowledge.db.get_db_path", return_value=populated_db):
+ results = search_papers("annotation HED neuroimaging")
+
+ assert len(results) >= 1
+ assert any("HED Annotation" in r.title for r in results)
+
+ def test_question_phrasing_finds_papers(self, populated_db: Path):
+ """A full question (with stopwords) still surfaces relevant papers."""
+ with patch("src.knowledge.db.get_db_path", return_value=populated_db):
+ results = search_papers("what papers are about HED annotation?")
+
+ assert len(results) >= 1
+
class TestSearchAll:
"""Tests for combined search."""
@@ -317,18 +340,43 @@ def test_number_lookup_deduplicates(self, populated_db: Path):
class TestFTS5Sanitization:
"""Tests for FTS5 query sanitization to prevent injection."""
- def test_sanitize_basic_query(self):
- """Test that basic queries are wrapped in quotes."""
+ def test_sanitize_splits_into_or_terms(self):
+ """Basic queries become an OR of individually quoted terms."""
result = _sanitize_fts5_query("validation error")
- assert result == '"validation error"'
+ assert result == '"validation" OR "error"'
+
+ def test_sanitize_drops_stopwords(self):
+ """Noise words are removed; meaningful terms (incl. acronyms) kept."""
+ result = _sanitize_fts5_query("what papers are about ICA")
+ assert result == '"ica"'
+
+ def test_sanitize_keeps_identifier_tokens(self):
+ """Function/identifier names stay intact (underscores preserved)."""
+ result = _sanitize_fts5_query("pop_runica parameters")
+ assert result == '"pop_runica" OR "parameters"'
+
+ def test_sanitize_keeps_command_noun_terms(self):
+ """Words that double as content/command nouns are not stopwords.
- def test_sanitize_escapes_quotes(self):
- """Test that double quotes in user input are escaped."""
+ 'list' and 'use' carry meaning in EEGLAB/MATLAB queries (e.g. channel
+ lists), so they must survive instead of being silently dropped.
+ """
+ assert _sanitize_fts5_query("list channels") == '"list" OR "channels"'
+ assert _sanitize_fts5_query("use function") == '"use" OR "function"'
+
+ def test_sanitize_strips_quotes_no_injection(self):
+ """Double quotes in input are stripped by tokenization, not escaped in."""
result = _sanitize_fts5_query('say "hello" world')
- assert result == '"say ""hello"" world"'
+ assert result == '"say" OR "hello" OR "world"'
+ assert '""' not in result
+
+ def test_sanitize_stopword_only_query_falls_back(self):
+ """A query of only stopwords falls back to a safe quoted phrase."""
+ result = _sanitize_fts5_query("what are the")
+ assert result == '"what are the"'
def test_sanitize_fts5_operators(self):
- """Test that FTS5 operators are treated as literal text."""
+ """FTS5 operators are quoted as literal terms, never executed."""
# These would be dangerous without sanitization
dangerous_queries = [
"test AND DROP TABLE",
@@ -339,9 +387,15 @@ def test_sanitize_fts5_operators(self):
]
for query in dangerous_queries:
result = _sanitize_fts5_query(query)
- # Should be wrapped in quotes, treating operators as literals
- assert result.startswith('"')
- assert result.endswith('"')
+ # Every OR-separated term must be individually double-quoted, so no
+ # bare FTS5 operator (AND/OR/NOT/NEAR/wildcard) can reach MATCH.
+ for term in result.split(" OR "):
+ assert term.startswith('"') and term.endswith('"'), (
+ f"unquoted term {term!r} in result for {query!r}: {result!r}"
+ )
+ # Removing every quoted term must leave only ' OR ' connectors.
+ remainder = re.sub(r'"[^"]*"', "", result).replace(" OR ", "").strip()
+ assert remainder == "", f"stray operator text in result for {query!r}: {result!r}"
def test_search_handles_special_characters(self, populated_db: Path):
"""Test that search doesn't crash with special FTS5 characters."""
diff --git a/tests/test_tools/test_bep_tool.py b/tests/test_tools/test_bep_tool.py
index 411cec8..4d7de3f 100644
--- a/tests/test_tools/test_bep_tool.py
+++ b/tests/test_tools/test_bep_tool.py
@@ -122,7 +122,10 @@ def test_lookup_no_results(self, bep_db: Path):
patch("src.assistants.bids.tools.get_db_path", return_value=bep_db),
patch("src.knowledge.db.get_db_path", return_value=bep_db),
):
- result = lookup_bep.invoke({"query": "nonexistent data type xyz"})
+ # Keyword search is OR-based and rank-ordered, so the query must
+ # contain no real terms present in any BEP to return zero results
+ # (e.g. "data" would match many BEP descriptions).
+ result = lookup_bep.invoke({"query": "qwertyuiop zxcvbnmlkj"})
assert "No BEPs found" in result
From dfd5d68f02987ab0ec8c0b8b18d024810e5333af Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Fri, 5 Jun 2026 07:45:42 +0000
Subject: [PATCH 27/39] Bump version to 0.8.4.dev13
---
src/version.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/version.py b/src/version.py
index 921c81f..7ea87eb 100644
--- a/src/version.py
+++ b/src/version.py
@@ -1,7 +1,7 @@
"""Version information for OSA."""
-__version__ = "0.8.4.dev12"
-__version_info__ = (0, 8, 4, "dev12")
+__version__ = "0.8.4.dev13"
+__version_info__ = (0, 8, 4, "dev13")
def get_version() -> str:
From b1814f9ada037938425d002fd7fc74f5fdb1c5bb Mon Sep 17 00:00:00 2001
From: "Seyed (Yahya) Shirazi"
Date: Fri, 5 Jun 2026 00:49:07 -0700
Subject: [PATCH 28/39] refactor(papers): use opencite for paper sync + declare
dependency (#309)
* refactor(papers): use opencite for paper sync
Replace the hand-rolled OpenAlex/Semantic Scholar/PubMed fetchers (and
inverted-index reconstruction) with the opencite multi-source client,
which aggregates and deduplicates across sources. Public sync function
signatures are unchanged, so the CLI and scheduler call them as before;
only the fetch layer is swapped. opencite Paper objects map to stable
(source, external_id) pairs compatible with existing rows.
Declares opencite>=0.5.2 as a server dependency, which also attributes
OSA to neuromechanist/opencite in GitHub's dependency graph.
Config is constructed directly (not Config.from_env) so sync never
depends on ambient .env files in the working directory.
Closes #307
* refactor(papers): share one opencite client per batch; guard async bridge
Address PR review:
- sync_all_papers/sync_citing_papers now open a single SearchOrchestrator/
CitationExplorer for the whole batch instead of one per query/DOI (each
open spins up 11 HTTP clients). Per-item errors are isolated so one bad
query/DOI no longer aborts the batch.
- Add _run() helper that uses asyncio.run normally but offloads to a worker
thread if a loop is already running, so the public sync functions are safe
to call from any context (CLI, scheduler thread, or future async caller).
- Test _run in both the no-loop and running-loop paths.
---
pyproject.toml | 1 +
src/knowledge/papers_sync.py | 697 ++++++++++-------------
tests/test_knowledge/test_papers_sync.py | 254 +++++----
uv.lock | 17 +
4 files changed, 464 insertions(+), 505 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index e54707c..83f9a74 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -66,6 +66,7 @@ server = [
"markdownify>=1.1.0",
# Scheduling
"apscheduler>=3.10.0,<4.0.0",
+ "opencite>=0.5.2",
]
observability = [
diff --git a/src/knowledge/papers_sync.py b/src/knowledge/papers_sync.py
index 95eea0d..640ad4c 100644
--- a/src/knowledge/papers_sync.py
+++ b/src/knowledge/papers_sync.py
@@ -1,258 +1,289 @@
-"""Paper sync from OpenALEX, Semantic Scholar, and PubMed Central.
+"""Paper sync backed by opencite.
-Syncs papers for community-configured search queries.
-Only stores title, abstract snippet, URL, and publication date.
+Fetches papers through the `opencite` multi-source search/citation client and
+writes them into the local knowledge database. opencite aggregates and
+deduplicates across OpenAlex, Semantic Scholar, PubMed (and more), replacing
+the previous hand-rolled per-source fetchers and inverted-index handling.
-Rate limits:
-- OpenALEX: No key required, generous limits
-- Semantic Scholar: ~100 requests/5 min (free), higher with API key
-- PubMed: ~3 requests/sec without key, 10/sec with key
+Public sync functions keep their original signatures so the CLI
+(`src/cli/sync.py`) and the scheduler (`src/api/scheduler.py`) call them
+unchanged; only the fetch layer is swapped.
+
+See: https://github.com/neuromechanist/opencite
"""
+import asyncio
import logging
-import time
-import xml.etree.ElementTree as ET
-from typing import Any
+from collections.abc import Iterable
+from concurrent.futures import ThreadPoolExecutor
-import httpx
-import pyalex
-from pyalex import Works
+from opencite import Config, Paper
+from opencite.citations import CitationExplorer
+from opencite.search import SearchOrchestrator
from src.knowledge.db import get_connection, update_sync_metadata, upsert_paper
logger = logging.getLogger(__name__)
-# Rate limiting settings
-SEMANTIC_SCHOLAR_DELAY = 3.0 # seconds between requests (to stay under 100/5min)
-PUBMED_DELAY = 0.4 # seconds between requests (to stay under 3/sec)
+# Scholarly sources synced by default. opencite also supports arxiv, biorxiv,
+# medrxiv, osf, zenodo, figshare, crossref and core; those broader sources are
+# reserved for the opt-in live-search feature (issue #308) so batch sync stays
+# focused on peer-reviewed literature and matches prior coverage.
+DEFAULT_SOURCES: tuple[str, ...] = ("openalex", "s2", "pubmed")
+
+# opencite source name -> OSA `papers.source` label. Kept stable so dedup and
+# the existing rows in the database (openalex / semanticscholar / pubmed) line
+# up with newly synced papers.
+_OSA_SOURCE_BY_OPENCITE: dict[str, str] = {
+ "openalex": "openalex",
+ "s2": "semanticscholar",
+ "pubmed": "pubmed",
+}
+# OSA source label -> opencite source name (used to restrict per-source syncs).
+_OPENCITE_SOURCE_BY_OSA: dict[str, str] = {v: k for k, v in _OSA_SOURCE_BY_OPENCITE.items()}
+
+# OpenAlex credentials set via configure_openalex(); merged into the per-sync
+# Config as a fallback when explicit call arguments are not supplied. This
+# preserves the CLI's "configure once, sync many" pattern.
+_OPENALEX_API_KEY: str | None = None
+_OPENALEX_EMAIL: str | None = None
def configure_openalex(api_key: str | None = None, email: str | None = None) -> None:
- """Configure pyalex with API key or email for polite pool access.
+ """Store OpenAlex credentials for subsequent opencite-backed syncs.
+
+ OpenAlex works anonymously; an API key grants premium limits and a contact
+ email enables the faster polite pool. Values are merged into the opencite
+ Config built for each sync (explicit per-call arguments still win).
Args:
- api_key: OpenAlex API key for premium access (~2M requests).
- email: Email for polite pool access (faster than anonymous).
+ api_key: OpenAlex API key for premium access.
+ email: Contact email for OpenAlex polite pool access.
"""
- # Treat empty strings as None
- api_key = api_key.strip() if api_key else None
- email = email.strip() if email else None
+ global _OPENALEX_API_KEY, _OPENALEX_EMAIL
+ _OPENALEX_API_KEY = api_key.strip() if api_key and api_key.strip() else None
+ _OPENALEX_EMAIL = email.strip() if email and email.strip() else None
- if api_key:
- pyalex.config.api_key = api_key
+ if _OPENALEX_API_KEY:
logger.info("OpenAlex configured with API key")
- elif email:
- pyalex.config.email = email
- logger.info("OpenAlex configured with email: %s (polite pool)", email)
+ elif _OPENALEX_EMAIL:
+ logger.info("OpenAlex configured with email: %s (polite pool)", _OPENALEX_EMAIL)
else:
logger.debug("OpenAlex using anonymous access (lower rate limits)")
-def _reconstruct_abstract(inverted_index: dict[str, list[int]] | None) -> str:
- """Reconstruct abstract from OpenALEX inverted index format.
-
- OpenALEX stores abstracts as inverted indexes: {"word": [positions]}
- This function reconstructs the original text.
-
- Args:
- inverted_index: Dict mapping words to their positions
-
- Returns:
- Reconstructed abstract text
- """
- if not inverted_index:
- return ""
-
- # Find max position to size the array
- max_pos = 0
- for positions in inverted_index.values():
- if positions:
- max_pos = max(max_pos, max(positions))
-
- # Build word array
- words = [""] * (max_pos + 1)
- for word, positions in inverted_index.items():
- for pos in positions:
- words[pos] = word
-
- return " ".join(words)
-
-
-def _get_paper_url(doi: str | None, fallback_id: str) -> str:
- """Get paper URL, preferring DOI when available.
-
- Args:
- doi: The DOI (may be full URL or bare DOI)
- fallback_id: Fallback URL/ID if no DOI
-
- Returns:
- URL string
+def _build_config(
+ *,
+ openalex_api_key: str | None = None,
+ openalex_email: str | None = None,
+ semantic_scholar_api_key: str | None = None,
+ pubmed_api_key: str | None = None,
+) -> Config:
+ """Build an opencite Config from explicit args and configure_openalex().
+
+ Credentials come from OSA settings (passed explicitly) with a fallback to
+ values set via configure_openalex(). We construct Config directly rather
+ than Config.from_env() so paper sync never depends on ambient ``.env``
+ files in the working directory, which are environment-specific and have
+ tripped opencite's dotenv loader.
"""
- if not doi:
- return fallback_id
- return doi if doi.startswith("http") else f"https://doi.org/{doi}"
-
-
-def _get_openalex_external_id(openalex_id: str) -> str:
- """Extract external ID from OpenALEX URL.
-
- Args:
- openalex_id: Full OpenALEX URL or bare ID
-
- Returns:
- Bare external ID (e.g., "W12345")
+ return Config(
+ openalex_api_key=openalex_api_key or _OPENALEX_API_KEY or "",
+ contact_email=openalex_email or _OPENALEX_EMAIL or "",
+ semantic_scholar_api_key=semantic_scholar_api_key or "",
+ pubmed_api_key=pubmed_api_key or "",
+ )
+
+
+def _native_id(paper: Paper, osa_source: str) -> str:
+ """Return the identifier matching a specific OSA source label, or ''."""
+ ids = paper.ids
+ if osa_source == "openalex":
+ return ids.openalex_id.removeprefix("https://openalex.org/") if ids.openalex_id else ""
+ if osa_source == "semanticscholar":
+ return ids.s2_id or ""
+ if osa_source == "pubmed":
+ return ids.pmid or ""
+ return ""
+
+
+def _paper_source_and_id(paper: Paper) -> tuple[str | None, str | None]:
+ """Pick a stable (source, external_id) for the papers table.
+
+ Prefers identifiers in the order OpenAlex > Semantic Scholar > PubMed > DOI
+ > arXiv so a paper maps to the same row across syncs and aligns with rows
+ already stored from the previous per-source fetchers. Returns (None, None)
+ when no usable identifier is present (such papers are skipped).
"""
- return openalex_id.removeprefix("https://openalex.org/")
-
-
-def sync_openalex_papers(query: str, max_results: int = 100, project: str = "hed") -> int:
- """Sync papers from OpenALEX matching query.
+ ids = paper.ids
+ openalex = ids.openalex_id.removeprefix("https://openalex.org/") if ids.openalex_id else ""
+ if openalex:
+ return "openalex", openalex
+ if ids.s2_id:
+ return "semanticscholar", ids.s2_id
+ if ids.pmid:
+ return "pubmed", ids.pmid
+ if ids.doi:
+ return "doi", ids.doi.lower()
+ if ids.arxiv_id:
+ return "arxiv", ids.arxiv_id
+ return None, None
+
+
+def _paper_url(paper: Paper) -> str:
+ """Best link for a paper, preferring a stable DOI landing page."""
+ if paper.doi:
+ return f"https://doi.org/{paper.doi}"
+ if paper.url:
+ return paper.url
+ if paper.best_pdf_url:
+ return paper.best_pdf_url
+ return ""
+
+
+def _store_papers(
+ papers: Iterable[Paper],
+ project: str,
+ *,
+ force_source: str | None = None,
+) -> dict[str, int]:
+ """Upsert opencite papers into the knowledge DB, returning counts by source.
Args:
- query: Search query
- max_results: Maximum number of papers to sync
- project: Assistant/project name for database isolation. Defaults to 'hed'.
-
- Returns:
- Number of papers synced
+ papers: opencite Paper objects to store.
+ project: Community/project ID for database isolation.
+ force_source: When set (a single-source sync), record this OSA source
+ label using its native identifier; falls back to the priority
+ mapping if that identifier is missing.
"""
- logger.info("Syncing OpenALEX papers for query: %s", query)
-
- try:
- # Build query and fetch results
- # pyalex returns a lazy query object, need to call .get() to fetch results
- works_query = (
- Works()
- .search(query)
- .select(
- [
- "id",
- "title",
- "abstract_inverted_index",
- "publication_date",
- "doi",
- "primary_location",
- ]
- )
- )
- # Fetch up to max_results using pagination
- works = list(works_query.get(per_page=min(max_results, 200)))
- except Exception as e:
- logger.warning("OpenALEX error for '%s': %s", query, e)
- return 0
-
- count = 0
+ counts: dict[str, int] = {}
with get_connection(project) as conn:
- for work in works:
- if count >= max_results:
- break
-
- # Skip if no title
- title = work.get("title")
- if not title:
+ for paper in papers:
+ if not paper.title:
continue
- abstract = _reconstruct_abstract(work.get("abstract_inverted_index"))
- url = _get_paper_url(work.get("doi"), work.get("id", ""))
- external_id = _get_openalex_external_id(work.get("id", ""))
+ if force_source:
+ external_id = _native_id(paper, force_source)
+ source: str | None = force_source if external_id else None
+ if not source:
+ source, external_id = _paper_source_and_id(paper)
+ else:
+ source, external_id = _paper_source_and_id(paper)
+
+ if not source or not external_id:
+ continue
upsert_paper(
conn,
- source="openalex",
+ source=source,
external_id=external_id,
- title=title,
- first_message=abstract,
- url=url,
- created_at=work.get("publication_date"),
+ title=paper.title,
+ first_message=paper.abstract or None,
+ url=_paper_url(paper),
+ created_at=paper.publication_date or (str(paper.year) if paper.year else None),
)
- count += 1
-
+ counts[source] = counts.get(source, 0) + 1
conn.commit()
+ return counts
- logger.info("Synced %d papers from OpenALEX for '%s'", count, query)
- update_sync_metadata("papers", f"openalex:{query}", count, project)
- return count
+def _run(coro):
+ """Execute an async coroutine from synchronous code.
-def sync_semanticscholar_papers(
+ OSA's sync callers (CLI command, scheduler thread) have no running event
+ loop, so asyncio.run is used directly. If a loop is already running in the
+ calling thread, the coroutine runs in a dedicated worker thread so these
+ public sync functions stay safe to call from any context.
+ """
+ try:
+ asyncio.get_running_loop()
+ except RuntimeError:
+ return asyncio.run(coro)
+ with ThreadPoolExecutor(max_workers=1) as pool:
+ return pool.submit(asyncio.run, coro).result()
+
+
+async def _search_queries(
+ config: Config,
+ queries: list[str],
+ max_results: int,
+ sources: tuple[str, ...] | None,
+) -> list[tuple[str, list[Paper]]]:
+ """Search every query through one shared opencite orchestrator.
+
+ A single orchestrator (and its HTTP client pool) is opened for the whole
+ batch. A failure for an individual query is logged and yields an empty
+ result for that query rather than aborting the batch.
+ """
+ out: list[tuple[str, list[Paper]]] = []
+ async with SearchOrchestrator(config) as searcher:
+ for query in queries:
+ try:
+ result = await searcher.search(query, max_results=max_results, sources=sources)
+ out.append((query, result.papers))
+ except Exception as e:
+ logger.warning("opencite search error for '%s': %s", query, e)
+ out.append((query, []))
+ return out
+
+
+async def _citing_for_dois(
+ config: Config,
+ dois: list[str],
+ max_results: int,
+) -> list[tuple[str, list[Paper]]]:
+ """Fetch citing papers for every DOI through one shared CitationExplorer."""
+ out: list[tuple[str, list[Paper]]] = []
+ async with CitationExplorer(config) as explorer:
+ for doi in dois:
+ try:
+ result = await explorer.citing_papers(doi, max_results=max_results)
+ out.append((doi, result.papers))
+ except Exception as e:
+ logger.warning("opencite citation error for DOI %s: %s", doi, e)
+ out.append((doi, []))
+ return out
+
+
+def _sync_single_source(
query: str,
- max_results: int = 100,
- api_key: str | None = None,
- project: str = "hed",
+ max_results: int,
+ project: str,
+ osa_source: str,
+ config: Config,
) -> int:
- """Sync papers from Semantic Scholar matching query.
-
- Args:
- query: Search query
- max_results: Maximum number of papers to sync
- api_key: Optional API key for higher rate limits
- project: Assistant/project name for database isolation. Defaults to 'hed'.
-
- Returns:
- Number of papers synced
- """
- logger.info("Syncing Semantic Scholar papers for query: %s", query)
-
- url = "https://api.semanticscholar.org/graph/v1/paper/search"
- params: dict[str, Any] = {
- "query": query,
- "limit": min(max_results, 100), # API limit per request
- "fields": "paperId,title,abstract,year,url,openAccessPdf",
- }
-
- headers = {}
- if api_key:
- headers["x-api-key"] = api_key
-
+ """Sync papers for one source (restricted opencite search) into the DB."""
+ opencite_source = _OPENCITE_SOURCE_BY_OSA[osa_source]
try:
- response = httpx.get(url, params=params, headers=headers, timeout=30.0)
- response.raise_for_status()
- data = response.json()
- except httpx.HTTPStatusError as e:
- logger.warning("Semantic Scholar HTTP error for '%s': %s", query, e)
- return 0
- except httpx.RequestError as e:
- logger.warning("Semantic Scholar request error for '%s': %s", query, e)
+ searched = _run(_search_queries(config, [query], max_results, (opencite_source,)))
+ except Exception as e:
+ logger.warning("opencite %s search failed for '%s': %s", osa_source, query, e)
return 0
- count = 0
- with get_connection(project) as conn:
- for paper in data.get("data", []):
- if count >= max_results:
- break
-
- # Skip if no title
- title = paper.get("title")
- if not title:
- continue
-
- paper_id = paper.get("paperId", "")
- paper_url = paper.get("url") or f"https://www.semanticscholar.org/paper/{paper_id}"
+ _, papers = searched[0]
+ counts = _store_papers(papers, project, force_source=osa_source)
+ count = sum(counts.values())
+ logger.info("Synced %d papers from %s for '%s'", count, osa_source, query)
+ update_sync_metadata("papers", f"{osa_source}:{query}", count, project)
+ return count
- # Prefer open access PDF URL if available
- open_access = paper.get("openAccessPdf")
- if open_access and open_access.get("url"):
- paper_url = open_access["url"]
- upsert_paper(
- conn,
- source="semanticscholar",
- external_id=paper_id,
- title=title,
- first_message=paper.get("abstract"),
- url=paper_url,
- created_at=str(paper.get("year")) if paper.get("year") else None,
- )
- count += 1
-
- conn.commit()
+def sync_openalex_papers(query: str, max_results: int = 100, project: str = "hed") -> int:
+ """Sync papers from OpenAlex matching query (via opencite)."""
+ logger.info("Syncing OpenAlex papers for query: %s", query)
+ return _sync_single_source(query, max_results, project, "openalex", _build_config())
- logger.info("Synced %d papers from Semantic Scholar for '%s'", count, query)
- update_sync_metadata("papers", f"semanticscholar:{query}", count, project)
- # Rate limiting
- time.sleep(SEMANTIC_SCHOLAR_DELAY)
- return count
+def sync_semanticscholar_papers(
+ query: str,
+ max_results: int = 100,
+ api_key: str | None = None,
+ project: str = "hed",
+) -> int:
+ """Sync papers from Semantic Scholar matching query (via opencite)."""
+ logger.info("Syncing Semantic Scholar papers for query: %s", query)
+ config = _build_config(semantic_scholar_api_key=api_key)
+ return _sync_single_source(query, max_results, project, "semanticscholar", config)
def sync_pubmed_papers(
@@ -261,109 +292,10 @@ def sync_pubmed_papers(
api_key: str | None = None,
project: str = "hed",
) -> int:
- """Sync papers from PubMed matching query.
-
- Uses NCBI E-utilities API (esearch + efetch).
-
- Args:
- query: Search query
- max_results: Maximum number of papers to sync
- api_key: Optional NCBI API key for higher rate limits
- project: Assistant/project name for database isolation. Defaults to 'hed'.
-
- Returns:
- Number of papers synced
- """
+ """Sync papers from PubMed matching query (via opencite)."""
logger.info("Syncing PubMed papers for query: %s", query)
-
- base_url = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
-
- # Step 1: Search for paper IDs
- search_params: dict[str, Any] = {
- "db": "pubmed",
- "term": query,
- "retmax": max_results,
- "retmode": "json",
- }
- if api_key:
- search_params["api_key"] = api_key
-
- try:
- search_response = httpx.get(f"{base_url}/esearch.fcgi", params=search_params, timeout=30.0)
- search_response.raise_for_status()
- search_data = search_response.json()
- except (httpx.HTTPStatusError, httpx.RequestError) as e:
- logger.warning("PubMed search error for '%s': %s", query, e)
- return 0
-
- id_list = search_data.get("esearchresult", {}).get("idlist", [])
- if not id_list:
- logger.info("No PubMed results for '%s'", query)
- return 0
-
- # Rate limiting between requests
- time.sleep(PUBMED_DELAY)
-
- # Step 2: Fetch paper details
- fetch_params: dict[str, Any] = {
- "db": "pubmed",
- "id": ",".join(id_list),
- "retmode": "xml",
- }
- if api_key:
- fetch_params["api_key"] = api_key
-
- try:
- fetch_response = httpx.get(f"{base_url}/efetch.fcgi", params=fetch_params, timeout=60.0)
- fetch_response.raise_for_status()
- except (httpx.HTTPStatusError, httpx.RequestError) as e:
- logger.warning("PubMed fetch error for '%s': %s", query, e)
- return 0
-
- # Parse XML response
- try:
- root = ET.fromstring(fetch_response.text)
- except ET.ParseError as e:
- logger.warning("PubMed XML parse error for '%s': %s", query, e)
- return 0
-
- count = 0
- with get_connection(project) as conn:
- for article in root.findall(".//PubmedArticle"):
- pmid_elem = article.find(".//PMID")
- title_elem = article.find(".//ArticleTitle")
- abstract_elem = article.find(".//AbstractText")
- year_elem = article.find(".//PubDate/Year")
-
- if pmid_elem is None or title_elem is None:
- continue
-
- pmid = pmid_elem.text or ""
- title = title_elem.text or ""
- abstract = abstract_elem.text if abstract_elem is not None else None
- year = year_elem.text if year_elem is not None else None
-
- url = f"https://pubmed.ncbi.nlm.nih.gov/{pmid}/"
-
- upsert_paper(
- conn,
- source="pubmed",
- external_id=pmid,
- title=title,
- first_message=abstract,
- url=url,
- created_at=year,
- )
- count += 1
-
- conn.commit()
-
- logger.info("Synced %d papers from PubMed for '%s'", count, query)
- update_sync_metadata("papers", f"pubmed:{query}", count, project)
-
- # Rate limiting
- time.sleep(PUBMED_DELAY)
- return count
+ config = _build_config(pubmed_api_key=api_key)
+ return _sync_single_source(query, max_results, project, "pubmed", config)
def sync_all_papers(
@@ -375,19 +307,23 @@ def sync_all_papers(
openalex_email: str | None = None,
project: str = "hed",
) -> dict[str, int]:
- """Sync papers from all sources for given queries.
+ """Sync papers from all default sources for given queries via opencite.
+
+ A single deduplicated opencite search runs per query across
+ ``DEFAULT_SOURCES``, replacing the previous three sequential per-source
+ fetches.
Args:
- queries: List of search queries (required - no default queries)
- max_results: Max results per query per source
- semantic_scholar_api_key: Optional Semantic Scholar API key
- pubmed_api_key: Optional PubMed/NCBI API key
- openalex_api_key: Optional OpenAlex API key for premium access
- openalex_email: Optional email for OpenAlex polite pool
- project: Project/community ID for database isolation
+ queries: List of search queries (required - no default queries).
+ max_results: Max deduplicated results per query.
+ semantic_scholar_api_key: Optional Semantic Scholar API key.
+ pubmed_api_key: Optional PubMed/NCBI API key.
+ openalex_api_key: Optional OpenAlex API key for premium access.
+ openalex_email: Optional email for OpenAlex polite pool.
+ project: Project/community ID for database isolation.
Returns:
- Dict mapping source to total items synced
+ Dict mapping OSA source label to total papers synced.
"""
if isinstance(queries, str):
raise TypeError(f"queries must be a list of strings, not a bare string: {queries!r}")
@@ -395,21 +331,25 @@ def sync_all_papers(
logger.warning("No queries provided for paper sync")
return {"openalex": 0, "semanticscholar": 0, "pubmed": 0}
- # Configure OpenAlex with API key or email if provided
- configure_openalex(api_key=openalex_api_key, email=openalex_email)
+ config = _build_config(
+ openalex_api_key=openalex_api_key,
+ openalex_email=openalex_email,
+ semantic_scholar_api_key=semantic_scholar_api_key,
+ pubmed_api_key=pubmed_api_key,
+ )
- results = {
- "openalex": 0,
- "semanticscholar": 0,
- "pubmed": 0,
- }
+ results: dict[str, int] = {"openalex": 0, "semanticscholar": 0, "pubmed": 0}
+ try:
+ searched = _run(_search_queries(config, queries, max_results, DEFAULT_SOURCES))
+ except Exception as e:
+ logger.warning("opencite search failed for %s: %s", project, e)
+ return results
- for query in queries:
- results["openalex"] += sync_openalex_papers(query, max_results, project=project)
- results["semanticscholar"] += sync_semanticscholar_papers(
- query, max_results, semantic_scholar_api_key, project=project
- )
- results["pubmed"] += sync_pubmed_papers(query, max_results, pubmed_api_key, project=project)
+ for query, papers in searched:
+ counts = _store_papers(papers, project)
+ for source, n in counts.items():
+ results[source] = results.get(source, 0) + n
+ update_sync_metadata("papers", f"opencite:{query}", sum(counts.values()), project)
total = sum(results.values())
logger.info("Total papers synced for %s: %d", project, total)
@@ -423,92 +363,35 @@ def sync_citing_papers(
openalex_api_key: str | None = None,
openalex_email: str | None = None,
) -> int:
- """Sync papers that cite the given DOIs using OpenALEX.
-
- OpenALEX supports finding papers that cite a specific work via
- the `cites` filter. This is useful for tracking citations to
- foundational papers in a field.
+ """Sync papers that cite the given DOIs using opencite's citation graph.
Args:
- dois: List of DOIs to find citations for. Should be in bare format
- (e.g., "10.1016/j.neuroimage.2021.118809") without the
- https://doi.org/ prefix. Invalid or unfound DOIs are skipped
- with a warning log.
- max_results: Maximum number of citing papers per DOI
- project: Project/community ID for database isolation
- openalex_api_key: Optional OpenAlex API key for premium access
- openalex_email: Optional email for OpenAlex polite pool
+ dois: List of DOIs to find citations for. Bare format preferred
+ (e.g. "10.1016/j.neuroimage.2021.118809"); opencite auto-detects
+ and resolves the identifier. Unresolved DOIs are skipped with a
+ warning.
+ max_results: Maximum number of citing papers per DOI.
+ project: Project/community ID for database isolation.
+ openalex_api_key: Optional OpenAlex API key for premium access.
+ openalex_email: Optional email for OpenAlex polite pool.
Returns:
- Total number of citing papers synced
+ Total number of citing papers synced.
"""
if isinstance(dois, str):
raise TypeError(f"dois must be a list of strings, not a bare string: {dois!r}")
- configure_openalex(api_key=openalex_api_key, email=openalex_email)
- total = 0
- for doi in dois:
- logger.info("Syncing papers citing DOI: %s", doi)
-
- try:
- # First, look up the OpenALEX work ID for this DOI
- work_lookup = Works()[f"https://doi.org/{doi}"]
- openalex_id = work_lookup.get("id")
-
- if not openalex_id:
- logger.warning("Could not find OpenALEX ID for DOI %s", doi)
- continue
+ config = _build_config(openalex_api_key=openalex_api_key, openalex_email=openalex_email)
+ try:
+ cited = _run(_citing_for_dois(config, dois, max_results))
+ except Exception as e:
+ logger.warning("opencite citation lookup failed for %s: %s", project, e)
+ return 0
- logger.debug("Found OpenALEX ID %s for DOI %s", openalex_id, doi)
-
- # Now find papers that cite this work using the OpenALEX ID
- works_query = (
- Works()
- .filter(cites=openalex_id)
- .select(
- [
- "id",
- "title",
- "abstract_inverted_index",
- "publication_date",
- "doi",
- "primary_location",
- ]
- )
- )
- works = list(works_query.get(per_page=min(max_results, 200)))
- except Exception as e:
- logger.warning("OpenALEX citation error for DOI %s: %s", doi, e)
- continue
-
- count = 0
- with get_connection(project) as conn:
- for work in works:
- if count >= max_results:
- break
-
- title = work.get("title")
- if not title:
- continue
-
- abstract = _reconstruct_abstract(work.get("abstract_inverted_index"))
- url = _get_paper_url(work.get("doi"), work.get("id", ""))
- external_id = _get_openalex_external_id(work.get("id", ""))
-
- upsert_paper(
- conn,
- source="openalex",
- external_id=external_id,
- title=title,
- first_message=abstract,
- url=url,
- created_at=work.get("publication_date"),
- )
- count += 1
-
- conn.commit()
-
- # Update sync metadata with citing_ prefix to distinguish from query-based syncs
+ total = 0
+ for doi, papers in cited:
+ counts = _store_papers(papers, project)
+ count = sum(counts.values())
update_sync_metadata("papers", f"citing_{doi}", count, project)
logger.info("Synced %d papers citing %s", count, doi)
total += count
diff --git a/tests/test_knowledge/test_papers_sync.py b/tests/test_knowledge/test_papers_sync.py
index ab2d123..323d0f4 100644
--- a/tests/test_knowledge/test_papers_sync.py
+++ b/tests/test_knowledge/test_papers_sync.py
@@ -1,17 +1,23 @@
-"""Tests for papers sync module.
+"""Tests for the opencite-backed papers sync module.
-Note: These are real API tests, not mocks, per project guidelines.
+Mapping tests use real opencite ``Paper`` objects and a real SQLite database
+(no mocks). The sync smoke tests make real network calls per project
+guidelines.
"""
+import asyncio
from pathlib import Path
from unittest.mock import patch
-import pyalex
import pytest
+from opencite import IDSet, Paper
+import src.knowledge.papers_sync as ps
from src.knowledge.db import get_connection, init_db
from src.knowledge.papers_sync import (
- _reconstruct_abstract,
+ _paper_source_and_id,
+ _paper_url,
+ _store_papers,
configure_openalex,
sync_all_papers,
sync_citing_papers,
@@ -29,117 +35,181 @@ def temp_db(tmp_path: Path):
class TestConfigureOpenalex:
- """Tests for configure_openalex helper."""
+ """Tests for the configure_openalex credential helper."""
def setup_method(self):
- """Reset pyalex config before each test."""
- pyalex.config.api_key = None
- pyalex.config.email = None
+ """Reset stored OpenAlex credentials before each test."""
+ configure_openalex(api_key=None, email=None)
def teardown_method(self):
- """Reset pyalex config after each test."""
- pyalex.config.api_key = None
- pyalex.config.email = None
+ """Reset stored OpenAlex credentials after each test."""
+ configure_openalex(api_key=None, email=None)
def test_sets_api_key(self):
- """Should set pyalex.config.api_key when api_key provided."""
configure_openalex(api_key="test-key-123")
- assert pyalex.config.api_key == "test-key-123"
+ assert ps._OPENALEX_API_KEY == "test-key-123"
- def test_sets_email_when_no_api_key(self):
- """Should set pyalex.config.email when only email provided."""
+ def test_sets_email(self):
configure_openalex(email="test@example.com")
- assert pyalex.config.email == "test@example.com"
+ assert ps._OPENALEX_EMAIL == "test@example.com"
+ assert ps._OPENALEX_API_KEY is None
- def test_api_key_takes_precedence_over_email(self):
- """Should use API key over email when both provided."""
+ def test_sets_both_key_and_email(self):
configure_openalex(api_key="test-key", email="test@example.com")
- assert pyalex.config.api_key == "test-key"
+ assert ps._OPENALEX_API_KEY == "test-key"
+ assert ps._OPENALEX_EMAIL == "test@example.com"
def test_handles_empty_strings(self):
- """Should treat empty strings as None (no config)."""
configure_openalex(api_key="", email="")
- assert pyalex.config.api_key is None
- assert pyalex.config.email is None
+ assert ps._OPENALEX_API_KEY is None
+ assert ps._OPENALEX_EMAIL is None
def test_handles_whitespace_strings(self):
- """Should strip whitespace and treat blank as None."""
configure_openalex(api_key=" ", email=" ")
- assert pyalex.config.api_key is None
- assert pyalex.config.email is None
+ assert ps._OPENALEX_API_KEY is None
+ assert ps._OPENALEX_EMAIL is None
def test_handles_none_values(self):
- """Should handle None values gracefully (anonymous access)."""
configure_openalex(api_key=None, email=None)
- assert pyalex.config.api_key is None
- assert pyalex.config.email is None
-
-
-class TestAbstractReconstruction:
- """Test OpenALEX inverted index reconstruction."""
-
- def test_reconstruct_abstract_basic(self):
- """Test basic abstract reconstruction from inverted index."""
- inverted_index = {
- "hello": [0],
- "world": [1],
- }
- result = _reconstruct_abstract(inverted_index)
- assert "hello" in result
- assert "world" in result
-
- def test_reconstruct_abstract_with_gaps(self):
- """Test reconstruction with gaps in position array."""
- inverted_index = {
- "hello": [0],
- "world": [2], # Missing position 1
- }
- result = _reconstruct_abstract(inverted_index)
- # Should handle gaps gracefully (empty string at position 1)
- assert "hello" in result
- assert "world" in result
-
- def test_reconstruct_abstract_empty(self):
- """Test reconstruction with empty/None input."""
- assert _reconstruct_abstract(None) == ""
- assert _reconstruct_abstract({}) == ""
-
- def test_reconstruct_abstract_complex(self):
- """Test reconstruction with longer text."""
- inverted_index = {
- "Hierarchical": [0],
- "Event": [1],
- "Descriptors": [2],
- "(HED)": [3],
- "is": [4],
- "a": [5],
- "framework": [6],
- }
- result = _reconstruct_abstract(inverted_index)
- expected_words = ["Hierarchical", "Event", "Descriptors", "HED", "framework"]
- for word in expected_words:
- assert word in result
+ assert ps._OPENALEX_API_KEY is None
+ assert ps._OPENALEX_EMAIL is None
+
+
+class TestPaperMapping:
+ """Map opencite Paper objects to (source, external_id) and URLs."""
+
+ def test_prefers_openalex_id(self):
+ paper = Paper(
+ title="X",
+ ids=IDSet(openalex_id="https://openalex.org/W7", doi="10.1/A", pmid="9"),
+ )
+ assert _paper_source_and_id(paper) == ("openalex", "W7")
+
+ def test_falls_back_to_semantic_scholar(self):
+ paper = Paper(title="Y", ids=IDSet(s2_id="S99"))
+ assert _paper_source_and_id(paper) == ("semanticscholar", "S99")
+
+ def test_falls_back_to_pubmed(self):
+ paper = Paper(title="Y", ids=IDSet(pmid="12345"))
+ assert _paper_source_and_id(paper) == ("pubmed", "12345")
+
+ def test_falls_back_to_doi_lowercased(self):
+ paper = Paper(title="Y", ids=IDSet(doi="10.1/AbC"))
+ assert _paper_source_and_id(paper) == ("doi", "10.1/abc")
+
+ def test_falls_back_to_arxiv(self):
+ paper = Paper(title="Y", ids=IDSet(arxiv_id="2106.15928"))
+ assert _paper_source_and_id(paper) == ("arxiv", "2106.15928")
+
+ def test_no_identifier_is_skipped(self):
+ paper = Paper(title="orphan", ids=IDSet())
+ assert _paper_source_and_id(paper) == (None, None)
+
+ def test_url_prefers_doi_landing_page(self):
+ paper = Paper(title="X", ids=IDSet(doi="10.1/A"), url="https://openalex.org/W7")
+ assert _paper_url(paper) == "https://doi.org/10.1/A"
+
+ def test_url_falls_back_to_paper_url(self):
+ paper = Paper(title="X", ids=IDSet(), url="https://example.org/p")
+ assert _paper_url(paper) == "https://example.org/p"
+
+ def test_url_empty_when_nothing_available(self):
+ paper = Paper(title="X", ids=IDSet())
+ assert _paper_url(paper) == ""
+
+
+class TestStorePapers:
+ """Persist opencite papers into the knowledge DB (real SQLite, no mocks)."""
+
+ def test_stores_and_labels_sources(self, temp_db: Path):
+ papers = [
+ Paper(
+ title="EEGLAB toolbox",
+ ids=IDSet(openalex_id="https://openalex.org/W1", doi="10.1/eeglab"),
+ year=2004,
+ abstract="An open source toolbox.",
+ ),
+ Paper(title="S2 paper", ids=IDSet(s2_id="S2"), year=2020),
+ ]
+ with patch("src.knowledge.db.get_db_path", return_value=temp_db):
+ counts = _store_papers(papers, "test")
+
+ assert counts == {"openalex": 1, "semanticscholar": 1}
+ with get_connection("test") as conn:
+ rows = {
+ r["source"]: r
+ for r in conn.execute("SELECT source, external_id, url, title FROM papers")
+ }
+ assert rows["openalex"]["external_id"] == "W1"
+ assert rows["openalex"]["url"] == "https://doi.org/10.1/eeglab"
+ assert rows["semanticscholar"]["external_id"] == "S2"
+
+ def test_skips_papers_without_title_or_id(self, temp_db: Path):
+ papers = [
+ Paper(title="", ids=IDSet(openalex_id="https://openalex.org/W1")),
+ Paper(title="no id", ids=IDSet()),
+ ]
+ with patch("src.knowledge.db.get_db_path", return_value=temp_db):
+ counts = _store_papers(papers, "test")
+ assert counts == {}
+
+ def test_upsert_deduplicates_same_paper(self, temp_db: Path):
+ paper = Paper(title="dup", ids=IDSet(openalex_id="https://openalex.org/W1"), year=2020)
+ with patch("src.knowledge.db.get_db_path", return_value=temp_db):
+ _store_papers([paper], "test")
+ _store_papers([paper], "test")
+ with get_connection("test") as conn:
+ count = conn.execute("SELECT COUNT(*) AS c FROM papers").fetchone()["c"]
+ assert count == 1
+
+ def test_force_source_uses_native_id(self, temp_db: Path):
+ # A PubMed-restricted sync should label the row 'pubmed' using the PMID,
+ # even though the paper also carries an OpenAlex id.
+ paper = Paper(
+ title="P",
+ ids=IDSet(openalex_id="https://openalex.org/W1", pmid="555"),
+ )
+ with patch("src.knowledge.db.get_db_path", return_value=temp_db):
+ counts = _store_papers([paper], "test", force_source="pubmed")
+ assert counts == {"pubmed": 1}
+ with get_connection("test") as conn:
+ row = conn.execute("SELECT source, external_id FROM papers").fetchone()
+ assert row["source"] == "pubmed"
+ assert row["external_id"] == "555"
+
+
+async def _answer() -> int:
+ return 42
+
+
+class TestRunHelper:
+ """The _run async bridge must work with or without a running event loop."""
+
+ def test_runs_without_existing_loop(self):
+ # Sync context (CLI / scheduler thread): asyncio.run path.
+ assert ps._run(_answer()) == 42
+
+ def test_runs_inside_running_loop(self):
+ # If a loop is already running, _run offloads to a worker thread instead
+ # of raising "asyncio.run() cannot be called from a running event loop".
+ async def driver() -> int:
+ return ps._run(_answer())
+
+ assert asyncio.run(driver()) == 42
class TestPapersSync:
- """Test papers sync functionality."""
+ """Smoke tests using real opencite/network calls."""
def test_sync_openalex_papers_basic(self, temp_db: Path):
- """Test basic OpenALEX papers sync.
-
- This is a smoke test using a real OpenALEX API call.
- """
+ """Basic OpenAlex sync through opencite (real API call)."""
with patch("src.knowledge.db.get_db_path", return_value=temp_db):
- # Sync a small number of papers with a specific query
count = sync_openalex_papers(
"Hierarchical Event Descriptors", max_results=5, project="test"
)
- # Should find at least some results (OpenALEX doesn't require auth)
- # Accept 0 for network issues
+ # Accept 0 for transient network issues.
assert count >= 0
-
- # If count > 0, verify data was written
if count > 0:
with get_connection("test") as conn:
row = conn.execute(
@@ -147,27 +217,15 @@ def test_sync_openalex_papers_basic(self, temp_db: Path):
).fetchone()
assert row["count"] > 0
- def test_sync_openalex_papers_no_results(self, temp_db: Path):
- """Test OpenALEX sync with query that returns no results."""
- with patch("src.knowledge.db.get_db_path", return_value=temp_db):
- # Use an extremely specific nonsense query
- count = sync_openalex_papers("xyzabc123nonsensequery", max_results=5, project="test")
-
- # Should return 0 for no results (not an error)
- assert count == 0
-
def test_sync_respects_max_results(self, temp_db: Path):
- """Test that max_results parameter is respected."""
+ """max_results is respected for a single-source sync."""
with patch("src.knowledge.db.get_db_path", return_value=temp_db):
- # Request only 2 results
count = sync_openalex_papers("neuroscience", max_results=2, project="test")
-
- # Should not exceed max_results
assert count <= 2
class TestPapersSyncTypeGuard:
- """Test that sync functions reject bare strings to prevent character iteration."""
+ """Sync functions reject bare strings to prevent character iteration."""
def test_sync_all_papers_rejects_bare_string(self) -> None:
with pytest.raises(TypeError, match="must be a list of strings"):
diff --git a/uv.lock b/uv.lock
index 22d7aee..6995cbd 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2040,6 +2040,7 @@ dev = [
{ name = "lxml" },
{ name = "markdownify" },
{ name = "mypy" },
+ { name = "opencite" },
{ name = "pre-commit" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pyalex" },
@@ -2071,6 +2072,7 @@ server = [
{ name = "litellm" },
{ name = "lxml" },
{ name = "markdownify" },
+ { name = "opencite" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pyalex" },
{ name = "pydantic-settings" },
@@ -2112,6 +2114,8 @@ requires-dist = [
{ name = "markdownify", marker = "extra == 'dev'", specifier = ">=1.1.0" },
{ name = "markdownify", marker = "extra == 'server'", specifier = ">=1.1.0" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.19.0" },
+ { name = "opencite", marker = "extra == 'dev'", specifier = ">=0.5.2" },
+ { name = "opencite", marker = "extra == 'server'", specifier = ">=0.5.2" },
{ name = "platformdirs", specifier = ">=4.5.0" },
{ name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.5.0" },
{ name = "psycopg", extras = ["binary"], marker = "extra == 'dev'", specifier = ">=3.3.0" },
@@ -2157,6 +2161,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/27/4b/7c1a00c2c3fbd004253937f7520f692a9650767aa73894d7a34f0d65d3f4/openai-2.14.0-py3-none-any.whl", hash = "sha256:7ea40aca4ffc4c4a776e77679021b47eec1160e341f42ae086ba949c9dcc9183", size = 1067558, upload-time = "2025-12-19T03:28:43.727Z" },
]
+[[package]]
+name = "opencite"
+version = "0.5.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "httpx" },
+ { name = "pyalex" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7a/18/313a129d8cee89784c409ec19049f09adf6ba515792747b9dd65dbbd9ddb/opencite-0.5.2.tar.gz", hash = "sha256:d7a63482e4d1a0372fd2d01cc246e3d10b74d50393ae153afe53029b490e7d7e", size = 142084, upload-time = "2026-05-21T19:54:15.971Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0e/b8/d00880ff96e9d3808c6b65e6347b7c4f9fdc8b72213a5dd4cd147224207e/opencite-0.5.2-py3-none-any.whl", hash = "sha256:d4ffb6b553c94df454dbc1de4eed55fdc2ea7604099fea0e3fdbc5a5ea654ce1", size = 96732, upload-time = "2026-05-21T19:54:14.574Z" },
+]
+
[[package]]
name = "opentelemetry-api"
version = "1.39.1"
From b016e3c36a64c729a052a3099e68a8f866c27fe4 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Fri, 5 Jun 2026 07:49:20 +0000
Subject: [PATCH 29/39] Bump version to 0.8.4.dev14
---
src/version.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/version.py b/src/version.py
index 7ea87eb..eda7351 100644
--- a/src/version.py
+++ b/src/version.py
@@ -1,7 +1,7 @@
"""Version information for OSA."""
-__version__ = "0.8.4.dev13"
-__version_info__ = (0, 8, 4, "dev13")
+__version__ = "0.8.4.dev14"
+__version_info__ = (0, 8, 4, "dev14")
def get_version() -> str:
From da68367282eb7ae45a34617d852deb2c05da0af6 Mon Sep 17 00:00:00 2001
From: "Seyed (Yahya) Shirazi"
Date: Fri, 5 Jun 2026 01:34:32 -0700
Subject: [PATCH 30/39] feat(papers): live on-demand 'search recent papers' via
opencite (#310)
* feat(papers): live on-demand paper search via opencite
Adds search__papers_live, an opt-in tool that queries opencite
for the most recent literature (newest first) when the user asks for
recent/new papers or the local index comes up short. Results are
best-effort cached into the community DB so future local searches find
them. Bounded by a timeout to keep chat responsive.
- search_papers_live() in papers_sync.py reuses the opencite client +
_run async bridge; keys read from the server's env.
- Gated by citations.live_search (new config flag, default on) wherever
paper citations are configured.
- EEGLAB prompt guides the agent to use it for recency.
Closes #308
* fix(papers): non-blocking cache + cleaner live-search timeout
Address PR review:
- cache live-search results in a background daemon thread so a chat
response is never delayed (was synchronous; could block up to the
SQLite busy timeout if the scheduler was mid-sync on the same DB).
- set opencite per-request timeout just under the overall cap so each
source finishes/times out cleanly before wait_for cancels, avoiding
orphaned-task error noise on the timeout path.
- test caching deterministically via _cache_papers_async (offline, real
SQLite); live network test now just asserts result shape.
---
src/assistants/community.py | 4 +
src/assistants/eeglab/config.yaml | 5 +-
src/core/config/community.py | 3 +
src/knowledge/papers_sync.py | 118 +++++++++++++++++++++++
src/tools/knowledge.py | 63 ++++++++++++
tests/test_knowledge/test_papers_sync.py | 63 ++++++++++++
tests/test_tools/test_knowledge_tools.py | 12 +++
7 files changed, 267 insertions(+), 1 deletion(-)
diff --git a/src/assistants/community.py b/src/assistants/community.py
index c65860a..620d923 100644
--- a/src/assistants/community.py
+++ b/src/assistants/community.py
@@ -225,6 +225,9 @@ def _build_tools(self, config: CommunityConfig) -> list[BaseTool]:
repos = config.github.repos if config.github else None
has_github = config.github and config.github.repos
has_citations = config.citations and (config.citations.queries or config.citations.dois)
+ has_live_papers = (
+ bool(has_citations) and config.citations is not None and config.citations.live_search
+ )
has_docstrings = config.docstrings and config.docstrings.repos
has_faq = config.faq_generation is not None and bool(config.mailman)
@@ -237,6 +240,7 @@ def _build_tools(self, config: CommunityConfig) -> list[BaseTool]:
include_discussions=bool(has_github),
include_recent=bool(has_github),
include_papers=bool(has_citations),
+ include_live_papers=has_live_papers,
include_docstrings=bool(has_docstrings),
include_faq=bool(has_faq),
faq_list_names=([m.list_name for m in config.mailman] if config.mailman else None),
diff --git a/src/assistants/eeglab/config.yaml b/src/assistants/eeglab/config.yaml
index 62708c4..7c67128 100644
--- a/src/assistants/eeglab/config.yaml
+++ b/src/assistants/eeglab/config.yaml
@@ -146,7 +146,8 @@ system_prompt: |
5. `search_eeglab_faq`: Search mailing list Q&A (archives since 2004)
**Research:**
- 6. `search_eeglab_papers`: Search academic literature about EEGLAB and EEG analysis
+ 6. `search_eeglab_papers`: Search already-indexed academic literature about EEGLAB and EEG analysis
+ 7. `search_eeglab_papers_live`: Live on-demand search of the latest literature (newest first) when the user asks for recent/new papers or the indexed search comes up short
## Tool Usage Guidelines
@@ -196,6 +197,8 @@ system_prompt: |
- "Has anyone cited the EEGLAB paper?" -> CALL `search_eeglab_papers(query="EEGLAB")`
- "Papers about ICA in EEGLAB?" -> CALL `search_eeglab_papers(query="ICA EEGLAB")`
- "Research on ICLabel?" -> CALL `search_eeglab_papers(query="ICLabel")`
+ - "What are the latest/newest papers on ICLabel?" -> CALL `search_eeglab_papers_live(query="ICLabel")`
+ - "Any recent papers using clean_rawdata?" -> CALL `search_eeglab_papers_live(query="clean_rawdata ASR EEG")`
**DO NOT:**
- Tell users to "visit GitHub", "check Google Scholar", or "use the API" when you have the data
diff --git a/src/core/config/community.py b/src/core/config/community.py
index a3ab38f..c7bce15 100644
--- a/src/core/config/community.py
+++ b/src/core/config/community.py
@@ -236,6 +236,9 @@ class CitationConfig(BaseModel):
dois: list[str] = Field(default_factory=list)
"""Core paper DOIs to track citations for (format: '10.xxxx/yyyy')."""
+ live_search: bool = Field(default=True)
+ """Expose an on-demand live paper search tool (opencite) for recent literature."""
+
@field_validator("queries")
@classmethod
def validate_queries(cls, v: list[str]) -> list[str]:
diff --git a/src/knowledge/papers_sync.py b/src/knowledge/papers_sync.py
index 640ad4c..b981207 100644
--- a/src/knowledge/papers_sync.py
+++ b/src/knowledge/papers_sync.py
@@ -14,6 +14,8 @@
import asyncio
import logging
+import os
+import threading
from collections.abc import Iterable
from concurrent.futures import ThreadPoolExecutor
@@ -22,6 +24,7 @@
from opencite.search import SearchOrchestrator
from src.knowledge.db import get_connection, update_sync_metadata, upsert_paper
+from src.knowledge.search import SearchResult
logger = logging.getLogger(__name__)
@@ -397,3 +400,118 @@ def sync_citing_papers(
total += count
return total
+
+
+def _config_from_env() -> Config:
+ """Build an opencite Config from the server's configured API-key env vars.
+
+ Reads the same variables OSA settings use. Missing keys fall back to
+ anonymous access (fine for a single on-demand query). Specific env vars
+ are read by name rather than via Config.from_env() to avoid the ambient
+ ``.env`` parsing path.
+ """
+ return _build_config(
+ openalex_api_key=os.environ.get("OPENALEX_API_KEY"),
+ openalex_email=os.environ.get("OPENALEX_EMAIL"),
+ semantic_scholar_api_key=os.environ.get("SEMANTIC_SCHOLAR_API_KEY"),
+ pubmed_api_key=os.environ.get("PUBMED_API_KEY"),
+ )
+
+
+def _paper_to_result(paper: Paper) -> SearchResult:
+ """Convert an opencite Paper to the shared SearchResult shape."""
+ source, _ = _paper_source_and_id(paper)
+ return SearchResult(
+ title=paper.title,
+ url=_paper_url(paper),
+ snippet=paper.abstract or "",
+ source=source or "opencite",
+ item_type=None,
+ status="published",
+ created_at=str(paper.year) if paper.year else "",
+ )
+
+
+async def _search_recent(
+ config: Config,
+ query: str,
+ limit: int,
+ timeout: float,
+) -> list[Paper]:
+ """Live opencite search for the most recent papers, bounded by a timeout.
+
+ The per-request timeout (``config.timeout``, set by the caller) is the
+ primary bound and is kept just under ``timeout`` so each source finishes or
+ times out cleanly before the outer ``wait_for`` would have to cancel and
+ orphan opencite's in-flight tasks.
+ """
+ async with SearchOrchestrator(config) as searcher:
+ result = await asyncio.wait_for(
+ searcher.search(query, max_results=limit, sources=DEFAULT_SOURCES, sort="year"),
+ timeout=timeout,
+ )
+ return result.papers
+
+
+def _cache_papers_async(papers: list[Paper], project: str) -> threading.Thread:
+ """Cache live-search results into the DB without blocking the caller.
+
+ Caching is best-effort: it must never add latency to (or fail) the chat
+ response, so the write runs in a daemon thread and logs on error. Returns
+ the thread (useful for tests).
+ """
+
+ def _write() -> None:
+ try:
+ _store_papers(papers, project)
+ except Exception as e:
+ logger.warning("Failed to cache live search papers for %s: %s", project, e)
+
+ thread = threading.Thread(target=_write, name=f"cache-papers-{project}", daemon=True)
+ thread.start()
+ return thread
+
+
+def search_papers_live(
+ query: str,
+ project: str = "hed",
+ limit: int = 5,
+ cache: bool = True,
+ timeout: float = 20.0,
+) -> list[SearchResult]:
+ """Search the live literature via opencite for the most recent papers.
+
+ Unlike :func:`src.knowledge.search.search_papers` (local FTS over already
+ synced rows), this hits opencite's multi-source APIs for fresh results,
+ newest first. This is for on-demand discovery of papers the batch sync has
+ not picked up yet.
+
+ Args:
+ query: Topic to search for.
+ project: Community/project ID (for caching into the right DB).
+ limit: Maximum number of papers to return.
+ cache: When True (default), best-effort upsert the results into the
+ community knowledge DB (in a background thread, never blocking the
+ response) so future local searches find them.
+ timeout: Hard cap (seconds) on the opencite call to keep chat snappy.
+
+ Returns:
+ List of SearchResult, newest first. Empty on timeout/error.
+ """
+ config = _config_from_env()
+ # Bound each source request just under the overall cap so opencite's
+ # per-source tasks finish cleanly before wait_for would cancel them.
+ config.timeout = max(1.0, timeout - 2.0)
+ try:
+ papers = _run(_search_recent(config, query, limit, timeout))
+ except TimeoutError:
+ logger.warning("opencite live search timed out for '%s' after %.0fs", query, timeout)
+ return []
+ except Exception as e:
+ logger.warning("opencite live search failed for '%s': %s", query, e)
+ return []
+
+ if cache and papers:
+ _cache_papers_async(papers, project)
+
+ return [_paper_to_result(p) for p in papers[:limit]]
diff --git a/src/tools/knowledge.py b/src/tools/knowledge.py
index 8f43258..d387570 100644
--- a/src/tools/knowledge.py
+++ b/src/tools/knowledge.py
@@ -22,6 +22,7 @@
from langchain_core.tools import BaseTool, StructuredTool
from src.knowledge.db import get_db_path
+from src.knowledge.papers_sync import search_papers_live
from src.knowledge.search import (
get_full_docstring,
list_recent_github_items,
@@ -256,6 +257,63 @@ def search_papers_impl(query: str, limit: int = 5) -> str:
)
+def create_search_papers_live_tool(
+ community_id: str,
+ community_name: str,
+) -> BaseTool:
+ """Create a tool for live (on-demand) academic paper search via opencite.
+
+ Unlike the local paper search (pre-synced rows), this fetches fresh results
+ from the live literature, newest first, and caches them for next time.
+
+ Args:
+ community_id: The community identifier (e.g., 'hed', 'eeglab')
+ community_name: Display name (e.g., 'HED', 'EEGLAB')
+
+ Returns:
+ A LangChain tool for live paper search
+ """
+
+ def search_papers_live_impl(query: str, limit: int = 5) -> str:
+ """Live academic paper search implementation."""
+ results = search_papers_live(query, project=community_id, limit=limit)
+
+ if not results:
+ return (
+ f"No recent papers found online for '{query}' "
+ "(the live search may have timed out; try the local paper search)."
+ )
+
+ lines = ["Most recent papers (live search):\n"]
+ for r in results:
+ year = f" ({r.created_at})" if r.created_at else ""
+ source_label = f"[{r.source}]" if r.source else ""
+ lines.append(f"- {r.title}{year} {source_label}")
+ lines.append(f" [View Paper]({r.url})")
+ if r.snippet:
+ snippet = r.snippet[:200] + "..." if len(r.snippet) > 200 else r.snippet
+ lines.append(f" Abstract: {snippet}")
+ lines.append("")
+
+ return "\n".join(lines)
+
+ description = (
+ f"Search the LIVE literature for the most recent papers about {community_name}. "
+ "**Use this only when the user explicitly asks for recent / latest / new papers, "
+ "or when the local paper search returns nothing relevant.** "
+ "It queries external sources on demand (slower than the local search) and returns "
+ "the newest results first. "
+ "**This is for DISCOVERY, not answering** - present results as references for "
+ "further reading; do NOT use paper content to formulate answers."
+ )
+
+ return StructuredTool.from_function(
+ func=search_papers_live_impl,
+ name=f"search_{community_id}_papers_live",
+ description=description,
+ )
+
+
def create_search_docstrings_tool(
community_id: str,
community_name: str,
@@ -611,6 +669,7 @@ def create_knowledge_tools(
include_discussions: bool = True,
include_recent: bool = True,
include_papers: bool = True,
+ include_live_papers: bool = False,
include_docstrings: bool = False,
docstrings_language: str | None = None,
include_faq: bool = False,
@@ -629,6 +688,7 @@ def create_knowledge_tools(
include_discussions: Include discussion search tool (default: True)
include_recent: Include recent activity tool (default: True)
include_papers: Include paper search tool (default: True)
+ include_live_papers: Include on-demand live paper search tool (default: False)
include_docstrings: Include code docstring search tool (default: False)
docstrings_language: Filter docstrings by language ('matlab' or 'python')
include_faq: Include mailing list FAQ search tool (default: False)
@@ -649,6 +709,9 @@ def create_knowledge_tools(
if include_papers:
tools.append(create_search_papers_tool(community_id, community_name))
+ if include_live_papers:
+ tools.append(create_search_papers_live_tool(community_id, community_name))
+
if include_docstrings:
tools.append(
create_search_docstrings_tool(community_id, community_name, docstrings_language)
diff --git a/tests/test_knowledge/test_papers_sync.py b/tests/test_knowledge/test_papers_sync.py
index 323d0f4..e6cbde2 100644
--- a/tests/test_knowledge/test_papers_sync.py
+++ b/tests/test_knowledge/test_papers_sync.py
@@ -15,10 +15,13 @@
import src.knowledge.papers_sync as ps
from src.knowledge.db import get_connection, init_db
from src.knowledge.papers_sync import (
+ _cache_papers_async,
_paper_source_and_id,
+ _paper_to_result,
_paper_url,
_store_papers,
configure_openalex,
+ search_papers_live,
sync_all_papers,
sync_citing_papers,
sync_openalex_papers,
@@ -224,6 +227,66 @@ def test_sync_respects_max_results(self, temp_db: Path):
assert count <= 2
+class TestPaperToResult:
+ """Map opencite Paper objects to the shared SearchResult shape."""
+
+ def test_maps_core_fields(self):
+ paper = Paper(
+ title="Recent EEG paper",
+ ids=IDSet(openalex_id="https://openalex.org/W9", doi="10.1/x"),
+ year=2026,
+ abstract="Latest findings.",
+ )
+ result = _paper_to_result(paper)
+ assert result.title == "Recent EEG paper"
+ assert result.url == "https://doi.org/10.1/x"
+ assert result.source == "openalex"
+ assert result.created_at == "2026"
+ assert result.status == "published"
+ assert result.snippet == "Latest findings."
+
+ def test_handles_missing_year_and_id(self):
+ result = _paper_to_result(Paper(title="No metadata", ids=IDSet()))
+ assert result.created_at == ""
+ assert result.source == "opencite"
+
+
+class TestCachePapersAsync:
+ """Background caching of live-search results (real SQLite, no mocks)."""
+
+ def test_caches_papers_into_db(self, temp_db: Path):
+ papers = [
+ Paper(
+ title="Cached paper", ids=IDSet(openalex_id="https://openalex.org/W5"), year=2026
+ ),
+ ]
+ with patch("src.knowledge.db.get_db_path", return_value=temp_db):
+ # Caching is async; join the returned thread before asserting.
+ _cache_papers_async(papers, "test").join(timeout=10)
+ with get_connection("test") as conn:
+ count = conn.execute("SELECT COUNT(*) AS c FROM papers").fetchone()["c"]
+ assert count == 1
+
+
+class TestLivePaperSearch:
+ """Live opencite search (real network)."""
+
+ def test_live_search_returns_recent(self, temp_db: Path):
+ with patch("src.knowledge.db.get_db_path", return_value=temp_db):
+ results = search_papers_live(
+ "EEGLAB EEG independent component analysis",
+ project="test",
+ limit=3,
+ timeout=40,
+ )
+
+ # Network-dependent: accept empty on transient failure, but the shape
+ # must always be correct and every result must be displayable.
+ assert isinstance(results, list)
+ assert all(r.status == "published" for r in results)
+ assert all(r.title for r in results)
+
+
class TestPapersSyncTypeGuard:
"""Sync functions reject bare strings to prevent character iteration."""
diff --git a/tests/test_tools/test_knowledge_tools.py b/tests/test_tools/test_knowledge_tools.py
index c1cd39a..682bc9b 100644
--- a/tests/test_tools/test_knowledge_tools.py
+++ b/tests/test_tools/test_knowledge_tools.py
@@ -278,6 +278,18 @@ def test_includes_docstrings_tool_when_enabled(self) -> None:
assert "get_test_full_docstring" in tool_names
assert len(tools) == 5
+ def test_excludes_live_papers_by_default(self) -> None:
+ """Live paper search is opt-in and absent unless requested."""
+ tools = create_knowledge_tools("test", "Test")
+ assert "search_test_papers_live" not in [t.name for t in tools]
+
+ def test_includes_live_papers_tool_when_enabled(self) -> None:
+ """Should include the live paper search tool when include_live_papers=True."""
+ tools = create_knowledge_tools("test", "Test", include_live_papers=True)
+ tool_names = [t.name for t in tools]
+ assert "search_test_papers_live" in tool_names
+ assert len(tools) == 4
+
def test_includes_faq_tool_when_enabled(self) -> None:
"""Should include FAQ search tool when include_faq=True."""
tools = create_knowledge_tools("test", "Test", include_faq=True)
From 226dc8272f64d39da04424297826d443297e6697 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Fri, 5 Jun 2026 08:34:48 +0000
Subject: [PATCH 31/39] Bump version to 0.8.4.dev15
---
src/version.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/version.py b/src/version.py
index eda7351..d3ebbe8 100644
--- a/src/version.py
+++ b/src/version.py
@@ -1,7 +1,7 @@
"""Version information for OSA."""
-__version__ = "0.8.4.dev14"
-__version_info__ = (0, 8, 4, "dev14")
+__version__ = "0.8.4.dev15"
+__version_info__ = (0, 8, 4, "dev15")
def get_version() -> str:
From 714e44f5c1041366de54406d5e1592e543ac4b1b Mon Sep 17 00:00:00 2001
From: "Seyed (Yahya) Shirazi"
Date: Fri, 5 Jun 2026 02:15:02 -0700
Subject: [PATCH 32/39] feat(papers): local-first live search, confirmed +
faster (#313)
* feat(papers): local-first live search, confirmed, OpenAlex-only
Dogfooding showed live search felt slow and fired too eagerly.
- Live search now queries OpenAlex only (LIVE_SOURCES) instead of also
waiting on Semantic Scholar (~1 req/s) and PubMed; latency drops from
~tens of seconds to ~1s. Batch sync still uses all three.
- Lower default timeout to 15s.
- Tool description + EEGLAB prompt require local-first: always search the
indexed library first, OFFER a live search and wait for confirmation,
and announce 'this might take a minute' before running it. Never call
the live tool as the first action on a paper question.
Closes #312
* docs(tool): align live-search description with announce-then-call framing
Address PR review: a tool description can't enforce pre-call sequencing,
so phrase the announce guidance as 'when you call it, your message in that
turn should first tell the user...' - matters most for communities that
rely on the description without extra prompt guidance.
* fix(papers): address full PR review on live search
silent-failure-hunter:
- narrow live-search error handling: APIKeyError/ConfigurationError -> error
log, OpenCiteError -> warning, both return []; let programming errors
propagate instead of masquerading as 'no results'.
- escalate cache-write failure to logger.error(exc_info=True) (a lost cache
write is a real degraded state).
- tool 'no results' message no longer falsely implies a timeout.
comment-analyzer:
- correct overstated duration: ~15s cap, not 'a minute' (tool description +
EEGLAB prompt say 'a few seconds').
- tighten DEFAULT_SOURCES/LIVE_SOURCES rationale comments.
pr-test-analyzer:
- add TestSourceConstants (live = OpenAlex-only, strict subset of batch
sources); exercise the new sources param + production timeout in the live
test.
---
src/assistants/eeglab/config.yaml | 14 +++++---
src/knowledge/papers_sync.py | 45 ++++++++++++++++++------
src/tools/knowledge.py | 18 ++++++----
tests/test_knowledge/test_papers_sync.py | 19 +++++++++-
4 files changed, 72 insertions(+), 24 deletions(-)
diff --git a/src/assistants/eeglab/config.yaml b/src/assistants/eeglab/config.yaml
index 7c67128..8e4f338 100644
--- a/src/assistants/eeglab/config.yaml
+++ b/src/assistants/eeglab/config.yaml
@@ -146,8 +146,8 @@ system_prompt: |
5. `search_eeglab_faq`: Search mailing list Q&A (archives since 2004)
**Research:**
- 6. `search_eeglab_papers`: Search already-indexed academic literature about EEGLAB and EEG analysis
- 7. `search_eeglab_papers_live`: Live on-demand search of the latest literature (newest first) when the user asks for recent/new papers or the indexed search comes up short
+ 6. `search_eeglab_papers`: Search our already-indexed academic literature about EEGLAB and EEG analysis (instant - use this first)
+ 7. `search_eeglab_papers_live`: SLOW live search of the latest external literature; only after the user confirms they want it (see the Papers flow below)
## Tool Usage Guidelines
@@ -193,12 +193,16 @@ system_prompt: |
**Core EEGLAB papers tracked for citations (DOIs in database):**
{paper_dois}
- **MANDATORY: Use tools for citation/paper questions:**
+ **MANDATORY: paper/citation questions use the LOCAL index first:**
- "Has anyone cited the EEGLAB paper?" -> CALL `search_eeglab_papers(query="EEGLAB")`
- "Papers about ICA in EEGLAB?" -> CALL `search_eeglab_papers(query="ICA EEGLAB")`
- "Research on ICLabel?" -> CALL `search_eeglab_papers(query="ICLabel")`
- - "What are the latest/newest papers on ICLabel?" -> CALL `search_eeglab_papers_live(query="ICLabel")`
- - "Any recent papers using clean_rawdata?" -> CALL `search_eeglab_papers_live(query="clean_rawdata ASR EEG")`
+
+ **Live literature search (slow - ALWAYS ask before running it):**
+ - For ANY paper question, use `search_eeglab_papers` (our indexed library) FIRST and present those results.
+ - If the user then wants the *latest/newest* papers, or the indexed results are thin, OFFER a live search rather than running it: e.g. "I can search the latest literature for this - it takes a few seconds. Want me to?" Then STOP and wait for their reply.
+ - Only after the user confirms (or if they explicitly asked to "search the web" / "latest literature"), first say "OK, let me search through the latest literature - this might take a few seconds." and THEN CALL `search_eeglab_papers_live(query="ICLabel")`.
+ - NEVER call `search_eeglab_papers_live` as the first action on a paper question.
**DO NOT:**
- Tell users to "visit GitHub", "check Google Scholar", or "use the API" when you have the data
diff --git a/src/knowledge/papers_sync.py b/src/knowledge/papers_sync.py
index b981207..280af36 100644
--- a/src/knowledge/papers_sync.py
+++ b/src/knowledge/papers_sync.py
@@ -21,6 +21,7 @@
from opencite import Config, Paper
from opencite.citations import CitationExplorer
+from opencite.exceptions import APIKeyError, ConfigurationError, OpenCiteError
from opencite.search import SearchOrchestrator
from src.knowledge.db import get_connection, update_sync_metadata, upsert_paper
@@ -28,12 +29,19 @@
logger = logging.getLogger(__name__)
-# Scholarly sources synced by default. opencite also supports arxiv, biorxiv,
-# medrxiv, osf, zenodo, figshare, crossref and core; those broader sources are
-# reserved for the opt-in live-search feature (issue #308) so batch sync stays
-# focused on peer-reviewed literature and matches prior coverage.
+# Scholarly sources synced by default (batch sync, where latency does not
+# matter). opencite also supports arxiv, biorxiv, medrxiv, osf, zenodo,
+# figshare, crossref and core; those cover preprints / grey literature and are
+# deliberately omitted so the default batch sync stays focused on peer-reviewed
+# work.
DEFAULT_SOURCES: tuple[str, ...] = ("openalex", "s2", "pubmed")
+# Interactive live search uses OpenAlex only: it is fast, free, comprehensive,
+# and supports server-side recency sorting (by publication date), so the chat
+# stays responsive. The slower, rate-limited sources (Semantic Scholar at
+# ~1 req/s, PubMed) are deliberately left to batch sync.
+LIVE_SOURCES: tuple[str, ...] = ("openalex",)
+
# opencite source name -> OSA `papers.source` label. Kept stable so dedup and
# the existing rows in the database (openalex / semanticscholar / pubmed) line
# up with newly synced papers.
@@ -437,6 +445,7 @@ async def _search_recent(
query: str,
limit: int,
timeout: float,
+ sources: tuple[str, ...],
) -> list[Paper]:
"""Live opencite search for the most recent papers, bounded by a timeout.
@@ -447,7 +456,7 @@ async def _search_recent(
"""
async with SearchOrchestrator(config) as searcher:
result = await asyncio.wait_for(
- searcher.search(query, max_results=limit, sources=DEFAULT_SOURCES, sort="year"),
+ searcher.search(query, max_results=limit, sources=sources, sort="year"),
timeout=timeout,
)
return result.papers
@@ -464,8 +473,11 @@ def _cache_papers_async(papers: list[Paper], project: str) -> threading.Thread:
def _write() -> None:
try:
_store_papers(papers, project)
- except Exception as e:
- logger.warning("Failed to cache live search papers for %s: %s", project, e)
+ except Exception:
+ # A failed cache write means these papers stay missing from local
+ # search until the next batch sync - a real degraded state, so log
+ # loudly (with traceback) even though the daemon thread must not crash.
+ logger.error("Failed to cache live search papers for %s", project, exc_info=True)
thread = threading.Thread(target=_write, name=f"cache-papers-{project}", daemon=True)
thread.start()
@@ -477,7 +489,8 @@ def search_papers_live(
project: str = "hed",
limit: int = 5,
cache: bool = True,
- timeout: float = 20.0,
+ timeout: float = 15.0,
+ sources: tuple[str, ...] = LIVE_SOURCES,
) -> list[SearchResult]:
"""Search the live literature via opencite for the most recent papers.
@@ -494,22 +507,32 @@ def search_papers_live(
community knowledge DB (in a background thread, never blocking the
response) so future local searches find them.
timeout: Hard cap (seconds) on the opencite call to keep chat snappy.
+ sources: opencite sources to query. Defaults to OpenAlex only for speed.
Returns:
- List of SearchResult, newest first. Empty on timeout/error.
+ List of SearchResult, newest first. Empty on timeout or a transient /
+ misconfiguration error (logged); programming errors propagate.
"""
config = _config_from_env()
# Bound each source request just under the overall cap so opencite's
# per-source tasks finish cleanly before wait_for would cancel them.
config.timeout = max(1.0, timeout - 2.0)
try:
- papers = _run(_search_recent(config, query, limit, timeout))
+ papers = _run(_search_recent(config, query, limit, timeout, sources))
except TimeoutError:
logger.warning("opencite live search timed out for '%s' after %.0fs", query, timeout)
return []
- except Exception as e:
+ except (APIKeyError, ConfigurationError) as e:
+ # Permanent misconfiguration (bad/absent key) - surface loudly; it will
+ # not fix itself and otherwise looks identical to "no results".
+ logger.error("opencite live search misconfigured for '%s': %s", query, e)
+ return []
+ except OpenCiteError as e:
+ # Transient API/network/rate-limit failure - a warning + empty is fine.
logger.warning("opencite live search failed for '%s': %s", query, e)
return []
+ # Any other exception is a programming error: let it propagate rather than
+ # masquerade as an empty result set.
if cache and papers:
_cache_papers_async(papers, project)
diff --git a/src/tools/knowledge.py b/src/tools/knowledge.py
index d387570..9409fbd 100644
--- a/src/tools/knowledge.py
+++ b/src/tools/knowledge.py
@@ -280,8 +280,8 @@ def search_papers_live_impl(query: str, limit: int = 5) -> str:
if not results:
return (
- f"No recent papers found online for '{query}' "
- "(the live search may have timed out; try the local paper search)."
+ f"No recent papers found online for '{query}'. "
+ "Try rephrasing, or use the local paper search."
)
lines = ["Most recent papers (live search):\n"]
@@ -298,11 +298,15 @@ def search_papers_live_impl(query: str, limit: int = 5) -> str:
return "\n".join(lines)
description = (
- f"Search the LIVE literature for the most recent papers about {community_name}. "
- "**Use this only when the user explicitly asks for recent / latest / new papers, "
- "or when the local paper search returns nothing relevant.** "
- "It queries external sources on demand (slower than the local search) and returns "
- "the newest results first. "
+ f"Live, on-demand search of the latest external literature about {community_name}, "
+ "newest first. It is slower than the local search (queries the web on demand; "
+ "up to ~15 seconds). "
+ f"Always try the local `search_{community_id}_papers` first. "
+ "**Only call this tool after the user has explicitly confirmed they want a live "
+ "literature search** (or explicitly asked to search the web / for the very latest "
+ "papers). Do NOT call it automatically as a first step. When you call it, your "
+ "message in that turn should first tell the user you are searching the latest "
+ "literature and it may take a few seconds. "
"**This is for DISCOVERY, not answering** - present results as references for "
"further reading; do NOT use paper content to formulate answers."
)
diff --git a/tests/test_knowledge/test_papers_sync.py b/tests/test_knowledge/test_papers_sync.py
index e6cbde2..b23740c 100644
--- a/tests/test_knowledge/test_papers_sync.py
+++ b/tests/test_knowledge/test_papers_sync.py
@@ -268,16 +268,33 @@ def test_caches_papers_into_db(self, temp_db: Path):
assert count == 1
+class TestSourceConstants:
+ """Enforce the live-vs-batch source contract (deterministic, no network)."""
+
+ def test_live_sources_is_openalex_only(self):
+ assert ps.LIVE_SOURCES == ("openalex",)
+
+ def test_default_sources_covers_all_three(self):
+ assert set(ps.DEFAULT_SOURCES) == {"openalex", "s2", "pubmed"}
+
+ def test_live_sources_is_strict_subset_of_default(self):
+ # Live search must never query more sources than batch sync.
+ assert set(ps.LIVE_SOURCES) < set(ps.DEFAULT_SOURCES)
+
+
class TestLivePaperSearch:
"""Live opencite search (real network)."""
def test_live_search_returns_recent(self, temp_db: Path):
with patch("src.knowledge.db.get_db_path", return_value=temp_db):
+ # Pass `sources` explicitly to exercise the parameter threading, and
+ # use the production default timeout.
results = search_papers_live(
"EEGLAB EEG independent component analysis",
project="test",
limit=3,
- timeout=40,
+ timeout=15,
+ sources=("openalex",),
)
# Network-dependent: accept empty on transient failure, but the shape
From 71b2a8514d4c4549557592ffdd7fe3518ab401d7 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Fri, 5 Jun 2026 09:15:19 +0000
Subject: [PATCH 33/39] Bump version to 0.8.4.dev16
---
src/version.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/version.py b/src/version.py
index d3ebbe8..c918c9c 100644
--- a/src/version.py
+++ b/src/version.py
@@ -1,7 +1,7 @@
"""Version information for OSA."""
-__version__ = "0.8.4.dev15"
-__version_info__ = (0, 8, 4, "dev15")
+__version__ = "0.8.4.dev16"
+__version_info__ = (0, 8, 4, "dev16")
def get_version() -> str:
From e7a849c233de19fa1dcd02fe2836d30b13ee6de4 Mon Sep 17 00:00:00 2001
From: "Seyed (Yahya) Shirazi"
Date: Fri, 5 Jun 2026 02:30:30 -0700
Subject: [PATCH 34/39] fix(ci): repair invalid YAML in release.yml (#314)
The 'Append widget SRI hash' step put its markdown heredoc at 6-space
indentation inside a 'run: |' block whose content base is 10 spaces. That
ended the YAML block scalar early and the parser hit '**Standard**' as a
YAML alias (ScannerError at line 77), making the whole workflow file
invalid. GitHub then emitted a startup_failure run on EVERY push (develop
and feature branches), regardless of the tags-only trigger.
Rewrite the step to pass version/hash via env vars into a quoted heredoc
and build the section as an explicit list of lines, so all content stays
within the block scalar. Verified: yaml.safe_load parses, trigger is still
push.tags v*, and the rendered notes section is correct.
Closes #311
---
.github/workflows/release.yml | 49 ++++++++++++++++++++---------------
1 file changed, 28 insertions(+), 21 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 758c515..bc8bc62 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -67,28 +67,35 @@ jobs:
content = f.read()
print('sha384-' + base64.b64encode(hashlib.sha384(content).digest()).decode())
")
- python3 - <
- \`\`\`
-
- **Versioned + SRI** (for supply-chain-secure environments):
- \`\`\`html
-
- \`\`\`
- > Pin to this exact release. Update the tag when upgrading.
- """
- pathlib.Path('/tmp/release_notes.md').write_text(notes + section)
+
+ version = os.environ["OSA_VERSION"]
+ sri = os.environ["OSA_SRI"]
+ section = [
+ "",
+ "## Widget Embedding",
+ "",
+ "**Standard** (always latest):",
+ "```html",
+ '',
+ "```",
+ "",
+ "**Versioned + SRI** (for supply-chain-secure environments):",
+ "```html",
+ f'",
+ "```",
+ "> Pin to this exact release. Update the tag when upgrading.",
+ "",
+ ]
+ path = pathlib.Path("/tmp/release_notes.md")
+ path.write_text(path.read_text() + "\n".join(section))
PYEOF
- name: Create GitHub Release
From 72e02284d38f68c255274bf1d6750962162788c0 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Fri, 5 Jun 2026 09:30:44 +0000
Subject: [PATCH 35/39] Bump version to 0.8.4.dev17
---
src/version.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/version.py b/src/version.py
index c918c9c..19f5a63 100644
--- a/src/version.py
+++ b/src/version.py
@@ -1,7 +1,7 @@
"""Version information for OSA."""
-__version__ = "0.8.4.dev16"
-__version_info__ = (0, 8, 4, "dev16")
+__version__ = "0.8.4.dev17"
+__version_info__ = (0, 8, 4, "dev17")
def get_version() -> str:
From 50addd25e48bbfae01ced688f03f7fedcb55b274 Mon Sep 17 00:00:00 2001
From: "Seyed (Yahya) Shirazi"
Date: Fri, 5 Jun 2026 12:46:30 -0700
Subject: [PATCH 36/39] chore(deps): bump opencite to >=0.5.3 (#315)
opencite 0.5.3 ships the search-task cancellation fix (neuromechanist/opencite#41):
search() now cancels and drains pending per-source tasks in a finally block, so a
wait_for timeout no longer orphans tasks that later touch closed HTTP clients.
Bump the floor to >=0.5.3 to require the fix and refresh uv.lock (0.5.2 -> 0.5.3).
No other packages change.
---
pyproject.toml | 2 +-
uv.lock | 10 +++++-----
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index 83f9a74..b45550c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -66,7 +66,7 @@ server = [
"markdownify>=1.1.0",
# Scheduling
"apscheduler>=3.10.0,<4.0.0",
- "opencite>=0.5.2",
+ "opencite>=0.5.3",
]
observability = [
diff --git a/uv.lock b/uv.lock
index 6995cbd..484cc1d 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2114,8 +2114,8 @@ requires-dist = [
{ name = "markdownify", marker = "extra == 'dev'", specifier = ">=1.1.0" },
{ name = "markdownify", marker = "extra == 'server'", specifier = ">=1.1.0" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.19.0" },
- { name = "opencite", marker = "extra == 'dev'", specifier = ">=0.5.2" },
- { name = "opencite", marker = "extra == 'server'", specifier = ">=0.5.2" },
+ { name = "opencite", marker = "extra == 'dev'", specifier = ">=0.5.3" },
+ { name = "opencite", marker = "extra == 'server'", specifier = ">=0.5.3" },
{ name = "platformdirs", specifier = ">=4.5.0" },
{ name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.5.0" },
{ name = "psycopg", extras = ["binary"], marker = "extra == 'dev'", specifier = ">=3.3.0" },
@@ -2163,15 +2163,15 @@ wheels = [
[[package]]
name = "opencite"
-version = "0.5.2"
+version = "0.5.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
{ name = "pyalex" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/7a/18/313a129d8cee89784c409ec19049f09adf6ba515792747b9dd65dbbd9ddb/opencite-0.5.2.tar.gz", hash = "sha256:d7a63482e4d1a0372fd2d01cc246e3d10b74d50393ae153afe53029b490e7d7e", size = 142084, upload-time = "2026-05-21T19:54:15.971Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/fd/47/2cef932d68ca50361afb888ac18592fcfdb8ea0f4d18df9f0af61b0765b9/opencite-0.5.3.tar.gz", hash = "sha256:c1e19d8253615644870be95a4d7bbb09540a33b1e241c43d451348aa3ef48df5", size = 157917, upload-time = "2026-06-05T18:59:56.706Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/0e/b8/d00880ff96e9d3808c6b65e6347b7c4f9fdc8b72213a5dd4cd147224207e/opencite-0.5.2-py3-none-any.whl", hash = "sha256:d4ffb6b553c94df454dbc1de4eed55fdc2ea7604099fea0e3fdbc5a5ea654ce1", size = 96732, upload-time = "2026-05-21T19:54:14.574Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/f1/7925aa055263bc62e3b4cded285e7992c167a85716df12cf456372701c2d/opencite-0.5.3-py3-none-any.whl", hash = "sha256:e8607ebff6eeda30b86362c3031307773c99925df9c429353c802b04f50c66ec", size = 97003, upload-time = "2026-06-05T18:59:55.284Z" },
]
[[package]]
From b6a22d7bbdadd24afbc21f4df4c6e36d2fcf44e9 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Fri, 5 Jun 2026 19:46:40 +0000
Subject: [PATCH 37/39] Bump version to 0.8.4.dev18
---
src/version.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/version.py b/src/version.py
index 19f5a63..d3f72ad 100644
--- a/src/version.py
+++ b/src/version.py
@@ -1,7 +1,7 @@
"""Version information for OSA."""
-__version__ = "0.8.4.dev17"
-__version_info__ = (0, 8, 4, "dev17")
+__version__ = "0.8.4.dev18"
+__version_info__ = (0, 8, 4, "dev18")
def get_version() -> str:
From 6c873f2c2e2c37fb152de9d4a728d9dc7eb9717b Mon Sep 17 00:00:00 2001
From: "Seyed (Yahya) Shirazi"
Date: Mon, 8 Jun 2026 11:02:42 -0700
Subject: [PATCH 38/39] fix: pre-release hardening for 0.8.4 (#317)
* feat(papers): make live_search opt-in (default off), enable for EEGLAB
Live web paper search added external-API latency to every community with a
citations block. Default to False so communities opt in explicitly; enable it
for EEGLAB, whose prompt already tells the agent to ask before running it.
* fix(papers): harden opencite sync error handling
Narrow the broad except in the batch search/citation loops to
(OpenCiteError, TimeoutError) for expected API failures; log any other
exception with a full traceback (logger.exception) so a bug no longer
masquerades as a routine 'no results'. Isolate per-query/per-DOI _store_papers
so one DB failure cannot abort the batch or desync metadata. Add type hints to
_run.
* chore(deps): drop direct pyalex dependency
pyalex is no longer imported anywhere; opencite pulls it in transitively. Remove
the redundant direct declaration and refresh the stale OpenAlex comment.
* fix(feedback): return 422 not 500 on FeedbackEntry invariant drift
FeedbackRequest and FeedbackEntry validate the same invariants independently;
if they ever drift, guard the construction so a bad shape returns a clean 422
instead of an unhandled 500. Add a whitespace-only general-comment rejection test.
* fix(widget): timeout and double-submit guard on general feedback
postFeedback now aborts after 10s instead of hanging indefinitely, and the
general-feedback Send button is disabled while the POST is in flight so a
double-click cannot store two rows.
* style: ruff-format widget-sri.py
---
frontend/osa-chat-widget.js | 26 +++++++---
pyproject.toml | 1 -
scripts/widget-sri.py | 2 +-
src/api/routers/feedback.py | 29 ++++++-----
src/assistants/eeglab/config.yaml | 1 +
src/cli/sync.py | 2 +-
src/core/config/community.py | 8 +++-
src/knowledge/papers_sync.py | 48 ++++++++++++++-----
tests/test_api/test_feedback_endpoint.py | 9 ++++
tests/test_core/test_config/test_community.py | 5 ++
uv.lock | 9 ++--
11 files changed, 100 insertions(+), 40 deletions(-)
diff --git a/frontend/osa-chat-widget.js b/frontend/osa-chat-widget.js
index 7065e82..a76045a 100644
--- a/frontend/osa-chat-widget.js
+++ b/frontend/osa-chat-widget.js
@@ -1938,6 +1938,7 @@
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ community_id: CONFIG.communityId, ...payload }),
+ signal: AbortSignal.timeout(10000),
});
if (!response.ok) {
console.warn('[OSA] Feedback submission returned', response.status);
@@ -2069,12 +2070,25 @@
return;
}
- const ok = await postFeedback({
- feedback_type: 'general',
- comment,
- session_id: sessionId || null,
- page_url: (typeof window !== 'undefined' && window.location) ? window.location.href : null,
- });
+ // Guard against a double-click submitting the comment twice while the POST
+ // is in flight (each would store a separate row).
+ const sendBtn = container.querySelector('.osa-feedback-send-btn');
+ if (sendBtn) {
+ if (sendBtn.disabled) return;
+ sendBtn.disabled = true;
+ }
+
+ let ok = false;
+ try {
+ ok = await postFeedback({
+ feedback_type: 'general',
+ comment,
+ session_id: sessionId || null,
+ page_url: (typeof window !== 'undefined' && window.location) ? window.location.href : null,
+ });
+ } finally {
+ if (sendBtn) sendBtn.disabled = false;
+ }
if (ok) {
const thanks = container.querySelector('.osa-feedback-modal-thanks');
diff --git a/pyproject.toml b/pyproject.toml
index b45550c..410b4af 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -56,7 +56,6 @@ server = [
"langchain-litellm>=0.2.0",
# External APIs
"pygithub>=2.8.0",
- "pyalex>=0.19",
# Database
"psycopg[binary]>=3.3.0",
# Utilities
diff --git a/scripts/widget-sri.py b/scripts/widget-sri.py
index 29b913c..274a7e3 100644
--- a/scripts/widget-sri.py
+++ b/scripts/widget-sri.py
@@ -46,7 +46,7 @@ def main() -> None:
hash_value = sri(content)
print(f"\nSRI hash ({label}):")
- print(f" integrity=\"{hash_value}\"")
+ print(f' integrity="{hash_value}"')
if tag:
print(f"\nVersioned embed snippet for {tag}:")
diff --git a/src/api/routers/feedback.py b/src/api/routers/feedback.py
index 392f4e0..b4a0629 100644
--- a/src/api/routers/feedback.py
+++ b/src/api/routers/feedback.py
@@ -125,18 +125,23 @@ async def submit_feedback(
body.community_id,
)
- entry = FeedbackEntry(
- feedback_id=str(uuid.uuid4()),
- timestamp=now_iso(),
- community_id=body.community_id,
- feedback_type=body.feedback_type,
- sentiment=body.sentiment,
- request_id=body.request_id,
- session_id=body.session_id,
- message_index=body.message_index,
- comment=body.comment,
- page_url=body.page_url,
- )
+ try:
+ entry = FeedbackEntry(
+ feedback_id=str(uuid.uuid4()),
+ timestamp=now_iso(),
+ community_id=body.community_id,
+ feedback_type=body.feedback_type,
+ sentiment=body.sentiment,
+ request_id=body.request_id,
+ session_id=body.session_id,
+ message_index=body.message_index,
+ comment=body.comment,
+ page_url=body.page_url,
+ )
+ except ValueError as e:
+ # FeedbackRequest already validates these invariants; this guards against
+ # the two validators drifting apart so a bad shape returns 422, not 500.
+ raise HTTPException(status_code=422, detail=str(e)) from e
# write_feedback is best-effort: it logs and swallows storage errors (and
# escalates after repeated failures) rather than failing the user's request,
diff --git a/src/assistants/eeglab/config.yaml b/src/assistants/eeglab/config.yaml
index 8e4f338..a51c072 100644
--- a/src/assistants/eeglab/config.yaml
+++ b/src/assistants/eeglab/config.yaml
@@ -414,6 +414,7 @@ github:
# Paper/citation search configuration
citations:
+ live_search: true # prompt already instructs the agent to ask before running it
queries:
- EEGLAB tutorial
- EEGLAB plugin
diff --git a/src/cli/sync.py b/src/cli/sync.py
index e149686..ce4503a 100644
--- a/src/cli/sync.py
+++ b/src/cli/sync.py
@@ -190,7 +190,7 @@ def _safe_load_config() -> tuple[str | None, str | None]:
if not pubmed_key:
pubmed_key = os.environ.get("PUBMED_API_KEY")
- # Configure OpenAlex from env vars (pyalex uses global config)
+ # Configure OpenAlex credentials from env vars (merged into the opencite Config)
openalex_key = os.environ.get("OPENALEX_API_KEY")
openalex_email = os.environ.get("OPENALEX_EMAIL")
configure_openalex(api_key=openalex_key, email=openalex_email)
diff --git a/src/core/config/community.py b/src/core/config/community.py
index c7bce15..75d01b4 100644
--- a/src/core/config/community.py
+++ b/src/core/config/community.py
@@ -236,8 +236,12 @@ class CitationConfig(BaseModel):
dois: list[str] = Field(default_factory=list)
"""Core paper DOIs to track citations for (format: '10.xxxx/yyyy')."""
- live_search: bool = Field(default=True)
- """Expose an on-demand live paper search tool (opencite) for recent literature."""
+ live_search: bool = Field(default=False)
+ """Expose an on-demand live paper search tool (opencite) for recent literature.
+
+ Off by default: the tool adds external-API latency to a turn and queries
+ OpenAlex anonymously. Communities opt in explicitly, and their prompt should
+ tell the agent to ask the user before running it."""
@field_validator("queries")
@classmethod
diff --git a/src/knowledge/papers_sync.py b/src/knowledge/papers_sync.py
index 280af36..a83806b 100644
--- a/src/knowledge/papers_sync.py
+++ b/src/knowledge/papers_sync.py
@@ -16,8 +16,9 @@
import logging
import os
import threading
-from collections.abc import Iterable
+from collections.abc import Coroutine, Iterable
from concurrent.futures import ThreadPoolExecutor
+from typing import Any, TypeVar
from opencite import Config, Paper
from opencite.citations import CitationExplorer
@@ -198,7 +199,10 @@ def _store_papers(
return counts
-def _run(coro):
+_T = TypeVar("_T")
+
+
+def _run(coro: Coroutine[Any, Any, _T]) -> _T:
"""Execute an async coroutine from synchronous code.
OSA's sync callers (CLI command, scheduler thread) have no running event
@@ -232,9 +236,15 @@ async def _search_queries(
try:
result = await searcher.search(query, max_results=max_results, sources=sources)
out.append((query, result.papers))
- except Exception as e:
+ except (OpenCiteError, TimeoutError) as e:
logger.warning("opencite search error for '%s': %s", query, e)
out.append((query, []))
+ except Exception:
+ # Unexpected (likely a bug, not an API failure): keep the batch
+ # going but log loudly with a traceback so it is not mistaken
+ # for a routine "no results" outcome.
+ logger.exception("unexpected error searching '%s'", query)
+ out.append((query, []))
return out
@@ -250,9 +260,12 @@ async def _citing_for_dois(
try:
result = await explorer.citing_papers(doi, max_results=max_results)
out.append((doi, result.papers))
- except Exception as e:
+ except (OpenCiteError, TimeoutError) as e:
logger.warning("opencite citation error for DOI %s: %s", doi, e)
out.append((doi, []))
+ except Exception:
+ logger.exception("unexpected error fetching citations for DOI %s", doi)
+ out.append((doi, []))
return out
@@ -357,10 +370,15 @@ def sync_all_papers(
return results
for query, papers in searched:
- counts = _store_papers(papers, project)
- for source, n in counts.items():
- results[source] = results.get(source, 0) + n
- update_sync_metadata("papers", f"opencite:{query}", sum(counts.values()), project)
+ try:
+ counts = _store_papers(papers, project)
+ for source, n in counts.items():
+ results[source] = results.get(source, 0) + n
+ update_sync_metadata("papers", f"opencite:{query}", sum(counts.values()), project)
+ except Exception:
+ # Isolate per-query: a DB failure on one query must not abort the
+ # whole batch or leave sync metadata inconsistent for the others.
+ logger.exception("failed to store papers for '%s' (%s)", query, project)
total = sum(results.values())
logger.info("Total papers synced for %s: %d", project, total)
@@ -401,11 +419,15 @@ def sync_citing_papers(
total = 0
for doi, papers in cited:
- counts = _store_papers(papers, project)
- count = sum(counts.values())
- update_sync_metadata("papers", f"citing_{doi}", count, project)
- logger.info("Synced %d papers citing %s", count, doi)
- total += count
+ try:
+ counts = _store_papers(papers, project)
+ count = sum(counts.values())
+ update_sync_metadata("papers", f"citing_{doi}", count, project)
+ logger.info("Synced %d papers citing %s", count, doi)
+ total += count
+ except Exception:
+ # Isolate per-DOI so one DB failure does not abort the batch.
+ logger.exception("failed to store citing papers for %s (%s)", doi, project)
return total
diff --git a/tests/test_api/test_feedback_endpoint.py b/tests/test_api/test_feedback_endpoint.py
index b307cf8..38540b3 100644
--- a/tests/test_api/test_feedback_endpoint.py
+++ b/tests/test_api/test_feedback_endpoint.py
@@ -116,6 +116,15 @@ def test_general_without_comment_rejected(self, client):
)
assert resp.status_code == 422
+ def test_general_whitespace_comment_rejected(self, client):
+ # _normalize collapses a whitespace-only comment to None, and _check_shape
+ # then rejects general feedback that carries no real comment.
+ resp = client.post(
+ "/feedback",
+ json={"community_id": "hed", "feedback_type": "general", "comment": " "},
+ )
+ assert resp.status_code == 422
+
def test_oversized_comment_rejected(self, client):
resp = client.post(
"/feedback",
diff --git a/tests/test_core/test_config/test_community.py b/tests/test_core/test_config/test_community.py
index 1e4ae5c..ab5f8f8 100644
--- a/tests/test_core/test_config/test_community.py
+++ b/tests/test_core/test_config/test_community.py
@@ -150,6 +150,11 @@ def test_empty_defaults(self) -> None:
assert config.queries == []
assert config.dois == []
+ def test_live_search_off_by_default(self) -> None:
+ """Live search is opt-in: a community must enable it explicitly."""
+ assert CitationConfig().live_search is False
+ assert CitationConfig(live_search=True).live_search is True
+
def test_validates_doi_format(self) -> None:
"""Should validate DOI format."""
with pytest.raises(ValidationError, match="Invalid DOI format"):
diff --git a/uv.lock b/uv.lock
index 484cc1d..8824ba3 100644
--- a/uv.lock
+++ b/uv.lock
@@ -811,6 +811,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/cb/48e964c452ca2b92175a9b2dca037a553036cb053ba69e284650ce755f13/greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e", size = 274908, upload-time = "2025-12-04T14:23:26.435Z" },
{ url = "https://files.pythonhosted.org/packages/28/da/38d7bff4d0277b594ec557f479d65272a893f1f2a716cad91efeb8680953/greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62", size = 577113, upload-time = "2025-12-04T14:50:05.493Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f2/89c5eb0faddc3ff014f1c04467d67dee0d1d334ab81fadbf3744847f8a8a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32", size = 590338, upload-time = "2025-12-04T14:57:41.136Z" },
+ { url = "https://files.pythonhosted.org/packages/80/d7/db0a5085035d05134f8c089643da2b44cc9b80647c39e93129c5ef170d8f/greenlet-3.3.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:670d0f94cd302d81796e37299bcd04b95d62403883b24225c6b5271466612f45", size = 601098, upload-time = "2025-12-04T15:07:11.898Z" },
{ url = "https://files.pythonhosted.org/packages/dc/a6/e959a127b630a58e23529972dbc868c107f9d583b5a9f878fb858c46bc1a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948", size = 590206, upload-time = "2025-12-04T14:26:01.254Z" },
{ url = "https://files.pythonhosted.org/packages/48/60/29035719feb91798693023608447283b266b12efc576ed013dd9442364bb/greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794", size = 1550668, upload-time = "2025-12-04T15:04:22.439Z" },
{ url = "https://files.pythonhosted.org/packages/0a/5f/783a23754b691bfa86bd72c3033aa107490deac9b2ef190837b860996c9f/greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5", size = 1615483, upload-time = "2025-12-04T14:27:28.083Z" },
@@ -818,6 +819,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" },
{ url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" },
{ url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" },
+ { url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" },
{ url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" },
{ url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" },
{ url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" },
@@ -825,6 +827,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" },
{ url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" },
{ url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" },
+ { url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" },
{ url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" },
{ url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" },
{ url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" },
@@ -832,6 +835,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" },
{ url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" },
{ url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" },
+ { url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" },
{ url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" },
{ url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" },
{ url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" },
@@ -839,6 +843,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" },
{ url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" },
{ url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" },
+ { url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" },
{ url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" },
{ url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" },
{ url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" },
@@ -2043,7 +2048,6 @@ dev = [
{ name = "opencite" },
{ name = "pre-commit" },
{ name = "psycopg", extra = ["binary"] },
- { name = "pyalex" },
{ name = "pydantic-settings" },
{ name = "pygithub" },
{ name = "pytest" },
@@ -2074,7 +2078,6 @@ server = [
{ name = "markdownify" },
{ name = "opencite" },
{ name = "psycopg", extra = ["binary"] },
- { name = "pyalex" },
{ name = "pydantic-settings" },
{ name = "pygithub" },
{ name = "python-dotenv" },
@@ -2120,8 +2123,6 @@ requires-dist = [
{ name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.5.0" },
{ name = "psycopg", extras = ["binary"], marker = "extra == 'dev'", specifier = ">=3.3.0" },
{ name = "psycopg", extras = ["binary"], marker = "extra == 'server'", specifier = ">=3.3.0" },
- { name = "pyalex", marker = "extra == 'dev'", specifier = ">=0.19" },
- { name = "pyalex", marker = "extra == 'server'", specifier = ">=0.19" },
{ name = "pydantic", specifier = ">=2.12.0" },
{ name = "pydantic-settings", marker = "extra == 'dev'", specifier = ">=2.12.0" },
{ name = "pydantic-settings", marker = "extra == 'server'", specifier = ">=2.12.0" },
From deddd44510bfa5a0bd1ca6dbeaa1e515d9bed257 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Mon, 8 Jun 2026 18:02:56 +0000
Subject: [PATCH 39/39] Bump version to 0.8.4.dev19
---
src/version.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/version.py b/src/version.py
index d3f72ad..20b7cc8 100644
--- a/src/version.py
+++ b/src/version.py
@@ -1,7 +1,7 @@
"""Version information for OSA."""
-__version__ = "0.8.4.dev18"
-__version_info__ = (0, 8, 4, "dev18")
+__version__ = "0.8.4.dev19"
+__version_info__ = (0, 8, 4, "dev19")
def get_version() -> str: