Commit Graph
379 Commits
Author SHA1 Message Date
Chris CoutinhoandGitHub 3b40898a79 Merge pull request #911 from cbcoutinho/feat/admin-searchable-sources
feat(vector-sync): honor Astrolabe admin consent for searchable sources
2026-06-16 16:26:35 +02:00
Chris CoutinhoandClaude Opus 4.8 7067c5fff1 test(vector-sync): close round-8 coverage gaps (500 path, non-string list)
- route test for purge_doc_types raising on total failure -> 500
- route test for doc_types list containing non-strings -> 400
- reword the capabilities move_to_end comment (no-op on new keys; needed only
  for the expired-key in-place update)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 02:10:54 +02:00
Chris CoutinhoandClaude Opus 4.8 21ce620a84 fix(vector-sync): address round-6 review — rename shadowed var, add test
- vector_sync route: rename the response dict from `body` to `resp` so it no
  longer shadows the request `body` (maintenance trap)
- scanner: comment the intentional files-vs-text purge timing asymmetry
- tests: add the all-text-types-disabled backstop case (empty allow-set)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:56:54 +02:00
Chris CoutinhoandClaude Opus 4.8 d0db530ac9 fix(vector-sync): address round-5 review — partial-failure signal, markers
- purge route: include a "failed" key in the 200 body listing requested doc
  types that were not purged, so Astrolabe knows consent isn't yet enforced
  for them (scanner backstop still catches up)
- tests: add @pytest.mark.unit / module-level pytestmark to the new test
  modules so they run under `pytest -m unit`; add a partial-failure route test
- capabilities: comment why the cache is keyed per-user despite a global value
- semantic/scanner: doc/comment clarifications (sorted-order, eviction timing)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:47:44 +02:00
Chris CoutinhoandClaude Opus 4.8 6b9f910a14 fix(vector-sync): address round-4 review — processor test, partial eviction
- tests: cover the process_document consent gate (drops an admin-disabled
  index task with record_ingest_dropped("admin_disabled"); allows approved)
- scanner: _consent_backstop_done is now an insertion-ordered dict and evicts
  the oldest entries to half capacity on overflow, so a bound hit re-fires the
  backstop for only the oldest markers instead of the whole fleet at once
- semantic: reword the short-circuit log (consent, not installation)
- capabilities: comment why move_to_end is needed after an expired-key update
- test: assert the global purge delete-filter is owner-agnostic (doc_type only);
  fix a pre-existing ty error on UnexpectedResponse(headers=None) in the file

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:33:18 +02:00
Chris CoutinhoandClaude Opus 4.8 cef477b877 fix(vector-sync): address round-3 review — gate purge route, bound set, nits
- app.py: register /api/v1/vector-sync/purge only when vector_sync_enabled, so
  it returns 404 (not a 500 from get_qdrant_client) when sync is off
- scanner: bound _consent_backstop_done so a long-running multi-tenant process
  with user churn can't grow it without limit (clears on overflow)
- purge route: distinct 400 for a missing doc_types key; enforce the admin
  check even for an empty no-op request (destructive route)
- tests: missing-key 400, admin-gated empty no-op, non-admin empty 403

The _consent_narrowed_doc_types precondition is enforced by its non-Optional
frozenset[str] signature (ty rejects a None caller). The httpx.BasicAuth
SonarCloud hotspot matches the existing webhook routes (false positive,
credential from the app-password store) — left consistent for UI triage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:21:01 +02:00
Chris CoutinhoandClaude Opus 4.8 24b8000a71 fix(vector-sync): address round-2 review — one-shot backstop, helper, caps
- scanner: gate the consent backstop with a per-(user,doc_type) one-shot
  marker so a standing admin-disable doesn't re-enqueue idempotent deletes
  every scan tick; the marker clears when the type is re-enabled. Derive
  _TEXT_BACKSTOP_DOC_TYPES from INDEXED_DOC_TYPES so new indexed types are
  covered automatically
- semantic: extract _consent_narrowed_doc_types so the search-side narrowing
  is unit-testable; add tests for restrict/intersect/disjoint/empty
- purge route: cap doc_types length (abuse guard) -> 400
- tests: one-shot + re-enable backstop, too-many-doc_types 400

Deferred (noted on PR): per-document allowed_doc_types call is cache-hot;
purge "last error wins" — both logged. SonarCloud broad-except hotspots are
deliberate (noqa BLE001), reviewable in the UI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:08:30 +02:00
Chris CoutinhoandClaude Opus 4.8 477fb02b0a fix(vector-sync): address PR review — dict guard, symmetric backstop, metrics
- purge route: 400 (not 500) on a valid-JSON non-object body
- scanner: backstop-purge admin-disabled note/news_item/deck_card points
  (their deletion-tracking lives inside the skipped scan_* fns), mirroring the
  files path; gated on a concrete allow-set so fail-open never deletes
- processor: record_ingest_dropped("admin_disabled") so consent-skipped index
  tasks are observable/alertable
- app.py: list /api/v1/vector-sync/purge in the endpoints log line
- capabilities: drop empty-string doc types; return frozenset throughout
- purge: document the count-before-delete approximation
- tests: non-object body -> 400, ProvisioningRequiredError -> 428, cache TTL
  expiry refetch, and the scanner consent backstop

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:54:50 +02:00
Chris CoutinhoandClaude Opus 4.8 ef5b3f3873 feat(vector-sync): honor Astrolabe admin consent for searchable sources
Consume the astrolabe.semantic_search capability as the source of truth for
which content sources an admin has approved for semantic search, and enforce
it independently of Astrolabe (this server queries Qdrant directly).

