Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3af591810 | |||
| edb0af2bda | |||
| 7d5bb54b64 | |||
| a18c63792a | |||
| 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 |
@@ -15,7 +15,7 @@ jobs:
|
|||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
|
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
|
||||||
@@ -25,7 +25,7 @@ jobs:
|
|||||||
github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||||
changelog_increment_filename: body.md
|
changelog_increment_filename: body.md
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
|
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||||
with:
|
with:
|
||||||
body_path: "body.md"
|
body_path: "body.md"
|
||||||
tag_name: v${{ env.REVISION }}
|
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@6337623ebba10cf8c8214b507993f8062fd4ccfb # 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@6337623ebba10cf8c8214b507993f8062fd4ccfb # 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
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||||
with:
|
with:
|
||||||
# list of Docker images to use as base name for tags
|
# list of Docker images to use as base name for tags
|
||||||
images: |
|
images: |
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|||||||
@@ -24,39 +24,25 @@ jobs:
|
|||||||
models: read
|
models: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
|
||||||
submodules: 'true'
|
|
||||||
|
|
||||||
###### Required to build OIDC App ######
|
|
||||||
- name: Set up php 8.4
|
|
||||||
uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # v2
|
|
||||||
with:
|
|
||||||
php-version: 8.4
|
|
||||||
coverage: none
|
|
||||||
|
|
||||||
- name: Install OIDC app composer dependencies
|
|
||||||
run: |
|
|
||||||
cd third_party/oidc
|
|
||||||
composer install --no-dev
|
|
||||||
###### Required to build OIDC App ######
|
|
||||||
|
|
||||||
- name: Run docker compose with vector sync
|
- name: Run docker compose with vector sync
|
||||||
uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1
|
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
|
||||||
with:
|
with:
|
||||||
compose-file: "./docker-compose.yml"
|
compose-file: |
|
||||||
|
./docker-compose.yml
|
||||||
|
./docker-compose.ci.yml
|
||||||
up-flags: "--build"
|
up-flags: "--build"
|
||||||
env:
|
env:
|
||||||
# Override MCP container environment for OpenAI + vector sync
|
# Environment variables passed to docker-compose.ci.yml
|
||||||
VECTOR_SYNC_ENABLED: "true"
|
|
||||||
VECTOR_SYNC_SCAN_INTERVAL: "5"
|
|
||||||
OPENAI_API_KEY: ${{ secrets.GITHUB_TOKEN }}
|
OPENAI_API_KEY: ${{ secrets.GITHUB_TOKEN }}
|
||||||
OPENAI_BASE_URL: "https://models.github.ai/inference"
|
OPENAI_BASE_URL: "https://models.github.ai/inference"
|
||||||
OPENAI_EMBEDDING_MODEL: ${{ inputs.embedding_model }}
|
OPENAI_EMBEDDING_MODEL: ${{ inputs.embedding_model }}
|
||||||
OPENAI_GENERATION_MODEL: ${{ inputs.generation_model }}
|
OPENAI_GENERATION_MODEL: ${{ inputs.generation_model }}
|
||||||
|
VECTOR_SYNC_SCAN_INTERVAL: "5"
|
||||||
|
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
|
||||||
|
|
||||||
- name: Wait for Nextcloud to be ready
|
- name: Wait for Nextcloud to be ready
|
||||||
run: |
|
run: |
|
||||||
@@ -101,11 +87,17 @@ jobs:
|
|||||||
OPENAI_EMBEDDING_MODEL: ${{ inputs.embedding_model }}
|
OPENAI_EMBEDDING_MODEL: ${{ inputs.embedding_model }}
|
||||||
OPENAI_GENERATION_MODEL: ${{ inputs.generation_model }}
|
OPENAI_GENERATION_MODEL: ${{ inputs.generation_model }}
|
||||||
run: |
|
run: |
|
||||||
uv run pytest tests/integration/test_rag_openai.py -v --log-cli-level=INFO
|
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
|
- name: Upload test results
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
||||||
with:
|
with:
|
||||||
name: rag-evaluation-results
|
name: rag-evaluation-results
|
||||||
path: |
|
path: |
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
|
||||||
- name: Install Python 3.11
|
- name: Install Python 3.11
|
||||||
run: uv python install 3.11
|
run: uv python install 3.11
|
||||||
- name: Build
|
- name: Build
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ jobs:
|
|||||||
linting:
|
linting:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
|
||||||
- name: Check format
|
- name: Check format
|
||||||
run: |
|
run: |
|
||||||
uv run --frozen ruff format --diff
|
uv run --frozen ruff format --diff
|
||||||
@@ -27,7 +27,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
submodules: 'true'
|
submodules: 'true'
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ jobs:
|
|||||||
###### Required to build OIDC App ######
|
###### Required to build OIDC App ######
|
||||||
|
|
||||||
- name: Set up php 8.4
|
- name: Set up php 8.4
|
||||||
uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # v2
|
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2
|
||||||
with:
|
with:
|
||||||
php-version: 8.4
|
php-version: 8.4
|
||||||
coverage: none
|
coverage: none
|
||||||
@@ -49,14 +49,14 @@ jobs:
|
|||||||
|
|
||||||
|
|
||||||
- name: Run docker compose
|
- name: Run docker compose
|
||||||
uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1
|
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
|
||||||
with:
|
with:
|
||||||
compose-file: "./docker-compose.yml"
|
compose-file: "./docker-compose.yml"
|
||||||
#compose-flags: "--profile qdrant"
|
#compose-flags: "--profile qdrant"
|
||||||
up-flags: "--build"
|
up-flags: "--build"
|
||||||
|
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
|
||||||
|
|
||||||
- name: Install Playwright dependencies
|
- name: Install Playwright dependencies
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -1,3 +1,51 @@
|
|||||||
|
## 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)
|
## v0.48.2 (2025-11-23)
|
||||||
|
|
||||||
### Fix
|
### Fix
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:b43ff04d5df04ad5cabb80890b7ef74e8410e3395b19af970dcd52d7a4bff921
|
FROM docker.io/library/python:3.12-slim-trixie@sha256:b43ff04d5df04ad5cabb80890b7ef74e8410e3395b19af970dcd52d7a4bff921
|
||||||
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.9.11@sha256:5aa820129de0a600924f166aec9cb51613b15b68f1dcd2a02f31a500d2ede568 /uv /uvx /bin/
|
COPY --from=ghcr.io/astral-sh/uv:0.9.16@sha256:ae9ff79d095a61faf534a882ad6378e8159d2ce322691153d68d2afac7422840 /uv /uvx /bin/
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
# 1. git (required for caldav dependency from git)
|
# 1. git (required for caldav dependency from git)
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ FROM docker.io/library/python:3.12-slim-trixie@sha256:b43ff04d5df04ad5cabb80890b
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install uv for fast dependency management
|
# Install uv for fast dependency management
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.9.11@sha256:5aa820129de0a600924f166aec9cb51613b15b68f1dcd2a02f31a500d2ede568 /uv /uvx /bin/
|
COPY --from=ghcr.io/astral-sh/uv:0.9.16@sha256:ae9ff79d095a61faf534a882ad6378e8159d2ce322691153d68d2afac7422840 /uv /uvx /bin/
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
# 1. git (required for caldav dependency from git)
|
# 1. git (required for caldav dependency from git)
|
||||||
|
|||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -euox pipefail
|
||||||
|
|
||||||
|
php /var/www/html/occ app:enable news
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
- name: qdrant
|
- name: qdrant
|
||||||
repository: https://qdrant.github.io/qdrant-helm
|
repository: https://qdrant.github.io/qdrant-helm
|
||||||
version: 1.16.0
|
version: 1.16.2
|
||||||
- name: ollama
|
- name: ollama
|
||||||
repository: https://otwld.github.io/ollama-helm
|
repository: https://otwld.github.io/ollama-helm
|
||||||
version: 1.35.0
|
version: 1.35.0
|
||||||
digest: sha256:da8db198b12ce0252df220fabb297cfe69186edb8e67952c52e05de778189b92
|
digest: sha256:bcb0779739e4710b90bb65f6a7baeaa295bd0ba9776f8a1cf8d9b69d233c8ec0
|
||||||
generated: "2025-11-21T11:09:07.997781541Z"
|
generated: "2025-12-05T11:11:27.999374001Z"
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
|||||||
name: nextcloud-mcp-server
|
name: nextcloud-mcp-server
|
||||||
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
||||||
type: application
|
type: application
|
||||||
version: 0.48.2
|
version: 0.49.1
|
||||||
appVersion: "0.48.2"
|
appVersion: "0.49.1"
|
||||||
keywords:
|
keywords:
|
||||||
- nextcloud
|
- nextcloud
|
||||||
- mcp
|
- mcp
|
||||||
@@ -27,7 +27,7 @@ annotations:
|
|||||||
grafana_dashboard_folder: "Nextcloud MCP"
|
grafana_dashboard_folder: "Nextcloud MCP"
|
||||||
dependencies:
|
dependencies:
|
||||||
- name: qdrant
|
- name: qdrant
|
||||||
version: "1.16.0"
|
version: "1.16.2"
|
||||||
repository: https://qdrant.github.io/qdrant-helm
|
repository: https://qdrant.github.io/qdrant-helm
|
||||||
condition: qdrant.networkMode.deploySubchart
|
condition: qdrant.networkMode.deploySubchart
|
||||||
- name: ollama
|
- name: ollama
|
||||||
|
|||||||
@@ -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
|
||||||
+4
-4
@@ -21,7 +21,7 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: docker.io/library/nextcloud:32.0.2@sha256:ac08482d73ffd85d94069ba291bbd5fb39a70ff21502030a2e3e2d89a7246a48
|
image: docker.io/library/nextcloud:32.0.2@sha256:04cc19547e586ac75e08dd056c11330d4ce4c5c561c89405b326180a37c19afb
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- 0.0.0.0:8080:80
|
- 0.0.0.0:8080:80
|
||||||
@@ -34,7 +34,7 @@ services:
|
|||||||
- ./app-hooks:/docker-entrypoint-hooks.d:ro
|
- ./app-hooks:/docker-entrypoint-hooks.d:ro
|
||||||
# Mount OIDC development directory outside /var/www/html to avoid rsync conflicts
|
# 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
|
# The post-installation hook will register /opt/apps as an additional app directory
|
||||||
- ./third_party:/opt/apps:ro
|
#- ./third_party:/opt/apps:ro
|
||||||
environment:
|
environment:
|
||||||
- NEXTCLOUD_TRUSTED_DOMAINS=app
|
- NEXTCLOUD_TRUSTED_DOMAINS=app
|
||||||
- NEXTCLOUD_ADMIN_USER=admin
|
- NEXTCLOUD_ADMIN_USER=admin
|
||||||
@@ -158,7 +158,7 @@ services:
|
|||||||
- oauth-tokens:/app/data
|
- oauth-tokens:/app/data
|
||||||
|
|
||||||
keycloak:
|
keycloak:
|
||||||
image: quay.io/keycloak/keycloak:26.4.5@sha256:653852bfdea2be6e958b9e90a976eff1c6de34edd55f2f679bdc48ef16bc528e
|
image: quay.io/keycloak/keycloak:26.4.7@sha256:9409c59bdfb65dbffa20b11e6f18b8abb9281d480c7ca402f51ed3d5977e6007
|
||||||
command:
|
command:
|
||||||
- "start-dev"
|
- "start-dev"
|
||||||
- "--import-realm"
|
- "--import-realm"
|
||||||
@@ -245,7 +245,7 @@ services:
|
|||||||
- smithery
|
- smithery
|
||||||
|
|
||||||
qdrant:
|
qdrant:
|
||||||
image: qdrant/qdrant:v1.16.0@sha256:1005201498cf927d835383d0f918b17d8c9da7db58550f169f694455e42d78f4
|
image: qdrant/qdrant:v1.16.2@sha256:dab6de32f7b2cc599985a7c764db3e8b062f70508fb85ca074aa856f829bf335
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- 127.0.0.1:6333:6333 # REST API
|
- 127.0.0.1:6333:6333 # REST API
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ from nextcloud_mcp_server.server import (
|
|||||||
configure_contacts_tools,
|
configure_contacts_tools,
|
||||||
configure_cookbook_tools,
|
configure_cookbook_tools,
|
||||||
configure_deck_tools,
|
configure_deck_tools,
|
||||||
|
configure_news_tools,
|
||||||
configure_notes_tools,
|
configure_notes_tools,
|
||||||
configure_semantic_tools,
|
configure_semantic_tools,
|
||||||
configure_sharing_tools,
|
configure_sharing_tools,
|
||||||
@@ -514,7 +515,7 @@ async def load_oauth_client_credentials(
|
|||||||
# and the authorization server will limit them to these allowed scopes.
|
# and the authorization server will limit them to these allowed scopes.
|
||||||
#
|
#
|
||||||
# The PRM endpoint advertises the same scopes dynamically via @require_scopes decorators.
|
# 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
|
# Add offline_access scope if refresh tokens are enabled
|
||||||
enable_offline_access = os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() in (
|
enable_offline_access = os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() in (
|
||||||
@@ -1046,6 +1047,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
"contacts": configure_contacts_tools,
|
"contacts": configure_contacts_tools,
|
||||||
"cookbook": configure_cookbook_tools,
|
"cookbook": configure_cookbook_tools,
|
||||||
"deck": configure_deck_tools,
|
"deck": configure_deck_tools,
|
||||||
|
"news": configure_news_tools,
|
||||||
}
|
}
|
||||||
|
|
||||||
# If no specific apps are specified, enable all
|
# If no specific apps are specified, enable all
|
||||||
|
|||||||
@@ -303,10 +303,13 @@ class UnifiedTokenVerifier(TokenVerifier):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Introspection requires client authentication
|
# 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(
|
response = await self.http_client.post(
|
||||||
self.introspection_uri,
|
self.introspection_uri,
|
||||||
data={"token": token},
|
data={"token": token},
|
||||||
auth=(self.settings.oidc_client_id, self.settings.oidc_client_secret),
|
auth=(client_id, client_secret),
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ async def _get_authenticated_client(request: Request) -> httpx.AsyncClient:
|
|||||||
raise RuntimeError("BasicAuth credentials not configured")
|
raise RuntimeError("BasicAuth credentials not configured")
|
||||||
|
|
||||||
assert nextcloud_host is not None # Type narrowing for type checker
|
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(
|
return httpx.AsyncClient(
|
||||||
base_url=nextcloud_host,
|
base_url=nextcloud_host,
|
||||||
auth=(username, password),
|
auth=(username, password),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from .contacts import ContactsClient
|
|||||||
from .cookbook import CookbookClient
|
from .cookbook import CookbookClient
|
||||||
from .deck import DeckClient
|
from .deck import DeckClient
|
||||||
from .groups import GroupsClient
|
from .groups import GroupsClient
|
||||||
|
from .news import NewsClient
|
||||||
from .notes import NotesClient
|
from .notes import NotesClient
|
||||||
from .sharing import SharingClient
|
from .sharing import SharingClient
|
||||||
from .tables import TablesClient
|
from .tables import TablesClient
|
||||||
@@ -81,6 +82,7 @@ class NextcloudClient:
|
|||||||
self.contacts = ContactsClient(self._client, username)
|
self.contacts = ContactsClient(self._client, username)
|
||||||
self.cookbook = CookbookClient(self._client, username)
|
self.cookbook = CookbookClient(self._client, username)
|
||||||
self.deck = DeckClient(self._client, username)
|
self.deck = DeckClient(self._client, username)
|
||||||
|
self.news = NewsClient(self._client, username)
|
||||||
self.users = UsersClient(self._client, username)
|
self.users = UsersClient(self._client, username)
|
||||||
self.groups = GroupsClient(self._client, username)
|
self.groups = GroupsClient(self._client, username)
|
||||||
self.sharing = SharingClient(self._client, username)
|
self.sharing = SharingClient(self._client, username)
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
"""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.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_id: Item ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Item data
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPStatusError: 404 if item not found
|
||||||
|
"""
|
||||||
|
response = await self._make_request("GET", f"{self.API_BASE}/items/{item_id}")
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
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", "")
|
||||||
@@ -1174,7 +1174,9 @@ class WebDAVClient(BaseNextcloudClient):
|
|||||||
|
|
||||||
if display_name_elem is not None and display_name_elem.text == tag_name:
|
if display_name_elem is not None and display_name_elem.text == tag_name:
|
||||||
tag_info = {
|
tag_info = {
|
||||||
"id": int(tag_id_elem.text) if tag_id_elem is not None else None,
|
"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,
|
"name": display_name_elem.text,
|
||||||
"userVisible": user_visible_elem.text.lower() == "true"
|
"userVisible": user_visible_elem.text.lower() == "true"
|
||||||
if user_visible_elem is not None
|
if user_visible_elem is not None
|
||||||
@@ -1369,7 +1371,9 @@ class WebDAVClient(BaseNextcloudClient):
|
|||||||
)
|
)
|
||||||
|
|
||||||
file_info = {
|
file_info = {
|
||||||
"id": int(fileid_elem.text) if fileid_elem is not None else None,
|
"id": int(fileid_elem.text)
|
||||||
|
if fileid_elem is not None and fileid_elem.text is not None
|
||||||
|
else None,
|
||||||
"path": path,
|
"path": path,
|
||||||
"name": displayname_elem.text
|
"name": displayname_elem.text
|
||||||
if displayname_elem is not None
|
if displayname_elem is not None
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -60,7 +60,7 @@ class TraceContextFormatter(JsonFormatter):
|
|||||||
|
|
||||||
def add_fields(
|
def add_fields(
|
||||||
self,
|
self,
|
||||||
log_record: dict[str, Any],
|
log_data: dict[str, Any],
|
||||||
record: logging.LogRecord,
|
record: logging.LogRecord,
|
||||||
message_dict: dict[str, Any],
|
message_dict: dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -68,28 +68,28 @@ class TraceContextFormatter(JsonFormatter):
|
|||||||
Add custom fields to the log record, including trace context.
|
Add custom fields to the log record, including trace context.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
log_record: Dictionary to be serialized as JSON
|
log_data: Dictionary to be serialized as JSON
|
||||||
record: LogRecord instance
|
record: LogRecord instance
|
||||||
message_dict: Dictionary of extra fields from log call
|
message_dict: Dictionary of extra fields from log call
|
||||||
"""
|
"""
|
||||||
# Call parent to add standard fields
|
# 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
|
# Add trace context if available
|
||||||
trace_context = get_trace_context()
|
trace_context = get_trace_context()
|
||||||
if trace_context:
|
if trace_context:
|
||||||
log_record["trace_id"] = trace_context.get("trace_id")
|
log_data["trace_id"] = trace_context.get("trace_id")
|
||||||
log_record["span_id"] = trace_context.get("span_id")
|
log_data["span_id"] = trace_context.get("span_id")
|
||||||
|
|
||||||
# Add standard fields with consistent naming
|
# Add standard fields with consistent naming
|
||||||
log_record["timestamp"] = self.formatTime(record)
|
log_data["timestamp"] = self.formatTime(record)
|
||||||
log_record["level"] = record.levelname
|
log_data["level"] = record.levelname
|
||||||
log_record["logger"] = record.name
|
log_data["logger"] = record.name
|
||||||
log_record["message"] = record.getMessage()
|
log_data["message"] = record.getMessage()
|
||||||
|
|
||||||
# Include exception info if present
|
# Include exception info if present
|
||||||
if record.exc_info:
|
if record.exc_info:
|
||||||
log_record["exception"] = self.formatException(record.exc_info)
|
log_data["exception"] = self.formatException(record.exc_info)
|
||||||
|
|
||||||
|
|
||||||
class TraceContextTextFormatter(logging.Formatter):
|
class TraceContextTextFormatter(logging.Formatter):
|
||||||
|
|||||||
@@ -17,18 +17,20 @@ class AnthropicProvider(Provider):
|
|||||||
Note: Anthropic doesn't provide embedding models, only text generation.
|
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.
|
Initialize Anthropic provider.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
api_key: Anthropic API key
|
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.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
|
@property
|
||||||
def supports_embeddings(self) -> bool:
|
def supports_embeddings(self) -> bool:
|
||||||
|
|||||||
@@ -7,13 +7,48 @@ Supports:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
from openai import AsyncOpenAI
|
import anyio
|
||||||
|
from openai import AsyncOpenAI, RateLimitError
|
||||||
|
|
||||||
from .base import Provider
|
from .base import Provider
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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
|
# Well-known embedding dimensions for OpenAI models
|
||||||
OPENAI_EMBEDDING_DIMENSIONS: dict[str, int] = {
|
OPENAI_EMBEDDING_DIMENSIONS: dict[str, int] = {
|
||||||
"text-embedding-3-small": 1536,
|
"text-embedding-3-small": 1536,
|
||||||
@@ -86,6 +121,7 @@ class OpenAIProvider(Provider):
|
|||||||
"""Whether this provider supports text generation."""
|
"""Whether this provider supports text generation."""
|
||||||
return self.generation_model is not None
|
return self.generation_model is not None
|
||||||
|
|
||||||
|
@retry_on_rate_limit
|
||||||
async def embed(self, text: str) -> list[float]:
|
async def embed(self, text: str) -> list[float]:
|
||||||
"""
|
"""
|
||||||
Generate embedding vector for text.
|
Generate embedding vector for text.
|
||||||
@@ -104,6 +140,7 @@ class OpenAIProvider(Provider):
|
|||||||
"Embedding not supported - no embedding_model configured"
|
"Embedding not supported - no embedding_model configured"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert self.embedding_model is not None # Type narrowing
|
||||||
response = await self.client.embeddings.create(
|
response = await self.client.embeddings.create(
|
||||||
input=text,
|
input=text,
|
||||||
model=self.embedding_model,
|
model=self.embedding_model,
|
||||||
@@ -151,14 +188,8 @@ class OpenAIProvider(Provider):
|
|||||||
for i in range(0, len(texts), batch_size):
|
for i in range(0, len(texts), batch_size):
|
||||||
batch = texts[i : i + batch_size]
|
batch = texts[i : i + batch_size]
|
||||||
|
|
||||||
response = await self.client.embeddings.create(
|
# Use helper method with retry logic for each batch
|
||||||
input=batch,
|
batch_embeddings = await self._embed_batch_request(batch)
|
||||||
model=self.embedding_model,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Sort by index to maintain order
|
|
||||||
sorted_data = sorted(response.data, key=lambda x: x.index)
|
|
||||||
batch_embeddings = [item.embedding for item in sorted_data]
|
|
||||||
all_embeddings.extend(batch_embeddings)
|
all_embeddings.extend(batch_embeddings)
|
||||||
|
|
||||||
# Update dimension if not set
|
# Update dimension if not set
|
||||||
@@ -171,6 +202,18 @@ class OpenAIProvider(Provider):
|
|||||||
|
|
||||||
return all_embeddings
|
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:
|
def get_dimension(self) -> int:
|
||||||
"""
|
"""
|
||||||
Get embedding dimension.
|
Get embedding dimension.
|
||||||
@@ -194,6 +237,7 @@ class OpenAIProvider(Provider):
|
|||||||
)
|
)
|
||||||
return self._dimension
|
return self._dimension
|
||||||
|
|
||||||
|
@retry_on_rate_limit
|
||||||
async def generate(self, prompt: str, max_tokens: int = 500) -> str:
|
async def generate(self, prompt: str, max_tokens: int = 500) -> str:
|
||||||
"""
|
"""
|
||||||
Generate text from a prompt.
|
Generate text from a prompt.
|
||||||
|
|||||||
@@ -108,8 +108,8 @@ async def get_indexed_doc_types(user_id: str) -> set[str]:
|
|||||||
with_vectors=False, # Don't need vectors for type discovery
|
with_vectors=False, # Don't need vectors for type discovery
|
||||||
)
|
)
|
||||||
|
|
||||||
doc_types = {
|
doc_types: set[str] = {
|
||||||
point.payload.get("doc_type")
|
str(point.payload.get("doc_type"))
|
||||||
for point in scroll_results
|
for point in scroll_results
|
||||||
if point.payload.get("doc_type")
|
if point.payload.get("doc_type")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,6 +204,8 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
|
|||||||
results = []
|
results = []
|
||||||
|
|
||||||
for result in search_response.points:
|
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 can be int (notes) or str (files - file paths)
|
||||||
doc_id = result.payload["doc_id"]
|
doc_id = result.payload["doc_id"]
|
||||||
doc_type = result.payload.get("doc_type", "note")
|
doc_type = result.payload.get("doc_type", "note")
|
||||||
|
|||||||
@@ -136,6 +136,8 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
|
|||||||
results = []
|
results = []
|
||||||
|
|
||||||
for result in search_response.points:
|
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 can be int (notes) or str (files - file paths)
|
||||||
doc_id = result.payload["doc_id"]
|
doc_id = result.payload["doc_id"]
|
||||||
doc_type = result.payload.get("doc_type", "note")
|
doc_type = result.payload.get("doc_type", "note")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from .calendar import configure_calendar_tools
|
|||||||
from .contacts import configure_contacts_tools
|
from .contacts import configure_contacts_tools
|
||||||
from .cookbook import configure_cookbook_tools
|
from .cookbook import configure_cookbook_tools
|
||||||
from .deck import configure_deck_tools
|
from .deck import configure_deck_tools
|
||||||
|
from .news import configure_news_tools
|
||||||
from .notes import configure_notes_tools
|
from .notes import configure_notes_tools
|
||||||
from .semantic import configure_semantic_tools
|
from .semantic import configure_semantic_tools
|
||||||
from .sharing import configure_sharing_tools
|
from .sharing import configure_sharing_tools
|
||||||
@@ -13,6 +14,7 @@ __all__ = [
|
|||||||
"configure_contacts_tools",
|
"configure_contacts_tools",
|
||||||
"configure_cookbook_tools",
|
"configure_cookbook_tools",
|
||||||
"configure_deck_tools",
|
"configure_deck_tools",
|
||||||
|
"configure_news_tools",
|
||||||
"configure_notes_tools",
|
"configure_notes_tools",
|
||||||
"configure_semantic_tools",
|
"configure_semantic_tools",
|
||||||
"configure_sharing_tools",
|
"configure_sharing_tools",
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
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()
|
||||||
|
@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()
|
||||||
|
@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()
|
||||||
|
@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()
|
||||||
|
@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()
|
||||||
|
@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()
|
||||||
|
@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()
|
||||||
|
@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()
|
||||||
|
@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}",
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -499,9 +499,11 @@ def configure_semantic_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 6. Request LLM completion via MCP sampling with timeout
|
# 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:
|
try:
|
||||||
with anyio.fail_after(30):
|
with anyio.fail_after(sampling_timeout_seconds):
|
||||||
sampling_result = await ctx.session.create_message(
|
sampling_result = await ctx.session.create_message(
|
||||||
messages=[
|
messages=[
|
||||||
SamplingMessage(
|
SamplingMessage(
|
||||||
@@ -548,14 +550,14 @@ def configure_semantic_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
logger.warning(
|
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"
|
f"returning search results only"
|
||||||
)
|
)
|
||||||
return SamplingSearchResponse(
|
return SamplingSearchResponse(
|
||||||
query=query,
|
query=query,
|
||||||
generated_answer=(
|
generated_answer=(
|
||||||
f"[Sampling request timed out]\n\n"
|
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"Found {len(accessible_results)} relevant documents. "
|
||||||
f"Please review the sources below or try a simpler query."
|
f"Please review the sources below or try a simpler query."
|
||||||
),
|
),
|
||||||
@@ -675,15 +677,22 @@ def configure_semantic_tools(mcp: FastMCP):
|
|||||||
# Get Qdrant client and query indexed count
|
# Get Qdrant client and query indexed count
|
||||||
indexed_count = 0
|
indexed_count = 0
|
||||||
try:
|
try:
|
||||||
|
from qdrant_client.models import Filter
|
||||||
|
|
||||||
from nextcloud_mcp_server.config import get_settings
|
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
|
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
qdrant_client = await get_qdrant_client()
|
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(
|
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
|
indexed_count = count_result.count
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -272,6 +272,45 @@ async def _index_document(
|
|||||||
file_path = None # Notes don't have file paths
|
file_path = None # Notes don't have file paths
|
||||||
content_bytes = None # Notes don't have binary content
|
content_bytes = None # Notes don't have binary content
|
||||||
content_type = None
|
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 == "file":
|
elif doc_task.doc_type == "file":
|
||||||
# For files, doc_id is now the numeric file ID, file_path comes from DocumentTask
|
# For files, doc_id is now the numeric file ID, file_path comes from DocumentTask
|
||||||
if not doc_task.file_path:
|
if not doc_task.file_path:
|
||||||
@@ -358,15 +397,16 @@ async def _index_document(
|
|||||||
chunks = await chunker.chunk_text(content)
|
chunks = await chunker.chunk_text(content)
|
||||||
|
|
||||||
# Assign page numbers to chunks if page boundaries are available (PDFs)
|
# Assign page numbers to chunks if page boundaries are available (PDFs)
|
||||||
if doc_task.doc_type == "file" and "page_boundaries" in file_metadata:
|
page_boundaries = file_metadata.get("page_boundaries")
|
||||||
|
if doc_task.doc_type == "file" and page_boundaries is not None:
|
||||||
with trace_operation(
|
with trace_operation(
|
||||||
"vector_sync.assign_page_numbers",
|
"vector_sync.assign_page_numbers",
|
||||||
attributes={
|
attributes={
|
||||||
"vector_sync.chunk_count": len(chunks),
|
"vector_sync.chunk_count": len(chunks),
|
||||||
"vector_sync.page_count": len(file_metadata["page_boundaries"]),
|
"vector_sync.page_count": len(page_boundaries),
|
||||||
},
|
},
|
||||||
):
|
):
|
||||||
assign_page_numbers(chunks, file_metadata["page_boundaries"])
|
assign_page_numbers(chunks, page_boundaries)
|
||||||
|
|
||||||
# Diagnostic: Verify page number assignment
|
# Diagnostic: Verify page number assignment
|
||||||
assigned_count = sum(1 for c in chunks if c.page_number is not None)
|
assigned_count = sum(1 for c in chunks if c.page_number is not None)
|
||||||
@@ -389,8 +429,8 @@ async def _index_document(
|
|||||||
f"Text length: {len(content)}, "
|
f"Text length: {len(content)}, "
|
||||||
f"Chunks: {len(chunks)}, "
|
f"Chunks: {len(chunks)}, "
|
||||||
f"Chunk offset range: [{chunks[0].start_offset}:{chunks[-1].end_offset}], "
|
f"Chunk offset range: [{chunks[0].start_offset}:{chunks[-1].end_offset}], "
|
||||||
f"Page boundaries: {len(file_metadata['page_boundaries'])} pages, "
|
f"Page boundaries: {len(page_boundaries)} pages, "
|
||||||
f"First boundary: {file_metadata['page_boundaries'][0] if file_metadata['page_boundaries'] else 'None'}"
|
f"First boundary: {page_boundaries[0] if page_boundaries else 'None'}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Extract chunk texts for embedding
|
# Extract chunk texts for embedding
|
||||||
@@ -566,6 +606,23 @@ async def _index_document(
|
|||||||
if doc_task.doc_type == "file"
|
if doc_task.doc_type == "file"
|
||||||
else {}
|
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 {}
|
||||||
|
),
|
||||||
# Highlighted page image (PDF only)
|
# Highlighted page image (PDF only)
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -206,7 +206,11 @@ async def scan_user_documents(
|
|||||||
limit=10000,
|
limit=10000,
|
||||||
)
|
)
|
||||||
|
|
||||||
indexed_doc_ids = {point.payload["doc_id"] 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_doc_ids)} indexed documents in Qdrant")
|
logger.debug(f"Found {len(indexed_doc_ids)} indexed documents in Qdrant")
|
||||||
|
|
||||||
@@ -376,7 +380,9 @@ async def scan_user_documents(
|
|||||||
)
|
)
|
||||||
|
|
||||||
indexed_file_ids = {
|
indexed_file_ids = {
|
||||||
point.payload["doc_id"] for point in file_scroll_result[0]
|
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")
|
logger.debug(f"Found {len(indexed_file_ids)} indexed files in Qdrant")
|
||||||
@@ -544,9 +550,206 @@ async def scan_user_documents(
|
|||||||
|
|
||||||
queued += file_queued
|
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}")
|
||||||
|
|
||||||
if queued > 0:
|
if queued > 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Sent {queued} documents ({file_queued} files) for incremental sync: {user_id}"
|
f"Sent {queued} documents ({file_queued} files, {news_queued} news items) for incremental sync: {user_id}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.debug(f"No changes detected for {user_id}")
|
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
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.48.2"
|
version = "0.49.1"
|
||||||
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
||||||
@@ -36,6 +36,7 @@ dependencies = [
|
|||||||
"python-json-logger>=3.2.0", # Structured JSON logging
|
"python-json-logger>=3.2.0", # Structured JSON logging
|
||||||
"jinja2>=3.1.6",
|
"jinja2>=3.1.6",
|
||||||
"langchain-text-splitters>=1.0.0",
|
"langchain-text-splitters>=1.0.0",
|
||||||
|
"markdownify>=0.14.1", # HTML to Markdown conversion for News items
|
||||||
"pymupdf>=1.26.6",
|
"pymupdf>=1.26.6",
|
||||||
"pymupdf4llm>=0.2.2",
|
"pymupdf4llm>=0.2.2",
|
||||||
"pymupdf-layout>=1.26.6",
|
"pymupdf-layout>=1.26.6",
|
||||||
|
|||||||
+7
-1
@@ -4,5 +4,11 @@
|
|||||||
"config:best-practices",
|
"config:best-practices",
|
||||||
"mergeConfidence:all-badges"
|
"mergeConfidence:all-badges"
|
||||||
],
|
],
|
||||||
"dependencyDashboard": true
|
"dependencyDashboard": true,
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"matchPackageNames": ["pillow"],
|
||||||
|
"allowedVersions": "<12.0.0"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -480,3 +480,222 @@ def create_mock_table_row_ocs_response(
|
|||||||
ocs_response = {"ocs": {"meta": {"status": "ok"}, "data": row_data}}
|
ocs_response = {"ocs": {"meta": {"status": "ok"}, "data": row_data}}
|
||||||
|
|
||||||
return create_mock_response(status_code=200, json_data=ocs_response)
|
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,561 @@
|
|||||||
|
"""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 a single item by ID."""
|
||||||
|
item = create_mock_news_item(item_id=123, title="Single Item")
|
||||||
|
mock_response = create_mock_response(status_code=200, json_data=item)
|
||||||
|
|
||||||
|
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_item(item_id=123)
|
||||||
|
|
||||||
|
assert result["id"] == 123
|
||||||
|
assert result["title"] == "Single Item"
|
||||||
|
|
||||||
|
mock_make_request.assert_called_once_with("GET", "/apps/news/api/v1-3/items/123")
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
@@ -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,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()
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""MCP sampling support for integration tests.
|
"""MCP sampling support for integration tests.
|
||||||
|
|
||||||
This module provides utilities to enable real LLM-based sampling in integration tests
|
This module provides utilities to enable real LLM-based sampling in integration tests
|
||||||
using OpenAI or GitHub Models API.
|
using any provider that supports text generation (OpenAI, Ollama, Anthropic, Bedrock).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -10,46 +10,58 @@ from typing import Any
|
|||||||
from mcp import types
|
from mcp import types
|
||||||
from mcp.client.session import ClientSession, RequestContext
|
from mcp.client.session import ClientSession, RequestContext
|
||||||
|
|
||||||
from nextcloud_mcp_server.providers.openai import OpenAIProvider
|
from nextcloud_mcp_server.providers.base import Provider
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def create_openai_sampling_callback(provider: OpenAIProvider):
|
def create_sampling_callback(provider: Provider):
|
||||||
"""Factory to create a sampling callback using OpenAI provider.
|
"""Factory to create a sampling callback using any generation-capable provider.
|
||||||
|
|
||||||
The callback conforms to MCP's SamplingFnT protocol and can be passed
|
The callback conforms to MCP's SamplingFnT protocol and can be passed
|
||||||
to ClientSession for handling sampling requests from the server.
|
to ClientSession for handling sampling requests from the server.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
provider: OpenAIProvider instance configured with a generation model
|
provider: Any Provider instance that supports generation
|
||||||
|
(supports_generation=True)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Async callback function for MCP sampling
|
Async callback function for MCP sampling
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If provider doesn't support generation
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
```python
|
```python
|
||||||
provider = OpenAIProvider(
|
from nextcloud_mcp_server.providers import get_provider
|
||||||
api_key=os.getenv("OPENAI_API_KEY"),
|
|
||||||
base_url=os.getenv("OPENAI_BASE_URL"),
|
|
||||||
generation_model="gpt-4o-mini",
|
|
||||||
)
|
|
||||||
callback = create_openai_sampling_callback(provider)
|
|
||||||
|
|
||||||
async for session in create_mcp_client_session(
|
provider = get_provider() # Auto-detect from environment
|
||||||
url="http://localhost:8000/mcp",
|
if provider.supports_generation:
|
||||||
sampling_callback=callback,
|
callback = create_sampling_callback(provider)
|
||||||
):
|
|
||||||
# Session now supports sampling
|
async for session in create_mcp_client_session(
|
||||||
pass
|
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(
|
async def sampling_callback(
|
||||||
context: RequestContext[ClientSession, Any],
|
context: RequestContext[ClientSession, Any],
|
||||||
params: types.CreateMessageRequestParams,
|
params: types.CreateMessageRequestParams,
|
||||||
) -> types.CreateMessageResult | types.ErrorData:
|
) -> types.CreateMessageResult | types.ErrorData:
|
||||||
"""Handle sampling requests using OpenAI provider."""
|
"""Handle sampling requests using the configured provider."""
|
||||||
logger.debug(f"Sampling callback invoked with {len(params.messages)} messages")
|
logger.debug(f"Sampling callback invoked with {len(params.messages)} messages")
|
||||||
|
|
||||||
# Extract messages and build prompt
|
# Extract messages and build prompt
|
||||||
@@ -68,14 +80,13 @@ def create_openai_sampling_callback(provider: OpenAIProvider):
|
|||||||
logger.debug(f"Generating response for prompt ({len(prompt)} chars)")
|
logger.debug(f"Generating response for prompt ({len(prompt)} chars)")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Generate response using OpenAI provider
|
# Generate response using provider
|
||||||
# Note: temperature is hardcoded in the provider at 0.7
|
# Note: temperature is typically hardcoded in providers at 0.7
|
||||||
response = await provider.generate(
|
response = await provider.generate(
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
max_tokens=params.maxTokens,
|
max_tokens=params.maxTokens,
|
||||||
)
|
)
|
||||||
|
|
||||||
model_name = provider.generation_model or "unknown"
|
|
||||||
logger.info(f"Sampling completed: {len(response)} chars from {model_name}")
|
logger.info(f"Sampling completed: {len(response)} chars from {model_name}")
|
||||||
|
|
||||||
return types.CreateMessageResult(
|
return types.CreateMessageResult(
|
||||||
@@ -85,10 +96,25 @@ def create_openai_sampling_callback(provider: OpenAIProvider):
|
|||||||
stopReason="endTurn",
|
stopReason="endTurn",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"OpenAI generation failed: {e}")
|
logger.error(f"Generation failed ({provider.__class__.__name__}): {e}")
|
||||||
return types.ErrorData(
|
return types.ErrorData(
|
||||||
code=types.INTERNAL_ERROR,
|
code=types.INTERNAL_ERROR,
|
||||||
message=f"OpenAI generation failed: {e!s}",
|
message=f"Generation failed: {e!s}",
|
||||||
)
|
)
|
||||||
|
|
||||||
return sampling_callback
|
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)
|
||||||
|
|||||||
@@ -1,26 +1,33 @@
|
|||||||
"""Integration tests for RAG pipeline with OpenAI/GitHub Models API.
|
"""Integration tests for RAG pipeline with multiple LLM providers.
|
||||||
|
|
||||||
These tests validate the complete semantic search and MCP sampling flow using:
|
These tests validate the complete semantic search and MCP sampling flow using:
|
||||||
1. OpenAI embeddings for semantic search
|
1. MCP server's built-in semantic search (embeddings handled server-side)
|
||||||
2. MCP sampling for answer generation
|
2. MCP sampling for answer generation (any generation-capable provider)
|
||||||
3. Pre-indexed Nextcloud User Manual as the knowledge base
|
3. Pre-indexed Nextcloud User Manual as the knowledge base
|
||||||
|
|
||||||
Environment Variables:
|
Usage:
|
||||||
OPENAI_API_KEY: OpenAI API key or GitHub token for models.github.ai
|
# Run with OpenAI (including GitHub Models API)
|
||||||
OPENAI_BASE_URL: Base URL override (e.g., "https://models.github.ai/inference")
|
OPENAI_API_KEY=... pytest tests/integration/test_rag.py --provider=openai -v
|
||||||
OPENAI_EMBEDDING_MODEL: Embedding model (default: "text-embedding-3-small")
|
|
||||||
OPENAI_GENERATION_MODEL: Generation model for sampling (default: "gpt-4o-mini")
|
|
||||||
RAG_MANUAL_PATH: Path to manual PDF in Nextcloud (default: "Nextcloud_User_Manual.pdf")
|
|
||||||
|
|
||||||
For GitHub CI, set:
|
# Run with Ollama
|
||||||
OPENAI_API_KEY: ${{ secrets.GITHUB_TOKEN }}
|
OLLAMA_BASE_URL=http://localhost:11434 OLLAMA_GENERATION_MODEL=llama3.2:1b \\
|
||||||
OPENAI_BASE_URL: https://models.github.ai/inference
|
pytest tests/integration/test_rag.py --provider=ollama -v
|
||||||
OPENAI_EMBEDDING_MODEL: openai/text-embedding-3-small
|
|
||||||
OPENAI_GENERATION_MODEL: openai/gpt-4o-mini
|
# 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:
|
Prerequisites:
|
||||||
- Nextcloud User Manual PDF uploaded to Nextcloud
|
- Nextcloud User Manual PDF uploaded to Nextcloud
|
||||||
- VECTOR_SYNC_ENABLED=true on the MCP server
|
- VECTOR_SYNC_ENABLED=true on the MCP server
|
||||||
|
- Provider-specific environment variables set
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -33,9 +40,10 @@ import anyio
|
|||||||
import pytest
|
import pytest
|
||||||
from mcp import ClientSession
|
from mcp import ClientSession
|
||||||
|
|
||||||
from nextcloud_mcp_server.providers.openai import OpenAIProvider
|
from nextcloud_mcp_server.providers.base import Provider
|
||||||
from tests.conftest import create_mcp_client_session
|
from tests.conftest import create_mcp_client_session
|
||||||
from tests.integration.sampling_support import create_openai_sampling_callback
|
from tests.integration.provider_fixtures import create_generation_provider
|
||||||
|
from tests.integration.sampling_support import create_sampling_callback
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -44,14 +52,14 @@ DEFAULT_MANUAL_PATH = "Nextcloud Manual.pdf"
|
|||||||
|
|
||||||
|
|
||||||
async def llm_judge(
|
async def llm_judge(
|
||||||
provider: "OpenAIProvider",
|
provider: Provider,
|
||||||
ground_truth: str,
|
ground_truth: str,
|
||||||
system_output: str,
|
system_output: str,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Use LLM to judge if system output aligns with ground truth.
|
"""Use LLM to judge if system output aligns with ground truth.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
provider: OpenAI provider with generation capability
|
provider: Any provider with generation capability
|
||||||
ground_truth: The expected/reference answer
|
ground_truth: The expected/reference answer
|
||||||
system_output: The system's actual output to evaluate
|
system_output: The system's actual output to evaluate
|
||||||
|
|
||||||
@@ -66,17 +74,18 @@ Does the system output contain the key facts from the ground truth?
|
|||||||
|
|
||||||
Answer: TRUE or FALSE"""
|
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)
|
response = await provider.generate(prompt, max_tokens=10)
|
||||||
|
logger.info("LLM Judge response: %s", response)
|
||||||
return "TRUE" in response.upper()
|
return "TRUE" in response.upper()
|
||||||
|
|
||||||
|
|
||||||
# Skip all tests if OpenAI API key not configured
|
# Mark all tests as integration tests
|
||||||
pytestmark = [
|
pytestmark = [
|
||||||
pytest.mark.integration,
|
pytest.mark.integration,
|
||||||
pytest.mark.skipif(
|
pytest.mark.rag,
|
||||||
not os.getenv("OPENAI_API_KEY"),
|
|
||||||
reason="OPENAI_API_KEY not set - skipping OpenAI RAG tests",
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Ground truth fixture path
|
# Ground truth fixture path
|
||||||
@@ -175,78 +184,49 @@ async def indexed_manual_pdf(nc_client, nc_mcp_client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
async def openai_provider():
|
def provider_name(request) -> str:
|
||||||
"""OpenAI provider configured from environment (embeddings only)."""
|
"""Get the provider name from --provider flag.
|
||||||
api_key = os.getenv("OPENAI_API_KEY")
|
|
||||||
base_url = os.getenv("OPENAI_BASE_URL")
|
|
||||||
embedding_model = os.getenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small")
|
|
||||||
|
|
||||||
provider = OpenAIProvider(
|
Raises pytest.skip if --provider not specified.
|
||||||
api_key=api_key,
|
"""
|
||||||
base_url=base_url,
|
name = request.config.getoption("--provider")
|
||||||
embedding_model=embedding_model,
|
if not name:
|
||||||
generation_model=None, # Embeddings only
|
pytest.skip("--provider flag required (openai, ollama, anthropic, bedrock)")
|
||||||
)
|
return name
|
||||||
|
|
||||||
yield provider
|
|
||||||
await provider.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
async def openai_generation_provider():
|
async def generation_provider(provider_name: str) -> AsyncGenerator[Provider, None]:
|
||||||
"""OpenAI provider configured for text generation (for sampling callback)."""
|
"""Provider configured for text generation.
|
||||||
api_key = os.getenv("OPENAI_API_KEY")
|
|
||||||
base_url = os.getenv("OPENAI_BASE_URL")
|
|
||||||
generation_model = os.getenv("OPENAI_GENERATION_MODEL", "gpt-4o-mini")
|
|
||||||
|
|
||||||
# For GitHub Models API, use the prefixed model name
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
Requires --provider flag to be set.
|
||||||
|
"""
|
||||||
|
provider = await create_generation_provider(provider_name)
|
||||||
yield provider
|
yield provider
|
||||||
await provider.close()
|
await provider.close()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
async def nc_mcp_client_with_sampling(
|
async def nc_mcp_client_with_sampling(
|
||||||
anyio_backend, openai_generation_provider
|
anyio_backend, generation_provider, provider_name
|
||||||
) -> AsyncGenerator[ClientSession, Any]:
|
) -> AsyncGenerator[ClientSession, Any]:
|
||||||
"""MCP client with OpenAI-based sampling support.
|
"""MCP client with sampling support using the specified provider.
|
||||||
|
|
||||||
This fixture creates an MCP client that can handle sampling requests
|
This fixture creates an MCP client that can handle sampling requests
|
||||||
from the server using OpenAI for text generation.
|
from the server using the configured generation provider.
|
||||||
"""
|
"""
|
||||||
sampling_callback = create_openai_sampling_callback(openai_generation_provider)
|
sampling_callback = create_sampling_callback(generation_provider)
|
||||||
|
|
||||||
async for session in create_mcp_client_session(
|
async for session in create_mcp_client_session(
|
||||||
url="http://localhost:8000/mcp",
|
url="http://localhost:8000/mcp",
|
||||||
client_name="OpenAI Sampling MCP",
|
client_name=f"Sampling MCP ({provider_name})",
|
||||||
sampling_callback=sampling_callback,
|
sampling_callback=sampling_callback,
|
||||||
):
|
):
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
async def test_openai_embeddings_work(openai_provider: OpenAIProvider):
|
|
||||||
"""Test that OpenAI embeddings can be generated."""
|
|
||||||
embedding = await openai_provider.embed("test query about Nextcloud")
|
|
||||||
|
|
||||||
assert isinstance(embedding, list)
|
|
||||||
assert len(embedding) > 0
|
|
||||||
assert all(isinstance(x, float) for x in embedding)
|
|
||||||
# OpenAI embedding dimensions: 1536 (small) or 3072 (large)
|
|
||||||
assert len(embedding) in [1536, 3072]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_semantic_search_retrieval(
|
async def test_semantic_search_retrieval(
|
||||||
nc_mcp_client, ground_truth_qa, indexed_manual_pdf, openai_generation_provider
|
nc_mcp_client, ground_truth_qa, indexed_manual_pdf, generation_provider
|
||||||
):
|
):
|
||||||
"""Test that semantic search retrieves relevant documents from the manual.
|
"""Test that semantic search retrieves relevant documents from the manual.
|
||||||
|
|
||||||
@@ -278,7 +258,7 @@ async def test_semantic_search_retrieval(
|
|||||||
# Use LLM judge to evaluate if excerpts are relevant to ground truth
|
# Use LLM judge to evaluate if excerpts are relevant to ground truth
|
||||||
all_excerpts = " ".join([r["excerpt"] for r in data["results"]])
|
all_excerpts = " ".join([r["excerpt"] for r in data["results"]])
|
||||||
is_relevant = await llm_judge(
|
is_relevant = await llm_judge(
|
||||||
openai_generation_provider,
|
generation_provider,
|
||||||
test_case["ground_truth"],
|
test_case["ground_truth"],
|
||||||
all_excerpts,
|
all_excerpts,
|
||||||
)
|
)
|
||||||
@@ -289,16 +269,16 @@ async def test_semantic_search_answer_with_sampling(
|
|||||||
nc_mcp_client_with_sampling,
|
nc_mcp_client_with_sampling,
|
||||||
ground_truth_qa,
|
ground_truth_qa,
|
||||||
indexed_manual_pdf,
|
indexed_manual_pdf,
|
||||||
openai_generation_provider,
|
generation_provider,
|
||||||
):
|
):
|
||||||
"""Test semantic search with MCP sampling for answer generation.
|
"""Test semantic search with MCP sampling for answer generation.
|
||||||
|
|
||||||
This tests the full RAG pipeline:
|
This tests the full RAG pipeline:
|
||||||
1. Semantic search retrieves relevant documents
|
1. Semantic search retrieves relevant documents
|
||||||
2. MCP sampling generates an answer from the retrieved context
|
2. MCP sampling generates an answer from the retrieved context
|
||||||
3. OpenAI generates the answer via the sampling callback
|
3. Provider generates the answer via the sampling callback
|
||||||
|
|
||||||
Uses nc_mcp_client_with_sampling which has OpenAI-based sampling enabled.
|
Uses nc_mcp_client_with_sampling which has sampling enabled.
|
||||||
"""
|
"""
|
||||||
# Use the 2FA question - has clear expected answer
|
# Use the 2FA question - has clear expected answer
|
||||||
test_case = ground_truth_qa[0]
|
test_case = ground_truth_qa[0]
|
||||||
@@ -348,7 +328,7 @@ async def test_semantic_search_answer_with_sampling(
|
|||||||
|
|
||||||
# Use LLM judge to evaluate answer relevance
|
# Use LLM judge to evaluate answer relevance
|
||||||
is_relevant = await llm_judge(
|
is_relevant = await llm_judge(
|
||||||
openai_generation_provider,
|
generation_provider,
|
||||||
test_case["ground_truth"],
|
test_case["ground_truth"],
|
||||||
data["generated_answer"],
|
data["generated_answer"],
|
||||||
)
|
)
|
||||||
@@ -189,25 +189,14 @@ async def test_get_file_info_returns_none_for_missing_file(mocker):
|
|||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
async def test_create_tag_creates_system_tag(mocker):
|
async def test_create_tag_creates_system_tag(mocker):
|
||||||
"""Test that create_tag creates a system tag via OCS API."""
|
"""Test that create_tag creates a system tag via WebDAV."""
|
||||||
mock_http_client = AsyncMock()
|
mock_http_client = AsyncMock()
|
||||||
client = WebDAVClient(mock_http_client, "testuser")
|
client = WebDAVClient(mock_http_client, "testuser")
|
||||||
|
|
||||||
# Mock OCS response
|
# Mock WebDAV response with Content-Location header
|
||||||
mock_response = AsyncMock()
|
mock_response = AsyncMock()
|
||||||
mock_response.status_code = 200
|
mock_response.status_code = 201
|
||||||
mock_response.json = mocker.Mock(
|
mock_response.headers = {"Content-Location": "/remote.php/dav/systemtags/42"}
|
||||||
return_value={
|
|
||||||
"ocs": {
|
|
||||||
"data": {
|
|
||||||
"id": 42,
|
|
||||||
"name": "vector-index",
|
|
||||||
"userVisible": True,
|
|
||||||
"userAssignable": True,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
mock_response.raise_for_status = mocker.Mock()
|
mock_response.raise_for_status = mocker.Mock()
|
||||||
|
|
||||||
mock_http_client.post = AsyncMock(return_value=mock_response)
|
mock_http_client.post = AsyncMock(return_value=mock_response)
|
||||||
@@ -224,8 +213,10 @@ async def test_create_tag_creates_system_tag(mocker):
|
|||||||
# Verify API call
|
# Verify API call
|
||||||
mock_http_client.post.assert_called_once()
|
mock_http_client.post.assert_called_once()
|
||||||
call_args = mock_http_client.post.call_args
|
call_args = mock_http_client.post.call_args
|
||||||
assert call_args[0][0] == "/ocs/v2.php/apps/systemtags/api/v1/tags"
|
assert call_args[0][0] == "/remote.php/dav/systemtags/"
|
||||||
assert call_args[1]["json"]["name"] == "vector-index"
|
assert call_args[1]["json"]["name"] == "vector-index"
|
||||||
|
assert call_args[1]["json"]["userVisible"] is True
|
||||||
|
assert call_args[1]["json"]["userAssignable"] is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
|
|||||||
Reference in New Issue
Block a user