mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-27 05:03:20 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2250ef96d | ||
|
|
50043ddb66 | ||
|
|
15a5618a6e | ||
|
|
34a01a5bd6 |
@@ -8,12 +8,6 @@ common [OCR](#ocr) related settings and some frontend settings. If set, these wi
|
||||
preference over the settings via environment variables. If not set, the environment setting
|
||||
or applicable default will be utilized instead.
|
||||
|
||||
!!! warning
|
||||
|
||||
Changing configuration from the UI requires the `AppConfig` permission, which applies
|
||||
instance-wide and should be treated as an admin-level permission. See
|
||||
[global permissions](usage.md#global-permissions).
|
||||
|
||||
- If you run paperless on docker, `paperless.conf` is not used.
|
||||
Rather, configure paperless by copying necessary options to
|
||||
`docker-compose.env`.
|
||||
@@ -1131,10 +1125,6 @@ they use underscores instead of dashes.
|
||||
so specifying invalid options may prevent paperless from consuming
|
||||
any documents. Use with caution!
|
||||
|
||||
These arguments are passed directly to OCRmyPDF, so this setting should only
|
||||
be changed by trusted users. This applies to the `AppConfig` permission as well,
|
||||
which allows setting these arguments from the UI.
|
||||
|
||||
Specify arguments as a JSON dictionary. Keep note of lower case
|
||||
booleans and double quoted parameter names and strings. Examples:
|
||||
|
||||
|
||||
+21
-21
@@ -427,27 +427,27 @@ Global permissions define what areas of the app and API endpoints users can acce
|
||||
determine if a user can create, edit, delete or view _any_ documents, but individual documents themselves
|
||||
still have "object-level" permissions.
|
||||
|
||||
| Type | Details |
|
||||
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| AppConfig | _Change_ or higher permissions grants access to the "Application Configuration" area.<br/>:warning: **This is a trusted, admin-level permission.** Application configuration applies instance-wide and some settings, such as OCR arguments, are passed directly to the underlying processing tools. |
|
||||
| Correspondent | Add, edit, delete or view Correspondents. |
|
||||
| CustomField | Add, edit, delete or view Custom Fields. |
|
||||
| Document | Add, edit, delete or view Documents. |
|
||||
| DocumentType | Add, edit, delete or view Document Types. |
|
||||
| Group | Add, edit, delete or view Groups. |
|
||||
| GlobalStatistics | View aggregate object counts and statistics. This does not grant access to view individual documents. |
|
||||
| MailAccount | Add, edit, delete or view Mail Accounts. |
|
||||
| MailRule | Add, edit, delete or view Mail Rules. |
|
||||
| Note | Add, edit, delete or view Notes. |
|
||||
| PaperlessTask | View or dismiss (_Change_) File Tasks. |
|
||||
| SavedView | Add, edit, delete or view Saved Views. |
|
||||
| ShareLink | Add, delete or view Share Links. |
|
||||
| StoragePath | Add, edit, delete or view Storage Paths. |
|
||||
| SystemMonitoring | View the system status dialog, tasks summary and their API endpoints. Admin users also retain system status access. |
|
||||
| Tag | Add, edit, delete or view Tags. |
|
||||
| UISettings | Add, edit, delete or view the UI settings that are used by the web app.<br/>:warning: **Users that will access the web UI must be granted at least _View_ permissions.** |
|
||||
| User | Add, edit, delete or view other user accounts via Settings > Users & Groups and `/api/users/`. These permissions are not needed for users to edit their own profile via "My Profile" or `/api/profile/`. |
|
||||
| Workflow | Add, edit, delete or view Workflows.<br/>Note that Workflows are global; all users who can access workflows see the same set. Workflows have other permission implications — see [Workflow permissions](#workflow-permissions). |
|
||||
| Type | Details |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| AppConfig | _Change_ or higher permissions grants access to the "Application Configuration" area. |
|
||||
| Correspondent | Add, edit, delete or view Correspondents. |
|
||||
| CustomField | Add, edit, delete or view Custom Fields. |
|
||||
| Document | Add, edit, delete or view Documents. |
|
||||
| DocumentType | Add, edit, delete or view Document Types. |
|
||||
| Group | Add, edit, delete or view Groups. |
|
||||
| GlobalStatistics | View aggregate object counts and statistics. This does not grant access to view individual documents. |
|
||||
| MailAccount | Add, edit, delete or view Mail Accounts. |
|
||||
| MailRule | Add, edit, delete or view Mail Rules. |
|
||||
| Note | Add, edit, delete or view Notes. |
|
||||
| PaperlessTask | View or dismiss (_Change_) File Tasks. |
|
||||
| SavedView | Add, edit, delete or view Saved Views. |
|
||||
| ShareLink | Add, delete or view Share Links. |
|
||||
| StoragePath | Add, edit, delete or view Storage Paths. |
|
||||
| SystemMonitoring | View the system status dialog, tasks summary and their API endpoints. Admin users also retain system status access. |
|
||||
| Tag | Add, edit, delete or view Tags. |
|
||||
| UISettings | Add, edit, delete or view the UI settings that are used by the web app.<br/>:warning: **Users that will access the web UI must be granted at least _View_ permissions.** |
|
||||
| User | Add, edit, delete or view other user accounts via Settings > Users & Groups and `/api/users/`. These permissions are not needed for users to edit their own profile via "My Profile" or `/api/profile/`. |
|
||||
| Workflow | Add, edit, delete or view Workflows.<br/>Note that Workflows are global; all users who can access workflows see the same set. Workflows have other permission implications — see [Workflow permissions](#workflow-permissions). |
|
||||
|
||||
#### Detailed Explanation of Object Permissions {#object-permissions}
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "paperless-ngx"
|
||||
version = "3.1.0"
|
||||
version = "3.0.5"
|
||||
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
+2
-9
@@ -4639,7 +4639,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/input/select/select.component.html</context>
|
||||
<context context-type="linenumber">58</context>
|
||||
<context context-type="linenumber">53</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5324147361912094446" datatype="html">
|
||||
@@ -6488,7 +6488,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/input/select/select.component.html</context>
|
||||
<context context-type="linenumber">76</context>
|
||||
<context context-type="linenumber">71</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/input/tags/tags.component.html</context>
|
||||
@@ -6614,13 +6614,6 @@
|
||||
</context-group>
|
||||
<note priority="1" from="description">Used for both types, correspondents, storage paths</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="6945988051184690124" datatype="html">
|
||||
<source>Remove item</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/input/select/select.component.html</context>
|
||||
<context context-type="linenumber">41</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3686284950598311784" datatype="html">
|
||||
<source>Private</source>
|
||||
<context-group purpose="location">
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paperless-ngx-ui",
|
||||
"version": "3.1.0",
|
||||
"version": "3.0.5",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"ng": "ng",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
infoLink="configuration">
|
||||
</pngx-page-header>
|
||||
|
||||
<form [formGroup]="configForm" (ngSubmit)="saveConfig()" class="pb-4" autocomplete="off">
|
||||
<form [formGroup]="configForm" (ngSubmit)="saveConfig()" class="pb-4">
|
||||
|
||||
<ul ngbNav #nav="ngbNav" class="nav-tabs">
|
||||
@for (category of optionCategories; track category) {
|
||||
@@ -44,7 +44,7 @@
|
||||
@case (ConfigOptionType.String) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
||||
@case (ConfigOptionType.JSON) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
||||
@case (ConfigOptionType.File) { <pngx-input-file [formControlName]="option.key" (upload)="uploadFile($event, option.key)" [error]="errors[option.key]"></pngx-input-file> }
|
||||
@case (ConfigOptionType.Password) { <pngx-input-password [formControlName]="option.key" [error]="errors[option.key]" autocomplete="new-password"></pngx-input-password> }
|
||||
@case (ConfigOptionType.Password) { <pngx-input-password [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-password> }
|
||||
}
|
||||
</div>
|
||||
@if (option.note) {
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
}
|
||||
<pngx-toasts-dropdown></pngx-toasts-dropdown>
|
||||
<li ngbDropdown class="nav-item dropdown">
|
||||
<button class="btn navbar-action border-0 me-1 me-md-2" id="userDropdown" ngbDropdownToggle aria-label="User menu" i18n-aria-label>
|
||||
<button class="btn navbar-action border-0" id="userDropdown" ngbDropdownToggle aria-label="User menu" i18n-aria-label>
|
||||
<i-bs width="1.3em" height="1.3em" name="person-circle"></i-bs>
|
||||
<span class="small ms-2 d-none d-sm-inline">
|
||||
{{this.settingsService.displayName}}
|
||||
|
||||
@@ -436,18 +436,6 @@ main {
|
||||
padding-right: .5rem;
|
||||
}
|
||||
|
||||
// true center the search with equal flex widths
|
||||
@media (min-width: 768px) {
|
||||
.navbar-brand,
|
||||
.navbar > ul {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.navbar > ul {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
:host ::ng-deep .navbar-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -40,10 +40,6 @@ describe('PasswordComponent', () => {
|
||||
// expect(component.value).toEqual('foo')
|
||||
})
|
||||
|
||||
it('should not offer itself to browser autofill by default', () => {
|
||||
expect(input.getAttribute('autocomplete')).toEqual('off')
|
||||
})
|
||||
|
||||
it('should support toggling field visibility', () => {
|
||||
expect(input.type).toEqual('password')
|
||||
component.showReveal = true
|
||||
|
||||
@@ -25,7 +25,7 @@ export class PasswordComponent extends AbstractInputComponent<string> {
|
||||
showReveal: boolean = false
|
||||
|
||||
@Input()
|
||||
autocomplete: string = 'off'
|
||||
autocomplete: string
|
||||
|
||||
public textVisible: boolean = false
|
||||
|
||||
|
||||
@@ -36,16 +36,11 @@
|
||||
(focus)="clearLastSearchTerm()"
|
||||
(clear)="clearLastSearchTerm()"
|
||||
(blur)="onBlur()">
|
||||
<ng-template ng-label-tmp let-item="item" let-clear="clear">
|
||||
@if (multiple && !disabled) {
|
||||
<span class="ng-value-icon left" role="button" tabindex="0" (click)="clear(item)" (keydown.enter)="clear(item)" aria-label="Remove item" i18n-aria-label>×</span>
|
||||
<ng-template ng-label-tmp let-item="item">
|
||||
@if (iconField && item[iconField]) {
|
||||
<i-bs class="me-2" [name]="item[iconField]"></i-bs>
|
||||
}
|
||||
<span class="ng-value-label" [title]="item[bindLabel]">
|
||||
@if (iconField && item[iconField]) {
|
||||
<i-bs class="me-2" [name]="item[iconField]"></i-bs>
|
||||
}
|
||||
{{item[bindLabel]}}
|
||||
</span>
|
||||
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
|
||||
</ng-template>
|
||||
<ng-template ng-option-tmp let-item="item">
|
||||
@if (iconField && item[iconField]) {
|
||||
|
||||
@@ -24,12 +24,6 @@ describe('TextComponent', () => {
|
||||
input = component.inputField.nativeElement
|
||||
})
|
||||
|
||||
it('should not offer itself to browser autofill by default', () => {
|
||||
expect(
|
||||
component.inputField.nativeElement.getAttribute('autocomplete')
|
||||
).toEqual('off')
|
||||
})
|
||||
|
||||
it('should support use of input field', () => {
|
||||
expect(component.value).toBeUndefined()
|
||||
input.value = 'foo'
|
||||
|
||||
@@ -28,7 +28,7 @@ import { AbstractInputComponent } from '../abstract-input'
|
||||
})
|
||||
export class TextComponent extends AbstractInputComponent<string> {
|
||||
@Input()
|
||||
autocomplete: string = 'off'
|
||||
autocomplete: string
|
||||
|
||||
@Input()
|
||||
placeholder: string = ''
|
||||
|
||||
@@ -15,35 +15,6 @@ describe('text search utilities', () => {
|
||||
expect(matchesSearchText('taxes 2026', 'tax receipt')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('does not let two terms match the same word', () => {
|
||||
expect(matchesSearchText('Another Tag', 'another tag th')).toBeFalsy()
|
||||
expect(matchesSearchText('Another Tag', 'another tag ag')).toBeFalsy()
|
||||
expect(matchesSearchText('Another Tag', 'another tag e')).toBeFalsy()
|
||||
expect(matchesSearchText('Another Tag', 'another tag')).toBeTruthy()
|
||||
expect(matchesSearchText('Another Tag', 'tag another')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('matches a single term anywhere in the value', () => {
|
||||
expect(matchesSearchText('Another Tag', 'anoth')).toBeTruthy()
|
||||
expect(matchesSearchText('Another Tag', 'th')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('treats punctuation as a separator on both sides', () => {
|
||||
expect(matchesSearchText('medical-history', 'medical history')).toBeTruthy()
|
||||
expect(matchesSearchText('medical history', 'medical-history')).toBeTruthy()
|
||||
expect(matchesSearchText('medical-history', 'medical dental')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('matches longer terms first so they cannot be starved', () => {
|
||||
expect(matchesSearchText('tagger tag', 'tag tagger')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('handles a query with no usable terms', () => {
|
||||
expect(matchesSearchText('Another Tag', '')).toBeTruthy()
|
||||
// Still filters, so the dropdown can offer to create a tag named "---"
|
||||
expect(matchesSearchText('Another Tag', '---')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('matches a large set of tag names without blocking input', () => {
|
||||
const tagNames = Array.from(
|
||||
{ length: 1280 },
|
||||
|
||||
@@ -3,18 +3,13 @@ import { diacritics } from 'normalize-diacritics/diacritics'
|
||||
export type SearchTextValue =
|
||||
string | number | boolean | bigint | null | undefined
|
||||
|
||||
const NON_ASCII = /[^\x00-\x7F]/
|
||||
const SEPARATORS = /[^\p{L}\p{N}]+/u
|
||||
|
||||
export function normalizeSearchText(value: SearchTextValue): string {
|
||||
const text = String(value ?? '')
|
||||
|
||||
// Nothing in the table matches ASCII, so skip normaliation
|
||||
if (!NON_ASCII.test(text)) return text.toLocaleLowerCase()
|
||||
|
||||
const normalized = diacritics.reduce((text, replacement) => {
|
||||
return text.replace(replacement.diacritics, replacement.letter)
|
||||
}, text)
|
||||
const normalized = diacritics.reduce(
|
||||
(text, replacement) => {
|
||||
return text.replace(replacement.diacritics, replacement.letter)
|
||||
},
|
||||
String(value ?? '')
|
||||
)
|
||||
|
||||
return normalized.toLocaleLowerCase()
|
||||
}
|
||||
@@ -23,28 +18,8 @@ export function matchesSearchText(
|
||||
value: SearchTextValue,
|
||||
searchText: SearchTextValue
|
||||
): boolean {
|
||||
const query = normalizeSearchText(searchText)
|
||||
const terms = query.split(SEPARATORS).filter(Boolean)
|
||||
const normalizedValue = normalizeSearchText(value)
|
||||
const searchTerms = normalizeSearchText(searchText).trim().split(/\s+/)
|
||||
|
||||
// Empty or punctuation-only query, nothing to split into terms
|
||||
if (terms.length === 0) {
|
||||
return normalizeSearchText(value).includes(query.trim())
|
||||
}
|
||||
|
||||
const words = normalizeSearchText(value).split(SEPARATORS).filter(Boolean)
|
||||
const claimed = new Array<boolean>(words.length).fill(false)
|
||||
|
||||
// Each term takes a word of its own, longest first, so that "another tag th"
|
||||
// doesn't match "Another Tag" by finding the "th" inside "another"
|
||||
return terms
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.every((term) => {
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
if (!claimed[i] && words[i].includes(term)) {
|
||||
claimed[i] = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
return searchTerms.every((term) => normalizedValue.includes(term))
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export const environment = {
|
||||
apiVersion: '10', // match src/paperless/settings.py
|
||||
appTitle: DEFAULT_APP_TITLE,
|
||||
tag: 'prod',
|
||||
version: '3.1.0',
|
||||
version: '3.0.5',
|
||||
webSocketHost: window.location.host,
|
||||
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
||||
webSocketBaseUrl: base_url.pathname + 'ws/',
|
||||
|
||||
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1136
-2307
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1135
-2306
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1162
-2333
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1325
-2496
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+2389
-3559
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1356
-2526
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1170
-2341
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
+1135
-2306
File diff suppressed because it is too large
Load Diff
+1136
-2307
File diff suppressed because it is too large
Load Diff
+1133
-2304
File diff suppressed because it is too large
Load Diff
@@ -2,12 +2,15 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import pickle
|
||||
import time
|
||||
from binascii import hexlify
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
from hashlib import sha256
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
from typing import Final
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
@@ -16,6 +19,7 @@ from django.core.cache import caches
|
||||
from documents.models import Document
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.cache.backends.base import BaseCache
|
||||
|
||||
from documents.classifier import DocumentClassifier
|
||||
@@ -52,6 +56,9 @@ CLASSIFIER_MODIFIED_KEY: Final[str] = "classifier_modified"
|
||||
# [...]} per taxonomy field (#13676)
|
||||
LLM_CACHE_CLASSIFIER_VERSION: Final[int] = 1001
|
||||
|
||||
# How often a request waiting on llm generation re-checks the cache
|
||||
LLM_SUGGESTION_POLL_INTERVAL: Final[float] = 0.5
|
||||
|
||||
CACHE_1_MINUTE: Final[int] = 60
|
||||
CACHE_5_MINUTES: Final[int] = 5 * CACHE_1_MINUTE
|
||||
CACHE_50_MINUTES: Final[int] = 50 * CACHE_1_MINUTE
|
||||
@@ -223,6 +230,68 @@ def get_llm_suggestion_cache(
|
||||
return None
|
||||
|
||||
|
||||
def retrieve_llm_suggestions(
|
||||
document: Document,
|
||||
user: User | None,
|
||||
output_language: str | None,
|
||||
*,
|
||||
backend: str,
|
||||
lock_timeout: int,
|
||||
) -> dict:
|
||||
"""Return cached LLM suggestions, generating them once across workers."""
|
||||
# Lazy import to avoid pulling in the whole AI stuff
|
||||
from paperless_ai.ai_classifier import get_ai_document_classification
|
||||
from paperless_ai.exceptions import LLMTimeoutError
|
||||
|
||||
lock_key = (
|
||||
f"{get_suggestion_cache_key(document.pk)}_llm_lock_"
|
||||
f"{sha256(backend.encode()).hexdigest()}"
|
||||
)
|
||||
waited = False
|
||||
|
||||
while True:
|
||||
cached = get_llm_suggestion_cache(document.pk, backend=backend)
|
||||
if cached is not None:
|
||||
refresh_suggestions_cache(document.pk)
|
||||
return cached.suggestions
|
||||
|
||||
lock_token = uuid4().hex
|
||||
if cache.add(lock_key, lock_token, lock_timeout):
|
||||
if waited:
|
||||
# The generation we were waiting on has ended without caching
|
||||
# anything so it either failed or outlived its lock. Give up
|
||||
# rather than re-running it
|
||||
cache.delete(lock_key)
|
||||
raise LLMTimeoutError
|
||||
|
||||
try:
|
||||
# The cache may have been populated while acquiring the lock.
|
||||
cached = get_llm_suggestion_cache(document.pk, backend=backend)
|
||||
if cached is not None:
|
||||
refresh_suggestions_cache(document.pk)
|
||||
return cached.suggestions
|
||||
|
||||
suggestions = get_ai_document_classification(
|
||||
document,
|
||||
user,
|
||||
output_language,
|
||||
)
|
||||
set_llm_suggestions_cache(
|
||||
document.pk,
|
||||
suggestions,
|
||||
backend=backend,
|
||||
)
|
||||
return suggestions
|
||||
finally:
|
||||
# Don't remove lock if this one expired while generation was still running
|
||||
if cache.get(lock_key) == lock_token:
|
||||
cache.delete(lock_key)
|
||||
|
||||
waited = True
|
||||
# Another worker is generating suggestions, poll to avoid another LLM request
|
||||
time.sleep(LLM_SUGGESTION_POLL_INTERVAL)
|
||||
|
||||
|
||||
def set_llm_suggestions_cache(
|
||||
document_id: int,
|
||||
suggestions: dict,
|
||||
|
||||
@@ -2486,7 +2486,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
response = self.client.get("/api/documents/34676/suggestions/")
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
@mock.patch("documents.views.get_ai_document_classification")
|
||||
@mock.patch("paperless_ai.ai_classifier.get_ai_document_classification")
|
||||
@override_settings(AI_ENABLED=True)
|
||||
def test_suggestions_still_uses_classifier_when_ai_enabled(
|
||||
self,
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import pickle
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Event
|
||||
from threading import Lock
|
||||
|
||||
import pytest
|
||||
|
||||
from documents.caching import StoredLRUCache
|
||||
from documents.caching import retrieve_llm_suggestions
|
||||
from paperless_ai.exceptions import LLMTimeoutError
|
||||
|
||||
|
||||
def test_lru_cache_entries() -> None:
|
||||
@@ -43,3 +50,119 @@ def test_stored_lru_cache_key_ttl(mocker) -> None:
|
||||
assert key == "test_key"
|
||||
assert timeout == 321
|
||||
assert pickle.loads(data) == {"x": "X", "y": "Y"}
|
||||
|
||||
|
||||
def test_llm_suggestions_are_generated_once_for_concurrent_requests(mocker) -> None:
|
||||
generation_started = Event()
|
||||
finish_generation = Event()
|
||||
waiter_started = Event()
|
||||
call_lock = Lock()
|
||||
calls = 0
|
||||
suggestions = {"title": "Generated once"}
|
||||
document = mocker.Mock(pk=42)
|
||||
user = mocker.Mock()
|
||||
|
||||
def generate(*args) -> dict:
|
||||
nonlocal calls
|
||||
with call_lock:
|
||||
calls += 1
|
||||
generation_started.set()
|
||||
assert finish_generation.wait(timeout=2)
|
||||
return suggestions
|
||||
|
||||
def wait_for_generation(_interval: float) -> None:
|
||||
waiter_started.set()
|
||||
assert finish_generation.wait(timeout=2)
|
||||
|
||||
mock_get_classification = mocker.patch(
|
||||
"paperless_ai.ai_classifier.get_ai_document_classification",
|
||||
side_effect=generate,
|
||||
)
|
||||
mocker.patch("documents.caching.time.sleep", side_effect=wait_for_generation)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
first = executor.submit(
|
||||
retrieve_llm_suggestions,
|
||||
document,
|
||||
user,
|
||||
None,
|
||||
backend="ollama:model",
|
||||
lock_timeout=10,
|
||||
)
|
||||
assert generation_started.wait(timeout=2)
|
||||
second = executor.submit(
|
||||
retrieve_llm_suggestions,
|
||||
document,
|
||||
user,
|
||||
None,
|
||||
backend="ollama:model",
|
||||
lock_timeout=10,
|
||||
)
|
||||
assert waiter_started.wait(timeout=2)
|
||||
finish_generation.set()
|
||||
|
||||
assert first.result(timeout=2) == suggestions
|
||||
assert second.result(timeout=2) == suggestions
|
||||
|
||||
assert calls == 1
|
||||
mock_get_classification.assert_called_once_with(document, user, None)
|
||||
|
||||
|
||||
def test_llm_suggestions_waiter_does_not_rerun_a_failed_generation(mocker) -> None:
|
||||
"""
|
||||
A request queued behind a generation that fails should give up, not take
|
||||
its turn at re-running a query that just failed.
|
||||
"""
|
||||
generation_started = Event()
|
||||
fail_generation = Event()
|
||||
waiter_started = Event()
|
||||
call_lock = Lock()
|
||||
calls = 0
|
||||
document = mocker.Mock(pk=43)
|
||||
user = mocker.Mock()
|
||||
|
||||
def generate(*args) -> dict:
|
||||
nonlocal calls
|
||||
with call_lock:
|
||||
calls += 1
|
||||
generation_started.set()
|
||||
assert fail_generation.wait(timeout=2)
|
||||
raise ValueError("Unknown model")
|
||||
|
||||
def wait_for_generation(_interval: float) -> None:
|
||||
waiter_started.set()
|
||||
assert fail_generation.wait(timeout=2)
|
||||
|
||||
mocker.patch(
|
||||
"paperless_ai.ai_classifier.get_ai_document_classification",
|
||||
side_effect=generate,
|
||||
)
|
||||
mocker.patch("documents.caching.time.sleep", side_effect=wait_for_generation)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
first = executor.submit(
|
||||
retrieve_llm_suggestions,
|
||||
document,
|
||||
user,
|
||||
None,
|
||||
backend="ollama:model",
|
||||
lock_timeout=10,
|
||||
)
|
||||
assert generation_started.wait(timeout=2)
|
||||
second = executor.submit(
|
||||
retrieve_llm_suggestions,
|
||||
document,
|
||||
user,
|
||||
None,
|
||||
backend="ollama:model",
|
||||
lock_timeout=10,
|
||||
)
|
||||
assert waiter_started.wait(timeout=2)
|
||||
fail_generation.set()
|
||||
|
||||
with pytest.raises(ValueError, match="Unknown model"):
|
||||
first.result(timeout=2)
|
||||
with pytest.raises(LLMTimeoutError):
|
||||
second.result(timeout=2)
|
||||
|
||||
assert calls == 1
|
||||
|
||||
@@ -441,7 +441,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
||||
self.assertEqual(response.json()["tags"], [])
|
||||
self.assertEqual(response.json()["suggested_tags"], [])
|
||||
|
||||
@patch("documents.views.get_ai_document_classification")
|
||||
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
|
||||
@override_settings(
|
||||
AI_ENABLED=True,
|
||||
LLM_BACKEND="mock_backend",
|
||||
@@ -491,7 +491,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
||||
None,
|
||||
)
|
||||
|
||||
@patch("documents.views.get_ai_document_classification")
|
||||
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
|
||||
@override_settings(
|
||||
AI_ENABLED=True,
|
||||
LLM_BACKEND="mock_backend",
|
||||
@@ -529,7 +529,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
||||
"KI Title",
|
||||
)
|
||||
|
||||
@patch("documents.views.get_ai_document_classification")
|
||||
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
|
||||
@override_settings(
|
||||
AI_ENABLED=True,
|
||||
LLM_BACKEND="mock_backend",
|
||||
@@ -568,7 +568,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
||||
"Titre IA",
|
||||
)
|
||||
|
||||
@patch("documents.views.get_ai_document_classification")
|
||||
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
|
||||
@override_settings(
|
||||
AI_ENABLED=True,
|
||||
LLM_BACKEND="mock_backend",
|
||||
@@ -604,7 +604,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
||||
),
|
||||
)
|
||||
|
||||
@patch("documents.views.get_ai_document_classification")
|
||||
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
|
||||
@override_settings(
|
||||
AI_ENABLED=True,
|
||||
LLM_BACKEND="openai-like",
|
||||
@@ -633,7 +633,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
||||
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
|
||||
)
|
||||
|
||||
@patch("documents.views.get_ai_document_classification")
|
||||
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
|
||||
@override_settings(
|
||||
AI_ENABLED=True,
|
||||
LLM_BACKEND="openai-like",
|
||||
@@ -660,7 +660,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
||||
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
|
||||
)
|
||||
|
||||
@patch("documents.views.get_ai_document_classification")
|
||||
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
|
||||
@override_settings(
|
||||
AI_ENABLED=True,
|
||||
LLM_BACKEND="mock_backend",
|
||||
@@ -698,7 +698,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
||||
self.assertEqual(response.json()["tags"], [self.tag1.pk])
|
||||
self.assertEqual(response.json()["suggested_tags"], ["Follow-up"])
|
||||
|
||||
@patch("documents.views.get_ai_document_classification")
|
||||
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
|
||||
@override_settings(
|
||||
AI_ENABLED=True,
|
||||
LLM_BACKEND="mock_backend",
|
||||
@@ -737,7 +737,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
||||
self.assertEqual(response.json()["tags"], [self.tag1.pk])
|
||||
self.assertEqual(response.json()["suggested_tags"], [])
|
||||
|
||||
@patch("documents.views.get_ai_document_classification")
|
||||
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
|
||||
@override_settings(
|
||||
AI_ENABLED=True,
|
||||
LLM_BACKEND="mock_backend",
|
||||
|
||||
@@ -5573,41 +5573,6 @@ class TestApplyAISuggestionsWorkflowAction(
|
||||
self.doc.refresh_from_db()
|
||||
return changed
|
||||
|
||||
def test_fields_persist_when_tags_are_applied_in_the_same_run(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A document that already has a filename, as any consumed document does
|
||||
- Suggestions carrying both a document type and tags
|
||||
WHEN:
|
||||
- The suggestions are applied
|
||||
THEN:
|
||||
- The document type is still set after the tags are added
|
||||
|
||||
Adding tags fires m2m_changed, and update_filename_and_move_files
|
||||
refreshes the document from the database. Assigning fields and then
|
||||
adding tags before saving loses those assignments, and only for
|
||||
documents with a filename, so it does not reproduce on a bare
|
||||
Document.objects.create().
|
||||
"""
|
||||
self.doc.filename = "originals/original.pdf"
|
||||
self.doc.save(update_fields=["filename"])
|
||||
|
||||
action = self.make_action(ai_create_missing=True)
|
||||
changed = self.apply(action)
|
||||
|
||||
self.assertIn("document_type", changed)
|
||||
self.assertIn("tags", changed)
|
||||
self.assertIsNotNone(
|
||||
self.doc.document_type,
|
||||
"document_type was reported as applied but did not persist",
|
||||
)
|
||||
self.assertEqual(self.doc.document_type.name, "Suggested Document Type")
|
||||
self.assertEqual(self.doc.correspondent.name, "Existing Correspondent")
|
||||
self.assertCountEqual(
|
||||
[t.name for t in self.doc.tags.all()],
|
||||
["Existing Tag", "Suggested Tag"],
|
||||
)
|
||||
|
||||
def test_document_added_trigger_queues_task(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
|
||||
+8
-11
@@ -115,7 +115,7 @@ from documents.caching import get_metadata_cache
|
||||
from documents.caching import get_suggestion_cache
|
||||
from documents.caching import refresh_metadata_cache
|
||||
from documents.caching import refresh_suggestions_cache
|
||||
from documents.caching import set_llm_suggestions_cache
|
||||
from documents.caching import retrieve_llm_suggestions
|
||||
from documents.caching import set_metadata_cache
|
||||
from documents.caching import set_suggestions_cache
|
||||
from documents.classifier import load_classifier
|
||||
@@ -246,7 +246,6 @@ from paperless.parsers.remote import RemoteEngineConfig
|
||||
from paperless.serialisers import GroupSerializer
|
||||
from paperless.serialisers import UserSerializer
|
||||
from paperless.views import StandardPagination
|
||||
from paperless_ai.ai_classifier import get_ai_document_classification
|
||||
from paperless_ai.ai_classifier import get_llm_output_language
|
||||
from paperless_ai.chat import stream_chat_with_documents
|
||||
from paperless_ai.exceptions import LLMTimeoutError
|
||||
@@ -1560,10 +1559,13 @@ class DocumentViewSet(
|
||||
llm_suggestions = cached_llm_suggestions.suggestions
|
||||
else:
|
||||
try:
|
||||
llm_suggestions = get_ai_document_classification(
|
||||
doc,
|
||||
request.user,
|
||||
output_language,
|
||||
llm_suggestions = retrieve_llm_suggestions(
|
||||
document=doc,
|
||||
user=request.user,
|
||||
output_language=output_language,
|
||||
backend=llm_cache_backend,
|
||||
# Classification, localization + 30s
|
||||
lock_timeout=(2 * ai_config.llm_request_timeout) + 30,
|
||||
)
|
||||
except ValueError as exc:
|
||||
logger.exception(
|
||||
@@ -1588,11 +1590,6 @@ class DocumentViewSet(
|
||||
{"ai": [_("AI backend request timed out.")]},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
set_llm_suggestions_cache(
|
||||
doc.pk,
|
||||
llm_suggestions,
|
||||
backend=llm_cache_backend,
|
||||
)
|
||||
|
||||
tags_choice: TaxonomyChoiceDict = llm_suggestions["tags"]
|
||||
correspondents_choice: TaxonomyChoiceDict = llm_suggestions["correspondents"]
|
||||
|
||||
@@ -226,20 +226,20 @@ def apply_ai_suggestions_to_document(
|
||||
document.created = created
|
||||
updated_fields.append("created")
|
||||
|
||||
tags_to_add: list[Tag] = []
|
||||
if AISuggestionField.TAGS in selected:
|
||||
choice = suggestions["tags"]
|
||||
names = choice["new_names"]
|
||||
tags_to_add = resolve_tags(
|
||||
tags = resolve_tags(
|
||||
names,
|
||||
resolve_tag_ids(choice["existing_ids"], owner)
|
||||
+ match_tags_by_name(names, owner),
|
||||
create_missing=create_missing,
|
||||
owner=owner,
|
||||
)
|
||||
if tags_to_add:
|
||||
if tags:
|
||||
# Suggested tags are always added, so overwrite_existing
|
||||
# does not really apply here
|
||||
document.add_nested_tags(tags)
|
||||
updated_fields.append("tags")
|
||||
|
||||
if updated_fields:
|
||||
@@ -249,10 +249,6 @@ def apply_ai_suggestions_to_document(
|
||||
]
|
||||
document.save(update_fields=[*direct_updated_fields, "modified"])
|
||||
|
||||
# Tags at the end so m2m_changed doesn't trigger db and overwrite other changes
|
||||
if tags_to_add:
|
||||
document.add_nested_tags(tags_to_add)
|
||||
|
||||
logger.info(
|
||||
"Applied AI suggestions %s to document %s",
|
||||
updated_fields or "(none)",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user