mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-27 21:23:20 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2250ef96d | ||
|
|
50043ddb66 | ||
|
|
15a5618a6e | ||
|
|
34a01a5bd6 |
@@ -2,12 +2,15 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import pickle
|
import pickle
|
||||||
|
import time
|
||||||
from binascii import hexlify
|
from binascii import hexlify
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from hashlib import sha256
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
@@ -16,6 +19,7 @@ from django.core.cache import caches
|
|||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from django.contrib.auth.models import User
|
||||||
from django.core.cache.backends.base import BaseCache
|
from django.core.cache.backends.base import BaseCache
|
||||||
|
|
||||||
from documents.classifier import DocumentClassifier
|
from documents.classifier import DocumentClassifier
|
||||||
@@ -52,6 +56,9 @@ CLASSIFIER_MODIFIED_KEY: Final[str] = "classifier_modified"
|
|||||||
# [...]} per taxonomy field (#13676)
|
# [...]} per taxonomy field (#13676)
|
||||||
LLM_CACHE_CLASSIFIER_VERSION: Final[int] = 1001
|
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_1_MINUTE: Final[int] = 60
|
||||||
CACHE_5_MINUTES: Final[int] = 5 * CACHE_1_MINUTE
|
CACHE_5_MINUTES: Final[int] = 5 * CACHE_1_MINUTE
|
||||||
CACHE_50_MINUTES: Final[int] = 50 * CACHE_1_MINUTE
|
CACHE_50_MINUTES: Final[int] = 50 * CACHE_1_MINUTE
|
||||||
@@ -223,6 +230,68 @@ def get_llm_suggestion_cache(
|
|||||||
return None
|
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(
|
def set_llm_suggestions_cache(
|
||||||
document_id: int,
|
document_id: int,
|
||||||
suggestions: dict,
|
suggestions: dict,
|
||||||
|
|||||||
@@ -2486,7 +2486,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
response = self.client.get("/api/documents/34676/suggestions/")
|
response = self.client.get("/api/documents/34676/suggestions/")
|
||||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
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)
|
@override_settings(AI_ENABLED=True)
|
||||||
def test_suggestions_still_uses_classifier_when_ai_enabled(
|
def test_suggestions_still_uses_classifier_when_ai_enabled(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import pickle
|
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 StoredLRUCache
|
||||||
|
from documents.caching import retrieve_llm_suggestions
|
||||||
|
from paperless_ai.exceptions import LLMTimeoutError
|
||||||
|
|
||||||
|
|
||||||
def test_lru_cache_entries() -> None:
|
def test_lru_cache_entries() -> None:
|
||||||
@@ -43,3 +50,119 @@ def test_stored_lru_cache_key_ttl(mocker) -> None:
|
|||||||
assert key == "test_key"
|
assert key == "test_key"
|
||||||
assert timeout == 321
|
assert timeout == 321
|
||||||
assert pickle.loads(data) == {"x": "X", "y": "Y"}
|
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()["tags"], [])
|
||||||
self.assertEqual(response.json()["suggested_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(
|
@override_settings(
|
||||||
AI_ENABLED=True,
|
AI_ENABLED=True,
|
||||||
LLM_BACKEND="mock_backend",
|
LLM_BACKEND="mock_backend",
|
||||||
@@ -491,7 +491,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@patch("documents.views.get_ai_document_classification")
|
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
|
||||||
@override_settings(
|
@override_settings(
|
||||||
AI_ENABLED=True,
|
AI_ENABLED=True,
|
||||||
LLM_BACKEND="mock_backend",
|
LLM_BACKEND="mock_backend",
|
||||||
@@ -529,7 +529,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
"KI Title",
|
"KI Title",
|
||||||
)
|
)
|
||||||
|
|
||||||
@patch("documents.views.get_ai_document_classification")
|
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
|
||||||
@override_settings(
|
@override_settings(
|
||||||
AI_ENABLED=True,
|
AI_ENABLED=True,
|
||||||
LLM_BACKEND="mock_backend",
|
LLM_BACKEND="mock_backend",
|
||||||
@@ -568,7 +568,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
"Titre IA",
|
"Titre IA",
|
||||||
)
|
)
|
||||||
|
|
||||||
@patch("documents.views.get_ai_document_classification")
|
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
|
||||||
@override_settings(
|
@override_settings(
|
||||||
AI_ENABLED=True,
|
AI_ENABLED=True,
|
||||||
LLM_BACKEND="mock_backend",
|
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(
|
@override_settings(
|
||||||
AI_ENABLED=True,
|
AI_ENABLED=True,
|
||||||
LLM_BACKEND="openai-like",
|
LLM_BACKEND="openai-like",
|
||||||
@@ -633,7 +633,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
|
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(
|
@override_settings(
|
||||||
AI_ENABLED=True,
|
AI_ENABLED=True,
|
||||||
LLM_BACKEND="openai-like",
|
LLM_BACKEND="openai-like",
|
||||||
@@ -660,7 +660,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
|
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(
|
@override_settings(
|
||||||
AI_ENABLED=True,
|
AI_ENABLED=True,
|
||||||
LLM_BACKEND="mock_backend",
|
LLM_BACKEND="mock_backend",
|
||||||
@@ -698,7 +698,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
self.assertEqual(response.json()["tags"], [self.tag1.pk])
|
self.assertEqual(response.json()["tags"], [self.tag1.pk])
|
||||||
self.assertEqual(response.json()["suggested_tags"], ["Follow-up"])
|
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(
|
@override_settings(
|
||||||
AI_ENABLED=True,
|
AI_ENABLED=True,
|
||||||
LLM_BACKEND="mock_backend",
|
LLM_BACKEND="mock_backend",
|
||||||
@@ -737,7 +737,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
self.assertEqual(response.json()["tags"], [self.tag1.pk])
|
self.assertEqual(response.json()["tags"], [self.tag1.pk])
|
||||||
self.assertEqual(response.json()["suggested_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(
|
@override_settings(
|
||||||
AI_ENABLED=True,
|
AI_ENABLED=True,
|
||||||
LLM_BACKEND="mock_backend",
|
LLM_BACKEND="mock_backend",
|
||||||
|
|||||||
+8
-11
@@ -115,7 +115,7 @@ from documents.caching import get_metadata_cache
|
|||||||
from documents.caching import get_suggestion_cache
|
from documents.caching import get_suggestion_cache
|
||||||
from documents.caching import refresh_metadata_cache
|
from documents.caching import refresh_metadata_cache
|
||||||
from documents.caching import refresh_suggestions_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_metadata_cache
|
||||||
from documents.caching import set_suggestions_cache
|
from documents.caching import set_suggestions_cache
|
||||||
from documents.classifier import load_classifier
|
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 GroupSerializer
|
||||||
from paperless.serialisers import UserSerializer
|
from paperless.serialisers import UserSerializer
|
||||||
from paperless.views import StandardPagination
|
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.ai_classifier import get_llm_output_language
|
||||||
from paperless_ai.chat import stream_chat_with_documents
|
from paperless_ai.chat import stream_chat_with_documents
|
||||||
from paperless_ai.exceptions import LLMTimeoutError
|
from paperless_ai.exceptions import LLMTimeoutError
|
||||||
@@ -1560,10 +1559,13 @@ class DocumentViewSet(
|
|||||||
llm_suggestions = cached_llm_suggestions.suggestions
|
llm_suggestions = cached_llm_suggestions.suggestions
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
llm_suggestions = get_ai_document_classification(
|
llm_suggestions = retrieve_llm_suggestions(
|
||||||
doc,
|
document=doc,
|
||||||
request.user,
|
user=request.user,
|
||||||
output_language,
|
output_language=output_language,
|
||||||
|
backend=llm_cache_backend,
|
||||||
|
# Classification, localization + 30s
|
||||||
|
lock_timeout=(2 * ai_config.llm_request_timeout) + 30,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
@@ -1588,11 +1590,6 @@ class DocumentViewSet(
|
|||||||
{"ai": [_("AI backend request timed out.")]},
|
{"ai": [_("AI backend request timed out.")]},
|
||||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
)
|
)
|
||||||
set_llm_suggestions_cache(
|
|
||||||
doc.pk,
|
|
||||||
llm_suggestions,
|
|
||||||
backend=llm_cache_backend,
|
|
||||||
)
|
|
||||||
|
|
||||||
tags_choice: TaxonomyChoiceDict = llm_suggestions["tags"]
|
tags_choice: TaxonomyChoiceDict = llm_suggestions["tags"]
|
||||||
correspondents_choice: TaxonomyChoiceDict = llm_suggestions["correspondents"]
|
correspondents_choice: TaxonomyChoiceDict = llm_suggestions["correspondents"]
|
||||||
|
|||||||
Reference in New Issue
Block a user