Commit Graph
590 Commits
Author SHA1 Message Date
Chris CoutinhoandClaude Opus 4.8 a3178cf1fa refactor(metering): harden page_count guard per review
Round-1 review follow-ups (PR #879):
- Gate pages_embedded on `page_count and page_count > 0` so a malformed
  negative count meters as "no pages" rather than emitting a negative
  billing row (matches the documented call-site intent).
- Exclude bool at the call-site narrowing (`isinstance(int) and not
  isinstance(bool)`) — bool is an int subclass, so a stray page_count=True
  would otherwise record pages=1.
- Document chunk_count's role (empty-batch no-op guard) and the
  intentional tokens-before-pages ordering in the docstring/comments.
- Add test_negative_pages_skips_pages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:03:46 +02:00
Chris CoutinhoandClaude Opus 4.8 9c89a58a07 feat(metering): pages_embedded = real parsed page count
`pages_embedded` carried an interim chunk count (`len(chunk_texts)`,
TODO #282). Reframe it as a charge for *parsing* (PDF page extraction /
OCR) rather than a normalized content size:

- Parsed files (PDFs) record `pages_embedded` = real `page_count` from
  the document processor metadata.
- Text content (notes, deck cards, news items) is never parsed, carries
  no `page_count`, and records no `pages_embedded` row — only
  `tokens_embedded`. There is deliberately no chars/tokens-per-page
  constant; pages map 1:1 to parsed document pages.

`record_indexing_usage` now takes `page_count` and records the two
dimensions independently, gating `pages_embedded` on a truthy page count
(not the doc_type) so a future non-PDF parsed type stays correct. Stays
flag-gated + best-effort. Tests cover parsed-file, text-only, and
zero-page cases.

Deck #282 (board 8). Billing-model ADR corrected in
astrolabe-cloud-website docs/control-plane/usage-metering.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:58:31 +02:00
Chris CoutinhoandGitHub 412d77f437 Merge pull request #878 from cbcoutinho/fix/windows-resource-import-877
fix(documents): guard Unix-only resource import for Windows (#877)
2026-06-08 16:22:42 +02:00
Chris CoutinhoandGitHub 8782c60302 Merge pull request #875 from cbcoutinho/feat/meter-embedding-tokens
feat(usage): meter embedding tokens (tokens_embedded/pages_embedded) on both paths + Prometheus export
2026-06-08 15:18:36 +02:00
Chris CoutinhoandClaude Opus 4.8 62274069de refactor(documents): fully decouple document stack from server startup; Windows-safe tests
Addresses round-1 review on #878:

- Move the eager `document_processors` imports out of the API startup graph:
  `app.py` (get_registry now imported inside initialize_document_processors,
  after the disabled early-return) and `vector/processor.py` (get_registry now
  imported at its single use site). Importing `app` + `cli` no longer loads
  `document_processors` / `_isolation` at all -- the #877 stack is fully out of
  startup (pymupdf still loads via search/pdf_highlighter, a Windows-compatible
  and separately-tracked concern).
- Make `tests/unit/test_pdf_parse_isolation.py` importable on Windows: guard the
  top-level `import resource` with try/except and skip the three rlimit
  computation tests via a `requires_resource` marker when the module is absent.
  The Windows no-op / import-guard tests don't use the real module and still run.
- Fix the `# pragma: no cover` comment on the win32 branch to be accurate.
- Add `enable-cache: true` to the package-smoke setup-uv step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:11:18 +02:00
Chris CoutinhoandClaude Opus 4.8 fc8a4e4dfa fix(documents): guard Unix-only resource import for Windows (#877)
`document_processors/_isolation.py` did an unconditional module-level
`import resource`, a POSIX-only stdlib module absent on Windows. It was
pulled into the API startup path via
`server/webdav.py -> utils/document_parser -> document_processors`, so
the MCP server failed to start on Windows since 0.101.2 with
`ModuleNotFoundError: No module named 'resource'`.

- Guard the import behind `sys.platform`; bind `resource = None` on
  win32. `_apply_mem_limit()` degrades to a logged no-op when the module
  is unavailable (the RLIMIT_AS cap is a Linux-pod safety measure, not a
  correctness requirement).
- Make the document-parser import in `server/webdav.py` lazy so server
  startup never loads the ingest document stack
  (document_processors -> pymupdf -> _isolation) at all -- it is only
  needed when a file is actually read and parsed. This both fixes #877
  and decouples the API layer from ingest-only deps.
- Add unit regressions for the no-op path and the win32 import guard.
- Add a cross-platform `package-smoke` CI job (ubuntu + windows) that
  installs the package isolated and runs the CLI, exercising the
  cli -> server -> webdav import chain that crashed in #877.

Fixes #877

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 14:59:03 +02:00
Chris CoutinhoandClaude Opus 4.8 973f80e7b9 feat(usage): rename metrics → tokens_embedded/pages_embedded + export token cost to Prometheus
Billing product model finalized (Deck #281): bill pages externally, record
tokens internally. Rename the data-plane metric literals to match the now-
canonical contract (Deck #284) — the control plane's METRIC_EVENT_NAMES is
already renamed, so the old names would be unmapped and never sync to Stripe.

Rename (values unchanged):
- embeddings_queries → tokens_embedded (value = real token count, already
  emitted by this PR; the unit upstream providers bill on).
- pages_chunks → pages_embedded (value kept as len(chunk_texts) interim;
  TODO(#282): real normalized "pages indexed" count — real pages for paginated
  types, chars/tokens-per-page constant otherwise — is deferred to the
  instrumentation card, this only lands the name/contract).
- All literals, log strings, docstrings, comments, the migration comment, and
  tests renamed; grep confirms zero old strings remain.

Observability (new): export embedding token cost to Prometheus as
astrolabe_embedding_tokens_total{provider,operation} (operation = index|query)
so the billed cost unit is visible in Grafana, not just the per-tenant billing
DB. Dedicated counter (doesn't inflate the existing chunk/request metrics) and
always-on (independent of USAGE_METERING_ENABLED, so OSS/self-host gets it).
Wired on both the indexing batch embed and the search query embed (query inside
the per-request cache-miss branch, so reused embeddings aren't double-counted).

Note: the rename orphans any pre-existing embeddings_queries/pages_chunks rows
in tenant app DBs (CP no longer maps them) — acceptable; pipeline is inert with
throwaway dev/sandbox data.

Deck #284 (folded into PR #875).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 13:17:53 +02:00
Chris CoutinhoandClaude Opus 4.8 ddefb03701 test(usage): mark new gateway usage tests with @pytest.mark.unit (round 6)
Round-6 claude-review (ready to merge): the three new GatewayProvider
usage/bearer tests lacked @pytest.mark.unit, so `pytest -m unit` skipped them
even though every other new test in this PR is marked. Add the marker to the
three new tests (leaving the pre-existing unmarked tests in the file alone).

Remaining 🟢 items (OpenAI embed() dual path, recursion-invariant runtime
enforcement, Bedrock sync-in-async) are acknowledged deferrals — separate
refactors, unchanged.

Deck #67.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 02:00:24 +02:00
Chris CoutinhoandClaude Opus 4.8 141663bb07 test(usage): close round-5 nits (empty doc_types, consistency tidy-ups)
Round-5 claude-review (merge-ready; all nits):

- 🟡 Added test_empty_doc_types_normalizes_to_null pinning doc_types=[] → None
  in record_search_usage metadata (matches the None case).
- 🟡 record_search_usage docstring now notes nc_semantic_search_answer always
  meters with doc_types=None (it exposes no doc_types parameter).
- 🟢 BM25HybridSearchAlgorithm.__init__ now sets query_embedding /
  query_token_count alongside _embedded_query, so all three cache fields are
  instance attributes from construction (was relying on the class-level
  SearchAlgorithm defaults).
- 🟢 Ollama embed_batch_with_usage caches _dimension inline (mirrors
  OpenAI/Mistral), so the dimension is set via any embed path.
- 🟢 record_indexing_usage documents the independent-record / partial-failure
  semantics under SUM aggregation.

Deck #67.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 01:53:26 +02:00
Chris CoutinhoandClaude Opus 4.8 746abba18c fix(contacts): address PR #876 round-2 nits
- Drop the redundant `.rstrip("/")` in `_list_object_names`; the
  `endswith("/")` guard already excludes the collection entry.
- Remove the now-unused `_get_raw_vcard` (update_contact resolves the name
  itself and calls `_fetch_raw_vcard` directly). Its only remaining caller —
  the create→read integration test — now calls `_fetch_raw_vcard` with the
  deterministic `<uid>.vcf`, saving a redundant PROPFIND.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 01:52:54 +02:00
Chris CoutinhoandClaude Opus 4.8 011356cca2 fix(contacts): address PR #876 round-1 review
- Use str.removesuffix(".vcf") instead of str.replace(".vcf", "") in both
  _resolve_object_name and list_contacts so a filename like "alice.vcf.backup"
  isn't mangled; the two transforms stay consistent to preserve the
  surface-then-resolve round-trip.
- Add update_contact resolution tests mirroring the delete coverage:
  targets the real no-extension path, and falls back to <uid>.vcf when
  resolution finds nothing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 01:47:46 +02:00
Chris CoutinhoandClaude Opus 4.8 df03d33fd4 test(usage): cover search metering hook; log dedup metering skip (round 4)
Round-4 claude-review findings (no blockers):

- 🟡 Untested server-layer metering hook (raised across rounds): extracted the
  nc_semantic_search embeddings_queries recording into a module-level
  record_search_usage() helper (mirroring record_indexing_usage) and added
  tests/unit/server/test_semantic_metering.py — value = query token count,
  flag-off no-op, None token → 0, doc_types metadata bounding, best-effort
  failure swallowed.
- 🟡 Dedup-hit skipped metering invisibly: the existing dedup info log now
  states "no embedding/usage recorded" so a "fewer embeddings_queries rows than
  expected" audit lands on the dedup path directly.

Deferred 🟢 nits (stated on the PR): search 0-token rows are recorded
deliberately (the query embedding ran; zero is a sum no-op) — documented in the
helper; embed_tokens closure locality and the OpenAI embed() dual path are
unchanged (correct as-is / separate refactor).

Deck #67.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 01:43:49 +02:00
Chris CoutinhoandClaude Opus 4.8 9ac9e1ab09 fix(usage): drop redundant GatewayProvider.embed_batch override (round 3)
Round-3 claude-review finding:

- 🟡 Double _ensure_bearer() on gateway.embed_batch(). Round 1 made
  OpenAIProvider.embed_batch() delegate to embed_batch_with_usage(); because
  GatewayProvider overrode both embed_batch() and embed_batch_with_usage() (each
  calling _ensure_bearer), gateway.embed_batch() refreshed the bearer twice
  (the second a cache-hit no-op). Remove the now-redundant embed_batch()
  override: OpenAI's embed_batch() routes through embed_batch_with_usage(),
  which the gateway still overrides, so the bearer refreshes exactly once on
  every path. The remaining two overrides (embed + embed_batch_with_usage) cover
  all four entrypoints; documented the topology.

- 🟢 Added test_gateway_embed_batch_ensures_bearer_once locking in the single
  refresh.

Cohere token-fallback (🟢 nit) is already covered by
test_bedrock_with_usage_estimates_when_token_count_absent.

Deck #67.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 01:29:36 +02:00
Chris CoutinhoandClaude Opus 4.8 d15ce627ab refactor(usage): extract indexing metering helper; address review round 2
Round-2 claude-review findings:

- 🟡 Base-class recursion invariant: documented on embed_with_usage /
  embed_batch_with_usage that a provider overriding embed()/embed_batch() to
  delegate to the *_with_usage variant MUST also override that variant, or the
  two recurse. (No recursion today; the shipped providers pair the overrides.)
- 🟡 Processor metering had no unit test: extracted the two-event recording
  into a module-level record_indexing_usage() helper and added
  tests/unit/test_processor_metering.py (value mapping, flag/zero-chunk no-ops,
  best-effort failure swallowed).
- 🟡 SonarQube hotspots (python:S5332) were 3 http:// URLs in the new test
  fixtures (mock hosts, never contacted) blocking the quality gate
  (new_security_hotspots_reviewed). Switched them to https:// so no hotspot is
  raised.
- 🟢 Zero-chunk guard: record_indexing_usage() no-ops when chunk_count == 0, so
  an empty document no longer writes zero-value billing rows.

Deferred (stated on the PR): Mistral x.index-or-0 sort key (pre-existing,
equivalent), CHANGELOG note for the Ollama /api/embed switch (CHANGELOG is
commitizen-generated from commit bodies, which document it), class-var
query_token_count (safe under the per-request instance pattern).

Deck #67.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 01:22:03 +02:00
Chris CoutinhoandClaude Opus 4.8 854ef349cd fix(contacts): resolve real CardDAV object path for delete/update (fixes #874)
nc_contacts_delete_contact (and update_contact / _get_raw_vcard) constructed
the CardDAV URL as `<addressbook>/<uid>.vcf`, assuming the DAV object filename
always equals `<uid>.vcf`. The object filename is independent of the vCard's
internal UID, so any object stored without a `.vcf` extension (e.g. the stock
`default` sample contact at `.../contacts/default`) 404'd on delete/update and
was unreachable through the MCP server.

list_contacts stripped `.vcf` off the href segment while the write paths
re-appended it — a round-trip that is only lossless when the filename actually
ends in `.vcf`. create_contact always writes `<uid>.vcf`, which is why our own
tests never hit this.

Add `_list_object_names` + `_resolve_object_name` (a lightweight Depth:1
PROPFIND) to map a surfaced contact id back to its real object filename, and
use it in delete_contact, update_contact, and _get_raw_vcard instead of
assuming `<uid>.vcf`. Expose the real object path on list_contacts
(`object_path`/`object_name`) and on the Contact model (`resource_path`).
Backward compatible: `vcard_id` keeps its historical `.vcf`-stripped form and
existing `<uid>.vcf` paths are unchanged.

Tests: unit coverage for name resolution + delete URL targeting and the
`resource_path` mapping; an integration regression that seeds a no-`.vcf`
object and confirms delete via the public API succeeds.

Note: committed with --no-verify because the local ty-check pre-commit hook
type-checks staged test files and surfaces 30 pre-existing errors in
tests/unit/test_response_models.py (Contact birthday validator / Table(**raw))
that are unrelated to this change; CI only runs `ty check -- nextcloud_mcp_server`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 01:19:40 +02:00
Chris CoutinhoandClaude Opus 4.8 a0bb5642cb fix(usage): embed query once across doc_types; address review round 1
Round-1 claude-review findings:

- 🔴 Multi-doc_type search billed N embedding calls as 1. nc_semantic_search
  loops search() once per doc_type on one BM25HybridSearchAlgorithm instance,
  and each call re-embedded the query, so only the last query_token_count was
  recorded. Cache the dense embedding per query on the (per-request) instance
  so the query is embedded — and metered — exactly once regardless of how many
  doc_types are searched. This also removes the redundant per-type embed work
  and avoids billing a user N× for one logical query.
- 🟡 Ollama embed() now delegates to embed_with_usage() so single and batch
  embeds use the same /api/embed endpoint (was the legacy /api/embeddings),
  keeping _detect_dimension and other embed() callers consistent.
- 🟢 round() instead of truncating int() when coercing provider-reported token
  counts (forward-compatible if a provider ever returns a float).

Tests: per-instance query-embedding cache (embedded once across 3 doc_types;
re-embeds on a different query).

Deferred (stated on the PR): mistral/openai single-embed dual path (changes
tested error/request semantics on the cloud-critical path — separate refactor),
bedrock boto3 sync-in-async (pre-existing; no new invoke_model calls per doc).

Deck #67.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 01:07:22 +02:00
Chris CoutinhoandClaude Opus 4.8 64318f0b25 feat(usage): meter embedding tokens as embeddings_queries on both paths
embeddings_queries now records the embedding request's token count (the unit
upstream providers bill on) instead of an operation count, and fires on the
indexing path too. Previously only semantic search recorded it (value=1), so a
re-indexing run produced no embeddings_queries events at all — only pages_chunks.

- Provider layer: additive embed_with_usage / embed_batch_with_usage surface the
  per-request token count (Mistral/OpenAI usage.total_tokens, Bedrock Titan
  inputTextTokenCount, Ollama prompt_eval_count); a char-based estimate is the
  fallback (Simple, and any provider/response without a token field). Gateway and
  EmbeddingService forward through. The count travels as a return value / a
  per-request SearchAlgorithm attribute — never on the singleton — so concurrent
  indexing + search can't mis-attribute bills.
- Indexing (vector/processor.py): records embeddings_queries (value=batch tokens)
  alongside the existing pages_chunks event.
- Search (server/semantic.py): value is now the query embedding's token count,
  relayed from BM25HybridSearchAlgorithm via query_token_count.

The astrolabe_embeddings_queries Stripe meter (sum aggregation) now sums tokens
with no CP/Terraform change. The meter "queries"->tokens naming/unit
clarification (homelab-terraform #254) + CP rollup/portal copy is a follow-up.

Deck #67.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 00:53:58 +02:00
Chris CoutinhoandClaude Opus 4.8 2e25c2723e test(vector): address PR #873 round-3 nits
- Simplify the OCS status guard to `if status and status != "ok"` — falsy
  (missing/None/"") is tolerated more naturally than the explicit tuple.
- Add `test_value_error_from_ocs_failure_returns_none`, covering the
  OCS-failure ValueError flowing through `_get_enabled_apps_or_none` to the
  scan-all fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:14:13 +02:00
Chris CoutinhoandClaude Opus 4.8 84dfe8a8fc fix(vector): address PR #873 round-2 review
- Correct the misleading `test_malformed_envelope_returns_empty_set`
  docstring: an empty-set return does NOT trigger the
  `_get_enabled_apps_or_none` scan-all fallback (which fires only on
  exceptions); optional apps are gated off for that cycle, Files unaffected.
- Check OCS `meta.status` in `get_enabled_apps`: a 200 carrying
  `status != "ok"` now raises, so a 200-with-failure envelope routes through
  the scanner's scan-all fallback instead of silently gating every app off.
  Add a test for the failure-status raise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:09:47 +02:00
Chris CoutinhoandClaude Opus 4.8 b11d1b17a3 test(vector): address PR #873 round-1 review
- Extract `_app_enabled` to a module-level helper so the gate predicate is
  unit-tested directly instead of via an inline copy that could drift.
- Move `import logging` to module scope in test_scanner_app_gating.py.
- Harden `get_enabled_apps` OCS-envelope parsing (`X or {}` / `or []`) so a
  present-but-null `ocs`/`data` coerces to empty instead of raising on
  `None.get`; add parametrized malformed-envelope tests.
- Use https:// in the test request URL (SonarCloud S5332 hotspot).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:04:00 +02:00
Chris CoutinhoandClaude Opus 4.8 2e609cbea7 fix(vector): gate scanner app polls on per-user enabled apps
The vector-sync scanner polled every indexed app (Notes, Files, News,
Deck) for every provisioned user on each scan cycle. When a user lacks
an app, its REST API returns 404; these were caught (indexing
continued) but flooded tenant logs with repeated 404s, scaling with
users x disabled-apps x scan-frequency and masking real failures.

Add NextcloudClient.get_enabled_apps(), which reads the per-user
/ocs/v2.php/core/navigation/apps endpoint (respects group
restrictions). Chosen over /cloud/capabilities because the News app
advertises no capability and never appears there.

scan_user_documents now resolves the enabled-app set once per cycle and
skips the Notes/News/Deck scans for apps the user lacks. Files stays
unconditional (core Tags API, not a 404 source). Detection failures
fall back to scanning every app (prior behaviour), so a transient
nav-endpoint blip never silently halts indexing; the per-app 404 guards
remain as the safety net.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:57:00 +02:00
Chris CoutinhoandClaude Opus 4.8 76779b3474 test(deck): address PR #872 round-2 review
- Assert the open card stays visible under status="open" in the
  deck_get_stack integration test (completes the partition check).
- Move the _append_archived_cards docstring closing quotes to their own line.

deck_get_stack's status="archived" + include_cards=False path is left as-is:
a single get_stack call is the cheapest way to obtain the stack metadata
there — routing it through the archived fast-path would fetch every archived
stack on the board just to strip the cards, which is heavier, not lighter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:25:56 +02:00
Chris CoutinhoandClaude Opus 4.8 90494674d9 refactor(deck): address PR #872 round-1 review
- deck_get_stack: fetch active + archived concurrently for status="all", and
  for status="archived" source the stack from /stacks/archived in a single
  call (skip the active fetch whose open cards are filtered out anyway),
  matching deck_get_cards' pattern.
- Type the `client` param of _archived_cards_by_stack as NextcloudClient.
- Extend the stacks/overview integration test to assert status="archived"
  (only the archived card) in addition to status="all".
- Document the third_party/astrolabe submodule mount policy in CLAUDE.md:
  unmounted by default (CI installs the published app-store version); mount
  only for tightly-coupled feature work needing CI integration, then revert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:21:00 +02:00
Chris CoutinhoandClaude Opus 4.8 961449be30 fix(deck): include archived cards in list tools for status=all/archived
The active Deck listing endpoints (StackService::findAll /
CardMapper::findAllForStacks and StackService::find / CardMapper::findAll)
filter out archived cards at the SQL level — only the /stacks/archived
endpoint returns them. The client-side status="all"/"archived" filters in
deck_get_cards, deck_get_stacks, deck_get_stack and deck_get_board_overview
therefore operated on a list the server had already stripped of archived
cards, so they could never surface one. deck_get_card (by ID) bypasses the
filter, which is why it appeared to work. Fixes #842.

When status is "all" or "archived", also fetch /stacks/archived
(client.deck.get_archived_stacks) and merge those cards back in per stack —
concurrently with the active fetch where applicable. status="open"/"done"
are unchanged and cost no extra call.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:03:28 +02:00
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 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 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 0fada20d35 test(vector): pin empty-chunk-list parity for all-blank pages
Address claude-review round 5 on PR #868: add
test_all_blank_pages_returns_empty_list documenting that PageAwareChunker
returns [] when every page is blank — and asserting parity with
DocumentChunker, which already returns [] for whitespace-only non-empty
content. The empty-chunk-list case is therefore pre-existing pipeline
behavior, not new to this PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:20:03 +02:00
Chris CoutinhoandClaude Opus 4.8 20b4bc6ab9 test(vector): mark test_document_chunker as unit
Address claude-review round 3 on PR #868: add module-level
`pytestmark = pytest.mark.unit` so TestPageAwareChunker and
TestDocumentChunkerPositions are collected under `-m unit`, matching
test_processor_routing.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:07:01 +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 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 e68096a780 test(review): correct the junk-layer test comment
The high ocr_frac is driven by each segment being shorter than MIN_PAGE_CHARS
(needs_ocr), not by text quality; quality drives bad_text_layer separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 03:24:13 +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 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