Compare commits

...
Author SHA1 Message Date
stumpylogandClaude Fable 5 a065a9a391 chore: add whoosh-compat transition skill
Encodes the settled integration decisions for replacing the
hand-maintained search translation layer with whoosh-compat:
user-typed query surface policy, analyzer seam, diagnostics-before-emit
contract, mandatory date parity audit, test churn, and rollout plan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 14:46:06 -07:00
Trenton HandGitHub 52a0484f74 Fix: raise ParseError on remote OCR failure instead of silently continuing (#13574) 2026-08-06 15:15:10 +00:00
GitHub Actions 71e2f86f70 Auto translate strings 2026-08-06 04:05:01 +00:00
shamoonandGitHub 1824e5fafd Fix: use selected version when creating share links (#13571) 2026-08-05 21:03:20 -07:00
6 changed files with 85 additions and 14 deletions
@@ -0,0 +1,55 @@
---
name: whoosh-compat-transition
description: Use when integrating the whoosh-compat library into paperless-ngx search, replacing src/documents/search/_translate.py or _dates.py, building the search FieldRegistry, or changing user query parsing during the whoosh-to-tantivy transition
---
# whoosh-compat transition
## Overview
whoosh-compat (github.com/stumpylog/whoosh-compat; local checkout usually at `../whoosh-compat`) replaces the hand-maintained translation layer (`src/documents/search/_translate.py`, `_dates.py`): it parses user queries with a faithful fork of whoosh's real grammar into a typed AST and emits programmatic tantivy queries. Read its README and ARCHITECTURE.md before wiring anything; its DIVERGENCES.md lists intended behavior differences and is the authority on "is this difference a bug".
## Decisions already made (do not re-derive)
- **Queries are user-typed free text.** The advanced search box passes whatever the user types straight to the parser (that is how the issue #13568 queries exist). Do NOT try to infer the supported field surface from frontend code; the frontend only generates a few date filter strings, everything else is typed by users.
- **The field surface is a policy decision, not `KNOWN_FIELDS`.** Today's `KNOWN_FIELDS` accepts internal ID fields (`tag_id`, `owner_id`, `viewer_id`, other `*_id`) that are undocumented in `docs/usage.md` and were ruled not user-searchable by the maintainer: exclude them from the `FieldRegistry` (they stay as programmatic permission/filter fields in `build_permission_filter`, which never touches user query text). The registry is built from documented syntax in `docs/usage.md` plus the v2-compat aliases (`type`, `path`, `type_id`-style aliases follow their canonical field's fate). Undocumented-but-working fields (`asn`, `page_count`, `num_notes`, `original_filename`, `checksum`) need an explicit maintainer yes/no; since users type freely, silently dropping one breaks any saved view using it, so a drop must be a visible, documented decision.
- **Analyzer seam:** `FieldSpec.analyzer` binds the live registered tantivy analyzer's `.analyze` (the same Rust analyzer used at index time; language-keyed, so rebuild the registry when `SEARCH_LANGUAGE` changes, on the same trigger as `register_tokenizers`). `pattern_normalizer` is `_tokenizer.ascii_fold`: character-level lowercase+fold only, NEVER stemming.
- **Diagnostics before emit:** `whoosh_compat.parse()` never raises on bad input. Check `ParseResult.diagnostics` and map to `SearchQueryError`/`InvalidDateQuery` (HTTP 400) BEFORE calling `emit()`; also catch the emitter's `UnsupportedQueryError` into a 400. Never carry forward the legacy raw-string fallback (`except Exception: query_str = raw_query`) into the new path; it masks integration bugs.
- **`notes` and `custom_fields` are JSON fields** with fixed subpaths (`notes.user`/`notes.note`, `custom_fields.name`/`custom_fields.value`); the registry stays a static, language-keyed singleton, never per-request.
## Mandatory before deleting old code
- Date-grammar parity audit, line by line: every keyword, relative unit, and abbreviation `_dates.py` and `_translate.py` accept today (including the whoosh-era abbreviations kept for old saved views) must have an accepted form in whoosh-compat's dateparse grammar. Silent keyword loss is the saved-view breakage class behind issue #13568.
- Acceptance corpus compared by matched-document-ID sets, not query strings: the #13568 queries verbatim, real saved-view strings, every date keyword, field aliases, comma lists, date and numeric ranges, wildcards with bracket classes, boosts, JSON subpaths.
## Tests: what goes, what comes
Removed with their modules (do not port their string-level assertions):
- `src/documents/tests/search/test_translate.py`: its subject is deleted; string-translation unit cases are whoosh-compat's own responsibility now. Cases that encode real user-visible behavior get reincarnated as result-level acceptance cases, not string assertions.
- Date-keyword unit tests tied to `_dates.py` internals: same treatment.
- `test_query.py` cases asserting `parse_user_query` internals or intermediate query strings: rewritten against the new pipeline, asserting on matched results.
Kept: `test_migration_fulltext_query_field_prefixes.py` (data migration, orthogonal), `test_schema.py`, `test_tokenizer.py`, permission-filter and simple-search tests.
Added:
- A result-level acceptance module (paperless's analogue of whoosh-compat's `test_acceptance_e2e.py`): the corpus above against a real index built from `build_schema()`, asserting document-ID sets. Use `pytest.param(..., id="...")` for every case.
- Registry unit tests: internal `*_id` names rejected, aliases resolve to canonical fields, JSON subpaths match `docs/usage.md`, construction deterministic per language.
- One `Multitoken` case nested inside a top-level `OR` (whoosh-compat DIVERGENCES entry on Multitoken.DEFAULT) to prove it does not matter for paperless's data.
- If acceptance work surfaces a new whoosh-compat divergence, that is a whoosh-compat-repo change (its `differential-triage` skill applies), not a silent paperless workaround.
## Coordination
- whoosh-compat is pre-1.0: pin an exact version or git SHA; upgrades are deliberate, reviewed changes.
- JSON subpath emission depends on the installed tantivy-py version (fallback until quickwit-oss/tantivy-py#716 ships). The whoosh-compat repo has a `carve-out-retirement` skill; coordinate tantivy pin bumps with it, in a separate PR from the parser migration.
- Rollout: settings flag defaulting to the legacy path plus shadow-compare logging (log when old and new paths return different ID sets; sample if cost matters) for one release; delete `_translate.py`/`_dates.py` only after the flag defaults to the new path with no material reports.
## Common mistakes
- Inferring the field surface from frontend code (users type queries directly).
- Copying `KNOWN_FIELDS` into the registry wholesale (resurfaces internal fields).
- Wiring stemming into `pattern_normalizer`.
- Calling `emit()` unconditionally, or porting the legacy raw-string fallback.
- Deleting `_dates.py` without the parity audit.
- Porting `test_translate.py`'s string assertions instead of writing result-level tests.
+2 -2
View File
@@ -8238,11 +8238,11 @@
<source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source> <source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">2024</context> <context context-type="linenumber">2026</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">2030</context> <context context-type="linenumber">2032</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4958946940233632319" datatype="html"> <trans-unit id="4958946940233632319" datatype="html">
@@ -2161,8 +2161,14 @@ describe('DocumentDetailComponent', () => {
it('should support open share links and email modals', () => { it('should support open share links and email modals', () => {
const modalSpy = jest.spyOn(modalService, 'open') const modalSpy = jest.spyOn(modalService, 'open')
initNormally() initNormally()
component.selectedVersionId.set(10)
component.openShareLinks() component.openShareLinks()
expect(modalSpy).toHaveBeenCalled() expect(modalSpy).toHaveBeenCalled()
expect(
(
modalSpy.mock.results[0].value as NgbModalRef
).componentInstance.documentId()
).toBe(10)
component.openEmailDocument() component.openEmailDocument()
expect(modalSpy).toHaveBeenCalled() expect(modalSpy).toHaveBeenCalled()
}) })
@@ -1959,7 +1959,9 @@ export class DocumentDetailComponent
public openShareLinks() { public openShareLinks() {
const modal = this.modalService.open(ShareLinksDialogComponent) const modal = this.modalService.open(ShareLinksDialogComponent)
modal.componentInstance.documentId.set(this.document().id) modal.componentInstance.documentId.set(
this.selectedVersionId() ?? this.document().id
)
modal.componentInstance.hasArchiveVersion.set( modal.componentInstance.hasArchiveVersion.set(
this.metadata()?.has_archive_version ?? this.metadata()?.has_archive_version ??
!!this.document()?.archived_file_name !!this.document()?.archived_file_name
+11 -5
View File
@@ -21,6 +21,7 @@ from typing import Self
from django.conf import settings from django.conf import settings
from documents.parsers import ParseError
from paperless.version import __full_version_str__ from paperless.version import __full_version_str__
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -366,8 +367,7 @@ class RemoteDocumentParser:
"""Send ``file`` to Azure AI Document Intelligence and return text. """Send ``file`` to Azure AI Document Intelligence and return text.
Downloads the searchable PDF output from Azure and stores it at Downloads the searchable PDF output from Azure and stores it at
``self._archive_path``. Returns the extracted text content, or ``self._archive_path``.
``None`` on failure (the error is logged).
Parameters Parameters
---------- ----------
@@ -379,7 +379,14 @@ class RemoteDocumentParser:
Returns Returns
------- -------
str | None str | None
Extracted text, or None if the Azure call failed. Extracted text.
Raises
------
ParseError
If the Azure call fails for any reason. The error is logged
and re-raised so consumption fails loudly instead of silently
producing a document with no content.
""" """
if TYPE_CHECKING: if TYPE_CHECKING:
# Callers must have already validated config via engine_is_valid(): # Callers must have already validated config via engine_is_valid():
@@ -426,8 +433,7 @@ class RemoteDocumentParser:
except Exception as e: except Exception as e:
logger.exception("Azure AI Vision parsing failed: %s", e) logger.exception("Azure AI Vision parsing failed: %s", e)
raise ParseError(f"Azure AI Vision parsing failed: {e}") from e
finally: finally:
client.close() client.close()
return None
@@ -20,6 +20,7 @@ from unittest.mock import Mock
import pytest import pytest
from documents.parsers import ParseError
from paperless.parsers import ParserContext from paperless.parsers import ParserContext
from paperless.parsers import ParserProtocol from paperless.parsers import ParserProtocol
from paperless.parsers.remote import RemoteDocumentParser from paperless.parsers.remote import RemoteDocumentParser
@@ -342,15 +343,14 @@ class TestRemoteParserParse:
class TestRemoteParserParseError: class TestRemoteParserParseError:
def test_parse_returns_empty_on_azure_error( def test_parse_raises_parse_error_on_azure_error(
self, self,
remote_parser: RemoteDocumentParser, remote_parser: RemoteDocumentParser,
simple_digital_pdf_file: Path, simple_digital_pdf_file: Path,
failing_azure_client: Mock, failing_azure_client: Mock,
) -> None: ) -> None:
remote_parser.parse(simple_digital_pdf_file, "application/pdf") with pytest.raises(ParseError, match="Azure AI Vision parsing failed"):
remote_parser.parse(simple_digital_pdf_file, "application/pdf")
assert remote_parser.get_text() == ""
def test_parse_closes_client_on_error( def test_parse_closes_client_on_error(
self, self,
@@ -358,7 +358,8 @@ class TestRemoteParserParseError:
simple_digital_pdf_file: Path, simple_digital_pdf_file: Path,
failing_azure_client: Mock, failing_azure_client: Mock,
) -> None: ) -> None:
remote_parser.parse(simple_digital_pdf_file, "application/pdf") with pytest.raises(ParseError):
remote_parser.parse(simple_digital_pdf_file, "application/pdf")
failing_azure_client.close.assert_called_once() failing_azure_client.close.assert_called_once()
@@ -371,7 +372,8 @@ class TestRemoteParserParseError:
) -> None: ) -> None:
mock_log = mocker.patch("paperless.parsers.remote.logger") mock_log = mocker.patch("paperless.parsers.remote.logger")
remote_parser.parse(simple_digital_pdf_file, "application/pdf") with pytest.raises(ParseError):
remote_parser.parse(simple_digital_pdf_file, "application/pdf")
mock_log.exception.assert_called_once() mock_log.exception.assert_called_once()
assert "Azure AI Vision parsing failed" in mock_log.exception.call_args[0][0] assert "Azure AI Vision parsing failed" in mock_log.exception.call_args[0][0]