Commit Graph
698 Commits
Author SHA1 Message Date
Chris CoutinhoandClaude Opus 4.8 98de8f331f refactor(usage): final round-6 nits on PR #871
- semantic.py: normalize both None and [] doc_types to null in the
  metadata so a future `metadata->'doc_types' IS NULL` query counts the
  all-types case consistently.
- test: use a fixed past date in test_occurred_at_roundtrip instead of a
  future literal (deterministic, no "why this date" confusion).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:52:54 +02:00
Chris CoutinhoandClaude Opus 4.8 c89f724585 refactor(usage): close out round-5 nits on PR #871
Non-blocking follow-ups from the merge-ready review:

- semantic.py: bound the doc_types copied into embeddings_queries metadata
  to _USAGE_METADATA_MAX_DOC_TYPES (16). doc_types is caller-supplied with
  no max_length on the tool signature; capping the stored copy keeps one
  JSONB row from ballooning (not a billing/injection risk — CP ignores
  metadata, binds are parameterized).
- migration: note that `metric` is intentionally unconstrained Text and
  that adding a third metric requires keeping the CP-side catalog in sync,
  else the rollup silently ignores the new rows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:47:57 +02:00
Chris CoutinhoandClaude Opus 4.8 9c2f9fac46 refactor(usage): address round-4 review on PR #871
- hooks: document why user_id in metadata is safe — it stays tenant-local
  (the CP rollup aggregates GROUP BY (day, metric) into usage_daily, which
  has no metadata column, so it never reaches Stripe) and is retained to
  keep Deck #67's future per-user attribution derivable from the app DB.
- migration: instantiate the SQLite-side column types (sa.Text() etc.) for
  visual parity with the instantiated Postgres types.
- tests: assert the WARNING contract in the unserializable-metadata test
  too; add an autouse fixture that resets UsageEventStore._shared_instance
  so a stray shared() call can't leak across tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:42:12 +02:00
Chris CoutinhoandClaude Opus 4.8 3a8ea893c6 refactor(usage): address round-3 review on PR #871
- store: guard UsageEventStore.shared() with a class-level anyio.Lock so
  two concurrent cold-start callers don't both build (and one silently
  overwrite) the cached instance — mirrors get_shared_storage(). Document
  that tests should construct the store directly to avoid singleton leak.
- migration: rename 20260610 -> 20260607 and fix Create Date to today so
  `alembic history` isn't future-dated (revision id 007 / down_revision
  006 unchanged; single head verified).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:35:21 +02:00
Chris CoutinhoandClaude Opus 4.8 2bbf4ed967 refactor(usage): address round-2 review on PR #871
- remove accidentally-committed .claude/scheduled_tasks.lock (Claude Code
  runtime artifact swept in by `git add -A`) and gitignore it; the rest
  of .claude/ stays tracked.
- store: cache UsageEventStore.shared() as a process-wide instance so the
  hot search path doesn't allocate a fresh wrapper per metered query (the
  wrapper is stateless beyond its storage handle).
- hooks: pass enabled=True directly (the outer guard already confirmed
  the flag) instead of re-reading settings.usage_metering_enabled.
- migration: document the no-TTL retention design (control-plane rollup
  owns the lifecycle; the data plane only appends).
- tests: assert the best-effort error path logs at WARNING (observability
  contract).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:28:24 +02:00
Chris CoutinhoandClaude Opus 4.8 702f66e6b1 refactor(usage): address round-1 review on PR #871
- store: add optional `enabled` param to record_usage_event so hot-path
  callers (nc_semantic_search) pass the already-resolved flag instead of
  forcing a second uncached Settings build (ADR-024); falls back to
  get_settings() when None so the store stays self-gating for standalone
  use.
- hooks: thread enabled= through both call sites; bump the outer
  shared()/construction failure log from debug → warning so "metering
  enabled but no billing data" is visible at the default INFO level.
- migration: instantiate postgresql.JSONB() to match the sibling
  TIMESTAMP(timezone=True) column.
- tests: fix the misleading "asyncpg returns JSONB as a JSON string"
  comment; add occurred_at dialect round-trip test and an enabled-param
  short-circuit test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:20:41 +02:00
Chris CoutinhoandClaude Opus 4.8 1c6b1a84ea feat(usage): record per-tenant usage events into the app DB
Deck #67 data-plane slice: tenant Pods record billable operations
(embedding queries, pages/chunks embedded) into an app-DB usage_events
table that the control plane later pulls read-only into the billing
ledger and syncs to Stripe Meter Events.

- migration 007: usage_events table (Postgres TIMESTAMPTZ/JSONB/UUID
  with portable SQLite fallbacks), indexed (occurred_at, metric) for the
  CP rollup's per-day range scan + GROUP BY metric.
- UsageEventStore: best-effort, flag-gated writer reusing the shared
  RefreshTokenStorage engine; ON CONFLICT (event_id) DO NOTHING for
  idempotent retries; dialect-branched occurred_at bind. All work
  (incl. metadata JSON encode) is swallowed so a metering failure never
  surfaces to the user op.
- USAGE_METERING_ENABLED flag (default off) wired through Settings +
  env map; off-path touches no storage, so OSS self-hosters get an empty
  table and zero write overhead.
- two recording hooks: embeddings_queries (per nc_semantic_search, which
  nc_semantic_search_answer reuses) and pages_chunks (after dense
  embedding succeeds, covering both in-process and procrastinate paths).
- storage.acquire()/.dialect public seams so the sibling store doesn't
  reach into the underscored internal.
- tests parametrized over SQLite + Postgres: flag-off no-op, roundtrip,
  ON CONFLICT dedup, JSON/NULL metadata, and the best-effort swallow of
  both DB errors and unserializable metadata.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:13:14 +02:00
