mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-26 20:53:20 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4068d38bc | ||
|
|
139739c2a2 | ||
|
|
cafed919a1 | ||
|
|
549afde1fd | ||
|
|
a8576d827e | ||
|
|
7330c4d9cb | ||
|
|
2e7cd14964 | ||
|
|
5090ffe39e | ||
|
|
35d40b51cb | ||
|
|
78025df405 |
@@ -667,6 +667,35 @@ The action takes no options, its presence is what enables remote OCR for a match
|
|||||||
If the remote engine is not configured, or does not support the document's file type, the document is
|
If the remote engine is not configured, or does not support the document's file type, the document is
|
||||||
processed locally instead and a warning is written to the log.
|
processed locally instead and a warning is written to the log.
|
||||||
|
|
||||||
|
##### Apply AI Suggestions {#workflow-action-apply-ai-suggestions}
|
||||||
|
|
||||||
|
"Apply AI Suggestions" actions ask the configured AI service for title and metadata suggestions,
|
||||||
|
the same as the AI suggestions shown on the document detail page, except applied automatically and in bulk.
|
||||||
|
It requires [AI features](configuration.md#ai) to be enabled. You can specify:
|
||||||
|
|
||||||
|
- Which suggestions to apply: title, tags, correspondent, document type, storage path and / or created
|
||||||
|
date. Suggestions for fields you did not select are discarded.
|
||||||
|
- Whether to create missing items. By default only tags, correspondents and document types that
|
||||||
|
already exist are assigned and any other suggestion is dropped. With this enabled, suggested items
|
||||||
|
that do not exist are created. Storage paths are never created.
|
||||||
|
- Whether to overwrite existing values. By default a field is only filled in if it is currently empty.
|
||||||
|
Note that documents almost always already have a title and created date, so if you select those you
|
||||||
|
will usually want to enable this too. Tags are an exception: suggested tags are always added and
|
||||||
|
never replace the document's existing tags.
|
||||||
|
|
||||||
|
The action works with every trigger **except Consumption Started**, because suggestions are made from
|
||||||
|
the document's text, which does not exist until after the document has been processed.
|
||||||
|
|
||||||
|
Because the query to the AI service is slow, the action is queued and runs in the background rather
|
||||||
|
than as part of the workflow run itself. The document is updated once the suggestions come back.
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
|
||||||
|
Every matching document results in a query to the AI service, which may incur costs and have privacy
|
||||||
|
implications. Queries can be slow, so a workflow matching a large number of documents can occupy the
|
||||||
|
task queue, and delay consumption of new documents, etc. Consider narrowing the trigger filters,
|
||||||
|
running in small batches and / or increasing workers.
|
||||||
|
|
||||||
#### Workflow placeholders
|
#### Workflow placeholders
|
||||||
|
|
||||||
Titles and webhook payloads can be generated by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
|
Titles and webhook payloads can be generated by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
|
||||||
|
|||||||
+203
-120
@@ -296,11 +296,11 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">93</context>
|
<context context-type="linenumber">89</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">95</context>
|
<context context-type="linenumber">91</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/dashboard/dashboard.component.html</context>
|
<context context-type="sourcefile">src/app/components/dashboard/dashboard.component.html</context>
|
||||||
@@ -323,11 +323,11 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">102</context>
|
<context context-type="linenumber">98</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">104</context>
|
<context context-type="linenumber">100</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html</context>
|
||||||
@@ -374,15 +374,15 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">60</context>
|
<context context-type="linenumber">56</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">284</context>
|
<context context-type="linenumber">280</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">286</context>
|
<context context-type="linenumber">282</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5890330709052835856" datatype="html">
|
<trans-unit id="5890330709052835856" datatype="html">
|
||||||
@@ -727,11 +727,11 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">320</context>
|
<context context-type="linenumber">316</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">322</context>
|
<context context-type="linenumber">318</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2272120016352772836" datatype="html">
|
<trans-unit id="2272120016352772836" datatype="html">
|
||||||
@@ -1142,11 +1142,11 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">244</context>
|
<context context-type="linenumber">240</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">246</context>
|
<context context-type="linenumber">242</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.html</context>
|
||||||
@@ -1620,6 +1620,10 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
|
||||||
<context context-type="linenumber">99</context>
|
<context context-type="linenumber">99</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
|
<context context-type="linenumber">173</context>
|
||||||
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">174</context>
|
<context context-type="linenumber">174</context>
|
||||||
@@ -1651,6 +1655,10 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
|
||||||
<context context-type="linenumber">100</context>
|
<context context-type="linenumber">100</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
|
<context context-type="linenumber">177</context>
|
||||||
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">178</context>
|
<context context-type="linenumber">178</context>
|
||||||
@@ -1682,6 +1690,10 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
|
||||||
<context context-type="linenumber">101</context>
|
<context context-type="linenumber">101</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
|
<context context-type="linenumber">181</context>
|
||||||
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">182</context>
|
<context context-type="linenumber">182</context>
|
||||||
@@ -1711,7 +1723,11 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">215</context>
|
<context context-type="linenumber">211</context>
|
||||||
|
</context-group>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
|
<context context-type="linenumber">169</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/input/tags/tags.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/input/tags/tags.component.ts</context>
|
||||||
@@ -1801,15 +1817,15 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
|
||||||
<context context-type="linenumber">162</context>
|
<context context-type="linenumber">164</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
|
||||||
<context context-type="linenumber">274</context>
|
<context context-type="linenumber">276</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
|
||||||
<context context-type="linenumber">304</context>
|
<context context-type="linenumber">306</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6904866445262015585" datatype="html">
|
<trans-unit id="6904866445262015585" datatype="html">
|
||||||
@@ -1820,11 +1836,11 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">307</context>
|
<context context-type="linenumber">303</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">309</context>
|
<context context-type="linenumber">305</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8492095365580052820" datatype="html">
|
<trans-unit id="8492095365580052820" datatype="html">
|
||||||
@@ -2087,7 +2103,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">95</context>
|
<context context-type="linenumber">96</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html</context>
|
||||||
@@ -2402,7 +2418,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">80</context>
|
<context context-type="linenumber">81</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="228975554646076615" datatype="html">
|
<trans-unit id="228975554646076615" datatype="html">
|
||||||
@@ -2462,7 +2478,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">122</context>
|
<context context-type="linenumber">123</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="235571817610183244" datatype="html">
|
<trans-unit id="235571817610183244" datatype="html">
|
||||||
@@ -2473,7 +2489,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">84</context>
|
<context context-type="linenumber">85</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="526966086395145275" datatype="html">
|
<trans-unit id="526966086395145275" datatype="html">
|
||||||
@@ -2484,7 +2500,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">76</context>
|
<context context-type="linenumber">77</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6520092939021340973" datatype="html">
|
<trans-unit id="6520092939021340973" datatype="html">
|
||||||
@@ -2601,11 +2617,11 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">268</context>
|
<context context-type="linenumber">264</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">270</context>
|
<context context-type="linenumber">266</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3818027200170621545" datatype="html">
|
<trans-unit id="3818027200170621545" datatype="html">
|
||||||
@@ -2962,11 +2978,11 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">298</context>
|
<context context-type="linenumber">294</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">300</context>
|
<context context-type="linenumber">296</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4569276013106377105" datatype="html">
|
<trans-unit id="4569276013106377105" datatype="html">
|
||||||
@@ -3275,104 +3291,104 @@
|
|||||||
<source>by Paperless-ngx</source>
|
<source>by Paperless-ngx</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">27,28</context>
|
<context context-type="linenumber">23,24</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7228136119811576789" datatype="html">
|
<trans-unit id="7228136119811576789" datatype="html">
|
||||||
<source>User menu</source>
|
<source>User menu</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">44</context>
|
<context context-type="linenumber">40</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2448391510242468907" datatype="html">
|
<trans-unit id="2448391510242468907" datatype="html">
|
||||||
<source>Logged in as <x id="INTERPOLATION" equiv-text="{{this.settingsService.displayName}}"/></source>
|
<source>Logged in as <x id="INTERPOLATION" equiv-text="{{this.settingsService.displayName}}"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">52,53</context>
|
<context context-type="linenumber">48,49</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2127032578120864096" datatype="html">
|
<trans-unit id="2127032578120864096" datatype="html">
|
||||||
<source>My Profile</source>
|
<source>My Profile</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">56</context>
|
<context context-type="linenumber">52</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3797778920049399855" datatype="html">
|
<trans-unit id="3797778920049399855" datatype="html">
|
||||||
<source>Logout</source>
|
<source>Logout</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">63</context>
|
<context context-type="linenumber">59</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4895326106573044490" datatype="html">
|
<trans-unit id="4895326106573044490" datatype="html">
|
||||||
<source>Documentation</source>
|
<source>Documentation</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">68</context>
|
<context context-type="linenumber">64</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">328,329</context>
|
<context context-type="linenumber">324,325</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">331</context>
|
<context context-type="linenumber">327</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="472206565520537964" datatype="html">
|
<trans-unit id="472206565520537964" datatype="html">
|
||||||
<source>Saved views</source>
|
<source>Saved views</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">112,113</context>
|
<context context-type="linenumber">108,109</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">142,143</context>
|
<context context-type="linenumber">138,139</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6988090220128974198" datatype="html">
|
<trans-unit id="6988090220128974198" datatype="html">
|
||||||
<source>Open documents</source>
|
<source>Open documents</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">151,152</context>
|
<context context-type="linenumber">147,148</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5687256342387781369" datatype="html">
|
<trans-unit id="5687256342387781369" datatype="html">
|
||||||
<source>Close all</source>
|
<source>Close all</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">173</context>
|
<context context-type="linenumber">169</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">175</context>
|
<context context-type="linenumber">171</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3897348120591552265" datatype="html">
|
<trans-unit id="3897348120591552265" datatype="html">
|
||||||
<source>Manage</source>
|
<source>Manage</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">184,185</context>
|
<context context-type="linenumber">180,181</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8008131619909556709" datatype="html">
|
<trans-unit id="8008131619909556709" datatype="html">
|
||||||
<source>Attributes</source>
|
<source>Attributes</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">192</context>
|
<context context-type="linenumber">188</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">194</context>
|
<context context-type="linenumber">190</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7437910965833684826" datatype="html">
|
<trans-unit id="7437910965833684826" datatype="html">
|
||||||
<source>Correspondents</source>
|
<source>Correspondents</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">220</context>
|
<context context-type="linenumber">216</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
|
||||||
@@ -3391,7 +3407,7 @@
|
|||||||
<source>Document types</source>
|
<source>Document types</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">225</context>
|
<context context-type="linenumber">221</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.ts</context>
|
||||||
@@ -3402,7 +3418,7 @@
|
|||||||
<source>Storage paths</source>
|
<source>Storage paths</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">230</context>
|
<context context-type="linenumber">226</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.ts</context>
|
||||||
@@ -3413,7 +3429,7 @@
|
|||||||
<source>Custom fields</source>
|
<source>Custom fields</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">235</context>
|
<context context-type="linenumber">231</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
|
||||||
@@ -3432,11 +3448,11 @@
|
|||||||
<source>Workflows</source>
|
<source>Workflows</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">253</context>
|
<context context-type="linenumber">249</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">255</context>
|
<context context-type="linenumber">251</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
|
||||||
@@ -3447,99 +3463,99 @@
|
|||||||
<source>Mail</source>
|
<source>Mail</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">261</context>
|
<context context-type="linenumber">257</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">263</context>
|
<context context-type="linenumber">259</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7844706011418789951" datatype="html">
|
<trans-unit id="7844706011418789951" datatype="html">
|
||||||
<source>Administration</source>
|
<source>Administration</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">278,279</context>
|
<context context-type="linenumber">274,275</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3008420115644088420" datatype="html">
|
<trans-unit id="3008420115644088420" datatype="html">
|
||||||
<source>Configuration</source>
|
<source>Configuration</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">291</context>
|
<context context-type="linenumber">287</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">293</context>
|
<context context-type="linenumber">289</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1534029177398918729" datatype="html">
|
<trans-unit id="1534029177398918729" datatype="html">
|
||||||
<source>GitHub</source>
|
<source>GitHub</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">338</context>
|
<context context-type="linenumber">334</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4112664765954374539" datatype="html">
|
<trans-unit id="4112664765954374539" datatype="html">
|
||||||
<source>is available.</source>
|
<source>is available.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">348</context>
|
<context context-type="linenumber">344</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1175891574282637937" datatype="html">
|
<trans-unit id="1175891574282637937" datatype="html">
|
||||||
<source>Click to view.</source>
|
<source>Click to view.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">348</context>
|
<context context-type="linenumber">344</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="9811291095862612" datatype="html">
|
<trans-unit id="9811291095862612" datatype="html">
|
||||||
<source>Paperless-ngx can automatically check for updates</source>
|
<source>Paperless-ngx can automatically check for updates</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">352</context>
|
<context context-type="linenumber">348</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="894819944961861800" datatype="html">
|
<trans-unit id="894819944961861800" datatype="html">
|
||||||
<source> How does this work? </source>
|
<source> How does this work? </source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">360</context>
|
<context context-type="linenumber">356</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="509090351011426949" datatype="html">
|
<trans-unit id="509090351011426949" datatype="html">
|
||||||
<source>Update available</source>
|
<source>Update available</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">372</context>
|
<context context-type="linenumber">368</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1329827712962827905" datatype="html">
|
<trans-unit id="1329827712962827905" datatype="html">
|
||||||
<source>Configure update checking</source>
|
<source>Configure update checking</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||||
<context context-type="linenumber">377</context>
|
<context context-type="linenumber">373</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1542489069631984294" datatype="html">
|
<trans-unit id="1542489069631984294" datatype="html">
|
||||||
<source>Sidebar views updated</source>
|
<source>Sidebar views updated</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
|
||||||
<context context-type="linenumber">425</context>
|
<context context-type="linenumber">427</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3547923076537026828" datatype="html">
|
<trans-unit id="3547923076537026828" datatype="html">
|
||||||
<source>Error updating sidebar views</source>
|
<source>Error updating sidebar views</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
|
||||||
<context context-type="linenumber">428</context>
|
<context context-type="linenumber">430</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2526035785704676448" datatype="html">
|
<trans-unit id="2526035785704676448" datatype="html">
|
||||||
<source>An error occurred while saving update checking settings.</source>
|
<source>An error occurred while saving update checking settings.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
|
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
|
||||||
<context context-type="linenumber">449</context>
|
<context context-type="linenumber">451</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4580988005648117665" datatype="html">
|
<trans-unit id="4580988005648117665" datatype="html">
|
||||||
@@ -4332,7 +4348,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">91</context>
|
<context context-type="linenumber">92</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||||
@@ -5322,7 +5338,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">137</context>
|
<context context-type="linenumber">138</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
|
||||||
@@ -5954,18 +5970,67 @@
|
|||||||
<context context-type="linenumber">461,462</context>
|
<context context-type="linenumber">461,462</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="4231754882471903566" datatype="html">
|
||||||
|
<source>The document will be sent to the configured AI service for suggestions. Consider costs and privacy.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
|
||||||
|
<context context-type="linenumber">468,469</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="3473589590476379802" datatype="html">
|
||||||
|
<source>Apply suggestions for</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
|
||||||
|
<context context-type="linenumber">471,472</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="6859826074028847234" datatype="html">
|
||||||
|
<source>Suggestions for fields that are not selected are discarded.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
|
||||||
|
<context context-type="linenumber">476,477</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="1137167921211640884" datatype="html">
|
||||||
|
<source>Create missing items</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
|
||||||
|
<context context-type="linenumber">486,487</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="449779688394460071" datatype="html">
|
||||||
|
<source>Create suggested tags, correspondents and document types that do not exist yet.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
|
||||||
|
<context context-type="linenumber">488,489</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="5192325026233872238" datatype="html">
|
||||||
|
<source>Overwrite existing values</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
|
||||||
|
<context context-type="linenumber">496,497</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="9010697250406492150" datatype="html">
|
||||||
|
<source>Apply suggestions even if the document already has a value. Tags are always added, never replaced.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
|
||||||
|
<context context-type="linenumber">498,499</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="4626030417479279989" datatype="html">
|
<trans-unit id="4626030417479279989" datatype="html">
|
||||||
<source>Consume Folder</source>
|
<source>Consume Folder</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">72</context>
|
<context context-type="linenumber">73</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3553216189604488439" datatype="html">
|
<trans-unit id="3553216189604488439" datatype="html">
|
||||||
<source>Modified</source>
|
<source>Modified</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">99</context>
|
<context context-type="linenumber">100</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/document.ts</context>
|
<context context-type="sourcefile">src/app/data/document.ts</context>
|
||||||
@@ -5976,63 +6041,63 @@
|
|||||||
<source>Custom Field</source>
|
<source>Custom Field</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">103</context>
|
<context context-type="linenumber">104</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8696908693776094667" datatype="html">
|
<trans-unit id="8696908693776094667" datatype="html">
|
||||||
<source>Consumption Started</source>
|
<source>Consumption Started</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">110</context>
|
<context context-type="linenumber">111</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7858311467093621703" datatype="html">
|
<trans-unit id="7858311467093621703" datatype="html">
|
||||||
<source>Document Added</source>
|
<source>Document Added</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">114</context>
|
<context context-type="linenumber">115</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7955486237346046731" datatype="html">
|
<trans-unit id="7955486237346046731" datatype="html">
|
||||||
<source>Document Updated</source>
|
<source>Document Updated</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">118</context>
|
<context context-type="linenumber">119</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5502398334173581061" datatype="html">
|
<trans-unit id="5502398334173581061" datatype="html">
|
||||||
<source>Assignment</source>
|
<source>Assignment</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">129</context>
|
<context context-type="linenumber">130</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6234812824772766804" datatype="html">
|
<trans-unit id="6234812824772766804" datatype="html">
|
||||||
<source>Removal</source>
|
<source>Removal</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">133</context>
|
<context context-type="linenumber">134</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4206419737792796794" datatype="html">
|
<trans-unit id="4206419737792796794" datatype="html">
|
||||||
<source>Webhook</source>
|
<source>Webhook</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">141</context>
|
<context context-type="linenumber">142</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4824906895380506720" datatype="html">
|
<trans-unit id="4824906895380506720" datatype="html">
|
||||||
<source>Password removal</source>
|
<source>Password removal</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">145</context>
|
<context context-type="linenumber">146</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2048798344356757326" datatype="html">
|
<trans-unit id="2048798344356757326" datatype="html">
|
||||||
<source>Move to trash</source>
|
<source>Move to trash</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">149</context>
|
<context context-type="linenumber">150</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>
|
||||||
@@ -6047,116 +6112,157 @@
|
|||||||
<source>Remote OCR</source>
|
<source>Remote OCR</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">153</context>
|
<context context-type="linenumber">154</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
<context context-type="sourcefile">src/app/data/paperless-config.ts</context>
|
||||||
<context context-type="linenumber">58</context>
|
<context context-type="linenumber">58</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="4101630920511881483" datatype="html">
|
||||||
|
<source>Apply AI suggestions</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
|
<context context-type="linenumber">158</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="5701618810648052610" datatype="html">
|
||||||
|
<source>Title</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
|
<context context-type="linenumber">165</context>
|
||||||
|
</context-group>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
|
<context context-type="linenumber">167</context>
|
||||||
|
</context-group>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||||
|
<context context-type="linenumber">218,219</context>
|
||||||
|
</context-group>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||||
|
<context context-type="linenumber">197</context>
|
||||||
|
</context-group>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/data/document.ts</context>
|
||||||
|
<context context-type="linenumber">30</context>
|
||||||
|
</context-group>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/data/document.ts</context>
|
||||||
|
<context context-type="linenumber">90</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="5224451373257709572" datatype="html">
|
||||||
|
<source>Created date</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
|
<context context-type="linenumber">185</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="4522609911791833187" datatype="html">
|
<trans-unit id="4522609911791833187" datatype="html">
|
||||||
<source>Has any of these tags</source>
|
<source>Has any of these tags</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">222</context>
|
<context context-type="linenumber">254</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4166903555074156852" datatype="html">
|
<trans-unit id="4166903555074156852" datatype="html">
|
||||||
<source>Has all of these tags</source>
|
<source>Has all of these tags</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">229</context>
|
<context context-type="linenumber">261</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6624363795312783141" datatype="html">
|
<trans-unit id="6624363795312783141" datatype="html">
|
||||||
<source>Does not have these tags</source>
|
<source>Does not have these tags</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">236</context>
|
<context context-type="linenumber">268</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7168528512669831184" datatype="html">
|
<trans-unit id="7168528512669831184" datatype="html">
|
||||||
<source>Has any of these correspondents</source>
|
<source>Has any of these correspondents</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">243</context>
|
<context context-type="linenumber">275</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5281365940563983618" datatype="html">
|
<trans-unit id="5281365940563983618" datatype="html">
|
||||||
<source>Has correspondent</source>
|
<source>Has correspondent</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">251</context>
|
<context context-type="linenumber">283</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6884498632428600393" datatype="html">
|
<trans-unit id="6884498632428600393" datatype="html">
|
||||||
<source>Does not have correspondents</source>
|
<source>Does not have correspondents</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">259</context>
|
<context context-type="linenumber">291</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4806713133917046341" datatype="html">
|
<trans-unit id="4806713133917046341" datatype="html">
|
||||||
<source>Has document type</source>
|
<source>Has document type</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">267</context>
|
<context context-type="linenumber">299</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8801397520369995032" datatype="html">
|
<trans-unit id="8801397520369995032" datatype="html">
|
||||||
<source>Has any of these document types</source>
|
<source>Has any of these document types</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">275</context>
|
<context context-type="linenumber">307</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1507843981661822403" datatype="html">
|
<trans-unit id="1507843981661822403" datatype="html">
|
||||||
<source>Does not have document types</source>
|
<source>Does not have document types</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">283</context>
|
<context context-type="linenumber">315</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4277260190522078330" datatype="html">
|
<trans-unit id="4277260190522078330" datatype="html">
|
||||||
<source>Has storage path</source>
|
<source>Has storage path</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">291</context>
|
<context context-type="linenumber">323</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8858580062214623097" datatype="html">
|
<trans-unit id="8858580062214623097" datatype="html">
|
||||||
<source>Has any of these storage paths</source>
|
<source>Has any of these storage paths</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">299</context>
|
<context context-type="linenumber">331</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6070943364927280151" datatype="html">
|
<trans-unit id="6070943364927280151" datatype="html">
|
||||||
<source>Does not have storage paths</source>
|
<source>Does not have storage paths</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">307</context>
|
<context context-type="linenumber">339</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6250799006816371860" datatype="html">
|
<trans-unit id="6250799006816371860" datatype="html">
|
||||||
<source>Matches custom field query</source>
|
<source>Matches custom field query</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">315</context>
|
<context context-type="linenumber">347</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3138206142174978019" datatype="html">
|
<trans-unit id="3138206142174978019" datatype="html">
|
||||||
<source>Create new workflow</source>
|
<source>Create new workflow</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">524</context>
|
<context context-type="linenumber">556</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5996779210524133604" datatype="html">
|
<trans-unit id="5996779210524133604" datatype="html">
|
||||||
<source>Edit workflow</source>
|
<source>Edit workflow</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">528</context>
|
<context context-type="linenumber">560</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5457837313196342910" datatype="html">
|
<trans-unit id="5457837313196342910" datatype="html">
|
||||||
@@ -8021,35 +8127,35 @@
|
|||||||
<source>Paperless-ngx is running!</source>
|
<source>Paperless-ngx is running!</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/dashboard/widgets/welcome-widget/welcome-widget.component.html</context>
|
<context context-type="sourcefile">src/app/components/dashboard/widgets/welcome-widget/welcome-widget.component.html</context>
|
||||||
<context context-type="linenumber">2</context>
|
<context context-type="linenumber">6</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3326049540711826572" datatype="html">
|
<trans-unit id="3326049540711826572" datatype="html">
|
||||||
<source>You're ready to start uploading documents! Explore the various features of this web app on your own, or start a quick tour using the button below.</source>
|
<source>You're ready to start uploading documents! Explore the various features of this web app on your own, or start a quick tour using the button below.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/dashboard/widgets/welcome-widget/welcome-widget.component.html</context>
|
<context context-type="sourcefile">src/app/components/dashboard/widgets/welcome-widget/welcome-widget.component.html</context>
|
||||||
<context context-type="linenumber">3,4</context>
|
<context context-type="linenumber">8,9</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4474647174688421179" datatype="html">
|
<trans-unit id="4474647174688421179" datatype="html">
|
||||||
<source>More detail on how to use and configure Paperless-ngx is always available in the <x id="START_LINK" ctype="x-a" equiv-text="<a href="https://docs.paperless-ngx.com" target="_blank">"/>documentation<x id="CLOSE_LINK" ctype="x-a" equiv-text="</a>"/>.</source>
|
<source>More detail on how to use and configure Paperless-ngx is always available in the <x id="START_LINK" ctype="x-a" equiv-text="<a href="https://docs.paperless-ngx.com" target="_blank">"/>documentation<x id="CLOSE_LINK" ctype="x-a" equiv-text="</a>"/>.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/dashboard/widgets/welcome-widget/welcome-widget.component.html</context>
|
<context context-type="sourcefile">src/app/components/dashboard/widgets/welcome-widget/welcome-widget.component.html</context>
|
||||||
<context context-type="linenumber">4,6</context>
|
<context context-type="linenumber">9,10</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4294899532887357745" datatype="html">
|
<trans-unit id="4294899532887357745" datatype="html">
|
||||||
<source>Thanks for being a part of the Paperless-ngx community!</source>
|
<source>Thanks for being a part of the Paperless-ngx community!</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/dashboard/widgets/welcome-widget/welcome-widget.component.html</context>
|
<context context-type="sourcefile">src/app/components/dashboard/widgets/welcome-widget/welcome-widget.component.html</context>
|
||||||
<context context-type="linenumber">7,8</context>
|
<context context-type="linenumber">13,14</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1415832194529539652" datatype="html">
|
<trans-unit id="1415832194529539652" datatype="html">
|
||||||
<source>Start the tour</source>
|
<source>Start the tour</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/dashboard/widgets/welcome-widget/welcome-widget.component.html</context>
|
<context context-type="sourcefile">src/app/components/dashboard/widgets/welcome-widget/welcome-widget.component.html</context>
|
||||||
<context context-type="linenumber">8</context>
|
<context context-type="linenumber">14</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7822640317427130239" datatype="html">
|
<trans-unit id="7822640317427130239" datatype="html">
|
||||||
@@ -8208,29 +8314,6 @@
|
|||||||
<context context-type="linenumber">164,165</context>
|
<context context-type="linenumber">164,165</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5701618810648052610" datatype="html">
|
|
||||||
<source>Title</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
|
||||||
<context context-type="linenumber">167</context>
|
|
||||||
</context-group>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
|
||||||
<context context-type="linenumber">218,219</context>
|
|
||||||
</context-group>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
|
||||||
<context context-type="linenumber">197</context>
|
|
||||||
</context-group>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/data/document.ts</context>
|
|
||||||
<context context-type="linenumber">30</context>
|
|
||||||
</context-group>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/data/document.ts</context>
|
|
||||||
<context context-type="linenumber">90</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="5114742157723900905" datatype="html">
|
<trans-unit id="5114742157723900905" datatype="html">
|
||||||
<source>Date created</source>
|
<source>Date created</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
|
|||||||
@@ -10,16 +10,12 @@
|
|||||||
tourAnchor="tour.intro">
|
tourAnchor="tour.intro">
|
||||||
@if (!hasCustomBranding) {
|
@if (!hasCustomBranding) {
|
||||||
<pngx-logo extra_classes="navbar-official-logo px-1" height="2.4rem"></pngx-logo>
|
<pngx-logo extra_classes="navbar-official-logo px-1" height="2.4rem"></pngx-logo>
|
||||||
<svg class="brand-mark brand-mark-slim d-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1.5em" height="1.5em" fill="currentColor">
|
<pngx-brand-mark class="brand-mark brand-mark-slim d-none"></pngx-brand-mark>
|
||||||
<path d="M341,949.1c-6.9-20.3-20.7-61.2-21.9-61-199.6-88.9-182.5-229.8-134.3-347.5,30,137.2,268.8,148.9,146.2,336-.9,2.2,10,27.8,19.5,51.3,22.7-51.9,58.6-115.5,55.8-120.8C178,398.7,724.9,299,807.1,18.5c83,251.5,53.1,659.8-377.4,814.9-2,1.4-63.5,148.6-66.9,150.2-.2-2.1-33.2,2.9-30.1-8.7,1.6-7,4.8-16.2,8.2-25.6h0v-.2h.1ZM323.1,846.2c48.3-71.9-12.7-120.8-56.9-152.2,81.2,107.4,66.4,120.8,56.9,152.2h0Z"/>
|
|
||||||
</svg>
|
|
||||||
} @else {
|
} @else {
|
||||||
@if (customAppLogo) {
|
@if (customAppLogo) {
|
||||||
<img class="brand-logo" [src]="customAppLogo" alt="" />
|
<img class="brand-logo" [src]="customAppLogo" alt="" />
|
||||||
} @else {
|
} @else {
|
||||||
<svg class="brand-mark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1.5em" height="1.5em" fill="currentColor">
|
<pngx-brand-mark class="brand-mark"></pngx-brand-mark>
|
||||||
<path d="M341,949.1c-6.9-20.3-20.7-61.2-21.9-61-199.6-88.9-182.5-229.8-134.3-347.5,30,137.2,268.8,148.9,146.2,336-.9,2.2,10,27.8,19.5,51.3,22.7-51.9,58.6-115.5,55.8-120.8C178,398.7,724.9,299,807.1,18.5c83,251.5,53.1,659.8-377.4,814.9-2,1.4-63.5,148.6-66.9,150.2-.2-2.1-33.2,2.9-30.1-8.7,1.6-7,4.8-16.2,8.2-25.6h0v-.2h.1ZM323.1,846.2c48.3-71.9-12.7-120.8-56.9-152.2,81.2,107.4,66.4,120.8,56.9,152.2h0Z"/>
|
|
||||||
</svg>
|
|
||||||
}
|
}
|
||||||
<div class="brand-copy ms-2 text-truncate" [class.d-md-none]="slimSidebarEnabled">
|
<div class="brand-copy ms-2 text-truncate" [class.d-md-none]="slimSidebarEnabled">
|
||||||
<span class="brand-title text-truncate">{{ appTitle }}</span>
|
<span class="brand-title text-truncate">{{ appTitle }}</span>
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { TasksService } from 'src/app/services/tasks.service'
|
|||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { ChatComponent } from '../chat/chat/chat.component'
|
import { ChatComponent } from '../chat/chat/chat.component'
|
||||||
|
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
|
||||||
import { LogoComponent } from '../common/logo/logo.component'
|
import { LogoComponent } from '../common/logo/logo.component'
|
||||||
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
||||||
@@ -61,6 +62,7 @@ const SCROLL_THRESHOLD = 16
|
|||||||
imports: [
|
imports: [
|
||||||
GlobalSearchComponent,
|
GlobalSearchComponent,
|
||||||
LogoComponent,
|
LogoComponent,
|
||||||
|
BrandMarkComponent,
|
||||||
DocumentTitlePipe,
|
DocumentTitlePipe,
|
||||||
IfPermissionsDirective,
|
IfPermissionsDirective,
|
||||||
ToastsDropdownComponent,
|
ToastsDropdownComponent,
|
||||||
|
|||||||
+39
@@ -462,6 +462,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@case (WorkflowActionType.ApplyAiSuggestions) {
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<p class="text-muted small" i18n>The document will be sent to the configured AI service for suggestions. Consider costs and privacy.</p>
|
||||||
|
<pngx-input-select
|
||||||
|
i18n-title
|
||||||
|
title="Apply suggestions for"
|
||||||
|
[items]="aiSuggestionFieldOptions"
|
||||||
|
[multiple]="true"
|
||||||
|
formControlName="ai_suggestion_fields"
|
||||||
|
[error]="error?.actions?.[i]?.ai_suggestion_fields"
|
||||||
|
hint="Suggestions for fields that are not selected are discarded."
|
||||||
|
i18n-hint
|
||||||
|
></pngx-input-select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<pngx-input-switch
|
||||||
|
[horizontal]="true"
|
||||||
|
i18n-title
|
||||||
|
title="Create missing items"
|
||||||
|
formControlName="ai_create_missing"
|
||||||
|
hint="Create suggested tags, correspondents and document types that do not exist yet."
|
||||||
|
i18n-hint
|
||||||
|
></pngx-input-switch>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<pngx-input-switch
|
||||||
|
[horizontal]="true"
|
||||||
|
i18n-title
|
||||||
|
title="Overwrite existing values"
|
||||||
|
formControlName="ai_overwrite_existing"
|
||||||
|
hint="Apply suggestions even if the document already has a value. Tags are always added, never replaced."
|
||||||
|
i18n-hint
|
||||||
|
></pngx-input-switch>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|||||||
+124
-2
@@ -22,6 +22,7 @@ import {
|
|||||||
} from 'src/app/data/matching-model'
|
} from 'src/app/data/matching-model'
|
||||||
import { Workflow } from 'src/app/data/workflow'
|
import { Workflow } from 'src/app/data/workflow'
|
||||||
import {
|
import {
|
||||||
|
AISuggestionField,
|
||||||
WorkflowAction,
|
WorkflowAction,
|
||||||
WorkflowActionType,
|
WorkflowActionType,
|
||||||
} from 'src/app/data/workflow-action'
|
} from 'src/app/data/workflow-action'
|
||||||
@@ -49,6 +50,7 @@ import { TagsComponent } from '../../input/tags/tags.component'
|
|||||||
import { TextComponent } from '../../input/text/text.component'
|
import { TextComponent } from '../../input/text/text.component'
|
||||||
import { EditDialogMode } from '../edit-dialog.component'
|
import { EditDialogMode } from '../edit-dialog.component'
|
||||||
import {
|
import {
|
||||||
|
AI_SUGGESTION_FIELD_OPTIONS,
|
||||||
DOCUMENT_SOURCE_OPTIONS,
|
DOCUMENT_SOURCE_OPTIONS,
|
||||||
SCHEDULE_DATE_FIELD_OPTIONS,
|
SCHEDULE_DATE_FIELD_OPTIONS,
|
||||||
TriggerFilterType,
|
TriggerFilterType,
|
||||||
@@ -239,14 +241,15 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
SCHEDULE_DATE_FIELD_OPTIONS
|
SCHEDULE_DATE_FIELD_OPTIONS
|
||||||
)
|
)
|
||||||
|
|
||||||
// Email disabled
|
// Email, remote OCR and AI all disabled
|
||||||
jest.spyOn(settingsService, 'get').mockReturnValue(false)
|
jest.spyOn(settingsService, 'get').mockReturnValue(false)
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
expect(component.actionTypeOptions).toEqual(
|
expect(component.actionTypeOptions).toEqual(
|
||||||
WORKFLOW_ACTION_OPTIONS.filter(
|
WORKFLOW_ACTION_OPTIONS.filter(
|
||||||
(a) =>
|
(a) =>
|
||||||
a.id !== WorkflowActionType.Email &&
|
a.id !== WorkflowActionType.Email &&
|
||||||
a.id !== WorkflowActionType.RemoteOcr
|
a.id !== WorkflowActionType.RemoteOcr &&
|
||||||
|
a.id !== WorkflowActionType.ApplyAiSuggestions
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -344,6 +347,125 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should offer apply AI suggestions unless every trigger is consumption', () => {
|
||||||
|
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||||
|
|
||||||
|
// Consumption runs before the document has been parsed, so there would be
|
||||||
|
// no content to make suggestions from
|
||||||
|
component.object = {
|
||||||
|
name: 'Workflow 1',
|
||||||
|
order: 0,
|
||||||
|
enabled: true,
|
||||||
|
triggers: [{ type: WorkflowTriggerType.Consumption }],
|
||||||
|
actions: [],
|
||||||
|
} as Workflow
|
||||||
|
component.ngOnInit()
|
||||||
|
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
|
||||||
|
WorkflowActionType.ApplyAiSuggestions
|
||||||
|
)
|
||||||
|
|
||||||
|
// A second, usable trigger is enough
|
||||||
|
component.object = {
|
||||||
|
name: 'Workflow 2',
|
||||||
|
order: 0,
|
||||||
|
enabled: true,
|
||||||
|
triggers: [
|
||||||
|
{ type: WorkflowTriggerType.Consumption },
|
||||||
|
{ type: WorkflowTriggerType.DocumentAdded },
|
||||||
|
],
|
||||||
|
actions: [],
|
||||||
|
} as Workflow
|
||||||
|
component.ngOnInit()
|
||||||
|
expect(component.actionTypeOptions.map((a) => a.id)).toContain(
|
||||||
|
WorkflowActionType.ApplyAiSuggestions
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should keep apply AI suggestions listed when an action already uses it', () => {
|
||||||
|
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||||
|
|
||||||
|
// Otherwise changing the trigger would silently blank the selection
|
||||||
|
component.object = {
|
||||||
|
name: 'Workflow 1',
|
||||||
|
order: 0,
|
||||||
|
enabled: true,
|
||||||
|
triggers: [{ type: WorkflowTriggerType.Consumption }],
|
||||||
|
actions: [{ type: WorkflowActionType.ApplyAiSuggestions }],
|
||||||
|
} as Workflow
|
||||||
|
component.ngOnInit()
|
||||||
|
|
||||||
|
expect(component.actionTypeOptions.map((a) => a.id)).toContain(
|
||||||
|
WorkflowActionType.ApplyAiSuggestions
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not offer apply AI suggestions when AI is disabled', () => {
|
||||||
|
jest
|
||||||
|
.spyOn(settingsService, 'get')
|
||||||
|
.mockImplementation((key) => key !== SETTINGS_KEYS.AI_ENABLED)
|
||||||
|
|
||||||
|
component.object = {
|
||||||
|
name: 'Workflow 1',
|
||||||
|
order: 0,
|
||||||
|
enabled: true,
|
||||||
|
triggers: [{ type: WorkflowTriggerType.DocumentAdded }],
|
||||||
|
actions: [],
|
||||||
|
} as Workflow
|
||||||
|
component.ngOnInit()
|
||||||
|
|
||||||
|
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
|
||||||
|
WorkflowActionType.ApplyAiSuggestions
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should create form fields for apply AI suggestions options', () => {
|
||||||
|
component.object = {
|
||||||
|
name: 'Workflow 1',
|
||||||
|
order: 0,
|
||||||
|
enabled: true,
|
||||||
|
triggers: [{ type: WorkflowTriggerType.DocumentAdded }],
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
type: WorkflowActionType.ApplyAiSuggestions,
|
||||||
|
ai_suggestion_fields: [
|
||||||
|
AISuggestionField.Title,
|
||||||
|
AISuggestionField.Tags,
|
||||||
|
],
|
||||||
|
ai_create_missing: true,
|
||||||
|
ai_overwrite_existing: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as Workflow
|
||||||
|
component.ngOnInit()
|
||||||
|
|
||||||
|
const action = component.actionFields.at(0)
|
||||||
|
expect(action.get('ai_suggestion_fields').value).toEqual([
|
||||||
|
AISuggestionField.Title,
|
||||||
|
AISuggestionField.Tags,
|
||||||
|
])
|
||||||
|
expect(action.get('ai_create_missing').value).toBeTruthy()
|
||||||
|
expect(action.get('ai_overwrite_existing').value).toBeTruthy()
|
||||||
|
expect(component.aiSuggestionFieldOptions).toEqual(
|
||||||
|
AI_SUGGESTION_FIELD_OPTIONS
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should default apply AI suggestions options on a new action', () => {
|
||||||
|
component.object = {
|
||||||
|
name: 'Workflow 1',
|
||||||
|
order: 0,
|
||||||
|
enabled: true,
|
||||||
|
triggers: [{ type: WorkflowTriggerType.DocumentAdded }],
|
||||||
|
actions: [],
|
||||||
|
} as Workflow
|
||||||
|
component.addAction()
|
||||||
|
|
||||||
|
const action = component.actionFields.at(component.actionFields.length - 1)
|
||||||
|
expect(action.get('ai_suggestion_fields').value).toEqual([])
|
||||||
|
expect(action.get('ai_create_missing').value).toBeFalsy()
|
||||||
|
expect(action.get('ai_overwrite_existing').value).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
it('should support add and remove triggers and actions', () => {
|
it('should support add and remove triggers and actions', () => {
|
||||||
component.object = workflow
|
component.object = workflow
|
||||||
component.addTrigger()
|
component.addTrigger()
|
||||||
|
|||||||
+62
@@ -30,6 +30,7 @@ import { StoragePath } from 'src/app/data/storage-path'
|
|||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { Workflow } from 'src/app/data/workflow'
|
import { Workflow } from 'src/app/data/workflow'
|
||||||
import {
|
import {
|
||||||
|
AISuggestionField,
|
||||||
WorkflowAction,
|
WorkflowAction,
|
||||||
WorkflowActionType,
|
WorkflowActionType,
|
||||||
} from 'src/app/data/workflow-action'
|
} from 'src/app/data/workflow-action'
|
||||||
@@ -152,6 +153,37 @@ export const WORKFLOW_ACTION_OPTIONS = [
|
|||||||
id: WorkflowActionType.RemoteOcr,
|
id: WorkflowActionType.RemoteOcr,
|
||||||
name: $localize`Remote OCR`,
|
name: $localize`Remote OCR`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: WorkflowActionType.ApplyAiSuggestions,
|
||||||
|
name: $localize`Apply AI suggestions`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const AI_SUGGESTION_FIELD_OPTIONS = [
|
||||||
|
{
|
||||||
|
id: AISuggestionField.Title,
|
||||||
|
name: $localize`Title`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: AISuggestionField.Tags,
|
||||||
|
name: $localize`Tags`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: AISuggestionField.Correspondent,
|
||||||
|
name: $localize`Correspondent`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: AISuggestionField.DocumentType,
|
||||||
|
name: $localize`Document type`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: AISuggestionField.StoragePath,
|
||||||
|
name: $localize`Storage path`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: AISuggestionField.Created,
|
||||||
|
name: $localize`Created date`,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export enum TriggerFilterType {
|
export enum TriggerFilterType {
|
||||||
@@ -576,6 +608,24 @@ export class WorkflowEditDialogComponent
|
|||||||
allowed = allowed.filter((a) => a.id !== WorkflowActionType.RemoteOcr)
|
allowed = allowed.filter((a) => a.id !== WorkflowActionType.RemoteOcr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only available after consumption. Unlike remote OCR this is hidden only
|
||||||
|
// once every trigger is consumption, so it stays offered on a workflow
|
||||||
|
// that has no triggers yet.
|
||||||
|
const aiSuggestionsUsable =
|
||||||
|
this.settingsService.get(SETTINGS_KEYS.AI_ENABLED) &&
|
||||||
|
(!formWorkflow?.triggers?.length ||
|
||||||
|
formWorkflow.triggers.some(
|
||||||
|
(trigger) => trigger.type !== WorkflowTriggerType.Consumption
|
||||||
|
) ||
|
||||||
|
formWorkflow.actions?.some(
|
||||||
|
(action) => action.type === WorkflowActionType.ApplyAiSuggestions
|
||||||
|
))
|
||||||
|
if (!aiSuggestionsUsable) {
|
||||||
|
allowed = allowed.filter(
|
||||||
|
(a) => a.id !== WorkflowActionType.ApplyAiSuggestions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.allowedActionTypes?.length === allowed.length &&
|
this.allowedActionTypes?.length === allowed.length &&
|
||||||
this.allowedActionTypes.every((a, i) => a.id === allowed[i].id)
|
this.allowedActionTypes.every((a, i) => a.id === allowed[i].id)
|
||||||
@@ -1227,6 +1277,11 @@ export class WorkflowEditDialogComponent
|
|||||||
passwords: new FormControl(
|
passwords: new FormControl(
|
||||||
this.formatPasswords(action.passwords ?? [])
|
this.formatPasswords(action.passwords ?? [])
|
||||||
),
|
),
|
||||||
|
ai_suggestion_fields: new FormControl(
|
||||||
|
action.ai_suggestion_fields ?? []
|
||||||
|
),
|
||||||
|
ai_create_missing: new FormControl(!!action.ai_create_missing),
|
||||||
|
ai_overwrite_existing: new FormControl(!!action.ai_overwrite_existing),
|
||||||
}),
|
}),
|
||||||
{ emitEvent }
|
{ emitEvent }
|
||||||
)
|
)
|
||||||
@@ -1316,6 +1371,10 @@ export class WorkflowEditDialogComponent
|
|||||||
return this.actionTypeOptions.find((t) => t.id === type)?.name ?? ''
|
return this.actionTypeOptions.find((t) => t.id === type)?.name ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get aiSuggestionFieldOptions() {
|
||||||
|
return AI_SUGGESTION_FIELD_OPTIONS
|
||||||
|
}
|
||||||
|
|
||||||
addAction() {
|
addAction() {
|
||||||
if (!this.object) {
|
if (!this.object) {
|
||||||
this.object = Object.assign({}, this.objectForm.value)
|
this.object = Object.assign({}, this.objectForm.value)
|
||||||
@@ -1369,6 +1428,9 @@ export class WorkflowEditDialogComponent
|
|||||||
include_document: false,
|
include_document: false,
|
||||||
},
|
},
|
||||||
passwords: [],
|
passwords: [],
|
||||||
|
ai_suggestion_fields: [],
|
||||||
|
ai_create_missing: false,
|
||||||
|
ai_overwrite_existing: false,
|
||||||
}
|
}
|
||||||
this.object.actions.push(action)
|
this.object.actions.push(action)
|
||||||
this.createActionField(action)
|
this.createActionField(action)
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<svg class="w-100 h-100 d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M341,949.1c-6.9-20.3-20.7-61.2-21.9-61-199.6-88.9-182.5-229.8-134.3-347.5,30,137.2,268.8,148.9,146.2,336-.9,2.2,10,27.8,19.5,51.3,22.7-51.9,58.6-115.5,55.8-120.8C178,398.7,724.9,299,807.1,18.5c83,251.5,53.1,659.8-377.4,814.9-2,1.4-63.5,148.6-66.9,150.2-.2-2.1-33.2,2.9-30.1-8.7,1.6-7,4.8-16.2,8.2-25.6h0v-.2h.1ZM323.1,846.2c48.3-71.9-12.7-120.8-56.9-152.2,81.2,107.4,66.4,120.8,56.9,152.2h0Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 545 B |
@@ -0,0 +1,14 @@
|
|||||||
|
import { Component, input } from '@angular/core'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-brand-mark',
|
||||||
|
templateUrl: './brand-mark.component.html',
|
||||||
|
host: {
|
||||||
|
'[style.width]': 'width()',
|
||||||
|
'[style.height]': 'height()',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class BrandMarkComponent {
|
||||||
|
readonly width = input<string>(null)
|
||||||
|
readonly height = input<string>(null)
|
||||||
|
}
|
||||||
+16
-8
@@ -1,10 +1,18 @@
|
|||||||
<ngb-alert class="pe-3" type="primary" [dismissible]="true" (closed)="dismiss.emit(true)">
|
<ngb-alert class="welcome-widget bg-light shadow-sm pe-3 position-relative overflow-hidden" type="primary" [dismissible]="true" (closed)="dismiss.emit(true)">
|
||||||
<h4 class="alert-heading"><ng-container i18n>Paperless-ngx is running!</ng-container> 🎉</h4>
|
<pngx-brand-mark class="welcome-watermark d-none d-md-block position-absolute top-50 translate-middle-y text-primary pe-none" width="26rem" height="26rem"></pngx-brand-mark>
|
||||||
<p i18n>You're ready to start uploading documents! Explore the various features of this web app on your own, or start a quick tour using the button below.</p>
|
<div class="d-flex align-items-start gap-3 gap-md-4 position-relative">
|
||||||
<p i18n>More detail on how to use and configure Paperless-ngx is always available in the <a href="https://docs.paperless-ngx.com" target="_blank">documentation</a>.</p>
|
<pngx-brand-mark class="d-none d-sm-block flex-shrink-0 text-primary" width="3rem" height="3rem"></pngx-brand-mark>
|
||||||
<hr>
|
<div class="flex-grow-1">
|
||||||
<div class="d-flex align-items-end">
|
<h4 class="alert-heading text-primary fw-semibold"><ng-container i18n>Paperless-ngx is running!</ng-container> 🎉</h4>
|
||||||
<p class="lead fs-6 m-0"><em i18n>Thanks for being a part of the Paperless-ngx community!</em></p>
|
<div class="welcome-copy text-dark">
|
||||||
<button class="btn btn-primary ms-auto flex-shrink-0" (click)="tourService.start()"><ng-container i18n>Start the tour</ng-container> →</button>
|
<p i18n>You're ready to start uploading documents! Explore the various features of this web app on your own, or start a quick tour using the button below.</p>
|
||||||
|
<p class="mb-0" i18n>More detail on how to use and configure Paperless-ngx is always available in the <a href="https://docs.paperless-ngx.com" target="_blank">documentation</a>.</p>
|
||||||
|
</div>
|
||||||
|
<hr class="opacity-100">
|
||||||
|
<div class="d-flex flex-column flex-sm-row align-items-sm-center gap-3">
|
||||||
|
<p class="m-0 text-secondary"><em i18n>Thanks for being a part of the Paperless-ngx community!</em></p>
|
||||||
|
<button class="btn btn-primary ms-sm-auto flex-shrink-0 align-self-start align-self-sm-auto" (click)="tourService.start()"><ng-container i18n>Start the tour</ng-container> →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ngb-alert>
|
</ngb-alert>
|
||||||
|
|||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
.welcome-widget {
|
||||||
|
border-color: color-mix(in srgb, var(--bs-primary) 50%, transparent);
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border-top-color: color-mix(in srgb, var(--bs-primary) 25%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-copy {
|
||||||
|
max-width: 68ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-watermark {
|
||||||
|
right: -6rem;
|
||||||
|
opacity: .07;
|
||||||
|
}
|
||||||
|
|||||||
+2
-1
@@ -1,12 +1,13 @@
|
|||||||
import { Component, EventEmitter, Output, inject } from '@angular/core'
|
import { Component, EventEmitter, Output, inject } from '@angular/core'
|
||||||
import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { TourService } from 'ngx-ui-tour-ng-bootstrap'
|
import { TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
|
import { BrandMarkComponent } from '../../../common/logo/brand-mark/brand-mark.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-welcome-widget',
|
selector: 'pngx-welcome-widget',
|
||||||
templateUrl: './welcome-widget.component.html',
|
templateUrl: './welcome-widget.component.html',
|
||||||
styleUrls: ['./welcome-widget.component.scss'],
|
styleUrls: ['./welcome-widget.component.scss'],
|
||||||
imports: [NgbAlertModule],
|
imports: [NgbAlertModule, BrandMarkComponent],
|
||||||
})
|
})
|
||||||
export class WelcomeWidgetComponent {
|
export class WelcomeWidgetComponent {
|
||||||
readonly tourService = inject(TourService)
|
readonly tourService = inject(TourService)
|
||||||
|
|||||||
@@ -8,6 +8,17 @@ export enum WorkflowActionType {
|
|||||||
PasswordRemoval = 5,
|
PasswordRemoval = 5,
|
||||||
MoveToTrash = 6,
|
MoveToTrash = 6,
|
||||||
RemoteOcr = 7,
|
RemoteOcr = 7,
|
||||||
|
ApplyAiSuggestions = 8,
|
||||||
|
}
|
||||||
|
|
||||||
|
// see src/documents/models.py AISuggestionField
|
||||||
|
export enum AISuggestionField {
|
||||||
|
Title = 'title',
|
||||||
|
Tags = 'tags',
|
||||||
|
Correspondent = 'correspondent',
|
||||||
|
DocumentType = 'document_type',
|
||||||
|
StoragePath = 'storage_path',
|
||||||
|
Created = 'created',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkflowActionEmail extends ObjectWithId {
|
export interface WorkflowActionEmail extends ObjectWithId {
|
||||||
@@ -102,4 +113,10 @@ export interface WorkflowAction extends ObjectWithId {
|
|||||||
webhook?: WorkflowActionWebhook
|
webhook?: WorkflowActionWebhook
|
||||||
|
|
||||||
passwords?: string[]
|
passwords?: string[]
|
||||||
|
|
||||||
|
ai_suggestion_fields?: AISuggestionField[]
|
||||||
|
|
||||||
|
ai_create_missing?: boolean
|
||||||
|
|
||||||
|
ai_overwrite_existing?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,35 @@ describe('text search utilities', () => {
|
|||||||
expect(matchesSearchText('taxes 2026', 'tax receipt')).toBeFalsy()
|
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', () => {
|
it('matches a large set of tag names without blocking input', () => {
|
||||||
const tagNames = Array.from(
|
const tagNames = Array.from(
|
||||||
{ length: 1280 },
|
{ length: 1280 },
|
||||||
|
|||||||
@@ -3,13 +3,18 @@ import { diacritics } from 'normalize-diacritics/diacritics'
|
|||||||
export type SearchTextValue =
|
export type SearchTextValue =
|
||||||
string | number | boolean | bigint | null | undefined
|
string | number | boolean | bigint | null | undefined
|
||||||
|
|
||||||
|
const NON_ASCII = /[^\x00-\x7F]/
|
||||||
|
const SEPARATORS = /[^\p{L}\p{N}]+/u
|
||||||
|
|
||||||
export function normalizeSearchText(value: SearchTextValue): string {
|
export function normalizeSearchText(value: SearchTextValue): string {
|
||||||
const normalized = diacritics.reduce(
|
const text = String(value ?? '')
|
||||||
(text, replacement) => {
|
|
||||||
return text.replace(replacement.diacritics, replacement.letter)
|
// Nothing in the table matches ASCII, so skip normaliation
|
||||||
},
|
if (!NON_ASCII.test(text)) return text.toLocaleLowerCase()
|
||||||
String(value ?? '')
|
|
||||||
)
|
const normalized = diacritics.reduce((text, replacement) => {
|
||||||
|
return text.replace(replacement.diacritics, replacement.letter)
|
||||||
|
}, text)
|
||||||
|
|
||||||
return normalized.toLocaleLowerCase()
|
return normalized.toLocaleLowerCase()
|
||||||
}
|
}
|
||||||
@@ -18,8 +23,28 @@ export function matchesSearchText(
|
|||||||
value: SearchTextValue,
|
value: SearchTextValue,
|
||||||
searchText: SearchTextValue
|
searchText: SearchTextValue
|
||||||
): boolean {
|
): boolean {
|
||||||
const normalizedValue = normalizeSearchText(value)
|
const query = normalizeSearchText(searchText)
|
||||||
const searchTerms = normalizeSearchText(searchText).trim().split(/\s+/)
|
const terms = query.split(SEPARATORS).filter(Boolean)
|
||||||
|
|
||||||
return searchTerms.every((term) => normalizedValue.includes(term))
|
// 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
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Generated by Django 5.2.16 on 2026-08-10 18:26
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("documents", "0024_alter_workflowaction_type"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="workflowaction",
|
||||||
|
name="ai_create_missing",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Create suggested tags, correspondents, document types and storage paths that do not already exist instead of skipping them.",
|
||||||
|
verbose_name="create missing objects",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="workflowaction",
|
||||||
|
name="ai_overwrite_existing",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Apply suggestions even if the document already has a value for that field. Tags are always added to, never replaced.",
|
||||||
|
verbose_name="overwrite existing values",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="workflowaction",
|
||||||
|
name="ai_suggestion_fields",
|
||||||
|
field=models.JSONField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Which of the AI-suggested fields to apply to the document.",
|
||||||
|
null=True,
|
||||||
|
verbose_name="AI suggestion fields",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="workflowaction",
|
||||||
|
name="type",
|
||||||
|
field=models.PositiveSmallIntegerField(
|
||||||
|
choices=[
|
||||||
|
(1, "Assignment"),
|
||||||
|
(2, "Removal"),
|
||||||
|
(3, "Email"),
|
||||||
|
(4, "Webhook"),
|
||||||
|
(5, "Password removal"),
|
||||||
|
(6, "Move to trash"),
|
||||||
|
(7, "Remote OCR"),
|
||||||
|
(8, "Apply AI suggestions"),
|
||||||
|
],
|
||||||
|
default=1,
|
||||||
|
verbose_name="Workflow Action Type",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="paperlesstask",
|
||||||
|
name="task_type",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("consume_file", "Consume File"),
|
||||||
|
("train_classifier", "Train Classifier"),
|
||||||
|
("sanity_check", "Sanity Check"),
|
||||||
|
("index_optimize", "Index Optimize"),
|
||||||
|
("mail_fetch", "Mail Fetch"),
|
||||||
|
("llm_index", "LLM Index"),
|
||||||
|
("empty_trash", "Empty Trash"),
|
||||||
|
("check_workflows", "Check Workflows"),
|
||||||
|
("bulk_update", "Bulk Update"),
|
||||||
|
("reprocess_document", "Reprocess Document"),
|
||||||
|
("build_share_link", "Build Share Link"),
|
||||||
|
("bulk_delete", "Bulk Delete"),
|
||||||
|
("apply_ai_suggestions", "Apply AI Suggestions"),
|
||||||
|
],
|
||||||
|
db_index=True,
|
||||||
|
help_text="The kind of work being performed",
|
||||||
|
max_length=50,
|
||||||
|
verbose_name="Task Type",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
+40
-14
@@ -373,7 +373,6 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
|||||||
If the queryset already annotated ``effective_content``, that value is used.
|
If the queryset already annotated ``effective_content``, that value is used.
|
||||||
"""
|
"""
|
||||||
# Here to avoid circular import
|
# Here to avoid circular import
|
||||||
from documents.versioning import LATEST_VERSION_CONTENT_PREFETCH_ATTR
|
|
||||||
from documents.versioning import sort_versions_newest_first
|
from documents.versioning import sort_versions_newest_first
|
||||||
from documents.versioning import versions_newest_first
|
from documents.versioning import versions_newest_first
|
||||||
|
|
||||||
@@ -383,19 +382,6 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
|||||||
if self.root_document_id is not None or self.pk is None:
|
if self.root_document_id is not None or self.pk is None:
|
||||||
return self.content
|
return self.content
|
||||||
|
|
||||||
latest_version_prefetch = getattr(
|
|
||||||
self,
|
|
||||||
LATEST_VERSION_CONTENT_PREFETCH_ATTR,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if latest_version_prefetch is not None:
|
|
||||||
# Empty list means prefetch ran and found no versions — use own content.
|
|
||||||
return (
|
|
||||||
latest_version_prefetch[0].content
|
|
||||||
if latest_version_prefetch
|
|
||||||
else self.content
|
|
||||||
)
|
|
||||||
|
|
||||||
prefetched_cache = getattr(self, "_prefetched_objects_cache", None)
|
prefetched_cache = getattr(self, "_prefetched_objects_cache", None)
|
||||||
prefetched_versions = (
|
prefetched_versions = (
|
||||||
prefetched_cache.get("versions")
|
prefetched_cache.get("versions")
|
||||||
@@ -780,6 +766,7 @@ class PaperlessTask(ModelWithOwner):
|
|||||||
REPROCESS_DOCUMENT = "reprocess_document", _("Reprocess Document")
|
REPROCESS_DOCUMENT = "reprocess_document", _("Reprocess Document")
|
||||||
BUILD_SHARE_LINK = "build_share_link", _("Build Share Link")
|
BUILD_SHARE_LINK = "build_share_link", _("Build Share Link")
|
||||||
BULK_DELETE = "bulk_delete", _("Bulk Delete")
|
BULK_DELETE = "bulk_delete", _("Bulk Delete")
|
||||||
|
APPLY_AI_SUGGESTIONS = "apply_ai_suggestions", _("Apply AI Suggestions")
|
||||||
|
|
||||||
COMPLETE_STATUSES = (
|
COMPLETE_STATUSES = (
|
||||||
Status.SUCCESS,
|
Status.SUCCESS,
|
||||||
@@ -1688,6 +1675,18 @@ class WorkflowAction(models.Model):
|
|||||||
7,
|
7,
|
||||||
_("Remote OCR"),
|
_("Remote OCR"),
|
||||||
)
|
)
|
||||||
|
APPLY_AI_SUGGESTIONS = (
|
||||||
|
8,
|
||||||
|
_("Apply AI suggestions"),
|
||||||
|
)
|
||||||
|
|
||||||
|
class AISuggestionField(models.TextChoices):
|
||||||
|
TITLE = ("title", _("Title"))
|
||||||
|
TAGS = ("tags", _("Tags"))
|
||||||
|
CORRESPONDENT = ("correspondent", _("Correspondent"))
|
||||||
|
DOCUMENT_TYPE = ("document_type", _("Document type"))
|
||||||
|
STORAGE_PATH = ("storage_path", _("Storage path"))
|
||||||
|
CREATED = ("created", _("Created date"))
|
||||||
|
|
||||||
type = models.PositiveSmallIntegerField(
|
type = models.PositiveSmallIntegerField(
|
||||||
_("Workflow Action Type"),
|
_("Workflow Action Type"),
|
||||||
@@ -1926,6 +1925,33 @@ class WorkflowAction(models.Model):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ai_suggestion_fields = models.JSONField(
|
||||||
|
_("AI suggestion fields"),
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text=_(
|
||||||
|
"Which of the AI-suggested fields to apply to the document.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
ai_create_missing = models.BooleanField(
|
||||||
|
_("create missing objects"),
|
||||||
|
default=False,
|
||||||
|
help_text=_(
|
||||||
|
"Create suggested tags, correspondents, document types and storage "
|
||||||
|
"paths that do not already exist instead of skipping them.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
ai_overwrite_existing = models.BooleanField(
|
||||||
|
_("overwrite existing values"),
|
||||||
|
default=False,
|
||||||
|
help_text=_(
|
||||||
|
"Apply suggestions even if the document already has a value for that "
|
||||||
|
"field. Tags are always added to, never replaced.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("workflow action")
|
verbose_name = _("workflow action")
|
||||||
verbose_name_plural = _("workflow actions")
|
verbose_name_plural = _("workflow actions")
|
||||||
|
|||||||
@@ -88,7 +88,6 @@ from documents.templating.utils import convert_format_str_to_template_format
|
|||||||
from documents.templating.workflows import validate_workflow_template
|
from documents.templating.workflows import validate_workflow_template
|
||||||
from documents.validators import uri_validator
|
from documents.validators import uri_validator
|
||||||
from documents.validators import url_validator
|
from documents.validators import url_validator
|
||||||
from documents.versioning import has_prefetched_effective_content
|
|
||||||
from documents.versioning import sort_versions_newest_first
|
from documents.versioning import sort_versions_newest_first
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -1147,14 +1146,8 @@ class DocumentSerializer(
|
|||||||
|
|
||||||
def to_representation(self, instance):
|
def to_representation(self, instance):
|
||||||
doc = super().to_representation(instance)
|
doc = super().to_representation(instance)
|
||||||
if "content" in self.fields and has_prefetched_effective_content(instance):
|
if "content" in self.fields and hasattr(instance, "effective_content"):
|
||||||
# Only resolve version-aware content when it's cheap: an SQL
|
doc["content"] = getattr(instance, "effective_content") or ""
|
||||||
# annotation or a versions prefetch is already on the instance.
|
|
||||||
# A caller that set up neither (e.g. TrashView, GlobalSearchView,
|
|
||||||
# which build their own querysets) gets the document's own,
|
|
||||||
# unresolved content instead of paying for an extra per-instance
|
|
||||||
# query -- same as before effective_content resolution existed.
|
|
||||||
doc["content"] = instance.get_effective_content() or ""
|
|
||||||
if self.truncate_content and "content" in self.fields:
|
if self.truncate_content and "content" in self.fields:
|
||||||
doc["content"] = doc.get("content")[0:550]
|
doc["content"] = doc.get("content")[0:550]
|
||||||
return doc
|
return doc
|
||||||
@@ -3242,6 +3235,9 @@ class WorkflowActionSerializer(serializers.ModelSerializer[WorkflowAction]):
|
|||||||
"email",
|
"email",
|
||||||
"webhook",
|
"webhook",
|
||||||
"passwords",
|
"passwords",
|
||||||
|
"ai_suggestion_fields",
|
||||||
|
"ai_create_missing",
|
||||||
|
"ai_overwrite_existing",
|
||||||
]
|
]
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
@@ -3299,6 +3295,23 @@ class WorkflowActionSerializer(serializers.ModelSerializer[WorkflowAction]):
|
|||||||
"Passwords are required for password removal actions",
|
"Passwords are required for password removal actions",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
"type" in attrs
|
||||||
|
and attrs["type"] == WorkflowAction.WorkflowActionType.APPLY_AI_SUGGESTIONS
|
||||||
|
):
|
||||||
|
fields = attrs.get("ai_suggestion_fields")
|
||||||
|
valid_fields = set(WorkflowAction.AISuggestionField.values)
|
||||||
|
if (
|
||||||
|
fields is None
|
||||||
|
or not isinstance(fields, list)
|
||||||
|
or len(fields) == 0
|
||||||
|
or any(field not in valid_fields for field in fields)
|
||||||
|
):
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"At least one valid field is required for apply AI "
|
||||||
|
f"suggestions actions, options are: {sorted(valid_fields)}",
|
||||||
|
)
|
||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
@@ -3327,24 +3340,43 @@ class WorkflowSerializer(serializers.ModelSerializer[Workflow]):
|
|||||||
action.get("type") == WorkflowAction.WorkflowActionType.REMOTE_OCR
|
action.get("type") == WorkflowAction.WorkflowActionType.REMOTE_OCR
|
||||||
for action in attrs["actions"]
|
for action in attrs["actions"]
|
||||||
)
|
)
|
||||||
|
has_ai_suggestions_action = any(
|
||||||
|
action.get("type")
|
||||||
|
== WorkflowAction.WorkflowActionType.APPLY_AI_SUGGESTIONS
|
||||||
|
for action in attrs["actions"]
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
has_remote_ocr_action = self.instance is not None and (
|
has_remote_ocr_action = self.instance is not None and (
|
||||||
self.instance.actions.filter(
|
self.instance.actions.filter(
|
||||||
type=WorkflowAction.WorkflowActionType.REMOTE_OCR,
|
type=WorkflowAction.WorkflowActionType.REMOTE_OCR,
|
||||||
).exists()
|
).exists()
|
||||||
)
|
)
|
||||||
|
has_ai_suggestions_action = self.instance is not None and (
|
||||||
|
self.instance.actions.filter(
|
||||||
|
type=WorkflowAction.WorkflowActionType.APPLY_AI_SUGGESTIONS,
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
|
||||||
if "triggers" in attrs:
|
if "triggers" in attrs:
|
||||||
has_consumption_trigger = any(
|
has_consumption_trigger = any(
|
||||||
trigger.get("type") == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION
|
trigger.get("type") == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION
|
||||||
for trigger in attrs["triggers"]
|
for trigger in attrs["triggers"]
|
||||||
)
|
)
|
||||||
|
has_non_consumption_trigger = any(
|
||||||
|
trigger.get("type") != WorkflowTrigger.WorkflowTriggerType.CONSUMPTION
|
||||||
|
for trigger in attrs["triggers"]
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
has_consumption_trigger = self.instance is not None and (
|
has_consumption_trigger = self.instance is not None and (
|
||||||
self.instance.triggers.filter(
|
self.instance.triggers.filter(
|
||||||
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||||
).exists()
|
).exists()
|
||||||
)
|
)
|
||||||
|
has_non_consumption_trigger = self.instance is not None and (
|
||||||
|
self.instance.triggers.exclude(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
|
||||||
# Remote OCR can only work with consumption triggers
|
# Remote OCR can only work with consumption triggers
|
||||||
if has_remote_ocr_action and not has_consumption_trigger:
|
if has_remote_ocr_action and not has_consumption_trigger:
|
||||||
@@ -3352,6 +3384,14 @@ class WorkflowSerializer(serializers.ModelSerializer[Workflow]):
|
|||||||
"Remote OCR actions require a consumption started trigger",
|
"Remote OCR actions require a consumption started trigger",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Suggestions are made from the document content, which does not exist
|
||||||
|
# until after consumption has finished
|
||||||
|
if has_ai_suggestions_action and not has_non_consumption_trigger:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"Apply AI suggestions actions require a trigger other than "
|
||||||
|
"consumption started",
|
||||||
|
)
|
||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
def update_triggers_and_actions(
|
def update_triggers_and_actions(
|
||||||
|
|||||||
@@ -984,6 +984,28 @@ def run_workflows(
|
|||||||
"triggers, ignoring",
|
"triggers, ignoring",
|
||||||
extra={"group": logging_group},
|
extra={"group": logging_group},
|
||||||
)
|
)
|
||||||
|
elif (
|
||||||
|
action.type
|
||||||
|
== WorkflowAction.WorkflowActionType.APPLY_AI_SUGGESTIONS
|
||||||
|
):
|
||||||
|
if use_overrides:
|
||||||
|
# The document has not been parsed yet, so there is no
|
||||||
|
# content for the LLM to make suggestions from
|
||||||
|
logger.debug(
|
||||||
|
"Apply AI suggestions action does not apply to "
|
||||||
|
"consumption triggers, ignoring",
|
||||||
|
extra={"group": logging_group},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Queued rather than run sync
|
||||||
|
from documents.tasks import apply_ai_suggestions
|
||||||
|
|
||||||
|
# kwargs so the PaperlessTask record can note the
|
||||||
|
# document, see _extract_input_data
|
||||||
|
apply_ai_suggestions.delay(
|
||||||
|
action_id=action.pk,
|
||||||
|
document_id=document.pk,
|
||||||
|
)
|
||||||
|
|
||||||
if not use_overrides:
|
if not use_overrides:
|
||||||
# limit title to 128 characters
|
# limit title to 128 characters
|
||||||
@@ -1039,6 +1061,7 @@ TRACKED_TASKS: dict[str, PaperlessTask.TaskType] = {
|
|||||||
"documents.tasks.update_document_content_maybe_archive_file": PaperlessTask.TaskType.REPROCESS_DOCUMENT,
|
"documents.tasks.update_document_content_maybe_archive_file": PaperlessTask.TaskType.REPROCESS_DOCUMENT,
|
||||||
"documents.tasks.build_share_link_bundle": PaperlessTask.TaskType.BUILD_SHARE_LINK,
|
"documents.tasks.build_share_link_bundle": PaperlessTask.TaskType.BUILD_SHARE_LINK,
|
||||||
"documents.bulk_edit.delete": PaperlessTask.TaskType.BULK_DELETE,
|
"documents.bulk_edit.delete": PaperlessTask.TaskType.BULK_DELETE,
|
||||||
|
"documents.tasks.apply_ai_suggestions": PaperlessTask.TaskType.APPLY_AI_SUGGESTIONS,
|
||||||
}
|
}
|
||||||
|
|
||||||
_CELERY_STATE_TO_STATUS: dict[str, PaperlessTask.Status] = {
|
_CELERY_STATE_TO_STATUS: dict[str, PaperlessTask.Status] = {
|
||||||
@@ -1092,6 +1115,12 @@ def _extract_input_data(
|
|||||||
return {"account_ids": account_ids}
|
return {"account_ids": account_ids}
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
if task_type == PaperlessTask.TaskType.APPLY_AI_SUGGESTIONS:
|
||||||
|
document_id = task_kwargs.get("document_id")
|
||||||
|
if document_id is not None:
|
||||||
|
return {"document_id": document_id}
|
||||||
|
return {}
|
||||||
|
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ from paperless.config import RemoteOCRConfig
|
|||||||
from paperless.logging import consume_task_id
|
from paperless.logging import consume_task_id
|
||||||
from paperless.parsers import ParserContext
|
from paperless.parsers import ParserContext
|
||||||
from paperless.parsers.registry import get_parser_registry
|
from paperless.parsers.registry import get_parser_registry
|
||||||
|
from paperless_ai.exceptions import LLMTimeoutError
|
||||||
from paperless_ai.indexing import llm_index_add_or_update_document
|
from paperless_ai.indexing import llm_index_add_or_update_document
|
||||||
from paperless_ai.indexing import llm_index_remove_document
|
from paperless_ai.indexing import llm_index_remove_document
|
||||||
from paperless_ai.indexing import update_llm_index
|
from paperless_ai.indexing import update_llm_index
|
||||||
@@ -714,6 +715,45 @@ def llmindex_index(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(
|
||||||
|
bind=True,
|
||||||
|
autoretry_for=(LLMTimeoutError,),
|
||||||
|
max_retries=3,
|
||||||
|
retry_backoff=60,
|
||||||
|
retry_backoff_max=600,
|
||||||
|
retry_jitter=True,
|
||||||
|
)
|
||||||
|
def apply_ai_suggestions(self, action_id: int, document_id: int) -> None:
|
||||||
|
"""
|
||||||
|
Deferred "apply AI suggestions" workflow action.
|
||||||
|
"""
|
||||||
|
from documents.models import WorkflowAction
|
||||||
|
from documents.workflows.ai import apply_ai_suggestions_to_document
|
||||||
|
|
||||||
|
try:
|
||||||
|
action = WorkflowAction.objects.get(pk=action_id)
|
||||||
|
document = Document.objects.select_related("owner").get(pk=document_id)
|
||||||
|
except (WorkflowAction.DoesNotExist, Document.DoesNotExist):
|
||||||
|
logger.warning(
|
||||||
|
"Workflow action %s or document %s no longer exists, "
|
||||||
|
"not applying AI suggestions",
|
||||||
|
action_id,
|
||||||
|
document_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not apply_ai_suggestions_to_document(action, document):
|
||||||
|
return
|
||||||
|
|
||||||
|
# No document_updated signal to avoid loop
|
||||||
|
clear_document_caches(document.pk)
|
||||||
|
index_document.delay(document.pk)
|
||||||
|
|
||||||
|
ai_config = AIConfig()
|
||||||
|
if ai_config.llm_index_enabled:
|
||||||
|
update_document_in_llm_index.apply_async(kwargs={"document": document})
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def update_document_in_llm_index(document) -> None:
|
def update_document_in_llm_index(document) -> None:
|
||||||
llm_index_add_or_update_document(document)
|
llm_index_add_or_update_document(document)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from django.contrib.auth.models import User
|
|||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.exceptions import FieldError
|
from django.core.exceptions import FieldError
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from django.test import TestCase as DjangoTestCase
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
@@ -21,6 +22,7 @@ from documents.filters import TitleContentFilter
|
|||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from documents.tests.utils import DirectoriesMixin
|
||||||
from documents.tests.utils import read_streaming_response
|
from documents.tests.utils import read_streaming_response
|
||||||
|
from documents.views import DocumentSelectionMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -923,3 +925,36 @@ class TestVersionAwareFilters(TestCase):
|
|||||||
|
|
||||||
self.assertIs(result, queryset)
|
self.assertIs(result, queryset)
|
||||||
queryset.filter.assert_not_called()
|
queryset.filter.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestBulkSelectionExcludesVersions(DjangoTestCase):
|
||||||
|
def test_select_all_matching_does_not_select_version_documents(self) -> None:
|
||||||
|
"""
|
||||||
|
"Select all matching" reconstructs the document list, which never
|
||||||
|
contains version documents as rows of their own.
|
||||||
|
"""
|
||||||
|
user = User.objects.create_superuser(username="bulk_versions")
|
||||||
|
root = Document.objects.create(
|
||||||
|
title="shared-title root",
|
||||||
|
checksum="bulk-root",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
content="root",
|
||||||
|
)
|
||||||
|
Document.objects.create(
|
||||||
|
title="shared-title version",
|
||||||
|
checksum="bulk-version",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
root_document=root,
|
||||||
|
version_index=1,
|
||||||
|
content="version",
|
||||||
|
)
|
||||||
|
|
||||||
|
selected = DocumentSelectionMixin()._resolve_document_ids(
|
||||||
|
user=user,
|
||||||
|
validated_data={
|
||||||
|
"all": True,
|
||||||
|
"filters": {"title__icontains": "shared-title"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(selected, [root.id])
|
||||||
|
|||||||
@@ -641,6 +641,145 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertEqual(self.workflow.triggers.get(), self.trigger)
|
self.assertEqual(self.workflow.triggers.get(), self.trigger)
|
||||||
|
|
||||||
|
def _post_ai_suggestions_workflow(self, *, trigger_types, action: dict):
|
||||||
|
def trigger(trigger_type):
|
||||||
|
# consumption triggers require a filter of their own
|
||||||
|
if trigger_type == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION:
|
||||||
|
return {"type": trigger_type, "filter_filename": "*.pdf"}
|
||||||
|
return {"type": trigger_type}
|
||||||
|
|
||||||
|
return self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"name": "Apply AI suggestions",
|
||||||
|
"order": 1,
|
||||||
|
"triggers": [trigger(t) for t in trigger_types],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": WorkflowAction.WorkflowActionType.APPLY_AI_SUGGESTIONS,
|
||||||
|
**action,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_api_create_apply_ai_suggestions_action(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- API request to create a workflow with an apply AI suggestions
|
||||||
|
action and a valid set of fields
|
||||||
|
WHEN:
|
||||||
|
- API is called
|
||||||
|
THEN:
|
||||||
|
- The workflow is created with the chosen options
|
||||||
|
"""
|
||||||
|
response = self._post_ai_suggestions_workflow(
|
||||||
|
trigger_types=[WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED],
|
||||||
|
action={
|
||||||
|
"ai_suggestion_fields": ["title", "tags", "correspondent"],
|
||||||
|
"ai_create_missing": True,
|
||||||
|
"ai_overwrite_existing": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
action = Workflow.objects.get(name="Apply AI suggestions").actions.first()
|
||||||
|
self.assertEqual(
|
||||||
|
action.ai_suggestion_fields,
|
||||||
|
["title", "tags", "correspondent"],
|
||||||
|
)
|
||||||
|
self.assertTrue(action.ai_create_missing)
|
||||||
|
self.assertTrue(action.ai_overwrite_existing)
|
||||||
|
|
||||||
|
def test_api_create_apply_ai_suggestions_action_requires_fields(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- API request to create an apply AI suggestions action with no
|
||||||
|
fields selected, which could never do anything
|
||||||
|
WHEN:
|
||||||
|
- API is called
|
||||||
|
THEN:
|
||||||
|
- Correct HTTP 400 response
|
||||||
|
- No objects are created
|
||||||
|
"""
|
||||||
|
existing_count = Workflow.objects.count()
|
||||||
|
|
||||||
|
response = self._post_ai_suggestions_workflow(
|
||||||
|
trigger_types=[WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED],
|
||||||
|
action={"ai_suggestion_fields": []},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertEqual(Workflow.objects.count(), existing_count)
|
||||||
|
|
||||||
|
def test_api_create_apply_ai_suggestions_action_rejects_unknown_field(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- API request to create an apply AI suggestions action naming a
|
||||||
|
field that does not exist
|
||||||
|
WHEN:
|
||||||
|
- API is called
|
||||||
|
THEN:
|
||||||
|
- Correct HTTP 400 response
|
||||||
|
"""
|
||||||
|
response = self._post_ai_suggestions_workflow(
|
||||||
|
trigger_types=[WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED],
|
||||||
|
action={"ai_suggestion_fields": ["title", "not_a_field"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
def test_api_create_apply_ai_suggestions_action_rejects_consumption_only(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- API request to create an apply AI suggestions action whose only
|
||||||
|
trigger is consumption started, so there is no document content
|
||||||
|
to make suggestions from yet
|
||||||
|
WHEN:
|
||||||
|
- API is called
|
||||||
|
THEN:
|
||||||
|
- Correct HTTP 400 response
|
||||||
|
- No objects are created
|
||||||
|
"""
|
||||||
|
existing_count = Workflow.objects.count()
|
||||||
|
|
||||||
|
response = self._post_ai_suggestions_workflow(
|
||||||
|
trigger_types=[WorkflowTrigger.WorkflowTriggerType.CONSUMPTION],
|
||||||
|
action={"ai_suggestion_fields": ["title"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertEqual(Workflow.objects.count(), existing_count)
|
||||||
|
|
||||||
|
def test_api_create_apply_ai_suggestions_action_allows_extra_consumption_trigger(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- API request to create an apply AI suggestions action with a
|
||||||
|
consumption trigger alongside a usable one
|
||||||
|
WHEN:
|
||||||
|
- API is called
|
||||||
|
THEN:
|
||||||
|
- The workflow is created, the action applies to the other trigger
|
||||||
|
"""
|
||||||
|
response = self._post_ai_suggestions_workflow(
|
||||||
|
trigger_types=[
|
||||||
|
WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||||
|
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
],
|
||||||
|
action={"ai_suggestion_fields": ["title"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
|
||||||
def test_api_create_workflow_trigger_action_empty_fields(self) -> None:
|
def test_api_create_workflow_trigger_action_empty_fields(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -1,239 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from django.db import connection
|
|
||||||
from django.test.utils import CaptureQueriesContext
|
|
||||||
from rest_framework import status
|
|
||||||
|
|
||||||
from documents.models import Document
|
|
||||||
from documents.tests.factories import DocumentFactory
|
|
||||||
from documents.versioning import LATEST_VERSION_CONTENT_PREFETCH_ATTR
|
|
||||||
from documents.versioning import has_prefetched_effective_content
|
|
||||||
from documents.versioning import latest_version_content_prefetch
|
|
||||||
from documents.views import DocumentViewSet
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from rest_framework.test import APIClient
|
|
||||||
|
|
||||||
|
|
||||||
class TestNeedsEffectiveContentAnnotation:
|
|
||||||
"""
|
|
||||||
DocumentViewSet._needs_effective_content_annotation() decides whether
|
|
||||||
the effective_content correlated subquery is worth attaching to the
|
|
||||||
queryset at all -- see TestDocumentListEffectiveContentAnnotation below
|
|
||||||
for why. This only checks that decision's own logic (a plain query-param
|
|
||||||
membership test), not that Django/DRF's filtering machinery works.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("params", "expected"),
|
|
||||||
[
|
|
||||||
({}, False),
|
|
||||||
({"ordering": "-added"}, False),
|
|
||||||
({"tags__id__in": "1,2"}, False),
|
|
||||||
({"search": ""}, False),
|
|
||||||
({"search": " "}, False),
|
|
||||||
({"content__icontains": ""}, False),
|
|
||||||
({"search": "foo"}, True),
|
|
||||||
({"title_content": "foo"}, True),
|
|
||||||
({"content__istartswith": "foo"}, True),
|
|
||||||
({"content__iendswith": "foo"}, True),
|
|
||||||
({"content__icontains": "foo"}, True),
|
|
||||||
({"content__iexact": "foo"}, True),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_detects_content_filter_params(
|
|
||||||
self,
|
|
||||||
params: dict[str, str],
|
|
||||||
expected: bool, # noqa: FBT001
|
|
||||||
) -> None:
|
|
||||||
# GIVEN a view bound to a request carrying the given query params
|
|
||||||
view = DocumentViewSet()
|
|
||||||
view.request = SimpleNamespace(query_params=params)
|
|
||||||
|
|
||||||
# WHEN checking whether the effective_content annotation is needed
|
|
||||||
# THEN it's needed only for requests that actually filter on it
|
|
||||||
assert view._needs_effective_content_annotation() is expected
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
class TestDocumentListEffectiveContentAnnotation:
|
|
||||||
"""
|
|
||||||
DocumentViewSet.get_queryset() only attaches the effective_content
|
|
||||||
correlated subquery when a request actually filters on it. Attaching it
|
|
||||||
unconditionally re-executes it once per candidate row before the page's
|
|
||||||
LIMIT is applied -- fine on SQLite/Postgres, but pathological on
|
|
||||||
MariaDB's default cardinality estimation for the root_document_id
|
|
||||||
self-join once candidate counts get large (see the root_document_id /
|
|
||||||
effective_content perf investigation).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_list_without_content_filter_skips_annotation_but_returns_latest_content(
|
|
||||||
self,
|
|
||||||
admin_client: APIClient,
|
|
||||||
) -> None:
|
|
||||||
# GIVEN a root document whose latest version has different content
|
|
||||||
root = DocumentFactory(content="old-root-content")
|
|
||||||
DocumentFactory(
|
|
||||||
root_document=root,
|
|
||||||
version_index=1,
|
|
||||||
content="new-version-content",
|
|
||||||
)
|
|
||||||
|
|
||||||
# WHEN listing documents with no search/content-filter param
|
|
||||||
with CaptureQueriesContext(connection) as ctx:
|
|
||||||
response = admin_client.get("/api/documents/?fields=id,content")
|
|
||||||
|
|
||||||
# THEN the response still reflects the latest version's content...
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
assert response.data["results"] == [
|
|
||||||
{"id": root.id, "content": "new-version-content"},
|
|
||||||
]
|
|
||||||
# ...without the database ever evaluating effective_content per row
|
|
||||||
assert not any(
|
|
||||||
"effective_content" in query["sql"] for query in ctx.captured_queries
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_latest_version_content_prefetch_carries_only_the_newest_version(
|
|
||||||
self,
|
|
||||||
) -> None:
|
|
||||||
# GIVEN a root document with two versions
|
|
||||||
root = DocumentFactory(content="root-content")
|
|
||||||
DocumentFactory(
|
|
||||||
root_document=root,
|
|
||||||
version_index=1,
|
|
||||||
content="older-version-content",
|
|
||||||
)
|
|
||||||
DocumentFactory(
|
|
||||||
root_document=root,
|
|
||||||
version_index=2,
|
|
||||||
content="newest-version-content",
|
|
||||||
)
|
|
||||||
|
|
||||||
# WHEN fetching the root through latest_version_content_prefetch()
|
|
||||||
fetched_root = (
|
|
||||||
Document.objects.filter(pk=root.pk)
|
|
||||||
.prefetch_related(
|
|
||||||
latest_version_content_prefetch(),
|
|
||||||
)
|
|
||||||
.get()
|
|
||||||
)
|
|
||||||
|
|
||||||
# THEN the prefetch carries only the single newest version, not
|
|
||||||
# every historical version's content (the whole point of not
|
|
||||||
# reusing the metadata-only "versions" prefetch for this)
|
|
||||||
latest = getattr(fetched_root, LATEST_VERSION_CONTENT_PREFETCH_ATTR)
|
|
||||||
assert [v.content for v in latest] == ["newest-version-content"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestHasPrefetchedEffectiveContent:
|
|
||||||
"""
|
|
||||||
DocumentSerializer.to_representation() only calls get_effective_content()
|
|
||||||
when has_prefetched_effective_content() says it's cheap -- otherwise a
|
|
||||||
caller that never set up an annotation or prefetch (TrashView,
|
|
||||||
GlobalSearchView, which build their own querysets and don't display
|
|
||||||
content at all) would pay for a per-instance query nobody asked for.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_false_with_no_annotation_or_prefetch(self) -> None:
|
|
||||||
document = Document()
|
|
||||||
assert has_prefetched_effective_content(document) is False
|
|
||||||
|
|
||||||
def test_true_with_effective_content_annotation(self) -> None:
|
|
||||||
document = Document()
|
|
||||||
document.effective_content = "resolved"
|
|
||||||
assert has_prefetched_effective_content(document) is True
|
|
||||||
|
|
||||||
def test_true_with_lean_prefetch_attr_even_when_empty(self) -> None:
|
|
||||||
document = Document()
|
|
||||||
setattr(document, LATEST_VERSION_CONTENT_PREFETCH_ATTR, [])
|
|
||||||
assert has_prefetched_effective_content(document) is True
|
|
||||||
|
|
||||||
def test_true_with_metadata_versions_prefetch_cache(self) -> None:
|
|
||||||
document = Document()
|
|
||||||
document._prefetched_objects_cache = {"versions": []}
|
|
||||||
assert has_prefetched_effective_content(document) is True
|
|
||||||
|
|
||||||
|
|
||||||
def _get_effective_content_fallback_queries(
|
|
||||||
ctx: CaptureQueriesContext,
|
|
||||||
) -> list[dict[str, str]]:
|
|
||||||
"""
|
|
||||||
Document.get_effective_content()'s per-instance fallback (no annotation,
|
|
||||||
no prefetch) is a `.values_list("content", flat=True).first()` query --
|
|
||||||
a SELECT of just the content column. Distinct from get_versions()'s own,
|
|
||||||
unrelated per-instance metadata query (id/checksum/added/etc, no
|
|
||||||
content) run to build the "versions" response field, which isn't part
|
|
||||||
of what this test file covers.
|
|
||||||
"""
|
|
||||||
return [
|
|
||||||
q
|
|
||||||
for q in ctx.captured_queries
|
|
||||||
if q["sql"].startswith('SELECT "documents_document"."content" FROM')
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
class TestTrashAndGlobalSearchDoNotResolveEffectiveContent:
|
|
||||||
"""
|
|
||||||
TrashView and GlobalSearchView serialize Document instances with
|
|
||||||
DocumentSerializer too, but build their querysets independently of
|
|
||||||
DocumentViewSet.get_queryset() -- and neither actually displays
|
|
||||||
document content. They should keep showing the document's own,
|
|
||||||
unresolved content with no extra query, exactly as before
|
|
||||||
effective_content resolution existed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_trash_list_shows_unresolved_content_with_no_extra_query(
|
|
||||||
self,
|
|
||||||
admin_client: APIClient,
|
|
||||||
) -> None:
|
|
||||||
# GIVEN a trashed root document whose own content differs from what
|
|
||||||
# a (also trashed, since deletion cascades) version would have had
|
|
||||||
root = DocumentFactory(content="own-content")
|
|
||||||
DocumentFactory(
|
|
||||||
root_document=root,
|
|
||||||
version_index=1,
|
|
||||||
content="version-content",
|
|
||||||
)
|
|
||||||
root.delete()
|
|
||||||
|
|
||||||
# WHEN listing trash
|
|
||||||
with CaptureQueriesContext(connection) as ctx:
|
|
||||||
response = admin_client.get("/api/trash/")
|
|
||||||
|
|
||||||
# THEN the response shows the document's own content...
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
[result] = [r for r in response.data["results"] if r["id"] == root.id]
|
|
||||||
assert result["content"] == "own-content"
|
|
||||||
# ...without ever querying for versions to resolve it
|
|
||||||
assert _get_effective_content_fallback_queries(ctx) == []
|
|
||||||
|
|
||||||
def test_global_search_db_only_shows_unresolved_content_with_no_extra_query(
|
|
||||||
self,
|
|
||||||
admin_client: APIClient,
|
|
||||||
) -> None:
|
|
||||||
# GIVEN a root document, findable by title, whose own content
|
|
||||||
# differs from its latest version's
|
|
||||||
root = DocumentFactory(title="findme", content="own-content")
|
|
||||||
DocumentFactory(
|
|
||||||
root_document=root,
|
|
||||||
version_index=1,
|
|
||||||
content="version-content",
|
|
||||||
)
|
|
||||||
|
|
||||||
# WHEN using the global search endpoint's db_only mode
|
|
||||||
with CaptureQueriesContext(connection) as ctx:
|
|
||||||
response = admin_client.get(
|
|
||||||
"/api/search/?query=findme&db_only=true",
|
|
||||||
)
|
|
||||||
|
|
||||||
# THEN the response shows the document's own content...
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
[result] = [d for d in response.data["documents"] if d["id"] == root.id]
|
|
||||||
assert result["content"] == "own-content"
|
|
||||||
# ...without ever querying for versions to resolve it
|
|
||||||
assert _get_effective_content_fallback_queries(ctx) == []
|
|
||||||
@@ -385,6 +385,25 @@ class TestTaskFailureHandler:
|
|||||||
task_failure_handler(task_id=None, exception=ValueError("x"), traceback=None)
|
task_failure_handler(task_id=None, exception=ValueError("x"), traceback=None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestApplyAiSuggestionsTracking:
|
||||||
|
def test_records_the_document_it_is_for(self) -> None:
|
||||||
|
"""
|
||||||
|
The action queues one task per document, so the tracked record notes
|
||||||
|
which document it is for -- otherwise a bulk run is an indistinguishable
|
||||||
|
wall of identical entries in the tasks list.
|
||||||
|
"""
|
||||||
|
task_id = send_publish(
|
||||||
|
"documents.tasks.apply_ai_suggestions",
|
||||||
|
(),
|
||||||
|
{"action_id": 1, "document_id": 42},
|
||||||
|
)
|
||||||
|
|
||||||
|
task = PaperlessTask.objects.get(task_id=task_id)
|
||||||
|
assert task.task_type == PaperlessTask.TaskType.APPLY_AI_SUGGESTIONS
|
||||||
|
assert task.input_data == {"document_id": 42}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestTaskRevokedHandler:
|
class TestTaskRevokedHandler:
|
||||||
def test_marks_task_revoked(self, mocker: pytest_mock.MockerFixture) -> None:
|
def test_marks_task_revoked(self, mocker: pytest_mock.MockerFixture) -> None:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from documents.models import Correspondent
|
|||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
|
from documents.models import WorkflowAction
|
||||||
from documents.sanity_checker import SanityCheckFailedException
|
from documents.sanity_checker import SanityCheckFailedException
|
||||||
from documents.sanity_checker import SanityCheckMessages
|
from documents.sanity_checker import SanityCheckMessages
|
||||||
from documents.tests.test_classifier import dummy_preprocess
|
from documents.tests.test_classifier import dummy_preprocess
|
||||||
@@ -447,3 +448,110 @@ class TestAIIndex(DirectoriesMixin, TestCase):
|
|||||||
rebuild=False,
|
rebuild=False,
|
||||||
document_ids=doc_ids,
|
document_ids=doc_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyAISuggestionsTask(DirectoriesMixin, TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
super().setUp()
|
||||||
|
self.doc = Document.objects.create(
|
||||||
|
title="doc",
|
||||||
|
content="content",
|
||||||
|
checksum="apply-ai-suggestions",
|
||||||
|
)
|
||||||
|
self.action = WorkflowAction.objects.create(
|
||||||
|
type=WorkflowAction.WorkflowActionType.APPLY_AI_SUGGESTIONS,
|
||||||
|
ai_suggestion_fields=[WorkflowAction.AISuggestionField.TITLE],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_reindexes_without_sending_document_updated(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An apply AI suggestions action that changes the document
|
||||||
|
WHEN:
|
||||||
|
- The task runs
|
||||||
|
THEN:
|
||||||
|
- The search index and caches are refreshed directly, deliberately
|
||||||
|
not via the document_updated signal: that re-runs updated
|
||||||
|
workflows, which for this action means queueing another LLM
|
||||||
|
query for a document it just changed, forever
|
||||||
|
"""
|
||||||
|
with (
|
||||||
|
mock.patch(
|
||||||
|
"documents.workflows.ai.apply_ai_suggestions_to_document",
|
||||||
|
return_value=["title"],
|
||||||
|
),
|
||||||
|
mock.patch("documents.tasks.index_document") as index_document,
|
||||||
|
mock.patch("documents.tasks.clear_document_caches") as clear_caches,
|
||||||
|
mock.patch("documents.tasks.document_updated") as document_updated,
|
||||||
|
):
|
||||||
|
tasks.apply_ai_suggestions(self.action.pk, self.doc.pk)
|
||||||
|
|
||||||
|
index_document.delay.assert_called_once_with(self.doc.pk)
|
||||||
|
clear_caches.assert_called_once_with(self.doc.pk)
|
||||||
|
document_updated.send.assert_not_called()
|
||||||
|
|
||||||
|
def test_no_changes_skips_reindex(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An apply AI suggestions action that changes nothing
|
||||||
|
WHEN:
|
||||||
|
- The task runs
|
||||||
|
THEN:
|
||||||
|
- No reindexing work is queued
|
||||||
|
"""
|
||||||
|
with (
|
||||||
|
mock.patch(
|
||||||
|
"documents.workflows.ai.apply_ai_suggestions_to_document",
|
||||||
|
return_value=[],
|
||||||
|
),
|
||||||
|
mock.patch("documents.tasks.index_document") as index_document,
|
||||||
|
):
|
||||||
|
tasks.apply_ai_suggestions(self.action.pk, self.doc.pk)
|
||||||
|
|
||||||
|
index_document.delay.assert_not_called()
|
||||||
|
|
||||||
|
@override_settings(AI_ENABLED=True, LLM_EMBEDDING_BACKEND="huggingface")
|
||||||
|
def test_updates_llm_index_when_enabled(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An apply AI suggestions action that changes the document
|
||||||
|
- The LLM index is enabled
|
||||||
|
WHEN:
|
||||||
|
- The task runs
|
||||||
|
THEN:
|
||||||
|
- The document is updated in the LLM index too
|
||||||
|
"""
|
||||||
|
with (
|
||||||
|
mock.patch(
|
||||||
|
"documents.workflows.ai.apply_ai_suggestions_to_document",
|
||||||
|
return_value=["title"],
|
||||||
|
),
|
||||||
|
mock.patch("documents.tasks.index_document"),
|
||||||
|
mock.patch(
|
||||||
|
"documents.tasks.update_document_in_llm_index",
|
||||||
|
) as update_in_llm_index,
|
||||||
|
):
|
||||||
|
tasks.apply_ai_suggestions(self.action.pk, self.doc.pk)
|
||||||
|
|
||||||
|
update_in_llm_index.apply_async.assert_called_once()
|
||||||
|
|
||||||
|
def test_deleted_document_is_a_noop(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A document that was deleted between the workflow running and the
|
||||||
|
queued task starting
|
||||||
|
WHEN:
|
||||||
|
- The task runs
|
||||||
|
THEN:
|
||||||
|
- It logs and exits rather than raising
|
||||||
|
"""
|
||||||
|
with (
|
||||||
|
mock.patch(
|
||||||
|
"documents.workflows.ai.apply_ai_suggestions_to_document",
|
||||||
|
) as apply_suggestions,
|
||||||
|
self.assertLogs("paperless.tasks", level="WARNING") as cm,
|
||||||
|
):
|
||||||
|
tasks.apply_ai_suggestions(self.action.pk, self.doc.pk + 1000)
|
||||||
|
|
||||||
|
apply_suggestions.assert_not_called()
|
||||||
|
self.assertIn("no longer exists", "".join(cm.output))
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ from documents.file_handling import create_source_path_directory
|
|||||||
from documents.file_handling import generate_filename
|
from documents.file_handling import generate_filename
|
||||||
from documents.file_handling import generate_unique_filename
|
from documents.file_handling import generate_unique_filename
|
||||||
from documents.signals.handlers import run_workflows
|
from documents.signals.handlers import run_workflows
|
||||||
|
from documents.workflows.ai import apply_ai_suggestions_to_document
|
||||||
from documents.workflows.webhooks import send_webhook
|
from documents.workflows.webhooks import send_webhook
|
||||||
|
from paperless_ai.base_model import ClassificationSuggestions
|
||||||
|
from paperless_ai.exceptions import LLMTimeoutError
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
@@ -5488,3 +5491,481 @@ class TestRemoteOCRWorkflowAction(DirectoriesMixin, SampleDirMixin, APITestCase)
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertIn("only applies to consumption triggers", "".join(cm.output))
|
self.assertIn("only applies to consumption triggers", "".join(cm.output))
|
||||||
|
|
||||||
|
|
||||||
|
SUGGESTIONS: ClassificationSuggestions = {
|
||||||
|
"title": "Suggested Title",
|
||||||
|
"tags": {
|
||||||
|
"existing_ids": [],
|
||||||
|
"new_names": ["Existing Tag", "Suggested Tag"],
|
||||||
|
},
|
||||||
|
"correspondents": {
|
||||||
|
"existing_ids": [],
|
||||||
|
"new_names": ["Existing Correspondent", "Suggested Correspondent"],
|
||||||
|
},
|
||||||
|
"document_types": {
|
||||||
|
"existing_ids": [],
|
||||||
|
"new_names": ["Suggested Document Type"],
|
||||||
|
},
|
||||||
|
"storage_paths": {
|
||||||
|
"existing_ids": [],
|
||||||
|
"new_names": ["Suggested Storage Path"],
|
||||||
|
},
|
||||||
|
"dates": ["2024-03-05"],
|
||||||
|
}
|
||||||
|
|
||||||
|
ALL_SUGGESTION_FIELDS = [
|
||||||
|
WorkflowAction.AISuggestionField.TITLE,
|
||||||
|
WorkflowAction.AISuggestionField.TAGS,
|
||||||
|
WorkflowAction.AISuggestionField.CORRESPONDENT,
|
||||||
|
WorkflowAction.AISuggestionField.DOCUMENT_TYPE,
|
||||||
|
WorkflowAction.AISuggestionField.STORAGE_PATH,
|
||||||
|
WorkflowAction.AISuggestionField.CREATED,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(AI_ENABLED=True)
|
||||||
|
class TestApplyAISuggestionsWorkflowAction(
|
||||||
|
DirectoriesMixin,
|
||||||
|
SampleDirMixin,
|
||||||
|
APITestCase,
|
||||||
|
):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
super().setUp()
|
||||||
|
self.user = User.objects.create(username="ai-user")
|
||||||
|
self.doc = Document.objects.create(
|
||||||
|
title="original.pdf",
|
||||||
|
content="the document content",
|
||||||
|
checksum="ai-suggestions-checksum",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
created=datetime.date(2020, 1, 1),
|
||||||
|
owner=self.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
def make_action(self, **kwargs) -> WorkflowAction:
|
||||||
|
return WorkflowAction.objects.create(
|
||||||
|
type=WorkflowAction.WorkflowActionType.APPLY_AI_SUGGESTIONS,
|
||||||
|
ai_suggestion_fields=kwargs.pop(
|
||||||
|
"ai_suggestion_fields",
|
||||||
|
ALL_SUGGESTION_FIELDS,
|
||||||
|
),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
def make_workflow(self, action: WorkflowAction, trigger_type) -> Workflow:
|
||||||
|
trigger = WorkflowTrigger.objects.create(type=trigger_type)
|
||||||
|
w = Workflow.objects.create(name="Apply AI suggestions", order=0)
|
||||||
|
w.triggers.add(trigger)
|
||||||
|
w.actions.add(action)
|
||||||
|
w.save()
|
||||||
|
return w
|
||||||
|
|
||||||
|
def apply(
|
||||||
|
self,
|
||||||
|
action: WorkflowAction,
|
||||||
|
suggestions: ClassificationSuggestions = SUGGESTIONS,
|
||||||
|
) -> list[str]:
|
||||||
|
with mock.patch(
|
||||||
|
"documents.workflows.ai.get_ai_document_classification",
|
||||||
|
return_value=suggestions,
|
||||||
|
):
|
||||||
|
changed = apply_ai_suggestions_to_document(action, self.doc)
|
||||||
|
self.doc.refresh_from_db()
|
||||||
|
return changed
|
||||||
|
|
||||||
|
def test_document_added_trigger_queues_task(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A document added workflow with an apply AI suggestions action
|
||||||
|
WHEN:
|
||||||
|
- A matching document is added
|
||||||
|
THEN:
|
||||||
|
- The work is queued rather than run inline, so a slow LLM query
|
||||||
|
cannot stall the rest of the workflow run
|
||||||
|
"""
|
||||||
|
action = self.make_action()
|
||||||
|
self.make_workflow(action, WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED)
|
||||||
|
|
||||||
|
with mock.patch("documents.tasks.apply_ai_suggestions.delay") as delay:
|
||||||
|
run_workflows(
|
||||||
|
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
self.doc,
|
||||||
|
)
|
||||||
|
|
||||||
|
delay.assert_called_once_with(action_id=action.pk, document_id=self.doc.pk)
|
||||||
|
|
||||||
|
def test_consumption_trigger_is_ignored(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A workflow with an apply AI suggestions action and a consumption
|
||||||
|
trigger alongside a valid one
|
||||||
|
WHEN:
|
||||||
|
- The consumption trigger fires
|
||||||
|
THEN:
|
||||||
|
- The action is skipped, since the document has not been parsed
|
||||||
|
yet and so has no content to make suggestions from
|
||||||
|
"""
|
||||||
|
action = self.make_action()
|
||||||
|
w = self.make_workflow(
|
||||||
|
action,
|
||||||
|
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
)
|
||||||
|
w.triggers.add(
|
||||||
|
WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
test_file = shutil.copy(
|
||||||
|
self.SAMPLE_DIR / "simple.pdf",
|
||||||
|
self.dirs.scratch_dir / "simple.pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch("documents.tasks.apply_ai_suggestions.delay") as delay,
|
||||||
|
self.assertLogs("paperless.handlers", level="DEBUG") as cm,
|
||||||
|
):
|
||||||
|
run_workflows(
|
||||||
|
WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||||
|
ConsumableDocument(
|
||||||
|
source=DocumentSource.ConsumeFolder,
|
||||||
|
original_file=test_file,
|
||||||
|
),
|
||||||
|
overrides=DocumentMetadataOverrides(),
|
||||||
|
)
|
||||||
|
|
||||||
|
delay.assert_not_called()
|
||||||
|
self.assertIn("does not apply to consumption triggers", "".join(cm.output))
|
||||||
|
|
||||||
|
def test_no_selected_fields_does_nothing(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An action with no suggestion fields selected
|
||||||
|
WHEN:
|
||||||
|
- The action is applied
|
||||||
|
THEN:
|
||||||
|
- Nothing is changed and it is logged
|
||||||
|
"""
|
||||||
|
action = self.make_action(ai_suggestion_fields=[])
|
||||||
|
|
||||||
|
with self.assertLogs("paperless.workflows.ai", level="WARNING") as cm:
|
||||||
|
changed = self.apply(action)
|
||||||
|
|
||||||
|
self.assertEqual(changed, [])
|
||||||
|
self.assertIn("no AI suggestion fields selected", "".join(cm.output))
|
||||||
|
|
||||||
|
@override_settings(AI_ENABLED=False)
|
||||||
|
def test_ai_disabled_does_nothing(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An action on an install where AI has since been disabled
|
||||||
|
WHEN:
|
||||||
|
- The action is applied
|
||||||
|
THEN:
|
||||||
|
- Nothing is changed and it is logged
|
||||||
|
"""
|
||||||
|
action = self.make_action()
|
||||||
|
|
||||||
|
with self.assertLogs("paperless.workflows.ai", level="ERROR") as cm:
|
||||||
|
changed = self.apply(action)
|
||||||
|
|
||||||
|
self.assertEqual(changed, [])
|
||||||
|
self.assertIn("AI is not enabled", "".join(cm.output))
|
||||||
|
|
||||||
|
def test_invalid_configuration_leaves_document_untouched(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An AI backend that is misconfigured
|
||||||
|
WHEN:
|
||||||
|
- The action is applied
|
||||||
|
THEN:
|
||||||
|
- The failure is logged and the document is left alone. It is not
|
||||||
|
re-raised, because retrying will not fix a bad configuration
|
||||||
|
"""
|
||||||
|
action = self.make_action()
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch(
|
||||||
|
"documents.workflows.ai.get_ai_document_classification",
|
||||||
|
side_effect=ValueError("nope"),
|
||||||
|
),
|
||||||
|
self.assertLogs("paperless.workflows.ai", level="ERROR") as cm,
|
||||||
|
):
|
||||||
|
changed = apply_ai_suggestions_to_document(action, self.doc)
|
||||||
|
|
||||||
|
self.assertEqual(changed, [])
|
||||||
|
self.doc.refresh_from_db()
|
||||||
|
self.assertEqual(self.doc.title, "original.pdf")
|
||||||
|
self.assertIn("Invalid AI configuration", "".join(cm.output))
|
||||||
|
|
||||||
|
def test_transient_llm_failure_is_raised_for_retry(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An LLM backend that times out, or rate limits the request
|
||||||
|
WHEN:
|
||||||
|
- The action is applied
|
||||||
|
THEN:
|
||||||
|
- The error propagates so the queued task can back off and retry,
|
||||||
|
rather than silently dropping this document's suggestions
|
||||||
|
"""
|
||||||
|
action = self.make_action()
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch(
|
||||||
|
"documents.workflows.ai.get_ai_document_classification",
|
||||||
|
side_effect=LLMTimeoutError(),
|
||||||
|
),
|
||||||
|
self.assertRaises(LLMTimeoutError),
|
||||||
|
):
|
||||||
|
apply_ai_suggestions_to_document(action, self.doc)
|
||||||
|
|
||||||
|
self.doc.refresh_from_db()
|
||||||
|
self.assertEqual(self.doc.title, "original.pdf")
|
||||||
|
|
||||||
|
def test_only_matching_objects_are_applied(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An action without create missing, and only some of the suggested
|
||||||
|
objects existing
|
||||||
|
WHEN:
|
||||||
|
- The action is applied
|
||||||
|
THEN:
|
||||||
|
- Only the existing objects are assigned, unmatched suggestions are
|
||||||
|
dropped rather than creating anything
|
||||||
|
"""
|
||||||
|
tag = Tag.objects.create(name="Existing Tag", owner=self.user)
|
||||||
|
correspondent = Correspondent.objects.create(
|
||||||
|
name="Existing Correspondent",
|
||||||
|
owner=self.user,
|
||||||
|
)
|
||||||
|
action = self.make_action(ai_overwrite_existing=True)
|
||||||
|
|
||||||
|
changed = self.apply(action)
|
||||||
|
|
||||||
|
self.assertEqual(self.doc.correspondent, correspondent)
|
||||||
|
self.assertEqual(list(self.doc.tags.all()), [tag])
|
||||||
|
# Nothing matched for these and create missing is off
|
||||||
|
self.assertIsNone(self.doc.document_type)
|
||||||
|
self.assertIsNone(self.doc.storage_path)
|
||||||
|
self.assertNotIn("document_type", changed)
|
||||||
|
self.assertEqual(Tag.objects.count(), 1)
|
||||||
|
self.assertEqual(Correspondent.objects.count(), 1)
|
||||||
|
|
||||||
|
def test_existing_id_suggestions_are_applied(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- AI suggestions that select existing taxonomy candidates by ID
|
||||||
|
WHEN:
|
||||||
|
- The suggestions are applied
|
||||||
|
THEN:
|
||||||
|
- Each selected object is assigned to the document
|
||||||
|
"""
|
||||||
|
tag = Tag.objects.create(name="Existing Tag", owner=self.user)
|
||||||
|
correspondent = Correspondent.objects.create(
|
||||||
|
name="Existing Correspondent",
|
||||||
|
owner=self.user,
|
||||||
|
)
|
||||||
|
document_type = DocumentType.objects.create(
|
||||||
|
name="Existing Document Type",
|
||||||
|
owner=self.user,
|
||||||
|
)
|
||||||
|
storage_path = StoragePath.objects.create(
|
||||||
|
name="Existing Storage Path",
|
||||||
|
path="{{ title }}",
|
||||||
|
owner=self.user,
|
||||||
|
)
|
||||||
|
action = self.make_action(ai_overwrite_existing=True)
|
||||||
|
suggestions: ClassificationSuggestions = {
|
||||||
|
**SUGGESTIONS,
|
||||||
|
"tags": {"existing_ids": [tag.pk], "new_names": []},
|
||||||
|
"correspondents": {
|
||||||
|
"existing_ids": [correspondent.pk],
|
||||||
|
"new_names": [],
|
||||||
|
},
|
||||||
|
"document_types": {
|
||||||
|
"existing_ids": [document_type.pk],
|
||||||
|
"new_names": [],
|
||||||
|
},
|
||||||
|
"storage_paths": {
|
||||||
|
"existing_ids": [storage_path.pk],
|
||||||
|
"new_names": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
changed = self.apply(action, suggestions)
|
||||||
|
|
||||||
|
self.assertEqual(list(self.doc.tags.all()), [tag])
|
||||||
|
self.assertEqual(self.doc.correspondent, correspondent)
|
||||||
|
self.assertEqual(self.doc.document_type, document_type)
|
||||||
|
self.assertEqual(self.doc.storage_path, storage_path)
|
||||||
|
self.assertTrue(
|
||||||
|
{"tags", "correspondent", "document_type", "storage_path"} <= set(changed),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_missing_creates_objects_owned_by_document_owner(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An action with create missing enabled
|
||||||
|
WHEN:
|
||||||
|
- The action is applied and suggestions match nothing
|
||||||
|
THEN:
|
||||||
|
- Tags, correspondents and document types are created, owned by the
|
||||||
|
document owner so they stay private to them
|
||||||
|
- Storage paths are never created, since a path template cannot be
|
||||||
|
inferred from a name
|
||||||
|
"""
|
||||||
|
action = self.make_action(
|
||||||
|
ai_create_missing=True,
|
||||||
|
ai_overwrite_existing=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
changed = self.apply(action)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
sorted(t.name for t in self.doc.tags.all()),
|
||||||
|
["Existing Tag", "Suggested Tag"],
|
||||||
|
)
|
||||||
|
self.assertEqual(self.doc.correspondent.name, "Existing Correspondent")
|
||||||
|
self.assertEqual(self.doc.correspondent.owner, self.user)
|
||||||
|
self.assertEqual(self.doc.document_type.name, "Suggested Document Type")
|
||||||
|
self.assertEqual(self.doc.document_type.owner, self.user)
|
||||||
|
|
||||||
|
self.assertIsNone(self.doc.storage_path)
|
||||||
|
self.assertFalse(StoragePath.objects.exists())
|
||||||
|
self.assertNotIn("storage_path", changed)
|
||||||
|
|
||||||
|
def test_overwrite_disabled_keeps_existing_values(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An action without overwrite existing
|
||||||
|
- A document that already has a title, created date and
|
||||||
|
correspondent
|
||||||
|
WHEN:
|
||||||
|
- The action is applied
|
||||||
|
THEN:
|
||||||
|
- The existing values are kept, only the empty document type is
|
||||||
|
filled in
|
||||||
|
"""
|
||||||
|
existing = Correspondent.objects.create(name="Mine", owner=self.user)
|
||||||
|
self.doc.correspondent = existing
|
||||||
|
self.doc.save()
|
||||||
|
action = self.make_action(ai_create_missing=True)
|
||||||
|
|
||||||
|
changed = self.apply(action)
|
||||||
|
|
||||||
|
self.assertEqual(self.doc.title, "original.pdf")
|
||||||
|
self.assertEqual(self.doc.created, datetime.date(2020, 1, 1))
|
||||||
|
self.assertEqual(self.doc.correspondent, existing)
|
||||||
|
self.assertEqual(self.doc.document_type.name, "Suggested Document Type")
|
||||||
|
self.assertNotIn("title", changed)
|
||||||
|
self.assertNotIn("correspondent", changed)
|
||||||
|
|
||||||
|
def test_overwrite_enabled_replaces_existing_values(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An action with overwrite existing
|
||||||
|
- A document that already has a title and created date
|
||||||
|
WHEN:
|
||||||
|
- The action is applied
|
||||||
|
THEN:
|
||||||
|
- The suggested values replace them
|
||||||
|
"""
|
||||||
|
action = self.make_action(
|
||||||
|
ai_create_missing=True,
|
||||||
|
ai_overwrite_existing=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
changed = self.apply(action)
|
||||||
|
|
||||||
|
self.assertEqual(self.doc.title, "Suggested Title")
|
||||||
|
self.assertEqual(self.doc.created, datetime.date(2024, 3, 5))
|
||||||
|
self.assertIn("title", changed)
|
||||||
|
self.assertIn("created", changed)
|
||||||
|
|
||||||
|
def test_tags_are_added_not_replaced(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A document that already has a tag unrelated to the suggestions
|
||||||
|
WHEN:
|
||||||
|
- The action is applied with overwrite existing enabled
|
||||||
|
THEN:
|
||||||
|
- The existing tag is kept, since suggested tags are always
|
||||||
|
additive regardless of the overwrite setting
|
||||||
|
"""
|
||||||
|
kept = Tag.objects.create(name="Do Not Remove", owner=self.user)
|
||||||
|
self.doc.tags.add(kept)
|
||||||
|
Tag.objects.create(name="Existing Tag", owner=self.user)
|
||||||
|
action = self.make_action(ai_overwrite_existing=True)
|
||||||
|
|
||||||
|
self.apply(action)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
sorted(t.name for t in self.doc.tags.all()),
|
||||||
|
["Do Not Remove", "Existing Tag"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unselected_fields_are_untouched(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An action that only selects the title
|
||||||
|
WHEN:
|
||||||
|
- The action is applied
|
||||||
|
THEN:
|
||||||
|
- Only the title changes, even though the LLM suggested everything
|
||||||
|
"""
|
||||||
|
action = self.make_action(
|
||||||
|
ai_suggestion_fields=[WorkflowAction.AISuggestionField.TITLE],
|
||||||
|
ai_create_missing=True,
|
||||||
|
ai_overwrite_existing=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
changed = self.apply(action)
|
||||||
|
|
||||||
|
self.assertEqual(changed, ["title"])
|
||||||
|
self.assertEqual(self.doc.title, "Suggested Title")
|
||||||
|
self.assertEqual(self.doc.tags.count(), 0)
|
||||||
|
self.assertIsNone(self.doc.correspondent)
|
||||||
|
self.assertEqual(self.doc.created, datetime.date(2020, 1, 1))
|
||||||
|
|
||||||
|
def test_another_users_private_objects_are_not_matched(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A suggested tag name that exists, but is owned by someone else
|
||||||
|
WHEN:
|
||||||
|
- The action is applied
|
||||||
|
THEN:
|
||||||
|
- It is not assigned, because the document owner cannot see it
|
||||||
|
"""
|
||||||
|
other = User.objects.create(username="someone-else")
|
||||||
|
Tag.objects.create(name="Existing Tag", owner=other)
|
||||||
|
action = self.make_action(
|
||||||
|
ai_suggestion_fields=[WorkflowAction.AISuggestionField.TAGS],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.apply(action)
|
||||||
|
|
||||||
|
self.assertEqual(self.doc.tags.count(), 0)
|
||||||
|
|
||||||
|
def test_unparsable_dates_are_skipped(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Suggested dates that are not all valid
|
||||||
|
WHEN:
|
||||||
|
- The action is applied
|
||||||
|
THEN:
|
||||||
|
- The first usable date is applied and the rest ignored
|
||||||
|
"""
|
||||||
|
action = self.make_action(
|
||||||
|
ai_suggestion_fields=[WorkflowAction.AISuggestionField.CREATED],
|
||||||
|
ai_overwrite_existing=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"documents.workflows.ai.get_ai_document_classification",
|
||||||
|
return_value={**SUGGESTIONS, "dates": ["not a date", "2019-07-04"]},
|
||||||
|
):
|
||||||
|
changed = apply_ai_suggestions_to_document(action, self.doc)
|
||||||
|
|
||||||
|
self.doc.refresh_from_db()
|
||||||
|
self.assertEqual(changed, ["created"])
|
||||||
|
self.assertEqual(self.doc.created, datetime.date(2019, 7, 4))
|
||||||
|
|||||||
@@ -7,12 +7,9 @@ from typing import Any
|
|||||||
|
|
||||||
from django.db.models import F
|
from django.db.models import F
|
||||||
from django.db.models import OuterRef
|
from django.db.models import OuterRef
|
||||||
from django.db.models import Prefetch
|
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
from django.db.models import Subquery
|
from django.db.models import Subquery
|
||||||
from django.db.models import Window
|
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django.db.models.functions import RowNumber
|
|
||||||
|
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
|
|
||||||
@@ -46,68 +43,6 @@ def annotate_effective_content(documents: QuerySet[Document]) -> QuerySet[Docume
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
LATEST_VERSION_CONTENT_PREFETCH_ATTR = "_latest_version_content_prefetch"
|
|
||||||
|
|
||||||
|
|
||||||
def latest_version_content_prefetch() -> Prefetch:
|
|
||||||
"""
|
|
||||||
A Prefetch for Document.versions scoped to just the newest version's
|
|
||||||
content, for get_effective_content()'s fallback when no SQL annotation
|
|
||||||
is present.
|
|
||||||
|
|
||||||
Deliberately not merged into a metadata-only "versions" prefetch (the one
|
|
||||||
used for the serialized versions list): that one fetches every historical
|
|
||||||
version of every document, and pulling full OCR content for versions
|
|
||||||
nobody will read wastes DB transfer/memory at scale. This one is windowed
|
|
||||||
down to a single row per root, then bounded by Prefetch's own IN-list to
|
|
||||||
whatever page/result set it's attached to -- one cheap bulk query total,
|
|
||||||
not one per document and not one per version.
|
|
||||||
"""
|
|
||||||
return Prefetch(
|
|
||||||
"versions",
|
|
||||||
queryset=(
|
|
||||||
Document.objects.filter(
|
|
||||||
root_document_id__isnull=False,
|
|
||||||
deleted_at__isnull=True,
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
rn=Window(
|
|
||||||
RowNumber(),
|
|
||||||
partition_by=F("root_document_id"),
|
|
||||||
order_by=[
|
|
||||||
F("version_index").desc(nulls_last=True),
|
|
||||||
F("id").desc(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.filter(rn=1)
|
|
||||||
.only("id", "root_document_id", "content")
|
|
||||||
),
|
|
||||||
to_attr=LATEST_VERSION_CONTENT_PREFETCH_ATTR,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def has_prefetched_effective_content(document: Document) -> bool:
|
|
||||||
"""
|
|
||||||
True if document.get_effective_content() can answer without an extra
|
|
||||||
per-instance query -- an SQL ``effective_content`` annotation, the lean
|
|
||||||
latest_version_content_prefetch(), or the metadata-only "versions"
|
|
||||||
prefetch is already present on the instance.
|
|
||||||
|
|
||||||
Callers that haven't set any of those up (e.g. views that build their
|
|
||||||
own querysets independently of DocumentViewSet.get_queryset(), like
|
|
||||||
TrashView or GlobalSearchView) intentionally don't pay for version-aware
|
|
||||||
content resolution -- see DocumentSerializer.to_representation(), which
|
|
||||||
uses this to decide whether to call get_effective_content() at all.
|
|
||||||
"""
|
|
||||||
if hasattr(document, "effective_content"):
|
|
||||||
return True
|
|
||||||
if getattr(document, LATEST_VERSION_CONTENT_PREFETCH_ATTR, None) is not None:
|
|
||||||
return True
|
|
||||||
prefetched_cache = getattr(document, "_prefetched_objects_cache", None)
|
|
||||||
return isinstance(prefetched_cache, dict) and "versions" in prefetched_cache
|
|
||||||
|
|
||||||
|
|
||||||
def sort_versions_newest_first(documents: list[Document]) -> list[Document]:
|
def sort_versions_newest_first(documents: list[Document]) -> list[Document]:
|
||||||
"""
|
"""
|
||||||
Same sorting as versions_newest_first()
|
Same sorting as versions_newest_first()
|
||||||
|
|||||||
+21
-63
@@ -233,7 +233,6 @@ from documents.versioning import VersionResolutionError
|
|||||||
from documents.versioning import get_latest_version_for_root
|
from documents.versioning import get_latest_version_for_root
|
||||||
from documents.versioning import get_request_version_param
|
from documents.versioning import get_request_version_param
|
||||||
from documents.versioning import get_root_document
|
from documents.versioning import get_root_document
|
||||||
from documents.versioning import latest_version_content_prefetch
|
|
||||||
from documents.versioning import resolve_requested_version_for_root
|
from documents.versioning import resolve_requested_version_for_root
|
||||||
from documents.versioning import versions_newest_first
|
from documents.versioning import versions_newest_first
|
||||||
from paperless import version
|
from paperless import version
|
||||||
@@ -248,6 +247,7 @@ 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_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.chat import stream_chat_with_documents
|
||||||
from paperless_ai.exceptions import LLMTimeoutError
|
from paperless_ai.exceptions import LLMTimeoutError
|
||||||
from paperless_ai.matching import extract_unmatched_names
|
from paperless_ai.matching import extract_unmatched_names
|
||||||
@@ -666,20 +666,6 @@ class TagViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[Tag]):
|
|||||||
update_document_parent_tags(tag, new_parent)
|
update_document_parent_tags(tag, new_parent)
|
||||||
|
|
||||||
|
|
||||||
def _get_llm_output_language(ai_config: AIConfig, request) -> str | None:
|
|
||||||
output_language = ai_config.llm_output_language
|
|
||||||
if (
|
|
||||||
not output_language
|
|
||||||
and hasattr(request.user, "ui_settings")
|
|
||||||
and isinstance(
|
|
||||||
request.user.ui_settings.settings,
|
|
||||||
dict,
|
|
||||||
)
|
|
||||||
):
|
|
||||||
output_language = request.user.ui_settings.settings.get("language")
|
|
||||||
return output_language
|
|
||||||
|
|
||||||
|
|
||||||
@extend_schema_view(**generate_object_with_permissions_schema(DocumentTypeSerializer))
|
@extend_schema_view(**generate_object_with_permissions_schema(DocumentTypeSerializer))
|
||||||
class DocumentTypeViewSet(
|
class DocumentTypeViewSet(
|
||||||
PermissionsAwareDocumentCountMixin,
|
PermissionsAwareDocumentCountMixin,
|
||||||
@@ -1086,40 +1072,12 @@ class DocumentViewSet(
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Query params whose filtering needs effective_content evaluated in SQL
|
|
||||||
# against every candidate row -- see _needs_effective_content_annotation().
|
|
||||||
_CONTENT_FILTER_PARAMS = (
|
|
||||||
"search", # DRF SearchFilter's search_fields includes effective_content
|
|
||||||
"title_content",
|
|
||||||
"content__istartswith",
|
|
||||||
"content__iendswith",
|
|
||||||
"content__icontains",
|
|
||||||
"content__iexact",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _needs_effective_content_annotation(self) -> bool:
|
|
||||||
# effective_content is a per-row correlated subquery resolving each
|
|
||||||
# document's latest version. Cheap when evaluated only for the page
|
|
||||||
# that survives filtering/sorting/pagination (the common case, via
|
|
||||||
# the "versions" prefetch + Document.get_effective_content()'s
|
|
||||||
# fallback), but if anything filters *on* it, the database has to
|
|
||||||
# evaluate it for every candidate row before the LIMIT is reached --
|
|
||||||
# pathological on MariaDB specifically for the root_document_id
|
|
||||||
# self-join once real candidate counts get large. Everything on this
|
|
||||||
# list is deprecated in favor of the Tantivy-backed search endpoint
|
|
||||||
# (see filters.py's TitleContentFilter/EffectiveContentFilter docs),
|
|
||||||
# so keep paying that cost only when one is actually used. Checked as
|
|
||||||
# a stripped, non-blank value (not just key presence) to match how
|
|
||||||
# DRF's SearchFilter and TitleContentFilter/EffectiveContentFilter
|
|
||||||
# themselves no-op on a blank value -- otherwise an empty `?search=`
|
|
||||||
# or a saved view with a cleared text filter would still pay for the
|
|
||||||
# annotation despite applying no actual predicate.
|
|
||||||
params = self.request.query_params
|
|
||||||
return any(
|
|
||||||
params.get(param, "").strip() for param in self._CONTENT_FILTER_PARAMS
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
latest_version_content = Subquery(
|
||||||
|
versions_newest_first(
|
||||||
|
Document.objects.filter(root_document=OuterRef("pk")),
|
||||||
|
).values("content")[:1],
|
||||||
|
)
|
||||||
# A correlated subquery avoids the LEFT JOIN + Count() this used to
|
# A correlated subquery avoids the LEFT JOIN + Count() this used to
|
||||||
# be, which forced a GROUP BY aggregate over every matching document
|
# be, which forced a GROUP BY aggregate over every matching document
|
||||||
# before the query could even be sorted or limited.
|
# before the query could even be sorted or limited.
|
||||||
@@ -1139,9 +1097,10 @@ class DocumentViewSet(
|
|||||||
# ObjectFilter.filter(). A blanket .distinct() here forces the
|
# ObjectFilter.filter(). A blanket .distinct() here forces the
|
||||||
# database to fully sort and dedupe every visible document before
|
# database to fully sort and dedupe every visible document before
|
||||||
# it can apply LIMIT, which is disastrous at scale.
|
# it can apply LIMIT, which is disastrous at scale.
|
||||||
queryset = (
|
return (
|
||||||
Document.objects.filter(root_document__isnull=True)
|
Document.objects.filter(root_document__isnull=True)
|
||||||
.order_by("-created", "-id")
|
.order_by("-created", "-id")
|
||||||
|
.annotate(effective_content=Coalesce(latest_version_content, F("content")))
|
||||||
.annotate(num_notes=Coalesce(note_count, 0))
|
.annotate(num_notes=Coalesce(note_count, 0))
|
||||||
.select_related("correspondent", "storage_path", "document_type", "owner")
|
.select_related("correspondent", "storage_path", "document_type", "owner")
|
||||||
.prefetch_related(
|
.prefetch_related(
|
||||||
@@ -1156,25 +1115,15 @@ class DocumentViewSet(
|
|||||||
"version_index",
|
"version_index",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
latest_version_content_prefetch(),
|
|
||||||
"tags",
|
"tags",
|
||||||
Prefetch(
|
Prefetch(
|
||||||
"custom_fields",
|
"custom_fields",
|
||||||
queryset=CustomFieldInstance.objects.select_related("field"),
|
queryset=CustomFieldInstance.objects.select_related("field"),
|
||||||
),
|
),
|
||||||
"notes",
|
# NotesSerializer nests the author, this avoids query per note
|
||||||
|
Prefetch("notes", queryset=Note.objects.select_related("user")),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if self._needs_effective_content_annotation():
|
|
||||||
latest_version_content = Subquery(
|
|
||||||
versions_newest_first(
|
|
||||||
Document.objects.filter(root_document=OuterRef("pk")),
|
|
||||||
).values("content")[:1],
|
|
||||||
)
|
|
||||||
queryset = queryset.annotate(
|
|
||||||
effective_content=Coalesce(latest_version_content, F("content")),
|
|
||||||
)
|
|
||||||
return queryset
|
|
||||||
|
|
||||||
def get_serializer(self, *args, **kwargs):
|
def get_serializer(self, *args, **kwargs):
|
||||||
fields_param = self.request.query_params.get("fields", None)
|
fields_param = self.request.query_params.get("fields", None)
|
||||||
@@ -1580,7 +1529,10 @@ class DocumentViewSet(
|
|||||||
if not ai_config.ai_enabled:
|
if not ai_config.ai_enabled:
|
||||||
return HttpResponseBadRequest("AI is required for this feature")
|
return HttpResponseBadRequest("AI is required for this feature")
|
||||||
|
|
||||||
output_language = _get_llm_output_language(ai_config=ai_config, request=request)
|
output_language = get_llm_output_language(
|
||||||
|
ai_config=ai_config,
|
||||||
|
user=request.user,
|
||||||
|
)
|
||||||
llm_cache_backend = ":".join(
|
llm_cache_backend = ":".join(
|
||||||
part
|
part
|
||||||
for part in (
|
for part in (
|
||||||
@@ -2369,7 +2321,10 @@ class ChatStreamingView(GenericAPIView[Any]):
|
|||||||
id__in=permitted_document_ids(request.user),
|
id__in=permitted_document_ids(request.user),
|
||||||
)
|
)
|
||||||
|
|
||||||
output_language = _get_llm_output_language(ai_config=ai_config, request=request)
|
output_language = get_llm_output_language(
|
||||||
|
ai_config=ai_config,
|
||||||
|
user=request.user,
|
||||||
|
)
|
||||||
|
|
||||||
response = StreamingHttpResponse(
|
response = StreamingHttpResponse(
|
||||||
stream_chat_with_documents(
|
stream_chat_with_documents(
|
||||||
@@ -2833,8 +2788,11 @@ class DocumentSelectionMixin:
|
|||||||
for key, value in filters.items()
|
for key, value in filters.items()
|
||||||
if key not in _TANTIVY_SEARCH_PARAM_NAMES
|
if key not in _TANTIVY_SEARCH_PARAM_NAMES
|
||||||
}
|
}
|
||||||
|
# Operations are addressed to roots, a caller that wants
|
||||||
|
# to act on a specific version passes its id explicitly instead
|
||||||
permitted_documents = Document.objects.filter(
|
permitted_documents = Document.objects.filter(
|
||||||
id__in=permitted_document_ids(user),
|
id__in=permitted_document_ids(user),
|
||||||
|
root_document__isnull=True,
|
||||||
)
|
)
|
||||||
# orm-filtered docs
|
# orm-filtered docs
|
||||||
filtered_documents = DocumentFilterSet(
|
filtered_documents = DocumentFilterSet(
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import date
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
|
from documents.models import Correspondent
|
||||||
|
from documents.models import Document
|
||||||
|
from documents.models import DocumentType
|
||||||
|
from documents.models import MatchingModel
|
||||||
|
from documents.models import StoragePath
|
||||||
|
from documents.models import Tag
|
||||||
|
from documents.models import WorkflowAction
|
||||||
|
from paperless.config import AIConfig
|
||||||
|
from paperless_ai.ai_classifier import get_ai_document_classification
|
||||||
|
from paperless_ai.ai_classifier import get_llm_output_language
|
||||||
|
from paperless_ai.matching import extract_unmatched_names
|
||||||
|
from paperless_ai.matching import match_correspondents_by_name
|
||||||
|
from paperless_ai.matching import match_document_types_by_name
|
||||||
|
from paperless_ai.matching import match_storage_paths_by_name
|
||||||
|
from paperless_ai.matching import match_tags_by_name
|
||||||
|
from paperless_ai.matching import resolve_correspondent_ids
|
||||||
|
from paperless_ai.matching import resolve_document_type_ids
|
||||||
|
from paperless_ai.matching import resolve_storage_path_ids
|
||||||
|
from paperless_ai.matching import resolve_tag_ids
|
||||||
|
|
||||||
|
logger = logging.getLogger("paperless.workflows.ai")
|
||||||
|
|
||||||
|
AISuggestionField = WorkflowAction.AISuggestionField
|
||||||
|
ObjT = TypeVar("ObjT", bound=MatchingModel)
|
||||||
|
|
||||||
|
# Tags use m2m relation instead
|
||||||
|
DIRECT_FIELDS: dict[str, str] = {
|
||||||
|
AISuggestionField.TITLE: "title",
|
||||||
|
AISuggestionField.CORRESPONDENT: "correspondent",
|
||||||
|
AISuggestionField.DOCUMENT_TYPE: "document_type",
|
||||||
|
AISuggestionField.STORAGE_PATH: "storage_path",
|
||||||
|
AISuggestionField.CREATED: "created",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_date(dates: list[str]) -> date | None:
|
||||||
|
"""
|
||||||
|
First usable date out of the suggestions, which are expected as
|
||||||
|
YYYY-MM-DD. Document.created is a DateField, so only one can be applied.
|
||||||
|
"""
|
||||||
|
for value in dates:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(value, "%Y-%m-%d").date()
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
logger.debug("Ignoring unparsable suggested date %s", value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_object(
|
||||||
|
model: type[ObjT],
|
||||||
|
names: list[str],
|
||||||
|
matched: list[ObjT],
|
||||||
|
*,
|
||||||
|
create_missing: bool,
|
||||||
|
owner: User | None,
|
||||||
|
) -> ObjT | None:
|
||||||
|
"""
|
||||||
|
Single object from a suggestion list. The best match if there was one, else
|
||||||
|
optionally a newly-created object. StoragePaths are excluded.
|
||||||
|
"""
|
||||||
|
if matched:
|
||||||
|
return matched[0]
|
||||||
|
|
||||||
|
if not create_missing or model is StoragePath:
|
||||||
|
return None
|
||||||
|
|
||||||
|
unmatched = extract_unmatched_names(names, matched)
|
||||||
|
if not unmatched:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# (name, owner) is what MatchingModel is unique on
|
||||||
|
obj, created = model.objects.get_or_create(
|
||||||
|
name=unmatched[0][:128],
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
logger.info("Created %s '%s' from AI suggestion", model.__name__, obj.name)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_tags(
|
||||||
|
names: list[str],
|
||||||
|
matched: list[Tag],
|
||||||
|
*,
|
||||||
|
create_missing: bool,
|
||||||
|
owner: User | None,
|
||||||
|
) -> list[Tag]:
|
||||||
|
"""
|
||||||
|
Matched tags, plus newly created ones if create_missing is set.
|
||||||
|
"""
|
||||||
|
tags = list(matched)
|
||||||
|
if not create_missing:
|
||||||
|
return tags
|
||||||
|
|
||||||
|
for name in extract_unmatched_names(names, matched):
|
||||||
|
tag, created = Tag.objects.get_or_create(
|
||||||
|
name=name[:128],
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
logger.info("Created tag '%s' from AI suggestion", tag.name)
|
||||||
|
tags.append(tag)
|
||||||
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
def apply_ai_suggestions_to_document(
|
||||||
|
action: WorkflowAction,
|
||||||
|
document: Document,
|
||||||
|
logging_group=None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
Get suggestions about `document` and write the chosen fields.
|
||||||
|
|
||||||
|
Returns the names of the fields that were actually changed.
|
||||||
|
"""
|
||||||
|
selected = set(action.ai_suggestion_fields or [])
|
||||||
|
if not selected:
|
||||||
|
logger.warning(
|
||||||
|
"Workflow action %s has no AI suggestion fields selected, skipping",
|
||||||
|
action.pk,
|
||||||
|
extra={"group": logging_group},
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
ai_config = AIConfig()
|
||||||
|
if not ai_config.ai_enabled:
|
||||||
|
logger.error(
|
||||||
|
"AI is not enabled, cannot apply AI suggestions for document %s",
|
||||||
|
document.pk,
|
||||||
|
extra={"group": logging_group},
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Workflows run without a user, so we use the document owner
|
||||||
|
owner = document.owner
|
||||||
|
|
||||||
|
try:
|
||||||
|
suggestions = get_ai_document_classification(
|
||||||
|
document,
|
||||||
|
owner,
|
||||||
|
get_llm_output_language(ai_config, owner),
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
# A bad AI config will not fix itself, so swallow it rather than
|
||||||
|
# letting the caller retry. Timeouts, rate limits, network errors etc
|
||||||
|
# propagate so the queued task can back off and try again.
|
||||||
|
logger.exception(
|
||||||
|
"Invalid AI configuration, cannot get suggestions for document %s",
|
||||||
|
document.pk,
|
||||||
|
extra={"group": logging_group},
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
overwrite = action.ai_overwrite_existing
|
||||||
|
create_missing = action.ai_create_missing
|
||||||
|
updated_fields: list[str] = []
|
||||||
|
|
||||||
|
def should_set(field: str) -> bool:
|
||||||
|
# The field is selected and (overwrite or it's empty)
|
||||||
|
return field in selected and (
|
||||||
|
overwrite or getattr(document, DIRECT_FIELDS[field]) in (None, "")
|
||||||
|
)
|
||||||
|
|
||||||
|
if should_set(AISuggestionField.TITLE):
|
||||||
|
title = suggestions["title"].strip()
|
||||||
|
if title:
|
||||||
|
# title is capped at 128 characters
|
||||||
|
document.title = title[:128]
|
||||||
|
updated_fields.append("title")
|
||||||
|
|
||||||
|
if should_set(AISuggestionField.CORRESPONDENT):
|
||||||
|
choice = suggestions["correspondents"]
|
||||||
|
names = choice["new_names"]
|
||||||
|
correspondent = resolve_object(
|
||||||
|
Correspondent,
|
||||||
|
names,
|
||||||
|
resolve_correspondent_ids(choice["existing_ids"], owner)
|
||||||
|
+ match_correspondents_by_name(names, owner),
|
||||||
|
create_missing=create_missing,
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
|
if correspondent:
|
||||||
|
document.correspondent = correspondent
|
||||||
|
updated_fields.append("correspondent")
|
||||||
|
|
||||||
|
if should_set(AISuggestionField.DOCUMENT_TYPE):
|
||||||
|
choice = suggestions["document_types"]
|
||||||
|
names = choice["new_names"]
|
||||||
|
document_type = resolve_object(
|
||||||
|
DocumentType,
|
||||||
|
names,
|
||||||
|
resolve_document_type_ids(choice["existing_ids"], owner)
|
||||||
|
+ match_document_types_by_name(names, owner),
|
||||||
|
create_missing=create_missing,
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
|
if document_type:
|
||||||
|
document.document_type = document_type
|
||||||
|
updated_fields.append("document_type")
|
||||||
|
|
||||||
|
if should_set(AISuggestionField.STORAGE_PATH):
|
||||||
|
choice = suggestions["storage_paths"]
|
||||||
|
names = choice["new_names"]
|
||||||
|
storage_path = resolve_object(
|
||||||
|
StoragePath,
|
||||||
|
names,
|
||||||
|
resolve_storage_path_ids(choice["existing_ids"], owner)
|
||||||
|
+ match_storage_paths_by_name(names, owner),
|
||||||
|
create_missing=create_missing,
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
|
if storage_path:
|
||||||
|
document.storage_path = storage_path
|
||||||
|
updated_fields.append("storage_path")
|
||||||
|
|
||||||
|
if should_set(AISuggestionField.CREATED):
|
||||||
|
created = resolve_date(suggestions["dates"])
|
||||||
|
if created:
|
||||||
|
document.created = created
|
||||||
|
updated_fields.append("created")
|
||||||
|
|
||||||
|
if AISuggestionField.TAGS in selected:
|
||||||
|
choice = suggestions["tags"]
|
||||||
|
names = choice["new_names"]
|
||||||
|
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:
|
||||||
|
# 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:
|
||||||
|
# save fields and update modified (excluding m2m tags from update_fields)
|
||||||
|
direct_updated_fields = [
|
||||||
|
field for field in updated_fields if field in DIRECT_FIELDS.values()
|
||||||
|
]
|
||||||
|
document.save(update_fields=[*direct_updated_fields, "modified"])
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Applied AI suggestions %s to document %s",
|
||||||
|
updated_fields or "(none)",
|
||||||
|
document.pk,
|
||||||
|
extra={"group": logging_group},
|
||||||
|
)
|
||||||
|
|
||||||
|
return updated_fields
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -67,6 +67,10 @@ def handle_social_account_updated(sender, request, sociallogin, **kwargs):
|
|||||||
)
|
)
|
||||||
or []
|
or []
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if isinstance(social_account_groups, str):
|
||||||
|
social_account_groups = [social_account_groups]
|
||||||
|
|
||||||
if settings.SOCIAL_ACCOUNT_SYNC_GROUPS and social_account_groups is not None:
|
if settings.SOCIAL_ACCOUNT_SYNC_GROUPS and social_account_groups is not None:
|
||||||
groups = Group.objects.filter(name__in=social_account_groups)
|
groups = Group.objects.filter(name__in=social_account_groups)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|||||||
@@ -295,6 +295,43 @@ class TestSyncSocialLoginGroups(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(list(user.groups.all()), [group])
|
self.assertEqual(list(user.groups.all()), [group])
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP="admin",
|
||||||
|
SOCIAL_ACCOUNT_SYNC_STAFF_GROUP="admin",
|
||||||
|
)
|
||||||
|
def test_sync_superuser_claim_no_substring_match(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Configured superuser group sync
|
||||||
|
- Provider emits the groups claim as a bare string, and the user's
|
||||||
|
only group merely *contains* the configured name
|
||||||
|
WHEN:
|
||||||
|
- Social login updated via signal
|
||||||
|
THEN:
|
||||||
|
- User is not promoted, since only an exact group match counts
|
||||||
|
"""
|
||||||
|
user = User.objects.create_user(
|
||||||
|
username="testuser",
|
||||||
|
is_superuser=False,
|
||||||
|
is_staff=False,
|
||||||
|
)
|
||||||
|
sociallogin = Mock(
|
||||||
|
user=user,
|
||||||
|
account=Mock(
|
||||||
|
extra_data={
|
||||||
|
"groups": "paperless-admins-readonly",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
handle_social_account_updated(
|
||||||
|
sender=None,
|
||||||
|
request=HttpRequest(),
|
||||||
|
sociallogin=sociallogin,
|
||||||
|
)
|
||||||
|
user.refresh_from_db()
|
||||||
|
self.assertFalse(user.is_superuser)
|
||||||
|
self.assertFalse(user.is_staff)
|
||||||
|
|
||||||
@override_settings(
|
@override_settings(
|
||||||
SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP="admin-group",
|
SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP="admin-group",
|
||||||
SOCIAL_ACCOUNT_SYNC_STAFF_GROUP=None,
|
SOCIAL_ACCOUNT_SYNC_STAFF_GROUP=None,
|
||||||
|
|||||||
@@ -47,6 +47,22 @@ def get_language_name(language_code: str) -> str:
|
|||||||
return language_code
|
return language_code
|
||||||
|
|
||||||
|
|
||||||
|
def get_llm_output_language(ai_config: AIConfig, user: User | None) -> str | None:
|
||||||
|
"""
|
||||||
|
Language to localize LLM output into: the configured language, falling back
|
||||||
|
to the user's own UI language when unset.
|
||||||
|
"""
|
||||||
|
output_language = ai_config.llm_output_language
|
||||||
|
if (
|
||||||
|
not output_language
|
||||||
|
and user is not None
|
||||||
|
and hasattr(user, "ui_settings")
|
||||||
|
and isinstance(user.ui_settings.settings, dict)
|
||||||
|
):
|
||||||
|
output_language = user.ui_settings.settings.get("language")
|
||||||
|
return output_language
|
||||||
|
|
||||||
|
|
||||||
def build_prompt_without_rag(
|
def build_prompt_without_rag(
|
||||||
document: Document,
|
document: Document,
|
||||||
config: AIConfig,
|
config: AIConfig,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from documents.models import Correspondent
|
|||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.permissions import get_objects_for_user_owner_aware
|
from documents.permissions import permitted_object_ids
|
||||||
from documents.permissions import restrict_queryset_to_visible
|
from documents.permissions import restrict_queryset_to_visible
|
||||||
|
|
||||||
MATCH_THRESHOLD = 0.8
|
MATCH_THRESHOLD = 0.8
|
||||||
@@ -63,30 +63,40 @@ def resolve_storage_path_ids(ids: list[int], user: User | None) -> list[StorageP
|
|||||||
|
|
||||||
def _match_by_name(
|
def _match_by_name(
|
||||||
names: list[str],
|
names: list[str],
|
||||||
user: User,
|
user: User | None,
|
||||||
model: type[ModelT],
|
model: type[ModelT],
|
||||||
perm: str,
|
perm: str,
|
||||||
) -> list[ModelT]:
|
) -> list[ModelT]:
|
||||||
queryset = get_objects_for_user_owner_aware(user, [perm], model)
|
# A workflow may have no user. In that case permitted_object_ids limits
|
||||||
|
# matching to unowned objects, avoiding another user's private taxonomy.
|
||||||
|
queryset = model.objects.filter(
|
||||||
|
pk__in=permitted_object_ids(user, model, perm),
|
||||||
|
)
|
||||||
return _match_names_to_queryset(names, queryset)
|
return _match_names_to_queryset(names, queryset)
|
||||||
|
|
||||||
|
|
||||||
def match_tags_by_name(names: list[str], user: User) -> list[Tag]:
|
def match_tags_by_name(names: list[str], user: User | None) -> list[Tag]:
|
||||||
return _match_by_name(names, user, Tag, "view_tag")
|
return _match_by_name(names, user, Tag, "view_tag")
|
||||||
|
|
||||||
|
|
||||||
def match_correspondents_by_name(
|
def match_correspondents_by_name(
|
||||||
names: list[str],
|
names: list[str],
|
||||||
user: User,
|
user: User | None,
|
||||||
) -> list[Correspondent]:
|
) -> list[Correspondent]:
|
||||||
return _match_by_name(names, user, Correspondent, "view_correspondent")
|
return _match_by_name(names, user, Correspondent, "view_correspondent")
|
||||||
|
|
||||||
|
|
||||||
def match_document_types_by_name(names: list[str], user: User) -> list[DocumentType]:
|
def match_document_types_by_name(
|
||||||
|
names: list[str],
|
||||||
|
user: User | None,
|
||||||
|
) -> list[DocumentType]:
|
||||||
return _match_by_name(names, user, DocumentType, "view_documenttype")
|
return _match_by_name(names, user, DocumentType, "view_documenttype")
|
||||||
|
|
||||||
|
|
||||||
def match_storage_paths_by_name(names: list[str], user: User) -> list[StoragePath]:
|
def match_storage_paths_by_name(
|
||||||
|
names: list[str],
|
||||||
|
user: User | None,
|
||||||
|
) -> list[StoragePath]:
|
||||||
return _match_by_name(names, user, StoragePath, "view_storagepath")
|
return _match_by_name(names, user, StoragePath, "view_storagepath")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_mock
|
import pytest_mock
|
||||||
@@ -45,33 +44,25 @@ class TestAIMatching(TestCase):
|
|||||||
self.storage_path1 = StoragePath.objects.create(name="Test Storage Path 1")
|
self.storage_path1 = StoragePath.objects.create(name="Test Storage Path 1")
|
||||||
self.storage_path2 = StoragePath.objects.create(name="Test Storage Path 2")
|
self.storage_path2 = StoragePath.objects.create(name="Test Storage Path 2")
|
||||||
|
|
||||||
@patch("paperless_ai.matching.get_objects_for_user_owner_aware")
|
def test_match_tags_by_name(self) -> None:
|
||||||
def test_match_tags_by_name(self, mock_get_objects) -> None:
|
|
||||||
mock_get_objects.return_value = Tag.objects.all()
|
|
||||||
names = ["Test Tag 1", "Nonexistent Tag"]
|
names = ["Test Tag 1", "Nonexistent Tag"]
|
||||||
result = match_tags_by_name(names, user=None)
|
result = match_tags_by_name(names, user=None)
|
||||||
self.assertEqual(len(result), 1)
|
self.assertEqual(len(result), 1)
|
||||||
self.assertEqual(result[0].name, "Test Tag 1")
|
self.assertEqual(result[0].name, "Test Tag 1")
|
||||||
|
|
||||||
@patch("paperless_ai.matching.get_objects_for_user_owner_aware")
|
def test_match_correspondents_by_name(self) -> None:
|
||||||
def test_match_correspondents_by_name(self, mock_get_objects) -> None:
|
|
||||||
mock_get_objects.return_value = Correspondent.objects.all()
|
|
||||||
names = ["Test Correspondent 1", "Nonexistent Correspondent"]
|
names = ["Test Correspondent 1", "Nonexistent Correspondent"]
|
||||||
result = match_correspondents_by_name(names, user=None)
|
result = match_correspondents_by_name(names, user=None)
|
||||||
self.assertEqual(len(result), 1)
|
self.assertEqual(len(result), 1)
|
||||||
self.assertEqual(result[0].name, "Test Correspondent 1")
|
self.assertEqual(result[0].name, "Test Correspondent 1")
|
||||||
|
|
||||||
@patch("paperless_ai.matching.get_objects_for_user_owner_aware")
|
def test_match_document_types_by_name(self) -> None:
|
||||||
def test_match_document_types_by_name(self, mock_get_objects) -> None:
|
|
||||||
mock_get_objects.return_value = DocumentType.objects.all()
|
|
||||||
names = ["Test Document Type 1", "Nonexistent Document Type"]
|
names = ["Test Document Type 1", "Nonexistent Document Type"]
|
||||||
result = match_document_types_by_name(names, user=None)
|
result = match_document_types_by_name(names, user=None)
|
||||||
self.assertEqual(len(result), 1)
|
self.assertEqual(len(result), 1)
|
||||||
self.assertEqual(result[0].name, "Test Document Type 1")
|
self.assertEqual(result[0].name, "Test Document Type 1")
|
||||||
|
|
||||||
@patch("paperless_ai.matching.get_objects_for_user_owner_aware")
|
def test_match_storage_paths_by_name(self) -> None:
|
||||||
def test_match_storage_paths_by_name(self, mock_get_objects) -> None:
|
|
||||||
mock_get_objects.return_value = StoragePath.objects.all()
|
|
||||||
names = ["Test Storage Path 1", "Nonexistent Storage Path"]
|
names = ["Test Storage Path 1", "Nonexistent Storage Path"]
|
||||||
result = match_storage_paths_by_name(names, user=None)
|
result = match_storage_paths_by_name(names, user=None)
|
||||||
self.assertEqual(len(result), 1)
|
self.assertEqual(len(result), 1)
|
||||||
@@ -83,16 +74,12 @@ class TestAIMatching(TestCase):
|
|||||||
unmatched_names = extract_unmatched_names(llm_names, matched_objects)
|
unmatched_names = extract_unmatched_names(llm_names, matched_objects)
|
||||||
self.assertEqual(unmatched_names, ["Nonexistent Tag"])
|
self.assertEqual(unmatched_names, ["Nonexistent Tag"])
|
||||||
|
|
||||||
@patch("paperless_ai.matching.get_objects_for_user_owner_aware")
|
def test_match_tags_by_name_with_empty_names(self) -> None:
|
||||||
def test_match_tags_by_name_with_empty_names(self, mock_get_objects) -> None:
|
|
||||||
mock_get_objects.return_value = Tag.objects.all()
|
|
||||||
names = [None, "", " "]
|
names = [None, "", " "]
|
||||||
result = match_tags_by_name(names, user=None)
|
result = match_tags_by_name(names, user=None)
|
||||||
self.assertEqual(result, [])
|
self.assertEqual(result, [])
|
||||||
|
|
||||||
@patch("paperless_ai.matching.get_objects_for_user_owner_aware")
|
def test_match_tags_with_fuzzy_matching(self) -> None:
|
||||||
def test_match_tags_with_fuzzy_matching(self, mock_get_objects) -> None:
|
|
||||||
mock_get_objects.return_value = Tag.objects.all()
|
|
||||||
names = ["Test Taag 1", "Teest Tag 2"]
|
names = ["Test Taag 1", "Teest Tag 2"]
|
||||||
result = match_tags_by_name(names, user=None)
|
result = match_tags_by_name(names, user=None)
|
||||||
self.assertEqual(len(result), 2)
|
self.assertEqual(len(result), 2)
|
||||||
|
|||||||
Reference in New Issue
Block a user