Compare commits
220 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b7c308188 | |||
| 40ac52654f | |||
| 034e405824 | |||
| 20404cf3f2 | |||
| 264bb5475c | |||
| 6e3f9f6e79 | |||
| 9d0a993c2a | |||
| cd3e60ba4f | |||
| 360299f5f6 | |||
| d61e33113c | |||
| 5faf7cf45f | |||
| cd922fa750 | |||
| a4d4c386f7 | |||
| c8da826ef7 | |||
| 5166c2c4d7 | |||
| ec70e70a5d | |||
| 4a79b37714 | |||
| 76ae1c3603 | |||
| a60b88b80e | |||
| e31b4433a1 | |||
| 19183ad14a | |||
| e1412320a7 | |||
| b9c94dfab0 | |||
| 6f43c09bd0 | |||
| 9e15e95c2b | |||
| 1306c4cc9c | |||
| f1247817d3 | |||
| fdad5b85c9 | |||
| 39ee0b5973 | |||
| 33675c8ae8 | |||
| 90d5e9887a | |||
| c3af591810 | |||
| bb8a6200aa | |||
| 44573366eb | |||
| edb0af2bda | |||
| 7d5bb54b64 | |||
| a18c63792a | |||
| 0b58707a49 | |||
| 0561b55af5 | |||
| d785ed9054 | |||
| 88fb8417fd | |||
| f70d743c8b | |||
| 251b8a10c0 | |||
| 3f06e2ee77 | |||
| 7f11c793ef | |||
| e28dcbff9a | |||
| 89ec0186a4 | |||
| 6e1efde8c6 | |||
| 6aa80d4210 | |||
| 4e86006b3f | |||
| 679e22a7c2 | |||
| 4d3228a4a8 | |||
| 0aa307f0b6 | |||
| 6a69ecefb1 | |||
| c05beb66e9 | |||
| 34ddb24014 | |||
| 9d69613df7 | |||
| 630f818538 | |||
| b280a720ff | |||
| 48bac9c212 | |||
| e88c49fb50 | |||
| 9e10a5a400 | |||
| 1dbea24fa2 | |||
| 0606228b40 | |||
| f35b9f0988 | |||
| c400c46672 | |||
| fbdeb2161d | |||
| 8c7d03dd29 | |||
| 135ce7b2df | |||
| 0e47ae051b | |||
| 04255473d2 | |||
| ce6bbff389 | |||
| 92c4bf36f6 | |||
| 0bedbf1877 | |||
| a5cb6e1242 | |||
| a33f6a2f15 | |||
| d79e9090e6 | |||
| 97fd660e38 | |||
| 96e168d035 | |||
| 4d2b77ecaf | |||
| e48da80a4b | |||
| 6125312f61 | |||
| 007fd0c2e3 | |||
| c4f90d6a57 | |||
| 5dd62c9466 | |||
| 4d072d7217 | |||
| b4242b1394 | |||
| fa2343dff9 | |||
| 1b1667bc2b | |||
| c2b4bf9c67 | |||
| 0845fefe6c | |||
| d911556a84 | |||
| 38be8d9401 | |||
| 9f3190f62a | |||
| 41aeb7e0f2 | |||
| f8e67519e1 | |||
| 4279dcba1e | |||
| be7e3d6b56 | |||
| 41e128190b | |||
| ba869ccde5 | |||
| 27fe066b23 | |||
| e94b8ff714 | |||
| e3a6894904 | |||
| 92b97bda00 | |||
| d5c6039296 | |||
| 3fa13c8bfd | |||
| 9d306b71fa | |||
| 38a936c120 | |||
| 86d13a7240 | |||
| 0b2d449ffa | |||
| d881373dce | |||
| 9ade4c65f3 | |||
| 5c73b85f65 | |||
| f5764c01fc | |||
| 8c7c2a4407 | |||
| 978de5e9a4 | |||
| 4e9859117c | |||
| a134a0fc08 | |||
| 6df58af0c3 | |||
| 852606ec8b | |||
| caae6922be | |||
| fafeaf3d83 | |||
| 2ab8dad6a5 | |||
| 50216accde | |||
| bf2fdac2d0 | |||
| 626c4bf562 | |||
| a56b3f3d51 | |||
| 2896fa1dc9 | |||
| 04251401aa | |||
| e86b6e83ae | |||
| 6f5e75da15 | |||
| b2742aab80 | |||
| 208365cd3d | |||
| 26f679d86e | |||
| cf39a15db1 | |||
| 1f3c35f162 | |||
| 2bccc3dad9 | |||
| 959cb8b21a | |||
| f8a2410a0a | |||
| 03b984d5a7 | |||
| 57db18c6a3 | |||
| ea79e94842 | |||
| b0612cfa0f | |||
| 4e61d73da5 | |||
| 3b41776110 | |||
| 3e3d38696c | |||
| 7b22e5be0f | |||
| 39fba49cfe | |||
| 706a15f0bc | |||
| b8dc413b73 | |||
| 8d29ce0122 | |||
| a272e7cbab | |||
| ce55b239e2 | |||
| 432ab73741 | |||
| f93d650992 | |||
| f9da19d1a1 | |||
| d2b6a26fe4 | |||
| 482ef89a73 | |||
| 34fd17ba55 | |||
| 8baa07db84 | |||
| ba8a53803a | |||
| 31fade9730 | |||
| fffe483c02 | |||
| 8c79993280 | |||
| 8a0672a6be | |||
| 395f798ee2 | |||
| debff75221 | |||
| 4bf0a6c22e | |||
| fb025821cb | |||
| ff880fd4c9 | |||
| 03495d901d | |||
| 798958f20a | |||
| 699295c5be | |||
| a62a007c87 | |||
| d4fc1de80d | |||
| 0902b5653f | |||
| 0b6a02075c | |||
| 7880a8de30 | |||
| 2abedd6b4b | |||
| 5a251a99e6 | |||
| 25ef33de7f | |||
| ec2c274cd9 | |||
| 47f0b3db9a | |||
| 233de3508f | |||
| 13b2d0048c | |||
| 944dd760ca | |||
| d67aa6ae5c | |||
| f1a5fac1b9 | |||
| d0691d5aa0 | |||
| f1610bbd2e | |||
| 327d843f64 | |||
| b8010270c1 | |||
| 0f24bdb17a | |||
| bf11f16e2f | |||
| bf05ff8d6e | |||
| c4ce28f05d | |||
| 9b2a06964b | |||
| c126c3ec03 | |||
| 9bd02d7ef7 | |||
| e38a830f02 | |||
| 18b753c3c7 | |||
| b0735bae85 | |||
| 53689d076b | |||
| 0f7d6c0e33 | |||
| 16701fdb72 | |||
| 9db20a4d01 | |||
| 7ddf8370e6 | |||
| 98dff98e9c | |||
| 73e8012707 | |||
| c2fd87a5d3 | |||
| 441d94301e | |||
| b488d69939 | |||
| 3642faf32c | |||
| 3b1cd96722 | |||
| 219d064459 | |||
| d0ab8d071a | |||
| b792e9d9a3 | |||
| 4288814ff4 | |||
| f34a1c5677 | |||
| 6d48f90112 |
@@ -6,3 +6,4 @@
|
||||
|
||||
!nextcloud_mcp_server/**/*.py
|
||||
!nextcloud_mcp_server/**/*.html
|
||||
!nextcloud_mcp_server/auth/static/*
|
||||
|
||||
@@ -15,17 +15,17 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
|
||||
- name: Create bump and changelog
|
||||
uses: commitizen-tools/commitizen-action@9615e7be1cf341393c52e865ebbdaa0712176d81 # 0.25.0
|
||||
uses: commitizen-tools/commitizen-action@bb4f1df6601e2a1a891506581b0c53acdc88e07d # 0.26.0
|
||||
with:
|
||||
github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||
changelog_increment_filename: body.md
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||
with:
|
||||
body_path: "body.md"
|
||||
tag_name: v${{ env.REVISION }}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@f0c8eb29807907de7f5412d04afceb5e24817127 # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
prompt: |
|
||||
REPO: ${{ github.repository }}
|
||||
PR NUMBER: ${{ github.event.pull_request.number }}
|
||||
|
||||
Please review this pull request and provide feedback on:
|
||||
- Code quality and best practices
|
||||
- Potential bugs or issues
|
||||
- Performance considerations
|
||||
- Security concerns
|
||||
- Test coverage
|
||||
|
||||
Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
|
||||
|
||||
Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
|
||||
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
|
||||
claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@f0c8eb29807907de7f5412d04afceb5e24817127 # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||
|
||||
@@ -12,11 +12,11 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
name: RAG Evaluation
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
manual_path:
|
||||
description: 'Path to Nextcloud User Manual PDF in Nextcloud'
|
||||
required: false
|
||||
default: 'Nextcloud Manual.pdf'
|
||||
embedding_model:
|
||||
description: 'OpenAI embedding model'
|
||||
required: false
|
||||
default: 'openai/text-embedding-3-small'
|
||||
generation_model:
|
||||
description: 'OpenAI generation model'
|
||||
required: false
|
||||
default: 'openai/gpt-4o-mini'
|
||||
|
||||
jobs:
|
||||
rag-evaluation:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
models: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Run docker compose with vector sync
|
||||
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
|
||||
with:
|
||||
compose-file: |
|
||||
./docker-compose.yml
|
||||
./docker-compose.ci.yml
|
||||
up-flags: "--build"
|
||||
env:
|
||||
# Environment variables passed to docker-compose.ci.yml
|
||||
OPENAI_API_KEY: ${{ secrets.GITHUB_TOKEN }}
|
||||
OPENAI_BASE_URL: "https://models.github.ai/inference"
|
||||
OPENAI_EMBEDDING_MODEL: ${{ inputs.embedding_model }}
|
||||
OPENAI_GENERATION_MODEL: ${{ inputs.generation_model }}
|
||||
VECTOR_SYNC_SCAN_INTERVAL: "5"
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
|
||||
|
||||
- name: Wait for Nextcloud to be ready
|
||||
run: |
|
||||
echo "Waiting for Nextcloud..."
|
||||
max_attempts=60
|
||||
attempt=0
|
||||
until curl -o /dev/null -s -w "%{http_code}\n" http://localhost:8080/ocs/v2.php/apps/serverinfo/api/v1/info | grep -q "401"; do
|
||||
attempt=$((attempt + 1))
|
||||
if [ $attempt -ge $max_attempts ]; then
|
||||
echo "Service did not become ready in time."
|
||||
exit 1
|
||||
fi
|
||||
echo "Attempt $attempt/$max_attempts: Service not ready, sleeping for 5 seconds..."
|
||||
sleep 5
|
||||
done
|
||||
echo "Nextcloud is ready."
|
||||
|
||||
- name: Wait for MCP server to be ready
|
||||
run: |
|
||||
echo "Waiting for MCP server..."
|
||||
max_attempts=30
|
||||
attempt=0
|
||||
until curl -o /dev/null -s -w "%{http_code}\n" http://localhost:8000/health/live | grep -q "200"; do
|
||||
attempt=$((attempt + 1))
|
||||
if [ $attempt -ge $max_attempts ]; then
|
||||
echo "MCP server did not become ready in time."
|
||||
exit 1
|
||||
fi
|
||||
echo "Attempt $attempt/$max_attempts: MCP not ready, sleeping for 2 seconds..."
|
||||
sleep 2
|
||||
done
|
||||
echo "MCP server is ready."
|
||||
|
||||
- name: Run RAG evaluation tests
|
||||
env:
|
||||
NEXTCLOUD_HOST: "http://localhost:8080"
|
||||
NEXTCLOUD_USERNAME: "admin"
|
||||
NEXTCLOUD_PASSWORD: "admin"
|
||||
RAG_MANUAL_PATH: ${{ inputs.manual_path }}
|
||||
OPENAI_API_KEY: ${{ secrets.GITHUB_TOKEN }}
|
||||
OPENAI_BASE_URL: "https://models.github.ai/inference"
|
||||
OPENAI_EMBEDDING_MODEL: ${{ inputs.embedding_model }}
|
||||
OPENAI_GENERATION_MODEL: ${{ inputs.generation_model }}
|
||||
run: |
|
||||
uv run pytest tests/integration/test_rag.py -v --log-cli-level=INFO --provider openai
|
||||
|
||||
- name: Capture MCP container logs
|
||||
if: always()
|
||||
run: |
|
||||
echo "=== MCP Container Logs ==="
|
||||
docker compose logs mcp --tail=500
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
||||
with:
|
||||
name: rag-evaluation-results
|
||||
path: |
|
||||
pytest-results.xml
|
||||
retention-days: 30
|
||||
@@ -18,9 +18,9 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
|
||||
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
|
||||
- name: Install Python 3.11
|
||||
run: uv python install 3.11
|
||||
- name: Build
|
||||
|
||||
@@ -9,9 +9,9 @@ jobs:
|
||||
linting:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
|
||||
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
|
||||
- name: Check format
|
||||
run: |
|
||||
uv run --frozen ruff format --diff
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
submodules: 'true'
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
###### Required to build OIDC App ######
|
||||
|
||||
- name: Set up php 8.4
|
||||
uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # v2
|
||||
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2
|
||||
with:
|
||||
php-version: 8.4
|
||||
coverage: none
|
||||
@@ -49,14 +49,14 @@ jobs:
|
||||
|
||||
|
||||
- name: Run docker compose
|
||||
uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1
|
||||
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
|
||||
with:
|
||||
compose-file: "./docker-compose.yml"
|
||||
#compose-flags: "--profile qdrant"
|
||||
up-flags: "--build"
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
|
||||
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
|
||||
|
||||
- name: Install Playwright dependencies
|
||||
run: |
|
||||
|
||||
@@ -1,3 +1,204 @@
|
||||
## v0.52.0 (2025-12-13)
|
||||
|
||||
### Feat
|
||||
|
||||
- **vector**: add Deck card vector search with visualization support
|
||||
|
||||
## v0.51.0 (2025-12-13)
|
||||
|
||||
### Feat
|
||||
|
||||
- **vector-viz**: add news_item support for links and chunk expansion
|
||||
|
||||
## v0.50.2 (2025-12-13)
|
||||
|
||||
### Fix
|
||||
|
||||
- **news**: revert get_item() to use get_items() + filter
|
||||
|
||||
## v0.50.1 (2025-12-12)
|
||||
|
||||
### Fix
|
||||
|
||||
- Disable DNS rebinding protection for containerized deployments
|
||||
- **deps**: update dependency mcp to >=1.23,<1.24
|
||||
|
||||
## v0.50.0 (2025-12-11)
|
||||
|
||||
### Feat
|
||||
|
||||
- add MCP tool annotations for enhanced UX
|
||||
|
||||
### Fix
|
||||
|
||||
- address PR review feedback
|
||||
|
||||
## v0.49.2 (2025-12-09)
|
||||
|
||||
### Fix
|
||||
|
||||
- Update lockfile
|
||||
|
||||
## v0.49.1 (2025-12-09)
|
||||
|
||||
### Fix
|
||||
|
||||
- Revert mcp version <1.23
|
||||
|
||||
## v0.49.0 (2025-12-08)
|
||||
|
||||
### Feat
|
||||
|
||||
- **news**: add Nextcloud News app integration
|
||||
|
||||
### Fix
|
||||
|
||||
- resolve all type checking errors (8 errors fixed)
|
||||
|
||||
### Refactor
|
||||
|
||||
- **news**: simplify vector sync to fetch all items
|
||||
|
||||
### Perf
|
||||
|
||||
- **news**: use direct API endpoint for get_item()
|
||||
|
||||
## v0.48.6 (2025-12-03)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency mcp to >=1.23,<1.24
|
||||
|
||||
## v0.48.5 (2025-11-28)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency pillow to v12
|
||||
|
||||
## v0.48.4 (2025-11-23)
|
||||
|
||||
### Fix
|
||||
|
||||
- Add rate limit retry logic to OpenAI provider
|
||||
|
||||
## v0.48.3 (2025-11-23)
|
||||
|
||||
### Fix
|
||||
|
||||
- Increase MCP sampling timeout to 5 minutes for slower LLMs
|
||||
|
||||
## v0.48.2 (2025-11-23)
|
||||
|
||||
### Fix
|
||||
|
||||
- Share vector sync state with FastMCP session lifespan via module singleton
|
||||
- Share vector sync state with FastMCP session lifespan via module singleton
|
||||
|
||||
## v0.48.1 (2025-11-23)
|
||||
|
||||
### Fix
|
||||
|
||||
- Use WebDAV for tag creation and add LLM-as-a-judge for RAG tests
|
||||
|
||||
### Refactor
|
||||
|
||||
- Move background tasks to server lifespan and deprecate SSE transport
|
||||
|
||||
## v0.48.0 (2025-11-23)
|
||||
|
||||
### Feat
|
||||
|
||||
- Add tag management methods to WebDAV client
|
||||
|
||||
## v0.47.0 (2025-11-23)
|
||||
|
||||
### Feat
|
||||
|
||||
- Add OpenAI provider support for embeddings and generation
|
||||
|
||||
## v0.46.2 (2025-11-22)
|
||||
|
||||
### Fix
|
||||
|
||||
- **smithery**: Enable JSON response format for scanner compatibility
|
||||
|
||||
## v0.46.1 (2025-11-22)
|
||||
|
||||
### Perf
|
||||
|
||||
- Optimize vector viz search performance
|
||||
|
||||
## v0.46.0 (2025-11-22)
|
||||
|
||||
### Feat
|
||||
|
||||
- Add Smithery CLI deployment support
|
||||
- Implement ADR-016 Smithery stateless deployment mode
|
||||
|
||||
### Fix
|
||||
|
||||
- **smithery**: Add JSON Schema metadata to mcp-config endpoint
|
||||
- **smithery**: Use container runtime pattern for config discovery
|
||||
- Add Smithery lifespan and auth mode detection
|
||||
|
||||
## v0.45.0 (2025-11-22)
|
||||
|
||||
### Feat
|
||||
|
||||
- Add context expansion to semantic search with chunk overlap removal
|
||||
- Use Ollama native batch API in embed_batch()
|
||||
- Implement Qdrant placeholder state management
|
||||
- Switch files to use numeric IDs with file_path resolution
|
||||
- Implement per-chunk vector visualization with context expansion
|
||||
|
||||
### Fix
|
||||
|
||||
- Use alpha_composite for proper RGBA highlight blending
|
||||
- Remove pymupdf.layout.activate() to fix page_chunks behavior
|
||||
- Centralize PDF processing and generate separate images per chunk
|
||||
- Set is_placeholder=False in processor to fix search filtering
|
||||
- Increase placeholder staleness threshold to 5x scan interval
|
||||
- Add placeholder staleness check to prevent duplicate processing
|
||||
- Use empty SparseVector instead of None for placeholders
|
||||
- Return empty array instead of null for query_coords when no results
|
||||
- Align PDF text extraction between indexing and context expansion
|
||||
- Update models and viz to use int-only doc_id
|
||||
- Reconstruct full content for notes to match indexed offsets
|
||||
- Add async/await, PDF metadata, and type safety fixes
|
||||
|
||||
### Refactor
|
||||
|
||||
- Simplify PDF text extraction with single to_markdown call
|
||||
|
||||
### Perf
|
||||
|
||||
- Optimize PDF processing with parallel extraction and single-render highlights
|
||||
|
||||
## v0.44.1 (2025-11-21)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency mcp to >=1.22,<1.23
|
||||
|
||||
## v0.44.0 (2025-11-19)
|
||||
|
||||
### Feat
|
||||
|
||||
- Improve vector visualization with static assets and fixes
|
||||
- Redesign UI to match Nextcloud ecosystem aesthetic
|
||||
|
||||
### Fix
|
||||
|
||||
- Improve 3D plot rendering with explicit dimensions and window resize support
|
||||
- Preserve 3D plot camera and improve documentation
|
||||
- Preserve 3D plot camera position and fix CSS loading
|
||||
|
||||
## v0.43.0 (2025-11-18)
|
||||
|
||||
### Feat
|
||||
|
||||
- Replace custom document chunker with LangChain MarkdownTextSplitter
|
||||
|
||||
## v0.42.0 (2025-11-17)
|
||||
|
||||
### Feat
|
||||
|
||||
@@ -56,6 +56,68 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
- Pass-through (default): Simple, stateless (ENABLE_TOKEN_EXCHANGE=false)
|
||||
- Token exchange (opt-in): RFC 8693 delegation (ENABLE_TOKEN_EXCHANGE=true)
|
||||
|
||||
### MCP Tool Annotations (ADR-017)
|
||||
|
||||
**All tools MUST include annotations** following these patterns:
|
||||
|
||||
```python
|
||||
from mcp.types import ToolAnnotations
|
||||
|
||||
# Read-only tools (list, search, get)
|
||||
@mcp.tool(
|
||||
title="Human Readable Name",
|
||||
annotations=ToolAnnotations(
|
||||
readOnlyHint=True,
|
||||
openWorldHint=True, # Nextcloud is external to MCP server
|
||||
),
|
||||
)
|
||||
|
||||
# Create operations
|
||||
@mcp.tool(
|
||||
title="Create Resource",
|
||||
annotations=ToolAnnotations(
|
||||
idempotentHint=False, # Creates new resources each time
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
|
||||
# Update operations (with etag/version control)
|
||||
@mcp.tool(
|
||||
title="Update Resource",
|
||||
annotations=ToolAnnotations(
|
||||
idempotentHint=False, # ETag changes = different inputs
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
|
||||
# Delete operations
|
||||
@mcp.tool(
|
||||
title="Delete Resource",
|
||||
annotations=ToolAnnotations(
|
||||
destructiveHint=True, # Permanently deletes data
|
||||
idempotentHint=True, # Same end state if called repeatedly
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
|
||||
# HTTP PUT without version control (special case)
|
||||
@mcp.tool(
|
||||
title="Write File",
|
||||
annotations=ToolAnnotations(
|
||||
idempotentHint=True, # Same content = same end state
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
**Key Principles**:
|
||||
- **Idempotency**: Same inputs → same result. ETags change after updates, making them non-idempotent
|
||||
- **Destructive**: Operations that permanently delete/overwrite data
|
||||
- **Open World**: All Nextcloud tools access external service (openWorldHint=True)
|
||||
- **Titles**: Use human-readable names, not snake_case function names
|
||||
|
||||
**See**: `docs/ADR-017-mcp-tool-annotations.md` for detailed rationale and examples
|
||||
|
||||
### Project Structure
|
||||
- `nextcloud_mcp_server/client/` - HTTP clients for Nextcloud APIs
|
||||
- `nextcloud_mcp_server/server/` - MCP tool/resource definitions
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:d86b4c74b936c438cd4cc3a9f7256b9a7c27ad68c7caf8c205e18d9845af0164
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:fa48eefe2146644c2308b909d6bb7651a768178f84fc9550dcd495e4d6d84d01
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.10 /uv /uvx /bin/
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.17@sha256:5cb6b54d2bc3fe2eb9a8483db958a0b9eebf9edff68adedb369df8e7b98711a2 /uv /uvx /bin/
|
||||
|
||||
# Install dependencies
|
||||
# 1. git (required for caldav dependency from git)
|
||||
# 2. sqlite for development with token db
|
||||
RUN apt update && apt install --no-install-recommends --no-install-suggests -y \
|
||||
git \
|
||||
tesseract-ocr \
|
||||
sqlite3 && apt clean
|
||||
|
||||
WORKDIR /app
|
||||
@@ -17,5 +18,7 @@ RUN uv sync --locked --no-dev --no-editable --no-cache
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV VIRTUAL_ENV=/app/.venv
|
||||
ENV PATH=/app/.vnev/bin:$PATH
|
||||
ENV TESSDATA_PREFIX=/usr/share/tesseract-ocr/5/tessdata
|
||||
|
||||
ENTRYPOINT ["/app/.venv/bin/nextcloud-mcp-server", "--host", "0.0.0.0"]
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Dockerfile for Smithery stateless deployment
|
||||
# ADR-016: Stateless mode for multi-user public Nextcloud instances
|
||||
#
|
||||
# This image excludes:
|
||||
# - Vector database dependencies (qdrant-client)
|
||||
# - Background sync workers
|
||||
# - Admin UI routes (/app)
|
||||
# - Semantic search tools
|
||||
#
|
||||
# Features included:
|
||||
# - Core Nextcloud tools (notes, calendar, contacts, files, deck, tables, cookbook)
|
||||
# - Per-session app password authentication
|
||||
# - Multi-user support via Smithery session config
|
||||
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:fa48eefe2146644c2308b909d6bb7651a768178f84fc9550dcd495e4d6d84d01
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install uv for fast dependency management
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.17@sha256:5cb6b54d2bc3fe2eb9a8483db958a0b9eebf9edff68adedb369df8e7b98711a2 /uv /uvx /bin/
|
||||
|
||||
# Install dependencies
|
||||
# 1. git (required for caldav dependency from git)
|
||||
# 2. sqlite for development with token db
|
||||
RUN apt update && apt install --no-install-recommends --no-install-suggests -y \
|
||||
git
|
||||
|
||||
# Copy project files
|
||||
COPY . .
|
||||
|
||||
RUN uv sync --locked --no-dev --no-editable --no-cache
|
||||
|
||||
# Set Smithery mode environment variables
|
||||
ENV SMITHERY_DEPLOYMENT=true
|
||||
ENV VECTOR_SYNC_ENABLED=false
|
||||
|
||||
# Smithery sets PORT=8081 by default
|
||||
EXPOSE 8081
|
||||
|
||||
# Health check endpoint
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD uv run python -c "import httpx; httpx.get('http://localhost:${PORT:-8081}/health/live').raise_for_status()"
|
||||
|
||||
CMD ["/app/.venv/bin/smithery-main"]
|
||||
@@ -1,6 +1,11 @@
|
||||
<p align="center">
|
||||
<img src="astrolabe.svg" alt="Nextcloud MCP Server" width="128" height="128">
|
||||
</p>
|
||||
|
||||
# Nextcloud MCP Server
|
||||
|
||||
[](https://github.com/cbcoutinho/nextcloud-mcp-server/pkgs/container/nextcloud-mcp-server)
|
||||
[](https://smithery.ai/server/@cbcoutinho/nextcloud-mcp-server)
|
||||
|
||||
**A production-ready MCP server that connects AI assistants to your Nextcloud instance.**
|
||||
|
||||
@@ -13,7 +18,20 @@ This is a **dedicated standalone MCP server** designed for external MCP clients
|
||||
|
||||
## Quick Start
|
||||
|
||||
Get up and running in 60 seconds using Docker:
|
||||
The fastest way to get started is via [Smithery](https://smithery.ai/server/@cbcoutinho/nextcloud-mcp-server) - no Docker or self-hosting required:
|
||||
|
||||
1. Visit the [Smithery marketplace page](https://smithery.ai/server/@cbcoutinho/nextcloud-mcp-server)
|
||||
2. Click "Deploy" and configure:
|
||||
- **Nextcloud URL**: Your Nextcloud instance (e.g., `https://cloud.example.com`)
|
||||
- **Username**: Your Nextcloud username
|
||||
- **App Password**: Generate one in Nextcloud → Settings → Security → Devices & sessions
|
||||
|
||||
> [!NOTE]
|
||||
> Smithery runs in stateless mode without semantic search. For full features, use [Docker](#docker-self-hosted) or see [ADR-016](docs/ADR-016-smithery-stateless-deployment.md).
|
||||
|
||||
## Docker (Self-Hosted)
|
||||
|
||||
For full features including semantic search, run with Docker:
|
||||
|
||||
```bash
|
||||
# 1. Create a minimal configuration
|
||||
@@ -29,10 +47,15 @@ docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||
|
||||
# 3. Test the connection
|
||||
curl http://127.0.0.1:8000/health/ready
|
||||
|
||||
# 4. Connect to the endpoint
|
||||
http://127.0.0.1:8000/sse
|
||||
|
||||
# Or with --transport streamable-http
|
||||
http://127.0.0.1:8000/mcp
|
||||
```
|
||||
|
||||
**Next Steps:**
|
||||
- Create an app password in Nextcloud: Settings → Security → Devices & sessions
|
||||
- Connect your MCP client (Claude Desktop, IDEs, `mcp dev`, etc.)
|
||||
- See [docs/installation.md](docs/installation.md) for other deployment options (local, Kubernetes)
|
||||
|
||||
@@ -40,7 +63,7 @@ curl http://127.0.0.1:8000/health/ready
|
||||
|
||||
- **90+ MCP Tools** - Comprehensive API coverage across 8 Nextcloud apps
|
||||
- **MCP Resources** - Structured data URIs for browsing Nextcloud data
|
||||
- **Semantic Search (Experimental)** - Optional vector-powered search for Notes (requires Qdrant + Ollama)
|
||||
- **Semantic Search (Experimental)** - Optional vector-powered search for Notes, Files, News items, and Deck cards (requires Qdrant + Ollama)
|
||||
- **Document Processing** - OCR and text extraction from PDFs, DOCX, images with progress notifications
|
||||
- **Flexible Deployment** - Docker, Kubernetes (Helm), VM, or local installation
|
||||
- **Production-Ready Auth** - Basic Auth with app passwords (recommended) or OAuth2/OIDC (experimental)
|
||||
@@ -58,7 +81,7 @@ curl http://127.0.0.1:8000/health/ready
|
||||
| **Cookbook** | 13 | Recipe management, URL import (schema.org) |
|
||||
| **Tables** | 5 | Row operations on Nextcloud Tables |
|
||||
| **Sharing** | 10+ | Create and manage shares |
|
||||
| **Semantic Search** | 2+ | Vector search for Notes (experimental, opt-in, requires infrastructure) |
|
||||
| **Semantic Search** | 2+ | Vector search for Notes, Files, News items, and Deck cards (experimental, opt-in, requires infrastructure) |
|
||||
|
||||
Want to see another Nextcloud app supported? [Open an issue](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) or contribute a pull request!
|
||||
|
||||
@@ -122,7 +145,8 @@ This enables natural language queries and helps discover related content across
|
||||
### Features
|
||||
- **[App Documentation](docs/)** - Notes, Calendar, Contacts, WebDAV, Deck, Cookbook, Tables
|
||||
- **[Document Processing](docs/configuration.md#document-processing)** - OCR and text extraction setup
|
||||
- **[Semantic Search Architecture](docs/semantic-search-architecture.md)** - Experimental vector search (Notes only, opt-in)
|
||||
- **[Semantic Search Architecture](docs/semantic-search-architecture.md)** - Experimental vector search (Notes, Files, News items, Deck cards; opt-in)
|
||||
- **[Vector Sync UI Guide](docs/user-guide/vector-sync-ui.md)** - Browser interface for semantic search visualization and testing
|
||||
|
||||
### Advanced Topics
|
||||
- **[OAuth Architecture](docs/oauth-architecture.md)** - How OAuth works (experimental)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euox pipefail
|
||||
|
||||
php /var/www/html/occ app:enable news
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="80" ry="80" fill="#0082C9"/>
|
||||
<path d="M255.9 21.04c-11.8 0-22.2 4.08-28.6 10.01-5.6 4.98-8.6 11.41-8.6 18.11 0 5.55 2.2 11.01 5.9 15.48-16.4 4.97-30.1 13.64-39 24.53 22.1-7.67 45.7-11.86 70.3-11.86 24.6 0 48.3 4.19 70.3 11.86-8.9-10.89-22.6-19.56-39-24.53 3.9-4.47 5.9-9.93 5.9-15.48 0-6.7-3-13.13-8.5-18.11-6.4-5.93-16.9-10.01-28.7-10.01zm0 20.34c5.3 0 10.1 1.27 13.6 3.52 1.7 1.16 3.4 2.43 3.4 4.27 0 1.76-1.7 3.03-3.4 4.19-3.5 2.33-8.3 3.61-13.6 3.61-5.3 0-10.1-1.28-13.6-3.61-1.6-1.16-3.3-2.43-3.3-4.19 0-1.84 1.7-3.11 3.3-4.27 3.5-2.25 8.3-3.52 13.6-3.52zm.1 48.1c-110.8 0-200.72 90.02-200.72 200.82S145.2 491 256 491s200.7-89.9 200.7-200.7c0-110.8-89.9-200.82-200.7-200.82zm0 32.62c92.9 0 168.2 75.3 168.2 168.2 0 92.8-75.3 168.2-168.2 168.2-92.9 0-168.26-75.4-168.26-168.2 0-92.9 75.36-168.2 168.26-168.2zm-8.2 6.3c-9.6.5-19 1.9-28.3 4.1l2.3 7.8c8.4-2 17.1-3.3 26-3.8v-8.1zm16.2 0v8.1c9 .5 17.7 1.8 26 3.8l2.2-7.8c-9.1-2.2-18.6-3.6-28.2-4.1zm-60 8.5c-9 3.2-17.6 7-25.8 11.6l4.1 7.1c7.7-4.3 15.6-7.9 23.9-10.8l-2.2-7.9zm103.7 0-2 7.9c8.4 2.9 16.2 6.5 23.8 10.8l4.2-7.1c-8.2-4.6-16.9-8.4-26-11.6zm-143.3 20.3c-7.5 5.4-14.6 11.4-21.1 17.9l5.8 5.8c5.9-6.1 12.5-11.7 19.5-16.6l-4.2-7.1zm182.9 0-4 7.1c6.9 4.9 13.5 10.5 19.5 16.6l5.7-5.8c-6.5-6.5-13.7-12.5-21.2-17.9zm-91.4 11.5c-37 0-67.4 28.6-70.3 64.9l15.9 4.7c.7-29.6 24.7-53.4 54.4-53.4 30.1 0 54.4 24.4 54.4 54.3 0 15-6.2 28.7-16 38.5l.1.1c1.7 2.7 3 5.6 4.1 8.6.9 3 1.7 5.7 2.3 8.6v.4c33.8-16.7 57.2-51.5 57.2-91.7 0-3.8-.2-7.3-.6-10.9-3.2-3.3-6.3-6.4-9.8-9.5 1.5 6.5 2.3 13.4 2.3 20.4 0 28.7-13 54.7-33.5 71.8 6.3-10.6 10.1-23 10.1-36.3 0-38.9-31.7-70.5-70.6-70.5zm-91.8 14.6c-3.3 3.1-6.5 6.2-9.7 9.5-.3 3.6-.5 7.1-.5 10.9 0 7.3.7 14.2 2.1 20.9l9.1 2.7c-2.1-7.5-3.1-15.4-3.1-23.6 0-7 .7-13.9 2.1-20.4zm-31.6 4c-5.8 7.1-10.9 14.6-15.4 22.6l7.1 4c4.1-7.4 8.8-14.3 14-20.8l-5.7-5.8zm246.8 0-5.7 5.8c5.3 6.5 10 13.4 13.9 20.8l7.1-4c-4.4-8-9.5-15.5-15.3-22.6zm-269.2 37.1c-2.5 5.7-4.6 11.4-6.4 17.6l.1-.3c3.4-5 7.9-9.3 12.9-12.5l.3-.6-6.9-4.2zm291.8 0-7.2 4.2c3.2 7.3 5.7 15.1 7.6 23.1l7.9-2.1c-2.1-8.8-4.9-17.3-8.3-25.2zm-261.2 11.5c-13.4.1-25.7 9-29.7 22.5l114.8 34.2c-4.9 16.7 4.6 34.2 21.2 39.2L361.7 366c16.6 5 34.1-4.4 39.1-21l-114.6-34.4c4.9-16.5-4.7-34.1-21.3-39.1 0 0-72.4-21.5-114.8-34.3-3.1-.9-6.3-1.4-9.4-1.3zm-42.09 29.7c-.9 6.9-1.4 14-1.4 21.3 0 1.3.1 2.9.1 4.2h8.09v-4.2c0-6.5.4-12.9 1.2-19.2l-7.99-2.1zm314.59 0-7.9 2.1c.7 6.3 1.3 12.7 1.3 19.2 0 1.3 0 2.9-.2 4.2h8.2v-4.2c0-7.3-.5-14.4-1.4-21.3zm-157.3 24.7c6.3 0 11.5 5 11.5 11.3 0 6.4-5.2 11.6-11.5 11.6s-11.5-5.2-11.5-11.6c0-6.3 5.2-11.3 11.5-11.3zM98.51 307.4c1 8.2 2.89 16.4 5.09 24.3l7.9-2.1c-2.1-7.2-3.8-14.6-4.8-22.2h-8.19zm306.69 0c-1.1 7.6-2.7 15-4.8 22.2l7.8 2.1c2.2-7.9 4.1-16.1 5.2-24.3h-8.2zm-191.3 10.9c-19 13.3-31.4 35.3-31.4 60.1 0 10.4 2.3 20.4 6.2 29.7 8.8 4.9 17.9 8.8 27.6 11.7-10.8-10.7-17.5-25.2-17.5-41.4 0-19 9.3-36 23.7-46.3-3.8-4.1-6.7-8.7-8.6-13.8zM116.8 345l-7.9 2c3.1 7.6 6.8 14.7 11 21.6l6.9-4.2c-3.8-6.2-7-12.8-10-19.4zm194.8 20.5c.9 4.1 1.4 8.5 1.4 12.9 0 16.2-6.7 30.7-17.4 41.4 9.6-2.9 18.8-6.8 27.5-11.7 4-9.3 6.2-19.3 6.2-29.7 0-2.7-.2-5.2-.4-7.7l-17.3-5.2zM136 377.9l-7.1 4.1c4.7 6.2 9.7 12.1 15.3 17.3l5.7-5.5c-5.1-5-9.7-10.3-13.9-15.9zm243.9 2.3-.2.1c-2.1.3-4 .6-6.2.7h-.1c-3.6 4.5-7.3 8.8-11.5 12.8l5.8 5.5c5.5-5.2 10.5-11.1 15.2-17.3l-3-1.8zm-217.8 24-5.9 5.9c6 4.8 12.2 9.7 18.8 13.6l3.8-7.8c-5.7-2.9-11.4-6.8-16.7-11.7zm187.7 0c-5.4 4.9-11.1 8.8-16.8 11.7l3.9 7.8c6.5-3.9 12.8-8.8 18.7-13.6l-5.8-5.9zm-156.4 19.5-4.1 6.8c6.6 4 13.7 5.8 20.7 8.8l2.2-7.9c-6.5-1.9-12.7-4.8-18.8-7.7zm125.2 0c-6.2 2.9-12.5 5.8-19.1 7.7l2.3 7.9c7.2-3 14-4.8 20.7-8.8l-3.9-6.8zm-90.7 11.7-2 7.8c7.1 1 14.5 1.9 21.9 1.9v-7.7c-6.8 0-13.5-1.1-19.9-2zm55.9 0c-6.3.9-13 2-19.8 2v7.7c7.5 0 14.8-.9 22.1-1.9l-2.3-7.8z" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -1,9 +1,9 @@
|
||||
dependencies:
|
||||
- name: qdrant
|
||||
repository: https://qdrant.github.io/qdrant-helm
|
||||
version: 1.16.0
|
||||
version: 1.16.2
|
||||
- name: ollama
|
||||
repository: https://otwld.github.io/ollama-helm
|
||||
version: 1.34.0
|
||||
digest: sha256:9dfb8d6e3d5488f669d4c37f3a766213b598ff3de2aead2c734789736c7835b4
|
||||
generated: "2025-11-17T17:08:48.055530019Z"
|
||||
version: 1.35.0
|
||||
digest: sha256:bcb0779739e4710b90bb65f6a7baeaa295bd0ba9776f8a1cf8d9b69d233c8ec0
|
||||
generated: "2025-12-05T11:11:27.999374001Z"
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: nextcloud-mcp-server
|
||||
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
||||
type: application
|
||||
version: 0.42.0
|
||||
appVersion: "0.42.0"
|
||||
version: 0.52.0
|
||||
appVersion: "0.52.0"
|
||||
keywords:
|
||||
- nextcloud
|
||||
- mcp
|
||||
@@ -27,10 +27,10 @@ annotations:
|
||||
grafana_dashboard_folder: "Nextcloud MCP"
|
||||
dependencies:
|
||||
- name: qdrant
|
||||
version: "1.16.0"
|
||||
version: "1.16.2"
|
||||
repository: https://qdrant.github.io/qdrant-helm
|
||||
condition: qdrant.networkMode.deploySubchart
|
||||
- name: ollama
|
||||
version: "1.34.0"
|
||||
version: "1.35.0"
|
||||
repository: https://otwld.github.io/ollama-helm
|
||||
condition: ollama.enabled
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# CI-specific overrides for RAG evaluation pipeline
|
||||
# This file is used by the rag-evaluation.yml workflow to configure the MCP
|
||||
# container with OpenAI/GitHub Models API for vector embeddings.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.ci.yml up
|
||||
#
|
||||
# Environment variables (set in CI workflow):
|
||||
# OPENAI_API_KEY - API key for embeddings (GitHub Models uses GITHUB_TOKEN)
|
||||
# OPENAI_BASE_URL - API endpoint (e.g., https://models.github.ai/inference)
|
||||
# OPENAI_EMBEDDING_MODEL - Model name (e.g., openai/text-embedding-3-small)
|
||||
# OPENAI_GENERATION_MODEL - Model name for generation (e.g., openai/gpt-4o-mini)
|
||||
|
||||
services:
|
||||
mcp:
|
||||
environment:
|
||||
# OpenAI provider configuration (required for CI vector sync)
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
- OPENAI_BASE_URL=${OPENAI_BASE_URL:-https://models.github.ai/inference}
|
||||
- OPENAI_EMBEDDING_MODEL=${OPENAI_EMBEDDING_MODEL:-openai/text-embedding-3-small}
|
||||
- OPENAI_GENERATION_MODEL=${OPENAI_GENERATION_MODEL:-openai/gpt-4o-mini}
|
||||
# Faster sync for CI
|
||||
- VECTOR_SYNC_SCAN_INTERVAL=${VECTOR_SYNC_SCAN_INTERVAL:-5}
|
||||
# Enable document processing for PDF parsing
|
||||
- ENABLE_DOCUMENT_PROCESSING=true
|
||||
@@ -3,7 +3,7 @@ services:
|
||||
# https://hub.docker.com/_/mariadb
|
||||
db:
|
||||
# Note: Check the recommend version here: https://docs.nextcloud.com/server/latest/admin_manual/installation/system_requirements.html#server
|
||||
image: docker.io/library/mariadb:lts@sha256:6b848cb24fbbd87429917f6c4422ac53c343e85692eb0fef86553e99e4f422f3
|
||||
image: docker.io/library/mariadb:lts@sha256:1cac8492bd78b1ec693238dc600be173397efd7b55eabc725abc281dc855b482
|
||||
restart: always
|
||||
command: --transaction-isolation=READ-COMMITTED
|
||||
volumes:
|
||||
@@ -17,11 +17,11 @@ services:
|
||||
# Note: Redis is an external service. You can find more information about the configuration here:
|
||||
# https://hub.docker.com/_/redis
|
||||
redis:
|
||||
image: docker.io/library/redis:alpine@sha256:28c9c4d7596949a24b183eaaab6455f8e5d55ecbf72d02ff5e2c17fe72671d31
|
||||
image: docker.io/library/redis:alpine@sha256:6cbef353e480a8a6e7f10ec545f13d7d3fa85a212cdcc5ffaf5a1c818b9d3798
|
||||
restart: always
|
||||
|
||||
app:
|
||||
image: docker.io/library/nextcloud:32.0.1@sha256:5b043f7ea2f609d5ff5635f475c30d303bec17775a5c3f7fa435e3818e669120
|
||||
image: docker.io/library/nextcloud:32.0.3@sha256:54993ed39dc77f7a6ade142b1625972cb7a9393074325373402d47231314afbb
|
||||
restart: always
|
||||
ports:
|
||||
- 0.0.0.0:8080:80
|
||||
@@ -34,7 +34,7 @@ services:
|
||||
- ./app-hooks:/docker-entrypoint-hooks.d:ro
|
||||
# Mount OIDC development directory outside /var/www/html to avoid rsync conflicts
|
||||
# The post-installation hook will register /opt/apps as an additional app directory
|
||||
- ./third_party:/opt/apps:ro
|
||||
#- ./third_party:/opt/apps:ro
|
||||
environment:
|
||||
- NEXTCLOUD_TRUSTED_DOMAINS=app
|
||||
- NEXTCLOUD_ADMIN_USER=admin
|
||||
@@ -51,7 +51,7 @@ services:
|
||||
retries: 30
|
||||
|
||||
recipes:
|
||||
image: docker.io/library/nginx:alpine@sha256:b3c656d55d7ad751196f21b7fd2e8d4da9cb430e32f646adcf92441b72f82b14
|
||||
image: docker.io/library/nginx:alpine@sha256:289decab414250121a93c3f1b8316b9c69906de3a4993757c424cb964169ad42
|
||||
restart: always
|
||||
volumes:
|
||||
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
|
||||
@@ -158,7 +158,7 @@ services:
|
||||
- oauth-tokens:/app/data
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.4.5@sha256:653852bfdea2be6e958b9e90a976eff1c6de34edd55f2f679bdc48ef16bc528e
|
||||
image: quay.io/keycloak/keycloak:26.4.7@sha256:9409c59bdfb65dbffa20b11e6f18b8abb9281d480c7ca402f51ed3d5977e6007
|
||||
command:
|
||||
- "start-dev"
|
||||
- "--import-realm"
|
||||
@@ -224,8 +224,28 @@ services:
|
||||
- keycloak-tokens:/app/data
|
||||
- keycloak-oauth-storage:/app/.oauth
|
||||
|
||||
# Smithery stateless deployment mode (ADR-016)
|
||||
# Test with: docker compose --profile smithery up smithery
|
||||
# Then: curl http://localhost:8081/.well-known/mcp-config
|
||||
smithery:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.smithery
|
||||
restart: always
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- 127.0.0.1:8081:8081
|
||||
environment:
|
||||
- SMITHERY_DEPLOYMENT=true
|
||||
- VECTOR_SYNC_ENABLED=false
|
||||
- PORT=8081
|
||||
profiles:
|
||||
- smithery
|
||||
|
||||
qdrant:
|
||||
image: qdrant/qdrant:v1.16.0@sha256:1005201498cf927d835383d0f918b17d8c9da7db58550f169f694455e42d78f4
|
||||
image: qdrant/qdrant:v1.16.2@sha256:dab6de32f7b2cc599985a7c764db3e8b062f70508fb85ca074aa856f829bf335
|
||||
restart: always
|
||||
ports:
|
||||
- 127.0.0.1:6333:6333 # REST API
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
# ADR-016: Smithery Stateless Deployment for Multi-User Public Nextcloud Instances
|
||||
|
||||
**Status:** Proposed
|
||||
**Date:** 2025-01-22
|
||||
**Deciders:** Development Team
|
||||
**Related:** ADR-004 (OAuth), ADR-007 (Background Vector Sync), ADR-015 (Unified Provider)
|
||||
|
||||
## Context
|
||||
|
||||
[Smithery](https://smithery.ai) is a hosting platform and marketplace for MCP servers that provides:
|
||||
|
||||
- **Discovery**: Marketplace listing for MCP servers
|
||||
- **Hosting**: Containerized deployment with auto-scaling
|
||||
- **Authentication UI**: OAuth flow presentation for users
|
||||
- **Session Configuration**: Per-user settings passed via URL parameters
|
||||
- **Observability**: Usage logs and monitoring
|
||||
|
||||
### Current Architecture Limitations
|
||||
|
||||
The current nextcloud-mcp-server architecture assumes a **self-hosted deployment** with:
|
||||
|
||||
1. **Persistent Infrastructure**
|
||||
- Qdrant vector database for semantic search
|
||||
- Background sync worker for content indexing
|
||||
- Refresh token storage for offline access
|
||||
|
||||
2. **Single-Tenant Configuration**
|
||||
- Environment variables configure one Nextcloud instance
|
||||
- `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD`
|
||||
- Or OAuth with a single IdP
|
||||
|
||||
3. **Stateful Operations**
|
||||
- Vector sync maintains index state across requests
|
||||
- Token storage persists between sessions
|
||||
|
||||
### Smithery Hosting Constraints
|
||||
|
||||
Smithery-hosted containers are **stateless by design**:
|
||||
|
||||
- No persistent storage between requests
|
||||
- No background workers or cron jobs
|
||||
- No databases (Qdrant, Redis, etc.)
|
||||
- Containers may be recycled at any time
|
||||
- Configuration passed per-session via URL parameters
|
||||
|
||||
### Opportunity
|
||||
|
||||
Many users have **publicly accessible Nextcloud instances** and want to:
|
||||
|
||||
1. Try the MCP server without self-hosting infrastructure
|
||||
2. Connect multiple users to different Nextcloud instances
|
||||
3. Use basic Nextcloud tools without semantic search
|
||||
4. Benefit from Smithery's discovery and OAuth UI
|
||||
|
||||
## Decision
|
||||
|
||||
Implement a **stateless deployment mode** for Smithery that:
|
||||
|
||||
1. **Disables stateful features** (vector sync, semantic search)
|
||||
2. **Creates clients per-session** from Smithery configuration
|
||||
3. **Supports multiple Nextcloud instances** via session config
|
||||
4. **Provides a useful subset of tools** that work without infrastructure
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Smithery-Hosted Stateless Mode │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ MCP Client Smithery │
|
||||
│ (Cursor, Claude) Infrastructure │
|
||||
│ │ │ │
|
||||
│ │ 1. Connect │ │
|
||||
│ ├───────────────────────────►│ │
|
||||
│ │ │ │
|
||||
│ │ 2. Config UI │ │
|
||||
│ │◄───────────────────────────┤ User enters: │
|
||||
│ │ (Smithery presents) │ - nextcloud_url │
|
||||
│ │ │ - auth_mode (basic/oauth) │
|
||||
│ │ │ - credentials │
|
||||
│ │ 3. Tool call │ │
|
||||
│ ├───────────────────────────►│ │
|
||||
│ │ + session config │ │
|
||||
│ │ │ │
|
||||
│ │ ┌───────┴───────┐ │
|
||||
│ │ │ MCP Server │ │
|
||||
│ │ │ Container │ │
|
||||
│ │ │ │ │
|
||||
│ │ │ 4. Create │ │
|
||||
│ │ │ client │ │
|
||||
│ │ │ from │ │
|
||||
│ │ │ config │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ ▼ │ │
|
||||
│ │ │ 5. Call │ │
|
||||
│ │ │ Nextcloud │───────► User's Nextcloud │
|
||||
│ │ │ API │ Instance │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ ▼ │ │
|
||||
│ │ 6. Response │ Return result │ │
|
||||
│ │◄───────────────────┤ │ │
|
||||
│ │ └───────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Session Configuration Schema
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class SmitheryConfigSchema(BaseModel):
|
||||
"""Configuration schema for Smithery session."""
|
||||
|
||||
# Required: Nextcloud instance
|
||||
nextcloud_url: str = Field(
|
||||
...,
|
||||
description="Your Nextcloud instance URL (e.g., https://cloud.example.com)"
|
||||
)
|
||||
|
||||
# Authentication mode
|
||||
auth_mode: str = Field(
|
||||
"app_password",
|
||||
description="Authentication method: 'app_password' or 'oauth'"
|
||||
)
|
||||
|
||||
# App Password authentication (recommended for Smithery)
|
||||
username: str | None = Field(
|
||||
None,
|
||||
description="Nextcloud username (required for app_password auth)"
|
||||
)
|
||||
app_password: str | None = Field(
|
||||
None,
|
||||
description="Nextcloud app password (Settings → Security → App passwords)"
|
||||
)
|
||||
|
||||
# OAuth authentication (advanced)
|
||||
# When auth_mode='oauth', Smithery handles the OAuth flow
|
||||
# and passes the access token automatically
|
||||
```
|
||||
|
||||
### Feature Matrix
|
||||
|
||||
| Feature | Self-Hosted | Smithery Stateless |
|
||||
|---------|-------------|-------------------|
|
||||
| **Notes** | | |
|
||||
| List/Search notes | ✓ | ✓ |
|
||||
| Get/Create/Update notes | ✓ | ✓ |
|
||||
| Semantic search | ✓ | ✗ |
|
||||
| **Calendar** | | |
|
||||
| List calendars | ✓ | ✓ |
|
||||
| Get/Create events | ✓ | ✓ |
|
||||
| **Contacts** | | |
|
||||
| List address books | ✓ | ✓ |
|
||||
| Search/Get contacts | ✓ | ✓ |
|
||||
| **Files (WebDAV)** | | |
|
||||
| List/Download files | ✓ | ✓ |
|
||||
| Upload files | ✓ | ✓ |
|
||||
| Search files | ✓ | ✓ (keyword only) |
|
||||
| **Deck** | | |
|
||||
| List boards/cards | ✓ | ✓ |
|
||||
| Create/Update cards | ✓ | ✓ |
|
||||
| **Tables** | | |
|
||||
| List/Query tables | ✓ | ✓ |
|
||||
| Create/Update rows | ✓ | ✓ |
|
||||
| **Cookbook** | | |
|
||||
| List/Get recipes | ✓ | ✓ |
|
||||
| **Semantic Search** | | |
|
||||
| Vector search | ✓ | ✗ |
|
||||
| RAG answers | ✓ | ✗ |
|
||||
| **Background Sync** | | |
|
||||
| Auto-indexing | ✓ | ✗ |
|
||||
| Webhook sync | ✓ | ✗ |
|
||||
| **Admin UI (`/app`)** | | |
|
||||
| Vector sync status | ✓ | ✗ |
|
||||
| Vector visualization | ✓ | ✗ |
|
||||
| Webhook management | ✓ | ✗ |
|
||||
| Session management | ✓ | ✗ |
|
||||
|
||||
### Implementation
|
||||
|
||||
#### 1. Deployment Mode Detection
|
||||
|
||||
```python
|
||||
# nextcloud_mcp_server/config.py
|
||||
|
||||
class DeploymentMode(Enum):
|
||||
SELF_HOSTED = "self_hosted" # Full features, env-based config
|
||||
SMITHERY_STATELESS = "smithery" # Stateless, session-based config
|
||||
|
||||
def get_deployment_mode() -> DeploymentMode:
|
||||
"""Detect deployment mode from environment."""
|
||||
if os.getenv("SMITHERY_DEPLOYMENT") == "true":
|
||||
return DeploymentMode.SMITHERY_STATELESS
|
||||
return DeploymentMode.SELF_HOSTED
|
||||
```
|
||||
|
||||
#### 2. Session-Based Client Factory
|
||||
|
||||
```python
|
||||
# nextcloud_mcp_server/context.py
|
||||
|
||||
async def get_client(ctx: Context) -> NextcloudClient:
|
||||
"""Get NextcloudClient - from session config or environment."""
|
||||
|
||||
mode = get_deployment_mode()
|
||||
|
||||
if mode == DeploymentMode.SMITHERY_STATELESS:
|
||||
# Create client from Smithery session config
|
||||
config = ctx.session_config
|
||||
if not config:
|
||||
raise McpError("Session configuration required")
|
||||
|
||||
return NextcloudClient(
|
||||
base_url=config.nextcloud_url,
|
||||
username=config.username,
|
||||
password=config.app_password,
|
||||
)
|
||||
else:
|
||||
# Existing behavior: from environment or OAuth context
|
||||
return await _get_client_from_context(ctx)
|
||||
```
|
||||
|
||||
#### 3. Conditional Tool Registration
|
||||
|
||||
```python
|
||||
# nextcloud_mcp_server/app.py
|
||||
|
||||
def create_mcp_server(mode: DeploymentMode) -> FastMCP:
|
||||
"""Create MCP server with mode-appropriate tools."""
|
||||
|
||||
mcp = FastMCP("Nextcloud MCP")
|
||||
|
||||
# Always register core tools
|
||||
configure_notes_tools(mcp)
|
||||
configure_calendar_tools(mcp)
|
||||
configure_contacts_tools(mcp)
|
||||
configure_webdav_tools(mcp)
|
||||
configure_deck_tools(mcp)
|
||||
configure_tables_tools(mcp)
|
||||
configure_cookbook_tools(mcp)
|
||||
|
||||
# Only register stateful tools in self-hosted mode
|
||||
if mode == DeploymentMode.SELF_HOSTED:
|
||||
configure_semantic_tools(mcp) # Requires Qdrant
|
||||
register_oauth_tools(mcp) # Requires token storage
|
||||
|
||||
return mcp
|
||||
```
|
||||
|
||||
#### 4. Exclude Admin UI Routes
|
||||
|
||||
The `/app` admin UI should **not be installed** in Smithery mode because:
|
||||
|
||||
- **Vector sync status** - No vector sync in stateless mode
|
||||
- **Vector visualization** - No Qdrant to visualize
|
||||
- **Webhook management** - No webhook sync without background workers
|
||||
- **Session management** - No persistent sessions to manage
|
||||
|
||||
```python
|
||||
# nextcloud_mcp_server/app.py
|
||||
|
||||
def create_app(mode: DeploymentMode) -> Starlette:
|
||||
"""Create Starlette app with mode-appropriate routes."""
|
||||
|
||||
routes = [
|
||||
Route("/health/live", health_live, methods=["GET"]),
|
||||
Route("/health/ready", health_ready, methods=["GET"]),
|
||||
]
|
||||
|
||||
# Only mount admin UI in self-hosted mode
|
||||
if mode == DeploymentMode.SELF_HOSTED:
|
||||
browser_app = create_browser_app()
|
||||
routes.append(
|
||||
Route("/app", lambda r: RedirectResponse("/app/", status_code=307))
|
||||
)
|
||||
routes.append(Mount("/app", app=browser_app))
|
||||
logger.info("Admin UI mounted at /app")
|
||||
else:
|
||||
logger.info("Admin UI disabled in Smithery stateless mode")
|
||||
|
||||
# Mount FastMCP at root
|
||||
mcp_app = create_mcp_server(mode).streamable_http_app()
|
||||
routes.append(Mount("/", app=mcp_app))
|
||||
|
||||
return Starlette(routes=routes, lifespan=starlette_lifespan)
|
||||
```
|
||||
|
||||
**Endpoints by Mode:**
|
||||
|
||||
| Endpoint | Self-Hosted | Smithery |
|
||||
|----------|-------------|----------|
|
||||
| `/mcp` | ✓ | ✓ |
|
||||
| `/health/live` | ✓ | ✓ |
|
||||
| `/health/ready` | ✓ | ✓ |
|
||||
| `/.well-known/mcp-config` | ✓ | ✓ |
|
||||
| `/app` | ✓ | ✗ |
|
||||
| `/app/vector-sync/status` | ✓ | ✗ |
|
||||
| `/app/vector-viz` | ✓ | ✗ |
|
||||
| `/app/webhooks` | ✓ | ✗ |
|
||||
|
||||
#### 5. Smithery Integration Files
|
||||
|
||||
**smithery.yaml:**
|
||||
```yaml
|
||||
runtime: "container"
|
||||
build:
|
||||
dockerfile: "Dockerfile.smithery"
|
||||
dockerBuildPath: "."
|
||||
startCommand:
|
||||
type: "http"
|
||||
configSchema:
|
||||
type: "object"
|
||||
required: ["nextcloud_url", "username", "app_password"]
|
||||
properties:
|
||||
nextcloud_url:
|
||||
type: "string"
|
||||
title: "Nextcloud URL"
|
||||
description: "Your Nextcloud instance URL (e.g., https://cloud.example.com)"
|
||||
username:
|
||||
type: "string"
|
||||
title: "Username"
|
||||
description: "Your Nextcloud username"
|
||||
app_password:
|
||||
type: "string"
|
||||
title: "App Password"
|
||||
description: "Generate at Settings → Security → App passwords"
|
||||
exampleConfig:
|
||||
nextcloud_url: "https://cloud.example.com"
|
||||
username: "alice"
|
||||
app_password: "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
|
||||
```
|
||||
|
||||
**Dockerfile.smithery:**
|
||||
```dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
|
||||
|
||||
# Copy project files
|
||||
COPY pyproject.toml uv.lock ./
|
||||
COPY nextcloud_mcp_server ./nextcloud_mcp_server
|
||||
|
||||
# Install dependencies (without vector/semantic extras)
|
||||
RUN uv sync --frozen --no-dev
|
||||
|
||||
# Set Smithery mode
|
||||
ENV SMITHERY_DEPLOYMENT=true
|
||||
ENV VECTOR_SYNC_ENABLED=false
|
||||
|
||||
# Smithery sets PORT=8081
|
||||
EXPOSE 8081
|
||||
|
||||
CMD ["uv", "run", "python", "-m", "nextcloud_mcp_server.smithery_main"]
|
||||
```
|
||||
|
||||
**nextcloud_mcp_server/smithery_main.py:**
|
||||
```python
|
||||
"""Smithery-specific entrypoint for stateless deployment."""
|
||||
|
||||
import os
|
||||
import uvicorn
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
|
||||
from nextcloud_mcp_server.app import create_mcp_server
|
||||
from nextcloud_mcp_server.config import DeploymentMode
|
||||
|
||||
def main():
|
||||
# Force stateless mode
|
||||
os.environ["SMITHERY_DEPLOYMENT"] = "true"
|
||||
os.environ["VECTOR_SYNC_ENABLED"] = "false"
|
||||
|
||||
mcp = create_mcp_server(DeploymentMode.SMITHERY_STATELESS)
|
||||
app = mcp.streamable_http_app()
|
||||
|
||||
# Add CORS for browser-based clients
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "OPTIONS"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=["mcp-session-id", "mcp-protocol-version"],
|
||||
)
|
||||
|
||||
# Smithery sets PORT environment variable
|
||||
port = int(os.environ.get("PORT", 8081))
|
||||
uvicorn.run(app, host="0.0.0.0", port=port)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
### Security Considerations
|
||||
|
||||
1. **App Passwords over User Passwords**
|
||||
- Smithery config encourages app passwords (revocable, scoped)
|
||||
- Documentation guides users to create dedicated app passwords
|
||||
- App passwords can be revoked without changing main password
|
||||
|
||||
2. **HTTPS Required**
|
||||
- `nextcloud_url` must be HTTPS for production use
|
||||
- Validation rejects HTTP URLs in Smithery mode
|
||||
|
||||
3. **No Credential Storage**
|
||||
- Credentials exist only for request duration
|
||||
- No server-side persistence of user credentials
|
||||
- Smithery handles secure config transmission
|
||||
|
||||
4. **Scope Limitation**
|
||||
- Stateless mode cannot access offline_access
|
||||
- No background operations on user's behalf
|
||||
- Clear user expectation: tools work during session only
|
||||
|
||||
### Migration Path
|
||||
|
||||
Users can start with Smithery stateless mode and migrate to self-hosted:
|
||||
|
||||
1. **Try on Smithery** → Basic tools, no setup
|
||||
2. **Self-host for semantic search** → Add Qdrant, enable vector sync
|
||||
3. **Full deployment** → Background sync, webhooks, multi-user OAuth
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
1. **Lower barrier to entry** - Users can try without infrastructure
|
||||
2. **Multi-user support** - Each session connects to different Nextcloud
|
||||
3. **Smithery ecosystem** - Discovery, observability, OAuth UI
|
||||
4. **Clear feature tiers** - Stateless (simple) vs self-hosted (full)
|
||||
|
||||
### Negative
|
||||
|
||||
1. **No semantic search** - Key differentiator unavailable on Smithery
|
||||
2. **Per-request auth** - Credentials sent with each request
|
||||
3. **No offline access** - Cannot perform background operations
|
||||
4. **Maintenance burden** - Two deployment modes to support
|
||||
|
||||
### Neutral
|
||||
|
||||
1. **Feature subset** - May encourage users to self-host for full features
|
||||
2. **Documentation needs** - Clear guidance on mode differences required
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### 1. External MCP Only
|
||||
|
||||
**Approach:** Only support self-hosted external MCP registration on Smithery.
|
||||
|
||||
**Rejected because:**
|
||||
- Higher barrier to entry for new users
|
||||
- Misses opportunity for Smithery marketplace visibility
|
||||
- Users want to try before committing to infrastructure
|
||||
|
||||
### 2. Embedded Vector DB (SQLite-vec)
|
||||
|
||||
**Approach:** Use SQLite with vector extensions for per-request indexing.
|
||||
|
||||
**Rejected because:**
|
||||
- No persistence between requests anyway
|
||||
- Indexing latency too high for synchronous requests
|
||||
- Complexity without benefit in stateless context
|
||||
|
||||
### 3. External Vector DB Service
|
||||
|
||||
**Approach:** Connect to Pinecone/Weaviate Cloud from Smithery container.
|
||||
|
||||
**Rejected because:**
|
||||
- Adds external dependency and cost
|
||||
- Per-user collections require complex multi-tenancy
|
||||
- Sync still impossible without background workers
|
||||
|
||||
### 4. Hybrid: Smithery + User's Qdrant
|
||||
|
||||
**Approach:** User provides their own Qdrant URL in session config.
|
||||
|
||||
**Considered for future:**
|
||||
- Could enable semantic search for advanced users
|
||||
- Adds complexity to session config
|
||||
- Sync still requires external trigger (manual or webhook)
|
||||
|
||||
## References
|
||||
|
||||
- [Smithery Documentation](https://smithery.ai/docs)
|
||||
- [Smithery Session Configuration](https://smithery.ai/docs/build/session-config)
|
||||
- [Smithery External MCPs](https://smithery.ai/docs/build/external)
|
||||
- [MCP Streamable HTTP Transport](https://modelcontextprotocol.io/docs/concepts/transports)
|
||||
- [Nextcloud App Passwords](https://docs.nextcloud.com/server/latest/user_manual/en/session_management.html#app-passwords)
|
||||
@@ -0,0 +1,506 @@
|
||||
# ADR-017: Add MCP Tool Annotations for Enhanced Client UX
|
||||
|
||||
## Status
|
||||
|
||||
Implemented
|
||||
|
||||
## Context
|
||||
|
||||
The MCP Python SDK supports tool annotations that provide behavioral hints and improved UX to MCP clients. Currently, our 101 tools across 10 modules lack these annotations, resulting in:
|
||||
|
||||
- Snake_case function names displayed to users (e.g., "nc_notes_create_note" instead of "Create Note")
|
||||
- No behavioral hints for clients about read-only, destructive, or idempotent operations
|
||||
- Missing parameter descriptions for better auto-completion and inline help
|
||||
- Clients cannot optimize caching, warn before destructive operations, or retry safely
|
||||
|
||||
### Available MCP Annotations
|
||||
|
||||
The MCP SDK provides three types of annotations:
|
||||
|
||||
#### 1. Tool Decorator Parameters
|
||||
```python
|
||||
@mcp.tool(
|
||||
title="Human-Readable Name",
|
||||
description="Tool description", # Can also come from docstring
|
||||
annotations=ToolAnnotations(...),
|
||||
icons=[Icon(...)] # Optional visual icons
|
||||
)
|
||||
```
|
||||
|
||||
#### 2. ToolAnnotations Behavioral Hints
|
||||
```python
|
||||
from mcp.types import ToolAnnotations
|
||||
|
||||
ToolAnnotations(
|
||||
title="Alternative Title", # Decorator title takes precedence
|
||||
readOnlyHint=True, # Tool doesn't modify data
|
||||
destructiveHint=True, # Tool may delete/overwrite data
|
||||
idempotentHint=True, # Repeated calls with same args are safe
|
||||
openWorldHint=True # Interacts with external entities
|
||||
)
|
||||
```
|
||||
|
||||
#### 3. Parameter Descriptions
|
||||
```python
|
||||
from pydantic import Field
|
||||
|
||||
async def tool(
|
||||
param: str = Field(description="What this parameter does"),
|
||||
ctx: Context
|
||||
):
|
||||
```
|
||||
|
||||
### Idempotency Analysis
|
||||
|
||||
**Important**: Idempotency means calling with **the same inputs** produces the same result.
|
||||
|
||||
**NOT Idempotent** (different inputs each call):
|
||||
- **Updates with etag**: `update_note(id=1, title="X", etag="abc")` → etag changes to "def"
|
||||
- Second call: `update_note(id=1, title="X", etag="abc")` → fails (etag mismatch)
|
||||
- Different input (stale etag) → different result (error)
|
||||
- **Creates**: `create_note(title="X")` → creates note 1
|
||||
- Second call → creates note 2 (different result)
|
||||
- **Append operations**: `append_content(id=1, text="X")` → adds X once
|
||||
- Second call → adds X again (different result)
|
||||
|
||||
**Idempotent**:
|
||||
- **Deletes**: `delete_note(id=1)` → note deleted
|
||||
- Second call → 404 or success (same end state: note doesn't exist)
|
||||
- Note: May return different status code, but end state is identical
|
||||
- **Full resource PUT without version control**: `write_file(path="/test.txt", content="Hello")` → file has "Hello"
|
||||
- Second call → file still has "Hello" (same end state)
|
||||
- Example: `nc_webdav_write_file` uses HTTP PUT without etags/version control
|
||||
- **Set operations**: `set_property(id=1, value="X")` → property = X
|
||||
- Second call → property still = X (same result)
|
||||
- Note: Nextcloud updates with etags use version control, so not idempotent
|
||||
|
||||
**Read-Only** (always idempotent, never destructive):
|
||||
- All list, search, get operations
|
||||
|
||||
## Decision
|
||||
|
||||
Add annotations to all 101 tools in three phases:
|
||||
|
||||
### Phase 1: Titles (Quick Win)
|
||||
Add human-readable titles to all tools:
|
||||
|
||||
```python
|
||||
@mcp.tool(title="Create Note")
|
||||
async def nc_notes_create_note(...):
|
||||
```
|
||||
|
||||
**Effort**: 2-3 hours
|
||||
**Impact**: Immediate UX improvement
|
||||
|
||||
### Phase 2: ToolAnnotations (Behavioral Hints)
|
||||
Add annotations based on corrected categorization:
|
||||
|
||||
```python
|
||||
# Read-only tools
|
||||
@mcp.tool(
|
||||
title="Search Notes",
|
||||
annotations=ToolAnnotations(
|
||||
readOnlyHint=True,
|
||||
openWorldHint=True # Nextcloud is external to MCP server
|
||||
)
|
||||
)
|
||||
|
||||
# Delete tools (idempotent: same end state)
|
||||
@mcp.tool(
|
||||
title="Delete Note",
|
||||
annotations=ToolAnnotations(
|
||||
destructiveHint=True,
|
||||
idempotentHint=True, # Deleting deleted item = same end state
|
||||
openWorldHint=True
|
||||
)
|
||||
)
|
||||
|
||||
# Create tools (not idempotent: creates multiple items)
|
||||
@mcp.tool(
|
||||
title="Create Note",
|
||||
annotations=ToolAnnotations(
|
||||
idempotentHint=False,
|
||||
openWorldHint=True
|
||||
)
|
||||
)
|
||||
|
||||
# Update tools with etag (not idempotent: etag changes)
|
||||
@mcp.tool(
|
||||
title="Update Note",
|
||||
annotations=ToolAnnotations(
|
||||
idempotentHint=False, # Etag required = different inputs each time
|
||||
openWorldHint=True
|
||||
)
|
||||
)
|
||||
|
||||
# Append operations (not idempotent: adds content each time)
|
||||
@mcp.tool(
|
||||
title="Append to Note",
|
||||
annotations=ToolAnnotations(
|
||||
idempotentHint=False,
|
||||
openWorldHint=True
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
**Effort**: 4-6 hours
|
||||
**Impact**: Better client behavior (caching, warnings, retry logic)
|
||||
|
||||
### Phase 3: Parameter Descriptions
|
||||
Add Field() descriptions to parameters:
|
||||
|
||||
```python
|
||||
from pydantic import Field
|
||||
|
||||
@mcp.tool(title="Create Note", annotations=ToolAnnotations(idempotentHint=False))
|
||||
async def nc_notes_create_note(
|
||||
title: str = Field(description="The title of the note"),
|
||||
content: str = Field(description="Markdown content of the note"),
|
||||
category: str = Field(description="Category or folder name for organizing"),
|
||||
ctx: Context
|
||||
) -> CreateNoteResponse:
|
||||
```
|
||||
|
||||
**Effort**: 6-8 hours
|
||||
**Impact**: Better auto-completion and inline help
|
||||
|
||||
## Tool Categorization
|
||||
|
||||
### Read-Only Tools (~40 tools)
|
||||
**Pattern**: List, search, get operations
|
||||
**Annotations**: `readOnlyHint=True`, `openWorldHint=True`
|
||||
|
||||
Examples:
|
||||
- `nc_notes_search_notes` → "Search Notes"
|
||||
- `nc_webdav_list_directory` → "List Files and Directories"
|
||||
- `nc_calendar_list_calendars` → "List Calendars"
|
||||
- `nc_contacts_get_contact` → "Get Contact"
|
||||
- `nc_semantic_search` → "Semantic Search"
|
||||
- `check_logged_in` → "Check Server Login Status"
|
||||
|
||||
### Create Tools (~20 tools)
|
||||
**Pattern**: Create new resources
|
||||
**Annotations**: `idempotentHint=False`, `openWorldHint=True`
|
||||
|
||||
Examples:
|
||||
- `nc_notes_create_note` → "Create Note"
|
||||
- `nc_calendar_create_event` → "Create Calendar Event"
|
||||
- `nc_contacts_create_contact` → "Create Contact"
|
||||
- `deck_create_card` → "Create Kanban Card"
|
||||
- `nc_tables_create_row` → "Create Table Row"
|
||||
|
||||
### Update Tools (~25 tools)
|
||||
**Pattern**: Modify existing resources with etag
|
||||
**Annotations**: `idempotentHint=False` (etag changes), `openWorldHint=True`
|
||||
|
||||
Examples:
|
||||
- `nc_notes_update_note` → "Update Note"
|
||||
- `nc_calendar_update_event` → "Update Calendar Event"
|
||||
- `nc_contacts_update_contact` → "Update Contact"
|
||||
- `deck_update_card` → "Update Kanban Card"
|
||||
|
||||
**Rationale**: Updates require etag, which changes after each update. Same parameters on second call will fail due to stale etag = NOT idempotent.
|
||||
|
||||
### Append/Accumulate Tools (~5 tools)
|
||||
**Pattern**: Add content without replacing
|
||||
**Annotations**: `idempotentHint=False`, `openWorldHint=True`
|
||||
|
||||
Examples:
|
||||
- `nc_notes_append_content` → "Append to Note"
|
||||
|
||||
**Rationale**: Each call adds content, changing the result = NOT idempotent.
|
||||
|
||||
### Delete Tools (~10 tools)
|
||||
**Pattern**: Remove resources
|
||||
**Annotations**: `destructiveHint=True`, `idempotentHint=True`, `openWorldHint=True`
|
||||
|
||||
Examples:
|
||||
- `nc_notes_delete_note` → "Delete Note"
|
||||
- `nc_webdav_delete_resource` → "Delete File or Directory"
|
||||
- `nc_calendar_delete_event` → "Delete Calendar Event"
|
||||
- `nc_contacts_delete_contact` → "Delete Contact"
|
||||
|
||||
**Rationale**: Deleting already-deleted item results in same end state (item doesn't exist) = idempotent. Status code may differ, but outcome is identical.
|
||||
|
||||
### Special Cases
|
||||
|
||||
#### OAuth Provisioning Tools
|
||||
```python
|
||||
# Not read-only but requires user interaction
|
||||
@mcp.tool(
|
||||
title="Grant Server Access to Nextcloud",
|
||||
annotations=ToolAnnotations(
|
||||
readOnlyHint=False,
|
||||
idempotentHint=False, # Creates new OAuth session each time
|
||||
openWorldHint=True
|
||||
)
|
||||
)
|
||||
async def provision_nextcloud_access(ctx: Context):
|
||||
```
|
||||
|
||||
#### Semantic Search (Closed World)
|
||||
```python
|
||||
@mcp.tool(
|
||||
title="Semantic Search",
|
||||
annotations=ToolAnnotations(
|
||||
readOnlyHint=True,
|
||||
openWorldHint=False # Searches only indexed Nextcloud data
|
||||
)
|
||||
)
|
||||
async def nc_semantic_search(query: str, ctx: Context):
|
||||
```
|
||||
|
||||
**Rationale**: Semantic search only queries pre-indexed Nextcloud content, not the "open world" like web search would.
|
||||
|
||||
## Tool Priority Matrix
|
||||
|
||||
### Critical Priority (~2 tools)
|
||||
OAuth tools required for server functionality:
|
||||
- `provision_nextcloud_access` → "Grant Server Access to Nextcloud"
|
||||
- `check_logged_in` → "Check Server Login Status"
|
||||
|
||||
### High Priority (~50 tools)
|
||||
Most commonly used modules:
|
||||
- **Notes** (14 tools): Create, read, update, delete notes
|
||||
- **WebDAV** (13 tools): File operations
|
||||
- **Calendar** (15 tools): Events and todos
|
||||
- **Semantic Search** (6 tools): AI-powered search
|
||||
- **Contacts** (9 tools): Address book operations
|
||||
|
||||
### Medium Priority (~35 tools)
|
||||
Secondary functionality:
|
||||
- **Deck** (9 tools): Kanban boards
|
||||
- **Tables** (7 tools): Structured data
|
||||
- **Sharing** (5 tools): File sharing
|
||||
|
||||
### Low Priority (~14 tools)
|
||||
Less frequently used:
|
||||
- **Cookbook** (8 tools): Recipe management
|
||||
- **News** (6 tools): RSS feeds
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Week 1: Phase 1 - Titles
|
||||
- Add human-readable titles to all 101 tools
|
||||
- Update tool name mapping in documentation
|
||||
- Manual test in MCP inspector
|
||||
|
||||
### Week 2: Phase 2 - ToolAnnotations (High Priority)
|
||||
- Add annotations to Critical and High priority tools (~52 tools)
|
||||
- Focus on Notes, WebDAV, Calendar, Semantic, OAuth
|
||||
- Add unit tests validating annotation presence
|
||||
|
||||
### Week 3: Phase 2 - ToolAnnotations (Medium/Low Priority)
|
||||
- Complete remaining tools (~49 tools)
|
||||
- Deck, Tables, Contacts, Cookbook, News
|
||||
- Update tool listings in README
|
||||
|
||||
### Week 4: Phase 3 - Parameter Descriptions
|
||||
- Add Field() descriptions to Critical/High priority tools
|
||||
- Start with OAuth, Notes, WebDAV modules
|
||||
- Incremental completion over time
|
||||
|
||||
## Benefits
|
||||
|
||||
### For Users
|
||||
- **Clearer UI**: "Create Note" vs "nc_notes_create_note"
|
||||
- **Safety**: Warnings before destructive operations
|
||||
- **Better help**: Parameter descriptions in auto-completion
|
||||
- **Confidence**: Know which operations are safe to retry
|
||||
|
||||
### For MCP Clients
|
||||
- **Caching**: Cache results from read-only tools
|
||||
- **Safety prompts**: Warn before destructiveHint=true
|
||||
- **Retry logic**: Safely retry idempotent operations
|
||||
- **UI organization**: Group by behavior (reads vs writes vs deletes)
|
||||
- **Performance**: Optimize based on hints
|
||||
|
||||
### For Developers
|
||||
- **Self-documenting**: Behavior is explicit
|
||||
- **Consistency**: Standard patterns across codebase
|
||||
- **Testing**: Validate annotations match implementation
|
||||
- **Maintenance**: Clear expectations for new tools
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- Immediate UX improvement with minimal effort
|
||||
- Clients can make smarter decisions
|
||||
- Self-documenting code
|
||||
- Follows MCP best practices
|
||||
|
||||
### Negative
|
||||
- Initial effort to add annotations (12-15 hours total)
|
||||
- Must maintain annotations when adding new tools
|
||||
- Risk of incorrect annotations misleading clients
|
||||
|
||||
### Neutral
|
||||
- Annotations are hints, not guarantees
|
||||
- Clients may ignore annotations
|
||||
- Backward compatible (additive change)
|
||||
|
||||
### Mitigations
|
||||
- **Incorrect annotations**: Add tests validating behavior matches hints
|
||||
- **Maintenance burden**: Add to code review checklist and tool template
|
||||
- **Documentation**: Update CLAUDE.md with annotation guidelines
|
||||
|
||||
## Examples
|
||||
|
||||
### Complete Annotated Tool (Delete)
|
||||
|
||||
```python
|
||||
from mcp.types import ToolAnnotations
|
||||
from pydantic import Field
|
||||
|
||||
@mcp.tool(
|
||||
title="Delete Note",
|
||||
annotations=ToolAnnotations(
|
||||
destructiveHint=True, # Deletes data permanently
|
||||
idempotentHint=True, # Same end state (note doesn't exist)
|
||||
openWorldHint=True # Nextcloud is external
|
||||
)
|
||||
)
|
||||
@require_scopes("notes:write")
|
||||
@instrument_tool
|
||||
async def nc_notes_delete_note(
|
||||
note_id: int = Field(description="The ID of the note to delete permanently"),
|
||||
ctx: Context
|
||||
) -> DeleteNoteResponse:
|
||||
"""Delete a note permanently (requires notes:write scope)"""
|
||||
client = await get_client(ctx)
|
||||
# ... implementation ...
|
||||
```
|
||||
|
||||
### Complete Annotated Tool (Update)
|
||||
|
||||
```python
|
||||
@mcp.tool(
|
||||
title="Update Note",
|
||||
annotations=ToolAnnotations(
|
||||
idempotentHint=False, # NOT idempotent: etag changes each update
|
||||
openWorldHint=True
|
||||
)
|
||||
)
|
||||
@require_scopes("notes:write")
|
||||
@instrument_tool
|
||||
async def nc_notes_update_note(
|
||||
note_id: int = Field(description="The ID of the note to update"),
|
||||
title: str | None = Field(
|
||||
default=None,
|
||||
description="New title (omit to keep current)"
|
||||
),
|
||||
content: str | None = Field(
|
||||
default=None,
|
||||
description="New markdown content (omit to keep current)"
|
||||
),
|
||||
category: str | None = Field(
|
||||
default=None,
|
||||
description="New category/folder (omit to keep current)"
|
||||
),
|
||||
etag: str = Field(
|
||||
description="ETag from get_note (prevents concurrent modification)"
|
||||
),
|
||||
ctx: Context
|
||||
) -> UpdateNoteResponse:
|
||||
"""Update an existing note's title, content, or category.
|
||||
|
||||
The etag parameter is required to prevent overwriting concurrent changes.
|
||||
Get the current ETag by first calling nc_notes_get_note.
|
||||
If the note has been modified since you retrieved it, the update will fail.
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
# ... implementation ...
|
||||
```
|
||||
|
||||
### Complete Annotated Tool (Read-Only)
|
||||
|
||||
```python
|
||||
@mcp.tool(
|
||||
title="Search Notes",
|
||||
annotations=ToolAnnotations(
|
||||
readOnlyHint=True, # Doesn't modify data
|
||||
openWorldHint=True # Queries Nextcloud
|
||||
)
|
||||
)
|
||||
@require_scopes("notes:read")
|
||||
@instrument_tool
|
||||
async def nc_notes_search_notes(
|
||||
query: str = Field(description="Search term to match in note titles or content"),
|
||||
ctx: Context
|
||||
) -> SearchNotesResponse:
|
||||
"""Search notes by title or content, returning id, title, and category.
|
||||
|
||||
This is a read-only operation that searches across all user notes.
|
||||
Use nc_notes_get_note to retrieve the full content of matching notes.
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
# ... implementation ...
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
Add tests validating annotation presence and correctness:
|
||||
|
||||
```python
|
||||
def test_notes_tools_have_annotations():
|
||||
"""Verify all notes tools have appropriate annotations."""
|
||||
tools = get_registered_tools(mcp)
|
||||
|
||||
# Check create tool
|
||||
create_tool = tools["nc_notes_create_note"]
|
||||
assert create_tool.title == "Create Note"
|
||||
assert create_tool.annotations.idempotentHint is False
|
||||
|
||||
# Check delete tool
|
||||
delete_tool = tools["nc_notes_delete_note"]
|
||||
assert delete_tool.title == "Delete Note"
|
||||
assert delete_tool.annotations.destructiveHint is True
|
||||
assert delete_tool.annotations.idempotentHint is True
|
||||
|
||||
# Check read-only tool
|
||||
search_tool = tools["nc_notes_search_notes"]
|
||||
assert search_tool.title == "Search Notes"
|
||||
assert search_tool.annotations.readOnlyHint is True
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
- Verify existing tests pass with annotations
|
||||
- Manual testing in MCP inspector/client
|
||||
|
||||
### Documentation Updates
|
||||
- Update README tool listings with new titles
|
||||
- Add annotation guidelines to CLAUDE.md
|
||||
- Include examples in developer documentation
|
||||
|
||||
## Resolved Questions
|
||||
|
||||
1. **WebDAV write_file idempotency** (Resolved: 2025-12-11)
|
||||
- **Decision**: Mark as `idempotentHint=True`
|
||||
- **Rationale**: Uses HTTP PUT without version control. Writing same content to same path repeatedly produces identical end state, which is the definition of idempotency in HTTP semantics.
|
||||
|
||||
2. **Semantic search openWorldHint** (Resolved: 2025-12-11)
|
||||
- **Decision**: Mark as `openWorldHint=True`
|
||||
- **Rationale**: For consistency with other Nextcloud tools. While the data being searched is "indexed/internal", Nextcloud itself is external to the MCP server. The fact that data is indexed is an implementation detail, not a fundamental difference from other Nextcloud queries.
|
||||
|
||||
3. **Read-only with side effects**: Should tools that log analytics still be readOnlyHint=true?
|
||||
- **Decision**: Yes. Logging/analytics are non-visible side effects that don't change user-observable state. Read-only refers to data modifications that affect the user's content.
|
||||
|
||||
## Future Considerations
|
||||
|
||||
1. **Icons**: Visual icons for tools (requires design work, deferred to future ADR)
|
||||
2. **Parameter descriptions**: Add Pydantic `Field(description=...)` for better auto-completion (Phase 3, future work)
|
||||
|
||||
## References
|
||||
|
||||
- MCP Python SDK: `/home/chris/Software/python-sdk/`
|
||||
- ToolAnnotations spec: `src/mcp/types.py:1247`
|
||||
- FastMCP decorator: `src/mcp/server/fastmcp/server.py:444`
|
||||
- Examples: `examples/fastmcp/parameter_descriptions.py`, `examples/fastmcp/icons_demo.py`
|
||||
|
||||
## Decision Timeline
|
||||
|
||||
- **Proposed**: 2025-12-11
|
||||
- **Reviewed**: 2025-12-11 (Self-review during implementation)
|
||||
- **Accepted**: 2025-12-11
|
||||
- **Implemented**: 2025-12-11 (Phase 1 & 2 complete)
|
||||
@@ -0,0 +1,104 @@
|
||||
# MCP 1.23.x DNS Rebinding Protection Fix
|
||||
|
||||
## Problem
|
||||
|
||||
MCP Python SDK 1.23.0 introduced **automatic DNS rebinding protection** that breaks containerized deployments (Kubernetes, Docker) when the protection is unintentionally auto-enabled.
|
||||
|
||||
### Root Cause
|
||||
|
||||
From `mcp/server/fastmcp/server.py:177-183` in the Python SDK:
|
||||
|
||||
```python
|
||||
# Auto-enable DNS rebinding protection for localhost (IPv4 and IPv6)
|
||||
if transport_security is None and host in ("127.0.0.1", "localhost", "::1"):
|
||||
transport_security = TransportSecuritySettings(
|
||||
enable_dns_rebinding_protection=True,
|
||||
allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"],
|
||||
allowed_origins=["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"],
|
||||
)
|
||||
```
|
||||
|
||||
### What Was Happening
|
||||
|
||||
1. **FastMCP initialization** in `app.py` didn't pass `host` or `transport_security` parameters
|
||||
2. **Defaults applied**: `host="127.0.0.1"`, `transport_security=None`
|
||||
3. **Auto-enablement triggered**: Condition `transport_security is None and host == "127.0.0.1"` was TRUE
|
||||
4. **Protection activated** with `allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"]`
|
||||
5. **Kubernetes requests rejected**: `Host: nextcloud-mcp-server.default.svc.cluster.local:8000` didn't match allowed hosts
|
||||
|
||||
### Why `--host 0.0.0.0` Didn't Help
|
||||
|
||||
The `--host` CLI flag (used in Dockerfile/docker-compose) controls **uvicorn's bind address**, NOT the **FastMCP `host` parameter**. These are separate concerns:
|
||||
|
||||
- **Uvicorn bind address** (`--host 0.0.0.0`): Where the HTTP server listens
|
||||
- **FastMCP host parameter** (defaulted to `"127.0.0.1"`): Used for auto-enablement logic
|
||||
|
||||
## Solution
|
||||
|
||||
Explicitly disable DNS rebinding protection by passing `transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False)` to all FastMCP instances.
|
||||
|
||||
### Changes Made
|
||||
|
||||
Modified `nextcloud_mcp_server/app.py`:
|
||||
|
||||
1. **Import** `TransportSecuritySettings` from `mcp.server.transport_security`
|
||||
2. **Updated all three FastMCP initializations**:
|
||||
- OAuth mode (line 1015)
|
||||
- Smithery stateless mode (line 1030)
|
||||
- BasicAuth mode (line 1040)
|
||||
|
||||
Each now includes:
|
||||
```python
|
||||
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False)
|
||||
```
|
||||
|
||||
## Impact
|
||||
|
||||
### ✅ What This Fixes
|
||||
|
||||
- **Kubernetes deployments**: Requests with k8s service DNS names now work
|
||||
- **Docker deployments**: Port-mapped requests (localhost:8000 → container) now work
|
||||
- **Reverse proxy deployments**: Proxied requests with various Host headers now work
|
||||
- **Ingress controllers**: Requests via ingress hostnames now work
|
||||
|
||||
### 🔒 Security Considerations
|
||||
|
||||
DNS rebinding protection defends against attacks where:
|
||||
1. Attacker controls a DNS domain (e.g., `evil.com`)
|
||||
2. DNS initially resolves to attacker's IP
|
||||
3. After victim's browser caches the origin, DNS changes to victim's localhost
|
||||
4. Attacker's page can now make requests to victim's localhost services
|
||||
|
||||
**Why it's safe to disable for this deployment:**
|
||||
|
||||
1. **OAuth authentication required** in production deployments (ADR-002, ADR-004)
|
||||
2. **Network-level isolation** in containerized environments (k8s network policies, Docker networks)
|
||||
3. **MCP is server-to-server**, not exposed to browsers (no CORS concerns)
|
||||
4. **Host header validation inappropriate** for multi-tenant k8s environments
|
||||
|
||||
If DNS rebinding protection is needed for specific deployments, it can be re-enabled with a custom allowed hosts list:
|
||||
|
||||
```python
|
||||
transport_security=TransportSecuritySettings(
|
||||
enable_dns_rebinding_protection=True,
|
||||
allowed_hosts=[
|
||||
"nextcloud-mcp-server.default.svc.cluster.local:*",
|
||||
"mcp.example.com:*",
|
||||
# Add all your expected Host header values
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
- ✅ Ruff linting passes
|
||||
- ✅ Type checking passes (pre-existing warnings unrelated)
|
||||
- ✅ Module imports successfully
|
||||
- ✅ Compatible with MCP 1.23.x
|
||||
|
||||
## References
|
||||
|
||||
- [MCP Python SDK 1.23.0 Release](https://github.com/modelcontextprotocol/python-sdk/releases/tag/v1.23.0)
|
||||
- Commit: `d3a1841` - "Auto-enable DNS rebinding protection for localhost servers"
|
||||
- Issue #373 (original report of k8s breakage)
|
||||
- PR #382 (MCP 1.23.x upgrade)
|
||||
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 483 KiB |
@@ -5,7 +5,7 @@ This document explains the architecture of the semantic search feature in the Ne
|
||||
> [!IMPORTANT]
|
||||
> **Status: Experimental**
|
||||
> - Disabled by default (`VECTOR_SYNC_ENABLED=false`)
|
||||
> - Currently supports **Notes app only** (multi-app architecture ready, additional apps planned)
|
||||
> - Currently supports **Notes, Files (PDFs), News items, and Deck cards**
|
||||
> - Requires additional infrastructure (Qdrant vector database + Ollama embedding service)
|
||||
> - RAG answer generation requires MCP client sampling support
|
||||
|
||||
@@ -39,9 +39,9 @@ Semantic search enables:
|
||||
|
||||
### Current Support
|
||||
|
||||
- **Supported Apps**: Notes (fully implemented)
|
||||
- **Planned Apps**: Calendar events, Calendar tasks, Deck cards, Files (with text extraction), Contacts
|
||||
- **Architecture**: Multi-app plugin system ready, awaiting implementation
|
||||
- **Supported Apps**: Notes, Files (PDFs with text extraction), News items, Deck cards
|
||||
- **Planned Apps**: Calendar events, Calendar tasks, Contacts
|
||||
- **Architecture**: Multi-app plugin system ready for additional apps
|
||||
|
||||
## System Components
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Vector Sync UI Guide
|
||||
|
||||
This guide covers the browser-based interface for the Nextcloud MCP Server's semantic search and vector synchronization features.
|
||||
|
||||
## Overview
|
||||
|
||||
The Vector Sync UI (`/app`) provides an interactive interface to test semantic search queries and visualize results from your Nextcloud documents. It exposes the same retrieval capabilities that LLMs use in Retrieval-Augmented Generation (RAG) workflows, powered by Alpine.js for reactive state, htmx for dynamic updates, and Plotly.js for 3D visualization.
|
||||
|
||||
**Supported Apps**: Notes, Files (text/PDF), Calendar (events/tasks), Contacts (CardDAV), and Deck are indexed and searchable.
|
||||
|
||||
## Accessing the UI
|
||||
|
||||
Navigate to `/app` after authentication:
|
||||
- **BasicAuth mode**: `http://localhost:8000/app` (uses credentials from environment)
|
||||
- **OAuth mode**: `http://localhost:8000/app` (redirects to login if not authenticated)
|
||||
|
||||
## Tabs
|
||||
|
||||
### Welcome Page
|
||||
|
||||
Landing page that introduces semantic search and RAG workflows. Shows authentication status, explains how vector embeddings work, and provides feature navigation. Adapts content based on whether `VECTOR_SYNC_ENABLED=true`.
|
||||
|
||||
### User Info
|
||||
|
||||
Displays authentication details and session information:
|
||||
- **BasicAuth**: Username, mode badge, Nextcloud host
|
||||
- **OAuth**: Username, session ID (truncated), background access status, IdP profile, revocation option
|
||||
|
||||
### Vector Sync Status
|
||||
|
||||
Real-time monitoring of document indexing:
|
||||
- **Indexed Documents**: Total chunks stored in Qdrant vector database (immediately searchable)
|
||||
- **Pending Documents**: Queue awaiting embedding processing
|
||||
- **Status**: "✓ Idle" (green) when up-to-date, "⟳ Syncing" (orange) during processing
|
||||
|
||||
Auto-refreshes every 10 seconds via htmx. Check this tab after adding content to verify indexing completion.
|
||||
|
||||
### Vector Visualization
|
||||
|
||||
Interactive search interface with 3D PCA plot of semantic space.
|
||||
|
||||
**Search Controls**:
|
||||
- **Query**: Natural language search (e.g., "health benefits of coffee")
|
||||
- **Algorithm**: Semantic (Dense) for pure vector search, or BM25 Hybrid (default) combining vectors + keywords
|
||||
- **Fusion** (Hybrid only): RRF (Reciprocal Rank Fusion) or DBSF (Distribution-Based Score Fusion)
|
||||
- **Advanced**: Filter by document type, adjust score threshold (0.0-1.0), set result limit (max 100)
|
||||
|
||||
**3D Visualization**:
|
||||
|
||||
The plot uses Principal Component Analysis (PCA) to reduce 768-dimensional embeddings to 3D. Documents are positioned by semantic similarity with the query point shown in red. Point size and opacity indicate relevance, and the Viridis color scale shows relative scores (yellow = highest match).
|
||||
|
||||
**Critical Fix**: Vectors are L2-normalized before PCA to match Qdrant's cosine distance, ensuring query points position accurately near similar documents. Without normalization, magnitude differences cause misleading spatial separation.
|
||||
|
||||
**Results List**:
|
||||
|
||||
Each result shows document title (clickable link to Nextcloud), excerpt, raw score, relative percentage, and document type. Click "Show Chunk" to view the matched text segment with surrounding context (up to 500 characters before/after).
|
||||
|
||||
## Configuration
|
||||
|
||||
**Required**:
|
||||
```bash
|
||||
VECTOR_SYNC_ENABLED=true
|
||||
```
|
||||
|
||||
**Optional** (for browser-accessible links):
|
||||
```bash
|
||||
NEXTCLOUD_PUBLIC_ISSUER_URL=https://your-public-nextcloud-url.com
|
||||
```
|
||||
|
||||
**Admin Access**: Webhooks tab only visible to Nextcloud admins (verified via Provisioning API).
|
||||
|
||||
## Use Cases
|
||||
|
||||
**Testing Search Queries**: Preview results before they reach LLMs in RAG workflows. Compare semantic vs. hybrid algorithms, verify relevance scores, and validate that correct documents are retrieved. Use chunk context to see exactly which text segments match and why unexpected documents appear.
|
||||
|
||||
**Monitoring Indexing**: Track real-time progress after creating or modifying documents. Check if the queue is backing up (high pending count) or confirm the system is idle after bulk imports. Verify documents become searchable immediately after indexing completes.
|
||||
|
||||
**Algorithm Comparison**: Pure semantic search excels at conceptual queries and synonyms. BM25 hybrid combines semantic understanding with precise keyword matching for better accuracy on specific terms. Experiment with RRF vs. DBSF fusion for different score distributions.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Vector Sync Tab Not Visible**: Set `VECTOR_SYNC_ENABLED=true` and restart the server.
|
||||
|
||||
**No Search Results**: Check Vector Sync Status to confirm documents are indexed (not just pending). Try broader queries or lower the score threshold in Advanced options. Initial indexing may take time depending on document volume.
|
||||
|
||||
**Links to Nextcloud Apps Not Working**: Set `NEXTCLOUD_PUBLIC_ISSUER_URL` to your browser-accessible Nextcloud URL for correct link generation.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Configuration Guide](../configuration.md) - Environment variables and settings
|
||||
- [Authentication Modes](../authentication.md) - BasicAuth vs OAuth setup
|
||||
- [Installation Guide](../installation.md) - Getting started
|
||||
- [ADR-008: MCP Sampling for RAG](../ADR-008-mcp-sampling-for-rag.md) - Technical details on RAG workflows
|
||||
@@ -3,6 +3,7 @@ import os
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
@@ -18,12 +19,16 @@ import httpx
|
||||
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
||||
from mcp.server.auth.settings import AuthSettings
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
from pydantic import AnyHttpUrl
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware.authentication import AuthenticationMiddleware
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.responses import JSONResponse, RedirectResponse
|
||||
from starlette.routing import Mount, Route
|
||||
from starlette.staticfiles import StaticFiles
|
||||
from starlette.types import ASGIApp, Receive, Send
|
||||
from starlette.types import Scope as StarletteScope
|
||||
|
||||
from nextcloud_mcp_server.auth import (
|
||||
InsufficientScopeError,
|
||||
@@ -35,6 +40,8 @@ from nextcloud_mcp_server.auth import (
|
||||
from nextcloud_mcp_server.auth.unified_verifier import UnifiedTokenVerifier
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import (
|
||||
DeploymentMode,
|
||||
get_deployment_mode,
|
||||
get_document_processor_config,
|
||||
get_settings,
|
||||
)
|
||||
@@ -54,6 +61,7 @@ from nextcloud_mcp_server.server import (
|
||||
configure_contacts_tools,
|
||||
configure_cookbook_tools,
|
||||
configure_deck_tools,
|
||||
configure_news_tools,
|
||||
configure_notes_tools,
|
||||
configure_semantic_tools,
|
||||
configure_sharing_tools,
|
||||
@@ -121,6 +129,26 @@ def initialize_document_processors():
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to register Tesseract processor: {e}")
|
||||
|
||||
# Register PyMuPDF processor (high priority, local, no API required)
|
||||
if "pymupdf" in config["processors"]:
|
||||
pymupdf_config = config["processors"]["pymupdf"]
|
||||
try:
|
||||
from nextcloud_mcp_server.document_processors.pymupdf import (
|
||||
PyMuPDFProcessor,
|
||||
)
|
||||
|
||||
processor = PyMuPDFProcessor(
|
||||
extract_images=pymupdf_config.get("extract_images", True),
|
||||
image_dir=pymupdf_config.get("image_dir"),
|
||||
)
|
||||
registry.register(processor, priority=15) # Higher than unstructured
|
||||
logger.info(
|
||||
f"Registered PyMuPDF processor: extract_images={pymupdf_config.get('extract_images', True)}"
|
||||
)
|
||||
registered_count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to register PyMuPDF processor: {e}")
|
||||
|
||||
# Register custom processor
|
||||
if "custom" in config["processors"]:
|
||||
custom_config = config["processors"]["custom"]
|
||||
@@ -217,6 +245,25 @@ def validate_pkce_support(discovery: dict, discovery_url: str) -> None:
|
||||
click.echo(f"✓ PKCE support validated: {code_challenge_methods}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class VectorSyncState:
|
||||
"""
|
||||
Module-level state for vector sync background tasks.
|
||||
|
||||
This singleton bridges the Starlette server lifespan (where background tasks run)
|
||||
and FastMCP session lifespans (where MCP tools need access to the streams).
|
||||
"""
|
||||
|
||||
document_send_stream: Optional[MemoryObjectSendStream] = None
|
||||
document_receive_stream: Optional[MemoryObjectReceiveStream] = None
|
||||
shutdown_event: Optional[anyio.Event] = None
|
||||
scanner_wake_event: Optional[anyio.Event] = None
|
||||
|
||||
|
||||
# Module-level singleton for vector sync state
|
||||
_vector_sync_state = VectorSyncState()
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppContext:
|
||||
"""Application context for BasicAuth mode."""
|
||||
@@ -243,17 +290,160 @@ class OAuthAppContext:
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SmitheryAppContext:
|
||||
"""Application context for Smithery stateless mode.
|
||||
|
||||
ADR-016: No shared client - clients created per-request from session config.
|
||||
"""
|
||||
|
||||
pass # No shared state needed - everything comes from session config
|
||||
|
||||
|
||||
# ADR-016: Smithery config schema for container runtime
|
||||
# This schema is served at /.well-known/mcp-config for Smithery discovery
|
||||
# See: https://smithery.ai/docs/build/session-config
|
||||
SMITHERY_CONFIG_SCHEMA = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://server.smithery.ai/nextcloud-mcp-server/.well-known/mcp-config",
|
||||
"title": "Nextcloud MCP Server Configuration",
|
||||
"description": "Configuration for connecting to your Nextcloud instance via app password authentication",
|
||||
"x-query-style": "flat", # Our schema has no nested objects, so flat style works
|
||||
"type": "object",
|
||||
"required": ["nextcloud_url", "username", "app_password"],
|
||||
"properties": {
|
||||
"nextcloud_url": {
|
||||
"type": "string",
|
||||
"title": "Nextcloud URL",
|
||||
"description": "Your Nextcloud instance URL (e.g., https://cloud.example.com). Must be publicly accessible.",
|
||||
"pattern": "^https?://.+",
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"title": "Username",
|
||||
"description": "Your Nextcloud username",
|
||||
"minLength": 1,
|
||||
},
|
||||
"app_password": {
|
||||
"type": "string",
|
||||
"title": "App Password",
|
||||
"description": "Nextcloud app password. Generate at Settings > Security > App passwords. Do NOT use your main password.",
|
||||
"minLength": 1,
|
||||
},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
# ADR-016: Context variable to hold Smithery session config per-request
|
||||
# This is set by SmitheryConfigMiddleware and accessed in context.py
|
||||
_smithery_session_config: ContextVar[dict[str, str] | None] = ContextVar(
|
||||
"smithery_session_config"
|
||||
)
|
||||
_smithery_session_config.set(None) # Set initial value
|
||||
|
||||
|
||||
def get_smithery_session_config() -> dict | None:
|
||||
"""Get the current Smithery session config from context variable.
|
||||
|
||||
Used by context.py to access config extracted from URL query parameters.
|
||||
"""
|
||||
return _smithery_session_config.get()
|
||||
|
||||
|
||||
class SmitheryConfigMiddleware:
|
||||
"""Middleware to extract Smithery config from URL query parameters.
|
||||
|
||||
ADR-016: For container runtime, Smithery passes configuration as URL query
|
||||
parameters to the /mcp endpoint. This middleware extracts those parameters
|
||||
and stores them in a context variable for access in tools.
|
||||
|
||||
Configuration parameters:
|
||||
- nextcloud_url: Nextcloud instance URL
|
||||
- username: Nextcloud username
|
||||
- app_password: Nextcloud app password
|
||||
|
||||
The extracted config is stored in a ContextVar and can be accessed via
|
||||
get_smithery_session_config() in context.py.
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp):
|
||||
self.app = app
|
||||
|
||||
async def __call__(
|
||||
self, scope: StarletteScope, receive: Receive, send: Send
|
||||
) -> None:
|
||||
if scope["type"] == "http":
|
||||
# Extract config from query parameters
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
query_string = scope.get("query_string", b"").decode("utf-8")
|
||||
params = parse_qs(query_string)
|
||||
|
||||
# Build session config from query parameters
|
||||
# Smithery uses dot notation for nested objects, but our schema is flat
|
||||
session_config = {}
|
||||
for key in ["nextcloud_url", "username", "app_password"]:
|
||||
if key in params:
|
||||
# parse_qs returns lists, take first value
|
||||
session_config[key] = params[key][0]
|
||||
|
||||
# Store in context variable for access by context.py
|
||||
if session_config:
|
||||
_smithery_session_config.set(session_config)
|
||||
logger.debug(
|
||||
f"Smithery config extracted: nextcloud_url={session_config.get('nextcloud_url')}, "
|
||||
f"username={session_config.get('username')}"
|
||||
)
|
||||
|
||||
try:
|
||||
await self.app(scope, receive, send)
|
||||
finally:
|
||||
# Clear context variable after request
|
||||
_smithery_session_config.set(None)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def app_lifespan_smithery(server: FastMCP) -> AsyncIterator[SmitheryAppContext]:
|
||||
"""
|
||||
Manage application lifecycle for Smithery stateless mode.
|
||||
|
||||
ADR-016: Minimal lifespan with no shared state.
|
||||
- No shared Nextcloud client (created per-request from session config)
|
||||
- No vector sync (disabled in Smithery mode)
|
||||
- No persistent storage (stateless deployment)
|
||||
- No document processors (not enabled in Smithery mode)
|
||||
"""
|
||||
logger.info("Starting MCP server in Smithery stateless mode")
|
||||
logger.info("Clients will be created per-request from session config")
|
||||
|
||||
try:
|
||||
yield SmitheryAppContext()
|
||||
finally:
|
||||
logger.info("Shutting down Smithery stateless mode")
|
||||
|
||||
|
||||
def is_oauth_mode() -> bool:
|
||||
"""
|
||||
Determine if OAuth mode should be used.
|
||||
|
||||
OAuth mode is enabled when:
|
||||
- NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD are NOT set
|
||||
- AND we are NOT in Smithery stateless mode
|
||||
- Or explicitly enabled via configuration
|
||||
|
||||
Returns:
|
||||
True if OAuth mode, False if BasicAuth mode
|
||||
"""
|
||||
# ADR-016: Smithery stateless mode uses per-request BasicAuth from session config
|
||||
# It's not OAuth mode even though env credentials aren't set
|
||||
deployment_mode = get_deployment_mode()
|
||||
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
|
||||
logger.info(
|
||||
"BasicAuth mode (Smithery stateless - credentials from session config)"
|
||||
)
|
||||
return False
|
||||
|
||||
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||
password = os.getenv("NEXTCLOUD_PASSWORD")
|
||||
|
||||
@@ -326,7 +516,7 @@ async def load_oauth_client_credentials(
|
||||
# and the authorization server will limit them to these allowed scopes.
|
||||
#
|
||||
# The PRM endpoint advertises the same scopes dynamically via @require_scopes decorators.
|
||||
dcr_scopes = "openid profile email notes:read notes:write calendar:read calendar:write todo:read todo:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write"
|
||||
dcr_scopes = "openid profile email notes:read notes:write calendar:read calendar:write todo:read todo:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write news:read news:write"
|
||||
|
||||
# Add offline_access scope if refresh tokens are enabled
|
||||
enable_offline_access = os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() in (
|
||||
@@ -386,15 +576,15 @@ async def load_oauth_client_credentials(
|
||||
@asynccontextmanager
|
||||
async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
|
||||
"""
|
||||
Manage application lifecycle for BasicAuth mode.
|
||||
Manage application lifecycle for BasicAuth mode (FastMCP session lifespan).
|
||||
|
||||
Creates a single Nextcloud client with basic authentication
|
||||
that is shared across all requests.
|
||||
that is shared across all requests within a session.
|
||||
|
||||
If vector sync is enabled (VECTOR_SYNC_ENABLED=true), also starts
|
||||
background tasks for automatic document indexing (ADR-007).
|
||||
Note: Background tasks (scanner, processor) are started at server level
|
||||
in starlette_lifespan, not here. This lifespan runs per-session.
|
||||
"""
|
||||
logger.info("Starting MCP server in BasicAuth mode")
|
||||
logger.info("Starting MCP session in BasicAuth mode")
|
||||
logger.info("Creating Nextcloud client with BasicAuth")
|
||||
|
||||
client = NextcloudClient.from_env()
|
||||
@@ -410,91 +600,20 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
|
||||
# Initialize document processors
|
||||
initialize_document_processors()
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Check if vector sync is enabled
|
||||
if settings.vector_sync_enabled:
|
||||
logger.info("Vector sync enabled - starting background tasks")
|
||||
|
||||
# Get username from environment for BasicAuth mode
|
||||
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||
if not username:
|
||||
raise ValueError(
|
||||
"NEXTCLOUD_USERNAME is required for vector sync in BasicAuth mode"
|
||||
)
|
||||
|
||||
# Initialize Qdrant collection before starting background tasks
|
||||
logger.info("Initializing Qdrant collection...")
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
try:
|
||||
await get_qdrant_client() # Triggers collection creation if needed
|
||||
logger.info("Qdrant collection ready")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize Qdrant collection: {e}")
|
||||
raise RuntimeError(
|
||||
f"Cannot start vector sync - Qdrant initialization failed: {e}"
|
||||
) from e
|
||||
|
||||
# Initialize shared state
|
||||
send_stream, receive_stream = anyio.create_memory_object_stream(
|
||||
max_buffer_size=settings.vector_sync_queue_max_size
|
||||
# Yield client context - scanner runs at server level (starlette_lifespan)
|
||||
# Include vector sync state from module singleton (set by starlette_lifespan)
|
||||
try:
|
||||
yield AppContext(
|
||||
client=client,
|
||||
storage=storage,
|
||||
document_send_stream=_vector_sync_state.document_send_stream,
|
||||
document_receive_stream=_vector_sync_state.document_receive_stream,
|
||||
shutdown_event=_vector_sync_state.shutdown_event,
|
||||
scanner_wake_event=_vector_sync_state.scanner_wake_event,
|
||||
)
|
||||
shutdown_event = anyio.Event()
|
||||
scanner_wake_event = anyio.Event()
|
||||
|
||||
# Start background tasks using anyio TaskGroup
|
||||
async with anyio.create_task_group() as tg:
|
||||
# Start scanner task
|
||||
await tg.start(
|
||||
scanner_task,
|
||||
send_stream,
|
||||
shutdown_event,
|
||||
scanner_wake_event,
|
||||
client,
|
||||
username,
|
||||
)
|
||||
|
||||
# Start processor pool (each gets a cloned receive stream)
|
||||
for i in range(settings.vector_sync_processor_workers):
|
||||
await tg.start(
|
||||
processor_task,
|
||||
i,
|
||||
receive_stream.clone(),
|
||||
shutdown_event,
|
||||
client,
|
||||
username,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Background sync tasks started: 1 scanner + {settings.vector_sync_processor_workers} processors"
|
||||
)
|
||||
|
||||
# Yield with background tasks running
|
||||
try:
|
||||
yield AppContext(
|
||||
client=client,
|
||||
storage=storage,
|
||||
document_send_stream=send_stream,
|
||||
document_receive_stream=receive_stream,
|
||||
shutdown_event=shutdown_event,
|
||||
scanner_wake_event=scanner_wake_event,
|
||||
)
|
||||
finally:
|
||||
# Shutdown signal
|
||||
logger.info("Shutting down background sync tasks")
|
||||
shutdown_event.set()
|
||||
|
||||
# TaskGroup automatically cancels all tasks on exit
|
||||
logger.info("Background sync tasks stopped")
|
||||
await client.close()
|
||||
else:
|
||||
# No vector sync - simple lifecycle
|
||||
try:
|
||||
yield AppContext(client=client, storage=storage)
|
||||
finally:
|
||||
logger.info("Shutting down BasicAuth mode")
|
||||
await client.close()
|
||||
finally:
|
||||
logger.info("Shutting down BasicAuth session")
|
||||
await client.close()
|
||||
|
||||
|
||||
async def setup_oauth_config():
|
||||
@@ -810,7 +929,7 @@ async def setup_oauth_config():
|
||||
)
|
||||
|
||||
|
||||
def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = None):
|
||||
# Initialize observability (logging will be configured by uvicorn)
|
||||
settings = get_settings()
|
||||
|
||||
@@ -837,8 +956,9 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
"OpenTelemetry tracing disabled (set OTEL_EXPORTER_OTLP_ENDPOINT to enable)"
|
||||
)
|
||||
|
||||
# Determine authentication mode
|
||||
# Determine authentication mode and deployment mode
|
||||
oauth_enabled = is_oauth_mode()
|
||||
deployment_mode = get_deployment_mode()
|
||||
|
||||
if oauth_enabled:
|
||||
logger.info("Configuring MCP server for OAuth mode")
|
||||
@@ -897,10 +1017,39 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
lifespan=oauth_lifespan,
|
||||
token_verifier=token_verifier,
|
||||
auth=auth_settings,
|
||||
# Disable DNS rebinding protection for containerized deployments (k8s, Docker)
|
||||
# MCP 1.23+ auto-enables this for localhost, breaking k8s service DNS names
|
||||
transport_security=TransportSecuritySettings(
|
||||
enable_dns_rebinding_protection=False
|
||||
),
|
||||
)
|
||||
else:
|
||||
logger.info("Configuring MCP server for BasicAuth mode")
|
||||
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_basic)
|
||||
# ADR-016: Use Smithery lifespan for stateless mode, BasicAuth otherwise
|
||||
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
|
||||
logger.info("Configuring MCP server for Smithery stateless mode")
|
||||
# json_response=True returns plain JSON-RPC instead of SSE format,
|
||||
# required for Smithery scanner compatibility
|
||||
mcp = FastMCP(
|
||||
"Nextcloud MCP",
|
||||
lifespan=app_lifespan_smithery,
|
||||
json_response=True,
|
||||
# Disable DNS rebinding protection for containerized deployments (k8s, Docker)
|
||||
# MCP 1.23+ auto-enables this for localhost, breaking k8s service DNS names
|
||||
transport_security=TransportSecuritySettings(
|
||||
enable_dns_rebinding_protection=False
|
||||
),
|
||||
)
|
||||
else:
|
||||
logger.info("Configuring MCP server for BasicAuth mode")
|
||||
mcp = FastMCP(
|
||||
"Nextcloud MCP",
|
||||
lifespan=app_lifespan_basic,
|
||||
# Disable DNS rebinding protection for containerized deployments (k8s, Docker)
|
||||
# MCP 1.23+ auto-enables this for localhost, breaking k8s service DNS names
|
||||
transport_security=TransportSecuritySettings(
|
||||
enable_dns_rebinding_protection=False
|
||||
),
|
||||
)
|
||||
|
||||
@mcp.resource("nc://capabilities")
|
||||
async def nc_get_capabilities():
|
||||
@@ -919,6 +1068,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
"contacts": configure_contacts_tools,
|
||||
"cookbook": configure_cookbook_tools,
|
||||
"deck": configure_deck_tools,
|
||||
"news": configure_news_tools,
|
||||
}
|
||||
|
||||
# If no specific apps are specified, enable all
|
||||
@@ -936,8 +1086,12 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
)
|
||||
|
||||
# Register semantic search tools (cross-app feature)
|
||||
# ADR-016: Skip in Smithery stateless mode (no vector database)
|
||||
settings = get_settings()
|
||||
if settings.vector_sync_enabled:
|
||||
deployment_mode = get_deployment_mode()
|
||||
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
|
||||
logger.info("Skipping semantic search tools (Smithery stateless mode)")
|
||||
elif settings.vector_sync_enabled:
|
||||
logger.info("Configuring semantic search tools (vector sync enabled)")
|
||||
configure_semantic_tools(mcp)
|
||||
else:
|
||||
@@ -1014,180 +1168,177 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
"Dynamic tool filtering enabled for OAuth mode (JWT and Bearer tokens)"
|
||||
)
|
||||
|
||||
if transport == "sse":
|
||||
mcp_app = mcp.sse_app()
|
||||
starlette_lifespan = None
|
||||
elif transport in ("http", "streamable-http"):
|
||||
mcp_app = mcp.streamable_http_app()
|
||||
mcp_app = mcp.streamable_http_app()
|
||||
|
||||
@asynccontextmanager
|
||||
async def starlette_lifespan(app: Starlette):
|
||||
# Set OAuth context for OAuth login routes (ADR-004)
|
||||
if oauth_enabled:
|
||||
# Prepare OAuth config from setup_oauth_config closure variables
|
||||
mcp_server_url = os.getenv(
|
||||
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
|
||||
)
|
||||
nextcloud_resource_uri = os.getenv(
|
||||
"NEXTCLOUD_RESOURCE_URI", nextcloud_host
|
||||
)
|
||||
discovery_url = os.getenv(
|
||||
"OIDC_DISCOVERY_URL",
|
||||
f"{nextcloud_host}/.well-known/openid-configuration",
|
||||
)
|
||||
scopes = os.getenv("NEXTCLOUD_OIDC_SCOPES", "")
|
||||
@asynccontextmanager
|
||||
async def starlette_lifespan(app: Starlette):
|
||||
# Set OAuth context for OAuth login routes (ADR-004)
|
||||
if oauth_enabled:
|
||||
# Prepare OAuth config from setup_oauth_config closure variables
|
||||
mcp_server_url = os.getenv(
|
||||
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
|
||||
)
|
||||
nextcloud_resource_uri = os.getenv("NEXTCLOUD_RESOURCE_URI", nextcloud_host)
|
||||
discovery_url = os.getenv(
|
||||
"OIDC_DISCOVERY_URL",
|
||||
f"{nextcloud_host}/.well-known/openid-configuration",
|
||||
)
|
||||
scopes = os.getenv("NEXTCLOUD_OIDC_SCOPES", "")
|
||||
|
||||
oauth_context_dict = {
|
||||
"storage": refresh_token_storage,
|
||||
"oauth_client": oauth_client,
|
||||
"token_verifier": token_verifier, # For querying IdP userinfo endpoint
|
||||
"config": {
|
||||
"mcp_server_url": mcp_server_url,
|
||||
"discovery_url": discovery_url,
|
||||
"client_id": client_id, # From setup_oauth_config (DCR or static)
|
||||
"client_secret": client_secret, # From setup_oauth_config (DCR or static)
|
||||
"scopes": scopes,
|
||||
"nextcloud_host": nextcloud_host,
|
||||
"nextcloud_resource_uri": nextcloud_resource_uri,
|
||||
"oauth_provider": oauth_provider,
|
||||
},
|
||||
}
|
||||
app.state.oauth_context = oauth_context_dict
|
||||
oauth_context_dict = {
|
||||
"storage": refresh_token_storage,
|
||||
"oauth_client": oauth_client,
|
||||
"token_verifier": token_verifier, # For querying IdP userinfo endpoint
|
||||
"config": {
|
||||
"mcp_server_url": mcp_server_url,
|
||||
"discovery_url": discovery_url,
|
||||
"client_id": client_id, # From setup_oauth_config (DCR or static)
|
||||
"client_secret": client_secret, # From setup_oauth_config (DCR or static)
|
||||
"scopes": scopes,
|
||||
"nextcloud_host": nextcloud_host,
|
||||
"nextcloud_resource_uri": nextcloud_resource_uri,
|
||||
"oauth_provider": oauth_provider,
|
||||
},
|
||||
}
|
||||
app.state.oauth_context = oauth_context_dict
|
||||
|
||||
# Also set oauth_context on browser_app for session authentication
|
||||
# browser_app is in the same function scope (defined later in create_app)
|
||||
# We need to find it in the mounted routes
|
||||
for route in app.routes:
|
||||
if isinstance(route, Mount) and route.path == "/app":
|
||||
route.app.state.oauth_context = oauth_context_dict
|
||||
logger.info(
|
||||
"OAuth context shared with browser_app for session auth"
|
||||
)
|
||||
break
|
||||
|
||||
logger.info(
|
||||
f"OAuth context initialized for login routes (client_id={client_id[:16]}...)"
|
||||
)
|
||||
else:
|
||||
# BasicAuth mode - share storage with browser_app for webhook management
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
app.state.storage = storage
|
||||
|
||||
# Also share with browser_app for webhook routes
|
||||
for route in app.routes:
|
||||
if isinstance(route, Mount) and route.path == "/app":
|
||||
route.app.state.storage = storage
|
||||
logger.info(
|
||||
"Storage shared with browser_app for webhook management"
|
||||
)
|
||||
break
|
||||
|
||||
# Start background vector sync tasks for BasicAuth mode (ADR-007)
|
||||
# For streamable-http transport, FastMCP lifespan isn't automatically triggered
|
||||
# so we manually start background tasks here if vector sync is enabled
|
||||
import anyio as anyio_module
|
||||
|
||||
settings = get_settings()
|
||||
if not oauth_enabled and settings.vector_sync_enabled:
|
||||
logger.info("Starting background vector sync tasks for BasicAuth mode")
|
||||
|
||||
# Get username from environment
|
||||
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||
if not username:
|
||||
raise ValueError(
|
||||
"NEXTCLOUD_USERNAME required for vector sync in BasicAuth mode"
|
||||
# Also set oauth_context on browser_app for session authentication
|
||||
# browser_app is in the same function scope (defined later in create_app)
|
||||
# We need to find it in the mounted routes
|
||||
for route in app.routes:
|
||||
if isinstance(route, Mount) and route.path == "/app":
|
||||
route.app.state.oauth_context = oauth_context_dict
|
||||
logger.info(
|
||||
"OAuth context shared with browser_app for session auth"
|
||||
)
|
||||
break
|
||||
|
||||
# Get Nextcloud client from MCP app context
|
||||
# Create client since we're outside FastMCP lifespan
|
||||
client = NextcloudClient.from_env()
|
||||
logger.info(
|
||||
f"OAuth context initialized for login routes (client_id={client_id[:16]}...)"
|
||||
)
|
||||
else:
|
||||
# BasicAuth mode - share storage with browser_app for webhook management
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
# Initialize Qdrant collection before starting background tasks
|
||||
logger.info("Initializing Qdrant collection...")
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
try:
|
||||
await get_qdrant_client() # Triggers collection creation if needed
|
||||
logger.info("Qdrant collection ready")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize Qdrant collection: {e}")
|
||||
raise RuntimeError(
|
||||
f"Cannot start vector sync - Qdrant initialization failed: {e}"
|
||||
) from e
|
||||
app.state.storage = storage
|
||||
|
||||
# Initialize shared state
|
||||
send_stream, receive_stream = anyio_module.create_memory_object_stream(
|
||||
max_buffer_size=settings.vector_sync_queue_max_size
|
||||
# Also share with browser_app for webhook routes
|
||||
for route in app.routes:
|
||||
if isinstance(route, Mount) and route.path == "/app":
|
||||
route.app.state.storage = storage
|
||||
logger.info(
|
||||
"Storage shared with browser_app for webhook management"
|
||||
)
|
||||
break
|
||||
|
||||
# Start background vector sync tasks for BasicAuth mode (ADR-007)
|
||||
# Scanner runs at server-level (once), not per-session
|
||||
import anyio as anyio_module
|
||||
|
||||
settings = get_settings()
|
||||
if not oauth_enabled and settings.vector_sync_enabled:
|
||||
logger.info("Starting background vector sync tasks for BasicAuth mode")
|
||||
|
||||
# Get username from environment
|
||||
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||
if not username:
|
||||
raise ValueError(
|
||||
"NEXTCLOUD_USERNAME required for vector sync in BasicAuth mode"
|
||||
)
|
||||
shutdown_event = anyio_module.Event()
|
||||
scanner_wake_event = anyio_module.Event()
|
||||
|
||||
# Store in app state for access from routes (ADR-007)
|
||||
app.state.document_send_stream = send_stream
|
||||
app.state.document_receive_stream = receive_stream
|
||||
app.state.shutdown_event = shutdown_event
|
||||
app.state.scanner_wake_event = scanner_wake_event
|
||||
# Create client for vector sync (server-level, not per-session)
|
||||
client = NextcloudClient.from_env()
|
||||
|
||||
# Also share with browser_app for /app route
|
||||
for route in app.routes:
|
||||
if isinstance(route, Mount) and route.path == "/app":
|
||||
route.app.state.document_send_stream = send_stream
|
||||
route.app.state.document_receive_stream = receive_stream
|
||||
route.app.state.shutdown_event = shutdown_event
|
||||
route.app.state.scanner_wake_event = scanner_wake_event
|
||||
logger.info(
|
||||
"Vector sync state shared with browser_app for /app"
|
||||
)
|
||||
break
|
||||
# Initialize Qdrant collection before starting background tasks
|
||||
logger.info("Initializing Qdrant collection...")
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
# Start background tasks using anyio TaskGroup
|
||||
async with anyio_module.create_task_group() as tg:
|
||||
# Start scanner task
|
||||
try:
|
||||
await get_qdrant_client() # Triggers collection creation if needed
|
||||
logger.info("Qdrant collection ready")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize Qdrant collection: {e}")
|
||||
raise RuntimeError(
|
||||
f"Cannot start vector sync - Qdrant initialization failed: {e}"
|
||||
) from e
|
||||
|
||||
# Initialize shared state
|
||||
send_stream, receive_stream = anyio_module.create_memory_object_stream(
|
||||
max_buffer_size=settings.vector_sync_queue_max_size
|
||||
)
|
||||
shutdown_event = anyio_module.Event()
|
||||
scanner_wake_event = anyio_module.Event()
|
||||
|
||||
# Store in app state for access from routes (ADR-007)
|
||||
app.state.document_send_stream = send_stream
|
||||
app.state.document_receive_stream = receive_stream
|
||||
app.state.shutdown_event = shutdown_event
|
||||
app.state.scanner_wake_event = scanner_wake_event
|
||||
|
||||
# Also store in module singleton for FastMCP session lifespans
|
||||
_vector_sync_state.document_send_stream = send_stream
|
||||
_vector_sync_state.document_receive_stream = receive_stream
|
||||
_vector_sync_state.shutdown_event = shutdown_event
|
||||
_vector_sync_state.scanner_wake_event = scanner_wake_event
|
||||
logger.info("Vector sync state stored in module singleton")
|
||||
|
||||
# Also share with browser_app for /app route
|
||||
for route in app.routes:
|
||||
if isinstance(route, Mount) and route.path == "/app":
|
||||
route.app.state.document_send_stream = send_stream
|
||||
route.app.state.document_receive_stream = receive_stream
|
||||
route.app.state.shutdown_event = shutdown_event
|
||||
route.app.state.scanner_wake_event = scanner_wake_event
|
||||
logger.info("Vector sync state shared with browser_app for /app")
|
||||
break
|
||||
|
||||
# Start background tasks using anyio TaskGroup
|
||||
async with anyio_module.create_task_group() as tg:
|
||||
# Start scanner task
|
||||
await tg.start(
|
||||
scanner_task,
|
||||
send_stream,
|
||||
shutdown_event,
|
||||
scanner_wake_event,
|
||||
client,
|
||||
username,
|
||||
)
|
||||
|
||||
# Start processor pool (each gets a cloned receive stream)
|
||||
for i in range(settings.vector_sync_processor_workers):
|
||||
await tg.start(
|
||||
scanner_task,
|
||||
send_stream,
|
||||
processor_task,
|
||||
i,
|
||||
receive_stream.clone(),
|
||||
shutdown_event,
|
||||
scanner_wake_event,
|
||||
client,
|
||||
username,
|
||||
)
|
||||
|
||||
# Start processor pool (each gets a cloned receive stream)
|
||||
for i in range(settings.vector_sync_processor_workers):
|
||||
await tg.start(
|
||||
processor_task,
|
||||
i,
|
||||
receive_stream.clone(),
|
||||
shutdown_event,
|
||||
client,
|
||||
username,
|
||||
)
|
||||
logger.info(
|
||||
f"Background sync tasks started: 1 scanner + "
|
||||
f"{settings.vector_sync_processor_workers} processors"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Background sync tasks started: 1 scanner + "
|
||||
f"{settings.vector_sync_processor_workers} processors"
|
||||
)
|
||||
|
||||
# Run MCP session manager and yield
|
||||
async with AsyncExitStack() as stack:
|
||||
await stack.enter_async_context(mcp.session_manager.run())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# Shutdown signal
|
||||
logger.info("Shutting down background sync tasks")
|
||||
shutdown_event.set()
|
||||
await client.close()
|
||||
# TaskGroup automatically cancels all tasks on exit
|
||||
else:
|
||||
# No vector sync - just run MCP session manager
|
||||
# Run MCP session manager and yield
|
||||
async with AsyncExitStack() as stack:
|
||||
await stack.enter_async_context(mcp.session_manager.run())
|
||||
yield
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# Shutdown signal
|
||||
logger.info("Shutting down background sync tasks")
|
||||
shutdown_event.set()
|
||||
await client.close()
|
||||
# TaskGroup automatically cancels all tasks on exit
|
||||
else:
|
||||
# No vector sync - just run MCP session manager
|
||||
async with AsyncExitStack() as stack:
|
||||
await stack.enter_async_context(mcp.session_manager.run())
|
||||
yield
|
||||
|
||||
# Health check endpoints for Kubernetes probes
|
||||
def health_live(request):
|
||||
@@ -1340,6 +1491,26 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
)
|
||||
logger.info("Test webhook endpoint enabled: /webhooks/nextcloud")
|
||||
|
||||
# ADR-016: Add Smithery well-known config endpoint for container runtime discovery
|
||||
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
|
||||
|
||||
def smithery_mcp_config(request):
|
||||
"""Smithery MCP configuration endpoint.
|
||||
|
||||
Returns JSON Schema for Smithery's configuration UI.
|
||||
This endpoint is required for Smithery container runtime discovery.
|
||||
"""
|
||||
return JSONResponse(SMITHERY_CONFIG_SCHEMA)
|
||||
|
||||
routes.append(
|
||||
Route(
|
||||
"/.well-known/mcp-config",
|
||||
smithery_mcp_config,
|
||||
methods=["GET"],
|
||||
)
|
||||
)
|
||||
logger.info("Smithery config endpoint enabled: /.well-known/mcp-config")
|
||||
|
||||
# Note: Metrics endpoint is NOT exposed on main HTTP port for security reasons.
|
||||
# Metrics are served on dedicated port via setup_metrics() (default: 9090)
|
||||
|
||||
@@ -1470,77 +1641,98 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
)
|
||||
|
||||
# Add user info routes (available in both BasicAuth and OAuth modes)
|
||||
# These require session authentication, so we wrap them in a separate app
|
||||
from nextcloud_mcp_server.auth.session_backend import SessionAuthBackend
|
||||
from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||
revoke_session,
|
||||
user_info_html,
|
||||
vector_sync_status_fragment,
|
||||
)
|
||||
from nextcloud_mcp_server.auth.viz_routes import (
|
||||
chunk_context_endpoint,
|
||||
vector_visualization_html,
|
||||
vector_visualization_search,
|
||||
)
|
||||
from nextcloud_mcp_server.auth.webhook_routes import (
|
||||
disable_webhook_preset,
|
||||
enable_webhook_preset,
|
||||
webhook_management_pane,
|
||||
)
|
||||
|
||||
# Create a separate Starlette app for browser routes that need session auth
|
||||
# This prevents SessionAuthBackend from interfering with FastMCP's OAuth
|
||||
browser_routes = [
|
||||
Route("/", user_info_html, methods=["GET"]), # /app → webapp (HTML UI)
|
||||
Route(
|
||||
"/revoke", revoke_session, methods=["POST"], name="revoke_session_endpoint"
|
||||
), # /app/revoke → revoke_session
|
||||
# Vector sync status fragment (htmx polling)
|
||||
Route(
|
||||
"/vector-sync/status",
|
||||
# ADR-016: Skip /app admin UI in Smithery stateless mode (no vector sync, webhooks)
|
||||
if deployment_mode != DeploymentMode.SMITHERY_STATELESS:
|
||||
# These require session authentication, so we wrap them in a separate app
|
||||
from nextcloud_mcp_server.auth.session_backend import SessionAuthBackend
|
||||
from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||
revoke_session,
|
||||
user_info_html,
|
||||
vector_sync_status_fragment,
|
||||
methods=["GET"],
|
||||
), # /app/vector-sync/status
|
||||
# Vector visualization routes
|
||||
Route(
|
||||
"/vector-viz", vector_visualization_html, methods=["GET"]
|
||||
), # /app/vector-viz
|
||||
Route(
|
||||
"/vector-viz/search",
|
||||
vector_visualization_search,
|
||||
methods=["GET"],
|
||||
), # /app/vector-viz/search
|
||||
Route(
|
||||
"/chunk-context",
|
||||
)
|
||||
from nextcloud_mcp_server.auth.viz_routes import (
|
||||
chunk_context_endpoint,
|
||||
methods=["GET"],
|
||||
), # /app/chunk-context
|
||||
# Webhook management routes (admin-only)
|
||||
Route("/webhooks", webhook_management_pane, methods=["GET"]), # /app/webhooks
|
||||
Route(
|
||||
"/webhooks/enable/{preset_id:str}", enable_webhook_preset, methods=["POST"]
|
||||
),
|
||||
Route(
|
||||
"/webhooks/disable/{preset_id:str}",
|
||||
vector_visualization_html,
|
||||
vector_visualization_search,
|
||||
)
|
||||
from nextcloud_mcp_server.auth.webhook_routes import (
|
||||
disable_webhook_preset,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
]
|
||||
enable_webhook_preset,
|
||||
webhook_management_pane,
|
||||
)
|
||||
|
||||
browser_app = Starlette(routes=browser_routes)
|
||||
browser_app.add_middleware(
|
||||
AuthenticationMiddleware, # type: ignore[invalid-argument-type]
|
||||
backend=SessionAuthBackend(oauth_enabled=oauth_enabled),
|
||||
)
|
||||
# Create a separate Starlette app for browser routes that need session auth
|
||||
# This prevents SessionAuthBackend from interfering with FastMCP's OAuth
|
||||
browser_routes = [
|
||||
Route(
|
||||
"/", user_info_html, methods=["GET"]
|
||||
), # /app → user info with all tabs
|
||||
Route(
|
||||
"/revoke",
|
||||
revoke_session,
|
||||
methods=["POST"],
|
||||
name="revoke_session_endpoint",
|
||||
), # /app/revoke → revoke_session
|
||||
# Vector sync status fragment (htmx polling)
|
||||
Route(
|
||||
"/vector-sync/status",
|
||||
vector_sync_status_fragment,
|
||||
methods=["GET"],
|
||||
), # /app/vector-sync/status
|
||||
# Vector visualization routes
|
||||
Route(
|
||||
"/vector-viz", vector_visualization_html, methods=["GET"]
|
||||
), # /app/vector-viz
|
||||
Route(
|
||||
"/vector-viz/search",
|
||||
vector_visualization_search,
|
||||
methods=["GET"],
|
||||
), # /app/vector-viz/search
|
||||
Route(
|
||||
"/chunk-context",
|
||||
chunk_context_endpoint,
|
||||
methods=["GET"],
|
||||
), # /app/chunk-context
|
||||
# Webhook management routes (admin-only)
|
||||
Route(
|
||||
"/webhooks", webhook_management_pane, methods=["GET"]
|
||||
), # /app/webhooks
|
||||
Route(
|
||||
"/webhooks/enable/{preset_id:str}",
|
||||
enable_webhook_preset,
|
||||
methods=["POST"],
|
||||
),
|
||||
Route(
|
||||
"/webhooks/disable/{preset_id:str}",
|
||||
disable_webhook_preset,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
]
|
||||
|
||||
# Add redirect from /app to /app/ (Starlette requires trailing slash for mounted apps)
|
||||
routes.append(
|
||||
Route("/app", lambda request: RedirectResponse("/app/", status_code=307))
|
||||
)
|
||||
# Add static files mount if directory exists
|
||||
static_dir = os.path.join(os.path.dirname(__file__), "auth", "static")
|
||||
if os.path.isdir(static_dir):
|
||||
browser_routes.append(
|
||||
Mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||
)
|
||||
logger.info(f"Mounted static files from {static_dir}")
|
||||
|
||||
# Mount browser app at /app (webapp and admin routes)
|
||||
routes.append(Mount("/app", app=browser_app))
|
||||
logger.info("App routes with session auth: /app, /app/webhooks, /app/revoke")
|
||||
browser_app = Starlette(routes=browser_routes)
|
||||
browser_app.add_middleware(
|
||||
AuthenticationMiddleware, # type: ignore[invalid-argument-type]
|
||||
backend=SessionAuthBackend(oauth_enabled=oauth_enabled),
|
||||
)
|
||||
|
||||
# Add redirect from /app to /app/ (Starlette requires trailing slash for mounted apps)
|
||||
routes.append(
|
||||
Route("/app", lambda request: RedirectResponse("/app/", status_code=307))
|
||||
)
|
||||
|
||||
# Mount browser app at /app (webapp and admin routes)
|
||||
routes.append(Mount("/app", app=browser_app))
|
||||
logger.info("App routes with session auth: /app, /app/webhooks, /app/revoke")
|
||||
else:
|
||||
logger.info("Admin UI (/app) disabled in Smithery stateless mode")
|
||||
|
||||
# Mount FastMCP at root last (catch-all, handles OAuth via token_verifier)
|
||||
routes.append(Mount("/", app=mcp_app))
|
||||
@@ -1660,4 +1852,11 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
|
||||
logger.info("WWW-Authenticate scope challenge handler enabled")
|
||||
|
||||
# ADR-016: Apply SmitheryConfigMiddleware in Smithery stateless mode
|
||||
# This must be the outermost middleware to extract config from URL query parameters
|
||||
# before any other middleware processes the request
|
||||
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
|
||||
app = SmitheryConfigMiddleware(app)
|
||||
logger.info("SmitheryConfigMiddleware enabled for query parameter config")
|
||||
|
||||
return app
|
||||
|
||||
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,219 @@
|
||||
.viz-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.viz-card {
|
||||
background: var(--color-main-background);
|
||||
border-radius: 0;
|
||||
padding: 16px;
|
||||
box-shadow: none;
|
||||
}
|
||||
.viz-controls-card {
|
||||
flex: 0 0 auto;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
.viz-controls-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.viz-controls-grid {
|
||||
grid-template-columns: 2fr 1.5fr 1.5fr auto auto;
|
||||
}
|
||||
}
|
||||
.viz-control-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.viz-control-group label {
|
||||
font-weight: 500;
|
||||
color: var(--color-main-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
.viz-control-group input[type="text"],
|
||||
.viz-control-group input[type="number"],
|
||||
.viz-control-group select {
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--color-border-dark);
|
||||
border-radius: var(--border-radius);
|
||||
font-size: 14px;
|
||||
background: var(--color-main-background);
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
.viz-control-group input:focus,
|
||||
.viz-control-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary-element);
|
||||
}
|
||||
.viz-control-group input[type="range"] {
|
||||
width: 100%;
|
||||
}
|
||||
.viz-control-group select[multiple] {
|
||||
min-height: 100px;
|
||||
}
|
||||
.viz-weight-display {
|
||||
display: inline-block;
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
color: #666;
|
||||
}
|
||||
.viz-btn {
|
||||
background: var(--color-primary-element);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 7px 16px;
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.viz-btn:hover {
|
||||
background: #0052a3;
|
||||
}
|
||||
.viz-btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 7px 16px;
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.viz-btn-secondary:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
.viz-card-plot {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 500px;
|
||||
height: 600px;
|
||||
/* Remove horizontal padding to extend to full viewport width */
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
margin-left: -16px;
|
||||
margin-right: -16px;
|
||||
}
|
||||
#viz-plot-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
#viz-plot {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.viz-loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}
|
||||
.viz-loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: white;
|
||||
color: #666;
|
||||
}
|
||||
.viz-no-results {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
.viz-advanced-section {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: var(--color-background-hover);
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.viz-info-box {
|
||||
background: var(--color-primary-element-light);
|
||||
border-left: 3px solid var(--color-primary-element);
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
.chunk-toggle-btn {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 4px 10px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.chunk-toggle-btn:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
.chunk-context {
|
||||
background: var(--color-background-hover);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 12px;
|
||||
margin-top: 8px;
|
||||
font-family: 'SFMono-Regular', 'Consolas', 'Liberation Mono', 'Menlo', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.chunk-text {
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
.chunk-matched {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
padding: 2px 4px;
|
||||
border-radius: var(--border-radius);
|
||||
font-weight: 500;
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
.chunk-ellipsis {
|
||||
color: var(--color-text-maxcontrast);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* PDF highlighted image styles */
|
||||
.chunk-image-container {
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius);
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
.chunk-image-header {
|
||||
background: var(--color-background-dark);
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-maxcontrast);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-family: var(--font-face);
|
||||
}
|
||||
.chunk-highlighted-image {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.chunk-highlighted-image:hover {
|
||||
opacity: 0.95;
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
// Initialize vizApp for vector visualization
|
||||
function vizApp() {
|
||||
return {
|
||||
query: '',
|
||||
algorithm: 'bm25_hybrid',
|
||||
fusion: 'rrf',
|
||||
showAdvanced: false,
|
||||
showQueryPoint: true,
|
||||
docTypes: [''],
|
||||
limit: 50,
|
||||
scoreThreshold: 0.0,
|
||||
loading: false,
|
||||
results: [],
|
||||
coordinates: null,
|
||||
queryCoords: null,
|
||||
expandedChunks: {},
|
||||
chunkLoading: {},
|
||||
|
||||
init() {
|
||||
// Set up window resize listener to resize plot
|
||||
window.addEventListener('resize', () => {
|
||||
if (this.coordinates && this.results.length > 0) {
|
||||
Plotly.Plots.resize('viz-plot');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async executeSearch() {
|
||||
this.loading = true;
|
||||
this.results = [];
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
query: this.query,
|
||||
algorithm: this.algorithm,
|
||||
limit: this.limit,
|
||||
score_threshold: this.scoreThreshold,
|
||||
});
|
||||
|
||||
if (this.algorithm === 'bm25_hybrid') {
|
||||
params.append('fusion', this.fusion);
|
||||
}
|
||||
|
||||
const selectedTypes = this.docTypes.filter(t => t !== '');
|
||||
if (selectedTypes.length > 0) {
|
||||
params.append('doc_types', selectedTypes.join(','));
|
||||
}
|
||||
|
||||
const response = await fetch(`/app/vector-viz/search?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.results = data.results;
|
||||
this.coordinates = data.coordinates_3d;
|
||||
this.queryCoords = data.query_coords;
|
||||
this.renderPlot(this.coordinates, this.queryCoords, this.results);
|
||||
} else {
|
||||
alert('Search failed: ' + data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error: ' + error.message);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
updatePlot() {
|
||||
// Toggle query point visibility without recreating the plot
|
||||
// This preserves camera position naturally since layout is untouched
|
||||
if (this.coordinates && this.queryCoords && this.results.length > 0) {
|
||||
const plotDiv = document.getElementById('viz-plot');
|
||||
|
||||
// If plot exists, just toggle the query trace visibility
|
||||
if (plotDiv && plotDiv.data && plotDiv.data.length >= 2) {
|
||||
// Trace index 1 is the query point
|
||||
Plotly.restyle('viz-plot', { visible: this.showQueryPoint }, [1]);
|
||||
} else {
|
||||
// Plot doesn't exist yet, render it
|
||||
this.renderPlot(this.coordinates, this.queryCoords, this.results);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
renderPlot(coordinates, queryCoords, results) {
|
||||
// Get container dimensions before creating layout
|
||||
const container = document.getElementById('viz-plot-container');
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
|
||||
const scores = results.map(r => r.score);
|
||||
|
||||
// Trace 1: Document results (always visible)
|
||||
const documentTrace = {
|
||||
x: coordinates.map(c => c[0]),
|
||||
y: coordinates.map(c => c[1]),
|
||||
z: coordinates.map(c => c[2]),
|
||||
mode: 'markers',
|
||||
type: 'scatter3d',
|
||||
name: 'Documents',
|
||||
visible: true,
|
||||
customdata: results.map((r, i) => ({
|
||||
title: r.title,
|
||||
raw_score: r.original_score,
|
||||
relative_score: r.score,
|
||||
x: coordinates[i][0],
|
||||
y: coordinates[i][1],
|
||||
z: coordinates[i][2]
|
||||
})),
|
||||
hovertemplate:
|
||||
'<b>%{customdata.title}</b><br>' +
|
||||
'Raw Score: %{customdata.raw_score:.3f} (%{customdata.relative_score:.0%} relative)<br>' +
|
||||
'(x=%{customdata.x}, y=%{customdata.y}, z=%{customdata.z})' +
|
||||
'<extra></extra>',
|
||||
marker: {
|
||||
size: results.map(r => 4 + (Math.pow(r.score, 2) * 10)),
|
||||
opacity: results.map(r => 0.3 + (r.score * 0.7)),
|
||||
color: scores,
|
||||
colorscale: 'Viridis',
|
||||
showscale: true,
|
||||
colorbar: {
|
||||
title: 'Relative Score',
|
||||
x: 1.02,
|
||||
xanchor: 'left',
|
||||
thickness: 20,
|
||||
len: 0.8
|
||||
},
|
||||
cmin: 0,
|
||||
cmax: 1
|
||||
}
|
||||
};
|
||||
|
||||
// Trace 2: Query point (visibility controlled by toggle)
|
||||
const queryTrace = {
|
||||
x: [queryCoords[0]],
|
||||
y: [queryCoords[1]],
|
||||
z: [queryCoords[2]],
|
||||
mode: 'markers',
|
||||
type: 'scatter3d',
|
||||
name: 'Query',
|
||||
visible: this.showQueryPoint, // Initial visibility from state
|
||||
hovertemplate:
|
||||
'<b>Search Query</b><br>' +
|
||||
`(x=${queryCoords[0]}, y=${queryCoords[1]}, z=${queryCoords[2]})` +
|
||||
'<extra></extra>',
|
||||
marker: {
|
||||
size: 10,
|
||||
color: '#ef5350', // Subdued red (Material Design Red 400)
|
||||
line: {
|
||||
color: '#c62828', // Darker red border (Material Design Red 800)
|
||||
width: 1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const layout = {
|
||||
title: `Vector Space (PCA 3D) - ${results.length} results`,
|
||||
width: width, // Explicit width from container
|
||||
height: height, // Explicit height from container
|
||||
scene: {
|
||||
xaxis: { title: 'PC1' },
|
||||
yaxis: { title: 'PC2' },
|
||||
zaxis: { title: 'PC3' },
|
||||
camera: {
|
||||
eye: { x: 1.5, y: 1.5, z: 1.5 }
|
||||
},
|
||||
// Full width for 3D scene
|
||||
domain: {
|
||||
x: [0, 1],
|
||||
y: [0, 1]
|
||||
}
|
||||
},
|
||||
hovermode: 'closest',
|
||||
autosize: true, // Enable auto-sizing for window resizes
|
||||
showlegend: false, // Hide legend
|
||||
margin: { l: 0, r: 100, t: 40, b: 0 } // Right margin for colorbar
|
||||
};
|
||||
|
||||
// Always render both traces - visibility is controlled by the visible property
|
||||
const traces = [documentTrace, queryTrace];
|
||||
|
||||
// Enable responsive resizing
|
||||
const config = {
|
||||
responsive: true,
|
||||
displayModeBar: true
|
||||
};
|
||||
|
||||
// Use newPlot() with explicit dimensions - renders at correct size immediately
|
||||
// Camera position will be preserved by subsequent Plotly.restyle() calls in updatePlot()
|
||||
Plotly.newPlot('viz-plot', traces, layout, config);
|
||||
},
|
||||
|
||||
getNextcloudUrl(result) {
|
||||
// Use global NEXTCLOUD_BASE_URL if set, otherwise construct from window location
|
||||
const baseUrl = window.NEXTCLOUD_BASE_URL || '';
|
||||
switch (result.doc_type) {
|
||||
case 'note':
|
||||
return `${baseUrl}/apps/notes/note/${result.id}`;
|
||||
case 'file':
|
||||
return `${baseUrl}/apps/files/?fileId=${result.id}`;
|
||||
case 'calendar':
|
||||
return `${baseUrl}/apps/calendar`;
|
||||
case 'contact':
|
||||
return `${baseUrl}/apps/contacts`;
|
||||
case 'deck_card':
|
||||
// URL pattern: /apps/deck/board/:boardId/card/:cardId
|
||||
if (result.metadata && result.metadata.board_id) {
|
||||
return `${baseUrl}/apps/deck/board/${result.metadata.board_id}/card/${result.id}`;
|
||||
}
|
||||
// Fallback if board_id not available
|
||||
return `${baseUrl}/apps/deck`;
|
||||
case 'news_item':
|
||||
return `${baseUrl}/apps/news/item/${result.id}`;
|
||||
default:
|
||||
return `${baseUrl}`;
|
||||
}
|
||||
},
|
||||
|
||||
hasChunkPosition(result) {
|
||||
return result.chunk_start_offset != null && result.chunk_end_offset != null;
|
||||
},
|
||||
|
||||
isChunkExpanded(resultKey) {
|
||||
return this.expandedChunks[resultKey] !== undefined;
|
||||
},
|
||||
|
||||
async toggleChunk(result) {
|
||||
const resultKey = `${result.doc_type}_${result.id}_${result.chunk_start_offset || 0}`;
|
||||
|
||||
if (this.isChunkExpanded(resultKey)) {
|
||||
delete this.expandedChunks[resultKey];
|
||||
return;
|
||||
}
|
||||
|
||||
this.chunkLoading[resultKey] = true;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
doc_type: result.doc_type,
|
||||
doc_id: result.id,
|
||||
start: result.chunk_start_offset,
|
||||
end: result.chunk_end_offset,
|
||||
context: 500
|
||||
});
|
||||
|
||||
const response = await fetch(`/app/chunk-context?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.expandedChunks[resultKey] = data;
|
||||
} else {
|
||||
alert('Failed to load chunk: ' + data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error loading chunk: ' + error.message);
|
||||
} finally {
|
||||
delete this.chunkLoading[resultKey];
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="theme-color" content="#0082c9">
|
||||
<title>{% block title %}Nextcloud MCP Server{% endblock %}</title>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 512 512'><rect width='512' height='512' rx='80' ry='80' fill='%230082C9'/><path d='M255.9 21.04c-11.8 0-22.2 4.08-28.6 10.01-5.6 4.98-8.6 11.41-8.6 18.11 0 5.55 2.2 11.01 5.9 15.48-16.4 4.97-30.1 13.64-39 24.53 22.1-7.67 45.7-11.86 70.3-11.86 24.6 0 48.3 4.19 70.3 11.86-8.9-10.89-22.6-19.56-39-24.53 3.9-4.47 5.9-9.93 5.9-15.48 0-6.7-3-13.13-8.5-18.11-6.4-5.93-16.9-10.01-28.7-10.01zm0 20.34c5.3 0 10.1 1.27 13.6 3.52 1.7 1.16 3.4 2.43 3.4 4.27 0 1.76-1.7 3.03-3.4 4.19-3.5 2.33-8.3 3.61-13.6 3.61-5.3 0-10.1-1.28-13.6-3.61-1.6-1.16-3.3-2.43-3.3-4.19 0-1.84 1.7-3.11 3.3-4.27 3.5-2.25 8.3-3.52 13.6-3.52zm.1 48.1c-110.8 0-200.72 90.02-200.72 200.82S145.2 491 256 491s200.7-89.9 200.7-200.7c0-110.8-89.9-200.82-200.7-200.82zm0 32.62c92.9 0 168.2 75.3 168.2 168.2 0 92.8-75.3 168.2-168.2 168.2-92.9 0-168.26-75.4-168.26-168.2 0-92.9 75.36-168.2 168.26-168.2zm-8.2 6.3c-9.6.5-19 1.9-28.3 4.1l2.3 7.8c8.4-2 17.1-3.3 26-3.8v-8.1zm16.2 0v8.1c9 .5 17.7 1.8 26 3.8l2.2-7.8c-9.1-2.2-18.6-3.6-28.2-4.1zm-60 8.5c-9 3.2-17.6 7-25.8 11.6l4.1 7.1c7.7-4.3 15.6-7.9 23.9-10.8l-2.2-7.9zm103.7 0-2 7.9c8.4 2.9 16.2 6.5 23.8 10.8l4.2-7.1c-8.2-4.6-16.9-8.4-26-11.6zm-143.3 20.3c-7.5 5.4-14.6 11.4-21.1 17.9l5.8 5.8c5.9-6.1 12.5-11.7 19.5-16.6l-4.2-7.1zm182.9 0-4 7.1c6.9 4.9 13.5 10.5 19.5 16.6l5.7-5.8c-6.5-6.5-13.7-12.5-21.2-17.9zm-91.4 11.5c-37 0-67.4 28.6-70.3 64.9l15.9 4.7c.7-29.6 24.7-53.4 54.4-53.4 30.1 0 54.4 24.4 54.4 54.3 0 15-6.2 28.7-16 38.5l.1.1c1.7 2.7 3 5.6 4.1 8.6.9 3 1.7 5.7 2.3 8.6v.4c33.8-16.7 57.2-51.5 57.2-91.7 0-3.8-.2-7.3-.6-10.9-3.2-3.3-6.3-6.4-9.8-9.5 1.5 6.5 2.3 13.4 2.3 20.4 0 28.7-13 54.7-33.5 71.8 6.3-10.6 10.1-23 10.1-36.3 0-38.9-31.7-70.5-70.6-70.5zm-91.8 14.6c-3.3 3.1-6.5 6.2-9.7 9.5-.3 3.6-.5 7.1-.5 10.9 0 7.3.7 14.2 2.1 20.9l9.1 2.7c-2.1-7.5-3.1-15.4-3.1-23.6 0-7 .7-13.9 2.1-20.4zm-31.6 4c-5.8 7.1-10.9 14.6-15.4 22.6l7.1 4c4.1-7.4 8.8-14.3 14-20.8l-5.7-5.8zm246.8 0-5.7 5.8c5.3 6.5 10 13.4 13.9 20.8l7.1-4c-4.4-8-9.5-15.5-15.3-22.6zm-269.2 37.1c-2.5 5.7-4.6 11.4-6.4 17.6l.1-.3c3.4-5 7.9-9.3 12.9-12.5l.3-.6-6.9-4.2zm291.8 0-7.2 4.2c3.2 7.3 5.7 15.1 7.6 23.1l7.9-2.1c-2.1-8.8-4.9-17.3-8.3-25.2zm-261.2 11.5c-13.4.1-25.7 9-29.7 22.5l114.8 34.2c-4.9 16.7 4.6 34.2 21.2 39.2L361.7 366c16.6 5 34.1-4.4 39.1-21l-114.6-34.4c4.9-16.5-4.7-34.1-21.3-39.1 0 0-72.4-21.5-114.8-34.3-3.1-.9-6.3-1.4-9.4-1.3zm-42.09 29.7c-.9 6.9-1.4 14-1.4 21.3 0 1.3.1 2.9.1 4.2h8.09v-4.2c0-6.5.4-12.9 1.2-19.2l-7.99-2.1zm314.59 0-7.9 2.1c.7 6.3 1.3 12.7 1.3 19.2 0 1.3 0 2.9-.2 4.2h8.2v-4.2c0-7.3-.5-14.4-1.4-21.3zm-157.3 24.7c6.3 0 11.5 5 11.5 11.3 0 6.4-5.2 11.6-11.5 11.6s-11.5-5.2-11.5-11.6c0-6.3 5.2-11.3 11.5-11.3zM98.51 307.4c1 8.2 2.89 16.4 5.09 24.3l7.9-2.1c-2.1-7.2-3.8-14.6-4.8-22.2h-8.19zm306.69 0c-1.1 7.6-2.7 15-4.8 22.2l7.8 2.1c2.2-7.9 4.1-16.1 5.2-24.3h-8.2zm-191.3 10.9c-19 13.3-31.4 35.3-31.4 60.1 0 10.4 2.3 20.4 6.2 29.7 8.8 4.9 17.9 8.8 27.6 11.7-10.8-10.7-17.5-25.2-17.5-41.4 0-19 9.3-36 23.7-46.3-3.8-4.1-6.7-8.7-8.6-13.8zM116.8 345l-7.9 2c3.1 7.6 6.8 14.7 11 21.6l6.9-4.2c-3.8-6.2-7-12.8-10-19.4zm194.8 20.5c.9 4.1 1.4 8.5 1.4 12.9 0 16.2-6.7 30.7-17.4 41.4 9.6-2.9 18.8-6.8 27.5-11.7 4-9.3 6.2-19.3 6.2-29.7 0-2.7-.2-5.2-.4-7.7l-17.3-5.2zM136 377.9l-7.1 4.1c4.7 6.2 9.7 12.1 15.3 17.3l5.7-5.5c-5.1-5-9.7-10.3-13.9-15.9zm243.9 2.3-.2.1c-2.1.3-4 .6-6.2.7h-.1c-3.6 4.5-7.3 8.8-11.5 12.8l5.8 5.5c5.5-5.2 10.5-11.1 15.2-17.3l-3-1.8zm-217.8 24-5.9 5.9c6 4.8 12.2 9.7 18.8 13.6l3.8-7.8c-5.7-2.9-11.4-6.8-16.7-11.7zm187.7 0c-5.4 4.9-11.1 8.8-16.8 11.7l3.9 7.8c6.5-3.9 12.8-8.8 18.7-13.6l-5.8-5.9zm-156.4 19.5-4.1 6.8c6.6 4 13.7 5.8 20.7 8.8l2.2-7.9c-6.5-1.9-12.7-4.8-18.8-7.7zm125.2 0c-6.2 2.9-12.5 5.8-19.1 7.7l2.3 7.9c7.2-3 14-4.8 20.7-8.8l-3.9-6.8zm-90.7 11.7-2 7.8c7.1 1 14.5 1.9 21.9 1.9v-7.7c-6.8 0-13.5-1.1-19.9-2zm55.9 0c-6.3.9-13 2-19.8 2v7.7c7.5 0 14.8-.9 22.1-1.9l-2.3-7.8z' fill='%23fff'/></svg>">
|
||||
|
||||
<!-- Open Sans font -->
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
src: local('Open Sans'), local('OpenSans');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: bold;
|
||||
src: local('Open Sans Semibold'), local('OpenSans-Semibold');
|
||||
}
|
||||
</style>
|
||||
|
||||
{% block extra_head %}{% endblock %}
|
||||
|
||||
<style>
|
||||
/* Nextcloud App Design System */
|
||||
|
||||
/* CSS Variables */
|
||||
:root {
|
||||
/* Primary Colors */
|
||||
--color-primary: #00679e;
|
||||
--color-primary-element: #00679e;
|
||||
--color-primary-light: #e5eff5;
|
||||
--color-primary-element-light: #e5eff5;
|
||||
|
||||
/* Background Colors */
|
||||
--color-main-background: #ffffff;
|
||||
--color-background-dark: #ededed;
|
||||
--color-background-hover: #f5f5f5;
|
||||
|
||||
/* Text Colors */
|
||||
--color-main-text: #222222;
|
||||
--color-text-maxcontrast: #6b6b6b;
|
||||
--color-text-light: #767676;
|
||||
|
||||
/* Border Colors */
|
||||
--color-border: #ededed;
|
||||
--color-border-dark: #dbdbdb;
|
||||
|
||||
/* Borders & Radius */
|
||||
--border-radius: 3px;
|
||||
--border-radius-large: 10px;
|
||||
--border-radius-pill: 100px;
|
||||
|
||||
/* Spacing */
|
||||
--default-grid-baseline: 4px;
|
||||
--default-clickable-area: 44px;
|
||||
}
|
||||
|
||||
/* SVG Icon Styles */
|
||||
.nav-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-block;
|
||||
fill: var(--color-main-text);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.app-navigation-entry.active .nav-icon {
|
||||
fill: var(--color-primary-element);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* General */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
color: var(--color-main-text);
|
||||
background: var(--color-main-background);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
font-weight: 300;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 32px;
|
||||
margin: 0 0 20px 0;
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
margin: 20px 0 12px 0;
|
||||
color: var(--color-main-text);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
margin: 16px 0 8px 0;
|
||||
color: var(--color-main-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* App Header (simplified, no full menu) */
|
||||
.app-header {
|
||||
height: 50px;
|
||||
background: var(--color-primary-element);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.app-header__brand {
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.app-header__brand:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.app-header__logo {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
fill: white;
|
||||
}
|
||||
|
||||
/* App Layout */
|
||||
.app-content-wrapper {
|
||||
display: flex;
|
||||
height: calc(100vh - 50px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Side Navigation */
|
||||
#app-navigation {
|
||||
width: 250px;
|
||||
background: var(--color-main-background);
|
||||
border-right: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
transition: margin-left 0.3s ease;
|
||||
}
|
||||
|
||||
#app-navigation.app-navigation--closed {
|
||||
margin-left: -250px;
|
||||
}
|
||||
|
||||
.app-navigation__content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-navigation-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.app-navigation-entry {
|
||||
position: relative;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.app-navigation-entry__wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.app-navigation-entry-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
min-height: var(--default-clickable-area);
|
||||
border-radius: var(--border-radius);
|
||||
transition: background-color 100ms ease-in-out;
|
||||
text-decoration: none;
|
||||
color: var(--color-main-text);
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.app-navigation-entry-link:hover {
|
||||
background-color: var(--color-background-hover);
|
||||
}
|
||||
|
||||
.app-navigation-entry.active .app-navigation-entry-link {
|
||||
background-color: var(--color-primary-element-light);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.app-navigation-entry-icon {
|
||||
width: var(--default-clickable-area);
|
||||
height: var(--default-clickable-area);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.app-navigation-entry__name {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.app-navigation-entry__counter {
|
||||
margin-left: auto;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--border-radius-pill);
|
||||
background-color: var(--color-background-dark);
|
||||
font-size: 11px;
|
||||
color: var(--color-text-maxcontrast);
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-navigation__settings {
|
||||
list-style: none;
|
||||
padding: 8px 0 0 0;
|
||||
margin: 8px 0 0 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-navigation-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
left: 10px;
|
||||
z-index: 110;
|
||||
background: var(--color-main-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 5px rgba(0,0,0,0.1);
|
||||
transition: left 0.3s ease;
|
||||
}
|
||||
|
||||
.app-navigation-toggle:hover {
|
||||
background: var(--color-background-hover);
|
||||
}
|
||||
|
||||
#app-navigation:not(.app-navigation--closed) ~ * .app-navigation-toggle {
|
||||
left: 260px;
|
||||
}
|
||||
|
||||
/* Main Content Area */
|
||||
#app-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: var(--color-main-background);
|
||||
}
|
||||
|
||||
.page-content {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.content-section {
|
||||
background: var(--color-main-background);
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.content-section h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.content-section h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
margin: 24px 0 12px 0;
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.content-section h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
#app-navigation {
|
||||
position: fixed;
|
||||
height: calc(100vh - 50px);
|
||||
z-index: 105;
|
||||
box-shadow: 2px 0 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer.page-footer {
|
||||
background-color: #0F0833;
|
||||
color: #ffffff;
|
||||
padding: 40px 0;
|
||||
margin-top: 60px;
|
||||
}
|
||||
|
||||
footer.page-footer .bootstrap-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
footer.page-footer h1 {
|
||||
font-size: 15px;
|
||||
font-weight: bold;
|
||||
line-height: 1.8;
|
||||
color: #ffffff;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
footer.page-footer ul {
|
||||
list-style-type: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
footer.page-footer li {
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
color: #ffffff;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
footer.page-footer li a {
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
footer.page-footer li a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
footer.page-footer p {
|
||||
font-size: 15px;
|
||||
line-height: 1.8;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
footer.page-footer p.copyright {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
border-radius: 50px;
|
||||
padding: 10px 20px;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #0082C9;
|
||||
border: 1px solid #0062C9;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #006ba3;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px 8px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
td:first-child {
|
||||
width: 180px;
|
||||
color: var(--color-text-maxcontrast);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: var(--color-background-dark);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--border-radius);
|
||||
font-family: 'SFMono-Regular', 'Consolas', 'Liberation Mono', 'Menlo', monospace;
|
||||
font-size: 90%;
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.badge-oauth {
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-basic {
|
||||
background-color: #2196f3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Messages */
|
||||
.warning {
|
||||
background-color: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
padding: 15px;
|
||||
margin: 15px 0;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.info-message {
|
||||
background-color: #e3f2fd;
|
||||
border-left: 4px solid #2196f3;
|
||||
padding: 15px;
|
||||
margin: 15px 0;
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: #ffebee;
|
||||
border-left: 4px solid #d32f2f;
|
||||
padding: 15px;
|
||||
margin: 15px 0;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.success {
|
||||
background-color: #e8f5e9;
|
||||
border: 2px solid #4caf50;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success h1 {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
{% block extra_styles %}{% endblock %}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- App Header -->
|
||||
<header class="app-header">
|
||||
<a href="/app" class="app-header__brand">
|
||||
<svg class="app-header__logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path d="M255.9 21.04c-11.8 0-22.2 4.08-28.6 10.01-5.6 4.98-8.6 11.41-8.6 18.11 0 5.55 2.2 11.01 5.9 15.48-16.4 4.97-30.1 13.64-39 24.53 22.1-7.67 45.7-11.86 70.3-11.86 24.6 0 48.3 4.19 70.3 11.86-8.9-10.89-22.6-19.56-39-24.53 3.9-4.47 5.9-9.93 5.9-15.48 0-6.7-3-13.13-8.5-18.11-6.4-5.93-16.9-10.01-28.7-10.01zm0 20.34c5.3 0 10.1 1.27 13.6 3.52 1.7 1.16 3.4 2.43 3.4 4.27 0 1.76-1.7 3.03-3.4 4.19-3.5 2.33-8.3 3.61-13.6 3.61-5.3 0-10.1-1.28-13.6-3.61-1.6-1.16-3.3-2.43-3.3-4.19 0-1.84 1.7-3.11 3.3-4.27 3.5-2.25 8.3-3.52 13.6-3.52zm.1 48.1c-110.8 0-200.72 90.02-200.72 200.82S145.2 491 256 491s200.7-89.9 200.7-200.7c0-110.8-89.9-200.82-200.7-200.82zm0 32.62c92.9 0 168.2 75.3 168.2 168.2 0 92.8-75.3 168.2-168.2 168.2-92.9 0-168.26-75.4-168.26-168.2 0-92.9 75.36-168.2 168.26-168.2zm-8.2 6.3c-9.6.5-19 1.9-28.3 4.1l2.3 7.8c8.4-2 17.1-3.3 26-3.8v-8.1zm16.2 0v8.1c9 .5 17.7 1.8 26 3.8l2.2-7.8c-9.1-2.2-18.6-3.6-28.2-4.1zm-60 8.5c-9 3.2-17.6 7-25.8 11.6l4.1 7.1c7.7-4.3 15.6-7.9 23.9-10.8l-2.2-7.9zm103.7 0-2 7.9c8.4 2.9 16.2 6.5 23.8 10.8l4.2-7.1c-8.2-4.6-16.9-8.4-26-11.6zm-143.3 20.3c-7.5 5.4-14.6 11.4-21.1 17.9l5.8 5.8c5.9-6.1 12.5-11.7 19.5-16.6l-4.2-7.1zm182.9 0-4 7.1c6.9 4.9 13.5 10.5 19.5 16.6l5.7-5.8c-6.5-6.5-13.7-12.5-21.2-17.9zm-91.4 11.5c-37 0-67.4 28.6-70.3 64.9l15.9 4.7c.7-29.6 24.7-53.4 54.4-53.4 30.1 0 54.4 24.4 54.4 54.3 0 15-6.2 28.7-16 38.5l.1.1c1.7 2.7 3 5.6 4.1 8.6.9 3 1.7 5.7 2.3 8.6v.4c33.8-16.7 57.2-51.5 57.2-91.7 0-3.8-.2-7.3-.6-10.9-3.2-3.3-6.3-6.4-9.8-9.5 1.5 6.5 2.3 13.4 2.3 20.4 0 28.7-13 54.7-33.5 71.8 6.3-10.6 10.1-23 10.1-36.3 0-38.9-31.7-70.5-70.6-70.5zm-91.8 14.6c-3.3 3.1-6.5 6.2-9.7 9.5-.3 3.6-.5 7.1-.5 10.9 0 7.3.7 14.2 2.1 20.9l9.1 2.7c-2.1-7.5-3.1-15.4-3.1-23.6 0-7 .7-13.9 2.1-20.4zm-31.6 4c-5.8 7.1-10.9 14.6-15.4 22.6l7.1 4c4.1-7.4 8.8-14.3 14-20.8l-5.7-5.8zm246.8 0-5.7 5.8c5.3 6.5 10 13.4 13.9 20.8l7.1-4c-4.4-8-9.5-15.5-15.3-22.6zm-269.2 37.1c-2.5 5.7-4.6 11.4-6.4 17.6l.1-.3c3.4-5 7.9-9.3 12.9-12.5l.3-.6-6.9-4.2zm291.8 0-7.2 4.2c3.2 7.3 5.7 15.1 7.6 23.1l7.9-2.1c-2.1-8.8-4.9-17.3-8.3-25.2zm-261.2 11.5c-13.4.1-25.7 9-29.7 22.5l114.8 34.2c-4.9 16.7 4.6 34.2 21.2 39.2L361.7 366c16.6 5 34.1-4.4 39.1-21l-114.6-34.4c4.9-16.5-4.7-34.1-21.3-39.1 0 0-72.4-21.5-114.8-34.3-3.1-.9-6.3-1.4-9.4-1.3zm-42.09 29.7c-.9 6.9-1.4 14-1.4 21.3 0 1.3.1 2.9.1 4.2h8.09v-4.2c0-6.5.4-12.9 1.2-19.2l-7.99-2.1zm314.59 0-7.9 2.1c.7 6.3 1.3 12.7 1.3 19.2 0 1.3 0 2.9-.2 4.2h8.2v-4.2c0-7.3-.5-14.4-1.4-21.3zm-157.3 24.7c6.3 0 11.5 5 11.5 11.3 0 6.4-5.2 11.6-11.5 11.6s-11.5-5.2-11.5-11.6c0-6.3 5.2-11.3 11.5-11.3zM98.51 307.4c1 8.2 2.89 16.4 5.09 24.3l7.9-2.1c-2.1-7.2-3.8-14.6-4.8-22.2h-8.19zm306.69 0c-1.1 7.6-2.7 15-4.8 22.2l7.8 2.1c2.2-7.9 4.1-16.1 5.2-24.3h-8.2zm-191.3 10.9c-19 13.3-31.4 35.3-31.4 60.1 0 10.4 2.3 20.4 6.2 29.7 8.8 4.9 17.9 8.8 27.6 11.7-10.8-10.7-17.5-25.2-17.5-41.4 0-19 9.3-36 23.7-46.3-3.8-4.1-6.7-8.7-8.6-13.8zM116.8 345l-7.9 2c3.1 7.6 6.8 14.7 11 21.6l6.9-4.2c-3.8-6.2-7-12.8-10-19.4zm194.8 20.5c.9 4.1 1.4 8.5 1.4 12.9 0 16.2-6.7 30.7-17.4 41.4 9.6-2.9 18.8-6.8 27.5-11.7 4-9.3 6.2-19.3 6.2-29.7 0-2.7-.2-5.2-.4-7.7l-17.3-5.2zM136 377.9l-7.1 4.1c4.7 6.2 9.7 12.1 15.3 17.3l5.7-5.5c-5.1-5-9.7-10.3-13.9-15.9zm243.9 2.3-.2.1c-2.1.3-4 .6-6.2.7h-.1c-3.6 4.5-7.3 8.8-11.5 12.8l5.8 5.5c5.5-5.2 10.5-11.1 15.2-17.3l-3-1.8zm-217.8 24-5.9 5.9c6 4.8 12.2 9.7 18.8 13.6l3.8-7.8c-5.7-2.9-11.4-6.8-16.7-11.7zm187.7 0c-5.4 4.9-11.1 8.8-16.8 11.7l3.9 7.8c6.5-3.9 12.8-8.8 18.7-13.6l-5.8-5.9zm-156.4 19.5-4.1 6.8c6.6 4 13.7 5.8 20.7 8.8l2.2-7.9c-6.5-1.9-12.7-4.8-18.8-7.7zm125.2 0c-6.2 2.9-12.5 5.8-19.1 7.7l2.3 7.9c7.2-3 14-4.8 20.7-8.8l-3.9-6.8zm-90.7 11.7-2 7.8c7.1 1 14.5 1.9 21.9 1.9v-7.7c-6.8 0-13.5-1.1-19.9-2zm55.9 0c-6.3.9-13 2-19.8 2v7.7c7.5 0 14.8-.9 22.1-1.9l-2.3-7.8z" fill="#fff"/>
|
||||
</svg>
|
||||
<span>Nextcloud MCP Server</span>
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<!-- App Content Wrapper (Sidebar + Main Content) -->
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ error_title|default('Error') }} - Nextcloud MCP Server{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ error_title|default('Error') }}</h1>
|
||||
|
||||
<div class="error">
|
||||
<strong>Error:</strong> {{ error_message }}
|
||||
</div>
|
||||
|
||||
{% if login_url %}
|
||||
<p><a href="{{ login_url }}" class="btn btn-primary">Login again</a></p>
|
||||
{% endif %}
|
||||
|
||||
{% if back_url %}
|
||||
<p><a href="{{ back_url }}" class="btn">Go Back</a></p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,21 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ success_title|default('Success') }} - Nextcloud MCP Server{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{% if redirect_url and redirect_delay %}
|
||||
<meta http-equiv="refresh" content="{{ redirect_delay }};url={{ redirect_url }}">
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="success">
|
||||
<h1>{{ success_title|default('✓ Success') }}</h1>
|
||||
{% for message in success_messages %}
|
||||
<p>{{ message }}</p>
|
||||
{% endfor %}
|
||||
{% if redirect_url %}
|
||||
<p>Redirecting...</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,650 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Nextcloud MCP Server{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<!-- htmx for dynamic loading -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
|
||||
<!-- Alpine.js for state management -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- Plotly.js for vector visualization -->
|
||||
<script src="https://cdn.plot.ly/plotly-3.3.0.min.js"></script>
|
||||
|
||||
<!-- Vector Viz static assets -->
|
||||
<link rel="stylesheet" href="/app/static/vector-viz.css">
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_styles %}
|
||||
/* Smooth htmx transitions */
|
||||
.htmx-swapping {
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease-out;
|
||||
}
|
||||
|
||||
.htmx-settling {
|
||||
opacity: 1;
|
||||
transition: opacity 200ms ease-in;
|
||||
}
|
||||
|
||||
/* Logout button styling */
|
||||
.logout-section {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Welcome tab specific styles */
|
||||
.hero-section {
|
||||
background: linear-gradient(135deg, var(--color-primary-element) 0%, #0082c9 100%);
|
||||
color: white;
|
||||
padding: 60px 24px;
|
||||
margin: -24px -24px 40px -24px;
|
||||
border-radius: 0 0 var(--border-radius-large) var(--border-radius-large);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-section h1 {
|
||||
color: white;
|
||||
font-size: 36px;
|
||||
margin: 0 0 16px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hero-section p {
|
||||
font-size: 18px;
|
||||
opacity: 0.95;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.feature-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 24px;
|
||||
margin: 32px 0;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: var(--color-main-background);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--border-radius-large);
|
||||
padding: 24px;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
border-color: var(--color-primary-element);
|
||||
box-shadow: 0 4px 12px rgba(0, 103, 158, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.feature-card h3 {
|
||||
color: var(--color-primary-element);
|
||||
font-size: 20px;
|
||||
margin: 12px 0 8px 0;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
color: var(--color-text-maxcontrast);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
margin: 8px 0 0 0;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: var(--color-primary-element-light);
|
||||
border-radius: var(--border-radius);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.feature-icon svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
fill: var(--color-primary-element);
|
||||
}
|
||||
|
||||
.info-section {
|
||||
background: var(--color-background-hover);
|
||||
border-radius: var(--border-radius-large);
|
||||
padding: 32px;
|
||||
margin: 32px 0;
|
||||
}
|
||||
|
||||
.info-section h2 {
|
||||
color: var(--color-main-text);
|
||||
font-size: 24px;
|
||||
margin: 0 0 16px 0;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.info-section p {
|
||||
color: var(--color-text-maxcontrast);
|
||||
line-height: 1.7;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.info-section ul {
|
||||
margin: 12px 0;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.info-section li {
|
||||
color: var(--color-text-maxcontrast);
|
||||
line-height: 1.7;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.info-section code {
|
||||
background: var(--color-main-background);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--border-radius);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.auth-status {
|
||||
background: var(--color-primary-element-light);
|
||||
border-left: 4px solid var(--color-primary-element);
|
||||
padding: 16px 20px;
|
||||
margin: 24px 0;
|
||||
border-radius: var(--border-radius);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.auth-status svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
fill: var(--color-primary-element);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.auth-status-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.auth-status-text strong {
|
||||
display: block;
|
||||
color: var(--color-main-text);
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.auth-status-text span {
|
||||
color: var(--color-text-maxcontrast);
|
||||
font-size: 13px;
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="app-content-wrapper" x-data="{ activeSection: 'welcome', navOpen: true }">
|
||||
<!-- Side Navigation -->
|
||||
<nav id="app-navigation" :class="{ 'app-navigation--closed': !navOpen }">
|
||||
<div class="app-navigation__content">
|
||||
<!-- Navigation List -->
|
||||
<ul class="app-navigation-list">
|
||||
<li class="app-navigation-entry" :class="{ 'active': activeSection === 'welcome' }">
|
||||
<div class="app-navigation-entry__wrapper">
|
||||
<a href="#"
|
||||
@click.prevent="activeSection = 'welcome'"
|
||||
class="app-navigation-entry-link">
|
||||
<span class="app-navigation-entry-icon">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24">
|
||||
<path d="M10,20V14H14V20H19V12H22L12,3L2,12H5V20H10Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="app-navigation-entry__name">Welcome</span>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="app-navigation-entry" :class="{ 'active': activeSection === 'user-info' }">
|
||||
<div class="app-navigation-entry__wrapper">
|
||||
<a href="#"
|
||||
@click.prevent="activeSection = 'user-info'"
|
||||
class="app-navigation-entry-link">
|
||||
<span class="app-navigation-entry-icon">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24">
|
||||
<path d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,14C16.42,14 20,15.79 20,18V20H4V18C4,15.79 7.58,14 12,14Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="app-navigation-entry__name">User Info</span>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
{% if show_vector_sync_tab %}
|
||||
<li class="app-navigation-entry" :class="{ 'active': activeSection === 'vector-sync' }">
|
||||
<div class="app-navigation-entry__wrapper">
|
||||
<a href="#"
|
||||
@click.prevent="activeSection = 'vector-sync'"
|
||||
class="app-navigation-entry-link">
|
||||
<span class="app-navigation-entry-icon">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24">
|
||||
<path d="M12,18A6,6 0 0,1 6,12C6,11 6.25,10.03 6.7,9.2L5.24,7.74C4.46,8.97 4,10.43 4,12A8,8 0 0,0 12,20V23L16,19L12,15M12,4V1L8,5L12,9V6A6,6 0 0,1 18,12C18,13 17.75,13.97 17.3,14.8L18.76,16.26C19.54,15.03 20,13.57 20,12A8,8 0 0,0 12,4Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="app-navigation-entry__name">Vector Sync</span>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="app-navigation-entry" :class="{ 'active': activeSection === 'vector-viz' }">
|
||||
<div class="app-navigation-entry__wrapper">
|
||||
<a href="#"
|
||||
@click.prevent="activeSection = 'vector-viz'"
|
||||
class="app-navigation-entry-link">
|
||||
<span class="app-navigation-entry-icon">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24">
|
||||
<path d="M22,21H2V3H4V19H6V10H10V19H12V6H16V19H18V14H22V21Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="app-navigation-entry__name">Vector Viz</span>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if show_webhooks_tab %}
|
||||
<li class="app-navigation-entry" :class="{ 'active': activeSection === 'webhooks' }">
|
||||
<div class="app-navigation-entry__wrapper">
|
||||
<a href="#"
|
||||
@click.prevent="activeSection = 'webhooks'"
|
||||
class="app-navigation-entry-link">
|
||||
<span class="app-navigation-entry-icon">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24">
|
||||
<path d="M10.59,13.41C11,13.8 11,14.44 10.59,14.83C10.2,15.22 9.56,15.22 9.17,14.83C7.22,12.88 7.22,9.71 9.17,7.76V7.76L12.71,4.22C14.66,2.27 17.83,2.27 19.78,4.22C21.73,6.17 21.73,9.34 19.78,11.29L18.29,12.78C18.3,11.96 18.17,11.14 17.89,10.36L18.36,9.88C19.54,8.71 19.54,6.81 18.36,5.64C17.19,4.46 15.29,4.46 14.12,5.64L10.59,9.17C9.41,10.34 9.41,12.24 10.59,13.41M13.41,9.17C13.8,8.78 14.44,8.78 14.83,9.17C16.78,11.12 16.78,14.29 14.83,16.24V16.24L11.29,19.78C9.34,21.73 6.17,21.73 4.22,19.78C2.27,17.83 2.27,14.66 4.22,12.71L5.71,11.22C5.7,12.04 5.83,12.86 6.11,13.65L5.64,14.12C4.46,15.29 4.46,17.19 5.64,18.36C6.81,19.54 8.71,19.54 9.88,18.36L13.41,14.83C14.59,13.66 14.59,11.76 13.41,10.59C13,10.2 13,9.56 13.41,9.17Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="app-navigation-entry__name">Webhooks</span>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
<!-- Settings/Logout at bottom -->
|
||||
{% if logout_url %}
|
||||
<ul class="app-navigation__settings">
|
||||
<li class="app-navigation-entry">
|
||||
<div class="app-navigation-entry__wrapper">
|
||||
<a href="{{ logout_url }}" class="app-navigation-entry-link">
|
||||
<span class="app-navigation-entry-icon">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24">
|
||||
<path d="M16,17V14H9V10H16V7L21,12L16,17M14,2A2,2 0 0,1 16,4V6H14V4H5V20H14V18H16V20A2,2 0 0,1 14,22H5A2,2 0 0,1 3,20V4A2,2 0 0,1 5,2H14Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="app-navigation-entry__name">Logout</span>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Toggle Button (mobile) -->
|
||||
<button @click="navOpen = !navOpen"
|
||||
class="app-navigation-toggle"
|
||||
:aria-expanded="navOpen.toString()">
|
||||
☰
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main id="app-content">
|
||||
<div class="page-content">
|
||||
<!-- Welcome Section -->
|
||||
<div x-show="activeSection === 'welcome'">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-section">
|
||||
<h1>Welcome to Nextcloud MCP Server</h1>
|
||||
<p>
|
||||
Interactive user interface for semantic search and document retrieval.
|
||||
Test queries, visualize results, and explore your Nextcloud content using RAG workflows.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Authentication Status -->
|
||||
<div class="auth-status">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,14C16.42,14 20,15.79 20,18V20H4V18C4,15.79 7.58,14 12,14Z" />
|
||||
</svg>
|
||||
<div class="auth-status-text">
|
||||
<strong>Authenticated as: {{ username }}</strong>
|
||||
<span>Authentication mode: <code>{{ auth_mode }}</code></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if vector_sync_enabled %}
|
||||
<!-- Vector Sync Enabled Content -->
|
||||
<div class="info-section">
|
||||
<h2>About Semantic Search</h2>
|
||||
<p>
|
||||
This interface provides access to <strong>semantic search</strong> capabilities powered by vector embeddings.
|
||||
Unlike traditional keyword search, semantic search understands the <em>meaning</em> of your queries and finds
|
||||
conceptually similar content across your Nextcloud apps.
|
||||
</p>
|
||||
<p>
|
||||
<strong>How it works:</strong>
|
||||
</p>
|
||||
<ul>
|
||||
<li>Documents from Notes, Calendar, Files, Contacts, and Deck are indexed into a vector database</li>
|
||||
<li>Each document chunk is converted to a 768-dimensional vector embedding that captures semantic meaning</li>
|
||||
<li>Queries are also converted to embeddings and matched against document vectors using similarity search</li>
|
||||
<li>Results can be retrieved using pure semantic search or hybrid BM25 search combining keywords and semantics</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<h2>RAG Workflow Integration</h2>
|
||||
<p>
|
||||
This UI allows you to <strong>test the same queries that Large Language Models (LLMs) would use</strong> in a
|
||||
Retrieval-Augmented Generation (RAG) workflow. When an AI assistant needs to answer questions about your data:
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Step 1:</strong> The assistant converts your question into a search query</li>
|
||||
<li><strong>Step 2:</strong> The MCP server retrieves relevant document chunks using semantic search</li>
|
||||
<li><strong>Step 3:</strong> Retrieved context is passed to the LLM to generate an informed answer</li>
|
||||
</ul>
|
||||
|
||||
<!-- RAG Workflow Diagram -->
|
||||
<div style="background: var(--color-main-background); border: 2px solid var(--color-primary-element); border-radius: var(--border-radius-large); padding: 24px; margin: 24px 0; overflow-x: auto;">
|
||||
<div style="text-align: center; font-weight: 600; margin-bottom: 20px; color: var(--color-primary-element); font-size: 16px;">
|
||||
MCP Sampling RAG Workflow
|
||||
</div>
|
||||
|
||||
<!-- Four-component bidirectional flow -->
|
||||
<div style="max-width: 1000px; margin: 0 auto;">
|
||||
<div style="display: grid; grid-template-columns: 0.7fr auto 1fr auto 1fr auto 0.9fr; gap: 10px; align-items: center;">
|
||||
<!-- User -->
|
||||
<div style="background: var(--color-background-hover); border: 2px solid var(--color-border); border-radius: var(--border-radius-large); padding: 14px; text-align: center;">
|
||||
<div style="font-size: 26px; margin-bottom: 5px;">👤</div>
|
||||
<div style="font-weight: 600; color: var(--color-main-text); font-size: 12px;">User</div>
|
||||
<div style="font-size: 9px; color: var(--color-text-maxcontrast); font-style: italic; margin-top: 5px; line-height: 1.2;">
|
||||
"What are health<br>benefits of coffee?"
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow User <-> Client -->
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 20px; color: var(--color-text-maxcontrast);">↔</div>
|
||||
</div>
|
||||
|
||||
<!-- MCP Client + LLM (combined) -->
|
||||
<div style="background: var(--color-primary-element-light); border: 2px solid var(--color-primary-element); border-radius: var(--border-radius-large); padding: 12px; text-align: center;">
|
||||
<div style="font-weight: 600; color: var(--color-primary-element); font-size: 13px; margin-bottom: 8px;">MCP Client + LLM</div>
|
||||
|
||||
<div style="background: var(--color-main-background); border-radius: var(--border-radius); padding: 8px; margin-bottom: 6px;">
|
||||
<div style="font-size: 9px; color: var(--color-text-maxcontrast);">(Claude Code)</div>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--color-main-background); border-radius: var(--border-radius); padding: 8px; border: 2px solid var(--color-primary-element);">
|
||||
<div style="font-size: 16px; margin-bottom: 2px;">🧠</div>
|
||||
<div style="font-weight: 600; color: var(--color-main-text); font-size: 10px;">Client's LLM</div>
|
||||
<div style="font-size: 8px; color: var(--color-text-maxcontrast);">(Claude)</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 8px; font-size: 8px; color: var(--color-text-maxcontrast); line-height: 1.2;">
|
||||
<strong>Enables RAG:</strong><br>
|
||||
Receives context,<br>
|
||||
generates answer
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow Client <-> Server -->
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 20px; color: var(--color-primary-element);">↔</div>
|
||||
<div style="font-size: 7px; color: var(--color-text-maxcontrast); margin-top: 2px; font-weight: 600; line-height: 1.1;">
|
||||
Query +<br>
|
||||
Sampling
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MCP Server -->
|
||||
<div style="background: var(--color-primary-element-light); border: 2px solid var(--color-primary-element); border-radius: var(--border-radius-large); padding: 12px; text-align: center;">
|
||||
<div style="font-weight: 600; color: var(--color-primary-element); font-size: 13px; margin-bottom: 8px;">MCP Server</div>
|
||||
|
||||
<div style="background: var(--color-main-background); border-radius: var(--border-radius); padding: 7px; margin-bottom: 5px;">
|
||||
<div style="font-weight: 600; color: var(--color-main-text); font-size: 9px; margin-bottom: 2px;">1. Semantic Search</div>
|
||||
<div style="font-size: 7px; color: var(--color-text-maxcontrast); line-height: 1.2;">
|
||||
Vector embeddings<br>
|
||||
BM25 Hybrid + RRF
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--color-main-background); border-radius: var(--border-radius); padding: 7px; margin-bottom: 5px;">
|
||||
<div style="font-weight: 600; color: var(--color-main-text); font-size: 9px; margin-bottom: 2px;">2. Retrieve Context</div>
|
||||
<div style="font-size: 7px; color: var(--color-text-maxcontrast); line-height: 1.2;">
|
||||
Top relevant docs<br>
|
||||
with scores
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--color-main-background); border-radius: var(--border-radius); padding: 7px; margin-bottom: 5px;">
|
||||
<div style="font-weight: 600; color: var(--color-main-text); font-size: 9px; margin-bottom: 2px;">3. Format Response</div>
|
||||
<div style="font-size: 7px; color: var(--color-text-maxcontrast); line-height: 1.2;">
|
||||
Document chunks<br>
|
||||
with citations
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--color-main-background); border-radius: var(--border-radius); padding: 7px;">
|
||||
<div style="font-weight: 600; color: var(--color-main-text); font-size: 9px; margin-bottom: 2px;">4. Send to LLM</div>
|
||||
<div style="font-size: 7px; color: var(--color-text-maxcontrast); line-height: 1.2;">
|
||||
Via MCP sampling<br>
|
||||
for answer generation
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow Server <-> Nextcloud -->
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 20px; color: var(--color-primary-element);">↔</div>
|
||||
<div style="font-size: 7px; color: var(--color-text-maxcontrast); margin-top: 2px; font-weight: 600; line-height: 1.1;">
|
||||
Retrieve
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nextcloud -->
|
||||
<div style="background: var(--color-background-hover); border: 2px solid var(--color-border); border-radius: var(--border-radius-large); padding: 12px; text-align: center; position: relative;">
|
||||
<img src="/app/static/nextcloud-logo.png" alt="Nextcloud" style="width: 40px; height: 40px; margin-bottom: 6px;" />
|
||||
<div style="font-weight: 600; color: var(--color-main-text); font-size: 12px; margin-bottom: 4px;">Nextcloud</div>
|
||||
<div style="font-size: 8px; color: var(--color-text-maxcontrast); line-height: 1.2;">
|
||||
Notes, Calendar,<br>
|
||||
Files, Contacts,<br>
|
||||
Deck
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Explanation below diagram -->
|
||||
<div style="margin-top: 24px; padding: 16px; background: var(--color-background-hover); border-radius: var(--border-radius); border-left: 4px solid var(--color-primary-element);">
|
||||
<div style="font-size: 12px; color: var(--color-main-text); line-height: 1.6;">
|
||||
<strong>How RAG works via MCP Sampling:</strong>
|
||||
</div>
|
||||
<ol style="margin: 8px 0 0 0; padding-left: 20px; font-size: 11px; color: var(--color-text-maxcontrast); line-height: 1.6;">
|
||||
<li>User asks question through MCP Client</li>
|
||||
<li>Client sends query to MCP Server</li>
|
||||
<li>Server retrieves relevant document context from Nextcloud</li>
|
||||
<li><strong>Server sends context back to Client's LLM</strong> (MCP Sampling)</li>
|
||||
<li>Client's LLM generates answer with citations using retrieved context</li>
|
||||
<li>Answer returned to user</li>
|
||||
</ol>
|
||||
<div style="margin-top: 8px; font-size: 10px; color: var(--color-text-maxcontrast); font-style: italic;">
|
||||
The server has no LLM - it only retrieves context. The client's existing LLM is reused for answer generation.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style="margin-top: 16px;">
|
||||
<strong>Key Point:</strong> The MCP server retrieves context but doesn't generate answers itself.
|
||||
Through <strong>MCP sampling</strong>, it requests the client's LLM to generate responses, giving users
|
||||
full control over which model is used and ensuring all processing happens client-side.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
By using this interface, you can preview search results, understand relevance scores, and verify
|
||||
that the system retrieves the right information before it reaches the LLM.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature Cards -->
|
||||
<h2>Available Features</h2>
|
||||
<div class="feature-grid">
|
||||
<a href="#" @click.prevent="activeSection = 'user-info'" class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,14C16.42,14 20,15.79 20,18V20H4V18C4,15.79 7.58,14 12,14Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>User Information</h3>
|
||||
<p>
|
||||
View your authentication details, session information, and IdP profile.
|
||||
Manage background access permissions.
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a href="#" @click.prevent="activeSection = 'vector-sync'" class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M12,18A6,6 0 0,1 6,12C6,11 6.25,10.03 6.7,9.2L5.24,7.74C4.46,8.97 4,10.43 4,12A8,8 0 0,0 12,20V23L16,19L12,15M12,4V1L8,5L12,9V6A6,6 0 0,1 18,12C18,13 17.75,13.97 17.3,14.8L18.76,16.26C19.54,15.03 20,13.57 20,12A8,8 0 0,0 12,4Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Vector Sync Status</h3>
|
||||
<p>
|
||||
Monitor real-time indexing progress with metrics for indexed documents, pending queue,
|
||||
and synchronization status.
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a href="#" @click.prevent="activeSection = 'vector-viz'" class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M22,21H2V3H4V19H6V10H10V19H12V6H16V19H18V14H22V21Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Vector Visualization</h3>
|
||||
<p>
|
||||
Interactive search interface with 2D PCA visualization. Compare algorithms,
|
||||
view relevance scores, and explore matched document chunks.
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<!-- Vector Sync Disabled Content -->
|
||||
<div class="warning">
|
||||
<h3 style="margin-top: 0;">Vector Sync is Disabled</h3>
|
||||
<p>
|
||||
Semantic search and vector visualization features are currently disabled.
|
||||
To enable these features, set <code>VECTOR_SYNC_ENABLED=true</code> in your environment configuration.
|
||||
</p>
|
||||
<p style="margin-bottom: 0;">
|
||||
<strong>Learn more:</strong>
|
||||
<a href="https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/configuration.md" target="_blank" style="color: inherit; text-decoration: underline;">
|
||||
Configuration Guide
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Limited Feature Card -->
|
||||
<h2>Available Features</h2>
|
||||
<div class="feature-grid">
|
||||
<a href="#" @click.prevent="activeSection = 'user-info'" class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,14C16.42,14 20,15.79 20,18V20H4V18C4,15.79 7.58,14 12,14Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>User Information</h3>
|
||||
<p>
|
||||
View your authentication details, session information, and IdP profile.
|
||||
Manage background access permissions.
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Documentation Section -->
|
||||
<div class="info-section" style="margin-top: 40px;">
|
||||
<h2>Documentation</h2>
|
||||
<p>
|
||||
For detailed information about configuration, authentication modes, and advanced features,
|
||||
please refer to the project documentation:
|
||||
</p>
|
||||
<ul>
|
||||
<li><a href="https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/installation.md" target="_blank">Installation Guide</a></li>
|
||||
<li><a href="https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/configuration.md" target="_blank">Configuration Options</a></li>
|
||||
<li><a href="https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/authentication.md" target="_blank">Authentication Modes</a></li>
|
||||
{% if vector_sync_enabled %}
|
||||
<li><a href="https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/user-guide/vector-sync-ui.md" target="_blank">Vector Sync UI Guide</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Info Section -->
|
||||
<div x-show="activeSection === 'user-info'">
|
||||
<div class="content-section">
|
||||
<h1>User Information</h1>
|
||||
{{ user_info_tab_html|safe }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if show_vector_sync_tab %}
|
||||
<!-- Vector Sync Section -->
|
||||
<div x-show="activeSection === 'vector-sync'">
|
||||
<div class="content-section">
|
||||
<h1>Vector Sync Status</h1>
|
||||
{{ vector_sync_tab_html|safe }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vector Viz Section -->
|
||||
<div x-show="activeSection === 'vector-viz'">
|
||||
<div class="content-section">
|
||||
<h1>Vector Visualization</h1>
|
||||
<div hx-get="/app/vector-viz" hx-trigger="load" hx-swap="outerHTML">
|
||||
<p style="color: #999;">Loading vector visualization...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if show_webhooks_tab %}
|
||||
<!-- Webhooks Section -->
|
||||
<div x-show="activeSection === 'webhooks'">
|
||||
<div class="content-section">
|
||||
<h1>Webhook Management</h1>
|
||||
{{ webhooks_tab_html|safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Set global Nextcloud base URL for use in external JS
|
||||
window.NEXTCLOUD_BASE_URL = '{{ nextcloud_host_for_links }}';
|
||||
</script>
|
||||
<script src="/app/static/vector-viz.js"></script>
|
||||
{% endblock %}
|
||||
@@ -1,286 +1,115 @@
|
||||
<style>
|
||||
.viz-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.viz-controls {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.viz-control-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr auto;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
.viz-control-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.viz-control-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
.viz-control-group input[type="text"],
|
||||
.viz-control-group input[type="number"],
|
||||
.viz-control-group select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.viz-control-group input[type="range"] {
|
||||
width: 100%;
|
||||
}
|
||||
.viz-control-group select[multiple] {
|
||||
min-height: 100px;
|
||||
}
|
||||
.viz-weight-display {
|
||||
display: inline-block;
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
color: #666;
|
||||
}
|
||||
.viz-btn {
|
||||
background: #0066cc;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.viz-btn:hover {
|
||||
background: #0052a3;
|
||||
}
|
||||
.viz-btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.viz-btn-secondary:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
#viz-plot-container {
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
position: relative;
|
||||
}
|
||||
#viz-plot {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.viz-loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}
|
||||
.viz-loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: white;
|
||||
color: #666;
|
||||
}
|
||||
.viz-no-results {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
.viz-advanced-section {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
.viz-advanced-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
.viz-info-box {
|
||||
background: #e3f2fd;
|
||||
border-left: 4px solid #2196f3;
|
||||
padding: 12px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.chunk-toggle-btn {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 4px 10px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.chunk-toggle-btn:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
.chunk-context {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
margin-top: 8px;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.chunk-text {
|
||||
color: #666;
|
||||
}
|
||||
.chunk-matched {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
.chunk-ellipsis {
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div x-data="vizApp()">
|
||||
<div class="viz-card">
|
||||
<h2>Vector Visualization</h2>
|
||||
<div class="viz-info-box">
|
||||
Testing search algorithms on your indexed documents. User: <strong>{{ username }}</strong>
|
||||
</div>
|
||||
<div class="viz-layout">
|
||||
<!-- Top: Search Controls -->
|
||||
<div class="viz-card viz-controls-card">
|
||||
<form @submit.prevent="executeSearch">
|
||||
<div class="viz-controls-grid">
|
||||
<div class="viz-control-group">
|
||||
<label>Search Query</label>
|
||||
<input type="text" x-model="query" placeholder="Enter search query..." required />
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="executeSearch">
|
||||
<div class="viz-controls">
|
||||
<!-- Main Controls -->
|
||||
<div class="viz-control-group">
|
||||
<label>Search Query</label>
|
||||
<input type="text" x-model="query" placeholder="Enter search query..." required />
|
||||
</div>
|
||||
|
||||
<div class="viz-control-row">
|
||||
<div class="viz-control-group" style="margin-bottom: 0;">
|
||||
<div class="viz-control-group">
|
||||
<label>Algorithm</label>
|
||||
<select x-model="algorithm">
|
||||
<option value="semantic">Semantic (Dense Vectors)</option>
|
||||
<option value="bm25_hybrid" selected>BM25 Hybrid (Dense + Sparse)</option>
|
||||
<option value="semantic">Semantic (Dense)</option>
|
||||
<option value="bm25_hybrid" selected>BM25 Hybrid</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="viz-control-group" style="margin-bottom: 0;">
|
||||
<label>Fusion Method</label>
|
||||
<div class="viz-control-group">
|
||||
<label>Fusion</label>
|
||||
<select x-model="fusion" :disabled="algorithm !== 'bm25_hybrid'" :style="algorithm !== 'bm25_hybrid' ? 'opacity: 0.5; cursor: not-allowed;' : ''">
|
||||
<option value="rrf" selected>RRF (Reciprocal Rank Fusion)</option>
|
||||
<option value="dbsf">DBSF (Distribution-Based Score Fusion)</option>
|
||||
<option value="rrf" selected>RRF</option>
|
||||
<option value="dbsf">DBSF</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: flex-end;">
|
||||
<button type="submit" class="viz-btn" style="width: 100%;">Search & Visualize</button>
|
||||
<div class="viz-control-group">
|
||||
<label> </label>
|
||||
<button type="submit" class="viz-btn">Search</button>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: flex-end;">
|
||||
<button type="button" class="viz-btn-secondary" @click="showAdvanced = !showAdvanced" style="white-space: nowrap;">
|
||||
<span x-text="showAdvanced ? 'Hide Advanced' : 'Advanced'"></span>
|
||||
<div class="viz-control-group">
|
||||
<label> </label>
|
||||
<button type="button" class="viz-btn-secondary" @click="showAdvanced = !showAdvanced">
|
||||
<span x-text="showAdvanced ? 'Hide' : 'Advanced'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Options (Collapsible) -->
|
||||
<div class="viz-advanced-section" x-show="showAdvanced" x-transition.opacity.duration.200ms>
|
||||
<h3 style="margin-top: 0; margin-bottom: 16px; font-size: 16px;">Advanced Options</h3>
|
||||
|
||||
<div class="viz-advanced-grid">
|
||||
<div x-show="showAdvanced" style="margin-top: 16px;">
|
||||
<div class="viz-controls-grid" style="grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));">
|
||||
<div class="viz-control-group">
|
||||
<label style="display: block; margin-bottom: 8px;">Document Types</label>
|
||||
<div style="display: grid; grid-template-columns: 1fr; gap: 6px;">
|
||||
<label>Document Types</label>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 13px;">
|
||||
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;">
|
||||
<input type="checkbox" x-model="docTypes" value="" style="margin-right: 8px;">
|
||||
<span>All Types</span>
|
||||
<input type="checkbox" x-model="docTypes" value="" style="margin-right: 4px;">
|
||||
<span>All</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;">
|
||||
<input type="checkbox" x-model="docTypes" value="note" style="margin-right: 8px;">
|
||||
<input type="checkbox" x-model="docTypes" value="note" style="margin-right: 4px;">
|
||||
<span>Notes</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;">
|
||||
<input type="checkbox" x-model="docTypes" value="file" style="margin-right: 8px;">
|
||||
<input type="checkbox" x-model="docTypes" value="file" style="margin-right: 4px;">
|
||||
<span>Files</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;">
|
||||
<input type="checkbox" x-model="docTypes" value="calendar" style="margin-right: 8px;">
|
||||
<span>Calendar Events</span>
|
||||
<input type="checkbox" x-model="docTypes" value="calendar" style="margin-right: 4px;">
|
||||
<span>Calendar</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;">
|
||||
<input type="checkbox" x-model="docTypes" value="contact" style="margin-right: 8px;">
|
||||
<input type="checkbox" x-model="docTypes" value="contact" style="margin-right: 4px;">
|
||||
<span>Contacts</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;">
|
||||
<input type="checkbox" x-model="docTypes" value="deck" style="margin-right: 8px;">
|
||||
<input type="checkbox" x-model="docTypes" value="deck_card" style="margin-right: 4px;">
|
||||
<span>Deck Cards</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;">
|
||||
<input type="checkbox" x-model="docTypes" value="news_item" style="margin-right: 4px;">
|
||||
<span>News</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="viz-control-group">
|
||||
<label>Score Threshold (Semantic/Hybrid)</label>
|
||||
<input type="number" x-model.number="scoreThreshold" min="0" max="1" step="any" />
|
||||
</div>
|
||||
<div class="viz-control-group">
|
||||
<label>Score Threshold</label>
|
||||
<input type="number" x-model.number="scoreThreshold" min="0" max="1" step="any" />
|
||||
</div>
|
||||
|
||||
<div class="viz-control-group">
|
||||
<label>Result Limit</label>
|
||||
<input type="number" x-model.number="limit" min="1" max="100" />
|
||||
</div>
|
||||
<div class="viz-control-group">
|
||||
<label>Result Limit</label>
|
||||
<input type="number" x-model.number="limit" min="1" max="1000" />
|
||||
</div>
|
||||
|
||||
<div class="viz-control-group">
|
||||
<label>Display Options</label>
|
||||
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal; margin-top: 4px;">
|
||||
<input type="checkbox" x-model="showQueryPoint" @change="updatePlot()" style="margin-right: 6px;">
|
||||
<span>Show Query Point</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info: BM25 Hybrid fusion methods -->
|
||||
<div x-show="algorithm === 'bm25_hybrid'" style="margin-top: 16px; padding: 12px; background: #e9ecef; border-radius: 4px;">
|
||||
<p style="margin: 0; font-size: 14px; color: #666;">
|
||||
<strong>BM25 Hybrid Search:</strong> Combines dense semantic vectors with sparse BM25 keyword vectors.
|
||||
</p>
|
||||
<p style="margin: 8px 0 0 0; font-size: 13px; color: #666;">
|
||||
<strong>RRF:</strong> Reciprocal Rank Fusion - Rank-based fusion producing scores in [0.0, 1.0]
|
||||
</p>
|
||||
<p style="margin: 4px 0 0 0; font-size: 13px; color: #666;">
|
||||
<strong>DBSF:</strong> Distribution-Based Score Fusion - Sums normalized scores (can exceed 1.0)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="viz-card">
|
||||
<div id="viz-plot-container">
|
||||
<div x-show="loading" class="viz-loading-overlay" x-transition.opacity.duration.200ms>
|
||||
Executing search and computing PCA projection...
|
||||
</div>
|
||||
<div id="viz-plot" x-show="!loading" x-transition.opacity.duration.200ms></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="viz-card">
|
||||
<h3>Search Results (<span x-text="loading ? '...' : results.length"></span>)</h3>
|
||||
<!-- Plot -->
|
||||
<div class="viz-card viz-card-plot">
|
||||
<div id="viz-plot-container">
|
||||
<div x-show="loading" class="viz-loading-overlay" x-transition.opacity.duration.200ms>
|
||||
Executing search and computing PCA projection...
|
||||
</div>
|
||||
<div id="viz-plot" x-show="!loading" x-transition.opacity.duration.200ms></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div class="viz-card" style="flex: 0 0 auto;">
|
||||
<h3 style="margin-top: 0;">Search Results (<span x-text="loading ? '...' : results.length"></span>)</h3>
|
||||
|
||||
<div x-show="loading" class="viz-loading" x-transition.opacity.duration.200ms>
|
||||
Loading results...
|
||||
@@ -292,12 +121,13 @@
|
||||
|
||||
<template x-if="!loading && results.length > 0">
|
||||
<div x-transition.opacity.duration.200ms>
|
||||
<template x-for="result in results" :key="result.id">
|
||||
<template x-for="result in results" :key="`${result.doc_type}_${result.id}_${result.chunk_start_offset || 0}`">
|
||||
<div style="padding: 12px; border-bottom: 1px solid #eee;">
|
||||
<a :href="getNextcloudUrl(result)" target="_blank" style="font-weight: 500; color: #0066cc; text-decoration: none;">
|
||||
<span x-text="result.title"></span>
|
||||
</a>
|
||||
<div style="font-size: 14px; color: #666; margin-top: 4px;" x-text="result.excerpt"></div>
|
||||
<div style="font-size: 14px; color: #666; margin-top: 4px;"
|
||||
x-text="result.excerpt.length > 200 ? result.excerpt.substring(0, 200) + '...' : result.excerpt"></div>
|
||||
<div style="font-size: 12px; color: #999; margin-top: 4px;">
|
||||
Raw Score: <span x-text="result.original_score.toFixed(3)"></span>
|
||||
(<span x-text="(result.score * 100).toFixed(0)"></span>% relative) |
|
||||
@@ -309,22 +139,36 @@
|
||||
<button
|
||||
class="chunk-toggle-btn"
|
||||
@click="toggleChunk(result)"
|
||||
x-text="isChunkExpanded(`${result.doc_type}_${result.id}`) ? 'Hide Chunk' : 'Show Chunk'"
|
||||
x-text="isChunkExpanded(`${result.doc_type}_${result.id}_${result.chunk_start_offset || 0}`) ? 'Hide Chunk' : 'Show Chunk'"
|
||||
></button>
|
||||
</template>
|
||||
|
||||
<!-- Chunk context (expanded inline) -->
|
||||
<template x-if="isChunkExpanded(`${result.doc_type}_${result.id}`)">
|
||||
<template x-if="isChunkExpanded(`${result.doc_type}_${result.id}_${result.chunk_start_offset || 0}`)">
|
||||
<div class="chunk-context" x-transition.opacity.duration.200ms>
|
||||
<template x-if="chunkLoading[`${result.doc_type}_${result.id}`]">
|
||||
<template x-if="chunkLoading[`${result.doc_type}_${result.id}_${result.chunk_start_offset || 0}`]">
|
||||
<div style="color: #666; font-style: italic;">Loading chunk...</div>
|
||||
</template>
|
||||
<template x-if="!chunkLoading[`${result.doc_type}_${result.id}`]">
|
||||
<template x-if="!chunkLoading[`${result.doc_type}_${result.id}_${result.chunk_start_offset || 0}`]">
|
||||
<div>
|
||||
<template x-if="expandedChunks[`${result.doc_type}_${result.id}`]?.has_more_before">
|
||||
<!-- Highlighted page image for PDFs -->
|
||||
<template x-if="expandedChunks[`${result.doc_type}_${result.id}_${result.chunk_start_offset || 0}`]?.highlighted_page_image">
|
||||
<div class="chunk-image-container">
|
||||
<div class="chunk-image-header">
|
||||
<span>Page <span x-text="expandedChunks[`${result.doc_type}_${result.id}_${result.chunk_start_offset || 0}`]?.page_number"></span></span>
|
||||
</div>
|
||||
<img
|
||||
:src="'data:image/png;base64,' + expandedChunks[`${result.doc_type}_${result.id}_${result.chunk_start_offset || 0}`]?.highlighted_page_image"
|
||||
:alt="'Page ' + expandedChunks[`${result.doc_type}_${result.id}_${result.chunk_start_offset || 0}`]?.page_number"
|
||||
class="chunk-highlighted-image"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Text context -->
|
||||
<template x-if="expandedChunks[`${result.doc_type}_${result.id}_${result.chunk_start_offset || 0}`]?.has_more_before">
|
||||
<span class="chunk-ellipsis">...</span>
|
||||
</template>
|
||||
<span class="chunk-text" x-text="expandedChunks[`${result.doc_type}_${result.id}`]?.before_context"></span><span class="chunk-matched" x-text="expandedChunks[`${result.doc_type}_${result.id}`]?.chunk_text"></span><span class="chunk-text" x-text="expandedChunks[`${result.doc_type}_${result.id}`]?.after_context"></span><template x-if="expandedChunks[`${result.doc_type}_${result.id}`]?.has_more_after">
|
||||
<span class="chunk-text" x-text="expandedChunks[`${result.doc_type}_${result.id}_${result.chunk_start_offset || 0}`]?.before_context"></span><span class="chunk-matched" x-text="expandedChunks[`${result.doc_type}_${result.id}_${result.chunk_start_offset || 0}`]?.chunk_text"></span><span class="chunk-text" x-text="expandedChunks[`${result.doc_type}_${result.id}_${result.chunk_start_offset || 0}`]?.after_context"></span><template x-if="expandedChunks[`${result.doc_type}_${result.id}_${result.chunk_start_offset || 0}`]?.has_more_after">
|
||||
<span class="chunk-ellipsis">...</span>
|
||||
</template>
|
||||
</div>
|
||||
@@ -335,5 +179,6 @@
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- Search Results -->
|
||||
</div><!-- .viz-layout -->
|
||||
</div><!-- x-data="vizApp()" -->
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Welcome - Nextcloud MCP Server{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<!-- Alpine.js for interactive elements -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_styles %}
|
||||
/* Welcome page specific styles */
|
||||
.hero-section {
|
||||
background: linear-gradient(135deg, var(--color-primary-element) 0%, #0082c9 100%);
|
||||
color: white;
|
||||
padding: 60px 24px;
|
||||
margin: -24px -24px 40px -24px;
|
||||
border-radius: 0 0 var(--border-radius-large) var(--border-radius-large);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-section h1 {
|
||||
color: white;
|
||||
font-size: 36px;
|
||||
margin: 0 0 16px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hero-section p {
|
||||
font-size: 18px;
|
||||
opacity: 0.95;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.feature-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 24px;
|
||||
margin: 32px 0;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: var(--color-main-background);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--border-radius-large);
|
||||
padding: 24px;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
border-color: var(--color-primary-element);
|
||||
box-shadow: 0 4px 12px rgba(0, 103, 158, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.feature-card h3 {
|
||||
color: var(--color-primary-element);
|
||||
font-size: 20px;
|
||||
margin: 12px 0 8px 0;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
color: var(--color-text-maxcontrast);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
margin: 8px 0 0 0;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: var(--color-primary-element-light);
|
||||
border-radius: var(--border-radius);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.feature-icon svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
fill: var(--color-primary-element);
|
||||
}
|
||||
|
||||
.info-section {
|
||||
background: var(--color-background-hover);
|
||||
border-radius: var(--border-radius-large);
|
||||
padding: 32px;
|
||||
margin: 32px 0;
|
||||
}
|
||||
|
||||
.info-section h2 {
|
||||
color: var(--color-main-text);
|
||||
font-size: 24px;
|
||||
margin: 0 0 16px 0;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.info-section p {
|
||||
color: var(--color-text-maxcontrast);
|
||||
line-height: 1.7;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.info-section ul {
|
||||
margin: 12px 0;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.info-section li {
|
||||
color: var(--color-text-maxcontrast);
|
||||
line-height: 1.7;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.info-section code {
|
||||
background: var(--color-main-background);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--border-radius);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.auth-status {
|
||||
background: var(--color-primary-element-light);
|
||||
border-left: 4px solid var(--color-primary-element);
|
||||
padding: 16px 20px;
|
||||
margin: 24px 0;
|
||||
border-radius: var(--border-radius);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.auth-status svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
fill: var(--color-primary-element);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.auth-status-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.auth-status-text strong {
|
||||
display: block;
|
||||
color: var(--color-main-text);
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.auth-status-text span {
|
||||
color: var(--color-text-maxcontrast);
|
||||
font-size: 13px;
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="app-content-wrapper">
|
||||
<!-- Main Content Area -->
|
||||
<main id="app-content">
|
||||
<div class="page-content">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-section">
|
||||
<h1>Welcome to Nextcloud MCP Server</h1>
|
||||
<p>
|
||||
Interactive user interface for semantic search and document retrieval.
|
||||
Test queries, visualize results, and explore your Nextcloud content using RAG workflows.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Authentication Status -->
|
||||
<div class="auth-status">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,14C16.42,14 20,15.79 20,18V20H4V18C4,15.79 7.58,14 12,14Z" />
|
||||
</svg>
|
||||
<div class="auth-status-text">
|
||||
<strong>Authenticated as: {{ username }}</strong>
|
||||
<span>Authentication mode: <code>{{ auth_mode }}</code></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if vector_sync_enabled %}
|
||||
<!-- Vector Sync Enabled Content -->
|
||||
<div class="info-section">
|
||||
<h2>About Semantic Search</h2>
|
||||
<p>
|
||||
This interface provides access to <strong>semantic search</strong> capabilities powered by vector embeddings.
|
||||
Unlike traditional keyword search, semantic search understands the <em>meaning</em> of your queries and finds
|
||||
conceptually similar content across your Nextcloud apps.
|
||||
</p>
|
||||
<p>
|
||||
<strong>How it works:</strong>
|
||||
</p>
|
||||
<ul>
|
||||
<li>Documents from Notes, Calendar, Files, Contacts, and Deck are indexed into a vector database</li>
|
||||
<li>Each document chunk is converted to a 768-dimensional vector embedding that captures semantic meaning</li>
|
||||
<li>Queries are also converted to embeddings and matched against document vectors using similarity search</li>
|
||||
<li>Results can be retrieved using pure semantic search or hybrid BM25 search combining keywords and semantics</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<h2>RAG Workflow Integration</h2>
|
||||
<p>
|
||||
This UI allows you to <strong>test the same queries that Large Language Models (LLMs) would use</strong> in a
|
||||
Retrieval-Augmented Generation (RAG) workflow. When an AI assistant needs to answer questions about your data:
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Step 1:</strong> The assistant converts your question into a search query</li>
|
||||
<li><strong>Step 2:</strong> The MCP server retrieves relevant document chunks using semantic search</li>
|
||||
<li><strong>Step 3:</strong> Retrieved context is passed to the LLM to generate an informed answer</li>
|
||||
</ul>
|
||||
|
||||
<!-- RAG Workflow Diagram -->
|
||||
<div style="background: var(--color-main-background); border: 2px solid var(--color-primary-element); border-radius: var(--border-radius-large); padding: 24px; margin: 24px 0; font-family: 'SFMono-Regular', 'Consolas', 'Liberation Mono', 'Menlo', monospace; font-size: 13px; line-height: 1.8; overflow-x: auto;">
|
||||
<div style="text-align: center; font-weight: 600; margin-bottom: 16px; color: var(--color-primary-element); font-size: 14px;">
|
||||
MCP Sampling RAG Workflow
|
||||
</div>
|
||||
<pre style="margin: 0; color: var(--color-main-text);">
|
||||
┌─────────────────┐
|
||||
│ <strong>MCP Client</strong> │ User asks: "What are health benefits of coffee?"
|
||||
│ (Claude Code) │
|
||||
└────────┬────────┘
|
||||
│ (1) User question
|
||||
↓
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ <strong>Nextcloud MCP Server</strong> │
|
||||
│ ┌──────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ <strong>nc_semantic_search_answer</strong> Tool (MCP Sampling-enabled) │ │
|
||||
│ │ │ │
|
||||
│ │ (2) Semantic Search │ │
|
||||
│ │ ┌────────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ Query: "health benefits of coffee" │ │ │
|
||||
│ │ │ → Convert to 768D vector embedding │ │ │
|
||||
│ │ │ → Search Qdrant (BM25 Hybrid + RRF fusion) │ │ │
|
||||
│ │ │ → Retrieve top 5 relevant document chunks │ │ │
|
||||
│ │ └────────────────────────────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ (3) Construct Prompt with Context │ │
|
||||
│ │ ┌────────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ "What are health benefits of coffee? │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ Documents: │ │ │
|
||||
│ │ │ - [MED-2155] Effects of habitual coffee consumption...│ │ │
|
||||
│ │ │ - [MED-1646] Beverage consumption guidance... │ │ │
|
||||
│ │ │ - [MED-1627] Coffee and depression risk... │ │ │
|
||||
│ │ │ ... │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ Provide answer with citations." │ │ │
|
||||
│ │ └────────────────────────────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ (4) MCP Sampling Request │ │
|
||||
│ │ ─────────────────────────────────────────────────────────────> │ │
|
||||
│ └──────────────────────────────────────────────────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ Sampling request with prompt + context
|
||||
↓
|
||||
┌─────────────────┐
|
||||
│ <strong>MCP Client</strong> │ (5) Client's LLM generates answer using retrieved context
|
||||
│ (Claude) │ → "Coffee consumption (2-3 cups/day) is associated with
|
||||
└────────┬────────┘ reduced risk of type 2 diabetes, cardiovascular disease,
|
||||
│ and improved liver health (Document 1, 2)..."
|
||||
│
|
||||
│ (6) Answer with citations
|
||||
↓
|
||||
┌─────────────────┐
|
||||
│ User │ Receives comprehensive answer with source citations
|
||||
└─────────────────┘</pre>
|
||||
</div>
|
||||
|
||||
<p style="margin-top: 16px;">
|
||||
<strong>Key Point:</strong> The MCP server retrieves context but doesn't generate answers itself.
|
||||
Through <strong>MCP sampling</strong>, it requests the client's LLM to generate responses, giving users
|
||||
full control over which model is used and ensuring all processing happens client-side.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
By using this interface, you can preview search results, understand relevance scores, and verify
|
||||
that the system retrieves the right information before it reaches the LLM.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature Cards -->
|
||||
<h2>Available Features</h2>
|
||||
<div class="feature-grid">
|
||||
<a href="/app/user-info" class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,14C16.42,14 20,15.79 20,18V20H4V18C4,15.79 7.58,14 12,14Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>User Information</h3>
|
||||
<p>
|
||||
View your authentication details, session information, and IdP profile.
|
||||
Manage background access permissions.
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a href="/app/user-info#vector-sync" class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M12,18A6,6 0 0,1 6,12C6,11 6.25,10.03 6.7,9.2L5.24,7.74C4.46,8.97 4,10.43 4,12A8,8 0 0,0 12,20V23L16,19L12,15M12,4V1L8,5L12,9V6A6,6 0 0,1 18,12C18,13 17.75,13.97 17.3,14.8L18.76,16.26C19.54,15.03 20,13.57 20,12A8,8 0 0,0 12,4Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Vector Sync Status</h3>
|
||||
<p>
|
||||
Monitor real-time indexing progress with metrics for indexed documents, pending queue,
|
||||
and synchronization status.
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a href="/app/user-info#vector-viz" class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M22,21H2V3H4V19H6V10H10V19H12V6H16V19H18V14H22V21Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Vector Visualization</h3>
|
||||
<p>
|
||||
Interactive search interface with 2D PCA visualization. Compare algorithms,
|
||||
view relevance scores, and explore matched document chunks.
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<!-- Vector Sync Disabled Content -->
|
||||
<div class="warning">
|
||||
<h3 style="margin-top: 0;">Vector Sync is Disabled</h3>
|
||||
<p>
|
||||
Semantic search and vector visualization features are currently disabled.
|
||||
To enable these features, set <code>VECTOR_SYNC_ENABLED=true</code> in your environment configuration.
|
||||
</p>
|
||||
<p style="margin-bottom: 0;">
|
||||
<strong>Learn more:</strong>
|
||||
<a href="https://github.com/YOUR_REPO/docs/configuration.md" target="_blank" style="color: inherit; text-decoration: underline;">
|
||||
Configuration Guide
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Limited Feature Card -->
|
||||
<h2>Available Features</h2>
|
||||
<div class="feature-grid">
|
||||
<a href="/app/user-info" class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,14C16.42,14 20,15.79 20,18V20H4V18C4,15.79 7.58,14 12,14Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>User Information</h3>
|
||||
<p>
|
||||
View your authentication details, session information, and IdP profile.
|
||||
Manage background access permissions.
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Documentation Section -->
|
||||
<div class="info-section" style="margin-top: 40px;">
|
||||
<h2>Documentation</h2>
|
||||
<p>
|
||||
For detailed information about configuration, authentication modes, and advanced features,
|
||||
please refer to the project documentation:
|
||||
</p>
|
||||
<ul>
|
||||
<li><a href="https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/installation.md" target="_blank">Installation Guide</a></li>
|
||||
<li><a href="https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/configuration.md" target="_blank">Configuration Options</a></li>
|
||||
<li><a href="https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/authentication.md" target="_blank">Authentication Modes</a></li>
|
||||
{% if vector_sync_enabled %}
|
||||
<li><a href="https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/user-guide/vector-sync-ui.md" target="_blank">Vector Sync UI Guide</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -303,10 +303,13 @@ class UnifiedTokenVerifier(TokenVerifier):
|
||||
|
||||
try:
|
||||
# Introspection requires client authentication
|
||||
client_id = self.settings.oidc_client_id
|
||||
client_secret = self.settings.oidc_client_secret
|
||||
assert client_id is not None and client_secret is not None
|
||||
response = await self.http_client.post(
|
||||
self.introspection_uri,
|
||||
data={"token": token},
|
||||
auth=(self.settings.oidc_client_id, self.settings.oidc_client_secret),
|
||||
auth=(client_id, client_secret),
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
|
||||
@@ -9,24 +9,38 @@ For OAuth mode: Requires browser-based OAuth login to establish session.
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from starlette.authentication import requires
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Setup Jinja2 environment for templates
|
||||
_template_dir = Path(__file__).parent / "templates"
|
||||
_jinja_env = Environment(loader=FileSystemLoader(_template_dir))
|
||||
|
||||
async def _get_authenticated_client_for_userinfo(request: Request) -> httpx.AsyncClient:
|
||||
"""Get an authenticated HTTP client for user info page operations.
|
||||
|
||||
async def _get_authenticated_client_for_userinfo(request: Request) -> NextcloudClient:
|
||||
"""Get an authenticated Nextcloud client for user info page operations.
|
||||
|
||||
This is a shared helper for authenticated routes that need to access
|
||||
Nextcloud APIs. It handles both BasicAuth and OAuth authentication modes.
|
||||
|
||||
Args:
|
||||
request: Starlette request object
|
||||
|
||||
Returns:
|
||||
Authenticated httpx.AsyncClient
|
||||
Authenticated NextcloudClient
|
||||
|
||||
Raises:
|
||||
RuntimeError: If credentials/session not configured
|
||||
"""
|
||||
oauth_ctx = getattr(request.app.state, "oauth_context", None)
|
||||
|
||||
@@ -39,11 +53,15 @@ async def _get_authenticated_client_for_userinfo(request: Request) -> httpx.Asyn
|
||||
if not all([nextcloud_host, username, password]):
|
||||
raise RuntimeError("BasicAuth credentials not configured")
|
||||
|
||||
assert nextcloud_host is not None # Type narrowing for type checker
|
||||
return httpx.AsyncClient(
|
||||
from httpx import BasicAuth
|
||||
|
||||
assert nextcloud_host is not None
|
||||
assert username is not None
|
||||
assert password is not None
|
||||
return NextcloudClient(
|
||||
base_url=nextcloud_host,
|
||||
auth=(username, password),
|
||||
timeout=30.0,
|
||||
username=username,
|
||||
auth=BasicAuth(username, password),
|
||||
)
|
||||
|
||||
# OAuth mode - get token from session
|
||||
@@ -58,15 +76,14 @@ async def _get_authenticated_client_for_userinfo(request: Request) -> httpx.Asyn
|
||||
raise RuntimeError("No access token found in session")
|
||||
|
||||
access_token = token_data["access_token"]
|
||||
username = token_data.get("username")
|
||||
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||
|
||||
if not nextcloud_host:
|
||||
raise RuntimeError("Nextcloud host not configured")
|
||||
if not nextcloud_host or not username:
|
||||
raise RuntimeError("Nextcloud host or username not configured")
|
||||
|
||||
return httpx.AsyncClient(
|
||||
base_url=nextcloud_host,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=30.0,
|
||||
return NextcloudClient.from_token(
|
||||
base_url=nextcloud_host, token=access_token, username=username
|
||||
)
|
||||
|
||||
|
||||
@@ -417,10 +434,10 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
||||
try:
|
||||
from nextcloud_mcp_server.auth.permissions import is_nextcloud_admin
|
||||
|
||||
# Get authenticated HTTP client
|
||||
http_client = await _get_authenticated_client_for_userinfo(request)
|
||||
is_admin = await is_nextcloud_admin(request, http_client)
|
||||
await http_client.aclose()
|
||||
# Get authenticated Nextcloud client
|
||||
nc_client = await _get_authenticated_client_for_userinfo(request)
|
||||
is_admin = await is_nextcloud_admin(request, nc_client._client)
|
||||
await nc_client.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to check admin status: {e}")
|
||||
# Default to not admin if check fails
|
||||
@@ -431,51 +448,14 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
||||
oauth_ctx = getattr(request.app.state, "oauth_context", None)
|
||||
login_url = str(request.url_for("oauth_login")) if oauth_ctx else "/oauth/login"
|
||||
|
||||
error_html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Error - Nextcloud MCP Server</title>
|
||||
<style>
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}}
|
||||
.container {{
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}}
|
||||
h1 {{
|
||||
color: #d32f2f;
|
||||
margin-top: 0;
|
||||
}}
|
||||
.error {{
|
||||
background-color: #ffebee;
|
||||
border-left: 4px solid #d32f2f;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Error Retrieving User Info</h1>
|
||||
<div class="error">
|
||||
<strong>Error:</strong> {user_context["error"]}
|
||||
</div>
|
||||
<p><a href="{login_url}">Login again</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return HTMLResponse(content=error_html)
|
||||
template = _jinja_env.get_template("error.html")
|
||||
return HTMLResponse(
|
||||
content=template.render(
|
||||
error_title="Error Retrieving User Info",
|
||||
error_message=user_context["error"],
|
||||
login_url=login_url,
|
||||
)
|
||||
)
|
||||
|
||||
# Build HTML response
|
||||
auth_mode = user_context.get("auth_mode", "unknown")
|
||||
@@ -654,457 +634,26 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
||||
</div>
|
||||
"""
|
||||
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Nextcloud MCP Server</title>
|
||||
# Check if vector sync is enabled (needed for Welcome tab)
|
||||
vector_sync_enabled = os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
|
||||
|
||||
<!-- htmx for dynamic loading -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
|
||||
<!-- Alpine.js for tab state management -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- Plotly.js for vector visualization -->
|
||||
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
|
||||
|
||||
<!-- Vector visualization app (Alpine.js component) -->
|
||||
<script>
|
||||
function vizApp() {{
|
||||
return {{
|
||||
query: '',
|
||||
algorithm: 'bm25_hybrid',
|
||||
fusion: 'rrf', // Default fusion method for BM25 Hybrid
|
||||
showAdvanced: false,
|
||||
docTypes: [''], // Default to "All Types"
|
||||
limit: 50,
|
||||
scoreThreshold: 0.0,
|
||||
loading: false,
|
||||
results: [],
|
||||
expandedChunks: {{}}, // Track which chunks are expanded (result_id -> chunk data)
|
||||
chunkLoading: {{}}, // Track loading state per result
|
||||
|
||||
async executeSearch() {{
|
||||
this.loading = true;
|
||||
this.results = [];
|
||||
|
||||
try {{
|
||||
const params = new URLSearchParams({{
|
||||
query: this.query,
|
||||
algorithm: this.algorithm,
|
||||
limit: this.limit,
|
||||
score_threshold: this.scoreThreshold,
|
||||
}});
|
||||
|
||||
// Add fusion parameter for BM25 Hybrid
|
||||
if (this.algorithm === 'bm25_hybrid') {{
|
||||
params.append('fusion', this.fusion);
|
||||
}}
|
||||
|
||||
// Add doc_types parameter (filter out empty string for "All Types")
|
||||
const selectedTypes = this.docTypes.filter(t => t !== '');
|
||||
if (selectedTypes.length > 0) {{
|
||||
params.append('doc_types', selectedTypes.join(','));
|
||||
}}
|
||||
|
||||
const response = await fetch(`/app/vector-viz/search?${{params}}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {{
|
||||
this.results = data.results;
|
||||
this.renderPlot(data.coordinates_2d, data.results);
|
||||
}} else {{
|
||||
alert('Search failed: ' + data.error);
|
||||
}}
|
||||
}} catch (error) {{
|
||||
alert('Error: ' + error.message);
|
||||
}} finally {{
|
||||
this.loading = false;
|
||||
}}
|
||||
}},
|
||||
|
||||
renderPlot(coordinates, results) {{
|
||||
// Calculate score range for auto-scaling
|
||||
const scores = results.map(r => r.score);
|
||||
const minScore = Math.min(...scores);
|
||||
const maxScore = Math.max(...scores);
|
||||
|
||||
const trace = {{
|
||||
x: coordinates.map(c => c[0]),
|
||||
y: coordinates.map(c => c[1]),
|
||||
mode: 'markers',
|
||||
type: 'scatter',
|
||||
text: results.map(r => `${{r.title}}<br>Raw Score: ${{r.original_score.toFixed(3)}} (${{(r.score * 100).toFixed(0)}}% relative)`),
|
||||
marker: {{
|
||||
// Multi-channel encoding: size + opacity + color for visual hierarchy
|
||||
// Power scaling (score^2) amplifies visual differences dramatically
|
||||
// score=0.0 → 6px, score=0.5 → 9.5px, score=1.0 → 20px
|
||||
size: results.map(r => 6 + (Math.pow(r.score, 2) * 14)),
|
||||
// Linear opacity scaling (0.2-1.0 range keeps all points visible)
|
||||
opacity: results.map(r => 0.2 + (r.score * 0.8)),
|
||||
// Color gradient shows score
|
||||
color: scores,
|
||||
colorscale: 'Viridis',
|
||||
showscale: true,
|
||||
colorbar: {{ title: 'Relative Score' }},
|
||||
// Scores are normalized 0-1 within result set
|
||||
cmin: 0,
|
||||
cmax: 1
|
||||
}}
|
||||
}};
|
||||
|
||||
const layout = {{
|
||||
title: `Vector Space (PCA 2D) - ${{results.length}} results`,
|
||||
xaxis: {{ title: 'PC1' }},
|
||||
yaxis: {{ title: 'PC2' }},
|
||||
hovermode: 'closest',
|
||||
height: 600
|
||||
}};
|
||||
|
||||
Plotly.newPlot('viz-plot', [trace], layout);
|
||||
}},
|
||||
|
||||
getNextcloudUrl(result) {{
|
||||
// Generate Nextcloud URL based on document type
|
||||
// Use the actual Nextcloud host (port 8080), not the MCP server
|
||||
const baseUrl = '{nextcloud_host_for_links}';
|
||||
|
||||
switch (result.doc_type) {{
|
||||
case 'note':
|
||||
return `${{baseUrl}}/apps/notes/note/${{result.id}}`;
|
||||
case 'file':
|
||||
return `${{baseUrl}}/apps/files/?fileId=${{result.id}}`;
|
||||
case 'calendar':
|
||||
return `${{baseUrl}}/apps/calendar`;
|
||||
case 'contact':
|
||||
return `${{baseUrl}}/apps/contacts`;
|
||||
case 'deck':
|
||||
return `${{baseUrl}}/apps/deck`;
|
||||
default:
|
||||
return `${{baseUrl}}`;
|
||||
}}
|
||||
}},
|
||||
|
||||
hasChunkPosition(result) {{
|
||||
// Check if result has position metadata
|
||||
return result.chunk_start_offset != null && result.chunk_end_offset != null;
|
||||
}},
|
||||
|
||||
isChunkExpanded(resultKey) {{
|
||||
return this.expandedChunks[resultKey] !== undefined;
|
||||
}},
|
||||
|
||||
async toggleChunk(result) {{
|
||||
const resultKey = `${{result.doc_type}}_${{result.id}}`;
|
||||
|
||||
// If already expanded, collapse
|
||||
if (this.isChunkExpanded(resultKey)) {{
|
||||
delete this.expandedChunks[resultKey];
|
||||
return;
|
||||
}}
|
||||
|
||||
// Otherwise, fetch and expand
|
||||
this.chunkLoading[resultKey] = true;
|
||||
|
||||
try {{
|
||||
const params = new URLSearchParams({{
|
||||
doc_type: result.doc_type,
|
||||
doc_id: result.id,
|
||||
start: result.chunk_start_offset,
|
||||
end: result.chunk_end_offset,
|
||||
context: 500 // 500 chars before/after
|
||||
}});
|
||||
|
||||
const response = await fetch(`/app/chunk-context?${{params}}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {{
|
||||
this.expandedChunks[resultKey] = data;
|
||||
}} else {{
|
||||
alert('Failed to load chunk: ' + data.error);
|
||||
}}
|
||||
}} catch (error) {{
|
||||
alert('Error loading chunk: ' + error.message);
|
||||
}} finally {{
|
||||
delete this.chunkLoading[resultKey];
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
max-width: 900px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}}
|
||||
.container {{
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
min-height: calc(100vh - 200px);
|
||||
}}
|
||||
h1 {{
|
||||
color: #0082c9;
|
||||
margin-top: 0;
|
||||
border-bottom: 2px solid #0082c9;
|
||||
padding-bottom: 10px;
|
||||
}}
|
||||
h2 {{
|
||||
color: #333;
|
||||
margin-top: 20px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
padding-bottom: 5px;
|
||||
}}
|
||||
|
||||
/* Tab navigation */
|
||||
.tabs {{
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin: 20px 0 0 0;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
}}
|
||||
.tab {{
|
||||
padding: 12px 24px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
transition: all 0.2s;
|
||||
}}
|
||||
.tab:hover {{
|
||||
color: #0082c9;
|
||||
background-color: #f5f5f5;
|
||||
}}
|
||||
.tab.active {{
|
||||
color: #0082c9;
|
||||
border-bottom-color: #0082c9;
|
||||
}}
|
||||
|
||||
/* Tab content - use grid to overlay panes */
|
||||
.tab-content {{
|
||||
padding: 20px 0;
|
||||
display: grid;
|
||||
}}
|
||||
|
||||
/* Tab panes - all occupy the same grid cell to overlay */
|
||||
.tab-pane {{
|
||||
grid-area: 1 / 1;
|
||||
}}
|
||||
|
||||
/* Tables */
|
||||
table {{
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 15px 0;
|
||||
}}
|
||||
td {{
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}}
|
||||
td:first-child {{
|
||||
width: 200px;
|
||||
color: #666;
|
||||
}}
|
||||
code {{
|
||||
background-color: #f5f5f5;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
}}
|
||||
|
||||
/* Badges */
|
||||
.badge {{
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}}
|
||||
.badge-oauth {{
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
}}
|
||||
.badge-basic {{
|
||||
background-color: #2196f3;
|
||||
color: white;
|
||||
}}
|
||||
|
||||
/* Messages */
|
||||
.warning {{
|
||||
background-color: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
padding: 15px;
|
||||
margin: 15px 0;
|
||||
color: #856404;
|
||||
}}
|
||||
.info-message {{
|
||||
background-color: #e3f2fd;
|
||||
border-left: 4px solid #2196f3;
|
||||
padding: 15px;
|
||||
margin: 15px 0;
|
||||
color: #1565c0;
|
||||
}}
|
||||
|
||||
/* Buttons */
|
||||
.button {{
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
background-color: #d32f2f;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}}
|
||||
.button:hover {{
|
||||
background-color: #b71c1c;
|
||||
}}
|
||||
.button-primary {{
|
||||
background-color: #0082c9;
|
||||
}}
|
||||
.button-primary:hover {{
|
||||
background-color: #006ba3;
|
||||
}}
|
||||
|
||||
/* Logout section */
|
||||
.logout {{
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}}
|
||||
|
||||
/* Smooth htmx content swaps */
|
||||
.htmx-swapping {{
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease-out;
|
||||
}}
|
||||
|
||||
/* Smooth htmx content settling */
|
||||
.htmx-settling {{
|
||||
opacity: 1;
|
||||
transition: opacity 200ms ease-in;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container" x-data="{{ activeTab: 'user-info' }}">
|
||||
<h1>Nextcloud MCP Server</h1>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="tabs">
|
||||
<button
|
||||
class="tab"
|
||||
:class="activeTab === 'user-info' ? 'active' : ''"
|
||||
@click="activeTab = 'user-info'">
|
||||
User Info
|
||||
</button>
|
||||
{
|
||||
""
|
||||
if not show_vector_sync_tab
|
||||
else '''
|
||||
<button
|
||||
class="tab"
|
||||
:class="activeTab === 'vector-sync' ? 'active' : ''"
|
||||
@click="activeTab = 'vector-sync'">
|
||||
Vector Sync
|
||||
</button>
|
||||
'''
|
||||
}
|
||||
{
|
||||
""
|
||||
if not show_vector_sync_tab
|
||||
else '''
|
||||
<button
|
||||
class="tab"
|
||||
:class="activeTab === 'vector-viz' ? 'active' : ''"
|
||||
@click="activeTab = 'vector-viz'">
|
||||
Vector Viz
|
||||
</button>
|
||||
'''
|
||||
}
|
||||
{
|
||||
""
|
||||
if not show_webhooks_tab
|
||||
else '''
|
||||
<button
|
||||
class="tab"
|
||||
:class="activeTab === 'webhooks' ? 'active' : ''"
|
||||
@click="activeTab = 'webhooks'">
|
||||
Webhooks
|
||||
</button>
|
||||
'''
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="tab-content">
|
||||
<!-- User Info Tab -->
|
||||
<div class="tab-pane" x-show="activeTab === 'user-info'" x-transition.opacity.duration.150ms>
|
||||
{user_info_tab_html}
|
||||
</div>
|
||||
|
||||
{
|
||||
""
|
||||
if not show_vector_sync_tab
|
||||
else f'''
|
||||
<!-- Vector Sync Tab -->
|
||||
<div class="tab-pane" x-show="activeTab === 'vector-sync'" x-transition.opacity.duration.150ms>
|
||||
{vector_sync_tab_html}
|
||||
</div>
|
||||
'''
|
||||
}
|
||||
|
||||
{
|
||||
""
|
||||
if not show_vector_sync_tab
|
||||
else '''
|
||||
<!-- Vector Viz Tab -->
|
||||
<div class="tab-pane" x-show="activeTab === 'vector-viz'" x-transition.opacity.duration.150ms>
|
||||
<div hx-get="/app/vector-viz" hx-trigger="load" hx-swap="outerHTML">
|
||||
<p style="color: #999;">Loading vector visualization...</p>
|
||||
</div>
|
||||
</div>
|
||||
'''
|
||||
}
|
||||
|
||||
{
|
||||
""
|
||||
if not show_webhooks_tab
|
||||
else f'''
|
||||
<!-- Webhooks Tab (admin-only, loaded dynamically) -->
|
||||
<div class="tab-pane" x-show="activeTab === 'webhooks'" x-transition.opacity.duration.150ms>
|
||||
{webhooks_tab_html}
|
||||
</div>
|
||||
'''
|
||||
}
|
||||
</div>
|
||||
|
||||
{
|
||||
f'<div class="logout"><a href="{logout_url}" class="button">Logout</a></div>'
|
||||
if auth_mode == "oauth"
|
||||
else ""
|
||||
}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return HTMLResponse(content=html_content)
|
||||
# Render template
|
||||
template = _jinja_env.get_template("user_info.html")
|
||||
return HTMLResponse(
|
||||
content=template.render(
|
||||
user_info_tab_html=user_info_tab_html,
|
||||
vector_sync_tab_html=vector_sync_tab_html,
|
||||
webhooks_tab_html=webhooks_tab_html,
|
||||
show_vector_sync_tab=show_vector_sync_tab,
|
||||
show_webhooks_tab=show_webhooks_tab,
|
||||
logout_url=logout_url if auth_mode == "oauth" else None,
|
||||
nextcloud_host_for_links=nextcloud_host_for_links,
|
||||
# Additional context for Welcome tab
|
||||
vector_sync_enabled=vector_sync_enabled,
|
||||
username=username,
|
||||
auth_mode=auth_mode,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@requires("authenticated", redirect="oauth_login")
|
||||
@@ -1124,17 +673,12 @@ async def revoke_session(request: Request) -> HTMLResponse:
|
||||
oauth_ctx = getattr(request.app.state, "oauth_context", None)
|
||||
|
||||
if not oauth_ctx:
|
||||
template = _jinja_env.get_template("error.html")
|
||||
return HTMLResponse(
|
||||
"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Error</title></head>
|
||||
<body>
|
||||
<h1>Error</h1>
|
||||
<p>OAuth mode not enabled</p>
|
||||
</body>
|
||||
</html>
|
||||
""",
|
||||
content=template.render(
|
||||
error_title="Error",
|
||||
error_message="OAuth mode not enabled",
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
@@ -1142,17 +686,12 @@ async def revoke_session(request: Request) -> HTMLResponse:
|
||||
session_id = request.cookies.get("mcp_session")
|
||||
|
||||
if not storage or not session_id:
|
||||
template = _jinja_env.get_template("error.html")
|
||||
return HTMLResponse(
|
||||
"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Error</title></head>
|
||||
<body>
|
||||
<h1>Error</h1>
|
||||
<p>Session not found</p>
|
||||
</body>
|
||||
</html>
|
||||
""",
|
||||
content=template.render(
|
||||
error_title="Error",
|
||||
error_message="Session not found",
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
@@ -1165,57 +704,26 @@ async def revoke_session(request: Request) -> HTMLResponse:
|
||||
# Redirect back to user page
|
||||
user_page_url = str(request.url_for("user_info_html"))
|
||||
|
||||
template = _jinja_env.get_template("success.html")
|
||||
return HTMLResponse(
|
||||
f"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="refresh" content="2;url={user_page_url}">
|
||||
<title>Background Access Revoked</title>
|
||||
<style>
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
max-width: 600px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}}
|
||||
.success {{
|
||||
background-color: #e8f5e9;
|
||||
border: 2px solid #4caf50;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
h1 {{
|
||||
color: #4caf50;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="success">
|
||||
<h1>✓ Background Access Revoked</h1>
|
||||
<p>Your refresh token has been deleted successfully.</p>
|
||||
<p>Browser session remains active.</p>
|
||||
<p>Redirecting back to user page...</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
content=template.render(
|
||||
success_title="✓ Background Access Revoked",
|
||||
success_messages=[
|
||||
"Your refresh token has been deleted successfully.",
|
||||
"Browser session remains active.",
|
||||
],
|
||||
redirect_url=user_page_url,
|
||||
redirect_delay=2,
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to revoke background access: {e}")
|
||||
template = _jinja_env.get_template("error.html")
|
||||
return HTMLResponse(
|
||||
f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Error</title></head>
|
||||
<body>
|
||||
<h1>Error</h1>
|
||||
<p>Failed to revoke background access: {e}</p>
|
||||
</body>
|
||||
</html>
|
||||
""",
|
||||
content=template.render(
|
||||
error_title="Error",
|
||||
error_message=f"Failed to revoke background access: {e}",
|
||||
),
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"""Vector visualization routes for testing search algorithms.
|
||||
|
||||
Provides a web UI for users to test different search algorithms on their own
|
||||
indexed documents and visualize results in 2D space using PCA.
|
||||
indexed documents and visualize results in 3D space using PCA.
|
||||
|
||||
All processing happens server-side following ADR-012:
|
||||
- Search execution via shared search/algorithms.py
|
||||
- PCA dimensionality reduction (768-dim → 2D)
|
||||
- Only 2D coordinates + metadata sent to client
|
||||
- Bandwidth-efficient (2 floats per doc vs 768)
|
||||
- Query embedding generation
|
||||
- PCA dimensionality reduction (768-dim → 3D)
|
||||
- Only 3D coordinates + metadata sent to client
|
||||
- Bandwidth-efficient (3 floats per doc vs 768)
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -21,11 +22,13 @@ from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.observability.tracing import trace_operation
|
||||
from nextcloud_mcp_server.search import (
|
||||
BM25HybridSearchAlgorithm,
|
||||
SemanticSearchAlgorithm,
|
||||
)
|
||||
from nextcloud_mcp_server.vector.pca import PCA
|
||||
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -77,19 +80,20 @@ async def vector_visualization_html(request: Request) -> HTMLResponse:
|
||||
|
||||
@requires("authenticated", redirect="oauth_login")
|
||||
async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
"""Execute server-side search and return 2D coordinates + results.
|
||||
"""Execute server-side search and return 3D coordinates + results.
|
||||
|
||||
All processing happens server-side:
|
||||
1. Execute search via shared algorithm module
|
||||
2. Fetch matching vectors from Qdrant
|
||||
3. Apply PCA reduction (768-dim → 2D)
|
||||
4. Return coordinates + metadata only
|
||||
2. Generate query embedding
|
||||
3. Fetch matching vectors from Qdrant
|
||||
4. Apply PCA reduction (768-dim → 3D) to query + documents
|
||||
5. Return coordinates + metadata only
|
||||
|
||||
Args:
|
||||
request: Starlette request with query parameters
|
||||
|
||||
Returns:
|
||||
JSON response with coordinates_2d and results
|
||||
JSON response with coordinates_3d and results (including query point)
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
@@ -136,7 +140,10 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
_get_authenticated_client_for_userinfo,
|
||||
)
|
||||
|
||||
async with await _get_authenticated_client_for_userinfo(request) as http_client: # noqa: F841
|
||||
with trace_operation("vector_viz.get_auth_client"):
|
||||
auth_client_ctx = await _get_authenticated_client_for_userinfo(request)
|
||||
|
||||
async with auth_client_ctx as nc_client: # noqa: F841
|
||||
# Create search algorithm (no client needed - verification removed)
|
||||
if algorithm == "semantic":
|
||||
search_algo = SemanticSearchAlgorithm(score_threshold=score_threshold)
|
||||
@@ -156,24 +163,40 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
all_results = []
|
||||
if doc_types is None or len(doc_types) == 0:
|
||||
# Cross-app search - search all indexed types
|
||||
unverified_results = await search_algo.search(
|
||||
query=query,
|
||||
user_id=username,
|
||||
limit=limit * 2, # Buffer for verification filtering
|
||||
doc_type=None, # Search all types
|
||||
score_threshold=score_threshold,
|
||||
)
|
||||
all_results.extend(unverified_results)
|
||||
else:
|
||||
# Search each document type and combine
|
||||
for doc_type in doc_types:
|
||||
with trace_operation(
|
||||
"vector_viz.search_execute",
|
||||
attributes={
|
||||
"search.algorithm": algorithm,
|
||||
"search.limit": limit * 2,
|
||||
"search.doc_type": "all",
|
||||
},
|
||||
):
|
||||
unverified_results = await search_algo.search(
|
||||
query=query,
|
||||
user_id=username,
|
||||
limit=limit * 2, # Buffer for verification filtering
|
||||
doc_type=doc_type,
|
||||
doc_type=None, # Search all types
|
||||
score_threshold=score_threshold,
|
||||
)
|
||||
all_results.extend(unverified_results)
|
||||
else:
|
||||
# Search each document type and combine
|
||||
for doc_type in doc_types:
|
||||
with trace_operation(
|
||||
"vector_viz.search_execute",
|
||||
attributes={
|
||||
"search.algorithm": algorithm,
|
||||
"search.limit": limit * 2,
|
||||
"search.doc_type": doc_type,
|
||||
},
|
||||
):
|
||||
unverified_results = await search_algo.search(
|
||||
query=query,
|
||||
user_id=username,
|
||||
limit=limit * 2, # Buffer for verification filtering
|
||||
doc_type=doc_type,
|
||||
score_threshold=score_threshold,
|
||||
)
|
||||
all_results.extend(unverified_results)
|
||||
# Sort by score before verification
|
||||
all_results.sort(key=lambda r: r.score, reverse=True)
|
||||
@@ -187,89 +210,84 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
# Store original scores and normalize for visualization
|
||||
# (best result = 1.0, worst result = 0.0 within THIS result set)
|
||||
# This makes visual encoding meaningful regardless of RRF normalization
|
||||
if search_results:
|
||||
scores = [r.score for r in search_results]
|
||||
min_score, max_score = min(scores), max(scores)
|
||||
score_range = max_score - min_score if max_score > min_score else 1.0
|
||||
with trace_operation(
|
||||
"vector_viz.score_normalize",
|
||||
attributes={"normalize.num_results": len(search_results)},
|
||||
):
|
||||
if search_results:
|
||||
scores = [r.score for r in search_results]
|
||||
min_score, max_score = min(scores), max(scores)
|
||||
score_range = max_score - min_score if max_score > min_score else 1.0
|
||||
|
||||
logger.info(
|
||||
f"Normalizing scores for viz: original range [{min_score:.3f}, {max_score:.3f}] "
|
||||
f"→ [0.0, 1.0]"
|
||||
)
|
||||
logger.info(
|
||||
f"Normalizing scores for viz: original range [{min_score:.3f}, {max_score:.3f}] "
|
||||
f"→ [0.0, 1.0]"
|
||||
)
|
||||
|
||||
# Store original score and rescale to 0-1 for visualization
|
||||
for r in search_results:
|
||||
# Store original score before normalization
|
||||
r.original_score = r.score
|
||||
# Rescale for visual encoding
|
||||
r.score = (r.score - min_score) / score_range
|
||||
# Store original score and rescale to 0-1 for visualization
|
||||
for r in search_results:
|
||||
# Store original score before normalization
|
||||
r.original_score = r.score
|
||||
# Rescale for visual encoding
|
||||
r.score = (r.score - min_score) / score_range
|
||||
|
||||
if not search_results:
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"results": [],
|
||||
"coordinates_2d": [],
|
||||
"coordinates_3d": [],
|
||||
"query_coords": [],
|
||||
"message": "No results found",
|
||||
}
|
||||
)
|
||||
|
||||
# Fetch vectors for matching results from Qdrant
|
||||
# Fetch vectors for specific matching chunks from Qdrant using batch retrieve
|
||||
vector_fetch_start = time.perf_counter()
|
||||
qdrant_client = await get_qdrant_client()
|
||||
doc_ids = [r.id for r in search_results]
|
||||
|
||||
# Retrieve vectors for the matching documents
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchAny
|
||||
with trace_operation("vector_viz.get_qdrant_client"):
|
||||
qdrant_client = await get_qdrant_client()
|
||||
|
||||
points_response = await qdrant_client.scroll(
|
||||
collection_name=settings.get_collection_name(),
|
||||
scroll_filter=Filter(
|
||||
must=[
|
||||
FieldCondition(
|
||||
key="doc_id",
|
||||
match=MatchAny(any=[str(doc_id) for doc_id in doc_ids]),
|
||||
),
|
||||
FieldCondition(
|
||||
key="user_id",
|
||||
match={"value": username},
|
||||
),
|
||||
]
|
||||
),
|
||||
limit=len(doc_ids) * 2, # Account for multiple chunks per doc
|
||||
with_vectors=["dense"], # Only fetch dense vectors for visualization
|
||||
with_payload=["doc_id"], # Need doc_id to map vectors to results
|
||||
)
|
||||
chunk_vectors_map = {} # Map (doc_id, chunk_start, chunk_end) -> vector
|
||||
|
||||
points = points_response[0]
|
||||
# Collect point IDs from search results for batch retrieval
|
||||
# point_id is the Qdrant internal ID returned by search algorithms
|
||||
point_ids = [r.point_id for r in search_results if r.point_id]
|
||||
|
||||
if not points:
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"results": [],
|
||||
"coordinates_2d": [],
|
||||
"message": "No vectors found for results",
|
||||
}
|
||||
)
|
||||
if point_ids:
|
||||
# Single batch retrieve call instead of N sequential scroll calls
|
||||
# This is ~50x faster for 50 results (1 HTTP request vs 50)
|
||||
with trace_operation(
|
||||
"vector_viz.vector_retrieve",
|
||||
attributes={"retrieve.num_points": len(point_ids)},
|
||||
):
|
||||
points_response = await qdrant_client.retrieve(
|
||||
collection_name=settings.get_collection_name(),
|
||||
ids=point_ids,
|
||||
with_vectors=["dense"],
|
||||
with_payload=["doc_id", "chunk_start_offset", "chunk_end_offset"],
|
||||
)
|
||||
|
||||
# Extract dense vectors (handle both named and unnamed vectors)
|
||||
def extract_dense_vector(point):
|
||||
if point.vector is None:
|
||||
return None
|
||||
# If named vectors (dict), extract "dense"
|
||||
if isinstance(point.vector, dict):
|
||||
return point.vector.get("dense")
|
||||
# If unnamed vector (array), use directly
|
||||
return point.vector
|
||||
# Build chunk_vectors_map from batch response
|
||||
for point in points_response:
|
||||
if point.vector is not None:
|
||||
# Extract dense vector (handle both named and unnamed vectors)
|
||||
if isinstance(point.vector, dict):
|
||||
vector = point.vector.get("dense")
|
||||
else:
|
||||
vector = point.vector
|
||||
|
||||
if vector is not None and point.payload:
|
||||
doc_id = point.payload.get("doc_id")
|
||||
chunk_start = point.payload.get("chunk_start_offset")
|
||||
chunk_end = point.payload.get("chunk_end_offset")
|
||||
chunk_key = (doc_id, chunk_start, chunk_end)
|
||||
chunk_vectors_map[chunk_key] = vector
|
||||
|
||||
vectors = np.array(
|
||||
[v for v in (extract_dense_vector(p) for p in points) if v is not None]
|
||||
)
|
||||
vector_fetch_duration = time.perf_counter() - vector_fetch_start
|
||||
|
||||
if len(vectors) < 2:
|
||||
# Not enough points for PCA
|
||||
if len(chunk_vectors_map) < 2:
|
||||
# Not enough chunks for PCA
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
@@ -280,38 +298,153 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
"title": r.title,
|
||||
"excerpt": r.excerpt,
|
||||
"score": r.score,
|
||||
"metadata": r.metadata,
|
||||
}
|
||||
for r in search_results
|
||||
],
|
||||
"coordinates_2d": [[0, 0]] * len(search_results),
|
||||
"message": "Not enough vectors for PCA",
|
||||
"coordinates_3d": [[0, 0, 0]] * len(search_results),
|
||||
"query_coords": [0, 0, 0],
|
||||
"message": "Not enough chunks for PCA",
|
||||
}
|
||||
)
|
||||
|
||||
# Apply PCA dimensionality reduction (768-dim → 2D)
|
||||
# Detect embedding dimension from first available vector
|
||||
embedding_dim = None
|
||||
for vector in chunk_vectors_map.values():
|
||||
if vector is not None:
|
||||
embedding_dim = len(vector)
|
||||
break
|
||||
|
||||
if embedding_dim is None:
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Could not determine embedding dimension",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
logger.info(f"Detected embedding dimension: {embedding_dim}")
|
||||
|
||||
# Build chunk vectors array in search_results order (1:1 mapping)
|
||||
chunk_vectors = []
|
||||
for result in search_results:
|
||||
chunk_key = (result.id, result.chunk_start_offset, result.chunk_end_offset)
|
||||
if chunk_key in chunk_vectors_map:
|
||||
chunk_vectors.append(chunk_vectors_map[chunk_key])
|
||||
else:
|
||||
# Chunk not found in vectors (shouldn't happen)
|
||||
logger.warning(
|
||||
f"Chunk {chunk_key} not found in fetched vectors, using zero vector"
|
||||
)
|
||||
# Use zero vector as fallback
|
||||
chunk_vectors.append(np.zeros(embedding_dim))
|
||||
|
||||
chunk_vectors = np.array(chunk_vectors)
|
||||
|
||||
# Reuse query embedding from search algorithm (avoids redundant embedding call)
|
||||
query_embed_start = time.perf_counter()
|
||||
if search_algo.query_embedding is not None:
|
||||
query_embedding = search_algo.query_embedding
|
||||
logger.info(
|
||||
f"Reusing query embedding from search algorithm "
|
||||
f"(dimension={len(query_embedding)})"
|
||||
)
|
||||
else:
|
||||
# Fallback: generate embedding if not available from search
|
||||
from nextcloud_mcp_server.embedding.service import get_embedding_service
|
||||
|
||||
embedding_service = get_embedding_service()
|
||||
query_embedding = await embedding_service.embed(query)
|
||||
logger.info(f"Generated query embedding (dimension={len(query_embedding)})")
|
||||
query_embed_duration = time.perf_counter() - query_embed_start
|
||||
|
||||
# Combine query vector with chunk vectors for PCA
|
||||
# Query will be the last point in the array
|
||||
all_vectors = np.vstack([chunk_vectors, np.array([query_embedding])])
|
||||
|
||||
# Normalize vectors to unit length (L2 normalization)
|
||||
# This is critical because Qdrant uses COSINE distance, which only measures
|
||||
# vector direction (angle), not magnitude. PCA uses Euclidean distance which
|
||||
# considers both direction and magnitude. By normalizing to unit length,
|
||||
# Euclidean distances in PCA space will match cosine distances.
|
||||
norms = np.linalg.norm(all_vectors, axis=1, keepdims=True)
|
||||
|
||||
# Check for zero-norm vectors (can happen with empty/corrupted embeddings)
|
||||
zero_norm_mask = norms[:, 0] < 1e-10
|
||||
if zero_norm_mask.any():
|
||||
zero_indices = np.where(zero_norm_mask)[0]
|
||||
logger.warning(
|
||||
f"Found {zero_norm_mask.sum()} zero-norm vectors at indices {zero_indices.tolist()}. "
|
||||
"Replacing with small epsilon to avoid division by zero."
|
||||
)
|
||||
# Replace zero norms with small epsilon to avoid NaN
|
||||
norms[zero_norm_mask] = 1e-10
|
||||
|
||||
all_vectors_normalized = all_vectors / norms
|
||||
logger.info(
|
||||
f"Normalized vectors: query_norm={norms[-1][0]:.3f}, "
|
||||
f"doc_norm_range=[{norms[:-1].min():.3f}, {norms[:-1].max():.3f}]"
|
||||
)
|
||||
|
||||
# Apply PCA dimensionality reduction (768-dim → 3D) on normalized vectors
|
||||
# Run in thread pool to avoid blocking the event loop (CPU-bound)
|
||||
pca_start = time.perf_counter()
|
||||
pca = PCA(n_components=2)
|
||||
coords_2d = pca.fit_transform(vectors)
|
||||
|
||||
def _compute_pca(vectors: np.ndarray) -> tuple[np.ndarray, PCA]:
|
||||
pca = PCA(n_components=3)
|
||||
coords = pca.fit_transform(vectors)
|
||||
return coords, pca
|
||||
|
||||
import anyio
|
||||
|
||||
with trace_operation(
|
||||
"vector_viz.pca_compute",
|
||||
attributes={
|
||||
"pca.num_vectors": len(all_vectors_normalized),
|
||||
"pca.embedding_dim": embedding_dim,
|
||||
},
|
||||
):
|
||||
coords_3d, pca = await anyio.to_thread.run_sync( # type: ignore[attr-defined]
|
||||
lambda: _compute_pca(all_vectors_normalized)
|
||||
)
|
||||
pca_duration = time.perf_counter() - pca_start
|
||||
|
||||
# After fit, these attributes are guaranteed to be set
|
||||
assert pca.explained_variance_ratio_ is not None
|
||||
|
||||
# Check for NaN values in PCA output (numerical instability)
|
||||
nan_mask = np.isnan(coords_3d)
|
||||
if nan_mask.any():
|
||||
nan_rows = np.where(nan_mask.any(axis=1))[0]
|
||||
logger.error(
|
||||
f"Found NaN values in PCA output at {len(nan_rows)} points: {nan_rows.tolist()[:10]}. "
|
||||
"Replacing NaN with 0.0 to prevent JSON serialization error."
|
||||
)
|
||||
# Replace NaN with 0 to allow JSON serialization
|
||||
coords_3d = np.nan_to_num(coords_3d, nan=0.0)
|
||||
|
||||
# Split query coords from chunk coords
|
||||
# Round to 2 decimal places for cleaner display
|
||||
query_coords_3d = [
|
||||
round(float(x), 2) for x in coords_3d[-1]
|
||||
] # Last point is query
|
||||
chunk_coords_3d = coords_3d[:-1] # All but last are chunks
|
||||
|
||||
logger.info(
|
||||
f"PCA explained variance: PC1={pca.explained_variance_ratio_[0]:.3f}, "
|
||||
f"PC2={pca.explained_variance_ratio_[1]:.3f}"
|
||||
f"PC2={pca.explained_variance_ratio_[1]:.3f}, "
|
||||
f"PC3={pca.explained_variance_ratio_[2]:.3f}"
|
||||
)
|
||||
logger.info(
|
||||
f"Embedding stats: chunks={len(chunk_vectors)}, "
|
||||
f"query_dim={len(query_embedding)}, chunk_vector_dim={chunk_vectors.shape[1] if chunk_vectors.size > 0 else 0}"
|
||||
)
|
||||
|
||||
# Map results to coordinates (use first chunk per document)
|
||||
result_coords = []
|
||||
seen_doc_ids = set()
|
||||
|
||||
for point, coord in zip(points, coords_2d):
|
||||
if point.payload:
|
||||
doc_id = int(point.payload.get("doc_id", 0))
|
||||
if doc_id not in seen_doc_ids and doc_id in doc_ids:
|
||||
seen_doc_ids.add(doc_id)
|
||||
result_coords.append(coord.tolist())
|
||||
# Coordinates already match search_results order (1:1 mapping)
|
||||
result_coords = [
|
||||
[round(float(x), 2) for x in coord] for coord in chunk_coords_3d
|
||||
]
|
||||
|
||||
# Build response
|
||||
response_results = [
|
||||
@@ -326,6 +459,7 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
), # Raw score from algorithm
|
||||
"chunk_start_offset": r.chunk_start_offset,
|
||||
"chunk_end_offset": r.chunk_end_offset,
|
||||
"metadata": r.metadata, # Include metadata (e.g., board_id for deck_card)
|
||||
}
|
||||
for r in search_results
|
||||
]
|
||||
@@ -338,26 +472,30 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
f"Viz search timing: total={total_duration * 1000:.1f}ms, "
|
||||
f"search={search_duration * 1000:.1f}ms ({search_duration / total_duration * 100:.1f}%), "
|
||||
f"vector_fetch={vector_fetch_duration * 1000:.1f}ms ({vector_fetch_duration / total_duration * 100:.1f}%), "
|
||||
f"query_embed={query_embed_duration * 1000:.1f}ms ({query_embed_duration / total_duration * 100:.1f}%), "
|
||||
f"pca={pca_duration * 1000:.1f}ms ({pca_duration / total_duration * 100:.1f}%), "
|
||||
f"results={len(search_results)}, vectors={len(vectors)}"
|
||||
f"results={len(search_results)}, chunk_vectors={len(chunk_vectors)}"
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"results": response_results,
|
||||
"coordinates_2d": result_coords[: len(search_results)],
|
||||
"coordinates_3d": result_coords[: len(search_results)],
|
||||
"query_coords": query_coords_3d,
|
||||
"pca_variance": {
|
||||
"pc1": float(pca.explained_variance_ratio_[0]),
|
||||
"pc2": float(pca.explained_variance_ratio_[1]),
|
||||
"pc3": float(pca.explained_variance_ratio_[2]),
|
||||
},
|
||||
"timing": {
|
||||
"total_ms": round(total_duration * 1000, 2),
|
||||
"search_ms": round(search_duration * 1000, 2),
|
||||
"vector_fetch_ms": round(vector_fetch_duration * 1000, 2),
|
||||
"query_embed_ms": round(query_embed_duration * 1000, 2),
|
||||
"pca_ms": round(pca_duration * 1000, 2),
|
||||
"num_results": len(search_results),
|
||||
"num_vectors": len(vectors),
|
||||
"num_chunk_vectors": len(chunk_vectors),
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -406,77 +544,118 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse:
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Type assertions - we validated these above
|
||||
assert doc_type is not None
|
||||
assert doc_id is not None
|
||||
assert start_str is not None
|
||||
assert end_str is not None
|
||||
|
||||
start = int(start_str)
|
||||
end = int(end_str)
|
||||
# Convert doc_id to int (all document types use int IDs)
|
||||
doc_id_int = int(doc_id)
|
||||
|
||||
# Currently only support notes
|
||||
if doc_type != "note":
|
||||
return JSONResponse(
|
||||
{"success": False, "error": f"Unsupported doc_type: {doc_type}"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Get authenticated HTTP client and fetch note
|
||||
# Get authenticated Nextcloud client
|
||||
from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||
_get_authenticated_client_for_userinfo,
|
||||
)
|
||||
from nextcloud_mcp_server.client.notes import NotesClient
|
||||
from nextcloud_mcp_server.search.context import get_chunk_with_context
|
||||
|
||||
# Get username from request auth
|
||||
username = (
|
||||
request.user.display_name
|
||||
if hasattr(request.user, "display_name")
|
||||
else "unknown"
|
||||
)
|
||||
# Use context expansion module to fetch chunk with surrounding context
|
||||
async with await _get_authenticated_client_for_userinfo(request) as nc_client:
|
||||
chunk_context = await get_chunk_with_context(
|
||||
nc_client=nc_client,
|
||||
user_id=request.user.display_name, # User ID from auth
|
||||
doc_id=doc_id_int,
|
||||
doc_type=doc_type,
|
||||
chunk_start=start,
|
||||
chunk_end=end,
|
||||
context_chars=context_chars,
|
||||
)
|
||||
|
||||
# Create notes client with authenticated HTTP client
|
||||
http_client = await _get_authenticated_client_for_userinfo(request)
|
||||
notes_client = NotesClient(http_client, username)
|
||||
|
||||
# Fetch full note content
|
||||
note = await notes_client.get_note(int(doc_id))
|
||||
full_content = f"{note['title']}\n\n{note['content']}"
|
||||
|
||||
# Validate offsets
|
||||
if start < 0 or end > len(full_content) or start >= end:
|
||||
# Check if context expansion succeeded
|
||||
if chunk_context is None:
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Invalid offsets: start={start}, end={end}, content_length={len(full_content)}",
|
||||
"error": f"Failed to fetch chunk context for {doc_type} {doc_id}",
|
||||
},
|
||||
status_code=400,
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
# Extract chunk
|
||||
chunk_text = full_content[start:end]
|
||||
|
||||
# Extract context before and after
|
||||
before_start = max(0, start - context_chars)
|
||||
before_context = full_content[before_start:start]
|
||||
|
||||
after_end = min(len(full_content), end + context_chars)
|
||||
after_context = full_content[end:after_end]
|
||||
|
||||
# Determine if there's more content
|
||||
has_more_before = before_start > 0
|
||||
has_more_after = after_end < len(full_content)
|
||||
|
||||
logger.info(
|
||||
f"Fetched chunk context for {doc_type}_{doc_id}: "
|
||||
f"chunk_len={len(chunk_text)}, before_len={len(before_context)}, "
|
||||
f"after_len={len(after_context)}"
|
||||
f"chunk_len={len(chunk_context.chunk_text)}, "
|
||||
f"before_len={len(chunk_context.before_context)}, "
|
||||
f"after_len={len(chunk_context.after_context)}"
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"chunk_text": chunk_text,
|
||||
"before_context": before_context,
|
||||
"after_context": after_context,
|
||||
"has_more_before": has_more_before,
|
||||
"has_more_after": has_more_after,
|
||||
}
|
||||
)
|
||||
# For PDF files, also fetch the highlighted page image from Qdrant
|
||||
highlighted_page_image = None
|
||||
page_number = None
|
||||
if doc_type == "file":
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
settings = get_settings()
|
||||
qdrant_client = await get_qdrant_client()
|
||||
username = request.user.display_name
|
||||
|
||||
# Query for this specific chunk's highlighted image
|
||||
points_response = await qdrant_client.scroll(
|
||||
collection_name=settings.get_collection_name(),
|
||||
scroll_filter=Filter(
|
||||
must=[
|
||||
get_placeholder_filter(),
|
||||
FieldCondition(
|
||||
key="doc_id", match=MatchValue(value=doc_id_int)
|
||||
),
|
||||
FieldCondition(
|
||||
key="user_id", match=MatchValue(value=username)
|
||||
),
|
||||
FieldCondition(
|
||||
key="chunk_start_offset", match=MatchValue(value=start)
|
||||
),
|
||||
FieldCondition(
|
||||
key="chunk_end_offset", match=MatchValue(value=end)
|
||||
),
|
||||
]
|
||||
),
|
||||
limit=1,
|
||||
with_vectors=False,
|
||||
with_payload=["highlighted_page_image", "page_number"],
|
||||
)
|
||||
|
||||
points = points_response[0]
|
||||
if points and points[0].payload:
|
||||
highlighted_page_image = points[0].payload.get(
|
||||
"highlighted_page_image"
|
||||
)
|
||||
page_number = points[0].payload.get("page_number")
|
||||
if highlighted_page_image:
|
||||
logger.info(
|
||||
f"Found highlighted image for chunk: "
|
||||
f"page={page_number}, image_size={len(highlighted_page_image)}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch highlighted image: {e}")
|
||||
|
||||
# Return response compatible with frontend expectations
|
||||
response_data: dict = {
|
||||
"success": True,
|
||||
"chunk_text": chunk_context.chunk_text,
|
||||
"before_context": chunk_context.before_context,
|
||||
"after_context": chunk_context.after_context,
|
||||
"has_more_before": chunk_context.has_before_truncation,
|
||||
"has_more_after": chunk_context.has_after_truncation,
|
||||
}
|
||||
|
||||
# Add image data if available
|
||||
if highlighted_page_image:
|
||||
response_data["highlighted_page_image"] = highlighted_page_image
|
||||
response_data["page_number"] = page_number
|
||||
|
||||
return JSONResponse(response_data)
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"Invalid parameter format: {e}")
|
||||
|
||||
@@ -139,6 +139,7 @@ async def _get_authenticated_client(request: Request) -> httpx.AsyncClient:
|
||||
raise RuntimeError("BasicAuth credentials not configured")
|
||||
|
||||
assert nextcloud_host is not None # Type narrowing for type checker
|
||||
assert username is not None and password is not None # Type narrowing
|
||||
return httpx.AsyncClient(
|
||||
base_url=nextcloud_host,
|
||||
auth=(username, password),
|
||||
|
||||
@@ -29,9 +29,9 @@ from .app import get_app
|
||||
@click.option(
|
||||
"--transport",
|
||||
"-t",
|
||||
default="sse",
|
||||
default="streamable-http",
|
||||
show_default=True,
|
||||
type=click.Choice(["sse", "streamable-http", "http"]),
|
||||
type=click.Choice(["streamable-http", "http"]),
|
||||
help="MCP transport protocol",
|
||||
)
|
||||
@click.option(
|
||||
|
||||
@@ -18,6 +18,7 @@ from .contacts import ContactsClient
|
||||
from .cookbook import CookbookClient
|
||||
from .deck import DeckClient
|
||||
from .groups import GroupsClient
|
||||
from .news import NewsClient
|
||||
from .notes import NotesClient
|
||||
from .sharing import SharingClient
|
||||
from .tables import TablesClient
|
||||
@@ -81,6 +82,7 @@ class NextcloudClient:
|
||||
self.contacts = ContactsClient(self._client, username)
|
||||
self.cookbook = CookbookClient(self._client, username)
|
||||
self.deck = DeckClient(self._client, username)
|
||||
self.news = NewsClient(self._client, username)
|
||||
self.users = UsersClient(self._client, username)
|
||||
self.groups = GroupsClient(self._client, username)
|
||||
self.sharing = SharingClient(self._client, username)
|
||||
@@ -130,10 +132,75 @@ class NextcloudClient:
|
||||
all_notes = self.notes.get_all_notes()
|
||||
return await self._notes_search.search_notes(all_notes, query)
|
||||
|
||||
async def find_files_by_tag(
|
||||
self, tag_name: str, mime_type_filter: str | None = None
|
||||
) -> list[dict]:
|
||||
"""Find files by system tag name, optionally filtered by MIME type.
|
||||
|
||||
This method coordinates tag lookup and file retrieval via WebDAV:
|
||||
1. Look up the tag ID by name
|
||||
2. Get all files with that tag (via REPORT with full metadata)
|
||||
3. Optionally filter by MIME type
|
||||
|
||||
Args:
|
||||
tag_name: Name of the system tag to search for (e.g., "vector-index")
|
||||
mime_type_filter: Optional MIME type filter (e.g., "application/pdf")
|
||||
|
||||
Returns:
|
||||
List of file dictionaries with WebDAV properties (path, size, content_type, etc.)
|
||||
|
||||
Raises:
|
||||
RuntimeError: If tag lookup or file query fails
|
||||
|
||||
Examples:
|
||||
# Find all files with "vector-index" tag
|
||||
files = await nc_client.find_files_by_tag("vector-index")
|
||||
|
||||
# Find only PDFs with the tag
|
||||
pdfs = await nc_client.find_files_by_tag("vector-index", "application/pdf")
|
||||
"""
|
||||
# Look up tag by name using WebDAV
|
||||
tag = await self.webdav.get_tag_by_name(tag_name)
|
||||
if not tag:
|
||||
logger.debug(f"Tag '{tag_name}' not found, returning empty list")
|
||||
return []
|
||||
|
||||
# Get files with this tag (returns full file info from REPORT)
|
||||
files = await self.webdav.get_files_by_tag(tag["id"])
|
||||
if not files:
|
||||
logger.debug(f"No files found with tag '{tag_name}'")
|
||||
return []
|
||||
|
||||
logger.debug(f"Found {len(files)} files with tag '{tag_name}'")
|
||||
|
||||
# Apply MIME type filter if specified
|
||||
if mime_type_filter:
|
||||
filtered_files = [
|
||||
f
|
||||
for f in files
|
||||
if f.get("content_type", "").startswith(mime_type_filter)
|
||||
]
|
||||
logger.info(
|
||||
f"Returning {len(filtered_files)} files with tag '{tag_name}' (filtered by {mime_type_filter})"
|
||||
)
|
||||
return filtered_files
|
||||
|
||||
logger.info(f"Returning {len(files)} files with tag '{tag_name}'")
|
||||
return files
|
||||
|
||||
def _get_webdav_base_path(self) -> str:
|
||||
"""Helper to get the base WebDAV path for the authenticated user."""
|
||||
return f"/remote.php/dav/files/{self.username}"
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Async context manager entry."""
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Async context manager exit - closes all clients."""
|
||||
await self.close()
|
||||
return False # Don't suppress exceptions
|
||||
|
||||
async def close(self):
|
||||
"""Close the HTTP client and CalDAV client."""
|
||||
await self._client.aclose()
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
"""Client for Nextcloud News app operations."""
|
||||
|
||||
import logging
|
||||
from enum import IntEnum
|
||||
from typing import Any
|
||||
|
||||
from .base import BaseNextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NewsItemType(IntEnum):
|
||||
"""Type constants for News API item queries."""
|
||||
|
||||
FEED = 0 # Single feed
|
||||
FOLDER = 1 # Folder and its feeds
|
||||
STARRED = 2 # All starred items
|
||||
ALL = 3 # All items
|
||||
|
||||
|
||||
class NewsClient(BaseNextcloudClient):
|
||||
"""Client for Nextcloud News app operations."""
|
||||
|
||||
app_name = "news"
|
||||
API_BASE = "/apps/news/api/v1-3"
|
||||
|
||||
# --- Folders ---
|
||||
|
||||
async def get_folders(self) -> list[dict[str, Any]]:
|
||||
"""Get all folders."""
|
||||
response = await self._make_request("GET", f"{self.API_BASE}/folders")
|
||||
return response.json().get("folders", [])
|
||||
|
||||
async def create_folder(self, name: str) -> dict[str, Any]:
|
||||
"""Create a new folder.
|
||||
|
||||
Args:
|
||||
name: Folder name
|
||||
|
||||
Returns:
|
||||
Created folder data
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: 409 if folder name already exists,
|
||||
422 if name is empty
|
||||
"""
|
||||
response = await self._make_request(
|
||||
"POST", f"{self.API_BASE}/folders", json={"name": name}
|
||||
)
|
||||
folders = response.json().get("folders", [])
|
||||
return folders[0] if folders else {}
|
||||
|
||||
async def rename_folder(self, folder_id: int, name: str) -> None:
|
||||
"""Rename a folder.
|
||||
|
||||
Args:
|
||||
folder_id: Folder ID
|
||||
name: New folder name
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: 404 if folder not found, 409 if name exists
|
||||
"""
|
||||
await self._make_request(
|
||||
"PUT", f"{self.API_BASE}/folders/{folder_id}", json={"name": name}
|
||||
)
|
||||
|
||||
async def delete_folder(self, folder_id: int) -> None:
|
||||
"""Delete a folder and all its feeds/items.
|
||||
|
||||
Args:
|
||||
folder_id: Folder ID
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: 404 if folder not found
|
||||
"""
|
||||
await self._make_request("DELETE", f"{self.API_BASE}/folders/{folder_id}")
|
||||
|
||||
async def mark_folder_read(self, folder_id: int, newest_item_id: int) -> None:
|
||||
"""Mark all items in a folder as read.
|
||||
|
||||
Args:
|
||||
folder_id: Folder ID
|
||||
newest_item_id: ID of newest item to mark read (prevents marking
|
||||
items user hasn't seen yet)
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: 404 if folder not found
|
||||
"""
|
||||
await self._make_request(
|
||||
"POST",
|
||||
f"{self.API_BASE}/folders/{folder_id}/read",
|
||||
json={"newestItemId": newest_item_id},
|
||||
)
|
||||
|
||||
# --- Feeds ---
|
||||
|
||||
async def get_feeds(self) -> dict[str, Any]:
|
||||
"""Get all feeds with metadata.
|
||||
|
||||
Returns:
|
||||
Dict with keys:
|
||||
- feeds: List of feed objects
|
||||
- starredCount: Number of starred items
|
||||
- newestItemId: ID of newest item (omitted if no items)
|
||||
"""
|
||||
response = await self._make_request("GET", f"{self.API_BASE}/feeds")
|
||||
return response.json()
|
||||
|
||||
async def create_feed(
|
||||
self, url: str, folder_id: int | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Subscribe to a new feed.
|
||||
|
||||
Args:
|
||||
url: Feed URL
|
||||
folder_id: Optional folder ID (None for root)
|
||||
|
||||
Returns:
|
||||
Created feed data
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: 409 if feed already exists, 422 if URL is invalid
|
||||
"""
|
||||
body: dict[str, Any] = {"url": url}
|
||||
if folder_id is not None:
|
||||
body["folderId"] = folder_id
|
||||
response = await self._make_request("POST", f"{self.API_BASE}/feeds", json=body)
|
||||
data = response.json()
|
||||
feeds = data.get("feeds", [])
|
||||
return feeds[0] if feeds else {}
|
||||
|
||||
async def delete_feed(self, feed_id: int) -> None:
|
||||
"""Unsubscribe from a feed (deletes all items).
|
||||
|
||||
Args:
|
||||
feed_id: Feed ID
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: 404 if feed not found
|
||||
"""
|
||||
await self._make_request("DELETE", f"{self.API_BASE}/feeds/{feed_id}")
|
||||
|
||||
async def move_feed(self, feed_id: int, folder_id: int | None) -> None:
|
||||
"""Move a feed to a different folder.
|
||||
|
||||
Args:
|
||||
feed_id: Feed ID
|
||||
folder_id: Target folder ID (None for root)
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: 404 if feed not found
|
||||
"""
|
||||
await self._make_request(
|
||||
"POST",
|
||||
f"{self.API_BASE}/feeds/{feed_id}/move",
|
||||
json={"folderId": folder_id},
|
||||
)
|
||||
|
||||
async def rename_feed(self, feed_id: int, title: str) -> None:
|
||||
"""Rename a feed.
|
||||
|
||||
Args:
|
||||
feed_id: Feed ID
|
||||
title: New feed title
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: 404 if feed not found
|
||||
"""
|
||||
await self._make_request(
|
||||
"POST",
|
||||
f"{self.API_BASE}/feeds/{feed_id}/rename",
|
||||
json={"feedTitle": title},
|
||||
)
|
||||
|
||||
async def mark_feed_read(self, feed_id: int, newest_item_id: int) -> None:
|
||||
"""Mark all items in a feed as read.
|
||||
|
||||
Args:
|
||||
feed_id: Feed ID
|
||||
newest_item_id: ID of newest item to mark read
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: 404 if feed not found
|
||||
"""
|
||||
await self._make_request(
|
||||
"POST",
|
||||
f"{self.API_BASE}/feeds/{feed_id}/read",
|
||||
json={"newestItemId": newest_item_id},
|
||||
)
|
||||
|
||||
# --- Items ---
|
||||
|
||||
async def get_items(
|
||||
self,
|
||||
batch_size: int = 50,
|
||||
offset: int = 0,
|
||||
type_: int = NewsItemType.ALL,
|
||||
id_: int = 0,
|
||||
get_read: bool = True,
|
||||
oldest_first: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get items (articles) with filtering.
|
||||
|
||||
Args:
|
||||
batch_size: Number of items to return (-1 for all)
|
||||
offset: Item ID to start after (for pagination)
|
||||
type_: Item type filter (NewsItemType)
|
||||
id_: Feed/folder ID (ignored for STARRED/ALL types)
|
||||
get_read: Include read items
|
||||
oldest_first: Sort oldest first instead of newest
|
||||
|
||||
Returns:
|
||||
List of item objects
|
||||
"""
|
||||
params: dict[str, Any] = {
|
||||
"batchSize": batch_size,
|
||||
"offset": offset,
|
||||
"type": type_,
|
||||
"id": id_,
|
||||
"getRead": str(get_read).lower(),
|
||||
"oldestFirst": str(oldest_first).lower(),
|
||||
}
|
||||
response = await self._make_request(
|
||||
"GET", f"{self.API_BASE}/items", params=params
|
||||
)
|
||||
return response.json().get("items", [])
|
||||
|
||||
async def get_item(self, item_id: int) -> dict[str, Any]:
|
||||
"""Get a specific item by ID.
|
||||
|
||||
Note: The News API doesn't have a direct single-item endpoint,
|
||||
so we fetch all items and filter. For efficiency, consider
|
||||
caching or using get_items with specific feed if known.
|
||||
|
||||
Args:
|
||||
item_id: Item ID
|
||||
|
||||
Returns:
|
||||
Item data
|
||||
|
||||
Raises:
|
||||
ValueError: If item not found
|
||||
"""
|
||||
# Fetch all items and find the one we need
|
||||
# This is inefficient but the API doesn't provide a direct endpoint
|
||||
items = await self.get_items(batch_size=-1, get_read=True)
|
||||
for item in items:
|
||||
if item.get("id") == item_id:
|
||||
return item
|
||||
raise ValueError(f"Item {item_id} not found")
|
||||
|
||||
async def get_updated_items(
|
||||
self,
|
||||
last_modified: int,
|
||||
type_: int = NewsItemType.ALL,
|
||||
id_: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get items modified since a timestamp (for delta sync).
|
||||
|
||||
Args:
|
||||
last_modified: Unix timestamp (seconds or microseconds)
|
||||
type_: Item type filter
|
||||
id_: Feed/folder ID
|
||||
|
||||
Returns:
|
||||
List of modified items (includes deleted items)
|
||||
"""
|
||||
params: dict[str, Any] = {
|
||||
"lastModified": last_modified,
|
||||
"type": type_,
|
||||
"id": id_,
|
||||
}
|
||||
response = await self._make_request(
|
||||
"GET", f"{self.API_BASE}/items/updated", params=params
|
||||
)
|
||||
return response.json().get("items", [])
|
||||
|
||||
async def mark_item_read(self, item_id: int) -> None:
|
||||
"""Mark a single item as read.
|
||||
|
||||
Args:
|
||||
item_id: Item ID
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: 404 if item not found
|
||||
"""
|
||||
await self._make_request("POST", f"{self.API_BASE}/items/{item_id}/read")
|
||||
|
||||
async def mark_item_unread(self, item_id: int) -> None:
|
||||
"""Mark a single item as unread.
|
||||
|
||||
Args:
|
||||
item_id: Item ID
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: 404 if item not found
|
||||
"""
|
||||
await self._make_request("POST", f"{self.API_BASE}/items/{item_id}/unread")
|
||||
|
||||
async def star_item(self, item_id: int) -> None:
|
||||
"""Star (favorite) a single item.
|
||||
|
||||
Args:
|
||||
item_id: Item ID
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: 404 if item not found
|
||||
"""
|
||||
await self._make_request("POST", f"{self.API_BASE}/items/{item_id}/star")
|
||||
|
||||
async def unstar_item(self, item_id: int) -> None:
|
||||
"""Unstar a single item.
|
||||
|
||||
Args:
|
||||
item_id: Item ID
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: 404 if item not found
|
||||
"""
|
||||
await self._make_request("POST", f"{self.API_BASE}/items/{item_id}/unstar")
|
||||
|
||||
async def mark_items_read(self, item_ids: list[int]) -> None:
|
||||
"""Mark multiple items as read.
|
||||
|
||||
Args:
|
||||
item_ids: List of item IDs
|
||||
"""
|
||||
await self._make_request(
|
||||
"POST", f"{self.API_BASE}/items/read/multiple", json={"itemIds": item_ids}
|
||||
)
|
||||
|
||||
async def mark_items_unread(self, item_ids: list[int]) -> None:
|
||||
"""Mark multiple items as unread.
|
||||
|
||||
Args:
|
||||
item_ids: List of item IDs
|
||||
"""
|
||||
await self._make_request(
|
||||
"POST",
|
||||
f"{self.API_BASE}/items/unread/multiple",
|
||||
json={"itemIds": item_ids},
|
||||
)
|
||||
|
||||
async def star_items(self, item_ids: list[int]) -> None:
|
||||
"""Star multiple items.
|
||||
|
||||
Args:
|
||||
item_ids: List of item IDs
|
||||
"""
|
||||
await self._make_request(
|
||||
"POST", f"{self.API_BASE}/items/star/multiple", json={"itemIds": item_ids}
|
||||
)
|
||||
|
||||
async def unstar_items(self, item_ids: list[int]) -> None:
|
||||
"""Unstar multiple items.
|
||||
|
||||
Args:
|
||||
item_ids: List of item IDs
|
||||
"""
|
||||
await self._make_request(
|
||||
"POST",
|
||||
f"{self.API_BASE}/items/unstar/multiple",
|
||||
json={"itemIds": item_ids},
|
||||
)
|
||||
|
||||
async def mark_all_read(self, newest_item_id: int) -> None:
|
||||
"""Mark all items as read.
|
||||
|
||||
Args:
|
||||
newest_item_id: ID of newest item to mark read
|
||||
"""
|
||||
await self._make_request(
|
||||
"POST", f"{self.API_BASE}/items/read", json={"newestItemId": newest_item_id}
|
||||
)
|
||||
|
||||
# --- Status ---
|
||||
|
||||
async def get_status(self) -> dict[str, Any]:
|
||||
"""Get News app status and configuration.
|
||||
|
||||
Returns:
|
||||
Dict with version and warnings
|
||||
"""
|
||||
response = await self._make_request("GET", f"{self.API_BASE}/status")
|
||||
return response.json()
|
||||
|
||||
async def get_version(self) -> str:
|
||||
"""Get News app version.
|
||||
|
||||
Returns:
|
||||
Version string (e.g., "25.0.0")
|
||||
"""
|
||||
response = await self._make_request("GET", f"{self.API_BASE}/version")
|
||||
return response.json().get("version", "")
|
||||
@@ -821,6 +821,20 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
item["file_id"] = int(value) if value else None
|
||||
elif tag == "favorite":
|
||||
item["is_favorite"] = value == "1"
|
||||
elif tag == "tags":
|
||||
# Tags can be comma-separated or have multiple child elements
|
||||
if value:
|
||||
# Handle comma-separated tags
|
||||
item["tags"] = [
|
||||
t.strip() for t in value.split(",") if t.strip()
|
||||
]
|
||||
else:
|
||||
# Check for child tag elements (alternative format)
|
||||
tag_elements = child.findall(".//{http://owncloud.org/ns}tag")
|
||||
if tag_elements:
|
||||
item["tags"] = [t.text for t in tag_elements if t.text]
|
||||
else:
|
||||
item["tags"] = []
|
||||
elif tag == "permissions":
|
||||
item["permissions"] = value
|
||||
elif tag == "size":
|
||||
@@ -948,3 +962,574 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
properties=properties,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
async def find_by_tag(
|
||||
self, tag_name: str, scope: str = "", limit: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Find files by tag name.
|
||||
|
||||
DEPRECATED: Use NextcloudClient.find_files_by_tag() instead, which uses
|
||||
the proper OCS Tags API rather than WebDAV SEARCH.
|
||||
|
||||
Args:
|
||||
tag_name: Tag to filter by (e.g., "vector-index")
|
||||
scope: Directory path to search in (empty string for user root)
|
||||
limit: Maximum number of results to return
|
||||
|
||||
Returns:
|
||||
List of files/directories with the specified tag
|
||||
|
||||
Examples:
|
||||
# Find all files tagged with "vector-index"
|
||||
results = await find_by_tag("vector-index")
|
||||
|
||||
# Find tagged files in a specific folder
|
||||
results = await find_by_tag("vector-index", scope="Documents")
|
||||
"""
|
||||
# Use LIKE for tag matching since tags can be comma-separated
|
||||
where_conditions = f"""
|
||||
<d:like>
|
||||
<d:prop>
|
||||
<oc:tags/>
|
||||
</d:prop>
|
||||
<d:literal>%{tag_name}%</d:literal>
|
||||
</d:like>
|
||||
"""
|
||||
|
||||
# Request tag property along with standard properties
|
||||
properties = [
|
||||
"displayname",
|
||||
"getcontentlength",
|
||||
"getcontenttype",
|
||||
"getlastmodified",
|
||||
"resourcetype",
|
||||
"getetag",
|
||||
"fileid",
|
||||
"tags",
|
||||
]
|
||||
|
||||
return await self.search_files(
|
||||
scope=scope,
|
||||
where_conditions=where_conditions,
|
||||
properties=properties,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
async def _get_file_info_by_id(self, file_id: int) -> Dict[str, Any]:
|
||||
"""Get file information by Nextcloud file ID using WebDAV.
|
||||
|
||||
Args:
|
||||
file_id: Nextcloud internal file ID
|
||||
|
||||
Returns:
|
||||
File information dictionary with path, size, content_type, etc.
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: If file not found or request fails
|
||||
"""
|
||||
# Nextcloud allows accessing files by ID via special meta endpoint
|
||||
meta_path = f"/remote.php/dav/meta/{file_id}/"
|
||||
|
||||
propfind_body = """<?xml version="1.0"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
<d:getcontentlength/>
|
||||
<d:getcontenttype/>
|
||||
<d:getlastmodified/>
|
||||
<d:resourcetype/>
|
||||
<d:getetag/>
|
||||
<oc:fileid/>
|
||||
</d:prop>
|
||||
</d:propfind>"""
|
||||
|
||||
headers = {"Depth": "0", "Content-Type": "text/xml", "OCS-APIRequest": "true"}
|
||||
|
||||
response = await self._make_request(
|
||||
"PROPFIND", meta_path, content=propfind_body, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse the XML response
|
||||
root = ET.fromstring(response.content)
|
||||
responses = root.findall(".//{DAV:}response")
|
||||
|
||||
if not responses:
|
||||
raise RuntimeError(f"File ID {file_id} not found")
|
||||
|
||||
response_elem = responses[0]
|
||||
href = response_elem.find(".//{DAV:}href")
|
||||
if href is None:
|
||||
raise RuntimeError(f"No href in response for file ID {file_id}")
|
||||
|
||||
propstat = response_elem.find(".//{DAV:}propstat")
|
||||
if propstat is None:
|
||||
raise RuntimeError(f"No propstat for file ID {file_id}")
|
||||
|
||||
prop = propstat.find(".//{DAV:}prop")
|
||||
if prop is None:
|
||||
raise RuntimeError(f"No prop for file ID {file_id}")
|
||||
|
||||
# Extract file path from displayname or construct from file ID
|
||||
displayname_elem = prop.find(".//{DAV:}displayname")
|
||||
name = (
|
||||
displayname_elem.text if displayname_elem is not None else f"file_{file_id}"
|
||||
)
|
||||
|
||||
# Get file properties
|
||||
size_elem = prop.find(".//{DAV:}getcontentlength")
|
||||
size = int(size_elem.text) if size_elem is not None and size_elem.text else 0
|
||||
|
||||
content_type_elem = prop.find(".//{DAV:}getcontenttype")
|
||||
content_type = content_type_elem.text if content_type_elem is not None else None
|
||||
|
||||
modified_elem = prop.find(".//{DAV:}getlastmodified")
|
||||
modified = modified_elem.text if modified_elem is not None else None
|
||||
|
||||
etag_elem = prop.find(".//{DAV:}getetag")
|
||||
etag = (
|
||||
etag_elem.text.strip('"')
|
||||
if etag_elem is not None and etag_elem.text
|
||||
else None
|
||||
)
|
||||
|
||||
# Check if it's a directory
|
||||
resourcetype = prop.find(".//{DAV:}resourcetype")
|
||||
is_directory = (
|
||||
resourcetype is not None
|
||||
and resourcetype.find(".//{DAV:}collection") is not None
|
||||
)
|
||||
|
||||
# Try to get actual file path - meta endpoint doesn't give us the real path
|
||||
# so we'll construct a reasonable path from the name
|
||||
# The calling code in NextcloudClient will have the context to determine the actual path
|
||||
file_info = {
|
||||
"name": name,
|
||||
"path": f"/{name}", # Placeholder - caller should use WebDAV to get real path if needed
|
||||
"size": size,
|
||||
"content_type": content_type,
|
||||
"last_modified": modified,
|
||||
"etag": etag,
|
||||
"is_directory": is_directory,
|
||||
"file_id": file_id,
|
||||
}
|
||||
|
||||
logger.debug(f"Retrieved file info for ID {file_id}: {name}")
|
||||
return file_info
|
||||
|
||||
async def get_tag_by_name(self, tag_name: str) -> dict[str, Any] | None:
|
||||
"""Get a system tag by its name via WebDAV.
|
||||
|
||||
Args:
|
||||
tag_name: Name of the tag to find (case-sensitive)
|
||||
|
||||
Returns:
|
||||
Tag dictionary if found, None otherwise
|
||||
"""
|
||||
# Use WebDAV PROPFIND to list all systemtags
|
||||
propfind_body = """<?xml version="1.0"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:prop>
|
||||
<oc:id/>
|
||||
<oc:display-name/>
|
||||
<oc:user-visible/>
|
||||
<oc:user-assignable/>
|
||||
</d:prop>
|
||||
</d:propfind>"""
|
||||
|
||||
response = await self._client.request(
|
||||
"PROPFIND",
|
||||
"/remote.php/dav/systemtags/",
|
||||
headers={"Depth": "1"},
|
||||
content=propfind_body,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse XML response
|
||||
root = ET.fromstring(response.content)
|
||||
ns = {
|
||||
"d": "DAV:",
|
||||
"oc": "http://owncloud.org/ns",
|
||||
}
|
||||
|
||||
for response_elem in root.findall("d:response", ns):
|
||||
href = response_elem.find("d:href", ns)
|
||||
if href is None or href.text == "/remote.php/dav/systemtags/":
|
||||
# Skip the collection itself
|
||||
continue
|
||||
|
||||
propstat = response_elem.find("d:propstat", ns)
|
||||
if propstat is None:
|
||||
continue
|
||||
|
||||
prop = propstat.find("d:prop", ns)
|
||||
if prop is None:
|
||||
continue
|
||||
|
||||
# Extract tag properties
|
||||
tag_id_elem = prop.find("oc:id", ns)
|
||||
display_name_elem = prop.find("oc:display-name", ns)
|
||||
user_visible_elem = prop.find("oc:user-visible", ns)
|
||||
user_assignable_elem = prop.find("oc:user-assignable", ns)
|
||||
|
||||
if display_name_elem is not None and display_name_elem.text == tag_name:
|
||||
tag_info = {
|
||||
"id": int(tag_id_elem.text)
|
||||
if tag_id_elem is not None and tag_id_elem.text is not None
|
||||
else None,
|
||||
"name": display_name_elem.text,
|
||||
"userVisible": user_visible_elem.text.lower() == "true"
|
||||
if user_visible_elem is not None
|
||||
else True,
|
||||
"userAssignable": user_assignable_elem.text.lower() == "true"
|
||||
if user_assignable_elem is not None
|
||||
else True,
|
||||
}
|
||||
logger.debug(f"Found tag '{tag_name}' with ID {tag_info['id']}")
|
||||
return tag_info
|
||||
|
||||
logger.debug(f"Tag '{tag_name}' not found")
|
||||
return None
|
||||
|
||||
async def get_files_by_tag(self, tag_id: int) -> list[dict[str, Any]]:
|
||||
"""Get all files tagged with a specific system tag via WebDAV REPORT.
|
||||
|
||||
Args:
|
||||
tag_id: Numeric ID of the tag
|
||||
|
||||
Returns:
|
||||
List of file info dictionaries with path, size, content_type, etc.
|
||||
"""
|
||||
# Use WebDAV REPORT method with systemtag filter, requesting all properties
|
||||
report_body = f"""<?xml version="1.0"?>
|
||||
<oc:filter-files xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
||||
<d:prop>
|
||||
<oc:fileid/>
|
||||
<d:displayname/>
|
||||
<d:getcontentlength/>
|
||||
<d:getcontenttype/>
|
||||
<d:getlastmodified/>
|
||||
<d:getetag/>
|
||||
</d:prop>
|
||||
<oc:filter-rules>
|
||||
<oc:systemtag>{tag_id}</oc:systemtag>
|
||||
</oc:filter-rules>
|
||||
</oc:filter-files>"""
|
||||
|
||||
response = await self._client.request(
|
||||
"REPORT",
|
||||
f"{self._get_webdav_base_path()}/",
|
||||
content=report_body,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse XML response
|
||||
root = ET.fromstring(response.content)
|
||||
ns = {
|
||||
"d": "DAV:",
|
||||
"oc": "http://owncloud.org/ns",
|
||||
}
|
||||
|
||||
files = []
|
||||
for response_elem in root.findall("d:response", ns):
|
||||
# Extract href (file path)
|
||||
href_elem = response_elem.find("d:href", ns)
|
||||
if href_elem is None or not href_elem.text:
|
||||
continue
|
||||
|
||||
propstat = response_elem.find("d:propstat", ns)
|
||||
if propstat is None:
|
||||
continue
|
||||
|
||||
prop = propstat.find("d:prop", ns)
|
||||
if prop is None:
|
||||
continue
|
||||
|
||||
# Extract all properties
|
||||
fileid_elem = prop.find("oc:fileid", ns)
|
||||
displayname_elem = prop.find("d:displayname", ns)
|
||||
contentlength_elem = prop.find("d:getcontentlength", ns)
|
||||
contenttype_elem = prop.find("d:getcontenttype", ns)
|
||||
lastmodified_elem = prop.find("d:getlastmodified", ns)
|
||||
etag_elem = prop.find("d:getetag", ns)
|
||||
|
||||
if fileid_elem is None or not fileid_elem.text:
|
||||
continue
|
||||
|
||||
# Decode href path and extract the file path
|
||||
from urllib.parse import unquote
|
||||
|
||||
href_path = unquote(href_elem.text)
|
||||
# Remove WebDAV prefix to get user-relative path
|
||||
webdav_prefix = f"/remote.php/dav/files/{self.username}/"
|
||||
file_path = href_path.replace(webdav_prefix, "/")
|
||||
|
||||
# Parse last modified timestamp
|
||||
last_modified_timestamp = None
|
||||
if lastmodified_elem is not None and lastmodified_elem.text:
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
try:
|
||||
dt = parsedate_to_datetime(lastmodified_elem.text)
|
||||
last_modified_timestamp = int(dt.timestamp())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
file_info = {
|
||||
"id": int(fileid_elem.text),
|
||||
"path": file_path,
|
||||
"name": displayname_elem.text
|
||||
if displayname_elem is not None
|
||||
else file_path.split("/")[-1],
|
||||
"size": int(contentlength_elem.text)
|
||||
if contentlength_elem is not None and contentlength_elem.text
|
||||
else 0,
|
||||
"content_type": contenttype_elem.text
|
||||
if contenttype_elem is not None
|
||||
else "",
|
||||
"last_modified": lastmodified_elem.text
|
||||
if lastmodified_elem is not None
|
||||
else None,
|
||||
"last_modified_timestamp": last_modified_timestamp,
|
||||
"etag": etag_elem.text if etag_elem is not None else None,
|
||||
}
|
||||
files.append(file_info)
|
||||
|
||||
logger.debug(f"Found {len(files)} files with tag ID {tag_id}")
|
||||
return files
|
||||
|
||||
async def get_file_info(self, path: str) -> dict[str, Any] | None:
|
||||
"""Get file info including file ID via WebDAV PROPFIND.
|
||||
|
||||
Args:
|
||||
path: Path to the file (relative to user's files directory)
|
||||
|
||||
Returns:
|
||||
File info dictionary with id, name, size, content_type, etc.
|
||||
Returns None if file not found.
|
||||
"""
|
||||
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
|
||||
|
||||
propfind_body = """<?xml version="1.0"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:prop>
|
||||
<oc:fileid/>
|
||||
<d:displayname/>
|
||||
<d:getcontentlength/>
|
||||
<d:getcontenttype/>
|
||||
<d:getlastmodified/>
|
||||
<d:getetag/>
|
||||
<d:resourcetype/>
|
||||
</d:prop>
|
||||
</d:propfind>"""
|
||||
|
||||
try:
|
||||
response = await self._client.request(
|
||||
"PROPFIND",
|
||||
webdav_path,
|
||||
headers={"Depth": "0"},
|
||||
content=propfind_body,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
logger.debug(f"File not found: {path}")
|
||||
return None
|
||||
raise
|
||||
|
||||
# Parse XML response
|
||||
root = ET.fromstring(response.content)
|
||||
ns = {
|
||||
"d": "DAV:",
|
||||
"oc": "http://owncloud.org/ns",
|
||||
}
|
||||
|
||||
response_elem = root.find("d:response", ns)
|
||||
if response_elem is None:
|
||||
return None
|
||||
|
||||
propstat = response_elem.find("d:propstat", ns)
|
||||
if propstat is None:
|
||||
return None
|
||||
|
||||
prop = propstat.find("d:prop", ns)
|
||||
if prop is None:
|
||||
return None
|
||||
|
||||
# Extract properties
|
||||
fileid_elem = prop.find("oc:fileid", ns)
|
||||
displayname_elem = prop.find("d:displayname", ns)
|
||||
contentlength_elem = prop.find("d:getcontentlength", ns)
|
||||
contenttype_elem = prop.find("d:getcontenttype", ns)
|
||||
lastmodified_elem = prop.find("d:getlastmodified", ns)
|
||||
etag_elem = prop.find("d:getetag", ns)
|
||||
resourcetype_elem = prop.find("d:resourcetype", ns)
|
||||
|
||||
is_directory = (
|
||||
resourcetype_elem is not None
|
||||
and resourcetype_elem.find("d:collection", ns) is not None
|
||||
)
|
||||
|
||||
file_info = {
|
||||
"id": int(fileid_elem.text)
|
||||
if fileid_elem is not None and fileid_elem.text is not None
|
||||
else None,
|
||||
"path": path,
|
||||
"name": displayname_elem.text
|
||||
if displayname_elem is not None
|
||||
else path.split("/")[-1],
|
||||
"size": int(contentlength_elem.text)
|
||||
if contentlength_elem is not None and contentlength_elem.text
|
||||
else 0,
|
||||
"content_type": contenttype_elem.text
|
||||
if contenttype_elem is not None
|
||||
else "",
|
||||
"last_modified": lastmodified_elem.text
|
||||
if lastmodified_elem is not None
|
||||
else None,
|
||||
"etag": etag_elem.text.strip('"')
|
||||
if etag_elem is not None and etag_elem.text
|
||||
else None,
|
||||
"is_directory": is_directory,
|
||||
}
|
||||
|
||||
logger.debug(f"Got file info for '{path}': id={file_info['id']}")
|
||||
return file_info
|
||||
|
||||
async def create_tag(
|
||||
self,
|
||||
name: str,
|
||||
user_visible: bool = True,
|
||||
user_assignable: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a system tag via WebDAV.
|
||||
|
||||
Args:
|
||||
name: Name of the tag to create
|
||||
user_visible: Whether the tag is visible to users
|
||||
user_assignable: Whether users can assign this tag
|
||||
|
||||
Returns:
|
||||
Tag dictionary with id, name, userVisible, userAssignable
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: If tag creation fails (409 if already exists)
|
||||
"""
|
||||
# Use WebDAV POST with JSON body to create tag
|
||||
response = await self._client.post(
|
||||
"/remote.php/dav/systemtags/",
|
||||
headers={"Content-Type": "application/json"},
|
||||
json={
|
||||
"name": name,
|
||||
"userVisible": user_visible,
|
||||
"userAssignable": user_assignable,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Extract tag ID from Content-Location header (e.g., /remote.php/dav/systemtags/42)
|
||||
content_location = response.headers.get("Content-Location", "")
|
||||
tag_id = None
|
||||
if content_location:
|
||||
# Extract the numeric ID from the path
|
||||
try:
|
||||
tag_id = int(content_location.rstrip("/").split("/")[-1])
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
tag_info = {
|
||||
"id": tag_id,
|
||||
"name": name,
|
||||
"userVisible": user_visible,
|
||||
"userAssignable": user_assignable,
|
||||
}
|
||||
|
||||
logger.info(f"Created tag '{name}' with ID {tag_info['id']}")
|
||||
return tag_info
|
||||
|
||||
async def get_or_create_tag(
|
||||
self,
|
||||
name: str,
|
||||
user_visible: bool = True,
|
||||
user_assignable: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Get a tag by name, creating it if it doesn't exist.
|
||||
|
||||
Args:
|
||||
name: Name of the tag
|
||||
user_visible: Whether the tag is visible to users (for creation)
|
||||
user_assignable: Whether users can assign this tag (for creation)
|
||||
|
||||
Returns:
|
||||
Tag dictionary with id, name, userVisible, userAssignable
|
||||
"""
|
||||
# First try to get existing tag
|
||||
existing_tag = await self.get_tag_by_name(name)
|
||||
if existing_tag:
|
||||
logger.debug(f"Tag '{name}' already exists with ID {existing_tag['id']}")
|
||||
return existing_tag
|
||||
|
||||
# Create new tag
|
||||
try:
|
||||
return await self.create_tag(name, user_visible, user_assignable)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 409:
|
||||
# Tag was created between our check and creation, fetch it
|
||||
existing_tag = await self.get_tag_by_name(name)
|
||||
if existing_tag:
|
||||
return existing_tag
|
||||
raise
|
||||
|
||||
async def assign_tag_to_file(self, file_id: int, tag_id: int) -> bool:
|
||||
"""Assign a system tag to a file.
|
||||
|
||||
Args:
|
||||
file_id: Numeric file ID
|
||||
tag_id: Numeric tag ID
|
||||
|
||||
Returns:
|
||||
True if tag was assigned successfully (or already assigned)
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: If tag assignment fails
|
||||
"""
|
||||
response = await self._client.request(
|
||||
"PUT",
|
||||
f"/remote.php/dav/systemtags-relations/files/{file_id}/{tag_id}",
|
||||
headers={"Content-Length": "0"},
|
||||
content=b"",
|
||||
)
|
||||
|
||||
# 201 = Created (new assignment), 409 = Conflict (already assigned)
|
||||
if response.status_code in (201, 409):
|
||||
logger.info(f"Tagged file {file_id} with tag {tag_id}")
|
||||
return True
|
||||
|
||||
response.raise_for_status()
|
||||
return True
|
||||
|
||||
async def remove_tag_from_file(self, file_id: int, tag_id: int) -> bool:
|
||||
"""Remove a system tag from a file.
|
||||
|
||||
Args:
|
||||
file_id: Numeric file ID
|
||||
tag_id: Numeric tag ID
|
||||
|
||||
Returns:
|
||||
True if tag was removed successfully (or wasn't assigned)
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: If tag removal fails
|
||||
"""
|
||||
response = await self._client.request(
|
||||
"DELETE",
|
||||
f"/remote.php/dav/systemtags-relations/files/{file_id}/{tag_id}",
|
||||
)
|
||||
|
||||
# 204 = No Content (removed), 404 = Not Found (wasn't assigned)
|
||||
if response.status_code in (204, 404):
|
||||
logger.info(f"Removed tag {tag_id} from file {file_id}")
|
||||
return True
|
||||
|
||||
response.raise_for_status()
|
||||
return True
|
||||
|
||||
@@ -2,8 +2,37 @@ import logging
|
||||
import logging.config
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
class DeploymentMode(Enum):
|
||||
"""Deployment mode for the MCP server.
|
||||
|
||||
SELF_HOSTED: Full features, environment-based configuration.
|
||||
Supports vector sync, semantic search, admin UI.
|
||||
|
||||
SMITHERY_STATELESS: Stateless mode for Smithery hosting.
|
||||
Session-based configuration, no persistent storage.
|
||||
Excludes semantic search, vector sync, admin UI.
|
||||
"""
|
||||
|
||||
SELF_HOSTED = "self_hosted"
|
||||
SMITHERY_STATELESS = "smithery"
|
||||
|
||||
|
||||
def get_deployment_mode() -> DeploymentMode:
|
||||
"""Detect deployment mode from environment.
|
||||
|
||||
Returns:
|
||||
DeploymentMode.SMITHERY_STATELESS if SMITHERY_DEPLOYMENT=true,
|
||||
otherwise DeploymentMode.SELF_HOSTED (default).
|
||||
"""
|
||||
if os.getenv("SMITHERY_DEPLOYMENT", "false").lower() == "true":
|
||||
return DeploymentMode.SMITHERY_STATELESS
|
||||
return DeploymentMode.SELF_HOSTED
|
||||
|
||||
|
||||
LOGGING_CONFIG = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
@@ -102,6 +131,14 @@ def get_document_processor_config() -> dict[str, Any]:
|
||||
"lang": os.getenv("TESSERACT_LANG", "eng"),
|
||||
}
|
||||
|
||||
# PyMuPDF configuration (local PDF processing)
|
||||
if os.getenv("ENABLE_PYMUPDF", "true").lower() == "true": # Enabled by default
|
||||
config["processors"]["pymupdf"] = {
|
||||
"extract_images": os.getenv("PYMUPDF_EXTRACT_IMAGES", "true").lower()
|
||||
== "true",
|
||||
"image_dir": os.getenv("PYMUPDF_IMAGE_DIR"), # None = use temp directory
|
||||
}
|
||||
|
||||
# Custom processor (via HTTP API)
|
||||
if os.getenv("ENABLE_CUSTOM_PROCESSOR", "false").lower() == "true":
|
||||
custom_url = os.getenv("CUSTOM_PROCESSOR_URL")
|
||||
@@ -180,6 +217,11 @@ class Settings:
|
||||
ollama_embedding_model: str = "nomic-embed-text"
|
||||
ollama_verify_ssl: bool = True
|
||||
|
||||
# OpenAI settings (for embeddings)
|
||||
openai_api_key: Optional[str] = None
|
||||
openai_base_url: Optional[str] = None
|
||||
openai_embedding_model: str = "text-embedding-3-small"
|
||||
|
||||
# Document chunking settings (for vector embeddings)
|
||||
document_chunk_size: int = 2048 # Characters per chunk
|
||||
document_chunk_overlap: int = 200 # Overlapping characters between chunks
|
||||
@@ -238,6 +280,29 @@ class Settings:
|
||||
f"DOCUMENT_CHUNK_OVERLAP ({self.document_chunk_overlap}) cannot be negative."
|
||||
)
|
||||
|
||||
def get_embedding_model_name(self) -> str:
|
||||
"""
|
||||
Get the active embedding model name based on provider priority.
|
||||
|
||||
Priority order (same as ProviderRegistry):
|
||||
1. OpenAI - if OPENAI_API_KEY is set
|
||||
2. Ollama - if OLLAMA_BASE_URL is set
|
||||
3. Simple - fallback (returns "simple-384")
|
||||
|
||||
Returns:
|
||||
Active embedding model name
|
||||
"""
|
||||
# Check OpenAI first (higher priority than Ollama in registry)
|
||||
if self.openai_api_key:
|
||||
return self.openai_embedding_model
|
||||
|
||||
# Check Ollama
|
||||
if self.ollama_base_url:
|
||||
return self.ollama_embedding_model
|
||||
|
||||
# Fallback to simple provider indicator
|
||||
return "simple-384"
|
||||
|
||||
def get_collection_name(self) -> str:
|
||||
"""
|
||||
Get Qdrant collection name.
|
||||
@@ -253,8 +318,9 @@ class Settings:
|
||||
Format: {deployment-id}-{model-name}
|
||||
|
||||
Examples:
|
||||
- "my-deployment-nomic-embed-text" (OTEL_SERVICE_NAME set)
|
||||
- "mcp-container-all-minilm" (hostname fallback)
|
||||
- "my-deployment-nomic-embed-text" (Ollama)
|
||||
- "my-deployment-text-embedding-3-small" (OpenAI)
|
||||
- "mcp-container-openai-text-embedding-3-small" (hostname fallback)
|
||||
|
||||
Returns:
|
||||
Collection name string
|
||||
@@ -274,7 +340,7 @@ class Settings:
|
||||
|
||||
# Sanitize deployment ID and model name
|
||||
deployment_id = deployment_id.lower().replace(" ", "-").replace("_", "-")
|
||||
model_name = self.ollama_embedding_model.replace("/", "-").replace(":", "-")
|
||||
model_name = self.get_embedding_model_name().replace("/", "-").replace(":", "-")
|
||||
|
||||
return f"{deployment_id}-{model_name}"
|
||||
|
||||
@@ -334,6 +400,12 @@ def get_settings() -> Settings:
|
||||
ollama_base_url=os.getenv("OLLAMA_BASE_URL"),
|
||||
ollama_embedding_model=os.getenv("OLLAMA_EMBEDDING_MODEL", "nomic-embed-text"),
|
||||
ollama_verify_ssl=os.getenv("OLLAMA_VERIFY_SSL", "true").lower() == "true",
|
||||
# OpenAI settings
|
||||
openai_api_key=os.getenv("OPENAI_API_KEY"),
|
||||
openai_base_url=os.getenv("OPENAI_BASE_URL"),
|
||||
openai_embedding_model=os.getenv(
|
||||
"OPENAI_EMBEDDING_MODEL", "text-embedding-3-small"
|
||||
),
|
||||
# Document chunking settings
|
||||
document_chunk_size=int(os.getenv("DOCUMENT_CHUNK_SIZE", "2048")),
|
||||
document_chunk_overlap=int(os.getenv("DOCUMENT_CHUNK_OVERLAP", "200")),
|
||||
|
||||
@@ -1,21 +1,37 @@
|
||||
"""Helper functions for accessing context in MCP tools."""
|
||||
|
||||
import logging
|
||||
|
||||
from httpx import BasicAuth
|
||||
from mcp.server.fastmcp import Context
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.config import (
|
||||
DeploymentMode,
|
||||
get_deployment_mode,
|
||||
get_settings,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_client(ctx: Context) -> NextcloudClient:
|
||||
"""
|
||||
Get the appropriate Nextcloud client based on authentication mode.
|
||||
|
||||
ADR-005 compliant implementation supporting two modes:
|
||||
1. BasicAuth mode: Returns shared client from lifespan context
|
||||
2. Multi-audience mode (ENABLE_TOKEN_EXCHANGE=false, default):
|
||||
Token already contains both MCP and Nextcloud audiences - use directly
|
||||
3. Token exchange mode (ENABLE_TOKEN_EXCHANGE=true):
|
||||
Exchange MCP token for Nextcloud token via RFC 8693
|
||||
ADR-016 compliant implementation supporting three deployment modes:
|
||||
|
||||
1. Smithery stateless mode (SMITHERY_DEPLOYMENT=true):
|
||||
Create client from session configuration (nextcloud_url, username, app_password)
|
||||
No persistent state - client created per-request from Smithery session config.
|
||||
|
||||
2. BasicAuth mode: Returns shared client from lifespan context
|
||||
|
||||
3. OAuth mode:
|
||||
a. Multi-audience mode (ENABLE_TOKEN_EXCHANGE=false, default):
|
||||
Token already contains both MCP and Nextcloud audiences - use directly
|
||||
b. Token exchange mode (ENABLE_TOKEN_EXCHANGE=true):
|
||||
Exchange MCP token for Nextcloud token via RFC 8693
|
||||
|
||||
SECURITY: Token passthrough has been REMOVED. All OAuth modes validate
|
||||
proper token audiences per MCP Security Best Practices specification.
|
||||
@@ -24,7 +40,7 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
||||
by the MCP server via @require_scopes decorator, not by the IdP.
|
||||
|
||||
This function automatically detects the authentication mode by checking
|
||||
the type of the lifespan context.
|
||||
the deployment mode and type of the lifespan context.
|
||||
|
||||
Args:
|
||||
ctx: MCP request context
|
||||
@@ -34,6 +50,7 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
||||
|
||||
Raises:
|
||||
AttributeError: If context doesn't contain expected data
|
||||
ValueError: If Smithery mode but session config is missing required fields
|
||||
|
||||
Example:
|
||||
```python
|
||||
@@ -43,6 +60,12 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
||||
return await client.capabilities()
|
||||
```
|
||||
"""
|
||||
deployment_mode = get_deployment_mode()
|
||||
|
||||
# ADR-016: Smithery stateless mode - create client from session config
|
||||
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
|
||||
return _get_client_from_session_config(ctx)
|
||||
|
||||
settings = get_settings()
|
||||
lifespan_ctx = ctx.request_context.lifespan_context
|
||||
|
||||
@@ -75,3 +98,82 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
||||
f"Lifespan context does not have 'client' or 'nextcloud_host' attribute. "
|
||||
f"Type: {type(lifespan_ctx)}"
|
||||
)
|
||||
|
||||
|
||||
def _get_client_from_session_config(ctx: Context) -> NextcloudClient:
|
||||
"""
|
||||
Create NextcloudClient from Smithery session configuration.
|
||||
|
||||
ADR-016: In Smithery stateless mode, each request includes session config
|
||||
with the user's Nextcloud credentials. This function creates a fresh client
|
||||
for each request - no state is persisted between requests.
|
||||
|
||||
For container runtime, config is extracted from URL query parameters by
|
||||
SmitheryConfigMiddleware and stored in a context variable.
|
||||
|
||||
Expected session config fields (from Smithery configSchema):
|
||||
- nextcloud_url: str - Nextcloud instance URL (required)
|
||||
- username: str - Nextcloud username (required)
|
||||
- app_password: str - Nextcloud app password (required)
|
||||
|
||||
Args:
|
||||
ctx: MCP request context (not used directly for Smithery config)
|
||||
|
||||
Returns:
|
||||
NextcloudClient configured with session credentials
|
||||
|
||||
Raises:
|
||||
ValueError: If required session config fields are missing
|
||||
"""
|
||||
# ADR-016: Get session config from context variable (set by SmitheryConfigMiddleware)
|
||||
from nextcloud_mcp_server.app import get_smithery_session_config
|
||||
|
||||
session_config = get_smithery_session_config()
|
||||
|
||||
if session_config is None:
|
||||
raise ValueError(
|
||||
"Session configuration required in Smithery mode. "
|
||||
"Ensure nextcloud_url, username, and app_password are provided as URL query parameters."
|
||||
)
|
||||
|
||||
# Extract required fields - config is always a dict from SmitheryConfigMiddleware
|
||||
nextcloud_url = session_config.get("nextcloud_url")
|
||||
username = session_config.get("username")
|
||||
app_password = session_config.get("app_password")
|
||||
|
||||
# Validate required fields
|
||||
missing_fields = []
|
||||
if not nextcloud_url:
|
||||
missing_fields.append("nextcloud_url")
|
||||
if not username:
|
||||
missing_fields.append("username")
|
||||
if not app_password:
|
||||
missing_fields.append("app_password")
|
||||
|
||||
if missing_fields:
|
||||
raise ValueError(
|
||||
f"Missing required session config fields: {', '.join(missing_fields)}. "
|
||||
f"Configure these in the Smithery connection settings."
|
||||
)
|
||||
|
||||
# Type assertions after validation (for type checker)
|
||||
# These are guaranteed to be str after the missing_fields check above
|
||||
assert nextcloud_url is not None
|
||||
assert username is not None
|
||||
assert app_password is not None
|
||||
|
||||
# Validate URL format
|
||||
if not nextcloud_url.startswith(("http://", "https://")):
|
||||
raise ValueError(
|
||||
f"Invalid nextcloud_url: {nextcloud_url}. "
|
||||
f"Must start with http:// or https://"
|
||||
)
|
||||
|
||||
logger.debug(f"Creating Smithery client for {nextcloud_url} as {username}")
|
||||
|
||||
# Create client with session credentials using BasicAuth
|
||||
return NextcloudClient(
|
||||
base_url=nextcloud_url,
|
||||
username=username,
|
||||
auth=BasicAuth(username, app_password),
|
||||
)
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
"""Document processing plugins for extracting text from various file formats."""
|
||||
|
||||
from .base import DocumentProcessor, ProcessingResult, ProcessorError
|
||||
from .pymupdf import PyMuPDFProcessor
|
||||
from .registry import ProcessorRegistry, get_registry
|
||||
|
||||
# Register processors at module initialization
|
||||
_registry = get_registry()
|
||||
_registry.register(PyMuPDFProcessor(), priority=10)
|
||||
|
||||
__all__ = [
|
||||
"DocumentProcessor",
|
||||
"ProcessingResult",
|
||||
"ProcessorError",
|
||||
"ProcessorRegistry",
|
||||
"get_registry",
|
||||
"PyMuPDFProcessor",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Document processor using PyMuPDF (fitz) library."""
|
||||
|
||||
import logging
|
||||
import pathlib
|
||||
import tempfile
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
# NOTE: Do NOT call pymupdf.layout.activate() here!
|
||||
# It changes the behavior of pymupdf4llm.to_markdown() when page_chunks=True,
|
||||
# causing it to return a string instead of a list[dict].
|
||||
# See: https://github.com/pymupdf/pymupdf4llm/issues/323
|
||||
import pymupdf
|
||||
import pymupdf4llm
|
||||
|
||||
from .base import DocumentProcessor, ProcessingResult, ProcessorError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PyMuPDFProcessor(DocumentProcessor):
|
||||
"""Document processor using PyMuPDF library for PDF processing.
|
||||
|
||||
PyMuPDF (fitz) is a fast, local PDF processing library that extracts text,
|
||||
metadata, and images without requiring external API calls.
|
||||
|
||||
Features:
|
||||
- Fast text extraction with layout preservation
|
||||
- PDF metadata extraction (title, author, creation date, page count)
|
||||
- Image extraction for future multimodal support
|
||||
- Page number tracking for precise citations
|
||||
"""
|
||||
|
||||
SUPPORTED_TYPES = {
|
||||
"application/pdf",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
extract_images: bool = True,
|
||||
image_dir: Optional[str | pathlib.Path] = None,
|
||||
):
|
||||
"""Initialize PyMuPDF processor.
|
||||
|
||||
Args:
|
||||
extract_images: Whether to extract embedded images from PDFs
|
||||
image_dir: Directory to store extracted images (defaults to temp directory)
|
||||
"""
|
||||
self.extract_images = extract_images
|
||||
|
||||
if image_dir is None:
|
||||
self.image_dir = pathlib.Path(tempfile.gettempdir()) / "pdf-images"
|
||||
else:
|
||||
self.image_dir = pathlib.Path(image_dir)
|
||||
|
||||
# Create image directory if it doesn't exist
|
||||
if self.extract_images:
|
||||
self.image_dir.mkdir(exist_ok=True, parents=True)
|
||||
logger.info(
|
||||
f"Initialized PyMuPDFProcessor with image extraction to {self.image_dir}"
|
||||
)
|
||||
else:
|
||||
logger.info("Initialized PyMuPDFProcessor without image extraction")
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "pymupdf"
|
||||
|
||||
@property
|
||||
def supported_mime_types(self) -> set[str]:
|
||||
return self.SUPPORTED_TYPES
|
||||
|
||||
async def process(
|
||||
self,
|
||||
content: bytes,
|
||||
content_type: str,
|
||||
filename: Optional[str] = None,
|
||||
options: Optional[dict[str, Any]] = None,
|
||||
progress_callback: Optional[
|
||||
Callable[[float, Optional[float], Optional[str]], Awaitable[None]]
|
||||
] = None,
|
||||
) -> ProcessingResult:
|
||||
"""Process a PDF document and extract text, metadata, and images.
|
||||
|
||||
Args:
|
||||
content: PDF document bytes
|
||||
content_type: MIME type (should be application/pdf)
|
||||
filename: Optional filename for better error messages
|
||||
options: Processing options (currently unused)
|
||||
progress_callback: Optional callback for progress updates
|
||||
|
||||
Returns:
|
||||
ProcessingResult with extracted text and metadata
|
||||
|
||||
Raises:
|
||||
ProcessorError: If PDF processing fails
|
||||
"""
|
||||
import anyio
|
||||
|
||||
try:
|
||||
if progress_callback:
|
||||
await progress_callback(0, 100, "Opening PDF document")
|
||||
|
||||
# Open document and extract metadata in thread
|
||||
doc = await anyio.to_thread.run_sync( # type: ignore[attr-defined]
|
||||
lambda: pymupdf.open("pdf", content)
|
||||
)
|
||||
|
||||
metadata = self._extract_metadata(doc, filename)
|
||||
metadata["file_size"] = len(content)
|
||||
page_count = doc.page_count
|
||||
|
||||
if progress_callback:
|
||||
await progress_callback(10, 100, f"Extracting {page_count} pages")
|
||||
|
||||
# Prepare image directory if needed
|
||||
pdf_image_dir = None
|
||||
if self.extract_images:
|
||||
pdf_id = filename.replace("/", "_") if filename else "unknown"
|
||||
pdf_image_dir = self.image_dir / pdf_id
|
||||
pdf_image_dir.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
# Extract all pages in a single call with page_chunks=True
|
||||
def do_extract() -> list[dict[str, Any]]:
|
||||
# When page_chunks=True, to_markdown returns list[dict] not str
|
||||
return pymupdf4llm.to_markdown( # type: ignore[return-value]
|
||||
doc,
|
||||
write_images=self.extract_images,
|
||||
image_path=pdf_image_dir if self.extract_images else None,
|
||||
page_chunks=True,
|
||||
)
|
||||
|
||||
page_chunks: list[dict[str, Any]] = await anyio.to_thread.run_sync( # type: ignore[attr-defined]
|
||||
do_extract
|
||||
)
|
||||
|
||||
if progress_callback:
|
||||
await progress_callback(90, 100, "Building result")
|
||||
|
||||
# Extract page texts and build boundaries from chunks
|
||||
page_texts: list[str] = []
|
||||
page_boundaries: list[dict[str, Any]] = []
|
||||
current_offset = 0
|
||||
for chunk in page_chunks:
|
||||
text = chunk.get("text", "")
|
||||
page_num = chunk.get("metadata", {}).get("page", len(page_texts) + 1)
|
||||
page_texts.append(text)
|
||||
page_boundaries.append(
|
||||
{
|
||||
"page": page_num,
|
||||
"start_offset": current_offset,
|
||||
"end_offset": current_offset + len(text),
|
||||
}
|
||||
)
|
||||
current_offset += len(text)
|
||||
|
||||
# Collect image paths
|
||||
image_paths = []
|
||||
if pdf_image_dir and pdf_image_dir.exists():
|
||||
image_paths = [str(p) for p in pdf_image_dir.glob("*")]
|
||||
|
||||
# Build final text and metadata
|
||||
md_text = "".join(page_texts)
|
||||
metadata["has_images"] = len(image_paths) > 0
|
||||
if image_paths:
|
||||
metadata["image_count"] = len(image_paths)
|
||||
metadata["image_paths"] = image_paths
|
||||
metadata["page_boundaries"] = page_boundaries
|
||||
|
||||
# Close document
|
||||
doc.close()
|
||||
|
||||
if progress_callback:
|
||||
await progress_callback(100, 100, "Processing complete")
|
||||
|
||||
logger.info(
|
||||
f"Successfully processed PDF {filename or '<bytes>'}: "
|
||||
f"{metadata['page_count']} pages, {len(md_text)} chars, "
|
||||
f"{metadata.get('image_count', 0)} images"
|
||||
)
|
||||
|
||||
return ProcessingResult(
|
||||
text=md_text,
|
||||
metadata=metadata,
|
||||
processor=self.name,
|
||||
success=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to process PDF {filename or '<bytes>'}: {e}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
raise ProcessorError(error_msg) from e
|
||||
|
||||
def _extract_metadata(
|
||||
self, doc: pymupdf.Document, filename: Optional[str]
|
||||
) -> dict[str, Any]:
|
||||
"""Extract metadata from PDF document.
|
||||
|
||||
Args:
|
||||
doc: Opened PyMuPDF document
|
||||
filename: Optional filename
|
||||
|
||||
Returns:
|
||||
Dictionary with PDF metadata
|
||||
"""
|
||||
metadata: dict[str, Any] = {}
|
||||
|
||||
# Basic document info
|
||||
metadata["page_count"] = doc.page_count
|
||||
metadata["format"] = "PDF 1." + str(
|
||||
doc.pdf_version() if hasattr(doc, "pdf_version") else "?" # type: ignore[call-non-callable]
|
||||
)
|
||||
|
||||
if filename:
|
||||
metadata["filename"] = filename
|
||||
|
||||
# Extract PDF metadata dictionary
|
||||
pdf_metadata = doc.metadata
|
||||
if pdf_metadata:
|
||||
# Standard PDF metadata fields
|
||||
if pdf_metadata.get("title"):
|
||||
metadata["title"] = pdf_metadata["title"]
|
||||
if pdf_metadata.get("author"):
|
||||
metadata["author"] = pdf_metadata["author"]
|
||||
if pdf_metadata.get("subject"):
|
||||
metadata["subject"] = pdf_metadata["subject"]
|
||||
if pdf_metadata.get("keywords"):
|
||||
metadata["keywords"] = pdf_metadata["keywords"]
|
||||
if pdf_metadata.get("creator"):
|
||||
metadata["creator"] = pdf_metadata["creator"]
|
||||
if pdf_metadata.get("producer"):
|
||||
metadata["producer"] = pdf_metadata["producer"]
|
||||
if pdf_metadata.get("creationDate"):
|
||||
metadata["creation_date"] = pdf_metadata["creationDate"]
|
||||
if pdf_metadata.get("modDate"):
|
||||
metadata["modification_date"] = pdf_metadata["modDate"]
|
||||
|
||||
return metadata
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""Check if PyMuPDF is available and working.
|
||||
|
||||
Returns:
|
||||
True if processor is ready to use
|
||||
"""
|
||||
try:
|
||||
# Try to create a simple PDF in memory
|
||||
test_doc = pymupdf.open()
|
||||
test_doc.close()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"PyMuPDF health check failed: {e}")
|
||||
return False
|
||||
@@ -37,7 +37,9 @@ class BM25SparseEmbeddingProvider:
|
||||
|
||||
def encode(self, text: str) -> dict[str, Any]:
|
||||
"""
|
||||
Generate BM25 sparse embedding for a single text.
|
||||
Generate BM25 sparse embedding for a single text (synchronous).
|
||||
|
||||
Note: For async contexts, prefer encode_async() to avoid blocking the event loop.
|
||||
|
||||
Args:
|
||||
text: Input text to encode
|
||||
@@ -53,7 +55,24 @@ class BM25SparseEmbeddingProvider:
|
||||
"values": sparse_embedding.values.tolist(),
|
||||
}
|
||||
|
||||
def encode_batch(self, texts: list[str]) -> list[dict[str, Any]]:
|
||||
async def encode_async(self, text: str) -> dict[str, Any]:
|
||||
"""
|
||||
Generate BM25 sparse embedding for a single text (async).
|
||||
|
||||
Runs CPU-bound BM25 encoding in thread pool to avoid blocking the event loop.
|
||||
|
||||
Args:
|
||||
text: Input text to encode
|
||||
|
||||
Returns:
|
||||
Dictionary with 'indices' and 'values' keys for Qdrant sparse vector
|
||||
"""
|
||||
import anyio
|
||||
|
||||
# Run CPU-bound BM25 encoding in thread pool
|
||||
return await anyio.to_thread.run_sync(lambda: self.encode(text)) # type: ignore[attr-defined]
|
||||
|
||||
async def encode_batch(self, texts: list[str]) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Generate BM25 sparse embeddings for multiple texts (batched).
|
||||
|
||||
@@ -63,7 +82,12 @@ class BM25SparseEmbeddingProvider:
|
||||
Returns:
|
||||
List of dictionaries with 'indices' and 'values' for each text
|
||||
"""
|
||||
sparse_embeddings = list(self.model.embed(texts))
|
||||
import anyio
|
||||
|
||||
# Run CPU-bound BM25 encoding in thread pool to avoid blocking event loop
|
||||
sparse_embeddings = await anyio.to_thread.run_sync( # type: ignore[attr-defined]
|
||||
lambda: list(self.model.embed(texts))
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Pydantic models for Nextcloud News app responses."""
|
||||
|
||||
from typing import List
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from .base import BaseResponse
|
||||
|
||||
|
||||
class NewsFolder(BaseModel):
|
||||
"""Model for a News folder."""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
id: int = Field(description="Folder ID")
|
||||
name: str = Field(description="Folder name")
|
||||
|
||||
|
||||
class NewsFeed(BaseModel):
|
||||
"""Model for a News feed (RSS/Atom subscription)."""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
id: int = Field(description="Feed ID")
|
||||
url: str = Field(description="Feed URL")
|
||||
title: str = Field(description="Feed title")
|
||||
favicon_link: str | None = Field(
|
||||
None, alias="faviconLink", description="Favicon URL"
|
||||
)
|
||||
link: str | None = Field(None, description="Website link")
|
||||
added: int = Field(description="Unix timestamp when feed was added")
|
||||
folder_id: int | None = Field(
|
||||
None, alias="folderId", description="Parent folder ID"
|
||||
)
|
||||
unread_count: int = Field(
|
||||
0, alias="unreadCount", description="Number of unread items"
|
||||
)
|
||||
ordering: int = Field(
|
||||
0, description="Feed ordering (0=default, 1=oldest, 2=newest)"
|
||||
)
|
||||
pinned: bool = Field(False, description="Whether feed is pinned to top")
|
||||
update_error_count: int = Field(
|
||||
0, alias="updateErrorCount", description="Consecutive update failures"
|
||||
)
|
||||
last_update_error: str | None = Field(
|
||||
None, alias="lastUpdateError", description="Last update error message"
|
||||
)
|
||||
|
||||
@property
|
||||
def has_errors(self) -> bool:
|
||||
"""Check if feed has update errors."""
|
||||
return self.update_error_count > 0
|
||||
|
||||
|
||||
class NewsItem(BaseModel):
|
||||
"""Model for a News item (article) with full content."""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
id: int = Field(description="Item ID")
|
||||
guid: str = Field(description="Globally unique identifier")
|
||||
guid_hash: str = Field(alias="guidHash", description="MD5 hash of GUID")
|
||||
url: str | None = Field(None, description="Article URL")
|
||||
title: str = Field(description="Article title")
|
||||
author: str | None = Field(None, description="Article author")
|
||||
pub_date: int | None = Field(
|
||||
None, alias="pubDate", description="Publication timestamp"
|
||||
)
|
||||
body: str | None = Field(None, description="Article content (HTML)")
|
||||
enclosure_mime: str | None = Field(
|
||||
None, alias="enclosureMime", description="Enclosure MIME type"
|
||||
)
|
||||
enclosure_link: str | None = Field(
|
||||
None, alias="enclosureLink", description="Enclosure URL"
|
||||
)
|
||||
media_thumbnail: str | None = Field(
|
||||
None, alias="mediaThumbnail", description="Media thumbnail URL"
|
||||
)
|
||||
media_description: str | None = Field(
|
||||
None, alias="mediaDescription", description="Media description"
|
||||
)
|
||||
feed_id: int = Field(alias="feedId", description="Parent feed ID")
|
||||
unread: bool = Field(True, description="Whether item is unread")
|
||||
starred: bool = Field(False, description="Whether item is starred")
|
||||
rtl: bool = Field(False, description="Right-to-left text")
|
||||
last_modified: int = Field(
|
||||
alias="lastModified", description="Last modification timestamp"
|
||||
)
|
||||
fingerprint: str | None = Field(
|
||||
None, description="Content fingerprint for deduplication"
|
||||
)
|
||||
content_hash: str | None = Field(
|
||||
None, alias="contentHash", description="Content hash"
|
||||
)
|
||||
|
||||
|
||||
class NewsItemSummary(BaseModel):
|
||||
"""Lightweight model for News item list responses."""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
id: int = Field(description="Item ID")
|
||||
title: str = Field(description="Article title")
|
||||
feed_id: int = Field(alias="feedId", description="Parent feed ID")
|
||||
unread: bool = Field(True, description="Whether item is unread")
|
||||
starred: bool = Field(False, description="Whether item is starred")
|
||||
pub_date: int | None = Field(
|
||||
None, alias="pubDate", description="Publication timestamp"
|
||||
)
|
||||
url: str | None = Field(None, description="Article URL")
|
||||
author: str | None = Field(None, description="Article author")
|
||||
|
||||
|
||||
class NewsStatus(BaseModel):
|
||||
"""Model for News app status."""
|
||||
|
||||
version: str = Field(description="News app version")
|
||||
warnings: dict = Field(default_factory=dict, description="Configuration warnings")
|
||||
|
||||
|
||||
# --- Response Models ---
|
||||
|
||||
|
||||
class ListFoldersResponse(BaseResponse):
|
||||
"""Response model for listing folders."""
|
||||
|
||||
results: List[NewsFolder] = Field(description="List of folders")
|
||||
total_count: int = Field(description="Total number of folders")
|
||||
|
||||
|
||||
class ListFeedsResponse(BaseResponse):
|
||||
"""Response model for listing feeds."""
|
||||
|
||||
results: List[NewsFeed] = Field(description="List of feeds")
|
||||
starred_count: int = Field(0, description="Number of starred items")
|
||||
newest_item_id: int | None = Field(None, description="ID of newest item")
|
||||
total_count: int = Field(description="Total number of feeds")
|
||||
|
||||
|
||||
class ListItemsResponse(BaseResponse):
|
||||
"""Response model for listing items."""
|
||||
|
||||
results: List[NewsItemSummary] = Field(description="List of items")
|
||||
total_count: int = Field(description="Number of items returned")
|
||||
has_more: bool = Field(False, description="Whether more items exist")
|
||||
oldest_id: int | None = Field(None, description="Oldest item ID (for pagination)")
|
||||
|
||||
|
||||
class GetItemResponse(BaseResponse):
|
||||
"""Response model for getting a single item."""
|
||||
|
||||
item: NewsItem = Field(description="Full item details")
|
||||
|
||||
|
||||
class FeedHealthResponse(BaseResponse):
|
||||
"""Response model for feed health status."""
|
||||
|
||||
feed_id: int = Field(description="Feed ID")
|
||||
title: str = Field(description="Feed title")
|
||||
url: str = Field(description="Feed URL")
|
||||
has_errors: bool = Field(description="Whether feed has update errors")
|
||||
error_count: int = Field(description="Number of consecutive errors")
|
||||
last_error: str | None = Field(None, description="Last error message")
|
||||
|
||||
|
||||
class GetStatusResponse(BaseResponse):
|
||||
"""Response model for app status."""
|
||||
|
||||
version: str = Field(description="News app version")
|
||||
warnings: dict = Field(default_factory=dict, description="Configuration warnings")
|
||||
@@ -10,7 +10,7 @@ from .base import BaseResponse
|
||||
class SemanticSearchResult(BaseModel):
|
||||
"""Model for semantic search results with additional metadata."""
|
||||
|
||||
id: int = Field(description="Document ID")
|
||||
id: int = Field(description="Document ID (int for all document types)")
|
||||
doc_type: str = Field(
|
||||
description="Document type (note, calendar_event, deck_card, etc.)"
|
||||
)
|
||||
@@ -35,6 +35,29 @@ class SemanticSearchResult(BaseModel):
|
||||
chunk_end_offset: Optional[int] = Field(
|
||||
default=None, description="Character position where chunk ends in document"
|
||||
)
|
||||
page_number: Optional[int] = Field(
|
||||
default=None, description="Page number for PDF documents"
|
||||
)
|
||||
# Context expansion fields (optional, populated when include_context=True)
|
||||
has_context_expansion: bool = Field(
|
||||
default=False, description="Whether context expansion was performed"
|
||||
)
|
||||
marked_text: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Full text with position markers around matched chunk",
|
||||
)
|
||||
before_context: Optional[str] = Field(
|
||||
default=None, description="Text before the matched chunk"
|
||||
)
|
||||
after_context: Optional[str] = Field(
|
||||
default=None, description="Text after the matched chunk"
|
||||
)
|
||||
has_before_truncation: Optional[bool] = Field(
|
||||
default=None, description="Whether before_context was truncated"
|
||||
)
|
||||
has_after_truncation: Optional[bool] = Field(
|
||||
default=None, description="Whether after_context was truncated"
|
||||
)
|
||||
|
||||
|
||||
class SemanticSearchResponse(BaseResponse):
|
||||
|
||||
@@ -37,7 +37,7 @@ class HealthCheckFilter(logging.Filter):
|
||||
"""
|
||||
# Check if the log message contains health check endpoints
|
||||
message = record.getMessage()
|
||||
return not any(
|
||||
health_check = any(
|
||||
endpoint in message
|
||||
for endpoint in [
|
||||
"/health/live",
|
||||
@@ -47,6 +47,8 @@ class HealthCheckFilter(logging.Filter):
|
||||
]
|
||||
)
|
||||
|
||||
return not health_check
|
||||
|
||||
|
||||
class TraceContextFormatter(JsonFormatter):
|
||||
"""
|
||||
@@ -58,7 +60,7 @@ class TraceContextFormatter(JsonFormatter):
|
||||
|
||||
def add_fields(
|
||||
self,
|
||||
log_record: dict[str, Any],
|
||||
log_data: dict[str, Any],
|
||||
record: logging.LogRecord,
|
||||
message_dict: dict[str, Any],
|
||||
) -> None:
|
||||
@@ -66,28 +68,28 @@ class TraceContextFormatter(JsonFormatter):
|
||||
Add custom fields to the log record, including trace context.
|
||||
|
||||
Args:
|
||||
log_record: Dictionary to be serialized as JSON
|
||||
log_data: Dictionary to be serialized as JSON
|
||||
record: LogRecord instance
|
||||
message_dict: Dictionary of extra fields from log call
|
||||
"""
|
||||
# Call parent to add standard fields
|
||||
super().add_fields(log_record, record, message_dict)
|
||||
super().add_fields(log_data, record, message_dict)
|
||||
|
||||
# Add trace context if available
|
||||
trace_context = get_trace_context()
|
||||
if trace_context:
|
||||
log_record["trace_id"] = trace_context.get("trace_id")
|
||||
log_record["span_id"] = trace_context.get("span_id")
|
||||
log_data["trace_id"] = trace_context.get("trace_id")
|
||||
log_data["span_id"] = trace_context.get("span_id")
|
||||
|
||||
# Add standard fields with consistent naming
|
||||
log_record["timestamp"] = self.formatTime(record)
|
||||
log_record["level"] = record.levelname
|
||||
log_record["logger"] = record.name
|
||||
log_record["message"] = record.getMessage()
|
||||
log_data["timestamp"] = self.formatTime(record)
|
||||
log_data["level"] = record.levelname
|
||||
log_data["logger"] = record.name
|
||||
log_data["message"] = record.getMessage()
|
||||
|
||||
# Include exception info if present
|
||||
if record.exc_info:
|
||||
log_record["exception"] = self.formatException(record.exc_info)
|
||||
log_data["exception"] = self.formatException(record.exc_info)
|
||||
|
||||
|
||||
class TraceContextTextFormatter(logging.Formatter):
|
||||
|
||||
@@ -4,12 +4,14 @@ from .anthropic import AnthropicProvider
|
||||
from .base import Provider
|
||||
from .bedrock import BedrockProvider
|
||||
from .ollama import OllamaProvider
|
||||
from .openai import OpenAIProvider
|
||||
from .registry import get_provider, reset_provider
|
||||
from .simple import SimpleProvider
|
||||
|
||||
__all__ = [
|
||||
"Provider",
|
||||
"OllamaProvider",
|
||||
"OpenAIProvider",
|
||||
"AnthropicProvider",
|
||||
"SimpleProvider",
|
||||
"BedrockProvider",
|
||||
|
||||
@@ -17,18 +17,20 @@ class AnthropicProvider(Provider):
|
||||
Note: Anthropic doesn't provide embedding models, only text generation.
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: str, model: str = "claude-3-5-sonnet-20241022"):
|
||||
def __init__(
|
||||
self, api_key: str, generation_model: str = "claude-3-5-sonnet-20241022"
|
||||
):
|
||||
"""
|
||||
Initialize Anthropic provider.
|
||||
|
||||
Args:
|
||||
api_key: Anthropic API key
|
||||
model: Model name (e.g., "claude-3-5-sonnet-20241022")
|
||||
generation_model: Model name (e.g., "claude-3-5-sonnet-20241022")
|
||||
"""
|
||||
self.client = AsyncAnthropic(api_key=api_key)
|
||||
self.model = model
|
||||
self.model = generation_model
|
||||
|
||||
logger.info(f"Initialized Anthropic provider (model={model})")
|
||||
logger.info(f"Initialized Anthropic provider (model={self.model})")
|
||||
|
||||
@property
|
||||
def supports_embeddings(self) -> bool:
|
||||
|
||||
@@ -92,14 +92,21 @@ class OllamaProvider(Provider):
|
||||
response.raise_for_status()
|
||||
return response.json()["embedding"]
|
||||
|
||||
async def embed_batch(self, texts: list[str]) -> list[list[float]]:
|
||||
async def embed_batch(
|
||||
self, texts: list[str], batch_size: int = 32
|
||||
) -> list[list[float]]:
|
||||
"""
|
||||
Generate embeddings for multiple texts (batched requests).
|
||||
Generate embeddings for multiple texts using Ollama's batch API.
|
||||
|
||||
Note: Ollama doesn't have native batch API, so we send requests sequentially.
|
||||
Uses /api/embed endpoint with array input for efficient batch processing.
|
||||
Conservative batch size (32) prevents quality degradation observed in
|
||||
Ollama issue #6262 with larger batches.
|
||||
|
||||
Note: Ollama processes batches serially, not in parallel.
|
||||
|
||||
Args:
|
||||
texts: List of texts to embed
|
||||
batch_size: Maximum texts per batch (default: 32)
|
||||
|
||||
Returns:
|
||||
List of vector embeddings
|
||||
@@ -112,11 +119,17 @@ class OllamaProvider(Provider):
|
||||
"Embedding not supported - no embedding_model configured"
|
||||
)
|
||||
|
||||
embeddings = []
|
||||
for text in texts:
|
||||
embedding = await self.embed(text)
|
||||
embeddings.append(embedding)
|
||||
return embeddings
|
||||
all_embeddings = []
|
||||
for i in range(0, len(texts), batch_size):
|
||||
batch = texts[i : i + batch_size]
|
||||
response = await self.client.post(
|
||||
f"{self.base_url}/api/embed",
|
||||
json={"model": self.embedding_model, "input": batch},
|
||||
)
|
||||
response.raise_for_status()
|
||||
all_embeddings.extend(response.json()["embeddings"])
|
||||
|
||||
return all_embeddings
|
||||
|
||||
async def _detect_dimension(self):
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Unified OpenAI provider for embeddings and text generation.
|
||||
|
||||
Supports:
|
||||
- OpenAI's standard API
|
||||
- GitHub Models API (models.github.ai)
|
||||
- Any OpenAI-compatible API via base_url override
|
||||
"""
|
||||
|
||||
import logging
|
||||
from functools import wraps
|
||||
|
||||
import anyio
|
||||
from openai import AsyncOpenAI, RateLimitError
|
||||
|
||||
from .base import Provider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Rate limit retry configuration
|
||||
MAX_RETRIES = 5
|
||||
INITIAL_RETRY_DELAY = 2.0 # seconds
|
||||
MAX_RETRY_DELAY = 60.0 # seconds
|
||||
|
||||
|
||||
def retry_on_rate_limit(func):
|
||||
"""Decorator to retry on OpenAI rate limit errors with exponential backoff."""
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
retry_delay = INITIAL_RETRY_DELAY
|
||||
last_error: Exception | None = None
|
||||
|
||||
for attempt in range(1, MAX_RETRIES + 1):
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except RateLimitError as e:
|
||||
last_error = e
|
||||
if attempt < MAX_RETRIES:
|
||||
logger.warning(
|
||||
f"Rate limit hit (attempt {attempt}/{MAX_RETRIES}), "
|
||||
f"retrying in {retry_delay:.1f}s..."
|
||||
)
|
||||
await anyio.sleep(retry_delay)
|
||||
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
|
||||
|
||||
logger.error(f"Rate limit exceeded after {MAX_RETRIES} attempts")
|
||||
raise last_error # type: ignore[misc]
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
# Well-known embedding dimensions for OpenAI models
|
||||
OPENAI_EMBEDDING_DIMENSIONS: dict[str, int] = {
|
||||
"text-embedding-3-small": 1536,
|
||||
"text-embedding-3-large": 3072,
|
||||
"text-embedding-ada-002": 1536,
|
||||
# GitHub Models API uses openai/ prefix
|
||||
"openai/text-embedding-3-small": 1536,
|
||||
"openai/text-embedding-3-large": 3072,
|
||||
}
|
||||
|
||||
|
||||
class OpenAIProvider(Provider):
|
||||
"""
|
||||
OpenAI provider supporting both embeddings and text generation.
|
||||
|
||||
Works with:
|
||||
- OpenAI's standard API (api.openai.com)
|
||||
- GitHub Models API (models.github.ai)
|
||||
- Any OpenAI-compatible API (via base_url)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
base_url: str | None = None,
|
||||
embedding_model: str | None = None,
|
||||
generation_model: str | None = None,
|
||||
timeout: float = 120.0,
|
||||
):
|
||||
"""
|
||||
Initialize OpenAI provider.
|
||||
|
||||
Args:
|
||||
api_key: OpenAI API key (or GITHUB_TOKEN for GitHub Models)
|
||||
base_url: Base URL override (e.g., "https://models.github.ai/inference")
|
||||
embedding_model: Model for embeddings (e.g., "text-embedding-3-small").
|
||||
None disables embeddings.
|
||||
generation_model: Model for text generation (e.g., "gpt-4o-mini").
|
||||
None disables generation.
|
||||
timeout: HTTP timeout in seconds (default: 120)
|
||||
"""
|
||||
self.embedding_model = embedding_model
|
||||
self.generation_model = generation_model
|
||||
self._dimension: int | None = None
|
||||
|
||||
# Initialize async client
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
# Try to get known dimension without API call
|
||||
if embedding_model and embedding_model in OPENAI_EMBEDDING_DIMENSIONS:
|
||||
self._dimension = OPENAI_EMBEDDING_DIMENSIONS[embedding_model]
|
||||
|
||||
logger.info(
|
||||
f"Initialized OpenAI provider: base_url={base_url or 'default'} "
|
||||
f"(embedding_model={embedding_model}, generation_model={generation_model}, "
|
||||
f"dimension={self._dimension})"
|
||||
)
|
||||
|
||||
@property
|
||||
def supports_embeddings(self) -> bool:
|
||||
"""Whether this provider supports embedding generation."""
|
||||
return self.embedding_model is not None
|
||||
|
||||
@property
|
||||
def supports_generation(self) -> bool:
|
||||
"""Whether this provider supports text generation."""
|
||||
return self.generation_model is not None
|
||||
|
||||
@retry_on_rate_limit
|
||||
async def embed(self, text: str) -> list[float]:
|
||||
"""
|
||||
Generate embedding vector for text.
|
||||
|
||||
Args:
|
||||
text: Input text to embed
|
||||
|
||||
Returns:
|
||||
Vector embedding as list of floats
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If embeddings not enabled (no embedding_model)
|
||||
"""
|
||||
if not self.supports_embeddings:
|
||||
raise NotImplementedError(
|
||||
"Embedding not supported - no embedding_model configured"
|
||||
)
|
||||
|
||||
assert self.embedding_model is not None # Type narrowing
|
||||
response = await self.client.embeddings.create(
|
||||
input=text,
|
||||
model=self.embedding_model,
|
||||
)
|
||||
|
||||
embedding = response.data[0].embedding
|
||||
|
||||
# Update dimension if not set
|
||||
if self._dimension is None:
|
||||
self._dimension = len(embedding)
|
||||
logger.info(
|
||||
f"Detected embedding dimension: {self._dimension} "
|
||||
f"for model {self.embedding_model}"
|
||||
)
|
||||
|
||||
return embedding
|
||||
|
||||
async def embed_batch(self, texts: list[str]) -> list[list[float]]:
|
||||
"""
|
||||
Generate embeddings for multiple texts using OpenAI's batch API.
|
||||
|
||||
OpenAI supports up to 2048 inputs per request.
|
||||
|
||||
Args:
|
||||
texts: List of texts to embed
|
||||
|
||||
Returns:
|
||||
List of vector embeddings
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If embeddings not enabled (no embedding_model)
|
||||
"""
|
||||
if not self.supports_embeddings:
|
||||
raise NotImplementedError(
|
||||
"Embedding not supported - no embedding_model configured"
|
||||
)
|
||||
|
||||
if not texts:
|
||||
return []
|
||||
|
||||
# OpenAI supports batches up to 2048, but use smaller batches for safety
|
||||
batch_size = 100
|
||||
all_embeddings: list[list[float]] = []
|
||||
|
||||
for i in range(0, len(texts), batch_size):
|
||||
batch = texts[i : i + batch_size]
|
||||
|
||||
# Use helper method with retry logic for each batch
|
||||
batch_embeddings = await self._embed_batch_request(batch)
|
||||
all_embeddings.extend(batch_embeddings)
|
||||
|
||||
# Update dimension if not set
|
||||
if self._dimension is None and batch_embeddings:
|
||||
self._dimension = len(batch_embeddings[0])
|
||||
logger.info(
|
||||
f"Detected embedding dimension: {self._dimension} "
|
||||
f"for model {self.embedding_model}"
|
||||
)
|
||||
|
||||
return all_embeddings
|
||||
|
||||
@retry_on_rate_limit
|
||||
async def _embed_batch_request(self, batch: list[str]) -> list[list[float]]:
|
||||
"""Make a single batch embedding request with retry logic."""
|
||||
assert self.embedding_model is not None # Type narrowing
|
||||
response = await self.client.embeddings.create(
|
||||
input=batch,
|
||||
model=self.embedding_model,
|
||||
)
|
||||
# Sort by index to maintain order
|
||||
sorted_data = sorted(response.data, key=lambda x: x.index)
|
||||
return [item.embedding for item in sorted_data]
|
||||
|
||||
def get_dimension(self) -> int:
|
||||
"""
|
||||
Get embedding dimension.
|
||||
|
||||
Returns:
|
||||
Vector dimension for the configured embedding model
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If embeddings not enabled (no embedding_model)
|
||||
RuntimeError: If dimension not detected yet (call embed first)
|
||||
"""
|
||||
if not self.supports_embeddings:
|
||||
raise NotImplementedError(
|
||||
"Embedding not supported - no embedding_model configured"
|
||||
)
|
||||
|
||||
if self._dimension is None:
|
||||
raise RuntimeError(
|
||||
f"Embedding dimension not detected yet for model {self.embedding_model}. "
|
||||
"Call embed() first or use a known model."
|
||||
)
|
||||
return self._dimension
|
||||
|
||||
@retry_on_rate_limit
|
||||
async def generate(self, prompt: str, max_tokens: int = 500) -> str:
|
||||
"""
|
||||
Generate text from a prompt.
|
||||
|
||||
Args:
|
||||
prompt: The prompt to generate from
|
||||
max_tokens: Maximum tokens to generate
|
||||
|
||||
Returns:
|
||||
Generated text
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If generation not enabled (no generation_model)
|
||||
"""
|
||||
if not self.supports_generation:
|
||||
raise NotImplementedError(
|
||||
"Text generation not supported - no generation_model configured"
|
||||
)
|
||||
|
||||
response = await self.client.chat.completions.create(
|
||||
model=self.generation_model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
max_tokens=max_tokens,
|
||||
temperature=0.7,
|
||||
)
|
||||
|
||||
return response.choices[0].message.content or ""
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close HTTP client."""
|
||||
await self.client.close()
|
||||
@@ -6,6 +6,7 @@ import os
|
||||
from .base import Provider
|
||||
from .bedrock import BedrockProvider
|
||||
from .ollama import OllamaProvider
|
||||
from .openai import OpenAIProvider
|
||||
from .simple import SimpleProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -17,8 +18,9 @@ class ProviderRegistry:
|
||||
|
||||
Checks environment variables in priority order and creates appropriate provider:
|
||||
1. Bedrock (AWS_REGION + BEDROCK_*_MODEL)
|
||||
2. Ollama (OLLAMA_BASE_URL)
|
||||
3. Simple (fallback for testing/development)
|
||||
2. OpenAI (OPENAI_API_KEY)
|
||||
3. Ollama (OLLAMA_BASE_URL)
|
||||
4. Simple (fallback for testing/development)
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@@ -28,8 +30,9 @@ class ProviderRegistry:
|
||||
|
||||
Priority order:
|
||||
1. Bedrock - if AWS_REGION or BEDROCK_EMBEDDING_MODEL is set
|
||||
2. Ollama - if OLLAMA_BASE_URL is set
|
||||
3. Simple - fallback for testing/development
|
||||
2. OpenAI - if OPENAI_API_KEY is set
|
||||
3. Ollama - if OLLAMA_BASE_URL is set
|
||||
4. Simple - fallback for testing/development
|
||||
|
||||
Returns:
|
||||
Provider instance
|
||||
@@ -42,6 +45,12 @@ class ProviderRegistry:
|
||||
- BEDROCK_EMBEDDING_MODEL: Model ID for embeddings (e.g., "amazon.titan-embed-text-v2:0")
|
||||
- BEDROCK_GENERATION_MODEL: Model ID for text generation (e.g., "anthropic.claude-3-sonnet-20240229-v1:0")
|
||||
|
||||
OpenAI:
|
||||
- OPENAI_API_KEY: OpenAI API key (or GITHUB_TOKEN for GitHub Models)
|
||||
- OPENAI_BASE_URL: Base URL override (e.g., "https://models.github.ai/inference")
|
||||
- OPENAI_EMBEDDING_MODEL: Model for embeddings (default: "text-embedding-3-small")
|
||||
- OPENAI_GENERATION_MODEL: Model for text generation (e.g., "gpt-4o-mini")
|
||||
|
||||
Ollama:
|
||||
- OLLAMA_BASE_URL: Ollama API base URL (e.g., "http://localhost:11434")
|
||||
- OLLAMA_EMBEDDING_MODEL: Model for embeddings (default: "nomic-embed-text")
|
||||
@@ -70,7 +79,28 @@ class ProviderRegistry:
|
||||
aws_secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"),
|
||||
)
|
||||
|
||||
# 2. Check for Ollama
|
||||
# 2. Check for OpenAI
|
||||
openai_api_key = os.getenv("OPENAI_API_KEY")
|
||||
if openai_api_key:
|
||||
base_url = os.getenv("OPENAI_BASE_URL")
|
||||
embedding_model = os.getenv(
|
||||
"OPENAI_EMBEDDING_MODEL", "text-embedding-3-small"
|
||||
)
|
||||
generation_model = os.getenv("OPENAI_GENERATION_MODEL")
|
||||
|
||||
logger.info(
|
||||
f"Using OpenAI provider: base_url={base_url or 'default'}, "
|
||||
f"embedding_model={embedding_model}, "
|
||||
f"generation_model={generation_model}"
|
||||
)
|
||||
return OpenAIProvider(
|
||||
api_key=openai_api_key,
|
||||
base_url=base_url,
|
||||
embedding_model=embedding_model,
|
||||
generation_model=generation_model,
|
||||
)
|
||||
|
||||
# 3. Check for Ollama (local LLM)
|
||||
ollama_url = os.getenv("OLLAMA_BASE_URL")
|
||||
if ollama_url:
|
||||
embedding_model = os.getenv("OLLAMA_EMBEDDING_MODEL", "nomic-embed-text")
|
||||
@@ -89,12 +119,12 @@ class ProviderRegistry:
|
||||
verify_ssl=verify_ssl,
|
||||
)
|
||||
|
||||
# 3. Fallback to Simple provider for development/testing
|
||||
# 4. Fallback to Simple provider for development/testing
|
||||
dimension = int(os.getenv("SIMPLE_EMBEDDING_DIMENSION", "384"))
|
||||
logger.warning(
|
||||
"No provider configured (AWS_REGION, OLLAMA_BASE_URL not set). "
|
||||
"No provider configured (AWS_REGION, OPENAI_API_KEY, OLLAMA_BASE_URL not set). "
|
||||
"Using SimpleProvider for testing/development. "
|
||||
"For production, configure Bedrock or Ollama."
|
||||
"For production, configure Bedrock, OpenAI, or Ollama."
|
||||
)
|
||||
return SimpleProvider(dimension=dimension)
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ async def get_indexed_doc_types(user_id: str) -> set[str]:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -97,15 +98,18 @@ async def get_indexed_doc_types(user_id: str) -> set[str]:
|
||||
scroll_results, _next_offset = await qdrant_client.scroll(
|
||||
collection_name=collection,
|
||||
scroll_filter=Filter(
|
||||
must=[FieldCondition(key="user_id", match=MatchValue(value=user_id))]
|
||||
must=[
|
||||
get_placeholder_filter(), # Exclude placeholders from doc_type discovery
|
||||
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
|
||||
]
|
||||
),
|
||||
limit=1000, # Sample size to discover types
|
||||
with_payload=["doc_type"],
|
||||
with_vectors=False, # Don't need vectors for type discovery
|
||||
)
|
||||
|
||||
doc_types = {
|
||||
point.payload.get("doc_type")
|
||||
doc_types: set[str] = {
|
||||
str(point.payload.get("doc_type"))
|
||||
for point in scroll_results
|
||||
if point.payload.get("doc_type")
|
||||
}
|
||||
@@ -123,7 +127,7 @@ class SearchResult:
|
||||
"""A single search result with metadata and score.
|
||||
|
||||
Attributes:
|
||||
id: Document ID
|
||||
id: Document ID (int for all document types)
|
||||
doc_type: Document type (note, file, calendar, contact, etc.)
|
||||
title: Document title
|
||||
excerpt: Content excerpt showing match context
|
||||
@@ -133,6 +137,10 @@ class SearchResult:
|
||||
metadata: Additional algorithm-specific metadata
|
||||
chunk_start_offset: Character position where chunk starts (None if not available)
|
||||
chunk_end_offset: Character position where chunk ends (None if not available)
|
||||
page_number: Page number for PDF documents (None for other doc types)
|
||||
chunk_index: Zero-based index of this chunk in the document
|
||||
total_chunks: Total number of chunks in the document
|
||||
point_id: Qdrant point ID for batch vector retrieval (None if not from Qdrant)
|
||||
"""
|
||||
|
||||
id: int
|
||||
@@ -143,6 +151,10 @@ class SearchResult:
|
||||
metadata: dict[str, Any] | None = None
|
||||
chunk_start_offset: int | None = None
|
||||
chunk_end_offset: int | None = None
|
||||
page_number: int | None = None
|
||||
chunk_index: int = 0
|
||||
total_chunks: int = 1
|
||||
point_id: str | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate score is non-negative.
|
||||
@@ -162,8 +174,15 @@ class SearchAlgorithm(ABC):
|
||||
|
||||
All search algorithms must implement the search() method with consistent
|
||||
interface, allowing them to be used interchangeably.
|
||||
|
||||
Attributes:
|
||||
query_embedding: The query embedding generated during the last search.
|
||||
Available after search() completes for algorithms that use embeddings.
|
||||
Can be reused by callers to avoid redundant embedding generation.
|
||||
"""
|
||||
|
||||
query_embedding: list[float] | None = None
|
||||
|
||||
@abstractmethod
|
||||
async def search(
|
||||
self,
|
||||
|
||||
@@ -9,7 +9,9 @@ from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.embedding import get_bm25_service, get_embedding_service
|
||||
from nextcloud_mcp_server.observability.metrics import record_qdrant_operation
|
||||
from nextcloud_mcp_server.observability.tracing import trace_operation
|
||||
from nextcloud_mcp_server.search.algorithms import SearchAlgorithm, SearchResult
|
||||
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -72,6 +74,9 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
|
||||
Returns unverified results from Qdrant. Access verification should be
|
||||
performed separately at the final output stage using verify_search_results().
|
||||
|
||||
Deduplicates by (doc_id, doc_type, chunk_start_offset, chunk_end_offset)
|
||||
to show multiple chunks from the same document while avoiding duplicate chunks.
|
||||
|
||||
Args:
|
||||
query: Natural language or keyword search query
|
||||
user_id: User ID for filtering
|
||||
@@ -95,13 +100,19 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
|
||||
)
|
||||
|
||||
# Generate dense embedding for semantic search
|
||||
embedding_service = get_embedding_service()
|
||||
dense_embedding = await embedding_service.embed(query)
|
||||
with trace_operation("search.get_embedding_service"):
|
||||
embedding_service = get_embedding_service()
|
||||
with trace_operation("search.dense_embedding"):
|
||||
dense_embedding = await embedding_service.embed(query)
|
||||
# Store for reuse by callers (e.g., viz_routes PCA visualization)
|
||||
self.query_embedding = dense_embedding
|
||||
logger.debug(f"Generated dense embedding (dimension={len(dense_embedding)})")
|
||||
|
||||
# Generate sparse embedding for BM25 keyword search
|
||||
bm25_service = get_bm25_service()
|
||||
sparse_embedding = bm25_service.encode(query)
|
||||
with trace_operation("search.get_bm25_service"):
|
||||
bm25_service = get_bm25_service()
|
||||
with trace_operation("search.sparse_embedding_bm25"):
|
||||
sparse_embedding = await bm25_service.encode_async(query)
|
||||
logger.debug(
|
||||
f"Generated sparse embedding "
|
||||
f"({len(sparse_embedding['indices'])} non-zero terms)"
|
||||
@@ -109,10 +120,11 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
|
||||
|
||||
# Build Qdrant filter
|
||||
filter_conditions = [
|
||||
get_placeholder_filter(), # Always exclude placeholders from user-facing queries
|
||||
FieldCondition(
|
||||
key="user_id",
|
||||
match=MatchValue(value=user_id),
|
||||
)
|
||||
),
|
||||
]
|
||||
|
||||
# Add doc_type filter if specified
|
||||
@@ -127,38 +139,44 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
|
||||
query_filter = Filter(must=filter_conditions)
|
||||
|
||||
# Execute hybrid search with Qdrant native RRF fusion
|
||||
qdrant_client = await get_qdrant_client()
|
||||
with trace_operation("search.get_qdrant_client"):
|
||||
qdrant_client = await get_qdrant_client()
|
||||
|
||||
try:
|
||||
# Use prefetch to run both dense and sparse searches
|
||||
# Qdrant will automatically merge results using RRF
|
||||
search_response = await qdrant_client.query_points(
|
||||
collection_name=settings.get_collection_name(),
|
||||
prefetch=[
|
||||
# Dense semantic search
|
||||
models.Prefetch(
|
||||
query=dense_embedding,
|
||||
using="dense",
|
||||
limit=limit * 2, # Get extra for deduplication
|
||||
filter=query_filter,
|
||||
),
|
||||
# Sparse BM25 search
|
||||
models.Prefetch(
|
||||
query=models.SparseVector(
|
||||
indices=sparse_embedding["indices"],
|
||||
values=sparse_embedding["values"],
|
||||
with trace_operation(
|
||||
"search.qdrant_query",
|
||||
attributes={"query.limit": limit * 2, "query.fusion": self.fusion_name},
|
||||
):
|
||||
search_response = await qdrant_client.query_points(
|
||||
collection_name=settings.get_collection_name(),
|
||||
prefetch=[
|
||||
# Dense semantic search
|
||||
models.Prefetch(
|
||||
query=dense_embedding,
|
||||
using="dense",
|
||||
limit=limit * 2, # Get extra for deduplication
|
||||
filter=query_filter,
|
||||
),
|
||||
using="sparse",
|
||||
limit=limit * 2, # Get extra for deduplication
|
||||
filter=query_filter,
|
||||
),
|
||||
],
|
||||
# Fusion query (RRF or DBSF based on initialization)
|
||||
query=models.FusionQuery(fusion=self.fusion),
|
||||
limit=limit * 2, # Get extra for deduplication
|
||||
score_threshold=score_threshold,
|
||||
with_payload=True,
|
||||
with_vectors=False, # Don't return vectors to save bandwidth
|
||||
)
|
||||
# Sparse BM25 search
|
||||
models.Prefetch(
|
||||
query=models.SparseVector(
|
||||
indices=sparse_embedding["indices"],
|
||||
values=sparse_embedding["values"],
|
||||
),
|
||||
using="sparse",
|
||||
limit=limit * 2, # Get extra for deduplication
|
||||
filter=query_filter,
|
||||
),
|
||||
],
|
||||
# Fusion query (RRF or DBSF based on initialization)
|
||||
query=models.FusionQuery(fusion=self.fusion),
|
||||
limit=limit * 2, # Get extra for deduplication
|
||||
score_threshold=score_threshold,
|
||||
with_payload=True,
|
||||
with_vectors=False, # Don't return vectors to save bandwidth
|
||||
)
|
||||
record_qdrant_operation("search", "success")
|
||||
except Exception:
|
||||
record_qdrant_operation("search", "error")
|
||||
@@ -176,41 +194,63 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
|
||||
f"Top 3 {self.fusion_name.upper()} fusion scores: {top_scores}"
|
||||
)
|
||||
|
||||
# Deduplicate by (doc_id, doc_type) - multiple chunks per document
|
||||
seen_docs = set()
|
||||
results = []
|
||||
# Deduplicate by (doc_id, doc_type, chunk_start, chunk_end)
|
||||
# This allows multiple chunks from same doc, but removes duplicate chunks
|
||||
with trace_operation(
|
||||
"search.deduplicate",
|
||||
attributes={"dedupe.num_points": len(search_response.points)},
|
||||
):
|
||||
seen_chunks = set()
|
||||
results = []
|
||||
|
||||
for result in search_response.points:
|
||||
doc_id = int(result.payload["doc_id"])
|
||||
doc_type = result.payload.get("doc_type", "note")
|
||||
doc_key = (doc_id, doc_type)
|
||||
for result in search_response.points:
|
||||
if result.payload is None:
|
||||
continue
|
||||
# doc_id can be int (notes) or str (files - file paths)
|
||||
doc_id = result.payload["doc_id"]
|
||||
doc_type = result.payload.get("doc_type", "note")
|
||||
chunk_start = result.payload.get("chunk_start_offset")
|
||||
chunk_end = result.payload.get("chunk_end_offset")
|
||||
chunk_key = (doc_id, doc_type, chunk_start, chunk_end)
|
||||
|
||||
# Skip if we've already seen this document
|
||||
if doc_key in seen_docs:
|
||||
continue
|
||||
# Skip if we've already seen this exact chunk
|
||||
if chunk_key in seen_chunks:
|
||||
continue
|
||||
|
||||
seen_docs.add(doc_key)
|
||||
seen_chunks.add(chunk_key)
|
||||
|
||||
# Return unverified results (verification happens at output stage)
|
||||
results.append(
|
||||
SearchResult(
|
||||
id=doc_id,
|
||||
doc_type=doc_type,
|
||||
title=result.payload.get("title", "Untitled"),
|
||||
excerpt=result.payload.get("excerpt", ""),
|
||||
score=result.score, # Fusion score (RRF or DBSF)
|
||||
metadata={
|
||||
"chunk_index": result.payload.get("chunk_index"),
|
||||
"total_chunks": result.payload.get("total_chunks"),
|
||||
"search_method": f"bm25_hybrid_{self.fusion_name}",
|
||||
},
|
||||
chunk_start_offset=result.payload.get("chunk_start_offset"),
|
||||
chunk_end_offset=result.payload.get("chunk_end_offset"),
|
||||
# Build metadata dict with common fields
|
||||
metadata = {
|
||||
"chunk_index": result.payload.get("chunk_index"),
|
||||
"total_chunks": result.payload.get("total_chunks"),
|
||||
"search_method": f"bm25_hybrid_{self.fusion_name}",
|
||||
}
|
||||
|
||||
# Add deck_card-specific metadata for frontend URL construction
|
||||
if doc_type == "deck_card":
|
||||
if board_id := result.payload.get("board_id"):
|
||||
metadata["board_id"] = board_id
|
||||
|
||||
# Return unverified results (verification happens at output stage)
|
||||
results.append(
|
||||
SearchResult(
|
||||
id=doc_id,
|
||||
doc_type=doc_type,
|
||||
title=result.payload.get("title", "Untitled"),
|
||||
excerpt=result.payload.get("excerpt", ""),
|
||||
score=result.score, # Fusion score (RRF or DBSF)
|
||||
metadata=metadata,
|
||||
chunk_start_offset=result.payload.get("chunk_start_offset"),
|
||||
chunk_end_offset=result.payload.get("chunk_end_offset"),
|
||||
page_number=result.payload.get("page_number"),
|
||||
chunk_index=result.payload.get("chunk_index", 0),
|
||||
total_chunks=result.payload.get("total_chunks", 1),
|
||||
point_id=str(result.id), # Qdrant point ID for batch retrieval
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if len(results) >= limit:
|
||||
break
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
logger.info(f"Returning {len(results)} unverified results after deduplication")
|
||||
if results:
|
||||
|
||||
@@ -0,0 +1,658 @@
|
||||
"""Context expansion for search results.
|
||||
|
||||
Provides utilities to expand matched chunks with surrounding context and
|
||||
position markers for better visualization and understanding of search results.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _get_chunk_from_qdrant(
|
||||
user_id: str, doc_id: int, doc_type: str, chunk_start: int, chunk_end: int
|
||||
) -> str | None:
|
||||
"""Retrieve full chunk text from Qdrant payload.
|
||||
|
||||
This avoids re-fetching and re-parsing documents by using the cached
|
||||
chunk content already stored in Qdrant.
|
||||
|
||||
Args:
|
||||
user_id: User ID who owns the document
|
||||
doc_id: Document ID
|
||||
doc_type: Document type (e.g., "note", "file")
|
||||
chunk_start: Character offset where chunk starts
|
||||
chunk_end: Character offset where chunk ends
|
||||
|
||||
Returns:
|
||||
Full chunk text from Qdrant excerpt field, or None if not found
|
||||
"""
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
qdrant_client = await get_qdrant_client()
|
||||
settings = get_settings()
|
||||
|
||||
# Query for the specific chunk
|
||||
scroll_result = await qdrant_client.scroll(
|
||||
collection_name=settings.get_collection_name(),
|
||||
scroll_filter=Filter(
|
||||
must=[
|
||||
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
|
||||
FieldCondition(key="doc_id", match=MatchValue(value=doc_id)),
|
||||
FieldCondition(key="doc_type", match=MatchValue(value=doc_type)),
|
||||
FieldCondition(
|
||||
key="chunk_start_offset", match=MatchValue(value=chunk_start)
|
||||
),
|
||||
FieldCondition(
|
||||
key="chunk_end_offset", match=MatchValue(value=chunk_end)
|
||||
),
|
||||
]
|
||||
),
|
||||
limit=1,
|
||||
with_payload=["excerpt"],
|
||||
with_vectors=False,
|
||||
)
|
||||
|
||||
if scroll_result[0]:
|
||||
point = scroll_result[0][0]
|
||||
excerpt = point.payload.get("excerpt")
|
||||
if excerpt:
|
||||
logger.debug(
|
||||
f"Retrieved chunk from Qdrant for {doc_type} {doc_id}: "
|
||||
f"{len(excerpt)} chars"
|
||||
)
|
||||
return str(excerpt)
|
||||
|
||||
logger.debug(
|
||||
f"Chunk not found in Qdrant for {doc_type} {doc_id}, "
|
||||
f"chunk [{chunk_start}:{chunk_end}]. Will fall back to document fetch."
|
||||
)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error querying Qdrant for chunk: {e}. Falling back to document fetch.",
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def _get_chunk_by_index_from_qdrant(
|
||||
user_id: str, doc_id: int, doc_type: str, chunk_index: int
|
||||
) -> str | None:
|
||||
"""Retrieve chunk text by chunk_index from Qdrant payload.
|
||||
|
||||
Used to fetch adjacent chunks for context expansion.
|
||||
|
||||
Args:
|
||||
user_id: User ID who owns the document
|
||||
doc_id: Document ID
|
||||
doc_type: Document type (e.g., "note", "file")
|
||||
chunk_index: Zero-based chunk index in document
|
||||
|
||||
Returns:
|
||||
Full chunk text from Qdrant excerpt field, or None if not found
|
||||
"""
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
qdrant_client = await get_qdrant_client()
|
||||
settings = get_settings()
|
||||
|
||||
# Query for chunk by index
|
||||
scroll_result = await qdrant_client.scroll(
|
||||
collection_name=settings.get_collection_name(),
|
||||
scroll_filter=Filter(
|
||||
must=[
|
||||
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
|
||||
FieldCondition(key="doc_id", match=MatchValue(value=doc_id)),
|
||||
FieldCondition(key="doc_type", match=MatchValue(value=doc_type)),
|
||||
FieldCondition(
|
||||
key="chunk_index", match=MatchValue(value=chunk_index)
|
||||
),
|
||||
]
|
||||
),
|
||||
limit=1,
|
||||
with_payload=["excerpt"],
|
||||
with_vectors=False,
|
||||
)
|
||||
|
||||
if scroll_result[0]:
|
||||
point = scroll_result[0][0]
|
||||
excerpt = point.payload.get("excerpt")
|
||||
if excerpt:
|
||||
logger.debug(
|
||||
f"Retrieved adjacent chunk {chunk_index} from Qdrant for "
|
||||
f"{doc_type} {doc_id}: {len(excerpt)} chars"
|
||||
)
|
||||
return str(excerpt)
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"Could not retrieve adjacent chunk {chunk_index} for "
|
||||
f"{doc_type} {doc_id}: {e}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def _get_file_path_from_qdrant(
|
||||
user_id: str, file_id: int, chunk_start: int, chunk_end: int
|
||||
) -> str | None:
|
||||
"""Resolve file_id to file_path by querying Qdrant payload.
|
||||
|
||||
Args:
|
||||
user_id: User ID who owns the file
|
||||
file_id: Numeric file ID
|
||||
chunk_start: Character offset where chunk starts
|
||||
chunk_end: Character offset where chunk ends
|
||||
|
||||
Returns:
|
||||
File path string, or None if not found in Qdrant
|
||||
"""
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
qdrant_client = await get_qdrant_client()
|
||||
settings = get_settings()
|
||||
|
||||
# Query for the specific chunk
|
||||
scroll_result = await qdrant_client.scroll(
|
||||
collection_name=settings.get_collection_name(),
|
||||
scroll_filter=Filter(
|
||||
must=[
|
||||
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
|
||||
FieldCondition(key="doc_id", match=MatchValue(value=file_id)),
|
||||
FieldCondition(key="doc_type", match=MatchValue(value="file")),
|
||||
FieldCondition(
|
||||
key="chunk_start_offset", match=MatchValue(value=chunk_start)
|
||||
),
|
||||
FieldCondition(
|
||||
key="chunk_end_offset", match=MatchValue(value=chunk_end)
|
||||
),
|
||||
]
|
||||
),
|
||||
limit=1,
|
||||
with_payload=["file_path"],
|
||||
with_vectors=False,
|
||||
)
|
||||
|
||||
if scroll_result[0]:
|
||||
point = scroll_result[0][0]
|
||||
file_path = point.payload.get("file_path")
|
||||
if file_path:
|
||||
logger.debug(f"Resolved file_id {file_id} to file_path {file_path}")
|
||||
return str(file_path)
|
||||
|
||||
logger.warning(
|
||||
f"Could not find file_path in Qdrant for file_id {file_id}, "
|
||||
f"chunk [{chunk_start}:{chunk_end}]"
|
||||
)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error querying Qdrant for file_path: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChunkContext:
|
||||
"""Expanded chunk with surrounding context and position markers.
|
||||
|
||||
Attributes:
|
||||
chunk_text: The matched chunk text
|
||||
before_context: Text before the chunk (up to context_chars)
|
||||
after_context: Text after the chunk (up to context_chars)
|
||||
chunk_start_offset: Character position where chunk starts in document
|
||||
chunk_end_offset: Character position where chunk ends in document
|
||||
page_number: Page number for PDFs (None for other doc types)
|
||||
chunk_index: Zero-based chunk index (N in "chunk N of M")
|
||||
total_chunks: Total number of chunks in document
|
||||
marked_text: Full text with position markers around the chunk
|
||||
has_before_truncation: True if before_context was truncated
|
||||
has_after_truncation: True if after_context was truncated
|
||||
"""
|
||||
|
||||
chunk_text: str
|
||||
before_context: str
|
||||
after_context: str
|
||||
chunk_start_offset: int
|
||||
chunk_end_offset: int
|
||||
page_number: int | None
|
||||
chunk_index: int
|
||||
total_chunks: int
|
||||
marked_text: str
|
||||
has_before_truncation: bool
|
||||
has_after_truncation: bool
|
||||
|
||||
|
||||
async def get_chunk_with_context(
|
||||
nc_client: NextcloudClient,
|
||||
user_id: str,
|
||||
doc_id: str | int,
|
||||
doc_type: str,
|
||||
chunk_start: int,
|
||||
chunk_end: int,
|
||||
page_number: int | None = None,
|
||||
chunk_index: int = 0,
|
||||
total_chunks: int = 1,
|
||||
context_chars: int = 300,
|
||||
) -> ChunkContext | None:
|
||||
"""Fetch chunk with surrounding context.
|
||||
|
||||
First tries to retrieve the chunk from Qdrant (fast, cached). If that fails
|
||||
(e.g., legacy data with truncated excerpts), falls back to fetching and
|
||||
parsing the full document (slower, for PDFs especially).
|
||||
|
||||
Args:
|
||||
nc_client: Authenticated Nextcloud client
|
||||
user_id: User ID who owns the document
|
||||
doc_id: Document ID (int for notes/files)
|
||||
doc_type: Type of document ("note", "file", etc.)
|
||||
chunk_start: Character offset where chunk starts
|
||||
chunk_end: Character offset where chunk ends
|
||||
page_number: Optional page number for PDFs
|
||||
chunk_index: Zero-based chunk index in document
|
||||
total_chunks: Total number of chunks in document
|
||||
context_chars: Number of characters to include before/after chunk
|
||||
|
||||
Returns:
|
||||
ChunkContext with expanded context and markers, or None if document
|
||||
cannot be retrieved
|
||||
"""
|
||||
# Convert doc_id to int for Qdrant query
|
||||
doc_id_int = (
|
||||
int(doc_id)
|
||||
if isinstance(doc_id, str) and doc_id.isdigit()
|
||||
else (doc_id if isinstance(doc_id, int) else None)
|
||||
)
|
||||
|
||||
# Try to get chunk from Qdrant first (fast path)
|
||||
if doc_id_int is not None:
|
||||
chunk_text = await _get_chunk_from_qdrant(
|
||||
user_id, doc_id_int, doc_type, chunk_start, chunk_end
|
||||
)
|
||||
if chunk_text:
|
||||
logger.info(
|
||||
f"Retrieved chunk from Qdrant cache for {doc_type} {doc_id} "
|
||||
f"(avoids document re-fetch/re-parse)"
|
||||
)
|
||||
|
||||
# Fetch adjacent chunks for context expansion
|
||||
# Get chunk overlap from config to remove duplicate text
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
chunk_overlap = settings.document_chunk_overlap
|
||||
|
||||
before_context = ""
|
||||
after_context = ""
|
||||
has_before_truncation = False
|
||||
has_after_truncation = False
|
||||
|
||||
# Fetch previous chunk if not first chunk
|
||||
if chunk_index > 0:
|
||||
before_chunk = await _get_chunk_by_index_from_qdrant(
|
||||
user_id, doc_id_int, doc_type, chunk_index - 1
|
||||
)
|
||||
if before_chunk:
|
||||
# Remove overlap: the last chunk_overlap chars of previous chunk
|
||||
# overlap with the first chunk_overlap chars of current chunk
|
||||
before_context = (
|
||||
before_chunk[:-chunk_overlap]
|
||||
if len(before_chunk) > chunk_overlap
|
||||
else ""
|
||||
)
|
||||
# Truncate if requested context_chars < remaining length
|
||||
if before_context and len(before_context) > context_chars:
|
||||
before_context = before_context[-context_chars:]
|
||||
has_before_truncation = True
|
||||
else:
|
||||
# Could not fetch previous chunk, but we're not at start
|
||||
has_before_truncation = True
|
||||
|
||||
# Fetch next chunk if not last chunk
|
||||
if chunk_index < total_chunks - 1:
|
||||
after_chunk = await _get_chunk_by_index_from_qdrant(
|
||||
user_id, doc_id_int, doc_type, chunk_index + 1
|
||||
)
|
||||
if after_chunk:
|
||||
# Remove overlap: the first chunk_overlap chars of next chunk
|
||||
# overlap with the last chunk_overlap chars of current chunk
|
||||
after_context = (
|
||||
after_chunk[chunk_overlap:]
|
||||
if len(after_chunk) > chunk_overlap
|
||||
else ""
|
||||
)
|
||||
# Truncate if requested context_chars < remaining length
|
||||
if after_context and len(after_context) > context_chars:
|
||||
after_context = after_context[:context_chars]
|
||||
has_after_truncation = True
|
||||
else:
|
||||
# Could not fetch next chunk, but we're not at end
|
||||
has_after_truncation = True
|
||||
|
||||
marked_text = _insert_position_markers(
|
||||
before_context=before_context,
|
||||
chunk_text=chunk_text,
|
||||
after_context=after_context,
|
||||
page_number=page_number,
|
||||
chunk_index=chunk_index,
|
||||
total_chunks=total_chunks,
|
||||
has_before_truncation=has_before_truncation,
|
||||
has_after_truncation=has_after_truncation,
|
||||
)
|
||||
return ChunkContext(
|
||||
chunk_text=chunk_text,
|
||||
before_context=before_context,
|
||||
after_context=after_context,
|
||||
chunk_start_offset=chunk_start,
|
||||
chunk_end_offset=chunk_end,
|
||||
page_number=page_number,
|
||||
chunk_index=chunk_index,
|
||||
total_chunks=total_chunks,
|
||||
marked_text=marked_text,
|
||||
has_before_truncation=has_before_truncation,
|
||||
has_after_truncation=has_after_truncation,
|
||||
)
|
||||
|
||||
# Fallback: Fetch full document and extract chunk with context
|
||||
# This path is taken for:
|
||||
# 1. Legacy data with truncated excerpts in Qdrant
|
||||
# 2. Failed Qdrant queries
|
||||
logger.info(
|
||||
f"Falling back to document fetch for {doc_type} {doc_id} "
|
||||
f"(Qdrant cache miss, possibly legacy data)"
|
||||
)
|
||||
|
||||
# For files, retrieve file_path from Qdrant payload
|
||||
resolved_doc_id = doc_id
|
||||
if doc_type == "file" and isinstance(doc_id, int):
|
||||
file_path = await _get_file_path_from_qdrant(
|
||||
user_id, doc_id, chunk_start, chunk_end
|
||||
)
|
||||
if not file_path:
|
||||
logger.warning(
|
||||
f"Could not resolve file_id {doc_id} to file_path from Qdrant"
|
||||
)
|
||||
return None
|
||||
resolved_doc_id = file_path
|
||||
logger.debug(f"Resolved file_id {doc_id} to file_path {file_path}")
|
||||
|
||||
# Fetch full document text
|
||||
full_text = await _fetch_document_text(nc_client, resolved_doc_id, doc_type)
|
||||
if full_text is None:
|
||||
logger.warning(
|
||||
f"Could not fetch document text for {doc_type} {doc_id}, "
|
||||
"skipping context expansion"
|
||||
)
|
||||
return None
|
||||
|
||||
# Validate offsets
|
||||
if chunk_start < 0 or chunk_end > len(full_text) or chunk_start >= chunk_end:
|
||||
logger.warning(
|
||||
f"Invalid chunk offsets for {doc_type} {doc_id}: "
|
||||
f"start={chunk_start}, end={chunk_end}, doc_len={len(full_text)}"
|
||||
)
|
||||
return None
|
||||
|
||||
# Extract chunk text
|
||||
chunk_text = full_text[chunk_start:chunk_end]
|
||||
|
||||
# Calculate context boundaries
|
||||
context_start = max(0, chunk_start - context_chars)
|
||||
context_end = min(len(full_text), chunk_end + context_chars)
|
||||
|
||||
# Extract context
|
||||
before_context = full_text[context_start:chunk_start]
|
||||
after_context = full_text[chunk_end:context_end]
|
||||
|
||||
# Check for truncation
|
||||
has_before_truncation = context_start > 0
|
||||
has_after_truncation = context_end < len(full_text)
|
||||
|
||||
# Create marked text with position markers
|
||||
marked_text = _insert_position_markers(
|
||||
before_context=before_context,
|
||||
chunk_text=chunk_text,
|
||||
after_context=after_context,
|
||||
page_number=page_number,
|
||||
chunk_index=chunk_index,
|
||||
total_chunks=total_chunks,
|
||||
has_before_truncation=has_before_truncation,
|
||||
has_after_truncation=has_after_truncation,
|
||||
)
|
||||
|
||||
return ChunkContext(
|
||||
chunk_text=chunk_text,
|
||||
before_context=before_context,
|
||||
after_context=after_context,
|
||||
chunk_start_offset=chunk_start,
|
||||
chunk_end_offset=chunk_end,
|
||||
page_number=page_number,
|
||||
chunk_index=chunk_index,
|
||||
total_chunks=total_chunks,
|
||||
marked_text=marked_text,
|
||||
has_before_truncation=has_before_truncation,
|
||||
has_after_truncation=has_after_truncation,
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_document_text(
|
||||
nc_client: NextcloudClient, doc_id: str | int, doc_type: str
|
||||
) -> str | None:
|
||||
"""Fetch full text content of a document.
|
||||
|
||||
Args:
|
||||
nc_client: Authenticated Nextcloud client
|
||||
doc_id: Document ID (note ID or file path)
|
||||
doc_type: Type of document ("note", "file", etc.)
|
||||
|
||||
Returns:
|
||||
Full document text, or None if document cannot be retrieved
|
||||
"""
|
||||
try:
|
||||
if doc_type == "note":
|
||||
# Fetch note by ID
|
||||
note = await nc_client.notes.get_note(note_id=int(doc_id))
|
||||
# Reconstruct full content as indexed: title + "\n\n" + content
|
||||
# This ensures chunk offsets align with indexed content structure
|
||||
title = note.get("title", "")
|
||||
content = note.get("content", "")
|
||||
return f"{title}\n\n{content}"
|
||||
elif doc_type == "file":
|
||||
# Fetch file content via WebDAV
|
||||
try:
|
||||
file_path = str(doc_id)
|
||||
file_content, content_type = await nc_client.webdav.read_file(file_path)
|
||||
|
||||
# Check if it's a PDF (by content type or file extension)
|
||||
is_pdf = (
|
||||
content_type and "pdf" in content_type.lower()
|
||||
) or file_path.lower().endswith(".pdf")
|
||||
|
||||
if is_pdf:
|
||||
# Extract text from PDF using PyMuPDF
|
||||
# IMPORTANT: Use pymupdf4llm.to_markdown() to match indexing extraction
|
||||
# This ensures character offsets align between indexed chunks and retrieval
|
||||
import pymupdf
|
||||
import pymupdf4llm
|
||||
|
||||
logger.debug(f"Extracting text from PDF: {file_path}")
|
||||
pdf_doc = pymupdf.open(stream=file_content, filetype="pdf")
|
||||
text_parts = []
|
||||
|
||||
# Extract each page as markdown (same as indexing)
|
||||
for page_num in range(pdf_doc.page_count):
|
||||
page_md = pymupdf4llm.to_markdown(
|
||||
pdf_doc,
|
||||
pages=[page_num],
|
||||
write_images=False, # Don't need images for context
|
||||
page_chunks=False,
|
||||
)
|
||||
text_parts.append(page_md)
|
||||
|
||||
pdf_doc.close()
|
||||
|
||||
# Join pages (no separator - matches indexing)
|
||||
full_text = "".join(text_parts)
|
||||
logger.debug(
|
||||
f"Extracted {len(full_text)} characters from "
|
||||
f"{pdf_doc.page_count} pages in {file_path}"
|
||||
)
|
||||
return full_text
|
||||
else:
|
||||
# Assume it's a text file, decode to string
|
||||
logger.debug(f"Decoding text file: {file_path}")
|
||||
return file_content.decode("utf-8", errors="replace")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error fetching file content for {doc_id}: {e}", exc_info=True
|
||||
)
|
||||
return None
|
||||
elif doc_type == "news_item":
|
||||
# Fetch news item by ID
|
||||
from nextcloud_mcp_server.vector.html_processor import html_to_markdown
|
||||
|
||||
item = await nc_client.news.get_item(int(doc_id))
|
||||
# Reconstruct full content as indexed: title + source + URL + body
|
||||
# This ensures chunk offsets align with indexed content structure
|
||||
body_markdown = html_to_markdown(item.get("body", ""))
|
||||
item_title = item.get("title", "")
|
||||
item_url = item.get("url", "")
|
||||
feed_title = item.get("feedTitle", "")
|
||||
|
||||
content_parts = [item_title]
|
||||
if feed_title:
|
||||
content_parts.append(f"Source: {feed_title}")
|
||||
if item_url:
|
||||
content_parts.append(f"URL: {item_url}")
|
||||
content_parts.append("") # Blank line
|
||||
content_parts.append(body_markdown)
|
||||
return "\n".join(content_parts)
|
||||
elif doc_type == "deck_card":
|
||||
# Fetch card from Deck API
|
||||
# Note: Deck API requires board_id and stack_id, but we don't store those
|
||||
# We need to search through boards to find the card (same as processor.py)
|
||||
boards = await nc_client.deck.get_boards()
|
||||
card_found = False
|
||||
|
||||
for board in boards:
|
||||
if card_found:
|
||||
break
|
||||
|
||||
# Skip deleted boards (soft delete: deletedAt > 0)
|
||||
if board.deletedAt > 0:
|
||||
logger.debug(
|
||||
f"Skipping deleted board {board.id} while searching for card {doc_id}"
|
||||
)
|
||||
continue
|
||||
|
||||
stacks = await nc_client.deck.get_stacks(board.id)
|
||||
|
||||
for stack in stacks:
|
||||
if card_found:
|
||||
break
|
||||
if stack.cards:
|
||||
for card in stack.cards:
|
||||
if card.id == int(doc_id):
|
||||
# Reconstruct full content as indexed: title + "\n\n" + description
|
||||
# This ensures chunk offsets align with indexed content structure
|
||||
content_parts = [card.title]
|
||||
if card.description:
|
||||
content_parts.append(card.description)
|
||||
card_found = True
|
||||
logger.debug(
|
||||
f"Found deck card {doc_id} in board {board.id}, stack {stack.id}"
|
||||
)
|
||||
return "\n\n".join(content_parts)
|
||||
|
||||
# Card not found (might be archived or deleted)
|
||||
logger.warning(f"Deck card {doc_id} not found in any board/stack")
|
||||
return None
|
||||
else:
|
||||
logger.warning(f"Unsupported doc_type for context expansion: {doc_type}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching document {doc_type} {doc_id}: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _insert_position_markers(
|
||||
before_context: str,
|
||||
chunk_text: str,
|
||||
after_context: str,
|
||||
page_number: int | None,
|
||||
chunk_index: int,
|
||||
total_chunks: int,
|
||||
has_before_truncation: bool,
|
||||
has_after_truncation: bool,
|
||||
) -> str:
|
||||
"""Insert position markers around matched chunk.
|
||||
|
||||
Creates markdown-formatted text with visual markers indicating chunk
|
||||
boundaries and metadata.
|
||||
|
||||
Args:
|
||||
before_context: Text before chunk
|
||||
chunk_text: The matched chunk
|
||||
after_context: Text after chunk
|
||||
page_number: Optional page number
|
||||
chunk_index: Zero-based chunk index
|
||||
total_chunks: Total chunks in document
|
||||
has_before_truncation: Whether before_context is truncated
|
||||
has_after_truncation: Whether after_context is truncated
|
||||
|
||||
Returns:
|
||||
Formatted text with position markers
|
||||
"""
|
||||
# Build position metadata
|
||||
position_parts = []
|
||||
if page_number is not None:
|
||||
position_parts.append(f"Page {page_number}")
|
||||
position_parts.append(f"Chunk {chunk_index + 1} of {total_chunks}")
|
||||
position_metadata = ", ".join(position_parts)
|
||||
|
||||
# Build marked text
|
||||
parts = []
|
||||
|
||||
# Add truncation indicator for before context
|
||||
if has_before_truncation:
|
||||
parts.append("**[...]**\n\n")
|
||||
|
||||
# Add before context if present
|
||||
if before_context:
|
||||
parts.append(before_context)
|
||||
|
||||
# Add chunk start marker
|
||||
parts.append(f"\n\n🔍 **MATCHED CHUNK START** ({position_metadata})\n\n")
|
||||
|
||||
# Add chunk text
|
||||
parts.append(chunk_text)
|
||||
|
||||
# Add chunk end marker
|
||||
parts.append("\n\n🔍 **MATCHED CHUNK END**\n\n")
|
||||
|
||||
# Add after context if present
|
||||
if after_context:
|
||||
parts.append(after_context)
|
||||
|
||||
# Add truncation indicator for after context
|
||||
if has_after_truncation:
|
||||
parts.append("\n\n**[...]**")
|
||||
|
||||
return "".join(parts)
|
||||
@@ -0,0 +1,907 @@
|
||||
"""PDF chunk highlighting utilities for vector visualization.
|
||||
|
||||
This module provides utilities to generate highlighted page images showing
|
||||
matched chunks and their context from semantic search results.
|
||||
|
||||
The highlighting uses character offsets to precisely locate chunks within
|
||||
PDF documents, ensuring accurate highlighting even when text formatting
|
||||
varies between indexing and rendering.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
import pymupdf
|
||||
import pymupdf4llm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PDFHighlighter:
|
||||
"""Generate highlighted page images from PDF chunks."""
|
||||
|
||||
# Color definitions (RGB, 0-1 range)
|
||||
COLORS = {
|
||||
"yellow": [1, 1, 0],
|
||||
"red": [1, 0, 0],
|
||||
"green": [0, 1, 0],
|
||||
"blue": [0, 0, 1],
|
||||
"orange": [1, 0.5, 0],
|
||||
"pink": [1, 0, 1],
|
||||
"gray": [0.7, 0.7, 0.7],
|
||||
"light_blue": [0.7, 0.9, 1.0],
|
||||
"light_green": [0.7, 1.0, 0.7],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def strip_markdown(text: str) -> str:
|
||||
"""Remove markdown formatting to improve search accuracy.
|
||||
|
||||
Args:
|
||||
text: Text with potential markdown formatting
|
||||
|
||||
Returns:
|
||||
Plain text with markdown removed
|
||||
"""
|
||||
# Remove bold/italic markers
|
||||
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
|
||||
text = re.sub(r"\*(.+?)\*", r"\1", text)
|
||||
text = re.sub(r"__(.+?)__", r"\1", text)
|
||||
text = re.sub(r"_(.+?)_", r"\1", text)
|
||||
|
||||
# Remove headers
|
||||
text = re.sub(r"^#+\s+", "", text, flags=re.MULTILINE)
|
||||
|
||||
# Remove inline code
|
||||
text = re.sub(r"`(.+?)`", r"\1", text)
|
||||
|
||||
return text.strip()
|
||||
|
||||
@staticmethod
|
||||
def extract_pdf_text_with_boundaries(
|
||||
pdf_doc: pymupdf.Document,
|
||||
) -> tuple[str, list[dict]]:
|
||||
"""Extract full document text with page boundary tracking.
|
||||
|
||||
Uses pymupdf4llm.to_markdown() for consistency with indexing.
|
||||
|
||||
IMPORTANT: Must use write_images=True to match PyMuPDFProcessor behavior!
|
||||
Even though we don't need the images, we need the image references in the
|
||||
markdown text to maintain consistent character offsets with indexing.
|
||||
|
||||
Args:
|
||||
pdf_doc: Open PyMuPDF document
|
||||
|
||||
Returns:
|
||||
Tuple of (full_text, page_boundaries) where page_boundaries is a list of:
|
||||
{"page": 1, "start_offset": 0, "end_offset": 1234}
|
||||
"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
page_boundaries = []
|
||||
text_parts = []
|
||||
current_offset = 0
|
||||
|
||||
# Use temp directory for image output (images are discarded after extraction)
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="pdf_highlight_"))
|
||||
|
||||
for page_idx in range(pdf_doc.page_count):
|
||||
page_md = pymupdf4llm.to_markdown(
|
||||
pdf_doc,
|
||||
pages=[page_idx],
|
||||
write_images=True, # Must match indexing! Otherwise offsets misalign
|
||||
image_path=temp_dir,
|
||||
page_chunks=False,
|
||||
)
|
||||
|
||||
page_boundaries.append(
|
||||
{
|
||||
"page": page_idx + 1, # 1-indexed
|
||||
"start_offset": current_offset,
|
||||
"end_offset": current_offset + len(page_md),
|
||||
}
|
||||
)
|
||||
|
||||
text_parts.append(page_md)
|
||||
current_offset += len(page_md)
|
||||
|
||||
full_text = "".join(text_parts)
|
||||
|
||||
# Clean up temp directory and extracted images
|
||||
import shutil
|
||||
|
||||
try:
|
||||
shutil.rmtree(temp_dir)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up temp directory {temp_dir}: {e}")
|
||||
|
||||
return full_text, page_boundaries
|
||||
|
||||
@staticmethod
|
||||
def find_chunk_page(
|
||||
chunk_start_offset: int,
|
||||
chunk_end_offset: int,
|
||||
page_boundaries: list[dict],
|
||||
) -> Optional[dict]:
|
||||
"""Find which page contains the most of a given chunk.
|
||||
|
||||
Args:
|
||||
chunk_start_offset: Chunk start position in full document
|
||||
chunk_end_offset: Chunk end position in full document
|
||||
page_boundaries: Page boundary list from extract_pdf_text_with_boundaries()
|
||||
|
||||
Returns:
|
||||
Dict with keys: page_num, overlap_chars, page_relative_start, page_relative_end
|
||||
or None if chunk not found on any page
|
||||
"""
|
||||
chunk_pages = []
|
||||
|
||||
for boundary in page_boundaries:
|
||||
page_start = boundary["start_offset"]
|
||||
page_end = boundary["end_offset"]
|
||||
|
||||
# Check if chunk overlaps with this page
|
||||
if chunk_start_offset < page_end and chunk_end_offset > page_start:
|
||||
overlap_start = max(chunk_start_offset, page_start)
|
||||
overlap_end = min(chunk_end_offset, page_end)
|
||||
overlap_chars = overlap_end - overlap_start
|
||||
|
||||
chunk_pages.append(
|
||||
{
|
||||
"page_num": boundary["page"],
|
||||
"overlap_chars": overlap_chars,
|
||||
"page_relative_start": overlap_start - page_start,
|
||||
"page_relative_end": overlap_end - page_start,
|
||||
}
|
||||
)
|
||||
|
||||
if not chunk_pages:
|
||||
return None
|
||||
|
||||
# Return page with maximum overlap
|
||||
return max(chunk_pages, key=lambda p: p["overlap_chars"])
|
||||
|
||||
@staticmethod
|
||||
def highlight_chunk_by_word_positions(
|
||||
page: pymupdf.Page,
|
||||
chunk_text: str,
|
||||
color: str = "yellow",
|
||||
search_region: tuple[float, float, float, float] | None = None,
|
||||
) -> int:
|
||||
"""Highlight chunk using word-position matching.
|
||||
|
||||
This method matches words from the chunk to their positions on the PDF page,
|
||||
avoiding text search mismatches between markdown-formatted text and raw PDF text.
|
||||
|
||||
Args:
|
||||
page: PyMuPDF page object
|
||||
chunk_text: Text to highlight (may contain markdown)
|
||||
color: Color name from COLORS dict
|
||||
search_region: Optional (x0, y0, x1, y1) bounding box to constrain search.
|
||||
If provided, only words within this region are considered.
|
||||
|
||||
Returns:
|
||||
Number of highlight rectangles added
|
||||
"""
|
||||
# Tokenize chunk into words (alphanumeric only, lowercase)
|
||||
chunk_words = re.findall(
|
||||
r"\w+", PDFHighlighter.strip_markdown(chunk_text).lower()
|
||||
)
|
||||
|
||||
if not chunk_words:
|
||||
logger.warning("No words found in chunk text")
|
||||
return 0
|
||||
|
||||
# Get all words from page with positions
|
||||
# Format: (x0, y0, x1, y1, "word", block_no, line_no, word_no)
|
||||
try:
|
||||
page_words = page.get_text("words")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to extract words from page: {e}")
|
||||
return 0
|
||||
|
||||
if not page_words:
|
||||
logger.warning("No words found on page")
|
||||
return 0
|
||||
|
||||
# Filter words by search region if provided
|
||||
if search_region:
|
||||
rx0, ry0, rx1, ry1 = search_region
|
||||
# Allow some tolerance (10 points) for words near region boundary
|
||||
tolerance = 10
|
||||
page_words = [
|
||||
w
|
||||
for w in page_words
|
||||
if (
|
||||
w[0] >= rx0 - tolerance
|
||||
and w[2] <= rx1 + tolerance
|
||||
and w[1] >= ry0 - tolerance
|
||||
and w[3] <= ry1 + tolerance
|
||||
)
|
||||
]
|
||||
logger.debug(
|
||||
f"Filtered to {len(page_words)} words in region "
|
||||
f"({rx0:.0f}, {ry0:.0f}, {rx1:.0f}, {ry1:.0f})"
|
||||
)
|
||||
|
||||
if not page_words:
|
||||
logger.warning("No words found in search region")
|
||||
return 0
|
||||
|
||||
# Find matching word sequence - use FIRST match, not longest
|
||||
# This ensures we highlight the actual chunk location, not similar text elsewhere
|
||||
matches = []
|
||||
|
||||
# Build a simple word-to-positions index for the first few chunk words
|
||||
# to find candidate starting positions
|
||||
first_chunk_word = chunk_words[0] if chunk_words else ""
|
||||
candidate_starts = []
|
||||
|
||||
for i, pw in enumerate(page_words):
|
||||
page_word = pw[4].lower()
|
||||
# Check if this could be the start of the chunk
|
||||
if (
|
||||
first_chunk_word == page_word
|
||||
or first_chunk_word in page_word
|
||||
or page_word in first_chunk_word
|
||||
):
|
||||
candidate_starts.append(i)
|
||||
|
||||
# Try each candidate start position and take the FIRST good match
|
||||
for start_pos in candidate_starts:
|
||||
current_matches = []
|
||||
chunk_idx = 0
|
||||
skip_count = 0
|
||||
max_skips = 3 # Allow some formatting differences
|
||||
|
||||
for page_idx in range(start_pos, len(page_words)):
|
||||
if chunk_idx >= len(chunk_words):
|
||||
break
|
||||
|
||||
page_word = page_words[page_idx][4].lower()
|
||||
chunk_word = chunk_words[chunk_idx]
|
||||
|
||||
# Check for match (allow partial matches for flexibility)
|
||||
if (
|
||||
chunk_word == page_word
|
||||
or chunk_word in page_word
|
||||
or page_word in chunk_word
|
||||
):
|
||||
current_matches.append(page_words[page_idx])
|
||||
chunk_idx += 1
|
||||
skip_count = 0
|
||||
elif skip_count < max_skips:
|
||||
# Allow skipping some words (formatting, punctuation)
|
||||
skip_count += 1
|
||||
continue
|
||||
else:
|
||||
break
|
||||
|
||||
# Accept if we matched at least 50% of chunk words
|
||||
if len(current_matches) >= len(chunk_words) * 0.5:
|
||||
matches = current_matches
|
||||
logger.debug(
|
||||
f"Found match at position {start_pos}: "
|
||||
f"{len(matches)}/{len(chunk_words)} words"
|
||||
)
|
||||
break # Take FIRST match, not best/longest
|
||||
|
||||
if not matches:
|
||||
logger.debug(f"No word matches found (chunk has {len(chunk_words)} words)")
|
||||
return 0
|
||||
|
||||
logger.debug(
|
||||
f"Matched {len(matches)} words out of {len(chunk_words)} chunk words"
|
||||
)
|
||||
|
||||
# Build rectangles from matched words
|
||||
rects = [pymupdf.Rect(w[0], w[1], w[2], w[3]) for w in matches]
|
||||
|
||||
# Check if matches are contiguous (not scattered across the page)
|
||||
# Scattered matches indicate false positives from common words
|
||||
if len(rects) > 1:
|
||||
# Sort by vertical position then horizontal
|
||||
sorted_matches = sorted(matches, key=lambda w: (round(w[1]), w[0]))
|
||||
|
||||
# Check for large vertical gaps (more than ~2 lines apart)
|
||||
# A typical line height is 12-20 points
|
||||
max_line_gap = 50 # Points - allows for ~2-3 lines gap
|
||||
prev_y = sorted_matches[0][1]
|
||||
large_gaps = 0
|
||||
|
||||
for match in sorted_matches[1:]:
|
||||
y_gap = match[1] - prev_y
|
||||
if y_gap > max_line_gap:
|
||||
large_gaps += 1
|
||||
prev_y = match[1]
|
||||
|
||||
# If matches are scattered (many large gaps), reject this match
|
||||
# A chunk should be mostly contiguous text
|
||||
if large_gaps > len(matches) * 0.3: # More than 30% have gaps
|
||||
logger.debug(
|
||||
f"Rejecting scattered matches: {large_gaps} large gaps "
|
||||
f"out of {len(matches)} matches"
|
||||
)
|
||||
return 0
|
||||
|
||||
# Merge adjacent rectangles on the same line for cleaner highlighting
|
||||
merged_rects = []
|
||||
sorted_rects = sorted(rects, key=lambda r: (round(r.y0), r.x0))
|
||||
|
||||
current_rect = None
|
||||
for rect in sorted_rects:
|
||||
if current_rect is None:
|
||||
current_rect = rect
|
||||
elif abs(rect.y0 - current_rect.y0) < 5: # Same line (within 5 points)
|
||||
current_rect = current_rect | rect # Union
|
||||
else:
|
||||
merged_rects.append(current_rect)
|
||||
current_rect = rect
|
||||
|
||||
if current_rect:
|
||||
merged_rects.append(current_rect)
|
||||
|
||||
# Add highlights
|
||||
rgb = PDFHighlighter.COLORS.get(color, PDFHighlighter.COLORS["yellow"])
|
||||
for rect in merged_rects:
|
||||
highlight = page.add_highlight_annot(rect)
|
||||
highlight.set_colors({"stroke": rgb})
|
||||
highlight.set_info(
|
||||
content="Chunk from semantic search",
|
||||
title="PDF Highlighter (word-position)",
|
||||
)
|
||||
highlight.update()
|
||||
|
||||
return len(merged_rects)
|
||||
|
||||
@staticmethod
|
||||
def find_unique_phrase(
|
||||
text: str, min_len: int = 30, max_len: int = 80
|
||||
) -> str | None:
|
||||
"""Find a relatively unique phrase from text for location search.
|
||||
|
||||
Looks for phrases that are likely to be unique on the page:
|
||||
- Prefers phrases with numbers or special terms
|
||||
- Avoids very common words
|
||||
|
||||
Args:
|
||||
text: Source text to extract phrase from
|
||||
min_len: Minimum phrase length
|
||||
max_len: Maximum phrase length
|
||||
|
||||
Returns:
|
||||
A phrase likely to be unique, or None if not found
|
||||
"""
|
||||
clean_text = PDFHighlighter.strip_markdown(text).strip()
|
||||
if not clean_text:
|
||||
return None
|
||||
|
||||
# Try first sentence (often unique due to context)
|
||||
sentences = re.split(r"[.!?]\s+", clean_text)
|
||||
for sentence in sentences:
|
||||
sentence = sentence.strip()
|
||||
if min_len <= len(sentence) <= max_len:
|
||||
return sentence
|
||||
elif len(sentence) > max_len:
|
||||
return sentence[:max_len]
|
||||
|
||||
# Fallback: first N chars
|
||||
if len(clean_text) >= min_len:
|
||||
return clean_text[:max_len]
|
||||
|
||||
return clean_text if clean_text else None
|
||||
|
||||
@staticmethod
|
||||
def _find_chunk_bbox(
|
||||
page: pymupdf.Page,
|
||||
chunk_text: str,
|
||||
page_relative_start: int,
|
||||
page_relative_end: int,
|
||||
page_text_length: int,
|
||||
) -> tuple[float, float, float, float] | None:
|
||||
"""Find bounding box for a chunk without modifying the page.
|
||||
|
||||
Returns (x0, y0, x1, y1) in page coordinates, or None if not found.
|
||||
"""
|
||||
page_rect = page.rect
|
||||
|
||||
# Strip markdown for searching
|
||||
search_text = PDFHighlighter.strip_markdown(chunk_text)
|
||||
|
||||
# Try to find chunk location using text search
|
||||
anchor_rect = None
|
||||
search_phrases = []
|
||||
|
||||
# Build search phrases from chunk text
|
||||
sentences = re.split(r"[.!?]\s+", search_text)
|
||||
for sentence in sentences[:3]:
|
||||
sentence = sentence.strip()
|
||||
if len(sentence) >= 20:
|
||||
search_phrases.append(sentence[:80])
|
||||
if len(sentence) >= 40:
|
||||
search_phrases.append(sentence[:40])
|
||||
|
||||
# Also try first N characters
|
||||
if len(search_text) >= 30:
|
||||
search_phrases.append(search_text[:60])
|
||||
search_phrases.append(search_text[:30])
|
||||
|
||||
for phrase in search_phrases:
|
||||
if not phrase:
|
||||
continue
|
||||
rects = page.search_for(phrase.strip())
|
||||
if rects:
|
||||
anchor_rect = rects[0]
|
||||
break
|
||||
|
||||
if not anchor_rect:
|
||||
return None
|
||||
|
||||
# Calculate chunk height based on character count
|
||||
chunk_chars = len(search_text)
|
||||
estimated_lines = max(1, chunk_chars / 60)
|
||||
estimated_height = estimated_lines * 14
|
||||
|
||||
# Build bounding box
|
||||
return (
|
||||
page_rect.x0 + 30, # Left margin
|
||||
anchor_rect.y0 - 5, # Start slightly above anchor
|
||||
page_rect.x1 - 30, # Right margin
|
||||
min(anchor_rect.y0 + estimated_height + 10, page_rect.y1 - 30),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def highlight_chunk_on_page(
|
||||
page: pymupdf.Page,
|
||||
chunk_text: str,
|
||||
color: str = "yellow",
|
||||
page_relative_start: int | None = None,
|
||||
page_relative_end: int | None = None,
|
||||
page_text_length: int | None = None,
|
||||
) -> int:
|
||||
"""Add bounding box highlight to a PDF page for the given chunk text.
|
||||
|
||||
Uses text search to find the chunk's location on the page, then draws
|
||||
a bounding box around that region. Falls back to character offset estimation
|
||||
if text search fails.
|
||||
|
||||
Args:
|
||||
page: PyMuPDF page object
|
||||
chunk_text: Text to highlight (may contain markdown)
|
||||
color: Color name from COLORS dict
|
||||
page_relative_start: Character offset where chunk starts on page (optional)
|
||||
page_relative_end: Character offset where chunk ends on page (optional)
|
||||
page_text_length: Total character length of page text (optional)
|
||||
|
||||
Returns:
|
||||
Number of highlights added (1 for bounding box, 0 if failed)
|
||||
"""
|
||||
page_rect = page.rect
|
||||
rgb = PDFHighlighter.COLORS.get(color, PDFHighlighter.COLORS["yellow"])
|
||||
|
||||
# Strip markdown for searching
|
||||
search_text = PDFHighlighter.strip_markdown(chunk_text)
|
||||
|
||||
# Try to find chunk location using text search
|
||||
# Search for progressively shorter phrases until we find a match
|
||||
anchor_rect = None
|
||||
search_phrases = []
|
||||
|
||||
# Build search phrases from chunk text
|
||||
sentences = re.split(r"[.!?]\s+", search_text)
|
||||
for sentence in sentences[:3]: # Try first 3 sentences
|
||||
sentence = sentence.strip()
|
||||
if len(sentence) >= 20:
|
||||
search_phrases.append(sentence[:80])
|
||||
if len(sentence) >= 40:
|
||||
search_phrases.append(sentence[:40])
|
||||
|
||||
# Also try first N characters
|
||||
if len(search_text) >= 30:
|
||||
search_phrases.append(search_text[:60])
|
||||
search_phrases.append(search_text[:30])
|
||||
|
||||
for phrase in search_phrases:
|
||||
if not phrase:
|
||||
continue
|
||||
rects = page.search_for(phrase.strip())
|
||||
if rects:
|
||||
anchor_rect = rects[0] # Use first match
|
||||
logger.debug(f"Found chunk anchor using phrase: '{phrase[:30]}...'")
|
||||
break
|
||||
|
||||
if not anchor_rect:
|
||||
page_num = page.number + 1 if page.number is not None else "unknown"
|
||||
logger.warning(f"Could not find chunk text on page {page_num}")
|
||||
return 0
|
||||
|
||||
# Calculate chunk height based on character count
|
||||
# Estimate ~15 chars per line, ~12pt line height
|
||||
chunk_chars = len(search_text)
|
||||
estimated_lines = max(1, chunk_chars / 60) # ~60 chars per line typical
|
||||
estimated_height = estimated_lines * 14 # ~14pt per line
|
||||
|
||||
# Build bounding box starting from anchor
|
||||
chunk_rect = pymupdf.Rect(
|
||||
page_rect.x0 + 30, # Left margin
|
||||
anchor_rect.y0 - 5, # Start slightly above anchor
|
||||
page_rect.x1 - 30, # Right margin
|
||||
min(
|
||||
anchor_rect.y0 + estimated_height + 10, page_rect.y1 - 30
|
||||
), # Estimated bottom
|
||||
)
|
||||
|
||||
# Draw a visible rectangle around the chunk region
|
||||
shape = page.new_shape()
|
||||
shape.draw_rect(chunk_rect)
|
||||
shape.finish(
|
||||
color=rgb, # Border color
|
||||
fill=None, # No fill (transparent)
|
||||
width=2.5, # Border width
|
||||
dashes="[4 2]", # Dashed line
|
||||
)
|
||||
shape.commit()
|
||||
|
||||
# Add semi-transparent fill for visibility
|
||||
fill_shape = page.new_shape()
|
||||
fill_shape.draw_rect(chunk_rect)
|
||||
fill_shape.finish(
|
||||
color=None, # No border
|
||||
fill=[1, 1, 0.7], # Light yellow fill
|
||||
fill_opacity=0.15, # Very transparent
|
||||
)
|
||||
fill_shape.commit()
|
||||
|
||||
logger.debug(
|
||||
f"Added bounding box at y={chunk_rect.y0:.0f}-{chunk_rect.y1:.0f} "
|
||||
f"(estimated {estimated_lines:.1f} lines)"
|
||||
)
|
||||
|
||||
return 1
|
||||
|
||||
@staticmethod
|
||||
def highlight_chunk(
|
||||
pdf_bytes: bytes,
|
||||
chunk_start_offset: int,
|
||||
chunk_end_offset: int,
|
||||
stored_page_number: Optional[int] = None,
|
||||
color: str = "yellow",
|
||||
zoom: float = 2.0,
|
||||
) -> Optional[tuple[bytes, int, int]]:
|
||||
"""Generate PNG image of PDF page with highlighted chunk.
|
||||
|
||||
This is the main entry point for highlighting. It:
|
||||
1. Extracts document text with page boundaries
|
||||
2. Finds which page contains the chunk
|
||||
3. Extracts chunk text using character offsets
|
||||
4. Highlights the chunk on the page
|
||||
5. Renders page to PNG
|
||||
|
||||
Args:
|
||||
pdf_bytes: PDF file bytes
|
||||
chunk_start_offset: Chunk start position (document-level)
|
||||
chunk_end_offset: Chunk end position (document-level)
|
||||
stored_page_number: Page number from metadata (optional, for validation)
|
||||
color: Highlight color name
|
||||
zoom: Rendering zoom factor (2.0 = 144 DPI)
|
||||
|
||||
Returns:
|
||||
Tuple of (png_bytes, page_number, highlight_count) or None if failed
|
||||
"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
temp_pdf_path = None
|
||||
try:
|
||||
# Write PDF to temp file with consistent name "pdf.pdf"
|
||||
# This ensures image references match indexing (e.g., pdf-0001.png)
|
||||
# Different temp filenames would cause different markdown text lengths!
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="pdf_highlight_"))
|
||||
temp_pdf_path = temp_dir / "pdf.pdf"
|
||||
temp_pdf_path.write_bytes(pdf_bytes)
|
||||
|
||||
# Open PDF from temp file
|
||||
doc = pymupdf.open(temp_pdf_path)
|
||||
|
||||
# Extract text with page boundaries
|
||||
full_text, page_boundaries = (
|
||||
PDFHighlighter.extract_pdf_text_with_boundaries(doc)
|
||||
)
|
||||
|
||||
# Find which page contains the chunk
|
||||
chunk_page_info = PDFHighlighter.find_chunk_page(
|
||||
chunk_start_offset, chunk_end_offset, page_boundaries
|
||||
)
|
||||
|
||||
if not chunk_page_info:
|
||||
logger.error("Chunk not found on any page")
|
||||
doc.close()
|
||||
return None
|
||||
|
||||
page_num = chunk_page_info["page_num"]
|
||||
|
||||
# Log if page differs from stored metadata
|
||||
if stored_page_number and stored_page_number != page_num:
|
||||
logger.info(
|
||||
f"Chunk primarily on page {page_num}, metadata says {stored_page_number}"
|
||||
)
|
||||
|
||||
# Extract page text
|
||||
page_boundary = page_boundaries[page_num - 1]
|
||||
page_start = page_boundary["start_offset"]
|
||||
page_end = page_boundary["end_offset"]
|
||||
page_text = full_text[page_start:page_end]
|
||||
|
||||
# Extract chunk text using page-relative offsets
|
||||
page_relative_start = chunk_page_info["page_relative_start"]
|
||||
page_relative_end = chunk_page_info["page_relative_end"]
|
||||
chunk_text = page_text[page_relative_start:page_relative_end]
|
||||
|
||||
# Calculate page text length for region estimation
|
||||
page_text_length = page_end - page_start
|
||||
|
||||
logger.debug(
|
||||
f"Extracted {len(chunk_text)} chars on page {page_num} "
|
||||
f"(offsets {page_relative_start}-{page_relative_end} of {page_text_length})"
|
||||
)
|
||||
|
||||
# Get page and add highlights
|
||||
page = doc[page_num - 1]
|
||||
highlight_count = PDFHighlighter.highlight_chunk_on_page(
|
||||
page,
|
||||
chunk_text,
|
||||
color,
|
||||
page_relative_start=page_relative_start,
|
||||
page_relative_end=page_relative_end,
|
||||
page_text_length=page_text_length,
|
||||
)
|
||||
|
||||
if highlight_count == 0:
|
||||
logger.warning("No highlights added")
|
||||
doc.close()
|
||||
return None
|
||||
|
||||
# Render page to PNG
|
||||
mat = pymupdf.Matrix(zoom, zoom)
|
||||
pix = page.get_pixmap(matrix=mat, alpha=False)
|
||||
png_bytes = pix.tobytes("png")
|
||||
|
||||
doc.close()
|
||||
|
||||
logger.info(
|
||||
f"Generated {len(png_bytes):,} byte image with {highlight_count} highlights"
|
||||
)
|
||||
|
||||
return (png_bytes, page_num, highlight_count)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error highlighting chunk: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
finally:
|
||||
# Clean up temp directory and PDF file
|
||||
if temp_pdf_path and temp_pdf_path.parent.exists():
|
||||
try:
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(temp_pdf_path.parent)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to delete temp directory {temp_pdf_path.parent}: {e}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def highlight_chunks_batch(
|
||||
pdf_bytes: bytes,
|
||||
chunks: list[tuple[int, int, int, int | None, str]],
|
||||
page_boundaries: list[dict],
|
||||
full_text: str,
|
||||
color: str = "yellow",
|
||||
zoom: float = 2.0,
|
||||
) -> dict[int, tuple[bytes, int, int]]:
|
||||
"""Generate highlighted images for multiple chunks.
|
||||
|
||||
Opens PDF once for rendering, uses pre-computed page boundaries from the
|
||||
document processor. This ensures consistent character offsets between
|
||||
chunking and highlighting.
|
||||
|
||||
Args:
|
||||
pdf_bytes: PDF file bytes
|
||||
chunks: List of (chunk_index, start_offset, end_offset, stored_page_number, chunk_text)
|
||||
The chunk_index is used as the key in the returned dict.
|
||||
chunk_text is the actual text content of the chunk.
|
||||
page_boundaries: Pre-computed page boundaries from document processor.
|
||||
Each entry: {"page": 1, "start_offset": 0, "end_offset": 1234}
|
||||
full_text: Full document text for extracting page-relative portions.
|
||||
color: Highlight color name
|
||||
zoom: Rendering zoom factor (2.0 = 144 DPI)
|
||||
|
||||
Returns:
|
||||
Dict mapping chunk_index to (png_bytes, page_number, highlight_count)
|
||||
Chunks that fail to highlight are omitted from the result.
|
||||
"""
|
||||
import shutil
|
||||
import tempfile
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
results: dict[int, tuple[bytes, int, int]] = {}
|
||||
|
||||
if not chunks:
|
||||
return results
|
||||
|
||||
temp_pdf_path = None
|
||||
try:
|
||||
# Write PDF to temp file
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="pdf_highlight_batch_"))
|
||||
temp_pdf_path = temp_dir / "pdf.pdf"
|
||||
temp_pdf_path.write_bytes(pdf_bytes)
|
||||
|
||||
# Open PDF once (only for rendering, not text extraction)
|
||||
doc = pymupdf.open(temp_pdf_path)
|
||||
|
||||
logger.debug(
|
||||
f"Batch highlighting: {len(chunks)} chunks, "
|
||||
f"{len(page_boundaries)} pages"
|
||||
)
|
||||
|
||||
# Group chunks by their target page for efficient rendering
|
||||
# We'll render each page only once with all its highlights
|
||||
chunks_by_page: dict[int, list[tuple[int, dict, str]]] = defaultdict(list)
|
||||
|
||||
for chunk_tuple in chunks:
|
||||
# Unpack chunk tuple - chunk_text is now passed directly
|
||||
chunk_index, start_offset, end_offset, stored_page_num, chunk_text = (
|
||||
chunk_tuple
|
||||
)
|
||||
|
||||
# Find which page contains this chunk
|
||||
chunk_page_info = PDFHighlighter.find_chunk_page(
|
||||
start_offset, end_offset, page_boundaries
|
||||
)
|
||||
|
||||
if not chunk_page_info:
|
||||
logger.warning(f"Chunk {chunk_index}: not found on any page")
|
||||
continue
|
||||
|
||||
page_num = chunk_page_info["page_num"]
|
||||
|
||||
# Log if page differs from stored metadata
|
||||
if stored_page_num and stored_page_num != page_num:
|
||||
logger.debug(
|
||||
f"Chunk {chunk_index}: found on page {page_num}, "
|
||||
f"metadata says {stored_page_num}"
|
||||
)
|
||||
|
||||
# Extract page-relative portion of chunk text
|
||||
# This is critical for cross-page chunks where the start
|
||||
# of the chunk might be on a different page
|
||||
page_boundary = page_boundaries[page_num - 1]
|
||||
page_start = page_boundary["start_offset"]
|
||||
page_end = page_boundary["end_offset"]
|
||||
page_text_length = page_end - page_start
|
||||
|
||||
# Calculate what portion of the chunk appears on this page
|
||||
chunk_start_on_page = max(start_offset, page_start)
|
||||
chunk_end_on_page = min(end_offset, page_end)
|
||||
|
||||
# Extract just the text that appears on this page
|
||||
page_relative_text = full_text[chunk_start_on_page:chunk_end_on_page]
|
||||
|
||||
chunks_by_page[page_num].append(
|
||||
(chunk_index, chunk_page_info, page_relative_text, page_text_length)
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Chunks distributed across {len(chunks_by_page)} unique pages"
|
||||
)
|
||||
|
||||
# OPTIMIZATION: Render each page ONCE, then draw highlights using PIL
|
||||
# This avoids expensive page.get_pixmap() calls per chunk
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
# PIL color for bounding box (RGB tuple)
|
||||
rgb = PDFHighlighter.COLORS.get(color, PDFHighlighter.COLORS["yellow"])
|
||||
pil_color = tuple(int(c * 255) for c in rgb)
|
||||
fill_color = (255, 255, 178, 38) # Light yellow with alpha
|
||||
|
||||
for page_num, page_chunks in chunks_by_page.items():
|
||||
page = doc[page_num - 1]
|
||||
|
||||
# Render page ONCE to get base image (most expensive operation)
|
||||
mat = pymupdf.Matrix(zoom, zoom)
|
||||
base_pix = page.get_pixmap(matrix=mat, alpha=False)
|
||||
base_png = base_pix.tobytes("png")
|
||||
|
||||
# Convert to PIL Image for fast highlight drawing
|
||||
base_image = Image.open(BytesIO(base_png)).convert("RGBA")
|
||||
page_rect = page.rect
|
||||
|
||||
logger.debug(
|
||||
f"Page {page_num}: rendered once, processing {len(page_chunks)} chunks"
|
||||
)
|
||||
|
||||
for (
|
||||
chunk_index,
|
||||
chunk_page_info,
|
||||
chunk_text,
|
||||
page_text_length,
|
||||
) in page_chunks:
|
||||
try:
|
||||
# Find chunk bounding box using text search
|
||||
bbox = PDFHighlighter._find_chunk_bbox(
|
||||
page,
|
||||
chunk_text,
|
||||
chunk_page_info["page_relative_start"],
|
||||
chunk_page_info["page_relative_end"],
|
||||
page_text_length,
|
||||
)
|
||||
|
||||
if bbox is None:
|
||||
logger.warning(f"Chunk {chunk_index}: could not find bbox")
|
||||
continue
|
||||
|
||||
# Copy base image for this chunk
|
||||
chunk_image = base_image.copy()
|
||||
|
||||
# Scale bbox coordinates to pixmap coordinates
|
||||
scale_x = base_pix.width / page_rect.width
|
||||
scale_y = base_pix.height / page_rect.height
|
||||
pil_bbox = (
|
||||
int(bbox[0] * scale_x),
|
||||
int(bbox[1] * scale_y),
|
||||
int(bbox[2] * scale_x),
|
||||
int(bbox[3] * scale_y),
|
||||
)
|
||||
|
||||
# Create transparent overlay for fill (proper alpha blending)
|
||||
overlay = Image.new("RGBA", chunk_image.size, (0, 0, 0, 0))
|
||||
overlay_draw = ImageDraw.Draw(overlay)
|
||||
overlay_draw.rectangle(pil_bbox, fill=fill_color)
|
||||
|
||||
# Alpha composite the overlay onto the chunk image
|
||||
chunk_image = Image.alpha_composite(chunk_image, overlay)
|
||||
|
||||
# Draw border on top (solid, not transparent)
|
||||
border_draw = ImageDraw.Draw(chunk_image)
|
||||
border_draw.rectangle(pil_bbox, outline=pil_color, width=3)
|
||||
|
||||
# Convert back to PNG bytes
|
||||
output = BytesIO()
|
||||
chunk_image.convert("RGB").save(output, format="PNG")
|
||||
png_bytes = output.getvalue()
|
||||
|
||||
results[chunk_index] = (png_bytes, page_num, 1)
|
||||
|
||||
logger.debug(
|
||||
f"Chunk {chunk_index}: {len(png_bytes):,} bytes, "
|
||||
f"page {page_num}, bbox {pil_bbox}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Chunk {chunk_index}: error - {e}")
|
||||
continue
|
||||
|
||||
doc.close()
|
||||
|
||||
logger.info(
|
||||
f"Batch highlighted {len(results)}/{len(chunks)} chunks successfully"
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in batch highlighting: {e}", exc_info=True)
|
||||
return results
|
||||
|
||||
finally:
|
||||
# Clean up temp directory
|
||||
if temp_pdf_path and temp_pdf_path.parent.exists():
|
||||
try:
|
||||
shutil.rmtree(temp_pdf_path.parent)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up temp dir: {e}")
|
||||
@@ -9,6 +9,7 @@ from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.embedding import get_embedding_service
|
||||
from nextcloud_mcp_server.observability.metrics import record_qdrant_operation
|
||||
from nextcloud_mcp_server.search.algorithms import SearchAlgorithm, SearchResult
|
||||
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -50,6 +51,9 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
|
||||
Returns unverified results from Qdrant. Access verification should be
|
||||
performed separately at the final output stage using verify_search_results().
|
||||
|
||||
Deduplicates by (doc_id, doc_type, chunk_start_offset, chunk_end_offset)
|
||||
to show multiple chunks from the same document while avoiding duplicate chunks.
|
||||
|
||||
Args:
|
||||
query: Natural language search query
|
||||
user_id: User ID for filtering
|
||||
@@ -74,16 +78,19 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
|
||||
# Generate embedding for query
|
||||
embedding_service = get_embedding_service()
|
||||
query_embedding = await embedding_service.embed(query)
|
||||
# Store for reuse by callers (e.g., viz_routes PCA visualization)
|
||||
self.query_embedding = query_embedding
|
||||
logger.debug(
|
||||
f"Generated embedding for query (dimension={len(query_embedding)})"
|
||||
)
|
||||
|
||||
# Build Qdrant filter
|
||||
filter_conditions = [
|
||||
get_placeholder_filter(), # Always exclude placeholders from user-facing queries
|
||||
FieldCondition(
|
||||
key="user_id",
|
||||
match=MatchValue(value=user_id),
|
||||
)
|
||||
),
|
||||
]
|
||||
|
||||
# Add doc_type filter if specified
|
||||
@@ -123,20 +130,37 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
|
||||
top_scores = [p.score for p in search_response.points[:3]]
|
||||
logger.debug(f"Top 3 similarity scores: {top_scores}")
|
||||
|
||||
# Deduplicate by (doc_id, doc_type) - multiple chunks per document
|
||||
seen_docs = set()
|
||||
# Deduplicate by (doc_id, doc_type, chunk_start, chunk_end)
|
||||
# This allows multiple chunks from same doc, but removes duplicate chunks
|
||||
seen_chunks = set()
|
||||
results = []
|
||||
|
||||
for result in search_response.points:
|
||||
doc_id = int(result.payload["doc_id"])
|
||||
if result.payload is None:
|
||||
continue
|
||||
# doc_id can be int (notes) or str (files - file paths)
|
||||
doc_id = result.payload["doc_id"]
|
||||
doc_type = result.payload.get("doc_type", "note")
|
||||
doc_key = (doc_id, doc_type)
|
||||
chunk_start = result.payload.get("chunk_start_offset")
|
||||
chunk_end = result.payload.get("chunk_end_offset")
|
||||
chunk_key = (doc_id, doc_type, chunk_start, chunk_end)
|
||||
|
||||
# Skip if we've already seen this document
|
||||
if doc_key in seen_docs:
|
||||
# Skip if we've already seen this exact chunk
|
||||
if chunk_key in seen_chunks:
|
||||
continue
|
||||
|
||||
seen_docs.add(doc_key)
|
||||
seen_chunks.add(chunk_key)
|
||||
|
||||
# Build metadata dict with common fields
|
||||
metadata = {
|
||||
"chunk_index": result.payload.get("chunk_index"),
|
||||
"total_chunks": result.payload.get("total_chunks"),
|
||||
}
|
||||
|
||||
# Add deck_card-specific metadata for frontend URL construction
|
||||
if doc_type == "deck_card":
|
||||
if board_id := result.payload.get("board_id"):
|
||||
metadata["board_id"] = board_id
|
||||
|
||||
# Return unverified results (verification happens at output stage)
|
||||
results.append(
|
||||
@@ -146,12 +170,13 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
|
||||
title=result.payload.get("title", "Untitled"),
|
||||
excerpt=result.payload.get("excerpt", ""),
|
||||
score=result.score,
|
||||
metadata={
|
||||
"chunk_index": result.payload.get("chunk_index"),
|
||||
"total_chunks": result.payload.get("total_chunks"),
|
||||
},
|
||||
metadata=metadata,
|
||||
chunk_start_offset=result.payload.get("chunk_start_offset"),
|
||||
chunk_end_offset=result.payload.get("chunk_end_offset"),
|
||||
page_number=result.payload.get("page_number"),
|
||||
chunk_index=result.payload.get("chunk_index", 0),
|
||||
total_chunks=result.payload.get("total_chunks", 1),
|
||||
point_id=str(result.id), # Qdrant point ID for batch retrieval
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from .calendar import configure_calendar_tools
|
||||
from .contacts import configure_contacts_tools
|
||||
from .cookbook import configure_cookbook_tools
|
||||
from .deck import configure_deck_tools
|
||||
from .news import configure_news_tools
|
||||
from .notes import configure_notes_tools
|
||||
from .semantic import configure_semantic_tools
|
||||
from .sharing import configure_sharing_tools
|
||||
@@ -13,6 +14,7 @@ __all__ = [
|
||||
"configure_contacts_tools",
|
||||
"configure_cookbook_tools",
|
||||
"configure_deck_tools",
|
||||
"configure_news_tools",
|
||||
"configure_notes_tools",
|
||||
"configure_semantic_tools",
|
||||
"configure_sharing_tools",
|
||||
|
||||
@@ -3,6 +3,7 @@ import logging
|
||||
from typing import Optional
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.types import ToolAnnotations
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
@@ -19,7 +20,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def configure_calendar_tools(mcp: FastMCP):
|
||||
# Calendar tools
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="List Calendars",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("calendar:read")
|
||||
@instrument_tool
|
||||
async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse:
|
||||
@@ -30,7 +34,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
calendars = [Calendar(**cal_data) for cal_data in calendars_data]
|
||||
return ListCalendarsResponse(calendars=calendars, total_count=len(calendars))
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Create Calendar Event",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("calendar:write")
|
||||
@instrument_tool
|
||||
async def nc_calendar_create_event(
|
||||
@@ -107,7 +114,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
|
||||
return await client.calendar.create_event(calendar_name, event_data)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="List Calendar Events",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("calendar:read")
|
||||
@instrument_tool
|
||||
async def nc_calendar_list_events(
|
||||
@@ -210,7 +220,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
|
||||
return events
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Get Calendar Event",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("calendar:read")
|
||||
@instrument_tool
|
||||
async def nc_calendar_get_event(
|
||||
@@ -223,7 +236,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
event_data, etag = await client.calendar.get_event(calendar_name, event_uid)
|
||||
return event_data
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Update Calendar Event",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("calendar:write")
|
||||
@instrument_tool
|
||||
async def nc_calendar_update_event(
|
||||
@@ -297,7 +313,12 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
calendar_name, event_uid, event_data, etag
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Delete Calendar Event",
|
||||
annotations=ToolAnnotations(
|
||||
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||
),
|
||||
)
|
||||
@require_scopes("calendar:write")
|
||||
@instrument_tool
|
||||
async def nc_calendar_delete_event(
|
||||
@@ -309,7 +330,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
client = await get_client(ctx)
|
||||
return await client.calendar.delete_event(calendar_name, event_uid)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Create Meeting",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("calendar:write")
|
||||
@instrument_tool
|
||||
async def nc_calendar_create_meeting(
|
||||
@@ -376,7 +400,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
|
||||
return await client.calendar.create_event(calendar_name, event_data)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Get Upcoming Events",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("calendar:read")
|
||||
@instrument_tool
|
||||
async def nc_calendar_get_upcoming_events(
|
||||
@@ -427,7 +454,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
all_events.sort(key=lambda x: x.get("start_datetime", ""))
|
||||
return all_events[:limit]
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Find Availability",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("calendar:read")
|
||||
@instrument_tool
|
||||
async def nc_calendar_find_availability(
|
||||
@@ -508,7 +538,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
constraints=constraints,
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Bulk Calendar Operations",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("calendar:write")
|
||||
@instrument_tool
|
||||
async def nc_calendar_bulk_operations(
|
||||
@@ -758,7 +791,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
"results": results,
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Manage Calendar",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("calendar:write")
|
||||
@instrument_tool
|
||||
async def nc_calendar_manage_calendar(
|
||||
@@ -828,7 +864,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
|
||||
# ============= Todo/Task Tools =============
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="List Todo Tasks",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("todo:read", "calendar:read")
|
||||
@instrument_tool
|
||||
async def nc_calendar_list_todos(
|
||||
@@ -874,7 +913,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
todos=todos, calendar_name=calendar_name, total_count=len(todos)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Create Todo Task",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("todo:write", "calendar:read")
|
||||
@instrument_tool
|
||||
async def nc_calendar_create_todo(
|
||||
@@ -918,7 +960,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
|
||||
return await client.calendar.create_todo(calendar_name, todo_data)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Update Todo Task",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("todo:write", "calendar:read")
|
||||
@instrument_tool
|
||||
async def nc_calendar_update_todo(
|
||||
@@ -979,7 +1024,12 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
|
||||
return await client.calendar.update_todo(calendar_name, todo_uid, todo_data)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Delete Todo Task",
|
||||
annotations=ToolAnnotations(
|
||||
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||
),
|
||||
)
|
||||
@require_scopes("todo:write", "calendar:read")
|
||||
@instrument_tool
|
||||
async def nc_calendar_delete_todo(
|
||||
@@ -1000,7 +1050,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
client = await get_client(ctx)
|
||||
return await client.calendar.delete_todo(calendar_name, todo_uid)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Search Todo Tasks",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("todo:read", "calendar:read")
|
||||
@instrument_tool
|
||||
async def nc_calendar_search_todos(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.types import ToolAnnotations
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
@@ -11,7 +12,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def configure_contacts_tools(mcp: FastMCP):
|
||||
# Contacts tools
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="List Address Books",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("contacts:read")
|
||||
@instrument_tool
|
||||
async def nc_contacts_list_addressbooks(ctx: Context):
|
||||
@@ -19,7 +23,10 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
client = await get_client(ctx)
|
||||
return await client.contacts.list_addressbooks()
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="List Contacts",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("contacts:read")
|
||||
@instrument_tool
|
||||
async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
|
||||
@@ -27,7 +34,10 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
client = await get_client(ctx)
|
||||
return await client.contacts.list_contacts(addressbook=addressbook)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Create Address Book",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("contacts:write")
|
||||
@instrument_tool
|
||||
async def nc_contacts_create_addressbook(
|
||||
@@ -44,7 +54,12 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
name=name, display_name=display_name
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Delete Address Book",
|
||||
annotations=ToolAnnotations(
|
||||
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||
),
|
||||
)
|
||||
@require_scopes("contacts:write")
|
||||
@instrument_tool
|
||||
async def nc_contacts_delete_addressbook(ctx: Context, *, name: str):
|
||||
@@ -52,7 +67,10 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
client = await get_client(ctx)
|
||||
return await client.contacts.delete_addressbook(name=name)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Create Contact",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("contacts:write")
|
||||
@instrument_tool
|
||||
async def nc_contacts_create_contact(
|
||||
@@ -70,7 +88,12 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
addressbook=addressbook, uid=uid, contact_data=contact_data
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Delete Contact",
|
||||
annotations=ToolAnnotations(
|
||||
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||
),
|
||||
)
|
||||
@require_scopes("contacts:write")
|
||||
@instrument_tool
|
||||
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
|
||||
@@ -78,7 +101,10 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
client = await get_client(ctx)
|
||||
return await client.contacts.delete_contact(addressbook=addressbook, uid=uid)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Update Contact",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("contacts:write")
|
||||
@instrument_tool
|
||||
async def nc_contacts_update_contact(
|
||||
|
||||
@@ -3,7 +3,7 @@ import logging
|
||||
from httpx import HTTPStatusError, RequestError
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.types import ErrorData
|
||||
from mcp.types import ErrorData, ToolAnnotations
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
@@ -71,7 +71,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Import Recipe from URL",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("cookbook:write")
|
||||
@instrument_tool
|
||||
async def nc_cookbook_import_recipe(url: str, ctx: Context) -> ImportRecipeResponse:
|
||||
@@ -129,7 +132,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="List Recipes",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("cookbook:read")
|
||||
@instrument_tool
|
||||
async def nc_cookbook_list_recipes(ctx: Context) -> ListRecipesResponse:
|
||||
@@ -155,7 +161,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Get Recipe",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("cookbook:read")
|
||||
@instrument_tool
|
||||
async def nc_cookbook_get_recipe(recipe_id: int, ctx: Context) -> Recipe:
|
||||
@@ -181,7 +190,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Create Recipe",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("cookbook:write")
|
||||
@instrument_tool
|
||||
async def nc_cookbook_create_recipe(
|
||||
@@ -261,7 +273,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Update Recipe",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("cookbook:write")
|
||||
@instrument_tool
|
||||
async def nc_cookbook_update_recipe(
|
||||
@@ -351,7 +366,12 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Delete Recipe",
|
||||
annotations=ToolAnnotations(
|
||||
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||
),
|
||||
)
|
||||
@require_scopes("cookbook:write")
|
||||
@instrument_tool
|
||||
async def nc_cookbook_delete_recipe(
|
||||
@@ -387,7 +407,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Search Recipes",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("cookbook:read")
|
||||
@instrument_tool
|
||||
async def nc_cookbook_search_recipes(
|
||||
@@ -424,7 +447,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="List Recipe Categories",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("cookbook:read")
|
||||
@instrument_tool
|
||||
async def nc_cookbook_list_categories(ctx: Context) -> ListCategoriesResponse:
|
||||
@@ -452,7 +478,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Get Recipes in Category",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("cookbook:read")
|
||||
@instrument_tool
|
||||
async def nc_cookbook_get_recipes_in_category(
|
||||
@@ -489,7 +518,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="List Recipe Keywords",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("cookbook:read")
|
||||
@instrument_tool
|
||||
async def nc_cookbook_list_keywords(ctx: Context) -> ListKeywordsResponse:
|
||||
@@ -515,7 +547,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Get Recipes with Keywords",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("cookbook:read")
|
||||
@instrument_tool
|
||||
async def nc_cookbook_get_recipes_with_keywords(
|
||||
@@ -550,7 +585,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Set Cookbook Configuration",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("cookbook:write")
|
||||
@instrument_tool
|
||||
async def nc_cookbook_set_config(
|
||||
@@ -594,7 +632,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Reindex Recipes",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("cookbook:write")
|
||||
@instrument_tool
|
||||
async def nc_cookbook_reindex(ctx: Context) -> ReindexResponse:
|
||||
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
from typing import Optional
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.types import ToolAnnotations
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
@@ -117,7 +118,10 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
|
||||
# Read Tools (converted from resources)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="List Deck Boards",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("deck:read")
|
||||
@instrument_tool
|
||||
async def deck_get_boards(ctx: Context) -> list[DeckBoard]:
|
||||
@@ -126,7 +130,10 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
boards = await client.deck.get_boards()
|
||||
return boards
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Get Deck Board",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("deck:read")
|
||||
@instrument_tool
|
||||
async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard:
|
||||
@@ -135,7 +142,10 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
board = await client.deck.get_board(board_id)
|
||||
return board
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="List Deck Stacks",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("deck:read")
|
||||
@instrument_tool
|
||||
async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]:
|
||||
@@ -144,7 +154,10 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
stacks = await client.deck.get_stacks(board_id)
|
||||
return stacks
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Get Deck Stack",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("deck:read")
|
||||
@instrument_tool
|
||||
async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack:
|
||||
@@ -153,7 +166,10 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
stack = await client.deck.get_stack(board_id, stack_id)
|
||||
return stack
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="List Deck Cards",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("deck:read")
|
||||
@instrument_tool
|
||||
async def deck_get_cards(
|
||||
@@ -166,7 +182,10 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
return stack.cards
|
||||
return []
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Get Deck Card",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("deck:read")
|
||||
@instrument_tool
|
||||
async def deck_get_card(
|
||||
@@ -177,7 +196,10 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
card = await client.deck.get_card(board_id, stack_id, card_id)
|
||||
return card
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="List Deck Labels",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("deck:read")
|
||||
@instrument_tool
|
||||
async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]:
|
||||
@@ -186,7 +208,10 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
board = await client.deck.get_board(board_id)
|
||||
return board.labels
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Get Deck Label",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("deck:read")
|
||||
@instrument_tool
|
||||
async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel:
|
||||
@@ -197,7 +222,10 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
|
||||
# Create/Update/Delete Tools
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Create Deck Board",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("deck:write")
|
||||
@instrument_tool
|
||||
async def deck_create_board(
|
||||
@@ -215,7 +243,10 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
|
||||
# Stack Tools
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Create Deck Stack",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("deck:write")
|
||||
@instrument_tool
|
||||
async def deck_create_stack(
|
||||
@@ -232,7 +263,10 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
stack = await client.deck.create_stack(board_id, title, order)
|
||||
return CreateStackResponse(id=stack.id, title=stack.title, order=stack.order)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Update Deck Stack",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("deck:write")
|
||||
@instrument_tool
|
||||
async def deck_update_stack(
|
||||
@@ -259,7 +293,12 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
board_id=board_id,
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Delete Deck Stack",
|
||||
annotations=ToolAnnotations(
|
||||
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||
),
|
||||
)
|
||||
@require_scopes("deck:write")
|
||||
@instrument_tool
|
||||
async def deck_delete_stack(
|
||||
@@ -281,7 +320,10 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
# Card Tools
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Create Deck Card",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("deck:write")
|
||||
@instrument_tool
|
||||
async def deck_create_card(
|
||||
@@ -316,7 +358,10 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
stackId=card.stackId,
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Update Deck Card",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("deck:write")
|
||||
@instrument_tool
|
||||
async def deck_update_card(
|
||||
@@ -370,7 +415,12 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
board_id=board_id,
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Delete Deck Card",
|
||||
annotations=ToolAnnotations(
|
||||
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||
),
|
||||
)
|
||||
@require_scopes("deck:write")
|
||||
@instrument_tool
|
||||
async def deck_delete_card(
|
||||
@@ -393,7 +443,10 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
board_id=board_id,
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Archive Deck Card",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("deck:write")
|
||||
@instrument_tool
|
||||
async def deck_archive_card(
|
||||
@@ -416,7 +469,10 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
board_id=board_id,
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Unarchive Deck Card",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("deck:write")
|
||||
@instrument_tool
|
||||
async def deck_unarchive_card(
|
||||
@@ -439,7 +495,10 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
board_id=board_id,
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Reorder/Move Deck Card",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("deck:write")
|
||||
@instrument_tool
|
||||
async def deck_reorder_card(
|
||||
@@ -472,7 +531,10 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
# Label Tools
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Create Deck Label",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("deck:write")
|
||||
@instrument_tool
|
||||
async def deck_create_label(
|
||||
@@ -489,7 +551,10 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
label = await client.deck.create_label(board_id, title, color)
|
||||
return CreateLabelResponse(id=label.id, title=label.title, color=label.color)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Update Deck Label",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("deck:write")
|
||||
@instrument_tool
|
||||
async def deck_update_label(
|
||||
@@ -516,7 +581,12 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
board_id=board_id,
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Delete Deck Label",
|
||||
annotations=ToolAnnotations(
|
||||
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||
),
|
||||
)
|
||||
@require_scopes("deck:write")
|
||||
@instrument_tool
|
||||
async def deck_delete_label(
|
||||
@@ -538,7 +608,10 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
# Card-Label Assignment Tools
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Assign Label to Deck Card",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("deck:write")
|
||||
@instrument_tool
|
||||
async def deck_assign_label_to_card(
|
||||
@@ -562,7 +635,10 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
board_id=board_id,
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Remove Label from Deck Card",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("deck:write")
|
||||
@instrument_tool
|
||||
async def deck_remove_label_from_card(
|
||||
@@ -587,7 +663,10 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
# Card-User Assignment Tools
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Assign User to Deck Card",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("deck:write")
|
||||
@instrument_tool
|
||||
async def deck_assign_user_to_card(
|
||||
@@ -611,7 +690,10 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
board_id=board_id,
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Unassign User from Deck Card",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("deck:write")
|
||||
@instrument_tool
|
||||
async def deck_unassign_user_from_card(
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
"""MCP tools for Nextcloud News app."""
|
||||
|
||||
import logging
|
||||
|
||||
from httpx import HTTPStatusError, RequestError
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.types import ErrorData, ToolAnnotations
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.client.news import NewsItemType
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
from nextcloud_mcp_server.models.news import (
|
||||
FeedHealthResponse,
|
||||
GetItemResponse,
|
||||
GetStatusResponse,
|
||||
ListFeedsResponse,
|
||||
ListFoldersResponse,
|
||||
ListItemsResponse,
|
||||
NewsFeed,
|
||||
NewsFolder,
|
||||
NewsItem,
|
||||
NewsItemSummary,
|
||||
)
|
||||
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def configure_news_tools(mcp: FastMCP):
|
||||
"""Configure News app MCP tools."""
|
||||
|
||||
@mcp.tool(
|
||||
title="List News Folders",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("news:read")
|
||||
@instrument_tool
|
||||
async def nc_news_list_folders(ctx: Context) -> ListFoldersResponse:
|
||||
"""List all News folders (requires news:read scope)."""
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
folders_data = await client.news.get_folders()
|
||||
folders = [NewsFolder(**f) for f in folders_data]
|
||||
return ListFoldersResponse(results=folders, total_count=len(folders))
|
||||
except RequestError as e:
|
||||
raise McpError(
|
||||
ErrorData(code=-1, message=f"Network error listing folders: {str(e)}")
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to list folders: {e.response.status_code}",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool(
|
||||
title="List News Feeds",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("news:read")
|
||||
@instrument_tool
|
||||
async def nc_news_list_feeds(ctx: Context) -> ListFeedsResponse:
|
||||
"""List all News feeds with metadata (requires news:read scope).
|
||||
|
||||
Returns feeds with unread counts, error status, and overall starred count.
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
data = await client.news.get_feeds()
|
||||
feeds = [NewsFeed(**f) for f in data.get("feeds", [])]
|
||||
return ListFeedsResponse(
|
||||
results=feeds,
|
||||
starred_count=data.get("starredCount", 0),
|
||||
newest_item_id=data.get("newestItemId"),
|
||||
total_count=len(feeds),
|
||||
)
|
||||
except RequestError as e:
|
||||
raise McpError(
|
||||
ErrorData(code=-1, message=f"Network error listing feeds: {str(e)}")
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to list feeds: {e.response.status_code}",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool(
|
||||
title="List News Items",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("news:read")
|
||||
@instrument_tool
|
||||
async def nc_news_list_items(
|
||||
ctx: Context,
|
||||
feed_id: int | None = None,
|
||||
folder_id: int | None = None,
|
||||
starred_only: bool = False,
|
||||
unread_only: bool = False,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> ListItemsResponse:
|
||||
"""List News items (articles) with optional filtering (requires news:read scope).
|
||||
|
||||
Args:
|
||||
feed_id: Filter by specific feed ID
|
||||
folder_id: Filter by specific folder ID
|
||||
starred_only: Return only starred items
|
||||
unread_only: Return only unread items
|
||||
limit: Maximum number of items to return (default 50, -1 for all)
|
||||
offset: Item ID to start after (for pagination)
|
||||
|
||||
Returns:
|
||||
ListItemsResponse with items, count, and pagination info
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
|
||||
# Determine item type filter
|
||||
type_ = NewsItemType.ALL
|
||||
id_ = 0
|
||||
if starred_only:
|
||||
type_ = NewsItemType.STARRED
|
||||
elif feed_id is not None:
|
||||
type_ = NewsItemType.FEED
|
||||
id_ = feed_id
|
||||
elif folder_id is not None:
|
||||
type_ = NewsItemType.FOLDER
|
||||
id_ = folder_id
|
||||
|
||||
try:
|
||||
items_data = await client.news.get_items(
|
||||
batch_size=limit,
|
||||
offset=offset,
|
||||
type_=type_,
|
||||
id_=id_,
|
||||
get_read=not unread_only,
|
||||
)
|
||||
items = [NewsItemSummary(**i) for i in items_data]
|
||||
|
||||
# Determine pagination info
|
||||
oldest_id = min((i.id for i in items), default=None) if items else None
|
||||
has_more = len(items) == limit and limit > 0
|
||||
|
||||
return ListItemsResponse(
|
||||
results=items,
|
||||
total_count=len(items),
|
||||
has_more=has_more,
|
||||
oldest_id=oldest_id,
|
||||
)
|
||||
except RequestError as e:
|
||||
raise McpError(
|
||||
ErrorData(code=-1, message=f"Network error listing items: {str(e)}")
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to list items: {e.response.status_code}",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool(
|
||||
title="Get News Item",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("news:read")
|
||||
@instrument_tool
|
||||
async def nc_news_get_item(item_id: int, ctx: Context) -> GetItemResponse:
|
||||
"""Get a specific News item by ID with full content (requires news:read scope).
|
||||
|
||||
Args:
|
||||
item_id: Item ID
|
||||
|
||||
Returns:
|
||||
GetItemResponse with full item details including HTML body
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
item_data = await client.news.get_item(item_id)
|
||||
item = NewsItem(**item_data)
|
||||
return GetItemResponse(item=item)
|
||||
except ValueError as e:
|
||||
raise McpError(ErrorData(code=-1, message=str(e)))
|
||||
except RequestError as e:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1, message=f"Network error getting item {item_id}: {str(e)}"
|
||||
)
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
raise McpError(ErrorData(code=-1, message=f"Item {item_id} not found"))
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to get item {item_id}: {e.response.status_code}",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool(
|
||||
title="Get Starred News Items",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("news:read")
|
||||
@instrument_tool
|
||||
async def nc_news_get_starred_items(
|
||||
ctx: Context, limit: int = 50, offset: int = 0
|
||||
) -> ListItemsResponse:
|
||||
"""Get starred (favorited) News items (requires news:read scope).
|
||||
|
||||
Convenience method for retrieving user's starred articles.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of items to return (default 50, -1 for all)
|
||||
offset: Item ID to start after (for pagination)
|
||||
|
||||
Returns:
|
||||
ListItemsResponse with starred items
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
items_data = await client.news.get_items(
|
||||
batch_size=limit,
|
||||
offset=offset,
|
||||
type_=NewsItemType.STARRED,
|
||||
get_read=True, # Include read starred items
|
||||
)
|
||||
items = [NewsItemSummary(**i) for i in items_data]
|
||||
|
||||
oldest_id = min((i.id for i in items), default=None) if items else None
|
||||
has_more = len(items) == limit and limit > 0
|
||||
|
||||
return ListItemsResponse(
|
||||
results=items,
|
||||
total_count=len(items),
|
||||
has_more=has_more,
|
||||
oldest_id=oldest_id,
|
||||
)
|
||||
except RequestError as e:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1, message=f"Network error getting starred items: {str(e)}"
|
||||
)
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to get starred items: {e.response.status_code}",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool(
|
||||
title="Get Unread News Items",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("news:read")
|
||||
@instrument_tool
|
||||
async def nc_news_get_unread_items(
|
||||
ctx: Context, limit: int = 50, offset: int = 0
|
||||
) -> ListItemsResponse:
|
||||
"""Get unread News items (requires news:read scope).
|
||||
|
||||
Convenience method for retrieving unread articles across all feeds.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of items to return (default 50, -1 for all)
|
||||
offset: Item ID to start after (for pagination)
|
||||
|
||||
Returns:
|
||||
ListItemsResponse with unread items
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
items_data = await client.news.get_items(
|
||||
batch_size=limit,
|
||||
offset=offset,
|
||||
type_=NewsItemType.ALL,
|
||||
get_read=False, # Only unread items
|
||||
)
|
||||
items = [NewsItemSummary(**i) for i in items_data]
|
||||
|
||||
oldest_id = min((i.id for i in items), default=None) if items else None
|
||||
has_more = len(items) == limit and limit > 0
|
||||
|
||||
return ListItemsResponse(
|
||||
results=items,
|
||||
total_count=len(items),
|
||||
has_more=has_more,
|
||||
oldest_id=oldest_id,
|
||||
)
|
||||
except RequestError as e:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1, message=f"Network error getting unread items: {str(e)}"
|
||||
)
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to get unread items: {e.response.status_code}",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool(
|
||||
title="Get News Feed Health",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("news:read")
|
||||
@instrument_tool
|
||||
async def nc_news_get_feed_health(feed_id: int, ctx: Context) -> FeedHealthResponse:
|
||||
"""Get health status for a specific feed (requires news:read scope).
|
||||
|
||||
Returns error count and last error message if the feed has update issues.
|
||||
|
||||
Args:
|
||||
feed_id: Feed ID to check
|
||||
|
||||
Returns:
|
||||
FeedHealthResponse with error status
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
data = await client.news.get_feeds()
|
||||
for feed_data in data.get("feeds", []):
|
||||
if feed_data.get("id") == feed_id:
|
||||
feed = NewsFeed(**feed_data)
|
||||
return FeedHealthResponse(
|
||||
feed_id=feed.id,
|
||||
title=feed.title,
|
||||
url=feed.url,
|
||||
has_errors=feed.has_errors,
|
||||
error_count=feed.update_error_count,
|
||||
last_error=feed.last_update_error,
|
||||
)
|
||||
raise McpError(ErrorData(code=-1, message=f"Feed {feed_id} not found"))
|
||||
except RequestError as e:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Network error getting feed health: {str(e)}",
|
||||
)
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to get feed health: {e.response.status_code}",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool(
|
||||
title="Get News App Status",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("news:read")
|
||||
@instrument_tool
|
||||
async def nc_news_get_status(ctx: Context) -> GetStatusResponse:
|
||||
"""Get News app status and version (requires news:read scope).
|
||||
|
||||
Returns version information and any configuration warnings.
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
status_data = await client.news.get_status()
|
||||
return GetStatusResponse(
|
||||
version=status_data.get("version", "unknown"),
|
||||
warnings=status_data.get("warnings", {}),
|
||||
)
|
||||
except RequestError as e:
|
||||
raise McpError(
|
||||
ErrorData(code=-1, message=f"Network error getting status: {str(e)}")
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to get status: {e.response.status_code}",
|
||||
)
|
||||
)
|
||||
@@ -3,7 +3,7 @@ import logging
|
||||
from httpx import HTTPStatusError, RequestError
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.types import ErrorData
|
||||
from mcp.types import ErrorData, ToolAnnotations
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
@@ -85,7 +85,13 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Create Note",
|
||||
annotations=ToolAnnotations(
|
||||
idempotentHint=False, # Multiple calls create multiple notes
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
@require_scopes("notes:write")
|
||||
@instrument_tool
|
||||
async def nc_notes_create_note(
|
||||
@@ -132,7 +138,13 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Update Note",
|
||||
annotations=ToolAnnotations(
|
||||
idempotentHint=False, # Requires etag which changes = not idempotent
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
@require_scopes("notes:write")
|
||||
@instrument_tool
|
||||
async def nc_notes_update_note(
|
||||
@@ -198,7 +210,13 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Append to Note",
|
||||
annotations=ToolAnnotations(
|
||||
idempotentHint=False, # Each call adds content = not idempotent
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
@require_scopes("notes:write")
|
||||
@instrument_tool
|
||||
async def nc_notes_append_content(
|
||||
@@ -249,7 +267,13 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Search Notes",
|
||||
annotations=ToolAnnotations(
|
||||
readOnlyHint=True, # Search doesn't modify data
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
@require_scopes("notes:read")
|
||||
@instrument_tool
|
||||
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
|
||||
@@ -296,7 +320,13 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Get Note",
|
||||
annotations=ToolAnnotations(
|
||||
readOnlyHint=True, # Read operation only
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
@require_scopes("notes:read")
|
||||
@instrument_tool
|
||||
async def nc_notes_get_note(note_id: int, ctx: Context) -> Note:
|
||||
@@ -326,7 +356,13 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Get Note Attachment",
|
||||
annotations=ToolAnnotations(
|
||||
readOnlyHint=True, # Read operation only
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
@require_scopes("notes:read")
|
||||
@instrument_tool
|
||||
async def nc_notes_get_attachment(
|
||||
@@ -373,7 +409,14 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Delete Note",
|
||||
annotations=ToolAnnotations(
|
||||
destructiveHint=True, # Permanently deletes data
|
||||
idempotentHint=True, # Deleting deleted note = same end state
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
@require_scopes("notes:write")
|
||||
@instrument_tool
|
||||
async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse:
|
||||
|
||||
@@ -15,6 +15,7 @@ import httpx
|
||||
from mcp.server.auth.middleware.auth_context import get_access_token
|
||||
from mcp.server.auth.provider import AccessToken
|
||||
from mcp.server.fastmcp import Context
|
||||
from mcp.types import ToolAnnotations
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
@@ -684,11 +685,16 @@ def register_oauth_tools(mcp):
|
||||
|
||||
@mcp.tool(
|
||||
name="provision_nextcloud_access",
|
||||
title="Grant Server Access to Nextcloud",
|
||||
description=(
|
||||
"Provision offline access to Nextcloud resources. "
|
||||
"This is required before using Nextcloud tools. "
|
||||
"You'll need to complete an OAuth authorization in your browser."
|
||||
),
|
||||
annotations=ToolAnnotations(
|
||||
idempotentHint=False, # Creates new OAuth session each time
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
@require_scopes("openid")
|
||||
async def tool_provision_access(
|
||||
@@ -699,7 +705,13 @@ def register_oauth_tools(mcp):
|
||||
|
||||
@mcp.tool(
|
||||
name="revoke_nextcloud_access",
|
||||
title="Revoke Server Access to Nextcloud",
|
||||
description="Revoke offline access to Nextcloud resources.",
|
||||
annotations=ToolAnnotations(
|
||||
destructiveHint=True, # Removes stored access tokens
|
||||
idempotentHint=True, # Revoking revoked access = same end state
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
@require_scopes("openid")
|
||||
async def tool_revoke_access(
|
||||
@@ -709,7 +721,12 @@ def register_oauth_tools(mcp):
|
||||
|
||||
@mcp.tool(
|
||||
name="check_provisioning_status",
|
||||
title="Check Provisioning Status",
|
||||
description="Check whether Nextcloud access is provisioned.",
|
||||
annotations=ToolAnnotations(
|
||||
readOnlyHint=True, # Only checks status, doesn't modify
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
@require_scopes("openid")
|
||||
async def tool_check_status(
|
||||
@@ -719,10 +736,15 @@ def register_oauth_tools(mcp):
|
||||
|
||||
@mcp.tool(
|
||||
name="check_logged_in",
|
||||
title="Check Server Login Status",
|
||||
description=(
|
||||
"Check if you are logged in to Nextcloud. "
|
||||
"If not logged in, this tool will prompt you to complete the login flow."
|
||||
),
|
||||
annotations=ToolAnnotations(
|
||||
readOnlyHint=True, # Checking status doesn't modify state
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
@require_scopes("openid")
|
||||
async def tool_check_logged_in(ctx: Context, user_id: Optional[str] = None) -> str:
|
||||
|
||||
@@ -12,6 +12,7 @@ from mcp.types import (
|
||||
ModelPreferences,
|
||||
SamplingMessage,
|
||||
TextContent,
|
||||
ToolAnnotations,
|
||||
)
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
@@ -26,6 +27,7 @@ from nextcloud_mcp_server.observability.metrics import (
|
||||
instrument_tool,
|
||||
)
|
||||
from nextcloud_mcp_server.search.bm25_hybrid import BM25HybridSearchAlgorithm
|
||||
from nextcloud_mcp_server.search.context import get_chunk_with_context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -33,7 +35,13 @@ logger = logging.getLogger(__name__)
|
||||
def configure_semantic_tools(mcp: FastMCP):
|
||||
"""Configure semantic search tools for MCP server."""
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Semantic Search",
|
||||
annotations=ToolAnnotations(
|
||||
readOnlyHint=True, # Search doesn't modify data
|
||||
openWorldHint=True, # Queries external Nextcloud service
|
||||
),
|
||||
)
|
||||
@require_scopes("semantic:read")
|
||||
@instrument_tool
|
||||
async def nc_semantic_search(
|
||||
@@ -43,6 +51,8 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
doc_types: list[str] | None = None,
|
||||
score_threshold: float = 0.0,
|
||||
fusion: str = "rrf",
|
||||
include_context: bool = False,
|
||||
context_chars: int = 300,
|
||||
) -> SemanticSearchResponse:
|
||||
"""
|
||||
Search Nextcloud content using BM25 hybrid search with cross-app support.
|
||||
@@ -55,17 +65,19 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
database for optimal relevance. This provides the best of both semantic
|
||||
understanding and keyword precision.
|
||||
|
||||
Requires VECTOR_SYNC_ENABLED=true. Currently only "note" documents are
|
||||
fully supported for indexing.
|
||||
Requires VECTOR_SYNC_ENABLED=true. Supports indexing of notes, files,
|
||||
news items, and deck cards.
|
||||
|
||||
Args:
|
||||
query: Natural language or keyword search query
|
||||
limit: Maximum number of results to return (default: 10)
|
||||
doc_types: Document types to search (e.g., ["note", "file"]). None = search all indexed types (default)
|
||||
doc_types: Document types to search (e.g., ["note", "file", "deck_card", "news_item"]). None = search all indexed types (default)
|
||||
score_threshold: Minimum fusion score (0-1, default: 0.0)
|
||||
fusion: Fusion algorithm: "rrf" (Reciprocal Rank Fusion, default) or "dbsf" (Distribution-Based Score Fusion)
|
||||
RRF: Good general-purpose fusion using reciprocal ranks
|
||||
DBSF: Uses distribution-based normalization, may better balance different score ranges
|
||||
include_context: Whether to expand results with surrounding context (default: False)
|
||||
context_chars: Number of characters to include before/after matched chunk (default: 300)
|
||||
|
||||
Returns:
|
||||
SemanticSearchResponse with matching documents ranked by fusion scores
|
||||
@@ -128,18 +140,16 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
# Sort combined results by score
|
||||
all_results.sort(key=lambda r: r.score, reverse=True)
|
||||
|
||||
# Deduplicate results (hybrid search may return same doc from dense + sparse)
|
||||
# Qdrant already filters by user_id for multi-tenant isolation
|
||||
# Sampling tool will verify access when fetching full content
|
||||
seen = set()
|
||||
unique_results = []
|
||||
for result in all_results:
|
||||
key = (result.id, result.doc_type)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique_results.append(result)
|
||||
|
||||
search_results = unique_results[:limit] # Final limit after deduplication
|
||||
# Note: BM25HybridSearchAlgorithm already deduplicates at chunk level
|
||||
# (doc_id, doc_type, chunk_start, chunk_end), which allows multiple
|
||||
# chunks from the same document while preventing duplicate chunks.
|
||||
# No additional deduplication needed here - multiple chunks per document
|
||||
# are valuable for RAG contexts.
|
||||
# Qdrant already filters by user_id for multi-tenant isolation.
|
||||
# Sampling tool will verify access when fetching full content.
|
||||
search_results = all_results[
|
||||
:limit
|
||||
] # Final limit after chunk-level dedup in algorithm
|
||||
|
||||
# Convert SearchResult objects to SemanticSearchResult for response
|
||||
results = []
|
||||
@@ -160,9 +170,99 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
else 1,
|
||||
chunk_start_offset=r.chunk_start_offset,
|
||||
chunk_end_offset=r.chunk_end_offset,
|
||||
page_number=r.page_number,
|
||||
)
|
||||
)
|
||||
|
||||
# Expand results with surrounding context if requested
|
||||
if include_context and results:
|
||||
logger.info(
|
||||
f"Expanding {len(results)} results with context "
|
||||
f"(context_chars={context_chars})"
|
||||
)
|
||||
|
||||
# Fetch context for all results in parallel
|
||||
# Limit concurrent requests to prevent connection pool exhaustion
|
||||
max_concurrent = 20
|
||||
semaphore = anyio.Semaphore(max_concurrent)
|
||||
expanded_results = [None] * len(results)
|
||||
|
||||
async def fetch_context(index: int, result: SemanticSearchResult):
|
||||
"""Fetch context for a single result (parallel with semaphore)."""
|
||||
async with semaphore:
|
||||
# Only expand if we have valid chunk offsets
|
||||
if (
|
||||
result.chunk_start_offset is None
|
||||
or result.chunk_end_offset is None
|
||||
):
|
||||
# Keep result as-is without context expansion
|
||||
expanded_results[index] = result
|
||||
return
|
||||
|
||||
try:
|
||||
chunk_context = await get_chunk_with_context(
|
||||
nc_client=client,
|
||||
user_id=username,
|
||||
doc_id=result.id,
|
||||
doc_type=result.doc_type,
|
||||
chunk_start=result.chunk_start_offset,
|
||||
chunk_end=result.chunk_end_offset,
|
||||
page_number=result.page_number,
|
||||
chunk_index=result.chunk_index,
|
||||
total_chunks=result.total_chunks,
|
||||
context_chars=context_chars,
|
||||
)
|
||||
|
||||
if chunk_context:
|
||||
# Create new result with context fields populated
|
||||
expanded_results[index] = SemanticSearchResult(
|
||||
id=result.id,
|
||||
doc_type=result.doc_type,
|
||||
title=result.title,
|
||||
category=result.category,
|
||||
excerpt=result.excerpt,
|
||||
score=result.score,
|
||||
chunk_index=result.chunk_index,
|
||||
total_chunks=result.total_chunks,
|
||||
chunk_start_offset=result.chunk_start_offset,
|
||||
chunk_end_offset=result.chunk_end_offset,
|
||||
page_number=result.page_number,
|
||||
# Context expansion fields
|
||||
has_context_expansion=True,
|
||||
marked_text=chunk_context.marked_text,
|
||||
before_context=chunk_context.before_context,
|
||||
after_context=chunk_context.after_context,
|
||||
has_before_truncation=chunk_context.has_before_truncation,
|
||||
has_after_truncation=chunk_context.has_after_truncation,
|
||||
)
|
||||
logger.debug(
|
||||
f"Expanded context for {result.doc_type} {result.id}"
|
||||
)
|
||||
else:
|
||||
# Context expansion failed, keep original result
|
||||
expanded_results[index] = result
|
||||
logger.debug(
|
||||
f"Failed to expand context for {result.doc_type} {result.id}, "
|
||||
"keeping original result"
|
||||
)
|
||||
except Exception as e:
|
||||
# Context expansion failed, keep original result
|
||||
expanded_results[index] = result
|
||||
logger.warning(
|
||||
f"Error expanding context for {result.doc_type} {result.id}: {e}"
|
||||
)
|
||||
|
||||
# Run all context fetches in parallel using anyio task group
|
||||
async with anyio.create_task_group() as tg:
|
||||
for idx, result in enumerate(results):
|
||||
tg.start_soon(fetch_context, idx, result)
|
||||
|
||||
# Replace results with expanded versions
|
||||
results = [r for r in expanded_results if r is not None]
|
||||
logger.info(
|
||||
f"Context expansion completed: {len(results)} results with context"
|
||||
)
|
||||
|
||||
logger.info(f"Returning {len(results)} results from BM25 hybrid search")
|
||||
|
||||
return SemanticSearchResponse(
|
||||
@@ -192,7 +292,13 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
logger.error(f"Search error: {e}", exc_info=True)
|
||||
raise McpError(ErrorData(code=-1, message=f"Search failed: {str(e)}"))
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Search with AI-Generated Answer",
|
||||
annotations=ToolAnnotations(
|
||||
readOnlyHint=True, # Search doesn't modify data
|
||||
openWorldHint=False, # Searches only indexed Nextcloud data
|
||||
),
|
||||
)
|
||||
@require_scopes("semantic:read")
|
||||
@instrument_tool
|
||||
async def nc_semantic_search_answer(
|
||||
@@ -202,6 +308,8 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
score_threshold: float = 0.7,
|
||||
max_answer_tokens: int = 500,
|
||||
fusion: str = "rrf",
|
||||
include_context: bool = False,
|
||||
context_chars: int = 300,
|
||||
) -> SamplingSearchResponse:
|
||||
"""
|
||||
Semantic search with LLM-generated answer using MCP sampling.
|
||||
@@ -227,6 +335,8 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
score_threshold: Minimum similarity score 0-1 (default: 0.7)
|
||||
max_answer_tokens: Maximum tokens for generated answer (default: 500)
|
||||
fusion: Fusion algorithm: "rrf" (Reciprocal Rank Fusion, default) or "dbsf" (Distribution-Based Score Fusion)
|
||||
include_context: Whether to expand results with surrounding context (default: False)
|
||||
context_chars: Number of characters to include before/after matched chunk (default: 300)
|
||||
|
||||
Returns:
|
||||
SamplingSearchResponse containing:
|
||||
@@ -238,27 +348,6 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
Note: Requires MCP client to support sampling. If sampling is unavailable,
|
||||
the tool gracefully degrades to returning documents with an explanation.
|
||||
The client may prompt the user to approve the sampling request.
|
||||
|
||||
Examples:
|
||||
>>> # Query about objectives across multiple apps
|
||||
>>> result = await nc_semantic_search_answer(
|
||||
... query="What are my Q1 2025 project goals?",
|
||||
... ctx=ctx
|
||||
... )
|
||||
>>> print(result.generated_answer)
|
||||
"Based on Document 1 (note: Project Kickoff), Document 2 (calendar event:
|
||||
Q1 Planning Meeting), and Document 3 (deck card: Implement semantic search),
|
||||
your main goals are: 1) Improve semantic search accuracy by 20%,
|
||||
2) Deploy new embedding model, 3) Reduce indexing latency..."
|
||||
|
||||
>>> # Query about appointments
|
||||
>>> result = await nc_semantic_search_answer(
|
||||
... query="When is my next dentist appointment?",
|
||||
... ctx=ctx,
|
||||
... limit=10
|
||||
... )
|
||||
>>> len(result.sources) # Calendar events and related notes
|
||||
3
|
||||
"""
|
||||
# 1. Retrieve relevant documents via existing semantic search
|
||||
search_response = await nc_semantic_search(
|
||||
@@ -267,6 +356,8 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
limit=limit,
|
||||
score_threshold=score_threshold,
|
||||
fusion=fusion,
|
||||
include_context=include_context,
|
||||
context_chars=context_chars,
|
||||
)
|
||||
|
||||
# 2. Handle no results case - don't waste a sampling call
|
||||
@@ -421,9 +512,11 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
# 6. Request LLM completion via MCP sampling with timeout
|
||||
# Note: 5 minute timeout to accommodate slower local LLMs (e.g., Ollama)
|
||||
sampling_timeout_seconds = 300
|
||||
|
||||
try:
|
||||
with anyio.fail_after(30):
|
||||
with anyio.fail_after(sampling_timeout_seconds):
|
||||
sampling_result = await ctx.session.create_message(
|
||||
messages=[
|
||||
SamplingMessage(
|
||||
@@ -470,14 +563,14 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
f"Sampling request timed out after 30 seconds for query: '{query}', "
|
||||
f"Sampling request timed out after {sampling_timeout_seconds} seconds for query: '{query}', "
|
||||
f"returning search results only"
|
||||
)
|
||||
return SamplingSearchResponse(
|
||||
query=query,
|
||||
generated_answer=(
|
||||
f"[Sampling request timed out]\n\n"
|
||||
f"The answer generation took too long (>30s). "
|
||||
f"The answer generation took too long (>{sampling_timeout_seconds}s). "
|
||||
f"Found {len(accessible_results)} relevant documents. "
|
||||
f"Please review the sources below or try a simpler query."
|
||||
),
|
||||
@@ -543,7 +636,13 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
success=True,
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Check Indexing Status",
|
||||
annotations=ToolAnnotations(
|
||||
readOnlyHint=True, # Only checks status
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
@require_scopes("semantic:read")
|
||||
@instrument_tool
|
||||
async def nc_get_vector_sync_status(ctx: Context) -> VectorSyncStatusResponse:
|
||||
@@ -597,15 +696,22 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
# Get Qdrant client and query indexed count
|
||||
indexed_count = 0
|
||||
try:
|
||||
from qdrant_client.models import Filter
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.placeholder import (
|
||||
get_placeholder_filter,
|
||||
)
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
settings = get_settings()
|
||||
qdrant_client = await get_qdrant_client()
|
||||
|
||||
# Count documents in collection
|
||||
# Count documents in collection, excluding placeholders
|
||||
# Placeholders are zero-vector points used to track processing state
|
||||
count_result = await qdrant_client.count(
|
||||
collection_name=settings.get_collection_name()
|
||||
collection_name=settings.get_collection_name(),
|
||||
count_filter=Filter(must=[get_placeholder_filter()]),
|
||||
)
|
||||
indexed_count = count_result.count
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import json
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.types import ToolAnnotations
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
@@ -16,7 +17,10 @@ def configure_sharing_tools(mcp: FastMCP):
|
||||
mcp: FastMCP server instance
|
||||
"""
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Create Share",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("sharing:write")
|
||||
@instrument_tool
|
||||
async def nc_share_create(
|
||||
@@ -56,7 +60,12 @@ def configure_sharing_tools(mcp: FastMCP):
|
||||
)
|
||||
return json.dumps(share_data, indent=2)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Delete Share",
|
||||
annotations=ToolAnnotations(
|
||||
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||
),
|
||||
)
|
||||
@require_scopes("sharing:write")
|
||||
@instrument_tool
|
||||
async def nc_share_delete(share_id: int, ctx: Context) -> str:
|
||||
@@ -76,7 +85,10 @@ def configure_sharing_tools(mcp: FastMCP):
|
||||
{"success": True, "message": f"Share {share_id} deleted"}, indent=2
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Get Share Details",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("sharing:write")
|
||||
@instrument_tool
|
||||
async def nc_share_get(share_id: int, ctx: Context) -> str:
|
||||
@@ -95,7 +107,10 @@ def configure_sharing_tools(mcp: FastMCP):
|
||||
share_data = await client.sharing.get_share(share_id)
|
||||
return json.dumps(share_data, indent=2)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="List Shares",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("sharing:write")
|
||||
@instrument_tool
|
||||
async def nc_share_list(
|
||||
@@ -117,7 +132,10 @@ def configure_sharing_tools(mcp: FastMCP):
|
||||
)
|
||||
return json.dumps(shares, indent=2)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Update Share",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("sharing:write")
|
||||
@instrument_tool
|
||||
async def nc_share_update(share_id: int, permissions: int, ctx: Context) -> str:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.types import ToolAnnotations
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
@@ -11,7 +12,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def configure_tables_tools(mcp: FastMCP):
|
||||
# Tables tools
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="List Tables",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("tables:read")
|
||||
@instrument_tool
|
||||
async def nc_tables_list_tables(ctx: Context):
|
||||
@@ -19,7 +23,10 @@ def configure_tables_tools(mcp: FastMCP):
|
||||
client = await get_client(ctx)
|
||||
return await client.tables.list_tables()
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Get Table Schema",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("tables:read")
|
||||
@instrument_tool
|
||||
async def nc_tables_get_schema(table_id: int, ctx: Context):
|
||||
@@ -27,7 +34,10 @@ def configure_tables_tools(mcp: FastMCP):
|
||||
client = await get_client(ctx)
|
||||
return await client.tables.get_table_schema(table_id)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Read Table Rows",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("tables:read")
|
||||
@instrument_tool
|
||||
async def nc_tables_read_table(
|
||||
@@ -40,7 +50,10 @@ def configure_tables_tools(mcp: FastMCP):
|
||||
client = await get_client(ctx)
|
||||
return await client.tables.get_table_rows(table_id, limit, offset)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Insert Table Row",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("tables:write")
|
||||
@instrument_tool
|
||||
async def nc_tables_insert_row(table_id: int, data: dict, ctx: Context):
|
||||
@@ -51,7 +64,10 @@ def configure_tables_tools(mcp: FastMCP):
|
||||
client = await get_client(ctx)
|
||||
return await client.tables.create_row(table_id, data)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Update Table Row",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("tables:write")
|
||||
@instrument_tool
|
||||
async def nc_tables_update_row(row_id: int, data: dict, ctx: Context):
|
||||
@@ -62,7 +78,12 @@ def configure_tables_tools(mcp: FastMCP):
|
||||
client = await get_client(ctx)
|
||||
return await client.tables.update_row(row_id, data)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Delete Table Row",
|
||||
annotations=ToolAnnotations(
|
||||
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||
),
|
||||
)
|
||||
@require_scopes("tables:write")
|
||||
@instrument_tool
|
||||
async def nc_tables_delete_row(row_id: int, ctx: Context):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.types import ToolAnnotations
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
@@ -16,7 +17,13 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def configure_webdav_tools(mcp: FastMCP):
|
||||
# WebDAV file system tools
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="List Files and Directories",
|
||||
annotations=ToolAnnotations(
|
||||
readOnlyHint=True,
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
@require_scopes("files:read")
|
||||
@instrument_tool
|
||||
async def nc_webdav_list_directory(
|
||||
@@ -50,7 +57,13 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
total_size=total_size,
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Read File",
|
||||
annotations=ToolAnnotations(
|
||||
readOnlyHint=True,
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
@require_scopes("files:read")
|
||||
@instrument_tool
|
||||
async def nc_webdav_read_file(path: str, ctx: Context):
|
||||
@@ -64,20 +77,6 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
- Text files are decoded to UTF-8
|
||||
- Documents (PDF, DOCX, etc.) are parsed and text is extracted
|
||||
- Other binary files are base64 encoded
|
||||
|
||||
Examples:
|
||||
# Read a text file
|
||||
result = await nc_webdav_read_file("Documents/readme.txt")
|
||||
logger.info(result['content']) # Decoded text content
|
||||
|
||||
# Read a PDF document (automatically parsed)
|
||||
result = await nc_webdav_read_file("Documents/report.pdf")
|
||||
logger.info(result['content']) # Extracted text from PDF
|
||||
logger.info(result['parsing_metadata']) # Document parsing info
|
||||
|
||||
# Read a binary file
|
||||
result = await nc_webdav_read_file("Images/photo.jpg")
|
||||
logger.info(result['encoding']) # 'base64'
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
content, content_type = await client.webdav.read_file(path)
|
||||
@@ -131,7 +130,13 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
"encoding": "base64",
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Write File",
|
||||
annotations=ToolAnnotations(
|
||||
idempotentHint=True, # HTTP PUT without version control is idempotent
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
@require_scopes("files:write")
|
||||
@instrument_tool
|
||||
async def nc_webdav_write_file(
|
||||
@@ -160,7 +165,13 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
|
||||
return await client.webdav.write_file(path, content_bytes, content_type)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Create Directory",
|
||||
annotations=ToolAnnotations(
|
||||
idempotentHint=True, # Creating existing dir returns 405 = same end state
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
@require_scopes("files:write")
|
||||
@instrument_tool
|
||||
async def nc_webdav_create_directory(path: str, ctx: Context):
|
||||
@@ -175,7 +186,14 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
client = await get_client(ctx)
|
||||
return await client.webdav.create_directory(path)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Delete File or Directory",
|
||||
annotations=ToolAnnotations(
|
||||
destructiveHint=True, # Permanently deletes data
|
||||
idempotentHint=True, # Deleting deleted resource = same end state
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
@require_scopes("files:write")
|
||||
@instrument_tool
|
||||
async def nc_webdav_delete_resource(path: str, ctx: Context):
|
||||
@@ -190,7 +208,13 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
client = await get_client(ctx)
|
||||
return await client.webdav.delete_resource(path)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Move or Rename File",
|
||||
annotations=ToolAnnotations(
|
||||
idempotentHint=False, # Moving changes source and dest
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
@require_scopes("files:write")
|
||||
@instrument_tool
|
||||
async def nc_webdav_move_resource(
|
||||
@@ -211,7 +235,13 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
source_path, destination_path, overwrite
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Copy File or Directory",
|
||||
annotations=ToolAnnotations(
|
||||
idempotentHint=False, # Creates new resource each time
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
@require_scopes("files:write")
|
||||
@instrument_tool
|
||||
async def nc_webdav_copy_resource(
|
||||
@@ -232,7 +262,13 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
source_path, destination_path, overwrite
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Search Files",
|
||||
annotations=ToolAnnotations(
|
||||
readOnlyHint=True,
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
@require_scopes("files:read")
|
||||
@instrument_tool
|
||||
async def nc_webdav_search_files(
|
||||
@@ -349,7 +385,13 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
filters_applied=filters if filters else None,
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Find Files by Name",
|
||||
annotations=ToolAnnotations(
|
||||
readOnlyHint=True,
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
@require_scopes("files:read")
|
||||
@instrument_tool
|
||||
async def nc_webdav_find_by_name(
|
||||
@@ -377,7 +419,13 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
filters_applied={"name_pattern": pattern},
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Find Files by Type",
|
||||
annotations=ToolAnnotations(
|
||||
readOnlyHint=True,
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
@require_scopes("files:read")
|
||||
@instrument_tool
|
||||
async def nc_webdav_find_by_type(
|
||||
@@ -405,7 +453,13 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
filters_applied={"mime_type": mime_type},
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="List Favorite Files",
|
||||
annotations=ToolAnnotations(
|
||||
readOnlyHint=True,
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
@require_scopes("files:read")
|
||||
@instrument_tool
|
||||
async def nc_webdav_list_favorites(
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Smithery-specific entrypoint for stateless deployment.
|
||||
|
||||
ADR-016: This entrypoint is used when deploying on Smithery's hosting platform.
|
||||
It configures the server for stateless operation with per-session authentication.
|
||||
|
||||
Features disabled in Smithery mode:
|
||||
- Vector sync / semantic search (no persistent storage)
|
||||
- Admin UI at /app (no webhooks, no vector viz)
|
||||
- OAuth provisioning tools (no token storage)
|
||||
|
||||
Features enabled:
|
||||
- Core Nextcloud tools (notes, calendar, contacts, files, deck, tables, cookbook)
|
||||
- Per-session app password authentication via Smithery configSchema
|
||||
- Health check endpoints (/health/live, /health/ready)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import uvicorn
|
||||
|
||||
from nextcloud_mcp_server.config import setup_logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def main():
|
||||
"""Start the MCP server in Smithery stateless mode."""
|
||||
# Setup logging first
|
||||
setup_logging()
|
||||
|
||||
# Force stateless mode environment variables
|
||||
os.environ["SMITHERY_DEPLOYMENT"] = "true"
|
||||
os.environ["VECTOR_SYNC_ENABLED"] = "false"
|
||||
|
||||
logger.info("Starting Nextcloud MCP Server in Smithery stateless mode")
|
||||
|
||||
# Import app after setting environment variables
|
||||
from nextcloud_mcp_server.app import get_app
|
||||
|
||||
# Create the app with streamable-http transport (required for Smithery)
|
||||
app = get_app(transport="streamable-http")
|
||||
|
||||
# Smithery sets PORT environment variable
|
||||
port = int(os.environ.get("PORT", 8081))
|
||||
|
||||
logger.info(f"Listening on port {port}")
|
||||
|
||||
uvicorn.run(
|
||||
app,
|
||||
host="0.0.0.0",
|
||||
port=port,
|
||||
log_level="info",
|
||||
# Disable access log for cleaner output
|
||||
access_log=False,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -3,7 +3,7 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from langchain_text_splitters import MarkdownTextSplitter
|
||||
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -15,14 +15,16 @@ class ChunkWithPosition:
|
||||
text: str
|
||||
start_offset: int # Character position where chunk starts
|
||||
end_offset: int # Character position where chunk ends (exclusive)
|
||||
page_number: int | None = None # Page number for PDF chunks (optional)
|
||||
metadata: dict | None = None # Additional processor-specific metadata (optional)
|
||||
|
||||
|
||||
class DocumentChunker:
|
||||
"""Chunk large documents for optimal embedding using LangChain text splitters.
|
||||
|
||||
Uses MarkdownTextSplitter which is optimized for Markdown content like
|
||||
Nextcloud Notes. Respects markdown structure (headers, code blocks, lists)
|
||||
while maintaining semantic boundaries.
|
||||
Uses RecursiveCharacterTextSplitter which preserves semantic boundaries
|
||||
by splitting on sentence and paragraph boundaries before resorting to
|
||||
character-level splitting.
|
||||
"""
|
||||
|
||||
def __init__(self, chunk_size: int = 2048, overlap: int = 200):
|
||||
@@ -36,43 +38,47 @@ class DocumentChunker:
|
||||
self.chunk_size = chunk_size
|
||||
self.overlap = overlap
|
||||
|
||||
# Initialize LangChain MarkdownTextSplitter
|
||||
# Optimized for Markdown content with special handling for:
|
||||
# - Headers (# ## ###)
|
||||
# - Code blocks (``` ```)
|
||||
# - Lists (- * 1.)
|
||||
# - Horizontal rules (---)
|
||||
# - Paragraphs and sentences
|
||||
# This preserves both markdown structure and semantic boundaries
|
||||
self.splitter = MarkdownTextSplitter(
|
||||
# Initialize LangChain RecursiveCharacterTextSplitter
|
||||
# Uses hierarchical splitting to preserve semantic boundaries:
|
||||
# - Paragraphs (\n\n)
|
||||
# - Sentences (. ! ?)
|
||||
# - Words (spaces)
|
||||
# - Characters (last resort)
|
||||
# This prevents mid-sentence splitting while maintaining semantic coherence
|
||||
self.splitter = RecursiveCharacterTextSplitter(
|
||||
chunk_size=chunk_size,
|
||||
chunk_overlap=overlap,
|
||||
add_start_index=True, # Enable position tracking
|
||||
strip_whitespace=True,
|
||||
)
|
||||
|
||||
def chunk_text(self, content: str) -> list[ChunkWithPosition]:
|
||||
async def chunk_text(self, content: str) -> list[ChunkWithPosition]:
|
||||
"""
|
||||
Split text into overlapping chunks with position tracking.
|
||||
|
||||
Uses LangChain's MarkdownTextSplitter to create chunks that respect
|
||||
both markdown structure and semantic boundaries. Optimized for Nextcloud
|
||||
Notes content with special handling for headers, code blocks, lists, etc.
|
||||
Preserves character positions for each chunk to enable precise document
|
||||
retrieval.
|
||||
Uses LangChain's RecursiveCharacterTextSplitter to create chunks that
|
||||
preserve semantic boundaries by splitting at paragraphs and sentences
|
||||
before resorting to word or character-level splitting. This ensures
|
||||
sentences are kept intact. Preserves character positions for each chunk
|
||||
to enable precise document retrieval.
|
||||
|
||||
Args:
|
||||
content: Markdown text content to chunk
|
||||
content: Text content to chunk
|
||||
|
||||
Returns:
|
||||
List of chunks with their character positions in the original content
|
||||
"""
|
||||
import anyio
|
||||
|
||||
# Handle empty content - return single empty chunk for backward compatibility
|
||||
if not content:
|
||||
return [ChunkWithPosition(text="", start_offset=0, end_offset=0)]
|
||||
|
||||
# Use LangChain to create documents with position tracking
|
||||
docs = self.splitter.create_documents([content])
|
||||
# Run CPU-bound text splitting in thread pool to avoid blocking event loop
|
||||
docs = await anyio.to_thread.run_sync( # type: ignore[attr-defined]
|
||||
self.splitter.create_documents,
|
||||
[content],
|
||||
)
|
||||
|
||||
# Convert LangChain Documents to ChunkWithPosition objects
|
||||
chunks = [
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""HTML to Markdown conversion utilities for vector sync."""
|
||||
|
||||
import logging
|
||||
|
||||
from markdownify import markdownify as md
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def html_to_markdown(html_content: str | None) -> str:
|
||||
"""Convert HTML content to Markdown, preserving semantic structure.
|
||||
|
||||
This function converts HTML (typically from RSS/Atom feed items) to Markdown
|
||||
for better text embedding. Markdown preserves:
|
||||
- Heading hierarchy (important for document structure)
|
||||
- Lists (bullet and numbered)
|
||||
- Links (as [text](url))
|
||||
- Bold/italic emphasis
|
||||
- Paragraphs and line breaks
|
||||
|
||||
Args:
|
||||
html_content: HTML string to convert (may be None or empty)
|
||||
|
||||
Returns:
|
||||
Markdown string, or empty string if input is None/empty
|
||||
|
||||
Example:
|
||||
>>> html_to_markdown("<h1>Title</h1><p>Content with <b>bold</b>.</p>")
|
||||
'# Title\\n\\nContent with **bold**.\\n\\n'
|
||||
"""
|
||||
if not html_content:
|
||||
return ""
|
||||
|
||||
try:
|
||||
markdown = md(
|
||||
html_content,
|
||||
heading_style="ATX", # Use # style headings
|
||||
strip=["script", "style", "iframe", "noscript"], # Remove unsafe elements
|
||||
bullets="-", # Use - for unordered lists
|
||||
code_language="", # Don't add language hints to code blocks
|
||||
)
|
||||
return markdown.strip()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to convert HTML to Markdown: {e}")
|
||||
# Fallback: strip all HTML tags as a last resort
|
||||
import re
|
||||
|
||||
text = re.sub(r"<[^>]+>", " ", html_content)
|
||||
return " ".join(text.split()) # Normalize whitespace
|
||||
@@ -0,0 +1,306 @@
|
||||
"""Placeholder point management for Qdrant state tracking.
|
||||
|
||||
Placeholders are zero-vector points stored in Qdrant to track document processing
|
||||
state. They prevent duplicate work by marking documents as "in-flight" during the
|
||||
gap between scanner queuing and processor completion.
|
||||
|
||||
Architecture:
|
||||
- Scanner writes placeholders when queuing documents for processing
|
||||
- Processor deletes placeholders and writes real vectors after processing
|
||||
- All user-facing queries filter out placeholders (is_placeholder: False)
|
||||
|
||||
Placeholders contain:
|
||||
- Zero vectors (dimension from embedding service)
|
||||
- is_placeholder: True flag (for filtering)
|
||||
- status: "pending", "processing", "completed", "failed"
|
||||
- modified_at, etag from source document
|
||||
- queued_at timestamp
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue, PointStruct
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.embedding import get_embedding_service
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _generate_placeholder_id(doc_type: str, doc_id: str | int) -> str:
|
||||
"""Generate deterministic UUID for placeholder point.
|
||||
|
||||
Args:
|
||||
doc_type: Document type (note, file, etc.)
|
||||
doc_id: Document ID
|
||||
|
||||
Returns:
|
||||
UUID string for point ID
|
||||
"""
|
||||
point_name = f"{doc_type}:{doc_id}:placeholder"
|
||||
return str(uuid.uuid5(uuid.NAMESPACE_DNS, point_name))
|
||||
|
||||
|
||||
async def write_placeholder_point(
|
||||
doc_id: str | int,
|
||||
doc_type: str,
|
||||
user_id: str,
|
||||
modified_at: int,
|
||||
etag: str = "",
|
||||
file_path: str | None = None,
|
||||
) -> None:
|
||||
"""Write a placeholder point to Qdrant to mark document as queued.
|
||||
|
||||
This should be called by the scanner BEFORE queuing a document for processing.
|
||||
The placeholder prevents duplicate work if the scanner runs again before
|
||||
processing completes.
|
||||
|
||||
Args:
|
||||
doc_id: Document ID (int for notes/files)
|
||||
doc_type: Document type (note, file, etc.)
|
||||
user_id: User ID who owns the document
|
||||
modified_at: Document modification timestamp
|
||||
etag: Document ETag (if available)
|
||||
file_path: File path (for files only)
|
||||
|
||||
Raises:
|
||||
Exception: If Qdrant write fails
|
||||
"""
|
||||
try:
|
||||
qdrant_client = await get_qdrant_client()
|
||||
settings = get_settings()
|
||||
embedding_service = get_embedding_service()
|
||||
|
||||
# Get dimension dynamically (never hardcode)
|
||||
dimension = embedding_service.get_dimension()
|
||||
|
||||
# Create zero vectors
|
||||
zero_dense = [0.0] * dimension
|
||||
|
||||
# Create empty sparse vector for placeholders
|
||||
# Use models.SparseVector with empty indices/values
|
||||
from qdrant_client import models
|
||||
|
||||
empty_sparse = models.SparseVector(indices=[], values=[])
|
||||
|
||||
# Generate deterministic point ID
|
||||
point_id = _generate_placeholder_id(doc_type, doc_id)
|
||||
|
||||
# Build payload
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"doc_id": doc_id,
|
||||
"doc_type": doc_type,
|
||||
"is_placeholder": True,
|
||||
"status": "pending",
|
||||
"modified_at": modified_at,
|
||||
"etag": etag,
|
||||
"queued_at": int(time.time()),
|
||||
}
|
||||
|
||||
# Add file_path for files
|
||||
if doc_type == "file" and file_path:
|
||||
payload["file_path"] = file_path
|
||||
|
||||
# Create placeholder point
|
||||
point = PointStruct(
|
||||
id=point_id,
|
||||
vector={
|
||||
"dense": zero_dense,
|
||||
"sparse": empty_sparse, # Empty sparse vector for placeholders
|
||||
},
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
# Upsert to Qdrant
|
||||
await qdrant_client.upsert(
|
||||
collection_name=settings.get_collection_name(),
|
||||
points=[point],
|
||||
wait=True,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Wrote placeholder for {doc_type}_{doc_id} (user={user_id}, "
|
||||
f"modified_at={modified_at})"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to write placeholder for {doc_type}_{doc_id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
async def query_document_metadata(
|
||||
doc_id: str | int,
|
||||
doc_type: str,
|
||||
user_id: str,
|
||||
) -> dict | None:
|
||||
"""Query Qdrant for existing document entry (placeholder or real).
|
||||
|
||||
Returns the payload of the first matching point, which could be:
|
||||
- A placeholder (is_placeholder: True)
|
||||
- A real indexed document (is_placeholder: False or missing)
|
||||
- None if document not in Qdrant
|
||||
|
||||
Args:
|
||||
doc_id: Document ID
|
||||
doc_type: Document type
|
||||
user_id: User ID
|
||||
|
||||
Returns:
|
||||
Payload dict if found, None otherwise
|
||||
"""
|
||||
try:
|
||||
qdrant_client = await get_qdrant_client()
|
||||
settings = get_settings()
|
||||
|
||||
# Query for any entry matching doc_id, doc_type, user_id
|
||||
scroll_result = await qdrant_client.scroll(
|
||||
collection_name=settings.get_collection_name(),
|
||||
scroll_filter=Filter(
|
||||
must=[
|
||||
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
|
||||
FieldCondition(key="doc_id", match=MatchValue(value=doc_id)),
|
||||
FieldCondition(key="doc_type", match=MatchValue(value=doc_type)),
|
||||
]
|
||||
),
|
||||
limit=1,
|
||||
with_payload=True,
|
||||
with_vectors=False,
|
||||
)
|
||||
|
||||
if scroll_result[0]:
|
||||
point = scroll_result[0][0]
|
||||
return dict(point.payload)
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error querying document metadata for {doc_type}_{doc_id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def delete_placeholder_point(
|
||||
doc_id: str | int,
|
||||
doc_type: str,
|
||||
user_id: str,
|
||||
) -> None:
|
||||
"""Delete a placeholder point from Qdrant.
|
||||
|
||||
This should be called by the processor BEFORE writing real vectors.
|
||||
We delete the placeholder to avoid duplicates, then write the real chunks.
|
||||
|
||||
Args:
|
||||
doc_id: Document ID
|
||||
doc_type: Document type
|
||||
user_id: User ID
|
||||
|
||||
Raises:
|
||||
Exception: If Qdrant delete fails
|
||||
"""
|
||||
try:
|
||||
qdrant_client = await get_qdrant_client()
|
||||
settings = get_settings()
|
||||
|
||||
# Delete by filter (in case there are multiple chunks from old indexing)
|
||||
await qdrant_client.delete(
|
||||
collection_name=settings.get_collection_name(),
|
||||
points_selector=Filter(
|
||||
must=[
|
||||
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
|
||||
FieldCondition(key="doc_id", match=MatchValue(value=doc_id)),
|
||||
FieldCondition(key="doc_type", match=MatchValue(value=doc_type)),
|
||||
FieldCondition(key="is_placeholder", match=MatchValue(value=True)),
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
logger.debug(f"Deleted placeholder for {doc_type}_{doc_id} (user={user_id})")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to delete placeholder for {doc_type}_{doc_id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
async def update_placeholder_status(
|
||||
doc_id: str | int,
|
||||
doc_type: str,
|
||||
user_id: str,
|
||||
status: str,
|
||||
) -> None:
|
||||
"""Update the status field of a placeholder point.
|
||||
|
||||
Status values:
|
||||
- "pending": Queued for processing
|
||||
- "processing": Currently being processed
|
||||
- "completed": Processing completed successfully
|
||||
- "failed": Processing failed
|
||||
|
||||
Args:
|
||||
doc_id: Document ID
|
||||
doc_type: Document type
|
||||
user_id: User ID
|
||||
status: New status value
|
||||
|
||||
Raises:
|
||||
Exception: If Qdrant update fails
|
||||
"""
|
||||
try:
|
||||
qdrant_client = await get_qdrant_client()
|
||||
settings = get_settings()
|
||||
|
||||
# Update payload using set_payload
|
||||
await qdrant_client.set_payload(
|
||||
collection_name=settings.get_collection_name(),
|
||||
payload={"status": status},
|
||||
points=Filter(
|
||||
must=[
|
||||
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
|
||||
FieldCondition(key="doc_id", match=MatchValue(value=doc_id)),
|
||||
FieldCondition(key="doc_type", match=MatchValue(value=doc_type)),
|
||||
FieldCondition(key="is_placeholder", match=MatchValue(value=True)),
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Updated placeholder status for {doc_type}_{doc_id} to '{status}' "
|
||||
f"(user={user_id})"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to update placeholder status for {doc_type}_{doc_id}: {e}"
|
||||
)
|
||||
# Don't raise - status updates are non-critical
|
||||
|
||||
|
||||
def get_placeholder_filter() -> FieldCondition:
|
||||
"""Get a filter condition to exclude placeholders from queries.
|
||||
|
||||
Add this to all user-facing search/visualization queries to ensure
|
||||
placeholders are never returned to users.
|
||||
|
||||
Returns:
|
||||
FieldCondition that filters out is_placeholder: True
|
||||
|
||||
Example:
|
||||
Filter(
|
||||
must=[
|
||||
get_placeholder_filter(), # Exclude placeholders
|
||||
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
|
||||
]
|
||||
)
|
||||
"""
|
||||
return FieldCondition(
|
||||
key="is_placeholder",
|
||||
match=MatchValue(value=False),
|
||||
)
|
||||
@@ -6,6 +6,7 @@ Processes documents from stream: fetches content, generates embeddings, stores i
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, cast
|
||||
|
||||
import anyio
|
||||
from anyio.abc import TaskStatus
|
||||
@@ -23,12 +24,50 @@ from nextcloud_mcp_server.observability.metrics import (
|
||||
)
|
||||
from nextcloud_mcp_server.observability.tracing import trace_operation
|
||||
from nextcloud_mcp_server.vector.document_chunker import DocumentChunker
|
||||
from nextcloud_mcp_server.vector.placeholder import delete_placeholder_point
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
from nextcloud_mcp_server.vector.scanner import DocumentTask
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def assign_page_numbers(chunks, page_boundaries):
|
||||
"""Assign page numbers to chunks based on page boundaries.
|
||||
|
||||
Each chunk gets the page number where most of its content appears.
|
||||
For chunks spanning multiple pages, assigns the page containing the
|
||||
majority of the chunk's characters.
|
||||
|
||||
Args:
|
||||
chunks: List of ChunkWithPosition objects
|
||||
page_boundaries: List of dicts with {page, start_offset, end_offset}
|
||||
|
||||
Returns:
|
||||
None (modifies chunks in place)
|
||||
"""
|
||||
if not page_boundaries:
|
||||
return
|
||||
|
||||
for chunk in chunks:
|
||||
# Find which page(s) this chunk overlaps with
|
||||
max_overlap = 0
|
||||
assigned_page = None
|
||||
|
||||
for boundary in page_boundaries:
|
||||
# Calculate overlap between chunk and page
|
||||
overlap_start = max(chunk.start_offset, boundary["start_offset"])
|
||||
overlap_end = min(chunk.end_offset, boundary["end_offset"])
|
||||
overlap = max(0, overlap_end - overlap_start)
|
||||
|
||||
# Assign to page with maximum overlap
|
||||
if overlap > max_overlap:
|
||||
max_overlap = overlap
|
||||
assigned_page = boundary["page"]
|
||||
|
||||
if assigned_page is not None:
|
||||
chunk.page_number = assigned_page
|
||||
|
||||
|
||||
async def processor_task(
|
||||
worker_id: int,
|
||||
receive_stream: MemoryObjectReceiveStream[DocumentTask],
|
||||
@@ -218,31 +257,368 @@ async def _index_document(
|
||||
settings = get_settings()
|
||||
|
||||
# Fetch document content
|
||||
if doc_task.doc_type == "note":
|
||||
document = await nc_client.notes.get_note(int(doc_task.doc_id))
|
||||
content = f"{document['title']}\n\n{document['content']}"
|
||||
title = document["title"]
|
||||
etag = document.get("etag", "")
|
||||
else:
|
||||
raise ValueError(f"Unsupported doc_type: {doc_task.doc_type}")
|
||||
with trace_operation(
|
||||
"vector_sync.fetch_content",
|
||||
attributes={
|
||||
"vector_sync.doc_type": doc_task.doc_type,
|
||||
"vector_sync.doc_id": doc_task.doc_id,
|
||||
},
|
||||
):
|
||||
if doc_task.doc_type == "note":
|
||||
document = await nc_client.notes.get_note(int(doc_task.doc_id))
|
||||
content = f"{document['title']}\n\n{document['content']}"
|
||||
title = document["title"]
|
||||
etag = document.get("etag", "")
|
||||
file_metadata = {} # No file-specific metadata for notes
|
||||
file_path = None # Notes don't have file paths
|
||||
content_bytes = None # Notes don't have binary content
|
||||
content_type = None
|
||||
elif doc_task.doc_type == "news_item":
|
||||
from nextcloud_mcp_server.vector.html_processor import html_to_markdown
|
||||
|
||||
item = await nc_client.news.get_item(int(doc_task.doc_id))
|
||||
# Convert HTML body to Markdown for better embedding
|
||||
body_markdown = html_to_markdown(item.get("body", ""))
|
||||
# Build content: title + URL + body
|
||||
item_title = item.get("title", "")
|
||||
item_url = item.get("url", "")
|
||||
feed_title = item.get("feedTitle", "")
|
||||
|
||||
# Structure content for embedding
|
||||
content_parts = [item_title]
|
||||
if feed_title:
|
||||
content_parts.append(f"Source: {feed_title}")
|
||||
if item_url:
|
||||
content_parts.append(f"URL: {item_url}")
|
||||
content_parts.append("") # Blank line
|
||||
content_parts.append(body_markdown)
|
||||
content = "\n".join(content_parts)
|
||||
|
||||
title = item_title
|
||||
etag = item.get("guidHash", "")
|
||||
# Store news-specific metadata for later use in payload
|
||||
file_metadata = {
|
||||
"feed_id": item.get("feedId"),
|
||||
"feed_title": feed_title,
|
||||
"author": item.get("author"),
|
||||
"pub_date": item.get("pubDate"),
|
||||
"starred": item.get("starred", False),
|
||||
"unread": item.get("unread", True),
|
||||
"url": item_url,
|
||||
"guid_hash": item.get("guidHash"),
|
||||
"enclosure_link": item.get("enclosureLink"),
|
||||
"enclosure_mime": item.get("enclosureMime"),
|
||||
}
|
||||
file_path = None
|
||||
content_bytes = None
|
||||
content_type = None
|
||||
elif doc_task.doc_type == "deck_card":
|
||||
# Fetch card from Deck API
|
||||
# Note: We need board_id and stack_id to fetch the card
|
||||
# For now, we'll need to get all boards and find the card
|
||||
# This is not optimal, but Deck API requires board_id and stack_id
|
||||
boards = await nc_client.deck.get_boards()
|
||||
card_found = False
|
||||
|
||||
for board in boards:
|
||||
if card_found:
|
||||
break
|
||||
# Skip deleted boards (soft delete: deletedAt > 0)
|
||||
if board.deletedAt > 0:
|
||||
continue
|
||||
stacks = await nc_client.deck.get_stacks(board.id)
|
||||
for stack in stacks:
|
||||
if card_found:
|
||||
break
|
||||
if stack.cards:
|
||||
for card in stack.cards:
|
||||
if card.id == int(doc_task.doc_id):
|
||||
# Build content from card title and description
|
||||
content_parts = [card.title]
|
||||
if card.description:
|
||||
content_parts.append(card.description)
|
||||
content = "\n\n".join(content_parts)
|
||||
title = card.title
|
||||
|
||||
# Store deck-specific metadata
|
||||
file_metadata = {
|
||||
"board_id": board.id,
|
||||
"board_title": board.title,
|
||||
"stack_id": stack.id,
|
||||
"stack_title": stack.title,
|
||||
"card_type": card.type,
|
||||
"duedate": (
|
||||
card.duedate.isoformat()
|
||||
if card.duedate
|
||||
else None
|
||||
),
|
||||
"archived": card.archived,
|
||||
"owner": (
|
||||
card.owner.uid
|
||||
if hasattr(card.owner, "uid")
|
||||
else str(card.owner)
|
||||
),
|
||||
}
|
||||
etag = card.etag or ""
|
||||
file_path = None
|
||||
content_bytes = None
|
||||
content_type = None
|
||||
card_found = True
|
||||
break
|
||||
|
||||
if not card_found:
|
||||
raise ValueError(
|
||||
f"Deck card {doc_task.doc_id} not found in any board/stack"
|
||||
)
|
||||
elif doc_task.doc_type == "file":
|
||||
# For files, doc_id is now the numeric file ID, file_path comes from DocumentTask
|
||||
if not doc_task.file_path:
|
||||
raise ValueError(
|
||||
f"File path required for file indexing but not provided (file_id={doc_task.doc_id})"
|
||||
)
|
||||
file_path = doc_task.file_path
|
||||
|
||||
# Read file content via WebDAV
|
||||
content_bytes, content_type = await nc_client.webdav.read_file(file_path)
|
||||
else:
|
||||
raise ValueError(f"Unsupported doc_type: {doc_task.doc_type}")
|
||||
|
||||
# Process file content (text extraction)
|
||||
if doc_task.doc_type == "file":
|
||||
# Type narrowing: content_bytes and content_type are set for files
|
||||
assert content_bytes is not None
|
||||
assert content_type is not None
|
||||
assert file_path is not None
|
||||
|
||||
with trace_operation(
|
||||
"vector_sync.document_process",
|
||||
attributes={
|
||||
"vector_sync.content_type": content_type,
|
||||
"vector_sync.file_size": len(content_bytes),
|
||||
},
|
||||
):
|
||||
# Use document processor registry to extract text
|
||||
from nextcloud_mcp_server.document_processors import get_registry
|
||||
|
||||
registry = get_registry()
|
||||
|
||||
try:
|
||||
result = await registry.process(
|
||||
content=content_bytes,
|
||||
content_type=content_type,
|
||||
filename=file_path,
|
||||
)
|
||||
content = result.text
|
||||
file_metadata = result.metadata
|
||||
title = file_metadata.get("title") or file_path.split("/")[-1]
|
||||
etag = "" # WebDAV read_file doesn't return etag
|
||||
|
||||
# Diagnostic: Log page boundary information if available
|
||||
if "page_boundaries" in file_metadata:
|
||||
page_boundaries = file_metadata["page_boundaries"]
|
||||
logger.info(
|
||||
f"Page boundaries for {file_path}: "
|
||||
f"{len(page_boundaries)} pages, text length: {len(content)}"
|
||||
)
|
||||
# Log first 3 page boundaries for debugging
|
||||
for boundary in page_boundaries[:3]:
|
||||
logger.debug(
|
||||
f" Page {boundary['page']}: "
|
||||
f"offsets [{boundary['start_offset']}:{boundary['end_offset']}]"
|
||||
)
|
||||
# Verify last boundary matches text length
|
||||
if page_boundaries:
|
||||
last_boundary = page_boundaries[-1]
|
||||
if last_boundary["end_offset"] != len(content):
|
||||
logger.warning(
|
||||
f"Text length mismatch: content={len(content)}, "
|
||||
f"last_boundary_end={last_boundary['end_offset']}"
|
||||
)
|
||||
else:
|
||||
logger.debug(f"No page_boundaries in metadata for {file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process file {file_path}: {e}")
|
||||
raise
|
||||
|
||||
# Tokenize and chunk (using configured chunk size and overlap)
|
||||
chunker = DocumentChunker(
|
||||
chunk_size=settings.document_chunk_size,
|
||||
overlap=settings.document_chunk_overlap,
|
||||
)
|
||||
chunks = chunker.chunk_text(content)
|
||||
with trace_operation(
|
||||
"vector_sync.chunk_text",
|
||||
attributes={
|
||||
"vector_sync.input_chars": len(content),
|
||||
"vector_sync.chunk_size": settings.document_chunk_size,
|
||||
"vector_sync.overlap": settings.document_chunk_overlap,
|
||||
},
|
||||
):
|
||||
chunker = DocumentChunker(
|
||||
chunk_size=settings.document_chunk_size,
|
||||
overlap=settings.document_chunk_overlap,
|
||||
)
|
||||
chunks = await chunker.chunk_text(content)
|
||||
|
||||
# Assign page numbers to chunks if page boundaries are available (PDFs)
|
||||
page_boundaries = file_metadata.get("page_boundaries")
|
||||
if doc_task.doc_type == "file" and page_boundaries is not None:
|
||||
# Type narrowing: page_boundaries is guaranteed to be list[dict] here
|
||||
page_boundaries_list = cast(list[dict[str, Any]], page_boundaries)
|
||||
with trace_operation(
|
||||
"vector_sync.assign_page_numbers",
|
||||
attributes={
|
||||
"vector_sync.chunk_count": len(chunks),
|
||||
"vector_sync.page_count": len(page_boundaries_list),
|
||||
},
|
||||
):
|
||||
assign_page_numbers(chunks, page_boundaries_list)
|
||||
|
||||
# Diagnostic: Verify page number assignment
|
||||
assigned_count = sum(1 for c in chunks if c.page_number is not None)
|
||||
logger.info(
|
||||
f"Assigned page numbers to {assigned_count}/{len(chunks)} chunks "
|
||||
f"for {file_path}"
|
||||
)
|
||||
|
||||
# Log first 3 chunks to see their page assignments
|
||||
for i, chunk in enumerate(chunks[:3]):
|
||||
logger.debug(
|
||||
f" Chunk {i}: page={chunk.page_number}, "
|
||||
f"offsets=[{chunk.start_offset}:{chunk.end_offset}]"
|
||||
)
|
||||
|
||||
# Warning if NO page numbers were assigned
|
||||
if assigned_count == 0:
|
||||
logger.warning(
|
||||
f"NO page numbers assigned! "
|
||||
f"Text length: {len(content)}, "
|
||||
f"Chunks: {len(chunks)}, "
|
||||
f"Chunk offset range: [{chunks[0].start_offset}:{chunks[-1].end_offset}], "
|
||||
f"Page boundaries: {len(page_boundaries_list)} pages, "
|
||||
f"First boundary: {page_boundaries_list[0] if page_boundaries_list else 'None'}"
|
||||
)
|
||||
|
||||
# Extract chunk texts for embedding
|
||||
chunk_texts = [chunk.text for chunk in chunks]
|
||||
|
||||
# Generate dense embeddings (I/O bound - external API call)
|
||||
embedding_service = get_embedding_service()
|
||||
dense_embeddings = await embedding_service.embed_batch(chunk_texts)
|
||||
# Initialize results containers
|
||||
dense_embeddings: list = []
|
||||
sparse_embeddings: list = []
|
||||
chunk_images: dict[int, dict] = {}
|
||||
|
||||
# Generate sparse embeddings (BM25 for keyword matching)
|
||||
bm25_service = get_bm25_service()
|
||||
sparse_embeddings = bm25_service.encode_batch(chunk_texts)
|
||||
# Determine if we need PDF highlighting
|
||||
is_pdf = doc_task.doc_type == "file" and content_type == "application/pdf"
|
||||
|
||||
# Define async tasks for parallel execution
|
||||
async def generate_dense_embeddings():
|
||||
"""Generate dense embeddings (I/O bound - external API call)."""
|
||||
nonlocal dense_embeddings
|
||||
with trace_operation(
|
||||
"vector_sync.embed_dense",
|
||||
attributes={
|
||||
"vector_sync.chunk_count": len(chunk_texts),
|
||||
"vector_sync.total_chars": sum(len(t) for t in chunk_texts),
|
||||
},
|
||||
):
|
||||
embedding_service = get_embedding_service()
|
||||
dense_embeddings = await embedding_service.embed_batch(chunk_texts)
|
||||
|
||||
async def generate_sparse_embeddings():
|
||||
"""Generate sparse embeddings (BM25 for keyword matching)."""
|
||||
nonlocal sparse_embeddings
|
||||
with trace_operation(
|
||||
"vector_sync.embed_sparse",
|
||||
attributes={
|
||||
"vector_sync.chunk_count": len(chunk_texts),
|
||||
},
|
||||
):
|
||||
bm25_service = get_bm25_service()
|
||||
sparse_embeddings = await bm25_service.encode_batch(chunk_texts)
|
||||
|
||||
async def generate_highlights():
|
||||
"""Generate highlighted page images for PDF chunks (CPU-bound)."""
|
||||
nonlocal chunk_images
|
||||
if not is_pdf:
|
||||
return
|
||||
|
||||
# Type narrowing: content_bytes is set for PDF files
|
||||
assert content_bytes is not None
|
||||
|
||||
with trace_operation(
|
||||
"vector_sync.generate_highlights",
|
||||
attributes={
|
||||
"vector_sync.chunk_count": len(chunks),
|
||||
"vector_sync.pdf_size": len(content_bytes),
|
||||
},
|
||||
):
|
||||
import base64
|
||||
|
||||
from nextcloud_mcp_server.search.pdf_highlighter import PDFHighlighter
|
||||
|
||||
# Build chunk data for batch processing
|
||||
# Format: (chunk_index, start_offset, end_offset, page_number, chunk_text)
|
||||
chunk_data: list[tuple[int, int, int, int | None, str]] = [
|
||||
(i, chunk.start_offset, chunk.end_offset, chunk.page_number, chunk.text)
|
||||
for i, chunk in enumerate(chunks)
|
||||
if chunk.page_number is not None
|
||||
]
|
||||
|
||||
# Get pre-computed page boundaries from document processor
|
||||
page_boundaries = file_metadata.get("page_boundaries")
|
||||
if not page_boundaries:
|
||||
logger.warning("No page boundaries available, skipping highlighting")
|
||||
return
|
||||
|
||||
# Type narrowing: page_boundaries is guaranteed to be list[dict] here
|
||||
page_boundaries_list = cast(list[dict[str, Any]], page_boundaries)
|
||||
|
||||
logger.info(
|
||||
f"Batch generating highlighted page images for {len(chunk_data)} PDF chunks"
|
||||
)
|
||||
|
||||
# Run CPU-bound highlighting in thread pool
|
||||
# Pass pre-computed page boundaries and full text to avoid re-processing the PDF
|
||||
batch_results = await anyio.to_thread.run_sync( # type: ignore[attr-defined]
|
||||
lambda: PDFHighlighter.highlight_chunks_batch(
|
||||
pdf_bytes=content_bytes,
|
||||
chunks=chunk_data,
|
||||
page_boundaries=page_boundaries_list,
|
||||
full_text=content,
|
||||
color="yellow",
|
||||
zoom=2.0,
|
||||
)
|
||||
)
|
||||
|
||||
# Convert results to storage format
|
||||
for chunk_index, (
|
||||
png_bytes,
|
||||
actual_page_num,
|
||||
highlight_count,
|
||||
) in batch_results.items():
|
||||
image_base64 = base64.b64encode(png_bytes).decode("utf-8")
|
||||
chunk_images[chunk_index] = {
|
||||
"image": image_base64,
|
||||
"page": actual_page_num,
|
||||
"highlights": highlight_count,
|
||||
"size": len(png_bytes),
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"Generated {len(chunk_images)}/{len(chunks)} highlighted page images "
|
||||
f"(avg {sum(img['size'] for img in chunk_images.values()) // max(len(chunk_images), 1):,} bytes)"
|
||||
)
|
||||
|
||||
# Run all embedding/highlighting operations in parallel
|
||||
# - Dense embeddings: I/O bound (API call)
|
||||
# - Sparse embeddings: CPU bound (local BM25)
|
||||
# - Highlighting: CPU bound (PyMuPDF rendering, runs in thread pool)
|
||||
with trace_operation(
|
||||
"vector_sync.parallel_processing",
|
||||
attributes={
|
||||
"vector_sync.is_pdf": is_pdf,
|
||||
"vector_sync.chunk_count": len(chunks),
|
||||
},
|
||||
):
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(generate_dense_embeddings)
|
||||
tg.start_soon(generate_sparse_embeddings)
|
||||
tg.start_soon(generate_highlights)
|
||||
|
||||
# Prepare Qdrant points
|
||||
indexed_at = int(time.time())
|
||||
@@ -267,8 +643,9 @@ async def _index_document(
|
||||
"user_id": doc_task.user_id,
|
||||
"doc_id": doc_task.doc_id,
|
||||
"doc_type": doc_task.doc_type,
|
||||
"is_placeholder": False, # Real indexed document (not placeholder)
|
||||
"title": title,
|
||||
"excerpt": chunk.text[:200],
|
||||
"excerpt": chunk.text, # Full chunk text (up to chunk_size, default 2048 chars)
|
||||
"indexed_at": indexed_at,
|
||||
"modified_at": doc_task.modified_at,
|
||||
"etag": etag,
|
||||
@@ -277,16 +654,105 @@ async def _index_document(
|
||||
"chunk_start_offset": chunk.start_offset,
|
||||
"chunk_end_offset": chunk.end_offset,
|
||||
"metadata_version": 2, # v2 includes position metadata
|
||||
# File-specific metadata (PDF, etc.)
|
||||
**(
|
||||
{
|
||||
"file_path": file_path, # Store file path for retrieval
|
||||
"mime_type": content_type, # From WebDAV response
|
||||
"file_size": file_metadata.get("file_size"),
|
||||
"page_number": chunk.page_number,
|
||||
"page_count": file_metadata.get("page_count"),
|
||||
"author": file_metadata.get("author"),
|
||||
"creation_date": file_metadata.get("creation_date"),
|
||||
"has_images": file_metadata.get("has_images", False),
|
||||
"image_count": file_metadata.get("image_count", 0),
|
||||
}
|
||||
if doc_task.doc_type == "file"
|
||||
else {}
|
||||
),
|
||||
# News item-specific metadata
|
||||
**(
|
||||
{
|
||||
"feed_id": file_metadata.get("feed_id"),
|
||||
"feed_title": file_metadata.get("feed_title"),
|
||||
"author": file_metadata.get("author"),
|
||||
"pub_date": file_metadata.get("pub_date"),
|
||||
"starred": file_metadata.get("starred"),
|
||||
"unread": file_metadata.get("unread"),
|
||||
"url": file_metadata.get("url"),
|
||||
"guid_hash": file_metadata.get("guid_hash"),
|
||||
"enclosure_link": file_metadata.get("enclosure_link"),
|
||||
"enclosure_mime": file_metadata.get("enclosure_mime"),
|
||||
}
|
||||
if doc_task.doc_type == "news_item"
|
||||
else {}
|
||||
),
|
||||
# Deck card-specific metadata
|
||||
**(
|
||||
{
|
||||
"board_id": file_metadata.get("board_id"),
|
||||
"board_title": file_metadata.get("board_title"),
|
||||
"stack_id": file_metadata.get("stack_id"),
|
||||
"stack_title": file_metadata.get("stack_title"),
|
||||
"card_type": file_metadata.get("card_type"),
|
||||
"duedate": file_metadata.get("duedate"),
|
||||
"owner": file_metadata.get("owner"),
|
||||
}
|
||||
if doc_task.doc_type == "deck_card"
|
||||
else {}
|
||||
),
|
||||
# Highlighted page image (PDF only)
|
||||
**(
|
||||
{
|
||||
"highlighted_page_image": chunk_images[i]["image"],
|
||||
"highlighted_page_number": chunk_images[i]["page"],
|
||||
"highlight_count": chunk_images[i]["highlights"],
|
||||
}
|
||||
if i in chunk_images
|
||||
else {}
|
||||
),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# Upsert to Qdrant
|
||||
await qdrant_client.upsert(
|
||||
collection_name=settings.get_collection_name(),
|
||||
points=points,
|
||||
wait=True,
|
||||
)
|
||||
# Delete placeholder before writing real vectors
|
||||
# This prevents duplicates and cleans up the placeholder state
|
||||
try:
|
||||
await delete_placeholder_point(
|
||||
doc_id=doc_task.doc_id,
|
||||
doc_type=doc_task.doc_type,
|
||||
user_id=doc_task.user_id,
|
||||
)
|
||||
except Exception as e:
|
||||
# Log but don't fail indexing if placeholder deletion fails
|
||||
logger.warning(
|
||||
f"Failed to delete placeholder for {doc_task.doc_type}_{doc_task.doc_id}: {e}"
|
||||
)
|
||||
|
||||
# Upsert to Qdrant in batches to avoid timeout with large payloads
|
||||
# Each batch is limited to avoid WriteTimeout when sending large image payloads
|
||||
BATCH_SIZE = 10 # ~2MB per batch with images
|
||||
with trace_operation(
|
||||
"vector_sync.qdrant_upsert",
|
||||
attributes={
|
||||
"vector_sync.point_count": len(points),
|
||||
"vector_sync.collection": settings.get_collection_name(),
|
||||
"vector_sync.images_count": len(chunk_images),
|
||||
"vector_sync.batch_size": BATCH_SIZE,
|
||||
},
|
||||
):
|
||||
for batch_start in range(0, len(points), BATCH_SIZE):
|
||||
batch_end = min(batch_start + BATCH_SIZE, len(points))
|
||||
batch = points[batch_start:batch_end]
|
||||
await qdrant_client.upsert(
|
||||
collection_name=settings.get_collection_name(),
|
||||
points=batch,
|
||||
wait=True,
|
||||
)
|
||||
if batch_end < len(points):
|
||||
logger.debug(
|
||||
f"Upserted batch {batch_start // BATCH_SIZE + 1}/{(len(points) + BATCH_SIZE - 1) // BATCH_SIZE}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Indexed {doc_task.doc_type}_{doc_task.doc_id} for {doc_task.user_id} "
|
||||
|
||||
@@ -93,27 +93,29 @@ async def get_qdrant_client() -> AsyncQdrantClient:
|
||||
|
||||
# Validate dimension matches
|
||||
if actual_dimension != expected_dimension:
|
||||
embedding_model = settings.get_embedding_model_name()
|
||||
raise ValueError(
|
||||
f"Dimension mismatch for collection '{collection_name}':\n"
|
||||
f" Expected: {expected_dimension} (from embedding model '{settings.ollama_embedding_model}')\n"
|
||||
f" Expected: {expected_dimension} (from embedding model '{embedding_model}')\n"
|
||||
f" Found: {actual_dimension}\n"
|
||||
f"This usually means you changed the embedding model.\n"
|
||||
f"Solutions:\n"
|
||||
f" 1. Delete the old collection: Collection will be recreated with new dimensions\n"
|
||||
f" 2. Set QDRANT_COLLECTION to use a different collection name\n"
|
||||
f" 3. Revert OLLAMA_EMBEDDING_MODEL to the original model"
|
||||
f" 3. Revert to the original embedding model"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Using existing Qdrant collection: {collection_name} "
|
||||
f"(dimension={actual_dimension}, model={settings.ollama_embedding_model})"
|
||||
f"(dimension={actual_dimension}, model={settings.get_embedding_model_name()})"
|
||||
)
|
||||
|
||||
else:
|
||||
# Collection doesn't exist - create it
|
||||
embedding_model = settings.get_embedding_model_name()
|
||||
logger.info(
|
||||
f"Collection '{collection_name}' not found, creating with "
|
||||
f"dimension={expected_dimension}, model={settings.ollama_embedding_model}..."
|
||||
f"dimension={expected_dimension}, model={embedding_model}..."
|
||||
)
|
||||
await _qdrant_client.create_collection(
|
||||
collection_name=collection_name,
|
||||
@@ -134,7 +136,7 @@ async def get_qdrant_client() -> AsyncQdrantClient:
|
||||
logger.info(
|
||||
f"Created Qdrant collection: {collection_name}\n"
|
||||
f" Dense vector dimension: {expected_dimension}\n"
|
||||
f" Dense embedding model: {settings.ollama_embedding_model}\n"
|
||||
f" Dense embedding model: {embedding_model}\n"
|
||||
f" Sparse vectors: BM25 (for hybrid search)\n"
|
||||
f" Distance: COSINE\n"
|
||||
f"Background sync will index all documents with dense + sparse vectors."
|
||||
|
||||
@@ -4,6 +4,7 @@ Periodically scans enabled users' content and queues changed documents for proce
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
@@ -16,6 +17,10 @@ from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.observability.metrics import record_vector_sync_scan
|
||||
from nextcloud_mcp_server.observability.tracing import trace_operation
|
||||
from nextcloud_mcp_server.vector.placeholder import (
|
||||
query_document_metadata,
|
||||
write_placeholder_point,
|
||||
)
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -26,10 +31,11 @@ class DocumentTask:
|
||||
"""Document task for processing queue."""
|
||||
|
||||
user_id: str
|
||||
doc_id: str
|
||||
doc_id: int | str # int for files/notes, str for legacy
|
||||
doc_type: str # "note", "file", "calendar"
|
||||
operation: str # "index" or "delete"
|
||||
modified_at: int
|
||||
file_path: str | None = None # File path for files (when doc_id is file_id)
|
||||
|
||||
|
||||
# Track documents potentially deleted (grace period before actual deletion)
|
||||
@@ -73,9 +79,11 @@ async def get_last_indexed_timestamp(user_id: str) -> int | None:
|
||||
|
||||
if scroll_result[0]:
|
||||
timestamps = [
|
||||
point.payload.get("indexed_at", 0) for point in scroll_result[0]
|
||||
point.payload.get("indexed_at", 0)
|
||||
for point in scroll_result[0]
|
||||
if point.payload is not None
|
||||
]
|
||||
max_timestamp = max(timestamps)
|
||||
max_timestamp = max(timestamps) if timestamps else 0
|
||||
logger.info(
|
||||
f"Max indexed_at: {max_timestamp}, timestamps sample: {timestamps[:3]}"
|
||||
)
|
||||
@@ -182,8 +190,9 @@ async def scan_user_documents(
|
||||
f"[SCAN-{scan_id}] Using pruneBefore={prune_before} to optimize data transfer"
|
||||
)
|
||||
|
||||
# Get indexed state from Qdrant first (for incremental sync)
|
||||
indexed_docs = {}
|
||||
# For deletion tracking, get all doc_ids in Qdrant (for incremental sync)
|
||||
# Note: We no longer bulk-query indexed_at, instead check per-document
|
||||
indexed_doc_ids = set()
|
||||
if not initial_sync:
|
||||
qdrant_client = await get_qdrant_client()
|
||||
scroll_result = await qdrant_client.scroll(
|
||||
@@ -194,17 +203,18 @@ async def scan_user_documents(
|
||||
FieldCondition(key="doc_type", match=MatchValue(value="note")),
|
||||
]
|
||||
),
|
||||
with_payload=["doc_id", "indexed_at"],
|
||||
with_payload=["doc_id"],
|
||||
with_vectors=False,
|
||||
limit=10000,
|
||||
)
|
||||
|
||||
indexed_docs = {
|
||||
point.payload["doc_id"]: point.payload["indexed_at"]
|
||||
for point in scroll_result[0]
|
||||
indexed_doc_ids = {
|
||||
point.payload["doc_id"]
|
||||
for point in (scroll_result[0] or [])
|
||||
if point.payload is not None
|
||||
}
|
||||
|
||||
logger.debug(f"Found {len(indexed_docs)} indexed documents in Qdrant")
|
||||
logger.debug(f"Found {len(indexed_doc_ids)} indexed documents in Qdrant")
|
||||
|
||||
# Stream notes from Nextcloud and process immediately
|
||||
note_count = 0
|
||||
@@ -218,7 +228,14 @@ async def scan_user_documents(
|
||||
modified_at = note.get("modified", 0)
|
||||
|
||||
if initial_sync:
|
||||
# Send everything on first sync
|
||||
# Send everything on first sync - write placeholder first
|
||||
await write_placeholder_point(
|
||||
doc_id=doc_id,
|
||||
doc_type="note",
|
||||
user_id=user_id,
|
||||
modified_at=modified_at,
|
||||
etag=note.get("etag", ""),
|
||||
)
|
||||
await send_stream.send(
|
||||
DocumentTask(
|
||||
user_id=user_id,
|
||||
@@ -230,9 +247,7 @@ async def scan_user_documents(
|
||||
)
|
||||
queued += 1
|
||||
else:
|
||||
# Incremental sync: compare with indexed state
|
||||
indexed_at = indexed_docs.get(doc_id)
|
||||
|
||||
# Incremental sync: check if document exists and compare modified_at
|
||||
# If document reappeared, remove from potentially_deleted
|
||||
doc_key = (user_id, doc_id)
|
||||
if doc_key in _potentially_deleted:
|
||||
@@ -241,8 +256,48 @@ async def scan_user_documents(
|
||||
)
|
||||
del _potentially_deleted[doc_key]
|
||||
|
||||
# Query Qdrant for existing entry (placeholder or real)
|
||||
existing_metadata = await query_document_metadata(
|
||||
doc_id=doc_id, doc_type="note", user_id=user_id
|
||||
)
|
||||
|
||||
# Send if never indexed or modified since last index
|
||||
if indexed_at is None or modified_at > indexed_at:
|
||||
# Compare against stored modified_at (not indexed_at!)
|
||||
needs_indexing = False
|
||||
if existing_metadata is None:
|
||||
# Never seen before
|
||||
needs_indexing = True
|
||||
elif existing_metadata.get("modified_at", 0) < modified_at:
|
||||
# Document modified since last indexing
|
||||
needs_indexing = True
|
||||
elif existing_metadata.get("is_placeholder", False):
|
||||
# Placeholder exists - check if it's stale (processing may have failed)
|
||||
# Only requeue if placeholder is older than 5x scan interval
|
||||
# (Large PDFs can take 3-4 minutes to process)
|
||||
queued_at = existing_metadata.get("queued_at", 0)
|
||||
placeholder_age = time.time() - queued_at
|
||||
stale_threshold = get_settings().vector_sync_scan_interval * 5
|
||||
if placeholder_age > stale_threshold:
|
||||
logger.debug(
|
||||
f"Found stale placeholder for note {doc_id} "
|
||||
f"(age={placeholder_age:.1f}s), requeuing"
|
||||
)
|
||||
needs_indexing = True
|
||||
else:
|
||||
logger.debug(
|
||||
f"Skipping note {doc_id} with recent placeholder "
|
||||
f"(age={placeholder_age:.1f}s < {stale_threshold:.1f}s)"
|
||||
)
|
||||
|
||||
if needs_indexing:
|
||||
# Write placeholder before queuing
|
||||
await write_placeholder_point(
|
||||
doc_id=doc_id,
|
||||
doc_type="note",
|
||||
user_id=user_id,
|
||||
modified_at=modified_at,
|
||||
etag=note.get("etag", ""),
|
||||
)
|
||||
await send_stream.send(
|
||||
DocumentTask(
|
||||
user_id=user_id,
|
||||
@@ -270,7 +325,7 @@ async def scan_user_documents(
|
||||
) # Allow 1.5 scan intervals
|
||||
current_time = time.time()
|
||||
|
||||
for doc_id in indexed_docs:
|
||||
for doc_id in indexed_doc_ids:
|
||||
if doc_id not in nextcloud_doc_ids:
|
||||
doc_key = (user_id, doc_id)
|
||||
|
||||
@@ -309,7 +364,605 @@ async def scan_user_documents(
|
||||
)
|
||||
_potentially_deleted[doc_key] = current_time
|
||||
|
||||
# Scan tagged PDF files (after notes)
|
||||
# Get indexed file IDs from Qdrant (for deletion tracking)
|
||||
indexed_file_ids = set()
|
||||
if not initial_sync:
|
||||
file_scroll_result = await qdrant_client.scroll(
|
||||
collection_name=settings.get_collection_name(),
|
||||
scroll_filter=Filter(
|
||||
must=[
|
||||
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
|
||||
FieldCondition(key="doc_type", match=MatchValue(value="file")),
|
||||
]
|
||||
),
|
||||
limit=10000, # Reasonable limit for file count
|
||||
with_payload=["doc_id"],
|
||||
with_vectors=False,
|
||||
)
|
||||
|
||||
indexed_file_ids = {
|
||||
point.payload["doc_id"]
|
||||
for point in (file_scroll_result[0] or [])
|
||||
if point.payload is not None
|
||||
}
|
||||
|
||||
logger.debug(f"Found {len(indexed_file_ids)} indexed files in Qdrant")
|
||||
|
||||
# Scan for tagged PDF files
|
||||
file_count = 0
|
||||
file_queued = 0
|
||||
nextcloud_file_ids = set()
|
||||
|
||||
try:
|
||||
# Find files with vector-index tag using OCS Tags API
|
||||
settings = get_settings()
|
||||
tag_name = os.getenv("VECTOR_SYNC_PDF_TAG", "vector-index")
|
||||
# Use NextcloudClient.find_files_by_tag() which uses proper OCS API
|
||||
# and filters by PDF MIME type
|
||||
tagged_files = await nc_client.find_files_by_tag(
|
||||
tag_name, mime_type_filter="application/pdf"
|
||||
)
|
||||
|
||||
for file_info in tagged_files:
|
||||
# Files are already filtered by MIME type in find_files_by_tag()
|
||||
file_count += 1
|
||||
file_id = file_info["id"] # Use numeric file ID, not path
|
||||
file_path = file_info["path"] # Keep path for logging
|
||||
nextcloud_file_ids.add(file_id)
|
||||
|
||||
# Use last_modified timestamp if available, otherwise use current time
|
||||
modified_at = file_info.get("last_modified_timestamp", int(time.time()))
|
||||
if isinstance(file_info.get("last_modified"), str):
|
||||
# Parse RFC 2822 date format if needed
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
try:
|
||||
dt = parsedate_to_datetime(file_info["last_modified"])
|
||||
modified_at = int(dt.timestamp())
|
||||
except (ValueError, KeyError):
|
||||
pass
|
||||
|
||||
if initial_sync:
|
||||
# Send everything on first sync - write placeholder first
|
||||
await write_placeholder_point(
|
||||
doc_id=file_id,
|
||||
doc_type="file",
|
||||
user_id=user_id,
|
||||
modified_at=modified_at,
|
||||
file_path=file_path,
|
||||
)
|
||||
await send_stream.send(
|
||||
DocumentTask(
|
||||
user_id=user_id,
|
||||
doc_id=file_id, # Use numeric file ID
|
||||
doc_type="file",
|
||||
operation="index",
|
||||
modified_at=modified_at,
|
||||
file_path=file_path, # Pass file path for content retrieval
|
||||
)
|
||||
)
|
||||
file_queued += 1
|
||||
else:
|
||||
# Incremental sync: check if file exists and compare modified_at
|
||||
# If file reappeared, remove from potentially_deleted
|
||||
file_key = (user_id, file_id)
|
||||
if file_key in _potentially_deleted:
|
||||
logger.debug(
|
||||
f"File {file_path} (ID: {file_id}) reappeared, removing from deletion grace period"
|
||||
)
|
||||
del _potentially_deleted[file_key]
|
||||
|
||||
# Query Qdrant for existing entry (placeholder or real)
|
||||
existing_metadata = await query_document_metadata(
|
||||
doc_id=file_id, doc_type="file", user_id=user_id
|
||||
)
|
||||
|
||||
# Send if never indexed or modified since last index
|
||||
# Compare against stored modified_at (not indexed_at!)
|
||||
needs_indexing = False
|
||||
if existing_metadata is None:
|
||||
# Never seen before
|
||||
needs_indexing = True
|
||||
elif existing_metadata.get("modified_at", 0) < modified_at:
|
||||
# File modified since last indexing
|
||||
needs_indexing = True
|
||||
elif existing_metadata.get("is_placeholder", False):
|
||||
# Placeholder exists - check if it's stale (processing may have failed)
|
||||
# Only requeue if placeholder is older than 5x scan interval
|
||||
# (Large PDFs can take 3-4 minutes to process)
|
||||
queued_at = existing_metadata.get("queued_at", 0)
|
||||
placeholder_age = time.time() - queued_at
|
||||
stale_threshold = get_settings().vector_sync_scan_interval * 5
|
||||
if placeholder_age > stale_threshold:
|
||||
logger.debug(
|
||||
f"Found stale placeholder for file {file_path} (ID: {file_id}) "
|
||||
f"(age={placeholder_age:.1f}s), requeuing"
|
||||
)
|
||||
needs_indexing = True
|
||||
else:
|
||||
logger.debug(
|
||||
f"Skipping file {file_path} (ID: {file_id}) with recent placeholder "
|
||||
f"(age={placeholder_age:.1f}s < {stale_threshold:.1f}s)"
|
||||
)
|
||||
|
||||
if needs_indexing:
|
||||
# Write placeholder before queuing
|
||||
await write_placeholder_point(
|
||||
doc_id=file_id,
|
||||
doc_type="file",
|
||||
user_id=user_id,
|
||||
modified_at=modified_at,
|
||||
file_path=file_path,
|
||||
)
|
||||
await send_stream.send(
|
||||
DocumentTask(
|
||||
user_id=user_id,
|
||||
doc_id=file_id, # Use numeric file ID
|
||||
doc_type="file",
|
||||
operation="index",
|
||||
modified_at=modified_at,
|
||||
file_path=file_path, # Pass file path for content retrieval
|
||||
)
|
||||
)
|
||||
file_queued += 1
|
||||
|
||||
logger.info(
|
||||
f"[SCAN-{scan_id}] Found {file_count} tagged PDFs for {user_id}"
|
||||
)
|
||||
record_vector_sync_scan(file_count)
|
||||
|
||||
# Check for deleted files (not initial sync)
|
||||
if not initial_sync:
|
||||
for file_id in indexed_file_ids:
|
||||
if file_id not in nextcloud_file_ids:
|
||||
file_key = (user_id, file_id)
|
||||
|
||||
if file_key in _potentially_deleted:
|
||||
# Check if grace period elapsed
|
||||
first_missing_time = _potentially_deleted[file_key]
|
||||
time_missing = current_time - first_missing_time
|
||||
|
||||
if time_missing >= grace_period:
|
||||
# Grace period elapsed, send for deletion
|
||||
logger.info(
|
||||
f"File ID {file_id} missing for {time_missing:.1f}s "
|
||||
f"(>{grace_period:.1f}s grace period), sending deletion"
|
||||
)
|
||||
await send_stream.send(
|
||||
DocumentTask(
|
||||
user_id=user_id,
|
||||
doc_id=file_id, # Use numeric file ID
|
||||
doc_type="file",
|
||||
operation="delete",
|
||||
modified_at=0,
|
||||
)
|
||||
)
|
||||
file_queued += 1
|
||||
del _potentially_deleted[file_key]
|
||||
else:
|
||||
# First time missing, add to grace period tracking
|
||||
logger.debug(
|
||||
f"File ID {file_id} missing for first time, starting grace period"
|
||||
)
|
||||
_potentially_deleted[file_key] = current_time
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to scan tagged files for {user_id}: {e}")
|
||||
|
||||
queued += file_queued
|
||||
|
||||
# Scan News items (starred + unread)
|
||||
news_queued = 0
|
||||
try:
|
||||
news_queued = await scan_news_items(
|
||||
user_id=user_id,
|
||||
send_stream=send_stream,
|
||||
nc_client=nc_client,
|
||||
initial_sync=initial_sync,
|
||||
scan_id=scan_id,
|
||||
)
|
||||
queued += news_queued
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to scan news items for {user_id}: {e}")
|
||||
|
||||
# Scan Deck cards
|
||||
deck_queued = 0
|
||||
try:
|
||||
deck_queued = await scan_deck_cards(
|
||||
user_id=user_id,
|
||||
send_stream=send_stream,
|
||||
nc_client=nc_client,
|
||||
initial_sync=initial_sync,
|
||||
scan_id=scan_id,
|
||||
)
|
||||
queued += deck_queued
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to scan deck cards for {user_id}: {e}")
|
||||
|
||||
if queued > 0:
|
||||
logger.info(f"Sent {queued} documents for incremental sync: {user_id}")
|
||||
logger.info(
|
||||
f"Sent {queued} documents ({file_queued} files, {news_queued} news items, {deck_queued} deck cards) for incremental sync: {user_id}"
|
||||
)
|
||||
else:
|
||||
logger.debug(f"No changes detected for {user_id}")
|
||||
|
||||
|
||||
async def scan_news_items(
|
||||
user_id: str,
|
||||
send_stream: MemoryObjectSendStream[DocumentTask],
|
||||
nc_client: NextcloudClient,
|
||||
initial_sync: bool,
|
||||
scan_id: int,
|
||||
) -> int:
|
||||
"""
|
||||
Scan user's News items and queue changed items for indexing.
|
||||
|
||||
Indexes all items from the user's feeds. The News app's auto-purge
|
||||
feature (default: 200 items per feed) naturally limits the total
|
||||
number of items, making explicit filtering unnecessary.
|
||||
|
||||
Args:
|
||||
user_id: User to scan
|
||||
send_stream: Stream to send changed documents to processors
|
||||
nc_client: Authenticated Nextcloud client
|
||||
initial_sync: If True, send all documents (first-time sync)
|
||||
scan_id: Scan identifier for logging
|
||||
|
||||
Returns:
|
||||
Number of items queued for processing
|
||||
"""
|
||||
from nextcloud_mcp_server.client.news import NewsItemType
|
||||
|
||||
settings = get_settings()
|
||||
queued = 0
|
||||
|
||||
# Get indexed news item IDs from Qdrant (for deletion tracking)
|
||||
indexed_item_ids: set[str] = set()
|
||||
if not initial_sync:
|
||||
qdrant_client = await get_qdrant_client()
|
||||
scroll_result = await qdrant_client.scroll(
|
||||
collection_name=settings.get_collection_name(),
|
||||
scroll_filter=Filter(
|
||||
must=[
|
||||
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
|
||||
FieldCondition(key="doc_type", match=MatchValue(value="news_item")),
|
||||
]
|
||||
),
|
||||
with_payload=["doc_id"],
|
||||
with_vectors=False,
|
||||
limit=10000,
|
||||
)
|
||||
indexed_item_ids = {
|
||||
point.payload["doc_id"]
|
||||
for point in (scroll_result[0] or [])
|
||||
if point.payload is not None
|
||||
}
|
||||
logger.debug(f"Found {len(indexed_item_ids)} indexed news items in Qdrant")
|
||||
|
||||
# Fetch all items (News app caps at ~200 per feed via auto-purge)
|
||||
all_items = await nc_client.news.get_items(
|
||||
batch_size=-1,
|
||||
type_=NewsItemType.ALL,
|
||||
get_read=True,
|
||||
)
|
||||
logger.debug(f"[SCAN-{scan_id}] Found {len(all_items)} news items")
|
||||
|
||||
item_count = len(all_items)
|
||||
nextcloud_item_ids: set[str] = set()
|
||||
|
||||
for item in all_items:
|
||||
doc_id = str(item["id"])
|
||||
nextcloud_item_ids.add(doc_id)
|
||||
|
||||
# Use lastModified timestamp (microseconds in News API)
|
||||
modified_at = item.get("lastModified", 0)
|
||||
# Convert to seconds if needed (News API uses microseconds)
|
||||
if modified_at > 10000000000: # > year 2286 in seconds
|
||||
modified_at = modified_at // 1000000
|
||||
|
||||
if initial_sync:
|
||||
# Send everything on first sync - write placeholder first
|
||||
await write_placeholder_point(
|
||||
doc_id=doc_id,
|
||||
doc_type="news_item",
|
||||
user_id=user_id,
|
||||
modified_at=modified_at,
|
||||
)
|
||||
await send_stream.send(
|
||||
DocumentTask(
|
||||
user_id=user_id,
|
||||
doc_id=doc_id,
|
||||
doc_type="news_item",
|
||||
operation="index",
|
||||
modified_at=modified_at,
|
||||
)
|
||||
)
|
||||
queued += 1
|
||||
else:
|
||||
# Incremental sync: check if item exists and compare modified_at
|
||||
doc_key = (user_id, doc_id)
|
||||
if doc_key in _potentially_deleted:
|
||||
logger.debug(
|
||||
f"News item {doc_id} reappeared, removing from deletion grace period"
|
||||
)
|
||||
del _potentially_deleted[doc_key]
|
||||
|
||||
# Query Qdrant for existing entry
|
||||
existing_metadata = await query_document_metadata(
|
||||
doc_id=doc_id, doc_type="news_item", user_id=user_id
|
||||
)
|
||||
|
||||
needs_indexing = False
|
||||
if existing_metadata is None:
|
||||
needs_indexing = True
|
||||
elif existing_metadata.get("modified_at", 0) < modified_at:
|
||||
needs_indexing = True
|
||||
elif existing_metadata.get("is_placeholder", False):
|
||||
queued_at = existing_metadata.get("queued_at", 0)
|
||||
placeholder_age = time.time() - queued_at
|
||||
stale_threshold = settings.vector_sync_scan_interval * 5
|
||||
if placeholder_age > stale_threshold:
|
||||
logger.debug(
|
||||
f"Found stale placeholder for news item {doc_id} "
|
||||
f"(age={placeholder_age:.1f}s), requeuing"
|
||||
)
|
||||
needs_indexing = True
|
||||
|
||||
if needs_indexing:
|
||||
await write_placeholder_point(
|
||||
doc_id=doc_id,
|
||||
doc_type="news_item",
|
||||
user_id=user_id,
|
||||
modified_at=modified_at,
|
||||
)
|
||||
await send_stream.send(
|
||||
DocumentTask(
|
||||
user_id=user_id,
|
||||
doc_id=doc_id,
|
||||
doc_type="news_item",
|
||||
operation="index",
|
||||
modified_at=modified_at,
|
||||
)
|
||||
)
|
||||
queued += 1
|
||||
|
||||
logger.info(
|
||||
f"[SCAN-{scan_id}] Found {item_count} news items (starred+unread) for {user_id}"
|
||||
)
|
||||
record_vector_sync_scan(item_count)
|
||||
|
||||
# Check for deleted items (not initial sync)
|
||||
# Items become "deleted" when they are no longer starred AND become read
|
||||
if not initial_sync:
|
||||
grace_period = settings.vector_sync_scan_interval * 1.5
|
||||
current_time = time.time()
|
||||
|
||||
for doc_id in indexed_item_ids:
|
||||
if doc_id not in nextcloud_item_ids:
|
||||
doc_key = (user_id, doc_id)
|
||||
|
||||
if doc_key in _potentially_deleted:
|
||||
first_missing_time = _potentially_deleted[doc_key]
|
||||
time_missing = current_time - first_missing_time
|
||||
|
||||
if time_missing >= grace_period:
|
||||
logger.info(
|
||||
f"News item {doc_id} missing for {time_missing:.1f}s "
|
||||
f"(>{grace_period:.1f}s grace period), sending deletion"
|
||||
)
|
||||
await send_stream.send(
|
||||
DocumentTask(
|
||||
user_id=user_id,
|
||||
doc_id=doc_id,
|
||||
doc_type="news_item",
|
||||
operation="delete",
|
||||
modified_at=0,
|
||||
)
|
||||
)
|
||||
queued += 1
|
||||
del _potentially_deleted[doc_key]
|
||||
else:
|
||||
logger.debug(
|
||||
f"News item {doc_id} missing for first time, starting grace period"
|
||||
)
|
||||
_potentially_deleted[doc_key] = current_time
|
||||
|
||||
return queued
|
||||
|
||||
|
||||
async def scan_deck_cards(
|
||||
user_id: str,
|
||||
send_stream: MemoryObjectSendStream[DocumentTask],
|
||||
nc_client: NextcloudClient,
|
||||
initial_sync: bool,
|
||||
scan_id: int,
|
||||
) -> int:
|
||||
"""
|
||||
Scan user's Deck cards and queue changed cards for indexing.
|
||||
|
||||
Indexes cards from all non-archived boards and stacks.
|
||||
|
||||
Args:
|
||||
user_id: User to scan
|
||||
send_stream: Stream to send changed documents to processors
|
||||
nc_client: Authenticated Nextcloud client
|
||||
initial_sync: If True, send all documents (first-time sync)
|
||||
scan_id: Scan identifier for logging
|
||||
|
||||
Returns:
|
||||
Number of cards queued for processing
|
||||
"""
|
||||
settings = get_settings()
|
||||
queued = 0
|
||||
|
||||
# Get indexed deck card IDs from Qdrant (for deletion tracking)
|
||||
indexed_card_ids: set[str] = set()
|
||||
if not initial_sync:
|
||||
qdrant_client = await get_qdrant_client()
|
||||
scroll_result = await qdrant_client.scroll(
|
||||
collection_name=settings.get_collection_name(),
|
||||
scroll_filter=Filter(
|
||||
must=[
|
||||
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
|
||||
FieldCondition(key="doc_type", match=MatchValue(value="deck_card")),
|
||||
]
|
||||
),
|
||||
with_payload=["doc_id"],
|
||||
with_vectors=False,
|
||||
limit=10000,
|
||||
)
|
||||
indexed_card_ids = {
|
||||
point.payload["doc_id"]
|
||||
for point in (scroll_result[0] or [])
|
||||
if point.payload is not None
|
||||
}
|
||||
logger.debug(f"Found {len(indexed_card_ids)} indexed deck cards in Qdrant")
|
||||
|
||||
# Fetch all boards
|
||||
boards = await nc_client.deck.get_boards()
|
||||
logger.debug(f"[SCAN-{scan_id}] Found {len(boards)} deck boards")
|
||||
|
||||
card_count = 0
|
||||
nextcloud_card_ids: set[str] = set()
|
||||
|
||||
# Iterate through boards
|
||||
for board in boards:
|
||||
# Skip archived boards
|
||||
if board.archived:
|
||||
continue
|
||||
|
||||
# Skip deleted boards (soft delete: deletedAt > 0)
|
||||
if board.deletedAt > 0:
|
||||
logger.debug(f"[SCAN-{scan_id}] Skipping deleted board {board.id}")
|
||||
continue
|
||||
|
||||
# Get stacks for this board
|
||||
stacks = await nc_client.deck.get_stacks(board.id)
|
||||
|
||||
# Iterate through stacks
|
||||
for stack in stacks:
|
||||
# Skip if stack has no cards
|
||||
if not stack.cards:
|
||||
continue
|
||||
|
||||
# Iterate through cards in stack
|
||||
for card in stack.cards:
|
||||
# Skip archived cards
|
||||
if card.archived:
|
||||
continue
|
||||
|
||||
card_count += 1
|
||||
doc_id = str(card.id)
|
||||
nextcloud_card_ids.add(doc_id)
|
||||
|
||||
# Use lastModified timestamp if available
|
||||
modified_at = card.lastModified or 0
|
||||
|
||||
if initial_sync:
|
||||
# Send everything on first sync - write placeholder first
|
||||
await write_placeholder_point(
|
||||
doc_id=doc_id,
|
||||
doc_type="deck_card",
|
||||
user_id=user_id,
|
||||
modified_at=modified_at,
|
||||
)
|
||||
await send_stream.send(
|
||||
DocumentTask(
|
||||
user_id=user_id,
|
||||
doc_id=doc_id,
|
||||
doc_type="deck_card",
|
||||
operation="index",
|
||||
modified_at=modified_at,
|
||||
)
|
||||
)
|
||||
queued += 1
|
||||
else:
|
||||
# Incremental sync: check if card exists and compare modified_at
|
||||
doc_key = (user_id, doc_id)
|
||||
if doc_key in _potentially_deleted:
|
||||
logger.debug(
|
||||
f"Deck card {doc_id} reappeared, removing from deletion grace period"
|
||||
)
|
||||
del _potentially_deleted[doc_key]
|
||||
|
||||
# Query Qdrant for existing entry
|
||||
existing_metadata = await query_document_metadata(
|
||||
doc_id=doc_id, doc_type="deck_card", user_id=user_id
|
||||
)
|
||||
|
||||
needs_indexing = False
|
||||
if existing_metadata is None:
|
||||
needs_indexing = True
|
||||
elif existing_metadata.get("modified_at", 0) < modified_at:
|
||||
needs_indexing = True
|
||||
elif existing_metadata.get("is_placeholder", False):
|
||||
queued_at = existing_metadata.get("queued_at", 0)
|
||||
placeholder_age = time.time() - queued_at
|
||||
stale_threshold = settings.vector_sync_scan_interval * 5
|
||||
if placeholder_age > stale_threshold:
|
||||
logger.debug(
|
||||
f"Found stale placeholder for deck card {doc_id} "
|
||||
f"(age={placeholder_age:.1f}s), requeuing"
|
||||
)
|
||||
needs_indexing = True
|
||||
|
||||
if needs_indexing:
|
||||
await write_placeholder_point(
|
||||
doc_id=doc_id,
|
||||
doc_type="deck_card",
|
||||
user_id=user_id,
|
||||
modified_at=modified_at,
|
||||
)
|
||||
await send_stream.send(
|
||||
DocumentTask(
|
||||
user_id=user_id,
|
||||
doc_id=doc_id,
|
||||
doc_type="deck_card",
|
||||
operation="index",
|
||||
modified_at=modified_at,
|
||||
)
|
||||
)
|
||||
queued += 1
|
||||
|
||||
logger.info(
|
||||
f"[SCAN-{scan_id}] Found {card_count} deck cards (non-archived) for {user_id}"
|
||||
)
|
||||
record_vector_sync_scan(card_count)
|
||||
|
||||
# Check for deleted cards (not initial sync)
|
||||
if not initial_sync:
|
||||
grace_period = settings.vector_sync_scan_interval * 1.5
|
||||
current_time = time.time()
|
||||
|
||||
for doc_id in indexed_card_ids:
|
||||
if doc_id not in nextcloud_card_ids:
|
||||
doc_key = (user_id, doc_id)
|
||||
|
||||
if doc_key in _potentially_deleted:
|
||||
first_missing_time = _potentially_deleted[doc_key]
|
||||
time_missing = current_time - first_missing_time
|
||||
|
||||
if time_missing >= grace_period:
|
||||
logger.info(
|
||||
f"Deck card {doc_id} missing for {time_missing:.1f}s "
|
||||
f"(>{grace_period:.1f}s grace period), sending deletion"
|
||||
)
|
||||
await send_stream.send(
|
||||
DocumentTask(
|
||||
user_id=user_id,
|
||||
doc_id=doc_id,
|
||||
doc_type="deck_card",
|
||||
operation="delete",
|
||||
modified_at=0,
|
||||
)
|
||||
)
|
||||
queued += 1
|
||||
del _potentially_deleted[doc_key]
|
||||
else:
|
||||
logger.debug(
|
||||
f"Deck card {doc_id} missing for first time, starting grace period"
|
||||
)
|
||||
_potentially_deleted[doc_key] = current_time
|
||||
|
||||
return queued
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.42.0"
|
||||
version = "0.52.0"
|
||||
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
||||
authors = [
|
||||
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
||||
@@ -10,7 +10,7 @@ license = {text = "AGPL-3.0-only"}
|
||||
requires-python = ">=3.11"
|
||||
keywords = ["nextcloud", "mcp", "model-context-protocol", "llm", "ai", "claude", "webdav", "caldav", "carddav"]
|
||||
dependencies = [
|
||||
"mcp[cli] (>=1.21,<1.22)",
|
||||
"mcp[cli] (>=1.23,<1.24)",
|
||||
"httpx (>=0.28.1,<0.29.0)",
|
||||
"pillow (>=10.3.0,<12.0.0)", # Compatible with fastembed
|
||||
"icalendar (>=6.0.0,<7.0.0)",
|
||||
@@ -36,6 +36,11 @@ dependencies = [
|
||||
"python-json-logger>=3.2.0", # Structured JSON logging
|
||||
"jinja2>=3.1.6",
|
||||
"langchain-text-splitters>=1.0.0",
|
||||
"markdownify>=0.14.1", # HTML to Markdown conversion for News items
|
||||
"pymupdf>=1.26.6",
|
||||
"pymupdf4llm>=0.2.2",
|
||||
"pymupdf-layout>=1.26.6",
|
||||
"openai>=2.8.1",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
@@ -96,6 +101,7 @@ extend-select = ["I"]
|
||||
|
||||
[tool.uv.sources]
|
||||
caldav = { git = "https://github.com/cbcoutinho/caldav", branch = "feature/httpx" }
|
||||
qdrant-client = { git = "https://github.com/cbcoutinho/qdrant-client", branch = "fix/fusion-score-threshold" }
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.9.4,<0.10.0"]
|
||||
@@ -123,6 +129,7 @@ dev = [
|
||||
|
||||
[project.scripts]
|
||||
nextcloud-mcp-server = "nextcloud_mcp_server.cli:run"
|
||||
smithery-main = "nextcloud_mcp_server.smithery_main:main"
|
||||
|
||||
[[tool.uv.index]]
|
||||
name = "testpypi"
|
||||
|
||||
@@ -4,5 +4,11 @@
|
||||
"config:best-practices",
|
||||
"mergeConfidence:all-badges"
|
||||
],
|
||||
"dependencyDashboard": true
|
||||
"dependencyDashboard": true,
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackageNames": ["pillow"],
|
||||
"allowedVersions": "<12.0.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Smithery configuration for Nextcloud MCP Server
|
||||
# See: https://smithery.ai/docs/build/configuration
|
||||
# ADR-016: Stateless deployment mode for multi-user public Nextcloud instances
|
||||
|
||||
runtime: "container"
|
||||
|
||||
build:
|
||||
dockerfile: "Dockerfile.smithery"
|
||||
dockerBuildPath: "."
|
||||
|
||||
startCommand:
|
||||
type: "http"
|
||||
configSchema:
|
||||
type: "object"
|
||||
required:
|
||||
- "nextcloud_url"
|
||||
- "username"
|
||||
- "app_password"
|
||||
properties:
|
||||
nextcloud_url:
|
||||
type: "string"
|
||||
title: "Nextcloud URL"
|
||||
description: "Your Nextcloud instance URL (e.g., https://cloud.example.com). Must be publicly accessible."
|
||||
pattern: "^https?://.+"
|
||||
username:
|
||||
type: "string"
|
||||
title: "Username"
|
||||
description: "Your Nextcloud username"
|
||||
minLength: 1
|
||||
app_password:
|
||||
type: "string"
|
||||
title: "App Password"
|
||||
description: "Nextcloud app password. Generate at Settings > Security > App passwords. Do NOT use your main password."
|
||||
minLength: 1
|
||||
exampleConfig:
|
||||
nextcloud_url: "https://cloud.example.com"
|
||||
username: "alice"
|
||||
app_password: "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
|
||||
@@ -480,3 +480,222 @@ def create_mock_table_row_ocs_response(
|
||||
ocs_response = {"ocs": {"meta": {"status": "ok"}, "data": row_data}}
|
||||
|
||||
return create_mock_response(status_code=200, json_data=ocs_response)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# News Mock Response Helpers
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def create_mock_news_folders_response(
|
||||
folders: list[dict] | None = None,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for News folders list.
|
||||
|
||||
Args:
|
||||
folders: List of folder dictionaries. If None, returns empty list.
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with folders data
|
||||
"""
|
||||
if folders is None:
|
||||
folders = []
|
||||
|
||||
return create_mock_response(status_code=200, json_data={"folders": folders})
|
||||
|
||||
|
||||
def create_mock_news_folder_response(
|
||||
folder_id: int = 1,
|
||||
name: str = "Test Folder",
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for a News folder.
|
||||
|
||||
Args:
|
||||
folder_id: Folder ID
|
||||
name: Folder name
|
||||
**kwargs: Additional folder fields
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with folder data
|
||||
"""
|
||||
folder_data = {
|
||||
"id": folder_id,
|
||||
"name": name,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
return create_mock_response(status_code=200, json_data={"folders": [folder_data]})
|
||||
|
||||
|
||||
def create_mock_news_feeds_response(
|
||||
feeds: list[dict] | None = None,
|
||||
starred_count: int = 0,
|
||||
newest_item_id: int | None = None,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for News feeds list.
|
||||
|
||||
Args:
|
||||
feeds: List of feed dictionaries. If None, returns empty list.
|
||||
starred_count: Number of starred items
|
||||
newest_item_id: ID of newest item
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with feeds data
|
||||
"""
|
||||
if feeds is None:
|
||||
feeds = []
|
||||
|
||||
data = {
|
||||
"feeds": feeds,
|
||||
"starredCount": starred_count,
|
||||
}
|
||||
if newest_item_id is not None:
|
||||
data["newestItemId"] = newest_item_id
|
||||
|
||||
return create_mock_response(status_code=200, json_data=data)
|
||||
|
||||
|
||||
def create_mock_news_feed_response(
|
||||
feed_id: int = 1,
|
||||
url: str = "https://example.com/feed",
|
||||
title: str = "Test Feed",
|
||||
favicon_link: str | None = None,
|
||||
folder_id: int | None = None,
|
||||
unread_count: int = 0,
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for a News feed.
|
||||
|
||||
Args:
|
||||
feed_id: Feed ID
|
||||
url: Feed URL
|
||||
title: Feed title
|
||||
favicon_link: Favicon URL
|
||||
folder_id: Parent folder ID
|
||||
unread_count: Number of unread items
|
||||
**kwargs: Additional feed fields
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with feed data
|
||||
"""
|
||||
feed_data = {
|
||||
"id": feed_id,
|
||||
"url": url,
|
||||
"title": title,
|
||||
"faviconLink": favicon_link,
|
||||
"folderId": folder_id,
|
||||
"unreadCount": unread_count,
|
||||
"link": kwargs.get("link", "https://example.com"),
|
||||
"added": kwargs.get("added", 1700000000),
|
||||
"updateErrorCount": kwargs.get("updateErrorCount", 0),
|
||||
"lastUpdateError": kwargs.get("lastUpdateError"),
|
||||
**{
|
||||
k: v
|
||||
for k, v in kwargs.items()
|
||||
if k not in ["link", "added", "updateErrorCount", "lastUpdateError"]
|
||||
},
|
||||
}
|
||||
|
||||
return create_mock_response(status_code=200, json_data={"feeds": [feed_data]})
|
||||
|
||||
|
||||
def create_mock_news_items_response(
|
||||
items: list[dict] | None = None,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for News items list.
|
||||
|
||||
Args:
|
||||
items: List of item dictionaries. If None, returns empty list.
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with items data
|
||||
"""
|
||||
if items is None:
|
||||
items = []
|
||||
|
||||
return create_mock_response(status_code=200, json_data={"items": items})
|
||||
|
||||
|
||||
def create_mock_news_item(
|
||||
item_id: int = 1,
|
||||
feed_id: int = 1,
|
||||
title: str = "Test Article",
|
||||
body: str = "<p>Test content</p>",
|
||||
url: str = "https://example.com/article",
|
||||
author: str | None = "Test Author",
|
||||
pub_date: int = 1700000000,
|
||||
unread: bool = True,
|
||||
starred: bool = False,
|
||||
**kwargs,
|
||||
) -> dict:
|
||||
"""Create a mock News item dictionary.
|
||||
|
||||
Args:
|
||||
item_id: Item ID
|
||||
feed_id: Parent feed ID
|
||||
title: Article title
|
||||
body: Article body (HTML)
|
||||
url: Article URL
|
||||
author: Article author
|
||||
pub_date: Publication timestamp (Unix)
|
||||
unread: Whether item is unread
|
||||
starred: Whether item is starred
|
||||
**kwargs: Additional item fields
|
||||
|
||||
Returns:
|
||||
Item dictionary
|
||||
"""
|
||||
return {
|
||||
"id": item_id,
|
||||
"feedId": feed_id,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"url": url,
|
||||
"author": author,
|
||||
"pubDate": pub_date,
|
||||
"unread": unread,
|
||||
"starred": starred,
|
||||
"guid": kwargs.get("guid", f"guid-{item_id}"),
|
||||
"guidHash": kwargs.get("guidHash", f"hash-{item_id}"),
|
||||
"lastModified": kwargs.get("lastModified", pub_date * 1000000),
|
||||
"enclosureLink": kwargs.get("enclosureLink"),
|
||||
"enclosureMime": kwargs.get("enclosureMime"),
|
||||
"fingerprint": kwargs.get("fingerprint", f"fp-{item_id}"),
|
||||
"contentHash": kwargs.get("contentHash", f"ch-{item_id}"),
|
||||
**{
|
||||
k: v
|
||||
for k, v in kwargs.items()
|
||||
if k
|
||||
not in [
|
||||
"guid",
|
||||
"guidHash",
|
||||
"lastModified",
|
||||
"enclosureLink",
|
||||
"enclosureMime",
|
||||
"fingerprint",
|
||||
"contentHash",
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def create_mock_news_status_response(
|
||||
version: str = "25.0.0",
|
||||
warnings: dict | None = None,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for News status.
|
||||
|
||||
Args:
|
||||
version: News app version
|
||||
warnings: Warning messages
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with status data
|
||||
"""
|
||||
data = {
|
||||
"version": version,
|
||||
"warnings": warnings or {},
|
||||
}
|
||||
|
||||
return create_mock_response(status_code=200, json_data=data)
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
"""Unit tests for NewsClient API methods."""
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.client.news import NewsClient, NewsItemType
|
||||
from tests.client.conftest import (
|
||||
create_mock_error_response,
|
||||
create_mock_news_feed_response,
|
||||
create_mock_news_feeds_response,
|
||||
create_mock_news_folder_response,
|
||||
create_mock_news_folders_response,
|
||||
create_mock_news_item,
|
||||
create_mock_news_items_response,
|
||||
create_mock_news_status_response,
|
||||
create_mock_response,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as unit tests
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Folder Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_news_api_get_folders(mocker):
|
||||
"""Test that get_folders correctly parses the API response."""
|
||||
mock_response = create_mock_news_folders_response(
|
||||
folders=[
|
||||
{"id": 1, "name": "Tech"},
|
||||
{"id": 2, "name": "News"},
|
||||
]
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
folders = await client.get_folders()
|
||||
|
||||
assert len(folders) == 2
|
||||
assert folders[0]["id"] == 1
|
||||
assert folders[0]["name"] == "Tech"
|
||||
assert folders[1]["name"] == "News"
|
||||
|
||||
mock_make_request.assert_called_once_with("GET", "/apps/news/api/v1-3/folders")
|
||||
|
||||
|
||||
async def test_news_api_create_folder(mocker):
|
||||
"""Test that create_folder correctly creates a folder."""
|
||||
mock_response = create_mock_news_folder_response(folder_id=3, name="New Folder")
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
folder = await client.create_folder(name="New Folder")
|
||||
|
||||
assert folder["id"] == 3
|
||||
assert folder["name"] == "New Folder"
|
||||
|
||||
mock_make_request.assert_called_once_with(
|
||||
"POST", "/apps/news/api/v1-3/folders", json={"name": "New Folder"}
|
||||
)
|
||||
|
||||
|
||||
async def test_news_api_rename_folder(mocker):
|
||||
"""Test that rename_folder makes the correct API call."""
|
||||
mock_response = create_mock_response(status_code=200, json_data={})
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
await client.rename_folder(folder_id=1, name="Renamed")
|
||||
|
||||
mock_make_request.assert_called_once_with(
|
||||
"PUT", "/apps/news/api/v1-3/folders/1", json={"name": "Renamed"}
|
||||
)
|
||||
|
||||
|
||||
async def test_news_api_delete_folder(mocker):
|
||||
"""Test that delete_folder makes the correct API call."""
|
||||
mock_response = create_mock_response(status_code=200, json_data={})
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
await client.delete_folder(folder_id=1)
|
||||
|
||||
mock_make_request.assert_called_once_with("DELETE", "/apps/news/api/v1-3/folders/1")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Feed Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_news_api_get_feeds(mocker):
|
||||
"""Test that get_feeds correctly parses the API response."""
|
||||
mock_response = create_mock_news_feeds_response(
|
||||
feeds=[
|
||||
{"id": 1, "url": "https://example.com/feed1", "title": "Feed 1"},
|
||||
{"id": 2, "url": "https://example.com/feed2", "title": "Feed 2"},
|
||||
],
|
||||
starred_count=5,
|
||||
newest_item_id=100,
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
result = await client.get_feeds()
|
||||
|
||||
assert len(result["feeds"]) == 2
|
||||
assert result["starredCount"] == 5
|
||||
assert result["newestItemId"] == 100
|
||||
|
||||
mock_make_request.assert_called_once_with("GET", "/apps/news/api/v1-3/feeds")
|
||||
|
||||
|
||||
async def test_news_api_create_feed(mocker):
|
||||
"""Test that create_feed correctly creates a feed."""
|
||||
mock_response = create_mock_news_feed_response(
|
||||
feed_id=10, url="https://example.com/new-feed", title="New Feed"
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
feed = await client.create_feed(url="https://example.com/new-feed")
|
||||
|
||||
assert feed["id"] == 10
|
||||
assert feed["url"] == "https://example.com/new-feed"
|
||||
|
||||
mock_make_request.assert_called_once_with(
|
||||
"POST",
|
||||
"/apps/news/api/v1-3/feeds",
|
||||
json={"url": "https://example.com/new-feed"},
|
||||
)
|
||||
|
||||
|
||||
async def test_news_api_create_feed_with_folder(mocker):
|
||||
"""Test that create_feed correctly creates a feed in a folder."""
|
||||
mock_response = create_mock_news_feed_response(
|
||||
feed_id=10, url="https://example.com/feed", folder_id=5
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
feed = await client.create_feed(url="https://example.com/feed", folder_id=5)
|
||||
|
||||
assert feed["folderId"] == 5
|
||||
|
||||
mock_make_request.assert_called_once_with(
|
||||
"POST",
|
||||
"/apps/news/api/v1-3/feeds",
|
||||
json={"url": "https://example.com/feed", "folderId": 5},
|
||||
)
|
||||
|
||||
|
||||
async def test_news_api_delete_feed(mocker):
|
||||
"""Test that delete_feed makes the correct API call."""
|
||||
mock_response = create_mock_response(status_code=200, json_data={})
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
await client.delete_feed(feed_id=10)
|
||||
|
||||
mock_make_request.assert_called_once_with("DELETE", "/apps/news/api/v1-3/feeds/10")
|
||||
|
||||
|
||||
async def test_news_api_move_feed(mocker):
|
||||
"""Test that move_feed makes the correct API call."""
|
||||
mock_response = create_mock_response(status_code=200, json_data={})
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
await client.move_feed(feed_id=10, folder_id=5)
|
||||
|
||||
mock_make_request.assert_called_once_with(
|
||||
"POST", "/apps/news/api/v1-3/feeds/10/move", json={"folderId": 5}
|
||||
)
|
||||
|
||||
|
||||
async def test_news_api_rename_feed(mocker):
|
||||
"""Test that rename_feed makes the correct API call."""
|
||||
mock_response = create_mock_response(status_code=200, json_data={})
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
await client.rename_feed(feed_id=10, title="Renamed Feed")
|
||||
|
||||
mock_make_request.assert_called_once_with(
|
||||
"POST",
|
||||
"/apps/news/api/v1-3/feeds/10/rename",
|
||||
json={"feedTitle": "Renamed Feed"},
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Item Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_news_api_get_items(mocker):
|
||||
"""Test that get_items correctly parses the API response."""
|
||||
items = [
|
||||
create_mock_news_item(item_id=1, title="Article 1"),
|
||||
create_mock_news_item(item_id=2, title="Article 2"),
|
||||
]
|
||||
mock_response = create_mock_news_items_response(items=items)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
result = await client.get_items()
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["title"] == "Article 1"
|
||||
assert result[1]["title"] == "Article 2"
|
||||
|
||||
# Verify default parameters
|
||||
call_args = mock_make_request.call_args
|
||||
assert call_args[0] == ("GET", "/apps/news/api/v1-3/items")
|
||||
params = call_args[1]["params"]
|
||||
assert params["batchSize"] == 50
|
||||
assert params["type"] == NewsItemType.ALL
|
||||
|
||||
|
||||
async def test_news_api_get_items_starred(mocker):
|
||||
"""Test that get_items with STARRED type filters correctly."""
|
||||
items = [create_mock_news_item(item_id=1, starred=True)]
|
||||
mock_response = create_mock_news_items_response(items=items)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
result = await client.get_items(type_=NewsItemType.STARRED)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["starred"] is True
|
||||
|
||||
call_args = mock_make_request.call_args
|
||||
params = call_args[1]["params"]
|
||||
assert params["type"] == NewsItemType.STARRED
|
||||
|
||||
|
||||
async def test_news_api_get_items_unread_only(mocker):
|
||||
"""Test that get_items with get_read=False filters correctly."""
|
||||
items = [create_mock_news_item(item_id=1, unread=True)]
|
||||
mock_response = create_mock_news_items_response(items=items)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
result = await client.get_items(get_read=False)
|
||||
|
||||
assert len(result) == 1
|
||||
|
||||
call_args = mock_make_request.call_args
|
||||
params = call_args[1]["params"]
|
||||
assert params["getRead"] == "false"
|
||||
|
||||
|
||||
async def test_news_api_get_item(mocker):
|
||||
"""Test that get_item fetches all items and filters for the requested ID."""
|
||||
# Create multiple items, only one should be returned
|
||||
items = [
|
||||
create_mock_news_item(item_id=100, title="Other Item 1"),
|
||||
create_mock_news_item(item_id=123, title="Single Item"),
|
||||
create_mock_news_item(item_id=200, title="Other Item 2"),
|
||||
]
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_get_items = mocker.patch.object(NewsClient, "get_items", return_value=items)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
result = await client.get_item(item_id=123)
|
||||
|
||||
assert result["id"] == 123
|
||||
assert result["title"] == "Single Item"
|
||||
|
||||
# Verify it fetched all items with correct params
|
||||
mock_get_items.assert_called_once_with(batch_size=-1, get_read=True)
|
||||
|
||||
|
||||
async def test_news_api_get_item_not_found(mocker):
|
||||
"""Test that get_item raises ValueError when item not found."""
|
||||
items = [
|
||||
create_mock_news_item(item_id=100, title="Item 1"),
|
||||
create_mock_news_item(item_id=200, title="Item 2"),
|
||||
]
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mocker.patch.object(NewsClient, "get_items", return_value=items)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
|
||||
with pytest.raises(ValueError, match="Item 999 not found"):
|
||||
await client.get_item(item_id=999)
|
||||
|
||||
|
||||
async def test_news_api_get_updated_items(mocker):
|
||||
"""Test that get_updated_items correctly calls the updated endpoint."""
|
||||
items = [create_mock_news_item(item_id=1)]
|
||||
mock_response = create_mock_news_items_response(items=items)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
result = await client.get_updated_items(last_modified=1700000000)
|
||||
|
||||
assert len(result) == 1
|
||||
|
||||
call_args = mock_make_request.call_args
|
||||
assert call_args[0] == ("GET", "/apps/news/api/v1-3/items/updated")
|
||||
params = call_args[1]["params"]
|
||||
assert params["lastModified"] == 1700000000
|
||||
|
||||
|
||||
async def test_news_api_mark_item_read(mocker):
|
||||
"""Test that mark_item_read makes the correct API call."""
|
||||
mock_response = create_mock_response(status_code=200, json_data={})
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
await client.mark_item_read(item_id=123)
|
||||
|
||||
mock_make_request.assert_called_once_with(
|
||||
"POST", "/apps/news/api/v1-3/items/123/read"
|
||||
)
|
||||
|
||||
|
||||
async def test_news_api_mark_item_unread(mocker):
|
||||
"""Test that mark_item_unread makes the correct API call."""
|
||||
mock_response = create_mock_response(status_code=200, json_data={})
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
await client.mark_item_unread(item_id=123)
|
||||
|
||||
mock_make_request.assert_called_once_with(
|
||||
"POST", "/apps/news/api/v1-3/items/123/unread"
|
||||
)
|
||||
|
||||
|
||||
async def test_news_api_star_item(mocker):
|
||||
"""Test that star_item makes the correct API call."""
|
||||
mock_response = create_mock_response(status_code=200, json_data={})
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
await client.star_item(item_id=123)
|
||||
|
||||
mock_make_request.assert_called_once_with(
|
||||
"POST", "/apps/news/api/v1-3/items/123/star"
|
||||
)
|
||||
|
||||
|
||||
async def test_news_api_unstar_item(mocker):
|
||||
"""Test that unstar_item makes the correct API call."""
|
||||
mock_response = create_mock_response(status_code=200, json_data={})
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
await client.unstar_item(item_id=123)
|
||||
|
||||
mock_make_request.assert_called_once_with(
|
||||
"POST", "/apps/news/api/v1-3/items/123/unstar"
|
||||
)
|
||||
|
||||
|
||||
async def test_news_api_mark_items_read_multiple(mocker):
|
||||
"""Test that mark_items_read makes the correct API call for multiple items."""
|
||||
mock_response = create_mock_response(status_code=200, json_data={})
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
await client.mark_items_read(item_ids=[1, 2, 3])
|
||||
|
||||
mock_make_request.assert_called_once_with(
|
||||
"POST", "/apps/news/api/v1-3/items/read/multiple", json={"itemIds": [1, 2, 3]}
|
||||
)
|
||||
|
||||
|
||||
async def test_news_api_star_items_multiple(mocker):
|
||||
"""Test that star_items makes the correct API call for multiple items."""
|
||||
mock_response = create_mock_response(status_code=200, json_data={})
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
await client.star_items(item_ids=[1, 2, 3])
|
||||
|
||||
mock_make_request.assert_called_once_with(
|
||||
"POST", "/apps/news/api/v1-3/items/star/multiple", json={"itemIds": [1, 2, 3]}
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Status Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_news_api_get_status(mocker):
|
||||
"""Test that get_status correctly parses the API response."""
|
||||
mock_response = create_mock_news_status_response(
|
||||
version="25.0.0",
|
||||
warnings={"improperlyConfiguredCron": False},
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
status = await client.get_status()
|
||||
|
||||
assert status["version"] == "25.0.0"
|
||||
assert "warnings" in status
|
||||
|
||||
mock_make_request.assert_called_once_with("GET", "/apps/news/api/v1-3/status")
|
||||
|
||||
|
||||
async def test_news_api_get_version(mocker):
|
||||
"""Test that get_version correctly parses the API response."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200, json_data={"version": "25.0.0"}
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
version = await client.get_version()
|
||||
|
||||
assert version == "25.0.0"
|
||||
|
||||
mock_make_request.assert_called_once_with("GET", "/apps/news/api/v1-3/version")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Error Handling Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_news_api_create_folder_conflict(mocker):
|
||||
"""Test that create_folder raises HTTPStatusError on 409 conflict."""
|
||||
error_response = create_mock_error_response(409, "Folder name already exists")
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(NewsClient, "_make_request")
|
||||
mock_make_request.side_effect = httpx.HTTPStatusError(
|
||||
"409 Conflict",
|
||||
request=httpx.Request("POST", "http://test.local"),
|
||||
response=error_response,
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||
await client.create_folder(name="Existing Folder")
|
||||
|
||||
assert excinfo.value.response.status_code == 409
|
||||
|
||||
|
||||
async def test_news_api_delete_feed_not_found(mocker):
|
||||
"""Test that delete_feed raises HTTPStatusError on 404."""
|
||||
error_response = create_mock_error_response(404, "Feed not found")
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(NewsClient, "_make_request")
|
||||
mock_make_request.side_effect = httpx.HTTPStatusError(
|
||||
"404 Not Found",
|
||||
request=httpx.Request("DELETE", "http://test.local"),
|
||||
response=error_response,
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||
await client.delete_feed(feed_id=999999)
|
||||
|
||||
assert excinfo.value.response.status_code == 404
|
||||
|
||||
|
||||
async def test_news_api_create_feed_invalid_url(mocker):
|
||||
"""Test that create_feed raises HTTPStatusError on 422 for invalid URL."""
|
||||
error_response = create_mock_error_response(422, "Invalid feed URL")
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(NewsClient, "_make_request")
|
||||
mock_make_request.side_effect = httpx.HTTPStatusError(
|
||||
"422 Unprocessable Entity",
|
||||
request=httpx.Request("POST", "http://test.local"),
|
||||
response=error_response,
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||
await client.create_feed(url="not-a-valid-url")
|
||||
|
||||
assert excinfo.value.response.status_code == 422
|
||||
@@ -9,7 +9,6 @@ import pytest
|
||||
from httpx import HTTPStatusError
|
||||
from mcp import ClientSession
|
||||
from mcp.client.session import RequestContext
|
||||
from mcp.client.sse import sse_client
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
from mcp.types import ElicitRequestParams, ElicitResult, ErrorData
|
||||
|
||||
@@ -114,6 +113,7 @@ async def create_mcp_client_session(
|
||||
token: str | None = None,
|
||||
client_name: str = "MCP",
|
||||
elicitation_callback: Any = None,
|
||||
sampling_callback: Any = None,
|
||||
) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""
|
||||
Factory function to create an MCP client session with proper lifecycle management.
|
||||
@@ -133,6 +133,8 @@ async def create_mcp_client_session(
|
||||
client_name: Client name for logging (e.g., "OAuth MCP (Playwright)")
|
||||
elicitation_callback: Optional callback for handling elicitation requests.
|
||||
Should match signature: async def callback(context: RequestContext, params: ElicitRequestParams) -> ElicitResult | ErrorData
|
||||
sampling_callback: Optional callback for handling sampling (LLM generation) requests.
|
||||
Should match signature: async def callback(context: RequestContext, params: CreateMessageRequestParams) -> CreateMessageResult | ErrorData
|
||||
|
||||
Yields:
|
||||
Initialized MCP ClientSession
|
||||
@@ -156,52 +158,10 @@ async def create_mcp_client_session(
|
||||
_,
|
||||
):
|
||||
async with ClientSession(
|
||||
read_stream, write_stream, elicitation_callback=elicitation_callback
|
||||
) as session:
|
||||
await session.initialize()
|
||||
logger.info(f"{client_name} client session initialized successfully")
|
||||
yield session
|
||||
|
||||
# Cleanup happens automatically in LIFO order - no exception suppression needed
|
||||
logger.debug(f"{client_name} client session cleaned up successfully")
|
||||
|
||||
|
||||
async def create_mcp_client_session_sse(
|
||||
url: str,
|
||||
token: str | None = None,
|
||||
client_name: str = "MCP",
|
||||
elicitation_callback: Any = None,
|
||||
) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""
|
||||
Factory function to create an MCP client session using SSE transport.
|
||||
|
||||
Similar to create_mcp_client_session but uses SSE transport instead of streamable-http.
|
||||
Uses native async context managers to ensure correct LIFO cleanup order.
|
||||
|
||||
Args:
|
||||
url: MCP server URL (e.g., "http://localhost:8000/sse")
|
||||
token: Optional OAuth access token for Bearer authentication
|
||||
client_name: Client name for logging (e.g., "Basic MCP (SSE)")
|
||||
elicitation_callback: Optional callback for handling elicitation requests
|
||||
|
||||
Yields:
|
||||
Initialized MCP ClientSession
|
||||
|
||||
Note:
|
||||
SSE transport is being deprecated in favor of streamable-http.
|
||||
This function exists for compatibility testing only.
|
||||
"""
|
||||
logger.info(f"Creating SSE client for {client_name}")
|
||||
|
||||
# Prepare headers with OAuth token if provided
|
||||
headers = {"Authorization": f"Bearer {token}"} if token else None
|
||||
|
||||
# Use native async with - Python ensures LIFO cleanup
|
||||
# Cleanup order will be: ClientSession.__aexit__ -> sse_client.__aexit__
|
||||
# Note: sse_client yields only (read_stream, write_stream), not 3 values like streamablehttp_client
|
||||
async with sse_client(url, headers=headers) as (read_stream, write_stream):
|
||||
async with ClientSession(
|
||||
read_stream, write_stream, elicitation_callback=elicitation_callback
|
||||
read_stream,
|
||||
write_stream,
|
||||
elicitation_callback=elicitation_callback,
|
||||
sampling_callback=sampling_callback,
|
||||
) as session:
|
||||
await session.initialize()
|
||||
logger.info(f"{client_name} client session initialized successfully")
|
||||
@@ -249,18 +209,10 @@ async def nc_client(anyio_backend) -> AsyncGenerator[NextcloudClient, Any]:
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_mcp_client(anyio_backend) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""
|
||||
Fixture to create an MCP client session for integration tests using SSE transport.
|
||||
Fixture to create an MCP client session for integration tests using streamable-http.
|
||||
|
||||
Uses anyio pytest plugin for proper async fixture handling.
|
||||
|
||||
Note: SSE transport is being deprecated. This fixture uses SSE for compatibility testing.
|
||||
"""
|
||||
|
||||
# async for session in create_mcp_client_session_sse(
|
||||
# url="http://localhost:8000/sse", client_name="Basic MCP (SSE)"
|
||||
# ):
|
||||
# yield session
|
||||
|
||||
async for session in create_mcp_client_session(
|
||||
url="http://localhost:8000/mcp",
|
||||
client_name="Basic MCP (HTTP)",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Pytest configuration for integration tests.
|
||||
|
||||
This conftest.py provides hooks and fixtures specific to integration tests,
|
||||
including the --provider flag for RAG tests.
|
||||
"""
|
||||
|
||||
# Valid provider names
|
||||
VALID_PROVIDERS = ["openai", "ollama", "anthropic", "bedrock"]
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
"""Add --provider command line option for RAG tests."""
|
||||
parser.addoption(
|
||||
"--provider",
|
||||
action="store",
|
||||
default=None,
|
||||
choices=VALID_PROVIDERS,
|
||||
help="LLM provider for RAG tests: openai, ollama, anthropic, bedrock",
|
||||
)
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
"""Configure custom markers."""
|
||||
config.addinivalue_line(
|
||||
"markers", "rag: mark test as RAG integration test (requires --provider flag)"
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
[
|
||||
{
|
||||
"id": "nc-manual-001",
|
||||
"query": "What is two-factor authentication and how does it protect my Nextcloud account?",
|
||||
"ground_truth": "Two-factor authentication (2FA) protects your Nextcloud account by requiring two different proofs of identity - something you know (like a password) and something you have (like a code from your phone). The first factor is typically a password, and the second can be a text message or code generated on your phone.",
|
||||
"expected_topics": ["two-factor authentication", "2FA", "password", "security"],
|
||||
"difficulty": "easy"
|
||||
},
|
||||
{
|
||||
"id": "nc-manual-002",
|
||||
"query": "How do file quotas work in Nextcloud when sharing files?",
|
||||
"ground_truth": "When you share files with other users, the shared files count against the original share owner's quota. When you share a folder and allow others to upload files, all uploaded and edited files count against your quota. Re-shared files still count against the original share owner's quota. Deleted files in trash don't count against quotas until trash exceeds 50% of quota.",
|
||||
"expected_topics": ["quota", "sharing", "files", "storage"],
|
||||
"difficulty": "medium"
|
||||
},
|
||||
{
|
||||
"id": "nc-manual-003",
|
||||
"query": "How do I install the Nextcloud desktop sync client on Linux?",
|
||||
"ground_truth": "Linux users must follow instructions on the download page to add the appropriate repository for their Linux distribution, install the signing key, and use their package managers to install the desktop sync client. Linux users also need a password manager enabled, such as GNOME Keyring or KWallet, so the sync client can login automatically.",
|
||||
"expected_topics": ["Linux", "desktop client", "installation", "package manager", "GNOME Keyring", "KWallet"],
|
||||
"difficulty": "medium"
|
||||
},
|
||||
{
|
||||
"id": "nc-manual-004",
|
||||
"query": "What are the system requirements for the Nextcloud desktop client on Windows?",
|
||||
"ground_truth": "The Nextcloud desktop sync client requires Windows 10 or later, 64-bits only.",
|
||||
"expected_topics": ["Windows", "system requirements", "desktop client"],
|
||||
"difficulty": "easy"
|
||||
},
|
||||
{
|
||||
"id": "nc-manual-005",
|
||||
"query": "How do I use client applications with two-factor authentication enabled?",
|
||||
"ground_truth": "Once you have enabled 2FA, your clients will no longer be able to connect with just your password unless they also support two-factor authentication. To solve this, you should generate device-specific passwords for them. This is managed through the connected browsers and devices settings.",
|
||||
"expected_topics": ["2FA", "client applications", "device-specific passwords", "app passwords"],
|
||||
"difficulty": "medium"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Provider fixtures for integration tests.
|
||||
|
||||
This module provides pytest fixtures that configure LLM providers based on
|
||||
an explicit --provider flag. Supports OpenAI, Ollama, Anthropic, and Bedrock.
|
||||
|
||||
Usage:
|
||||
pytest tests/integration/test_rag.py --provider=openai
|
||||
pytest tests/integration/test_rag.py --provider=ollama
|
||||
pytest tests/integration/test_rag.py --provider=anthropic
|
||||
pytest tests/integration/test_rag.py --provider=bedrock
|
||||
|
||||
Environment Variables by Provider:
|
||||
|
||||
OpenAI:
|
||||
OPENAI_API_KEY: API key (required)
|
||||
OPENAI_BASE_URL: Base URL override (e.g., "https://models.github.ai/inference")
|
||||
OPENAI_EMBEDDING_MODEL: Embedding model (default: "text-embedding-3-small")
|
||||
OPENAI_GENERATION_MODEL: Generation model (default: "gpt-4o-mini")
|
||||
|
||||
Ollama:
|
||||
OLLAMA_BASE_URL: API URL (required, e.g., "http://localhost:11434")
|
||||
OLLAMA_EMBEDDING_MODEL: Embedding model (default: "nomic-embed-text")
|
||||
OLLAMA_GENERATION_MODEL: Generation model (default: "llama3.2:1b")
|
||||
|
||||
Anthropic:
|
||||
ANTHROPIC_API_KEY: API key (required)
|
||||
ANTHROPIC_GENERATION_MODEL: Model (default: "claude-3-haiku-20240307")
|
||||
|
||||
Bedrock:
|
||||
AWS_REGION: AWS region (required)
|
||||
BEDROCK_EMBEDDING_MODEL: Embedding model ID
|
||||
BEDROCK_GENERATION_MODEL: Generation model ID
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.providers.base import Provider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Valid provider names (must match conftest.py)
|
||||
VALID_PROVIDERS = ["openai", "ollama", "anthropic", "bedrock"]
|
||||
|
||||
|
||||
async def create_generation_provider(provider_name: str) -> Provider:
|
||||
"""Create a provider configured for text generation.
|
||||
|
||||
Args:
|
||||
provider_name: One of "openai", "ollama", "anthropic", "bedrock"
|
||||
|
||||
Returns:
|
||||
Provider instance configured for generation
|
||||
|
||||
Raises:
|
||||
ValueError: If provider_name is invalid or required env vars missing
|
||||
"""
|
||||
if provider_name == "openai":
|
||||
from nextcloud_mcp_server.providers.openai import OpenAIProvider
|
||||
|
||||
api_key = os.getenv("OPENAI_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError("OPENAI_API_KEY environment variable required")
|
||||
|
||||
base_url = os.getenv("OPENAI_BASE_URL")
|
||||
generation_model = os.getenv("OPENAI_GENERATION_MODEL", "gpt-4o-mini")
|
||||
|
||||
# GitHub Models API requires model name prefix
|
||||
if base_url and "models.github.ai" in base_url:
|
||||
if not generation_model.startswith("openai/"):
|
||||
generation_model = f"openai/{generation_model}"
|
||||
|
||||
provider = OpenAIProvider(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
embedding_model=None, # Generation only
|
||||
generation_model=generation_model,
|
||||
)
|
||||
logger.info(f"Created OpenAI generation provider: model={generation_model}")
|
||||
return provider
|
||||
|
||||
elif provider_name == "ollama":
|
||||
from nextcloud_mcp_server.providers.ollama import OllamaProvider
|
||||
|
||||
base_url = os.getenv("OLLAMA_BASE_URL")
|
||||
if not base_url:
|
||||
raise ValueError("OLLAMA_BASE_URL environment variable required")
|
||||
|
||||
generation_model = os.getenv("OLLAMA_GENERATION_MODEL", "llama3.2:1b")
|
||||
|
||||
provider = OllamaProvider(
|
||||
base_url=base_url,
|
||||
embedding_model=None, # Generation only
|
||||
generation_model=generation_model,
|
||||
)
|
||||
logger.info(f"Created Ollama generation provider: model={generation_model}")
|
||||
return provider
|
||||
|
||||
elif provider_name == "anthropic":
|
||||
from nextcloud_mcp_server.providers.anthropic import AnthropicProvider
|
||||
|
||||
api_key = os.getenv("ANTHROPIC_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError("ANTHROPIC_API_KEY environment variable required")
|
||||
|
||||
generation_model = os.getenv(
|
||||
"ANTHROPIC_GENERATION_MODEL", "claude-3-haiku-20240307"
|
||||
)
|
||||
|
||||
provider = AnthropicProvider(
|
||||
api_key=api_key,
|
||||
generation_model=generation_model,
|
||||
)
|
||||
logger.info(f"Created Anthropic generation provider: model={generation_model}")
|
||||
return provider
|
||||
|
||||
elif provider_name == "bedrock":
|
||||
from nextcloud_mcp_server.providers.bedrock import BedrockProvider
|
||||
|
||||
region = os.getenv("AWS_REGION")
|
||||
if not region:
|
||||
raise ValueError("AWS_REGION environment variable required")
|
||||
|
||||
generation_model = os.getenv("BEDROCK_GENERATION_MODEL")
|
||||
if not generation_model:
|
||||
raise ValueError("BEDROCK_GENERATION_MODEL environment variable required")
|
||||
|
||||
provider = BedrockProvider(
|
||||
region=region,
|
||||
embedding_model=None, # Generation only
|
||||
generation_model=generation_model,
|
||||
)
|
||||
logger.info(f"Created Bedrock generation provider: model={generation_model}")
|
||||
return provider
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown provider: {provider_name}. Valid: {VALID_PROVIDERS}")
|
||||
|
||||
|
||||
async def create_embedding_provider(provider_name: str) -> Provider:
|
||||
"""Create a provider configured for embeddings.
|
||||
|
||||
Args:
|
||||
provider_name: One of "openai", "ollama", "bedrock"
|
||||
(Anthropic does not support embeddings)
|
||||
|
||||
Returns:
|
||||
Provider instance configured for embeddings
|
||||
|
||||
Raises:
|
||||
ValueError: If provider_name is invalid, doesn't support embeddings,
|
||||
or required env vars missing
|
||||
"""
|
||||
if provider_name == "anthropic":
|
||||
raise ValueError("Anthropic does not support embeddings")
|
||||
|
||||
if provider_name == "openai":
|
||||
from nextcloud_mcp_server.providers.openai import OpenAIProvider
|
||||
|
||||
api_key = os.getenv("OPENAI_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError("OPENAI_API_KEY environment variable required")
|
||||
|
||||
base_url = os.getenv("OPENAI_BASE_URL")
|
||||
embedding_model = os.getenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small")
|
||||
|
||||
# GitHub Models API requires model name prefix
|
||||
if base_url and "models.github.ai" in base_url:
|
||||
if not embedding_model.startswith("openai/"):
|
||||
embedding_model = f"openai/{embedding_model}"
|
||||
|
||||
provider = OpenAIProvider(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
embedding_model=embedding_model,
|
||||
generation_model=None, # Embeddings only
|
||||
)
|
||||
logger.info(f"Created OpenAI embedding provider: model={embedding_model}")
|
||||
return provider
|
||||
|
||||
elif provider_name == "ollama":
|
||||
from nextcloud_mcp_server.providers.ollama import OllamaProvider
|
||||
|
||||
base_url = os.getenv("OLLAMA_BASE_URL")
|
||||
if not base_url:
|
||||
raise ValueError("OLLAMA_BASE_URL environment variable required")
|
||||
|
||||
embedding_model = os.getenv("OLLAMA_EMBEDDING_MODEL", "nomic-embed-text")
|
||||
|
||||
provider = OllamaProvider(
|
||||
base_url=base_url,
|
||||
embedding_model=embedding_model,
|
||||
generation_model=None, # Embeddings only
|
||||
)
|
||||
logger.info(f"Created Ollama embedding provider: model={embedding_model}")
|
||||
return provider
|
||||
|
||||
elif provider_name == "bedrock":
|
||||
from nextcloud_mcp_server.providers.bedrock import BedrockProvider
|
||||
|
||||
region = os.getenv("AWS_REGION")
|
||||
if not region:
|
||||
raise ValueError("AWS_REGION environment variable required")
|
||||
|
||||
embedding_model = os.getenv("BEDROCK_EMBEDDING_MODEL")
|
||||
if not embedding_model:
|
||||
raise ValueError("BEDROCK_EMBEDDING_MODEL environment variable required")
|
||||
|
||||
provider = BedrockProvider(
|
||||
region=region,
|
||||
embedding_model=embedding_model,
|
||||
generation_model=None, # Embeddings only
|
||||
)
|
||||
logger.info(f"Created Bedrock embedding provider: model={embedding_model}")
|
||||
return provider
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown provider: {provider_name}. Valid: {VALID_PROVIDERS}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pytest Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def provider_name(request) -> str:
|
||||
"""Get the provider name from --provider flag.
|
||||
|
||||
Raises pytest.skip if --provider not specified.
|
||||
"""
|
||||
name = request.config.getoption("--provider")
|
||||
if not name:
|
||||
pytest.skip("--provider flag required (openai, ollama, anthropic, bedrock)")
|
||||
return name
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def generation_provider(provider_name: str) -> AsyncGenerator[Provider, None]:
|
||||
"""Fixture providing a generation-capable provider.
|
||||
|
||||
Requires --provider flag to be set.
|
||||
"""
|
||||
provider = await create_generation_provider(provider_name)
|
||||
yield provider
|
||||
await provider.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def embedding_provider(provider_name: str) -> AsyncGenerator[Provider, None]:
|
||||
"""Fixture providing an embedding-capable provider.
|
||||
|
||||
Requires --provider flag to be set.
|
||||
Note: Anthropic does not support embeddings - test will fail if used.
|
||||
"""
|
||||
if provider_name == "anthropic":
|
||||
pytest.skip("Anthropic does not support embeddings")
|
||||
|
||||
provider = await create_embedding_provider(provider_name)
|
||||
yield provider
|
||||
await provider.close()
|
||||
@@ -0,0 +1,120 @@
|
||||
"""MCP sampling support for integration tests.
|
||||
|
||||
This module provides utilities to enable real LLM-based sampling in integration tests
|
||||
using any provider that supports text generation (OpenAI, Ollama, Anthropic, Bedrock).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from mcp import types
|
||||
from mcp.client.session import ClientSession, RequestContext
|
||||
|
||||
from nextcloud_mcp_server.providers.base import Provider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_sampling_callback(provider: Provider):
|
||||
"""Factory to create a sampling callback using any generation-capable provider.
|
||||
|
||||
The callback conforms to MCP's SamplingFnT protocol and can be passed
|
||||
to ClientSession for handling sampling requests from the server.
|
||||
|
||||
Args:
|
||||
provider: Any Provider instance that supports generation
|
||||
(supports_generation=True)
|
||||
|
||||
Returns:
|
||||
Async callback function for MCP sampling
|
||||
|
||||
Raises:
|
||||
ValueError: If provider doesn't support generation
|
||||
|
||||
Example:
|
||||
```python
|
||||
from nextcloud_mcp_server.providers import get_provider
|
||||
|
||||
provider = get_provider() # Auto-detect from environment
|
||||
if provider.supports_generation:
|
||||
callback = create_sampling_callback(provider)
|
||||
|
||||
async for session in create_mcp_client_session(
|
||||
url="http://localhost:8000/mcp",
|
||||
sampling_callback=callback,
|
||||
):
|
||||
# Session now supports sampling
|
||||
pass
|
||||
```
|
||||
"""
|
||||
if not provider.supports_generation:
|
||||
raise ValueError(
|
||||
f"Provider {provider.__class__.__name__} does not support generation"
|
||||
)
|
||||
|
||||
# Get model name for logging (provider-specific attribute)
|
||||
model_name = (
|
||||
getattr(provider, "generation_model", None) or provider.__class__.__name__
|
||||
)
|
||||
|
||||
async def sampling_callback(
|
||||
context: RequestContext[ClientSession, Any],
|
||||
params: types.CreateMessageRequestParams,
|
||||
) -> types.CreateMessageResult | types.ErrorData:
|
||||
"""Handle sampling requests using the configured provider."""
|
||||
logger.debug(f"Sampling callback invoked with {len(params.messages)} messages")
|
||||
|
||||
# Extract messages and build prompt
|
||||
messages_text = []
|
||||
for msg in params.messages:
|
||||
if hasattr(msg.content, "text"):
|
||||
role_prefix = "User" if msg.role == "user" else "Assistant"
|
||||
messages_text.append(f"{role_prefix}: {msg.content.text}")
|
||||
|
||||
prompt = "\n\n".join(messages_text)
|
||||
|
||||
# Add system prompt if provided
|
||||
if params.systemPrompt:
|
||||
prompt = f"System: {params.systemPrompt}\n\n{prompt}"
|
||||
|
||||
logger.debug(f"Generating response for prompt ({len(prompt)} chars)")
|
||||
|
||||
try:
|
||||
# Generate response using provider
|
||||
# Note: temperature is typically hardcoded in providers at 0.7
|
||||
response = await provider.generate(
|
||||
prompt=prompt,
|
||||
max_tokens=params.maxTokens,
|
||||
)
|
||||
|
||||
logger.info(f"Sampling completed: {len(response)} chars from {model_name}")
|
||||
|
||||
return types.CreateMessageResult(
|
||||
role="assistant",
|
||||
content=types.TextContent(type="text", text=response),
|
||||
model=model_name,
|
||||
stopReason="endTurn",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Generation failed ({provider.__class__.__name__}): {e}")
|
||||
return types.ErrorData(
|
||||
code=types.INTERNAL_ERROR,
|
||||
message=f"Generation failed: {e!s}",
|
||||
)
|
||||
|
||||
return sampling_callback
|
||||
|
||||
|
||||
def create_openai_sampling_callback(provider: "Provider"):
|
||||
"""Factory to create a sampling callback using OpenAI provider.
|
||||
|
||||
This is a backward-compatible wrapper around create_sampling_callback().
|
||||
Prefer using create_sampling_callback() directly for new code.
|
||||
|
||||
Args:
|
||||
provider: OpenAIProvider instance configured with a generation model
|
||||
|
||||
Returns:
|
||||
Async callback function for MCP sampling
|
||||
"""
|
||||
return create_sampling_callback(provider)
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Integration tests for Deck card vector search.
|
||||
|
||||
These tests validate that Deck cards are properly indexed and searchable
|
||||
via semantic search.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.smoke]
|
||||
|
||||
|
||||
async def test_deck_card_semantic_search(nc_mcp_client, nc_client, mocker):
|
||||
"""Test that Deck cards can be indexed and searched via semantic search.
|
||||
|
||||
This test:
|
||||
1. Creates a Deck board with a card
|
||||
2. Manually triggers indexing (simulates vector sync)
|
||||
3. Performs semantic search filtering by deck_card doc_type
|
||||
4. Verifies the card is found in results
|
||||
"""
|
||||
# Skip if vector sync is not enabled
|
||||
settings_response = await nc_mcp_client.call_tool("nc_get_vector_sync_status", {})
|
||||
if settings_response.isError:
|
||||
pytest.skip("Vector sync not enabled")
|
||||
|
||||
# Create a test board
|
||||
board_title = "Test Board for Vector Search"
|
||||
board = await nc_client.deck.create_board(title=board_title, color="ff0000")
|
||||
|
||||
try:
|
||||
# Create a stack for the board
|
||||
stack = await nc_client.deck.create_stack(
|
||||
board_id=board.id, title="Test Stack", order=0
|
||||
)
|
||||
|
||||
# Create a test card with searchable content
|
||||
card_title = "Machine Learning Project Plan"
|
||||
card_description = """
|
||||
# ML Project Outline
|
||||
|
||||
## Phase 1: Data Collection
|
||||
- Gather training data from multiple sources
|
||||
- Clean and preprocess the dataset
|
||||
|
||||
## Phase 2: Model Training
|
||||
- Experiment with different neural network architectures
|
||||
- Use gradient descent optimization
|
||||
|
||||
## Phase 3: Deployment
|
||||
- Deploy model to production environment
|
||||
- Monitor performance metrics
|
||||
"""
|
||||
card = await nc_client.deck.create_card(
|
||||
board_id=board.id,
|
||||
stack_id=stack.id,
|
||||
title=card_title,
|
||||
description=card_description,
|
||||
)
|
||||
|
||||
# Note: In a real integration test with vector sync enabled,
|
||||
# we would wait for the background scanner to index the card.
|
||||
# For now, we'll test the scanning function directly if needed.
|
||||
|
||||
# TODO: Once vector sync is running in test environment,
|
||||
# add actual semantic search test here
|
||||
# For now, just verify the card was created successfully
|
||||
assert card.id is not None
|
||||
assert card.title == card_title
|
||||
assert card.description == card_description
|
||||
|
||||
# Test semantic search with deck_card filter
|
||||
# Note: This will only work if vector sync is actually running
|
||||
# and the card has been indexed
|
||||
try:
|
||||
search_result = await nc_mcp_client.call_tool(
|
||||
"nc_semantic_search",
|
||||
{
|
||||
"query": "machine learning neural networks",
|
||||
"doc_types": ["deck_card"],
|
||||
"limit": 10,
|
||||
},
|
||||
)
|
||||
|
||||
# If vector sync is working, we should find the card
|
||||
if not search_result.isError:
|
||||
data = search_result.structuredContent
|
||||
results = data.get("results", [])
|
||||
|
||||
# Check if our card is in the results
|
||||
found_card = any(
|
||||
r.get("doc_type") == "deck_card" and r.get("title") == card_title
|
||||
for r in results
|
||||
)
|
||||
|
||||
# Log result for debugging
|
||||
if found_card:
|
||||
print("✓ Successfully found Deck card in vector search")
|
||||
else:
|
||||
print(
|
||||
"⚠ Deck card not found in search (may need time for indexing)"
|
||||
)
|
||||
except Exception as e:
|
||||
# If search fails, it might be because indexing hasn't happened yet
|
||||
print(f"⚠ Semantic search failed (indexing may not be complete): {e}")
|
||||
|
||||
finally:
|
||||
# Cleanup: delete the board
|
||||
try:
|
||||
await nc_client.deck.delete_board(board.id)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to cleanup test board: {e}")
|
||||
|
||||
|
||||
async def test_deck_card_appears_in_cross_app_search(nc_mcp_client, nc_client):
|
||||
"""Test that Deck cards appear in cross-app semantic search (no doc_type filter).
|
||||
|
||||
This verifies that when searching without specifying doc_types,
|
||||
Deck cards are included in the results alongside notes, files, etc.
|
||||
"""
|
||||
# Skip if vector sync is not enabled
|
||||
settings_response = await nc_mcp_client.call_tool("nc_get_vector_sync_status", {})
|
||||
if settings_response.isError:
|
||||
pytest.skip("Vector sync not enabled")
|
||||
|
||||
# Create a test board with a distinctive card
|
||||
board_title = "Cross-App Search Test Board"
|
||||
board = await nc_client.deck.create_board(title=board_title, color="00ff00")
|
||||
|
||||
try:
|
||||
# Create a stack for the board
|
||||
stack = await nc_client.deck.create_stack(
|
||||
board_id=board.id, title="Test Stack", order=0
|
||||
)
|
||||
|
||||
# Use a very distinctive term to make it easy to find
|
||||
unique_term = "xylophone_banana_unicorn_test"
|
||||
_card = await nc_client.deck.create_card(
|
||||
board_id=board.id,
|
||||
stack_id=stack.id,
|
||||
title=f"Test Card with {unique_term}",
|
||||
description=f"This card contains the unique search term: {unique_term}",
|
||||
)
|
||||
|
||||
# Test cross-app search (no doc_type filter)
|
||||
try:
|
||||
search_result = await nc_mcp_client.call_tool(
|
||||
"nc_semantic_search",
|
||||
{
|
||||
"query": unique_term,
|
||||
"limit": 20,
|
||||
},
|
||||
)
|
||||
|
||||
if not search_result.isError:
|
||||
data = search_result.structuredContent
|
||||
results = data.get("results", [])
|
||||
|
||||
# Check if deck_card appears in cross-app results
|
||||
deck_cards_found = [
|
||||
r for r in results if r.get("doc_type") == "deck_card"
|
||||
]
|
||||
|
||||
if deck_cards_found:
|
||||
print(
|
||||
f"✓ Found {len(deck_cards_found)} Deck card(s) in cross-app search"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
"⚠ No Deck cards in cross-app search (may need time for indexing)"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"⚠ Cross-app search failed: {e}")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
try:
|
||||
await nc_client.deck.delete_board(board.id)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to cleanup test board: {e}")
|
||||
|
||||
|
||||
async def test_deck_card_chunk_context(nc_client):
|
||||
"""Test that Deck card chunk context can be fetched for visualization.
|
||||
|
||||
This test validates that the vector viz UI can display Deck card previews
|
||||
by fetching the chunk context via the context expansion module.
|
||||
"""
|
||||
from nextcloud_mcp_server.search.context import get_chunk_with_context
|
||||
|
||||
# Create board, stack, and card
|
||||
board = await nc_client.deck.create_board(title="Test Board", color="ff0000")
|
||||
|
||||
try:
|
||||
stack = await nc_client.deck.create_stack(
|
||||
board_id=board.id, title="Test Stack", order=0
|
||||
)
|
||||
|
||||
card_title = "Test Card for Context Expansion"
|
||||
card_description = "This is a test description that should be fetched by the context expansion module when displaying chunk previews in the vector visualization UI."
|
||||
|
||||
card = await nc_client.deck.create_card(
|
||||
board_id=board.id,
|
||||
stack_id=stack.id,
|
||||
title=card_title,
|
||||
description=card_description,
|
||||
)
|
||||
|
||||
# Fetch chunk context (simulates viz UI request)
|
||||
# The chunk spans the title, so start=0 and end=len(card_title)
|
||||
context = await get_chunk_with_context(
|
||||
nc_client=nc_client,
|
||||
user_id=nc_client.username,
|
||||
doc_id=card.id,
|
||||
doc_type="deck_card",
|
||||
chunk_start=0,
|
||||
chunk_end=len(card_title),
|
||||
context_chars=100,
|
||||
)
|
||||
|
||||
# Verify context was fetched successfully
|
||||
assert context is not None, "Chunk context should not be None"
|
||||
assert card_title in context.chunk_text, (
|
||||
f"Card title '{card_title}' should be in chunk_text"
|
||||
)
|
||||
|
||||
# Verify context includes description
|
||||
assert card_description[:50] in context.after_context, (
|
||||
"Card description should be in after_context"
|
||||
)
|
||||
|
||||
print(f"✓ Successfully fetched chunk context for Deck card {card.id}")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
try:
|
||||
await nc_client.deck.delete_board(board.id)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to cleanup test board: {e}")
|
||||
@@ -0,0 +1,361 @@
|
||||
"""Integration tests for PDF document indexing and semantic search.
|
||||
|
||||
These tests validate the complete PDF processing flow:
|
||||
1. Process PDF with PyMuPDFProcessor
|
||||
2. Chunk extracted text with page numbers
|
||||
3. Index chunks into Qdrant with metadata
|
||||
4. Perform semantic search on PDF content
|
||||
5. Verify page numbers and metadata are preserved
|
||||
"""
|
||||
|
||||
import pymupdf
|
||||
import pytest
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
from qdrant_client.models import Distance, PointStruct, VectorParams
|
||||
|
||||
from nextcloud_mcp_server.document_processors.pymupdf import PyMuPDFProcessor
|
||||
from nextcloud_mcp_server.embedding import SimpleEmbeddingProvider
|
||||
from nextcloud_mcp_server.vector.document_chunker import (
|
||||
ChunkWithPosition,
|
||||
RecursiveCharacterTextSplitter,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def create_test_pdf() -> bytes:
|
||||
"""Create a small test PDF with multiple pages."""
|
||||
doc = pymupdf.open()
|
||||
|
||||
# Page 1: Introduction
|
||||
page1 = doc.new_page(width=595, height=842) # A4 size
|
||||
page1.insert_text(
|
||||
(50, 50),
|
||||
"Nextcloud Administration Guide\n\n"
|
||||
"Chapter 1: Introduction\n\n"
|
||||
"Nextcloud is a self-hosted file sharing and collaboration platform. "
|
||||
"It provides secure file storage, sharing, and synchronization across devices. "
|
||||
"This guide covers installation, configuration, and maintenance of Nextcloud.",
|
||||
)
|
||||
|
||||
# Page 2: Installation
|
||||
page2 = doc.new_page(width=595, height=842)
|
||||
page2.insert_text(
|
||||
(50, 50),
|
||||
"Chapter 2: Installation\n\n"
|
||||
"System Requirements:\n"
|
||||
"- PHP 8.0 or higher\n"
|
||||
"- MySQL 8.0 or MariaDB 10.5\n"
|
||||
"- Apache or Nginx web server\n\n"
|
||||
"Installation steps:\n"
|
||||
"1. Download Nextcloud package\n"
|
||||
"2. Extract to web server directory\n"
|
||||
"3. Configure database connection\n"
|
||||
"4. Run installation wizard",
|
||||
)
|
||||
|
||||
# Page 3: Configuration
|
||||
page3 = doc.new_page(width=595, height=842)
|
||||
page3.insert_text(
|
||||
(50, 50),
|
||||
"Chapter 3: Configuration\n\n"
|
||||
"Database Configuration:\n"
|
||||
"Edit config/config.php to set database parameters. "
|
||||
"Configure database host, username, password, and database name. "
|
||||
"For optimal performance, use MySQL or MariaDB.\n\n"
|
||||
"Security Settings:\n"
|
||||
"Enable HTTPS, configure trusted domains, and set up firewall rules.",
|
||||
)
|
||||
|
||||
# Convert to bytes
|
||||
pdf_bytes = doc.tobytes()
|
||||
doc.close()
|
||||
|
||||
return pdf_bytes
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def simple_embedding_provider():
|
||||
"""Simple in-process embedding provider for testing."""
|
||||
return SimpleEmbeddingProvider(dimension=384)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def qdrant_test_client():
|
||||
"""Qdrant client for testing (in-memory)."""
|
||||
client = AsyncQdrantClient(":memory:")
|
||||
yield client
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_collection(qdrant_test_client: AsyncQdrantClient):
|
||||
"""Create test collection in Qdrant."""
|
||||
collection_name = "test_pdf_indexing"
|
||||
|
||||
# Create collection
|
||||
await qdrant_test_client.create_collection(
|
||||
collection_name=collection_name,
|
||||
vectors_config=VectorParams(size=384, distance=Distance.COSINE),
|
||||
)
|
||||
|
||||
yield collection_name
|
||||
|
||||
# Cleanup
|
||||
try:
|
||||
await qdrant_test_client.delete_collection(collection_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pymupdf_processor():
|
||||
"""PyMuPDF processor for testing (without image extraction)."""
|
||||
return PyMuPDFProcessor(extract_images=False)
|
||||
|
||||
|
||||
async def test_pymupdf_processor_extracts_text_and_metadata(pymupdf_processor):
|
||||
"""Test PyMuPDF processor extracts text and metadata from PDF."""
|
||||
pdf_bytes = create_test_pdf()
|
||||
|
||||
result = await pymupdf_processor.process(
|
||||
content=pdf_bytes,
|
||||
content_type="application/pdf",
|
||||
filename="test-admin-guide.pdf",
|
||||
)
|
||||
|
||||
# Verify result structure
|
||||
assert result.success is True
|
||||
assert result.processor == "pymupdf"
|
||||
assert result.text is not None
|
||||
assert len(result.text) > 0
|
||||
|
||||
# Verify extracted text contains expected content
|
||||
assert "Nextcloud Administration Guide" in result.text
|
||||
assert "Chapter 1: Introduction" in result.text
|
||||
assert "Chapter 2: Installation" in result.text
|
||||
assert "Chapter 3: Configuration" in result.text
|
||||
assert "PHP 8.0 or higher" in result.text
|
||||
assert "MySQL" in result.text
|
||||
|
||||
# Verify metadata
|
||||
assert result.metadata is not None
|
||||
assert result.metadata["page_count"] == 3
|
||||
assert result.metadata["filename"] == "test-admin-guide.pdf"
|
||||
assert "format" in result.metadata
|
||||
|
||||
|
||||
async def test_document_chunker_preserves_page_numbers():
|
||||
"""Test that document chunker can handle chunks with page number metadata."""
|
||||
# Create chunks with page numbers
|
||||
chunks = [
|
||||
ChunkWithPosition(
|
||||
text="Chapter 1 content on page 1",
|
||||
start_offset=0,
|
||||
end_offset=28,
|
||||
page_number=1,
|
||||
),
|
||||
ChunkWithPosition(
|
||||
text="Chapter 2 content on page 2",
|
||||
start_offset=29,
|
||||
end_offset=57,
|
||||
page_number=2,
|
||||
),
|
||||
ChunkWithPosition(
|
||||
text="Chapter 3 content on page 3",
|
||||
start_offset=58,
|
||||
end_offset=86,
|
||||
page_number=3,
|
||||
),
|
||||
]
|
||||
|
||||
# Verify page numbers are preserved
|
||||
assert chunks[0].page_number == 1
|
||||
assert chunks[1].page_number == 2
|
||||
assert chunks[2].page_number == 3
|
||||
|
||||
|
||||
async def test_pdf_indexing_and_search_flow(
|
||||
pymupdf_processor: PyMuPDFProcessor,
|
||||
qdrant_test_client: AsyncQdrantClient,
|
||||
test_collection: str,
|
||||
simple_embedding_provider: SimpleEmbeddingProvider,
|
||||
):
|
||||
"""Test complete PDF indexing and semantic search flow."""
|
||||
|
||||
# Step 1: Process PDF with PyMuPDF
|
||||
pdf_bytes = create_test_pdf()
|
||||
result = await pymupdf_processor.process(
|
||||
content=pdf_bytes,
|
||||
content_type="application/pdf",
|
||||
filename="/Documents/admin-guide.pdf",
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.metadata["page_count"] == 3
|
||||
|
||||
# Step 2: Chunk the extracted text
|
||||
# Note: In real implementation, we'd track which chunk came from which page
|
||||
# For this test, we'll simulate by creating chunks manually
|
||||
splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
|
||||
chunks = splitter.split_text(result.text)
|
||||
|
||||
assert len(chunks) > 0
|
||||
|
||||
# Step 3: Index chunks into Qdrant with PDF metadata
|
||||
points = []
|
||||
for idx, chunk_text in enumerate(chunks):
|
||||
embedding = await simple_embedding_provider.embed(chunk_text)
|
||||
|
||||
# Simulate page number assignment (in real implementation, this would be tracked)
|
||||
# For simplicity, assign page based on content
|
||||
page_number = 1
|
||||
if "Chapter 2" in chunk_text or "Installation" in chunk_text:
|
||||
page_number = 2
|
||||
elif "Chapter 3" in chunk_text or "Configuration" in chunk_text:
|
||||
page_number = 3
|
||||
|
||||
points.append(
|
||||
PointStruct(
|
||||
id=idx,
|
||||
vector=embedding,
|
||||
payload={
|
||||
"user_id": "admin",
|
||||
"doc_id": "/Documents/admin-guide.pdf",
|
||||
"doc_type": "file",
|
||||
"title": "Nextcloud Administration Guide",
|
||||
"file_path": "/Documents/admin-guide.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"page_number": page_number,
|
||||
"page_count": result.metadata["page_count"],
|
||||
"chunk_index": idx,
|
||||
"excerpt": chunk_text[:200],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
await qdrant_test_client.upsert(
|
||||
collection_name=test_collection, points=points, wait=True
|
||||
)
|
||||
|
||||
# Step 4: Perform semantic search for installation instructions
|
||||
query = "how to install Nextcloud system requirements"
|
||||
query_embedding = await simple_embedding_provider.embed(query)
|
||||
|
||||
response = await qdrant_test_client.query_points(
|
||||
collection_name=test_collection,
|
||||
query=query_embedding,
|
||||
limit=3,
|
||||
score_threshold=0.0,
|
||||
)
|
||||
|
||||
# Verify search results
|
||||
assert len(response.points) > 0
|
||||
|
||||
# Top result should be from installation chapter (page 2)
|
||||
top_result = response.points[0]
|
||||
assert top_result.payload["doc_type"] == "file"
|
||||
assert top_result.payload["file_path"] == "/Documents/admin-guide.pdf"
|
||||
assert (
|
||||
"Installation" in top_result.payload["excerpt"]
|
||||
or top_result.payload["page_number"] == 2
|
||||
)
|
||||
|
||||
# Verify page number is preserved
|
||||
assert top_result.payload["page_number"] in [1, 2, 3]
|
||||
assert top_result.payload["page_count"] == 3
|
||||
|
||||
# Step 5: Search for configuration
|
||||
query = "database configuration settings MySQL"
|
||||
query_embedding = await simple_embedding_provider.embed(query)
|
||||
|
||||
response = await qdrant_test_client.query_points(
|
||||
collection_name=test_collection,
|
||||
query=query_embedding,
|
||||
limit=3,
|
||||
score_threshold=0.0,
|
||||
)
|
||||
|
||||
assert len(response.points) > 0
|
||||
|
||||
# Should find configuration chapter (page 3)
|
||||
found_config = any(
|
||||
"Configuration" in r.payload["excerpt"] or r.payload["page_number"] == 3
|
||||
for r in response.points[:2]
|
||||
)
|
||||
assert found_config
|
||||
|
||||
|
||||
async def test_pdf_search_with_filters(
|
||||
pymupdf_processor: PyMuPDFProcessor,
|
||||
qdrant_test_client: AsyncQdrantClient,
|
||||
test_collection: str,
|
||||
simple_embedding_provider: SimpleEmbeddingProvider,
|
||||
):
|
||||
"""Test PDF search with metadata filters."""
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
# Process and index PDF
|
||||
pdf_bytes = create_test_pdf()
|
||||
result = await pymupdf_processor.process(
|
||||
content=pdf_bytes,
|
||||
content_type="application/pdf",
|
||||
filename="/Documents/admin-guide.pdf",
|
||||
)
|
||||
|
||||
splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
|
||||
chunks = splitter.split_text(result.text)
|
||||
|
||||
# Index with metadata
|
||||
points = []
|
||||
for idx, chunk_text in enumerate(chunks):
|
||||
embedding = await simple_embedding_provider.embed(chunk_text)
|
||||
|
||||
points.append(
|
||||
PointStruct(
|
||||
id=idx,
|
||||
vector=embedding,
|
||||
payload={
|
||||
"user_id": "admin",
|
||||
"doc_id": "/Documents/admin-guide.pdf",
|
||||
"doc_type": "file",
|
||||
"mime_type": "application/pdf",
|
||||
"excerpt": chunk_text[:200],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
await qdrant_test_client.upsert(
|
||||
collection_name=test_collection, points=points, wait=True
|
||||
)
|
||||
|
||||
# Search with filter for PDFs only
|
||||
query = "Nextcloud installation"
|
||||
query_embedding = await simple_embedding_provider.embed(query)
|
||||
|
||||
response = await qdrant_test_client.query_points(
|
||||
collection_name=test_collection,
|
||||
query=query_embedding,
|
||||
query_filter=Filter(
|
||||
must=[FieldCondition(key="doc_type", match=MatchValue(value="file"))]
|
||||
),
|
||||
limit=3,
|
||||
)
|
||||
|
||||
# All results should be from file documents
|
||||
assert len(response.points) > 0
|
||||
for result in response.points:
|
||||
assert result.payload["doc_type"] == "file"
|
||||
assert result.payload["mime_type"] == "application/pdf"
|
||||
|
||||
|
||||
async def test_pymupdf_health_check(pymupdf_processor: PyMuPDFProcessor):
|
||||
"""Test PyMuPDF processor health check."""
|
||||
is_healthy = await pymupdf_processor.health_check()
|
||||
assert is_healthy is True
|
||||
|
||||
|
||||
async def test_pymupdf_supports_pdf_mime_type(pymupdf_processor: PyMuPDFProcessor):
|
||||
"""Test PyMuPDF processor declares PDF support."""
|
||||
assert "application/pdf" in pymupdf_processor.supported_mime_types
|
||||
assert pymupdf_processor.name == "pymupdf"
|
||||
@@ -0,0 +1,403 @@
|
||||
"""Integration tests for RAG pipeline with multiple LLM providers.
|
||||
|
||||
These tests validate the complete semantic search and MCP sampling flow using:
|
||||
1. MCP server's built-in semantic search (embeddings handled server-side)
|
||||
2. MCP sampling for answer generation (any generation-capable provider)
|
||||
3. Pre-indexed Nextcloud User Manual as the knowledge base
|
||||
|
||||
Usage:
|
||||
# Run with OpenAI (including GitHub Models API)
|
||||
OPENAI_API_KEY=... pytest tests/integration/test_rag.py --provider=openai -v
|
||||
|
||||
# Run with Ollama
|
||||
OLLAMA_BASE_URL=http://localhost:11434 OLLAMA_GENERATION_MODEL=llama3.2:1b \\
|
||||
pytest tests/integration/test_rag.py --provider=ollama -v
|
||||
|
||||
# Run with Anthropic
|
||||
ANTHROPIC_API_KEY=... pytest tests/integration/test_rag.py --provider=anthropic -v
|
||||
|
||||
# Run with AWS Bedrock
|
||||
AWS_REGION=us-east-1 BEDROCK_GENERATION_MODEL=... \\
|
||||
pytest tests/integration/test_rag.py --provider=bedrock -v
|
||||
|
||||
Environment Variables:
|
||||
See tests/integration/provider_fixtures.py for provider-specific configuration.
|
||||
RAG_MANUAL_PATH: Path to manual PDF in Nextcloud (default: "Nextcloud Manual.pdf")
|
||||
|
||||
Prerequisites:
|
||||
- Nextcloud User Manual PDF uploaded to Nextcloud
|
||||
- VECTOR_SYNC_ENABLED=true on the MCP server
|
||||
- Provider-specific environment variables set
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
from mcp import ClientSession
|
||||
|
||||
from nextcloud_mcp_server.providers.base import Provider
|
||||
from tests.conftest import create_mcp_client_session
|
||||
from tests.integration.provider_fixtures import create_generation_provider
|
||||
from tests.integration.sampling_support import create_sampling_callback
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default path to the Nextcloud User Manual PDF
|
||||
DEFAULT_MANUAL_PATH = "Nextcloud Manual.pdf"
|
||||
|
||||
|
||||
async def llm_judge(
|
||||
provider: Provider,
|
||||
ground_truth: str,
|
||||
system_output: str,
|
||||
) -> bool:
|
||||
"""Use LLM to judge if system output aligns with ground truth.
|
||||
|
||||
Args:
|
||||
provider: Any provider with generation capability
|
||||
ground_truth: The expected/reference answer
|
||||
system_output: The system's actual output to evaluate
|
||||
|
||||
Returns:
|
||||
True if output aligns with ground truth, False otherwise
|
||||
"""
|
||||
prompt = f"""GROUND TRUTH: {ground_truth}
|
||||
|
||||
SYSTEM OUTPUT: {system_output}
|
||||
|
||||
Does the system output contain the key facts from the ground truth?
|
||||
|
||||
Answer: TRUE or FALSE"""
|
||||
|
||||
logger.info("Received ground truth: %s", ground_truth)
|
||||
logger.info("Received system output: %s", system_output)
|
||||
|
||||
response = await provider.generate(prompt, max_tokens=10)
|
||||
logger.info("LLM Judge response: %s", response)
|
||||
return "TRUE" in response.upper()
|
||||
|
||||
|
||||
# Mark all tests as integration tests
|
||||
pytestmark = [
|
||||
pytest.mark.integration,
|
||||
pytest.mark.rag,
|
||||
]
|
||||
|
||||
# Ground truth fixture path
|
||||
FIXTURES_DIR = Path(__file__).parent / "fixtures"
|
||||
GROUND_TRUTH_FILE = FIXTURES_DIR / "nextcloud_manual_ground_truth.json"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ground_truth_qa():
|
||||
"""Load ground truth Q&A pairs for the Nextcloud manual."""
|
||||
if not GROUND_TRUTH_FILE.exists():
|
||||
pytest.skip(f"Ground truth file not found: {GROUND_TRUTH_FILE}")
|
||||
|
||||
with open(GROUND_TRUTH_FILE) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def indexed_manual_pdf(nc_client, nc_mcp_client):
|
||||
"""Ensure the Nextcloud User Manual PDF is tagged and indexed for vector search.
|
||||
|
||||
This fixture:
|
||||
1. Gets file info for the manual PDF
|
||||
2. Creates/gets the 'vector-index' tag
|
||||
3. Assigns the tag to the file
|
||||
4. Waits for vector sync to complete indexing
|
||||
|
||||
Environment Variables:
|
||||
RAG_MANUAL_PATH: Path to manual PDF in Nextcloud (default: Nextcloud Manual.pdf)
|
||||
"""
|
||||
manual_path = os.getenv("RAG_MANUAL_PATH", DEFAULT_MANUAL_PATH)
|
||||
|
||||
logger.info(f"Setting up indexed manual PDF: {manual_path}")
|
||||
|
||||
# Get file info to verify file exists and get file ID
|
||||
file_info = await nc_client.webdav.get_file_info(manual_path)
|
||||
if not file_info:
|
||||
pytest.skip(f"Manual PDF not found at '{manual_path}'")
|
||||
|
||||
file_id = file_info["id"]
|
||||
logger.info(f"Found manual PDF: {manual_path} (file_id={file_id})")
|
||||
|
||||
# Create or get the vector-index tag
|
||||
tag = await nc_client.webdav.get_or_create_tag("vector-index")
|
||||
tag_id = tag["id"]
|
||||
logger.info(f"Using tag 'vector-index' (tag_id={tag_id})")
|
||||
|
||||
# Assign tag to file
|
||||
await nc_client.webdav.assign_tag_to_file(file_id, tag_id)
|
||||
logger.info(f"Tagged file {file_id} with vector-index tag")
|
||||
|
||||
# Wait for vector sync to complete indexing
|
||||
max_attempts = 60
|
||||
poll_interval = 10
|
||||
|
||||
logger.info("Waiting for vector sync to index the manual...")
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
# Call the MCP tool via the existing client session
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"nc_get_vector_sync_status",
|
||||
arguments={},
|
||||
)
|
||||
|
||||
if not result.isError:
|
||||
content = result.structuredContent or {}
|
||||
indexed = content.get("indexed_count", 0)
|
||||
pending = content.get("pending_count", 1)
|
||||
|
||||
logger.info(
|
||||
f"Attempt {attempt}/{max_attempts}: "
|
||||
f"indexed={indexed}, pending={pending}"
|
||||
)
|
||||
|
||||
if indexed > 0 and pending == 0:
|
||||
logger.info(
|
||||
f"Vector indexing complete: {indexed} documents indexed"
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"Attempt {attempt}: Error checking status: {e}")
|
||||
|
||||
if attempt < max_attempts:
|
||||
await anyio.sleep(poll_interval)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Vector indexing may not be complete after {max_attempts} attempts"
|
||||
)
|
||||
|
||||
yield {
|
||||
"path": manual_path,
|
||||
"file_id": file_id,
|
||||
"tag_id": tag_id,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def provider_name(request) -> str:
|
||||
"""Get the provider name from --provider flag.
|
||||
|
||||
Raises pytest.skip if --provider not specified.
|
||||
"""
|
||||
name = request.config.getoption("--provider")
|
||||
if not name:
|
||||
pytest.skip("--provider flag required (openai, ollama, anthropic, bedrock)")
|
||||
return name
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def generation_provider(provider_name: str) -> AsyncGenerator[Provider, None]:
|
||||
"""Provider configured for text generation.
|
||||
|
||||
Requires --provider flag to be set.
|
||||
"""
|
||||
provider = await create_generation_provider(provider_name)
|
||||
yield provider
|
||||
await provider.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def nc_mcp_client_with_sampling(
|
||||
anyio_backend, generation_provider, provider_name
|
||||
) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""MCP client with sampling support using the specified provider.
|
||||
|
||||
This fixture creates an MCP client that can handle sampling requests
|
||||
from the server using the configured generation provider.
|
||||
"""
|
||||
sampling_callback = create_sampling_callback(generation_provider)
|
||||
|
||||
async for session in create_mcp_client_session(
|
||||
url="http://localhost:8000/mcp",
|
||||
client_name=f"Sampling MCP ({provider_name})",
|
||||
sampling_callback=sampling_callback,
|
||||
):
|
||||
yield session
|
||||
|
||||
|
||||
async def test_semantic_search_retrieval(
|
||||
nc_mcp_client, ground_truth_qa, indexed_manual_pdf, generation_provider
|
||||
):
|
||||
"""Test that semantic search retrieves relevant documents from the manual.
|
||||
|
||||
This tests the retrieval component of RAG - ensuring that queries
|
||||
return relevant chunks from the indexed Nextcloud User Manual.
|
||||
"""
|
||||
# Use first query from ground truth
|
||||
test_case = ground_truth_qa[0] # 2FA question
|
||||
query = test_case["query"]
|
||||
|
||||
# Perform semantic search via MCP tool
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"nc_semantic_search",
|
||||
arguments={
|
||||
"query": query,
|
||||
"limit": 5,
|
||||
"score_threshold": 0.0,
|
||||
},
|
||||
)
|
||||
|
||||
assert result.isError is False, f"Tool call failed: {result}"
|
||||
data = result.structuredContent
|
||||
|
||||
# Verify we got results
|
||||
assert data["success"] is True
|
||||
assert data["total_found"] > 0, f"No results for query: {query}"
|
||||
assert len(data["results"]) > 0
|
||||
|
||||
# Use LLM judge to evaluate if excerpts are relevant to ground truth
|
||||
all_excerpts = " ".join([r["excerpt"] for r in data["results"]])
|
||||
is_relevant = await llm_judge(
|
||||
generation_provider,
|
||||
test_case["ground_truth"],
|
||||
all_excerpts,
|
||||
)
|
||||
assert is_relevant, f"LLM judge: excerpts not relevant to query: {query}"
|
||||
|
||||
|
||||
async def test_semantic_search_answer_with_sampling(
|
||||
nc_mcp_client_with_sampling,
|
||||
ground_truth_qa,
|
||||
indexed_manual_pdf,
|
||||
generation_provider,
|
||||
):
|
||||
"""Test semantic search with MCP sampling for answer generation.
|
||||
|
||||
This tests the full RAG pipeline:
|
||||
1. Semantic search retrieves relevant documents
|
||||
2. MCP sampling generates an answer from the retrieved context
|
||||
3. Provider generates the answer via the sampling callback
|
||||
|
||||
Uses nc_mcp_client_with_sampling which has sampling enabled.
|
||||
"""
|
||||
# Use the 2FA question - has clear expected answer
|
||||
test_case = ground_truth_qa[0]
|
||||
query = test_case["query"]
|
||||
|
||||
result = await nc_mcp_client_with_sampling.call_tool(
|
||||
"nc_semantic_search_answer",
|
||||
arguments={
|
||||
"query": query,
|
||||
"limit": 5,
|
||||
"score_threshold": 0.0,
|
||||
"max_answer_tokens": 300,
|
||||
},
|
||||
)
|
||||
|
||||
assert result.isError is False, f"Tool call failed: {result}"
|
||||
data = result.structuredContent
|
||||
|
||||
# Verify response structure
|
||||
assert data["success"] is True
|
||||
assert "query" in data
|
||||
assert "generated_answer" in data
|
||||
assert "sources" in data
|
||||
assert "search_method" in data
|
||||
|
||||
# Check for either successful sampling or graceful fallback
|
||||
fallback_methods = {
|
||||
"semantic_sampling_unsupported",
|
||||
"semantic_sampling_user_declined",
|
||||
"semantic_sampling_timeout",
|
||||
"semantic_sampling_mcp_error",
|
||||
"semantic_sampling_fallback",
|
||||
}
|
||||
|
||||
if data["search_method"] in fallback_methods:
|
||||
# Fallback mode - verify sources still returned
|
||||
assert len(data["sources"]) > 0, "Expected sources even in fallback mode"
|
||||
pytest.skip(
|
||||
f"MCP sampling not available (method: {data['search_method']}), "
|
||||
f"but retrieval succeeded with {len(data['sources'])} sources"
|
||||
)
|
||||
else:
|
||||
# Successful sampling - verify answer quality
|
||||
assert data["search_method"] == "semantic_sampling"
|
||||
assert data["generated_answer"] is not None
|
||||
assert len(data["generated_answer"]) > 50 # Non-trivial answer
|
||||
|
||||
# Use LLM judge to evaluate answer relevance
|
||||
is_relevant = await llm_judge(
|
||||
generation_provider,
|
||||
test_case["ground_truth"],
|
||||
data["generated_answer"],
|
||||
)
|
||||
assert is_relevant, f"LLM judge: answer not relevant to query: {query}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"qa_index,min_expected_results",
|
||||
[
|
||||
(0, 1), # 2FA question
|
||||
(1, 1), # File quotas question
|
||||
(2, 1), # Linux installation question
|
||||
(3, 1), # Windows requirements question
|
||||
(4, 1), # Client apps with 2FA question
|
||||
],
|
||||
)
|
||||
async def test_retrieval_quality_all_queries(
|
||||
nc_mcp_client, ground_truth_qa, indexed_manual_pdf, qa_index, min_expected_results
|
||||
):
|
||||
"""Test retrieval quality for all ground truth queries.
|
||||
|
||||
Validates that each query returns at least the minimum expected
|
||||
number of relevant results from the Nextcloud manual.
|
||||
"""
|
||||
if qa_index >= len(ground_truth_qa):
|
||||
pytest.skip(f"Ground truth index {qa_index} not available")
|
||||
|
||||
test_case = ground_truth_qa[qa_index]
|
||||
query = test_case["query"]
|
||||
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"nc_semantic_search",
|
||||
arguments={
|
||||
"query": query,
|
||||
"limit": 5,
|
||||
"score_threshold": 0.0,
|
||||
},
|
||||
)
|
||||
|
||||
assert result.isError is False
|
||||
data = result.structuredContent
|
||||
|
||||
assert data["total_found"] >= min_expected_results, (
|
||||
f"Query '{query}' returned {data['total_found']} results, "
|
||||
f"expected at least {min_expected_results}"
|
||||
)
|
||||
|
||||
|
||||
async def test_no_results_for_unrelated_query(nc_mcp_client, indexed_manual_pdf):
|
||||
"""Test that completely unrelated queries return low/no scores.
|
||||
|
||||
The Nextcloud manual shouldn't have relevant content for
|
||||
quantum physics queries.
|
||||
"""
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"nc_semantic_search",
|
||||
arguments={
|
||||
"query": "quantum entanglement hadron collider particle physics",
|
||||
"limit": 5,
|
||||
"score_threshold": 0.5, # Higher threshold to filter irrelevant
|
||||
},
|
||||
)
|
||||
|
||||
assert result.isError is False
|
||||
data = result.structuredContent
|
||||
|
||||
# Should have few or no high-scoring results
|
||||
# Low score threshold means we might get some results, but they should be low quality
|
||||
if data["total_found"] > 0:
|
||||
# If results exist, they should have low scores
|
||||
max_score = max(r["score"] for r in data["results"])
|
||||
assert max_score < 0.8, f"Unexpected high score {max_score} for unrelated query"
|
||||