Chris CoutinhoandClaude Opus 4.8 446320983a fix(vector): skip page-assignment span/warning on empty boundaries
Address claude-review round 4 on PR #868: tighten the assign_page_numbers
guard from `page_boundaries is not None` to a truthy check, so a PDF with an
empty boundary list no longer enters the trace span and fires the alarming
"NO page numbers assigned" warning for a harmless no-op.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:13:07 +02:00
Chris CoutinhoandClaude Opus 4.8 bb4ef809c2 test(vector): unit-test page-aware routing; clarify fallback comment
Address claude-review round 2 on PR #868:
- Extract the use_page_aware branching into a pure `should_use_page_aware`
  helper and cover the (doc_type, page_boundaries, page_aware_setting) matrix
  in tests/unit/test_processor_routing.py (file+boundaries+enabled, empty
  list, None, non-file doc types, disabled setting).
- Clarify the PageAwareChunker.chunk_text no-boundaries comment: the processor
  pre-filters via should_use_page_aware, so that branch is a direct-call safety
  net, not a production indexing path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:02:05 +02:00
Chris CoutinhoandClaude Opus 4.8 8d20339b3a fix(vector): route empty page_boundaries to char-based path; test ws offsets
Address claude-review round 1 on PR #868:
- use_page_aware now gates on `bool(page_boundaries)` instead of
  `is not None`, so a PDF that yields an empty boundary list takes the
  char-based path explicitly (assign_page_numbers no-ops on []) rather than
  the page-aware chunker's no-boundaries fallback. Same result, clearer intent.
- add test_oversized_page_with_leading_whitespace_offsets, exercising the
  start+start_index offset path for an oversized page whose sub-chunks have
  leading whitespace.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:56:30 +02:00
Chris CoutinhoandClaude Opus 4.8 2f2a7f9659 feat(vector): page-aware PDF chunking for predictable per-page retrieval
Add PageAwareChunker, which splits paginated documents (PDFs) on page
boundaries first and only character-splits pages larger than chunk_size.
No chunk spans a page boundary, so page_number is always exact and stored
excerpts never lead with a neighbouring page's text. When chunk_size is at
least the largest page, this yields exactly one chunk per page: a
predictable vector count (== page count), a flat per-page embedding cost,
and zero cross-page overlap duplication.

Gated by DOCUMENT_CHUNK_PAGE_AWARE (default true). When false, the legacy
char-based DocumentChunker + post-hoc assign_page_numbers path runs
unchanged. Only doc_type="file" with page_boundaries (PDFs) takes the
page-aware path; notes/deck/news are unaffected.

Measured on a 15-page record (query "leadership award louis", target =
top-half of page 15): char-based degraded the target to dense-rank 10 at
cs=2048 (OCR) and mislabeled its page; page-aware restored rank 1 across
every fusion/modality and chunk size, with correct page labels and clean
snippets.

BREAKING CHANGE: PDFs are re-chunked page-aware by default. Existing
deployments will re-index PDF content on the next vector sync (different
chunk counts and page_number labels). Set DOCUMENT_CHUNK_PAGE_AWARE=false
to retain the previous char-based behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:42:25 +02:00
Chris CoutinhoandClaude Opus 4.8 1322e5aba0 feat(vector): index files in real time on vector-index tag changes
Tagging an existing file/folder emits only OCP\SystemTag\MapperEvent — never a
Node*Event — so tagged PDFs were previously only picked up by the hourly
scanner. Subscribe to the tag event and reconcile membership so adding/removing
the `vector-index` tag (re)indexes in near-real time.

- webhook_presets: add OCP\SystemTag\MapperEvent to the files_sync preset
  (NC 32+, where MapperEvent gained getWebhookSerializable(); harmless on older
  servers — it just never fires).
- webhook_parser: parse MapperEvent (objectType=files) into a path-less file
  "reconcile" task. The payload carries only a fileid + tagIds (no name/path),
  so assign and unassign both collapse to a reconcile.
- processor._reconcile_tag_event: resolve the fileid against the user's current
  vector-index PDFs (find_files_by_tag). Present -> index with the resolved
  path/etag; absent -> flip to delete. Naturally handles "an unrelated tag
  changed" and a tagged folder's own fileid (no-op; the scanner still expands
  folders to descendants).
- Unit tests for the parser branch and the reconcile.

The matching admin-UI preset change ships separately in the astrolabe app repo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 16:20:05 +02:00
Chris CoutinhoandClaude Opus 4.8 36209e3160 docs(review): note image_heavy only fires when scan detection is on
Address PR #863 round 4: classify_from_text's docstring now states that the
image_heavy flag (and the image-coverage trigger) are only set when
image_coverage is supplied, so the flag reads zero for tenants with
DOCUMENT_OCR_DETECT_SCANNED=false -- self-documenting the metric semantics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 05:16:25 +02:00
Chris CoutinhoandClaude Opus 4.8 820be135fb fix(review): unify "scanned" flag name + log image_coverage length drift
Address PR #863 round 3:

