Merge pull request #750 from cbcoutinho/feat/verify-on-read-semantic-search
feat(search): verify-on-read for semantic search (ADR-019)
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
---
|
||||
name: pre-push-review
|
||||
description: |
|
||||
Self-review the current branch's diff against nextcloud-mcp-server review patterns
|
||||
before pushing a PR. Runs ruff/ty/tests on changed files and produces a punch list
|
||||
of likely review-round findings, calibrated to issues that have repeatedly surfaced
|
||||
in this repo's automated PR reviews. Use when the user is about to push, says "ready
|
||||
to push", "review my work", "check before PR", or invokes /pre-push-review.
|
||||
Report-only — does not modify code.
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
---
|
||||
|
||||
# Pre-Push Review
|
||||
|
||||
## Purpose
|
||||
|
||||
Catch the issues that have repeatedly surfaced in PR reviews on this repo (#733–#750)
|
||||
*before* the human reviewer or the automated `claude` PR-review bot sees them. Output is
|
||||
a labelled punch list. The main loop decides what to fix.
|
||||
|
||||
This skill is calibrated to **this repo's recurring patterns** — not a generic code
|
||||
review. The point is to short-circuit the review-round-N loop, not to replace human
|
||||
judgment.
|
||||
|
||||
## When to Use
|
||||
|
||||
**Trigger when:**
|
||||
- User says "review my work", "ready to push", "check before PR", "pre-push", or
|
||||
invokes `/pre-push-review`.
|
||||
- After a substantive change (new module, new tool, refactor, behavior change) and
|
||||
before `git push`.
|
||||
- Before opening or updating a PR.
|
||||
|
||||
**Skip when:**
|
||||
- Tiny diffs (typo fix, README tweak, dependency bump only).
|
||||
- User has explicitly said "just push it" / "skip the review".
|
||||
- Branch is `master` or has zero commits ahead of base.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Phase 1 — Establish scope (≤ 30s)
|
||||
|
||||
Determine the base branch and diff range. Default base is `master`.
|
||||
|
||||
```bash
|
||||
git fetch origin master --quiet
|
||||
BASE=$(git merge-base HEAD origin/master)
|
||||
git rev-list --count $BASE..HEAD # commits ahead
|
||||
git diff --stat $BASE..HEAD # files touched
|
||||
git log --format="%h %s" $BASE..HEAD # commit list
|
||||
```
|
||||
|
||||
If the user names a different base (e.g. `main`, a stacked branch), use that instead.
|
||||
|
||||
**Identify the change shape** — these classifications drive scope-aware checks (Phase 3):
|
||||
|
||||
| Touches | Treat as |
|
||||
|---|---|
|
||||
| `nextcloud_mcp_server/auth/`, `*scope*`, `*token*`, `*verifier*` | **security-sensitive** (escalate B, J, K to blocking) |
|
||||
| `nextcloud_mcp_server/server/*.py` (new `@mcp.tool`) | **MCP surface** (D4, I1 mandatory) |
|
||||
| `nextcloud_mcp_server/client/*.py` | **client layer** (B1–B4, H1, retry/backoff) |
|
||||
| `nextcloud_mcp_server/models/*.py` | **schema** (D1–D3, validators for PHP quirks) |
|
||||
| `nextcloud_mcp_server/search/`, `nextcloud_mcp_server/vector/` | **vector subsystem** (E*, F3, latency budget) |
|
||||
| Webhooks, `nextcloud_mcp_server/auth/webhook_*` | **webhook handler** (J3, J4, K1) |
|
||||
| Tests only | **test scope** (skip A–E, run F1–F4) |
|
||||
| Docs / ADR only | **docs scope** (run G3, G4 only) |
|
||||
|
||||
**Report up front:** branch, base, commits ahead, files changed, change shape.
|
||||
|
||||
### Phase 2 — Automated checks (in parallel, ≤ 30s)
|
||||
|
||||
Run these from the project root with separate `Bash` calls in **one message** so they
|
||||
run concurrently:
|
||||
|
||||
```bash
|
||||
uv run ruff check # lint
|
||||
uv run ruff format --check # formatting
|
||||
uv run ty check -- nextcloud_mcp_server # types
|
||||
uv run pytest tests/unit/ -x --no-header -q # unit tests
|
||||
```
|
||||
|
||||
If the diff touches `nextcloud_mcp_server/server/` or `tests/server/`, also run:
|
||||
|
||||
```bash
|
||||
uv run pytest -m smoke -x --no-header -q
|
||||
```
|
||||
|
||||
**If any automated check fails: stop and report.** Don't run the checklist on top of a
|
||||
broken build — fixing the failures may eliminate findings or change the diff.
|
||||
|
||||
### Phase 3 — Read the diff and run the project checklist (2–5min)
|
||||
|
||||
```bash
|
||||
git diff $BASE..HEAD # full diff
|
||||
git diff $BASE..HEAD -- '*.py' | head -2000 # python only, capped
|
||||
```
|
||||
|
||||
Read the **whole diff** before composing findings. Cross-file patterns (test symmetry,
|
||||
single-source-of-truth, deduplicated logic) are only visible in aggregate.
|
||||
|
||||
For each violation, capture:
|
||||
- **Severity label** (🔴 / 🟡 / 🟢 — see Severity Guide below)
|
||||
- **Category tag** (A1, B2, J3, etc.)
|
||||
- **File:line** (new-side line number)
|
||||
- **One-sentence note** — what's wrong, with a concrete fix direction
|
||||
|
||||
### Phase 4 — Output the punch list
|
||||
|
||||
Format follows the template in [§ Output Template](#output-template). Group by
|
||||
severity. Include a brief **Strengths** section to balance the findings — this
|
||||
matches the tone of the bot review on PRs #733–#747 and avoids the impression that
|
||||
the review is purely negative.
|
||||
|
||||
**Hard rules:**
|
||||
- Do **not** edit any files.
|
||||
- Do **not** propose patches inline (one-line fix direction is OK; full code blocks are not).
|
||||
- Do **not** repeat findings the linter or type checker already surfaces.
|
||||
- If a finding is style-only and CLAUDE.md doesn't mandate it, demote to 🟢.
|
||||
|
||||
---
|
||||
|
||||
## Severity Guide
|
||||
|
||||
| Label | Meaning | Examples |
|
||||
|---|---|---|
|
||||
| 🔴 **blocking** | Must fix before push. Real bug, security issue, or violates an ADR/CLAUDE.md mandate. | Cache-hit bypass on auth gate; raw `List[Dict]` from MCP tool; missing `await` on async call; HMAC compare with strings instead of bytes. |
|
||||
| 🟡 **important** | Should fix. Not a bug today but a footgun that has caused review-rounds before. | Field description mismatches value; unbounded fan-out without semaphore; `int()` cast inside catch-all `except`; missing `openWorldHint=True`. |
|
||||
| 🟢 **nit** | Polish. Not blocking, would be nice. | `Optional[X]` when surrounding file uses `X | None`; redundant regex anchors; missing test docstring; pluralization in error messages. |
|
||||
| 💡 **suggestion** | Alternative approach to consider. No fix expected. | "Could use `frozenset` here for O(1) lookup"; "Could centralise this rewrite in `_make_request`". |
|
||||
| 🎉 **praise** | Notable good pattern. Worth calling out for visibility. | Cache-hit AND cache-miss paths both enforce the gate; CI-guard test for registry exhaustiveness; explicit `*` keyword-only separator. |
|
||||
|
||||
**Escalation rules** (apply per change shape from Phase 1):
|
||||
- Security-sensitive scope → all J* and K* findings are at least 🟡; auth bypass risks are 🔴.
|
||||
- MCP surface → D4 (annotations) and I1 (response wrapping) are 🔴.
|
||||
- Client layer → H1 (duplicate round-trips) is 🟡 minimum.
|
||||
|
||||
---
|
||||
|
||||
## Project Checklist
|
||||
|
||||
Each item is tagged for use in the punch list (e.g. `[B1]`). Items are mined from
|
||||
PRs #733, #734, #735, #736, #737, #741, #745, #746, #747, #750.
|
||||
|
||||
### A. Style & typing (CLAUDE.md)
|
||||
|
||||
- **A1** — PEP 604 unions: `X | None`, never `Optional[X]`. (#737, #745, #735)
|
||||
- **A2** — Lowercase generics: `dict[str, Any]`, `list[T]`, never `Dict`/`List`. (#736, #745)
|
||||
- **A3** — `anyio` not `asyncio`: `anyio.create_task_group`, `anyio.Lock`, `anyio.run`. Flag any new `asyncio.gather`/`asyncio.Lock`/`asyncio.run`.
|
||||
- **A4** — Lazy `%`-style logging: `logger.warning("msg %s", var)`, not f-strings, in any file touched on this branch. (memory: feedback_lazy_logging)
|
||||
- **A5** — Typed signatures: every new/modified function has parameter and return type hints. (#733)
|
||||
- **A6** — Stay consistent with surrounding file: if a touched file uses legacy `Dict`/`Optional`, flag the introduced inconsistency, not pre-existing debt. (#736, #735, #745)
|
||||
|
||||
### B. Error handling specificity
|
||||
|
||||
- **B1** — Hoist parsing/casts before network calls: `int()`, `json.loads()`, etc. in their own `try/except (TypeError, ValueError)` *before* the network call. Catch-all `except Exception` over the parse + network produces "unexpected error" instead of a specific log line. (#750-r3, #750-r5)
|
||||
- **B2** — Narrow exception types: `except RuntimeError`, not bare `except Exception`, when only one failure mode is documented. (#750-r6)
|
||||
- **B3** — Specific error messages with context: include the problematic value and its surrounding context (e.g. `f"expected int id for {doc_type}, got {type(value).__name__}: {value!r}"`), not opaque re-raises. (#750-r6)
|
||||
- **B4** — Fail-open vs fail-closed is a deliberate choice: any new fail-open branch (transient error → keep going) needs a log line and a brief comment on *why* fail-open is correct here. (#750-r2)
|
||||
- **B5** — Except clause order: when catching a subclass and superclass in the same `try`, the subclass must come first. `except TimeoutError` before `except Exception` (TimeoutError is OSError → Exception subtype). (#747)
|
||||
- **B6** — Defensive parsing envelope: when extracting fields from external payloads, wrap in `try/except (KeyError, TypeError, ValueError)` and return a sentinel. Bubbling `ValueError` from `int("not-a-number")` to the caller is a finding. (#747)
|
||||
|
||||
### C. Comment honesty & documentation
|
||||
|
||||
- **C1** — Comments must match behavior: don't say "background" if the code runs inline; don't say "evicts asynchronously" if it `await`s. (#750-r1)
|
||||
- **C2** — Document all return-None / sentinel cases: if a function returns `None` for both 404 *and* malformed XML, the docstring must enumerate both. (#750-r6)
|
||||
- **C3** — Cross-references point at canonical/earlier definition: "Mirrors `_verify_X`" should reference the function defined first. (#750-r1)
|
||||
- **C4** — TODOs include WHY: `# TODO(perf): get_items(batch_size=-1) fetches all items at query time` ✓; `# TODO: optimize` ✗. (#750-r3)
|
||||
- **C5** — Defensive code carries its reason: `.get(key, default)` that "shouldn't fire in practice" needs a comment saying so — otherwise readers treat it as load-bearing. (#750-r6)
|
||||
- **C6** — Surprising-on-first-read code needs a one-liner: e.g. `password=token` for bearer auth is surprising; a `# caldav reuses the password slot for bearer tokens` line saves readers from suspecting copy-paste bugs. (#734)
|
||||
- **C7** — Mark deliberate omissions: when a code path *intentionally* skips a guard (e.g. DELETE returns empty body, no shape guard), say so explicitly. Otherwise future maintainers will treat it as an oversight. (#736)
|
||||
|
||||
### D. API & type boundaries
|
||||
|
||||
- **D1** — Public response models narrow, internal types may widen: if `SearchResult.id: int | str` for forward-compat, `SemanticSearchResult.id: int` and convert at the boundary with an explicit cast that fails loudly. (#750-r3)
|
||||
- **D2** — Field descriptions match the value: a field described as "unique documents" must hold a unique-document count, not a chunk count. Watch for `len(results)` assigned to a field whose docstring implies dedup. (#750-r1, #735)
|
||||
- **D3** — Symmetry between related fields: `verified_count` and `dropped_count` should count at the same granularity. (#750-r6)
|
||||
- **D4** — MCP tool annotations (ADR-017): tools that hit Nextcloud have `openWorldHint=True`. Read-only ops have `readOnlyHint=True`. Destructive ops have `destructiveHint=True` + `idempotentHint=True`. Create ops have `idempotentHint=False`. Update ops have `idempotentHint=False` (etag changes mean different inputs). (#741)
|
||||
- **D5** — Keyword-only separators: use `*` in signatures with multiple optional params of the same/related types, e.g. `def __init__(self, *, password=None, token=None)` to prevent positional misuse. (#734)
|
||||
- **D6** — Defaults match documented constraints: if the docstring says "max 1000 characters", add `Field(max_length=1000)` — don't rely on the server returning 400. (#737)
|
||||
- **D7** — Create/update tools return `*Response` wrappers, not raw models: any new `@mcp.tool` returning a raw `BaseModel` (not `BaseResponse` subclass) is a finding. (#737, CLAUDE.md MCP Response Patterns)
|
||||
- **D8** — Pagination metadata: a field called `total` should be the server-side total, not page count. Use `count` for "returned in this page" or add `has_more`. (#737)
|
||||
|
||||
### E. Concurrency, resource bounds & lifespan
|
||||
|
||||
- **E1** — Bound concurrency: any new `asyncio.gather` / task-group fan-out over user data needs an `anyio.Semaphore` cap (default 20 in this repo). (#750-r1)
|
||||
- **E2** — Bound over-fetching: `limit * K` for filtering must have a fixed `K`. Unbounded N-types or unbounded `K` is a finding. (#750-r1)
|
||||
- **E3** — Don't snapshot mutable singletons: `eviction_task_group` set during lifespan startup must be read via `@property`/accessor, not snapshotted into another object's `__init__` (order-sensitive race). (#750-r5)
|
||||
- **E4** — Comment safety of dict/list mutation across tasks: when multiple concurrent tasks write distinct keys to a shared dict, add a one-line comment explaining cooperative-multitasking safety. (#750-r1)
|
||||
- **E5** — Lifespan context symmetry: when adding fields to one of `AppContext` / `OAuthAppContext`, mirror them in the other to prevent silent regressions in OAuth mode. (#746)
|
||||
- **E6** — Security gates apply on cache-hit AND cache-miss paths: an allowlist or scope check must run after both branches resolve. A test asserting cache-hit enforcement is mandatory. (#745)
|
||||
|
||||
### F. Tests
|
||||
|
||||
- **F1** — Symmetry across verifiers/handlers: if you added a 403 test for the notes verifier, add one for files/deck/news too. Asymmetric coverage in a registry-style module is a finding. (#750-r5, #741)
|
||||
- **F2** — Fail-open paths have a test: any verifier or handler with a fail-open branch (non-numeric id, malformed payload) has a unit test that exercises it. (#750-r5, #750-r6)
|
||||
- **F3** — CI guard tests for registries: registries (e.g. `INDEXED_DOC_TYPES → verifier`) should have a test that the registry is exhaustive. (#750-r2)
|
||||
- **F4** — New public functions have tests: any new MCP tool, client method, or Pydantic model without a unit test is a 🟡 finding (or 🟢 if integration-only).
|
||||
- **F5** — `caplog` is logger-scoped: prefer `caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.client.notes")` over plain `caplog.at_level("WARNING")` to avoid false positives from other loggers. (#736)
|
||||
- **F6** — Wire-through tests at the right layer: pure-unit tests on the leaf class are great, but a test that the parent (`NextcloudClient.from_env`) actually threads the param through is a common gap. (#734)
|
||||
- **F7** — Production-evidence regressions get a unit test: any bug found via CloudWatch / production logs (not in tests) needs a unit test added in the fix PR. (#746)
|
||||
- **F8** — Test docstring consistency: if 4 of 5 new tests have docstrings, give the 5th one too. (#735)
|
||||
|
||||
### G. Configuration, docs & ADRs
|
||||
|
||||
- **G1** — New config knobs go through `Settings` + dynaconf validator: any new env var read directly via `os.getenv` in module code (rather than via `Settings`) is a finding. (#750-r5)
|
||||
- **G2** — Resolve config lazily: `default = settings.X` at function-call time, not at decoration / module-import time, so test overrides work. (#750-r5)
|
||||
- **G3** — User-facing docs cover non-obvious costs: new code paths with measurable latency (unbounded fetches, per-item round-trips) need a note in `docs/configuration.md` or the relevant ADR. (#750-r2, #750-r6)
|
||||
- **G4** — ADRs match shipped interface: example code blocks in any modified ADR reflect the actual module's public API. If the ADR shows `Verifier` but the code ships `BatchVerifier`, the ADR is wrong. (#750-r3)
|
||||
- **G5** — Tool docstrings document hidden round-trips: if an MCP tool now does an extra fetch per result (e.g. post-verification race guard), the docstring says so. (#750-r6)
|
||||
- **G6** — Migration notes for breaking changes: a PR that silently changes failure modes (e.g. previously-allowed clients now get 401) needs a migration note in the PR description and ideally a startup log line. (#745)
|
||||
- **G7** — Priority order in env var fallbacks: if a setting reads from `WEBHOOK_INTERNAL_URL` → `NEXTCLOUD_MCP_SERVER_URL` → `/.dockerenv` → localhost, document the order in code comments. (#747)
|
||||
|
||||
### H. Performance hygiene
|
||||
|
||||
- **H1** — No duplicate round-trips: if data needed by a verifier/handler is already in `SearchResult.metadata` or a passed-in object, don't re-scroll/re-fetch it. (#750-r1)
|
||||
- **H2** — Single source of truth: hardcoded lists of doc types / scopes / app names in multiple files should reference one constant (e.g. `INDEXED_DOC_TYPES`). (#750-r2)
|
||||
- **H3** — Limit clamping at the right layer: `min(max(1, limit), 200)` at the client layer gives a clear error rather than a confusing server-side mismatch. (#741)
|
||||
- **H4** — Avoid empty JSON bodies in HTTP requests: `json=body or None` (instead of `json=body`) prevents sending `{}` and a spurious `Content-Type: application/json` header for bodyless calls. (#741)
|
||||
|
||||
### I. MCP response patterns (CLAUDE.md)
|
||||
|
||||
- **I1** — No raw `List[Dict]` from MCP tools: tools return Pydantic models inheriting from `BaseResponse`. FastMCP mangles raw lists into dicts with numeric string keys. **🔴 blocking when violated.**
|
||||
- **I2** — Co-author trailer in commits: each AI-assisted commit ends with `Co-Authored-By: Claude ...`. Missing trailer on AI-touched commits is a 🟢 note.
|
||||
|
||||
### J. Security & input validation
|
||||
|
||||
- **J1** — Validate untrusted identifiers before HTTP layer: room tokens, paths, IDs that go into URLs must be validated against an allowlist regex (e.g. `r"[A-Za-z0-9]+"` with `fullmatch`) *before* the request is built — not after. Path traversal (`../etc/passwd`) is the canonical example. (#741)
|
||||
- **J2** — `hmac.compare_digest` on bytes: both sides encoded to `bytes` before comparison. Comparing strings is incorrect. (#747)
|
||||
- **J3** — HTML-escape user-controlled values in HTML responses: even when the input is gated by an upstream check (e.g. `get_preset(preset_id)` returning `None` for unknown), escape on the success path too. Defense-in-depth, not single-layer. (#747)
|
||||
- **J4** — Static-config-but-still-rendered fields should be escaped consistently: if dynamic fields are escaped and static fields aren't, the inconsistency itself is the smell. (#747)
|
||||
- **J5** — Privacy defaults: defaults for polling/read-receipt parameters should minimize side effects (e.g. `no_status_update=True` for chat polling so users don't appear "online" on every poll). (#741)
|
||||
- **J6** — Server-layer hardening even when client supports it: if an MCP tool calls a client with hardcoded safe values (e.g. `look_into_future=False`), don't expose the unsafe option as a tool parameter. (#741)
|
||||
- **J7** — Redundant regex anchors with `fullmatch()`: `re.compile(r"^[A-Za-z0-9]+$").fullmatch(...)` — the `^`/`$` are redundant and misleading. Use `re.compile(r"[A-Za-z0-9]+")` with `fullmatch`. (#741)
|
||||
|
||||
### K. Trust boundaries
|
||||
|
||||
- **K1** — Comment where input comes from at trust boundaries: webhook payloads, request body fields, query params — say "user-controlled" or "static config" so future readers know the threat model. (#747)
|
||||
- **K2** — Differentiate HTTP status codes by retry semantics: 503 for "try again" (queue saturated, dependency down), 500 for "broken" (genuine bug). 401 without `WWW-Authenticate` is correct when the caller has no auth state machine (e.g. Nextcloud webhook delivery). (#747)
|
||||
- **K3** — Trust-boundary comments at API entrypoints: when a `uri` field in a request body is taken at face value (e.g. Astrolabe provides its own webhook URI), document the trust assumption. (#747)
|
||||
|
||||
### L. Sentinel values & PHP API quirks
|
||||
|
||||
- **L1** — `0` as a valid value vs `None` as "absent": fields like `lastReadMessage` or `expirationTimestamp` may use `0` for "no messages read" or "never expires". Don't truthy-test them. Document semantics in field docstrings. (#741)
|
||||
- **L2** — PHP empty-array-as-list quirks: spreed and other PHP backends serialize empty objects as `[]`. Pydantic validators must coerce `[] → {}` for object-shaped fields and `[] → None` for nullable single-object fields. (#741)
|
||||
- **L3** — `isinstance(value, (date, datetime))` is redundant: `datetime` is a subclass of `date`. Either drop `datetime` from the tuple, or distinguish behavior between the two. (#735)
|
||||
- **L4** — Field description matches serialized form: a field documented as "ISO date format" but accepting `datetime` will serialize as `"2000-01-01T12:30:00"`, not `"2000-01-01"`. Either coerce to date or update the description. (#735)
|
||||
|
||||
### M. Defensive code patterns & symmetry
|
||||
|
||||
- **M1** — `getattr(obj, "field", None)` masks future typos: when an attribute is *expected* to exist post-fix, prefer direct access so a typo (`document_recieve_stream`) fails loudly. The defensive `getattr` is the right call only when the attribute is genuinely optional. (#746)
|
||||
- **M2** — Both-credentials / multiple-mode case has explicit precedence: when two mutually-exclusive params can both be set, either raise `ValueError` or `logger.warning` — silent precedence is a footgun. (#734)
|
||||
- **M3** — Defensive dict construction over passing `None`: `kwargs = {"password": password, "auth_type": "basic"} if password else {"auth_type": "bearer"}` — don't pass `None` into constructors that don't accept it. (#734)
|
||||
- **M4** — Speculative defensive code without observed cause: if you add an unwrap for `[{...}] → {...}` "in case the API returns it", cite the version/route where it's been observed. Otherwise it silently masks contract changes. (#736)
|
||||
- **M5** — Idempotency of URL/path transforms: `_resolve_url` should be safe to call twice. If it adds `/index.php` only when missing, document and test the idempotency. (#733)
|
||||
|
||||
---
|
||||
|
||||
## What NOT to Flag
|
||||
|
||||
- **Pre-existing technical debt** outside the diff. Note as 💡 if relevant, but don't list as a blocking finding.
|
||||
- **Style violations the linter already catches.** ruff and ty are authoritative for what they cover. Manual style checklist is for things they don't cover (PEP 604 unions, lazy logging, `Optional` vs `|`).
|
||||
- **Code formatting** — `ruff format --check` is the source of truth.
|
||||
- **Bikeshed-tier preferences** (variable naming taste, line breaks, comment phrasing).
|
||||
- **Anything you'd phrase as "I would have done it differently"** without a concrete repo-pattern violation.
|
||||
- **Findings that duplicate the bot's previous review** if there's an open PR with one already (use `gh pr view --comments` to check).
|
||||
|
||||
If a category has zero applicable findings, omit it from the punch list. Don't pad.
|
||||
|
||||
---
|
||||
|
||||
## Output Template
|
||||
|
||||
```
|
||||
# Pre-push review for <branch>
|
||||
|
||||
**Base:** <base> · **Commits:** <N> · **Files changed:** <M>
|
||||
**Change shape:** <classification from Phase 1>
|
||||
|
||||
## Automated checks
|
||||
- ruff: ✅ / ❌
|
||||
- format: ✅ / ❌
|
||||
- ty: ✅ / ❌
|
||||
- unit tests: ✅ / ❌ (X passed, Y failed)
|
||||
- smoke (if run): ✅ / ❌
|
||||
|
||||
[If any check failed, stop here and report. Otherwise continue.]
|
||||
|
||||
## Findings
|
||||
|
||||
### 🔴 Blocking (<count>)
|
||||
|
||||
1. **[J1]** `nextcloud_mcp_server/client/talk.py:42` — Token interpolated into
|
||||
URL before validation; path-traversal risk. Move `_validate_token(token)`
|
||||
above the `httpx.URL(...)` call.
|
||||
|
||||
### 🟡 Important (<count>)
|
||||
|
||||
1. **[D2]** `nextcloud_mcp_server/models/semantic.py:87` — `verified_count`
|
||||
description says "unique documents" but value is `len(verified_results)`
|
||||
(chunk count). Either reword or dedup the value.
|
||||
|
||||
2. **[F1]** `tests/unit/search/test_verification.py` — 403 tests exist for
|
||||
notes/deck verifiers but not for files/news. Add for symmetry.
|
||||
|
||||
### 🟢 Nits (<count>)
|
||||
|
||||
1. **[A1]** `nextcloud_mcp_server/server/deck.py:118` — `Optional[int]` →
|
||||
`int | None` per CLAUDE.md.
|
||||
|
||||
### 💡 Suggestions
|
||||
|
||||
1. `nextcloud_mcp_server/auth/client_registry.py` — Pre-existing legacy
|
||||
`Dict`/`Optional` typing throughout. If touching anyway, opportunity
|
||||
to modernize.
|
||||
|
||||
## Strengths
|
||||
|
||||
- 🎉 **[E6]** Allowlist enforced on both cache-hit and cache-miss paths in
|
||||
`verify_token_for_management_api`, with a dedicated test
|
||||
(`test_cache_hit_also_enforces_allowlist`). Exemplary security gate.
|
||||
- 🎉 **[F3]** New `test_supported_doc_types_covers_indexed_types` enforces
|
||||
registry exhaustiveness — will catch any future doc_type added without a
|
||||
verifier.
|
||||
|
||||
## Notes
|
||||
|
||||
- 5 commits on branch; all have `Co-Authored-By` trailer ✓
|
||||
- ADR-019 implementation checklist all marked [x] (G4 ✓)
|
||||
```
|
||||
|
||||
### Clean diff (no findings)
|
||||
|
||||
```
|
||||
# Pre-push review for fix/notes-etag-handling
|
||||
|
||||
**Base:** master · **Commits:** 2 · **Files changed:** 3
|
||||
**Change shape:** client layer
|
||||
|
||||
## Automated checks
|
||||
- ruff: ✅
|
||||
- format: ✅
|
||||
- ty: ✅
|
||||
- unit tests: ✅ (412 passed)
|
||||
|
||||
## Findings
|
||||
|
||||
No checklist violations found.
|
||||
|
||||
## Strengths
|
||||
|
||||
- 🎉 **[B1]** ETag parsing hoisted into its own try/except before the
|
||||
network call — error logs will be specific to malformed ETag, not
|
||||
generic "unexpected error".
|
||||
|
||||
Safe to push.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes for the Running Model
|
||||
|
||||
- Read the **whole diff** before composing the report. Cross-file patterns
|
||||
(F1 symmetry, H2 single-source-of-truth, E5 lifespan symmetry) are only
|
||||
visible in aggregate.
|
||||
- Keep findings **specific and short**. One sentence each plus a fix direction.
|
||||
The point is to feed them back into the main loop for fixing, not to write
|
||||
a treatise.
|
||||
- If an automated check fails, **stop and report** — don't run the checklist
|
||||
on top of a broken build.
|
||||
- The checklist is calibrated to *this repo's* recurring review feedback. If
|
||||
the diff is in an unrelated area (CI config, random docs), most items won't
|
||||
apply — say so rather than padding.
|
||||
- **Don't fix anything.** This skill reports; the main loop fixes. Even if a
|
||||
finding is a one-character change, leave it for the user to action.
|
||||
- Use `gh pr view <PR#> --comments` (not `gh api`) when checking for prior bot
|
||||
reviews on an existing PR — see memory feedback_use_gh_pr.
|
||||
@@ -23,11 +23,21 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
- **Run ruff and ty before committing**:
|
||||
- **Before committing or pushing, invoke the `pre-push-review` skill**:
|
||||
- Runs `ruff check`, `ruff format --check`, `ty check`, and unit tests, then audits the
|
||||
branch diff against this repo's recurring PR-review patterns (mined from PRs #733–#750).
|
||||
- Output is a labelled punch list (🔴 blocking / 🟡 important / 🟢 nit). The main loop
|
||||
fixes; the skill reports.
|
||||
- Skill location: `.claude/skills/pre-push-review/SKILL.md`. Invoke via the `Skill` tool
|
||||
with `skill="pre-push-review"`, or when the user types `/pre-push-review`.
|
||||
- Skip only for tiny diffs (typo, README tweak, single-line dependency bump) or when the
|
||||
user explicitly says "just push it".
|
||||
- **Manual fallback** (if the skill is unavailable):
|
||||
```bash
|
||||
uv run ruff check
|
||||
uv run ruff format
|
||||
uv run ty check -- nextcloud_mcp_server
|
||||
uv run pytest tests/unit/ -x -q
|
||||
```
|
||||
- **Ruff configuration** in pyproject.toml (extends select: ["I"] for import sorting)
|
||||
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ services:
|
||||
# Mount OIDC development directory outside /var/www/html to avoid rsync conflicts
|
||||
# The post-installation hook will register /opt/apps as an additional app directory
|
||||
#- ./third_party:/opt/apps:ro
|
||||
- ./third_party/astrolabe:/opt/apps/astrolabe:ro
|
||||
#- ./third_party/astrolabe:/opt/apps/astrolabe:ro
|
||||
#- ./third_party/oidc:/opt/apps/oidc:ro
|
||||
environment:
|
||||
- NEXTCLOUD_TRUSTED_DOMAINS=app
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
# ADR-019: Verify-on-Read for Semantic Search Results
|
||||
|
||||
**Status**: Accepted
|
||||
**Date**: 2026-05-01
|
||||
**Depends On**: ADR-007 (Background Vector Sync), ADR-010 (Webhook-Based Vector Sync)
|
||||
|
||||
## Context
|
||||
|
||||
The vector index in Qdrant is a *recall layer*, not the source of truth. Authoritative state for every indexed document — whether a note exists, whether a file is still shared with the user, whether a deck card is on a board the user can read — lives in Nextcloud, not in our index. Whenever those two views drift, semantic search returns **ghost records**: results that point to documents the user can no longer access (or that no longer exist at all).
|
||||
|
||||
### How drift happens
|
||||
|
||||
Two mechanisms keep Qdrant in sync with Nextcloud, and both have non-zero latency:
|
||||
|
||||
1. **Webhook delivery (ADR-010)**. Nextcloud's `webhook_listeners` app dispatches change notifications via background jobs. The default `cron` job runs every 5 minutes, so even a healthy webhook pipeline opens a 0–5 minute window where deletions/unshares are not yet reflected in the index. Operators with dedicated webhook workers can shrink this, but most production deployments stay on the default cadence.
|
||||
|
||||
2. **Periodic scanner (ADR-007)**. The fallback reconciliation scan runs on `vector_sync_scan_interval`. The dev default is 60 seconds, but ADR-010 explicitly recommends raising this to 1 hour or more in production once webhooks are in place, since the scanner exists primarily to recover from missed events. Large deployments may run it once per day.
|
||||
|
||||
Beyond cadence, several failure modes cause webhooks to be missed entirely:
|
||||
|
||||
- The MCP server is down or unreachable when the webhook fires (Nextcloud does not durably retry).
|
||||
- Sharing changes (revoking a share, leaving a group) do not always emit file events that match what we registered for.
|
||||
- Application-level deletions in apps without rich event support (older Deck versions, custom Tables flows) bypass the file-event hooks.
|
||||
|
||||
In all of these cases, the document remains in Qdrant until the next periodic scan reconciles it — which may be hours away. Until then, `nc_semantic_search` happily returns the stale entry.
|
||||
|
||||
### Why this matters more for semantic search than keyword search
|
||||
|
||||
A keyword search via the Notes API is naturally bounded by what the API returns: deleted notes are not in the result set. The vector index is a separate store with its own lifecycle. The further we extend semantic search across apps (notes, files, deck cards, news items today; calendar, contacts, tables, cookbook tomorrow), the more divergent surfaces we expose to drift. Every new doc_type is another path where a webhook can be missed and another type of "ghost" can leak into results.
|
||||
|
||||
The risk is not just a confusing UX. For RAG flows like `nc_semantic_search_answer` (ADR-008), a stale result means the LLM is asked to synthesize an answer over content the user no longer has access to — a privacy boundary violation, not just a relevance bug.
|
||||
|
||||
### Current state of verification
|
||||
|
||||
Verification today is ad-hoc and inconsistent:
|
||||
|
||||
| Surface | Verifies? | Mechanism |
|
||||
|---|---|---|
|
||||
| `nc_semantic_search` | No | Returns raw Qdrant results. The docstring at `search/semantic.py:52` and `search/bm25_hybrid.py:75` references a `verify_search_results()` helper that was never implemented. |
|
||||
| `nc_semantic_search_answer` | Partially | `server/semantic.py:431-448` fetches `notes.get_note(id)` and drops on exception — but only for `doc_type == "note"`. Files, news items, and deck cards fall through to the `else` branch (`server/semantic.py:449`) and are returned with their excerpt unverified. |
|
||||
| `get_chunk_with_context` (when `include_context=True`) | Implicitly, all types | `search/context.py::_fetch_document_text` re-fetches the document; on failure the *context expansion* is skipped but the original (unverified) result is still returned (`server/semantic.py:246-251`). |
|
||||
|
||||
There is no single point where the system asks: "is this document still accessible to this user, right now?"
|
||||
|
||||
### The four indexed doc types
|
||||
|
||||
The vector pipeline (`vector/scanner.py`, `vector/processor.py`) currently indexes four types, each with its own access-check shape:
|
||||
|
||||
| doc_type | Cheapest authoritative check | Notes |
|
||||
|---|---|---|
|
||||
| `note` | `notes.get_note(id)` — single REST call, 404 on deletion | Per-user store; access is binary (yours or not). |
|
||||
| `news_item` | `get_items(batch_size=-1)` once per search + intersect | No per-item REST endpoint (`get_item()` is itself a fetch-all + filter); batching once is cheaper than N fetch-all-and-filter calls. |
|
||||
| `file` | WebDAV `PROPFIND` with `Depth: 0` on `file_path` (already stored in Qdrant payload, see `server/semantic.py:161`) | `read_file()` works but downloads the body — too heavy for a verification check. PROPFIND is the WebDAV equivalent of HEAD. Catches both deletes and unshares. |
|
||||
| `deck_card` | `deck.get_card(board_id, stack_id, card_id)` using metadata cached in Qdrant (`search/context.py::_get_deck_metadata_from_qdrant`) | Fallback iteration through all boards/stacks (used by context expansion) is O(boards × stacks) and far too expensive to run on every query. |
|
||||
|
||||
All four are query-time-cheap **if** we (a) deduplicate per-document before checking and (b) run checks concurrently.
|
||||
|
||||
## Decision
|
||||
|
||||
Implement **verify-on-read** as the authoritative access gate for semantic search. The vector index decides *what might be relevant*; Nextcloud decides *what the user can see*. We will:
|
||||
|
||||
1. Introduce a single `nextcloud_mcp_server/search/verification.py` module exposing `verify_search_results(client, results) -> list[SearchResult]`.
|
||||
2. Wire it into both `nc_semantic_search` and `nc_semantic_search_answer` as the final step before results leave the server, replacing the ad-hoc note-only verification in the answer tool.
|
||||
3. Dispatch per `doc_type` to a registry of verifiers using the cheapest authoritative check for each type.
|
||||
4. Lazily evict from Qdrant when verification reveals a definitively-gone document, so the next query for the same content does not re-pay the verification cost.
|
||||
|
||||
The vector index becomes a **hint**, not a contract. We never trust it for access decisions.
|
||||
|
||||
## Implementation
|
||||
|
||||
### Module shape
|
||||
|
||||
```python
|
||||
# nextcloud_mcp_server/search/verification.py
|
||||
|
||||
from typing import Awaitable, Callable
|
||||
import anyio
|
||||
import httpx
|
||||
|
||||
from nextcloud_mcp_server.search.algorithms import SearchResult
|
||||
|
||||
# A batch verifier takes a list of results for a single doc_type and returns
|
||||
# the set of doc_ids that are currently accessible to the user. The shared
|
||||
# semaphore caps concurrent Nextcloud round-trips across all verifier types.
|
||||
#
|
||||
# - Definitive 403/404 → omit the id from the returned set (drop the result).
|
||||
# - Transient error (5xx, network, parse) → include the id (fail-open keep).
|
||||
# - Verifier crash → caught by the dispatcher and treated as transient
|
||||
# (all results for that type are kept; logged distinctly).
|
||||
BatchVerifier = Callable[
|
||||
["NextcloudClientProtocol", list[SearchResult], anyio.Semaphore],
|
||||
Awaitable[set[int | str]],
|
||||
]
|
||||
|
||||
|
||||
async def verify_search_results(
|
||||
client: "NextcloudClientProtocol",
|
||||
results: list[SearchResult],
|
||||
*,
|
||||
max_concurrent: int = 20,
|
||||
evict_on_missing: bool = True,
|
||||
eviction_task_group: anyio.abc.TaskGroup | None = None,
|
||||
) -> list[SearchResult]:
|
||||
"""Filter search results to those the user can currently access.
|
||||
|
||||
Deduplicates by (doc_id, doc_type) before verifying, so multiple chunks
|
||||
from the same document cost a single check. Each verifier owns its own
|
||||
concurrency under the shared semaphore. Drops results whose verifier
|
||||
omitted them; keeps results whose verifier raised or whose doc_type has
|
||||
no registered verifier (transient failure should not silently shrink
|
||||
results).
|
||||
|
||||
When evict_on_missing=True, schedules async deletion of the Qdrant points
|
||||
for the missing document(s) so subsequent queries don't re-pay the cost.
|
||||
Pass ``eviction_task_group`` (typically the lifespan-owned background task
|
||||
group) to make eviction fire-and-forget; without it we run a local task
|
||||
group that blocks the response until evictions complete.
|
||||
"""
|
||||
```
|
||||
|
||||
**Why batch?** A per-id `Verifier` would force one task-group creation per id, multiply the number of small tasks, and prevent the news single-fetch optimization (the News API has no per-item endpoint, so per-id verification would be O(N × all_items)). The batch interface lets each verifier own its own concurrency strategy: notes/files/deck cards parallelize per id under the shared semaphore; news fetches once and intersects.
|
||||
|
||||
### Verifier registry
|
||||
|
||||
```python
|
||||
_VERIFIERS: dict[str, BatchVerifier] = {
|
||||
"note": _verify_notes,
|
||||
"news_item": _verify_news_items,
|
||||
"file": _verify_files,
|
||||
"deck_card": _verify_deck_cards,
|
||||
}
|
||||
```
|
||||
|
||||
Each verifier follows the same shape — accept a list of results for its type, fan out per-id under the shared semaphore (or fetch once and intersect, for news), and return the set of accessible ids:
|
||||
|
||||
```python
|
||||
async def _verify_notes(
|
||||
client,
|
||||
results: list[SearchResult],
|
||||
semaphore: anyio.Semaphore,
|
||||
) -> set[int | str]:
|
||||
accessible: set[int | str] = set()
|
||||
|
||||
async def check(result: SearchResult) -> None:
|
||||
async with semaphore:
|
||||
try:
|
||||
await client.notes.get_note(int(result.id))
|
||||
accessible.add(result.id)
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code in (403, 404):
|
||||
return # definitive — drop
|
||||
accessible.add(result.id) # transient — keep
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
for r in results:
|
||||
tg.start_soon(check, r)
|
||||
return accessible
|
||||
```
|
||||
|
||||
For `file`, use WebDAV PROPFIND (`Depth: 0`) on the `file_path` from the Qdrant payload, not `read_file()`. For `deck_card`, use the cached `(board_id, stack_id)` from `_get_deck_metadata_from_qdrant`; if metadata is absent, treat the result as accessible and log — we will not run the iteration fallback in the hot path. For `news_item`, batch-fetch the user's items once via `get_items(batch_size=-1)` and intersect, since the News API has no per-item endpoint.
|
||||
|
||||
### Deduplication
|
||||
|
||||
A 10-result page typically references 3–4 unique documents because of chunking. Dedupe by `(doc_id, doc_type)` *before* invoking the verifiers, so each batch verifier sees only unique ids. The dispatcher then propagates each id's verdict to every chunk of that document:
|
||||
|
||||
```python
|
||||
unique: list[SearchResult] = []
|
||||
seen: set[tuple[int | str, str]] = set()
|
||||
for r in results:
|
||||
key = (r.id, r.doc_type)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(r)
|
||||
|
||||
# Group unique results by doc_type and run their batch verifiers in parallel.
|
||||
by_type: dict[str, list[SearchResult]] = group_by_doc_type(unique)
|
||||
accessible_by_type: dict[str, set[int | str]] = {}
|
||||
async with anyio.create_task_group() as tg:
|
||||
for dtype, items in by_type.items():
|
||||
verifier = _VERIFIERS.get(dtype)
|
||||
if verifier is None:
|
||||
# Soft failure: keep all results for unknown doc_types.
|
||||
accessible_by_type[dtype] = {r.id for r in items}
|
||||
continue
|
||||
tg.start_soon(_run_verifier, verifier, dtype, items, accessible_by_type)
|
||||
|
||||
return [
|
||||
r for r in results
|
||||
if r.id in accessible_by_type.get(r.doc_type, set())
|
||||
]
|
||||
```
|
||||
|
||||
A verifier crash maps to "keep all" for that type — we do not want a flaky network blip to silently shrink results.
|
||||
|
||||
### Lazy eviction
|
||||
|
||||
When a verdict is `False`, queue a Qdrant delete for all points matching `(user_id, doc_id, doc_type)`. The plumbing already exists in `vector/placeholder.py::delete_placeholder_point` (which uses a filter-based delete); we need a sibling `delete_document_points` that omits the `is_placeholder` filter, so it removes real chunks too.
|
||||
|
||||
Eviction is fire-and-forget from the verification path — wrap it in a background task group on the lifespan context to avoid blocking the response. If eviction fails, the next query will simply re-verify and re-attempt; this is self-healing.
|
||||
|
||||
### Wiring
|
||||
|
||||
In `server/semantic.py::nc_semantic_search`, after the existing dedup and `[:limit]` slice, but **before** context expansion (which is expensive and pointless on inaccessible results):
|
||||
|
||||
```python
|
||||
search_results = all_results[:limit * 2] # fetch extra to absorb evictions
|
||||
search_results = await verify_search_results(client, search_results)
|
||||
search_results = search_results[:limit]
|
||||
```
|
||||
|
||||
Note the over-fetch: verification can shrink the page, so we ask for `limit * 2` candidates and trim *after* verification. This preserves the user's requested page size when ghosts are present without paying for full re-search.
|
||||
|
||||
In `server/semantic.py::nc_semantic_search_answer`, replace the per-type `if result.doc_type == "note"` branch (lines 428-453) with a call to `verify_search_results` followed by the existing full-content fetch (which can stay note-specific, since only notes use full content; the rest still use excerpts).
|
||||
|
||||
### What we deliberately do NOT do
|
||||
|
||||
- **No verification cache.** The whole point of verify-on-read is that the answer can change between calls. A short-TTL cache (say, 30s) is plausible if benchmarks show verification dominating latency, but it is not in the v1 scope.
|
||||
- **No verifier for unsupported doc_types.** If a future doc_type lands in Qdrant without a registered verifier, log a warning and pass the result through. Verification is opt-in per type; missing a verifier is a soft failure.
|
||||
- **No deck-card iteration fallback.** The fallback in `_fetch_document_text` exists for context expansion, where O(boards × stacks) is acceptable for a single result. In verification we may run the check on every chunk in every search; the fallback would amplify search latency unacceptably.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Correctness**: Deletes/unshares are reflected in search results within one query, regardless of webhook delivery delays or scanner intervals. Operators can safely raise `vector_sync_scan_interval` to its production-recommended value without leaking ghost records.
|
||||
- **Privacy**: RAG flows (`nc_semantic_search_answer`) can no longer synthesize answers over content the user has lost access to.
|
||||
- **Self-healing index**: Lazy eviction means the index converges toward correctness as users query, without needing the scanner to find every drifted record.
|
||||
- **Single source of truth**: Removes the docstring/code mismatch where `verify_search_results()` was promised but never delivered.
|
||||
|
||||
### Negative
|
||||
|
||||
- **Latency tax on every search**: Each unique `(doc_id, doc_type)` adds one Nextcloud round-trip. With 3–4 unique docs and 20-way concurrency, this is one parallel batch — likely under 100ms on a healthy connection, but it *is* on the critical path.
|
||||
- **API load on Nextcloud**: A query that previously hit only Qdrant now hits Nextcloud once per unique result. For high-QPS deployments this is non-trivial and may need rate limiting (already present in `BaseNextcloudClient` retry logic).
|
||||
- **More moving parts in the search path**: Errors in verification can mask errors in search. Verifier exceptions must be logged distinctly so debugging stays tractable.
|
||||
- **Doc_type coverage is now a correctness contract**: When we add a new indexable doc_type, we must add a verifier in the same PR, or accept that ghost records are possible for that type. CI should fail if a doc_type is indexed without a registered verifier.
|
||||
|
||||
### Neutral
|
||||
|
||||
- The `verify_search_results()` function name in existing docstrings becomes accurate. No public API breakage.
|
||||
- Webhooks remain valuable — they keep the index *recall* fresh (so semantically-relevant new docs appear in results quickly). Verification only handles the *precision* side (filtering inaccessible ones out).
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
**1. Tighten webhook delivery cadence.** Reduce Nextcloud's webhook cron interval from 5 minutes to 1 minute, or run a dedicated webhook worker. *Rejected as a complete solution*: addresses average-case latency but does nothing for missed webhooks, server-down windows, or app surfaces that lack rich events. We still recommend operators do this — it improves recall freshness — but it cannot replace verification.
|
||||
|
||||
**2. Synchronous webhook acknowledgement.** Have the MCP server delete from Qdrant inside the webhook handler before returning 2xx. *Rejected*: still doesn't help missed webhooks, and adds a hard dependency from the webhook critical path to Qdrant being reachable. Already partially implemented; verify-on-read complements it rather than replacing it.
|
||||
|
||||
**3. Bloom filter / negative cache of recently-deleted IDs.** Maintain an in-memory set of "known deleted" IDs populated by webhook handlers, consulted before returning search results. *Rejected*: cannot answer for unshares (which are user-relative, not global), grows unbounded, and is essentially a worse verifier — verifying against Nextcloud is authoritative and not much slower for the page sizes we deal with.
|
||||
|
||||
**4. Verify only in `nc_semantic_search_answer`, not `nc_semantic_search`.** Argue that raw search is "advisory" and verification only matters when the LLM consumes content. *Rejected*: ghost records in raw search results are still misleading to users and to other tools that compose on top of search. The bar for a search tool is "results are accessible," not "results are accessible if you happen to feed them into a sampling tool."
|
||||
|
||||
**5. Pre-verification at index time only (no query-time check).** *Already what we have*, and the problem statement.
|
||||
|
||||
## Related Decisions
|
||||
|
||||
- ADR-007: Background Vector Sync — establishes the polling architecture that produces drift.
|
||||
- ADR-008: MCP Sampling for Semantic Search — defines the RAG flow that most acutely needs verified results.
|
||||
- ADR-010: Webhook-Based Vector Sync — reduces but does not eliminate drift; verify-on-read closes the residual gap.
|
||||
- ADR-013: RAG Evaluation — verification policy should be exercised in eval suites (with both fresh and stale fixtures).
|
||||
|
||||
## References
|
||||
|
||||
- `nextcloud_mcp_server/search/semantic.py:52` and `search/bm25_hybrid.py:75` — orphaned `verify_search_results()` references.
|
||||
- `nextcloud_mcp_server/server/semantic.py:428-453` — current note-only verification in `nc_semantic_search_answer`.
|
||||
- `nextcloud_mcp_server/search/context.py::_fetch_document_text` — per-doc-type fetch logic that informs the verifier registry.
|
||||
- `nextcloud_mcp_server/vector/placeholder.py::delete_placeholder_point` — filter-based Qdrant delete pattern to extend for full-document eviction.
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- [x] Create `nextcloud_mcp_server/search/verification.py` with `verify_search_results()` and the verifier registry.
|
||||
- [x] Implement `_verify_notes`, `_verify_news_items`, `_verify_files` (PROPFIND), `_verify_deck_cards` (metadata fast-path only). Names plural to reflect the batch-verifier interface (see "Module shape" above).
|
||||
- [x] Add `delete_document_points()` in `nextcloud_mcp_server/vector/eviction.py` for non-placeholder filter-based deletes.
|
||||
- [x] Wire into `nc_semantic_search` with `limit * 2` over-fetch, trim to `limit` after verification.
|
||||
- [x] Wire into `nc_semantic_search_answer`; verification runs upstream in `nc_semantic_search`, and the note-only re-fetch is retained as a sub-second race guard.
|
||||
- [x] Update existing docstrings in `search/semantic.py` and `search/bm25_hybrid.py` to reflect the new verify-on-read path.
|
||||
- [x] Unit tests: each verifier handles 200/403/404/transient distinctly; dedup collapses chunks; eviction is scheduled on missing.
|
||||
- [x] Integration test: index a note, delete via API (no webhook), confirm the next semantic search does not return it. (See `tests/integration/test_verify_on_read.py`. Coverage gap for `file`, `deck_card`, `news_item` integration tests is tracked as a follow-up.)
|
||||
- [x] CI guard: enumerate indexed doc_types in `vector/scanner.py` and assert each has a registered verifier. (`INDEXED_DOC_TYPES` in `vector/scanner.py`; `tests/unit/search/test_verification.py::test_supported_doc_types_covers_indexed_types`.)
|
||||
- [x] Document the latency budget and rate-limit posture in `docs/configuration.md`. (See "Verify-on-Read Latency Budget" section.)
|
||||
@@ -474,6 +474,51 @@ DOCUMENT_CHUNK_OVERLAP=100
|
||||
|
||||
**Important**: Changing chunk size requires re-embedding all documents. The collection naming strategy (see "Qdrant Collection Naming" above) helps manage this by creating separate collections for different configurations.
|
||||
|
||||
### Verify-on-Read Latency Budget
|
||||
|
||||
Every semantic search request runs an access-control verification pass over its
|
||||
results before returning them, to filter out documents the user can no longer
|
||||
access (deleted, unshared, permissions changed). See
|
||||
[ADR-019](ADR-019-verify-on-read-for-semantic-search.md) for the full design.
|
||||
|
||||
This adds Nextcloud round-trips to the search path that operators should be
|
||||
aware of:
|
||||
|
||||
- **Per-search cost**: one Nextcloud round-trip per *unique* `(doc_id, doc_type)`
|
||||
in the result set. Chunking means a 10-result page typically references 3-5
|
||||
unique documents, so verification adds 3-5 round-trips. With the default
|
||||
20-way concurrency this is one parallel batch — usually under 100 ms on a
|
||||
healthy connection.
|
||||
- **Concurrency**: all verifications fan out under a shared semaphore.
|
||||
Tunable via the `VERIFICATION_CONCURRENCY` env var (settings field
|
||||
`verification_concurrency`, default 20) — lower it if your Nextcloud
|
||||
backend struggles with the parallel fan-out, or raise it on a healthy
|
||||
connection to speed up large result pages.
|
||||
- **News API caveat**: the News app has no per-item endpoint, so the news
|
||||
verifier issues a single `news.get_items(batch_size=-1, get_read=True)` call
|
||||
per search that contains any news result, then intersects locally. The
|
||||
payload is **unbounded** — for users with very large feed backlogs this can
|
||||
dominate verification latency. As a rough guide on a healthy LAN connection:
|
||||
a typical purged backlog (1k–5k items) returns in ~200–500 ms; very large
|
||||
backlogs (>20k items) can exceed 2 s and become the dominant cost of any
|
||||
search that surfaces news results. Disabling News in the indexer or running
|
||||
with a smaller backlog mitigates this; per-item paginated verification is
|
||||
tracked as a future improvement.
|
||||
- **Eviction**: when verification finds a definitive miss (404 / 403), the
|
||||
corresponding Qdrant points are deleted in the background on a lifespan-owned
|
||||
task group — fire-and-forget, does **not** block the search response.
|
||||
Eviction failures are logged but never propagated; the next query will
|
||||
re-verify and re-attempt (self-healing).
|
||||
- **Failure modes**: transient errors (5xx, network) keep results visible
|
||||
(fail open) so a flaky link does not silently shrink result pages; only
|
||||
*definitive* 404 / 403 drops them.
|
||||
|
||||
If eviction ever needs to be disabled (debugging, benchmarking), the
|
||||
`evict_on_missing=False` keyword argument on `verify_search_results()` skips
|
||||
the Qdrant deletes without changing what is returned to the caller. **This
|
||||
is a developer/test flag, not an operator knob — it has no env-var
|
||||
equivalent.** Operators who need a runtime toggle should open an issue.
|
||||
|
||||
### Environment Variables Reference
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|
||||
+56
-19
@@ -9,12 +9,13 @@ import traceback
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, cast
|
||||
from typing import cast
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import anyio
|
||||
import click
|
||||
import httpx
|
||||
from anyio.abc import TaskGroup
|
||||
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
||||
from mcp.server.auth.settings import AuthSettings
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
@@ -315,10 +316,14 @@ class VectorSyncState:
|
||||
and FastMCP session lifespans (where MCP tools need access to the streams).
|
||||
"""
|
||||
|
||||
document_send_stream: Optional[MemoryObjectSendStream] = None
|
||||
document_receive_stream: Optional[MemoryObjectReceiveStream] = None
|
||||
shutdown_event: Optional[anyio.Event] = None
|
||||
scanner_wake_event: Optional[anyio.Event] = None
|
||||
document_send_stream: MemoryObjectSendStream | None = None
|
||||
document_receive_stream: MemoryObjectReceiveStream | None = None
|
||||
shutdown_event: anyio.Event | None = None
|
||||
scanner_wake_event: anyio.Event | None = None
|
||||
# Long-lived task group used for fire-and-forget background work spawned
|
||||
# from the request path (e.g. ADR-019 verify-on-read eviction). Set by the
|
||||
# starlette lifespan after entering its task group; cleared on shutdown.
|
||||
eviction_task_group: TaskGroup | None = None
|
||||
|
||||
|
||||
# Module-level singleton for vector sync state
|
||||
@@ -330,11 +335,20 @@ class AppContext:
|
||||
"""Application context for BasicAuth mode."""
|
||||
|
||||
client: NextcloudClient
|
||||
storage: Optional["RefreshTokenStorage"] = None
|
||||
document_send_stream: Optional[MemoryObjectSendStream] = None
|
||||
document_receive_stream: Optional[MemoryObjectReceiveStream] = None
|
||||
shutdown_event: Optional[anyio.Event] = None
|
||||
scanner_wake_event: Optional[anyio.Event] = None
|
||||
storage: "RefreshTokenStorage | None" = None
|
||||
document_send_stream: MemoryObjectSendStream | None = None
|
||||
document_receive_stream: MemoryObjectReceiveStream | None = None
|
||||
shutdown_event: anyio.Event | None = None
|
||||
scanner_wake_event: anyio.Event | None = None
|
||||
|
||||
@property
|
||||
def eviction_task_group(self) -> TaskGroup | None:
|
||||
# Read dynamically from the module-level singleton instead of
|
||||
# snapshotting at lifespan-yield time. Snapshotting is order-sensitive:
|
||||
# if the FastMCP server lifespan ever runs before the Starlette
|
||||
# lifespan assigns the task group, every session for the life of the
|
||||
# process would see ``None`` and fall back to inline eviction.
|
||||
return _vector_sync_state.eviction_task_group
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -343,16 +357,19 @@ class OAuthAppContext:
|
||||
|
||||
nextcloud_host: str
|
||||
token_verifier: object # UnifiedTokenVerifier (ADR-005 compliant)
|
||||
refresh_token_storage: Optional["RefreshTokenStorage"] = None
|
||||
oauth_client: Optional[object] = None
|
||||
refresh_token_storage: "RefreshTokenStorage | None" = None
|
||||
oauth_client: object | None = None
|
||||
oauth_provider: str = "nextcloud" # "nextcloud" or "keycloak"
|
||||
server_client_id: Optional[str] = (
|
||||
None # MCP server's OAuth client ID (static or DCR)
|
||||
)
|
||||
document_send_stream: Optional[MemoryObjectSendStream] = None
|
||||
document_receive_stream: Optional[MemoryObjectReceiveStream] = None
|
||||
shutdown_event: Optional[anyio.Event] = None
|
||||
scanner_wake_event: Optional[anyio.Event] = None
|
||||
server_client_id: str | None = None # MCP server's OAuth client ID (static or DCR)
|
||||
document_send_stream: MemoryObjectSendStream | None = None
|
||||
document_receive_stream: MemoryObjectReceiveStream | None = None
|
||||
shutdown_event: anyio.Event | None = None
|
||||
scanner_wake_event: anyio.Event | None = None
|
||||
|
||||
@property
|
||||
def eviction_task_group(self) -> TaskGroup | None:
|
||||
# See AppContext.eviction_task_group for rationale.
|
||||
return _vector_sync_state.eviction_task_group
|
||||
|
||||
|
||||
class BasicAuthMiddleware:
|
||||
@@ -569,6 +586,8 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
|
||||
document_receive_stream=_vector_sync_state.document_receive_stream,
|
||||
shutdown_event=_vector_sync_state.shutdown_event,
|
||||
scanner_wake_event=_vector_sync_state.scanner_wake_event,
|
||||
# eviction_task_group is exposed via @property (reads
|
||||
# _vector_sync_state at access time, not snapshot).
|
||||
)
|
||||
finally:
|
||||
logger.info("Shutting down BasicAuth session")
|
||||
@@ -1189,6 +1208,8 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
document_receive_stream=_vector_sync_state.document_receive_stream,
|
||||
shutdown_event=_vector_sync_state.shutdown_event,
|
||||
scanner_wake_event=_vector_sync_state.scanner_wake_event,
|
||||
# eviction_task_group is exposed via @property (reads
|
||||
# _vector_sync_state at access time, not snapshot).
|
||||
)
|
||||
finally:
|
||||
logger.info("Shutting down MCP server")
|
||||
@@ -1604,6 +1625,12 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
username,
|
||||
)
|
||||
|
||||
# Expose this long-lived task group to request-path code that
|
||||
# wants to spawn background work (e.g. ADR-019 verify-on-read
|
||||
# eviction). Eviction coroutines have their own try/except, so
|
||||
# they cannot panic the parent group.
|
||||
_vector_sync_state.eviction_task_group = tg
|
||||
|
||||
logger.info(
|
||||
f"Background sync tasks started: 1 scanner + "
|
||||
f"{settings.vector_sync_processor_workers} processors"
|
||||
@@ -1617,6 +1644,8 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
# Shutdown signal
|
||||
logger.info("Shutting down background sync tasks")
|
||||
shutdown_event.set()
|
||||
# Request path must not spawn into a cancelling group.
|
||||
_vector_sync_state.eviction_task_group = None
|
||||
await client.close()
|
||||
# TaskGroup automatically cancels all tasks on exit
|
||||
|
||||
@@ -1786,6 +1815,12 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
use_basic_auth, # Pass as positional arg (before task_status)
|
||||
)
|
||||
|
||||
# Expose this long-lived task group to request-path code
|
||||
# that wants to spawn background work (e.g. ADR-019
|
||||
# verify-on-read eviction). Eviction coroutines have their
|
||||
# own try/except, so they cannot panic the parent group.
|
||||
_vector_sync_state.eviction_task_group = tg
|
||||
|
||||
logger.info(
|
||||
f"Background sync tasks started: 1 user manager + "
|
||||
f"{settings.vector_sync_processor_workers} processors"
|
||||
@@ -1799,6 +1834,8 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
# Shutdown signal
|
||||
logger.info("Shutting down background sync tasks")
|
||||
shutdown_event.set()
|
||||
# Request path must not spawn into a cancelling group.
|
||||
_vector_sync_state.eviction_task_group = None
|
||||
# Close token broker HTTP client
|
||||
if token_broker._http_client:
|
||||
await token_broker._http_client.aclose()
|
||||
|
||||
@@ -483,13 +483,6 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
"status_code": 409,
|
||||
"message": "Parent directory of destination doesn't exist",
|
||||
}
|
||||
logger.debug(
|
||||
f"Parent directory of destination '{destination_path}' doesn't exist"
|
||||
)
|
||||
return {
|
||||
"status_code": 409,
|
||||
"message": "Parent directory of destination doesn't exist",
|
||||
}
|
||||
else:
|
||||
logger.error(
|
||||
f"HTTP error moving resource from '{source_path}' to '{destination_path}': {e}"
|
||||
@@ -1301,12 +1294,33 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
async def get_file_info(self, path: str) -> dict[str, Any] | None:
|
||||
"""Get file info including file ID via WebDAV PROPFIND.
|
||||
|
||||
.. note::
|
||||
**Behavior change (ADR-019):** previously this method returned
|
||||
``None`` for HTTP 404. It now raises ``HTTPStatusError`` for any
|
||||
non-2xx status, including 404. ``None`` is reserved for the
|
||||
ambiguous *malformed PROPFIND* case (server returned 2xx with a
|
||||
response body missing required XML elements). External callers
|
||||
updating from the old contract must catch ``HTTPStatusError``
|
||||
and inspect ``e.response.status_code`` to handle 404 explicitly.
|
||||
|
||||
Args:
|
||||
path: Path to the file (relative to user's files directory)
|
||||
|
||||
Returns:
|
||||
File info dictionary with id, name, size, content_type, etc.
|
||||
Returns None if file not found.
|
||||
Returns ``None`` ONLY when the server returned a malformed
|
||||
PROPFIND response (missing ``<d:response>`` /
|
||||
``<d:propstat>`` / ``<d:prop>`` elements) — an ambiguous
|
||||
state where we cannot tell whether the file exists.
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: For any non-2xx HTTP status, including 404
|
||||
("not found"). Callers that want to treat 404 as
|
||||
"absent" should catch ``HTTPStatusError`` and check
|
||||
``e.response.status_code``. This matches the convention
|
||||
of the rest of this client and lets verify-on-read
|
||||
distinguish a definitive absence (HTTP 404) from a
|
||||
brittle response (None).
|
||||
"""
|
||||
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
|
||||
|
||||
@@ -1323,19 +1337,13 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
</d:prop>
|
||||
</d:propfind>"""
|
||||
|
||||
try:
|
||||
response = await self._client.request(
|
||||
"PROPFIND",
|
||||
webdav_path,
|
||||
headers={"Depth": "0"},
|
||||
content=propfind_body,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
logger.debug(f"File not found: {path}")
|
||||
return None
|
||||
raise
|
||||
response = await self._client.request(
|
||||
"PROPFIND",
|
||||
webdav_path,
|
||||
headers={"Depth": "0"},
|
||||
content=propfind_body,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse XML response
|
||||
root = ET.fromstring(response.content)
|
||||
|
||||
@@ -65,6 +65,8 @@ _DEFAULTS: dict[str, Any] = {
|
||||
"vector_sync_processor_workers": 3,
|
||||
"vector_sync_queue_max_size": 10000,
|
||||
"vector_sync_user_poll_interval": 60,
|
||||
# Verify-on-read concurrency cap (ADR-019)
|
||||
"verification_concurrency": 20,
|
||||
# Qdrant
|
||||
"qdrant_url": None,
|
||||
"qdrant_location": None,
|
||||
@@ -170,6 +172,7 @@ _dynaconf = Dynaconf(
|
||||
Validator("VECTOR_SYNC_PROCESSOR_WORKERS", gte=1),
|
||||
Validator("VECTOR_SYNC_QUEUE_MAX_SIZE", gte=1),
|
||||
Validator("VECTOR_SYNC_USER_POLL_INTERVAL", gte=1),
|
||||
Validator("VERIFICATION_CONCURRENCY", gte=1),
|
||||
Validator("DOCUMENT_CHUNK_SIZE", gte=1),
|
||||
# Non-negative
|
||||
Validator("DOCUMENT_CHUNK_OVERLAP", gte=0),
|
||||
@@ -455,6 +458,12 @@ class Settings:
|
||||
vector_sync_queue_max_size: int = 10000
|
||||
vector_sync_user_poll_interval: int = 60 # seconds - OAuth mode user discovery
|
||||
|
||||
# Verify-on-read concurrency (ADR-019). Cap on parallel Nextcloud
|
||||
# round-trips during search-result verification fan-out. Lower this if the
|
||||
# Nextcloud backend struggles with the parallel load; raise it on a
|
||||
# healthy connection to speed up large result pages.
|
||||
verification_concurrency: int = 20
|
||||
|
||||
# Qdrant settings (mutually exclusive modes)
|
||||
qdrant_url: str | None = None # Network mode: http://qdrant:6333
|
||||
qdrant_location: str | None = None # Local mode: :memory: or /path/to/data
|
||||
@@ -793,6 +802,8 @@ def get_settings() -> Settings:
|
||||
"vector_sync_processor_workers": "VECTOR_SYNC_PROCESSOR_WORKERS",
|
||||
"vector_sync_queue_max_size": "VECTOR_SYNC_QUEUE_MAX_SIZE",
|
||||
"vector_sync_user_poll_interval": "VECTOR_SYNC_USER_POLL_INTERVAL",
|
||||
# Verify-on-read (ADR-019)
|
||||
"verification_concurrency": "VERIFICATION_CONCURRENCY",
|
||||
# Qdrant settings
|
||||
"qdrant_url": "QDRANT_URL",
|
||||
"qdrant_location": "QDRANT_LOCATION",
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Pydantic models for semantic search responses."""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .base import BaseResponse
|
||||
@@ -10,7 +8,15 @@ from .base import BaseResponse
|
||||
class SemanticSearchResult(BaseModel):
|
||||
"""Model for semantic search results with additional metadata."""
|
||||
|
||||
id: int = Field(description="Document ID (int for all document types)")
|
||||
id: int = Field(
|
||||
description=(
|
||||
"Document ID. Numeric for all currently indexed types (notes, files, "
|
||||
"deck cards, news items). The internal SearchResult.id is typed as "
|
||||
"int|str to leave room for future doc types with string identifiers; "
|
||||
"the MCP response narrows to int and a future widening here would be "
|
||||
"a deliberate, breaking-by-design API change."
|
||||
)
|
||||
)
|
||||
doc_type: str = Field(
|
||||
description="Document type (note, calendar_event, deck_card, etc.)"
|
||||
)
|
||||
@@ -29,36 +35,36 @@ class SemanticSearchResult(BaseModel):
|
||||
)
|
||||
chunk_index: int = Field(description="Index of matching chunk in document")
|
||||
total_chunks: int = Field(description="Total number of chunks in document")
|
||||
chunk_start_offset: Optional[int] = Field(
|
||||
chunk_start_offset: int | None = Field(
|
||||
default=None, description="Character position where chunk starts in document"
|
||||
)
|
||||
chunk_end_offset: Optional[int] = Field(
|
||||
chunk_end_offset: int | None = Field(
|
||||
default=None, description="Character position where chunk ends in document"
|
||||
)
|
||||
page_number: Optional[int] = Field(
|
||||
page_number: int | None = Field(
|
||||
default=None, description="Page number for PDF documents"
|
||||
)
|
||||
page_count: Optional[int] = Field(
|
||||
page_count: int | None = Field(
|
||||
default=None, description="Total number of pages in PDF document"
|
||||
)
|
||||
# Context expansion fields (optional, populated when include_context=True)
|
||||
has_context_expansion: bool = Field(
|
||||
default=False, description="Whether context expansion was performed"
|
||||
)
|
||||
marked_text: Optional[str] = Field(
|
||||
marked_text: str | None = Field(
|
||||
default=None,
|
||||
description="Full text with position markers around matched chunk",
|
||||
)
|
||||
before_context: Optional[str] = Field(
|
||||
before_context: str | None = Field(
|
||||
default=None, description="Text before the matched chunk"
|
||||
)
|
||||
after_context: Optional[str] = Field(
|
||||
after_context: str | None = Field(
|
||||
default=None, description="Text after the matched chunk"
|
||||
)
|
||||
has_before_truncation: Optional[bool] = Field(
|
||||
has_before_truncation: bool | None = Field(
|
||||
default=None, description="Whether before_context was truncated"
|
||||
)
|
||||
has_after_truncation: Optional[bool] = Field(
|
||||
has_after_truncation: bool | None = Field(
|
||||
default=None, description="Whether after_context was truncated"
|
||||
)
|
||||
|
||||
@@ -66,7 +72,7 @@ class SemanticSearchResult(BaseModel):
|
||||
class SemanticSearchResponse(BaseResponse):
|
||||
"""Response model for semantic search across all indexed Nextcloud apps."""
|
||||
|
||||
results: List[SemanticSearchResult] = Field(
|
||||
results: list[SemanticSearchResult] = Field(
|
||||
description="Semantic search results with similarity scores"
|
||||
)
|
||||
query: str = Field(description="The search query used")
|
||||
@@ -74,6 +80,30 @@ class SemanticSearchResponse(BaseResponse):
|
||||
search_method: str = Field(
|
||||
default="semantic", description="Search method used (semantic or hybrid)"
|
||||
)
|
||||
verified_chunk_count: int = Field(
|
||||
default=0,
|
||||
description=(
|
||||
"Number of search result chunks that passed verify-on-read "
|
||||
"access checks (ADR-019). Equals len(verified_results) before "
|
||||
"trimming to limit. Sized in chunks (result rows), NOT in "
|
||||
"unique documents — see dropped_document_count for the "
|
||||
"per-document counterpart."
|
||||
),
|
||||
)
|
||||
dropped_document_count: int = Field(
|
||||
default=0,
|
||||
description=(
|
||||
"Number of unique (doc_id, doc_type) pairs dropped as ghost "
|
||||
"records during verify-on-read (ADR-019). A short result page "
|
||||
"(len(results) < limit) combined with a non-zero "
|
||||
"dropped_document_count indicates ghost density rather than "
|
||||
"scarcity of relevant content. Note: this counter is sized in "
|
||||
"unique documents while verified_chunk_count is sized in "
|
||||
"chunks — a single document can contribute multiple chunks, "
|
||||
"so subtracting dropped_document_count from "
|
||||
"verified_chunk_count is NOT a meaningful operation."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class SamplingSearchResponse(BaseResponse):
|
||||
@@ -98,7 +128,7 @@ class SamplingSearchResponse(BaseResponse):
|
||||
generated_answer: str = Field(
|
||||
..., description="LLM-generated answer based on retrieved documents"
|
||||
)
|
||||
sources: List[SemanticSearchResult] = Field(
|
||||
sources: list[SemanticSearchResult] = Field(
|
||||
default_factory=list,
|
||||
description="Source documents with excerpts and relevance scores",
|
||||
)
|
||||
@@ -106,10 +136,10 @@ class SamplingSearchResponse(BaseResponse):
|
||||
search_method: str = Field(
|
||||
default="semantic_sampling", description="Search method used"
|
||||
)
|
||||
model_used: Optional[str] = Field(
|
||||
model_used: str | None = Field(
|
||||
default=None, description="Model that generated the answer"
|
||||
)
|
||||
stop_reason: Optional[str] = Field(
|
||||
stop_reason: str | None = Field(
|
||||
default=None, description="Reason generation stopped"
|
||||
)
|
||||
|
||||
|
||||
@@ -67,6 +67,11 @@ class NextcloudClientProtocol(Protocol):
|
||||
"""Tables client for accessing table row documents."""
|
||||
...
|
||||
|
||||
@property
|
||||
def news(self) -> Any:
|
||||
"""News client for accessing news item documents."""
|
||||
...
|
||||
|
||||
|
||||
async def get_indexed_doc_types(user_id: str) -> set[str]:
|
||||
"""Query Qdrant to get actually-indexed document types for a user.
|
||||
@@ -127,7 +132,9 @@ class SearchResult:
|
||||
"""A single search result with metadata and score.
|
||||
|
||||
Attributes:
|
||||
id: Document ID (int for all document types)
|
||||
id: Document ID. Numeric for indexed types today (notes, files,
|
||||
deck cards, news items), but typed as ``int | str`` to allow
|
||||
future doc types that use string identifiers (e.g., file paths).
|
||||
doc_type: Document type (note, file, calendar, contact, etc.)
|
||||
title: Document title
|
||||
excerpt: Content excerpt showing match context
|
||||
@@ -144,7 +151,7 @@ class SearchResult:
|
||||
point_id: Qdrant point ID for batch vector retrieval (None if not from Qdrant)
|
||||
"""
|
||||
|
||||
id: int
|
||||
id: int | str
|
||||
doc_type: str
|
||||
title: str
|
||||
excerpt: str
|
||||
|
||||
@@ -71,8 +71,10 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
|
||||
"""
|
||||
Execute hybrid search using dense + sparse vectors with native RRF fusion.
|
||||
|
||||
Returns unverified results from Qdrant. Access verification should be
|
||||
performed separately at the final output stage using verify_search_results().
|
||||
Returns unverified results from Qdrant. Access verification is
|
||||
performed separately at the server tool layer via
|
||||
``nextcloud_mcp_server.search.verification.verify_search_results``
|
||||
(see ADR-019).
|
||||
|
||||
Deduplicates by (doc_id, doc_type, chunk_start_offset, chunk_end_offset)
|
||||
to show multiple chunks from the same document while avoiding duplicate chunks.
|
||||
@@ -206,7 +208,7 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
|
||||
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 (files) or str (notes/news_items/deck_cards) — see scanner.py
|
||||
doc_id = result.payload["doc_id"]
|
||||
doc_type = result.payload.get("doc_type", "note")
|
||||
chunk_start = result.payload.get("chunk_start_offset")
|
||||
@@ -231,9 +233,14 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
|
||||
metadata["path"] = path
|
||||
|
||||
# Add deck_card-specific metadata for frontend URL construction
|
||||
# and verify-on-read (ADR-019) — both board_id and stack_id are
|
||||
# required to call deck.get_card without an O(boards × stacks)
|
||||
# iteration fallback.
|
||||
if doc_type == "deck_card":
|
||||
if board_id := result.payload.get("board_id"):
|
||||
metadata["board_id"] = board_id
|
||||
if stack_id := result.payload.get("stack_id"):
|
||||
metadata["stack_id"] = stack_id
|
||||
|
||||
# Return unverified results (verification happens at output stage)
|
||||
results.append(
|
||||
|
||||
@@ -48,8 +48,10 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
|
||||
) -> list[SearchResult]:
|
||||
"""Execute semantic search using vector similarity.
|
||||
|
||||
Returns unverified results from Qdrant. Access verification should be
|
||||
performed separately at the final output stage using verify_search_results().
|
||||
Returns unverified results from Qdrant. Access verification is
|
||||
performed separately at the server tool layer via
|
||||
``nextcloud_mcp_server.search.verification.verify_search_results``
|
||||
(see ADR-019).
|
||||
|
||||
Deduplicates by (doc_id, doc_type, chunk_start_offset, chunk_end_offset)
|
||||
to show multiple chunks from the same document while avoiding duplicate chunks.
|
||||
@@ -162,9 +164,14 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
|
||||
metadata["path"] = path
|
||||
|
||||
# Add deck_card-specific metadata for frontend URL construction
|
||||
# and verify-on-read (ADR-019) — both board_id and stack_id are
|
||||
# required to call deck.get_card without an O(boards × stacks)
|
||||
# iteration fallback.
|
||||
if doc_type == "deck_card":
|
||||
if board_id := result.payload.get("board_id"):
|
||||
metadata["board_id"] = board_id
|
||||
if stack_id := result.payload.get("stack_id"):
|
||||
metadata["stack_id"] = stack_id
|
||||
|
||||
# Return unverified results (verification happens at output stage)
|
||||
results.append(
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
"""Verify-on-read access checks for semantic search results (ADR-019).
|
||||
|
||||
The vector index is a recall layer; Nextcloud is the source of truth for
|
||||
access. This module filters search results by checking each unique document
|
||||
against Nextcloud at query time, dropping any that the user can no longer
|
||||
access (deleted, unshared, etc.) and lazily evicting them from the index.
|
||||
|
||||
Per-doc_type verifiers are registered in ``_VERIFIERS``. Each takes the
|
||||
authenticated client, the (deduplicated) list of ``SearchResult``s for that
|
||||
doc_type, and a shared concurrency semaphore. They return the subset of
|
||||
``doc_id`` values that are currently accessible. Verifiers read whatever
|
||||
metadata they need (file path, deck card board/stack ids) directly from the
|
||||
SearchResult — these fields are populated at index-time and propagated by
|
||||
the algorithm layer (see ``search/bm25_hybrid.py`` and ``search/semantic.py``)
|
||||
so verification adds zero extra Qdrant round-trips.
|
||||
|
||||
Concurrency is bounded by a shared semaphore (default 20) so a large search
|
||||
result page (or a multi-doc_type query) cannot exhaust the httpx connection
|
||||
pool or trigger Nextcloud rate limiting. The 20-slot default matches the
|
||||
context-expansion convention in ``server/semantic.py``.
|
||||
|
||||
Failure policy:
|
||||
|
||||
- Definitive 403/404 from Nextcloud → drop the result and schedule eviction.
|
||||
- Transient errors (5xx, network blips, unexpected exceptions) → keep the
|
||||
result and log a warning. We never silently shrink result sets due to
|
||||
flakes; the next query will re-verify.
|
||||
- Unsupported doc_type (no registered verifier) → keep the result and log a
|
||||
warning. Verification is opt-in per type; a missing verifier is a soft
|
||||
failure, not a search failure.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
import anyio
|
||||
from anyio.abc import TaskGroup
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.search.algorithms import (
|
||||
NextcloudClientProtocol,
|
||||
SearchResult,
|
||||
)
|
||||
from nextcloud_mcp_server.vector.eviction import delete_document_points
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
BatchVerifier = Callable[
|
||||
[NextcloudClientProtocol, list[SearchResult], anyio.Semaphore],
|
||||
Awaitable[set[int | str]],
|
||||
]
|
||||
"""(client, results, semaphore) -> set of doc_ids accessible to the user."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-doc-type verifiers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _is_definitive_404_or_403(exc: BaseException) -> bool:
|
||||
"""Return True if exc indicates the document is definitively inaccessible.
|
||||
|
||||
401 is intentionally excluded — it usually signals expired credentials
|
||||
rather than permanent denial, so it is treated as transient (keep the
|
||||
result; the next query will re-verify after the client refreshes).
|
||||
"""
|
||||
if isinstance(exc, HTTPStatusError):
|
||||
return exc.response.status_code in (403, 404)
|
||||
return False
|
||||
|
||||
|
||||
async def _verify_notes(
|
||||
client: NextcloudClientProtocol,
|
||||
results: list[SearchResult],
|
||||
semaphore: anyio.Semaphore,
|
||||
) -> set[int | str]:
|
||||
# safe: cooperative concurrency, no lock needed (see verify_search_results)
|
||||
accessible: set[int | str] = set()
|
||||
|
||||
async def check(result: SearchResult) -> None:
|
||||
doc_id = result.id
|
||||
# Parse defensively before the network call so a malformed payload
|
||||
# produces a specific log line, not a generic "unexpected error"
|
||||
# from the catch-all ``except Exception`` below. ``_verify_notes``
|
||||
# is the canonical shape; ``_verify_deck_cards`` and
|
||||
# ``_verify_news_items`` mirror this hoisted-cast pattern.
|
||||
try:
|
||||
note_id_int = int(doc_id)
|
||||
except (TypeError, ValueError) as e:
|
||||
logger.warning(
|
||||
"Non-numeric note id %r: %s; keeping result",
|
||||
doc_id,
|
||||
e,
|
||||
)
|
||||
accessible.add(doc_id)
|
||||
return
|
||||
|
||||
async with semaphore:
|
||||
try:
|
||||
await client.notes.get_note(note_id_int)
|
||||
accessible.add(doc_id)
|
||||
except HTTPStatusError as e:
|
||||
if _is_definitive_404_or_403(e):
|
||||
return
|
||||
logger.warning(
|
||||
"Transient error verifying note %s: %s %s; keeping result",
|
||||
doc_id,
|
||||
e.response.status_code,
|
||||
e,
|
||||
)
|
||||
accessible.add(doc_id)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Unexpected error verifying note %s: %s; keeping result",
|
||||
doc_id,
|
||||
e,
|
||||
)
|
||||
accessible.add(doc_id)
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
for r in results:
|
||||
tg.start_soon(check, r)
|
||||
|
||||
return accessible
|
||||
|
||||
|
||||
async def _verify_files(
|
||||
client: NextcloudClientProtocol,
|
||||
results: list[SearchResult],
|
||||
semaphore: anyio.Semaphore,
|
||||
) -> set[int | str]:
|
||||
# safe: cooperative concurrency, no lock needed (see verify_search_results)
|
||||
accessible: set[int | str] = set()
|
||||
|
||||
async def check(result: SearchResult) -> None:
|
||||
doc_id = result.id
|
||||
# file_path is propagated from the Qdrant payload by the algorithm
|
||||
# layer (bm25_hybrid.py / semantic.py). No extra Qdrant round-trip.
|
||||
file_path = (result.metadata or {}).get("path")
|
||||
if not file_path:
|
||||
# Cannot verify without a path; treat as accessible to avoid
|
||||
# silently dropping legitimate results when payload is missing
|
||||
# (legacy data, or a future doc_type that doesn't propagate path).
|
||||
logger.warning(
|
||||
"No file path in metadata for file_id %s; keeping result "
|
||||
"(verification skipped)",
|
||||
doc_id,
|
||||
)
|
||||
accessible.add(doc_id)
|
||||
return
|
||||
|
||||
async with semaphore:
|
||||
try:
|
||||
info = await client.webdav.get_file_info(file_path)
|
||||
if info is None:
|
||||
# Contract (see WebDAVClient.get_file_info docstring):
|
||||
# `None` means a malformed PROPFIND response — an
|
||||
# ambiguous state, not a definitive 404. Treat as
|
||||
# transient and KEEP the result rather than evicting.
|
||||
# Real 404s raise HTTPStatusError and land in the
|
||||
# _is_definitive_404_or_403 branch below.
|
||||
logger.warning(
|
||||
"Malformed PROPFIND response verifying file %s (%s); "
|
||||
"keeping result (ambiguous state, not a definitive 404)",
|
||||
doc_id,
|
||||
file_path,
|
||||
)
|
||||
accessible.add(doc_id)
|
||||
return
|
||||
accessible.add(doc_id)
|
||||
except HTTPStatusError as e:
|
||||
if _is_definitive_404_or_403(e):
|
||||
return
|
||||
logger.warning(
|
||||
"Transient error verifying file %s (%s): %s %s; keeping result",
|
||||
doc_id,
|
||||
file_path,
|
||||
e.response.status_code,
|
||||
e,
|
||||
)
|
||||
accessible.add(doc_id)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Unexpected error verifying file %s (%s): %s; keeping result",
|
||||
doc_id,
|
||||
file_path,
|
||||
e,
|
||||
)
|
||||
accessible.add(doc_id)
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
for r in results:
|
||||
tg.start_soon(check, r)
|
||||
|
||||
return accessible
|
||||
|
||||
|
||||
async def _verify_deck_cards(
|
||||
client: NextcloudClientProtocol,
|
||||
results: list[SearchResult],
|
||||
semaphore: anyio.Semaphore,
|
||||
) -> set[int | str]:
|
||||
# safe: cooperative concurrency, no lock needed (see verify_search_results)
|
||||
accessible: set[int | str] = set()
|
||||
|
||||
async def check(result: SearchResult) -> None:
|
||||
doc_id = result.id
|
||||
# board_id and stack_id are propagated from the Qdrant payload by the
|
||||
# algorithm layer. No extra Qdrant round-trip.
|
||||
meta = result.metadata or {}
|
||||
board_id = meta.get("board_id")
|
||||
stack_id = meta.get("stack_id")
|
||||
if board_id is None or stack_id is None:
|
||||
# Without metadata we cannot run the cheap fast-path. Per ADR-019
|
||||
# we deliberately do NOT fall back to O(boards × stacks) iteration
|
||||
# in the search hot path; treat as accessible.
|
||||
logger.warning(
|
||||
"Incomplete deck metadata for card %s (board_id=%s, stack_id=%s); "
|
||||
"keeping result (verification skipped, legacy data)",
|
||||
doc_id,
|
||||
board_id,
|
||||
stack_id,
|
||||
)
|
||||
accessible.add(doc_id)
|
||||
return
|
||||
|
||||
# Parse defensively before the network call so a malformed payload
|
||||
# produces a specific log line, not a generic "unexpected error"
|
||||
# from the catch-all ``except Exception`` below. Mirrors the
|
||||
# canonical hoisted-cast pattern in ``_verify_notes``.
|
||||
try:
|
||||
board_id_int = int(board_id)
|
||||
stack_id_int = int(stack_id)
|
||||
card_id_int = int(doc_id)
|
||||
except (TypeError, ValueError) as e:
|
||||
logger.warning(
|
||||
"Non-numeric deck metadata for card %s "
|
||||
"(board_id=%r, stack_id=%r): %s; keeping result",
|
||||
doc_id,
|
||||
board_id,
|
||||
stack_id,
|
||||
e,
|
||||
)
|
||||
accessible.add(doc_id)
|
||||
return
|
||||
|
||||
async with semaphore:
|
||||
try:
|
||||
await client.deck.get_card(
|
||||
board_id=board_id_int,
|
||||
stack_id=stack_id_int,
|
||||
card_id=card_id_int,
|
||||
)
|
||||
accessible.add(doc_id)
|
||||
except HTTPStatusError as e:
|
||||
if _is_definitive_404_or_403(e):
|
||||
return
|
||||
logger.warning(
|
||||
"Transient error verifying deck card %s: %s %s; keeping result",
|
||||
doc_id,
|
||||
e.response.status_code,
|
||||
e,
|
||||
)
|
||||
accessible.add(doc_id)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Unexpected error verifying deck card %s: %s; keeping result",
|
||||
doc_id,
|
||||
e,
|
||||
)
|
||||
accessible.add(doc_id)
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
for r in results:
|
||||
tg.start_soon(check, r)
|
||||
|
||||
return accessible
|
||||
|
||||
|
||||
async def _verify_news_items(
|
||||
client: NextcloudClientProtocol,
|
||||
results: list[SearchResult],
|
||||
semaphore: anyio.Semaphore,
|
||||
) -> set[int | str]:
|
||||
"""Batch-verify news items with a single fetch.
|
||||
|
||||
The Nextcloud News API has no per-item endpoint, so ``news.get_item`` is
|
||||
implemented as a fetch-all + filter — which would be O(N × all_items) if
|
||||
called per id. Instead we fetch once and intersect. Only one slot of
|
||||
the shared semaphore is consumed per search (rather than one per id),
|
||||
but that slot is held for the full ``get_items`` round-trip; see the
|
||||
in-body comment for the backpressure rationale.
|
||||
"""
|
||||
doc_ids = [r.id for r in results]
|
||||
|
||||
# Semaphore lifetime: this slot is held for the duration of ONE
|
||||
# deduplicated News fetch (≤1 per search), not per-id. That is the
|
||||
# correct backpressure behaviour — a single user's news verification
|
||||
# must not hammer Nextcloud with concurrent fetch-all requests, and
|
||||
# any other verifiers running in parallel for the same search share
|
||||
# the same semaphore. Latency of this fetch is proportional to the
|
||||
# user's full news corpus; see the News caveat in
|
||||
# docs/configuration.md for production guidance.
|
||||
#
|
||||
# Multi-user note: the "≤1 per search" bound is per-search, not
|
||||
# per-process. If N users simultaneously search news content, all N
|
||||
# hold a slot for the duration of their respective fetches, each
|
||||
# consuming 1/max_concurrent of the shared verification budget. A
|
||||
# single news-heavy user can therefore hold their slot for seconds.
|
||||
async with semaphore:
|
||||
try:
|
||||
# TODO(perf): if profiling shows this fetch dominates query latency
|
||||
# for news-heavy users, cache the per-request item set or push for
|
||||
# a per-item News API endpoint. The shared semaphore protects
|
||||
# against runaway concurrent fetches, but the payload itself can
|
||||
# be large (News auto-purge cap is in the thousands of items).
|
||||
#
|
||||
# NOTE: ``batch_size`` is intentionally unbounded (-1). A numeric
|
||||
# ceiling here would silently *break correctness*: any item beyond
|
||||
# the cap would be missing from ``present_ids`` and incorrectly
|
||||
# dropped from the result set. The fail-open contract requires
|
||||
# fetching every item the user has access to. See the news caveat
|
||||
# in docs/configuration.md (Verify-on-Read) for the latency
|
||||
# tradeoff and follow-up paths.
|
||||
news_fetch_start = anyio.current_time()
|
||||
items = await client.news.get_items(batch_size=-1, get_read=True)
|
||||
logger.debug(
|
||||
"News fetch for verification took %.2fs (%d item(s) returned)",
|
||||
anyio.current_time() - news_fetch_start,
|
||||
len(items),
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
# If the News API itself is gone (app disabled, user lost access),
|
||||
# treat *all* requested items as inaccessible. Eviction will reclaim.
|
||||
if _is_definitive_404_or_403(e):
|
||||
# News app commonly disabled/uninstalled — debug-level keeps
|
||||
# this off operator dashboards; transient errors below stay
|
||||
# at warning because they're unexpected.
|
||||
logger.debug(
|
||||
"News API returned %s for user %s; treating all %d news_items as inaccessible",
|
||||
e.response.status_code,
|
||||
client.username,
|
||||
len(doc_ids),
|
||||
)
|
||||
return set()
|
||||
logger.warning(
|
||||
"Transient error fetching news items for verification: %s %s; keeping all results",
|
||||
e.response.status_code,
|
||||
e,
|
||||
)
|
||||
return set(doc_ids)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Unexpected error fetching news items for verification: %s; keeping all results",
|
||||
e,
|
||||
)
|
||||
return set(doc_ids)
|
||||
|
||||
# Build present_ids from the API response. Granularity is intentionally
|
||||
# asymmetric with the per-item loop below:
|
||||
#
|
||||
# * Here (structural failure): if the API response itself is corrupt
|
||||
# — even one item with a non-numeric id — we cannot reliably build
|
||||
# `present_ids`, so every requested doc_id fails open. The batch
|
||||
# is the only safe blast radius when the source-of-truth payload
|
||||
# can't be trusted.
|
||||
# * Below (data failure): a single non-numeric *stored* doc_id is a
|
||||
# local data issue. Failing the whole batch open would let one bad
|
||||
# row in Qdrant mask real revocations for every other item, so we
|
||||
# scope the fail-open to that one id.
|
||||
try:
|
||||
present_ids = {
|
||||
int(item.get("id")) for item in items if item.get("id") is not None
|
||||
}
|
||||
except (TypeError, ValueError) as e:
|
||||
logger.warning(
|
||||
"Non-numeric id in news API response (sample=%r): %s; keeping all results",
|
||||
items[:3] if items else items,
|
||||
e,
|
||||
)
|
||||
return set(doc_ids)
|
||||
|
||||
# Per-item check: a single non-numeric *stored* doc_id is fail-open
|
||||
# for THAT item only — not the whole batch. Mirrors the per-item
|
||||
# shape of the notes/files/deck verifiers. See the granularity note
|
||||
# above for why this is narrower than the API-response failure path.
|
||||
accessible: set[int | str] = set()
|
||||
for d in doc_ids:
|
||||
try:
|
||||
if int(d) in present_ids:
|
||||
accessible.add(d)
|
||||
except (TypeError, ValueError):
|
||||
logger.debug("Non-numeric news doc_id %r; keeping (cannot verify)", d)
|
||||
accessible.add(d)
|
||||
return accessible
|
||||
|
||||
|
||||
_VERIFIERS: dict[str, BatchVerifier] = {
|
||||
"note": _verify_notes,
|
||||
"file": _verify_files,
|
||||
"deck_card": _verify_deck_cards,
|
||||
"news_item": _verify_news_items,
|
||||
}
|
||||
|
||||
|
||||
def get_supported_doc_types() -> set[str]:
|
||||
"""Return the set of doc_types that have registered verifiers.
|
||||
|
||||
Used by CI guards and tests to ensure every indexed doc_type has a
|
||||
verifier (see ADR-019 implementation checklist).
|
||||
"""
|
||||
return set(_VERIFIERS.keys())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def verify_search_results(
|
||||
client: NextcloudClientProtocol,
|
||||
results: list[SearchResult],
|
||||
*,
|
||||
evict_on_missing: bool = True,
|
||||
max_concurrent: int | None = None,
|
||||
eviction_task_group: TaskGroup | None = None,
|
||||
) -> tuple[list[SearchResult], int]:
|
||||
"""Filter search results to those the user can currently access.
|
||||
|
||||
Deduplicates by ``(doc_id, doc_type)`` before verifying, so multiple
|
||||
chunks from the same document cost a single check. Verifiers run
|
||||
concurrently per doc_type and concurrently per id within each verifier,
|
||||
bounded by a shared semaphore (``max_concurrent``).
|
||||
|
||||
When ``evict_on_missing=True``, points for documents that fail verification
|
||||
are deleted from Qdrant. If ``eviction_task_group`` is provided (the
|
||||
lifespan-owned task group from ``app.py::VectorSyncState``), eviction is
|
||||
fire-and-forget — the search response returns immediately and Qdrant
|
||||
deletes happen in the background. If no task group is provided (unit
|
||||
tests, modes without vector sync), eviction falls back to running inline
|
||||
in a local task group. Eviction failures are logged but never propagated.
|
||||
|
||||
Args:
|
||||
client: Authenticated NextcloudClient (must expose ``username``).
|
||||
results: SearchResult list from the algorithm layer (may include
|
||||
multiple chunks per document).
|
||||
evict_on_missing: Schedule lazy eviction for inaccessible docs.
|
||||
max_concurrent: Cap on concurrent verification round-trips against
|
||||
Nextcloud. When ``None`` (the default), resolved from
|
||||
``Settings.verification_concurrency`` (env var
|
||||
``VERIFICATION_CONCURRENCY``, default 20).
|
||||
eviction_task_group: Optional long-lived task group on which to
|
||||
spawn fire-and-forget eviction. Pass
|
||||
``ctx.request_context.lifespan_context.eviction_task_group``
|
||||
from FastMCP tools.
|
||||
|
||||
Returns:
|
||||
Tuple of ``(kept_results, dropped_count)`` where ``kept_results`` is
|
||||
the filtered list preserving the original order and ``dropped_count``
|
||||
is the number of unique ``(doc_id, doc_type)`` pairs that failed
|
||||
verification (ghost records).
|
||||
"""
|
||||
if not results:
|
||||
return results, 0
|
||||
|
||||
user_id: str = client.username
|
||||
|
||||
if max_concurrent is None:
|
||||
max_concurrent = get_settings().verification_concurrency
|
||||
|
||||
# Group unique (doc_id, doc_type) by doc_type so each verifier sees a
|
||||
# deduplicated batch. We pick one SearchResult per (id, doc_type) to carry
|
||||
# metadata (path, board_id/stack_id) into the verifier — chunks of the
|
||||
# same document share these fields, so any chunk works.
|
||||
by_type: dict[str, dict[int | str, SearchResult]] = {}
|
||||
for r in results:
|
||||
by_type.setdefault(r.doc_type, {}).setdefault(r.id, r)
|
||||
|
||||
# Shared semaphore bounds total Nextcloud round-trips across all
|
||||
# per-id verifiers. Without it, a 50-result mostly-notes page could fan
|
||||
# out 50 concurrent get_note calls and exhaust the connection pool.
|
||||
semaphore = anyio.Semaphore(max_concurrent)
|
||||
|
||||
# Concurrency note: ``accessible_by_type`` is mutated by multiple
|
||||
# ``run_verifier`` tasks running under the task group below. This is
|
||||
# safe without an explicit lock because (a) anyio uses cooperative
|
||||
# multitasking — a task only yields at ``await`` points, never
|
||||
# mid-statement; (b) each task is dispatched once per ``doc_type``
|
||||
# by the loop ``for doc_type, ... in by_type.items()`` further down,
|
||||
# so two tasks never write to the same key; and (c) Python dict key
|
||||
# assignment is not an await point, so two tasks cannot race on the
|
||||
# same write. Adding a lock would be dead weight; using ``anyio.Lock``
|
||||
# here would force serialization on a path that is intentionally
|
||||
# parallel.
|
||||
accessible_by_type: dict[str, set[int | str]] = {}
|
||||
|
||||
async def run_verifier(doc_type: str, unique_results: list[SearchResult]) -> None:
|
||||
verifier = _VERIFIERS.get(doc_type)
|
||||
if verifier is None:
|
||||
logger.warning(
|
||||
"No verifier registered for doc_type=%r; keeping %d result(s) unverified",
|
||||
doc_type,
|
||||
len(unique_results),
|
||||
)
|
||||
accessible_by_type[doc_type] = {r.id for r in unique_results}
|
||||
return
|
||||
try:
|
||||
accessible_by_type[doc_type] = await verifier(
|
||||
client, unique_results, semaphore
|
||||
)
|
||||
except Exception as e:
|
||||
# Verifier itself blew up (not per-id) — fail open.
|
||||
logger.error(
|
||||
"Verifier for doc_type=%s raised: %s; keeping all %d result(s) unverified",
|
||||
doc_type,
|
||||
e,
|
||||
len(unique_results),
|
||||
exc_info=True,
|
||||
)
|
||||
accessible_by_type[doc_type] = {r.id for r in unique_results}
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
for doc_type, id_to_result in by_type.items():
|
||||
tg.start_soon(run_verifier, doc_type, list(id_to_result.values()))
|
||||
|
||||
# Compute (doc_id, doc_type) pairs that failed verification
|
||||
inaccessible: set[tuple[int | str, str]] = set()
|
||||
for doc_type, id_to_result in by_type.items():
|
||||
# The .get() default is defensive only — run_verifier always populates
|
||||
# accessible_by_type[doc_type], either with the verifier's result or
|
||||
# with all ids on verifier crash (fail-open).
|
||||
accessible = accessible_by_type.get(doc_type, set(id_to_result.keys()))
|
||||
for doc_id in id_to_result.keys():
|
||||
if doc_id not in accessible:
|
||||
inaccessible.add((doc_id, doc_type))
|
||||
|
||||
if inaccessible:
|
||||
# Tag ids with their type (int vs str) so ghost-record logs are
|
||||
# unambiguous: int 42 and str "42" both render as "42" otherwise.
|
||||
logger.info(
|
||||
"Verification dropped %d inaccessible document(s): %s",
|
||||
len(inaccessible),
|
||||
sorted((f"{type(d).__name__}:{d}", t) for d, t in inaccessible),
|
||||
)
|
||||
|
||||
# Filter results, preserving order. All chunks of an inaccessible document
|
||||
# are dropped together (dedup happened before verification, but the result
|
||||
# list still contains all chunks).
|
||||
kept = [r for r in results if (r.id, r.doc_type) not in inaccessible]
|
||||
|
||||
# Lazy eviction.
|
||||
#
|
||||
# Preferred path: spawn evict() on the lifespan-owned task group via
|
||||
# `start_soon`, which returns immediately — the search response is not
|
||||
# blocked on Qdrant deletes. If the server is shutting down, the task
|
||||
# group is cleared back to None (see app.py) and we fall through to the
|
||||
# inline path. Cancellation mid-eviction is fine: the next query will
|
||||
# re-verify and re-attempt (self-healing per ADR-019).
|
||||
#
|
||||
# Fallback path: when no task group is supplied (unit tests, deployment
|
||||
# modes without vector sync), run eviction inline in a local task group.
|
||||
# This preserves prior behaviour for tests that rely on eviction being
|
||||
# complete by the time `verify_search_results` returns.
|
||||
if evict_on_missing and inaccessible:
|
||||
|
||||
async def evict(doc_id: int | str, doc_type: str) -> None:
|
||||
try:
|
||||
await delete_document_points(doc_id, doc_type, user_id)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to evict %s_%s from Qdrant: %s", doc_type, doc_id, e
|
||||
)
|
||||
|
||||
if eviction_task_group is not None:
|
||||
for doc_id, doc_type in inaccessible:
|
||||
# Guard against the lifespan task group having exited between
|
||||
# the getattr() capture in server/semantic.py and this call —
|
||||
# start_soon raises RuntimeError on a closed group, which
|
||||
# would otherwise surface as a search error. Eviction is
|
||||
# best-effort: the next query re-verifies and re-attempts.
|
||||
try:
|
||||
eviction_task_group.start_soon(evict, doc_id, doc_type)
|
||||
except RuntimeError:
|
||||
logger.debug("Eviction task group closed; will retry on next query")
|
||||
else:
|
||||
async with anyio.create_task_group() as tg:
|
||||
for doc_id, doc_type in inaccessible:
|
||||
tg.start_soon(evict, doc_id, doc_type)
|
||||
|
||||
return kept, len(inaccessible)
|
||||
@@ -32,6 +32,7 @@ from nextcloud_mcp_server.observability.metrics import (
|
||||
)
|
||||
from nextcloud_mcp_server.search.bm25_hybrid import BM25HybridSearchAlgorithm
|
||||
from nextcloud_mcp_server.search.context import get_chunk_with_context
|
||||
from nextcloud_mcp_server.search.verification import verify_search_results
|
||||
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
@@ -86,15 +87,29 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
context_chars: Number of characters to include before/after matched chunk (default: 300)
|
||||
|
||||
Returns:
|
||||
SemanticSearchResponse with matching documents ranked by fusion scores
|
||||
SemanticSearchResponse with matching documents ranked by fusion scores.
|
||||
|
||||
Verification fields (ADR-019 verify-on-read):
|
||||
- verified_chunk_count: chunk rows that passed access checks
|
||||
(sized in chunks; counted before trimming to ``limit``, so it
|
||||
can exceed ``len(results)`` when a doc has multiple matching
|
||||
chunks).
|
||||
- dropped_document_count: unique ``(doc_id, doc_type)`` pairs
|
||||
evicted as ghost records during this search (sized in
|
||||
documents, not chunks).
|
||||
"""
|
||||
settings = get_settings()
|
||||
client = await get_client(ctx)
|
||||
username = client.username
|
||||
|
||||
logger.info(
|
||||
f"BM25 hybrid search: query='{query}', user={username}, "
|
||||
f"limit={limit}, score_threshold={score_threshold}, fusion={fusion}"
|
||||
"BM25 hybrid search: query=%r, user=%s, "
|
||||
"limit=%d, score_threshold=%s, fusion=%s",
|
||||
query,
|
||||
username,
|
||||
limit,
|
||||
score_threshold,
|
||||
fusion,
|
||||
)
|
||||
|
||||
# Check that vector sync is enabled
|
||||
@@ -119,48 +134,122 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
|
||||
if doc_types is None:
|
||||
# Cross-app search: search all indexed types
|
||||
# Get unverified results from Qdrant
|
||||
# Get unverified results from Qdrant.
|
||||
#
|
||||
# NOTE (ADR-019): Over-fetch by 2× to absorb ghost-record drops
|
||||
# during verify-on-read. When ghost density is high (e.g. a
|
||||
# large board share was just revoked) this budget can still
|
||||
# under-deliver against the requested ``limit``; the index
|
||||
# self-heals via lazy eviction so subsequent searches recover.
|
||||
# The 2× factor is a deliberate v1 trade-off — raising it
|
||||
# costs Nextcloud round-trips on every search. Trim to
|
||||
# ``limit`` happens AFTER verification.
|
||||
# TODO(ADR-019): expose VERIFICATION_OVERFETCH so operators
|
||||
# with persistent high ghost density can tune this without a
|
||||
# code change.
|
||||
unverified_results = await search_algo.search(
|
||||
query=query,
|
||||
user_id=username,
|
||||
limit=limit * 2, # Get extra for access filtering
|
||||
limit=limit * 2,
|
||||
doc_type=None, # Signal to search all types
|
||||
score_threshold=score_threshold,
|
||||
)
|
||||
all_results.extend(unverified_results)
|
||||
else:
|
||||
# Search specific document types
|
||||
# For each requested type, execute search and combine results
|
||||
# Search specific document types.
|
||||
#
|
||||
# Per-Qdrant-query cost: this branch issues ONE query per
|
||||
# requested doc_type, each capped at `limit * 2`. With N
|
||||
# types in `doc_types`, the pre-merge result pool is
|
||||
# therefore N × `limit * 2`, NOT `limit * 2`. That is more
|
||||
# Qdrant work than the cross-app branch above (which makes a
|
||||
# single multi-type query returning `limit * 2` total).
|
||||
#
|
||||
# The post-merge trim below clamps the pool back down to
|
||||
# `limit * 2` so verification (and the Nextcloud round-trips
|
||||
# it triggers) sees the same budget as the cross-app branch.
|
||||
# The per-type Qdrant cost remains higher; pre-trim cost
|
||||
# scales linearly with len(doc_types).
|
||||
for dtype in doc_types:
|
||||
unverified_results = await search_algo.search(
|
||||
query=query,
|
||||
user_id=username,
|
||||
limit=limit * 2, # Get extra for combining and filtering
|
||||
limit=limit * 2,
|
||||
doc_type=dtype,
|
||||
score_threshold=score_threshold,
|
||||
)
|
||||
all_results.extend(unverified_results)
|
||||
|
||||
# Sort combined results by score
|
||||
# Sort combined results by score, then cap to `limit * 2` to
|
||||
# match the cross-app branch's over-fetch budget. Without this
|
||||
# cap, N requested doc_types × `limit * 2` results would all
|
||||
# flow into verification, multiplying the Nextcloud round-trip
|
||||
# cost by N.
|
||||
all_results.sort(key=lambda r: r.score, reverse=True)
|
||||
all_results = all_results[: limit * 2]
|
||||
|
||||
# Note: BM25HybridSearchAlgorithm already deduplicates at chunk level
|
||||
# (doc_id, doc_type, chunk_start, chunk_end), which allows multiple
|
||||
# chunks from the same document while preventing duplicate chunks.
|
||||
# No additional deduplication needed here - multiple chunks per document
|
||||
# are valuable for RAG contexts.
|
||||
# Qdrant already filters by user_id for multi-tenant isolation.
|
||||
# Sampling tool will verify access when fetching full content.
|
||||
search_results = all_results[
|
||||
:limit
|
||||
] # Final limit after chunk-level dedup in algorithm
|
||||
# ADR-019: Verify-on-read. The vector index is a recall layer;
|
||||
# Nextcloud is the source of truth for access. Filter out ghost
|
||||
# records (deleted/unshared docs not yet reconciled by webhooks)
|
||||
# BEFORE trimming to `limit`, so we don't lose accessible results
|
||||
# to the limit slot that ghosts would otherwise occupy. We also
|
||||
# run this BEFORE context expansion to avoid re-fetching docs that
|
||||
# are about to be dropped. Pass the lifespan-owned task group so
|
||||
# eviction of dropped points is fire-and-forget (does not block
|
||||
# the response).
|
||||
# Direct attribute access — both AppContext and OAuthAppContext
|
||||
# expose ``eviction_task_group`` as a @property (see app.py),
|
||||
# reading dynamically from the module-level VectorSyncState
|
||||
# singleton. A defensive ``getattr(..., None)`` here would mask
|
||||
# typos; if a future lifespan-context type forgets the property,
|
||||
# AttributeError surfaces during the first search rather than
|
||||
# silently degrading to inline eviction for the life of the
|
||||
# process.
|
||||
eviction_task_group = (
|
||||
ctx.request_context.lifespan_context.eviction_task_group
|
||||
)
|
||||
verification_start = anyio.current_time()
|
||||
verified_results, dropped_count = await verify_search_results(
|
||||
client,
|
||||
all_results,
|
||||
eviction_task_group=eviction_task_group,
|
||||
)
|
||||
verified_chunk_count = len(verified_results)
|
||||
logger.debug(
|
||||
"Verification completed in %.2fs: kept %d chunk(s), dropped %d doc(s)",
|
||||
anyio.current_time() - verification_start,
|
||||
verified_chunk_count,
|
||||
dropped_count,
|
||||
)
|
||||
search_results = verified_results[:limit]
|
||||
|
||||
# Convert SearchResult objects to SemanticSearchResult for response
|
||||
# Convert SearchResult objects to SemanticSearchResult for response.
|
||||
# SearchResult.id is typed `int | str` for forward-compat with future
|
||||
# doc_types, but every currently indexed type uses numeric ids and
|
||||
# the MCP response model narrows to `int`. Casting here makes the
|
||||
# narrowing explicit and surfaces any future string-id type as a
|
||||
# loud failure at the boundary instead of silently widening the
|
||||
# public API.
|
||||
results = []
|
||||
for r in search_results:
|
||||
try:
|
||||
narrowed_id = int(r.id)
|
||||
except (TypeError, ValueError) as e:
|
||||
# Re-raise with explicit context so the outer handler logs
|
||||
# something operators can act on (the generic "Search
|
||||
# failed: invalid literal for int()" is opaque).
|
||||
raise TypeError(
|
||||
f"SemanticSearchResult.id must be int-convertible, "
|
||||
f"got {r.id!r} (type={type(r.id).__name__}) for "
|
||||
f"doc_type={r.doc_type!r}. This indicates a doc_type "
|
||||
f"with non-numeric ids has been indexed but the "
|
||||
f"public response model has not been widened. Add "
|
||||
f"the doc_type to the SemanticSearchResult.id type "
|
||||
f"or convert at the verifier layer."
|
||||
) from e
|
||||
results.append(
|
||||
SemanticSearchResult(
|
||||
id=r.id,
|
||||
id=narrowed_id,
|
||||
doc_type=r.doc_type,
|
||||
title=r.title,
|
||||
category=r.metadata.get("category", "") if r.metadata else "",
|
||||
@@ -181,12 +270,20 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
# Expand results with surrounding context if requested
|
||||
if include_context and results:
|
||||
logger.info(
|
||||
f"Expanding {len(results)} results with context "
|
||||
f"(context_chars={context_chars})"
|
||||
"Expanding %d results with context (context_chars=%d)",
|
||||
len(results),
|
||||
context_chars,
|
||||
)
|
||||
|
||||
# Fetch context for all results in parallel
|
||||
# Limit concurrent requests to prevent connection pool exhaustion
|
||||
# Fetch context for all results in parallel.
|
||||
# Limit concurrent requests to prevent connection pool exhaustion.
|
||||
#
|
||||
# Intentionally distinct from settings.verification_concurrency:
|
||||
# that knob bounds Nextcloud round-trips during access
|
||||
# verification (ADR-019); this one bounds context-expansion
|
||||
# fetches that run only when ``include_context=True``. Operators
|
||||
# tuning one rarely want the other in lockstep, so they share
|
||||
# the default value (20) but not the env var.
|
||||
max_concurrent = 20
|
||||
semaphore = anyio.Semaphore(max_concurrent)
|
||||
expanded_results = [None] * len(results)
|
||||
@@ -240,20 +337,27 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
has_after_truncation=chunk_context.has_after_truncation,
|
||||
)
|
||||
logger.debug(
|
||||
f"Expanded context for {result.doc_type} {result.id}"
|
||||
"Expanded context for %s %s",
|
||||
result.doc_type,
|
||||
result.id,
|
||||
)
|
||||
else:
|
||||
# Context expansion failed, keep original result
|
||||
expanded_results[index] = result
|
||||
logger.debug(
|
||||
f"Failed to expand context for {result.doc_type} {result.id}, "
|
||||
"keeping original result"
|
||||
"Failed to expand context for %s %s, "
|
||||
"keeping original result",
|
||||
result.doc_type,
|
||||
result.id,
|
||||
)
|
||||
except Exception as e:
|
||||
# Context expansion failed, keep original result
|
||||
expanded_results[index] = result
|
||||
logger.warning(
|
||||
f"Error expanding context for {result.doc_type} {result.id}: {e}"
|
||||
"Error expanding context for %s %s: %s",
|
||||
result.doc_type,
|
||||
result.id,
|
||||
e,
|
||||
)
|
||||
|
||||
# Run all context fetches in parallel using anyio task group
|
||||
@@ -264,16 +368,19 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
# Replace results with expanded versions
|
||||
results = [r for r in expanded_results if r is not None]
|
||||
logger.info(
|
||||
f"Context expansion completed: {len(results)} results with context"
|
||||
"Context expansion completed: %d results with context",
|
||||
len(results),
|
||||
)
|
||||
|
||||
logger.info(f"Returning {len(results)} results from BM25 hybrid search")
|
||||
logger.info("Returning %d results from BM25 hybrid search", len(results))
|
||||
|
||||
return SemanticSearchResponse(
|
||||
results=results,
|
||||
query=query,
|
||||
total_found=len(results),
|
||||
search_method=f"bm25_hybrid_{fusion}",
|
||||
verified_chunk_count=verified_chunk_count,
|
||||
dropped_document_count=dropped_count,
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
@@ -293,14 +400,14 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
ErrorData(code=-1, message=f"Network error during search: {str(e)}")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Search error: {e}", exc_info=True)
|
||||
logger.error("Search error: %s", e, exc_info=True)
|
||||
raise McpError(ErrorData(code=-1, message=f"Search failed: {str(e)}"))
|
||||
|
||||
@mcp.tool(
|
||||
title="Search with AI-Generated Answer",
|
||||
annotations=ToolAnnotations(
|
||||
readOnlyHint=True, # Search doesn't modify data
|
||||
openWorldHint=False, # Searches only indexed Nextcloud data
|
||||
openWorldHint=True, # Calls into Nextcloud via nc_semantic_search
|
||||
),
|
||||
)
|
||||
@require_scopes("semantic.read")
|
||||
@@ -352,6 +459,14 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
Note: Requires MCP client to support sampling. If sampling is unavailable,
|
||||
the tool gracefully degrades to returning documents with an explanation.
|
||||
The client may prompt the user to approve the sampling request.
|
||||
|
||||
Latency profile: For each note in the result page, this tool fetches
|
||||
the full note body via ``client.notes.get_note`` after upstream
|
||||
verify-on-read has already round-tripped to the same endpoint as a
|
||||
race guard (ADR-019). Expect one additional Nextcloud round-trip per
|
||||
note result; raising ``limit`` above the default of 5 amplifies this
|
||||
cost roughly linearly. File / news / deck results do not pay this
|
||||
cost — they reuse the verified excerpt.
|
||||
"""
|
||||
# 1. Retrieve relevant documents via existing semantic search
|
||||
search_response = await nc_semantic_search(
|
||||
@@ -366,7 +481,7 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
|
||||
# 2. Handle no results case - don't waste a sampling call
|
||||
if not search_response.results:
|
||||
logger.debug(f"No documents found for query: {query}")
|
||||
logger.debug("No documents found for query: %r", query)
|
||||
return SamplingSearchResponse(
|
||||
query=query,
|
||||
generated_answer="No relevant documents found in your Nextcloud content for this query.",
|
||||
@@ -383,22 +498,25 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
|
||||
# Log capability check result for debugging
|
||||
logger.info(
|
||||
f"Sampling capability check: client_has_sampling={client_has_sampling}, "
|
||||
f"query='{query}'"
|
||||
"Sampling capability check: client_has_sampling=%s, query=%r",
|
||||
client_has_sampling,
|
||||
query,
|
||||
)
|
||||
if hasattr(ctx.session, "_client_params") and ctx.session._client_params:
|
||||
client_caps = ctx.session._client_params.capabilities
|
||||
logger.debug(
|
||||
f"Client advertised capabilities: "
|
||||
f"roots={client_caps.roots is not None}, "
|
||||
f"sampling={client_caps.sampling is not None}, "
|
||||
f"experimental={client_caps.experimental is not None}"
|
||||
"Client advertised capabilities: "
|
||||
"roots=%s, sampling=%s, experimental=%s",
|
||||
client_caps.roots is not None,
|
||||
client_caps.sampling is not None,
|
||||
client_caps.experimental is not None,
|
||||
)
|
||||
|
||||
if not client_has_sampling:
|
||||
logger.info(
|
||||
f"Client does not support sampling (query: '{query}'), "
|
||||
f"returning {len(search_response.results)} documents"
|
||||
"Client does not support sampling (query: %r), returning %d documents",
|
||||
query,
|
||||
len(search_response.results),
|
||||
)
|
||||
return SamplingSearchResponse(
|
||||
query=query,
|
||||
@@ -414,14 +532,24 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
success=True,
|
||||
)
|
||||
|
||||
# 4. Fetch full content for notes in parallel (also verifies access)
|
||||
# Use anyio task group for concurrent fetching with semaphore to prevent
|
||||
# connection pool exhaustion
|
||||
# 4. Fetch full content for notes in parallel.
|
||||
# Access verification has already happened upstream in
|
||||
# nc_semantic_search via verify_search_results (ADR-019), so any
|
||||
# exception here is a sub-second race (doc deleted between
|
||||
# verification and this fetch) — drop the result in that case.
|
||||
client = await get_client(ctx)
|
||||
accessible_results = [None] * len(search_response.results)
|
||||
full_contents = [None] * len(search_response.results)
|
||||
|
||||
# Limit concurrent requests to prevent connection pool exhaustion
|
||||
# Limit concurrent requests to prevent connection pool exhaustion.
|
||||
#
|
||||
# Intentionally distinct from settings.verification_concurrency:
|
||||
# that knob bounds Nextcloud round-trips during access
|
||||
# verification (ADR-019). This one bounds the answer tool's
|
||||
# full-content fetch — a separate request phase tied to RAG
|
||||
# answer generation. Operators tuning one rarely want the other
|
||||
# in lockstep, so they share the default value (20) but not the
|
||||
# env var.
|
||||
max_concurrent = 20
|
||||
semaphore = anyio.Semaphore(max_concurrent)
|
||||
|
||||
@@ -429,26 +557,30 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
"""Fetch full content for a single document (parallel with semaphore)."""
|
||||
async with semaphore:
|
||||
if result.doc_type == "note":
|
||||
# SemanticSearchResult.id is typed `int` (Pydantic enforces
|
||||
# at construction); no defensive cast is needed here. The
|
||||
# catch-all below covers only the verify-then-delete race.
|
||||
try:
|
||||
note = await client.notes.get_note(result.id)
|
||||
# Note is accessible, store result and full content
|
||||
content = note.get("content", "")
|
||||
accessible_results[index] = result
|
||||
full_contents[index] = content
|
||||
logger.debug(
|
||||
f"Fetched full content for note {result.id} "
|
||||
f"(length: {len(content)} chars)"
|
||||
"Fetched full content for note %s (length: %d chars)",
|
||||
result.id,
|
||||
len(content),
|
||||
)
|
||||
except Exception as e:
|
||||
# Note might have been deleted or permissions changed
|
||||
# Leave as None to filter out later
|
||||
# Race window after verify_search_results — drop result.
|
||||
logger.debug(
|
||||
f"Note {result.id} not accessible: {e}. "
|
||||
f"Excluding from results."
|
||||
"Note %s disappeared between verification and "
|
||||
"content fetch: %s. Excluding from results.",
|
||||
result.id,
|
||||
e,
|
||||
)
|
||||
else:
|
||||
# Non-note document types (future: calendar, deck, files)
|
||||
# For now, keep them with excerpts
|
||||
# Non-note types (file, news_item, deck_card) keep the
|
||||
# excerpt — already access-verified upstream.
|
||||
accessible_results[index] = result
|
||||
# full_contents[index] remains None (will use excerpt)
|
||||
|
||||
@@ -466,7 +598,9 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
|
||||
# Check if we filtered out all results
|
||||
if not accessible_results:
|
||||
logger.warning(f"All search results became inaccessible for query: {query}")
|
||||
logger.warning(
|
||||
"All search results became inaccessible for query: %r", query
|
||||
)
|
||||
return SamplingSearchResponse(
|
||||
query=query,
|
||||
generated_answer="All matching documents are no longer accessible.",
|
||||
@@ -508,9 +642,12 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Initiating sampling request: query_length={len(query)}, "
|
||||
f"documents={len(search_response.results)}, "
|
||||
f"prompt_length={len(prompt)}, max_tokens={max_answer_tokens}"
|
||||
"Initiating sampling request: query_length=%d, documents=%d, "
|
||||
"prompt_length=%d, max_tokens=%d",
|
||||
len(query),
|
||||
len(search_response.results),
|
||||
len(prompt),
|
||||
max_answer_tokens,
|
||||
)
|
||||
|
||||
# 6. Request LLM completion via MCP sampling with timeout
|
||||
@@ -543,13 +680,15 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
# Handle non-text responses (shouldn't happen for text prompts)
|
||||
generated_answer = f"Received non-text response of type: {sampling_result.content.type}"
|
||||
logger.warning(
|
||||
f"Unexpected content type from sampling: {sampling_result.content.type}"
|
||||
"Unexpected content type from sampling: %s",
|
||||
sampling_result.content.type,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Sampling successful: model={sampling_result.model}, "
|
||||
f"stop_reason={sampling_result.stopReason}, "
|
||||
f"answer_length={len(generated_answer)}"
|
||||
"Sampling successful: model=%s, stop_reason=%s, answer_length=%d",
|
||||
sampling_result.model,
|
||||
sampling_result.stopReason,
|
||||
len(generated_answer),
|
||||
)
|
||||
|
||||
return SamplingSearchResponse(
|
||||
@@ -565,8 +704,10 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
f"Sampling request timed out after {sampling_timeout_seconds} seconds for query: '{query}', "
|
||||
f"returning search results only"
|
||||
"Sampling request timed out after %d seconds for query: %r, "
|
||||
"returning search results only",
|
||||
sampling_timeout_seconds,
|
||||
query,
|
||||
)
|
||||
return SamplingSearchResponse(
|
||||
query=query,
|
||||
@@ -588,18 +729,20 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
|
||||
if "rejected" in error_msg.lower() or "denied" in error_msg.lower():
|
||||
# User explicitly declined - this is normal, not an error
|
||||
logger.info(f"User declined sampling request for query: '{query}'")
|
||||
logger.info("User declined sampling request for query: %r", query)
|
||||
search_method = "semantic_sampling_user_declined"
|
||||
user_message = "User declined to generate an answer"
|
||||
elif "not supported" in error_msg.lower():
|
||||
# Client doesn't support sampling - also normal
|
||||
logger.info(f"Sampling not supported by client for query: '{query}'")
|
||||
logger.info("Sampling not supported by client for query: %r", query)
|
||||
search_method = "semantic_sampling_unsupported"
|
||||
user_message = "Sampling not supported by this client"
|
||||
else:
|
||||
# Other MCP protocol errors
|
||||
logger.warning(
|
||||
f"MCP error during sampling for query '{query}': {error_msg}"
|
||||
"MCP error during sampling for query %r: %s",
|
||||
query,
|
||||
error_msg,
|
||||
)
|
||||
search_method = "semantic_sampling_mcp_error"
|
||||
user_message = f"Sampling unavailable: {error_msg}"
|
||||
@@ -620,8 +763,10 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
except Exception as e:
|
||||
# Truly unexpected errors - these SHOULD have tracebacks
|
||||
logger.error(
|
||||
f"Unexpected error during sampling for query '{query}': "
|
||||
f"{type(e).__name__}: {e}",
|
||||
"Unexpected error during sampling for query %r: %s: %s",
|
||||
query,
|
||||
type(e).__name__,
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
@@ -670,11 +815,15 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
try:
|
||||
# Get document receive stream from lifespan context
|
||||
# Get document receive stream from lifespan context. Direct
|
||||
# attribute access matches the eviction_task_group pattern at
|
||||
# ``nc_semantic_search`` (see comment there): both AppContext
|
||||
# and OAuthAppContext define ``document_receive_stream``, so a
|
||||
# missing attribute is a typo that should fail loudly. The
|
||||
# value itself can legitimately be ``None`` before sync starts,
|
||||
# which the check below handles.
|
||||
lifespan_ctx = ctx.request_context.lifespan_context
|
||||
document_receive_stream = getattr(
|
||||
lifespan_ctx, "document_receive_stream", None
|
||||
)
|
||||
document_receive_stream = lifespan_ctx.document_receive_stream
|
||||
|
||||
if document_receive_stream is None:
|
||||
logger.debug(
|
||||
@@ -705,7 +854,7 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
indexed_count = count_result.count
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to query Qdrant for indexed count: {e}")
|
||||
logger.warning("Failed to query Qdrant for indexed count: %s", e)
|
||||
# Continue with indexed_count = 0
|
||||
|
||||
# Determine status
|
||||
@@ -719,7 +868,7 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting vector sync status: {e}")
|
||||
logger.error("Error getting vector sync status: %s", e)
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Lazy eviction of stale documents from the vector index.
|
||||
|
||||
Used by the verify-on-read path (ADR-019) to remove points for documents that
|
||||
have been deleted or unshared in Nextcloud but not yet reconciled by the
|
||||
webhook/scanner sync loop. Eviction is fire-and-forget from the search hot
|
||||
path; failures are logged but never propagated, since the next query will
|
||||
simply re-verify and re-attempt.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def delete_document_points(
|
||||
doc_id: str | int,
|
||||
doc_type: str,
|
||||
user_id: str,
|
||||
) -> None:
|
||||
"""Remove all Qdrant points for a single document.
|
||||
|
||||
Deletes both real chunk points and any leftover placeholder points for the
|
||||
given (user_id, doc_id, doc_type) tuple. Safe to call when the document is
|
||||
not present — Qdrant returns successfully with zero points affected.
|
||||
|
||||
Args:
|
||||
doc_id: Document ID (int for notes/files/cards/news, str otherwise)
|
||||
doc_type: Document type (note, file, deck_card, news_item)
|
||||
user_id: Owner of the points being evicted
|
||||
|
||||
Raises:
|
||||
Exception: If the underlying Qdrant client raises. Callers in the
|
||||
search hot path should catch and log; eviction failures must not
|
||||
block search responses.
|
||||
"""
|
||||
qdrant_client = await get_qdrant_client()
|
||||
settings = get_settings()
|
||||
|
||||
await qdrant_client.delete(
|
||||
collection_name=settings.get_collection_name(),
|
||||
points_selector=Filter(
|
||||
must=[
|
||||
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
|
||||
FieldCondition(key="doc_id", match=MatchValue(value=doc_id)),
|
||||
FieldCondition(key="doc_type", match=MatchValue(value=doc_type)),
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Evicted Qdrant points for %s_%s (user=%s); "
|
||||
"document was inaccessible at verification time",
|
||||
doc_type,
|
||||
doc_id,
|
||||
user_id,
|
||||
)
|
||||
@@ -658,6 +658,23 @@ async def _index_document(
|
||||
indexed_at = int(time.time())
|
||||
points = []
|
||||
|
||||
# Surface deck card data quality issues at indexing time rather than
|
||||
# only at verification time (where _verify_deck_cards falls through to
|
||||
# legacy-data pass-through when board_id/stack_id are missing). This is
|
||||
# logged once per document — not per chunk — to avoid log spam.
|
||||
if doc_task.doc_type == "deck_card":
|
||||
missing_deck_fields = [
|
||||
field for field in ("board_id", "stack_id") if not file_metadata.get(field)
|
||||
]
|
||||
if missing_deck_fields:
|
||||
logger.warning(
|
||||
"Indexing deck_card %s for user %s with missing metadata: %s; "
|
||||
"verification will fall back to legacy-data pass-through",
|
||||
doc_task.doc_id,
|
||||
doc_task.user_id,
|
||||
missing_deck_fields,
|
||||
)
|
||||
|
||||
for i, (chunk, dense_emb, sparse_emb) in enumerate(
|
||||
zip(chunks, dense_embeddings, sparse_embeddings)
|
||||
):
|
||||
|
||||
@@ -29,6 +29,16 @@ from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Single source of truth for which doc_types this scanner indexes. The verifier
|
||||
# registry in `search/verification.py` must cover every type listed here
|
||||
# (enforced by `tests/unit/search/test_verification.py`). Add a verifier in the
|
||||
# same PR that adds a new indexed doc_type, or accept ghost-record exposure for
|
||||
# that type (see ADR-019).
|
||||
INDEXED_DOC_TYPES: frozenset[str] = frozenset(
|
||||
{"note", "file", "deck_card", "news_item"}
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DocumentTask:
|
||||
"""Document task for processing queue."""
|
||||
|
||||
@@ -38,6 +38,7 @@ from typing import Any, AsyncGenerator
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
from mcp import ClientSession
|
||||
|
||||
from nextcloud_mcp_server.providers.base import Provider
|
||||
@@ -130,10 +131,18 @@ async def indexed_manual_pdf(nc_client, nc_mcp_client):
|
||||
|
||||
logger.info(f"Setting up indexed manual PDF: {manual_path}")
|
||||
|
||||
# Get file info to verify file exists and get file ID
|
||||
file_info = await nc_client.webdav.get_file_info(manual_path)
|
||||
# Get file info to verify file exists and get file ID. After the
|
||||
# round-7 contract widening, get_file_info raises HTTPStatusError on
|
||||
# 404 instead of returning None — so wrap and skip on a definitive
|
||||
# not-found.
|
||||
try:
|
||||
file_info = await nc_client.webdav.get_file_info(manual_path)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
pytest.skip(f"Manual PDF not found at '{manual_path}'")
|
||||
raise
|
||||
if not file_info:
|
||||
pytest.skip(f"Manual PDF not found at '{manual_path}'")
|
||||
pytest.skip(f"Manual PDF unreadable at '{manual_path}' (malformed PROPFIND)")
|
||||
|
||||
file_id = file_info["id"]
|
||||
logger.info(f"Found manual PDF: {manual_path} (file_id={file_id})")
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Integration tests for verify-on-read access checks (ADR-019).
|
||||
|
||||
These tests exercise ``verify_search_results`` against a real Nextcloud
|
||||
instance — the verification path's whole purpose is to consult Nextcloud as
|
||||
the source of truth, so unit-level mocks don't catch protocol or status-code
|
||||
mismatches between our verifier and the real API.
|
||||
|
||||
**Coverage**: only the ``note`` verifier is exercised against real Nextcloud
|
||||
here. The ``file`` (WebDAV PROPFIND), ``deck_card`` (Deck app), and
|
||||
``news_item`` (News app) verifiers are unit-tested with mocked HTTP
|
||||
responses in ``tests/unit/search/test_verification.py``. Adding integration
|
||||
coverage for those types is tracked as a follow-up — it requires fixture
|
||||
data (tagged PDFs in user files, a Deck board with cards, a News feed) that
|
||||
is non-trivial to seed from CI. The mocked unit tests are accurate for
|
||||
status-code semantics but won't catch payload-shape regressions in those
|
||||
Nextcloud apps; the trade-off is documented here so future readers know
|
||||
which suite owns which verifier.
|
||||
|
||||
Qdrant is mocked out (``delete_document_points`` and the payload-resolution
|
||||
helpers) so these tests don't require a running vector database. The unit
|
||||
suite in ``tests/unit/search/test_verification.py`` covers the Qdrant-side
|
||||
behaviour separately.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.search import verification
|
||||
from nextcloud_mcp_server.search.algorithms import SearchResult
|
||||
from nextcloud_mcp_server.search.verification import verify_search_results
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def _result_for_note(note_id: int) -> SearchResult:
|
||||
return SearchResult(
|
||||
id=note_id,
|
||||
doc_type="note",
|
||||
title=f"note_{note_id}",
|
||||
excerpt="...",
|
||||
score=0.9,
|
||||
)
|
||||
|
||||
|
||||
async def test_verify_keeps_accessible_note(
|
||||
nc_client: NextcloudClient, temporary_note: dict, mocker
|
||||
):
|
||||
"""A note that exists in Nextcloud must be kept by verification."""
|
||||
spy_evict = mocker.AsyncMock()
|
||||
mocker.patch.object(verification, "delete_document_points", spy_evict)
|
||||
|
||||
note_id = temporary_note["id"]
|
||||
results = [_result_for_note(note_id)]
|
||||
|
||||
kept, dropped_count = await verify_search_results(nc_client, results)
|
||||
|
||||
assert [r.id for r in kept] == [note_id]
|
||||
assert dropped_count == 0
|
||||
spy_evict.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_verify_drops_deleted_note_and_schedules_eviction(
|
||||
nc_client: NextcloudClient, mocker
|
||||
):
|
||||
"""The core ghost-record scenario.
|
||||
|
||||
Create a note, delete it via the API (no webhook delivery), then run
|
||||
verification with a SearchResult still pointing at the gone-but-indexed
|
||||
document. verify-on-read must drop it and schedule eviction.
|
||||
"""
|
||||
spy_evict = mocker.AsyncMock()
|
||||
mocker.patch.object(verification, "delete_document_points", spy_evict)
|
||||
|
||||
# Create a note we'll delete to simulate a ghost record
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
created = await nc_client.notes.create_note(
|
||||
title=f"verify-on-read ghost {unique_suffix}",
|
||||
content="This note will be deleted before verification runs.",
|
||||
category="VerifyOnReadTest",
|
||||
)
|
||||
note_id = created["id"]
|
||||
|
||||
# Delete via API directly. In production a webhook *should* fire and
|
||||
# evict from Qdrant — but the whole point of ADR-019 is that we cannot
|
||||
# rely on this. Verification must catch the drift independently.
|
||||
await nc_client.notes.delete_note(note_id=note_id)
|
||||
|
||||
# Confirm the note is really gone before running verification, so the
|
||||
# test fails fast if the API behaves unexpectedly.
|
||||
with pytest.raises(HTTPStatusError) as exc_info:
|
||||
await nc_client.notes.get_note(note_id)
|
||||
assert exc_info.value.response.status_code == 404
|
||||
|
||||
kept, dropped_count = await verify_search_results(
|
||||
nc_client, [_result_for_note(note_id)]
|
||||
)
|
||||
|
||||
assert kept == [], "deleted note must not pass verification"
|
||||
assert dropped_count == 1
|
||||
spy_evict.assert_awaited_once_with(note_id, "note", nc_client.username)
|
||||
|
||||
|
||||
async def test_verify_mixed_accessible_and_deleted(
|
||||
nc_client: NextcloudClient, temporary_note: dict, mocker
|
||||
):
|
||||
"""Verification must drop only the inaccessible result, keep the rest."""
|
||||
spy_evict = mocker.AsyncMock()
|
||||
mocker.patch.object(verification, "delete_document_points", spy_evict)
|
||||
|
||||
# temporary_note stays alive for the duration of the test.
|
||||
accessible_id = temporary_note["id"]
|
||||
|
||||
# Make a second note and immediately delete it to create a ghost id.
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
ghost = await nc_client.notes.create_note(
|
||||
title=f"verify-on-read ghost mix {unique_suffix}",
|
||||
content="ghost",
|
||||
category="VerifyOnReadTest",
|
||||
)
|
||||
ghost_id = ghost["id"]
|
||||
await nc_client.notes.delete_note(note_id=ghost_id)
|
||||
|
||||
results = [
|
||||
_result_for_note(accessible_id),
|
||||
_result_for_note(ghost_id),
|
||||
]
|
||||
kept, dropped_count = await verify_search_results(nc_client, results)
|
||||
|
||||
assert [r.id for r in kept] == [accessible_id]
|
||||
assert dropped_count == 1
|
||||
spy_evict.assert_awaited_once_with(ghost_id, "note", nc_client.username)
|
||||
|
||||
|
||||
async def test_verify_dedupes_chunks_of_same_document(
|
||||
nc_client: NextcloudClient, temporary_note: dict, mocker
|
||||
):
|
||||
"""Multiple chunks of the same note must produce ONE Nextcloud round-trip."""
|
||||
spy_evict = mocker.AsyncMock()
|
||||
mocker.patch.object(verification, "delete_document_points", spy_evict)
|
||||
|
||||
# Spy through to the real notes client to count round-trips
|
||||
real_get_note = nc_client.notes.get_note
|
||||
spy_get_note = mocker.AsyncMock(side_effect=real_get_note)
|
||||
mocker.patch.object(nc_client.notes, "get_note", spy_get_note)
|
||||
|
||||
note_id = temporary_note["id"]
|
||||
# Three chunks of the same note (chunk_index varies)
|
||||
results = [
|
||||
SearchResult(
|
||||
id=note_id,
|
||||
doc_type="note",
|
||||
title="note",
|
||||
excerpt=f"chunk {i}",
|
||||
score=0.9 - i * 0.1,
|
||||
chunk_index=i,
|
||||
)
|
||||
for i in range(3)
|
||||
]
|
||||
|
||||
kept, dropped_count = await verify_search_results(nc_client, results)
|
||||
|
||||
# All three chunks kept (they're all from the same accessible note)
|
||||
assert len(kept) == 3
|
||||
assert dropped_count == 0
|
||||
# ...but verification only fetched the note ONCE
|
||||
assert spy_get_note.await_count == 1
|
||||
@@ -164,8 +164,14 @@ async def test_get_file_info_returns_file_details(mocker):
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_get_file_info_returns_none_for_missing_file(mocker):
|
||||
"""Test that get_file_info returns None for missing files."""
|
||||
async def test_get_file_info_raises_on_404(mocker):
|
||||
"""get_file_info now raises HTTPStatusError on 404 (was: returned None).
|
||||
|
||||
The contract was widened so verify-on-read can distinguish a definitive
|
||||
404 from an ambiguous malformed-PROPFIND response. Callers that want
|
||||
"absent → None" semantics should catch HTTPStatusError and check the
|
||||
status code themselves.
|
||||
"""
|
||||
from httpx import HTTPStatusError, Response
|
||||
|
||||
mock_http_client = AsyncMock()
|
||||
@@ -180,11 +186,10 @@ async def test_get_file_info_returns_none_for_missing_file(mocker):
|
||||
)
|
||||
)
|
||||
|
||||
# Call get_file_info
|
||||
result = await client.get_file_info("nonexistent.pdf")
|
||||
with pytest.raises(HTTPStatusError) as exc_info:
|
||||
await client.get_file_info("nonexistent.pdf")
|
||||
|
||||
# Verify result is None
|
||||
assert result is None
|
||||
assert exc_info.value.response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Vendored
+1
-1
Submodule third_party/astrolabe updated: 2bf4a70599...d9e641b93e
Reference in New Issue
Block a user