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 @@

+

Admin: Feedback

+
Loading feedback...
`; @@ -1167,6 +1221,8 @@

Failed to load feedback (HTTP ${resp.status})

`; + } + return; + } + renderAdminFeedback(await resp.json(), communityId); + } catch (err) { + console.error('Failed to load feedback:', err); + container.innerHTML = '

Failed to load feedback: unexpected server response. Try refreshing the page.

'; + } + } + + function toggleFeedbackCommentsOnly(checked, communityId) { + feedbackCommentsOnly = checked; + loadAdminFeedback(decodeURIComponent(communityId)); + } + + function formatFeedbackTime(isoStr) { + if (!isoStr) return '—'; + try { + const d = new Date(isoStr); + if (isNaN(d.getTime())) return isoStr; + return d.toLocaleString(undefined, { + year: 'numeric', month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit', + }); + } catch (err) { console.warn('Failed to parse feedback timestamp:', isoStr, err); return isoStr; } + } + + function renderAdminFeedback(data, communityId) { + const container = document.getElementById('adminFeedback'); + if (!container) return; + + const summary = data.summary || {}; + const up = summary.thumbs_up || 0; + const down = summary.thumbs_down || 0; + const satRate = (summary.satisfaction_rate === null || summary.satisfaction_rate === undefined) + ? '—' : `${(summary.satisfaction_rate * 100).toFixed(0)}%`; + const responseTotal = summary.response_total || 0; + const generalTotal = summary.general_total || 0; + const commentTotal = summary.comment_total || 0; + + const summaryHtml = ` +
+
+ +
Thumbs Up
+
+
+ +
Thumbs Down
+
+
+
${satRate}
+
Satisfaction Rate
+
+
+
${responseTotal.toLocaleString()}
+
Responses Rated
+
+
+
${generalTotal.toLocaleString()}
+
General Feedback
+
+
+
${commentTotal.toLocaleString()}
+
Comments
+
+
`; + + const safeCommunity = encodeURIComponent(communityId); + const toolbarHtml = ` + `; + + const entries = Array.isArray(data.entries) ? data.entries : []; + let tableHtml; + if (entries.length === 0) { + tableHtml = ''; + } else { + const rows = entries.map(e => { + const time = escapeHtml(formatFeedbackTime(e.timestamp)); + const type = escapeHtml(e.feedback_type || '—'); + let sentiment = ''; + if (e.sentiment === 'up') sentiment = ''; + else if (e.sentiment === 'down') sentiment = ''; + const comment = e.comment ? escapeHtml(e.comment) : '—'; + // Defense in depth: only render a clickable link for http(s) URLs + // (the backend already enforces this on write). + const isHttpUrl = typeof e.page_url === 'string' && /^https?:\/\//i.test(e.page_url); + const page = isHttpUrl + ? `link` + : ''; + return ` + ${time} + ${type} + ${sentiment} + ${comment} + ${page} + `; + }).join(''); + tableHtml = ` + `; + } + + container.innerHTML = summaryHtml + toolbarHtml + tableHtml; + } + function renderAdminCharts(data) { if (adminTokenChartInstance) adminTokenChartInstance.destroy(); const tokenCanvas = document.getElementById('adminTokenChart'); diff --git a/frontend/index.html b/frontend/index.html index 3f76f44..da81881 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -391,6 +391,17 @@

Integration

}); </script>

The widget auto-configures the API endpoint based on the communityId.

+
+

Security-sensitive environments (supply-chain policies, SRI required): pin to a specific release using a versioned jsDelivr URL. The integrity hash for each release is published in the GitHub releases.

+
<!-- Replace vX.Y.Z and the integrity hash from the GitHub release notes -->
+<script src="https://cdn.jsdelivr.net/gh/OpenScience-Collective/osa@vX.Y.Z/frontend/osa-chat-widget.js"
+        integrity="sha384-..."
+        crossorigin="anonymous"
+        defer></script>
+<script>
+  OSAChatWidget.setConfig({ communityId: 'hed' });
+</script>
+

Note: you must update the version tag manually when upgrading.

@@ -479,6 +490,17 @@

Add to Your Site

// widgetInstructions: 'Focus on topics relevant to this page.' }); </script> +
+

Security-sensitive environments (supply-chain policies, SRI required): use a versioned URL pinned to a specific release. The integrity hash for each release is published in the GitHub releases.

+
<!-- Replace vX.Y.Z and the integrity hash from the GitHub release notes -->
+<script src="https://cdn.jsdelivr.net/gh/OpenScience-Collective/osa@vX.Y.Z/frontend/osa-chat-widget.js"
+        integrity="sha384-..."
+        crossorigin="anonymous"
+        defer></script>
+<script>
+  OSAChatWidget.setConfig({ communityId: '${communityId}' });
+</script>
+

Note: you must update the version tag manually when upgrading.

diff --git a/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. + +
+
+ + +
`; @@ -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} + +
`; + } + } + 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"