Commit Graph
25 Commits
Author SHA1 Message Date
Chris CoutinhoandClaude Opus 4.8 cd243ed6c3 fix(search): address review feedback on multi-folder path filter
- visualization.py: drop the CSV string-split branch. The Astrolabe PHP
  client sends path_prefixes as a JSON array, so only a list is accepted;
  any other shape is ignored rather than comma-split (which would corrupt
  folder names containing commas).
- viz_routes.py: split the path_prefixes query param on newline (a comma
  is a valid POSIX path char; a newline is not) and pass None instead of
  [""] when the param is absent.
- access_filter.py: widen build_base_filter_conditions' path_prefixes to
  Iterable[str] for consistency with normalize_path_prefixes.
- ADR-027: document the newline delimiter (frontend/viz route) and JSON
  array (PHP->MCP body), and the PHP-side cap on list width.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 13:03:42 +02:00
Chris CoutinhoandClaude Opus 4.8 de6c4b360d feat(search): support multiple folders in the semantic-search path filter
Extend the ADR-027 Phase 2 path filter from a single path_prefix to a
list of folders. The new normalize_path_prefixes() helper is the single
source of truth for trimming, dropping blanks, and de-duplicating, and
folds the legacy single path_prefix into the list for backward
compatibility.

build_base_filter_conditions() adds one MatchText to the must clause for
a single folder (unchanged shape) and OR-s multiple folders via a nested
Filter(should=[...]) so a file under any selected folder matches while
still AND-ing against the ACL/doc_type/date conditions.

path_prefixes is threaded through every search surface: the
nc_semantic_search MCP tool, the visualization API (JSON body), and the
viz route (CSV query param). The Astrolabe frontend folder picker that
produces these lists ships in a companion astrolabe PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 12:51:37 +02:00
Chris CoutinhoandClaude Opus 4.8 ab128bef5b feat(search): ADR-027 Phase 2 — file-path filter
Add a path_prefix filter to semantic search, honoured on both the MCP tool and
the dense-only visualization/API paths through the shared filter contract.

- build_base_filter_conditions: append FieldCondition(file_path,
  MatchText(path_prefix)) when set. file_path is only on doc_type == "file"
  points, so a non-empty path_prefix implicitly restricts to files.
- Promote path_prefix to an explicit keyword param on the SearchAlgorithm ABC
  and both algorithms; thread it through nc_semantic_search (blank ⇒ no filter),
  the /api/v1 search endpoints, and the viz route.
- Add a file_path TEXT payload index to _PAYLOAD_INDEX_FIELDS (no content
  re-index; idempotent startup migration). MatchText tokenizes on server Qdrant
  and matches by substring on local/embedded qdrant-client — both serve folder
  scoping.
- Update ADR-027 (Phase 2 implemented; readiness table; semantics note). Tests.

Refs ADR-027 Phase 2. Deck #177.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:51:12 +02:00
Chris CoutinhoandClaude Opus 4.8 c2c8dc1a08 feat(search): ADR-027 Phase 1 — modified-date range filter
Add a modified_after/modified_before date-range filter to semantic search,
honoured on both the MCP tool path (BM25HybridSearchAlgorithm) and the
dense-only visualization/API path (SemanticSearchAlgorithm) through one shared
contract.

- Promote modified_after/modified_before to explicit keyword params on the
  SearchAlgorithm ABC and both concrete algorithms; factor the shared
  placeholder+ownership+doc_type+date filter into
  access_filter.build_base_filter_conditions so new filters land in one place.
- nc_semantic_search: accept RFC 3339 / ISO 8601 (or Unix seconds) bounds via
  utils.validation.parse_modified_timestamp; Annotated/Field constraints on the
  numeric args; explicit McpError guard for after > before. Thread the parsed
  bounds through the cross-app and per-doc_type dispatch.
- /api/v1 search endpoints + viz route parse the same formats and 400 on bad or
  inverted ranges.
- Add a modified_at INTEGER payload index to _PAYLOAD_INDEX_FIELDS; the
  idempotent _ensure_payload_indexes() startup path migrates existing
  collections with no content re-index.
- Update ADR-027 to resolve the review feedback (validation placement, shared
  algorithm contract, deferral of nc_semantic_search_answer, payload index,
  RFC-3339-at-the-boundary rationale). Add unit tests.