- classify_from_text emits the "scanned" flag (was "no_text_layer") for the
  empty-text-layer case -- same name + meaning as classify_pdf, so
  astrolabe_document_classifier_flag_total isn't split across two labels for the
  same concept (and matches the metric's documented vocab).
- classify_from_text logs at DEBUG when image_coverage length != the expected
  min(pages, MAX_SAMPLED_PAGES), so a 1:1-alignment contract break (extractor
  reorders/skips pages) surfaces instead of silently misattributing coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 05:10:09 +02:00
Chris CoutinhoandClaude Opus 4.8 0287bd9175 fix(review): align classify_pdf routing with the hot path + scan-tail test
Address PR #863 round 2:

- classify_pdf now flags a page needs_ocr on the SAME three signals as
  classify_from_text (image scan OR low text-quality OR near-empty), not image
  coverage alone. Previously a word-merged digital doc with no images routed
  "fast" via classify_pdf but "ocr" via the pipeline -- so an operator
  reproducing routing offline got a different answer. They now match.
- Add a test that when image_coverage is shorter than the page boundaries (the
  MAX_SAMPLED_PAGES cap on large scans), the leading page uses the scan signal
  and later pages fall back to text-quality.

Left as-is: overlong_score (>20) partially overlaps merge_score (>12) -- the
double-penalty on very-long tokens is intentional, not a bug (per review).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 05:01:09 +02:00
Chris CoutinhoandClaude Opus 4.8 fbc9a3a675 fix(review): align quality threshold, cap + DRY scan coverage, warn on failure
Address PR #863 review:

- MIN_TEXT_QUALITY 0.45 -> 0.5 so the module/diagnostic default matches the
  DOCUMENT_OCR_MIN_TEXT_QUALITY setting (registry always passes the setting; this
  keeps classify_pdf and the test/default path on the production threshold).
- image_coverage_per_page is bounded to MAX_SAMPLED_PAGES (the image pass is the
  costly part, so a 200-page scan isn't fully rasterised on the hot path); pages
  beyond the cap fall back to the text-quality signal, and page_fraction still
  gates over every page.
- Extracted _page_image_coverage(page) helper, shared by classify_pdf and
  image_coverage_per_page (DRY + keeps the tiling-double-count note in one place).
- Scan-detection failure logs at WARNING (not DEBUG) so a systematic failure on
  an OCR-enabled tenant is visible at LOG_LEVEL=INFO.
- Add the missing DOCUMENT_OCR_MIN_PAGE_CHARS range-validator test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 04:53:19 +02:00
Chris CoutinhoandClaude Opus 4.8 b1f347b8fc feat: quality + scan OCR escalation trigger (junk-text-layer scans)
The hot-path classifier escalated to OCR purely on character count, so a
scanned/handwritten PDF with a low-quality embedded text layer (>16 chars/page
but garbled) routed `fast` and indexed the junk -- e.g. Student 147.pdf's
"Little Acoms Primary"/"0110912020", which pollutes the vector and demotes the
doc in search (Deck #207).

- classifier: recalibrate `_text_quality` with a long-token-fraction term that
  detects word-merging (dropped inter-word spaces) -- the dominant junk-layer
  failure the old whitespace/overlong(>20) terms missed. Measured: the Student
  147 scan ~0.42 (60% pages junk) vs >=0.94 for clean digital docs.
- classify_from_text now routes on quality + scan: a page is OCR-worthy if
  near-empty OR low text-quality OR (when OCR + scan detection are enabled) it's
  mostly a raster image. New `image_coverage_per_page` re-opens the PDF for the
  scan signal, so that cost is paid only by OCR-opted-in tenants. Thresholds are
  passed in from per-tenant settings (keyword-only).
- config: 4 per-tenant settings -- DOCUMENT_OCR_MIN_TEXT_QUALITY (0.5),
  DOCUMENT_OCR_PAGE_FRACTION (0.5), DOCUMENT_OCR_MIN_PAGE_CHARS (16),
  DOCUMENT_OCR_DETECT_SCANNED (true) -- with range validators.
- metrics: new astrolabe_document_ocr_page_fraction histogram (the value the
  page-fraction threshold acts on) alongside document_text_quality, so operators
  can tune the OCR escalation per tenant (quality vs cost).

Escalation gate, OCR backends, and off-by-default behavior unchanged (#858).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 04:44:15 +02:00
Chris CoutinhoandClaude Opus 4.8 9ffe0645b8 fix: close pypdfium2 page handle on error + cover classifier/OCR edge cases
Follow-up to the tiered document processor (#858), landing the round-4 review
nits the reviewer approved without:

- pypdfium2_fast: free the page handle in an outer finally so a corrupt page
  that makes get_textpage() raise can't orphan it.
- test: classify_from_text junk-text-layer path (non-zero chars, low quality,
  high ocr_frac) flags bad_text_layer -- the hot-path coverage gap.
- test: build_ocr_backend raises ValueError when the gateway M2M client_id is
  set without its token_url/secret.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 03:21:10 +02:00
Chris CoutinhoandGitHub c67b998784 Merge pull request #858 from cbcoutinho/feat/tiered-doc-processor-b2-tier1
feat: tiered PDF processor — pypdfium2 fast path (deprecate pymupdf4llm)
2026-06-05 02:43:11 +02:00
Chris CoutinhoandClaude Opus 4.8 bc957b5bc7 fix(review): _enum_fields validation, gate classify_from_text flags, OCR warnings
Address PR #858 review round 3:

- document_tier1_engine / document_ocr_provider now validate + normalize via
  Settings.__post_init__ _enum_fields (the repo's canonical opt-in-enum pattern;
  case-insensitive) instead of dynaconf Validators. A typo now raises ValueError
  at load and "Gateway" normalizes to "gateway".
- classify_from_text gates no_text_layer/bad_text_layer on ocr_frac >=
  OCR_PAGE_FRACTION, matching classify_pdf -- a "fast"-routed doc with a few junk
  pages no longer emits a misleading flag (keeps the shadow vs hot-path
  classification metrics consistent).
- build_ocr_backend warns when an EXPLICIT provider is misconfigured
  (gateway without EMBEDDING_GATEWAY_URL, mistral without MISTRAL_API_KEY)
  instead of silently returning None.
- Pypdfium2FastProcessor.health_check probes the import; documented why
  OcrProcessor.health_check is unconditionally True (lazy per-tenant backends).
- Removed the leftover per-boundary / per-chunk debug logging loops.

Tests: enum normalization + rejection for the two new settings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 02:35:26 +02:00
Chris CoutinhoandClaude Opus 4.8 f1272dfe84 fix(review): lock OCR backend init, warn on rollback fallthrough, zero-page metric
Address PR #858 review round 2:

- OcrProcessor backend resolution is now guarded by an anyio.Lock (lazy-init,
  double-checked) so a burst of concurrent first-OCR calls resolves the backend
  once instead of each fetching its own gateway M2M token.
- The document_tier1_engine=pymupdf rollback now logs a warning when it falls
  back to the fast processor (no 'structured' registered) instead of silently
  using the very engine the operator opted out of.
- classify_from_text defaults ocr_frac to 0.0 (not 1.0) for a zero-page PDF, so
  the recorded classification metric is "fast" (no OCR evidence) rather than a
  misleading "ocr"; the no_text_layer/bad_text_layer flags are gated on having
  sampled at least one page.

New tests: zero-page classify routes fast, rollback-fallback warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 02:24:08 +02:00
Chris CoutinhoandClaude Opus 4.8 1634e8adc2 fix(review): cache OCR backend, drop asserts, real pipeline_tier, guard zero-page
Address PR #858 review:

- 🔴 OcrProcessor now resolves its backend once and reuses it. Rebuilding per
  call created a fresh GatewayTokenProvider each time -- discarding its M2M-token
  cache, so every OCR'd document fetched a new token -- and a new Mistral client.
- 🔴 build_ocr_backend uses explicit ValueError (not assert, which is stripped
  under `python -O`) for the gateway M2M triple.
- PIPELINE_TIER in the Qdrant payload now reflects the tier that actually
  produced the doc: the registry stamps result.metadata["pipeline_tier"] and the
  processor reads it (was hardcoded "fast", wrong for OCR/structured).
- Escalation now requires classification.page_count > 0, so a zero-page
  (empty/corrupt) PDF isn't pointlessly sent to OCR; documented that a fast
  FAILURE (encrypted/unopenable) is a hard failure and is not OCR-escalated.
- Documented the OCR page_boundaries separator-attribution choice.
- Downgraded the per-document page-boundary / page-assignment INFO logs to debug.

New tests: zero-page no-escalation, pipeline_tier stamping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 02:13:09 +02:00
Chris CoutinhoandClaude Opus 4.8 4dbf362261 fix: OCR escalation falls back to tier-1 result when OCR can't run
OCR is an enhancement, not a gate. Previously, escalating a scanned doc to the
OCR tier returned the OCR result unconditionally -- so with DOCUMENT_OCR_ENABLED
=true but no backend configured (no gateway URL / no MISTRAL_API_KEY) the OCR
processor returned success=False and the whole document was marked failed and
skipped: strictly worse than leaving OCR off (where it would at least index the
tier-1 text).

Now the registry keeps the tier-1 fast result when the OCR escalation doesn't
succeed (no backend, API down, empty output), logging a warning. A
misconfiguration degrades gracefully instead of dropping scanned docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 01:49:52 +02:00
Chris CoutinhoandClaude Opus 4.8 3bd1b46d9c feat: tier-3 OCR processor (gateway or direct Mistral)
Adds the OCR escalation target the tiered registry already routes to. Scanned /
no-text-layer PDFs (the tier-0 "ocr" verdict) escalate here when
document_ocr_enabled (default off).

Two interchangeable backends, selected by document_ocr_provider
(auto | gateway | mistral | none):
- gateway: POST to the Astrolabe Cloud model gateway's /v1/ocr -- the same
  M2M-authenticated gateway as embeddings, so NO provider keys live in the pod
  (the platform default; reuses EMBEDDING_GATEWAY_URL + the M2M creds).
- mistral: call the Mistral OCR API directly from the pod (MISTRAL_API_KEY), for
  self-hosters / deployments without the gateway.
"auto" prefers the gateway, then direct Mistral.

Both return per-page markdown joined into text + exact page_boundaries (the
pdf_highlighter contract; bbox re-derived from the PDF bytes as for other tiers).
Validated end-to-end via direct Mistral on the scanned Student 147.pdf:
success, 15 pages, 22k chars, offsets exact, ~4s.

Settings: document_ocr_provider (enum-validated), document_ocr_model
("mistral/mistral-ocr-latest" -- gateway routes on the prefix, the direct mistral
backend strips it). OcrProcessor registered at lowest priority so it is never the
non-tiered default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 01:42:35 +02:00
Chris CoutinhoandClaude Opus 4.8 c48a797896 feat: tiered PDF processor with pypdfium2 fast path (deprecate pymupdf4llm)
Replaces single-engine pymupdf4llm extraction with a tiered pipeline (Deck #205,
follows the tier-0 classifier #855). pypdfium2 becomes the default and only
hot-path PDF extractor; pymupdf4llm is deprecated to a rollback toggle.

Why: pymupdf4llm's O(n^2) find_tables drove the OOM (#852) and the form-PDF
parse timeouts (#856), carries AGPL/commercial licensing liability, and -- per
the benchmarks -- recovers near-zero usable tables on the real corpus. pypdfium2
(Apache/BSD) extracts the same text far faster (Student 1a.pdf: 120s timeout ->
0.2s) with no table-detection bomb.

- document_processors/pypdfium2_fast.py: tier-1 "fast" processor emitting text +
  exact page_boundaries (the pdf_highlighter contract). pymupdf processor is now
  tier "structured" (the rollback engine), registered but not default.
- registry: tiered routing in ProcessorRegistry. tier-1 fast extracts, then
  classification is DERIVED from that text (classifier.classify_from_text -- no
  PDF re-open), records the classification metrics, and escalates scanned /
  no-text-layer docs to the "ocr" tier when document_ocr_enabled (default off;
  no provider yet, so fast is terminal). Wires record_document_escalation + the
  real "escalated" span attribute (was hardcoded False).
- Removes the separate _shadow_classify pass from vector/processor.py -- it
  re-opened every PDF and re-extracted text (~0.5-1.3s/doc of pure duplicated
  CPU that lowered throughput); classification now rides the tier-1 extraction.
- Settings: document_tier1_engine ("pypdfium2" default | "pymupdf" rollback,
  enum-validated), document_ocr_enabled (default false).

Tests: pypdfium2 extractor, registry tiering (fast routing, rollback, classify
recording, OCR escalation on/off), classify_from_text. Full unit suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 01:32:14 +02:00
Chris CoutinhoandClaude Opus 4.8 b7479b0d07 docs(review): correct reconcile docstring + clarify scanner rename comment
Round-2 review follow-ups (PR #857), both documentation-only:
- sharing_state.py: reconcile_document_path docstring no longer claims it
  returns False when no real points exist — it returns True and the set_payload
  is a Qdrant-side no-op (callers discard the return value).
- scanner.py: reword the rename-reconcile comment to state the precise reason
  (modified_at stable so not re-queued; path may be stale from a rename) rather
  than the loose "dedup miss / etag changed" phrasing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 01:23:13 +02:00
Chris CoutinhoandClaude Opus 4.8 a03bc0af66 fix(review): guard placeholder in scanner reconcile + test dual-write path
Round-1 review follow-ups (PR #857):
- scanner.py: skip the rename-reconcile when the existing metadata point is a
  placeholder. reconcile_document_path only touches real chunks, so a not-yet-
  indexed file would just incur a 0-point set_payload; the real index writes the
  current path anyway.
- test_sharing_state.py: add a dedup-hit case where the file was renamed AND the
  user is new to the ACL, asserting both set_payload writes fire (file_path/title
  and acl_principals).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 01:18:58 +02:00
Chris CoutinhoandClaude Opus 4.8 bded41de5d feat: use Nextcloud filename for indexed file title + reconcile on rename
The vector-sync pipeline derived an indexed file's display title from the
document's embedded metadata (e.g. a PDF's /Title), falling back to the
filename only when absent. That embedded title frequently disagrees with how
the user named the file in Nextcloud and is confusing in the astrolabe
vector-viz UI (a passive consumer of the `title` payload field).

For files, always derive the title from the Nextcloud filename via a shared
`file_title_from_path` helper. Notes/deck/news keep their metadata titles.

A rename/move in Nextcloud keeps the fileid (doc_id) and content (etag/mtime)
but changes the path, so both the dedup claim and the scanner freshness gate
skip re-embedding and the stored file_path/title go stale. Add
`reconcile_document_path`: a metadata-only set_payload that refreshes
file_path + title on the existing real chunks without re-fetch/re-embed.
Wire it into both skip paths:
  - dedup hit (etag unchanged on rename) via claim_existing_index(current_path=...)
  - scanner incremental skip (etag changed, mtime stable)
Both reuse already-fetched payloads, so steady-state scans add no extra
round-trip (reconcile is a no-op when the path is unchanged).

Refs: Deck #204

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 01:16:45 +02:00
Chris CoutinhoandClaude Opus 4.8 4bdb0bc6d6 fix(review): warn (not debug) on shadow-classify failure; tidy pymupdf usage
Address PR #855 round 2:

- 🔴 _shadow_classify swallowed all exceptions at DEBUG, so a systematic
  failure (pymupdf bug, memory pressure) is invisible at LOG_LEVEL=INFO and
  trips SonarQube S2221/S5754. Log at WARNING instead (still best-effort --
  indexing is unaffected).
- classifier: use `with pymupdf.open(...) as doc` instead of manual try/finally.
- tests: release the Pixmap's native memory (del pix) in the image fixtures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 00:22:17 +02:00
Chris CoutinhoandClaude Opus 4.8 0347e96679 fix(review): sample last page, document flags-vs-routing, add flag-path tests
Address PR #855 review (all non-blocking):

- classifier: _sample_indices now always includes the first AND last page (the
  old evenly-spaced sample missed the tail, e.g. last sampled index 95 on a
  100-page doc -- a scanned tail could be missed).
- classifier + metrics: document that flags are diagnostic and fire
  independently of routing (image_heavy on ANY page vs the ocr route needing a
  page FRACTION), so flag{image_heavy} is expected to exceed classified{ocr}.
- classifier: clarify the text-quality whitespace comment (caps at 12%) and note
  the image double-count approximation (min() caps coverage).
- tests: add the scanned (no text layer) and bad_text_layer (junk text over an
  image) flag paths, and a test pinning first/last-page sampling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 00:12:26 +02:00
Chris CoutinhoandClaude Opus 4.8 044c1da750 feat: tier-0 document classifier in shadow mode
First step of the tiered document-processor effort (Deck #203): a cheap, local
pre-pass that recommends which extraction tier a PDF should start in, emitting
metrics WITHOUT changing routing yet -- so we gather per-tenant doc-mix data
before turning escalation on.

document_processors/classifier.py: classify_pdf(content) -> DocClassification.
Page-sampled (bounded on large docs), <~1s. Cheap signals only -- text-layer
chars, a text-quality score (catches the "Student 147" failure where a text
layer exists but is mashed/space-less junk), and image coverage. A page that is
mostly a raster image routes to OCR: its content (handwriting, stamps) isn't in
any text layer. Deliberately no get_drawings/graphics-density signal -- it's
slow on the exact pages it'd flag, the hotfix's graphics_limit already makes the
parse safe, and the (future) tier-1 quality gate catches lost tables.

Validated on the sample corpus: born-digital 2-col arxiv and a digital student
record -> fast (tier 1); a scanned+handwritten form -> ocr (tier 3).

Wiring (vector/processor.py): _shadow_classify runs the classifier on PDFs in a
worker thread, best-effort (never blocks/fails indexing), gated by the new
DOCUMENT_CLASSIFY_ENABLED setting. Metrics: astrolabe_document_classified_total
{recommended_tier}, astrolabe_document_classifier_flag_total{flag},
astrolabe_document_text_quality histogram.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 00:12:25 +02:00
Chris CoutinhoandClaude Opus 4.8 77a36fa821 fix: lower DOCUMENT_PDF_GRAPHICS_LIMIT default 5000 -> 1000
The OOM hotfix (#852) set graphics_limit=5000, which caught the 955k-drawing
bomb but let a second pathology through: form/table PDFs (e.g. student records)
have ~1.5k grid-line vector drawings per page -- under 5000, so uncapped. With
those pages uncapped, pymupdf4llm's O(n^2) find_tables grinds ~17s/page, so a
7-page form hits the 120s timeout. All 6 current backfill parse failures in
tenant-blackbox-demo are this exact timeout (zero OOM, zero error).

Measured on a 7-page sample: graphics_limit=2000 -> 119s (timeout), 1000 -> 2.9s
-- with identical extracted text and ZERO recovered tables either way (the
expensive analysis produces nothing useful on these dense forms). Lowering the
default to 1000 makes them index in ~3s; the bomb file (955k >> 1000) stays
capped, and pages with genuine simple tables (<1000 drawings) still get table
detection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 00:06:40 +02:00
Chris CoutinhoandClaude Opus 4.8 93f0f4f881 fix(review): type timeout as float; document worker reuse + identity check
Address PR #852 round 3 (all 🟡, no blockers):

- config: DOCUMENT_PARSE_TIMEOUT_SECONDS is now float (default 120.0) so a
  fractional value is honoured rather than silently stored in an int field;
  matches anyio.move_on_after's float seconds.
- _isolation: comment that a clean rlimit MemoryError leaves the worker alive
  in anyio's pool (vs the SIGKILL/BrokenWorkerProcess path that respawns) --
  acceptable since RLIMIT_AS caps virtual address space, not RSS.
- processor: note the `if indexed is False` is a deliberate identity check --
  a successful index (incl. dedup hit) returns None and must not be mistaken
  for a parse failure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:32:35 +02:00
Chris CoutinhoandClaude Opus 4.8 6589e8e7fc fix(review): require graphics_limit>=1, type _index_document, cover rlimit branch
Address PR #852 round 2:

- config: DOCUMENT_PDF_GRAPHICS_LIMIT validator is now gte=1 (pymupdf4llm treats
  0 as "no cap", which would re-expose the OOM); documented the zero semantics
  and that the per-worker mem rlimit needs a pod restart to change.
- processor: annotate `_index_document -> bool | None` and document the contract
  so the `if indexed is False` check is explicit/type-checkable.
- tests: add the RLIM_INFINITY-hard branch assertion for _apply_mem_limit
  (soft==target, hard stays unbounded).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:32:34 +02:00
Chris CoutinhoandClaude Opus 4.8 7ec116a3c7 fix(review): close doc via try/finally; don't count parse failures as indexed
Address PR #852 review:

- pymupdf.py: the metadata `doc` was only closed on the PdfParseFailed and
  success paths, so a failure in `_extract_metadata`/`mkdir`/`get_settings`
  leaked it. `doc` is only needed for metadata + page_count (the heavy parse
  works from `content` bytes in the worker), so open it, read metadata, and
  close it immediately under try/finally; drop the two later doc.close() calls.

- processor.py: a permanent parse failure early-returned from `_index_document`,
  after which `process_document` still recorded record_qdrant_operation("upsert",
  "success") + record_vector_sync_processing(success) -- counting an OOM/timeout
  bomb as astrolabe_documents_indexed_total{status="success"}. `_index_document`
  now returns False on that path and the caller skips the success metrics (the
  failure is already recorded via document_parse_failed_total + the registry's
  document_parse_total{error}).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:32:34 +02:00
Chris CoutinhoandClaude Opus 4.8 7db8d3e301 fix: isolate PDF parse in a subprocess so a bad file can't OOM the pod
The document processor crash-looped on one pathological PDF: pymupdf4llm's
table/graphics detection over a page with ~1M vector path items ballooned past
the 2 GiB pod limit. The parse ran in a thread, so nothing could interrupt or
memory-bound it -- a single bad file OOM-killed the whole pod.

Run the parse in an isolated worker subprocess (anyio.to_process, cancellable)
with an RLIMIT_AS memory cap and a wall-clock timeout, so a pathological file
fails THAT document instead of the pod (new document_processors/_isolation.py).
Also pass graphics_limit (default 5000) to to_markdown -- validated to cut the
known trigger page from 112 s to 23 s with bounded memory.

On a permanent parse failure the processor returns success=False (instead of
raising, which would retry 3x); vector/processor.py marks the placeholder
"failed" and skips indexing, and the scanner stops re-queuing failed placeholders
until the file changes -- so a doomed file no longer churns.

New per-tenant (per-pod env) settings: DOCUMENT_PDF_GRAPHICS_LIMIT,
DOCUMENT_PARSE_TIMEOUT_SECONDS, DOCUMENT_PARSE_MEM_LIMIT_MB. New metric
astrolabe_document_parse_failed_total{reason=timeout|oom|error} surfaces hard
failures that previously killed the process before any except ran.

First PR of the tiered document-processor effort (Deck #199); tier 0/1/3
pipeline tracked separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:32:34 +02:00
Chris CoutinhoandClaude Opus 4.8 1f8b3ba95e fix: resolve startup NameError in vector-sync metrics task
The Starlette lifespan started `vector_sync_metrics_task` with undefined
names `task_producer` and `receive_stream`. Those locals only exist inside
the `_wire_vector_sync_state` helper; in the lifespan the transport is bound
as `ingest_transport`. The undefined reference raised `NameError`, which
aborted the background-sync task group and crashed startup in every
deployment mode ("Application startup failed. Exiting.").

Introduced by fbe70ecd ("feat: backend-agnostic vector-sync gauges").

Pass `ingest_transport.producer` / `ingest_transport.receive_stream` at both
call sites (single-user app.py:1791, OAuth/login-flow app.py:2012).

Also fix 10 pre-existing `ty` possibly-missing-attribute diagnostics: the
deck indexing code in scanner.py, processor.py and search/context.py reads
full-DeckCard-only fields (description, type, owner, etag, lastModified) off
`stack.cards`, typed `list[DeckCard | DeckCardSummary]`. Freshly-fetched
stacks from `get_stacks()` always hold full DeckCards (the summary
projection only happens in the tool layer), so narrow with
`cast(list[DeckCard], ...)` — matching the existing pattern in
server/deck.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:10:40 +02:00
Chris CoutinhoandGitHub d95ed7be68 Merge pull request #850 from cbcoutinho/feat/ingest-pending-documents-metric
feat: backend-agnostic vector-sync gauges (pending / documents / chunks)
2026-06-04 21:27:18 +02:00
Chris CoutinhoandClaude Opus 4.8 bf84db35b4 refactor: address PR #851 review round 5 (ingest transport)
- _clear_vector_sync_state also nulls shutdown_event / scanner_wake_event on
  shutdown, symmetric with the stream/producer fields (the next startup rebinds
  them via _wire_vector_sync_state).
- Comment that the "DocumentTask" string subscript in LocalTransport is
  intentional (TYPE_CHECKING-only class; anyio ignores the runtime type arg).
- Move app.py's annotation-only IngestTransport / TaskProducer imports under
  TYPE_CHECKING (the module uses `from __future__ import annotations`), keeping
  only build_transport at runtime.

Refs: Deck #196

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 20:49:17 +02:00
Chris CoutinhoandClaude Opus 4.8 c4401af9c6 refactor: address PR #851 review round 4 (ingest transport)
- Clear the module-singleton ingest references (task_producer,
  document_send_stream, document_receive_stream) on lifespan shutdown via a new
  _clear_vector_sync_state() helper, mirroring the eviction_task_group cleanup.
  Defense-in-depth so a late webhook (or a module-singleton integration test)
  can't touch a producer/stream backed by an already-closed resource.
- Add IngestTransport.backend_name ("memory"/"postgres") and use it in both
  lifespan log lines, removing the last settings.ingest_queue read from the
  background-sync setup — the lifespan no longer inspects the backend at all.
- Cover backend_name in the build_transport adapter-selection tests.

Refs: Deck #196

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 20:42:57 +02:00
Chris CoutinhoandClaude Opus 4.8 c0c52c1b34 refactor: address PR #851 review round 3 (ingest transport)
- LocalTransport.run_consumers increments active_consumer_count per worker
  (instead of once after the loop) so the count is accurate if a later
  tg.start() raises mid-pool.
- Add LocalTransport.aclose() to explicitly close its owned send/receive stream
  ends (belt-and-suspenders against unclosed-resource warnings; anyio aclose is
  idempotent, and by shutdown the scanner is already winding down). Reworded the
  base IngestTransport.aclose() docstring to point at the overrides.
- Inline ingest_transport.producer at the scanner/user_manager call sites,
  dropping the single-use task_producer alias in both lifespan paths.
- Annotate DistributedTransport._producer explicitly as ProcrastinateTaskProducer
  so the drain() coupling is visible and ty catches drift.
- Add a unit test for LocalTransport.aclose() (closes the owned streams,
  idempotent).

Refs: Deck #196

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 20:36:58 +02:00
Chris CoutinhoandClaude Opus 4.8 e4d81d47d9 feat: harmonize MCP tool + userinfo page to documents/chunks model
Extend the documents-vs-chunks split to the remaining status surfaces so all
three report consistently (Deck #195):

- nc_get_vector_sync_status MCP tool + VectorSyncStatusResponse: add
  indexed_documents (distinct) and indexed_chunks; keep indexed_count as a
  deprecated alias of indexed_chunks. Reuses count_indexed.
- userinfo HTML page (/app/vector-sync/status): show Indexed Documents AND
  Indexed Chunks rows; switch its count to count_indexed (which also excludes
  placeholder points — the old raw count included them).
- /api/v1/vector-sync/status: restore indexed_count as a deprecated alias of
  indexed_chunks so existing consumers (integration tests, pre-#115 UI) keep
  working; the change is now purely additive for indexed_count.

Tests: VectorSyncStatusResponse documents/chunks/alias + zeroed defaults.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 20:28:44 +02:00
Chris CoutinhoandClaude Opus 4.8 2179e9ddb0 refactor: address PR #851 review round 2 (ingest transport)
- Rename the lifespan-local `transport` to `ingest_transport` in both paths so it
  no longer shadows the get_app(transport=...) HTTP-transport parameter.
- Log the memory backend selection in build_transport, symmetric with the
  postgres branch, so startup logs name the chosen ingest backend either way.
- Note in _wire_vector_sync_state why eviction_task_group is intentionally not
  set there (it only exists once the lifespan's task group is running).

Refs: Deck #196

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 20:27:40 +02:00
Chris CoutinhoandClaude Opus 4.8 655d608fb7 refactor: address PR #851 review round 1 (ingest transport)
- Add IngestTransport.active_consumer_count (0 by default; LocalTransport stores
  the started count) so app.py logs the worker count without re-checking
  INGEST_QUEUE — the last backend-knowledge leak in the lifespan is gone.
- Document that DistributedTransport is postgres/procrastinate-specific by design
  (aclose() calls ProcrastinateTaskProducer.drain()); other distributed backends
  would be separate IngestTransport subclasses.
- Clarify the _wire_vector_sync_state log line (writes app.state + singleton, not
  only the singleton).
- Strengthen the LocalTransport test: assert active_consumer_count transitions
  0→N and that each worker receives a distinct cloned receive stream.

Refs: Deck #196

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 20:07:19 +02:00
Chris CoutinhoandClaude Opus 4.8 8c9f97d6c4 docs: clarify postgres-mode None + test exact=True default (review #850)
- Note at both metrics-task call sites that receive_stream is None in postgres
  mode (get_ingest_pending falls back to procrastinate counts).
- Add test_default_is_exact_true covering the status-endpoint count path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:47:29 +02:00
Chris CoutinhoandClaude Opus 4.8 e7bcdb1950 feat: add IngestTransport port for local/distributed ingest backends
Finish the hexagonal ports-&-adapters split started in #183. The producer side
already had a TaskProducer port + adapters, but the consumer side was
unabstracted and the INGEST_QUEUE selection leaked into a duplicated
`if use_postgres:` branch across both app.py lifespan paths.

Introduce an IngestTransport ABC (vector/queue/transport.py) that bundles the
producer with running (or not running) the in-process consumer pool, built by a
single build_transport() factory:

- LocalTransport (INGEST_QUEUE=memory): in-process anyio stream drained by an
  N-worker pool that run_consumers starts.
- DistributedTransport (INGEST_QUEUE=postgres): wraps ProcrastinateTaskProducer;
  run_consumers is a no-op because the consumer is the external `worker` role.

Both lifespan paths now call build_transport + _wire_vector_sync_state (new
helper that centralizes the app.state / module-singleton / browser-app writes) +
transport.run_consumers + transport.aclose(), with no INGEST_QUEUE branching and
no getattr drain probe. Adding a future backend (Redis/NATS/SQS) is one new
adapter + one build_transport arm, with no app.py or scanner change.

Preserves the single-tenant parallelism invariant (one shared multiplexed queue
+ N-worker pool, per-document not per-user dispatch) and documents it in
ADR-028. The worker CLI is unchanged (it is the external consumer).

Refs: Deck #196 (Deck #197 tracks the explicit parallelism regression test)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:43:31 +02:00
Chris CoutinhoandClaude Opus 4.8 fc3e0f28f6 fix: add metrics-interval validator + type/test gaps (review #850)
- Add Validator("VECTOR_SYNC_METRICS_REFRESH_INTERVAL", gte=1) so a 0/negative
  value can't turn the publish loop into a busy-spin.
- Annotate count_indexed's qdrant_client param as AsyncQdrantClient.
- Add tests: exact kwarg is forwarded to qdrant count, and the placeholder
  filter matches False (excludes placeholders) with chunk_index pinned to 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:39:46 +02:00
Chris CoutinhoandClaude Opus 4.8 fbe70ecd9c feat: backend-agnostic vector-sync gauges (pending/documents/chunks)
The only queue metric, mcp_vector_sync_queue_size, was updated inline by the
single-user consumer (processor_task) but never by the multi-user consumer
(oauth_processor_task). On multi-user tenants (e.g. blackbox-demo, 5 users) the
gauge read 0 for 24h while the live anyio buffer held ~2214 pending documents
(shown by /api/v1/vector-sync/status). The "indexed" figure was also a chunk
count (16039 points ≈ 480 docs) mislabelled as documents.

Publish a consumer-independent snapshot from a periodic task
(vector/metrics_publisher.vector_sync_metrics_task), spawned in BOTH lifespan
task groups (single-user and multi-user) and every queue backend:
- mcp_vector_sync_pending_documents — outstanding work via
  ingest_status.get_ingest_pending() (anyio buffer depth or procrastinate
  todo+doing); also keeps the legacy queue_size gauge meaningful on all paths.
- mcp_vector_sync_indexed_documents — distinct documents, counted exactly and
  cheaply via the one chunk_index=0 point per document (no facet).
- mcp_vector_sync_indexed_chunks — total non-placeholder points.

The /api/v1/vector-sync/status endpoint now returns indexed_documents (distinct
docs) AND indexed_chunks separately, so documents and chunks are no longer
conflated. The publisher uses approximate Qdrant counts (every-N-seconds gauge);
the on-demand endpoint counts exactly. New knob:
VECTOR_SYNC_METRICS_REFRESH_INTERVAL (default 20s). Fail-safe: a metrics refresh
never disturbs ingest.

BREAKING CHANGE: /api/v1/vector-sync/status field `indexed_documents` now holds
the distinct-document count (was the chunk count); the chunk count moved to the
new `indexed_chunks` field. The Astrolabe UI + the nc_get_vector_sync_status MCP
tool / userinfo page are harmonized in a follow-up (Deck #195).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:30:10 +02:00
Chris CoutinhoandGitHub a7d043e43c Merge pull request #848 from cbcoutinho/feat/vector-sync-cross-user-dedup
feat: dedup shared-file parsing/embedding across users in vector sync
2026-06-04 17:59:31 +02:00
Chris CoutinhoandClaude Opus 4.8 9452057570 fix(webdav): harden offset/key truthiness and escape SEARCH mime type
Optional review hardening on #849 (non-blocking nits from the approve):

- `_build_search_xml`: emit `<d:firstresult>` on `offset is not None` rather
  than truthiness, so a future explicit offset=0 isn't silently dropped.
- `_key`: key on `file_id is not None` so a (hypothetical) file_id of 0 isn't
  treated as absent and mis-keyed onto path.
- `_type_search_args`: XML-escape the MIME type before interpolating it into
  the SEARCH literal (defense-in-depth for any future user-supplied value),
  with a unit test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:19:30 +02:00