diff --git a/.context/community-admin-merge.md b/.context/community-admin-merge.md
new file mode 100644
index 0000000..282959f
--- /dev/null
+++ b/.context/community-admin-merge.md
@@ -0,0 +1,177 @@
+# Community Maintainer Merge Command
+
+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 comment is posted on a PR, the workflow:
+
+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`.
+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). Default posture is "do not merge".
+
+## Trust model
+
+- **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
+
+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 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, 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 (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 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 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 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 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
+ (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. 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 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
new file mode 100644
index 0000000..48b5a58
--- /dev/null
+++ b/.github/workflows/community-admin-pr-merge.yml
@@ -0,0 +1,335 @@
+name: Community Maintainer Merge Command
+
+# 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 + security model
+# -----------------------------------------------------------------------------
+# 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 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: 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:
+ issue_comment:
+ types: [created]
+
+# 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
+
+concurrency:
+ group: community-maintainer-merge-${{ github.event.issue.number }}
+ cancel-in-progress: false
+
+jobs:
+ merge-on-command:
+ # 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?
+ # 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
+ 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. 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
+ if: steps.cmd.outputs.command == 'true'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ REPO: ${{ github.repository }}
+ PR_NUMBER: ${{ github.event.issue.number }}
+ COMMENTER_LOGIN: ${{ github.event.comment.user.login }}
+ run: |
+ set -euo pipefail
+
+ not_eligible() {
+ echo "::notice::Not merging: $1"
+ { echo "eligible=false"; echo "community="; } >> "$GITHUB_OUTPUT"
+ exit 0
+ }
+
+ # 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[@]}"
+
+ COMMUNITY=""
+ for f in "${CHANGED[@]}"; do
+ if [[ "$f" == *"/../"* || "$f" == "../"* || "$f" == *"/.." || "$f" == /* ]]; then
+ not_eligible "path contains a parent-directory or absolute segment: $f"
+ fi
+ 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
+ [ -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. 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
+ python - <<'PY' > /tmp/base_maintainers.txt || PARSE_RC=$?
+ import sys, yaml
+ 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())
+ for n in sorted(set(names)):
+ print(n)
+ PY
+ PARSE_RC=${PARSE_RC:-0}
+ [ "$PARSE_RC" -eq 0 ] || not_eligible "base config.yaml maintainers could not be parsed"
+
+ # 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 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 (delete/rename needs human review)"
+ fi
+ python - <<'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}
+ [ "$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
+
+ # 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
+
+ echo "::notice::Merging PR #$PR_NUMBER ($COMMUNITY) into develop on @$COMMENTER command; head $HEAD_SHA"
+ {
+ echo "eligible=true"
+ echo "community=$COMMUNITY"
+ echo "head_sha=$HEAD_SHA"
+ echo "commenter=$COMMENTER"
+ } >> "$GITHUB_OUTPUT"
+
+ # Mint the GitHub App installation token. Only reached when authorized.
+ - 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 }}
+
+ # 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.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" \
+ "/repos/$REPO/pulls/$PR_NUMBER/reviews" \
+ -f event=APPROVE \
+ -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 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.issue.number }}
+ HEAD_SHA: ${{ steps.eval.outputs.head_sha }}
+ run: |
+ set -euo pipefail
+ 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"
+
+ # 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 commenter = process.env.COMMENTER;
+ const marker = '';
+ const body = `${marker}\n` +
+ `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.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,
+ });
+ } else {
+ await github.rest.issues.createComment({
+ owner: context.repo.owner, repo: context.repo.repo,
+ issue_number: context.payload.issue.number, body,
+ });
+ }
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a6332f7..bc8bc62 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -58,6 +58,45 @@ 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())
+ ")
+ export OSA_VERSION="$VERSION"
+ export OSA_SRI="$HASH"
+ python3 - <<'PYEOF'
+ import os
+ import pathlib
+
+ 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
uses: softprops/action-gh-release@v2
diff --git a/CLAUDE.md b/CLAUDE.md
index 472865b..3ec24c1 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** - 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
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 @@
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/frontend/osa-chat-widget.js b/frontend/osa-chat-widget.js
index 1df9f59..a76045a 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)
@@ -118,7 +118,9 @@
copy: '',
check: '',
popout: '',
- settings: ''
+ settings: '',
+ thumbUp: '',
+ thumbDown: ''
};
// CSS Styles
@@ -569,6 +571,122 @@
gap: 8px;
}
+ .osa-message-feedback {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ 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-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);
@@ -748,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;
@@ -810,6 +951,18 @@
text-decoration: underline;
}
+ .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 {
@@ -1293,7 +1446,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) {
@@ -1766,6 +1927,180 @@
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 }),
+ signal: AbortSignal.timeout(10000),
+ });
+ 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;
+ }
+ }
+
+ // 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;
+
+ msg.feedback = sentiment;
+ 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) {
+ 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);
+ try {
+ saveHistory();
+ } catch (e) {
+ console.error('[OSA] Failed to persist feedback locally:', e);
+ }
+ return;
+ }
+
+ // 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
+ 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;
+ }
+
+ // 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');
+ 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');
@@ -1958,7 +2293,10 @@
${ICONS.send}
-
${escapeHtml(CONFIG.disclaimerText || '')}
+
+ ${escapeHtml(CONFIG.disclaimerText || '')}
+
+
+
+
+
+
+
Send feedback
+
+
+
+
+
+
+
+ Shared with the ${escapeHtml(CONFIG.title.replace(' Assistant', ''))} community maintainers. Please do not include personal information.
+
+
+
+
+ Thanks for your feedback!
+
+
+
`;
@@ -2056,12 +2432,43 @@
? ``
: '';
+ // Per-response feedback (thumbs up/down) for assistant replies, but not
+ // 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 committed = !!msg.feedbackCommitted;
+ const pendingDown = fb === 'down' && !committed;
+ let feedbackRow = '';
+ if (showFeedback) {
+ const upBtn = ``;
+ const downBtn = ``;
+ if (pendingDown) {
+ feedbackRow = `
+ ${upBtn}${downBtn}
+
+
+
+
+
+
+
+
`;
+ } else {
+ feedbackRow = `
+ ${upBtn}${downBtn}
+ Thanks!
+
`;
+ }
+ }
+
msgEl.innerHTML = `
${escapeHtml(label)}
${copyBtn}
${content}
+ ${feedbackRow}
`;
messagesEl.appendChild(msgEl);
});
@@ -2093,6 +2500,44 @@
});
});
+ // 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);
+ });
+ });
+
+ // 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';
@@ -2236,7 +2681,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 +2698,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 {
@@ -2357,6 +2807,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
@@ -2473,7 +2926,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) {
@@ -2582,6 +3039,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 {
@@ -2609,6 +3068,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;
@@ -2852,6 +3313,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/pyproject.toml b/pyproject.toml
index e54707c..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
@@ -66,6 +65,7 @@ server = [
"markdownify>=1.1.0",
# Scheduling
"apscheduler>=3.10.0,<4.0.0",
+ "opencite>=0.5.3",
]
observability = [
diff --git a/scripts/widget-sri.py b/scripts/widget-sri.py
new file mode 100644
index 0000000..274a7e3
--- /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()
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..b4a0629
--- /dev/null
+++ b/src/api/routers/feedback.py
@@ -0,0 +1,150 @@
+"""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,
+ )
+
+ 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,
+ # 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/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 7d147ff..a51c072 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:
@@ -145,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 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
@@ -191,11 +193,17 @@ 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")`
+ **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
- Make up PR numbers, issue numbers, paper titles, authors, or citation counts
@@ -406,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/assistants/hed/config.yaml b/src/assistants/hed/config.yaml
index 84a41f9..718b6f0 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:
@@ -461,3 +462,8 @@ 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.
+# (verifying the LGTM/merge comment command)
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:
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
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 a3ab38f..75d01b4 100644
--- a/src/core/config/community.py
+++ b/src/core/config/community.py
@@ -236,6 +236,13 @@ 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=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
def validate_queries(cls, v: list[str]) -> list[str]:
diff --git a/src/knowledge/papers_sync.py b/src/knowledge/papers_sync.py
index 95eea0d..a83806b 100644
--- a/src/knowledge/papers_sync.py
+++ b/src/knowledge/papers_sync.py
@@ -1,177 +1,301 @@
-"""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
+import os
+import threading
+from collections.abc import Coroutine, Iterable
+from concurrent.futures import ThreadPoolExecutor
+from typing import Any, TypeVar
-import httpx
-import pyalex
-from pyalex import Works
+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
+from src.knowledge.search import SearchResult
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 (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.
+_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
+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 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.
+ 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).
+ """
+ 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:
- doi: The DOI (may be full URL or bare DOI)
- fallback_id: Fallback URL/ID if no DOI
-
- Returns:
- URL string
+ 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.
"""
- if not doi:
- return fallback_id
- return doi if doi.startswith("http") else f"https://doi.org/{doi}"
+ counts: dict[str, int] = {}
+ with get_connection(project) as conn:
+ for paper in papers:
+ if not paper.title:
+ continue
+ 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)
-def _get_openalex_external_id(openalex_id: str) -> str:
- """Extract external ID from OpenALEX URL.
+ if not source or not external_id:
+ continue
- Args:
- openalex_id: Full OpenALEX URL or bare ID
+ upsert_paper(
+ conn,
+ source=source,
+ external_id=external_id,
+ 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),
+ )
+ counts[source] = counts.get(source, 0) + 1
+ conn.commit()
+ return counts
- Returns:
- Bare external ID (e.g., "W12345")
- """
- return openalex_id.removeprefix("https://openalex.org/")
+_T = TypeVar("_T")
-def sync_openalex_papers(query: str, max_results: int = 100, project: str = "hed") -> int:
- """Sync papers from OpenALEX matching query.
- Args:
- query: Search query
- max_results: Maximum number of papers to sync
- project: Assistant/project name for database isolation. Defaults to 'hed'.
+def _run(coro: Coroutine[Any, Any, _T]) -> _T:
+ """Execute an async coroutine from synchronous code.
- Returns:
- Number of papers synced
+ 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.
"""
- 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)))
+ 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 (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
+
+
+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 (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
+
+
+def _sync_single_source(
+ query: str,
+ max_results: int,
+ project: str,
+ osa_source: str,
+ config: Config,
+) -> int:
+ """Sync papers for one source (restricted opencite search) into the DB."""
+ opencite_source = _OPENCITE_SOURCE_BY_OSA[osa_source]
+ try:
+ searched = _run(_search_queries(config, [query], max_results, (opencite_source,)))
except Exception as e:
- logger.warning("OpenALEX error for '%s': %s", query, 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 work in works:
- if count >= max_results:
- break
-
- # Skip if no title
- 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
+ _, 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
- conn.commit()
- logger.info("Synced %d papers from OpenALEX for '%s'", count, query)
- update_sync_metadata("papers", f"openalex:{query}", count, project)
- return count
+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())
def sync_semanticscholar_papers(
@@ -180,79 +304,10 @@ def sync_semanticscholar_papers(
api_key: str | None = None,
project: str = "hed",
) -> 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
- """
+ """Sync papers from Semantic Scholar matching query (via opencite)."""
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
-
- 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)
- 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}"
-
- # 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()
-
- 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
+ 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 +316,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 +331,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 +355,30 @@ 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:
+ 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)
@@ -423,94 +392,171 @@ 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)
+
+ 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
+
total = 0
+ for doi, papers in cited:
+ 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
+
+
+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,
+ sources: tuple[str, ...],
+) -> 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=sources, sort="year"),
+ timeout=timeout,
+ )
+ return result.papers
- for doi in dois:
- logger.info("Syncing papers citing DOI: %s", doi)
+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:
- # First, look up the OpenALEX work ID for this DOI
- work_lookup = Works()[f"https://doi.org/{doi}"]
- openalex_id = work_lookup.get("id")
+ _store_papers(papers, project)
+ 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)
- if not openalex_id:
- logger.warning("Could not find OpenALEX ID for DOI %s", doi)
- continue
+ thread = threading.Thread(target=_write, name=f"cache-papers-{project}", daemon=True)
+ thread.start()
+ return thread
- 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
- update_sync_metadata("papers", f"citing_{doi}", count, project)
- logger.info("Synced %d papers citing %s", count, doi)
- total += count
- return total
+def search_papers_live(
+ query: str,
+ project: str = "hed",
+ limit: int = 5,
+ cache: bool = True,
+ timeout: float = 15.0,
+ sources: tuple[str, ...] = LIVE_SOURCES,
+) -> 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.
+ sources: opencite sources to query. Defaults to OpenAlex only for speed.
+
+ Returns:
+ 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, sources))
+ except TimeoutError:
+ logger.warning("opencite live search timed out for '%s' after %.0fs", query, timeout)
+ return []
+ 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)
+
+ return [_paper_to_result(p) for p in papers[:limit]]
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/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/src/tools/knowledge.py b/src/tools/knowledge.py
index 8f43258..9409fbd 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,67 @@ 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}'. "
+ "Try rephrasing, or use 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"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."
+ )
+
+ 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 +673,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 +692,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 +713,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/src/version.py b/src/version.py
index d79d2d1..20b7cc8 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.dev19"
+__version_info__ = (0, 8, 4, "dev19")
def get_version() -> str:
diff --git a/tests/test_api/test_feedback_endpoint.py b/tests/test_api/test_feedback_endpoint.py
new file mode 100644
index 0000000..38540b3
--- /dev/null
+++ b/tests/test_api/test_feedback_endpoint.py
@@ -0,0 +1,348 @@
+"""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_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",
+ 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_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/tests/test_knowledge/test_papers_sync.py b/tests/test_knowledge/test_papers_sync.py
index ab2d123..b23740c 100644
--- a/tests/test_knowledge/test_papers_sync.py
+++ b/tests/test_knowledge/test_papers_sync.py
@@ -1,18 +1,27 @@
-"""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,
+ _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,
@@ -29,117 +38,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 +220,92 @@ 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."""
+ def test_sync_respects_max_results(self, temp_db: Path):
+ """max_results is respected for a single-source sync."""
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")
+ count = sync_openalex_papers("neuroscience", max_results=2, project="test")
+ assert count <= 2
- # 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."""
+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):
- # Request only 2 results
- count = sync_openalex_papers("neuroscience", max_results=2, project="test")
+ # 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
- # Should not exceed max_results
- assert count <= 2
+
+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=15,
+ sources=("openalex",),
+ )
+
+ # 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:
- """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/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_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
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
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)
diff --git a/uv.lock b/uv.lock
index 22d7aee..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" },
@@ -2040,9 +2045,9 @@ dev = [
{ name = "lxml" },
{ name = "markdownify" },
{ name = "mypy" },
+ { name = "opencite" },
{ name = "pre-commit" },
{ name = "psycopg", extra = ["binary"] },
- { name = "pyalex" },
{ name = "pydantic-settings" },
{ name = "pygithub" },
{ name = "pytest" },
@@ -2071,8 +2076,8 @@ server = [
{ name = "litellm" },
{ name = "lxml" },
{ name = "markdownify" },
+ { name = "opencite" },
{ name = "psycopg", extra = ["binary"] },
- { name = "pyalex" },
{ name = "pydantic-settings" },
{ name = "pygithub" },
{ name = "python-dotenv" },
@@ -2112,12 +2117,12 @@ 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.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" },
{ 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" },
@@ -2157,6 +2162,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.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "httpx" },
+ { name = "pyalex" },
+]
+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/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]]
name = "opentelemetry-api"
version = "1.39.1"