Refs ADR-027. Deck #177.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:35:20 +02:00
Chris CoutinhoandClaude Opus 4.8 29500bea7e fix: PR #813 review — cap unified_search multi-type pool; document deck self-only
🟡 Performance: unified_search's _execute sorted but did not cap the merged
multi-doc_type pool, so N doc_types each fetched at search_limit sent
N*search_limit candidates into verify-on-read (one Nextcloud round-trip each).
Cap to search_limit*2 after the sort, matching vector_search, nc_semantic_search
and the viz_routes pattern — bounding verification cost to O(2*search_limit)
regardless of how many doc_types are requested.

🟡 Consistency: _get_deck_metadata_from_qdrant is the one internal Qdrant lookup
that uses a raw user_id filter instead of build_ownership_filter. This is not a
bug — deck cards are a documented cross-user gap (the Deck API is per-user, so
cross-user context can't be fetched with the caller's credentials) — but the
inconsistency was unexplained. Added a comment documenting the deliberate
self-only scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 17:49:21 +02:00
Chris CoutinhoandClaude Opus 4.8 350358b802 fix: PR #813 review — shared-file context in MCP tool path + viz over-fetch cap
🟡 Important: nc_semantic_search's include_context branch did not forward
accessible_owners to get_chunk_with_context, so context expansion for shared
files stayed self-only, found nothing in Qdrant, and silently fell back to the
plain excerpt. Forward accessible_owners (the per-file file_accessible_by_id
gate still enforces access).

🟡 Performance: auth/viz_routes.py's multi-doc_type branch sorted but did not
cap the candidate pool before verify-on-read, so N doc_types × limit*2 went
into verification (N× the Nextcloud round-trips). Cap to limit*2 after the
sort, matching server/semantic.py and the cross-app branch.

Also clear the SonarCloud gate (new_duplicated_lines_density 5.1% > 3%) the
ACL wiring introduced: extract the duplicated /api/v1 client-resolution +
owner-expansion + verify-on-read block from unified_search/vector_search into a
shared _search_with_acl helper, define a constant for the repeated
"Nextcloud host not configured" literal (S1192), and reword the access_filter
move_to_end comment so it isn't misread as commented-out code (S125) while
adding the other-owner count to its debug log (review nits).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 17:16:23 +02:00
Chris CoutinhoandClaude Opus 4.8 8deb48e6fa fix: address PR #813 review round 4 (log leak, cross-user chunk ctx, algo, overlap)
1. Don't log unverified result titles: both search algorithms logged top-5
   titles at DEBUG before verify-on-read; with owner-level share expansion the
   unverified set can contain other users' docs. Algorithms now log a count
   only; the verifying callers (server/semantic, viz_routes, api/visualization)
   log verified titles after verify-on-read.