- capabilities.py: cached per-user reader for enabled_doc_types (TTL+LRU,
  fail-open so older Astrolabe / transient OCS errors don't break search)
- semantic search: intersect requested doc_types with the allowed set;
  restrict to the allowed set when none requested; short-circuit when empty
- scanner: skip disabled sources during discovery (files discovery yields
  nothing when disabled, so the existing grace-period reconcile purges them)
- processor: drop near-real-time index tasks for disabled doc_types
  (webhook events bypass the scanner gate); deletes always proceed
- vector/purge.py + POST /api/v1/vector-sync/purge: admin-only global
  delete-by-doc_type, called by Astrolabe when a source is disabled so
  consent is binding on data-at-rest

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:38:35 +02:00
Chris CoutinhoandClaude Opus 4.8 210a234c11 test(ocr): round-6 — batch submit-error propagation test + import cleanup
Round 6 review (PR #910):
- Add the missing propagation test: a transport error (httpx.ConnectError) from
  batch submit() propagates out of process() rather than being caught by the sync
  OCR try/except or falling back to a sync transcription — guards the intentional
  "opted into batch → procrastinate retry, not sync fallback" asymmetry.
- Move the batch-test module imports (BatchPollResult, batch_ocr_store) to the
  top of test_ocr_processor.py, dropping the mid-file `# noqa: E402`.

Deferred (reviewer: not actionable for this PR): extracting a lazy-init helper
for the parallel _backend / _batch_client resolution quadruplets.

1653 unit tests pass; ruff + ty green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:20:38 +02:00
Chris CoutinhoandClaude Opus 4.8 bb08245c91 fix(ocr): round-5 review nits — empty-pages failure, comments, test cleanup
Round 5 review (PR #910), all nits, no blockers:
- _result_from_success: a succeeded job with `pages=[]` (empty list, not just a
  missing key) is now a per-document failure ("no pages returned") instead of a
  silent 0-chunk success. Test added.
- Comment the deadline-expiry path: the gateway-side job isn't cancelled (no
  cancel endpoint at this layer) — it's reaped by the gateway file purge; we just
  stop polling it.
- Drop the vestigial status="pending" from the BatchOcrJob test fakes (the column
  was removed in round 4; BatchPollResult.status fakes are untouched).

1653 unit tests pass; ruff + ty green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:12:42 +02:00
Chris CoutinhoandClaude Opus 4.8 232684e881 fix(ocr): round-4 review — defensive poll + drop dead tracking columns
Round 4 review (PR #910), no blockers:
- poll(): a 2xx body with no `status` now fails fast (logged) instead of being
  treated as perpetually pending until the deadline; defensive page index
  (`p.get("index", i)`) so a malformed page degrades rather than KeyError-ing.
- Document on poll() that job_id is namespaced (embeds "/") so the gateway route
  must be a path-capture param (GET /v1/ocr/batch/{job_id:path}).
- Drop the vestigial `status` + `updated_at` columns from batch_ocr_jobs: a row
  only ever exists while pending (terminal jobs are deleted) and the live status
  comes from a fresh poll, so a stored mirror was permanently "pending" /
  redundant with submitted_at. Simplifies the migration, store, and dataclass.
- Tests: submit() ValueError on missing job_id; poll() missing-status → failed.

1653 unit tests pass; ruff + ty green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:04:03 +02:00
Chris CoutinhoandClaude Opus 4.8 55630ba25c fix(ocr): round-3 review — guard unexpected batch status + tests/comments
Round 3 review (PR #910):
- Guard an unexpected terminal batch status in _process_batch: anything that
  isn't succeeded/failed (gateway version skew, a new lifecycle state) now marks
  the document parse-failed instead of falling through to _pages_to_text([]) — a
  0-chunk "success" that silently indexed empty text and re-submitted forever.
  Test added.
- gateway_batch_client.submit: raise an actionable ValueError on a 2xx response
  with no job_id (was a bare KeyError deep in the caller).
- Document that a _process_batch transport error intentionally propagates to
  procrastinate for retry rather than falling back to sync (opt-in batch wants
  the retry).
- Annotate _batch_client as GatewayBatchOcrClient | None (TYPE_CHECKING import
  already present); clarify the delete_stale_for_doc first-submit no-op comment.
- Add a parametrized build_gateway_batch_client test (the gateway-only invariant:
  mistral/none/no-URL -> None; gateway|auto + URL -> client).

1653 unit tests pass; ruff + ty green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:53:24 +02:00
Chris CoutinhoandClaude Opus 4.8 995e810d89 fix(ocr): round-2 review — lazy store lock, mode enum normalization, type hints
Round 2 review (PR #910):
- BLOCKING: BatchOcrJobStore._shared_lock is now lazy-init (anyio.Lock | None,
  created on first shared() call) instead of at class-definition time — matches
  the CLAUDE.md "no anyio primitives at import time" rule and OcrProcessor's
  pattern. The None-check->assign has no await between, so it's race-free.
- document_ocr_mode now normalizes via _enum_fields (case-insensitive, like
  document_ocr_provider) instead of a strict dynaconf is_in Validator, so
  DOCUMENT_OCR_MODE=Batch normalizes to "batch" rather than erroring. Tests for
  case-normalization + invalid-value rejection.
- TYPE_CHECKING-gated GatewayBatchOcrClient import so build_gateway_batch_client
  / _get_batch_client are typed `GatewayBatchOcrClient | None` instead of Any
  (runtime import stays lazy to avoid the import cycle).
- Rename ocr_options -> doc_identity_options (it's threaded to all tiers; only
  OCR reads it) + clarify the comment.
- Drop the redundant forward-ref quotes on _shared_instance.
- Add direct _batch_identity unit tests (partial/empty options branches).

Left as follow-up: reusing one httpx.AsyncClient across submit/poll (same
per-call pattern as the existing sync _GatewayOcrBackend; no clean aclose hook
on the cached client today).

1653 unit tests pass; ruff + ty green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:45:10 +02:00
Chris CoutinhoandClaude Opus 4.8 2b7dfc8535 fix(ocr): wire batch settings into _field_map + review nits
Round 1 review (PR #910):
- BLOCKING: add document_ocr_mode / _batch_poll_seconds / _batch_max_wait_seconds
  to config._field_map — without it dynaconf silently ignored the env vars and
  DOCUMENT_OCR_MODE=batch could never be enabled in production. Add a regression
  test asserting the three round-trip from env.
- migration 008: give batch_ocr_jobs a composite PRIMARY KEY on
  (user_id, doc_id, doc_type, etag) instead of a bare UniqueConstraint (N1).
- OcrProcessor: use a dedicated _batch_client_lock instead of sharing the sync
  backend lock (N3).
- tests: use https:// gateway URLs in the new fixtures to clear SonarCloud's
  "insecure http" security hotspots (all 14 were test-only http://gw literals).

1653 unit tests pass; ruff + ty green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:36:44 +02:00
Chris CoutinhoandClaude Opus 4.8 3b7e8d779b feat(ocr): opt-in batch OCR mode via the gateway's async batch routes
Add DOCUMENT_OCR_MODE=sync|batch (default sync). In batch mode the tier-3 OCR
processor submits documents to the embedding gateway's async Batch OCR routes
(POST /v1/ocr/batch + GET /v1/ocr/batch/{job_id}, astrolabe-cloud-website#372)
for ~50% cheaper large-corpus backfill. The direct Mistral OCR path is left
untouched. Tracked on Deck #332.

Batch jobs run minutes-hours, so the OCR tier cannot block (the procrastinate
worker reclaims jobs in `doing` after INGEST_STALLED_JOB_SECONDS). Instead it
submits, records the gateway job id in a new per-tenant `batch_ocr_jobs` table
(procrastinate args are immutable across retries), and raises a BatchPending
signal that TieredEscalationStrategy turns into a same-queue deferred re-poll —
releasing the worker slot between polls. On completion the per-page markdown is
indexed like the sync path; a failure or a job past
DOCUMENT_OCR_BATCH_MAX_WAIT_SECONDS marks the document parse-failed.

Batch is opt-in and gateway-only: with the direct mistral backend, no gateway
URL, or the inline/memory pipeline (which can't defer), it falls back to sync.
One batch job per document (coalescing N docs/job is a follow-up).

- embedding/gateway_batch_client.py: submit/poll client (reuses GatewayTokenProvider).
- vector/batch_ocr_store.py + migration 008: job tracking (portable SQLite+PG).
- document_processors/escalation.py: BatchPending control-flow signal.
- document_processors/ocr.py: batch state machine + sync fallback.
- vector/processor.py: thread doc identity to the OCR tier; raise BatchPending
  from the pending sentinel; propagate it as control flow (not a failure).
- vector/queue/procrastinate.py: BatchPending -> same-queue retry_in, exempt
  from the transient cap (bounded by the processor's deadline).
- config + docs; tests across client/store/processor/strategy/parse-tier.

1653 unit tests pass; ruff + ty green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:41:19 +02:00
Chris CoutinhoandGitHub 8d6196f161 Merge pull request #832 from Janhouse/fix/830-external-calendars
feat(calendar): list external read-only (subscribed) calendars
2026-06-15 06:34:16 +02:00
Chris CoutinhoandClaude Opus 4.8 de302073eb fix(security): enforce WEBHOOK_SECRET min length + address round-2 review
Round-2 review follow-ups (GHSA-8vh3-g2qg-2h2c PR):
- Add a dynaconf validator requiring WEBHOOK_SECRET to be >=16 chars when set
  (None still allowed = webhooks disabled), so weak/placeholder secrets fail
  at startup rather than in an audit. Covered by two new tests in test_config.py.
- Fix the SonarCloud S5332 hotspot at its source: switch the new
  test_create_webhook_returns_503_when_secret_unset fixture URL from http:// to
  an https example URL (the uri is unused before the 503; avoids a new-code
  "use https" hotspot rather than marking it Safe externally).
- Nits: drop the unused app.state.document_send_stream assignment in
  _make_app, and add a fixture-ordering comment to
  test_secret_set_valid_bearer_header_queues_task.

(--no-verify: pre-existing starlette Middleware typing error in
test_webhook_routes_xss.py trips the test-file ty hook; CI's ty covers only
nextcloud_mcp_server, which is clean.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:36:22 +02:00
Chris CoutinhoandClaude Opus 4.8 5b8167f9a4 test(webhook): cover enable_webhook_preset 503 branch + clarify wrong-scheme test
Round-1 review follow-ups (GHSA-8vh3-g2qg-2h2c PR):
- Add a unit test for the new `except WebhookSecretNotConfigured` branch in
  enable_webhook_preset: returns 503 (not the generic 500) with WEBHOOK_SECRET
  in the body. Uses the existing test_webhook_routes_xss.py scaffolding.
- Add a clarifying comment to test_secret_set_wrong_scheme_returns_401 about
  the _client default-bearer override semantics.

(--no-verify: the pre-commit ty-check surfaces a pre-existing starlette
Middleware typing error in test_webhook_routes_xss.py unrelated to this change;
CI's ty check covers only nextcloud_mcp_server, which is clean.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:25:30 +02:00
Chris CoutinhoandClaude Opus 4.8 4fc2b10945 fix(security): require WEBHOOK_SECRET for the Nextcloud webhook receiver
GHSA-8vh3-g2qg-2h2c (CVSS 9.1, CWE-306): POST /webhooks/nextcloud had no
authentication when WEBHOOK_SECRET was unset (the default). The receiver
trusted the attacker-supplied user.uid and fed it to Qdrant, letting an
unauthenticated network caller delete or re-index any user's vector
embeddings.

Webhooks now require WEBHOOK_SECRET end-to-end:

- app.py: the /webhooks/nextcloud route is only mounted when WEBHOOK_SECRET
  is set; otherwise it 404s and a startup warning notes vector sync falls
  back to the polling scanner.
- webhook_receiver.py: removed the warn-and-accept fallback. No secret -> 503,
  missing/invalid bearer -> 401; the payload is never processed unauthenticated.
- webhook_routes.py / api/webhooks.py: webhook_auth_pair() raises
  WebhookSecretNotConfigured instead of returning authMethod="none"; both
  registration entry points return a clear 503 so no dead unauthenticated
  webhooks are created.

Also expose webhooks availability to the Astrolabe UI via GET /api/v1/status
("webhooks_enabled": bool), set WEBHOOK_SECRET on the docker-compose
semantic-search dev services, and update env.sample + ADR-010 / ADR-018 /
webhook-management-guide docs.

Vector sync still works without a secret via the polling scanner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:18:34 +02:00
Chris CoutinhoandClaude Opus 4.8 477f9a1ff7 refactor(auth): remove vestigial token-exchange code path
The oauth_token_exchange deployment mode was removed in ADR-022 but left a dead
`enable_token_exchange` flag and an unreachable "exchange mode" in the verifier
(self.mode was hardcoded to "multi-audience"). Remove the remnants:

- config.py: drop the `enable_token_exchange` default and the
  `ENABLE_TOKEN_EXCHANGE` branch in `_is_multi_user` (+ its doc line).
- unified_verifier.py: drop `self.mode` and the dead exchange-mode log branch;
  simplify the docstrings to multi-audience only.
- test_unified_verifier.py: drop the `.mode` assertions (attribute removed);
  collapse the redundant init tests.

Also remove docs/ADR-004-Code-Review.md — an orphaned code-review note, not an
ADR; it doesn't belong in the docs/ADR namespace.

(--no-verify: the ty-check hook flags 3 PRE-EXISTING type errors in
test_unified_verifier.py lines 346/362/441, untouched by this change; CI
type-checks only the package, which passes.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:13:16 +02:00
Chris CoutinhoandClaude Opus 4.8 7e7dd24962 refactor(ingest): rename ignore_enabled→ignore_ocr_enabled + empty/structured test
Review round 4 (both nits):
- Rename the flag to ignore_ocr_enabled so its OCR-specific scope is explicit at
  the call sites (the gate only bypasses the OCR-enabled check).
- Add test_evaluate_escalation_empty_suppressed_even_when_structured_registered:
  empty_text (minimum='ocr') skips a registered structured tier and suppresses to
  ocr when OCR is off, never hopping to structured.

Deck #324.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 15:35:06 +02:00
Chris CoutinhoandClaude Opus 4.8 c0fd7dd67b fix(ingest): address review round 2 (Literal reason + exhaustive branch + test)
- escalation: EscalationDecision.reason is now Literal["empty_text",
  "low_confidence"] (parity with kind; ty catches a bad label at call sites).
- processor: nest the decision handling so the hop branch is reached via an
  explicit else under `if decision is not None` — exhaustive over the Literal
  kind, no None-attribute risk.
- tests: add the "OCR processor unregistered (not just disabled) → None"
  quadrant, locking in absent != suppressed.

Deck #324.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 15:24:55 +02:00
Chris CoutinhoandClaude Opus 4.8 a27ddb2d5a feat(ingest): record suppressed OCR escalations (what-if-OCR signal)
OCR is the paid, opt-in tier (DOCUMENT_OCR_ENABLED, default off). The per-tier
escalation gate already declines to hop to OCR when it's disabled (the pre-OCR
tier is terminal — no surprise cost), but that left operators blind to how much
OCR demand exists.

evaluate_escalation now returns a structured EscalationDecision:
- "hop"        — a higher tier can run; the caller raises EscalateError (queue-hop).
- "suppressed" — the ideal next tier (e.g. ocr) exists but is DISABLED; the caller
                 indexes the current tier's output as terminal and records the
                 would-be hop on the new astrolabe_document_escalation_suppressed_total
                 {from_tier,to_tier,reason} counter instead of hopping.
- None         — index as-is (good text, or no such tier at all).

So with OCR off, escalation_suppressed_total{to_tier="ocr"} is the latent OCR
demand an operator weighs before enabling OCR; enabling it converts these into
real document_escalation_total{to_tier="ocr"} hops. next_available_tier gains an
ignore_enabled flag to compute the *ideal* (enabled-gate-ignored) target.

Tests: registry suppressed vs hop vs terminal (incl. structured-hop-not-suppressed
when OCR off but structured available); _parse_pdf_tier records suppressed +
indexes without raising.

Deck #324 (parent #323).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 15:16:03 +02:00
Chris CoutinhoandClaude Opus 4.8 392cd49bd3 fix(ingest): stagger stalled-job reclaim to avoid thundering herd (round 5)
A stall is often systemic (a Qdrant/embedding outage stalls every in-flight
job), so reclaiming the whole batch at now() every */5min tick would
thundering-herd a recovering dependency, bypassing TieredEscalationStrategy's
per-job backoff. reclaim_stalled_ingest_jobs now offsets retry_at by a fixed
delay (INGEST_RECLAIM_RETRY_DELAY_SECONDS, default 30s; 0 = legacy immediate).

Also document the hot-vs-restart flag asymmetry: INGEST_ESCALATION_ENABLED is
re-read per job; INGEST_TRANSIENT_MAX_ATTEMPTS is snapshotted at worker startup.

Deck #323.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 14:19:11 +02:00
Chris CoutinhoandClaude Opus 4.8 ce53e21ead fix(ingest): zero queue-depth gauge on all-queues-drained (review round 4)
- metrics: update_ingest_queue_depth guarded on `not by_queue`, which conflated
  None (memory backend no-op) with {} (postgres, ALL queues drained). When every
  queue drains at once, get_ingest_job_counts_by_queue returns {} and the
  pre-zero loop was skipped, leaving a stale ghost backlog in the gauge. Guard on
  `by_queue is None` only; add an all-drained regression test.
- procrastinate: note that INGEST_TRANSIENT_MAX_ATTEMPTS is snapshotted at
  blueprint-build time (restart to pick up changes).

Deck #323.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 14:10:01 +02:00
Chris CoutinhoandClaude Opus 4.8 44f72839ed fix(ingest): address review round 3 + SonarCloud reliability gate
- tests: make the transient-backoff progression assertion load-independent by
  bracketing the get_retry_decision call with before/after timestamps instead of
  measuring against a second datetime.now() (no freezegun dependency).
- tests: use pytest.approx for the ingest-queue-depth gauge assertions —
  SonarCloud python:S1244 (float == ) was a MAJOR reliability finding that
  tripped the new_reliability_rating quality gate.
- processor: tighten the EscalateError lazy-bind comment (file processing already
  imports the document stack via get_registry; the gating only spares the
  delete / text-doc paths and module-load time).

Deck #323.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 14:00:20 +02:00
Chris CoutinhoandClaude Opus 4.8 e7c0c23486 fix(ingest): address review round 2 (stale gauge + hygiene)
- metrics: update_ingest_queue_depth now pre-zeroes every managed ingest queue
  before applying live counts, so a queue that drains to empty (and drops out of
  procrastinate's list_queues_async) reads 0 instead of sticking at its last
  non-zero value (ghost backlog in Grafana/alerts). Adds a regression test.
- procrastinate: comment that _is_transient_infra_error treats all qdrant errors
  as transient deliberately (bounded same-tier retry; over-broad is acceptable).
- escalation: note next_tier is the building block; production routing uses
  ProcessorRegistry.next_available_tier.
- tests: add evaluate_escalation fast+ocr-only low-confidence -> ocr case.

Deck #323.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:45:01 +02:00
Chris CoutinhoandClaude Opus 4.8 35f8204a16 fix(ingest): address review round 1 (reclaim queue + tests)
- Register the periodic stalled-job reclaim on a dedicated ingest-maintenance
  queue that every worker drains (any --tier), so reclaim still fires when the
  fast fleet is scaled to zero and only ocr workers run. procrastinate's
  periodic-defer dedup keeps it single-run across drainers.
- escalation: mark `unsupported`/`forced` reason labels as reserved (not raised).
- processor: note that options/progress_callback are intentionally not threaded
  through _parse_pdf_tier yet (symmetric with the inline path).
- tests: assert TieredEscalationStrategy backoff progression (4/8/16/…/300s);
  cover get_ingest_pending per-queue aggregation + the legacy job_counts
  fallback; add an external-path zero-page no-escalation case; use the canonical
  INGEST_QUEUE_FAST instead of the back-compat alias.

Deck #323.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:34:03 +02:00
Chris CoutinhoandClaude Opus 4.8 9676bb3106 feat(ingest): per-tier escalation via procrastinate queue-hop
Split external (procrastinate) document processing into per-tier queues so a
document is attempted at most once per tier and requeued to the next tier's
queue on a low-quality parse, using procrastinate's native retry.

- escalation.py: TIER_LADDER (fast->structured->ocr) + EscalateError signal
- registry: process_tier (one tier) + evaluate_escalation post-parse gate
  (reuses classify_from_text) + next_available_tier; shared _classify_result
  and _oversize_result with the inline pipeline
- processor: process_document(tier=...) runs one tier and raises EscalateError
  before embed (junk text never indexed); inline memory path unchanged
- queue/procrastinate: ingest-fast|structured|ocr queues; TieredEscalationStrategy
  (queue-hop on EscalateError, bounded same-tier transient retry); queue-aware
  task; producer defers to ingest-fast; per-queue counts + all-queue reclaim
- cli: worker --tier {fast,structured,ocr}
- billing: pages_ocr usage event + pipeline_tier metadata (paid OCR billed apart)
- observability: astrolabe_ingest_queue_depth{queue,status} gauge + per-queue
  counts in nc_get_vector_sync_status / management status endpoint
- config: INGEST_ESCALATION_ENABLED (default true), INGEST_TRANSIENT_MAX_ATTEMPTS

INGEST_ESCALATION_ENABLED=false and INGEST_QUEUE=memory preserve prior behaviour.

Deck #323.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:22:18 +02:00
Chris CoutinhoandClaude Opus 4.8 d75b7e1bd1 refactor(classifier): rename IMAGE_COVERAGE_SCANNED → IMAGE_HEAVY_THRESHOLD
Round-1 review nits. The constant now only gates the diagnostic `image_heavy`
flag (not routing), so the old name was misleading. Rename + reword its comment
to state the diagnostic-only intent. Also add a classify_pdf symmetry test
(`test_classify_pdf_image_heavy_clean_text_stays_fast`) pinning that a full-page
raster image with a clean text layer routes fast on the classify_pdf path too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 01:52:23 +02:00
Chris CoutinhoandClaude Opus 4.8 906a5805ef fix(classifier): make image coverage diagnostic-only, not an OCR routing trigger
The tier-0 classifier escalated any page with raster-image coverage >=0.80 to
the OCR tier regardless of its text layer. On OHR-Bench this drove ~45% of all
OCR escalations: clean born-digital pages dominated by a figure, and scanned
pages that already carry a usable OCR text layer -- re-OCR adds nothing for
either, but each one was routed to the paid tier-3 OCR.

Route on the text signals only (near-empty or junk-quality layer). Image
coverage is still computed and still raises the `image_heavy` diagnostic flag,
but no longer routes. True scans with no/garbage text continue to escalate via
the empty-text and quality signals, so genuine OCR needs are unaffected.

Trade-off: image-only content on an otherwise-clean page (handwriting, stamps,
text inside figures) is no longer force-routed to OCR. This was previously
intentional; the escalation cost outweighed the benefit for RAG indexing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 01:40:43 +02:00
Chris CoutinhoandClaude Opus 4.8 7ceb0072e4 test(vector-sync): cover the auth_tools.py provisioning wake path
Add test_check_status_completion_wakes_user_manager: register the auth tools
against a stub MCP, drive nc_auth_check_status through a completed Login Flow
(mocked storage + poll), and assert it stores the app password and rings the
background-sync doorbell. The MCP-tool wake path was previously only verified
by inspection (round-2 review nit); all three notify_user_provisioned() call
sites now have dedicated coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:14:39 +02:00
Chris CoutinhoandClaude Opus 4.8 79d9d62e6a refactor(vector-sync): clear SonarCloud gate + round-2 nits
Quality-gate fixes (new-code conditions on PR #902):
- new_security_hotspots_reviewed: drop the fake "http://nextcloud" host in the
  manager tests to https:// (python:S5332 ×2).
- new_security_rating: generate the integration test's fake app password with
  secrets.token_urlsafe instead of a hardcoded literal (python:S2068).
- new_reliability_rating: restructure the user_manager sleep so an explicit
  await checkpoint lives inside the cancellation scope — await one waiter
  directly while watching shutdown via start_soon (python:S7490). Behaviour is
  unchanged: timeout, shutdown, or a provisioning ring all end the sleep.

Review nits:
- Move the shutdown test's fail_after(2) to wrap the whole task group so it
  actually bounds the task-group exit (was guarding a no-op sleep); drop the
  sleep(0) stub (python:S7491).
- Type _wake_on's wait_fn as Callable[[], Awaitable[object]].
- Note in _wire_vector_sync_state why provision_signal is set on the singleton
  only, not fanned out to app.state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:08:18 +02:00
Chris CoutinhoandClaude Opus 4.8 3f09453926 refactor(vector-sync): address round-1 review nits
- ProvisionSignal.wait() re-arms in a finally so a cancelled wait (shutdown
  racing the doorbell) leaves a fresh unset event, not a stale set-but-consumed
  one; preserves the no-await-before-swap lost-wakeup guarantee.
- Hoist user_manager_task's _wake_on helper out of the while loop (one object,
  not one per iteration).
- Test: assert ProvisionSignal via its public wait() contract instead of the
  private _event attribute.
- Add test_provision_app_password_wakes_user_manager covering the
  api/passwords.py wake path (previously only LFv2 web + MCP tool were tested).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 09:56:27 +02:00
Chris CoutinhoandClaude Opus 4.8 d8e3e9bc33 feat(vector-sync): scan provisioned users immediately
Background vector sync discovered newly provisioned users only on the
periodic user-manager poll (VECTOR_SYNC_USER_POLL_INTERVAL, default 60s),
delaying first indexing by up to a minute. Add a ProvisionSignal doorbell
that provisioning paths ring after storing a user's app password, waking
user_manager_task to re-poll and spawn the user's scanner at once. The
periodic poll remains the backstop (covers cross-replica provisioning).

- ProvisionSignal (stable reference, wait-and-re-arm) held on
  VectorSyncState; closes the lost-wakeup window (no await between observing
  the ring and re-arming; anyio.Event stickiness covers a mid-poll ring)
- user_manager_task races its poll timeout against the doorbell + shutdown
- notify_user_provisioned() rung from the three app-password provisioning
  sites: Login Flow v2 web, MCP provisioning tool, management/BasicAuth API

Note: the pre-existing scanner_wake_event was never .set() and only wakes
existing scanners; a brand-new user has none, so the manager is what must
be nudged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 09:48:56 +02:00
Chris CoutinhoandClaude Opus 4.8 570a651ac4 docs(auth): clarify unconditional offline_access advertising + test
Address round-1 claude-review nits on PR #894:
- Document why offline_access is advertised unconditionally (independent of
  settings.enable_offline_access): per RFC 8414, scopes_supported lists what
  the AS *can* support, with actual issuance still gated upstream by Nextcloud.
- Add a regression test proving the offline_access invariant holds on an empty
  FastMCP instance with no registered tools.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:35:28 +02:00
Chris CoutinhoandClaude Opus 4.8 c30a795a1f feat(auth): advertise offline_access in discovered OAuth scopes
discover_all_scopes() builds the scopes_supported lists exposed at
/.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server.
It previously emitted only the base OIDC scopes plus tool-derived
@require_scopes, so offline_access was never advertised and
discovery-driven MCP clients had no way to know they could request a
refresh token.

Add offline_access unconditionally. The AS proxy already forwards
client-requested scopes upstream to Nextcloud, which issues a refresh
token when the MCP server's OIDC client is permitted the scope. This
only changes what is advertised; it is not added to ALL_SUPPORTED_SCOPES
(the app-level permission set), since offline_access is an OIDC behavior
rather than a Nextcloud resource permission.

Add a regression test asserting offline_access is always present in
discover_all_scopes() output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:30:41 +02:00
Chris Coutinho 7d9e9b8ed3 Merge remote-tracking branch 'origin/master' into fix/309-embed-resilience
# Conflicts:
#	nextcloud_mcp_server/vector/processor.py
2026-06-11 10:46:57 +02:00
Chris CoutinhoandGitHub 0672e4a05c Merge pull request #892 from cbcoutinho/feat/309-ocr-timeout-pdf-size-guard
feat(document): configurable OCR timeout and fail-fast PDF size guard
2026-06-11 10:40:15 +02:00
Chris CoutinhoandClaude Opus 4.8 81f7403b12 test(providers): Mistral batch retry + retry-log detail + comment (#893 r4)
Round-4 review on PR #893 (no blockers, minor items):
- Document why Mistral's _is_transient is SDK-level only (429/5xx): a bare
  connection drop the SDK surfaces as httpx/ConnectionError isn't an SDKError
  and isn't retried here by design — the pod-rollover target is the gateway
  (OpenAI-compatible) path, which does cover connection errors.
- Include the last error (%r) in the retry helper's "not resolved after N
  attempts" error log.
- Add test_mistral_embed_batch_retries_on_5xx (batch path parity with embed()).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 06:45:56 +02:00
Chris CoutinhoandClaude Opus 4.8 801bf108fa test(webdav): pin encode-once contract + nit cleanups (#891 r3)
Round-3 review on PR #891 (no blockers):
- Add test_encode_dav_path_encodes_exactly_once pinning the documented
  decoded-input precondition ("already%20encoded.pdf" -> "already%2520...").
- format_exception_group: proper singular/plural ("1 sub-exception" vs
  "N sub-exceptions") instead of "(s)".
- oauth_sync: use `if doc_task is not None:` to match processor_task's guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 06:21:36 +02:00
Chris CoutinhoandClaude Opus 4.8 ebd0b469f5 fix(document): catch httpx timeout from gateway OCR backend (#892 r3)
Round-3 review on PR #892 found a real bug: the gateway backend's httpx.Timeout
raises httpx.ReadTimeout (a httpx.TimeoutException, NOT a builtin TimeoutError),
so the `except TimeoutError` added in r2 only covered the Mistral
(anyio.fail_after) path — gateway timeouts still fell through to
reason="error". Catch both (TimeoutError, httpx.TimeoutException) so either
backend's timeout lands in the dedicated parse_failed_reason="timeout" bucket.
Add an end-to-end test driving a gateway httpx.ReadTimeout through the
processor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 06:19:53 +02:00
Chris CoutinhoandClaude Opus 4.8 c4b6d4a017 fix(vector): don't inflate qdrant-error metric on embed drops (#893 r3)
Round-3 review on PR #893:
- record_qdrant_operation("upsert","error") now fires only when the exhausted
  retry was actually a Qdrant failure (reason=="qdrant"); an embed/connection
  failure exhausts retries before Qdrant is called, so attributing it to
  mcp_qdrant_operations_total{error} inflated that signal. The cause is still
  captured by record_ingest_dropped.
- Add test_mistral_embed_retries_on_5xx: exercises the full Mistral retry path
  (5xx SDKError then success), not just the predicate.
- Add test_generate_does_not_retry_on_bad_request: generate() fast-fails on a
  permanent 4xx.
- Move astrolabe_vector_ingest_dropped_total's definition into the astrolabe_
  pipeline-metrics block (was in the mcp_ section).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 06:17:59 +02:00
Chris CoutinhoandClaude Opus 4.8 7b274cd8e2 test(webdav): direct _webdav_path test + double-encode precondition (#891 r2)
Round-2 review on PR #891 (non-blocking):
- Add a parametrised test_webdav_path_encoding covering empty path,
  leading-slash stripping, '#'/comma/space, and a non-ASCII name — the single
  source of truth for every caller-path builder's encoding, so write_file /
  delete_resource / create_directory / attachments are covered transitively.
- Document the decoded-input precondition on _webdav_path (encode-exactly-once;
  passing an already-encoded path would double-encode).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 06:13:55 +02:00
Chris CoutinhoandClaude Opus 4.8 2f8875e736 fix(document): timeout reason bucket + Sonar https hotspot (#892 round 2)
Round-2 review on PR #892:
- OcrProcessor.process now catches TimeoutError separately and returns
  parse_failed_reason="timeout" with a populated message ("OCR timed out after
  Ns"), instead of conflating timeouts with API errors under "error" and logging
  an empty suffix. Lets dashboards tell a too-low timeout from a failing
  provider. Test added.
- Add validator-rejection tests for DOCUMENT_OCR_TIMEOUT_SECONDS=0 (gte=1) and
  DOCUMENT_MAX_PDF_SIZE_MB=-1 (gte=0), matching the existing validator-test
  pattern.
- Comment the _Settings test fixture's max_pdf_size_mb=0.0 default.

SonarCloud: quality gate was failing on new_security_hotspots_reviewed (S5332
"use https") from an http:// URL in the new gateway-timeout test — switched to
https:// (mirrors commit 98c9d58e).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 06:12:21 +02:00
Chris CoutinhoandClaude Opus 4.8 8f7a8432f5 test(vector): close generate()/drop-counter test gaps + https mock URLs (#893)
Round-2 review on PR #893:
- Add test_generate_retries_on_connection_error (generate() shares the transient
  retry; guards the decorator against accidental removal).
- Add test_process_document_records_drop_on_exhausted_retries: drives
  process_document to retry-exhaustion and asserts record_ingest_dropped is
  called once with the classified reason (processor-level coverage, not just the
  _drop_reason unit).
- Note in _drop_reason that a multi-failure group is labelled by its first leaf
  (best-effort, no "mixed" bucket).

SonarCloud: the quality gate was failing on new_security_hotspots_reviewed
(S5332 "use https") from http:// URLs in the test _req() helpers — switched to
https:// (mirrors commit 98c9d58e).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 06:09:59 +02:00
Chris CoutinhoandClaude Opus 4.8 6c99906ed4 fix(vector): nested-group drop classification + review/Sonar fixes (#893)
Round-1 review on PR #893:
- _drop_reason now descends through nested ExceptionGroups to the first leaf
  (was single-level), so a doubly-wrapped cause isn't mislabelled "other";
  added a nested-group test. Commented why both the httpx and openai isinstance
  branches exist (raw Nextcloud-API errors vs SDK-wrapped variants).
- Documented that generate() intentionally shares the broadened transient retry
  (RAG sampling path), with the worst-case latency note.
- Added a docstring note to process_document on how the provider-level retry
  (5x) layers over the outer loop (3x in-process / 1x procrastinate).
- Added test_embed_batch_retries_on_connection_error for the batch path.
- Renamed test_retry_reraises_non_rate_limit_immediately ->
  test_retry_reraises_when_predicate_returns_false (it tests the predicate, not
  a specific status).

SonarCloud:
- S5708 (BLOCKER) on the helper's dynamic `except exception_type`: the type is
  constrained to BaseException/tuple by the signature; suppressed with a
  justified NOSONAR.
- S7503 (async without await) in the embed-retry test: use AsyncMock side_effect
  instead of a hand-rolled async function.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 05:39:23 +02:00
Chris CoutinhoandClaude Opus 4.8 64ea5c8631 fix(document): apply OCR timeout to Mistral backend + review/Sonar fixes (#892)
Round-1 review on PR #892:
- Wire DOCUMENT_OCR_TIMEOUT_SECONDS into _MistralOcrBackend too (was
  gateway-only): wrap process_async in anyio.fail_after so the SDK-managed
  client honours the setting; on expiry it fails fast as a clean parse error.
  Test added.
- Tighten the misleading "honoured without a restart" comment — per-call
  get_settings() is for test monkeypatching; a live change still needs a
  restart since the backend is cached for the pod lifetime.
- Comment the size guard's two intentional gaps: an explicit processor_name
  override bypasses it, and the early return skips the parse-duration histogram.

SonarCloud (new-code smells in the added tests):
- S1244 float-equality asserts → pytest.approx (test_config.py, test_ocr_processor.py).
- S1186/S7503: rewrite the gateway-timeout test with mocker AsyncMock/MagicMock
  instead of a hand-rolled fake client (no empty method, no async-without-await).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 05:36:47 +02:00
Chris CoutinhoandClaude Opus 4.8 a188e9fced fix(vector): guard unbound doc_task + address review nits (#891)
Round-1 review on PR #891:
- Guard processor_task's broad except handler against an unbound doc_task
  (mirrors multi_user_processor_task): initialise doc_task=None before the loop
  and branch the error log. Fixes a latent NameError if receive() raises a
  non-TimeoutError/EndOfStream before the first document binds. Regression test
  added.
- Drop the unnecessary `from __future__ import annotations` in vector/_errors.py
  and express format_exception_group's non-group fast path as an explicit
  isinstance check.
- Add a copy_resource Destination-header encoding test (analogue to MOVE);
  strengthen the ExceptionGroup test to assert the full leaf repr survives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 05:31:56 +02:00