mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-09 04:13:18 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a065a9a391 | ||
|
|
52a0484f74 | ||
|
|
71e2f86f70 | ||
|
|
1824e5fafd |
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user