2. Cross-user FILE chunk context: get_chunk_with_context + the Qdrant chunk
   helpers now take accessible_owners and use build_ownership_filter. For files
   the expanded scope is honoured only after a per-file file_accessible_by_id
   check (accessible_owners is owner-level, so the gate prevents a one-file
   share recipient from reading any of the owner's cached chunks). note/deck/
   news stay self-only (per-user APIs) — a documented gap. Both chunk endpoints
   pass accessible_owners.

3. Algorithm usage: SemanticSearchAlgorithm is not dead (it backs the dense-only
   option on the viz/API surfaces); added a clarifying comment in server/
   semantic.py. Additionally wired accessible_owners + verify-on-read into the
   /api/v1 search routes (unified_search, vector_search) so the astrolabe
   surface is ACL-aware too — degrading gracefully to self-only/unverified for
   non-provisioned callers instead of 401.

4. Overlapping conditions: build_ownership_filter no longer lists self in the
   owner_id MatchAny branch (self is already covered by the user_id branch);
   the owner_id branch carries only the OTHER owners.

Tests: build_ownership_filter dedup + chunk-bbox filter-shape updates; new
ACL-aware get_indexed_doc_types, cached-chunk lookup, and end-to-end cross-user
file chunk-context (recipient gets the chunk, non-recipient denied) tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 16:55:00 +02:00
Chris CoutinhoandClaude Opus 4.7 665cb9b1eb refactor: convert f-string logging to lazy %-style format (G004)
Sweep all 1676 G004 violations across 112 files, converting
`logger.<level>(f"…{x}…")` to `logger.<level>("…%s…", x)`.

Why: ruff rule G004 was added to pyproject.toml to enforce lazy
%-style logging — defers formatting until the log level is enabled
and lets structured log tooling match the unformatted template.

Conversion preserves rendered output byte-for-byte:
- `{x}` → `%s` + `x`
- `{x!r}` / `{x!s}` / `{x!a}` → `%r` / `%s` / `%a`
- Format specs (`{x:.2f}`, `{x:>10}`) → `%s` + `format(x, 'spec')`
  (printf-style specs aren't 1:1 with Python format specs, so we
  delegate to `format()` to keep identical output)
- Literal `%` → `%%`
- Concatenated f-strings (`f"a {x} " "b"`) flattened
- Trailing kwargs (`exc_info=True`) preserved

Verified:
- `uv run ruff check --select G004` → 0 violations
- `uv run ty check -- nextcloud_mcp_server` → passes
- `uv run pytest tests/unit/` → 1010 passed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 01:12:17 +02:00
Chris CoutinhoandClaude Opus 4.7 8f4f5c0079 fix(vector): address PR review round 16 — type-aware index check, comments
Detect pre-existing payload indexes with the wrong schema type in
`_ensure_payload_indexes`. The previous "field already in
existing_schema → skip" branch silently survived a collection migrated
from the int-doc_id era where `doc_id` is indexed as INTEGER, letting
`MatchValue(value="123")` searches keep failing with HTTP 400 on Qdrant
Cloud strict mode — exactly the production failure this PR was meant to
fix. New behaviour: compare `existing_schema[field].data_type` against
the declared type; on mismatch log a WARNING and append to
`failed_fields` so the consolidated end-of-function summary picks it up.
No auto-repair (operator intervention only — see docs/configuration.md
recovery procedure). New test exercises the doc_id-INTEGER scenario
end-to-end and asserts both the per-field WARNING and the summary line.

Clarify the `_verify_news_items` malformed-doc_id rationale: the news
API has no per-item endpoint, so a malformed doc_id genuinely cannot be
verified against the source of truth. We err toward false-positive
(keep) over false-negative (drop) — same conservative posture as
`_verify_notes` and `_verify_deck_cards`. The producer-side validation
is the real security boundary; the verifier is defence-in-depth. Both
the inline comment and the WARNING message now spell this out.

Add a TODO in `get_last_indexed_timestamp` flagging the O(N) cost on
every incremental sync tick. The previous single-page `limit=10_000`
silently bounded the scroll; paginating fixed correctness but made the
unbounded cost visible. The follow-up tracker (canonical TODO at
`api/visualization.py`) covers migrating the max-`indexed_at` to a
sentinel point or collection metadata for O(1) lookup.

Consolidate the duplicate non-numeric-doc_type TODOs at
`api/visualization.py:508` and `auth/viz_routes.py:570` into a single
canonical comment in `visualization.py`; `viz_routes.py` is reduced to
a back-reference. Removes the rot risk of "fixed in one place,
forgotten in the other." The canonical comment also references the
O(1) timestamp follow-up in `scanner.py`.

Document the `batch_size = 256` (qdrant_client.py) vs
`_DELETION_TRACKING_PAGE_SIZE = 1024` (scanner.py) split with
cross-referencing comments at each site: the smaller batch is for the
read-write backfill upsert path (Qdrant accepts ~256-point chunks
comfortably); the larger page is for read-only deletion-tracking
scrolls where no per-page write round-trip applies.

Replace `assert qdrant_client is not None` in `scan_user_documents`
with `cast(AsyncQdrantClient, qdrant_client)` plus an explanatory
comment. `assert` is silently elided under `-O`; `cast` is the
conventional zero-cost narrower for branches the type checker can't
infer from the surrounding `if not initial_sync` ternary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:50:23 +02:00
Chris CoutinhoandClaude Opus 4.7 ae23bbe8b8 fix(vector): address PR review round 13 — index offset fields + tighten test
- Add chunk_start_offset / chunk_end_offset to _PAYLOAD_INDEX_FIELDS so
  the legacy offset-based fallback in search/context.py works on Qdrant
  Cloud strict mode (pre-#75 clients have no chunk_index payload).
- Cover chunk_index / chunk_start_offset / chunk_end_offset in the
  payload-index summary test; refresh the stale field-list comment.
- Flag the is_valid_nextcloud_doc_id gate at both chunk-context handler
  sites with a TODO for future non-numeric doc_types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:27:57 +02:00
Chris CoutinhoandClaude Opus 4.7 f9ad7dc52e fix(vector): address PR review round 12 — bool guard + strict doc_id validation
- _group_int_doc_ids: use type(value) is not int instead of isinstance,
  since bool is an int subclass and would otherwise stringify to
  "True"/"False" and corrupt legacy payloads on backfill.
- Replace doc_id.isdigit() guards in 5 boundary sites
  (api/visualization, auth/viz_routes, search/context note/news_item/
  deck_card branches) with a shared is_valid_nextcloud_doc_id helper
  that rejects "0", leading zeros, and Unicode digit classes
  (superscripts, Arabic-Indic, Devanagari) which pass isdigit() but
  cannot be valid MySQL AUTO_INCREMENT IDs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:28:47 +02:00
Chris CoutinhoandClaude Opus 4.7 d60348e77b fix(api): validate doc_id at chunk-context handler boundary
Add an .isdigit() guard at the top of both chunk-context handlers so a
non-numeric doc_id fails fast with a clear 400 ("doc_id must be numeric,
got 'abc'") rather than silently bottoming out as a 404 from deep inside
get_chunk_with_context. The earlier int(doc_id) coercion was removed when
doc_id became a pure pass-through to Qdrant's keyword payload index, which
also dropped this boundary validation.

Also align test_backfill_emits_progress_log_every_20_batches' scroll stub
with real Qdrant: next_offset is now "next-1" (str) instead of 1 (int),
matching the sibling test_backfill_rewrites_int_doc_ids_to_str. Pure
stub-fidelity fix; production code already treats next_offset as opaque.

Addresses both 🟡 Important items from PR #773 review round 10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:54:06 +02:00
Chris Coutinho 9720a7e4fe Merge remote-tracking branch 'origin/master' into fix/qdrant-doc-id-keyword-index 2026-05-09 13:48:21 +02:00
Chris CoutinhoandClaude Opus 4.7 7ef8760d27 fix(chunk-context): address PR #767 review — extract bbox helper, fix page_number overwrite
Resolves both 🟡 important issues from the latest review:

1. `page_number` was unconditionally overwritten in `viz_routes.py:696` even
   when Qdrant's payload lacked the field, clobbering the value resolved
   from `chunk_context.page_number`. The new helper returns each field
   independently and both call sites only overwrite via `is not None`
   guards, matching the existing logic in `visualization.py`.

2. The ~60-line `if chunk_index is not None: ... else: ...` Qdrant scroll
   block was duplicated between `api/visualization.py` and
   `auth/viz_routes.py`. Extracted into `get_chunk_bbox_and_page_from_qdrant`
   in `search/context.py` alongside the existing private `_get_chunk_*_from_qdrant`
   helpers; both routes now share ~12 lines of caller code.

New unit tests at `tests/unit/test_chunk_bbox_helper.py` cover the indexed
and offset paths, the `(bbox, None)` regression case, and graceful
degradation on Qdrant strict-mode 400 (which also closes nit #4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:29:22 +02:00
Chris CoutinhoandClaude Opus 4.7 47b0b737b6 fix(chunk-context): address PR #767 round-3 review — gate readability + legacy-fallback comment
- search/context.py: rename triple-negation gate condition to
  `skip_offset_lookup` named boolean for readability; convert new
  logger.warning to lazy %-style per repo convention.
- api/visualization.py, auth/viz_routes.py: add comment on the offset-only
  Qdrant scroll branch noting it is a legacy path for pre-astrolabe#75
  clients and degrades gracefully on Qdrant Cloud strict mode.

Reviewer item #2 (extracting the duplicated scroll block into a shared
helper) deferred to a follow-up issue per the reviewer's "not blocking"
framing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:21:30 +02:00
Chris Coutinho 058463ee87 Merge remote-tracking branch 'origin/master' into fix/chunk-context-indexed-lookup
# Conflicts:
#	nextcloud_mcp_server/api/visualization.py
#	nextcloud_mcp_server/auth/viz_routes.py
2026-05-08 23:10:09 +02:00
Chris Coutinho 02744a50e0 Merge remote-tracking branch 'origin/master' into fix/qdrant-doc-id-keyword-index 2026-05-08 23:08:39 +02:00
Chris CoutinhoandClaude Opus 4.7 51c1d42ea3 fix(chunk-context): address PR #767 round-2 review — gate, parity, doc
Latest reviewer comment flagged five items on top of the original PR. This
commit addresses every one:

🟡 1. Skip the offset-based Qdrant fallback for `doc_type=file` when
   `chunk_index` is supplied. Qdrant Cloud's strict mode rejects unindexed
   filter fields with HTTP 400, which `_get_chunk_from_qdrant` catches and
   logs at `logger.error` — masking real Qdrant problems in monitoring.
   Notes/cards keep the offset fallback (cheap, useful for legacy data).

🟡 2. Add a `logger.warning` and clarifying inline comment in the doc-text
   fallback path when `chunk_index` is None — surfaces the pre-existing
   "0/N misreport" so callers can detect it. Type-nullability propagation
   is deferred to a follow-up (out of scope for this hotfix).

🟢 3. Simplify `if chunk_text and doc_id_int is not None:` →
   `if chunk_text:` with an inner `assert doc_id_int is not None` for
   `ty` narrowing. The outer second clause was dead.

🟢 4. Add `doc_type` `FieldCondition` to the offset-based image lookup in
   both `visualization.py` and `viz_routes.py` for parity with the
   `chunk_index` branches.

🟢 5. Inline the `chunk_filter` local in `visualization.py` directly into
   the `must=[]` list (matches `viz_routes.py` style).

Adds `tests/unit/test_chunk_context_offset_gate.py` with three regression
tests covering the gate matrix: (file, with-index → skip offset),
(note, with-index → still tries offset), (file, no-index → still tries
offset). Lives at top-level rather than `tests/unit/search/` to side-step
a pre-existing circular-init issue in `nextcloud_mcp_server.search`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:28:50 +02:00
Chris CoutinhoandClaude Opus 4.7 ee402ea00e feat(vector): replace inline page-image payloads with chunk_bbox (Deck #76)
Per-chunk PDF page renders (~150–700 KB base64 PNG each) were the dominant
disk consumer in production, repeatedly tripping `No space left on device:
WAL buffer size exceeds available disk space` on welcomed-malamute Qdrant.

Replace the inline highlighted_page_image / highlighted_page_number /
highlight_count fields with a small `chunk_bbox` field:
list[(x0, y0, x1, y1)] of normalized [0, 1] floats, ~32 bytes per chunk.
Astrolabe (the only known consumer) renders the highlight client-side as
a percentage-positioned overlay on top of the existing /api/v1/pdf-preview
render-on-demand path (cbcoutinho/astrolabe#76).

- pdf_highlighter: new compute_chunk_bboxes_batch() that reuses the
  existing _find_chunk_bbox text-search path, skipping all pixmap/PIL/PNG
  work.
- processor: store chunk_bbox + chunk_bbox_page in the Qdrant payload,
  drop highlighted_page_image + friends, drop the base64 import.
- visualization /api/v1/chunk-context and auth/viz_routes: read
  chunk_bbox instead of highlighted_page_image.
- vector/__init__: stop eagerly re-exporting `processor`/`scanner` —
  fixes a pre-existing circular import (search.algorithms ->
  vector.placeholder -> vector/__init__ -> processor -> scanner ->
  server.semantic -> search.bm25_hybrid -> search.algorithms partial).
  Test suite that was broken on master (test_bm25_hybrid.py et al.) now
  collects and passes.
- scripts/purge_page_images.py: ad-hoc, idempotent migration that
  delete_payload's the legacy keys from existing points. No reindex
  required; legacy chunks render the page with no overlay.

Pairs with cbcoutinho/astrolabe#76. Frontend handles missing chunk_bbox
gracefully, so this can land in either order.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:16:00 +02:00
Chris CoutinhoandClaude Opus 4.7 8457c427a5 fix(chunk-context): address PR #767 review — doc_type filter parity + tests
- Add `doc_type` FieldCondition to the chunk_index-path highlighted-image
  Qdrant filter in both `api/visualization.py` and `auth/viz_routes.py`,
  matching the shape of `_get_chunk_by_index_from_qdrant`. Safe today (the
  block is guarded by `doc_type == "file"` and Nextcloud file IDs are
  globally unique) but prevents a latent bug if other doc types start
  storing highlighted images.
- Demote `viz_routes.py` `ValueError` log from `error` to `warning` (lazy
  %-style) — `_parse_int_param` raises on user-supplied bad input, which
  is a 400 not a server error and shouldn't pollute error logs.
- Hoist `effective_chunk_index` to compute once at the top of
  `get_chunk_with_context`, removing two duplicate assignments.
- Add `test_file_doc_type_qdrant_miss_yields_fast_404` to the management
  endpoint tests, locking in the proxy-timeout fix contract.
- Add `tests/unit/test_viz_routes_chunk_context.py` mirroring management
  coverage for the OAuth-session route: param forwarding (chunk_index /
  total_chunks), `doc_type=file` fast 404, and 400 on invalid int params.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:50:27 +02:00
Chris CoutinhoandClaude Opus 4.7 719b3b5034 fix(vector): normalize doc_id to str + add Qdrant keyword payload indexes
Production was logging two cascading classes of Qdrant errors against the
welcomed-malamute deployment:

1. HTTP 400 — "Bad request: Index required but not found for \"doc_id\" of
   one of the following types: [keyword]". The collection was created via
   create_collection() with no payload indexes, so any FieldCondition
   filter on doc_id failed at the Qdrant layer (placeholder writes/reads,
   eviction, search context lookups).

2. Compounding the missing index, producers wrote a mix of int and str
   doc_ids: webhook_parser stringified node_id, scanner stringified note
   IDs, news IDs, and deck card IDs — but the file scanner passed the
   numeric file_id through unchanged. A keyword index would not have
   covered both kinds even if it had existed.

This change:

- Normalizes doc_id to str at every producer site (scanner.py:459,
  DocumentTask.doc_id, indexed_*_ids reads from Qdrant).
- Tightens str|int annotations to str across placeholder.py,
  eviction.py, search/verification.py, search/context.py,
  SearchResult.id, and the auth/api visualization endpoints.
- Defensive str() coercion on doc_id reads in semantic.py /
  bm25_hybrid.py / vector/visualization.py for the transition window
  before the backfill runs.
- Adds an idempotent startup migration in get_qdrant_client():
  - _ensure_keyword_payload_indexes creates KEYWORD indexes for
    doc_id, user_id, and doc_type (tolerates "already exists" 400s).
  - _backfill_doc_id_to_string scrolls the collection once and rewrites
    int doc_ids to str. Skipped after a quick sample shows no legacy
    int payloads.
- Public API preserved: SemanticSearchResult.id stays int via explicit
  int(r.id) narrowing in server/semantic.py — surfaces a TypeError with
  actionable context if a future doc_type ships non-numeric ids.
- Documents the startup migration in docs/configuration.md.

Tests: 11 new unit tests in tests/unit/vector/test_qdrant_client.py
covering happy path / already-exists / unrelated-400 for the index
helpers, and sample-skip / mixed-batch rewrite / payload=None edge cases
for the backfill. 889 unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:30:51 +02:00
Chris CoutinhoandClaude Opus 4.7 90458b6f08 fix(chunk-context): use indexed chunk_index lookup, fix close-after-use bug
Two related bugs surfaced in production while viewing chunks from the
Astrolabe frontend on the AWS-hosted MCP server:

1. PyMuPDF document closed: in _fetch_document_text the fallback path
   referenced pdf_doc.page_count after pdf_doc.close(), raising
   "document closed" and returning None. The slow PDF re-parse already
   completed but its result was discarded. Capture page_count into a
   local before close().

2. Slow/fragile chunk lookup: get_chunk_with_context filtered Qdrant by
   (chunk_start_offset, chunk_end_offset). Those fields are not part of
   the always-indexed payload schema, and with strict_mode enabled they
   yield 400 errors. Even with manually-added indexes the filter is
   fragile if a doc is re-chunked. Switch to chunk_index (always
   indexed) as the primary lookup key, falling back to offset-based
   lookup when callers don't supply it.

Plumb chunk_index/total_chunks through both the management API
(api/visualization.py) and the OAuth viz route (auth/viz_routes.py).
Apply the same change to the highlighted-image lookup so all four
chunk-context Qdrant queries prefer the indexed field.

Skip the slow PDF re-parse fallback entirely for files: when both the
chunk_index and offset Qdrant lookups miss, re-downloading and
re-parsing the source PDF won't find the chunk either, and routinely
exceeds 30s on large documents - which is the proxy timeout in
Astrolabe. Notes/cards keep the document-fetch fallback (cheap).

Removes dead code (_get_file_path_from_qdrant) that was only used by
the now-unreachable file fallback path.

Companion change in the Astrolabe app passes chunk_index from search
results through to the new endpoint params.

---

_This PR was generated with the help of AI, and reviewed by a Human_

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 10:58:48 +02:00
Chris CoutinhoandClaude Opus 4.7 8a0d06107e fix(api): use stored app password for chunk-context and pdf-preview
The /api/v1/chunk-context and /api/v1/pdf-preview handlers in
api/visualization.py forwarded the incoming OAuth bearer directly to
Nextcloud via NextcloudClient.from_token. In multi-user BasicAuth mode
Nextcloud has no validator for those bearers on Notes/WebDAV, so it
treats the request as anonymous and returns 401 — surfaced to the user
as a 500 from /apps/astrolabe/api/chunk-context. Search worked because
it only hits Qdrant.

Architecturally, OAuth is only for Astrolabe→MCP server; MCP server→
Nextcloud always uses the per-user app password stored during provision
(background sync already does this via vector.oauth_sync).

- Resolve the Nextcloud client through get_user_client_basic_auth in
  both get_chunk_context and get_pdf_preview, surfacing
  NotProvisionedError as a clean 401 instead of opaque 500.
- Apply the same fix to the session-cookie variant in
  auth/viz_routes.chunk_context_endpoint for the internal viz UI.

Tests:
- New unit file test_management_chunk_context_endpoint.py, including a
  regression guard that asserts get_user_client_basic_auth is awaited
  (so reverting to from_token fails without needing a live Nextcloud).
- Updated test_management_pdf_preview_endpoint.py to mock the new auth
  path (drops extract_bearer_token / NextcloudClient.from_token patches).
- New integration test test_astrolabe_chunk_context.py drives the full
  chain (browser → Astrolabe → MCP → Nextcloud) in multi-user BasicAuth
  mode, plus bare-bones 401 checks on the MCP endpoint.

Full unit suite: 546 passed.

Companion PR on astrolabe (cbcoutinho/astrolabe#66) sends the Nextcloud
UID as loginName in the app-password POST body so the stored record is
complete. Submodule bump to that branch will follow once CI reproduces
the failure on the old submodule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:50:13 +02:00
Chris CoutinhoandClaude Opus 4.6 a11ae9c027 refactor: enforce PLC0415 (import-outside-top-level) for source code
Enable ruff PLC0415 rule for all source files (tests excluded via
per-file-ignores). Move 136 inline imports to top-level across 33 files.
8 imports suppressed with noqa for legitimate reasons: circular
dependencies (client/__init__.py, context.py), optional dependency
guards (app.py document processors, auth/userinfo_routes.py), and
post-env-setup imports (smithery_main.py).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 08:04:50 +01:00
Chris CoutinhoandClaude Opus 4.5 2e7774654b refactor(api): split management.py into domain-focused modules
Split the monolithic management.py (1988 lines) into 4 focused modules:
- management.py: Server status, user sessions, shared helpers (~520 lines)
- passwords.py: App password provisioning for BasicAuth mode (~300 lines)
- webhooks.py: Webhook registration management (~290 lines)
- visualization.py: Search and PDF preview endpoints (~810 lines)

Backward compatibility maintained via __init__.py re-exports.
Updated test imports to use new module paths.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 21:28:18 +01:00