Compare commits

..
16 changed files with 188 additions and 315 deletions
+5 -5
View File
@@ -1048,7 +1048,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.ts</context>
<context context-type="linenumber">124</context>
<context context-type="linenumber">130</context>
</context-group>
</trans-unit>
<trans-unit id="2818183879511244335" datatype="html">
@@ -3714,22 +3714,22 @@
<source>Successfully updated object.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.ts</context>
<context context-type="linenumber">213</context>
<context context-type="linenumber">219</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.ts</context>
<context context-type="linenumber">251</context>
<context context-type="linenumber">257</context>
</context-group>
</trans-unit>
<trans-unit id="1801333259018423190" datatype="html">
<source>Error occurred saving object.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.ts</context>
<context context-type="linenumber">216</context>
<context context-type="linenumber">222</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.ts</context>
<context context-type="linenumber">254</context>
<context context-type="linenumber">260</context>
</context-group>
</trans-unit>
<trans-unit id="8193912662253833654" datatype="html">
@@ -9,7 +9,7 @@
autocomplete="off"
spellcheck="false"
[ngModel]="query()"
(ngModelChange)="queryDebounce.next($event)"
(ngModelChange)="onQueryChange($event)"
(keydown)="searchInputKeyDown($event)"
ngbDropdownAnchor>
<div class="position-absolute top-50 end-0 translate-middle">
@@ -272,6 +272,19 @@ describe('GlobalSearchComponent', () => {
expect(advancedSearchSpy).toHaveBeenCalled()
})
it('should set query immediately and run full search on enter without waiting for debounce', () => {
jest.useFakeTimers()
const searchSpy = jest.spyOn(searchService, 'globalSearch')
searchSpy.mockReturnValue(of({} as any))
const fullSearchSpy = jest.spyOn(component, 'runFullSearch')
component.onQueryChange('test')
expect(component.query()).toBe('test')
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
expect(fullSearchSpy).toHaveBeenCalled()
expect(searchSpy).not.toHaveBeenCalled()
jest.useRealTimers()
})
it('should search on query debounce', () => {
jest.useFakeTimers()
const query = 'test'
@@ -119,6 +119,12 @@ export class GlobalSearchComponent implements OnInit {
})
}
public onQueryChange(text: string) {
// set immediately so Enter / the full search button work without waiting for the debounce
this.query.set(text)
this.queryDebounce.next(text)
}
public ngOnInit() {
this.hotkeyService
.addShortcut({ keys: '/', description: $localize`Global search` })
+4 -6
View File
@@ -45,16 +45,14 @@ CLASSIFIER_HASH_KEY: Final[str] = "classifier_hash"
CLASSIFIER_MODIFIED_KEY: Final[str] = "classifier_modified"
# Marker distinguishing LLM suggestions from classifier-generated ones (whose
# FORMAT_VERSION lives in a much lower range - see DocumentClassifier). Bump
# this whenever cached suggestions must not be reused, including changes to
# their shape or interpretation, so a previous release's result cannot leak
# incompatible or obsolete behavior into the new one:
# this whenever the *shape* of the cached `suggestions` dict changes, so a
# cache entry written by a previous release can never be read back by code
# that expects a different shape:
# 1000 - initial LLM suggestions cache (flat lists of resolved object ids
# per taxonomy field)
# 1001 - suggestions reshaped to {"existing_ids": [...], "new_names":
# [...]} per taxonomy field (#13676)
# 1002 - names are always generated and optional candidate mappings are
# validated separately, so candidate-anchored 1001 results are stale
LLM_CACHE_CLASSIFIER_VERSION: Final[int] = 1002
LLM_CACHE_CLASSIFIER_VERSION: Final[int] = 1001
CACHE_1_MINUTE: Final[int] = 60
CACHE_5_MINUTES: Final[int] = 5 * CACHE_1_MINUTE
+2 -1
View File
@@ -7,9 +7,10 @@ from rest_framework import status
from rest_framework.test import APITestCase
from documents.models import Document
from documents.tests.utils import DirectoriesMixin
class TestTrashAPI(APITestCase):
class TestTrashAPI(DirectoriesMixin, APITestCase):
def setUp(self) -> None:
super().setUp()
@@ -409,6 +409,7 @@ class TestBulkDownloadPermissionChecksRootDocument:
@pytest.mark.django_db
@pytest.mark.usefixtures("_search_index")
class TestTrashRestorePermissionBoundary:
def test_restore_rejects_document_without_delete_permission(
self,
+15 -23
View File
@@ -67,6 +67,7 @@ def build_prompt_without_rag(
document: Document,
config: AIConfig,
candidates: TaxonomyCandidates | None = None,
assigned: AssignedMetadata | None = None,
) -> str:
filename = document.filename or ""
content = truncate_content(
@@ -76,7 +77,9 @@ def build_prompt_without_rag(
)
taxonomy_block = (
format_taxonomy_for_prompt(candidates) if candidates is not None else ""
format_taxonomy_for_prompt(candidates, assigned)
if candidates is not None and assigned is not None
else ""
)
has_candidates = candidates is not None and any(candidates.values())
@@ -94,12 +97,14 @@ def build_prompt_with_rag(
document: Document,
config: AIConfig,
candidates: TaxonomyCandidates | None = None,
assigned: AssignedMetadata | None = None,
context: str = "",
) -> str:
base_prompt = build_prompt_without_rag(
document,
config,
candidates=candidates,
assigned=assigned,
)
truncated_context = truncate_content(
context,
@@ -264,22 +269,6 @@ def _restrict_to_shown_candidates(
)
def _candidate_id_allowlist(
candidates: TaxonomyCandidates,
) -> dict[str, set[int]]:
"""Candidate IDs grouped by category for validating model mappings."""
return {
"tags": {candidate["id"] for candidate in candidates["tags"]},
"document_types": {
candidate["id"] for candidate in candidates["document_types"]
},
"correspondents": {
candidate["id"] for candidate in candidates["correspondents"]
},
"storage_paths": {candidate["id"] for candidate in candidates["storage_paths"]},
}
def get_ai_document_classification(
document: Document,
user: User | None = None,
@@ -288,25 +277,28 @@ def get_ai_document_classification(
ai_config = AIConfig()
if ai_config.llm_embedding_backend:
candidates, _assigned, context = get_taxonomy_context(document, user)
candidates, assigned, context = get_taxonomy_context(document, user)
prompt = build_prompt_with_rag(
document,
ai_config,
candidates=candidates,
assigned=assigned,
context=context,
)
else:
candidates = empty_taxonomy_candidates()
prompt = build_prompt_without_rag(document, ai_config, candidates=candidates)
prompt = build_prompt_without_rag(
document,
ai_config,
candidates=candidates,
assigned=get_assigned_metadata(document, user),
)
client = AIClient()
# Hand the pooled DB connection back while the (slow) LLM query runs so it
# is not pinned for the call's duration; see paperless_ai.db and #12976.
with db_connection_released():
result = client.run_llm_query(
prompt,
allowed_candidate_ids=_candidate_id_allowlist(candidates),
)
result = client.run_llm_query(prompt)
suggestions = _restrict_to_shown_candidates(
parse_ai_response(result),
candidates,
+41 -120
View File
@@ -48,107 +48,75 @@ class DocumentClassifierSchema(BaseModel):
default_factory=list,
max_length=MAX_NEW_NAMES,
description=(
"All topic labels you would suggest from the document itself, e.g. "
"'Insurance', 'Car', 'Warranty'. Always include every suggested "
"name here, even when it matches an available tag."
),
)
matched_tags: list[str] = Field(
default_factory=list,
max_length=MAX_NEW_NAMES,
description=(
"Names copied exactly from tags that mean the same thing as an "
"available tag. Align each name by position with tag_ids."
"Names of topic labels describing what this document is about, "
"e.g. 'Insurance', 'Car', 'Warranty'. When an available tag "
"represents the same label, use its ID in tag_ids instead."
),
)
tag_ids: list[int] = Field(
default_factory=list,
max_length=MAX_EXISTING_IDS,
description=(
"Available tag IDs matching matched_tags, in the same order. "
"Only use IDs shown in the prompt."
"IDs of available tags that clearly apply to this document. Only "
"use IDs shown in the prompt; never invent one or choose a weak match."
),
)
correspondents: list[str] = Field(
default_factory=list,
max_length=MAX_NEW_NAMES,
description=(
"All people, institutions or companies you would suggest as who "
"this document is from or was sent to, not every party merely "
"mentioned. Always include every suggested name here, even when it "
"matches an available correspondent."
),
)
matched_correspondents: list[str] = Field(
default_factory=list,
max_length=MAX_NEW_NAMES,
description=(
"Names copied exactly from correspondents that identify the same "
"entity as an available correspondent. Align each name by position "
"with correspondent_ids."
"Names of people, institutions or companies this document is from "
"or was sent to, not every party merely mentioned. When an "
"available correspondent is the same entity, use its ID in "
"correspondent_ids instead."
),
)
correspondent_ids: list[int] = Field(
default_factory=list,
max_length=MAX_EXISTING_IDS,
description=(
"Available correspondent IDs matching matched_correspondents, in "
"the same order. Only use IDs shown in the prompt."
"IDs of available correspondents that clearly apply to this "
"document. Only use IDs shown in the prompt; never invent one or "
"choose a weak match."
),
)
document_types: list[str] = Field(
default_factory=list,
max_length=MAX_NEW_NAMES,
description=(
"All names describing what kind of document this is, e.g. 'Invoice', "
"Names describing what kind of document this is, e.g. 'Invoice', "
"'Contract', 'Bank Statement', 'Letter'. Never use its subject or "
"sender as a document type. Always include every suggested name "
"here, even when it matches an available document type."
),
)
matched_document_types: list[str] = Field(
default_factory=list,
max_length=MAX_NEW_NAMES,
description=(
"Names copied exactly from document_types that mean the same thing "
"as an available document type. Align each name by position with "
"document_type_ids."
"sender as a document type. When an available document type is the "
"same kind, use its ID in document_type_ids instead."
),
)
document_type_ids: list[int] = Field(
default_factory=list,
max_length=MAX_EXISTING_IDS,
description=(
"Available document type IDs matching matched_document_types, in "
"the same order. Only use IDs shown in the prompt."
"IDs of available document types that clearly apply to this "
"document. Only use IDs shown in the prompt; never invent one or "
"choose a weak match."
),
)
storage_paths: list[str] = Field(
default_factory=list,
max_length=MAX_NEW_NAMES,
description=(
"All folder-style filing locations you would suggest, e.g. "
"Names of folder-style filing locations, e.g. "
"'Finance/Invoices'. Leave empty unless a filing location is "
"clearly implied - never put tags, document types or "
"correspondents here. Always include every suggested name here, "
"even when it matches an available storage path."
),
)
matched_storage_paths: list[str] = Field(
default_factory=list,
max_length=MAX_NEW_NAMES,
description=(
"Names copied exactly from storage_paths that mean the same filing "
"location as an available storage path. Align each name by position "
"with storage_path_ids."
"correspondents here. When an available storage path is the same "
"location, use its ID in storage_path_ids instead."
),
)
storage_path_ids: list[int] = Field(
default_factory=list,
max_length=MAX_EXISTING_IDS,
description=(
"Available storage path IDs matching matched_storage_paths, in the "
"same order. Only use IDs shown in the prompt."
"IDs of available storage paths that clearly apply to this "
"document. Only use IDs shown in the prompt; never invent one or "
"choose a weak match."
),
)
dates: list[str] = Field(
@@ -164,16 +132,12 @@ class DocumentClassifierSchema(BaseModel):
@field_validator(
"title",
"tags",
"matched_tags",
"tag_ids",
"correspondents",
"matched_correspondents",
"correspondent_ids",
"document_types",
"matched_document_types",
"document_type_ids",
"storage_paths",
"matched_storage_paths",
"storage_path_ids",
"dates",
mode="before",
@@ -203,64 +167,25 @@ class ClassificationSuggestions(TypedDict):
def model_to_classification_suggestions(
model: DocumentClassifierSchema,
allowed_candidate_ids: dict[str, set[int]] | None = None,
) -> ClassificationSuggestions:
"""Validate optional candidate mappings and convert to the internal shape.
A mapping is accepted only when its name is copied from the model's own
complete suggestion list and its ID was actually shown for that category.
Invalid or unpaired mappings leave the original name untouched.
"""
allowed_candidate_ids = allowed_candidate_ids or {}
def _choice(
names: list[str],
matched_names: list[str],
ids: list[int],
category: str,
) -> TaxonomyChoiceDict:
remaining_names = list(names)
existing_ids: list[int] = []
allowed_ids = allowed_candidate_ids.get(category, set())
for name, object_id in zip(matched_names, ids, strict=False):
if (
name not in remaining_names
or object_id not in allowed_ids
or object_id in existing_ids
):
continue
remaining_names.remove(name)
existing_ids.append(object_id)
return TaxonomyChoiceDict(
existing_ids=existing_ids,
new_names=remaining_names,
)
"""Convert the flat, model-friendly response to the internal shape."""
return ClassificationSuggestions(
title=model.title,
tags=_choice(
model.tags,
model.matched_tags,
model.tag_ids,
"tags",
tags=TaxonomyChoiceDict(
existing_ids=model.tag_ids,
new_names=model.tags,
),
correspondents=_choice(
model.correspondents,
model.matched_correspondents,
model.correspondent_ids,
"correspondents",
correspondents=TaxonomyChoiceDict(
existing_ids=model.correspondent_ids,
new_names=model.correspondents,
),
document_types=_choice(
model.document_types,
model.matched_document_types,
model.document_type_ids,
"document_types",
document_types=TaxonomyChoiceDict(
existing_ids=model.document_type_ids,
new_names=model.document_types,
),
storage_paths=_choice(
model.storage_paths,
model.matched_storage_paths,
model.storage_path_ids,
"storage_paths",
storage_paths=TaxonomyChoiceDict(
existing_ids=model.storage_path_ids,
new_names=model.storage_paths,
),
dates=model.dates,
)
@@ -273,16 +198,12 @@ def classification_suggestions_to_model(
return DocumentClassifierSchema(
title=suggestions["title"],
tags=suggestions["tags"]["new_names"],
matched_tags=[],
tag_ids=[],
tag_ids=suggestions["tags"]["existing_ids"],
correspondents=suggestions["correspondents"]["new_names"],
matched_correspondents=[],
correspondent_ids=[],
correspondent_ids=suggestions["correspondents"]["existing_ids"],
document_types=suggestions["document_types"]["new_names"],
matched_document_types=[],
document_type_ids=[],
document_type_ids=suggestions["document_types"]["existing_ids"],
storage_paths=suggestions["storage_paths"]["new_names"],
matched_storage_paths=[],
storage_path_ids=[],
storage_path_ids=suggestions["storage_paths"]["existing_ids"],
dates=suggestions["dates"],
)
+3 -14
View File
@@ -117,12 +117,7 @@ class AIClient:
else:
raise ValueError(f"Unsupported LLM backend: {self.settings.llm_backend}")
def run_llm_query(
self,
prompt: str,
*,
allowed_candidate_ids: dict[str, set[int]] | None = None,
) -> ClassificationSuggestions:
def run_llm_query(self, prompt: str) -> ClassificationSuggestions:
logger.debug(
"Running LLM query against %s with model %s",
self.settings.llm_backend,
@@ -141,10 +136,7 @@ class AIClient:
)
logger.debug("LLM query result: %s", result)
parsed = DocumentClassifierSchema(**json.loads(result.message.content))
return model_to_classification_suggestions(
parsed,
allowed_candidate_ids,
)
return model_to_classification_suggestions(parsed)
from llama_index.core.program.function_program import get_function_tool
@@ -163,10 +155,7 @@ class AIClient:
)
logger.debug("LLM query result: %s", tool_calls)
parsed = DocumentClassifierSchema(**tool_calls[0].tool_kwargs)
return model_to_classification_suggestions(
parsed,
allowed_candidate_ids,
)
return model_to_classification_suggestions(parsed)
@contextmanager
def _normalize_timeouts(self) -> Iterator[None]:
+4 -1
View File
@@ -13,7 +13,10 @@ Analyze the following document and fill in these fields:
- dates: up to 3 relevant dates in YYYY-MM-DD format
{% if has_candidates %}
First produce the complete name suggestions from the document itself in tags, correspondents, document_types, and storage_paths. Always include every suggested name in those fields, even when an available candidate represents the same value. Then, as a separate reconciliation step, copy each name that means the same thing as an available candidate into the matching matched_* field and put that candidate's ID at the same position in the corresponding *_ids field. Candidates must not create, replace, or suppress suggestions. Do not match a candidate that is merely related.
For tags, correspondents, document types, and storage paths: first decide whether there is a useful, well-supported suggestion. If an available candidate clearly represents that suggestion, put its id in the matching tag_ids, correspondent_ids, document_type_ids, or storage_path_ids field instead of repeating its name. If no candidate represents the suggestion, put its name in tags, correspondents, document_types, or storage_paths. Do not choose a weak candidate merely because it exists.
{% else %}
No candidates are shown for this document, so leave every field ending in "_ids" empty and put suggestions in the corresponding name fields.
{% endif %}
Filename:
+1 -1
View File
@@ -1,6 +1,6 @@
You are localizing document classification suggestions for display in Paperless-ngx.
Rewrite only the "title", "tags", "document_types", and "storage_paths" fields in {{ language_name }}.
Rewrite only the "title", "tags", "document_types", and "storage_paths" fields in {{ language_name }}. Leave every field ending in "_ids" exactly as given - these are database identifiers, not text, and are not used from your response even if changed.
Do not translate correspondents or dates.
Preserve proper nouns, organization names, product names, and exact official document names. Translate generic category words when a {{ language_name }} equivalent exists.
+3 -7
View File
@@ -245,7 +245,7 @@ def _assigned_block(assigned: AssignedMetadata) -> str:
def format_taxonomy_for_prompt(
candidates: TaxonomyCandidates,
assigned: AssignedMetadata | None = None,
assigned: AssignedMetadata,
) -> str:
"""Render assigned metadata and ranked candidates as labelled prompt
blocks. Candidate names are untrusted, user-controlled data, so they are
@@ -255,7 +255,7 @@ def format_taxonomy_for_prompt(
is nothing to say (no assigned metadata and no candidates), so callers can
treat the result the same as no hints at all.
"""
has_assigned = assigned is not None and any(
has_assigned = any(
[
assigned["tags"],
assigned["document_type"],
@@ -271,11 +271,7 @@ def format_taxonomy_for_prompt(
return render_prompt(
TaxonomyBlockPromptContext(
assigned_block=(
_assigned_block(assigned)
if assigned is not None and has_assigned
else ""
),
assigned_block=_assigned_block(assigned) if has_assigned else "",
candidate_payload_json=(
json.dumps(candidate_payload, ensure_ascii=False)
if candidate_payload
+61 -42
View File
@@ -175,7 +175,6 @@ def test_get_ai_document_classification_failure(mock_run_llm_query, mock_documen
@pytest.mark.django_db
@patch("paperless_ai.client.AIClient.run_llm_query")
@patch("paperless_ai.ai_classifier.build_prompt_with_rag")
@patch("paperless_ai.ai_classifier.build_taxonomy_candidates")
@patch("paperless_ai.ai_classifier.retrieve_similar_nodes")
@override_settings(
LLM_EMBEDDING_BACKEND="huggingface",
@@ -185,7 +184,6 @@ def test_get_ai_document_classification_failure(mock_run_llm_query, mock_documen
)
def test_use_rag_if_configured(
mock_retrieve,
mock_build_candidates,
mock_build_prompt_with_rag,
mock_run_llm_query,
mock_document,
@@ -197,29 +195,12 @@ def test_use_rag_if_configured(
- get_ai_document_classification() is called
THEN:
- The RAG-augmented prompt builder is used
- Classification and candidate reconciliation happen in one LLM call
- Only candidate IDs from the permission-filtered candidate set are allowed
"""
mock_retrieve.return_value = []
mock_build_candidates.return_value = TaxonomyCandidates(
tags=[TaxonomyCandidate(id=12, name="Contractor", weight=1.0)],
document_types=[],
correspondents=[],
storage_paths=[],
)
mock_build_prompt_with_rag.return_value = "Prompt with RAG"
mock_run_llm_query.return_value = NESTED_SUGGESTIONS
get_ai_document_classification(mock_document)
mock_build_prompt_with_rag.assert_called_once()
mock_run_llm_query.assert_called_once_with(
"Prompt with RAG",
allowed_candidate_ids={
"tags": {12},
"document_types": set(),
"correspondents": set(),
"storage_paths": set(),
},
)
@pytest.mark.django_db
@@ -625,11 +606,10 @@ def test_build_prompt_without_rag_includes_taxonomy_block():
GIVEN:
- Non-empty taxonomy candidates
WHEN:
- build_prompt_without_rag() is called with candidates
- build_prompt_without_rag() is called with candidates and assigned metadata
THEN:
- The candidate and single-call reconciliation instructions appear
- Complete name suggestions remain mandatory
- Assigned metadata is not included
- The candidate's id and the flat name/ID instructions appear
- Candidates are presented as deduplication options, not requirements
"""
document = DocumentFactory.create(content="Some content")
config = AIConfig()
@@ -639,31 +619,40 @@ def test_build_prompt_without_rag_includes_taxonomy_block():
"correspondents": [],
"storage_paths": [],
}
assigned = {
"tags": [],
"document_type": None,
"correspondent": None,
"storage_path": None,
}
prompt = build_prompt_without_rag(
document,
config,
candidates=candidates,
assigned=assigned,
)
assert '"id": 12' in prompt
assert "Always include every suggested name" in prompt
assert "matched_*" in prompt
assert "corresponding *_ids" in prompt
assert "Candidates must not create, replace, or suppress suggestions" in prompt
assert "already assigned" not in prompt
assert "tag_ids" in prompt
assert "correspondent_ids" in prompt
assert "not requirements" in prompt
assert "weak candidate" in prompt
@pytest.mark.django_db
def test_build_prompt_without_rag_identical_when_no_candidates():
def test_build_prompt_without_rag_identical_when_no_hints():
"""
GIVEN:
- Empty taxonomy candidates
- Empty taxonomy candidates and empty assigned metadata
WHEN:
- build_prompt_without_rag() is called with those empty values, and
separately with no candidates at all
separately with no candidates/assigned at all
THEN:
- Both prompts are identical
- Neither carries candidate reconciliation instructions
- Neither carries the "Available ..." candidate block or the
id-vs-name routing instruction
- Both still tell the model to leave every ID field empty
"""
document = DocumentFactory.create(content="Some content")
config = AIConfig()
@@ -673,37 +662,67 @@ def test_build_prompt_without_rag_identical_when_no_candidates():
"correspondents": [],
"storage_paths": [],
}
empty_assigned = {
"tags": [],
"document_type": None,
"correspondent": None,
"storage_path": None,
}
with_empty_hints = build_prompt_without_rag(
document,
config,
candidates=empty_candidates,
assigned=empty_assigned,
)
with_no_hints = build_prompt_without_rag(document, config)
assert with_empty_hints == with_no_hints
assert "Available " not in with_no_hints
assert "matched_*" not in with_no_hints
assert "put its id in the matching" not in with_no_hints
assert 'leave every field ending in "_ids" empty' in with_no_hints
@pytest.mark.django_db
def test_build_prompt_without_rag_never_includes_assigned_metadata():
def test_build_prompt_without_rag_tells_model_to_skip_ids_when_no_candidates():
"""
GIVEN:
- A document with assigned taxonomy metadata
- Assigned metadata but empty taxonomy candidates
WHEN:
- build_prompt_without_rag() is called
- build_prompt_without_rag() is called with candidates and assigned metadata
THEN:
- Assigned metadata is absent so it cannot anchor classification
- The assigned-metadata block appears (taxonomy_block is non-empty)
- The prompt tells the model to leave every ID field empty
Staying silent about ID fields here is not enough: the response schema
advertises the field whatever the prompt says, and models fill it with
placeholder ids that resolve to real but unrelated objects (#13831).
"""
document = DocumentFactory.create(content="Some content")
config = AIConfig()
assigned_tag = TagFactory.create(name="Bloodwork")
document.tags.add(assigned_tag)
empty_candidates = {
"tags": [],
"document_types": [],
"correspondents": [],
"storage_paths": [],
}
assigned = {
"tags": ["Bloodwork"],
"document_type": None,
"correspondent": None,
"storage_path": None,
}
prompt = build_prompt_without_rag(document, config)
prompt = build_prompt_without_rag(
document,
config,
candidates=empty_candidates,
assigned=assigned,
)
assert "Bloodwork" not in prompt
assert "already assigned" not in prompt
assert "already assigned" in prompt
assert "No candidates are shown" in prompt
assert 'leave every field ending in "_ids" empty' in prompt
@pytest.mark.django_db
+18 -82
View File
@@ -1,6 +1,7 @@
import json
from paperless_ai.base_model import MAX_DATES
from paperless_ai.base_model import MAX_EXISTING_IDS
from paperless_ai.base_model import MAX_NEW_NAMES
from paperless_ai.base_model import MAX_TITLE_LENGTH
from paperless_ai.base_model import ClassificationSuggestions
@@ -18,7 +19,7 @@ def test_document_classifier_schema_declared_defaults():
WHEN:
- The schema is dumped to a dict via model_dump()
THEN:
- Every optional name field, and dates, dump as empty lists
- Every name and ID field, and dates, dump as empty lists
The model may omit optional fields, so the schema must provide the complete
empty shape expected by the conversion and matching pipeline.
@@ -30,106 +31,57 @@ def test_document_classifier_schema_declared_defaults():
assert dumped == {
"title": "Test Title",
"tags": [],
"matched_tags": [],
"tag_ids": [],
"correspondents": [],
"matched_correspondents": [],
"correspondent_ids": [],
"document_types": [],
"matched_document_types": [],
"document_type_ids": [],
"storage_paths": [],
"matched_storage_paths": [],
"storage_path_ids": [],
"dates": [],
}
def test_model_response_converts_names_to_internal_taxonomy_choices():
def test_flat_model_response_converts_to_internal_taxonomy_choices():
"""
GIVEN:
- A model response containing taxonomy names
- A flat model response with separate name and candidate-ID fields
WHEN:
- It is converted to Paperless' internal suggestion representation
THEN:
- Names enter the internal taxonomy representation as new names
- Existing IDs remain empty for deterministic application-side matching
- Names and IDs are paired under their taxonomy category
"""
parsed = DocumentClassifierSchema(
title="Electricity Bill",
tags=["Utilities", "Electricity"],
tag_ids=[12],
correspondents=["Power Company"],
correspondent_ids=[23],
document_types=["Utility Bill"],
document_type_ids=[34],
storage_paths=["Finance/Utilities"],
storage_path_ids=[45],
)
suggestions = model_to_classification_suggestions(parsed)
assert suggestions["tags"] == {
"existing_ids": [],
"existing_ids": [12],
"new_names": ["Utilities", "Electricity"],
}
assert suggestions["correspondents"] == {
"existing_ids": [],
"existing_ids": [23],
"new_names": ["Power Company"],
}
assert suggestions["document_types"] == {
"existing_ids": [],
"existing_ids": [34],
"new_names": ["Utility Bill"],
}
assert suggestions["storage_paths"] == {
"existing_ids": [],
"existing_ids": [45],
"new_names": ["Finance/Utilities"],
}
def test_valid_candidate_mappings_replace_only_the_matched_names():
parsed = DocumentClassifierSchema(
title="Electricity Bill",
tags=["Utilities", "Electricity"],
matched_tags=["Utilities"],
tag_ids=[12],
correspondents=["Power Company"],
matched_correspondents=["Power Company"],
correspondent_ids=[23],
)
suggestions = model_to_classification_suggestions(
parsed,
{
"tags": {12},
"correspondents": {23},
},
)
assert suggestions["tags"] == {
"existing_ids": [12],
"new_names": ["Electricity"],
}
assert suggestions["correspondents"] == {
"existing_ids": [23],
"new_names": [],
}
def test_invalid_or_unpaired_candidate_mappings_do_not_remove_names():
parsed = DocumentClassifierSchema(
title="Electricity Bill",
tags=["Utilities", "Electricity", "Energy"],
matched_tags=["Invented", "Utilities", "Electricity"],
tag_ids=[12, 999],
)
suggestions = model_to_classification_suggestions(
parsed,
{"tags": {12}},
)
assert suggestions["tags"] == {
"existing_ids": [],
"new_names": ["Utilities", "Electricity", "Energy"],
}
def test_document_classifier_schema_json_schema_is_self_contained():
"""
GIVEN:
@@ -223,11 +175,13 @@ def test_over_long_response_is_truncated_rather_than_rejected():
parsed = DocumentClassifierSchema(
title="T" * (MAX_TITLE_LENGTH + 50),
tags=["n"] * (MAX_NEW_NAMES + 20),
tag_ids=list(range(MAX_EXISTING_IDS + 20)),
dates=[f"2016-{month:02d}-01" for month in range(1, 13)],
)
assert len(parsed.title) == MAX_TITLE_LENGTH
assert len(parsed.dates) == MAX_DATES
assert len(parsed.tag_ids) == MAX_EXISTING_IDS
assert len(parsed.tags) == MAX_NEW_NAMES
@@ -260,7 +214,7 @@ def test_model_conversion_matches_internal_typed_dict_keys():
- The converted tags dict's keys exactly match TaxonomyChoiceDict's
declared keys
"""
schema = DocumentClassifierSchema(title="T", tags=["Tag"])
schema = DocumentClassifierSchema(title="T", tags=["Tag"], tag_ids=[1])
suggestions = model_to_classification_suggestions(schema)
assert set(suggestions.keys()) == set(
@@ -271,7 +225,7 @@ def test_model_conversion_matches_internal_typed_dict_keys():
)
def test_internal_suggestions_convert_to_names_only_model():
def test_internal_suggestions_round_trip_through_flat_model():
suggestions = ClassificationSuggestions(
title="Electricity Bill",
tags=TaxonomyChoiceDict(existing_ids=[1], new_names=["Utilities"]),
@@ -292,22 +246,4 @@ def test_internal_suggestions_convert_to_names_only_model():
model = classification_suggestions_to_model(suggestions)
converted = model_to_classification_suggestions(model)
assert converted == ClassificationSuggestions(
title="Electricity Bill",
tags=TaxonomyChoiceDict(existing_ids=[], new_names=["Utilities"]),
correspondents=TaxonomyChoiceDict(
existing_ids=[],
new_names=["Power Company"],
),
document_types=TaxonomyChoiceDict(
existing_ids=[],
new_names=["Utility Bill"],
),
storage_paths=TaxonomyChoiceDict(
existing_ids=[],
new_names=["Finance/Utilities"],
),
dates=["2026-08-30"],
)
assert model_to_classification_suggestions(model) == suggestions
+10 -12
View File
@@ -124,23 +124,22 @@ def test_run_llm_query_ollama_uses_structured_json(mock_ai_config, mock_ollama_l
{
"title": "Test Title",
"tags": ["document"],
"matched_tags": ["document"],
"tag_ids": [1],
"correspondents": ["John Doe"],
"correspondent_ids": [],
"document_types": ["report"],
"document_type_ids": [],
"storage_paths": ["Reports"],
"storage_path_ids": [],
"dates": ["2023-01-01"],
},
)
client = AIClient()
result = client.run_llm_query(
"test_prompt",
allowed_candidate_ids={"tags": {1}},
)
result = client.run_llm_query("test_prompt")
assert result["title"] == "Test Title"
assert result["tags"] == {"existing_ids": [1], "new_names": []}
assert result["tags"] == {"existing_ids": [1], "new_names": ["document"]}
mock_llm_instance.chat.assert_called_once_with(
[ANY],
format=ANY,
@@ -162,11 +161,13 @@ def test_run_llm_query_openai_uses_tools(mock_ai_config, mock_openai_llm):
tool_kwargs={
"title": "Test Title",
"tags": ["document"],
"matched_tags": ["document"],
"tag_ids": [1],
"correspondents": ["John Doe"],
"correspondent_ids": [],
"document_types": ["report"],
"document_type_ids": [],
"storage_paths": ["Reports"],
"storage_path_ids": [],
"dates": ["2023-01-01"],
},
)
@@ -175,13 +176,10 @@ def test_run_llm_query_openai_uses_tools(mock_ai_config, mock_openai_llm):
mock_llm_instance.get_tool_calls_from_response.return_value = [tool_selection]
client = AIClient()
result = client.run_llm_query(
"test_prompt",
allowed_candidate_ids={"tags": {1}},
)
result = client.run_llm_query("test_prompt")
assert result["title"] == "Test Title"
assert result["tags"] == {"existing_ids": [1], "new_names": []}
assert result["tags"] == {"existing_ids": [1], "new_names": ["document"]}
mock_llm_instance.chat_with_tools.assert_called_once()